Skip to content

runtime: make the Array-subclass elements store the default representation (−11.4% / −11.9% on the wolf-ecs twins) - #8974

Merged
proggeramlug merged 3 commits into
PerryTS:mainfrom
proggeramlug:perf/array-subclass-elements-default-on
Aug 28, 2026
Merged

runtime: make the Array-subclass elements store the default representation (−11.4% / −11.9% on the wolf-ecs twins)#8974
proggeramlug merged 3 commits into
PerryTS:mainfrom
proggeramlug:perf/array-subclass-elements-default-on

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #8966, which landed the elements store behind PERRY_ARRAY_SUBCLASS_ELEMENTS=1. This flips the default; PERRY_ARRAY_SUBCLASS_ELEMENTS=0 restores the shape-carried form as a bisecting kill switch.

Evidence

Whole-corpus differential. The gate is a pure runtime switch (codegen always emits the probes), so every fixture can be compiled once and run twice. Across test-files/: 1386 files, 1285 compiled, 9 output differences — none attributable to the store. Those nine are test_crypto (random bytes/UUID), test_date (timestamps), test_gap_console_methods (console.time values), test_parity_sys (PID in a deprecation warning), test_parcel_watcher_facade (flaky watcher timeout — it fails with the store off and passes with it on), test_perry_tui_inkcompat_useref (times out either way), plus test_issue63_escape, test_math, test_require, each of which produces a different output hash on five consecutive runs with the switch untouched.

Integration suites with the store enabled: issue_8655_array_subclass_indexing 2/2, issue_array_subclass_super_init 1/1, issue_4908_subclass_native_member_base 2/2, issue_8690_loop_versioned_arraylike 3/3, issue_8773_closure_capture_packed_loops 4/4, issue_5139_object_arraylike_method_dispatch 3/3, interface_typed_arraylike_method_dispatch 3/3, issue_8897_field_push_writeback 3/3.

Performance (Mac mini, 11 alternating pairs, same binary, store off → on):

window add/remove entity cycle
2 s 0.4114 → 0.3644 (−11.41%, 11/11) 0.3399 → 0.2999 (−11.80%, 11/11)
50 ms 0.4110 → 0.3640 (−11.49%, 11/11) 0.3399 → 0.2995 (−11.91%, 11/11)

Perry vs Node 26.5 on that host goes from 3.02× / 2.24× to 2.71× / 2.01×.

Semantics

Strictly closer to node. With the store on, JSON.stringify(sub) produces the array form, Object.keys no longer leaks length, and sort / reverse / splice / shift / unshift / length truncation / holes / spread / Array.from become node-identical — every one of those printed the object form ({"0":5,"1":1,"length":2}) before.

Unchanged divergences (identical with the store on or off, pre-existing, tracked in #8953): fill in getOwnPropertyNames/for..in, A.from([…]) not populating, f.constructor === A after map, entries()/keys() unimplemented on a subclass instance.

Tests

The shape-carried form stays reachable through the kill switch, so its unit tests now name it explicitly (ArraySubclassRepresentationGuard::shape_carried(), 13 tests) and the elements tests pin the other direction. cargo test -p perry-runtime --lib 2779/0.

Gates

RUSTFLAGS=-D warnings cargo check --workspace --all-targets clean; file size; GC store-site inventory; raw-handle debt unchanged (967, none raised); shape census; local-binding audit; addr-class audit. (temp_root_operand_temporaries::string_literal_concat_operand_is_re_derived_below_the_allocating_sibling fails on plain origin/main too — unrelated to this change.)

https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ

Summary by CodeRabbit

  • New Features
    • Array subclasses now store indexed elements and length using the standard elements representation by default.
    • Array operations such as push, pop, indexing, serialization, and key enumeration now more closely match Node.js behavior.
  • Performance
    • Improved benchmark results for array-subclass additions, removals, and entity cycles.
  • Bug Fixes
    • Prevented length from appearing unexpectedly in object keys.
  • Configuration
    • Added an environment variable option to restore the previous representation for troubleshooting.

Ralph Küpper and others added 2 commits August 28, 2026 20:17
…entation

`class X extends Array` instances keep their indexed elements and `length`
in `ObjectMeta.elements` (PerryTS#8966) instead of shape-carried properties, so
`push`/`pop`/`obj[i]` are element operations rather than property-shape
transitions. `PERRY_ARRAY_SUBCLASS_ELEMENTS=0` restores the previous
representation as a bisecting kill switch.

Evidence for the flip:

* the whole `test-files/` corpus compiled once and run twice (the gate is a
  pure runtime switch): 1285 binaries, 9 output differences, every one of
  them nondeterministic output — random bytes, timestamps, `console.time`
  values, a PID in a deprecation warning, a flaky watcher fixture that fails
  with the store OFF — each reproducible with the switch untouched;
* the Array-subclass integration suites pass with the store enabled
  (indexing, super-init, native member base, loop-versioned array-like,
  closure-capture packed loops, object array-like dispatch, interface
  dispatch, field-push write-back);
* wolf-ecs twins on the quiet Mac, 11 alternating pairs, same binary:
  add/remove −11.4%, entity cycle −11.9%, 11/11 in both the 2 s and 50 ms
  windows.

Semantics move toward node: `JSON.stringify` produces the array form,
`Object.keys` no longer leaks `length`, and `sort`/`reverse`/`splice`/
`shift`/`unshift`, `length` truncation, holes and spread become
node-identical (they printed the object form `{"0":…,"length":…}` before).

The shape-carried form stays reachable through the kill switch, so its unit
tests now pin it explicitly with `ArraySubclassRepresentationGuard`; the
elements tests pin the other direction.

Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ
@coderabbitai

coderabbitai Bot commented Aug 28, 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: 7fe85fa8-11a1-4e88-9908-acfb8087377f

📥 Commits

Reviewing files that changed from the base of the PR and between 1069058 and bd7f2b5.

📒 Files selected for processing (1)
  • changelog.d/8974-array-subclass-elements-default.md

📝 Walkthrough

Walkthrough

The runtime now uses the elements store for Array subclasses by default. An environment variable can restore the shape-carried representation. Tests use scoped guards to select either representation.

Changes

Array subclass representation

Layer / File(s) Summary
Representation default and test guard
crates/perry-runtime/src/array/subclass_elements.rs, changelog.d/8974-array-subclass-elements-default.md
The elements-store representation is enabled by default. PERRY_ARRAY_SUBCLASS_ELEMENTS=0, off, or false selects the shape-carried representation. Test-only guards force and restore either representation. The changelog records the storage and semantic changes.
Elements-store test coverage
crates/perry-runtime/src/array/subclass_elements_tests.rs
Three tests explicitly force the elements-store representation for property access, freezing, and counted-loop behavior.
Shape-carried test coverage
crates/perry-runtime/src/array/subclass_tests.rs
Thirteen Array subclass tests explicitly force the shape-carried representation before testing shape transitions, inline caches, garbage collection, numeric proofs, and ECS guards.

Estimated code review effort: 2 (Simple) | ~15 minutes

Merge Risk: ⚪ Minimal · up to 10690

Array subclasses now use the more efficient elements representation by default, with the prior representation available through a startup rollback switch. The supplied tests and checks support merge readiness, and no actionable merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary change: making the Array-subclass elements store the default representation. It also reports the relevant benchmark improvement.
Description check ✅ Passed The description provides a clear summary, concrete behavior changes, related issue context, extensive test evidence, performance results, and validation gates. It does not use every template heading a…
Docstring Coverage ✅ Passed Docstring coverage is 90.48% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 21 functions across 3 files. (1 skipped: 1 …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description provides a clear summary, concrete behavior changes, related issue context, extensive test evidence, performance results, and validation gates. It does not use every template heading and omits the checklist, but it contains the required substantive information and is mostly complete.

Full details: Docstring Coverage

Explanation

Docstring coverage is 90.48% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 21 functions across 3 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merged. This closes the finding I left on #8966, and I verified it directly rather than inferring it from the diff.

When I audited #8966 I reported that with PERRY_ARRAY_SUBCLASS_ELEMENTS=1 the runtime suite was 2766 passed / 13 failed — all 13 in array::subclass_tests::*, the old shape-carried representation's suite. That is now fixed. All three states are clean:

state result
default (now ON) 2779 / 0
PERRY_ARRAY_SUBCLASS_ELEMENTS=0 (kill switch) 2779 / 0
PERRY_ARRAY_SUBCLASS_ELEMENTS=1 (explicit) 2779 / 0

That is what the kill-policy actually asks for — both sides of the knob exercised, so the mode is a decision that has been made rather than one nobody has run end to end. The kill switch being green matters as much as the new default: it is only a usable bisection tool if the old path still passes.

The whole-corpus differential is the right kind of evidence too — compiling each fixture once and running it twice under a pure runtime switch removes compilation as a variable, and attributing all nine differences to crypto randomness, timestamps and console formatting is the sort of accounting that makes a null result believable.

One fix pushed: the fragment was named with the 0000 placeholder; renamed to 8974-. I had just cleaned three of those off main in #8973, so this would have re-introduced the collision.

Validation — the three states above; under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 the failing count is 16, the pre-existing set; the GC env-knob gate passes; scripts/run_lint_gates.sh 57 of 58 with the compile tier green — the exception is the pre-existing Actions-expression artifact (#8929).

@proggeramlug
proggeramlug merged commit 79555a8 into PerryTS:main Aug 28, 2026
18 checks passed
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Aug 28, 2026
…ops as their own kind

The loop guard resolved an elements-backed receiver to its store and
published it as kind 1. Kind 1 means "the receiver IS the ArrayHeader",
and the generated loop computes `receiver + header + i*8` itself — on the
ordinary (non-capture) path `fast_raw` is the receiver's own address — so
element 0 read the object's `meta` word as a double (`1.8e-311`) while
later elements happened to land on real data.
`issue_8773_closure_capture_packed_loops`'s dense case caught it once the
store became the default representation (PerryTS#8974).

Elements-backed receivers are now admitted as **kind 3**: every proof is
the store's (no descriptors, prototype latch clear,
`bound <= length <= capacity`, and the whole-array raw-f64 bit for a
numeric mode), the live address stays the RECEIVER so the capture-safe
caller keeps reloading the binding it owns, and the payload address is
published in descriptor word 3. Codegen's two plain-payload sites take
their base from that word (`plain_payload_base`), so both the capture path
and the ordinary path read the payload. Revalidation re-resolves the store
from the receiver, refreshes word 3 after an evacuation, and side-exits
when an append re-allocates it — exactly as a grown plain Array does.
Mode 2 (the fused ECS entity-id clone) declines, as it does for plain
Arrays.

Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ
proggeramlug added a commit that referenced this pull request Aug 28, 2026
…nt 0 as the object's meta word (#8976)

* runtime,codegen: admit elements-backed Array subclasses to counted loops as their own kind

The loop guard resolved an elements-backed receiver to its store and
published it as kind 1. Kind 1 means "the receiver IS the ArrayHeader",
and the generated loop computes `receiver + header + i*8` itself — on the
ordinary (non-capture) path `fast_raw` is the receiver's own address — so
element 0 read the object's `meta` word as a double (`1.8e-311`) while
later elements happened to land on real data.
`issue_8773_closure_capture_packed_loops`'s dense case caught it once the
store became the default representation (#8974).

Elements-backed receivers are now admitted as **kind 3**: every proof is
the store's (no descriptors, prototype latch clear,
`bound <= length <= capacity`, and the whole-array raw-f64 bit for a
numeric mode), the live address stays the RECEIVER so the capture-safe
caller keeps reloading the binding it owns, and the payload address is
published in descriptor word 3. Codegen's two plain-payload sites take
their base from that word (`plain_payload_base`), so both the capture path
and the ordinary path read the payload. Revalidation re-resolves the store
from the receiver, refreshes word 3 after an evacuation, and side-exits
when an append re-allocates it — exactly as a grown plain Array does.
Mode 2 (the fused ECS entity-id clone) declines, as it does for plain
Arrays.

Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ

* chore(changelog): name the fragment for its PR

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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.

2 participants