数据布局优化:NHWC vs NCHW 格式选择

说到推理缓存命中率,数据布局是个绕不开的话题。我刚开始做嵌入式AI时,总觉得这玩意儿是框架该操心的事。直到有一次,我在一个ARM Cortex-M7的板子上跑MobileNet,同样的模型,换了个数据排布方式,推理速度差了将近一倍。嗯,从那以后我再也不敢小看数据布局了。

NHWC 和 NCHW 到底是个啥?

说白了,这就是数据在内存里怎么摆的问题。拿一张彩色图片举例:

  • NCHW:先把所有通道的数据放一起。比如RGB三通道,先放所有R,再放所有G,最后放B。
  • NHWC:按像素点来。第一个像素的R、G、B,然后第二个像素的R、G、B,以此类推。

你想想看,这两种排布方式,对缓存的影响能一样吗?

核心差异:NCHW 是「通道优先」,NHWC 是「通道最后」。这个顺序决定了数据在内存中的访问模式。

通道优先与通道最后的性能差异

我在项目中遇到过这样一个场景:用TensorFlow Lite部署一个分类模型,默认用的是NHWC。当时板子上的缓存只有32KB,跑起来总是卡顿。我试着把数据转成NCHW格式,结果发现——

其实没有绝对的谁好谁坏。关键看你的硬件和算子实现:

格式 优势场景 劣势场景
NCHW GPU推理、批量处理 逐像素操作、CPU推理
NHWC CPU推理、单张图片处理 通道数多的层(如深度可分离卷积)

为什么会这样?我解释一下:

  • NCHW 对 GPU 友好:因为GPU擅长做矩阵运算,把同一通道的数据连续存放,可以一次性加载到寄存器里。
  • NHWC 对 CPU 友好:CPU做卷积时,经常需要同时访问多个通道的同一个位置。NHWC 让这些数据挨着放,缓存命中率自然就高了。

我的经验:在ARM Cortex-A系列上做推理,我一般优先选NHWC。但在用NPU或GPU加速时,NCHW往往表现更好。别死记硬背,跑个benchmark就知道了。

数据对齐(Alignment)的重要性

嗯,这里要注意。数据对齐是个容易被忽略的细节,但它直接影响缓存行利用率。

我曾经在一个项目里,发现同样的模型,同样的数据布局,推理时间差了15%。排查了半天,最后发现是数据地址没对齐到16字节。你想想看,ARM的NEON指令一次能加载128位数据,如果你的数据没对齐,CPU就得额外做一次内存访问,缓存行就被浪费了。

对齐的基本原则

  • 数据起始地址对齐到缓存行大小(通常是64字节)
  • 每个通道的数据对齐到SIMD宽度(ARM NEON是16字节)
  • 如果用了DMA,对齐到DMA传输粒度

避坑指南:我曾经在STM32H7上做推理,malloc分配的内存默认只对齐到8字节。结果跑卷积时,每次加载数据都要做两次内存访问。后来改用memalign分配64字节对齐的内存,性能直接提升了20%。

结构体数组 vs 数组结构体

这个其实和NHWC/NCHW是一个道理。说白了就是:

  • 结构体数组(AoS):每个元素是一个结构体,里面包含所有属性。对应NHWC。
  • 数组结构体(SoA):每个属性单独一个数组。对应NCHW。

我举个具体的例子。假设你要处理1000个点的三维坐标:

// 结构体数组(AoS)
typedef struct {
    float x;
    float y;
    float z;
} Point3D;
Point3D points[1000];

// 数组结构体(SoA)
typedef struct {
    float x[1000];
    float y[1000];
    float z[1000];
} Points3D;

在推理场景里,怎么选?

  • 如果你要同时访问x、y、z(比如做向量运算),AoS更合适。因为数据在内存里挨着,一次缓存加载就能拿到全部。
  • 如果你只处理其中一个分量(比如只算所有点的x坐标平均值),SoA更合适。因为数据是连续的,缓存利用率高。

关键判断标准:看你的访问模式是「按元素」还是「按属性」。按元素用AoS,按属性用SoA。就这么简单。

我在做轻量化模型部署时,经常把权重数据从SoA转成AoS。为什么呢?因为卷积核的访问模式是「按通道取权重」,AoS能让同一个卷积核的权重挨着放,缓存命中率能提高不少。

实战建议

说了这么多,给你几个可以直接用的建议:

  1. 先分析访问模式:用perf或者硬件计数器看看缓存缺失率,别瞎猜。
  2. 数据对齐是基本功:写代码时养成习惯,用aligned_alloc或者posix_memalign。
  3. 别迷信某一种格式:我见过有人死磕NCHW,结果在CPU上跑得还不如NHWC。跑个benchmark,用数据说话。
  4. 考虑转换开销:如果模型输入是NHWC,但你非要用NCHW推理,中间的数据转换时间可能抵消掉性能提升。

小技巧:在TFLite Micro里,可以用tflite::micro::GetTensorData()直接获取tensor数据指针,然后检查它的对齐情况。如果没对齐,手动做个内存拷贝对齐一下,往往有奇效。

好了,数据布局这块就聊到这儿。记住一句话:缓存不关心你的数据长什么样,只关心你访问它的方式。把数据摆对了,缓存命中率自然就上去了。