[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
- Python Garbage Collector Documentation
- gc — Garbage Collector interface
- gc.get_stats()
- CPython Source Code - gc.c
참고 자료
- https://docs.python.org/3/reference/datamodel.html#garbage-collection
- https://docs.python.org/3/library/gc.html
- https://docs.python.org/3/library/gc.html#gc.get_stats
- https://github.com/python/cpython/blob/main/Python/gc.c
⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.
관련 포스트
- [cpython] Python 토크나이저 최적화: 불필요한 개행 문자 변환 건너뛰기로 성능 개선
- [cpython] CPython 성능 최적화: re.split의 리스트 빌드 과정 개선하기
- [cpython] CPython unicodedata.normalize() 최적화: Py_UCS4 버퍼 직접 조작으로 성능 향상
- [cpython] CPython 성능 최적화: io.BufferedReader.readline()에 memchr() 도입
- [cpython] CPython `PyFloat_Pack/Unpack2` 최적화: 네이티브 `_Float16` 활용으로 성능 향상
PR Analysis 의 다른글
- 이전글 [uvicorn] Uvicorn의 리로드 패턴 최적화: O(N²)에서 O(N log N)으로의 성능 개선
- 현재글 : [cpython] Python GC, 불필요한 순회 제거로 33% 성능 향상!
- 다음글 [flashinfer] FlashInfer Blackwell MSA 커널 최적화: CAKE 기반의 성능 개선 분석
댓글