NVIDIA 发布 CUDA Rust:写 GPU kernel 的两条路线
NVIDIA 把 Rust 原生 kernel 编程分成两条路线:SIMT 保留线程级控制,Tile 让编译器决定线程映射;两个项目都还早,但边界已经画清楚。
中文
复制

2026 年 9 月,NVIDIA 宣布正式投入 Rust 原生 GPU 编程。CUDA C++ 与 CUDA Python 已是成熟的企业级工具链,而 CUDA Rust 会在 2027 年及之后继续成长和打磨
AI 的系统层——推理引擎、服务基础设施、驱动、agent 运行时——一直在随模型与技术的更迭而翻新,而且其中越来越多是 Rust 写的:它在编译期就能拦下一整类 bug,又不牺牲性能。
NVIDIA 也在这股潮流里,理由是一样的。Nova Linux 驱动是 Rust 写的,NVIDIA Dynamo 的内核基于 Rust,NVTX 有 Rust 绑定。
只有 GPU kernel 是例外。你可以从 Rust 启动 kernel,但 kernel 本身往往还是得用另一种语言写。
CUDA Rust 补的就是这个缺口:kernel 可以用 Rust 写、原生编译到 PTX,而不是给别处来的代码套一层壳。
用 Rust 写 kernel 有两条路线,对应 CUDA 自己的两条路线。SIMT 就是你在 CUDA C++ 或 numba-cuda 里已经熟悉的模型:你描述一个线程做什么,然后启动成千上万个线程。Tile 是更新的编程模型,C++ 和 Python 里也有。这些前端都让你描述一tile 数据要做什么,剩下的交给 Tile IR 编译器。
要选一条路线来做时,先用 Tile:编译器决定 tile 如何映射到具体架构,你的源码里不必编码架构相关的选择;需要那种控制力、想自己管理内存和线程时,再下到 SIMT。
用哪种语言和用哪个模型是两个独立的问题。哪个 CUDA 前端最贴合你现有的技术栈就用哪个,下面两个项目是给「技术栈就是 Rust」的人准备的。我们计划支持跨语言互操作,所以这个选择不会把你锁死在一条路上。
下面同一个 kernel 在两条路线上各写了一遍,做的是对 1,024 个 float 的逐元素相加。两个都是完整程序、都能跑、都打印同一行,可以并排读,看差别在哪。
SIMT 路线:cuda-oxide
cuda-oxide 是一个自定义的 rustc codegen backend。它拦截编译过程,把 #[kernel] 函数经 Rust MIR、社区项目 Pliron 的 IR 框架和 LLVM IR 一路降到 PTX,其余代码交回标准后端。Pliron 之上的 GPU dialect 是我们写的;在标准 LLVM 后端接手之前,dialect 和每一步变换都留在 Rust 里。
你需要 Linux、compute capability 8.0 及以上的 GPU、CUDA toolkit 12.x 或更新、带 libclang 头文件的 clang,以及固定版本的 nightly toolchain。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 要把 codegen backend 编译出来,所以会慢,之后的运行复用缓存。
它会打印 PASSED: all 1024 elements correct。这就是完成这件事的整个程序,和 cargo oxide new 生成的一模一样,只是这里加了注释:
use cuda_device::{kernel, launch_bounds, launch_contract, thread, DisjointSlice};
use cuda_host::cuda_module;
use cuda_core::{CudaContext, DeviceBuffer, LaunchConfig1D};
// === DEVICE CODE - everything in here is compiled to PTX ===
// The macro also generates the host-side API used further down:
// `load`, `prepare_vecadd`, and the safe `vecadd` launch method.
#[cuda_module]
mod kernels {
use super::*;
#[kernel] // GPU entry point
#[launch_bounds(256)] // max threads per block; lets the compiler budget registers
#[launch_contract(domain = 1, block = (256, 1, 1))] // indexes in 1-D, 256-thread blocks
pub fn vecadd(a: &[f32], b: &[f32], mut c: DisjointSlice<f32>) {
let idx = thread::index_1d();
let idx_raw = idx.get(); // the plain usize, for reading the inputs
if let Some(c_elem) = c.get_mut(idx) {
*c_elem = a[idx_raw] + b[idx_raw];
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// === HOST SETUP - device, stream, and buffers ===
let ctx = CudaContext::new(0)?;
let stream = ctx.default_stream();
const N: usize = 1024;
let a_host: Vec<f32> = (0..N).map(|i| i as f32).collect();
let b_host: Vec<f32> = (0..N).map(|i| (i * 2) as f32).collect();
let a_dev = DeviceBuffer::from_host(&stream, &a_host)?;
let b_dev = DeviceBuffer::from_host(&stream, &b_host)?;
let mut c_dev = DeviceBuffer::<f32>::zeroed(&stream, N)?;
// === LOAD, PREPARE, LAUNCH ===
// SAFETY: this package owns the embedded device bundle produced for the
// kernels module above.
let module = unsafe { kernels::load(&ctx)? };
// 4 blocks of 256 threads, 0 bytes of dynamic shared memory. `prepare_vecadd`
// checks that against the contract above and against the live device limits.
// The safe `vecadd` below takes that token where a raw config would go.
let prepared = module.prepare_vecadd(LaunchConfig1D::new((N as u32).div_ceil(256), 256, 0))?;
module.vecadd(&stream, &prepared, &a_dev, &b_dev, &mut c_dev)?;
// === READ BACK AND VERIFY ===
// Copies down and synchronizes, so the launch has finished by the time
// `c_host` can be read.
let c_host = c_dev.to_host_vec(&stream)?;
let errors = (0..N)
.filter(|&i| (c_host[i] - (a_host[i] + b_host[i])).abs() > 1e-5)
.count();
if errors == 0 {
println!("PASSED: all {} elements correct", N);
} else {
eprintln!("FAILED: {} errors", errors);
std::process::exit(1);
}
Ok(())
}
Host 代码和 device 代码在同一个文件里,一条命令构建,不需要单独的 kernel crate。
先看 kernel 的签名,整套安全性论证都压在这儿。a 和 b 是普通的共享 slice,每个线程都能读;c 是 DisjointSlice<f32>,它把每个元素的所有权单独交给一个线程,不涉及其它。之所以需要这个类型,是因为 &mut [f32] 形状不对:那样每个线程都要同一个 &mut,Rust 会正确地拒绝。DisjointSlice 把这一次可变借用切成每线程一份。
thread::index_1d() 返回的是索引类型而不是裸整数,c.get_mut(idx) 只接受这个类型。拿回来的是 Option,所以越界是一个你必须处理的分支,而不是日后才发现的访存错误。
启动是「被检查的」,不是「被信任的」。#[launch_contract] 声明这个 kernel 按一维索引、block 为 256 线程;prepare_vecadd 拿你的 LaunchConfig1D 去对照这份声明和设备的实际限制,通过后返回一个凭证,安全的 vecadd 方法要求这个凭证。没有 contract 的 kernel 只暴露原始的 unsafe 启动方法,因为一个裸 LaunchConfig 并不能说明它启动的是什么 kernel。
Tile 路线:cutile-rs
cutile-rs 站得更高一层:你在 tile 上而不是标量上做计算。每个 tile block 把 kernel 体当作一个逻辑线程、在一个子张量上跑一次,具体背后映射多少个真实 GPU 线程由编译器决定。#[cutile::module] 宏把 kernel 的 AST 嵌进 host 二进制,在第一次真正需要时经 CUDA Tile IR(NVIDIA 的 tile 级编译器 IR)JIT 编译出来。
依赖比 SIMT 路线轻:compute capability 8.0 及以上的 GPU、CUDA 13.3、stable Rust 1.89 或更新、Linux;不需要 nightly,也不需要自备 LLVM。
cutile 已经发布,所以不用 clone:
cargo new vecadd_demo
cd vecadd_demo
cargo add cutile
下面是同一个逐元素加法,用 tile 来写。贴进 src/main.rs,然后 cargo run:
use cutile::prelude::*;
// The macro captures this module's AST into the host binary. The kernel is
// JIT-compiled through CUDA Tile IR the first time it is actually launched.
#[cutile::module]
mod kernel {
use cutile::core::*;
#[cutile::entry()]
fn add<const B: i32>(
// B is the tile width, a static dimension. A different B produces a
// different specialization.
z: &mut Tensor<f32, { [B] }>, // exclusive output, one sub-tensor of B elements
x: &Tensor<f32, { [-1] }>, // shared input; -1 is a dynamic dimension, resolved at launch
y: &Tensor<f32, { [-1] }>,
) {
// This body runs once per mut sub-tensor, as a single logical thread.
// Tile kernels load tiles, not scalars, from x and y.
let tx = load_tile_like(x, z); // the slice of x lining up with this sub-tensor of z
let ty = load_tile_like(y, z);
z.store(tx + ty); // elementwise across the whole tile
}
}
fn main() -> Result<(), Error> {
let device = Device::new(0)?;
let stream = device.new_stream()?;
// These are lazy. Nothing has touched the GPU yet.
let x = api::ones::<f32>(&[1024]);
let y = api::ones::<f32>(&[1024]);
// Partitioning does three things at once: gives each tile exclusive
// ownership of its own 128-element chunk, fixes the grid at 1024/128 = 8
// tiles, and supplies B.
let z = api::zeros::<f32>(&[1024]).partition([128]);
let c: Vec<f32> = kernel::add(z, x, y) // takes ownership of all three tensors
.first() // ...and returns them; pick the output back out
.unpartition() // drop the host-side partition wrapper; no data moves
.to_host_vec() // record the copy back
.sync_on(&stream)?; // and only now does any of it run
let errors = c.iter().filter(|&&v| (v - 2.0).abs() > 1e-5).count();
if errors == 0 {
println!("PASSED: all {} elements correct", c.len());
} else {
eprintln!("FAILED: {errors} errors");
}
Ok(())
}
PASSED: all 1024 elements correct
Tile 路线在 stable Rust 上得到同样的答案,签名给出的安全性论证也一样。这次没有 DisjointSlice:host 侧的 partition 只对可变张量需要,它交给每个 tile block 一个可写子张量,别的 tile block 不可能与之重叠——这种独占性本来就是 &mut 保证的。
输入形状里的 -1 是哨兵而不是尺寸:那一维在启动时从张量上读出来,所以形状可以变,不用重新编译。
host 侧有意思的一行是 .partition([128]),它同时干三件事:让独占性落到实处,每个 tile 拥有自己的 128 元素块、别的 tile 碰不到;定下启动几何,1,024 ÷ 128 = 8 个 tile;并且提供 B。
网格由 partition 推出来,而不是另外算一遍再拿去和 kernel 的索引方式对账。它同时送上 B——调用点上从来不写 B,因为 launcher 是从 partition 上把 tile 宽度读出来的。这也是为什么 &mut 输出必须先 partition 才能传进去。
再看启动返回什么。你在 host 上调用的 add 是宏生成的 launcher,不是上面那个 device 函数:它接管三个张量的所有权,GPU 完成后把它们作为元组交回来,.first() 就是从里面把输出挑出来。
在 .sync_on(&stream) 之前什么都不会跑。之前的一切都是惰性的描述——被记录下来,而不是被提交,包括 ones、zeros、kernel 调用,甚至拷回 host 的那一步。整个程序是一条链,只有一个同步点。
编译器能拦下什么
两个 kernel 对内存的主张是一样的:输入是共享的,输出只属于一个写者。区别只在于它们在哪个层面上提出这个主张,以及是否需要专门造一个类型才能提出来。
这一点要紧,是因为成千上万个线程以不保证的顺序访问同一批 buffer:当两个线程撞上同一地址、其中一个在写时,谁先谁后决定结果。这类 bug 很少能按需复现,往往是测试全过、上线才炸。
把 SIMT kernel 的输出 buffer 同时当作输入传进去,编译不过——无论这个 kernel 是否真的会 race:
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::<f32>(&[1024]);
kernel::add(z.partition([128]), z, y)
error[E0382]: use of moved value: `z`
两个例子都在编译期抓住了经典的别名错误,只是划线的地方不同:cuda-oxide 检查每一次启动调用,cutile-rs 的所有权则跟着张量跨过启动边界——后者是更强的主张。
Tile 不给你共享内存和线程索引去写错,因为编译器同时拥有这两者:一个 tile block 就是一个逻辑线程,没有线程可 race。这就是它「构造上安全」的来源,也正是你要换掉的东西。SIMT 保留那份控制权,而今天那条路上的共享内存还需要 unsafe;共享内存是高性能 SIMT kernel 的地基,把这条路径做安全是正在进行的工作。
两个项目的现状
两个项目都很早期,都还不能上生产。cuda-oxide 是 early alpha;cutile-rs 走得更远,已经发布到 crates.io,并且已经在 NVIDIA 之外被用起来:HuggingFace 的 Grout 推理引擎和 mistral.rs。覆盖率还不完整,API 还会变。碰到粗糙的地方,我们希望听到反馈。
Cargo 和 crates 给人的预期是「上手很容易」,而 GPU 编程历来恰好相反;把这之间的距离抹平,也是这项工作的一部分。SIMT 路线仍然需要一个固定版本的 nightly toolchain——这正是我们最想不再麻烦你的那类事。
GPU 上的 Rust 并不是新话题:这个领域里有早于我们、并且仍在并行推进的好工作。cuda-oxide book 里的 ecosystem appendix 画出了我们相对 Rust-GPU、rust-cuda、CubeCL 等所处的位置;在两个项目成熟的过程中,我们一直和 rust-cuda 的维护者合作。
新的是我们投在这上面的工程力量,以及对它去向的清晰判断。
今天可以做什么
- 跑 SIMT 例子。 在 cuda-oxide 里
cargo oxide new,然后cargo oxide run。 - 跑 Tile 例子。 clone cutile-rs,然后
cargo run -p cutile-examples --example hello_world。 - 读文档。 cuda-oxide book 和 cuTile Rust 文档。
- 读论文。 Fearless Concurrency on the GPU。
- 提 issue。 哪里坏了、缺了什么,cuda-oxide 或 cutile-rs。
- 加入讨论。 两个仓库的 GitHub Discussions,或者 cuda-oxide Discord。
- 来听演讲。 Melih Elibol 会在 RustConf 2026(9 月 8–11 日,蒙特利尔)讲 “Fearless Concurrency on the GPU”。NVIDIA 还会有其他同事在场,你在的话可以来找我们。
拿这里的东西玩一玩,也可以来和我们一起做。它很早期、很开放,而你现在构建的东西会塑造接下来的走向。
Rust 社区
NVIDIA 很高兴和 Rust 社区一起,把原生 Rust GPU 编程往前推。rust-cuda、rust-gpu、cudarc 这些项目开创了 GPU 与 Rust 的结合,它们背后的人——包括 VectorWare 的团队——在我们与社区一起构建的过程中,持续塑造着我们对自家工作的思考。