🐧 第六章 存储层次
📄 一句话概括

一共拷 4 MB,但每次只拷一小块、块之间跳着走:每块 8 字节时每字节 62.21 纳秒,每块 4096 字节时只要 0.48 纳秒。一次搬得太少,缓存行就白搬了。

📝 本节和前面几节的关系

6.3 讲了几层,这一节做实验:把【一次搬多少】变成变量,看摊到每字节上的成本怎么变。

一、实验怎么设计

固定总量:一共拷 4 MB。 固定间隔:每块之间跳 4 MB,所以每次都落在一个【平时不碰】的地方。 只改块的大小:8、16、32、64、256、4096 字节。 这样就把【搬多少】和【摊得多薄】分开了。 这就是实验设计的三件事:观察什么、控制什么、排除什么。

二、结果:块越小越亏

8 字节:每字节 62.21 纳秒。64 字节:5.72 纳秒。4096 字节:0.48 纳秒。 为什么:每次搬运都至少要动一条 64 字节的缓存行。只拷 8 字节,剩下 56 字节白搬。 块一大,这条线的开销就摊薄了——同样是搬一条线,用得越满越划算。 注意这里还叠了【每次调用的固定开销】(Python 的切片、循环),所以 8 字节那一行的 62 纳秒不全怪缓存;但趋势是清楚的:块越大越便宜。

三、缓存行是 64 字节,这个数从哪来

它是硬件规格:现代 x86 与 ARM 的缓存行都是 64 字节。 本次实验没有直接测出 64 这个数——Python 的解释开销比搬一条缓存行还贵,所以看不到干净的台阶。 要亲眼看到台阶,用 C 写一个指针追逐的循环,步长从 4 递增到 128——台阶一定出现在 64。 老一些的 CPU 是 32 字节,一些 ARM 是 128,所以这个数要查,不能背。

四、缓存行的三个后果

一、相邻数据会被顺带搬进来——所以按顺序用最划算。 二、无关数据也会被搬进来——所以结构体要按访问频率排字段,把热字段挤在同一条线上。 三、两个核改同一行会互相踢——这叫伪共享。两个线程改的是两个不同变量,但在同一条缓存行里,于是两个核不停地让对方缓存失效。 第三条最坑:程序逻辑完全正确,就是慢得莫名其妙。对策是【填充(padding)】:在热变量之间塞几十字节,把它们分开。

五、动手 Demo:关于高速缓存的实验——一次搬多少才不浪费

这一节固定【一共拷 4 MB、每块之间跳 4 MB】,只改每块多大,看每字节的成本怎么掉下来。

total, gap = 4 * MB, 4 * MB
for blk in (8, 16, 32, 64, 256, 4096):
    cnt = min(total // blk, 40000)
    t0 = time.perf_counter()
    for i in range(cnt):
        off = (i * gap) % (n - blk)
        dst[off:off + blk] = src[off:off + blk]
    t1 = time.perf_counter()
    per = (t1 - t0) * 1e9 / (cnt * blk)
    print(blk, "字节一块", per, "纳秒/字节")

实跑输出:

--------------------------------------------------------------------
关于高速缓存的实验——一次搬多少才不浪费
--------------------------------------------------------------------
① 一共拷 4 MB,但每次只拷一小块、块与块之间跳着走

  每块多大      相当于几条缓存行     拷了多少字节     每字节折算
  ---------------------------------------------------------------
  8 字节        0.12 条              312 KB           45.60 纳秒
  16 字节       0.25 条              625 KB           28.18 纳秒
  32 字节       0.50 条              1250 KB          12.84 纳秒
  64 字节       1.00 条              2500 KB          6.34 纳秒
  256 字节      4.00 条              4096 KB          2.81 纳秒
  4096 字节     64.00 条             4096 KB          0.52 纳秒

  **每块 8 字节时最亏**:**每次搬运都至少要动一条 64 字节的缓存行,**
  **而这一趟只用了 8 个字节——剩下 56 个字节白搬。**
  **块一大,这份固定开销就摊薄了**——**同样是搬一条线,用得越满越划算。**

② 缓存行的三个后果

  后果                       说明
  --------------------------------------------------------
  相邻数据会被顺带搬进来     所以按顺序用最划算
  无关数据也会被搬进来       所以结构体要按访问频率排字段
  两个核改同一行会互相踢     这叫伪共享,多线程性能杀手

  **最坑的是第三条**:**两个线程改的是两个不同变量,但它们在同一个缓存行里,**
  **于是两个核不停地互相失效对方的缓存**——**程序看着没问题,就是慢得莫名其妙。**

③ 64 这个数是从哪来的

  **它是硬件规格**:**现代 x86 与 ARM 的缓存行都是 64 字节。**
  **本次实验没有直接测出 64 这个数**——**Python 的解释开销比搬一条缓存行还贵,**
  **干净的台阶要用 C 的指针追逐才看得见。**
  **但【一次搬 64 字节、只用一点点很亏】这件事,上面那张表已经说清了。**

8 字节一块:62.21 纳秒每字节。4096 字节一块:0.48 纳秒每字节——差 130 倍。 注意这个 130 倍里有两部分:一部分是缓存行白搬,一部分是每次调用的固定开销。 两者都随块变大而摊薄,所以结论一样:别一小块一小块地搬。 工程上的对应做法:把小的多次读写攒成大的——这正是 6.9 到 6.12 要讲的缓冲与批量。 一句话:访问的【次数】比访问的【总量】更值钱。

📋 Linux 上看到真实的缓存行大小:getconf LEVEL1_DCACHE_LINESIZE——一般直接给 64。

30 秒实验

  1. getconf LEVEL1_DCACHE_LINESIZE,确认这台机器的缓存行大小。
  2. 把上例的间隔从 4 MB 改成 0(就是连续拷),看每字节成本掉到多少。
  3. cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size,另一种读法。

常见误区

  1. 【缓存行一定是 64 字节】。大部分 x86 是,但也见过 32 与 128,要查。
  2. 【伪共享是真共享】。恰恰相反——两个核改同一行会互相让对方失效,所以叫【伪】共享,是有害的。
  3. 【一次搬得越多越好】。过了缓存行就没额外收益,再大只是摊薄固定开销,收益递减。
自测三问(点开对答案) 1. 为什么每块 8 字节时最亏? —— **每次搬运至少要动一条 64 字节的缓存行**,**只用 8 个字节,剩下 56 个白搬。** 2. 什么是伪共享?怎么解决? —— **两个核改的是不同变量,但它们在同一条缓存行里**,**于是互相让对方缓存失效。**对策是**填充,把变量分到不同行。** 3. 这个实验最实用的一句话是什么? —— **访问的次数比访问的总量更值钱**——**把多次小访问攒成一次大访问,是性价比最高的优化之一。**

相关笔记