PEP 757 – Python 정수를 임포트-익스포트하는 C API
- Author:
- Sergey B Kirpichev <skirpichev at gmail.com>, Victor Stinner <vstinner at python.org>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Type:
- Standards Track
- Created:
- 13-Sep-2024
- Python-Version:
- 3.14
- Post-History:
- 14-Sep-2024
- Resolution:
- 08-Dec-2024
Table of Contents
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
Python 정수, 즉 int 객체를 임포트하고 익스포트하는 새로운 C API를 추가합니다. 특히 PyLongWriter_Create()와 PyLong_Export() 함수를 추가합니다.
근거
gmpy2, SAGE, Python-FLINT 같은 프로젝트는 파이썬 int 객체를 가져오고 내보내기 위해 파이썬 “내부”(PyLongObject 구조체)에 직접 접근하거나, 비효율적인 임시 형식(Python-FLINT의 경우 16진수 문자열)을 사용합니다. Python int 구현은 Python 3.12에서 태그와 “압축 값(compact values)”을 추가하도록 변경되었습니다.
3.13 알파 1 릴리스에서 문서화되지 않은 비공개 _PyLong_New() 함수가 제거되었지만, 이 프로젝트들은 Python 정수를 임포트하는 데 이 함수를 사용하고 있습니다. 이 비공개 함수는 3.13 알파 2에서 복원되었습니다.
구현 세부 사항을 노출하지 않고 Python을 이러한 프로젝트와 인터페이스할 수 있는 효율적인 공개 추상화가 필요합니다. 이를 통해 Python은 이 프로젝트들을 망가뜨리지 않고 내부 구조를 변경할 수 있습니다. 예를 들어, 최근 gmpy2의 구현은 CPython 3.9와 CPython 3.12에 맞추어 변경되었습니다.
명세
레이아웃 API
GMP 방식의 import-export 함수에 필요한 데이터입니다.
-
struct PyLongLayout
- 임의 정밀도 정수의 절댓값을 표현하는 데 사용하는 “자릿수(digit)”(GMP 용어로는 “limb”) 배열의 레이아웃입니다.
PyLong_GetNativeLayout()을 사용하여 Pythonint객체의 네이티브 레이아웃을 얻으십시오. 이 레이아웃은 절댓값이 “충분히 큰” 정수에 대해 내부적으로 사용됩니다.Python에 비슷한 정보를 노출하는
sys.int_info도 참고하십시오.-
uint8_t bits_per_digit
- 자릿수당 비트 수입니다. 예를 들어, 15비트 자릿수는 0~14번 비트에 의미 있는 정보가 담겨 있다는 뜻입니다.
-
uint8_t digit_size
- 바이트 단위의 자릿수 크기입니다. 예를 들어, 15비트 자릿수에는 최소 2바이트가 필요합니다.
-
int8_t digits_order
- 자릿수 순서:
- 최상위 자릿수가 먼저 오면
1 - 최하위 자릿수가 먼저 오면
-1
- 최상위 자릿수가 먼저 오면
-
int8_t digit_endianness
- 자릿수 엔디언:
- 최상위 바이트가 먼저 오면(빅 엔디언)
1 - 최하위 바이트가 먼저 오면(리틀 엔디언)
-1
- 최상위 바이트가 먼저 오면(빅 엔디언)
-
uint8_t bits_per_digit
-
const PyLongLayout *PyLong_GetNativeLayout(void)
- Python
int객체의 네이티브 레이아웃을 얻습니다.PyLongLayout구조체를 참고하십시오.이 함수는 Python 초기화 전이나 Python 종료 처리 후에 호출해서는 안 됩니다. 반환된 레이아웃은 Python이 종료 처리될 때까지 유효합니다. 레이아웃은 모든 Python 서브인터프리터에서 동일하므로 캐시할 수 있습니다.
내보내기 API
-
struct PyLongExport
- Python
int객체의 익스포트입니다.두 가지 경우가 있습니다:
-
int64_t value
- 익스포트된
int객체의 네이티브 정수 값입니다.digits가NULL인 경우에만 유효합니다.
-
uint8_t negative
- 수가 음수이면 1, 그렇지 않으면 0입니다.
digits가NULL이 아닌 경우에만 유효합니다.
-
Py_ssize_t ndigits
digits배열의 자릿수 개수입니다.digits가NULL이 아닌 경우에만 유효합니다.
-
const void *digits
- 부호 없는 자릿수의 읽기 전용 배열입니다.
NULL일 수 있습니다.
-
int64_t value
PyLongExport.digits가 NULL이 아니면, PyLongExport 구조체의 비공개 필드가 Python int 객체에 대한 강한 참조를 저장하여 PyLong_FreeExport()가 호출될 때까지 해당 구조체가 유효하게 유지되도록 보장합니다.
-
int PyLong_Export(PyObject *obj, PyLongExport *export_long)
- Python
int객체를 익스포트합니다.export_long은 호출자가 할당한
PyLongExport구조체를 가리켜야 합니다.NULL이어서는 안 됩니다.성공하면 *export_long을 채우고 0을 반환합니다. 오류가 발생하면 예외를 설정하고 -1을 반환합니다.
내보내기가 더 이상 필요하지 않으면
PyLong_FreeExport()를 호출해야 합니다.CPython 구현 세부 사항: obj가 Python
int객체이거나 그 서브클래스이면 이 함수는 항상 성공합니다.
CPython 3.14에서는 PyLong_Export()에 메모리 복사가 필요하지 않습니다. 이 함수는 파이썬 int의 내부 digit 배열을 노출하는 얇은 래퍼일 뿐입니다.
-
void PyLong_FreeExport(PyLongExport *export_long)
PyLong_Export()로 생성한 내보내기 export_long을 해제합니다.CPython 구현 세부 사항: export_long->digits가
NULL이면PyLong_FreeExport()호출은 선택 사항입니다.
가져오기 API
정수를 가져오는 데 PyLongWriter API를 사용할 수 있습니다.
-
struct PyLongWriter
- Python
int작성기(writer) 인스턴스입니다.이 인스턴스는
PyLongWriter_Finish()또는PyLongWriter_Discard()로 파괴해야 합니다.
-
PyLongWriter *PyLongWriter_Create(int negative, Py_ssize_t ndigits, void **digits)
PyLongWriter를 생성합니다.성공하면 *digits를 할당하고 작성기를 반환합니다. 오류가 발생하면 예외를 설정하고
NULL을 반환합니다.negative는 수가 음수이면
1, 그렇지 않으면0입니다.ndigits는 digits 배열에 있는 자릿수의 개수입니다. 0보다 커야 합니다.
digits는 NULL이어서는 안 됩니다.
이 함수를 성공적으로 호출한 후, 호출자는 숫자 배열 digits를 채운 다음
PyLongWriter_Finish()를 호출하여 Pythonint를 얻어야 합니다. digits의 레이아웃은PyLong_GetNativeLayout()이 기술합니다.자릿수는 [
0;(1 << bits_per_digit) - 1] 범위에 있어야 합니다(여기서bits_per_digit은 자릿수 하나당 비트 수입니다). 사용하지 않는 최상위 자릿수는 모두0으로 설정해야 합니다.또는
PyLongWriter_Discard()를 호출하여int객체를 만들지 않고 작성기 인스턴스를 파괴할 수도 있습니다.
CPython 3.14에서 PyLongWriter_Create() 구현은 비공개 함수 _PyLong_New()에 대한 얇은 래퍼입니다.
-
PyObject *PyLongWriter_Finish(PyLongWriter *writer)
PyLongWriter_Create()로 생성한PyLongWriter를 완료합니다.성공하면 Python
int객체를 반환합니다. 오류가 발생하면 예외를 설정하고NULL을 반환합니다.이 함수는 자릿수 정규화를 처리하며, 필요하면 객체를 콤팩트 정수로 변환합니다.
호출 후에는 작성기 인스턴스와 digits 배열이 무효가 됩니다.
-
void PyLongWriter_Discard(PyLongWriter *writer)
PyLongWriter_Create()로 생성한PyLongWriter를 폐기합니다.writer는
NULL이어서는 안 됩니다.호출 후에는 작성기 인스턴스와 digits 배열이 무효가 됩니다.
작은 정수의 가져오기 최적화
제안하는 가져오기 API는 큰 정수에 대해 효율적입니다. Python 내부에 직접 접근하는 것과 비교하면, 제안된 임포트 API는 작은 정수에서 상당한 성능 오버헤드가 있을 수 있습니다.
digit 수가 적은(예를 들어 1개 또는 2개) 작은 정수에는 기존 API를 사용할 수 있습니다.
구현
벤치마크
코드:
/* Query parameters of Python’s internal representation of integers. */
const PyLongLayout *layout = PyLong_GetNativeLayout();
size_t int_digit_size = layout->digit_size;
int int_digits_order = layout->digits_order;
size_t int_bits_per_digit = layout->bits_per_digit;
size_t int_nails = int_digit_size*8 - int_bits_per_digit;
int int_endianness = layout->digit_endianness;
내보내기: gmpy2에서의 PyLong_Export()
코드:
static int
mpz_set_PyLong(mpz_t z, PyObject *obj)
{
static PyLongExport long_export;
if (PyLong_Export(obj, &long_export) < 0) {
return -1;
}
if (long_export.digits) {
mpz_import(z, long_export.ndigits, int_digits_order, int_digit_size,
int_endianness, int_nails, long_export.digits);
if (long_export.negative) {
mpz_neg(z, z);
}
PyLong_FreeExport(&long_export);
}
else {
const int64_t value = long_export.value;
if (LONG_MIN <= value && value <= LONG_MAX) {
mpz_set_si(z, value);
}
else {
mpz_import(z, 1, -1, sizeof(int64_t), 0, 0, &value);
if (value < 0) {
mpz_t tmp;
mpz_init(tmp);
mpz_ui_pow_ui(tmp, 2, 64);
mpz_sub(z, z, tmp);
mpz_clear(tmp);
}
}
}
return 0;
}
참조 코드: 커밋 9177648 기준 gmpy2 master의 mpz_set_PyLong().
벤치마크:
import pyperf
from gmpy2 import mpz
runner = pyperf.Runner()
runner.bench_func('1<<7', mpz, 1 << 7)
runner.bench_func('1<<38', mpz, 1 << 38)
runner.bench_func('1<<300', mpz, 1 << 300)
runner.bench_func('1<<3000', mpz, 1 << 3000)
CPU 격리를 적용한 Linux Fedora 40에서 릴리스 모드로 빌드한 Python으로 측정한 결과:
| 벤치마크 | ref | pep757 |
|---|---|---|
| 1<<7 | 91.3 ns | 89.9 ns: 1.02배 빠름 |
| 1<<38 | 120 ns | 94.9 ns: 1.27배 빠름 |
| 1<<300 | 196 ns | 203 ns: 1.04배 느림 |
| 1<<3000 | 939 ns | 945 ns: 1.01배 느림 |
| 기하 평균 | (ref) | 1.05배 빠름 |
가져오기: gmpy2에서의 PyLongWriter_Create()
코드:
static PyObject *
GMPy_PyLong_From_MPZ(MPZ_Object *obj, CTXT_Object *context)
{
if (mpz_fits_slong_p(obj->z)) {
return PyLong_FromLong(mpz_get_si(obj->z));
}
size_t size = (mpz_sizeinbase(obj->z, 2) +
int_bits_per_digit - 1) / int_bits_per_digit;
void *digits;
PyLongWriter *writer = PyLongWriter_Create(mpz_sgn(obj->z) < 0, size,
&digits);
if (writer == NULL) {
return NULL;
}
mpz_export(digits, NULL, int_digits_order, int_digit_size,
int_endianness, int_nails, obj->z);
return PyLongWriter_Finish(writer);
}
참조 코드: 커밋 9177648 기준 gmpy2 master의 GMPy_PyLong_From_MPZ().
벤치마크:
import pyperf
from gmpy2 import mpz
runner = pyperf.Runner()
runner.bench_func('1<<7', int, mpz(1 << 7))
runner.bench_func('1<<38', int, mpz(1 << 38))
runner.bench_func('1<<300', int, mpz(1 << 300))
runner.bench_func('1<<3000', int, mpz(1 << 3000))
CPU 격리를 적용한 Linux Fedora 40에서 릴리스 모드로 빌드한 Python으로 측정한 결과:
| 벤치마크 | ref | pep757 |
|---|---|---|
| 1<<7 | 56.7 ns | 56.2 ns: 1.01배 빠름 |
| 1<<300 | 191 ns | 213 ns: 1.12배 느림 |
| 기하 평균 | (ref) | 1.03배 느림 |
유의미하지 않아 숨긴 벤치마크 (2): 1<<38, 1<<3000.
하위 호환성
새로운 API만 추가되므로 하위 호환성에는 영향이 없습니다.
기각된 아이디어
임의의 레이아웃 지원
Python 정수를 임포트-익스포트할 때 임의의 레이아웃을 지원하면 편리할 것입니다.
예를 들어, PyLongWriter_Create()에 layout 매개변수를 추가하고 PyLongExport 구조체에 layout 멤버를 추가하자는 제안이 있었습니다.
문제는 이를 구현하기가 더 복잡하고 실제로 필요하지도 않다는 점입니다. 엄밀히 필요한 것은 Python “네이티브” 레이아웃을 사용하여 임포트-익스포트하는 API뿐입니다.
나중에 임의의 레이아웃이 필요한 사용 사례가 생기면 새로운 API를 추가할 수 있습니다.
PyLong_GetNativeLayout() 함수를 추가하지 않기
현재 int 임포트/익스포트에 필요한 정보 대부분은 이미 PyLong_GetInfo()(및 sys.int_info)를 통해 얻을 수 있습니다. 또한 (digit 순서 같은) 정보를 더 추가할 수도 있으며, 이 인터페이스는 PyLongObject의 향후 발전에 어떠한 제약도 가하지 않습니다.
문제는 PyLong_GetInfo()가 편리한 C 구조체가 아니라 Python 객체인 named tuple을 반환한다는 점이며, 이 때문에 사람들이 이 함수를 사용하지 않고 PyLong_SHIFT와 PyLong_BASE 같은 현재의 준비공개 매크로를 선호하게 될 수 있다는 것입니다.
대신 mpz_import/export와 유사한 API 제공
int 객체의 데이터를 임포트/익스포트하는 또 다른 접근 방식은 다음과 같을 수 있습니다: C 확장이 연속된 버퍼를 제공하고, CPython이 그 버퍼로 정수의 절대 값을 익스포트(또는 임포트)한다고 가정하는 것입니다.
API 예시:
struct PyLongLayout {
uint8_t bits_per_digit;
uint8_t digit_size;
int8_t digits_order;
};
size_t PyLong_GetDigitsNeeded(PyLongObject *obj, PyLongLayout layout);
int PyLong_Export(PyLongObject *obj, PyLongLayout layout, void *buffer);
PyLongObject *PyLong_Import(PyLongLayout layout, void *buffer);
GMP에는 mpz_t의 내부에 필요한 접근을 제공하는 mpz_limbs_read()와 mpz_limbs_write() 함수가 있으므로, GMP에서는 이 방식이 동작할 수 있습니다. 다른 라이브러리는 임시 버퍼를 사용한 다음 자체적으로 mpz_import/export와 유사한 함수를 사용해야 할 수 있습니다.
이 접근 방식의 주요 단점은 CPython 쪽 구현이 훨씬 더 복잡하다는 점입니다(즉, 서로 다른 레이아웃 간의 실제 변환). 예를 들어, CPython에서 PyLong_FromNativeBytes()와 PyLong_AsNativeBytes()(둘이 합쳐 필요한 API의 제한된 버전을 제공)를 구현하는 데 ~500 LOC가 들었습니다(현재 구현의 ~100 LOC와 비교).
내보내기 API에서 value 필드 제거
이 제안을 따르면 내보내기 유형은 하나(“digits”의 배열)만 존재하게 됩니다. 주어진 정수에 대해 그러한 뷰를 사용할 수 없다면, 내보내기 함수가 이를 에뮬레이션하거나 PyLong_Export()가 오류를 반환하게 됩니다. 두 경우 모두, 사용자는 “충분히 작은” 정수(즉, 어떤 기계 정수 타입에 들어맞는 정수)를 얻기 위해 PyLong_AsLongAndOverflow()와 같은 다른 C-API 함수를 사용할 것으로 가정합니다. 이 경우 PyLong_Export()는 비효율적이거나 아예 실패할 것입니다.
예시:
static int
mpz_set_PyLong(mpz_t z, PyObject *obj)
{
int overflow;
#if SIZEOF_LONG == 8
long value = PyLong_AsLongAndOverflow(obj, &overflow);
#else
/* Windows has 32-bit long, so use 64-bit long long instead */
long long value = PyLong_AsLongLongAndOverflow(obj, &overflow);
#endif
Py_BUILD_ASSERT(sizeof(value) == sizeof(int64_t));
if (!overflow) {
if (LONG_MIN <= value && value <= LONG_MAX) {
mpz_set_si(z, (long)value);
}
else {
mpz_import(z, 1, -1, sizeof(int64_t), 0, 0, &value);
if (value < 0) {
mpz_t tmp;
mpz_init(tmp);
mpz_ui_pow_ui(tmp, 2, 64);
mpz_sub(z, z, tmp);
mpz_clear(tmp);
}
}
}
else {
static PyLongExport long_export;
if (PyLong_Export(obj, &long_export) < 0) {
return -1;
}
mpz_import(z, long_export.ndigits, int_digits_order, int_digit_size,
int_endianness, int_nails, long_export.digits);
if (long_export.negative) {
mpz_neg(z, z);
}
PyLong_FreeExport(&long_export);
}
return 0;
}
API 설계자의 관점에서는 단순화처럼 보일 수 있지만, 최종 사용자에게는 덜 편리할 것입니다. 사용자는 Python 개발 동향을 따라가야 하고, 작은 정수를 내보내는 여러 방식을 벤치마크해야 하며(위의 경우에 왜 PyLong_AsInt64() 대신 그 방식이 선택되었는지 명확합니까?), 어쩌면 여러 CPython 버전이나 서로 다른 Python 구현마다 다른 코드 경로를 지원해야 할 수도 있습니다.
논의
- Discourse: PEP 757 – Python 정수 가져오기/내보내기를 위한 C API
- C API 워킹 그룹 결정 이슈 #35
- 풀 리퀘스트 #121339
- 이슈 #102471: Python 정수를 C 정수로 변환하는 C-API는 솔직히 말해 엉망입니다.
- 공개 함수 PyLong_GetDigits() 추가
- _PyLong_New() 함수를 공개 함수로 복원하는 것을 고려
- 풀 리퀘스트 gh-106320: 비공개 _PyLong_New() 함수를 제거합니다.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.