Skip to content

perf(codegen): keep the numeric-array specialization when the array is captured (#6369)#6377

Merged
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:fix/6369-captured-array-specialization
Jul 14, 2026
Merged

perf(codegen): keep the numeric-array specialization when the array is captured (#6369)#6377
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:fix/6369-captured-array-specialization

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #6369.

A number[] got the guarded packed-numeric access path when it arrived as a parameter, but fell all the way back to the fully generic js_dyn_index_get when the same array was reached through a capture — a module-scope const read inside a function or an arrow closure. Same declared type, same loop, 27x.

That is the shape bundled code is made of (a large esbuild-bundled CLI app is overwhelmingly module-scope consts captured by closures), so in practice almost nothing was reaching the fast path — a captured typed array was no faster than an untyped one, i.e. the annotation bought nothing once the value was captured.

Root cause

The declared type was there all along; it just never reached the read sites. Three seams dropped it:

1. The closure receiver oracle. FnCtx.local_types is what static_type_of / is_array_expr consult to pick a specialized path. compile_function and compile_method seed it from the unfiltered module_global_types — but compile_closure seeded it from module_local_types, a map that two filters had already stripped:

Both hazards are about the typed-ABI capture representation, not about the receiver type — and #6039 had already scoped its filter to a dedicated copy (typed_abi_local_types) for exactly this reason, leaving a comment explaining that module_local_types is also the receiver oracle. The boxed filter never got the same treatment. So every captured binding lost its declared type inside a closure, and arr[i] reached the lowering as an unknown receiver.

Fixed by scoping the boxed filter the same way #6039 scoped the module-global one: closures now seed local_types from an unfiltered module_receiver_types, and every typed-ABI path keeps the filtered maps untouched.

2. The packed-loop array facts. #6011 seeds a packed-numeric array kind for a param purely from its declared type, on the explicit grounds that the loop's entry guard re-validates the actual runtime array and body-observed mutations still downgrade the seed. A module-scope binding sits in exactly that position, so it is seeded the same way — and the two packed-loop matchers now version on it, instead of rejecting every module global outright.

The storage test (packed_loop_array_binding_is_eligible) mirrors Expr::LocalGet's own precedence — capture slot → box slot → alloca → module global — which is what makes the module-global arm safe from the module-wide boxed union that compile_closure inherits wholesale.

3. A latent LocalGet bug this exposed. Expr::LocalGet's box arm did not exclude module globals, unlike LocalSet and Update which both already carry that exclusion. A module global's @perry_global_* cell holds the valueStmt::Let stores it there directly and never allocates a box — so a box deref there reinterprets e.g. an array pointer as a box pointer. It stayed latent only because a module global normally has no ctx.locals slot for the arm to find. The packed loop's invariant-global read cache installs exactly such a slot, and the read came back as NaN.

Numbers

Best of 5, 2M iterations. Node: 1.1ms.

shape before after
number[] as a parameter 2.0ms 2.0ms
number[] captured by a plain function 19.0ms 2.0ms
number[] captured by an arrow closure 55.0ms 2.0ms

Both captured forms now emit the identical guard as the parameter form, and zero js_dyn_index_get calls (verified via PERRY_LLVM_KEEP_IR=1; pinned by a test).

Soundness

Every specialized path remains a runtime-guarded fast path with the generic fallback intact — the change makes the existing guard reachable from a captured read, it does not assert any new fact. A wrong declared-type seed costs a guard check, never correctness: the entry guard re-validates kind, packed-ness and the whole index window, and side-exits to the generic loop otherwise; the matched loop body admits no call, await or closure, so nothing can rebind the global or reshape the array between the guard and the last iteration.

Verified with a node-vs-perry differential covering heterogeneous arrays ([1,'x',{}]), holes (delete a[i]), out-of-bounds, negative / fractional / non-canonical ("01", NaN) keys, rebinding the captured binding to a different array, shrinking and regrowing it under the guard, sparse arrays, and element stores: identical on all 42 assertions. A captured array also now behaves exactly like the same array passed as a parameter on every degraded shape (string-degraded, object-degraded, shrunk, holed).

New regression test crates/perry/tests/issue_6369_captured_array_specialization.rs pins both halves: the IR evidence (specialized guard present, js_dyn_index_get gone) and the spec semantics.

Tests

cargo test --release --no-fail-fast -p perry-runtime -p perry-codegen -p perry-hir -- --test-threads=12213 passed, 4 failed. All 4 (every_dispatch_entry_has_manifest_counterpart, logical_property_assignment_short_circuits_the_store_4586, native_owned_uint8array_get_fallback_uses_uint8array_helper, pod_field_read_after_dynamic_materialization_uses_number_coerce) reproduce identically on a clean origin/main tree with the same pass/fail splits — pre-existing, unrelated to this change.

Out of scope

  • Captured object bindings (the class-field get/set guards the issue asks about): measured on origin/main, they do not degrade on capture — a captured pt.x hot loop runs at 6ms vs 7ms for the same object passed as a parameter, i.e. already at parity, both reaching js_typed_feedback_class_field_get_guard. Nothing to fix; this PR leaves them unchanged (still 7ms/7ms after).
  • Arrays reached through a closure capture slot (a binding captured from an enclosing function, not module scope) are still rejected by the packed-loop matcher: their read is a js_closure_get_capture_* call, which the raw-slot fast loop cannot host, so they'd need capture-read hoisting first. They do get the per-element guarded path from fix (1). Module-scope bindings — the bundle-relevant shape, and the one in the issue — get the full packed loop.

Summary by CodeRabbit

  • Bug Fixes

    • Improved typed-ABI closure lowering by using receiver-type information distinct from local-type information, improving captured-array specialization.
    • Enhanced native region/type analysis by incorporating declared binding types for module-scope bindings.
    • Fixed boxed local lowering so module globals no longer take the boxed-dereference path.
    • Corrected computed-key call dispatch to avoid bypassing the native method lookup for non-array numeric-key cases.
    • Centralized packed-numeric loop eligibility checks to ensure optimized loops are only used when safe.
  • Tests

    • Added a regression test covering captured numeric arrays, including IR shape checks and runtime semantics.

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fe2f25a2-9d20-4961-86a4-e3f84d001b63

📥 Commits

Reviewing files that changed from the base of the PR and between ec7e725 and 495504b.

📒 Files selected for processing (11)
  • crates/perry-codegen/src/codegen/artifacts.rs
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/collectors/hir_facts.rs
  • crates/perry-codegen/src/expr/literals_vars.rs
  • crates/perry-codegen/src/lower_call/early_branches.rs
  • crates/perry-codegen/src/stmt/loops.rs
  • crates/perry/tests/issue_6369_captured_array_specialization.rs
🚧 Files skipped from review as they are similar to previous changes (9)
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/expr/literals_vars.rs
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/stmt/loops.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/codegen/artifacts.rs
  • crates/perry-codegen/src/collectors/hir_facts.rs

📝 Walkthrough

Walkthrough

The codegen preserves module receiver types separately, propagates declared binding types into native fact collection, adjusts boxed and packed-array lowering checks, fixes numeric-key method dispatch, and adds regression tests for captured numeric arrays.

Changes

Captured array specialization

Layer / File(s) Summary
Receiver type plumbing
crates/perry-codegen/src/codegen/mod.rs, crates/perry-codegen/src/codegen/artifacts.rs, crates/perry-codegen/src/codegen/closure.rs
Module compilation keeps an unfiltered receiver-type map separate from filtered local types and passes it through artifact emission into closure compilation.
Declared binding fact collection
crates/perry-codegen/src/collectors/hir_facts.rs, crates/perry-codegen/src/codegen/{entry,function,method}.rs
Native fact collection accepts binding types, seeds module-bound array facts, and receives local or empty scope maps from compilation paths.
Specialized lowering guards
crates/perry-codegen/src/expr/literals_vars.rs, crates/perry-codegen/src/stmt/loops.rs, crates/perry-codegen/src/lower_call/early_branches.rs
Module globals bypass boxed dereferencing, packed numeric loops use centralized binding eligibility checks, and numeric-key interception is restricted to array receivers.
Regression validation
crates/perry/tests/issue_6369_captured_array_specialization.rs
Tests verify specialized LLVM IR and runtime semantics for captured arrays, rebinding, shrinking, holes, exotic keys, and stores.

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

Sequence Diagram(s)

sequenceDiagram
  participant ModuleCompiler
  participant ClosureCompiler
  participant FactCollector
  participant LoopLowerer
  ModuleCompiler->>ClosureCompiler: pass module_receiver_types
  ClosureCompiler->>FactCollector: pass local_types as binding types
  FactCollector->>LoopLowerer: provide seeded array facts
  LoopLowerer->>LoopLowerer: select packed guarded loop path
Loading

Possibly related PRs

  • PerryTS/perry#6039: Updates typed-ABI closure specialization and separates module-global handling from unboxed capture slots.
  • PerryTS/perry#6096: Separates receiver-type inference from typed-ABI closure specialization while preserving module-local types.
  • PerryTS/perry#6033: Modifies native fact collection and packed-numeric array seeding.

Suggested reviewers: andrewtdiz

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR adds a separate this-binding/numeric-key dispatch fix in early_branches that is not part of #6369's captured-array specialization scope. Split the this-binding regression fix into a separate PR, or document why it is required for the #6369 change.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: preserving numeric-array specialization for captured arrays.
Description check ✅ Passed The description covers the issue, implementation, and testing, though it uses custom headings instead of the exact template.
Linked Issues check ✅ Passed The code propagates declared types to captured module-scope arrays, enabling the guarded numeric-array path and adding regression coverage for #6369.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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/collectors/hir_facts.rs (1)

607-696: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

seed_module_bindings seeds every module-scope array binding, not just ones this body reads.

The doc comment says this seeds "a MODULE-SCOPE binding this body only reads through", but seed_module_bindings unconditionally seeds every id in module_globals ∩ binding_types before collect_stmts even runs — it never checks whether stmts actually references that id. Combined with function.rs/method.rs passing the entire module's declared-type map as binding_types (they need the full map for unrelated purposes — type-aware dispatch, hazardous_module_global_ids), every function/method/closure in a module re-seeds local_kinds for every top-level typed array in the module, not just the ones it touches. For "bundled code" with many top-level number[]/typed-array consts (the scenario this PR targets), this means O(functions × module-array-bindings) extra HashMap inserts, plus wasted iteration in mark_unknown_call_escape's per-id loop, on every compile.

This is not a correctness issue — unused seeded facts just sit inert (no LocalGet/IndexGet ever queries them) — but it's avoidable: collect_array_facts already has stmts in scope before seeding, so seed_module_bindings (or its caller) could intersect module_globals with ids actually referenced in stmts before seeding, without touching the separate full local_types map that FnCtx needs for other purposes.

♻️ Sketch of a usage-filtered seed
 fn collect_array_facts(
     stmts: &[Stmt],
     params: &[perry_hir::Param],
     module_globals: &HashMap<u32, String>,
     binding_types: &HashMap<u32, perry_types::Type>,
 ) -> (ArrayFacts, EffectFacts, MaterializationHazardFacts) {
     let mut collector = ArrayFactCollector::default();
     collector.seed_params(params);
-    collector.seed_module_bindings(module_globals, binding_types);
+    let referenced = collect_referenced_local_ids(stmts); // walk stmts, collect Expr::LocalGet ids
+    collector.seed_module_bindings(module_globals, binding_types, &referenced);
     collector.collect_stmts(stmts);
     collector.finish()
 }
🤖 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/collectors/hir_facts.rs` around lines 607 - 696,
Restrict module-binding seeding to module-global IDs actually referenced by the
current statement body. Update collect_array_facts or
ArrayFactCollector::seed_module_bindings to derive the referenced IDs from stmts
before calling seed_declared_array_type, while retaining the full binding_types
map for lookups and leaving function.rs/method.rs callers unchanged.
🤖 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/tests/issue_6369_captured_array_specialization.rs`:
- Around line 1-198: Add a cargo-test-visible unit test alongside the existing
integration coverage for the captured-array specialization fix. Focus the test
on packed_loop_array_binding_is_eligible and its receiver-type plumbing,
asserting the relevant eligible and ineligible branches or a lightweight IR-text
guard without compiling or spawning the generated binary; retain the existing
end-to-end semantics test for broader validation.

---

Nitpick comments:
In `@crates/perry-codegen/src/collectors/hir_facts.rs`:
- Around line 607-696: Restrict module-binding seeding to module-global IDs
actually referenced by the current statement body. Update collect_array_facts or
ArrayFactCollector::seed_module_bindings to derive the referenced IDs from stmts
before calling seed_declared_array_type, while retaining the full binding_types
map for lookups and leaving function.rs/method.rs callers unchanged.
🪄 Autofix (Beta)

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: b66b7ef7-5acc-47e4-8947-b70378aed3de

📥 Commits

Reviewing files that changed from the base of the PR and between 6f10050 and ec7e725.

📒 Files selected for processing (10)
  • crates/perry-codegen/src/codegen/artifacts.rs
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/collectors/hir_facts.rs
  • crates/perry-codegen/src/expr/literals_vars.rs
  • crates/perry-codegen/src/stmt/loops.rs
  • crates/perry/tests/issue_6369_captured_array_specialization.rs

Comment thread crates/perry/tests/issue_6369_captured_array_specialization.rs
Ralph Küpper and others added 2 commits July 14, 2026 13:00
…s captured (PerryTS#6369)

A `number[]` got the guarded packed-numeric access path when it arrived as a
parameter, but fell all the way back to the fully generic `js_dyn_index_get`
when the same array was reached through a capture — a module-scope `const` read
inside a function or an arrow closure. Same declared type, same loop, 27x.

That is the shape bundled code is made of (a bundle is overwhelmingly
module-scope `const`s captured by closures), so in practice almost nothing was
reaching the fast path — a captured *typed* array was no faster than an untyped
one, i.e. the annotation bought nothing once the value was captured.

The declared type was there all along; it just never reached the read sites.
Three seams dropped it, each fixed here:

* The closure receiver oracle. `FnCtx.local_types` is what `static_type_of` /
  `is_array_expr` consult to pick a specialized path. `compile_function` and
  `compile_method` seed it from the unfiltered `module_global_types`, but
  closures seeded it from `module_local_types` — a map that two filters (PerryTS#5869
  boxed ids, PerryTS#5982/PerryTS#6039 module globals) had already stripped. Both filters
  exist to protect the typed-ABI *capture representation*, not the receiver
  type, and PerryTS#6039 had already scoped the second one to a dedicated copy for
  exactly this reason. Scope the first one the same way and give closures the
  unfiltered map (`module_receiver_types`); the typed-ABI paths keep the
  filtered ones untouched.

* The packed-loop array facts. PerryTS#6011 seeds a packed-numeric array *kind* for a
  param from its declared type, on the grounds that the loop's entry guard
  re-validates the real array and the matched body admits no call / await /
  closure. A module-scope binding is in exactly that position, so seed it the
  same way — and let the two packed-loop matchers version on it, instead of
  rejecting every module global outright. The storage test now mirrors
  `Expr::LocalGet`'s own precedence (capture slot -> box slot -> alloca ->
  global), which is what makes this safe from the module-wide boxed union that
  closures inherit wholesale.

* `Expr::LocalGet`'s box arm did not exclude module globals, unlike `LocalSet`
  and `Update`. A module global's cell holds the VALUE — `Stmt::Let` stores it
  there directly and never allocates a box — so the deref would reinterpret an
  array pointer as a box pointer. It stayed latent only because a module global
  normally has no `ctx.locals` slot for the arm to find; the packed loop's
  invariant-global read cache installs exactly such a slot, and the read came
  back as NaN.

Best of 5, 2M iterations (Node 1.1ms):

| shape                          | before | after |
|--------------------------------|--------|-------|
| `number[]` as a parameter      |  2.0ms | 2.0ms |
| `number[]` captured by a fn    | 19.0ms | 2.0ms |
| `number[]` captured by a arrow | 55.0ms | 2.0ms |

The captured forms now emit the identical guard as the parameter form and zero
`js_dyn_index_get` calls.

Soundness is unchanged: every specialized path stays a runtime-guarded fast path
with the generic fallback intact. A node-vs-perry differential over
heterogeneous arrays, holes, out-of-bounds, negative / fractional /
non-canonical keys, rebinding, shrink/regrow under the guard, sparse arrays and
stores is identical on all 42 assertions, and a captured array now behaves
exactly like the same array passed as a parameter on every degraded shape.
Restoring declared local types inside closure bodies made `obj[k]()` on a
plain object statically provable as a numeric-key call for the first time.
`try_lower_index_get_call` bailed on *any* numeric index, handing the call
to the array element lowering, which reads the slot and calls it as a bare
closure -- dropping the receiver. `this` came back `undefined`, resurrecting
the PerryTS#6328 defect that test_gap_6328_async_index_call guards.

The bail is only sound for real arrays, which is what its comment always
claimed. Gate it on `is_array_expr` so non-array receivers fall through to
`js_native_call_method_value`, which binds `this` for numeric and string
keys alike. Array element calls keep the specialized path, so the PerryTS#6369
perf win is unaffected.
@proggeramlug proggeramlug force-pushed the fix/6369-captured-array-specialization branch from ec7e725 to 495504b Compare July 14, 2026 11:01
@proggeramlug

Copy link
Copy Markdown
Contributor Author

The conformance-smoke (2) failure was real, not a flake: this PR reintroduced the this-binding half of #6328. Pushed a fix (495504bd6) and rebased onto main for linear history.

What happened. Seeding FnCtx.local_types for closure bodies is what the PR is for — but it also makes let k = 3 statically provable as numeric inside an async body for the first time. That is exactly the condition test_gap_6328_async_index_call documents as previously unreachable:

Inside an async function it never can (the async-to-generator transform turns body locals into boxed Any captures)

Once the key is provably numeric, try_lower_index_get_call (lower_call/early_branches.rs:208) bailed out:

if is_numeric_expr(ctx, index) && !object_is_class_ref {
    return Ok(None);   // -> array element lowering
}

That hands the call to the array element lowering, which reads the slot and calls it as a bare closure — dropping the receiver. So obj[k]() on a plain object came back with this === undefined.

Repro (thisBinding case of the existing fixture):

async function thisBinding() {
  const obj: any = { tag: "obj", 3: function () { return this.tag; } };
  let k = 3;
  console.log("thisBinding", obj[k]());  // node: "obj"   this PR: "undefined"
}

Verified causally by an in-place A/B on one tree — reverting only this PR's crates/perry-codegen/ and rebuilding restores obj.

The fix. The bail is only sound when the receiver really is an array, which is what its comment always claimed — it just never checked. It now gates on is_array_expr(ctx, object), so non-array receivers fall through to js_native_call_method_value, which binds this for numeric and string keys alike.

Array element calls still take the specialized path, so the #6369 perf win is untouched — both of this PR's own tests (captured_numeric_array_takes_the_specialized_path, captured_array_semantics_match_spec) pass, and the #6328 fixture is now byte-identical to node.

@proggeramlug proggeramlug merged commit e7290d8 into PerryTS:main Jul 14, 2026
26 checks passed
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: numeric-array specialization is lost when the array is captured from an enclosing scope (27x slower than the same array passed as a parameter)

1 participant