架构概览#
你已经用 Rust 编写了一个 #[kernel] 函数。它通过了类型检查,通过了借用检查。现在它需要变成在 GPU 上运行的 PTX。本章将解释 cuda-oxide 如何完成这一过程——每一个阶段、每一个 crate,以及每个选择背后的原因。
如果你只想编写 kernel,你完全不需要阅读本页。但如果你想修改编译器、贡献一个 pass,或者满足"它到底实际上是怎么工作的?"的好奇心,欢迎。给自己来杯咖啡。
设计理念#
指导原则简短到可以写在一张便利贴上:
为每个阶段使用最佳工具——但拥有完整管道。
编译器就像千层蛋糕。每一层都有截然不同的工作,而不同的工具擅长不同的层面。cuda-oxide 在每个阶段选择最强大的选项,而不是从头构建一切:
前端:
rustc+rustc_public(Stable MIR)。 当已有的类型检查器是历史上最好的之一时,为什么还要重写一个呢?Rust 的编译器处理了语法解析、名称解析、类型推导、借用检查、trait 解析、单态化和 MIR 优化。我们免费得到了所有这些。中端:
pliron(Pliron IR,类似 MLIR)。 我们需要一个地方将 Rust MIR 转换为类似 LLVM 的表示形式。pliron 是一个可扩展的 IR 框架,受 LLVM 的 MLIR 启发,但完全用 Rust 编写。没有 C++ 依赖,没有 CMake,没有 tablegen——只需cargo build。我们在这里定义了三个自定义 dialect:一个用于 MIR,一个用于 LLVM IR,一个用于 NVIDIA GPU 内部函数。后端:LLVM NVPTX。 NVIDIA 在 LLVM 的 NVPTX 后端上投入了多年的工作。它了解每一个寄存器类、每一条指令编码、每一个调度细节。我们以文本形式生成 LLVM IR 并将其交给
llc。站在巨人的肩膀上胜过重新发明 PTX 汇编器。
回报:整个编译器都用 Rust 编写(除了最后的 llc 调用)。没有对 C++ 中端的不透明交接。你可以在任何转换 pass 中设置断点,用 println! 遍历 IR,如果你有冒险精神,还可以在 Miri 下运行整个东西。全程使用标准 Rust 工具链。
备注
llc 是唯一的外部二进制文件。它来自一个启用了 NVPTX 后端的 LLVM 安装;仅靠 CUDA Toolkit 是不够的。cuda-oxide 的所有阶段(直到 LLVM IR 生成)都是用 Rust 实现的;后端写入 .ll 文件后,调用外部 LLVM llc 来生成 PTX。
管道一览#
这是一个 #[kernel] 函数从源代码到硅片的完整旅程:
完整的编译管道。Rust 源代码进入 rustc 前端,经过 Stable MIR,转换为 dialect-mir(通过 mem2reg 将 allocas 提升回 SSA),降级为 dialect-llvm,导出为文本形式的 LLVM IR,最后通过 NVPTX 后端编译为 PTX。#
阶段详解:
Rust 源代码。 你编写一个函数,贴上
#[kernel],然后继续你的一天。proc macro 将其重命名到预留的cuda_oxide_kernel_<hash>_<name>命名空间中,以便后端稍后识别。确切的前缀定义在工作区内部的reserved-oxide-symbolscrate 中;<hash>使得意外命中冲突几乎不可能。rustc 前端。 rustc 执行解析、类型检查、借用检查、单态化泛型,并运行 MIR 优化 pass(内联、常量传播、死代码消除)。所有繁重的工作都在这里完成。
Stable MIR。 codegen 后端接收 rustc 的内部 MIR,并将其桥接到
rustc_public的稳定类型。这为我们提供了一个版本化、稳定的 MIR 视图,不会因下一个 nightly 而崩溃。dialect-mir(pliron)。mir-importer将 Stable MIR 转换为dialect-mir——一个建模 Rust MIR 语义(places、projections、Rvalue、BinOp等)的 pliron dialect。初始形式为每个局部变量使用mir.alloca槽位,配合mir.load/mir.store进行跨块数据流传递;然后pliron::opts::mem2reg将这些槽位提升回 SSA 值。dialect-llvm(pliron)。mir-lower将dialect-mir操作转换为dialect-llvm操作:llvm.alloca、llvm.load、llvm.store、llvm.getelementptr、llvm.call等等。这里将 Rust 层面的概念扁平化为面向机器的 IR。LLVM IR(.ll 文件)。
dialect-llvm打印器将 IR 序列化为文本形式的 LLVM IR。这是一个纯文本.ll文件——你可以阅读它,将其送入opt,或在编译器版本之间比较差异。PTX(.ptx 文件)。 以 NVPTX 为目标的
llc将.ll文件编译为 PTX 汇编。结果是一个.ptx文件,可以在运行时由 CUDA 驱动加载。
Crate 分布图#
cuda-oxide 被拆分为专注的 crate。以下是每一个 crate 及其角色:
Crate |
角色 |
|---|---|
|
自定义 rustc codegen 后端——拦截 |
|
将 Stable MIR 转换为 |
|
建模 Rust MIR 语义的 pliron dialect(places、rvalues、terminators) |
|
建模 LLVM IR + 文本 |
|
NVIDIA GPU 内部函数的 pliron dialect( |
|
将 |
|
CLI 工具: |
|
设备端 API:内部函数、 |
|
Proc macro: |
|
宿主端类型化模块加载和启动辅助 |
|
CUDA 驱动 API 的安全绑定( |
|
异步 GPU 编程: |
|
CUDA 驱动( |
依赖流#
编译器 crate 形成清晰的管道:
编译器 crate 如何连接。管道通过 codegen 后端、importer 和降级 pass,从左到右流动。dialect crate 位于下方,都构建在 pliron 之上。#
pliron 作为共享的 IR 框架,位于所有三个 dialect crate 之下——它提供了 Context、Module、Region、Block、Operation、Type 和 Attribute 基础设施。rustc_public 提供了 mir-importer 从 rustc 读取的稳定 MIR 类型。面向用户的 crate(cuda-device、cuda-macros、cuda-host、cuda-core、cuda-async)独立于编译器内部,仅彼此依赖。
两个关键依赖#
cuda-oxide 的存在依赖于两个外部项目。这两个都不是可选的,都值得在接下来的深入章节之前做一个简要介绍。
pliron——Pliron IR(类似 MLIR)#
pliron 是一个可扩展的编译器 IR 框架,受 LLVM 的 MLIR 启发,但完全用 Rust 编写。它提供了相同的核心抽象——dialect、operation、type、attribute、region、block——而不需要 C++ 工具链、CMake 或 tablegen。
cuda-oxide 选择 pliron 而非上游 MLIR 出于一个务实的原因:我们希望整个编译器能用 cargo 构建。依赖 MLIR 意味着引入 LLVM 单仓库、C++ 构建系统和 Rust-C++ FFI 胶水——所有这些都会增加构建复杂性,拖慢 CI,并使贡献者入门变得痛苦。有了 pliron,dialect 使用标准 Rust trait 和 derive 宏定义,IR 可以用任何 Rust 调试器检查。
cuda-oxide 在 pliron 之上定义了三个 dialect:dialect-mir(建模 Rust MIR)、dialect-llvm(建模 LLVM IR + 文本导出)和 dialect-nvvm(NVIDIA GPU 内部函数)。
参见
要深入了解 pliron 的架构,请参阅 Pliron——Rust 中的 MLIR。
rustc_public——Stable MIR#
rustc_public(历史上称为 Stable MIR 或 stable_mir)是 Rust 官方稳定接口,用于访问编译器内部。MIR——中间表示(Mid-level Intermediate Representation)——是借用检查、生命周期验证和大多数优化发生的地方。它也是一个保留类型信息的丰富高层表示,使其成为 GPU 后端的理想起点。
问题在于:MIR 是一个内部表示。它的数据结构在 nightly 版本之间变化,没有稳定性保证。直接读取内部 MIR 的后端会在每次 rustc 重构字段名或重新排序枚举变体时崩溃——这发生的频率比你想象的要高。rustc_public 通过提供一个版本化的稳定 API 来解决这个问题,该 API 将内部类型桥接到公共接口。cuda-oxide 在 CodegenBackend::codegen_crate() 入口点接入,将内部类型桥接到稳定 MIR 类型,并将结果交给 mir-importer 进行转换。
参见
要深入了解 rustc_public,请参阅 rustc_public——Stable MIR。
宿主/设备分离#
cuda-oxide 是一个单源代码编译器。宿主代码和设备代码存在于同一个 .rs 文件中,而一个单一的构建命令就能编译两者。以下是这如何逐步工作的:
1. cargo-oxide 使用自定义后端调用 rustc。
cargo oxide run vecadd
在底层,这会设置 -Z codegen-backend=librustc_codegen_cuda.so,告诉 rustc 通过 cuda-oxide 的后端而不是默认的 LLVM 后端来进行代码生成。
2. rustc 为依赖树中的每个 crate 调用 codegen_crate()。
这不是 cuda-oxide 特有的步骤——这就是 rustc 的工作方式。对于每个正在编译的 crate(你的二进制文件、cuda-device 和任何其他依赖),rustc 都会调用 codegen 后端。
3. 后端扫描 kernel 入口点。
它查找单态化函数中名称包含预留的 cuda_oxide_kernel_<hash>_ 前缀的函数。这些就是 #[kernel] 创建的函数。
4. 如果找到 kernel:构建设备调用图并生成 PTX。
从每个 kernel 开始,后端遍历调用图,收集该 kernel 间接调用的每一个设备函数。这个函数集合交给 mir-importer,后者运行完整管道(dialect-mir -> dialect-llvm -> .ll -> PTX)。结果是一个 .ptx 文件,写在宿主二进制文件旁边。
5. 始终:将宿主代码委托给标准 LLVM 后端。
无论是否找到 kernel,宿主代码都正常编译。cuda-oxide 的后端将所有不是设备代码的部分委托给 rustc 的默认 LLVM codegen。你的 main() 函数、CLI 解析、异步运行时——全部按常规方式编译。
6. 结果:一次构建生成一个宿主二进制文件 + 一个 .ptx 文件。
target/debug/vecadd ← 宿主二进制文件(在运行时加载 PTX)
target/debug/vecadd.ptx ← 设备代码(由 CUDA 驱动加载)
备注
来自依赖项(如 cuda-device)的设备代码会延迟编译。只有当你的 crate 中的一个 kernel 间接调用外部 crate 中的函数时,这些函数才会被编译为 PTX。MIR 可从 .rlib 元数据中获得,因此无需从源代码重新编译依赖项——后端按需读取它们的 Stable MIR。
一个简化的心智模型#
一个构建命令,两个编译目标。每个函数都经过 rustc 的前端。在 codegen 边界处,kernel 送往 cuda-oxide;其他所有内容送往 LLVM。#
每个函数都经过 rustc 的前端。在 codegen 边界处,后端查看每个函数并问:"你是 kernel 还是被 kernel 调用的?"如果是,你走右边(cuda-oxide 管道)。如果不是,你走左边(标准 LLVM)。有些函数两边都走——一个宿主和设备上都会使用的泛型辅助函数将被编译两次,每个目标一次。
rustc 免费给予我们的东西#
在 rustc 之上构建而不是发明一种新语言,最棒的一点是我们不必做的工作量之大。以下是 rustc 在 cuda-oxide 见到代码之前处理的内容:
工作 |
对 GPU 代码的价值 |
|---|---|
类型检查 |
在 GPU 编译之前捕获错误——不会出现晦涩的 PTX 汇编器失败 |
生命周期跟踪 |
跨越宿主/设备边界的安全性保证 |
借用检查 |
在编译时防止数据竞争,即使跨 GPU 线程也不例外 |
单态化 |
泛型在 GPU 上"开箱即用"—— |
MIR 优化 |
内联、常量传播、死代码消除——全部在我们开始之前应用 |
Trait 解析 |
Trait 对象已解析,vtable 已消失,一切都是静态分发 |
模式匹配 |
|
我们不需要重新实现任何这些。rustc 完成了繁重的工作,我们在最后拿到完全优化、单态化、借用检查过的 MIR。我们的工作"只是"翻译——公平地说,这仍然是大量的工作。但这比起从头构建一个 GPU 语言来说,是一个显著缩小的难题。
备注
这也意味着 Rust 的错误消息正常工作。如果你在 kernel 中犯了类型错误,你得到的和在其他 Rust 代码中一样的实用的 rustc 诊断信息——包括建议、片段高亮和"你是说……吗?"的提示。不需要学习单独的 GPU 编译器错误格式。
接下来去哪里#
本章剩余部分将深入架构的每个部分:
Pliron——Rust 中的 MLIR——将管道粘合在一起的 IR 框架。
rustc_public——Stable MIR——如何在不必在每个 nightly 上崩溃的情况下读取 MIR。
Code Generator:rustc-codegen-cuda——拦截 rustc 的 codegen 后端。
MIR Importer——将 Stable MIR 转换为 pliron。
Pliron Dialects——三个自定义 dialect 及其操作集。
降级管道——从
dialect-mir到dialect-llvm,逐个 pass。添加新的内部函数——扩展编译器的贡献者指南。