/** * One pool slot's engine, in its own JS realm. * * Several MLCEngines cannot share a realm. Isolated by running the same e2e * three ways against Qwen3.5-0.8B: * * 1 engine, background page -> passes * 2 engines, background page -> both load, the first generates * fine, the second's first * generation fails with * "Expected null or instance of * VectorInt, got an instance of * VectorInt" * 2 engines, one worker each -> passes * * So the trigger is a second engine generating in the same realm, not the pool * or the engine count as such. That error is embind reporting a type-registry * mismatch, and the bundle does carry module-scoped emscripten state * (`var Module`, `var __wasmLib`) shared by every instance, which fits — but * the fix rests on the isolation above, not on having traced the registry. * * Workers are viable because Firefox exposes WebGPU to dedicated workers and * the 100 ms completion tick is shared across them, so the concurrency win * survives the move off the main thread (measured: 4 workers, 36.3 syncs/s). * * The realm is also where the decode loop lives, so it is where multi-step * decoding is installed — the background page only ever holds a proxy. */ import { WebWorkerMLCEngineHandler } from "../../vendor/web-llm.js"; import { WORKER_CONFIGURE } from "./constants.js"; import { DEFAULT_DECODE_STEPS, installMultiStepDecoding } from "./multistep.js"; const handler = new WebWorkerMLCEngineHandler(); /** * Running totals for the decode probe (AI.md, "Where the 46 ms goes"). * * `encodeMs` is content-process CPU — command encoding, `createBindGroup`, IPC. * `syncMs` is GPU execution plus the wait for Firefox's 100 ms poll tick. They * are measured on either side of the burst's single `await`, so together they * partition the decode budget with nothing unaccounted for. */ const stats = { bursts: 0, tokens: 0, encodeMs: 0, syncMs: 0, dispatches: 0, forwardDispatches: 0, flushes: 0, }; const resetStats = () => Object.keys(stats).forEach((k) => (stats[k] = 0)); /** * Set when a pipeline fails the multi-step contract, and never cleared — a * retune resets the measurement window, not the fact that the fast path is off. */ let multiStepOff = null; // No `kind` field: WebLLM's client handler ignores messages it cannot classify // instead of throwing UnknownMessageKindError. const postStats = () => postMessage({ ewgpuStats: { ...stats, steps: multiStep.steps, multiStepOff } }); const multiStep = installMultiStepDecoding(handler.engine, { steps: DEFAULT_DECODE_STEPS, onBurst: (b) => { stats.bursts += 1; stats.tokens += b.tokens; stats.encodeMs += b.encodeMs ?? 0; stats.syncMs += b.syncMs ?? 0; stats.dispatches += b.dispatches ?? 0; stats.forwardDispatches += b.forwardDispatches ?? 0; stats.flushes += b.flushes ?? 0; postStats(); }, // The only message that can ever report this. When the fast path is off there // are no bursts, so `onBurst` never fires and the decode probe simply stops // arriving — indistinguishable, from the host's side, from an idle engine. onFallback: ({ missing }) => { multiStepOff = missing; postStats(); }, }); /** * Force a full re-prefill instead of reusing the KV cache across rounds. * * Multi-round reuse routes attention through `batch_prefill_paged_kv_kernel`, * which binds 10 storage buffers: q, pages, lse, output and six small i32 * metadata arrays. Firefox's Metal backend caps `maxStorageBuffersPerShaderStage` * at 9, so that pipeline fails to build — and an invalid WebGPU pipeline is * silent, its dispatches becoming no-ops. The symptom is a second turn that * answers the *previous* question behind a garbage prefix that differs run to * run, which is uninitialised memory being read. * * Resetting the conversation first makes WebLLM's own conversation comparison * fail, so it re-prefills from scratch through `batch_prefill_ragged_kv_kernel` * (9 bindings, works). The cost is re-reading the history each turn; prefill is * one sync per chunk, so it is far cheaper than the garbage it replaces. * * Conditional on the limit, not on the browser: a device that allows 10 keeps * the KV cache and the faster path. */ /** Bindings `batch_prefill_paged_kv_kernel` needs; see tools/audit-wasm.mjs. */ const PAGED_PREFILL_STORAGE_BUFFERS = 10; /** Whether this device is too tight to build that pipeline. Probed once. */ const kvReuseUnsafe = (async () => { const adapter = await navigator.gpu?.requestAdapter().catch(() => null); const limit = adapter?.limits?.maxStorageBuffersPerShaderStage ?? 0; const unsafe = limit < PAGED_PREFILL_STORAGE_BUFFERS; if (unsafe) { console.info( `[everything-webgpu] KV reuse disabled: paged prefill needs ` + `${PAGED_PREFILL_STORAGE_BUFFERS} storage buffers, this device allows ${limit}`, ); } return unsafe; })(); // Wrapped synchronously at module load: deciding first and wrapping after the // await would leave a window where an early prefill slips through unguarded. const basePrefill = handler.engine.prefill.bind(handler.engine); handler.engine.prefill = async (input, pipeline, chatConfig, genConfig) => { if (await kvReuseUnsafe) pipeline.resetChat(/* keepStats= */ true); return basePrefill(input, pipeline, chatConfig, genConfig); }; // The engine is reachable before any model is loaded, so the host can set the // step count on the very first message and never has to reload to change it. self.onmessage = (msg) => { if (msg.data?.kind === WORKER_CONFIGURE) { if (msg.data.decodeSteps !== undefined) multiStep.setSteps(msg.data.decodeSteps); // Every retune starts a fresh measurement window, so a sweep's points never // bleed into each other. resetStats(); postStats(); return; } handler.onmessage(msg); };