PEP 659 – 특수화 적응형 인터프리터
- Author:
- Mark Shannon <mark at hotpy.org>
- Status:
- Final
- Type:
- Informational
- Created:
- 13-Apr-2021
- Post-History:
- 11-May-2021
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
동적 언어를 위한 가상 머신이 좋은 성능을 내려면, 실행하는 코드를 실행 중인 프로그램의 타입과 값에 맞게 특수화해야 합니다. 이러한 특수화는 흔히 “JIT” 컴파일러와 연관되지만, 기계어 코드를 생성하지 않아도 이점이 있습니다.
특수화 적응형 인터프리터는 현재 다루고 있는 타입이나 값에 맞춰 투기적으로 특수화하고, 그 타입과 값이 바뀌면 이에 적응하는 인터프리터입니다.
특수화는 성능을 높여 주고, 적응은 프로그램의 사용 패턴이 바뀔 때 인터프리터가 빠르게 바뀔 수 있게 하여 잘못된 특수화로 생기는 추가 작업의 양을 제한합니다.
이 PEP는 코드를 공격적으로 특수화하되 매우 작은 영역에 대해서만 특수화하고, 잘못된 특수화에 빠르고 적은 비용으로 대응할 수 있는 특수화 적응형 인터프리터를 사용할 것을 제안합니다.
CPython에 특수화 적응형 인터프리터를 추가하면 성능이 크게 향상될 것입니다. 벤치마크와 아직 이루어지지 않은 작업에 크게 좌우되므로 의미 있는 수치를 제시하기는 어렵습니다. 광범위한 실험 결과는 최대 50%의 속도 향상을 시사합니다. 속도 향상이 25%에 그치더라도 여전히 가치 있는 개선입니다.
동기
Python은 느리다고 널리 인정받고 있습니다. Python이 C, Fortran, 심지어 Java 같은 저수준 언어의 성능에는 결코 도달하지 못하겠지만, 우리는 Python이 Javascript의 V8이나 lua의 luajit처럼 스크립트 언어의 빠른 구현과 경쟁할 수 있기를 바랍니다. 구체적으로는 PyPy나 다른 대체 가상 머신을 사용할 수 없는 사용자를 포함하여 모든 파이썬 사용자가 혜택을 누릴 수 있도록, CPython으로 이러한 성능 목표를 달성하고자 합니다.
이러한 성능 목표를 달성하기까지는 갈 길이 멀고 많은 엔지니어링 노력이 필요하겠지만, 인터프리터의 속도를 높임으로써 목표를 향해 크게 한 걸음 나아갈 수 있습니다. 학술 연구와 실제 구현 모두 빠른 인터프리터가 빠른 가상 머신의 핵심 요소임을 보여 주었습니다.
가상 머신의 일반적인 최적화는 비용이 크기 때문에, 최적화 비용을 들일 만하다는 확신을 얻으려면 긴 “워밍업” 시간이 필요합니다. 눈에 띄는 워밍업 시간 없이 빠르게 속도를 높이려면, VM은 함수가 몇 번만 실행된 뒤에도 특수화할 가치가 있다고 추측해야 합니다. 이를 효과적으로 하려면 인터프리터가 지속적으로, 매우 적은 비용으로 최적화와 탈최적화를 할 수 있어야 합니다.
개별 가상 머신 명령어 단위로 적응형 투기적 특수화를 사용하면, 더 빠른 인터프리터를 얻을 뿐 아니라 향후 더 정교한 최적화에 쓸 프로파일링 정보도 생성할 수 있습니다.
근거
동적 언어를 위한 가상 머신의 속도를 높이는 실용적인 방법은 많습니다. 하지만 그 자체로도, 다른 최적화를 가능하게 하는 수단으로서도 특수화가 가장 중요합니다. 따라서 CPython의 성능을 개선하려면 먼저 특수화에 노력을 집중하는 것이 합리적입니다.
특수화는 보통 JIT 컴파일러의 맥락에서 이루어지지만, 연구에 따르면 인터프리터에서의 특수화도 성능을 크게 높일 수 있으며 단순한 컴파일러보다 나은 성능을 내기도 합니다 [1].
학술 문헌에서는 이를 위한 여러 방법이 제안되었지만, 대부분은 바이트코드 하나보다 큰 영역을 최적화하려고 합니다 [1] [2]. 명령어 하나보다 큰 영역을 사용하려면 영역 중간에서 일어나는 탈최적화를 처리하는 코드가 필요합니다. 개별 바이트코드 수준에서 특수화하면 영역 중간에서 탈최적화가 일어날 수 없으므로 탈최적화가 간단해집니다.
개별 바이트코드를 투기적으로 특수화하면, 가장 국소적이고 구현하기 쉬운 탈최적화 외에는 아무것도 없이 성능을 크게 향상할 수 있습니다.
문헌에서 이 PEP와 가장 가까운 접근 방식은 “Inline Caching meets Quickening” [3]입니다. 이 PEP는 인라인 캐싱의 장점을 갖추면서도 빠르게 탈최적화하는 기능을 더하여, 특수화가 실패하거나 안정적이지 않은 경우에도 성능이 더 견고하게 유지되도록 합니다.
성능
많은 특수화가 다른 최적화에 의존하기 때문에 특수화로 인한 속도 향상을 정확히 파악하기는 어렵습니다. 속도 향상은 10% - 60% 범위인 것으로 보입니다.
- 속도 향상의 대부분은 특수화에서 직접 비롯됩니다. 가장 큰 기여자는 속성 조회, 전역 변수, 호출의 속도 향상입니다.
- 작지만 유용한 일부는 슈퍼 명령어와 같은 개선된 디스패치 및 퀵닝(quickening)으로 가능해진 기타 최적화에서 비롯됩니다.
구현
개요
특수화의 이점을 얻을 수 있는 모든 명령어는 해당 명령어의 “적응형(adaptive)” 형태로 대체됩니다. 적응형 명령어는 실행될 때 자신이 마주치는 타입과 값에 맞추어 스스로를 특수화합니다. 이 과정을 “퀵닝(quickening)”이라고 합니다.
코드 객체 안의 명령어가 충분히 여러 번 실행되면, 그 명령어는 해당 연산을 더 빠르게 실행할 것으로 기대되는 새 명령어로 대체되어 “특수화”됩니다.
퀵닝
퀵닝은 느린 명령어를 더 빠른 변형으로 대체하는 과정입니다.
퀵닝된 코드는 불변 바이트코드에 비해 여러 가지 장점이 있습니다:
- 런타임에 변경할 수 있습니다.
- 여러 줄에 걸치고 여러 피연산자를 받는 슈퍼 명령어를 사용할 수 있습니다.
- 트레이싱이 필요할 때는 원래 바이트코드로 대체(fallback)할 수 있으므로 트레이싱을 처리할 필요가 없습니다.
트레이싱을 지원할 수 있도록, 퀵닝된 명령어 형식은 사용자에게 보이는 불변 바이트코드 형식, 즉 8비트 옵코드 뒤에 8비트 피연산자가 오는 16비트 명령어 형식과 일치해야 합니다.
적응형 명령어
특수화의 이점을 얻을 수 있는 각 명령어는 퀵닝 과정에서 적응형 버전으로 대체됩니다. 예를 들어 LOAD_ATTR 명령어는 LOAD_ATTR_ADAPTIVE로 대체됩니다.
각 적응형 명령어는 주기적으로 스스로를 특수화하려고 시도합니다.
특수화
CPython 바이트코드에는 고수준 연산을 나타내며 특수화의 이점을 얻을 수 있는 명령어가 많이 있습니다. 예로는 CALL, LOAD_ATTR, LOAD_GLOBAL, BINARY_ADD가 있습니다.
이러한 명령어 각각에 대해 특수화된 명령어의 “패밀리”를 도입하면, 새로운 명령어 각각이 단일 작업에 특수화되므로 효과적인 특수화가 가능합니다. 각 패밀리에는 카운터를 유지하다가 그 카운터가 0에 도달하면 스스로를 특수화하려고 시도하는 “적응형” 명령어가 포함됩니다.
또한 각 패밀리에는 입력이 예상과 같을 경우 일반 연산과 동등한 작업을 훨씬 더 빠르게 수행하는 특수화된 명령어가 하나 이상 포함됩니다. 각 특수화된 명령어는 입력이 예상과 같을 때마다 증가하는 포화 카운터를 유지합니다. 입력이 예상과 다르면 카운터가 감소하고 일반 연산이 수행됩니다. 카운터가 최솟값에 도달하면, 해당 명령어의 옵코드를 적응형 버전으로 대체하는 것만으로 명령어가 역최적화됩니다.
보조 데이터
특수화된 명령어 계열 대부분은 8비트 피연산자에 담을 수 있는 것보다 더 많은 정보를 필요로 합니다. 이를 위해 명령어 바로 뒤에 오는 여러 개의 16비트 항목을 사용하여 이 데이터를 저장합니다. 이는 인라인 캐시의 한 형태로, “인라인 데이터 캐시”라고 합니다. 특수화되지 않은 명령어, 즉 적응형 명령어는 이 캐시의 첫 번째 항목을 카운터로 사용하고 나머지 항목은 그냥 건너뜁니다.
명령어 계열의 예
LOAD_ATTR
LOAD_ATTR 명령어는 스택 최상단에 있는 객체에서 이름으로 지정한 속성을 로드한 다음, 스택 최상단의 객체를 그 속성으로 교체합니다.
이는 명백한 특수화 대상입니다. 속성은 일반 인스턴스, 클래스, 모듈 또는 그 밖의 여러 특수한 경우 중 하나에 속할 수 있습니다.
LOAD_ATTR는 처음에 LOAD_ATTR_ADAPTIVE로 가속화되며, 이 명령어는 자신이 얼마나 자주 실행되는지 추적하다가 충분히 여러 번 실행되면 _Py_Specialize_LoadAttr 내부 함수를 호출하고, 그렇지 않으면 원래의 LOAD_ATTR 명령어로 점프하여 로드를 수행합니다. 최적화할 때는 속성의 종류를 검사하고, 적합한 특수화된 명령어를 찾으면 그 명령어가 LOAD_ATTR_ADAPTIVE를 그 자리에서 대체합니다.
LOAD_ATTR에 대한 특수화에는 다음이 포함될 수 있습니다.
LOAD_ATTR_INSTANCE_VALUE속성이 객체의 값 배열에 저장되어 있고, 재정의하는 디스크립터에 의해 가려지지 않는 일반적인 경우입니다.LOAD_ATTR_MODULE모듈에서 속성을 로드합니다.LOAD_ATTR_SLOT클래스가__slots__를 정의하는 객체에서 속성을 로드합니다.
이 방식으로 다른 최적화를 보완하는 최적화가 어떻게 가능해지는지 주목하십시오. LOAD_ATTR_INSTANCE_VALUE는 많은 객체에 사용되는 “지연 딕셔너리”와 잘 어울려 동작합니다.
LOAD_GLOBAL
LOAD_GLOBAL 명령어는 전역 네임스페이스에서 이름을 조회하고, 전역 네임스페이스에 없으면 내장 네임스페이스에서 조회합니다. 3.9에서 LOAD_GLOBAL의 C 코드에는 캐시를 추가하기 위해 코드 객체 전체를 수정해야 하는지 확인하는 코드, 전역 네임스페이스인지 내장 네임스페이스인지 확인하는 코드, 캐시에서 값을 조회하는 코드, 그리고 대체 코드가 포함되어 있습니다. 이 때문에 코드가 복잡하고 비대해집니다. 또한 최적화되었다고 하는 경우에도 불필요한 연산을 많이 수행합니다.
명령어 계열을 사용하면 각 명령어가 한 가지 관심사만 처리하면 되므로 코드를 더 유지 보수하기 쉽고 더 빠르게 만들 수 있습니다.
특수화에는 다음이 포함됩니다.
LOAD_GLOBAL_ADAPTIVE는 위의LOAD_ATTR_ADAPTIVE와 같이 동작합니다.LOAD_GLOBAL_MODULE은 값이 전역 네임스페이스에 있는 경우에 대해 특수화될 수 있습니다. 네임스페이스의 키가 변경되지 않았는지 확인한 후, 저장된 인덱스에서 값을 로드할 수 있습니다.LOAD_GLOBAL_BUILTIN은 값이 내장 네임스페이스에 있는 경우에 대해 특수화될 수 있습니다. 이 명령어는 전역 네임스페이스에 키가 추가되지 않았는지, 그리고 내장 네임스페이스가 변경되지 않았는지 확인해야 합니다. 전역 네임스페이스의 값이 변경되었는지는 신경 쓰지 않고 키만 신경 쓴다는 점에 유의하십시오.
전체 구현은 [4]를 참고하십시오.
Note
이 PEP는 특수화를 관리하는 메커니즘을 개략적으로 설명하며, 적용할 구체적인 최적화는 명시하지 않습니다. 코드가 더 개발됨에 따라 세부 사항, 심지어 구현 전체가 바뀔 가능성도 있습니다.
호환성
언어, 라이브러리, API에는 변경이 없습니다.
사용자가 새 인터프리터의 존재를 감지할 수 있는 방법은 실행 시간 측정, 디버깅 도구 사용, 메모리 사용량 측정뿐입니다.
비용
메모리 사용량
어떤 종류든 캐싱을 수행하는 방식이라면 당연히 “메모리를 얼마나 더 사용합니까?”라는 우려가 따릅니다. 짧게 답하자면 “그리 많지 않습니다”.
3.10과의 메모리 사용량 비교
CPython 3.10은 명령어당 2바이트를 사용하다가, 실행 횟수가 약 2000에 도달하면 명령어당 1바이트를 추가로 할당하고, 캐시가 있는 명령어(LOAD_GLOBAL, LOAD_ATTR)에는 명령어당 32바이트를 할당했습니다.
다음 표는 64비트 머신에서 3.10 opcache 또는 제안된 적응형 인터프리터를 지원하기 위해 명령어당 추가로 필요한 바이트 수를 보여 줍니다.
| Version | 3.10 cold | 3.10 hot | 3.11 |
| Specialised | 0% | ~15% | ~25% |
| code | 2 | 2 | 2 |
| opcache_map | 0 | 1 | 0 |
| opcache/data | 0 | 4.8 | 4 |
| Total | 2 | 7.8 | 6 |
3.10 cold는 코드가 약 2000의 한계에 도달하기 전의 상태입니다. 3.10 hot은 임곗값에 도달한 뒤의 캐시 사용량을 보여 줍니다.
상대적인 메모리 사용량은 3.10에서 캐시 생성을 유발할 만큼 “뜨거운” 코드가 얼마나 되는지에 따라 달라집니다. 3.10이 사용하는 메모리가 3.11과 같아지는 손익분기점은 약 70%입니다.
또한 실제 바이트코드는 코드 객체의 일부에 불과하다는 점도 주목할 만합니다. 코드 객체에는 이름, 상수, 그리고 상당히 많은 디버깅 정보도 포함됩니다.
요약하면, 많은 함수가 비교적 사용되지 않는 대부분의 애플리케이션에서 3.11은 3.10보다 메모리를 더 소비하지만, 그 차이는 크지 않습니다.
보안 영향
없습니다.
기각된 아이디어
인라인 데이터 캐시를 갖춘 특수화 적응형 인터프리터를 구현함으로써, 우리는 CPython을 최적화하는 다른 여러 방법을 암묵적으로 기각하는 셈입니다. 그러나 JIT(just-in-time) 컴파일과 같은 일부 아이디어는 기각된 것이 아니라 단지 보류되었을 뿐이라는 점을 강조해 둘 필요가 있습니다.
바이트코드 앞에 데이터 캐시 저장하기.
3.11 알파를 위한 이 PEP의 초기 구현에서는 아래에 설명된 것과 같은 다른 캐싱 방식을 사용했습니다:
퀵닝된 명령어는 원래 바이트코드와 같은 형식으로 배열에 저장됩니다(이를 Python 객체에 저장하는 것은 필요하지도 바람직하지도 않습니다). 보조 데이터는 별도의 배열에 저장됩니다.각 명령어는 0개 이상의 데이터 항목을 사용합니다. 한 패밀리에 속한 명령어는 모두 같은 양의 데이터를 할당받아야 하지만, 일부 명령어는 그 전부를 사용하지 않을 수도 있습니다. 특수화할 수 없는 명령어(예:
POP_TOP)에는 항목이 필요하지 않습니다. 실험 결과, 명령어의 25%에서 30%를 유용하게 특수화할 수 있는 것으로 나타났습니다. 패밀리마다 필요한 데이터 양은 다르지만, 대부분은 2개의 항목(64비트 머신에서 16바이트)이 필요합니다.256개보다 많은 명령어를 가진 함수를 지원하기 위해, 명령어의 첫 번째 데이터 항목 오프셋을
(instruction offset)//2 + (quickened operand)로 계산합니다.Python 3.10의 opcache와 비교할 때 이 설계는 다음과 같습니다:
- 더 빠릅니다. 오프셋을 계산하는 데 메모리 읽기가 전혀 필요하지 않습니다. 3.10에서는 서로 의존하는 두 번의 읽기가 필요합니다.
- 명령어 계열마다 데이터 크기가 다를 수 있고 추가 오프셋 배열이 필요하지 않으므로, 메모리를 훨씬 적게 사용합니다. 함수당 약 5000개 명령어까지, 훨씬 더 큰 함수를 지원할 수 있습니다. 3.10에서는 약 1000개까지 지원할 수 있습니다.
인라인 캐시 방식이 더 빠르고 더 단순하기 때문에 이 방식은 기각했습니다.
참고 문헌
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.