Skip to content

perf: shapes 0.139 -> 0.061 s (beats node) and pipeline 0.240 -> 0.175 s — a 2000-element array was born immortal, and this.vals[i]=v had no inline arm - #7895

Merged
proggeramlug merged 5 commits into
mainfrom
perf/shapes-promote-copy
Aug 11, 2026

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What this is

Two independent findings from the shapes / pipeline round. Both are measured on the
quiet M1 mini, best-of-5, exit-checked, against my own reference build of the same commit.


1. shapes was not a promotion-cost problem. It was a born-tenured leak.

arena_alloc_gc births anything over LARGE_OBJECT_THRESHOLD_BYTES (16 KB) in the old
generation and stamps GC_FLAG_TENURED — and a minor collection never sweeps old-gen.

gc-handoff/apps/shapes.ts builds a 2000-element Node2D[] per round and drops it. Its
backing store is 8 + 2048*8 + 8 = 16 400 bytes: sixteen bytes over the line. So every
round's array was immortal, the write barrier had recorded an old→young edge for each of
its 2 000 stores, and every subsequent minor's remembered-set scan marked all of them live
again — through containers nothing referred to any more.

PERRY_GC_TRACE=1, before:

cycle pause copied promoted survival‰ remembered-set newly_marked
0 38.4 ms 157 909 0 739 94 000
1 55.5 ms 0 193 592 925 118 006

shapes's actual live set is ~3 200 objects. 770 800 = 47 × 16 400 and
94 000 = 47 × 2 000: 47 dead arrays, one per completed round.

The controlled experiment, before touching any code: shapes_half.ts runs
build(1000) × 120 instead of build(2000) × 60 — identical total work, identical
output, and a 1000-element backing store is 8 200 bytes, one step under the line. Same
binary, same runtime, same box: 2 cycles / 93.9 ms of GC / 739‰ / 94 000 re-marks becomes
1 cycle / 5.0 ms / 30‰ / 0 re-marks.

The change

The threshold is now type-dependent, because crossing it trades two costs that are
only the same quantity for a pointer-free object:

  • copy cost — one memcpy, bounded by the object's own size;
  • retention cost — for a pointer_free object, its own bytes; for a pointer-bearing
    one, transitively everything it names, until a full mark-sweep.

So pointer_free types keep 16 KB and arrays / objects / closures get
LARGE_POINTER_BEARING_OBJECT_THRESHOLD_BYTES = 128 KB — V8's
kMaxRegularHeapObjectSize, which draws this line for this reason. Selection reads the
existing GcTypeInfo::pointer_free flag rather than a hardcoded type list; an unknown type
keeps the conservative value.

Deliberately not widened for strings: a >16 KB string would newly move, and this repo
has a known class of latent "borrowed &[u8] across an allocation" bugs. That risk buys
nothing, because a dead pointer-free object retains only itself.

The widened value is inside both structural ceilings of the copier — the 1 MB nursery block
and move_young's 1 MiB MAX_YOUNG_MOVE_BYTES refusal — so everything it admits to the
nursery is provably movable. MAX_YOUNG_MOVE_BYTES is hoisted out of the function body so
a unit test can assert that.


2. this.vals[i] = v had no inline arm at all

lower_index_set_fast gives a[i] = v a guarded diamond only when a is a stack
local
, because it needs a slot to write a realloc'd head back to. Every other receiver
shape — this.vals[i], obj.arr[i], a closure-captured array — fell through to a
five-argument js_typed_feedback_array_set_f64_extend call, while the matching read has
had a complete inline diamond for both tiers all along.

The unlock: a strictly in-bounds store changes no head and no length, so it needs no
writeback and can be inlined for exactly the receivers that path cannot serve. index == length (an extend), sparse writes, growth and every exotic array still take the helper.
expr/index_set_guarded.rs states the guard conjunction it proves, against
js_typed_feedback_plain_array_index_set_guard's; the slot write reuses
emit_jsvalue_slot_store_scalar_aware_on_block + emit_array_numeric_write_note_on_block
verbatim from the local-receiver arm, so the string addref, layout note, write barrier and
raw-f64 downgrade are one implementation, not two.



3. …and the one-permille interaction that fell out of it

Fixing (1) made retain1 +15.7% and retain_wide1 +11.3%, and the mechanism is exact.
young_survival_permille, deterministic on every run of all four retain* variants and
phase_flip:

cycle 0 later cycles
before 999 999
after (1) 992 1000

#7888's UNTRACED_PROMOTION_SURVIVAL_PERMILLE is 999, so cycle 1 went from untraced
(2.3 ms) to traced (18.1 ms). That single cycle was the whole regression.

Why the ratio moved, and why the old reading was the wrong one. all.push(rec) grows
its backing store by doubling, and under the flat 16 KB threshold every intermediate store
past 2048 elements was born in old-gen — so the garbage each growth abandons was never in
the young generation to be counted. 999‰ was measuring a nursery with its own array
garbage removed. With those stores nursery-resident the first cycle sees them and reads
992; every cycle after reads 1000 rather than 999, i.e. the estimator is strictly more
confident once that garbage is actually collected instead of parked in old-gen.

So 999 was over-fitted to an artifact of the policy this PR changes. Re-derived to 990:
the live mode now measures 992–1000 and the garbage mode still 0–4, three orders of
magnitude apart, and nothing in this corpus lands between them. The exposure it widens is
bounded twice already — note_untraced_promotion charges
promoted × (1000 − permille) / 1000 against the 32 MB PROMOTED_DEAD_BUDGET_BYTES, so an
untraced run capped at the 128 MB floor carries at most 1.28 MB of assumed-live-but-dead
bytes at 990 against 0.128 MB at 999. The binding bound is the untraced-bytes budget in
both cases, unchanged, and phase_flip still disarms on the flip cycle exactly as before.

It more than pays the regression back: retain1's GC pause goes 46.5 ms (base) → 60.4
(without this) → 42.0, retain 70.0 → 80.4 → 57.5, retain_wide1 40.7 → 49.4 →
33.8.

Validation

Quiet mini, best-of-5, exit-checked, VERDICT CLEAN (load 2.33 → 2.46, foreign 0 → 0)

s2base is my own build of the matched merge base, not a borrowed binary.

bench base this PR ratio node scriptc
shapes 0.1390 0.0611 0.440 0.082 0.038
pipeline 0.2397 0.1754 0.732 0.094 0.247
pipeline_big 2.2899 1.6912 0.739
bigarr_move (probe) 0.2531 0.0910 0.360 0.13
retain 0.1564 0.1564 1.000 0.132
retain1 0.0690 0.0690 1.000 0.085
retain_wide 0.2007 0.2008 1.000 0.157
retain_wide1 0.0719 0.0719 1.000 0.089
deeplist 0.0574 0.0575 1.002 0.098
bigarr_live (adversarial) 0.3349 0.3213 0.959
phase_flip 1.2023 1.1936 0.993
churn / churn_alloc / churn_read / push_num / push_cls / cycles / tree / tree_wide / fib40 / interp / iso_miss / asyncpipe 0.987 – 1.007

shapes now beats node by 1.35× (was 1.71× slower) and is 1.6× scriptc (was 3.66×).
pipeline is 1.86× node (was 2.81× at the start of this round) and 1.41× faster than
scriptc. Twelve programs with zero reach set the noise floor at ±1.3%.

GC pause and object counts, same quiet window (PERRY_GC_TRACE=1):

cycles pause handled promoted
shapes base 2 85.87 ms 351 501 193 592
shapes this PR 1 4.17 ms 7 416 0
retain base → PR 5 → 5 51.24 → 50.98 ms
retain1 base → PR 3 → 3 35.91 → 35.45 ms
retain_wide base → PR 8 → 8 78.54 → 78.09 ms
pipeline base → PR 6 → 6 9.38 → 2.84 ms 15 595 → 127

On ns/promoted-object, read the counts, not the ratio. shapes before is
85.87 ms / 193 592 promoted = 443.6 ns per promoted object (244.3 ns per handled
object, counting the 157 909 copies). After, the figure is undefined because zero
objects are promoted
: the 193 592 the base promoted were ~98% garbage, so the win is
not a cheaper promotion, it is that the promotion no longer exists. Per handled object
the ratio RISES (244 → 563 ns) purely because the fixed per-cycle root scan is now
amortised over 7 416 objects instead of 351 501 — which is exactly the "a share rises as
a program gets faster" trap, and why the counts are quoted beside it.

The rest

check result
23-program corpus × 3 arms byte-identical to node, exit 0
iso_miss canary, plain + stressed checksum 437840 misses 0, instrument live (50 retired sets)
GC stress, 12 programs (PROTECT_FROMSPACE=1 +DEPTH=800, VERIFY_EVACUATION=1, SCHEDULE_RATE=1) clean, instrument live in every arm
max sensitivity (+SCHEDULE_ALLOC_KB=0, FORCE_EVACUATE=1) clean
cmp, both codegen arms on ONE pinned runtime 18/22 identical; the 4 movers are exactly the 4 programs with a non-local array store
peak RSS shapes −54.8% (71.4 → 32.3 MB), bigarr_move −58.8%, pipeline −17.2%; retain / retain_wide / deeplist / churn / interp / phase_flip (#7888's own RSS bound probe) all within ±0.1%
cargo test --release -p perry-runtime --lib / -p perry-codegen --lib 2149 / 897 pass, 0 fail (post-rebase)
gap suite, 543 tests 11 flagged rows, all 11 A/B'd one by one through the harness against the base compiler and against the intermediate arm: 11 for 11 identical on all three. Ten more moved node_fail → parity_fail in the same run, which is the oracle-shift tell. One IMPROVEMENT (test_gap_iterator_helpers_2874: parity_fail → pass)
gc_root_dominance_corpus.sh + checker 139/139 sources compiled, 0 skipped; 2764 functions / 161 modules / 10 942 root stores, 0 violations — the checker refuses a verdict at zero root stores, so its subject is asserted live
bench/idxset_recv.ts semantics probe byte-identical to node on both arms: index === length (extend), sparse extend with holes, negative index, frozen array (throws), an array index carrying an accessor descriptor, a plain-object receiver, a raw-f64 downgrade, and an OOB read fed back into an in-bounds store
unit tests sabotage-verified — reverting arena_alloc_gc to the flat threshold fails the new test

shapes now retires only one from-space set (it has one minor left), so it is thin
stress coverage by construction. The evidence for the newly-movable band comes from a probe
built for it: bench/bigarr_move.ts keeps a rolling window of eight 2000/3000-element
arrays so they genuinely survive and are evacuated, then reads and checksums every
element of every survivor — a stale from-space read is an observable wrong answer. Output
20600072000 8 on both arms and node; GC 222.9 ms → 11.0 ms; wall 0.34 → 0.10
(node 0.13).

The footprint trade, stated rather than hidden

bench/bigarr_live.ts is the adversarial case, built to find the price: 600 arrays × 2500
elements — all in the newly-nursery-resident band, all retained to the end, so every
one now transits Eden/survivor instead of being born in place.

base this PR
wall (quiet mini, best-of-5) 0.3349 0.3213 (−4%)
GC pause 280.4 ms 258.6 ms
peak RSS 180.0 MB 231.4 MB (+28.6%)

Faster, not slower — and a real footprint cost in the all-survive case. Note the price is not the
choice of 128 KB — anything that fixes shapes must admit a 16.4 KB array to the nursery,
so it admits a 20 KB one too. What it buys is that the footprint is now bounded and
collectable
instead of unbounded: under the old policy those bytes, and everything they
named, were held until a full mark-sweep that many programs never run.

Summary by CodeRabbit

  • Performance

    • Improved garbage collection efficiency by applying type-aware thresholds for large objects.
    • Medium-sized pointer-bearing objects can now be reclaimed during minor collections.
    • Reduced overhead for strictly in-bounds typed-array writes through an optimized fast path.
  • Bug Fixes

    • Corrected typed-array storage behavior while preserving growth and fallback handling.
    • Refined object promotion decisions based on updated survival measurements.
  • Tests

    • Added coverage for type-dependent allocation thresholds, object promotion, copying, and collection behavior.

Ralph Küpper added 5 commits August 12, 2026 01:04
…s to 128 KB

A 2000-element array is 16 400 bytes, sixteen over the flat 16 KB line, so it
was born in old-gen with GC_FLAG_TENURED — which a minor never sweeps. Its
remembered-set edges then kept every object it referenced live forever.
…local array receiver

`this.vals[i] = v` had no inline arm at all — a full
js_typed_feedback_array_set_f64_extend call — while the matching read has a
complete guarded diamond. A strictly in-bounds store changes no head and no
length, so it needs no writeback slot and can be inlined for receivers
lower_index_set_fast cannot serve.
999 was read off retain/retain_wide/deeplist, and that reading was partly an
artifact of the flat born-tenured threshold: array growth abandoned its
intermediate backing stores into old-gen, so the garbage was never in the young
generation to be counted. With those stores nursery-resident, retain's FIRST
cycle measures 992 deterministically and every later one measures 1000.

Also update the old-gen fixtures that sized themselves off the flat constant.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d43e40e6-f326-4f2c-8d5e-c478b2e05dfe

📥 Commits

Reviewing files that changed from the base of the PR and between d831231 and e9fbabb.

📒 Files selected for processing (14)
  • changelog.d/7895-born-tenured-pointer-bearing-threshold.md
  • crates/perry-codegen/src/expr/index_set.rs
  • crates/perry-codegen/src/expr/index_set_guarded.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-runtime/src/arena/allocators.rs
  • crates/perry-runtime/src/arena/tests.rs
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/promote_in_place.rs
  • crates/perry-runtime/src/gc/tests/copying.rs
  • crates/perry-runtime/src/gc/tests/helper_stores.rs
  • crates/perry-runtime/src/gc/tests/inline_generation_gate_contract.rs
  • crates/perry-runtime/src/gc/tests/promote_in_place.rs
  • crates/perry-runtime/src/gc/types.rs

📝 Walkthrough

Walkthrough

The runtime now selects large-object thresholds by object type, updates promotion limits, and aligns GC tests with those thresholds. Non-local typed-array stores gain a guarded inline path for strictly in-bounds writes while retaining the extending fallback.

Changes

Type-aware garbage-collection thresholds

Layer / File(s) Summary
Type-aware threshold contract and allocation
crates/perry-runtime/src/gc/types.rs, crates/perry-runtime/src/arena/allocators.rs, changelog.d/7895-born-tenured-pointer-bearing-threshold.md
Pointer-bearing objects use a 128 KiB threshold. Pointer-free and unknown types retain the 16 KiB threshold. Allocation uses the type-aware predicate.
Young-object and promotion limits
crates/perry-runtime/src/gc/copying.rs, crates/perry-runtime/src/gc/mod.rs, crates/perry-runtime/src/gc/promote_in_place.rs, changelog.d/7895-born-tenured-pointer-bearing-threshold.md
The young-object relocation ceiling is shared through the GC module. The untraced promotion threshold changes from 999‰ to 990‰.
GC validation and threshold-based fixtures
crates/perry-runtime/src/arena/tests.rs, crates/perry-runtime/src/gc/tests/*
Tests cover type-specific tenuring, nursery limits, old-generation fixtures, type-aware predicates, and promotion budget behavior.

Guarded in-bounds array stores

Layer / File(s) Summary
Guarded store checks and fast path
crates/perry-codegen/src/expr/index_set_guarded.rs
The new helper validates pointer, type, forwarding, integrity, prototype, bounds, and capacity state before performing a scalar-aware inline store. Failed guards use the supplied fallback.
Index-set integration and module wiring
crates/perry-codegen/src/expr/index_set.rs, crates/perry-codegen/src/expr/mod.rs, changelog.d/7895-born-tenured-pointer-bearing-threshold.md
Non-local stores use the guarded path when feedback emission is disabled. Feedback builds and failed guards retain the extend-capable helper.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant IndexSet
  participant GuardedStore
  participant ArrayStorage
  participant ExtendHelper
  IndexSet->>GuardedStore: submit receiver, index, and value
  GuardedStore->>ArrayStorage: validate metadata and in-bounds state
  alt Guards pass
    GuardedStore->>ArrayStorage: store element and update layout bookkeeping
  else Guards fail
    GuardedStore->>ExtendHelper: extend-capable fallback store
  end
Loading

Possibly related PRs

  • PerryTS/perry#6810: Related guarded numeric array-store fast paths in perry-codegen.
  • PerryTS/perry#6831: Related old-generation array allocation thresholds and write-barrier behavior.
  • PerryTS/perry#7888: Introduced the untraced in-place promotion mechanism updated here.

Suggested reviewers: andrewtdiz, jdalton, thehypnoo

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/shapes-promote-copy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant