You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Perf: GC tracking takes a global lock per allocation; refcounts always atomic — free-threading-friendly mitigations #44
Two costs the new GC + free-threaded object model imposes on every thread, even single-threaded code:
Allocation-time GC tracking serializes on a global lock.GcState::track_object (crates/vm/src/gc_state.rs ~285) does generation_lists[0].write() (a global PyRwLock) + two SeqCstfetch_adds + a maybe_collect check for every tracked allocation, and untrack_object repeats it on death. Everything with HAS_TRAVERSE, an instance dict, or a heap type is tracked (crates/vm/src/object/core.rs ~2279) — that includes every Frame (i.e. every Python call) and every user-class instance. Under threads this is a contention point; single-threaded it's still a lock + SeqCst RMWs in the hottest allocation paths.
Every refcount operation is an atomic RMW.RefCount::inc = fetch_add(Relaxed), dec = fetch_sub(Release) + acquire fence (crates/common/src/refcount.rs ~105/153). The interpreter is refcount-saturated (stack pushes/pops, argument passing, code/globals clones per call), so this is a diffuse tax over everything — one big reason the loop baseline (acc += 1 per iteration) is 4.6x CPython even before any "interesting" work happens. LOAD_FAST_BORROW-style elision already helps but only covers local loads.
Suggested direction (keeping free threading — that's the point of this design)
For (1):
Per-thread gen-0 nursery lists that are only merged (or stolen) at collection time — allocation becomes a lock-free thread-local list push; this mirrors what the per-thread datastack already does for frames.
Relax the counters from SeqCst to Relaxed (they only feed the collection heuristic).
Defer tracking for objects that usually die immediately: e.g. track a Frame only when it escapes (generator/traceback/sys._getframe) — a frame that never escapes can live and die untracked. (Or remove the per-call Frame allocation entirely, per the call-overhead issue, which makes this moot for calls.)
For (2), the proven free-threading-compatible option is biased refcounting (Choi et al., what CPython 3.13t ships): each object stores an owner thread id + a non-atomic local refcount for the owner, plus an atomic shared count for other threads. The owner thread — the overwhelmingly common case — does plain increments. This slots into the existing bit-packed RefCount design, and could be combined with deferred refcounting for objects referenced from the datastack (also a 3.13t technique; the borrow-bit PyStackRef machinery already present is halfway there).
Also worth auditing while in here: the object header is ~48 bytes before payload (refcount + vtable + gc bits/generation + two intrusive GC list pointers + type pointer) for every object including untracked ints/strs. Moving the GC link pointers out of untracked objects (side allocation or only for tracked types) would cut memory traffic across the board.
None of this changes user-visible semantics; it's all about making the no-GIL design pay less rent on the fast path.
Researched and written by Claude on behalf of @youknowone. Part of the performance tracking series.
plain-call incremental overhead (f(): pass loop minus inline loop)
58.9 ns/call
34.1 ns/call
1.6 ns/call
fib(28)
~140 ms
~135 ms
13.6 ms
user-class instance allocation loop (C())
~148 ns/iter
~152 ns/iter
30.8 ns/iter
loop baseline (acc += 1, refcount-saturated)
~82 ns/iter
~90 ns/iter
13.1 ns/iter
Takeaways:
"Defer tracking for Frames / remove the per-call Frame allocation" is done via Perf: Python→Python call overhead is ~15x CPython (eager frame lifecycle + recursive eval loop) #40: the fast call path now runs on datastack InterpreterFrames that are never heap-allocated as objects at all — no track_object/untrack_object, no generation-list lock, per call. A FrameObject (tracked) is materialized only on escape (tracing, generators, sys._getframe, traceback). That's most of the 58.9 → 34.1 ns/call improvement.
Point 1 for non-frame objects is unchanged: every user-class instance still takes the global generation_lists[0].write() lock + SeqCst counters — the C() loop didn't move (~150 ns/iter vs CPython's 30.8, ~5x). Per-thread nursery lists + relaxed counters remain open.
Activity
youknowone commentedon Aug 5, 2026
Status update (2026-08-05) — effect of the call-overhead work (#40)
With #40's PRs merged (RustPython#8354, RustPython#8434, RustPython#8448, RustPython#8431), one item from this issue's suggestions is now effectively done, the rest is untouched. Same-machine A/B (
main@ 1c7759c vs pre-RustPython#8354 04c3ecf, identical scripts, interleaved runs, Apple M5 Max moderately loaded, CPython 3.14.5 reference):f(): passloop minus inline loop)C())acc += 1, refcount-saturated)Takeaways:
InterpreterFrames that are never heap-allocated as objects at all — notrack_object/untrack_object, no generation-list lock, per call. AFrameObject(tracked) is materialized only on escape (tracing, generators,sys._getframe, traceback). That's most of the 58.9 → 34.1 ns/call improvement.generation_lists[0].write()lock + SeqCst counters — theC()loop didn't move (~150 ns/iter vs CPython's 30.8, ~5x). Per-thread nursery lists + relaxed counters remain open.PyStackRefborrow-bit machinery from Perf: Python→Python call overhead is ~15x CPython (eager frame lifecycle + recursive eval loop) #40 is the natural foundation for the deferred half.— commented by Claude