고성능 GPGPU와 Rust 및 wgpu
Source: Dev.to
The Architecture of a Compute Application
GPGPU 애플리케이션은 전통적인 렌더링 루프와는 크게 다릅니다. 그래픽스 컨텍스트에서는 파이프라인이 복잡하여 버텍스 셰이더, 프래그먼트 셰이더, 래스터화, 깊이 버퍼 등을 포함합니다. 이에 비해 컴퓨트 파이프라인은 매우 단순합니다. 주로 데이터 버퍼와 컴퓨트 셰이더로 구성됩니다. 워크플로는 GPU 디바이스 초기화, 셰이더 코드 로드, GPU가 접근할 수 있는 메모리 버퍼 생성, 그리고 워크그룹을 디스패치하여 로직을 실행하는 순서로 이루어집니다.
wgpu의 핵심 추상화는 Instance, Adapter, Device, Queue 입니다.
- Instance – API에 대한 진입점.
- Adapter – 물리 하드웨어를 나타냅니다.
- Device – 리소스를 생성할 수 있게 해 주는 논리적 연결.
- Queue – 실행을 위해 커맨드 버퍼를 제출하는 곳.
그래픽스 렌더링이 창(surface)을 필요로 하는 반면, 컴퓨트 컨텍스트는 완전히 헤드리스로 실행될 수 있어 백그라운드 처리 도구나 서버‑사이드 애플리케이션에 이상적입니다.
Writing the Kernel in WGSL
GPU에서 실행되는 로직은 WebGPU Shading Language (WGSL) 로 작성합니다. 이 언어는 Rust와 GLSL을 혼합한 느낌입니다. 컴퓨트 셰이더의 경우 @compute 속성으로 장식된 엔트리 포인트를 정의하고 워크그룹 크기를 지정합니다. GPU는 이 함수를 3D 그리드 전역에 걸쳐 병렬로 실행합니다.
// shader.wgsl
@group(0) @binding(0)
var data: array;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) global_id: vec3) {
let index = global_id.x;
// Guard against out‑of‑bounds access if the array size
// isn't a perfect multiple of the workgroup size
if (index < arrayLength(&data)) {
data[index] = data[index] * data[index];
}
}
워크그룹 크기는 64 로 설정했습니다. Rust 쪽에서 워크를 디스패치할 때는 데이터 배열을 모두 커버하기 위해 몇 개의 64‑크기 그룹이 필요한지 계산합니다. 함수 내부 로직은 단순하지만 하드웨어는 이러한 인스턴스를 수천 개 동시에 실행합니다.
Buffer Management and Bind Groups
메모리 관리가 GPGPU 프로그래밍에서 가장 중요한 요소입니다. CPU와 GPU는 종종 별개의 메모리 공간을 가집니다. 이를 연결하기 위해 wgpu는 버퍼를 사용합니다. 컴퓨트 작업에서는 일반적으로 스토리지 버퍼가 필요합니다. 이는 셰이더가 임의의 데이터를 읽고 쓸 수 있게 해 줍니다. 하지만 CPU가 GPU 메모리에 직접 접근하는 것은 느리거나 불가능하므로 스테이징 버퍼 전략이 흔히 사용됩니다:
- 처리용 GPU‑전용 버퍼를 생성합니다.
- CPU가 매핑하여 읽을 수 있는 별도 버퍼를 생성합니다.
버퍼를 만든 뒤에는 셰이더에 해당 버퍼가 어디에 있는지 알려줘야 합니다. 이를 바인드 그룹을 통해 수행합니다. 바인드 그룹 레이아웃은 인터페이스를 설명합니다—예를 들어 바인딩 슬롯 0은 스토리지 버퍼입니다. 바인드 그룹 자체는 실제 wgpu::Buffer 객체를 해당 슬롯에 연결합니다. 이 분리를 통해 wgpu는 GPU가 명령을 받기 전에 리소스 사용을 검증할 수 있어, 저수준 그래픽 API에서 흔히 발생하는 충돌을 방지합니다.
Dispatching the Work
파이프라인을 만들고 데이터를 업로드했으면 명령을 인코딩합니다:
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: None,
});
{
let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: None,
timestamp_writes: None,
});
cpass.set_pipeline(&compute_pipeline);
cpass.set_bind_group(0, &bind_group, &[]);
// Example: 1024 elements, workgroup size 64 → 16 workgroups on X axis
cpass.dispatch_workgroups(data_size / 64, 1, 1);
}
디스패치 후 결과를 CPU로 읽어오려면 GPU‑전용 스토리지 버퍼에서 매핑 가능한 스테이징 버퍼로 데이터를 복사하는 명령을 발행합니다. 마지막으로 인코더를 마무리하고 커맨드 버퍼를 큐에 제출합니다.
Asynchronous Readback
wgpu는 비동기적입니다. 큐에 작업을 제출하면 즉시 반환되지만 GPU는 나중에 명령을 처리합니다. 데이터를 다시 읽어오려면 스테이징 버퍼를 매핑해야 하는데, 이는 Future를 반환합니다. 애플리케이션은 디바이스를 폴링해야 합니다, 예를 들어:
device.poll(wgpu::Maintain::Wait);
이 코드는 GPU 작업이 완료되고 매핑 콜백이 실행될 때까지 메인 스레드를 차단합니다. 버퍼가 매핑되면 원시 바이트를 Rust 슬라이스로 캐스팅하고, 로컬 벡터에 복사한 뒤 버퍼를 언매핑합니다. 이는 CPU가 결과에 접근하기 전에 GPU가 작업을 마쳤음을 보장하는 동기화 지점이 됩니다.
Conclusion
wgpu 생태계는 안전성과 이식성을 우선하면서도 하드웨어의 순수한 병렬 성능을 포기하지 않는 견고한 GPGPU 프로그래밍 기반을 제공합니다. WGSL과 WebGPU 리소스 모델을 표준화함으로써 개발자는 데스크톱, 모바일, 웹 어디서든 원활히 실행되는 컴퓨트 커널을 작성할 수 있습니다. 파이프라인 설정 및 메모리 버퍼 관리에 필요한 보일러플레이트가 고수준 CPU 스레딩보다 다소 길지만, 그 대가로 거대한 데이터셋을 병렬로 처리할 수 있어 CPU만으로는 도달할 수 없는 성능을 얻을 수 있습니다.