Skip to content

필사 모드: 一条指令可能要跑 62 秒 —— 延迟是路径的属性,不是指令的属性

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 —— 在 1 个周期与 1,980 亿个周期之间

指令延迟表是做优化的人的基本工具。乘法 3 个周期、除法 20 个周期,就这样记下来再写代码。

2026 年 8 月公开的 Assembly Hall of Shame 走的是反方向。副标题是「Racing to the bottom of CPU performance」。这是一份把单条指令跑到最慢的比赛排行榜。

垫底的第 27 名是 nop,1 个周期。第一名是 fxrstor64,1,980 亿个周期,换算成时间是 62 秒

如果同一类东西之间能差出 2,000 亿倍,那你首先该怀疑的是「用一把尺子去量这一类东西」这个前提。本文从下往上读这份榜单,看是什么造成了这种差距,以及它对你的代码和基准测试意味着什么。

规则造就的东西 —— 什么才算「一条指令」

在这类比赛里,规则本身就是论点。仓库里写下的规则是这样的。

  • 准备阶段用什么都可以,但计分的只有单独一条指令
  • 对于会陷入陷阱、被模拟或被虚拟化的指令,只计到陷阱为止,不计处理程序。
  • 指令不得是可中断的。像 rep movspause 这样的直接判负。
  • 时间按 CPU 基础时钟归一化。
  • 所有平台必须是出厂设置,禁止改动硬件。

第三条规则让这场比赛变得有意义。如果允许可中断的指令,用带重复前缀的字符串指令就能造出任意数字。要求不可中断,数的才是流水线真正被这一条指令拴住的时间

第二条规则同理。允许会陷入陷阱的指令,测的对象就不再是指令,而是操作系统的处理程序。

正因为有这两条规则,榜单才不是一场杂耍,而成了一份微架构观察记录

第一区段 —— 核心内部微码介入的地方

下半段全都是发生在核心内部的事。测量大多在 Intel Core i7-8559U 上进行。

名次指令周期发生了什么
27nop1什么都没发生
26nop1620加了七个 data16 前缀的长 nop
25rdtsc49基准点
24idiv77用 128 位被除数走完除法器最长的路径
23enter112用最大嵌套深度触发微码的 display 遍历
22fldl133加载非规格化数进入 FP 微码辅助
20fsin257指数为 0x7ff 的特殊值处理路径
17fadd677非规格化操作数导致的 FP 微码辅助
15fdiv883非规格化除数
14cpuid1,248选取延迟最大的那个 leaf

这一区段的共同模式已经看得出来。给硬件快速路径一个它处理不了的输入,控制权就会交给微码。

fadd 这一条尤其有教益。同一条指令,仅仅因为操作数是非规格化数就要花 677 个周期。正常的浮点加法只有几个周期。指令没变,只有数据变了,倍率就多出了两个数量级。

enter 这一条也很有意思。把嵌套深度给到最大值 31,就会走上加载并压入 30 个 display 指针的微码路径。指令编码上是允许的,但现代编译器绝不会生成这种形式。

第二区段 —— 一致性与平台介入的地方

从中段开始,事情就不止于一个核心了。

名次指令周期发生了什么
21clflush165驱逐脏行
19mfence326用 16 条 movnti 把写合并缓冲填满后全部排空
18mov cr3352TLB 全量失效
16split lock865跨缓存行的 lock xaddl 触发外部总线锁
13rdrand5,579硬件熵池枯竭后等待恢复
12wrmsr34,304写 Zen 的 MCG_CTL。推测会同步到片外单元
11out49,857跨 NIC 寄存器边界的端口写入使 TX DMA 停摆
9wbinvd1,616,480把整个缓存层级填成脏数据后回写到 DRAM

第 16 名的 split lock 是实务中最常遇到的一条。当原子操作的操作数跨越缓存行边界时,CPU 无法使用快速的 MESI 缓存一致性路径,只能加外部总线锁。它要花 865 个周期,期间其他核心也会受影响。

第 9 名的 wbinvd 高达 160 万个周期,也值得留意。这不是因为指令复杂,而是因为缓存里所有的脏数据都必须被推到 DRAM。也就是说,这条指令的成本不是由指令决定的,而是由那一刻缓存的状态决定的。

第 12 名 wrmsr 条目里的推测也值得引用。仓库写道,这看起来是微码在停止并同步分布于多个硬件单元的机器检查库,其中一部分位于片外,因此似乎需要的不是简单的本地寄存器写入,而是总线级别的通信。作者本人已表明这是推测,因此原样搬过来。

第三区段 —— 走出芯片的那一刻

上半段的性质完全不同。

名次指令周期时间
8inl(ACPI PM 端口)12,524,4153.92 毫秒
7movl(MMIO GPU 寄存器)443,937,696139 毫秒
6movq(8 字节 MMIO)887,716,864278 毫秒
5vmovdqu xmm(16 字节)1,774,555,776556 毫秒
4vmovdqu ymm(32 字节)3,549,079,2961.111 秒
3vmovdqu ymm(非对齐 32 字节)4,453,212,2561.394 秒

这里的全都是 mov。除了搬数据什么也不做的指令,跑了一秒多。

原因在地址上。这些地址不是 DRAM,而是PCIe 总线另一侧的设备寄存器。作者用另一个叫 mmiotic 的工具翻遍 MMIO 空间,找出了响应最慢的区域。

而从第 6 名到第 3 名的增长模式,把这一区段的原理直接摊了出来。把访问宽度从 8 字节加到 16 字节再加到 32 字节,时间大致每次翻倍。按仓库的说明,这是因为一次 8 字节的 MMIO 读取会被拆成两次双字寄存器访问。32 字节就是八次,弄成非对齐就是九次。

这里的关键是这一点。MMIO 读取是非投递式事务。 写入可以发出去就忘掉,读取却必须等到响应。所以往返时间就直接变成了指令的执行时间。关于第 3 名,仓库写道,非对齐的 32 字节 MMIO 读取「技术上并不被允许,但反正它能跑」。

第一名的策略证明了什么

现在轮到第一名。

; CPU 0 - 被测量的指令
movl $0xfcc68830, %rsi
fxrstor64 %rsi

; CPU 1..N - 敲打另一个高延迟位置的锤击循环
movl 0xfcc68858, %eax

fxrstor64 是从内存恢复 512 字节 FPU/MMX/XMM 状态的指令。让它从最慢的 MMIO 区域去读这 512 字节,就成了上面第 3 名那套手法的加强版。仅此一项就是 74,584,168,512 个周期、23.35 秒。

第一名在这上面又叠了一层。在那次加载进行的同时,其余的核心以 4 字节为单位不断敲打另一个高延迟的 MMIO 寄存器。 PCIe 根复合体与端点被非投递式事务塞满,CPU 0 的那次 512 字节加载只能排在后面。

结果是 198,002,498,236 个周期、62 秒。也就是说,一条指令的执行时间,因为其他核心在做什么而变化了两倍以上。

这个实验所证明的命题很清楚。「这条指令是几个周期」这个问题,不把周边状态固定住就没有答案。 表里写的数字不是指令的属性,而是指令与系统状态这个组合的观测值。

顺带一提,这套手法也带来了实用的结果。仓库写道,第 3 名那条非对齐的 ymm 加载被用来打破 System Management Mode 的根本设计,并链接了另一个仓库。只要能把中断任意拉长,以原子性为前提的设计就会被打破。

这张表在讲你的基准测试

榜单看起来像是极端的玩闹,但结论可以原封不动地套用到平常的性能工作上。

微基准测试测的是系统状态,不是指令。 同一段代码,在缓存温热与冰冷时、其他核心空闲与繁忙时、数据是规格化数与非规格化数时,会给出完全不同的数字。这就是引用基准测试结果时必须一并写明条件的原因。

最坏情况不是平均值的倍数。 上面表格里的各个区段并不连续,而是被悬崖分开的。平均延迟再漂亮,只要踩中一次悬崖,那一个请求就慢一百倍。处理尾延迟时,找出并消除悬崖比改善平均值更有效。

同样的指令流也会受其他核心活动的影响。 第一名的案例是极端情形,但原理相同。共享资源一路从末级缓存延伸到内存控制器、互连,直到 I/O 总线。这就是单独运行的基准测试预测不了生产环境性能的原因。

实际会绊倒你的三件事

最后,把这张表里与实务直接相关的三条整理出来。

非规格化数。 表里的第 22、17、15 名全是这个。在信号处理或物理仿真这类数值会缓慢收敛到 0 的代码里,它是真会出现的。症状是「同一段代码会因输入数据不同而突然变慢」。应对方式因硬件和语言而异(x86 的 flush-to-zero 与 denormals-are-zero 模式是代表性做法),需要各自查阅平台文档。这里重要的是:只有知道这道悬崖存在,你才找得到原因。

split lock。 表里的第 16 名。Linux 内核提供了检测这一现象的功能。内核文档把 split lock 定义为「操作数跨越两个缓存行的所有原子操作」,把总线锁定义为「对回写内存的 split lock 访问,或对非回写内存的所有加锁访问」。启动参数 split_lock_detect 接受 offwarnfatalratelimit:N 这些值,文档中记载的默认值是 warn。如果内核日志里出现了相关警告,那很可能就是一个真实的性能问题。

把 MMIO 当内存来用的代码。 表的上半段整个都在讲这件事。语法只是一条 mov,成本却可能是 DRAM 访问的一百万倍。如果驱动或嵌入式代码在循环里读设备寄存器,那个循环就不是在计算,而是在反复做 I/O 往返。值得考察一下:能不能把状态轮询换成中断,能不能一次读多个寄存器,能不能把读改成写。

仓库表示目前只填了 x86 的榜单,ARM 与 RISC-V 还在准备中。等其他架构的清单填上来,哪些悬崖是 x86 独有的、哪些是现代 SoC 的普遍性质,就会更清楚了。

参考资料

这份榜单里的任何一条我都没有亲自运行过。其中相当一部分是会让系统停摆或让设备不稳定的操作,而仓库里的实测值是针对特定硬件(主要是 Intel Core i7-8559U 与 AMD Ryzen 7 5800H)的数值。

현재 단락 (1/80)

指令延迟表是做优化的人的基本工具。乘法 3 个周期、除法 20 个周期,就这样记下来再写代码。

작성 글자: 0원문 글자: 4,671작성 단락: 0/80