Skip to content

perf(codegen): put the numeric array push's GC bookkeeping behind one live test (push_num 0.149 -> 0.069) - #7839

Merged
proggeramlug merged 2 commits into
mainfrom
perf/7830-push-numeric-guard
Aug 11, 2026
Merged

perf(codegen): put the numeric array push's GC bookkeeping behind one live test (push_num 0.149 -> 0.069)#7839
proggeramlug merged 2 commits into
mainfrom
perf/7830-push-numeric-guard

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What

The inline array-append tier emitted three GC-bookkeeping obligations on every
element
: js_string_addref_if_heap_string, js_gc_note_slot_layout, and a
seq_cst load of PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT to gate
js_write_barrier_slot. On gc-handoff/bench/push_num.ts — 20,000,000 pushes of
a double into a number[] — all three are dead on all 20M of them.

This is #7511's answer to the identical problem on class-field stores, applied to
the array append: ask the question once, inline, on the live bits, and branch
over all three calls.

Why the static proof cannot do it

array_store_needs_layout_note rests on
expr_produces_non_pointer_bits_by_construction, and the shape that matters is
keep.push(base + j) — an Expr::Binary { Add }, where that predicate answers
false unconditionally because + is string concatenation for non-numeric
operands. It fires only for a bare canonical-i32 local, which is why
keep.push(j) and keep.push(base + j) compile to materially different loops
today.

The two halves of the guard

  • The value. emit_may_carry_heap_pointer_check — already the codegen mirror
    of layout_pointer_bearing_bits / decode_heap_addr, already contract-tested
    over the whole 16-bit tag space. The store itself stays unconditional and
    outside the branch; only the bookkeeping moves.
  • The array. Rides the header test the nofwd block already performs: the
    integrity mask widens 0x04070x3C07, so reaching the inline store
    additionally proves GC_ARRAY_ELEMENT_SHAPE, GC_OBJ_TYPED_LAYOUT_INTACT and
    GC_LAYOUT_ALL_POINTERS clear — the three states in which
    js_gc_note_slot_layout does real work for a non-pointer value. An array in
    any of them takes js_array_push_f64, which notes the slot exactly as before.
    Same and, same icmp, wider constant.

GC_LAYOUT_SIDE_MASK is deliberately not in the mask: skipping the note there
leaves a stale set bit over a non-pointer, and mark_field_into_worklist
re-validates every slot word, so the cost is one rejected visit and never a
stranded child — the identical argument class_field_store_needs_layout_note
already ships.

A guard, not an elision. the_guarded_arm_still_reaches_every_call_it_moved
asserts the calls are still emitted, so a future "simplification" to an outright
elision fails there rather than as heap corruption.

Gated on is_numeric_expr, so a pointer-pushing loop emits byte-identical IR.

Measured — absolute seconds, quiet M1 mini

best-of-25, interleaved with rotated start position, exit codes checked, output
byte-identical to node --experimental-strip-types.
Load 1.17 before / 1.99
after, zero foreign benchmark or compiler processes at both ends.

arm best p25 med p75 worst
perry main @1ee158d27 0.1494 0.1506 0.1508 0.1509 0.1751
perry + this PR 0.0692 0.0697 0.0698 0.0700 0.0896
node v26.5.1 0.1151 0.1170 0.1177 0.1183 0.1838
scriptc 0.0.23 0.0931 0.0933 0.0935 0.0936 0.0947

push_num 0.1494 → 0.0692 s, −53.7%. It was the only benchmark in the corpus
Perry lost to both node and scriptc; it is now 0.60× node and 0.74×
scriptc
. The distributions are disjoint — this PR's worst of 25 (0.0896) is
below scriptc's best (0.0931).

No regression anywhere else — by construction, not by timing

24 of the 25 corpus programs compile BYTE-IDENTICALLY between main and this
branch (cmp over gc-handoff/m0810's full list plus the _real arms). Only
push_num differs. Nothing else can have moved.

The two byte-identical pairs were timed anyway as an in-run noise floor: the same
binary under two names read +0.19% (push_cls) and −0.05% (churn)
apart, so the host's floor on this run is ~0.2% and the −53.7% is ~250× it.

_real arms

churn_real, churn_alloc_real, push_cls_real, cycles_real, tree_real,
retain_real are all in the byte-identical set, so their bootstrap penalty is
unchanged by construction.

The declared-type lie (#7831/#7837) — reviewed, and it cannot reach this guard

A peer raised the case my own probes could not reach: a number[] fed a heap
string built by +. I checked it rather than assumed, and the answer is worth
more than the test would have been.

gc-handoff/m0810/numarr_lie.ts does not reach the guard. IR census: 0
apush.gc_bookkeeping blocks. Its pushed value is (s1 + s2 + String(i)) with
s1/s2 typed any, so is_numeric_expr is false, guarded_numeric_bookkeeping
is false, and the push keeps the historical unguarded tier untouched by this PR.
Empirically it prints 2000 string hello wor0 hello wor1999 6000 on pre-#7839
main, on this branch, and on a deliberately sabotaged compiler with the guard
hard-wired to false
— so it cannot fail on a broken guard. It is a fine
regression test for the pre-existing path; it is not a detector for #7839.

Four further lie shapes — a number parameter, a number object field, a
module-level number global, and an element read off a number[]all emit
zero guard blocks
. The reason is structural, and is the clearest statement of
why this guard is safe:

  • is_numeric_expr does admit a read off a number[] (fix(codegen): stop typing a symbol-keyed element read as a number (#7796) #7810), so an erased
    annotation alone would put a heap string on a numeric push path.
  • But expr_produces_canonical_raw_f64 excludes every read ("cold fallbacks
    return boxed bits"), so keep_guarded_numeric_push stays true and those pushes
    route to the pre-existing runtime numeric tier
    (js_array_numeric_push_f64_unboxed behind its feedback guard), which
    validates the value at runtime.
  • This PR's inline guard is therefore reached only for values that are
    canonical raw f64 by construction — a machine FP op. Such a value cannot be a
    pointer except by ARM NaN-payload propagation from a NaN-boxed operand, which
    is exactly what the live-bits test catches.

Two sabotage-verified detectors

Both are IR gates that run in cargo test, and both were confirmed to fail on
a deliberately broken compiler — the bar the runtime probes could not clear:

test sabotage that fails it
the_guard_branches_on_the_live_bits_not_on_a_constant branch hard-wired to br i1 false (bookkeeping arm unreachable)
a_declared_type_lie_is_routed_to_the_runtime_tier_not_the_guard expr_produces_canonical_raw_f64 widened to admit a read

The first pins the guard's condition to a computed register and its predicate to
the full heap-tag comparand set. The second pins the routing above, so a future
widening of the numeric proof surfaces here rather than as a guard resting on an
annotation.

Correctness

  • cargo test --release -p perry-codegen --lib861 passed, 0 failed,
    including five IR-census tests for this change.
  • Whole corpus (25 programs) compiled and run: exit 0, output byte-identical to
    the recorded node expectations.
  • Canary gc-handoff/apps/iso_miss.ts prints checksum 437840 misses 0, plain
    and under PERRY_GC_SCHEDULE_RATE=1.
  • numarr_lie.ts matches node under PERRY_GC_SCHEDULE_RATE=1, …_ALLOC_KB=0
    and PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_FORCE_EVACUATE=1.
  • cargo fmt --all -- --check clean.

Honest limitations

The runtime probes are not detectors, which is why the two IR gates exist. A
sabotaged compiler with the guard hard-wired off passed every runtime probe I
wrote, and passed numarr_lie.ts too. Soundness rests on the routing argument
above and on the two sabotage-verified gates, not on a runtime detector.

I am not citing the from-space protector as evidence. Under
PERRY_GC_DIAG=1 it prints zero [gc-fromspace-protect] retired_set= lines
on these programs, so the instrument never ran and that arm proves nothing.

Side observation for anyone extending this: for these shapes the layout note
appears less load-bearing than the surrounding comments imply, most likely
because the arrays end up GC_LAYOUT_UNKNOWN and are tag-scanned anyway.

Not run locally

The gap suite — it needs a quiet host, and the shared mini had several sessions
contending for it all day.

Pre-existing red gates, not from this branch

scripts/check_file_size.sh fails on main @1ee158d27 for
crates/perry-hir/src/lower/pre_scan.rs (2011 lines) and
crates/perry-runtime/src/gc/layout.rs (2023) — both were under the cap at
0a2bf15bd. Neither is touched here.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Numeric array pushes now store values unconditionally and guard GC bookkeeping with live-bit heap-pointer checks. Array admission masks cover additional numeric layouts. New LLVM IR tests verify bookkeeping placement, runtime routing, pointer-push behavior, and non-constant guard conditions.

Changes

Numeric push GC bookkeeping

Layer / File(s) Summary
Numeric push code generation
crates/perry-codegen/src/expr/array_push.rs, crates/perry-codegen/src/expr/mod.rs, changelog.d/7839-numeric-push-pointer-tested.md
Numeric pushes use a live-bit heap-pointer check before emitting string addref, layout-note, and write-barrier operations. Slot stores remain unconditional. Numeric admission accepts additional layouts, while other push paths retain their existing behavior.
IR guard and runtime-tier validation
crates/perry-codegen/src/expr/array_push_guard_tests.rs, crates/perry-codegen/src/expr/mod.rs
Tests verify guard placement, widened admission masks, retained guarded bookkeeping, pointer-push behavior, runtime numeric element-read routing, and computed checks for pointer, string, bigint, and bare-heap-address tags.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • PerryTS/perry#7536: Both gate GC bookkeeping with inline heap-pointer checks while keeping value stores unconditional.
  • PerryTS/perry#7602: Both modify array_push.rs to conditionally emit GC bookkeeping.
  • PerryTS/perry#6915: Both refine inline numeric array-push admission and guard logic.

Suggested labels: bug

Suggested reviewers: thehypnoo

Sequence Diagram(s)

sequenceDiagram
  participant ArrayPushCodegen
  participant HeapPointerCheck
  participant NumericSlotStore
  participant GCBookkeepingOps
  ArrayPushCodegen->>NumericSlotStore: store numeric value unconditionally
  ArrayPushCodegen->>HeapPointerCheck: test live bits for heap pointers
  HeapPointerCheck-->>ArrayPushCodegen: computed pointer-carry result
  ArrayPushCodegen->>GCBookkeepingOps: emit addref, layout note, and write barrier when needed
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning Issue #7837 requires fixes for lying declared-string addition, but the summarized code changes only show numeric array-push bookkeeping work. Add or identify code and tests that fix declared-string operator selection and preserve non-string operands in js_string_concat_box.
Out of Scope Changes check ⚠️ Warning The numeric array-push optimization and related tests are unrelated to the directly linked declared-string addition issue [#7837]. Move the numeric array-push optimization to a separate pull request or link an issue that defines its requirements.
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title clearly identifies the main change: optimizing numeric array-push GC bookkeeping with a live-value test.
Description check ✅ Passed The description is detailed and covers the change, rationale, tests, benchmark results, limitations, and pre-existing failures, despite using different section headings.
✨ 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/7830-push-numeric-guard

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.

… live test

The inline array-append tier emitted `js_string_addref_if_heap_string`,
`js_gc_note_slot_layout` and a seq_cst load of
`PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT` on EVERY element. On
`bench/push_num.ts` — 20,000,000 pushes of a double into a `number[]` — all
three are dead on all 20M of them.

The static proof that retires them cannot be made for the shape that matters:
`keep.push(base + j)` is an `Expr::Binary { Add }`, and
`expr_produces_non_pointer_bits_by_construction` answers `false` there
unconditionally, because `+` is string concatenation for non-numeric operands.

This is #7511's answer to the identical problem on class-field stores, applied
to the array append: ask the question ONCE inline, on the live bits, and branch
over all three calls. The array's half of the proof rides the header test the
`nofwd` block already performs — the integrity mask widens from 0x0407 to
0x3C07, so reaching the inline store additionally proves ELEMENT_SHAPE,
TYPED_LAYOUT_INTACT and ALL_POINTERS clear, the three states in which
`js_gc_note_slot_layout` does real work for a non-pointer value.

A guard, not an elision: Perry does not validate declared types, so a
`number`-annotated value that is a heap string at runtime takes the guarded arm
and records the slot exactly as it always did.
@proggeramlug
proggeramlug force-pushed the perf/7830-push-numeric-guard branch from 98b9956 to 8a828ec Compare August 11, 2026 10:54
…push guard

Two sabotage-verified IR gates, prompted by review of the #7831/#7837 family
against #7839's guard.

`a_declared_type_lie_is_routed_to_the_runtime_tier_not_the_guard` — a
`number[]` really can hold heap strings at runtime, and `is_numeric_expr`
admits an element read off one (#7810). What keeps that value off the inline
guard is `expr_produces_canonical_raw_f64` excluding every READ, which routes
it to the pre-existing runtime numeric tier instead. Widening that predicate to
admit a read fails this test.

`the_guard_branches_on_the_live_bits_not_on_a_constant` — pins the guard's
condition to a computed register and its predicate to the full heap-tag set.
Hard-wiring the branch to `false` fails this test; it is invisible to every
output-equality probe, because the elided bookkeeping is a GC-liveness fact
rather than an arithmetic one.
@proggeramlug
proggeramlug marked this pull request as ready for review August 11, 2026 11:35

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/perry-codegen/src/expr/array_push_guard_tests.rs (1)

243-289: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that the write barrier remains in the guarded control-flow path.

These tests verify only js_gc_note_slot_layout and js_string_addref_if_heap_string. They do not verify js_write_barrier_slot.

If emit_numeric_push_store_pointer_tested returns barrier bits, crates/perry-codegen/src/expr/array_push.rs Lines 1025-1036 emit the parent-generation gate after apush.gc_bookkeeping.done. The current tests still pass, but every numeric push pays the barrier gate again.

Assert that the barrier call remains emitted and that its parent-generation gate is reachable only from apush.gc_bookkeeping.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/expr/array_push_guard_tests.rs` around lines 243 -
289, Extend the numeric push guard tests around
emit_numeric_push_store_pointer_tested to verify js_write_barrier_slot is
emitted and remains in the guarded control-flow path. Assert the barrier call is
absent from the inline inbounds block and that its parent-generation gate is
reachable only after apush.gc_bookkeeping.done, preserving the existing
assertions for the note and addref calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/perry-codegen/src/expr/array_push_guard_tests.rs`:
- Around line 243-289: Extend the numeric push guard tests around
emit_numeric_push_store_pointer_tested to verify js_write_barrier_slot is
emitted and remains in the guarded control-flow path. Assert the barrier call is
absent from the inline inbounds block and that its parent-generation gate is
reachable only after apush.gc_bookkeeping.done, preserving the existing
assertions for the note and addref calls.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9d611dda-7e33-4f74-b934-23611e7f41c0

📥 Commits

Reviewing files that changed from the base of the PR and between ab1bd46 and 30f4dc9.

📒 Files selected for processing (4)
  • changelog.d/7839-numeric-push-pointer-tested.md
  • crates/perry-codegen/src/expr/array_push.rs
  • crates/perry-codegen/src/expr/array_push_guard_tests.rs
  • crates/perry-codegen/src/expr/mod.rs

@proggeramlug
proggeramlug merged commit a64c5a9 into main Aug 11, 2026
1 of 19 checks passed
@proggeramlug
proggeramlug deleted the perf/7830-push-numeric-guard branch August 11, 2026 11:46
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