3. 内存子系统优化:让数据在芯片里跑得更顺畅

内存优化这事儿,说白了就是解决一个核心矛盾——计算单元饿不死,但数据喂不饱。我刚开始做模型移植时,总觉得算子优化到位就万事大吉,结果一跑起来,性能惨不忍睹。后来才明白,很多时候瓶颈不在算力,而在内存。

这一章,我们重点聊三个方向:内存层级结构怎么用、数据布局怎么摆、内存怎么复用。搞懂了这些,你的模型在芯片上才能真正跑起来。

3.1 内存层级结构分析

先看一张图,帮你快速建立整体认知。

AI芯片内存层级结构 寄存器 (Register) 片上SRAM / 共享内存 L2 Cache / 全局内存 DDR / HBM 外部存储 关键认知: • 越靠近计算单元,容量越小、速度越快、带宽越高 • 数据搬运的代价:从DDR读一次数据,够寄存器算几百次

这张图你应该不陌生。但我想强调一个容易被忽略的点:每一层之间的带宽差异,往往在10倍以上。我见过不少项目,算子本身优化得挺好,但数据在DDR和SRAM之间来回倒腾,性能直接腰斩。

3.2 数据布局优化:NCHW vs NHWC

数据布局,说白了就是张量在内存里怎么排排坐。两种主流格式:NCHW和NHWC。别看只是维度顺序不同,对性能的影响天差地别。

对比维度 NCHW NHWC
内存访问模式 通道连续,空间不连续 空间连续,通道不连续
卷积计算 适合GPU cuDNN,向量化友好 适合CPU,缓存命中率高
BN/激活层 通道维度连续,计算高效 需要转置或特殊处理
硬件偏好 NVIDIA GPU、寒武纪 高通、联发科、ARM CPU

我个人习惯,在项目开始前先搞清楚目标芯片的偏好。比如我之前移植一个分类模型到高通平台,默认用了NCHW,结果跑起来比预期慢了一倍。改成NHWC后,性能直接翻回来。为什么?因为高通DSP的向量单元,对空间连续的数据访问效率更高。

核心原则:数据布局没有的好坏,关键看你的计算单元怎么访存。如果卷积核是1x1,NHWC反而更优,因为通道维度可以并行计算。

来看一个实际转换的例子:

// PyTorch默认NCHW: [1, 3, 224, 224]
// 转成NHWC: [1, 224, 224, 3]

import torch

tensor_nchw = torch.randn(1, 3, 224, 224)
tensor_nhwc = tensor_nchw.permute(0, 2, 3, 1).contiguous()

// 检查内存布局
print(tensor_nhwc.stride())  // (150528, 1, 672, 224)

注意那个contiguous()调用。嗯,这里有个坑——permute只是改变了视图,内存还是原来的顺序。必须显式调用contiguous,才能真正重排数据。我曾经因为这个疏忽,调试了整整一个下午。

3.3 内存池与复用机制

模型推理时,每一层都要分配和释放内存。频繁的malloc/free,不仅慢,还会产生碎片。你想想看,嵌入式设备内存本来就紧张,碎片一多,后面的大张量可能就分配不出来了。

解决方案就是内存池。提前申请一大块连续内存,然后在池子里做分配和回收。

我的经验:内存池的大小,建议设为模型最大中间张量总和的1.2倍。太小了容易溢出,太大了浪费。我一般先跑一遍profile,统计所有层的输出大小,再定池大小。

内存复用的核心思路:生命周期不重叠的张量,可以共用同一块内存。比如卷积层的输出,在进入激活函数后就不再需要了,那激活函数的输出就可以复用这块内存。

来看一个简单的内存池实现思路:

// 伪代码:内存池管理器
class MemoryPool {
    void* base_ptr;      // 池基地址
    size_t pool_size;    // 池大小
    size_t offset;       // 当前分配偏移

    void* alloc(size_t size) {
        // 对齐到64字节(缓存行对齐)
        size = align_up(size, 64);
        if (offset + size > pool_size) return nullptr;
        void* ptr = base_ptr + offset;
        offset += size;
        return ptr;
    }

    void reset() {
        offset = 0;  // 一轮推理结束,重置偏移
    }
};

实际项目中,我更喜欢用双缓冲机制。什么意思?就是准备两块内存,一块给当前层读,一块给下一层写。这样读写可以流水线并行,不用等上一块内存释放。

注意:双缓冲虽然快,但内存占用翻倍。如果芯片内存吃紧,建议用单缓冲+原地计算。比如某些激活函数(ReLU、LeakyReLU)可以直接修改输入数据,不需要额外内存。

最后,分享一个我踩过的坑。有一次做端侧部署,模型里有个超大特征图(1x128x128x256),我按常规思路做了内存池,结果跑起来总是崩。排查了半天,发现是内存对齐的问题——芯片的DMA要求地址64字节对齐,我分配时没注意。加上对齐后,问题解决。

所以,内存对齐不是小事。不同芯片要求不同,有的32字节,有的128字节。建议在内存池的alloc函数里,统一做对齐处理。

内存复用流程示意 输入张量 Conv输出 ReLU输出 Pool输出 Conv输出 → ReLU输出 可复用同一块内存 • Conv输出在ReLU计算完成后即可释放 • ReLU输出可直接覆盖Conv输出的内存区域 • 内存复用率越高,整体内存占用越低

好了,这一章的内容就这些。内存优化是个细活,需要你对芯片的架构、数据的流向、内存的分配都有清晰的认识。多跑profile,多看内存分布图,慢慢你就能找到感觉。