Browse documentation

0141 - The tracked-bytes pressure trigger backs off above a live set that sits inside the reserve

Date: 2026-10-14 Area: gc Related: ADR 0129, ADR 0130, ADR 0110, #1673, #1508

Context

While a script runs, the collector runs only at the VM's pressure checkpoints (every 1,024 dispatched instructions) and at the forced collection a refused request takes. A checkpoint collects when a trigger has fired. The tracked-bytes trigger fired whenever BytesAllocated was at or above MaxBytes - Reserve, where the reserve is MaxBytes / 8 clamped to 16 KiB…16 MiB. A charged reservation (string payloads, ArrayBuffer backing stores) that left the total there also latched a collection for the next instruction.

The trigger kept no record of the last collection. A script whose working set fits under the ceiling but sits inside the reserve stays at or above the line after every collection, so every checkpoint collected and every charged reservation latched another collection. Nothing leaked and each collection freed the garbage, but the cost of a collection grows with the live set, so allocation slowed by one to three orders of magnitude. #1673 measured it in bytecode mode on a production build, at --max-memory=64MiB with 20,000 live objects:

AllocationLive before the loopNµs per allocationCollections
{ a: 1, b: 2 }26.6 MiB, below the line100,0002.982
{ a: 1, b: 2 }58.1 MiB, inside the reserve100,000101.11,562
new ArrayBuffer(64)26.6 MiB, below the line2,0003.190
new ArrayBuffer(64)58.1 MiB, inside the reserve2,0007,050.82,096

#1508 reported the same trigger in the interpreted mode that ADR 0138 has since removed, where the checkpoint followed every expression.

ADR 0129 gave the heap-manager trigger a back-off for the same shape: after a full collection it records the heap still in use and raises the trigger above it. The tracked trigger next to it had none.

Decision

The tracked trigger backs off above what the last full collection left live. Collect records BytesAllocated once it has swept. While that record sits below the line, the trigger is the line, as before. When it is at or above the line, a collection could not get the live set under it, and another one before the script allocates more would mark the same live set to free little or nothing. The trigger then moves halfway from the recorded level to the ceiling. Collections follow allocated bytes: one for each half of the remaining headroom that the script allocates, rather than one per checkpoint.

The trigger comes back down on its own. A collection that gets the live set under the line records a level below it, and the trigger is the line again. Between collections, a release that takes the live total below the recorded level (a destructor's release, a resize that shrinks a buffer) lowers the record to that total, so freeing memory lowers the trigger without waiting for a collection, and the lower record holds when the script grows back before any checkpoint runs. A young collection (CollectYoung, used only between benchmark rounds) keeps every older object, so it does not move the record, as ADR 0129 decided for the heap record.

The trigger never rises above the ceiling. Charged reservations and gated growth do not rely on the trigger: both force a collection and re-test before they refuse (ADR 0110), so they never pass the ceiling and still find garbage the back-off left on the heap. A plain value allocation that would pass the ceiling is refused with the catchable RangeError without collecting first (TGocciaValue.AfterConstruction), so only a checkpoint collection clears the garbage in front of it. The backed-off trigger is therefore at most MaxBytes, and a script whose live set has reached the ceiling collects at every checkpoint, as before.

A live set inside the reserve gives up burst headroom. Before this change every checkpoint inside the reserve collected, so the plain allocations between two checkpoints could use all of the headroom h above the live set. With the back-off, a collection becomes due only once the garbage reaches h / 2, the trigger, and that is not a bound on the garbage: the VM tests the trigger only at its checkpoints, every 1,024 instructions, so whatever the script allocates between crossing the trigger and the next checkpoint adds to it. When a burst starts, h / 2 of garbage or more can already sit in that headroom. A burst between two checkpoints that fits in h but not in what is left is refused with the RangeError where the earlier trigger ran it. The ceiling does not move, nothing allocates past it, a script whose live set is below the line is unaffected, and catching the error, calling Goccia.gc() and retrying succeeds. This is accepted in exchange for removing the collection at every checkpoint.

One predicate serves every caller. NeedsMemoryPressureCollection tests the backed-off trigger, so the VM's checkpoint and the latch in TryChargeExternalBytesLocked change together. The non-collecting charge path (TryChargeExternalBytes, used for VM stacks and suspended generator frames) still never latches. The forced collections (TryCollectForLimitedBytes, the GC-stress checkpoint) pass AForce and do not consult the trigger. The 256 MiB external-allocation trigger is unchanged.

ADR 0130 stops a deep recursion's stacks at the line partly because "inside the reserve, every check would be a full collection". With the back-off, the checks inside the reserve collect once per half of the remaining headroom instead. That still grows more frequent as the headroom closes, each collection still marks every frame, and the reserve is still kept for the RangeError and its handler, so the stack line stays where it is.

Considered options

  • A fixed budget, such as a fraction of the reserve. Simple, but a budget larger than the remaining headroom would put the trigger above the ceiling, and one that is small next to the headroom collects more often than needed. Halving the headroom adapts to both.
  • Double the budget after each collection that fails to get under the line. It amortises well when there is room to grow, but the ceiling caps the trigger, so the doubling ends at the ceiling after a few steps, and from there it is the next option.
  • Move the trigger to the ceiling itself. Collections would be half as frequent, but plain objects would run past the ceiling for up to a checkpoint interval before a collection, and most collections would move from the checkpoints into the forced collections of charged and gated requests, which run with more of the engine's state held in Pascal locals.
  • Collect before refusing a plain value allocation, as charged reservations do. It would keep the full headroom, but a collection could then run inside any allocation, including in the middle of a built-in that holds values only in Pascal locals, the bug class #1679 tracks. The GC-stress build collects at instruction boundaries, not at allocation sites, so it could not find such cases.
  • Back off by half the survivors, as the heap trigger does. The heap trigger may sit above the ceiling because refusal does not use it. The tracked trigger may not, for the reason above.

Consequences

Measured on Linux x86_64 with production builds of main and of this change, running the #1673 reproduction (--max-memory=64MiB, 20,000 live objects, bytecode mode), median of 3 runs. "Below the line" holds about 26.6 MiB live, "inside the reserve" about 58 MiB:

AllocationLive setNBefore: µs eachBefore: collectionsAfter: µs eachAfter: collections
{ a: 1, b: 2 }below the line100,0001.9321.932
{ a: 1, b: 2 }inside the reserve100,00065.91,5621.894
new ArrayBuffer(64)below the line2,0001.7901.800
new ArrayBuffer(64)inside the reserve2,0004,1772,0961.801

Inside the reserve, allocation now costs what it costs below the line. In the interpreted mode that existed when this was measured, the same in-reserve object loop, cut to 2,000 allocations, went from 64.5 s and 51,091 collections to 9 ms and 2. That is the collection half of #1508; its --timeout overshoot is a separate fix.

Without a ceiling in reach nothing changes. Callgrind instruction counts on eight perf/probes workloads (string append and builder, typed arrays, property access, calls) and an object, string and ArrayBuffer allocation loop, with no --max-memory, differ from main by at most 314 instructions per run, under 0.0001%. The predicate tests the line before it looks at the back-off, and the back-off arithmetic is inlined, so the charge path that every string payload takes stays a leaf function. A first version that called the back-off out of line cost up to 0.25% on the allocation loop.

The invariants hold, and the tests pin them. Goccia.GarbageCollector.Test checks that a checkpoint inside the reserve does not collect again right after a collection that could not get below the line, and that a charged reservation there latches nothing. It checks that the backed-off trigger still fires once the script allocates half of the remaining headroom, and that a reservation that does not fit still collects first, finds the garbage made in between, and fits. It also checks that what no collection can make room for is still refused under the ceiling, and that the trigger returns to the line once a collection gets back under it. scripts/test-cli.ts counts memory.gc.collections for a run that parks its live set 3 MiB under a 64 MiB ceiling and then allocates 20,000 objects and 500 small buffers: 779 collections before, 2 after, against a bound of 40.

The cost is the burst headroom described above. Two reproductions from the review of this change, at --max-memory=64MiB, production builds, bytecode mode:

  • A live set parked 128 KiB under the ceiling makes two Object.entries bursts of about 40 KiB and 88 KiB per iteration, separated by loops longer than a checkpoint interval that allocate nothing. The earlier trigger finishes 200 iterations; this change refuses the second.
  • A live set parked 12 KiB under the ceiling allocates 100,000 small objects that are garbage at once. The earlier trigger finishes; this change refuses after 2,041.

Both refusals are the catchable RangeError, and no run passed the ceiling. scripts/test-cli.ts pins the shape: with the live set parked 1.5 bursts under the ceiling and 0.6 of a burst of garbage made, an Object.entries burst is refused, and after Goccia.gc() the same burst fits.