Rust на GPU: два пути
Системный слой AI охватывает engine'ы вывода, инфраструктуру обслуживания, драйверы и runtime'ы агентов. Эта часть стека постоянно меняется вместе с моделями и методами, и всё больше её написано на Rust, который выявляет целые классы ошибок на этапе компиляции без потери производительности.
NVIDIA участвует в этом сдвиге по той же причине. Linux-драйвер Nova написан на Rust. NVIDIA Dynamo построена на ядре на Rust. NVTX имеет привязки для Rust.
Исключение — GPU-ядро. Можно запускать ядра из Rust, но само ядро часто приходилось писать на другом языке.
CUDA Rust закрывает этот разрыв. GPU-ядра можно писать на Rust и компилировать непосредственно в PTX вместо обёртки вокруг кода из другого источника.
Есть два пути использования Rust, соответствующих двум путям самого CUDA. SIMT — это модель, которую вы уже пишете на CUDA C++ или numba-cuda. Вы указываете, что делает один поток, и запускаете их тысячи. Tile — это более новая модель программирования, доступная также на C++ и Python. Все эти интерфейсы позволяют вам сказать, что делает один tile данных, а компилятор Tile IR сделает остальное.
При выборе одного пути начните с Tile. Компилятор решает, как плитки отображаются на каждую архитектуру, поэтому ваш исходный код не кодирует архитектурные выборы, и вы переходите на SIMT, когда нужен этот контроль или вы хотите управлять памятью и потоками сами.
Выбор языка — отдельный вопрос от выбора модели. Используйте CUDA-интерфейс, который лучше всего подходит для стека, который у вас уже есть. Два проекта ниже предназначены для случаев, когда этот стек — Rust. Мы планируем поддерживать межъязыковую совместимость, чтобы выбор не закрывал вам доступ к остальным.
Ниже представлено одно и то же ядро на каждом пути, выполняющее поэлементное сложение по 1024 float'ам. Обе программы полные, обе работают и обе выводят одну и ту же строку, так что вы можете читать их рядом и видеть, что меняется.
Путь SIMT: cuda-oxide
cuda-oxide — это пользовательский backend кодогенерации rustc. Он перехватывает компиляцию, маршрутизирует функции #[kernel] через MIR Rust, фреймворк IR сообщества Pliron и LLVM IR вниз к PTX, остальное отправляя стандартному backend'у. GPU-диалекты поверх Pliron — наши. Диалекты и каждое преобразование остаются на Rust, пока стандартный backend LLVM не вступит в игру.
Вам понадобится Linux, GPU с compute capability 8.0 или выше, CUDA toolkit (12.x или новее), clang с заголовками libclang и закреплённый nightly toolchain. 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 создаёт 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(())
}Код хоста и устройства находятся в одном файле, собираются одной командой и не требуют отдельного kernel 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. Ядра без контракта предоставляют только сырые небезопасные методы запуска, потому что голая LaunchConfig ничего не говорит о ядре, которое она запускает.
Путь Tile: cutile-rs
cutile-rs работает на уровень выше. Вы выполняете вычисления над плитками, а не скалярами. Каждый блок плиток запускает тело ядра один раз как один логический поток над одним подтензором данных, и компилятор решает, сколько реальных GPU-потоков его поддерживают. Макрос #[cutile::module] встраивает AST ядра в хост-бинарный файл и JIT-компилирует его через CUDA Tile IR (NVIDIA компилятор IR уровня плиток) когда ядро сначала понадобится.
Требования легче, чем на пути SIMT. Вам нужен GPU с compute capability 8.0 или выше, CUDA 13.3, стабильный Rust 1.89 или новее и Linux, но без nightly toolchain и без собственного 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-элементным куском и никакая другая плитка не может его трогать. Она фиксирует геометрию запуска, поскольку 1024, делённое на 128, даёт сетку из 8 плиток.
Сетка вытекает из разбиения вместо того, чтобы вычисляться отдельно и проверяться против индексирования ядра. Она также предоставляет B, который никогда не пишется на месте вызова, потому что запускатель читает ширину плиток от разбиения. Вот почему изменяемый вывод должен быть разбит перед тем, как его вообще можно передать.
Затем посмотрите, что возвращает запуск. 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 — ранняя альфа. cutile-rs дальше, опубликована на crates.io и уже используется вне NVIDIA в Grout inference engine HuggingFace и в mistral.rs. Покрытие неполно и API будут меняться. Где вы найдёте неровные края, мы хотим об этом услышать.
Cargo и crates создают ожидание, что начало работы легко. Программирование GPU исторически было противоположным, и сокращение этого расстояния — часть работы. Путь SIMT по-прежнему нуждается в закреплённом nightly toolchain, что именно то, о чём мы хотели бы перестать вас просить.
Rust на GPU не новый. Есть хорошая работа в этом пространстве, которая предшествует нашей и продолжается рядом с ней. Приложение экосистемы в книге cuda-oxide показывает, где мы находимся относительно Rust-GPU, rust-cuda, CubeCL и остальных, и мы работали с разработчиками rust-cuda, поскольку оба проекта созревают.
Что новый — это инженерия, которую мы в это вложили, и ясное понимание того, куда это идёт.
Что вы можете сделать сегодня
- Запустите пример SIMT.
cargo oxide new, затемcargo oxide run, в cuda-oxide. - Запустите пример Tile. Клонируйте cutile-rs, затем
cargo run -p cutile-examples --example hello_world. - Читайте документацию. Книга cuda-oxide и документация cuTile Rust.
- Читайте статью. Fearless Concurrency on the GPU.
- Сообщайте об ошибках. Скажите нам, что сломалось и что отсутствовало, в cuda-oxide или cutile-rs.
- Присоединяйтесь к беседе. GitHub Discussions на обоих репозиториях или cuda-oxide Discord.
- Приходите на доклад. Melih Elibol представляет «Fearless Concurrency on the GPU» на RustConf 2026, с 8 по 11 сентября в Монреале. NVIDIA будет иметь других сотрудников, так что приходите нас найти, если вы там!
Экспериментируйте с тем, что здесь, и приходите работать над этим с нами. Это рано, это открыто, и то, что вы создаёте сейчас, сформирует то, что будет дальше.
Сообщество Rust
NVIDIA рада сотрудничать с сообществом Rust, поднимая встроенное программирование GPU на Rust. Проекты, такие как rust-cuda, rust-gpu и cudarc, пионерили брак GPU и Rust, и люди за ними, включая команду в VectorWare, продолжают формировать то, как мы думаем о собственной работе, создавая с сообществом Rust.