From ef44ed0902b634da0372fde6bea30f9275a7bd29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 29 Aug 2026 09:52:26 +0200 Subject: [PATCH 1/2] codegen: number-by-construction locals step inline in ++/-- MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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 #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 --- .../0000-update-number-by-construction.md | 3 +++ crates/perry-codegen/src/expr/literals_vars.rs | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 changelog.d/0000-update-number-by-construction.md diff --git a/changelog.d/0000-update-number-by-construction.md b/changelog.d/0000-update-number-by-construction.md new file mode 100644 index 0000000000..bb8b0a5b08 --- /dev/null +++ b/changelog.d/0000-update-number-by-construction.md @@ -0,0 +1,3 @@ +### Changed + +- `i++`/`i--` on a local proven to always hold a Number (the #8105 number-by-construction fact) now steps inline as `±1.0` instead of calling `js_to_numeric` + `js_numeric_step` per update. Loops like `for (let j = a.length - 1; j >= 0; j--)`, whose counter the integer fact can never admit, drop two runtime calls per iteration. diff --git a/crates/perry-codegen/src/expr/literals_vars.rs b/crates/perry-codegen/src/expr/literals_vars.rs index a67d13f71b..56ad7bafe1 100644 --- a/crates/perry-codegen/src/expr/literals_vars.rs +++ b/crates/perry-codegen/src/expr/literals_vars.rs @@ -865,8 +865,21 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // stays a BigInt (`let i = 10n; i++` → `11n`, not the Number `11` // which would make a later `i + 87n` throw a mixed-type // TypeError; test262 BigInt/prototype/toString/a-z). - let needs_numeric_coerce = - !ctx.integer_locals.contains(id) && !ctx.unsigned_i32_locals.contains(id); + // + // #8105's number-by-construction fact retires both calls for a + // reassigned NON-integer counter too (`for (let j = a.length - 1; + // j >= 0; j--)` — `j`'s init is not an Integer literal, so the + // integer fact never admits it). The fact is already trusted for a + // strictly harder claim (a bare `load double` with no value + // check), and for a value that IS a Number both calls are the + // identity this 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`. + // Boxed and captured locals are never in the set, so the capture + // arms below keep their calls. + let needs_numeric_coerce = !ctx.integer_locals.contains(id) + && !ctx.unsigned_i32_locals.contains(id) + && !ctx.number_by_construction_locals.contains(id); let is_increment_arg = match op { UpdateOp::Increment => "1", UpdateOp::Decrement => "0", From 39625ef3e66116145bf7125b4011b05dea86c25c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 29 Aug 2026 10:22:47 +0200 Subject: [PATCH 2/2] docs: renumber the changeset fragment 0000 -> 9018 The `0000-` placeholder is never a legal fragment number, and #9010's gate now rejects it outright rather than letting it reach `main` and misattribute the change at release time. --- ...r-by-construction.md => 9018-update-number-by-construction.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{0000-update-number-by-construction.md => 9018-update-number-by-construction.md} (100%) diff --git a/changelog.d/0000-update-number-by-construction.md b/changelog.d/9018-update-number-by-construction.md similarity index 100% rename from changelog.d/0000-update-number-by-construction.md rename to changelog.d/9018-update-number-by-construction.md