架构概览#

你已经用 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] 函数从源代码到硅片的完整旅程:

compiler/images/compiler-pipeline.svg

完整的编译管道。Rust 源代码进入 rustc 前端,经过 Stable MIR,转换为 dialect-mir(通过 mem2reg 将 allocas 提升回 SSA),降级为 dialect-llvm,导出为文本形式的 LLVM IR,最后通过 NVPTX 后端编译为 PTX。#

阶段详解:

  1. Rust 源代码。 你编写一个函数,贴上 #[kernel],然后继续你的一天。proc macro 将其重命名到预留的 cuda_oxide_kernel_<hash>_<name> 命名空间中,以便后端稍后识别。确切的前缀定义在工作区内部的 reserved-oxide-symbols crate 中;<hash> 使得意外命中冲突几乎不可能。

  2. rustc 前端。 rustc 执行解析、类型检查、借用检查、单态化泛型,并运行 MIR 优化 pass(内联、常量传播、死代码消除)。所有繁重的工作都在这里完成。

  3. Stable MIR。 codegen 后端接收 rustc 的内部 MIR,并将其桥接到 rustc_public 的稳定类型。这为我们提供了一个版本化、稳定的 MIR 视图,不会因下一个 nightly 而崩溃。

  4. dialect-mir(pliron)。 mir-importer 将 Stable MIR 转换为 dialect-mir——一个建模 Rust MIR 语义(places、projections、RvalueBinOp 等)的 pliron dialect。初始形式为每个局部变量使用 mir.alloca 槽位,配合 mir.load/mir.store 进行跨块数据流传递;然后 pliron::opts::mem2reg 将这些槽位提升回 SSA 值。

  5. dialect-llvm(pliron)。 mir-lowerdialect-mir 操作转换为 dialect-llvm 操作:llvm.allocallvm.loadllvm.storellvm.getelementptrllvm.call 等等。这里将 Rust 层面的概念扁平化为面向机器的 IR。

  6. LLVM IR(.ll 文件)。 dialect-llvm 打印器将 IR 序列化为文本形式的 LLVM IR。这是一个纯文本 .ll 文件——你可以阅读它,将其送入 opt,或在编译器版本之间比较差异。

  7. PTX(.ptx 文件)。 以 NVPTX 为目标的 llc.ll 文件编译为 PTX 汇编。结果是一个 .ptx 文件,可以在运行时由 CUDA 驱动加载。


Crate 分布图#

cuda-oxide 被拆分为专注的 crate。以下是每一个 crate 及其角色:

Crate

角色

rustc-codegen-cuda

自定义 rustc codegen 后端——拦截 codegen_crate(),拆分宿主/设备代码

mir-importer

将 Stable MIR 转换为 dialect-mir,编排完整管道

dialect-mir

建模 Rust MIR 语义的 pliron dialect(places、rvalues、terminators)

dialect-llvm

建模 LLVM IR + 文本 .ll 导出的 pliron dialect

dialect-nvvm

NVIDIA GPU 内部函数的 pliron dialect(tidntid、barriers、TMA)

mir-lower

dialect-mir 降级为 dialect-llvm——主要转换 pass

cargo-oxide

CLI 工具:cargo oxide buildcargo oxide runcargo oxide pipeline

cuda-device

设备端 API:内部函数、DisjointSlice、barriers、shared memory、warp 操作

cuda-macros

Proc macro:#[kernel]#[device]

cuda-host

宿主端类型化模块加载和启动辅助

cuda-core

CUDA 驱动 API 的安全绑定(CudaContextDeviceBufferCudaStream

cuda-async

异步 GPU 编程:DeviceOperation、组合子、流池调度

cuda-bindings

CUDA 驱动(libcuda.so)的底层 FFI 绑定

依赖流#

编译器 crate 形成清晰的管道:

compiler/images/crate-dependency-flow.svg

编译器 crate 如何连接。管道通过 codegen 后端、importer 和降级 pass,从左到右流动。dialect crate 位于下方,都构建在 pliron 之上。#

pliron 作为共享的 IR 框架,位于所有三个 dialect crate 之下——它提供了 ContextModuleRegionBlockOperationTypeAttribute 基础设施。rustc_public 提供了 mir-importer 从 rustc 读取的稳定 MIR 类型。面向用户的 crate(cuda-devicecuda-macroscuda-hostcuda-corecuda-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。

一个简化的心智模型#

compiler/images/host-device-split.svg

一个构建命令,两个编译目标。每个函数都经过 rustc 的前端。在 codegen 边界处,kernel 送往 cuda-oxide;其他所有内容送往 LLVM。#

每个函数都经过 rustc 的前端。在 codegen 边界处,后端查看每个函数并问:"你是 kernel 还是被 kernel 调用的?"如果是,你走右边(cuda-oxide 管道)。如果不是,你走左边(标准 LLVM)。有些函数两边都走——一个宿主和设备上都会使用的泛型辅助函数将被编译两次,每个目标一次。


rustc 免费给予我们的东西#

rustc 之上构建而不是发明一种新语言,最棒的一点是我们不必做的工作量之大。以下是 rustc 在 cuda-oxide 见到代码之前处理的内容:

工作

对 GPU 代码的价值

类型检查

在 GPU 编译之前捕获错误——不会出现晦涩的 PTX 汇编器失败

生命周期跟踪

跨越宿主/设备边界的安全性保证

借用检查

在编译时防止数据竞争,即使跨 GPU 线程也不例外

单态化

泛型在 GPU 上"开箱即用"——map<f32, _> 变成一个具体的 PTX kernel

MIR 优化

内联、常量传播、死代码消除——全部在我们开始之前应用

Trait 解析

Trait 对象已解析,vtable 已消失,一切都是静态分发

模式匹配

match 分支被降级为优化的 SwitchInt MIR 终止器

我们不需要重新实现任何这些。rustc 完成了繁重的工作,我们在最后拿到完全优化、单态化、借用检查过的 MIR。我们的工作"只是"翻译——公平地说,这仍然是大量的工作。但这比起从头构建一个 GPU 语言来说,是一个显著缩小的难题。

备注

这也意味着 Rust 的错误消息正常工作。如果你在 kernel 中犯了类型错误,你得到的和在其他 Rust 代码中一样的实用的 rustc 诊断信息——包括建议、片段高亮和"你是说……吗?"的提示。不需要学习单独的 GPU 编译器错误格式。


接下来去哪里#

本章剩余部分将深入架构的每个部分: