On September 8, 2026, NVIDIA published a technical blog post that introduced CUDA Rust, a pair of projects that let developers write GPU kernels in Rust and compile them natively to PTX. The first, cuda-oxide, is a custom rustc codegen backend that routes kernel functions through Rust MIR, the Pliron IR framework and LLVM before handing everything else to the standard backend. The second, cutile-rs, is published on crates.io, runs on stable Rust 1.89 or newer, and JIT-compiles tile-based kernels through CUDA Tile IR. Neither project is production-ready, and NVIDIA describes both as work that will keep maturing into 2027 and beyond.
The interesting part is not the syntax. It is where each track draws the line between what the compiler proves and what the programmer still has to prove by hand.
What changed in September 2026
The systems layer of AI has been moving toward Rust for a while. The Nova Linux driver is written in Rust, NVIDIA Dynamo is built on a Rust core, and NVTX has Rust bindings. The GPU kernel stayed the exception. Kernels could be launched from Rust, but the kernel body itself usually had to be written in another language. CUDA Rust closes that gap by making the kernel a Rust compilation target rather than a wrapper around code produced somewhere else.
Two tracks match the two programming models CUDA already exposes. SIMT is the model used in CUDA C++ and numba-cuda: the programmer states what one thread does and launches thousands of them. Tile is the newer model, also available in C++ and Python, where the programmer states what one tile of data does and the compiler decides how that maps onto the hardware. cuda-oxide is the SIMT track. cutile-rs is the Tile track.
NVIDIA's advice on picking between the two models is explicit. Reach for Tile first, because the compiler decides how tiles map onto each architecture and the source does not encode architecture-specific choices. Drop to SIMT when that control is needed, or when the kernel wants to manage memory and threads directly.
Both projects are early. cuda-oxide is early alpha. cutile-rs is further along, published on crates.io and already used outside NVIDIA in the Grout inference engine at Hugging Face and in mistral.rs. Coverage is incomplete in both, and APIs will move. NVIDIA also says it plans to support inter-language interop between CUDA Rust, CUDA C++ and CUDA Python, so picking one frontend does not cut a team off from the others.
The SIMT track: one thread at a time
cuda-oxide is a custom rustc codegen backend. It intercepts compilation, routes functions marked as kernels through Rust MIR, Pliron and LLVM IR down to PTX, and hands everything else to the standard backend. The GPU dialects layered on top of Pliron are NVIDIA's own. Host and device code live in one file, build with one command, and need no separate kernel crate.
The safety argument lives in the kernel signature. A mutable slice is the wrong shape for an output buffer, because every thread would need the same mutable borrow and the borrow checker refuses that. cuda-oxide uses a type called DisjointSlice<f32> instead, which splits one mutable borrow into per-thread pieces, giving each thread exclusive access to its own element and nothing else. The index returned by thread::index_1d() is not a bare integer, and get_mut only accepts that index type. It hands back an Option, so the out-of-bounds case becomes a branch the kernel handles rather than a memory error discovered later.
Here is the vector addition kernel from the announcement, attributes included:
use cuda_device::{kernel, launch_bounds, launch_contract, thread, DisjointSlice};
#[kernel]
#[launch_bounds(256)]
#[launch_contract(domain = 1, block = (256, 1, 1))]
pub fn vecadd(a: &[f32], b: &[f32], mut c: DisjointSlice<f32>) {
let idx = thread::index_1d();
let idx_raw = idx.get();
if let Some(c_elem) = c.get_mut(idx) {
*c_elem = a[idx_raw] + b[idx_raw];
}
}
#[launch_bounds(256)] tells the compiler how many threads per block to budget registers for. #[launch_contract] declares that this kernel indexes in one dimension with 256-thread blocks. The inputs a and b are ordinary shared slices that every thread can read. The output c is the exclusive piece.
The host side has to satisfy that declaration rather than assert it. The launcher validates the requested geometry against the contract and against the live device limits, and the safe launch method only accepts the token that validation returns:
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)?;
A kernel without a contract exposes only raw unsafe launch methods, because a bare launch configuration says nothing about the kernel it is launching. The declaration and the check are what turn a launch from a convention into something the toolchain can reject.
The Tile track: one logical thread per tile
cutile-rs works one level higher. Each tile block runs the kernel body once, as a single logical thread over one sub-tensor of data, and the compiler decides how many real GPU threads back that logical thread. The #[cutile::module] macro embeds the kernel AST in the host binary and JIT-compiles it through CUDA Tile IR the first time the kernel is actually launched.
The exclusivity mechanism is different. There is no DisjointSlice in this track. Mutable tensors are partitioned on the host before launch, and each tile block receives one writable sub-tensor that no other tile block can overlap. That is what a mutable reference already guarantees in Rust, extended across the GPU launch boundary.
The same elementwise addition, written for tiles, looks like this:
use cutile::prelude::*;
#[cutile::module]
mod kernel {
use cutile::core::*;
#[cutile::entry()]
fn add<const B: i32>(
z: &mut Tensor<f32, { [B] }>,
x: &Tensor<f32, { [-1] }>,
y: &Tensor<f32, { [-1] }>,
) {
let tx = load_tile_like(x, z);
let ty = load_tile_like(y, z);
z.store(tx + ty);
}
}
The body loads the tile of each input that lines up with the output sub-tensor, adds them, and stores the result across the whole tile. The minus one in the input shapes is a sentinel rather than a size: that dimension is read off the tensor at launch, so the shape can vary without recompiling. The kernel body runs once per sub-tensor, and there are no thread indices to compute.
The host side is where the geometry is fixed, and one call does three jobs at once:
let z = api::zeros::<f32>(&[1024]).partition([128]);
let c: Vec<f32> = kernel::add(z, x, y)
.first()
.unpartition()
.to_host_vec()
.sync_on(&stream)?;
.partition([128]) gives each tile exclusive ownership of a 128-element chunk, fixes the grid at 1024 divided by 128, which is 8 tiles, and supplies the const generic tile width that never appears at the call site. Nothing runs until .sync_on(&stream). Everything before it, including the allocations, the kernel call and the copy back to the host, is a lazy description recorded rather than submitted, which is why the whole program is one chain with a single synchronization point.
Where the two tracks actually differ
The difference is not only syntax. It is the level at which the safety contract is expressed, and how much of the execution model the programmer keeps.
In cuda-oxide, the programmer thinks in threads. The kernel body describes one thread, and the launch contract describes how many threads exist and how they are organized. Shared memory, barriers, atomics and warp-level operations stay available as explicit primitives. That control is what makes the SIMT track usable for kernels with unusual access patterns or hardware-specific tricks, and it is also what keeps the shared memory path on unsafe code today. Shared memory is the bedrock of fast SIMT kernels, and making that path safe is described as active work.
In cutile-rs, the programmer thinks in tiles. The kernel body describes operations on blocks of data, and the compiler decides how those operations map onto threads, shared memory and hardware resources. There is no thread index and no explicit shared memory allocation in the source. A tile block is a single logical thread, so there are no threads for the programmer to race. The trade is less control over scheduling, and in exchange, less opportunity to get the scheduling wrong.
Both tracks catch the aliasing mistake that is hardest to debug: passing an output buffer as one of its own inputs. They draw the line in different places. cuda-oxide checks each launch call. cutile-rs follows ownership of the tensors across the launch boundary, which is the stronger of the two claims.
What the compiler catches, and what it cannot
The guarantees are real but bounded. Both toolchains reject aliasing and ownership violations before the kernel runs, and cuda-oxide turns an out-of-bounds index into an Option the kernel must handle. Neither toolchain catches a kernel that reads the wrong input, performs an incorrect reduction, or walks memory in an order that destroys performance. Memory safety and data-race freedom are not computational correctness.
The aliasing rejections look like this in practice. Passing the output buffer as one of its own inputs does not compile, whether or not that kernel would actually race:
error[E0502]: cannot borrow `c_dev` as mutable because it is also borrowed as immutable
The same mistake on the Tile side does not compile either, because the tensor has already moved:
error[E0382]: use of moved value: `z`
Neither error depends on a runtime check or a race detector. The first is the borrow checker looking at one call site. The second is ownership that survives the launch boundary, which is why the Tile track can make the claim without a purpose-built type.
Reading the two examples side by side
The two kernels above compute the same thing for the same input, and both print the same line when the host side is attached. The SIMT version processes one element per thread, reads the index from a helper that returns an index type, and writes through a DisjointSlice. The Tile version processes one tile per block, takes the tile width as a const generic parameter, and writes through a mutable tensor reference.
The signatures carry the difference. The SIMT kernel takes two shared slices and one exclusive slice-like type. The Tile kernel takes two shared tensors with a dynamic dimension and one mutable tensor whose static width is the partition size. The SIMT kernel needs a launch contract so the host side can validate the geometry. The Tile kernel needs a host-side partition so exclusivity is established and the grid follows from it.
Where cuda-oxide makes the programmer state the launch geometry and then checks it, cutile-rs derives the geometry from the data layout and removes the geometry from the source entirely. That is the same trade as before, restated at the level of the signature, and it is the clearest way to see what each track is optimizing for.
Trying it today: requirements and rough edges
Both tracks need Linux and a GPU with compute capability 8.0 or later. The similarity ends there. cuda-oxide also needs a CUDA toolkit of version 12.x or newer, clang with its libclang headers, and the pinned nightly toolchain. Installing the driver subcommand and scaffolding a project looks like this:
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
The first run builds the codegen backend, so it takes a while, and later runs reuse the cache. cargo oxide doctor checks the whole environment, including an optional system LLVM.
cutile-rs asks for less. It needs stable Rust 1.89 or newer, CUDA 13.3, and a GPU of the same compute capability, with no nightly toolchain and no LLVM of the programmer's own. It is on crates.io, so there is nothing to clone:
cargo new vecadd_demo
cd vecadd_demo
cargo add cutile
Adoption follows the same split. cutile-rs is already used outside NVIDIA, while cuda-oxide remains early alpha. The pinned nightly in the SIMT track is the kind of requirement NVIDIA says it would like to stop asking for, and until then the toolchain moves on NVIDIA's schedule rather than the team's.
How to choose a track for a real workload
The choice is about where complexity should live. Tile programming is easier to write and safe by construction for elementwise work, reductions and matrix operations, because the compiler owns thread mapping and memory layout, and the partition removes aliasing without asking the programmer to reason about thread indices. SIMT programming keeps control for kernels that need specific thread coordination, custom shared memory, or access patterns that the tile compiler cannot express.
The heuristic NVIDIA offers is short: find the Tile track first, use it while the operations map naturally to tiles, and reach for SIMT when control over memory and threads is needed. That ordering also matches maturity. The Tile track is the one with a published crate and stable Rust support, so it is the reasonable place to prototype, and the SIMT track is where a team lands when the prototype shows that the compiler's scheduling decisions are the bottleneck.
What this means for existing CUDA C++ and Python stacks
CUDA Rust does not ask for a rewrite. The tracks are additive, and the stated plan for inter-language interop means a Rust kernel can sit next to C++ and Python kernels rather than replacing them. A team with a working CUDA C++ codebase can move one hot kernel at a time and keep the rest of the pipeline where it is.
The nearer problem is churn. cuda-oxide pins a nightly toolchain, which means the toolchain updates on NVIDIA's schedule, and both projects are early enough that APIs will move between releases. Coverage is incomplete, so a kernel that depends on a specific hardware feature may not have a safe path yet. The honest reading of the announcement is that the destination is clear and the road is not finished.
What the announcement does settle is where the ownership guarantees stop. For years, Rust on the CPU could prove that two writers never touched the same memory at the same time, and the same program had to hand its GPU work to another language and lose that proof at the boundary. The SIMT track restores it with a purpose-built type that splits a mutable borrow across threads. The Tile track restores it by partitioning the output before the launch, so exclusivity is established on the host and carried into the kernel. Either way, the aliasing mistakes that used to surface as flaky production failures become compile errors or rejected launches, and what remains for the programmer is the part that was always a human responsibility, which is getting the computation right.
Originally published on Dispatch.
Top comments (0)