everything-webgpu / src /engine /engine-worker.js
nyaaorick's picture
feat: publish everything-webgpu package, engine source and documentation
1944112 verified
Raw
History Blame Contribute Delete
6.13 kB
/**
* 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);
};