-
-
Notifications
You must be signed in to change notification settings - Fork 155
perf(eh): owned single-phase unwinder — 14.3x on deep unwinds, 5.6x on shallow throws (#7302) #7308
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
159aeb1
2ced1c6
9294de4
e74c697
1d98d64
3a54ac1
5c82f45
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,187 @@ | ||||||||||||||||||||||||||||||
| # CI arm for the exception transport (#7302) and its owned unwinder. | ||||||||||||||||||||||||||||||
| # | ||||||||||||||||||||||||||||||
| # Two things need exercising, and neither is visible from behavior alone: | ||||||||||||||||||||||||||||||
| # | ||||||||||||||||||||||||||||||
| # 1. The owned single-phase walker must actually CARRY throws. If it | ||||||||||||||||||||||||||||||
| # silently starts declining every throw, every test still passes — the | ||||||||||||||||||||||||||||||
| # system unwinder is the fallback and semantics are identical — and the | ||||||||||||||||||||||||||||||
| # only symptom is that the optimization quietly stopped happening. So | ||||||||||||||||||||||||||||||
| # this job asserts `fallback=0` with a nonzero `fast=` count, not merely | ||||||||||||||||||||||||||||||
| # that the program produced the right answer. | ||||||||||||||||||||||||||||||
| # 2. The walker's agreement with the system unwinder must be re-proven, not | ||||||||||||||||||||||||||||||
| # assumed from the bring-up run. `PERRY_EH_WALKER=diff` predicts the | ||||||||||||||||||||||||||||||
| # landing for every throw and asserts it inside the personality against | ||||||||||||||||||||||||||||||
| # the system unwinder's answer; this job requires a nonzero verified | ||||||||||||||||||||||||||||||
| # count and zero declines. | ||||||||||||||||||||||||||||||
| # | ||||||||||||||||||||||||||||||
| # Both are the "assert the subject was LIVE" rule from CLAUDE.md's "Four | ||||||||||||||||||||||||||||||
| # ways a gate can be unable to fail" — a green run here means the fast path | ||||||||||||||||||||||||||||||
| # ran and agreed, not that nothing threw. | ||||||||||||||||||||||||||||||
| # | ||||||||||||||||||||||||||||||
| # NON-REQUIRED until it has run green once on main; promote afterwards (a | ||||||||||||||||||||||||||||||
| # new gate has never been green, so promoting first blocks every PR). | ||||||||||||||||||||||||||||||
| name: eh-transport | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # No trigger-level `paths:` — a required check whose workflow is | ||||||||||||||||||||||||||||||
| # path-filtered at the trigger never creates a check run for unrelated PRs, | ||||||||||||||||||||||||||||||
| # so the context waits forever and blocks the merge. Filtering lives in the | ||||||||||||||||||||||||||||||
| # `changes` job: a job skipped by `if:` still reports a check run. | ||||||||||||||||||||||||||||||
| on: | ||||||||||||||||||||||||||||||
| pull_request: | ||||||||||||||||||||||||||||||
| push: | ||||||||||||||||||||||||||||||
| branches: [main] | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| concurrency: | ||||||||||||||||||||||||||||||
| group: eh-transport-${{ github.ref }} | ||||||||||||||||||||||||||||||
| # Cancel superseded PR runs, never main runs — a busy merge day would | ||||||||||||||||||||||||||||||
| # otherwise starve the gate to zero executions. | ||||||||||||||||||||||||||||||
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| jobs: | ||||||||||||||||||||||||||||||
| changes: | ||||||||||||||||||||||||||||||
| runs-on: ubuntu-latest | ||||||||||||||||||||||||||||||
| timeout-minutes: 5 | ||||||||||||||||||||||||||||||
| permissions: | ||||||||||||||||||||||||||||||
| contents: read | ||||||||||||||||||||||||||||||
| pull-requests: read | ||||||||||||||||||||||||||||||
| outputs: | ||||||||||||||||||||||||||||||
| relevant: ${{ steps.filter.outputs.relevant }} | ||||||||||||||||||||||||||||||
| steps: | ||||||||||||||||||||||||||||||
| - id: filter | ||||||||||||||||||||||||||||||
| env: | ||||||||||||||||||||||||||||||
| GH_TOKEN: ${{ github.token }} | ||||||||||||||||||||||||||||||
| run: | | ||||||||||||||||||||||||||||||
| if [ "${{ github.event_name }}" = "push" ]; then | ||||||||||||||||||||||||||||||
| echo "relevant=true" >> "$GITHUB_OUTPUT" | ||||||||||||||||||||||||||||||
| exit 0 | ||||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||||
| files=$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \ | ||||||||||||||||||||||||||||||
| --paginate --jq '.[].filename') | ||||||||||||||||||||||||||||||
| if echo "$files" | grep -qE '^(crates/perry-runtime/src/(eh|eh_walker|eh_windows|exception)\.rs$|crates/perry-codegen/src/stmt/|test-files/test_(gap|issue)_7302_|\.cargo/config\.toml$|Cargo\.toml$|\.github/workflows/eh-transport\.yml$)'; then | ||||||||||||||||||||||||||||||
| echo "relevant=true" >> "$GITHUB_OUTPUT" | ||||||||||||||||||||||||||||||
| else | ||||||||||||||||||||||||||||||
| echo "relevant=false" >> "$GITHUB_OUTPUT" | ||||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||||
|
Comment on lines
+58
to
+64
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Fix the The regex on Line 60 has 🐛 Proposed fix for the Cargo.toml path pattern- '^(crates/perry-runtime/src/(eh|eh_walker|eh_windows|exception)\.rs$|crates/perry-codegen/src/stmt/|test-files/test_(gap|issue)_7302_|\.cargo/config\.toml$|Cargo\.toml$|\.github/workflows/eh-transport\.yml$)'; then
+ '^(crates/perry-runtime/src/(eh|eh_walker|eh_windows|exception)\.rs$|crates/perry-codegen/src/stmt/|test-files/test_(gap|issue)_7302_|\.cargo/config\.toml$|(.*/)?Cargo\.toml$|\.github/workflows/eh-transport\.yml$)'; then📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| eh-transport: | ||||||||||||||||||||||||||||||
| needs: changes | ||||||||||||||||||||||||||||||
| if: needs.changes.outputs.relevant == 'true' | ||||||||||||||||||||||||||||||
| # arm64 macOS: the owned walker's current platform. Other targets fall | ||||||||||||||||||||||||||||||
| # back to the system unwinder by design, so there is nothing to assert | ||||||||||||||||||||||||||||||
| # there beyond the behavior the parity suites already cover. | ||||||||||||||||||||||||||||||
| runs-on: macos-15 | ||||||||||||||||||||||||||||||
| timeout-minutes: 60 | ||||||||||||||||||||||||||||||
| steps: | ||||||||||||||||||||||||||||||
| - uses: actions/checkout@v4 | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| - name: Install Rust toolchain | ||||||||||||||||||||||||||||||
| uses: dtolnay/rust-toolchain@stable | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| - name: Setup Node.js | ||||||||||||||||||||||||||||||
| uses: actions/setup-node@v7 | ||||||||||||||||||||||||||||||
| with: | ||||||||||||||||||||||||||||||
| node-version-file: .node-version | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| - uses: Swatinem/rust-cache@v2 | ||||||||||||||||||||||||||||||
| with: | ||||||||||||||||||||||||||||||
| shared-key: "${{ runner.os }}-perry" | ||||||||||||||||||||||||||||||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| - name: Build compiler + runtime archives | ||||||||||||||||||||||||||||||
| run: | | ||||||||||||||||||||||||||||||
| cargo build --profile perry-dev -p perry -p perry-runtime-static \ | ||||||||||||||||||||||||||||||
| -p perry-stdlib-static | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| - name: Owned transport — liveness, agreement, fallback, parity | ||||||||||||||||||||||||||||||
| run: | | ||||||||||||||||||||||||||||||
| set -euo pipefail | ||||||||||||||||||||||||||||||
| export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" | ||||||||||||||||||||||||||||||
| export PERRY_NO_AUTO_OPTIMIZE=1 | ||||||||||||||||||||||||||||||
| BIN=target/perry-dev/perry | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # Two subjects: the structural try/catch matrix (nesting, finally | ||||||||||||||||||||||||||||||
| # on both edges, return-in-try, rethrow) and the GC probe that | ||||||||||||||||||||||||||||||
| # throws across a collection point. | ||||||||||||||||||||||||||||||
| for SRC in test-files/test_gap_7302_invoke_eh_paths.ts \ | ||||||||||||||||||||||||||||||
| test-files/test_gap_7302_gc_throw_across_collection.ts; do | ||||||||||||||||||||||||||||||
| NAME=$(basename "$SRC" .ts) | ||||||||||||||||||||||||||||||
| "$BIN" "$SRC" -o "/tmp/$NAME" | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # Oracle parity first: a fast path that is wrong is worse than | ||||||||||||||||||||||||||||||
| # no fast path, so nothing below is meaningful without this. | ||||||||||||||||||||||||||||||
| node --experimental-strip-types --no-warnings "$SRC" > "/tmp/$NAME.node" | ||||||||||||||||||||||||||||||
| "/tmp/$NAME" > "/tmp/$NAME.perry" | ||||||||||||||||||||||||||||||
| diff "/tmp/$NAME.node" "/tmp/$NAME.perry" | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # LIVENESS: the walker carried every throw. `fallback=0` alone | ||||||||||||||||||||||||||||||
| # would pass vacuously on a program that never threw, so require | ||||||||||||||||||||||||||||||
| # a nonzero fast count too. | ||||||||||||||||||||||||||||||
| stats=$(PERRY_EH_WALKER=stats "/tmp/$NAME" 2>&1 >/dev/null | grep "eh-walker: fast=") | ||||||||||||||||||||||||||||||
| echo "$NAME: $stats" | ||||||||||||||||||||||||||||||
| fast=$(echo "$stats" | sed -E 's/.*fast=([0-9]+).*/\1/') | ||||||||||||||||||||||||||||||
| fb=$(echo "$stats" | sed -E 's/.*fallback=([0-9]+).*/\1/') | ||||||||||||||||||||||||||||||
| [ "$fast" -gt 0 ] || { echo "walker never took the fast path"; exit 1; } | ||||||||||||||||||||||||||||||
| [ "$fb" -eq 0 ] || { echo "walker fell back $fb time(s)"; exit 1; } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # AGREEMENT: re-prove the prediction against the system | ||||||||||||||||||||||||||||||
| # unwinder on this build, rather than trusting the bring-up run. | ||||||||||||||||||||||||||||||
| # The personality aborts on mismatch; the tally must show real | ||||||||||||||||||||||||||||||
| # verifications and no declines. | ||||||||||||||||||||||||||||||
| diffout=$(PERRY_EH_WALKER=diff "/tmp/$NAME" 2>&1 >/dev/null | grep "eh-walker diff:") | ||||||||||||||||||||||||||||||
| echo "$NAME: $diffout" | ||||||||||||||||||||||||||||||
| ver=$(echo "$diffout" | sed -E 's/.*verified=([0-9]+).*/\1/') | ||||||||||||||||||||||||||||||
| dec=$(echo "$diffout" | sed -E 's/.*declined=([0-9]+).*/\1/') | ||||||||||||||||||||||||||||||
| [ "$ver" -gt 0 ] || { echo "diff mode verified nothing"; exit 1; } | ||||||||||||||||||||||||||||||
| [ "$dec" -eq 0 ] || { echo "walk declined $dec throw(s)"; exit 1; } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # FALLBACK: the escape hatch must still produce correct output — | ||||||||||||||||||||||||||||||
| # it is the bisection knob and the every-other-platform path. | ||||||||||||||||||||||||||||||
| PERRY_EH_WALKER=off "/tmp/$NAME" > "/tmp/$NAME.off" | ||||||||||||||||||||||||||||||
| diff "/tmp/$NAME.node" "/tmp/$NAME.off" | ||||||||||||||||||||||||||||||
| done | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| - name: Throws on perry/thread workers | ||||||||||||||||||||||||||||||
| run: | | ||||||||||||||||||||||||||||||
| set -euo pipefail | ||||||||||||||||||||||||||||||
| export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" | ||||||||||||||||||||||||||||||
| export PERRY_NO_AUTO_OPTIMIZE=1 | ||||||||||||||||||||||||||||||
| # Each worker walks ITS OWN stack while the row cache and image | ||||||||||||||||||||||||||||||
| # index are process-global behind a mutex. node cannot run | ||||||||||||||||||||||||||||||
| # perry/thread, so the oracle here is the system unwinder itself: | ||||||||||||||||||||||||||||||
| # walker-on must equal walker-off, and diff mode must agree on | ||||||||||||||||||||||||||||||
| # every worker throw. | ||||||||||||||||||||||||||||||
| SRC=test-files/test_issue_7302_thread_throws.ts | ||||||||||||||||||||||||||||||
| target/perry-dev/perry "$SRC" -o /tmp/eh_threads | ||||||||||||||||||||||||||||||
| /tmp/eh_threads > /tmp/eh_threads.on | ||||||||||||||||||||||||||||||
| PERRY_EH_WALKER=off /tmp/eh_threads > /tmp/eh_threads.off | ||||||||||||||||||||||||||||||
| diff /tmp/eh_threads.on /tmp/eh_threads.off | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| stats=$(PERRY_EH_WALKER=stats /tmp/eh_threads 2>&1 >/dev/null | grep "eh-walker: fast=") | ||||||||||||||||||||||||||||||
| echo "threads: $stats" | ||||||||||||||||||||||||||||||
| fast=$(echo "$stats" | sed -E 's/.*fast=([0-9]+).*/\1/') | ||||||||||||||||||||||||||||||
| fb=$(echo "$stats" | sed -E 's/.*fallback=([0-9]+).*/\1/') | ||||||||||||||||||||||||||||||
| [ "$fast" -gt 0 ] || { echo "walker never took the fast path on workers"; exit 1; } | ||||||||||||||||||||||||||||||
| [ "$fb" -eq 0 ] || { echo "walker fell back $fb time(s) on workers"; exit 1; } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| diffout=$(PERRY_EH_WALKER=diff /tmp/eh_threads 2>&1 >/dev/null | grep "eh-walker diff:") | ||||||||||||||||||||||||||||||
| echo "threads: $diffout" | ||||||||||||||||||||||||||||||
| dec=$(echo "$diffout" | sed -E 's/.*declined=([0-9]+).*/\1/') | ||||||||||||||||||||||||||||||
| ver=$(echo "$diffout" | sed -E 's/.*verified=([0-9]+).*/\1/') | ||||||||||||||||||||||||||||||
| [ "$ver" -gt 0 ] || { echo "diff mode verified nothing on workers"; exit 1; } | ||||||||||||||||||||||||||||||
| [ "$dec" -eq 0 ] || { echo "walk declined $dec worker throw(s)"; exit 1; } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| - name: Cross-helper throws + unwind-table self-check | ||||||||||||||||||||||||||||||
| run: | | ||||||||||||||||||||||||||||||
| set -euo pipefail | ||||||||||||||||||||||||||||||
| export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" | ||||||||||||||||||||||||||||||
| export PERRY_NO_AUTO_OPTIMIZE=1 | ||||||||||||||||||||||||||||||
| # Throws crossing runtime Rust frames (throwing getter, throwing | ||||||||||||||||||||||||||||||
| # toString, JSON.parse, map callback, deep recursion). These are | ||||||||||||||||||||||||||||||
| # the frames the panic=abort + force-unwind-tables contract exists | ||||||||||||||||||||||||||||||
| # for; a runtime built without the tables aborts here with the | ||||||||||||||||||||||||||||||
| # self-check's message instead of stranding the throw silently. | ||||||||||||||||||||||||||||||
| SRC=test-files/test_gap_7302_throw_across_helper_frames.ts | ||||||||||||||||||||||||||||||
| target/perry-dev/perry "$SRC" -o /tmp/eh_helpers | ||||||||||||||||||||||||||||||
| node --experimental-strip-types --no-warnings "$SRC" > /tmp/eh_helpers.node | ||||||||||||||||||||||||||||||
| /tmp/eh_helpers > /tmp/eh_helpers.perry | ||||||||||||||||||||||||||||||
| diff /tmp/eh_helpers.node /tmp/eh_helpers.perry | ||||||||||||||||||||||||||||||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| ### Exception transport: owned single-phase unwinder (#7302 follow-up) | ||
|
|
||
| The `invoke`/`landingpad` migration (#7305) traded `longjmp`'s O(1) register | ||
| restore for real stack unwinding, which made throws slower — honestly reported | ||
| at the time. This closes that gap without giving back any of the correctness. | ||
|
|
||
| The system unwinder walks every frame **twice** (search + cleanup) and | ||
| re-decodes each frame's CFI on **every throw** (measured: 512 ns per | ||
| frame-step on macOS arm64). Perry needs neither property: the handler stack | ||
| already *is* the search result, and throw paths repeat, so decoded rows can be | ||
| cached. `js_throw` now walks to the handler itself with a per-PC row cache and | ||
| installs the handler frame's register context directly. | ||
|
|
||
| | microbenchmark (20k iters, macOS arm64) | system unwinder | owned walker | node/V8 | | ||
| |---|---|---|---| | ||
| | deep unwind, 200 frames | 4096 ms | **287 ms** (14.3×) | 168 ms | | ||
| | shallow throw + catch | 451 ms | **80 ms** (5.6×) | 110 ms | | ||
|
|
||
| Deep-unwind throws went from 24× slower than V8 to within 1.7×; shallow throws | ||
| now beat V8. Non-throwing paths are untouched. | ||
|
|
||
| **Safety.** Every register reload dereferences a computed address, so a | ||
| misdecoded row is a wild read rather than a wrong answer. Three layers, each | ||
| required to prove it ran: | ||
|
Comment on lines
+22
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Correct the safety failure-mode description in both records. An incorrect CFI row can cause either an invalid read or an in-bounds read from the wrong slot. The second case can silently restore incorrect register values. Describe both failure modes and explain which validation layer detects each one.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| - The owned walk reproduces `_Unwind_Backtrace`'s frame chain exactly (unit | ||
| differential). | ||
| - `PERRY_EH_WALKER=diff` predicts (landing pad, CFA) before each raise and | ||
| asserts it inside the personality against the system unwinder, tallying | ||
| verified/declined at exit so a silent run cannot pass for a verified one: | ||
| **20,000 deep unwinds (~4M frame steps), the GC throw-across-collection | ||
| probe, and the smoke corpus — zero mispredictions, zero declines.** The | ||
| checker's own liveness was proven by deliberately corrupting a prediction | ||
| and confirming the abort fires. | ||
| - Stepping is fail-safe: the CFA must climb a plausible stack monotonically | ||
| and every slot address must lie inside the walk's stack span, else the walk | ||
| declines and the system unwinder carries that throw with identical | ||
| semantics. Not theoretical — unguarded, the walker segfaulted stepping | ||
| `libtest`'s frame shapes, which compiled programs never produce. | ||
|
|
||
| `d8..d15` are tracked and restored alongside the integer callee-saves: a | ||
| handler frame holding a live `f64` across the `try` would otherwise resume | ||
| with a stale value (silent numeric corruption, not a crash). | ||
|
|
||
| **Acceptance.** The full gap suite under the owned transport returns the | ||
| byte-identical failure set as merged main (95.4%, same 21 mismatches, same | ||
| single known crash). The GC probe passes under default, | ||
| `PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1`, and `PERRY_GEN_GC=0`. | ||
| `PERRY_EH_WALKER=off` reverts to the system unwinder for bisection (verified | ||
| live: the deep-unwind benchmark returns to 4231 ms). | ||
|
|
||
| **Platform scope.** aarch64/macOS takes the fast path; every other target | ||
| keeps the system unwinder unchanged — the walk simply declines when it has no | ||
| image to decode. Linux bring-up needs `dl_iterate_phdr` + `PT_GNU_EH_FRAME` | ||
| discovery; the stepping and cache above it are platform-independent. | ||
Uh oh!
There was an error while loading. Please reload this page.