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
gc_runtime_safepoint_poll() evaluates the whole budgeted trigger ladder on every call — 436 instructions to answer "nothing is due" — and on a poll-heavy workload that is ~33% of the program with almost no collection behind it (measured by another session on a String.prototype.replace over 1.1M characters; their null probe puts the ceiling at −39.7%).
The obvious fix is a cheap precheck in front of the ladder. I investigated it and am not proposing it. Filing the analysis because the profile is inviting, the obvious design is unsound in two specific ways, and the payoff is ~5× smaller than a first estimate suggests — so the next person to look at that profile should start from here rather than from scratch.
8df61a817 is not the gap
That commit ("make the 'nothing due' GC check cheap on safepoint polls and trigger checks") does exactly what it says, and all three of its changes are present and working on this path: the out-of-lining is in place, the no-trigger poll returns at gc_idle_step_result() before the cycle machinery, and copying_from_space_in_use_bytes measures 57 instructions per call — it really is O(1).
Its de-duplication was scoped to gc_check_trigger, the allocator-side caller, and that is not an oversight: on that workload gc_check_trigger reaches the ladder 15 times out of 22,031,111. There was nothing there to de-duplicate. 436 instructions is already the cheap version; 33% of a program is 22 million polls multiplied by a small number.
Two soundness constraints, both at one entry point
A precheck must never answer "nothing is due" in a state where the ladder would have fired. Two things break the obvious byte-watermark design, and both are js_gc_memory_pressure — a #[no_mangle] extern "C" the platform host calls:
GC_OLD_RECLAIM_PENDING has a setter that fires without allocation. Two of its three production setters are collection-driven and unreachable from a poll that just answered "nothing due". The third is js_gc_memory_pressure(level >= 2) (pressure.rs:81), and it is live in a shipped product — crates/perry-ui-android/src/lib.rs:230 declares it, so Android's onTrimMemory reaches it. The module doc names the dangerous case itself: pressure arrives when a process is idle, which reaches no loop back-edge and pumps no microtasks. An idle process allocates nothing, so every byte count a cheap precheck samples is unchanged. Omitting this flag means answering "nothing due" right after the OS delivers its final warning — silently regressing what [gc] React to OS memory pressure: didReceiveMemoryWarning / onTrimMemory / PSI / dispatch_source adapters #6184 created that entry point to fix, on the platform least able to report it. Two instructions to close.
next_arena_trigger_base() can move down without a collection. Of its five non-test writers, two tiny-parse sites lower it but are allocation-gated, one only ever raises, one is the post-collection re-baseline — and pressure.rs:69 clamps it to arena_total_bytes() + 1 MiB and arms it, with no allocation. So "bytes have not grown since last time" does not imply "the ladder would not fire": under a static byte count the threshold can be pulled beneath it. One compare and one bool to close.
Generalisable shape: a cheap precheck is blind to exactly the paths that change state without allocating, and those are usually the host-facing ones.
No new counter is needed — but the memo is not 6 loads
Nothing monotone the allocator already maintains fits as a "nothing happened since" signal: HEAP_GENERATION bumps only on layout changes (thousands of allocations pass without moving it, which is why 8df61a817 could key its from-space cache on it), and ARENA_TOTAL_BYTES tracks committed blocks, so it is unchanged across every allocation that fits the current block.
Good news: none is needed. The ladder's inputs are already cheap TLS loads by design, because the ladder already sits on the gc_malloc hot path — arena_total_bytes is documented as "one TLS load instead of an O(blocks) walk on the gc-trigger hot path", and old_free_bytes is a Cell::get in a "hot-cache slot… which gc_budgeted_due_trigger reads on every gc_malloc". So a sound precheck is a memo on the input tuple, touching no allocation path — and the hot-path-bump risk (#10377's shape: help the axis you are watching, tax the one you are not) does not arise.
But the tuple is 10–12 quantities, not 6, and two are not single loads: copying_from_space_in_use_bytes (57 Ir, already near-minimal — it is the bump pointer plus a cached base, and it is the only fine-grained nursery-allocation signal in the system, so a memo must read it) and malloc_object_count (a RefCell borrow plus len). Reading the tuple costs ~90–110 instructions against 436 — about 4–5×, i.e. 33% → ~7–8%, not the 33% → under 1% a 6-load estimate implies.
Why I am not proposing it
Removing polls beats making them cheaper, and that work already exists: #10657 takes the same 33% → ~9% by removing 73% of the polls, with no per-poll cost and no conservatism proof to defend. The precheck lands in the same range while carrying the two external-entry-point terms above plus a memo-invalidation argument that a future edit can silently break.
They do compose — fewer polls × cheaper polls ≈ 33% → ~2% — so this is a reasonable second-order follow-on once #10657 is in. It is not the main event, and I said it was on the strength of a number I had not decomposed.
One more thing for whoever does build it: the ladder is not pure.maybe_seed_object_census_from_allocation sets a flag, walks the young generation (~1M instructions) and replaces the mean feeding the very cap it compares against, once per process. A memo that skips that arm skips the seed. That is still safe — the seed's own trigger is a pure function of an input the memo already carries — but the soundness argument must be made explicitly rather than resting on "the ladder is pure", because it is not.
Original measurement and the null probe are another session's work; the poll-frequency half is #10657.
Summary
gc_runtime_safepoint_poll()evaluates the whole budgeted trigger ladder on every call — 436 instructions to answer "nothing is due" — and on a poll-heavy workload that is ~33% of the program with almost no collection behind it (measured by another session on aString.prototype.replaceover 1.1M characters; their null probe puts the ceiling at −39.7%).The obvious fix is a cheap precheck in front of the ladder. I investigated it and am not proposing it. Filing the analysis because the profile is inviting, the obvious design is unsound in two specific ways, and the payoff is ~5× smaller than a first estimate suggests — so the next person to look at that profile should start from here rather than from scratch.
8df61a817is not the gapThat commit ("make the 'nothing due' GC check cheap on safepoint polls and trigger checks") does exactly what it says, and all three of its changes are present and working on this path: the out-of-lining is in place, the no-trigger poll returns at
gc_idle_step_result()before the cycle machinery, andcopying_from_space_in_use_bytesmeasures 57 instructions per call — it really is O(1).Its de-duplication was scoped to
gc_check_trigger, the allocator-side caller, and that is not an oversight: on that workloadgc_check_triggerreaches the ladder 15 times out of 22,031,111. There was nothing there to de-duplicate. 436 instructions is already the cheap version; 33% of a program is 22 million polls multiplied by a small number.Two soundness constraints, both at one entry point
A precheck must never answer "nothing is due" in a state where the ladder would have fired. Two things break the obvious byte-watermark design, and both are
js_gc_memory_pressure— a#[no_mangle] extern "C"the platform host calls:GC_OLD_RECLAIM_PENDINGhas a setter that fires without allocation. Two of its three production setters are collection-driven and unreachable from a poll that just answered "nothing due". The third isjs_gc_memory_pressure(level >= 2)(pressure.rs:81), and it is live in a shipped product —crates/perry-ui-android/src/lib.rs:230declares it, so Android'sonTrimMemoryreaches it. The module doc names the dangerous case itself: pressure arrives when a process is idle, which reaches no loop back-edge and pumps no microtasks. An idle process allocates nothing, so every byte count a cheap precheck samples is unchanged. Omitting this flag means answering "nothing due" right after the OS delivers its final warning — silently regressing what [gc] React to OS memory pressure: didReceiveMemoryWarning / onTrimMemory / PSI / dispatch_source adapters #6184 created that entry point to fix, on the platform least able to report it. Two instructions to close.next_arena_trigger_base()can move down without a collection. Of its five non-test writers, two tiny-parse sites lower it but are allocation-gated, one only ever raises, one is the post-collection re-baseline — andpressure.rs:69clamps it toarena_total_bytes() + 1 MiBand arms it, with no allocation. So "bytes have not grown since last time" does not imply "the ladder would not fire": under a static byte count the threshold can be pulled beneath it. One compare and one bool to close.Generalisable shape: a cheap precheck is blind to exactly the paths that change state without allocating, and those are usually the host-facing ones.
No new counter is needed — but the memo is not 6 loads
Nothing monotone the allocator already maintains fits as a "nothing happened since" signal:
HEAP_GENERATIONbumps only on layout changes (thousands of allocations pass without moving it, which is why8df61a817could key its from-space cache on it), andARENA_TOTAL_BYTEStracks committed blocks, so it is unchanged across every allocation that fits the current block.Good news: none is needed. The ladder's inputs are already cheap TLS loads by design, because the ladder already sits on the
gc_mallochot path —arena_total_bytesis documented as "one TLS load instead of an O(blocks) walk on the gc-trigger hot path", andold_free_bytesis aCell::getin a "hot-cache slot… whichgc_budgeted_due_triggerreads on everygc_malloc". So a sound precheck is a memo on the input tuple, touching no allocation path — and the hot-path-bump risk (#10377's shape: help the axis you are watching, tax the one you are not) does not arise.But the tuple is 10–12 quantities, not 6, and two are not single loads:
copying_from_space_in_use_bytes(57 Ir, already near-minimal — it is the bump pointer plus a cached base, and it is the only fine-grained nursery-allocation signal in the system, so a memo must read it) andmalloc_object_count(aRefCellborrow pluslen). Reading the tuple costs ~90–110 instructions against 436 — about 4–5×, i.e. 33% → ~7–8%, not the 33% → under 1% a 6-load estimate implies.Why I am not proposing it
Removing polls beats making them cheaper, and that work already exists: #10657 takes the same 33% → ~9% by removing 73% of the polls, with no per-poll cost and no conservatism proof to defend. The precheck lands in the same range while carrying the two external-entry-point terms above plus a memo-invalidation argument that a future edit can silently break.
They do compose — fewer polls × cheaper polls ≈ 33% → ~2% — so this is a reasonable second-order follow-on once #10657 is in. It is not the main event, and I said it was on the strength of a number I had not decomposed.
One more thing for whoever does build it: the ladder is not pure.
maybe_seed_object_census_from_allocationsets a flag, walks the young generation (~1M instructions) and replaces the mean feeding the very cap it compares against, once per process. A memo that skips that arm skips the seed. That is still safe — the seed's own trigger is a pure function of an input the memo already carries — but the soundness argument must be made explicitly rather than resting on "the ladder is pure", because it is not.Original measurement and the null probe are another session's work; the poll-frequency half is #10657.