GPU 코드에서 Rust의 async/await와 Future 트레이트를 사용할 수 있게 된 이유와, 이것이 GPU 프로그래밍에 무엇을 가능하게 하는지 소개합니다.
URL: https://www.vectorware.com/blog/async-await-on-gpu/
2026년 2월 17일·읽는 데 15분
GPU 코드에서 이제 Rust의 async/await를 사용할 수 있습니다. 왜 가능해졌는지, 그리고 이것이 GPU 프로그래밍에서 무엇을 열어주는지 공유합니다.
VectorWare에서는 최초의 GPU 네이티브 소프트웨어 회사를 만들고 있습니다. 오늘 우리는 GPU에서 Rust의 Future 트레이트와 async/await를 성공적으로 사용할 수 있게 되었음을 기쁜 마음으로 발표합니다. 이 이정표는 익숙한 Rust 추상화를 사용해 GPU 하드웨어의 모든 성능을 활용하는 복잡하고 고성능의 애플리케이션을 개발자가 작성할 수 있도록 하겠다는 우리의 비전에 큰 발걸음을 내딛는 것입니다.
전통적으로 GPU 프로그래밍은 데이터 병렬성에 초점을 맞춥니다. 개발자는 하나의 연산을 작성하고 GPU는 그 연산을 데이터의 서로 다른 부분에 대해 병렬로 실행합니다.
fn conceptual_gpu_kernel(data) {
// 모든 워프(warp)의 모든 스레드가 data의 서로 다른 부분에 대해 동일한 작업을 수행
data[thread_id] = data[thread_id] * 2;
}
이 모델은 그래픽 렌더링, 행렬 곱셈, 이미지 처리처럼 독립적이고 균일한 작업에 잘 맞습니다.
GPU 프로그램이 더 정교해지면서 개발자들은 더 복잡한 제어 흐름과 동적 동작을 도입하기 위해 워프 특화(warp specialization)를 사용합니다. 워프 특화를 사용하면 GPU의 서로 다른 부분이 프로그램의 서로 다른 부분을 동시에 실행합니다.
fn conceptual_gpu_kernel(data) {
let communication = ...;
if warp == 0 {
// 워프 0이 메인 메모리에서 데이터를 로드
load(data, communication);
} else if warp == 1 {
// 워프 1이 로드된 데이터에 대해 A를 계산하고 B로 전달
compute_A(communication);
} else {
// 워프 2와 3이 로드된 데이터에 대해 B를 계산하고 저장
compute_B(communication, data);
}
}
워프 특화는 GPU 로직을 균일한 데이터 병렬성에서 명시적인 작업(task) 기반 병렬성으로 옮겨 놓습니다. 이는 하드웨어를 더 잘 활용하는 더 정교한 프로그램을 가능하게 합니다. 예를 들어, 한 워프가 메모리에서 데이터를 로드하는 동안 다른 워프가 계산을 수행해 연산과 메모리 사용률을 모두 높일 수 있습니다.
하지만 이러한 표현력 증가는 비용을 수반합니다. 이를 위한 언어 또는 런타임 지원이 없기 때문에 개발자가 동시성과 동기화를 수동으로 관리해야 합니다. CPU에서의 스레딩과 동기화처럼, 이는 오류가 나기 쉽고 이해하기도 어렵습니다.
수동 동시성/동기화의 고통 없이 워프 특화의 장점을 제공하려는 프로젝트가 많이 있습니다.
JAX는 GPU 프로그램을 연산 간의 의존성을 인코딩하는 계산 그래프로 모델링합니다. JAX 컴파일러는 이 그래프를 분석해 실제 실행 프로그램을 생성하기 전에 순서, 병렬성, 배치를 결정합니다. 이를 통해 JAX는 실행을 관리하고 최적화할 수 있으며, 동시에 Python 기반 DSL에서 고수준 프로그래밍 모델을 제공합니다. 같은 모델은 사용자 코드를 바꾸지 않고도 CPU와 TPU를 포함한 여러 하드웨어 백엔드를 지원합니다.
Triton은 GPU에서 독립적으로 실행되는 블록(block) 단위로 연산을 표현합니다. JAX처럼 Triton도 Python 기반 DSL을 사용해 이러한 블록이 어떻게 실행될지 정의합니다. Triton 컴파일러는 블록 정의를 다단계 파이프라인으로 MLIR 다이얼렉트들을 거치며 낮추는(lowering) 과정에서, 블록 수준 데이터 흐름 분석을 적용해 생성되는 프로그램을 관리하고 최적화합니다.
최근 NVIDIA는 CUDA Tile을 소개했습니다. Triton과 마찬가지로 CUDA Tile은 연산을 블록 중심으로 구성합니다. 여기에 더해 “타일(tile)”을 1급(first-class) 데이터 단위로 도입합니다. 타일은 데이터 의존성을 추론이 아니라 명시적으로 만들기 때문에, 성능 최적화 기회와 정합성(correctness) 추론을 모두 개선합니다. CUDA Tile은 Python 같은 기존 언어로 작성된 코드를 입력으로 받아 Tile IR이라는 MLIR 다이얼렉트로 낮춘 뒤 GPU에서 실행합니다.
우리는 이러한 시도들, 특히 CUDA Tile에서 큰 영감을 받고 있습니다. GPU 프로그램을 명시적인 작업 및 데이터 단위로 구조화하고, 동시성의 정의를 그 실행과 분리하는 것은 훌륭한 아이디어라고 생각합니다. GPU 하드웨어는 구조적 동시성(structured concurrency)과 자연스럽게 맞물리며, 소프트웨어를 이에 맞게 바꾸면 더 안전하고 더 고성능인 코드를 가능하게 할 것이라 믿습니다.
이러한 고수준 GPU 프로그래밍 접근법은 개발자가 코드를 새롭고 특정한 방식으로 구조화해야 합니다. 이는 일부 애플리케이션 범주에는 잘 맞지 않을 수 있습니다.
또한 새로운 프로그래밍 패러다임과 생태계는 채택의 큰 장벽입니다. 개발자들이 JAX와 Triton을 주로 사용하는 곳은 기저 연산과 잘 맞는 머신러닝 워크로드입니다. CUDA Tile은 더 새롭고 일반적이지만, 아직 광범위한 채택은 이루어지지 않았습니다. 사실상 누구도 이러한 기술로 애플리케이션 전체를 작성하지는 않습니다. 대신 애플리케이션의 일부는 이런 프레임워크로, 다른 일부는 더 전통적인 언어와 모델로 작성합니다.
코드 재사용도 제한됩니다. 기존 CPU 라이브러리들은 전통적인 언어 런타임과 실행 모델을 가정하기 때문에 직접 재사용할 수 없습니다. 기존 GPU 라이브러리들은 수동 동시성 관리를 전제로 하며, 마찬가지로 이러한 프레임워크들과 조합(composition)되기 어렵습니다.
이상적으로는, 새로운 언어나 생태계를 요구하지 않으면서도 명시적이고 구조적인 동시성의 장점을 담아내는 추상화를 원합니다. 그것은 기존 CPU 코드 및 실행 모델과 함께 조합 가능해야 합니다. 필요할 때 워프 특화처럼 미세한 제어를 제공해야 합니다. 또한 일반적인 경우를 위한 인체공학적인 기본값도 제공해야 합니다.
Future 트레이트와 async/await우리는 Rust의 Future 트레이트와 async/await가 그러한 추상화를 제공한다고 믿습니다. 이는 특정 실행 모델에 묶이지 않으면서, 기존 언어 안에 구조적 동시성을 직접 인코딩합니다.
퓨처(future)는 아직 완료되지 않았을 수도 있는 계산을 나타냅니다. 퓨처는 스레드, 코어, 블록, 타일, 워프 중 어디에서 실행되는지 지정하지 않습니다. 실행되는 하드웨어나 운영체제에도 신경 쓰지 않습니다. Future 트레이트 자체는 의도적으로 최소화되어 있습니다. 핵심 연산은 poll이며, 이는 Ready 또는 Pending 중 하나를 반환합니다. 나머지 모든 것은 그 위에 층층이 쌓입니다. 이러한 분리가 같은 async 코드가 서로 다른 환경에서 구동될 수 있게 해줍니다. 더 자세한 내용은 Rust async book을 참고하세요.
JAX의 계산 그래프처럼, 퓨처는 지연(deferred)되고 조합 가능합니다. 개발자는 실행 전에 프로그램을 값(value)으로 구성합니다. 이는 컴파일러가 실행 전에 의존성과 조합을 분석할 수 있게 해주면서도 사용자 코드의 형태를 유지합니다.
Triton의 블록처럼, 퓨처는 독립적인 동시성 단위를 자연스럽게 표현합니다. 퓨처를 어떻게 결합하느냐에 따라 작업 블록이 직렬로 실행되는지 병렬로 실행되는지를 나타냅니다. 개발자는 별도의 DSL 대신 일반적인 Rust 제어 흐름, 트레이트 구현, 퓨처 콤비네이터를 사용해 동시성을 표현합니다.
CUDA Tile의 명시적 타일과 데이터 의존성처럼, Rust의 소유권 모델은 데이터 제약을 프로그램 구조에 명시적으로 드러냅니다. 퓨처는 자신이 다루는 데이터를 캡처하며, 그 캡처된 상태는 컴파일러가 생성하는 상태 머신의 일부가 됩니다. 소유권, 빌림(borrowing), Pin, 그리고 Send, Sync 같은 바운드는 데이터가 동시 작업 단위들 사이에서 어떻게 공유되고 전달될 수 있는지를 인코딩합니다.
워프 특화는 보통 이런 방식으로 설명되지는 않지만, 실질적으로는 수동으로 작성한 작업 상태 머신(task state machine)으로 환원됩니다. 퓨처는 Rust 컴파일러가 자동으로 생성하고 관리하는 상태 머신으로 컴파일됩니다.
Rust의 퓨처가 단지 컴파일러가 생성한 상태 머신이라면, GPU에서 실행되지 못할 이유가 없습니다. 우리가 바로 그것을 해냈습니다.
async/awaitGPU에서 async/await를 실행하는 것은 시각적으로 보여주기 어렵습니다. 코드가 일반적인 Rust처럼 보이고, 실제로도 그렇게 실행되기 때문입니다. 설계상 CPU에서 쓰는 같은 문법이 GPU에서도 변경 없이 동작합니다.
여기서는 작은 async 함수들을 정의하고, 단일 GPU 커널에서 block_on을 사용해 호출합니다. 이들은 Rust async 모델의 핵심 기능을 모두 다룹니다: 단순 퓨처, 체이닝된 퓨처, 조건문, 다단계 워크플로, async 블록, 그리고 서드파티 콤비네이터.
// 아래 GPU 커널에서 호출할 간단한 async 함수들
async fn async_double(x: i32) -> i32 {
x * 2
}
async fn async_add_then_double(a: i32, b: i32) -> i32 {
let sum = a + b;
async_double(sum).await
}
async fn async_conditional(x: i32, do_double: bool) -> i32 {
if do_double {
async_double(x).await
} else {
x
}
}
async fn async_multi_step(x: i32) -> i32 {
let step1 = async_double(x).await;
let step2 = async_double(step1).await;
step2
}
#[unsafe(no_mangle)]
pub unsafe extern "ptx-kernel" fn demo_async(
val: i32,
flag: u8,
) {
// 단일 await가 있는 기본 async 함수가 디바이스에서 올바르게 실행됨
let doubled = block_on(async_double(val));
// 여러 async 호출의 체이닝도 기대대로 동작
let chained = block_on(async_add_then_double(val, doubled));
// async 코드 내부의 조건문도 지원
let conditional = block_on(async_conditional(val, flag));
// 여러 await 지점을 가진 async 함수도 동작
let multi_step = block_on(async_multi_step(val));
// async 블록도 동작하며 자연스럽게 조합됨
let from_block = block_on(async {
let doubled_a = async_double(val).await;
let doubled_b = async_double(chained).await;
doubled_a.wrapping_add(doubled_b)
});
// CPU 기반 async 유틸리티도 동작함. 여기서는 `futures_util` 크레이트의
// 콤비네이터를 사용해 새 async 함수를 작성하지 않고도 퓨처를 만들고 조합.
use futures_util::future::ready;
use futures_util::FutureExt;
let from_combinator = block_on(
ready(val).then(move |v| ready(v.wrapping_mul(2).wrapping_add(100)))
);
}
이 모든 것을 동작시키기 위해 여러 컴파일러 백엔드 전반에 걸친 버그를 수정하고 빈틈을 메웠습니다. 또한 NVIDIA의 ptxas 도구에서 문제를 발견해 보고하고, 우회 방법을 적용했습니다.
async/await를 사용하면 GPU에서 동시성을 인체공학적으로 표현할 수 있습니다. 하지만 Rust에서 퓨처는 스스로 실행되지 않으며, 실행기(executor)가 퓨처를 완료까지 구동(drive)해야 합니다. Rust는 의도적으로 내장 실행기를 포함하지 않으며, 대신 서드파티가 서로 다른 기능과 트레이드오프를 가진 실행기를 제공합니다.
우리의 초기 목표는 Rust의 async 모델이 GPU에서 ‘가능하기나 한지’를 증명하는 것이었습니다. 이를 위해 우리는 간단한 실행기인 block_on으로 시작했습니다. block_on은 하나의 퓨처를 받아 현재 스레드에서 반복적으로 폴링(poll)해 완료까지 구동합니다. 단순하고 블로킹이지만, 퓨처와 async/await가 올바른 GPU 코드로 컴파일될 수 있음을 보여주기에는 충분했습니다. block_on 실행기가 제한적으로 보일 수 있지만, 퓨처는 게으르고(lazy) 조합 가능하기 때문에 우리는 콤비네이터와 async 함수를 통해 여전히 복잡한 동시 워크로드를 표현할 수 있었습니다.
퓨처가 종단 간(end-to-end)으로 동작하게 된 뒤에는 더 강력한 실행기로 옮겼습니다. Embassy 실행기는 임베디드 시스템을 위해 설계되었고 Rust의 #![no_std] 환경에서 동작합니다. 이는 전통적인 운영체제가 없고 Rust 표준 라이브러리를 지원하지 않는 GPU에 자연스럽게 맞습니다. GPU에서 동작하도록 적응시키는 데에는 매우 적은 변경만 필요했습니다. 이런 식으로 기존 오픈소스 라이브러리를 재사용할 수 있는 능력은 다른(비-Rust) GPU 생태계에서 존재하는 것보다 훨씬 낫습니다.
아래에서는 스케줄링을 시연하기 위해, 무한 루프를 돌며 공유 상태의 카운터를 증가시키는 서로 독립적인 async 태스크 3개를 구성합니다. 태스크 자체는 유용한 계산을 수행하지 않습니다. 각 태스크는 작은 단위로 일을 수행하고 주기적으로 양보(yield)하는 간단한 퓨처를 await합니다. 이를 통해 실행기는 태스크들 사이의 진행을 교차(interleave)시킬 수 있습니다.
#![no_std]
#![feature(abi_ptx)]
#![feature(stdarch_nvptx)]
use core::future::Future;
use core::pin::Pin;
use core::sync::atomic::{AtomicU32, Ordering};
use core::task::{Context, Poll};
use embassy_executor::Executor;
use ptx_embassy_shared::SharedState;
pub struct InfiniteWorkFuture {
pub shared: &'static SharedState
pub iteration_counter: &'static AtomicU32,
}
impl Future for InfiniteWorkFuture {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
// 호스트가 중단을 요청했는지 확인
if self.shared.stop_flag.load(Ordering::Relaxed) != 0 {
unsafe { core::arch::nvptx::trap() };
}
// 시연 목적의 반복 및 활동량 추적
self.iteration_counter.fetch_add(1, Ordering::Relaxed);
self.shared.last_activity.fetch_add(1, Ordering::Relaxed);
// 작업 시뮬레이션
unsafe {
core::arch::nvptx::_nanosleep(100);
}
cx.waker().wake_by_ref();
Poll::Pending
}
}
// 서로 매우 유사한 세 가지 태스크: 서로 다른 변수를 증가
#[embassy_executor::task]
async fn task_a(shared: &'static SharedState) {
InfiniteWorkFuture {
iteration_counter: &shared.task_a_iterations,
shared,
}.await
}
#[embassy_executor::task]
async fn task_b(shared: &'static SharedState) {
InfiniteWorkFuture {
iteration_counter: &shared.task_b_iterations,
shared,
}.await
}
#[embassy_executor::task]
async fn task_c(shared: &'static SharedState) {
InfiniteWorkFuture {
iteration_counter: &shared.task_c_iterations,
shared,
}.await
}
#[unsafe(no_mangle)]
pub unsafe extern "ptx-kernel" fn run_forever(shared_state: *mut SharedState) {
// ... 실행기 설정 및 초기화 ...
// 안전성: CPU는 이것이 실행되는 동안 버퍼가 살아있도록 보장해야 함
let shared = unsafe { &const (*shared_state) };
executor.run(|spawner| {
if let Ok(token) = task_a(shared) {
spawner.spawn(token);
}
if let Ok(token) = task_b(shared) {
spawner.spawn(token);
}
if let Ok(token) = task_c(shared) {
spawner.spawn(token);
}
});
}
아래는 Embassy 실행기를 통해 GPU가 async 태스크를 실행하는 Asciinema 녹화입니다. 이 예제는 빈 무한 루프를 실행하고 원자 연산(atomic)으로 활동을 추적하므로 성능은 대표적이지 않습니다. 중요한 점은, 여러 태스크가 Rust의 일반적인 async/await를 사용하면서도, 기존의 프로덕션급 실행기에 의해 구동되어 GPU에서 동시 실행된다는 것입니다.
종합하면, 우리는 Rust와 그 async 모델이 GPU에 매우 잘 맞는다고 생각합니다. 특히 C++에서 NVIDIA가 진행 중인 stdexec 같은 작업처럼, 비슷한 아이디어가 다른 언어 생태계에서도 등장하고 있습니다. 차이점은 이런 추상화가 Rust에는 이미 존재하고 널리 사용되며, 성숙한 실행기와 라이브러리 생태계의 지원을 받는다는 점입니다.
async/await가 갖는 단점퓨처는 협력적(cooperative)입니다. 퓨처가 양보하지 않으면 다른 작업을 굶길(starve) 수 있고 성능이 저하될 수 있습니다. 이는 GPU에만 국한된 것이 아니라 CPU의 협력적 멀티태스킹도 같은 실패 모드를 가집니다.
GPU는 인터럽트를 제공하지 않습니다. 결과적으로 디바이스에서 실행되는 실행기는 퓨처가 진행할 수 있는지 판단하기 위해 주기적으로 폴링해야 합니다. 이는 스핀 루프나 유사한 대기 메커니즘을 수반합니다. nanosleep 같은 API는 지연을 효율과 맞바꿀 수 있지만, 여전히 인터럽트 기반 실행보다 비효율적이며 이는 현 GPU 아키텍처의 한계를 반영합니다. 우리는 이를 완화할 몇 가지 아이디어를 가지고 있으며 다양한 접근을 실험 중입니다.
퓨처를 구동하고 스케줄링 상태를 유지하는 것은 레지스터 압박(register pressure)을 증가시킵니다. GPU에서는 이는 점유율(occupancy)을 낮추고 성능에 영향을 줄 수 있습니다.
마지막으로, GPU에서의 Rust async 모델도 CPU에서와 동일한 함수 색칠 문제(function coloring problem)를 여전히 안고 있습니다.
CPU에서는 Tokio, Glommio, Smol 같은 실행기들이 스케줄링, 지연, 처리량 측면에서 서로 다른 트레이드오프를 제공합니다. GPU에서도 비슷한 다양성이 나타날 것으로 예상합니다. 우리는 GPU 하드웨어 특성에 맞춰 특별히 설계된 GPU 네이티브 실행기를 실험하고 있습니다.
GPU 네이티브 실행기는 CUDA Graphs나 CUDA Tile 같은 메커니즘을 활용해 효율적인 태스크 스케줄링을 구현하거나, 동시 태스크 간 빠른 통신을 위해 공유 메모리를 활용할 수 있습니다. 또한 임베디드나 CPU 중심 실행기를 단순 포팅하는 것보다 GPU 스케줄링 프리미티브와 더 깊게 통합할 수도 있습니다.
VectorWare에서는 최근 GPU에서 std를 활성화했습니다. 퓨처는 no_std 호환이므로 핵심 기능에는 영향이 없습니다. 그러나 GPU에서 Rust 표준 라이브러리를 사용할 수 있게 되면 더 풍부한 런타임과 기존 Rust async 라이브러리와의 더 긴밀한 통합이 가능해집니다.
마지막으로, 우리는 퓨처와 async/await가 GPU 하드웨어와 잘 매핑되고 CUDA Tile 같은 노력과도 자연스럽게 정렬된다고 믿지만, 동시성을 표현하는 유일한 방법은 아닙니다. 우리는 서로 다른 트레이드오프를 가진 Rust 기반 대안 접근도 탐구하고 있으며, 향후 글에서 그 실험들을 더 공유하겠습니다.
우리는 이 작업을 몇 달 전에 완료했습니다. GPU에서 우리가 이처럼 빠르게 진척을 낼 수 있었던 것은 Rust의 추상화와 생태계가 가진 힘을 보여줍니다.
회사로서 우리는 모두가 Rust를 쓰지 않는다는 점을 이해합니다. 우리의 미래 제품은 여러 프로그래밍 언어와 런타임을 지원할 것입니다. 다만 우리는 Rust가 고성능·고신뢰의 GPU 네이티브 애플리케이션을 구축하는 데 특히 적합하다고 믿으며, 그 점이 우리가 가장 기대하는 부분입니다.
X, Bluesky, LinkedIn에서 팔로우하거나 블로그를 구독해 우리의 진행 상황을 받아보세요. 앞으로 몇 달 동안 더 많은 작업 내용을 공유할 예정입니다. 또한 hello@vectorware.com으로 연락하실 수 있습니다.