Skip to content

chore: merge train 232 (v0.5.1611) - #10780

Merged
proggeramlug merged 5 commits into
mainfrom
train232r
Sep 20, 2026
Merged

proggeramlug merged 5 commits into
mainfrom
train232r

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Merge train 232 — one stacked series, released as v0.5.1611.

Contents

PR Change
#10731 perf(codegen,hir): stop re-proving a loop-invariant array receiver on every element access
#10746 perf(codegen): hoist the loop-invariant receiver proof for array element stores — 105 → 17.4 instructions
#10752 perf(codegen): let a[i] += 1 reach the loop tier a[i] = a[i] + 1 already had — 277 → 25.5

These are three commits of one author stack, not three independent PRs: #10746 contains #10731, and #10752 contains both. They land together because they cannot land apart.

Issue #10718 measured the starting point — ordinary Array element access at 87/105 instructions per read/write against Float64Array's 6/8, a 14× gap inside perry and 0.09× node on read-modify-write. #10743 measured the compound-assignment half: a[i] += 1 at 11.5× the cost of a[i] = a[i] + 1, because the compound lowering emits alias Lets the one-statement loop matcher can never admit.

Their CI reds are stale, and I checked rather than assumed

Every PR in this stack shows gap-suite and e2e-scoped failing. Neither is real:

Both runs date from 2026-09-19, against a main that has since moved four times. A red gate is worth exactly as much as a green one until you read which thing is failing in the current run.

Representation was proved by tree identity, not by the line check

The assembler's verify() reported 14 and 12 "missing" insertions for #10731 and #10746 — every one of them superseded within the stack by a later commit. The classifier cannot see that, because it only compares against main.

Settled the strong way instead: git diff <train> pr/10752 over the PR's own 16 files is empty, while the same comparison against origin/main is not — so the check can see a difference, and did not find one.

One pre-existing red, named rather than buried

perry-hir's eval_classifier::tests::remedy_is_scoped_to_bundled_npm_shims fails on this tree — and identically on main. It asserts shimmed_package_module("dayjs.businessDaysAdd") == Some("dayjs"); merge train 231 removed the dayjs binding, so the lookup correctly returns None. The production code is right, the test is stale, and this train neither causes nor worsens it. The fix is in flight and deliberately not bundled here — it belongs with the removal campaign, not with array codegen.

It reached main because the validation driver ran unit suites with expect=None and recorded only the exit code. hir rc=101 means "something failed"; it cannot distinguish the same known failure from a new one. This train's landing gate now compares the failing set against a named baseline (a set, not a count — a count cannot see one test fixed and another broken in the same push), asserts each suite reached a test result: line so a killed suite cannot read as clean, and fails if a baseline entry stops matching, so the fix must delete its own entry.

Validation

Assembled on 3f5b5b1424; source heads asserted unchanged; no attribution trailers in any commit (the stack carried one per commit; the rewrite left the tree hash byte-identical). All nine cheap gates, cargo check --workspace --all-targets under -D warnings, the release build of all five pinned artifacts — byte-identical before and after the gap sweep — and six unit suites whose failing set is exactly the one pre-existing entry above.

lint completed its full 6-of-6 compile tier with nothing outside the known-red public-baseline step.

Gap sweep at PERRY_RUN_TIMEOUT=30, nine areas chosen to hit this train's own subject, every one asserted to have run a non-zero number of tests, zero unexplained regressions:

array 99   index 25   loop 17   numeric 15   typed_ 12
for_ 8     push 6     packed 2   compound 2

perry-bot and others added 5 commits September 20, 2026 05:14
… every element access (#10718)

An indexed read on an ordinary `Array` cost 87 instructions per element,
against 6 for the identical arithmetic on a `Float64Array` and 16 for node.
None of it was a runtime call: callgrind attributes 88.3 instructions per
element to straight-line code in `main`, of which **56 are loop-invariant
receiver revalidation** re-executed every iteration — the NaN-box tag and
handle-band test, the forwarding-flag follow, and a six-load live-head guard
that re-reads gc_type, gc_flags, obj_flags, a volatile invalidation flag,
length and capacity.

perry already has tiers that hoist exactly this proof into the loop
preheader and version the loop. They were declining at a single gate in
`stmt/loops.rs` — `array_static_type_excluded` — a **declared static type**
test sitting in front of a tier that is otherwise fully runtime-guarded.
`const a: number[] = new Array(400)` reached it and measured 13.4; plain
`new Array(400)` infers `Array<any>` and did not, so ordinary JavaScript
never got the tier it already had.

Separately, and larger: `a[i] += 1` cost 948 instructions per element, 3.7x
the identical `a[i] = a[i] + 1`, and a type annotation did not help.
`lower/expr_assign.rs` minted the compound-assignment spill temporaries as
`Type::Any`, erasing the receiver's array-ness and the index's
integer-ness before codegen could see the statement — which is why no
annotation could recover it. The temporaries now carry the operand types.

  Array read          87.0 -> 13.5   (node 16.3 — perry now wins 1.21x)
  a[i] += 1            948 ->  273
  a[i] += b[i]        1025 ->  347
  bare loop            4.0 ->  4.0   unchanged to the instruction
  Float64Array r/w   6.0/8.0 -> 6.0/8.0   unchanged to the instruction

A particle simulation over four numeric arrays spends 60.9% fewer
instructions (41.21 G -> 16.12 G) and 59% less peak RSS.

The widened tier initially regressed a numeric window inside an otherwise
non-numeric array by 33%, because the array-wide layout walk runs per loop
entry. A window-scoped layout proof is used as a second chance after that
walk declines; both shapes are now 12-13% wins, and arrays that are
non-numeric from slot 0 are unchanged to the instruction.
…ent stores (#10718)

An indexed write cost 105 instructions per element with zero runtime calls;
51 of them were loop-invariant receiver revalidation and 42 was a write-barrier
decision provable away from the value type. This widens the store admission the
way #10731 widened reads.

a[i] = k + i 105 -> 17.4, a[i] = a[i] + 1 256 -> 24.5, a[i] = a[i] + b[i]
333 -> 35.9. The bare loop, both Float64Array paths and the indexed read are
unchanged to the instruction.

The barrier-stem census probe for idxset.recv_global gains a second statement:
the widened tier made its loop qualify, which would have left that stem with no
live witness. A future multi-statement store tier must re-shape the probe, not
delete it.
…already had (#10743)

Fixes #10743. Stacked on #10746 (`2b2b89063`), which contains #10731.

`a[i] += 1` and `a[i] = a[i] + 1` are the same operation and node compiles both
to the same cost. perry compiled them 11x apart -- 277 instructions per element
against 24 -- and the slow one was the idiomatic spelling.

HIR's `hoist_compound_member_assign` lowers a compound member assignment into two
immutable alias `Let`s plus the store, so the base and the key are each evaluated
exactly once and before the right-hand side. `packed_f64_range_loop_body_collect`
admits exactly ONE statement, so the lowering guaranteed the statement could
never reach the tier that makes the expanded form fast. Annotating the array
changed nothing: the obstacle is the statement count, not type information.

The temporaries stay. They are load-bearing -- an RHS call can reassign the
bindings they were read from, and the store must still land at the index
evaluated before it ran (`a[i] += (() => { i = 2; return 5; })()`). So the fold
is in the MATCHER, and it applies only to the guarded fast clones: the slow clone
lowers the statements as written, so a failed guard and every side exit still
execute the specified evaluation order.

Inside the matched subset the fold is exact, because
`packed_f64_range_loop_pure_expr_collect` is a whitelist that admits no call,
closure, `await`, update or assignment anywhere in the statement -- nothing can
write the locals the aliases read. This needs none of #10741's mid-iteration
side-exit discipline: the folded-away statements perform no stores, so there is
nothing to un-do when a guard fails partway.

Per element, fitted across N=10,000 -> 50,000, three interleaved rounds per arm:

  a[i] += 1      277.05 -> 25.46   (node 16.9)
  a[i] -= 1      208.05 -> 27.45   (node 16.9)
  a[i] += b[i]   347.05 -> 35.86   (node 25.8) -- now exactly a[i] = a[i] + b[i]
  a[i] *= 1      206.05 -> 25.45   (node 15.1)
  a[i] |= 0      236.05 -> 52.46   (node 12.7)

The bare loop, both `Float64Array` rows, the indexed read and write and both
expanded spellings are unchanged -- their emitted LLVM IR is byte-identical
between arms, which is a stronger witness than a flat fitted number.

It moves none of the five real programs in #10695, and the compiler's own trace
says why: `sim` still reports 3 x `body_not_admissible`, because its inner loop
is five statements containing two `if`s. This admits a body whose extra
statements are compound-assign ALIASES, not a body whose extra statements do
things. That remains #10741.

New diagnostic: `PERRY_PACKED_LOOP_TRACE=1` prints
`[range-loop] admitted: compound_assign_alias_fold`. The existing traces report
only DECLINES, so there was no way to show that a fixture claiming to exercise a
guarded path actually reached it -- the exact gap that let #10746 ship a GC
stress whose fixtures were all declined before the guard under test ran.

Correctness: 25 differential fixtures (evaluation order with a side-effecting
RHS, getters and setters on the array / on `Array.prototype` / on the index, a
prototype getter that truncates, deletes from or freezes the array mid-loop,
frozen and sealed in sloppy and strict mode, a non-writable index, holes, out of
bounds, past `length`, non-number elements, string `+=`, heap-reference stores,
offset and affine indices, module-global receivers, typed arrays, and all
fifteen compound operators) pass on both arms with byte-identical results.

Guard sabotage: removing the per-store numeric-bits value check produces 27
SIGABRTs across seven heap-limit seeds under `PERRY_GC_FROMSPACE_SCAN_ABORT=1`
(0 unsabotaged), with the from-space scan naming a survivor-space array holding
an un-evacuated nursery pointer through a slot that was never marked dirty -- so
this path does not bypass #10746's write barrier, it is the same check on the
same store. Neutering the loop-entry guard turns four fixtures red, all of them
through the fold. The `mutable: false` condition is witnessed by a unit test and
an IR test; the `__cmpd_` name test and the initialiser grammar are scoping
restrictions with unit-test witnesses only, and lowering the folded body in the
slow clone as well turns nothing red -- all three are stated as unwitnessed in
the report rather than claimed.

Gates: node identity 92 identical / 1 diff / 0 compile-fail on both arms with
byte-identical results files (the diff is the pre-existing #10733 `nest`
defect); `perry-codegen` lib 1650 pass including the barrier stem census and its
four sabotage twins, with NO census probe change needed; `perry-hir` green;
clippy 470 = 470 with identical per-category counts; `cargo fmt`,
`check_file_size.sh`, `gc_runtime_root_holders.py` and
`local_binding_type_audit.py` clean and identical on both arms; peak RSS worst
case +0.60%.
@coderabbitai

coderabbitai Bot commented Sep 20, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 9bee44b4-df0c-488f-91e5-16a9e70cc3e0

📥 Commits

Reviewing files that changed from the base of the PR and between 3f5b5b1 and 115ad45.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10718-array-index-hoist.md
  • changelog.d/10718-array-store-hoist.md
  • changelog.d/10743-compound-assign-alias-fold.md
  • crates/perry-codegen/src/expr/barrier_stem_census_tests.rs
  • crates/perry-codegen/src/stmt/compound_alias_fold_tests.rs
  • crates/perry-codegen/src/stmt/loops.rs
  • crates/perry-codegen/src/stmt/mod.rs
  • crates/perry-codegen/tests/native_proof_regressions.rs
  • crates/perry-hir/src/lower/compound_assign_temp_type_tests.rs
  • crates/perry-hir/src/lower/expr_assign.rs
  • crates/perry-hir/src/lower/mod.rs
  • crates/perry-runtime/src/array/header.rs
  • crates/perry-runtime/src/array/mod.rs
  • crates/perry-runtime/src/typed_feedback.rs
  • crates/perry/src/commands/compile/build_cache.rs
  • scripts/local_binding_type_allowlist.json
 ________________________________________________________________________________________________________________________
< I've finally learned what 'upward compatible' means. It means we get to keep all our old mistakes. - Dennie van Tassel >
 ------------------------------------------------------------------------------------------------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ 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.

2 participants