Skip to content

perf(gc): on a retained object graph perry is 8.5× node — 13 minors at ~0.8G instructions each (measured on #10352) #10362

Description

@proggeramlug

Measured on: PR #10352 head 6c9d74188 (= main fcd108bfb v0.5.1579 + the #10348 fix), release build, Linux x86_64 (perrybuilder). node v26.8.1, bun 1.4.2. Metric is instructions:u, min-of-N (load-insensitive; wall-clock on that box is only indicative).

Every row below is correctness-gated. Output is byte-identical to node, and every perry binary exits cleanly under PERRY_GC_FROMSPACE_SCAN_ABORT=1. On builds without #10352 this workload has ~15 000 dangling references and runs about 5× cheaper because it skips work, so pre-#10352 numbers are not a valid baseline here.

Symptom: cost is superlinear in the retained live set

Allocation volume is constant (300 000 chains of 8 nodes, each with a 4-element array); only the number of retained chains changes. Source is plain JS and runs unchanged on all three runtimes.

retained chains perry node bun perry / node
1 000 1.10 G 0.69 G 0.64 G 1.6×
5 000 2.06 G 1.12 G 0.98 G 1.8×
20 000 5.20 G 1.37 G 1.11 G 3.8×
40 000 12.82 G 1.50 G 1.32 G 8.5×

For contrast, short-lived allocation alone (1 M objects, nothing retained) is 325 M vs node 177 M (1.8×), and startup is fine (3.3 M vs bun 9.1 M, node 109 M). The gap only appears once the program retains a graph.

Where it goes (40 000 retained)

PERRY_GC_TRACE=1 shows 13 collections. run_copied_minor_attempt is 55 % inclusive, and the old-gen incremental sweep adds 14.8 %.

Top self time:

13.92%  __memmove_avx512_unaligned_erms
 9.23%  gc::layout_slot_visit::visit_gc_layout_slot_descriptors
 7.38%  CopyingNurseryCollector::mark_addr
 5.74%  gc::layout_slot_visit::visit_gc_rewrite_slots<…scan_object_fields…>
 3.56%  HeapChildSlotIterator::next
 3.25%  oldgen::sweep_batch::PendingOldUnregister::flush
 2.94%  trace::ValidPointerSetBuilder::step
 2.60%  trace::ValidPointerSet::contains

Where the memmove comes from (call graph):

  • 6.98 % is a memcpy inlined inside visit_gc_layout_slot_descriptors, under run_copied_minor_attempt
  • 4.53 % is under GcCycleState::step
  • 2.17 % is under gc::verify::rebuild_evacuated_old_to_young_remembered_set, which is 7.26 % inclusive and runs in a production minor despite being in the verify module

The write barrier is not involved: mark_dirty_old_page_uncached does not appear, and the PtrHasher insert is 0.03 %.

Policy knobs: collection count is the dominant lever

Same binary; every configuration still produces the correct output with a clean scan:

config instructions collections
default (16 MB nursery) 12.57 G 13
PERRY_GC_TENURING_SURVIVALS=1 8.73 G 10
PERRY_GC_TENURING_SURVIVALS=2 12.01 G 13
PERRY_GC_TENURING_SURVIVALS=4 10.62 G 13
PERRY_GC_SCAVENGE_NURSERY_MB=64 6.36 G 4
PERRY_GC_SCAVENGE_NURSERY_MB=256 3.19 G 1
PERRY_GC_SCAVENGE=0 12.57 G 13 (no effect; not sure the knob does what its name suggests)

Mutator work plus one collection is about 3.2 G. Each extra collection costs about 0.8 G on this live set. Two things are wrong at once:

  1. Too many collections. tenuring.rs describes an influx-driven nursery scale for exactly this live-set-bound shape, but it does not grow enough here. Growing the nursery by hand removes 75 % of the instructions.
  2. Each collection is expensive. ~0.8 G to handle a ~40 000-chain survivor set is the more fundamental cost. A bigger nursery only hides it until the live set grows again.

Hypotheses (not verified — named so the work can start from them)

  • HeapChildSlotIterator holds object_shape: Option<ShapeDescriptor> by value, and gc_child_slots() builds one for every visited object. If ShapeDescriptor is large, that could be the memcpy inside visit_gc_layout_slot_descriptors. size_of::<HeapChildSlotIterator>() would settle it quickly.
  • ValidPointerSetBuilder::step + ValidPointerSet::contains (5.5 %) rebuild a set of valid pointers on every cycle. Per-cycle cost should scale with survivors, not with a rebuilt index.
  • Rebuilding the remembered set after evacuation (7.3 % inclusive) runs on every minor.
  • Possibly related to perf(gc): per-object layout metadata is address-keyed, so every copying minor rehashes it — 304 MB allocated / 14.9 MB live per 400-char reply #9792 (address-keyed layout metadata rehashed on every copying minor); layout_transfer is only 1.98 % here, so it is not the main cost on this workload.

Explicitly not claimed

No rewrite is proposed here. The nursery-size result shows where the leverage is (collection count × per-collection cost). It does not show that the fix is a bigger default nursery, which would trade memory for instructions and needs checking against the memory-parity work. Please confirm the hypotheses above with measurements before building on them.

Repro

gc3.js, plain JS, identical on all three runtimes. Expected output: checksum=-606613590 live=-593353216 nodes=320000.

var CHAINS = 40000, CHAIN_LEN = 8, TOTAL = 300000;
function makeChain(seed) {
  var head = null;
  for (var i = 0; i < CHAIN_LEN; i++) {
    head = { id: seed + i, payload: [seed, i, (seed ^ i) | 0, (seed + i * 3) | 0], next: head };
  }
  return head;
}
var ring = new Array(CHAINS);
for (var i = 0; i < CHAINS; i++) ring[i] = null;
var checksum = 0;
for (var n = 0; n < TOTAL; n++) {
  var c = makeChain(n);
  var tag = "n" + (n % 1024);
  checksum = (checksum + c.payload[n & 3] + tag.length) | 0;
  ring[n % CHAINS] = c;
}
var live = 0, nodes = 0;
for (var i = 0; i < CHAINS; i++) { var cur = ring[i]; while (cur !== null) { live = (live + cur.id) | 0; nodes++; cur = cur.next; } }
console.log("checksum=" + checksum + " live=" + live + " nodes=" + nodes);

To get the table rows, change CHAINS. Build perry compile gc3.ts --no-cache on #10352 and always run with PERRY_GC_FROMSPACE_SCAN_ABORT=1 to gate.

Context: how this workload got here

gc3 went from 7.26 G (v0.5.1573) to 2.34 G (v0.5.1579) through two commits, found by bisect. Then #10352 raised it to 12.57 G by restoring correct tracing:

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