본문으로 건너뛰기

[cpython] CPython 성능 최적화: list/tuple에서 bytes 생성 시 30% 성능 향상 및 Free-threading 대응

PR 링크: python/cpython#132590 상태: Merged | 변경: +69 / -67

들어가며

파이썬에서 bytes([1, 2, 3])이나 bytes((1, 2, 3))와 같이 리스트나 튜플을 바이트 객체로 변환하는 작업은 매우 빈번하게 발생합니다. 기존 CPython 구현에서는 이 과정이 각 요소를 하나씩 순회하며 참조 횟수를 조정하고 타입을 확인하는 방식으로 이루어졌습니다.

특히 파이썬 3.13에서 도입된 Free-threading(GIL 제거) 빌드에서는 리스트의 요소를 안전하게 가져오기 위해 _PyList_GetItemRef와 같은 원자적(atomic) 연산을 사용해야 했는데, 이는 요소가 많아질수록 상당한 성능 오버헤드를 유발했습니다. 이번 gh-128213 PR은 이러한 병목 지점을 해결하여, 리스트와 튜플로부터 bytes를 생성할 때 약 27~31%의 성능 향상을 이끌어냈습니다.

코드 분석: 무엇이 바뀌었는가?

핵심 변경 사항은 Objects/bytesobject.c에 집중되어 있습니다. 기존의 개별적인 리스트/튜플 처리 로직을 하나로 통합하고, 내부 데이터에 직접 접근하는 'Fast Path'를 도입했습니다.

1. 중복 코드 제거 및 통합 (Before & After)

기존에는 리스트용(_PyBytes_FromList)과 튜플용(_PyBytes_FromTuple) 함수가 따로 존재했습니다.

Before (List 처리 예시):

static PyObject*
_PyBytes_FromList(PyObject *x)
{
    Py_ssize_t size = PyList_GET_SIZE(x);
    // ... writer 생성 ...
    for (Py_ssize_t i = 0; i < PyList_GET_SIZE(x); i++) {
        PyObject *item = _PyList_GetItemRef((PyListObject *)x, i); // 매 요소마다 atomic refcount 발생
        if (item == NULL) goto error;
        Py_ssize_t value = PyNumber_AsSsize_t(item, NULL);
        Py_DECREF(item);
        // ... 범위 체크 및 저장 ...
    }
}

After (통합된 Fast Path): 새로운 구현에서는 _PyBytes_FromSequence_lock_held라는 내부 함수를 통해 리스트와 튜플을 공통으로 처리합니다.

static int
_PyBytes_FromSequence_lock_held(PyObject *x, PyObject **result)
{
    Py_ssize_t size = PySequence_Fast_GET_SIZE(x);
    PyBytesWriter *writer = PyBytesWriter_Create(size);
    // ...
    PyObject *const *items = PySequence_Fast_ITEMS(x); // 내부 배열에 직접 접근
    for (Py_ssize_t i = 0; i < size; i++) {
        Py_ssize_t value = PyLong_AsSsize_t(items[i]); // PyNumber_AsSsize_t보다 가벼운 호출
        if (value == -1 && PyErr_Occurred()) {
            // 에러 시 slow path로 폴백하거나 종료
            return 0;
        }
        if (value < 0 || value >= 256) {
            // ValueError 처리
            return -1;
        }
        *str++ = (char) value;
    }
    *result = PyBytesWriter_Finish(writer);
    return 1;
}

2. Critical Section 도입을 통한 스레드 안전성 확보

Free-threading 빌드에서 리스트는 가변(mutable) 객체이므로, 순회 중에 다른 스레드가 리스트를 수정하면 크래시가 발생할 수 있습니다. 이를 방지하기 위해 Py_BEGIN_CRITICAL_SECTION_SEQUENCE_FAST 매크로를 사용하여 시퀀스 전체에 락을 걸고 안전하게 접근합니다.

if (PyList_CheckExact(x) || PyTuple_CheckExact(x)) {
    int rc;
    Py_BEGIN_CRITICAL_SECTION_SEQUENCE_FAST(x); // 시퀀스 전체 잠금
    rc = _PyBytes_FromSequence_lock_held(x, &result);
    Py_END_CRITICAL_SECTION_SEQUENCE_FAST();
    if (rc != 0) {
        return result;
    }
}

왜 이게 좋은 최적화인가?

1. 원자적 연산의 최소화

기존 코드의 _PyList_GetItemRef는 매 루프마다 참조 횟수를 원자적으로 증가/감소시켜야 했습니다. 하지만 새로운 방식은 Critical Section을 통해 시퀀스 전체를 한 번만 잠그고, 내부 items 배열에 직접 접근(PySequence_Fast_ITEMS)함으로써 수천 번의 원자적 연산을 단 한 번의 락 획득으로 대체했습니다. 이것이 FT 빌드에서 2~3배의 성능 향상을 가져온 핵심 이유입니다.

2. 불필요한 추상화 제거

기존에는 PyNumber_AsSsize_t를 사용했는데, 이는 내부적으로 __index____int__ 매직 메서드를 확인하는 복잡한 과정을 거칩니다. 최적화된 경로에서는 정수임이 거의 확실한 상황에서 PyLong_AsSsize_t를 직접 호출하여 오버헤드를 줄였습니다.

3. 코드 응집도 향상

리스트와 튜플은 구조적으로 매우 유사합니다. 두 타입을 별도의 함수로 관리하던 것을 하나로 합치면서 유지보수 효율성이 높아졌고, 바이너리 크기도 미세하게 줄어드는 효과를 얻었습니다.

리뷰어들의 통찰

리뷰 과정에서 Mark Shannon은 "튜플은 불변(immutable)인데 왜 동기화(Critical Section)가 필요한가?"라는 날카로운 질문을 던졌습니다. 이에 대해 구현자인 eendebakpt는 튜플의 경우 동기화가 엄밀히 필요 없지만, 리스트와 경로를 통합함으로써 얻는 코드 단순화의 이득이 크고 튜플에서의 락 오버헤드는 무시할 수 있는 수준이라고 답변했습니다.

또한, PyLong_CheckExact를 사용하여 정수 객체의 내부 값에 더 빠르게 접근하는 추가 최적화 아이디어도 논의되었습니다. 이번 PR에서는 안정성을 위해 PyLong_AsSsize_t까지만 적용되었지만, 향후 더 극단적인 최적화의 여지를 남겨두었습니다.

결론

이번 최적화는 단순히 루프 속도를 높인 것을 넘어, 파이썬의 새로운 시대인 Free-threading 환경에서 가변 객체를 어떻게 효율적이고 안전하게 다룰 것인가에 대한 모범 사례를 보여줍니다. 대량의 데이터를 bytes로 변환하는 데이터 처리 파이프라인에서 이번 변경은 체감할 수 있는 성능 향상을 제공할 것입니다.

이 변경사항은 Python 3.14 버전에 포함될 예정입니다.

참고 자료

⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.

댓글

관련 포스트

PR Analysis 의 다른글