Python vs PyPy

Python과 PyPy는 같은 언어를 다른 엔진으로 돌리는 것으로 볼 수 있다. Python(CPython)은 우리가 흔히 파이썬이라고 부르는 기본 구현체이며 C언어로 작성되어 있다. .py 파일을 실행할 때 실제로 코드를 해석하고 실행하는 것은 CPython 인터프리터다. PyPy는 파이썬언어의 대체 구현체로 자체적인 JIT(Just-In-Time) 컴파일러를 포함하고 있어 실행 속도를 극적으로 높일 수 있다. CPython은 모든 코드를 한 줄 씩 읽고 해석하기 때문에 느리지만 PyPy는 실행 중 자주 반복되는 코드를 기계어로 즉석에서 컴파일(JIT)하기 때문에 이후엔 빠르게 실행한다.
CPython = 바이트코드 인터프리터
CPython은 파이썬 코드를 바로 실행할 때는 다음과 같은 일이 일어난다.
x = 0
for i in range(10):
x += i
print(x)
(1) 바이트코드(bytecode)로 변환
바이트코드는 "파이썬 가상 머신(Python Virtual Machine, PVM)이 이해하는 작은 명령어들"이다. 예를 들면 LOAD_FAST, INPLACE_ADD, STORE_FAST 같은 짧은 명령어들의 리스트가 된다. 이건 아직 CPU가 이해하는 기계어는 아니고 중간 미니 언어정도이다.
(2) 바이트코드 실행
CPython 안에는 “바이트코드 인터프리터 루프”라는 게 있다. 그 루프는 한 바이트코드 명령어를 읽고 → 어떤 동작을 하고 → 다음 바이트코드를 읽고… 이런 식으로 계속 돈다.
예: INPLACE_ADD라는 명령어를 만나면 “스택 위의 두 값을 꺼내서 더하고 다시 넣어라” 같은 C 코드 함수가 실행된다.
CPython은 코드를 바이트코드로 바꾼 다음, 바이트코드를 한 줄씩 해석해서 실행하는 구조다. 이 구조는 단순하고 유연하지만 느리다. CPU는 더하기를 한 번에 할 수 있는데 CPython은 바이트코드 명령어를 가져오고 이 명령어가 뭔지 switch문 같은 걸로 확인하고 필요한 파이썬 객체를 꺼내고 (여기서도 참조 카운트 증가/감소 등 부가 작업) 타입이 뭔지 확인하고 (int인지 bool인지 등) 실제 연산하고 다시 파이썬 객체로 결과를 감싸서 스택에 넣고 다음 바이트코드로 넘어간다.
CPU 입장에서 보면 “1+2” 한 번 하는데 매번 이런 의식을 치르는 셈이다. 절차는 엄청 안전하지만 빠르진 않다. 바이트코드를 매 순간 읽고 해석하고 처리하기 때문에 오버헤드가 크다.
PyPy + JIT
PyPy도 처음에는 똑같이 파이썬 코드를 읽고 바이트코드를 실행한다. 하지만 루프를 돌다가 이 구조가 계속 반복되는 구조라면 CPU가 바로 실행할 수 있는 네이티브 기계어로 바꿔서 캐시에 저장해두는 식이다. 이 과정을 JIT 컴파일이라고 한다.
즉 코드를 실행하면서 어떤 부분이 많이 반복되는지 기록한다. 보통은 루프나 함수 호출 같은 부분이 hot spot으로 찍혀 반복구간이 많이 도는 hot 구간이면 그 부분의 바이트코드를 분석해 결국 어떤 연산들의 조합인지 파악하게 되고 이 덩어리를 trace라고 한다.
trace를 바탕으로 실제 CPU용 기계어를 즉석에서 만든다. C코드로 컴파일하지 않고 x86같은 CPU가 바로 실행 가능한 수준의 어셈블리/기계어이다. 따라서 동일 루프를 다시 돌 때는 바이트코드로 한 줄씩 해석 > 함수 호출 > 타입 체크 같은 과정 없이 이미 만들어둔 기계어 브록을 점프해서 실행한다.
x = 0
i = 0
while i < 10:
x += i
i += 1
print(x)
이걸 CPython이든 PyPy든 바이트코드로 바꾸면 대충 이런 흐름이 나온다
LOAD i
LOAD 10
COMPARE_LT
JUMP_IF_FALSE → (루프 끝으로 나감)
LOAD x
LOAD i
INPLACE_ADD
STORE x
LOAD i
LOAD 1
INPLACE_ADD
STORE i
JUMP_ABSOLUTE → (루프 조건 검사 위치로 돌아감)
JUMP_ABSOLUTE. 이 명령은 프로그램 카운터(=다음에 실행할 바이트코드 위치)를 다시 위로 되돌린다는 뜻이다.
루프는 기계적으로 보면: A 지점에서 시작해서 … 쭉 실행한 다음 … 다시 A 지점으로 점프한다.
파이썬 소스 코드의 while/for 같은 문법은 런타임한테는 결국 동일한 위치로 반복해서 되돌아오는 점프 사이클이다.
“같은 루프냐?”는 “같은 바이트코드 위치로 계속 되돌아오고 있냐?”로 치환할 수 있다.
PyPy JIT은 모든 바이트코드 위치(정확히 말하면 특정 loop header 지점)에 대해 얼마나 자주 여길 다시 방문하는지 카운터를 유지한다.
루프 시작 위치 A에 도착할 때마다 counter[A] += 1, counter[A]가 어떤 임계값(threshold)을 넘으면 A는 진짜 자주 돈다. 여기 최적화할 가치가 있다고 판단하여 JIT을 발동해 trace를 만든다.
PyPy가 같은 루프라고 말할 때, 그건 같은 루프 헤더(같은 진입 지점 A)를 계속 방문하고 있다는 뜻에 가깝다.
이제 threshold를 넘어서 이 루프 최적화해보자고 결정했다면 PyPy는 다음과 같은 작업을 시작한다:
인터프리터 모드에서 실제로 그 루프를 한 바퀴(혹은 여러 바퀴) 돌아본다. 그동안 실행된 바이트코드들과 그 사이의 연산(덧셈, 비교, 변수 로드 등)을 그대로 기록한다. 이걸 trace라고 부른다. trace는 실제로 일어난 실행 경로의 기록이다. 그리고 trace는 가능한 모든 경우가 아니라 지금 실제로 일어난 경우만 적는다.
PyPy는 결국 trace를 바탕으로 네이티브 코드를 만든다. 여기서 말하는 네이티브 코드 = x86-64나 ARM 같은 실제 CPU 명령어들의 시퀀스다. 이건 운영체제가 바로 실행할 수 있는 수준의 코드 조각이며 파이썬 바이트코드랑은 다르다. 바이트코드는 파이썬 인터프리터만 이해한다. 네이티브 코드는 CPU가 직접 이해한다. 이 과정에서 PyPy는 불필요한 것들을 빼버린다.
if i < 10:
x += i
else:
x -= i
현재 실행에서 i가 계속 0,1,2,...9라서 항상 i < 10이 참이라고 하면 trace에는 “if의 참 경로”만 기록된다. 거기엔 else 분기가 안 들어간다. 덕분에 trace는 굉장히 직선적인 코드(분기 거의 없음)가 된다. 직선적이어야 기계어로 변환하기 쉽다. CPU는 직선 코드를 사랑한다. 분기는 느리다. 결과적으로 최적화하기 쉬운 핫 경로(hot path)를 얻게 된다.
매번 이 값은 파이썬 int인지 확인 → 이 루프 안에서는 이미 int만 봤으니 생략
파이썬 객체 언박싱/박싱 반복 → 가능하면 내부에서 그냥 원시 정수 레지스터로 굴림
스택에 push/pop 반복 호출 → 그냥 레지스터 이동으로 단순화
결과적으로 trace는 고도로 최적화된 기계어 블록으로 변환된다. 이 블록은 한 바퀴(또는 여러 바퀴) 루프를 도는 데 필요한 계산을 거의 C에 가까운 속도로 수행한다.
그런데 else로 다시 진입하는 경우나 타입이 계속 고정이지 않은 경우가 있을 것이다.. 이렇게 캐시한 과정에서 타입이 바뀌거나 하는 변동이 생기는 것을 대비해 guard, safepoint도 같이 만든다. 예를 들어 i가 int일 거라고 가정하고 만든 기계어가 있는데 갑자기 i가 문자열이 되면 그 최적화는 깨진다. 가정이 깨지면 safepoint로 점프해서 원래의 느린 인터프리터 실행으로 돌아가게 된다.
그리고 이 만들어진 기계어 블록, 즉 JIT 컴파일 결과물은 메모리에 올려눋다. 운영체제 입장에서 보면 실행 가능한 코드 덩어리(메모리 페이지)인 것이다. 그다음 PyPy는 내부적으로 이렇게 매핑을 만든다.
key: 이 루프 헤더(= 특정 바이트코드 오프셋, 즉 ‘프로그램 카운터 위치’)
value: 이 루프를 위한 JIT 컴파일된 기계어 블록의 주소
즉 이 핫 루프는 이미 최적화된 코드가 있다는 사실이 PyPy 내부 테이블에 기록된다. 이 테이블은 파이썬 레벨에서 접근하는 일반 dict는 아니고, JIT 런타임(저수준 C/어셈블리 쪽)에서 관리하는 캐시다.
루프 시작 지점 A → 네이티브 코드 블록 #17
루프 시작 지점 B → 네이티브 코드 블록 #22
…
PyPy 이득이 있는 상황
PyPy는 빠른 파이썬이지만 모든 상황에서 이득이 있는 것은 아니다. PyPy는 JIT과 내부 최적화 때문에 실행 속도는 빨라도 메모리 사용량은 더 많다. 언제 쓰면 좋은지 판단하는 기준은 결국 "JIT이 최적화할 여지가 있느냐"에 달려있다. 특히 루프나 수학 연산이 많은 코드에서 성능 차이가 뚜렷하기 때문에 수치 계산이나 시뮬레이션, 알고리즘 테스트, CPU 중심 작업(피보나치, 행렬곱, 정렬 등)은 3-10배 빨라질 수 있다. 짧게 실행되는 스크립트는 JIT이 학습할 시간도 없이 종료하기 때문에 이득이 거의 없다. 서버, 게임 엔진, 수학 시뮬레이션처럼 장기간 반복 실행하는 경우에만 유리하다.
PyPy가 JIT 최적화를 못하거나 어려운 파이썬의 기능들을 살펴보자. 파이썬은 언어 자체가 너무 유연하기 때문에 JIT처럼 규칙기반으로 캐싱하는 컴파일러는 아래와 같은 예시들을 어려워한다. 예측 가능한 패턴만 발견하면 빠르게 실행할 수 있지만 그렇지 않으면 원래 인터프리팅처럼 동작한다.
eval("x + 10")
exec("def f(): return 123")
eval/exec는 실행 중 새 코드를 문자열로 만들고 돌리는 구조이다. JIT이 보면 어떤 코드가 나올 건지 예측할 수 없기 때문에 최적화가 불가능하다. 따라서 이런 코드를 만나면 trace를 버리고 최적화된 루프 진입을 취소한 뒤 그냥 인터프리터 모드로 돌린다. 즉 동적 생성 코드는 최적화할 수 없다는 것이다.
globals()["x"] = 123
globals(), locals() 직접 건드리는 것. 인터프리터는 x가 어떤 값을 가리키는지 정적 분석으로 파악할 수 없게 된다. JIT의 기본 전략은 실행 흐름을 관찰해 패턴을 찾는 것인데, 글로벌/로컬 네임스페이스를 마음대로 뒤집어엎는 행위는 패턴 자체를 불가능하게 만든다.
class A:
pass
A.new_attr = 123
def f(self): return 10
A.method = f
클래스 동적 변형. 파이썬은 클래스나 인스턴스에 언제든 속성을 추가하거나 교체할 수 있다. 이건 사용자 입장에서는 언어가 편리하고 ㅇ유연하다는 장점은 있지만 JIT 최적화에겐 매우 불리하다. JIT이 이 객체는 이런 속성을 가진다라고 가정하려고 해도 그 속성은 없어져버린 것이다. 그러면 JIT은 guard를 많이 작성해야하고 가드가 너무 많으면 최적화가 이득이 사라진다.
class X:
def __getattr__(self, name):
return 999
객체의 __getattr__, __getattribute__ 오버라이드. 이 두 메서드는 속성 접근을 전역 후킹하는 기능이다. 속성 이름으로 실제 속성을 가져오는 로직이 완전히 사용자 정의 코드에 의존하게 된다. JIT 입장에서는 속성 접근이 단순 메모리 오프셋이 아니라 뒤에 어떤 실행을 하게될지 예측할 수 없는 함수 호출이 되어 마찬가지로 최적화 패턴이 상실되고 트레이스가 지나치게 복잡해진다.
파이썬은 변수가 아닌 객체 중심이다
C, Java같은 정적언어는 타입이 변수에 붙게된다. 정수형 변수로 선언되었으면 계속 그 변수는 정수형 변수이다. 타입이 변수 이름에 고정되는 것이다. 파이썬은 x에 정수를 담았어도 문자열로 바꿀 수 있다. 타입이 바뀌는게 아니라, x가 가리키는 대상이 바뀐다. x에 3을 담으면 객체는 int형 객체가 되고 'hello'를 담으면 str형 객체가 된다. 그 값을 담은 x는 두 객체 중 하나를 가리키는 이름표일 뿐이다. 즉 파이썬은 객체에 타입이 붙는 것이고 변수는 객체를 가리키는 포인터일 뿐이다.
파이썬에서 x=3을 C구조체 수준에서 보면 이와 같다.
typedef struct {
PyObject_HEAD
long ob_ival; // 실제 값
} PyLongObject;
obj->ob_type == &PyLong_Type
3은 PyLongObject로 만들어지고 이 객체는 스스로의 타입 정보도 들고 있다. 마찬기지로 문자열 객체는 PyUnicode_Type을 가리킨다. 다른 정적 언어와 마찬가지로 모든 객체는 생성될 때부터 자기 타입을 알고 처음부터 타입이 정해져있다.
하지만 인터프리터는 변수가 뭘 가르킬지 실행시점까지는 모르기 때문에 실행 중 실제 들어온 객체를 보고 타입을 안다. x + y를 계산하려면 인터프리터가 해야 하는 일은,
x가 가리키는 객체를 가져온다.
그 객체의 타입 정보를 확인한다 (x.ob_type).
이 타입의 + 연산은 무엇으로 정의돼 있는지 찾는다 (x.ob_type->tp_as_number->nb_add).
그 함수를 호출해서 실제 연산을 수행한다.
즉, 실행 시점에 객체의 타입을 보고 그 타입에 맞는 메서드를 호출하는 구조다. C 언어에서는 컴파일할 때 이미 “정수끼리 더하기”라고 결정되기 때문에 add eax, ebx 같은 CPU 명령으로 바로 번역된다.즉 정적 타입 언어는 변수 중심이고 동적 타입은 객체 중심이다.
하지만 파이썬은 +가 정수 더하기인지 문자열 연결인지 실행 전에는 모른다. 그래서 매번 타입을 확인하고 알맞은 연산 함수를 찾아서 호출하기 때문에느리다.
PyPy의 JIT타입이 타입 체크를 관찰하고 제거하는 원리인 type specialization이라는 것이 있다.
PyPy는 루프를 돌 때 실제로 실행된 명령을 trace한다. 이때 단순히 바이트코드를 적는 게 아니라 실제 객체의 타입까지 기록한다. 처음 몇번 루프를 도는 동안 PyPy는 각 변수의 타입을 메모한다. 이 루프에서는 int 타입만 쓰이는 구나를 인식하게 되고 이걸 type profiling이라고 한다.
이런 관찰을 했으면 int만 쓰인다는 가정하에 trace를 단순화한다.
Cpython의 경우 코드를 이런 형태로 처리하지만 이 루프에서 s, i가 항상 int라는 가정으로 이 과정을 단순화한다.
def add_loop(n):
s = 0
for i in range(n):
s += i
return s
# Cpython
LOAD s
LOAD i
CHECK_TYPE(s)
CHECK_TYPE(i)
CALL nb_add(s, i)
STORE s
# PyPy
LOAD_INT s
LOAD_INT i
ADD_INT s, i
STORE_INT s
GUARD_TYPE s=int
GUARD_TYPE i=int
즉, 타입 검사를 처음에 한 번만 하고 그 뒤에는 진짜 정수 덧셈 기계어(ADD eax, ebx)로 꾼다. 만약 다음 번 루프에서 s가 갑자기 문자열이 되면 guard에서 걸리고 원래 느린 인터프리터로 복귀한다. 이걸 speculative optimization (가정 기반 최적화) 라고 한다.
이런 과정 때문에 PyPy JIT은 루프 실행 중 타입이 같은지 관찰하고 그 타입에 맞춰 기계어를 생성해 타입 체크를 코드 밖으로 빼버리는 것이다. 즉 동적언어를 정적언어처럼 보이게 만든다.
'SW 개발' 카테고리의 다른 글
| [Cursor] 커서로 Slack 액션 아이템 생성 봇(feat.llm) 만든 후기 (0) | 2025.03.26 |
|---|---|
| 알고리즘 공부 (0) | 2025.01.31 |
| 알고리즘 공부 - 파이썬 (0) | 2025.01.15 |
| [개발자 상식] 개발자가 되기 위한 첫 걸음을 떼어줄 책 (1) | 2023.03.15 |
| [Node.js로 서버 만들기] 책을 출간하였습니다. (0) | 2021.12.19 |
소중한 공감 감사합니다