From c9d25fa6a21242f669e757e9e0ab1dee7af05560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 02:44:11 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(gc-ratchet):=2010=5Fstore=5Freceiver=5F?= =?UTF-8?q?across=5Falloc=20runs=20no=20minor=20collection=20=E2=80=94=20g?= =?UTF-8?q?ive=20it=20margin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On `main` this probe reports `minor_cycles = 0`. It allocated just enough to cross the nursery threshold exactly once, and #8313 — shrinking a two-field object from 56 to 40 bytes, a change we want — dropped it under. No evacuating minor runs, so the three conditions the probe's own header says must all hold cannot bite, and the probe measures nothing. It is the only probe covering stale-root-across-evacuation (#6970 / #9523), a class this project has shipped real bugs in twice. It has been inert for part of 2026-08-18..09-06 and nobody saw it, because `gc-ratchet` was red on `main` for unrelated reasons that whole time (#9829) and its failures are unwatched (#9830). Measured on `main` @ d36a1af0c, three repeats each: ITERATIONS minor_cycles wall_ms 200,000 0 26 <- as shipped 600,000 2 47 1,200,000 4 81 2,400,000 9 147 <- chosen 2,400,000 keeps several evacuating minors after a further 8x reduction in bytes per object, for ~120 ms on `wall_ms`, which the gate does not band. Verified by sabotage, not just by the number moving: removing the allocating RHS — condition (3) in the probe's header — returns `minor_cycles=0 copied_objects=0 freed_bytes=0`, the exact signature the probe had while broken. So the biting condition is load-bearing and observable, and this is a restoration of coverage rather than of counters. Side effect worth recording: `heap_used_bytes` goes 464,072 -> 244,648, against a pinned baseline of 220,384. The +110.57 % that this cell showed on `main` was never retention — it was the post-`gc()` residue of a run in which nothing was ever collected. `gc_ratchet.py` refuses to pin a baseline whose `minor_cycles < 1`, so the inert probe could not have been blessed by a re-pin; it will however pin `minor_cycles == 1`, which is the marginal state that caused this. The header now records that invariant. This unblocks the #9829 re-pin, which cannot be assembled while a probe is inert. Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m --- .../probes/10_store_receiver_across_alloc.ts | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/benchmarks/gc_ratchet/probes/10_store_receiver_across_alloc.ts b/benchmarks/gc_ratchet/probes/10_store_receiver_across_alloc.ts index ba1e98be34..8a3cafad4a 100644 --- a/benchmarks/gc_ratchet/probes/10_store_receiver_across_alloc.ts +++ b/benchmarks/gc_ratchet/probes/10_store_receiver_across_alloc.ts @@ -33,7 +33,41 @@ declare function gc(): void; const SLOTS = 1024; -const ITERATIONS = 200000; + +// ITERATIONS EXISTS TO GIVE THIS PROBE MARGIN, NOT TO MAKE IT LONGER. +// +// This probe went **inert on main for part of 2026-08-18..09-06** and nobody +// noticed: `minor_cycles` fell from 1 to 0, so no evacuating minor ran, so the +// three conditions below could not bite and the probe measured nothing. It +// still "passed" everything except a gc-ratchet gate that was already red for +// unrelated reasons (#9829, #9832), which is why it went weeks undetected. +// +// The cause was margin, not a bug: at 200,000 iterations the probe allocated +// just enough to cross the nursery threshold exactly once. #8313 shrank a +// two-field object from 56 to 40 bytes — a change everyone wants — and that +// alone dropped the total under the threshold. **A probe that fires exactly one +// collection is one optimisation away from firing none**, and any future +// allocation win re-creates this silently. +// +// Measured on `main` @ d36a1af0c, 40-byte objects, three repeats each: +// +// ITERATIONS minor_cycles wall_ms +// 200,000 0 26 <- inert, shipped for weeks +// 600,000 2 47 +// 1,200,000 4 81 +// 2,400,000 9 147 +// +// 2,400,000 is chosen so the probe still runs several evacuating minors after a +// further 8x reduction in allocated bytes per object. The cost is ~120 ms on a +// metric the gate does not band (`wall_ms`), which is the cheapest insurance in +// the suite. +// +// INVARIANT, and please check it if you touch this file: this probe must report +// `minor_cycles >= 2`. `gc_ratchet.py` refuses to PIN a baseline whose +// `minor_cycles < 1`, so a fully inert probe cannot be blessed — but it will +// happily pin `minor_cycles == 1`, which is the marginal state that produced +// this outage. One is not margin. +const ITERATIONS = 2400000; // (1) module-level, so the receiver is loaded from a global handle rather than // a shadow slot. From 22c07ac5b44c5ff6a988257067110c7451da0223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 02:45:07 +0200 Subject: [PATCH 2/2] docs(changelog): fragment for PR 9833 Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m --- changelog.d/9833-probe10-margin.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 changelog.d/9833-probe10-margin.md diff --git a/changelog.d/9833-probe10-margin.md b/changelog.d/9833-probe10-margin.md new file mode 100644 index 0000000000..faebd01afb --- /dev/null +++ b/changelog.d/9833-probe10-margin.md @@ -0,0 +1,29 @@ +**The `10_store_receiver_across_alloc` GC-ratchet probe was running no +collection at all, and has been given margin** (#9833, fixes #9832). + +The probe exists to catch a store receiver held in a register across an +evacuating minor — the stale-root class of #6970 / #9523 — and its own header +lists three conditions that must all hold for it to bite, the third being an +allocating right-hand side. On `main` it reported `minor_cycles = 0`: no minor +ran, so no evacuation happened, so there was no window and the probe measured +nothing. `freed_bytes = 0` alongside `copied_objects = 0` rules out "a minor ran +and found nothing live". + +The cause was margin rather than a bug. At 200,000 iterations the probe crossed +the nursery threshold exactly once, and #8313 — shrinking a two-field object +from 56 to 40 bytes — put it under. A probe that fires exactly one collection is +one optimisation away from firing none. It is now 2,400,000 iterations, which +measured 9 minors and keeps several after a further eightfold reduction in bytes +per object, for about 120 ms on `wall_ms`, which the gate does not band. + +Verified by sabotage rather than by the counter moving: removing the allocating +RHS returns `minor_cycles=0 copied_objects=0 freed_bytes=0`, the exact signature +the probe had while broken. + +`heap_used_bytes` returns from 464,072 to 244,648 against a pinned baseline of +220,384 — the +110.57 % that cell showed on `main` was the post-`gc()` residue +of a run in which nothing was ever collected, not retention. + +Five further probes (`01`, `02`, `03`, `09`, `11`) currently sit at +`minor_cycles == 1` and are one allocation win away from the same silent state; +that is recorded in #9832 and not addressed here.