MemorySSA¶
简介¶
MemorySSA 是一种分析,它允许我们廉价地推理各种内存操作之间的交互。它的目标是取代 MemoryDependenceAnalysis 用于大多数(如果不是全部)用例。这是因为,除非您非常小心,否则使用 MemoryDependenceAnalysis 很容易导致 LLVM 中的二次时间复杂度算法。此外,MemorySSA 没有像 MemoryDependenceAnalysis 那样多的任意限制,因此您应该获得更好的结果。 MemorySSA 的一个常见用途是快速找出某些事情绝对不可能发生(例如,推断出循环外提无法发生)。
在高层次上,MemorySSA 的目标之一是为内存提供基于 SSA 的形式,完成 def-use 和 use-def 链,这使使用者能够快速找到内存操作的 may-def 和 may-use。它也可以被认为是廉价地为内存的完整状态赋予版本,并将内存操作与这些版本关联的一种方式。
本文档介绍了 MemorySSA 的结构,以及关于 MemorySSA 如何工作的一些基本直觉。
一篇关于 MemorySSA 的论文(附有关于如何在 GCC 中实现的注释)可以在这里找到。虽然,它相对过时了;该论文引用了多个内存分区,但 GCC 最终切换到仅使用一个,就像我们现在在 LLVM 中所做的那样。与 GCC 一样,LLVM 的 MemorySSA 是过程内的。
MemorySSA 结构¶
MemorySSA 是一种虚拟 IR。构建完成后,MemorySSA 将包含一个结构,该结构将 Instruction 映射到 MemoryAccess,后者是 MemorySSA 并行于 LLVM Instruction 的结构。
每个 MemoryAccess 可以是以下三种类型之一
MemoryDefMemoryPhiMemoryUse
MemoryDef 是可能修改内存或引入某种排序约束的操作。MemoryDef 的示例包括 store、函数调用、带有 acquire(或更高)排序的 load、volatile 操作、内存栅栏等。MemoryDef 始终引入整个内存的新版本,并与单个 MemoryDef/MemoryPhi 链接,后者是新版本所基于的内存版本。这意味着存在一个单一的 Def 链,直接或间接地连接所有 Def。例如,在
b = MemoryDef(a)
c = MemoryDef(b)
d = MemoryDef(c)
d 直接与 c 连接,并间接与 b 连接。这意味着 d 可能会覆写(见下文)c或 b或两者。这反过来意味着,在不使用 walker(遍历器) 的情况下,最初每个 MemoryDef 都会覆写每个其他 MemoryDef。
MemoryPhi 是 PhiNode,但用于内存操作。如果在任何时候我们有两个(或更多)MemoryDef 可能流入一个 BasicBlock,则该块的顶部 MemoryAccess 将是一个 MemoryPhi。与 LLVM IR 中一样,MemoryPhi 不对应于任何具体操作。因此,BasicBlock 在 MemorySSA 内部映射到 MemoryPhi,而 Instruction 映射到 MemoryUse 和 MemoryDef。
另请注意,在 SSA 中,Phi 节点合并 must-reach 定义(即,必须是变量新版本的定义)。在 MemorySSA 中,PHI 节点合并 may-reach 定义(即,在消除歧义之前,到达 phi 节点的版本可能或可能不覆写给定的变量)。
MemoryUse 是使用但不修改内存的操作。MemoryUse 的一个示例是 load 或 readonly 函数调用。
每个存在的函数都有一个特殊的 MemoryDef,称为 liveOnEntry。它支配着 MemorySSA 正在其上运行的函数中的每个 MemoryAccess,并暗示我们已到达函数的顶部。它是唯一不映射到 LLVM IR 中任何 Instruction 的 MemoryDef。liveOnEntry 的使用意味着正在使用的内存是未定义的或在函数开始之前定义的。
所有这些都覆盖在 LLVM IR 上的一个示例(通过在 .ll 文件上运行 opt -passes='print<memoryssa>' -disable-output 获得)如下所示。查看此示例时,以 clobber(覆写)的角度查看可能很有帮助。给定 MemoryAccess 的操作数是所述 MemoryAccess 的所有(潜在)clobber,并且 MemoryAccess 产生的值可以充当其他 MemoryAccess 的 clobber。
如果 MemoryAccess 是另一个的 clobber,则意味着这两个 MemoryAccess 可能会访问相同的内存。例如,x = MemoryDef(y) 意味着 x 可能会修改 y 修改/约束(或已经修改/约束)的内存。同样,a = MemoryPhi({BB1,b},{BB2,c}) 意味着任何使用 a 的人都可能访问由 b 或 c(或两者)修改/约束的内存。最后,MemoryUse(x) 意味着此 use 访问 x 已修改/约束的内存(例如,考虑如果 x = MemoryDef(...) 和 MemoryUse(x) 在同一循环中,则 use 不能单独外提)。
另一种有用的看待方式是内存版本。在该视图中,给定 MemoryAccess 的操作数是操作之前整个内存的版本,并且如果访问产生一个值(即 MemoryDef/MemoryPhi),则该值是操作之后内存的新版本。
define void @foo() {
entry:
%p1 = alloca i8
%p2 = alloca i8
%p3 = alloca i8
; 1 = MemoryDef(liveOnEntry)
store i8 0, ptr %p3
br label %while.cond
while.cond:
; 6 = MemoryPhi({entry,1},{if.end,4})
br i1 undef, label %if.then, label %if.else
if.then:
; 2 = MemoryDef(6)
store i8 0, ptr %p1
br label %if.end
if.else:
; 3 = MemoryDef(6)
store i8 1, ptr %p2
br label %if.end
if.end:
; 5 = MemoryPhi({if.then,2},{if.else,3})
; MemoryUse(5)
%1 = load i8, ptr %p1
; 4 = MemoryDef(5)
store i8 2, ptr %p2
; MemoryUse(1)
%2 = load i8, ptr %p3
br label %while.cond
}
MemorySSA IR 显示在它们映射到的指令之前的注释中(如果存在这样的指令)。例如,1 = MemoryDef(liveOnEntry) 是一个 MemoryAccess(具体来说,是一个 MemoryDef),它描述了 LLVM 指令 store i8 0, ptr %p3。MemorySSA 中的其他地方将此特定的 MemoryDef 称为 1(很像在 LLVM 中可以使用 %1 来引用 load i8, ptr %p1)。同样,MemoryPhi 不对应于任何 LLVM 指令,因此 MemoryPhi 正下方的行不是特殊的。
从上到下
6 = MemoryPhi({entry,1},{if.end,4})注释到,当进入while.cond时,到达它的定义是1或4。此MemoryPhi在文本 IR 中由数字6引用。2 = MemoryDef(6)注释到store i8 0, ptr %p1是一个定义,并且它之前的到达定义是6,或while.cond之后的MemoryPhi。(请参阅下面的 Use 和 Def 优化 和 精度 部分,了解为什么此MemoryDef未链接到单独的、消除歧义的MemoryPhi。)3 = MemoryDef(6)注释到store i8 0, ptr %p2是一个定义;它的到达定义也是6。5 = MemoryPhi({if.then,2},{if.else,3})注释到此块之前的 clobber 可能是2或3。MemoryUse(5)注释到load i8, ptr %p1是内存的使用,并且它被5clobber。4 = MemoryDef(5)注释到store i8 2, ptr %p2是一个定义;它的到达定义是5。MemoryUse(1)注释到load i8, ptr %p3只是内存的使用者,并且可能 clobber 此 use 的最后一件事是在while.cond之上(例如,store 到%p3)。在内存版本控制术语中,它实际上仅取决于内存版本 1,并且不受此后生成的新内存版本的影响。
顺便说一句,MemoryAccess 主要为了方便起见是一个 Value;它并非旨在与 LLVM IR 交互。
MemorySSA 的设计¶
MemorySSA 是一种可以为任何任意函数构建的分析。构建时,它会对函数的 IR 进行一次遍历,以便构建其 MemoryAccess 的映射。然后,您可以查询 MemorySSA 以获取诸如 MemoryAccess 之间的支配关系之类的信息,并获取任何给定 Instruction 的 MemoryAccess。
当 MemorySSA 完成构建时,它还会向您提供一个 MemorySSAWalker,您可以使用它(见下文)。
walker(遍历器)¶
帮助 MemorySSA 完成其工作的一个结构是 MemorySSAWalker,或简称 walker(遍历器)。walker(遍历器)的目标是提供超出 MemoryAccess 直接表示的 clobber(覆写)查询的答案。例如,给定
define void @foo() {
%a = alloca i8
%b = alloca i8
; 1 = MemoryDef(liveOnEntry)
store i8 0, ptr %a
; 2 = MemoryDef(1)
store i8 0, ptr %b
}
对 %a 的 store 显然不是对 %b 的 store 的 clobber(覆写)。walker(遍历器)的目标是弄清楚这一点,并在查询 MemoryAccess 2 的 clobber(覆写)时返回 liveOnEntry。
默认情况下,MemorySSA 提供了一个 walker(遍历器),可以通过查阅您碰巧正在使用的任何别名分析堆栈来优化 MemoryDef 和 MemoryUse。但是,walker(遍历器)构建为具有灵活性,因此创建更专业的 walker(遍历器)(例如,专门查询 GlobalsAA 的 walker(遍历器),始终在 MemoryPhi 节点处停止的 walker(遍历器)等)完全合理(并且是预期的)。
默认 walker API¶
有两个主要的 API 用于使用 walker(遍历器)检索 clobber(覆写)访问
MemoryAccess *getClobberingMemoryAccess(MemoryAccess *MA);返回MA的 clobber(覆写)内存访问,缓存沿途计算的所有中间结果,作为每个查询的访问的一部分。MemoryAccess *getClobberingMemoryAccess(MemoryAccess *MA, const MemoryLocation &Loc);返回 clobber(覆写)内存位置Loc的访问,从MA开始。由于此 API 不请求特定内存访问的 clobber(覆写)访问,因此没有可以缓存的结果。
自行定位 clobber(覆写)¶
如果您选择制作自己的 walker(遍历器),则可以通过遍历支配所述 MemoryAccess 的每个 MemoryDef 来找到 MemoryAccess 的 clobber(覆写)。MemoryDef 的结构使这相对简单;它们最终形成一个链接列表,其中包含支配您尝试优化的 MemoryAccess 的每个 clobber(覆写)。换句话说,MemoryDef 的 definingAccess 始终是所述 MemoryDef 的最近支配的 MemoryDef 或 MemoryPhi。
Use 和 Def 优化¶
MemoryUse 保留一个操作数,即它们的定义或优化访问。传统上,MemorySSA 在构建时优化 MemoryUse,直到给定的阈值。具体来说,每个 MemoryUse 的操作数都经过优化,以指向所述 MemoryUse 的实际 clobber(覆写)。这可以在上面的示例中看到;if.end 中的第二个 MemoryUse 的操作数为 1,它是来自入口块的 MemoryDef。这样做是为了使 walker(遍历器)、值编号等更快、更轻松。截至 此修订版,默认设置已更改为不在构建时优化 use,以便在 pass 中不需要 walker(遍历器)时提供减少编译时间的选项。大多数用户调用新的 API ensureOptimizedUses() 以保持先前的行为,并对 MemoryUse 进行一次性优化(如果之前未完成此操作)。建议新的 pass 用户调用 ensureOptimizedUses()。
最初,不可能以相同的方式优化 MemoryDef,因为我们将 MemorySSA 限制为每个访问一个操作数。这种情况已经改变,MemoryDef 现在保留两个操作数。第一个操作数,即定义访问,始终是同一基本块中的前一个 MemoryDef 或 MemoryPhi,或者如果当前块没有任何其他写入内存的访问,则是支配前驱中的最后一个。Def 链的 walker(遍历器)需要这样做。第二个操作数是优化的访问,如果之前调用过 walker(遍历器)的 getClobberingMemoryAccess(MA)。此 API 将缓存信息作为 MA 的一部分。优化所有 MemoryDef 具有二次时间复杂度,并且默认情况下不执行。
遍历任何 MemoryDef 的 use 可以找到优化到它的访问。此类遍历的代码片段如下所示
MemoryDef *Def; // find who's optimized or defining for this MemoryDef
for (auto &U : Def->uses()) {
MemoryAccess *MA = cast<MemoryAccess>(U.getUser());
if (auto *DefUser = dyn_cast<MemoryDef>(MA))
if (DefUser->isOptimized() && DefUser->getOptimized() == Def) {
// User who is optimized to Def
} else {
// User who's defining access is Def; optimized to something else or not optimized.
}
}
当 MemoryUse 优化后,对于给定的 store,您可以通过遍历 store 的直接和传递 use 来找到由该 store clobber(覆写)的所有 load。
checkUses(MemoryAccess *Def) { // Def can be a MemoryDef or a MemoryPhi.
for (auto &U : Def->uses()) {
MemoryAccess *MA = cast<MemoryAccess>(U.getUser());
if (auto *MU = dyn_cast<MemoryUse>(MA)) {
// Process MemoryUse as needed.
} else {
// Process MemoryDef or MemoryPhi as needed.
// As a user can come up twice, as an optimized access and defining
// access, keep a visited list.
// Check transitive uses as needed
checkUses(MA); // use a worklist for an iterative algorithm
}
}
}
在 DeadStoreElimination pass 中可以找到类似遍历的示例。
失效和更新¶
由于 MemorySSA 跟踪 LLVM IR,因此每当 IR 更新时都需要更新。“更新”在这种情况下包括 Instruction 的添加、删除和移动。更新 API 是根据需要制作的。如果您需要示例,GVNHoist 和 LICM 是 MemorySSA 更新 API 的用户。请注意,添加新的 MemoryDef(通过调用 insertDef)可能是一个耗时的更新,如果新的访问触发了许多 MemoryPhi 插入和许多 MemoryAccesses 的重命名(优化失效)。
Phi 放置¶
MemorySSA 仅在实际需要 MemoryPhi 的地方放置它们。也就是说,它是一种剪枝的 SSA 形式,就像 LLVM 的 SSA 形式一样。例如,考虑
define void @foo() {
entry:
%p1 = alloca i8
%p2 = alloca i8
%p3 = alloca i8
; 1 = MemoryDef(liveOnEntry)
store i8 0, ptr %p3
br label %while.cond
while.cond:
; 3 = MemoryPhi({%0,1},{if.end,2})
br i1 undef, label %if.then, label %if.else
if.then:
br label %if.end
if.else:
br label %if.end
if.end:
; MemoryUse(1)
%1 = load i8, ptr %p1
; 2 = MemoryDef(3)
store i8 2, ptr %p2
; MemoryUse(1)
%2 = load i8, ptr %p3
br label %while.cond
}
由于我们从 if.then 和 if.else 中删除了 store,因此 if.end 的 MemoryPhi 将毫无意义,因此我们不放置一个。因此,如果您需要在 if.then 或 if.else 中放置 MemoryDef,您还需要为 if.end 创建一个 MemoryPhi。
如果事实证明这是一个很大的负担,我们可以只在任何地方放置 MemoryPhi。因为我们有能够优化上述 phi 的 walker(遍历器),所以这样做不应禁止优化。
非目标¶
MemorySSA 旨在推理内存操作之间的关系,并实现更快的查询。它并非旨在成为所有潜在的内存相关优化的唯一真理来源。具体来说,在尝试使用 MemorySSA 推理原子或 volatile 操作时必须小心,如下所示
define i8 @foo(ptr %a) {
entry:
br i1 undef, label %if.then, label %if.end
if.then:
; 1 = MemoryDef(liveOnEntry)
%0 = load volatile i8, ptr %a
br label %if.end
if.end:
%av = phi i8 [0, %entry], [%0, %if.then]
ret i8 %av
}
仅通过 MemorySSA 的分析,将 load 外提到 entry 似乎是合法的。但是,由于它是 volatile load,因此不是。
设计权衡¶
精度¶
LLVM 中的 MemorySSA 故意牺牲精度以换取速度。让我们将内存变量视为内存的不相交分区(也就是说,如果您有一个变量,如上所述,它代表整个内存,如果您有多个变量,则每个变量代表内存的某些不相交部分)
首先,由于别名分析结果相互冲突,并且每个结果都可能是分析想要的(即 TBAA 可能说 no-alias,而其他东西可能说 must-alias),因此不可能按照每个优化想要的方式对内存进行分区。其次,某些别名分析结果不是传递的(即 A noalias B,B noalias C,并不意味着 A noalias C),因此在所有情况下都不可能得出精确的分区,而无需变量来表示每对可能的别名。因此,精确分区可能需要引入至少 N^2 个新的虚拟变量、phi 节点等。
这些变量中的每一个都可能在多个 def 站点被 clobber(覆写)。
举个例子,如果您要将结构字段拆分为单独的变量,则可能 def 多个结构字段的所有别名操作将可能 def 它们中的多个。这很常见(调用、复制、字段存储等)。
其他编译器中内存 SSA 形式的经验表明,精确地做到这一点根本不可能,事实上,精确地做到这一点是不值得的,因为现在所有的优化都必须遍历大量虚拟变量和 phi 节点。
因此我们进行分区。在您进行分区时,经验再次向我们表明,分区到一个以上的变量是没有意义的。它只会生成更多的 IR,并且优化仍然必须查询某些东西才能进一步消除歧义。
因此,LLVM 分区为一个变量。
实践中的精度¶
在实践中,LLVM 中存在一些实现细节,这些细节也会影响 MemorySSA 提供的结果的精度。例如,别名分析(AliasAnalysis)有各种限制或约束(例如在查看 phi 节点时),这可能会影响 MemorySSA 可以推断出的内容。不同 Pass 所做的更改可能会使 MemorySSA 变得“过度优化”(它可以提供比从头开始重新计算更准确的结果),或者“优化不足”(如果重新计算,它可以推断出更多)。当结果依赖于 MemorySSA 获取的状态(由于被多个后续 Pass 更新)时,这可能会导致在单个 Pass 中隔离地重现结果时遇到挑战。使用和更新 MemorySSA 的 Pass 应该通过 MemorySSAUpdater 提供的 API 或通过 Walker 上的调用来完成。不允许直接优化 MemorySSA。目前存在一个范围狭窄的例外情况,即 DSE(死存储消除)在保证优化正确的遍历之后,更新对存储的优化访问。之所以允许这样做,仅仅是因为遍历和推断超出了 MemorySSA 的范围,并且它们是“免费的”(即 DSE 无论如何都会执行它们)。此例外情况在标志(“-dse-optimize-memoryssa”)下设置,并且可以禁用它以帮助隔离地重现优化。
