Scalar Evolution(SCEV)用于分析循环中的标量(scalar)是如何变化的(evolution)。
SCEV 是 LLVM 中一个很重要的 analysis pass,可识别 general induction variables。该 analysis 主要用于 induction variable substitution 和 strength reduction。
例子:
void foo(int *a, int n, int k) {
int t = 0;
for (int i = 0; i < n; i++) {
t = t + k;
}
*a = t;
}
t 的初值是 0,每次循环都会对 t 加 k,这便是 t 的演化。
但只有 t 的终值会被 *a = t
用到,那么是否有一种方法可以直接算出 t 的终值,而不必进入循环中一步一步去算?
下图使用了 SCEV 分析结果后进行优化的例子:
优化后的 IR 直接用 t = n * k
得到了 t 的终值,从而去掉了 for 循环。
另外一个例子:
优化后的 IR 用加法代替了乘法运算。
以上优化是如何是如何做到的?下一章看下 SCEV 是如何对循环中的变量进行建模的。
SCEV 采用非循环表达式对循环中的变量建模,该表达式称为 Chains of Recurrences (CR)。
int f = k0;
for (int i = 0; i < n; i++) {
… = f ;
f = f + k1;
}
其中,k_0 和 k_1 是循环不变量,f 的递推公式为:
basic recurrence = \left\{k_0, +, k_1\right\} ,表示初值是 k_0 ,每次递增 k_1 。
BR 形式化表示:\left\{a_0, \bigodot, a_1\right\}
例如,BR = {3, +, 7} 可以生成序列 3, 10, 17, ...
与 basic recurrence 相比,chain recurrrence 中的步长可能不是常量,而是另一个 basic recurrence。
例如 {1, +, {3, +, 7}} 指一个序列的初始值是 1,以另一个 BR = {3, +, 7} 作为步长进行增加:
上述式子比较抽象,写成代码形式会更容易理解变量 i 是如何变化的:
// SCEV for i: {1, +, 3, +, 7}
int i = 1;
int j = 3;
while (i < n) {
i += j;
j += 7;
}
CR 形式化表示:\left\{a\_0, \bigodot\_1, a\_1, \bigodot\_2, a\_2 ..., \bigodot\_n, a\_n \right\}
常用的运算规则:
例如 12 + {7, 3} => {19, +, 3}
例如 12 * {7, +, 3} => {84, +, 36}
例如 {7, +, 3} + {1, +, 1} => {8, +, 4}
如果 f 和 h 为常数,则公式还可表示为 \left\{e * g, +, e * h + f * g + f * h, +, 2 * f * h\right\}
例如 {0, +, 1} * {0, +, 1} => {0, +, 1, +, 2}
有了上述规则,就可以通过已有变量的 CR 推出其它变量的 CR,例如:
前面的规则比较好理解,如果有兴趣的话,可以看下最后一个规则的推导:
设 x = \left\{e, +, f\right\} ,y = \left\{g, +, h\right\} ,则 start_{xy} = eg ,
所以
参考文献 7 给出了另一种推导方式。
计算 sum:
int sum = 0;
for (int i = 1; i <= n; i++) {
sum += i * i * i;
}
其中,i * i * i 的 CR 可用如下式子简化
所以
下图中的方式也可以计算出 CR
第 1 行表示循环次数,第 2 行是 sum 的值,第 3 行是两次循环之间的 sum 的差值 \delta ,第 4 行是两次循环之间 \delta 的差值 \delta^2 ,第 5 行是两次循环之间 \delta^2 的差值 \delta^3 ,第 5 行是两次循环之间 \delta^3 的差值 $\delta^4$。
体现在代码上:
int sum = 0;
int t1 = 1;
int t2 = 7;
int t3 = 12;
for (int i = 1; i <= count; i++) {
// sum += i * i * i;
sum += t1;
t1 += t2;
t2 += t3;
t3 += 6;
}
用加法运算取代了昂贵的乘法运算。
但是发现,求和过程中,虽然 sum 在不断变化,但是不需要追踪它的变化,只需要知道它的终值即可。
其实,有了 CR 后,我们可以直接用数学的方法推出该循环执行了 i 次后 sum 是多少。例如,var = {0, +, 3},可以直接算出循环 3 次后,var = 9。对于一般情况 var = \left\{a_0, +, a_1, +, ..., +, a_n\right\} ,则第 i 次循环后 var 的值为
推导过程见参考文献 7。
可简单理解为: a_0 只有在最初时被加了 1 = C_i^0 次 a_1 在每个循环中都被加了 i = C_i^1 次 a_2 在第 2 个循环被加了 1 次,第 3 个循环被加了 2 次,...,共被加了 i * (i - 1) / 2 = C_i^2 a_j 共被加了 C_i^j 所以 var(i) = \sum_{j=0}^n a_j C_i^j
那么,对于 sum= {0, +, 1, +, 7, +, 12, +, 6},可以直接得到第 i 次循环后
编译器只需要生成类似生成计算上述式子的代码即可,从而将时间复杂度由 O(n) 降到 O(1)。
使用如下命令可查看优化前的代码:
$ clang -emit-llvm -S sum.c -o sum.ll
使用如下命令可查看优化后的代码:
$ clang -emit-llvm -O3 -S sum.c -o sum.ll
使用如下命令可查看 SCEV 分析:
$ opt -analyze -scalar-evolution sum.ll
使用如下命令可以得到更为简短的输出:
$ opt -analyze -scalar-evolution -scalar-evolution-classify-expressions=0 sum.ll
int simpleLoop(int n) {
int sum = 0;
for (int i = 0; i <= n; i++) {
sum += i;
}
return sum;
}
先将循环规范后,再供 SCEV 使用。
循环
header:循环入口节点
backedge:回到 header 的边
latch:backage 的起始节点
exiting:后继位于循环外部的节点
exit:前继位于循环内部的节点
规范后的循环
生成变量 V 的 SCEV 表达式。
返回循环 L 中变量 S 的 SCEV 表达式。如果 S 位于 L 外,则可结合 getExitCount 计算 S 的退出值。
SCEV 表达式支持:
SCEV 表达式可嵌套,叶子节点是常数或者“unknown”。
不同于 IR 指令,SCEV 表达式支持多个操作数,比如 %a + %b + %c。
SCEV 表达式在构建的时候就会被规范化,比如会将 %a + (%b + %c) 替换为 %a + %b + %c,会将 %a + 1 替换为 1 + %a,会将 %a - %b 替换为 (-1 * %b) + %a。所以给定一系列操作数和类型,只会对应唯一的 SCEV 表达式。如果想比较两个 SCEV 表达式是否相同,只需要比较它们的指针是否相同即可。
getExitCount(L, ExitBB, ExitKind)
对于循环 L,返回某个退出块 ExitBB 的退出计数(backedge-taken count, BE count)。
注意,如果循环异常退出,退出计数不会因此发生改变。如果循环从未从该出口退出过,该值可能是无限大,或者至少大于其它出口的退出计数。它只是一个近似实用的函数。
ExitKind 有三种模式: exact:仅适用于只有一个出口的循环; symbolic max:可以认为是所有出口中的最大退出计数; constant max:是 symbolic max 的常量上限,常见的值是 -1(或者在无符号数情况下是 UINT_MAX)。
有了 BE count,就可以算出”exit value“或”SCEV at scope" 。例如,有 SCEV 为 {2, +, 3},BE count 为 -1 + %n,那么退出值为 2 + 3 * (-1 + %n) 或 -1 + (3 * %n)
Predicate checking
LoopDisposition / Invariance / dominates
对于给定值,返回它是否”可能“是潜在循环不变的,是否是支配的,主要用于 SCEV 程序内部。
注意,这里的“潜在循环不变”比较微妙,SCEV 级别上的 domination / invariance 和 IR 级别上的会有一些不同。例如:
/*
for (int i = 0; i < end; i++) {
d = a / b;
}
*/
loop:
%iv = phi i32 [ 0, %preheader ], [ %iv.next, %loop.latch ]
%iv.next = add i32 %iv, 1
%cmp = icmp ne i32 %iv.next, %end
br i1 %cmp, label %loop.latch, label %exit
loop.latch:
%div = udiv i32 %a, %b ; operands defined outside of loop
br label %loop
udiv i32 %a, %b
不能被提到循环外,因为如果循环在第一次迭代就退出的话,udiv 是不会被执行到的。如果向 LoopInfo 询问 %div
对于 %loop
是否是循环不变的,答案是否。
但是,如果看 SCEV 表达式 %a /u %b
,那么它对于 %loop
是循环不变的,可以提到循环外面。所以 SCEV 级别上的 domination / invariance 要强于 IR 级别。
有时候需要从 SCEV 表达式回到 IR 指令,比如在 IR 中计算退出计数或退出值,这可被 SCEVExpander 处理,它可生成一些必要的指令来计算 SCEV 表达式的值。
在扩展前,要检查两件事:
expander 尝试在两方面生成更加有效的代码:尽量将指令外提;尽量重复使用已有的指令。
expander 有多种模式来扩展操作,默认采用”canonical mode“,地址的表达会基于一个规范 {0, +, 1} 归纳变量。还有一种特殊的模式是 LSR 模式,会被 LSR pass 使用。
有时候传入的 IR 不满足变换所需的某些先决条件,这种情况下,”predicted“ scalar evolution 允许我们假设某个谓词成立,并在该假设下计算 SCEV 表达式。
为了执行真正的转换,需要对循环进行版本控制:生成循环的两个副本,其中一个副本由假设的谓词保护。然后被保护的循环可基于这些谓词进行转换。
时间关系,没有读完 SCEV 源码。源码的注释中给出了如下参考文献:
getSCEV(V) 的实现与上述文献 1 中的逻辑有点像:
1 2018 EuroLLVM Developers’ Meeting: J. Absar
2(https://kristerw.blogspot.com/2019/04/how-llvm-optimizes-geometric-sums.html)
3(https://www.cnblogs.com/gnuemacs/p/14167695.html)
4(https://www.youtube.com/watch?v=y07BE1og4VI)
5(https://llvm.org/devmtg/2009-10/ScalarEvolutionAndLoopOptimization.pdf)
7(https://dl.acm.org/doi/pdf/10.1145/190347.190423)
8(https://www.npopov.com/2023/10/03/LLVM-Scalar-evolution.html)
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。