上一篇(statement 翻译)讲完了 block 前半部分的翻译。这一篇拆块尾——rustc 内部叫
mir::Terminator(中文”终结符”),但真实 MIR dump 里没这个标签,就是 basic block(基本块)的最后一行,决定下一步去哪。重点拆let xxx = thread::xxx()这一行的处理:为什么它是块尾、translate_call怎么走、桩函数体怎么被绕过、GPU intrinsic(编译器内建函数)怎么直接发对应 NVVM op。
0. 几个名词先说清楚
| 缩写 / 术语 | 英文全称 | 中文 | 含义 |
|---|---|---|---|
| MIR | Mid-level Intermediate Representation | 中级中间表示 | rustc 内部 IR,介于 HIR 和 LLVM IR 之间,以 basic block + control flow graph 形式存在 |
| Terminator | Terminator(终结符) | 终结符 / 块尾 | rustc MIR 概念:每个 basic block 的最后一条”控制流指令” |
| CFG | Control Flow Graph | 控制流图 | basic block 之间通过 terminator 连接形成的有向图 |
| unwind | unwind(栈展开) | 栈展开 / 异常传播 | Rust panic 时的栈帧清理过程,类似 C++ 异常处理 |
| drop glue | drop glue(drop 胶水) | drop 胶水代码 | rustc 自动生成的析构代码,负责调用 Drop::drop 释放资源 |
| FQDN | Fully Qualified Domain Name | 完全限定路径名 | 含完整 crate / module 路径的函数名,如 cuda_device::thread::xxx |
| intrinsic | intrinsic(编译器内建) | 编译器内建函数 | 没有真正的函数体,在编译阶段被替换成特定指令的函数 |
| 桩函数 | stub function | 桩函数 / 占位函数 | 函数体写成 unreachable!() 等占位代码,实际语义由 backend 通过名字识别后替换 |
顺手提一下:“Terminator” 这个英文词在编译器语境下不常对应一个简洁中文译名,业内常直接用”终结符”或”块尾控制流”。本文混用两种说法。
1. 真实 dump 里的块尾长什么样
hello_constant 有 3 个 basic block,docs/run.log 里真实的块尾(每个 block 最后一行)是这样:
bb0: {
_2 = cuda_device::xxx() -> [return: bb1, unwind continue] ← Call
}
bb1: {
... 一堆 statement ...
_3 = cuda_device::debug::__gpu_vprintf(move _6, move _8) -> [return: bb2, unwind continue] ← Call
}
bb2: {
... 一堆 storage_dead 和 (*_1) = const 42_i32 ...
return ← Return
}
注意几个真实格式细节:
- 块尾不带分号——整个 MIR dump 都不带
;,跟 statement 一样 return: bb1带冒号——后面跟的是 BasicBlock id(label 形式)unwind continue不带冒号——continue是UnwindAction枚举值(token 形式),不是 label(*_1) = const 42_i32是 statement(在 bb2 倒数第二行),return才是 bb2 的块尾
2. 为什么 thread::xxx() 落在块尾
let xxx = thread::xxx(); 直觉上是一条赋值 statement,但 MIR 里所有函数调用都是块尾——因为 call 可以 unwind(栈展开,Rust panic 时清理栈帧的机制,类似 C++ 异常),实际有两条出边:
当前 block
│
▼
thread::xxx()
│
├── 正常返回 → 跳到 return target block
│
└── unwind → 按 UnwindAction(unwind 动作)处理
(continue / cleanup → bbN / unreachable / terminate)
两条控制流 → 必须是块尾。所以 docs/run.log 里 bb0 的 statement 部分整个是空的,唯一一行就是 Call。
UnwindAction 的值(unwind 时的动作)
UnwindAction 是 rustc MIR 里一个枚举,表示”如果这个 call panic 了,接下来怎么办”。真实 dump 里见到的值:
unwind continue ← 向上传播给调用者(最常见)
unwind unreachable ← 编译器证明不会 unwind
unwind cleanup -> bbN ← 去 bbN 跑 drop glue
unwind terminate(...) ← 直接 abort
hello_constant 这个 example 里所有 call 都是 unwind continue——简单 kernel 不需要 cleanup。
3. translate_terminator 的分发表
translate_terminator(在 crates/mir-importer/src/translator/terminator/mod.rs)就是一个大 match:
match &term.kind {
Return => translate_return(...), // 终止函数
Goto { target } => translate_goto(...), // 无条件跳转
Call { func, args, ... }=> translate_call(...), // ← thread::xxx() 走这里
SwitchInt { discr, ... }=> translate_switch(...), // if / match 编译后
Assert { cond, ... } => translate_assert(...), // 数组越界 / 整数溢出检查
Drop { place, ... } => translate_drop(...), // 显式调 drop glue
Unreachable => emit unreachable op, // unreachable!()
// ...
}
每种块尾翻译成的 dialect-mir op(对照 docs/run.log 看):
| 真实 MIR 块尾 | dialect-mir op | run.log 出现 |
|---|---|---|
return | mir.return | ✓ mir.return () [] [] |
goto -> bbN | mir.goto | ✓ mir.goto () [^block3v1] |
_N = fn(...) -> [return: bbM, unwind ...] | 看情况 | 见下 ↓ |
switchInt(_N) -> [...] | mir.cond_br / mir.switch | (本 example 没 if/match) |
drop(_N) -> [...] | mir.drop | (本 example 没需要 drop 的值) |
Call 那一行特殊——根据被调函数是不是 intrinsic 分两条路。
4. translate_call 的三步流程
Call 翻译进入 translate_call,做三件事:
Step 1: extract_func_info(func)
├── 拿到 FQDN 字符串,比如 "cuda_device::thread::xxx"
└── 拿到返回类型、泛型实例信息
Step 2: try_dispatch_intrinsic(name, ...)
├── 看 FQDN 匹配哪个 intrinsic
├── 匹配 → 发对应的 dialect-nvvm op,返回 Some
└── 不匹配 → 返回 None
Step 3: 如果是普通函数调用(Step 2 返回 None)
└── 发一条 mir.call
下面逐步拆。
5. extract_func_info:从 Operand 拿 FQDN
FQDN 全称 Fully Qualified Domain Name,本来是网络术语(域名系统里”完全限定的域名”),编译器圈借用过来表示**“包含完整 crate / module 路径的函数名”**。比如 cuda_device::thread::xxx 就是个 FQDN,光 xxx 不算。
stable MIR 的 Call 里,func 是一个 Operand,指向被调用的函数 instance(实例)。extract_func_info 从中拿出字符串形式的 FQDN:
let (pattern_name, call_name, substs_str, func_ret_ty) = extract_func_info(func);
// ↑
// pattern_name 是 FQDN 字符串,比如:
// "cuda_device::thread::xxx"
// "cuda_device::threadIdx_x"
// "cuda_device::debug::__gpu_vprintf"
// "core::ptr::write"
为什么用字符串名字而不是 DefId?
| 理由 | 解释 |
|---|---|
| 跨 crate 调用统一 | cuda_device::xxx 和 user crate 里 use cuda_device::* 引用都解析成同一 FQDN |
| 简单 | 纯字符串比较,不依赖 rustc internal DefId 体系 |
| 可读 | dispatch table 里 match name { "..." => ... } 眼看就知道在匹配什么 |
代价:要保证宏没把函数名字改掉。这就是为什么 cuda-device 里的桩函数(thread::xxx、threadIdx_x 这些)都加 #[inline(never)]——防止 rustc 把函数体 inline 掉、让 FQDN 在 MIR 里消失。
6. try_dispatch_intrinsic:巨型 match
try_dispatch_intrinsic 就是个几百 case 的大 match:
fn try_dispatch_intrinsic(ctx, body, name, args, destination, target, ...)
-> Result<Option<Ptr<Operation>>>
{
match name {
// Thread/Block Position Intrinsics
"cuda_device::xxx" | "cuda_device::thread::xxx" => {
Ok(Some(helpers::emit_nvvm_intrinsic(
ctx,
ReadPtxXXXOp::get_concrete_op_info(),
destination, target, block_ptr, prev_op,
value_map, block_map, loc,
)?))
}
"cuda_device::threadIdx_x" | "cuda_device::thread::threadIdx_x" => {
Ok(Some(helpers::emit_nvvm_intrinsic(
ctx,
ReadPtxSregTidXOp::get_concrete_op_info(),
...
)?))
}
"cuda_device::debug::__gpu_vprintf" => { /* 发 nvvm.vprintf op */ }
// ...几百个其它 intrinsic...
"core::ptr::write" => translate_core_ptr_write(...),
"core::intrinsics::transmute" => ...,
_ => Ok(None), // 不是 intrinsic,让 translate_call 走普通调用路径
}
}
对 hello_constant,run.log 里命中的两个 intrinsic:
| FQDN | 匹配到的 dialect-nvvm op |
|---|---|
cuda_device::xxx | nvvm.read_ptx_xxx |
cuda_device::debug::__gpu_vprintf | nvvm.vprintf |
而 core::ptr::write 这类 statement 内调用,在 statement 翻译时就处理掉了(*out = 42 直接拆成 constant + load + store,根本没走 call dispatch)。
7. emit_nvvm_intrinsic:三件事
匹配命中后,emit_nvvm_intrinsic 这个 helper 做三件事:
fn emit_nvvm_intrinsic(ctx, op_info, destination, target, ...) -> Result<Ptr<Operation>> {
// ① 创建 NVVM op
let nvvm_op = Operation::new(
ctx,
op_info, // ReadPtxXXXOp 的 metadata
vec![result_ty], // 返回类型 (u32)
vec![], // 无操作数
vec![], // 无 region
0,
);
nvvm_op.insert_after(ctx, prev_op);
// ② 把 NVVM op 的结果 SSA value 绑到 destination local(_2)
let result_value = nvvm_op.get_result(0);
value_map.store_local(ctx, destination.local, result_value, ...);
// ③ 发一条 branch 跳到 return target(bb1)
let target_block = block_map[target.unwrap_or(0)];
let br_op = mir::GotoOp::new(ctx, target_block);
br_op.insert_after(ctx, nvvm_op);
Ok(br_op)
}
最关键的观察:
桩函数体根本没翻译。
cuda_device::xxx这个函数的 MIR body 在 rustc 看来就是unreachable!("xxx called outside CUDA kernel context"),但 dispatcher 直接绕过它,把整个 call 替换成nvvm.read_ptx_xxxop。translator 不会再去翻译那个unreachable!函数体。
block_map 是另一个关键概念:MIR 里 bb1 是个编号,pliron 里 BasicBlock 是个指针。block_map 把编号映射成指针,这样发 branch 时才能写出”跳到 ^block3v1”。
8. 一次 Call 块尾翻译完的真实结果(对照 run.log)
原 MIR:
bb0: {
_2 = cuda_device::xxx() -> [return: bb1, unwind continue]
}
翻译后(docs/run.log 第 142-145 行):
^block2v1(v0: mir.ptr<si32, ...>):
... 10 条 mir.alloca ...
mir.store (v1, v0) ← entry 把参数 copy 进 _1 槽
v11 = nvvm.read_ptx_xxx () [...] ← 桩函数被替换成 NVVM op
mir.store (v2, v11) ← 写到 _2 槽(就是 destination)
mir.goto () [^block3v1] [...] ← 跳到 bb1
一次 Call 翻译完产生三条 op:
| op | 角色 |
|---|---|
v11 = nvvm.read_ptx_xxx () | 算值的 op |
mir.store (v2, v11) | 写到 destination 栈槽 |
mir.goto () [^block3v1] | 跳到 return target block |
中间那条 mir.store 是因为 alloca-load-store 模型(见 上一篇)——_2 是栈槽,赋值必须 store。mem2reg 跑完后这条 store 会消掉,留下 v11 直接被下游使用。
9. __gpu_vprintf 那条:Call 不止 intrinsic
bb1 的块尾对应 __gpu_vprintf 调用,run.log 第 192-194 行:
v37 = nvvm.vprintf (v35, v36) [...] ← 调 vprintf,带两个参数
mir.store (v3, v37) ← 写 _3 槽(printf 返回值)
mir.goto () [^block4v1] [...] ← 跳到 bb2
同样的模式,只是这次 NVVM op 带操作数(v35, v36 是 printf 的格式字符串指针和 args 指针)。__gpu_vprintf 也是 intrinsic,但本质上是个”包装 device runtime call”——dispatcher 把它翻译成 nvvm.vprintf op,最终会 lower 成调 vprintf 这个 device 端函数(下一篇看 mir-lower 时讲)。
10. 为什么 intrinsic dispatch 放在块尾翻译这一步
可以争论:为什么不让用户调 thread::xxx() 时只发 mir.call cuda_device::thread::xxx,等后面 mir-lower 阶段去识别?
三个理由:
| 理由 | 说明 |
|---|---|
| 早识别 = 早消除桩函数 | 如果让 mir.call 进 dialect-mir,importer 还得翻译那个 unreachable!() 函数体。早识别 = 整个调用链就此终止 |
| 类型信息更全 | 在 terminator 阶段,参数类型、返回类型、target block 都齐全。等到 mir-lower 阶段才识别,要么自己解析这些,要么把上下文也带进 op |
| dispatch 集中 | 所有 intrinsic 在一个 match 里看得清清楚楚,方便管理。新增 intrinsic 只动这一处 |
代价:try_dispatch_intrinsic 这个函数巨长(几百个 case),但这是接受的复杂度——真正的复杂分散到了每个 emit helper 里,match 本身只是表。
11. 一句话总结
MIR 里所有函数调用都落在块尾(因为可以 unwind),真实 dump 里写作
_N = fn(...) -> [return: bbM, unwind continue]。translate_call 先用 extract_func_info 拿到 FQDN 字符串,交给 try_dispatch_intrinsic 巨型 match——匹配到 GPU intrinsic 就发对应的 dialect-nvvm op,加一条 mir.goto 跳到 return target,桩函数体整个跳过;没匹配到就走普通 mir.call。hello_constant在 run.log 里实际命中两个 intrinsic:cuda_device::xxx→nvvm.read_ptx_xxx,__gpu_vprintf→nvvm.vprintf。
系列上一篇: cuda-oxide:hello-constant 拆解 06——MIR → dialect-mir 翻译(statement 层)