Skip to content

Performance Benchmarks

Dang Mai edited this page Jun 19, 2026 · 3 revisions

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

How to read this

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-latest runner (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-serialize run ~13–15% cv. Treat absolute ms as ±15%, trends as solid.

Apex formatting — XStream baseline (before 2026-06-19)

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

What this says

  • java-serialize is 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 leandeserialize + prepping + printing ≈ 152ms on the large file, in line with the Prettier floor below.
  • spawn-ipc is a steady ~9–11ms regardless of file size (process overhead).

Codegen serializer + GraalVM build-time init — merged 2026-06-19

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-time for apex.jorje.*, org.antlr.runtime, com.google.common) → moves parser/jorje class init out of the per-spawn path, cutting java-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.

What this says

  • java-serialize went 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-parse was per-spawn class init, not parsing. Large java-parse 23.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 and transport is 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 RuntimeReflectionRegistrationFeature was deleted — the reflection registration that was the main WASM/native-image blocker — so this also de-risks the WASM future (see WASM research).

The Prettier "floor" (in-process, native parse)

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

What this says

  • ~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.

Synthesis — where the ceiling is

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.

GraalJS spike — printing inside the native binary

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.)