Skip to content

perf(runtime): give the shadow-stack root state the hot-TLS fast path (−8% try entry) - #10619

Open
proggeramlug wants to merge 2 commits into
mainfrom
perf/try-entry
Open

proggeramlug wants to merge 2 commits into
mainfrom
perf/try-entry

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Entering and leaving a try block that does not throw costs 179 instructions; node does it in 13. try appears 4.3 times per 1k lines of real TypeScript.

179.3 → 164.8, −8.1%. This is the smallest win in this series and worth saying so plainly — but the probe measures only one of the paths it helps, and it moves a ratchet the repo actively wants moved.

What it is

CatchSavepoint::capture() runs on every try entry. Wave 1 already latched most of its fields behind "has any thread used this subsystem", but that latch does not help for SHADOW: it is set by the first shadow-frame push anywhere in the process, and for any real program that is effectively immediate — a catch (e) binding needs a shadow slot itself. So the read happens on essentially every entry.

And unlike EXCEPTION_STATE, CALL_METHOD_DEPTH and the named runtime_handle_stack / temp_roots fields — all already routed through tls_hotSHADOW was still a raw thread_local!, so this one read never got the fast path. On Darwin that means _tlv_get_addr per read. Profiling pinned it to a single call site: try_push_with_kind+176 → _tlv_get_addr, ~6% of instructions retired per entry.

The change is a storage-mechanism swap to crate::perry_thread_local! — same type, same .with()/.try_with() call sites, same const-init and drop-free semantics, so no destructor is registered and the address-stability contract js_shadow_frame_enter depends on is unchanged. After it, _tlv_get_addr no longer appears anywhere in the leaf profile.

scripts/thread_local_cold_allowlist.json's own header says new code should use perry_thread_local! for exactly this reason. This converts one and the enforced per-file count goes 2 → 1.

Measurement

arm (try80−try16)/64 control (loop80−loop16)/64 control o.a
base 179.3 0.08 46.3
after 164.8 0.04 45.8

Differenced within each binary so driver dispatch and code layout cancel before the arms are compared. N=20000, median of 7.

The 8% understates the change: SHADOW is the shadow-stack root state, read on many paths, and this probe measures only the one where it was profiled.

Validation — deeper than the size of the win, because this is precise-root state

The shadow stack is the GC's precise root set, not bookkeeping, so the fixture attacks rooting rather than confirming the happy path. test_gap_try_entry_shadow_hot_tls.ts covers a same-level catch, a throw four frames down, a throw crossing Array.prototype.map's runtime trampoline, finally on both the normal and throwing paths, nested try with an inner rethrow, a catch that itself throws, 25-level nesting with finally-order and try_depth checked afterwards, and a non-throwing control — every caught value read back after GC-pressure allocation. Byte-identical to node 26.5.1.

That fixture plus four pre-existing exception/rooting gap fixtures then ran under five PERRY_GC_SCHEDULE_SEED values at PERRY_GC_SCHEDULE_RATE=1 with PERRY_GC_SCHEDULE_ALLOC_KB=0 and from-space protection: all five byte-identical to node, with the diagnostics confirming the instrument was live rather than vacuous — forced_collections=2566, copying_minors=2566, moved_objects=37000, quarantine retiring sets with growing bytes_protected, no faults.

cargo test --release -p perry-runtime --lib: 4,010 passed, 2 failed — both confirmed pre-existing by reverting with git checkout -- and re-running them on the pristine parent. They assert debug_assert!-gated behaviour that --release compiles out. cargo fmt --all --check, file-size cap, test registration and RUSTFLAGS=-D warnings cargo check -p perry-runtime --all-targets all clean.

Ratchet note

_hot_declarations in the allowlist had drifted on main independently of this change: a clean --update on unmodified 9df5075fbe already produces 466 against a committed 460. That aggregate is not what the checker enforces — only the per-file files counts are — so it is left untouched here rather than silently absorbed into this diff. The allowlist change is one line.

Left on the table

try_push_with_kind's remaining CatchSavepoint::capture() work is unchanged, and so is js_try_end's own state resolution — it disassembles to about 28 near-minimal instructions. The larger win would be threading a pointer from js_eh_try_push to js_try_end so the exit skips resolving the state again, but that needs codegen changes across try_stmt.rs's early-exit, closure, generator and async call sites. Deliberately deferred as materially riskier for a smaller win than the runtime-only change here.

Summary by CodeRabbit

  • Performance

    • Improved the speed of non-throwing try blocks, reducing overhead by approximately 8% on supported platforms.
  • Reliability

    • Strengthened exception handling across nested try, catch, and finally flows, including rethrows and deep unwinding.
    • Improved stability when exceptions are retained during garbage collection and runtime callbacks.
  • Tests

    • Added coverage for exception handling, garbage collection, nested control flow, and repeated try execution.

Ralph Küpper added 2 commits September 18, 2026 13:26
…path

Every non-throwing `try` entry paid a real out-of-line `_tlv_get_addr`
call, unconditionally, that nothing else in `try_push_with_kind` did.

Profiling `try { t += v; } catch { t = 0; }` (differenced inside each
binary: `(try80-try16)/64` vs `(loop80-loop16)/64` control, per
CLAUDE.md's verification method, N=20000, median of 7) confirmed the
178-instruction/entry baseline and pinned the cost to one leaf via
`sample`+`PERRY_KEEP_SYMBOLS=1` at 20M iterations:
`try_push_with_kind`+176 -> `_tlv_get_addr`, ~6% of instructions
retired per entry.

Root cause: `CatchSavepoint::capture()`'s shadow field is captured
unconditionally on every entry (`shadow_stack_savepoint`), and once any
function anywhere in the process has pushed a shadow frame — true
almost immediately for any real program, since a `catch (e)` binding
itself needs one — the `SHADOW_FRAMES` latch from #7469's wave-1 fix is
already set, so the latch stops making this read rare in practice.
Unlike `EXCEPTION_STATE`, `CALL_METHOD_DEPTH`, and the named
`runtime_handle_stack`/`temp_roots` fields, `SHADOW` was still a raw
`thread_local!`, so this one read never got the `tls_hot` fast path at
all.

Fix: move `SHADOW` (crates/perry-runtime/src/gc/roots/shadow_stack.rs)
from `thread_local!` to `crate::perry_thread_local!` — a pure
storage-mechanism swap (same type, same `.with()`/`.try_with()` call
sites, same const-init/drop-free semantics: `ShadowStackState` still
has no `Drop`, so no destructor is registered) that changes nothing
about liveness, throw-time restore, or the fixed-address contract
`js_shadow_frame_enter` depends on for its whole-activation pointer
cache. Confirmed via `--trace llvm`/`sample` that the leaf disappears
entirely post-change.

Measured (mybase = own build at this commit's parent, 9df5075,
codegen-units=16; both PERRY_NO_AUTO_OPTIMIZE=1):
  before: try 179.0-179.4 instr/entry, control -0.001..-0.2
  after:  try 164.3-165.7 instr/entry, control -0.14..+0.44
  -> ~14-15 instructions/entry (~8%), controls stay noise-floor both arms.

`try_push_with_kind`'s remaining `CatchSavepoint::capture()` work and
`js_try_end`'s own (already tls_hot-fast) EXCEPTION_STATE resolution
are UNTOUCHED — js_try_end disassembles to ~28 near-minimal
instructions with nothing left to cut without threading a pointer
through codegen from push to end, which was evaluated and deferred as
materially riskier (touches try_stmt.rs's early-exit/closure/
generator/async call sites) for a smaller remaining win.

scripts/thread_local_cold_allowlist.json: only the
`shadow_stack.rs: 2 -> 1` line this change causes. `_hot_declarations`
is left untouched — verify() never reads it (only `files` is
enforced) — because a plain `--update` on unmodified main already
produces 466, not the committed 460: six pre-existing, unrelated
`perry_thread_local!` additions had drifted the count before this
change touched anything. Fixing that drift is not this PR's job.

Correctness: the shadow stack is the GC's precise root set, not pure
exception bookkeeping, so this was checked past "it compiles":
- Added test-files/test_gap_try_entry_shadow_hot_tls.ts (byte-identical
  to node v26.5.1 --experimental-strip-types --no-warnings, checked
  against both this change and the pristine parent arm): same-level
  catch, a throw 4 frames deep, a throw crossing Array.prototype.map's
  runtime trampoline, finally on both the normal and throwing paths,
  nested try with an inner rethrow, a catch that itself throws, 25
  levels of try/finally nesting with finally-order and try_depth
  restored checked afterward, and a non-throwing control loop. Every
  caught value is read back after GC-pressure allocation.
- Ran that fixture, plus the pre-existing
  test_gap_gc_catch_param_rooting.ts, test_gap_try_savepoint_subsystems.ts,
  test_gap_try_finally_no_catch_rethrow.ts and test_gap_try_setjmp_volatile.ts,
  under PERRY_GC_SCHEDULE_SEED (5 seeds) + PERRY_GC_SCHEDULE_RATE=1 +
  PERRY_GC_SCHEDULE_ALLOC_KB=0 + PERRY_GC_PROTECT_FROMSPACE=1 +
  PERRY_GC_DIAG=1 (per CLAUDE.md's rooting-bug instruments) — a
  collect-at-every-safepoint, evacuating, quarantine-and-mprotect
  schedule. All 5 seeds matched node byte-for-byte; the diagnostic
  output confirms the instrument was live, not vacuous
  (forced_collections=2566, copying_minors=2566, moved_objects=37000,
  fromspace retired_set up to #4 with bytes_protected growing) — no
  stale-shadow-stack SIGSEGV, no output drift.
- `RUST_TEST_THREADS=1 cargo test --release -p perry-runtime --lib`:
  4010 passed. The 2 failures
  (gc::tests::copy_slot_decode::sabotaged_remembering_arm_is_refused_by_the_coverage_cross_check,
  gc::tests::heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_builds)
  are pre-existing: reverted this change with `git checkout --` and
  reran just those two on the pristine parent commit — identical
  failures, same messages, confirming they assert debug_assert!-gated
  behavior compiled out under --release, unrelated to this change.
- `cargo fmt --all -- --check`, `scripts/check_file_size.sh`,
  `scripts/check_test_registration.py`,
  `RUSTFLAGS="-D warnings" cargo check -p perry-runtime --all-targets`:
  all clean.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 8d5315f9-b67b-480d-955a-baeb24eea752

📥 Commits

Reviewing files that changed from the base of the PR and between 0058bab and bfe4a5b.

📒 Files selected for processing (4)
  • changelog.d/10619-shadow-stack-hot-tls.md
  • crates/perry-runtime/src/gc/roots/shadow_stack.rs
  • scripts/thread_local_cold_allowlist.json
  • test-files/test_gap_try_entry_shadow_hot_tls.ts

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


📝 Walkthrough

Walkthrough

The change moves SHADOW to the perry_thread_local! storage path, updates the cold allowlist, documents the performance change, and adds tests for shadow-stack rooting and exception unwinding.

Changes

Shadow stack TLS and exception handling

Layer / File(s) Summary
Shadow-stack thread-local migration
crates/perry-runtime/src/gc/roots/shadow_stack.rs, scripts/thread_local_cold_allowlist.json, changelog.d/10619-shadow-stack-hot-tls.md
SHADOW now uses crate::perry_thread_local! with the same type and initialization semantics. The allowlist count and changelog are updated.
Try-entry exception coverage
test-files/test_gap_try_entry_shadow_hot_tls.ts
The new fixture tests GC rooting across throws, rethrows, trampolines, finally, deep unwinding, and repeated try entry and exit.

Priority: ⬇️ Low

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

Change: Refactor

Merge Risk: ⚪ Minimal · up to bfe4a

The TLS migration preserves the required storage behavior and is covered by deterministic exception and GC-pressure tests, so the change is ready to merge.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 56.25% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 2 files. (2 skipped: … 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 and concisely describes the main change: routing the shadow-stack root state through the hot-TLS fast path and reporting the measured performance improvement.
Description check ✅ Passed The description is detailed, relevant, and covers the change, rationale, measurements, validation, test results, and deferred work. It does not use the repository template headings or explicitly provi…
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: Docstring Coverage

Explanation

Docstring coverage is 56.25% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 2 files. (2 skipped: 2 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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.

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