Skip to content

perf(codegen): inline strict === against a string literal (interp 2.29s -> 1.89s) - #7767

Merged
proggeramlug merged 4 commits into
mainfrom
perf/inline-strict-eq
Aug 10, 2026
Merged

perf(codegen): inline strict === against a string literal (interp 2.29s -> 1.89s)#7767
proggeramlug merged 4 commits into
mainfrom
perf/inline-strict-eq

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

The finding

n.kind === "num" — the shape every tree-walking interpreter, reducer and
discriminated-union dispatch is built out of — compiled to a js_eq
js_jsvalue_equals call pair. On gc-handoff/apps/interp.ts that pair plus the
memcmp under it was ~21% of the program's runtime, against 30% for the user
code itself. interp.ts is the only program in the perf corpus that resembles
real software, and this was its largest single remaining cost.

The mechanism

The call was never necessary. When one operand is a string literal, both of a
string's runtime representations are known at compile time:

what the operand is how the inline sequence decides it
the pooled literal itself icmp eq on the NaN-boxed bits — one instruction
the literal's SSO form (charAt, JSON.parse) icmp eq against a compile-time immediate
any other SSO value different bits ⇒ different content (SSO is canonical) ⇒ false
a number / int32 / pointer / bigint / null / undefined / bool not STRING_TAGfalse, no memory touched
a heap string of a different length byte_len vs a compile-time constant ⇒ false
a heap string with a different first or last byte two i8 loads inside bytes the length check proved the header owns ⇒ false
a same-length, same-endpoints heap string the only case that calls js_string_equals

For literals of ≤ 2 bytes the sequence is total — no call exists on any path.

{ kind: "num" } stores the same pooled pointer the comparison site loads, and
GC evacuation rewrites the pool root and the object slot together, so identity
survives collection. That is what makes the true case one icmp.

The two string-equality arms with no literal operand (names[i] === name)
gained only the shortcuts that need no compile-time facts: identical bits, and
SSO × SSO with differing bits. Their existing fallbacks are untouched — which
matters for the legacy arm, since it keeps js_get_string_pointer_unified's
number-coercing behaviour for operands whose string annotation lies. That
composition materializes an SSO operand onto the heap, so routing SSO × SSO
around it also removes two throwaway allocations per comparison.

Semantics

Exact ===, and in one respect stricter than what it replaces: the old
both_strings arm reached equality through js_get_string_pointer_unified,
which coerces a number to its decimal string, so (5 as any) === "5" could come
back true. Pinned against Node in
test-files/test_strict_eq_string_literal_inline.ts: NaN !== NaN, +0 === -0,
distinct heap strings with equal content are equal, int32 and double
representations of the same Number are equal, new String("num") !== "num",
null vs undefined, object identity, and the multi-byte-UTF-8 cases where "first
byte" is not "first character" ("é" vs "è", "日本" vs "月本").

Measurements — absolute seconds, quiet M1 mini, best-of-5

Outputs verified byte-identical to node --experimental-strip-types before
timing, on every row.

benchmark before after floor
interp 2.29 1.89 ≤ 1.90
iso_miss 2.72 2.36 canary: misses 0
asyncpipe 0.90 0.90 ≤ 0.93
shapes 0.28 0.28 ≤ 0.29
churn 0.42 0.42 ≤ 0.43
churn_alloc 0.37 0.37 ≤ 0.38
churn_read 0.02 0.02 ≤ 0.03
push_cls 0.36 0.35 ≤ 0.37
push_num 0.14 0.13 ≤ 0.14
cycles 0.19 0.19 ≤ 0.20
deeplist 0.25 0.24 ≤ 0.25
tree 1.69 1.63 ≤ 1.66
tree_wide 2.11 2.10 ≤ 2.15
retain 0.54 0.53 ≤ 0.55
retain_wide 1.09 1.08 ≤ 1.10
fib40 0.39 0.39 ≤ 0.40

Profile share of the equality family on interp_big.ts (symbolicated, two runs):

before after (run 1 / run 2)
js_jsvalue_equals + js_eq + memcmp ~21% 2.16% / 1.80%

evalNode (user code) is now 35% of the profile.

Tests

  • test-files/test_strict_eq_string_literal_inline.ts — the ECMAScript edges
    above, diffed against Node.
  • crates/perry-codegen/src/expr/compare_tests.rs — an IR census in the per-PR
    cargo-test gate: the streqlit.* blocks present and js_eq absent, so a
    fast path that is implemented but never reached fails here rather than
    silently. Paired negatives: loose == must keep its coercing helper, and a
    comparison with no literal operand must not use the dispatch. Plus numeric pins
    on the hand-built SSO immediate, which perry-codegen cannot share with
    perry-runtime — if that encoding drifts, "+" === "+" across a charAt
    result and a literal silently becomes false.

Measured and rejected

Marking js_string_equals nounwind willreturn readonly (the #6082 allowlist)
so a dispatch chain could keep its discriminant in a register: measured null
(1.89 vs 1.89 interleaved, six pairs). Once the fast arms contain no calls at
all, LLVM already keeps the load in a register along the hot path, and the
remaining js_string_equals sites are cold. Dropped rather than shipped as
unmeasured surface on a soundness-sensitive allowlist.

Summary by CodeRabbit

  • Performance

    • String strict-equality comparisons are now handled more efficiently through optimized inline checks.
    • Common cases such as pooled, inline, empty, Unicode, and literal strings avoid unnecessary runtime work.
  • Bug Fixes

    • Preserved exact === semantics across strings, boxed values, symbols, numbers, objects, and other types.
    • Improved handling of edge cases including NaN, signed zero, substrings, and cross-type comparisons.
  • Tests

    • Added comprehensive coverage for strict string equality, operand order, union types, and dynamic comparisons.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 seconds

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6998b653-6617-441c-ace2-d0891f4524ab

📥 Commits

Reviewing files that changed from the base of the PR and between 946f9dd and da3fd4a.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • CLAUDE.md
  • Cargo.toml
📝 Walkthrough

Walkthrough

The compiler now lowers strict string equality inline. It handles pooled pointers, SSO values, heap strings, boxed values, and runtime fallbacks. Tests cover IR dispatch, encoding helpers, JavaScript equality semantics, and varied string representations.

Changes

Strict string equality optimization

Layer / File(s) Summary
Inline literal equality lowering
crates/perry-codegen/src/expr/compare.rs
Adds SSO encoding and signed-byte helpers. Strict string literals use inline pointer, SSO, tag, length, endpoint, and heap-content checks.
SSO-aware string dispatch
crates/perry-codegen/src/expr/compare.rs
Routes canonical and general string equality through the inline routine while preserving loose equality and fallback runtime behavior.
Compiler and runtime validation
crates/perry-codegen/src/expr/compare_tests.rs, crates/perry-codegen/src/expr/mod.rs, test-files/test_strict_eq_string_literal_inline.ts, changelog.d/7767-inline-strict-string-equality.md
Adds IR and executable tests for dispatch, operand order, SSO encoding, UTF-8 bytes, value types, unions, switches, and literal comparisons. Documents the behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ExpressionLowering
  participant InlineStringEquality
  participant RuntimeStringEquality
  ExpressionLowering->>InlineStringEquality: Lower strict comparison with a string literal
  InlineStringEquality->>InlineStringEquality: Check pointers, SSO values, tags, lengths, and endpoints
  InlineStringEquality->>RuntimeStringEquality: Compare remaining heap or boxed values
  RuntimeStringEquality-->>ExpressionLowering: Return equality result
Loading

Possibly related PRs

  • PerryTS/perry#6888: Handles related NaN-boxed SSO string representations in indexing and iteration paths.

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description gives detailed technical context, measurements, semantics, and tests, but it omits the required template sections and checklist. Restructure the description using Summary, Changes, Related issue, Test plan, Screenshots / output, and Checklist headings; mark applicable items and provide issue or n/a.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main codegen change and includes its measured performance improvement.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/inline-strict-eq

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
crates/perry-codegen/src/expr/compare_tests.rs (1)

72-112: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a census for the two arms the changelog claims eliminate the call.

The current positives assert only streqlit.tag. Two claims stay unpinned:

  • a short literal emits the streqlit.sso block, which is what makes "+" === "+" work across a charAt result and the pooled literal;
  • a literal of 2 bytes or fewer emits no js_string_equals call, which is the "no call at all" claim.

Both are single contains assertions on IR you already capture.

🧪 Proposed additional tests
/// A literal short enough for an inline immediate must emit the SSO arm, or a
/// `charAt` result never matches the pooled literal.
#[test]
fn a_short_literal_emits_the_sso_immediate_arm() {
    let ir = cmp_ir(
        "streq_sso",
        CompareOp::Eq,
        Expr::LocalGet(X),
        Expr::String("+".to_string()),
    );
    assert!(ir.contains("streqlit.sso"), "{ir}");
}

/// For <= 2 bytes the endpoint checks settle the answer, so no content call
/// may survive.
#[test]
fn a_two_byte_literal_needs_no_content_call() {
    let ir = cmp_ir(
        "streq_two",
        CompareOp::Eq,
        Expr::LocalGet(X),
        Expr::String("if".to_string()),
    );
    assert!(ir.contains("streqlit.tag"), "{ir}");
    assert!(
        !ir.contains("call i32 `@js_string_equals`("),
        "a <= 2-byte literal still reaches the content compare:\n{ir}"
    );
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/expr/compare_tests.rs` around lines 72 - 112, Add
tests alongside the existing comparison tests: update the short-literal coverage
around cmp_ir to assert that comparing against "+" emits the "streqlit.sso" arm,
and add a two-byte literal case such as "if" that asserts "streqlit.tag" is
present and no "call i32 `@js_string_equals`(" appears. Keep these as IR contains
assertions and preserve the existing Eq/Ne coverage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/perry-codegen/src/expr/compare_tests.rs`:
- Around line 72-112: Add tests alongside the existing comparison tests: update
the short-literal coverage around cmp_ir to assert that comparing against "+"
emits the "streqlit.sso" arm, and add a two-byte literal case such as "if" that
asserts "streqlit.tag" is present and no "call i32 `@js_string_equals`(" appears.
Keep these as IR contains assertions and preserve the existing Eq/Ne coverage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 18d80687-439f-4569-82ac-d519da99b619

📥 Commits

Reviewing files that changed from the base of the PR and between 423bb44 and 946f9dd.

📒 Files selected for processing (5)
  • changelog.d/7767-inline-strict-string-equality.md
  • crates/perry-codegen/src/expr/compare.rs
  • crates/perry-codegen/src/expr/compare_tests.rs
  • crates/perry-codegen/src/expr/mod.rs
  • test-files/test_strict_eq_string_literal_inline.ts

Ralph Küpper added 4 commits August 10, 2026 15:32
`n.kind === "num"` compiled to a `js_eq` -> `js_jsvalue_equals` call pair;
that pair plus its memcmp was ~21% of gc-handoff/apps/interp.ts.

When one operand is a string literal both of a string's runtime
representations are known at compile time — the pooled StringHeader
pointer and, for <= 5 bytes, the canonical SSO immediate — so identity
settles the true case in one icmp, a non-STRING_TAG operand is decided
false by tag alone, and a heap string is filtered by byte_len plus its
first and last byte before any call. The no-literal string arms gain the
two shortcuts that need no compile-time facts (identical bits, SSO x SSO),
keeping their existing fallbacks.

Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ
…ands

Initializing an `any` local with a string literal refines its static
type to `string`, which routes the comparison to the both-strings arm
and made both negatives vacuous — they asserted the absence of a
dispatch that was never going to fire and the presence of a helper the
refined path never calls.

Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ
@proggeramlug
proggeramlug force-pushed the perf/inline-strict-eq branch from 946f9dd to da3fd4a Compare August 10, 2026 13:42
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merging as v0.5.1449

Verified independently

Semantics: test_strict_eq_string_literal_inline.ts is byte-identical to node 26.5.1 on my host, including the multi-byte-UTF-8 rows where "first byte" is not "first character". And the case where this is stricter than what it replaces checks out directly: (5 as any) === "5" is now false with == still true, matching node — the old both_strings arm reached equality through js_get_string_pointer_unified's number coercion, so this fixes a live correctness bug on the way to the perf win.

The IR census bites. Neutering the admission (if false && (lit_on_right || lit_on_left) …) fails 3 of 8 tests — the "implemented but never reached" case the census exists for. Both negatives are present and right: loose == must keep its coercing helper, and two any locals must not use the literal dispatch. (Two earlier sabotage attempts of mine silently missed — a wrong function-name regex, then an edit that only printed — and the 8-passed results they produced were meaningless. The third, verified-applied one is the result that counts.)

The dispatch table is the design

Enumerating every runtime representation a string can have and resolving each against compile-time knowledge is what makes this exact rather than heuristic: pooled-pointer identity in one icmp, the literal's SSO form as an immediate, SSO-canonicality making any other SSO bits false by construction, tag check before any memory touch, then length and endpoint bytes before the only remaining call. For ≤2-byte literals the sequence is total — no call on any path. n.kind === "num" never leaves straight-line code.

The pooled-identity argument is the subtle half and it is stated correctly: { kind: "num" } stores the same pooled pointer the comparison loads, and evacuation rewrites the pool root and the object slot together, so identity survives collection.

Restraint on the no-literal arms is also right: names[i] === name gains only the shortcuts that need no compile-time facts, and the legacy fallback keeps its coercing behaviour for operands whose string annotation lies — with the bonus that routing SSO×SSO around it removes two throwaway heap materializations per comparison.

Measurements

The equality family falling ~21% → ~2% on interp_big with evalNode rising to 35% is the profile actually moving to user code, which is the point. All 16 floors held, outputs byte-verified before timing. This continues #7753's arc: interp 3.96 → 2.39 → 1.89 s.

Gates 21/21.

@proggeramlug
proggeramlug merged commit 22611e4 into main Aug 10, 2026
1 of 18 checks passed
@proggeramlug
proggeramlug deleted the perf/inline-strict-eq branch August 10, 2026 13:49
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