Python 3.14 tail-call 인터프리터의 성능
원문은 Nelson Elhage님이 에 게재했습니다. 이 블로그 구독하기
한 달쯤 전, CPython 프로젝트는 바이트코드 인터프리터를 위한 새로운 구현 전략을 머지했다. 초기 헤드라인 결과는 매우 인상적이었는데, 다양한 플랫폼에서 광범위한 벤치마크에 걸쳐 평균 10~15%의 성능 향상을 보였다.
안타깝게도, 이 글에서 문서화하겠지만, 이 인상적인 성능 향상은 주로 LLVM 19의 리그레션을 무심코 우회한 덕분인 것으로 드러났다. 더 적절한 베이스라인(GCC나 clang-18, 혹은 특정 튜닝 플래그를 적용한 LLVM 19 등)과 비교해 벤치마크하면, 정확한 설정에 따라 성능 향상은 1~5% 정도로 떨어진다.
tail-call 인터프리터가 발표되었을 때, 나는 성능 향상에 놀라고 감탄하면서도 한편으로는 혼란스러웠다. 전문가는 아니지만 현대 CPU 하드웨어와 컴파일러, 인터프리터 설계에 어느 정도 익숙한데도, 왜 이 변경이 그토록 효과적인지 설명할 수 없었기 때문이다. 호기심이 생겼고 — 어쩌면 약간 집착하게 됐고 —, 이 글에 담긴 보고서는 내가 보고 있는 것을 이해하려는 시도로 몇 주에 걸쳐 틈틈이 수십 개의 서로 다른 Python 바이너리를 컴파일하고 벤치마크하고 디스어셈블한 결과다.
마지막에는 소회를 통해 벤치마킹과 성능 엔지니어링, 그리고 더 넓게는 소프트웨어 엔지니어링 전반의 난제에 대한 사례 연구로서 이 상황을 되돌아볼 것이다.
또한 분명히 하고 싶은 점은, 나는 여전히 tail-call 인터프리터가 훌륭한 작업이자 진정한 속도 향상(비록 처음 기대보다는 소폭이지만)이라고 생각한다는 것이다. 그리고 이 글에서 설명하겠지만, 기존 인터프리터보다 더 견고한 접근 방식이라고 낙관적으로 본다. Python 팀의 누구에게도 이 오류에 대한 책임을 묻고 싶지 않다. 이런 혼란은 매우 흔하게 일어나며 — 나 자신도 수많은 벤치마크를 오해한 적이 있다 — 마지막 부분에서 이에 대한 소회도 나눌 것이다.
덧붙이자면, LLVM 리그레션의 영향은 이 작업 이전에는 알려지지 않았던 것으로 보이며(그리고 이 글이 게시될 당시에는 버그가 수정되지 않은 상태였지만, 그 이후 수정되었다), 그런 의미에서 대안 — 이 작업이 없었다면 — 은 clang-19 이상으로 빌드한 경우 실제로 10~15% 더 느렸을 것이다. 예컨대 Simon Willison은 python-build-standalone의 빌드를 사용해 Python 3.13과 비교했을 때 야생 환경에서도 10% 속도 향상을 재현했다.
성능 결과
다음은 핵심 결과다. 나는 두 대의 머신 — Hetzner에서 직접 운영하는 Intel 서버(Raptor Lake i5-13500)와 내 Apple M1 Macbook Air — 에서 여러 컴파일러와 다양한 설정 옵션으로 CPython 인터프리터의 여러 빌드를 벤치마크했다. 이 빌드들은 내 nix 설정으로 재현할 수 있는데, 한 번에 이토록 많은 요소를 관리하는 데 필수적이었다.
모든 빌드는 LTO와 PGO를 사용한다. 각 설정은 다음과 같다.
clang18: Clang 18.1.8로 빌드했으며, computed goto를 사용.gcc(Intel 전용): GCC 14.2.1로 빌드했으며, computed goto를 사용.clang19: Clang 19.1.7로 빌드했으며, computed goto를 사용.clang19.tc: Clang 19.1.7로 빌드했으며, 새로운 tail-call 인터프리터를 사용.clang19.taildup: Clang 19.1.7로 빌드했으며, computed goto와 리그레션을 우회하는 일부-mllvm튜닝 플래그를 사용.
나는 clang18을 베이스라인으로 삼고, pypeformance/pyperf compare_to가 보고하는 최종 “평균”을 기재했다. 전체 출력 파일과 리포트는 GitHub에서 확인할 수 있다.
| 플랫폼 | clang18 | clang19 | clang19.taildup | clang19.tc | gcc |
|---|---|---|---|---|---|
| Raptor Lake i5-13500 | (기준) | 1.09배 느림 | 1.01배 빠름 | 1.03배 빠름 | 1.02배 빠름 |
| Apple M1 Macbook Air | (기준) | 1.12배 느림 | 1.02배 느림 | 1.00배 느림 | N/A |
tail-call 인터프리터가 clang-18에 비해서는 여전히 속도 향상을 보이지만, clang-19로 옮기면서 발생한 성능 저하에 비하면 그 폭이 훨씬 작다는 점을 알 수 있다. Python 팀은 (버그를 감안한 뒤에도) 일부 다른 플랫폼에서 내가 관찰한 것보다 더 큰 속도 향상을 관찰하기도 했다.
오래된 Clang 릴리스에서는 tail-call 인터프리터를 벤치마크하지 않았다는 점을 눈치챘을 것이다(한다면 clang18.tc가 될 빌드다). tail-call 인터프리터는 Clang 19에서야 들어온 새로운 컴파일러 기능에 의존하기 때문에, 이전 버전에서는 테스트할 수 없다. 내 생각에 이러한 상호작용이 이 이야기를 그토록 혼란스럽게 만든 큰 이유이며, 내가 상황을 이해했다고 확신하기까지 그토록 많은 벤치마크가 필요했던 이유다.
LLVM 리그레션
간단한 배경
고전적인 바이트코드 인터프리터는 while 루프 안에 switch 문이 들어 있는 형태로, 대략 다음과 같이 생겼다.
while (true) {
opcode_t this_op = bytecode[pc++];
switch (this_op) {
case OP_IMM: {
// push an immediate onto the stack
break;
}
case OP_ADD: {
// handle the add
break;
}
// etc
}
}대부분의 컴파일러는 switch를 점프 테이블로 컴파일한다 — 각 case OP_xxx 블록의 주소를 담은 테이블을 만들고, opcode로 인덱싱한 뒤 간접 점프를 수행한다.
이러한 스타일의 바이트코드 인터프리터는 점프 테이블 디스패치를 각 opcode 본문에 복제함으로써 속도를 높일 수 있다는 사실은 오래전부터 알려져 왔다. 즉, 각 opcode를 jmp loop_top으로 끝내는 대신, 각 opcode가 “다음 명령어를 디코드하고 점프 테이블을 인덱싱하는” 로직의 별도 인스턴스를 포함하도록 하는 방식이다.
현대 C 컴파일러는 레이블의 주소를 취하는 것을 지원하고, 이 패턴을 구현하기 위해 그 레이블을 “computed goto”에서 사용하는 것을 지원한다. 따라서 CPython(tail-call 작업 이전)을 비롯한 많은 현대 바이트코드 인터프리터는 대략 다음과 같은 인터프리터 루프를 사용한다.
static void *opcode_table[256] = {
[OP_IMM] = &&TARGET_IMM,
[OP_ADD] = &&TARGET_ADD,
// etc
};
#define DISPATCH() goto *opcode_table[bytecode[pc++]]
DISPATCH();
TARGET_IMM: {
// push an immediate onto the stack
DISPATCH();
}
TARGET_ADD: {
// handle the add
DISPATCH();
}LLVM에서의 computed goto
성능상의 이유(생성된 코드의 성능이 아니라 컴파일러 자체의 성능)로, Clang과 LLVM은 내부적으로 앞서 본 코드의 모든 goto를 사실상 하나의 indirectbr LLVM 명령어로 병합하고, 각 opcode가 그곳으로 점프하도록 만든다. 즉, 컴파일러가 우리의 노력을 가져다가 switch 기반 인터프리터와 본질적으로 동일한 제어 흐름 그래프로 의도적으로 다시 쓰는 셈이다!
그리고 코드 생성 단계에서 LLVM은 “tail duplication”을 수행해 분기를 각 위치에 다시 복사함으로써 원래 의도를 복원한다. 이 과정은 새로운 구현을 소개하는 오래된 LLVM 블로그 포스트에 높은 수준에서 문서화되어 있다.
LLVM 19 리그레션
이 중복 제거 후 복사 과정을 거치는 근본적인 이유는, 기술적인 이유로 많은 indirectbr 명령어를 포함하는 제어 흐름 그래프를 생성하고 다루는 데 비용이 많이 들 수 있기 때문이다.
특정 사례에서 치명적인 속도 저하(또는 메모리 사용량 증가)를 피하기 위해, LLVM 19에서는 tail duplication 패스에 일부 제한을 두어, 복제로 인해 IR 크기가 특정 한도를 넘어서 커지면 중단하도록 했다.
안타깝게도 CPython에서는 이러한 제한 때문에 Clang이 모든 디스패치 점프를 병합된 상태로 남겨 두었고, computed goto 기반 구현의 목적 자체를 완전히 무력화시켰다! 이 버그는 유사한 인터프리터 루프를 가진 다른 언어 구현에서 처음 식별되었지만, 내가 찾은 바로는 CPython에 영향을 미친다는 사실은 알려져 있지 않았다.
성능 영향 외에도, 결과 오브젝트 코드를 디스어셈블하고 서로 다른 간접 점프의 개수를 세어 보면 버그를 직접 확인할 수 있다.
$ objdump -S --disassemble=_PyEval_EvalFrameDefault ${clang18}/bin/python3.14 | \
egrep -c 'jmp\s+\*'
332
$ objdump -S --disassemble=_PyEval_EvalFrameDefault ${clang19}/bin/python3.14 | \
egrep -c 'jmp\s+\*'
3추가적인 기묘함
tail-call duplication 로직의 변경이 리그레션을 일으켰다는 점은 확신한다. 이를 수정하면 성능이 clang-18과 일치하기 때문이다. 하지만 리그레션의 규모는 완전히 설명할 수 없다.
역사적으로 바이트코드 디스패치를 각 opcode에 복제하는 최적화는 인터프리터를 20%에서 100%까지 빠르게 한다는 평가를 받아 왔다. 하지만 분기 예측기가 개선된 현대 프로세서에서는, 더 최근의 연구에 따르면 속도 향상은 2~4% 정도로 훨씬 작다.
Python이 설정 옵션을 통해 단일 switch 문을 사용하는 “구형” 인터프리터를 여전히 지원하기 때문에, 이 2~4% 수치를 실제로 검증할 수 있다. 해당 인터프리터를 벤치마크하면 다음과 같은 결과가 나온다(아래 표에서 “.nocg”는 “no computed gotos”를 의미한다).
| 벤치마크 | clang18 | clang18.nocg | clang19.nocg | clang19 |
|---|---|---|---|---|
| 성능 변화 | (기준) | 1.01배 빠름 | 1.02배 느림 | 1.09배 느림 |
clang19.nocg가 기반이 되는 clang19 빌드가 9% 더 느린데도 clang18보다 불과 2% 느릴 뿐이라는 점에 주목하자! 나는 이 “2%”가 opcode 디스패치 복제 자체의 비용/이득에 대한 더 공정한 추정치라고 해석하며, 나머지 부분은 완전히 이해하지 못한다.
computed goto가 필요한가?
나는 clang19.nocg 벤치마크에 대해 언급하지 않았는데, 눈치챘겠지만 이 벤치마크는 clang19보다 빠르다고 나온다. 바로 이 지점에서 나는 이야기에 추가적이고 매우 재미있는 반전을 발견했다.
앞서 Clang과 LLVM이 다음과 같이 동작한다고 설명했다.
switch를 점프 테이블과 우리가 computed goto로 직접 만드는 것과 매우 유사한 간접 점프로 컴파일한다- computed goto를 단일 opcode 디스패치 인스턴스를 갖는 고전적인
switch그래프와 매우 흡사한 제어 흐름 그래프로 컴파일한다 - 코드 생성 과정에서 그 변환을 되돌려 디스패치를 복제할 수 있다
이러한 사실을 종합하면, “그냥 switch 기반 인터프리터에서 시작해 컴파일러가 tail duplication을 수행하도록 하면 같은 이점을 얻을 수 있지 않을까?”라는 질문이 떠오를 수 있다.
그리고 실제로 답은 그렇다이다.
clang-18(또는 적절한 플래그를 적용한 clang-19)은 “고전적인” switch 기반 인터프리터를 만나면 어쨌든 각 opcode 본문에 디스패치 로직을 복제한다. 앞서의 objdump | grep 테스트를 이용해 동일한 빌드들의 간접 점프 개수를 보여 주는 또 다른 표는 다음과 같다.
| 벤치마크 | clang18 | clang18.nocg | clang19.nocg | clang19 |
|---|---|---|---|---|
| 간접 점프 수 | 332 | 306 | 3 | 3 |
따라서 전체 “computed goto” 인터프리터가 완전히 불필요한 복잡성이라는 주장도 가능해진다(적어도 현대 Clang에서는). 컴파일러는 동일한 변환을 스스로 완벽하게 수행할 수 있으며, (보기에는) computed goto조차도 그것을 보장하지 못한다!
그렇다고는 해도, 나 역시 GCC를 테스트해 보았는데, GCC(적어도 14.2.1까지)는 switch를 복제하지 않지만 computed goto를 사용할 때는 원하는 동작을 구현한다. 그러니 적어도 그 경우에는 기대한 동작을 볼 수 있다.
수정
LLVM 풀 리퀘스트 114990은 이 글을 게시한 직후에 머지되었으며, 리그레션을 수정한다. 나는 머지 전에 이를 벤치마크하여 예상 성능이 복원되는 것을 확인할 수 있었다.
해당 수정 이전 릴리스에서는, 리그레션을 일으킨 PR이 tail duplication이 중단되는 임계값을 선택할 수 있는 조정 가능한 옵션을 추가했다. 그 한도를 매우 큰 값으로 설정함으로써 clang-19에서도 유사한 동작을 복원할 수 있다1.
소회
솔직히 인정하자면, 나는 이 주제에 제대로 낚여서 정말 필요한 수준보다 훨씬 깊이 파고들었다. 그렇다고는 해도, 그렇게 한 덕분에 소프트웨어 엔지니어링과 성능 엔지니어링 전반에 일반화할 수 있는 흥미로운 교훈과 소회들을 얻을 수 있었다고 생각하며, 그중 일부를 추려 곱씹어 보려 한다.
벤치마킹에 대하여
시스템을 최적화할 때, 우리는 일반적으로 일련의 벤치마크와 벤치마킹 방법론을 구성한 뒤, 그 벤치마크를 이용해 제안된 변경 사항을 평가한다.
어떤 벤치마크 집합이나 벤치마크 절차든, 내가 “성능 이론”이라고 부르고 싶어 하는 것을 (종종 암묵적으로) 내포한다. 성능 이론이란 “어떤 변수가 어떤 방식으로 성능에 영향을 (미칠 수) 있는가?”나 “벤치마크 결과와 ‘프로덕션’에서의 ‘진정한’ 성능 사이에는 어떤 관계가 있는가?” 같은 질문에 답하는 일련의 믿음과 가정이다.
tail-call 인터프리터에 대해 실행된 벤치마크는 기존 computed goto 인터프리터와 비교했을 때 10~15%의 속도 향상을 보였다. 그 벤치마크들은 정확했는데, 내가 아는 한 해당 빌드들 간의 성능 차이를 정확히 측정하고 있었기 때문이다. 하지만 그 특정 데이터 포인트들을 “tail-call 인터프리터는 일반적으로 computed goto 인터프리터보다 10~15% 빠르다”거나, 심지어 “tail-call 인터프리터는 우리 사용자들을 위해 Python을 10~15% 빠르게 할 것이다”라는 진술로 일반화하려면, 세상에 대한 더 많은 가정과 믿음을 끌어와야 한다. 이번 사례에서는 이야기가 더 복잡했고, 그러한 더 넓은 주장은 완전한 일반성에서는 사실이 아니었다.
(다시 한 번, Python 개발자들을 비난하고 싶지 않다! 이런 일은 어렵고, 혼란에 빠지거나 다소 부정확한 결론에 도달하는 방법은 수없이 많다. 더 나은 이해에 도달하기 위해 나는 약 3주간의 강도 높은 벤치마킹과 실험을 해야 했다. 내 요점은 이것이 매우 일반적인 난제라는 것이다!)
베이스라인
이 사례는 소프트웨어 성능뿐 아니라 많은 다른 영역에서도 반복되는 또 다른 난제를 부각한다. 바로 “무엇을 베이스라인으로 삼아 비교할 것인가?”라는 문제다.
어떤 문제에 대해 새로운 해결책이나 방법을 제안할 때마다, 일반적으로는 자신의 새로운 방법을 실행하고 관련 성능 지표를 산출하는 방법이 있다.
하지만 일단 자신의 시스템에 대한 지표를 갖게 되면, 그것이 좋은지 판단하기 위해 무엇과 비교해야 하는지를 알아야 한다! 어떤 절대적인 척도에서 좋은 점수를 받더라도(평가할 만한 합리적인 절대 척도가 존재한다고 가정하더라도), 자신의 방법이 기존 솔루션보다 나쁘다면 아마 그리 흥미롭지 않을 것이다.
일반적으로는 “현재 알려진 최선의 접근 방식”과 비교하고 싶을 것이다. 하지만 때로는 그게 쉽지 않다! 현재 접근 방식을 이론적으로 이해하고 있더라도, 실제로 적용하는 데 있어 전문가일 수도 있고 아닐 수도 있다. 소프트웨어의 경우, 이는 운영체제나 컴파일러 옵션 또는 기타 플래그를 튜닝하는 것과 같은 것을 의미할 수 있다. 현재 최선의 접근 방식에 대한 벤치마크가 공개되어 있을 수도 있지만, 항상 자신에게 유의미한 것은 아니다. 예를 들어, 수년 전에 오래된 하드웨어에서 공개된 것이라 공개된 수치와 일대일 비교를 할 수 없을 수도 있다. 혹은 그들의 테스트가 자신이 감당할 수 없는 규모로 실행되었을 수도 있다.
요즘 나는 Anthropic에서 머신러닝을 하고 있는데, ML 논문에서 이런 경우를 늘 본다. 어떤 알고리즘 개선이나 다른 진전을 주장하는 논문이 나오면, 우리 연구원들이 가장 먼저 묻는 것이 종종 “그들이 무엇을 했는가?”가 아니라 “무엇을 베이스라인으로 삼아 비교했는가?”라는 점을 알게 되었다. 제대로 튜닝되지 않은 베이스라인과 비교하면 인상적으로 보이는 결과를 얻기 쉽고, 이러한 관찰은 주장된 개선 중 놀랄 만큼 큰 비중을 설명하곤 한다.
소프트웨어 엔지니어링에 대하여
나에게 또 하나의 인상적인 점은, 우리 소프트웨어 시스템이 얼마나 복잡하고 서로 얽혀 있는지, 얼마나 빠르게 변하는지, 그리고 모든 구성 요소를 추적하는 것이 얼마나 어려운지다.
한 달 전에 내게 LLVM 릴리스가 CPython에 10% 성능 리그레션을 일으키고도 다섯 달 동안 아무도 눈치채지 못할 가능성을 추정해 보라고 했다면, 꽤 가능성이 낮은 일이라고 생각했을 것이다! 둘 다 널리 쓰이는 프로젝트이고 둘 다 성능을 꽤 중요하게 여기는데, “분명” 누군가는 테스트하고 알아챘을 것 같기 때문이다.
그리고 아마 그 특정 상황 자체는 꽤 가능성이 낮았을 것이다! 하지만 저마다 빠르게 변하며 서로 의존하고 수많은 다른 프로젝트에 의해 사용되는, 그토록 많은 소프트웨어 프로젝트가 존재하는 상황에서는, “그런 종류의” 어떤 리그레션이 거의 끊임없이 발생하는 것이 사실상 불가피해진다.
최적화 컴파일러
computed goto 인터프리터에 얽힌 이야기는 옵티마이저와 최적화 컴파일러를 둘러싼 반복되는 긴장과 아직 분야 차원에서 합의된 답을 찾지 못한 질문들을 잘 보여 준다.
우리는 일반적으로 컴파일러가 프로그래머의 의도를 존중하고, 작성된 코드를 그 의도를 보존하는 방식으로 컴파일하기를 기대한다.
하지만 동시에 컴파일러가 우리 코드를 최적화하고, 더 빠르게 실행되도록 잠재적으로 복잡하고 직관에 어긋나는 방식으로 변환해 주기를 기대하기도 한다.
이러한 기대는 서로 긴장 관계에 있으며, 우리가 왜 특정 방식으로 코드를 작성했는지, 특정 출력을 의도적으로 유도하려 했는지 또는 특정 성능 관련 결정을 내리려 했는지 여부를 컴파일러에게 설명할 패턴과 관용구는 부족하다.
우리 컴파일러는 일반적으로 우리가 작성한 코드와 “동일한 동작”을 하는 코드를 생성하겠다는 것만 보장한다. 성능은 그 보장 위에 얹힌 일종의 최선 노력(best-effort) 기능이다.
따라서 우리는 clang-19가 computed goto 인터프리터를 “정확하게” — 결과 바이너리가 우리가 기대하는 모든 동일한 값을 생성한다는 의미에서 — 컴파일하면서도, 동시에 최적화 의도와는 완전히 어긋나는 출력을 내놓는 기묘한 상황에 놓이게 된다. 게다가 다른 버전의 컴파일러가 “단순한” switch() 기반 인터프리터에 최적화를 적용해, 우리가 소스 코드를 다시 작성함으로써 “의도했던” 최적화와 정확히 동일한 최적화를 구현하는 모습도 본다.
돌이켜 보면, 소스 코드 수준에서의 “computed goto” 인터프리터와 머신 코드 수준에서의 “디스패치 복제”는 거의 직교하는 개념이었던 것으로 보인다! 우리는 그 결과로 나온 2x2 행렬의 모든 경우에 대한 예시를 보았다! 그 모든 python 바이너리가 실행 시 동일한 값을 계산하기 때문에, 현재의 도구로는 그 사이의 차이를 일관된 방식으로 논하는 것이 사실상 불가능하다.
이러한 혼란이야말로 tail-call 인터프리터(그리고 그 뒤의 컴파일러 기능)가 기술 수준에서 진정으로 유용한 발전을 이룬 지점이라고 생각한다. tail-call 인터프리터는 the musttail 속성 위에 구축되어 있는데, 이는 비교적 새로운 종류의 컴파일러 기능이다. musttail은 컴파일러가 생각하는 고전적인 의미의 “관찰 가능한 프로그램 동작”에는 영향을 주지 않으며, 오히려 옵티마이저와의 대화다. 이는 컴파일러가 특정 최적화를 수행할 수 있어야 함을 요구하고, 그 최적화가 일어나지 않으면 컴파일이 실패하도록 한다.
이 프레임워크가 특히 시간이 지나고 컴파일러가 발전함에 따라, 성능에 민감한 코드를 작성하는 훨씬 더 견고한 스타일이 되기를 기대한다. 이 범주에 속하는 기능들에 대한 지속적인 실험을 고대한다.
구체적으로, computed goto 인터프리터를 인터프리터 while 루프에 (가상의) [[clang::musttailduplicate]] 같은 속성으로 대체하는 것이 가능할지 궁금해진다. 나는 관련된 모든 IR과 패스에 대해 이 제안을 확신할 만큼 전문적이지 않지만, 더 잘 아는 누군가가 실현 가능성에 대해 의견을 보탤 수 있을지도 모른다.
한 가지 더: nix에 대하여
이 프로젝트에서 nix가 얼마나 도움이 되었는지 언급하며 마무리하고 싶다. 지난 1년 정도 개인 인프라를 위해 nix와 NixOS를 실험해 왔는데, 이번 조사에서는 정말 구세주 역할을 했다.
이러한 실험 과정에서 나는 네 가지 컴파일러(gcc, clang-18, clang-19, clang-20)에 걸쳐, 수많은 컴파일러 플래그 조합을 사용해 수십 개의 서로 다른 Python 인터프리터를 빌드하고 벤치마크했다. 그 모든 것을 수작업으로 관리했다면 정신적으로 견디기 어려웠을 것이고, 어떤 빌드에 어떤 컴파일러와 어떤 플래그가 들어갔는지 헷갈리는 등 수많은 실수를 했을 것이 확실하다.
nix를 이용해 이 모든 병렬 버전을 체계적으로 관리하고, 재현 가능하고 독립적인 방식으로 빌드할 수 있었다. 정의를 매우 쉽게 만들어 주는 짧은 추상화를 작성할 수 있었고, 내 nix 스토어에 있는 특정 빌드가 어떤 컴파일러와 어떤 플래그로, 어디에서 왔는지 절대적인 확신을 가지고 알 수 있었다. 헬퍼 함수를 만들기 위해 약간의 작업을 한 뒤, 빌드 매트릭스의 핵심 정의는 놀라울 정도로 간결해졌다. 맛보기는 다음과 같다.
{
base = callPackage buildPython { python3 = python313; };
optimized = withOptimizations base;
optLTO = withLTO optimized;
clang18 = withLLVM llvmPackages_18 optLTO;
clang19 = withLLVM llvmPackages_19 optLTO;
clang20 = withLLVM llvmPackages_20 optLTO;
clang18nozero = noZeroCallUsed clang18;
clang18nocg = withoutCG clang18;
clang19taildup = withTailDup clang19;
}나는 심지어 LLVM의 커스텀 버전(버그 수정 패치가 적용된)을 빌드하고, 그 컴파일러로 Python 빌드를 수행할 수 있었다. 그러는 데 필요한 코드는 고작 10줄 정도였다.
그렇다고 모든 것이 장밋빛이었던 것은 아니다. 우선, nix는 필요상 “일반적인 사람들”이 소프트웨어를 사용하는 방식과 비교했을 때 여러모로 “특이”하며, 그 특이함 중 일부가 내가 알아채지 못한 방식으로 일부 벤치마크나 결론에 영향을 미쳤을까 걱정된다. 예를 들어, 초기에 nix가 (기본적으로) tail-call 인터프리터에 불균형적으로 영향을 미치는 특정 하드닝 플래그를 사용해 프로젝트를 빌드한다는 사실을 알게 됐다. 그 문제는 처리했지만, 또 다른 문제는 없을까?
게다가 Nix는 엄청나게 확장 가능하고 커스터마이징할 수 있지만, 특정 커스터마이징을 어떻게 해야 하는지 알아내는 것은 정말 힘든 싸움이 될 수 있으며, 많은 시행착오와 소스 탐독을 수반한다. 내 패치된 LLVM 빌드는 결국 꽤 짧고 깔끔하게 끝났지만, 거기에 이르기까지는 많은 nixpkgs 소스 코드를 읽고, 문서화가 부족한 두 가지 확장 메커니즘(extend와 overrideAttrs — 다른 곳에서 쓰이는 override와 혼동하지 말 것)을 조합해야 했으며, libllvm은 성공적으로 패치했지만 조용히 패치되지 않은 버전에 대해 새로운 clang을 빌드해 버린 실패한 시도도 한 번 있었다.
그럼에도 nix는 여기서 분명 엄청나게 도움이 되었고, 전체적으로 볼 때 이런 종류의 다중 버전 탐색과 디버깅을 내가 상상할 수 있는 다른 어떤 접근 방식보다 훨씬 더 합리적으로 만들어 주었다.
이 옵션을 설정하는 것은 LTO를 사용할 때 다소 복잡하다. Tail duplication은 코드 생성 중에 일어나며, LTO 빌드에서는 코드 생성이 컴파일 타임이 아니라 링크 타임에 일어난다. 따라서 플래그가 컴파일러뿐만 아니라
lld까지 전달되도록 해야 한다. 나는 이를 동작시킬 수 있었다 —./configure시점에 다음 변수들로 Python을 설정함으로써:
↩︎./configure [other flags] \ "OPT=-g -O3 -Wall -mllvm -tail-dup-pred-size=5000" \ "LDFLAGS=-fuse-ld=lld -Wl,-mllvm -Wl,-tail-dup-pred-size=5000"
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기