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

Python 개선 제안 한국어 번역

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 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

Important

This PEP is a historical document. The up-to-date, canonical documentation can now be found at the Export API and the PyLongWriter API.

×

See PEP 1 for how to propose changes.

초록

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()을 사용하여 Python int 객체의 네이티브 레이아웃을 얻으십시오. 이 레이아웃은 절댓값이 “충분히 큰” 정수에 대해 내부적으로 사용됩니다.

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
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일 수 있습니다.

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()를 호출하여 Python int를 얻어야 합니다. 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 구현마다 다른 코드 경로를 지원해야 할 수도 있습니다.

논의