Skip to content

fix(jit): restore gpui-shell performance regressed in 0.12.10 - #33

Merged
huacnlee merged 7 commits into
mainfrom
fix/shell-tiering-regression
Sep 30, 2026
Merged

huacnlee merged 7 commits into
mainfrom
fix/shell-tiering-regression

Conversation

@huacnlee

@huacnlee huacnlee commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Summary

0.12.10 regressed real gpui-shell renders (acceptance sample, one core): compute ran at 0.13x, mixed at 0.49x and panel at 0.82x the speed of 0.12.9. The 35-kernel jit-bench matrix could not show it: it polls the JIT after every batch and warms 640 calls, while gpui-shell never calls Jit::poll and is measured from the first frames.

Three root causes, one commit each:

  • de40dca Baseline demoted during the funded Tier2 trial. Five rejected Tier2 profitability retries returned every Baseline to the interpreter until the trial installed. The retries only say the modeled Tier2 saving has not paid off; the runtime never measures the interpreter. Since P2 widened Tier 1 coverage, the trial waited behind other first Baselines on the single worker, so the layout kernel ran at interpreter speed for about 100 frames. Now only a Baseline whose fastest complete run is under 10 µs (entry bookkeeping can dominate, e.g. return a + b) is demoted. A longer one keeps running, and its trial moves to the front of the compile queue. The worker channel never holds more jobs than there are workers, so the backlog stays reorderable.
  • 1680c2c P2 opcodes slower than the interpreter in host code. The panel builder's short helpers (closures, push_this, typeof, …) became native, with about 220 entries per render, at 0.8x the interpreter's speed. Stable-IC trials bypass the retry demotion, so nothing returned them. Automatic tiering now rejects functions containing any of the 52 opcodes Tier 1 gained in P2, with the reason 0.12.9 reported. BaselineOnly, Optimize and forced test tiers still compile them. (Chosen as the conservative option for 0.12.11; letting profitability measure the interpreter is the follow-up.)
  • 8fa2a48 Retry backoff stretched without host polling. Retries are evaluated only by maintenance, which ran every 64 hot ticks (about 31 frames) once the kernel was native. Maintenance is now due at the next retry, and exhausted retries queue the trial at the next maintenance. The demotion rule uses the fastest complete (non-OSR) Baseline run, so preemption or a partial OSR run cannot flip it.

A generic "Tier2 first" queue order was tried and dropped. It exposed ordering hazards: a callee reaching Tier2 before its caller's Baseline loses call-link feedback, and tagged_call_link caught it.

Performance

gpui-shell (emit_one_jit_acceptance_sample, 10 retained process pairs, mean render µs):

Workload Cores 0.12.9 0.12.10 this PR vs 0.12.9 vs 0.12.10
compute 1 15.1 127.1 10.3 1.5x (faster) 12.3x (faster)
compute 2 16.3 62.4 10.2 1.6x (faster) 6.6x (faster)
mixed 1 178.5 363.3 92.7 1.9x (faster) 3.9x (faster)
mixed 2 179.2 180.1 92.4 1.9x (faster) 2.0x (faster)
panel 1 812.0 981.4 803.7 1.03x (faster) 1.3x (faster)
panel 2 806.9 987.9 805.5 1.00x (tied) 1.2x (faster)

jit-bench, all 35 scenarios, shared-js-multibatch-v3, 30 retained processes per configuration, CPU 8. Full tables are in the README.

  • vs v0.12.10: 8 faster, 11 slower, 16 tied. The two large losses are the deliberate P2 deferral: exceptions-sync 0.21x and for-of-array 0.36x, back to QuickJS speed. The other nine are 0.5–3.6% slower; for three of them the unchanged interpreter moved by a similar amount.
  • vs QuickJS: 23 faster, 5 slower, 7 tied.
  • vs Bun: 0 of 35 faster.

Tests

  • Regression tests, each verified to fail without its fix: automatic_gpui_layout_kernel_keeps_baseline_until_tier2_trial, automatic_layout_kernel_reaches_tier2_without_host_polling, prioritized_request_is_taken_ahead_of_earlier_requests, dispatch_keeps_the_backlog_in_the_coordinator_while_workers_are_busy.
  • Exception lowering tests moved to BaselineOnly. The settle tests assert the automatic deferral. The background tests follow bounded dispatch.
  • cargo test -p quickjs-jit-runtime --release --features compiler,test-support: 56 binaries, 987 tests pass, run twice. cargo clippy --tests: no warnings.

Sanitizer CI

The nightly sanitizer job had failed since the P0–P4 merge (#30), including on docs-only #31 and #32. It stopped at the first slow MSan test, so the ASan and TSan steps never ran. A local no-fail-fast run of the CI commands, also pinned to two cores to mimic the runner, found every remaining issue. No ASan memory error and no TSan data race was reported.

  • 2166391: sanitizer builds default to a ten-minute compile budget. One MSan-instrumented Tier 1 compile took over 30 s on the runner and timed out on every retry. object_fast_paths now waits 300 s. A forced test tier ignores timed Tier2 losses, which timing noise alone could trigger.
  • 7f88490: every fixed let deadline = now + N s in the JIT tests allows 5x under sanitizers. The tier2_ownership runner keeps calling until Tier 2 when the test expects it. fast_entry's intentionally leaked state stays reachable for LeakSanitizer. The absolute-time entry-dominated demotion test is ignored under sanitizers: instrumentation slows the bookkeeping but not the generated code.
  • 36ddc59: frame_inline waits for the linked Tier2 caller before counting generic calls.

CI: MSan, ASan and TSan all pass. Locally, on two cores, 62/62 JIT test binaries pass under each sanitizer.

🤖 Generated with Claude Code

huacnlee and others added 7 commits September 30, 2026 12:33
Exhausting the five Tier2 profitability retries returned every Baseline to
the interpreter until the funded trial installed. The retries only say the
modeled Tier2 saving has not paid off yet; the runtime never measures the
interpreter, so they are no evidence that the Baseline is harmful. Since
P2 widened Tier 1 coverage, the trial waits behind first Baseline compiles
of other functions on the single worker, and gpui-shell renders ran at
interpreter speed for about 100 frames (compute 0.13x, mixed 0.49x the
speed of 0.12.9 in its acceptance window).

- Demote on exhausted retries only when a Baseline execution is shorter
  than 10 us, where native entry bookkeeping can dominate the body.
  Longer Baselines keep running and stay enterable while blacklisted.
- Move such a funded trial to the front of the compile queue, and hand the
  worker channel no more jobs than there are workers so the backlog stays
  reorderable.
- The call-only and untranslatable demotion paths still demote a retained
  Baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Roadmap P2 let Tier 1 compile closures, var refs, generic operators,
exception regions and for-in/for-of. In host UI code those functions are
short helper-bridged calls whose native entry bookkeeping exceeds their
body: gpui-shell's panel builder ran at 0.8x the interpreter's speed with
about 220 native entries per render, and their compiles queued ahead of
hot kernels. Profitability never measures the interpreter, and stable-IC
trials bypass the retry demotion, so nothing returned them.

Automatic tiering now rejects functions containing any opcode Tier 1
gained in P2, with the reason 0.12.9 reported, until profitability can
measure the interpreter. BaselineOnly, Optimize and forced test tiers
still compile them; the exception lowering tests move to BaselineOnly and
the settle tests assert the automatic deferral.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Profitability retries back off by 2, 4, 8 and 16 clock ticks, but they are
evaluated only by maintenance. A host that never calls Jit::poll (gpui-shell)
runs maintenance every HOT_MAINTENANCE_INTERVAL hot ticks once its kernel
runs natively, so each short backoff became about 31 frames and the funded
Tier2 trial of gpui-shell's layout kernel waited about 130 frames. 0.12.9
only escaped this through unrelated rejections that happened to trigger
maintenance.

- Maintenance is due when the earliest future retry is reached; exhausting
  the retries schedules the funded trial for the next maintenance.
- The entry-dominated demotion rule now uses the fastest complete Baseline
  execution. A preempted sample inflated the average above the threshold
  under load, and an OSR entry times only the rest of one call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he tiering fixes

Full 35-scenario shared-js-multibatch-v3 matrix for 8fa2a48 against the
v0.12.10 runtime, same-version QuickJS and Bun 1.4.0, plus the paired
gpui-shell acceptance results (compute, mixed, panel on one and two cores)
that the matrix protocol cannot observe. The 889f7c1 matrix moves to a
labeled historical block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The nightly sanitizer job failed on object_fast_paths since the P0-P4
merge: one MSan-instrumented Tier 1 compile took over 30 s on the CI
runner, timed out on every retry, and outlived the 60 s wait. CI stopped
there, hiding later sanitizer-only failures; a local no-fail-fast MSan run
found zero_argument_tier2_callee_deopts_under_a_tier1_generic_op_caller,
whose forced Tier2 callee was demoted by the timed trial under load before
the deopt could be observed.

- Sanitizer builds default to a ten-minute compile budget.
- object_fast_paths waits 300 s under sanitizers, like inline_refcount.
- A forced test tier ignores timed Tier2 losses; timing noise alone must
  not tier it down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI's sanitizer job stopped at the first slow failure, hiding the rest. A
local no-fail-fast MSan/ASan/TSan run of the CI commands found:

- Wait deadlines tuned for release builds: every fixed
  `let deadline = now + N s` in the JIT tests now allows 5x under
  sanitizers (array_bce failed on CI at 60 s).
- tier2_ownership's shared runner stopped after 200 calls even when the
  test expects Tier 2; it now keeps calling until Tier 2 or the deadline.
- LeakSanitizer reported fast_entry's intentionally leaked Calls once the
  owning test returned; they stay registered, and reachable, for the
  process lifetime.
- The entry-dominated demotion test relies on absolute timing, which
  instrumentation inflates for bookkeeping but not for generated code; it is
  ignored under sanitizers.

No ThreadSanitizer race or AddressSanitizer memory error was reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alls

On the sanitizer CI runner the caller's first Tier2 artifact was entered
before its callee edge was ready, so the test counted two generic CALLs.
Wait, as wait_for_caller_tier2 does, until a Tier2 run makes none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@huacnlee
huacnlee merged commit f9e64ff into main Sep 30, 2026
42 checks passed
@huacnlee
huacnlee deleted the fix/shell-tiering-regression branch September 30, 2026 17:06
huacnlee added a commit to longbridge/gpui-kit that referenced this pull request Sep 30, 2026
## Description

- Update `quickjs-jit`, `quickjs-jit-runtime`, and the local
`rquickjs-compat` facade from 0.12.9 to the v0.12.11 release commit
(`96593cd`).
- Keep `quickjs-jit-stdlib` on its existing compatibility revision, as
in the 0.12.9 update (#3116); it is not part of the release tag.
- Refresh the lockfile. Only the five quickjs-jit packages change
version and source.

0.12.10 is skipped deliberately: it regressed gpui-shell renders.
0.12.11
([longbridge/quickjs-jit#33](longbridge/quickjs-jit#33))
fixes that regression.

## Performance

`tests::benchmark::emit_one_jit_acceptance_sample`, i7-13700KF, release
build. Each fresh process renders 64 warmup frames, then the mean of 200
measured renders is reported in µs. Ten retained process pairs per
configuration, alternating order. "1 core" pins the render thread and
the JIT worker to one CPU; "2 cores" uses two physical cores. Measured
on the quickjs-jit runtime code that shipped as 0.12.11 (`8fa2a48`;
later commits only change tests and sanitizer builds).

| Workload | Cores | JIT 0.12.9 | JIT 0.12.10 | JIT 0.12.11 | vs 0.12.9
|
| --- | --- | ---: | ---: | ---: | ---: |
| Compute-heavy JavaScript | 1 | 15.1 µs | 127.1 µs | **10.3 µs** |
**1.5x faster** |
| Compute-heavy JavaScript | 2 | 16.3 µs | 62.4 µs | **10.2 µs** |
**1.6x faster** |
| Mixed market + UI | 1 | 178.5 µs | 363.3 µs | **92.7 µs** | **1.9x
faster** |
| Mixed market + UI | 2 | 179.2 µs | 180.1 µs | **92.4 µs** | **1.9x
faster** |
| UI-builder dominated | 1 | 812.0 µs | 981.4 µs | **803.7 µs** | 1.03x
faster |
| UI-builder dominated | 2 | 806.9 µs | 987.9 µs | **805.5 µs** | tied
(1.1% slower to 0.4% faster) |

Checksums agree in every process; there are no native fallbacks or
deopts. The raw data is in [quickjs-jit
`benchmarks/results/shell-tiering-8fa2a48-gpui-shell-x86_64.json`](https://github.com/longbridge/quickjs-jit/blob/main/benchmarks/results/shell-tiering-8fa2a48-gpui-shell-x86_64.json).

## How to Test

- `cargo fmt -p gpui-shell -- --check`
- `cargo check -p gpui-shell --all-targets`
- `cargo test -p gpui-shell --release` (714 + 8 passed)
- `git diff --check`

## Checklist

- [x] I have read the [CONTRIBUTING](../CONTRIBUTING.md) document and
followed the guidelines.
- [x] Reviewed the changes in this PR and confirmed AI generated code
(If any) is accurate.
- [ ] Passed `cargo run` for story tests related to the changes.
- [ ] Tested macOS, Windows and Linux platforms performance (if the
change is platform-specific)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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