上一次(mem2reg:从 alloca-load-store 回到 SSA)讲了 mem2reg 是什么、怎么做、在 cuda-oxide 里跑在哪。这一篇换个角度,从初学者视角讲’为什么’:为什么前端要生成那一堆笨重的 alloca?mem2reg 为什么能消掉它们而不出错?为什么有时候它不能消?这一切都绕回一个核心:编译器必须能看得见这块内存的所有读写。
0. 几个名词先说清楚
| 缩写 / 术语 | 英文全称 | 中文 | 含义 |
|---|---|---|---|
| SSA | Static Single Assignment | 静态单赋值 | 每个值只被赋值一次,使用点能直接追溯到唯一定义点 |
| alloca | allocate(在栈上分配) | 栈分配 | LLVM IR 指令,在函数栈帧上开一块空间,返回这块空间的指针 |
| promote | promote(提升) | 提升 | 把”内存里的变量”提升成”SSA 寄存器值”,省掉 load/store |
| promotable | promotable | 可提升的 | 这个 alloca 满足 mem2reg 的安全前提,可以消掉 |
| 地址逃逸 | address escape | 地址逃逸 | alloca 的指针流到 load/store 之外的地方,编译器追踪不到 |
| φ(phi)函数 | phi node | 汇合节点 | SSA 形式里,在控制流汇合点表达”值从哪条路来”的特殊指令 |
| 别名 | aliasing | 别名 | 同一块内存有多个指针指向它,通过任一指针都能读写 |
1. 前端为什么先生成一堆 alloca
你写的局部变量,编译器前端一开始通常不会直接当成寄存器/SSA 值处理,而是老老实实给每个变量在栈上开一块空间。比如:
int x = 1;
x = x + 2;
return x;
前端生成的 IR 大概长这样(伪代码):
slot = alloca i32 ; 给 x 在栈上开一格
store slot, 1 ; x = 1
t0 = load slot ; 读 x
t1 = add t0, 2 ; x + 2
store slot, t1 ; x = ...
t2 = load slot ; 读 x
return t2
每次用到 x 都要 load,每次赋值都要 store。
为什么前端要这么笨? 因为这样生成代码简单、统一——不管变量被赋值几次、在多复杂的控制流里,“内存里的一格 + load/store”总是对的。前端不用操心 SSA 那套规则(尤其是跨 block 时的 φ 节点放置)。
代价是:这些 load / store 全是内存访问,慢,而且后续很多优化(常量传播、死代码消除、寄存器分配)在内存上很难做。
2. mem2reg 做的事:把内存变量提升成 SSA 值
mem2reg = “memory to register”,把上面那种只在栈格里待着的变量,提升成 SSA 寄存器值。优化后:
; slot 消失了
t1 = add 1, 2 ; 直接用上一次写进去的值 1
return t1
注意发生了什么:
| 步 | 推理 |
|---|---|
store slot, 1 之后 | 任何 load slot 应该读到 1 → 把 load 直接换成 1 |
store slot, t1 之后 | load slot 应该读到 t1 → 换成 t1 |
| 所有 store 删掉 | 没人再读这块内存了 |
整个 slot 没人用 | 把 alloca 也删掉 |
核心规则一句话:
load 替换成最近一次 store 的值,store 删掉,alloca 消掉。
为什么这是巨大的收益
一旦变量变成 SSA 值(每个值只被赋值一次、有清晰的定义点),后面一大票优化突然都变得容易:
- 常量传播:
t1 = add 1, 2可以直接折成t1 = 3 - 死代码消除:用不到的计算可以删
- 寄存器分配:更高效,因为不用考虑别名内存
- 公共子表达式消除:能识别”算过的算式”
mem2reg 几乎是所有现代编译器最关键的一个 pass,因为它把”难优化的内存形式”变成了”好优化的 SSA 形式”。LLVM、Cranelift、pliron 全都靠它。
3. 控制流一复杂,就需要 φ 节点(顺带一提)
上面是直线代码。真实情况经常有分支:
int x;
if (cond) x = 1;
else x = 2;
return x; // 这里 x 是 1 还是 2?取决于走了哪个分支
提升成 SSA 时,最后那个 load x 该换成哪个值?编译器没法静态确定走哪条分支,于是引入 φ(phi)节点:
bb_then: ... ; x 这条路是 1
bb_else: ... ; x 这条路是 2
bb_merge:
x_final = phi [1, bb_then], [2, bb_else] ; 看从哪来,选对应的值
return x_final
φ 节点的意思是”根据控制流从哪个前驱块过来,取对应的值”。mem2reg 的完整版就是负责在正确的汇合点插入这些 φ 节点。
初学阶段你只要知道 φ 节点是干嘛的就行——把”跨 block 的值汇合”显式化。精确算法(怎么算”正确的汇合点”)是进阶内容,涉及一个叫支配边界(dominance frontier)的概念,这里跳过。
4. 重点:为什么”地址逃逸”就不能 promote
这才是 mem2reg 真正的精髓。
promote 的前提是:编译器能 100% 掌握这块内存的所有读写。它把 load 换成”最近一次 store 的值”,这个推理只有在”我看得见所有对这块内存的写”时才成立。
一旦 alloca 的”地址”跑到了编译器追踪不了的地方,这个前提就崩了。看几个真实场景。
4.1 情况一:地址传给别的函数
slot = alloca i32
store slot, 1
call mystery(slot) ; 把地址交出去了!
t = load slot ; 现在这里是 1 吗?不知道!
mystery 函数可能往 slot 里写了新值。编译器看不进 mystery(或者看得进但分析太贵),所以它不敢断定 load slot 还是 1。
这块内存可能被偷偷改了,“最近一次 store 是 1”的推理失效。
→ 不能 promote。
对应到 Rust 里就是:
let mut x = 0;
some_function(&mut x); // 借用了 x 的可变引用,函数里可能改 x
let y = x; // y 现在是几?编译器不知道
只要 &mut x 跑了出去,x 这个 alloca 就不能 promote。
4.2 情况二:地址被 cast 成别的类型(别名)
slot = alloca i32
p = cast slot to i8* ; 当成字节指针
store_byte p, 0xFF ; 通过 p 改了其中一个字节
t = load slot ; 这个 i32 现在是多少?
通过别名(同一块内存的另一个指针 p)发生了写入。编译器追踪的是”对 slot 的 load/store”,但这次写是通过 p 发生的——它的 SSA 值替换逻辑根本没把这次写算进去。
如果还把 load slot 换成之前的值,就会算错。
→ 不能 promote。
4.3 情况三:地址被存进另一个指针
slot = alloca i32
store some_ptr, slot ; 把 slot 的地址存到了别处
地址被存出去之后,程序后面可能从 some_ptr 把这个地址取回来,再通过它读写 slot。编译器同样追踪不了这些间接访问。
→ 不能 promote。
5. 核心:一句话讲清”地址逃逸”
promote 安全的唯一条件是:这个 alloca 的指针只被 load 和 store 直接消费,从没以”地址值”的身份流到别处。
只有这样,编译器才能保证:它看到的那些 load/store 就是这块内存的全部访问,没有任何”暗箱操作”。
一旦地址逃逸,就可能存在编译器视野之外的读写,“用最近一次 store 替换 load”这个核心推理不再可靠,promote 就会产生错误的程序——这比”没优化”严重得多。
更精确地说:不是”改成什么”的问题,而是”会不会被改、被谁改”编译器都无法保证,于是只能保守地放弃 promote,让这个变量老实待在内存里(后续靠别的、更弱但更安全的优化处理)。
6. 在 GPU kernel 上代价特别大
为什么 GPU kernel 开发者要特别小心地址逃逸?
如果一个 alloca 不能被 promote,后续 lower 到 PTX 时它会落进 PTX 的 .local 状态空间——名字叫”本地”,但物理上在 GPU 外置 DRAM 上,访问延迟比寄存器慢几十倍。
所以 CUDA / Rust GPU 风格代码经常强调:
- 能用值传递就不取地址
- 能内联的辅助函数就 inline
- 避免
&mut local_var这种模式
这些不只是风格问题,本质上是为了让 mem2reg 跑成功——一旦地址逃逸,kernel 性能会显著下降。
7. 一句话总结
mem2reg 把”栈上变量 + load/store”提升成”SSA 寄存器值”,是打开后续一切优化的钥匙;它能这么做,靠的是”我看得见这块内存的所有读写”这个保证;而”地址逃逸”恰恰打破了这个保证——指针传给函数、cast 成别的类型、存进别的指针,任何一种情况都让 mem2reg 无法保证安全,只能保守地放弃提升。所以”不取栈变量地址”不只是代码风格,而是直接决定后端能不能把这个变量留在寄存器里。
相关文章: mem2reg:从 alloca-load-store 回到 SSA(讲算法骨架 + cuda-oxide 里怎么用)
系列上一篇: 从 Rust 写 inline PTX 模板