PEP 744 – JIT 컴파일
- Author:
- Brandt Bucher <brandt at python.org>, Savannah Ostrowski <savannah at python.org>
- Discussions-To:
- Discourse thread
- Status:
- Draft
- Type:
- Informational
- Created:
- 11-Apr-2024
- Python-Version:
- 3.13
- Post-History:
- 11-Apr-2024
Table of Contents
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
올해 초, 실험적인 “JIT(just-in-time)” 컴파일러가 CPython의 main 개발 브랜치에 병합되었습니다. 최근 CPython 릴리스에도 다른 상당한 내부 변경 사항이 포함되어 왔지만, 이번 추가는 CPython이 전통적으로 Python 코드를 실행해 온 방식에서 특히 크게 벗어나는 변화입니다. 따라서 더 폭넓게 논의할 가치가 있습니다.
이 PEP는 이번 추가의 배경이 된 설계 결정, 현재 구현 상태, 그리고 JIT를 CPython의 실험적이지 않은 영구적인 일부로 만들기 위한 향후 계획을 요약하는 것을 목표로 합니다. 이 PEP는 JIT가 어떻게 동작하는지에 대한 포괄적인 개요를 제공하려는 것이 아닙니다. 대신 선택한 접근 방식의 구체적인 장단점에 초점을 맞추고, JIT가 도입된 이후 제기된 많은 질문에 답합니다.
새 JIT에 대해 더 알고 싶은 독자는 다음 자료를 참고하시기 바랍니다.
- 2023년 CPython 코어 개발자 스프린트에서 JIT를 처음 소개한 발표입니다. 여기에는 관련 배경, 사용된 “복사 후 패치(copy-and-patch)” 기법에 대한 가벼운 기술적 소개, 그리고 그 자리에 있던 코어 개발자들 사이에서 이루어진 설계에 대한 공개 토론이 포함되어 있습니다. 이 발표의 슬라이드는 GitHub에서 볼 수 있습니다.
- 복사 후 패치 기법을 처음 기술한 오픈 액세스 논문입니다.
- 논문 저자가 Lua용 복사 후 패치 JIT 컴파일러의 구현을 자세히 설명한 블로그 게시물입니다. 이 글은 해당 접근 방식을 저수준에서 훌륭하게 설명하지만, CPython의 JIT와는 크게 관련이 없는 다른 기법들도 함께 사용하고 그러한 구현 결정도 내리고 있다는 점에 유의하십시오.
- 구현 그 자체입니다.
동기
지금까지 CPython은 항상 Python 코드를 바이트코드로 컴파일한 뒤 런타임에 이를 인터프리트하는 방식으로 실행해 왔습니다. 이 바이트코드는 소스 코드를 거의 그대로 옮긴 것입니다. 타입 정보가 없고, 대부분 최적화되어 있지 않습니다.
Python 3.11 릴리스부터 CPython은 “특수화 적응형 인터프리터”(PEP 659)를 사용해 왔으며, 이 인터프리터는 바이트코드 명령어가 실행되는 동안 이를 타입 특수화 버전으로 제자리에서 재작성합니다. 이 새 인터프리터는 최적화 가능성이 개별 바이트코드 명령어의 경계에 의해 제한되는데도 상당한 성능 향상을 제공합니다. 또한 이 인터프리터는 프로그램을 흐르는 타입, 특정 객체의 메모리 레이아웃, 프로그램에서 가장 많이 실행되는 경로 등 풍부한 새 프로파일링 정보도 수집합니다. 즉, 무엇을 최적화하고 어떻게 최적화할지를 알려 줍니다.
Python 3.12 릴리스부터 CPython은 이 인터프리터를 C와 유사한 도메인 특화 언어 (DSL)로부터 생성해 왔습니다. 이 DSL은 새 적응형 인터프리터의 복잡성을 어느 정도 다스릴 뿐 아니라, 명령어 정의와 동기화 상태를 유지해야 하는 인터프리터, 컴파일러, 표준 라이브러리의 여러 부분에서 CPython 유지 관리자들이 지루한 보일러플레이트 코드를 직접 작성하지 않아도 되게 해 줍니다. 단일 진실 공급원(single source of truth)으로부터 대량의 런타임 인프라를 생성하는 이 능력은 유지 관리에 편리할 뿐만 아니라, CPython의 실행을 새로운 방식으로 확장할 수 있는 많은 가능성을 열어 줍니다. 예를 들어, 명령어 시퀀스를 그와 동등한 더 작은 “마이크로 연산(micro-op)” 시퀀스로 변환하는 테이블을 자동으로 생성하고, 이러한 마이크로 연산 시퀀스를 위한 최적화기를 생성하며, 심지어 이를 실행하기 위한 두 번째 인터프리터 전체를 생성하는 것까지 가능해집니다.
실제로 Python 3.13 릴리스 주기 초기부터 모든 CPython 빌드에는 바로 이 마이크로 연산 변환, 최적화, 실행 장치가 포함되어 있습니다. 그러나 이 기능은 기본적으로 비활성화되어 있습니다. 최적화된 마이크로 연산 트레이스라 하더라도 이를 인터프리트하는 오버헤드가 대부분의 코드에서 너무 크기 때문입니다. 더 강도 높은 최적화를 적용하더라도 상황이 크게 나아지지는 않을 것입니다. 새로운 최적화로 얻는 효율 향상은 더 작고 더 복잡해진 마이크로 연산을 인터프리트하는 오버헤드로 상쇄될 가능성이 높기 때문입니다.
이 새로운 병목을 극복하기 위한 가장 명백한 전략은 이렇게 최적화된 트레이스를 정적으로 컴파일하는 것입니다. 이렇게 하면 인터프리트 과정에서 생기는 여러 간접 참조와 오버헤드의 원인을 피할 기회가 생깁니다. 특히 마이크로 연산 간의 디스패치 오버헤드(범용 인터프리터를 핫 코드의 직선형 시퀀스로 대체함으로써), 개별 마이크로 연산의 명령어 디코딩 오버헤드(인자, 상수, 캐시된 값의 값이나 주소를 기계어 명령어에 직접 “새겨 넣음”으로써), 그리고 메모리 트래픽(데이터를 힙에 할당된 Python 프레임에서 물리적 하드웨어 레지스터로 옮김으로써)을 제거할 수 있습니다.
이 데이터의 상당 부분은 똑같은 프로그램을 똑같이 실행해도 실행할 때마다 달라집니다. 또한 기존 최적화 파이프라인은 런타임 프로파일링 정보에 크게 의존합니다. 따라서 이러한 트레이스를 미리(ahead of time) 컴파일하는 것은 별로 의미가 없으며, 그렇게 하려면 이미 구현된 기존 명세와 마이크로 연산 트레이싱 인프라를 대대적으로 재설계해야 합니다. 다른 많은 동적 언어에서(그리고 Python 자체에서도) 입증되었듯이, 가장 유망한 접근 방식은 최적화된 마이크로 연산을 실행 시점에 “그때그때(just in time)” 컴파일하는 것입니다.
근거
흔히 알려진 평판과 달리 JIT 컴파일러는 무엇이든 “더 빠르게” 만들어 주는 마법의 기계가 아닙니다. CPython이 지원하는 가장 널리 쓰이는 플랫폼 전체는 말할 것도 없고 단 하나의 플랫폼만 대상으로 하더라도, 어떤 종류든 최적화 컴파일러를 개발하고 유지 보수하는 것은 엄청나게 복잡하고 비용이 많이 드는 작업입니다. LLVM 같은 기존 컴파일러 프레임워크를 사용하면 이 작업이 더 간단해질 수 있지만, 그 대가로 무거운 런타임 의존성이 생기고 JIT 컴파일 오버헤드도 상당히 커집니다.
런타임에 Python 코드를 성공적으로 컴파일하려면 실행되는 코드에 대한 고품질의 Python 특화 최적화뿐만 아니라 최적화된 프로그램을 위한 효율적인 기계어 코드를 빠르게 생성하는 것도 필요하다는 점은 분명합니다. Python 코어 개발 팀은 전자(인터프리터와 긴밀하게 결합된 미들엔드)에 필요한 기술과 경험을 갖추고 있으며, 복사 후 패치 컴파일은 후자에 대한 매력적인 해결책을 제공합니다.
간단히 말해, 복사 후 패치는 인터프리터의 나머지 부분을 생성하는 데 사용되는 것과 동일한 DSL로부터 고품질의 템플릿 JIT 컴파일러를 생성할 수 있게 해 줍니다. CPython처럼 널리 사용되고 자원봉사자가 주도하는 프로젝트에서 이 이점은 아무리 강조해도 지나치지 않습니다. CPython 유지 관리자는 바이트코드 정의를 수정하기만 하면 JIT를 지원하는 모든 플랫폼의 JIT 백엔드도 한 번에 “공짜로” 갱신됩니다. 이는 명령어를 추가하든, 수정하든, 제거하든 똑같이 적용됩니다.
인터프리터의 나머지 부분과 마찬가지로 JIT 컴파일러는 빌드 시점에 생성되며, 런타임 의존성이 없습니다. JIT 컴파일러는 다양한 플랫폼을 지원하며(아래 Support 섹션을 참조하십시오), 유지 보수 부담이 비교적 적습니다. 현재 구현은 전체적으로 약 900줄의 빌드 시점 Python 코드와 500줄의 런타임 C 코드로 이루어져 있습니다.
명세
JIT는 현재 기본 빌드 구성에 포함되어 있지 않으며, 당분간 그 상태가 유지될 가능성이 높습니다(다만 공식 바이너리에는 포함될 수도 있습니다). 그렇긴 하지만, 다음 조건이 모두 충족되면 JIT는 더 이상 실험적 기능이 아니게 됩니다:
- 널리 쓰이는 플랫폼 중 적어도 하나에서 의미 있는 성능 향상을 제공합니다(현실적으로 5% 정도).
- 혼란을 최소화하면서 빌드, 배포, 적용할 수 있습니다.
- 요청을 받은 운영 위원회(Steering Council)가 (유지 보수 부담, 메모리 사용량, 대안 설계의 실현 가능성 등의 트레이드오프를 고려하여) JIT를 비활성화할 때보다 활성화할 때 커뮤니티에 더 큰 가치를 제공한다고 판단했습니다.
이 기준은 출발점으로 간주해야 하며, 시간이 지나면서 확장될 수 있습니다. 예를 들어, 이 PEP에 대한 논의를 통해 추가 요구 사항(여러 명의 헌신적인 유지 관리자, 보안 감사, devguide 문서화, 프로세스 외부 디버깅 지원, JIT를 비활성화하는 런타임 옵션 등)을 이 목록에 추가해야 한다는 점이 드러날 수도 있습니다.
JIT가 실험적 기능에서 벗어나기 전까지는 프로덕션 환경에서 사용해서는 안 되며, 언제든지 예고 없이 망가지거나 제거될 수 있습니다.
JIT가 더 이상 실험적 기능이 아니게 되면, --enable-optimizations나 --with-lto 같은 다른 빌드 옵션과 거의 같은 방식으로 취급해야 합니다. 일부 플랫폼에서는 권장(또는 기본) 옵션이 될 수도 있으며, 릴리스 관리자는 공식 릴리스에서 이 옵션을 활성화하기로 결정할 수도 있습니다.
지원
JIT는 PEP 11의 현재 1단계(tier one) 플랫폼 전체, 2단계(tier two) 플랫폼 대부분, 그리고 3단계(tier three) 플랫폼 중 하나를 대상으로 개발되었습니다. 구체적으로, CPython의 main 브랜치에는 다음 플랫폼에서 릴리스 빌드와 디버그 빌드 모두에 대해 JIT를 빌드하고 테스트하는 CI가 있습니다:
aarch64-apple-darwin/clangaarch64-pc-windows/msvc[1]aarch64-unknown-linux-gnu/clang[2]aarch64-unknown-linux-gnu/gcc[2]i686-pc-windows-msvc/msvcx86_64-apple-darwin/clangx86_64-pc-windows-msvc/msvcx86_64-unknown-linux-gnu/clangx86_64-unknown-linux-gnu/gcc
일부 플랫폼은 향후의 1등급 플랫폼이라 하더라도 JIT 지원을 끝내 얻지 못할 수 있다는 점에 유의할 필요가 있습니다. 그 이유는 다양합니다. LLVM 지원이 충분하지 않을 수도 있고(powerpc64le-unknown-linux-gnu/gcc), 플랫폼 자체에 본질적인 한계가 있을 수도 있으며(wasm32-unknown-wasi/clang), 개발자의 관심이 부족할 수도 있습니다(x86_64-unknown-freebsd/clang).
어떤 플랫폼에 JIT 지원이 추가되면(즉, 사용자에게 경고를 표시하지 않고 JIT가 성공적으로 빌드되면) 그 플랫폼은 PEP 11에서 규정하는 것과 거의 같은 방식으로 다루어야 합니다. 신뢰할 수 있는 CI/빌드봇을 갖추어야 하고, 1등급 및 2등급 플랫폼에서 JIT가 실패하면 릴리스를 막아야 합니다. JIT 지원 여부를 명시하려고 PEP 11을 반드시 갱신해야 하는 것은 아니지만, 그래도 갱신해 두면 도움이 될 수 있습니다. 갱신하지 않는다면 지원 플랫폼 목록을 JIT의 README에서 관리해야 합니다.
CPython은 언제나 JIT 없이도 빌드할 수 있어야 하므로, 어떤 플랫폼에서 JIT 지원을 제거하는 것을 하위 호환성을 깨는 변경으로 간주해서는 안 됩니다. 다만 합리적으로 가능한 경우에는 PEP 387에 설명된 일반적인 폐지 절차를 따라야 합니다.
JIT의 빌드 시점 의존성은 합리적인 범위 안에서 릴리스마다 바뀔 수 있습니다.
하위 호환성
현재 인터프리터와 JIT 백엔드는 모두 동일한 명세로부터 생성되므로, Python 코드의 동작은 전혀 바뀌지 않아야 합니다. 실제로 테스트 중에 발견되어 수정된 관찰 가능한 차이는 복사 후 패치(copy-and-patch) 단계의 버그라기보다 대체로 기존 마이크로 연산 변환 및 최적화 단계의 버그였습니다.
디버깅
Python 코드를 프로파일링하고 디버깅하는 도구는 계속해서 문제없이 동작합니다. 여기에는 Python이 제공하는 기능(sys.monitoring, sys.settrace, sys.setprofile 등)을 사용하는 프로세스 내부 도구뿐만 아니라, 인터프리터 상태로부터 Python 프레임을 순회하는 프로세스 외부 도구도 포함됩니다.
그러나 현재 C 코드용 프로파일러와 디버거는 JIT 프레임을 거슬러 추적하지 못하는 것으로 보입니다. 말단(leaf) 프레임을 다루는 것은 가능하지만(JIT 자체도 이 방식으로 디버깅합니다), JIT 프레임에 제대로 된 디버깅 정보가 없어서 그 쓸모는 제한적입니다.
JIT가 내보내는 코드 템플릿은 Clang으로 컴파일되므로, 프레임 포인터를 더 신중하게 사용하도록 컴파일러 플래그를 수정하기만 해도 JIT 프레임을 거쳐 추적할 수 있을지도 모릅니다. Clang이 생성하는 디버깅 정보를 수집하여 내보내는 것도 가능할 수 있습니다. 두 아이디어 모두 아직 깊이 탐구되지는 않았습니다.
반드시 고쳐야 할 문제이기는 하지만, 지금 당장은 이를 고치는 것이 특별히 높은 우선순위가 아닙니다. 이 문제는 해당 분야에 전문성이 더 깊은 사람이 JIT 관리자들과 협력하여 탐구하는 것이 가장 좋을 것입니다. JIT 관리자들은 이러한 도구의 내부 동작에 대한 경험이 적기 때문입니다.
보안 영향
이 JIT는 다른 모든 JIT와 마찬가지로 런타임에 대량의 실행 가능한 데이터를 생성합니다. 이 데이터의 내용에 영향을 줄 수 있는 악의적인 행위자는 곧 임의의 코드를 실행할 수 있으므로, 이는 CPython에 잠재적인 새로운 공격 표면이 됩니다. 이것은 JIT 컴파일러의 잘 알려진 취약점입니다.
이 위험을 줄이기 위해 JIT는 모범 사례를 염두에 두고 작성되었습니다. 특히 JIT 컴파일러는 해당 데이터가 쓰기 가능한 동안에는 프로그램의 다른 부분에 이를 노출하지 않으며, 데이터가 동시에 writable and executable인 시점은 전혀 없습니다.
템플릿 기반 JIT는 그 특성상 생성할 수 있는 코드의 종류도 크게 제한하므로, 익스플로잇이 성공할 가능성은 더욱 낮아집니다. 추가 예방 조치로, 템플릿 자체는 정적인 읽기 전용 메모리에 저장됩니다.
그러나 특히 아직 초기 단계인 지금, JIT에 취약점이 전혀 없을 것이라고 가정하는 것은 순진한 생각입니다. 저자는 보안 전문가가 아니지만, 보안 문제가 발생하는 대로 이를 분류하고 수정하기 위해 Python 보안 대응 팀에 합류하거나 긴밀히 협력할 수 있습니다.
Apple Silicon
macOS 릴리스를 실제로 서명하고 패키징해 보지 않고는 테스트하기 어렵지만, macOS 릴리스에서는 Hardened Runtime용 JIT Entitlement를 활성화해야 하는 것으로 보입니다.
이로 인해 Python을 설치하는 일이 더 어려워지지는 않겠지만, 릴리스 관리자가 수행해야 할 단계가 추가될 수 있습니다.
교육 방법
자신에게 가장 잘 맞는 섹션을 선택하십시오:
- Python 프로그래머 또는 최종 사용자라면…
- …달라지는 것이 없습니다. JIT가 아직 실험적 기능인 동안에는 누구도 JIT가 활성화된 CPython 인터프리터를 여러분에게 배포해서는 안 됩니다. 실험 단계를 벗어나면 성능이 약간 향상되고 메모리 사용량이 약간 증가한 것을 알아차리게 될 것입니다. 그 밖의 다른 변화는 관찰할 수 없어야 합니다.
- 서드파티 패키지를 유지 관리한다면…
- …달라지는 것이 없습니다. API나 ABI 변경은 없으며, JIT는 서드파티 코드에 노출되지 않습니다. CI 매트릭스를 변경할 필요가 없어야 하며, JIT가 활성화되었을 때 패키지의 동작 방식에서 차이를 관찰할 수 없어야 합니다.
- Python 코드를 프로파일링하거나 디버깅한다면…
- …달라지는 것이 없습니다. 모든 Python 프로파일링 및 추적 기능은 그대로 유지됩니다.
- C 코드를 프로파일링하거나 디버깅한다면…
- …현재 JIT 프레임을 관통하여 추적하는 기능은 제한적입니다. 이는 “리프” 프레임만이 아니라 전체 C 호출 스택을 관찰해야 하는 경우 문제가 될 수 있습니다. 자세한 내용은 위의 Debugging 섹션을 참조하십시오.
- 자신만의 Python 인터프리터를 컴파일한다면….
- …JIT를 빌드하고 싶지 않다면 그냥 무시하면 됩니다. 그렇지 않다면 호환되는 버전의 LLVM을 설치하고 빌드 스크립트에 적절한 플래그를 전달해야 합니다. 빌드 시간이 최대 1분 정도 더 걸릴 수 있습니다. JIT가 아직 실험 단계에 있는 동안에는 최종 사용자에게 배포하거나 프로덕션 환경에서 사용해서는 안 된다는 점에 유의하십시오.
- CPython(또는 CPython 포크)의 유지 관리자라면…
- …그리고 바이트코드 정의나 메인 인터프리터 루프를 변경한다면…
- …일반적으로 JIT가 (하려는 작업에 따라 다르겠지만) 여러분에게 큰 불편을 주지는 않을 것입니다. 마이크로 연산 인터프리터는 사라지지 않으며, 현재 주 바이트코드 인터프리터가 제공하는 것과 비슷한 디버깅 경험을 계속 제공합니다. 인터프리터에 대한 더 큰 변경(새 지역 변수 추가, 오류 처리 및 역최적화 로직 변경, 마이크로 연산 형식 변경 등)이 JIT를 생성하는 데 사용되는 C 템플릿의 변경을 요구할 가능성은 중간 정도입니다. 이 템플릿은 주 인터프리터 루프를 모방하도록 만들어져 있습니다. 또한 가끔은 그저 운이 나빠서 JIT 코드 생성을 망가뜨릴 수도 있으며, 이 경우 Python 빌드 스크립트를 직접 수정하거나 빌드 스크립트에 더 익숙한 사람에게 도움을 요청해야 합니다(아래 참조).
- …그리고 JIT 자체를 작업한다면…
- …자신이 무엇에 뛰어드는 것인지 이미 어느 정도 알고 있기를 바랍니다. 여러분은 Python 빌드 스크립트, JIT를 생성하는 데 사용되는 C 템플릿, 그리고 JIT의 런타임 부분을 실제로 구성하는 C 코드를 정기적으로 수정하게 될 것입니다. 또한 온갖 종류의 크래시를 다루고, 디버거에서 기계어 코드를 한 단계씩 실행하고, COFF/ELF/Mach-O 덤프를 들여다보고, 다양한 플랫폼에서 개발하며, 바이트코드를 변경하는 사람들의 PR에서 CI가 실패하기 시작할 때 대체로 그들의 연락 창구 역할을 하게 될 것입니다(위 참조). 이상적으로는 어셈블리에 적어도 익숙하고, 이름에 “컴파일러”가 들어간 강좌를 한두 개 수강했으며, 링커에 관한 블로그 글을 한두 편 읽어 보았어야 합니다.
- …그리고 CPython의 다른 부분을 유지 관리한다면…
- …여러분에게 달라지는 것은 없습니다. 로컬에서 JIT 빌드로 개발할 필요는 없을 것입니다. 그렇게 하기로 선택한다면(예를 들어 JIT 이슈를 재현하고 분류하는 것을 돕기 위해), 관련 파일이 수정될 때마다 빌드 시간이 최대 1분 더 걸릴 수 있습니다.
- …그리고 바이트코드 정의나 메인 인터프리터 루프를 변경한다면…
참조 구현
구현의 주요 부분은 다음과 같습니다:
Tools/jit/README.md: JIT를 빌드하는 방법에 대한 안내입니다.Python/jit.c: JIT 컴파일러의 런타임 부분 전체입니다.jit_stencils.h: JIT가 생성한 템플릿의 예시입니다.Tools/jit/template.c: JIT의 템플릿을 만들기 위해 컴파일되는 코드입니다.Tools/jit/_targets.py: 빌드 시점에 템플릿을 컴파일하고 파싱하는 코드입니다.
기각된 아이디어
CPython 외부에서 유지 관리하기
JIT를 CPython 외부에서 유지 관리하는 것이 아마도 가능하기는 하겠지만, 그 구현이 인터프리터의 나머지 부분과 상당히 긴밀하게 얽혀 있어서 최신 상태로 유지하는 일이 JIT 자체를 실제로 개발하는 것보다 더 어려울 것입니다. 게다가 기존 마이크로 연산 정의와 최적화를 작업하는 기여자들은 자신의 변경 사항이 JIT에서 어떤 효과를 내는지 측정하기 위해 별개의 두 프로젝트를 수정하고 빌드해야 할 것입니다(반면 현재는 제안된 모든 변경에 대해 이를 자동으로 수행하는 인프라가 존재합니다).
별도의 “JIT” 프로젝트의 릴리스 역시 정확히 어떤 변경 사항이 포함되어 있는지에 따라 특정 CPython 사전 릴리스 및 패치 릴리스에 대응해야 할 것입니다. 릴리스 사이의 개별 CPython 커밋에는 대응하는 JIT 릴리스가 아예 없을 가능성이 높으며, 이는 디버깅 작업(업스트림에서 문제를 일으킨 변경을 찾기 위한 이분 탐색 등)을 더욱 복잡하게 만듭니다.
JIT는 이미 상당히 안정적이며 궁극적인 목표는 JIT가 CPython의 실험적이지 않은 일부가 되는 것이므로, main에 유지하는 것이 최선의 방향으로 보입니다. 그렇기는 하지만, 관련 코드는 JIT가 결국 목표를 달성하지 못할 경우 쉽게 “삭제”할 수 있도록 구성되어 있습니다.
기본으로 활성화하기
한편, 일부에서는 JIT를 현재 형태 그대로 기본으로 활성화해야 한다고 제안했습니다.
다시 말하지만, JIT는 마법처럼 “빠르게 만들어 주는” 기계가 아니라는 점을 기억하는 것이 중요합니다. 현재 JIT는 기존의 특수화 인터프리터와 비슷한 속도입니다. 이는 기대에 못 미치는 것처럼 들릴 수 있지만 실제로는 상당히 중요한 성과이며, 이 접근 방식이 추가 개발을 위해 main에 병합될 만큼 실현 가능하다고 여겨진 주된 이유입니다.
JIT는 기존 마이크로 연산 인터프리터에 비해 상당한 성능 향상을 제공하지만, 항상 활성화했을 때 확실한 이득이라고 보기는 아직 어렵습니다(늘어난 메모리 사용량과 추가적인 빌드 타임 의존성을 고려하면 특히 그렇습니다). 이것이 바로 이 PEP의 목적입니다. 즉, “스위치를 켜기” 위해 충족해야 하는 객관적인 기준에 대한 기대치를 명확히 하는 것입니다.
적어도 현재로서는 이 기능을 main에 포함하되 기본적으로 비활성화해 두는 것이, 항상 켜 두는 것과 아예 사용할 수 없게 하는 것 사이의 좋은 절충안으로 보입니다.
여러 컴파일러 툴체인 지원
Clang이 특별히 필요한 이유는 보장된 꼬리 호출(musttail)을 지원하는 유일한 C 컴파일러이기 때문이며, 보장된 꼬리 호출은 CPython의 연속 전달 스타일 JIT 컴파일 방식에 필수적입니다. 이것이 없으면 템플릿 간의 꼬리 재귀 호출로 인해 C 스택이 무한히 커질 수 있습니다(그리고 결국 오버플로가 발생합니다).
LLVM은 JIT 빌드 과정에 필요한 다른 기능(즉, 객체 파일 파싱과 디스어셈블을 위한 유틸리티)도 포함하고 있고, 추가 툴체인은 추가적인 테스트와 유지보수 부담을 초래하므로, 현재로서는 하나의 툴체인의 메이저 버전 하나만 지원하는 것이 편리합니다.
기본 인터프리터의 바이트코드 컴파일
복사 후 패치에 관한 기존 사례 대부분은 이를 빠른 기본(baseline) JIT로 사용하는 반면, CPython의 JIT는 이 기법을 최적화된 마이크로 연산(micro-op) 트레이스를 컴파일하는 데 사용합니다.
실제로 새 JIT는 현재 다른 동적 언어 런타임의 “베이스라인” 컴파일러 계층과 “최적화” 컴파일러 계층 사이 어딘가에 위치합니다. 이는 CPython이 특수화 적응형 인터프리터를 사용해 런타임 프로파일링 정보를 수집하고, 이 정보를 사용해 코드 내의 “핫” 경로를 감지하고 최적화하기 때문입니다. 이 단계는 자기 수정 코드를 사용해 수행되는데, 이 기법은 JIT 컴파일러로 구현하기가 훨씬 더 어렵습니다.
복사 후 패치를 사용해 일반 바이트코드를 컴파일하는 것도 가능하지만(실제로 초기 프로토타입은 마이크로 연산 인터프리터보다 먼저 만들어졌으며 바로 그렇게 동작했습니다), 더 세분화된 마이크로 연산 형식만큼 충분한 최적화 잠재력을 제공하지는 못하는 것으로 보입니다.
GPU 지원 추가
JIT는 현재 CPU 전용입니다. 예를 들어, Numba와 같은 JIT처럼 NumPy 배열 연산을 CUDA GPU로 오프로드하지는 않습니다.
이러한 종류의 특수한 작업을 가속하기 위한 도구 생태계는 이미 풍부하며, CPython의 JIT는 이를 대체하려는 것이 아닙니다. 그 대신, 더 깊은 GPU 통합의 이점을 누릴 가능성이 낮은 범용 Python 코드의 성능을 향상시키는 것을 목표로 합니다.
미해결 문제
속도
현재 JIT는 대부분의 플랫폼에서 기존 특수화 인터프리터와 거의 같은 속도입니다. 상당한 성능 향상을 제공하는 것이 애초에 JIT를 두는 목적의 전부이므로, 현시점에서 이를 개선하는 것은 당연히 최우선 과제입니다. 여러 개선 제안이 이미 진행 중이며, 이 진행 중인 작업은 GH-115802에서 추적하고 있습니다.
메모리
JIT는 실행 가능한 기계어 코드를 위해 추가 메모리를 할당하므로, 런타임에 기존 인터프리터보다 실제로 더 많은 메모리를 사용합니다. 공식 벤치마크에 따르면, 현재 JIT는 기본 인터프리터보다 약 10-20% 더 많은 메모리를 사용합니다. 이 범위의 상단은 aarch64-apple-darwin 때문인데, 이 플랫폼은 페이지 크기가 더 크기 때문입니다(따라서 최소 할당 단위도 더 큽니다).
그러나 벤치마크 자체의 기본 메모리 사용량이 실제로 그다지 높지 않기 때문에, 이 수치는 걸러서 받아들여야 합니다. 벤치마크는 데이터에 대한 코드의 비율이 더 높기 때문에, 메모리 압박이 실제 문제가 될 가능성이 더 큰 일반적인 워크로드에서보다 JIT의 메모리 오버헤드가 더 두드러지게 나타납니다.
JIT의 메모리 사용량을 최적화하는 데에는 아직 많은 노력을 기울이지 않았으므로, 이 수치는 시간이 지나면서 줄어들 최댓값일 가능성이 높습니다. 이를 개선하는 것은 중간 수준의 우선순위이며, GH-116017에서 추적하고 있습니다. 향후 메모리 사용량을 제한하기 위한 설정 가능한 매개변수를 노출하는 것을 고려할 수도 있지만, JIT가 실험적 기능이 아닌 것으로 간주될 수 있는 요건을 충족하기 전까지는 공식 API를 노출하지 않을 것입니다.
이전 버전의 JIT는 더 복잡한 메모리 할당 방식을 사용했습니다. 이 방식은 생성되는 코드의 크기와 배치에 여러 가지 취약한 제약을 가했으며, Python 실행 파일의 메모리 사용량을 크게 늘렸습니다. 현재 설계에는 이러한 문제가 더 이상 존재하지 않습니다.
의존성
이 글을 쓰는 시점에 JIT는 LLVM에 대한 빌드 시점 의존성을 가지고 있습니다. LLVM은 개별 마이크로 연산 명령어를 기계어 코드 조각으로 컴파일하는 데 사용되며, 이 조각들이 서로 링크되어 JIT의 템플릿을 구성합니다. 이 템플릿은 CPython 자체를 빌드하는 데 사용됩니다. JIT는 런타임에는 LLVM에 의존하지 않으므로 최종 사용자에게는 의존성으로 전혀 노출되지 않습니다.
JIT를 빌드하면 플랫폼에 따라 빌드 과정에 3초에서 60초가 추가됩니다. JIT는 생성된 파일이 최신 상태가 아닐 때만 다시 빌드되므로, 메인 인터프리터 루프를 활발히 개발하는 사람들만 JIT를 자주 다시 빌드하게 됩니다.
CPython의 다른 많은 생성 파일과 달리 JIT의 생성 파일은 Git으로 추적되지 않습니다. 이는 이 파일들에 호스트 플랫폼뿐만 아니라 해당 플랫폼의 현재 빌드 구성에도 특화된, 컴파일된 바이너리 코드 템플릿이 들어 있기 때문입니다. 따라서 이 파일들을 호스팅하려면 생성 코드를 변경하는 커밋마다 수십 개의 대용량 바이너리 파일을 빌드하고 호스팅해야 하므로 상당한 엔지니어링 노력이 필요합니다. 실현이 가능할 수는 있지만, CPython을 빌드하는 대부분의 사람에게 필요한 도구를 설치하는 일이 지나치게 어렵지 않고 빌드 단계에 시간이 특별히 많이 걸리지도 않으므로 이는 우선순위가 아닙니다.
이 가능성에 여전히 관심을 두는 사람들이 있으므로, 관련 논의는 GH-115869에서 추적하고 있습니다.
각주
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.