- Published on
CPython 바이트코드 인터프리터 Deep Dive — ceval.c, Specializing Adaptive, PEP 659, Copy-and-Patch JIT 완전 정복 (2025)
- Authors

- Name
- Youngju Kim
- @fjvbn20031
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에서는?
x와y의 타입 확인.x의__add__메서드 검색.- 없으면
y의__radd__검색. - 메서드 호출.
- 결과 객체 생성 (힙 할당).
- 레퍼런스 카운트 조정.
수십 개의 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는 더 유연.
import tokenize
import io
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:
import 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 코드의 바이트코드를 사람이 읽을 수 있게 출력:
import dis
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 함수가 호출되면:
- 새 frame object 생성.
- 로컬 변수 초기화.
_PyEval_EvalFrameDefault(frame)호출.- 루프 실행.
- 리턴 시 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마다:
next_instr++읽기.switch테이블 조회.- 점프.
분기 예측 실패가 많다. 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
<module> frame (bottom)
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을 탐색:
import traceback
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
import sys
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이 하는 일:
obj의type의 MRO 순회.name을 descriptor로 찾기.- 찾으면 descriptor protocol.
- 못 찾으면
obj.__dict__조회. __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 모듈 (실험):
import interpreters
interp = interpreters.create()
interp.run("""
import math
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 동작
import math
CPython:
sys.modules에서 'math' 확인. 있으면 반환.sys.meta_path의 finder들을 순회.- Finder가 loader 반환.
- Loader가 module 생성 + 코드 실행.
sys.modules에 등록.
11.2 .pyc 캐시
소스 파일:
a.py
캐시:
__pycache__/a.cpython-312.pyc
다음 import 시:
a.pymtime 확인..pyc의 mtime과 비교.- 일치하면
.pyc로드 (파싱 skip → 빠름). - 다르면 재컴파일.
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']
<module '_frozen_importlib' (frozen)>
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 시:
- Exception 객체 생성.
- Frame stack 순회하며 handler 찾기.
- 없으면 프로그램 종료.
- 있으면 stack unwind 후 handler 실행.
각 frame의 exception table 조회. unwind마다 frame의 로컬 정리 (finalizer 호출).
13. 튜닝과 최적화
13.1 무엇이 느린가 찾기
cProfile:
import 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
import dis
dis.dis(my_function)
dis.show_code(my_function.__code__)
14.2 gc
import gc
gc.get_objects() # 모든 객체
gc.collect() # 수동 GC
gc.get_threshold() # generational 임계
gc.set_debug(gc.DEBUG_STATS)
14.3 sys
import sys
sys.getsizeof(obj) # 객체 크기 (바이트)
sys.getrefcount(obj) # 참조 카운트
sys.settrace(tracer) # 모든 함수 호출 훅
sys.getframeinfo(frame)
14.4 tracemalloc
import 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/cpythonGitHub.- 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. 퀴즈
Q1. Python이 "해석 언어"라는 말은 정확한가?
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" 시대가 끝나가고 있다.
Q2. Computed GOTO가 switch보다 빠른 이유는?
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)의 표준.
Q3. PEP 659의 specializing adaptive interpreter는 어떻게 작동하는가?
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%+ 성능 향상의 주역.
Q4. Python 3.13의 Free-Threaded 빌드가 single-threaded 코드를 느리게 만드는 이유는?
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배 이득".
Q5. Copy-and-Patch JIT이 기존 JIT(LLVM 등)보다 빠른 이유는?
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 인프라를 뒤흔드는 사례.
Q6. Generator와 일반 함수의 구현 차이는?
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 보존" 트릭 하나.
Q7. Python 3.11의 "zero-cost exceptions"은 무엇을 제거했는가?
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 워크로드를 돌리는 배경.