最近在阅读 LLVM Code Generation 相关的 RFC 时,有一个标题特别吸引我:Change MIR to use block arguments instead of phis。作为一个经常和 MIR、SelectionDAG 打交道的后端开发者,我对这种“表面上是语法改动,实际会牵动整个代码生成流程”的提案非常敏感。很多人看到 phi 字眼,第一反应是 LLVM IR 里的 SSA 汇合节点。但这份 RFC 讨论的不是上层 IR,而是 Machine IR,也就是机器指令层。这个位置的每一处变化,都可能影响寄存器分配、指令调度、调试输出和所有后端 pass。
我的核心判断是:这个 RFC 真正触及的问题,不是 phi 和 block arguments 哪个更“现代”,而是编译器中间表示里“控制流边携带数据”的能力该放在哪里。放在基本块入口就是 phi,放在边上就是 block arguments。MIR 里如果真换成 block arguments,带来的不只是语法变化,而是数据流信息的归属权变化,进而影响后端的复杂度和优化空间。但这个变化能不能落地,取决于 LLVM 社区愿不愿意承受迁移成本。
1. 先看清楚:MIR 里的 phi 到底是什么,block arguments 又是什么
1.1 一个最常见的 if-else 合并场景
为了不陷入概念讨论,先从最典型的控制流合并说起。假设我们有这样一段 C 代码:
int sign(int x) { if (x > 0) return 1; else return -1; }不管用哪个编译器,在 SSA 形式的中间表示里,返回值都需要汇合到一个虚拟值。LLVM IR 通常是这样:
define i32 @sign(i32 %x) { entry: %cmp = icmp sgt i32 %x, 0 br i1 %cmp, label %if.then, label %if.else if.then: br label %merge if.else: br label %merge merge: %ret = phi i32 [ 1, %if.then ], [ -1, %if.else ] ret i32 %ret }这里%ret是 phi 节点,它根据从哪个块进入merge来选择一个值。当 LLVM IR 被降低到 MIR 时,这种 phi 会被转换到机器指令层。用llc打印 MIR,可能会看到类似这样的指令:
%bb.2: %0:gpr = PHI %1:gpr, %bb.0, %2:gpr, %bb.1PHI看起来是一条指令,但它不是真正的机器指令,而是一个占位标记。它告诉后端的各个 pass:在这里,虚拟寄存器的定义取决于其前驱块。
1.2 block arguments 的另一种存在方式
block arguments 的想法是:不给基本块一个“基于前驱来取值”的 phi 节点,而是让基本块自己拥有参数。每一个跳转到该基本块的边,在跳转时把值作为参数传进去。
还是上面那个例子,如果用 block arguments 风格的 MLIR 来表示,大概是:
func.func @sign(%x: i32) -> i32 { %cmp = arith.cmpi sgt, %x, %c0 : i32 cond_br %cmp, ^bb1, ^bb2 ^bb1: %1 = arith.constant 1 : i32 br ^merge(%1 : i32) ^bb2: %2 = arith.constant -1 : i32 br ^merge(%2 : i32) ^merge(%ret: i32): return %ret : i32 }注意^merge块有一个参数%ret,前驱块在跳转边br ^merge(%1)和br ^merge(%2)上传入实际值。这里没有 phi。所有从边上传入的参数在块入口处就已经是“确定”的值了。
MLIR 把这种设计称为 Block Arguments,而 SSA 汇合数据直接在 CFG 边上传递。相比之下,LLVM IR 和 MIR 传统的 phi 是“记录在前驱块的汇合点处,等到每个前驱块的信息都齐了再挑选”。
1.3 为什么 block arguments 不是新鲜事物
如果你了解 MLIR、Swift 的 SIL、或者一些调试信息格式,会发现 block arguments 在编译器领域并不是什么颠覆性发明。MLIR 的 region 和 block 天然支持 block arguments;Swift 的 SIL 也使用类似的表示。LLVM 的上层 IR 没有采用,更多是因为历史包袱和发展路径。而这份 RFC 要把 block arguments 引入到更底层的 MIR,思路本身顺理成章,但工程影响面极宽。
我们需要区分:LLVM IR 的 phi 和 MIR 的 phi 用途层面其实略有不同。LLVM IR 的 phi 是高层 SSA 汇合,它在上层优化中非常有用;而 MIR 的 phi 是代码生成过程中的一种过渡标记,它更接近“copy 指令待展开”的抽象。MIR 里如果换成 block arguments,本质上是把“哪些值需要复制到目标块”直接摆到控制流边上。
2. phi 在代码生成阶段为什么会变成麻烦
2.1 物理机器不支持 phi,必须降级成 copy
理解这个 RFC 的关键,在于认识到 phi 不是真实指令。任何目标处理器都没有“根据前驱块选择虚拟寄存器”的硬件指令。当 MIR 做完指令选择、寄存器分配之后,需要把 phi 展开成普通的 copy 指令。这个展开过程叫 phi elimination 或 phi lowering。
展开的思路很直接:在每条进入 phi 汇合块的边,插入一条 move,把源寄存器 copy 到 phi 指定的目标寄存器。听起来简单,但真实实现要考虑的细节非常多。比如在目标机器上,数据可能是通过寄存器传递,但也可能要在栈上传递;多个 phi 节点同时出现时,可能会形成循环依赖或并行 copy 的冲突。
举个例子:
%bb.1: ... br %bb.3 %bb.2: ... br %bb.3 %bb.3: %a = phi [ %r1, %bb.1 ], [ %r2, %bb.2 ] %b = phi [ %r2, %bb.1 ], [ %r1, %bb.2 ]如果直接在%bb.1尾部插入 copy,把%r1和%r2分别赋给%a和%b,当物理寄存器重叠时,需要生成临时寄存器解决循环交换问题。这就是经典 ParallelCopy 问题。处理不好,寄存器分配会冲突,代码生成会出错。
2.2 关键边、布局和边界问题让 phi lowering 雪上加霜
除了并行 copy,还有一个经典问题叫 “critical edge”。当一条边从有多个后继的块指向有多个前驱的块时,这条边是 critical edge。在 critical edge 上插入 phi copy 会破坏控制流结构,通常需要先去“分裂”这条边,增加一个中间的基本块。这在线性布局、分支编址、机器优化中会引入额外的跳转开销。即使有优化,也会让后端 pass 的代码复杂度暴增。
更重要的是,MIR 里每个 pass 都会看到 phi。寄存器分配器要追踪 phi 值;MachineLICM、MachineCSE、MachineSink 等优化 pass 都要处理这种特殊的“非真实指令”;即使只是想直观查看 CFG 图,也要把 phi 单独画出来。代码生成阶段本身就已经很复杂,phi 的存在让“数据流合并”这件事被拆成了“指令选择时抽象描述”和“寄存器分配前具体展开”两个阶段,中间隔着一整条流水线。
2.3 调试 MIR dump 时的真实痛点
我不知道别人怎么想,但我在调试后端问题时,看到 PHI 的第一反应是先在脑子里把它翻译成“等会儿要变成若干 copy”。这个翻译动作既消耗注意力,又容易出错。尤其当 CFG 图比较大,虚拟寄存器编号混乱的时候,一条 phi 背后藏着哪些前驱块、哪些活跃值、哪些物理寄存器映射,全靠脑补。
更麻烦的是,MIR dump 是 pass 之间的快照。如果某个 pass 把 phi 信息改坏了,你要翻好几层 dump 才能定位是哪一步把数据流理清了。如果我们换成 block arguments,控制流边的br ^merge(%r1)本身就表达了“这条边把%r1交给下一个块”,从 dump 上直接能看出数据从哪来、到哪去,不需要再去搜 phi 的前驱列表。
3. 换成 block arguments 后,LLVM 后端会得到什么,又失去什么
3.1 数据沿边传递,更接近机器跳转语义
block arguments 最直观的优势,是让数据流与控制流边绑定。机器层面,一个条件跳转指令brcc或brcond本身就决定了下一条执行路径。如果 MIR 允许 branch 指令携带参数,就可以把这组参数清晰地挂在边上面。当 phi lowering 需要展开 copy 时,不再是“找所有入边”,而是“读 branch 自带的参数列表”。
这也让寄存器分配的前瞻更友好。后端 pass 可以知道,某条边进入后继块时,哪些虚拟寄存器是活跃的、需要保持的。如果有 block argument,等于把“接口”显式暴露给了跳转。不做 phi lowering,也许仍然需要把参数 copy 到约定寄存器,但至少不会出现 phi 这种“幽灵指令”在 pass 流程中悬空。
3.2 潜在收益:可能移除一整段 phi 处理逻辑
如果 MIR 全面改成 block arguments,原先的 phi lowering pass 就可以删除或大幅简化。所有需要处理 phi 的 pass,只需要理解“基本块参数列表”和“branch 参数列表”这两种普通概念。它们和函数调用的参数传递有很强的相似性,这意味着很多处理函数调用参数传递的机制可以复用。对新的后端开发者和 IR 学习者来说,概念负担会小很多。
同时,调试输出会更接近人类阅读习惯。CFG dump 中的每个块入口列出参数,每条边列出实际参数。我在脑内模拟的时候,觉得这比从 phi 反推前驱要直觉得多。
3.3 代价:存量 pass 的大面积重写和测试更新
但这只是看起来很美。LLVM 发展到现在,MIR 层已经有大量 pass 和工具对 phi 做了深度适配。比如:
PHIElimination这个 pass 要重写或删除。RegisterCoalescer需要理解 block arguments 的区间。MachineBasicBlock类的数据结构要新增“入口参数”和“branch 参数”。- 所有遍历 MIR 的 utility,比如
MachineInstr扫描器、MachineOperand类型、无证 pass,都要跟着改。 - 测试里的 MIR CHECK 行全部要更新。
llvm-mca、llc、opt等工具的 dump 格式改变,影响范围远不止编译器的核心 library。
LLVM 项目有一个特点:越是底层的表示,改动影响面越大。MIR 的每个 pass 都可能读取、分析、修改这些 phi。如果贸然切换,相当于把一大片设施从地基重建。而且 LLVM 同时支持很多后端,不同后端的 MIR 状态也可能不同。一个统一的 block arguments 表示,未必能照顾到所有后端的特殊需求。
3.4 一个中肯的判断:收益在长乐,成本在当下
我觉得,这个 RFC 如果只是作为“未来路线图”,很有价值。但如果有人指望立刻在 LLVM 主干上切换,可能低估了迁移成本。block arguments 更适合在新的、还没有历史包袱的 IR 上推行。把现有 MIR 迁移过去,需要非常长的铺垫期和大量的自动化迁移工具。对很多应用方来说,短期内更现实的行为是:先把实验性实现放在一个分支上,用几个典型后端跑 benchmark,量化 phi lowering 在整个编译时间中的占比,然后才有说服力。
注意:不要为了“更现代”的设计而草率替换一个成熟表示。编译器 IR 的每一项结构性变更,都要拿数据说话,而不是拿审美说话。
4. 从 RFC 引发的工程思考:评估 IR 表示变更的几个维度
4.1 维度一:表示是否自然贴合目标机器模型
评价一个 IR 设计首先看它是不是服务于你的目标。LLVM IR 的 phi 更适合上层优化,因为上层关心值在汇合点如何使用,不关心值从哪个物理通道到达。MIR 则更贴近目标机器,机器指令本身就带有明确的读写操作数关系。从这个角度看,block arguments 把“数据到达”放在控制流边上,其实更贴近机器跳转时的寄存器移动动作。
但这并不代表所有后端都受益。有些后端的调用约定复杂,返回值通过多个寄存器或栈位置传递,block arguments 需要能够表达“一个逻辑参数映射到多个物理位置”的细节。如果只是简单地把 phi 换成块参数,实际上只是换了个名字,没有解决物理传递的问题。
4.2 维度二:转换成本和回退风险
任何 IR 变更都要考虑“怎么从旧的表示转到新的”,以及“转换失败后怎么回退”。phi 到 block arguments 的转换不是不可逆,但要做无损转换和验证。比如要保证每个前驱块的传递值和原 phi 的语义一致;要处理没有前驱块的情况(比如入口块);要处理 phi 中有 undef 的情况;要处理有多个 phi 节点但只有其中一个有值时的情况。
在实际迁移中,最危险的是“看起来等价但边界条件不匹配”。比如 LLVM IR 中的undef和poison在 MIR 层面如何处理?block arguments 可以携带这些特殊值吗?这些问题如果没有明确答案,回退成本会很高。
4.3 维度三:pass 生态的可迁移性
LLVM 的优化 pass 不会一夜之间全部重写。迁移需要时间窗口,在这个窗口期内,旧的 phi 和新的 block arguments 可能同时存在。这意味着 pass 作者要写两套逻辑,或者写一层“兼容跳转”。这是工程里最烦人的过渡状态。需要考虑是否提供自动迁移 pass,是否允许两种表示并存,并通过统一接口访问控制流数据。如果这些事先设计不好,就会演化成又一个“CC 与 C++”式长期并存的缝缝补补。
4.4 维度四:验证与调试工具的同步更新
我在使用 LLVM 时最依赖的是-print-after-all,每个 pass 之后的 MIR dump 能帮我定位问题。如果表示切换了,dump 格式会变化,相关的 LLVM 可视化工具、Graphviz 脚本、静态分析工具、以及构建系统里的测试基线都要跟着更新。同步更新工具链,有时比改核心 pass 更花时间。
可以对照下面这个简单的评估表,来审视一个 IR 变更:
| 维度 | 问题示例 | 影响程度 |
|---|---|---|
| 机器模型契合度 | 是否能让后端在跳转时直接表达数据传递 | 中高 |
| 转换成本 | 现有 IR 到新表示的转换是否无损 | 高 |
| Pass 生态 | 所有 pass 的迁移和兼容期工作量 | 极高 |
| 验证工具 | dump、验证、测试是否同步更新 | 高 |
| 长期维护 | 新开发者是否更容易理解 | 中 |
这个表也适用于其他 IR 设计讨论。先想清楚受影响范围,再讨论“哪个更好”,顺序不能反。
5. 如果这个 RFC 进入实验阶段,普通 LLVM 用户应该怎么跟进
5.1 先判断你是哪一类使用者
很多读者可能用 LLVM 只是做交叉编译,或者调用llc把 LLVM IR 变成目标汇编。如果你是这类使用者,就算 RFC 合并了,你的日常影响也主要是 MIR dump 格式变化和编译输出差异。LLVM IR 层面不变,你甚至可能感知不到。
如果你是写后端 pass 的,那么 block arguments 会影响你的每个MachineBasicBlock迭代逻辑。你需要关注MachineBasicBlock::arguments()这类新接口。如果你是编译器教学或者自己设计 IR,那么这个 RFC 倒是给你提供了一个很好的参考案例:在低层 IR 中使用 block arguments 是否更易讲解和实现。
5.2 用最小实验验证核心思路
在没有官方代码分支之前,想验证 block arguments 和 phi 的行为差异,有几个现实路径。最简单的是在 MLIR 里做一个低层 IR 实验,因为 MLIR 天然支持 block arguments,可以模拟“机器层”指令。另一个路径是 fork LLVM 源码,在 MIR 中增加块参数,但只在一个自定义后端上跑通一个简单函数。如果需要更进一步,可以写一个可选的 lowering pass:把 MIR 里的 phi 先转换成 block arguments 再继续后续流程,但前提是你对 LLVM 内部结构非常熟悉。
类似实验至少能验证两个问题:第一,block arguments 是否能避免 critical edge 分裂;第二,寄存器分配器是否能更自然地处理控制流边上的参数。不要一开始就追求完整的上游实现,先看看收益是不是真的存在。
5.3 使用现有工具时,如何排查与 phi 相关的底层问题
如果你的工作流仍然在传统 MIR 上,我觉得可以养成几个习惯。
- 先开启
-print-after=phi-elimination看 phi 展开后的 copy 插入位置。 - 遇到寄存器分配冲突,优先检查是否存在 critical edge,以及 phi copy 是否被错误地放在某个前驱块。
- 使用
-verify-machineinstrs验证 MIR 合法性,很多 phi 相关的边界问题会直接报出来。 - 如果怀疑是上游 LLVM 版本变更导致的,优先用最新稳定版和最新主干做对照。
经验之谈:大部分 phi 相关 bug 都不是 phi 本身写错了,而是 phi 展开后的 copy 顺序、位置或活跃范围处理错了。所以排查时,不要只盯着 phi 指令,要把前后几条边和前驱块的终止指令一起看。
5.4 Windows 工具链使用者的视角
顺带提一句,很多 LLVM 工具链使用者是从 Windows 上下载预编译包或免安装版来跑的。这种模式下,你看到的 MIR dump 格式往往取决于工具链版本。如果将来某一天官方把 MIR 改成 block arguments,Windows 预编译工具链的更新会略滞后于源码版本。如果你依赖llc -print-after-all做某些测试,最好固定一个稳定版本,同时在升级前先查看 release notes 里的 MIR 变化说明。对普通用户来说,这个 RFC 的可见影响没有想象中那么大,但如果你自动解析 MIR dump 做检查,就要提前关注格式兼容。
6. 到底该不该把 phi 全部换掉:我的判断
回到题目本身。Change MIR to use block arguments instead of phis 这个 RFC 背后,其实是编译器中间表示设计中的一次“常规争议”。Phi 在 SSA 理论中优雅且经典,是编译器教科书的必修内容。Block arguments 在 MLIR 等现代框架中被证明对“多级 IR”更友好,因为它能把数据流信息附着在 CFG 边上,形成更一致的低级表示。
在我看来,如果 LLVM 是一个从零开始的项目,我一定会选择 block arguments 而不是 phi 作为 MIR 的基本数据流汇合方式。原因很简单:低层 IR 需要表达跳转时的数据传递,block arguments 直接还原了这种机器语义。但 LLVM 不是从零开始,它已经拥有庞大的 phi 历史和成熟的代码生成链路。在这种情况下,把 MIR 从 phi 改成 block arguments,更像是一次“风险极高的重构”,需要社区投入大量精力,还要兼容数以万计的测试。
所以我的建议是:支持这个 RFC 作为探索方向,但不要急着全面铺开。可以先用一两个后端做概念验证,量化它对编译时间、寄存器分配质量、调试体验的真实影响。如果收益足够明显,再制定一个“先引入新表示,再通过迁移工具逐步淘汰 phi”的路线。更现实的目标不应该是“替换”,而应该是“并存-迁移-淘汰”的三阶段演进。
最终,编译器 IR 的选择从来不是美学问题。Phi 和 block arguments 都只是工具,关键在于你的优化流水线、目标机器模型和团队维护能力更适合哪一种。一个在纸面上更清晰的设计,如果迁移成本压得你动弹不得,那它就不算好选择。反过来,一个看似老旧的表示,如果能稳定运行十几年,也有它不可替代的理由。
Block arguments 在低层 IR 中的价值,我认为是真实存在的。但它能否改变 LLVM MIR,取决于 LLVM 社区能否拿出一份可以落地的迁移计划,而不只是一个漂亮的标题。作为后端开发者,我更希望看到的是:有人先把它跑起来,拿数据说话,再谈“取代”。