返回 文章 学习 CMS 文章

NVIDIA 推出 CUDA Rust:用 Rust 原生编写 GPU 内核的两条路径

NVIDIA 发布 CUDA Rust 双轨方案,让 GPU 内核用 Rust 原生编写,并借助所有权系统在编译期消除数据竞争。

CUDA RustGPU 编程RustNVIDIA
成长分 / 100 75 综合收获、行动、留存与影响

NVIDIA 推出 CUDA Rust:用 Rust 原生编写 GPU 内核的两条路径
为什么值得读了解 NVIDIA 在 Rust 原生 GPU 编程上的最新布局,以及 SIMT 与 Tile 两种编程模型的差异与选择依据。

通过同一个向量加法内核的完整代码示例,直观对比 cuda-oxide 与 cutile-rs 在类型系统、启动检查和所有权保证上的不同。

关键洞察
  1. CUDA Rust 让 GPU 内核用 Rust 原生编译为 PTX,而非封装其他语言代码,填补了 Rust 系统层与 GPU 内核之间的空白。
  2. 两条路径对应 CUDA 的两种模型:SIMT 沿用线程级编程,Tile 在瓦片数据上编程并由编译器决定线程映射,官方建议优先考虑 Tile。
  3. cuda-oxide 通过 DisjointSlice 和 launch_contract 在每次启动调用时检查别名与配置;cutile-rs 则让所有权跨越启动边界跟随张量,提供更强的编译期保证。
转成行动

深入阅读

正文与原文对照

原文保真覆盖:全文原文字符:16227

2026 年 9 月,NVIDIA 宣布将大力投入 Rust 原生 GPU 编程。CUDA C++ 和 CUDA Python 是成熟的企业级工具链,而 NVIDIA 将在 2027 年及以后持续发展和完善 CUDA Rust

AI 的系统层涵盖推理引擎、服务基础设施、驱动程序和智能体运行时,并且随着模型和技术的变化而不断更迭。其中越来越多的部分用 Rust 编写,它能在编译期捕获一整类缺陷,同时不牺牲性能。

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 子命令 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 写出的内容,这里加上了注释:

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、内核调用,甚至拷回主机的操作。整个程序就是一条链,只有一个同步点。

编译器能捕获什么

两个内核对内存做出同样的声明:输入是共享的,输出只属于唯一的写入者。它们的区别仅在于做出这一声明的层级,以及是否需要一个专门设计的类型才能做到。

这很重要,因为成千上万个线程会以无法保证的顺序访问相同的缓冲区。当其中两个线程访问同一地址且其中一个在写入时,顺序就决定了结果。这类缺陷很少能按需复现,往往先通过了测试,再在生产环境中出问题。

把 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 社区

NVIDIA 很高兴与 Rust 社区携手,共同推进原生 Rust GPU 编程。rust-cuda、rust-gpu 和 cudarc 等项目开创了 GPU 与 Rust 的结合,其背后的人们,包括 VectorWare 团队,在我们与 Rust 社区共同构建的过程中,继续影响着我们对自身工作的思考。