clang如何生成非循环的平方和代码?

45

我承认答案可能是“某些非常特定的魔法”,但我对这里观察到的情况感到震惊。我想知道这些类型的优化如何工作。我发现编译器设计非常有趣,但我真的无法想象这是如何实现的。我确定答案在clang源代码中某个地方,但我甚至不知道该去哪里找。

我是大学课程的TA,最近被要求帮助解决一个简单的作业问题。这使我走上了一条有趣的道路...

问题很简单:在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

我希望有一种好的方式可以在一条指令中进行分支,如果--x1 == 0,但我想不到任何方法。

注意:有一个简单的公式可以解决这个问题,来自任何基础数论课程,即[n(n + 1)(2n + 1)] / 6,但我认为这并不符合问题的本意。

然后我想知道clang如何为一个简单的C版本生成汇编代码。编写了简单的C实现后,我发现使用-Og选项的clang会生成似乎有点冗长但通常按预期工作的汇编代码,其中包括循环和累加器(尽管非常低效):

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就产生了一些神奇的代码。我不知道它是如何得出这段代码的,它似乎与这个求和的基本公式有些相似,但却用了位运算。我没想到编译器能在没有循环的情况下推导出这个公式,但事实证明我错了。生成的代码如下(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是什么意思。对这些步骤的任何见解都将不胜感激。


3
这主要涉及到“clang中的优化器是如何工作”的问题,这是一个非常广泛的问题。 - 500 - Internal Server Error
6
我不知道。但完全有可能存在一种窥视孔式的优化方法来识别平方和并用众所周知的求和形式n(n+1)(2n+1)/6替换它,然后用“魔数”优化将除以6的运算改为乘法。这两种优化都是非常常见的。 - Gene
6
0x55555556是一个数字,将它乘以3得到2,有点像三分之二,但是仅当你知道要乘的数是3的倍数时(在这种情况下我们知道),才成立。 - harold
12
这并不是成语识别,LLVM也可以做到那一点。这是LLVM的“标量进化”分析,它将循环变量重写为大致的行程次数多项式。这个想法是由Bachmann、Wang和Zima在论文“循环链”中介绍的。 - Nick Lewycky
4
在SO上询问问题的好处之一是各种专家都会出现。你现在可能正在与曾经参与这个优化工作的开发人员交谈呢!;-) https://bugs.llvm.org/show_bug.cgi?id=1798 - Nick Lewycky
显示剩余4条评论
2个回答

54
TL:DR:是的,clang知道整数幂级数和的闭式公式,并且可以检测这样的循环。现代编译器已经能够识别某些操作模式并用源代码中不存在的操作替换它们,例如旋转、甚至popcount循环和bithacks。对于特定的clang/LLVM,还包括i^power和步长不为1的和的闭式公式。所以你可以得到不仅仅是源代码展开或向量化版本的asm逻辑。
请参见博客文章LLVM如何优化幂和,该文章介绍了编译器如何通过查看变量在循环迭代中的更新方式来查找这些循环。
Matthieu M.评论说,闭式公式是由LLVM中的标量演化优化导出的。代码中的注释说,它主要用于分析涉及循环中归纳变量的表达式,并引用了其用于链式递推的技术的参考文献。
现代 C 编译器可以在代码的内部表示中识别一些循环或短逻辑序列的模式。编译器开发人员告诉了编译器要寻找什么,并提供了手工制作的替代方案。我预计在 GIMPLE(GCC)或 LLVM-IR 中,不只是在生成汇编语言时像 peephole 优化那样非常晚才进行。因此,我猜想 LLVM 优化器内部的逻辑会检查它找到的每个循环,以查找以下一个或多个可能性之一,其中包括查找代表该循环的程序逻辑的 LLVM-IR 的某些属性的代码:
1. 是否将一个数组复制到另一个未修改的数组中?如果是,请使用 `__builtin_memcpy` 替换它,这可能稍后会被展开为内联或编译为 `call memcpy`。如果它还具有其他副作用,例如使指针变量递增,则还需在包含循环的函数的新 LLVM-IR 中表示该副作用。
2. 是否将一段内存范围的每个字节都设置为一个常量字节值?如果是,请使用 `memset`。
3. 它的操作序列是否等同于可以执行 popcnt 的此序列?如果是,则在硬件支持存在的情况下发出 `popcnt`,否则保留循环策略。(因此,它不仅将其视为 `__builtin_popcount`,而是不使用位运算或调用辅助函数来替换循环。这是有意义的,因为某些策略对于具有少量位设置的数字非常好,并且程序员可能考虑了这一点。)
4. 循环变量是否更新为一系列整数(具有某个步幅)的总和,或者提高到幂次方?然后使用避免固定宽度整数溢出的闭式公式。(如果起始点和步幅不是 `1`,则添加偏移量和/或比例因子。)
检查可能是根据考虑被循环修改的变量,在循环后读取该变量来工作的。因此,它知道在查看操作时要考虑哪个变量。(没有使用结果的循环会被删除。)
GCC 不查找整数序列的总和,但 Clang 查找。我不知道这实际上加快了多少真实世界的代码库;闭式公式相当着名,如 高斯小时候重新发现 的那样。因此,希望许多代码使用该公式而不是循环。我认为不需要太多程序以这种方式准确执行此操作,除非它是一个练习。
(关于平方和的闭式公式的存在不太为人所知,但 有一种,显然也适用于一般幂。)
Clang实现的公式当C抽象机器不遇到未定义行为(对于有符号整数溢出)或匹配无符号乘法的截断时,必须为每个输入整数给出精确正确的结果。否则它将不满足as-if规则,或者只能在已知有限值范围的地方内联使用。(实际上,似乎clang没有使用无符号的封闭形式优化,但也许我在尝试的版本中犯了一个错误。使用64位整数可以安全地计算32位整数的总和。然后截断它可以给出与源代码相同的结果。)
n*(n+1)/2仍在范围内的情况下,n*(n+1)可能会溢出,因此这是非平凡的。对于64位机器上的32位int,LLVM可以并且确实使用64位乘法和右移。这可能是通用情况的窥视孔优化,其中使用双宽输出和扩展精度右移,在两个寄存器之间跨越,如果乘积不适合一个寄存器,则会出现这种情况。(例如,x86 shrd edx,eax,1mul r32在EDX:EAX中产生64位乘积之后,将低位从高半部分移入EAX的顶部。)
它还使用n *(n-1)/ 2 + n而不是通常的n *(n+1)/ 2;不确定它如何有所帮助。我猜它避免了输入类型的溢出,在原始循环只会包装而不是UB的无符号类型的情况下,如果这对于某些情况很重要。除非它不为无符号执行此优化。(顺便说一下,nn+-1都是偶数,因此除法(右移)是精确的;这很好,因为整数的总和最好是一个整数。)
在您的平方和汇编中,可以看到它使用umull x,w,w进行扩展乘法,以及64位右移,然后是32位乘法逆元素用于除以3。

在您的代码中进行调试,如果不进行平方运算,则计数或递减时会对代码生成产生微小差异

int sum_ints(int n) {
    int a = 0;
    //for (int i=0 ; i<n ; i++)  a += i;        // count up, skipping n
    while (n--) a += n;                      // count down, skipping n
    return a;
}

你的版本会导致负数 n 产生未定义行为,因为循环将运行到 INT_MIN--,并且先溢出 a。所以 clang 可能使用这个来假设初始值 n 是非负的,也可能不是。但如果不是,我不知道为什么它会生成更复杂的代码使乘法操作执行两次。
// count down version, starting with a += n-1, so x = n-1 in the usual formulae.
//  clang15 -O3
sum_ints(int):
        cbz     w0, .LBB0_2        // only bail on zero, not negative.
        sub     w8, w0, #1         // n-1
        sub     w9, w0, #2         // n-2
        umull   x10, w8, w9        // (n-1)*(n-2)
        madd    w8, w8, w9, w0     // w8 = (n-1)*(n-2) + n
        lsr     x9, x10, #1        // w9 = (n-1)*(n-2)/2
        mvn     w9, w9             // w9 = ~w9 = -w9 - 1
        add     w0, w8, w9         // (n-1)*(n-2) - (n-1)*(n-2)/2 + n - 1 I think?
.LBB0_2:
        ret

// count up version, ending with n-1.  clang15 -O3
sum_ints(int):
        subs    w8, w0, #1       // n-1
        b.lt    .LBB0_2
        sub     w9, w0, #2       // n-2
        umull   x9, w8, w9       // (n-1)*(n-2)
        lsr     x9, x9, #1       // . / 2
        add     w0, w8, w9       // (n-1)*(n-2)/2 + (n-1) = (n-1)*(n-2 + 2)/2
                                 // = the usual               x  * (x+1   )/2 for x=n-1
        ret
.LBB0_2:
        mov     w0, wzr         // separate return path for all negative inputs
        ret


其他循环模式识别/替换类型

GCC和clang对于计算设置位数的循环进行了模式识别,以及标准的bithack人们会从SO复制/粘贴。(这很有用,因为ISO C未提供一种可移植的方式来表达大多数现代CPU具有的此操作。而ISO C++只通过<bit>或通过std::bitset<32>.count()在C++20中修复了这个缺陷)。所以一些真实的代码库只有一个bithack或简单的循环来处理设置位,而不是__builtin_popcount,因为人们更喜欢简单,并且希望将性能留给编译器。

这些模式识别器仅适用于实现popcount的某些特定方式,即x&= x-1; count++;尝试证明每个可能的循环等价性可能会花费太多的编译时间。因此,我们可以相当确定,这些工作是通过寻找特定实现而不是实际结果来完成的。

当然,变量名称并不重要,但是对输入变量进行的操作序列很重要。我认为,在重新排列操作以在检查等价性时给出相同结果的方式方面,存在一定的灵活性。在GCC的情况下,显然number_of_iterations_popcount是发现这一点的函数名称:编译器通常希望知道循环将运行多少次:如果它是一个小常数,他们可能会完全展开它。如果可以从其他变量计算出来,在开始循环之前,它就是自动向量化的候选对象。(GCC / clang无法自动向量化搜索循环或任何其他具有数据相关if()break的内容。)

Count the number of set bits in a 32-bit integer中顶部答案所示,GCC10和clang10 (Godbolt)也可以使用SWAR bithack识别popcount,因此您可以获得最佳策略:理想情况下只需一条指令,但如果不能,则至少有一个良好的策略。

当期望设置位数较小时,计算x&= x-1的迭代次数直到x == 0是可行的,因此有时也是明智的选择,因为这是GCC / clang可以替换的另一件事情,如果硬件popcount可用。(而且很容易编写,不需要掩码常量,并且如果不用单个指令替换,则可以使用-Os编译为小的机器代码大小。)

int popcount_blsr_until_zero(unsigned x){
    int count = 0;
    while (x){
        count++;  // loop trip-count = popcount, this is what GCC looks for
        x &= x - 1;
    }
    return count;
}

针对x86-64架构的GCC和clang,使用-O3 -march=nehalem或更新版本,在Godbolt上进行编译,并适用于此及其他一些版本。

# gcc12 -O3 -march=znver2
popcount_blsr_until_zero(unsigned int):
        popcnt  eax, edi
        ret

// clang -O3        for AArch64
popcount_blsr_until_zero(unsigned int):
        mov     w8, w0            // this is pointless, GCC doesn't do it.
        fmov    d0, x8
        cnt     v0.8b, v0.8b      // ARM unfortunately only has vector popcnt
        uaddlv  h0, v0.8b         // hsum bytes
        fmov    w0, s0            // copy back to GP-integer
        ret

一种最简单的模式识别代码替换形式是将(n<<5) | (n>>(32-5))编译成左旋5的形式。(有关运行时变量计数和如何安全地编写能够被识别但避免UB的内容,请参见此Q&A。)
但这可能发生在编译过程的较晚阶段,你可以称之为一个peephole optimization。CISC ISA tend to have more peephole optimizations, like x86 having special-case shorter instructions to sign within the accumulator (cdqe instead of movzx eax, ax). x86 xor-zeroing to set a register to zero can still be called a peephole, despite sometimes needing to rearrange things because that clobbers FLAGS while mov eax, 0 doesn't.
GCC启用了-fpeephole2-O2的一部分)来进行异或零操作;也许将其视为一个peephole就是GCC有时做得不好并且无法找到重新排序的方法,以便使用xor-zero / cmp / setcc而不是cmp / setcc / movzx,因为x86 setcc根据FLAGS条件设置寄存器很糟糕,只写入低8位。AArch64有更好的指令,如csinc,它可以与零寄存器一起使用来实现0/1的材料化,或者与其他寄存器一起使用来有条件地选择和递增。
但是,序列求和循环是一个较大规模的替换,不完全是我想到的peephole,特别是因为它不是针对特定目标的。
此外相关内容:
  • C编译器是否允许用另一个算法替换算法? - 是的。但通常不会这样做,因为编译器是机械的,不能总是正确,而选择一种有效的数据算法是C程序员期望编译器尊重的,除非有一个显然更快的单个指令。

  • clang知道如何使用AVX2 vpshub 作为半字节查找表自动矢量化__builtin_popcount数组。它不仅仅是将相同操作制作成SIMD版本,而是使用人工编译器开发者放置在那里供其使用的扩展。

  • 为什么编译器优化不会生成从1..N求和的循环?是关于这种优化不会发生的情况,例如对于无符号的j <= n,这是一个潜在的无限循环。

    那里的评论发现了一些有趣的限制,例如如果循环是for (int j=0 ; j < n; j += 3),行程计数将不太可预测/可计算,并且会破坏此优化。


“after a mul r32 produced ).” 这里有什么遗漏吗?“通过对您的代码进行测试和简化版本而不是平方,当您可以向下或向上时,它在代码生成中产生了一些微小的差异。” 应该读作“difference”,缺少单词“round”。 - ecm
3
@ecm:感谢你的校对。我写这篇文章所花的时间比预期的长,有点马虎了,再加上通常的ADHD(注意力缺陷多动障碍),当我在中途想到另一个思路时,可能会让我的一些想法没有得到完整表述。后面那个错误是一个"could" / "count"的错别字! - Peter Cordes
3
闭式公式是通过LLVM中的标量演化优化派生的。 - Matthieu M.

15
为了至少启动事情,这确实是您的“基本数论”公式,尽管它是以相当隐晦和低效的形式呈现的。显然,clang开发人员也学过这门课。
以下提示可帮助验证公式:
- 您的sum_squares函数有一个偏移一的错误,只加到n-1。因此,我们期望得到的公式是n(n-1)(2n-1)/6。 - 在这种情况下,orr w9,w9,#0x2等同于add w9,w9,#0x2。先前的指令mul w9,w8,w8将w9加载为w8的平方。现在,模4的唯一完全平方数是0和1,它们的位1都清除了,因此w9的第1位始终会被清除。因此,w9 | 2等同于w9 + 2。(不,我不知道为什么clang会这样做。)
  • 正如Harold所评论的,使用0x55555556进行乘法运算在模2^32下等价于除以3并乘以2(假设没有余数)。这种技术有时被称为“魔数除法”。参见为什么GCC在实现整数除法时使用奇怪的数字进行乘法运算?。因此,在此之前,您有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只是直接生成相应的代码,而不是先进行代数简化。


    3
    为了应对每一个可能的整型输入,clang的闭式公式避免了溢出而改变结果,这就使得公式更加复杂。以高斯求和公式为例,我们都知道其简单形式是 n * (n+1)/2,但clang并没有采用这种方式。 - Peter Cordes
    1
    @PeterCordes:好的,这很有道理。虽然在这种情况下,可以通过将可能溢出的步骤简单地扩展到64位来实现。它已经在一些地方本地执行了这个操作。 - Nate Eldredge
    1
    好的,这是一个被忽略的优化点。我想知道如果你把那个汇编逻辑转换成C语言,并将其输入给clang,它是否能够使用64位整数将其转换为更简单的公式。可能不行,所以这并不能帮助我们确定这是否是一个“预定义指令序列”,在很晚的时候被替换(就像有时发生在GCC中的那样),或者其他优化步骤可以将此逻辑优化为使用结果的周围代码等。 - Peter Cordes
    4
    我在这里找到了一些关于GCC和LLVM如何实现这一点的信息:https://kristerw.blogspot.com/2019/04/how-llvm-optimizes-geometric-sums.html。 - harold
    4
    顺便提一下,-print-after-all 选项是用来查找clang如何进行这些操作的。它会显示哪个步骤做了关键更改,而步骤的名称通常就足够解释了。 - arnt

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接