Skip to content

gc: the budgeted ladder costs 436 instructions per poll — why the obvious precheck is unsound, and why it is only ~4-5x anyway #10659

Description

@proggeramlug

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

  1. 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 productcrates/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.

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

Activity

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions