Repository navigation
Conversation
Every tracked allocation pushed onto the one shared gen0 list under its write lock and bumped a shared atomic count, and every dealloc took the lock again to unlink: three to four lock-prefixed read-modify-writes per object lifetime, on the hottest allocation path. A thread attached to an interpreter now keeps its own young list and touches it with no lock and no atomic read-modify-write. A stop-the-world cannot park an attached thread outside a safepoint, so nothing else reads the list while its owner uses it. Everyone else (the owner while detached, a collector, thread exit) takes the list's own lock, and a collector only does so with the world stopped. A collection starts by splicing every young list into the shared gen0 list, so the rest of the collector, gc.get_objects() and gc.freeze() keep walking the shared lists only. An object carries its list id in gc_refs, which is unused outside a collection, under a new GC_LOCAL_YOUNG generation. A thread that deallocates an object linked into another thread's young list cannot unlink it. It still runs the finalizer, clears weakrefs and drops the fields right away, then hands the bare memory back through the owner's husk queue; the owner unlinks and frees husks on its next tracked allocation, a collection does at its start, and thread exit does before the list is retired. Untracking a live object in another thread's list (only reachable through the C API) stops the world and splices first. Each young list keeps its own count delta and folds it into the shared gen0 count once it drifts by 64, so the collection threshold stays within that much per thread of the truth; gc.get_count() adds the deltas in. gc.get_objects(), gc.freeze() and that C API path stop the world outside a collection, so they take the collecting lock first (waiting detached): two requesters stopping the world at once deadlock, because the loser waits for the admission lock while still attached. Assisted-by: Claude Code Claude-Session: https://claude.ai/code/session_01GKcHXRR8VEWRsXXMxp1nWK
default_dealloc checks for everything any type may need: the class's __del__ slot, a weakref list, GC tracking and the trashcan, a clear function, then instance dict and member slots before the freelist. An exact int, float or complex needs none of it. Their classes are static and immutable, so they have no __del__ and no dict and never gain one, they do not support weakrefs, and the payloads hold no references, so they are never tracked and have nothing to clear. PyPayload::TRIVIAL_EXACT_DEALLOC says so for a payload, and the vtable then points at trivial_exact_dealloc: for an instance of exactly T::class() it goes straight to the freelist (or PyInner::dealloc for a published object); a subclass instance, which can have all of those, falls through to default_dealloc. Picking the function in the vtable rather than branching early inside default_dealloc keeps the common case out of the generic path's stack frame. Assisted-by: Claude Code Claude-Session: https://claude.ai/code/session_01GKcHXRR8VEWRsXXMxp1nWK
PyPayload::into_ref cloned the class for PyRef::new_ref, which checks it for an instance dict and a heap type, and when the object came off the freelist dropped the clone again because the husk already pointed at the same class: an atomic increment and decrement of int's refcount for every int built, on a line every thread building ints shares. For a payload that sets TRIVIAL_EXACT_DEALLOC, into_ref now goes to PyRef::new_trivial_exact. Its class has no dict and its instances are never tracked, so there is nothing to check, and since both dealloc paths only put exact instances on the freelist, a reused husk already holds the class reference the new object needs and keeps it. The int, float and complex freelists also stop moving their vector out of the thread-local Cell and back on every push and pop, which copied it twice and dropped the empty list left behind; FreeList::push_to and pop_from work on it in place. Assisted-by: Claude Code Claude-Session: https://claude.ai/code/session_01GKcHXRR8VEWRsXXMxp1nWK
Merging this PR will improve performance by 14.41%
Performance Changes
Tip Curious why performance improved? Comment Comparing Footnotes
|
|
<!-- pyperformance-local-diff --> pyperformance: base vs. head vs. CPython (same runner)All three interpreters were measured back to back in this one job, so they share the exact same CPU and kernel. Ratios are
|
One of checkbox below must be checked.
Summary
성능 실험 ④ GC 추적 스레드 로컬화와 타입별 해제 특화의 구현과 측정 결과입니다. 커밋 3개로 나뉘어 있고, 각 커밋의 효과를 따로 쟀습니다.
1. 가설 (원문 인용)
제안:
__del__·약한 참조·trashcan 검사를 건너뛰고 프리리스트로 바로 가는 전용 해제 경로를 둡니다.new_int/new_float생성 경로(프리리스트 pop, 헤더 초기화)를 다듬습니다.2. 구현 요약, 설계 선택, 한계
(a)
Keep gen0 in thread-local young listsgc_state::young::LocalYoung)를 가지며, 락이나 원자적 RMW 없이 push와 unlink를 합니다. 이 방식이 안전한 이유: stop-the-world는 attach된 스레드를 safepoint에서만 멈추는데, 리스트 연산 안에는 safepoint가 없습니다. 그 밖의 접근자(detach된 소유자, 수집기, 스레드 종료 처리)는 모두 리스트별 lock을 잡고, 수집기는 world가 멈춘 상태에서만 접근합니다. "attach 여부"는 새 캐시 포인터CURRENT_THREAD_STATE의 relaxed load 한 번으로 확인합니다.gc.get_objects(),gc.freeze()는 전과 같이 공유 리스트만 봅니다. 객체는GC_LOCAL_YOUNG(=4) 세대 표시와 함께, 수집 중이 아닐 때는 쓰이지 않는gc_refs필드에 리스트 id를 담습니다. 헤더 크기는 바뀌지 않습니다.__del__, weakref 콜백, 필드 drop은 그 스레드에서 즉시 수행하고(의미 보존), 메모리만 소유자의 husk 큐로 돌려줍니다. 해당 리스트에서 unlink하고 해제하는 쪽은 소유자(다음 추적 할당 때), 수집 시작, 스레드 종료 처리입니다. 이 경로를 위해PyInner::dealloc을drop_into_raw+RawAlloc::free로 나눴습니다.PyObject_GC_UnTrack로만 도달 가능)는 STW 후 splice하고 처리합니다. 드문 경로입니다.counts[0]에 합칩니다. 임계치 판정은 공유값 + 자기 증감분으로 하고,gc.get_count()는 모든 증감분을 더해 보고합니다.get_objects,freeze, 위 C API 경로)는 먼저collecting락을 detach 상태로 기다려 잡습니다. 처음에는 이 처리가 없어서vm::interpreter::tests를 병렬로 돌리면 교착이 생겼습니다. STW 요청자 둘이 동시에 들어오면 진 쪽이 attach된 채 admission 락을 기다리기 때문입니다. 수정 후 해당 테스트를 반복 실행해 통과를 확인했습니다.gc.disable()상태면 더 오래). (2) VM이 없는 스레드가 다른 스레드의 young 객체를 살아 있는 상태로 untrack하는 경우는 STW를 할 수 없어 안전하지 않습니다. 인터프리터 내부에는 이런 호출 경로가 없고 debug_assert로 막아 두었습니다. (3) VM 없이get_objects를 부르면 young 객체가 보이지 않습니다(STW 불가). (4) non-threading 빌드는 기존 경로를 그대로 씁니다.(b)
Give exact int, float and complex a dealloc of their ownPyPayload::TRIVIAL_EXACT_DEALLOC을 추가하고 int, float, complex에 설정했습니다. 이 타입들의 클래스는 static/immutable이라__del__을 가질 수 없고(설정 시 TypeError), weakref를 지원하지 않으며(TypeError 확인), dict/slots가 없습니다. 페이로드도 참조를 갖지 않으므로 GC 추적 대상이 아니고 clear할 것도 없습니다(HAS_TRAVERSE/HAS_CLEAR가 false인지 const assert로 검사).trivial_exact_dealloc를 가리킵니다. 클래스가 정확히T::class()이면 곧바로 프리리스트로 보내고, published 객체는 QSBR 지연 해제가 되는PyInner::dealloc으로 보냅니다. 서브클래스 인스턴스는default_dealloc으로 넘어갑니다.default_dealloc안에서 일찍 분기하지 않고 vtable에서 함수를 고르는 이유는, 범용 경로의 6-레지스터 prologue를 피하기 위해서입니다.(c)
Build exact ints and floats without touching their class's refcountinto_ref가int타입(immortal이 아님)을 clone하고, 프리리스트에서 재사용하면 다시 drop했습니다. 정수를 하나 만들 때마다lock xadd가 두 번 들어갔습니다.PyRef::new_trivial_exact는 프리리스트 husk가 이미 가진 클래스 참조를 그대로 이어받습니다. 두 dealloc 경로 모두 exact 인스턴스만 프리리스트에 넣으므로 가능합니다. dict/추적 검사도 생략합니다.Cell::take/set으로 Vec을 빼고 다시 넣던 방식을FreeList::push_to/pop_from의 제자리 접근으로 바꿨습니다.3. 판정: 가설 확인 (단, 메커니즘에 관한 단서가 있음)
into_ref<PyInt>67 → 40, 정수 dealloc 96 → 57 (b) → 37 (c)입니다.new_int래퍼(약 19~22)를 거치는 경로는 약 96입니다. 실수도 같은 구조입니다(body_float반복당 1046 → 864).cmp.sh, 기준 대비): obj_alloc 1.12×, nqueens 1.13×, float_arith 1.14× (1차 측정 1.13/1.12/1.12×). 가설 범위(1.1~1.3×) 안이고, 28개 벤치 기하평균은 0.933(약 1.07×)입니다.4개를 없앱니다. valgrind는12% 줄었습니다. 반면 float_arith와 int_arith의 이득은 (b)와 (c)의 명령어 감소(전체 −20%, −13%)에서 옵니다.lock cmpxchg를 명령어 하나로 세므로 이 효과가 드러나지 않습니다. 실제로 전체 명령어 수는 obj_alloc +0.8%, nqueens +1.2%인데도 perf의 track/untrack 비중은 14.3% → 2.0%(obj_alloc), 12.0% → 1.4%(nqueens)로 줄었고, wall-clock도 11lock xadddecref 직후 refcount 워드를 읽는 지점에 몰려 있습니다. 해제 로직이 아니라 원자적 참조 카운트 자체의 비용이며, 편향 참조 카운트 세션의 영역입니다.4. 측정
환경: 같은 컨테이너(4 vCPU VM, HW 카운터 없음 → perf는
-e cpu-clock), rustc 1.98.1,cargo build --release(기본 threading 빌드). 기준 = 분기 시점 커밋7cd4a54(별도 worktree/target). CPython 3.11.15.bench/cmp.sh BASE PATCHED(5회 최소값, 시작 시간 차감), 최종 브랜치 기준startup: base=.045 patched=.044 cpy=.014
기하평균: patched/base 0.933, base/cpy 6.11, patched/cpy 5.71.
(같은 비교의 1차 측정(교착 수정 전 커밋 (c)): 기하평균 0.932, obj_alloc 0.883, nqueens 0.891, float_arith 0.892, recursion 1.056, kwargs_call 1.038.)
반복당 x86 명령어 수 (
bench/instr_per_iter.sh, valgrind lackey, f(200000) − f(0))loop_simple(x = i)body_int(x = i * 7)body_float(y = x * 1.0000001)body_tuple(t = (i, n))body_list(l = [i])body_obj(P(i, i),__init__에서 속성 2개)호출당 명령어 수 (callgrind,
body_int/body_list)into_ref<PyInt>new_int22 = 185)new_int19 = 96)track_new_object전체 명령어 수 (callgrind, 벤치 전체 실행)
단계별 wall-clock (
wall.sh, 교차 실행 11회 최소값 ms, 시작 시간 포함)perf (
-e cpu-clock, self 비중)untrack_object+track_new_objectuntrack_young0.80% +track_new_object1.17%5. 실행한 테스트와 결과
cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher --exclude rustpython-capi: 전부 통과(rustpython-vmlib 102개 포함). 첫 실행은 위에서 설명한 교착으로vm::interpreter::tests에서 멈췄고, 수정 후 전체를 다시 돌려 통과했습니다. 수정 후vm::interpreter::tests는 10회 반복해 모두 통과했습니다. 중간에 한 번_opcode스냅숏 테스트 8개가 실패했는데, 실행 도중 소스를 수정해 생긴 일이었고 재실행에서 통과했습니다.(cd crates/capi && cargo test): 110개 통과.cargo check -p rustpython-vm --no-default-features --features compiler(non-threading): 경고 없음.cargo run --release -- -m test: test_gc, test_weakref, test_finalization, test_threading, test_thread, test_threading_local, test_queue, test_concurrent_futures, test_int, test_float, test_complex, test_generators, test_sys, test_os 모두 SUCCESS. (test_capi는 이 저장소의Lib/test에 없어서 기준 빌드에서도 import 단계에서 실패하므로 제외했습니다.)gc.collect(0)/gc.get_objects(0), 다른 스레드에서 마지막 참조를 끊을 때의 weakref 콜백): release 빌드에서 반복 통과했고, debug 빌드(debug_assert 활성)에서도 통과했습니다.🤖 Generated with Claude Code
https://claude.ai/code/session_01GKcHXRR8VEWRsXXMxp1nWK