Skip to content

Commit 67743a5

Browse files
fix(vol1): 修正 cache 步长实验量纲错误(总耗时→每次访问耗时) (#68)
原版用「遍历数组的总耗时」展示 Cache 效应,但 i+=stride 使步长 翻倍访问次数减半,总耗时被访问次数主导而单调下降,cache 命中差异 被完全淹没——参考输出(68ms→223ms 上升)在任何真实机器上都复现不出来。 改用 clock_gettime(CLOCK_MONOTONIC) 测墙上时间,输出 per_access=总时间÷访问次数,剥离访问次数干扰;同步重写解释段、 踩坑预警、练习题。修正后 per_access 在 stride=16(64B 缓存行边界) 处出现拐点,与文字解释自洽。 Closes #67
1 parent 8bae16a commit 67743a5

1 file changed

Lines changed: 42 additions & 21 deletions

File tree

documents/vol1-fundamentals/c_tutorials/advanced_feature/02-cache-and-memory-hierarchy.md

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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

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

0 commit comments

Comments
 (0)