yuqi-zheng

编译器优化:乘以常量


整数乘法是人们最早想”帮”编译器的地方之一:把 x * 8 写成 x << 3,把 x * 3 改写为 (x << 1) + x。这种手动位操作是乘法指令真正昂贵那个年代的残留。在现代硬件上,这通常是没必要的,有时还是适得其反的。看看编译器实际对常量乘法发出的代码,就能明白为什么。

小常数:使用 lea 指令

对于像 2、3、4、5 这样的乘数,现代 x86 编译器几乎从不发出 mul 指令。它改用 lea —— Load Effective Address —— 虽然是为指针算术设计的,但能在单条指令里计算 base + index * scale + displacement,其中 scale 可以是 1、2、4 或 8。

; x * 2
lea eax, [rdi + rdi]

; x * 3
lea eax, [rdi + rdi*2]

; x * 4
lea eax, [rdi*4]

; x * 5
lea eax, [rdi + rdi*4]

lea 可以写入任意目标寄存器、不修改标志位,在大多数现代内核上具有单周期延迟。它是小常数乘法的理想工具。

2 的幂:移位,但有个小问题

对 2 的幂,编译器使用左移(shl)。但 x86 的移位指令要求源和目标是同一寄存器,所以当输出寄存器与输入不同时,需要先 mov

; x * 16
mov eax, edi
shl eax, 4

这比 lea eax, [rdi*16] 稍显不优雅,但 lea 只支持 1、2、4、8 的 scale。对更大的 2 的幂,shl 是唯一的选择。

复合常数:链式 lea

对像 25 这样的常数,编译器把乘法分解为一系列较小的乘法:

; x * 25 = (x * 5) * 5
lea eax, [rdi + rdi*4]   ; eax = x * 5
lea eax, [rax + rax*4]   ; eax = (x*5) * 5 = x * 25

两条 lea,没有 mul、没有显式加法。编译器在这里解决一个小小的优化问题:找出产生目标乘数的、最短的 leashl 操作序列。

大常数:直接用 imul

当常数大到没有短序列的移位和加法能搞定时,编译器切换到三操作数形式的 imul

; x * 522
imul eax, edi, 522

现代 CPU 拥有深度流水化的整数乘法单元。imul 的延迟通常是 3 到 4 个周期,但吞吐量是每周期一次。把 522 分解为移位和加法 —— (x << 9) + (x << 3) + (x << 1),因为 512 + 8 + 2 = 522 —— 需要三次移位和两次加法,指令更多、数据依赖也更多。一条 imul 确实更快。

当你”帮”编译器的时候发生了什么

这里开始有意思了。如果你写:

return (x << 9) + (x << 3) + (x << 1);

试图手工表达 x * 522,编译器会识别出这个模式,把它转换回 imul eax, edi, 522。编译器看穿你的位操作,识别出底层的乘法意图,然后按它自己的判断选择最好的实现 —— 而那就是它本来就会生成的 imul

反过来,如果你针对像 Pentium 这样的老架构 -march=pentium,编译器会用移位和加法来做同一个乘法,因为那个年代的整数乘法器足够慢,把操作分解是正确的选择。

编译器的选择并不是任意的。它知道目标 CPU 的指令延迟和吞吐,并据此做决定。手工把算术表达式改写成位操作并不能给编译器提供它本来不知道的信息 —— 只会让源代码更难读,偶尔还会妨碍它识别更高级的模式。

一般性原则

常量乘法是”以性能为导向的编程”中更广泛原则的一个缩影:编译器携带关于目标微架构的详细性能模型。它知道何时 leaimul 快、何时 imul 比若干移位快、何时”移位加加法”的序列两者皆优。这份知识被嵌入优化器,并随着 CPU 的演进在每个编译器版本中更新。

x * 522 对意图没有歧义。编译器知道你想要什么,并选择最好的实现方式。写 (x << 9) + (x << 3) + (x << 1) 更难读、模糊了意图,最好的情况是得到相同的输出。最坏的情况,会妨碍编译器看到你没预料到的优化机会。

把乘法写成乘法。让编译器决定怎么实现它。