Skip to content

perf(codegen): unary + proves a Number by construction — const v = +o.a goes 20 to 9 instructions (#10777) - #10781

Closed
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:perf/10777-unary-pos-numeric
Closed

proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:perf/10777-unary-pos-numeric

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Part of #10777.

expr_numeric_by_construction required rec(operand) for Pos, the same condition it applies to Neg and BitNot. That was not a soundness guard — it was a missed proof.

Unary + is ToNumber, which either completes holding a Number or throws. A BigInt and a Symbol both throw a TypeError; an object goes through ToPrimitive and then ToNumber again, so a valueOf returning a string yields a Number and one returning a BigInt throws; undefined is NaN, and NaN is a Number. A throw stores no value, so the store-universe question this fixpoint asks is vacuous on that path. There is no input for which +x finishes holding something other than a Number, so Pos needs no operand condition at all.

Neg and BitNot keep theirs, because ToNumeric is BigInt-preserving: -1n is -1n and ~1n is -2n, neither a Number.

The missed proof left the accumulator unproven, so its add kept a per-iteration tag test:

fixture base fix node bun
const v = +o.a; h += v 20 9 7.03 4.27
const v = +a[0]; h += v (Float64Array) 20 9 7.50 4.48
const v = -o.a 29 29
const v = o.a * 1 (control) 9 9

Flat at both fit ranges, float accumulator, output identical to node. It closes the perry-versus-perry gap exactly onto the *1 / -0 control. It does not reach parity — perry at 9 still trails node's ~7.

One behavioural line; the rest is the comment recording why Pos differs from its neighbours.

Half of my own patch was cut

I had also relaxed Neg | BitNot to the "either side not BigInt" shape the neighbouring Sub | Mul | Div | Mod arm uses. Building a third distinct binary with that guard removed entirely produced zero behavioural change-5n still printed bigint|-5. So the guard is unwitness-able through that route, and the relaxation also measured zero (up_neg and up_bitnot at 29 on every arm).

Shipping an unwitnessed, unmeasured relaxation alongside a measured one is the part that should be cut, so Neg and BitNot are byte-identical to upstream. Pos => true has no guard to witness because the specification gives it none.

Real programs: an absence, not a zero

No fixture in the suites exercises this. 0 of 52 realsuite files, 0 of 97 rungs, 0 of 14 resid contain a unary-plus binding. The one real-code site is clisuite/csv.ts:6 (amt = +parts[2]), and there the readings were +2,043 / +8,275 / +13,964 / −8,372 / +10,974 instructions on a 51.1 M baseline — noise in both directions. An earlier single run read −6,146; I am not quoting it as a result.

Two greens discarded rather than reported

The CLI wall-clock harness first returned 0.00s / 0.00s OK on all four programs — /usr/bin/time -f %e has 0.01 s resolution and these run in 3–9 ms. Re-measured with perf_counter_ns, min of 60 interleaved: +1.14 / −2.03 / −0.31 / −1.09 %, mixed signs, noise.

Gates

cargo fmt --check clean, check_file_size.sh OK, local_binding_type_audit.py OK with no new allowlist entry, gc_runtime_root_holders.py ran and passed. perry-codegen 1648 passed / 0 failed; perry-runtime 4097 passed / 0 failed; perry-transform 157 passed. perry-hir has one failure, eval_classifier::tests::remedy_is_scoped_to_bundled_npm_shims, verified failing identically on the untouched base tree — pre-existing, and this change is in perry-codegen. Node identity: clisuite 4/4, realsuite 51/1 (the known nest diff, #10733), both arms unchanged.

https://claude.ai/code/session_01YaNfLEjMRdhCLtk3SB5MdJ

Summary by CodeRabbit

  • Performance

    • Improved optimization of unary + numeric expressions, including property and array access patterns.
    • Reduced instructions for eligible accumulator loops, matching equivalent multiplication and subtraction forms.
  • Documentation

    • Documented numeric behavior for unary +, while clarifying the distinct handling of unary - and bitwise complement.

…nd condition (PerryTS#10777)

`expr_numeric_by_construction` required `rec(operand)` for `Pos`, the same as
for `Neg` and `BitNot`. That was not a soundness guard, it was a missed proof.

Unary `+` is ToNumber, which either completes holding a Number or throws: a
BigInt and a Symbol both throw a TypeError, an object goes through ToPrimitive
and then ToNumber again, `undefined` is NaN, and NaN is a Number. A throw
stores no value, so the store-universe question this fixpoint asks is vacuous
on that path — there is no input for which `+x` finishes holding something
other than a Number.

`Neg` and `BitNot` keep their operand condition, because ToNumeric is
BigInt-preserving: `-1n` is `-1n` and `~1n` is `-2n`, neither a Number.

The missed proof left the ACCUMULATOR unproven, so its add kept a
per-iteration tag test:

  const v = +o.a;  for (…) h += v     20 -> 9 Ir/iteration
  const v = +a[0]; for (…) h += v     20 -> 9   (Float64Array)

which is exactly where `o.a * 1` and `o.a - 0` already sat. node is 7.03 and
7.50 on the same fixtures, bun 4.27 and 4.48, so this closes the
perry-versus-perry gap and does not reach parity.

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

coderabbitai Bot commented Sep 20, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: e4f9d132-0b67-4419-8df1-55b6761b2fef

📥 Commits

Reviewing files that changed from the base of the PR and between dd00a00 and 31efa00.

📒 Files selected for processing (2)
  • changelog.d/10777-unary-pos-numeric.md
  • crates/perry-codegen/src/collectors/ptr_shape_numeric.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The numeric-expression analysis now treats unary + as numeric by construction. Unary - and ~ retain operand checks. The changelog documents the resulting accumulator optimization.

Changes

Numeric-by-construction analysis

Layer / File(s) Summary
Unary plus numeric classification
crates/perry-codegen/src/collectors/ptr_shape_numeric.rs, changelog.d/10777-unary-pos-numeric.md
Unary + now returns true without checking its operand. Unary - and ~ still require recursive numeric checks. The changelog records the reduction from 20 to 9 instructions per accumulator iteration.

Priority: ⬇️ Low

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

Change: Refactor

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the code-generation optimization and the measured reduction for unary +. It is specific and directly related to the main change, although somewhat long.
Description check ✅ Passed The description explains the change, rationale, affected operators, measured results, limitations, related issue, and validation status. It does not use the template headings or checklist format, but …
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. 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 …
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.
✨ Finishing Touches 💡 1
🧪 Generate unit tests (beta)
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

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

Copy link
Copy Markdown
Contributor Author

Landed via merge train 235 (#10788) as v0.5.16141afa961894. Closing #10779 with it.

Your commits are on main unmodified (the train rebases, so SHAs changed; the trees did not). Only your two unique commits were picked — the perf chain this branch carried as ancestry had already landed as v0.5.1611 and v0.5.1613.

The fix is verified closed against the train's own pinned artifacts, not just against your measurements. Same four-pattern probe I ran on main before queueing this:

bits v0.5.1611 (before) v0.5.1614 (now) node 26.5.1
0x7FF9_0000_0000_0001 typeof=string / isNaN=false number / true number / true
0x7FFD_0000_0000_0001 typeof=object / isNaN=false number / true number / true
0x7FFF_0000_0000_0001 typeof=string / isNaN=false number / true number / true
0x7FFE_0000_1234_5678 number / isNaN=false number / true number / true

Gap sweep weighted at the blast radius — every read of a double out of binary data: gc_ 54, json 43, buffer 15, numeric 15, typed_ 12, number 10, dataview 5, math 2, arraybuffer 1. Zero unexplained regressions, all five pinned artifacts byte-identical before and after, six unit suites with an empty failing set.

Both behavioural notes are in the train body rather than only here, since the body is what becomes the release note: NaN payload bits are no longer preserved (101 of 144 cases differ in bits only — matches JSC and SpiderMonkey, differs from node), and the two regressing rows are named explicitly with the Float32Array inline tier called out as +12% on a path that already loses to node.

Your rejected-alternatives table is preserved in the body verbatim in substance, particularly that canonicalising only in-band NaNs is unsound because a signalling NaN quiets into the band and fneg/fabs move negative payload NaNs in. That is exactly the kind of reasoning a later reader would otherwise "simplify" back into the bug.

One thing still open, not blocking: PERRY_NANBOX_CANON=0 is the right arm-distinctness control, but per the GC-knob kill policy an escape hatch needs a CI arm exercising its off state or a plan to delete it after one release of soak. Worth settling before it becomes another untested configuration.

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.

2 participants