@@ -73,6 +73,7 @@ Python 和 Java 这类语言把内存管理彻底抽象掉了,程序员基本
7373我们可以写一段简单的 C 代码来直观感受缓存行的存在。这个程序以不同的步长遍历同一个数组,观察耗时变化:
7474
7575``` c
76+ #define _POSIX_C_SOURCE 199309L // 启用 clock_gettime / CLOCK_MONOTONIC
7677#include <stdio.h>
7778#include <stdlib.h>
7879#include <time.h>
@@ -88,16 +89,23 @@ int main(void)
8889 }
8990
9091 // 以不同步长遍历,只做读操作
92+ volatile int sink = 0; // 防止 sum 被"死代码消除"优化掉
9193 for (int stride = 1; stride <= 4096; stride *= 2) {
92- clock_t start = clock();
94+ struct timespec t0, t1;
95+ clock_gettime(CLOCK_MONOTONIC, &t0); // 记录墙上时间起点
9396 int sum = 0;
9497 for (int i = 0; i < kArraySize; i += stride) {
9598 sum += arr[i];
9699 }
97- clock_t end = clock();
98- printf("stride=%5d time=%.3f ms\n",
99- stride,
100- (double)(end - start) / CLOCKS_PER_SEC * 1000);
100+ clock_gettime(CLOCK_MONOTONIC, &t1); // 记录墙上时间终点
101+ sink = sum; // 强制编译器真的去算 sum
102+
103+ double total_ms = (t1.tv_sec - t0.tv_sec) * 1000.0
104+ + (t1.tv_nsec - t0.tv_nsec) / 1e6;
105+ long accesses = kArraySize / stride; // 注意:步长翻倍,访问次数减半
106+ double ns_per_access = total_ms * 1e6 / accesses;
107+ printf("stride=%5d accesses=%9ld total=%7.3f ms per_access=%6.2f ns\n",
108+ stride, accesses, total_ms, ns_per_access);
101109 }
102110
103111 free(arr);
@@ -109,25 +117,38 @@ int main(void)
109117
110118```text
111119$ gcc -O2 -std=c11 stride_test.c -o stride_test && ./stride_test
112- stride= 1 time=68.245 ms
113- stride= 2 time=68.891 ms
114- stride= 4 time=69.012 ms
115- stride= 8 time=69.453 ms
116- stride= 16 time=70.102 ms
117- stride= 32 time=132.567 ms
118- stride= 64 time=201.345 ms
119- stride= 128 time=215.789 ms
120- stride= 256 time=218.901 ms
121- stride= 512 time=220.134 ms
122- stride= 1024 time=221.567 ms
123- stride= 2048 time=222.890 ms
124- stride= 4096 time=223.456 ms
120+ stride= 1 accesses= 67108864 total= 20.518 ms per_access= 0.31 ns
121+ stride= 2 accesses= 33554432 total= 13.900 ms per_access= 0.41 ns
122+ stride= 4 accesses= 16777216 total= 12.080 ms per_access= 0.72 ns
123+ stride= 8 accesses= 8388608 total= 9.663 ms per_access= 1.15 ns
124+ stride= 16 accesses= 4194304 total= 10.263 ms per_access= 2.45 ns
125+ stride= 32 accesses= 2097152 total= 8.678 ms per_access= 4.14 ns
126+ stride= 64 accesses= 1048576 total= 4.679 ms per_access= 4.46 ns
127+ stride= 128 accesses= 524288 total= 2.733 ms per_access= 5.21 ns
128+ stride= 256 accesses= 262144 total= 1.409 ms per_access= 5.38 ns
129+ stride= 512 accesses= 131072 total= 0.866 ms per_access= 6.61 ns
130+ stride= 1024 accesses= 65536 total= 0.672 ms per_access= 10.25 ns
131+ stride= 2048 accesses= 32768 total= 0.304 ms per_access= 9.27 ns
132+ stride= 4096 accesses= 16384 total= 0.115 ms per_access= 7.00 ns
125133```
126134
127- 当步长从 1 增长到 16(16 个 int = 64 字节,正好一条缓存行)的过程中,耗时几乎不怎么变化——因为无论你是逐个访问还是每隔几个访问,反正一条缓存行被拉上来之后里面的所有数据都已经在 Cache 里了。但步长一旦超过 16(跨越缓存行边界),每次访问都会触发新的 Cache Line 加载,耗时就会明显上升。这个小实验非常好地展示了缓存行作为最小搬运单位的效果。
135+ 先别急着看 ` total ` 那一列——它是"把整个数组从头到尾扫一遍的总耗时",而我们的循环是 ` i += stride ` ,步长翻倍访问次数就直接减半:stride=1 要访问 6700 万次,stride=4096 只访问 1.6 万次,差了四千多倍。所以 ` total ` 这一列被"访问次数"这个量主导着一路往下掉(20ms 掉到 0.1ms),它根本反映不出 Cache 的存在——换台机器、把数组改大改小,绝对值都会跟着抖,没有可比性。
136+
137+ 真正该盯的是 ` per_access ` ——** 每次访存平均摊到多少纳秒** 。它把"访问次数"这个干扰量给除掉了,剩下的才是单次访存的纯开销,这才看得见 Cache 的影子。你会发现这条曲线有三个明显的段:
138+
139+ - ** stride 1 → 16** :` per_access ` 从 0.31ns 慢慢爬到 2.45ns。16 个 ` int ` 正好 64 字节、一条缓存行,所以这一段里相邻几次访问还窝在同一条缓存行里——缓存行一旦被拉上来,行内的数据全在 Cache 里白送,再加上硬件预取在背后偷偷提前搬,单次开销就被压到了亚纳秒级。
140+ - ** stride 超过 16** :开始跨缓存行边界了,` per_access ` 明显加速往上抬,到 stride=512 已经 6.6ns。这时候每跳一步,基本都得等一条新的缓存行从 L2/L3 甚至主存搬上来,预取也追不上这么大的步子。
141+ - ** stride 到 1024 往上** :步长已经 ≥ 4KB,连页都跨了,访问又稀疏到 Cache 根本兜不住,` per_access ` 攀到 7~ 10ns,基本就是每次都冷访问、逼近一次 DRAM 访问的延迟量级。
142+
143+ 这就是缓存行作为最小搬运单位的效果——** 只要访问还窝在一条 64 字节的缓存行里,单次访存就便宜到亚纳秒;一旦跳出这条行,每一步都得付出整条缓存行搬运的代价。**
128144
129145> ** 踩坑预警**
130- > 做步长实验时一定要加上 ` -O2 ` 编译选项。用 ` -O0 ` 的话,循环本身的开销会掩盖 Cache 带来的差异;而 ` -O3 ` 有时又会激进到把整个循环优化成一个常数表达式,导致你什么都测不出来。如果发现所有步长的耗时都一样,很可能是编译器把你的循环吃掉了,可以尝试用 ` volatile ` 修饰 ` sum ` 或者在循环体内插入一个编译器屏障(` __asm__ volatile("" ::: "memory") ` )。
146+ >
147+ > 这个实验有几个特别容易翻车的点,挨个说一下:
148+ >
149+ > - ** 一定要看每次访问的平均耗时,别被总耗时骗了。** 如果你直接拿"扫完整个数组的总时间"来比,步长越大访问次数越少,总时间当然越来越短——但这跟 Cache 没半点关系,纯粹是"活儿干少了"。所以代码里专门算了 ` per_access = 总时间 ÷ 访问次数 ` ,把访问次数这个干扰量除掉,才能看到 Cache 命中率的变化。(这也是本教程早先版本踩过的坑,感谢读者在 issue 里指出。)
150+ > - ** 别让编译器把循环优化没了。** ` -O0 ` 会让循环本身的开销盖过 Cache 差异,` -O3 ` 又可能激进到把整个循环折叠成常数表达式。代码里的 ` volatile int sink = sum; ` 就是干这个的——` sum ` 算完没人用,编译器会判定它是"死代码"直接删掉,我们用一个 ` volatile ` 汇聚点逼它老老实实算完。
151+ > - ** 计时要用墙上时间,别用 ` clock() ` 。** ` clock() ` 量的是进程占用的 CPU 时间,不是真实流逝的墙上时间;访存 benchmark 该用 ` clock_gettime(CLOCK_MONOTONIC, ...) ` 。它需要 ` #define _POSIX_C_SOURCE 199309L ` (或直接 ` -std=gnu11 ` 编译),否则在严格的 ` -std=c11 ` 下会报"隐式声明"。
131152
132153## 第三步——搞明白一条缓存行被放到了哪里
133154
@@ -339,7 +360,7 @@ C++ 标准库里的容器在设计时也考虑了缓存因素。`std::vector`
339360
340361## 练习
341362
342- 1 . ** 步长实验验证** :修改本文的步长测试代码,将数组大小改为 4MB(恰好塞进大部分 CPU 的 L3), 观察步长从 1 到 32 时耗时的变化曲线。 思考:为什么步长超过 16 之后耗时又开始趋于平缓 ?
363+ 1 . ** 步长实验验证** :修改本文的步长测试代码,把数组缩小到 4MB(基本能塞进大部分 CPU 的 L3,避免主存延迟的干扰),重点盯 ` per_access ` 那一列。 观察步长从 1 涨到 32 时单次访问耗时的变化—— 思考:为什么步长突破 16(一条缓存行边界)之后, ` per_access ` 才开始明显往上抬?这个拐点对应的字节数,能反推出你机器的缓存行大小吗 ?
343364
3443652 . ** 伪共享复现** :写一个多线程程序(使用 pthread 或 C++ ` <thread> ` ),创建两个线程各自累加一个共享结构体里的不同字段到一亿次。先不加对齐地跑一次,然后用 ` alignas(64) ` 把两个字段分别对齐到不同缓存行再跑一次,对比耗时。
345366
0 commit comments