CPython 3.14에 병합된 테일 콜 기반 바이트코드 인터프리터의 성능 향상 수치가 LLVM 19 회귀로 인해 과대평가되었음을 분석하고, 원인과 수정, 그리고 벤치마킹/소프트웨어 공학적 교훈을 정리한다.
약 한 달 전, CPython 프로젝트는 바이트코드 인터프리터에 대한 새 구현 전략을 병합했습니다. 초기의 헤드라인 결과는 매우 인상적 이었는데, 다양한 플랫폼에서 광범위한 벤치마크 전반에 걸쳐 평균적으로 10~15%의 성능 향상을 보인다는 것이었습니다.
하지만 이 글에서 기록하겠지만, 그 인상적인 성능 향상은 주로 LLVM 19의 회귀(regression)를 우연히 피해 간 것 때문인 것으로 드러났습니다. 더 나은 기준선(예: GCC, clang-18, 또는 특정 튜닝 플래그를 적용한 LLVM 19)과 비교해 벤치마킹하면, 성능 향상은 구성에 따라 대략 1~5% 정도로 떨어집니다.
테일 콜 인터프리터 발표를 처음 들었을 때, 저는 성능 개선에 놀라고 감탄하면서도 한편으로는 혼란스러웠습니다. 저는 전문가라고 할 수는 없지만 현대 CPU 하드웨어, 컴파일러, 인터프리터 설계에 대해 어느 정도는 알고 있는데, 왜 이 변화가 그렇게 효과적인지 설명할 수가 없었습니다. 호기심이 생겼고 — 어쩌면 약간 집착하게 되었고 — 이 글의 보고서는 몇 주 동안 틈틈이 여러 파이썬 바이너리를 컴파일하고 벤치마킹하고 디스어셈블해 보며, 제가 보고 있는 현상을 이해하려 시도한 결과입니다.
마지막에는 이 상황을 벤치마킹, 성능 공학, 그리고 일반적인 소프트웨어 공학에서 마주치는 도전 과제에 대한 사례 연구로서 되돌아보려 합니다.
또, 테일 콜 인터프리터가 훌륭한 작업이며 실제로 속도 향상(다만 초기 기대보다는 더 소박한)이 있다는 점은 분명히 하고 싶습니다. 이 글에서 설명하겠지만, 저는 이것이 기존 인터프리터보다 어떤 면에서 더 견고한 접근이라고도 낙관합니다. 또한 이 오류에 대해 파이썬 팀 누구에게도 책임을 묻고 싶지 않습니다. 이런 혼동은 매우 흔하고 — 저 역시 벤치마크를 오해한 적이 많습니다 — 이 주제에 대한 생각도 마지막에 덧붙이겠습니다.
추가로, 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로 빌드, 새 테일 콜 인터프리터 사용.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.09x 느림 | 1.01x 빠름 | 1.03x 빠름 | 1.02x 빠름 |
| Apple M1 Macbook Air | (기준) | 1.12x 느림 | 1.02x 느림 | 1.00x 느림 | N/A |
테일 콜 인터프리터는 여전히 clang-18 대비 속도 향상이 있지만, clang-19로 넘어가며 생기는 성능 저하에 비하면 훨씬 덜 극적임을 알 수 있습니다. 또한 파이썬 팀은(버그를 고려한 뒤에도) 다른 일부 플랫폼에서 제가 측정한 것보다 더 큰 속도 향상을 관측하기도 했습니다.
clang18.tc(즉, 오래된 Clang에서 테일 콜 인터프리터) 벤치마크가 없는 이유도 보일 겁니다. 테일 콜 인터프리터는 Clang 19에 들어온 새로운 컴파일러 기능에 의존하므로 이전 버전에서 시험할 수 없습니다. 이 상호작용이 이 이야기를 특히 혼란스럽게 만들었고, 제가 상황을 확신할 때까지 수많은 벤치마크가 필요했던 큰 이유라고 생각합니다.
전형적인 바이트코드 인터프리터는 while 루프 안의 switch 문으로 구성되며 대략 다음과 같습니다:
cwhile (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로 인덱싱한 뒤 간접 점프(indirect jump)를 수행합니다.
이런 스타일의 바이트코드 인터프리터는 각 opcode 본문 안에 점프 테이블 디스패치를 복제하면 속도가 빨라진다는 사실이 오래전부터 알려져 있습니다. 즉 각 opcode가 jmp loop_top으로 끝나는 대신, 각 opcode가 “다음 명령을 디코드하고 점프 테이블을 통해 분기”하는 로직을 자체적으로 포함하는 것입니다.
현대 C 컴파일러는 라벨의 주소를 취하는 기능을 지원하며, 이를 “computed goto”로 사용해 이 패턴을 구현할 수 있습니다. 그래서 CPython(테일 콜 작업 전) 등 많은 현대 바이트코드 인터프리터는 대략 다음과 같은 루프를 사용합니다:
cstatic 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(); }
(생성 코드의 성능이 아니라 컴파일러의 성능 때문에) 성능상 이유로, Clang/LLVM은 내부적으로 위 코드의 모든 goto를 단 하나의 indirectbr LLVM 명령으로 합쳐 버리는 것으로 알려져 있습니다. 각 opcode는 그 indirectbr로 점프합니다. 즉 컴파일러가 우리가 힘들게 만든 구조를 일부러 고전적인 switch 기반 인터프리터와 본질적으로 동일한 제어 흐름 그래프로 다시 써 버리는 것입니다!
그런 다음 코드 생성(codegen) 단계에서 LLVM은 “tail duplication”을 수행하여, 분기 로직을 각 위치로 다시 복사해 넣어 원래 의도를 복원합니다. 이 과정은 새로운 구현을 소개하는 오래된 LLVM 블로그 글에 상위 수준에서 설명되어 있습니다.
이 “중복 제거 후 복사” 춤의 목적은, 기술적 이유로 indirectbr 명령이 많이 들어간 제어 흐름 그래프를 만들고 조작하는 것이 매우 비쌀 수 있기 때문입니다.
특정 경우에 치명적인 속도 저하(또는 메모리 사용)를 피하기 위해 LLVM 19는 tail-duplication 패스에 몇 가지 제한을 도입했고, 중복 복사가 IR 크기를 일정 한도 이상 키울 것 같으면 패스가 중단되도록 했습니다.
불행히도 CPython에서는 그 제한 때문에 Clang이 디스패치 점프를 모두 병합된 상태로 남겨 두었고, computed goto 기반 구현의 목적을 완전히 무효화해 버렸습니다! 이 버그는 유사한 인터프리터 루프를 가진 다른 언어 구현에서 처음 식별되었지만, (제가 찾은 한) CPython에 영향을 준다는 사실은 알려져 있지 않았습니다.
성능 영향 외에도, 결과 오브젝트 코드를 디스어셈블해 간접 점프의 개수를 세면 버그를 직접 관찰할 수 있습니다:
sh$ 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
테일 복제 로직 변화가 회귀를 유발했다는 점은 확신합니다. 이를 고치면 성능이 clang-18과 맞아떨어집니다. 하지만 회귀의 규모는 완전히 설명하지 못합니다.
역사적으로, 바이트코드 디스패치를 각 opcode에 복제하는 최적화는 인터프리터를 20%에서 100%까지 빠르게 한다고 인용되곤 했습니다. 하지만 분기 예측기가 개선된 현대 프로세서에서는, 좀 더 최근 연구가 2~4% 수준의 더 작은 향상을 보고합니다.
파이썬은 여전히(구성 옵션을 통해) 단일 switch 문을 사용하는 “구식” 인터프리터를 지원하므로, 실제로 이 2~4% 정도를 실험으로 확인할 수 있습니다. 아래 표에서 “.nocg”는 “no computed gotos”를 뜻합니다:
| 벤치마크 | clang18 | clang18.nocg | clang19.nocg | clang19 |
|---|---|---|---|---|
| 성능 변화 | (기준) | 1.01x 빠름 | 1.02x 느림 | 1.09x 느림 |
여기서 clang19.nocg는 clang18보다 2% 정도만 느린데, 기본 clang19 빌드는 9% 느립니다! 저는 이 “2%”를 opcode 디스패치 복제 자체의 비용/편익에 대한 더 공정한 추정치로 해석하며, 나머지 차이는 충분히 이해하지 못합니다.
아직 언급하지 않은 clang19.nocg 벤치마크를 보면, 이것이 clang19보다 더 빠르다고 주장하는 것을 볼 수 있습니다. 이 지점에서 저는 이야기의 추가적인, 그리고 매우 웃긴 반전을 발견했습니다.
앞에서 Clang/LLVM이:
switch를 점프 테이블과 간접 점프로 컴파일하며(우리가 computed goto로 손수 만드는 것과 매우 유사)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를 사용할 때는 원하는 동작을 구현합니다. 즉 적어도 GCC에서는 우리가 기대하는 동작이 나타납니다.
LLVM PR 114990은 이 글을 게시한 직후 병합되었고, 회귀를 수정합니다. 저는 병합 전에 벤치마킹해 기대 성능이 복원됨을 확인할 수 있었습니다.
그 수정 이전 릴리스들에 대해서는, 회귀를 유발한 PR이 tail-duplication이 중단되는 임계값을 조절하는 옵션을 추가했습니다. clang-19에서 그 한도를 매우 큰 값으로 설정하기만 해도1 유사한 동작을 복구할 수 있습니다.
솔직히 말해 저는 이 주제에 제대로 ‘덕질 저격(nerdsniped)’을 당했고, 사실 필요 이상으로 깊게 파고들었습니다. 하지만 그렇게 하고 나니, 소프트웨어 공학과 성능 공학 전반으로 일반화될 만한 흥미로운 교훈과 생각거리가 여럿 있다고 느꼈습니다. 아래에서는 그중 일부를 뽑아 곱씹어 보려 합니다.
시스템을 최적화할 때 우리는 보통 벤치마크와 벤치마킹 방법론을 구성하고, 제안된 변경을 그 벤치마크로 평가합니다.
어떤 벤치마크 집합이나 절차든 (종종 암묵적으로) 제가 “성능 이론(theory of performance)”이라고 부르는 것을 내장합니다. 성능 이론이란 “어떤 변수들이 성능에 영향을 (줄 수) 있는가, 어떤 방식으로 영향을 주는가?”, “벤치마크 결과와 실제 운영(production)에서의 ‘진짜’ 성능의 관계는 무엇인가?” 같은 질문에 답하는 믿음과 가정의 집합입니다.
테일 콜 인터프리터에 대해 수행된 벤치마크는 기존 computed-goto 인터프리터 대비 1015% 속도 향상을 보였습니다. 그 벤치마크는 정확했는데, 제가 아는 한 해당 빌드들 간 성능 차이를 정확히 측정했습니다. 하지만 그 특정 데이터 포인트를 “일반적으로 테일 콜 인터프리터가 computed-goto 인터프리터보다 1015% 빠르다” 또는 “테일 콜 인터프리터가 사용자들에게 Python을 10~15% 빠르게 해 줄 것이다”라는 주장으로 일반화하려면, 세계에 대한 더 많은 가정과 믿음을 끌어와야 합니다. 이 경우에는 이야기가 더 복잡했고, 그 넓은 주장은 완전한 일반성에서는 사실이 아니었습니다.
(다시 말하지만 파이썬 개발자들을 비난하고 싶지 않습니다! 이런 일은 어렵고, 혼동하거나 다소 부정확한 결론에 도달할 방법이 무수히 많습니다. 저도 더 나은 이해에 도달하기 위해 약 3주간의 강도 높은 벤치마킹과 실험이 필요했습니다. 제 요지는 이것이 매우 일반적인 도전이라는 것입니다.)
이 사례는 소프트웨어 성능뿐 아니라 많은 분야에서 반복되는 또 다른 문제를 강조합니다. “어떤 기준선과 비교할 것인가?”
어떤 문제에 대해 새로운 해법이나 방법을 제안할 때, 보통은 그 새로운 방법을 실행해 관련 성능 지표를 만드는 방법이 있습니다.
하지만 내 시스템의 지표를 얻고 나면, 그것이 좋은지 판단하려면 무엇과 비교해야 하는지가 필요합니다! 어떤 절대적 척도에서 점수가 좋아 보이더라도(애초에 말이 되는 절대 척도가 있다고 해도), 기존 해법보다 나쁘다면 흥미롭지 않을 가능성이 큽니다.
대개는 “현재 알려진 최선의 접근(current best-known approach)”과 비교하고 싶습니다. 하지만 그게 어려울 때가 있습니다. 이론적으로 현재 접근을 이해하더라도, 실제로 적용하는 데 능숙한 전문가일 수도 아닐 수도 있습니다. 소프트웨어에서는 OS나 컴파일러 옵션, 플래그 튜닝 같은 것을 의미할 수 있습니다. 현재 최선의 접근에 대한 공개 벤치마크가 있어도, 여러분에게 관련이 없을 수 있습니다. 예컨대 수년 전 구형 하드웨어에서 수행되어 공개 수치와 사과대사과 비교가 안 된다든지, 또는 그들이 수행한 규모를 재현할 비용이 없다든지요.
저는 요즘 Anthropic에서 머신러닝 일을 하는데, ML 논문에서 이런 현상을 항상 봅니다. 어떤 논문이 알고리즘 개선을 주장하면, 연구자들이 가장 먼저 묻는 디테일은 종종 “무엇을 했나?”가 아니라 “어떤 기준선과 비교했나?”입니다. 제대로 튜닝되지 않은 기준선과 비교하면 인상적인 결과를 얻기 쉽고, 그 관찰이 놀랄 만큼 많은 ‘개선’ 사례를 설명합니다.
저에게 또 하나의 하이라이트는, 우리 소프트웨어 시스템이 얼마나 복잡하고 서로 얽혀 있으며, 얼마나 빠르게 변화하고, 모든 조각을 추적하기가 얼마나 어려운가 하는 점입니다.
만약 한 달 전에 “LLVM 릴리스가 CPython에서 10% 성능 회귀를 일으켰고 5개월 동안 아무도 눈치채지 못했다”는 상황의 가능성을 물었다면, 저는 꽤 가능성이 낮다고 추정했을 겁니다. 두 프로젝트 모두 널리 쓰이고 성능에도 꽤 신경 쓰는데, “당연히” 누군가 테스트해서 알아챘을 것이라고 생각했겠죠.
아마 그 특정한 상황은 꽤 드물었을 겁니다. 하지만 수많은 소프트웨어 프로젝트가 있고, 각자가 빠르게 움직이며, 서로 의존하고 사용되는 조합이 너무 많다 보니, 그와 비슷한 회귀들은 사실상 거의 끊임없이 발생하는 것이 실질적으로 불가피해집니다.
computed-goto 인터프리터의 사가는, 최적화기와 최적화 컴파일러를 둘러싼 반복되는 긴장과, 아직 합의된 답이 없는 질문들을 보여줍니다.
우리는 보통 컴파일러가 프로그래머의 의도를 존중하고, 작성된 코드를 그 의도를 보존하는 방식으로 컴파일하길 기대합니다.
하지만 동시에 우리는 컴파일러가 코드를 최적화하고, 더 빠르게 만들기 위해 복잡하고 직관에 반하는 변환까지 수행하길 기대합니다.
이 기대들은 긴장 관계에 있고, 우리가 어떤 이유로 코드를 그런 방식으로 작성했는지, 그것이 어떤 출력이나 성능 결정을 의도적으로 유도하려는 것이었는지 여부를 컴파일러에 설명하는 패턴과 관용구가 부족합니다.
컴파일러는 보통 우리가 작성한 코드와 “동일한 동작”을 하는 코드를 내보내겠다고만 약속합니다. 성능은 그 보장 위에 얹힌 일종의 최선 노력(best-effort) 특성입니다.
그래서 clang-19가 computed-goto 인터프리터를 “정확하게” 컴파일하긴 합니다 — 결과 바이너리는 기대하는 동일한 값을 계산합니다 — 하지만 동시에 최적화의 의도와는 완전히 어긋나는 출력을 만들 수 있습니다. 게다가 다른 컴파일러 버전들은 “순진한” switch() 기반 인터프리터에 최적화를 적용해, 우리가 소스 재작성으로 “의도했던” 것과 똑같은 최적화를 구현해 버리기도 합니다.
되돌아보면, 소스 코드 수준의 “computed goto” 인터프리터와, 머신 코드 수준의 “디스패치 복제”는 거의 직교하는 개념이었던 듯합니다! 결과적으로 2x2 행렬의 모든 경우를 다 본 셈입니다. 이 모든 python 바이너리는 실행하면 같은 값을 계산하기 때문에, 현재 도구들은 이 차이들을 일관되게 설명할 언어를 거의 제공하지 못합니다.
이 혼동은, 테일 콜 인터프리터(및 그 뒤의 컴파일러 기능들)가 기술 수준에서 진정한(그리고 유용한) 진전이라고 생각하게 만드는 이유 중 하나입니다. 테일 콜 인터프리터는 비교적 새로운 종류의 컴파일러 기능인 musttail 속성을 기반으로 합니다. musttail은 컴파일러가 전통적으로 생각하는 의미의 “관측 가능한 프로그램 동작”을 바꾸는 것이 아니라, 최적화기와의 대화에 가깝습니다. 즉 컴파일러가 특정 최적화를 할 수 있어야 함을 요구하고, 그 최적화가 일어나지 않으면 컴파일이 실패하도록 요구합니다.
저는 이 프레임워크가 시간이 흐르고 컴파일러가 진화하더라도 성능 민감 코드를 더 견고하게 작성하는 스타일이 될 수 있기를 희망합니다. 그리고 그런 범주의 기능들에 대한 실험이 계속되길 기대합니다.
구체적으로는, computed-goto 인터프리터를 (가상의) 인터프리터 while 루프에 대한 [[clang::musttailduplicate]] 같은 속성으로 대체하는 것이 가능할지 궁금해지기도 합니다. 관련 IR과 패스들에 충분히 정통하지 않아 이 제안의 실현 가능성을 자신 있게 말하긴 어렵지만, 더 익숙한 누군가가 가능성에 대해 의견을 줄 수 있지 않을까 합니다.
nix에 대하여🔗︎마지막으로, 이 프로젝트에서 nix가 얼마나 도움이 되었는지 언급하며 마무리하고 싶습니다. 지난 1년 정도 개인 인프라에서 nix와 NixOS를 실험해 왔는데, 이번 조사에서는 정말 구세주였습니다.
이 실험 과정에서 저는 네 가지 컴파일러(gcc, clang-18, clang-19, clang-20)와 수많은 컴파일 플래그 조합을 사용해 수십 개의 서로 다른 파이썬 인터프리터를 빌드하고 벤치마킹했습니다. 이 모든 것을 수작업으로 관리했다면 제 정신이 먼저 무너졌을 것이고, 어떤 빌드에 어떤 컴파일러/플래그가 들어갔는지 섞어버리는 실수도 분명 여러 번 했을 겁니다.
nix를 사용하면서, 이 병렬 버전들을 명확히 구분해 재현 가능하고 격리된(hermetic) 방식으로 빌드할 수 있었습니다. 정의를 쉽게 만드는 짧은 추상화를 몇 개 작성할 수 있었고, nix store 안의 특정 빌드가 어디서 왔는지, 어떤 컴파일러와 플래그로 만들어졌는지 절대적 확신을 가질 수 있었습니다. 약간의 헬퍼 함수만 만들고 나니, 제 빌드 매트릭스의 핵심 정의가 놀랄 만큼 간결해졌습니다. 일부를 보면:
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 커스텀 버전도 빌드해서 그 컴파일러로 파이썬을 빌드할 수 있었습니다. 그 과정은 약 10줄 정도의 코드면 충분했습니다.
물론 모든 것이 장밋빛은 아니었습니다. 하나는 nix가 필연적으로 “일반적인 사람들”이 소프트웨어를 쓰는 방식과 비교해 여러 면에서 “이상하다”는 점이고, 그 이상함이 제가 눈치채지 못한 방식으로 벤치마크나 결론에 영향을 줬을까 걱정된다는 점입니다. 예컨대 초기에 nix가(기본값으로) 특정 하드닝 플래그를 켠 채로 빌드하고, 그것이 테일 콜 인터프리터에 불균형하게 영향을 준다는 사실을 발견했습니다. 이 문제는 처리했지만, 더 있을까요?
또한 Nix는 확장성과 커스터마이즈 가능성이 엄청나지만, 특정 커스터마이즈를 구현하는 방법을 알아내는 것은 상당히 험난할 수 있고, 많은 시행착오와 소스 탐색을 필요로 합니다. 패치된 LLVM 빌드는 결과적으로 짧고 깔끔했지만, 그에 도달하기 위해 nixpkgs 소스 코드를 많이 읽었고, 문서가 부족한 두 확장 메커니즘(extend와 overrideAttrs — 다른 곳에서 쓰는 override와 혼동 주의)을 섞어 썼으며, 한 번은 libllvm에는 패치가 적용됐지만 clang가 조용히 패치되지 않은 버전에 링크되는 실패도 겪었습니다.
그럼에도 nix는 여기서 엄청난 도움을 줬고, 결과적으로 이런 다중 버전 탐색과 디버깅을 제가 상상할 수 있는 어떤 접근보다도 훨씬 더 sane하게 만들어 주었습니다.
LTO를 사용할 때 이 옵션을 설정하는 일은 조금 복잡하다는 점에 유의하세요. Tail duplication은 코드 생성 단계에서 일어나며, LTO 빌드에서는 코드 생성이 컴파일 시점이 아니라 링크 시점에 일어납니다. 따라서 이 플래그가 컴파일러에만 전달되는 것이 아니라 lld에도 전달되도록 해야 합니다. 저는 ./configure 시점에 다음 변수들을 설정해 작동하게 만들 수 있었습니다:
sh./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"