Skip to content

Compile the browser build's hot paths ahead of time from a recorded profile (3.5–4× faster in WASM) - #653

Merged
JSv4 merged 1 commit into
mainfrom
feat/wasm-profiled-aot
Sep 2, 2026
Merged

Compile the browser build's hot paths ahead of time from a recorded profile (3.5–4× faster in WASM)#653
JSv4 merged 1 commit into
mainfrom
feat/wasm-profiled-aot

Conversation

@JSv4

@JSv4 JSv4 commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Closes #652

Why

The browser build runs the engine on the Mono interpreter, tiered by the jiterpreter. Once warm it was still 5–10× slower than the same call in a native process on the same inputs: a compare that takes 17 ms natively took 126 ms in the browser, a 147 KB legal form took 5.9 s to compare and 4.6 s to render, and the editor's per-keystroke block re-render took 56 ms. None of this is algorithmic — the native engine is fast — it is how IL executes in the browser.

What this does

Profile-guided AOT. The publish now runs the Mono AOT compiler with a recorded profile (wasm/DocxodusWasm/docxodus.aotprofile) and profile-only, so it compiles ahead of time exactly the ~6,100 methods a representative workload executes — DocxDiff compare, DOCX→HTML conversion, and the editor's ReplaceText + single-block re-render — and leaves everything else on the interpreter. Full AOT was measured too: it compiles ~90,000 methods (52,000 of them the Open XML SDK's typed schema) into a 48 MB binary, 9.7 MB over the wire, and is no faster than the profiled build. The profile buys the whole speedup.

The profile is recorded in a real browser by scripts/record-aot-profile.sh: a profiler-flavour build (AOT off, Mono AOT profiler linked in) runs the workload through npm/tests/aot-profile-record.spec.ts, which dumps the runtime's profile buffer to disk, then the shipped configuration is rebuilt. The same workload definition (npm/tests/wasm-workload.ts) drives the new measurement spec, so what is measured is by construction what is compiled. A stale profile costs speed, never correctness — a method missing from it simply runs interpreted.

Measured on the same inputs (medians of a warm loop; full table in docs/architecture/wasm-packaging.md):

Operation Warm native Before (interpreter) After (profiled AOT)
DocxDiff compare, 11 KB pair 17 ms 126 ms 30 ms
DocxDiff compare, 147 KB legal form vs edited variant 818 ms 5.90 s 1.43 s
DOCX→HTML, 42 KB 150 ms 893 ms 235 ms
DOCX→HTML, 147 KB 902 ms 4.55 s 1.31 s
Editor refresh (ReplaceText + block render) 5.6 ms 55.9 ms 15.3 ms

3.5–4.3× faster across the board, within 1.5–2.7× of warm native (the issue's target was 2–3×).

The cost, stated plainly: +1.2 MB over the wire on a brotli host (3.6 → 4.8 MB) and +6.5 MB uncompressed (14.7 → 21.2 MB), all of it in dotnet.native.wasm. Cold boot on localhost goes 637 → 738 ms; at 50 Mbps that is ~200 ms once, then cached. The build-time wire budget in scripts/build-wasm.sh moves from 4 MB to 5 MB accordingly. Compiling the AOT code for size (-Oz) recovers 9 KB, so there is no cheaper point on this frontier; the alternative is the interpreter, one property away (RunAOTCompilation).

Jiterpreter verified, not assumed. npm/tests/wasm-steady-state.spec.ts boots the bundle with --jiterpreter-stats-enabled and asserts traces/interp-entry/jit-call thunks are on, traces were compiled, and generated code sits inside the jiterpreter's 8 MB budget. It was already active; now a build knob can't silently turn it off.

Two SDK gotchas worth knowing (documented in wasm-packaging.md)

  • The documented AOTProfilePath property silently yields full AOT: the SDK targets pass it and the item fed by WasmAotProfilePath to the same case-insensitive task parameter, and the empty item wins. The csproj uses WasmAotProfilePath; the 9.7 MB "full AOT" column above is how this was discovered.
  • The runtime's default profiler hand-off method (Interop/Runtime::DumpAotProfileData) no longer exists in .NET 10; it lives on JavaScriptExports, is named explicitly in the test harness, and is rooted by a descriptor that is only included in the profiler flavour (full trimming removes it otherwise; the symptom is a console error, not an exception).

Validation

  • Full Playwright suite on this branch's final AOT bundle (profile re-recorded against this branch's code): 666 passed, 11 skipped, 5 failed. The 5 are the tabs-visual screenshot tests, which fail identically on the interpreter build on this host (no Times New Roman; green in CI). The 11 skips are the suite's env-gated opt-ins — LibreOffice/visual/generated-PDF parity, the Doom marathon, Word-reference capture (10 skipped on main's last CI run as well) — plus the new opt-in profile recorder. main's last CI run: 669 passed / 10 skipped; this branch adds two steady-state tests and one opt-in recorder, so CI should show 671 / 11.
  • Trim canaries (trim-validation.spec.ts) green — the AOT build is a different native binary, so these were re-verified rather than assumed.
  • Wire size gate: 4871 KB against the new 5120 KB budget.

No C# behaviour change in the library; the csproj, build script, test harness, two specs, one shared workload module, one descriptor, the profile, and docs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VSnwkK1Nx6zZb2RnnoxKdx

…rofile

The WASM runtime ran everything on the Mono interpreter (jiterpreter on — now
verified by a test rather than assumed), 5–10× slower than warm native at steady
state. Full AOT closes the gap but compiles ~90k methods into a 48 MB binary
(9.7 MB brotli) and is no faster than this. The publish now runs the AOT
compiler with `profile-only` against wasm/DocxodusWasm/docxodus.aotprofile,
recorded in a real browser over the representative workload (DocxDiff compare,
DOCX→HTML, the editor's per-mutation refresh), so only the ~6k methods that
workload executes are compiled and everything else stays interpreted:
3.5–4.3× faster, within 1.5–2.7× of warm native, for +1.2 MB brotli /
+6.5 MB raw. The wire budget in build-wasm.sh moves from 4 MB to 5 MB.

- scripts/record-aot-profile.sh re-records the profile (profiler build →
  npm/tests/aot-profile-record.spec.ts → shipped rebuild); build-wasm.sh now
  passes extra arguments through to dotnet publish so variant builds need no
  script edits.
- npm/tests/wasm-steady-state.spec.ts times the shared workload
  (npm/tests/wasm-workload.ts) and pins that the jiterpreter is active and
  inside its code budget.
- WasmAotProfilePath, not the documented AOTProfilePath: the SDK passes both to
  one case-insensitive task parameter and the empty item wins, so AOTProfilePath
  silently yields full AOT. The profiler's hand-off method
  (JavaScriptExports.DumpAotProfileData — the runtime's default names a type it
  no longer lives on) is rooted for the profiler flavour only.
- Frontier, costs and procedure: docs/architecture/wasm-packaging.md.

Closes #652
@JSv4
JSv4 merged commit fa91c45 into main Sep 2, 2026
14 checks passed
@JSv4
JSv4 deleted the feat/wasm-profiled-aot branch September 2, 2026 03:18
JSv4 pushed a commit that referenced this pull request Sep 2, 2026
#653 compiles the browser build's hot paths ahead of time from a
recorded profile, and it is the largest movement this page has recorded.
revisions 143 -> 52 ms, redline 170 -> 74, full+HTML 324 -> 141;
compareProducts on fixed inputs 125 -> 51, and the conversion stage in
isolation 144 -> 60. Roughly 2x to 2.45x on every measure.

A claimed 2x deserves more evidence than the one session that made an
earlier baseline look better than it was, so both methods were run and
the controlled figures pooled over two sessions before anything was
published. They agree.

Uniform across depths again, but the shared thing this time is not a code
path — it is the .NET execution underneath all of them, which is what
ahead-of-time compilation buys. That also explains why the ratio against
the recording path moved rather than holding: recording got faster too,
so 68-157x becomes 33-78x.

The headline claim changes with it and is rewritten rather than quietly
left. "No arrangement of the code was going to make it 60fps" is still
true, but it is a much closer no: the loop now runs 7-19 fps where a
month of engine work ago it was 2-6, and the shallow depth is inside the
range a viewer reads as motion rather than as a series of updates.

Both GIFs recut, because a 2x speedup makes a committed meter actively
misleading rather than merely stale. The stress GIF still reads higher
than the table (132 against 74) for the documented reasons, and the note
at the GIF now carries the current pair.

Also in this merge:

- v11.0.0 shipped, and git's auto-merge swept the REDLINE THEATER entry
  out of [Unreleased] and into the released 11.0.0 section — which would
  have claimed the demo shipped in a version it is not in. Moved back
  under [Unreleased]; the 11.0.0 section is left exactly as cut.
- redline.html still pinned docxodus@10.0.0 while the release re-pinned
  every sibling page to @11.0.0, because this page is not on main yet to
  be re-pinned. Now at @11.0.0, confirmed with a real fetch against
  jsDelivr first, as the release procedure requires.
- The demo README conflicted on the pin AND the page count; resolved to
  seven pages at @11.0.0, which is both sides rather than either.

14/14 browser assertions and 61/61 node checks pass on the rebuilt
engine.
JSv4 pushed a commit that referenced this pull request Sep 2, 2026
The pooled re-establishment deferred through the #650 nullable run, now
that the run is over (#675 was the last annotated file, #676 retired the
NoWarn list, and main has moved on to docs). Three 40-frame stress runs
plus two controlled sessions, which is the sample size the earlier
single-session readings lacked — and this time the stress reps land
within 3 ms of each other and both methods agree.

They came out 21-28% faster than the published figures on every absolute:
revisions 52 -> 41 ms, redline 74 -> 55, full+HTML 141 -> 101. Nothing
merged since #653 can explain a quarter — the nullable run added
null-guards, which cost time, and #676 removed two provably redundant
checks.

What makes the reading worth keeping is the column that did NOT move. The
ratios against the recording path are 33 -> 34x, 45 -> 45x, 78 -> 80x.
The mutation path scaled by the same factor as the diff path, so the
ratio held while both halves got a quarter faster together. That is the
container being faster this hour, and it is the clean counterexample to
#653, which announced itself precisely BY moving the ratio (68-157x down
to 33-78x) because recording and computing changed by different amounts.

So the README gains a sharper diagnostic than "is the movement uniform
across depths": when the absolutes move and the ratio holds it is the
machine; when the ratio itself moves, the two paths changed by different
amounts and something real happened. That is a better test because it
needs no knowledge of what merged.

The table keeps its original pooled figures rather than adopting the
newer ones. Both are honest pooled measurements of the same build; taking
whichever is faster would be chasing weather, and the panel recomputes the
ratio live regardless.

14/14 browser assertions and 61/61 node checks pass on the current head.
Also merges #679 and #680, both docs-only; CHANGELOG conflicted the same
both-added way and was resolved keeping both, and the release-merge check
confirms this demo's entry is still inside [Unreleased].
JSv4 pushed a commit that referenced this pull request Sep 3, 2026
The README's diagnostic says a moving ratio means the two paths changed by
different amounts and something real happened. True, but it never said how much
movement counts, which makes it unfalsifiable in the direction that matters: any
wobble can be read as a finding.

Two consecutive full + HTML runs on one build within one hour read 77x and 84x.
That is the noise floor measured rather than guessed, and #653 -- which halved
the ratio outright -- is the other end of the scale. Stating both puts a number
on the rule: under about 10% is noise, approaching a halving or doubling is the
engine, and in between you measure again.

Found while checking #684, which changed the OPC part serializer so a save keeps
whatever byte-order-mark convention a part already had. That is the save path,
so it is the denominator of every ratio in the table, and the extra three-byte
read per part write predicted a slower recording path. It did not happen:
avgMutateMs came in at 1.39-1.65 ms against the ~2 ms the table publishes, the
ratios held at 33x / 47x / 77-84x against a published 33 / 45 / 78, and the
absolutes moved in both directions at once. Nothing to republish. 14 browser
assertions and 61 node checks pass on the merged build.
JSv4 pushed a commit that referenced this pull request Sep 7, 2026
…asuring

Main's range 6f140f0..2a1e6f9 is mostly two large-but-inert changes — #728
corrects misattributed Microsoft copyright headers across ~200 post-fork files,
and #721 adds the ASCII DOOM showcase — plus the tail of the history archive
work. npm/package.json conflicted in the usual shape (both sides extending
pretest and test:demo-logic) and was resolved as a verified union: main's
doom-ascii copy and check alongside this branch's four redline entries, split on
" && ", diffed list against list, and revalidated with a JSON parse. The demo
logic suite is eight checks now.

Two things in that range could move the published diff figures, so this merge
re-measured rather than assuming: the WASM project now passes -Oz to the SDK's
Binaryen post-link pass, and #721 re-recorded docxodus.aotprofile (1.34 MB to
1.65 MB). Profile-guided AOT is exactly what halved these numbers in #653, so
both deserved a reading rather than a guess.

The figures do not move, and the table stays put for the third time. A pooled
reading — two controlled fixed-input sessions plus a three-depth stress run —
came out at 46 / 56 / 103 ms with ratios 32x / 41x / 89x. That is the band the
README already records for its second session (41 / 55 / 101 at 34x / 45x / 80x),
not a new one, and two of three ratios held. Only full + HTML crossed the noise
threshold, and it moved UP while its absolute moved down — the recording path got
quicker too, from about 2 ms to 1.3 ms, so the deepest pipeline merely failed to
keep pace. That is the opposite of a faster-engine signature. The README gains a
paragraph recording the reading, because "a size-optimizing link pass and a wider
AOT profile did not move the ratio" is worth knowing precisely because both sound
like they should.

One correction to this branch's own recent history: the last few merge messages
said they rebuilt "through the full pretest". They did not. pretest runs the demo
checks, typechecks and copies already-built artifacts into dist/wasm; npm run
build is what compiles the WASM engine and the bundles. dist/wasm/Docxodus.wasm
was dated 2026-09-06T01:05 — the #702 merge — so #703, v12.1.0 and all three
history batches were verified against that engine rather than a fresh one. The
specs passed, but they did not test what those messages claimed. This merge runs
the real build: Docxodus.wasm 3.47 MB to 3.80 MB, framework total up 708 KB,
which is the history subsystem and the wider AOT profile arriving at last. The
measurement above is therefore of everything since #702, not of this range alone,
and it is reported that way.

Verified on the genuinely rebuilt engine: 61 redline node checks, 3 pin checks,
14 browser assertions.
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.

WASM: close the interpreter speed gap (jiterpreter verification + profiled AOT within the payload budget)

1 participant