Data Center / Cloud

CUDA Rust 소개: GPU 커널 작성을 위한 두 가지 트랙

Reading Time: 7 minutes

2026년 9월, NVIDIA는 네이티브 GPU 프로그래밍 언어로 Rust를 채택한다고 발표했습니다. CUDA C++와 CUDA Python이 성숙한 엔터프라이즈급 툴체인으로 자리 잡은 가운데, NVIDIA는 CUDA Rust를 2027년 이후까지 지속적으로 성장시키고 고도화할 계획입니다.

추론 엔진, 서빙 인프라, 드라이버, 에이전트 런타임에 이르는 AI 시스템 레이어는 모델과 기법의 변화에 따라 끊임없이 진화하고 있습니다. 이 시스템 레이어의 점증하는 영역이 Rust로 작성되고 있으며, 이를 통해 성능 저하 없이 컴파일 타임에 수많은 유형의 버그를 포착하고 있습니다.

NVIDIA가 이러한 전환에 동참하는 이유도 같습니다. Nova Linux 드라이버는 Rust로 작성되었습니다. NVIDIA Dynamo 역시 Rust 코어를 기반으로 구축되었습니다. NVTX 또한 Rust 바인딩을 제공합니다.

GPU 커널은 예외적이었습니다. Rust에서 커널을 호출할 수는 있었지만, 커널 자체는 다른 언어로 작성해야 하는 경우가 많았습니다.

NVIDIA CUDA Rust는 이러한 격차를 해소합니다. 이제 다른 언어로 작성된 코드를 래핑하는 대신, GPU 커널을 Rust로 작성하고 PTX로 직접 네이티브 컴파일할 수 있습니다.

CUDA 자체에 두 가지 경로가 있는 것처럼 Rust를 사용하는 데에도 두 가지 트랙이 존재합니다. SIMT는 CUDA C++나 numba-cuda에서 이미 작성해 온 모델입니다. 하나의 스레드가 수행할 작업을 지정하고 이를 수천 개 실행합니다. 타일(Tile)C++Python에서도 사용할 수 있는 비교적 최신의 프로그래밍 모델입니다. 이러한 프론트엔드를 통해 데이터의 한 타일이 수행할 작업을 정의하면 나머지는 Tile IR 컴파일러가 처리합니다.

구축을 위한 모델을 선택할 때는 타일을 먼저 고려하십시오. 컴파일러가 각 아키텍처에 타일을 매핑하는 방식을 결정하므로 소스 코드에 아키텍처 특화적 선택을 직접 인코딩할 필요가 없으며, 더 세밀한 제어가 필요하거나 메모리와 스레드를 직접 관리하고자 할 때 SIMT로 전환할 수 있습니다.

어떤 모델을 선택할지와 어떤 언어를 선택할지는 별개의 문제입니다. 현재 보유한 스택에 가장 잘 맞는 CUDA 접근 방식을 활용하십시오. 아래 소개하는 두 프로젝트는 해당 스택이 Rust일 때를 위한 것입니다. NVIDIA는 언어 간 상호 운용성을 지원할 계획이므로, 특정 언어 선택이 다른 선택을 제한하지는 않습니다.

아래는 각 트랙에서 1,024개 32비트 부동소수점(float)의 요소별(Elementwise) 덧셈을 수행하는 동일한 커널 코드입니다. 두 코드 모두 작동하는 완전한 프로그램이며 동일한 결과를 출력하므로, 나란히 비교하며 변경 사항을 확인할 수 있습니다.

SIMT 트랙: cuda-oxide

cuda-oxide는 커스텀 rustc 커스텀 코드 생성(Codegen) 백엔드입니다. 컴파일 과정을 중간에서 가로채 #[kernel] 함수를 Rust MIR, 커뮤니티 Pliron IR 프레임워크, LLVM IR을 거쳐 PTX로 전달하고, 나머지 모든 과정은 표준 백엔드에 위임합니다. Pliron 상단의 GPU 다이얼렉트(Dialect)는 NVIDIA가 직접 구축했습니다. 모든 다이얼렉트와 변환 작업은 표준 LLVM 백엔드가 인계받을 때까지 Rust 내부에서 유지됩니다.

실행 환경으로 Linux, 컴퓨팅 아키텍처 8.0 이상의 GPU, CUDA 툴킷(12.x 이상), libclang 헤더를 포함한 Clang, 고정된 Nightly 툴체인이 필요합니다. 빌드를 제어하는 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 문구가 출력됩니다. 아래는 주석을 추가한 전체 코드입니다:

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

// === 디바이스 코드 - 이 블록 내부의 모든 코드는 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>> {
    // === 호스트 설정 - 디바이스, 스트림, 버퍼 ===
    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)?;

    // === 로드, 준비, 실행 ===
    // 안전성: 이 패키지는 상단의 kernels 모듈용으로 생성되어 임베디드된 디바이스 번들의 소유권을 갖습니다.
    let module = unsafe { kernels::load(&ctx)? };

    // 256개 스레드로 구성된 4개 블록, 0바이트 동적 공유 메모리.
    // `prepare_vecadd`는 지정된 계약 및 실제 디바이스 제한 사항과 비교하여 검증합니다.
    // 하단의 안전한 `vecadd`는 원시(Raw) 구성 대신 해당 토큰을 전달받습니다.
    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)?;

    // === 결과 읽기 및 검증 ===
    // 메모리를 복사하고 동기화하므로, `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(())
}

호스트 코드와 디바이스 코드가 하나의 파일에 위치하며 단 하나의 명령어만으로 빌드되므로, 별도의 커널 크레이트(Crate)를 구성할 필요가 없습니다.

먼저 안전성 인자를 포함하는 커널 시그니처를 확인하십시오. ab는 일반적인 공유 슬라이스(Shared slice)로 모든 스레드에서 읽을 수 있습니다. cDisjointSlice<f32> 타입으로, 각 스레드에 자체 요소에 대한 독점적인 접근 권한을 부여하고 그 외의 요소에는 접근할 수 없도록 제약합니다. &mut [f32] 형태는 이러한 작업에 적합하지 않은 구조이기 때문에 이 타입이 사용됩니다. &mut 형태를 사용하면 모든 스레드가 동일한 가변 참조자를 요구하게 되므로 Rust의 소유권 규격에 의해 거부됩니다. DisjointSlice는 하나의 가변 빌림(Mutable borrow)을 스레드별 조각으로 분할하여 안전성을 보장합니다.

thread::index_1d()는 단순 정수가 아닌 인덱스 타입을 반환하며, c.get_mut(idx)는 해당 인덱스 타입만을 인자로 수용합니다. 반환값으로 Option을 제공하므로, 범위를 벗어난 접근(Out-of-bounds) 문제를 추후 발견되는 메모리 오류가 아닌 명시적인 분기 로직으로 처리할 수 있습니다.

커널 실행은 신뢰 기반이 아닌 검증 방식으로 이루어집니다. #[launch_contract] 선언은 해당 커널이 256개 스레드 블록을 사용하여 1차원으로 인덱싱됨을 명시합니다. prepare_vecadd 메서드는 선언된 계약 및 실제 디바이스 제약에 맞춰 LaunchConfig1D의 유효성을 검증하고 검증 증명을 반환하며, 안전한 vecadd 메서드는 이 토큰을 요구합니다. 계약이 지정되지 않은 커널은 LaunchConfig만으로 커널 내부 정보를 검증할 수 없으므로 unsafe 키워드가 적용된 원시 실행 메서드만 제공합니다.

타일 트랙: cutile-rs

cutile-rs는 한 단계 더 높은 추상화 수준에서 작동합니다. 스칼라(Scalar) 대신 타일(Tile) 단위로 연산을 수행합니다. 각 타일 블록은 데이터의 하위 텐서(Sub-tensor) 하나에 대해 커널 본문을 단일 논리 스레드로 한 번 실행하며, 이를 뒷받침할 실제 GPU 스레드의 개수는 컴파일러가 결정합니다. #[cutile::module] 매크로는 커널의 AST를 호스트 바이너리에 임베딩하고, 커널이 처음 호출될 때 NVIDIA의 타일 레벨 컴파일러 IR인 CUDA Tile IR을 통해 JIT 컴파일을 진행합니다.

요구사항은 SIMT 트랙보다 가볍습니다. 컴퓨팅 아키텍처 8.0 이상의 GPU, CUDA 13.3, Rust 1.89 이상의 Stable 버전, 그리고 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 값이 다르면
        // 서로 다른 특성화(Specialization) 코드가 생성됩니다.
        z: &mut Tensor<f32, { [B] }>, // 독점 출력, B개 요소로 구성된 하위 텐서 1개
        x: &Tensor<f32, { [-1] }>,    // 공유 입력; -1은 실행 시 결정되는 동적 차원
        y: &Tensor<f32, { [-1] }>,
    ) {
        // 이 본문은 mut 하위 텐서당 단일 논리 스레드로 한 번씩 실행됩니다.
        // 타일 커널은 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()?;

    // 지연 평가(Lazy) 방식으로 동작합니다. 아직 GPU 작업은 실행되지 않았습니다.
    let x = api::ones::<f32>(&[1024]);
    let y = api::ones::<f32>(&[1024]);

    // 파티셔닝(Partitioning)은 세 가지 역할을 동시에 수행합니다: 각 타일에 128개 요소 덩어리의 독점 소유권 부여,
    // 그리드를 1024/128 = 8개 타일로 고정, 그리고 B 값 공급.
    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)?;               // 이 시점에 비로소 실제 작업이 실행됩니다.

    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

타일 트랙은 Rust Stable 버전에서 동일한 결과에 도달하며, 해당 시그니처 역시 동일한 안전성 인자를 제시합니다. 이번에는 DisjointSlice가 사용되지 않습니다. 호스트에서의 파티셔닝은 가변(Mutable) 텐서에만 필요하며, 각 타일 블록에 다른 타일 블록이 겹칠 수 없는 하나의 쓰기 가능한 하위 텐서를 부여합니다. 이러한 독점성은 Rust의 &mut가 이미 보장하는 속성입니다.

입력 형상의 -1은 고정 크기가 아닌 센티널(Sentinel) 값입니다. 해당 차원은 실행 시 텐서에서 직접 읽어 들으므로, 재컴파일 없이도 형상을 자유롭게 변경할 수 있습니다.

호스트 측에서 가장 눈여겨볼 구문은 .partition([128])이며, 이는 세 가지 역할을 동시에 수행합니다. 독점성을 실체화하여 각 타일이 128개 요소 덩어리를 소유하고 다른 타일이 이에 접근할 수 없도록 합니다. 또한 1,024를 128로 나눈 8개 타일 그리드로 실행 형상(Launch geometry)을 고정합니다.

그리드는 별도로 계산되어 커널 인덱싱과 비교 검증되는 것이 아니라 파티션으로부터 자동으로 결정됩니다. 아울러 실행기가 파티션에서 타일 너비를 읽어 들기 때문에 호출 위치에서 명시적으로 쓰지 않아도 B 값을 공급해 줍니다. 이것이 &mut 출력을 전달하기 전 반드시 파티셔닝해야 하는 이유입니다.

다음으로 커널 실행이 반환하는 결과를 확인해 보십시오. 호스트에서 호출하는 add는 상단의 디바이스 함수가 아니라 매크로가 생성한 실행기(Launcher)입니다. 세 텐서의 소유권을 가져와 GPU 작업이 완료되면 튜플 형태로 다시 반환합니다. .first()는 이 결과에서 출력 텐서를 다시 추출하기 위해 사용됩니다.

.sync_on(&stream)이 호출되기 전까지는 아무것도 실행되지 않습니다. 그 이전의 모든 과정은 지연 처리되는 설명에 불과하며, 실제 제출이 아닌 기록 단계입니다. 여기에는 ones, zeros, 커널 호출, 호스트로의 데이터 복사까지 포함됩니다. 전체 프로그램은 단 하나의 동기화 지점을 갖는 하나의 체인으로 구성됩니다.

컴파일러가 포착하는 오류

두 커널은 메모리에 대해 동일한 주장을 합니다. 입력은 공유되고, 출력은 단 하나의 작성자에게만 귀속된다는 점입니다. 차이점은 이러한 주장을 수행하는 추론 수준과, 명시적 목적의 전용 타입이 있어야만 주장이 성립하는지 여부뿐입니다.

수천 개의 스레드가 보장되지 않은 순서로 동일한 버퍼에 접근하기 때문에 이는 매우 중요합니다. 두 스레드가 동일한 주소에 접근하고 그중 하나가 쓰기 작업을 수행할 때 연산 순서가 결과를 결정짓습니다. 이러한 버퍼 관련 버그는 필요할 때 재현되는 경우가 드물며, 테스트를 통과하더라도 프로덕션 환경에서 오류를 일으키곤 합니다.

SIMT 커널의 출력 버퍼를 동일 커널의 입력 중 하나로 전달하는 코드는 실제로 경쟁 상태(Race condition)가 발생하는지와 무관하게 컴파일되지 않습니다:

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

타일 측에서의 동일한 앨리어싱(Aliasing) 시도 역시 컴파일되지 않습니다:

let z = api::zeros::<f32>(&[1024]);
kernel::add(z.partition([128]), z, y)

error[E0382]: use of moved value: `z`

두 예시 모두 전형적인 앨리어싱 실수를 컴파일 타임에 포착하며, 경계를 설정하는 위치에서 차이를 보입니다. cuda-oxide는 각 실행 호출 시점을 검증합니다. cutile-rs의 소유권은 실행 경계를 넘어 텐서를 따라 이동하므로 두 방식 중 더 강한 검증 주장을 제공합니다.

타일 트랙은 컴파일러가 공유 메모리와 스레드 인덱싱을 모두 소유하므로 사용자가 이를 실수할 여지 자체가 없습니다. 타일 블록은 단일 논리 스레드이므로 경쟁을 일으킬 스레드가 존재하지 않습니다. 이것이 구조적으로 안전함(Safe by construction)을 만들어 내는 핵심이자, 제어권과의 트레이드오프 관계입니다. SIMT는 그러한 제어권을 유지하며, 현재 해당 경로의 공유 메모리는 unsafe를 필요로 합니다. 공유 메모리는 고속 SIMT 커널의 기반이므로 이 경로를 안전하게 만드는 작업이 활발히 진행 중입니다.

프로젝트의 현재 단계

두 프로젝트 모두 초기 단계이며 프로덕션에 즉시 적용할 수 있는 상태는 아닙니다. cuda-oxide는 초기 알파 단계입니다. cutile-rs는 조금 더 진행되어 crates.io에 게시되었으며, NVIDIA 외부의 HuggingFace Grout 추론 엔진 및 mistral.rs 등에서 이미 사용되고 있습니다. 기능 커버리지는 아직 불완전하며 API는 계속 변경될 예정입니다. 개선이 필요한 부분을 발견하면 적극적인 피드백을 부탁드립니다.

Cargo와 크레이트 생태계는 간편한 시작을 기본 전제로 합니다. GPU 프로그래밍은 역사적으로 그 반대였으며, 이 격차를 줄이는 것 역시 중요한 과제입니다. SIMT 트랙은 여전히 고정된 Nightly 툴체인을 요구하며, 이는 향후 개선을 통해 요구 사항에서 제외하고자 하는 부분입니다.

GPU에서의 Rust 활용은 완전히 새로운 개념이 아닙니다. NVIDIA의 프로젝트 이전부터 뛰어난 작업들이 존재해 왔으며 현재도 함께 발전하고 있습니다. cuda-oxide 설명서의 생태계 부록에는 Rust-GPU, rust-cuda, CubeCL 등과의 상대적 위치가 정리되어 있으며, 두 프로젝트가 성숙함에 따라 rust-cuda 메인테이너들과 지속적으로 협력하고 있습니다.

새로운 점은 프로젝트 뒤에 투입되는 공학적 노력과 발전 방향성에 대한 명확한 비전입니다.

현재 시도해 볼 수 있는 것들

  • SIMT 예제 실행: cuda-oxide에서 cargo oxide new 실행 후 cargo oxide run 수행
  • 타일 예제 실행: 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 참여
  • 발표 참석: 2026년 9월 8일부터 11일까지 몬트리올에서 열리는 RustConf 2026에서 Melih Elibol이 진행하는 “Fearless Concurrency on the GPU” 발표 참석 (NVIDIA의 다른 엔지니어들도 참석할 예정입니다)

제공되는 프로젝트를 직접 다뤄보고 개발에 동참해 보시기 바랍니다. 초기 단계의 오픈 소스 프로젝트인 만큼, 지금 구축하는 결과물이 다음 단계의 표준을 만들어 갈 것입니다.

Rust 커뮤니티

NVIDIA는 네이티브 Rust GPU 프로그래밍을 한 단계 끌어올리며 Rust 커뮤니티에 본격적으로 참여하게 된 것을 매우 뜻깊게 생각합니다. rust-cuda, rust-gpu, cudarc와 같은 프로젝트는 GPU와 Rust의 결합을 개척했으며, VectorWare 팀을 포함하여 해당 프로젝트를 이끄는 이들은 NVIDIA가 Rust 커뮤니티와 함께 개발을 진행하는 과정에서 작업 방식을 정립하는 데 계속해서 큰 영감을 주고 있습니다.

Discuss (0)

Tags