Compile the browser build's hot paths ahead of time from a recorded profile (3.5–4× faster in WASM) - #653
Merged
Merged
Conversation
…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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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) andprofile-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 throughnpm/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):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 inscripts/build-wasm.shmoves 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.tsboots the bundle with--jiterpreter-stats-enabledand 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)AOTProfilePathproperty silently yields full AOT: the SDK targets pass it and the item fed byWasmAotProfilePathto the same case-insensitive task parameter, and the empty item wins. The csproj usesWasmAotProfilePath; the 9.7 MB "full AOT" column above is how this was discovered.Interop/Runtime::DumpAotProfileData) no longer exists in .NET 10; it lives onJavaScriptExports, 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
tabs-visualscreenshot 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 onmain'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-validation.spec.ts) green — the AOT build is a different native binary, so these were re-verified rather than assumed.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