본문으로 건너뛰기
김신건의 로그

[Python] 바이트코드와 dis 모듈

· 수정 · 📖 약 2분 · 919자/단어 #python #bytecode #dis #internals #compiler #advanced
python bytecode, dis module, PYC, specializing adaptive interpreter, PEP 659

정의

CPython 인터프리터는 Python 소스를 바이트코드로 컴파일해 평가 루프(ceval.c의 메인 switch)에서 실행한다. 바이트코드는 스택 기반 가상 머신을 위한 작은 명령어 시퀀스로, __pycache__ 아래 .pyc 파일로 캐싱된다.

def add(a, b):
    return a + b

내부적으로 다음 같은 명령어 시퀀스로 변환:

LOAD_FAST       a
LOAD_FAST       b
BINARY_OP       0 (+)
RETURN_VALUE

dis 모듈

dis로 함수의 바이트코드를 디스어셈블할 수 있다.

python
import dis

def add(a, b):
  return a + b

dis.dis(add)
결과
  2           0 RESUME                   0

3           2 LOAD_FAST                a
            4 LOAD_FAST                b
            6 BINARY_OP                0 (+)
           10 RETURN_VALUE

dis.dis()는 함수, 메서드, 클래스, 모듈, 소스 문자열 모두 받는다.

dis.dis("[x ** 2 for x in range(5)]")

주요 opcode

opcode의미
LOAD_FAST로컬 변수 로드 (배열 인덱스 접근)
LOAD_GLOBAL전역 변수 로드 (dict 조회)
LOAD_CONST상수 로드 (튜플/리터럴)
STORE_FAST로컬 변수 저장
LOAD_ATTR속성 조회 (obj.attr)
CALL함수 호출 (3.11+)
BINARY_OP산술/비교 (3.11+에서 통합됨)
COMPARE_OP비교 연산
POP_JUMP_FORWARD_IF_FALSE조건부 분기
BUILD_LIST / BUILD_TUPLE / BUILD_DICT컨테이너 생성
LIST_APPEND컴프리헨션 전용 빠른 append
RETURN_VALUE반환
YIELD_VALUEgenerator의 yield
MAKE_FUNCTION함수 객체 생성

LOAD_FAST vs LOAD_GLOBAL

이름 룩업 비용 차이를 보여준다.

python
import dis

def f():
  x = 1          # 로컬
  return x + len([])

dis.dis(f)
결과
  4           2 LOAD_CONST               1 (1)
            4 STORE_FAST               x

5           6 LOAD_FAST                x
            8 LOAD_GLOBAL              1 (NULL + len)
           20 BUILD_LIST               0
           22 CALL                     1
           30 BINARY_OP                0 (+)
           34 RETURN_VALUE

LOAD_FAST는 함수 로컬 배열의 정수 인덱스 접근 (가장 빠름). LOAD_GLOBAL은 모듈 dict, 빌트인 dict 순으로 조회 (느림). 자주 쓰는 빌트인을 로컬로 캐싱하는 미세 최적화가 있는 이유.

def hot_loop():
    _len = len     # 로컬 캐싱
    for x in data:
        if _len(x) > 10: ...

마이크로벤치 외엔 거의 의미 없음. 가독성 손해가 보통 크다.

.pyc 파일

python -c "import my_module"
# __pycache__/my_module.cpython-313.pyc 생성

.pyc는 매직 넘버 + 타임스탬프(또는 hash) + 직렬화된 코드 객체. 다음 import 시 소스가 변경되지 않았으면 컴파일 단계 생략.

import sys
print(sys.implementation.cache_tag)    # cpython-313

소스 없이 .pyc만 배포 가능 (코드 보호엔 약함, 디컴파일 도구 존재).

컴파일 단계

  1. 렉서(tokenizer): 소스 → 토큰
  2. 파서(PEP 617, PEG): 토큰 → AST
  3. 컴파일러: AST → 중간 표현 → 바이트코드
  4. 인터프리터(ceval.c): 바이트코드 평가 루프
import ast

tree = ast.parse("a + b")
print(ast.dump(tree, indent=2))

ast 모듈로 AST를 직접 다룰 수 있어 코드 분석/변환 도구의 기반.

PEP 659: Specializing Adaptive Interpreter (3.11+)

런타임 프로파일링으로 opcode를 특화 버전으로 자동 교체. 예를 들어 BINARY_OP가 int+int만 받으면 BINARY_OP_ADD_INT로 바뀌어 타입 체크 생략.

import dis

def add_ints(n):
    total = 0
    for i in range(n):
        total = total + i
    return total

add_ints(1000)
dis.dis(add_ints, adaptive=True)    # 3.11+ adaptive=True

이전 호출 패턴이 적용된 특화 opcode가 보임 (BINARY_OP_ADD_INT 등).

3.11에서 약 25%, 3.12에서 추가 5% 평균 성능 향상의 핵심 동력. 자세한 건 py-pep-659 참고 (미작성 시 PEP 659 직접 링크).

코드 객체 (code object)

함수 안에는 __code__ 속성이 있고, 그게 진짜 바이트코드를 담는 객체다.

def f(a, b):
    x = a + b
    return x

print(f.__code__.co_code)         # 바이트열
print(f.__code__.co_consts)       # 상수
print(f.__code__.co_varnames)     # 로컬 변수명
print(f.__code__.co_names)        # 참조하는 전역명
print(f.__code__.co_argcount)     # 위치 인수 개수
print(f.__code__.co_filename)

exec(f.__code__)로 바이트코드 직접 실행 가능 (어떤 글로벌에서 실행할지 지정).

컴파일 함수

src = "x = 1 + 2"
code = compile(src, "<string>", "exec")
exec(code)
print(x)    # 3

# 표현식
code = compile("1 + 2", "<string>", "eval")
print(eval(code))    # 3

exec/eval을 신뢰할 수 없는 입력에 쓰면 보안 위험. AST 검증 후 실행하는 패턴(ast.literal_eval) 등으로 한정.

디버그 팁

  • dis.show_code(fn): 메타데이터 요약
  • dis.Bytecode(fn): 명령어를 객체로 순회
  • python -m dis script.py: CLI로 디스어셈블
  • __pycache__ 삭제 후 다시 실행으로 컴파일 강제

실용 가치는?

  • 마이크로 최적화 (LOAD_FAST 활용, 로컬 캐싱) → 거의 무가치
  • 알고리즘이 정말 동등한지 확인 (is vs ==, list comp vs map)
  • 디버거·프로파일러 동작 이해
  • 코드 변환 도구(린터, AST 기반 자동 수정) 개발
  • C 익스텐션 작성 시 opcode 동작 이해

평상시 작성에는 별 영향 없지만, “왜 이게 빠른가?”를 이해하는 데 필수.

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기