Skip to content

perf(codegen): type erased numeric predicates - #9921

Draft
proggeramlug wants to merge 5 commits into
PerryTS:mainfrom
proggeramlug:perf/typed-numeric-predicate
Draft

perf(codegen): type erased numeric predicates#9921
proggeramlug wants to merge 5 commits into
PerryTS:mainfrom
proggeramlug:perf/typed-numeric-predicate

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Compiler-only, on main 504e180d0. Written by codex from the campaign's YF6 map (cc-perf-campaign/codex/REPORT_map_yf6.md); not yet compiled into a cc bundle — the perrymaster rows and the lowering proof on the real bundle will be appended here.

Why

On the current best cc bundle the wide-character predicate YF6 (136 || terms of cp >= a && cp <= b range tests, 152 generic relational calls per invocation) is the largest single mutator symbol: 9.46 % self on the 3300-char reply, called once per grapheme from the wrapText loop. Its parameter is erased (any) in the bundle, so the existing typed-i1 clone declined it (ParamNotI1).

What changes

  • typed_i1_function_param_reps: a Boolean-returning, non-async, non-generator, capture-free top-level function with erased (Any/Unknown) parameters is admitted to the typed-i1 clone with those parameters as F64 when the complete straight-line body lowers under the typed-i1 body rules with that assignment and contains at least four numeric comparisons (ERASED_NUMERIC_PREDICATE_MIN_COMPARISONS). Declared scalar parameters keep their declared reps; nothing changes for functions that already admitted.
  • The public JSValue entry guards every inferred F64 parameter once and keeps the unchanged generic body for guard failure, so a non-number argument runs the spec path.
  • typed_i1_function_rejection_reason reports None for such a function (instead of ParamNotI1), so the module-level clone inventory and the trampoline agree.

Evidence to land

  • Lowering proof on the bundle: YF6's generic name gains a typed-i1 clone and the trampoline's one-time guard (objdump/nm on the compiled bundle), plus the native-proof regression test in native_proof_regressions.rs.
  • Rows: the best bundle (I7-view) vs I7-view + this commit, 5×3300 + 2×400, and a perf draw with YF6's self share (from 9.46 %).

Gates

Authored: the native-proof regression test. Not yet run here (disk floor): cargo build --release -p perry, the codegen test target, the bundle compile. Draft until the rows are on this PR.

Evidence so far (from the authoring run)

  • Before, clean main compiler on cc-perf-campaign/codex/yf6_probe.js (23 ranges + 22 equalities, called with codePointAt): param q: Any, param_not_i1=1, no $typed_i1 clone, 23 js_rel_ge + 23 js_rel_le calls in the predicate.
  • After, emitter-level (the regression's saved LLVM): …is_wide$typed_i1(double %arg1) with fcmp oge/ole/oeq and zero @js_rel_ references; the generic fallback survives with its js_rel_ge/le calls; the public entry holds one numeric tag guard, calls the clone on success and the generic body on failure; the codePointAt caller calls the guarded public entry, never the clone directly.
  • The regression is sabotage-sensitive: removing the erased-parameter eligibility removes the $typed_i1 symbol and fails it.

Gates run / not run

  • Run: cargo test -p perry-codegen after the change: 1,939 passed, 0 failed, 5 ignored (31 suites); the new regression passed. cargo build --release -p perry was run only on clean main (for the before trace).
  • Not run (11 GB disk, below the build floor): the post-fix cargo build --release -p perry default-feature gate, the probe recompiled with the post-fix CLI, any linked timing, the cc bundle. All of these run on perrymaster; the bundle marker is perry_fn_cli_2_1_112_js__YF6 self share from 9.46 %.

Gate history

  • f01946abd (this head, picked onto the campaign's best tree as ac0d8c6f6) on perrymaster: cargo build --release -p perry rc=0; cargo test --release -p perry-codegen rc=0 with typed_i1_erased_numeric_predicate_guards_once_and_uses_f64_comparisons and every other typed_i1_* clone test green by name.
  • Probe falsifier (yf6_probe.js, --no-auto-optimize --no-link --trace hir,llvm --focus isWide --explain-lowering): control compiler rejects with param_not_i1=1, defines no isWide$typed_i1, and calls js_rel_ge 23 / js_rel_le 23. The branch compiler defines isWide$typed_i1 once, adds exactly 23 fcmp oge + 23 fcmp ole + 22 fcmp oeq (46/46/44 total vs 23/23/22), and has zero @js_rel_ calls inside the clone; the generic body keeps its 23 + 23 relational calls as the wrapper's fallback.
  • Next: the full cc bundle from this compiler with the best tree's configuration, the identity count on the artefact (perry_fn_cli_2_1_112_js__YF6$typed_i1 1 vs 0), then paired rows and a 999 Hz perf draw. Stays draft until those are here.
  • Bundle identity FAILED (perrymaster, full cc bundle from this compiler with the best tree's configuration): nm count of perry_fn_cli_2_1_112_js__YF6$typed_i1 = 0 in the branch bundle (and 0 in the control); the branch bundle holds 4 $typed_i1 clones in total. The admission fires on the probe but not on the real YF6, so no rows were taken for this arm. Next: the HIR dump and typed-clone rejection lines for YF6 on the bundle (PERRY_NATIVE_REPS_ALL_TYPED_CLONE_REJECTIONS=1 … --no-link --trace hir --focus YF6 --explain-lowering), then a follow-up commit that makes the admission match the real function, then the identity check again before any rows. Until then this PR has no measured effect on cc.
  • YF6 HIR from the bundle (perrymaster, --trace hir --focus YF6): YF6 (params: 1, async: false, exported: false), param q: Any, body = one Return of a left-deep 136-term || chain; source is only >=, <=, ===, &&, || with decimal literals (76 / 76 / 60 comparisons) — the probe's shape at three times the size. The bundle compiled with this branch holds 4 typed-i1 clones (Tu_, vu_, zF6, one more) and none for YF6, so the admission fails on size or depth or on the return-type inference for a deep tree, not on shape. The whole-bundle explain run died at 85 % of codegen under 23 GB RSS before printing its rejection summary. Follow-up in progress: find the failing condition statically, fix it, and add a regression that lowers the real 136-term function verbatim.
  • Root cause found and fixed (ae8e15aa2): not the admission — HIR return-type inference caps recursive expression inference at 48 levels (perry-hir/src/lower_types.rs), and YF6's 135-deep left-associated || spine exceeded it, so an inner subtree inferred Any, the logical unification propagated Any to the root, Function.return_type stayed Any, and codegen rejected with ReturnTypeNotI1 before the erased-parameter admission was consulted (it only runs on ParamNotI1). The 22-join probe stays below the cap, which is why it passed. Fix: LogicalAnd/LogicalOr return inference flattens the chain onto a worklist with the same sound rule (every leaf infers to the same non-Any type), the depth-48 guard intact, plus a 512-node work cap covering YF6's 423 nodes. New tests/yf6_admission.rs lowers the exact 2,274-byte function verbatim and asserts the Boolean return type, YF6$typed_i1 with 76 oge / 76 ole / 60 oeq, zero @js_rel_ in the clone, the generic fallback and the guarded wrapper. Not yet compiled; perrymaster rebuilds the compiler and the bundle and checks the identity count before any rows.
  • ae8e15aa2 on perrymaster (picked onto the best tree, and separately on this branch's own base): cargo build --release -p perry green; cargo test --release -p perry-codegen green except the new regression's last assertion — everything before it passed on both trees: the real 136-term YF6 infers Boolean, gets YF6$typed_i1 with 76 oge / 76 ole / 60 oeq, no @js_rel_ in the clone, fallback intact. The failing assertion read the fixture caller's public entry, but a caller with a string parameter is lowered as caller$spec_b / caller$generic behind js_typed_string_arg_guard, and the guarded wrapper call lives in those clones.
  • 4d95f5423 (head, test-only): the assertion reads the union of the caller and its specialisation clones. Next on perrymaster: the compiler and full bundle from this head, the identity count on the artefact (YF6$typed_i1 = 1, the whole point), then rows and the perf draw.

Measured — YF3 (perrymaster: compiler and full bundle from this head on the best tree; identity on the artefact perry_fn_cli_2_1_112_js__YF6$typed_i1 = 1 (control 0), 5 typed-i1 clones in total; quiet box, paired 5-round rotation vs the best bundle)

3300 turn CPU (5 rounds) 400 (2 rounds) peak RSS settled
best bundle 2.36, 2.27, 2.35, 2.27, 2.30 0.97, 0.90 570–580 equal
this PR 2.37, 2.35, 2.35, 2.32, 2.39 0.98, 0.93 571–582 equal
Δ +0.4, +3.5, 0.0, +2.2, +3.9 % +1.0, +3.3 %

The 999 Hz draw of this PR's bundle shows the clone running: YF6$typed_i1 1.04 % self (+ zF6$typed_i1 0.05 %, the public YF6 0.05 %) against YF6 at 9.46 % self on the best bundle's draw. So the predicate's self share is gone from the profile and the turn did not move: either the 9.46 % was sampling attribution into a 26 KB function whose cost is really paid by its callers, or the wrapper's guard falls through to the generic body for the values codePointAt returns. Call counts of the clone vs the generic body during one reply, and the symbol-level diff of the two draws, are being taken to decide which. Not a landing candidate on these rows.

Verdict from the draw split (same tool as the reference draw; 2,263 main-thread samples): collector 34.0 % vs 34.3 %, wrapText subtree 62.0 % vs 59.3 %; the clone is called from the grapheme loop (0.85 % inclusive) and no generic body appears, so the guard is not falling through. The loop did not get cheaper: its mutator leaves are now memcmp 5.8 % and js_object_get_field 4.4 % (before: memcmp 7.1 %, YF6 6.5 %, js_regexp_new 3.0 %). The 136 comparisons were cheap instructions overlapping with the string and field traffic the loop is bound by, so removing them does not shorten the turn. The typed clone is correct and free, but not a cc win; the branch stays draft as a correctness-neutral compiler improvement with its own regression, and the loop's remaining cost is the receiver-probe and string-payload work targeted elsewhere.

Call-level confirmation (uprobes on the compiled bundle, one 3300-char reply): the public wrapper's fast path fires 1,486,021 times, the generic entry 0 times, the clone's straight path 1,486,021 times, AF6 entries 1,481,897 (one predicate call per width call); every argument at the guard is a plain double (the reply's code points), never a tagged value. Paired same-session draws: YF6 self 9.6 / 10.5 % → YF6$typed_i1 0.94 %, AF6 inclusive 10.0 / 11.9 % → 2.35 %, js_segments_view_regexp_test inclusive 7.9 / 7.0 % → 2.0 / 3.1 %; the collector's share rose 34.2 / 31.5 % → 37.1 / 37.3 % (budgeted majors and dead-owner work) and js_object_get_field, js_native_call_method, dispatch_primitive and the buffer probes rose by the same total inside the same loop. About 14 points left the predicate's subtrees and the same amount reappeared as memory-side work; the five paired rows (+0…+4 %) are the truth.

@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

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.

The entry was created under PerryTS#9919, which is the GC pointer-validation PR.
This change is PR PerryTS#9921.

@jdalton jdalton left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of f01946abd549110dab4a6c00836140c18c979ca5 (2026-09-07).

The diff consistently threads the inferred parameter representation through clone emission, inventory, and the public trampoline. The remaining acceptance gap is behavioral guard-failure coverage: the new test inspects LLVM but never executes the wrapper with a numeric string, an object with side-effecting valueOf, BigInt, NaN, or undefined from an out-of-range codePointAt. Please add a linked parity fixture for those cases, asserting the coercion count as well as the Boolean result. That would distinguish a retained-but-unreachable generic body from a functioning fallback. No correctness defect established in the inspected lowering.

Validation scope: source/diff inspection; I have not run this PR's build or test suite locally.

Ralph Küpper added 3 commits September 7, 2026 07:14
Infer long &&/|| chains with a bounded iterative walk so generated Boolean
predicates retain their Boolean return type past the general recursion cap.
Cover the exact YF6 source and its guarded codePointAt caller through HIR and
LLVM lowering.
Record the failing inference condition, the bounded iterative fix, validation
status, and the requested perrymaster bundle and perf checks.
…tion clones

The fixture caller takes a `string`, so the lowering splits it into
`caller$spec_b` / `caller$generic` behind `js_typed_string_arg_guard`; the
guarded YF6 wrapper call lives in those clones, not in the public entry the
assertion read. Assert over the union of the caller and its clones.
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