Skip to content

perf(codegen): keep the element-shape clone's accumulator and counter in native domains - #10255

Closed
proggeramlug wants to merge 7 commits into
mainfrom
codegen/clone-native-accumulator
Closed

proggeramlug wants to merge 7 commits into
mainfrom
codegen/clone-native-accumulator

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary

The element-shape loop clone (#7480#10171#10185) now keeps its accumulator as an f64 SSA value and its loop counter as an i32 for the clone's duration. The four JSON access cells that missed parity at the official settings (repeat ×3, 16k fields) now beat Node 26.5.1 and Bun 1.3.14, and all 12 cells win with the official settings.

Root cause

The fast clone was already what executed. The repeat loop (sum += rows[7].id) cost 16 instructions at 13.0 cycles per iteration because two loop-carried chains crossed the integer/float register boundary on every iteration:

  • Accumulator. An any-typed sum lives in a precise GC root slot. Every reload of a root slot goes through the RS4GC launder (function/precise_roots.rs, ROOT_RELOAD_LAUNDER, an asm identity LLVM cannot see through). So mem2reg promoted the slot as NaN-box i64 bits, giving x25 → fmov → fadd → fmov → x25.
  • Counter. A counter the clone never indexes with (the constant-index repeat form and the carried random form) had no i32 slot. It stayed a double (fadd d8, #1.0) and was compared with fcmp against count. count is also a root slot, so it was reloaded through the launder every iteration, even though the preheader had already turned it into a checked i32.

Change (stmt/element_shape_native.rs, wired in stmt/element_shape_loop.rs)

  • Accumulator. Redirected into a promotable alloca double, using the numeric_accumulator_f64_slots redirect the packed clones already use. It is seeded with the value the deref block just tag-tested as a Number.
  • Counter. Gets an i32 slot: its Let-site parallel slot, or a clone-private slot seeded with the literal start. The Update lowering advances only that slot (deferred_integer_update_accumulators), so the precomputed i32 trip count turns the condition into icmp slt i32. A canonical-i32 counter (the indexing sequential/fields forms) has no double storage and is left alone.
  • Exits. Every residual check branches to an element_shape.loop.side_exit trampoline. The trampoline writes both values back to their real slots and then enters the slow clone. The fall-through exit writes them back in element_shape.loop.fast.write_back.
  • Exclusions. Each one declines only that one redirect, never the clone: boxed, captured, module-global, i32/Str-represented, POD or scalar-replaced accumulators, and accumulators an enclosing clone already redirected.

Soundness (the rules the clone enforced are unchanged)

  • Commit ordering (perf(codegen): serve the fields and random access shapes from the element-shape loop clone #10185). The redirected store sits exactly where the root-slot store sat, and perf(codegen): serve the fields and random access shapes from the element-shape loop clone #10185's fold and carried-commit ordering already put that point after every side exit of the iteration. At any side exit the f64 alloca therefore holds the iteration's entry value, and the i32 counter holds the iteration's own index, because the Update runs after the body. That is the state the slow clone re-runs the iteration from. The trampoline never writes the carried binding: its real slot keeps the previous iteration's commit, which a unit test pins.
  • Call-free scan. The seed, the trampoline and the write-back are plain loads and stores plus one sitofp. They are created inside the block range the post-emission contains_gc_unsafe_call scan covers. These clones emit no poll, so no collection can see the stale root slot, and that slot holds a Number, which a scan treats as data.
  • JS +. Nothing new is treated as a raw double. The accumulator is the fact's numeric_accumulator, which is_numeric_expr already consumed as a raw double inside this clone. The fadds are the same IEEE operations on the same operands in the same order, so -0, NaN and overflow to Infinity are bit-identical. A non-Number entry value (string concatenation) still fails the preheader test and takes the slow clone.
  • i32 counter. The trip count in the fast clone is always a literal in 0..=i32::MAX, arr.length, or materialize_loop_i32's integral 0..=i32::MAX value, and the start is an integer literal in that range. So i < bound <= i32::MAX holds before every add i32 1. Fractional, NaN, negative and out-of-range bounds still route to the slow clone.

New repeat loop (arm64, perry_fn_access_worker_ts__run$spec_b_b, whole body)

0000000100000a90	cbz	w9, 0x100003030            ; residual check (header loads hoisted)
0000000100000a94	ldr	d2, [x10, w0, sxtw #3]     ; field load
0000000100000a98	fmov	x12, d2
0000000100000a9c	cmp	x12, x11                   ; Number tag test
0000000100000aa0	b.gt	0x100003038
0000000100000aa4	fadd	d1, d1, d2                 ; sum stays in d1
0000000100000aa8	add	w8, w8, #0x1               ; i is an i32
0000000100000aac	cmp	w19, w8                    ; against the materialized i32 count
0000000100000ab0	b.ne	0x100000a90

Before (main): 16 instructions, with fmov d2, x12; fadd; fmov x25, d1 for sum, fadd d8, d8, d0 for i, and mov x12, x26; fmov d1, x12; fcmp d8, d1 for count.

Instructions and cycles per iteration

Measured with /usr/bin/time -l on the bench mini: 50M iterations minus a 0-iteration run.

mode (16k) instructions main → PR cycles main → PR
repeat 16.0 → 9.0 13.0 → 3.0
sequential 25.0 → 22.0 13.0 → 3.4
random 29.0 → 27.0 15.6 → 15.6
fields 51.0 → 47.0 19.0 → 9.0

The PR's instruction counts are identical on 1m and 20m. Its cycles on 1m/20m are: sequential 5.2/10.9, random 16.3/17.3, fields 9.0/11.1, repeat 3.0/3.0.

Access screen: official settings (1M iterations, warmup 0)

Bench mini, best of 9 interleaved rounds, CPU ns/iteration. main = eb13fa188d, this PR's compiler = 17363b000c. The later commits change only tests, the changelog and mod declaration order.

cell main ns/iter this PR Node 26.5.1 Bun 1.3.14 PR / best(Node,Bun)
16k repeat 4.06 0.94 2.92 4.47 0.32
16k sequential 4.06 1.07 4.09 5.69 0.26
16k random 4.86 4.80 6.81 9.23 0.70
16k fields 5.94 2.81 5.57 9.20 0.50
1m repeat 4.07 0.94 2.89 4.49 0.33
1m sequential 4.11 1.63 8.28 7.42 0.22
1m random 5.13 5.05 10.64 10.68 0.47
1m fields 5.96 2.82 10.88 14.24 0.26
20m repeat 4.06 0.94 3.07 4.60 0.31
20m sequential 4.16 3.31 6.68 8.04 0.50
20m random 5.45 5.39 8.62 9.91 0.63
20m fields 6.03 3.39 11.33 14.28 0.30

All 12 cells are at or better than the better of Node and Bun. main missed 4.

Access screen: warmed (50M iterations, 1M warmup)

Best of 5 interleaved rounds.

cell main ns/iter this PR Node 26.5.1 Bun 1.3.14 PR / best(Node,Bun)
16k repeat 4.06 0.94 0.52 0.38 2.47 (miss)
16k sequential 4.06 1.06 1.67 1.50 0.71
16k random 4.88 4.86 4.63 5.40 1.05 (miss)
16k fields 5.93 2.81 2.77 3.06 1.01 (miss)
1m repeat 4.06 0.94 0.52 0.38 2.47 (miss)
1m sequential 4.11 1.65 1.85 1.32 1.25 (miss)
1m random 5.13 5.09 5.01 4.47 1.14 (miss)
1m fields 5.96 2.81 3.21 2.87 0.98
20m repeat 4.06 0.94 0.52 0.31 3.03 (miss)
20m sequential 4.17 3.38 2.13 2.02 1.67 (miss)
20m random 5.45 5.39 5.34 4.64 1.16 (miss)
20m fields 6.03 3.45 5.67 4.05 0.85

main lost all 12 warmed cells; this PR wins 3. The 9 remaining misses have these structural causes, none of which is the register-domain problem this PR fixes:

  • repeat (0.94 vs 0.31–0.52). One IEEE add per iteration is 3 cycles of latency on this core, and the loop is now exactly that. The JITs' 1.0–1.6 cycles mean an int32-speculated accumulator, which the brief rules out (JS double semantics for sum).
  • 16k fields (2.81 vs Node 2.77). Three serial IEEE adds make a 9-cycle chain (measured 9.02 cycles/iteration). That is the floor for the source-order fold in the double domain.
  • random (4.86/5.09/5.39 vs 4.63/4.47/4.64).
    • The loop-carried chain is the recurrence's sdiv/msub: 15.6 cycles, unchanged by this PR.
    • perf(codegen): serve the fields and random access shapes from the element-shape loop clone #10185's i64 srem, which it needs for exactness over carried ∈ [0, i32::MAX], is ~5 % slower than an int32 division. A same-host C microbenchmark measured 4.69 vs 4.46 ns, which accounts for the whole 16k gap.
    • The larger 1m/20m gap is not in the loop code. Perry's instruction count is identical across sizes while its cycles rise 15.6 → 16.3 → 17.3, and Bun gets faster than its own 16k number. That points to a record-memory locality difference, which I did not investigate.
  • sequential 1m/20m (1.65/3.38 vs 1.32/2.02). The same 22 instructions take 3.4 → 5.2 → 10.9 cycles from 16k to 20m, so these cells are memory-bound, with the same locality difference.

JSON matrix (50 rows, 5 interleaved rounds, best-of, PR vs main)

  • 48 of 50 rows are within ±2 % CPU. The only element-shape consumer, scan, is 0.5–1.1 % faster on all four sizes. No row's peak RSS went up.
  • Two rows that never run a clone moved further, one each way:
    • long_string_1m:stringify: −4.4 %.
    • escaped_1m:stringify: +10.3 % (160.8 → 177.3 ms, reproduced on symbolized binaries).
  • The escaped_1m:stringify regression is code layout, not this change:
    • Its hot function is perry_runtime::json::stringify_flat::emit_piece. The runtime archives differ only in same-size members (the build-commit stamp).
    • That function sits at 0x1004c8dd0 on main and 0x1004c8d50 on this PR, because the worker module got smaller.
    • Appending 1–6 unused functions to main's own worker, with main's compiler and runtime, moves the same row to 149.0–149.4 ms.
    • So on main alone the row spans 149–177 ms from layout, and ±2 % cannot be resolved for it.

Tests and gates

  • cargo test --release -p perry-codegen --lib: test result: ok. 1538 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out.
  • New stmt/element_shape_native_tests.rs, 5 IR-census tests.
    • No root-slot access or launder inside the clone.
    • The accumulator is read and committed once per iteration on its f64 alloca, seeded with the Number-tested reload.
    • The repeat condition is icmp slt i32 on the seeded counter, and the update touches only the i32 slot.
    • Every side exit goes through a call-free trampoline that writes back both values; the fall-through exit writes them back too.
    • The trampoline never writes the carried binding.
    • fields redirects only the accumulator.
    • Sabotage check: with both redirects disabled, all 5 fail (70 passed; 5 failed).
  • Two perf(codegen): serve the fields and random access shapes from the element-shape loop clone #10185 assertions updated. side_exit_count now also counts the trampoline spelling of an exit. The carried "published exactly once" check now counts the store of the converted carried value, because the accumulator store in that block is now also a store double.
  • test-files/test_gap_json_record_loop_clone.ts gains section 10:
    • -0/+0 accumulation, overflow to Infinity, Infinity + -Infinity, and a NaN start.
    • A string entry value.
    • A first-iteration side exit after a non-zero prefix, and mid-loop side exits in the counter and derived forms.
    • Fractional, NaN, negative, zero and string trip counts on the repeat form.
    • A random side exit on the fifth iteration.
    • Output is byte-identical to node --experimental-strip-types (v26.5.1) with both this PR's compiler and main's (86 lines).
    • --trace llvm confirms the clone fires and emits the trampoline in every new function.
  • scripts/check_file_size.sh: OK: no Rust source files exceed 2000 lines.
  • scripts/run_lint_gates.sh: run_lint_gates: 3 of 83 FAILED; 2 CI-only skipped. All three are pre-existing:
    • [Public benchmark evidence freshness] python3 benchmarks/ci_public_baseline_check.py
    • [warnings: rustc warnings (host-compatible, all targets)]: dead code in crates/perry-runtime/src/object/global_this_webassembly.rs:201.
    • [check: Check for API docs drift]: the two docs files were restored afterwards.
  • Because that workspace -D warnings check stops at perry-runtime, RUSTFLAGS="-D warnings" cargo check -p perry-codegen --all-targets was run separately: clean. Clippy reports nothing in the new files.

Summary by CodeRabbit

  • Performance

    • Improved execution speed for eligible repeated JSON access loops, reducing per-iteration work and delivering performance comparable to or better than leading JavaScript runtimes in benchmark scenarios.
  • Bug Fixes

    • Improved handling of numeric accumulators and loop counters, including side exits, overflow, NaN, signed zero, and unsupported counter values.
    • Preserved correct results when optimized loops transition to fallback execution.
  • Tests

    • Added coverage for native numeric storage, fallback behavior, edge cases, and late loop exits.

Ralph Küpper added 3 commits September 14, 2026 06:24
… in native domains

The fast clone now redirects the accumulator into a promotable f64 alloca
and defers the counter's double storage to an i32 slot the Update lowering
advances alone, publishing both at a side-exit trampoline and on the
fall-through exit.
…ounter

IR census for the f64 accumulator redirect, the i32 counter, the side-exit
trampoline and the fall-through write-back; the carried-commit and side-exit
helpers count the trampoline spelling of an exit.
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 960cdfe5-8a38-4c83-890e-6f7da1f313e2

📥 Commits

Reviewing files that changed from the base of the PR and between eb13fa1 and 8ba5e16.

📒 Files selected for processing (7)
  • changelog.d/10255-element-shape-native-domains.md
  • crates/perry-codegen/src/stmt/element_shape_fields_random_tests.rs
  • crates/perry-codegen/src/stmt/element_shape_loop.rs
  • crates/perry-codegen/src/stmt/element_shape_native.rs
  • crates/perry-codegen/src/stmt/element_shape_native_tests.rs
  • crates/perry-codegen/src/stmt/mod.rs
  • test-files/test_gap_json_record_loop_clone.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The fast element-shape loop clone now keeps eligible accumulators in f64 storage and counters in i32 storage. It writes both values back on exits, preserves slow-clone fallback behavior, and adds IR and runtime tests for numeric edge cases.

Changes

Native element-shape loop domains

Layer / File(s) Summary
Native storage and write-back
crates/perry-codegen/src/stmt/element_shape_native.rs
Adds NativeLoopDomains and redirects eligible accumulators to f64 storage and counters to i32 storage. Side exits and fall-through exits write values back to the original slots.
Fast-clone integration
crates/perry-codegen/src/stmt/element_shape_loop.rs
Records the literal counter start, enters native domains while lowering the fast clone, uses the native side-exit label, and finishes at the merge block.
IR and fixture validation
crates/perry-codegen/src/stmt/element_shape_native_tests.rs, crates/perry-codegen/src/stmt/element_shape_fields_random_tests.rs, crates/perry-codegen/src/stmt/mod.rs
Adds IR checks for native storage, integer counter operations, write-back ordering, side exits, carried bindings, and fields mode. Shared exit-counting helpers recognize the new trampoline.
Runtime coverage and changelog
test-files/test_gap_json_record_loop_clone.ts, changelog.d/10255-element-shape-native-domains.md
Adds runtime coverage for signed zero, Infinity, NaN, side exits, and unsupported counter values. Documents benchmark results and test coverage.

Priority: ➖ Normal

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

Change: Refactor

Sequence Diagram(s)

sequenceDiagram
  participant ElementShapeVersionedLoop
  participant NativeLoopDomains
  participant FastClone
  participant SlowClone
  ElementShapeVersionedLoop->>NativeLoopDomains: enter with counter start and accumulator value
  NativeLoopDomains->>FastClone: provide f64 accumulator and i32 counter storage
  FastClone->>NativeLoopDomains: route residual side exit
  NativeLoopDomains->>SlowClone: write back values and branch to slow preheader
  FastClone->>NativeLoopDomains: finish at merge
Loading

Merge Risk: ⚪ Minimal · up to 8ba5e

The loop optimization retains its cleanup and fallback behavior, with no remaining merge-blocking issue identified.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main performance change: keeping the element-shape clone accumulator and counter in native domains.
Description check ✅ Passed The description provides a detailed summary, root cause, implementation changes, soundness rationale, benchmark results, and test results. It does not use every template heading and does not explicitl…
Docstring Coverage ✅ Passed Docstring coverage is 92.59% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 27 functions across 6 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.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codegen/clone-native-accumulator

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

Independent check before taking this out of draft.

Review. Reads of the deferred counter inside the clone go to the registered i32 slot (LocalGet prefers i32_counter_slots), and its Update advances only that slot. Accumulator reads and writes go to the f64 alloca. Both redirects are removed in finish before the slow clone is lowered. finish also runs before lowered? propagates an error, so a failed lowering cannot leave a redirect registered. Side exits reach the slow preheader only through the write-back trampoline.

Re-measured on the bench mini with this PR's worker and a main worker (eb13fa188d), interleaved with Node 26.5.1 and Bun 1.3.14. Official settings (1M iterations, warmup 0), best of 3 rounds, CPU ns per iteration and peak MiB:

cell main this PR Node Bun PR / best CPU
16k fields 5.96 / 13 2.81 / 13 5.66 / 57 9.27 / 36 0.50
16k random 4.88 / 13 4.88 / 13 6.91 / 57 9.26 / 35 0.71
16k repeat 4.07 / 13 0.94 / 13 3.01 / 57 4.52 / 35 0.31
1m repeat 4.07 / 18 0.94 / 18 2.99 / 63 4.54 / 38 0.31
20m repeat 4.07 / 94 0.94 / 94 3.18 / 194 4.69 / 94 0.30

These agree with the description's table. escaped_1m:stringify, the one matrix row that moved against main, is still 177.3 ms against Node's 314.0 and Bun's 345.1.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed in merge train #10261: #10261. The merged main tree matches the validated train, and the fresh-head patch audit confirms the changes arrived.

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