CPU 主频远快于内存,访存延迟是性能的头号杀手。这一篇聚焦《传统ARM优化的思考》里的「缓存与内存层次」维度,用 4 个可复现的 C 用例,把缓存行、局部性、伪共享、软件预取这些访存相关的坑一个个填上。
说明:代码为节选,聚焦「优化前 vs 优化后」的关键差异;完整可编译运行的用例见 arm-perf/cases/ 目录。
01 · 缓存局部性:顺序访问 vs 跨大步长访问
同一个二维数组,按「行优先(连续)」与「列优先(跨行跨步)」两种顺序求和。
❌ 原始代码(性能问题所在)
static uint64_t run_strided(void) { /* baseline:跨步访问(列优先) */
uint64_t s = 0;
uint64_t t0 = now_ns();
for (int j = 0; j < N; j++)
for (int i = 0; i < N; i++)
s += (uint64_t)a[i][j];
uint64_t t1 = now_ns();
sink_u64(s);
return t1 - t0;
}
🔍 慢在哪
跨步访问破坏了空间局部性:CPU 每次取数据都按缓存行(通常 64 字节)整行加载,而跨步访问只用到了其中的 4 字节,剩下的都浪费了,导致大量 cache miss、内存带宽被白白消耗。
✅ 优化后的代码
static uint64_t run_sequential(void) { /* optimized:顺序访问(行优先) */
uint64_t s = 0;
uint64_t t0 = now_ns();
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++)
s += (uint64_t)a[i][j];
uint64_t t1 = now_ns();
sink_u64(s);
return t1 - t0;
}
int main(void) {
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++)
a[i][j] = (i * 31 + j * 17) & 0xFF;
uint64_t base = run_strided();
uint64_t opt = run_sequential();
print_result("cache_line", base, opt);
return 0;
}
🛠 怎么优化
把遍历顺序改成「行优先(连续)」,让相邻的访问落在同一条缓存行里,把每一行都吃干榨净。
📊 实测结果
本机 x86_64:基线 228,637,975 ns → 优化 4,789,559 ns,加速 47.737×。
ARM(aarch64, QEMU):基线 50,052,863 ns → 优化 17,664,825 ns,加速 2.833×。
📱 ARM 视角
ARM 的 L1/L2 普遍较小(手机上更甚),顺序访问对带宽和功耗都更友好;低功耗小核上这个差距会更明显。
02 · 数据布局:AoS vs SoA
同一份点数据,用结构体数组(AoS)与独立字段数组(SoA)两种布局做批量运算。
❌ 原始代码(性能问题所在)
/* baseline:AoS,跨步取字段 */
float acc = 0.0f;
uint64_t t0 = now_ns();
for (int i = 0; i < N; i++)
acc += aos[i].x * aos[i].y + aos[i].z * aos[i].w;
uint64_t t1 = now_ns();
sink_f32(acc);
uint64_t base = t1 - t0;
🔍 慢在哪
AoS(结构体数组)字段交错存放,访问单个字段时步长跨越整个结构体,缓存行利用率低,而且编译器难以把交错的数据向量化。
✅ 优化后的代码
/* optimized:SoA,连续数组 */
acc = 0.0f;
t0 = now_ns();
for (int i = 0; i < N; i++)
acc += xs[i] * ys[i] + zs[i] * ws[i];
t1 = now_ns();
sink_f32(acc);
uint64_t opt = t1 - t0;
print_result("aos_soa", base, opt);
return 0;
}
🛠 怎么优化
改成 SoA(数组结构):每个字段单独放成一段连续数组,访问连续、缓存命中率高,还便于 SIMD 向量化。
📊 实测结果
本机 x86_64:基线 2,172,685 ns → 优化 1,894,270 ns,加速 1.147×。
ARM(aarch64, QEMU):基线 28,130,293 ns → 优化 27,921,078 ns,加速 1.007×。
📱 ARM 视角
连续数组让编译器能直接生成 NEON 的加载/运算指令;数据布局是 SIMD 能否自动向量化的前提。
03 · 软件预取:大数组随机访问
用一个预先打乱的随机索引序列去访问大数组。
❌ 原始代码(性能问题所在)
/* baseline:随机访问,无预取 */
uint64_t s = 0;
uint64_t t0 = now_ns();
for (uint32_t i = 0; i < N; i++) s += arr[idx[i]];
uint64_t t1 = now_ns();
sink_u64(s);
uint64_t base = t1 - t0;
🔍 慢在哪
随机访问的地址无法被硬件预取器预测,几乎每次都 cache miss,访存延迟(上百个周期)成为瓶颈,CPU 只能干等。
✅ 优化后的代码
/* optimized:提前 DIST 步预取 */
s = 0;
t0 = now_ns();
uint32_t i = 0;
for (; i + DIST < N; i++) {
__builtin_prefetch(&arr[idx[i + DIST]], 0, 3);
s += arr[idx[i]];
}
for (; i < N; i++) s += arr[idx[i]];
t1 = now_ns();
sink_u64(s);
uint64_t opt = t1 - t0;
print_result("prefetch", base, opt);
return 0;
}
🛠 怎么优化
用 __builtin_prefetch 提前把「若干步之后」要访问的地址拉进缓存,把访存延迟隐藏到前面的计算里。
📊 实测结果
本机 x86_64:基线 6,316,855 ns → 优化 4,995,759 ns,加速 1.264×。
ARM(aarch64, QEMU):基线 29,455,945 ns → 优化 35,854,972 ns,加速 0.822×。
📱 ARM 视角
老一些的 ARM 核硬件预取能力较弱,软件预取的收益比 x86 上更明显;但也要注意预取距离太近或太远都会打折扣。
04 · 伪共享与缓存行填充
两个线程各自自增一个计数器。
❌ 原始代码(性能问题所在)
A = B = 0;
uint64_t base = run_pair(bump_a, bump_b);
sink_u64(A + B);
🔍 慢在哪
两个计数器落在同一条缓存行里,A 线程每次写入都会让 B 线程缓存里的这一行失效,两边互相「踢缓存」,产生大量一致性流量。
✅ 优化后的代码
C.a = C.b = 0;
uint64_t opt = run_pair(bump_ca, bump_cb);
sink_u64(C.a + C.b);
print_result("false_sharing", base, opt);
return 0;
}
🛠 怎么优化
用填充把两个共享变量拆到不同的缓存行,让它们互不干扰。
📊 实测结果
本机 x86_64:基线 339,123,753 ns → 优化 102,217,777 ns,加速 3.318×。
ARM(aarch64, QEMU):基线 809,672,443 ns → 优化 538,787,594 ns,加速 1.503×。
📱 ARM 视角
多核 ARM 同样有缓存一致性协议(MOESI/MESI),这个现象在真机上才测得出;QEMU 用户态不仿真一致性,所以 ARM 一列数据仅供参考。
本系列其余文章见「性能优化」分类,或回到《传统ARM优化的思考》看总述。
