Skip to content

perf(codegen): resolve read-only boxed capture cells once per closure entry (−1.8% / −2.8% wolf-ecs) - #9026

Merged
proggeramlug merged 4 commits into
PerryTS:mainfrom
proggeramlug:perf/box-capture-entry-cells
Aug 29, 2026
Merged

perf(codegen): resolve read-only boxed capture cells once per closure entry (−1.8% / −2.8% wolf-ecs)#9026
proggeramlug merged 4 commits into
PerryTS:mainfrom
proggeramlug:perf/box-capture-entry-cells

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

What

An ordinary closure body that repeatedly reads a boxed capture it never
assigns now resolves the box's cell address once at entry (new runtime
helper js_box_capture_cell_ptr) and loads the cell directly per read,
through the existing trusted_box_capture_ptrs read arm (per-use load +
inline TDZ check). Only the pointer is cached — never the value — so a write
through any sibling closure sharing the binding stays visible.

Why

Every read of a boxed capture in a public closure body pays js_box_get_bits:
an is_registered_box_ptr registry probe plus one load. In wolf-ecs the probe
alone is 1.45% of the entity cycle, because the benchmark's drivers are
hoisted function declarations (function add(lB) capturing the ECS and its
queries) — the #8644/#8705 trusted-clone machinery never fires for them (it
resolves method callback parameters), so ecs/qA were re-probed and re-read
on every loop iteration.

Caching the pointer is sound where caching the value is not:

Admission is narrow: never-written bindings only (the trusted write arms store
straight through the cached pointer, which must never reach the fallback
cell); read ≥2 times or inside a loop (a cold-branch single read must not
become an unconditional entry call); not in async / generator-wrapper /
CPS-async-step bodies (the repsel context gate's own exclusions).
PERRY_BOX_CAPTURE_ENTRY_CELLS=0 restores the per-read calls.

Numbers

Sized against a hand-hoisted source ceiling before building (−1.65%/−2.72%);
the compiler version slightly beats it. Mac mini, 11 alternating pairs on the
#9016+#9018 stack:

benchmark window before after delta wins
wolf-ecs add_remove 50 ms 0.2958 0.2904 −1.82% 11/11
wolf-ecs entity_cycle 50 ms 0.2370 0.2305 −2.73% 10/11
wolf-ecs add_remove 2 s 0.2959 0.2906 −1.81% 11/11
wolf-ecs entity_cycle 2 s 0.2372 0.2307 −2.75% 11/11

IR: the two hot driver closures drop from 6 js_box_get_bits calls each to 0,
plus 2 entry resolves.

Semantics

Differential vs node, identical output: sibling-closure mutation visibility
interleaved with reads, hoisted function declarations over const bindings,
pre-initialization reads, a written shared binding read in a loop, nested-loop
reads with calls in between. The PERRY_BOX_CAPTURE_ENTRY_CELLS=0 build is
output-identical to the enabled build on the same corpus. ECS differential
probes vs node unchanged.

Testing

  • RUSTFLAGS=-D warnings cargo check --workspace --all-targets (host excludes) — clean.
  • perry-codegen and perry-runtime --lib suites.
  • Integration: issue_8655_array_subclass_indexing, issue_8690_loop_versioned_arraylike,
    issue_8897_field_push_writeback, issue_8773_closure_capture_packed_loops.
  • Lint: census, address-class, gc-store-site, file-size, raw-handle debt, runtime symbols.

Summary by CodeRabbit

  • Performance

    • Improved closure handling for repeatedly read, non-mutated boxed captures.
    • Shared updates from other closures remain visible while reducing repeated lookup overhead.
  • Configuration

    • Added an environment setting to enable or disable the optimized boxed-capture behavior.
  • Documentation

    • Added changelog documentation describing the closure capture optimization.

Ralph Küpper added 2 commits August 29, 2026 10:43
Every read of a boxed capture in an ordinary closure body paid
`js_box_get_bits`: an `is_registered_box_ptr` probe (thread-local cache +
registry, 1.45% of the wolf-ecs entity cycle by itself) followed by one load.
The PerryTS#8644/PerryTS#8705 trusted-clone machinery already retires this inside its
private clones — validated at dispatch, cell pointers cached at entry, cells
loaded per use — but only method callback parameters resolve those clones;
a hoisted function declaration called directly (`function add(lB){...}`,
capturing the ECS and its queries) runs its public body and pays the probe on
every read of every iteration.

This is the public body's variant of the same cache. Entry resolves each
admitted capture slot through the new `js_box_capture_cell_ptr`: a registered
pointer answers its own cell — boxes never move (the collector rewrites the
value inside the cell) and cell memory is never returned to the allocator
while a capturing closure is live (the PerryTS#8208 argument the update lowering
already relies on) — and an unregistered pointer answers a shared immutable
`undefined` cell, so per-read behaviour is exactly `js_box_get_bits`'s
(PerryTS#4926: invalid box reads as `undefined`) in both cases. The cached pointers
feed the existing `trusted_box_capture_ptrs` read arm: per-use cell load with
the inline TDZ check, so writes through sibling closures stay visible.

Admission is narrow by construction: only bindings the body never writes (the
trusted `LocalSet`/`Update` arms store straight through the cached pointer,
which must never reach the fallback cell), read at least twice or inside a
loop (a cold-branch single read must not become an unconditional entry call),
and never in async, generator-wrapper, or CPS async-step bodies (the repsel
context gate's own exclusions). `PERRY_BOX_CAPTURE_ENTRY_CELLS=0` restores
the per-read calls.

Differential vs node (sibling-closure mutation visibility, hoisted
function-decl consts, pre-initialization reads, shared written bindings):
identical output, and the kill switch produces byte-identical results.

Mac mini, 11 alternating pairs on the PerryTS#9016+PerryTS#9018 stack, both windows:
add_remove −1.81%/−1.82%, entity_cycle −2.75%/−2.73% (11/11 except one
10/11) — slightly better than the hand-hoisted source ceiling (−1.65%/−2.72%)
this was sized against before building.

Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ
@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 27e762fc-421e-4116-997f-e7a8f2b0e069

📥 Commits

Reviewing files that changed from the base of the PR and between 7e3f673 and f17647a.

📒 Files selected for processing (1)
  • changelog.d/9026-box-capture-entry-cells.md

📝 Walkthrough

Walkthrough

The change profiles boxed-capture usage, gates entry-cell caching with an environment variable, adds runtime pointer resolution, and caches eligible capture-cell pointers in supported closures.

Changes

Box Capture Entry-Cell Caching

Layer / File(s) Summary
Capture-use profiling
crates/perry-codegen/src/codegen/closure_collect.rs
The closure collector records capture reads, writes, and loop reads. It skips nested closure bodies.
Runtime cell resolution
crates/perry-runtime/src/box.rs, crates/perry-codegen/src/runtime_decls/strings.rs
The runtime resolves registered capture pointers to box cells and invalid pointers to a shared undefined cell. Runtime declarations expose the new helpers.
Closure entry-cell caching
crates/perry-codegen/src/expr/mod.rs, crates/perry-codegen/src/codegen/closure.rs, changelog.d/9026-box-capture-entry-cells.md
An environment-variable gate controls caching. Eligible non-async closures resolve repeated or loop-read, never-written captures once at entry. The changelog documents the behavior.

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

Merge Risk: 🔵 Low · up to 7e3f6

The optimization changes generated closure behavior and adds a runtime ABI helper; native integrations should confirm that the new symbol is only reachable through authorized generated callers. No concrete user-facing failure is identified, so the PR is mergeable with explicit owner awareness of this bounded integration risk.

Sequence Diagram(s)

sequenceDiagram
  participant ClosureCodegen
  participant js_box_capture_cell_ptr
  participant BoxCell
  ClosureCodegen->>js_box_capture_cell_ptr: Resolve eligible capture slot at closure entry
  js_box_capture_cell_ptr->>BoxCell: Resolve registered pointer or shared undefined cell
  BoxCell-->>ClosureCodegen: Return stable cell address
  ClosureCodegen->>BoxCell: Load capture value on repeated reads
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.64% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 5 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description is detailed and directly explains the optimization, scope, semantics, benchmark results, and test coverage. It does not use the template headings or provide a related issue and checkli…
Title check ✅ Passed The title clearly and concisely describes the primary change: resolving read-only boxed capture cells once per closure entry. It also includes relevant benchmark impact.
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.
Full details: Description check

Explanation

The description is detailed and directly explains the optimization, scope, semantics, benchmark results, and test coverage. It does not use the template headings or provide a related issue and checklist, but the required technical information is mostly present.

Full details: Docstring Coverage

Explanation

Docstring coverage is 63.64% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 5 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ 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.

Ralph Küpper added 2 commits August 29, 2026 11:31
The `0000-` placeholder is never a legal fragment number; PerryTS#9010's gate rejects
it outright rather than letting it misattribute the change at release time.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merged. Caching a raw cell pointer across a body is the shape that has bitten this repo repeatedly (a runtime-side cache of a heap pointer is a GC root the static checker cannot see), so I went at the two things that would make it unsound rather than the measurement.

The admission collector is the right shape. CLAUDE.md's own pitfall list says a capture collector's catch-all silently skips refs — that is exactly how a "never written" verdict goes wrong, and here a wrong verdict is not a missed optimization but a store through a cached pointer. Checked all three walkers:

  • scan_capture_use_stmt has no _ => arm — it ends with an explicit no-op list (Break | Continue | … | ReleaseBoxes), so the match is exhaustive and a new Stmt variant fails to compile instead of being skipped. That is the property that makes this safe to extend.
  • scan_capture_use_expr's catch-all recurses via walk_expr_children, so no expression form hides a write.
  • Expr::Update { id, op, prefix } carries no child expressions, so counting the write without recursing loses nothing.

Skipping nested Expr::Closure bodies is sound, and for a subtler reason than "they have their own maps": a nested write mutates the shared cell, and this body loads through the cached pointer per use, so it observes the write. Only the pointer is cached, never the value — which is what makes the whole thing work. Probed it directly (case D: a nested arrow bumps the capture inside the reading loop → 6 4, matching node).

The fallback cell deserves its narrow admission. BOX_CAPTURE_UNDEFINED_CELL is an immutable static, so it lands in .rodata and a stray store through a substituted pointer would SIGSEGV rather than corrupt silently. That is the right failure direction, but it means writes == 0 is load-bearing, not merely an optimization heuristic — worth the comment it already has.

Probe against node 26.5.1, 8 cases: read-only capture in a loop with a sibling writer between calls, a nested closure writing the cell mid-loop, read-before-initialization, two closures sharing a binding across interleaved writes, the hoisted-function-declaration shape the PR is motivated by, and a capture reassigned to a different object between calls. Byte-identical to node — and identical again with PERRY_BOX_CAPTURE_ENTRY_CELLS=0, so the kill switch is a real off state rather than a no-op.

Renumbered the fragment from 0000- to 9026-; the placeholder is now a hard failure in the changeset gate (#9010), so this would have gone red.

Validation: perry-codegen 1343/0, perry-runtime --lib 2804/0, fmt --check, run_lint_gates.sh all 60 gates passed; 2 CI-only skipped.

@proggeramlug
proggeramlug merged commit ce8e2a4 into PerryTS:main Aug 29, 2026
19 of 20 checks passed
proggeramlug added a commit that referenced this pull request Aug 29, 2026
…is red) (#9044)

* fix(build-cache): register the two unkeyed codegen env vars

CI's codegen_env_vars_are_build_cache_inputs gate fails on current main:
PERRY_BOX_CAPTURE_ENTRY_CELLS (#9026's once-per-closure-entry cell
resolution gate) and PERRY_GUARDED_PREINLINE_MAX_IR_BYTES (the
guarded-preinline size ceiling) both change emitted code but key neither
the cache nor an exclusion — every open PR is red on it. Register both as
cache inputs, same rationale as the RS4GC and TRE budgets beside them.

Claude-Session: https://claude.ai/code/session_01Ay8VyLkKbm8Hkc1xmvTEsP

* docs: add the changeset fragment for #9044

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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