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

Python 개선 제안 한국어 번역

PEP 523 – CPython에 프레임 평가 API 추가

Author:
Brett Cannon <brett at python.org>, Dino Viehland <dinoviehland at gmail.com>
Status:
Final
Type:
Standards Track
Created:
16-May-2016
Python-Version:
3.6
Post-History:
16-May-2016
Resolution:
Python-Dev message

Table of Contents

번역·라이선스 안내

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

초록

이 PEP는 프레임 평가 [5]를 처리하는 인터프리터별 함수 포인터를 지정할 수 있도록 CPython의 C API [2]를 확장할 것을 제안합니다. 이 제안은 프레임 평가 함수가 사용할 임의의 데이터를 저장하기 위해 코드 객체 [3]에 새 필드를 추가할 것도 제안합니다.

근거

Python에서 유연성이 부족했던 영역 중 하나는 Python 코드의 직접 실행입니다. CPython의 C API [2]는 프레임 객체에 들어갈 데이터를 구성한 다음 PyEval_EvalFrameEx() [5]를 통해 이를 평가할 수 있게 하지만, Python 코드 실행에 대한 제어는 프레임 수준에서 전체적으로 제어하는 대신 개별 객체에 국한됩니다.

프레임 평가에 영향을 주는 것이 다소 지나치게 저수준인 것처럼 보일 수 있지만, 이를 통해 CPython 자체가 JIT를 제공하지 않아도 메서드 수준 JIT와 같은 기능을 CPython에 도입할 가능성이 열립니다. 외부 C 코드가 프레임 평가를 제어할 수 있도록 하면, JIT는 평가가 수행되는 핵심 지점에서 Python 코드 실행에 참여할 수 있습니다. 그러면 JIT는 필요에 따라 Python 바이트코드를 조건부로 기계 코드로 재컴파일하면서도, JIT 실행을 원하지 않을 때는 일반적인 CPython 바이트코드를 실행할 수 있습니다. 이는 인터프리터가 프레임을 평가하기 위해 호출할 함수를 지정할 수 있도록 함으로써 구현할 수 있습니다. 또한 API를 프레임 평가 수준에 배치하면 JIT가 코드의 실행 환경을 완전히 파악할 수 있습니다.

프레임 평가 함수를 지정하는 이 기능은 CPython을 JIT에 개방하는 것 외에도 다른 사용 사례를 가능하게 합니다. 예를 들어 이 API를 사용하면 호출 수준에서 추적 또는 프로파일링 함수를 어렵지 않게 구현할 수 있습니다. CPython은 Python 수준에서 추적 또는 프로파일링 함수를 설정하는 기능을 제공하지만, 이를 사용하면 프로파일러의 데이터 수집 기능에 부합할 수 있으며 줄별 추적 지원을 단순히 건너뜀으로써 추적 성능이 더 빠를 가능성도 있습니다.

또한 프레임 평가 함수가 특정 코드 객체를 곧 실행하려 한다는 것을 감지했을 때만 특별한 디버깅 작업을 수행하는 방식으로 디버깅할 가능성도 열립니다. 이 경우 디버깅을 지원하기 위해 적절한 지점에 중단점 함수 호출을 삽입하도록 바이트코드를 이론적으로 제자리에서 다시 작성할 수 있으므로, sys.settrace() 에서 요구하는 것과 같은 강제적인 접근 방식을 사용할 필요가 없습니다.

이러한 사용 사례를 지원하기 위해 새 필드를 통해 코드 객체에 “스크래치 공간”을 추가할 것도 제안합니다. 이를 통해 코드 객체별 데이터를 코드 객체 자체에 저장하여 필요할 때 프레임 평가 함수가 쉽게 검색할 수 있습니다. 필드 자체는 단순히 PyObject *형식이므로, 필드에 저장된 모든 데이터가 일반적인 객체 메모리 관리에 참여합니다.

제안

아래에서 제안하는 모든 C API 변경 사항은 안정 ABI의 일부가 되지 않습니다.

PyCodeObject 확장

PyCodeObject 구조체에 필드 하나를 추가합니다 [3]:

typedef struct {
   ...
   void *co_extra;  /* "Scratch space" for the code object. */
} PyCodeObject;

co_extra는 기본적으로 NULL이며 필요할 때만 채워집니다. 필드에 저장되는 값은 코드 객체가 작동하는 데 필요하지 않을 것으로 예상되므로, 해당 필드의 데이터가 손실되어도 허용될 수 있습니다.

이 필드와 연동하기 위한 비공개 API가 도입되었습니다.:

PyAPI_FUNC(Py_ssize_t) _PyEval_RequestCodeExtraIndex(freefunc);
PyAPI_FUNC(int) _PyCode_GetExtra(PyObject *code, Py_ssize_t index,
                                 void **extra);
PyAPI_FUNC(int) _PyCode_SetExtra(PyObject *code, Py_ssize_t index,
                                 void *extra);

이 필드의 사용자는 _PyEval_RequestCodeExtraIndex()를 호출하여 co-extra에 데이터를 추가하기 위한 불투명한 인덱스 값으로 간주해야 하는 값을 받아야 합니다. 사용자는 해당 인덱스를 사용하여 _PyCode_SetExtra()로 데이터를 설정하고, 나중에 _PyCode_GetExtra()로 데이터를 검색할 수 있습니다. 이 API가 Python 릴리스 간에 의미론적 보장을 제공하지 않는다는 사실을 알리기 위해 의도적으로 비공개 API로 명시했습니다.

리스트와 튜플을 사용하는 방안도 고려되었지만 성능이 더 낮은 것으로 확인되었으며, 핵심 사용 사례가 JIT 사용인 만큼 Python 객체 대신 사용자 정의 구조체를 사용하는 쪽으로 성능상의 고려가 기울었습니다.

딕셔너리도 고려되었지만, 다시 한번 성능이 더 중요했습니다. 딕셔너리는 데이터를 조회할 때 일정한 오버헤드가 발생하지만, 데이터 구조에 단일 객체가 저장되는 일반적인 경우의 오버헤드 때문에 튜플의 성능 특성이 더 우수합니다(즉, 길이가 1인 튜플을 순회하는 것이 딕셔너리에서 객체를 해싱하고 조회하는 오버헤드보다 빠릅니다).

PyInterpreterState 확장

프레임 평가 함수의 진입점은 인터프리터별로 존재합니다.:

// Same type signature as PyEval_EvalFrameEx().
typedef PyObject* (*_PyFrameEvalFunction)(PyFrameObject*, int);

typedef struct {
    ...
    _PyFrameEvalFunction eval_frame;
} PyInterpreterState;

기본적으로 eval_frame 필드는 현재 PyEval_EvalFrameEx()를 나타내는 함수 포인터로 초기화됩니다(이 함수는 _PyEval_EvalFrameDefault()라고 하며, 이 PEP의 뒷부분에서 논의합니다). 그런 다음 서드파티 코드는 Python 코드의 실행을 제어하기 위해 자체 프레임 평가 함수를 대신 설정할 수 있습니다. 포인터 비교를 사용하여 필드가 _PyEval_EvalFrameDefault()로 설정되었는지, 따라서 아직 변경되지 않았는지를 감지할 수 있습니다.

Python/ceval.c 변경 사항

현재 존재하는 PyEval_EvalFrameEx() [5]_PyEval_EvalFrameDefault()로 이름이 변경됩니다. 그러면 새로운 PyEval_EvalFrameEx()는 다음과 같이 됩니다.:

PyObject *
PyEval_EvalFrameEx(PyFrameObject *frame, int throwflag)
{
    PyThreadState *tstate = PyThreadState_GET();
    return tstate->interp->eval_frame(frame, throwflag);
}

이를 통해 서드파티 코드는 기존 C API를 이미 사용하고 있는 코드와 하위 호환성을 유지하면서 Python 코드 실행 경로에 직접 참여할 수 있습니다.

python-gdb.py 업데이트

GDB에서 Python 지원에 사용되는 생성된 python-gdb.py 파일은 PyEval_EvalFrameEx()에 대해 일부 하드코딩된 가정을 합니다. 예를 들어 지역 변수의 이름을 가정합니다. 제안된 변경 사항과 함께 작동하도록 업데이트해야 합니다.

성능 영향

이 PEP는 플러그 가능성을 추가하는 API를 제안하므로, 성능 영향은 서드파티 코드가 어떠한 변경도 하지 않은 경우에만 고려합니다.

pybench [14]를 여러 차례 실행한 결과, API 변경만으로 인한 성능 비용은 일관되게 나타나지 않았습니다.

Python 벤치마크 모음 [9]을 한 차례 실행한 결과, 측정 가능한 성능 비용은 나타나지 않았습니다.

메모리 영향 측면에서는 일반적으로 하나의 프로세스에서 실행되는 CPython 인터프리터가 많지 않으므로, PyCodeObjectco_extra가 추가되는 영향만 우려됩니다. [8]에 따르면 Python 테스트 모음을 한 차례 실행할 때 약 72,395개의 코드 객체가 생성됩니다. 64비트 CPU에서 모든 코드 객체가 한꺼번에 살아 있고 co_extra 필드에 아무것도 설정되어 있지 않다면, 그 결과 579,160바이트의 추가 메모리가 사용됩니다.

사용 예

CPython용 JIT

Pyjion

Pyjion 프로젝트 [1]는 CoreCLR의 JIT [4]를 사용하여 CPython용 JIT를 구현하는 데 이 제안된 API를 사용했습니다. 각 코드 객체의 co_extra 필드에는 네 가지 정보를 저장하는 PyjionJittedCode 객체가 설정됩니다.

  1. 실행 횟수
  2. 이전 JIT 시도가 실패했는지를 나타내는 부울값
  3. 트램펄린에 대한 함수 포인터(타입 추적을 수행할 수도 있고 수행하지 않을 수도 있음)
  4. JIT로 컴파일된 모든 기계 코드에 대한 void 포인터

프레임 평가 함수는 (대략적으로) 다음 알고리즘을 가집니다.:

def eval_frame(frame, throw_flag):
    pyjion_code = frame.code.co_extra
    if not pyjion_code:
        frame.code.co_extra = PyjionJittedCode()
    elif not pyjion_code.jit_failed:
        if not pyjion_code.jit_code:
            return pyjion_code.eval(pyjion_code.jit_code, frame)
        elif pyjion_code.exec_count > 20_000:
            if jit_compile(frame):
                return pyjion_code.eval(pyjion_code.jit_code, frame)
            else:
                pyjion_code.jit_failed = True
    pyjion_code.exec_count += 1
    return _PyEval_EvalFrameDefault(frame, throw_flag)

핵심은 이 모든 작업과 로직이 CPython과는 별개이지만, 제안된 API 변경 사항을 사용하면 Python 의미론을 준수하는 JIT를 제공할 수 있다는 점입니다(이 글을 작성하는 시점에서 성능은 새로운 API가 없는 CPython과 거의 동등합니다). 이는 제안된 API를 활용하여 다른 사람들이 CPython용 자체 JIT를 구현하는 것을 기술적으로 막는 요소가 전혀 없다는 의미입니다.

기타 JIT

Pyston [10] 팀이 보다 JIT에 특화된 이 PEP의 이전 버전에 대해 자문을 받았다는 점을 언급할 필요가 있습니다. 그러나 이들은 메모리 레이아웃을 제어하기를 원했고 CPython 자체를 직접 지원하는 데에는 관심이 없었기 때문에 제안된 변경 사항을 활용하는 데 관심이 없었습니다. PyPy [11] 팀의 한 개발자와 나눈 비공식적인 논의에서도 비슷한 의견이 나왔습니다.

반면 Numba [6] 는 자신들의 경우 1.0 이후의 미래에 제안된 변경 사항을 활용하는 데 관심이 있을 것이라고 밝혔습니다 [7].

실험적인 Coconut JIT [13] 는 이 PEP의 혜택을 받을 수 있었을 것입니다. Coconut의 제작자와 비공개로 나눈 대화에서, CPython에 JIT 지원을 추가하기 위해 Coconut용으로 개발한 API보다 우리의 API가 아마도 더 우수하다는 이야기를 들었습니다.

디버깅

Python Tools for Visual Studio 팀(PTVS) [12] 과 나눈 대화에서, 이들은 더 높은 성능의 디버깅을 구현하는 데 이러한 API 변경 사항이 유용할 것이라고 생각했습니다. Rationale 절에서 언급했듯이, 이 API를 사용하면 디버깅 기능이 필요한 프레임에서만 디버깅 기능을 활성화할 수 있습니다. 이를 통해 sys.settrace()이 일반적으로 제공하는 정보를 건너뛸 수 있을 뿐만 아니라, 실행 전에 바이트코드를 동적으로 다시 작성하여 바이트코드에 중단점 등을 삽입할 수도 있습니다.

또한 Google이 내부적으로 매우 유사한 API를 제공한다는 사실도 밝혀졌습니다. 이 API는 고성능 디버깅을 목적으로 사용되었습니다.

구현

제안된 API를 구현하는 패치 모음은 Pyjion 프로젝트 [1] 를 통해 사용할 수 있습니다. 현재 형태에서는 제안된 API 외에도 CPython에 더 많은 변경 사항이 포함되어 있지만, 이는 목표 달성을 위한 엄격한 요구 사항 때문이 아니라 개발을 용이하게 하기 위한 것입니다.

미해결 문제

eval_frameNULL로 허용

현재는 프레임 평가 함수가 항상 설정되어 있어야 합니다. 대신 이 값을 아주 간단히 NULL로 기본 설정할 수 있으며, 이는 _PyEval_EvalFrameDefault()를 사용하라는 신호가 됩니다. 필드를 특수 처리하지 않는 현재 제안이 가장 간단해 보였지만, 이 경우 필드가 실수로 지워지지 않도록 해야 하며, 그렇지 않으면 충돌이 발생할 수 있습니다.

거부된 아이디어

JIT에 특화된 C API

원래 이 PEP에서는 훨씬 더 규모가 크고 JIT에 특화된 API 변경을 제안할 예정이었습니다. 그러나 Numba 팀 [6] 에 피드백을 요청한 후, 해당 API가 불필요하게 크다는 점이 분명해졌습니다. 실제로 필요한 것은 JIT로 컴파일된 Python 코드의 실행을 처리할 트램펄린 함수를 제공할 기회와, 해당 컴파일된 기계어 코드를 다른 핵심 데이터와 함께 대응하는 Python 코드 객체에 연결할 방법뿐이라는 사실을 깨달았습니다. 필요한 API 변경을 최소화하면서도 기능이나 성능의 손실이 없다는 점이 입증되자, 제안은 현재 형태로 변경되었습니다.

co_extra가 필요합니까?

PyCon US 2016에서 이 PEP를 논의하는 동안 일부 핵심 개발자들은 co_extra필드가 코드 객체를 변경 가능하게 만들 수 있다는 우려를 표명했습니다. 코드 객체가 생성된 후 변경되는 필드를 두면 다른 코드 객체의 어떤 측면도 변경되지 않더라도 해당 객체가 변경 가능한 것처럼 보인다는 것이 이들의 생각인 듯했습니다.

이 PEP의 관점은 co_extra 필드가 코드 객체가 불변이라는 사실을 바꾸지 않는다는 것입니다. 이 필드는 코드 객체를 사용할 수 있도록 하는 데 필요한 정보를 포함하지 않도록 이 PEP에서 지정되어 있으므로, 캐시 필드에 더 가깝습니다. 문자열 객체가 내부적으로 갖는 UTF-8 캐시와 유사하다고 볼 수 있습니다. 문자열에는 조건부로 설정되는 필드가 있더라도 여전히 불변으로 간주됩니다.

또한 JIT 작업에서 이 필드를 사용할 수 없는 경우에 대한 성능 측정도 수행했습니다. C++의 비순서 맵이나 Python의 딕셔너리를 사용하여 코드 객체를 JIT별 데이터 객체와 연결할 때 이 필드가 없으면 성능 저하 비용이 너무 크다고 판단했습니다.

참조