原文地址:blog.matthieud.me/2020/explor…
原文作者:blog.matthieud.me/
发布时间:2020年
最近,我遇到了一个效率不高的isEven函数的实现(来自r/programmingghorror)。
bool isEven(int number)
{
int numberCompare = 0;
bool even = true;
while (number != numberCompare)
{
even = !even;
numberCompare++;
}
return even;
}
代码是用C++编写的,但算法的本质是对输入的数字从基数0(即偶数)进行迭代上升,在每次迭代中切换布尔结果。它是有效的,但是与明显的恒定时间O(1)O(1)modulo算法相比,你会得到一个线性时间O(n)O(n)isEven函数。
令人惊讶的是,Clang/LLVM能够将迭代算法优化到恒定时间算法(GCC在这方面失败了)。在具有完全优化的Clang 10中,这段代码被编译为。
; Function Attrs: norecurse nounwind readnone ssp uwtable
define zeroext i1 @_Z6isEveni(i32 %0) local_unnamed_addr #0 {
%2 = and i32 %0, 1
%3 = icmp eq i32 %2, 0
ret i1 %3
}
第一条指令是和1的布尔运算,只保留最小有效位(这是一种非常快速的计算模数%2的方法),第二条指令将结果与0进行比较。
我决定探索一下Clang是如何做到这一点的。你可能知道,像LLVM(Clang后台)这样的优化器被设计成有一堆特定的通道,将代码(以LLVM IR的形式)转换成另一种(最好是更优化的)代码,保持相同的语义。LLVM有大约50个不同的优化通道,每一个都针对一个特定的代码模式。当你给你的编译器一个特定的优化级别(如-O2),有一个固定的优化通道列表,它将在你的代码上执行,以优化它(该列表已经由编译器编写者为你定义)1。
原始代码
如果你不应用任何优化方法(-O0),Clang将C++ isEven函数编译成简单的汇编(以LLVM IR的形式)。
; Function Attrs: noinline nounwind ssp uwtable
define zeroext i1 @_Z6isEveni(i32 %0) #0 {
%2 = alloca i32, align 4 ; number
%3 = alloca i32, align 4 ; numberCompare
%4 = alloca i8, align 1 ; even
store i32 %0, i32* %2, align 4 ; store function argument in number
store i32 0, i32* %3, align 4 ; store 0 in numberCompare
store i8 1, i8* %4, align 1 ; store 1 (true) in even
br label %5
5: ; preds = %9, %1
%6 = load i32, i32* %2, align 4
%7 = load i32, i32* %3, align 4
%8 = icmp ne i32 %6, %7
br i1 %8, label %9, label %16
9: ; preds = %5
%10 = load i8, i8* %4, align 1
%11 = trunc i8 %10 to i1
%12 = xor i1 %11, true
%13 = zext i1 %12 to i8
store i8 %13, i8* %4, align 1
%14 = load i32, i32* %3, align 4
%15 = add nsw i32 %14, 1
store i32 %15, i32* %3, align 4
br label %5
16: ; preds = %5
%17 = load i8, i8* %4, align 1
%18 = trunc i8 %17 to i1
ret i1 %18
}
LLVM IR是一种低级(接近机器)的中间表示形式,其特殊性在于它是单一静态赋值形式(SSA)。每条指令总是产生一个新的值(看起来像%3),而不是重新分配之前的值。
同样的IR的图形版本被称为控制流图(CFG),它是一个称为基本块(总是从上到下完全执行的指令序列)的顶点和代表我们程序可能流程的边的图。对于我们的代码,CFG看起来像这样。
- 第一个块(标有数字的%1)为函数中的各种变量初始化了内存(两个4字节的整数用于number和numberCompare,一个字节用于even)。
- 第二块(数字为%5)是循环检查,确定我们是否应该进入循环或退出循环。
- 第三块(数字为%9)是循环主体,它增加numberCompare(%15)并切换布尔值even(%12)。
- 第四块是函数的返回,它将结果转换为一个布尔值(%18)并返回给调用者。
优化代码
在这一点上,该代码只是我们原始C++代码的汇编SSA版本。让我们在它上面运行一些LLVM优化通道,看看它是如何演变的。
注册的内存
我们运行的第一个通道叫做内存到寄存器(mem2reg):它的目标是将变量从内存(在RAM中)转移到寄存器(直接在CPU内部),以使其速度更快(内存延迟是~100ns)。
我们看到,所有与内存有关的指令(alloca、load、store)都被优化器删除了,现在所有的操作(add、xor)都直接在CPU寄存器上完成。
4个块仍然存在,但略有不同(它们看起来更接近于原始C++代码)。
- 初始化块现在是空的。
- 循环条件块发生了变化,它包含了两个名为phi节点的指令。这些是特殊的节点,根据之前执行的块(前身块),取值在2之间。例如,一行%.04 = phi i32 [ 0, %1 ], [ %6, %4 ]意味着变量%.04%(在我们的C++代码中代表numberCompare)应该取值0,如果我们来自基本块%1,即函数的开始,或者我们来自基本块%4,即循环的主体,则取值为%8。
指令组合
这个通道将几条指令合并为一条更简单/更快速的指令,例如,同一变量的2个连续加法可以减少为一个;或者乘以8的指令可以改为左移3,等等。
在我们的代码中,有几个变化是由这一程序完成的。
- 偶数不再是一个字节(i8),而是直接存储为一个比特(i1),因此删除了几个转换指令。
- 循环条件已经从不等式转换为等式,并对两个区块进行了调整,以保持语义的完整性。
循环和诱导变量
我们将应用的下一个传递是归纳变量的规范化传递。这是一个 "神奇 "的过程,它完全删除了我们的循环,从而使算法变成了恒定时间。LLVM文档中解释道。
任何在循环外使用从indvar派生的表达式的行为都会被改变为在循环外计算派生值,从而消除对归纳变量出口值的依赖。如果循环的唯一目的是计算某个派生表达式的退出值,那么这种转变将使循环失效。
诱导变量(indvar)是一个循环的 "计数器":有时计数是微不足道的,如for (i=0; i < 100; i++),诱导变量是i,行程计数是100;但通常情况下,传递者更难正确确定诱导变量和循环行程计数。在我们的例子中,诱导变量numberCompare是显而易见的,循环次数也是。
因为它现在只对单比特值进行操作(因此最大为1),LLVM在这里实现了我们的算法是循环计数数和初始值的直接数学函数。
现在的CFG是:
我们在之前的代码上运行-simplifycfg,以删除任何无用的分支。经过最后的指令组合传递,我们得到完全优化的O(1)O(1)复杂度代码。
; Function Attrs: noinline nounwind ssp uwtable
define zeroext i1 @_Z6isEveni(i32 %0) #0 {
%2 = trunc i32 %0 to i1
%3 = add i1 %2, true
ret i1 %3
}
递归版本
Clang也能够对这个算法的递归线性时间版本进行量化。找到产生最终结果的各种优化通道是留给读者的一项练习👨🏫。
bool isEvenRec(int number)
{
if (number == 0) return true;
return !isEvenRec(number-1);
}
备注
这是LLVM在标记-O2时应用的优化过程的列表(顺序很重要)。请注意,其中一些会运行多次:-targetlibinfo -tti -targetpassconfig -tbaa -scoped-noalias -assumption-cache-tracker -profile-summary-info -forceattrs -inferattrs -ipsccp -called-value-propagation -attributor -globalopt -domtree -mem2reg -deadargelim -domtree -basicaa -aa -loops -lazy-branch-prob -lazy-block-freq -opt-remark-emitter -instcombine -simplifycfg -basiccg -globals-aa -prune-eh -inline -functionattrs -domtree -sroa -basicaa -aa -memoryssa -early-cse-memssa -speculative-execution -aa -lazy-value-info -jump-threading -correlated-propagation -simplifycfg -domtree -basicaa -aa -loops -lazy-branch-prob -lazy-block-freq -opt-remark-emitter -instcombine -libcalls-shrinkwrap -loops -branch-prob -block-freq -lazy-branch-prob -lazy-block-freq -opt-remark-emitter -pgo-memop-opt -basicaa -aa -loops -lazy-branch-prob -lazy-block-freq -opt-remark-emitter -tailcallelim -simplifycfg -reassociate -domtree -loops -loop-simplify -lcssa-verification -lcssa -basicaa -aa -scalar-evolution -loop-rotate -memoryssa -licm -loop-unswitch -simplifycfg -domtree -basicaa -aa -loops -lazy-branch-prob -lazy-block-freq -opt-remark-emitter -instcombine -loop-simplify -lcssa-verification -lcssa -scalar-evolution -indvars -loop-idiom -loop-deletion -loop-unroll -mldst-motion -phi-values -aa -memdep -lazy-branch-prob -lazy-block-freq -opt-remark-emitter -gvn -phi-values -basicaa -aa -memdep -memcpyopt -sccp -demanded-bits -bdce -aa -lazy-branch-prob -lazy-block-freq -opt-remark-emitter -instcombine -lazy-value-info -jump-threading -correlated-propagation -basicaa -aa -phi-values -memdep -dse -aa -memoryssa -loops -loop-simplify -lcssa-verification -lcssa -scalar-evolution -licm -postdomtree -adce -simplifycfg -domtree -basicaa -aa -loops -lazy-branch-prob -lazy-block-freq -opt-remark-emitter -instcombine -barrier -elim-avail-extern -basiccg -rpo-functionattrs -globalopt -globaldce -basiccg -globals-aa -domtree -float2int -lower-constant-intrinsics -domtree -loops -loop-simplify -lcssa-verification -lcssa -basicaa -aa -scalar-evolution -loop-rotate -loop-accesses -lazy-branch-prob -lazy-block-freq -opt-remark-emitter -loop-distribute -branch-prob -block-freq -scalar-evolution -basicaa -aa -loop-accesses -demanded-bits -lazy-branch-prob -lazy-block-freq -opt-remark-emitter -loop-vectorize -loop-simplify -scalar-evolution -aa -loop-accesses -lazy-branch-prob -lazy-block-freq -loop-load-elim -basicaa -aa -lazy-branch-prob -lazy-block-freq -opt-remark-emitter -instcombine -simplifycfg -domtree -loops -scalar-evolution -basicaa -aa -demanded-bits -lazy-branch-prob -lazy-block-freq -opt-remark-emitter -slp-vectorizer -opt-remark-emitter -instcombine -loop-simplify -lcssa-verification -lcssa -scalar-evolution -loop-unroll -lazy-branch-prob -lazy-block-freq -opt-remark-emitter -instcombine -memoryssa -loop-simplify -lcssa-verification -lcssa -scalar-evolution -licm -lazy-branch-prob -lazy-block-freq -opt-remark-emitter -transform-warning -alignment-from-assumptions -strip-dead-prototypes -globaldce -constmerge -domtree -loops -branch-prob -block-freq -loop-simplify -lcssa-verification -lcssa -basicaa -aa -scalar-evolution -block-freq -loop-sink -lazy-branch-prob -lazy-block-freq -opt-remark-emitter -instsimplify -div-rem-pairs -simplifycfg -domtree -basicaa -aa -memoryssa -loops -loop-simplify -lcssa-verification -lcssa -scalar-evolution -licm -verify -print-module