2026年9月27日
如果预取器猜错了我的代码怎么办?
机器的默认设置是别人替你做的猜测。我把 Intel 的硬件预取器做成了 /proc 里可以按核心开关的旋钮,好拿 goblin-store、goblin-core,甚至 Python 解释器,去对比一台不再瞎猜的 CPU。
你买来的每一台机器,出厂时就已经被调好了。有人替你选定了内存时序、频率调节策略、预取器设置,以及另外上百个默认值,而他们是按一个通用负载来选的——那多半不是你的负载。我们把这些选择当成硬件本来的样子,但它们其实是选择,而性能上的决定从来都不是免费的。哪怕是好的决定也有代价,只不过代价花在了你没去看的地方。
硬件预取器是我最喜欢的例子,因为它看上去全是好处。现代的 Intel 核心会盯着你的内存访问,一旦它觉得看出了某种规律,就会在你开口之前把接下来的缓存行拉进缓存。猜对了,数据就等在那儿,这次读取几乎不要钱。在跑这套东西的 Xeon 上,一共有四个这样的预测器:一个 L2 流预取器、一个 L2 相邻缓存行预取器,还有两个在 L1,即数据缓存的下一行预取器和 IP 历史预取器。它们默认全开,而且大多数时候谁也不会去想它们。
容易被忘掉的是下面这一点。缓存是有限的。预取器投机拉进来的每一行,都得顶掉一行本来就在那儿的数据。预测对了,你赚了;预测错了,你要付两次钱:一次是为一趟根本不需要的取数付出的内存带宽,另一次——也是更贵的那次——是因为你换出了一行还活着的数据,把一次未来的命中变成了未命中。一次糟糕预取的代价不在于那趟取数,而在于你为它腾地方而丢掉的东西。预取用换出并使之失效的缓存内容作为机会成本,换来了对延迟的掩盖。
对于那种又长又线性、可预测的扫描负载来说,这笔买卖很划算。可对于一个到处追逐指针、或者数据只流过一次、或者故意以不可预测的顺序遍历哈希表的负载来说,预测器可能会系统性地猜错,那它就不再是补贴,而是一笔你在任何只统计自己指令的性能分析器里都看不见的税。
这些旋钮并不神秘。在某些机器上,你可以在 BIOS 里开关这些预取器。但 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 预取器"并不是单个线程能独自回答的问题。我用保守的方式解决了它:只要同一核心上任意一个兄弟线程要求关闭,这个预取器就在该核心上被关闭。对于隔离某个效应来说这是正确的默认值,而当你故意把两个进程放到同一个核心的两个线程上、观察它们相互干扰时,这恰恰是你想要的配置。为了让整件事便宜到可以一直开着,每个核心都会缓存它已经写入的值,于是真正的寄存器只在生效设置发生变化时才被写入,而不是每次上下文切换都写。
在它旁边,我还留着一个未经修改、其余部分完全相同的内核作为基线。这是把三件不同的事分开来的唯一诚实办法:这个特性存在的代价、使用它的代价,以及预取器本身的效应。
这就说到了我一开始为什么想要它。我有两样我在乎的存储软件,goblin-store.dev 和 goblin-core.dev,它们的访问模式并不通用。存储引擎一辈子都在非常刻意地决定:碰哪些字节、按什么顺序碰。这正是那种代码——硬件对我接下来会读什么所做的猜测,可能在帮我,也可能在悄悄换出我马上就要用的那些页。我想拿它们去跑一台已经不再瞎猜的机器,看看结果往哪边动。而旋钮一旦存在,就很难忍住不把它对准别的东西——比如 Python 解释器,那玩意儿不过是把指针追逐打扮成了一门语言。
这件事有一个让人谦卑的版本,和一个好笑的版本,而它们也许是同一个版本。这些年我一直是在预取器开着的情况下给这套软件调优的,也就是说我做过的每一个布局决定,都是在它们在场的前提下做出的。也许它们一直在拯救一个我本该去修的数据结构;也许它们一直在跟一套精心设计的访问模式对着干,而我一直在围着这份损害做优化。要真是我们一路以来都做错了选择,那可就有意思了——不是因为我们推理得糟,而是因为我们是在一个没人专门替我们选过的默认值之上推理的。
我还没有数字。工具已经做好,机器此刻正带着打过补丁的内核启动。但这套搭建本身,就是我想先讲的那个论点:默认值是一种猜测,猜测是有代价的,在信任它之前,我至少能做的,是给它装上一个我能随手拨动的开关。