다음에 올 것을 만들어가는 기술에 대한 심층 기사.

Python JIT 컴파일러, 드디어 현실로

Python 3.15에 들어가는 copy-and-patch JIT 컴파일러의 동작 원리, 예상 성능 향상폭, CPython이 이 기능을 늦게 도입한 이유를 정리했습니다.

회로 기판 무늬 제트팩을 메고 트랙 출발선에 선 뱀

Python은 존재하기 시작한 이래로 줄곧 '느리다'는 말을 들어왔습니다. 파이썬 커뮤니티의 표준 답변인 '핫 루프는 C 확장으로 짜라'는 말은 사실 이 언어의 기본 실행 모델에 근본적인 한계가 있다는 양보였죠. CPython은 바이트코드를 명령어 하나씩 해석하고, 각 명령어는 switch 문을 통해 분기됩니다. 단순하고 이식성이 좋고 디버깅도 쉽습니다. 대신 연산량이 많은 작업에서는 컴파일된 C에 비해 약 100배 느립니다.

Python 3.15에서 이 상황이 바뀝니다. 수년간의 실험 끝에 copy-and-patch JIT 컴파일러가 기본 활성화된 기능으로 출하됩니다. Python을 C만큼 빠르게 만들어주지는 못합니다. 정적 컴파일이 아니라면 어떤 방법으로도 그렇게는 못 하죠. 하지만 초기 벤치마크에서는 실제 코드 기준으로 15~30% 속도 향상이 나왔고, 특정 패턴에서는 훨씬 큰 개선이 보입니다. 30년 동안 '핫 경로만 C로 다시 짜라'가 성능 이야기의 전부였던 언어에서, 순수 Python 코드를 실질적으로 가속하는 JIT은 분명한 이정표입니다.

CPython에 JIT이 한 번도 없었던 이유

시도가 없었던 건 아닙니다. PyPy는 10년 넘게 JIT을 갖고 있었고, 보통 CPython보다 5~10배 빠르게 Python 코드를 실행합니다. 하지만 PyPy는 별도의 런타임을 가진 독립 구현체이고, NumPy, pandas, scikit-learn처럼 Python을 데이터 과학의 언어로 만든 C 확장 생태계가 CPython의 C API에 묶여 있어서, CPython의 시장 점유율을 끝내 따라잡지 못했습니다.

CPython 자체에 JIT을 넣자는 논의와 시도는 여러 번 있었고, 어려운 점도 잘 알려져 있습니다. 먼저 바이트코드가 동적 타입이라서, JIT이 효율적인 코드를 만들려면 필요한 타입 정보를 Python이 정적으로 제공하지 않습니다. 또 C API는 C 코드가 Python 객체를 직접 조작하도록 허용하는데, 이것이 JIT의 가정을 깨뜨리곤 합니다. 그리고 참조 카운팅 기반 가비지 컬렉터의 장부 관리 비용은 JIT으로도 쉽게 없앨 수 없습니다.

이전 시도로는 Unladen Swallow(Google, 2009)와 Pyston(Dropbox, 2014)이 있었습니다. 둘 다 LLVM 기반 JIT을 CPython에 붙이려 했습니다. 그런데 LLVM의 컴파일 오버헤드가 Python의 일반적인 작업 부하에 비해 너무 컸습니다. LLVM은 대규모 코드베이스의 AOT 컴파일을 위해 설계되었기 때문에, 짧은 Python 함수를 JIT 컴파일하면 실행은 마이크로초인데 컴파일에 밀리초가 들어갑니다. 컴파일 비용이 속도 향상보다 더 컸던 셈입니다.

Copy-and-Patch: 전혀 다른 방식의 JIT

copy-and-patch 기법은 2021년 연구 논문에서 소개됐고, JIT 컴파일에 근본적으로 다른 접근을 취합니다. 바이트코드를 중간 표현으로 바꾸고 최적화 패스를 돌리는 대신(LLVM 방식), copy-and-patch는 미리 컴파일된 코드 템플릿을 사용합니다.

원리는 이렇습니다. 각 바이트코드 명령어(LOAD_FAST, BINARY_ADD, CALL_FUNCTION 등)에 대해, 컴파일러가 C 구현을 미리 기계어로 컴파일해 둡니다. 이때 변수별 데이터인 레지스터 할당, 상수 값, 메모리 오프셋 같은 부분은 '구멍(hole)' 형태의 플레이스홀더로 남겨 둡니다. 런타임에서 JIT 컴파일은 단순히 템플릿을 복사하고, 이 함수에 맞는 값으로 구멍을 채우는 일입니다. 최적화 패스도, 레지스터 할당 알고리즘도, 명령어 선택도 없습니다. 그냥 memcpy하고 patch하는 것이죠.

Traditional JIT pipeline:
Python bytecode
→ Parse into IR
→ Type inference
→ Optimization passes (CSE, DCE, loop unrolling, inlining...)
→ Register allocation
→ Instruction selection
→ Machine code
Total: 1-100ms per function
Copy-and-patch pipeline:
Python bytecode
→ For each instruction, copy pre-compiled template
→ Fill in holes (constants, offsets)
→ Done — machine code ready
Total: 1-100μs per function (1000x faster compilation)

트레이드오프는 코드 품질입니다. LLVM은 매우 최적화된 기계어를 만듭니다. copy-and-patch가 만드는 기계어는 사실상 인터프리터 루프를 컴파일한 것과 같습니다. 각 바이트코드 명령어가 여전히 별개의 템플릿이고, 명령어 간 최적화는 거의 없습니다. 그래도 인터프리션보다는 낫습니다. 디스패치 오버헤드도, switch 문도 없고, 분기 예측도 좋아집니다. 다만 완전한 최적화 컴파일러가 만드는 코드보다는 떨어집니다.

Python에서는 이 트레이드오프가 아주 좋습니다. Python 함수는 대개 짧고, 여러 번 호출되고, 하나당 실행 시간은 마이크로초 단위입니다. 마이크로초 단위로 컴파일하고 실행을 20~30% 빠르게 해 주는 JIT이, 밀리초 단위로 컴파일하고 200% 빠르게 해 주는 JIT보다 실용적입니다. 앞쪽은 컴파일 비용이 거의 바로 상쇄되기 때문입니다.

무엇이 더 빨라지나

JIT이 모든 Python 코드를 똑같이 마법처럼 빠르게 만들지는 않습니다. 어떤 부분이 가장 이득을 보는지는 인터프리터가 시간을 어디에 쓰는지 알아야 이해할 수 있습니다.

바이트코드 디스패치 오버헤드. 인터프리터에서는 바이트코드 명령어 하나마다 다음 opcode를 가져오고, 디코딩하고, switch 문을 통해 핸들러로 점프해야 합니다. 타이트한 루프에서는 이 디스패치 오버헤드가 전체 실행 시간의 30~50%를 차지하기도 합니다. JIT은 이것을 완전히 없앱니다. 명령어들이 직접 점프하는 순차 기계어로 컴파일되기 때문입니다.

타입 특화 연산. Python 3.11은 실제로 쓰인 타입을 관찰한 뒤 일반 연산을 타입 특화 연산으로 바꾸는 specializing adaptive interpreter를 도입했습니다. 정수 두 개를 보면 BINARY_ADD가 BINARY_ADD_INT로 바뀝니다. JIT은 이런 특화 명령어를 효율적인 기계어로 컴파일합니다. 정수 덧셈은 함수 호출 대신 add 명령어 하나가 됩니다.

분기 예측. 인터프리터의 중앙 디스패치 루프는 수백 개의 case가 있는 switch 문인데, CPU의 분기 예측기 입장에서는 악몽입니다. JIT은 이것을 CPU가 정확히 예측할 수 있는 직접 제어 흐름으로 바꿉니다. 분기 예측 실패 한 번에 15~20 사이클이 드는 최신 CPU에서는, 이것만으로도 속도 향상의 상당 부분을 차지합니다.

빨라지지 않는 것도 있습니다. C 확장 호출(NumPy, pandas), I/O 작업(네트워크, 디스크), 수백만 개의 작은 객체를 만드는 등 메모리 할당이 지배적인 작업입니다. Python 프로그램 시간의 95%를 C 확장에서 보내고 5%만 순수 Python이라면, JIT은 그 5%만 빠르게 합니다. 측정 가능한 차이이긴 하지만 판도를 바꿀 정도는 아닙니다.

특화 파이프라인

JIT은 혼자 동작하지 않습니다. Python 3.11의 specializing 인터프리터에서 시작해 3.12~3.14의 점진적 개선을 거쳐 이어지는 성능 파이프라인의 마지막 단계입니다.

  1. Tier 0: 인터프리터. 모든 코드는 여기서 시작합니다. 적응형 특화를 곁들인 표준 바이트코드 해석입니다. 함수가 여러 번 호출되면 자주 실행되는 명령어가 타입 특화 버전으로 교체됩니다.
  2. Tier 1: JIT 컴파일된 바이트코드. copy-and-patch JIT이 특화된 바이트코드를 기계어로 컴파일합니다. 디스패치 오버헤드가 사라지고, 컴파일된 템플릿 안에서 상수 폴딩이나 죽은 코드 제거 같은 기본적인 최적화가 가능해집니다.
  3. Tier 2 (향후): 트레이스 기반 최적화. 핫 코드 경로의 실행 트레이스를 기록하고, 함수 경계를 넘어 전체 트레이스를 최적화된 기계어로 컴파일하는 방식입니다. 계획은 되어 있지만 아직 출하되지 않았습니다.

이 계층화된 접근은 Ruby의 YJIT 등 최신 언어 런타임이 쓰는 방식과 비슷합니다. 빠른 해석으로 시작하고, 코드가 뜨거워지면 빠른 컴파일로 넘어가고, 비싼 최적화는 가장 뜨거운 경로에만 아껴 씁니다. 근본적인 통찰은 같습니다. 대부분의 코드는 최적화할 가치가 없으니, 컴파일 예산은 가장 많이 실행되는 코드에 쓰라는 것입니다.

메모리와 시작 시간의 영향

JIT 컴파일러는 컴파일된 코드를 위해 메모리를 씁니다. copy-and-patch JIT의 메모리 오버헤드는 적당합니다. 컴파일된 코드는 바이트코드보다 크지만, LLVM 기반 JIT이 만드는 코드보다는 작습니다(최적화로 인한 부풀림이 없기 때문입니다). 현재 구현은 대체하는 바이트코드의 약 1.5~3배 메모리를 사용하고, 실제로 자주 호출되어 이득이 될 만한 함수만 컴파일합니다.

짧게 실행되는 Python 스크립트에서는 시작 시간이 문제가 됩니다. JIT은 템플릿 라이브러리를 로드하고 컴파일 인프라를 준비하는 데 오버헤드가 있습니다. 1초 미만으로 끝나는 스크립트라면 JIT 오버헤드가 속도 향상을 넘어설 수도 있습니다. CPython은 함수가 임계 횟수만큼 호출된 뒤에만 JIT 컴파일을 하도록 해서 이 문제를 줄입니다. 수명이 짧은 스크립트는 인터프리터에 머물러 JIT 비용을 내지 않습니다.

이 동작은 설정할 수 있습니다. -X jit 플래그가 JIT 동작을 제어하고, 환경 변수로 컴파일 임계값을 조정할 수 있습니다. 시작 시간이 중요한 서버리스 함수나 CLI 도구라면 임계값을 올리거나 JIT을 아예 끌 수 있습니다. 정상 상태의 성능이 중요한 장시간 실행 서버나 데이터 처리 스크립트라면 기본값으로 충분히 잘 동작합니다.

Python 생태계에 미치는 영향

JIT이 Python의 성능 순위를 바꾸지는 않습니다. 연산 집약적인 작업에서는 C, Rust, Go, Java가 여전히 훨씬 빠릅니다. 바뀌는 것은 Python 개발자가 그런 대안을 꺼내 들어야 하는 기준점입니다.

순수 Python 코드에서 20~30% 빨라진다는 것은, 이전에는 C 확장이나 재작성이 필요했던 일부 작업이 이제 순수 Python만으로 충분히 빠르게 돌아간다는 뜻입니다. 10분 걸리던 데이터 처리 스크립트가 7분이 되고, 초당 1000개 요청을 처리하던 웹 서버가 1300개를 처리합니다. 혁명적인 숫자는 아니지만, '이 정도면 Python으로 충분하다'와 '이건 Go로 다시 짜야겠다' 사이를 가르는 차이입니다.

더 중요한 점은 JIT 인프라가 앞으로의 최적화 기반을 만든다는 것입니다. copy-and-patch 방식은 더 나은 템플릿, 더 많은 특화, 그리고 결국 트레이스 기반 컴파일로 확장할 수 있습니다. 각 Python 버전은 JIT의 근본 구조를 바꾸지 않고도 더 나은 템플릿을 출하할 수 있습니다. 3.15의 20~30% 속도 향상은 천장이 아니라 바닥입니다.

가장 인기 있으면서 가장 느린 언어 중 하나로 30년을 보낸 뒤, CPython은 드디어 성능에 본격적으로 투자하고 있습니다. JIT이 '파이썬은 너무 느리다' 진영을 만족시키지는 못할 겁니다. 어떤 방법으로도 그럴 수 없습니다. 일부 작업에서 Python은 실제로 너무 느리니까요. 하지만 대다수의 Python 코드, 즉 '그럭저럭 괜찮지만 훌륭하진 않던' 실행 속도를 가진 코드는 JIT 덕분에 '정말 좋은' 쪽으로 가까워집니다. 들리는 것보다 훨씬 큰 변화입니다.