내가 Numba를 도장에 데려갔을 때: Rust와 CUDA에 맞서는 배틀 로얄
Source: Dev.to
Before we dive in, I want to acknowledge Shreyan Ghosh (@zenoguy) and his wonderful article “When Time Became a Variable — Notes From My Journey With Numba” (dev.to link).
His piece captured something beautiful about computing: the joy of experimentation, the thrill of watching code go fast, and the curiosity to ask “what if?”.
“Somewhere between algorithms and hardware, Numba didn’t just make my code faster. It made exploration lighter.”
Reading his benchmarks, I couldn’t help but wonder: What happens when we throw Rust into the mix? What about raw CUDA? Where does the hardware actually give up?
So I built a dojo. Let’s spar.
🎯 도전 과제
Shreyan의 원래 실험과 동일한 도전 과제:
f(x) = sqrt(x² + 1) × sin(x) + cos(x/2)
2천만 개 요소에 대해 계산하십시오.
간단한 수학. 최대 최적화. 누가 이길까?
🥊 The Contenders
Team Python 🐍
| Variant | Description |
|---|---|
| Pure Python | 기본 구현 (인터프리터 오버헤드, GIL‑바인드). |
| NumPy Vectorized | 표준 NumPy 기반 접근법. |
| Numba JIT | Numba를 통한 단일 스레드 컴파일 코드. |
| Numba Parallel | prange를 사용한 다중 스레드 버전. |
| Numba @vectorize | 병렬 ufunc 구현. |
Team Rust 🦀
| Variant | Description |
|---|---|
| Single‑threaded | 관용적인 이터레이터 기반 코드. |
| Parallel (Rayon) | Rayon 크레이트를 이용한 워크‑스틸링 병렬 처리. |
| Parallel Chunks | 캐시 최적화 청크 처리. |
Team GPU 🎮
| Variant | Description |
|---|---|
| Numba CUDA | GPU에서 실행되는 파이썬 커널. |
| CUDA C++ FP64 | 배정밀도(64비트) 네이티브 구현. |
| CUDA C++ FP32 | 단정밀도(32비트) 네이티브 구현. |
| CUDA C++ Intrinsics | 하드웨어 최적화 수학 인트린식. |
🏗️ 설정
I wanted this to be reproducible and fair:
- 모든 구현에서 동일한 계산.
- 동일한 배열 크기 (2천만 개
float64요소). - 동일한 랜덤 시드 (
42). - JIT/캐시 효과를 없애기 위한 여러 번의 워밍업 실행.
- 여러 실행 중 최소값을 취함 (노이즈 최소화).
전체 벤치마크 스위트는 오픈 소스입니다: github.com/copyleftdev/numba-dojo
# Run everything yourself
git clone https://github.com/copyleftdev/numba-dojo.git
cd numba-dojo
make all
📊 결과
전체 순위표
| 순위 | 구현 | 시간 (ms) | NumPy 대비 속도 향상 |
|---|---|---|---|
| 🥇 | CUDA C++ FP32 | 0.21 | 3,255× |
| 🥈 | Numba CUDA FP32 | 2.52 | 265× |
| 🥉 | CUDA C++ FP64 | 4.11 | 162× |
| 4 | Numba CUDA FP64 | 4.14 | 161× |
| 5 | Rust Parallel | 12.39 | 54× |
| 6 | Numba @vectorize | 14.86 | 45× |
| 7 | Numba Parallel | 15.55 | 43× |
| 8 | Rust Single | 555.62 | 1.2× |
| 9 | Numba JIT | 558.30 | 1.2× |
| 10 | NumPy Vectorized | 667.30 | 1.0× |
| 11 | Pure Python | ~6,650 | 0.1× |

속도 향상 시각화

카테고리 챔피언

🔬 배운 내용
1. GPU ≫ CPU (가능할 때)
- RTX 3080 Ti:
0.21 ms→ 3,255× 빠름 (NumPy 대비). - 완전히 병렬화가 가능한 원소‑단위 작업에서는 GPU가 완전히 다른 수준이다.
- 방대한 병렬성(80 SM, 수천 개 코어)이 순차 실행을 완전히 압도한다.
2. FP32 ≈ FP64보다 20배 빠름 (소비자용 GPU)
CUDA FP64: 4.11 ms
CUDA FP32: 0.21 ms ← 20× faster!
- 소비자용 GeForce GPU는 FP64 유닛이 매우 적으며(≈ FP32의 1/32 수준) 처리량이 낮다.
- 알고리즘이 단정밀도를 허용한다면 FP32를 사용하라.
3. Rust ≈ Numba JIT (단일 스레드)
Rust (single‑threaded): 555.62 ms
Numba JIT: 558.30 ms
- 두 언어 모두 LLVM IR로 컴파일되며 거의 동일한 코드를 생성한다.
- 미세한 차이는 잡음 수준에 불과하며, Numba의 주장 “파이썬처럼 느끼고 C처럼 동작한다” 를 확인시켜준다.
4. Rust가 Numba보다 병렬에서 약 20 % 빠름
Rust Parallel (Rayon): 12.39 ms
Numba Parallel: 15.55 ms
- Rayon의 워크‑스틸링 스케줄러가 Numba의 스레딩보다 오버헤드가 적다.
- 프로덕션 환경의 CPU‑병렬 작업에서는 Rust가 확실히 우위에 있다.
5. 메모리 대역폭 한계에 부딪힘
FP32 CUDA 커널 프로파일링 결과:
Time: 0.21 ms
Bandwidth: ~777 GB/s achieved
Theoretical: 912 GB/s (RTX 3080 Ti)
Efficiency: 85 %
- GPU가 피크 메모리 대역폭의 85 % 수준으로 동작하고 있다.
- 코어는 대부분 유휴 상태이며, 병목은 메모리 입출력에 있다.
요약
- GPU는 순수 데이터‑병렬 작업을 장악합니다.
- FP32는 소비자 하드웨어에서 최적의 선택입니다.
- Rust는 CPU에서 자체적으로 충분히 경쟁력을 갖추며 (심지어 Numba보다 빠르기도 합니다).
- 메모리 대역폭이 양쪽 모두의 궁극적인 한계입니다.
레포를 복제하고 직접 벤치마크를 실행해 보세요!
데이터 대기 중* – 어떤 알고리즘도 물리법칙을 이길 수 없다
This is the Roofline Model in action:
Peak Compute
/
/
Performance /
/ ← We're here (memory‑bound)
/
/
──────────────────────
Memory Bandwidth
이 작업의 연산 강도는 낮아(바이트당 연산 수가 적음) 메모리 대역폭 한계에 도달했습니다.
🧪 코드
아래는 동일한 커널을 각각 독립적으로 구현한 세 가지 예시입니다.
1️⃣ Numba (원본 기사에서 영웅)
from numba import njit, prange
import numpy as np
@njit(parallel=True, fastmath=True, cache=True)
def compute_numba_parallel(arr, out):
"""Compute sqrt(x²+1)·sin(x) + cos(0.5·x) element‑wise."""
n = len(arr)
for i in prange(n):
x = arr[i]
out[i] = np.sqrt(x * x + 1.0) * np.sin(x) + np.cos(0.5 * x)
@njit만 추가하면 됩니다; 나머지는 순수 NumPy‑스타일 파이썬입니다.
2️⃣ Rust (도전자)
use rayon::prelude::*;
/// Compute sqrt(x²+1)·sin(x) + cos(0.5·x) element‑wise.
pub fn compute_parallel(arr: &[f64], out: &mut [f64]) {
out.par_iter_mut()
.zip(arr.par_iter())
.for_each(|(o, &x)| {
*o = (x * x + 1.0).sqrt() * x.sin() + (0.5 * x).cos();
});
}
rayon은 데이터 병렬성을 일반 이터레이터만큼 자연스럽게 만들어 줍니다.
3️⃣ CUDA C++ (챔피언)
#include <cuda_runtime.h>
#include <cmath>
__global__ void compute_fp32(const float *arr, float *out, size_t n) {
// One thread per element
size_t idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < n) {
float x = arr[idx];
out[idx] = sqrtf(x * x + 1.0f) * sinf(x) + cosf(0.5f * x);
}
}
/* Helper to launch the kernel */
void launch_compute(const float *d_arr, float *d_out, size_t n) {
const int threads_per_block = 256;
const int blocks = (n + threads_per_block - 1) / threads_per_block;
compute_fp32<<<blocks, threads_per_block>>>(d_arr, d_out, n);
cudaDeviceSynchronize(); // error checking omitted for brevity
}
각 배열 요소에 하나의 스레드를 매핑하는 직관적인 그리드‑스트라이드 커널입니다.
참고 문헌
- Original inspiration – 이 비교를 촉발시킨 기사.
- Numba 문서 – https://numba.pydata.org/
- Rayon (Rust) – https://github.com/rayon-rs/rayon
- Roofline Model – https://en.wikipedia.org/wiki/Roofline_model
계속 실험하고, 계속 놀아보세요. 그것이 컴퓨팅의 목적이죠. ✨
가장 좋아하는 성능 최적화 이야기는 무엇인가요? 댓글에 남겨 주세요!