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는 CPython의 C API [2]를 확장하여, 프레임 평가 [5]를 처리하는 인터프리터별 함수 포인터의 명세를 허용할 것을 제안합니다. 또한 이 제안은 프레임 평가 함수가 사용할 임의의 데이터를 저장할 수 있도록 코드 객체 [3]에 새 필드를 추가할 것도 제안합니다.

근거

Python에서 유연성이 부족했던 부분 중 하나는 Python 코드의 직접 실행입니다. CPython의 C API [2]를 사용하면 프레임 객체에 들어갈 데이터를 구성한 뒤 PyEval_EvalFrameEx() [5]로 평가할 수 있습니다. 하지만 파이썬 코드 실행은 프레임 수준에서 총체적으로 제어되지 않고 개별 객체 단위로만 제어됩니다.

프레임 평가에 영향을 미치려는 것이 다소 지나치게 저수준으로 보일 수도 있습니다. 하지만 이렇게 하면 CPython이 직접 JIT를 제공하지 않고도 메서드 수준 JIT 같은 것을 CPython에 도입할 수 있습니다. 외부 C 코드가 프레임 평가를 제어할 수 있게 하면 JIT는 평가가 일어나는 핵심 지점에서 Python 코드의 실행에 참여할 수 있습니다. 그러면 JIT는 원할 때 파이썬 바이트코드를 조건부로 기계어로 재컴파일할 수 있고, JIT를 실행하고 싶지 않을 때는 일반 CPython 바이트코드를 그대로 실행할 수 있습니다. 이는 인터프리터가 프레임을 평가할 때 호출할 함수를 지정할 수 있게 하여 구현할 수 있습니다. 또한 API를 프레임 평가 수준에 두면 JIT가 코드의 실행 환경 전체를 볼 수 있습니다.

프레임 평가 함수를 지정하는 이 기능은 CPython을 JIT에 개방하는 것 외에 다른 사용 사례도 가능하게 합니다. 예를 들어, 이 API를 사용하면 호출 수준의 추적 함수나 프로파일링 함수를 어렵지 않게 구현할 수 있습니다. CPython도 파이썬 수준에서 추적 함수나 프로파일링 함수를 설정하는 기능을 제공합니다. 하지만 이 방식은 프로파일러와 같은 수준으로 데이터를 수집할 수 있고, 줄 단위 추적 지원을 건너뛰기만 해도 추적이 더 빨라질 가능성이 큽니다.

또한 프레임 평가 함수가 특정 코드 객체를 실행하기 직전임을 감지했을 때에만 특별한 디버깅 작업을 수행하는 방식의 디버깅도 가능해집니다. 이 경우 이론적으로는 바이트코드를 제자리에서 다시 작성하여 적절한 지점에 중단점 함수 호출을 삽입할 수 있습니다. 이렇게 하면 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 객체 대신 사용자 정의 구조체를 사용하기로 했습니다.

딕셔너리도 고려했지만, 이번에도 성능이 더 중요했습니다. dict는 데이터를 조회할 때 상수 오버헤드를 가집니다. 그러나 자료 구조에 객체 하나만 저장되는 일반적인 경우의 오버헤드를 따지면 튜플의 성능 특성이 더 좋습니다(즉, 길이가 1인 튜플을 순회하는 것이 dict에서 객체를 해싱하고 조회하는 오버헤드보다 빠릅니다).

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 인터프리터가 많지 않으므로 PyCodeObject에 co_extra가 추가되는 영향만이 유일한 우려 사항입니다. [8]에 따르면 Python 테스트 스위트를 한 번 실행하면 약 72,395개의 코드 객체가 생성됩니다. 64비트 CPU에서 모든 코드 객체가 동시에 살아 있고 co_extra 필드에 아무것도 설정되어 있지 않다면, 579,160바이트의 추가 메모리가 사용됩니다.

사용 예

CPython용 JIT

Pyjion

Pyjion 프로젝트 [1]는 이 제안된 API를 사용하여 CoreCLR의 JIT [4]로 CPython용 JIT를 구현했습니다. 각 코드 객체의 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

참고로 JIT에 더 특화되어 있던 이 PEP의 이전 버전에 대해 Pyston [10] 팀의 의견을 구했으나, 그들은 메모리 레이아웃을 직접 제어하기를 원했고 CPython 자체를 직접 지원하는 데에는 관심이 없었기 때문에 제안된 변경 사항을 활용하는 데 관심을 보이지 않았습니다. PyPy [11] 팀의 한 개발자와 비공식적으로 논의했을 때도 비슷한 의견이 나왔습니다.

반면 Numba [6] 측은 1.0 이후에는 제안된 변경 사항에 관심을 가질 수 있을 것이라는 뜻을 밝혔습니다 [7].

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

디버깅

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

또한 Google도 내부적으로 매우 유사한 API를 제공하고 있는 것으로 밝혀졌습니다. 이 API는 성능이 좋은 디버깅을 위해 사용되어 왔습니다.

구현

제안된 API를 구현하는 패치 모음은 Pyjion 프로젝트 [1]를 통해 이용할 수 있습니다. 현재 형태에서는 이 제안된 API 외에도 CPython에 더 많은 변경을 가하고 있지만, 이는 목표를 달성하는 데 반드시 필요해서가 아니라 개발 편의를 위한 것입니다.

미해결 문제

eval_frame이 NULL이 되도록 허용하기

현재 프레임 평가 함수는 항상 설정되어 있을 것으로 기대됩니다. 대신 아주 간단히 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 전용 데이터 객체와 연결하면, 필드가 없어져서 생기는 성능 비용이 너무 큰 것으로 판단되었습니다.

참고 문헌