Skip to content

perf(codegen): interp 1.095 -> 0.844 s — gate the class-field barrier on the parent's generation, and let a hot recursive function inline its bump allocator - #7871

Merged
proggeramlug merged 5 commits into
mainfrom
perf/interp-round5
Aug 11, 2026

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

interp 1.095 → 0.844 s, iso_miss 1.464 → 1.234 s

Two independent codegen gates, both of which restore a mechanism this repo
already had and had pointed at only one caller.

Quiet M1 mini, best-of-5, exit-checked, outputs byte-compared to
node --experimental-strip-types, taken under the bench lock (load 1.58 before
/ 2.19 after, zero foreign processes at both ends). The re-measured baseline
reproduces round 4's published numbers to 0.3% (interp 1.0945 vs 1.097;
iso_miss 1.4635 vs 1.465), which is the check that the two rounds are on the
same instrument.

bench before after delta
interp 1.0945 0.8441 −22.9%
cycles 0.1115 0.0866 −22.3%
iso_miss 1.4635 1.2338 −15.7%
tree 1.1665 1.0222 −12.4%
tree_wide 1.6464 1.5216 −7.6%
deeplist 0.1071 0.1018 −4.9%
pipeline 0.2738 0.2643 −3.5%
shapes, asyncpipe ±0.2%
churn, push_cls, retain, fib40byte-identical binaries −0.1%…+0.3%

The four byte-identical binaries set the run's noise floor at ±0.3%.

1. The class-field write barrier now tests the parent's generation

emit_jsvalue_slot_store_pointer_tested (#7511) put the class-field store's
three bookkeeping calls behind one live test of the stored value — "does
this publish a heap pointer at all". It never asked the barrier's other
question, "is the parent old enough for anyone to care", although
emit_parent_may_need_remembering_check sits 400 lines above it in the same
file. That predicate had exactly one caller: expr/array_push.rs.

HIR rewrites every closed-shape object literal into a new of a synthesized
anon-shape class, so { kind: "num", num: n } reaches a shared
<class>_constructor that writes its fields into an instance allocated a few
instructions earlier in the nursery — the !TENURED case, where the minor
GC retraces the parent anyway and the remembered-set record is pure cost.

The same predicate now gates the class-field store, on the identical argument:
Old ⟹ TENURED, so !TENURED can only skip a subset of what the runtime
already skips; and the second disjunct is the incremental-cycle count, because
skipping the call also skips barrier_child_prologue's SATB shading, which is
not a generational question. It stays a live header test (#7501's shape),
never a static claim: a parent promoted between its allocation and the store
reads TENURED here and takes the call.

2. A hot recursive function may inline its bump allocator

new_site_is_in_loop admitted a new site to the inline bump allocator only
if it was lexically inside a loop, or its function was in
collect_hot_loop_callees — a set capped at 4 direct call sites
module-wide
.

That cap is inlinehint's anti-bloat backstop, where cost scales with call
sites because LLVM duplicates the callee body at each one. The inline bump
allocator costs ~268 bytes per new site in the function, paid once,
whatever the caller count. So the cap priced a cost that does not exist here —
and excluded exactly the functions that earn the inline form.
gc-handoff/apps/interp.ts's evalNode is the shape: the hottest function in
the program, one allocation per invocation, and 11 direct call sites because
ten of them are its own recursion
.

New collector collect_alloc_hot_functions answers the allocator's question
with the allocator's cost model — ≥1 in-loop direct call site (uncapped),
or direct self-recursion, since a function that calls itself is a loop the
lexical test cannot see. It is deliberately a second set, not a widening of
hot_loop_callees: raising the shared cap to 32 instead buys interp −26.5%
but regresses iso_miss +4.0%, because that cap also moves inlinehint.

interp's compiled binary grows 16 KB (+0.13%).

The two levers are independent

cmp over the corpus separates them cleanly. Forcing inline allocation
(PERRY_INLINE_NEW=1) changes 4 of 19 binaries — exactly the programs whose
new sites sit in non-loop functions. The barrier gate changes 9 — exactly
the programs whose class fields hold pointers. Only interp, iso_miss and
pipeline are in both sets; 10 of 19 binaries are byte-identical across the
full change and needed no timing at all.

Validation

  • All 19 corpus programs, both arms: byte-identical to node and exit 0,
    including iso_miss's misses 0 counter and shapes' 1176000 (the
    silent-wrong-answer canary from earlier this week).
  • The nine programs that differ also pass under PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_FORCE_EVACUATE=1; five of them additionally under
    PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_ALLOC_KB=64.
  • cargo test --release -p perry-codegen — 26 test binaries, 0 failures,
    including perf(gc): write barriers cost 16% on an all-numeric store workload — elide on provably-non-pointer stores #7511's own class_field_store_pointer_test (whose CFG-region walk
    still finds js_write_barrier_slot inside the guarded region) and
    large_object_barriers.
  • Gap suite (--filter test_gap_, node 26.5.1): running; every failure so far
    is already in test-parity/gap_snapshot.json. Result appended below.
  • cargo fmt --all -- --check, scripts/check_file_size.sh,
    scripts/addr_class_inventory.py, scripts/gc_runtime_root_holders.py — all
    clean.

★ The test is sabotage-verified, and its first draft was not

expr/class_field_barrier_tests.rs asserts the gate is REACHED (a cond_br
into class_field_set.barrier, not merely a block with that name), that the
barrier is on the TAKEN edge, and that js_write_barrier_slot is still
reachable inside it — a guard, never an elision.

The first draft asserted that the TENURED mask and the incremental-count load
were present in the branching block. It passed a sabotage that hard-wired
the branch to br i1 false while leaving the now-dead predicate instructions
sitting behind it — the exact "gate that cannot fail" shape CLAUDE.md
catalogues. The assertion now walks the def chain from the branch condition:

cond → or i1 %a, %b
%a   → icmp ne i8 %t, 0 → %t → and i8 %f, 32 → %f → load i8   (the live header)
%b   → icmp ne i32 %c, 0 → %c → atomic load of the incremental count

Both sabotages now go red with the diagnostic that names the failure:
"the gate's condition is the constant false — the branch cannot fail" and
"the barrier is on the FALSE edge — an untenured parent would take the call
and a tenured one would skip it"
.

Refuted

PERRY_WRITE_BARRIERS=0 is not a cost ceiling for the write barrier. It
makes interp 4.3× and iso_miss 5.4× slower (1.46 → 6.32, 1.71 → 9.16):
the knob is compile-time, and the GC's evacuation policy requires generated
barriers to be active, so with them off the copying minor is ineligible and the
program falls back to full mark-sweeps. It measures "no generational GC", not
"no barrier cost" — and reads backwards to anyone sizing a barrier lever with
it. Size the barrier by building the gate.

Not reached

Round 5's targets were interp ≤ 0.80 and iso_miss ≤ 1.10. At 0.844 / 1.234
they are missed. A --debug-symbols profile of the patched binary shows why:
with the outlined allocator and the barrier calls gone, evalNode's own inline
code is now essentially the whole program, and the remaining lever is the one
round 4 identified — hoisting the shape check out of a discriminant arm.
gc-handoff/INTERP5-NOTES.md carries the sizing and the two side findings
(PERRY_INLINE_CTOR=1 is worth churn −10.6% / cycles −5.8% on its own).

Summary by CodeRabbit

  • Performance

    • Improved allocation performance for hot recursive functions and functions called repeatedly inside loops by enabling faster inline allocation paths.
    • Reduced unnecessary write-barrier work during class-field updates when garbage-collection tracking does not require it.
  • Reliability

    • Strengthened garbage-collection barrier conditions to account for object generation and incremental marking state.
    • Added validation covering recursive allocation behavior, barrier execution, generated output, and binary-size impact.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds allocation-hot function analysis for inline bump allocation and adds parent GC-state gating for class-field write barriers. It also adds LLVM IR regression tests and documents benchmark and validation results.

Changes

Allocation-hot function classification

Layer / File(s) Summary
Allocation-hotness analysis
crates/perry-codegen/src/collectors/..., crates/perry-codegen/src/codegen/opts.rs, crates/perry-codegen/src/codegen/mod.rs
The collector identifies uncapped in-loop allocation calls and direct self-recursion. CrossModuleCtx stores the resulting function IDs separately from loop-hot callees.
Allocator selection and validation
crates/perry-codegen/src/function.rs, crates/perry-codegen/src/codegen/function.rs, crates/perry-codegen/src/lower_call/..., changelog.d/7871-interp-round5-alloc-and-barrier.md
Function lowering transfers alloc_hot to LlFunction. Allocation-site lowering uses it for inline bump allocation. Tests cover recursive and non-recursive functions. The changelog records benchmark and binary validation results.

Class-field write-barrier gating

Layer / File(s) Summary
Parent-state barrier gate
crates/perry-codegen/src/expr/write_barrier.rs
Class-field stores check the parent’s tenure and incremental-marking state before emitting barrier bookkeeping.
Barrier IR validation
crates/perry-codegen/src/expr/class_field_barrier_tests.rs, crates/perry-codegen/src/expr/mod.rs, changelog.d/7871-interp-round5-alloc-and-barrier.md
LLVM IR tests verify the barrier predicate, control-flow edges, SSA definitions, retained runtime calls, and default barrier configuration. The changelog records GC and barrier validation findings.

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

Possibly related issues

  • PerryTS/perry issue 7715: Covers class-field write-barrier gating, parent-generation checks, and benchmark behavior when barriers are disabled.

Possibly related PRs

  • PerryTS/perry#7566: Introduces the inline bump-allocation logic extended here to allocation-hot and recursive functions.
  • PerryTS/perry#7602: Applies parent-generation and incremental-marking barrier gating to another code-generation path.
  • PerryTS/perry#7834: Shares allocation-hotness tracking and inline allocation changes in LlFunction, compile_function, and new_alloc.rs.

Sequence Diagram(s)

sequenceDiagram
  participant HIRModule
  participant AllocHotCollector
  participant CrossModuleCtx
  participant compile_function
  participant new_site_is_in_loop
  HIRModule->>AllocHotCollector: scan loops and direct recursion
  AllocHotCollector->>CrossModuleCtx: record alloc_hot_functions
  CrossModuleCtx->>compile_function: provide allocation-hot function IDs
  compile_function->>new_site_is_in_loop: classify allocation site
  new_site_is_in_loop->>new_site_is_in_loop: select inline bump allocation
Loading
sequenceDiagram
  participant ClassFieldStore
  participant ParentGCState
  participant BarrierBlock
  participant js_write_barrier_slot
  ClassFieldStore->>ParentGCState: evaluate tenure and incremental marking
  ParentGCState-->>ClassFieldStore: return live barrier condition
  ClassFieldStore->>BarrierBlock: branch when barrier is required
  BarrierBlock->>js_write_barrier_slot: emit slot barrier
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes both primary code-generation optimizations and their performance impact, although it is longer than ideal.
Description check ✅ Passed The description thoroughly explains the changes, benchmarks, validation, risks, and unmet targets, but it omits the template's explicit issue and checklist sections.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/interp-round5

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.

@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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/perry-codegen/src/lower_call/alloc_hot_tests.rs (1)

219-233: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the uncapped in-loop rule.

This test covers direct self-recursion only. It never exceeds inline_hot_small_max_call_sites. Add a fixture with a direct in-loop call and more than four module call sites. Assert that its new site uses the inline bump allocator.

🤖 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/lower_call/alloc_hot_tests.rs` around lines 219 -
233, Add a fixture and test alongside
a_self_recursive_function_inlines_its_bump_allocator with a direct in-loop call
and more than four module call sites, exceeding inline_hot_small_max_call_sites.
Assert the generated IR contains INLINE_SLOW_CALL and INLINE_FAST_BLOCK for that
new site and does not contain OUTLINED_CALL.
🤖 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.

Inline comments:
In `@crates/perry-codegen/src/expr/class_field_barrier_tests.rs`:
- Around line 383-390: Update the assertion for count_reg in the incremental
barrier test to require its definition contains “load atomic i32”,
INCREMENTAL_GLOBAL, and “seq_cst”, preserving the existing failure context and
ensuring the incremental-count read is verified as atomic.

---

Nitpick comments:
In `@crates/perry-codegen/src/lower_call/alloc_hot_tests.rs`:
- Around line 219-233: Add a fixture and test alongside
a_self_recursive_function_inlines_its_bump_allocator with a direct in-loop call
and more than four module call sites, exceeding inline_hot_small_max_call_sites.
Assert the generated IR contains INLINE_SLOW_CALL and INLINE_FAST_BLOCK for that
new site and does not contain OUTLINED_CALL.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 91e4ada8-ec56-41ae-91f0-09f692fee6e9

📥 Commits

Reviewing files that changed from the base of the PR and between 79d2f59 and b622d35.

📒 Files selected for processing (13)
  • changelog.d/7871-interp-round5-alloc-and-barrier.md
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/codegen/opts.rs
  • crates/perry-codegen/src/collectors/hot_callees.rs
  • crates/perry-codegen/src/collectors/mod.rs
  • crates/perry-codegen/src/expr/class_field_barrier_tests.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/write_barrier.rs
  • crates/perry-codegen/src/function.rs
  • crates/perry-codegen/src/lower_call/alloc_hot_tests.rs
  • crates/perry-codegen/src/lower_call/mod.rs
  • crates/perry-codegen/src/lower_call/new_alloc.rs

Comment on lines +383 to +390
let count_reg = operand(incremental_cmp, 0).expect("icmp lhs");
assert!(
def_of(&body, &count_reg)
.unwrap_or_default()
.contains(INCREMENTAL_GLOBAL),
"the incremental clause does not read {INCREMENTAL_GLOBAL}; skipping \
the barrier also skips SATB shading:\n{body}"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline crates/perry-codegen/src/expr/write_barrier.rs \
  --items all --type function --match 'emit_parent_may_need_remembering_check'

rg -n -C 6 '\bload_atomic_seq_cst\s*\(' \
  crates/perry-codegen/src --glob '*.rs'

rg -n -P \
  'load atomic i32.*`@PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT`.*seq_cst' \
  . -g '*.ll' -g '*.rs'

Repository: PerryTS/perry

Length of output: 11586


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- class-field barrier test ---'
sed -n '330,405p' crates/perry-codegen/src/expr/class_field_barrier_tests.rs

printf '%s\n' '--- remembering check implementation ---'
sed -n '120,155p' crates/perry-codegen/src/expr/write_barrier.rs

printf '%s\n' '--- LLVM load emission ---'
sed -n '575,615p' crates/perry-codegen/src/block.rs

printf '%s\n' '--- test helpers and related assertions ---'
rg -n -C 8 'fn def_of|incremental_cmp|INCREMENTAL_GLOBAL|load atomic i32' \
  crates/perry-codegen/src/expr/class_field_barrier_tests.rs \
  crates/perry-codegen/tests crates/perry-codegen/src --glob '*.rs'

Repository: PerryTS/perry

Length of output: 27252


Assert that the incremental-count read is atomic.

Require the definition to contain load atomic i32, INCREMENTAL_GLOBAL, and seq_cst.

🤖 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/class_field_barrier_tests.rs` around lines 383
- 390, Update the assertion for count_reg in the incremental barrier test to
require its definition contains “load atomic i32”, INCREMENTAL_GLOBAL, and
“seq_cst”, preserving the existing failure context and ensuring the
incremental-count read is verified as atomic.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Per-lever attribution (dev box, best-of-7, interleaved, one compiler per column)

Both arms link the same runtime archives, so cmp is valid and the
byte-identical binaries in each column are that column's noise floor
(±1.6% / ±1.2%).

bench base PERRY_INLINE_NEW=1 (lever 2's superset) parent-generation gate alone
interp 1.1462 0.9609 (−16.2%) 1.0776 (−6.0%)
iso_miss 1.5329 1.3741 (−10.4%) 1.4729 (−3.9%)
pipeline 0.2880 0.2637 (−8.4%) 0.2785 (−3.3%)
cycles 0.1169 0.1175 0.0903 (−22.8%)
tree 1.2390 1.2355 1.0962 (−11.5%)
tree_wide 1.7680 1.7719 1.6322 (−7.7%)
deeplist 0.1112 0.1118 0.1063 (−4.4%)
byte-identical binaries 15 of 19 10 of 19

The disjointness is the evidence that each lever hits what it claims: the
allocator change moves only programs whose new sites sit in non-loop
functions; the barrier change moves only programs whose class fields hold
pointers.

Base commit and mergeability

Measured as an A/B at bf98134ba (perry 0.5.1471). main has since moved 9
commits; git merge-tree --write-tree reports a clean merge, and none of
those 9 commits touches any file this PR edits (write_barrier.rs,
new_alloc.rs, hot_callees.rs, function.rs, opts.rs — the diff between
the two is empty for all five). Two of them (#7861, #7868) will shift the
absolute baseline; the A/B delta is unaffected because the mechanisms are
disjoint.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Gap suite: 541 tests, zero regressions

./run_parity_tests.sh --filter test_gap_, node 26.5.1 (matches .node-version),
PERRY_SKIP_BUILD=1 against this branch's compiler and main's runtime
archives. 46 minutes on a box at load 20–90.

Parity Pass:   518
Parity Fail:    17
Compile Fail:    0
Crashed:         6
Skipped:         0
Parity Rate:  95.7%

All 23 non-passing tests were triaged, none is mine.

  • 15 are already recorded in test-parity/gap_snapshot.json:
    2159_defineproperty_class_prototype, 2514_settracesigint,
    4510_enum_forward_ref, backoff_options, cron_cronjob,
    dayjs_factory_arg, derived_param_props, enum_in_function_body,
    moment_methods, perfhooks_3088_3008_3010_3011,
    prop_plan_cache_invalidation, ratelimiter_memory, slugify_options,
    stream_tee_tick_parity, v8_2_3680plus.

  • 8 are NOT in the snapshot, so each was re-run against the baseline
    compiler
    (main @ bf98134ba, same runtime dir) rather than assumed:

    test baseline @ bf98134ba this branch
    specabi_reassign FAIL (output mismatch) FAIL — identical
    zlib_3285_params FAIL (output mismatch) FAIL — identical
    http_client_no_redirect_follow CRASH SIGABRT CRASH
    http_overloads_3226plus CRASH SIGABRT CRASH
    http_req_async_iterator CRASH SIGABRT CRASH
    http_res_socket_writable_onfinished CRASH SIGABRT CRASH
    fetch_request_from_node_incoming_message CRASH SIGABRT CRASH
    net_connect_bound_value CRASH SIGABRT CRASH

    The six SIGABRTs are the http/net/fetch family and reproduce on a clean
    baseline in this environment; specabi_reassign and zlib_3285_params are
    pre-existing output mismatches that are simply missing from the snapshot
    (test-parity: audit known_failures.json — every entry needs an issue # and date #797's provenance gap). test-parity/reports/latest.json was left unwritten,
    so the tracked snapshot is unpolluted.

The measured binary is the shipped code

After the mini run I edited comments (renaming the issue reference to this PR)
and added the two #[cfg(test)] test files. I rebuilt the compiler from branch
HEAD and re-compiled interp, iso_miss, cycles and tree: all four are
byte-identical to the binaries that were timed on the mini.

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