integ: merge merge/mesa-cold (Mesa cold split) 3b803040

This commit is contained in:
velzie committed 2026-10-12 04:57:40 -04:00
commit f3689d12cd
20 files changed
+9890 -2527

No files matched your search

+519
View File
@@ -0,0 +1,519 @@
# Mesa's wasm size: what runs, what ships
Stream M. Branch `merge/mesa-cold`, worktree `wt/mesa-cold`; Mesa changes on
the local branch `m/cold` of third_party/mesa (off the wink pin 60202246).
The page's OpenGL is Mesa built to wasm (`gex-wgpu-replay.wasm`,
web/build-mesa-wasm.sh). On master (07f1ed19) it is 20.6 MB with names and
18.9 MB as the dists ship it (names stripped): 16.0 MB of code in 18,864
functions, 2.8 MB of data, 1.7 MB of names. The goal: cut what a page
downloads and compiles, without breaking a workload.
## 1. Baseline
The shipped files (web/build-all.sh strips custom sections for the dists):
| flavour | raw | zstd -19 |
| --- | --- | --- |
| MP (mt) | 18,898,007 | 3,305,617 |
| SP (st) | 18,669,577 | 3,300,261 |
Code by component (names; mt, master):
| component | functions | code |
| --- | --- | --- |
| Mesa core, util, gallium aux (`_mesa_`, `util_`, `save_`, `vbo_`, ...) | 6,207 | 3.25 MB |
| Tint (SPIR-V reader, IR, WGSL writer and reader) | 2,966 | 2.67 MB |
| GLSL compiler and IR | 658 | 2.16 MB |
| other (unprefixed C helpers, lambdas) | 5,691 | 2.14 MB |
| gex's wire decoder, `one()` (a single function) | 1 | 1.23 MB |
| NIR | 371 | 1.06 MB |
| SPIRV-Tools (Tint's input validation) | 1,308 | 0.98 MB |
| gex_wgpu driver, emdawnwebgpu | 288 | 0.96 MB |
| st/mesa | 773 | 0.78 MB |
| libc, libc++ | 433 | 0.42 MB |
| spirv_to_nir (vtn) | 168 | 0.37 MB |
## 2. Measuring what runs
A profiling build is `GEX_MESA_PROFILE=1 web/build-mesa-wasm.sh` (add
`GEX_MESA_THREADS=0` for SP): the release link, then
`wasm-split --instrument --in-memory` on its plain module, shipped whole.
Each function's entry stores 1 at byte `funcIndex` of linear memory, which
is free: the link puts the main stack first (`-Wl,--stack-first`, see
section 3), so address 0 up is the far end of a 16 MB stack nothing reaches.
The module exports `__write_profile`; the glue (web/gex-wgpu-jspi.js) finds
it and, every 2 s while the set grows, prints
`[gex-wgpu] mesa-profile N HITS BASE64` (bit i = defined function i) to the
console, where every page harness already keeps `gex-wgpu` warnings. Other
builds export no such function.
```
GEX_MESA_PROFILE=1 web/build-mesa-wasm.sh
# swap target/web-build/mesa-mt/build/src/gallium/targets/gex-wasm/split/gex-wgpu-replay.{js,wasm}
# into a page tree, run the workload, keep its console log
tools/mesa-hot.py target/web-build/mesa-mt/build/src/gallium/targets/gex-wasm/gex-wgpu-replay.wasm.orig \
LOG... --also web/mesa-hot.txt -o web/mesa-hot.txt
```
`--also` keeps what an earlier list held, by name, so a new workload adds
to the hot set rather than replacing it. (The M1 numbers below came from an
earlier form of this: a link with `-sGLOBAL_BASE=65536` and no ASSERTIONS,
which wrote a cookie at address 0.)
### Hot sets (M1)
Profiling build of the master pin (18,829 functions, 15.59 MB of code; no
ASSERTIONS, so 35 fewer functions than the shipped one). Runs on edger,
Chromium, real GPU:
| workload | how | functions run | their code |
| --- | --- | --- | --- |
| Muck, to its menu (10 min) | muck-page.sh on a copy of edger's wt/unity | 3,313 (17.6%) | 8.26 MB (53.0%) |
| Undertale on W3 (10 min, 2,220 frames) | W's run-utpage.sh, tree D with this Mesa | 2,968 (15.8%) | 7.47 MB (47.9%) |
| Hollow Knight, to its menu (12 min, interpreter) | hk-page.sh on the same copy | 3,095 (16.4%) | 7.99 MB (51.2%) |
| Noita, menu then 150 s of world (63.2 world fps) | scratch/GL/np/gex-page.sh on a copy of wt/glpath | 3,006 (16.0%) | 7.97 MB (51.1%) |
| **union** | | **3,605 (19.1%)** | **8.55 MB (54.8%)** |
| margin: direct callees of the union up to 1 KB | tools/mesa-hot.py | +848 | +0.31 MB |
Four different engines agree closely: the union is only 292 functions
more than Muck alone. Copies of edger's trees (`cp -al`, Mesa swapped by
`rm` + copy, never through the hard links) kept the other streams' trees
untouched.
Most functions never run, but most of the code bytes do: the big functions
all run. The wire decoder's `one()` (1.18 MB), GLSL's builtin-function
initialiser (0.55 MB), `_mesa_swizzle_and_convert` (0.44 MB) and
`nir_eval_const_opcode` (0.38 MB) are a quarter of the code by themselves,
and each runs in every workload.
The Undertale run used master's Mesa in place of tree D's (W's MSAA patch
68c5ed1d); it drew its 2,220 frames, but WebGPU reported a CopyDst usage
error that tree D's Mesa does not, and utpage.mjs's frame counter, which
stops at the page's first GL failure, stayed at 0.
Cold code by area (Muck and Undertale union): Mesa core 2.44 MB (display
lists `save_*` 0.7 MB, `_hw_select`, ARB programs, format packing for
formats not used), Tint 1.50 MB (`ReadIR`, the IR path used only when the
AST reader fails, and the WGSL reader), spirv_to_nir 0.35 MB, SPIRV-Tools
0.35 MB. None of it is a whole subsystem a workload can do without: display
lists, GL_SELECT and ARB programs are GL features some application uses.
Hence the split (section 4), not deletion.
## 3. Trimming the build (M2)
Sizes are of the module as a dist ships it (names and DWARF stripped by
web/build-all.sh), mt unless said; zstd at -19. Mesa CPU ms per frame is
the replay's `submit_ms` (scratch/M/edger/replay-run.mjs on edger, Chromium on
its GPU, the last 600 frames of Noita's world trace and of HK's menu
trace, counters off), two passes in ABBA order; lower is faster.
### ASSERTIONS=2 out, the main stack first
ASSERTIONS=2 implies STACK_OVERFLOW_CHECK=2: a call to a limit check at
each of the 15,695 places the stack pointer moves, and a cookie at address
0; the rest of it is checks in the JS glue. What it caught that matters is a
stack overflow (a long Unity run hit one). Mesa runs on the module's main
stack in every realm (nothing here uses glthread or another pthread), and
the replay now links with `-Wl,--stack-first`: that stack sits at the bottom
of memory, so overflowing it wraps below address 0 and the next store traps,
as the check did, instead of silently overwriting data -- without a call per
function. (A pthread's stack would have no such guard; none runs Mesa.
With STACK_FIRST, emcc also stops passing Binaryen `--low-memory-unused`; no
measurable cost below.)
| build | stripped | zstd -19 | Noita world, ms/frame | HK menu, ms/frame |
| --- | --- | --- | --- | --- |
| master (ASSERTIONS=2) | 18,898,007 | 3,305,617 | 4.885, 4.901 / 5.172, 5.091 | 6.253, 6.456 / 5.122, 5.191 |
| ASSERTIONS=0 | 18,444,998 | 3,347,542 | 4.980, 5.123 | 5.354, 5.213 |
| ASSERTIONS=0, --stack-first | 18,564,629 | 3,360,944 | 5.046, 4.349 | 6.268, 5.175 |
| + decoders kept apart (one-caller inlining off) | 18,655,290 | 3,296,522 | 5.099, 5.140 | 6.461, 5.525 |
| + Tint's SPIR-V validation off (**M2 build**) | 18,655,830 | 3,296,846 | 5.114, 5.231 | 5.201, 5.161 |
| M2 build at -Os (compile and link) | 13,158,052 | 2,798,299 | see below | see below |
The CPU runs of master's second pair and the last two rows (t2, which ended
at 05:25) overlapped another stream's Chromium left running on edger (05:17
to 06:14), and were rerun (t2r):
| build | Noita world, ms/frame | HK menu, ms/frame |
| --- | --- | --- |
| master (ASSERTIONS=2) | 5.106, 4.984 | 5.263, 5.232 |
| + decoders kept apart | 5.365, 5.071 | 6.249, 6.082 |
| M2 build | 5.292, 5.118 | 4.918, 6.527 |
On this trace the HK runs of every build fall near 5.0 or near 6.2 ms/frame
(section 4 has twenty of them), so two runs do not separate these builds;
Noita's M2 build is 3.0% slower than master's (5.205 against 5.045), about
its run-to-run spread.
Two runs each, one per ABBA pass; master's second pair is from the second
job (each job compares its builds against master in the same session). The
spread between passes of one build (up to 1.1 ms on HK) is larger than any
difference between builds: none of these steps costs measurable Mesa CPU.
SP (st) M2 build: 18,442,251 stripped, 3,294,291 zstd, against master's
18,669,577 and 3,300,261.
### -O3 or -Os, and wasm-opt -Oz
Compiling and linking all of Mesa at -Os (Tint stays -O3) takes the M2
build from 18,655,830 to 13,158,052 bytes (29.5% smaller) and from
3,296,846 to 2,798,299 compressed (15.1% smaller). It costs Mesa CPU on HK's
menu trace; four ABBA runs each, same job:
| trace | -O3 (M2 build), ms/frame | -Os, ms/frame |
| --- | --- | --- |
| Noita world | 5.107, 5.048, 5.050, 5.010 (mean 5.054) | 4.976, 4.993, 4.983, 4.721 (mean 4.918) |
| HK menu | 4.712, 4.873, 4.980, 4.733 (mean 4.825) | 6.324, 6.141, 6.248, 5.497 (mean 6.053) |
-Os is 2.7% faster on Noita and 25.5% slower on HK (1.23 ms more per frame,
all four runs), so the build stays -O3. Running only Binaryen at -Oz on the
-O3 objects (`-Oz` on the link) saves 364 KB raw (18,444,998 to
18,080,546 with ASSERTIONS=0) and 2 KB compressed: not worth a measurement.
### The wire decoder's `one()`
`one()` was 1.18 MB because tools/gen_gl_compat.py's 2,520 per-command
decoders (web/mesa/gl-compat-generated-decoders.inc) are each called from
one `case` of its switch, and Binaryen inlines any function with a single
caller whatever its size (`--one-caller-inline-max-function-size`, unlimited
by default) -- LLVM's `noinline` does not reach it. The generator now marks
them `noinline`, and the replay links with
`-sBINARYEN_EXTRA_PASSES=--one-caller-inline-max-function-size=0`: `one()`
is 166,537 bytes, the decoders 2,522 functions of 1.04 MB (35 over 1 KB),
GLSL's builtin initialiser comes apart the same way (`create_builtins`
374,567 and `create_intrinsics`). 91 KB more raw, 64 KB less compressed,
and no Mesa CPU cost (table above). The decoders are what the split can now
place one by one: a workload runs a few hundred of them.
### Tint's SPIR-V validation
Tint's SPIR-V reader ran SPIRV-Tools' validator (`spvtools::ValidateBinary`,
363 KB of code that every workload ran) on every module Mesa handed it. Mesa
wrote that SPIR-V itself, from NIR it had already validated, so in a release
the check only finds Mesa bugs. wink/patches/tint-spirv-reader-skip-validation.patch
makes it conditional on `TINT_SPIRV_VALIDATE=1` (read once; the replay takes
it through `_gex_gl_setenv`, a page through `GEX_NT_PAGE_ENV`/`?env=`). Mesa
CPU over the whole trace, where every shader translation counts, two runs
each, ms/frame:
| trace | run | validation off | validation on (`TINT_SPIRV_VALIDATE=1`) | warm WGSL cache |
| --- | --- | --- | --- | --- |
| HK menu (3,166 frames, 49 translations) | first (t2b) | 4.956, 4.866 | 5.228, 5.769 | 4.768, 4.781 |
| | rerun (t2rb) | 4.924, 5.087 | 4.941, 5.029 | 4.872, 4.741 |
| Noita world (2,777 frames, 54 translations) | first (t2b) | 4.330, 4.340 | 4.627, 4.626 | 4.794, 4.867 |
| | rerun (t2rb) | 4.675, 4.488 | 4.384, 4.496 | 4.910, 4.367 |
The first runs overlapped another stream's Chromium left running on edger
(05:17 to 06:14), and the rerun does not hold them up: with validation off
HK is 0.02 ms/frame slower (5.006 against 4.985 on, 0.4%) and Noita 0.14
ms/frame slower (4.582 against 4.440, 3.2%), both inside the spread. Over a whole trace the
validator's cost is lost in the noise; what it costs is time inside each
translation (49 and 54 of them here), which an average over 3,000 frames
dilutes. The switch stays (it costs nothing and keeps a debug check), but it
is not a measured CPU win. The code stays linked, so the size does not
change.
Per translation, from V8 CPU profiles of the same whole-trace replays
(cp2: 200 us sampling, both orders, time under `translate_here` and under
SPIRV-Tools' `spvValidateWithOptions`):
| trace | run | in translation, ms | of it in the validator, ms |
| --- | --- | --- | --- |
| HK menu | validation off | 414.3, 461.9 | 73.4, 80.2 |
| | validation on | 514.3, 465.4 | 136.5, 124.0 |
| Noita world | validation off | 682.9, 640.2 | 102.5, 109.7 |
| | validation on | 709.9, 689.3 | 177.6, 170.7 |
So with the switch off the validator still ran: 17.5% of HK's translation
time and 16.0% of Noita's. `spvtools::Optimizer::Run`, which Tint's reader
uses right after to split combined image-samplers, validates its input
first, the same check again. The patch now turns that one off too
(`OptimizerOptions::set_run_validator`, under the same switch), and the
validator, 475 functions of the hot list (`spvtools::val::`), moves to the
cold part (`tools/mesa-hot.py --drop`). With that build (cp3, same method):
| trace | run | in translation, ms | of it in the validator, ms | per translation, ms |
| --- | --- | --- | --- | --- |
| HK menu (49 translations) | validation off | 379.2, 339.6 | 0.0, 0.0 | 7.33 |
| | validation on | 514.2, 485.6 | 138.4, 135.8 | 10.20 |
| Noita world (54) | validation off | 490.3, 529.3 | 0.0, 0.0 | 9.44 |
| | validation on | 725.3, 650.5 | 165.4, 162.8 | 12.74 |
Off is now 28.1% less translation time on HK (359.4 against 499.9 ms, 2.9
ms less per translation) and 25.9% less on Noita (509.8 against 687.9 ms,
3.3 ms less per translation): the hitch a mid-frame shader compile costs.
Over the whole trace it stays inside the noise (Mesa CPU, ms/frame, off
then on: HK 5.041, 5.019 against 5.020, 4.750; Noita 4.452, 4.762 against
4.950, 4.428), since translation is under 0.5 s of about 15 s of Mesa CPU.
The sampling cannot cut the time
into single translations (a translation's samples are not contiguous
enough), so there is no per-translation maximum; the largest contiguous
run under `translate_here` was 47.25 ms with validation on and 36.47 ms
off, on Noita.
### Tint and SPIRV-Tools behind a warm WGSL cache
With every translation in the WGSL cache (`_gex_wgsl_cache_load` of the blob
a first run dumped; a page gets one from the site's gex-wgsl-cache.bin or
IndexedDB), the profiling build shows Tint and SPIRV-Tools do not run at
all:
| trace | run | functions run | Tint | SPIRV-Tools |
| --- | --- | --- | --- | --- |
| HK menu | cold cache | 3,962 | 1,237 (0.87 MB) | 510 (0.51 MB) |
| HK menu | warm cache (49 of 49 hits) | 2,035 | 4 (0 MB) | 0 |
| Noita world | cold cache | 3,895 | 1,256 (0.88 MB) | 505 (0.51 MB) |
| Noita world | warm cache (54 of 54 hits) | 1,959 | 4 (0 MB) | 1 |
GLSL, NIR and spirv_to_nir still run (Mesa compiles GLSL to SPIR-V before
it looks the WGSL up). Mesa CPU with the warm cache (table above, rerun):
HK 4.807 ms/frame against 5.006 cold, 4.0% less; Noita 4.639 against 4.582,
1.2% more, inside its spread (4.367 to 4.910). So a shipped cache is a load
time and stall win more than an average-CPU one, and the phase split it
allows (Tint and SPIRV-Tools, 1.38 MB, to the cold part when a cache ships)
is in the backlog.
## 4. The cold part (M3)
web/build-mesa-wasm.sh links the replay with `-sSPLIT_MODULE=1` and splits
its plain module (`gex-wgpu-replay.wasm.orig`) with web/mesa-split.py:
the functions in web/mesa-hot.txt stay in `gex-wgpu-replay.wasm`, the rest
go to `gex-wgpu-replay.deferred.wasm` beside it. Every publish step that
copies the one copies the other (build-all's dists, build-eject-page.sh, the
mesa-wgpu-*/gl44 tests). `GEX_MESA_HOT=FILE` splits by another list,
`GEX_MESA_HOT=none` (any missing file) ships it whole.
### Loading (web/gex-wgpu-split.js, a --pre-js)
- A miss is one load of the whole cold part per instance of the module,
however many cold functions run after it.
- In a Worker (the desktop's compositor in MP, the system Worker in SP),
the cold part is left until a function there first runs. Once the module
has presented a frame, the Worker fetches and compiles it in the
background (`fetch` + `WebAssembly.compile`); a miss after that only
instantiates it. A miss before then fetches it with a synchronous XHR and
compiles it synchronously, which a Worker may do at any size.
- On a page's main thread (demos.js' gears in MP, the replay and smoke test
pages) Chromium refuses a synchronous compile or instantiation of a module
over 8 MB, so there the cold part is instantiated with the module,
asynchronously, before it starts, and never missed. (`gexColdLazy: true`
in the module's options keeps it lazy, for measuring a miss.)
Two things had to change in what wasm-split and Emscripten's glue do:
- Features. The module has no target_features section, and with `-all`
Binaryen 130 writes the cold part's imports as exact function imports
(import kind 0x20), which Chromium refuses ("Invalid import kind 32").
With reference types it also puts the placeholders in a second table,
while Emscripten's glue (6.0.1) calls the loaded function through the
first, `wasmTable.get(index)`: some other function. mesa-split.py passes
the link's own feature set.
- JSPI. Emscripten's placeholders are JS functions that load the cold part
and then call the function from JS. The first call into a cold function
that then waits on WebGPU (glReadPixels mapping a buffer) failed: "SuspendError:
trying to suspend JS frames". mesa-split.py replaces each placeholder with
a wasm trampoline (generated, merged in with wasm-merge) that calls the
one import `gexcold.load` and then calls through its own table slot, by
then the function itself, so no JS frame is left on the stack.
gex-wgpu-split.js supplies `gexcold.load` through Emscripten's
`instantiateWasm` hook.
Nothing in gex's Mesa runs on a pthread (no glthread), so one instance per
page or Worker is all there is; a pthread would load its own the same way.
### The hot list
web/mesa-hot.txt is made from the profiling build of this branch's M2 build
(24,721 functions, 15.79 MB of code; `GEX_MESA_PROFILE=1`), edger,
Chromium on its GPU, unless said:
| workload | functions run | their code |
| --- | --- | --- |
| Muck page, to its menu (10 min) | 4,219 | 4.91 MB |
| HK page, to its menu (12 min, interpreter) | 3,954 | 4.65 MB |
| Noita page, menu then 150 s of world | 3,833 | 4.62 MB |
| Undertale page on W3 (10 min) | 3,750 | 4.51 MB |
| browser-gui x86 --gl (Wine's glwindow.exe; gooner) | 2,364 | 2.63 MB |
| gears trace replay (gooner) | 3,243 | 4.85 MB |
| HK menu trace replay (3,166 frames) | 3,962 | 5.45 MB |
| Noita world trace replay (2,777 frames) | 3,895 | 5.46 MB |
| Muck menu trace replay (3,000 frames) | 4,479 | 5.95 MB |
| **union** | **4,865 (19.7%)** | **6.22 MB (39.4%)** |
| margin: direct callees up to 1 KB, of callers of up to 64 | +1,047 | +0.34 MB |
| first list | 5,912 (23.9%) | 6.56 MB (41.5%) |
| HK and Muck pages again, printed in full (4,036 and 4,303 run) | +1 (`_dispatch_stub_GetObjectLabelEXT`) | |
| margin over the new union | +229 | +0.07 MB |
| second list | 6,143 (24.8%) | 6.63 MB (42.0%) |
| SPIR-V validator off in both places: `--drop 'spvtools::val::'` and its entry points | -475 | -0.51 MB |
| margin over the new union | +64 | +0.02 MB |
| names matched without Binaryen's `_N` suffix (twins of listed functions, and three the Tint rebuild renamed) | +86 | +0.05 MB |
| final build profiled again (trace replays with no WGSL cache, gears, Muck and HK pages): nothing outside the list; margin over it | +20 | +0.01 MB |
| **kept** | **5,844 (23.6%)** | **6.20 MB (39.2%)** |
The page harnesses cut console lines at about 4,000 characters, and a set
of 24,721 functions is 4,120 in base64, so the first page rows above miss
the last 937 functions (0.98 MB: dispatch stubs, part of Tint's IR); the
trace replays of the same games, which print in full, covered all but one:
the HK and Muck pages, run again with the profile printed in pieces, each
ran exactly one function outside the first list, the GL_EXT_debug_label
dispatch stub, which was each page's one miss. The glue now
prints the set in 2 KiB pieces (`mesa-profile N HITS @OFFSET BASE64`), and
tools/mesa-hot.py reads both forms (and as much of a cut line as there is).
The margin leaves out the callees of a dispatcher (a caller of more than 64
functions): the wire decoder `one()` calls all 2,522 per-command decoders,
which would otherwise all be kept.
### Sizes (M3)
As the dists ship them (stripped), zstd at -19:
| flavour | file | raw | zstd -19 |
| --- | --- | --- | --- |
| MP | master, whole | 18,898,007 | 3,305,617 |
| MP | M3: gex-wgpu-replay.wasm | 9,593,437 | 1,929,678 |
| MP | M3: gex-wgpu-replay.deferred.wasm | 9,953,763 | 1,586,817 |
| SP | master, whole | 18,669,577 | 3,300,261 |
| SP | M3: gex-wgpu-replay.wasm | 9,392,394 | 1,929,097 |
| SP | M3: gex-wgpu-replay.deferred.wasm | 9,948,024 | 1,580,830 |
What a page fetches and compiles before Mesa starts is 49.2% smaller raw
than master's (9.59 against 18.90 MB) and 41.6% smaller compressed (1.93
against 3.31 MB) in MP; 49.7% and 41.5% in SP. The two parts together are
0.89 MB more raw than the unsplit M2 build (19,547,200 against 18,655,830):
the 17,173 trampolines and the exports the cold part imports through.
(With the second list, before the validator went cold: MP 10,031,571 /
2,032,243 and 9,503,551 / 1,493,059.)
Binaryen at -Oz on the cold part alone takes it to 8,640,842 bytes but
makes it 15 KB bigger compressed, so it is left at -O3.
### Speed and misses (M3, edger)
The final list (6,143 functions), pages against master's Mesa, ABBA
(j7), last-minute presents/s for each of the last four minutes:
| page | run | build | presents/s | cold part |
| --- | --- | --- | --- | --- |
| Muck | 1 | master's | 11.1 11.0 10.9 10.4 | none |
| | 2 | split | 11.0 11.4 11.4 10.8 | prefetched (62.4 ms), never missed |
| | 3 | split | 10.9 11.1 11.1 10.5 | prefetched (113.0 ms), never missed |
| | 4 | master's | 11.0 11.0 10.9 10.6 | none |
| HK | 1 | master's | 14.2 14.4 14.3 14.5 | none |
| | 2 | split | (42.3: HK went into a NullReferenceException loop in Unity's TickRealtimeProbes; excluded) | prefetched (90.7 ms), never missed |
| | 3 | split | 14.7 14.7 14.6 14.6 | prefetched (121.3 ms), never missed |
| | 4 | master's | 13.6 13.5 13.7 13.9 | none |
| HK (j8, for run 2) | 5 | split | 14.2 14.5 14.7 15.1 | prefetched (115.4 ms), never missed |
| | 6 | master's | 13.4 13.7 13.9 13.8 | none |
Muck's split is 1.5% faster (11.03 against 10.86). HK's split is 5.2%
faster (14.64 against 13.91, two runs against three), about the spread
of master's own runs (14.35 to 13.68). HK run 2's split never missed, so
no cold function ran in it and the Mesa code it ran was the same as in run
3: the exception loop is HK's, not the split's. With the final list
neither page missed in any run; the prefetch, about 0.1 s of fetch and
compile in the Worker after the first frame, was all the cold part cost.
The runs below are of the first list (5,912 functions), with which each
page missed once (`_dispatch_stub_GetObjectLabelEXT`).
Mesa CPU per frame, by trace replay (the last 600 frames, page main thread,
so the cold part is loaded with the module), ms/frame, runs in alternating
order:
| trace | unsplit (M2 build) | split | split, every function kept |
| --- | --- | --- | --- |
| Noita world | 5.144 5.102 5.207 5.066 | 4.884 4.955 4.905 4.796 | 4.906 4.978 |
| HK menu | 4.857 5.078 6.5 5.066 5.088 5.049 4.949 4.815 6.322 6.109 | 6.191 6.237 6.345 5.249 5.491 5.112 6.284 5.159 5.488 4.89 | 5.068 5.106 |
The split is 4.8% faster on Noita's trace (mean 4.885 against 5.130) and
4.9% slower on HK's (mean 5.645 against 5.383). The HK trace's runs fall
into two groups, about 5.0 and about 6.2 ms, in every build; three of the
ten unsplit runs land high and four of the ten split ones. A V8 CPU profile
of the split's measured HK window has no sample in the cold part (fin
5.583, split 5.503 ms/frame with the profiler on), so the difference is
not cold code running: it is where the code sits, about the size of the
spread.
Pages (last-minute presents/s, each minute of the last four):
| page | build | presents/s | cold part |
| --- | --- | --- | --- |
| Muck | master's Mesa | 10.8 11.0 11.1 10.4 | none |
| Muck | split | 11.2 11.2 11.1 10.5 | one miss, prefetched: 5.7 ms |
| HK (j4) | split, then master's | 13.6 12.8 12.9 13.3 / 14.9 14.9 14.8 14.6 | one miss, prefetched: 9.4 ms |
| HK (j5, ABBA) | master's | 15.0 14.9 15.1 15.3 | none |
| | split | 13.6 13.2 13.3 13.5 | one miss, prefetched: 8.9 ms |
| | split | 14.6 14.7 14.8 14.7 | one miss, prefetched: 6.2 ms |
| | master's | 14.0 13.7 13.6 13.9 | none |
Muck's split is 1.6% faster (11.0 against 10.8). HK's ABBA is 2.7% slower
split (14.05 against 14.44), inside the spread of master's own two runs
(15.08 and 13.80).
A miss's stall, edger:
| where | how | stall |
| --- | --- | --- |
| Muck and HK pages, compositor Worker | prefetched, then instantiated | 5.7 to 9.4 ms |
| Muck page, compositor Worker, forced early miss (`_mesa_Viewport` cold) | synchronous fetch and compile, 9,577,491 bytes | 304.5 ms |
| HK trace replay, main thread (lazy), forced late miss, no prefetch | synchronous fetch and compile | 261.7 ms |
| the same, early miss | synchronous fetch and compile | 239.9 ms |
| the same, late miss, prefetch still compiling | synchronous fetch and compile | 306.9 ms |
On worldmachine (RX 6500 XT, AMD; never compared with edger's numbers),
the final build, HK menu trace, page main thread:
| run | build | Mesa CPU, ms/frame | cold part |
| --- | --- | --- | --- |
| 1 | whole (fin3) | 2.630 | none |
| 1 | split (finsplit3) | 2.648 | loaded with the module in 60.8 ms |
| 2 | split | 2.623 | loaded with the module in 70.0 ms |
| 2 | whole | 2.625 | none |
| forced miss, first frame (`_mesa_Viewport` cold, lazy, no prefetch) | split | 3.414 | fetched and compiled synchronously, 10,009,314 bytes, in 233.4 ms |
| the same again | split | 3.256 | 232.1 ms |
The split is 0.3% slower there (2.636 against 2.628 ms/frame), and loads
cleanly on a second GPU vendor. The same runs showed each of the module's
four pthreads prefetching its own copy of the cold part (fetch and compile,
129 to 368 ms each, on Workers), even where the main thread had loaded it
with the module and even with `gexNoColdPrefetch` (a pthread does not get
the module's options). Mesa runs no code on them; the prefetch now skips
pthreads.
A miss while the prefetch is still compiling does not wait for it (a
synchronous call cannot): it fetches and compiles again.
A miss now says where it came from (`..., from CALLER <- ...`, the wasm
frames under the trampoline). One Muck page run of the build before the
last list (j10) missed once before its first frame (241.0 ms, synchronous,
in the compositor Worker), during a shader translation with no WGSL cache
entry; three more runs of it (j11, j12) and the final build's profiles did
not show it again, so which function it was is not known.
The final build on the pages (j14, edger): Muck 11.4 11.2 11.3 10.7
presents/s, HK 14.5 14.6 14.7 14.6; each prefetched the cold part (83.6 and
84.6 ms) and never missed.
### Testing it
The gate leg, after `web/build-all.sh --both`, on any host (SwiftShader):
`tests/mesa-cold/gate.sh` runs the two below on the mt and st builds and
the SP dist, and prints `ok mesa-cold gate: 3/3`. MP gears needs a GPU
(`web/test/site.mjs gears` on web/dist, on the bench box: `ok site-gears`).
`node tests/mesa-cold/run.mjs [TARGET_DIR]` (GEX_WEBGPU_SOFTWARE=1 on a host
without a GPU) replays the gears trace through the build's Mesa whole, split,
and with glReadPixels forced cold (`web/mesa-split.py --cold`) and missed,
with and without the prefetch, and checks every read-back hashes the same.
On gooner, mt and st, all four legs pass with digest 9e7d5cb9/307200.
The realms, on gooner (SwiftShader; correctness only):
| realm | test | result |
| --- | --- | --- |
| MP page main thread | tests/mesa-cold (split leg) | cold part loaded with the module |
| MP compositor Worker | tests/nt/browser-gui.mjs --arch x86 --gl, x64 --gl | 12/12 ok each, no miss |
| MP compositor Worker, direct present | the same with GEX_NT_PAGE_FLAGS='&direct=1' | 12/12 ok each, no miss |
| MP compositor Worker, forced miss | browser-gui x86 --gl with a hot list missing glwindow's functions | 12/12 ok, one synchronous load |
| SP system Worker | web/test/site.mjs gears on web/dist-sp | ok |
| eject page | web/build-eject-page.sh | carries gex-wgpu-replay.deferred.wasm beside the module; Mesa runs in the same compositor Worker |
+3
View File
@@ -32,6 +32,9 @@ case ${GEX_CTS_BACKEND:-mesa} in
mkdir -p "$tree/gex"
cp "$MESA_BUILD/gex-wgpu-replay.js" \
"$MESA_BUILD/gex-wgpu-replay.wasm" "$tree/gex/"
rm -f "$tree/gex/gex-wgpu-replay.deferred.wasm"
[ ! -f "$MESA_BUILD/gex-wgpu-replay.deferred.wasm" ] ||
cp "$MESA_BUILD/gex-wgpu-replay.deferred.wasm" "$tree/gex/"
done
;;
*) echo "unknown GEX_CTS_BACKEND: ${GEX_CTS_BACKEND}" >&2; exit 2 ;;
+3
View File
@@ -70,6 +70,9 @@ if [ -f "$MESA_BUILD/gex-wgpu-replay.wasm" ]; then
mkdir -p "$tree/gex"
cp "$MESA_BUILD/gex-wgpu-replay.js" \
"$MESA_BUILD/gex-wgpu-replay.wasm" "$tree/gex/"
rm -f "$tree/gex/gex-wgpu-replay.deferred.wasm"
[ ! -f "$MESA_BUILD/gex-wgpu-replay.deferred.wasm" ] ||
cp "$MESA_BUILD/gex-wgpu-replay.deferred.wasm" "$tree/gex/"
done
else
echo "warning: no gex-wgpu-replay in $MESA_BUILD; testing whatever host" \
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# tests/mesa-cold/gate.sh: the gate leg for Mesa's cold part (docs/mesa-wasm-size.md), after
# `web/build-all.sh --both`. Correctness only (SwiftShader, any host); timing is the bench box's.
# 1. tests/mesa-cold/run.mjs on the mt and the st build: whole, split, a forced miss, a prefetched miss
# 2. web/test/site.mjs gears on the SP dist (Mesa in the system Worker, split, lazy)
# Prints `ok mesa-cold gate: 3/3` when every leg passes; exits non-zero otherwise.
# MP gears needs a GPU (it fails on SwiftShader on master too): run it on the bench box.
set -u
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
cd "$ROOT"
# Chromium's singleton socket path must stay short.
export TMPDIR=$(mktemp -d /tmp/mcold.XXXX)
trap 'sleep 1; rm -rf "$TMPDIR" 2>/dev/null' EXIT
export GEX_WEBGPU_SOFTWARE=1
pass=0
for v in mt st; do
out=$(node tests/mesa-cold/run.mjs "target/web-build/mesa-$v/build/src/gallium/targets/gex-wasm" 2>&1)
echo "$out" | sed "s/^/[$v] /"
echo "$out" | grep -q '^ok mesa-cold: 4/4' && pass=$((pass + 1))
done
out=$(GEX_NO_COI=1 GEX_DIST="$ROOT/web/dist-sp" node web/test/site.mjs gears 2>&1)
echo "$out" | tail -2 | sed 's/^/[sp] /'
echo "$out" | grep -q '^ok site-gears' && pass=$((pass + 1))
if [ $pass = 3 ]; then echo "ok mesa-cold gate: 3/3"; else echo "FAIL mesa-cold gate: $pass/3"; exit 1; fi
+105
View File
@@ -0,0 +1,105 @@
<!doctype html>
<meta charset="utf-8">
<title>Mesa cold part</title>
<pre id="out">loading</pre>
<script type="module">
// tests/mesa-cold/run.mjs's page: replays a GL trace through one build of
// Mesa's replay module on WebGPU, then reads the last frame back (READ_PIXELS,
// which waits on a buffer map: a JSPI suspension) and hashes it.
// ?mesa=URL of gex-wgpu-replay.js &trace=URL
// &lazy=1 leave the cold part for a miss on this main thread too
// &noprefetch=1 and do not prefetch it after the first frame
// &settle=MS wait this long before the read-back (a prefetch's time)
// Result in globalThis.__result.
const q = new URLSearchParams(location.search);
const out = document.getElementById('out');
const log = (s) => { out.textContent += '\n' + s; console.log(s); };
function textureCanvas() {
const canvas = new OffscreenCanvas(16, 16);
let config = null, texture = null;
const context = {
canvas,
configure(c) { config = c; },
unconfigure() { config = null; },
getConfiguration() { return config; },
getCurrentTexture() {
if (!texture || texture.width !== canvas.width || texture.height !== canvas.height) {
texture?.destroy();
texture = config.device.createTexture({
size: [canvas.width, canvas.height], format: config.format, viewFormats: config.viewFormats ?? [],
usage: (config.usage ?? GPUTextureUsage.RENDER_ATTACHMENT) | GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC,
});
}
return texture;
},
};
const own = canvas.getContext.bind(canvas);
canvas.getContext = (t, o) => (t === 'webgpu' ? context : own(t, o));
return canvas;
}
function hasPresent(b) {
const v = new DataView(b.buffer, b.byteOffset, b.byteLength);
for (let at = 0; at + 8 <= b.length;) {
const op = v.getUint32(at, true), size = v.getUint32(at + 4, true);
if (op === 4) return true;
if (size < 8) break;
at += size;
}
return false;
}
try {
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) throw new Error('no WebGPU adapter');
const optional = ['texture-compression-bc', 'depth32float-stencil8', 'rg11b10ufloat-renderable',
'depth-clip-control', 'float32-filterable', 'float32-blendable', 'clip-distances', 'dual-source-blending'];
const device = await adapter.requestDevice({ requiredFeatures: optional.filter((f) => adapter.features.has(f)) });
let validation = 0;
device.addEventListener('uncapturederror', (e) => { if (validation++ < 5) console.error('WebGPU validation: ' + e.error?.message); });
const glCanvas = textureCanvas();
const qs = document.querySelector.bind(document);
document.querySelector = (s) => (String(s).includes('gex-gl') ? glCanvas : qs(s));
const mesa = new URL(q.get('mesa'), location.href).href;
const { default: create } = await import(mesa);
const m = await create({
preinitializedWebGPUDevice: device, mainScriptUrlOrBlob: mesa,
locateFile: (f) => new URL(f, mesa).href,
gexColdLazy: q.get('lazy') === '1', gexNoColdPrefetch: q.get('noprefetch') === '1',
print: (l) => console.log('[mesa] ' + l), printErr: (l) => console.warn('[mesa] ' + l),
});
const submit = WebAssembly.promising(m.gexGlSubmitRaw);
const bytes = new Uint8Array(await (await fetch(q.get('trace'))).arrayBuffer());
const dv = new DataView(bytes.buffer);
const W = dv.getUint32(16, true), H = dv.getUint32(20, true);
glCanvas.width = W; glCanvas.height = H;
if (m._gex_gl_init(W, H) !== 0) throw new Error('init failed');
let ptr = m._malloc(1 << 20), cap = 1 << 20, frames = 0, rejects = 0;
for (let at = 24; at < bytes.length;) {
const len = dv.getUint32(at, true);
const b = bytes.subarray(at + 8, at + 8 + len);
at += 8 + len + ((4 - (len & 3)) & 3);
if (b.length > cap) { m._free(ptr); cap = b.length; ptr = m._malloc(cap); }
m.HEAPU8.set(b, ptr);
if ((await submit(ptr, b.length, 0)) !== 0) rejects++;
if (hasPresent(b)) {
m._gex_gl_display();
if (++frames % 32 === 0) await device.queue.onSubmittedWorkDone();
}
}
await device.queue.onSubmittedWorkDone();
await new Promise((r) => setTimeout(r, Number(q.get('settle') || 0)));
// READ_PIXELS of the default framebuffer, RGBA/UNSIGNED_BYTE.
const cmd = new Uint8Array(44); const cv = new DataView(cmd.buffer);
[128, cmd.length, 0, 0, W, H, 0x1908, 0x1401, 0, 0, 0].forEach((w, i) => cv.setUint32(i * 4, w, true));
m.HEAPU8.set(cmd, ptr);
if ((await submit(ptr, cmd.length, 0)) !== 0) rejects++;
const rlen = m._gex_gl_reply_len(), rat = m._gex_gl_reply_data();
const px = m.HEAPU8.slice(rat + 8, rat + rlen);
let h = 0x811c9dc5;
for (let i = 0; i < px.length; i++) { h ^= px[i]; h = Math.imul(h, 0x01000193) >>> 0; }
globalThis.__result = { ok: rejects === 0 && validation === 0, digest: `${h.toString(16)}/${px.length}`, frames, rejects, validation };
} catch (e) {
globalThis.__result = { ok: false, error: String(e?.stack || e) };
}
log(JSON.stringify(globalThis.__result));
</script>
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env node
// Mesa's cold part (docs/mesa-wasm-size.md): does the split replay draw what
// the whole one does, and does a miss load the cold part and carry on?
//
// node tests/mesa-cold/run.mjs [TARGET_DIR]
//
// TARGET_DIR is a Mesa build's gex-wasm target directory, after
// web/build-mesa-wasm.sh (default target/web-build/mesa-mt/build/src/gallium/
// targets/gex-wasm; the st one works the same). Replays
// tests/gl-traces/gles-gears-frames.gexgltr in Chromium (GEX_WEBGPU_SOFTWARE=1:
// on SwiftShader), reads the last frame back and hashes it, through:
//
// whole the module unsplit (the reference)
// split the build's own split, cold part loaded with the module
// (as a page's main thread does)
// miss every function kept but glReadPixels' (web/mesa-split.py
// --cold), left for a miss: the cold part is fetched and
// compiled synchronously, and the read-back then waits on
// WebGPU (JSPI) inside a function it brought
// prefetch the same after the background prefetch: instantiated only
//
// Prints `ok mesa-cold <leg>: ...` per leg and exits 0 when every digest
// matches the whole module's and each miss was taken as described.
import { spawn, spawnSync } from 'node:child_process';
import { createServer } from 'node:http';
import { copyFileSync, createReadStream, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, extname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Cdp, browserWsUrl } from '../../web/test/cdp.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const TGT = resolve(process.argv[2] || join(ROOT, 'target/web-build/mesa-mt/build/src/gallium/targets/gex-wasm'));
const TRACE = join(ROOT, 'tests/gl-traces/gles-gears-frames.gexgltr');
const CHROME = process.env.CHROME || '/usr/bin/chromium';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const fail = (msg) => { console.log(`FAIL mesa-cold: ${msg}`); process.exitCode = 1; };
for (const f of ['gex-wgpu-replay.wasm.orig', 'gex-wgpu-replay.js', 'split/gex-wgpu-replay.wasm']) {
if (!existsSync(join(TGT, f))) { console.log(`skip mesa-cold: no ${f} in ${TGT} (web/build-mesa-wasm.sh)`); process.exit(0); }
}
const WORK = mkdtempSync(join(process.env.TMPDIR || tmpdir(), 'mesa-cold-'));
const glue = readFileSync(join(TGT, 'gex-wgpu-replay.js'), 'utf8');
const marked = (split) => glue.replace(/(gexMesaColdSplit *= *["'])GEX_MESA_COLD_[A-Z]*/,
`$1GEX_MESA_COLD_${split ? 'YES' : 'NO'}`);
const variant = (name, files, split) => {
spawnSync('mkdir', ['-p', join(WORK, name)]);
for (const [to, from] of Object.entries(files)) copyFileSync(from, join(WORK, name, to));
writeFileSync(join(WORK, name, 'gex-wgpu-replay.js'), marked(split));
};
variant('whole', { 'gex-wgpu-replay.wasm': join(TGT, 'gex-wgpu-replay.wasm.orig') }, false);
const cold = join(TGT, 'split/gex-wgpu-replay.deferred.wasm');
variant('split', { 'gex-wgpu-replay.wasm': join(TGT, 'split/gex-wgpu-replay.wasm'),
...(existsSync(cold) ? { 'gex-wgpu-replay.deferred.wasm': cold } : {}) }, existsSync(cold));
const r = spawnSync('python3', [join(ROOT, 'web/mesa-split.py'), join(TGT, 'gex-wgpu-replay.wasm.orig'), '-',
join(WORK, 'miss'), '--cold', '[Rr]ead[Pp]ixels'], { encoding: 'utf8' });
if (r.status !== 0) { fail(`mesa-split.py: ${r.stderr || r.stdout}`); process.exit(1); }
writeFileSync(join(WORK, 'miss', 'gex-wgpu-replay.js'), marked(true));
console.log(` ${r.stdout.trim()}`);
const types = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.wasm': 'application/wasm' };
const server = createServer((req, res) => {
const p = decodeURIComponent(new URL(req.url, 'http://x').pathname);
if (p.includes('..')) { res.writeHead(403).end(); return; }
const file = p === '/' ? join(ROOT, 'tests/mesa-cold/page.html') : p === '/trace' ? TRACE : join(WORK, p);
let st;
try { st = statSync(file); } catch { res.writeHead(404).end(); return; }
res.writeHead(200, { 'Content-Type': types[extname(file)] ?? 'application/octet-stream', 'Content-Length': st.size,
'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp' });
createReadStream(file).pipe(res);
});
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
const port = server.address().port;
async function leg(name, dir, query) {
const user = mkdtempSync(join(WORK, 'chrome-'));
const cdpPort = Number(process.env.GEX_MESA_COLD_CDP_PORT || 9667);
const chrome = spawn(CHROME, ['--headless', '--no-sandbox', '--disable-gpu-sandbox', '--enable-unsafe-webgpu',
'--enable-unsafe-swiftshader',
...(process.env.GEX_WEBGPU_SOFTWARE === '1' ? ['--use-angle=swiftshader-webgl', '--use-webgpu-adapter=swiftshader'] : []),
'--no-first-run', '--remote-allow-origins=*', `--remote-debugging-port=${cdpPort}`, `--user-data-dir=${user}`,
'about:blank'], { stdio: 'ignore' });
try {
let ws = null;
for (let i = 0; i < 80 && !ws; i++) { ws = await browserWsUrl(cdpPort); if (!ws) await sleep(250); }
if (!ws) throw new Error('no browser');
const browser = await Cdp.connect(ws);
const { targetId } = await browser.send('Target.createTarget', { url: 'about:blank' });
const { sessionId: s } = await browser.send('Target.attachToTarget', { targetId, flatten: true });
await browser.send('Runtime.enable', {}, s);
const lines = [];
browser.on('Runtime.consoleAPICalled', (p, sid) => {
if (sid === s) lines.push(p.args.map((a) => a.value ?? a.description ?? '').join(' '));
});
await browser.send('Page.navigate', { url: `http://127.0.0.1:${port}/?mesa=/${dir}/gex-wgpu-replay.js&trace=/trace${query}` }, s);
let res = null;
for (let t0 = Date.now(); !res && Date.now() - t0 < 300000;) {
await sleep(250);
res = (await browser.send('Runtime.evaluate', { expression: 'globalThis.__result ?? null', returnByValue: true }, s)
.catch(() => null))?.result?.value ?? null;
}
browser.close();
return { res: res ?? { ok: false, error: 'timeout' }, cold: lines.filter((l) => l.includes('cold part')) };
} finally {
chrome.kill('SIGKILL');
await sleep(500);
}
}
const want = {
whole: [],
split: existsSync(cold) ? ['loaded with the module'] : [],
miss: ['loaded on a miss (fetched and compiled now'],
prefetch: ['loaded on a miss (prefetched)'],
};
const legs = [['whole', 'whole', ''], ['split', 'split', ''], ['miss', 'miss', '&lazy=1&noprefetch=1'],
['prefetch', 'miss', '&lazy=1&settle=3000']];
let ref = null, passed = 0;
for (const [name, dir, query] of legs) {
const { res, cold: seen } = await leg(name, dir, query);
const how = seen.join(' | ') || 'no cold part loaded';
if (!res.ok) { fail(`${name}: ${res.error ?? JSON.stringify(res)}`); continue; }
ref ??= res.digest;
if (res.digest !== ref) { fail(`${name}: digest ${res.digest}, the whole module's ${ref}`); continue; }
const need = want[name];
if (need.some((w) => !seen.some((l) => l.includes(w))) || (need.length === 0 && seen.length)) {
fail(`${name}: ${how}; wanted ${need.join(', ') || 'none'}`);
continue;
}
passed++;
console.log(`ok mesa-cold ${name}: digest ${res.digest}, ${res.frames} frames; ${how}`);
}
server.close();
rmSync(WORK, { recursive: true, force: true });
if (passed === legs.length) console.log(`ok mesa-cold: ${passed}/${legs.length}`);
process.exit(process.exitCode ?? 0);
+3
View File
@@ -31,6 +31,9 @@ for tree in "$ROOT/web/public" "$ROOT/web/dist"; do
[ -d "$tree" ] || continue
mkdir -p "$tree/gex"
cp "$BUILD/gex-wgpu-replay.js" "$BUILD/gex-wgpu-replay.wasm" "$tree/gex/"
# The cold part, when the build split one off (docs/mesa-wasm-size.md).
rm -f "$tree/gex/gex-wgpu-replay.deferred.wasm"
[ ! -f "$BUILD/gex-wgpu-replay.deferred.wasm" ] || cp "$BUILD/gex-wgpu-replay.deferred.wasm" "$tree/gex/"
done
# The guest's own GL driver too, and the glibc build of it, which is the one
+3
View File
@@ -34,6 +34,9 @@ BUILD=${GEX_MESA_WASM_OUT:-${TMPDIR:-/tmp}/gex-mesa-wasm}/build/src/gallium/targ
mkdir -p "$WORK" "$ROOT/web/public/gex" "$ROOT/web/dist/gex"
for tree in "$ROOT/web/public" "$ROOT/web/dist"; do
cp "$BUILD/gex-wgpu-replay.js" "$BUILD/gex-wgpu-replay.wasm" "$tree/gex/"
# The cold part, when the build split one off (docs/mesa-wasm-size.md).
rm -f "$tree/gex/gex-wgpu-replay.deferred.wasm"
[ ! -f "$BUILD/gex-wgpu-replay.deferred.wasm" ] || cp "$BUILD/gex-wgpu-replay.deferred.wasm" "$tree/gex/"
done
GEX_CHROME_PORT=${GEX_CHROME_PORT:-9335} \
+3
View File
@@ -30,6 +30,9 @@ REPLAY=$BUILD/gex-mesa-replay.js
for tree in "$ROOT/web/public" "$ROOT/web/dist"; do
mkdir -p "$tree/gex"
cp "$BUILD/gex-wgpu-replay.js" "$BUILD/gex-wgpu-replay.wasm" "$tree/gex/"
# The cold part, when the build split one off (docs/mesa-wasm-size.md).
rm -f "$tree/gex/gex-wgpu-replay.deferred.wasm"
[ ! -f "$BUILD/gex-wgpu-replay.deferred.wasm" ] || cp "$BUILD/gex-wgpu-replay.deferred.wasm" "$tree/gex/"
cp "$TRACE" "$tree/gex/replay.gexgltr"
done
cp "$ROOT/web/public/mesa-wgpu-replay.html" "$ROOT/web/dist/mesa-wgpu-replay.html"
+3
View File
@@ -34,6 +34,9 @@ node "$ROOT/tests/gl-make-context-trace.mjs" "$WORK/context.gexgltr" "$API" 1 0
for tree in "$ROOT/web/public" "$ROOT/web/dist"; do
mkdir -p "$tree/gex"
cp "$BUILD/gex-wgpu-replay.js" "$BUILD/gex-wgpu-replay.wasm" "$tree/gex/"
# The cold part, when the build split one off (docs/mesa-wasm-size.md).
rm -f "$tree/gex/gex-wgpu-replay.deferred.wasm"
[ ! -f "$BUILD/gex-wgpu-replay.deferred.wasm" ] || cp "$BUILD/gex-wgpu-replay.deferred.wasm" "$tree/gex/"
cp "$WORK/context.gexgltr" "$tree/gex/replay.gexgltr"
done
cp "$ROOT/web/public/mesa-wgpu-replay.html" "$ROOT/web/dist/mesa-wgpu-replay.html"
+4 -1
View File
@@ -583,7 +583,10 @@ def emit_nt(cmds, plans, opcodes, pname_rust):
def host_case(c, opcode, plan):
toks = plan.split(" ")
r, ptoks = toks[0], toks[1:]
L = [f"/* {c.name} */", f"template <typename R> static bool gexgen_{opcode}(R& a) {{"]
# Never inlined: each is called from one case of the decoder's switch, and
# inlined, all 2,520 made that switch one 1.2 MB function -- slow for V8
# to compile, and indivisible by wasm-split (docs/mesa-wasm-size.md).
L = [f"/* {c.name} */", f"template <typename R> __attribute__((noinline)) static bool gexgen_{opcode}(R& a) {{"]
args = []
post = []
check = []
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Make web/mesa-hot.txt, the functions Mesa's replay keeps out of its cold part.
tools/mesa-hot.py PLAIN.wasm LOG... [-o web/mesa-hot.txt] [--callee-max BYTES]
[--fanout-max N] [--also NAMES.txt]... [--drop REGEX]...
PLAIN.wasm is the profiling build's module before instrumentation; each LOG
holds the `[gex-wgpu] mesa-profile N HITS BASE64` lines the instrumented one
printed while a workload ran (docs/mesa-wasm-size.md, "Measuring"). A
function is hot if any LOG saw it run. The margin adds what a hot function
calls directly when it is at most --callee-max bytes (default 1024): the
error and edge paths a hot function reaches on another workload's input.
A caller of more than --fanout-max functions (default 64) adds none: that is
a dispatcher, like the wire decoder's `one()` over its 2,520 per-command
decoders, and its callees are as cold as the profiles say.
--also adds the names in a list this script wrote before, for another build
of the same sources (a page run's profile outlives a change elsewhere in
Mesa: names are matched, not indices). --callee-max 0 leaves the margin out.
--drop leaves out every function whose name matches REGEX (Python, searched),
from the profiles and the margin both: code a workload ran that a later
change keeps from running (`spvtools::val::`, the SPIR-V validator, which
runs only with TINT_SPIRV_VALIDATE=1).
Prints the hot set per log, the union and the margin, in functions and code
bytes. The list holds Binaryen's names without ThinLTO's `.llvm.<hash>`
suffixes (web/mesa-split.py matches them the same way).
"""
import base64
import os
import re
import shutil
import subprocess
import sys
LLVM_SUFFIX = re.compile(r'\.llvm\.\d+')
# Binaryen tells functions of the same name apart with a `_N` suffix whose N
# moves whenever the module gains or loses one, so a list is matched with
# those suffixes off too (a duplicate kept with its twin costs little).
DUP_SUFFIX = re.compile(r'_\d+$')
def stable(name):
return DUP_SUFFIX.sub('', LLVM_SUFFIX.sub('', name))
PROFILE = re.compile(r'mesa-profile (\d+) (\d+) (?:@(\d+) )?([A-Za-z0-9+/=]+)')
def tool(name):
try:
root = subprocess.run(['em-config', 'BINARYEN_ROOT'], capture_output=True, text=True,
check=True).stdout.strip()
if os.path.exists(os.path.join(root, 'bin', name)):
return os.path.join(root, 'bin', name)
except (OSError, subprocess.CalledProcessError):
pass
return shutil.which(name) or sys.exit(f'no {name}')
def leb(b, i):
r = s = 0
while True:
x = b[i]
i += 1
r |= (x & 0x7f) << s
s += 7
if x < 0x80:
return r, i
def body_sizes(path):
"""Code bytes of each defined function, in order."""
b = open(path, 'rb').read()
i = 8
while i < len(b):
sid = b[i]
size, i = leb(b, i + 1)
if sid == 10:
n, j = leb(b, i)
out = []
for _ in range(n):
s, k = leb(b, j)
out.append(s + k - j)
j = k + s
return out
i += size
return []
def main():
args = sys.argv[1:]
out, callee_max, fanout_max, also, drop = None, 1024, 64, [], []
while '--drop' in args:
k = args.index('--drop')
drop.append(re.compile(args[k + 1]))
del args[k:k + 2]
while '--also' in args:
k = args.index('--also')
also.append(args[k + 1])
del args[k:k + 2]
if '-o' in args:
k = args.index('-o')
out = args[k + 1]
del args[k:k + 2]
if '--fanout-max' in args:
k = args.index('--fanout-max')
fanout_max = int(args[k + 1])
del args[k:k + 2]
if '--callee-max' in args:
k = args.index('--callee-max')
callee_max = int(args[k + 1])
del args[k:k + 2]
if len(args) < 1 or (len(args) < 2 and not also):
sys.exit(__doc__)
plain, logs = args[0], args[1:]
fmap = subprocess.run([tool('wasm-opt'), '-all', plain, '--print-function-map', '-o', os.devnull],
capture_output=True, text=True, check=True).stdout
names = [l.split(':', 1)[1] for l in fmap.splitlines() if ':' in l]
sizes = body_sizes(plain)
nimp = len(names) - len(sizes)
defined = names[nimp:]
total = sum(sizes)
print(f'{plain}: {len(defined)} functions, {total} code bytes')
def report(what, idx):
b = sum(sizes[i] for i in idx)
print(f' {what}: {len(idx)} functions ({100 * len(idx) / len(defined):.1f}%), '
f'{b} bytes ({100 * b / total:.1f}%)')
union = set()
for log in logs:
seen = set()
for line in open(log, errors='replace'):
m = PROFILE.search(line)
if not m:
continue
n = int(m.group(1))
if n != len(defined):
sys.exit(f'{log}: a profile of {n} functions, not this module\'s {len(defined)}')
# `@OFFSET`: a piece of the set from that byte on (web/gex-wgpu-jspi.js);
# without one, the whole set. A line a harness cut short still
# gives the functions it got as far as.
text = m.group(4)
bits = base64.b64decode(text[:len(text) // 4 * 4])
base = int(m.group(3) or 0) * 8
seen |= {base + i for i in range(min(len(bits) * 8, n - base)) if bits[i >> 3] >> (i & 7) & 1}
report(os.path.basename(log), seen)
union |= seen
by_name = {}
for i, n in enumerate(defined):
by_name.setdefault(stable(n), []).append(i)
for path in also:
names = {l.strip() for l in open(path) if l.strip() and not l.startswith('#')}
seen = {i for n in names for i in by_name.get(stable(n), ())}
report(f'{os.path.basename(path)} ({len(names)} names, {len({n for n in names if stable(n) not in by_name})} not in this build)', seen)
union |= seen
unescape = lambda n: re.sub(r'\\([0-9a-f]{2})', lambda m: chr(int(m.group(1), 16)), n)
dropped = {i for i in range(len(defined))
if any(r.search(defined[i]) or r.search(unescape(defined[i])) for r in drop)}
if drop:
report('dropped (--drop)', union & dropped)
union -= dropped
report('union', union)
# Direct calls, from Binaryen's text: `(func $name` opens a body.
index = {n: i for i, n in enumerate(defined)}
calls, cur = {}, None
head, call = re.compile(r'^ \(func \$(\S+)'), re.compile(r'\(call \$([^\s)]+)')
dis = subprocess.Popen([tool('wasm-dis'), plain], stdout=subprocess.PIPE, text=True,
errors='replace')
for line in dis.stdout:
m = head.match(line)
if m:
cur = index.get(m.group(1))
continue
if cur in union and '(call $' in line:
for c in call.findall(line):
j = index.get(c)
if j is not None:
calls.setdefault(cur, set()).add(j)
dis.wait()
margin = {j for i in union if len(calls.get(i, ())) <= fanout_max
for j in calls.get(i, ()) if j not in union and j not in dropped and sizes[j] <= callee_max}
report(f'margin (callees up to {callee_max} bytes of callers of up to {fanout_max})', margin)
keep = union | margin
report('kept', keep)
if out:
with open(out, 'w') as f:
f.write('# Mesa replay functions kept out of the cold part: tools/mesa-hot.py,\n'
'# docs/mesa-wasm-size.md. Binaryen names, without .llvm.<hash>.\n')
f.writelines(sorted({LLVM_SUFFIX.sub('', defined[i]) + '\n' for i in keep}))
print(f'wrote {out}')
if __name__ == '__main__':
main()
+4
View File
@@ -31,4 +31,8 @@ for f in gex-wgpu-replay.js gex-wgpu-replay.wasm; do
if [ -f "$WEB/public/gex/$f" ]; then cp "$WEB/public/gex/$f" "$OUT/gex/$f"
else echo "build-eject-page: no web/public/gex/$f (web/build-mesa-wasm.sh): the page has no OpenGL" >&2; fi
done
# Mesa's cold part, when the build split one off (docs/mesa-wasm-size.md).
rm -f "$OUT/gex/gex-wgpu-replay.deferred.wasm"
[ ! -f "$WEB/public/gex/gex-wgpu-replay.deferred.wasm" ] ||
cp "$WEB/public/gex/gex-wgpu-replay.deferred.wasm" "$OUT/gex/"
echo "build-eject-page: $OUT"
+48 -4
View File
@@ -148,12 +148,56 @@ fi
# Where the page loads them from. Left out before, so a rebuilt module sat in
# the build directory while the site served whatever had last been copied by
# hand -- which looks exactly like a change that did not work.
#
# The replay links with -sSPLIT_MODULE=1: its .wasm is wasm-split's
# instrumented module and never shipped, and .wasm.orig is the plain one,
# which web/mesa-split.py splits by web/mesa-hot.txt into what the page loads
# and a cold part (gex-wgpu-replay.deferred.wasm) it fetches the first time a
# function there runs (docs/mesa-wasm-size.md). GEX_MESA_HOT=none ships it
# whole; GEX_MESA_HOT=FILE splits by another list. GEX_MESA_PROFILE=1 ships
# it whole and instrumented instead, to measure which functions a workload
# runs (wasm-split --instrument --in-memory: a byte per function at address
# 0 up, below the main stack, which --stack-first puts first; the glue prints
# the set, and tools/mesa-hot.py turns logs of it into web/mesa-hot.txt).
TGT=$BUILD/src/gallium/targets/gex-wasm
if [ "$SANITIZE" != 1 ] && [ -f "$TGT/gex-wgpu-replay.wasm.orig" ]; then
HOT=${GEX_MESA_HOT:-$ROOT/web/mesa-hot.txt}
if [ "${GEX_MESA_PROFILE:-0}" = 1 ]; then
mkdir -p "$TGT/split"
rm -f "$TGT/split/gex-wgpu-replay.deferred.wasm"
# The link's features, as web/mesa-split.py names them.
"$(em-config BINARYEN_ROOT)/bin/wasm-split" --mvp-features --enable-threads --enable-simd \
--enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int \
--enable-sign-ext --enable-mutable-globals --enable-multivalue \
--instrument --in-memory -g \
"$TGT/gex-wgpu-replay.wasm.orig" -o "$TGT/split/gex-wgpu-replay.wasm"
echo " profiling build: tools/mesa-hot.py $TGT/gex-wgpu-replay.wasm.orig LOG..."
else
python3 "$ROOT/web/mesa-split.py" "$TGT/gex-wgpu-replay.wasm.orig" "$HOT" "$TGT/split"
fi
# The glue's loader (web/gex-wgpu-split.js) prefetches a cold part only
# where there is one.
if [ -f "$TGT/split/gex-wgpu-replay.deferred.wasm" ]; then
cold=YES
else
cold=NO
fi
# The assignment only: the test beside it names YES too.
sed -i "s/\\(gexMesaColdSplit *= *[\"']\\)GEX_MESA_COLD_[A-Z]*/\\1GEX_MESA_COLD_$cold/" "$TGT/gex-wgpu-replay.js"
cp "$TGT/gex-wgpu-replay.js" "$TGT/split/gex-wgpu-replay.js"
# And over the instrumented one, for the tests that copy from here.
cp "$TGT/split/gex-wgpu-replay.wasm" "$TGT/gex-wgpu-replay.wasm"
rm -f "$TGT/gex-wgpu-replay.deferred.wasm"
[ -f "$TGT/split/gex-wgpu-replay.deferred.wasm" ] &&
cp "$TGT/split/gex-wgpu-replay.deferred.wasm" "$TGT/gex-wgpu-replay.deferred.wasm"
fi
PUB="$ROOT/web/public/gex"
[ "$THREADS" = 0 ] && [ -d "$PUB" ] && PUB="$PUB/st" && mkdir -p "$PUB"
if [ -d "$PUB" ]; then
for f in gex-wgpu-replay.js gex-wgpu-replay.wasm; do
[ -f "$BUILD/src/gallium/targets/gex-wasm/$f" ] || continue
cp "$BUILD/src/gallium/targets/gex-wasm/$f" "$PUB/$f"
if [ -d "$PUB" ] && [ -f "$TGT/split/gex-wgpu-replay.wasm" ]; then
rm -f "$PUB/gex-wgpu-replay.deferred.wasm"
for f in gex-wgpu-replay.js gex-wgpu-replay.wasm gex-wgpu-replay.deferred.wasm; do
[ -f "$TGT/split/$f" ] || continue
cp "$TGT/split/$f" "$PUB/$f"
echo " published $PUB/$f"
done
fi
+35 -1
View File
@@ -1,5 +1,38 @@
// JSPI bridge for synchronous Gallium readback. The exported smoke/replay
// entry must be wrapped with WebAssembly.promising by its JavaScript caller.
// A profiling build (wasm-split --instrument --in-memory; docs/mesa-wasm-size.md)
// exports __write_profile: a flag per defined function, set the first time it
// runs. Every 2 s, when the set has grown, the module prints it as a bitmap --
// `[gex-wgpu] mesa-profile N HITS BASE64`, bit i for defined function i -- to
// the console, where the test harnesses already keep `gex-wgpu` warnings.
// Every other build exports no such function and this is never called.
var GEX_PROFILE_JS = `
function gexReportProfile(write) {
var last = -1;
var dump = function () {
var need = write(0, 0);
var at = _malloc(need);
write(at, need);
var n = (need - 8) >> 2, bits = new Uint8Array((n + 7) >> 3), hits = 0;
if (typeof growMemViews == "function") growMemViews();
var words = new Uint32Array(HEAPU8.slice(at + 8, at + 8 + n * 4).buffer);
_free(at);
for (var i = 0; i < n; i++) if (words[i]) { bits[i >> 3] |= 1 << (i & 7); hits++; }
if (hits === last) return;
last = hits;
// In pieces of 2 KiB (2,732 characters of base64): page harnesses cut
// console lines at about 4,000, and a 25,000-function set is 4,120.
// Each piece says where it starts, in bytes: \`@OFFSET\`.
for (var at0 = 0; at0 < bits.length; at0 += 2048) {
var bin = '';
for (var j = at0; j < Math.min(bits.length, at0 + 2048); j++) bin += String.fromCharCode(bits[j]);
console.warn('[gex-wgpu] mesa-profile ' + n + ' ' + hits + ' @' + at0 + ' ' + btoa(bin));
}
};
setInterval(dump, 2000);
Module['gexDumpProfile'] = dump;
}
`;
mergeInto(LibraryManager.library, {
gex_wgpu_jspi_wait: function () {
// A list, not one slot. WebGPU dispatches its map callbacks back into the
@@ -18,7 +51,8 @@ mergeInto(LibraryManager.library, {
// Publish whichever the linked module actually exports -- the raw wasm
// export, because WebAssembly.promising cannot wrap the JS-side wrapper.
gex_wgpu_jspi_wait__postset:
'_gex_wgpu_jspi_wait = new WebAssembly.Suspending(_gex_wgpu_jspi_wait); addOnPostRun(function() { if (wasmExports[\'gex_wgpu_smoke\']) Module[\'gexWgpuRaw\'] = wasmExports[\'gex_wgpu_smoke\']; if (wasmExports[\'gex_gl_submit\']) Module[\'gexGlSubmitRaw\'] = wasmExports[\'gex_gl_submit\']; });',
'_gex_wgpu_jspi_wait = new WebAssembly.Suspending(_gex_wgpu_jspi_wait); addOnPostRun(function() { if (wasmExports[\'gex_wgpu_smoke\']) Module[\'gexWgpuRaw\'] = wasmExports[\'gex_wgpu_smoke\']; if (wasmExports[\'gex_gl_submit\']) Module[\'gexGlSubmitRaw\'] = wasmExports[\'gex_gl_submit\']; if (wasmExports[\'__write_profile\']) gexReportProfile(wasmExports[\'__write_profile\']); });' +
GEX_PROFILE_JS,
gex_wgpu_jspi_signal: function () {
var waiting = Module['__gexWgpuWaiting'];
+161
View File
@@ -0,0 +1,161 @@
// Mesa's cold part (docs/mesa-wasm-size.md): a --pre-js of gex-wgpu-replay.
//
// The build splits the module with Binaryen's wasm-split (web/mesa-split.py).
// The functions the page's workloads run stay in gex-wgpu-replay.wasm; the
// rest moves to gex-wgpu-replay.deferred.wasm, and each table slot that would
// reach one of them holds a trampoline instead. A trampoline calls the import
// `gexcold.load` below, which instantiates the whole cold part over those
// slots, and then calls through its slot again: the function itself now. So
// a miss costs one load per instance of the module (the page's or Worker's
// that made it, and a pthread's own should Mesa run code there), not one per
// function, and leaves no JS frame on the stack that JSPI would have to
// suspend across. (Emscripten's own placeholders are JS functions that make
// the call themselves; web/mesa-split.py replaces them.)
//
// Two ways in, both here because this file runs in every realm the module
// does:
//
// - Prefetch. Once the module has presented a frame, the realm fetches the
// cold part and compiles it asynchronously, in the background. A miss
// after that only instantiates it.
// - A miss before then loads it synchronously: a synchronous XHR as text in
// the x-user-defined charset, compiled synchronously, which a Worker may
// do at any size.
//
// A page's main thread may not (Chromium refuses a synchronous compile or
// instantiation over 8 MB there), so on one the cold part is instantiated
// with the module, asynchronously, and never missed.
//
// The build sets the marker below when it split the module
// (web/build-mesa-wasm.sh); an unsplit build fetches nothing and changes
// nothing.
var gexMesaColdSplit = 'GEX_MESA_COLD_NO';
var gexMesaCold = function () {
return globalThis.__gexMesaCold || (globalThis.__gexMesaCold = {});
};
var gexMesaColdFile = function () {
return wasmBinaryFile.slice(0, -5) + '.deferred.wasm';
};
var gexMesaColdInstance = null;
// `gexcold.load`: the cold part, instantiated over this instance's table.
var gexMesaColdLoad = function () {
if (gexMesaColdInstance) return;
var t0 = performance.now();
var file = gexMesaColdFile();
var cache = gexMesaCold();
var module = cache[file];
var how = 'prefetched';
if (!(module instanceof WebAssembly.Module)) {
var xhr = new XMLHttpRequest();
xhr.open('GET', file, false);
xhr.overrideMimeType('text/plain; charset=x-user-defined');
xhr.send(null);
if (xhr.status !== 200 && xhr.status !== 0) {
throw new Error("Mesa's cold part " + file + ': HTTP ' + xhr.status);
}
var text = xhr.responseText;
var buf = new Uint8Array(text.length);
for (var i = 0; i < text.length; i++) buf[i] = text.charCodeAt(i) & 0xff;
module = cache[file] = new WebAssembly.Module(buf);
how = 'fetched and compiled now, ' + buf.length + ' bytes';
}
gexMesaColdInstance = new WebAssembly.Instance(module, { primary: wasmRawExports });
// Who missed: the frames under the trampoline, for web/mesa-hot.txt.
var where = String(new Error().stack).split('\n').filter(function (l) {
return l.indexOf('.wasm') >= 0 && l.indexOf('gexcoldtramp') < 0;
}).slice(0, 4).map(function (l) { return l.trim().replace(/^at /, '').replace(/ \(.*$/, ''); });
console.warn('[gex-wgpu] cold part loaded on a miss (' + how + ') in ' +
(performance.now() - t0).toFixed(1) + ' ms, from ' + (where.join(' <- ') || '?'));
};
if (gexMesaColdSplit === 'GEX_MESA_COLD_YES' && !Module['instantiateWasm']) {
// The glue's own instantiation, with the trampolines' import added.
Module['instantiateWasm'] = function (imports, done) {
var cold = { load: gexMesaColdLoad };
var all = new Proxy(imports, {
get: function (target, ns) { return ns === 'gexcold' ? cold : target[ns]; },
});
if (typeof ENVIRONMENT_IS_PTHREAD !== 'undefined' && ENVIRONMENT_IS_PTHREAD) {
// A pthread instantiates the module the page sent it, and has no file
// name of its own for the cold part's to follow.
wasmBinaryFile ??= findWasmBinary();
done(new WebAssembly.Instance(wasmModule, all), wasmModule);
return {};
}
wasmBinaryFile ??= findWasmBinary();
var file = wasmBinaryFile;
var slow = function () {
return fetch(file, { credentials: 'same-origin' })
.then(function (r) { return r.arrayBuffer(); })
.then(function (bytes) { return WebAssembly.instantiate(bytes, all); });
};
// A page's main thread may not compile or instantiate a module over
// 8 MB synchronously, so there the cold part is not left for a miss: it
// is instantiated now, asynchronously, before the module starts. (The
// pages that run Mesa there are demos and test harnesses; the desktop's
// compositor and SP's system Worker are Workers, and load it lazily.)
// `gexColdLazy` keeps it lazy anyway, for measuring a miss.
var eager = typeof ENVIRONMENT_IS_WEB !== 'undefined' && ENVIRONMENT_IS_WEB &&
!Module['gexColdLazy'];
WebAssembly.instantiateStreaming(fetch(file, { credentials: 'same-origin' }), all)
.catch(slow)
.then(function (r) {
if (!eager) return r;
var t0 = performance.now();
var cold = gexMesaColdFile();
var imp = { primary: r.instance.exports };
return WebAssembly.instantiateStreaming(fetch(cold, { credentials: 'same-origin' }), imp)
.catch(function () {
return fetch(cold, { credentials: 'same-origin' })
.then(function (x) { return x.arrayBuffer(); })
.then(function (bytes) { return WebAssembly.instantiate(bytes, imp); });
})
.then(function (c) {
gexMesaColdInstance = c.instance;
console.warn('[gex-wgpu] cold part loaded with the module (a page\'s main thread) in ' +
(performance.now() - t0).toFixed(1) + ' ms');
return r;
});
})
.then(function (r) { done(r.instance, r.module); },
function (e) { abort('instantiating Mesa failed: ' + e); });
return {};
};
}
if (gexMesaColdSplit === 'GEX_MESA_COLD_YES' && !Module['gexNoColdPrefetch'] &&
!(typeof ENVIRONMENT_IS_PTHREAD !== 'undefined' && ENVIRONMENT_IS_PTHREAD)) {
// Wait for the first presented frame, then fetch and compile in the
// background. Not in a pthread: the module's pool started each with this
// file, Mesa runs no code there, and every one of them fetched and
// compiled its own copy (four, on an mt build). A miss on a pthread would
// compile the cold part synchronously, which a Worker may do.
var gexColdPoll = setInterval(function () {
if (typeof _gex_gl_presented !== 'function' || typeof wasmBinaryFile !== 'string') return;
var presented = 0;
try { presented = _gex_gl_presented(); } catch (e) { return; }
if (!presented) return;
clearInterval(gexColdPoll);
if (gexMesaColdInstance) return;
var file = gexMesaColdFile();
var cache = gexMesaCold();
if (cache[file]) return;
cache[file] = 'pending';
var t0 = performance.now(), size = 0;
fetch(file).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.arrayBuffer();
}).then(function (bytes) {
size = bytes.byteLength;
return WebAssembly.compile(bytes);
}).then(function (module) {
if (!(cache[file] instanceof WebAssembly.Module)) cache[file] = module;
console.warn('[gex-wgpu] cold part prefetched (' + size + ' bytes) in ' +
(performance.now() - t0).toFixed(1) + ' ms');
}, function (e) {
if (cache[file] === 'pending') delete cache[file];
console.warn('[gex-wgpu] cold part prefetch failed: ' + e);
});
}, 1000);
}
+5839
View File
File diff suppressed because it is too large. Load diff
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""Split Mesa's replay module into what the page runs and a cold part.
web/mesa-split.py ORIG.wasm HOT.txt OUTDIR [--max-cold BYTES] [--cold REGEX]
ORIG.wasm is the link's plain module (gex-wgpu-replay.wasm.orig: the target
links with -sSPLIT_MODULE=1, so the .wasm beside it is wasm-split's
instrumented one and never shipped). HOT.txt lists the functions to keep in
the primary (web/mesa-hot.txt; docs/mesa-wasm-size.md says how it is made).
Writes OUTDIR/gex-wgpu-replay.wasm (names kept) and, when anything is left
over, OUTDIR/gex-wgpu-replay.deferred.wasm (names stripped). --max-cold
fails the split when the cold part is that big or bigger (default: no
limit; a Worker compiles it synchronously at any size, and a page's main
thread loads it with the module, web/gex-wgpu-split.js).
HOT.txt `-` keeps every function. --cold moves the functions whose names
match REGEX to the cold part whatever HOT.txt says: a forced miss, for
tests/mesa-cold.
Names are matched as Binaryen prints them, without ThinLTO's `.llvm.<hash>`
suffixes, which change whenever their translation unit does. A name in the
list that the module lacks is ignored; a function the list lacks is cold and
costs one load of the cold part the first time it runs.
"""
import os
import re
import shutil
import subprocess
import sys
import tempfile
LLVM_SUFFIX = re.compile(r'\.llvm\.\d+')
# Binaryen tells functions of the same name apart with a `_N` suffix whose N
# moves whenever the module gains or loses one, so a list is matched with
# those suffixes off too (a duplicate kept with its twin costs little).
DUP_SUFFIX = re.compile(r'_\d+$')
def stable(name):
return DUP_SUFFIX.sub('', LLVM_SUFFIX.sub('', name))
# The features the link used. The module carries no target_features section
# for Binaryen to detect them from, and `-all` is not a substitute: with it,
# wasm-split writes the cold part's imports in newer encodings (exact
# function imports, kind 0x20) that Chromium refuses to compile. Nor are
# reference types here, though the module would validate with them: they let
# wasm-split put the placeholders in a second table, and Emscripten's glue
# calls the function it loaded through the first (`wasmTable.get(index)`),
# which then holds something else at that index.
FEATURES = ['--mvp-features', '--enable-threads', '--enable-simd', '--enable-bulk-memory',
'--enable-bulk-memory-opt', '--enable-nontrapping-float-to-int', '--enable-sign-ext',
'--enable-mutable-globals', '--enable-multivalue']
def binaryen(tool):
"""Binaryen from the Emscripten that linked the module."""
try:
root = subprocess.run(['em-config', 'BINARYEN_ROOT'], capture_output=True, text=True,
check=True).stdout.strip()
path = os.path.join(root, 'bin', tool)
if os.path.exists(path):
return path
except (OSError, subprocess.CalledProcessError):
pass
return shutil.which(tool) or sys.exit(f'mesa-split: no {tool}')
# -- Trampolines ---------------------------------------------------------------
#
# wasm-split leaves each table slot that reaches a cold function holding an
# imported placeholder, and Emscripten's glue makes every placeholder a JS
# function that loads the cold part and then calls the function from JS. A
# JS frame in the middle of the stack is what JSPI cannot suspend across:
# the first call into a cold function that then waits on WebGPU (a glReadPixels,
# a buffer map) failed with "SuspendError: trying to suspend JS frames". So
# the placeholders are wasm instead: a trampoline per slot that calls the one
# import `gexcold.load` (web/gex-wgpu-split.js: loads the cold part, which
# overwrites the slots) and then calls through its own slot again, now the
# function itself, with no JS frame left on the stack.
PLACEHOLDER = 'placeholder.deferred'
TRAMP = 'gexcoldtramp'
VALTYPES = {0x7f: 'i32', 0x7e: 'i64', 0x7d: 'f32', 0x7c: 'f64', 0x7b: 'v128', 0x70: 'funcref',
0x6f: 'externref'}
def leb(b, i):
r = s = 0
while True:
x = b[i]
i += 1
r |= (x & 0x7f) << s
s += 7
if x < 0x80:
return r, i
def uleb(n):
out = bytearray()
while True:
x = n & 0x7f
n >>= 7
out.append(x | (0x80 if n else 0))
if not n:
return bytes(out)
def sections(b):
i, out = 8, []
while i < len(b):
sid = b[i]
size, j = leb(b, i + 1)
out.append((sid, i, j, j + size))
i = j + size
return out
def name(b, i):
n, i = leb(b, i)
return b[i:i + n].decode(), i + n
def limits(b, i):
flags = b[i]
lo, i = leb(b, i + 1)
hi = None
if flags & 1:
hi, i = leb(b, i)
return (lo, hi), i
def trampolines(primary, out_path, tmp):
"""Replace PRIMARY's placeholder imports with wasm trampolines, in place."""
b = open(primary, 'rb').read()
types, imports, table_export, imp_at = [], [], None, None
for sid, start, body, end in sections(b):
if sid == 1:
n, i = leb(b, body)
for _ in range(n):
assert b[i] == 0x60, 'mesa-split: a type other than a function type'
np, i = leb(b, i + 1)
params = [VALTYPES[x] for x in b[i:i + np]]
i += np
nr, i = leb(b, i)
results = [VALTYPES[x] for x in b[i:i + nr]]
i += nr
types.append((params, results))
elif sid == 2:
imp_at = (start, end)
n, i = leb(b, body)
for _ in range(n):
at = i
mod, i = name(b, i)
base, i = name(b, i)
kind = b[i]
i += 1
if kind == 0:
t, i = leb(b, i)
elif kind == 1:
i += 1
_, i = limits(b, i)
t = None
elif kind == 2:
_, i = limits(b, i)
t = None
elif kind == 3:
i += 2
t = None
else:
sys.exit(f'mesa-split: import kind {kind}')
imports.append((mod, base, kind, t, b[at:i]))
elif sid == 7:
n, i = leb(b, body)
for _ in range(n):
ex, i = name(b, i)
kind = b[i]
idx, i = leb(b, i + 1)
if kind == 1 and idx == 0:
table_export = ex
holders = [(int(base), t) for mod, base, kind, t, _ in imports if mod == PLACEHOLDER]
if not holders:
return 0
if table_export is None:
sys.exit('mesa-split: the primary exports no table')
# The primary, its placeholders imported from the trampolines' module.
entries = bytearray()
for mod, base, kind, t, raw in imports:
if mod == PLACEHOLDER:
m, nm = TRAMP.encode(), f'{TRAMP}_{base}'.encode()
raw = uleb(len(m)) + m + uleb(len(nm)) + nm + bytes([0]) + uleb(t)
entries += raw
body = uleb(len(imports)) + entries
start, end = imp_at
renamed = os.path.join(tmp, 'renamed.wasm')
with open(renamed, 'wb') as f:
f.write(b[:start] + bytes([2]) + uleb(len(body)) + body + b[end:])
# The trampolines.
wat = [f'(module (import "gexcold" "load" (func $load))',
f' (import "primary" "{table_export}" (table $t 1 funcref))']
for k, t in holders:
params, results = types[t]
sig = ''.join(f' (param {p})' for p in params) + ''.join(f' (result {r})' for r in results)
gets = ''.join(f' (local.get {i})' for i in range(len(params)))
wat.append(f' (func (export "{TRAMP}_{k}"){sig} (call $load)'
f' (call_indirect $t{sig}{gets} (i32.const {k})))')
wat.append(')')
wat_path = os.path.join(tmp, 'tramp.wat')
tramp = os.path.join(tmp, 'tramp.wasm')
with open(wat_path, 'w') as f:
f.write('\n'.join(wat))
subprocess.run([binaryen('wasm-as'), *FEATURES, wat_path, '-o', tramp], check=True)
merged = os.path.join(tmp, 'merged.wasm')
subprocess.run([binaryen('wasm-merge'), *FEATURES, renamed, 'primary', tramp, TRAMP, '-g',
'-o', merged], check=True)
subprocess.run([binaryen('wasm-opt'), *FEATURES, merged, '-g', '--remove-exports',
'--pass-arg', f'remove-exports@{TRAMP}_*', '-o', out_path], check=True)
return len(holders)
def main():
args = sys.argv[1:]
max_cold = None
cold_re = None
if '--cold' in args:
i = args.index('--cold')
cold_re = re.compile(args[i + 1])
del args[i:i + 2]
if '--max-cold' in args:
i = args.index('--max-cold')
max_cold = int(args[i + 1])
del args[i:i + 2]
if len(args) != 3:
sys.exit(__doc__)
orig, hot_path, out = args
os.makedirs(out, exist_ok=True)
primary = os.path.join(out, 'gex-wgpu-replay.wasm')
cold = os.path.join(out, 'gex-wgpu-replay.deferred.wasm')
if os.path.exists(cold):
os.remove(cold)
if hot_path != '-' and not os.path.exists(hot_path):
shutil.copyfile(orig, primary)
print(f'mesa-split: no {hot_path}; not split')
return
hot = None if hot_path == '-' else {l.strip() for l in open(hot_path) if l.strip() and not l.startswith('#')}
fmap = subprocess.run([binaryen('wasm-opt'), *FEATURES, orig, '--print-function-map', '-o', os.devnull],
capture_output=True, text=True, check=True).stdout
# `index:name`, imports included (wasm-split never moves those).
names = [l.split(':', 1)[1] for l in fmap.splitlines() if ':' in l]
if hot is not None:
hot = {stable(n) for n in hot}
keep = [n for n in names if (hot is None or stable(n) in hot)
and not (cold_re and cold_re.search(n))]
with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False) as f:
f.write(''.join(n + '\n' for n in keep))
keep_file = f.name
with tempfile.TemporaryDirectory() as tmp:
raw_cold = os.path.join(tmp, 'cold.wasm')
raw_primary = os.path.join(tmp, 'primary.wasm')
subprocess.run([binaryen('wasm-split'), orig, *FEATURES, '--split', '-g', '-q',
'--keep-funcs', '@' + keep_file, '-o1', raw_primary, '-o2', raw_cold],
check=True)
slots = trampolines(raw_primary, primary, tmp)
if not slots:
shutil.copyfile(raw_primary, primary)
subprocess.run([binaryen('wasm-opt'), *FEATURES, raw_cold, '--strip-debug', '-o', cold], check=True)
os.remove(keep_file)
size = os.path.getsize(cold)
print(f'mesa-split: {len(keep)} of {len(names)} functions kept; '
f'{os.path.getsize(primary)} bytes + {size} cold; {slots} trampolines')
if hot is not None:
gone = hot - {stable(n) for n in names}
if gone:
# A listed function this build no longer has under that name (a
# rename leaves the new one cold): re-run tools/mesa-hot.py.
print(f'mesa-split: {len(gone)} listed names not in this build, e.g. {sorted(gone)[0][:120]}')
if max_cold is not None and size >= max_cold:
sys.exit(f'mesa-split: the cold part is {size} bytes, not under {max_cold}')
if __name__ == '__main__':
main()
File diff suppressed because it is too large. Load diff