我承认这个问题的答案可能是“一些非常特殊的魔法”,但我对我在这里观察到的情况感到有点震惊。我想知道是否有人能洞察这些类型的优化是如何工作的。我发现编译器设计非常有趣,我真的无法想象它是如何工作的。我确信答案在clang源代码中的某个地方,但我甚至不知道我会去哪里寻找。
我是大学里一个班级的助教,最近我被要求帮助解决一个简单的家庭作业问题。这让我走上了一条有趣的道路...
问题很简单:在x86_64汇编中,编写一个函数,给定一个(正)整数n返回1^2 + 2^2 + 3^2 + ... + n^2
。
我决定尝试一下,在帮助他们用x86_64汇编语言编写了这个程序之后,我(有一台M1 MacBook)决定看看是否能用arm64汇编语言创建一个不错的解决方案。我想出了一个相对简单直接的解决方案:
_sum_squares:
mov x1, x0 ; Do multiplication from x1
mov x0, xzr ; Clear x0
Lloop:
; x0 <-- (x1 * x1) + x0
madd x0, x1, x1, x0
; Loop until x1 == 0
subs x1, x1, #1
bne Lloop
ret
(我希望有某种很好的方法在一条指令中执行分支if --x1 == 0
,但我想不出任何方法)
注意:在任何基础数论课上都有一个简单的公式,就是[n(n + 1)(2n + 1)] / 6
,但我认为这并不符合问题的精神。
然后我想知道clang如何为一个简单的C版本生成汇编。在编写简单的C实现时,我发现clang与-Og
一起生成汇编似乎有点冗长,但通常与预期的循环和累加器一起工作(尽管效率非常低):
int sum_squares(int n)
{
int a = 0;
while (n--)
a += (n * n);
return a;
}
(clang -Og -S
,我自己注解,删除cfi,重命名标签)
_sum_squares:
sub sp, sp, #16 ; create stack space
str w0, [sp, #12] ; store n
str wzr, [sp, #8] ; store 0
b Ldec ; silly clang, this just falls through...
Ldec: ; n-- and return if n == 0
ldr w8, [sp, #12] ; load n
subs w9, w8, #1 ; w9 = (n - 1)
str w9, [sp, #12] ; store (n - 1) over n
subs w8, w8, #0 ; w8 = n - 0 (set flags based on n)
cset w8, eq ; set w8 = 1 if n == 0 else w8 = 0
tbnz w8, #0, Lret ; branch to return if n == 0, else fall through
b Ladd ; silly clang, this falls through again...
Ladd: ; a += n^2
ldr w8, [sp, #12] ; load n
ldr w9, [sp, #12] ; load n
mul w9, w8, w9 ; w9 = n * n
ldr w8, [sp, #8] ; load a
add w8, w8, w9 ; a += w9
str w8, [sp, #8] ; store a
b Ldec ; go back to start of look
Lret: ; return a from top of stack
ldr w0, [sp, #8] ; w0 = a
add sp, sp, #16 ; cleanup temp stack
ret ; back to caller
这对于将C代码直接转换为arm64汇编是完全合理的。(O1使用了类似的公式,O2和O3是相同的),Clang想出了一些魔法,我不知道它是如何想出这个代码的,它似乎有点类似于这个求和的基本公式,除了使用bit magic。我没有想到编译器能够在没有循环的情况下导出一个公式,但看来我错了。生成的代码如下(我尽量尝试注解,n是w0
中的输入):
_sum_squares:
cbz w0, Lret ; return if n == 0
sub w8, w0, #1 ; w8 = (n - 1)
mul w9, w8, w8 ; w9 = (n - 1)^2
orr w9, w9, #0x2 ; w9 = ((n - 1)^2) | 2
sub w9, w9, w0 ; w9 = [((n - 1)^2) | 2] - n
mov w10, #5 ; w10 = 5
sub w10, w10, w0, lsl #1 ; w10 = 5 - (n / 2)
sub w11, w0, #2 ; w11 = n - 2
umull x11, w8, w11 ; w11 = (n - 1)(n - 2)
lsr x12, x11, #1 ; x12 = ((n - 1)(n - 2)) / 2
mul w10, w10, w12 ; w10 = (5 - (n / 2))(((n - 1)(n - 2)) / 2)
sub w12, w0, #3 ; w12 = n - 3
mul x11, x11, x12 ; x11 = (n - 1)(n - 2)(n - 3)
lsr x11, x11, #1 ; x11 = ((n - 1)(n - 2)(n - 3)) / 2
mov w12, #21846 ; w12 = 0x5556
movk w12, #21845, lsl #16 ; w12 = 0x55555556
; w8 = ((n - 1)([((n - 1)^2) | 2] - n)) + (5 - (n / 2))(((n - 1)(n - 2)) / 2)
madd w8, w9, w8, w10
; let A = w8 (set in last instruction)
; w0 = (0x55555556 * (((n - 1)(n - 2)(n - 3)) / 2)) + A
madd w0, w11, w12, w8
; somehow, this is the correct result?
; this feels like magic to me...
Lret:
ret ; return. Result already in w0.
我的问题:这到底是怎么工作的呢?一个C编译器怎么可能被赋予这样一个循环,然后推导出一个甚至不涉及循环的公式呢?我希望可能会有一些循环展开,但没有这样的。有人有涉及这种优化类型的参考资料吗?
我尤其不明白像orr w9, w9, #0x2
或者0x55555556这样的步骤是做什么的。
2条答案
按热度按时间pjngdqdw1#
**TL:DR:是的,clang知道整数幂级数和的封闭式公式,并且可以检测到这样的循环。**聪明的人类已经教会了现代编译器识别特定的运算模式,并用源代码中不存在的运算来替换它们,例如rotations,甚至popcount循环和bithack。特别是对于clang/LLVM,i^幂和的封闭式公式,包括步幅不是1的情况。太棒了!这样就可以得到asm逻辑,而不仅仅是源代码的展开或矢量化版本。
另请参阅一篇博客文章How LLVM optimizes power sums,其中讨论了编译器如何通过查看变量在循环迭代中的更新方式来找到这些循环。
Matthieu M.评论说,* 封闭形式公式是由Scalar Evolution optimization in LLVM导出的。* 代码中的评论说,它 * 主要用于分析循环中涉及归纳变量的表达式。* 并引用了它用于递归链的技术的参考文献。
现代C编译器可以识别代码内部表示中的某些循环或短逻辑序列中的模式。人类(编译器开发人员)已经告诉编译器要查找什么,并提供了一个手工制作的替换“公式”。我希望在GIMPLE(GCC)或LLVM-IR中,不只是在编译后期,像生成asm时的窥视孔优化。
因此我猜想LLVM的优化器内部的逻辑会检查它找到的每个循环,以查找以下一种或多种可能性,并使用一些代码来查找表示该循环的程序逻辑的LLVM-IR的某些属性:
__builtin_memcpy
,它可能会在以后进行内联扩展或编译为call memcpy
。如果它有其他副作用,如使指针变量递增,请在包含循环的函数的新LLVM-IR中表示它。memset
popcnt
,否则保持循环策略。(因此,它并不只是把它当作__builtin_popcount
来处理,也不是用bithack或helper函数的调用来替换循环。这是有意义的,因为有些策略适用于位数较少的数字,程序员可能已经考虑到了这一点。)1
,则添加一个偏移量和/或比例因子。)这种检查可以考虑一个被循环修改的变量,它是在循环之后读取的,所以它知道在查看操作时要考虑什么变量(没有使用结果的循环会被删除)。
GCC并不寻找整数序列的和,但是clang会。IDK有多少真实世界的代码基础这实际上加速了;封闭形式的公式是相当有名的,著名的是re-discovered by Gauss as a schoolboy。(所以希望很多代码使用公式而不是循环)。我想,除了作为练习,没有多少程序需要完全这样做。
(The封闭形式的平方和公式的存在不太为人所知,但是there is one,并且显然也适用于一般的幂。)
当然,Clang的公式实现必须为 * 每个 * 输入整数给予精确正确的结果,其中C抽象机不会遇到未定义的行为(对于有符号整数溢出),或者匹配无符号乘法的截断。否则,它将不满足as-if规则,或者只能在内联到具有已知有限值范围的位置时使用。(实际上,看起来clang并没有对unsigned函数进行封闭式优化,但可能是我在尝试的版本中犯了一个错误。使用64位整数可以安全地计算32位整数的和。然后截断它可以给予与源代码相同的结果。)
n*(n+1)
可能会在n*(n+1)/2
仍在范围内的情况下溢出,因此这并不简单。对于64位计算机上的32位int
,LLVM可以并且确实简单地使用64位乘法和右移。这可能是使用双倍宽度输出和扩展精度右移的一般情况的窥视孔优化,如果乘积不适合一个寄存器,则跨两个寄存器。(例如,在mul r32
生成EDX:EAX中的64位乘积之后,x86shrd edx, eax, 1
将低位从高半部分移到EAX的顶部。)它还执行
n * (n-1) / 2 + n
而不是通常的n * (n+1)/2
;我不知道这有什么帮助。我想它避免了输入类型的溢出,如果unsigned
类型的情况下,原来的循环只会有 Package ,而不是UB。除了它不做这种优化无符号。(顺便说一句,n
或n+-1
是偶数,所以除法(右移)是精确的;这很好,因为整数之和最好是整数。在平方和asm中,您可以看到它使用
umull x, w, w
执行扩展乘法和64位右移,然后执行32位乘法逆运算以除以3。利用您的代码和未平方的简化版本,当您向下或向上计数时,代码生成会产生微小差异。
如果
n
为负,那么你的版本会有UB,因为循环会运行到INT_MIN--
,并首先溢出a
。所以clang可能会也可能不会使用它来假设初始的n
是非负的。但如果不是,IDK为什么它会产生更复杂的代码,乘以两次。第一次
其他类型的循环模式识别/替换
GCC和clang为计算集合位的循环以及人们从SO复制/粘贴的标准bithack做模式识别。(这是有用的,因为ISO C无法提供一种可移植的方式来表示大多数现代CPU所具有的这种操作。ISO C只在C20中使用
<bit>
修复了这一缺陷,或者通过std::bitset<32>
.count()
)。因此,一些真实的的代码库只是在set位上有一个bithack或简单的循环,而不是__builtin_popcount
,因为人们更喜欢简单,并希望将性能留给编译器。这些模式识别器仅在实现
popcount
的某些特定方式上工作,即x &= x-1; count++
;要证明每个可能循环的等价性大概要花费太多的编译时间。2从这一点上,我们可以非常肯定这些方法是通过寻找一个特定的实现来工作的,而不是寻找每个可能整数的实际结果。变量 names 当然不重要,但输入变量的操作顺序很重要。我假设在检查等价性时,重新排序操作的方式会有一定的灵活性,从而给予相同的结果。在GCC的例子中,
number_of_iterations_popcount
显然是发现这一点的函数的名称:编译器经常想知道一个循环将运行多少次迭代:如果它是一个很小的常量,它们可能会完全展开它。如果它可以在开始循环之前从其他变量计算出来,那么它就是自动矢量化的候选对象。(GCC/clang不能自动矢量化搜索循环,或者任何带有依赖于数据的if()断点的东西。)如在计算32位整数中的设置位数的顶部答案中所示,GCC 10和clang 10(Godbolt)也可以使用SWAR bithack识别
popcount
,因此您可以获得两个世界的最佳结果:理想情况下是一个单一的指令,但如果不是,那么至少是一个好的策略。当期望的设置位数很小时,计算
x &= x-1
直到x == 0
的迭代次数是可以的,所以有时也是一个明智的选择,因为如果硬件popcount可用,GCC / clang可以替代的另一个东西。(并且写起来简单,不需要屏蔽常数,并且如果不被单个指令替换,则可以用popcnt
编译成较小的机器代码大小。GCC和clang为x86-64,
-O3 -march=nehalem
或更高版本,在Godbolt为这个和一些其他版本。第一个
通过模式识别进行代码替换的最简单形式之一是将
(n<<5) | (n>>(32-5))
编译为rotate-left,每次5(参见this Q&A了解运行时变量计数,以及如何安全地编写可识别的代码,同时避免UB(即使计数为0))。但是,这可能在编译过程中发生得很晚,您可以称之为**peephole optimization**。CISC ISA往往有更多的窥视孔优化,比如x86在累加器中有特殊情况的较短指令进行签名(
cdqe
而不是movzx eax, ax
)。将寄存器设置为零的x86xor
-zeroing仍然可以被称为窥视孔,尽管有时需要重新安排一些东西,因为这样会破坏FLAGS,而mov eax, 0
不会。GCC支持
-fpeephole2
(-O2
的一部分)的异或归零;也许把它当作一个窥视孔就是GCC有时候做得不好的原因,并且 * 失败 * 找到重新排序它的方法,所以xor
-zero /cmp
/setcc
而不是cmp
/setcc
/movzx
,因为x86 setcc根据FLAGS条件设置寄存器很糟糕,AArch 64有更好的指令,如csinc
,它可以与零寄存器一起使用来实现0/1,或与其他寄存器一起使用来有条件地选择和递增。但是,级数求和循环是一个更大规模的替代,并不完全是我所认为的窥视孔,特别是因为它不是特定于目标的。
还相关:
Would a C compiler be allowed to replace an algorithm with another?-是的。但通常情况下它们不会,因为编译器的机械性很强,它们不可能总是正确的,而为数据选择一个有效的算法是C程序员希望编译器尊重的事情,除非有一条指令明显总是更快。
clang知道如何使用AVX 2
vpshub
作为半字节的查找表,在数组上自动矢量化__builtin_popcount
。它不仅仅是从同一个操作中生成SIMD版本,它还使用了人类编译器开发人员为它提供的扩展。Why does compiler optimization not generate a loop for sum of integers from 1..N?是关于这种优化 * 不 * 发生的情况,例如,
j <= n
表示unsigned,这是一个潜在的无限循环。评论中发现了一些有趣的限制,关于什么时候可以优化clang:例如,如果循环是
for (int j=0 ; j < n; j += 3)
,则行程计数将是较不可预测/可计算的,并且使该优化失败。qaxu7uf22#
至少让事情开始,这是你的"基本数论"公式,尽管形式相当混乱和低效。
一些提示有助于验证它:
sum_squares
函数有一个"差一"的bug,它的和只等于n-1
,因此我们期望得到的公式是n(n-1)(2n-1)/6
。orr w9, w9, #0x2
等价于add w9, w9, #0x2
。前一条指令mul w9, w8, w8
将w8
的平方加载到w9
。现在,模4的完美平方只有0和1,两者的位1都是清零的,因此w9
的位1总是清零的。因此w9 | 2
等价于w9 + 2
。(不,我不知道为什么clang会这样做。)0x55555556
等于mod 2^32除以3和乘以2(假设没有余数)。这种技术有时被称为"幻数除法"。参见Why does GCC use multiplication by a strange number in implementing integer division?。所以在此之前,你有x11 = ((n - 1)(n - 2)(n - 3)) / 2
,注意,总是3的倍数(除以2总是精确的,因为分子总是偶数)。因此w11 * w12
的结果是(n-1)(n-2)(n-3)/6
。把所有这些放在一起,你可以检查代数来验证最终结果是否等价于
n(n-1)(2n-1)/6
。我不知道clang是如何执行这种优化的。我想我曾经做过一次练习,弄清楚是哪一个LLVM优化通道让它发生的,但是我不记得是什么了。但是有一些已知的算法可以自动推导出这种封闭形式的表达式,比如Gosper's algorithm。所以clang可能正在应用类似的东西。我现在推测,但也许算法会以未简化的形式给出一个公式,也许clang只是发出直接对应的代码,而不是首先尝试进行代数简化。