Skip to content

perf(runtime): concat a chain of heap strings without transient roots (iso_miss -16%) - #7912

Merged
proggeramlug merged 8 commits into
mainfrom
perf/isomiss-concat-no-collect
Aug 12, 2026
Merged

perf(runtime): concat a chain of heap strings without transient roots (iso_miss -16%)#7912
proggeramlug merged 8 commits into
mainfrom
perf/isomiss-concat-no-collect

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

iso_miss is the worst ratio in the 19-program corpus (2.89× node). A quarter
of it was transient-root bookkeeping in js_string_concat_chain, and none of
that bookkeeping was doing anything.

The mechanism

gc-handoff/apps/iso_miss.ts is a tree-walking interpreter whose environment
lookup appends a trace string per frame:

seen = seen + "[" + names[i] + "]";

That is one js_string_concat_chain(parts, 4) per frame walked, ~9 M times.
concat_chain_sized roots every part into RUNTIME_HANDLE_STACK before the
result allocation and re-reads every one of them after it, because
string_storage_alloc can collect and a copying minor would move the parts
out from under the copy loop.

Darwin has no local-exec TLS, so each thread_local! access is an
_tlv_get_addr call; with the RefCell borrow and the Vec push that is
~10 round trips per 4-part chain. xctrace on 6d9f12e60:

share of iso_miss
RuntimeHandleScope::root_string_ptr 8.48%
RuntimeHandle::get_raw_const_ptr 4.91%
js_string_concat_chain itself 8.29%

13.4% of the program was root bookkeeping — more than the concatenation it
was protecting.

The fix: buy the guarantee instead of paying for the roots

arena_cell_alloc's FIRST step is try_alloc_current, a pure bump of the
block that is already open. Everything after it — gc_check_trigger(), the
cross-block scan, reserve_arena_block — is a collection point or can reach
one. So:

A successful try_alloc_current is the runtime's own proof that no
collection ran, and therefore that nothing moved.

With that proof the transient roots are not merely unnecessary, they are
unreachable work.

  • arena::arena_alloc_gc_no_collect / string::string_storage_alloc_no_collect
    — allocate, or refuse; never reach the collection point.
  • js_string_concat_chain grows a fast arm that admits only chains whose every
    part is already a live heap string (those need no js_jsvalue_to_string, so
    classification allocates nothing), sizes them, and allocates through the
    no-collect entry. Zero handle operations.
  • On a refusal it falls through to the original rooted path unchanged. A
    refusal is not an event — nothing has collected — so the operands are still
    readable where they were, and the rooted path re-roots and re-reads them
    exactly as it always did.

The admission scan runs before the sizing scan and touches nothing but the
parts array, so a chain with a number in it reaches the rooted path having
paid n register compares rather than n cold StringHeader loads it is about to
throw away.

Results — retired instructions, dev box, best-of-N, exit-checked

The dev host ran at load 30–200 all session (five concurrent agents), so wall
clock cannot resolve this. /usr/bin/time -l reports retired instructions on
Apple Silicon; those are load-independent and reproduce to ~0.05% here. Full
19-program corpus, both arms, byte-exact output and exit 0 in every cell.

bench ratio (fix / base)
iso_miss 0.842
asyncpipe 0.986
the other 17 0.996 – 1.003

Both arms built from scratch in their own CARGO_TARGET_DIR with an identical
-p set, off c4b2c1c8e. ★ Re-measure your own baseline: #7906 landed
mid-round and inlines the RuntimeHandle accessors — it moved iso_miss's
base 18.456 → 18.325 Gi and took that much off this change's headroom
(−16.4% → −15.8%). Measured, not assumed. The final binaries were rebuilt from
branch HEAD and reproduce 15.423 Gi (0.03% from the measured arm).

interp is 0.9998 — interp.ts is the same program without the trace-string
instrument, which is exactly the control this change predicts.

★ The corpus sweep caught a +5.5% regression the targeted A/B would have shipped

The first cut measured iso_miss −16.7% and interp 0.0%, and regressed
pipeline by +5.53% — a program whose profile does not contain
js_string_concat_chain at all. It was not the concatenation change. To reach
the no-collect primitive, that cut had refactored two functions every
allocation in the program goes through: arena_alloc_gc into a
const MAY_COLLECT: bool generic, and arena_cell_alloc's first statement
into a call. Both #[inline]/#[inline(always)], both "should" have been
free. PERRY_GC_DIAG=1 showed identical GC schedules across the arms
(12 copying minors / 6 steps / 6 drains), so it was pure mutator work.

The fix was to stop touching them. arena_alloc_gc and arena_cell_alloc are
now byte-for-byte main's, and the no-collect entry is written out
separately — its only divergences are two refusals (oversized request; the hot
free-list latch set, which nothing in the tree ever sets), so it can only ever
hand back memory arena_alloc_gc would have handed back identically.

Coverage, and the first version of it that could not fail

Nine tests: seven in string::tests::concat_chain_no_collect, two in
arena::tests.

★ The first cut of the safety test asserted only "a small concat reached no
GC trigger". That is vacuous — a small allocation into a block with room does
not reach the trigger through the collecting arena_alloc either. Replacing
the no-collect entry's body with arena_alloc left it green. The tests now
drive the block to the point where the two entries must diverge:

  • no_collect_alloc_refuses_a_full_block_instead_of_collecting — fills the
    open block through the entry, asserts it eventually refuses, that
    gc_trigger_arena_calls() stayed at 0 across the whole fill, and that the
    collecting fallback still serves afterwards.
  • a_full_block_falls_back_to_the_rooted_path_with_the_same_answer — builds
    the operands, fills the block (through the no-collect entry, so nothing
    moves), then asserts the chain took the rooted path and produced the same
    bytes. This is the arm that used to be reachable only in production.
  • plus: the fast path is live on every scratch-size class (4/8/32); a
    non-heap-string part falls back; an empty part contributes neither bytes nor
    flags (the rooted loop ORs piece_flags inside its blen > 0 guard, and a
    divergence there would be a silent WTF-8 change); utf16_len summing and
    high→low surrogate canonicalisation survive the fast path.

Sabotage, run, RED:

sabotage result
no-collect entry calls the collecting arena_alloc 2 tests fail
fast path skips canonicalize_surrogate_pairs 1 test fails

Validation

  • cargo test -p perry-runtime --lib2169 passed, 0 failed
  • 19-program corpus byte-exact + exit 0, both arms
  • whole corpus byte-exact under
    PERRY_GC_PROTECT_FROMSPACE=1 …_DEPTH=200 PERRY_GC_VERIFY_EVACUATION=1,
    with the instrument shown live (retired_set=#49, 940 MB protected on
    iso_miss) rather than assumed
  • canary checksum 437840 misses 0
  • the whole test_gap_* suite vs node 26.5.1 — 516 pass / 17 fail /
    13 node-skip, and every one of the 17 is IDENTICAL on the baseline

    (re-run arm-by-arm, not assumed)
  • cargo fmt --all -- --check, scripts/check_file_size.sh

Rebased onto 05edeac94 (after #7907 / #7913 / #7914) and re-verified there:
cargo test -p perry-runtime --lib 2178 passed, 0 failed. #7914 touches
the promoted-page index and this change touches the allocation entry point —
unrelated mechanisms, and the rebase was a clean textual merge in
arena/mod.rs (two use lists) with both intact.

One unrelated line in the diff: arena/mod.rs's #[cfg(test)] pub(crate) use page_meta::{..} block is reflowed. That block is byte-identical to main and
is what cargo fmt --all produces — main is currently fmt-dirty there from
#7914, and this branch carries the two-line fix only so its own lint gate can
be green.

Summary by CodeRabbit

  • Performance

    • Improved string concatenation performance by adding a fast path for eligible heap-string chains.
    • Avoids unnecessary garbage-collection work when allocations can be completed immediately.
    • Automatically falls back to the existing allocation behavior when needed.
  • Bug Fixes

    • Preserved correct handling for UTF-16 text, surrogate pairs, empty strings, and allocation limits.
  • Tests

    • Added comprehensive coverage for optimized concatenation, fallback behavior, and allocation safety.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ceac72b8-adf7-4d50-9bc7-79430b6599cb

📥 Commits

Reviewing files that changed from the base of the PR and between 05edeac and 925f3b5.

📒 Files selected for processing (8)
  • changelog.d/7912-concat-chain-no-collect.md
  • crates/perry-runtime/src/arena/allocators.rs
  • crates/perry-runtime/src/arena/block.rs
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/arena/tests.rs
  • crates/perry-runtime/src/string/concat.rs
  • crates/perry-runtime/src/string/mod.rs
  • crates/perry-runtime/src/string/tests.rs

📝 Walkthrough

Walkthrough

The runtime adds current-nursery-block allocation helpers that refuse requests requiring collection. Heap-string concatenation uses these helpers through an unrooted fast path and falls back to the existing rooted path when operands or allocation are unsupported.

Changes

No-collection concatenation

Layer / File(s) Summary
Current-block allocation foundation
crates/perry-runtime/src/arena/allocators.rs, crates/perry-runtime/src/arena/block.rs, crates/perry-runtime/src/arena/mod.rs, crates/perry-runtime/src/arena/tests.rs
The arena adds current-block-only allocation helpers, exports them, and tests refusal for exhausted nursery blocks and oversized requests.
Heap-string concatenation fast path
crates/perry-runtime/src/string/mod.rs, crates/perry-runtime/src/string/concat.rs, crates/perry-runtime/src/string/tests.rs, changelog.d/7912-concat-chain-no-collect.md
Heap-string chains attempt direct no-collection allocation and copying. Unsupported operands or allocation refusal use the rooted fallback. Tests cover metadata, UTF-16 behavior, surrogate handling, scratch sizes, and fallback cases.

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

Sequence Diagram(s)

sequenceDiagram
  participant concat_chain_sized
  participant concat_chain_all_heap_strings_no_collect
  participant string_storage_alloc_no_collect
  participant arena_alloc_gc_no_collect

  concat_chain_sized->>concat_chain_all_heap_strings_no_collect: Try heap-string fast path
  concat_chain_all_heap_strings_no_collect->>string_storage_alloc_no_collect: Request result storage
  string_storage_alloc_no_collect->>arena_alloc_gc_no_collect: Request current-block allocation
  arena_alloc_gc_no_collect-->>string_storage_alloc_no_collect: Return storage or refusal
  string_storage_alloc_no_collect-->>concat_chain_all_heap_strings_no_collect: Return allocation result
  concat_chain_all_heap_strings_no_collect-->>concat_chain_sized: Return string or use rooted fallback
Loading

Possibly related PRs

✨ 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/isomiss-concat-no-collect

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 force-pushed the perf/isomiss-concat-no-collect branch from a3075ed to 925f3b5 Compare August 12, 2026 06:26
@proggeramlug
proggeramlug marked this pull request as ready for review August 12, 2026 06:28
@proggeramlug
proggeramlug merged commit c109b08 into main Aug 12, 2026
0 of 19 checks passed
@proggeramlug
proggeramlug deleted the perf/isomiss-concat-no-collect branch August 12, 2026 06:30
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