MNN 核心类介绍
MNN 框架作为一个高性能的深度学习推理引擎,核心设计围绕着几个关键的抽象类展开。其中,VARP、Expr、Op 等类是整个框架的基石,它们不仅定义了数据的表示方式,还构建了计算图的基本结构。本文按当前仓库代码,直接看这些核心类的职责、文件位置和相互关系。
1. MNN核心类
MNN 中的核心类主要包括:
| 类名 |
职责 |
文件位置 |
VARP |
智能指针包装的 Variable,表示表达式中的变量节点 |
include/MNN/expr/Expr.hpp |
Variable |
表达式图中的变量节点,持有张量数据或计算信息 |
include/MNN/expr/Expr.hpp |
Expr |
表达式边,表示一个计算操作以及输入节点和输出节点 |
include/MNN/expr/Expr.hpp、express/Expr.cpp |
Tensor |
张量数据容器,存储实际的多维数据 |
include/MNN/Tensor.hpp |
Op |
算子描述符,定义计算的类型和参数、模型权重等 |
schema/current/MNN_generated.h |
Runtime |
运行时抽象层,负责创建具体 Backend 并管理线程、内存与运行时资源 |
source/core/Backend.hpp |
Backend |
执行层后端抽象基类,负责创建 Execution 并管理张量内存与设备资源 |
source/core/Backend.hpp |
Execution |
单个算子的执行抽象基类,负责 resize 和 execute 两阶段计算 |
source/core/Execution.hpp |
Pipeline |
一条执行流水线,负责 encode、allocMemory 和 execute |
source/core/Pipeline.hpp |
Session |
一次推理会话,持有 Runtime、Pipeline 和输入输出 Tensor |
source/core/Session.hpp、source/core/Session.cpp |
Interpreter |
模型数据持有者,也是创建 Session 的入口 |
include/MNN/Interpreter.hpp |
Executor |
Express 层的执行协调器,负责 shape 推导、缓存和临时 Session |
include/MNN/expr/Executor.hpp、express/Executor.cpp |
ExecutorScope |
Executor 的线程局部作用域,用来切换当前执行上下文 |
include/MNN/expr/ExecutorScope.hpp、express/ExecutorScope.cpp |
其中 Runtime、Backend、Execution 这三个类属于执行层的核心抽象。这里只先给出职责总览,具体的分层关系、调用顺序和关键接口可结合后端介绍的第 2 节一起看。
1.1 类之间关系
1.1.1 逻辑依赖关系
VARP 和 Expr 是 MNN 计算图格式的核心,分别表示计算图的节点和边,Op 类负责描述 Expr 对应的计算操作。关系图如下:
1
2
3
4
5
6
7
|
VARP (Variable Ptr)
↓ 指向
Variable
↓ 包含
Expr ───────────────→ Op (操作描述)
↓包含输出张量
Tensor (存储数据)
|
其中:
- 每个
Expr 边都有 std::vector<VARP> mInputs; 记录输入节点,const Op* mOp; 代表当前边的计算操作;
- 同时
Expr 还持有一个 std::shared_ptr<Inside> mInside;,其中保存了当前边的输出 Tensor、shape 信息等;
- 每个
VARP 节点内部持有一个 std::shared_ptr<Variable>;
Variable 继续朝输入方向指向 std::shared_ptr<Expr> mFrom 输入边,从而一步一步构成计算图;
- 叶节点就是用
VARP 指向输入边 Expr,这个输入边没有 mOp 和 mInputs,只持有一个 Tensor 数据。
一个简单的计算图VARP z = _Add(x, y)可以表示如下:
1
2
3
4
5
6
7
8
9
10
11
12
|
Expr (mFrom) -> Tensor x(data) Expr (mFrom) -> Tensor y(data)
│ │
│ │
VARP x VARP y
↓ ↓
└──────────────┬──────────────────────┘
│
▼
Tensor z ←────── Expr ───────→ Op(Add)
│
↓
VARP z
|
1.1.2 执行依赖关系
MNN 的计算图有两种模式,Defer(延迟计算)模式或 Eager(立即计算)模式:Defer 模式下,调用表达式相关 API 不会直接计算,而是先搭建模型,在需要获取输出值时才执行(如 Variable::readMap() 或 Variable::writeMap() 接口);Eager 模式下会直接进行计算,对应地无法搭建模型。
下面以 Eager 模式为例,梳理 MNN 中表达式计算顺序,更具体的代码分析见readmap详解:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
用户定义 `VARP` 计算代码 // 如:`VARP x = _Input({2, 3}); VARP y = _Input({2, 3}); VARP z = _Add(x, y);`
↓
`varp->readMap()` // 触发延迟计算接口
↓
//计算节点信息
`ExecutorScope::Current()->computeInfo()` ─→ `SizeComputer::computeOutputSize()` // 动态计算中间节点/输出的形状
↓
`ExecutorScope::Current()->makeCache()` // 计算缓存,可复用,按数据依赖顺序准备中间节点的 `Tensor` 以及 `Session` 会话的计算信息
↓
`Executor::ComputeCache::compute()` // 按数据依赖顺序执行计算缓存中的 `resize` 和执行,算子执行会从计算缓存的 `mSession` 进入下一步
↓
`Session::run()` // 这里继续进入 `Session::mPipelines` 的执行
↓
`Pipeline::execute()` // 这里执行 `execution->onExecute` 进入算子的后端执行
↓
`Execution::onExecute()` // 按计算图依次执行各算子的后端计算,计算结果存在 `Tensor` 中,最后从 `readMap` 读取为指针
↓
获得数据指针
|
这当中主要配置的信息是在 ExecutorScope::Current()->makeCache() 中写入的 Session,后续执行都依赖这份缓存。
1.2 VARP类
VARP 本质是 Variable 的智能指针包装类。当前实现位于 include/MNN/expr/Expr.hpp,比较运算符直接比较内部指针。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
// include/MNN/expr/Expr.hpp
class MNN_PUBLIC VARP {
public:
// 绑定 / 解绑底层 Variable
VARP();
VARP(std::shared_ptr<Variable> c);
VARP(Variable* c);
// 直接取内部的 Variable 指针
Variable* get() const;
enum InputType {
INPUT = 0,
CONSTANT = 1,
TRAINABLE = 2,
};
// 标记当前 VARP 的输入类型和维度格式
bool fix(InputType type) const;
void setOrder(Dimensionformat format);
private:
std::shared_ptr<Variable> mContent;
};
|
1.2.1 Variable 类
Variable 描述算子图里的一个变量节点,既提供图结构接口,也提供数据访问接口,部分核心代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
// include/MNN/expr/Expr.hpp
class MNN_PUBLIC Variable {
public:
// 节点命名,便于调试和导出
const std::string& name() const;
void setName(const std::string& name);
struct Info {
Dimensionformat order = NHWC;
INTS dim;
halide_type_t type;
size_t size;
void syncSize();
};
// 读取当前节点的 shape / dtype / format 信息
const Info* getInfo();
// 建立 Variable 到 Expr 的反向引用
void setExpr(EXPRP expr, int index);
std::pair<EXPRP, int> expr() const;
// 返回当前节点关联的 Tensor
const Tensor* getTensor() const;
// 仅调整逻辑 shape,不直接触发算子执行
bool resize(INTS dims);
// 序列化 / 反序列化相关入口
static std::vector<VARP> load(const char* fileName);
static void save(const std::vector<VARP>& vars, NetT* dest);
// 直接把底层 Tensor 映射成可读 / 可写指针
template <typename T>
const T* readMap();
template <typename T>
T* writeMap();
private:
// 核心类属性 表示入边
EXPRP mFrom;
int mFromIndex;
};
|
1.2.2 readMap详解
Variable::readMap<T>() 只是模板包装,真正逻辑在 express/Expr.cpp 的 Variable::readInternal()。这条调用链基本就是 Express 图执行的主入口之一,顺着它往下看,能把 shape 推导、缓存构建、Session 执行和结果回传这几步串联,最后会返回数据指针用于数据读取。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// express/Expr.cpp
void* Variable::readInternal(bool forShape) {
if (nullptr == mFrom->get()) {
// 输入 / 常量 / 可训练参数,直接走已有 Tensor
return mFrom->inside()->mOutputTensors[0]->buffer().host;
}
// 普通 Expr,先补齐 shape 信息
if (!mFrom->requireInfo()) {
return nullptr;
}
// 没有 cache 时,先把当前子图收敛成一个临时 Session
auto cache = mFrom->inside()->mCache;
if (nullptr == cache) {
ExecutorScope::Current()->makeCache({mFrom}, forShape);
cache = mFrom->inside()->mCache;
}
if (nullptr == cache || NO_ERROR != cache->compute()) {
return nullptr;
}
return cache->mapOutput(mFrom->mInside->mCacheOffset + mFromIndex,
mFrom->mInside->mOutputTensors[mFromIndex]);
}
|
这里的 mFrom->get() 实际对应当前 Expr 持有的 Op。如果它是空,说明当前 Variable 对应的不是普通计算算子,而是输入、常量或者可训练参数这类叶子节点。这种情况下不需要再走调度和执行流程,直接返回已有 Tensor 的 host 指针。(实际代码还有额外处理:如果底层数据在其它设备后端上,或带量化属性,会先构造一个 host Tensor把数据同步回 CPU)
1.2.2.1 shape 推导
接下来普通算子节点会先进入:Expr::requireInfo(),确保当前节点以及依赖输入的 shape / dtype / format 都已经准备好。对应代码在 express/Expr.cpp:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
bool Expr::requireInfo() {
if (!mInside->mInfoDirty) {
return true;
}
if (nullptr == mOp) {
return !HasUnknownDim(mInside->mOutputInfos[0].dim);
}
// 递归补齐所有的输入链节点信息
for (int i = 0; i < mInputs.size(); ++i) {
auto inputInfo = mInputs[i]->getInfo();
if (nullptr == inputInfo) {
mValid = false;
return false;
}
}
for (int i = 0; i < mInputs.size(); ++i) {
if (mInside->mReq.shapeNeedContent[i]) {
auto ptr = mInputs[i]->readInternal(true);
if (nullptr == ptr) {
return false;
}
}
}
auto res = ExecutorScope::Current()->computeInfo(this);
if (NO_ERROR == res) {
mInside->mInfoDirty = false;
}
return NO_ERROR == res;
}
|
这里有两个重点:
requireInfo() 会递归调用输入节点的 getInfo(),因此会沿着当前 Expr 一直向前,把依赖链上的 shape 信息逐层补齐;
- 如果某些算子的 shape 推导依赖输入内容而不只是维度,比如部分
shape 相关算子,那么它会通过 readInternal(true) 先把输入内容读出来。
接下来真正做执行shape 推导的是 Executor::computeInfo():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// express/Executor.cpp
ErrorCode Executor::computeInfo(Expr* expr) {
auto op = expr->get();
std::vector<Tensor*> inputTensors(expr->inputs().size());
for (int i = 0; i < inputTensors.size(); ++i) {
auto inputExpr = expr->inputs()[i]->expr();
inputTensors[i] = inputExpr.first->inside()->mOutputTensors[inputExpr.second];
}
bool res = SizeComputer::computeOutputSize(op, inputTensors, expr->inside()->mOutputTensors);
if (!res) {
return COMPUTE_SIZE_ERROR;
}
for (int i = 0; i < expr->outputSize(); ++i) {
auto tensor = expr->inside()->mOutputTensors[i];
TensorUtils::setLinearLayout(tensor);
auto shape = expr->outputInfo(i);
Utils::copyTensorToInfo(shape, tensor);
}
return NO_ERROR;
}
|
这一层最终会进入 SizeComputer::computeOutputSize(),也就是前面讲过的 shape 工厂系统,各个算子会根据输入信息推导输出维度。执行完成后,当前 Expr 的输出 Tensor 和 Variable::Info 会同步更新,后面才能继续构建执行缓存。
1.2.2.2 把子图收敛成 ComputeCache
shape 信息准备好之后,Variable::readInternal() 函数会继续检查当前节点有没有现成的执行缓存:
1
2
3
4
5
6
|
// express/Expr.cpp
auto cache = mFrom->inside()->mCache;
if (nullptr == cache) {
ExecutorScope::Current()->makeCache({mFrom}, forShape);
cache = mFrom->inside()->mCache;
}
|
然后调用 makeCache() 把未缓存算子的整段子图收敛成一个临时 Session类,对应 express/Executor.cpp 里的 Executor::_makeCache()函数。这个过程主要做四件事:
- 从目标
Expr 开始反向 DFS,找到所有真正参与本次输出计算的依赖节点;
- 按依赖顺序把每个
Expr 改写成 Schedule::OpCacheInfo,整理出输入输出 Tensor;
- 生成
Schedule::ScheduleInfo,并给目标输出记录 mCacheOffset;
- 用这份
ScheduleInfo 构造一个临时 Session,保存到 ComputeCache::mSession。
核心代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// express/Executor.cpp
void Executor::_makeCache(const std::vector<EXPRP>& expr, bool forceCPU) {
Schedule::ScheduleInfo scheduleInfo;
scheduleInfo.pipelineInfo.resize(1);
auto& pipeline = scheduleInfo.pipelineInfo[0].second;
// 反向遍历 Expr 子图,构造 pipeline 里的 OpCacheInfo
while (!dfsStack.empty()) {
// ...
Schedule::OpCacheInfo opInfo;
opInfo.op = expr->get();
opInfo.inputs.resize(inputs.size());
opInfo.outputs.resize(expr->outputSize());
// ...
pipeline.emplace_back(std::move(opInfo));
}
std::shared_ptr<ComputeCache> cahce(new ComputeCache);
for (auto& iter : dstExpr) {
iter.first->inside()->mCacheOffset = iter.second;
iter.first->inside()->mCache = cahce;
}
cahce->mSession.reset(new Session(std::move(scheduleInfo), group, std::move(rt)));
}
|
最后这里的 ComputeCache 是“某一段 Expr 子图对应的可执行快照”,里面最重要的成员就是 mSession,后面将用于计算。
1.2.2.3 compute()时 触发 resize 和 run
缓存建好以后,Variable::readInternal() 会调用cache->compute()在 express/Utils.cpp 里,核心逻辑如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
ErrorCode Executor::ComputeCache::compute() {
while (!dfsStack.empty()) {
auto cache = dfsStack.top();
if (cache->mShapeDirty) {
auto code = cache->resize();
if (NO_ERROR != code) {
return code;
}
}
if (!cache->mContentDirty) {
// 已有可复用结果,跳过执行
continue;
}
// 输入依赖都满足后,真正执行 Session
code = cache->mSession->run();
if (NO_ERROR != code) {
return code;
}
cache->mContentDirty = false;
}
return NO_ERROR;
}
|
这里可以看到两层状态:
mShapeDirty 控制是否需要重新 resize;
mContentDirty 控制是否需要真正重新执行算子。
其中 resize() 最终会走到:
1
2
3
4
5
6
7
|
ErrorCode Executor::ComputeCache::resizeImpl() {
mShapeDirty = false;
mSession->setNeedResize();
mSession->resize();
mContentDirty = true;
return NO_ERROR;
}
|
也就是先把临时 Session 标记为需要重新调整,再执行 Session::resize()。这一层会继续进入 Pipeline,为每个算子创建 Execution、分配内存并调用 Execution::onResize()。
而真正执行计算时,ComputeCache::compute() 会调用 Session::run():
1
2
3
4
5
6
7
8
9
10
|
// source/core/Session.cpp
ErrorCode Session::run() const {
for (auto& iter : mPipelines) {
auto error = iter->execute();
if (NO_ERROR != error) {
return error;
}
}
return NO_ERROR;
}
|
继续往下就是 Pipeline::execute(),再到每个算子的 Execution::onExecute()。也就是说,readMap() 表面上是“读一个变量”,底下实际上已经把整段子图执行完了。
1.2.2.4 映射结果
执行完成后,Variable::readInternal() 最后一步是cache->mapOutput(),通过mCacheOffset + mFromIndex 用来定位当前输出在临时 Session 里的目标 Tensor(前面1.2.2.2时记录的位置)。mapOutput() 对应代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
|
// express/Utils.cpp
void* Executor::ComputeCache::mapOutput(int offset, Tensor* dest) {
auto tensor = mSession->getTensor(offset);
if (0 == tensor->deviceId() && TensorUtils::getDescribe(tensor)->quantAttr.get() == nullptr) {
auto ptr = tensor->host<void>();
dest->buffer().host = (uint8_t*)ptr;
return ptr;
}
Utils::allocMemoryForHostTensor(dest);
tensor->copyToHostTensor(dest);
return dest->host<void>();
}
|
这里分成两种情况:
- 如果结果本身就在 host 内存上,直接返回底层指针;
- 如果结果在其它后端设备上,例如 GPU,或者需要量化相关处理,就先复制回 host
Tensor,再返回 host 指针。
1.2.2.5 整体流程
把上面的步骤合起来,readMap() 的完整调用路径可以按函数栈层次看。主路径从 Variable::readInternal() 开始,里面分出四段:先补 shape,再构建 ComputeCache,然后通过临时 Session 执行,最后把输出 Tensor 映射成 host 指针。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
|
Variable::readMap<T>()
# 模板接口,只负责把 readInternal() 返回的 void* 转成 T*
└── Variable::readInternal(forShape = false)
# readMap 的真正入口;forShape=false 表示这次是为了读取最终数据
├── [叶子节点] mFrom->get() == nullptr
│ # 没有关联 Op,说明是 Input / Const / Trainable 这类已持有 Tensor 的节点
│ └── 直接返回 mOutputTensors[0]->buffer().host
│
├── Expr::requireInfo()
│ # 普通算子节点先保证输出 shape / dtype / format 已经推导完成
│ ├── 递归补齐输入 shape
│ │ # 当前 Expr 的 shape 依赖所有输入 Variable 的 Info
│ │ └── input VARP -> Variable::getInfo()
│ │ └── input Expr -> Expr::requireInfo()
│ ├── [shape 依赖输入内容]
│ │ # 部分 shape 算子不只依赖维度,还要先读输入 Tensor 的真实内容
│ │ └── input VARP -> Variable::readInternal(forShape = true)
│ └── ExecutorScope::Current()->computeInfo(this)
│ └── Executor::computeInfo(expr)
│ └── SizeComputer::computeOutputSize(op, inputs, outputs)
│ # 进入算子 shape 推导注册表,写回 Expr 输出 Tensor 的形状信息
│
├── [当前 Expr 没有 cache] ExecutorScope::Current()->makeCache({mFrom}, forShape)
│ # 第一次真正求值时才会把 Expr 子图收敛成可执行缓存
│ └── Executor::makeCache(outputs, forceCPU)
│ └── Executor::_makeCache(outputs, forceCPU)
│ ├── 从目标 Expr 反向 DFS 收集依赖
│ │ # 只收集本次输出真正依赖的 Expr,已有 cache 的输入会作为边界
│ ├── 按依赖顺序生成 Schedule::OpCacheInfo
│ │ # 每个 OpCacheInfo 记录一个 Op、它的输入 Tensor 和输出 Tensor
│ ├── 填充 Schedule::ScheduleInfo::pipelineInfo
│ │ # pipelineInfo 后续会被 Session / Pipeline 用来创建 command
│ ├── 记录目标 Expr 的 mCacheOffset / mCache
│ │ # mCacheOffset 用于 mapOutput() 时找到目标输出 Tensor
│ └── new Session(scheduleInfo, group, runtime)
│ └── 保存到 ComputeCache::mSession
│ # ComputeCache 持有临时 Session,后续 resize/run 都靠它
│
├── Executor::ComputeCache::compute()
│ # cache 已经存在后,从这里开始决定是否 resize、是否重新执行
│ ├── DFS 检查依赖的 input cache
│ │ # 保证上游 cache 已经完成,输入内容没有脏状态
│ ├── [mShapeDirty] ComputeCache::resize()
│ │ # shape 脏时先重新 resize;resize 后内容也会变脏
│ │ └── ComputeCache::resizeImpl()
│ │ ├── Session::setNeedResize()
│ │ └── Session::resize()
│ │ ├── Pipeline::encode()
│ │ │ # 根据 ScheduleInfo 生成 command,并创建算子 Execution
│ │ │ └── Backend 创建每个 Op 对应的 Execution
│ │ └── Pipeline::allocMemory()
│ │ # 为 command 里的 Tensor 分配或复用后端内存
│ │ └── 为输入、中间结果和输出 Tensor 分配后端内存
│ └── [mContentDirty] Session::run() / Session::runWithCallBack()
│ # 内容脏时才真正执行;有 debug callback 时走 runWithCallBack()
│ └── Pipeline::execute()
│ # 按 command buffer 顺序执行每个算子
│ └── cmd.execution->onExecute(...)
│ └── 具体 Backend Execution 执行算子
│
└── Executor::ComputeCache::mapOutput(mCacheOffset + mFromIndex, outputTensor)
# 从临时 Session 的输出 Tensor 映射回当前 Variable 持有的 Tensor
├── host Tensor 且无 quantAttr
│ # 已经在 CPU host 内存上,可以零拷贝返回指针
│ └── 直接返回 tensor->host<void>()
└── 设备 Tensor / 量化 Tensor
# GPU / NPU 等设备内存或量化 Tensor,需要先复制到 host
├── Utils::allocMemoryForHostTensor(dest)
├── tensor->copyToHostTensor(dest)
└── 返回 dest->host<void>()
|
1.3 Expr类
Expr 表示计算图中的一条边,核心属性就是输入 VARP、输出 Tensor 和计算算子 Op。当前公开声明在 include/MNN/expr/Expr.hpp。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
// include/MNN/expr/Expr.hpp
class MNN_PUBLIC Expr {
public:
struct Inside;
enum MemoryType {
COPY,
MOVE,
REF
};
// 用不同来源构造 Expr
static EXPRP create(Tensor* tensor, bool own = false);
static EXPRP create(Variable::Info&& info, const void* ptr, VARP::InputType type, MemoryType copy = COPY);
static EXPRP create(const OpT* op, std::vector<VARP> inputs, int outputSize = 1);
static EXPRP create(std::shared_ptr<BufferStorage> extra, std::vector<VARP>&& inputs, int outputSize = 1);
static EXPRP create(std::unique_ptr<OpT>&& op, std::vector<VARP> inputs, int outputSize = 1);
// 当前边关联的算子和输入
const Op* get() const;
const std::vector<VARP>& inputs() const;
int outputSize() const;
// 当前边的名字和输出名
void setName(const std::string& name);
const std::string& name() const;
const std::string& outputName(int index);
// 替换旧边,供图改写使用
static void replace(EXPRP oldExpr, EXPRP newExpr);
VARP::InputType inputType() const;
// 递归补齐 shape 信息
bool requireInfo();
// 遍历输出引用
void visitOutputs(const std::function<bool(EXPRP, int)>& visit);
static void visit(EXPRP expr, const std::function<bool(EXPRP)>& before, const std::function<bool(EXPRP)>& after);
private:
const Op* mOp; // 核心属性 表示边的操作 如果为空表示当前边的输出VARP节点是常量节点
std::vector<VARP> mInputs; // 核心属性 表示边的输入节点
VARP::InputType mType;
std::string mName;
std::vector<std::string> mOutputNames;
std::shared_ptr<Inside> mInside = nullptr; // 包括当前边的输出信息
};
|
其中边有关输出信息都存在 Inside 类中,其内部状态定义在 express/Utils.hpp:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// express/Utils.hpp
struct Expr::Inside {
// 按输出个数初始化内部状态
Inside(int outputSize);
// 直接用已有 Tensor 构造 Expr 的内部表示
Inside(Tensor* tensor, bool own = false);
// 释放输出 Tensor / Host Tensor 等内部资源
~ Inside();
std::vector<Variable::Info> mOutputInfos; // 每个输出对应的 shape / dtype / format 描述
std::vector<Tensor*> mOutputTensors; // 每个输出真正绑定的 Tensor 指针
Executor::Requirement mReq; // 当前 Expr 对输入内容和 shape 的依赖要求
std::shared_ptr<Executor::ComputeCache> mCache; // 当前 Expr 关联的执行缓存,里面会持有临时 Session
int mCacheOffset = 0; // 当前 Expr 的第一个输出在 cache Tensor 列表中的偏移
bool mInfoDirty = true; // 输出信息是否失效,失效时需要重新做 shape 推导
bool mContentDirty = true; // 输出内容是否失效,失效时需要重新执行算子
bool mOwnTensor = true; // 当前 Inside 是否负责释放 mOutputTensors
Tensor* mHostTensor = nullptr; // 设备 Tensor 读回 Host 时使用的临时拷贝 Tensor
std::shared_ptr<Backend> mHoldBackend; // 持有输出数据的后端,避免底层资源被提前释放
};
|
1.3.1 requireInfo 详解
Expr::requireInfo() 的职责是把当前 Expr 和所有输入 Expr 的 shape、dtype、format 信息补齐,必要时会读取部分输入内容。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
// express/Expr.cpp
bool Expr::requireInfo() {
// 已经计算过就直接返回
if (!mInside->mInfoDirty) {
return true;
}
// 节点本身已经失效,直接失败
if (!mValid) {
return false;
}
// 没有算子时,只检查输出维度是否还包含未知值
if (nullptr == mOp) {
return !HasUnknownDim(mInside->mOutputInfos[0].dim);
}
// 先把所有输入节点的信息补齐
for (int i = 0; i < mInputs.size(); ++i) {
auto inputInfo = mInputs[i]->getInfo();
if (nullptr == inputInfo) {
mValid = false;
return false;
}
}
// 某些算子的 shape 推导需要真实输入内容
for (int i = 0; i < mInputs.size(); ++i) {
if (mInside->mReq.shapeNeedContent[i]) {
// 某些算子的 shape 推导依赖输入内容
if (nullptr == mInputs[i]->readInternal(true)) {
return false;
}
}
}
// 交给 Executor 做真正的 shape / info 推导
auto res = ExecutorScope::Current()->computeInfo(this);
if (NO_ERROR == res) {
mInside->mInfoDirty = false;
} else {
mValid = false;
}
return NO_ERROR == res;
}
|
这里有两个点需要特别注意:
mInside->mReq.shapeNeedContent 不是固定为真,它来自 Executor::getRequirement(),只有少数算子会在 shape 阶段要求真实输入内容;
- 真正做 shape 推导的是
Executor::computeInfo(),里面会继续调用 SizeComputer::computeOutputSize()。
1.4 Tensor类
张量数据类,包括数据指针,数据格式,数据维度等信息,所有属性存储在下面两个结构体对象中,
1
2
3
4
5
6
7
|
// include/MNN/Tensor.hpp
class MNN_PUBLIC Tensor{
// 其它代码
private:
halide_buffer_t mBuffer;
struct InsideDescribe* mDescribe;
};
|
1.4.1 数据格式
MNN支持下面列出的常见的数据格式,其中N C H W分别表示 批次大小 通道数 高度 宽度。这个格式是对图片格式的兼容,在大模型推理中输入embedding的shape是(batch_size, seq_len, hidden_size),依次是输入、序列长度和隐藏层大小(大模型推理过程中的shape变化可以参考这篇介绍)。
1
2
3
4
5
6
7
|
// include/MNN/Tensor.hpp
// 维度类型
enum DimensionType {
TENSORFLOW, // TensorFlow 格式:NHWC
CAFFE, // Caffe 格式:NCHW
CAFFE_C4 // Caffe 格式:NC4HW4(4通道对齐)
};
|
在 LLM 场景里, MNN-LLM默认把输入整理成 [seq_len, 1, hidden_size] 或 [1, seq_len, hidden_size]维度, 具体流程可以参考这篇介绍。
我尝试在MNN框架上做了Chunk prefill,把不同输入请求合并在seq_len维度上,并在Attention算子中展开:传送门
1.4.2 底层数据格式
从更底层出发,Tensor类的数据格式信息由halide_buffer_t结构体存储,对应Tensor::halide_buffer_t mBuffer属性,其中存储了数据的指针、数据类型、数据维度等信息,核心代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
// include/MNN/HalideRuntime.h
/**
* The raw representation of an image passed around by generated
* Halide code. It includes some stuff to track whether the image is
* not actually in main memory, but instead on a device (like a
* GPU). For a more convenient C++ wrapper, use Halide::Buffer<T>. */
typedef struct halide_buffer_t {
/** A device-handle for e.g. GPU memory used to back this buffer. */
uint64_t device; // 设备句柄
/** The interface used to interpret the above handle. */
const struct halide_device_interface_t *device_interface; // 接口指针
/** A pointer to the start of the data in main memory. In terms of
* the Halide coordinate system, this is the address of the min
* coordinates (defined below). */
uint8_t* host; // 指针 指向数据
/** flags with various meanings. */
uint64_t flags;
/** The type of each buffer element. */
struct halide_type_t type; // 数据类型
/** The dimensionality of the buffer. */
int32_t dimensions; // 数据维度,如 [batch_size, seq_len, hidden_size] 就是 3 维
/** The shape of the buffer. Halide does not own this array - you
* must manage the memory for it yourself. */
halide_dimension_t *dim; // 数据各维度的数值, 如如[batch_size, seq_len, hidden_size]大小的Tensor数据 dim[0]就表示batch_size维度的信息
/** Pads the buffer up to a multiple of 8 bytes */
void *padding; // 用来对齐内存
} halide_buffer_t;
|
其中的数据类型halide_type_t type通过数据占比特数和数据的性质 判断数据的类型,例如code = halide_type_float并且bits = 16表示半精度浮点数。核心代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
|
// include/MNN/HalideRuntime.h
/** A runtime tag for a type in the halide type system. Can be ints,
* unsigned ints, or floats of various bit-widths (the 'bits'
* field). Can also be vectors of the same (by setting the 'lanes'
* field to something larger than one). This struct should be
* exactly 32-bits in size. */
struct halide_type_t {
/** The basic type code: signed integer, unsigned integer, or floating point. */
// 这个code表示数据的性质 见本代码块最下方结构体,例如 halide_type_int = 0, 表示 signed integers 有符号整型
#ifndef _MSC_VER
HALIDE_ATTRIBUTE_ALIGN(1) halide_type_code_t code; // halide_type_code_t
#else
HALIDE_ATTRIBUTE_ALIGN(1) uint8_t code; // halide_type_code_t
#endif
/** The number of bits of precision of a single scalar value of this type. */
HALIDE_ATTRIBUTE_ALIGN(1) uint8_t bits; // 数据占用比特数
/** How many elements in a vector. This is 1 for scalar types. */
HALIDE_ATTRIBUTE_ALIGN(2) uint16_t lanes; // 一次处理的数据宽度 用于SIMD
// 构造函数
#ifdef __cplusplus
/** Construct a runtime representation of a Halide type from:
* code: The fundamental type from an enum.
* bits: The bit size of one element.
* lanes: The number of vector elements in the type. */
HALIDE_ALWAYS_INLINE halide_type_t(halide_type_code_t code, uint8_t bits, uint16_t lanes = 1)
: code(code), bits(bits), lanes(lanes) {
}
/** Default constructor is required e.g. to declare halide_trace_event
* instances. */
HALIDE_ALWAYS_INLINE halide_type_t() : code((halide_type_code_t)0), bits(0), lanes(0) {}
// 重载了对比函数
/** Compare two types for equality. */
HALIDE_ALWAYS_INLINE bool operator==(const halide_type_t &other) const {
return (code == other.code &&
bits == other.bits &&
lanes == other.lanes);
}
HALIDE_ALWAYS_INLINE bool operator!=(const halide_type_t &other) const {
return !(*this == other);
}
// 单个数据占据的内存字节数, 按8bit向上对齐
/** Size in bytes for a single element, even if width is not 1, of this type. */
HALIDE_ALWAYS_INLINE int bytes() const { return (bits + 7) / 8; }
#endif
};
typedef enum halide_type_code_t
{
halide_type_int = 0, //!< signed integers
halide_type_uint = 1, //!< unsigned integers
halide_type_float = 2, //!< IEEE floating point numbers
halide_type_handle = 3, //!< opaque pointer type (void *)
halide_type_bfloat = 4 //!< floating point numbers in the bfloat format
} halide_type_code_t;
|
halide_buffer_t结构体记录的数据维度信息 halide_dimension_t *dim 包含该维度的元素个数(extent)和在该维度移动一步时内存地址的偏移量信息(stride,通常在 TensorUtils::setLinearLayout 中计算)。核心代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
// include/MNN/HalideRuntime.h
typedef struct halide_dimension_t {
// extend: 该维度的元素个数
// stride: 在该维度移动一步时内存地址的偏移量信息
int32_t min, extent, stride;
// Per-dimension flags. None are defined yet (This is reserved for future use).
uint32_t flags;
#ifdef __cplusplus
HALIDE_ALWAYS_INLINE halide_dimension_t() : min(0), extent(0), stride(0), flags(0) {}
HALIDE_ALWAYS_INLINE halide_dimension_t(int32_t m, int32_t e, int32_t s, uint32_t f = 0) :
min(m), extent(e), stride(s), flags(f) {}
HALIDE_ALWAYS_INLINE bool operator==(const halide_dimension_t &other) const {
return (min == other.min) &&
(extent == other.extent) &&
(stride == other.stride) &&
(flags == other.flags);
}
HALIDE_ALWAYS_INLINE bool operator!=(const halide_dimension_t &other) const {
return !(*this == other);
}
#endif
} halide_dimension_t;
|
1.4.3 核心接口
Tensor类的核心接口包括设置/获取数据信息、把数据映射到执行设备、调整大小等,MNN文档有详细介绍, 需要用到时可以自己查看,比较常用的调试接口是打印数据和打印形状,这里打印数据中有根据数据底层的bits和code信息自动转换成对应数据类型的指针进行打印的转化。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// include/MNN/Tensor.hpp
class MNN_PUBLIC Tensor {
public:
/**
* @brief print tensor data. for DEBUG use only.
*/
void print() const;
/**
*@brief print tensor shape
*/
void printShape() const;
}
|
1.5 Op类
这一层的核心包括 Op 和 OpT 两个结构体:
Op 是 FlatBuffers 映射出来的只读视图,主要用于运行时读取算子类型、参数和输入输出索引;
OpT 是 FlatBuffers 对应的 NativeTable 结构,主要用于修改、构造和重新打包算子信息。
- 两者之间可以通过
UnPack() / Pack()接口切换,其中Op 适合“读”,OpT 适合“改”。
- 具体的算子则包括
ArgMax、QuantizedAdd、InstanceNorm等,每个算子都有对应的参数结构体,例如 ArgMaxT、QuantizedAddT、InstanceNormT等。
从宏观角度看,Op 和 OpT 的关系如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
构造阶段(NativeTable,可修改)
OpT
├── type = OpType_ArgMax # 算子类型
├── inputIndexes / outputIndexes / name # 输入输出索引和算子名
└── main : OpParameterUnion
├── type = OpParameter_ArgMax # 参数类型标签
└── value -------> ArgMaxT # 指向具体参数结构体
├── axis
├── topK
├── outMaxVal
└── softmaxThreshold
模型二进制转换过程(FlatBuffers)
OpT
└── Pack / CreateOp(...) # 把 NativeTable 打包成 FlatBuffers 二进制
↓
FlatBuffer binary # 模型中的实际存储形式
↓
flatbuffers::GetRoot<Op>(...) # 从二进制上取得 Op 只读视图
↓
Op
├── type()
├── inputIndexes()
└── main_as_ArgMax() # 按参数类型取出 ArgMax 只读视图
↓
ArgMax
└── UnPack() # 如需修改,再转回 NativeTable
↓
ArgMaxT
|
其中:
OpT 和 ArgMaxT 都是 NativeTable 风格的可编辑结构体,适合在内存里构造、修改和重写参数;
flatbuffers::GetRoot<Op>(...) 拿到的 Op 不是重新分配出来的新对象,而是对模型二进制 buffer 的只读映射,因此 ArgMax 也是只读参数视图;
- 只有继续调用
UnPack(),才会把 Op 或 ArgMax 重新转换成可修改的 OpT、ArgMaxT。
1.5.1 OpT结构体
Op 是算子的描述类,定义了神经网络中各种操作的类型和参数。MNN 使用内存高效的 FlatBuffers 库来序列化/反序列化 Op 等信息。其底层通过 OpT结构体存储各参数属性,核心代码如下:
1
2
3
4
5
6
7
8
9
10
|
// schema/current/MNN_generated.h
struct OpT : public flatbuffers::NativeTable {
std::vector<int32_t> inputIndexes; // 输入张量索引列表
OpParameterUnion main; // 算子参数(联合体)
std::string name; // 算子名称
std::vector<int32_t> outputIndexes; // 输出张量索引列表
OpType type; // 算子类型枚举
MNN_DATA_FORMAT defaultDimentionFormat; // 默认数据格式(NHWC/NCHW等)
std::string externalPath; // 外部权重路径
};
|
这里 OpType 是一个枚举类型,用于表示不同的算子类型,例如 Conv2D、Add、Relu 等。MNN 中定义了多个算子类型,每个类型对应一个具体的算子实现,部分代码如下:
1
2
3
4
5
6
7
8
9
|
// schema/current/MNN_generated.h
enum OpType {
OpType_AbsVal = 0,
OpType_QuantizedAdd = 1,
OpType_ArgMax = 2,
OpType_AsString = 3,
OpType_InstanceNorm = 4,
// ...
};
|
OpT结构体的main成员是一个联合体(OpParameterUnion),根据算子类型的不同存储不同的参数结构体,例如 QuantizedAdd 算子会存储一个 QuantizedAddT 结构体,ArgMax 算子会存储一个 ArgMaxT 结构体,对应的算子结构体通过OpParameterUnion的方法进行**参数值指针 value**的类型转换,例如AsQuantizedAdd(),AsArgMax()等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// schema/current/MNN_generated.h
struct OpParameterUnion {
OpParameter type; // 参数类型
void *value; // 参数值,指向具体的参数结构体,如 QuantizedAddT、ArgMaxT 等
// 根据参数类型转换成对应的参数结构体指针
QuantizedAddT *AsQuantizedAdd() {
return type == OpParameter_QuantizedAdd ?
reinterpret_cast<QuantizedAddT *>(value) : nullptr;
}
const QuantizedAddT *AsQuantizedAdd() const {
return type == OpParameter_QuantizedAdd ?
reinterpret_cast<const QuantizedAddT *>(value) : nullptr;
}
ArgMaxT *AsArgMax() {
return type == OpParameter_ArgMax ?
reinterpret_cast<ArgMaxT *>(value) : nullptr;
}
const ArgMaxT *AsArgMax() const {
return type == OpParameter_ArgMax ?
reinterpret_cast<const ArgMaxT *>(value) : nullptr;
}
|
OpParameterUnion中出现的OpParameter type是枚举类型,其包括不同的算子参数类型,例如 QuantizedAdd、ArgMax、InstanceNorm 等,部分枚举如下:
1
2
3
4
5
6
7
|
// schema/current/MNN_generated.h
enum OpParameter {
OpParameter_NONE = 0,
OpParameter_QuantizedAdd = 1,
OpParameter_ArgMax = 2,
// ... 其它代码
};
|
也就是一个算子通过OpType来区分算子类型,通过OpParameter来区分参数类型,二者共同决定了算子具体的计算逻辑和参数结构,例如ArgMax算子的构建主要是确定类型以及参数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// express/MathOp.cpp
VARP _ArgMax(VARP input, int axis) {
input = _checkNC4HW4(input);
std::unique_ptr<OpT> op(new OpT);
// 设置算子类型和参数
op->main.type = OpParameter_ArgMax;
op->type = OpType_ArgMax;
// 构造参数结构体并设置参数值
op->main.value = new ArgMaxT;
op->main.AsArgMax()->axis = axis;
op->main.AsArgMax()->outMaxVal = 0;
op->main.AsArgMax()->topK = 0;
op->main.AsArgMax()->softmaxThreshold = 0;
return (Variable::create(Expr::create(std::move(op), {input})));
}
|
相对应的 ArgMaxT 结构体可以通过 OpParameterUnion::AsArgMax() 获得,其包含 outMaxVal、topK、axis、softmaxThreshold 等参数,核心代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
struct ArgMaxT : public flatbuffers::NativeTable {
typedef ArgMax TableType;
int32_t outMaxVal;
int32_t topK;
int32_t axis;
int32_t softmaxThreshold;
ArgMaxT()
: outMaxVal(0),
topK(0),
axis(0),
softmaxThreshold(0) {
}
};
|
OpT 不能直接强转成 Op,要先 Pack 成 FlatBuffers,才能通过GetRoot<Op>(uoffset_t i) 接口取出来。例如,Module类加载模型时:
1
2
|
// express/module/Module.cpp:461
auto op = net->oplists()->GetAs<Op>(i);
|
1.5.2 Op结构体
Op 结构体是模型二进制上的只读视图,主要在读取、解析、构建算子的 Execution 时,部分核心代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
// schema/current/MNN_generated.h
struct Op FLATBUFFERS_FINAL_CLASS : private flatbuffers::Table {
typedef OpT NativeTableType;
static const flatbuffers::TypeTable *MiniReflectTypeTable() {
return OpTypeTable();
}
// 获取输入索引列表
const flatbuffers::Vector<int32_t> *inputIndexes() const {
return GetPointer<const flatbuffers::Vector<int32_t> *>(4);
}
// 获取参数类型
OpParameter main_type() const {
return static_cast<OpParameter>(GetField<uint8_t>(6, 0));
}
// 获取指针
const void *main() const {
return GetPointer<const void *>(8);
}
// 通过一组 main_as_XXX 函数,根据参数类型转换成对应的参数结构体
template<typename T> const T *main_as() const;
const QuantizedAdd *main_as_QuantizedAdd() const {
return main_type() == OpParameter_QuantizedAdd ? static_cast<const QuantizedAdd *>(main()) : nullptr;
}
const ArgMax *main_as_ArgMax() const {
return main_type() == OpParameter_ArgMax ? static_cast<const ArgMax *>(main()) : nullptr;
}
// ... 其它代码
// 通过 FlatBuffers 在 Op 和 OpT 之间切换
OpT *UnPack(const flatbuffers::resolver_function_t *_resolver = nullptr) const;
void UnPackTo(OpT *_o, const flatbuffers::resolver_function_t *_resolver = nullptr) const;
static flatbuffers::Offset<Op> Pack(flatbuffers::FlatBufferBuilder &_fbb, const OpT* _o, const flatbuffers::rehasher_function_t *_rehasher = nullptr);
};
|
在Op结构体中,算子有一个自己的参数解析方法: 通过main_type() 获取参数类型(OpParameter 枚举类型), 并通过一组 main_as_XXX 函数来把Op转换成具体算子的结构体,例如,通过 main_as_ArgMax() 转换成 ArgMax 结构体指针。
ArgMax结构体核心代码如下,是flatbuffers生成的解析函数,可以解析对应参数的值:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
// schema/current/CaffeOp_generated.h
struct ArgMax FLATBUFFERS_FINAL_CLASS : private flatbuffers::Table {
typedef ArgMaxT NativeTableType;
static const flatbuffers::TypeTable *MiniReflectTypeTable() {
return ArgMaxTypeTable();
}
// 获取各种算子的参数
int32_t outMaxVal() const {
return GetField<int32_t>(4, 0);
}
int32_t topK() const {
return GetField<int32_t>(6, 0);
}
int32_t axis() const {
return GetField<int32_t>(8, 0);
}
int32_t softmaxThreshold() const {
return GetField<int32_t>(10, 0);
}
bool Verify(flatbuffers::Verifier &verifier) const {
return VerifyTableStart(verifier) &&
VerifyField<int32_t>(verifier, 4) &&
VerifyField<int32_t>(verifier, 6) &&
VerifyField<int32_t>(verifier, 8) &&
VerifyField<int32_t>(verifier, 10) &&
verifier.EndTable();
}
// `UnPack`接口可以把反序列化成包含了算子参数的各种属性
ArgMaxT *UnPack(const flatbuffers::resolver_function_t *_resolver = nullptr) const;
void UnPackTo(ArgMaxT *_o, const flatbuffers::resolver_function_t *_resolver = nullptr) const;
static flatbuffers::Offset<ArgMax> Pack(flatbuffers::FlatBufferBuilder &_fbb, const ArgMaxT* _o, const flatbuffers::rehasher_function_t *_rehasher = nullptr);
};
|
因此,执行层最常见的访问路径通常是 Op -> main_as_ArgMax() -> ArgMax -> 读取字段。例如source/backend/cpu/CPUArgMax.cpp中构建Execution时就通过Op结构体获取到ArgMax参数结构体,然后通过FlatBuffers生成的访问接口获取参数,核心代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
// source/backend/cpu/CPUArgMax.cpp
class CPUArgMaxCreator : public CPUBackend::Creator {
public:
virtual Execution *onCreate(const std::vector<Tensor *> &inputs, const std::vector<Tensor *> &outputs,
const MNN::Op *op, Backend *backend) const {
// 通过 Op 结构体获取 ArgMax 参数结构体
auto argMax = op->main_as_ArgMax();
if (op->type() == OpType_ArgMin) {
return new CPUArgMax(backend, CPUArgMax::ArgMinOrMax::ARGMIN,
argMax->topK(), argMax->outMaxVal(), argMax->softmaxThreshold(), argMax->axis());
// 上面通过topK()、outMaxVal()、softmaxThreshold()、axis()等接口获取算子的参数值
} else {
return new CPUArgMax(backend, CPUArgMax::ArgMinOrMax::ARGMAX,
argMax->topK(), argMax->outMaxVal(), argMax->softmaxThreshold(), argMax->axis());
}
}
};
|
1.5.3 其他细节
总结一下:读取算子时通常用 Op 结构体,修改/写入时通常用 OpT 结构体。阅读代码时可以优先留意带 T 的结构体名称,例如 ArgMaxT、ConvolutionT,它们一般更接近可编辑的上层表示。
MNN 中会把常见的线性层转换为卷积 Convolution 算子,常用算子 UnaryOp 和 BinaryOp 分别表示一元和二元算子。
常用接口:可以使用 EnumNameXXX 方式获取 OpType、OpParameter 等枚举类型的字符串名称,便于调试,例如:
1
2
3
4
5
6
|
// schema/current/MNN_generated.h
inline const char *EnumNameOpType(OpType e);
inline const char *EnumNameOpParameter(OpParameter e);
// schema/current/TensorflowOp_generated.h
inline const char *EnumNameBinaryOpOperation(BinaryOpOperation e);
|
这里的获取都是从一个静态数组中取值实现的,例如二元操作的各个名称存储在静态数组中,并且通过 EnumNameBinaryOpOperation() 根据枚举值获取对应的名称字符串,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
// schema/current/TensorflowOp_generated.h
inline const char * const *EnumNamesBinaryOpOperation() {
static const char * const names[] = {
"ADD",
"SUB",
"MUL",
"DIV",
"MAX_TEMP",
"MIN_TEMP",
"POW",
"REALDIV",
"MINIMUM",
"MAXIMUM",
"GREATER",
"GREATER_EQUAL",
"LESS",
"FLOORDIV",
"SquaredDifference",
"EQUAL",
"LESS_EQUAL",
"FLOORMOD",
"",
"MOD",
"ATAN2",
"LOGICALOR",
"NOTEQUAL",
"BITWISE_AND",
"BITWISE_OR",
"BITWISE_XOR",
"LOGICALXOR",
"LEFTSHIFT",
"RIGHTSHIFT",
nullptr
};
return names;
}
inline const char *EnumNameBinaryOpOperation(BinaryOpOperation e) {
if (e < BinaryOpOperation_ADD || e > BinaryOpOperation_RIGHTSHIFT) return "";
const size_t index = static_cast<int>(e);
return EnumNamesBinaryOpOperation()[index];
}
|
1.5.4 GEMM转卷积算子
端侧执行LLM时MNN框架会把Linear/MatMul算子替换成Conv2d算子,这一节形式化的描述“卷积”和“矩阵乘法”之间的等价关系。
1.5.4.1 二维卷积
最常见的二维卷积输入可以写成:
- 输入:$[N, C_{\mathrm{in}}, H, W]$
- 卷积核:$[C_{\mathrm{out}}, C_{\mathrm{in}}, K_h, K_w]$
- 输出:$[N, C_{\mathrm{out}}, H_{\mathrm{out}}, W_{\mathrm{out}}]$
这个四维卷积核有$C_{\mathrm{out}}$个输入通道数为$C_{\mathrm{in}}$大小为$[K_h, K_w]$的二维维积核,每个卷积核的计算就是通常的二维卷积:在$[H,W]$的二维矩阵上把卷积核滑动,每个位置上做输入局部矩阵(大小和卷积核一致)与卷积核的点积,得到一个输出值:
$$Y[h, w] = \sum_{k_h=0}^{K_h-1}\sum_{k_w=0}^{K_w-1} X[h+k_h, w+k_w] \cdot K[k_h, k_w]$$叠加上输入通道和输出通道的维度后,
$$\mathrm{output}[n, c_{\mathrm{out}}, h, w] = \sum_{c_{\mathrm{in}}, k_h, k_w}\mathrm{input}[n, c_{\mathrm{in}}, h + k_h, w + k_w] \cdot \mathrm{weight}[c_{\mathrm{out}}, c_{\mathrm{in}}, k_h, k_w]$$每个输出通道都有自己的一套跨输入通道权重,用它把输入通道混合成一张新的输出特征图。
1.5.4.2 im2col
卷积直接按窗口滑动实现效率低下,工程上更常见的做法是先把输入展开成二维,再把卷积改写成一次大的矩阵乘法,这个展开过程通常就叫 im2col。
以二维卷积为例,im2col 的思路是:
- 对每一个输出空间位置,取出对应的输入感受野;
- 把这个局部窗口拉平成一行;
- 把所有输出位置对应的局部窗口按行堆起来,形成一个大矩阵。
如果卷积核大小是 $K_h \times K_w$,输入通道数是 $C_{\mathrm{in}}$,那么每个局部窗口拉平后长度就是:
$$C_{\mathrm{in}} \times K_h \times K_w$$如果总共有
$$H_{\mathrm{out}} \times W_{\mathrm{out}}$$个输出位置,那么 im2col 后的输入矩阵可以理解成:
$$\left[H_{\mathrm{out}} \times W_{\mathrm{out}}, C_{\mathrm{in}} \times K_h \times K_w\right]$$而卷积核本身也可以拉平成另一个矩阵:
$$\left[C_{\mathrm{out}}, C_{\mathrm{in}} \times K_h \times K_w\right]$$这样卷积就被改写成了一个标准的矩阵乘法:
$$\left[H_{\mathrm{out}} \times W_{\mathrm{out}}, C_{\mathrm{in}} \times K_h \times K_w\right] \times \left[C_{\mathrm{in}} \times K_h \times K_w, C_{\mathrm{out}}\right] = \left[H_{\mathrm{out}} \times W_{\mathrm{out}}, C_{\mathrm{out}}\right]$$最后再把结果 reshape 回 $[C_{\mathrm{out}}, H_{\mathrm{out}}, W_{\mathrm{out}}]$ 即可。
1.5.4.3 GEMM等价成卷积
以 LLM 推理里常见的线性层 $xW$ 为例。设:
- $x$ 的维度是 $[\mathrm{bs}, \mathrm{seq}, \mathrm{hidden}]$;
- $W$ 的维度是 $[\mathrm{hidden}, \mathrm{hidden}]$;
- 输出 $y$ 的维度仍然是 $[\mathrm{bs}, \mathrm{seq}, \mathrm{hidden}]$。
矩阵乘法写成:
$$y = xW$$展开到单个 batch、token 位置和输出通道:
$$y[b, i, o] = \sum_{d=0}^{\mathrm{hidden}-1} x[b, i, d] \cdot W[d, o]$$这里 $i$ 是 token 位置,$d$ 是输入 hidden 通道,$o$ 是输出 hidden 通道。这个计算对每个 token 位置独立发生,不混合相邻 token。
可以想象成有 bs 个输入通道数是 hidden,空间大小是 $[1, \mathrm{seq}]$的样本,与输入/输出通道数都是 hidden二维卷积核大小是 $[1, 1]$的卷积核进行卷积。把算子替换成卷积可以复用底层卷积实现的高效计算,同时也能更好地适配端侧的内存访问模式。
1.6 Pipeline类
这一层对应的核心类是 Pipeline。Pipeline 不是单个算子,而是一条已经切分好的执行流水线,负责把 Schedule::PipelineInfo 里的算子信息真正落到 Execution、Tensor 和运行时后端上。
source/core/Pipeline.hpp 里最重要的三个阶段是:
encode():计算 shape、做 geometry transform,并把 op / tensor 信息写入缓冲;
allocMemory():为每个 op 创建 Execution 并分配内存;
execute():按流水线顺序真正跑算子。
1
2
3
4
5
6
7
|
// source/core/Pipeline.hpp
class Pipeline : public NonCopyable {
public:
ErrorCode encode(bool supportDebug = false, bool permitCodegen = false);
ErrorCode allocMemory(bool firstMalloc, bool permitCodegen);
ErrorCode execute();
};
|
这里可以把 Pipeline 理解成一次“从调度信息到可执行算子序列”的落地过程。
1
2
3
4
5
6
|
/** encode :
1. compute shape for every op's inputs and outputs;
2. geometry transform;
3. copy op, inputs and outputs tensor info to mBuffer
*/
ErrorCode encode(bool supportDebug = false, bool permitCodegen = false);
|
Pipeline 干的是把调度阶段已经决定好的结果继续往后推进:
- 先把 shape 和几何变换补齐;
- 再创建每个算子的
Execution;
- 最后按顺序进入
execute()。
流程如下:
1
2
3
4
5
|
Session::run()
↓
Pipeline::execute()
↓
Execution::onExecute()
|
这里的 Pipeline 是 Session 下面去调用“算子序列”执行的层。
1.7 Session类
这一层对应的核心类是 Session。Session 负责把模型切成一组 Pipeline,再把当前运行时、输入输出张量、resize 状态和 cache 状态串起来。
source/core/Session.hpp 里可以直接看到它的职责:
- 持有
RuntimeInfo;
- 持有
std::vector<std::shared_ptr<Pipeline>> mPipelines;
- 提供
run()、runWithCallBack()、resize()、getInput()、getOutput() 等入口。
1
2
3
4
5
6
7
8
9
|
// source/core/Session.hpp
class MNN_PUBLIC Session {
public:
ErrorCode run() const;
ErrorCode runWithCallBack(const TensorCallBackWithInfo& before,
const TensorCallBackWithInfo& after,
bool sync = false) const;
ErrorCode resize();
};
|
Session 的关键不只是“有一组 Pipeline”,而是它把运行模式、后端、输入输出张量和 cache 策略放到了同一个上下文里。比如 ModeGroup 里就直接收了这些控制项:
1
2
3
4
5
6
7
|
struct ModeGroup {
Interpreter::SessionMode inputMode = Interpreter::Session_Input_Inside;
Interpreter::SessionMode outputMode = Interpreter::Session_Output_Inside;
Interpreter::SessionMode backendMode = Interpreter::Session_Backend_Fix;
Interpreter::SessionMode resizeMode = Interpreter::Session_Resize_Direct;
Interpreter::SessionMode memoryUsageMode = Interpreter::Session_Memory_Collect;
};
|
在source/core/Session.cpp里的createPipelineBackend()` 会进一步把调度结果落成具体后端:
1
2
3
4
5
6
7
8
9
10
|
// source/core/Session.cpp
auto rt = runtime.first.find(iter.first.info.type)->second.get();
auto cpuRuntime = runtime.second;
iter.first.cache.first.reset(rt->onCreate(iter.first.info.user));
if (iter.first.cache.first->type() == MNN_FORWARD_CPU && (!specialUsage)) {
iter.first.cache.second = iter.first.cache.first;
} else {
iter.first.cache.second.reset(cpuRuntime->onCreate(&defaultConfig, origin));
}
|
这里的设计重点是:
- 主后端负责按用户选择真正执行算子;
- CPU 备份后端负责 shape 计算和不支持算子的兜底;
- 一个
Session 里可以有多个 Pipeline,每个 Pipeline 共享同一套运行时上下文。
这里的Session 不是单纯的数据容器,而是一次推理的完整执行上下文。
1.8 Executor & ExecutorScope类
这一层对应的核心类是 Executor 和 ExecutorScope。Executor 负责 Express 图的 shape 推导、依赖收集和 ComputeCache 构建;ExecutorScope 则是线程局部的当前执行器入口。
先看 Executor 的公开接口:
1
2
3
4
5
6
7
8
9
10
11
|
// include/MNN/expr/Executor.hpp
class MNN_PUBLIC Executor {
public:
struct Requirement {
std::vector<bool> contentNeedContent;
std::vector<bool> shapeNeedContent;
};
Requirement getRequirement(Expr* expr) const;
ErrorCode computeInfo(Expr* expr);
void makeCache(const std::vector<EXPRP>& expr, bool forceCPU = false);
};
|
分别对应三层职责:
getRequirement():判断一个 Expr 的输入到底是“只要 shape”还是“必须读内容”;
computeInfo():调用 SizeComputer::computeOutputSize() 推导输出张量信息;
makeCache():把当前 Expr 子图整理成 ComputeCache,并在里面构造临时 Session。
express/Executor.cpp 里的 computeInfo() 实现是这条链路的核心:
1
2
3
4
5
6
7
8
9
10
11
|
// express/Executor.cpp
bool res = SizeComputer::computeOutputSize(op, inputTensors, expr->inside()->mOutputTensors);
if (!res) {
return COMPUTE_SIZE_ERROR;
}
for (int i = 0; i < expr->outputSize(); ++i) {
auto tensor = expr->inside()->mOutputTensors[i];
TensorUtils::setLinearLayout(tensor);
auto shape = expr->outputInfo(i);
Utils::copyTensorToInfo(shape, tensor);
}
|
而 _makeCache() 是“小型调度器”,它会:
- 从输出
Expr 逆向遍历依赖;
- 收集每个算子的输入输出
Tensor;
- 组装
Schedule::ScheduleInfo;
- 最后直接构造一个临时
Session 塞进 ComputeCache。
1
2
3
4
5
6
7
|
// express/Executor.cpp
scheduleInfo.pipelineInfo.resize(1);
auto& pipeline = scheduleInfo.pipelineInfo[0].second;
...
pipeline.emplace_back(std::move(opInfo));
...
cahce->mSession.reset(new Session(std::move(scheduleInfo), group, std::move(rt)));
|
这里的Executor 在 Express 层的作用包括做 shape 推导和把动态图临时收敛成一次可执行的 Session。
ExecutorScope 则负责解释当前代码到底应该使用哪个 Executor。
1
2
3
4
5
6
7
8
|
// express/ExecutorScope.cpp
const std::shared_ptr<Executor> ExecutorScope::Current() {
auto exe = _getGlobalScope()->Content();
if (exe) {
return exe;
}
return Executor::getGlobalExecutor();
}
|
它的设计点在于线程局部作用域:
- 显式进入
ExecutorScope(current) 后,当前线程会临时绑定新的 Executor;
- 作用域结束时自动退出;
- 如果当前线程没有显式绑定,就回退到全局
Executor。
普通使用不需要手动进入 ExecutorScope。直接调用 ExecutorScope::Current() 时,如果当前线程没有绑定过局部执行器,它会返回全局作用域 Executor::getGlobalExecutor()。只有在需要临时切换到某个自定义 Executor 时,才需要手动创建一个 ExecutorScope 对象:
1
2
3
4
5
6
7
8
9
10
|
BackendConfig config;
auto exe = Executor::newExecutor(MNN_FORWARD_CPU, config, 1);
{
ExecutorScope scope(exe);
// 这个作用域内,ExecutorScope::Current() 返回 exe
auto y = _Add(x0, x1);
y->readMap<float>();
}
// 离开作用域后,自动恢复到上一层 Executor;
// 如果没有上一层,就继续回退到全局 Executor。
|