diff --git a/documents/vol1-fundamentals/c_tutorials/advanced_feature/02-cache-and-memory-hierarchy.md b/documents/vol1-fundamentals/c_tutorials/advanced_feature/02-cache-and-memory-hierarchy.md index 6d988e610..130c2f51d 100644 --- a/documents/vol1-fundamentals/c_tutorials/advanced_feature/02-cache-and-memory-hierarchy.md +++ b/documents/vol1-fundamentals/c_tutorials/advanced_feature/02-cache-and-memory-hierarchy.md @@ -73,6 +73,7 @@ Python 和 Java 这类语言把内存管理彻底抽象掉了,程序员基本 我们可以写一段简单的 C 代码来直观感受缓存行的存在。这个程序以不同的步长遍历同一个数组,观察耗时变化: ```c +#define _POSIX_C_SOURCE 199309L // 启用 clock_gettime / CLOCK_MONOTONIC #include #include #include @@ -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); @@ -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` 下会报"隐式声明"。 ## 第三步——搞明白一条缓存行被放到了哪里 @@ -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++ ``),创建两个线程各自累加一个共享结构体里的不同字段到一亿次。先不加对齐地跑一次,然后用 `alignas(64)` 把两个字段分别对齐到不同缓存行再跑一次,对比耗时。