Introductie van CUDA Rust: Twee paden voor het schrijven van GPU-kernels

De systeemlaag van AI omvat inference-engines, serving-infrastructuur, drivers en agent-runtimes. Deze laag verandert voortdurend naarmate modellen en technieken evolueren. Steeds meer van deze laag wordt geschreven in Rust, omdat deze taal hele klassen van bugs tijdens het compileren onderschept zonder in te leveren op prestaties.

NVIDIA volgt deze trend om dezelfde reden. De Nova Linux-driver is geschreven in Rust. NVIDIA Dynamo is gebouwd op een Rust-core. NVTX heeft Rust-bindings.

De GPU-kernel was tot nu toe de uitzondering. Je kunt kernels lanceren vanuit Rust, maar de kernel zelf moet vaak in een andere taal worden geschreven. NVIDIA CUDA Rust dicht dit gat. GPU-kernels kunnen nu in Rust worden geschreven en native worden gecompileerd naar PTX, in plaats van een wrapper te zijn rond code uit een andere bron.

Twee benaderingen: SIMT en Tile

Er zijn twee paden om Rust te gebruiken, passend bij de twee paden die CUDA zelf hanteert.

SIMT (Single Instruction, Multiple Threads) is het model dat je al kent van CUDA C++ of numba-cuda. Je geeft aan wat één thread doet en lanceert er vervolgens duizenden.

Tile is een nieuwer programmeermodel, dat ook beschikbaar is in C++ en Python. Al deze front-ends stellen je in staat om te definiëren wat één 'tile' van data doet, waarna de Tile IR-compiler de rest afhandelt.

Wanneer je kiest waar je mee begint, is het advies om eerst voor Tile te kiezen. De compiler bepaalt hoe tiles worden toegewezen aan elke architectuur, waardoor je broncode geen architectuurspecifieke keuzes bevat. Je stapt pas over op SIMT wanneer je die specifieke controle nodig hebt of zelf het geheugen en de threads wilt beheren.

De keuze voor de taal is een aparte vraag van de keuze voor het model. Gebruik de CUDA-implementatie die het beste past bij je huidige stack. De twee onderstaande projecten zijn bedoeld voor wanneer die stack Rust is. NVIDIA is van plan om inter-language interoperabiliteit te ondersteunen, zodat de keuze voor één front-end je niet uitsluit van andere ecosystemen.

Hieronder volgt hetzelfde kernel-voorbeeld voor beide paden, dat elementgewijze optelling uitvoert over 1.024 floats.

Het SIMT-pad: cuda-oxide

cuda-oxide is een aangepaste rustc codegen-backend. Het onderschept de compilatie en routeert #[kernel] functies via Rust MIR, het community-framework Pliron IR, en LLVM IR naar PTX. Alle overige code wordt afgehandeld door de standaard backend. De GPU-dialecten bovenop Pliron zijn ontwikkeld door NVIDIA. De dialecten en elke transformatie blijven in Rust totdat de standaard LLVM-backend het overneemt.

Vereisten:

  • Linux
  • Een GPU met compute capability 8.0 of hoger
  • Een CUDA toolkit (12.x of nieuwer)
  • Clang met libclang-headers
  • De gepinde nightly toolchain

Met cargo oxide doctor kun je controleren of aan alle eisen is voldaan, inclusief de optionele systeem-LLVM. Installeer cargo-oxide, de Cargo-subcommand die de build aanstuurt:

cargo +nightly-2026-04-03 install --git https://github.com/NVlabs/cuda-oxide.git cargo-oxide

Maak vervolgens een project aan en voer het uit. De template is een compleet vector-optellingsprogramma:

cargo oxide new vecadd_demo
cd vecadd_demo
cargo oxide doctor
cargo oxide run

De eerste cargo oxide run bouwt de codegen-backend, dus dit kan enige tijd duren. Latere runs maken gebruik van de cache. Het programma print: PASSED: all 1024 elements correct.

Dit is het volledige programma:

use cuda_device::{kernel, launch_bounds, launch_contract, thread, DisjointSlice};
use cuda_host::cuda_module;
use cuda_core::{CudaContext, DeviceBuffer, LaunchConfig1D};

// === DEVICE CODE - alles hierin wordt gecompileerd naar PTX ===
// De macro genereert ook de host-side API die verderop wordt gebruikt:
// `load`, `prepare_vecadd`, en de veilige `vecadd` launch-methode.
#[cuda_module]
mod kernels {
    use super::*;

    #[kernel] // GPU entry point
    #[launch_bounds(256)] // max threads per block; helpt de compiler bij register-budgettering
    #[launch_contract(domain = 1, block = (256, 1, 1))] // indexen 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(); // de gewone usize, voor het lezen van de 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, en 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: dit package bezit de embedded device bundle geproduceerd voor de kernels module hierboven.
    let module = unsafe { kernels::load(&ctx)? };

    // 4 blocks van 256 threads, 0 bytes dynamisch shared memory. `prepare_vecadd`
    // controleert dit tegen het contract hierboven en tegen de actuele device limieten.
    // De veilige `vecadd` hieronder neemt dat token waar een raw config zou staan.
    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 ===
    // Kopieert terug en synchroniseert, zodat de launch is voltooid voordat `c_host` gelezen kan worden.
    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- en device-code bevinden zich in één bestand, worden met één commando gebouwd en hebben geen aparte kernel-crate nodig.

De veiligheid zit in de kernel-signatuur. a en b zijn gewone shared slices, leesbaar door elke thread. c is een DisjointSlice<f32>, een type dat elke thread exclusieve toegang geeft tot zijn eigen element en niets anders. Dit is noodzakelijk omdat &mut [f32] niet geschikt is voor deze taak; elke thread zou dezelfde &mut nodig hebben, wat Rust weigert. DisjointSlice splitst die ene mutable borrow in per-thread stukken.

thread::index1d() retourneert een index-type, geen simpel integer, en c.getmut(idx) accepteert alleen dat type. Je krijgt een Option terug, waardoor de out-of-bounds case een branch is die je afhandelt in plaats van een geheugenfout die je later vindt.

De launch wordt gecontroleerd in plaats van blind vertrouwd. #[launchcontract] verklaart dat deze kernel indexeert in één dimensie met 256-thread blocks. preparevecadd valideert je LaunchConfig1D tegen die declaratie en de actuele device-limieten, en geeft een bewijs terug waar de veilige vecadd-methode om vraagt. Kernels zonder contract bieden alleen raw unsafe launch-methoden aan.

Het Tile-pad: cutile-rs

cutile-rs werkt een niveau hoger. Je voert berekeningen uit op tiles in plaats van op scalars. Elk tile-blok voert de kernel-body één keer uit als een enkele logische thread over een sub-tensor van data, en de compiler bepaalt hoeveel echte GPU-threads dit ondersteunen. De #[cutile::module] macro embedt de AST van de kernel in het host-binary en JIT-compileert deze via CUDA Tile IR wanneer de kernel voor het eerst nodig is.

De vereisten zijn lichter dan bij het SIMT-pad. Je hebt een GPU nodig met compute capability 8.0 of hoger, CUDA 13.3, stable Rust 1.89 of nieuwer, en Linux. Een nightly toolchain of eigen LLVM is niet nodig.

cutile is gepubliceerd op crates.io:

cargo new vecadd_demo
cd vecadd_demo
cargo add cutile

Hier is dezelfde elementgewijze optelling, geschreven voor tiles:

use cutile::prelude::*;

// De macro capturet de AST van deze module in het host-binary. De kernel wordt
// JIT-gecompileerd via CUDA Tile IR de eerste keer dat hij daadwerkelijk wordt gelanceerd.
#[cutile::module]
mod kernel {
    use cutile::core::*;

    #[cutile::entry()]
    fn add<const B: i32>(
        // B is de tile-breedte, een statische dimensie. Een andere B produceert een andere specialisatie.
        z: &mut Tensor<f32, { [B] }>, // exclusieve output, één sub-tensor van B elementen
        x: &Tensor<f32, { [-1] }>,    // shared input; -1 is een dynamische dimensie, opgelost bij launch
        y: &Tensor<f32, { [-1] }>,
    ) {
        // Deze body draait één keer per mut sub-tensor, als een enkele logische thread.
        // Tile-kernels laden tiles, geen scalars, van x en y.
        let tx = load_tile_like(x, z); // de slice van x die uitlijnt met deze sub-tensor van z
        let ty = load_tile_like(y, z);
        z.store(tx + ty); // elementgewijs over de hele tile
    }
}

fn main() -> Result<(), Error> {
    let device = Device::new(0)?;
    let stream = device.new_stream()?;

    // Deze zijn lazy. Er is nog niets aangeraakt op de GPU.
    let x = api::ones::<f32>(&[1024]);
    let y = api::ones::<f32>(&[1024]);

    // Partitionering doet drie dingen tegelijk: geeft elke tile exclusief
    // eigendom van zijn eigen 128-element chunk, stelt het grid vast op 1024/128 = 8 tiles,
    // en levert B.
    let z = api::zeros::<f32>(&[1024]).partition([128]);

    let c: Vec<f32> = kernel::add(z, x, y) // neemt eigendom van alle drie de tensors
        .first()                           // ...en geeft ze terug; kies de output eruit
        .unpartition()                     // verwijder de host-side partition wrapper; er verplaatst geen data
        .to_host_vec()                     // registreer de kopie terug
        .sync_on(&stream)?;                // en pas nu wordt alles uitgevoerd

    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(())
}

Het Tile-pad komt tot hetzelfde resultaat met stable Rust. De signatuur hanteert hetzelfde veiligheidsargument, maar zonder DisjointSlice. Partitionering op de host is alleen nodig voor mutable tensors; het geeft elk tile-blok één schrijfbare sub-tensor die niet overlapt met andere tile-blocks. Die exclusiviteit is precies wat &mut al garandeert.

De -1 in de input-shapes is een sentinel in plaats van een vaste maat. Die dimensie wordt bij de launch uit de tensor gelezen, zodat de shape kan variëren zonder dat er opnieuw gecompileerd hoeft te worden.

De belangrijkste regel aan de host-zijde is .partition([128]). Dit zorgt voor de feitelijke exclusiviteit: elke tile bezit zijn eigen chunk van 128 elementen. Het bepaalt ook de launch-geometrie (1.024 / 128 = grid van 8 tiles) en levert de waarde B aan de launcher. Daarom moet een &mut output gepartitioneerd worden voordat deze kan worden doorgegeven.

De add functie die je op de host aanroept is een door de macro gegenereerde launcher, niet de device-functie zelf. Deze neemt eigendom van alle drie de tensors en geeft ze als een tuple terug wanneer de GPU klaar is. Dat is waarom .first() wordt gebruikt om de output terug te halen.

Niets wordt uitgevoerd tot .sync_on(&stream). Alles daarvóór is een lazy beschrijving. Dit omvat de ones, de zeros, de kernel-aanroep en zelfs de kopie terug naar de host. Het hele programma is één keten met één enkel synchronisatiepunt.

Wat de compiler detecteert

Beide kernels maken dezelfde claim over het geheugen: de inputs zijn gedeeld, en de output behoort aan één schrijver alleen. Ze verschillen alleen in het niveau waarop deze claim wordt gemaakt.

Dit is cruciaal omdat duizenden threads dezelfde buffers benaderen in een onvoorspelbare volgorde. Wanneer twee threads hetzelfde adres raken en één ervan schrijft, bepaalt de volgorde het resultaat. Dergelijke bugs zijn zelden reproduceerbaar op verzoek en passeren vaak tests voordat ze in productie falen.

Het doorgeven van de output-buffer van de SIMT-kernel als een van de inputs ervan compileert niet:

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

Dezelfde aliasing aan de Tile-zijde compileert evenmin:

let z = api::zeros::<f32>(&[1024]);
kernel::add(z.partition([128]), z, y)
// error[E0382]: use of moved value: `z`

Beide voorbeelden vangen de klassieke aliasing-fout op tijdens het compileren. cuda-oxide controleert elke launch-aanroep; cutile-rs volgt het eigendom van de tensors over de launch-boundary heen, wat een sterkere garantie biedt.

Tile voorkomt dat je fouten maakt met shared memory of thread-indexing, omdat de compiler deze beheert. Een tile-block is een enkele logische thread, dus er zijn geen threads die kunnen racen. Dit maakt het "safe by construction", maar je levert hiermee controle in. SIMT behoudt die controle, maar op dit moment vereist shared memory daar nog unsafe. Het veilig maken van dat pad is een actueel werkpunt.

Huidige status van de projecten

Beide projecten bevinden zich in een vroeg stadium en zijn nog niet klaar voor productie. cuda-oxide is in early alpha. cutile-rs is verder gevorderd, is gepubliceerd op crates.io en wordt al buiten NVIDIA gebruikt in de Grout inference-engine van HuggingFace en in mistral.rs. De dekking is onvolledig en API's zullen veranderen.

GPU-programmering was historisch gezien complex om mee te beginnen; het verkleinen van die afstand is een belangrijk doel. Het SIMT-pad vereist nog steeds een gepinde nightly toolchain, iets wat NVIDIA in de toekomst wil elimineren.

Rust op GPU's is niet nieuw. Er is goed werk verricht door projecten zoals rust-cuda, rust-gpu, cudarc en CubeCL. NVIDIA werkt samen met de maintainers van rust-cuda terwijl beide projecten volwassen worden. Wat nieuw is, is de engineering die NVIDIA erachter zet en de duidelijke visie op de toekomst.

Wat je vandaag kunt doen

  • Run het SIMT-voorbeeld: Gebruik cargo oxide new, gevolgd door cargo oxide run in cuda-oxide.
  • Run het Tile-voorbeeld: Clone cutile-rs en voer uit: cargo run -p cutile-examples --example hello_world.
  • Lees de documentatie: Raadpleeg het cuda-oxide boek en de cuTile Rust documentatie.
  • Lees het paper: "Fearless Concurrency on the GPU".
  • Meld issues: Geef aan wat er kapot is of wat er mist in cuda-oxide of cutile-rs.
  • Neem deel aan de discussie: Via GitHub Discussions op beide repositories of de cuda-oxide Discord.
  • Bezoek de presentatie: Melih Elibol presenteert “Fearless Concurrency on the GPU” op RustConf 2026 (8 t/m 11 september in Montréal).