推理模型的内存访问模式
做嵌入式推理优化,说白了就是在跟内存打交道。我刚开始接触这行时,总觉得算力才是瓶颈,后来踩了不少坑才明白——内存访问的效率,往往决定了推理速度的天花板。今天咱们就聊聊推理模型里几种核心算子的访存特征,以及它们怎么影响缓存命中率。
卷积层的内存访问特征
卷积层是CNN里最耗时的部分,没有之一。它的访存模式有个显著特点:数据重用率极高。
你想想看,一个3x3的卷积核在输入特征图上滑动,相邻两次计算之间,输入数据有大量重叠。我习惯把这种现象叫做「滑动窗口的局部性红利」。具体来说:
- 输入特征图:每个像素会被多个输出通道的卷积核反复读取
- 权重:同一个卷积核会在空间位置上反复使用
- 输出特征图:写操作是顺序的,但累加过程需要频繁读回
我在项目中遇到过一个问题:某个轻量级模型在Cortex-M7上跑,卷积层占用了80%的时间。一分析才发现,输入特征图是按CHW格式存储的,但卷积核是按HWC顺序访问的——每次读取都跨行,缓存行利用率不到20%。后来改成NHWC格式,速度直接翻倍。
关键结论:卷积层的缓存优化,核心在于数据布局与访问模式的匹配。NHWC格式对缓存更友好,因为相邻通道的像素在内存中是连续的。
全连接层的访存模式
全连接层就完全是另一回事了。它的访存模式可以用四个字概括:带宽饥渴。
为什么这么说?因为全连接层的计算本质是矩阵乘法,每个输出神经元都要读取全部输入特征。这意味着:
- 输入向量会被反复读取N次(N是输出神经元个数)
- 权重矩阵的访问是行优先或列优先,取决于存储格式
- 没有空间局部性可言——每次读取都是新的数据
我曾经调试过一个语音识别模型,全连接层占了推理时间的60%。一开始我以为是计算量太大,后来用性能计数器一看,缓存缺失率高达85%。权重矩阵是1024x1024的float32,4MB大小,远超L1缓存。每次计算都要从DDR里搬数据,能不慢吗?
我的建议:对于全连接层,可以考虑权重量化(int8甚至int4),把4MB压缩到1MB以内,让权重尽可能留在L2缓存里。另外,批处理也能提升缓存利用率——多个输入共享同一份权重。
激活函数与池化层的访存特点
激活函数和池化层,虽然计算量不大,但访存模式同样值得关注。
激活函数
ReLU、Sigmoid这些激活函数,都是逐元素操作。它们的访存模式很简单:读一个元素,算一下,写回去。但这里有个坑——很多框架会把激活函数单独实现为一个kernel,这意味着数据要从缓存里读出来,算完再写回去,然后下一个算子再读一遍。
嗯,这里要注意:算子融合是解决这个问题的关键。我习惯把ReLU直接融合到卷积层里,这样卷积计算完直接做激活,数据不用离开寄存器。我在一个项目里做过测试,单独做ReLU kernel比融合版本慢了30%——纯粹是访存开销。
池化层
池化层的访存模式和卷积类似,但有个区别:池化没有权重,只有输入和输出。最大池化只需要比较,平均池化需要累加。它们的共同特点是:
- 输入数据的读取是非连续的(尤其是步长大于1时)
- 输出数据的写入是稀疏的(空间维度缩小)
- 池化窗口越大,数据重用率越高
我曾经在优化一个图像分类模型时,发现2x2最大池化层的缓存命中率只有40%。原因很简单:输入特征图是按行存储的,但池化窗口是2x2,每次读取两行数据,第二行的数据在缓存里已经被挤出去了。后来我把池化层的实现改成按块处理,一次处理4x4的区域,命中率提升到了75%。
避坑指南:我曾经在池化层上犯过一个低级错误——用浮点除法实现平均池化。在Cortex-M4上,浮点除法要20多个周期。后来改成乘以倒数(1/4 = 0.25),速度提升了5倍。记住:除法能免则免。
张量形状对缓存的影响
张量的形状,说白了就是数据在内存里的排列方式。它对缓存命中率的影响,往往被初学者忽略。我把它总结为三个关键维度:
| 维度 | 影响 | 优化建议 |
|---|---|---|
| 通道数C | 决定数据在内存中的跨度 | 尽量让C是4或8的倍数(对齐) |
| 空间尺寸HxW | 影响行缓存利用率 | 避免过大的W导致缓存行浪费 |
| 批大小N | 决定权重复用次数 | 批大小=1时,权重无法复用 |
举个例子。假设输入是[1, 3, 224, 224](NCHW),每个通道是224x224的float32。在内存里,三个通道是连续存储的:先存通道0的50176个元素,再存通道1的,最后通道2的。如果卷积核是3x3,每次计算需要读取3x3x3=27个元素——这27个元素分布在三个不同的内存区域,缓存行利用率极低。
我建议的做法是:把数据布局改成NHWC,这样每个像素的3个通道是连续的。3x3卷积只需要读取3x3个像素,每个像素包含3个通道——9次内存读取就能拿到27个数据,缓存行利用率提升3倍。
核心原则:张量形状的设计,要遵循访问模式与存储布局一致的原则。你希望怎么访问数据,就怎么存储数据。别让CPU在内存里「跳来跳去」。
好了,关于推理模型的内存访问模式,我就聊这么多。下一章咱们会深入具体的缓存优化技巧,包括循环分块、数据预取和内存对齐这些实战方法。到时候我会拿几个真实案例出来,咱们一起分析。