Ch08: 内存管理入门——为什么”省内存”比”跑得快”更重要
Part 2: 移动端推理基础(从零开始) 前置章节:Ch07: GPU 计算入门 后续章节:Ch09: 模型格式与转换
你的书桌有多大
想象你在准备期末考试。服务器就像一张巨大的办公桌,面积有好几平方米——你可以把所有课本、笔记、草稿纸、计算器全摊开在桌上,随时拿任何一样东西。手机则是学校食堂里那种窄窄的单人课桌——你只能放下一本课本、一个笔记本和一支笔。如果你要换一门课复习,就必须先把当前的课本收起来,腾出空间放新课本。
更麻烦的是,这张小课桌你还不能独占。桌子的一半已经被食堂的托盘(操作系统和常驻应用)占了,你只能用剩下的一半。如果你的东西堆得太高超过桌边(内存超过系统阈值),食堂阿姨会直接把你的东西全扫到地上(App 被系统杀掉,用户看到的就是闪退)。
这就是移动端推理引擎面临的内存现实。在服务器上,内存是充裕的——256GB 系统内存加上 80GB GPU 显存,你不太需要精打细算。在手机上,8GB 总内存中可用的 2-4GB 就是你的全部预算,超了就死。所以在移动端,“省内存”的优先级往往高于”跑得快”——跑得慢顶多体验差一点,内存不够直接闪退。
推理过程中的三种内存消耗
推理过程中的内存消耗可以分为三大类,就像你的书桌上放着三类东西。
第一类:模型权重——常驻的课本
模型权重是神经网络在训练过程中学到的参数值——卷积核的系数、全连接层的矩阵、BatchNorm 的均值和方差。它们在整个推理过程中不会改变,需要从模型加载开始一直驻留在内存中,直到推理结束。
模型权重的大小取决于模型的参数量和数据精度:
| 模型 | 参数量 | fp32 体积 | int8 体积 |
|---|---|---|---|
| YOLOv8n | 3.2M | ~12.8 MB | ~3.2 MB |
| YOLOv8s | 11.2M | ~44.8 MB | ~11.2 MB |
| MobileNetV2 | 3.4M | ~13.6 MB | ~3.4 MB |
| ResNet-50 | 25.6M | ~102 MB | ~25.6 MB |
| LLaMA-7B | 7B | ~28 GB | INT4: ~3.5 GB |
对于传统的 CNN 模型(YOLOv8n、MobileNet 等),模型权重通常在几 MB 到几十 MB 之间,不是主要的内存瓶颈。但对于大语言模型(LLM),即使用 INT4 量化,7B 参数的模型权重仍然需要约 3.5GB——这已经接近手机可用内存的上限了。
第二类:中间激活值——临时的草稿纸
中间激活值(intermediate activations)是每一层的输出张量——前一层的计算结果,也是后一层的输入。它们是临时的:一旦后续所有依赖这个结果的层都计算完毕,这份中间结果就不再需要了。
中间激活值的大小取决于特征图的空间尺寸和通道数。以 YOLOv8n 为例,输入 640x640 图像时:
第一层输出: [1, 16, 320, 320] = 16 × 320 × 320 × 4 bytes = ~6.3 MB
后续某层: [1, 128, 80, 80] = 128 × 80 × 80 × 4 bytes = ~3.3 MB
更后面: [1, 256, 40, 40] = 256 × 40 × 40 × 4 bytes = ~1.6 MB
单独看每一层的激活值似乎不大,但如果所有层的激活值同时存在于内存中,加起来可能达到 50-100MB。这就是内存优化的主要战场——如何减少同时存在于内存中的激活值总量。
第三类:框架开销——管理用的文具
推理引擎自身也需要一些内存来维护管理结构:计算图的节点和边信息、内存分配器的管理表、线程池、Vulkan 的命令缓冲区和描述符集、OpenCL 的上下文和命令队列等。
这部分开销通常在几 MB 以内,不是主要瓶颈。但在极端场景下(比如用 ncnn 跑一个只有 1MB 权重的超小模型),框架开销可能反而比模型本身还大——这也是 ncnn 追求极简设计的原因之一。
内存峰值:真正的生死线
理解了三种内存消耗后,关键指标就清晰了——内存峰值(Peak Memory)。
内存峰值不是三种消耗的简单相加,而是在推理过程中某一个时刻,内存占用最高的那个点。就像你的书桌空间不是取决于你今天总共用了多少东西,而是取决于你同时摊开最多东西的那一刻——那一刻你的桌子最挤,如果超过桌子面积就会掉东西。
假设一个简单的三层网络:
时间轴 →
层1开始 层1结束/层2开始 层2结束/层3开始 层3结束
| | | |
| 权重 | 权重 | 权重 |
| 层1输出 | 层1输出 | |
| | 层2输出 | 层2输出 |
| | | 层3输出 |
没有内存复用时:
峰值 = 权重 + 层1输出 + 层2输出(在层2计算时,层1输出还没释放)
有内存复用时:
峰值 = 权重 + max(层1输出, 层2输出, 层3输出)
(因为层1输出用完就释放,空间给层3复用)
在没有任何内存优化的情况下,内存峰值出现在”最多中间结果同时存活”的时刻。优化的核心思路就是:尽快释放不再需要的中间结果,让内存空间可以被后续层复用。
为什么峰值如此重要
手机操作系统对 App 的内存管理是严格的。以 iOS 为例,当一个 App 的内存占用超过系统阈值(因机型和当前内存压力而异,通常在 1.5-4GB 之间),系统会发出内存警告。如果 App 不及时释放内存,会被直接终止——这就是 OOM(Out Of Memory)崩溃。
OOM 是移动端推理引擎最常见的崩溃原因。它的隐蔽性在于:在 8GB 内存的测试机上一切正常,到了 4GB 内存的低端机上就频繁闪退。推理引擎的内存优化不是锦上添花——它直接决定了你的 AI 功能能在多大范围的设备上正常运行。
六种内存优化策略
六个推理引擎都在用各种技巧来降低内存峰值。这些策略可以归纳为六种主要方法。
策略一:内存池(Memory Pooling)
问题:频繁向操作系统申请和释放内存(malloc/free)有显著的性能开销——系统需要查找空闲内存块、更新分配表、可能涉及内核态切换。
解决:推理引擎在初始化时一次性向系统申请一大块内存,然后自己在内部管理这块内存的分配和回收。就像图书馆一次性买了 100 个书架,借还书时只改管理表,不用每次都去家具店买新书架。
系统内存
┌──────────────────────────────────────────┐
│ ┌──────────────────────────────────────┐ │
│ │ 推理引擎内存池 │ │
│ │ ┌──────┐ ┌──────┐ ┌──────┐ │ │
│ │ │张量A │ │ 空闲 │ │张量C │ │ │
│ │ └──────┘ └──────┘ └──────┘ │ │
│ └──────────────────────────────────────┘ │
└──────────────────────────────────────────┘
TNN 的 BlobManager 通过 blob_memory_pool_map_ 实现内存池化。MACE 的 MemoryManager 提供了四种租用方式:RENT_PRIVATE(权重独占)、RENT_SHARE(Op 输出间共享)、RENT_SCRATCH(临时缓冲区)、RENT_SLICE(从模型数据零拷贝切片)。
策略二:生命周期复用(Lifetime-based Reuse)
问题:不同层的中间激活值在时间上不会全部重叠——第 1 层的输出在第 3 层之后就不再需要了,但第 3 层的输出此时才刚产生。
解决:分析所有中间张量的”生命周期”(从产生到最后一次被使用),如果两个张量的生命周期不重叠(一个已经”死了”,另一个才”出生”),就让它们共享同一块内存。
时间轴 →
张量 A: |=====生存期=====|
张量 B: |=====生存期=====|
张量 C: |=====生存期=====|
A 和 B 不重叠 → 可以共享同一块内存
B 和 C 不重叠 → 可以共享同一块内存
所以 A、B、C 三个张量只需要一块内存(取三者中的最大值)
这是最基本也是最有效的内存优化策略。ORT 的 SequentialExecutionPlan 在初始化阶段就完成了所有张量的生命周期分析和内存复用规划——运行时只需按计划分配,不做任何额外判断。Paddle-Lite 的 MIR pass 中有专门的 memory_optimize_pass 做同样的事情。MNN 在 Pipeline 阶段统一规划所有 Execution 的内存分配。
策略三:即时释放(ncnn Light Mode)
问题:生命周期复用需要在初始化阶段完成全局分析,对于 ncnn 这种”按需递归”调度的引擎来说,它不预先知道全局的执行顺序。
解决:ncnn 提供了 Light mode——一种更简单粗暴但非常有效的策略。当一个中间 blob 被下游层消费后(即被读取后),立即释放它的内存。不需要全局分析——每个 blob 维护一个引用计数,读取一次减一,到零就释放。
// ncnn Light mode 的核心逻辑(简化版)
// 在 forward_layer 中:
for (每个输入 blob) {
if (input_blob 已计算 && opt.lightmode) {
// 消费完毕后立即释放输入 blob 的内存
blob_mats[input_blob_index] = Mat(); // 释放
}
}
Light mode 的优点是实现简单、不需要预分析、内存释放非常及时。缺点是如果一个 blob 被多个下游层使用(分支结构),需要等所有下游层都消费完才能释放——这需要引用计数来跟踪。
策略四:零拷贝(MNN 虚拟 Region)
问题:Reshape、Transpose、Slice、Concat 这些操作本身不做任何计算——它们只是”换一种方式看同一块数据”。但在传统实现中,这些操作仍然会分配新的内存并拷贝数据。
解决:MNN 的 Geometry Compiler 将这些操作”降级”为 Region 描述——一个虚拟的内存视图映射,只记录”源张量的哪些数据映射到目标张量的哪些位置”,不实际分配内存也不拷贝数据。
// MNN 的 Region 结构
struct Region {
struct View {
int32_t offset; // 起始偏移
int32_t stride[3]; // 每个维度的步长
};
View src, dst; // 源视图和目标视图
int32_t size[3]; // 每个维度的大小
Tensor* origin; // 指向实际持有内存的源张量
};
当一个 Reshape 操作只是改变张量的形状解释(从 [1, 128, 10, 10] 变成 [1, 128, 100])时,底层数据完全不需要移动——只需要创建一个新的 Region 描述即可。所有这些 Region 最终被合并到一个统一的 Raster Op 中批量执行。
这个设计特别适合现代深度学习模型中大量存在的 Reshape/Permute/Slice 操作。在 BERT 类模型中,这些”形状变换”操作可能占算子总数的 30-40%。如果每个都实际拷贝数据,内存占用和计算时间都会显著增加。
策略五:权重共享(MNN 多 Session)
问题:如果需要同时对多个输入进行推理(比如一个视频流的多帧并行处理),每路推理都加载一份完整的模型权重会浪费大量内存。
解决:MNN 支持一个 Interpreter(模型解释器)创建多个 Session(推理会话),所有 Session 共享同一份模型权重,但各自拥有独立的中间激活值。
Interpreter(持有模型权重: 50MB)
├── Session A(独有激活值: 20MB)
├── Session B(独有激活值: 20MB)
└── Session C(独有激活值: 20MB)
不共享: 3 × (50+20) = 210 MB
共享: 50 + 3×20 = 110 MB (节省 48%)
ncnn 的 Extractor 多实例也是类似的设计——多个 Extractor 共享同一个 Net 的模型权重。Paddle-Lite 通过 Clone 方法实现权重共享。
策略六:内存映射(mmap)
问题:加载大模型时,传统方式是 open() + read() 把整个文件读入内存——几个 GB 的模型文件会导致巨大的内存峰值和加载延迟。
解决:使用操作系统的 mmap(Memory-Mapped File)机制。mmap 不是把文件内容拷贝到内存,而是建立文件和进程虚拟地址空间的映射关系。实际的物理内存只在被访问到的页面(通常 4KB 一页)才会加载。
传统 read():
文件(3.5GB) ──完整拷贝──→ 内存(占3.5GB)
mmap():
文件(3.5GB) ──建立映射──→ 虚拟地址空间(3.5GB)
↓ 实际只加载被访问的页
物理内存(按需加载, 可能只用1-2GB)
mmap 的另一个巨大优势是:当系统内存紧张时,mmap 的页面可以被直接回收(evict),因为数据的”原件”还在文件里,随时可以重新加载。这比 read() 方式友好得多——read() 分配的内存如果被回收,数据就丢了,只能再次从头读文件。
这个特性对端侧 LLM 推理非常关键。一个 3.5GB 的 INT4 模型通过 mmap 加载后,系统可以根据当前内存压力动态管理实际驻留在物理内存中的页面数量——不用的权重页面被自动换出,需要时再自动换入。代价是如果换入频率太高(thrashing),推理速度会下降。
内存布局对性能的影响
内存不仅要”省”,还要”排好”。Ch06 已经介绍了 NCHW、NHWC、NC4HW4 三种数据布局对 SIMD 和缓存效率的影响。这里从内存管理的角度补充一个重要视角。
NCHW vs NHWC vs NC4HW4
一个形状为 [N, C, H, W] 的四维张量(N=batch, C=channel, H=height, W=width),在内存中是一维连续存储的。不同的布局决定了哪些维度在内存中相邻。
NCHW(PyTorch 默认):同一 channel 的所有空间像素连续存储。
内存顺序: C0H0W0, C0H0W1, ..., C0H(H-1)W(W-1), C1H0W0, ...
适合: 按空间位置连续扫描(如池化、空间卷积的一部分)
不适合: 跨 channel 访问(如 1x1 卷积,需要同时读多个 channel 的同一位置)
NHWC(TensorFlow 默认):同一空间位置的所有 channel 值连续存储。
内存顺序: H0W0C0, H0W0C1, ..., H0W0C(C-1), H0W1C0, ...
适合: 1x1 卷积(需要读一个位置的所有 channel)
不适合: 空间维度的连续扫描
NC4HW4(MNN/TNN 使用):channel 按 4 个一组打包,组内的 4 个 channel 在同一空间位置连续存储。
内存顺序: [C0-C3]H0W0, [C0-C3]H0W1, ..., [C4-C7]H0W0, ...
综合了 NCHW 和 NHWC 的优势:
- 4 个 channel 连续 → SIMD 友好(一次 vld1q_f32 加载 4 个 channel)
- 同一 channel 组的空间像素连续 → 空间扫描也缓存友好
为什么布局转换有内存开销
推理引擎的输入通常来自用户代码(通常是 NCHW 或 NHWC),但内部计算可能使用 NC4HW4。这就需要在输入阶段做一次布局转换(layout conversion),在输出阶段再转回来。
布局转换需要分配一块与原始张量等大的临时内存(新布局的数据要写到某个地方),并执行一次全量数据搬运。对于大张量,这个开销不可忽视。TNN 的 BlobConverter 专门负责在用户接口的 Mat(NCHW)和内部 Blob(NC4HW4 或 NC8HW8)之间做转换。
MNN 的 Geometry Compiler 在这里展示了优势——对于 Reshape/Transpose 这类”只改布局不改数据”的操作,Geometry Compiler 生成 Region 描述而非实际搬运,大幅减少了布局转换的内存和时间开销。
缓存友好性:为什么连续访问比跳着访问快100倍
Ch06 简要提到了缓存局部性。这里用一个更具体的数字来说明它对推理性能的影响。
现代 ARM 处理器(如 Cortex-X4)的内存层级如下:
L1 数据缓存: 64 KB, 延迟 ~1 ns (约 4 时钟周期)
L2 缓存: 512 KB, 延迟 ~5 ns (约 15 时钟周期)
L3 缓存: 8 MB, 延迟 ~15 ns (约 50 时钟周期)
主内存 (LPDDR5X): 延迟 ~33 ns (约 100 时钟周期)
从 L1 缓存读数据只要 4 个时钟周期,从主内存读要 100 个——差 25 倍。如果你的计算是内存受限的(memory-bound,即等数据的时间比算数的时间长),缓存命中率决定了性能的上限。
什么情况下缓存命中率高?连续访问。CPU 每次从主内存加载数据时,不是只加载你要的那一个字节,而是加载整个缓存行(ARM 上是 64 字节 = 16 个 float32)。如果你接下来访问的数据恰好在这 64 字节之内(连续内存),就是免费的缓存命中。如果你跳到一个完全不同的地址,就是一次昂贵的缓存未命中。
这就是 NC4HW4 布局比 NCHW 布局在卷积运算中更快的根本原因——卷积核扫描同一个空间位置的多个 channel 时,NC4HW4 让这些 channel 的数据连续存储(缓存友好),而 NCHW 让它们相隔 H×W 个元素(缓存不友好)。
数据预取(Prefetch)
更进一步的优化是”预取”——在你实际需要数据之前,提前告诉 CPU”过一会儿我要用地址 X 的数据,现在就开始从内存加载到缓存里”。这样当你真正读取时,数据已经在缓存中了,不需要等 100 个时钟周期。
ncnn 的手写 NEON 算子中会使用 __builtin_prefetch() 指令来实现预取。一个典型的模式是:在当前循环迭代中预取下一次迭代需要的数据。
// 预取优化的循环模式
for (int i = 0; i < n; i += 4) {
__builtin_prefetch(a + i + 16); // 预取下一轮的数据
float32x4_t va = vld1q_f32(a + i); // 当前轮的数据(已在缓存中)
float32x4_t vb = vld1q_f32(b + i);
float32x4_t vc = vmlaq_f32(sum, va, vb);
// ...
}
实际案例:YOLOv8n 的内存占用分析
用一个真实的模型来综合理解内存管理。以 YOLOv8n 为例(输入 640x640,fp32 精度)。
模型权重
YOLOv8n 有约 320 万参数,fp32 精度下权重体积约 12.8 MB。如果量化到 INT8,降为约 3.2 MB。权重在推理全程常驻内存。
中间激活值
YOLOv8n 的骨干网络(backbone)逐层的输出尺寸和内存占用(fp32):
层 输出形状 单层激活值大小
stem [1, 16, 320, 320] ~6.3 MB
P1 [1, 32, 160, 160] ~3.1 MB
P2 [1, 64, 80, 80] ~1.6 MB
P3 [1, 128, 40, 40] ~0.8 MB
P4 [1, 256, 20, 20] ~0.4 MB
如果所有激活值同时存在,总量约 12-15 MB。但通过生命周期复用(stem 的输出在 P1 之后就不需要了),峰值激活值可以降到约 8-10 MB。
峰值内存估算
不优化: 权重(12.8MB) + 所有激活值(~15MB) + 框架开销(~2MB) ≈ 30 MB
生命周期复用: 权重(12.8MB) + 峰值激活值(~8MB) + 框架开销(~2MB) ≈ 23 MB
INT8量化: 权重(3.2MB) + 峰值激活值(~4MB) + 框架开销(~2MB) ≈ 9 MB
从 30MB 优化到 9MB,内存占用降低了 70%。对于 YOLOv8n 这种小模型来说,30MB 在手机上完全不是问题。但对于更大的模型(如 YOLOv8x 约 68M 参数,或者 LLM 模型),内存优化的价值就极其显著了。
对比 LLM 场景
以 LLaMA-7B INT4 量化为例:
权重: ~3.5 GB(模型参数)
KV-Cache: 取决于序列长度,2048 token 约 ~1 GB
激活值: 单层峰值约 ~100 MB
框架开销: ~50 MB
总峰值: 约 4.5-5 GB
在 8GB 内存的手机上(可用约 3-4GB),这已经非常紧张了。mmap 的按需加载特性在这个场景下几乎是必需的——让系统自动管理哪些权重页驻留在物理内存中,而非一次性全部加载。
各引擎内存管理策略总结
| 策略 | ncnn | MNN | ORT | TNN | Paddle-Lite | MACE |
|---|---|---|---|---|---|---|
| 内存池 | 简单 allocator | 内置 | Workspace | BlobManager pool | Buffer lazy alloc | MemoryManager |
| 生命周期复用 | Light mode 隐式 | Pipeline 规划 | SequentialPlan | BlobManager | memory_optimize_pass | RENT_SHARE |
| 即时释放 | Light mode | - | - | - | - | - |
| 零拷贝 | channel_range 视图 | 虚拟 Region | - | BlobHandle offset | Buffer offset | - |
| 权重共享 | Extractor 多实例 | 多 Session | 多 Session | 多 Instance | Clone | 多 Flow |
| mmap | 支持 | 支持 | 支持 | 支持 | 支持 | 支持 |
ncnn 的特色是 Light mode 的即时释放——简单粗暴但非常有效,不需要预分析就能获得很低的峰值内存。MNN 的特色是虚拟 Region 的零拷贝——对 Reshape/Transpose 密集的模型效果显著。ORT 的 SequentialExecutionPlan 是最”正统”的编译器式内存优化——在初始化时做完全局分析,运行时零开销。
本章小结
内存管理是移动端推理引擎中优先级最高的优化维度——比速度还重要,因为内存不够直接导致 App 闪退。推理过程消耗三种内存:模型权重(常驻)、中间激活值(临时)、框架开销(管理)。内存峰值是真正的生死线——超过系统阈值,App 被杀。
六种主要优化策略分别从不同角度降低峰值内存:内存池减少分配开销,生命周期复用让不重叠的张量共享内存,即时释放让用完的数据立即回收,零拷贝避免不必要的数据搬运,权重共享让多路推理复用参数,mmap 让大模型按需加载。
数据布局(NCHW/NHWC/NC4HW4)不仅影响 SIMD 效率,也影响缓存命中率——连续访问比跳着访问快 25 倍。推理引擎在数据布局的选择和转换上花费了大量工程精力。
下一章将讨论模型格式——推理引擎的”菜谱”具体长什么样,以及从 PyTorch 的模型文件到手机上能读懂的格式,中间要经过什么样的转换。
导读目录:README.md 上一章:Ch07: GPU 计算入门——从渲染管线到通用计算 下一章:Ch09: 模型格式与转换——从 PyTorch 到手机的桥梁