표준 인터프리터 최적화

소개

(CPython과 비교했을 때) PyPy 표준 인터프리터의 장점 중 하나이자, 실은 PyPy를 만든 동기가 된 목표 중 하나는 유연성과 설정 가능성이 향상되었다는 점입니다.

이에 대한 한 가지 예로, 애플리케이션 수준 코드에 아무런 차이도 드러내지 않으면서 같은 객체(예: 리스트)에 대해 여러 구현을 제공할 수 있다는 점을 들 수 있습니다. 따라서 일반적인 경우의 구현에 지장을 주지 않으면서 특정 상황에 최적화된 타입의 특수 구현을 쉽게 제공할 수 있습니다.

이 문서에서는 이러한 최적화 몇 가지를 설명합니다. 이 중 대부분은 기본적으로 활성화되어 있지 않습니다. 또한 이러한 최적화 중 상당수는 실제 애플리케이션에서 그만한 가치가 있는지 분명하지 않습니다(일부 마이크로벤치마크를 훨씬 빠르게 만들고 메모리를 덜 사용하게 하는 것은 확실하지만, 그것만으로는 별 의미가 없습니다). 이와 관련하여 관찰하신 내용이 있다면 저희에게 알려 주십시오! 덧붙여 말씀드리면, 대안 객체 구현은 PyPy의 비교적 작은 부분만 알아도 작업할 수 있으므로 PyPy 개발에 입문하기에 아주 좋은 방법입니다. 게다가 재미도 있습니다!

객체 최적화

정수 최적화

작은 정수 캐싱

CPython과 마찬가지로, 간단한 산술 연산을 할 때 매번 할당하지 않아도 되도록 작은 정수 객체의 캐싱을 활성화할 수 있습니다. 새 정수 객체가 생성될 때마다 해당 정수가 캐시에서 가져올 수 있을 만큼 작은지 확인합니다.

이 옵션은 기본적으로 비활성화되어 있으며, –objspace-std-withprebuiltint 옵션으로 이 기능을 활성화할 수 있습니다.

태그된 포인터로 표현한 정수

정수를 사용할 때 메모리를 한층 더 적극적으로 절약하는 방법은 “small int” 정수 구현입니다. 이는 31비트(64비트 머신에서는 63비트)만 필요한 정수에 사용되는 또 다른 정수 구현입니다. 이러한 정수는 최하위 비트를 설정하여 일반 포인터와 구별되도록 한 태그 포인터(tagged pointer)로 표현됩니다. 이렇게 하면 박싱(boxing) 단계를 완전히 피할 수 있어 시간과 메모리가 절약됩니다.

이 기능은 –objspace-std-withsmalllong 옵션으로 활성화할 수 있습니다.

딕셔너리 최적화

딕셔너리 전략

딕셔너리 전략(dict strategies)은 딕셔너리(및 리스트)를 구현하는 한 가지 방식입니다. 이 방식을 사용하면 딕셔너리 데이터를 특수화된 표현으로 나타낼 수 있으면서도, 나중에 필요해지면 다시 일반적인 표현으로 전환할 수 있습니다.

딕셔너리 전략은 항상 활성화되어 있으며, 기본적으로 문자열 키만 있는 딕셔너리, 유니코드 키만 있는 딕셔너리, 정수 키만 있는 딕셔너리를 위한 특수 전략이 있습니다. 이러한 특수 전략 중 하나가 사용되면 딕셔너리 조회 시 딕셔너리 키에 대해 훨씬 빠른 해싱과 비교를 사용할 수 있습니다. 물론 일반적인 키를 위한 전략도 있습니다.

동일성 딕셔너리(Identity Dicts)

또한 “동일성(identity)으로” 비교하는 클래스의 인스턴스인 키에 특화된 전략도 있습니다. __hash__, __eq__ 또는 __cmp__를 재정의하지 않는 한 이 비교 방식이 기본입니다. 이 전략은 새 스타일 클래스(new-style class)에서만 사용됩니다.

맵 딕셔너리(Map Dict)

맵 딕셔너리(map dictionary)는 딕셔너리 전략(dict strategy)과 함께 사용되는 특수한 표현 방식입니다. 이 딕셔너리 전략은 인스턴스 딕셔너리에만 사용되며, 인스턴스 딕셔너리가 메모리를 덜 사용하도록 합니다(실제로 메모리 동작은 보통 __slots__를 사용할 때와 거의 같습니다).

기본 아이디어는 다음과 같습니다. 같은 클래스의 인스턴스는 대부분 속성이 매우 비슷하며, __init__()이 실행되는 동안 이 키들을 같은 순서로 딕셔너리에 추가하기까지 합니다. 즉, 이 인스턴스들의 딕셔너리는 모두 매우 비슷한 모습을 띱니다. 키 집합은 같고 값만 인스턴스마다 다릅니다. 공유 딕셔너리(sharing dict)는 이러한 공통 키들을 공통 구조 객체에 저장하여 개별 인스턴스 딕셔너리의 공간을 절약합니다. 인스턴스 딕셔너리의 표현에는 값 목록만 담깁니다.

리스트 최적화

범위 리스트(Range-Lists)

범위 리스트(range-list)는 내장 함수 xrange가 제대로 해결하지 못하는 문제와 같은 문제를 해결합니다. 바로 결과 리스트를 순회하는 데에만 사용하더라도 range가 메모리를 할당한다는 문제입니다. 범위 리스트는 리스트의 또 다른 구현입니다. 범위 리스트는 range 호출의 결과로만 생성됩니다. 결과 리스트가 변경되지 않고 사용되는 한, 리스트는 범위의 시작(start), 끝(stop), 간격(step)만 저장합니다. 누군가 리스트를 변경할 때에만 비로소 실제 리스트가 생성됩니다. 이를 통해 xrange의 메모리 및 속도 특성과 range의 범용성을 모두 얻을 수 있으며, xrange는 사실상 쓸모가 없어집니다.

이 기능은 –objspace-std-withliststrategies 옵션의 일부로 기본적으로 활성화되어 있습니다.

사용자 클래스 최적화

메서드 캐싱

메서드 조회 결과를 저장하는 메서드 캐시가 도입되었습니다(메서드 조회에는 클래스의 기반 클래스들에 대한 조회가 여러 번 수반될 수 있습니다). 메서드 캐시의 항목은 조회하는 이름, 호출 지점(즉, 바이트코드 객체와 현재 프로그램 카운터), 그리고 조회가 일어나는 타입의 특별한 “버전”으로부터 계산한 해시를 사용하여 저장됩니다(이 버전은 해당 타입이나 그 기반 클래스 중 하나가 변경될 때마다 증가합니다). 인스턴스가 자신의 클래스 속성을 하나도 가리지(shadow) 않는 한, 이후의 조회에서는 캐시된 버전을 사용할 수 있습니다.

이 기능은 기본적으로 활성화되어 있습니다.

인터프리터 최적화

특수 바이트코드

LOOKUP_METHOD & CALL_METHOD

Python식 객체 지향 프로그래밍의 독특한 특징 중 하나는 “바인딩된 메서드(bound method)”라는 개념입니다. 이 개념은 깔끔하고 강력하지만, 해당 객체를 할당하고 초기화하는 데에는 성능 비용이 따릅니다. 저희는 이 비용을 줄여 주는 한 쌍의 바이트코드를 구현했습니다.

메서드 호출 obj.meth(x, y)에 대한 표준 바이트코드는 다음과 같습니다:

LOAD_GLOBAL     obj      # push 'obj' on the stack
LOAD_ATTR       meth     # read the 'meth' attribute out of 'obj'
LOAD_GLOBAL     x        # push 'x' on the stack
LOAD_GLOBAL     y        # push 'y' on the stack
CALL_FUNCTION   2        # call the 'obj.meth' object with arguments x, y

우리는 다른 몇몇 접근 방식과 달리 메서드 조회를 메서드 호출과 분리된 상태로 유지하되, 임시 객체를 만드는 대신 값 스택을 캐시로 사용하는 방식으로 이를 개선했습니다. 우리는 obj.meth(x, y)에 대해 다음 코드를 (선택적으로) 생성하도록 바이트코드 컴파일러를 확장했습니다.:

LOAD_GLOBAL     obj
LOOKUP_METHOD   meth
LOAD_GLOBAL     x
LOAD_GLOBAL     y
CALL_METHOD     2

LOOKUP_METHOD는 LOAD_ATTR와 정확히 같은 속성 조회 로직을 포함하므로 의미론을 완전히 보존하지만, 스택에 값을 하나가 아니라 두 개 푸시합니다. 이 두 값은 바인딩된 메서드 객체를 “인라인”한 형태인 im_func와 im_self로, 각각 기반이 되는 Python 함수 객체와 obj에 대한 참조입니다. 이는 속성이 실제로 클래스의 함수 객체를 가리킬 때에만 가능합니다. 그렇지 않은 경우에도 LOOKUP_METHOD는 여전히 두 값을 푸시하지만, 하나 (im_func)는 LOAD_ATTR가 반환했을 일반적인 결과일 뿐이고, 다른 하나 (im_self)는 인터프리터 수준의 None 자리 표시자입니다.

인자를 푸시한 후 위 예제의 스택 레이아웃은 다음과 같습니다(스택은 위쪽으로 자랍니다):

y (두 번째 인자)
x (첫 번째 인자)
obj (im_self)
function object (im_func)

CALL_METHOD N 바이트코드는 N개의 인자 아래에 있는 스택의 im_self 항목을 검사하여 바인딩된 메서드 호출을 에뮬레이션합니다: 이 항목이 None이 아니면, 스택에서 가져온 im_func 객체를 호출할 때 추가적인 첫 번째 인자로 간주됩니다.

전반적인 효과

이러한 다양한 최적화가 성능에 미치는 영향은 당연하게도 실행되는 프로그램에 따라 달라집니다. 문자열 키 딕셔너리만 특별히 처리하는 기본 multi-dict 구현을 사용하는 것은 모든 벤치마크에서 확실한 이득이며, 결과를 15~40퍼센트가량 향상시킵니다.

한결같이 좋은 효과를 내는 또 다른 최적화, 정확히 말하면 최적화 묶음은 두 가지 ‘메서드 최적화’, 즉 메서드 캐시와 LOOKUP_METHOD 및 CALL_METHOD 옵코드입니다. 객체 지향적 성격이 강한 벤치마크(richards)에서는 이 둘이 함께 작용하여 50%에 가까운 속도 향상을 보이며, 객체 지향과는 거리가 매우 먼 pystone 벤치마크에서도 20%가 넘는 개선을 보입니다.

PyPy를 빌드할 때 --opt 옵션으로 변환 최적화 수준을 명시적으로 낮추지 않는 한, 일반적으로 유용한 모든 최적화가 기본적으로 활성화됩니다.