十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MIR改用Block Arguments:从phi节点到基本块参数的设计变革

MIR改用Block Arguments:从phi节点到基本块参数的设计变革 很多编译原理初学者在接触中间表示IR时经常会遇到两个容易混淆的概念phi节点phi nodes和block arguments基本块参数。最近一个关于“Change MIR to use block arguments instead of phis”的 LLVM Code Generation RFC 引发了不少讨论。简单来说这个 RFC 建议在 MIR 层面放弃传统 SSA 的phi指令改用带参数的基本块来表示控制流的合并。本文会从背景、核心概念、转换示例、对优化器和代码生成的影响几个角度完整拆解这个设计变更并给出一份可以直接参考的工程实践建议。无论你是编译器学习者、Rust 开发者还是对 LLVM IR 感兴趣的工程师都能从中获得一套清晰的思路。1. 背景编译器中间表示为什么需要 SSA1.1 什么是编译器中间表示编译器并不是直接把源代码翻译成机器码而是会经过一层或多层中间表示Intermediate RepresentationIR。你可以把 IR 理解为一种“更接近机器、但又不绑定具体硬件”的通用语言。它既能承载源代码中的高级语义又方便编译器做优化最后再翻译成目标平台的原生代码。常见的 IR 包括抽象语法树AST最接近源代码偏向语法结构。高层中间表示HIR如 Rust 的 HIR保留类型信息。中层中间表示MIR如 Rust 的 MIR用于借用检查和部分优化。底层中间表示LLVM IR接近汇编用于代码生成。其中 MIR 是一个比较关键的位置。它比 AST 更接近机器但又保留了很多高级信息比如借用关系、生命周期、类型。LLVM IR 则是真正进入 LLVM 后端流水线的输入。1.2 什么是 SSASSAStatic Single Assignment静态单赋值是 IR 设计中的经典形式。它的核心约束是每个变量只能被赋值一次。听起来很苛刻但恰恰因为这个限制许多优化算法会变得非常简单。举个例子普通代码int y 0; if (cond) { y 1; } else { y 2; } use(y);在非 SSA 形式下变量y被赋值了三次两次发生在分支内部最后一次发生在分支之后。编译器很难直接判断use(y)中的y到底来自哪一次赋值。在 SSA 形式下我们需要把问题转换成“每个变量只有一个定义点”。对于上述控制流合并的情况就需要引入phi节点它的职责就是“根据控制流来源选择对应值”。伪 SSA 表示如下entry: br i1 %cond, label %then, label %else then: %y.1 add i32 0, 1 br label %merge else: %y.2 add i32 0, 2 br label %merge merge: %y phi i32 [ %y.1, %then ], [ %y.2, %else ] call void use(i32 %y)可以看到phi节点看起来像函数参数列表它列出了每个前驱块对应的值。在执行到merge块时如果上一个块是then就取%y.1如果上一个块是else就取%y.2。1.3 为什么从 phi 迁移到 block argumentsphi节点虽然经典但它在实际工程中也会带来一些麻烦。比如phi节点必须在基本块的开头集中出现。phi节点的语义是“根据前驱选择值”但它并不真正指定如何传递值。在很多优化 pass 中需要特殊处理phi节点增加了代码复杂度。将 IR 降低到机器码时phi节点通常会被消除但这个消除过程需要插入复制指令或select指令处理不当会引入额外开销。与之相对block arguments的做法是让基本块自身携带参数跳转到该基本块时由跳转方提供实参。例如entry: br i1 %cond, label %then, label %else then: br label %merge(1) else: br label %merge(2) merge(%y : i32): call void use(i32 %y)这里merge块有一个参数%ythen块通过br label %merge(1)传入1else块传入2。语义上与phi完全等价但信息流更显式。这正是本次 RFC 的核心思路在 MIR 中用 block arguments 替代传统的 phis。这个变更表面上看只是“表示形式变了”但会深刻影响后续的优化、降级lowering和 LLVM 代码生成。2. 核心概念拆解phis 与 block arguments2.1 phi 节点的工作方式phi节点在 LLVM IR 中写作%result phi i32 [ %value1, %block1 ], [ %value2, %block2 ]关键点是它位于某个基本块的开头并且每对[值, 前驱标签]都表示“如果上一步从哪个块来就取哪个值”。通常编译器会保证每个前驱恰好出现一次。对于只有一个前驱的情况phi节点等于简单的复制。对于多个前驱就需要列多对参数。一个经典的例子是循环entry: br label %loop loop: %indvar phi i32 [ 0, %entry ], [ %next, %loop ] %next add i32 %indvar, 1 %cmp icmp ult i32 %next, 10 br i1 %cmp, label %loop, label %exit exit: ret void%indvar在第一次进入循环时取0之后每次迭代取%next的值。这个模式非常典型几乎所有循环都会用到。2.2 block arguments 的工作方式block arguments也叫“基本块参数”或“入口参数”。它的形式是定义基本块时声明参数列表跳转到该块时传入实参列表。在类 MLIR 或 Rust MIR 风格中可以写作bb0 { ... br bb1(1); } bb1(arg: i32) { ... call(use(arg)); }如果把上方循环改写成 block arguments 风格会是entry: br label %loop(0) loop(%indvar: i32): %next add i32 %indvar, 1 %cmp icmp ult i32 %next, 10 br i1 %cmp, label %loop(%next), label %exit exit: ret void可以看到loop块自带参数%indvar每次从循环体跳回loop时把新值%next传进去。不需要phi列一堆前驱值。2.3 两种表示能力的等价性编译器理论中已经证明在纯 SSA 语义下phi节点和 block arguments 表达能力是等价的。它们可以相互转换从 phi 到 block arguments把基本块的phi节点列表转换成该基本块的参数列表把每个跳转到该基本块的br指令在末尾追加对应的实际参数。从 block arguments 到 phi把每个基本块参数转换成该基本块开头的phi节点需要枚举所有前驱并在每条跳转指令中提取对应实参。所以这个 RFC 并不是一种“能力革命”而是一种“工程优化”。基于不同的表示优化器可以更容易地实现某些变换。2.4 对比表格维度phi 节点block arguments信息位置合并块开头集中声明基本块参数 跳转实参跳转指令形态普通跳转跳转时附带参数语义理解需要根据前驱动态选择直接在跳转点确定传入值优化器处理需要特殊逻辑处理 phi参数传递跟普通指令类似对代码生成影响需要降低成复制指令或 select可映射为移动参数或块入口复制典型使用者LLVM IRMLIR、Rust MIR 某些阶段3. RFC 提案深度解读MIR 如何与 LLVM Code Generation 衔接3.1 提案的动机这个 RFC 背后的核心动机是当 MIR 最终要生成 LLVM IR 时如果 MIR 已经使用 block arguments那么与 LLVM 的phi之间会有一个直接的映射关系。为什么这么说因为 MIR 中如果使用 block arguments那么每个基本块的参数可以很自然地变成 LLVM IR 中相应基本块的phi节点。编译器不需要做复杂的“phi 节点放置”分析。更具体地RFC 还提到了以下几点减少重复代码很多优化 pass 在操作 MIR 时不需要专门绕过phi。简化降级过程从 MIR 到 LLVM IR 时只需要每遇到一个带参数的基本块就在 LLVM IR 对应基本块生成一组phi节点。提升可读性对于维护编译器的人来说block arguments 更加直观因为值来源清晰可见。利于后续优化block arguments 形式更适合做控制流图变换比如块合并、循环旋转等。3.2 对 LLVM Code Generation 的具体影响LLVM Code Generation 是指从 LLVM IR 到目标机器代码的过程。这个 RFC 的重点虽然是 MIR 层的表示但因为它处在 LLVM 上游所以也会间接影响代码生成质量。在传统phi路径下LLVM 后端的指令选择器需要把phi节点映射到目标指令。通常会通过“phi elimination”这一步把phi转换成复制指令。一些情况下还会使用条件移动指令如cmov来优化。在 block arguments 的路径下MIR 层面的清晰数据流会让 LLVM 更早地看到值的“传播链条”。例如一个分支跳转到下一个块时传入参数本质上就是复制或移动。LLVM 的优化器比如 GVN、Jump Threading可以直接利用这个传递链而不需要先做phi消除。3.3 提案可能引发的连锁变更采纳这个 RFC 后下列组件可能需要同步调整MIR 构建器Builder生成 block arguments 而不是 phi。MIR 打印与解析用于调试的单元测试格式。各类 MIR pass比如借用检查、常量传播、死代码消除。LLVM IR 下降器Lowering将 block arguments 映射为 phi 节点或直接映射为更优的指令。优化管线如果某些优化依赖 phi 的形态需要做适配。这其实说明了一个通用规律IR 设计上的一个微小变化会在整个编译器栈上引发连锁影响。因此RFC 通常会非常谨慎并且会配套大量测试用例。4. 实战演示从 phi 到 block arguments 的转换为了让你更直观地理解我们来看一个完整的转换示例。4.1 示例场景绝对值函数我们来写一个简单的 C 语言逻辑int abs(int x) { if (x 0) { return -x; } else { return x; } }该函数在 LLVM IR 中可能被翻译成define i32 abs(i32 %x) { entry: %cmp icmp slt i32 %x, 0 br i1 %cmp, label %lt, label %ge lt: %neg sub i32 0, %x br label %merge ge: br label %merge merge: %result phi i32 [ %neg, %lt ], [ %x, %ge ] ret i32 %result }这个 IR 清晰展示了phi的使用场景merge块有两个前驱lt和ge需要根据来源选择结果值。4.2 转换成 block arguments 形式现在我们把上面的 IR 改写成 block arguments 形式define i32 abs(i32 %x) { entry: %cmp icmp slt i32 %x, 0 br i1 %cmp, label %lt, label %ge lt: %neg sub i32 0, %x br label %merge(%neg) ge: br label %merge(%x) merge(%result: i32): ret i32 %result }注意merge块不再有phi指令而是有了参数%result。lt块跳转到merge时传入%negge块跳转到merge时传入%x。4.3 转换算法伪代码如果你要在自己的 IR 上实现这种转换可以参照下面的思路Function ConvertPhiToBlockArgs(Module M): for each Function F in M: for each BasicBlock BB in F: # 收集 BB 中的所有 phi 指令 phi_list BB.GetPhiList() if phi_list is empty: continue # 为 BB 增加参数列表参数类型与每个 phi 返回类型一致 for phi in phi_list: BB.AddArgument(new_param(phi.Type)) # 遍历所有前驱块把 phi 中对应的“值”写入前驱块的跳转指令末尾 for predecessor in BB.Predecessors: terminator predecessor.Terminator for phi in phi_list: incoming_value phi.GetIncomingValueForBlock(predecessor) terminator.AddArgument(incoming_value) # 删除 BB 中的 phi 指令 BB.RemoveAllPhis()反过来从 block arguments 到 phi 的转换算法会更复杂一些因为你需要为每个参数枚举所有前驱并从前驱的跳转实参中取值。4.4 直观验证为了验证转换正确性可以写一个小型的 IR 解释器对两种形式分别输入相同的测试用例观察输出是否一致。对于上面的abs函数输入-3输出3。输入5输出5。输入0输出0。由于两种形式语义等价它们在所有输入上应该表现一致。5. 对编译器优化与代码生成的影响分析5.1 优化器面对 phi 节点的负担传统优化器在处理phi节点时会遇到一个尴尬问题phi节点既不是纯粹的指令也不是纯粹的数据流边。它们位于“控制流合并”的位置需要编译器额外维护一份前驱到值的映射。很多经典的优化算法比如支配树Dominator Tree和循环分析在phi面前都要绕路。例如要做全局值编号GVN你需要处理phi的“参数化”特性这会让算法复杂度上升。而 block arguments 更像是“函数调用”。每个参数都有明确的来源位置优化器可以更方便地做内联/合并基本块常量传播到块参数死参数消除参数重命名。5.2 block arguments 可能带来的问题任何设计都有取舍。block arguments 虽然带来显式性但也可能引发新的问题跳转指令变长每个分支跳转都要携带参数对于参数很多的块边上的信息会增多。参数重命名难如果多个块之间循环传递参数可能需要额外的“复制”节点来断开循环依赖。下放到 LLVM IR 时最终还是需要转换成phi因此不能完全免除phi消除。不过在编译器中大部分场景下 block arguments 的收益大于成本这也是为什么 MLIR 和 Rust 的 MIR 都偏向这种风格。5.3 对 LLVM Code Generation 的质量影响从实践角度看block arguments 对最终代码生成质量的影响主要取决于 LLVM 后端如何处理这些参数如果 LLVM IR 中仍然使用phi那么从 block arguments 降到phi的过程需要保证不引入额外的复制指令。如果后端能直接从 block arguments 生成复制/移动指令那么可以跳过phi消除从而减少一次遍历。在某些架构上phi消除会生成两条mov指令并且由于寄存器分配的原因可能产生交换操作swap。而 block arguments 的设计允许后端在跳转块入口处直接建立传入实参到块参数的映射实际上把问题转移到了寄存器分配阶段处理方式更灵活。6. 常见问题与排查思路6.1 混合使用 phi 和 block arguments 是否可行技术上可行但会带来复杂度。如果一部分基本块使用phi一部分使用参数优化器必须同时支持两种模式容易产生一致性问题。建议在一个 IR 层中只选一种。6.2 循环头header在 block arguments 中怎么表示循环头也可以带参数。例如loop(%i: i32): ; 循环体 br label %loop(%next)这要求跳转到自身时也能传参。在设计时要注意循环参数可能形成“自环”需要检查参数依赖。6.3 多个前驱时如何保证参数顺序上面伪代码中我们提到了按前驱遍历。实现时要注意一个基本块可能会有很多前驱跳转指令的实参顺序必须与目标块的参数声明顺序一致。建议在数据结构中显式保存这一映射。6.4 LLVM IR 要求 phiMIR 是 block arguments怎么办这是最核心的工程问题。实际上主流做法是MIR 中保留 block arguments在生成 LLVM IR 时执行一道转换 pass专门把 block arguments 映射为phi节点。这是一道很标准的 lowering 任务不需要后端改动 LLVM IR 规范。6.5 Windows 环境下使用 LLVM 工具链需要注意什么有些 Windows 用户会想自己去编译 LLVM然后配合 Rust 使用。这里给一个通用提示不需要手动编译整个 LLVM。现代 Rust 工具链已经内置了 LLVM 后端。Windows 下安装 Rust 推荐使用官方rustup.exe然后在 Visual Studio Build Tools 中安装 Linker 组件。如果只是为了编程学习可以通过rustc --print sysroot查看当前工具链内置的 LLVM。不要随意把 LLVM 的 DLL 替换到 Rust 工具链目录容易导致版本不匹配。7. 最佳实践与工程建议7.1 设计 IR 时要明确抽象层次如果你的项目需要实现一个编译器不要一开始就设计成phi和 block arguments 混用。先明确你的 IR 是“高层优化友好”还是“代码生成友好”。高层优化建议使用 block arguments因为信息流更清晰底层机器表示可以考虑模拟 LLVM 的phi。7.2 迁移 plan 要分阶段如果是在已有编译器上做类似 RFC 的迁移建议按以下步骤先在 MIR 引入互不兼容的新表示同时保留旧表示。用专门的 converter 在两种表示之间切换。为所有 MIR pass 添加新表示的支持。增加“转换一致性”测试确保新旧表示行为一致。当所有 pass 都支持新表示后再移除旧的phi。7.3 构建完善的测试矩阵IR 表示变化影响非常大。测试至少覆盖单前驱基本块多前驱基本块循环头嵌套循环if-else 合并空块多返回值块。每一条都要同时跑新旧表示对比优化结果和代码生成结果。7.4 关注码量复杂度从phi迁移到 block arguments并不意味着代码量一定减少。有些 pass 可能写起来更自然但数据结构的存储成本会上升。要在 review 时注意是否引入了隐藏的 O(n²) 操作尤其是在遍历跳转指令实参时。7.5 在代码审查中注意什么检查参数顺序是否一致检查循环参数是否被正确传播检查常量传播是否因为参数形式而遗漏检查 lowering 到 LLVM IR 时生成的phi是否与原始行为等价。8. 总结与下一步学习路线本文围绕“Change MIR to use block arguments instead of phis”这个 RFC详细拆解了从phi节点到block arguments的设计动机、语义、转换方法、优化影响和工程实践。核心收获是SSA 不只有phi一种表达方式block arguments 更加显式有利于优化器和代码生成但最终是否需要迁移取决于编译器项目的整体架构。下一步你可以做以下几件事学习 LLVM IR 的phi节点自己动手编写简单的 IR 并观察opt优化结果。阅读 Rust MIR 的官方介绍理解 MIR 基本块参数是如何组织的。尝试为一个小型解释器实现两种 SSA 表示并互相转换加深理解。阅读 LLVM 官方文档中关于phi和“SSA 简化”的相关章节结合源码分析代码生成流程。如果你对编译器后端有兴趣还可以继续学习指令选择Instruction Selection、寄存器分配Register Allocation和指令调度Instruction Scheduling。这些主题都会反复用到你对 SSA 和基本块的理解。希望本文能成为你后续深入学习 compiler 技术的一把钥匙。
返回列表