목적: 한 벌의 Rust 커널 코드를 유지하면서 다양한 GPU/CPU 백엔드에서 동일하게 실행하는 방법을, 아키텍처→예제 코드→빌드/운영→성능/보안까지 실무적으로 정리합니다.
대상: 그래픽/컴퓨트 혼합 워크로드, 크로스플랫폼 앱/엔진, 사내 라이브러리 표준화, GPU 도입 PoC 담당자.
1) 큰그림 한 장 — 무엇을 만들 것인가
- 커널(crate) = no_std Rust
- Rust 코드를 SPIR-V(Vulkan/Metal/DX12/WebGPU) 또는 PTX/NVVM(CUDA)로 빌드
- 타입/레이아웃을 명시(예:
#[repr(C)]
)해 호스트와 데이터 호환
- 호스트(crate) = 런타임 실행기
- 멀티백엔드용:
wgpu
(Vulkan/Metal/DX12/WebGPU 자동 선택) - NVIDIA 전용:
rustacuda
/CUDA Driver API
- 멀티백엔드용:
- 임베드/선택
- 빌드 시 산출된 SPIR-V/PTX를 바이너리에 임베드
- 런타임에 가용 백엔드 자동 선택 → 디스패치
이 구조로 코드 중복을 줄이고, Rust 생태계(테스트/포맷/린트/문서화)를 그대로 GPU 코드에도 적용합니다.
2) 워크스페이스 뼈대
my-gpu-workspace/
├─ Cargo.toml # [workspace]
├─ kernel/ # no_std 커널 (공용 로직)
│ └─ src/lib.rs
├─ host-wgpu/ # Vulkan/Metal/DX12/WebGPU 경로
│ ├─ build.rs # SPIR-V 선빌드 후 경로 전달
│ └─ src/main.rs
└─ host-cuda/ # CUDA 경로
└─ src/main.rs
루트 Cargo.toml
[workspace]
members = ["kernel", "host-wgpu", "host-cuda"]
resolver = "2"
3) 사전 준비(로컬 개발 환경)
- Rust 최신 툴체인(필요 시 nightly)
- Vulkan/Metal/DX12 런타임(각 OS 기본 제공 계열)
- CUDA 경로 사용 시: NVIDIA 드라이버 & CUDA 런타임
- 크레이트
- SPIR-V 빌더:
spirv-builder
, 커널 헬퍼:spirv-std
- 멀티백엔드 호스트:
wgpu
, 바이트캐스팅:bytemuck
- CUDA 호스트:
rustacuda
- SPIR-V 빌더:
4) 커널 구현(공용) — 안전한 타입·레이아웃·조건부 컴파일
포인트
✅ no_std/#[repr(C)]로 데이터 호환성 확보
✅ cfg(target_arch="spirv") 등으로 경로별 코드 스위치
✅ 인덱스/바운드/공유메모리에서 안전 경계 확보
kernel/src/lib.rs
#![cfg_attr(target_arch = "spirv", no_std)]
#[cfg(target_arch = "spirv")]
use spirv_std::{spirv, glam::UVec3};
// 호스트-디바이스 공유 파라미터 (레이아웃 고정)
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct Params {
pub scale: f32,
}
// Newtype으로 인덱스 타입 오용 방지(실수로 byte 오프셋/요소 인덱스 혼동 방지)
#[repr(transparent)]
#[derive(Clone, Copy, Debug)]
pub struct Index(pub u32);
impl Index {
#[inline]
pub fn to_usize(self) -> usize { self.0 as usize }
}
// CPU/테스트와 GPU에서 동일 로직 재사용을 위해 "순수 로직"을 분리
#[inline]
pub fn mul_scale(x: f32, p: &Params) -> f32 {
x * p.scale
}
// ── SPIR-V 경로(컴퓨트 셰이더) ───────────────────────────────────────────────
#[cfg(target_arch = "spirv")]
#[spirv(compute(threads(256)))]
pub fn kernel_main(
#[spirv(global_invocation_id)] gid: UVec3,
#[spirv(storage_buffer, descriptor_set = 0, binding = 0)] data: &mut [f32],
#[spirv(uniform, descriptor_set = 0, binding = 1)] params: &Params,
) {
let i = Index(gid.x).to_usize();
if i < data.len() {
data[i] = mul_scale(data[i], params);
}
}
// ── CUDA 경로(PTX 엔트리 가정) ───────────────────────────────────────────────
// CUDA 타깃에선 별도 엔트리/어트리뷰트를 사용하지만, 핵심 로직은 동일하게 재사용.
// 여기선 개념만 보여주기 위해 CPU fallback 함수로 대체합니다.
#[cfg(not(target_arch = "spirv"))]
pub fn kernel_cpu_fallback(data: &mut [f32], params: &Params) {
for x in data.iter_mut() {
*x = mul_scale(*x, params);
}
}
실무 팁
공용 로직 함수(mul_scale)를 중심에 두고, SPIR-V/CUDA/CPU가 같은 검증 루틴을 공유하게 설계하면 테스트·유지보수가 압도적으로 수월합니다.
5) SPIR-V 빌드 & 임베드(host-wgpu/build.rs)
fn main() {
let result = spirv_builder::SpirvBuilder::new("../kernel", "spirv-unknown-vulkan1.1")
.print_metadata(spirv_builder::MetadataPrintout::Full)
.build()
.expect("SPIR-V build failed");
// 단일 엔트리 산출물 경로를 환경변수로 전달
let (path, _) = result.module.unwrap_single().into_iter().next().unwrap();
println!("cargo:rustc-env=KERNEL_SPV_PATH={}", path.display());
}
6) 멀티백엔드 실행(wgpu) — Vulkan/Metal/DX12/WebGPU 자동 선택
host-wgpu/Cargo.toml
(요지)
[package]
name = "host-wgpu"
edition = "2021"
build = "build.rs"
[dependencies]
wgpu = "0.20"
bytemuck = { version = "1", features = ["derive"] }
pollster = "0.3" # block_on 대체(간단 실행용)
[build-dependencies]
spirv-builder = "0.9"
host-wgpu/src/main.rs
(핵심만)
use std::{borrow::Cow, fs};
use wgpu::util::DeviceExt;
use bytemuck::{Pod, Zeroable};
#[repr(C)]
#[derive(Clone, Copy, Debug, Pod, Zeroable)]
struct Params { scale: f32 }
fn main() {
pollster::block_on(run());
}
async fn run() {
// 1) 백엔드 자동 선택(운영 환경에 맞는 어댑터)
let instance = wgpu::Instance::default();
let adapter = instance.request_adapter(&Default::default()).await.unwrap();
let (device, queue) = adapter.request_device(&Default::default(), None).await.unwrap();
// 2) SPIR-V 모듈 로드
let spv = fs::read(std::env::var("KERNEL_SPV_PATH").expect("no KERNEL_SPV_PATH")).unwrap();
let module = unsafe { device.create_shader_module_spirv(&wgpu::ShaderModuleDescriptorSpirV {
label: Some("kernel"),
source: Cow::from(bytemuck::cast_slice(&spv)),
})};
// 3) 데이터/파라미터 버퍼
let mut host = vec![1.0f32; 1024];
let params = Params { scale: 2.0 };
let storage = device.create_buffer_init(&wgpu::util::BufferInitDescriptor{
label: Some("storage"),
contents: bytemuck::cast_slice(&host),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST
});
let params_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor{
label: Some("params"),
contents: bytemuck::bytes_of(¶ms),
usage: wgpu::BufferUsages::UNIFORM
});
// 4) 파이프라인/바인딩
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor{
label: Some("bgl"),
entries: &[
wgpu::BindGroupLayoutEntry{
binding: 0, visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer{ ty: wgpu::BufferBindingType::Storage { read_only: false }, has_dynamic_offset: false, min_binding_size: None },
count: None
},
wgpu::BindGroupLayoutEntry{
binding: 1, visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer{ ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
count: None
}
]
});
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor{
label: Some("bg"),
layout: &layout,
entries: &[
wgpu::BindGroupEntry{ binding: 0, resource: storage.as_entire_binding() },
wgpu::BindGroupEntry{ binding: 1, resource: params_buf.as_entire_binding() },
]
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor{
label: Some("pl"), bind_group_layouts: &[&layout], push_constant_ranges: &[]
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor{
label: Some("cp"),
layout: Some(&pipeline_layout),
module: &module,
entry_point: "kernel_main",
});
// 5) 디스패치(=ceil(1024/256)=4 워크그룹)
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_compute_pass(&Default::default());
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &bind, &[]);
pass.dispatch_workgroups(4, 1, 1);
}
queue.submit([encoder.finish()]);
// 6) 결과 읽기
let readback = device.create_buffer(&wgpu::BufferDescriptor{
label: Some("readback"),
size: (host.len() * 4) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false
});
let mut enc = device.create_command_encoder(&Default::default());
enc.copy_buffer_to_buffer(&storage, 0, &readback, 0, (host.len()*4) as u64);
queue.submit([enc.finish()]);
let slice = readback.slice(..);
slice.map_async(wgpu::MapMode::Read, |_|{});
device.poll(wgpu::Maintain::Wait);
let data = slice.get_mapped_range();
let out: &[f32] = bytemuck::cast_slice(&data);
assert!(out.iter().all(|&v| (v - 2.0).abs() < 1e-6));
println!("OK: first={} len={}", out[0], out.len());
}
7) NVIDIA 전용(CUDA) 실행 — 최대 성능 루트
개념 흐름
- PTX 모듈 로드 → 2) 디바이스 메모리 할당/복사 → 3)
launch!
로 그리드/블록 설정 → 4) 스트림 동기화
host-cuda/Cargo.toml
(요지)
[package]
name = "host-cuda"
edition = "2021"
[dependencies]
rustacuda = "0.3"
rustacuda_core = "0.1"
rustacuda_derive = "0.1"
host-cuda/src/main.rs
(핵심만)
use rustacuda::prelude::*;
use std::{error::Error, ffi::CString};
fn main() -> Result<(), Box<dyn Error>> {
rustacuda::init(CudaFlags::empty())?;
let device = Device::get_device(0)?;
let _ctx = Context::create_and_push(ContextFlags::SCHED_AUTO, device)?;
// 사전 빌드된 PTX를 임베드했다고 가정
let ptx = CString::new(include_str!("../kernel.ptx"))?;
let module = Module::load_from_string(&ptx)?;
let stream = Stream::new(StreamFlags::NON_BLOCKING, None)?;
let n = 1024usize;
let mut host = vec![1.0f32; n];
let mut d_buf = DeviceBuffer::from_slice(&host)?;
let scale: f32 = 2.0;
unsafe {
// <<< grid, block, shared_mem, stream >>>
launch!(module.kernel_main<<<(n as u32 +255)/256, 256, 0, stream>>>(
d_buf.as_device_ptr(),
n as u32,
scale
))?;
}
stream.synchronize()?;
d_buf.copy_to(&mut host)?;
assert!(host.iter().all(|&v| (v - 2.0).abs() < 1e-6));
println!("OK CUDA");
Ok(())
}
실무 팁
CUDA 경로에서는 그리드/블록/공유메모리 튜닝과 스트림 파이프라인(복수 커널/복사 오버랩)이 핵심입니다.
8) 테스트/CI — GPU 없어도 검증 가능한 루틴
- CPU 폴백 단위테스트: 커널 “순수 로직” 함수에 대한 테스트를
kernel
크레이트에서 실행 - 프로퍼티 테스트: 무작위 데이터에서 결괏값의 범위/불변식 검증
- 소프트웨어 드라이버: 헤드리스 CI에서 Vulkan 소프트웨어 드라이버(lavapipe/SwiftShader 등)를 사용해 wgpu 경로 연속 통합
- 결과 동등성 테스트: wgpu와 CUDA 경로의 출력 벡터가 동일한지 비교(허용 오차 포함)
9) 성능/디버깅 요령
- 메모리 접근 패턴
- 연속/공동 접근(coalesced), 구조체-of-배열(SoA) 고려, 정렬/패딩 관리
- 워크그룹/블록 크기
- 파형/워프 크기 고려(예: 32/64) → 128/256/512 후보로 벤치 스윕
- 타임라인 계측
- wgpu: Timestamp/Duration 쿼리로 커맨드 경계 측정
- CUDA: Events + Nsight Compute/Systems로 커널/메모리 단계별 확인
- 디버깅 도구
- RenderDoc(그래픽/컴퓨트 지원), PIX(DirectX), Xcode GPU Tools(Metal), Nsight(CUDA)
- 정확성 이슈
- 부동소수점 결정성, NaN 전파, FMA 사용 여부, fast-math 옵션 차이 관찰
10) 보안 체크리스트
- 아티팩트 검증
- SPIR-V 산출물 정적 검증(validator) 필수 통과
- 빌드 파이프라인에서 실패 시 차단(게이트)
- 데이터 레이아웃 일치
#[repr(C)]
로 구조체 고정, 호스트/커널 양쪽의 align/padding 주석·테스트
- unsafe 최소화
- 인덱싱/공유메모리/원자 연산 구간에
// SAFETY:
근거 코멘트 필수 .unwrap()
사용 금지, 에러 전파?
/Result
표준화
- 인덱싱/공유메모리/원자 연산 구간에
- 격리/권한
- 외부 제공 SPIR-V/WGSL/PTX 직접 실행 금지(화이트리스트·사전 서명·고정 임베드)
- 컨테이너/샌드박스/리소스쿼터(타임아웃/메모리 상한) 설정
- 의존성 잠금
- 코드젠 백엔드/크레이트/드라이버 버전 핀고정, 재현 가능한 빌드
- 로깅/감사
- 커널 해시, 파라미터, 백엔드 선택, 실행 시간, GPU/드라이버 버전 로깅
- 회귀 시험
- 성능 지표(시간/대역폭)와 정확성(허용 오차) 골든 세트 유지
11) 고급 패턴 — 공유메모리·패턴매칭·제네릭
워크그룹 공유메모리 예시(개념)
#[cfg(target_arch = "spirv")]
#[spirv(compute(threads(256)))]
pub fn reduce_sum(
#[spirv(local_invocation_id)] lid: spirv_std::glam::UVec3,
#[spirv(workgroup)] scratch: &mut [f32; 256], // 공유메모리
#[spirv(storage_buffer, descriptor_set=0, binding=0)] data: &mut [f32],
) {
let i = lid.x as usize;
// ... data를 scratch로 로드 → 워크그룹 내 병렬 감소 → data에 기록
}
Enum/패턴매칭으로 안전한 분기 제어
#[repr(u32)]
#[derive(Clone, Copy)]
pub enum Mode { Scale = 0, Clamp = 1 }
#[inline] fn apply(x: f32, m: Mode, p: &Params) -> f32 {
match m {
Mode::Scale => x * p.scale,
Mode::Clamp => x.clamp(-p.scale, p.scale),
}
}
Trait 기반 제네릭 커널 핵심
pub trait Op { fn f(x: f32, p: &Params) -> f32; }
pub struct Scale; impl Op for Scale {
#[inline] fn f(x: f32, p: &Params) -> f32 { x * p.scale }
}
pub fn apply_all<O: Op>(buf: &mut [f32], p: &Params) {
for v in buf { *v = O::f(*v, p); }
}
💡 제네릭+#[inline]으로 추상화 비용을 지우면서 공통 커널을 타입별로 재사용할 수 있습니다.
12) 선택 가이드 — 언제/무엇을 쓰나
- 크로스플랫폼·엔진/툴링 재사용이 중요:
kernel(no_std)
+host-wgpu
- NVIDIA 전용 최고 성능/에코시스템 필요:
host-cuda
병행 - 하이브리드:
- 기본은 이식성 커널(SPIR-V)
cfg(feature="cuda_opt")
로 벤더 최적화 커널 추가 제공- 런타임/설치 환경에 따라 선택 실행
13) 운영 팁 & 자주 겪는 이슈(FAQ)
- 속도가 안 나요 → 워크그룹 크기/메모리 접근/공유메모리 활용/파이프라인 배치 재검토
- 플랫폼별 결과가 조금 달라요 → 부동소수점 결정성·FMA·정밀도 옵션 일치 여부 확인
- 데이터 깨짐 →
#[repr(C)]
/패딩 정렬, 바인딩 인덱스, 버퍼 크기/오프셋 재검증 - 디버깅 난해 → CPU 폴백 경로로 문제 축소 후 재현 → GPU 툴로 단계적 추적
14) 부록 — 최소 설정 예시 모음
커널 Cargo.toml
(요지)
[package]
name = "kernel"
edition = "2021"
[dependencies]
# SPIR-V 경로에서만 사용됨
spirv-std = { version = "0.9", optional = true }
[features]
spirv = ["spirv-std"] # cargo build -p host-wgpu 로 빌드 시 활성화됨
wgpu 호스트 실행
# 루트에서
cargo run -p host-wgpu # 운영체제에 맞는 백엔드(Vulkan/Metal/DX12/WebGPU)로 실행
CUDA 호스트 실행
cargo run -p host-cuda # NVIDIA 환경에서 PTX 모듈 로드 후 실행
정리해보면,
- 핵심은 “커널의 순수 로직을 공용으로 두고, 각 백엔드의 진입부만 최소로 다르게” 설계하는 것입니다.
- 이렇게 하면 코드 중복/복잡도를 줄이고, Rust 툴체인(테스트/린트/문서화/CI)을 그대로 활용할 수 있습니다.
- 보안·신뢰성은 검증(validator) → 레이아웃 일치 → unsafe 최소화 → 로깅/회귀의 루틴을 팀 표준으로 묶으면 안정적으로 운영됩니다.
댓글