Skip to content

Merge train 213: array push receiver, copying-minor single decode (v0.5.1591) - #10546

Merged
proggeramlug merged 9 commits into
mainfrom
train213r
Sep 17, 2026
Merged

proggeramlug merged 9 commits into
mainfrom
train213r

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

This train lands #10414 and #10491 as v0.5.1591, on 5030e6eed6. Seven source commits, each verified to preserve its patch-id and authorship. The two PRs touch no file in common.

Train repair

#10491 raised the string payload-access ratchet, perry-runtime 350 → 351. Its new gc/tests/copy_slot_decode.rs helper open-codes the payload offset as (s as *const u8).add(size_of::<StringHeader>()). The ratchet's own header says the committed baseline is debt, not an allowance for new code, so re-baselining would have been the wrong fix even though the script suggests it.

Replaced with crate::string::string_data(s), which is literally the same expression

pub(crate) fn string_data(s: *const StringHeader) -> *const u8 {
    unsafe { (s as *const u8).add(std::mem::size_of::<StringHeader>()) }
}

— and is what OwnedStringBytes::copy_from_header uses internally, so the read is byte-for-byte identical. Follows the existing form at gc/tests/concat_site.rs:29. lint returns to 1 of 83.

One test is release-incompatible by design

gc::tests::copy_slot_decode::sabotaged_remembering_arm_is_refused_by_the_coverage_cross_check fails under --release and cannot do otherwise. Its observable is the restore_surviving_dirty_coverage cross-check, gated behind #[cfg(debug_assertions)] at copying.rs:1033; in a release build that walk silently re-adds the page the sabotaged arm failed to remember, so the forgotten entry is invisible. The test's own doc says exactly this.

Verified where the mechanism exists, rather than assumed:

cargo test --profile gcaudit -p perry-runtime --lib copy_slot_decode
  4 passed; 0 failed

[profile.gcaudit] is release codegen with debug-assertions = true, and exists in Cargo.toml for precisely this. Worth stating plainly, because [profile.release] and [profile.perry-dev] both compile these assertions out: a guard or sabotage test evidenced only by a debug_assert! is unenforced in what ships.

Validation

Validated head 80f7a4b615. Five-package release build pinned and hash-verified, and re-verified after the gap run.

  • Crate suites: codegen 1571, runtime 3988, stdlib 139, hir 433, transform 137, cli 1139. Two runtime failures, both release-profile artifacts rather than regressions: the one above, and main's long-known heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_builds, whose assertion is likewise #[cfg(debug_assertions)]-gated.
  • All nine preflight gates pass, including both raw-handle ratchet invocations and the string payload ratchet after the repair.
  • Gap: filters push, array, weak, gc, object, shape. push is clean — the most direct filter for perf(runtime): stop re-deriving the receiver on every array push (−74%) #10414.

Reds attributed

Eleven fixtures A/B'd against main's own artifact set; ten behave identically on both arms, with distinct build stamps asserted per arm. These were re-attributed from scratch rather than reused from earlier trains: #10414 changes array push receiver derivation and #10491 changes the copying minor, so array, gc, weak and shape fixtures are all genuinely in scope and a verdict measured on binaries that predate them proves nothing. The two that mattered most — test_issue_2656_weakref_finalization_gc and test_guarded_raw_numeric_arrays — are both clear.

The eleventh, test_issue_1425_gc_unsafe_zones, is environmental: both arms refuse to build with byte-identical error text because the fixture needs the fastify adapter, which was removed from the in-stdlib build and requires compiling without PERRY_NO_AUTO_OPTIMIZE. Symmetric agreement in a known environmental mode, not a code difference.

test_issue_4826_array_headers needs no A/B — it is node_fail, an oracle failure, and node never runs Perry's output. test_issue58_object_string is listed verbatim in run_parity_tests.sh's SKIP_TESTS on main.

Note that --filter is a substring match over all fixtures, so these runs select test_issue_*/test_perry_* names that CI's gap suite (--filter test_gap_) never runs. Every test_gap_* fixture in scope passed.

Before merging, the pushed head and unchanged main are checked again. After merging, the rewritten commits are checked for preserved authorship and the main tree must match the validated train exactly.

Summary by CodeRabbit

  • New Features

    • Improved Array.prototype.push performance for numeric arrays.
    • Added spec-compliant errors when pushing to non-extensible or sealed arrays.
    • Preserved correct garbage collection when array slots change between pointers and numbers.
  • Bug Fixes

    • Fixed incorrect length handling for pushes on non-extensible arrays.
    • Improved copying garbage collection reliability and reduced redundant processing.
  • Documentation

    • Updated version information to 0.5.1591 and added changelog entries.

Ralph Küpper and others added 9 commits September 17, 2026 13:02
A hot `for (…) { a.push(v); a.pop(); }` loop costs 1,350 instructions per
push+pop pair. Profiled with symbols, more than half of that is not the append
— it is the append re-asking questions about a receiver it has already
resolved, once per helper in the chain.

Four removals, no inlining and no caching; the runtime gets smaller.

1. `typed_feedback::numeric_array_push_guard` probed the property descriptor
   of `"length"` on EVERY push — a string-keyed lookup that was the single
   heaviest frame in the loop at 16.5% of samples, more than the append it
   guarded. It was also unreachable-true: a non-writable `length` is only
   reachable through a descriptor write, every one of which marks the receiver
   `OBJ_FLAG_ARRAY_DESCRIPTORS` (documented on
   `array::named_props::mark_array_descriptors` as the shared "index-accessor /
   non-writable-length / sparse-index" gate), and the flag test three lines
   above has already returned `false` for any receiver carrying it.
   `array_length_is_non_writable_with_flags` encodes the same implication the
   other way round, short-circuiting on the flag before it will look anything
   up.

2. `js_array_numeric_push_f64_unboxed` asked `array_is_sealed_or_no_extend`,
   `array_is_frozen` and `guard_writable_length` in sequence. Each goes through
   the non-resolved `array::header::array_object_flags`, which re-runs
   `clean_arr_ptr` — allocator-ownership plus forwarding classification —
   before every single bit test, on the pointer `clean_arr_ptr_mut` resolved on
   the line above. `array_object_flags` was 24.0% of the loop, every sample of
   it from this one function. One read of the resolved header now answers all
   three.

3. The append chain resolved twice more: `array_numeric_raw_f64_push_inbounds`
   and then `ensure_array_numeric_raw_f64` inside it.

4. `array_iteration_is_exotic` and the layout chain
   (`js_array_is_numeric_f64_layout` → `array_numeric_layout`) each resolved
   again. The exotic check keeps its Buffer / TypedArray registry probes — the
   flag word cannot answer that, since those headers are not `GC_TYPE_ARRAY`
   and read as flags `0`.

1,350.6 -> 432.0 instructions per push+pop pair, -68.0%. Measured by
differencing two probes that differ only in push count, inside each binary, so
driver dispatch and code layout cancel before the arms are compared; the bare
loop control reads 0.05 and -0.04 in the two arms.

`js_array_pop_f64` already carries this exact fix — its comment describes the
same three redundant classifications per pop. `push` never got it.

Also fixes a parity bug the integrity fixture found:
`Object.preventExtensions(a); a.push(1)` silently kept the old length where
node throws. The dense append answers `SEALED | NO_EXTEND` with a bare
`return arr`, which is right for `js_array_push_f64` — the INTERNAL
CreateDataProperty-style append that runtime code uses to build fresh result
arrays, and which must not throw — and wrong for user `push`. The observable
entry now throws. `Object.seal` masked this: sealing also marks the element
descriptors, so it routed down the exotic path and threw for another reason.
The frozen-push message now matches node's wording too, since what fails is
CreateDataProperty for the new index rather than a write to a read-only one.

Known remaining divergence, not addressed here: frozen `pop` reports "Cannot
mutate a frozen array" where node says "Cannot delete property 'N' of
[object Array]". It throws from an earlier branch than the push path, and
getting it right needs care about the empty-array ordering.
`layout_note_slot` was 15.0% of a push/pop loop — 96 of its 109 samples from
the single call in `array_numeric_raw_f64_push_inbounds`. Its MASK work is a
provable no-op there, because the caller has already proved the value is a
plain number (`value_bits_to_number` returned `Some` two lines above).

That argument is not new: it is written out in full on the object twin of
`array_store_needs_layout_note`, which is how the codegen-side element store
already elides the same call. It holds in every layout state the receiver can
be in — `GC_LAYOUT_UNKNOWN` returns at the note's own state check; an intact
typed descriptor lets a non-pointer fall through the pointer-mask arm
untouched; `GC_LAYOUT_POINTER_FREE` hits the `!pointer && POINTER_FREE` early
return; and under `GC_LAYOUT_SIDE_MASK` the note could only ever CLEAR this
slot's bit, so skipping it leaves at worst a stale set bit over a numeric word,
which costs one extra visit and nothing else because
`gc::trace::mark_field_into_worklist` re-validates every slot word and rejects
f64 bit patterns as out-of-range addresses.

The #7480 element-shape invariant is NOT part of that argument and is kept —
through `note_element_store_resolved_flags`, so it reads the header this
function already holds instead of classifying the parent a second time.

346.0 instructions per push+pop pair, from 432.0. Together with the receiver
work in this branch's first commit: 1,350.7 -> 346.0, -74.4%.

The stale-mask case is the one that has to survive, so it gets a fixture built
to produce it: fill array slots with POINTERS, pop them all, then refill the
same slots with plain numbers, and interleave numbers and pointers in one
array while a retained subset keeps a live graph. Under seeded GC scheduling
with from-space protection, evacuation verification and
PERRY_GC_FROMSPACE_SCAN_ABORT=1, three seeds each ran ~270,000 copying minors
and ~33,700 from-space scans with dangling=0 and missing_rewrites=0, output
byte-identical to node every time.
`array_numeric_raw_f64_push_inbounds` existed only to resolve the receiver and
delegate. Its one caller now holds a resolved head and calls the _resolved core
directly, so the wrapper is dead — `-D warnings` caught it as an unused import
plus a never-used function, which is the right verdict. Deleted rather than
re-exported or silenced: leaving a resolving wrapper in place is exactly the
shape this branch is removing.
Base: e6dcb62 (main).

A raw (untagged) word was classified TWICE on the copying minor's slot path:
`CopyingPointerSet::decode_bits` classified it only to validate it, and
`mark_addr` classified it again. Every traced shaped object visits its shape
record's `keys` word, a raw address, so that was a second page-table probe and
`plausible_gc_header` read per traced object. The remembering arm then
re-decoded the very slot the visit had just decoded.

`visit_value_bits_child` now decodes, classifies and marks once, and returns
the child's address as the word reads after the visit. The validating
classification is the one the mark uses (the memo is still consulted after it,
in the same order as before), and the remembering arm reuses that child; only a
raw word that MOVED is validated again, which is all the re-decode could still
reject.

Two codegen facts are load-bearing, measured, and pinned by comment:

* the decode is `#[inline(always)]` — out of line, its frame and the by-memory
  return of its result cost as much as the classification it saves (that first
  cut measured flat to +1.05% on the six fixtures);
* `barrier_parent_needs_remembering` is asked BEFORE the visit. It reads only
  the parent and the slot's own address, never the child, so the order cannot
  change its answer — but asked after, the optimizer duplicated the call into
  both decode arms and stopped inlining it, which cost a third of the win on
  gc3 and more on w20000.

instructions:u, min of 5, same host, base vs this:
  gc3       11,756,388,818 -> 11,541,805,498  -1.83%
  w5000      1,886,467,237 ->  1,853,133,603  -1.77%
  w20000     4,727,775,800 ->  4,655,712,038  -1.52%
  oldyoung   1,454,636,978 ->  1,433,367,558  -1.46%
  w1000      1,045,691,197 ->  1,036,890,921  -0.84%
  alloc        320,204,861 ->    320,203,910  -0.00%

Exact counts under callgrind agree (gc3 -1.79%) and attribute it: `classify_arena`
calls fall from 6.09M to 4.20M on gc3, and on the pointer-slot control the
per-slot term falls from 379.2 to 349.6 instructions at K=16. Peak RSS and max
pause are flat within their own run-to-run spread on all six fixtures.

Witness: `gc::tests::copy_slot_decode`, two behavioural tests each with a
sabotaged twin — a raw word's child must be evacuated and the word rewritten
(sabotage: drop the validated raw word, and the word goes stale), and an old
parent's edge must be re-remembered from the decoded child (sabotage: forget
it, and `restore_surviving_dirty_coverage`'s cross-check refuses the cycle).
#10491's new `copy_slot_decode.rs` helper open-codes the StringHeader
payload offset as `.add(size_of::<StringHeader>())`, which raises the
string payload-access ratchet for perry-runtime from 350 to 351. The
baseline is debt, not an allowance for new code.

`crate::string::string_data()` is the sanctioned accessor and is what
`OwnedStringBytes::copy_from_header` uses internally, so the read is
byte-for-byte identical. Follows `gc/tests/concat_site.rs:29`.
@proggeramlug
proggeramlug merged commit 193889d into main Sep 17, 2026
21 of 22 checks passed
@proggeramlug
proggeramlug deleted the train213r branch September 17, 2026 13:30
@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: b582e84e-af80-41ab-bb57-d758992fe8f9

📥 Commits

Reviewing files that changed from the base of the PR and between 5030e6e and 80f7a4b.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10414-array-push-layout-note.md
  • changelog.d/10414-array-push-receiver-requeries.md
  • changelog.d/10491-copying-minor-single-decode.md
  • crates/perry-runtime/src/array/header.rs
  • crates/perry-runtime/src/array/indexing.rs
  • crates/perry-runtime/src/array/mod.rs
  • crates/perry-runtime/src/array/push_pop.rs
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/copying_parent_facts.rs
  • crates/perry-runtime/src/gc/tests/copy_slot_decode.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/object/native_call_method/common_methods.rs
  • crates/perry-runtime/src/object/native_call_method/handle_methods.rs
  • crates/perry-runtime/src/typed_feedback.rs
  • test-files/test_gap_array_push_integrity.ts
  • test-files/test_gap_array_push_numeric_over_pointer_slot.ts

📝 Walkthrough

Walkthrough

The changes optimize numeric array push paths, correct integrity-error handling, and remove duplicate copying-GC word decoding. New tests cover array integrity, pointer-slot reuse, raw-child evacuation, and remembered-set behavior.

Changes

Numeric Array Push

Layer / File(s) Summary
Resolved array helpers
crates/perry-runtime/src/array/header.rs, crates/perry-runtime/src/array/indexing.rs, crates/perry-runtime/src/array/mod.rs
Numeric array and exotic-layout helpers add resolved-state variants. The raw-f64 push path reuses resolved headers and flags.
Push fast paths and integrity errors
crates/perry-runtime/src/array/push_pop.rs, crates/perry-runtime/src/object/native_call_method/*.rs, crates/perry-runtime/src/typed_feedback.rs
Push guards consolidate flag checks, avoid repeated receiver resolution, and throw "Cannot add property {index}, object is not extensible" for blocked array extensions.
Array push validation and release notes
test-files/test_gap_array_push_integrity.ts, test-files/test_gap_array_push_numeric_over_pointer_slot.ts, changelog.d/10414-*.md, CLAUDE.md, Cargo.toml
Tests cover integrity states, typed-feedback behavior, and pointer-to-number slot reuse. Changelogs and version files record the updates.

Copying Minor Decode

Layer / File(s) Summary
Classified marking primitives
crates/perry-runtime/src/gc/copying.rs
Copying collector marking returns direct addresses and supports marking an already-classified pointer.
Single-decode parent visits
crates/perry-runtime/src/gc/copying_parent_facts.rs
Parent-slot visits reuse the first classification and decoded child. Only moved raw words are revalidated.
Copying collector regression tests
crates/perry-runtime/src/gc/tests/copy_slot_decode.rs, crates/perry-runtime/src/gc/tests/mod.rs, changelog.d/10491-copying-minor-single-decode.md
Tests verify raw-child evacuation and remembered-set behavior with decode sabotage. The changelog records the single-decode invariant and measurements.

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

Change: Bug fix · Severity of issue fixed: Medium

✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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