4、内存访问模式:连续访问 vs 随机访问,性能差10倍的原因

做嵌入式AI推理,内存带宽是绕不开的坎。我见过太多团队,模型选对了,算子也优化了,可推理速度就是上不去。最后查来查去,问题出在内存访问模式上。

连续访问和随机访问,性能能差10倍。这不是夸张。我自己在RK3588上调一个YOLOv5的后处理时,就踩过这个坑。当时把NMS的随机访存改成连续访存,帧率直接从15fps飙到了42fps。嗯,就是这么夸张。

4.1 为什么连续访问这么快?

说白了,这跟硬件的设计哲学有关。CPU、DSP、NPU,它们从内存读数据时,不是一次只读一个字节。而是按“块”来读的。这个块,在DDR里叫“burst”,在Cache里叫“cache line”。

举个例子。你读一个int32数据,4个字节。但硬件实际上会一次性读64个字节甚至128个字节回来。你想想看,如果你要的数据刚好都在这个块里,那后面的几次访问就相当于“免费”了。

关键概念:空间局部性

连续访问利用了空间局部性。你访问了地址A,那么地址A+1、A+2...很可能也会被访问。硬件预取器会提前把这些数据拉进Cache。

我在项目中遇到过这样一个场景:一个3x3的卷积,如果按HWC格式连续存储,访存效率极高。但如果换成CHW格式,跨行访问,性能直接腰斩。原因就是连续访问让DDR的burst传输用满了。

4.2 随机访问为什么慢?

随机访问的代价,比你想象的大得多。

每次随机访问,硬件都要经历:

  • 地址翻译:虚拟地址转物理地址,TLB miss的话更慢
  • Cache miss:数据不在Cache里,得去DDR拿
  • DDR行切换:这是最要命的。DDR内部是按bank和row组织的。随机访问会导致频繁的行激活和预充电。一次行切换,几十个时钟周期就没了

我曾经调试过一个Transformer的推理。Self-Attention里的QKV计算,如果按原始实现,每个token都要随机访问其他所有token的key。结果DDR带宽利用率不到10%。说白了,大部分时间都花在了等待内存上。

避坑指南

我曾经在STM32H7上做一个小模型推理,用了动态分配的内存池。每次推理时,张量的物理地址都不一样。结果同样的模型,推理时间差了3倍。后来我改成静态分配,确保连续地址,问题就解决了。

4.3 性能差距到底有多大?

我们来做个实验。假设DDR带宽是10GB/s,Cache命中率对性能的影响如下:

访问模式 Cache命中率 有效带宽 相对性能
连续访问 ~95% ~9.5 GB/s 10x
步长为2的访问 ~50% ~5 GB/s 5x
完全随机访问 ~5% ~0.5 GB/s 1x

你看,连续访问比随机访问快了整整10倍。这还没算DDR行切换的额外开销。如果算上,差距更大。

4.4 实战中怎么优化?

我个人的习惯是,拿到一个算子,先看它的内存访问模式。如果是随机访问,我会想办法改成连续访问。

几个常用的技巧:

  • 数据重排:把不连续的数据重新排列成连续块。比如把CHW转成HWC,或者做im2col
  • 分块处理:把大矩阵切成小块,确保每个块在Cache里能放下。这样块内访问就是连续的
  • 预取指令:在循环里手动插入预取指令,告诉硬件“我马上要用这个地址的数据了”
  • 对齐分配:确保数据起始地址是Cache line对齐的(通常是64字节对齐)

小技巧

在ARM Cortex-A系列上,可以用 __builtin_prefetch 做预取。我试过,在数据量大的时候,能提升20%-30%的性能。

4.5 一个真实的优化案例

我之前优化过一个Depthwise卷积。原始实现是逐通道逐像素计算的,访存模式是:

// 随机访问模式
for (int c = 0; c < channels; c++) {
    for (int h = 0; h < height; h++) {
        for (int w = 0; w < width; w++) {
            // 每次访问 input[c][h][w],地址不连续
            output[c][h][w] = input[c][h][w] * weight[c];
        }
    }
}

这个写法,每次循环都在跨行访问。我改成连续访问后:

// 连续访问模式
for (int h = 0; h < height; h++) {
    for (int w = 0; w < width; w++) {
        for (int c = 0; c < channels; c++) {
            // 连续访问 input[c][h][w],地址递增
            output[c][h][w] = input[c][h][w] * weight[c];
        }
    }
}

只是交换了循环顺序,性能提升了4倍。嗯,就是这么简单粗暴。

4.6 总结

连续访问 vs 随机访问,本质上是硬件特性决定的。DDR和Cache的设计,天生偏爱连续数据。你想想看,如果每次访问都要等几十个周期,那再强的算力也白搭。

我建议你在写推理代码时,时刻问自己一个问题:“我现在的数据访问模式,是连续的还是随机的?” 如果是随机的,想办法改成连续的。这比优化算子的计算量,效果来得更直接。

记住,内存带宽是嵌入式推理的命门。而连续访问,就是打开这扇门的钥匙。