본문으로 건너뛰기

[cpython] CPython의 PySequence_GetSlice 성능 개선: 불필요한 참조 카운트 연산 제거

PR 링크: python/cpython#145193 상태: Merged | 변경: +17 / -21

들어가며

Python은 강력하고 유연한 언어이지만, 그 이면에는 C로 구현된 CPython 인터프리터가 있습니다. CPython의 C API는 Python 객체를 다루는 저수준 인터페이스를 제공하며, 때로는 이 API의 성능 최적화가 전체 Python 성능에 큰 영향을 미치기도 합니다. 이번 PR (gh-145192)은 CPython의 PySequence_GetSlice 함수에서 발생하는 불필요한 참조 카운트 연산을 제거하여 성능을 개선하는 내용을 담고 있습니다.

PySequence_GetSlice는 Python 코드에서 직접적으로 호출할 수는 없지만, 내부적으로 시퀀스 슬라이싱(list[start:stop], tuple[start:stop] 등)을 처리하는 데 사용됩니다. 이 PR은 _PyBuildSlice_ConsumeRefs 함수의 동작을 변경하고, 이를 통해 PySequence_GetSlice 호출 시 발생하는 오버헤드를 줄이는 것을 목표로 합니다.

코드 변경사항 분석

이번 PR의 핵심 변경 사항은 Objects/sliceobject.c 파일에 집중되어 있으며, 특히 슬라이스 객체 생성과 관련된 내부 함수들의 참조 카운트 관리를 최적화하는 데 있습니다.

1. _PyBuildSlice_ConsumeRefs 함수의 변경

가장 중요한 변경은 _PyBuildSlice_ConsumeRefs 함수의 시그니처와 동작 방식의 변경입니다. 이전에는 startstop 두 개의 인자만 받았지만, 이제는 step까지 세 개의 인자를 받도록 변경되었습니다. 또한, 함수 이름에서 알 수 있듯이, 이 함수는 이제 전달받은 모든 인자의 참조를 소비(consume)하도록 설계되었습니다.

Before:

static PySliceObject *
_PyBuildSlice_Consume2(PyObject *start, PyObject *stop, PyObject *step)
{
    // ...
    obj->start = start;
    obj->stop = stop;
    obj->step = Py_NewRef(step);
    // ...
    return obj;
error:
    Py_DECREF(start);
    Py_DECREF(stop);
    return NULL;
}

PyObject *
_PyBuildSlice_ConsumeRefs(PyObject *start, PyObject *stop)
{
    assert(start != NULL && stop != NULL);
    return (PyObject *)_PyBuildSlice_Consume2(start, stop, Py_None);
}

After:

PyObject *
_PyBuildSlice_ConsumeRefs(PyObject *start, PyObject *stop, PyObject *step)
{
    assert(start != NULL && stop != NULL && step != NULL);
    PySliceObject *obj = _Py_FREELIST_POP(PySliceObject, slices);
    if (obj == NULL) {
        return NULL;
    }

    obj->start = start;
    obj->stop = stop;
    obj->step = step;

    _PyObject_GC_TRACK(obj);
    return (PyObject *)obj;
error:
    Py_DECREF(start);
    Py_DECREF(stop);
    Py_DECREF(step);
    return NULL;
}

이전 코드에서는 _PyBuildSlice_Consume2 함수가 startstop 인자의 참조를 소비했지만, step 인자에 대해서는 Py_NewRef를 호출하여 새로운 참조를 생성했습니다. 이는 step 인자가 Py_None일 경우 불필요한 incref 연산을 유발했습니다. 또한, _PyBuildSlice_ConsumeRefs 함수는 _PyBuildSlice_Consume2를 호출하면서 Py_Nonestep으로 전달했는데, 이 과정에서 Py_None에 대한 incref가 발생했습니다.

변경된 코드에서는 _PyBuildSlice_ConsumeRefs 함수가 세 개의 인자(start, stop, step)를 모두 받고, 이들을 직접 슬라이스 객체의 멤버로 할당합니다. Py_NewRef 호출이 사라지고, error 경로에서 step에 대한 DECREF가 추가되었습니다. 이는 stepPy_None일 때 발생하는 불필요한 incref/decref 쌍을 제거합니다.

2. PySlice_New 함수의 변경

PySlice_New 함수는 Python 레벨에서 slice(start, stop, step)를 호출할 때 사용되는 함수입니다. 이 함수 역시 내부적으로 _PyBuildSlice_ConsumeRefs를 호출하도록 변경되었습니다.

Before:

PySlice_New(PyObject *start, PyObject *stop, PyObject *step)
{
    // ...
    return (PyObject *)_PyBuildSlice_Consume2(Py_NewRef(start),
                                              Py_NewRef(stop), step);
}

After:

PySlice_New(PyObject *start, PyObject *stop, PyObject *step)
{
    // ...
    return _PyBuildSlice_ConsumeRefs(Py_NewRef(start),
                                              Py_NewRef(stop), Py_NewRef(step));
}

이전에는 startstop에 대해서만 Py_NewRef를 호출하고 step은 그대로 전달했습니다. 변경 후에는 start, stop, step 세 인자 모두에 대해 Py_NewRef를 호출하여 참조를 증가시킨 후 _PyBuildSlice_ConsumeRefs에 전달합니다. 이는 _PyBuildSlice_ConsumeRefs가 이제 모든 인자의 참조를 소비하도록 변경되었기 때문에 일관성을 맞추기 위한 조치입니다.

3. _PySlice_FromIndices 함수의 변경

_PySlice_FromIndices 함수는 정수 인덱스로부터 슬라이스 객체를 생성하는 내부 함수입니다. 이 함수 역시 PySequence_GetSlice와 같은 C API 호출 시 사용될 수 있습니다.

Before:

slice = PySlice_New(start, end, NULL);
Py_DECREF(start);
Py_DECREF(end);

After:

slice = _PyBuildSlice_ConsumeRefs(start, end, Py_None);

이전에는 PySlice_New를 호출한 후, startend에 대한 참조를 직접 해제했습니다. 변경 후에는 _PyBuildSlice_ConsumeRefs 함수가 start, end, Py_None의 참조를 소비하므로, 명시적인 Py_DECREF 호출이 사라졌습니다. 이는 _PyBuildSlice_ConsumeRefs가 인자의 참조를 소유하게 되면서 불필요한 DECREF 호출을 제거한 것입니다.

4. 바이트코드 및 테스트 케이스에서의 변경

Python/bytecodes.cPython/executor_cases.c.h, Modules/_testinternalcapi/test_cases.c.h 파일에서도 _PyBuildSlice_ConsumeRefs 함수 호출 시 Py_None을 세 번째 인자로 추가하는 변경이 이루어졌습니다. 이는 Python 바이트코드 _BINARY_SLICESTORE_SLICE와 같이 슬라이싱 연산을 수행하는 내부 로직에서 step 인자가 Py_None인 경우에도 일관된 방식으로 슬라이스 객체를 생성하도록 하기 위함입니다.

예시 (Python/bytecodes.c):

 op(_STORE_SLICE, (v, container, start, stop -- )) {
     PyObject *slice = _PyBuildSlice_ConsumeRefs(PyStackRef_AsPyObjectSteal(start),
-                                                        PyStackRef_AsPyObjectSteal(stop));
+                                                        PyStackRef_AsPyObjectSteal(stop),
+                                                        Py_None);
     int err;
     if (slice == NULL) {
         err = 1;

왜 이게 좋은가?

이번 PR의 주요 개선점은 다음과 같습니다.

  1. 불필요한 참조 카운트 연산 제거: 이전 코드에서는 step 인자가 Py_None일 경우, Py_None에 대한 increfdecref 연산이 불필요하게 수행되었습니다. 또한 _PySlice_FromIndices에서는 PySlice_New 호출 후 startend에 대한 DECREF가 필요했습니다. 변경된 코드는 이러한 불필요한 연산을 제거하여 오버헤드를 줄였습니다.
  2. 성능 향상: 벤치마크 결과에 따르면, PySequence_GetSlice 함수의 성능이 개선되었습니다. 특히 튜플과 바이트 객체에 대한 슬라이싱 연산에서 눈에 띄는 성능 향상이 관찰되었습니다.

벤치마크 결과 비교:

Main (Before PR):

PySequence_GetSlice benchmark (2000000 iterations)
==============================================

list[1200:1204]      : 0.1314 s  (65.7 ns/call)
list[1500:1800]: 0.9673 s  (480.7 ns/call)
list[1500:1500]: 0.1138 s  (56.9 ns/call)

tuple[1200:1204]     : 0.1216 s  (60.8 ns/call)
tuple[1500:1800]: 1.0420 s  (501.0 ns/call)
tuple[1500:1500]  : 0.0873 s  (43.6 ns/call)

bytes[1200:1204]     : 0.1179 s  (58.9 ns/call)
bytes[1500:1800]: 0.1254 s  (62.7 ns/call)
bytes[1500:1500]  : 0.0905 s  (45.3 ns/call)

PR (After PR):

PySequence_GetSlice benchmark (2000000 iterations)
==============================================

list[1200:1204]      : 0.1213 s  (60.6 ns/call)
list[1500:1800]: 0.9424 s  (471.2 ns/call)
list[1500:1500]: 0.1006 s  (50.3 ns/call)

tuple[1200:1204]     : 0.1026 s  (51.3 ns/call)
tuple[1500:1800]: 0.9769 s  (488.5 ns/call)
tuple[1500:1500]  : 0.0724 s  (36.2 ns/call)

bytes[1200:1204]     : 0.1001 s  (50.1 ns/call)
bytes[1500:1800]: 0.1093 s  (54.6 ns/call)
bytes[1500:1500]  : 0.0744 s  (37.2 ns/call)

예를 들어, tuple[1500:1500] 슬라이싱의 경우 콜 당 43.6ns에서 36.2ns로 약 17%의 성능 향상이 있었습니다. bytes[1500:1800]의 경우 콜 당 62.7ns에서 54.6ns로 약 13%의 성능 향상이 있었습니다.

일반적인 교훈

  • 참조 카운트 최적화의 중요성: Python의 성능은 C API 레벨에서의 미세한 최적화에 크게 좌우될 수 있습니다. 특히 반복적으로 호출되는 함수나 내부 로직에서는 불필요한 incref/decref 연산을 제거하는 것이 중요합니다.
  • 함수 책임의 명확화: _PyBuildSlice_ConsumeRefs와 같이 참조 카운트를 관리하는 함수의 책임 범위를 명확히 하는 것이 코드의 복잡성을 줄이고 잠재적인 버그를 예방하는 데 도움이 됩니다. 이 PR은 함수가 모든 인자의 참조를 소비하도록 하여 이러한 명확성을 높였습니다.
  • 벤치마킹의 중요성: 성능 개선 PR은 반드시 벤치마크 결과를 통해 그 효과를 입증해야 합니다. 이 PR은 구체적인 수치를 제시하여 개선 효과를 명확히 보여주었습니다.

리뷰 코멘트 반영

  • Mark Shannon의 제안: Mark Shannon은 _PyBuildSlice_Consume2 함수에서 step 인자에 대한 Py_NewRef 호출이 불필요하다고 지적했습니다. 또한, 이 함수와 _PyBuildSlice_ConsumeRefs 함수가 사실상 동일한 역할을 하게 되었으므로 하나로 합치고 이름을 변경할 것을 제안했습니다. PR 작성자는 이 제안을 반영하여 두 함수를 _PyBuildSlice_ConsumeRefs로 통합하고, 세 개의 인자를 받도록 변경했습니다.
  • Buildbot 실패: 초기 빌드에서 test_free_threading 테스트가 실패하는 문제가 있었습니다. 이는 NoGIL 환경에서의 Refleaks와 관련이 있었으며, PR 자체의 로직 문제라기보다는 빌드 환경의 문제였을 가능성이 높습니다. (이슈 트래커에서 추가적인 논의가 필요할 수 있습니다.)
  • Benedikt Johannes의 제안: Benedikt Johannes는 NEWS 파일의 설명을 좀 더 상세하게 작성할 것을 제안했습니다. 이에 따라 "Improve performance of PySequence_GetSlice by avoiding unnecessary reference count decrements." 와 같이 변경되었습니다.

References

참고 자료

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

댓글

관련 포스트

PR Analysis 의 다른글