Skip to content

Keep a worker's imported heap out of its cycle collections - #34

Merged
dhruvl merged 1 commit into
mainfrom
gc-freeze
Aug 4, 2026
Merged

Keep a worker's imported heap out of its cycle collections#34
dhruvl merged 1 commit into
mainfrom
gc-freeze

Conversation

@dhruvl

@dhruvl dhruvl commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Profiling the jobqueue campaign found 38.6% of a seed's wall time inside the
gc.collect() in run_until_complete (_loop.py:558) — 2.4 ms a call, twice
per seed. The collection is load-bearing: it is what finalizes a dropped
failing task's reference cycle so its exception reaches the loop instead of
vanishing. What it should not be doing is re-walking the imported heap.

Measured in a warmed worker: 20,091 of 22,791 tracked objects are modules,
classes and functions a run cannot make garbage, and a full collection walks
all of them every time. gc.freeze() moves them to the permanent generation,
which collections never traverse. A run's own cycles are all allocated after
the freeze, so the collection still sees everything it saw before — the
collect drops from 1.65 ms to under a microsecond.

Worker throughput on the green campaign roughly doubles: 32.5 to 67.8
seeds per CPU-second, +108%
(median of 5 interleaved 2,500-seed runs, 10
jobs, every tree's own spread under 1.5%; reproduced in two separate
sessions at +109.7% and +108.3%). Wall clock went 225 to 439 seeds/s on a
laptop that was not idle, which is why the CPU-second figure is the one to
read.

The freeze happens in simloop's own spawned workers, on the first batch —
after the workload's module is imported, once, so a later call cannot make a
run's garbage permanent. It never touches the calling process: jobs=1
never reaches this path, and neither does the parent of a parallel run.

Verified:

  • 566 passed with -m "slow or not slow" (565 before, +1 new test), mypy
    strict clean.
  • No recorded trace hash moves — the change adds no callback and no
    scheduling event, and the digest guards are green.
  • 2,000-seed ablations run before and after are identical in every recorded
    field except elapsed time: same failing seeds, same labels, same densities.
  • New test drops a failing task inside a parallel explore and requires the
    worker to report it. It fails if the collection is removed, so it guards
    the guarantee rather than restating it.

Two things deliberately left out:

  1. Rejected: gating the collection on whether any task holds an
    unretrieved exception. It skips 100% of collections on the green campaign
    and is 1.5x faster, but it is not sound — a loop.create_future() set with
    an exception and dropped is a cyclic orphan that is reported today and is
    not in the loop's task registry. Confirmed by direct test, not by argument.
  2. Not shipped: the same freeze in-process, which would take the
    sequential sweep from 77.6 to 111.8 seeds/s (+44%). It works, but it
    freezes the caller's heap, and a library making that global a change to
    someone's pytest process is your call, not mine.

Note benchmarks/README.md still records the green campaign at 263.1
seeds/s. That number is now low, but this laptop is not idle and PR #33 is
exactly about not publishing absolute throughput measured in that state —
worth re-running on an idle machine alongside the version stamp before
tagging. Merging #33 first avoids touching the same section twice.

@dhruvl
dhruvl merged commit 90b9b9c into main Aug 4, 2026
11 checks passed
@dhruvl
dhruvl deleted the gc-freeze branch August 4, 2026 17:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant