Skip to content

perf(codegen): inline short heap string equality - #8597

Closed
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:perf/8591-tree-interpreters
Closed

perf(codegen): inline short heap string equality#8597
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:perf/8591-tree-interpreters

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Inline equality for statically proven heap strings when their lengths differ or their equal-length payloads contain at most three bytes. This targets the short identifier comparisons that dominate the tree-walking interpreter workloads in #8591 while keeping generic-key and long-string comparisons on the existing helper path.

Changes

  • Preserve the existing identity, tag, SSO, and invalid-low-pointer handling.
  • Compare heap-string length, first byte, last byte, and (for three-byte strings) middle byte before calling js_string_equals.
  • Keep the larger prefix out of string-vs-generic-key comparisons.
  • Add IR coverage for the short-string blocks and long-string fallback, plus parity coverage for distinct dynamically allocated strings of lengths 0–3 and longer.

Related issue

Fixes #8591

Performance

Measured against fefdc367b on the issue's quiet M1 mini with five shuffled interleaved repeats. Control and candidate used the same freshly rebuilt runtime archives and matching binary basenames. All 20 corpus programs were byte-exact against Node; only interp and iso_miss produced different binaries, and no other corpus row moved.

workload retired instructions median wall max RSS peak footprint
interp 8.642 G → 8.433 G (-2.41%) 0.479 s → 0.460 s (-3.93%) 32.92 MB → 32.90 MB (-0.05%) 27.07 MB → 27.03 MB (-0.12%)
iso_miss 12.461 G → 12.259 G (-1.62%) 0.713 s → 0.700 s (-1.86%) 32.46 MB → 32.49 MB (+0.10%) 26.84 MB → 26.81 MB (-0.12%)

Verdict: CLEAN. No timing, instruction, or RSS result outside the two target rows moved more than 1%.

Test plan

  • cargo build --release clean (isolated target directory)
  • Full workspace test suite (the affected-crate suites were used instead)
  • Added or updated coverage under test-files/ and in the affected crate
  • Docs not needed: no CLI, stdlib, runtime API, or UI change
  • UI backend build not applicable

Commands/checks:

  • cargo test -p perry-codegen --lib --no-fail-fast — 1,133 passed
  • cargo test -p perry-runtime --lib --no-fail-fast via scripts/test_affected_crates.sh — 2,633 passed, 4 ignored
  • Perry CLI suite via scripts/test_affected_crates.sh — 1,025 passed; the only failure is the pre-existing stale ext_zlib_covers_every_stdlib_symbol_the_flip_strips ratchet in untouched code
  • run_parity_tests.sh --filter test_strict_eq_string_literal_inline against pinned Node 26.5.1 — passed, byte-exact
  • python3 scripts/check_test_registration.py — passed
  • scripts/pre-tag-check.sh --quick — passed
  • cargo fmt --all -- --check — passed
  • 20-program sweep corpus — byte-exact against Node

Screenshots / output

Not applicable.

Checklist

  • I have NOT bumped the workspace version or edited CLAUDE.md / CHANGELOG.md
  • My commits follow the loose feat: / fix: / docs: / chore: prefix convention used in the log
  • I've read CONTRIBUTING.md and agree to the Code of Conduct

Summary by CodeRabbit

  • Performance

    • Improved equality checks for short heap-allocated strings by comparing length and content inline before using the general fallback.
    • Reduced instructions for common string comparisons without changing memory usage or generic-key behavior.
  • Bug Fixes

    • Preserved correct strict-equality results for strings of varying lengths, including substring-generated values.
  • Tests

    • Added coverage for matching and mismatching short strings, longer strings, and generic key comparisons.

@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bf9b8ecc-82ae-47a5-9dd1-54a35ba4f88e

📥 Commits

Reviewing files that changed from the base of the PR and between fefdc36 and d8f363f.

📒 Files selected for processing (4)
  • changelog.d/8597-short-heap-string-equality.md
  • crates/perry-codegen/src/expr/compare.rs
  • crates/perry-codegen/src/expr/compare_tests.rs
  • test-files/test_strict_eq_string_literal_inline.ts

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


📝 Walkthrough

Walkthrough

The compiler now emits inline length and payload checks for statically proven short heap strings. Longer strings and invalid handles use js_string_equals. Tests cover generated IR, generic-key dispatch, and string lengths from zero through nine.

Changes

String equality optimization

Layer / File(s) Summary
Inline short-heap comparison
crates/perry-codegen/src/expr/compare.rs
Adds short heap-string equality lowering with handle validation, length checks, payload-byte checks, and runtime fallback.
Equality-path wiring
crates/perry-codegen/src/expr/compare.rs
Enables the optimization for canonical and statically proven string comparisons. String-versus-unknown comparisons retain unified fallback behavior.
Regression and behavioral validation
crates/perry-codegen/src/expr/compare_tests.rs, test-files/test_strict_eq_string_literal_inline.ts, changelog.d/8597-short-heap-string-equality.md
Adds IR assertions, generic-key coverage, runtime string comparisons, and changelog benchmark results.

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

Merge Risk: ⚪ Minimal · up to d8f36

The change localizes short heap-string equality handling and retains the existing fallback for other comparisons; no actionable merge-blocking risk remains after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant StringEqualityLowering
  participant ShortHeapEquality
  participant JSStringEquals
  StringEqualityLowering->>ShortHeapEquality: Lower statically proven heap-string equality
  ShortHeapEquality->>ShortHeapEquality: Check handle, length, and payload bytes
  ShortHeapEquality->>JSStringEquals: Compare longer strings or invalid handles
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main change: inlining short heap string equality in code generation.
Description check ✅ Passed The description includes the required summary, changes, issue, test plan, screenshots status, and checklist, with relevant performance results.
Linked Issues check ✅ Passed The PR meets issue #8591 objectives by reducing instructions on both targets, preserving corpus parity, reporting timing changes, and quantifying RSS impact.
Out of Scope Changes check ✅ Passed The code, tests, changelog entry, and performance data are directly related to the linked optimization objective.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 3 files. (1 skipped: 1 unsupported.)
✨ 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.

@proggeramlug
proggeramlug marked this pull request as ready for review August 22, 2026 09:56
proggeramlug added a commit that referenced this pull request Aug 22, 2026
…icts resolved (#8602)

* fix(codegen): replace the pre-RS4GC optnone knob with a post-rewrite budget assertion and per-unit codegen stats (#8583)

Three things, none of them an optimization-policy change.

1. `PERRY_LL_PREOPT_OPTNONE_INSTRS` is deleted. It stamped `optnone` BEFORE
   `rewrite-statepoints-for-gc`, and the new pass manager skips `mem2reg`/
   `sccp` on an optnone function while RS4GC (a module pass keyed on the `gc`
   attribute) still runs — so a demoted function kept its root allocas
   unpromoted and the collector never saw them. The new
   `optnone_before_rs4gc_hides_every_root_from_the_collector` test shows the
   rewritten fixture with no `gc-live` bundle and its allocas intact. The cap
   defaulted to 0, so no build hit it; it is removed rather than calibrated.

2. `PERRY_LL_RS4GC_MAX_INSTRS`: after the rewrite and its verify, any function
   past the per-function budget (default 1.5 Mi instructions — between
   #8128's ~413k known-fine and ~2.1M known-hang) makes the unit fail with
   the function's name, its size before and after the rewrite, and the
   override spellings (`<n>`, `warn:<n>`, `0`). This is an assertion about
   relocation fan-out, not a fallback: nothing is demoted, #8421's contract
   that every function is optimized at the requested level stands, and an
   estimator miss now fails in seconds instead of hanging for hours.
   `rs4gc_budget_fires_only_on_the_rewritten_module` pins that the check is
   post-rewrite: the same fixture is under the budget before RS4GC and over
   it after. Both caches key on the new variable; the old key is gone.

3. `UnitCodegenStats`: under `PERRY_CODEGEN_UNIT_TIMINGS` the native unit
   pipeline prints, per unit, the function count, instruction totals and the
   widest function before and after RS4GC, the growth factor, and the
   rewrite/optimize/emit times; at freeze time it names each unit's widest
   function by estimated IR size before LLVM starts. A stuck build now says
   which function it is stuck on.

* docs: changelog fragment for #8586

* perf(codegen): spill giant-function GC roots to a shadow frame to bound RS4GC fan-out (#8583)

`rewrite-statepoints-for-gc` inserts one relocation per GC value live across
each safepoint, so its cost scales with `live_roots × safepoints`. The Claude
Code bundle's 68 MB minified entry function measured 795 root slots × ~106k
safepoints; RS4GC grew it 439k → 6.5M instructions and the `-Os` middle-end
then did not finish in practical time. Measured on that isolated unit: without
RS4GC the whole thing optimizes at `-Os` in ~5s and 520 MB; the fan-out is the
entire pathology.

This makes the precise-root LOWERING a per-function choice. A function whose
estimated relocation count (`root_slots × safepoint_sites`, counted from the
HIR) exceeds `PERRY_ROOT_SPILL_RELOCATIONS` (default 4,000,000) keeps its roots
in the heap shadow frame — the pre-#7370 lowering — instead of native
statepoints, so RS4GC skips it. Nothing else changes: the function is still
compiled at the requested optimization level (this is not an opt-level
downgrade — see #8586's contract), the runtime already scans shadow-frame and
stack-map roots in one walk, the frame pointer is kept so the FP-chain walker
steps over the spilled frame, and each spilled function is reported at default
verbosity. `=0` disables spilling (every function on statepoints, prior
behavior); the estimate is also backstopped by #8586's post-RS4GC instruction
budget, which fails loudly if a function the estimate missed still fans out.

Mechanism: a `force_shadow_frame` flag on `LlFunction` routes
`enable_shadow_frame_inner` / `reserve_shadow_slot` / `define_header` into the
existing shadow path; `codegen/helpers::maybe_spill_roots_to_shadow_frame`
sets it, before the frame is built, at the five sizing sites
(function / method×2 / closure / module entry). No new lowering or runtime
code — it reuses exactly what `PERRY_RS4GC=0` builds.

Tests: a codegen test that a spilled function drops the statepoint strategy,
keeps the frame pointer, and emits the shadow frame while its sibling keeps
statepoints; `count_safepoint_sites` unit tests (including that nested closures
are not counted); and an end-to-end differential GC test
(`gc_root_spill_mixed_frames_8583`) that compiles one program with spilling off
and aggressively on, then runs both under every moving-collector arm and
requires byte-identical output — a mixed statepoint/shadow-frame stack whose
live roots a relocating minor must find in both frame kinds. Both caches key on
the new variable.

* perf(codegen): CPU-aware default for native LLVM unit workers (#8583)

The native codegen-unit pipeline hard-coded 2 concurrent LLVM workers (#8017,
chosen for Windows pagefile pressure and applied on every platform), while the
text path already scales with the machine. On the Claude Code `cli.js` — ~84
codegen units, many of them dense generated closures whose RS4GC pass + ISel
run for minutes each — two-at-a-time was the dominant wall-clock bottleneck
once the giant entry function's roots were spilled (#8583) so no unit fans out
unboundedly and per-unit peak RSS is a bounded ~1-2 GiB.

Default is now half the logical CPUs clamped to [2, 8] on non-Windows; Windows
keeps 2 until its pagefile behavior under higher fan-out is measured.
`PERRY_CODEGEN_UNIT_JOBS` overrides everywhere. Bounds test included.

* perf(codegen): inline short heap string equality

* docs(changelog): note short string equality optimization

* chore: PR-key the fragments for #8589 and #8593

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via #8602, which carries this branch's commits with the cache-key union conflicts against current main resolved.

#8586 merged first and added its own entry to the compile cache-key lists; #8589 and #8593 each append theirs at the same insertion point in build_cache.rs, object_cache.rs and object_cache_tests.rs. Taking either side would have silently dropped the other's key — and a dropped cache key means the object cache stops keying on a variable that changes generated code, so a stale object gets served after that knob flips.

Verified after resolution: PERRY_ROOT_SPILL_RELOCATIONS, PERRY_CODEGEN_UNIT_JOBS and PERRY_LL_RS4GC_MAX_INSTRS all present in both cache files, and PERRY_LL_PREOPT_OPTNONE_INSTRS correctly absent since #8586 deleted it.

Validation on the resolved tree: cargo check --workspace --all-targets exit 0, perry-codegen --lib 1146 passed, perry --bin perry object_cache 47 passed (the suite whose fixture the conflict was in), perry-runtime --lib 2633 passed, all seven ratchets and cargo fmt --check clean.

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.

Opt target 2 — iso_miss (2.11x) and interp (1.48x): tree-walking interpreters

1 participant