필사 모드: CPython 바이트코드 인터프리터 Deep Dive — ceval.c, Specializing Adaptive, PEP 659, Copy-and-Patch JIT 완전 정복 (2025)
한국어TL;DR
- **CPython**은 "Python 소스 → AST → 바이트코드 → `ceval.c` 인터프리터" 파이프라인으로 실행된다. "해석 언어"라지만 실제로는 **컴파일 후 VM 실행**.
- **Bytecode**: 스택 기반 VM 명령어. `LOAD_FAST`, `BINARY_ADD`, `CALL_FUNCTION` 등. `.pyc` 파일에 저장.
- **ceval.c**: Python의 심장. 거대한 switch 또는 computed-goto 루프. 모든 Python 코드가 여기를 거친다.
- **Python 3.11+ PEP 659**: Specializing Adaptive Interpreter. 핫 경로를 런타임에 특수화된 opcode로 교체 → **25%+ 성능 향상**.
- **Inline Cache**: 각 opcode 뒤에 **cache 슬롯**. 첫 실행 시 프로파일 → 특수화 → 후속 실행 빠름.
- **Python 3.12 PEP 684**: Per-interpreter GIL. 서브인터프리터마다 독립 GIL → 병렬성 개선.
- **Python 3.13 PEP 703**: Free-threaded build. **GIL 제거** (실험적). 아직 기본 아님.
- **Python 3.13 PEP 744**: Copy-and-patch JIT. Tier 2 optimizer. 템플릿 기반 초고속 컴파일.
- **Frame Object**: 각 함수 호출의 실행 컨텍스트. 로컬/스택/명령 포인터/예외.
- **dis 모듈**: 바이트코드 확인. `dis.dis(f)`로 함수의 명령어 출력.
1. Python이 "느린" 이유
1.1 동적 타입의 대가
Python의 단순한 덧셈:
x + y
C에서 `int + int`는 CPU 명령어 **1개**(`add`). Python에서는?
1. `x`와 `y`의 타입 확인.
2. `x`의 `__add__` 메서드 검색.
3. 없으면 `y`의 `__radd__` 검색.
4. 메서드 호출.
5. 결과 객체 생성 (힙 할당).
6. 레퍼런스 카운트 조정.
수십 개의 C 명령어. 100배 이상 느리다.
1.2 Boxing
Python의 모든 값은 **객체**. `int` 하나도 `PyLongObject` 힙 객체:
typedef struct _longobject {
PyObject_VAR_HEAD
digit ob_digit[1];
} PyLongObject;
32 바이트 이상의 구조체. "1"도 이 크기.
1.3 Reference Counting
CPython의 메모리 관리는 **참조 카운트** 기반:
Py_INCREF(obj); // 카운트 +1
Py_DECREF(obj); // 카운트 -1, 0이면 해제
모든 객체 할당/해제에 이 오버헤드. 멀티스레드에선 atomic 필요 → 더 느림. GIL이 있는 이유 중 하나.
1.4 "그래서 Python은 영원히 느려야 하나?"
아니다. 2021년 이후 CPython은 **실질적 성능 혁명** 중이다:
- **Python 3.11** (2022 10월): 평균 25% 빠름.
- **Python 3.12** (2023): 추가 5%.
- **Python 3.13** (2024): Free-threaded + JIT (실험).
- **Python 3.14** (2025 예정): 더 공격적 최적화.
"Faster CPython" 프로젝트 (Guido van Rossum이 Microsoft 합류 후 시작). 목표: **Python 3.10 대비 5배 빠름**.
2. 컴파일 파이프라인
2.1 단계
Python source (hello.py)
↓
Lexer (tokenizer)
↓
Parser
↓
AST (abstract syntax tree)
↓
Compiler (ast → bytecode)
↓
Bytecode (code object)
↓
ceval.c interpreter
↓
실행
**놀라움**: Python도 "컴파일"한다. "해석 언어"라는 말은 기계어가 아닌 VM 명령어로 컴파일된다는 의미.
2.2 Lexer & Parser
Python 3.9+는 **PEG parser** (Pegen). 이전엔 LL(1) parser. PEG는 더 유연.
code = "x = 1 + 2\n"
tokens = list(tokenize.tokenize(io.BytesIO(code.encode()).readline))
for t in tokens:
print(t)
TokenInfo(type=ENCODING, string='utf-8', ...)
TokenInfo(type=NAME, string='x', ...)
TokenInfo(type=OP, string='=', ...)
TokenInfo(type=NUMBER, string='1', ...)
TokenInfo(type=OP, string='+', ...)
...
2.3 AST
파서의 출력은 **AST**:
tree = ast.parse("x = 1 + 2")
print(ast.dump(tree, indent=2))
Module(
body=[
Assign(
targets=[Name(id='x', ctx=Store())],
value=BinOp(
left=Constant(value=1),
op=Add(),
right=Constant(value=2)))],
type_ignores=[])
AST는 소스와 거의 1:1. `ast` 모듈로 조작 가능 → AST transform, macro 유사 기능.
2.4 Code Object
Compiler가 AST를 **code object**로 변환.
def add(a, b):
return a + b
print(add.__code__)
print(add.__code__.co_code) # 바이트코드 (bytes)
print(add.__code__.co_consts) # 상수들
print(add.__code__.co_names) # 글로벌 이름
print(add.__code__.co_varnames) # 로컬 이름
`PyCodeObject` 구조체:
- `co_code`: 바이트코드.
- `co_consts`: 상수 풀 (리터럴, docstring 등).
- `co_names`: 전역/attr 이름.
- `co_varnames`: 로컬 변수 이름.
- `co_flags`: 특수 플래그 (async, generator 등).
- `co_lnotab`: 라인 번호 테이블 (바이트코드 offset → 소스 라인).
- `co_stacksize`: 최대 스택 깊이.
2.5 .pyc 파일
Code object는 **marshal** 형식으로 `.pyc` 파일에 저장. `__pycache__/` 디렉토리.
`.pyc` 구조:
magic number (버전)
source mtime or hash
source size
marshaled code object
재실행 시 Python이 `.py`와 `.pyc` 비교 → 변경 없으면 `.pyc` 로드 (파싱 skip).
**구조 변경**: Python 3.7+는 `SOURCE_DATE_EPOCH`를 고려한 **reproducible** `.pyc` 지원.
3. 바이트코드 해부
3.1 dis 모듈
Python 코드의 바이트코드를 사람이 읽을 수 있게 출력:
def add(a, b):
return a + b
dis.dis(add)
2 0 RESUME 0
3 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+)
10 RETURN_VALUE
각 줄:
- 소스 라인 번호 (왼쪽).
- 바이트 offset.
- Opcode 이름.
- Operand (숫자).
- 주석 (dis가 추가).
3.2 Opcode
Python 3.12 기준 ~200개 opcode. 주요 카테고리:
**스택 조작**:
- `LOAD_CONST`: 상수를 스택에 push.
- `LOAD_FAST`: 로컬 변수.
- `LOAD_GLOBAL`: 전역.
- `LOAD_ATTR`: 속성 (obj.attr).
- `STORE_FAST`: 로컬 변수 저장.
- `POP_TOP`: 스택 top 제거.
- `DUP_TOP`: top 복제.
**산술**:
- `BINARY_OP`: +, -, *, /, // 등 (Python 3.11부터 통합).
- `COMPARE_OP`: `<`, `>`, `==`.
**제어 흐름**:
- `JUMP_FORWARD`: 무조건 점프.
- `POP_JUMP_IF_FALSE`: 조건 점프.
- `FOR_ITER`: 반복자 next.
**함수 호출**:
- `CALL`: 함수 호출.
- `RETURN_VALUE`: 반환.
- `MAKE_FUNCTION`: 함수 객체 생성.
**객체 생성**:
- `BUILD_LIST`, `BUILD_TUPLE`, `BUILD_DICT`.
3.3 스택 기반
Python VM은 **스택 기반**. 레지스터 없이 스택에서만 작동.
`a + b` 실행:
LOAD_FAST 0 # 스택: [a]
LOAD_FAST 1 # 스택: [a, b]
BINARY_OP 0 # a, b pop → a+b push. 스택: [a+b]
RETURN_VALUE # 스택 top 반환.
스택 기반의 장점:
- **명령어 단순**: operand 위치를 명시할 필요 없음.
- **컴파일 쉬움**: AST → 스택 push/pop 순차 생성.
단점:
- **레지스터 기반보다 명령어 수 많음**: 각 값이 push/pop 왕복.
- **JIT에 덜 친화적**: 레지스터 기반이 더 최적화 쉬움.
Lua, JVM(Java), .NET(CLR)은 스택 기반. Dalvik(Android)은 레지스터 기반.
3.4 Python 3.11의 변화
Python 3.11 이전:
LOAD_FAST 0
LOAD_FAST 1
BINARY_ADD # BINARY_SUB, BINARY_MULT 등 각자
Python 3.11+:
LOAD_FAST 0
LOAD_FAST 1
BINARY_OP 0 # 0=+, 5=*, 등. 통합된 하나
**통합의 이유**: PEP 659 specialization을 쉽게 하기 위해. 모든 이진 연산이 하나의 "적응형" opcode를 거침.
3.5 더 복잡한 예
def fib(n):
if n < 2:
return n
return fib(n-1) + fib(n-2)
2 0 RESUME 0
3 2 LOAD_FAST 0 (n)
4 LOAD_CONST 1 (2)
6 COMPARE_OP 40 (<)
10 POP_JUMP_IF_FALSE 6 (to 24)
4 12 LOAD_FAST 0 (n)
14 RETURN_VALUE
5 >> 24 LOAD_GLOBAL 1 (NULL + fib)
36 LOAD_FAST 0 (n)
38 LOAD_CONST 1 (2)
40 BINARY_OP 10 (-)
44 CALL 1
52 LOAD_GLOBAL 1 (NULL + fib)
64 LOAD_FAST 0 (n)
66 LOAD_CONST 2 (1)
68 BINARY_OP 10 (-)
72 CALL 1
80 BINARY_OP 0 (+)
84 RETURN_VALUE
재귀 호출, 조건 분기, 산술의 조합.
4. ceval.c — Python의 심장
4.1 파일 개요
`Python/ceval.c`는 **CPython에서 가장 중요한 파일**. 대략 **8,000 줄** (Python 3.12 기준).
주요 함수:
- `_PyEval_EvalFrameDefault()`: 메인 인터프리터 루프.
- `PyEval_EvalCode()`: 최상위 code 실행.
4.2 Frame 실행
Python 함수가 호출되면:
1. 새 **frame object** 생성.
2. 로컬 변수 초기화.
3. `_PyEval_EvalFrameDefault(frame)` 호출.
4. 루프 실행.
5. 리턴 시 frame 해제.
typedef struct _PyInterpreterFrame {
PyFunctionObject *f_funcobj;
PyObject *f_builtins;
PyObject *f_globals;
PyObject *f_locals;
PyCodeObject *f_code;
PyObject *frame_obj;
struct _PyInterpreterFrame *previous;
_Py_CODEUNIT *prev_instr; // 현재 명령어
int stacktop;
bool is_entry;
char owner;
PyObject *localsplus[1]; // 로컬 + 스택 (flex array)
} _PyInterpreterFrame;
`localsplus`가 핵심. 로컬 변수와 evaluation stack이 하나의 배열에 붙어있다 (성능 이유).
4.3 Main Loop 구조
단순화된 pseudo code:
PyObject *
_PyEval_EvalFrameDefault(_PyInterpreterFrame *frame, int throwflag)
{
_Py_CODEUNIT *next_instr = frame->prev_instr + 1;
PyObject **stack_pointer = _PyFrame_GetStackPointer(frame);
while (1) {
_Py_CODEUNIT word = *next_instr++;
int opcode = _Py_OPCODE(word);
int oparg = _Py_OPARG(word);
switch (opcode) {
case LOAD_FAST: {
PyObject *value = frame->localsplus[oparg];
Py_INCREF(value);
*stack_pointer++ = value;
DISPATCH();
}
case LOAD_CONST: {
PyObject *value = PyTuple_GET_ITEM(
frame->f_code->co_consts, oparg);
Py_INCREF(value);
*stack_pointer++ = value;
DISPATCH();
}
case BINARY_OP: {
PyObject *right = *(--stack_pointer);
PyObject *left = *(--stack_pointer);
PyObject *result = binary_op(left, right, oparg);
Py_DECREF(left);
Py_DECREF(right);
if (result == NULL) goto error;
*stack_pointer++ = result;
DISPATCH();
}
case RETURN_VALUE: {
PyObject *retval = *(--stack_pointer);
// frame 정리
return retval;
}
// ... 수백 개 case
}
}
}
**거대한 switch**. 각 opcode가 자기 handler.
4.4 Computed GOTO
`switch` 기반은 느릴 수 있다. 각 iter마다:
1. `next_instr++` 읽기.
2. `switch` 테이블 조회.
3. 점프.
분기 예측 실패가 많다. GCC와 Clang은 **computed goto** 확장 지원:
#define DISPATCH() goto *opcode_targets[opcode];
static void *opcode_targets[] = {
[LOAD_FAST] = &&LOAD_FAST_LABEL,
[LOAD_CONST] = &&LOAD_CONST_LABEL,
...
};
LOAD_FAST_LABEL:
// ... handler ...
DISPATCH();
LOAD_CONST_LABEL:
// ...
DISPATCH();
Switch 대신 **직접 점프**. CPU의 branch predictor가 각 opcode 별로 **독립된 history**를 가짐. Switch 기반보다 10-15% 빠름.
Python은 `USE_COMPUTED_GOTOS` 매크로로 컴파일 시 결정. GCC/Clang에서 기본 활성화.
4.5 Dispatch Overhead
이 루프의 오버헤드는 사소하지 않다. 각 opcode 실행 시:
- fetch.
- decode.
- execute (작은 경우).
- branch predict.
각 Python 명령어가 수 ns. 1,000,000 명령어 실행 = 수 ms의 "오버헤드만".
C 코드는 같은 일을 수백 ns에 한다. 이것이 Python 속도의 큰 부분.
5. Frame과 호출 스택
5.1 Frame의 역할
각 함수 호출이 새 frame. Frame은 "실행 중인 함수 하나의 상태".
def outer():
x = 1
inner()
def inner():
print("hi")
outer()
실행 중:
Frame stack:
inner frame (top)
outer frame
`inner`가 리턴하면 그 frame pop, `outer` frame으로 제어 이동.
5.2 Python 3.11의 Frame 개선
Python 3.11에서 frame 할당 비용 대폭 감소:
- **이전**: 각 frame이 heap 객체 (malloc).
- **3.11+**: frame이 **C stack**에 할당. 필요할 때만 heap.
결과: 함수 호출 50% 빠름.
5.3 traceback과 frame
Exception 발생 시 traceback은 frame stack을 탐색:
def inner():
raise ValueError("oops")
def outer():
inner()
try:
outer()
except:
traceback.print_exc()
Traceback (most recent call last):
File "a.py", line 8, in <module>
outer()
File "a.py", line 6, in outer
inner()
File "a.py", line 3, in inner
raise ValueError("oops")
각 줄이 frame에 대응.
5.4 sys._getframe
frame = sys._getframe()
print(frame.f_code.co_name) # 현재 함수 이름
print(frame.f_locals) # 로컬 변수 dict
print(frame.f_back) # 호출자 frame
디버거, 프로파일러, logging 라이브러리가 활용.
5.5 f-string과 Frame
f-string `f"{x}"`는 컴파일 타임에 해석되지만, **값은 런타임 frame의 로컬**. 그래서 `f"{name}"`은 현재 frame에서 `name`을 찾음.
6. Specializing Adaptive Interpreter (PEP 659)
Python 3.11의 가장 큰 혁신. Faster CPython 프로젝트의 핵심.
6.1 문제
`LOAD_ATTR` opcode를 생각해보자:
def get(obj):
return obj.name
`obj.name` 실행 시 CPython이 하는 일:
1. `obj`의 `type`의 MRO 순회.
2. `name`을 descriptor로 찾기.
3. 찾으면 descriptor protocol.
4. 못 찾으면 `obj.__dict__` 조회.
5. `__getattr__` fallback.
**복잡하다**. 그런데 대부분의 경우:
- `obj`가 같은 타입.
- `name`이 `obj.__dict__`의 직접 키.
캐시할 수 있지 않나?
6.2 Inline Cache
**Inline cache**는 각 opcode 옆에 **cache slot**을 두는 기법. JavaScript의 V8, Java의 HotSpot이 사용.
CPython 3.11+: **adaptive opcodes**.
LOAD_ATTR 0 ← 일반 opcode
첫 실행 시:
- 프로파일링: obj type, offset 기록
- Opcode를 LOAD_ATTR_INSTANCE_VALUE로 교체 (내부에서만)
다음 실행 시:
- 새로운 specialized 버전 사용
- Type 확인 (가정한 type과 같은가?)
- 같으면: 직접 offset으로 접근 (매우 빠름)
- 다르면: 원래 LOAD_ATTR로 fallback
6.3 Inline Cache 구조
[LOAD_ATTR opcode (2 bytes)] [cache slot (여러 바이트)]
Cache slot에는:
- 예상 type의 version tag.
- Dict offset 또는 descriptor 캐시.
- 통계 (hit/miss).
이것이 **inline** 이유: cache가 바이트코드 스트림 자체에 포함.
6.4 Specialization 예시
**LOAD_ATTR**의 specialized variants:
- `LOAD_ATTR_INSTANCE_VALUE`: `obj.__dict__`에 직접 있는 속성.
- `LOAD_ATTR_WITH_HINT`: dict offset hint 사용.
- `LOAD_ATTR_SLOT`: `__slots__` 기반.
- `LOAD_ATTR_MODULE`: 모듈 전역.
- `LOAD_ATTR_CLASS`: 클래스 속성.
- `LOAD_ATTR_METHOD_WITH_VALUES`: method + instance 동시.
- `LOAD_ATTR_PROPERTY`: property descriptor.
각자 별도 C 코드 경로 → **가정이 맞으면 매우 빠름**.
6.5 BINARY_OP 특수화
BINARY_OP_ADD_INT
BINARY_OP_ADD_FLOAT
BINARY_OP_ADD_UNICODE
BINARY_OP_MULTIPLY_INT
BINARY_OP_SUBTRACT_INT
...
`x + y`가 항상 int라면 `BINARY_OP_ADD_INT`로 교체. 타입 체크 후 바로 int 덧셈. 10배 이상 빠름.
6.6 De-optimization
가정이 틀리면?
def add(a, b):
return a + b
add(1, 2) # → BINARY_OP_ADD_INT
add(1.0, 2.0) # → INT 가정 실패 → 원래 BINARY_OP로 fallback
일정 실패 횟수 후 opcode를 de-optimize. 하지만 re-specialize도 가능.
6.7 성능 결과
Python 3.11 릴리스 노트:
- **pyperformance 벤치마크**: 평균 **25%+ 빠름**.
- 일부 벤치마크: 60% 이상.
- Django 템플릿 렌더링: 15% 빠름.
실제 코드에서 눈에 띄는 차이. 그리고 **사용자는 코드를 전혀 바꿀 필요 없다**.
6.8 성공의 의미
PEP 659는 **JIT 없이** 성능 향상을 증명했다. "Python을 빠르게 하려면 JIT이 필요하다"는 통념 반박. 단순 인터프리터 최적화로도 큰 이득.
그렇다고 JIT이 불필요한 건 아니다 — 다음 단계. 하지만 JIT 없이도 갈 길이 많이 남았음을 보임.
7. Python 3.12 — Per-Interpreter GIL
7.1 Sub-Interpreters
CPython에는 오래된 기능이 있다: **서브인터프리터**. 한 프로세스 안에 여러 독립적 Python 상태.
PyInterpreterState *sub = Py_NewInterpreter();
// 새 interpreter에서 코드 실행
Py_EndInterpreter(sub);
문제: 이전엔 서브인터프리터들이 **하나의 GIL 공유**. 병렬 실행 불가. "왜 있는 건지" 의문.
7.2 PEP 684
**Per-Interpreter GIL** (Python 3.12). 각 서브인터프리터가 **자기 GIL**을 가짐.
프로세스
├── Main Interpreter (GIL 1)
├── Sub 1 (GIL 2) ← 독립 병렬 실행
└── Sub 2 (GIL 3) ← 독립 병렬 실행
결과: C API로 서브인터프리터 생성하면 진짜 병렬 Python 코드 실행 가능.
7.3 Python에서 사용
Python 3.13+에서 `interpreters` 모듈 (실험):
interp = interpreters.create()
interp.run("""
print(math.pi)
""")
각 interpreter에서 코드가 진짜 병렬 실행.
7.4 한계
- **공유 객체 제한**: 두 interpreter가 같은 Python 객체를 공유 불가 (채널로 전달).
- **C 확장 호환성**: 많은 기존 확장이 per-interpreter state 지원 안 함.
- **메모리 비용**: 각 interpreter가 자체 상태.
하지만 "CPU-bound Python 병렬"의 새 옵션.
7.5 GIL 없는 프로세스 vs 공유 메모리 interpreter
**multiprocessing**: 프로세스 분리 → 완전 독립, 공유 비싸다.
**Sub-interpreter**: 프로세스 공유, 공유 객체 제한 → 경량 병렬.
중간 지점. 특정 워크로드(plugin system, sandbox)에 유용.
8. Python 3.13 — Free-Threaded Build
8.1 Sam Gross의 실험
2021년 Sam Gross가 **"nogil" 브랜치**를 공개. CPython을 GIL 없이 실행. 성능 하락 최소화.
놀라운 결과였다. 몇 년에 걸친 개선 후 **PEP 703** 수락.
8.2 PEP 703
**Python 3.13 (2024년 10월)**: Free-threaded build 옵션 추가.
./configure --disable-gil
make
./python
이 빌드는:
- **GIL 없음**.
- 여러 스레드가 진짜 병렬 실행.
- 모든 내장 객체는 **thread-safe** (새 lock 필요).
8.3 기술적 도전
**Reference counting**: 원래 GIL이 보호했다. GIL 없으면 race condition.
해결: **biased reference counting**. 각 객체의 "owner" 스레드를 추적. Owner가 수정 시 lock 없이, 다른 스레드가 수정 시 atomic.
**Dict thread-safety**: 모든 dict에 per-bucket lock.
**GC 변경**: stop-the-world를 줄이고 incremental 수준으로.
8.4 성능
초기 결과:
- **Single-threaded**: GIL 없는 버전이 ~5-10% 느림 (atomic 오버헤드).
- **Multi-threaded CPU**: N 스레드에서 선형 확장 (GIL 버전보다 N배).
"Tradeoff". 싱글 스레드 코드는 약간 느리지만 멀티는 대폭 빠름.
8.5 Python 3.13 상태
**실험적**. 기본 빌드가 아님. 사용자가 `--disable-gil`로 직접 빌드 또는 특수 wheel 설치.
**Python 3.14**부터 기본에 포함될 가능성. **Python 3.15**에 안정화.
생태계 호환성이 주요 과제: 수많은 C 확장이 thread-safety 검토 필요.
9. Python 3.13 — Copy-and-Patch JIT
9.1 JIT의 오래된 약속
PyPy가 오래 전부터 JIT으로 Python을 수 배 빠르게. 하지만 복잡하고 호환성 이슈.
CPython은 JIT을 피해왔다. 2024년 바뀌었다.
9.2 PEP 744
**Copy-and-Patch JIT**. Brandt Bucher, Mark Shannon 등이 주도.
특징:
- **템플릿 기반**: 각 opcode에 대해 미리 컴파일된 기계어 스니펫.
- **초고속 컴파일**: 단순히 스니펫을 복사하고 주소를 패치.
- **Tier 2 optimizer**: Tier 1(인터프리터)로 시작, 핫 경로만 JIT.
9.3 아이디어
일반 JIT은 "온디맨드 컴파일러" — 런타임에 LLVM 같은 걸 돌림. **느리다**.
Copy-and-patch: 빌드 시점에 각 opcode의 기계어를 LLVM으로 컴파일해두고, 런타임에는 **메모리 복사 + 주소 수정**만.
// 빌드 시 컴파일된 템플릿:
// (pseudo code)
const char LOAD_FAST_template[] = {
// mov rax, [rdi + <FRAME_OFFSET_PLACEHOLDER>]
0x48, 0x8B, 0x47, 0x00, 0x00, 0x00, 0x00,
// push rax
0x50,
...
};
// 런타임:
memcpy(code_buffer, LOAD_FAST_template, sizeof(LOAD_FAST_template));
patch_offset(code_buffer + 3, actual_offset);
수 μs에 기계어 생성. LLVM JIT는 수 ms.
9.4 Tier 2 Optimizer
**Tier 1**: 일반 인터프리터 (인라인 캐시 포함).
**Tier 2**: 핫 trace를 검출해서 specialized bytecode (superinstruction) 생성.
**Tier 2 JIT**: Tier 2의 superinstruction을 copy-and-patch로 기계어화.
단계적 최적화. 가장 뜨거운 코드만 기계어.
9.5 성능
초기 벤치마크: **추가 5-10% 성능**. PEP 659의 specialization과 결합.
Python 3.13은 JIT이 **실험적**. Python 3.14부터 성숙. **Python 3.15-3.16**에서 주류.
최종 목표: **C 수준의 속도에 점진적 접근**. PyPy 수준(2-10배)까지는 아직 멀지만 방향이 확립됐다.
10. Generator와 Coroutine
10.1 Generator Function
def count_up():
n = 0
while True:
yield n
n += 1
이건 일반 함수가 아니다. `yield`가 있으면 **generator function**. 호출 시 generator object 반환.
gen = count_up()
next(gen) # 0
next(gen) # 1
next(gen) # 2
10.2 Frame Suspension
Generator의 핵심: **frame이 유지된다**.
일반 함수는 리턴 시 frame 해제. Generator는 `yield` 시 frame을 **suspend** — 다음 `next()`까지 보존.
gen = count_up()
↓
Frame 생성, n=0
↓
next(gen) → 루프 진입 → yield 0 → frame suspend
↓
next(gen) → frame resume → n += 1 → yield 1 → suspend
↓
...
C 수준에서 `_PyInterpreterFrame`이 heap에 유지되고 `prev_instr`가 yield 다음 위치를 가리킴.
10.3 Coroutine과 async
async def fetch():
data = await download()
return process(data)
`async def`는 generator의 일반화. **coroutine** 객체 반환. `await`은 yield의 일반화.
내부적으로 같은 frame suspension 메커니즘. `asyncio` 이벤트 루프가 이 coroutine들을 스케줄.
10.4 yield from / await chaining
async def outer():
return await inner()
내부적으로 frame chain. `outer`의 frame이 `inner`의 frame을 기다림. Stack 없이 frame 기반 호출 스택.
10.5 Async 성능
Python의 async는 **C 레벨 frame suspension** 덕분에 매우 빠르다. 스레드보다 가볍다 (고루틴 유사). 하지만 GIL 때문에 CPU 병렬은 여전히 불가.
11. Import System과 Bytecode Caching
11.1 import 동작
CPython:
1. `sys.modules`에서 'math' 확인. 있으면 반환.
2. `sys.meta_path`의 finder들을 순회.
3. Finder가 loader 반환.
4. Loader가 module 생성 + 코드 실행.
5. `sys.modules`에 등록.
11.2 .pyc 캐시
소스 파일:
a.py
캐시:
__pycache__/a.cpython-312.pyc
다음 import 시:
1. `a.py` mtime 확인.
2. `.pyc`의 mtime과 비교.
3. 일치하면 `.pyc` 로드 (파싱 skip → 빠름).
4. 다르면 재컴파일.
11.3 Invalidation 모드
전통: timestamp 기반
python -m py_compile a.py
hash 기반 (3.7+)
python -m py_compile --invalidation-mode=checked_hash a.py
Hash 기반은 reproducible builds에 필요 (Docker, CI).
11.4 Frozen Modules
일부 stdlib 모듈은 **frozen**: 바이트코드가 CPython 바이너리 자체에 포함.
>>> import sys
>>> sys.modules['_frozen_importlib']
Frozen 모듈은 디스크 읽기 불필요 → 매우 빠른 startup. Python 3.11+ 많은 stdlib 모듈이 frozen.
12. Exception Handling
12.1 `try` 블록 바이트코드
Python 3.10까지:
try:
risky()
except ValueError:
handle()
SETUP_FINALLY target
risky()
POP_BLOCK
JUMP_FORWARD
target:
<예외 처리 경로>
**SETUP_FINALLY**: "이 이후 예외가 나면 target으로 점프".
12.2 Python 3.11: Zero-Cost Exceptions
3.11은 **예외 처리가 공짜**다. try 블록 진입 시 bytecode 오버헤드 없음.
대신 exception table을 사용:
exception_table:
[bytecode range] → [handler location, stack_depth]
예외 발생 시 이 테이블을 조회 → 해당 범위의 핸들러 찾기.
"try 블록이 많아도 성능 손실 없음". Rust의 접근과 유사 (DWARF-based unwinding).
12.3 Raise와 Unwinding
`raise` 시:
1. Exception 객체 생성.
2. Frame stack 순회하며 handler 찾기.
3. 없으면 프로그램 종료.
4. 있으면 stack unwind 후 handler 실행.
각 frame의 exception table 조회. unwind마다 frame의 로컬 정리 (finalizer 호출).
13. 튜닝과 최적화
13.1 무엇이 느린가 찾기
**cProfile**:
cProfile.run("myfunc()")
43 function calls in 0.005 seconds
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 0.005 0.005 <string>:1(<module>)
10 0.001 0.000 0.005 0.000 a.py:5(work)
30 0.004 0.000 0.004 0.000 a.py:2(helper)
**line_profiler**:
@profile
def myfunc():
줄 단위 시간 측정
**py-spy**: 샘플링 프로파일러. 실행 중인 프로세스에 attach 가능.
13.2 일반 팁
**Built-in 사용**:
sum(xs) # 빠름
vs
total = 0
for x in xs: total += x # 느림
**Local var > Global**:
def f():
sqrt = math.sqrt # local로
for x in data:
y = sqrt(x) # LOAD_FAST (빠름)
LOAD_FAST는 LOAD_GLOBAL보다 빠름.
**List comprehension > for loop**:
[x*2 for x in data] # 빠름 (특수 최적화)
**Generator에서 list()**: 불필요한 list 생성 피하기.
13.3 Cython / PyPy / C Extension
**Cython**: Python 문법 + type annotation → C 컴파일 → 10-100x.
fib.pyx
def fib(int n):
cdef int a = 0, b = 1
for _ in range(n):
a, b = b, a + b
return a
**PyPy**: CPython 대체. 자체 JIT. 호환성 이슈.
**C 확장 직접 작성**: 최고 성능, 개발 시간.
**FFI (cffi, ctypes)**: 이미 있는 C 라이브러리 wrap.
13.4 New Alternatives
**Mojo** (Modular): Python 상위 집합 + 타입 + AI 최적화.
**Codon**: LLVM 기반 Python 컴파일러.
**Nuitka**: Python → C++.
14. 내부 탐험 도구
14.1 dis
dis.dis(my_function)
dis.show_code(my_function.__code__)
14.2 gc
gc.get_objects() # 모든 객체
gc.collect() # 수동 GC
gc.get_threshold() # generational 임계
gc.set_debug(gc.DEBUG_STATS)
14.3 sys
sys.getsizeof(obj) # 객체 크기 (바이트)
sys.getrefcount(obj) # 참조 카운트
sys.settrace(tracer) # 모든 함수 호출 훅
sys.getframeinfo(frame)
14.4 tracemalloc
tracemalloc.start()
... code ...
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
for stat in top_stats[:10]:
print(stat)
메모리 할당 추적. "어디서 메모리를 많이 쓰나" 답변.
14.5 CPython 소스 읽기
**가장 배우는 방법**. GitHub: `python/cpython`.
주요 파일:
- `Python/ceval.c`: 인터프리터 루프.
- `Objects/`: 모든 내장 타입 (`longobject.c`, `listobject.c` 등).
- `Python/compile.c`: AST → bytecode.
- `Include/internal/`: 내부 헤더.
15. 역사적 변화 타임라인
15.1 주요 변화
- **Python 1.0** (1994): 첫 안정화.
- **Python 2.0** (2000): 리스트 comprehension, GC.
- **Python 3.0** (2008): 대대적 정리. 호환성 포기.
- **Python 3.6** (2016): f-string, type hint 기본.
- **Python 3.8** (2019): Walrus operator, Positional-only params.
- **Python 3.9** (2020): PEG parser.
- **Python 3.10** (2021): Structural pattern matching, Better errors.
- **Python 3.11** (2022): **Specializing adaptive interpreter** (PEP 659). 25% 빠름.
- **Python 3.12** (2023): Per-interpreter GIL, type param syntax.
- **Python 3.13** (2024): **Free-threaded build**, **JIT experimental**.
- **Python 3.14** (2025 예정): JIT 확장, free-threaded 성숙.
15.2 Performance 진화
Python 3.10: baseline
Python 3.11: 1.25x 빠름
Python 3.12: 1.3x
Python 3.13: ~1.4x (JIT 포함 시)
Python 3.15 예상: 2-3x
Faster CPython 프로젝트 목표: **Python 3.10의 5배**. 아직 갈 길이 있지만 방향 확립.
16. 학습 리소스
**책**:
- "CPython Internals" — Anthony Shaw. 유일한 종합서.
- "Python Internals for Developers" — Obi Ike-Nwosu.
**온라인**:
- "Faster CPython" 프로젝트: GitHub discussions.
- Łukasz Langa의 블로그 (Python core dev).
- Brett Cannon의 "How import works" 시리즈.
**영상**:
- "CPython from the Inside Out" — Philip Guo.
- PyCon, EuroPython의 CPython internals 토크.
- Guido van Rossum, Mark Shannon, Brandt Bucher의 발표들.
**코드**:
- `python/cpython` GitHub.
- PEP 659, 703, 744 문서.
**대안 구현**:
- **PyPy**: JIT 기반.
- **MicroPython**: 임베디드.
- **RustPython**: Rust로 재구현.
- **Pyston**: LLVM 기반 (Dropbox 출신).
17. 요약 — 한 장 정리
┌─────────────────────────────────────────────────────┐
│ CPython Internals Cheat Sheet │
├─────────────────────────────────────────────────────┤
│ 컴파일 파이프라인: │
│ Source → Lexer → Parser (PEG) │
│ → AST → Compiler → Bytecode → Code Object │
│ → .pyc 캐시 │
│ │
│ Bytecode: │
│ Stack-based VM │
│ ~200 opcodes │
│ LOAD_FAST, BINARY_OP, CALL, RETURN_VALUE │
│ dis 모듈로 확인 │
│ │
│ ceval.c: │
│ _PyEval_EvalFrameDefault (메인 루프) │
│ 거대한 switch 또는 computed goto │
│ 각 opcode = C 코드 handler │
│ │
│ Frame: │
│ _PyInterpreterFrame │
│ 로컬 + 스택 (localsplus 배열) │
│ Python 3.11+: C stack 할당 (빠름) │
│ │
│ PEP 659 (3.11+): │
│ Specializing adaptive interpreter │
│ Inline cache (바이트코드 옆) │
│ Opcode를 specialized version으로 in-place 교체 │
│ LOAD_ATTR_INSTANCE_VALUE │
│ BINARY_OP_ADD_INT │
│ De-optimize on fail │
│ +25% 성능 │
│ │
│ PEP 684 (3.12): │
│ Per-interpreter GIL │
│ 서브인터프리터 진짜 병렬 │
│ │
│ PEP 703 (3.13): │
│ Free-threaded build │
│ GIL 제거 (실험) │
│ Biased reference counting │
│ │
│ PEP 744 (3.13): │
│ Copy-and-patch JIT │
│ 템플릿 기반 초고속 컴파일 │
│ Tier 2 optimizer │
│ 실험적 │
│ │
│ Generator / async: │
│ Frame suspension │
│ yield / await = frame 보존 │
│ │
│ Exception (3.11+): │
│ Zero-cost exception handling │
│ Exception table │
│ │
│ 성능 툴: │
│ cProfile, line_profiler, py-spy │
│ tracemalloc, gc module │
│ │
│ 대안 구현: │
│ PyPy (JIT) │
│ Cython (C 컴파일) │
│ MicroPython, RustPython │
│ Mojo, Codon (2024+) │
└─────────────────────────────────────────────────────┘
18. 퀴즈
**A.** 부분적으로 맞지만 오해를 일으킨다. Python 소스는 **바이트코드로 컴파일**된다 — `.pyc` 파일이 이 결과. 실행 시점에는 기계어가 아니라 **바이트코드 VM**(`ceval.c`의 거대한 dispatch loop)이 해석한다. 즉 "컴파일되는 것 맞지만 타겟이 기계어가 아니라 VM 명령어"다. Java와 C#도 같은 접근 (JVM, CLR). 차이는 Java/C#이 JIT으로 기계어화하는 반면 CPython은 오랫동안 pure interpreter였다. Python 3.13부터 copy-and-patch JIT이 실험적으로 추가 → "pure interpreter" 시대가 끝나가고 있다.
**A.** **분기 예측 정확도**. Switch 기반 dispatch는 모든 opcode가 하나의 jump 위치에서 분기 → CPU의 branch predictor가 "다음 opcode"를 예측하기 어려움(pattern이 opcode 시퀀스에 달림). Computed GOTO는 **각 opcode handler 끝에서 직접 다음 opcode로 점프**하는 코드를 두어서, 각 jump location마다 **독립된 branch history**를 가진다. 예: `LOAD_FAST` 뒤에 자주 `LOAD_FAST`가 오면 그 jump만 그 패턴을 학습. 결과: 10-15% 빠른 인터프리터. GCC/Clang의 `&&label` 확장 필요하지만 이것이 현대 인터프리터(Python, Ruby, Lua, V8)의 표준.
**A.** **Inline cache 기반 런타임 opcode 교체**. 각 opcode 옆에 cache slot이 있어서 첫 실행 시 프로파일링(어떤 타입? 어떤 offset?) 후 **opcode 자체를 specialized version으로 in-place 교체**. 예: `LOAD_ATTR` → 첫 실행 시 `obj`가 특정 type이고 `name`이 그 type의 `__dict__`에 있음을 확인 → opcode가 `LOAD_ATTR_INSTANCE_VALUE`로 바뀜. 다음 실행: type version tag 확인(빠름) → 일치 시 dict 검색 건너뛰고 직접 offset 접근 → 기존 대비 5-10배 빠름. 가정이 틀리면 de-optimize → 원래 opcode로 복귀. V8이나 HotSpot의 inline cache를 "JIT 없이 인터프리터에만" 적용한 것. Python 3.11에서 25%+ 성능 향상의 주역.
**A.** **Reference counting의 atomic 비용**. CPython은 모든 객체에 refcount가 있고, 참조할 때마다 +1, 해제할 때마다 -1 한다. GIL이 있는 buildings에서는 단순 정수 연산(수 ns). GIL이 없으면 refcount가 여러 스레드에서 동시 수정될 수 있으므로 **atomic operation**이 필요(수십 ns). 모든 객체 접근에 이 오버헤드가 붙어서 single-threaded 벤치마크에서 5-10% 느려진다. Sam Gross의 해결책: **biased reference counting** — "owner thread"는 atomic 없이 증감, 다른 스레드만 atomic. 평균적으로 여전히 약간 느리지만 multi-threaded에서는 선형 확장. Trade-off: "single-threaded 5% 손해 vs multi-threaded N배 이득".
**A.** **컴파일 시점과 런타임 분리**. 일반 JIT은 런타임에 LLVM 같은 컴파일러를 돌려 기계어를 생성 → 컴파일 시간 수 ms. 대부분 프로그램이 런타임에 수만 함수를 본다면 JIT 비용이 누적. Copy-and-Patch는 **빌드 시점에 LLVM으로 각 opcode의 기계어 템플릿을 컴파일**해두고, 런타임에는 **메모리에 템플릿을 복사하고 주소/상수만 패치**. 수 μs에 기계어 생성 — LLVM의 1000배 빠름. 품질은 LLVM보다 덜 공격적이지만 Python의 특성상 "JIT의 75%를 25%의 노력으로" 얻는다. Python 3.13의 Tier 2 optimizer에서 실험적으로 사용, 2025-2026년 성숙 예정. 작은 혁신(템플릿 아이디어)이 기존 JIT 인프라를 뒤흔드는 사례.
**A.** **Frame의 생명주기**. 일반 함수는 호출 시 frame 생성, 리턴 시 해제. Generator는 `yield` 시 **frame을 suspend** — frame을 heap에 유지하고 `prev_instr`(현재 명령어 포인터)를 yield 다음 위치로 설정. `next()` 호출 시 frame을 resume → 마지막 위치부터 계속. 로컬 변수, eval stack, 모든 상태가 frame에 보존되므로 "함수가 중간에서 재개"되는 것처럼 동작. `async def`는 동일 메커니즘의 일반화 — coroutine은 generator의 확장. 이 덕분에 Python async는 스레드 없이 가볍게 동작하고(고루틴과 유사), `asyncio` 이벤트 루프가 coroutine의 frame을 스케줄한다. 모든 것이 결국 "frame 보존" 트릭 하나.
**A.** **`try` 블록 진입 시의 bytecode 오버헤드**. Python 3.10까지는 `try` 진입 시 `SETUP_FINALLY` opcode가 실행되어 "예외 발생 시 어디로 점프할지"를 설정. 예외가 없어도 이 오버헤드가 매번 발생. 3.11+는 **exception table**을 코드 오브젝트에 저장 — "bytecode 범위 X~Y에서 예외 발생 시 Z로"를 컴파일 타임에 정적으로 기록. 런타임에 `try` 진입은 **아무 일도 안 함** (bytecode 실행 안 함). 예외가 실제로 발생하면 그때 테이블 조회. Rust의 DWARF 기반 zero-cost exceptions과 같은 접근. 결과: try/except가 많은 코드에서 성능 손실 없음. "예외는 정상 경로를 느리게 하지 않는다"는 원칙 준수.
이 글이 도움이 됐다면 다음 포스트도 확인해 보세요:
- "Python GIL과 CPython 내부" — 같은 프로젝트의 병렬성 이슈.
- "JIT Compilation V8 & JVM" — 유사한 기법의 다른 언어.
- "Rust Tokio Async Runtime" — zero-cost async의 극단적 접근.
- "Diffusion Models" — Python이 AI 워크로드를 돌리는 배경.
현재 단락 (1/691)
- **CPython**은 "Python 소스 → AST → 바이트코드 → `ceval.c` 인터프리터" 파이프라인으로 실행된다. "해석 언어"라지만 실제로는 **컴파일 후 VM ...