표준 인터프리터 최적화

소개

PyPy 표준 인터프리터가 지닌 장점 중 하나(CPython과 비교했을 때) — 사실상 이 프로젝트의 동기가 된 목표 중 하나이기도 합니다 — 는 유연성과 설정 가능성이 향상되었다는 점입니다.

이에 대한 한 가지 예로, 애플리케이션 레벨 코드에 어떠한 차이도 노출하지 않으면서 동일한 객체(예: 리스트)에 대해 여러 구현을 제공할 수 있다는 점을 들 수 있습니다. 이를 통해 일반적인 경우의 구현에 지장을 주지 않으면서 특정 상황에 최적화된 타입의 특수한 구현을 손쉽게 제공할 수 있습니다.

이 문서는 이러한 최적화 몇 가지를 설명합니다. 이 중 대부분은 기본적으로 활성화되어 있지 않습니다. 또한, 이러한 최적화 중 다수는 실제 애플리케이션에서 그만한 가치가 있는지 명확하지 않습니다(일부 마이크로벤치마크를 훨씬 빠르게 만들고 메모리를 덜 사용하는 것은 확실하지만, 이는 그다지 큰 의미가 없습니다). 그 방향으로 관찰하신 바가 있다면 저희에게 알려 주세요! 그런데, 대안적인 객체 구현은 PyPy 개발에 뛰어들기 좋은 방법인데, 이를 위해서는 PyPy의 아주 작은 부분만 알면 되기 때문입니다. 그리고 재미있기도 합니다!

객체 최적화

정수 최적화

작은 정수 캐싱

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

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

태그된 포인터로서의 정수

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

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

딕셔너리 최적화

Dict 전략(Strategies)

Dict strategy(전략)는 딕셔너리(및 리스트)를 위한 구현 접근 방식으로, 딕셔너리 데이터의 특화된 표현을 사용할 수 있게 하면서도, 나중에 필요해지면 일반적인 표현으로 다시 전환할 수 있도록해줍니다.

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

동일성 딕셔너리

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

맵 딕셔너리(Map Dict)

맵 딕셔너리(Map dictionary)는 딕트 전략(dict strategy)과 함께 사용되는 특수한 표현입니다. 이 딕트 전략은 인스턴스 딕셔너리에만 사용되며, 인스턴스 딕셔너리가 메모리를 덜 사용하도록 시도합니다(사실 보통 메모리 동작 방식은 __slots__를 사용하는 것과 대체로 비슷해야 합니다).

아이디어는 다음과 같습니다: 같은 클래스의 대부분의 인스턴스는 매우 비슷한 속성을 가지고 있으며, __init__()가 실행되는 동안 같은 순서로 이 키들을 딕셔너리에 추가합니다. 이는 이 인스턴스들의 모든 딕셔너리가 매우 비슷해 보인다는 것을 의미합니다: 인스턴스마다 값은 다르지만 동일한 키 집합을 가지고 있습니다. 공유 딕셔너리(sharing dicts)가 하는 일은 이러한 공통 키들을 공통 구조 객체에 저장하여 개별 인스턴스 딕셔너리의 공간을 절약하는 것입니다: 인스턴스 딕셔너리의 표현에는 값 목록만 포함됩니다.

리스트 최적화

범위 리스트(Range-Lists)

레인지 리스트는 내장 함수 xrange가 제대로 해결하지 못하는 문제, 즉 결과 리스트가 순회에만 사용되더라도 range가 메모리를 할당하는 문제를 해결합니다. 레인지 리스트는 리스트에 대한 또 다른 구현입니다. 레인지 리스트는 range호출의 결과로만 생성됩니다. 결과 리스트가 변경 없이 사용되는 한, 리스트는 시작(start), 끝(stop), 스텝(step)만 저장합니다. 리스트가 변경될 때만 실제 리스트가 생성됩니다. 이는 xrange의 메모리 및 속도 특성과 range의 범용성을 함께 제공하며, xrange를 사실상 무용지물로 만듭니다.

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

사용자 클래스최적화

메서드 캐싱

메서드 캐시(method cache)가 도입되어 메서드 조회(method lookup)의 결과를 저장합니다(이는 클래스의 베이스 클래스들에서 잠재적으로 많은 조회를 수반할 수 있습니다). 메서드 캐시의 항목들은 조회 중인 이름, 호출 지점(call site, 즉 바이트코드 객체와 현재 프로그램 카운터), 그리고 조회가 발생하는 타입의 특별한 “버전”으로부터 계산된 해시를 사용하여 저장됩니다(이 버전은 타입이나 그 베이스 클래스 중 하나가 변경될 때마다 증가합니다). 이후의 조회에서는, 인스턴스가 자신의 클래스 속성들 중 어느 것도 가리지(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

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

LOAD_GLOBAL     obj
LOOKUP_METHOD   meth
LOAD_GLOBAL     x
LOAD_GLOBAL     y
CALL_METHOD     2

LOOKUP_METHODLOAD_ATTR과 정확히 동일한 속성 조회 로직을 담고 있어서 - 따라서 의미론을 완전히 보존하지만 - 하나 대신 두 개의 값을 스택에 푸시합니다. 이 두 값은 바인딩된 메서드 객체의 “인라인된” 버전, 즉 im_funcim_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 옵션으로 변환 최적화 수준을 명시적으로 낮추지 않는 한 일반적으로 유용한 모든 최적화가 기본적으로 켜집니다.