Introduciendo CUDA Rust: Dos vías para escribir kernels de GPU
En septiembre de 2026, NVIDIA anunció que está apostando firmemente por la programación nativa de GPUs en Rust. Mientras que CUDA C++ y CUDA Python son toolchains maduros de nivel empresarial, NVIDIA continuará desarrollando y fortaleciendo CUDA Rust durante 2027 y años posteriores.
La capa de sistemas de la inteligencia artificial abarca motores de inferencia, infraestructura de servicios, controladores y entornos de ejecución de agentes, los cuales cambian constantemente a medida que evolucionan los modelos y las técnicas. Gran parte de esta capa se escribe actualmente en Rust, lenguaje que permite detectar clases enteras de errores en tiempo de compilación sin sacrificar el rendimiento.
NVIDIA es parte de este cambio por la misma razón. El controlador Nova Linux está escrito en Rust. NVIDIA Dynamo está construido sobre un núcleo en Rust. NVTX también cuenta con bindings para Rust.
El kernel de la GPU es la excepción. Aunque es posible lanzar kernels desde Rust, el kernel en sí mismo a menudo debe escribirse en otro lenguaje. NVIDIA CUDA Rust cierra esa brecha. Los kernels de GPU ahora pueden escribirse en Rust y compilarse de forma nativa a PTX, en lugar de utilizar un wrapper sobre código escrito en otro lenguaje.
Existen dos vías para utilizar Rust, que coinciden con las dos vías que ofrece el propio CUDA. SIMT es el modelo que ya se utiliza en CUDA C++ o numba-cuda. En este modelo, se indica lo que hace un solo hilo y se lanzan miles de ellos. Tile es un modelo de programación más reciente, también disponible en C++ y Python. Todos estos frontends permiten definir lo que hace un tile (mosaico) de datos, mientras que el compilador Tile IR se encarga del resto.
Al elegir una opción para desarrollar, se recomienda comenzar por Tile. El compilador decide cómo se asignan los tiles a cada arquitectura, por lo que el código fuente no contiene decisiones específicas de la arquitectura; se puede recurrir a SIMT cuando se necesite ese nivel de control o cuando se desee gestionar la memoria y los hilos manualmente.
La elección del lenguaje es una cuestión distinta a la del modelo. Se debe utilizar la exposición a CUDA que mejor se adapte al stack tecnológico existente. Los dos proyectos mencionados a continuación están diseñados para cuando ese stack es Rust. Existe el plan de soportar interoperabilidad entre lenguajes, por lo que la elección actual no impedirá el acceso a otras herramientas.
A continuación, se presenta el mismo kernel en cada vía, el cual realiza una suma de elementos sobre 1.024 números de punto flotante (floats). Ambos son programas completos, ejecutables y generan el mismo resultado, lo que permite comparar las diferencias lado a lado.
La vía SIMT: cuda-oxide
cuda-oxide es un backend personalizado de generación de código para rustc. Intercepta la compilación, dirige las funciones marcadas con #[kernel] a través de Rust MIR, el framework comunitario Pliron IR y LLVM IR hasta llegar a PTX, delegando el resto al backend estándar. Los dialectos de GPU sobre Pliron son propios de NVIDIA. Los dialectos y cada transformación permanecen en Rust hasta que el backend estándar de LLVM toma el control.
Para utilizarlo, se requiere Linux, una GPU con capacidad de cómputo 8.0 o superior, un kit de herramientas CUDA (12.x o más reciente), clang con sus cabeceras libclang y el toolchain nocturno (nightly) fijado. cargo oxide doctor verifica todos estos componentes, incluyendo el LLVM del sistema opcional. Instale cargo-oxide, el subcomando de Cargo que gestiona la compilación:
cargo +nightly-2026-04-03 install --git https://github.com/NVlabs/cuda-oxide.git cargo-oxideLuego, cree un proyecto y ejecútelo. La plantilla es un programa completo de suma de vectores:
cargo oxide new vecadd_demo
cd vecadd_demo
cargo oxide doctor
cargo oxide runLa primera ejecución de cargo oxide run compila el backend de generación de código, por lo que puede tomar tiempo. Las ejecuciones posteriores reutilizan la caché.
El programa imprime PASSED: all 1024 elements correct. Este es el código completo que realiza la operación, tal como lo genera cargo oxide new, con comentarios añadidos:
use cuda_device::{kernel, launch_bounds, launch_contract, thread, DisjointSlice};
use cuda_host::cuda_module;
use cuda_core::{CudaContext, DeviceBuffer, LaunchConfig1D};
// === CÓDIGO DE DISPOSITIVO - compilado a PTX ===
// El macro también genera la API de host utilizada más abajo:
// `load`, `prepare_vecadd`, y el método de lanzamiento seguro `vecadd`.
#[cuda_module]
mod kernels {
use super::*;
#[kernel] // Punto de entrada de la GPU
#[launch_bounds(256)] // hilos máximos por bloque; permite al compilador ajustar registros
#[launch_contract(domain = 1, block = (256, 1, 1))] // índices en 1-D, bloques de 256 hilos
pub fn vecadd(a: &[f32], b: &[f32], mut c: DisjointSlice<f32>) {
let idx = thread::index_1d();
let idx_raw = idx.get(); // el usize plano, para leer las entradas
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>> {
// === CONFIGURACIÓN DE HOST - dispositivo, stream y 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)?;
// === CARGA, PREPARACIÓN, LANZAMIENTO ===
// SAFETY: este paquete posee el bundle de dispositivo embebido producido para el
// módulo de kernels de arriba.
let module = unsafe { kernels::load(&ctx)? };
// 4 bloques de 256 hilos, 0 bytes de memoria compartida dinámica.
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)?;
// === LECTURA Y VERIFICACIÓN ===
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(())
}El código de host y de dispositivo conviven en un mismo archivo, se compilan con un solo comando y no requieren un crate de kernel separado.
La firma del kernel es fundamental, ya que conlleva el argumento de seguridad. a y b son slices compartidos ordinarios, legibles por cada hilo. c es un DisjointSlice<f32>, un tipo que otorga a cada hilo acceso exclusivo a su propio elemento. Esto existe porque &mut [f32] no es la forma correcta para esta tarea; cada hilo necesitaría el mismo &mut, lo cual Rust rechaza correctamente. DisjointSlice divide ese préstamo mutable en partes por hilo.
thread::index_1d() devuelve un tipo de índice, no un entero simple, y c.get_mut(idx) solo acepta ese tipo. Se devuelve un Option, por lo que el caso fuera de límites es una bifurcación que se maneja en lugar de un error de memoria.
El lanzamiento se verifica en lugar de confiar ciegamente. #[launch_contract] declara que este kernel indexa en una dimensión con bloques de 256 hilos. prepare_vecadd valida la configuración de lanzamiento contra esa declaración y los límites del dispositivo, entregando una prueba que requiere el método vecadd seguro.
La vía Tile: cutile-rs
cutile-rs funciona un nivel más arriba. Se realizan cálculos en tiles en lugar de escalares. Cada bloque de tiles ejecuta el cuerpo del kernel una vez como un hilo lógico sobre un subtensor de datos, y el compilador decide cuántos hilos de GPU reales lo respaldan.
Vía: NVIDIA Developer.




