Skip to content

perf(gc): drained-side-bytes pressure (#10268) triggers unproductive old-gen cycles — +33% on a regex loop, GC 0.3% -> 19.3% of wall #10376

Description

@proggeramlug

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.

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