Ch12: MNN——端侧瑞士军刀
Part 3: 六大框架架构精讲 前置章节:Ch11: ncnn——零依赖的手工匠人 后续章节:Ch13: ONNX Runtime——标准集装箱港口
一句话定位
MNN 是六大移动端推理引擎中功能最全面的一个:它既能做传统 CNN 推理,也能跑端侧 LLM;既有 CPU/GPU 推理能力,也支持端侧训练(finetune);既有手写 SIMD 内核,也有 Geometry Compiler 自动生成计算方案。它就像一把瑞士军刀——刀、锯子、开瓶器、剪刀、螺丝刀一应俱全,每一个工具单独拿出来可能不如专业工具精良,但组合在一起的覆盖面无人能及。
GitHub 9.5K star,阿里开源,约 2000 个源文件,C++14+ 风格——代码量是 ncnn 的 4 倍,抽象层次也明显更高。
类比:自动化工厂的工作方式
上一章把 ncnn 比作手工木匠。MNN 则完全是另一个物种——它更像一座现代化的自动化工厂。
手工木匠做一把椅子,每一步都是人工操作:量尺寸、锯木头、打磨、拼装。自动化工厂做同样一把椅子,先由设计部门在电脑上画图纸(模型转换),然后生产规划部门把图纸分解成工序(Geometry Compiler 降级),再由调度中心决定每道工序在哪条产线上做(后端分配),最后由产线上的机器人执行(Execution)。
这种工厂模式的核心优势是:加一个新产品(新算子)不需要从头设计产线。生产规划部门可以把新产品拆解成已有产线能做的基本工序——比如一个新形状的桌子,可以拆成”切割”+”打孔”+”拼接”三个基本操作,而这三个基本操作产线上的机器人已经会做了。这就是 MNN 的 Geometry Compiler 的精髓:把复杂的高层算子”降级”为简单的基本操作组合。
类比边界:自动化工厂的类比强调的是 MNN 的 Geometry Compiler 和 Op-Execution 分离。但真正的工厂是批量生产同一产品,而推理引擎每次处理的输入不同(不同图片、不同文本)。更准确地说,MNN 的”自动化”体现在计算方案的自动生成,而不是数据的批量处理。
设计哲学精讲
哲学一:Geometry Compiler——非计算 Op 的降级与合并
Geometry Compiler 是 MNN 最核心的设计创新,也是它和 ncnn 最根本的区别。要理解它,先看一个实际问题。
假设模型中有一个 Permute(0,2,3,1) 操作——把 tensor 的维度顺序从 NCHW 重排为 NHWC。在 ncnn 里,这个操作需要一个专门的 Permute 层来执行,这个层要做真正的内存拷贝(把数据从 NCHW 的内存布局搬到 NHWC 的内存布局)。
MNN 的做法不同。它认识到 Permute 本质上不是一个”计算”——它不对数据做任何算术运算,只是改变了数据的”观察方式”。就像一本书的内容没变,你只是换了一种方式翻页而已。MNN 把这类操作称为”非计算 Op”,包括 Reshape、Permute、Slice、Concat、Expand 等。
Geometry Compiler 的工作原理分两步:
第一步:降级——把非计算 Op 转化为 Region 描述
// MNN 的 Region 结构(简化)
struct Tensor::InsideDescribe::Region {
int32_t src_offset; // 源数据的起始偏移
int32_t dst_offset; // 目标数据的起始偏移
int32_t size[3]; // 三个维度的大小
int32_t src_stride[3]; // 源数据在三个维度的步长
int32_t dst_stride[3]; // 目标数据在三个维度的步长
Tensor* origin; // 源 tensor
};
这个 Region 结构描述的是一个从源 tensor 到目标 tensor 的”拷贝规则”——用 src_offset、src_stride 定义在源 tensor 中怎么取数据,用 dst_offset、dst_stride 定义在目标 tensor 中怎么放数据。它本质上是一个”视图变换的数学描述”,而不是一个实际的内存操作。
比如 Permute(0,2,3,1) 作用在一个 (1,3,4,4) 的 tensor 上,Geometry Compiler 会生成一个 Region:源 stride 是 NCHW 的 stride (48,16,4,1),目标 stride 是 NHWC 的 stride (48,12,3,1)。这个 Region 描述了”怎么从 NCHW 布局的内存中读数据,写到 NHWC 布局的内存中”。
第二步:合并——把连续的 Region 操作合并成一个 Raster Op
如果模型中有连续的 Reshape -> Permute -> Slice 三个操作,每个都生成一个 Region,Geometry Compiler 会尝试把这三个 Region 合并成一个——通过组合三次步长变换,得到一个等效的”从原始输入直接到最终输出”的 Region。然后只需要执行一次 Raster Op(一次内存拷贝),而不是三次。
传统方式(ncnn/TNN):
input -> [Reshape] -> tmp1 -> [Permute] -> tmp2 -> [Slice] -> output
3 次内存拷贝,2 个中间 tensor
MNN Geometry Compiler:
input -> [合并后的 Raster Op] -> output
1 次内存拷贝,0 个中间 tensor
这在 Transformer 类模型中效果尤其明显。Transformer 的 Multi-Head Attention 涉及大量的 Reshape、Permute、Split 操作(把输入拆成多个 head,重排维度,然后合并回来),这些”非计算 Op”在传统引擎中可能占据 20-30% 的推理时间,但在 MNN 中可以被合并成很少几次 Raster 操作。
// Geometry Compiler 的核心调用链(概念简化)
bool GeometryComputer::compute(const Op* op,
const std::vector<Tensor*>& inputs,
const std::vector<Tensor*>& outputs,
Context& context) {
// 对于 Permute, Reshape, Slice 等非计算 Op:
// 不执行实际计算,只生成 Region 描述
auto& region = outputs[0]->describe()->regions[0];
region.origin = inputs[0];
region.src_stride = compute_src_stride(op);
region.dst_stride = compute_dst_stride(op);
return true; // 标记为已处理
}
// Raster Op: 所有 Region 的最终执行者
class RasterExecution : public Execution {
ErrorCode onExecute(const std::vector<Tensor*>& inputs,
const std::vector<Tensor*>& outputs) override {
for (auto& region : output->describe()->regions) {
// 按 Region 描述执行一次内存拷贝/重排
blit(region);
}
return NO_ERROR;
}
};
哲学二:NC4HW4 固定布局——一劳永逸的通道打包
ncnn 用 elempack 做动态打包,不同硬件打包数不同。MNN 走了另一条路:所有后端统一使用 NC4HW4 布局。
NC4HW4 是什么意思?N 是 batch,C4 是通道按 4 分组,H 是高,W 是宽,最后一个 4 是每组 4 个元素。换句话说,MNN 在整个引擎内部只用一种打包方式:永远把 4 个通道打包在一起。
NCHW 布局 (标准):
Channel 0: [a00, a01, a02, a03, a10, a11, ...] // H*W 个元素
Channel 1: [b00, b01, b02, b03, b10, b11, ...]
Channel 2: [c00, c01, c02, c03, c10, c11, ...]
Channel 3: [d00, d01, d02, d03, d10, d11, ...]
NC4HW4 布局:
Group 0 (Channel 0-3):
Pos(0,0): [a00, b00, c00, d00] // 4个通道的同一位置打包
Pos(0,1): [a01, b01, c01, d01]
Pos(0,2): [a02, b02, c02, d02]
...
这和 ncnn 的 elempack=4 在效果上非常相似,区别是:
| ncnn elempack | MNN NC4HW4 | |
|---|---|---|
| 打包数 | 根据硬件动态选择 (4/8/16) | 固定为 4 |
| x86 AVX 场景 | elempack=8(充分利用 256-bit 寄存器) | 仍然是 4(AVX 寄存器用一半) |
| 实现复杂度 | 每个打包数需要一套内核 | 只需一套内核 |
MNN 选择固定 4 的原因是工程简洁性:4 是 ARM NEON 和 OpenCL image2d 的最大公约数。NEON 的 128-bit 寄存器装 4 个 float,OpenCL 的 image2d 每个像素 4 个分量 (RGBA)。如果用固定 4 打包,同一份数据可以在 CPU 和 GPU 之间零拷贝传递——不需要在 CPU 用 4 打包、GPU 用另一个打包方式。
代价是在 x86 AVX 场景下不够高效(只用了 AVX 寄存器的一半),但 MNN 的主要目标是移动端(ARM + 移动 GPU),x86 不是第一优先级。
哲学三:Op-Execution 分离——形状推断与计算的解耦
如果说 ncnn 的 Layer 是”一个人包办所有事”,MNN 的做法就是”设计师和施工队分开”。
MNN 把算子的逻辑分成两层:
Op (上层): 定义算子的语义(输入输出类型、形状推断规则)
|
Execution (下层): 在特定后端上执行计算
// MNN 的 Execution 接口(简化)
class Execution {
public:
virtual ErrorCode onResize(const std::vector<Tensor*>& inputs,
const std::vector<Tensor*>& outputs);
virtual ErrorCode onExecute(const std::vector<Tensor*>& inputs,
const std::vector<Tensor*>& outputs);
};
这里的 onResize 和 onExecute 的分离非常关键:
onResize 在输入形状确定后调用(但还没有实际数据),用来做内存分配、权重重排、临时缓冲区预计算等”准备工作”。比如卷积的 onResize 会把权重从标准布局重排为 im2col 友好的布局。
onExecute 在实际数据到来后调用,执行真正的数学计算。因为 onResize 已经做好了所有准备,onExecute 只需要做纯计算,不需要做任何内存分配或数据重排。
// CPU 卷积的 Execution(概念简化)
class CPUConvolution : public Execution {
std::shared_ptr<Tensor> mWeightReordered;
std::shared_ptr<Tensor> mTempBuffer;
ErrorCode onResize(const std::vector<Tensor*>& inputs,
const std::vector<Tensor*>& outputs) override {
// 根据输入形状计算需要的缓冲区大小
auto inputShape = inputs[0]->shape();
// 分配临时 im2col 缓冲区
mTempBuffer.reset(Tensor::createDevice<float>({/* dims */}));
backend()->onAcquireBuffer(mTempBuffer.get(), Backend::DYNAMIC);
// 重排权重(只需做一次)
reorderWeight(mWeight, mWeightReordered);
return NO_ERROR;
}
ErrorCode onExecute(const std::vector<Tensor*>& inputs,
const std::vector<Tensor*>& outputs) override {
// 纯计算,不做任何内存分配
im2col(inputs[0], mTempBuffer.get());
gemm(mTempBuffer.get(), mWeightReordered.get(), outputs[0]);
return NO_ERROR;
}
};
这种分离的好处在运行时体现得很明显:如果模型的输入形状不变(大多数 CV 模型都是固定输入的),onResize 只需要调用一次,之后每次推理只调用 onExecute。这避免了每次推理都重复做内存分配和权重重排。
哲学四:虚拟 Tensor——零成本的内存视图
MNN 的 Tensor 有一个其他框架都没有的特性:MEMORY_VIRTUAL(虚拟 Tensor)。
虚拟 Tensor 不拥有实际的内存——它只是通过一组 Region 引用其他 Tensor 的内存。它存在的意义完全是为了配合 Geometry Compiler:当 Geometry Compiler 把一个 Permute Op 降级为 Region 描述时,输出 Tensor 不需要分配新的内存,它只需要知道”从源 Tensor 的哪些位置读数据、按什么步长排列”。
// Tensor 的内存类型
enum MemoryType {
MEMORY_HOST, // 有实际 CPU 内存
MEMORY_DEVICE, // 有实际设备内存(GPU 等)
MEMORY_VIRTUAL // 无实际内存,只有 Region 引用
};
// 虚拟 Tensor 的使用(概念示意)
Tensor* permuted = new Tensor();
permuted->describe()->memoryType = MEMORY_VIRTUAL;
// 不分配内存,只设置 Region
auto& region = permuted->describe()->regions[0];
region.origin = input_tensor; // 引用原始 tensor 的内存
region.src_stride = {48, 16, 4, 1}; // NCHW stride
region.dst_stride = {48, 12, 3, 1}; // NHWC stride
虚拟 Tensor 的价值在于:连续多个非计算 Op 的输出都是虚拟 Tensor,它们不占用任何实际内存。只有到了真正需要执行计算 Op(如 Conv)时,才通过 Raster Op 把虚拟 Tensor”物化”成实际内存。这比 ncnn 的”每个 Permute 都做一次真实的内存拷贝”节省了大量的内存分配和拷贝操作。
哲学五:Pipeline 四步——推理的完整流水线
MNN 的推理流水线分为清晰的四个步骤。理解这四个步骤,就理解了 MNN 的整个运行时工作流程。
步骤 1: 形状推断 (Shape Inference)
-- 从输入到输出,逐层推断每个 Tensor 的形状
步骤 2: Geometry 计算
-- 对非计算 Op 运行 Geometry Compiler,生成 Region
-- 合并相邻的 Region
-- 虚拟 Tensor 不分配内存
步骤 3: onResize(资源准备)
-- 为计算 Op 的 Execution 调用 onResize
-- 分配临时缓冲区
-- 重排权重
步骤 4: onExecute(实际计算)
-- 按拓扑序执行每个 Execution 的 onExecute
-- Raster Op 处理虚拟 Tensor 的物化
-- 实际的 GEMM、Conv 等数学运算在这里发生
// Pipeline 的四步执行(概念简化)
class Pipeline {
ErrorCode encode() {
// 步骤 1: 形状推断
for (auto& unit : mUnits) {
unit.mOp->onInferShape(unit.mInputs, unit.mOutputs);
}
// 步骤 2: Geometry 计算
for (auto& unit : mUnits) {
if (unit.mOp->type() == NON_COMPUTE) {
GeometryComputer::compute(unit.mOp, unit.mInputs,
unit.mOutputs, mContext);
}
}
// 步骤 3: onResize
for (auto& unit : mUnits) {
unit.mExecution->onResize(unit.mInputs, unit.mOutputs);
}
return NO_ERROR;
}
ErrorCode execute() {
// 步骤 4: onExecute
for (auto& unit : mUnits) {
unit.mExecution->onExecute(unit.mInputs, unit.mOutputs);
}
return NO_ERROR;
}
};
关键观察:步骤 1-3(encode)可以只做一次(在输入形状不变的情况下),之后每次推理只需要做步骤 4(execute)。这就是 MNN 的”预编译”模式——第一次推理会比较慢(因为要做 Geometry 计算和 onResize),后续推理就非常快了。
哲学六:多后端覆盖——OpenCL + Metal + Vulkan + CUDA
MNN 在 GPU 后端的覆盖面远超其他移动端引擎。它同时支持四种 GPU API:
| 后端 | 目标平台 | 特点 |
|---|---|---|
| OpenCL | Android (高通/ARM Mali) | image2d 优化,覆盖面最广 |
| Metal | iOS | Apple GPU 原生 API |
| Vulkan | Android (跨厂商) | Compute Shader |
| CUDA | 服务端 GPU | 让 MNN 可以在服务端跑 |
ncnn 只有 Vulkan,TNN 有 OpenCL + Metal,MACE 有 OpenCL + Hexagon DSP。MNN 是唯一把四种主流 GPU API 全部覆盖的移动端引擎——这也是它被称为”瑞士军刀”的原因之一。
每个 GPU 后端都实现了 Backend 接口:
// MNN 的 Backend 接口(简化)
class Backend {
public:
virtual Execution* onCreate(const std::vector<Tensor*>& inputs,
const std::vector<Tensor*>& outputs,
const Op* op) = 0;
virtual void onResizeBegin() = 0;
virtual void onResizeEnd() = 0;
virtual void onExecuteBegin() const = 0;
virtual void onExecuteEnd() const = 0;
virtual ErrorCode onAcquireBuffer(const Tensor* tensor,
StorageType type) = 0;
virtual ErrorCode onReleaseBuffer(const Tensor* tensor,
StorageType type) = 0;
virtual ErrorCode onCopyBuffer(const Tensor* src,
const Tensor* dst) const = 0;
};
Backend 接口统一了不同后端的行为:创建 Execution(onCreate)、管理内存(onAcquireBuffer/onReleaseBuffer)、数据搬运(onCopyBuffer)。上层的 Pipeline 不需要知道底层用的是 OpenCL 还是 Metal——它只和 Backend 接口打交道。
核心数据结构:Tensor
MNN 的 Tensor 比 ncnn 的 Mat 复杂得多,它承载了更多的元信息。
// MNN 的 Tensor(简化)
class Tensor {
public:
// 形状信息
std::vector<int> shape() const;
int dimensions() const;
int width() const;
int height() const;
int channel() const;
int batch() const;
// 数据访问
template<typename T>
T* host() const; // 获取 CPU 内存指针
// 设备相关
uint64_t deviceId() const; // GPU buffer/image ID
// 内部描述(包含 Region 等元信息)
struct InsideDescribe {
MemoryType memoryType; // HOST / DEVICE / VIRTUAL
std::vector<Region> regions; // Geometry Compiler 的 Region
int useCount; // 引用计数
};
// 数据格式
DimensionType getDimensionType() const; // NHWC, NCHW, NC4HW4, ...
halide_type_t getType() const; // float, int8, uint8, ...
};
和 ncnn 的 Mat 的关键区别:
第一,MNN 的 Tensor 有明确的 dtype(通过 halide_type_t 表达),ncnn 的 Mat 没有(只有 elemsize,需要结合 elempack 推断具体类型)。MNN 借鉴了 Halide 的类型系统来表达 float32、float16、int8、uint8 等不同精度。
第二,MNN 的 Tensor 有 MEMORY_VIRTUAL 状态,ncnn 的 Mat 总是拥有实际内存。这是 Geometry Compiler 体系的基础——虚拟 Tensor 只描述数据的来源和变换方式,不占用实际内存。
第三,MNN 的 Tensor 有 InsideDescribe,存放 Region 信息,这是 Geometry Compiler 工作的数据载体。ncnn 的 Mat 没有任何类似结构,因为 ncnn 没有 Region 概念。
第四,MNN 的 Tensor 可以同时存在 host(CPU)和 device(GPU)两份数据,ncnn 的 CPU 数据用 Mat,GPU 数据用 VkMat,是完全不同的类型。
MNN 的 Tensor 本质上是一个”胖描述符”——它不仅描述数据的形状和类型,还描述数据的来源(Region)、存储位置(CPU/GPU/虚拟)和使用状态(引用计数)。ncnn 的 Mat 则是一个”瘦容器”——它只管存数据,其他逻辑由外部代码处理。
推理流程
MNN 的完整推理流程比 ncnn 复杂得多,涉及更多的阶段和抽象层次。
阶段一:模型加载与 Session 创建
// 加载模型
auto interpreter = Interpreter::createFromFile("model.mnn");
// 配置推理参数
ScheduleConfig config;
config.type = MNN_FORWARD_CPU; // 选择后端
config.numThread = 4;
BackendConfig backendConfig;
backendConfig.precision = BackendConfig::Precision_Normal;
config.backendConfig = &backendConfig;
// 创建 Session
Session* session = interpreter->createSession(config);
MNN 的模型格式是 FlatBuffers 序列化的(.mnn 文件),比 ncnn 的纯文本 .param 解析更高效但不可人工阅读。createSession 是一个重量级操作:它会解析模型图、创建后端、构建 Pipeline、做形状推断和 Geometry 计算(Pipeline 四步中的前三步)。
阶段二:输入填充
auto input = interpreter->getSessionInput(session, nullptr);
// 如果需要动态输入形状
interpreter->resizeTensor(input, {1, 3, 224, 224});
interpreter->resizeSession(session);
// 填充输入数据
auto inputHost = input->host<float>();
memcpy(inputHost, data, input->size());
如果调用了 resizeTensor 改变了输入形状,需要调用 resizeSession 重新执行 Pipeline 的步骤 1-3(形状推断、Geometry 计算、onResize)。这是 MNN 支持动态形状的方式——虽然 resize 有一定开销,但比从头创建 Session 快得多。
阶段三:推理执行
interpreter->runSession(session);
runSession 对应 Pipeline 的步骤 4(execute):按拓扑序遍历每个执行单元,调用 onExecute。因为步骤 1-3 在 createSession(或 resizeSession)时已经做过了,runSession 只做纯计算。
阶段四:输出读取
auto output = interpreter->getSessionOutput(session, nullptr);
// 如果推理在 GPU 上执行,需要拷贝到 CPU
auto outputHost = new Tensor(output, Tensor::CAFFE);
output->copyToHostTensor(outputHost);
float* data = outputHost->host<float>();
如果推理是在 GPU 上做的,输出数据在 GPU 内存中,需要用 copyToHostTensor 拷贝到 CPU。如果是 CPU 推理,可以直接用 host<float>() 获取指针。
关键差异:MNN 与其他框架
vs ncnn:编译器 vs 手工
这是最本质的区别。ncnn 通过手写 SIMD 汇编获得性能,每个新算子都需要大量人工优化。MNN 通过 Geometry Compiler 让新算子自动获得一个”基线性能”(非计算 Op 被降级为 Region,计算 Op 可以使用通用的 GEMM 内核),然后再对热点算子做手动优化。MNN 的方式更适合快速迭代——模型的算子种类越来越多,手写每一个算子的汇编已经不现实了。
vs ORT:端侧 vs 通用
ORT 是一个通用推理引擎,它的 Execution Provider 插件体系让它可以在任何硬件上跑。MNN 是一个端侧优化引擎,它的 NC4HW4 布局和 Geometry Compiler 都是为移动端场景定制的。ORT 在通用性上胜出,MNN 在移动端特化优化上胜出。
vs TNN:功能全 vs 架构精
TNN 在架构上比 MNN 更干净(BaseLayer/LayerAcc 分离清晰),但功能覆盖面较窄——不支持端侧训练、不支持 LLM。MNN 的功能覆盖面更广,但代码复杂度也更高(约 2000 个文件 vs TNN 约 800 个文件)。
vs MACE:通用 GPU vs 专用 DSP
MACE 在高通平台上有独特的 Hexagon DSP 整图下推能力(Ch16 会详细讲),这是 MNN 不具备的。但 MNN 在 GPU 后端覆盖面上远超 MACE(四种 GPU API vs OpenCL 一种)。
适用场景与局限
最适合 MNN 的场景
Transformer 类模型的端侧推理。Geometry Compiler 在处理 Reshape/Permute/Split 密集的 Transformer 模型时优势明显,连续的非计算 Op 被合并成少量 Raster 操作,显著减少了内存拷贝开销。
端侧 LLM 推理。MNN 内置了 LLM 推理引擎,支持 KV Cache、Rotary Position Embedding 等 LLM 特有的算子和优化。在端侧跑 ChatGLM、Qwen 等小模型时,MNN 是少数几个可选的框架之一。
端侧训练(Finetune)。MNN 支持在手机上做模型微调——虽然速度远不如服务端,但在隐私敏感场景(用户数据不出端)下有实际价值。ncnn、TNN、MACE 都不支持端侧训练。
需要同时覆盖多个 GPU 后端。如果你的应用需要在 Android(OpenCL/Vulkan)、iOS(Metal)和服务端(CUDA)上都能跑,MNN 是唯一能同时覆盖所有这些平台的移动端引擎。
MNN 的局限
代码复杂度高。约 2000 个文件、C++14+ 风格、大量模板和继承层次——对初学者来说,阅读 MNN 源码的门槛比阅读 ncnn 高得多。如果你的目标是”学习推理引擎的基本原理”,建议先读 ncnn,再读 MNN。
二进制体积较大。由于功能全面、后端众多,MNN 的完整编译产物比 ncnn 大不少。在极度关注安装包体积的场景下,ncnn 或 TNN 是更好的选择。
Geometry Compiler 的开销。Geometry Compiler 在第一次推理时有额外的编译开销(生成 Region、合并 Region)。对于只需要推理一次的场景(冷启动),这个开销可能比 ncnn 的直接执行更大。但如果需要多次推理(热路径),编译开销被摊薄后 MNN 的优势就体现出来了。
NC4HW4 的固定打包限制。在 x86 AVX/AVX-512 平台上,固定 4 打包无法充分利用 256-bit 或 512-bit 寄存器。ncnn 的 elempack=8/16 在这些平台上性能更好。不过对于 MNN 的主要目标平台(ARM 移动端),这不是问题。
代码结构
MNN 的源码组织比 ncnn 复杂得多,约 2000 个文件分布在多层目录结构中:
MNN/source/
├── core/ # 核心抽象
│ ├── Tensor.cpp # Tensor 数据结构
│ ├── Backend.cpp # 后端基类
│ ├── Execution.cpp # 执行基类
│ ├── Pipeline.cpp # 推理流水线
│ ├── Session.cpp # 推理会话
│ ├── Schedule.cpp # 调度器
│ └── Interpreter.cpp # 模型加载与管理
│
├── geometry/ # Geometry Compiler
│ ├── GeometryComputer.cpp # 编译器框架
│ ├── GeometryConcat.cpp # Concat 的降级规则
│ ├── GeometryPermute.cpp # Permute 的降级规则
│ ├── GeometryReshape.cpp # Reshape 的降级规则
│ ├── GeometrySlice.cpp # Slice 的降级规则
│ └── ...
│
├── backend/
│ ├── cpu/ # CPU 后端
│ │ ├── CPUBackend.cpp
│ │ ├── CPUConvolution.cpp
│ │ ├── compute/ # SIMD 内核
│ │ │ ├── ConvolutionPackWinograd.cpp
│ │ │ └── ...
│ │ └── ...
│ ├── opencl/ # OpenCL 后端
│ │ ├── OpenCLBackend.cpp
│ │ ├── execution/
│ │ │ ├── ConvExecution.cpp
│ │ │ └── ...
│ │ └── core/
│ ├── metal/ # Metal 后端 (iOS)
│ ├── vulkan/ # Vulkan 后端
│ └── cuda/ # CUDA 后端
│
├── shape/ # 形状推断
│ ├── ShapeConv2D.cpp
│ ├── ShapePool.cpp
│ └── ...
│
└── optimizer/ # 图优化
├── Merge.cpp # 算子融合
└── ...
MNN/tools/
├── converter/ # 模型转换工具
│ ├── source/onnx/ # ONNX 转 MNN
│ ├── source/tensorflow/ # TF 转 MNN
│ └── source/caffe/ # Caffe 转 MNN
│
└── train/ # 端侧训练
└── source/
关键阅读路径:
- 入门:
core/Tensor.cpp->core/Backend.cpp->core/Pipeline.cpp->core/Session.cpp - 理解 Geometry Compiler:
geometry/GeometryComputer.cpp->geometry/GeometryPermute.cpp(一个具体例子) - 理解 CPU 后端:
backend/cpu/CPUBackend.cpp->backend/cpu/CPUConvolution.cpp - 理解 GPU 后端:
backend/opencl/OpenCLBackend.cpp->backend/opencl/execution/ConvExecution.cpp
小结
MNN 的设计哲学可以用一个词概括:全面性。它试图在一个框架中覆盖尽可能多的场景——多后端(CPU/OpenCL/Metal/Vulkan/CUDA)、多模型类型(CNN/Transformer/LLM)、多功能(推理/训练)。为了实现这种全面性,MNN 引入了比 ncnn 复杂得多的抽象层次:Geometry Compiler 把非计算 Op 降级为 Region 描述、Op-Execution 分离让形状推断和计算解耦、虚拟 Tensor 实现零成本的内存视图、Pipeline 四步流程让推理流水线清晰可控。
它的核心创新 Geometry Compiler 解决了一个真实的工程问题:在算子种类爆炸增长的今天,手写每一个算子的优化内核(ncnn 的做法)已经不可持续。Geometry Compiler 提供了一个自动的”降级路径”,让新算子不需要手写优化就能获得合理的性能,同时保留了对热点算子手动优化的能力。
如果说 ncnn 是一个”做一件事做到极致”的专注型框架,MNN 就是一个”什么都能做”的平台型框架。选择哪一个,取决于你的需求:如果你只需要在 ARM CPU 上跑经典 CV 模型,ncnn 可能是更好的选择(更简单、更快);如果你需要端侧 LLM、端侧训练、多 GPU 后端支持,MNN 是不二之选。
下一章,我们将看到一个更加不同的思路:ONNX Runtime 如何用”标准化 + 插件化”的方式解决推理引擎的跨平台问题。
导读目录:README.md 上一章:Ch11: ncnn——零依赖的手工匠人 下一章:Ch13: ONNX Runtime——标准集装箱港口