Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Python 和 Java 这类语言把内存管理彻底抽象掉了,程序员基本
我们可以写一段简单的 C 代码来直观感受缓存行的存在。这个程序以不同的步长遍历同一个数组,观察耗时变化:

```c
#define _POSIX_C_SOURCE 199309L // 启用 clock_gettime / CLOCK_MONOTONIC
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
Expand All @@ -88,16 +89,23 @@ int main(void)
}

// 以不同步长遍历,只做读操作
volatile int sink = 0; // 防止 sum 被"死代码消除"优化掉
for (int stride = 1; stride <= 4096; stride *= 2) {
clock_t start = clock();
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0); // 记录墙上时间起点
int sum = 0;
for (int i = 0; i < kArraySize; i += stride) {
sum += arr[i];
}
clock_t end = clock();
printf("stride=%5d time=%.3f ms\n",
stride,
(double)(end - start) / CLOCKS_PER_SEC * 1000);
clock_gettime(CLOCK_MONOTONIC, &t1); // 记录墙上时间终点
sink = sum; // 强制编译器真的去算 sum

double total_ms = (t1.tv_sec - t0.tv_sec) * 1000.0
+ (t1.tv_nsec - t0.tv_nsec) / 1e6;
long accesses = kArraySize / stride; // 注意:步长翻倍,访问次数减半
double ns_per_access = total_ms * 1e6 / accesses;
printf("stride=%5d accesses=%9ld total=%7.3f ms per_access=%6.2f ns\n",
stride, accesses, total_ms, ns_per_access);
}

free(arr);
Expand All @@ -109,25 +117,38 @@ int main(void)

```text
$ gcc -O2 -std=c11 stride_test.c -o stride_test && ./stride_test
stride= 1 time=68.245 ms
stride= 2 time=68.891 ms
stride= 4 time=69.012 ms
stride= 8 time=69.453 ms
stride= 16 time=70.102 ms
stride= 32 time=132.567 ms
stride= 64 time=201.345 ms
stride= 128 time=215.789 ms
stride= 256 time=218.901 ms
stride= 512 time=220.134 ms
stride= 1024 time=221.567 ms
stride= 2048 time=222.890 ms
stride= 4096 time=223.456 ms
stride= 1 accesses= 67108864 total= 20.518 ms per_access= 0.31 ns
stride= 2 accesses= 33554432 total= 13.900 ms per_access= 0.41 ns
stride= 4 accesses= 16777216 total= 12.080 ms per_access= 0.72 ns
stride= 8 accesses= 8388608 total= 9.663 ms per_access= 1.15 ns
stride= 16 accesses= 4194304 total= 10.263 ms per_access= 2.45 ns
stride= 32 accesses= 2097152 total= 8.678 ms per_access= 4.14 ns
stride= 64 accesses= 1048576 total= 4.679 ms per_access= 4.46 ns
stride= 128 accesses= 524288 total= 2.733 ms per_access= 5.21 ns
stride= 256 accesses= 262144 total= 1.409 ms per_access= 5.38 ns
stride= 512 accesses= 131072 total= 0.866 ms per_access= 6.61 ns
stride= 1024 accesses= 65536 total= 0.672 ms per_access= 10.25 ns
stride= 2048 accesses= 32768 total= 0.304 ms per_access= 9.27 ns
stride= 4096 accesses= 16384 total= 0.115 ms per_access= 7.00 ns
```

当步长从 1 增长到 16(16 个 int = 64 字节,正好一条缓存行)的过程中,耗时几乎不怎么变化——因为无论你是逐个访问还是每隔几个访问,反正一条缓存行被拉上来之后里面的所有数据都已经在 Cache 里了。但步长一旦超过 16(跨越缓存行边界),每次访问都会触发新的 Cache Line 加载,耗时就会明显上升。这个小实验非常好地展示了缓存行作为最小搬运单位的效果。
先别急着看 `total` 那一列——它是"把整个数组从头到尾扫一遍的总耗时",而我们的循环是 `i += stride`,步长翻倍访问次数就直接减半:stride=1 要访问 6700 万次,stride=4096 只访问 1.6 万次,差了四千多倍。所以 `total` 这一列被"访问次数"这个量主导着一路往下掉(20ms 掉到 0.1ms),它根本反映不出 Cache 的存在——换台机器、把数组改大改小,绝对值都会跟着抖,没有可比性。

真正该盯的是 `per_access`——**每次访存平均摊到多少纳秒**。它把"访问次数"这个干扰量给除掉了,剩下的才是单次访存的纯开销,这才看得见 Cache 的影子。你会发现这条曲线有三个明显的段:

- **stride 1 → 16**:`per_access` 从 0.31ns 慢慢爬到 2.45ns。16 个 `int` 正好 64 字节、一条缓存行,所以这一段里相邻几次访问还窝在同一条缓存行里——缓存行一旦被拉上来,行内的数据全在 Cache 里白送,再加上硬件预取在背后偷偷提前搬,单次开销就被压到了亚纳秒级。
- **stride 超过 16**:开始跨缓存行边界了,`per_access` 明显加速往上抬,到 stride=512 已经 6.6ns。这时候每跳一步,基本都得等一条新的缓存行从 L2/L3 甚至主存搬上来,预取也追不上这么大的步子。
- **stride 到 1024 往上**:步长已经 ≥ 4KB,连页都跨了,访问又稀疏到 Cache 根本兜不住,`per_access` 攀到 7~10ns,基本就是每次都冷访问、逼近一次 DRAM 访问的延迟量级。

这就是缓存行作为最小搬运单位的效果——**只要访问还窝在一条 64 字节的缓存行里,单次访存就便宜到亚纳秒;一旦跳出这条行,每一步都得付出整条缓存行搬运的代价。**

> **踩坑预警**
> 做步长实验时一定要加上 `-O2` 编译选项。用 `-O0` 的话,循环本身的开销会掩盖 Cache 带来的差异;而 `-O3` 有时又会激进到把整个循环优化成一个常数表达式,导致你什么都测不出来。如果发现所有步长的耗时都一样,很可能是编译器把你的循环吃掉了,可以尝试用 `volatile` 修饰 `sum` 或者在循环体内插入一个编译器屏障(`__asm__ volatile("" ::: "memory")`)。
>
> 这个实验有几个特别容易翻车的点,挨个说一下:
>
> - **一定要看每次访问的平均耗时,别被总耗时骗了。** 如果你直接拿"扫完整个数组的总时间"来比,步长越大访问次数越少,总时间当然越来越短——但这跟 Cache 没半点关系,纯粹是"活儿干少了"。所以代码里专门算了 `per_access = 总时间 ÷ 访问次数`,把访问次数这个干扰量除掉,才能看到 Cache 命中率的变化。(这也是本教程早先版本踩过的坑,感谢读者在 issue 里指出。)
> - **别让编译器把循环优化没了。** `-O0` 会让循环本身的开销盖过 Cache 差异,`-O3` 又可能激进到把整个循环折叠成常数表达式。代码里的 `volatile int sink = sum;` 就是干这个的——`sum` 算完没人用,编译器会判定它是"死代码"直接删掉,我们用一个 `volatile` 汇聚点逼它老老实实算完。
> - **计时要用墙上时间,别用 `clock()`。** `clock()` 量的是进程占用的 CPU 时间,不是真实流逝的墙上时间;访存 benchmark 该用 `clock_gettime(CLOCK_MONOTONIC, ...)`。它需要 `#define _POSIX_C_SOURCE 199309L`(或直接 `-std=gnu11` 编译),否则在严格的 `-std=c11` 下会报"隐式声明"。

## 第三步——搞明白一条缓存行被放到了哪里

Expand Down Expand Up @@ -339,7 +360,7 @@ C++ 标准库里的容器在设计时也考虑了缓存因素。`std::vector`

## 练习

1. **步长实验验证**:修改本文的步长测试代码,将数组大小改为 4MB(恰好塞进大部分 CPU 的 L3),观察步长从 1 32 时耗时的变化曲线。思考:为什么步长超过 16 之后耗时又开始趋于平缓
1. **步长实验验证**:修改本文的步长测试代码,把数组缩小到 4MB(基本能塞进大部分 CPU 的 L3,避免主存延迟的干扰),重点盯 `per_access` 那一列。观察步长从 1 涨到 32 时单次访问耗时的变化——思考:为什么步长突破 16(一条缓存行边界)之后,`per_access` 才开始明显往上抬?这个拐点对应的字节数,能反推出你机器的缓存行大小吗

2. **伪共享复现**:写一个多线程程序(使用 pthread 或 C++ `<thread>`),创建两个线程各自累加一个共享结构体里的不同字段到一亿次。先不加对齐地跑一次,然后用 `alignas(64)` 把两个字段分别对齐到不同缓存行再跑一次,对比耗时。

Expand Down
Loading