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

Python 개선 제안 한국어 번역

PEP 590 – Vectorcall: CPython을 위한 빠른 호출 프로토콜

Author:
Mark Shannon <mark at hotpy.org>, Jeroen Demeyer <J.Demeyer at UGent.be>
BDFL-Delegate:
Petr Viktorin <encukou at gmail.com>
Status:
Final
Type:
Standards Track
Created:
29-Mar-2019
Python-Version:
3.8
Post-History:


Table of Contents

번역·라이선스 안내

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

Important

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

×

See PEP 1 for how to propose changes.

초록

이 PEP는 객체 호출을 최적화하기 위한 새로운 C API를 도입합니다. 새로운 “vectorcall” 프로토콜과 호출 규약을 도입합니다. 이는 CPython이 이미 내부적으로 사용하고 있는 “fastcall” 규약에 기반합니다. 새로운 기능은 모든 사용자 정의 확장 클래스에서 사용할 수 있습니다.

새 API의 대부분은 CPython 3.8에서 비공개입니다. Python 3.9에서 의미를 확정하고 공개할 계획입니다.

참고: 이 PEP는 Python/C API만을 다루며, Python 언어나 표준 라이브러리에는 영향을 주지 않습니다.

동기

호출 규약의 선택은 호출하는 쪽과 호출되는 쪽 양쪽 코드의 성능과 유연성에 영향을 줍니다. 성능과 유연성은 서로 상충하는 경우가 많습니다.

현재의 tp_call [2] 호출 규약은 모든 경우를 다룰 수 있을 만큼 충분히 유연하지만, 성능이 좋지 않습니다. 성능이 좋지 않은 주된 이유는 호출 중에 중간 튜플과, 경우에 따라서는 중간 딕셔너리까지 생성해야 하기 때문입니다. CPython에서는 Python 함수와 내장 함수의 호출 속도를 높이기 위한 특수 사례 코드를 포함하여 이 문제를 완화합니다. 안타깝게도 이는 클래스나 서드파티 확장 객체와 같은 다른 호출 가능 객체는 더 느리고 더 일반적인 tp_call 호출 규약을 사용하여 호출된다는 뜻입니다.

이 PEP는 Python 함수와 내장 함수에 내부적으로 사용되는 호출 규약을 일반화하고 공개하여 모든 호출이 더 나은 성능의 혜택을 누릴 수 있도록 할 것을 제안합니다. 새로 제안하는 호출 규약은 완전히 일반적이지는 않지만, 대다수의 호출을 다룹니다. 이 규약은 임시 객체 생성과 여러 단계의 간접 참조로 인한 오버헤드를 없애도록 설계되었습니다.

tp_call 규약의 또 다른 비효율의 원인은 함수 포인터가 객체마다가 아니라 클래스마다 하나씩 있다는 점입니다. 이 때문에 클래스를 호출할 때는 여러 중간 객체를 생성해야 하므로 비효율적입니다. cls 클래스의 경우, type.__call__, cls.__new__, cls.__init__로 이어지는 호출 순서에서 각 호출마다 적어도 하나의 중간 객체가 생성됩니다.

이 PEP는 확장 모듈에서 사용할 인터페이스를 제안합니다. 이러한 인터페이스는 사용자가 개발 과정에 참여하지 않고서는 효과적으로 테스트하거나 설계할 수 없습니다. 이런 이유로 비공개(밑줄 접두사가 붙은) 이름을 제공합니다. 이 API는 (사용자 피드백에 따라) Python 3.9에서 변경될 수 있으며, 그때 확정되고 밑줄이 제거될 것으로 예상합니다.

명세

함수 포인터 타입

호출은 다음 매개변수를 받는 함수 포인터를 통해 이루어집니다:

  • PyObject *callable: 호출되는 객체
  • PyObject *const *args: 인자 벡터
  • size_t nargs: 인자 개수에 선택적 플래그 PY_VECTORCALL_ARGUMENTS_OFFSET을 더한 값 (아래 참조)
  • PyObject *kwnames: NULL이거나 키워드 인자의 이름을 담은 튜플입니다.

이는 다음 함수 포인터 타입으로 구현됩니다: typedef PyObject *(*vectorcallfunc)(PyObject *callable, PyObject *const *args, size_t nargs, PyObject *kwnames);

PyTypeObject 구조체의 변경 사항

사용되지 않는 슬롯 printfunc tp_print는 tp_vectorcall_offset으로 대체됩니다. 이 슬롯의 타입은 Py_ssize_t입니다. 새로운 tp_flags 플래그인 _Py_TPFLAGS_HAVE_VECTORCALL이 추가되며, vectorcall 프로토콜을 사용하는 모든 클래스는 이 플래그를 설정해야 합니다.

_Py_TPFLAGS_HAVE_VECTORCALL이 설정되어 있으면 tp_vectorcall_offset은 양의 정수여야 합니다. 이 값은 vectorcallfunc 타입인 vectorcall 함수 포인터의 객체 내 오프셋입니다. 이 포인터는 NULL일 수 있으며, 이 경우의 동작은 _Py_TPFLAGS_HAVE_VECTORCALL이 설정되지 않은 경우와 같습니다.

tp_print 슬롯은 외부 프로젝트가 vectorcall 프로토콜을 이전 Python 버전으로 더 쉽게 백포트할 수 있도록 tp_vectorcall_offset 슬롯으로 재사용됩니다. 특히 Cython 프로젝트가 이 작업에 관심을 보였습니다(https://mail.python.org/pipermail/python-dev/2018-June/153927.html 참고).

디스크립터 동작

추가 타입 플래그 하나를 규정합니다: Py_TPFLAGS_METHOD_DESCRIPTOR.

호출 가능 객체가 디스크립터 프로토콜을 사용하여 바운드 메서드와 유사한 객체를 생성하는 경우 Py_TPFLAGS_METHOD_DESCRIPTOR를 설정해야 합니다. 인터프리터는 메서드를 호출할 때 임시 객체 생성을 피하기 위해 이 플래그를 사용합니다(_PyObject_GetMethod와 LOAD_METHOD/CALL_METHOD 옵코드를 참고하십시오).

구체적으로, type(func)에 Py_TPFLAGS_METHOD_DESCRIPTOR가 설정되어 있으면 다음이 성립해야 합니다:

  • (obj가 None이 아닐 때) func.__get__(obj, cls)(*args, **kwds)는 func(obj, *args, **kwds)와 동등해야 합니다.
  • func.__get__(None, cls)(*args, **kwds)는 func(*args, **kwds)와 동등해야 합니다.

객체 func.__get__(obj, cls)에는 아무런 제약이 없습니다. 이 객체는 vectorcall 프로토콜을 구현하지 않아도 됩니다.

호출

호출은 ((vectorcallfunc)(((char *)o)+offset))(o, args, n, kwnames) 형태를 취하며, 여기서 offset은 Py_TYPE(o)->tp_vectorcall_offset입니다. kwnames 튜플을 생성하고 그 안에 중복이 없도록 보장하는 것은 호출자의 책임입니다.

n은 위치 인자의 개수이며, 경우에 따라 PY_VECTORCALL_ARGUMENTS_OFFSET 플래그가 더해질 수 있습니다.

PY_VECTORCALL_ARGUMENTS_OFFSET

피호출자가 args[-1]을 일시적으로 변경해도 되는 경우에는 n에 PY_VECTORCALL_ARGUMENTS_OFFSET 플래그를 더해야 합니다. 다시 말해, args가 할당된 벡터에서 인자 1을 가리키는 경우에 이 플래그를 사용할 수 있습니다. 피호출자는 반환하기 전에 args[-1]의 값을 복원해야 합니다.

호출자는 (할당 없이) 적은 비용으로 할 수 있을 때마다 PY_VECTORCALL_ARGUMENTS_OFFSET을 사용하는 것이 좋습니다. 그렇게 하면 바운드 메서드와 같은 호출 가능 객체가 이어지는 호출을 적은 비용으로 수행할 수 있습니다. 바이트코드 인터프리터는 이미 스택에 호출 가능 객체를 위한 공간을 할당하므로, 추가 비용 없이 이 기법을 사용할 수 있습니다.

피호출자가 할당을 피하려고 PY_VECTORCALL_ARGUMENTS_OFFSET을 사용하는 예는 [3]을 참고하십시오.

매개변수 n에서 실제 인자 수를 얻으려면 PyVectorcall_NARGS(n) 매크로를 사용해야 합니다. 이렇게 하면 향후 변경이나 확장이 가능합니다.

새로운 C API와 CPython 변경 사항

C API에 다음 함수 또는 매크로가 추가됩니다:

  • PyObject *_PyObject_Vectorcall(PyObject *obj, PyObject *const *args, size_t nargs, PyObject *keywords): 주어진 인자로 obj를 호출합니다. nargs에 PY_VECTORCALL_ARGUMENTS_OFFSET 플래그가 포함될 수 있다는 점에 유의하십시오. 실제 위치 인자 수는 PyVectorcall_NARGS(nargs)로 구합니다. keywords 인자는 키워드 이름의 튜플이거나 NULL입니다. 빈 튜플은 NULL을 전달하는 것과 같은 효과를 냅니다. 내부적으로 vectorcall 프로토콜이나 tp_call을 사용하며, 둘 다 지원되지 않으면 예외가 발생합니다.
  • PyObject *PyVectorcall_Call(PyObject *obj, PyObject *tuple, PyObject *dict): (vectorcall을 지원해야 하는) 객체를 예전의 *args 및 **kwargs 호출 규약으로 호출합니다. 이 함수는 주로 tp_call 슬롯에 넣기 위한 것입니다.
  • Py_ssize_t PyVectorcall_NARGS(size_t nargs): vectorcall nargs 인자가 주어지면 실제 인자 수를 반환합니다. 현재는 nargs & ~PY_VECTORCALL_ARGUMENTS_OFFSET과 동등합니다.

서브클래싱

확장 타입은 tp_call을 베이스 클래스와 같은 방식으로 구현하는 경우, 베이스 클래스로부터 타입 플래그 _Py_TPFLAGS_HAVE_VECTORCALL과 값 tp_vectorcall_offset을 상속합니다. 또한 tp_descr_get이 베이스 클래스와 같은 방식으로 구현되어 있으면 Py_TPFLAGS_METHOD_DESCRIPTOR 플래그도 상속됩니다.

힙 타입은 vectorcall 프로토콜을 절대 상속하지 않습니다. 그렇게 하는 것은 안전하지 않기 때문입니다(힙 타입은 동적으로 변경될 수 있습니다). 이 제약은 향후 해제될 수 있지만, 그러려면 type.__setattribute__에서 __call__을 특별히 처리해야 합니다.

API 확정

_PyObject_Vectorcall 및 _Py_TPFLAGS_HAVE_VECTORCALL 이름의 밑줄은 이 API가 Python 마이너 버전에서 변경될 수 있음을 나타냅니다. 확정되면(Python 3.9로 계획되어 있습니다) 이들은 PyObject_Vectorcall과 Py_TPFLAGS_HAVE_VECTORCALL로 이름이 바뀝니다. 밑줄로 시작하는 기존 이름은 별칭으로 계속 사용할 수 있습니다.

새 API는 평소와 같이 문서화되지만, 위 사항에 대한 경고가 함께 표시됩니다.

이 PEP에서 도입하는 다른 이름들(PyVectorcall_NARGS, PyVectorcall_Call, Py_TPFLAGS_METHOD_DESCRIPTOR, PY_VECTORCALL_ARGUMENTS_OFFSET)의 의미는 확정되었습니다.

CPython 내부 변경 사항

기존 클래스의 변경 사항

function, builtin_function_or_method, method_descriptor, method, wrapper_descriptor, method-wrapper 클래스는 vectorcall 프로토콜을 사용하게 됩니다(초기 구현에서 이 모두가 변경되지는 않습니다).

(PyMethodDef 자료 구조를 사용하는) builtin_function_or_method와 method_descriptor의 경우, 기존의 모든 호출 규약마다 전용 vectorcall 래퍼를 구현할 수 있습니다. 그렇게 할 만한 가치가 있는지는 더 두고 보아야 합니다.

클래스에 vectorcall 프로토콜 사용하기

클래스 cls에서 cls(xxx)를 사용하여 새 인스턴스를 생성하려면 여러 번의 호출이 필요합니다. type.__call__, cls.__new__, cls.__init__로 이어지는 순서의 각 호출마다 최소 하나의 중간 객체가 생성됩니다. 따라서 클래스를 호출할 때 vectorcall을 사용하는 것은 매우 합리적입니다. 이는 실질적으로 type에 vectorcall 프로토콜을 구현한다는 의미입니다. 가장 자주 사용되는 클래스 중 일부가 이 프로토콜을 사용하게 될 것이며, 아마도 range, list, str, type이 그 대상일 것입니다.

PyMethodDef 프로토콜과 Argument Clinic(인자 Clinic)

Argument Clinic(인자 Clinic) [4]은 하위 수준의 호출 가능 객체를 감싸는 래퍼 함수를 자동으로 생성하며, 기본 타입의 안전한 언박싱과 그 밖의 안전성 검사를 제공합니다. Argument Clinic(인자 Clinic)을 확장하여 새로운 vectorcall 프로토콜을 따르는 래퍼 객체를 생성하도록 할 수 있습니다. 이렇게 하면 단 한 번의 간접 참조만으로 실행 흐름이 호출자에서 Argument Clinic(인자 Clinic)이 생성한 래퍼로, 그리고 거기서 직접 작성한 코드로 이어질 수 있습니다.

vectorcall을 사용하는 서드파티 확장 클래스

Python 함수 및 내장 함수와 대등한 호출 성능을 얻으려면, 서드파티 호출 가능 객체는 vectorcallfunc 함수 포인터를 포함하고, tp_vectorcall_offset을 올바른 값으로 설정하고, _Py_TPFLAGS_HAVE_VECTORCALL 플래그를 추가해야 합니다. 이렇게 하는 클래스는 모두 tp_call 함수를 구현해야 하며, 그 동작이 vectorcallfunc 함수와 일관되도록 해야 합니다. tp_call을 PyVectorcall_Call로 설정하는 것으로 충분합니다.

이러한 변경 사항이 성능에 미치는 영향

이 PEP는 기존 코드의 성능에 큰 영향을 미치지 않을 것입니다(긍정적인 의미로도, 부정적인 의미로도 마찬가지입니다). 이 PEP의 주된 목적은 효율적인 새 코드를 작성할 수 있도록 하는 것이지, 기존 코드를 더 빠르게 만드는 것이 아닙니다.

그럼에도 불구하고 이 PEP는 METH_FASTCALL 함수에 맞춰 최적화합니다. METH_VARARGS를 사용하는 함수의 성능은 약간 나빠질 것입니다.

안정 ABI

이 PEP의 어떤 내용도 안정 ABI(PEP 384)에 추가되지 않습니다.

대안 제안

bpo-29259

PEP 590은 bpo-29259 [1]에서 제안된 내용과 거의 같습니다. 주요 차이점은 이 PEP가 함수 포인터를 클래스가 아닌 인스턴스에 저장한다는 것입니다. 이는 모든 인스턴스가 서로 다른 C 함수에 대응하는, C로 함수를 구현하는 경우에 더 합리적입니다. 또한 type.__call__의 최적화도 가능하게 하는데, 이는 bpo-29259로는 불가능합니다.

PEP 576과 PEP 580

PEP 576과 PEP 580은 모두 서드파티 객체가 표현력과 성능을 모두 갖출 수 있도록(CPython 객체와 대등한 수준으로) 설계되었습니다. 이 PEP의 목적은 CPython 생태계에서 객체를 호출하는 통일된 방법을 제공하되, 그 방법이 표현력을 갖추면서도 가능한 한 높은 성능을 내도록 하는 것입니다.

이 PEP는 PEP 576보다 범위가 넓으며, 고정 오프셋 함수 포인터가 아닌 가변 오프셋 함수 포인터를 사용합니다. 기반이 되는 호출 규약은 비슷합니다. PEP 576은 함수 포인터에 고정 오프셋만 허용하기 때문에, 레이아웃에 제약이 있는 객체에는 이러한 개선을 적용할 수 없습니다.

PEP 580은 내장 함수를 정의하는 데 사용되는 PyMethodDef 프로토콜을 크게 변경할 것을 제안합니다. 이 PEP는 새로운 호출 규약의 형태로 더 일반적이고 더 단순한 메커니즘을 제공합니다. 이 PEP도 PyMethodDef 프로토콜을 확장하지만, 이는 단지 기존 관례를 공식화하기 위한 것입니다.

거부된 그 밖의 접근 방식

벡터 인자와 선택적인 튜플 및 딕셔너리 인자를 모두 결합한, 인자 6개짜리의 더 긴 형식도 검토되었습니다. 그러나 이 형식과 기존 tp_call 형식 사이를 변환하는 코드가 지나치게 번거롭고 비효율적이라는 점이 밝혀졌습니다. 또한 x64 Windows에서는 인자 4개만 레지스터로 전달되므로, 추가되는 인자 2개에는 무시할 수 없는 비용이 발생합니다.

모든 특수한 경우를 없애고 모든 호출이 tp_call 형식을 사용하도록 하는 방안도 검토되었습니다. 그러나 튜플을, 그리고 그보다는 덜하지만 딕셔너리를 생성하고 해제하는 훨씬 더 효율적인 방법을 찾지 못한다면 너무 느릴 것입니다.

감사의 글

CPython 내부에서 원래의 “fastcall” 호출 규약을 개발한 Victor Stinner에게 감사드립니다. 이 PEP는 그의 작업을 성문화하고 확장합니다.

참고 문헌

참조 구현

최소 구현은 https://github.com/markshannon/cpython/tree/vectorcall-minimal 에서 찾을 수 있습니다.