Everything WebGPU β project harness
Project Vision
An embeddable local-LLM engine for the browser: WebLLM on WebGPU, plus a priority scheduler, multi-step
decoding and compute-pass batching that together take decode from 9.7 to 25.9 tok/s. main is the library β
host-neutral, no UI, usable from a page, a worker or an extension. demo keeps the Firefox WebExtension
that was the original vehicle and is now the library's first consumer. See ARCHIVE.md.
Weights arrive by whichever route suits the app: WebLLM's 163 prebuilt models from HuggingFace, any base URL the developer hosts, or a local folder read off disk with no network connection at any point.
Model: empero-ai/Qwen3.8-2B-Distill, compiled to MLC in-house β done and running at 16.6-18.1 tok/s. The original "4B+" goal was set before decode was instrumented; the measurements retired it. Decode is memory-bandwidth-bound, so time per token scales with weight bytes: a 4B at q4f16_1 is ~2.25 GB and projects to 7-9 tok/s with only one engine fitting in 16 GB, while this 2B is 1.06 GB and was projected at 14-18 tok/s β the measurement landed inside that band. Build notes and the toolchain fixes are in MLC-COMPILE.md.
Workflow & Development Principles
- Fail Fast: Validate inputs, model states, and cache availability early. Throw descriptive errors immediately upon invalid conditions.
- Minimal Surface: Write only necessary code and tests. Prefer a single, comprehensive integration test over redundant unit tests.
- Reuse First: Leverage existing internal APIs, built-in libraries, and ecosystem patterns (e.g., WebLLM, Cache API) before introducing net-new abstractions.
- Direct Execution: Output exact code changes or direct answers. Omit preamble, pleasantries, conversational fillers, and unsolicited caveats.
Where the work lives
| README.md | The developer-facing entry point. Asserted by test/readme.test.mjs, so its examples cannot drift from the API. |
| API.md | Every call form, one page. Asserted by test/api-doc.test.mjs β method names, error codes, enum values and the export list are all derived from the source. |
| ROADMAP.md | The only list of open work. |
| ARCHIVE.md | What was done and why β decisions with their reasoning, so they are not re-litigated. |
| WEBLLM-SURFACE.md | What WebLLM already does. Read before adding a capability, and follow its "Upgrading" runbook on every dependency bump. |
| This file | What is true and measured. Reference, not a plan. |
Task lists used to live here, in AI2.md and in NATIVE-REUSE-PLAN.md at the same time, with "Track 1" and "Track 2" meaning different things in each. That is how a reader implements the wrong item, and it is the same shape of failure β no single place to look before acting β that produced the duplicated WebLLM helpers ARCHIVE.md records.
Completed Tasks
Through the in-house model compile. Everything after that β the library extraction, the model sources, the WebLLM de-duplication β is in ARCHIVE.md with its reasoning.
- Defined core requirements for local WebGPU-based execution in Firefox on macOS.
- Established direct cache-injection architecture for offline local models.
- Configured
manifest.json(MV2, persistent background page,wasm-unsafe-evalCSP,unlimitedStorage) and documented theabout:configflags in the manager page. - Implemented drag-and-drop model folder ingestion that writes straight into
Cache Storageunder WebLLM's own scopes and keys. - Built the minimal test chat popup driven by the background WebLLM engine.
- Exposed the
everything-webgpu/v1message + port API for other Firefox extensions. - Verified end-to-end on real hardware with
Qwen3.5-0.8B-q4f16_1-MLC(Firefox 154, macOS, M4). - Root-caused decode throughput to Firefox's 100 ms WebGPU poll timer (Mozilla bug 1870699), not to anything in this extension.
- Replaced the fail-fast
busyflag with a priority scheduler: bands, session supersession, opt-in preemption. - Added a worker-backed engine pool and a
batchAPI so independent work runs concurrently. - Implemented multi-step decoding (vLLM's
--num-scheduler-steps, default 15) so one GPU sync covers K tokens: 9.7 -> 18.4 tok/s single-stream. - Instrumented decode with a CPU-encode / GPU-execute probe and root-caused the remaining ~46 ms/token to 664 per-token kernel launches, each in its own WebGPU compute pass β not the poll tick, not command encoding.
- Batched consecutive tvmjs kernel launches into one compute pass (664 passes/token -> ~16): 10.3 -> 25.9 tok/s on an identical greedy generation, byte-identical output.
- Priced the remaining gap against the platform:
50 GB/s is the most a hand-written WGSL kernel gets here, ~16 GB/s is what the generated dequant-GEMV gets, and the difference is the reduction tail amortised over a 2-iteration loop β not load width (1.15x) and not dequantisation (~free). - Compiled
Qwen3.8-2B-Distillto MLC/WebGPU in-house: vision tower and MTP head stripped,q4f16_1, 1.06 GB, 26 shards. Five separate breakages in the published MLC nightlies had to be worked around first β see MLC-COMPILE.md andtools/. - Made the engine pool demand-driven: one engine at load, a second only when a second task competes, one engine per task. Measured that a second engine is worth 1.06x on this model, so it buys isolation rather than throughput.
- Surfaced the model-load report in the UI. WebLLM's text was always being passed through, but
#statuswas a single ellipsised line, so it was truncated toLoading model from cache[26/5β¦.
Consolidated Context
- Target Platform: Firefox WebExtension (macOS, requiring WebGPU flags). Test machine: M4 MacBook Air, 16 GB unified memory, ~120 GB/s.
- Core Stack: JavaScript, WebGPU, WebLLM (
@mlc-ai/web-llm0.2.84, patched at build time), Cache API (for local file injection), Extension Message Passing. - Architecture: A host-neutral engine (
src/engine/, asserted free of any WebExtension API) behind per-host adapters (src/adapters/). The Firefox extension is one host: background engine host + popup test UI + extension-to-extension API provider. See ARCHIVE.md. - Model:
Qwen3.8-2B-q4f16_1(1.06 GB), compiled in-house fromempero-ai/Qwen3.8-2B-Distillβ see MLC-COMPILE.md.Qwen3.5-0.8B-q4f16_1-MLCremains the baseline most of the analysis below was measured on. Keepq4f16_1β dequantisation measured ~free, so wider formats only add bytes, and bytes are what decode pays for. - Scheduling: one shared GPU, one engine per task, pool grows on demand. A second engine measured 1.06x on this model, so it buys isolation rather than throughput.
- Build-time patches (build/patches.mjs, applied by build.mjs):
storage-buffer-limit(Firefox caps storage buffers per stage at 9, tvmjs asks for 10) andcompute-pass-batching(one compute pass per kernel launch -> one per flush). Anchors are literal JS matched modulo whitespace, word-bounded, and optionally scoped to an enclosing function; all are verified before anything is rewritten, so a WebLLM bump reports every break at once with the nearest candidate lines.npm run verify-patcheschecks them without rebuilding;NO_PASS_MERGE=1skips the second patch for A/B.
Verified
All numbers on an M4 MacBook Air (16 GB), Firefox 154 release, macOS.
The shipping model: Qwen3.8-2B-q4f16_1 (1.06 GB, 26 shards)
Compiled in-house; see MLC-COMPILE.md.
| Ingest 1.06 GB into Cache Storage | 4.8 s |
| Model load (cache only, zero network) | 51 s |
| Prefill | 48 tok/s short prompt, 100-200 tok/s at length |
| Decode | 16.6-18.1 tok/s |
| Re-prefill cost per history token | 5.27 ms (no cross-turn KV reuse β see Current Tasks) |
| Kernel launches per decoded token | 664 = 639 forward + 25 sampling, across ~16 flushes |
| Second engine, 4-prompt batch | 1.06x β see "Scheduling" |
| Two tasks on two engines (e2e) | 1.05x, then 1.11x and 1.12x on two later runs. The ratio held across runs whose absolute times differed by 35%, so it is a real shift rather than noise β but 1.11x is still isolation, not throughput scaling. |
The baseline it was built against: Qwen3.5-0.8B-q4f16_1 (443 MB, 11 shards)
Everything below this point was measured on the 0.8B. It is kept because it is where the architecture came from β the ceiling, the multi-step fix and the pass-batching fix were all found here.
| WebGPU in the MV2 background page | available, shader-f16 supported |
| Ingest 443 MB into Cache Storage | 2.1 s |
| Model load (cache only, zero network) | 48 s |
| Prefill | 95-98 tok/s |
| Decode, stock single-step | 9.6 tok/s (warm) |
| Decode, multi-step K=15, one pass per kernel | 17.3-18.3 tok/s |
| Decode, multi-step K=15 + batched compute passes | 24.9-28.0 tok/s (25.9 when first measured; 27.4, 28.0, 24.9 across later e2e runs on the same build β run-to-run spread is ~12%, so treat any single number as Β±1.5) |
| Decode, K=32 + batched passes | 26.8 tok/s |
| Kernel launches per decoded token | 664, across ~16 flushes |
| Decode budget at K=16 | 3.8 ms CPU encode + ~34 ms GPU + <6 ms tick |
Reproduce with npm run e2e, or ENGINE_COUNT=1 DECODE_STEPS=1,2,4,8,16,24 npm run e2e for the multi-step
curve β see test/e2e/run.mjs.
Three things that surfaced from running it for real:
- WebGPU works in the background page. This was the load-bearing assumption behind putting the engine there, and it holds on release Firefox.
- Neither the poll tick nor CPU encoding is the ceiling. Both measured small once instrumented. What was left split in two: 664 per-kernel compute passes (fixed, ~2.5x) and, underneath, decode running at ~13% of memory bandwidth (open). See "Where the 46 ms goes" and "Still not 100+ tok/s".
- Firefox needed a shim. tvmjs hardcodes a request for 10 storage buffers per shader stage; Firefox's
Metal backend caps
maxStorageBuffersPerShaderStageat 9, sodetectGPUDevice()threw before a device was ever requested.build.mjsclamps that request to what the adapter reports, and fails the build loudly if the patch stops matching after a WebLLM upgrade. Kernels that genuinely need a 10th binding still fail at pipeline creation β silently, as a no-op dispatch that emits garbage rather than an error. Both models ship four such kernels; three are unreachable by config and the fourth,batch_prefill_paged_kv_kernel, is kept off the live path byengine-worker.jscallingresetChat()before every prefill when the device reports fewer than 10.node tools/audit-wasm.mjs <folder>checks this.
The 10 tok/s ceiling
Decode is capped by Firefox, not by this extension or by the GPU. WebGPUParent
detects GPU completion by polling on a fixed timer:
const uint64_t POLL_TIME_MS = 100;
mTimer.Start(base::TimeDelta::FromMilliseconds(POLL_TIME_MS), this, &WebGPUParent::MaintainDevices);
So every onSubmittedWorkDone() / mapAsync() resolves only on a 100 ms tick. WebLLM needs exactly one
GPU->CPU sync per token (it reads back the 4-byte sampled token id before it can compute the next one), and a
serial sync loop always lands just after a tick and waits the full period. One token per tick = 9.6 tok/s.
npm run bench (no extension, no model, plain page) measures this directly:
| measurement | result | meaning |
|---|---|---|
idleSyncMs |
104 ms | awaiting an empty queue with nothing submitted still costs a full tick |
dispatch256Ms |
104 ms | 256 dispatches sharing one compute pass and one bind group cost the same as zero |
pipelined10SubmitsMs |
104 ms | 10 submits, one await β it is latency, not throughput |
syncAfterRandomDelay |
mean 52 ms, min 3, max 106 | desynchronize from the tick and you get the expected uniform 0-100 ms |
n2048 |
1pass=104ms 2048passes=309ms |
a compute pass costs ~100 Β΅s |
dispatch65536InOnePassMs |
207 ms | a dispatch inside a pass costs ~2 Β΅s β measured past the tick, not bounded by it |
Read the dispatch256Ms row carefully β reusing one pass and one bind group is not what the runtime does,
and reading it as "compute is free" is what produced the wrong conclusion corrected below.
WebLLM's own enable_latency_breakdown agrees: totalTime=101.2ms, of which sampleTime=101.1ms β the step
containing the sync is the entire token budget. Identical numbers in a hidden background page, a visible tab,
and an ordinary web page, with and without MOZ_DISABLE_GPU_PROCESS=1, so it is not extension-specific and
not GPU-process IPC.
Correction. This section used to claim actual compute was "~7 ms/token (β137 tok/s if the sync were free)". That was wrong, and it was wrong in a way that mattered: the 7 ms came from
dispatch256Ms - dispatch1Ms, a micro-benchmark of trivial kernels sharing one compute pass and one bind group β nothing like a real forward pass. Removing the per-token sync (see "Multi-step decoding") took decode to 18.4 tok/s, not 137, because real per-token GPU work is ~46 ms. See "Where the 46 ms goes".
This is Mozilla bug 1870699, "Don't poll WebGPU from a timer", still open on trunk. Event-driven completion in Firefox would fix it outright. Short of that, the lever is to stop needing a sync per token β see "Multi-step decoding" below, which does exactly that and lifts a single stream well past 10 tok/s.
Practical consequence: the tick sets a floor of ~10 tok/s on serial decode, and multi-step lifts a single stream off it β but only to ~18 tok/s, where a second, larger cost takes over. Prefill is unaffected (one sync per forward pass regardless of token count), so prompt processing, embeddings and short completions were never the problem.
Multi-step decoding
The ceiling above is a sync budget, so the fix is to spend fewer syncs: run K forward steps per sync. This
is vLLM's --num-scheduler-steps (v0.6.0, +28% on
Llama-70B / 4xH100), and it lands here without recompiling any model, because WebLLM's sampling path is
already entirely on the GPU. softmax_with_temperature -> argsort_probs -> sample_with_top_p returns an
int32[1] device tensor, and Tensor.copyFrom(Tensor) is a device-to-device copy β so the sampled id can
be fed straight back into embed without ever becoming a JS number:
embed -> decode -> penalties -> softmax -> argsort -> sample ββ x K, no sync
^ β
βββββββββββββββββ device tensor, never read back ββββββββββββ
then: one device.sync()
Each step stages its id into its own CPU tensor; tvmjs queues those readbacks into pendingGPUToCPUCopy and
only awaits them in sync(), so K readbacks still cost one tick. steps defaults to 15, vLLM's documented
cap. src/background/multistep.js is the whole implementation; it lives in the
engine worker because that is where the decode loop is.
Measured, single engine, Qwen3.5-0.8B on an M4 Air. DECODE_STEPS=... npm run e2e sweeps K live on one
loaded model and reports the probe built into the burst β the K-step loop contains no await, so timing
either side of its single sync partitions the budget exactly:
| K | tok/s | cpu-encode | gpu + tick | kernels/tok |
|---|---|---|---|---|
| 1 (stock) | 9.7 | β | β | β |
| 2 | 11.5 | 4.3 ms | 82.1 ms | 664 |
| 4 | 16.0 | 4.8 ms | 57.6 ms | 664 |
| 8 | 16.8 | 4.5 ms | 54.8 ms | 664 |
| 16 | 17.8 | 4.2 ms | 52.1 ms | 664 |
| 24 | 18.4 | 4.2 ms | 50.1 ms | 664 |
The curve is smooth and monotone, saturating near 18-19 tok/s: +90% over stock at the shipped default of 15. There is no sawtooth β an earlier version of this section predicted one from tick quantization, which only appears when per-step compute is small next to the 100 ms tick. It is not: it is ~46 ms. Past Kβ16 the tick is already amortized to under 6 ms/token and there is nothing left to win.
Where the 46 ms goes
Subtracting the amortized tick from the gpu + tick column leaves ~46 ms/token of real GPU time, against a
memory-bandwidth floor of ~3.5 ms (420 MB of q4f16 weights read once per token at ~120 GB/s). So ~93% of
decode is overhead, and the probes say which:
| candidate | measured | verdict |
|---|---|---|
| the 100 ms poll tick | <6 ms/token at Kβ₯16 | amortized away by multi-step |
CPU command encoding, createBindGroup, IPC |
4.2 ms/token, flat in K | not the bottleneck |
| GPU execution | ~46 ms/token over 664 kernel launches | this is it, at ~70 Β΅s/kernel |
664 kernels per token is ~24 per layer β an unfused graph β and tvmjs opens a separate compute pass for
every one of them (submitShader: beginComputePass β setPipeline β createBindGroup β dispatch β
end). WebGPU barriers between passes, and on Metal each pass is its own command encoder. npm run bench
isolates the cost:
| measurement | result |
|---|---|
| 2048 dispatches, one compute pass | 104 ms (i.e. free β it is all tick) |
| 2048 dispatches, 2048 compute passes | 309 ms β ~100 Β΅s per pass |
| CPU cost of encoding one tvm-style kernel | 3.9 Β΅s |
So the pass is ~25x its own encode cost, and it is charged 664 times per token β while only 16 flushes per token actually happen, i.e. ~41 consecutive launches share an encoder and each open their own pass for no reason. Dispatches within one WebGPU compute pass are already ordered with implicit barriers (a compute pass has a usage scope per dispatch, so implementations must synchronize between them), so those passes were not buying correctness.
Batching the compute passes
The compute-pass-batching patch in build/patches.mjs rewrites tvmjs to open a pass
lazily and close it in flushCommands() β already the one chokepoint every operation that cannot run
mid-pass routes through. That turns 664 passes/token into ~16. Three edits, each refusing to apply if its
anchor stops matching after a WebLLM upgrade, same as the storage-buffer shim next to it. The compute.end();
anchor is scoped to the function the pass is opened in, so an unrelated compute pass elsewhere in tvmjs is not
mistaken for ambiguity. Build with NO_PASS_MERGE=1 to skip it and A/B on one machine.
Measured on the same 127-token greedy generation (temperature: 0, single engine, K=15):
| stock WebLLM | + multi-step | + pass batching | |
|---|---|---|---|
| decode | 9.7 tok/s | 10.3 tok/s | 25.9 tok/s |
gpu + tick |
β | 85.2 ms/tok | 33.8 ms/tok |
cpu-encode |
β | 5.1 ms/tok | 4.4 ms/tok |
| kernels/token | 664 | 664 | 664 |
| compute passes/token | 664 | 664 | ~16 |
Output is byte-identical across the A/B (490 bytes, greedy) β the check that matters, since the whole change rests on WebGPU synchronizing dispatches inside a pass.
One caveat on the numbers: the unbatched build is noisy run to run (K=16 measured anywhere from 10.7 to
18.6 tok/s, gpu + tick from 49.6 to 92.1 ms), while the batched build is tight (24.6-26.8 tok/s, 33.4-37.3
ms across five runs). The table above is a same-session pair, and its baseline happened to land on the slow
side. Against the best baseline seen the gain is ~1.4x rather than 2.5x; against the median, ~1.8x. The
direction and the mechanism are not in doubt, but quote a range, not the 2.5x.
The K curve keeps the same shape, shifted up, and still has no sawtooth: 2 β 19.3, 8 β 23.8, 16 β 25.6,
32 β 26.8 tok/s. DEFAULT_DECODE_STEPS stays at 15 (25.6 tok/s); K=32 buys another 4.7% for double the
transient buffers and more lookahead discarded at every stop token. K=1 is unchanged at 9.7 β with GPU time
now ~34 ms it fits inside a tick, so single-step is purely tick-bound and only multi-step can help it.
Still not 100+ tok/s, and it is not the launch count
~34 ms/token of GPU remains. It is tempting to blame the 664 launches, and an earlier version of this file did. That is wrong, and the bench says so once it is pushed past the tick:
| dispatches in one pass | wall | per dispatch |
|---|---|---|
| 2 048 | 104 ms | β€50.8 Β΅s β but this is the tick, so it only bounds the number |
| 16 384 | 102 ms | β€6.2 Β΅s β still one tick |
| 65 536 | 207 ms | ~2.1 Β΅s marginal |
A dispatch inside a pass costs ~2 Β΅s, so all 664 of them cost ~1.3 ms, and the ~16 remaining passes add ~1.6 ms. Neither is the 34 ms. Note how the first row alone would have supported the wrong conclusion: any measurement that lands on 104 ms is the poll grid talking, not the GPU.
What is left is the kernels' own execution. Splitting the launches at the forward/sample boundary gives
639 forward + 25 sampling β the 248k-vocab argsort_probs is only 4% of the budget, so the sampling tail
is not it either. Subtracting the amortized tick leaves 27 ms of forward execution to move 420 MB of q4f16
weights, i.e. **16 GB/s against the M4's ~120 GB/s β about 13% of peak**.
That is the signature of batch-size-1 decode: a matrix-vector product where every weight is read once, used for a single multiply-accumulate, and thrown away, with 4-bit dequantization on top. Intermediates are not the problem (hidden size 1024 means a ~2 KB activation, negligible next to the weights). So the lever is faster kernels, not fewer: better dequant-GEMV, or a quantization whose kernels reach a higher fraction of peak. See "Does a different model help?".
Why not llama.cpp/Ollama-class, and what would close it
Decode moves 420 MB of weights per token, so every question about throughput is a question about achieved
memory bandwidth. npm run bench measures the two ceilings above us β a hand-written WGSL kernel that does
nothing but stream a 512 MB buffer, swept over workgroup counts so an untuned kernel is not mistaken for a
platform limit:
| layer | achieved | of hardware peak |
|---|---|---|
| M4 (128-bit LPDDR5X) theoretical | ~120 GB/s | 100% |
| Best hand-written WebGPU streaming read, Firefox | ~50 GB/s | ~43% |
| MLC/TVM dequant-GEMV during decode | ~16 GB/s | ~13% |
Two independent gaps, with different owners:
- 120 β 50 is the platform. Flat at 50-53 GB/s across 256/1024/4096/16384 workgroups, so it is not an occupancy or parallelism problem in the probe. Nothing in this repo moves it.
- 50 β 16 is the kernels. This is the one worth attacking: ~3x, and it is entirely inside the generated dequant-GEMV.
Where the 50 -> 16 goes, measured
The generated kernel is in the model's .wasm as WGSL, so it can be read directly. The hot one
(fused_dequantize2_fused_NT_matmul1_silu1_multiply1_kernel) is @workgroup_size(64), each thread runs a
2-iteration loop over packed weights, and the workgroup then reduces through var<workgroup> red_buf0
with barriers.
npm run bench rebuilds that kernel one property at a time against the same 512 MB buffer, so each step's
cost is isolated:
| kernel property | GB/s | cost |
|---|---|---|
vec4 loads, 256-wide, long loop β the ceiling |
~50 | β |
scalar u32 loads (what packed 4-bit weights force) |
~41-46 | ~1.15x |
| + unpack 8 nibbles, centre, scale | ~41 | ~1.0x β dequant arithmetic is nearly free |
| + 64-lane barrier reduction, 32 iters/thread | 34.0 | 1.2x |
| + 8 iters/thread | 27.0 | 1.5x |
| + 2 iters/thread β what MLC actually generated | 18.6 | 2.2x |
| decode in the real engine (adds scale traffic, writes, real grid) | ~16 | 2.6x |
So neither load width nor dequantisation is the problem. The cost is the reduction tail amortised over
almost no work: each workgroup reads 128 u32 β 512 bytes β and then spends six workgroupBarrier()s
doing a 64-lane tree reduction. Raising work per thread from 2 to 32 iterations recovers 1.83x on its own
(18.6 -> 34.0 GB/s) with everything else held fixed.
That is a schedule parameter, chosen by TVM's dlight GEMV rule at model-compile time and baked into the
.wasm. It is not something the runtime can change: the launch grid is emitted by the host side of the same
module, so substituting a shader would desynchronise it from its dispatch shape. The fix is to recompile the
model lib β same weights, retuned GEMV tiling β and it is also exactly what subgroups would make moot, since
simd_sum reduces 32 lanes without a barrier at all.
Caveat: these are faithful models of the kernel, not the kernel itself; the real one also streams the scale array and writes results, which is roughly where the last 18.6 -> 16 goes.
What is structurally different in llama.cpp's pipeline (reasoned from the two codebases, not measured here β Ollama is not installed on the test machine):
- Hand-written Metal vs WGSLβnagaβMSL. WebGPU mandates bounds-checked buffer access; naga emits clamps on dynamic indexing, which lands in a GEMV's innermost loop.
- No subgroup ops. llama.cpp's Metal GEMV leans on
simd_sum/simd_shufflefor cross-lane reduction andsimdgroup_matrixfor matmul. WGSL subgroups are not exposed in Firefox, so TVM must reduce through workgroup shared memory and barriers β the expensive path for a reduction-shaped kernel. - Quantization co-designed with its kernel.
Q4_Kand friends are laid out so a block dequantizes in registers from vectorized loads;q4f16_1goes through a generated TVM dequant instead.
Is "80% of Ollama" reachable here? Probably not on Firefox WebGPU today, and the arithmetic says why rather than the vibes. Even if the dequant-GEMV became as efficient as a pure streaming read β which it cannot be, since it also has to dequantize and reduce β 420 MB at 50 GB/s is 8.4 ms/token, about 110-120 tok/s ceiling for this model on this platform. Any Ollama number above ~140 tok/s therefore puts 80% of it out of reach no matter how good the kernels get, because the 120 β 50 gap is not ours to close.
So the honest targets are: ~70 tok/s is plausible (halve the 50 β 16 kernel gap), ~110 tok/s is the absolute platform ceiling, and matching a fast native runtime is not on the table without subgroups landing in Firefox or a move off WebGPU. Measure the actual Ollama figure on the same machine before committing to a percentage target β it is the one number in this section that is assumed rather than measured.
Also worth knowing: the bench loses the WebGPU device outright when it creates 2048 compute passes in one encoder (
deviceLostDuringBench = yes, during pass-sweep), and a lost device does not throw β later calls silently no-op and report impossible numbers like 1374 GB/s. The unpatched runtime issued 664 passes per token, which is uncomfortably close to that. The dispatch cap inpatchComputePassBatching()exists for this reason.
Does a different model help?
Decode cost per token is roughly weight bytes / effective bandwidth + ~1.3 ms of launches + tick/K.
The measured effective bandwidth is ~16 GB/s of the M4's ~120 GB/s, so the first term dominates and scales
with parameter count and quantization β not with how the layers are arranged. Extrapolating from the measured
26 tok/s at 420 MB:
| change | weight bytes | projected | why |
|---|---|---|---|
| another 0.8B architecture, same quant | ~450 MB | ~26 tok/s | same traffic per token; layer layout is not the variable |
| Qwen3.8-2B-Distill (shipped) | 1.06 GB | 16.6-18.1 tok/s, measured | projected 14-18; the projection held |
| Llama-3.2-3B | ~1.7 GB | ~9 tok/s | prebuilt MLC folder exists, so no toolchain β the cheap way to sanity-check these projections |
| a 4B at q4f16 | ~2.25 GB | ~7-9 tok/s | 5x the bytes, and only engineCount=1 fits in 16 GB. Even at 100% of peak it is ~19 ms/token = 53 tok/s |
| a ~0.3B at q4f16 | ~160 MB | ~60-70 tok/s | helps, but buys less than fixing the efficiency |
| same weights, different quantization | varies | see below | resolved: it changes byte count and nothing else |
Every row except the 2B is still a projection from the measured 16.8 GB/s. The 2B row is now a measurement, and it landed inside its projected band β which is the only evidence available that this model of decode cost predicts anything.
So: switching models does not get to 100+ tok/s, and going bigger costs throughput roughly in proportion to the extra bytes. This table is why the "4B+" goal was retired in favour of a 2B β see "Project Vision". 100 tok/s means ~10 ms/token, which at today's 16 GB/s buys only ~160 MB of weights, but at full bandwidth would buy ~1.2 GB. Closing the efficiency gap is worth ~7x more than shrinking the model.
Which quantization to recompile with: keep q4f16_1. That experiment is settled by the kernel breakdown
above β unpacking eight nibbles, centring and scaling them measured ~free (41 GB/s with the dequant
arithmetic vs 41-46 GB/s without). Since dequantisation costs nothing and bytes-per-token is what decides
throughput, a wider format is pure loss:
| format | bytes per 32 weights | vs q4f16_1 | verdict |
|---|---|---|---|
q4f16_1 (current) |
16 + 2 scale = 18 | 1.00x | keep |
q3f16_1 |
12 + 2 = 14 | 0.78x -> ~1.28x faster | the only quantization that buys speed, and it costs accuracy |
q4f32_1 |
16 + 4 = 20 | 1.11x slower | f32 scales and activations, and f16 ALU is faster on Apple |
q0f16 |
64 + 0 = 64 | 3.6x slower | skips a dequant that was already free |
q4f16_awq |
18 | 1.00x | same speed, better accuracy β worth it for quality, not for speed |
So the ~1.28x from q3f16_1 is both smaller than the 1.83x available from the schedule and the only one of
the two that trades away accuracy. Fix the schedule first; treat quantization as a quality knob.
One structural note specific to these weights: vocab_size is 248320 with tie_word_embeddings, so the
output projection alone is 1024 x 248320 = 254 M parameters, about 30% of every token's memory traffic.
That is unusually large (most models are 32k-128k) and no quantization choice changes its share.
Set decodeSteps: 1 to turn multi-step off; NO_PASS_MERGE=1 npm run build to turn pass batching off.
Build and install
npm install
npm run build # bundles @mlc-ai/web-llm into vendor/web-llm.js
npm test # integration test over the cache-injection contract
npm run e2e # real Firefox + real model + real GPU (needs a model folder)
npm run bench # WebGPU submit/sync latency only - no extension, no model
npm run package # -> everything-webgpu.xpi
Env vars that matter when measuring rather than just running:
| Var | Applies to | Effect |
|---|---|---|
NO_PASS_MERGE=1 |
npm run build |
Skip compute-pass batching, for an A/B on one machine. |
ENGINE_COUNT=n |
npm run e2e |
Force the pool size; use 1 to measure a single stream. |
DECODE_STEPS=a,b,c |
npm run e2e |
Sweep multi-step widths live on one loaded model. |
SKIP_BENCH=1 |
npm run e2e |
Drop the two ~40 s gpuBench passes when comparing builds. |
E2E_VERBOSE=1 |
npm run e2e |
Surface web-ext/Firefox output; without it a failed launch and a hung extension look the same. |
MODEL_DIR=β¦ |
npm run e2e |
Model folder (defaults to ~/Downloads/Qwen3.5-0.8B-q4f16_1-MLC). |
Load it with about:debugging β This Firefox β Load Temporary Add-on β pick manifest.json.
Before a model can load, set these in about:config and restart Firefox:
| Pref | Value | Why |
|---|---|---|
dom.webgpu.enabled |
true |
Exposes navigator.gpu. |
gfx.webgpu.ignore-blocklist |
true |
Only if your Mac's GPU is blocklisted. |
dom.webgpu.service-workers.enabled |
true |
Harmless; needed on builds that gate non-visible contexts. |
The manager page shows live WebGPU status, so you can tell a flag problem from a model problem.
Adding a model
Three routes, and load() resolves across all of them. Two of them are one call:
// 1. prebuilt β one of WebLLM's 163 HuggingFace models. Nothing to register.
await engine.load("Llama-3.2-1B-Instruct-q4f16_1-MLC");
// 2. remote β any base URL you host: an HF repo, a CDN, a path on your origin, localhost.
await engine.registerModel({
modelId: "Qwen3.8-2B-q4f16_1-MLC",
model: "/models/Qwen3.8-2B-q4f16_1-MLC/",
modelLib: "/models/Qwen3.8-2B-q4f16_1-MLC/Qwen3.8-2B-q4f16_1-webgpu.wasm",
});
// 3. local β read off disk. No network connection at any point, ever.
await engine.registerModel({ modelId: "Qwen3.8-2B-q4f16_1-MLC", files });
files is { path, file }[]; filesFromDataTransfer and filesFromInput build it from a drop event or a
directory picker. listAvailableModels() enumerates all three; { prebuilt: false } on the engine gives a
build that can never fetch a model.
In the demo extension this is the Models⦠page: drop a compiled MLC folder on it.
A local folder must contain:
mlc-chat-config.jsontensor-cache.json(or a legacyndarray-cache.json)- every
params_shard_*.binlisted in that manifest tokenizer.json(ortokenizer.model)- exactly one
*-webgpu.wasmmodel library
Grab both halves from Hugging Face β the weights from mlc-ai/<Model>-MLC, the matching library from
mlc-ai/binary-mlc-llm-libs β or compile your own with mlc_llm convert_weights + gen_config + compile.
Local registration validates the whole folder before writing anything, then copies each file into Cache Storage. A missing shard fails in milliseconds rather than after 2 GB of copying. A remote URL is not validated at all β there is nothing to check without fetching, and WebLLM's loader reports a bad base URL far better than a HEAD request would.
How local registration works
WebLLM composes every artifact URL as new URL(relative, base) and runs the base through cleanModelUrl,
which itself calls new URL(...) β so the base must be absolute and resolvable. A blob: URL cannot serve
as one, and there is no hook to hand the loader bytes directly. Pre-populating the cache under WebLLM's own
scopes and keys therefore is its native path: the loader does its ordinary thing and finds everything
already present.
WebLLM is never told the model is local. Each model gets a synthetic base URL
(https://local-model.invalid/<id>/resolve/main/) and its artifacts are written into the exact cache
scopes and keys WebLLM's loader looks up:
| Cache scope | Keys |
|---|---|
webllm/config |
<base>mlc-chat-config.json |
webllm/model |
<base>tensor-cache.json, tokenizer, every params_shard_*.bin |
webllm/wasm |
<base><model>-webgpu.wasm |
reload() therefore finds a full cache and issues zero requests. .invalid is reserved by RFC 6761 and can
never resolve β so this is the mechanism of the offline guarantee, not a label for it: there is no bug, no
eviction and no future refactor by which a locally-registered model reaches the network. It fails with a DNS
error instead. test/sources.test.mjs asserts that structurally, checking that every URL such a record
carries is on a .invalid host.
ModelStore.verify() checks every key before a load, so storage eviction is reported as "re-register the
folder" rather than a mid-load fetch. It gates only the local route: a remote or prebuilt model that
loses its cache just re-downloads, which is slow, not fatal.
test/integration.test.mjs pins this contract, including a guard that fails if a WebLLM upgrade renames a
cache scope or artifact. test/e2e/run.mjs proves it against a real model on a real GPU:
it temporarily wires a self-test page into the extension, drives ingest -> load -> streaming generation
through the production code paths, and restores the tree afterwards. Re-run it after bumping
@mlc-ai/web-llm.
Compiling a model in-house
Done, for empero-ai/Qwen3.8-2B-Distill. The full
record β every command, every toolchain breakage, and the numbers β is in
MLC-COMPILE.md; the scripts are in tools/. Start with:
tools/setup-mlc-toolchain.sh # venv, patches, emsdk, tvm web runtime
Do not follow the upstream MLC quickstart. No published pair of MLC nightlies works together, and five
separate breakages sit between pip install and a loadable .wasm β including two that produce a model
that compiles, ships and ingests cleanly and only fails at load. setup-mlc-toolchain.sh and
patch-mlc-nightly.py encode all of them and are idempotent; re-run after any pip install.
Why this model. The text architecture is the one already verified end to end β two dimensions differ and nothing else:
| Qwen3.5-0.8B (baseline) | Qwen3.8-2B-Distill (shipped) | |
|---|---|---|
model_type |
qwen3_5 |
qwen3_5 |
| layers / heads / kv-heads / head_dim | 24 / 8 / 2 / 256 | 24 / 8 / 2 / 256 |
vocab_size, tie_word_embeddings |
248320, true | 248320, true |
linear_*, full_attention_interval |
16/128/16/128/4, every 4th | identical |
hidden_size |
1024 | 2048 |
intermediate_size |
3584 | 6144 |
The wider hidden_size also helps: the GEMV reduction is split across 64 lanes, so 1024 leaves 2 iterations
per thread while 2048 leaves 4 and the 6144 down_proj leaves 12 β further along the measured efficiency
curve (x2 = 18.6, x8 = 27.0, x32 = 34.0 GB/s) before any schedule retune.
Quantization is not a tuning knob here. Keep q4f16_1: unpacking eight nibbles and scaling them measured
free (41 GB/s with the dequant arithmetic vs 41-46 without), so a wider format only adds bytes, and bytes are
exactly what decode pays for. 1.28x) and it costs accuracy β less than
the 1.83x the schedule retune offers, and unlike it, not free.q3f16_1 is the only faster option (
Verifying a build
node tools/audit-wasm.mjs <folder> # storage buffers per kernel
node tools/wasm-imports.mjs <folder>/*.wasm <known-good>/*.wasm # runtime vs JS glue
MODEL_DIR=<folder> ENGINE_COUNT=2 npm run e2e
The import check is not optional: the model library links against whatever TVM web runtime built it but runs
against whatever tvmjs @mlc-ai/web-llm bundles, and a mismatch fails only at load, after everything else
has passed. Read the e2e's decode probe line too β kernels/tok Γ· 24 layers still β27 means dlight chose
more reduction threads over more work per thread and the schedule retune did not take.
Scheduling
The engine is one GPU shared by every caller, so requests carry scheduling metadata and the engine β not the caller β decides what runs when. Four mechanisms, no more (src/background/pool.js):
| Priority bands | interactive > normal (default) > background, FIFO within a band. |
| Session supersession | A new request with the same session cancels the previous one. This is the ghost-text primitive: each keystroke replaces the in-flight request instead of queueing behind it. |
| Opt-in preemption | An interactive request with no free engine interrupts a running job that set preemptible: true. The victim resolves with its partial output and is never requeued, so nothing can starve. |
| One task, one engine | Every request belongs to a task β a whole batch is one task, an unlabelled chat is its own. A task holds at most one engine, so two runnable tasks always run side by side whenever two engines exist. |
Nothing else interrupts work in flight. A job that did not opt in always runs to completion.
Why one engine per task
A batch used to spread across the whole pool. On the 0.8B that was worth 1.3-2.0x, because decode was sync-bound β a stream spent most of its time waiting on Firefox's ~100 ms tick, so a second stream filled idle GPU. Multi-step decoding removed most of that wait, and on the 2B the remaining cost is real GPU work. Measured, same four prompts, greedy so both runs emit exactly 97 tokens:
| pool | wall | aggregate | per item | peak overlap |
|---|---|---|---|---|
| 1 | 8.2 s | 11.9 tok/s | ~2.0 s | 1 |
| 2 | 7.7 s | 12.6 tok/s | ~3.9 s | 2 |
1.06x. The overlap is real β busy time summed to 15.2 s against 7.7 s of wall β but each stream runs at half speed, so they cancel. Spreading one task over the pool buys ~nothing and costs the thing a second engine is actually for: a page translation would sit on both engines while ghost-text waited behind it.
So the rule is flat. An engine may idle while one task still has work queued; that ~6% is deliberately given
up to keep an engine free for whoever shows up next. engine scaling: in the e2e prints this ratio β
re-measure it per model, because on a small enough model the old fan-out logic would win again.
The pool grows, it is not sized
engineCount is a cap, not a size. load() brings up exactly one engine; the pool adds another only
when a task that owns no engine is waiting. An engine no second task ever needed is ~1.6 GB on the 0.8B and
~2.4 GB on the 2B, bought for nothing.
There is no budget to check first. Firefox implements neither navigator.deviceMemory nor
performance.memory, and navigator.storage.estimate() reports disk quota, not RAM β verified against the
shipped binary, and re-checked every run by the memory signals: line in the e2e. Nothing tells an extension
how much memory is left. So the pool does not predict, it probes: a failed load is the memory check.
Growth then stops for that model and is not retried, and status().growthBlocked says so.
Growth is not instant. Building an engine is a full model load β measured 51 s for the 2B β so both tasks that triggered it will have finished first. The pool pays that once, in the background, and the second engine is there for the next collision. If a workload is known to be concurrent from the start, the honest fix is a warm-up request pair right after load, not a lower growth threshold.
Why each engine is a Web Worker
Several MLCEngines cannot share a realm. Running the same e2e three ways isolates it:
| setup | result |
|---|---|
| 1 engine, background page | passes |
| 2 engines, background page | both load, the first generates fine, the second's first generation fails: Expected null or instance of VectorInt, got an instance of VectorInt |
| 2 engines, one worker each | passes |
The trigger is a second engine generating in the same realm β not the pool, and not the engine count by
itself. That message 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 rather than 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 concurrency survives the move off the main thread (measured: 4 workers, 36.3 syncs/s).
How many engines are worth it
Measured on the 0.8B, four independent prompts, back when fan-out still scaled:
| pool | peak overlap | aggregate vs. serial |
|---|---|---|
| 1 | 1 | 1.00x |
| 2 | 2 | 1.3x - 2.0x |
| 4 | 4 | 0.3x β 3x slower than one engine |
Four engines overlap in wall-clock terms but each drops from ~9.6 to ~0.7 tok/s: four copies of the weights plus their KV caches leave a 16 GB machine with nothing free, and they starve each other. More engines is not more throughput β and on the 2B, per the table above, a second one is not more throughput either. The route to concurrent throughput is batched decode inside one engine (see Current Tasks), not more engines.
API
In-process β the library
Migrating off @mlc-ai/web-llm costs one line; everything after it is unchanged.
-import { CreateMLCEngine } from "@mlc-ai/web-llm";
-const engine = await CreateMLCEngine(modelId, { initProgressCallback });
+import { CreateScheduledEngine } from "everything-webgpu";
+const engine = await CreateScheduledEngine(modelId, { initProgressCallback });
await engine.chat.completions.create({
messages, stream: true,
session: "ghost-text", // added β supersedes the previous request
priority: "interactive", // added β may preempt work that opted in
});
chat.completions.create() returns WebLLM's own shapes, including its
"stop" | "length" | "abort" finish reasons. What it has no room for is cancelled and preempted
as distinct outcomes β both collapse to "abort" β which is why complete() below stays the direct
API rather than a legacy one.
The fuller surface, when you want the store, the model source, or those outcomes:
import { ScheduledEngine, ModelStore } from "everything-webgpu";
import { indexedDBStorage, ensurePersistent } from "everything-webgpu/adapters/idb";
// A page origin holds weights in *evictable* storage until this is granted.
await ensurePersistent();
const engine = new ScheduledEngine({ store: new ModelStore(await indexedDBStorage()) });
await engine.load("Llama-3.2-1B-Instruct-q4f16_1-MLC");
const { text } = await engine.complete({
messages: [{ role: "user", content: "hi" }],
session: "ghost-text",
priority: "interactive",
});
| method | what it does |
|---|---|
load(id, {keepResident, signal}) |
bring a model up; signal aborts the download, partial shards kept for a free resume |
use(id) |
switch between resident models β free, no reload |
unload(id?) / unloadAll() |
free VRAM, keep the cached bytes |
resident |
model ids with a live pool right now |
store.evict(id) |
free the disk, keep the record so it can be re-fetched |
remove(id) |
forget it entirely β frees bytes for every source, then drops the record |
chat.completions.create |
streamed chunks are WebLLM's own, verbatim: tool_calls, logprobs, stable created |
store.cacheState(rec) |
"cached" / "partial" / "absent" |
estimateSpeed(id?) |
projected tok/s, measured once anything has decoded |
features() |
what is actually switched on: KV reuse, decode steps, engines |
complete(req, onChunk?) |
one completion; onChunk streams deltas |
batch(req, onItem?) |
many independent prompts as one task β see below |
cancel(idOrSession) |
by job id or session key; returns how many it stopped |
configure({ decodeSteps }) |
retune a live engine, no reload |
registerModel(spec) |
a base URL, or local files |
listModels() / listAvailableModels() |
registered only (cheap) / all three routes |
subscribe(fn) |
lifecycle changes; returns an unsubscribe |
state / hasWebGPU |
current snapshot, WebGPU presence |
chat.completions.create(req) |
the WebLLM/OpenAI facade over complete() |
probe() |
WebGPU, adapter, shader-f16, limits, storage quota β cached |
canRun(modelId) |
{ ok, blockers, warnings }, before anything is downloaded |
recommendModels({maxVramMB, prefer}) |
rank the 163 prebuilt models for this device |
Failures are EngineError { code, message, detail } β NO_WEBGPU, NO_MODEL, UNKNOWN_MODEL,
CACHE_INCOMPLETE, INVALID_MODEL_FOLDER, BAD_REQUEST, GENERATION_FAILED. detail carries the
structured context (the evicted keys, the missing field, why a folder was rejected), so no caller
parses a message. Over the wire the code rides beside error, which stays a plain string.
Generation ops take messages, temperature, max_tokens, response_format and extra_body β the
OpenAI shape WebLLM already speaks β plus the scheduling fields task, session, priority and
preemptible, which are what this adds over calling WebLLM directly.
Over a wire β the WebExtension adapter
Only for the case where the engine and the caller are in different processes. Extension id:
everything-webgpu@local; the manager page prints a copy-pasteable version.
Two transports, one vocabulary (src/adapters/protocol.js):
browser.runtime.sendMessage(id, req)β request/response. Ops:status,listModels,load,unload,chat,batch,cancel,configure.browser.runtime.connect(id, { name: "everything-webgpu/v1" })β streaming. Ops:subscribe,chat.stream,batch.stream,abort; the port also pushesengineStateon every lifecycle change.
Every message carries protocol, so a stray message from another sender fails fast instead of being
half-interpreted. Generation ops accept modelId, temperature, max_tokens, response_format,
extra_body, plus the scheduling fields task, session, priority and preemptible.
Send raw requests. Do not ask for a translate op. The engine schedules a shared GPU; it does not
author prompts. Prompts belong to whoever owns the feature, because they are model-specific β switching this
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. Wrap the transport in a client-side helper
if you want translate() ergonomics β just keep it on your side of sendMessage.
The three shapes of work
What differs between these is not the op or the transport. It is who owns an engine, and what may
interrupt what. The examples below use the wire form; in-process the same fields go to complete() and
batch().
| op | priority | key fields | why | |
|---|---|---|---|---|
| Completion (ghost text) | chat.stream |
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) | chat |
background |
preemptible: true |
Nobody is watching; let interactive work cut in. |
Completion β latency is the whole product
const port = browser.runtime.connect("everything-webgpu@local", { name: "everything-webgpu/v1" });
port.onMessage.addListener((m) => {
if (m.op === "chunk") render(m.delta);
if (m.op === "done") finish(m.text);
});
// On every keystroke. The previous request is cancelled, not queued behind.
port.postMessage({
protocol: "everything-webgpu/v1",
op: "chat.stream",
id: crypto.randomUUID(),
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
messages: [{ role: "user", content: prefix }],
});
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 `chat` calls.
const res = await browser.runtime.sendMessage("everything-webgpu@local", {
protocol: "everything-webgpu/v1",
op: "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}` }],
})),
});
res.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. Results carry engineIndex, startedAt and
finishedAt, so a caller can check what actually ran where.
batch is still the right call rather than a loop of chat: it is one round trip, the engine keeps the
items in one queue it can reason about, and if this ever runs on a model small enough for fan-out to pay
again β or once batched decode lands β the same call gets faster with no change on your side.
Use batch.stream over a port instead if you want items as they land rather than one array at the end.
Reformat β cheap to interrupt
await browser.runtime.sendMessage("everything-webgpu@local", {
protocol: "everything-webgpu/v1",
op: "chat",
priority: "background",
preemptible: true, // the direction matters β see below
max_tokens: 2048,
messages: [{ role: "user", content: `Reformat as clean Markdown, no commentary:\n\n${doc}` }],
});
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, so it is never requeued and can never starve β but that also
means 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 status().growthBlocked if two are waiting and it still has not. |
By default every installed extension may call the API. The manager page has an allowlist field; fill it in with extension ids to restrict access.
Layout
| Path | Role |
|---|---|
| manifest.json | MV2, persistent background page, wasm-unsafe-eval CSP |
| src/engine/index.js | Public entry point of the library |
| src/engine/create.js | CreateScheduledEngine β the one-line swap for CreateMLCEngine |
| src/engine/chat.js | chat.completions.create(), the WebLLM/OpenAI facade |
| src/engine/environment.js | environment() β the read-only device/runtime report; writes are configure() |
| src/engine/errors.js | EngineError and the eight codes |
| src/engine/device.js | Hardware probe, canRun, model ranking |
| src/engine/engine.js | ScheduledEngine β the engine with no transport attached |
| src/engine/pool.js | Engine pool + priority scheduler |
| src/engine/engine-worker.js | One pool slot's engine, in its own realm |
| src/engine/multistep.js | Multi-step decoding: K forward steps per GPU sync |
| src/engine/sources.js | What load() was handed β pure dispatch across id / URL / spec / folder |
| src/engine/ingest.js | Folder validation and cache injection |
| src/engine/recipes.js | ask() / conversation() / ghostText() β the three shapes as one call each, scheduling only |
| src/engine/prefetch.js | prefetch() β fill the cache with no engine and no GPU; WebLLM's hasModelInCache is the oracle |
| src/engine/model-store.js | Cache layout, registry, settings, StorageAdapter, the three model sources |
| src/engine/constants.js | PRIORITY, ENGINE_STATE β engine vocabulary, transport-free |
| src/adapters/protocol.js | Wire protocol: PROTOCOL, OP, PORT_OP |
| src/adapters/webext.js | browser.storage.local + the message/port router |
| src/adapters/idb.js | IndexedDB StorageAdapter + ensurePersistent(), for pages |
| src/adapters/memory.js | In-memory StorageAdapter, for tests |
| src/background/background.js | The extension host: build an engine, attach the transport |
| src/popup/ | Minimal test chat (moves to demo in Phase 4) |
| src/manager/ | Drop target, registry, settings, setup help (moves to demo in Phase 4) |
| test/integration.test.mjs | The cache-injection contract |
| test/scheduler.test.mjs | Priority, supersession, preemption and pool growth, GPU-free |
| test/sources.test.mjs | How load() resolves prebuilt / remote / injected, in what order it refuses, and that a local model has no reachable URL |
| test/errors.test.mjs | That failures carry the right code, and that nothing throws an untyped Error |
| test/chat.test.mjs | That the WebLLM facade really is drop-in, shape by shape |
| test/device.test.mjs | The compatibility rules, and that blockers and warnings stay distinct |
| test/manage.test.mjs | The four model states, and that unload / evict / remove stay distinct |
| test/e2e/ | Real-hardware end-to-end run (npm run e2e) |
| test/e2e/bench.mjs | Standalone WebGPU sync-latency benchmark (npm run bench) |
| MLC-COMPILE.md | How the model was compiled, and every toolchain breakage on the way |
| tools/ | Model-compilation toolchain: setup, nightly patches, weight strip, wasm audits |
| WEBLLM-SURFACE.md | What WebLLM already does, what we add, and where the line is. Read before adding a capability; its "Upgrading" section is the dependency-bump runbook. |
| ROADMAP.md | The only list of open work |
| ARCHIVE.md | What was done and why β the extraction, the model sources, the de-duplication |
src/engine/ references no WebExtension API β asserted by a test, because that claim is only broken in the
host nobody ran. The three places the host used to leak in are injected: a StorageAdapter for the registry,
a worker URL, and the WebLLM import. src/adapters/ holds one implementation of each per host. See
ARCHIVE.md.
In this host the engine lives in the MV2 persistent background page β a real document on the extension
origin, so it has both navigator.gpu and the same Cache Storage the manager page writes to. The model stays
resident in VRAM across popup opens and across calls from other extensions.
Known limits
- AMO signing:
vendor/web-llm.jsis ~6 MB, overweb-ext lint's 5 MB parse limit. Fine for temporary install and self-distribution; it would need splitting before an AMO listing. - MV2: MV3 event pages get evicted, which would unload a multi-GB model between calls. Migrating needs a
keep-alive or a dedicated engine tab. This constrains
demoonly β the library is host-agnostic. - Storage eviction off an extension origin: an ordinary page has no
unlimitedStorage, so a multi-GB model is evictable untilensurePersistent()is granted. For a prebuilt or remote model that means a slow reload; for a locally-registered one it is fatal and it must be re-registered. Unmeasured: the exact quota and grant behaviour per browser. - Never run outside a Firefox extension: the library is written against capabilities rather than browsers, and should be faster on Chrome (KV reuse is not disabled there β see "Multi-step decoding"). Both claims are predictions. See ROADMAP.md, Gates A and B.
- Thinking burns the budget: this model opens every reply with a
<think>block β it is a reasoning distill and its card says so. At ~17 tok/s that is seconds of nothing before the answer starts. For translation and completion, suppress it in your prompt, or rebuild the config with theqwen3_5_nothinkconversation template. The engine deliberately does not rewrite prompts for you. - No cross-turn KV reuse: every turn re-prefills the whole history at 5.27 ms/token, so a long conversation pays ~22 s before its first token at the 4096 limit. See Current Tasks.
- Decode speed: was bounded by Firefox's 100 ms WebGPU poll timer (Mozilla bug 1870699). Multi-step decoding and compute-pass batching bought most of that back; what remains is memory bandwidth β decode achieves ~16 GB/s of the M4's ~120. See "The 10 tok/s ceiling" and "Why not llama.cpp/Ollama-class".
- A second engine is not more speed: measured 1.06x on this model. It buys isolation between tasks. Concurrent throughput needs batched decode inside one engine. See "Scheduling".