Nvidia、RustでのネイティブGPUプログラミングを発表
Nvidia announces native GPU programming in Rust (developer.nvidia.com)
要約
Nvidiaは、GPUカーネルをRustで直接記述できる2つの新しい方法を発表しました。cuda-oxideはSIMTモデルを、cutile-rsはタイルベースモデルをサポートし、どちらもコンパイル時のメモリ安全性を提供します。これにより、Rustの安全性とパフォーマンスをGPU開発にもたらします。
全文翻訳
Introducing CUDA Rust: GPUカーネルを記述するための2つのトラック
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を使用するための2つのトラックがあり、CUDA自体の2つのトラックに対応しています。SIMTは、CUDA C++またはnumba-cudaで既に記述しているモデルです。1つのスレッドが行うことを示し、数千のスレッドを起動します。Tileは、C++およびPythonでも利用可能な、より新しいプログラミングモデルです。これらのフロントエンドはすべて、1つのタイルのデータが行うことを記述できるようにし、Tile IRコンパイラが残りを処理します。
構築する際に、まずTileを選択してください。コンパイラがタイルを各アーキテクチャにマッピングする方法を決定するため、ソースコードにアーキテクチャ固有の選択をエンコードする必要がなく、その制御が必要な場合やメモリとスレッドを自分で管理したい場合はSIMTにフォールバックします。
どの言語を選択するかは、どのモデルを選択するかとは別の問題です。スタックに最も適したCUDAエクスポージャーを使用してください。以下の2つのプロジェクトは、そのスタックがRustである場合のためのものです。言語間の相互運用性をサポートする予定ですので、選択によって他の言語が使えなくなることはありません。
以下は、各トラックにおける同じカーネルで、1,024個の浮動小数点数に対して要素ごとの加算を実行するものです。どちらも完全なプログラムであり、どちらも実行され、同じ行を出力するため、並べて読んで何が変更されたかを確認できます。
SIMTトラック: cuda-oxide
cuda-oxideはカスタムrustcコード生成バックエンドです。コンパイルをインターセプトし、#[kernel]関数をRust MIR、コミュニティPliron IRフレームワーク、LLVM IRを経由してPTXまでルーティングし、残りは標準バックエンドに渡します。Pliron上のGPUダイアレクトは独自のものであり、ダイアレクトとすべての変換は、標準LLVMバックエンドが引き継ぐまでRust内に留まります。
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 - ここにあるものはすべてPTXにコンパイルされます ===
// このマクロは、さらに下で使用されるホスト側のAPIも生成します。
// `load`, `prepare_vecadd`, および安全な`vecadd`起動メソッド。
#[cuda_module]
mod kernels {
use super::*;
#[kernel] // GPUエントリポイント
#[launch_bounds(256)] // ブロックあたりの最大スレッド数。コンパイラがレジスタを予算計上できるようにします。
#[launch_contract(domain = 1, block = (256, 1, 1))] // 1次元、256スレッドブロックでのインデックス付け
pub fn vecadd(a: &[f32], b: &[f32], mut c: DisjointSlice<f32>) {
let idx = thread::index_1d();
let idx_raw = idx.get(); // 入力を読み取るための生のusize
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 - デバイス、ストリーム、バッファ ===
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: このパッケージは、上記のkernelsモジュール用に生成された埋め込みデバイスバンドルを所有しています。
let module = unsafe { kernels::load(&ctx)? };
// 4ブロック x 256スレッド、0バイトの動的共有メモリ。`prepare_vecadd`は、上記の契約およびライブデバイス制限に対してこれを検証します。
// 安全な`vecadd`は、生の構成が指定される場所にそのトークンを取ります。
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 ===
// コピーダウンして同期するため、`c_host`を読み取る前に起動は完了しています。
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(())
}
ホストコードとデバイスコードは1つのファイルに共存し、1つのコマンドでビルドでき、別のカーネルクレートを必要としません。
カーネルのシグネチャから先に読むべきです。なぜなら、それが安全性に関する主張全体を担っているからです。aとbは通常の共有スライスであり、すべてのスレッドが読み取ることができます。cはDisjointSlice<f32>であり、各スレッドにその要素への排他的アクセス権のみを付与する型です。これは、&mut [f32]がこのジョブに適した形状ではないため存在します。すべてのスレッドが同じ&mutを必要としますが、Rustはこれを正しく拒否します。DisjointSliceは、その1つのミュータブルボローをスレッドごとの断片に分割します。
thread::index_1d()は、生の整数ではなくインデックス型を返します。そして、c.get_mut(idx)はその型のみを受け入れます。返されるのはOptionなので、範囲外の場合は後で見つけるメモリエラーではなく、処理するブランチになります。
起動は信頼されるのではなくチェックされます。#[launch_contract]は、このカーネルが256スレッドブロックで1次元でインデックス付けされることを宣言します。prepare_vecaddは、LaunchConfig1Dをその宣言およびライブデバイス制限に対して検証し、安全なvecaddメソッドが要求する証明を返します。契約のないカーネルは、生のLaunchConfigが起動するカーネルについて何も語らないため、生のunsafe起動メソッドのみを公開します。
Tileトラック: cutile-rs
cutile-rsは、より上位のレベルで動作します。スカラーではなくタイルの上で計算を実行します。各タイルブロックは、1つのサブテンソルデータを対象にカーネル本体を1回、単一の論理スレッドとして実行し、コンパイラがそれをバックする実際の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::*;
// このマクロは、このモジュールのASTをホストバイナリにキャプチャします。カーネルは、最初に実際に起動されたときにCUDA Tile IRを通じてJITコンパイルされます。
#[cutile::module]
mod kernel {
use cutile::core::*;
#[cutile::entry()]
fn add<const B: i32>(
// Bはタイル幅であり、静的な次元です。異なるBは異なる特殊化を生成します。
z: &mut Tensor<f32, { [B] }>, // 排他的な出力、B要素の1つのサブテンソル
x: &Tensor<f32, { [-1] }>, // 共有入力; -1は動的な次元で、起動時に解決されます
y: &Tensor<f32, { [-1] }>,
) {
// この本体は、各ミュータブルサブテンソルに対して、単一の論理スレッドとして実行されます。
// タイルカーネルはスカラーではなく、xとyからタイルをロードします。
let tx = load_tile_like(x, z); // zのこのサブテンソルに整列するxのスライス
let ty = load_tile_like(y, z);
z.store(tx + ty); // タイル全体での要素ごとの加算
}
}
fn main() -> Result<(), Error> {
let device = Device::new(0)?;
let stream = device.new_stream()?;
// これらは遅延実行されます。まだ何もGPUに触れていません。
let x = api::ones::<f32>(&[1024]);
let y = api::ones::<f32>(&[1024]);
// パーティショニングは3つのことを同時に行います。各タイルに128要素のチャンクの排他的所有権を与え、グリッドを1024/128 = 8タイルに固定し、Bを供給します。
let z = api::zeros::<f32>(&[1024]).partition([128]);
let c: Vec<f32> = kernel::add(z, x, y) // 3つのテンソルすべてを所有権ごと取得します
.first() // ...そしてそれらを返します。出力だけを取り出します
.unpartition() // ホスト側のパーティションラッパーをドロップします。データは移動しません
.to_host_vec() // コピーバックを記録します
.sync_on(&stream)?; // そして、これらすべてが実行されるのはこの時点からです
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はありません。ホストでのパーティショニングはミュータブルテンソルにのみ必要であり、各タイルブロックに他のタイルブロックが重複できない書き込み可能なサブテンソルを1つ渡します。その排他性が、&mutが既に保証しているものです。
入力形状の-1はサイズではなくセンチネルです。その次元は起動時にテンソルから読み取られるため、形状は再コンパイルなしで変更できます。
ホストの興味深い行は.partition([128])であり、これは3つのジョブを同時に行います。これにより排他性が現実のものとなります。各タイルは128要素のチャンクを所有し、他のタイルはそれに触れることができません。これは起動ジオメトリを固定します。1,024を128で割ると8タイルからなるグリッドになります。
グリッドは、別途計算してカーネルのインデックス付けに対して検証するのではなく、パーティションから導き出されます。また、ランチャーがパーティションからタイル幅を読み取るため、呼び出しサイトで決して書き込まれないBも供給します。そのため、ミュータブルな出力は、渡される前にパーティション化される必要があります。
次に、起動が返すものを確認します。ホストで呼び出されるaddは、上記のデバイス関数ではなく、マクロ生成されたランチャーです。これは3つのテンソルすべてを所有権ごと取得し、GPUが完了したときにタプルとして返します。それが.first()の役割であり、そこから出力を取り出します。
.sync_on(&stream)まで何も実行されません。それ以前のすべては遅延された説明であり、送信されるのではなく記録されます。これにはones、zeros、カーネル呼び出し、さらにはホストへのコピーバックも含まれます。プログラム全体は、単一の同期ポイントを持つ1つのチェーンです。
コンパイラが検出するもの
どちらのカーネルもメモリに関して同じ主張をします。入力は共有され、出力は単一の書き込み元にのみ属します。それらは、それを実行するレベルと、それを実現するために専用の型が必要かどうかという点でのみ異なります。
これは、数千のスレッドが保証されない順序で同じバッファに到達するため重要です。2つのスレッドが同じアドレスにヒットし、一方が書き込んでいる場合、順序によって結果が決まります。これらのバグは、オンデマンドで再現することはめったになく、本番環境で失敗する前にテストをパスします。
SIMTカーネルの出力バッファを、その入力の1つとして渡しても、カーネルが実際に競合するかどうかに関わらず、コンパイルされません。
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の所有権は、起動境界を越えてテンソルを追跡し、これは2つの主張のうちより強力なものです。
Tileは、コンパイラが両方を所有しているため、間違える可能性のある共有メモリやスレッドインデックスを提供しません。タイルブロックは単一の論理スレッドであるため、競合するスレッドはありません。これが構築による安全性を提供し、また失うものです。SIMTはその制御を維持し、今日の共有メモリはunsafeを必要とします。共有メモリは高速なSIMTカーネルの基盤であるため、そのパスを安全にすることはアクティブな作業です。
プロジェクトの現状
どちらのプロジェクトも初期段階であり、どちらも本番環境対応ではありません。cuda-oxideは初期アルファ版です。cutile-rsはより進んでおり、crates.ioで公開されており、既にNVIDIA外のHuggingFaceのGrout推論エンジンやmistral.rsで使用されています。カバレッジは不完全であり、APIは変更される可能性があります。粗いエッジを見つけた場合は、ぜひお知らせください。
Cargoとcratesは、開始が簡単であるという期待を設定します。GPUプログラミングは歴史的にその逆であり、この距離を縮めることが作業の一部です。SIMTトラックはまだピン留めされたnightlyツールチェーンを必要としますが、これはまさに私たちがあなたに要求するのをやめたいことの1つです。
GPU上のRustは新しいものではありません。私たちのプロジェクトよりも前に存在し、並行して続いているこの分野での優れた作業があります。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 bookとcuTile Rust documentation。
- 論文を読む。 Fearless Concurrency on the GPU。
- 問題を報告する。 cuda-oxideまたはcutile-rsで、何が壊れたか、何が欠けていたかを教えてください。
- 会話に参加する。 両方のリポジトリのGitHub Discussions、またはcuda-oxide Discord。
- 講演に参加する。 Melih ElibolがRustConf 2026(9月8日から11日までモントリオールで開催)で「Fearless Concurrency on the GPU」を発表します。NVIDIAの他のスタッフも参加しますので、もしそこにいるなら私たちを見つけてください!
ここにあるものをいじって、私たちと一緒に開発しましょう。まだ初期段階で、オープンであり、今構築するものが次のものを形作ります。
NVIDIAは、ネイティブRust GPUプログラミングを向上させるにあたり、Rustコミュニティと協力できることを嬉しく思っています。rust-cuda、rust-gpu、cudarcのようなプロジェクトは、GPUとRustの結婚を開拓し、それらの背後にいる人々(VectorWareのチームを含む)は、Rustコミュニティと共に構築するにつれて、私たち自身の仕事の考え方を形作り続けています。