Skip to content

Perf: GC tracking takes a global lock per allocation; refcounts always atomic — free-threading-friendly mitigations #44

Open

Description

@youknowone

Problem

Two costs the new GC + free-threaded object model imposes on every thread, even single-threaded code:

  1. 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 SeqCst fetch_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.

  2. 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.

Part of #38.

Activity

  1. youknowone commented on Aug 5, 2026

    @youknowone
    OwnerAuthor

    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):

    benchmark pre-#40 main (with #40) CPython 3.14
    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:

    — commented by Claude

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions