Rust 写 GPU 内核的两条轨道:cuda-oxide 编 SIMT 到 PTX,cutile-rs 在 Tile 上 JIT
- cuda-oxide:(原
NVlabs/cuda-oxide会重定向到这里),文档 - cutile-rs:,crates.io 上名为 cutile
2026 年 9 月,NVIDIA 宣布在 Rust 中采用原生 GPU 编程。CUDA C++ 和 CUDA Python 是成熟的企业级工具链,NVIDIA 会持续发展它们,同时把 CUDA Rust 推向成熟,直到 2027 年及以后。
AI 的系统层涵盖推理引擎,为基础设施、驱动和智能体运行时提供服务,并随模型和技术不断变化。越来越多的代码用 Rust 写,它在编译期捕获各类错误,又不牺牲性能。NVIDIA 自己也在这个转变里:Nova Linux 驱动用 Rust,Dynamo 基于 Rust 核心构建,NVTX 有 Rust 绑定。
GPU 内核是个例外。你可以从 Rust 启动内核,但内核本身通常得用另一种语言写。CUDA Rust 补上这段差距:内核可以直接用 Rust 写,原生编译为 PTX,而不是在别处写完再套一层包装。
Rust 的两条轨道,对应 CUDA 自己的两条。SIMT 就是你在 CUDA C++ 或 numba-cuda 里用的模型:指明某个线程做什么,然后启动数千个线程。Tile 是较新的编程模型,C++ 和 Python 里也有。两种前端都让你描述每块数据做什么,剩下的交给 Tile IR 编译器。
选型上,能上 Tile 就先看 Tile。编译器决定图块如何映射到每个架构,源文件里不写死架构选择;需要自己控制、想手动管理内存和线程时,用 SIMT。
下面两个项目都面向 Rust 栈,计划支持多语言互操作,选哪个都不会把你排除在其他语言之外。
SIMT 轨道:cuda-oxide
cuda-oxide 是一个自定义的 rustc 代码生成后端。它拦截编译,通过 Rust MIR、社区的 Pliron IR 框架和 LLVM IR,把 #[kernel] 函数路由到 PTX,其余内容交给标准后端。Pliron 上的 GPU 方言是 NVIDIA 的,这些方言和每一次转换都留在 Rust 里,直到标准 LLVM 后端接手。
环境要求:Linux、计算能力 8.0 及以上的 GPU、CUDA 工具包 12.x 及以上、带 libclang 头文件的 Clang,以及固定的 nightly 工具链。cargo oxide doctor 会检查以上全部,包括可选的系统 LLVM。安装驱动构建的 Cargo 子命令 cargo-oxide:
cargo +nightly-2026-04-03 install --git https://github.com/NVlabs/cuda-oxide.git cargo-oxide
用脚手架建项目并运行:
cargo oxide new vecadd_demo
cd vecadd_demo
cargo oxide doctor
cargo oxide run
第一次 cargo oxide run 会构建代码生成后端,耗时较长;后续运行复用缓存。
它会打印 PASSED: all 1024 elements correct。这就是 cargo oxide new 生成的完整程序:向量加法,设备端和主机端在同一个文件里。
#[cuda_module]
mod kernels {
#[kernel]
#[launch_bounds(256)]
#[launch_contract(domain = 1, block = (256,1,1))]
pub fn vecadd(a: &[f32], b: &[f32], mut c: DisjointSlice) { /* ... */ }
}
主机侧依次是 CudaContext::new(0)、DeviceBuffer::from_host / zeroed、unsafe { kernels::load(&ctx)? }、module.prepare_vecadd(LaunchConfig1D::new((N as u32).div_ceil(256), 256, 0))?、module.vecadd(&stream, &prepared, &a_dev, &b_dev, &mut c_dev)?,最后用 c_dev.to_host_vec(&stream)? 校验。
先看内核签名,整个安全参数都在这里。a 和 b 是普通的共享切片,每个线程都能读。c 是 DisjointSlice,它把对各自元素的独占访问判给对应线程。它存在的原因是 &mut [f32] 对这项任务来说是错误的形状:每个线程都需要同一个 &mut,而 Rust 正确地拒绝了。DisjointSlice 把可变借用拆成每线程一份。
thread::index_1d() 返回索引类型而非裸整数,c.get_mut(idx) 只接受这个类型。返回值是 Option,越界因此是你要处理的分支,而不是事后才发现的访存错误。
启动会被检查,而不是被信任。#[launch_contract] 声明这个内核在一个维度上索引 256 线程块。prepare_vecadd 拿这份声明和实时设备限制校验你的 LaunchConfig1D,并为安全的 vecadd 方法提供所需证明。没有合约的内核只暴露原始的 unsafe 启动方法,因为裸 LaunchConfig 并不说明要启动的是哪个内核。
Tile 轨道:cutile-rs
cutile-rs 的抽象高一层:在 tile 上算,而不是在标量上算。每个图块块作为单个逻辑线程,在一个子张量的数据上跑一遍内核函数体,实际使用的 GPU 线程数由编译器决定。
#[cutile::module] 宏把内核的 AST 嵌进主机二进制,在首次需要该内核时通过 CUDA Tile IR(NVIDIA 的 Tile-Level Compiler IR)做 JIT 编译。
要求比 SIMT 轨道更轻:计算能力 8.0 及以上的 GPU、CUDA 13.3、稳定版 Rust 1.89 或更高,以及 Linux。不需要 nightly 工具链,也不需要自己的 LLVM。
cutile 已发布到 crates.io,不用克隆任何东西:
cargo new vecadd_demo
cd vecadd_demo
cargo add cutile
同样的元素加法,这次按图块写。粘进 src/main.rs 后 cargo run:
use cutile::prelude::*;
#[cutile::module]
mod kernel {
#[cutile::entry()]
fn add(
z: &mut Tensor,
x: &Tensor,
y: &Tensor,
) {
let tx = load_tile_like(x, z);
let ty = load_tile_like(y, z);
z.store(tx + ty);
}
}
主机侧:Device::new(0)?、device.new_stream()?、api::ones::(&[1024])、api::zeros::(&[1024]).partition([128]),然后 kernel::add(z, x, y).first().unpartition().to_host_vec().sync_on(&stream)?,再校验。
PASSED: all 1024 elements correct
在 Rust 稳定版上,Tile 轨道给出相同的答案,签名发出的安全参数也相同。这次没有 DisjointSlice。主机侧的分区只适用于可变张量,它为每个图块传一个其他图块无法重叠的可写子张量,这种排他性正是 &mut 所保证的。
输入形状里的 -1 是 sentinel,不是大小。该维度在启动时读取张量,因此可以不重新编译就改变形状。
主机上值得细看的是 .partition([128]),它同时做三件事:
- 让排他性成真。每个图块有自己的 128 个元素,别的图块碰不到。
- 固定启动几何。1024 除以 128 得到 8 个图块的网格;网格从分区算出,而不是另算一遍,再拿内核的索引校验。
- 提供
B。启动器读取分区的图块宽度,所以调用点根本不用写这个值。
因此必须先对 &mut 输出分区,再传给内核。
再看启动返回什么。你在主机上调用的 add 是宏生成的启动器,不是上面那个设备函数。它取走三个张量的所有权,在 GPU 完成后作为元组返回,.first() 用来取出输出。
.sync_on(&stream) 之前什么都不会运行。之前的一切都是延迟描述——记录而非提交,包括 ones、zeros、内核调用,甚至拷回主机。整个程序是一条只有一个同步点的链。
编译器捕获的内容
这两个内核都作出了相同的内存声明:输入共享,输出内容只属于一个写入者。区别只在构造层级,以及是否需要专门构建的类型。
这很重要,因为数千个线程会以无法保证的顺序到达同一块缓冲区。当两个使用者碰到同一地址、其中一个正在写入时,顺序决定结果。这类错误很难按需复现,却会在生产环境失败之前先通过测试。
把 SIMT 内核的输出缓冲区作为它自己的输入之一传进去,不管这个内核是否真的会竞争,都编译不过:
module.vecadd(&stream, &prepared, &c_dev, &b_dev, &mut c_dev)?;
error[E0502]: cannot borrow `c_dev` as mutable because it is also borrowed as immutable
Tile 侧同样的锯齿也编译不过:
let z = api::zeros::(&[1024]);
kernel::add(z.partition([128]), z, y)
error[E0382]: use of moved value: `z`
两个例子都在编译期发现了典型的锯齿错误,只是划线位置不同:cuda-oxide 检查每一次启动调用;cutile-rs 的所有权跨启动边界跟随张量,这是两种声明中更强的一种。
Tile 不给你共享内存或线程索引,这本身是为了少出错,因为两者都归编译器所有:图块是单个逻辑线程,没有线程可竞争。这就是「靠构造保证安全」,也是你为此交换出去的东西。SIMT 保留这种控制权,而这条路径上的共享内存需要 unsafe。共享内存是快速 SIMT 内核的基石,让这条路安全需要主动做工作。
项目现状
两个项目都还处于早期阶段,尚未生产就绪。cuda-oxide 是早期 Alpha。cutile-rs 发布在 crates.io,并已在 NVIDIA 之外的 HuggingFace Grout 推理引擎和 mistral.rs 中使用。覆盖率不完整,API 会移动。遇到粗糙的边缘,NVIDIA 希望听到反馈。
SIMT 轨道仍然需要固定的 nightly 工具链,这正是 NVIDIA 不想再向你提的问题。
用 Rust 写 GPU 并不新鲜。这个领域里有些出色的工作早于这两个项目,并且仍在继续。cuda-oxide 书中的生态附录画出了它相对 Rust-GPU、rust-cuda、CubeCL 等的位置;随着两个项目走向成熟,NVIDIA 一直在与 rust-cuda 的维护者合作。
可以做什么
- 跑 SIMT 示例:在 cuda-oxide 里用
cargo oxide new和cargo oxide run。 - 跑 Tile 示例:克隆 cutile-rs,然后
cargo run -p cutile-examples --example hello_world。 - 读文档:cuda-oxide 手册和 cuTile Rust 文档。
- 读白皮书:GPU 上的「无畏并发(Fearless Concurrency)」。
- 提 issue:cuda-oxide 或 cutile-rs。
- 加入讨论:GitHub 或 cuda-oxide Discord。
RustConf 2026(9 月 8 日至 11 日,蒙特利尔),Melih Elibol 将分享「GPU 上的无畏并发」。
NVIDIA 表示很荣幸与 Rust 社区携手:rust-cuda、rust-gpu 和 cudarc 等项目开创了 GPU 与 Rust 的结合,包括 VectorWare 团队在内的相关人员仍在继续塑造这项工作。