Skip to content

perf(codegen): compute the callee-rooting window instead of hardcoding it (#8159) - #8240

Merged
proggeramlug merged 2 commits into
mainfrom
perf/8159-narrow-callee-rooting
Aug 16, 2026
Merged

perf(codegen): compute the callee-rooting window instead of hardcoding it (#8159)#8240
proggeramlug merged 2 commits into
mainfrom
perf/8159-narrow-callee-rooting

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Closes #8159 — but not in the direction the issue expected, so the headline first.

The narrowing works, and it does not recover pipeline's 3.9%

#8159 asked whether #8084's per-call-site rooting sequence can be narrowed "e.g. skipping the reread when nothing between adopt and use can allocate". It can, this PR does it, and it recovers none of the cost on pipeline — because pipeline never reaches the arms #8084 touched.

pipeline's inner loop is rec = stage(rec), which lowers through lower_dynamic_closure_call (js_closure_unbox_callee_checked) — the #7154 lowering, already fully rooted before #8084. The emitted IR for gc-handoff/apps/pipeline.ts is byte-identical across this change, and grep finds no call to js_new_function_construct or js_closure_call_apply_with_spread in it at all. That rules the mechanism out structurally rather than failing to detect it.

So the issue's attribution to the commit stands; its attribution to that hunk does not. The +3.95% is somewhere in #8084's other ~2,500 lines — gc_map.rs (+355), gc/roots/stack_maps* (~1,660), gc/copying.rs + copying_pointer_set.rs, pin.rs, tenuring.rs, object/this_binding.rs. See the issue comment for the sharpest suspect.

What the change is

collects is not a strategy, it is a window: "can anything between this operand and its consumer collect?" #8084 hardcoded it true at five arms, which buys a slot store, a re-read and a release at every call site whether or not the window contains a collection point. operand_protection has always been able to answer this — its Reuse arm emits no push, no re-read and no truncate, keeping the pre-#8084 IR byte for byte. It just needed the truthful window, which any_operand_may_collect computes and with_operands_rooted_window has passed all along.

Three arms now compute it; two deliberately keep true and say whybundle_args_rooted opens with js_array_alloc, and a spread call reaches js_array_like_to_array unconditionally, so those windows allocate in every instance of the arm. Nothing to narrow, as opposed to nothing narrowed.

Each flag is computed below the operand it protects, as with_operands_rooted_window computes it: the predicate reads ctx, so asking before the lowering asks about a different state.

Measurements

Both arms are the same tree at 07c8040bf differing only in these three files; identical libperry_runtime.a / libperry_stdlib.a (codegen-only change, cmp-verified untouched across the second build), per-arm PERRY_CACHE_DIR, PERRY_NO_AUTO_OPTIMIZE=1, /usr/bin/time -l, min-of-5, stdout sha256 identical on every row.

row base this PR Δ
pipeline 3,952,488,683 3,953,452,078 +0.02%
interp 13,815,893,339 13,810,291,395 −0.04%
iso_miss 16,498,233,755 16,489,972,804 −0.05%
asyncpipe 1,304,733,639 1,306,205,988 +0.11%
shapes 1,325,703,306 1,323,270,162 −0.18%

All inside noise. Where it does move something is zod, which is the population that has these shapes: dep-native live bundles 36,611 → 36,598, relocates 432,545 → 432,338.

Soundness

gc_root_dominance_check.py, base vs this PR on the same build, both corpora — identical verdicts:

corpus unrooted stale seeded
dep-native (zod, 81 modules, 12,957 fns) 2 (budget 3) 0 40/40 caught
curated-native (145 sources, 172 modules) 0 1 39/40 caught

--self-test passes, so a green here means "examined and clean" rather than "declined to examine". #7803's own shape keeps its root by construction — its arguments are freshly-allocated object literals.

Two observations about clean main, neither caused by this PR (both arms report them identically), recorded so they aren't rediscovered:

  • curated-native is red at 07c8040bf: one stale hazard (test_gap_class_forward_capture_6523::__closure_7, unmasked->js_box_set, moving via js_object_get_field_by_name_f64) against --max-stale 0, plus 39/40 seeded violations caught where CI requires 40. It was invisible until fix(gc): the root-dominance symbol scan must accept the C-unwind ABI #8207, because the job aborted earlier at --audit-poll-capable.
  • dep-native reads 2, not the 3 the workflow budgets. Slack nobody knows about is where the next regression lands green. Not tightened here on purpose: the number is LLVM-version dependent and I measured it with homebrew opt, not CI's — lowering a budget I cannot verify in CI is how the gate goes red for the next person. Worth CI measuring and tightening.

Tests

temp_root_coverage/call_callee.rs — two differential pairs, in src/ so they run in the per-PR cargo-test gate rather than the nightly tier. The two fixtures in each pair differ in exactly one thing, the argument's kind.

Sabotage-checked: against #8084's hardcoded true, both NEGATIVES go red and both positives stay green. Neither half passes for nothing.

The callee local's object-literal initializer is load-bearing and is commented as such: expr_is_known_non_pointer_shadow_value suppresses rooting for a LocalGet with no reserved shadow slot whose type proof is not pointer-bearing, so a first draft using Expr::Undefined had both POSITIVES failing against a correct compiler and — worse — both negatives passing for that reason rather than the one they claim.

Local: cargo test -p perry-codegen --lib temp_root_coverage 17/17 under both lowerings, cargo fmt --all --check clean.

…g it (#8159)

#8084 closed a real moving-GC defect: five call arms lowered the CALLEE into a
bare register, lowered the arguments below it -- each of which can allocate --
and handed the original register to the consuming call. Under the shipping
statepoint lowering that register is in no live bundle, so nothing marks it and
nothing relocates it. The fix asked rooting/'s existing machinery for
protection, and paid for it with a hardcoded `collects = true` at every site.

`collects` is not a strategy, it is a WINDOW: "can anything between this operand
and its consumer collect?" Hardcoding it true buys a slot, a re-read and a
release at every call site whether or not the window contains a collection
point. `operand_protection` has always been able to answer this -- its `Reuse`
arm emits no push, no re-read and no truncate, keeping the pre-#8084 IR byte for
byte. What it needed was the truthful window, which `any_operand_may_collect`
computes and `with_operands_rooted_window` has passed all along.

  lower_call/early_branches.rs  the window is the argument lowering. The re-read
    sits above the unmask because that is where the value leaves the tracked
    domain; a collection point BELOW the unmask is a separate exposure that
    rooting the box cannot repair either way.
  expr/new_dynamic.rs  both js_new_function_construct arms: the callee's window
    is every argument, argument i's is the arguments after it. Neither reaches a
    collection point of its own -- lower_js_args_array is an entry alloca plus
    stores, emit_call_location_at emits nothing in a default build -- so
    `new C(a, b)` over plain locals is back to emitting no rooting, the shape
    operand_needs_root's own doc already claims for it.
  expr/new_dynamic.rs's NewDynamicSpread and expr/call_spread.rs KEEP true, and
    now say why: bundle_args_rooted opens with js_array_alloc and a spread call
    reaches js_array_like_to_array unconditionally, so those windows allocate in
    every instance of the arm. Nothing to narrow, as opposed to nothing narrowed.

Each flag is computed BELOW the operand it protects, as
with_operands_rooted_window computes it: the predicate reads ctx, so asking
before the lowering asks about a different state.

temp_root_coverage/call_callee.rs is two differential pairs, in src/ so they run
in the per-PR cargo-test gate rather than the nightly tier. The two fixtures in
each pair differ in exactly one thing -- the argument's kind. Sabotage-checked
against #8084's hardcoded true: both NEGATIVES go red there and both positives
stay green, so neither half passes for nothing.

The callee local is initialized to an object literal, and that is load-bearing.
expr_is_known_non_pointer_shadow_value suppresses rooting for a LocalGet with no
reserved shadow slot whose type proof is not pointer-bearing, so a first draft
using `Expr::Undefined` had both POSITIVES failing against a correct compiler
and -- worse -- both negatives passing for that reason rather than the one they
claim.

MEASURED, and the headline is negative: this recovers none of #8159's 3.9% on
pipeline. That row never reaches these arms. Its `rec = stage(rec)` lowers
through lower_dynamic_closure_call (js_closure_unbox_callee_checked), already
fully rooted pre-#8084, and the emitted IR for pipeline.ts is BYTE-IDENTICAL
across this change. Corpus instructions retired, min-of-5, same runtime archives
both arms: pipeline +0.02%, interp -0.04%, iso_miss -0.05%, asyncpipe +0.11%,
shapes -0.18% -- all inside noise, all outputs sha-identical. What it does move
is zod: dep-native live bundles 36611 -> 36598, relocates 432545 -> 432338.

Correctness, both corpora, base vs this change on the same build:
  dep-native   unrooted 2 (budget 3), stale 0, seeded 40/40 -- IDENTICAL
  curated-native  unrooted 0, 1 stale, seeded 39/40 -- IDENTICAL, and both
                  halves of that verdict are pre-existing on clean 07c8040
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

Limit details: You’ve used all 8 included reviews currently available under your plan.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d7281043-9023-426b-b797-6fdfb68a18f2

📥 Commits

Reviewing files that changed from the base of the PR and between 537f74a and 0703f58.

📒 Files selected for processing (6)
  • changelog.d/8240-narrow-callee-rooting-window.md
  • crates/perry-codegen/src/expr/call_spread.rs
  • crates/perry-codegen/src/expr/new_dynamic.rs
  • crates/perry-codegen/src/lower_call/early_branches.rs
  • crates/perry-codegen/src/temp_root_coverage/call_callee.rs
  • crates/perry-codegen/src/temp_root_coverage/mod.rs

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
proggeramlug marked this pull request as ready for review August 16, 2026 17:57
@proggeramlug
proggeramlug merged commit 389adf3 into main Aug 16, 2026
3 of 18 checks passed
@proggeramlug
proggeramlug deleted the perf/8159-narrow-callee-rooting branch August 16, 2026 18:14
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.

perf(codegen): #8084's callee rooting costs pipeline 3.9%, consuming #8157's win on that row

1 participant