LLVM 项目博客

LLVM 项目新闻和来自一线的详情

GSoC 2024: 重振 NewGVN

今年夏天我参加了 LLVM 编译器基础设施的 GSoC 项目。该项目的目的是改进 NewGVN 通道,使其能够取代 GVN 成为 LLVM 中主要的数值编号通道。

背景

全局值编号 (GVN) 的作用是为指令分配数值,使具有相同数值的指令等效。NewGVN 于 2016 年推出,旨在取代 GVN。现在我们来强调一下 NewGVN 比 GVN 更好的几个方面。

NewGVN 相对于 GVN 的一个关键优势是,它对循环是完整的,而 GVN 仅对无环代码是完整的。NewGVN 对循环是完整的,因为当它首次处理循环时,它假设只会执行第一个迭代,并在之后证实这些假设——这被称为乐观假设。在实践中,乐观假设等同于假设回边不可到达,因此在评估 phi 指令时,可以忽略它们携带的值。例如,在下面的示例中,%a 被乐观地评估为 0。这导致 %c 被评估为 %x,进而导致 %a.i 被评估为 0。此时,有两种可能性:要么假设是正确的,并且循环实际上只执行一次,并且到目前为止计算出的数值是正确的,要么需要重新评估循环中的指令。假设对于这个例子,NewGVN 无法证明只执行一次迭代。那么 %a 再次被评估为 0,并且所有其他寄存器也被评估为相同的值。由于乐观假设,我们能够发现 %a 是循环不变的,而且它等于 0

define i32 @optimistic(i32 %x, i32 %y){
entry:
  br label %loop
loop:
  %a = phi i32 [0, %entry], [%a.i, %loop]
  ...
  %c = xor i32 %x, %a
  %a.i = sub i32 %x, %c
  br i1 ..., label %loop,label %exit
exit:
  ret i32 %a
}

另一方面,GVN 无法检测到这种等价性,因为它会悲观地将 %a 评估为自身,并且前面描述的评估步骤永远不会发生。

NewGVN 的另一个优势是使用 MemorySSA 对内存操作进行数值编号。它提供了内存的功能视图,其中可以修改内存的指令会生成一个新的内存版本,然后其他内存操作会使用该版本。这极大地简化了对内存操作中冗余的检测。例如,来自等效指针和内存版本的相同类型的两个加载,在本质上是等效的。

define i32 @foo(i32 %v, ptr %p) {
entry:
; 1 = MemoryDef(liveOnEntry)
  store i32 %v, ptr %p, align 4
; MemoryUse(1)
  %a = load i32, ptr %p, align 4
; MemoryUse(1)
  %b = load i32, ptr %p, align 4
; 2 = MemoryDef(1)
  call void @f(i32 %a)
; MemoryUse(2)
  %c = load i32, ptr %p, align 4
  %d = sub i32 %b, %c
  ret i32 %d
}

在上面的示例(带有 MemorySSA 注释)中,%a%b 是等效的,而 %c 不是。所有三个加载都是来自相同指针的相同类型,但它们并不都从相同的内存状态加载。加载 %a%b 从存储定义的内存(Memory 1)中加载,而 %c 从函数调用定义的内存(Memory 2)中加载。GVN 也可以检测到这些冗余,但它依赖于更昂贵且更不一般的 MemoryDependenceAnalysis。

尽管有这些和其他改进,NewGVN 仍然没有得到广泛使用,主要是因为它缺乏部分冗余消除 (PRE),并且存在错误。

实现 PRE

我们的主要贡献是为 NewGVN 开发了一个 PRE 阶段(请点击此处查看)。我们的解决方案依赖于对 Phi-of-Ops 进行泛化。它执行了一个特殊的 PRE 案例,其中指令依赖于 phi 指令,并且在所有到达路径上都存在等效的值。这是通过两个步骤完成的:phi 翻译和 phi 插入。

phi 翻译包括在每个块的前驱上下文下评估原始指令。Phi 操作数被替换为从前驱传入的值。如果翻译后的指令等效于常量、函数参数或支配前驱的另一条指令,那么该值在前驱中可用。

如果该值在每个前驱中都可用,则在 phi 翻译之后会发生 phi 插入。此时,会构造一个等效值的 phi,并用来替换原始指令。以下示例说明了整个过程。


我们的泛化消除了对依赖 phi 的需求,并且引入了在指令部分冗余的情况下插入缺失值的能力。为了防止代码大小增加(忽略插入的 phi 指令),只有在需要插入唯一一个 phi 时才会进行插入。以下示例说明了整个过程。


将 PRE 集成到现有框架中也使我们能够免费获得循环不变代码移动 (LICM)。乐观假设结合 PRE,允许 NewGVN 推测性地将指令从循环中提升。另一方面,GVN 中的 LICM 依赖于使用 LoopInfo,并且只能处理非常具体的案例。

缺失的功能

我们 PRE 实现缺少的两个主要功能是关键边拆分和加载强制转换。关键边拆分是确保我们不会将指令插入到不会使用它们的路径中。目前,我们的实现只会在这种情况下放弃。加载强制转换允许我们检测不同类型加载值的等效性,例如 i32float 的加载,然后使用转换操作强制转换加载的类型。

实现这些功能的难度在于,NewGVN 被设计为在单独的步骤中执行分析和转换,而这些功能需要在分析阶段修改函数。

结果

我们使用自动基准测试工具 Phoronix 测试套件 对我们的实现进行了评估,从中我们选择了一组 20 个 C/C++ 应用程序(列在下面)。

aircrack-ng encode-flac luajit scimark2
botan espeak mafft simdjson
zstd fftw ngspice sqlite-speedtest
crafty john-the-ripper quantlib tjbench
draco jpegxl rnnoise graphics-magick

使用了默认的 -O2 管道。编译之间的唯一区别是使用的数值编号通道。

尽管缺少功能,但我们观察到我们的实现平均比 GVN 性能提高了 0.4%。但是,需要指出的是,我们的解决方案还没有经过微调,以考虑优化管道的其余部分,这导致了一些情况下,与 GVN 和现有 NewGVN 相比,我们的实现出现了倒退。最严重的案例是 jpegxl,我们的实现平均比 GVN 性能下降了 10%。需要注意的是,这是一个特例,排除 jpegxl 后,大多数倒退最多为 2%。不幸的是,由于时间限制,我们无法更详细地研究这些案例。

未来工作

未来,我们计划实现上述缺失的功能,并微调执行 PRE 的启发式算法,以防止结果部分讨论的倒退现象。解决这些问题后,我们将把我们的实现上游,使我们离重振 NewGVN 更近了一步。