Summary
gc_check_trigger runs the budgeted due-ladder — 436 instructions, measured exactly under callgrind — once per gc_malloc, in every workload. This is the residue left after #10659 was closed won't-fix: the epoch-memo approach there is structurally refuted (the ladder's inputs are the allocation counters, so an epoch keyed on them bumps on every allocation and the memo's hit rate is zero), and the poll-frequency lever is regex-local and handled at its own call sites.
This issue is for the remaining, general cost, and for the one design shape that looks viable.
It is now costed, on a workload that matters
A profile of RegExp.prototype.exec (2M calls, control-subtracted, by the session that owns perex) attributes:
gc_budgeted_due_trigger_eval — 3.68%
gc_budgeted_step_work_units_inner_with_progress — 1.58%
copying_from_space_in_use_bytes — 1.48%
≈6.7% of every exec call. And it is not the safepoint poll: at 2M calls with #10494's 1-in-64 stride the poll cannot account for it. It is gc_check_trigger firing once per gc_malloc, and exec allocates several objects per call, so it pays the 436 several times over.
Since #10695 measures regex as 63% of a realistic program's runtime, ~6.7% of exec is roughly 4% of whole-program time on that workload — a concrete figure rather than "everywhere, forever".
Why the cost is what it is
The 436 is not arithmetic. It is ~13 thread-local lookups (enumerated in trigger_path_hot_slot_indices, policy.rs:3362-3422), plus a RefCell borrow for malloc_object_count, plus the from-space memo read. The arms themselves are already counter >= watermark comparisons — total >= next_arena_trigger_base(), malloc_count >= GC_NEXT_MALLOC_TRIGGER.
So the ladder re-gathers a dozen quantities in order to evaluate a handful of comparisons that are mostly not close to firing.
The shape that looks viable: a watermark, not a memo
Collapse the arms into a single "next check at" scalar plus one counter, so the common case is one load and one compare.
For the young-generation arm specifically there is an existing precedent that costs nothing: alloc_sample::inline_limit (arena/alloc_sample.rs:162-171) already clamps the inline allocator's limit word to force a return to the runtime at a chosen byte count. That limit is compared on every allocation already. Setting state.size = min(block.size, offset + (cap − sealed)) at each sync/reset point makes "is the nursery cap due" a branch the allocator is already taking — zero added instructions on the compiled fast path, which is the constraint that killed every other approach (the inline bump sequence is seven instructions, emitted directly into LLVM IR at perry-codegen/src/lower_call/new_alloc.rs:528, with no call into the runtime).
Known complication: js_inline_arena_slow_alloc would need an extra arm to distinguish a trip-wire from a genuine block-full, since try_alloc_current would succeed and skip gc_check_trigger. That arm is off the inline path.
Not costed
I have not costed the redesign itself, and the per-arm work of collapsing the ladder is unknown. What is measured is the prize (436 per gc_malloc; ~6.7% of exec; ~4% of a regex-heavy real program) and the existence of a zero-cost mechanism for the hottest arm.
Anything built here must not add instructions to the compiled inline bump sequence. That is #10377's lesson twice over, and the reason the obvious incremental-counter approaches were rejected in #10659.
Related: #10659 (closed won't-fix, with the full refutation of the memo approach), #10695, #10494, #10377.
Summary
gc_check_triggerruns the budgeted due-ladder — 436 instructions, measured exactly under callgrind — once pergc_malloc, in every workload. This is the residue left after #10659 was closed won't-fix: the epoch-memo approach there is structurally refuted (the ladder's inputs are the allocation counters, so an epoch keyed on them bumps on every allocation and the memo's hit rate is zero), and the poll-frequency lever is regex-local and handled at its own call sites.This issue is for the remaining, general cost, and for the one design shape that looks viable.
It is now costed, on a workload that matters
A profile of
RegExp.prototype.exec(2M calls, control-subtracted, by the session that owns perex) attributes:gc_budgeted_due_trigger_eval— 3.68%gc_budgeted_step_work_units_inner_with_progress— 1.58%copying_from_space_in_use_bytes— 1.48%≈6.7% of every
execcall. And it is not the safepoint poll: at 2M calls with #10494's 1-in-64 stride the poll cannot account for it. It isgc_check_triggerfiring once pergc_malloc, andexecallocates several objects per call, so it pays the 436 several times over.Since #10695 measures regex as 63% of a realistic program's runtime, ~6.7% of
execis roughly 4% of whole-program time on that workload — a concrete figure rather than "everywhere, forever".Why the cost is what it is
The 436 is not arithmetic. It is ~13 thread-local lookups (enumerated in
trigger_path_hot_slot_indices,policy.rs:3362-3422), plus aRefCellborrow formalloc_object_count, plus the from-space memo read. The arms themselves are alreadycounter >= watermarkcomparisons —total >= next_arena_trigger_base(),malloc_count >= GC_NEXT_MALLOC_TRIGGER.So the ladder re-gathers a dozen quantities in order to evaluate a handful of comparisons that are mostly not close to firing.
The shape that looks viable: a watermark, not a memo
Collapse the arms into a single "next check at" scalar plus one counter, so the common case is one load and one compare.
For the young-generation arm specifically there is an existing precedent that costs nothing:
alloc_sample::inline_limit(arena/alloc_sample.rs:162-171) already clamps the inline allocator's limit word to force a return to the runtime at a chosen byte count. That limit is compared on every allocation already. Settingstate.size = min(block.size, offset + (cap − sealed))at each sync/reset point makes "is the nursery cap due" a branch the allocator is already taking — zero added instructions on the compiled fast path, which is the constraint that killed every other approach (the inline bump sequence is seven instructions, emitted directly into LLVM IR atperry-codegen/src/lower_call/new_alloc.rs:528, with no call into the runtime).Known complication:
js_inline_arena_slow_allocwould need an extra arm to distinguish a trip-wire from a genuine block-full, sincetry_alloc_currentwould succeed and skipgc_check_trigger. That arm is off the inline path.Not costed
I have not costed the redesign itself, and the per-arm work of collapsing the ladder is unknown. What is measured is the prize (436 per
gc_malloc; ~6.7% ofexec; ~4% of a regex-heavy real program) and the existence of a zero-cost mechanism for the hottest arm.Anything built here must not add instructions to the compiled inline bump sequence. That is #10377's lesson twice over, and the reason the obvious incremental-counter approaches were rejected in #10659.
Related: #10659 (closed won't-fix, with the full refutation of the memo approach), #10695, #10494, #10377.