Python을 빠르게 만드는 모든 방법을 벤치마크로 비교하고, 각 접근의 비용과 성능 향상을 ‘사다리’의 단계로 정리한다.
Python을 빠르게 만드는 모든 방법, 벤치마크로 검증
2026-03-10
pythonperformancebenchmarkcythonrustnumbanumpymypycmojocodontaichigraalpypypyjax
매년 누군가 Python이 C보다 100배 느리다는 벤치마크를 올린다. 그러면 똑같은 논쟁이 반복된다. 한쪽은 “벤치마크는 중요하지 않다, 실제 앱은 I/O 바운드다”라고 하고, 다른 쪽은 “그냥 진짜 언어를 써라”라고 한다. 둘 다 틀렸다.
나는 가장 많이 인용되는 Benchmarks Game 문제 두 개—n-body와 spectral-norm—를 골라 내 머신에서 재현하고, 찾을 수 있는 모든 최적화 도구를 돌려봤다. 그리고 현실의 코드에 더 가까운 것을 시험하기 위해 세 번째 벤치마크—JSON 이벤트 파이프라인—도 추가했다.
같은 문제, 같은 Apple M4 Pro, 실제 수치. 이 글은 “결정판 순위”가 아니라 한 개발자가 사다리를 타고 올라가며 본 여정이다. 숙련된 전문가라면 여기 나온 어떤 도구에서도 더 많은 성능을 뽑아낼 수 있다. 전체 코드는 faster-python-bench에 있다.
출발점은 이렇다—공식 Benchmarks Game 실행에서의 CPython 3.13:
| Benchmark | C gcc | CPython 3.13 | Ratio |
|---|---|---|---|
| n-body (50M) | 2.1s | 372s | 177x |
| spectral-norm (5500) | 0.4s | 350s | 875x |
| fannkuch-redux (12) | 2.1s | 311s | 145x |
| mandelbrot (16000) | 1.3s | 183s | 142x |
| binary-trees (21) | 1.6s | 33s | 21x |
질문은 “Python이 계산에 느린가”가 아니다. 느리다. 질문은 각 해결책이 얼마나 노력(비용)을 요구하고, 어디까지 끌어올려 주느냐이다. 그게 사다리다.
흔히 범인으로 지목되는 건 GIL, 인터프리팅, 동적 타이핑이다. 셋 다 영향을 주지만, 그 어떤 것도 핵심 이야기는 아니다. 핵심은 Python이 _최대한 동적_이 되도록 설계되어 있다는 점이다—런타임에 메서드를 몽키 패치할 수 있고, 빌트인을 바꿀 수 있고, 인스턴스가 존재하는 동안에도 클래스의 상속 체인을 바꿀 수 있다—그리고 이런 설계 때문에 근본적으로 최적화하기가 어렵다.
C 컴파일러는 두 정수 사이의 a + b를 보고 CPU 명령어 하나를 낸다. Python VM은 a + b를 보면 이렇게 물어야 한다: a는 무엇인가? b는 무엇인가? a.__add__가 존재하는가? 마지막 호출 이후에 교체되었는가? a가 int의 서브클래스인데 __add__를 오버라이드한 건 아닌가? 언어가 언제든 무엇이든 바꿀 수 있음을 _보장_하기 때문에, 모든 연산은 이런 디스패치를 거친다.
이 점은 객체 오버헤드에서 구체적으로 드러난다. C에서 정수는 스택에 4바이트다. Python에서는:
C int: [ 4 bytes ]
Python int: [ ob_refcnt 8B ] reference count
[ ob_type 8B ] pointer to type object
[ ob_size 8B ] number of digits
[ ob_digit 4B ] the actual value
─────────────────
= 28 bytes minimum
(단순화했다—CPython 3.12+는 재구성된 int 레이아웃에서 ob_size를 lv_tag로 대체했다. 총합은 여전히 28바이트다. longintrepr.h 참고.)
숫자 4바이트, 동적성을 지원하기 위한 기계장치 24바이트. a + b는 곧: 힙 포인터 두 개를 역참조하고, 타입 슬롯을 조회하고, int.__add__로 디스패치하고, 결과를 위한 새로운 PyObject를 할당하고(작은 정수 캐시를 맞추지 않는 한), 참조 카운트를 업데이트하는 것이다. CPython 3.11+는 적응형 특수화로 이를 완화한다—BINARY_OP_ADD_INT 같은 핫 바이트코드는 알려진 타입에 대해 디스패치를 건너뛴다—하지만 일반 경우를 위한 오버헤드는 여전히 남는다. 숫자 하나는 느리지 않다. 루프에서 수백만 개가 문제다.
GIL(Global Interpreter Lock)은 자주 비난받지만, 단일 스레드 성능에는 영향이 없다—여러 CPU 바운드 스레드가 인터프리터를 두고 경쟁할 때만 의미가 있다. 이 글의 벤치마크에서는 GIL이 무관하다. CPython 3.13은 실험적 자유 스레딩 모드(--disable-gil)를 출시했고(3.14에서도 여전히 실험적), 하지만 보게 되겠지만 GIL을 제거하면 모든 참조 카운트 연산에 오버헤드가 추가되기 때문에 단일 스레드 코드는 오히려 느려진다.
인터프리팅 오버헤드는 실제로 존재하지만 적극적으로 개선되고 있다. CPython 3.11의 Faster CPython 프로젝트는 적응형 특수화를 도입했다—VM이 “핫” 바이트코드를 감지해 타입 특수화 버전으로 교체하여 일부 디스패치를 건너뛴다. 효과는 있었다(~1.4배). CPython 3.13은 더 나아가 실험적 copy-and-patch JIT 컴파일러를 도입했다—처음부터 코드를 생성하는 대신 미리 컴파일된 머신 코드 템플릿을 이어붙이는 경량 JIT이다. V8의 TurboFan 같은 완전한 최적화 JIT도 아니고 PyPy의 트레이싱 JIT도 아니다. 크기가 작고 시작이 빠르도록 설계되어, 역사적으로 CPython이 이 길을 선택하지 못하게 했던 무거운 JIT 시작 비용을 피한다. 3.13의 초기 결과는 대부분의 벤치마크에서 개선이 없지만, 앞으로 더 공격적인 최적화를 위한 기반은 마련되었다. JavaScript의 V8은 훨씬 더 나은 JIT 결과를 내지만, V8에는 큰 전담 팀이 있었고, 단일 스레드 JavaScript 실행 모델은 추측 기반 최적화를 더 쉽게 만든다. (“왜 CPython은 JIT을 쓰지 않나”에 대해서는 Anthony Shaw의 "Why is Python so slow?" 참고.)
정리하면 이렇게 된다: Python이 느린 이유는 동적 설계가 매 연산마다 런타임 디스패치를 요구하기 때문이다. GIL, 인터프리터, 객체 모델은 모두 그 설계 선택의 결과다. 사다리의 각 단은 이 디스패치의 일부를 제거한다. 더 위로 올라갈수록 더 많이 우회하지만, 그만큼 비용도 든다.
비용: 베이스 이미지만 바꾸기. 보상: 최대 1.4배.
| Version | N-body | vs 3.14 | Spectral-norm | vs 3.14 |
|---|---|---|---|---|
| CPython 3.10 | 1,663ms | 0.75x | 16,826ms | 0.83x |
| CPython 3.11 | 1,200ms | 1.04x | 13,430ms | 1.05x |
| CPython 3.13 | 1,134ms | 1.10x | 13,637ms | 1.03x |
| CPython 3.14 | 1,242ms | 1.0x | 14,046ms | 1.0x |
| CPython 3.14t (free-threaded) | 1,513ms | 0.82x | 14,551ms | 0.97x |
이 이야기의 핵심은 3.10에서 3.11이다: n-body에서 공짜로 1.39배 빨라진다. 이것이 Faster CPython 프로젝트다—바이트코드 적응형 특수화, 인라인 캐싱, 제로 코스트 예외. 3.13은 조금 더 짜냈다. 3.14는 그중 일부를 다시 내줬다—이 벤치마크들에서는 작은 퇴행이다.
자유 스레딩 Python(3.14t)은 단일 스레드 코드에서 더 느리다. GIL 제거가 모든 참조 카운트 연산에 오버헤드를 추가하기 때문이다. 진짜로 병렬인 CPU 바운드 스레드가 있을 때만 가치가 있다. (전체 버전 비교)
이 단계는 비용이 없다. 아직 3.10이라면 업그레이드하라.
비용: 인터프리터 교체. 보상: 6-66배.
| N-body | Spectral-norm | |
|---|---|---|
| CPython 3.14 | 1,242ms | 14,046ms |
| GraalPy | 211ms (5.9x) | 212ms (66x) |
| PyPy | 98ms (13x) | 1,065ms (13x) |
둘 다 수정하지 않은 Python 코드에서 네이티브 머신 코드를 생성하는 JIT 컴파일 런타임이다. 코드 변경은 0. 인터프리터만 바꾸면 된다.
PyPy는 트레이싱 JIT을 쓴다—핫 루프를 기록해 컴파일한다. GraalPy는 GraalVM의 Truffle 프레임워크 위에서 메서드 기반 JIT으로 돌아간다. PyPy는 n-body에서 이긴다(13배 vs 5.9배). 하지만 GraalPy는 spectral-norm에서 압도한다(66배 vs 13배)—행렬 중심의 내부 루프가 GraalVM의 강점과 맞아떨어진다. GraalPy는 Java 상호운용도 제공하며 Oracle이 적극적으로 개발 중이다.
단점은 생태계 호환성이다. 둘 다 주요 패키지를 지원하지만, C 확장은 호환 레이어를 통해 실행되며 CPython보다 느릴 수 있다. GraalPy는 Python 3.12 기반이며(아직 3.14 아님) 시작이 느리다—JVM 기반이라 JIT이 최고 성능에 도달하려면 워밍업이 필요하다. 순수 Python 코드에, 오래 도는 핫 루프가 있다면—이건 공짜 속도다.
비용: 아마 이미 갖고 있는 타입 애너테이션. 보상: 2.4-14배.
| N-body | Spectral-norm | |
|---|---|---|
| CPython 3.14 | 1,242ms | 14,046ms |
| Mypyc | 518ms (2.4x) | 990ms (14x) |
Mypyc는 타입 애너테이션이 달린 Python을 mypy와 같은 타입 분석으로 C 확장 모듈로 컴파일한다. 새 문법도, 새 언어도 없다—기존의 타입이 붙은 Python을 AOT로 컴파일하는 것뿐이다.
# Already valid typed Python -- mypyc compiles this to C
def advance(dt: float, n: int, bodies: list[Body], pairs: list[BodyPair]) -> None:
dx: float
dy: float
dz: float
dist_sq: float
dist: float
mag: float
for _ in range(n):
for (r1, v1, m1), (r2, v2, m2) in pairs:
dx = r1[0] - r2[0]
dy = r1[1] - r2[1]
dz = r1[2] - r2[2]
dist_sq = dx * dx + dy * dy + dz * dz
dist = math.sqrt(dist_sq)
mag = dt / (dist_sq * dist)
# ...
베이스라인과의 차이는: mypyc가 Python 객체 대신 C 원시 타입을 쓰도록 모든 로컬 변수에 명시적 타입 선언을 넣은 것, 그리고 느린 거듭제곱 디스패치를 피하기 위해 ** (-1.5)를 sqrt() + 산술로 분해한 것. 그게 전부다—특수 데코레이터도 없고, mypycify() 외에 새로운 빌드 시스템도 없다.
mypy 프로젝트 자체—Python 약 10만+ 라인—도 mypyc로 컴파일해 종단 간 4배 속도 향상을 달성했다. 공식 문서는 “기존 애너테이션 코드에서 1.5배에서 5배”, “컴파일에 맞게 튜닝한 코드에서 5배에서 10배”라고 말한다. spectral-norm 결과(14배)는 내부 루프가 순수 산술이라 mypyc가 이를 C로 직접 컴파일하기 때문에 그 범위를 웃돈다. 딕트 중심의 JSON 파이프라인에서는, mypyc가 미리 파싱된 딕트에서 2.3배를 기록했는데—예상되는 하한에 가깝다.
제약은: mypyc는 Python의 부분집합만 지원한다. **kwargs, getattr 트릭, 강한 덕 타이핑 같은 동적 패턴은 컴파일되더라도 최적화되지 않는다—느린 제네릭 경로로 떨어진다. 하지만 코드베이스가 이미 mypy strict 모드를 통과한다면, mypyc는 사다리에서 가장 적은 노력으로 “컴파일”을 얻는 단계다.
비용: NumPy를 아는 것. 보상: 최대 520배.
| Spectral-norm | |
|---|---|
| CPython 3.14 | 14,046ms |
| NumPy | 27ms (520x) |
520배. 같은 문제에서 단일 스레드 Rust의 154배보다도 빠르다—다만 NumPy는 BLAS에 위임하며, BLAS는 여러 코어를 쓴다.
Spectral-norm은 행렬-벡터 곱이다. NumPy는 행렬을 한 번 미리 계산하고 BLAS( macOS에서는 Apple Accelerate)로 위임한다:
a = build_matrix(n)
for _ in range(10):
v = a.T @ (a @ u)
u = a.T @ (a @ v)
각 @는 SIMD와 멀티스레딩으로 손으로 최적화된 BLAS를 한 번 호출하는 것이다. NumPy는 O(N) 메모리 대신 O(N^2) 메모리를 택한다—전체 2000x2000 행렬(30MB)을 저장한다—하지만 계산은 Python이 아니라 컴파일된 C/C++(macOS에서는 Apple Accelerate, Linux에서는 OpenBLAS 또는 MKL)에서 수행된다.
사람들이 “Python은 느리다”라고 말할 때 놓치는 교훈이 여기 있다. 루프를 돌리는 Python은 느리다. 컴파일된 라이브러리를 지휘하는 Python은 무엇과도 비슷하게 빠르다.
제약은: 문제가 벡터화 연산에 맞아야 한다. 원소별 수학, 행렬 대수, 리덕션, 조건부(np.where는 두 분기를 모두 계산하고 결과를 마스크한다—중복 작업이지만 큰 배열에서는 Python 루프보다 여전히 빠르다)—NumPy는 이런 것들을 처리한다. 도움을 주지 못하는 것은: 각 단계가 다음 단계로 이어지는 순차 의존성, 재귀적 구조, 그리고 NumPy의 호출당 오버헤드가 계산 자체보다 비싼 작은 배열이다.
비용: 루프를 jax.lax.fori_loop + 배열 연산으로 재작성. 보상: 12-1,633배.
Reddit 댓글러(justneurostuff)가 JAX 테스트를 제안했다—XLA JIT 컴파일을 쓰는 배열 계산 라이브러리다. 나는 NumPy 근처 어딘가에 있을 거라 예상했다. 틀렸다.
| N-body | Spectral-norm | |
|---|---|---|
| CPython 3.14 | 1,242ms | 14,046ms |
| NumPy | -- | 27ms (520x) |
| JAX JIT | 100ms (12.2x) | 8.6ms (1,633x) |
spectral-norm에서 8.6ms. NumPy보다 3배 빠르고, 이 글 전체에서 가장 빠른 결과다. n-body에서는 12.2배—Mypyc와 Numba 사이. 두 결과 모두 CPython 베이스라인과 소수점 9자리까지 일치한다. 이것은 단일 스레드다—스레드를 1개로 강제하면 spectral-norm에서 9.1ms vs 8.6ms였다.
같은 행렬 곱인데 왜 NumPy보다 3배 빠른지 나는 JAX를 충분히 잘 알지 못해 정확히 설명할 수 없다. 둘 다 내부적으로 BLAS를 호출한다. 내가 가진 가장 그럴듯한 추측은 JAX의 @jit가 함수 전체—행렬 생성, 루프, dot product—를 컴파일해 연산 사이에 Python이 전혀 개입하지 않는 반면, NumPy는 각 @ 호출 사이에 Python으로 돌아오기 때문이라는 것이다. 하지만 자세히 검증하진 않았다. 배울 시간일지도.
단점은: JAX는 다른 프로그래밍 모델이다. Python 루프는 lax.fori_loop가 된다. 조건문은 lax.cond가 된다. Python 문법을 쓰지만 사실상 함수형 배열 프로그램을 작성하는 것으로—드롭인 최적화기라기보다 도메인 특화 언어에 가깝다. 하지만 문제가 맞으면 숫자가 모든 걸 말해준다. 배열 코드를 컴파일하는 라이브러리는 JAX만 있는 게 아니다—예를 들어 PyTorch에는 torch.compile이 있다. 나는 JAX만 테스트했기 때문에, 다른 것들도 이 벤치마크에서 비슷한 결과를 낼지 말하긴 어렵다.
비용: @njit + 데이터를 NumPy 배열로 재구성. 보상: 56-135배.
| N-body | Spectral-norm | |
|---|---|---|
| CPython 3.14 | 1,242ms | 14,046ms |
| Numba @njit | 22ms (56x) | 104ms (135x) |
Numba는 LLVM을 통해 데코레이트된 함수를 머신 코드로 JIT 컴파일한다:
@njit(cache=True)
def advance(dt, n, pos, vel, mass):
for i in range(n):
for j in range(i + 1, n):
dx = pos[i, 0] - pos[j, 0]
dy = pos[i, 1] - pos[j, 1]
dz = pos[i, 2] - pos[j, 2]
dist = sqrt(dx * dx + dy * dy + dz * dz)
mag = dt / (dist * dist * dist)
vel[i, 0] -= dx * mag * mass[j]
# ...
데코레이터 하나. 데이터를 NumPy 배열로 재구성. 제약은: Numba는 NumPy 배열과 수치 타입에서 가장 잘 동작한다. typed dict, typed list, @jitclass를 제한적으로 지원하긴 하지만, 문자열과 일반 Python 객체는 대체로 손이 닿지 않는다. 톱이 아니라 메스다.
비용: C의 사고 모델을(하지만 Python 문법으로) 배우기. 보상: 99-124배.
| N-body | Spectral-norm | |
|---|---|---|
| CPython 3.14 | 1,242ms | 14,046ms |
| Cython | 10ms (124x) | 142ms (99x) |
n-body에서 124배. Rust의 10% 이내. 하지만 이 단계에는 이런 이야기가 있다:
내 첫 Cython n-body는 10.5배였다. 같은 Cython, 같은 컴파일러. 최종 버전은 124배였다. 차이는 지뢰 3개였고, 그 어떤 것도 경고를 내지 않았다:
** 연산자. typed double과 -ffast-math를 써도, Cython에서 는 보다 40배 느리다—연산자가 C의 sqrt()로 컴파일되지 않고 느린 디스패치 경로를 거친다. n-body 베이스라인은 ** (-1.5)를 쓰는데, 이는 sqrt() 한 번으로 대체할 수 없다—공식을 sqrt() + 산술로 분해해야 했다. 전체 벤치마크에서 7배 페널티.@cython.cdivision(True)가 빠지면 내부 루프의 모든 부동소수점 나눗셈 앞에 0으로 나누기 체크가 들어간다. 절대 타지 않는 분기 수백만 개.Cython의 약속은 “Python만큼 쉽게 Python용 C 확장을 쓰게 해준다”이다. 실제로는: C의 사고 모델을 배우고, 그것을 Python 문법으로 표현하며, 애너테이션 리포트(cython -a)로 컴파일러가 생각대로 했는지 검증하라는 뜻이다. 전체 이야기는 The Cython Minefield에 있다.
보상은 진짜다—99-124배로, 컴파일 언어급이다. 하지만 실패 모드는 “조용함”이다. 이 지뢰 셋은 조용히 비용을 치르게 하고, 애너테이션 리포트만이 이를 잡아낸다.
비용: 새로운 툴체인, 거친 모서리, 생태계 공백. 보상: 26-198배.
세 가지 도구가 Python(혹은 Python 비슷한 코드)을 네이티브 머신 코드로 컴파일하겠다고 약속한다. 나는 셋 다 테스트했다.
| N-body | Speedup | Spectral-norm | Speedup | The catch | |
|---|---|---|---|---|---|
| Codon 0.19 | 47ms | 26x | 99ms | 142x | 자체 런타임, 제한된 stdlib, 제한된 CPython 상호운용 |
| Mojo nightly | 16ms | 78x | 118ms | 119x | 새 언어(1.0 이전), 전면 재작성 필요 |
| Taichi 1.7 | 16ms | 78x | 71ms | 198x | Python 3.13 전용(3.14 휠 없음) |
수치는 진짜다. 개발 경험은 거칠다. Codon은 기존 코드를 import할 수 없다. Mojo는 Python의 옷을 입은 새 언어다. Taichi는 spectral-norm에서 가장 좋은 결과(198배)를 냈지만 Python 3.14용 휠을 제공하지 않는다—위 수치는 별도의 Python 3.13 환경에서 벤치마크했다. 이런 도구들의 타협점은 이것이다: 런타임이 CPython 릴리스 속도를 따라오지 못하면, 낡은 버전에 묶이거나 여러 환경을 저글링해야 한다. (코드와 DX 판정까지 포함한 전체 딥다이브)
드롭인은 아니다. 모두 지켜볼 가치는 있다.
비용: Rust 학습. 보상: 113-154배.
| N-body | Spectral-norm | |
|---|---|---|
| CPython 3.14 | 1,242ms | 14,046ms |
| Rust (PyO3) | 11ms (113x) | 91ms (154x) |
사다리의 꼭대기. 하지만 주목할 점: n-body에서 Cython 10ms vs Rust 11ms—사실상 동점이다. 둘 다 네이티브 머신 코드로 컴파일됐다. 남는 차이는 언어의 근본적 격차가 아니라 노이즈다.
Rust의 진짜 장점은 순수한 속도가 아니라 파이프라인 소유권이다. Rust가 serde로 JSON을 직접 파싱해 타입이 있는 구조체로 만들면, Python dict를 만들지 않는다. Python 객체 시스템을 통째로 우회한다. 이건 다음 벤치마크에서 더 중요해진다.
Benchmarks Game 문제들은 순수 연산이다: 타이트한 루프, I/O 없음, 배열 외의 자료구조 거의 없음. 대부분의 Python 코드는 전혀 그렇지 않다. 그래서 세 번째 벤치마크를 만들었다: 10만 개 JSON 이벤트를 로드하고, 필터링/변환하고, 사용자별로 집계한다. 딕트, 문자열, datetime 파싱—Numba가 무력해지고 Cython이 Python 객체 시스템과 씨름하게 되는 종류의 코드다.
먼저, 모든 도구가 미리 파싱된 Python dict에서 출발하게 했다—같은 입력, 같은 작업:
| Approach | Time | Speedup | What it costs you |
|---|---|---|---|
| CPython 3.14 | 48ms | 1.0x | 없음 |
| Mypyc | 21ms | 2.3x | 타입 애너테이션 |
| Cython (dict optimized) | 12ms | 4.1x | 며칠치 애너테이션 작업 |
4.1배. 50배가 아니다. 병목은 Python dict 접근이다. Cython의 완전 최적화 버전—@cython.cclass, 카운터용 C 배열, 직접 CPython C-API 호출(PyList_GET_ITEM, borrowed ref를 쓰는 PyDict_GetItem)—조차도 입력 dict는 Python C API를 통해 읽는다.
그런데 잠깐—왜 Cython에 Python dict를 먹이고 있는가? json.loads()는 그 dict를 만드는 데 ~57ms가 든다. 베이스라인 파이프라인 전체보다 큰 시간이다. Cython이 raw bytes를 직접 읽으면 어떨까?
나는 yyjson을 호출하는 두 번째 Cython 파이프라인을 작성했다—일반 목적의 C JSON 파서로, Rust의 serde_json과 견줄 만하다. 둘 다 스키마 비의존이다: 우리 이벤트 포맷만이 아니라 어떤 유효 JSON이든 파싱한다. Cython은 C 포인터로 파싱된 트리를 순회하며 필터링/집계를 C 구조체로 수행하고, 최종 출력에만 Python dict를 만든다. Rust는 관용적인 serde를 써서 zero-copy 역직렬화를 한다. 둘 다 데이터를 종단 간으로 소유한다:
| Approach | Time | Speedup | What it costs you |
|---|---|---|---|
| CPython 3.14 (json.loads + pipeline) | 105ms | 1.0x | 없음 |
| Mypyc (json.loads + pipeline) | 77ms | 1.4x | 타입 애너테이션 |
| Cython (json.loads + pipeline) | 67ms | 1.6x | C-API dict 접근 |
| Rust (serde, from bytes) | 21ms | 5.0x | 새 언어 + 바인딩 |
| Cython (yyjson, from bytes) | 17ms | 6.3x | C 라이브러리 + Cython 선언 |
Cython 6.3배, Rust 5.0배. 천장은 파이프라인 코드가 아니라 json.loads()였다. 두 접근 모두 일반 목적 JSON 파서를 쓴다—Cython 쪽은 yyjson, Rust 쪽은 serde—그리고 핫 루프에서 Python 객체를 완전히 피한다: Cython은 yyjson의 C 트리를 C 구조체로 옮기며 순회하고, Rust는 serde로 네이티브 구조체로 역직렬화한다.
나는 Cython이 Rust보다 빠르다거나 그 반대라고 주장하는 게 아니다. 의욕 있는 사람이라면 어느 쪽이든 더 빠르게 만들 수 있다—파서 교체, 할당기 튜닝, 파이프라인 재구성 등. 요점은 이 특정 벤치마크에서 누가 이기느냐가 아니라, _얼마나 많은 단계를 올라갈 의지가 있느냐_다. json.loads()를 우회하면 둘은 비슷한 동네에 모인다. 코드는 faster-python-bench에 있다.
| Approach | Time | Speedup | What it costs you |
|---|---|---|---|
| CPython 3.10 | 1,663ms | 0.75x | 구버전 |
| CPython 3.14 | 1,242ms | 1.0x | 없음 |
| CPython 3.14t | 1,513ms | 0.82x | GIL 제거지만 단일 스레드에서 느림 |
| Mypyc | 518ms | 2.4x | 타입 애너테이션 |
| GraalPy | 211ms | 5.9x | Python 3.12 전용, 생태계 호환성 |
| JAX JIT | 100ms | 12.2x | 루프를 lax.fori_loop로 재작성 |
| PyPy | 98ms | 13x | 생태계 호환성 |
| Codon | 47ms | 26x | 별도 런타임, 제한된 stdlib |
| Numba | 22ms | 56x | @njit + NumPy 배열 |
| Taichi | 16ms | 78x | Python 3.13 전용(3.14 휠 없음) |
| Mojo | 16ms | 78x | 새 언어 + 툴체인 |
| Cython | 10ms | 124x | C 지식 + 지뢰들 |
| Rust (PyO3) | 11ms | 113x | Rust 학습 |
| Approach | Time | Speedup | What it costs you |
|---|---|---|---|
| CPython 3.10 | 16,826ms | 0.83x | 구버전 |
| CPython 3.14 | 14,046ms | 1.0x | 없음 |
| CPython 3.14t | 14,551ms | 0.97x | GIL 제거지만 단일 스레드에서 느림 |
| Mypyc | 990ms | 14x | 타입 애너테이션 |
| GraalPy | 212ms | 66x | Python 3.12 전용, 생태계 호환성 |
| PyPy | 1,065ms | 13x | 생태계 호환성 |
| Codon | 99ms | 142x | 별도 런타임, 제한된 stdlib |
| Numba | 104ms | 135x | @njit + NumPy 배열 |
| Mojo | 118ms | 119x | 새 언어 + 툴체인 |
| Rust (PyO3) | 91ms | 154x | Rust 학습 |
| Cython | 142ms | 99x | C 지식 + 지뢰들 |
| Taichi | 71ms | 198x | Python 3.13 전용(3.14 휠 없음) |
| NumPy | 27ms | 520x | NumPy 지식 + O(N^2) 메모리 |
| JAX JIT | 8.6ms | 1,633x | 루프를 lax.fori_loop로 재작성 |
| Approach | Time | Speedup | What it costs you |
|---|---|---|---|
| CPython 3.14 (json.loads + pipeline) | 105ms | 1.0x | 없음 |
| Mypyc (json.loads + pipeline) | 77ms | 1.4x | 타입 애너테이션 |
| Cython (json.loads + pipeline) | 67ms | 1.6x | C-API dict 접근 |
| Rust (serde, from bytes) | 21ms | 5.0x | 새 언어 + 바인딩 |
| Cython (yyjson, from bytes) | 17ms | 6.3x | C 라이브러리 + Cython 선언 |
노력 곡선은 지수적이다. Mypyc(2.4-14배)는 타입 애너테이션 비용. PyPy/GraalPy(6-66배)는 바이너리 교체 비용. Numba(56-135배)는 데코레이터와 데이터 재구성 비용. JAX(12-1,633배)는 코드를 함수형으로 재작성하는 비용. Cython(99-124배)은 며칠과 C 지식 비용. Rust(113-154배)은 새 언어 학습 비용.
먼저 업그레이드하라. 3.10에서 3.11은 공짜로 1.4배다.
타입이 있는 코드베이스에는 Mypyc. 코드가 이미 mypy strict를 통과한다면 컴파일하라. n-body 2.4배, spectral-norm 14배를 거의 노력 없이 얻는다.
벡터화 가능한 수학에는 NumPy. 문제가 행렬 대수나 원소별 연산이라면, NumPy는 이미 아는 코드로 520배를 준다.
함수형으로 표현할 수 있다면 JAX. NumPy와 같은 배열 패러다임이지만, XLA의 전체 그래프 컴파일로 spectral-norm을 1,633배까지 올렸다—NumPy보다 3배 빠르다. 비용은 루프를 lax.fori_loop, 조건문을 lax.cond로 재작성하는 것이다. 잘 벡터화되지 않는 문제(n-body에서 5개 바디)에서는 JAX가 12배로—좋지만 압도적이진 않다.
수치 루프에는 Numba. @njit는 데코레이터 하나로 56-135배를 주고, 에러 메시지도 솔직하다.
C를 안다면 Cython. 99-124배는 진짜지만, 실패 모드는 “조용한 느림”이다.
파이프라인 소유권에는 Rust. 순수 계산에서는 Cython과 Rust가 막상막하다. 진짜 장점은 Rust가 데이터 흐름을 종단 간으로 소유할 때 나온다.
순수 Python에는 PyPy 또는 GraalPy. 코드 변경 없이 6-66배는 놀랍다—의존성이 지원해 준다면. GraalPy의 spectral-norm 결과(66배)는 컴파일 솔루션과도 견줄 만하다.
대부분의 코드는 이런 게 필요 없다. 가장 현실적인 파이프라인 벤치마크는—Python dict에서 시작하면—최대 4.1배였다. Cython이 yyjson을 호출해 바이트를 소유하면 6.3배였다. 핫 패스가 dict[str, Any]라면 답은 “언어를 바꿔라”가 아니라 “dict 생성을 그만하라”일 수 있다. 그리고 코드가 I/O 바운드라면, 이 모든 건 전혀 중요하지 않다.
최적화 전에 프로파일링하라.cProfile로 함수를 찾고, line_profiler로 줄을 찾은 다음, 맞는 단계를 고르라.
다루지 않은 것:Nuitka (Python-to-C 컴파일러, 주로 패키징에 쓰임—속도 향상은 Mypyc 범위), Pythran (NumPy 중심 AOT 컴파일러, 니치), SPy (Antonio Cuni의 정적 Python 방언—아직 준비되지 않았지만 지켜볼 만함), 그리고 CinderX (Meta의 성능 지향 CPython 포크—아직 준비되지 않음).
오류를 찾았나? PR을 열어 달라.
2026-03-10: NumPy 제약 문단을 다시 썼다. 원문은 NumPy가 못하는 것으로 _"불규칙한 접근 패턴, 원소별 조건문, 재귀 구조"_를 나열했는데, 그중 두 가지가 틀렸다: NumPy의 팬시 인덱싱은 불규칙 접근을 잘 처리하며(랜덤 gather에서 Python보다 22배 빠름), np.where는 조건을 처리한다(원소 100만 개에서 2.8-15.5배 빠름, 비록 두 분기를 모두 계산하더라도). NumPy가 실제로 도와줄 수 없는 것으로 바꿨다: 순차 의존성(n-body에서 5개 바디는 NumPy가 2.3배 느림), 재귀 구조, 그리고 작은 배열(호출당 오버헤드 때문에 ~50개 원소 이하에서는 NumPy가 짐).
2026-03-10: 원문에는 _"초기 결과는 소박하다(한 자릿수 퍼센트 개선)"_라고 되어 있었는데, 이는 3.13 JIT이 이미 이득을 내고 있다는 뉘앙스를 준다. _"3.13의 초기 결과는 대부분의 벤치마크에서 개선이 없다"_로 바꿨다. 내 표현이 나빴다—3.13 JIT은 속도 향상이 없고(오히려 약간 느릴 수 있다). 속도 향상은 3.15에서 나오고 있다: Savannah Ostrowski의 예비 FastAPI 벤치마크는 3.15에서 ~8% 개선을 보여준다(doesjitgobrrr.com도 참고). Fidget-Spinner(JIT 작업 중인 CPython 코어 개발자)가 정정해 준 것에 감사한다.
2026-03-11: Reddit 댓글에서 justneurostuff가 테스트를 제안한 뒤 JAX JIT 벤치마크를 추가했다. 결과: spectral-norm에서 1,633배(글에서 가장 빠름—NumPy보다 3배 빠름), n-body에서 12.2배. 둘 다 베이스라인과 소수점 9자리까지 일치한다. NumPy와 Numba 섹션 사이의 막간으로, 그리고 두 성적표 표에도 추가했다.