Skip to content

perf(codegen): number-by-construction locals step inline in ++/-- (−2.1% / −1.1% wolf-ecs) - #9018

Merged
proggeramlug merged 3 commits into
PerryTS:mainfrom
proggeramlug:perf/update-number-by-construction
Aug 29, 2026
Merged

perf(codegen): number-by-construction locals step inline in ++/-- (−2.1% / −1.1% wolf-ecs)#9018
proggeramlug merged 3 commits into
PerryTS:mainfrom
proggeramlug:perf/update-number-by-construction

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

What

i++ / i-- on a local carrying the #8105 number-by-construction fact now
steps inline (fadd/fsub ±1.0) instead of calling js_to_numeric +
js_numeric_step per update. One added clause in the Update lowering's gate;
boxed and captured locals are never in the fact, so the capture arms keep their
calls.

Why

The two calls exist for BigInt stepping and ToNumeric coercion, and the gate
admitting the inline arm predates #8105: only integer_locals /
unsigned_i32_locals qualified. A counter like

for (let j = arch.length - 1; j >= 0; j--) { ... }

can never be admitted by the integer facts — its init is not an Integer
literal — so every j-- paid two runtime calls per loop iteration. That is
the standard "iterate an untyped array backwards" shape; wolf-ecs's benchmark
drivers run it once per entity per operation (js_numeric_step 1.15% +
js_to_numeric/js_number_coerce 1.26% of add_remove).

The number-by-construction collector admits a local only when its initialiser
and every later write is spec-guaranteed to evaluate to a Number, and the fact
already licenses a harder optimization (a bare load double with no value
check, #8105). For a value that IS a Number, both retired calls are the
identity the inline arm computes: js_to_numeric routes a non-BigInt through
js_number_coerce (identity on a Number), and js_numeric_step's non-BigInt
arm is literally numeric ± 1.0 (value/dynamic_arith.rs).

Semantics

Differential check against node, exact-output identical: fractional counters
reassigned in loops, a.length - 1 inits, postfix returning the old value,
prefix returning the new, NaN propagation, the (-1)++ → +0 (not -0) edge,
BigInt locals (still step as BigInt — not in the fact), string counters (still
coerce — not in the fact), and 2^53-boundary doubles.

Numbers

Mac mini, 11 alternating pairs, measured on top of the #9016 build (the change
is independent of #9016 — different file, different mechanism):

benchmark window before after delta wins
wolf-ecs add_remove 50 ms 0.3022 0.2959 −2.12% 11/11
wolf-ecs entity_cycle 50 ms 0.2396 0.2371 −1.08% 10/11
wolf-ecs add_remove 2 s 0.3024 0.2958 −2.14% 11/11
wolf-ecs entity_cycle 2 s 0.2399 0.2372 −1.12% 11/11

IR confirmation: the two benchmark driver closures drop from
step=2 to_numeric=2 calls to zero; the remaining callers are field updates
(this.entID++), a different lowering.

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.
  • Lint: census, address-class, file-size, raw-handle debt.
  • ECS differential probes vs node unchanged.

Summary by CodeRabbit

  • Performance
    • Improved loop counter updates for numeric local variables.
    • Reduced runtime overhead for increment and decrement operations, especially in loops that count down from computed values.
    • Preserved existing numeric handling for boxed or captured variables to maintain correct behavior.

The Update lowering keeps `js_to_numeric` + `js_numeric_step` for any counter
outside `integer_locals`/`unsigned_i32_locals`, to preserve BigInt stepping and
ToNumeric coercion. That gate predates PerryTS#8105: a local admitted by
`collect_number_by_construction_locals` can never hold a BigInt (or anything
else non-Number) — the collector admits a local only when its initialiser and
every later write is an expression the spec guarantees evaluates to a Number,
and the fact is already trusted for the strictly harder claim of licensing a
bare `load double` with no value check.

For such a value both calls are the identity the inline arm computes:
`js_to_numeric` routes a non-BigInt through `js_number_coerce` (identity on a
Number), and `js_numeric_step`'s non-BigInt arm is exactly `numeric ± 1.0`. So
the gate now accepts the fact and emits the plain `fadd`/`fsub`.

The shape this retires is a loop counter no integer fact can admit:
`for (let j = a.length - 1; j >= 0; j--)` — the init is not an Integer
literal, so `j--` paid two runtime calls per iteration. wolf-ecs's benchmark
drivers run exactly that loop per operation. Boxed and captured locals are
never in the set, so the capture arms keep their calls.

Differential check vs node (fractional counters, postfix/prefix returns, NaN,
-0, BigInt locals, string counters, 2^53-boundary doubles): identical output.

Mac mini, 11 pairs vs the PerryTS#9016 build, both windows:
add_remove −2.12%/−2.14%, entity_cycle −1.08%/−1.12% (11/11 except one 10/11).

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: e4569b20-b46d-4fe6-ab26-276c2d87d97d

📥 Commits

Reviewing files that changed from the base of the PR and between ef44ed0 and 39625ef.

📒 Files selected for processing (1)
  • changelog.d/9018-update-number-by-construction.md

📝 Walkthrough

Walkthrough

The update lowering now uses inline numeric stepping for locals proven to contain Numbers. The changelog documents this optimization.

Changes

Numeric update optimization

Layer / File(s) Summary
Number-proven update lowering
crates/perry-codegen/src/expr/literals_vars.rs, changelog.d/9018-update-number-by-construction.md
Expr::Update skips numeric coercion for number-by-construction locals and uses inline fadd/fsub operations. The changelog documents the optimization.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to ef44e

The change replaces redundant numeric coercion and stepping calls with an equivalent inline update only for locals proven to contain Numbers, while preserving existing behavior for other values. No actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 1 files. (1 skipped: 1 … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the codegen optimization for number-by-construction locals in ++/-- and includes the measured benchmark impact.
Description check ✅ Passed The description is detailed and covers the change, motivation, semantics, benchmark results, related issue reference, and testing. It does not use the template headings or include the checklist, but t…
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 covers the change, motivation, semantics, benchmark results, related issue reference, and testing. It does not use the template headings or include the checklist, but the required technical information is mostly present.

Full details: Docstring Coverage

Explanation

Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 1 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 10:16
The `0000-` placeholder is never a legal fragment number, and PerryTS#9010's gate now
rejects it outright rather than letting it reach `main` and misattribute the
change at release time.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merged. The one-clause change is sound because the fact it leans on is already stronger than what it needs: collectors/number_by_construction.rs admits a local only when its initialiser and every later write is structurally spec-guaranteed to evaluate to a Number — declared types are explicitly never evidence (#7773) — and it is already trusted for the harder claim of a bare load double with no value check. It is also fail-closed in exactly the directions that matter here: parameters, captured locals, closure-boxed locals and module globals are all excluded, and let x; drops the local since undefined is not a Number. So the capture arms keep their calls, as you say.

Given that, both retired calls really are the identity: js_to_numeric routes a non-BigInt through js_number_coerce, and js_numeric_step's non-BigInt arm is numeric ± 1.0. The edges are IEEE-exact under fadd/fsub too — -1 + 1 is +0.0 not -0.0, and NaN propagates — which matches the differential you ran.

Fixed on the branch: the fragment was changelog.d/0000-…, the unfilled placeholder. That is no longer merely untidy — #9010 made it a hard failure, so this PR would have gone red on the changeset gate. Renumbered to 9018-.

Incidentally this was the gate's first live catch. Running it against this PR before the fix:

$ ./scripts/check_changeset_fragment.sh PerryTS/perry 9018
::error::A changelog.d fragment still carries the 0000- placeholder. Rename it to changelog.d/9018-<slug>.md
exit=1

Validation with main merged in: perry-codegen 1342/0, perry-runtime --lib 2794/0, fmt --check, and run_lint_gates.sh all 60 gates passed; 2 CI-only skipped.

@proggeramlug
proggeramlug merged commit cbc3925 into PerryTS:main Aug 29, 2026
16 of 17 checks passed
proggeramlug added a commit that referenced this pull request Aug 29, 2026
… entry (−1.8% / −2.8% wolf-ecs) (#9026)

* codegen: resolve read-only boxed capture cells once per closure entry

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 #8644/#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 #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
(#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 #9016+#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

* fix: restore #9017's runtime declares clobbered by a cross-branch file checkout

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

* docs: renumber the changeset fragment 0000 -> 9026

The `0000-` placeholder is never a legal fragment number; #9010's gate rejects
it outright rather than letting it misattribute the change at release time.

---------

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