File size: 16,018 Bytes
1944112 3c72141 00a752e 3c72141 00a752e 3c72141 00a752e 1944112 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | # Roadmap β open work only
**This is the only list of open work.** Anything finished moves to [ARCHIVE.md](ARCHIVE.md) with the
reasoning that produced it. [AI.md](AI.md) is the reference for what is true and measured;
[API.md](API.md) is the full call surface, asserted against the code;
[WEBLLM-SURFACE.md](WEBLLM-SURFACE.md) is what WebLLM already does and must be read before adding a
capability.
Sections are named, not numbered. An earlier split across three files used "Track 1"/"Track 2" to
mean different things in each, which is how a reader ends up implementing the wrong item.
**Sequencing.** The whole project exists to make WebLLM *more compatible, easier to use, foolproof
to build on*. Every item below is ranked against that. `everything-webgpu@0.1.0` is on npm as of
this writing β the install path, the four-line demo, the ergonomic verbs, `API.md` and `examples/`
all shipped and are recorded in [ARCHIVE.md](ARCHIVE.md). Section 1 is what remains before the
extraction is fully proven: the extension itself rebuilding on the package. Section 4 (performance)
is frozen as a block, except for "Measure Ollama", until a real user calls it slow: it is the
highest-risk, highest-effort work and it re-touches the tvmjs internals the WebLLM-upgrade section
(also in [ARCHIVE.md](ARCHIVE.md)) just spent its time hardening.
---
## 1. Ship it β the last mile
`everything-webgpu@0.1.0` is on npm. What shipped β the install path, the four-line demo, the
ergonomic verbs, [API.md](API.md), [examples/](examples/), the bundle-size story, licence
compliance β is in [ARCHIVE.md](ARCHIVE.md), "Shipping 0.1.0". What remains is proving the boundary
from the other side.
- [ ] **`demo` consumes the package**, and the popup + manager leave `main`. The acceptance test for
the whole extraction: if the extension rebuilds on the published package, the boundary is
right. `npm run e2e` passing is the same claim at the source-tree level.
- [~] **Gate A β a bare Vite page loads a prebuilt model and generates.**
[examples/bare/](examples/bare/) exists and the GPU-free half is proven: `vite build` resolves
through the `exports` map and splits into an entry chunk, the decode worker as its own chunk,
and the 6 MB WebLLM bundle as a lazy one. What remains is **one run in a WebGPU browser** to
confirm it generates. `npm run verify-consumer` already exercises a real install end to end,
short of the actual generation.
- [ ] **Defect: Worker unbundled relative imports in production (`vite build` / Rollup blind spot).**
First real-browser run on a static host (Hugging Face Spaces) surfaced a packaging defect
that passed all prior headless assertions. On `vite build`, `engine.load()` threw
`PACKAGE_INCOMPLETE`: *"The decode worker failed to load from .../assets/engine-worker-*.js"*.
**Root cause:** Rollup's `new Worker(new URL(..., import.meta.url), { type: "module" })`
handling copies `engine-worker.js` as an asset chunk without bundling or rewriting its internal
ESM imports. The emitted worker still carries:
`import { WebWorkerMLCEngineHandler } from "../../vendor/web-llm.js"`
`import { WORKER_CONFIGURE } from "./constants.js"`
`import { DEFAULT_DECODE_STEPS } from "./multistep.js"`
On a static production deployment, these relative URLs resolve to 404s on the root domain,
killing the worker silently before handshake. `vite dev` hid this because `optimizeDeps.exclude`
served files directly from source where siblings exist. `test/consumer/verify.mjs` line 130
missed it because it asserted file *existence* (`assets.some(f => f.startsWith("engine-worker"))`),
never executing the worker in a browser.
**Resolution:**
- [x] **Demo workaround:** Pre-bundle `engine-worker.js` with `esbuild --bundle --format=esm` into a
standalone 6.58 MB file (`public/engine-worker.js`) with zero external imports, and pass explicit
`workerUrl: new URL("engine-worker.js", window.location.href)`.
- [ ] **Library permanent fix for 0.1.1:** In `build.mjs`, pre-bundle `engine-worker.js` into
a self-contained `dist/worker.bundle.js` at package build time. Point `DEFAULT_WORKER_URL`
at the bundle so zero consumer configuration or `workerUrl` is required. Update
`verify-consumer` with a headless browser fetch/execute assertion to prevent regressions.
- [ ] **Gate Aβ² β the same page ingests a local folder.** `cache.put()` against the synthetic
`local-model.invalid` key is proven only on an extension origin. Gates the *offline* route,
not the library.
- [ ] **Gate B β Chrome.** Measure tok/s. Expected to beat Firefox because KV reuse is not disabled
there. Entirely a prediction today; `probeDevice()` is the instrument that makes it reportable.
- [ ] **Every `ERROR` code carries an actionable `fix`.** The preflight (`environment()`) and the
typed errors (9 codes now, `PACKAGE_INCOMPLETE` included) both exist; what is not yet done is
the systematic pass confirming each code's `message`/`detail` names a cause *and* a fix, and
that the README/API.md point at `environment()` as the "will this run here?" call it is meant
to be.
## 2. Verb consolidation β done
`load(src, opts)`, `unload(id, level)` / `remove()` / `unloadAll()`, and `environment()` +
`environment.measure()` all shipped. The reasoning β the two dropped `load()` dispatch rules, why a
bare `unload()` frees only the current model, why read and write are split β is in
[ARCHIVE.md](ARCHIVE.md), "Verb consolidation". `chat.completions.create()` is unchanged and stays
that way.
## 3. Engine capability
`prefetch()`, `embed()` / `embedRaw()`, and the `ask()` / `conversation()` / `ghostText()` recipes
shipped β see [ARCHIVE.md](ARCHIVE.md), "Engine capability". One item is still open:
- [ ] **LRU eviction** when quota is short. `cacheState()` and `evict()` exist; nothing yet decides
*what* to drop. Table stakes for "it just works" the moment a second model is cached β but the
eviction policy is a decision, not an implementation detail.
## 4. Performance β frozen, except the measurement
Model- and kernel-level work, independent of the library structure β and therefore independent of
everything this project is *for*. **Frozen as a block until a real user calls it slow.** The library
ships now, so "nobody can install it" is no longer the argument; the argument is that this is the
highest-risk, highest-effort work on the page, it re-touches the tvmjs internals the WebLLM-upgrade
section (in [ARCHIVE.md](ARCHIVE.md)) just spent its time hardening, and a speedup nobody has asked
for is risk without a return. The one exception is "Measure Ollama": it is cheap, and it decides
whether any of the rest is worth its risk.
- [ ] **Retune the dlight GEMV schedule** β more work per thread before the reduction. Measured
**1.83x** in isolation, which would put decode near the ~41 GB/s dequant ceiling. Confirmed
untaken on the 2B build. Needs a recompile; toolchain is stood up.
- [ ] **Batched decode.** The model lib exports `batch_decode` / `batch_prefill` / `batch_verify`
and a paged KV cache; WebLLM hardcodes `defaultMaxNumSequence = 1` and `numSamples = 1`
(bundle lines 15250, 15276). Lifting that reads the weights once per step for N sequences β
projected **~4x on the `batch` API**, which is the page-translation shape and the one workload
the engine currently makes no faster than a loop of `chat` calls. No recompilation needed.
**Not** a fix for the 1.06x second-engine measurement: a second engine buys task isolation,
never aggregate throughput, and the two are complementary rather than alternatives.
- [ ] **Interrupt granularity in `sampleBurst`.** `interruptGenerate()` only sets a flag the
caller's loop reads between `decode()` calls, and a burst runs all K forward steps without
checking it β so a preempted job finishes its whole burst first (~583 ms at K=15). It binds in
three cases: the seconds-long window while `#grow()` builds a second engine, a machine where
`#growthBlocked` is set, and any third concurrent task once the pool is at cap. Fix in our
`multistep.js`, not WebLLM; `discardLookahead` already handles the resulting state.
- [ ] **Auto-tune `decodeSteps` from the burst probe.** K=15 is a constant derived from one machine
and one model β Firefox on Apple Silicon, `Qwen3.5-0.8B` at ~7.3 ms/step. `multistep.js`'s own
header says the best K falls as the model grows (a 25 ms/step model wants K=4), so the shipped
default is wrong for every model but the one it was measured on, and wrong silently: a bad K
costs throughput and reports nothing.
**The infrastructure is already there.** `onBurst` delivers `{ steps, tokens, ms, encodeMs,
syncMs, dispatches, forwardDispatches, flushes }` per burst
([multistep.js:246](src/engine/multistep.js#L246)), `setSteps()` is hot
([multistep.js:259](src/engine/multistep.js#L259)), and `WORKER_CONFIGURE` already calls
`resetStats()` on every retune so "a sweep's points never bleed into each other"
([engine-worker.js:137](src/engine/engine-worker.js#L137)). It was built for this.
**The obvious derivation does not work.** `K = floor(tickMs / perStepMs)` needs `perStepMs`, and
the probe cannot separate it: `syncMs` is GPU execution *plus* the wait for the poll tick, which
is the quantity being solved for. Only `encodeMs` is clean β the K-step loop contains no `await`,
so it is content-process CPU alone β and it is the smaller half.
**What works instead** is a hill-climb on the one quantity directly observed, `tokens / ms`.
Throughput at K is `K / (ceil(KΒ·p/tick)Β·tick)`; its peaks sit at `K = floor(nΒ·tick/p)` and are
asymptotically equal in n, so the first peak is the target and climbing K until throughput drops
finds it without ever naming `p` or `tick`. That is also what makes it survive Chrome, where the
100 ms tick premise does not hold.
**Three subtleties that will bite.** (1) `onBurst.steps` is the *clamped* burst size β
`burstSize()` cuts it by `max_tokens` and the context window
([multistep.js:297](src/engine/multistep.js#L297)) β so the tuner must learn only from bursts
where `steps === config.steps`, or it learns from end-of-generation stubs. (2) The first burst on
a pipeline carries shader compilation; skip it. (3) `clampSteps("auto")` is **1** today
(`Number("auto")` β NaN β `|| 1`), so an `"auto"` sentinel silently *disables* multi-step unless
`clampSteps` and [API.md](API.md)'s documented `1`β`32` range change together β
`api-doc.test.mjs` asserts the two agree.
**Sequencing.** Composes with per-priority K below rather than replacing it: this finds the
throughput ceiling K_max for the device+model, per-priority then spends it (`interactive` takes
min(K_max, 4), `background` takes K_max). Do this one first β per-priority is written against the
15 this replaces. Unlike the rest of section 4 it touches **no new tvmjs internals**: it reads
telemetry that already flows and calls a setter that already exists, which is the argument for
lifting the freeze on this item alone. It cannot be *validated* here, though β the sawtooth only
exists on real hardware, and Gate B (Chrome) is what proves the controller is finding a real peak
rather than re-deriving 15.
- [ ] **Per-priority `decodeSteps`.** K=15 maximises throughput but emits 15 tokens every ~583 ms,
which reads as a stall. `interactive` should use K=2β4, `background`/`batch` K=32. Do this
*after* the interrupt fix β raising K for background work lengthens exactly the bursts that
preemption has to wait out.
- [ ] **Restore cross-turn KV reuse.** Every turn re-prefills the whole history at **5.27 ms/token**,
so a turn near the 4096 limit pays ~22 s before its first token. Pack
`batch_prefill_paged_kv_kernel`'s six i32 metadata buffers into one with offsets (10 bindings
β 5); the offsets already exist in its uniform block.
## 5. Test and infrastructure
Both are "fix when it next bites", not scheduled work. The completed e2e run that used to sit here
is in [ARCHIVE.md](ARCHIVE.md), including the `storageBuffersPerStage=9` anomaly worth re-checking
on the next run.
- [ ] Fix `PROFILE_PATH` in [test/e2e/run.mjs](test/e2e/run.mjs): it passes `--profile-path`, but
web-ext 8 calls it `--firefox-profile` and exits with `Unknown arguments`.
- [~] **Isolate the bench's pass-sweep onto its own device** β done, and it **did not do what the
item assumed**. 2048 compute passes in one encoder loses the WebGPU device, and a lost device
does not throw: later calls silently no-op.
Two corrections from measuring it. First, the stated worry (later probes poisoned) was empty
β the sweep was already the last phase, so nothing ran after it. The real damage was to the
sweep's **own** numbers: the run that prompted this reported `perPass=-3.9us`, 512 passes
measured faster than one, which is the device dying mid-sweep and the remaining submits
becoming free. That half is fixed β `n512` now reads a plausible ~10us and `n2048` reports
`discarded (device lost during the 2048-pass encode)` instead of inventing a number.
Second, **isolation does not contain the loss.** With the sweep on its own device,
`deviceLostDuringBench` still reports the main device lost during this phase, on both runs. A
runaway command buffer on Metal appears to reset the whole adapter rather than one device on
it. So the honest state is: sweep numbers are now trustworthy, containment is not achieved,
and the bench is safe only because this is the last phase.
Still open, if containment is wanted: run the sweep in a dedicated worker or a separate page
so the reset cannot reach the measuring context at all. Also worth noting the attribution is
imprecise β `lostDuring` records the phase at which `device.lost` *resolves*, and the
16384-dispatch probe immediately above is another plausible culprit.
## 6. Deferred, with the condition that would reopen them
| Item | Reopens when |
| --- | --- |
| **Vision / image understanding** | An in-house compact VLM exists. The prebuilt option is `Phi-3.5-vision` at 3.95 GB, projecting ~4β5 tok/s and not co-resident with a text model in 16 GB. `modelType: "vlm"` already flows through registration, so the compile lands on an engine that accepts it. Open questions to answer against the real model: whether multi-step decoding survives an image prefill, and whether the Firefox 9-storage-buffer workaround holds for the vision tower's kernels. |
| **MV3 migration** | `demo` is forced off MV2. `CreateExtensionServiceWorkerMLCEngine` solves the messaging and in-work keep-alive but **not** the actual problem β an idle service worker is killed and takes the resident multi-GB model with it. Use the native helper for messaging; hold the engine in an offscreen document, persistent page, or dedicated tab. Also verify WebGPU is exposed in a Chrome extension SW at all. |
| **SRI / `verifyIntegrity`** | Self-hosted models are hardened, or corruption is actually observed. `ModelStore.verify()` checks key *presence*; `verifyIntegrity` checks *content* β different problems. Use the native one; do not hand-roll a hash check. |
| **Multi-model via `reload([...])`** | Memory pressure shows up before scheduling contention does. Rejected for now: `reload()` is all-or-nothing, so adding a third model to `{A, B}` reloads A and B too (~51 s each). Additive residency is a hard requirement and only `#pools` provides it. |
|