Merge 2ddb25ce into integ

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01PeRutwFgZ66XpxjasKrPn1

# Conflicts:
#	shims/browser/js/worker.mjs
#	web/src/runtime.js
This commit is contained in:
velzie committed 2026-10-05 14:20:03 -04:00
commit b7fead079d
8 files changed
+135 -6

No files matched your search

+4 -2
View File
@@ -568,7 +568,7 @@ pub fn build(dir: &Path, project: &Project, config: &Config) -> Result<PeReport>
// The web build's units, when the project has a browser runtime.
if dir.join(WEB_NT_DIR).join(WEB_NT_WASM[0]).exists() {
let stateful: Vec<(String, PathBuf)> = config.providers.iter().map(|p| (p.library.clone(), provider_wasm(dir, p))).collect();
build_web(dir, &ntss, project, &units, &web_inputs, &stateful, class32, jobs, &mut report)?;
build_web(dir, &ntss, project, config.args.as_deref(), &units, &web_inputs, &stateful, class32, jobs, &mut report)?;
std::env::set_var("GEX_CGEN_CACHE", &units);
}
let manifest = BuildManifest {
@@ -616,6 +616,8 @@ fn build_web(
dir: &Path,
ntss: &NtSession,
project: &Project,
// user/eject.json's "args", over the recorded ones (as `eject run`).
args: Option<&[String]>,
native: &Path,
inputs: &[(String, u64, Vec<u8>, Vec<u64>)],
stateful: &[(String, PathBuf)],
@@ -734,7 +736,7 @@ fn build_web(
("exe", s(&ntss.exe)),
("prefix", s(&ntss.prefix)),
("cwd", s(project.program.cwd.as_deref().unwrap_or("/"))),
("args", strs(&ntss.args)),
("args", strs(args.unwrap_or(&ntss.args))),
("env", strs(&ntss.env)),
("settings", strs(&settings)),
("gl", b(ntss.gl)),
+66
View File
@@ -831,6 +831,72 @@ and the compositor. Paths: project root `~/gex-sandbox`, scratch
multi-threaded loading. Then the compositor items only if a profile
puts the worker back on the critical path.
- **Post-merge check (site19, master 5ab54ab2 merged, keys unchanged: 38
units present, 0 built; p-noita17's user/eject.json now has `"args": []`,
which the web build honours since this commit's sibling):** ABBA on edger
ejected 57.2, 58.6 vs pewasm 46.0, 45.0 = **1.27x**, world-up 49 s after
the process start. Multi-threaded loading is the project's setting
(user/eject.json `args`), not an eject-site default: eject-site is
generic, and `--args` stays the override.
- Start-up profiles from the process start for both runtimes:
`$S/pw-prof-start.sh TAG AFTER SECS` (pewasm, F's browser-prof.mjs) and
`prof-edger.sh SITE TAG 1 60` (page-run now profiles Workers that start
during the window too).
- **Queued (user decision, after the start-up work): no WebGL in the
browser.** Port the Wayland compositor (web/src/compositor-worker.js, and
dom-compositor.js if still live) from WebGL2 to WebGPU on the GPUDevice
Mesa uses: GL windows become textures the compositor samples directly
(no cross-API copy, no second context, no webgl2 start-up retry, no
overlay canvas), wl_shm rows uploaded with queue.writeTexture from guest
memory; keep the frame-skip semantics (no present when nothing changed).
web/src/gl.js and the demos' WebGL path are being removed on master by
the coordinator.
- **Start-up profile from the process start (site19, prof-gst):** every
guest thread's Worker spent 2.5-3.7 s in `loadFile` (synchronous
requests through the service worker) plus 0.5-1.5 s compiling modules:
`loadUnit` fetched each unit's c.wasm (131 MiB for noita.exe) only to
read its entries, though the Module came from a sibling. Fix 065139bb:
the bytes are fetched lazily, only by a Worker that compiles. A/B
site19/site20 queued (`$S/edger-wab3.txt`).
- **Start-up: threads recompiled every unit (fixed, 35578368).** A
per-second timeline of the start-up profile (`$S/profline.mjs`,
`$S/profimg.mjs`) showed Noita's loader threads starting one after the
other, each busy ~3 s while the rest (main included) waited in the ConcRT
condvar provider: each new thread's Worker fetched and compiled all 39
units (352 `[unit]` lines in one run). host.mjs's thread start path
never sent the process's compiled modules (the `start` closure that did
is dead code). With them: 44 unit compiles per run. **World-up after
navigation (edger, A/B x3): site19 50.6-51.6 s -> site21 36.5-40.6 s**;
pewasm 30.2 s. ABBA (site21): ejected 56.8, 56.8 vs pewasm 48.3, 48.0 =
1.18x; world-up 35-36 s after the process start. The lazy c.wasm fetch
(065139bb) alone measured nothing; it matters now that threads have the
Modules (no bytes fetched at all).
- pewasm's own start-up timeline (`$S/wup-pewasm2.sh`, timestamped
console): process ~4.3 s, first surface 4.7 s, its input frames 60/120/180
at 10.6/11.0/11.3 s, world 29.2 s: about 6 s of init and 18 s of world
generation. Ours before the fix: init ~20 s (the serial thread starts),
world generation ~16 s.
- **Unit preload (a2… see log, eject page `?preload=`):** the page
compiles every unit with `compileStreaming` while the bundle downloads
and hands the Modules to every Worker (`preModules`); no Worker fetches
or compiles a unit (0 `[unit]` lines). World-up A/B x3: site21 37.5-38.5
s -> site22 35.6-38.8 s (mean 38.2 -> 36.7). pewasm 30.2 s.
- **Where the remaining ~6.5 s is (site21, per-second timeline, process
time):** boot 0-5 s on the main thread (now mostly gone with the
preload: 40% loadFile, 10% compile; 17% gex_key sha256 remains);
shader translation in the compositor worker (Tint SPIR-V->WGSL) at
9-12 s and 40-43 s, during which Noita's main thread waits on GL
(~3 s each), so the menus run at ~40 fps where pewasm's run at ~170
(its clicks at frames 60/120/180 land within 0.7 s, ours within ~3 s);
world generation 19-40 s on one thread, guest code plus spin-yields
(~19k NtYieldExecution per 10 s; 0x1d80 guest 7.6-9.9 s of 10), against
~18 s in pewasm. Levers: a persistent SPIR-V->WGSL cache (IndexedDB,
helps every load after the first), the shader translation off the
critical path, and world generation (F, codegen).
- Harness: `$S/play.mjs` = page-run.mjs + `--script FILE` (timed clicks,
keys, holds, screenshots, evals, `persist` snapshots and reloads through
CDP input, as a person would).
+1 -1
View File
@@ -2421,7 +2421,7 @@ export class Gex {
if (mod) return mod;
const tl = performance.now();
got ??= this.#env.loadUnit?.(key) ?? null;
if (!got) throw new Error(`gex-cgen: unit ${key} was published but cannot be loaded here`);
if (!got?.wasm) throw new Error(`gex-cgen: unit ${key} was published but cannot be loaded here`);
const t0 = performance.now();
mod = got.wasm instanceof WebAssembly.Module ? got.wasm : new WebAssembly.Module(got.wasm);
this.#compileMs += performance.now() - t0;
+16 -3
View File
@@ -348,9 +348,21 @@ async function handle(msg) {
// with a synchronous request -- allowed in a Worker, and what lets the
// nested callback loop install one too. Null when there is none.
loadUnit(key) {
const wasm = this.loadFile(`${key}/c.wasm`);
const entries = wasm && this.loadFile(`${key}/c.entries`);
return wasm && entries ? { wasm, entries } : null;
// The entries now; the module's bytes only if this Worker compiles it
// (most get the Module from a sibling: see `Gex#cgenModule`). Fetching
// every unit's bytes in every thread's Worker, only to use a shared
// Module, cost each new guest thread seconds at start-up.
const entries = this.loadFile(`${key}/c.entries`);
if (!entries) return null;
const env = this;
let wasm;
return {
entries,
get wasm() {
wasm ??= env.loadFile(`${key}/c.wasm`);
return wasm;
},
};
}
// Any file of the unit cache by its relative path (an export
// provider's module: `providers/<name>-<fingerprint>.wasm`), or null.
@@ -414,6 +426,7 @@ async function handle(msg) {
if (msg.cgen?.mode) gex.useCgen(msg.cgen.mode);
// Any sibling modules the host already had when this Worker started.
for (const [key, mod] of msg.sharedModules ?? []) gex.addSharedModule(key, mod);
for (const [key, mod] of msg.preModules ?? []) gex.addSharedModule(key, mod);
if (globalThis.GEX_SHARE_TRACE || msg.shareTrace) console.warn(`[share] ${msg.kind} start with ${(msg.sharedModules ?? []).length} shared modules (${(msg.sharedModules ?? []).filter(([k]) => String(k).startsWith('cgen:')).length} units)`);
// The GL device (gles-gears): the page's GlDevice, reached over a ring in a
+10
View File
@@ -83,6 +83,7 @@ walk(join(project, 'runtime/web/page'), (p, rel) => place(p, rel));
const cgen = nt.cgen;
const units = join(project, cgen);
let unitBytes = 0;
const preload = [];
// Only this build's units: a unit cache keeps every key it ever built
// (a re-keyed rebuild leaves the old ones). build/build.json names the
// images' units; the NT trampolines' unit is not an image, and is kept when
@@ -108,8 +109,17 @@ walk(units, (p, rel) => {
if (!want) return;
unitBytes += statSync(p).size;
place(p, join('project', cgen, rel));
// What the page compiles while the bundle downloads (nt.html ?preload=):
// module names as gex.mjs keys them.
const url = `/project/${cgen}/${rel}`;
if (name === 'c.wasm') preload.push([`cgen:${rel.slice(0, -'/c.wasm'.length)}`, url]);
else if (top === 'providers') preload.push([`cgenfile:${rel}`, url]);
});
// Largest first: noita.exe's unit is most of the compile.
preload.sort((a, b) => (files[b[1]]?.size ?? 0) - (files[a[1]]?.size ?? 0));
writeFileSync(join(out, 'project', cgen, 'preload.json'), JSON.stringify(preload));
// The bundle, without what --drop names, with --overlay DIR's files (guest
// paths under DIR, as user/files) laid over it.
const drops = opts('--drop');
+2
View File
@@ -77,6 +77,8 @@
cgenurl: `/project/${m.cgen}`,
size: q.get('size') ?? m.size ?? fit,
aot: '1',
// Compile the units while the bundle unpacks (eject-site's list).
preload: `/project/${m.cgen}/preload.json`,
});
if (m.cwd && m.cwd !== '/') p.set('cwd', m.cwd);
if (m.unguarded) p.set('unguarded', '1');
+31
View File
@@ -72,6 +72,31 @@ async function fetchShared(url) {
return out;
}
/** Compile the units a preload list names, a few at a time, streaming from
* the network (or the service worker's cache); null if the list is gone. */
async function preloadUnits(url) {
let list;
try {
list = await (await fetch(url)).json();
} catch {
return null;
}
const out = [];
let i = 0;
const one = async () => {
while (i < list.length) {
const [name, href] = list[i++];
try {
out.push([name, await WebAssembly.compileStreaming(fetch(href))]);
} catch (e) {
console.warn(`[nt-page] preload ${href}: ${e?.message ?? e}`);
}
}
};
await Promise.all([one(), one(), one(), one()]);
return out;
}
async function main() {
// `?trapstack=1`: a trap's wasm frames, which name the shim function it
// was in (runtime.js passes the switch to every Worker).
@@ -96,6 +121,9 @@ async function main() {
canvas.style.height = `${h}px`;
state.phase = 'fetching';
mark('fetching');
// `preload=URL`: the units to compile while the bundle downloads (a JSON
// list of [module name, url]); every Worker then starts with them.
const preloading = q.get('preload') ? preloadUnits(q.get('preload')) : Promise.resolve(null);
// `tar=a.tar,b.tar`: bundles laid over one another, later ones winning --
// a small test program over a large cached bundle of its libraries.
const trees = [];
@@ -142,6 +170,8 @@ async function main() {
if (!env.some((e) => e.startsWith('GEX_WINE_PREFIX='))) env.push('GEX_WINE_PREFIX=/wine');
if (!env.some((e) => e.startsWith('GEX_NT_STRICT='))) env.push('GEX_NT_STRICT=1');
const gl = q.get('gl') === '1';
const preModules = await preloading;
if (preModules) mark(`compiled ${preModules.length} unit modules ahead`);
state.phase = 'running';
mark('running');
if (persistDir) {
@@ -167,6 +197,7 @@ async function main() {
wl: canvas,
wlInput: canvas,
hostX: q.get('hostx') === '1',
preModules,
});
state.status = status;
state.phase = 'done';
+5
View File
@@ -440,6 +440,10 @@ export async function runProgram(
// process of the same program starts at its own entry with every
// relocation already applied.
link = Number(globalThis.GEX_LINK ?? 0),
// Function-tier modules compiled ahead of the run (an ejected project's
// page compiles its units while the bundle downloads), as [name, Module]
// pairs every Worker takes instead of fetching and compiling its own.
preModules = null,
},
) {
const { shimBytes, seedBytes } = await loadRuntime();
@@ -661,6 +665,7 @@ export async function runProgram(
traceMisses: !!globalThis.GEX_TRACE_MISSES,
// `?sharetrace=1`: say how compiled modules are shared between a process's Workers.
shareTrace: flag('sharetrace') ? 1 : undefined,
preModules: preModules ?? undefined,
// `?forkskip=N`, in `extra` because the process that acts on it is a
// forked child, not the page's own.
forkSkip: typeof globalThis.GEX_FORK_SKIP_MIB === 'number'