- everything-webgpu
everything-webgpu
Run local MLC/WebLLM models on WebGPU, in a browser, with a scheduler in front of them.
Migrating off @mlc-ai/web-llm is one line.
-import { CreateMLCEngine } from "@mlc-ai/web-llm";
-const engine = await CreateMLCEngine(modelId, { initProgressCallback });
+import { CreateScheduledEngine } from "everything-webgpu";
+const engine = await CreateScheduledEngine(modelId, { initProgressCallback });
Everything after that line is unchanged. engine.chat.completions.create() takes and returns the
same shapes, streams the same chunk objects, and reports the same finish reasons. That call is the
compatibility layer and it never changes β see Stability.
What the swap buys:
| A scheduler | session, priority, task, preemptible on any request. One engine per task, pool grows on demand, stale requests supersede themselves. |
| Multi-step decoding | K forward steps per GPU sync. On the 0.8B baseline: 9.6 β 25.9 tok/s. |
| Two build-time patches | Firefox's 9-storage-buffer cap, and one compute pass per flush instead of per kernel. |
| Three model sources | A prebuilt id, a URL you host, or a folder off disk with no network at any point. |
| Typed errors | Eight codes, each one because a caller does something different about it. |
Status
On npm β npm i everything-webgpu. Pre-1.0, ESM only ("type": "module").
npm i everything-webgpu
vendor/web-llm.js β the patched WebLLM bundle the engine loads β is a build product and is not
checked in, so every install route has to produce it. The published tarball ships it prebuilt;
prepare regenerates it for a git dependency or a clone. If it is ever missing you get
PACKAGE_INCOMPLETE naming the fix, rather than a module-resolution error pointing inside this
package.
Requires WebGPU. Verified end to end on Firefox 154 / macOS / M4; on Firefox you may need to set
dom.webgpu.enabled and the related flags first β AI.md lists them and says what each one
does. Chrome is expected to be faster (it does not hit the storage-buffer cap that disables KV
reuse) but that is a prediction, not yet a measurement.
Quick start β four lines
import { CreateScheduledEngine } from "everything-webgpu"; // 1. import
const engine = await CreateScheduledEngine("Llama-3.2-1B-Instruct-q4f16_1-MLC"); // 2. load a model
const reply = await engine.ask("Name three primary colours."); // 3. ask
console.log(reply); // 4. the answer
reply is a plain string. Line 2 downloads ~0.8 GB the first time and prints throttled progress to
the console; pass initProgressCallback to render it yourself, or initProgressCallback: null to
silence it. On Vite, add one plugin β see API.md Β§ Bundlers. Every other call
form β conversation(), load() from a URL or off disk, streaming, embeddings, the OpenAI shape β
is catalogued in API.md.
The WebLLM-compatible shape, unchanged:
const reply = await engine.chat.completions.create({
messages: [{ role: "user", content: "hi" }],
session: "ghost-text", // added by this engine
priority: "interactive", // added by this engine
});
console.log(reply.choices[0].message.content);
Streaming is WebLLM's, passed through untouched:
const stream = await engine.chat.completions.create({ messages, stream: true });
for await (const chunk of stream) process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
The first load() downloads the weights; after that it is cache-only and needs no network.
Runnable
examples/ has three, each a standalone project that consumes this package through its
exports map rather than reaching into the source tree:
cd examples/bare && npm install && npm run dev
| bare/ | Vanilla Vite, one page. ~100 lines. |
| react/ | A chat UI over conversation(). |
| webext/ | MV2 Firefox β browser.storage.local as the store, and the CSP a wasm runtime needs. |
What it costs your bundle
53 kB (~19 kB gzip) in the entry chunk, before a model is loaded. WebLLM's 6 MB bundle is a
lazy chunk β fetched on the first load() or listAvailableModels(), and never by a visitor
who does neither, so a page can decide whether this machine has a GPU before paying for it. The
IndexedDB adapter is another 0.8 kB lazy chunk, absent entirely if you pass your own store.
On Vite, add the plugin this package ships:
import { everythingWebGPU } from "everything-webgpu/vite";
export default defineConfig({ plugins: [everythingWebGPU()] });
It exists for one reason: Vite's dependency pre-bundler otherwise rewrites the
decode worker's URL to a path that 404s β in vite dev only, and only on a real
install, which is why no example in this repo can catch it and
npm run verify-consumer exists instead. Skip it and load() fails with
PACKAGE_INCOMPLETE naming the fix, rather than hanging.
Three ready-made shapes
Most apps want one of three things. Each is one call, with the scheduling already right:
// 1. One question, nothing kept.
const answer = await engine.ask("Summarise this in one line:\n" + doc);
// 2. A conversation that remembers.
const chat = engine.conversation({ system: "You are terse." });
await chat.say("what's the capital of France?");
await chat.say("and its population?"); // remembers
// 3. Ghost text, debounced and superseding.
const ghost = engine.ghostText({ prompt: (before) => `Continue:\n${before}` });
editor.on("input", async () => {
const hint = await ghost.suggest(editor.textBefore());
if (hint !== null) render(hint); // null = a newer keystroke won
});
editor.on("blur", () => ghost.cancel());
complete() can express all three. These exist because the scheduling is the part that's easy
to get wrong and invisible when you do β every row of Getting these wrong is
a scheduling mistake, not a generation one. So each verb is a policy:
| policy | |
|---|---|
ask() |
its own task, no session β two in flight never supersede each other |
conversation() |
one stable task for every turn, turns serialised, history bounded |
ghostText() |
debounce + one session key + interactive + resolves null when stale |
That last one is the difference between a verb and a wrapper. The engine already drops superseded
work; what a caller still had to remember was not to paint the answer that came back anyway.
Returning null removes the choice.
They author no prompts. ask() and conversation() carry your text through; ghostText()
requires a prompt function and has no default. Prompts are model-specific β switching this
project's own build from a 0.8B to a 2B changed the conversation template and made every reply open
with a <think> block. A prompt that lives in your code survives that.
conversation() bounds history at 12 exchanges by default, because there is no cross-turn KV reuse
here: every turn re-prefills the whole history at ~5.27 ms/token, so an unbounded conversation gets
quadratically slower and a turn near the context limit waits ~22 s for its first token. Pass
keep: Infinity to opt out, having read that sentence.
Where models come from
One call covers all three. load() works out what you handed it, registers whatever needs
registering, and brings the model up:
// 1. Prebuilt β one of WebLLM's 163 HuggingFace models, or anything you registered earlier.
await engine.load("Llama-3.2-1B-Instruct-q4f16_1-MLC");
// 2. Remote β any base URL you host. `modelLib` is required; see below.
await engine.load("https://cdn.example/models/my-model/", {
modelLib: "https://cdn.example/models/my-model/my-model-webgpu.wasm",
});
await engine.load({ model: "/models/my-model/", modelLib: "/models/my-model/lib.wasm" });
// 3. Injected β a folder off disk. No network at any point.
await engine.load(dropEvent.dataTransfer); // a drop
await engine.load(input.files); // <input webkitdirectory>
await engine.load({ files }); // already-unpacked { path, file }[]
The model id is derived from the URL's last segment; pass { id } to override it. Add
{ defer: true } to register a source without building a pool β it returns the registry record,
for a drop-now-load-later flow. registerModel() and ingestModelFolder() remain as the low-level
primitives; load() composes them rather than replacing them.
listAvailableModels() enumerates all three routes. Construct the engine with { prebuilt: false }
for a build that must never fetch a model over the network. A misspelled id is told what it might
have meant.
Warming the cache early
await engine.prefetch(modelId, { onProgress: (p) => bar(p.done / p.total) });
Downloads the model without building an engine and without WebGPU β so an app can warm the cache
while the user is still reading the welcome screen, before it has decided whether this machine can
run the model at all. WebLLM cannot express this: reload() instantiates the wasm and needs a GPU
before it fetches a single shard. A later load() is then a cache read. Interrupted downloads
resume, and a second call is free.
Embeddings
await engine.load("snowflake-arctic-embed-s-q0f32-MLC", { keepResident: true });
const [q] = await engine.embed("how do I cancel a job?");
const docs = await engine.embed(paragraphs); // one vector per input, in order
Embedding models are separate models β WebLLM ships four snowflake-arctic-embed-* from 239 MB β so
this usually names modelId and holds it resident alongside a chat model. Embeddings go through the
same scheduler as completions: same priority bands, same session supersession, same
one-task-one-engine rule. embedRaw() returns WebLLM's OpenAI-shaped envelope if you are porting
code that expects data[].embedding.
One difference worth knowing: a running embedding cannot be interrupted. Cancellation works by
making a decode loop break out, and one forward pass has no loop β so a cancel() landing after the
job starts marks it cancelled without stopping it. Queued embeddings supersede normally.
modelLib is required for a remote source and is not guessed. It is genuinely underivable:
across all 163 prebuilt models, zero have a lib name derivable from the model id, and zero host the
lib on the same origin as the weights. A guess would be wrong every time and would surface as a
confusing 404 deep inside the loader, so the engine asks instead.
The three shapes of work
What differs between these is not the call or the transport. It is who owns an engine, and what may interrupt what.
| call | priority | key field | why | |
|---|---|---|---|---|
| Completion (ghost text) | complete streaming |
interactive |
session |
Each keystroke supersedes the last request; may preempt opted-in work. |
| Translation (a page) | batch |
normal |
one shared task |
One request instead of N, so the engine schedules it as a unit and it never hogs the pool. |
| Reformat (markdown) | complete |
background |
preemptible: true |
Nobody is watching; let interactive work cut in. |
Completion β latency is the whole product
// On every keystroke. The previous request is cancelled, not queued behind.
const { text } = await engine.complete(
{
messages: [{ role: "user", content: prefix }],
session: "ghost-text", // supersession key β the important field
priority: "interactive", // may preempt jobs that opted in
max_tokens: 24, // ghost text is short; do not pay for more
},
(delta) => render(delta),
);
session is what makes this work, not cancel. Reusing one session key means the engine drops
the stale request itself. A caller that mints a fresh id per keystroke and calls cancel races its
own typing.
Translation β throughput, one task
// One batch, not a loop of `complete` calls.
const results = await engine.batch({
task: "translate-page", // optional; a batch is one task either way
requests: sentences.map((s) => ({
messages: [{ role: "user", content: `Translate to French, output only the translation:\n${s}` }],
})),
});
results.forEach((r) => apply(r.index, r.text));
Every item of one batch shares a task, and a task holds one engine, so a 200-sentence page occupies
exactly one engine and can never freeze ghost-text behind it. Items carry engineIndex, startedAt
and finishedAt, so you can check what actually ran where.
batch stays the right call rather than a loop: it is one scheduling unit the engine can reason
about, and when batched decode lands the same call gets faster with no change on your side.
Reformat β cheap to interrupt
await engine.complete({
messages: [{ role: "user", content: `Reformat as clean Markdown, no commentary:\n\n${doc}` }],
priority: "background",
preemptible: true, // the direction matters β see below
max_tokens: 2048,
});
Set preemptible on the work that can afford to lose, not on the work you care about. Only an
interactive request preempts, and only a job that opted in can be preempted. A preempted job
resolves with preempted: true and whatever text it had β never requeued, so it can never starve,
but you must be able to use or discard a partial result.
Getting these wrong
| symptom | cause |
|---|---|
| Ghost text lags behind typing | Fresh id per keystroke with no session, so every stale request still runs. |
| Page translation is slower than expected | Expected: one task is one engine, and a second engine measured 1.06x anyway. Throughput here comes from batched decode, not from more engines. |
| Reformatting blocks completions | preemptible left off the background job, so interactive has nothing to take. |
| Pool stays at one engine | Expected: it grows only when a second task waits. Check engine.state.pool.growthBlocked if two are waiting and it still has not. |
Prompts stay with you
The engine does not author prompts. They are model-specific β switching one build from
Qwen3.5-0.8B to Qwen3.8-2B-Distill changed the conversation template and made every reply open
with a <think> block. A prompt that lives in the caller survives that; a translate op baked into
the engine would have to be rewritten and re-shipped to every caller.
When it does not work
Every failure carries a code. message stays human-readable and stays the thing you print; detail
carries structured context so you never parse the sentence.
import { isEngineError, ERROR } from "everything-webgpu";
try {
await engine.load(id);
} catch (err) {
if (isEngineError(err, ERROR.CACHE_INCOMPLETE)) return reRegisterFolder();
throw err;
}
| code | what to do about it |
|---|---|
NO_WEBGPU |
Tell the user to check flags/hardware; retrying is futile. |
NO_MODEL |
Nothing registered at all β send them to your setup flow. |
UNKNOWN_MODEL |
That id is not resolvable; listAvailableModels() says what is. |
CACHE_INCOMPLETE |
A locally-registered model was evicted; re-register the folder. |
INVALID_MODEL_FOLDER |
Not a compiled MLC model; detail says what is missing. |
BAD_REQUEST |
The caller's arguments are wrong β a bug in the caller. |
ABORTED |
The caller cancelled it. Not a failure; do not report it as one. |
GENERATION_FAILED |
The model failed mid-generation. |
PACKAGE_INCOMPLETE |
Your build is wrong, not your code β a missing vendor/ bundle or a decode worker the bundler did not emit. message names the fix; detail.cause says which. |
Ask before you download, not after
environment() is the preflight: one call that says what this machine will do, and why.
const report = await engine.environment();
if (!report.ok) console.error(report.lines[0].fix ?? report.lines[0].cause);
Every line carries the same five fields, so you can render the whole report without special-casing any of it:
| field | |
|---|---|
severity |
blocked Β· degraded Β· tune Β· info Β· ok β lines come sorted worst-first |
affects |
what you lose, in your terms |
cause |
the measured fact behind the verdict |
fix |
the exact call to make, or null when nothing can be done |
operable |
whether this is reachable from JS at all |
fix is null for hardware, build-time flags, and browser settings JS cannot reach. Reporting a
consequence with no remedy is still the point: "your second turn is slow because this device caps
storage buffers at 9" is the difference between a bug report and an informed decision.
await engine.environment({ scope: "local" }); // never touches the model layer β cheap, poll freely
await engine.environment({ scope: "device" }); // hardware only
await engine.environment.measure(); // one calibration generation β measured tok/s
environment() only reports. Writes go through configure(), and passing a setting to
environment() is an error that names the call you wanted β one function doing both, told apart by
argument shape, is how you get a call that silently does nothing.
await engine.configure({ decodeSteps: 8 }); // hot, no reload
await engine.configure({ engineCount: 3 }); // persisted; applies to pools built after it
canRun(modelId) stays separate and answers the per-model question β will this one fit and run
here β where environment() is per-device.
Freeing memory
A resident model is a full copy of its weights in VRAM, and nothing reports free VRAM to a page β so residency is explicit rather than guessed at.
await engine.unload(); // the current model's VRAM; cached bytes stay, so reloading is free
await engine.unload(id); // that model's VRAM
await engine.unload(id, "cache"); // and delete its cached bytes, keeping the registry entry
await engine.unloadAll(); // every resident model
await engine.remove(id); // bytes and entry; for an injected model, means re-supplying the folder
A bare unload() frees only the current model β unloadAll() is the explicit form, because
freeing everything is the more destructive reading and should have to be asked for by name.
remove() keeps its own verb for the same reason: it is the one that cannot be undone.
evict(id) remains as the low-level primitive unload(id, "cache") is built on.
load() unloads whatever else is up before bringing a model in, which is the safe default on a
16 GB machine. Pass { keepResident: true } to hold two at once, having checked the budget with
canRun() first. cacheState(modelId) says what is already on disk.
Choosing a store
CreateScheduledEngine defaults to IndexedDB, because a registry that dies with the page would
strand the weights β the bytes stay in Cache Storage but nothing remembers they are there. Pass one
explicitly in a worker or a test:
import { ScheduledEngine, ModelStore } from "everything-webgpu";
import { memoryStorage } from "everything-webgpu/adapters/memory";
const engine = new ScheduledEngine({ store: new ModelStore(memoryStorage()) });
Adapters ship for idb (pages, plus ensurePersistent()), memory (tests), and webext
(browser.storage.local and the message/port router).
Measured
M4 MacBook Air (16 GB), Firefox 154 release, macOS. Qwen3.8-2B-q4f16_1 (1.06 GB) shipping,
Qwen3.5-0.8B-q4f16_1-MLC (443 MB) as the baseline most of the analysis was done on.
| 2B | 0.8B | |
|---|---|---|
| Model load, cache only, zero network | 51 s | 48 s |
| Prefill | 48 tok/s short, 100β200 at length | 95β98 tok/s |
| Decode | 16.6β18.1 tok/s | 25.9 tok/s |
| Decode, stock single-step WebLLM | β | 9.6 tok/s |
| Second engine, 4-prompt batch | 1.06x | β |
Reproduce with npm run e2e. The full analysis β where every millisecond goes, why a second engine
buys isolation rather than throughput, and what is still open β is in AI.md.
Stability
chat.completions.create() is the compatibility layer. It does not change, and any change to
its request or response shape is a major version. That is the whole point of the one-line migration:
if it drifted, the line would not be one line.
Everything else is pre-1.0 and may move. The ergonomic verbs (load, unload, environment) are
being consolidated β see ROADMAP.md.
Docs
| API.md | Every way to call it β one page, asserted against the code. |
| examples/ | Three runnable projects. Read one before reading the source. |
| AI.md | What is true and measured. The reference. |
| ROADMAP.md | The only list of open work. |
| ARCHIVE.md | What was done and why β decisions with their reasoning. |
| WEBLLM-SURFACE.md | What WebLLM already does, where our line is, and the dependency-bump runbook. |
| MLC-COMPILE.md | How the model was compiled, and every toolchain breakage on the way. |
Development
npm run build # bundle WebLLM + apply the two patches
npm test # contract, patches, README, examples, scheduler, multistep β GPU-free
npm run e2e # real Firefox + real GPU + real model
npm run verify-consumer # pack + install for real; proves the Vite plugin is needed and works
npm run verify-patches # check patch anchors without rebuilding
@mlc-ai/web-llm is pinned exactly, because the build rewrites its bundle at verified anchors.
Bumping it is a documented, guarded procedure β see WEBLLM-SURFACE.md, "Upgrading".
Licence
This package is ISC (LICENSE). It ships a pre-built copy of WebLLM
(vendor/web-llm.js, Apache-2.0) and its dependency loglevel (MIT); their full licence
texts travel in THIRD-PARTY-NOTICES.md, and
test/license.test.mjs fails the build if a dependency is ever bundled
without one.