Skip to content

gc(measurement): poll-time moving buys ~nothing the 16 MB nursery cap does not already buy — delete the loop back-edge poll, do not build pin-based poll moving #7056

Description

@proggeramlug

Measurement run to settle one decision: under the plan to replace shadow-stack precise roots with a conservative stack scan plus per-object pinning, the shadow stack's only remaining unique job is enabling the moving minor at loop back-edge polls. If poll-time moving buys ~nothing, that arm gets deleted outright; if it buys something real, pin-based poll moving has to be built first.

Bottom line

Poll-time moving buys ~nothing. Delete the arm; do not build pin-based poll moving.

Every gram of the footprint benefit attributed to the loop back-edge poll is actually the 16 MB nursery cap — a pacing number that merely shares an enable gate with the poll. Two independent isolations show it:

  • Keep the poll, raise the cap to 128 MB: peak RSS becomes identical to the kill switch on 7 of 7 workloads — while the poll is still firing and still relocating a million objects.
  • Drop the poll, keep the 16 MB cap: peak RSS is better than shipped (231.9 MB vs 272.2 MB) at the same wall time, copying more objects.

Meanwhile the poll's codegen half costs up to 3.7× on tight loops, 19 of 24 suite benchmarks never collect at all, and on the server-shaped workload the shadow stack contributed 3 rewritten slots across 63 moving minors that relocated 10.6 million objects.

Recommendation: delete the loop back-edge poll, decouple the 16 MB cap from gc_moving_loop_polls_enabled() and keep it. See §9 for what this does not settle — in particular it is not a verdict on conservative scanning, which §6–§7 measure separately and which #6981 has already shown is load-bearing for correctness.

Separately and more urgently: this run turned up a deterministic wrong answer produced by the shipped default configuration on an ordinary server-shaped program. It is not a poll bug — the discriminator is whether a copying minor runs at all. Filed separately as #7055; see §10.


Provenance

Host perry-macos — Mac mini, 8-core M1, 8 GB, macOS 26.5.1, idle
Load average 1.5–2.8 throughout, recorded per run inside every result JSON (loadavg_start/loadavg_end)
Commit e279b2d54, detached worktree, isolated CARGO_TARGET_DIR
Build cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static
perry aeac6a6e562cf6b13e585b7e49e53c136c97d9dc1a8493ef81ec10717ea2d08a
libperry_runtime.a 58a83af57503a84e1163e041f64aa355476b3354c16e7acb22b3a362f95a39cf
libperry_stdlib.a 645fb9c483d46bb5fe4846b401f42dbcc2bc846b9c5763e3cf3ae6cdccf9d282
Oracle ~/node-v26.5.0-darwin-arm64/bin/node, asserted v26.5.0
PERRY_NO_AUTO_OPTIMIZE not set — production path, not the deterministic one

Instrumentation is benchmarks/gc_ratchet/gc_ratchet.py's run_once (peak RSS from this child's os.wait4 ru_maxrss, not RUSAGE_CHILDREN) plus the per-cycle JSON from PERRY_GC_TRACE=1. Arms are interleaved — repeat i runs every arm before repeat i+1 — so drift in machine state perturbs all arms equally. Run-to-run spread is reported next to every delta; nothing below is claimed as a difference unless it exceeds the spread, and most of it exceeds the spread by 50–300×.

The stale-archive check, adapted

The usual failure mode — linking a stale .a so both arms behave identically — cannot occur here, because both arms link the same archives. PERRY_GC_MOVING_LOOP_POLLS changes the compiled TypeScript, not the runtime. The correct analogue is that the compiled binaries must differ, so I checked something stronger than a hash: I counted call void @js_gc_loop_safepoint() in the emitted LLVM IR (--trace llvm) for every workload under every arm. Every workload: N>0 with polls on, exactly 0 with polls off.

That check earned its place. 05_fibonacci reports binaries_differ=true with poll_sites=0/0 — differing bytes, no behavioural difference. A hash comparison alone would have called that arm "changed".

One run discarded

My first driver compiled the off arm correctly but ran it without the runtime env, producing exactly the incoherent mixed mode gc_moving_loop_polls_enabled's doc comment warns about: codegen emits no poll, the runtime still defers collections to a poll that never drains. It measured +14.3% wall / +50.6% peak RSS vs shipped on w1_srv_pumpthe opposite sign from the real kill switch (−71.6% / +54.9%). Discarded; every number below is from the corrected driver. Recording it because mixed-mode being genuinely bad is a real datum, and because it is a good example of a plausible-looking A/B returning a confidently wrong answer.


What flipping the flag actually changes

Read by two processes; moves four things at once, which is why a single on/off flip cannot attribute anything.

  1. codegen (stmt/loops.rs:5227) — whether js_gc_loop_safepoint() exists in the binary at all. Part of the .o cache key, so no stale-object hazard.
  2. runtime, routing (policy.rs:1294) — alloc-point triggers defer to a poll instead of collecting inline non-moving.
  3. runtime, pacing (policy.rs:80-102) — the nursery is capped at 16 MB; with the flag off it reverts to the legacy adaptive trigger with a 128 MB ceiling.
  4. runtime, eligibility (copying.rs:26-51) — CopyingPointerSet::new eagerly builds the malloc registry; without it the always-on event-loop-safepoint moving minor falls back to non-moving whenever malloc-tracked objects exist. Workload-wide, nothing to do with loops.

Arms:

arm polls (codegen) polls (runtime) nursery cap
on on on 16 MB — shipped
off off off legacy 128 MB — full kill switch
off_rt on off legacy 128 MB — codegen frozen
on_cap128 on on 128 MB — isolates pacing
off_scav on off 16 MB via PERRY_GC_SCAVENGE=1 — isolates the cap

Also worth recording, because the doc comments disagree with the code: both gc_moving_loop_polls_enabled (policy.rs:369) and moving_safepoint_polls_enabled (loops.rs:5227) open with a paragraph saying the poll is opt-in/default-off, then a second paragraph saying DEFAULT ON. The code is unambiguously default-on. The stale first paragraphs are worth deleting before they mislead the campaign.


1. Primary A/B — benchmark suite (24 --full benchmarks, 5 repeats, interleaved)

Median Δwall, off vs on: −20.6% (range −76.5% … +1.1%). Median Δpeak RSS: 0.0% (range −7.7% … +160.6%). Spread on the affected benchmarks is 0.2–1.5%.

benchmark polls on/off on ms off ms Δ off_rt ms spread
bench_buffer_readwrite 12/0 416.7 98.0 −76.5% 381.6 1.22%
13_factorial 2/0 400.8 97.7 −75.6% 368.4 1.13%
02_loop_overhead 2/0 396.5 97.6 −75.4% 363.7 0.88%
14_closure 2/0 201.1 50.6 −74.8% 184.1 0.62%
06_math_intensive 2/0 200.2 52.9 −73.6% 183.6 0.62%
04_array_read 3/0 97.6 42.6 −56.4% 93.8 0.89%
10_nested_loops 6/0 102.7 51.5 −49.9% 97.0 0.33%
15_mandelbrot 6/0 39.6 24.9 −37.2% 36.6 1.09%
bench_json_roundtrip 5/0 330.3 261.8 −20.7% 265.5 0.43%
bench_gc_pressure 5/0 37.9 35.5 −6.4% 37.8 1.35%
bench_string_heavy 5/0 66.9 67.7 +1.1% 67.7 0.72%

Correctness: every arm exits 0 and every checksum is byte-identical to Node 26.5.0. (The suite prints its own timing line, so a raw stdout diff is red by construction — the checksums are what was compared.)

19 of the 24 benchmarks never collect at all (minor_cycles = 0 in every arm), yet 22 of 24 carry poll sites. For those 19 the poll is overhead that can never do any work.

2. Which half costs: codegen or runtime

off_rt runs the polls-on binary with the runtime flag off — call sites present, body returns immediately.

  • 02_loop_overhead: on 396.5 → off_rt 363.7 → off 97.6 ms. The poll body costs 8%; the mere existence of the call costs 3.7×. That is the vectorization defeat the doc comment predicts, measured.
  • bench_json_roundtrip: on 330.3 → off_rt 265.5 → off 261.8 ms. The reverse — runtime half ~20%, call sites ~1%.

Why so many non-allocating loops get a poll: loop_purity::loop_may_allocate's whitelist accepts Compare{Eq,Ne} but none of the relational operators, accepts LocalSet but not GlobalSet, and no arithmetic Binary at all. for (let i = 0; i < N; i++) { sum = sum + 1 } therefore fails purity on its loop condition, and the "only emit the poll for allocating loops" mitigation misfires on 22 of 24 suite benchmarks. Cheap and separable win regardless of what happens to the poll.

3. Server-shaped workloads

Nothing in the repo is simultaneously server-shaped and sync-stretch-heavy: the batch benchmarks have long synchronous allocation stretches but no event loop; honest_bench's Fastify workload has an event loop but trivial handlers (and needs oha, not installed). So I wrote three, each diffed byte-for-byte against Node 26.5.0:

  • w1_srv_pump — event-loop-driven request pump, 600 requests; each handler builds 1,500 rows + tags + JSON in one long synchronous stretch, then yields via setImmediate. Ordinary for loops.
  • w2_srv_pump_while — identical, every hot loop a while (generic lowering path).
  • w3_mono_sync — one long synchronous allocating stretch that never reaches the event loop.
workload polls on off Δwall off_rt peak RSS on → off spread
w1_srv_pump 7/0 6746 ms 1918 ms −71.6% 1927 ms 271.9 → 421.1 MB (+54.9%) 0.89%
w2_srv_pump_while 5/0 6278 ms 2007 ms −68.0% 2021 ms 240.5 → 426.6 MB (+77.4%) 1.02%
w3_mono_sync 10/0 104.0 ms 76.5 ms −26.5% 84.7 ms 27.8 → 66.0 MB (+136.9%) 1.29%

off ≈ off_rt here, so on server-shaped work the cost is entirely the runtime half. Read naively this says the shipped configuration is 3.2–3.5× slower for 1.5–2.4× less peak RSS, and the trade is at least arguable. §4 is why it is not the poll's trade to make.

On w1_srv_pump the shipped arm spends 5.97 s of its 6.76 s wall inside GC pauses (88%), across 63 minors that copied 10.6 M objects.

4. The decisive isolation: it is the cap, not the poll

Same binary; only the runtime env differs. on_cap128 keeps the poll fully enabled and only raises the nursery cap.

workload on (16 MB) on_cap128 (128 MB) off_rt
w1_srv_pump 6756 ms / 272.2 MB 2678 ms / 422.1 MB 1935 ms / 421.1 MB
w2_srv_pump_while 6303 ms / 242.0 MB 3242 ms / 434.4 MB 2017 ms / 426.6 MB
w3_mono_sync 104.2 ms / 27.8 MB 84.8 ms / 66.0 MB 76.5 ms / 66.0 MB
bench_json_roundtrip 330.8 ms / 94.5 MB 265.4 ms / 100.4 MB 265.2 ms / 100.4 MB
bench_string_heavy 67.0 ms / 22.4 MB 67.5 ms / 58.4 MB 67.5 ms / 58.4 MB
bench_gc_pressure 38.0 ms / 21.7 MB 37.7 ms / 24.2 MB 37.9 ms / 24.2 MB
02_loop_overhead 403.6 ms / 4.0 MB 402.9 ms / 4.0 MB 369.4 ms / 4.0 MB

on_cap128 peak RSS equals off_rt peak RSS on all seven, to within 0.2–1.8%, inside spread. And not because the poll stopped working: on w1_srv_pump, on_cap128 still ran 5 moving minors and copied 1,040,734 objects while off_rt copied 0. A million relocated objects bought 1 MB out of 422 MB.

The mirror-image arm closes it. off_scav keeps the 16 MB cap with the poll off (reached via PERRY_GC_SCAVENGE=1, the other gate on effective_next_arena_trigger):

workload on (poll + 16 MB) off_scav (no poll + 16 MB)
w1_srv_pump 6764 ms / 272.2 MB / 10,640,721 copied 6718 ms / 231.9 MB / 11,066,933 copied
w3_mono_sync 104.4 ms / 27.8 MB / 16,365 copied 104.3 ms / 27.7 MB / 16,390 copied
bench_string_heavy 67.0 ms / 22.4 MB 68.8 ms / 22.6 MB
bench_json_roundtrip 331.2 ms / 94.5 MB 372.3 ms / 94.8 MB

Without any loop poll, the cap alone delivers equal or better footprint at equal or near-equal throughput, while evacuating more. (Caveat: PERRY_GC_SCAVENGE is documented measurement-only and also switches the alloc-point minor to precise roots, so this is not a clean single-variable move. It shows a poll-free configuration reaching the footprint; the clean single-variable evidence is on_cap128 above.)

Decomposing the shipped-vs-kill-switch gap on w1_srv_pump (6756 → 1935 ms):

  • nursery cap 16 MB → 128 MB: 4078 ms, 84% of the gap
  • poll / deferral / eager-registry: 743 ms, 16% of the gap
  • of the 149 MB peak-RSS difference, the poll accounts for 1 MB

The mechanism is unremarkable once stated: peak RSS is set by how large the nursery may grow before a collection triggers — that is the cap. Whether the collection then relocates survivors or sweeps them in place does not move the high-water mark.

5. Does footprint stay bounded? (the property that matters for a server)

Same binary, request count swept. A terminating benchmark cannot distinguish "cheap because efficient" from "cheap because it deferred the work past process exit", so the question is the shape.

Peak RSS, MB — the two 16 MB-cap arms are grouped, then the two 128 MB-cap arms:

requests on (poll, 16 MB) off_scav (no poll, 16 MB) on_cap128 (poll, 128 MB) off_rt (no poll, 128 MB)
150 162.8 168.7 174.8 174.8
300 236.9 219.0 309.1 309.1
600 271.9 267.3 384.6 422.0
1200 277.0 307.5 620.2 648.6

The curves group by cap, not by poll. The 16 MB arms plateau together (1.7–1.8× over an 8× request increase); the 128 MB arms grow together (3.5–3.7×) and would not survive a process that never exits. The poll is on in one member of each pair and off in the other, and it makes no difference to which curve the arm lands on.

(The 1200-request row is the noisiest point in this report — wall-time spread 7.9% on on and 13.6% on on_cap128, against 0.2–1.9% everywhere else. Read it for the shape, not the digits; the shape is a 2.2× separation between the cap groups, far outside that noise.)

So the correct reading of §3 is not "the poll bounds footprint" but "the 16 MB cap bounds footprint, costs the throughput, and is gated behind a flag named for polls".

6. Per-minor conservative-stack-scan cost

One binary, only the runtime env differs, stack depth from argv. phase_us["root_marking"] is shared by four subphases so it cannot isolate the scan alone — but in the precise arm that accumulator reads 0 µs on every automatic cycle at every depth, so differencing against it isolates the scan exactly.

live stack frames root_marking µs (median/cycle) conservative roots/cycle conservative_pinned_bytes (max/cycle)
0 551.5 11.2 3,104
250 608.0 205.5 11,136
1,000 834.5 768.5 35,128
2,500 1,275.5 1,893.8 95,168

Clean linear fits:

  • scan cost ≈ 543 µs + 0.29 µs per live stack frame
  • conservative roots ≈ 15 + 0.75 per frame
  • bytes pinned ≈ 1.5 KB + 37 B per frame

For scale, the shipped arm's entire median minor pause is 726–828 µs. A conservative scan adds roughly one whole current minor pause of pure root work before collecting anything, ~1.5× at a server-ish 2,500 frames.

The direct cost is not the important number. The consequential one is: with the scan on, copying_nursery.fallback_reason is conservative_stack and copied_objects = 0 on every cycle of every probe. Today a conservative scan does not degrade the copying minor — it disables it. Median pause 828 µs → 14,745 µs (17.8×); total GC pause 22.3 ms → 58.6 ms on the same workload. On w1_srv_pump, forcing the scan takes wall 6752 → 11,805 ms and peak RSS 272.5 → 413.6 MB.

7. False retention, and what it means for pinning

Reproduced on an independent build; 8 gc_ratchet probes, heapUsed after an explicit gc():

probe precise conservative ratio
01_nursery_churn 0.77 MB 3.57 MB 4.6×
02_survivor_promotion 4.79 MB 24.07 MB 5.0×
03_cross_gen_writes 0.67 MB 6.19 MB 9.2×
04_dead_after_deep_stack 0.95 MB 8.76 MB 9.2×
05_closure_capture 0.99 MB 8.00 MB 8.1×
06_string_retention 0.71 MB 4.53 MB 6.4×
07_array_grow_evacuate 1.73 MB 94.76 MB 54.8×
08_map_set_sidetables 0.44 MB 6.44 MB 14.6×

+360% to +5378% — the recorded 364%–5371% band, reproduced independently on a different build. Peak RSS rises 44%–274% alongside.

The finer characterisation asked for is the gap between what gets pinned and what gets retained, and the two are orders of magnitude apart:

  • pinned: 328 B – 419,624 B per cycle across the 8 probes; ≤3.7% of the retained heap, usually far less. On w1_srv_pump, 13,280 B against a 413 MB heap.
  • retained: 4.6×–54.8× the precise baseline. At depth 2,500, 1,894 conservative roots pin 95 KB directly but retain ~3.1 MB extra.

This cuts against the intuition the question is usually asked with. Pinning does not reduce retention. A conservative root marks its object live whether or not you also pin it; pinning only preserves the movability of everything else. So the retention blow-up is the unavoidable price of conservative rooting, pinned or not — and what the pinned-bytes figures actually say is the good news: 96–99% of the heap would remain movable.

That mechanism already exists and works, for the old generation. The cons_evac arm shows the old-gen evacuator pinning 7,537 objects (95 KB) under a conservative scan and still relocating 435 KB in the same cycle. It is only the nursery copying minor that bails outright on conservative_stack. Pin-based moving is therefore not novel machinery — it is machinery that exists one generation over.

8. What the shadow stack is actually carrying

From root_sources in the per-cycle trace:

  • w1_srv_pump, shipped arm, 68 cycles / 63 moving minors that relocated 10,640,721 objects: compiled_shadow contributed 199 slots scanned, 79 pointer roots, 3 rewritten slots — for the entire run. runtime_mutable_scanners contributed 1,451,297 slots and 354,421 pointer roots over the same run. The shadow stack is 0.02% of pointer roots.
  • w3_mono_sync: 3–4 shadow slots per cycle.
  • scan_cost at depth 2,500: 105,028 slots, 35,000 roots, 10,000 rewritten — about 31% of roots.

So the shadow stack's contribution is essentially a function of JS stack depth with live locals: negligible on server-shaped work, substantial under deep recursion. It is load-bearing precisely where a conservative scan is also most expensive (§6), which is the real tension in the campaign and is not resolved by this measurement.

9. Recommendation

Delete the loop back-edge poll arm. Do not build pin-based poll moving.

  1. Held at a constant cap, the poll's peak-RSS contribution is 1 MB out of 422 MB while it relocates a million objects, and it costs 0–38% of throughput (§4).
  2. A poll-free configuration with the same cap gets better footprint at equal throughput while evacuating more (§4), and tracks the bounded curve as requests scale (§5).
  3. The poll's codegen half costs up to 3.7× on tight loops, and 19 of 24 suite benchmarks never collect at all (§1–§2).
  4. The shadow stack's unique remaining job is worth 3 rewritten slots across 63 moving minors on the workload it exists to serve (§8).

Concretely: decouple the 16 MB cap from gc_moving_loop_polls_enabled() in effective_next_arena_trigger and keep the cap — it is what buys the footprint and it is what costs the throughput; that trade should be made explicitly and tunably, not as a side effect of a flag named for polls. If the poll survives for a reason this measurement does not cover, fix the loop_may_allocate whitelist gaps in §2 first — they are most of the codegen cost and independent of everything else here.

10. Incidental, and more urgent than the above

While building the request-count sweep I hit a deterministic wrong answer produced by the shipped default configuration. Same binary, same input, only the GC runtime env differs:

arm minors copied objects checksum @150 checksum @600
on (shipped) 8 915,961 -90240474 1986912948
on_cap128 0 / 3 0 -341887099 1735266323
off_rt 0 / 1 0 -341887099 1735266323
off_scav 8 1,045,097 -90240474 1986912948

Node 26.5.0 says -341887099 / 1735266323. Deterministic across repeats in every arm.

The discriminator is exactly copied_objects > 0 — a relocating minor. Not the poll, not the cap, not minor_cycles: at 600 requests on_cap128 runs 3 minors and gets the right answer because none of them copied anything. The error is a constant 251,646,625 at 150, 300, 600 and 1200 requests — one corruption of fixed magnitude, occurring once, early, independent of workload length.

This is the #6951/#6972/#6981/#6982 defect class — a reference held across a relocating collection — reproducing in the shipped default, with native_stack_fallback.decision = skip_disabled on 62 of 63 cycles, i.e. with no conservative scan masking it. #6981 concluded the forced scan at gc_check_trigger was what kept this class out of production; on the deferred/moving path measured here, that mask is not present.

It is also a concrete instance of exactly what #6993 says the per-PR gate cannot catch — and it needs no special arm at all, just the default configuration. Filed separately with a reproducer: #7055.

11. What this does not settle, and what I could not measure

  • Not a verdict on conservative scanning. §6–§7 price it at 543 µs + 0.29 µs/frame and as the thing that disables the copying minor outright (17.8× pause). Those costs stand whatever happens to the poll, and gc: a relocating minor with PRECISE roots breaks 14 of 20 representation-corpus files (5 crashes, 9 mismatches) — the conservative stack scan is load-bearing for correctness #6981 has already shown the scan is currently load-bearing for correctness. Deleting the shadow stack still has to answer that.
  • off_scav is not a clean single-variable arm. It reaches the 16 MB cap via a flag that also switches alloc-point roots to precise. The clean evidence for "it's the cap" is on_cap128; off_scav corroborates by construction from the other side.
  • No real socket server. node:http needs libperry_ext_http.a, which a -p perry -p perry-runtime-static -p perry-stdlib-static build does not produce — the link fails on _js_node_http_create_server_with_options / _js_node_http_server_listen. My workloads are event-loop-driven request pumps with heavy synchronous handlers (the shape that matters here) but do not exercise the accept loop, sockets, or the tokio paths.
  • No isolated timing for the conservative scan. Four subphases share the root_marking accumulator (cycle.rs:544-549). Differencing against a zero-valued precise arm works today but would not survive a change that makes precise root marking non-trivial.
  • No exact false-retention counter. conservative_root_count / conservative_pinned are structurally over-inclusive — the conservative subphase runs before the precise ones every cycle, so they count objects precise roots would have reached anyway. Upper bounds.
  • Pin-based nursery moving does not exist, so its cost could not be measured directly; the old-gen evacuator's pinning is the proxy §7 uses.
  • The setImmediate-driven workloads are not bit-deterministic in their GC scheduling, unlike the gc_ratchet probes. w5 at 600 requests measured 248.6 MB peak RSS in one session and 271.9 MB in another (58 vs 56 minors, 11.64 M vs 11.66 M copied). Within-session spread is 0.2–1.9%; between-session peak-RSS drift on these workloads is up to ~9%. That is far below the effects being claimed (55–137%), but it means single-digit peak-RSS differences on the async workloads should not be read as signal. The on_cap128-vs-off_rt equality in §4 rests on 0.0% differences on five workloads and 0.24%/1.83% on the other two, all measured within one session.
  • Single-threaded only (spawn / parallelMap arenas untested). Single host, single architecture (arm64 macOS).

Related: #6981, #6993, #6950, #6977, #6982.

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