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. |