본문으로 건너뛰기

[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' 방식은 리스트의 자원을 즉시 재활용하므로 메모리 사용량 측면에서도 더 유리합니다.


일반적인 교훈

  1. 임시 객체는 자산이다: 시스템 프로그래밍(C/C++)에서 말하는 '이동 생성자(Move Constructor)'의 개념이 파이썬 내부 구현에도 적용된 사례입니다. 수명이 짧은 객체의 자원을 재사용하는 것은 성능 최적화의 기본입니다.
  2. Context-Specific Optimization: 모든 list -> tuple 변환을 최적화하려 했다면 참조 카운트 체크 등 복잡한 로직이 필요했겠지만, 컴파일러가 생성한 특정 바이트코드(Intrinsic)에 집중함으로써 안전하고 단순하게 최적화를 달성했습니다.
  3. Assertion의 중요성: _PyObject_IsUniquelyReferenced와 같은 체크를 통해 내부 개발자가 최적화의 전제 조건을 명확히 인지하도록 돕습니다.

마치며

이번 변경은 파이썬 코드 자체를 바꾸지 않아도, 우리가 흔히 쓰는 tuple(...)이나 starred expression(*)의 성능이 내부적으로 어떻게 개선될 수 있는지를 잘 보여줍니다. 파이썬 3.14를 사용하게 된다면, 여러분의 코드는 보이지 않는 곳에서 조금 더 빨라져 있을 것입니다.


참고 자료

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

댓글

관련 포스트

PR Analysis 의 다른글