cuda-oxide:hello-constant 拆解 07——块尾控制流 + intrinsic dispatch

拆 hello-constant 系列第七站。每个 basic block(基本块)末尾的控制流(rustc 内部叫 Terminator,终结符)是 translator 最复杂的一段。本文按 docs/run.log 真实 dump,讲清楚函数调用为什么必须落在块尾、translate_call 的三步流程、try_dispatch_intrinsic 怎么靠 FQDN(完全限定路径名)字符串识别 GPU intrinsic(编译器内建函数)、以及桩函数体为什么根本没翻译。

📚 系列 cuda-oxide · 第 12 篇

上一篇(statement 翻译)讲完了 block 前半部分的翻译。这一篇拆块尾——rustc 内部叫 mir::Terminator(中文”终结符”),但真实 MIR dump 里没这个标签,就是 basic block(基本块)的最后一行,决定下一步去哪。重点拆 let xxx = thread::xxx() 这一行的处理:为什么它是块尾、translate_call 怎么走、桩函数体怎么被绕过、GPU intrinsic(编译器内建函数)怎么直接发对应 NVVM op。

0. 几个名词先说清楚

缩写 / 术语英文全称中文含义
MIRMid-level Intermediate Representation中级中间表示rustc 内部 IR,介于 HIR 和 LLVM IR 之间,以 basic block + control flow graph 形式存在
TerminatorTerminator(终结符)终结符 / 块尾rustc MIR 概念:每个 basic block 的最后一条”控制流指令”
CFGControl Flow Graph控制流图basic block 之间通过 terminator 连接形成的有向图
unwindunwind(栈展开)栈展开 / 异常传播Rust panic 时的栈帧清理过程,类似 C++ 异常处理
drop gluedrop glue(drop 胶水)drop 胶水代码rustc 自动生成的析构代码,负责调用 Drop::drop 释放资源
FQDNFully Qualified Domain Name完全限定路径名含完整 crate / module 路径的函数名,如 cuda_device::thread::xxx
intrinsicintrinsic(编译器内建)编译器内建函数没有真正的函数体,在编译阶段被替换成特定指令的函数
桩函数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 不带冒号——continueUnwindAction 枚举值(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 oprun.log 出现
returnmir.returnmir.return () [] []
goto -> bbNmir.gotomir.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::xxxthreadIdx_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::xxxnvvm.read_ptx_xxx
cuda_device::debug::__gpu_vprintfnvvm.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_xxx op。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::xxxnvvm.read_ptx_xxx,__gpu_vprintfnvvm.vprintf

系列上一篇: cuda-oxide:hello-constant 拆解 06——MIR → dialect-mir 翻译(statement 层)

评论区
评论功能即将上线, 敬请期待。