计算机体系结构 22:数据布局与实测解释

核心问题

第 16 到第 19 篇讨论了缓存映射、写策略、DRAM 和并发缺失。第 20、21 篇把虚拟地址翻译补上。到这里,优化问题可以回到程序员最常见的选择:同一份逻辑数据,应该按什么布局放进内存。

本文只回答一个可验收的问题:当模型说 SoA 比 AoS 少读 cache line,或者矩阵分块缩小工作集时,怎样把这个模型结论和本机 wall-clock 计时分开解释。

结论先列出来:

  • AoS/SoA 的 cache-line 模型显示:只读一个字段时,SoA 的模型缺失数更少。
  • 本机计时中,SoA 的中位时间低于 AoS。
  • 本机小参数矩阵乘法中,blocked 版本慢于 plain 版本。
  • 没有性能计数器,所以本文不声称任何真实 L1 miss 数、带宽或能耗。

优化文章要分成三栏:模型预测、程序产物、机器计时。三栏可以互相解释,但不能互相冒充。

边界

本文的一手依据有两类。CS61C cache blocking 材料支持“分块可以改善数据复用,但必须基于声明的缓存模型”。LLVM auto-vectorization 文档说明 LLVM 有 loop vectorizer 和 SLP vectorizer,并提供 -Rpass=loop-vectorize-Rpass-missed=loop-vectorize-Rpass-analysis=loop-vectorize 等诊断选项;这意味着同一段 C 代码的时间变化可能同时来自缓存、向量化、展开、调度和别名分析,不应只归因于 cache。

本文的附件:

证据等级:手算、功能执行、真机测量。

真机测量只代表当前 macOS AArch64 环境中这次编译和运行的 wall-clock 样本。执行日期为 2026-09-22,编译器为 Apple clang 21.0.0(clang-2100.3.34.2),每个 case 记录 5 个样本。程序在每个 rep 内固定先跑 AoS 再跑 SoA、先跑 plain 再跑 blocked,因此可能存在顺序、缓存预热、频率和后台负载偏差。没有 perf 计数器、没有硬件 cache miss 记录、没有带宽计数、没有能耗记录。/private/tmp 下新编译 Mach-O 在本环境会退出 137,复现实验使用仓库内 examples/computer-architecture/layout/build/ 路径。

AoS 与 SoA 的模型差异

AoS 把同一个对象的字段放在一起:

1
2
3
4
5
6
typedef struct {
float x;
float y;
float z;
float w;
} Point;

如果只累加 x 字段,每个 Point 占 16 字节,64 字节 cache line 里放 4 个点。读取 4096 个点的 x 字段,会触碰:

1
4096 / 4 = 1024 条 cache line

SoA 把所有 x 连续放在一个 float x[] 中。64 字节 cache line 里有 16 个 float,读取 4096 个元素触碰:

1
4096 / 16 = 256 条 cache line

模型输出:

1
2
3
4
5
6
{
"aos_loads": 4096,
"aos_misses": 1024,
"soa_loads": 4096,
"soa_misses": 256
}

这个“misses”来自一个全相联 LRU 教学模型,不是硬件计数器。它表达的是空间局部性差异:只读一个字段时,AoS 会把 y/z/w 一起带进来但不用;SoA 把要读的 x 密集排列。

布局选择取决于访问投影。只读一个字段时 SoA 常有优势;如果每次都要同时读写同一个对象的多个字段,AoS 的相邻字段也可能正好被利用。

本机计时怎样读

复现实验命令:

1
2
3
mkdir -p examples/computer-architecture/layout/build
cc -O2 -std=c11 -Wall -Wextra -pedantic examples/computer-architecture/layout/src/layout_bench.c -o examples/computer-architecture/layout/build/layout_bench
examples/computer-architecture/layout/build/layout_bench 1048576 192 32 5 > examples/computer-architecture/layout/outputs/layout_timings.jsonl

输入解析已做边界检查:负数、超大整数、非法后缀、block > matmul_n、参数过多都会返回错误。验证记录在 layout_cli_validation.txt

本次 AoS/SoA 计时样本的中位数:

case median ns
AoS 715375
SoA 593167

SoA 在这次样本中更快,比例约为:

1
715375 / 593167 = 1.207

这个结果和模型方向一致,但不能写成“真实 L1 miss 少了 4 倍导致时间快 1.2 倍”。原因至少有三层:

  • 编译器可能向量化、展开或改变循环形态。
  • 本机 cache 层级、预取器、乱序执行会掩盖一部分 cache-line 差异。
  • wall-clock 受后台负载和频率策略影响。

能成立的表述是:在本文参数和编译条件下,SoA 的模型触线数更少,真实 wall-clock 中位数也更低;缺少硬件计数器时,只能说方向一致但不能确定因果,不能升级为实测 cache miss 结论。

分块不是保证加速

矩阵乘法的 plain 版本按 i-k-j 顺序遍历。blocked 版本把 i/k/j 划成 32×32 小块。工作集手算显示:

1
2
3
4
A tile = 32 * 32 * 4 = 4096 bytes
B tile = 32 * 32 * 4 = 4096 bytes
C tile = 32 * 32 * 4 = 4096 bytes
three tiles = 12288 bytes

这个数说明 32×32 分块把当前参与计算的 A/B/C 小块控制在 12 KiB 量级。它只说明工作集规模,不说明程序必然更快。

本次计时中位数:

case median ns
plain matmul 477333
blocked matmul 801917

blocked 反而更慢。原因不需要强行猜成某一种。本文程序规模小,plain 的循环顺序本来就连续写 C、连续读 B 的行;blocked 版本增加了三层块循环和边界判断;编译器对两个版本的优化也可能不同。没有性能计数器时,最安全的结论是:分块模型说明了潜在复用结构,但本次小参数真机测量没有出现加速。

反例比漂亮结论更重要。如果实验结果和常见直觉相反,保留反例,降低结论强度,而不是补一个没有证据的解释。

本篇验收

  • 能从 64 字节 cache line 手算 AoS/SoA 单字段扫描触碰的 cache line 数。
  • 能区分教学 cache 模型输出和真机 wall-clock 输出。
  • 能解释为什么 SoA 在本文样本中更快,同时说明固定运行顺序、后台负载和缺少硬件计数器带来的因果边界。
  • 能解释 blocked matmul 的工作集手算,并接受本文参数下 blocked 更慢的结果。
  • 能复跑 C 基准,并确认坏输入不会触发负数或 huge 参数分配溢出。

练习

练习 1

一个结构体有 4 个 float 字段,大小 16 字节。只扫描第一个字段。cache line 为 64 字节,扫描 8192 个元素。AoS 和 SoA 分别至少触碰多少条 cache line?

答案:

1
2
AoS: 8192 / (64 / 16) = 8192 / 4 = 2048
SoA: 8192 / (64 / 4) = 8192 / 16 = 512

练习 2

一次 blocked matmul 的 wall-clock 比 plain 更慢,能否因此断言 cache blocking 没有价值?

答案:不能。只能说明这组参数、代码、编译器和机器状态下没有出现加速。分块是否有价值取决于矩阵尺寸、块大小、cache 层级、编译器优化、访存顺序和测量边界。本文只能陈述:工作集模型显示 32×32 三块为 12288 字节,但当前小参数真机计时 blocked 更慢。

参考资料