Ch04: 神经网络推理的本质——从训练到部署
Part 2: 移动端推理基础(从零开始) 前置章节:Ch03: 前置知识检查清单 后续章节:Ch05: 为什么手机和服务器不一样
先讲一个做菜的故事
假设你是一家餐厅的主厨,正在研发一道新菜。研发阶段你会反复试错:放多少盐?火候多大?调料比例怎么调?每做一次,你尝一口,觉得”太咸了”就减盐,觉得”不够香”就加料。经过几十次甚至几百次试错,你终于找到了最完美的配方——盐3克、糖2克、酱油5毫升、大火翻炒90秒。
研发完成后,你把这个配方写成一张标准化菜谱卡,交给后厨的厨师们。他们不需要再试错了,只需要严格按照菜谱上的参数执行:称3克盐、量5毫升酱油、计时90秒。每一道端上桌的菜都应该味道一致。
这个故事就是深度学习”训练”与”推理”的本质区别。训练是”研发菜品”的过程——反复调整参数直到找到最优组合;推理是”按菜谱做菜”的过程——拿着已经确定好的参数,对新输入执行固定的计算流程,产出结果。
整本导读要讲的”推理引擎”,就是那个帮你在手机上”按菜谱做菜”的工具。它不关心菜谱是怎么研发出来的,它只关心一件事:怎么用最少的时间、最少的资源,把这道菜做出来。
训练 vs 推理:四个本质区别
训练和推理虽然都在运行同一个神经网络,但它们的运行模式截然不同。理解这些区别是理解”为什么需要专门的推理引擎”的前提。
区别一:方向不同——单程票 vs 往返票
训练是一个”往返”的过程。数据先从输入层流到输出层(前向传播),计算出预测结果;然后把预测结果和正确答案的差距(损失值)从输出层倒推回输入层(反向传播),计算每个参数应该怎么调整。这就像你做完菜后尝了一口,发现太咸,然后倒推”盐放多了0.5克”这个信息回去修改配方。
推理只有”单程”——数据从输入层流到输出层,计算出结果就结束了。没有反向传播,没有参数调整。这意味着推理的计算量大约只有训练的一半(甚至更少,因为反向传播通常比前向传播更耗时)。更重要的是,推理不需要存储中间层的梯度信息,内存需求也大幅降低。
回到做菜的类比:研发阶段你需要记住”这次放了多少盐、结果怎样”,这样才能调整下一次的用量。但按菜谱做菜时你不需要记这些——做完就做完了,端上桌就行。
区别二:精度要求不同——显微镜 vs 放大镜
训练时需要非常高的数值精度——通常使用 fp32(32位浮点数,也叫单精度)。为什么?因为反向传播计算的梯度值往往非常小,比如0.000001这样的数字。如果精度不够,这些微小的梯度就会被”四舍五入”成0,参数就调不动了——这叫做”梯度消失”。
推理就没这个限制了。既然不需要计算梯度,我们可以用更低的精度来表示数据和权重:fp16(16位浮点数,范围和精度都更小但够用)甚至 int8(8位整数,只有256个可能的值)。用更低精度意味着:
- 每个数字占的内存更小(int8 只要 fp32 的四分之一)
- 计算更快(CPU 一次能处理更多低精度数字)
- 模型文件更小(同样的权重参数,int8 格式只有 fp32 的四分之一大小)
这就是为什么后面会有一整章(Ch10)专门讲”量化”——把 fp32 模型压缩成 int8 模型的技术。现在只需要知道:推理可以容忍精度损失,而这个特性是移动端部署的关键优化空间。
区别三:批大小不同——大锅饭 vs 小炒
训练时通常一次处理一大批数据(batch size = 32、64 甚至 256),因为GPU擅长并行处理大量相似的计算,大批量能让GPU的利用率更高——就像食堂做饭,一次炒一大锅比一份一份地炒效率高得多。
推理时通常一次只处理一张图片或一句话(batch size = 1)。原因很简单:用户拍了一张照片想知道这是什么花,他不会等你凑齐64张照片再一起识别。实时性是推理场景的核心需求。
这个区别直接影响了优化策略。训练主要优化”吞吐量”(单位时间处理多少数据),推理主要优化”延迟”(一条数据从输入到输出要多久)。同一个卷积算子,在 batch=64 时最优的实现方式,和在 batch=1 时最优的实现方式,可能完全不同。
区别四:运行环境不同——豪华厨房 vs 路边摊
训练通常在高配服务器上进行:一台训练服务器可能配备 8 张 NVIDIA A100 GPU(每张80GB显存、312 TFLOPS 算力),256GB 系统内存,不限电力,有专业水冷散热。这就像一个设备齐全的五星级酒店厨房。
推理——特别是我们关注的移动端推理——发生在用户的手机上。一部 2024 年的旗舰手机有 8GB 内存(其中大部分被系统和其他 App 占用),骁龙 8 Gen3 的 AI 算力约 45 TOPS,整机功耗只有 3-5W,被动散热(没有风扇),温度高了还会降频。这就像在路边摊做菜——灶台小、火力弱、还怕热。
这个巨大的环境差异,是移动端推理引擎存在的根本原因。我们不能简单地把训练框架搬到手机上——就像你不能把五星级酒店的工业灶台搬到路边摊上一样。我们需要专门为”路边摊”设计的灶具:更小巧、更省燃气、更适应露天环境。
| 维度 | 训练 | 推理(移动端) |
|---|---|---|
| 方向 | 前向+反向 | 仅前向 |
| 精度 | fp32 | fp32/fp16/int8 |
| 批大小 | 32-256 | 通常 1 |
| 设备 | 服务器GPU集群 | 手机/嵌入式 |
| 内存 | 256-512GB | 4-8GB(可用 2-4GB) |
| 算力 | 312+ TFLOPS (A100) | ~45 TOPS (骁龙8 Gen3) |
| 功耗 | 300W+/GPU | 整机 3-5W |
| 散热 | 风扇/水冷 | 被动散热 |
| 优化目标 | 吞吐量(数据/秒) | 延迟(毫秒/次) |
计算图:神经网络的”菜谱”长什么样
前面反复提到”按菜谱做菜”,那这个”菜谱”到底长什么样?在深度学习中,这个菜谱就是计算图(Computational Graph)。
想象你要做一顿丰盛的早餐:煎蛋、烤面包、煮咖啡、摆盘上桌。你可以画一张流程图来描述整个过程。流程图里有两种东西:一种是”操作”(打蛋、倒油、开火……),用方框表示;另一种是”数据”(鸡蛋、食用油、面包片……),用箭头表示数据从一个操作流向下一个操作。
计算图就是这样一张流程图。在神经网络中:
- 节点(Node)代表操作(算子),比如卷积、加法、激活函数
- 边(Edge)代表数据流动方向,连接前一个操作的输出和后一个操作的输入
- 张量(Tensor)是沿着边流动的数据——你可以简单理解为多维数组
一个简单的图像分类网络的计算图可能长这样:
输入图片 [3, 224, 224]
↓
[卷积] → 提取图像特征
↓
[BatchNorm] → 归一化特征值
↓
[ReLU] → 过滤负值
↓
[池化] → 压缩空间尺寸
↓
[全连接] → 将特征映射到类别
↓
[Softmax] → 输出概率分布
↓
输出: [猫: 0.92, 狗: 0.05, 鸟: 0.03]
为什么”计算图”这个概念重要?因为推理引擎做的第一件事,就是把你的模型文件解析成一张计算图。然后它可以对这张图做各种优化:合并节点(把卷积和BatchNorm合成一步)、删除无用节点、重排执行顺序。这些优化是推理引擎的核心价值之一,后续章节(Ch21)会详细展开。
常见算子:计算图里的”基本操作”
计算图中的每个节点是一个”算子(Operator,简称 Op)”。就像做菜有”切、炒、煮、蒸”几种基本操作一样,神经网络也有一组基本算子。理解最常见的几个,对后续章节的阅读很有帮助。
卷积(Convolution)是神经网络中计算量最大的算子,通常占总计算量的 70%-90%。它的作用是用一个小窗口(卷积核,通常 3x3 或 1x1)在输入图像上滑动,每滑到一个位置就做一次乘加运算,提取局部特征。可以类比为”用放大镜扫描一张照片的每个角落,在每个位置总结一下看到了什么”。卷积是六个推理引擎花最多精力优化的算子——ncnn 的 Convolution_arm.cpp 一个文件里就包含了五六种不同的卷积实现策略。
全连接(Fully Connected / Dense)把前面所有的特征”拉平”成一个一维向量,然后做一次大矩阵乘法。作用是把空间上的局部特征汇总成全局的判断依据。好比”把放大镜看到的所有细节汇总到一张报告里”。本质上就是矩阵乘法(MatMul),这是另一个推理引擎重点优化的算子。
池化(Pooling)用一个小窗口(比如 2x2)在特征图上滑动,每个窗口只保留一个值(最大值或平均值)。效果是把特征图的长宽各缩小一半,减少后续计算量。类比”把一张1000万像素的照片缩小到250万像素——关键信息还在,但数据量小了很多”。
激活函数(Activation)对每个数值做一个简单的非线性变换。最常见的是 ReLU(Rectified Linear Unit),它做的事情极其简单:负数变成0,正数不变。数学表达就是 output = max(0, input)。虽然简单,但没有它的话,无论你叠多少层网络,最终效果都等价于一个线性变换——加上激活函数才能让网络学到非线性的复杂模式。
BatchNorm(批归一化)把每一层的输出值”校准”到均值为0、方差为1的标准分布上,防止数值在层与层之间越来越大(爆炸)或越来越小(消失)。有一个重要的优化常识:推理阶段的 BatchNorm 可以和前面的卷积层”合并”成一个操作(称为”BN折叠”或”BN融合”),不需要单独执行。这是几乎所有推理引擎都会做的基础图优化。
Softmax 把一组任意实数转换成概率分布——所有值在 0 到 1 之间且总和为 1。通常出现在分类网络的最后一层,告诉你”这张图是猫的概率 92%,是狗的概率 5%”。
以上六种算子覆盖了绝大多数经典 CNN(卷积神经网络)模型的计算。现代的 Transformer 架构还会大量使用 MatMul、LayerNorm、Attention 等算子,但基本原理是一样的——都是计算图中的节点,输入张量、执行运算、输出张量。
前向传播的实际过程
理解了计算图和常见算子,现在可以看看”推理”到底是怎么一步步执行的了。
前向传播(Forward Pass)就是数据从计算图的输入端一路流到输出端的过程。假设我们有一个简单的三层网络要对一张 224x224 的 RGB 图片做分类:
输入: [1, 3, 224, 224] → 1张图片, 3个颜色通道, 224x224像素
↓
Conv1: 64个3x3卷积核 → 输出 [1, 64, 224, 224]
↓
ReLU → 输出 [1, 64, 224, 224](负值变0)
↓
MaxPool: 2x2 → 输出 [1, 64, 112, 112](尺寸减半)
↓
Conv2: 128个3x3卷积核 → 输出 [1, 128, 112, 112]
↓
ReLU → 输出 [1, 128, 112, 112]
↓
GlobalAvgPool → 输出 [1, 128, 1, 1](全局平均)
↓
Flatten → 输出 [1, 128]
↓
FullyConnected: 1000 → 输出 [1, 1000](1000个类别的分数)
↓
Softmax → 输出 [1, 1000](1000个类别的概率)
每一层做的事情本质上都是一次或几次矩阵运算。以第一个卷积层 Conv1 为例,它要执行大约 1.7 亿次乘加运算(FLOPs)——这只是一层。一个完整的 ResNet-50 网络有约 40 亿次 FLOPs。在骁龙 8 Gen3 的 CPU 上以 fp32 精度跑 ResNet-50 的单帧推理大约需要 30-50 毫秒。
这里有一个关键观察:前向传播的执行顺序是确定的——前一层的输出就是后一层的输入,必须严格按顺序来。但中间结果是临时的——一旦后一层读取了前一层的输出,前一层的输出就不再需要了。这个特性是内存优化的基础(Ch08 会详细讲):我们可以在后一层开始计算后,立即释放前一层输出所占的内存。ncnn 的 Light mode 就是这么做的。
推理引擎的职责
把上面的过程归纳一下,一个推理引擎需要做四件事,环环相扣。
第一步:加载模型。 推理引擎需要读取模型文件(不同引擎用不同的格式,Ch09 详解),解析出计算图的结构(哪些算子、怎么连接)和权重数据(每个算子的参数值)。这一步类似于”读懂菜谱”。模型文件可能只有几MB(YOLOv8n 约 6MB),也可能几个GB(LLM 模型)。
第二步:分配内存。 推理引擎需要为所有中间结果(激活值)分配内存空间。聪明的引擎不会给每个中间结果都分配独立的内存——它会分析哪些中间结果的”生命周期”不重叠,让它们共用同一块内存。这就像一个小餐厅只有三个盘子,但通过合理安排上菜顺序(吃完前菜洗盘子再装主菜),照样能服务一桌完整的宴席。
第三步:调度计算。 推理引擎按照计算图的依赖关系,一层一层(或更聪明地)执行每个算子。每个算子的执行要调用对应硬件上的优化实现——ARM CPU 上调用 NEON 优化版本,GPU 上调用 Compute Shader 版本。调度策略因引擎而异:ncnn 用按需递归(从输出反推需要哪些层),MNN 用 Pipeline 顺序执行,Paddle-Lite 用编译好的指令序列逐条执行。
第四步:返回结果。 推理完成后,把输出张量的数据返回给调用者。对于图像分类就是一个概率向量,对于目标检测就是一组检测框的坐标和类别。
用 ncnn 的代码来看,这四步在 API 层面非常简洁:
ncnn::Net net;
net.load_param("model.param"); // 第一步:加载计算图结构
net.load_model("model.bin"); // 第一步:加载权重数据
ncnn::Extractor ex = net.create_extractor();
ex.input("input", input_mat); // 设置输入数据
ex.extract("output", output_mat); // 第二三四步:触发推理,返回结果
// ncnn 在 extract 时才真正执行计算(按需递归)
这四行代码背后,是推理引擎数万行的底层优化:内存池、SIMD向量化、算子融合、缓存优化、多线程调度。整本导读后续的二十多章,本质上都是在拆解这四行代码背后发生了什么。
为什么不直接用 PyTorch 做推理
到这里你可能会问:既然推理就是跑一遍前向传播,PyTorch 本身不就能做吗?为什么还需要 ncnn、MNN 这些专门的推理引擎?
确实,PyTorch 可以做推理。但在移动端场景下,直接用 PyTorch 有四个致命问题。
问题一:体积太大。 PyTorch 的运行时(runtime)包括 Python 解释器、libtorch 库、各种依赖库(NumPy、Protobuf等),安装包超过 100MB。而 ncnn 的核心库只有约 1MB(静态编译后甚至可以更小),因为它零第三方依赖——不依赖 Protobuf、不依赖 Eigen、不依赖 BLAS,所有代码手写。一个手机 App 如果因为 AI 功能就增加 100MB 体积,用户下载意愿会大幅下降。
问题二:启动太慢。 PyTorch 需要初始化 Python 运行时、导入各种模块、构建计算图。在服务器上这个过程可能只需要几秒,但在手机上可能需要十几秒甚至更久——用户打开相机准备拍照识别花卉,等十几秒才能用?不可接受。ncnn 的模型加载通常在 10 毫秒以内完成。
问题三:依赖关系复杂。 PyTorch 依赖 Python 运行时,而手机操作系统(Android/iOS)的原生开发语言是 Java/Kotlin/Swift/C++。在 App 中嵌入一个完整的 Python 运行时不仅增大体积,还引入大量兼容性问题。ncnn 是纯 C++ 库,可以直接通过 JNI(Java Native Interface)集成到 Android App 中,或直接在 iOS 项目中链接。
问题四:缺乏移动端专项优化。 PyTorch 的算子实现主要针对服务器端 GPU(CUDA)优化,对 ARM CPU 的 NEON 指令、移动 GPU 的 Compute Shader、NPU/DSP 等移动端专属硬件几乎没有优化。ncnn 的每个关键算子都有手写的 ARM NEON 版本,MNN 有专门的 Metal/OpenCL 后端,MACE 甚至针对高通 Hexagon DSP 做了整图下推优化。
| 维度 | PyTorch | ncnn(推理引擎) |
|---|---|---|
| 核心库体积 | >100MB | ~1MB |
| 模型加载时间 | 秒级 | 毫秒级 |
| 语言依赖 | Python | 纯 C++ |
| ARM NEON 优化 | 基础 | 手写极致优化 |
| 移动 GPU 支持 | 无 | Vulkan/OpenCL/Metal |
| NPU 支持 | 无 | 部分引擎支持 |
| 内存优化 | 通用 | 移动端专项(Light mode 等) |
用一句话总结:PyTorch 是”什么都能做的通用工具箱”,推理引擎是”为特定场景打造的专业工具”。在服务器上用 PyTorch 做推理完全可以(事实上很多线上服务就这么做,或用 TorchScript/Torch-TensorRT 加速),但在手机上,你需要推理引擎。
训练框架 vs 推理引擎的分工
理解了上面的区别后,可以看到整个深度学习的工作流形成了一个清晰的分工:
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ 训练框架 │ │ 模型转换工具 │ │ 推理引擎 │
│ (PyTorch/TF) │ ──→ │ (ONNX/PNNX) │ ──→ │ (ncnn/MNN/ORT) │
│ │ │ │ │ │
│ 研发菜品 │ │ 翻译菜谱 │ │ 按菜谱做菜 │
│ 服务器上运行 │ │ 格式转换桥梁 │ │ 手机上运行 │
│ fp32 高精度 │ │ 图优化+量化 │ │ fp16/int8 高效率 │
└────────────────────┘ └────────────────────┘ └────────────────────┘
训练框架负责用大量数据”教”出一个好模型(确定最优参数)。模型转换工具负责把训练框架的模型格式”翻译”成推理引擎能读懂的格式,同时做一些优化(算子融合、量化等)。推理引擎负责在目标设备上高效地执行推理。
这个分工的核心洞察是:训练和推理的需求差异太大,试图用一个框架同时满足两者,要么训练性能打折扣,要么推理性能打折扣。专业化分工让每个环节都能做到极致。
不过也有例外——MNN 试图打通这个链路,在一个框架中同时支持推理和端侧训练。这种”全栈”路线有其独特价值(比如在手机上做模型微调),但代价是代码量和复杂度显著增加(MNN 约 2000 个源文件,而 ncnn 只有约 500 个)。
推理的性能衡量标准
既然推理引擎的核心工作是”高效执行前向传播”,那怎么衡量”高效”呢?移动端推理有三个最重要的指标。
延迟(Latency):一次推理从输入到输出需要多少时间,通常以毫秒为单位。对实时应用来说,延迟是最核心的指标。比如手机上的实时目标检测,一般要求延迟 < 33ms(即帧率 > 30fps)。延迟又可以细分为:冷启动延迟(第一次推理,包含模型加载)和热推理延迟(模型已加载,只是执行计算)。
内存占用(Memory Footprint):推理过程中消耗的总内存,通常包括模型权重(常驻内存)和中间激活值(临时内存)。在移动端,内存是最稀缺的资源——超过系统阈值,App 会被强制杀死(OOM,Out Of Memory)。Ch08 会专门讨论这个话题。
功耗(Power Consumption):推理消耗的电量。这个指标在服务器端几乎没人关心(反正插着电),但在手机上至关重要。用户不希望用了一下 AI 功能,电量就掉了 10%。功耗和计算量、硬件选择(CPU vs GPU vs NPU)、运行频率都有关系。
这三个指标往往互相矛盾:要更低延迟就要更高频率运行,但频率高了功耗就大;要更省内存就要减少缓存和预计算,但这可能增加延迟。推理引擎的设计本质上就是在这三者之间寻找最佳平衡点。
本章小结
本章建立了几个核心概念。第一,训练是”研发菜品”(往返传播、高精度、大批量、服务器),推理是”按菜谱做菜”(单向传播、可低精度、单条数据、用户设备)。第二,计算图是神经网络
导读目录:README.md 上一章:Ch03: 前置知识检查清单 下一章:Ch05: 为什么手机和服务器不一样——移动端约束全解