본문으로 건너뛰기

[cpython] Python GC, 불필요한 순회 제거로 33% 성능 향상!

PR 링크: python/cpython#159154 상태: Merged | 변경: +31 / -35

들어가며

Python의 가비지 컬렉터(GC)는 메모리 관리에 필수적인 요소입니다. 하지만 GC 과정에서 발생하는 오버헤드는 애플리케이션의 전반적인 성능에 영향을 미칠 수 있습니다. 이번에 살펴볼 python/cpython 레포지토리의 gh-159137 PR은 Python GC의 핵심 부분에서 불필요한 객체 순회를 제거하여 상당한 성능 개선을 이뤄낸 사례입니다.

기존 Python GC는 도달 불가능한(unreachable) 객체를 식별한 후, 살아남은(surviving) 객체들을 대상으로 두 번의 추가적인 순회를 수행했습니다. 한 번은 튜플의 추적을 해제하기 위한 untrack_tuples() 호출에서, 다른 한 번은 살아남은 객체의 수를 세기 위한 gc_list_size() 호출에서였습니다. 이 PR은 move_unreachable() 함수가 이미 살아남은 객체들을 방문한다는 점에 착안하여, 이 두 가지 작업을 move_unreachable() 내부로 통합함으로써 불필요한 두 번의 순회를 제거합니다.

핵심 변경 분석

이 최적화의 핵심은 Python/gc.c 파일 내의 move_unreachable 함수에 있습니다.

1. move_unreachable 함수의 기능 확장

move_unreachable 함수는 원래 도달 가능한 객체들을 young 리스트에 유지하고, 도달 불가능한 객체들을 unreachable 리스트로 옮기는 역할을 했습니다. 이 PR에서는 이 함수가 살아남은 객체들을 처리하는 과정에서 튜플 추적 해제와 객체 수 세기 작업을 동시에 수행하도록 변경되었습니다.

Before:

static void
move_unreachable(PyGC_Head *young, PyGC_Head *unreachable)
{
    // ...
            prev = gc;
    // ...
}

After:

static Py_ssize_t
move_unreachable(PyGC_Head *young, PyGC_Head *unreachable)
{
    Py_ssize_t reachable = 0; // 새로 추가된 변수
    // previous elem in the young list, used for restore gc_prev.
    PyGC_Head *prev = young;
    PyGC_Head *gc = GC_NEXT(young);

    while (gc != young) {
        PyObject *op = FROM_GC(gc);
        PyGC_Head *next = GC_NEXT(gc);
        if (gc_is_reachable(op)) {
            _PyGCHead_SET_PREV(gc, prev);
            // gc is not COLLECTING state after here.
            gc_clear_collecting(gc);
            if (PyTuple_CheckExact(op)) {
                // Untracking gc overwrites the gc_refs of the next object.
                PyGC_Head *next_gc_head = GC_NEXT(gc);
                uintptr_t next_refs = next_gc_head->_gc_prev;
                _PyTuple_MaybeUntrack(op);
                next_gc_head->_gc_prev = next_refs;
                if (gc->_gc_next != 0) { // 리뷰어 피드백 반영
                    prev = gc;
                    reachable++;
                }
            }
            else {
                prev = gc;
                reachable++;
            }
        }
        // ... (unchanged unreachable handling)
        gc = next;
    }
    young->_gc_prev = (uintptr_t)prev;
    // don't let the pollution of the list head's next pointer leak
    unreachable->_gc_next &= ~NEXT_MASK_UNREACHABLE;
    return reachable; // 살아남은 객체 수 반환
}

변경 후 move_unreachable 함수는 Py_ssize_t를 반환하여 살아남은 객체의 수를 알려줍니다. PyTuple_CheckExact(op)를 통해 현재 객체가 튜플인지 확인하고, 튜플이라면 _PyTuple_MaybeUntrack(op)를 호출하여 추적을 해제합니다. 이 과정에서 gc->_gc_next != 0 조건이 추가되었는데, 이는 _PyTuple_MaybeUntrack 호출 후 튜플이 GC 리스트에서 완전히 제거되어 _gc_next가 0으로 설정될 수 있는 엣지 케이스를 처리하여, 실제로 GC 리스트에 남아있는 객체만 reachable로 카운트하고 prev를 업데이트하기 위함입니다. 살아남은 객체는 reachable 변수를 증가시켜 수를 셉니다.

2. untrack_tuples 함수의 제거

move_unreachable에서 튜플 추적 해제 로직을 통합했으므로, 기존에 별도로 존재하던 untrack_tuples 함수는 더 이상 필요 없게 되어 완전히 제거되었습니다.

Before:

static void
untrack_tuples(PyGC_Head *head)
{
    PyGC_Head *gc = GC_NEXT(head);
    while (gc != head) {
        PyObject *op = FROM_GC(gc);
        PyGC_Head *next = GC_NEXT(gc);
        if (PyTuple_CheckExact(op)) {
            _PyTuple_MaybeUntrack(op);
        }
        gc = next;
    }
}

After: (해당 함수 자체가 삭제됨)

-static void
-untrack_tuples(PyGC_Head *head)
-{
-    PyGC_Head *gc = GC_NEXT(head);
-    while (gc != head) {
-        PyObject *op = FROM_GC(gc);
-        PyGC_Head *next = GC_NEXT(gc);
-        if (PyTuple_CheckExact(op)) {
-            _PyTuple_MaybeUntrack(op);
-        }
-        gc = next;
-    }
-}

3. deduce_unreachable 및 gc_collect_main 함수의 변경

move_unreachable의 시그니처가 변경되고 반환값이 추가됨에 따라, 이를 호출하는 deduce_unreachable 함수와 GC의 메인 로직인 gc_collect_main 함수도 수정되었습니다.

deduce_unreachable Before:

static inline Py_ssize_t
deduce_unreachable(PyGC_Head *base, PyGC_Head *unreachable) {
    // ...
    move_unreachable(base, unreachable);  // gc_prev is pointer again
    // ...
}

deduce_unreachable After:

static inline Py_ssize_t
deduce_unreachable(PyGC_Head *base, PyGC_Head *unreachable,
                   Py_ssize_t *reachable) { // reachable 포인터 추가
    // ...
    // gc_prev is pointer again
    *reachable = move_unreachable(base, unreachable); // 반환값 사용
    // ...
}

gc_collect_main 함수에서는 deduce_unreachable 호출 후 untrack_tuples 호출이 사라지고, gc_list_size(young) 대신 deduce_unreachable에서 얻은 reachable 값을 직접 사용합니다.

gc_collect_main Before:

    stats.candidates = deduce_unreachable(young, &unreachable);

    untrack_tuples(young);
    /* Move reachable objects to next generation. */
    if (young != old) {
        if (generation == NUM_GENERATIONS - 2) {
            gcstate->long_lived_pending += gc_list_size(young);
        }
        gc_list_merge(young, old);
    }
    // ...
    gcstate->long_lived_total = gc_list_size(young);

gc_collect_main After:

    Py_ssize_t reachable; // reachable 변수 선언
    stats.candidates = deduce_unreachable(young, &unreachable, &reachable);

    /* Move reachable objects to next generation. */
    if (young != old) {
        if (generation == NUM_GENERATIONS - 2) {
            gcstate->long_lived_pending += reachable; // reachable 사용
        }
        gc_list_merge(young, old);
    }
    // ...
    gcstate->long_lived_total = reachable; // reachable 사용

이러한 변경을 통해 GC는 살아남은 객체 수를 세기 위해 gc_list_size를 호출하는 추가적인 순회도 피할 수 있게 됩니다.

왜 이게 좋은가?

성능 향상

이 최적화의 가장 큰 장점은 명확한 성능 향상입니다. PR 설명에 따르면, 다양한 워크로드에서 GC 시간이 27.1%에서 47.7%까지 감소했으며, 기하 평균(Geometric mean)으로 33.3%의 GC 시간 단축을 달성했습니다. 이는 불필요한 두 번의 객체 리스트 순회(튜플 추적 해제, 살아남은 객체 수 세기)를 제거하고, move_unreachable 함수 내에서 단 한 번의 순회로 모든 작업을 처리했기 때문입니다.

Workload GC before GC after Less GC time
Sphinx building the CPython docs 27.956 s 20.387 s 27.1%
Pylint on five stdlib packages 2.740 s 1.607 s 41.4%
Parso parsing the standard library 1.397 s 0.860 s 38.4%
sqlglot parsing and transpiling 4,000 queries 4.248 s 2.900 s 31.7%
Beautiful Soup parsing 90 HTML pages 2.122 s 1.485 s 30.0%
Black checking three stdlib packages 1.643 s 1.460 s 11.1%
Full collection of 720,000 live AST objects 292.4 ms 153.0 ms 47.7%
Geometric mean 33.3%

자원 효율성

순회 횟수가 줄어들면서 CPU 사이클 사용량도 감소합니다. Pylint 벤치마크의 Flame Graph에서도 GC 사이클이 현저히 줄어든 것을 확인할 수 있습니다. 이는 특히 대규모 객체 그래프를 처리하는 애플리케이션에서 더욱 두드러진 효과를 가져올 것입니다.

코드 간결성 및 유지보수성

untrack_tuples 함수가 제거되고 관련 로직이 move_unreachable로 통합되면서, GC 로직이 더 응집력 있고 이해하기 쉬워졌습니다. 이는 향후 GC 코드의 유지보수에도 긍정적인 영향을 미칠 수 있습니다.

일반적인 교훈

이 최적화는 "데이터를 한 번 순회할 때 가능한 많은 작업을 수행하라"는 소프트웨어 최적화의 중요한 원칙을 잘 보여줍니다. 동일한 데이터 구조를 여러 번 순회하는 것은 종종 성능 병목의 원인이 되며, 이를 단일 순회로 통합하는 것은 매우 효과적인 최적화 전략입니다.

리뷰어 피드백 반영

sergey-miryanov는 이 변경에 대해 긍정적인 의견을 표명하며, 작은 도달 불가능 집합(unreachable set)의 경우 전체 힙을 한 번 더 스캔하는 것보다 이득이 크다고 언급했습니다. 특히 PyTuple_CheckExact 조건문 내에서 if (gc->_gc_next != 0)와 같은 추가적인 검사를 피할 수 있는지에 대한 논의가 있었습니다. 이는 _PyTuple_MaybeUntrack 호출이 gc_refs를 변경할 수 있는 가능성 때문에 발생한 것으로 보입니다. pablogsal은 이 피드백을 반영하여 코드를 수정했습니다. 이러한 논의는 엣지 케이스를 고려하고 코드의 견고성을 높이는 데 기여했습니다.

References

참고 자료

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

댓글

관련 포스트

PR Analysis 의 다른글