rustc_public——Stable MIR#
cuda-oxide 不会发明自己的 Rust 解析器或类型系统。它搭载在真正的 Rust 编译器之上,拦截 rustc 在类型检查和单态化之后产生的内部表示,并将其编译为 PTX。本章解释了 cuda-oxide 读取的中间表示(MIR)、它用来读取的稳定性层(rustc_public),以及连接这两个世界的桥接模式。
什么是 MIR?#
在 rustc 解析你的源代码、解析名称、检查类型,并消除所有语法糖(闭包、for 循环、? 运算符)之后,它产生 MIR——中间表示(Mid-level Intermediate Representation)。MIR 是你程序的简化、面向控制流的形式,看起来比抽象语法树更接近机器实际执行的内容。
繁重的工作在 MIR 中发生:
借用检查——Rust 的所有权规则是针对 MIR 验证的,而不是针对你的源代码。
优化——常量传播、复制传播、死存储消除和内联都在 MIR 上操作。
单态化——泛型函数为每种类型参数组合生成具体版本。
在正常的 Rust 编译中,MIR 被降级为 LLVM IR(或者如果你使用 cranelift 后端则为 Cranelift IR),再从那里变成机器码。但如果能在 MIR 到达 LLVM 之前拦截它,并用它做其他事情——比如将其编译为 GPU 的 PTX,会怎样呢?
这就是 cuda-oxide 所做的事情。
稳定性问题#
这里有一个问题。MIR 是 rustc 的内部中间表示。它从未被设计为公共 API。类型会被重命名,枚举变体会被重新排序,字段会出现又消失——这一切都发生在连续的 nightly 版本之间,有时甚至在连续的提交之间。编译器团队没有义务保持其中任何部分的稳定,他们也不这样做。
如果 cuda-oxide 直接消费 rustc_middle 类型,每次 nightly 更新都将是一场打地鼠游戏:某些东西变了,某些东西坏了,有人花了一个周末修补编译错误而不是编写 GPU 代码。
这就是 rustc_public 发挥作用的地方。
什么是 rustc_public?#
rustc_public(以前称为 stable_mir)是 Rust 编译器内部的一个稳定接口。它允许工具开发者——验证器、lint 工具、像 cuda-oxide 这样的 codegen 后端——在不必随着编译器内部管道的变化而每次都崩溃的情况下进行分析和代码生成。
其实现存在于 rustc 仓库内的两个 crate 中:
Crate |
角色 |
|---|---|
|
面向用户的公共 API。为 |
|
翻译层。在 |
稳定 API 覆盖了 cuda-oxide 最关心的类型:
Body——单个函数的 MIR(基本块、局部变量、类型)。BasicBlock——一段直线序列的语句后跟一个终止器。Local和Place——变量和内存位置。Ty——完整的 Rust 类型系统:原始类型、引用、元组、ADT、函数指针、闭包。StatementKind——赋值、存储注解、判别式读取。TerminatorKind——分支、调用、返回、断言、drop。Instance——单态化后的函数(具体类型已填充)。
备注
rustc_public 的工作由 AWS 的 Kani 团队(Rust 的形式验证)和其他需要稳定编译器访问的项目推动。cuda-oxide 受益于他们的工作,而无需自行维护桥接层。
cuda-oxide 如何接入#
CodegenBackend trait#
在 rustc 内部深处,编译围绕一个名为 CodegenBackend 的 trait 组织。其关键方法是 codegen_crate,它接收一个 TyCtxt——编译器包含所有类型信息、MIR 体和当前编译元数据的上帝对象——并且必须生成编译输出。
通常,rustc_codegen_llvm 实现此 trait,并通过 LLVM 将 MIR 转换为机器码。cuda-oxide 提供了 CudaCodegenBackend,它包装而不是替换 LLVM 后端。这是一个深思熟虑的设计选择:cuda-oxide 不是 LLVM 的完整替代品,它是一个专门处理 GPU 方面的专家,同时让 LLVM 做 LLVM 最擅长的事情。
包装流程如下所示:
rustc调用CudaCodegenBackend::codegen_crate(tcx)。cuda-oxide 拦截,运行其 collector 来识别所有设备函数(kernel 及其间接调用的函数),并进入 stable MIR 上下文通过
mir-importer将其编译为 PTX。PTX 输出写入磁盘,位于构建产物旁边。
cuda-oxide 将宿主代码委托给包装的 LLVM 后端,后者像往常一样将其编译为本地二进制文件。
结果是一次 cargo 调用生成一个本地宿主二进制文件和一个 PTX 模块,而不需要两个独立的工具链或分离的构建系统。
进入 stable MIR 上下文#
在 codegen_crate 内部,cuda-oxide 接收 rustc_middle 类型——内部的、不稳定的那种。但执行实际 MIR 到 Pliron IR 转换的 mir-importer crate 完全构建在 rustc_public 类型之上。要跨越边界,cuda-oxide 使用桥接层:
// 在 codegen_crate() 内部:
let result = rustc_internal::run(tcx, || {
// 现在处于 stable MIR 上下文中
let stable_instance = rustc_internal::stable(func.instance);
let body = stable_instance.body().unwrap();
// 送入 cuda-oxide 管道
mir_importer::run_pipeline(&functions, &config)
});
rustc_internal::run(tcx, || { ... }) 设置一个作用域上下文,其中 stable MIR 查询可用。在闭包内部,rustc_internal::stable() 将内部 rustc_middle::ty::Instance<'tcx> 转换为其稳定对应物 rustc_public::mir::mono::Instance。从那里,调用 Instance::body() 通过稳定 API 检索 MIR——无需直接接触 rustc_middle。
线程局部上下文管理#
你可能会想,为什么桥接层需要一个特殊的 run() 作用域,而不是只传递一个上下文对象。答案是生命周期的纠缠。
TyCtxt<'tcx> 从编译器的 arena 分配器借用数据。'tcx 生命周期与编译会话绑定,且不能逃脱 arena 的作用域。你不能将 TyCtxt 存储在结构体中、从函数返回它或将其发送到另一个线程。编译器的解决方案是作用域线程局部存储:上下文仅在你处于作用域内时可用,类型系统(加上运行时检查)阻止其泄漏。
桥接层设置两个嵌套的线程局部变量(TLV):
TLV |
类型 |
用途 |
|---|---|---|
|
|
高层查询: |
|
|
通过 |
两者都指向相同的底层 Container 结构体,但提供不同的访问模式:
with()访问外部 TLV 用于进行高层编译器查询。with_container()访问内部 TLV 用于在稳定和内部类型之间转换。
这种两层设计将查询接口与原始翻译机制分开,这样只需要"给我本地 crate 中的所有函数"的代码就不必知道内部 ID 映射。
如果你曾经使用过 Web 框架的请求作用域上下文(想想 Actix 的 web::Data 或 Axum 的提取器),心理模型是类似的:数据存在于请求期间(在这里是编译期间),框架使其可用,而不需要你将其贯穿每个函数签名。
桥接模式#
在 Container 的核心有一个 Tables 结构体——rustc 内部 ID 与稳定 API 类型之间的双向映射。当你调用 rustc_internal::stable(instance) 时,桥接层在表中查找(或创建)相应的稳定 ID。当稳定 API 需要代表你查询编译器时——比如说获取函数的 MIR 体——它会反向通过表来恢复内部类型。
rustc_middle::ty::Instance<'tcx>
│
▼
┌─────────┐
│ Tables │ (双向:内部 ↔ 稳定)
└─────────┘
│
▼
rustc_public::mir::mono::Instance
一些值得了解的实现细节:
内部可变性——
Tables使用RefCell,因为代码库中的多个部分在单个翻译 pass 期间需要对映射的可变访问。这是安全的,因为访问是单线程的(由线程局部存储保证)。缓存——一旦类型或实例被翻译,结果将存储在表中。重复查找命中缓存而不是重新计算。
自动清理——当
rustc_internal::run()返回时,线程局部存储被拆除,表被丢弃。无需手动清理,不可能存在过时引用。
从 cuda-oxide 的角度来看,桥接层是不可见的。mir-importer crate 只看到 rustc_public 类型——它从不导入 rustc_middle,从不处理 'tcx 生命周期,且从不直接接触 Tables。所有这些复杂性都被封装在 rustc_internal::run() 和 rustc_internal::stable() 背后,它们位于 rustc-codegen-cuda crate 中,处于编译器和 cuda-oxide 管道之间的边界上。
MIR 长什么样#
在 cuda-oxide 能够翻译 MIR 之前,了解 MIR 实际上是什么会有所帮助。这里是一个简单的函数及其 MIR:
fn add(x: i32, y: i32) -> i32 {
x + y
}
// `add` 的 MIR:
// _0: i32 (返回位置)
// _1: i32 (参数 `x`)
// _2: i32 (参数 `y`)
// _3: (i32, bool) (带检查算术运算的临时值)
//
// bb0: {
// _3 = CheckedAdd(_1, _2);
// assert(!(_3.1), "attempt to compute `{} + {}`, which would overflow") -> bb1;
// }
// bb1: {
// _0 = (_3.0);
// return;
// }
注意几点:
局部变量是编号的。
_0始终是返回位置(结果放的地方)。_1、_2、……是函数参数。更高编号的局部变量是编译器引入的临时值。基本块(
bb0、bb1、……)是语句的直线序列。每个块恰好以一个终止器结束,终止器转移控制权——分支、调用、返回或断言。CheckedAdd返回一个元组(i32, bool)。bool是溢出标志。assert终止器检查它,要么继续到bb1,要么 panic。在 debug 构建中这会捕获整数溢出;在 release 构建中检查会被优化掉。没有嵌套表达式。 源代码中的
x + y在 MIR 中变成两个独立的操作:计算带检查的加法,然后提取结果。每个中间值都获得自己的局部变量。这种扁平结构正是使 MIR 对像 cuda-oxide 这样的工具易于消费的原因——没有递归表达式树,每块只有一个扁平的操作列表。
备注
你可以通过向 rustc 传递 --emit=mir 来查看任何函数的 MIR,或者访问 Rust Playground 并选择 MIR 输出。习惯了局部变量编号后,这是一种出奇可读的格式。
为什么这对 cuda-oxide 很重要#
MIR 的扁平、显式结构使得在其上构建 GPU 编译器是可行的。考虑替代方案:如果 cuda-oxide 在 Rust 的 AST 或 HIR(高层 IR)上操作,它将不得不处理闭包、方法解析、trait 分发、类型推导和一百种其他的语言特性,而这些在 MIR 生成时已经*被解决了。*通过读取 MIR,cuda-oxide 获得了一个泛型已经单态化、闭包已经降级为结构体、控制流已经显式化的表示。mir-importer crate 将其转换为 Pliron IR(一个类似 MLIR 的框架),从那里降级管道将其带到 PTX 的最后一段路程。
Nightly 固定#
即使 rustc_public 提供了稳定的API,桥接层(rustc_public_bridge)仍然是对着编译器内部编译的,并且不是独立版本化的。在实践中,这意味着 cuda-oxide 的特定版本适用于特定的 nightly。
cuda-oxide 通过 rust-toolchain.toml 固定到确切的 nightly 版本:
[toolchain]
channel = "nightly-2026-04-03"
components = ["rust-src", "rustc-dev", "rust-analyzer"]
这个固定保证了可重现的构建:克隆仓库的任何人都获得相同的编译器、相同的 MIR 形状和相同的桥接行为。更新固定版本的流程是:
在
rust-toolchain.toml中提升 nightly 日期。修复任何
rustc_publicAPI 的变化(通常是微小的——这就是稳定 API 的全部意义)。运行完整的测试套件来验证所有示例仍然编译并生成正确的 PTX。
庆祝一下,或者回退并尝试下个星期的 nightly。
备注
随着 rustc_public 的成熟并走向 crates.io 发布,与特定 nightly 的耦合将会放松。长期目标是 cuda-oxide 能够与任何足够新的稳定 Rust 工具链一起工作——但我们还没有到达那一步。
现在你理解了 cuda-oxide 如何与 Rust 编译器对话,下一章将涵盖当那种对话到达 codegen 后端时发生了什么:Code Generator。