Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 659 – 전문화 적응형 인터프리터

Author:
Mark Shannon <mark at hotpy.org>
Status:
Final
Type:
Informational
Created:
13-Apr-2021
Post-History:
11-May-2021

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

Important

This PEP is a historical document. The up-to-date, canonical documentation can now be found at Specializing Adaptive Interpreter.

×

See PEP 1 for how to propose changes.

초록

동적 언어의 가상 머신이 제대로 성능을 발휘하려면 실행 중인 프로그램의 유형과 값에 맞게 실행할 코드를 전문화해야 합니다. 이러한 전문화는 흔히 “JIT” 컴파일러와 연관되지만, 기계어 코드를 생성하지 않더라도 유용합니다.

전문화 적응형 인터프리터란 현재 처리 중인 유형이나 값에 대해 추측적으로 전문화하고, 해당 유형과 값의 변화에 적응하는 인터프리터입니다.

전문화는 성능을 향상시키며, 적응은 프로그램의 사용 패턴이 변경될 때 인터프리터가 신속하게 변경되도록 하여 잘못된 전문화로 인해 발생하는 추가 작업량을 제한합니다.

이 PEP는 코드를 공격적으로 전문화하되 매우 작은 영역에 대해서만 전문화하고, 잘못된 전문화에 신속하고 낮은 비용으로 대응할 수 있는 전문화 적응형 인터프리터의 사용을 제안합니다.

CPython에 전문화 적응형 인터프리터를 추가하면 성능이 크게 향상됩니다. 의미 있는 수치를 제시하기는 어렵습니다. 벤치마크와 아직 이루어지지 않은 작업에 크게 좌우되기 때문입니다. 광범위한 실험 결과 최대 50%의 속도 향상이 나타났습니다. 속도 향상이 25%에 불과하더라도 이는 여전히 가치 있는 개선입니다.

동기

Python은 느리다는 평가를 널리 받고 있습니다. Python이 C, Fortran 또는 Java와 같은 저수준 언어의 성능에 도달하는 일은 결코 없겠지만, JavaScript용 V8이나 Lua용 luajit과 같은 빠른 스크립팅 언어 구현과 경쟁할 수 있기를 바랍니다. 구체적으로는 PyPy나 다른 대체 가상 머신을 사용할 수 없는 사용자를 포함하여 모든 Python 사용자가 혜택을 받을 수 있도록 CPython으로 이러한 성능 목표를 달성하고자 합니다.

이러한 성능 목표를 달성하려면 아직 갈 길이 멀고 많은 엔지니어링 노력이 필요하지만, 인터프리터의 속도를 높여 목표를 향해 크게 한 걸음 나아갈 수 있습니다. 학술 연구와 실제 구현 모두 빠른 인터프리터가 빠른 가상 머신의 핵심 요소임을 보여 주었습니다.

가상 머신의 일반적인 최적화에는 많은 비용이 들기 때문에 최적화 비용이 정당한지 확신하려면 긴 “워밍업” 시간이 필요합니다. 눈에 띄는 워밍업 시간 없이 신속하게 속도를 향상시키려면 VM은 함수를 몇 번만 실행한 뒤에도 전문화가 정당하다고 추측해야 합니다. 이를 효과적으로 수행하려면 인터프리터가 지속적으로 매우 저렴하게 최적화와 탈최적화를 수행할 수 있어야 합니다.

개별 가상 머신 명령어 단위로 적응적이고 추측적인 전문화를 사용하면 더 빠른 인터프리터를 얻는 동시에 향후 더 정교한 최적화를 위한 프로파일링 정보도 생성할 수 있습니다.

근거

동적 언어용 가상 머신의 속도를 높이는 실용적인 방법은 여러 가지가 있습니다. 그러나 전문화는 그 자체로도 가장 중요하며 다른 최적화를 가능하게 한다는 점에서도 가장 중요합니다. 따라서 CPython의 성능을 향상시키려면 먼저 전문화에 노력을 집중하는 것이 타당합니다.

전문화는 일반적으로 JIT 컴파일러의 맥락에서 수행되지만, 연구에 따르면 인터프리터에서의 전문화는 성능을 크게 향상시킬 수 있으며 단순한 컴파일러보다도 뛰어난 성능을 낼 수 있습니다 [1].

학술 문헌에서는 이를 수행하는 여러 방법이 제안되었지만, 대부분은 단일 바이트코드보다 큰 영역을 최적화하려고 합니다 [1][2]. 단일 명령어보다 큰 영역을 사용하려면 영역 중간에서 탈최적화를 처리하는 코드가 필요합니다. 개별 바이트코드 수준에서 전문화를 수행하면 영역 중간에서는 탈최적화가 발생할 수 없으므로 탈최적화가 간단해집니다.

개별 바이트코드를 추측적으로 전문화하면 가장 국소적이고 구현하기 쉬운 탈최적화 외에는 아무것도 추가하지 않고도 성능을 크게 향상시킬 수 있습니다.

문헌에서 이 PEP에 가장 가까운 접근 방식은 “Inline Caching meets Quickening” [3]입니다. 이 PEP는 인라인 캐싱의 장점을 가지면서도 신속하게 탈최적화할 수 있는 기능을 추가하여, 전문화가 실패하거나 안정적이지 않은 경우에도 성능을 더욱 견고하게 만듭니다.

성능

전문화로 인한 속도 향상은 확인하기 어렵습니다. 많은 전문화가 다른 최적화에 의존하기 때문입니다. 속도 향상은 10% - 60% 범위인 것으로 보입니다.

  • 속도 향상의 대부분은 전문화에서 직접 발생합니다. 가장 크게 기여하는 요소는 속성 조회, 전역 변수 및 호출의 속도 향상입니다.
  • 일부는 작지만 유용한 비중으로, 슈퍼 명령어와 퀵닝으로 활성화되는 기타 최적화 같은 개선된 디스패치에서 비롯됩니다.

구현

개요

전문화의 이점을 얻을 수 있는 모든 명령어는 해당 명령어의 “적응형” 형식으로 대체됩니다. 적응형 명령어는 실행될 때 관찰한 유형과 값에 따라 스스로를 전문화합니다. 이 프로세스를 “퀵닝”이라고 합니다.

코드 객체의 명령어가 충분히 여러 번 실행되면, 해당 연산을 더 빠르게 실행할 것으로 예상되는 새 명령어로 대체하여 해당 명령어를 “전문화”합니다.

퀵닝

퀵닝은 느린 명령어를 더 빠른 변형으로 대체하는 프로세스입니다.

퀵닝된 코드는 불변 바이트코드에 비해 여러 가지 장점이 있습니다.

  • 런타임에 변경할 수 있습니다.
  • 여러 줄에 걸치고 여러 피연산자를 사용하는 슈퍼 명령어를 사용할 수 있습니다.
  • 추적을 위해 원래 바이트코드로 폴백할 수 있으므로 추적을 처리할 필요가 없습니다.

추적을 지원하려면 퀵닝된 명령어 형식이 불변의 사용자 표시 바이트코드 형식과 일치해야 합니다. 즉, 8비트 opcode 뒤에 8비트 operand가 오는 16비트 명령어 형식이어야 합니다.

적응형 명령어

전문화의 이점을 얻을 수 있는 각 명령어는 퀵닝 중에 적응형 버전으로 대체됩니다. 예를 들어 LOAD_ATTR 명령어는 LOAD_ATTR_ADAPTIVE로 대체됩니다.

각 적응형 명령어는 주기적으로 스스로를 전문화하려고 시도합니다.

전문화

CPython 바이트코드에는 고수준 연산을 나타내며 전문화의 이점을 얻을 수 있는 명령어가 많이 포함되어 있습니다. 예로는 CALL, LOAD_ATTR, LOAD_GLOBALBINARY_ADD가 있습니다.

이러한 각 명령어에 대해 전문화된 명령어의 “패밀리”를 도입하면 효과적인 전문화가 가능해집니다. 각각의 새 명령어가 단일 작업에 특화되기 때문입니다. 각 패밀리에는 카운터를 유지하고 해당 카운터가 0에 도달하면 스스로를 전문화하려고 시도하는 “적응형” 명령어가 포함됩니다.

또한 각 패밀리에는 입력이 예상대로 제공되는 경우 일반 연산과 동등한 작업을 훨씬 더 빠르게 수행하는 하나 이상의 전문화된 명령어가 포함됩니다. 각 전문화된 명령어는 입력이 예상대로 제공될 때마다 증가하는 포화형 카운터를 유지합니다. 입력이 예상대로 제공되지 않으면 카운터가 감소하고 일반 연산이 수행됩니다. 카운터가 최솟값에 도달하면 해당 명령어의 opcode를 적응형 버전으로 간단히 대체하여 최적화를 해제합니다.

부가 데이터

대부분의 특수화된 명령어 패밀리에는 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은 실행 횟수가 ~2000에 도달할 때까지 명령어당 2바이트를 사용했으며, 그 시점에 명령어당 1바이트를 추가로 할당하고 캐시(LOAD_GLOBALLOAD_ATTR)가 있으면 명령어당 32바이트를 할당했습니다.

다음 표는 64비트 머신에서 3.10 옵캐시 또는 제안된 적응형 인터프리터를 지원하는 데 필요한 명령어당 추가 바이트 수를 보여 줍니다.

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을 최적화하는 여러 대안적 방법을 암묵적으로 거부합니다. 그러나 저스트 인 타임 컴파일과 같은 일부 아이디어는 거부된 것이 아니라 단지 연기되었을 뿐이라는 점을 강조할 필요가 있습니다.

바이트코드 앞에 데이터 캐시 저장하기

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개까지 지원할 수 있습니다.

인라인 캐시 방식이 더 빠르고 단순하기 때문에 이 방식을 채택하지 않았습니다.

참고 문헌