-
Notifications
You must be signed in to change notification settings - Fork 48
Performance Benchmarks
Reference performance numbers for Prettier Apex, so we can reason about optimizations against real data instead of guesses, and track them over time. Related: WASM research (the in-process direction these numbers motivate).
Last updated: 2026-06-19
Numbers come from the performance harness (packages/prettier-plugin-apex/tests_perf/, see .claude/rules/performance-harness.md). A format is broken into buckets:
| bucket | what it is | runs in |
|---|---|---|
transport |
process spawn + jorje parse + serialize + receiving the payload | Java binary + IPC |
↳ java-parse
|
jorje parsing the source | Java |
↳ java-serialize
|
turning the AST into JSON (includes building the XStream instance each spawn) | Java |
↳ spawn-ipc
|
process spawn + pipe residual (transport − java-parse − java-serialize) |
OS / IPC |
deserialize |
JSON.parse of the payload |
JS (V8) |
prepping |
post-parse DFS enrichment in parser.ts (comments, line indexes, locations) |
JS (V8) |
printing |
Prettier's doc-IR print walk | JS (V8) |
total |
end-to-end prettier.format
|
— |
Methodology: native parser mode (the default / zero-config adopter path), median of 30 measured iterations after 5 warmups.
Caveats — read before quoting:
- The Apex numbers below are from a GitHub-hosted
ubuntu-latestrunner (shared, noisy). The "Prettier floor" numbers are from a local dev machine (faster). Do not compare absolute ms across the two sections — different hardware. What is robust is the decomposition within each section (the ratios), which is hardware-independent. - Runner noise shows up as
cv(coefficient of variation). On the large file,transport/java-serializerun ~13–15% cv. Treat absolute ms as ±15%, trends as solid.
This is the pre-codegen state (reflection-based XStream serializer). For current numbers see Codegen serializer + build-time init below.
Source: benchmark CI run 27792393172, native mode, main @ 52836ba, Node v24.16.0, ubuntu-latest. Median ms.
| fixture (lines) | total | transport | ↳java-parse | ↳java-serialize | ↳spawn-ipc | deserialize | prepping | printing |
|---|---|---|---|---|---|---|---|---|
| ExpressionClass.cls (~small) | 97.0 | 84.0 | 11.8 | 62.9 | 9.0 | 1.8 | 4.3 | 5.6 |
| Comments.cls (~small) | 117.7 | 95.4 | 12.2 | 73.6 | 9.2 | 2.2 | 6.3 | 10.2 |
| SOQLClass.cls (~small) | 117.9 | 98.7 | 42.2 | 47.6 | 9.2 | 2.1 | 5.6 | 6.5 |
| PerfBenchmarkLarge.cls (3,903) | 439.8 | 286.8 | 23.3 | 252.3 | 11.2 | 20.6 | 60.5 | 71.2 |
-
java-serializeis the dominant cost — ~252ms (57% of total) on the large file, and 48–74ms even on tiny files. The fixed chunk on small files is XStream instance construction (registering ~269 jorje class aliases/converters) paid on every spawn. This is the single biggest lever, and the main motivation for the codegen serializer effort. - jorje parsing is cheap — ~23ms on the large file (and comparable to a native JS parser, see below). Parsing is not the problem.
-
The JS side is already lean —
deserialize + prepping + printing≈ 152ms on the large file, in line with the Prettier floor below. -
spawn-ipcis a steady ~9–11ms regardless of file size (process overhead).
PR #2411 (merged bcf5ec3) replaced reflection-based XStream with a build-time code-generated, reflection-free serializer (Jackson streaming backend), and folded in GraalVM build-time class initialization for jorje/ANTLR (the approach from #2405). Two Java-side levers:
-
Codegen serializer → crushes
java-serialize(no per-spawn XStream construction, no reflection). -
Build-time init (
--initialize-at-build-timeforapex.jorje.*,org.antlr.runtime,com.google.common) → moves parser/jorje class init out of the per-spawn path, cuttingjava-parse.
Source: benchmark CI on the merge, native mode, base b7671df (XStream) vs head fad981d (merged), Node v24.16.0, ubuntu-latest. Median ms.
| fixture | total (before → after) | java-serialize | java-parse | total Δ |
|---|---|---|---|---|
| ExpressionClass.cls | 99.5 → 23.7 | 64.5 → 1.6 (−97.6%) | 12.1 → 3.4 (−72%) | −76% (~4.2×) |
| Comments.cls | 112.6 → 33.4 | 67.9 → 2.0 (−97.1%) | 12.1 → 3.4 (−72%) | −70% (~3.4×) |
| SOQLClass.cls | 108.3 → 59.9 | 40.4 → 2.0 (−95.1%) | 42.8 → 34.6 (−19%) | −45% (~1.8×) |
| PerfBenchmarkLarge.cls | 465.7 → 213.9 | 265.1 → 27.8 (−89.5%) | 23.6 → 14.5 (−39%) | −54% (~2.2×) |
deserialize / prepping / printing are unchanged (both levers are Java-side). Interning was not included — and deserialize staying flat re-confirms payload-size tricks don't help the construction-bound JS parse.
-
java-serializewent from the dominant cost to a rounding error — ~265 → ~28ms on the large file (−89%), ~1.5–2ms on small files (−97%). The per-spawn XStream-construction cost is gone, exactly as the baseline finding predicted. -
Build-time init revealed ~9ms of
java-parsewas per-spawn class init, not parsing. Largejava-parse23.6 → 14.5ms (the ~14ms residual is real parsing work — now ≈ oxc's ~17ms native parse); small files drop 12 → 3.4ms (almost all init). SOQL gains least because it's gated by genuinely heavy SOQL parsing (~35ms). -
The bottleneck has shifted to the JS-side floor. On the large file,
printing(77) +prepping(64) now dominate andtransportis down to 50ms. The 214ms total is within ~45ms of the ~150–170ms theoretical limit — and that last slice (serialize 28 + spawn 8 + deserialize 20) is in-process/WASM territory. As the synthesis predicted: plugin-level levers banked ~2×, the rest needs going in-process. -
Bonus: the GraalVM
RuntimeReflectionRegistrationFeaturewas deleted — the reflection registration that was the main WASM/native-image blocker — so this also de-risks the WASM future (see WASM research).
To find the theoretical limit, we benchmarked Prettier formatting a comparable-size JS file fully in-process (no cross-language tax): @prettier/plugin-oxc (native Rust parser) and Prettier's built-in babel parser.
Source: local machine, Node v24.16.0, prettier@3.8.4 + @prettier/plugin-oxc@0.1.4, generated 3,951-line / 113 KB JS file, median of 30 after 5 warmups.
| setup | total | parse | doc-IR print |
|---|---|---|---|
| prettier + oxc (native parser) | 139.9 | 16.6 | 123.3 |
| prettier + babel (JS parser) | 136.1 | 10.6 | 125.5 |
- ~90% of Prettier's time is the doc-IR print (~125ms), and it's irreducible — that's Prettier doing its formatting job in JS, identical regardless of parser. This is the floor.
- A native parser barely helps. oxc (16.6ms) is actually slower than babel (10.6ms) here — WASM-boundary overhead eats the advantage. Parsing isn't the lever for a formatter.
Lining up the decomposition (mindful of the hardware caveat — compare ratios, not raw ms):
- jorje parse (~23ms) ≈ oxc native parse (~17ms) → parsing is already at the floor.
- Apex JS-side work (deser+prep+print ≈ 152ms) ≈ Prettier floor (~140ms) → printing/enrichment is already at the floor.
- The entire gap is
java-serialize+spawn-ipc≈ 263ms of cross-process tax that an in-process design wouldn't pay.
Theoretical limit ≈ ~150–170ms (in-process jorje parse + JS-side work, serialize/IPC/deserialize eliminated) vs ~440ms today → a ~2.6–3× ceiling, all of it in eliminating cross-process serialization.
How the levers stack against that ceiling:
- Codegen serializer (replace reflection-based XStream): attacks the ~252ms (and its fixed XStream-setup chunk). Stays a plain Prettier plugin, no architecture change. ✅ Landed 2026-06-19 — measured ~2.2× on the large file (and ~3–4× on small files) with build-time init folded in; see above.
- Full in-process (jorje as a shared lib / WASM, or printing inside the binary — see WASM research): squeezes the last ~0.6×, at a much larger architectural cost. Embedding a JS engine (GraalJS) to print inside the binary is a separate question — see below.
Could we run the whole Prettier stack inside the Java native binary via GraalVM's JS engine, eliminating the cross-process tax? Measured 2026-06-18.
Verdict: non-starter, because of cold start. GraalJS only approaches V8 after hundreds of warmup iterations — but a per-file CLI invocation gets exactly one format call, so it lives entirely in the cold regime. Same generated ~3,951-line JS, prettier 3.8.4 standalone, local machine:
| engine / mode | cold first format | warm median | note |
|---|---|---|---|
| V8 / Node v24 (baseline) | 252 ms | 128 ms | — |
GraalJS — JVM + Graal JIT (HotSpotTruffleRuntime) |
2,184 ms | 236 ms | converges to ~V8-band only after ~100 iters |
GraalJS — native-image AOT (SubstrateTruffleRuntime) |
~4,800–7,050 ms | ~300–530 ms | the realistic "print inside the binary" model |
- Cold native-image is ~30–50× slower than V8; cold JVM-JIT is ~9–16× slower. There's no warmup budget in a short-lived process to amortize.
- Native-image is worse than the JVM here: AOT strips the HotSpot JIT, so Truffle's runtime must rebuild JS compilation profiles from scratch every process, with no shared code cache.
- Output was correct in all modes — this is purely a performance verdict.
- Replacing a ~280 ms IPC tax with a multi-second cold print is a net loss by ~25×.
Implication: if the in-process floor is ever pursued, the path is porting the printer to a native language (as Biome/oxc did for JS) — not embedding a JS engine. (Spike artifacts were in /tmp/graaljs-spike; GraalVM 23.0.1, polyglot/js 24.1.2.)