- CPython의 새 꼬리 호출 인터프리터는 처음엔 pyperformance 평균 10~15% 향상처럼 보였지만, 기준선을 조정하면 실제 개선 폭은 설정에 따라 약 1~5%로 좁아짐
- 큰 폭의 개선은 새 구현만의 효과라기보다 LLVM 19 회귀를 우회한 결과에 가까웠으며, Clang 19가 기존 computed goto 디스패치를 제대로 복제하지 못한 것이 핵심 변수였음
- Intel Raptor Lake i5-13500에서는 clang19 빌드가 clang18보다 1.09배 느렸고 clang19.tc는 1.03배 빨랐지만, Apple M1 MacBook Air에서는 clang19가 1.12배 느리고 clang19.tc도 1.00배 느린 수준에 머묾
- LLVM의 tail duplication 제한으로 간접 점프 수가 clang18의 332개에서 clang19의 3개로 줄면서, computed goto 기반 인터프리터가 의도한 구조가 사실상 사라짐
- 꼬리 호출 방식은 여전히 의미 있는 개선이지만,
musttail처럼 컴파일러 최적화 여부를 명시적으로 요구하는 접근이 성능 민감 코드에는 더 견고할 수 있음
성능 개선처럼 보인 기준선 효과
- CPython 프로젝트는 약 한 달 전 바이트코드 인터프리터의 새 구현 전략을 병합함
- 초기 결과는 다양한 플랫폼과 pyperformance 벤치마크에서 평균 10~15% 성능 향상을 보였음
- 이후 분석에서는 이 큰 향상이 주로 LLVM 19 회귀를 우연히 우회한 결과로 드러남
- GCC, clang-18, 또는 특정 튜닝 플래그를 적용한 LLVM 19와 비교하면 향상 폭은 약 1~5% 수준으로 낮아짐
- 꼬리 호출 인터프리터 자체는 실제 속도 향상을 만들지만, 초기 수치가 암시한 것보다 개선 폭은 완만함
- clang-19 또는 그 이후 버전으로 빌드했다면 기존 경로가 실제로 10~15% 느렸을 가능성이 있음
- Simon Willison은
python-build-standalone빌드와 Python 3.13 비교에서 10% 속도 향상을 재현함
- Simon Willison은
벤치마크 구성과 핵심 수치
- 여러 CPython 빌드를 Intel 서버와 Apple M1 MacBook Air에서 비교함
- Intel 서버는 Hetzner에서 운영하는 Raptor Lake i5-13500
- 모든 빌드는 LTO와 PGO를 사용함
- 빌드 재현에는 nix 구성을 사용함
- 비교 대상은 다음과 같음
clang18: Clang 18.1.8, computed gotogcc: GCC 14.2.1, computed goto, Intel만 해당clang19: Clang 19.1.7, computed gotoclang19.tc: Clang 19.1.7, 새 꼬리 호출 인터프리터clang19.taildup: Clang 19.1.7, computed goto와 회귀 우회용-mllvm튜닝 플래그
clang18을 기준선으로 둔 pyperformance 평균 결과는 다음과 같음- Raptor Lake i5-13500:
clang19: 1.09배 느림clang19.taildup: 1.01배 빠름clang19.tc: 1.03배 빠름gcc: 1.02배 빠름
- Apple M1 MacBook Air:
clang19: 1.12배 느림clang19.taildup: 1.02배 느림clang19.tc: 1.00배 느림
- Raptor Lake i5-13500:
- 꼬리 호출 인터프리터는 clang-18 대비 일부 속도 향상을 보였지만, clang-19로 이동하며 생긴 성능 하락 폭보다 작았음
clang18.tc는 측정하지 못함- 꼬리 호출 인터프리터가 Clang 19에 새로 들어간 컴파일러 기능에 의존하기 때문임
- 이 제약 때문에 상황을 이해하려면 더 많은 벤치마크 조합이 필요했음
LLVM 19 회귀가 만든 디스패치 붕괴
- 전통적인 바이트코드 인터프리터는
while루프 안의switch문으로 opcode를 처리함- 컴파일러는 보통
switch를 점프 테이블과 간접 점프로 컴파일함
- 컴파일러는 보통
- 오래전부터 각 opcode 본문에 디스패치 로직을 복제하면 이런 인터프리터를 빠르게 만들 수 있음
- opcode 끝에서 루프 처음으로 돌아가는 대신, 다음 명령을 디코딩하고 점프 테이블을 인덱싱하는 로직을 opcode마다 별도로 둠
- C 컴파일러는 label 주소를 가져와 computed goto로 사용하는 기능을 제공하며, CPython도 꼬리 호출 작업 전에는 이 방식의 인터프리터 루프를 사용함
- Clang/LLVM은 컴파일러 성능상의 이유로 computed goto의 여러
goto를 내부적으로 하나의 indirectbr LLVM instruction으로 병합함- 이후 코드 생성 단계에서 tail duplication을 수행해 분기 로직을 각 위치에 다시 복제함
- 이 흐름은 LLVM의 오래된 블로그 글에 고수준으로 문서화되어 있음
- LLVM 19는 특정 경우의 심각한 컴파일 시간 또는 메모리 사용 증가를 피하려고 tail duplication pass 제한을 도입함
- CPython에서는 이 제한 때문에 Clang이 디스패치 점프를 병합된 상태로 남겨둠
- 그 결과 computed goto 기반 구현의 목적이 사실상 무력화됨
- 이 문제는 비슷한 인터프리터 루프를 가진 다른 언어 구현에서 먼저 식별됐지만, CPython에 영향을 준다는 사실은 알려져 있지 않았음
- 객체 코드를 디스어셈블해 간접 점프 수를 세면 차이가 직접 보임
- clang18 빌드의
_PyEval_EvalFrameDefault:jmp *332개 - clang19 빌드의
_PyEval_EvalFrameDefault:jmp *3개
- clang18 빌드의
computed goto가 보인 애매한 위치
- tail duplication 로직 변화가 회귀를 일으킨 점은 수정 후 clang-18 수준 성능이 회복된다는 점으로 확인됨
- 다만 회귀의 크기는 완전히 설명되지 않음
- 역사적으로 opcode 디스패치 복제는 인터프리터를 20%에서 100%까지 빠르게 한다고 인용된 적이 있음
- 현대 프로세서의 향상된 분기 예측기에서는 더 최근 연구가 약 2~4% 수준의 더 작은 향상을 보임
- Python은 설정 옵션으로 단일
switch문을 쓰는 구식 인터프리터도 지원함clang18.nocg: clang18 대비 1.01배 빠름clang19.nocg: clang18 대비 1.02배 느림clang19: clang18 대비 1.09배 느림
clang19.nocg가clang19보다 빠르게 나온 점은 추가적인 반전임- Clang 18 또는 적절한 플래그를 적용한 Clang 19는
switch기반 인터프리터에서도 디스패치 로직을 각 opcode 본문에 복제함
- Clang 18 또는 적절한 플래그를 적용한 Clang 19는
- 간접 점프 수 비교에서도 이 차이가 드러남
clang18: 332개clang18.nocg: 306개clang19.nocg: 3개clang19: 3개
- 현대 Clang에서는 computed goto 인터프리터 전체가 불필요한 복잡성일 수 있음
- 컴파일러가
switch기반 코드에서도 같은 변환을 수행할 수 있기 때문임 - 반대로 computed goto 자체도 변환을 보장하기에는 충분하지 않았음
- 컴파일러가
- GCC 14.2.1은
switch를 복제하지 않았지만, computed goto를 사용할 때는 의도한 동작을 구현함
수정과 우회 방법
- LLVM pull request 114990이 글 게시 직후 병합되어 회귀를 수정함
- 병합 전 벤치마크에서도 해당 수정이 기대한 성능을 회복하는 것이 확인됨
- 수정 전 릴리스에서는 회귀를 일으킨 PR이 추가한 튜닝 옵션으로 tail duplication 중단 임계값을 조정할 수 있음
- clang-19에서 해당 제한을 매우 큰 값으로 설정하면 비슷한 동작을 회복할 수 있음
- LTO 빌드에서는 이 옵션 전달이 복잡함
- tail duplication은 코드 생성 중 일어나며, LTO 빌드의 코드 생성은 컴파일 시간이 아니라 링크 시간에 일어남
- 따라서 플래그를 컴파일러뿐 아니라
lld에도 전달해야 함
- 사용한 설정 예시는
./configure단계에서OPT와LDFLAGS에-mllvm -tail-dup-pred-size=5000을 전달하는 방식임
벤치마킹이 드러낸 기준선 문제
- 벤치마크는 특정 빌드 간 성능 차이를 정확히 측정할 수 있지만, 그 결과를 “일반적인 성능 향상”으로 확장하려면 추가 가정이 필요함
- 꼬리 호출 인터프리터 벤치마크는 기존 computed goto 인터프리터 대비 10~15% 빠른 결과를 보였지만, 더 넓은 결론으로 일반화하기에는 기준선이 복잡했음
- 성능 작업에서는 무엇을 기준선으로 비교하는지가 반복적으로 어려운 문제임
- 현재 최고로 알려진 접근을 이론적으로 이해해도 실제 운영체제, 컴파일러 옵션, 플래그를 제대로 조정하는 것은 별도 문제임
- 오래된 하드웨어나 재현하기 어려운 규모에서 나온 공개 벤치마크는 직접 비교에 적합하지 않을 수 있음
- 머신러닝 논문에서도 알고리듬 개선을 주장할 때 “무엇을 했는가”보다 “어떤 기준선과 비교했는가”가 먼저 중요한 질문이 되는 경우가 많음
- 잘못 튜닝된 기준선과 비교하면 인상적인 결과가 쉽게 나올 수 있음
최적화 컴파일러와 musttail
- computed goto 사례는 최적화 컴파일러에 대한 기대가 서로 충돌할 수 있음을 보여줌
- 컴파일러는 프로그래머의 의도를 존중하고 같은 동작을 보존해야 함
- 동시에 코드를 빠르게 만들기 위해 복잡하고 직관적이지 않은 변환도 수행해야 함
- clang-19는 computed goto 인터프리터를 프로그램 동작 관점에서는 올바르게 컴파일했지만, 최적화 의도와는 완전히 다른 출력을 만들었음
- 다른 Clang 버전은 단순한
switch()기반 인터프리터에도 의도했던 것과 같은 최적화를 적용함 - 소스 코드 수준의 computed goto와 머신 코드 수준의 디스패치 복제는 거의 직교하는 개념처럼 나타남
- 실행 결과가 같기 때문에 현재 도구는 이 차이를 일관되게 표현하기 어려움
- 꼬리 호출 인터프리터는 musttail attribute에 기반함
musttail은 전통적인 관찰 가능한 프로그램 동작을 바꾸지는 않지만, 최적화기와의 대화에 가까움- 컴파일러가 특정 최적화를 수행할 수 있어야 하며, 해당 최적화가 일어나지 않으면 컴파일 실패를 요구함
- 이런 방식은 컴파일러가 진화하는 동안에도 성능 민감 코드를 더 견고하게 작성하는 스타일이 될 수 있음
- 가상의
[[clang::musttailduplicate]]같은 속성으로 인터프리터while루프의 computed goto를 대체할 수 있을지도 검토할 만함
nix가 제공한 재현성과 한계
nix는 여러 Python 인터프리터 빌드를 관리하는 데 큰 도움이 됨- 실험 과정에서
gcc,clang-18,clang-19,clang-20네 컴파일러와 다양한 플래그 조합으로 수십 개의 Python 인터프리터를 빌드하고 벤치마크함
- 실험 과정에서
nix를 사용해 병렬 버전을 재현 가능하고 격리된 방식으로 유지할 수 있었음- 특정 빌드가 어떤 컴파일러와 플래그에서 나왔는지 확신할 수 있었음
- 빌드 매트릭스 정의도 짧은 추상화로 관리할 수 있었음
- 버그 수정 패치를 적용한 커스텀 LLVM을 빌드하고, 그 컴파일러로 Python 빌드를 수행하는 것도 약 10줄의 코드로 가능했음
- 단점도 남아 있음
nix는 일반적인 소프트웨어 사용 방식과 다른 부분이 있어, 이런 차이가 벤치마크나 결론에 영향을 줬을 가능성을 완전히 배제하기 어려움- 예를 들어 nix는 기본적으로 특정 hardening 플래그로 프로젝트를 빌드하며, 이 플래그가 꼬리 호출 인터프리터에 불균형하게 영향을 준다는 점을 초기에 발견함
- Nix는 확장성과 커스터마이징이 강하지만, 특정 커스터마이징 방법을 찾으려면 많은 시행착오와
nixpkgs소스 탐색이 필요했음