2026 年 9 月,NVIDIA 宣布将全力投入 Rust 原生 GPU 编程。CUDA C++ 和 CUDA Python 是成熟的企业级工具链,而 NVIDIA 将在 2027 年及以后持续发展和完善 CUDA Rust
AI 的系统层涵盖推理引擎、服务基础设施、驱动和智能体运行时,随着模型和技术的变化而不断更迭。其中越来越多的部分用 Rust 编写,它能在编译期捕获整整一类 bug,同时不牺牲性能。
NVIDIA 也出于同样的原因加入了这一转变。Nova Linux 驱动是用 Rust 编写的。NVIDIA Dynamo 构建在 Rust 核心之上。NVTX 提供了 Rust 绑定。
GPU 内核是个例外。你可以从 Rust 启动内核,但内核本身往往得用另一种语言来写。
NVIDIA CUDA Rust 填补了这一空白。GPU 内核可以用 Rust 编写,并原生编译为 PTX,而不是对别处代码的一层封装。
使用 Rust 有两条路径,对应 CUDA 本身的两条路径。SIMT 是你在 CUDA C++ 或 numba-cuda 中已经在用的模型:你描述一个线程做什么,然后启动成千上万个线程。Tile 是较新的编程模型,也提供 C++ 和 Python 版本。所有这些前端都让你描述一个数据块(tile)要做什么,其余工作由 Tile IR 编译器完成。
选择在哪条路径上构建时,优先考虑 Tile。编译器决定块如何映射到各个架构,所以你的源码中不会编码特定架构的选择;当你需要那种控制,或想自己管理内存和线程时,再退回到 SIMT。
选用哪种语言,与选用哪种模型是两个独立的问题。请使用最契合你现有技术栈的 CUDA 接入方式。下面这两个项目适用于你的技术栈是 Rust 的情形。我们计划支持跨语言互操作,所以这个选择不会把你与其他语言隔绝开。
下面是两条路径上的同一个内核,它对 1,024 个浮点数做逐元素加法。两者都是完整的程序,都能运行,且都打印同样的一行输出,所以你可以并排阅读,看看有什么不同。
SIMT 路径:cuda-oxide
cuda-oxide 是一个自定义的 rustc 代码生成后端。它拦截编译过程,把 #[kernel] 函数经由 Rust MIR、社区的 Pliron IR 框架和 LLVM IR 一路降到 PTX,并把其余部分交给标准后端。构建在 Pliron 之上的 GPU 方言是我们自己的。这些方言和每一个转换都保持在 Rust 中,直到标准 LLVM 后端接手。
你需要 Linux、计算能力 8.0 或更高的 GPU、CUDA 工具包(12.x 或更新)、带 libclang 头文件的 clang,以及固定版本的 nightly 工具链。cargo oxide doctor 会检查所有这些,包括可选的系统 LLVM。安装 cargo-oxide,即驱动构建的 Cargo 子命令:
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 写出的内容,这里加了注释:
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(())
}
主机代码和设备代码在同一个文件里,一条命令构建,不需要单独的内核 crate。
先读内核签名,因为整个安全性论证都在其中。a 和 b 是普通的共享切片,每个线程都可读取。c 是一个 DisjointSlice<f32>,这种类型让每个线程只能独占访问属于自己的那个元素,别无其他。之所以需要它,是因为 &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 工作在更高一层。你对块而不是标量执行计算。每个块把内核体作为单个逻辑线程,对一个数据子张量执行一次,由编译器决定实际有多少 GPU 线程来支撑它。#[cutile::module] 宏把内核的 AST 嵌入主机二进制文件,并在首次需要该内核时,通过 CUDA Tile IR(NVIDIA 块级编译器 IR)对其进行 JIT 编译。
要求比 SIMT 路径更低。你需要计算能力 8.0 或更高的 GPU、CUDA 13.3、稳定版 Rust 1.89 或更新版本以及 Linux,但不需要 nightly 工具链,也不需要自备 LLVM。
cutile 已发布,所以无需克隆任何东西:
cargo new vecadd_demo
cd vecadd_demo
cargo add cutile
下面是同样的逐元素加法,针对块来写。把它粘贴到 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 路径在稳定版 Rust 上得到同样的结果,其签名也提出同样的安全性论证。这次没有 DisjointSlice。只有可变张量才需要在主机上做划分,划分会给每个块一个可写的子张量,其他任何块都无法与之重叠。这种独占性正是 &mut 已经保证的。
输入形状中的 -1 是一个哨兵值而不是大小。该维度在启动时从张量读取,所以形状可以变化而无需重新编译。
主机侧有意思的一行是 .partition([128]),它同时承担三项工作。它让独占性真正成立:每个块拥有自己的 128 个元素,其他块都碰不到。它确定了启动几何,因为 1,024 除以 128 就是 8 个块的网格。
网格由划分推导而来,而不是单独计算后再对照内核的索引去检查。它还提供了 B,调用处从不写出它,因为启动器会从划分中读出块宽度。这就是为什么 &mut 输出必须先划分才能传入。
再看启动返回了什么。你在主机上调用的 add 是宏生成的启动器,而不是上面的设备函数。它取得全部三个张量的所有权,并在 GPU 完成后以元组形式交还。.first() 的用途就是把输出从中重新取出来。
在 .sync_on(&stream) 之前什么都不会运行。在它之前的一切都是惰性描述,只被记录而不被提交。这包括 ones、zeros、内核调用,乃至拷回主机的操作。整个程序是一条链,只有一个同步点。
编译器能捕获什么
两个内核对内存做出同样的声明:输入是共享的,输出只属于唯一的写入者。它们的区别仅在于做出声明的层级,以及是否需要专门设计的类型才能做出声明。
这一点之所以重要,是因为成千上万个线程以不保证的顺序访问同一批缓冲区。当其中两个线程访问同一地址且有一个在写入时,顺序决定了结果。这类 bug 很少能按需复现,它们能通过测试,却在生产环境中出错。
把 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::<f32>(&[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 也会变动。如果你遇到粗糙之处,我们希望听到你的反馈。
Cargo 和 crates 让人预期上手应当很容易。GPU 编程历来恰恰相反,缩短这段距离是工作的一部分。SIMT 路径仍需要固定版本的 nightly 工具链,这正是我们希望不再要求你做的事情。
Rust 上 GPU 并不是新鲜事。这一领域有不少早于我们的优秀工作,并仍在并行推进。cuda-oxide 手册中的生态附录梳理了我们相对于 Rust-GPU、rust-cuda、CubeCL 等项目所处的位置,随着两个项目逐渐成熟,我们也一直在与 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 文档。
- 阅读论文。Fearless Concurrency on the GPU。
- 提交 issue。在 cuda-oxide 或 cutile-rs 上告诉我们哪里出了问题、缺了什么。
- 加入讨论。两个仓库的 GitHub Discussions,或 cuda-oxide Discord。
- 来听演讲。Melih Elibol 将在 9 月 8 日至 11 日于蒙特利尔举行的 RustConf 2026 上作题为“Fearless Concurrency on the GPU”的演讲。NVIDIA 也会有其他员工出席,如果你在现场,欢迎来找我们!
动手试试这里的东西,并来和我们一起完善它。现在还处于早期,它是开放的,你现在构建的东西将塑造接下来的走向。
Rust 社区
在我们提升 Rust 原生 GPU 编程的地位之际,NVIDIA 很高兴能与 Rust 社区携手同行。rust-cuda、rust-gpu 和 cudarc 等项目开创了 GPU 与 Rust 的结合,其背后的人们,包括 VectorWare 团队,在我们与 Rust 社区共同构建的过程中,持续影响着我们对自身工作的思考。
