What happened
05709427da perf(gc): keep drained side bytes in old-reclaim pressure (train 193, v0.5.1571, from #10268) makes a program that builds a large live set and then works over it run three old-generation collection cycles, one of them a full, where it previously ran none. The full frees 59 KB.
On a 1,000,000-iteration RegExp.prototype.test loop the cost is +2,749 instructions per iteration — a 33 % increase — and GC goes from 0.32 % of wall time to 19.3 %.
This was found while measuring #10166 and is not specific to regex: the loop allocates nothing per iteration. What it does is poll safepoints in a program whose live set (1M strings) was built before the loop started.
Measurements
perf stat -e instructions:u, perrymaster (AMD Ryzen 7 7700X, 16 logical cores), release builds from source with --no-auto-optimize, three runs per arm, spread under 0.01 %. The per-call figure subtracts a control program that builds the same million strings and runs no regex; that control is unchanged between the arms (1,314.2 M vs 1,314.3 M, +0.01 %), so the delta is in the loop, not in the setup.
| revision |
|
instructions per iteration |
388d15a5db |
the commit before |
8,315 |
05709427da |
this commit |
11,064 |
b3534565d4 |
start of the same train |
8,315 |
5400dba90c |
train 192 |
8,315 |
26ef9c9973 |
train 193 |
11,064 |
46084f23e4 |
train 196 |
11,064 |
33690c563 |
main today |
10,979 |
Bisected by release-building each revision and running the same probe; every revision before 05709427da reads 8,315 and every revision after reads 11,064.
PERRY_GC_DIAG=1, same probe, same host:
|
before (388d15a5db) |
after (05709427da) |
gc-incremental cycle_starts |
0 |
3 |
steps |
0 |
4,485 |
completions |
0 |
3 |
gc-time step_us |
0 |
113,952 |
gc-time share_permille |
32 (3.2 %) |
193 (19.3 %) |
| wall |
551 ms |
690 ms |
The full collection reports trigger=OldGenBytes kind=full steps=1495 units=3061760 wall_us=38243 **freed=59176** — it traced the whole live set and reclaimed 58 KB.
A sampling profile of the after arm shows where the instructions went: gc::verify::remember_retained_old_to_young_slots 2.3 %, gc::oldgen::IncrementalSweepState::step 2.1 %, gc::verify::remember_evacuated_old_to_young_slot 2.1 %, gc::trace::trace_heap_rewrite_slots 2.0 %, gc::trace::ValidPointerSetBuilder::step_arena_walk 1.9 % — none of which appear above 1.5 % on the before arm.
Why it happens
The commit adds what a non-full collection released since the last full into the old-reclaim pressure term, so that the pacing band holds the live reading down. Its own evidence is a lazily-parsed record loop, where the external side term was genuinely what pushed old-reclaim over its band, and where the change removed six of seven fulls.
This probe is the opposite shape: a large live set, almost no external side bytes, and a long loop that polls safepoints without allocating. There the added term pushes old-reclaim pressure over its band rather than under it, and the collections it triggers are unproductive — 59 KB freed for a full pass over a 52 MB live arena (arena_live=52418768).
That is the failure mode #9589 recorded: a sweep must be priced by the bytes it frees, not by occupancy or by a pressure term that does not predict them.
Reproducer
Save as probe.ts and compile with perry compile probe.ts --no-auto-optimize -o probe:
const vals: string[] = [];
for (let i = 0; i < 1000000; i++) vals.push((i % 2 ? "record_" : "!bad_") + i);
const re = /^[a-z]+_[0-9]+$/;
let c = 0;
for (let i = 0; i < vals.length; i++) if (re.test(vals[i])) c++;
console.log(c);
The control, for the subtraction, replaces re.test(vals[i]) with vals[i].length > 0.
perf stat -e instructions:u ./probe
PERRY_GC_DIAG=1 ./probe 2>&1 | grep -E "gc-incremental|gc-time"
Both arms print 500000, so nothing here is a correctness difference.
What I am not claiming
I have not established which workloads depend on the change's intended effect, so this is not a revert request. The question for whoever owns the pacing work is whether the drained-side-bytes term can be scoped to the case it was measured on — a live external side term that a parse-boundary minor actually drains — rather than added to old-reclaim pressure unconditionally. records_array_1m:sparse should keep its 7-fulls-to-1 win; this probe should keep its zero cycles.
What happened
05709427da perf(gc): keep drained side bytes in old-reclaim pressure(train 193, v0.5.1571, from #10268) makes a program that builds a large live set and then works over it run three old-generation collection cycles, one of them a full, where it previously ran none. The full frees 59 KB.On a 1,000,000-iteration
RegExp.prototype.testloop the cost is +2,749 instructions per iteration — a 33 % increase — and GC goes from 0.32 % of wall time to 19.3 %.This was found while measuring #10166 and is not specific to regex: the loop allocates nothing per iteration. What it does is poll safepoints in a program whose live set (1M strings) was built before the loop started.
Measurements
perf stat -e instructions:u, perrymaster (AMD Ryzen 7 7700X, 16 logical cores), release builds from source with--no-auto-optimize, three runs per arm, spread under 0.01 %. The per-call figure subtracts a control program that builds the same million strings and runs no regex; that control is unchanged between the arms (1,314.2 M vs 1,314.3 M, +0.01 %), so the delta is in the loop, not in the setup.388d15a5db05709427dab3534565d45400dba90c26ef9c997346084f23e433690c563Bisected by release-building each revision and running the same probe; every revision before
05709427dareads 8,315 and every revision after reads 11,064.PERRY_GC_DIAG=1, same probe, same host:388d15a5db)05709427da)gc-incremental cycle_startsstepscompletionsgc-time step_usgc-time share_permilleThe full collection reports
trigger=OldGenBytes kind=full steps=1495 units=3061760 wall_us=38243 **freed=59176**— it traced the whole live set and reclaimed 58 KB.A sampling profile of the after arm shows where the instructions went:
gc::verify::remember_retained_old_to_young_slots2.3 %,gc::oldgen::IncrementalSweepState::step2.1 %,gc::verify::remember_evacuated_old_to_young_slot2.1 %,gc::trace::trace_heap_rewrite_slots2.0 %,gc::trace::ValidPointerSetBuilder::step_arena_walk1.9 % — none of which appear above 1.5 % on the before arm.Why it happens
The commit adds what a non-full collection released since the last full into the old-reclaim pressure term, so that the pacing band holds the live reading down. Its own evidence is a lazily-parsed record loop, where the external side term was genuinely what pushed old-reclaim over its band, and where the change removed six of seven fulls.
This probe is the opposite shape: a large live set, almost no external side bytes, and a long loop that polls safepoints without allocating. There the added term pushes old-reclaim pressure over its band rather than under it, and the collections it triggers are unproductive — 59 KB freed for a full pass over a 52 MB live arena (
arena_live=52418768).That is the failure mode #9589 recorded: a sweep must be priced by the bytes it frees, not by occupancy or by a pressure term that does not predict them.
Reproducer
Save as
probe.tsand compile withperry compile probe.ts --no-auto-optimize -o probe:The control, for the subtraction, replaces
re.test(vals[i])withvals[i].length > 0.Both arms print
500000, so nothing here is a correctness difference.What I am not claiming
I have not established which workloads depend on the change's intended effect, so this is not a revert request. The question for whoever owns the pacing work is whether the drained-side-bytes term can be scoped to the case it was measured on — a live external side term that a parse-boundary minor actually drains — rather than added to old-reclaim pressure unconditionally.
records_array_1m:sparseshould keep its 7-fulls-to-1 win; this probe should keep its zero cycles.