Rust MIR的SSA演进:从phi节点到block arguments的RFC解析

发布时间:2026/8/30 3:12:42
Rust MIR的SSA演进:从phi节点到block arguments的RFC解析 先理解一下这个主题在讨论什么。Rust 编译器的中间表示 MIRMid-level Intermediate Representation长期使用phi节点来表示控制流汇合处的值选择而这篇 RFC 提议改用block arguments基本块参数来传递这类值。文章会先解释为什么存在两种 SSA 形式、各自解决了什么问题再结合 LLVM Code Generation 场景分析这次变更的动机、影响面和技术难点最后用示例对比两种写法的差异让对编译器内部感兴趣的读者建立完整的分析框架。1. MIR 与 phi 节点一段绕不开的编译器基础1.1 MIR 在 Rust 编译器中的位置在深入了解 Change MIR to use block arguments instead of phis 这个 RFC 之前先理清 MIR 在 Rust 编译器中的角色。Rust 编译器的前端处理过程大致如下源码 - AST抽象语法树 - HIR高级中间表示High-level IR - THIR类型化高级表示Typed High-level IR - MIR中级中间表示Mid-level IR - LLVM IR / Cranelift IR / GCC IR - 机器码其中 MIR 是 rustc 内部非常关键的一层。它一方面承担着借用检查borrow checking的任务另一方面也承担着若干编译优化、常量求值、代码生成前的准备等工作。与 HIR 相比MIR 已经非常接近传统编译器教科书里的“控制流图 三地址码”结构。MIR 中的基本块basic block把指令组织成线性序列块与块之间通过跳转指令连接整体形成一个 CFGControl Flow Graph控制流图。下面是一个 Rust 函数对应的 MIR 简化示意图fn abs(x: i32) - i32 { if x 0 { -x } else { x } }这段代码的 MIR 大致会是bb0: { _2 _1 0; switchInt(_2) - [false: bb2, true: bb1]; } bb1: { _3 -_1; goto - bb3; } bb2: { _3 _1; goto - bb3; } bb3: { _0 _3; return; }这里bb0、bb1、bb2、bb3就是基本块。_0是返回值槽_1是参数_2、_3是临时变量。这个例子虽然简单但已经能看出一个问题_3的值可能来自bb1也可能来自bb2。当控制流在bb3汇合时我们需要一种机制来准确表达“_3到底是哪一个值”。在传统 SSA 形式中这种“多路汇合”的需求由phi 指令解决。1.2 什么是 SSA为什么需要 phi 节点SSAStatic Single Assignment静态单赋值是编译器中间表示的一种重要形式。它的核心约束非常直观每个变量只能被赋值一次。在普通 IR 中一个变量可以被反复赋值v 1 v v 2 v v * 3而在 SSA 形式中每次赋值都会引入一个新的“版本”v1 1 v2 v1 2 v3 v2 * 3这种“不可变”的特点让数据流分析、常量传播、死代码消除、寄存器分配等优化变得异常方便。但 SSA 有一个经典问题如果两个控制流路径在某处汇合同一个逻辑变量会携带不同的值到达汇合点。此时SSA 要求每个变量只能有一个定义可是这里有两条定义路径。为了解决这个矛盾编译器引入了一个特殊的指令phi 节点phi instruction。bb3: { x phi [bb1: v1, bb2: v2] // 从 bb1 来取 v1从 bb2 来取 v2 }phi 节点不是一个真正意义上的“计算指令”它更像一个选择器它本身不产生新值而是根据前驱基本块选择对应传入路径上的值。1.3 phi 节点在 Rust MIR 中的存在方式在目前的 rustc 实现中MIR 里有一个专门的表示Rvalue::Use、Rvalue::BinaryOp等指令之外还有一种特殊表达式Rvalue::Ref? 不实际存放 phi 的地方是mir::Statement中的FakeRead、Assign以及一种叫mir::TerminatorKind::Goto与SwitchInt配合在汇合点生成赋值的方式。严谨地说当前 MIR 并不是把 phi 作为一条独立指令原样保存而是通过一个叫「Locals 赋值 基本块跳转」的框架来表达 SSA 信息。rustc 内部有专门的结构管理这些信息在部分优化和 MIR 借用检查中会以“类 phi 信息”的方式处理代码从 HIR 降级lowering到 MIR 时会尽量生成局部变量赋值。路径汇合处一个局部变量会被多个基本块赋值。借用检查器需要追踪这些赋值来源。后续优化如GVN、DSE也会依赖这些定义-使用关系。也就是说当前 MIR 虽然不会像 LLVM IR 那样出现显式的phi指令文本但在语义上它是通过“同一局部变量在多个前驱中赋值、在汇合块读取”的方式来模拟 phi 行为的。这套方案可行但并不优雅尤其在做数据流分析和 MIR 变换时常常需要额外维护一套映射关系来记录“某个变量在某条边上来自哪个定义”。2. Block Arguments另一种 SSA 形态2.1 用参数代替选择器Block arguments基本块参数是 SSA 的另一种等价表达方式。它的思想很直接把“选择”这个动作从汇合点前移到跳转边edge上。我们继续用前面的abs函数来演示 phi 写法与 block arguments 写法的差异。phi 风格bb0: { _2 _1 0; switchInt(_2) - [false: bb2, true: bb1]; } bb1: { _3 -_1; goto - bb3; } bb2: { _3 _1; goto - bb3; } bb3: { _0 phi [bb1: _3, bb2: _3]; return; }block arguments 风格bb0: { _2 _1 0; switchInt(_2) - [false: bb2, true: bb1]; } bb1: { _3 -_1; goto - bb3(_3); } bb2: { _3 _1; goto - bb3(_3); } bb3(v: i32): { // bb3 声明了一个参数 v _0 v; return; }可以看到block arguments 不再需要“同一变量多前驱赋值”的特殊结构。每个基本块可以声明自己的参数列表当一条跳转边跳转到这个块时跳转指令必须带上对应的实参。2.2 两种形式在语义上是等价的吗从数据流角度看phi 节点和 block arguments 都能完整表达SSA所需的值传递语义而且两者在表达能力上是等价的。但从编译器的工程实现角度两者存在不少差异对比维度phi 节点block arguments值传递发生位置汇合块内部跳转边上表示路径来源需要记录前驱块映射通过实参位置自然对应变换时的局部性修改前驱可能影响 phi 参数列表修改前驱时只需调整参数优化分析直观性需要扫描前驱参数和块绑定直观工程实现复杂度需要额外的边信息辅助结构内建无额外状态从工程角度看block arguments 最诱人的点在于它把一个“多对一”的边信息压缩到了每一条跳转边上。每个前驱只需要关心自己传入的参数不需要关心汇合块里究竟有多少个 phi 节点、这些 phi 节点按什么顺序排列、它应当填充 phi 的哪个槽位。2.3 其他编译器是怎么做的block arguments 并不是一个新鲜概念它已经被多个编译器项目和中间表示体系采用MLIRMLIR 的基本块天然支持参数block arguments是标准设计而不是通过 phi 指令。Cranelift这是 Rust 生态中最著名的使用 block arguments 的代码生成器。Swift SILSwift 编译器中间表示 SIL 也使用基本块参数。LLVMLLVM IR 至今仍然使用 phi 节点但新的 LLVM 内部 pass 和一些新 API 对 phi 的处理也在不断进化。从这个角度看Rust MIR 尝试切换到 block arguments本质上不是一次激进创新而是跟随了已经经过生产验证的工程路线。3. 这次 RFC 的核心动机为什么现在要改3.1 降低 MIR 变换的复杂度当前 rustc 的 MIR 中如果一个局部变量在不同前驱块中被赋值在汇合处读取它时rustc 需要回溯到前驱块去寻找定义。这种跨块的“定义-使用”追踪在编译器开发中是一个持续的心智负担。举一个具体的例子假设有一个 MIR pass 想要删除某个不必要的前驱块并把它的跳转重定向到后继块。在 phi 风格下你需要同步更新目标块里所有 phi 指令的参数列表找到“来自被删除块”的参数项改成“来自新前驱块”的参数项。如果目标块有大量的 phi或者存在更复杂的菱形结构这个操作非常容易出错。而 block arguments 形态下前驱块与后继块之间的参数传递关系直接被跳转指令携带。删除一个块时只需把“传给新前驱的参数”合并到新的跳转参数上缺失参数可以在构建过程中直接暴露不容易产生隐性错误。3.2 与前端降低lowering过程的配合从 HIR 降级到 MIR 时需要根据语法结构生成 CFG。例如Rust 的?操作符、if-else、match、loop等结构在降级过程中会生成大量汇合块。当前的做法是先生成块结构再把需要穿越汇合块的值赋给局部变量最终在汇合后读取。如果目标块支持参数降级阶段可以更自然地把语义上的“值”直接绑定到块参数上减少“创建临时变量 跨块赋值 汇合后读取”这串样板逻辑。3.3 为未来优化和代码生成铺路rustc 的 MIR 优化一直围绕“借用检查 基础优化”展开深度优化更多依赖后端 LLVM。但长期看rustc 希望在 MIR 层面承担更多代码生成管线职责例如MIR inlining、MIR 常量传播、MIR 死代码消除等。如果 MIR 能切换到 block arguments这些 pass 的实现会明显更简洁。此外从代码生成角度MIR 最终要翻译成 LLVM IR。LLVM IR 使用的仍然是 phi 节点。因此从 block arguments 形式到 phi 形式需要一次转换。这个转换是机械化的只需要把每个块的参数统一收集在块的开头生成 phi 节点再把跳转边的实参对应到 phi 的传入参数即可。这种机械转换非常稳定适合在 rustc 的codegen阶段完成。4. 具体设计思路与 LLVM Code Generation 的影响分析4.1 从 MIR 的当前结构出发如果要把 MIR 改成 block arguments需要修改的不仅是“显示形式”而是整个 MIR 的类型系统和控制流结构。大致会涉及以下几个层面给基本块类型增加参数列表BasicBlockData中需要增加类似args: VecLocal的定义表示这个块接收多少个参数。修改跳转指令TerminatorKind::Goto、SwitchInt、Call的返回值语义等都需要调整为“携带实参”。局部变量与块参数的关系 由于块参数本身是一种“值来源”在借用检查器中块参数需要被视作一个独立的借用位置或内存位置。降级过程重构 从 HIR 到 MIR 的构建过程需要重新设计优先确定基本块参数再生成对应的跳转实参。4.2 对借用检查器的影响借用检查器是 Rust MIR 最重要的消费者。它检查内存安全时需要追踪每个值的生命周期、借用关系、移动语义。在 phi 风格下如果有两个前驱分别把一个String移动到汇合块借用检查器需要判断“移动发生在哪条路径上”、“是否所有路径都移动了”、“汇合后是否还有未初始化的可能”。block arguments 会改变这些信息的呈现方式。移动语义会变得非常显式前驱块向目标块传入参数本质上是“传递所有权”。借用检查器可以直接沿着跳转边的实参与目标块的形参识别所有权转移。这种显式性一方面让语义更清晰另一方面也意味着借用检查器需要重新设计部分分析逻辑。尤其是在处理Drop标志、移动路径和部分初始化时需要把“块参数”作为新的分析对象纳入模型。4.3 对 LLVM Code Generation 的影响MIR 最终要翻译为 LLVM IR。LLVM IR 目前仍然是 phi 节点模型因此 block arguments 到 phi 的转换是必要的。这个转换并不复杂// 伪代码BlockArguments 到 LLVM phi 的转换逻辑 for block in mir_blocks { if block.parameters.is_empty() { continue; } let phi builder.create_phi(block.parameters.types); for (pred, args) in predecessors(block) { phi.add_incoming(args, pred.terminator_llvm_block); } insert(block.entry, phi); }需要注意的是在 LLVM IR 中 phi 节点必须位于基本块的最前面入口处。从 block arguments 转换时如果目标块在 LLVM IR 里已经有了其他指令需要把 phi 插入到所有非 phi 指令之前。这个约束虽然机械但必须小心处理。在 MIR 优化 pass 已经做过一轮变换、并且 pass 之间彼此依赖的情况下转换时的信息完整性非常重要。如果某些优化 pass 不够规范可能导致 block arguments 与 phi 之间的对应关系丢失进而产生不正确的 LLVM IR。这正是这次 RFC 的一个关键技术风险点MIR 层面的优化 pass 数量虽少但都要重新验证。5. 两种表示形式的完整示例对比5.1 一个包含汇合路径的 Rust 函数为了更好理解两者差异这里写一段稍微复杂一点的 Rust 代码fn choose(flag: bool, a: i32, b: i32) - i32 { let v if flag { a 1 } else { b - 1 }; v * 2 }5.2 当前 MIR 的 phi 式表达示意用 rustc 的 MIR 风格近似表示fn choose(_1: bool, _2: i32, _3: i32) - i32 { let mut _0: i32; let mut _4: i32; let mut _5: i32; let mut _6: i32; bb0: { _5 _2 1; _4 _5; goto - bb2; } bb1: { _6 _3 - 1; _4 _6; goto - bb2; } bb2: { // _4 在这里可能来自 bb0也可能来自 bb1 _0 _4 * 2; return; } }在这个当前形态中_4在bb0和bb1都有赋值bb2读取它。虽然 rustc 没有显式写出 phi 节点但从语义上讲_4的值是一个“phi 选择”。5.3 block arguments 形态示意如果采用 block arguments 重写这段 MIR 会变成fn choose(_1: bool, _2: i32, _3: i32) - i32 { let mut _0: i32; bb0: { _5 _2 1; goto - bb2(_5); } bb1: { _6 _3 - 1; goto - bb2(_6); } bb2(v: i32): { _0 v * 2; return; } }在这个形态中bb2声明了一个参数vbb0传入_5bb1传入_6。原先那个需要跨块追踪的_4直接消失了值传递关系变得一目了然。5.4 两种形态在代码生成阶段的差异如果要在代码生成阶段把这两种形态转换成 LLVM IR当前形态需要识别“同一局部变量在多个前驱赋值、在后继块读取”的模式然后生成phi。block arguments 形态直接根据块参数和跳转实参生成phi无需额外扫描。本质上block arguments 把“发现 phi”提前到了 MIR 构建阶段让后续每一步分析都更加省事。6. 潜在难点与现实工程约束6.1 在 rustc 生态中推行这项变更的难度MIR 是 rustc 内部结构但它并不是一个封闭的黑盒。围绕 MIR 工作的工具和机制很多rustc_codegen_llvmLLVM 后端rustc_codegen_craneliftCranelift 后端rustc_codegen_gccGCC 后端MIR 相关的调试工具如-Z dump-mir若干基于 rustc 内部的 MIR pass 和测试任何一个 MIR 结构变更都意味着这些子系统需要同步更新。这非常考验 RFC 的实施策略。6.2 渐进式迁移还是全量重写如果一次性把所有 MIR 的构建过程从“局部变量赋值模拟 phi”重写为“block arguments”改动规模会非常大且容易在初始阶段引入大量回归。更现实的做法是分阶段推进先在 MIR 结构层面增加基本块参数的能力。修改 HIR lowering使新建的 MIR 尽可能使用 block arguments。逐步调整借用检查器和优化 pass使其既能处理旧结构也能处理新结构。在某个版本之后彻底移除旧的“跨块赋值模拟 phi”逻辑。这种渐进式迁移在 rustc 内部不是没有先例但需要维护者在很长一段时间内同时兼容两套结构工作和心智负担都不小。6.3 对普通 Rust 开发者的影响这里需要明确一点这个 RFC 的影响面几乎完全限制在 rustc 编译器的内部实现。普通 Rust 开发者写代码、编译、运行几乎不会感知到这一变化。唯一可能感受到的差异在于编译期错误信息某些借用检查的提示信息可能更精确因为 MIR 的结构更适合分析所有权转移。编译速度如果 MIR 形态更直观某些分析可能更快或少占用内存。编译器 bug 的潜在变化任何大规模重构都有短期回归风险但长期看会降低维护成本。7. 常见问题与排查思路由于这个主题面向编译器内部与其说是“问题排查”不如说是“理解与验证”。下面几个问题对想深入 rustc 开发或研究 MIR 的读者更有价值。问题原因与背景建议思路我在哪里能看到 MIRrustc 没有默认输出 MIR需要环境变量或参数打开使用rustc -Z dump-mir或RUSTC_LOGrustc_mirdebug查看这个 RFC 会不会影响的 my crate 的运行性能不会MIR 是编译期结构最终生成机器码经过后端优化不必担心运行时影响关注未来的编译期体验即可为什么 LLVM IR 还是 phi而 MIR 要改成 block argumentsLLVM 生态依赖 phi而 rustc 内部优化和借用检查更适 block args保持转换层职责分离rustc 在后端完成 phi 重建这个变更和 Windows 上使用 LLVM 工具链有关系吗没有直接关系这是 rustc 内部 IR 形态变更不是 LLVM 工具链安装问题面向编译器开发者普通工具链用户无需操作如果你对编译器内部表示变更感兴趣推荐从阅读 rustc 的编译文档、MIR 相关 rustc-dev-guide 章节以及rustc_codegen_cranelift的源码入手——因为 Cranelift 本身采用 block arguments它的 MIR 转换逻辑可以作为参考实现。8. 关注这个 RFC 的几个理由与学习建议如果你是一名对编译器底层感兴趣的 Rust 开发者这个 RFC 值得关注原因有三它是理解 SSA 表示演进的绝佳案例。 同一个语义用 phi 和 block arguments 两种方式表达分析和理解两者的差异能帮助你建立更强的中间表示设计能力。它展示了编译器架构中“表示选择”如何影响多方系统。 从 lowering、借用检查、优化 pass 到 codegen一个 IR 形态的改动会波及整条流水线。这种全局观是编译器开发中非常宝贵的能力。它和 LLVM codegen 的交互非常典型。 换一种表示不是“为了换而换”而是要综合权衡 IR 表达能力、优化便利性、后端转换成本和工程维护负担。如果你希望真正掌握这个领域建议按照下面的路径学习先准确理解 SSA 与 phi 的必要性。再通过 Cranelift 的文档和源码熟悉 block arguments 的实际用法。对比 rustc 当前 MIR 的 lowering 逻辑和借用检查逻辑。最后尝试为 rustc 编写一个简单的 MIR pass观察它在 phi 风格下的实现方式再想象如果改成 block arguments 会如何变化。等到 RFC 真正实施并合并时回头再看这篇文章里的示例你会对这次变更的收益和代价有更深刻的体会。9. 结语MIR 从“模拟 phi”迁移到 block arguments看似是一次内部表示层面的结构性调整实质上影响着 Rust 编译器未来在借用检查、MIR 优化和后端代码生成方面的发展空间。这个 RFC 的核心价值不在于“推倒重来”而在于让 MIR 的表示方式更贴合现代编译器工程实践中被反复验证过的设计路线。对于普通 Rust 用户这更像是一次无感升级对于编译器工具链开发者则是一次需要谨慎评估、渐进落地的架构演进。如果你正站在学习编译原理或参与 rustc 开发的路口这个 RFC 是一个非常值得拆解的活案例。