一条指令也可能花 62 秒 — 延迟是路径的属性
一句话总结
如果不固定周边状态,“这条指令需要多少个周期”就没有唯一答案。延迟不是指令本身的属性,而是对指令与系统状态这一组合的观测结果。
为什么需要它
学习性能优化时,人们往往先背指令延迟表:加法 1 个周期、乘法 3 个周期、除法 20 个周期。这张表很有用,但一旦把这些数字当成指令固定不变的性质,就无法解释真实世界中的性能。
2026 年发布的 Assembly Hall of Shame 排行榜把这一点展示到了极致。它的副标题是“奔向 CPU 性能谷底的竞赛”,记录的是如何让一条指令运行得最慢。榜尾的 nop 需要 1 个周期,而榜首的 fxrstor64 需要 1980 亿个周期,也就是 62 秒。同一类别中的差距能达到 2000 亿倍,那么首先就该怀疑:用一个数字衡量整个类别的前提是否成立。
它如何工作
从榜尾向榜首阅读这张排行榜,就能得到一份现代 CPU 可能停顿的位置清单。它可以分成三个区间。
第一,核心内部由微码介入的位置。 如果输入无法由硬件快速路径处理,控制权就会转交给微码例程。fadd 处理正常操作数只需几个周期,但操作数是非规格化数(denormal)时却需要 677 个周期。指令完全没变,只改变数据就产生了数十倍的差距。
第二,一致性与平台介入的位置。 如果原子操作的操作数跨越缓存行边界(分裂锁,split lock),就无法使用快速的缓存一致性路径,只能锁住外部总线。这会耗费 865 个周期,并在此期间影响其他核心。清空整个缓存的 wbinvd 需要 160 万个周期,并不是因为这条指令本身复杂,而是因为必须把当时缓存里的所有脏数据都推送到 DRAM。
第三,离开芯片的瞬间。 排名靠前的指令全都是 mov。这些除了搬运数据什么也不做的指令,却会花费超过 1 秒。原因在于地址:它读取的不是 DRAM,而是 PCIe 互连另一端的设备寄存器(MMIO)。MMIO 读取是必须收到响应才能结束的非过账事务,因此往返时间会原封不动地成为执行时间。
第一名的策略最为关键:让系统从最慢的 MMIO 区域恢复 512 字节的状态,就会耗费 23 秒;再让其他核心不断访问不同的 MMIO 寄存器,时间便会增至 62 秒。也就是说,一条指令的执行时间会随着其他核心正在做什么而相差一倍以上。
实际工作中的表现
这项极端实验得出的结论,可以原样应用到日常性能工作中。
微基准测试测量的不是指令,而是系统状态。 同一段代码在缓存热与冷、其他核心空闲与繁忙时,会得到截然不同的数字。这就是引用基准测试结果时必须同时写明测试条件的原因。
最坏情况不是平均值的某个倍数。 上述区间并不连续,而是由一道道性能悬崖分隔。平均延迟再优秀,只要踩中一道悬崖,单个请求就可能慢上百倍。处理尾延迟时,与其改善平均值,不如找出并消除这些悬崖。
实际工作中常见的三类悬崖是:数值趋近于零的信号处理代码中的非规格化数;未考虑对齐的原子操作造成的分裂锁(Linux 内核的 split_lock_detect 默认值为 warn,会发出警告);以及在循环中读取设备寄存器的 MMIO 轮询。
接下来的测验将检查什么
检查面对“这段代码为什么昨天快、今天慢”这一问题时,你能否不只罗列指令,而是从执行路径和系统状态出发作答。