4、数据表示与内存布局:深入理解NHWC与NCHW数据格式
各位同学,咱们今天聊点实在的。数据格式和内存布局,说白了就是张量在内存里怎么排队的问题。你别小看这个排队顺序,我见过太多项目,模型精度调得漂漂亮亮,一上板子跑起来慢得像蜗牛,最后查来查去,问题就出在这个数据布局上。
4.1 NHWC 与 NCHW:两种主流的“排队方式”
先说说这两个名字。NHWC 和 NCHW,其实就是四个维度的排列顺序:
- N:Batch size,一次处理多少张图
- H:Height,图像高度
- W:Width,图像宽度
- C:Channels,通道数(比如RGB就是3)
NHWC 的意思是:先按批次排,再按高度排,再按宽度排,最后按通道排。NCHW 则是:先按批次,再按通道,再按高度,最后按宽度。
你想想看,这两种顺序在内存里走起来,差别可大了去了。
4.2 内存里的“邻居”关系
我个人习惯用一张图来理解。假设我们有一张 4x4 的 RGB 图像,也就是 H=4, W=4, C=3。
NHWC 布局:内存里先存第一个像素的 R、G、B,再存第二个像素的 R、G、B……这样每个像素的三个通道是紧挨着的。说白了,就是“像素优先”。
NCHW 布局:内存里先把所有像素的 R 通道存完,再存所有像素的 G 通道,最后存 B 通道。这是“通道优先”。
我在项目中遇到过这样一个坑:用 TensorFlow 训练好的模型,默认是 NHWC 格式。直接拿到 ARM Cortex 上跑,发现卷积运算慢得离谱。为什么?因为 Cortex 的 NEON 指令集做向量化计算时,更喜欢连续的内存访问。NHWC 下,每个像素的通道是连续的,但卷积核要跨通道做乘加运算,就得跳来跳去地取数,缓存命中率低得可怜。
关键点:内存访问的局部性,决定了计算效率。连续的内存访问,比随机访问快一个数量级。
4.3 内存对齐:别让你的数据“错位”
嗯,这里要注意。ARM Cortex 处理器对内存对齐非常敏感。什么叫对齐?简单说,就是数据的起始地址要是某个值的整数倍。比如 4 字节对齐,地址就得是 4 的倍数。
为什么重要?因为 Cortex 的加载指令(比如 LDR、VLDR)在访问未对齐地址时,要么触发异常,要么需要多个时钟周期才能完成。我曾经调试过一个模型,推理时间忽高忽低,最后发现是输入图像的地址没对齐,导致某些帧加载慢了几十个周期。
来看一个实际例子。假设你的张量是 float32 类型,每个元素占 4 字节。如果张量的起始地址是 0x1000(4 的倍数),那没问题。但如果地址是 0x1002,那就麻烦了。
// 对齐访问(推荐)
float32_t input[64] __attribute__((aligned(16)));
// 这样 input 的起始地址一定是 16 字节对齐的
// 未对齐访问(尽量避免)
float32_t* ptr = (float32_t*)((uint8_t*)input + 2);
// 此时 ptr 指向的地址不是 4 的倍数
警告:在 ARM Cortex-M 系列上,未对齐访问会触发 HardFault 异常。在 Cortex-A 系列上,虽然不会崩溃,但性能会下降 2-5 倍。千万别指望硬件帮你兜底。
4.4 实战中的选择策略
那么问题来了:到底该用 NHWC 还是 NCHW?
我的建议是:看你的硬件和算子库。
| 场景 | 推荐格式 | 原因 |
|---|---|---|
| ARM Cortex + CMSIS-NN | NHWC | CMSIS-NN 的卷积实现针对 NHWC 做了优化,内存访问更连续 |
| ARM Cortex + 自定义 NEON 代码 | NCHW | NCHW 下通道维度连续,方便用 NEON 做向量化乘加 |
| 移动端 GPU(如 Mali) | NHWC | GPU 的纹理单元天然适合 NHWC 的像素布局 |
| CPU 推理(如 x86) | NCHW | Intel MKL 等库对 NCHW 支持更好 |
我曾经在 STM32H7 上部署一个轻量级网络,一开始用了 NCHW,结果 CMSIS-NN 的卷积函数跑起来慢得让人抓狂。后来改成 NHWC,速度直接提升了 40%。说白了,库怎么设计的,你就怎么配合它。
4.5 内存对齐的实操技巧
讲几个我常用的对齐技巧:
- 分配内存时指定对齐:用
aligned_alloc或memalign,别用普通的malloc。 - 结构体对齐:如果张量是结构体的一部分,用
__attribute__((packed))要谨慎,它会让字段不对齐。 - 缓冲区填充:有时为了对齐,需要在行尾或通道末尾填充一些无用数据。比如宽度是 13,但为了 16 字节对齐,可以填充到 16。
// 示例:为 NHWC 格式分配对齐内存
size_t width = 13;
size_t height = 13;
size_t channels = 3;
size_t element_size = sizeof(float);
// 计算每行需要的字节数,并对齐到 16 字节
size_t row_stride = (width * channels * element_size + 15) & ~15;
size_t total_size = row_stride * height;
float* buffer = (float*)aligned_alloc(16, total_size);
// 现在 buffer 是 16 字节对齐的,每行也是 16 字节对齐的
小提示:对齐到 16 字节通常就够用了,因为 NEON 的加载指令一次处理 16 字节(4 个 float32)。对齐到 32 字节或 64 字节在某些 Cortex-A 系列上能进一步提升性能,但收益递减。
4.6 避坑指南:我曾经踩过的坑
我曾经在一个项目里,把输入图像的宽度设成了 224,高度 224,通道 3。看起来没问题对吧?但问题在于,224 * 3 = 672 字节,672 不是 16 的倍数。结果每行数据在内存里都错位了,NEON 加载时总是跨边界,性能直接腰斩。
后来我改成每行填充到 688 字节(224*3 + 16 填充),问题就解决了。你想想看,就多了 16 个字节的填充,换来的是 2 倍的速度提升,这笔账怎么算都划算。
另一个坑是:不同层的输入输出格式可能不一样。比如第一层是 NHWC,但中间某层用了 NCHW 的算子,那就得做格式转换。这个转换本身也有开销,我建议尽量保持全网络统一格式,除非你能证明转换的收益大于开销。
4.7 总结
数据表示和内存布局,听起来是基础,但往往是性能瓶颈的根源。记住三点:
- NHWC 适合像素级操作,NCHW 适合通道级操作。选哪个,看你的核心计算是什么。
- 内存对齐不是可选项,是必选项。不对齐的代价,可能是 2-5 倍的性能损失。
- 用工具验证:在 ARM 上跑代码前,用
__alignof__或调试器检查地址是否对齐。别靠猜。
下一章咱们会深入卷积算子的实现,到时候你会看到,今天讲的内存布局知识,直接决定了卷积代码怎么写。嗯,先消化这些吧。