在《如果预取器猜错了我的代码怎么办?》中,我介绍了这个开关,以及准备做的实验。现在,CPython、Valkey 有序集合和 SPY 报价查询的测量结果已经出来了。在这台机器上,应该怎样设置预取器,取决于负载以及它使用的内存位于哪里。

硬件预取器是我最喜欢的例子,因为它看上去全是好处。现代的 Intel 核心会盯着你的内存访问,一旦它觉得看出了某种规律,就会在你开口之前把接下来的缓存行拉进缓存。猜对了,数据就等在那儿,这次读取几乎不要钱。在跑这套东西的 Xeon 上,一共有四个这样的预测器:一个 L2 流预取器、一个 L2 相邻缓存行预取器,还有两个在 L1,即数据缓存的下一行预取器和 IP 历史预取器。它们默认全开,而且大多数时候谁也不会去想它们。

这里有一件容易被忘掉的事:一次预取花掉的是两种稀缺资源。第一种是缓存容量。缓存是有限的,所以预取器投机拉进来的每一行,都得顶掉一行本来就在那儿的数据——而一旦猜错了,你就换出了一行还活着的数据,把一次未来的命中变成了未命中。这就是一次糟糕预取的机会成本:代价不在那趟取数本身,而在你为它腾地方而丢掉的东西。第二种是内存带宽。每一次投机取数,无论对错,都是内存总线上实打实的流量,而这条总线是共享的——在一个插槽的各个核心之间共享,跨到另一个插槽的链路上则更加狭窄。当它被压饱和时,一次被浪费的预取不只让你损失一行缓存;它是直接从那些真正卡住你程序的按需读取那里把带宽抢走。所以一次糟糕的预取可以以两种不同的方式伤到你——污染一个本就没有余地的缓存,或者烧掉一条本就没有余量的总线——而究竟是哪一种咬你,取决于你的瓶颈是容量还是吞吐。

对于那种又长又线性、可预测的扫描负载来说,这笔买卖很划算。可对于一个到处追逐指针、或者数据只流过一次、或者故意以不可预测的顺序遍历哈希表的负载来说,预测器可能会系统性地猜错,那它就不再是补贴,而是一笔你在任何只统计自己指令的性能分析器里都看不见的税。

有些机器的 BIOS 提供预取器开关。这些设置作用于整台机器,修改后需要重启。这样也能做 A/B 对比,只是来回重启让每轮实验都慢而麻烦。我想测量它们的影响,也想写一个内核补丁,让每个进程都能选择自己的预取器设置。这相当于把又一项硬件控制交给内核,通过 /proc 提供按进程配置的接口。

于是我把它做了出来。这是给主线 Linux 内核打的一个小补丁。每个任务都带着一份掩码,标明它想关掉哪些预取器;内核在上下文切换时写入 Intel 的 MSR_MISC_FEATURE_CONTROL(0x1A4),于是这个设置会跟着进程走,无论它被调度到哪个 CPU 上。用户态通过一个出现在每个进程下的目录来控制它:

ls /proc/self/prefetch_disable/
# l1_ip  l1_stream  l2_adjacent  l2_stream

echo 1 > /proc/self/prefetch_disable/l1_stream   # 关掉 L1 下一行预取器
echo 1 > /proc/self/prefetch_disable/l1_ip        # 再关掉 L1 IP 历史预取器
cat /proc/self/prefetch_disable/l2_stream         # 0 == 仍然开启

每个文件不是 1(这个预取器对该任务关闭)就是 0(开启)。一个进程可以设置自己的,或者在权限允许的情况下设置另一个进程的——当你想拖慢的那个东西并不是握着 shell 的那个进程时,这一点就很要紧。

文件 预取器
l2_stream L2 硬件流预取器
l2_adjacent L2 相邻缓存行(128 字节一对)预取器
l1_stream L1 数据缓存下一行预取器
l1_ip L1 数据缓存 IP 历史预取器

这里有一个确实有意思的小麻烦。这个寄存器是按核心算的,而一个核心有两个超线程共用它。所以"这个进程想关掉 L2 预取器"并不是单个线程能独自回答的问题。我用保守的方式解决了它:只要同一核心上任意一个兄弟线程要求关闭,这个预取器就在该核心上被关闭。对于隔离某个效应来说这是正确的默认值,而当你故意把两个进程放到同一个核心的两个线程上、观察它们相互干扰时,这恰恰是你想要的配置。为了让整件事便宜到可以一直开着,每个核心都会缓存它已经写入的值,于是真正的寄存器只在生效设置发生变化时才被写入,而不是每次上下文切换都写。

在它旁边,我还留着一个未经修改、其余部分完全相同的内核作为基线。这是把三件不同的事分开来的唯一诚实办法:这个特性存在的代价、使用它的代价,以及预取器本身的效应。

先问一句:这开关真的管用吗?

在相信任何一个结果之前,我先让这个旋钮在一个必定喜欢预取的负载上自证清白:顺序扫描一个远大于 30 MB 末级缓存的数组。在单核上:

顺序扫描 512 MB 带宽
全部预取器开启 9.0 GB/s
关掉 L2 流预取器 3.7 GB/s
全部关闭 2.45 GB/s

把预取器关掉,顺序扫描慢了 3.7 倍,完全符合预期——现在每次按需读取都要吃满内存延迟,而不再是发现下一行早已备好。在核心上读取 MSR 0x1A4 确认了:进程运行时这些位确实翻转,进程一退出就清零。开关是有效的。接下来才是有意思的部分:那些答案并不显而易见的负载。

解释器:完全无所谓

我从 CPython 开始,因为"让 Python 更快"是个经久不衰的痒处。我跑了官方的 pyperformance 套件——richards、chaos、raytrace、float、spectral_norm、nqueens、fannkuch、nbody、go——把四个预取器的全部十六种开关组合都跑了一遍。

什么都没有。每个基准测试都停留在全开基线的 ±1% 以内,而那些微小的偏差朝两个方向乱跳——这是噪声的特征,不是信号。原因既乏味又彻底:这些解释器负载的工作集很小,就住在 L1 和 L2 里,所以预取器既没什么可猜的,也没什么可污染的。默认值就是无关紧要。如果你原本指望是 CPU 的投机在暗中拖累你的解释器,那并不是。

这一轮里有一个值得记下的教训,因为我差点骗了自己:我第一次跑的时候把十六种情况分散到四个 NUMA 节点上,得出了一个漂亮的 −4%"胜利"。那是个假象——基线恰好落在了最繁忙的节点上。把十六种情况都放到同一个安静的核心上重跑,它就消失了。在这么小的效应上,各种干扰因素比你要测的东西还大。

Valkey 有序集合:预取器在布局面前只是个零头

那就来点有真实工作集的。我往一个 Valkey(Redis 的分支)有序集合里灌了 500 万个成员——那是一个跳表,全是指针追逐——然后用 ZRANGEBYSCORE ... LIMIT 0 128 范围扫描去猛敲它,同时按 PID 切换服务器的预取器。

预取开启比关闭快了大约 14%。一个令人愉快的、普通的结果:跳表的底层是一个有序链表,扫描按顺序遍历它,所以有足够的顺序性让预取器帮上忙。

只不过这个"14%"其实是一个关于我怎么构建这个集合的故事。我是按分数顺序插入成员的,这让节点在内存里大致连续排布。当我用打乱的顺序重建同一个集合——内容相同,但节点四散——预取的好处就塌缩到了 3% 以下,而整件事无论预取开不开都慢了 34%。再对分配器做一轮反复增删,情况更糟。

这个 5M 有序集合是怎么建的 吞吐 预取值多少
按分数顺序插入 ~41,000 次/秒 +14%
打乱顺序插入 ~27,000 次/秒 +3%
反复增删 ~26,000 次/秒 +3%

预取器最多值 14%。插入顺序值 37%。 新增的按进程控制功能,让我能测出预取对每种内存布局究竟有多大帮助。改变插入顺序,既改变了整体吞吐量,也改变了预取带来的收益。

行情数据:同样的字节,相反的答案

真正的回报来自一个我真正在乎的负载:用 massive-speedup.dev 库在一天的 SPY 报价里做检索。我用了一个编译好的二进制报价数据库——定长 83 字节的记录,按时间戳排序,直接从 RAM 支持的 tmpfs 里 mmap 出来,所以磁盘从头到尾都不在画面里——并把数据钉在一个 NUMA 节点上,这样我还能问第二个问题:当内存住在 QPI 总线的另一头、在另一个插槽上时,答案会变吗?

那个 83 字节的记录正是关键所在。一个缓存行是 64 字节,所以一条记录永远装不进一行:每条记录都至少跨越两行,而在一行里起始得足够靠后的那些记录会跨越三行。读一条记录,你就需要它所在的那一行以及它的后一行——而且大约四分之一的时候,还要再后一行。

第一个负载:一次读一条随机记录,通过数据库的 Python API。这里预取器帮了忙,原因恰恰就是那个跨行——L1 下一行预取器会在你开口之前把记录的第二行取进来。而它帮多少,完全取决于内存在哪:

一次读一条随机记录,12 核 预取值多少
数据在本插槽 ~+10%
数据跨越 QPI 总线 +19%(其中仅 L1 下一行预取器就 +11%)

在本地,第二行的未命中很便宜(约 90 ns),所以把它预取出来省不了多少。跨越插槽边界后,那次未命中很贵,于是下一行预取器掩盖了一次真实的停顿,价值翻了一倍。同样的代码,同样的数据——预取器的价值随着内存有多远而缩放。

第二个负载:一次随机二分查找,在同样的记录上,用 C++ 写的,这样就没有解释器开销可以藏身。每次查询要做约 21 次相互依赖、四散的探测,而少了 Python 挡在中间,一个插槽上的 24 个线程真的把内存链路压饱和了。这时预取器成了自伤的枪:

随机二分查找,跨 QPI 吞吐 相对全开
全部预取器开启 7.71 M 次查询/秒 —
关掉 L2 流预取器 9.42 M 次查询/秒 +22%
全部关闭 9.64 M 次查询/秒 +25%

把 L2 流预取器关掉,让它快了 22%。二分查找在最初几跳会在整个文件里不可预测地跳来跳去,于是流预取器投机取来的那些行从不会被用到——而在一条已经饱和的跨插槽链路上,那些被浪费的流量直接从按需读取那里偷走带宽。在本地,带宽有富余,关掉它毫无变化(+1.5%)。给每个核心加上第二个超线程,收益涨到 +26%,因为更多线程让饱和更深。(这些是 5 次运行的均值;运行间的噪声不到 1%,所以这些效应离噪声差得很远。)

于是这就是同一台机器、同一份 SPY 报价,给出的截然相反的建议:

负载 状态 结论 是谁干的
CPython(pyperformance) 装得进缓存 无所谓(±1%) —
Valkey 有序集合扫描 有序跳表 保持开启(+14%),但布局值 37% —
SPY 随机读一条记录 83 字节记录跨行、延迟受限 保持开启(跨 QPI +11%) L1 下一行预取器(功臣)
SPY 随机二分查找 四散、带宽饱和 关掉(跨 QPI +22%) L2 流预取器(自伤)
合成的跨步扫描 稀疏、带宽饱和 关掉(+62%) L2 流预取器(自伤)

结论同时取决于三件事:你的访问模式、你是卡在延迟还是带宽上,以及——大家会忘的那个——内存是在本地还是在一个插槽之外。只要你饱和了、而且并不是在真正地做流式访问,L2 流预取器就是个可靠的带宽浪费者。只要你的记录跨越缓存行、而且痛点是延迟,L1 下一行预取器就是个默默无闻的功臣。不同的单元,不同的结论,而对一个进程正确的设置,对它旁边的进程恰恰是错的。

所以它必须是按进程的

上面这句话,就是把它做成 /proc 旋钮、而不是留在 BIOS 里的理由。在一台共享的机器上,二分查找器在猛敲远端内存时想把 L2 流预取器关掉,而紧挨着它跑的记录读取器想把 L1 下一行预取器开着。一个全局的、改一次要重启的 BIOS 开关没法同时满足这两者。一个按进程的开关——从用户态设置、在上下文切换时生效——可以,而且只在那些真正在碰远端内存的核心上生效。

自己动手试试

内核补丁和那个小小的用户态助手都在这里,基于 Linux v7.3-rc1 构建: distribution.goblinreactor.com/linux-prefetch-control/ ——下载 prefetch-control.tar.zst(补丁、prefetchctl.c 和一份 README)。

# 解包
zstd -d prefetch-control.tar.zst && tar xf prefetch-control.tar

# 打到一棵主线树上,并打开这个特性
git clone https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
cd linux && git checkout v7.3-rc1
git am ../prefetch-control/0001-x86-prefetch-*.patch
./scripts/config --enable X86_PREFETCH_CTL          # 默认 y,在 Processor type and features 下
make -j"$(nproc)" && sudo make modules_install install && sudo reboot

# 然后拨动开关——1 = 关闭,0 = 开启——按进程:
echo 1 > /proc/self/prefetch_disable/l2_stream      # 仅对当前 shell 关闭
gcc -O2 -o prefetchctl prefetch-control/prefetchctl.c
./prefetchctl exec l2_stream -- ./mybench           # 关掉它来跑一个基准
./prefetchctl disable l2_stream "$(pgrep -n victim)"  # 或者对准另一个进程

只要同一核心上任意一个超线程要求关闭,该预取器就在这个核心上被关闭;掩码会跟着进程穿过上下文切换;它在 exec 之后仍然保留,但在 fork 之后不保留——所以要包住你真正在测量的那个进程,而不是它的启动器。

老实说的几点保留:这是一台机器,一台四插槽的 Ivy Bridge Xeon,而预取器的行为在不同的微架构上并不一样。每一次运行都钉在固定的核心上、锁死时钟频率、把内存有意地放在选定的 NUMA 节点上,并且每一份掩码都通过读 MSR 确认它确实生效——因为,正如那轮解释器测试提醒我的,在这么小的效应上,只要你放任,测量装置自己就能编出一个结果来。

但我出发时想验证的那件事站住了脚。预取器的默认值是一个猜测。对我的解释器来说,这个猜测怎么都不花钱。对我的有序集合来说,它是一个骑在一个我并不知道自己在做的、大得多的选择之上的小帮助。对读记录来说,它在默默救我,数据越远救得越多。而对查记录来说,它在默默向我收税,最高到 22%。要知道这几件事里,正在发生在你的代码上的是哪一件,唯一的办法就是拨动那个开关去测量——所以我把那个开关做了出来。