Python 3.14 tail-call(꼬리 호출) 인터프리터의 성능
(blog.nelhage.com)- CPython 프로젝트는 최근 바이트코드 인터프리터의 새로운 구현 전략을 도입. 초기 결과는 다양한 플랫폼에서 평균 10-15%의 성능 향상을 보여줬음
- 그러나 이 성능 향상은 주로 LLVM 19의 회귀 문제를 우회한 결과였음. 더 나은 기준(예: GCC, clang-18, 특정 튜닝 플래그가 있는 LLVM 19)과 비교했을 때 성능 향상은 1-5%로 감소
성능 결과
- 여러 컴파일러와 구성 옵션을 사용하여 CPython 인터프리터의 여러 빌드를 벤치마크했음. Intel 서버와 Apple M1 Macbook Air에서 테스트했음.
- 모든 빌드는 LTO와 PGO를 사용했음.
clang18
을 기준으로pypeformance
/pyperf compare_to
에서 보고된 평균을 사용했음. -
컴파일러 성능 비교
-
Apple M1 Macbook Air :
- clang18: 기준
- clang19: 1.12배 느림
- clang19.taildup: 1.02배 느림
- clang19.tc: 1.00배 느림
- gcc: N/A
-
Apple M1 Macbook Air :
- 꼬리 호출 인터프리터는 여전히 clang-18과 비교하여 속도 향상을 보였지만, clang-19로 이동하면서의 속도 저하가 더 극적이었음.
LLVM 회귀
간단한 배경
- 전통적인 바이트코드 인터프리터는
while
루프 내의switch
문으로 구성됨. 대부분의 컴파일러는switch
를 점프 테이블로 컴파일함. - 현대 C 컴파일러는 레이블의 주소를 취하고 이를 "계산된 goto"로 사용하는 패턴을 지원함. CPython은 꼬리 호출 작업 전까지 이 패턴을 사용했음.
LLVM 19 회귀
- LLVM 19는 tail-duplication 패스에 제한을 두어, IR 크기가 특정 한계를 초과할 경우 중복을 중단하도록 했음. 이로 인해 CPython에서는 모든 디스패치 점프가 병합되어 계산된
goto
기반 구현의 목적이 완전히 무산됨.
추가 이상 현상
- 꼬리 호출 중복 논리의 변경이 회귀를 초래했음을 확신하지만, 회귀의 크기를 완전히 설명할 수는 없음.
- 현대 프로세서에서는 2-4%의 속도 향상이 더 일반적임.
계산된 goto가 필요한가?
-
clang19.nocg
벤치마크는clang19
보다 빠르다고 주장함. 이는 컴파일러가switch
기반 인터프리터를 사용하여 동일한 최적화를 수행할 수 있음을 보여줌.
수정
- LLVM 풀 리퀘스트 114990이 회귀를 수정했음. 이 수정은 예상 성능을 복원함.
반성
벤치마킹에 대하여
- 시스템 최적화 시 벤치마크와 벤치마킹 방법론을 구성하고, 제안된 변경 사항을 평가함.
- 벤치마크는 특정 데이터 포인트를 일반화하기 위해 더 많은 가정과 믿음을 필요로 함.
기준선
- 새로운 솔루션이나 방법을 제안할 때, "현재 가장 잘 알려진 접근 방식"과 비교하는 것이 일반적임.
소프트웨어 엔지니어링에 대하여
- 소프트웨어 시스템은 복잡하고 상호 연결되어 있으며, 빠르게 변화하고 있음.
- 최적화 컴파일러는 프로그래머의 의도를 존중하면서도 코드를 최적화해야 하는 긴장 관계에 있음.
최적화 컴파일러
-
musttail
속성은 최적화와 관련된 새로운 종류의 컴파일러 기능을 나타냄. 이는 성능에 민감한 코드를 작성하는 데 더 강력한 스타일을 제공할 수 있음.
nix
에 대한 한 가지 더
-
nix
는 이 프로젝트에서 매우 유용했음. 여러 버전의 Python 인터프리터를 관리하고 빌드하는 데 큰 도움이 되었음.
Hacker News 의견
-
안녕하세요. 저는 CPython에 tail-calling 인터프리터를 도입한 PR의 작성자임
- 먼저, 이 문제의 근본을 찾기 위해 거의 한 달을 소비한 Nelson에게 감사의 말을 전하고 싶음
- 또한, 이런 큰 실수를 저질러 매우 부끄럽고 죄송함을 느끼고 있음
- 우리가 사용한 컴파일러에 이런 버그가 있을 줄은 CPython 팀도 예상하지 못했음
- 사과 블로그 게시물을 여기에 올렸음: 링크
-
벤치마킹은 정말로 잘하기 어려운 작업임
- 최근에 알고리즘을 약 15% 더 빠르게 만드는 방법을 발견했음
- 그러나 테스트 중에 더 빠른 버전의 함수를 호출하지 않고도 원래 코드가 15% 더 빨라짐
- 이는 코드와 메모리 배치 문제로, CPU 캐시와의 정렬이 더 잘 맞았기 때문임
- Casey Muratori가 이런 주제에 대해 흥미로운 시리즈를 진행 중임
-
저자가 이 문제의 진실을 파헤친 것에 찬사를 보냄
- Python 3.14의 tail-call 인터프리터는 여전히 좋은 개선점임
- 이 사건은 벤치마킹의 엄격함과 다양한 환경에서의 테스트 중요성을 가르쳐 주었음
- 또한, 이제 모든 사람에게 이익이 될 수 있는 컴파일러 버그를 발견하게 됨
- 얼마나 많은 "X% 더 빠름" 결과가 실제로 벤치마킹 아티팩트나 알려지지 않은 회귀로 인한 것인지 궁금함
-
C가 "기계에 가까운" 언어가 아니라는 좋은 예임
- clang-19가 계산된 goto 인터프리터를 "올바르게" 컴파일하지만, 최적화 의도와는 완전히 다른 출력을 생성함
- 다른 컴파일러 버전도 "순진한" switch() 기반 인터프리터에 최적화를 적용함
-
컴파일러가 루프를 조직하는 방식을 조정하여 tail-call 인터프리터가 발표된 만큼 효과적이지 않음
- CPU 아키텍처와 버전이 매우 중요함
- C 추상 기계는 의도를 제대로 표현하기에 충분히 저수준이 아님
- 특정 파라노이드 인터프리터 구현은 직접 어셈블리를 작성하는 것으로 돌아감
- luajit는 매크로 시스템을 구현하여 효율적인 어셈블리 루프 구현을 아키텍처 간에 이식 가능하게 만듦
-
Python 빌드의 성능을 평가하는 것은 매우 어려움
- 최근 astral 팀이 conda-forge 빌드가 다른 대부분의 빌드보다 빠르다는 것을 보여줌
- tail-call 인터프리터가 다른 빌드 최적화와 함께 어떻게 작동하는지 궁금함
-
관련 논의:
-
훌륭한 기사임
- 참조된 기사 중 하나에서 3.14.0a5가 3.13보다 1.12배 빠르다고 언급함
- 벤치마크를 다른 프로세스로 과부하된 상태에서 실행했는지 혼란스러움
- 벤치마크는 외부 변수를 제거하기 위해 엄격히 통제된 환경에서 수행되어야 함
-
최근 Python 3.9에서 3.13까지 벤치마킹을 수행함
- 3.11까지는 성능이 개선되었으나, 3.12와 3.13은 3.11보다 약 10% 느렸음
- 자체 벤치마크가 충분하지 않다고 생각했지만, 핵심 서비스에 배포했을 때도 동일한 변화를 관찰함
-
이런 최적화가 tail-call 최적화와 어떻게 관련이 있는지 궁금함
- 인터프리터 점프 테이블 구현이 스택 프레임 생성에 영향을 주지 않아야 함