NVIDIA が CUDA Rust を発表:GPU kernel を書く2つの路線

NVIDIA は Rust ネイティブのカーネルプログラミングを二つの路線に分けた。SIMT はスレッド単位の制御を残し、Tile はスレッドマッピングをコンパイラに任せる。どちらのプロジェクトもまだ初期段階だが、境界線はすでに引かれている。

日本語
コピー
深色终端里放大的 Rust kernel 代码截图,展示

2026 年 9 月、NVIDIA は Rust によるネイティブ GPU プログラミングへの本格参入を発表した。CUDA C++ と CUDA Python はすでに成熟したエンタープライズ向けツールチェーンであり、CUDA Rust は 2027 年以降も成長と研磨を続けていく

AI のシステム層——推論エンジン、サービング基盤、ドライバ、agent ランタイム——はモデルと技術の交代に合わせて絶えず作り替えられてきた。しかもその多くが Rust で書かれている。コンパイル時にまとまった種類のバグを止められ、性能も犠牲にしないからだ。

NVIDIA も同じ理由でこの流れに乗っている。Nova Linux ドライバは Rust 製、NVIDIA Dynamo のカーネルは Rust ベース、NVTX には Rust バインディングがある。

例外は GPU kernel だけだった。Rust から kernel を起動することはできるが、kernel 自体はたいてい別の言語で書く必要がある。

CUDA Rust が埋めるのはこの隙間だ。kernel を Rust で書き、ネイティブに PTX へコンパイルできる。他所から持ってきたコードに薄い殻を被せるのではない。

Rust で kernel を書く道は 2 本あり、それは CUDA 自身の 2 本に対応する。SIMT は CUDA C++ や numba-cuda でおなじみのモデルだ。1 つのスレッドが何をするかを書き、それを何千個も起動する。Tile はより新しいプログラミングモデルで、C++Python にもある。どちらのフロントエンドも、あるtile のデータに対して何をするかを記述すれば、あとは Tile IR コンパイラ に任せる形になる。

どちらの道で書くかを選ぶときは、まず Tile から始めるといい。tile を特定のアーキテクチャにどう対応させるかはコンパイラが決めるので、ソースにアーキテクチャ依存の選択を書き込まずに済む。その制御が必要で、メモリとスレッドを自分で管理したいなら SIMT に降りればいい。

どの言語を使うかと、どのモデルを使うかは別々の問題だ。自分の技術スタックに最も合う CUDA フロントエンドを選べばよく、以下の 2 つのプロジェクトは「スタックが 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 コードは同じファイルにあり、コマンド 1 つでビルドできる。別の kernel crate は要らない。

まず kernel のシグネチャを見てほしい。安全性の論証はここに集約されている。ab は普通の共有スライスで、どのスレッドも読める。cDisjointSlice<f32> で、各要素の所有権を 1 つのスレッドだけに渡し、他には及ばない。この型が必要なのは &mut [f32] では形が合わないからだ。それだとどのスレッドも同じ &mut を要求することになり、Rust は正しく拒否する。DisjointSlice はこの 1 回の可変借用をスレッドごとに 1 つへ分割する。

thread::index_1d() が返すのは裸の整数ではなくインデックス型で、c.get_mut(idx) はこの型しか受け付けない。返ってくるのは Option なので、範囲外は後から見つかるメモリアクセス違反ではなく、必ず処理しなければならない分岐になる。

起動は「信頼される」のではなく「検査される」。#[launch_contract] はこの kernel が 1 次元インデックスで、block が 256 スレッドだと宣言する。prepare_vecadd はあなたの LaunchConfig1D をこの宣言とデバイスの実際の制限に照らし、通れば証明を返す。安全な vecadd メソッドはこの証明を要求する。contract のない kernel は生の unsafe 起動メソッドしか公開しない。裸の LaunchConfig では、どんな kernel を起動するのか何も分からないからだ。

Tile の道:cutile-rs

cutile-rs はさらに一段高いところに立つ。スカラーではなく tile に対して計算する。各 tile block は kernel 本体を 1 つの論理スレッドとして、部分テンソル上で 1 回実行し、その裏で実際の 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) の前には何も走らない。それ以前はすべて遅延された記述だ——記録されるだけで、投入はされない。oneszeros、kernel 呼び出し、host へのコピー戻しまで含めて。プログラム全体が一本のチェーンで、同期点は一つしかない。

コンパイラが止められるもの

二つの kernel がメモリに対して主張している内容は同じだ。入力は共有され、出力は一人の書き手だけに属する。違うのは、その主張をどの層で行うか、そして主張するために専用の型を作る必要があるかどうかだけだ。

これが重要になるのは、何千ものスレッドが順序の保証されないまま同じ buffer に触るからだ。二つのスレッドが同じアドレスで衝突し、片方が書き込んでいるとき、どちらが先かで結果が決まる。こうしたバグが望んだときに再現することはほとんどなく、たいていテストは全部通って本番で初めて炸裂する。

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-oxidecargo oxide new、続けて cargo oxide run
  • Tile の例を動かす。 cutile-rs を clone して、cargo run -p cutile-examples --example hello_world
  • ドキュメントを読む。 cuda-oxide bookcuTile Rust ドキュメント
  • 論文を読む。 Fearless Concurrency on the GPU
  • issue を立てる。 壊れているところ、足りないものがあれば cuda-oxidecutile-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 のチームも含めて——は、私たちがコミュニティと一緒に構築を進める中で、自分たちの仕事の考え方を今も形づくってくれている。

出典: NVIDIA Technical Blog← ホームへ戻る