ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Rust MIR 数据流变革:phi 节点与 block arguments 深度解析

Rust MIR 数据流变革:phi 节点与 block arguments 深度解析 在编译原理里跨基本块的数据流表达是每个编译器中间表示都必须先做的基础决策。LLVM IR 选择的是 phi 节点而 Cranelift、Swift SIL、MLIR 这类 IR 则选择 block arguments 来表达同一件事。rustc 内部有一份标题为 Change MIR to use block arguments instead of phis 的 RFC讨论的不是 LLVM 本身怎么做而是 Rust 的 MIR 是否也把数据流表达改成 block arguments从而让后续 LLVM 代码生成、借用检查、drop 处理和 MIR 优化都受益。这篇文章会从 SSA、phi 节点和块参数的基础概念讲起结合最小代码示例说明两种表达的差异再给出在本地观察 LLVM IR 和 MIR 数据流的具体方法最后聊一聊这类编译器 RFC 应该怎么读、怎么评审、怎么落地。1. 先搞清楚两个核心概念phi 节点和 block arguments1.1 SSA 形式为什么需要“会合点”SSAStatic Single Assignment静态单赋值要求每个变量在整个程序里只被赋值一次。这种约束让每个变量都天然代表一个不变量后续数据流分析和优化可以非常直接地依赖“这个值从哪来、被谁用”的信息。但普通程序员写的程序几乎不可能天然满足这个约束。同一个变量在if的两个分支里被赋了不同的值汇合之后又要继续使用这个变量到底代表哪个值就无法用“只赋值一次”的模型描述。解决办法是在控制流汇合的地方引入一个特殊节点它根据“当前是从哪一个前驱块跳过来的”来选择对应的值。这个节点在 LLVM IR 里的名字就是 phi 节点。可以把 phi 理解成 SSA 形式里的“会合点登记处”它自己不产生新的值只是把前驱块传过来的值按路径选择出来。1.2 LLVM 里的 phi 节点长什么样LLVM IR 的 phi 节点写在基本块开头格式是一组[ 值, 前驱块 ]配对define i32 choose(i32 %c, i32 %a, i32 %b) { entry: %tobool icmp ne i32 %c, 0 br i1 %tobool, label %then, label %else then: br label %merge else: br label %merge merge: %r phi i32 [ %a, %then ], [ %b, %else ] ret i32 %r }这里的关键是merge块开头的phi指令当控制流从then过来时%r取%a从else过来时%r取%b。这段 IR 在执行时并不是真的有一条 CPU 指令去“执行 phi”phi 只是编译器中间层的表达。真正生成机器码时LLVM 会把 phi 消解成前驱块末尾的拷贝或者让寄存器分配器直接分配同一个寄存器从而连拷贝都省掉。1.3 block arguments 是另一种“会合”方式block arguments 的想法更直白既然数据要跨块流动那就让基本块像函数一样带参数。每个块可以声明自己的参数列表前驱块在做跳转时把值作为“实参”传进来。用这种模型表达上面的例子会是下面这样伪代码示意不是 LLVM 语法block entry(c, a, b): if c ! 0: goto block then() else: goto block else() block then(): goto block merge(a) block else(): goto block merge(b) block merge(r): return r注意merge块声明了一个参数r两个前驱跳转时分别传入a和b。这个设计和函数调用在外观上很像但语义完全不同它不是一次运行时函数调用只是把数据流关系显式写在了控制流边上。采用这种表达的典型系统包括 Cranelift、Swift SIL 和 MLIR。1.4 两种表达的核心差异对比项phi 节点block arguments汇合点信息放在哪里写在块的入口显式列出所有前驱的值写在跳转边上由前驱传递值前驱块数量变化时phi 的参数列表要同步修改跳转参数随边自动保持一致数据流方向从边到块入口间接表达从边到块参数直接表达是否引入额外块一般不需要不需要块签名本身携带信息更适合什么场景LLVM IR 这类经典 SSA高层次的编译器 IR、需要频繁做数据流分析的 IR从表达力上说两者可以互相转换很多编译器教科书会把 block arguments 解释成 phi 节点的一种变体。真正拉开差距的是工程细节数据流关系放在哪一侧、修改控制流时会不会破坏一致性、转化到机器码时能不能减少拷贝。注意phi 节点不是运行时指令block arguments 也不是函数调用。两者都是编译期的数据流表达方式最终都会在代码生成阶段被消解成拷贝或寄存器分配。2. MIR 在 rustc 里的位置以及它和 LLVM IR 的关系2.1 MIR 是什么解决什么问题Rust 编译器 rustc 的核心中间表示有三层HIRHigh-level IR高层中间表示、MIRMid-level IR中层中间表示和最终交给后端的 LLVM IR 或 Cranelift IR。MIR 在 rustc 中承担几个关键任务借用检查在 MIR 上做许多 Rust 特定的优化在 MIR 上做甚至async、generator的状态机构造也在 MIR 上做。因此 MIR 的设计对编译速度、优化效果、代码生成质量和错误诊断都有直接影响。和 LLVM IR 不同MIR 一开始并不是严格的 SSA 形式。它更接近“带局部变量槽的 CFG”每个基本块是一系列语句加一个终止符值通常写进局部变量槽再由后续块读取。这种设计对借用检查友好因为 Rust 的移动语义、借用生命周期和 drop 都需要看到变量槽的变化。2.2 代码生成链路MIR 到 LLVM IR 再到机器码rustc 的默认后端是 LLVM。一条典型链路是前端把源码解析、类型检查后生成 HIR。HIR 降级lowering成 MIR。MIR 上完成借用检查和一批 MIR 优化。MIR 被翻译成 LLVM IR。LLVM 自己再做一轮优化生成目标机器码。所以 MIR 是“带 Rust 语义”的中间层LLVM IR 是“被 LLVM 优化器理解”的中间层。两者之间必然存在语义映射问题MIR 里方便表达的移动语义在 LLVM IR 里可能对应成一次内存拷贝MIR 里一份很自然的数据流关系翻译成 LLVM 的 phi 时可能又要多绕一个局部变量。2.3 MIR 现在的数据流表达为什么看起来像“隐式 phi”在 rustc 当前的 MIR 表达里跨块传递值往往写成“赋值语句 跳转”。可以看下面这个示意结构bb0: { _0 discriminant(_1); match _0 { 0 bb1, _ bb2 } } bb1: { _3 move _2; goto - bb3; } bb2: { _3 move _4; goto - bb3; } bb3: { // 后续逻辑使用 _3 }这里_3在两个前驱块里分别被赋值再在bb3被读取。从数据流角度看_3的行为和 phi 节点完全一样它根据来源块选择不同的值。但因为它是通过一个共享的局部变量槽表达的代码生成阶段很容易把它对应成一个实际的内存槽或一次显式拷贝而不是直接映射到 LLVM 的 phi 节点。这还牵出另一个问题变量槽需要管理生命周期。MIR 里的StorageLive、StorageDead语句就是用来标记槽位何时有效、何时可以释放的。对同一个值既要做“赋值 跳转”又要管理槽位生命周期这些结构在翻译成 LLVM IR 时都会增加无谓的表达负担。2.4 RFC 想解决的三个具体痛点从工程角度看这份 RFC 针对的主要是三个问题第一数据流表达不够直接。MIR 里用变量槽模拟 phi翻译到 LLVM IR 时容易产生多余的alloca、load、store或memcpy。第二移动语义和借用检查耦合太深。值在块之间移动时MIR 需要准确地回答“这个值现在归哪个块管、谁负责 drop”。块参数可以把这个责任显式化而变量槽表达需要额外分析才能确定。第三多个后端需要统一的数据流模型。rustc 除了 LLVM 后端还有 Cranelift 和 GCC 后端而后两者自己就使用 block arguments。如果 MIR 也改成 block arguments后端翻译会简单很多语义映射也更少。3. RFC 核心设计给每个基本块加参数3.1 块签名块不再只是标签改动之后每个 MIR 基本块不再只是一个标签加一组语句而是可以声明自己的参数列表。参数从哪来、值什么类型、是否拥有所有权这些信息都写在块签名里。以开头的示例为例改造后的 MIR 示意大致是bb0: { match discriminant(_1) { 0 goto bb1, _ goto bb2, } } bb1: { goto bb3(_2); } bb2: { goto bb3(_4); } bb3(v: Ty) { // 后续逻辑直接使用块参数 v }bb3的块参数v取代了原来的变量槽_3。控制流走到bb3时v已经有确定的值不需要再分析_3是哪个块写入的。3.2 终止符携带传递值块参数化之后终止符terminator也要跟着扩展。现在goto、SwitchInt、Call、Return这类终止符除了跳转目标还需要携带传递给目标块的“实参”。这会带来一系列连锁修改MIR 的 pretty printer、MIR 分析 pass、借用检查器、drop 分析、以及把 MIR 翻译到 LLVM IR 的代码生成器都要理解和传播新的终止符参数。任何一处没有同步更新就会出现“块签名声明了参数但跳转没传值”之类的内部错误。3.3 借用检查、drop 处理和优化 pass 需要跟着改借用检查是 rustc 里最复杂、最敏感的部分。改成 block arguments 后值的所有权关系变得更显式一个块参数如果带有所有权那么它在块内被移动、被借用、在块结束时被 drop都会直接体现在块的语句和终止符里。这对借用检查器来说反而是一份更干净的数据因为它不再需要从一个共享变量槽推断值的来源。drop 处理同样会清晰很多。当前 MIR 需要分析变量槽的生命周期来决定在哪里插入 drop。块参数化之后每个块知道自己接收了哪些拥有所有权的值块结束时就明确知道哪些值需要 drop不需要再去追踪变量的“最后一次使用”到底发生在哪条路径。MIR 优化 pass 也会受影响但总体是正向的。很多数据流分析在块参数模型下可以直接复用控制流上的信息不需要额外做前驱关系查询。代价是凡是遍历 MIR 终止符的 pass都要适配新的参数列表这是一次范围很大的重构。3.4 收益和代价对比维度潜在收益需要付出的代价LLVM 代码生成减少无谓拷贝和变量槽更容易生成干净的 SSALLVM IR builder 需要重新映射块参数到 phi借用检查值的来源和所有权更显式借用检查器核心逻辑要适配新结构drop 处理生命周期责任边界更清楚drop 插入逻辑要重写一部分多后端支持与 Cranelift、MLIR 的表达一致所有后端适配工具链MIR dump、调试输出更直观lint、可视化工具、相关 crate 都要改迁移风险长期架构收益明显中期需要大量 PR 和回归测试从收益和代价的对比可以看出这不是一个“加一个语法糖”的改动而是对 rustc 内部核心数据结构的一次重构。正因如此这类 RFC 通常不会一步到位而是分阶段迁移。4. 用最小例子看两种表达的实际差异4.1 一个简单的条件选择先从一个 C 程序开始因为它可以最快生成带 phi 的 LLVM IRint choose(int c, int a, int b) { int r; if (c) r a; else r b; return r; }这个逻辑非常简单但已经包含了“两个分支各自赋值、汇合后继续使用”的典型数据流结构。4.2 LLVM IR 的 phi 版本用clang把它转成未优化的 LLVM IR汇合处会生成 phi 节点clang -S -emit-llvm choose.c -o choose.ll得到的核心结构如下define i32 choose(i32 %c, i32 %a, i32 %b) { entry: %tobool icmp ne i32 %c, 0 br i1 %tobool, label %then, label %else then: br label %merge else: br label %merge merge: %r phi i32 [ %a, %then ], [ %b, %else ] ret i32 %r }未优化 IR 的好处是结构忠实地反映了源码控制流。merge块里的 phi 用一个语句统一表达了“值来自哪条边”这正是 LLVM IR 在 IR 层面的数据流表达。4.3 改成 block arguments 的形态如果换成 block arguments 模型同一个逻辑可以描述成block entry(c, a, b): if c ! 0: goto then() else: goto else() block then(): goto merge(a) block else(): goto merge(b) block merge(r): return r差异非常直观merge不再需要记录“前驱列表和每个前驱给的候选值”而是直接把r声明成自己的参数。哪个前驱传了什么值看跳转边就知道。4.4 为什么这种改动对 LLVM codegen 有影响回到 rustc 的场景。假设 MIR 里本来有一个_3变量槽先被_2赋值再从bb1跳到bb3LLVM 后端要把这段表达翻译成 phi 时需要先识别出_3其实是一个“按路径取值的值”而不是真正的内存变量。如果识别失败生成出的 LLVM IR 就会变成entry: ; ... 分配 _3 的槽位 store i32 %a, i32* %_3 br label %merge else: store i32 %b, i32* %_3 br label %merge merge: %r load i32, i32* %_3 ret i32 %r这会引入一次store加一次load。虽然 LLVM 的优化器后续可能把这段代码优化掉但在未优化、调试构建、或者优化器未能正确分析时就会保留多余的内存操作。对于 Rust 这种大量依赖 move 语义、很在意拷贝开销的语言来说这类损耗值得从来源上消除。block arguments 表达把“这个值就是数据流上的值不是内存槽”写进了 IR 本身LLVM 后端可以直接把它映射成 phi省去中间的识别和推断。注意block arguments 和 phi 在语义上等价但在工程上“等价”不代表“成本一样”。中间层如果多绕了一个变量槽后端就多了一次识别工作也多了识别失败兜底生成的拷贝。5. 本地实验自己观察 phi 和 MIR 的数据流5.1 在 Windows 上准备好 LLVM 工具链如果机器上没有 LLVM 工具链Windows 上的常规做法有两个。一是用包管理器安装winget install LLVM.LLVM安装后新开一个终端确认工具可用clang --version opt --version llvm-config --version二是从 LLVM 官方 GitHub Releases 页面下载预编译的 Windows 安装包。下载后同样把bin目录加到PATH或者直接用绝对路径调用。注意Windows 上如果之前装过其他编译器或 Rust 工具链自带的 LLVM端口和 PATH 里可能出现多个版本。先用where clang、where opt确认实际调用的是哪一个避免版本不一致导致实验现象对不上。5.2 用 clang 生成带 phi 的 IR先写上面的choose.c然后执行clang -S -emit-llvm -O0 choose.c -o choose.ll打开choose.ll在merge块附近就能看到phi指令。这是观察 phi 节点最直接的方法。如果还想看优化器如何消解 phi可以再跑一步optopt -S -passesmem2reg choose.ll -o choose-mem2reg.ll不过choose.ll本身用的是 SSA 寄存器没有alloca所以更合适的实验是把优化后的 IR 转成机器码观察寄存器分配llc choose.ll -o choose.s打开汇编文件你会看到 phi 节点消失取而代之的是寄存器间的mov或者干脆因为两个分支可以合并而直接复用同一个寄存器。这个现象能帮你理解“phi 只是中间表达最终会被消解”。5.3 用 rustc nightly 导出 MIR要观察 rustc 的 MIR需要 nightly 工具链rustup toolchain install nightly写一个最简单的 Rust 源文件fn choose(c: bool, a: i32, b: i32) - i32 { if c { a } else { b } } fn main() { let _ choose(true, 1, 2); }用-Z unprettymir导出 MIR 文本rustc nightly -Z unprettymir choose.rs输出里能看到基本块和终止符以及_0、_1这样的局部变量槽。也可以用-Z dump-mir导出每个优化阶段的 MIR观察同一份代码在不同 pass 前后的形态差异。要注意-Z是 nightly 内部选项不同版本之间格式会变实验时固定工具链版本会更稳定。5.4 从本地实验得到的判断做完这几个实验你会对“数据流表达方式影响代码生成”有直观感受同一个语义用变量槽表达会产生额外的内存操作用 SSA phi 表达能直接被寄存器分配器处理而 block arguments 则是把“这只是一个值不需要槽位”这件事在更高层就讲清楚。6. 评审一份编译器 RFC 应该看什么6.1 先明确变更边界编译器内部 RFC 最容易踩的坑是“改动范围失控”。评审时先问几个问题这个改动只影响 MIR 内部数据结构还是会改变用户可见行为借用检查的诊断信息会不会变化MIR 的序列化格式比如增量编译用的编码是否兼容这份 RFC 的核心边界在 rustc 内部理论上不改变 Rust 语言语义也不改变源码兼容性。但它会影响大量内部 crate评审重点应该是迁移方案是否分阶段、是否允许新旧表达共存。6.2 关注兼容性和回溯路径编译器重构最怕的是“改到一半发现模型不成立”。评审时要看 RFC 有没有描述清楚如果 block arguments 在某个 pass 里退化回变量槽形式应该怎么办每个 pass 是否都能无损处理新结构借用检查器在遇到复杂模式时能否回退到旧的借用分析一份合格的编译器 RFC 不仅要描述最终形态还要描述中间态。理想状态下MIR 可以先同时支持两种表达pass 逐个迁移最后再删除旧的变量槽路径。6.3 落地顺序和增量迁移现实中的落地顺序通常是先在rustc_middle::mir的数据结构里加入块参数。扩展 MIR 解析、打印和 dump 工具链。让 borrow checker 和 drop 分析支持新表达。改造一个或几个 MIR pass 作为试点。后端 LLVM builder 把块参数映射成 phi。跑完整测试套件对比前后生成的 IR 和性能。全部通过后再删除旧表达。每一步都要有回归测试、性能对照和 IR 差异检查。这个过程比写 RFC 本身更漫长。6.4 普通开发者和贡献者怎么跟进对大多数 Rust 开发者来说这个 RFC 不会带来日常代码层面的变化。它影响的是编译器和生成代码的质量。如果想跟进可以关注 rustc 的 MIR 相关模块、编译器团队的设计会议记录以及后端相关的 PR。对想参与编译器开发的贡献者这份 RFC 是一个很好的切入点可以先从 MIR pretty printer 或 dump 工具的适配开始这类任务范围明确、测试充分、反馈直接。7. 常见误区与排查清单7.1 误以为 phi 是“执行期指令”这是最常见的误解。phi 节点不会出现在机器码里也没有对应的 CPU 指令。它只是在 SSA 形式中表达“值的选择关系”。如果你在调试 LLVM IR 时找不到 phi 对应的汇编这是正常的不需要排查。7.2 误以为 block arguments 是函数调用block arguments 长得像函数参数跳转也长得像函数调用但它不是运行时调用。它不产生栈帧、不保存返回地址、没有调用约定。把它理解成“跳转边上多带了一包数据”更准确。7.3 把 MIR dump 里的赋值语句当成 phi因为当前 MIR 用共享局部变量槽模拟跨块数据流所以 MIR dump 里经常看到“bb1里赋值bb3里读取”的结构。这是变量槽表达不是显式 phi。理解这一点能避免误判 RFC 的改动范围。7.4 Windows 工具链和 nightly 选项问题问题现象常见原因检查方式处理建议终端里找不到clang安装后未重开终端或 PATH 未更新运行where clang重新打开终端或手动把 LLVM 的 bin 目录加入 PATHclang --version版本和预期不符机器上存在多个 LLVM 安装运行where clang、where opt清理 PATH 或使用绝对路径调用rustc -Z unprettymir报错使用了 stable 工具链运行rustc --version确认版本改用rustup toolchain install nightly和rustc nightly-Z dump-mir输出目录为空nightly 版本语法变化查看 rustc 版本对应的-Z help固定工具链版本并按版本提示调整 filter 参数MIR dump 文件太多默认会为每个 pass 生成文件查看输出目录中的文件列表使用-Z dump-mir-filter只保留目标函数7.5 排错检查表先确认工具链版本LLVM、rustc nightly、cargo 是否匹配。再确认输入文件C 文件、Rust 文件、IR 文件路径是否正确。然后确认输出类型-S -emit-llvm输出 IR-O0保留未优化结构。看到 phi 之后再去llc的汇编输出里找mov验证“phi 被消解”的过程。最后回到 RFC把现象和设计动机对应起来形成自己的判断。8. 最佳实践与后续学习路径8.1 学习编译器 IR 的推荐顺序如果这份 RFC 是你接触编译器中间表示的第一站建议按下面的顺序补基础先用 LLVM IR 理解 SSA 和 phi本地生成、修改、运行上一份手工 IR。用opt跑几个标准 pass观察 IR 在优化前后的差异。看 rustc 的 MIR dump认识StorageLive、StorageDead和终止符。对比同一份 Rust 源码在-O0和-O2下的 MIR 变化。再读这份 RFC 的正文和评论把概念和工程动机串起来。自己动手改造一份小型 IR 会比读十篇理论文章更有帮助。可以试着给一个玩具 IR 加上块参数然后写一个把它们转换成 phi 的小工具理解这条迁移路线的真实复杂度。8.2 参与 RFC 讨论的正确姿势参与编译器内部 RFC 讨论时最有价值的评论是“针对具体设计点做分析”而不是泛泛评价。比如可以指出哪个 pass 遍历方式需要重写、哪个优化依赖了变量槽的语义、哪个测试容易被新旧表达切换打破。这类评论即使最终不被采纳也能帮助作者发现盲区。如果发现 RFC 中某个假设和你的项目经验不符直接给出反例代码或 IR 片段比抽象争论更有说服力。8.3 可继续研究的方向看完这份 RFC 后可以沿着几个方向继续深入研究 Cranelift 如何用 block arguments 做前端翻译和后端指令选择。对比 MLIR 的 block argument 和 region 机制看它如何在比 MIR 更复杂的结构上组织控制流。跟踪 rustc 后续对 MIR 数据流表达的实际改动对比 RFC 提案和最终实现之间的差异。用一份小型自定义 IR 实践“phi 转 block arguments”的和“block arguments 转 phi”的双向转换理解两种表达的成本边界。编译器中间表示的选择本质上是在表达力、可分析性和后端翻译成本之间做权衡。phi 和 block arguments 都是正确答案区别只在于放在哪个抽象层更合适。这份 RFC 的真正价值不在于“哪个表达更好”而在于它推动 rustc 重新审视 MIR 的数据流模型并把这种审视落到代码生成、借用检查、drop 处理和多个后端的一致性上。对想深入编译器开发的读者把这份 RFC 从头到尾读一遍、再动手做一组对照实验比停留在“理解了概念”这一层要有效得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进