Skip to content

perf(strings): per-site concat cache for "literal" + proven-small value — bench_object_property beats node - #9514

Merged
proggeramlug merged 1 commit into
PerryTS:mainfrom
proggeramlug:concat-site-cache
Sep 2, 2026
Merged

perf(strings): per-site concat cache for "literal" + proven-small value — bench_object_property beats node#9514
proggeramlug merged 1 commit into
PerryTS:mainfrom
proggeramlug:concat-site-cache

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Per-site concat cache for "literal" + value — bench_object_property beats node

bench_object_property was the last suite row still behind node after the concat memo (#9373) and its governor (#9397): 14 vs 12 ms. The memo made the obj["field_" + j] key allocation-free, but its hit is still a call into an ~850-instruction function (tag test, fract, range test, itoa into a stack buffer, ASCII scan, hash, byte compare, governor bookkeeping). Seven single-cause experiments on that function were all flat; the standing verdict was "a revisit means a per-site redesign, never shaving the current one". This is that redesign.

Mechanism

Every + site whose left operand is a source string literal gets a private [32 x i64] zeroinitializer table. Slot k is either 0 or the NaN-boxed heap string prefix + String(k) — by construction, because the prefix cannot vary at the site and only the runtime fill arm writes the table — so a filled slot needs no verification. Emitted per site:

  • gate: 0.0 <= r < 32 as two ordered fcmps (every NaN-box fails; dominates the fptosi);
  • probe: k = fptosi r, sitofp k == r, load slot k, non-zero → the cached handle is the result. For a loop counter already proven i32 the round trip folds away, leaving the gate and one load;
  • fill arm (gated value, empty slot): new js_string_concat_site_value(table, prefix, r) — answers exactly what js_string_concat_value_box does, fills the slot when the result is a heap string, and registers the slot through js_gc_register_global_root, the funnel module-global string literals already use, so evacuation rewrites it;
  • plain arm (value outside the table): the fused js_string_concat_value_box call this lane replaced — a site whose values mostly miss ("item_" + i to 500k in bench_gc_pressure) pays two fcmps and a branch over the old cost, not an extra call level.

An SSO result (≤5 ASCII bytes, "k" + 4) is cached by value without a registration: it carries no pointer, and leaving it uncached would send every call of such a site through the fill arm forever. concat.rs (memo + governor) is untouched; it still serves the fill arm and every non-admitted site. Kill switch: PERRY_CONCAT_SITE_CACHE=0.

Admission — measured, not assumed

Round 1 shipped the lane on every literal-prefix site and bench_gc_pressure lost ~1 ms at min (13 vs 12, median unchanged): its "item_" + i runs to 500k, and the gate alone (two compares and a branch) is 1-2 ns per call at 501k calls. Splitting the cold arm (round 2) did not move it, which pinned the cost on the gate, not the call level. Ceiling arithmetic from the two benches: a hit saves ~19 ns (object_property: 210k calls, 4 ms), a wasted gate costs 1-2 ns. So a site gets a table only when the right operand is proven inside 0..=255: a loop counter with a proven induction interval (the loop_bounded_i32 collector's intervals, now exposed through the facts struct), a compile-time integer (an Expr::Integer/Expr::Number literal, a module constant or never-written const with a literal initialiser — the loop proof's own set, now exposed with its intervals as LoopInductionFacts — and -/+/* of those), or x % C with C <= 256. A sweep to 255 still hits one call in eight (2.4 ns saved vs 1.75 ns spent); an unproven or large-bound operand keeps the plain fused call and the memo, so gc_pressure's site emits no table at all.

Numbers (Mac mini, interleaved with node in one window, min / median ms)

row node lane off lane on
bench_object_property 12 / 13 14 / 15 10 / 11
bench_gc_pressure 12 / 13 12 / 13 12 / 13

11 rounds each, one round = node then each binary; "lane off" is the same compiler with PERRY_CONCAT_SITE_CACHE=0, so the pair is a single-toolchain A/B. The gc_pressure pair's kept IR is byte-identical (0 diff lines), so anything between its arms is the measurement's own band: one 11-round run showed on 13/13 vs off 12/12, and the same two binaries re-run with the arm order swapped gave 12/13 vs 12/13 — a 1 ms drift-with-order effect on a 12 ms row, not the code. object_property's admitted sites are the j < FIELDS key writes (warmup and timed loop) and the "field_" + (FIELDS - 1) read; gc_pressure emits no table at all (IR census: 0 tables, 0 fill-arm calls, its 4 fused calls unchanged).

Regression guard (lane on vs off, same session): bench_string_heavy 41/41 vs 41/41, 08_string_concat 5/5 vs 5/6 — its "word" + i + " " is a chain and does not enter this arm.

Proof

  • crates/perry-runtime/src/gc/tests/concat_site.rs: fill-once/identity with exactly one root registered, every non-slot value (fractional, negative, past the table, NaN) leaves the table alone, -0 is slot 0, an SSO immediate is cached by value with no root; and a copied minor rewrites a filled slot (an independent shadow root keeps the string moving, so the premise cannot depend on the registration under test). Sabotage-run on the final code: with the js_gc_register_global_root call removed, the move test fails on the assertion that names it ("the slot must follow the moved string — is the filled slot still registered as a global root?") and the fill test fails on its root count; restored, both pass.
  • crates/perry/tests/concat_site_cache.rs: IR pin that the fill arm is CALLED (the lane fired) and a per-site table is read; output node-exact for the bench shape plus every slot-rule edge (a proven bound past the table, += on a handed-out handle, -0, 1.5, -1, NaN, 1e21, dynamic number/string/null right operands, the SSO twin), plain and under PERRY_GC_HEAP_LIMIT=8 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1; admission pin (a counter sweeping to 100k gets no table and still reaches the fused arm; a small-bound counter, a constant expression over a module constant and an integer literal get exactly three tables while the unbounded accumulator gets none — the literal case was caught by this pin: HIR spells 19 as Expr::Integer, which the first evaluator did not match); kill switch restores the plain helper (vacuity-guarded) with identical output.
  • scripts/gc_store_site_inventory.py --gate passes (the one raw store is GC_STORE_AUDIT(ROOT), registered on the next line).
  • Suites: issue_7841_declared_string_self_append, string_append_heap_alias, gc_string_coerce_property_key_rooting_6943, issue_5579_arguments_string_key_dyn_set green.

Notes for the memo owner

The hook is one if let in lower_string_concat.rs's l_is_string && !r_is_string arm, after the string_proof_is_declared_only check. The table is emitted through typed_parse_rodata, the per-function deferred raw-global sink every lowering context already drains, and the site id comes from ic_site_counter.

Summary by CodeRabbit

  • New Features

    • Added a per-site cache for repeated string concatenations involving literal prefixes and small integer values.
    • Improved performance for supported bounded loops, constants, arithmetic expressions, and modulo expressions.
    • Added an environment-variable option to disable the cache when needed.
    • Preserved correct handling for unsupported, non-integral, negative, and out-of-range values.
  • Bug Fixes

    • Ensured cached strings remain valid across garbage-collection evacuation.
  • Tests

    • Added coverage for cache reuse, garbage collection, cache eligibility, and fallback behavior.

bench_object_property was the last suite row behind node after the concat
memo (PerryTS#9373) and its governor (PerryTS#9397): 14 vs 12 ms. The memo made the
`obj["field_" + j]` key allocation-free, but a memo hit is still a call into
an ~850-instruction function (tag test, fract, range test, itoa, ASCII scan,
hash, byte compare, governor bookkeeping); seven single-cause experiments on
that function were all flat, and the standing verdict was that a revisit
means a per-site redesign. This is it.

Every `+` site whose left operand is a string literal and whose right
operand is PROVEN to stay inside 0..=255 gets a private
`[32 x i64] zeroinitializer` table. Slot k is either 0 or the NaN-boxed
string `prefix + String(k)` — by construction, since the prefix cannot vary
at the site and only the runtime fill arm writes the table — so a filled slot
needs no verification. The emitted hot path is two ordered fcmps (every
NaN-box fails them), fptosi/sitofp integrality (folds away for an i32
counter), one load and a non-zero test. The fill arm
(`js_string_concat_site_value`) answers exactly what
`js_string_concat_value_box` does, fills the slot and registers it through
`js_gc_register_global_root`, the funnel string literals already use, so
evacuation rewrites it; an SSO result is cached by value with no root. A
value outside the table takes the original fused call directly.

Admission is measured, not assumed: a gate on a value that sweeps past the
table costs 1-2 ns per call (bench_gc_pressure's `"item_" + i` to 500k lost
~1 ms at min with an unconditional lane), a hit saves ~19 ns (210k calls,
4 ms). So the lane needs a proven bound — a loop counter's induction
interval, an integer constant (literal, module constant, never-written
`const`; `-`/`+`/`*` of those), or `x % C` with small C — exposed from the
`loop_bounded_i32` collector as `LoopInductionFacts`. gc_pressure now emits
no table and is byte-identical to the lane-off build.

Mac mini, interleaved with node in one window, min/median ms:
  bench_object_property  node 12/13   lane off 14/15   lane on 10/11
  bench_gc_pressure      node 12/13   lane off 12/13   lane on 12/13
  bench_string_heavy / 08_string_concat unchanged (41/41, 5/5).

Proof: runtime lifecycle tests (fill-once + one root, non-slot values leave
the table alone, -0 is slot 0, SSO cached by value with no root, a copied
minor rewrites a filled slot — sabotage-run: removing the registration fails
the named assertion); perry integration tests (lane fires, node-exact plain
and under forced verified evacuation across every slot-rule edge, admission
pin with exact table count, kill switch PERRY_CONCAT_SITE_CACHE=0 restores
the plain helper); gc store-site gate passes; string/concat suites green.
@coderabbitai

coderabbitai Bot commented Sep 2, 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: Team

Run ID: c7c01d9e-49e6-450c-a996-fdcd648ae2af

📥 Commits

Reviewing files that changed from the base of the PR and between 1c51b65 and b603c49.

📒 Files selected for processing (11)
  • crates/perry-codegen/src/collectors/hir_facts.rs
  • crates/perry-codegen/src/collectors/loop_bounded_i32.rs
  • crates/perry-codegen/src/concat_site_cache.rs
  • crates/perry-codegen/src/lib.rs
  • crates/perry-codegen/src/lower_string_concat.rs
  • crates/perry-codegen/src/runtime_decls/strings.rs
  • crates/perry-runtime/src/gc/tests/concat_site.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/string/concat_site.rs
  • crates/perry-runtime/src/string/mod.rs
  • crates/perry/tests/concat_site_cache.rs

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


📝 Walkthrough

Walkthrough

The compiler now emits per-site caches for proven-small literal-prefix string concatenations. New loop facts support admission analysis. The runtime stores cached boxed strings with GC handling. Runtime and integration tests cover reuse, numeric boundaries, evacuation, and the disable switch.

Changes

Per-site string concatenation cache

Layer / File(s) Summary
Loop induction facts
crates/perry-codegen/src/collectors/hir_facts.rs, crates/perry-codegen/src/collectors/loop_bounded_i32.rs
Shared analysis now exposes induction intervals and integer constants through RepresentationFacts. Existing accumulator analysis uses the shared state and interval helpers.
Compiler cache lowering
crates/perry-codegen/src/concat_site_cache.rs, crates/perry-codegen/src/lower_string_concat.rs, crates/perry-codegen/src/runtime_decls/strings.rs, crates/perry-codegen/src/lib.rs
Code generation admits proven-small operands, emits 32-slot tables and probes, calls js_string_concat_site_value on misses, and preserves the existing fallback path.
Runtime cache storage and tracing
crates/perry-runtime/src/string/concat_site.rs, crates/perry-runtime/src/string/mod.rs
The runtime validates numeric slots, returns cached values, stores new results, registers heap-string slots as GC roots, and re-exports the new runtime API.
Cache behavior validation
crates/perry-runtime/src/gc/tests/concat_site.rs, crates/perry-runtime/src/gc/tests/mod.rs, crates/perry/tests/concat_site_cache.rs
Tests cover cache filling, reuse, invalid operands, SSO values, copying GC evacuation, compiler admission bounds, output comparison, and PERRY_CONCAT_SITE_CACHE=0.

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

Merge Risk: 🔵 Low · up to b603c

This PR adds bounded caching for eligible string concatenations and roots cached heap strings so they continue to work across garbage-collection movement. It is mergeable with owner awareness of a low-probability runtime risk around concurrent or asynchronous collection during cache publication; the change otherwise has no actionable merge-blocking risk in the supplied evidence.

Sequence Diagram(s)

sequenceDiagram
  participant TypeScript
  participant perry_codegen
  participant concat_site_cache
  participant perry_runtime
  participant GarbageCollector
  TypeScript->>perry_codegen: compile literal-prefix concatenation
  perry_codegen->>concat_site_cache: analyze operand and emit site table
  concat_site_cache->>perry_runtime: call js_string_concat_site_value on cache miss
  perry_runtime->>GarbageCollector: register heap-string cache slot
  perry_runtime-->>TypeScript: return cached or newly concatenated string
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.29% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 35 functions across 11 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: a per-site cache for literal-plus-small-value string concatenation. The benchmark result adds useful context and remains related to the implementation.
Description check ✅ Passed The description is detailed and on-topic. It explains the mechanism, admission rules, runtime and compiler changes, benchmarks, tests, GC behavior, and kill switch. It does not use every template head…
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.
Full details: Description check

Explanation

The description is detailed and on-topic. It explains the mechanism, admission rules, runtime and compiler changes, benchmarks, tests, GC behavior, and kill switch. It does not use every template heading or provide an explicit checklist, but it contains the required substantive information.

  • Fix all pre-merge checks with AI
✨ 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 merged commit 0b68a25 into PerryTS:main Sep 2, 2026
20 checks passed
proggeramlug added a commit that referenced this pull request Sep 2, 2026
Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit that referenced this pull request Sep 2, 2026
…h after the dispatch split, two new StringHeader payload sites via counted readers (ratchet 359->358), #9514's concat-site symbol in POLL_CAPABLE_RUNTIME, unused import in the capture-stash split
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