Skip to content

Old-gen growth from large-object births never arms a full collection: RSS climbs unbounded on repeated wide-object parse #10123

Description

@proggeramlug

Old-generation growth from large-object births does not arm a full collection

A workload that repeatedly allocates and drops a large pointer-bearing object grows old-gen without bound. Every minor promotes essentially the whole nursery, copies nothing and frees nothing, and the full collection that would reclaim it never arrives.

records_array_wide_1m:parse — a flat object of 50 000 integer keys, parsed in a loop:

old_in_use=6578096    survival_permille=997  copied_objects=0  freed_bytes=0
old_in_use=28272432   survival_permille=997  copied_objects=0  freed_bytes=0
old_in_use=59873112   survival_permille=997  copied_objects=0  freed_bytes=0
old_in_use=112274032  survival_permille=997  copied_objects=0  freed_bytes=0

Peak RSS 228 MiB against Node's 121 and Bun's 95.

The same signature on records_array_16k:scan before #10098's lazy-array work, with the trigger state visible:

old_in_use=48385432  old_threshold=50331648  old_pending=false
[gc-arena-rebaseline] step=134217728 -> 268435456 -> 536870912 -> 1073741824

An old-gen threshold of 48 MB exists and old_in_use reaches 48.3 MB with old_pending=false; meanwhile the arena trigger rebaselines itself upward exponentially. So the arm that would notice old-gen filling keeps stepping out of the way of the arm that is filling it.

Why this is one cause, not two

Each object is individually reasonable. arena_alloc_gc puts a pointer-bearing object over LARGE_POINTER_BEARING_OBJECT_THRESHOLD_BYTES (128 KB) directly into non-moving old-gen, and the reasoning in its own comment is sound: for a pointer-bearing object the cost of tenuring "is not its own bytes, it is every object it can reach, held live through the remembered set by a container nothing refers to any more".

That reasoning assumes a full collection eventually runs. When it does not, the policy inverts: the objects chosen for old-gen because they are expensive to hold are exactly the ones nothing reclaims.

Two of these are now fixed by moving the allocation rather than the policy — #10098 made the lazy JSON array cluster nursery-resident and took records_array_16k:scan from 205 MiB to 51 MiB and records_array_1m:scan from 159 to 75, both now below Node and Bun. That works because a lazy cluster's cache is small enough to sit under the line. A 50 000-key object's field storage is ~400 KB and cannot.

What to look at

  • Whether old-gen growth from large-object births feeds the full-collection trigger at all, or only growth observed through promotion.
  • Whether the arena trigger's exponential rebaseline should be bounded while old_in_use is climbing — the two arms currently move in opposite directions.
  • old_pending=false at old_in_useold_threshold is the specific observation to explain first.

Scope note

This is GC policy, which CLAUDE.md routes separately and warns about, so it wants its own change with the ratchet corpus run before and after rather than riding along with JSON work. Filing it with the evidence rather than attempting it inside a JSON performance pass.

Rows currently affected: wide_1m:parse RSS 228 vs 95 MiB (2.4x), and the residual records_array_8m:scan 187 vs 134 and records_array_20m:scan 256 vs 226, whose clusters are genuinely over the pointer-bearing line.

Refs #10098, #793.

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

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions