본문으로 건너뛰기

[cpython] Python 문자열 split/splitlines 성능 개선: _PyList_AppendTakeRef 도입

PR 링크: python/cpython#155922 상태: Merged | 변경: +5 / -11

들어가며

Python의 문자열 처리 기능은 매우 자주 사용됩니다. 특히 split()splitlines() 함수는 문자열을 특정 구분자나 줄바꿈 문자를 기준으로 분리하는 데 필수적입니다. 하지만 이 함수들이 내부적으로 어떻게 동작하는지에 따라 성능 차이가 발생할 수 있습니다. 최근 CPython 레포지토리의 PR gh-150942는 stringlibsplitsplitlines 함수에서 발생하는 성능 병목 현상을 해결하기 위한 중요한 최적화를 포함하고 있습니다. 이 PR은 PyList_Append 대신 _PyList_AppendTakeRef를 사용하여 불필요한 참조 카운트 연산을 제거함으로써, 특히 많은 수의 조각으로 문자열을 분리할 때 성능을 향상시킵니다.

이 글에서는 해당 PR의 코드 변경 사항을 자세히 살펴보고, 왜 이러한 변경이 성능 향상으로 이어지는지, 그리고 이 최적화가 우리에게 주는 일반적인 교훈은 무엇인지 알아보겠습니다.

코드 분석

이번 PR의 핵심 변경 사항은 Objects/stringlib/split.h 파일에 집중되어 있습니다. 이 파일은 CPython 내부에서 문자열 분리 로직을 구현하는 매크로들을 정의하고 있습니다. 주요 변경점은 SPLIT_ADDSPLIT_APPEND 매크로에서 PyList_Append를 호출하는 부분을 _PyList_AppendTakeRef로 대체한 것입니다.

SPLIT_ADD 매크로 변경

SPLIT_ADD 매크로는 문자열 분리 시 생성된 각 조각(substring)을 결과 리스트에 추가하는 역할을 합니다. 원래 코드에서는 PyList_Append를 사용한 후, Py_DECREF를 호출하여 참조 카운트를 감소시켰습니다.

Before:

#define SPLIT_ADD(data, left, right) {          \
    sub = STRINGLIB_NEW((data) + (left),        \
                        (right) - (left));      \
    if (sub == NULL)                            \
        goto onError;                           \
    if (PyList_Append(list, sub)) {             \
        Py_DECREF(sub);                         \
        goto onError;                           \
    }                                           \
    else                                        \
        Py_DECREF(sub);

After:

#define SPLIT_ADD(data, left, right) {          \
    sub = STRINGLIB_NEW((data) + (left),        \
                        (right) - (left));      \
    if (sub == NULL)                            \
        goto onError;                           \
    if (_PyList_AppendTakeRef((PyListObject *)list, sub)) \
        goto onError;

변경 후에는 _PyList_AppendTakeRef 함수를 사용합니다. 이 함수는 CPython 3.11부터 도입된 함수로, 리스트에 객체를 추가할 때 해당 객체의 소유권을 가져갑니다. 즉, _PyList_AppendTakeRef가 성공적으로 호출되면, 함수 내부에서 참조 카운트 증가를 처리해주므로, 호출자 측에서 별도로 Py_DECREF를 호출할 필요가 없어집니다. 이는 PyList_Append 호출 후 Py_DECREF(sub)를 하는 불필요한 참조 카운트 감소 및 증가 연산(내부적으로 PyList_AppendPy_INCREF를 호출함)을 제거합니다.

SPLIT_APPEND 매크로 변경

SPLIT_APPEND 매크로는 splitlines() 함수에서 각 줄을 추가할 때 사용됩니다. 이 매크로 역시 SPLIT_ADD와 유사한 로직을 가지고 있으며, 동일하게 PyList_AppendPy_DECREF 쌍을 _PyList_AppendTakeRef로 대체했습니다.

Before:

#define SPLIT_APPEND(data, left, right) {       
    sub = STRINGLIB_NEW((data) + (left),        
                        (right) - (left));      
    if (sub == NULL)                            
        goto onError;                           
    if (count < MAX_PREALLOC) {                 
        PyList_SET_ITEM(list, count, sub);      
    } else {                                    
        if (PyList_Append(list, sub)) {         
            Py_DECREF(sub);                     
            goto onError;                       
        }                                       
        else                                    
            Py_DECREF(sub);                     
    }                                           
    count++; }

After:

#define SPLIT_APPEND(data, left, right) {       
    sub = STRINGLIB_NEW((data) + (left),        
                        (right) - (left));      
    if (sub == NULL)                            
        goto onError;                           
    if (count < MAX_PREALLOC) {                 
        PyList_SET_ITEM(list, count, sub);      
    } else {                                    
        if (_PyList_AppendTakeRef((PyListObject *)list, sub)) 
            goto onError;                       
    }                                           
    count++; }

이 변경 역시 PyList_Append 호출 후 Py_DECREF를 하는 과정을 제거하여, 객체 소유권 이전 방식을 통해 참조 카운트 연산을 최적화합니다. MAX_PREALLOC (기본값 12)보다 많은 항목을 추가할 때 이 경로가 사용됩니다.

_PyList_AppendTakeRef의 중요성

_PyList_AppendTakeRef 함수는 CPython 3.11에서 도입된 내부 API로, 리스트에 새로운 항목을 추가할 때 해당 항목의 참조 카운트를 직접 관리하지 않고, 객체의 소유권을 리스트로 이전시키는 방식으로 동작합니다. 이는 다음과 같은 이점을 제공합니다:

  1. 참조 카운트 오버헤드 감소: PyList_Append는 내부적으로 Py_INCREF를 호출하여 리스트에 추가되는 객체의 참조 카운트를 증가시킵니다. 이후 Py_DECREF를 호출하여 원래 참조 카운트를 감소시키는데, 이는 불필요한 참조 카운트 증감 연산입니다. _PyList_AppendTakeRef는 이 과정을 생략하고 객체의 소유권만 이전하므로, 이중 참조 카운트 연산을 제거합니다.
  2. GIL 경합 감소 (Free-threaded build): CPython의 free-threaded 빌드에서는 리스트와 같은 내장 타입에 대한 접근 시 전역 인터프리터 잠금(GIL) 경합이 발생할 수 있습니다. _PyList_AppendTakeRef는 내부적으로 리스트의 ob_item 배열에 직접 접근하여 항목을 추가하므로, PyList_Append가 수행하는 잠금 및 해제 과정을 줄여 GIL 경합을 완화할 수 있습니다.

이 PR은 split, rsplit, splitlines 함수에서 str, bytes, bytearray 타입을 모두 지원하며, 결과 리스트가 함수 내에서 지역적으로 할당되고 반환될 때까지 유지되므로 이러한 최적화의 효과를 극대화합니다.

왜 이게 좋은가?

이번 PR은 명확한 성능 향상을 가져왔습니다. 제공된 마이크로벤치마크 결과는 다음과 같습니다:

  • str_split_10k_pieces: `(

참고 자료

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

댓글

관련 포스트

PR Analysis 의 다른글