[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 함수의 시그니처와 동작 방식의 변경입니다. 이전에는 start와 stop 두 개의 인자만 받았지만, 이제는 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 함수가 start와 stop 인자의 참조를 소비했지만, step 인자에 대해서는 Py_NewRef를 호출하여 새로운 참조를 생성했습니다. 이는 step 인자가 Py_None일 경우 불필요한 incref 연산을 유발했습니다. 또한, _PyBuildSlice_ConsumeRefs 함수는 _PyBuildSlice_Consume2를 호출하면서 Py_None을 step으로 전달했는데, 이 과정에서 Py_None에 대한 incref가 발생했습니다.
변경된 코드에서는 _PyBuildSlice_ConsumeRefs 함수가 세 개의 인자(start, stop, step)를 모두 받고, 이들을 직접 슬라이스 객체의 멤버로 할당합니다. Py_NewRef 호출이 사라지고, error 경로에서 step에 대한 DECREF가 추가되었습니다. 이는 step이 Py_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));
}
이전에는 start와 stop에 대해서만 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를 호출한 후, start와 end에 대한 참조를 직접 해제했습니다. 변경 후에는 _PyBuildSlice_ConsumeRefs 함수가 start, end, Py_None의 참조를 소비하므로, 명시적인 Py_DECREF 호출이 사라졌습니다. 이는 _PyBuildSlice_ConsumeRefs가 인자의 참조를 소유하게 되면서 불필요한 DECREF 호출을 제거한 것입니다.
4. 바이트코드 및 테스트 케이스에서의 변경
Python/bytecodes.c 및 Python/executor_cases.c.h, Modules/_testinternalcapi/test_cases.c.h 파일에서도 _PyBuildSlice_ConsumeRefs 함수 호출 시 Py_None을 세 번째 인자로 추가하는 변경이 이루어졌습니다. 이는 Python 바이트코드 _BINARY_SLICE나 STORE_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의 주요 개선점은 다음과 같습니다.
- 불필요한 참조 카운트 연산 제거: 이전 코드에서는
step인자가Py_None일 경우,Py_None에 대한incref및decref연산이 불필요하게 수행되었습니다. 또한_PySlice_FromIndices에서는PySlice_New호출 후start와end에 대한DECREF가 필요했습니다. 변경된 코드는 이러한 불필요한 연산을 제거하여 오버헤드를 줄였습니다. - 성능 향상: 벤치마크 결과에 따르면,
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 ofPySequence_GetSliceby avoiding unnecessary reference count decrements." 와 같이 변경되었습니다.
References
PySequence_GetSlice: Python C API DocumentationPySlice_New: Python C API Documentation_PyBuildSlice_ConsumeRefs: (내부 함수로 공식 문서 없음)
참고 자료
- https://docs.python.org/3/c-api/sequence.html#c.PySequence_GetSlice
- https://docs.python.org/3/c-api/slice.html#c.PySlice_New
⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.
관련 포스트
PR Analysis 의 다른글
- 이전글 [sglang] sglang의 torch.compile 활용: Advanced Indexing Gather 최적화로 LLM 추론 가속화
- 현재글 : [cpython] CPython의 PySequence_GetSlice 성능 개선: 불필요한 참조 카운트 연산 제거
- 다음글 [triton] Triton Reduce 커널 성능 최적화: Subtiling과 RowIdxs 도입
댓글