[cpython] CPython 성능 최적화: 임시 리스트를 튜플로 변환할 때의 '아이템 스틸' 기법
PR 링크: python/cpython#149960 상태: Merged | 변경: +11 / -2
들어가며
파이썬에서 tuple(x for x in range(1000))이나 (*genexpr,)와 같은 구문을 사용할 때, 내부적으로 어떤 일이 벌어지는지 고민해 본 적이 있으신가요? 파이썬 컴파일러는 이러한 구문을 처리하기 위해 종종 임시 리스트(temporary list)를 먼저 생성한 뒤, 이를 최종 결과물인 튜플로 변환합니다.
기존의 CPython은 이 과정에서 리스트의 모든 요소를 새로운 튜플로 복사(Copy)했습니다. 하지만 이 리스트는 오직 튜플을 만들기 위해 잠시 생성된 '임시 객체'일 뿐입니다. 그렇다면 굳이 복사할 필요가 있을까요? 리스트가 가진 메모리 포인터를 튜플이 그대로 '훔쳐(Steal)'올 수는 없을까요?
이번 글에서는 gh-138325 PR을 통해 CPython 내부의 INTRINSIC_LIST_TO_TUPLE 명령어가 어떻게 최적화되었는지, 그리고 이를 통해 얻은 성능 이득이 무엇인지 분석해 보겠습니다.
코드 분석: 복사에서 소유권 이전으로
핵심 변경 사항은 Python/intrinsics.c 파일에 집중되어 있습니다. 이 파일은 파이썬 바이트코드 실행 중 호출되는 '내장 함수(intrinsics)'들을 정의합니다.
1. 변경 전 (Before)
기존 코드에서는 PyTuple_FromArray 함수를 사용하여 리스트의 아이템들을 새로운 튜플로 복사했습니다.
// Python/intrinsics.c (Before)
static PyObject *
list_to_tuple(PyThreadState* unused, PyObject *v)
{
assert(PyList_Check(v));
return PyTuple_FromArray(((PyListObject *)v)->ob_item, Py_SIZE(v));
}
- 문제점:
PyTuple_FromArray는 리스트의 내부 배열(ob_item)에 있는 모든 객체 참조를 순회하며 새로운 튜플의 슬롯으로 복사합니다. 이 과정에서 O(N)의 시간이 소요되며, 새로운 메모리 할당과 참조 횟수(reference count) 증가 작업이 동반됩니다.
2. 변경 후 (After)
최적화된 코드에서는 _PyList_AsTupleAndClear라는 내부 함수를 사용합니다.
// Python/intrinsics.c (After)
static PyObject *
list_to_tuple(PyThreadState* unused, PyObject *v)
{
/* INTRINSIC_LIST_TO_TUPLE은 컴파일러에 의해 새로 생성되고,
유일하게 참조되는 임시 리스트에 대해서만 방출됩니다.
따라서 아이템을 복사하는 대신 튜플로 '훔쳐올(steal)' 수 있습니다. */
assert(PyList_CheckExact(v));
assert(_PyObject_IsUniquelyReferenced(v));
return _PyList_AsTupleAndClear((PyListObject *)v);
}
- 개선점:
_PyList_AsTupleAndClear는 리스트가 들고 있던 데이터 배열의 소유권을 튜플에게 직접 넘겨줍니다. 리스트는 빈 상태가 되고, 튜플은 리스트가 이미 확보해 둔 메모리를 그대로 사용합니다. - 안전 장치:
_PyObject_IsUniquelyReferenced(v)단언문(assert)이 추가되었습니다. 이 리스트를 참조하는 곳이 오직 현재 실행 흐름뿐이라는 것이 보장될 때만 이 '훔치기' 전략이 안전합니다. 만약 다른 곳에서 이 리스트를 참조하고 있다면, 리스트를 비워버리는 행위는 심각한 버그를 초래할 수 있기 때문입니다.
왜 이게 좋은 최적화인가?
1. 불필요한 O(N) 작업 제거
데이터의 양이 많아질수록 복사 비용은 선형적으로 증가합니다. 이 최적화는 포인터만 갈아끼우는 수준의 작업으로 변환 과정을 단축시킵니다. 벤치마크 결과에 따르면 다음과 같은 성능 향상이 확인되었습니다.
| Benchmark | 성능 향상 |
|---|---|
tuple(genexpr) |
1.07x faster |
(*genexpr,) |
1.05x faster |
(*[listcomp],) |
1.08x faster |
특히 (*[listcomp],)와 같이 리스트 컴프리헨션을 언패킹하여 튜플을 만드는 경우 약 8%의 유의미한 속도 향상을 보였습니다.
2. 컴파일러의 보장을 활용한 'Zero-cost' 추상화
이 최적화가 가능한 이유는 INTRINSIC_LIST_TO_TUPLE 바이트코드가 생성되는 맥락을 컴파일러가 완벽히 제어하고 있기 때문입니다. 컴파일러는 이 리스트가 방금 만들어진 임시 객체라는 것을 알고 있습니다.
반면, 일반적인 tuple([1, 2, 3]) 호출은 이 최적화의 대상이 아닙니다. 벤치마크 결과에서도 tuple([listcomp])는 성능 변화가 없었는데(not significant), 이는 해당 구문이 tuple이라는 전역 이름을 찾아 함수를 호출하는 일반적인 경로를 타기 때문입니다. 이번 최적화는 컴파일러가 의도를 명확히 파악할 수 있는 특정 패턴에 대해서만 정밀하게 적용되었습니다.
3. 메모리 효율성
복사 방식은 일시적으로 리스트와 튜플이 동일한 객체들을 동시에 참조하게 만들어 메모리 피크(peak)를 높일 수 있습니다. 'Stealing' 방식은 리스트의 자원을 즉시 재활용하므로 메모리 사용량 측면에서도 더 유리합니다.
일반적인 교훈
- 임시 객체는 자산이다: 시스템 프로그래밍(C/C++)에서 말하는 '이동 생성자(Move Constructor)'의 개념이 파이썬 내부 구현에도 적용된 사례입니다. 수명이 짧은 객체의 자원을 재사용하는 것은 성능 최적화의 기본입니다.
- Context-Specific Optimization: 모든
list -> tuple변환을 최적화하려 했다면 참조 카운트 체크 등 복잡한 로직이 필요했겠지만, 컴파일러가 생성한 특정 바이트코드(Intrinsic)에 집중함으로써 안전하고 단순하게 최적화를 달성했습니다. - Assertion의 중요성:
_PyObject_IsUniquelyReferenced와 같은 체크를 통해 내부 개발자가 최적화의 전제 조건을 명확히 인지하도록 돕습니다.
마치며
이번 변경은 파이썬 코드 자체를 바꾸지 않아도, 우리가 흔히 쓰는 tuple(...)이나 starred expression(*)의 성능이 내부적으로 어떻게 개선될 수 있는지를 잘 보여줍니다. 파이썬 3.14를 사용하게 된다면, 여러분의 코드는 보이지 않는 곳에서 조금 더 빨라져 있을 것입니다.
참고 자료
- https://docs.python.org/3/c-api/tuple.html#c.PyTuple_FromArray
- https://docs.python.org/3/c-api/list.html
- https://github.com/python/cpython/blob/main/Include/internal/pycore_list.h
⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.
관련 포스트
PR Analysis 의 다른글
- 이전글 [loki] Grafana Loki: Range Aggregation 성능 최적화와 메모리 할당 감소
- 현재글 : [cpython] CPython 성능 최적화: 임시 리스트를 튜플로 변환할 때의 '아이템 스틸' 기법
- 다음글 [triton] AMD GPU에서 불필요한 워프 로드를 제거하여 성능을 최적화한 Triton PR 분석
댓글