feat: publish everything-webgpu package, engine source and documentation
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- AI.md +940 -0
- API.md +367 -0
- ARCHIVE.md +493 -0
- LICENSE +21 -0
- MLC-COMPILE.md +292 -0
- README.md +496 -0
- ROADMAP.md +181 -0
- THIRD-PARTY-NOTICES.md +267 -0
- WEBLLM-SURFACE.md +134 -0
- package.json +72 -0
- src/adapters/idb.js +84 -0
- src/adapters/memory.js +22 -0
- src/adapters/protocol.js +57 -0
- src/adapters/webext.js +183 -0
- src/background/background.html +4 -0
- src/background/background.js +22 -0
- src/engine/chat.js +142 -0
- src/engine/constants.js +68 -0
- src/engine/create.js +138 -0
- src/engine/device.js +279 -0
- src/engine/engine-worker.js +142 -0
- src/engine/engine.js +1336 -0
- src/engine/environment.js +295 -0
- src/engine/errors.js +85 -0
- src/engine/index.js +89 -0
- src/engine/ingest.js +294 -0
- src/engine/model-store.js +371 -0
- src/engine/multistep.js +607 -0
- src/engine/pool.js +439 -0
- src/engine/prefetch.js +200 -0
- src/engine/recipes.js +281 -0
- src/engine/sources.js +197 -0
- src/manager/manager.css +83 -0
- src/manager/manager.html +122 -0
- src/manager/manager.js +350 -0
- src/popup/popup.css +50 -0
- src/popup/popup.html +40 -0
- src/popup/popup.js +235 -0
- src/ui.css +108 -0
- src/vite.js +71 -0
- tsconfig.json +31 -0
- types/adapters/idb.d.ts +18 -0
- types/adapters/memory.d.ts +10 -0
- types/adapters/protocol.d.ts +28 -0
- types/adapters/webext.d.ts +15 -0
- types/engine/chat.d.ts +18 -0
- types/engine/constants.d.ts +26 -0
- types/engine/create.d.ts +24 -0
- types/engine/device.d.ts +142 -0
- types/engine/engine.d.ts +582 -0
AI.md
ADDED
|
@@ -0,0 +1,940 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Everything WebGPU — project harness
|
| 2 |
+
|
| 3 |
+
## Project Vision
|
| 4 |
+
An embeddable local-LLM engine for the browser: WebLLM on WebGPU, plus a priority scheduler, multi-step
|
| 5 |
+
decoding and compute-pass batching that together take decode from 9.7 to 25.9 tok/s. `main` is the library —
|
| 6 |
+
host-neutral, no UI, usable from a page, a worker or an extension. `demo` keeps the Firefox WebExtension
|
| 7 |
+
that was the original vehicle and is now the library's first consumer. See [ARCHIVE.md](ARCHIVE.md).
|
| 8 |
+
|
| 9 |
+
Weights arrive by whichever route suits the app: WebLLM's 163 prebuilt models from HuggingFace, any base URL
|
| 10 |
+
the developer hosts, or a local folder read off disk with no network connection at any point.
|
| 11 |
+
|
| 12 |
+
**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](MLC-COMPILE.md).
|
| 13 |
+
|
| 14 |
+
## Workflow & Development Principles
|
| 15 |
+
- **Fail Fast**: Validate inputs, model states, and cache availability early. Throw descriptive errors immediately upon invalid conditions.
|
| 16 |
+
- **Minimal Surface**: Write only necessary code and tests. Prefer a single, comprehensive integration test over redundant unit tests.
|
| 17 |
+
- **Reuse First**: Leverage existing internal APIs, built-in libraries, and ecosystem patterns (e.g., WebLLM, Cache API) before introducing net-new abstractions.
|
| 18 |
+
- **Direct Execution**: Output exact code changes or direct answers. Omit preamble, pleasantries, conversational fillers, and unsolicited caveats.
|
| 19 |
+
|
| 20 |
+
## Where the work lives
|
| 21 |
+
|
| 22 |
+
| | |
|
| 23 |
+
| --- | --- |
|
| 24 |
+
| [README.md](README.md) | The developer-facing entry point. Asserted by [test/readme.test.mjs](test/readme.test.mjs), so its examples cannot drift from the API. |
|
| 25 |
+
| [API.md](API.md) | Every call form, one page. Asserted by [test/api-doc.test.mjs](test/api-doc.test.mjs) — method names, error codes, enum values and the export list are all derived from the source. |
|
| 26 |
+
| [ROADMAP.md](ROADMAP.md) | **The only list of open work.** |
|
| 27 |
+
| [ARCHIVE.md](ARCHIVE.md) | What was done and *why* — decisions with their reasoning, so they are not re-litigated. |
|
| 28 |
+
| [WEBLLM-SURFACE.md](WEBLLM-SURFACE.md) | What WebLLM already does. **Read before adding a capability**, and follow its "Upgrading" runbook on every dependency bump. |
|
| 29 |
+
| This file | What is true and measured. Reference, not a plan. |
|
| 30 |
+
|
| 31 |
+
Task lists used to live here, in AI2.md and in NATIVE-REUSE-PLAN.md at the same time, with "Track 1"
|
| 32 |
+
and "Track 2" meaning different things in each. That is how a reader implements the wrong item, and
|
| 33 |
+
it is the same shape of failure — no single place to look before acting — that produced the
|
| 34 |
+
duplicated WebLLM helpers ARCHIVE.md records.
|
| 35 |
+
|
| 36 |
+
## Completed Tasks
|
| 37 |
+
|
| 38 |
+
Through the in-house model compile. Everything after that — the library extraction, the model
|
| 39 |
+
sources, the WebLLM de-duplication — is in [ARCHIVE.md](ARCHIVE.md) with its reasoning.
|
| 40 |
+
|
| 41 |
+
- [x] Defined core requirements for local WebGPU-based execution in Firefox on macOS.
|
| 42 |
+
- [x] Established direct cache-injection architecture for offline local models.
|
| 43 |
+
- [x] Configured `manifest.json` (MV2, persistent background page, `wasm-unsafe-eval` CSP, `unlimitedStorage`) and documented the `about:config` flags in the manager page.
|
| 44 |
+
- [x] Implemented drag-and-drop model folder ingestion that writes straight into `Cache Storage` under WebLLM's own scopes and keys.
|
| 45 |
+
- [x] Built the minimal test chat popup driven by the background WebLLM engine.
|
| 46 |
+
- [x] Exposed the `everything-webgpu/v1` message + port API for other Firefox extensions.
|
| 47 |
+
- [x] Verified end-to-end on real hardware with `Qwen3.5-0.8B-q4f16_1-MLC` (Firefox 154, macOS, M4).
|
| 48 |
+
- [x] Root-caused decode throughput to Firefox's 100 ms WebGPU poll timer (Mozilla bug 1870699), not to anything in this extension.
|
| 49 |
+
- [x] Replaced the fail-fast `busy` flag with a priority scheduler: bands, session supersession, opt-in preemption.
|
| 50 |
+
- [x] Added a worker-backed engine pool and a `batch` API so independent work runs concurrently.
|
| 51 |
+
- [x] 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.
|
| 52 |
+
- [x] 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.
|
| 53 |
+
- [x] 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.
|
| 54 |
+
- [x] 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).
|
| 55 |
+
- [x] Compiled `Qwen3.8-2B-Distill` to 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](MLC-COMPILE.md) and `tools/`.
|
| 56 |
+
- [x] 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.
|
| 57 |
+
- [x] Surfaced the model-load report in the UI. WebLLM's text was always being passed through, but `#status` was a single ellipsised line, so it was truncated to `Loading model from cache[26/5…`.
|
| 58 |
+
|
| 59 |
+
## Consolidated Context
|
| 60 |
+
- **Target Platform**: Firefox WebExtension (macOS, requiring WebGPU flags). Test machine: M4 MacBook Air, 16 GB unified memory, ~120 GB/s.
|
| 61 |
+
- **Core Stack**: JavaScript, WebGPU, WebLLM (`@mlc-ai/web-llm` 0.2.84, patched at build time), Cache API (for local file injection), Extension Message Passing.
|
| 62 |
+
- **Architecture**: A host-neutral engine (`src/engine/`, asserted free of any WebExtension API) behind
|
| 63 |
+
per-host adapters (`src/adapters/`). The Firefox extension is one host: background engine host + popup test
|
| 64 |
+
UI + extension-to-extension API provider. See [ARCHIVE.md](ARCHIVE.md).
|
| 65 |
+
- **Model**: `Qwen3.8-2B-q4f16_1` (1.06 GB), compiled in-house from `empero-ai/Qwen3.8-2B-Distill` — see [MLC-COMPILE.md](MLC-COMPILE.md). `Qwen3.5-0.8B-q4f16_1-MLC` remains the baseline most of the analysis below was measured on. Keep `q4f16_1` — dequantisation measured ~free, so wider formats only add bytes, and bytes are what decode pays for.
|
| 66 |
+
- **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.
|
| 67 |
+
- **Build-time patches** ([build/patches.mjs](build/patches.mjs), applied by [build.mjs](build.mjs)): `storage-buffer-limit` (Firefox caps storage buffers per stage at 9, tvmjs asks for 10) and `compute-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-patches` checks them without rebuilding; `NO_PASS_MERGE=1` skips the second patch for A/B.
|
| 68 |
+
|
| 69 |
+
---
|
| 70 |
+
|
| 71 |
+
## Verified
|
| 72 |
+
|
| 73 |
+
All numbers on an M4 MacBook Air (16 GB), Firefox 154 release, macOS.
|
| 74 |
+
|
| 75 |
+
### The shipping model: `Qwen3.8-2B-q4f16_1` (1.06 GB, 26 shards)
|
| 76 |
+
|
| 77 |
+
Compiled in-house; see [MLC-COMPILE.md](MLC-COMPILE.md).
|
| 78 |
+
|
| 79 |
+
| | |
|
| 80 |
+
| --- | --- |
|
| 81 |
+
| Ingest 1.06 GB into Cache Storage | 4.8 s |
|
| 82 |
+
| Model load (cache only, zero network) | 51 s |
|
| 83 |
+
| Prefill | 48 tok/s short prompt, 100-200 tok/s at length |
|
| 84 |
+
| **Decode** | **16.6-18.1 tok/s** |
|
| 85 |
+
| Re-prefill cost per history token | 5.27 ms (no cross-turn KV reuse — see Current Tasks) |
|
| 86 |
+
| Kernel launches per decoded token | 664 = 639 forward + 25 sampling, across ~16 flushes |
|
| 87 |
+
| Second engine, 4-prompt batch | **1.06x** — see "Scheduling" |
|
| 88 |
+
| 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. |
|
| 89 |
+
|
| 90 |
+
### The baseline it was built against: `Qwen3.5-0.8B-q4f16_1` (443 MB, 11 shards)
|
| 91 |
+
|
| 92 |
+
Everything below this point was measured on the 0.8B. It is kept because it is where the
|
| 93 |
+
architecture came from — the ceiling, the multi-step fix and the pass-batching fix were all found here.
|
| 94 |
+
|
| 95 |
+
| | |
|
| 96 |
+
| --- | --- |
|
| 97 |
+
| WebGPU in the MV2 background page | available, `shader-f16` supported |
|
| 98 |
+
| Ingest 443 MB into Cache Storage | 2.1 s |
|
| 99 |
+
| Model load (cache only, zero network) | 48 s |
|
| 100 |
+
| Prefill | 95-98 tok/s |
|
| 101 |
+
| Decode, stock single-step | 9.6 tok/s (warm) |
|
| 102 |
+
| Decode, multi-step K=15, one pass per kernel | 17.3-18.3 tok/s |
|
| 103 |
+
| 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) |
|
| 104 |
+
| Decode, K=32 + batched passes | 26.8 tok/s |
|
| 105 |
+
| Kernel launches per decoded token | 664, across ~16 flushes |
|
| 106 |
+
| Decode budget at K=16 | 3.8 ms CPU encode + ~34 ms GPU + <6 ms tick |
|
| 107 |
+
|
| 108 |
+
Reproduce with `npm run e2e`, or `ENGINE_COUNT=1 DECODE_STEPS=1,2,4,8,16,24 npm run e2e` for the multi-step
|
| 109 |
+
curve — see [test/e2e/run.mjs](test/e2e/run.mjs).
|
| 110 |
+
|
| 111 |
+
Three things that surfaced from running it for real:
|
| 112 |
+
|
| 113 |
+
- **WebGPU works in the background page.** This was the load-bearing assumption behind putting the engine
|
| 114 |
+
there, and it holds on release Firefox.
|
| 115 |
+
- **Neither the poll tick nor CPU encoding is the ceiling.** Both measured small once instrumented. What was
|
| 116 |
+
left split in two: 664 per-kernel compute passes (fixed, ~2.5x) and, underneath, decode running at ~13% of
|
| 117 |
+
memory bandwidth (open). See "Where the 46 ms goes" and "Still not 100+ tok/s".
|
| 118 |
+
- **Firefox needed a shim.** tvmjs hardcodes a request for 10 storage buffers per shader stage; Firefox's
|
| 119 |
+
Metal backend caps `maxStorageBuffersPerShaderStage` at 9, so `detectGPUDevice()` threw before a device was
|
| 120 |
+
ever requested. `build.mjs` clamps that request to what the adapter reports, and fails the build loudly if
|
| 121 |
+
the patch stops matching after a WebLLM upgrade. Kernels that genuinely need a 10th binding still fail at
|
| 122 |
+
pipeline creation — **silently**, as a no-op dispatch that emits garbage rather than an error. Both models
|
| 123 |
+
ship four such kernels; three are unreachable by config and the fourth, `batch_prefill_paged_kv_kernel`, is
|
| 124 |
+
kept off the live path by `engine-worker.js` calling `resetChat()` before every prefill when the device
|
| 125 |
+
reports fewer than 10. `node tools/audit-wasm.mjs <folder>` checks this.
|
| 126 |
+
|
| 127 |
+
### The 10 tok/s ceiling
|
| 128 |
+
|
| 129 |
+
Decode is capped by Firefox, not by this extension or by the GPU. [`WebGPUParent`](https://searchfox.org/firefox-main/source/dom/webgpu/ipc/WebGPUParent.cpp)
|
| 130 |
+
detects GPU completion by polling on a fixed timer:
|
| 131 |
+
|
| 132 |
+
```cpp
|
| 133 |
+
const uint64_t POLL_TIME_MS = 100;
|
| 134 |
+
mTimer.Start(base::TimeDelta::FromMilliseconds(POLL_TIME_MS), this, &WebGPUParent::MaintainDevices);
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
So every `onSubmittedWorkDone()` / `mapAsync()` resolves only on a 100 ms tick. WebLLM needs exactly one
|
| 138 |
+
GPU->CPU sync per token (it reads back the 4-byte sampled token id before it can compute the next one), and a
|
| 139 |
+
serial sync loop always lands just after a tick and waits the full period. One token per tick = 9.6 tok/s.
|
| 140 |
+
|
| 141 |
+
`npm run bench` (no extension, no model, plain page) measures this directly:
|
| 142 |
+
|
| 143 |
+
| measurement | result | meaning |
|
| 144 |
+
| --- | --- | --- |
|
| 145 |
+
| `idleSyncMs` | 104 ms | awaiting an **empty** queue with nothing submitted still costs a full tick |
|
| 146 |
+
| `dispatch256Ms` | 104 ms | 256 dispatches **sharing one compute pass and one bind group** cost the same as zero |
|
| 147 |
+
| `pipelined10SubmitsMs` | 104 ms | 10 submits, one await — it is latency, not throughput |
|
| 148 |
+
| `syncAfterRandomDelay` | mean 52 ms, min 3, max 106 | desynchronize from the tick and you get the expected uniform 0-100 ms |
|
| 149 |
+
| `n2048` | `1pass=104ms 2048passes=309ms` | a compute *pass* costs ~100 µs |
|
| 150 |
+
| `dispatch65536InOnePassMs` | 207 ms | a *dispatch* inside a pass costs ~2 µs — measured past the tick, not bounded by it |
|
| 151 |
+
|
| 152 |
+
Read the `dispatch256Ms` row carefully — reusing one pass and one bind group is **not** what the runtime does,
|
| 153 |
+
and reading it as "compute is free" is what produced the wrong conclusion corrected below.
|
| 154 |
+
|
| 155 |
+
WebLLM's own `enable_latency_breakdown` agrees: `totalTime=101.2ms`, of which `sampleTime=101.1ms` — the step
|
| 156 |
+
containing the sync is the entire token budget. Identical numbers in a hidden background page, a visible tab,
|
| 157 |
+
and an ordinary web page, with and without `MOZ_DISABLE_GPU_PROCESS=1`, so it is not extension-specific and
|
| 158 |
+
not GPU-process IPC.
|
| 159 |
+
|
| 160 |
+
> **Correction.** This section used to claim actual compute was "~7 ms/token (≈137 tok/s if the sync were
|
| 161 |
+
> free)". That was wrong, and it was wrong in a way that mattered: the 7 ms came from `dispatch256Ms -
|
| 162 |
+
> dispatch1Ms`, a micro-benchmark of trivial kernels sharing one compute pass and one bind group — nothing
|
| 163 |
+
> like a real forward pass. Removing the per-token sync (see "Multi-step decoding") took decode to 18.4 tok/s,
|
| 164 |
+
> not 137, because real per-token GPU work is ~46 ms. See "Where the 46 ms goes".
|
| 165 |
+
|
| 166 |
+
This is [Mozilla bug 1870699, "Don't poll WebGPU from a timer"](https://bugzilla.mozilla.org/show_bug.cgi?id=1870699),
|
| 167 |
+
still open on trunk. Event-driven completion in Firefox would fix it outright. Short of that, the lever is to
|
| 168 |
+
stop needing a sync per token — see "Multi-step decoding" below, which does exactly that and lifts a single
|
| 169 |
+
stream well past 10 tok/s.
|
| 170 |
+
|
| 171 |
+
**Practical consequence:** the tick sets a *floor* of ~10 tok/s on serial decode, and multi-step lifts a
|
| 172 |
+
single stream off it — but only to ~18 tok/s, where a second, larger cost takes over. Prefill is unaffected
|
| 173 |
+
(one sync per forward pass regardless of token count), so prompt processing, embeddings and short completions
|
| 174 |
+
were never the problem.
|
| 175 |
+
|
| 176 |
+
### Multi-step decoding
|
| 177 |
+
|
| 178 |
+
The ceiling above is a *sync* budget, so the fix is to spend fewer syncs: run K forward steps per sync. This
|
| 179 |
+
is vLLM's [`--num-scheduler-steps`](https://blog.vllm.ai/2024/09/05/perf-update.html) (v0.6.0, +28% on
|
| 180 |
+
Llama-70B / 4xH100), and it lands here without recompiling any model, because WebLLM's sampling path is
|
| 181 |
+
already entirely on the GPU. `softmax_with_temperature` -> `argsort_probs` -> `sample_with_top_p` returns an
|
| 182 |
+
int32[1] **device** tensor, and `Tensor.copyFrom(Tensor)` is a device-to-device copy — so the sampled id can
|
| 183 |
+
be fed straight back into `embed` without ever becoming a JS number:
|
| 184 |
+
|
| 185 |
+
```
|
| 186 |
+
embed -> decode -> penalties -> softmax -> argsort -> sample ─┐ x K, no sync
|
| 187 |
+
^ │
|
| 188 |
+
└──��───────────── device tensor, never read back ───────────┘
|
| 189 |
+
then: one device.sync()
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
Each step stages its id into its own CPU tensor; tvmjs queues those readbacks into `pendingGPUToCPUCopy` and
|
| 193 |
+
only awaits them in `sync()`, so K readbacks still cost one tick. `steps` defaults to **15**, vLLM's documented
|
| 194 |
+
cap. [src/background/multistep.js](src/background/multistep.js) is the whole implementation; it lives in the
|
| 195 |
+
engine worker because that is where the decode loop is.
|
| 196 |
+
|
| 197 |
+
**Measured, single engine, Qwen3.5-0.8B on an M4 Air.** `DECODE_STEPS=... npm run e2e` sweeps K live on one
|
| 198 |
+
loaded model and reports the probe built into the burst — the K-step loop contains no `await`, so timing
|
| 199 |
+
either side of its single sync partitions the budget exactly:
|
| 200 |
+
|
| 201 |
+
| K | tok/s | cpu-encode | gpu + tick | kernels/tok |
|
| 202 |
+
| --- | --- | --- | --- | --- |
|
| 203 |
+
| 1 (stock) | 9.7 | — | — | — |
|
| 204 |
+
| 2 | 11.5 | 4.3 ms | 82.1 ms | 664 |
|
| 205 |
+
| 4 | 16.0 | 4.8 ms | 57.6 ms | 664 |
|
| 206 |
+
| 8 | 16.8 | 4.5 ms | 54.8 ms | 664 |
|
| 207 |
+
| 16 | 17.8 | 4.2 ms | 52.1 ms | 664 |
|
| 208 |
+
| 24 | **18.4** | 4.2 ms | 50.1 ms | 664 |
|
| 209 |
+
|
| 210 |
+
The curve is smooth and monotone, saturating near 18-19 tok/s: **+90% over stock at the shipped default of
|
| 211 |
+
15**. There is no sawtooth — an earlier version of this section predicted one from tick quantization, which
|
| 212 |
+
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
|
| 213 |
+
tick is already amortized to under 6 ms/token and there is nothing left to win.
|
| 214 |
+
|
| 215 |
+
### Where the 46 ms goes
|
| 216 |
+
|
| 217 |
+
Subtracting the amortized tick from the `gpu + tick` column leaves ~46 ms/token of real GPU time, against a
|
| 218 |
+
memory-bandwidth floor of ~3.5 ms (420 MB of q4f16 weights read once per token at ~120 GB/s). So ~93% of
|
| 219 |
+
decode is overhead, and the probes say which:
|
| 220 |
+
|
| 221 |
+
| candidate | measured | verdict |
|
| 222 |
+
| --- | --- | --- |
|
| 223 |
+
| the 100 ms poll tick | <6 ms/token at K≥16 | amortized away by multi-step |
|
| 224 |
+
| CPU command encoding, `createBindGroup`, IPC | 4.2 ms/token, flat in K | **not** the bottleneck |
|
| 225 |
+
| GPU execution | ~46 ms/token over **664 kernel launches** | this is it, at ~70 µs/kernel |
|
| 226 |
+
|
| 227 |
+
664 kernels per token is ~24 per layer — an unfused graph — and tvmjs opens a **separate compute pass for
|
| 228 |
+
every one of them** (`submitShader`: `beginComputePass` → `setPipeline` → `createBindGroup` → `dispatch` →
|
| 229 |
+
`end`). WebGPU barriers between passes, and on Metal each pass is its own command encoder. `npm run bench`
|
| 230 |
+
isolates the cost:
|
| 231 |
+
|
| 232 |
+
| measurement | result |
|
| 233 |
+
| --- | --- |
|
| 234 |
+
| 2048 dispatches, **one** compute pass | 104 ms (i.e. free — it is all tick) |
|
| 235 |
+
| 2048 dispatches, **2048** compute passes | 309 ms → ~100 µs per pass |
|
| 236 |
+
| CPU cost of encoding one tvm-style kernel | 3.9 µs |
|
| 237 |
+
|
| 238 |
+
So the pass is ~25x its own encode cost, and it is charged 664 times per token — while only **16 flushes per
|
| 239 |
+
token** actually happen, i.e. ~41 consecutive launches share an encoder and each open their own pass for no
|
| 240 |
+
reason. Dispatches *within* one WebGPU compute pass are already ordered with implicit barriers (a compute
|
| 241 |
+
pass has a usage scope per dispatch, so implementations must synchronize between them), so those passes were
|
| 242 |
+
not buying correctness.
|
| 243 |
+
|
| 244 |
+
### Batching the compute passes
|
| 245 |
+
|
| 246 |
+
The `compute-pass-batching` patch in [build/patches.mjs](build/patches.mjs) rewrites tvmjs to open a pass
|
| 247 |
+
lazily and close it in `flushCommands()` — already the one chokepoint every operation that cannot run
|
| 248 |
+
mid-pass routes through. That turns 664 passes/token into ~16. Three edits, each refusing to apply if its
|
| 249 |
+
anchor stops matching after a WebLLM upgrade, same as the storage-buffer shim next to it. The `compute.end();`
|
| 250 |
+
anchor is scoped to the function the pass is opened in, so an unrelated compute pass elsewhere in tvmjs is not
|
| 251 |
+
mistaken for ambiguity. Build with `NO_PASS_MERGE=1` to skip it and A/B on one machine.
|
| 252 |
+
|
| 253 |
+
Measured on the same 127-token greedy generation (`temperature: 0`, single engine, K=15):
|
| 254 |
+
|
| 255 |
+
| | stock WebLLM | + multi-step | + pass batching |
|
| 256 |
+
| --- | --- | --- | --- |
|
| 257 |
+
| decode | 9.7 tok/s | 10.3 tok/s | **25.9 tok/s** |
|
| 258 |
+
| `gpu + tick` | — | 85.2 ms/tok | **33.8 ms/tok** |
|
| 259 |
+
| `cpu-encode` | — | 5.1 ms/tok | 4.4 ms/tok |
|
| 260 |
+
| kernels/token | 664 | 664 | 664 |
|
| 261 |
+
| compute passes/token | 664 | 664 | **~16** |
|
| 262 |
+
|
| 263 |
+
Output is **byte-identical** across the A/B (490 bytes, greedy) — the check that matters, since the whole
|
| 264 |
+
change rests on WebGPU synchronizing dispatches inside a pass.
|
| 265 |
+
|
| 266 |
+
One caveat on the numbers: the unbatched build is *noisy* run to run (K=16 measured anywhere from 10.7 to
|
| 267 |
+
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
|
| 268 |
+
ms across five runs). The table above is a same-session pair, and its baseline happened to land on the slow
|
| 269 |
+
side. Against the *best* baseline seen the gain is ~1.4x rather than 2.5x; against the median, ~1.8x. The
|
| 270 |
+
direction and the mechanism are not in doubt, but quote a range, not the 2.5x.
|
| 271 |
+
|
| 272 |
+
The K curve keeps the same shape, shifted up, and still has no sawtooth: 2 → 19.3, 8 → 23.8, 16 → 25.6,
|
| 273 |
+
32 → 26.8 tok/s. `DEFAULT_DECODE_STEPS` stays at 15 (25.6 tok/s); K=32 buys another 4.7% for double the
|
| 274 |
+
transient buffers and more lookahead discarded at every stop token. K=1 is unchanged at 9.7 — with GPU time
|
| 275 |
+
now ~34 ms it fits inside a tick, so single-step is purely tick-bound and only multi-step can help it.
|
| 276 |
+
|
| 277 |
+
### Still not 100+ tok/s, and it is not the launch count
|
| 278 |
+
|
| 279 |
+
~34 ms/token of GPU remains. It is tempting to blame the 664 launches, and an earlier version of this file
|
| 280 |
+
did. That is wrong, and the bench says so once it is pushed past the tick:
|
| 281 |
+
|
| 282 |
+
| dispatches in **one** pass | wall | per dispatch |
|
| 283 |
+
| --- | --- | --- |
|
| 284 |
+
| 2 048 | 104 ms | ≤50.8 µs — but this *is* the tick, so it only bounds the number |
|
| 285 |
+
| 16 384 | 102 ms | ≤6.2 µs — still one tick |
|
| 286 |
+
| 65 536 | 207 ms | ~2.1 µs marginal |
|
| 287 |
+
|
| 288 |
+
A dispatch inside a pass costs **~2 µs**, so all 664 of them cost ~1.3 ms, and the ~16 remaining passes add
|
| 289 |
+
~1.6 ms. Neither is the 34 ms. Note how the first row alone would have supported the wrong conclusion: any
|
| 290 |
+
measurement that lands on 104 ms is the poll grid talking, not the GPU.
|
| 291 |
+
|
| 292 |
+
What is left is the kernels' own execution. Splitting the launches at the forward/sample boundary gives
|
| 293 |
+
**639 forward + 25 sampling** — the 248k-vocab `argsort_probs` is only 4% of the budget, so the sampling tail
|
| 294 |
+
is not it either. Subtracting the amortized tick leaves ~27 ms of forward execution to move 420 MB of q4f16
|
| 295 |
+
weights, i.e. **~16 GB/s against the M4's ~120 GB/s — about 13% of peak**.
|
| 296 |
+
|
| 297 |
+
That is the signature of batch-size-1 decode: a matrix-*vector* product where every weight is read once, used
|
| 298 |
+
for a single multiply-accumulate, and thrown away, with 4-bit dequantization on top. Intermediates are not
|
| 299 |
+
the problem (hidden size 1024 means a ~2 KB activation, negligible next to the weights). So the lever is
|
| 300 |
+
*faster* kernels, not fewer: better dequant-GEMV, or a quantization whose kernels reach a higher fraction of
|
| 301 |
+
peak. See "Does a different model help?".
|
| 302 |
+
|
| 303 |
+
### Why not llama.cpp/Ollama-class, and what would close it
|
| 304 |
+
|
| 305 |
+
Decode moves 420 MB of weights per token, so every question about throughput is a question about achieved
|
| 306 |
+
memory bandwidth. `npm run bench` measures the two ceilings above us — a hand-written WGSL kernel that does
|
| 307 |
+
nothing but stream a 512 MB buffer, swept over workgroup counts so an untuned kernel is not mistaken for a
|
| 308 |
+
platform limit:
|
| 309 |
+
|
| 310 |
+
| layer | achieved | of hardware peak |
|
| 311 |
+
| --- | --- | --- |
|
| 312 |
+
| M4 (128-bit LPDDR5X) theoretical | ~120 GB/s | 100% |
|
| 313 |
+
| Best hand-written WebGPU streaming read, Firefox | **~50 GB/s** | ~43% |
|
| 314 |
+
| MLC/TVM dequant-GEMV during decode | **~16 GB/s** | ~13% |
|
| 315 |
+
|
| 316 |
+
Two independent gaps, with different owners:
|
| 317 |
+
|
| 318 |
+
- **120 → 50 is the platform.** Flat at 50-53 GB/s across 256/1024/4096/16384 workgroups, so it is not an
|
| 319 |
+
occupancy or parallelism problem in the probe. Nothing in this repo moves it.
|
| 320 |
+
- **50 → 16 is the kernels.** This is the one worth attacking: ~3x, and it is entirely inside the generated
|
| 321 |
+
dequant-GEMV.
|
| 322 |
+
|
| 323 |
+
#### Where the 50 -> 16 goes, measured
|
| 324 |
+
|
| 325 |
+
The generated kernel is in the model's `.wasm` as WGSL, so it can be read directly. The hot one
|
| 326 |
+
(`fused_dequantize2_fused_NT_matmul1_silu1_multiply1_kernel`) is `@workgroup_size(64)`, each thread runs a
|
| 327 |
+
**2-iteration** loop over packed weights, and the workgroup then reduces through `var<workgroup> red_buf0`
|
| 328 |
+
with barriers.
|
| 329 |
+
|
| 330 |
+
`npm run bench` rebuilds that kernel one property at a time against the same 512 MB buffer, so each step's
|
| 331 |
+
cost is isolated:
|
| 332 |
+
|
| 333 |
+
| kernel property | GB/s | cost |
|
| 334 |
+
| --- | --- | --- |
|
| 335 |
+
| `vec4` loads, 256-wide, long loop — the ceiling | ~50 | — |
|
| 336 |
+
| scalar `u32` loads (what packed 4-bit weights force) | ~41-46 | ~1.15x |
|
| 337 |
+
| + unpack 8 nibbles, centre, scale | ~41 | ~1.0x — **dequant arithmetic is nearly free** |
|
| 338 |
+
| + 64-lane barrier reduction, 32 iters/thread | 34.0 | 1.2x |
|
| 339 |
+
| + 8 iters/thread | 27.0 | 1.5x |
|
| 340 |
+
| **+ 2 iters/thread — what MLC actually generated** | **18.6** | **2.2x** |
|
| 341 |
+
| decode in the real engine (adds scale traffic, writes, real grid) | ~16 | 2.6x |
|
| 342 |
+
|
| 343 |
+
So neither load width nor dequantisation is the problem. **The cost is the reduction tail amortised over
|
| 344 |
+
almost no work**: each workgroup reads 128 `u32` — 512 bytes — and then spends six `workgroupBarrier()`s
|
| 345 |
+
doing a 64-lane tree reduction. Raising work per thread from 2 to 32 iterations recovers **1.83x** on its own
|
| 346 |
+
(18.6 -> 34.0 GB/s) with everything else held fixed.
|
| 347 |
+
|
| 348 |
+
That is a *schedule* parameter, chosen by TVM's dlight GEMV rule at model-compile time and baked into the
|
| 349 |
+
`.wasm`. It is not something the runtime can change: the launch grid is emitted by the host side of the same
|
| 350 |
+
module, so substituting a shader would desynchronise it from its dispatch shape. The fix is to recompile the
|
| 351 |
+
model lib — same weights, retuned GEMV tiling — and it is also exactly what subgroups would make moot, since
|
| 352 |
+
`simd_sum` reduces 32 lanes without a barrier at all.
|
| 353 |
+
|
| 354 |
+
Caveat: these are faithful *models* of the kernel, not the kernel itself; the real one also streams the scale
|
| 355 |
+
array and writes results, which is roughly where the last 18.6 -> 16 goes.
|
| 356 |
+
|
| 357 |
+
**What is structurally different in llama.cpp's pipeline** (reasoned from the two codebases, *not* measured
|
| 358 |
+
here — Ollama is not installed on the test machine):
|
| 359 |
+
|
| 360 |
+
1. **Hand-written Metal vs WGSL→naga→MSL.** WebGPU mandates bounds-checked buffer access; naga emits clamps
|
| 361 |
+
on dynamic indexing, which lands in a GEMV's innermost loop.
|
| 362 |
+
2. **No subgroup ops.** llama.cpp's Metal GEMV leans on `simd_sum`/`simd_shuffle` for cross-lane reduction
|
| 363 |
+
and `simdgroup_matrix` for matmul. WGSL subgroups are not exposed in Firefox, so TVM must reduce through
|
| 364 |
+
workgroup shared memory and barriers — the expensive path for a reduction-shaped kernel.
|
| 365 |
+
3. **Quantization co-designed with its kernel.** `Q4_K` and friends are laid out so a block dequantizes in
|
| 366 |
+
registers from vectorized loads; `q4f16_1` goes through a generated TVM dequant instead.
|
| 367 |
+
|
| 368 |
+
**Is "80% of Ollama" reachable here?** Probably not on Firefox WebGPU today, and the arithmetic says why
|
| 369 |
+
rather than the vibes. Even if the dequant-GEMV became as efficient as a *pure streaming read* — which it
|
| 370 |
+
cannot be, since it also has to dequantize and reduce — 420 MB at 50 GB/s is 8.4 ms/token, about **110-120
|
| 371 |
+
tok/s ceiling** for this model on this platform. Any Ollama number above ~140 tok/s therefore puts 80% of it
|
| 372 |
+
out of reach no matter how good the kernels get, because the 120 → 50 gap is not ours to close.
|
| 373 |
+
|
| 374 |
+
So the honest targets are: **~70 tok/s is plausible** (halve the 50 → 16 kernel gap), ~110 tok/s is the
|
| 375 |
+
absolute platform ceiling, and matching a fast native runtime is not on the table without subgroups landing
|
| 376 |
+
in Firefox or a move off WebGPU. Measure the actual Ollama figure on the same machine before committing to a
|
| 377 |
+
percentage target — it is the one number in this section that is assumed rather than measured.
|
| 378 |
+
|
| 379 |
+
> Also worth knowing: the bench loses the WebGPU device outright when it creates 2048 compute passes in one
|
| 380 |
+
> encoder (`deviceLostDuringBench = yes, during pass-sweep`), and a lost device does not throw — later calls
|
| 381 |
+
> silently no-op and report impossible numbers like 1374 GB/s. The unpatched runtime issued 664 passes per
|
| 382 |
+
> token, which is uncomfortably close to that. The dispatch cap in `patchComputePassBatching()` exists for
|
| 383 |
+
> this reason.
|
| 384 |
+
|
| 385 |
+
### Does a different model help?
|
| 386 |
+
|
| 387 |
+
Decode cost per token is roughly `weight bytes / effective bandwidth + ~1.3 ms of launches + tick/K`.
|
| 388 |
+
The measured effective bandwidth is ~16 GB/s of the M4's ~120 GB/s, so the first term dominates and scales
|
| 389 |
+
with parameter count and quantization — not with how the layers are arranged. Extrapolating from the measured
|
| 390 |
+
26 tok/s at 420 MB:
|
| 391 |
+
|
| 392 |
+
| change | weight bytes | projected | why |
|
| 393 |
+
| --- | --- | --- | --- |
|
| 394 |
+
| another 0.8B architecture, same quant | ~450 MB | ~26 tok/s | same traffic per token; layer layout is not the variable |
|
| 395 |
+
| **Qwen3.8-2B-Distill** (shipped) | 1.06 GB | **16.6-18.1 tok/s, measured** | projected 14-18; the projection held |
|
| 396 |
+
| 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 |
|
| 397 |
+
| 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 |
|
| 398 |
+
| a ~0.3B at q4f16 | ~160 MB | ~60-70 tok/s | helps, but buys less than fixing the efficiency |
|
| 399 |
+
| **same weights, different quantization** | varies | see below | resolved: it changes byte count and nothing else |
|
| 400 |
+
|
| 401 |
+
Every row except the 2B is still a projection from the measured 16.8 GB/s. The 2B row is now a measurement,
|
| 402 |
+
and it landed inside its projected band — which is the only evidence available that this model of decode cost
|
| 403 |
+
predicts anything.
|
| 404 |
+
|
| 405 |
+
So: switching models does not get to 100+ tok/s, and going bigger costs throughput roughly in proportion to
|
| 406 |
+
the extra bytes. This table is why the "4B+" goal was retired in favour of a 2B — see "Project Vision".
|
| 407 |
+
100 tok/s means ~10 ms/token, which at today's 16 GB/s buys only ~160 MB of weights, but at full bandwidth
|
| 408 |
+
would buy ~1.2 GB. **Closing the efficiency gap is worth ~7x more than shrinking the model.**
|
| 409 |
+
|
| 410 |
+
**Which quantization to recompile with: keep `q4f16_1`.** That experiment is settled by the kernel breakdown
|
| 411 |
+
above — unpacking eight nibbles, centring and scaling them measured ~free (41 GB/s with the dequant
|
| 412 |
+
arithmetic vs 41-46 GB/s without). Since dequantisation costs nothing and bytes-per-token is what decides
|
| 413 |
+
throughput, a wider format is pure loss:
|
| 414 |
+
|
| 415 |
+
| format | bytes per 32 weights | vs q4f16_1 | verdict |
|
| 416 |
+
| --- | --- | --- | --- |
|
| 417 |
+
| `q4f16_1` (current) | 16 + 2 scale = 18 | 1.00x | keep |
|
| 418 |
+
| `q3f16_1` | 12 + 2 = 14 | 0.78x -> ~1.28x faster | the only quantization that buys speed, and it costs accuracy |
|
| 419 |
+
| `q4f32_1` | 16 + 4 = 20 | 1.11x slower | f32 scales and activations, and f16 ALU is faster on Apple |
|
| 420 |
+
| `q0f16` | 64 + 0 = 64 | 3.6x slower | skips a dequant that was already free |
|
| 421 |
+
| `q4f16_awq` | 18 | 1.00x | same speed, better accuracy — worth it for quality, not for speed |
|
| 422 |
+
|
| 423 |
+
So the ~1.28x from `q3f16_1` is both smaller than the 1.83x available from the schedule *and* the only one of
|
| 424 |
+
the two that trades away accuracy. Fix the schedule first; treat quantization as a quality knob.
|
| 425 |
+
|
| 426 |
+
One structural note specific to these weights: `vocab_size` is 248320 with `tie_word_embeddings`, so the
|
| 427 |
+
output projection alone is 1024 x 248320 = 254 M parameters, about **30% of every token's memory traffic**.
|
| 428 |
+
That is unusually large (most models are 32k-128k) and no quantization choice changes its share.
|
| 429 |
+
|
| 430 |
+
Set `decodeSteps: 1` to turn multi-step off; `NO_PASS_MERGE=1 npm run build` to turn pass batching off.
|
| 431 |
+
|
| 432 |
+
## Build and install
|
| 433 |
+
|
| 434 |
+
```sh
|
| 435 |
+
npm install
|
| 436 |
+
npm run build # bundles @mlc-ai/web-llm into vendor/web-llm.js
|
| 437 |
+
npm test # integration test over the cache-injection contract
|
| 438 |
+
npm run e2e # real Firefox + real model + real GPU (needs a model folder)
|
| 439 |
+
npm run bench # WebGPU submit/sync latency only - no extension, no model
|
| 440 |
+
npm run package # -> everything-webgpu.xpi
|
| 441 |
+
```
|
| 442 |
+
|
| 443 |
+
Env vars that matter when measuring rather than just running:
|
| 444 |
+
|
| 445 |
+
| Var | Applies to | Effect |
|
| 446 |
+
| --- | --- | --- |
|
| 447 |
+
| `NO_PASS_MERGE=1` | `npm run build` | Skip compute-pass batching, for an A/B on one machine. |
|
| 448 |
+
| `ENGINE_COUNT=n` | `npm run e2e` | Force the pool size; use `1` to measure a single stream. |
|
| 449 |
+
| `DECODE_STEPS=a,b,c` | `npm run e2e` | Sweep multi-step widths live on one loaded model. |
|
| 450 |
+
| `SKIP_BENCH=1` | `npm run e2e` | Drop the two ~40 s `gpuBench` passes when comparing builds. |
|
| 451 |
+
| `E2E_VERBOSE=1` | `npm run e2e` | Surface web-ext/Firefox output; without it a failed launch and a hung extension look the same. |
|
| 452 |
+
| `MODEL_DIR=…` | `npm run e2e` | Model folder (defaults to `~/Downloads/Qwen3.5-0.8B-q4f16_1-MLC`). |
|
| 453 |
+
|
| 454 |
+
Load it with `about:debugging` → This Firefox → Load Temporary Add-on → pick `manifest.json`.
|
| 455 |
+
|
| 456 |
+
Before a model can load, set these in `about:config` and restart Firefox:
|
| 457 |
+
|
| 458 |
+
| Pref | Value | Why |
|
| 459 |
+
| --- | --- | --- |
|
| 460 |
+
| `dom.webgpu.enabled` | `true` | Exposes `navigator.gpu`. |
|
| 461 |
+
| `gfx.webgpu.ignore-blocklist` | `true` | Only if your Mac's GPU is blocklisted. |
|
| 462 |
+
| `dom.webgpu.service-workers.enabled` | `true` | Harmless; needed on builds that gate non-visible contexts. |
|
| 463 |
+
|
| 464 |
+
The manager page shows live WebGPU status, so you can tell a flag problem from a model problem.
|
| 465 |
+
|
| 466 |
+
## Adding a model
|
| 467 |
+
|
| 468 |
+
Three routes, and `load()` resolves across all of them. Two of them are one call:
|
| 469 |
+
|
| 470 |
+
```js
|
| 471 |
+
// 1. prebuilt — one of WebLLM's 163 HuggingFace models. Nothing to register.
|
| 472 |
+
await engine.load("Llama-3.2-1B-Instruct-q4f16_1-MLC");
|
| 473 |
+
|
| 474 |
+
// 2. remote — any base URL you host: an HF repo, a CDN, a path on your origin, localhost.
|
| 475 |
+
await engine.registerModel({
|
| 476 |
+
modelId: "Qwen3.8-2B-q4f16_1-MLC",
|
| 477 |
+
model: "/models/Qwen3.8-2B-q4f16_1-MLC/",
|
| 478 |
+
modelLib: "/models/Qwen3.8-2B-q4f16_1-MLC/Qwen3.8-2B-q4f16_1-webgpu.wasm",
|
| 479 |
+
});
|
| 480 |
+
|
| 481 |
+
// 3. local — read off disk. No network connection at any point, ever.
|
| 482 |
+
await engine.registerModel({ modelId: "Qwen3.8-2B-q4f16_1-MLC", files });
|
| 483 |
+
```
|
| 484 |
+
|
| 485 |
+
`files` is `{ path, file }[]`; `filesFromDataTransfer` and `filesFromInput` build it from a drop event or a
|
| 486 |
+
directory picker. `listAvailableModels()` enumerates all three; `{ prebuilt: false }` on the engine gives a
|
| 487 |
+
build that can never fetch a model.
|
| 488 |
+
|
| 489 |
+
In the `demo` extension this is the **Models…** page: drop a compiled MLC folder on it.
|
| 490 |
+
|
| 491 |
+
A local folder must contain:
|
| 492 |
+
|
| 493 |
+
- `mlc-chat-config.json`
|
| 494 |
+
- `tensor-cache.json` (or a legacy `ndarray-cache.json`)
|
| 495 |
+
- every `params_shard_*.bin` listed in that manifest
|
| 496 |
+
- `tokenizer.json` (or `tokenizer.model`)
|
| 497 |
+
- exactly one `*-webgpu.wasm` model library
|
| 498 |
+
|
| 499 |
+
Grab both halves from Hugging Face — the weights from `mlc-ai/<Model>-MLC`, the matching library from
|
| 500 |
+
`mlc-ai/binary-mlc-llm-libs` — or compile your own with `mlc_llm convert_weights` + `gen_config` + `compile`.
|
| 501 |
+
|
| 502 |
+
Local registration validates the whole folder **before** writing anything, then copies each file into Cache
|
| 503 |
+
Storage. A missing shard fails in milliseconds rather than after 2 GB of copying. A remote URL is not
|
| 504 |
+
validated at all — there is nothing to check without fetching, and WebLLM's loader reports a bad base URL
|
| 505 |
+
far better than a HEAD request would.
|
| 506 |
+
|
| 507 |
+
### How local registration works
|
| 508 |
+
|
| 509 |
+
WebLLM composes every artifact URL as `new URL(relative, base)` and runs the base through `cleanModelUrl`,
|
| 510 |
+
which itself calls `new URL(...)` — so the base must be absolute and resolvable. A `blob:` URL cannot serve
|
| 511 |
+
as one, and there is no hook to hand the loader bytes directly. Pre-populating the cache under WebLLM's own
|
| 512 |
+
scopes and keys therefore **is** its native path: the loader does its ordinary thing and finds everything
|
| 513 |
+
already present.
|
| 514 |
+
|
| 515 |
+
WebLLM is never told the model is local. Each model gets a synthetic base URL
|
| 516 |
+
(`https://local-model.invalid/<id>/resolve/main/`) and its artifacts are written into the exact cache
|
| 517 |
+
scopes and keys WebLLM's loader looks up:
|
| 518 |
+
|
| 519 |
+
| Cache scope | Keys |
|
| 520 |
+
| --- | --- |
|
| 521 |
+
| `webllm/config` | `<base>mlc-chat-config.json` |
|
| 522 |
+
| `webllm/model` | `<base>tensor-cache.json`, tokenizer, every `params_shard_*.bin` |
|
| 523 |
+
| `webllm/wasm` | `<base><model>-webgpu.wasm` |
|
| 524 |
+
|
| 525 |
+
`reload()` therefore finds a full cache and issues zero requests. `.invalid` is reserved by RFC 6761 and can
|
| 526 |
+
never resolve — so this is the *mechanism* of the offline guarantee, not a label for it: there is no bug, no
|
| 527 |
+
eviction and no future refactor by which a locally-registered model reaches the network. It fails with a DNS
|
| 528 |
+
error instead. `test/sources.test.mjs` asserts that structurally, checking that every URL such a record
|
| 529 |
+
carries is on a `.invalid` host.
|
| 530 |
+
|
| 531 |
+
`ModelStore.verify()` checks every key before a load, so storage eviction is reported as "re-register the
|
| 532 |
+
folder" rather than a mid-load fetch. It gates **only** the local route: a remote or prebuilt model that
|
| 533 |
+
loses its cache just re-downloads, which is slow, not fatal.
|
| 534 |
+
|
| 535 |
+
`test/integration.test.mjs` pins this contract, including a guard that fails if a WebLLM upgrade renames a
|
| 536 |
+
cache scope or artifact. [test/e2e/run.mjs](test/e2e/run.mjs) proves it against a real model on a real GPU:
|
| 537 |
+
it temporarily wires a self-test page into the extension, drives ingest -> load -> streaming generation
|
| 538 |
+
through the production code paths, and restores the tree afterwards. Re-run it after bumping
|
| 539 |
+
`@mlc-ai/web-llm`.
|
| 540 |
+
|
| 541 |
+
## Compiling a model in-house
|
| 542 |
+
|
| 543 |
+
Done, for [`empero-ai/Qwen3.8-2B-Distill`](https://huggingface.co/empero-ai/Qwen3.8-2B-Distill). The full
|
| 544 |
+
record — every command, every toolchain breakage, and the numbers — is in
|
| 545 |
+
[MLC-COMPILE.md](MLC-COMPILE.md); the scripts are in `tools/`. Start with:
|
| 546 |
+
|
| 547 |
+
```sh
|
| 548 |
+
tools/setup-mlc-toolchain.sh # venv, patches, emsdk, tvm web runtime
|
| 549 |
+
```
|
| 550 |
+
|
| 551 |
+
**Do not follow the upstream MLC quickstart.** No published pair of MLC nightlies works together, and five
|
| 552 |
+
separate breakages sit between `pip install` and a loadable `.wasm` — including two that produce a model
|
| 553 |
+
that compiles, ships and ingests cleanly and only fails at load. `setup-mlc-toolchain.sh` and
|
| 554 |
+
`patch-mlc-nightly.py` encode all of them and are idempotent; re-run after any `pip install`.
|
| 555 |
+
|
| 556 |
+
**Why this model.** The text architecture is the one already verified end to end — two dimensions differ and
|
| 557 |
+
nothing else:
|
| 558 |
+
|
| 559 |
+
| | Qwen3.5-0.8B (baseline) | Qwen3.8-2B-Distill (shipped) |
|
| 560 |
+
| --- | --- | --- |
|
| 561 |
+
| `model_type` | `qwen3_5` | `qwen3_5` |
|
| 562 |
+
| layers / heads / kv-heads / head_dim | 24 / 8 / 2 / 256 | 24 / 8 / 2 / 256 |
|
| 563 |
+
| `vocab_size`, `tie_word_embeddings` | 248320, true | 248320, true |
|
| 564 |
+
| `linear_*`, `full_attention_interval` | 16/128/16/128/4, every 4th | identical |
|
| 565 |
+
| `hidden_size` | 1024 | **2048** |
|
| 566 |
+
| `intermediate_size` | 3584 | **6144** |
|
| 567 |
+
|
| 568 |
+
The wider `hidden_size` also helps: the GEMV reduction is split across 64 lanes, so 1024 leaves 2 iterations
|
| 569 |
+
per thread while 2048 leaves 4 and the 6144 `down_proj` leaves 12 — further along the measured efficiency
|
| 570 |
+
curve (x2 = 18.6, x8 = 27.0, x32 = 34.0 GB/s) before any schedule retune.
|
| 571 |
+
|
| 572 |
+
**Quantization is not a tuning knob here.** Keep `q4f16_1`: unpacking eight nibbles and scaling them measured
|
| 573 |
+
~free (41 GB/s with the dequant arithmetic vs 41-46 without), so a wider format only adds bytes, and bytes are
|
| 574 |
+
exactly what decode pays for. `q3f16_1` is the only faster option (~1.28x) and it costs accuracy — less than
|
| 575 |
+
the 1.83x the schedule retune offers, and unlike it, not free.
|
| 576 |
+
|
| 577 |
+
### Verifying a build
|
| 578 |
+
|
| 579 |
+
```sh
|
| 580 |
+
node tools/audit-wasm.mjs <folder> # storage buffers per kernel
|
| 581 |
+
node tools/wasm-imports.mjs <folder>/*.wasm <known-good>/*.wasm # runtime vs JS glue
|
| 582 |
+
MODEL_DIR=<folder> ENGINE_COUNT=2 npm run e2e
|
| 583 |
+
```
|
| 584 |
+
|
| 585 |
+
The import check is not optional: the model library links against whatever TVM web runtime built it but runs
|
| 586 |
+
against whatever tvmjs `@mlc-ai/web-llm` bundles, and a mismatch fails only at load, after everything else
|
| 587 |
+
has passed. Read the e2e's `decode probe` line too — `kernels/tok ÷ 24 layers` still ≈27 means dlight chose
|
| 588 |
+
more reduction threads over more work per thread and the schedule retune did not take.
|
| 589 |
+
|
| 590 |
+
## Scheduling
|
| 591 |
+
|
| 592 |
+
The engine is one GPU shared by every caller, so requests carry scheduling metadata and the engine — not the
|
| 593 |
+
caller — decides what runs when. Four mechanisms, no more ([src/background/pool.js](src/background/pool.js)):
|
| 594 |
+
|
| 595 |
+
| | |
|
| 596 |
+
| --- | --- |
|
| 597 |
+
| **Priority bands** | `interactive` > `normal` (default) > `background`, FIFO within a band. |
|
| 598 |
+
| **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. |
|
| 599 |
+
| **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. |
|
| 600 |
+
| **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. |
|
| 601 |
+
|
| 602 |
+
Nothing else interrupts work in flight. A job that did not opt in always runs to completion.
|
| 603 |
+
|
| 604 |
+
### Why one engine per task
|
| 605 |
+
|
| 606 |
+
A batch used to spread across the whole pool. On the 0.8B that was worth 1.3-2.0x, because decode was
|
| 607 |
+
**sync-bound** — a stream spent most of its time waiting on Firefox's ~100 ms tick, so a second stream filled
|
| 608 |
+
idle GPU. Multi-step decoding removed most of that wait, and on the 2B the remaining cost is real GPU work.
|
| 609 |
+
Measured, same four prompts, greedy so both runs emit exactly 97 tokens:
|
| 610 |
+
|
| 611 |
+
| pool | wall | aggregate | per item | peak overlap |
|
| 612 |
+
| --- | --- | --- | --- | --- |
|
| 613 |
+
| 1 | 8.2 s | 11.9 tok/s | ~2.0 s | 1 |
|
| 614 |
+
| 2 | 7.7 s | 12.6 tok/s | ~3.9 s | 2 |
|
| 615 |
+
|
| 616 |
+
**1.06x.** The overlap is real — busy time summed to 15.2 s against 7.7 s of wall — but each stream runs at
|
| 617 |
+
half speed, so they cancel. Spreading one task over the pool buys ~nothing and costs the thing a second
|
| 618 |
+
engine is actually for: a page translation would sit on both engines while ghost-text waited behind it.
|
| 619 |
+
|
| 620 |
+
So the rule is flat. An engine may idle while one task still has work queued; that ~6% is deliberately given
|
| 621 |
+
up to keep an engine free for whoever shows up next. `engine scaling:` in the e2e prints this ratio —
|
| 622 |
+
re-measure it per model, because on a small enough model the old fan-out logic would win again.
|
| 623 |
+
|
| 624 |
+
### The pool grows, it is not sized
|
| 625 |
+
|
| 626 |
+
`engineCount` is a **cap, not a size**. `load()` brings up exactly one engine; the pool adds another only
|
| 627 |
+
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
|
| 628 |
+
~2.4 GB on the 2B, bought for nothing.
|
| 629 |
+
|
| 630 |
+
**There is no budget to check first.** Firefox implements neither `navigator.deviceMemory` nor
|
| 631 |
+
`performance.memory`, and `navigator.storage.estimate()` reports disk quota, not RAM — verified against the
|
| 632 |
+
shipped binary, and re-checked every run by the `memory signals:` line in the e2e. Nothing tells an extension
|
| 633 |
+
how much memory is left. So the pool does not predict, it probes: **a failed load is the memory check.**
|
| 634 |
+
Growth then stops for that model and is not retried, and `status().growthBlocked` says so.
|
| 635 |
+
|
| 636 |
+
**Growth is not instant.** Building an engine is a full model load — measured **51 s** for the 2B — so both
|
| 637 |
+
tasks that triggered it will have finished first. The pool pays that once, in the background, and the second
|
| 638 |
+
engine is there for the *next* collision. If a workload is known to be concurrent from the start, the honest
|
| 639 |
+
fix is a warm-up request pair right after load, not a lower growth threshold.
|
| 640 |
+
|
| 641 |
+
### Why each engine is a Web Worker
|
| 642 |
+
|
| 643 |
+
Several MLCEngines cannot share a realm. Running the same e2e three ways isolates it:
|
| 644 |
+
|
| 645 |
+
| setup | result |
|
| 646 |
+
| --- | --- |
|
| 647 |
+
| 1 engine, background page | passes |
|
| 648 |
+
| 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` |
|
| 649 |
+
| 2 engines, one worker each | passes |
|
| 650 |
+
|
| 651 |
+
The trigger is a second engine *generating* in the same realm — not the pool, and not the engine count by
|
| 652 |
+
itself. That message is embind reporting a type-registry mismatch, and the bundle does carry module-scoped
|
| 653 |
+
emscripten state (`var Module`, `var __wasmLib`) shared by every instance, which fits; but the fix rests on
|
| 654 |
+
the isolation above rather than on having traced the registry.
|
| 655 |
+
|
| 656 |
+
Workers are viable because Firefox exposes WebGPU to dedicated workers and the 100 ms completion tick is
|
| 657 |
+
shared across them, so concurrency survives the move off the main thread (measured: 4 workers, 36.3 syncs/s).
|
| 658 |
+
|
| 659 |
+
### How many engines are worth it
|
| 660 |
+
|
| 661 |
+
Measured on the 0.8B, four independent prompts, back when fan-out still scaled:
|
| 662 |
+
|
| 663 |
+
| pool | peak overlap | aggregate vs. serial |
|
| 664 |
+
| --- | --- | --- |
|
| 665 |
+
| 1 | 1 | 1.00x |
|
| 666 |
+
| 2 | 2 | 1.3x - 2.0x |
|
| 667 |
+
| 4 | 4 | **0.3x** — 3x slower than one engine |
|
| 668 |
+
|
| 669 |
+
Four engines overlap in wall-clock terms but each drops from ~9.6 to ~0.7 tok/s: four copies of the weights
|
| 670 |
+
plus their KV caches leave a 16 GB machine with nothing free, and they starve each other. **More engines is
|
| 671 |
+
not more throughput** — and on the 2B, per the table above, a second one is not more throughput either. The
|
| 672 |
+
route to concurrent throughput is batched decode inside one engine (see Current Tasks), not more engines.
|
| 673 |
+
|
| 674 |
+
## API
|
| 675 |
+
|
| 676 |
+
### In-process — the library
|
| 677 |
+
|
| 678 |
+
Migrating off `@mlc-ai/web-llm` costs one line; everything after it is unchanged.
|
| 679 |
+
|
| 680 |
+
```js
|
| 681 |
+
-import { CreateMLCEngine } from "@mlc-ai/web-llm";
|
| 682 |
+
-const engine = await CreateMLCEngine(modelId, { initProgressCallback });
|
| 683 |
+
+import { CreateScheduledEngine } from "everything-webgpu";
|
| 684 |
+
+const engine = await CreateScheduledEngine(modelId, { initProgressCallback });
|
| 685 |
+
|
| 686 |
+
await engine.chat.completions.create({
|
| 687 |
+
messages, stream: true,
|
| 688 |
+
session: "ghost-text", // added — supersedes the previous request
|
| 689 |
+
priority: "interactive", // added — may preempt work that opted in
|
| 690 |
+
});
|
| 691 |
+
```
|
| 692 |
+
|
| 693 |
+
`chat.completions.create()` returns WebLLM's own shapes, including its
|
| 694 |
+
`"stop" | "length" | "abort"` finish reasons. What it has no room for is `cancelled` and `preempted`
|
| 695 |
+
as distinct outcomes — both collapse to `"abort"` — which is why `complete()` below stays the direct
|
| 696 |
+
API rather than a legacy one.
|
| 697 |
+
|
| 698 |
+
The fuller surface, when you want the store, the model source, or those outcomes:
|
| 699 |
+
|
| 700 |
+
```js
|
| 701 |
+
import { ScheduledEngine, ModelStore } from "everything-webgpu";
|
| 702 |
+
import { indexedDBStorage, ensurePersistent } from "everything-webgpu/adapters/idb";
|
| 703 |
+
|
| 704 |
+
// A page origin holds weights in *evictable* storage until this is granted.
|
| 705 |
+
await ensurePersistent();
|
| 706 |
+
|
| 707 |
+
const engine = new ScheduledEngine({ store: new ModelStore(await indexedDBStorage()) });
|
| 708 |
+
await engine.load("Llama-3.2-1B-Instruct-q4f16_1-MLC");
|
| 709 |
+
|
| 710 |
+
const { text } = await engine.complete({
|
| 711 |
+
messages: [{ role: "user", content: "hi" }],
|
| 712 |
+
session: "ghost-text",
|
| 713 |
+
priority: "interactive",
|
| 714 |
+
});
|
| 715 |
+
```
|
| 716 |
+
|
| 717 |
+
| method | what it does |
|
| 718 |
+
| --- | --- |
|
| 719 |
+
| `load(id, {keepResident, signal})` | bring a model up; `signal` aborts the download, partial shards kept for a free resume |
|
| 720 |
+
| `use(id)` | switch between **resident** models — free, no reload |
|
| 721 |
+
| `unload(id?)` / `unloadAll()` | free VRAM, **keep the cached bytes** |
|
| 722 |
+
| `resident` | model ids with a live pool right now |
|
| 723 |
+
| `store.evict(id)` | free the disk, **keep the record** so it can be re-fetched |
|
| 724 |
+
| `remove(id)` | forget it entirely — frees bytes for **every** source, then drops the record |
|
| 725 |
+
| `chat.completions.create` | streamed chunks are WebLLM's own, verbatim: `tool_calls`, `logprobs`, stable `created` |
|
| 726 |
+
| `store.cacheState(rec)` | `"cached"` / `"partial"` / `"absent"` |
|
| 727 |
+
| `estimateSpeed(id?)` | projected tok/s, measured once anything has decoded |
|
| 728 |
+
| `features()` | what is actually switched on: KV reuse, decode steps, engines |
|
| 729 |
+
| `complete(req, onChunk?)` | one completion; `onChunk` streams deltas |
|
| 730 |
+
| `batch(req, onItem?)` | many independent prompts as **one task** — see below |
|
| 731 |
+
| `cancel(idOrSession)` | by job id or session key; returns how many it stopped |
|
| 732 |
+
| `configure({ decodeSteps })` | retune a live engine, no reload |
|
| 733 |
+
| `registerModel(spec)` | a base URL, or local `files` |
|
| 734 |
+
| `listModels()` / `listAvailableModels()` | registered only (cheap) / all three routes |
|
| 735 |
+
| `subscribe(fn)` | lifecycle changes; returns an unsubscribe |
|
| 736 |
+
| `state` / `hasWebGPU` | current snapshot, WebGPU presence |
|
| 737 |
+
| `chat.completions.create(req)` | the WebLLM/OpenAI facade over `complete()` |
|
| 738 |
+
| `probe()` | WebGPU, adapter, `shader-f16`, limits, storage quota — cached |
|
| 739 |
+
| `canRun(modelId)` | `{ ok, blockers, warnings }`, before anything is downloaded |
|
| 740 |
+
| `recommendModels({maxVramMB, prefer})` | rank the 163 prebuilt models for *this* device |
|
| 741 |
+
|
| 742 |
+
Failures are `EngineError { code, message, detail }` — `NO_WEBGPU`, `NO_MODEL`, `UNKNOWN_MODEL`,
|
| 743 |
+
`CACHE_INCOMPLETE`, `INVALID_MODEL_FOLDER`, `BAD_REQUEST`, `GENERATION_FAILED`. `detail` carries the
|
| 744 |
+
structured context (the evicted keys, the missing field, why a folder was rejected), so no caller
|
| 745 |
+
parses a message. Over the wire the code rides beside `error`, which stays a plain string.
|
| 746 |
+
|
| 747 |
+
Generation ops take `messages`, `temperature`, `max_tokens`, `response_format` and `extra_body` — the
|
| 748 |
+
OpenAI shape WebLLM already speaks — plus the scheduling fields `task`, `session`, `priority` and
|
| 749 |
+
`preemptible`, which are what this adds over calling WebLLM directly.
|
| 750 |
+
|
| 751 |
+
### Over a wire — the WebExtension adapter
|
| 752 |
+
|
| 753 |
+
Only for the case where the engine and the caller are in different processes. Extension id:
|
| 754 |
+
`everything-webgpu@local`; the manager page prints a copy-pasteable version.
|
| 755 |
+
|
| 756 |
+
Two transports, one vocabulary ([src/adapters/protocol.js](src/adapters/protocol.js)):
|
| 757 |
+
|
| 758 |
+
- `browser.runtime.sendMessage(id, req)` — request/response. Ops: `status`, `listModels`, `load`,
|
| 759 |
+
`unload`, `chat`, `batch`, `cancel`, `configure`.
|
| 760 |
+
- `browser.runtime.connect(id, { name: "everything-webgpu/v1" })` — streaming. Ops: `subscribe`,
|
| 761 |
+
`chat.stream`, `batch.stream`, `abort`; the port also pushes `engineState` on every lifecycle change.
|
| 762 |
+
|
| 763 |
+
Every message carries `protocol`, so a stray message from another sender fails fast instead of being
|
| 764 |
+
half-interpreted. Generation ops accept `modelId`, `temperature`, `max_tokens`, `response_format`,
|
| 765 |
+
`extra_body`, plus the scheduling fields `task`, `session`, `priority` and `preemptible`.
|
| 766 |
+
|
| 767 |
+
**Send raw requests. Do not ask for a `translate` op.** The engine schedules a shared GPU; it does not
|
| 768 |
+
author prompts. Prompts belong to whoever owns the feature, because they are model-specific — switching this
|
| 769 |
+
build from `Qwen3.5-0.8B` to `Qwen3.8-2B-Distill` changed the conversation template and made every reply open
|
| 770 |
+
with a `<think>` block. A prompt that lives in the caller survives that; a `translate` op baked into the
|
| 771 |
+
engine would have to be rewritten and re-shipped to every caller. Wrap the transport in a client-side helper
|
| 772 |
+
if you want `translate()` ergonomics — just keep it on your side of `sendMessage`.
|
| 773 |
+
|
| 774 |
+
### The three shapes of work
|
| 775 |
+
|
| 776 |
+
What differs between these is *not* the op or the transport. It is who owns an engine, and what may
|
| 777 |
+
interrupt what. The examples below use the wire form; in-process the same fields go to `complete()` and
|
| 778 |
+
`batch()`.
|
| 779 |
+
|
| 780 |
+
| | op | priority | key fields | why |
|
| 781 |
+
| --- | --- | --- | --- | --- |
|
| 782 |
+
| **Completion** (ghost text) | `chat.stream` | `interactive` | `session` | Each keystroke supersedes the last request; may preempt opted-in work. |
|
| 783 |
+
| **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. |
|
| 784 |
+
| **Reformat** (markdown) | `chat` | `background` | `preemptible: true` | Nobody is watching; let interactive work cut in. |
|
| 785 |
+
|
| 786 |
+
#### Completion — latency is the whole product
|
| 787 |
+
|
| 788 |
+
```js
|
| 789 |
+
const port = browser.runtime.connect("everything-webgpu@local", { name: "everything-webgpu/v1" });
|
| 790 |
+
port.onMessage.addListener((m) => {
|
| 791 |
+
if (m.op === "chunk") render(m.delta);
|
| 792 |
+
if (m.op === "done") finish(m.text);
|
| 793 |
+
});
|
| 794 |
+
|
| 795 |
+
// On every keystroke. The previous request is cancelled, not queued behind.
|
| 796 |
+
port.postMessage({
|
| 797 |
+
protocol: "everything-webgpu/v1",
|
| 798 |
+
op: "chat.stream",
|
| 799 |
+
id: crypto.randomUUID(),
|
| 800 |
+
session: "ghost-text", // supersession key — the important field
|
| 801 |
+
priority: "interactive", // may preempt jobs that opted in
|
| 802 |
+
max_tokens: 24, // ghost text is short; do not pay for more
|
| 803 |
+
messages: [{ role: "user", content: prefix }],
|
| 804 |
+
});
|
| 805 |
+
```
|
| 806 |
+
|
| 807 |
+
`session` is what makes this work, not `cancel`. Reusing one session key means the engine drops the stale
|
| 808 |
+
request itself; a caller that mints a fresh id per keystroke and calls `cancel` races its own typing.
|
| 809 |
+
|
| 810 |
+
#### Translation — throughput, one task
|
| 811 |
+
|
| 812 |
+
```js
|
| 813 |
+
// One batch, not a loop of `chat` calls.
|
| 814 |
+
const res = await browser.runtime.sendMessage("everything-webgpu@local", {
|
| 815 |
+
protocol: "everything-webgpu/v1",
|
| 816 |
+
op: "batch",
|
| 817 |
+
task: "translate-page", // optional; a batch is one task either way
|
| 818 |
+
requests: sentences.map((s) => ({
|
| 819 |
+
messages: [{ role: "user", content: `Translate to French, output only the translation:\n${s}` }],
|
| 820 |
+
})),
|
| 821 |
+
});
|
| 822 |
+
res.results.forEach((r) => apply(r.index, r.text));
|
| 823 |
+
```
|
| 824 |
+
|
| 825 |
+
Every item of one batch shares a task, and a task holds one engine, so a 200-sentence page occupies exactly
|
| 826 |
+
one engine and can never freeze ghost-text behind it. Results carry `engineIndex`, `startedAt` and
|
| 827 |
+
`finishedAt`, so a caller can check what actually ran where.
|
| 828 |
+
|
| 829 |
+
`batch` is still the right call rather than a loop of `chat`: it is one round trip, the engine keeps the
|
| 830 |
+
items in one queue it can reason about, and if this ever runs on a model small enough for fan-out to pay
|
| 831 |
+
again — or once batched decode lands — the same call gets faster with no change on your side.
|
| 832 |
+
|
| 833 |
+
Use `batch.stream` over a port instead if you want items as they land rather than one array at the end.
|
| 834 |
+
|
| 835 |
+
#### Reformat — cheap to interrupt
|
| 836 |
+
|
| 837 |
+
```js
|
| 838 |
+
await browser.runtime.sendMessage("everything-webgpu@local", {
|
| 839 |
+
protocol: "everything-webgpu/v1",
|
| 840 |
+
op: "chat",
|
| 841 |
+
priority: "background",
|
| 842 |
+
preemptible: true, // the direction matters — see below
|
| 843 |
+
max_tokens: 2048,
|
| 844 |
+
messages: [{ role: "user", content: `Reformat as clean Markdown, no commentary:\n\n${doc}` }],
|
| 845 |
+
});
|
| 846 |
+
```
|
| 847 |
+
|
| 848 |
+
**Set `preemptible` on the work that can afford to lose, not on the work you care about.** Only an
|
| 849 |
+
`interactive` request preempts, and only a job that opted in can be preempted. A preempted job resolves with
|
| 850 |
+
`preempted: true` and whatever text it had, so it is never requeued and can never starve — but that also
|
| 851 |
+
means you must be able to use, or discard, a partial result.
|
| 852 |
+
|
| 853 |
+
### Getting these wrong
|
| 854 |
+
|
| 855 |
+
| symptom | cause |
|
| 856 |
+
| --- | --- |
|
| 857 |
+
| Ghost text lags behind typing | Fresh `id` per keystroke with no `session`, so every stale request still runs. |
|
| 858 |
+
| 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. |
|
| 859 |
+
| Reformatting blocks completions | `preemptible` left off the background job, so `interactive` has nothing to take. |
|
| 860 |
+
| 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. |
|
| 861 |
+
|
| 862 |
+
By default every installed extension may call the API. The manager page has an allowlist field; fill it in
|
| 863 |
+
with extension ids to restrict access.
|
| 864 |
+
|
| 865 |
+
## Layout
|
| 866 |
+
|
| 867 |
+
| Path | Role |
|
| 868 |
+
| --- | --- |
|
| 869 |
+
| [manifest.json](manifest.json) | MV2, persistent background page, `wasm-unsafe-eval` CSP |
|
| 870 |
+
| [src/engine/index.js](src/engine/index.js) | Public entry point of the library |
|
| 871 |
+
| [src/engine/create.js](src/engine/create.js) | `CreateScheduledEngine` — the one-line swap for `CreateMLCEngine` |
|
| 872 |
+
| [src/engine/chat.js](src/engine/chat.js) | `chat.completions.create()`, the WebLLM/OpenAI facade |
|
| 873 |
+
| [src/engine/environment.js](src/engine/environment.js) | `environment()` — the read-only device/runtime report; writes are `configure()` |
|
| 874 |
+
| [src/engine/errors.js](src/engine/errors.js) | `EngineError` and the eight codes |
|
| 875 |
+
| [src/engine/device.js](src/engine/device.js) | Hardware probe, `canRun`, model ranking |
|
| 876 |
+
| [src/engine/engine.js](src/engine/engine.js) | `ScheduledEngine` — the engine with no transport attached |
|
| 877 |
+
| [src/engine/pool.js](src/engine/pool.js) | Engine pool + priority scheduler |
|
| 878 |
+
| [src/engine/engine-worker.js](src/engine/engine-worker.js) | One pool slot's engine, in its own realm |
|
| 879 |
+
| [src/engine/multistep.js](src/engine/multistep.js) | Multi-step decoding: K forward steps per GPU sync |
|
| 880 |
+
| [src/engine/sources.js](src/engine/sources.js) | What `load()` was handed — pure dispatch across id / URL / spec / folder |
|
| 881 |
+
| [src/engine/ingest.js](src/engine/ingest.js) | Folder validation and cache injection |
|
| 882 |
+
| [src/engine/recipes.js](src/engine/recipes.js) | `ask()` / `conversation()` / `ghostText()` — the three shapes as one call each, scheduling only |
|
| 883 |
+
| [src/engine/prefetch.js](src/engine/prefetch.js) | `prefetch()` — fill the cache with no engine and no GPU; WebLLM's `hasModelInCache` is the oracle |
|
| 884 |
+
| [src/engine/model-store.js](src/engine/model-store.js) | Cache layout, registry, settings, `StorageAdapter`, the three model sources |
|
| 885 |
+
| [src/engine/constants.js](src/engine/constants.js) | `PRIORITY`, `ENGINE_STATE` — engine vocabulary, transport-free |
|
| 886 |
+
| [src/adapters/protocol.js](src/adapters/protocol.js) | Wire protocol: `PROTOCOL`, `OP`, `PORT_OP` |
|
| 887 |
+
| [src/adapters/webext.js](src/adapters/webext.js) | `browser.storage.local` + the message/port router |
|
| 888 |
+
| [src/adapters/idb.js](src/adapters/idb.js) | IndexedDB `StorageAdapter` + `ensurePersistent()`, for pages |
|
| 889 |
+
| [src/adapters/memory.js](src/adapters/memory.js) | In-memory `StorageAdapter`, for tests |
|
| 890 |
+
| [src/background/background.js](src/background/background.js) | The extension host: build an engine, attach the transport |
|
| 891 |
+
| [src/popup/](src/popup/) | Minimal test chat (moves to `demo` in Phase 4) |
|
| 892 |
+
| [src/manager/](src/manager/) | Drop target, registry, settings, setup help (moves to `demo` in Phase 4) |
|
| 893 |
+
| [test/integration.test.mjs](test/integration.test.mjs) | The cache-injection contract |
|
| 894 |
+
| [test/scheduler.test.mjs](test/scheduler.test.mjs) | Priority, supersession, preemption and pool growth, GPU-free |
|
| 895 |
+
| [test/sources.test.mjs](test/sources.test.mjs) | How `load()` resolves prebuilt / remote / injected, in what order it refuses, and that a local model has no reachable URL |
|
| 896 |
+
| [test/errors.test.mjs](test/errors.test.mjs) | That failures carry the right code, and that nothing throws an untyped Error |
|
| 897 |
+
| [test/chat.test.mjs](test/chat.test.mjs) | That the WebLLM facade really is drop-in, shape by shape |
|
| 898 |
+
| [test/device.test.mjs](test/device.test.mjs) | The compatibility rules, and that blockers and warnings stay distinct |
|
| 899 |
+
| [test/manage.test.mjs](test/manage.test.mjs) | The four model states, and that unload / evict / remove stay distinct |
|
| 900 |
+
| [test/e2e/](test/e2e/) | Real-hardware end-to-end run (`npm run e2e`) |
|
| 901 |
+
| [test/e2e/bench.mjs](test/e2e/bench.mjs) | Standalone WebGPU sync-latency benchmark (`npm run bench`) |
|
| 902 |
+
| [MLC-COMPILE.md](MLC-COMPILE.md) | How the model was compiled, and every toolchain breakage on the way |
|
| 903 |
+
| [tools/](tools/) | Model-compilation toolchain: setup, nightly patches, weight strip, wasm audits |
|
| 904 |
+
| [WEBLLM-SURFACE.md](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. |
|
| 905 |
+
| [ROADMAP.md](ROADMAP.md) | The only list of open work |
|
| 906 |
+
| [ARCHIVE.md](ARCHIVE.md) | What was done and why — the extraction, the model sources, the de-duplication |
|
| 907 |
+
|
| 908 |
+
`src/engine/` references no WebExtension API — asserted by a test, because that claim is only broken in the
|
| 909 |
+
host nobody ran. The three places the host used to leak in are injected: a `StorageAdapter` for the registry,
|
| 910 |
+
a worker URL, and the WebLLM import. `src/adapters/` holds one implementation of each per host. See
|
| 911 |
+
[ARCHIVE.md](ARCHIVE.md).
|
| 912 |
+
|
| 913 |
+
In *this* host the engine lives in the MV2 persistent background page — a real document on the extension
|
| 914 |
+
origin, so it has both `navigator.gpu` and the same Cache Storage the manager page writes to. The model stays
|
| 915 |
+
resident in VRAM across popup opens and across calls from other extensions.
|
| 916 |
+
|
| 917 |
+
## Known limits
|
| 918 |
+
|
| 919 |
+
- **AMO signing**: `vendor/web-llm.js` is ~6 MB, over `web-ext lint`'s 5 MB parse limit. Fine for temporary
|
| 920 |
+
install and self-distribution; it would need splitting before an AMO listing.
|
| 921 |
+
- **MV2**: MV3 event pages get evicted, which would unload a multi-GB model between calls. Migrating needs a
|
| 922 |
+
keep-alive or a dedicated engine tab. This constrains `demo` only — the library is host-agnostic.
|
| 923 |
+
- **Storage eviction off an extension origin**: an ordinary page has no `unlimitedStorage`, so a multi-GB
|
| 924 |
+
model is evictable until `ensurePersistent()` is granted. For a prebuilt or remote model that means a slow
|
| 925 |
+
reload; for a locally-registered one it is fatal and it must be re-registered. Unmeasured: the exact quota
|
| 926 |
+
and grant behaviour per browser.
|
| 927 |
+
- **Never run outside a Firefox extension**: the library is written against capabilities rather than
|
| 928 |
+
browsers, and should be *faster* on Chrome (KV reuse is not disabled there — see "Multi-step decoding").
|
| 929 |
+
Both claims are predictions. See ROADMAP.md, Gates A and B.
|
| 930 |
+
- **Thinking burns the budget**: this model opens every reply with a `<think>` block — it is a reasoning
|
| 931 |
+
distill and its card says so. At ~17 tok/s that is seconds of nothing before the answer starts. For
|
| 932 |
+
translation and completion, suppress it in your prompt, or rebuild the config with the `qwen3_5_nothink`
|
| 933 |
+
conversation template. The engine deliberately does not rewrite prompts for you.
|
| 934 |
+
- **No cross-turn KV reuse**: every turn re-prefills the whole history at 5.27 ms/token, so a long
|
| 935 |
+
conversation pays ~22 s before its first token at the 4096 limit. See Current Tasks.
|
| 936 |
+
- **Decode speed**: *was* bounded by Firefox's 100 ms WebGPU poll timer (Mozilla bug 1870699). Multi-step
|
| 937 |
+
decoding and compute-pass batching bought most of that back; what remains is memory bandwidth — decode
|
| 938 |
+
achieves ~16 GB/s of the M4's ~120. See "The 10 tok/s ceiling" and "Why not llama.cpp/Ollama-class".
|
| 939 |
+
- **A second engine is not more speed**: measured 1.06x on this model. It buys isolation between tasks.
|
| 940 |
+
Concurrent throughput needs batched decode inside one engine. See "Scheduling".
|
API.md
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API — every way to call it
|
| 2 |
+
|
| 3 |
+
The complete call surface of `everything-webgpu`, one page. [README.md](README.md) is the pitch and
|
| 4 |
+
the migration story; this is the catalogue. Asserted against the code by
|
| 5 |
+
[test/api-doc.test.mjs](test/api-doc.test.mjs) — every engine method named here exists, every
|
| 6 |
+
package export appears, and the error table equals `ERROR`, so this page cannot drift from the
|
| 7 |
+
source without failing `npm test`.
|
| 8 |
+
|
| 9 |
+
The page is in three tiers. **[Start here](#start-here)** — `load`, `ask`, `conversation`,
|
| 10 |
+
`environment` — is the dead-simple path, and most apps need nothing else. **[Native
|
| 11 |
+
passthrough](#native-passthrough)** is `chat.completions.create()`, the WebLLM/OpenAI compatibility
|
| 12 |
+
layer, which **never changes** (see [Stability](README.md#stability)). **[When you need
|
| 13 |
+
more](#when-you-need-more)** is the rest of the surface: the general calls and their scheduling
|
| 14 |
+
fields, embeddings, residency, device inspection, configuration, lifecycle. Everything outside the
|
| 15 |
+
passthrough is pre-1.0 and may move; the ergonomic verbs are being consolidated in
|
| 16 |
+
[ROADMAP.md](ROADMAP.md).
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## The four lines
|
| 21 |
+
|
| 22 |
+
```js
|
| 23 |
+
import { CreateScheduledEngine } from "everything-webgpu"; // 1. import
|
| 24 |
+
const engine = await CreateScheduledEngine("Llama-3.2-1B-Instruct-q4f16_1-MLC"); // 2. load a model
|
| 25 |
+
const reply = await engine.ask("Name three primary colours."); // 3. ask
|
| 26 |
+
console.log(reply); // 4. the answer
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
`reply` is a plain string. Line 2 downloads ~0.8 GB the first time and prints throttled progress to
|
| 30 |
+
the console unless you pass `initProgressCallback` (render it yourself) or `initProgressCallback:
|
| 31 |
+
null` (silence). After that first run it is a cache read and needs no network.
|
| 32 |
+
|
| 33 |
+
On Vite, add one plugin — see [Bundlers](#bundlers).
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
## Start here
|
| 38 |
+
|
| 39 |
+
Four calls. Get an engine with `CreateScheduledEngine` (below, under [Getting an
|
| 40 |
+
engine](#getting-an-engine)), then `load` a model, `ask` it things or hold a `conversation`, and
|
| 41 |
+
`environment` tells you whether the machine is up to it.
|
| 42 |
+
|
| 43 |
+
### Load a model — `engine.load(src, opts?)`
|
| 44 |
+
|
| 45 |
+
**One call, four source shapes.** `engine.load(src, opts?)` works out what you handed it, registers
|
| 46 |
+
whatever needs registering, and brings the model up.
|
| 47 |
+
|
| 48 |
+
| `src` | route |
|
| 49 |
+
| --- | --- |
|
| 50 |
+
| `"Llama-3.2-1B-Instruct-q4f16_1-MLC"` | a prebuilt id, or anything you registered earlier. A typo is answered with near matches. |
|
| 51 |
+
| `"https://huggingface.co/mlc-ai/Foo-MLC"` | an HF repo. `/resolve/main/` is **not** derived — WebLLM appends it. |
|
| 52 |
+
| `"https://cdn.example/models/foo/"` + `{ modelLib }` | any base URL you host. `modelLib` is **required** and never guessed (0 of 163 prebuilt models have a derivable lib name or same-origin lib). |
|
| 53 |
+
| `{ model, modelLib }` | the explicit remote spec. |
|
| 54 |
+
| `input.files` \| `dropEvent.dataTransfer` \| `{ files }` | a folder off disk. **No network at any point.** |
|
| 55 |
+
|
| 56 |
+
**`opts`** — `keepResident`, `signal`, `modelType`, `contextWindow`, `vramRequiredMB`, `id`,
|
| 57 |
+
`onProgress`, `defer`.
|
| 58 |
+
|
| 59 |
+
- `id` overrides the id derived from the URL's last segment.
|
| 60 |
+
- `defer: true` registers the source **without building a pool** — the drop-now-load-later flow. It
|
| 61 |
+
returns the registry record. `defer` on a bare prebuilt id is an error, not a silent load.
|
| 62 |
+
- `keepResident: true` holds this model in VRAM alongside whatever is already up. The default
|
| 63 |
+
unloads everything else first — the safe choice on a 16 GB machine.
|
| 64 |
+
|
| 65 |
+
```js
|
| 66 |
+
await engine.load("Llama-3.2-1B-Instruct-q4f16_1-MLC");
|
| 67 |
+
await engine.load("https://cdn.example/models/my-model/", { modelLib: "https://cdn.example/models/my-model/lib.wasm" });
|
| 68 |
+
await engine.load(dropEvent.dataTransfer);
|
| 69 |
+
await engine.load(input.files, { defer: true }); // register now, build the pool on first use
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
`load()` composes the lower-level `registerModel`, `ingestModelFolder` and the download primitives;
|
| 73 |
+
`prefetch()` warms the cache with no GPU. All of that is under [More on
|
| 74 |
+
loading](#more-on-loading).
|
| 75 |
+
|
| 76 |
+
### Ask one question — `engine.ask(input, opts?)`
|
| 77 |
+
|
| 78 |
+
One question, its own task, **no session** — two `ask()`s never supersede each other. Returns the
|
| 79 |
+
reply string. `opts.onDelta` to stream. Goes through the same scheduler as everything else: priority
|
| 80 |
+
bands, one-task-one-engine, opt-in preemption.
|
| 81 |
+
|
| 82 |
+
### Hold a conversation — `engine.conversation(opts)`
|
| 83 |
+
|
| 84 |
+
A multi-turn chat that keeps its own history. `engine.conversation({ system?, keep?, ...defaults })`
|
| 85 |
+
— one stable task for every turn, turns serialised, history bounded at `keep: 12` exchanges
|
| 86 |
+
(`Infinity` opts out).
|
| 87 |
+
|
| 88 |
+
```js
|
| 89 |
+
const chat = engine.conversation({ system: "You are terse." });
|
| 90 |
+
await chat.say("capital of France?"); // → { text, finishReason }
|
| 91 |
+
await chat.say("and its population?", onDelta); // remembers
|
| 92 |
+
chat.messages; chat.length; chat.reset(); chat.restore(messages);
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
### Inspect the machine — `engine.environment(opts?)`
|
| 96 |
+
|
| 97 |
+
| call | answers |
|
| 98 |
+
| --- | --- |
|
| 99 |
+
| `await engine.environment(opts?)` | **the preflight.** A report; every line has `severity` · `affects` · `cause` · `fix` · `operable`. `{ scope: "local" }` never touches the model layer (poll freely); `{ scope: "device" }` is hardware only. |
|
| 100 |
+
| `await engine.environment.measure()` | one calibration generation → measured tok/s for the current model |
|
| 101 |
+
|
| 102 |
+
**`environment()` only reports.** Writes go through `configure()`; passing a setting to
|
| 103 |
+
`environment()` is an error that names `configure()`. Per-model "will it run" is
|
| 104 |
+
[`canRun(modelId)`](#inspecting-the-machine); the fuller device surface is there too.
|
| 105 |
+
|
| 106 |
+
## Native passthrough
|
| 107 |
+
|
| 108 |
+
The one call that **never changes**. `@mlc-ai/web-llm` is OpenAI-shaped, and so is this: the
|
| 109 |
+
migration off it is a one-line import swap, and `chat.completions.create()` then takes and returns
|
| 110 |
+
exactly the same shapes — same streamed chunk objects, same finish reasons, same non-streaming
|
| 111 |
+
envelope. See [Stability](README.md#stability).
|
| 112 |
+
|
| 113 |
+
| call | shape |
|
| 114 |
+
| --- | --- |
|
| 115 |
+
| `engine.chat.completions.create(params)` | the **WebLLM/OpenAI** shape, unchanged. Streams the same chunks, same finish reasons. `session`/`priority`/`task`/`preemptible` are additive. |
|
| 116 |
+
|
| 117 |
+
**`params`** = the OpenAI generation fields WebLLM already speaks (`messages`, `temperature`,
|
| 118 |
+
`max_tokens`, `response_format`, `extra_body`, …) **plus** the scheduling fields that are the only
|
| 119 |
+
thing this adds over calling WebLLM directly:
|
| 120 |
+
|
| 121 |
+
| field | meaning |
|
| 122 |
+
| --- | --- |
|
| 123 |
+
| `modelId` | load/route to this model instead of the current one |
|
| 124 |
+
| `id` | job id; also what `cancel(id)` takes |
|
| 125 |
+
| `task` | the unit that owns an engine; a whole batch shares one |
|
| 126 |
+
| `session` | a later job with this key supersedes the earlier one |
|
| 127 |
+
| `priority` | `"interactive"` \| `"normal"` \| `"background"` |
|
| 128 |
+
| `preemptible` | may be interrupted by an `interactive` job (set it on the work that can afford to lose) |
|
| 129 |
+
|
| 130 |
+
`complete()`, `completeRaw()` and `batch()` take the same scheduling fields and expose `cancelled` /
|
| 131 |
+
`preempted` as first-class outcomes the OpenAI shape has no room for — see [The general
|
| 132 |
+
calls](#the-general-calls).
|
| 133 |
+
|
| 134 |
+
## When you need more
|
| 135 |
+
|
| 136 |
+
### Getting an engine
|
| 137 |
+
|
| 138 |
+
| call | when |
|
| 139 |
+
| --- | --- |
|
| 140 |
+
| `await CreateScheduledEngine(modelId?, opts?)` | the common case. Loads `modelId` before returning, like WebLLM's `CreateMLCEngine`. Omit it for an engine that loads later. |
|
| 141 |
+
| `new ScheduledEngine({ store, workerUrl?, loadWebLLM?, prebuilt? })` | when you must pass a store explicitly — a worker, a test, an extension. Does **not** load anything. |
|
| 142 |
+
|
| 143 |
+
**`opts` for `CreateScheduledEngine`** — `store`, `initProgressCallback`, `workerUrl`, `loadWebLLM`,
|
| 144 |
+
`prebuilt`. Anything not `store`/`initProgressCallback` is forwarded to the constructor.
|
| 145 |
+
|
| 146 |
+
| constructor field | default | meaning |
|
| 147 |
+
| --- | --- | --- |
|
| 148 |
+
| `store` | IndexedDB (`CreateScheduledEngine` only; the constructor requires it) | a `ModelStore`, or a bare `StorageAdapter` it wraps |
|
| 149 |
+
| `prebuilt` | `true` | expose WebLLM's 163 HuggingFace models. `false` = offline-only: `load()` resolves registered models and nothing else, and an unknown id fails before the WebLLM bundle is fetched |
|
| 150 |
+
| `workerUrl` | `new URL("./engine-worker.js", import.meta.url)` | the decode worker's module URL |
|
| 151 |
+
| `loadWebLLM` | `() => import("../../vendor/web-llm.js")` | override the bundle source (tests) |
|
| 152 |
+
|
| 153 |
+
**Stores** — `import { indexedDBStorage } from "everything-webgpu/adapters/idb"` (pages, plus
|
| 154 |
+
`ensurePersistent()`), `everything-webgpu/adapters/memory` (`memoryStorage()`, tests),
|
| 155 |
+
`everything-webgpu/adapters/webext` (`webExtensionStorage()` + `attachWebExtensionTransport()`).
|
| 156 |
+
|
| 157 |
+
```js
|
| 158 |
+
import { ScheduledEngine, ModelStore } from "everything-webgpu";
|
| 159 |
+
import { memoryStorage } from "everything-webgpu/adapters/memory";
|
| 160 |
+
const engine = new ScheduledEngine({ store: new ModelStore(memoryStorage()) });
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
### More on loading
|
| 164 |
+
|
| 165 |
+
**Warming the cache first** — `await engine.prefetch(modelId, { onProgress, signal })`. Downloads
|
| 166 |
+
the weights **without building an engine and without WebGPU**, so an app can warm the cache before
|
| 167 |
+
it knows whether the machine can run the model. Interrupted downloads resume; a second call is free.
|
| 168 |
+
WebLLM cannot express this — `reload()` needs a GPU before it fetches a shard.
|
| 169 |
+
|
| 170 |
+
**Low-level, still exported** — `load()` composes these rather than replacing them:
|
| 171 |
+
|
| 172 |
+
| call | does |
|
| 173 |
+
| --- | --- |
|
| 174 |
+
| `engine.registerModel(spec)` | add a `{ modelId, model, modelLib }` or `{ modelId, files }` record, no pool |
|
| 175 |
+
| `ingestModelFolder(entries, { store })` | folder → populated Cache Storage, returns the record |
|
| 176 |
+
| `filesFromInput(input.files)` / `filesFromDataTransfer(dt)` | either browser shape → flat `{ path, file }[]` (the latter is async) |
|
| 177 |
+
| `prefetchModel({ modelId, record, ... })` / `resolveModelUrl(...)` | the engine-free download primitives |
|
| 178 |
+
|
| 179 |
+
### The general calls
|
| 180 |
+
|
| 181 |
+
Every call here goes through the **same scheduler** as `ask` / `conversation` / `chat.completions`:
|
| 182 |
+
priority bands, `session` supersession, one-task-one-engine, opt-in preemption.
|
| 183 |
+
|
| 184 |
+
| call | shape |
|
| 185 |
+
| --- | --- |
|
| 186 |
+
| `await engine.complete(payload, onChunk?)` | `{ text, usage, finishReason, cancelled?, preempted? }`. `onChunk(delta)` streams plain text. |
|
| 187 |
+
| `await engine.completeRaw(payload, onRawChunk?)` | same, but `onRawChunk` gets WebLLM's chunk object verbatim. |
|
| 188 |
+
| `await engine.batch({ requests, task?, ...sched }, onItem?)` | `requests` fanned across the pool as **one task**. Returns `BatchItem[]` — each with `index`, `engineIndex`, `startedAt`, `finishedAt`, and `text`/`usage` or `error`. |
|
| 189 |
+
|
| 190 |
+
**`payload`** is the OpenAI generation fields plus the scheduling fields — the same table as [Native
|
| 191 |
+
passthrough](#native-passthrough) (`modelId`, `id`, `task`, `session`, `priority`, `preemptible`).
|
| 192 |
+
|
| 193 |
+
Result flags: `cancelled: true` (superseded or `cancel()`ed), `preempted: true` (`text` is partial).
|
| 194 |
+
|
| 195 |
+
### Ghost text — `engine.ghostText(opts)`
|
| 196 |
+
|
| 197 |
+
`engine.ghostText({ prompt, debounceMs?, maxTokens?, session?, ...defaults })` — debounce + one
|
| 198 |
+
session key + `interactive` priority + **resolves `null` when stale**. `prompt` is **required**, no
|
| 199 |
+
default: prompts are model-specific and belong to whoever owns the feature.
|
| 200 |
+
|
| 201 |
+
```js
|
| 202 |
+
const ghost = engine.ghostText({ prompt: (before) => `Continue:\n${before}` });
|
| 203 |
+
const hint = await ghost.suggest(editor.textBefore()); // string | null
|
| 204 |
+
ghost.cancel(); // on blur / accept
|
| 205 |
+
```
|
| 206 |
+
|
| 207 |
+
### Embeddings
|
| 208 |
+
|
| 209 |
+
Needs an **embedding model** (`snowflake-arctic-embed-*`, from 239 MB), usually held resident
|
| 210 |
+
alongside a chat model.
|
| 211 |
+
|
| 212 |
+
| call | returns |
|
| 213 |
+
| --- | --- |
|
| 214 |
+
| `await engine.embed(input, opts?)` | `number[][]` — one vector per input, in order |
|
| 215 |
+
| `await engine.embedRaw(input, opts?)` | WebLLM's OpenAI envelope (`data[].embedding`) |
|
| 216 |
+
|
| 217 |
+
`opts`: `modelId`, `task`, `session`, `priority`, `preemptible`, `id`. **A running embedding cannot
|
| 218 |
+
be interrupted** — one forward pass has no decode loop to break out of; queued embeddings supersede
|
| 219 |
+
normally.
|
| 220 |
+
|
| 221 |
+
### Residency and cache
|
| 222 |
+
|
| 223 |
+
A resident model is a full copy of its weights in VRAM, and nothing reports free VRAM to a page —
|
| 224 |
+
so residency is explicit.
|
| 225 |
+
|
| 226 |
+
| call | frees | keeps |
|
| 227 |
+
| --- | --- | --- |
|
| 228 |
+
| `await engine.unload()` | current model's VRAM | cache + registry |
|
| 229 |
+
| `await engine.unload(id)` | that model's VRAM | cache + registry |
|
| 230 |
+
| `await engine.unload(id, "cache")` | VRAM + cached bytes | registry entry |
|
| 231 |
+
| `await engine.unloadAll()` | every resident model's VRAM | cache + registry |
|
| 232 |
+
| `await engine.remove(id)` | bytes + registry entry | nothing — for an injected model, means re-supplying the folder |
|
| 233 |
+
| `engine.evict(id)` | low-level primitive `unload(id, "cache")` is built on | registry entry |
|
| 234 |
+
|
| 235 |
+
**Routing without loading** — `engine.use(id)` points unaddressed requests at an already-resident
|
| 236 |
+
model (free and instant; `load()` is what costs). `engine.resident` lists model ids with a live
|
| 237 |
+
pool. `await engine.cacheState(id)` says what is on disk (`"complete"` / `"partial"` / absent).
|
| 238 |
+
|
| 239 |
+
### Inspecting the machine
|
| 240 |
+
|
| 241 |
+
The device surface behind the [Start here](#start-here) preflight.
|
| 242 |
+
|
| 243 |
+
| call | answers |
|
| 244 |
+
| --- | --- |
|
| 245 |
+
| `await engine.canRun(modelId)` | per-**model**: `{ ok, blockers, warnings }`, before anything downloads |
|
| 246 |
+
| `await engine.recommendModels(opts?)` | which models this device should be asked to run, best first. `opts`: `maxVramMB`, `needsVision`, `needsToolCalling`, `prefer` |
|
| 247 |
+
| `await engine.estimateSpeed(modelId?)` | projected decode tok/s (uses the measured rate once one generation has happened) |
|
| 248 |
+
| `await engine.probe()` | raw device probe: WebGPU, adapter, `shader-f16`, the five limits, storage quota. Cached. |
|
| 249 |
+
| `await engine.features()` | what is switched **on** now, vs what the device could support. `multiStepOff` is non-null when decode fell back to one GPU sync per token — the silent halving `environment()` reports as `degraded` |
|
| 250 |
+
| `engine.hasWebGPU` | `Boolean(navigator.gpu)` |
|
| 251 |
+
| `await engine.listAvailableModels()` | registered + prebuilt, normalised. Costs one bundle fetch. |
|
| 252 |
+
| `engine.listModels()` | registered only — cheap, no bundle load |
|
| 253 |
+
|
| 254 |
+
### Configuration
|
| 255 |
+
|
| 256 |
+
`await engine.configure(patch)` — applies a runtime knob and persists it as the default.
|
| 257 |
+
|
| 258 |
+
| knob | effect |
|
| 259 |
+
| --- | --- |
|
| 260 |
+
| `decodeSteps` | forward steps per GPU sync. Hot, no reload. `1`–`32` (`DEFAULT_DECODE_STEPS` = 15). |
|
| 261 |
+
| `engineCount` | pool size. Persisted; live pools keep the size they came up with. |
|
| 262 |
+
| `temperature`, `maxTokens`, `systemPrompt` | generation defaults (`DEFAULT_SETTINGS`) |
|
| 263 |
+
|
| 264 |
+
Not operable from JS, report-only via `environment()`: KV reuse (derived from the 9-storage-buffer
|
| 265 |
+
cap), compute-pass batching (build-time `NO_PASS_MERGE`), `shader-f16`, GPU, `about:config` flags.
|
| 266 |
+
|
| 267 |
+
### Lifecycle and cancellation
|
| 268 |
+
|
| 269 |
+
| call | |
|
| 270 |
+
| --- | --- |
|
| 271 |
+
| `const stop = engine.subscribe(listener)` | `listener(state)` fires immediately, then on every change. Returns unsubscribe. |
|
| 272 |
+
| `engine.state` | snapshot: `status`, `modelId`, `progress`, `error`, `pool {size,busy,queued,maxSize,growthBlocked}`, `resident`, `decode` |
|
| 273 |
+
| `engine.store` | the `ModelStore`, so a host can drive the registry without a second handle |
|
| 274 |
+
| `engine.cancel(idOrSession)` | cancel by job id or by session key |
|
| 275 |
+
| `engine.load(id, { signal })` | an `AbortController` signal tears down an in-flight download |
|
| 276 |
+
|
| 277 |
+
`state.status` is one of `ENGINE_STATE`: `"idle"` · `"loading"` · `"ready"` · `"error"`.
|
| 278 |
+
|
| 279 |
+
## Errors
|
| 280 |
+
|
| 281 |
+
Every failure is an `EngineError` with a `.code`, a human-readable `.message` (the thing you print),
|
| 282 |
+
and structured `.detail`. `import { isEngineError, ERROR } from "everything-webgpu"`.
|
| 283 |
+
|
| 284 |
+
| code | what to do |
|
| 285 |
+
| --- | --- |
|
| 286 |
+
| `NO_WEBGPU` | tell the user to check flags/hardware; retrying is futile |
|
| 287 |
+
| `NO_MODEL` | nothing registered — send them to your setup flow |
|
| 288 |
+
| `UNKNOWN_MODEL` | that id is not resolvable; `listAvailableModels()` says what is |
|
| 289 |
+
| `CACHE_INCOMPLETE` | a locally-registered model was evicted; re-register the folder |
|
| 290 |
+
| `INVALID_MODEL_FOLDER` | not a compiled MLC model; `detail` says what is missing |
|
| 291 |
+
| `BAD_REQUEST` | the caller's arguments are wrong — a bug in the caller |
|
| 292 |
+
| `ABORTED` | the caller cancelled it. Not a failure; do not report it as one |
|
| 293 |
+
| `GENERATION_FAILED` | the model failed mid-generation |
|
| 294 |
+
| `PACKAGE_INCOMPLETE` | your **build** is wrong, not your code — missing `vendor/` bundle, or a decode worker the bundler did not emit. `message` names the fix; `detail.cause` says which |
|
| 295 |
+
|
| 296 |
+
```js
|
| 297 |
+
try { await engine.load(id); }
|
| 298 |
+
catch (err) {
|
| 299 |
+
if (isEngineError(err, ERROR.CACHE_INCOMPLETE)) return reRegisterFolder();
|
| 300 |
+
throw err;
|
| 301 |
+
}
|
| 302 |
+
```
|
| 303 |
+
|
| 304 |
+
## Bundlers
|
| 305 |
+
|
| 306 |
+
The engine spawns its decode worker with `new Worker(new URL("./engine-worker.js",
|
| 307 |
+
import.meta.url), { type: "module" })`. On **Vite**, its dependency pre-bundler rewrites that URL to
|
| 308 |
+
a path that 404s — in `vite dev`, on a real (non-linked) install only. Add the plugin:
|
| 309 |
+
|
| 310 |
+
```js
|
| 311 |
+
import { everythingWebGPU } from "everything-webgpu/vite";
|
| 312 |
+
export default defineConfig({ plugins: [everythingWebGPU()] });
|
| 313 |
+
```
|
| 314 |
+
|
| 315 |
+
Equivalent by hand: `optimizeDeps: { exclude: ["everything-webgpu"] }`. Skip both and `load()`
|
| 316 |
+
throws `PACKAGE_INCOMPLETE` naming the fix rather than hanging. `vite build` is unaffected either
|
| 317 |
+
way. Other bundlers that honour `new URL(..., import.meta.url)` for workers (Webpack 5, Rollup,
|
| 318 |
+
Parcel 2) need nothing.
|
| 319 |
+
|
| 320 |
+
## Every export
|
| 321 |
+
|
| 322 |
+
`import { … } from "everything-webgpu"` — 43 names.
|
| 323 |
+
|
| 324 |
+
**Engine & entry** — `ScheduledEngine`, `CreateScheduledEngine`, `EnginePool`
|
| 325 |
+
|
| 326 |
+
**Model sources** — `ModelStore`, `ingestModelFolder`, `filesFromInput`, `filesFromDataTransfer`,
|
| 327 |
+
`prefetchModel`, `resolveModelUrl`, `isInjected`, `baseUrlFor`, `groupKeysByScope`, `toAppConfig`
|
| 328 |
+
|
| 329 |
+
**Recipes** (also methods on the engine) — `ask`, `conversation`, `ghostText`
|
| 330 |
+
|
| 331 |
+
**Device** — `probeDevice`, `canRun`, `projectSpeed`, `rankModels`, `REFERENCE_DECODE_BYTES_PER_SECOND`
|
| 332 |
+
|
| 333 |
+
**Multi-step decoding** — `installMultiStepDecoding`, `burstSize`, `clampSteps`,
|
| 334 |
+
`DEFAULT_DECODE_STEPS`, `MAX_DECODE_STEPS`
|
| 335 |
+
|
| 336 |
+
**Errors** — `EngineError`, `ERROR`, `isEngineError`, `asEngineError`
|
| 337 |
+
|
| 338 |
+
**Formatting** — `formatBytes`
|
| 339 |
+
|
| 340 |
+
**Enums / constants** — `PRIORITY`, `PRIORITY_ORDER`, `ENGINE_STATE`, `UNLOAD_LEVEL`, `SEVERITY`,
|
| 341 |
+
`MODEL_TYPE`, `SOURCE`, `DEFAULT_SETTINGS`, `WORKER_CONFIGURE`, `CACHE_CONFIG`, `CACHE_MODEL`,
|
| 342 |
+
`CACHE_WASM`
|
| 343 |
+
|
| 344 |
+
### Enum values
|
| 345 |
+
|
| 346 |
+
| enum | values |
|
| 347 |
+
| --- | --- |
|
| 348 |
+
| `PRIORITY` | `interactive` · `normal` · `background` |
|
| 349 |
+
| `ENGINE_STATE` | `idle` · `loading` · `ready` · `error` |
|
| 350 |
+
| `UNLOAD_LEVEL` | `vram` · `cache` |
|
| 351 |
+
| `SEVERITY` | `blocked` · `degraded` · `tune` · `info` · `ok` |
|
| 352 |
+
| `SOURCE` | `prebuilt` · `remote` · `injected` |
|
| 353 |
+
| `MODEL_TYPE` | `llm` = 0 · `embedding` = 1 · `vlm` = 2 |
|
| 354 |
+
| `CACHE_*` | `webllm/config` · `webllm/model` · `webllm/wasm` |
|
| 355 |
+
| `DEFAULT_SETTINGS` | `engineCount: 2`, `decodeSteps: 15`, `temperature: 0.6`, `maxTokens: 1024`, `systemPrompt: ""` |
|
| 356 |
+
|
| 357 |
+
## Subpath exports
|
| 358 |
+
|
| 359 |
+
| specifier | |
|
| 360 |
+
| --- | --- |
|
| 361 |
+
| `everything-webgpu` | everything above |
|
| 362 |
+
| `everything-webgpu/vite` | `everythingWebGPU()` Vite plugin |
|
| 363 |
+
| `everything-webgpu/worker` | the decode worker entry (for a custom `workerUrl`) |
|
| 364 |
+
| `everything-webgpu/adapters/idb` | `indexedDBStorage()` |
|
| 365 |
+
| `everything-webgpu/adapters/memory` | `memoryStorage()` |
|
| 366 |
+
| `everything-webgpu/adapters/webext` | `webExtensionStorage()`, `attachWebExtensionTransport()` |
|
| 367 |
+
| `everything-webgpu/adapters/protocol` | the wire-protocol constants |
|
ARCHIVE.md
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Archive — done, and why
|
| 2 |
+
|
| 3 |
+
Completed work, kept for its **reasoning** rather than its status. A decision recorded without its
|
| 4 |
+
cause gets re-litigated by the next session, or quietly reversed.
|
| 5 |
+
|
| 6 |
+
Open work is in [ROADMAP.md](ROADMAP.md). What is true and measured is in [AI.md](AI.md).
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## The extraction — `main` became a library
|
| 11 |
+
|
| 12 |
+
`main` is a library for developers embedding a local model in their own app; `demo` keeps the Firefox
|
| 13 |
+
extension and is the library's first consumer.
|
| 14 |
+
|
| 15 |
+
**Phase 0 — froze the extension.** `demo` branch, `demo-baseline` tag.
|
| 16 |
+
|
| 17 |
+
**Phase 1 — decoupled the three platform dependencies.** `background.js` went from 390 lines to 22.
|
| 18 |
+
The engine core (`src/engine/`) references no WebExtension API, asserted by a test — that claim is
|
| 19 |
+
only ever broken in the host nobody ran.
|
| 20 |
+
|
| 21 |
+
| | before | after |
|
| 22 |
+
| --- | --- | --- |
|
| 23 |
+
| Registry | `browser.storage.local` ×4 | `ModelStore` over an injected `StorageAdapter` — deliberately `get(key)`/`set(obj)`, the exact shape of `browser.storage.local`, so the WebExtension adapter is a passthrough |
|
| 24 |
+
| Worker | `browser.runtime.getURL(…)` | `new URL("./engine-worker.js", import.meta.url)` — understood by Vite/webpack/esbuild *and* correct on `moz-extension://`, so it replaces `getURL` rather than sitting beside it |
|
| 25 |
+
| Transport | 6 `browser.runtime` listeners | `attachWebExtensionTransport(engine)`; wire format unchanged byte-for-byte |
|
| 26 |
+
|
| 27 |
+
The wire protocol was **demoted**, not removed: in a page there is no `sendMessage`, so `OP`/`PORT_OP`
|
| 28 |
+
became one adapter's vocabulary rather than the interface.
|
| 29 |
+
|
| 30 |
+
**Phase 2 — the developer-facing surface.**
|
| 31 |
+
|
| 32 |
+
- `CreateScheduledEngine()` + `chat.completions.create()`. Migration off `@mlc-ai/web-llm` costs one
|
| 33 |
+
line; everything after it is unchanged. `complete()`/`batch()` keep the names so `chat` stays free
|
| 34 |
+
for the facade.
|
| 35 |
+
- `EngineError { code, message, detail }` — 8 codes, each existing because a caller does something
|
| 36 |
+
*different* about it. A test walks `src/engine/` for bare `throw new Error`, because one untyped
|
| 37 |
+
throw forces every caller back to string matching.
|
| 38 |
+
- `.d.ts` generated from the JSDoc that was already there; only the request/result typedefs were
|
| 39 |
+
added. Source stays plain ESM.
|
| 40 |
+
- `@mlc-ai/web-llm` pinned to exactly `0.2.84` — `build.mjs` patches the bundle by string anchor and
|
| 41 |
+
throws on a miss, so a caret range would break every consumer's build.
|
| 42 |
+
|
| 43 |
+
## Zero-download stopped being a constraint
|
| 44 |
+
|
| 45 |
+
It existed because the *extension* could not reasonably download. A developer embedding a model
|
| 46 |
+
usually can, and often must. So a model now arrives by one of three routes, resolved by `load()`:
|
| 47 |
+
|
| 48 |
+
| route | how | validation |
|
| 49 |
+
| --- | --- | --- |
|
| 50 |
+
| `prebuilt` | one of WebLLM's 163 HuggingFace models | none needed |
|
| 51 |
+
| `remote` | `registerModel({ modelId, model, modelLib })`, any base URL | none — a bad URL is reported far better by WebLLM's loader than by a HEAD request |
|
| 52 |
+
| `injected` | `registerModel({ modelId, files })`, **no network at any point** | exhaustive, before the first byte is written |
|
| 53 |
+
|
| 54 |
+
Three things fell out that were not obvious going in:
|
| 55 |
+
|
| 56 |
+
1. **It cost almost no code.** `toAppConfig` already emitted `{model, model_id, model_lib}`; it had
|
| 57 |
+
only ever been handed `.invalid` URLs.
|
| 58 |
+
2. **It retired the top risk.** Cache injection on an ordinary page origin was risk #1 because the
|
| 59 |
+
whole design rested on it. It is now the offline route only.
|
| 60 |
+
3. **The routes cannot be confused, so downloads default on.** `.invalid` is reserved by RFC 6761
|
| 61 |
+
and can never resolve, so an injected model whose cache was evicted fails with a DNS error rather
|
| 62 |
+
than silently pulling a gigabyte. That is the *mechanism* of the offline guarantee, not a label
|
| 63 |
+
for it — a test asserts every URL such a record carries is on a `.invalid` host.
|
| 64 |
+
|
| 65 |
+
**Why local upload keeps a synthetic origin.** WebLLM composes every artifact URL as
|
| 66 |
+
`new URL(relative, base)` and runs the base through `cleanModelUrl`, which calls `new URL(...)` — so
|
| 67 |
+
the base must be absolute and resolvable. A `blob:` URL cannot serve as one, and there is no hook to
|
| 68 |
+
hand the loader bytes directly. Pre-populating the cache under WebLLM's own scopes and keys *is* its
|
| 69 |
+
native path. Seeding the cache for a URL the developer hosts was rejected: it would unify the record
|
| 70 |
+
shapes but make eviction silently re-download a gigabyte, which is the failure the design prevents.
|
| 71 |
+
|
| 72 |
+
## Model lifecycle — four states, three operations
|
| 73 |
+
|
| 74 |
+
The old API collapsed them, and that was a real bug: `remove()` deleted the cache **and** the
|
| 75 |
+
registry record, so for a remote model it threw away the only URL the bytes could be fetched from.
|
| 76 |
+
|
| 77 |
+
| state | VRAM | cache | record | leave via |
|
| 78 |
+
| --- | --- | --- | --- | --- |
|
| 79 |
+
| resident | ✅ | ✅ | ✅ | `unload(id)` |
|
| 80 |
+
| cached | — | ✅ | ✅ | `evict(id)` |
|
| 81 |
+
| registered | — | — | ✅ | `remove(id)` |
|
| 82 |
+
| unknown | — | — | — | — |
|
| 83 |
+
|
| 84 |
+
**Multiple resident models.** `#pool` became `#pools: Map<modelId, EnginePool>` with a current
|
| 85 |
+
selection. `use(id)` switches for free; a request naming a resident model routes to it *without*
|
| 86 |
+
changing which is current. Additive residency is opt-in (`{ keepResident: true }`) because each
|
| 87 |
+
resident model is a full copy of its weights and nothing reports free VRAM to a page.
|
| 88 |
+
|
| 89 |
+
## Device and compatibility
|
| 90 |
+
|
| 91 |
+
`probeDevice()` / `canRun()` / `rankModels()` answer "will this run here" before a byte is fetched.
|
| 92 |
+
The rules are this project's platform scars as code: the blocklisted adapter, Firefox's
|
| 93 |
+
9-storage-buffer cap, `q4f16_1` on a device without `shader-f16`.
|
| 94 |
+
|
| 95 |
+
Two things learned while building it:
|
| 96 |
+
|
| 97 |
+
- **Blockers and warnings must stay separate.** The 9-buffer cap costs KV reuse but is a *warning* —
|
| 98 |
+
blocking it would refuse the exact configuration this project ships on.
|
| 99 |
+
- **"Largest that fits" is bad default advice.** Decode is memory-bandwidth-bound, so the largest
|
| 100 |
+
model that fits is also the slowest thing that fits. `prefer: "quality" | "speed"` makes it the
|
| 101 |
+
caller's choice rather than an assumption.
|
| 102 |
+
|
| 103 |
+
`probeDevice()` never throws — an unusable device is a result to explain, not an exception.
|
| 104 |
+
|
| 105 |
+
## Stop reinventing WebLLM
|
| 106 |
+
|
| 107 |
+
An audit ([WEBLLM-SURFACE.md](WEBLLM-SURFACE.md)) found three functions reimplemented that WebLLM
|
| 108 |
+
exported all along. Cause: the bundle was treated as something to `grep` for narrow facts rather than
|
| 109 |
+
an API to survey once — `cleanModelUrl` was even *read on screen* and then rewritten, without asking
|
| 110 |
+
what else used it. This violated AI.md's own **Reuse First** principle.
|
| 111 |
+
|
| 112 |
+
| Removed | Replaced by |
|
| 113 |
+
| --- | --- |
|
| 114 |
+
| `cleanModelUrl()` reimplemented | nothing — it only propped up the two below |
|
| 115 |
+
| `ModelStore.cacheKeysFor()` | WebLLM derives the keys it fetched |
|
| 116 |
+
| `ModelStore.cacheState()` | `engine.cacheState()` → `hasModelInCache` for remote |
|
| 117 |
+
| `ModelStore.evict()` (all sources) | `evictInjected()` + `engine.evict()` → `deleteModelAllInfoInCache` |
|
| 118 |
+
| speed re-derived from the worker probe | `usage.extra.decode_tokens_per_s`, already on every response |
|
| 119 |
+
|
| 120 |
+
The routing rule is now explicit: **whoever wrote the bytes owns the keys.** Our path survives only
|
| 121 |
+
where it demonstrably does more — WebLLM's delete and cache-check both read `tensor-cache.json` to
|
| 122 |
+
enumerate shards, so once *that* file is evicted they can neither find nor clean the shards it
|
| 123 |
+
indexes. Injected records carry an explicit key list and have no such failure. There is a test for
|
| 124 |
+
exactly that case, and it is the only justification for keeping the code.
|
| 125 |
+
|
| 126 |
+
**Speed was worse than duplication:** the pool already set `include_usage` and already stored
|
| 127 |
+
`chunk.usage`, so the measurement was being *received and discarded* so the worker probe could
|
| 128 |
+
recompute it.
|
| 129 |
+
|
| 130 |
+
## Raw chunk pass-through, and the tool-calling bug
|
| 131 |
+
|
| 132 |
+
The pool stripped every chunk to `delta.content` and the facade rebuilt an envelope from scratch —
|
| 133 |
+
so `tool_calls` was dropped entirely (**tool calling returned nothing usable**), `logprobs` was
|
| 134 |
+
always `null`, and `created` was restamped per chunk. Chunks now pass through verbatim.
|
| 135 |
+
|
| 136 |
+
Nothing is synthesized on the normal path: WebLLM emits its own terminal `finish_reason` chunk and
|
| 137 |
+
its own usage chunk. The one exception is an interrupted generation, where the stream simply stops
|
| 138 |
+
and a consumer would otherwise never learn why.
|
| 139 |
+
|
| 140 |
+
**A correction to the plan that produced this.** It specified `mergeToolCallDeltas()` for
|
| 141 |
+
"standard OpenAI fragment accumulation". WebLLM does not stream fragments — it parses the whole
|
| 142 |
+
output message at the end and emits tool calls complete in one terminal chunk. Building the merge
|
| 143 |
+
would have been machinery for a wire shape that is never produced: the plan's own failure mode,
|
| 144 |
+
inside the plan meant to prevent it.
|
| 145 |
+
|
| 146 |
+
## Bugs found and fixed along the way
|
| 147 |
+
|
| 148 |
+
| | |
|
| 149 |
+
| --- | --- |
|
| 150 |
+
| `EnginePool.load()` leaked an engine | It awaited `createEngine` before assigning `#slots`, so an `unload()` in that window tore down an *empty* pool and the engine then installed itself into a pool nobody referenced — leaking a worker and a full copy of the weights. `#grow()` had always guarded this; `load()` never did. Fixed with a `#generation` counter. |
|
| 151 |
+
| `registerModel` accepted URLs that fail at load | WebLLM's `cleanModelUrl` ends in `new URL()` with no base, so a relative `/models/x/` throws deep in the loader. Now resolved at registration. |
|
| 152 |
+
| `state.modelId` / `resident` went stale | Views onto `#pools` that nothing re-synced after unload, and left pointing at a model that never came up after a failed load. |
|
| 153 |
+
| `probeDevice` threw on a partial `navigator.gpu` | A polyfill without `requestAdapter` produced a TypeError from a function documented never to throw. |
|
| 154 |
+
| The pool discarded `finish_reason` | A `max_tokens` truncation was indistinguishable from the model choosing to stop. |
|
| 155 |
+
| `store.remove()` stranded remote shards | It iterates `groupKeysByScope`, empty for remote records — and deleting the entry destroys the only URL those bytes could be derived from. Now `engine.remove()` evicts first. |
|
| 156 |
+
| `throw`-as-`goto` in `multistep.js` | Caught two lines below; replaced with the control flow it was emulating. |
|
| 157 |
+
| `features()` called a dead fast path healthy | It answered "is multi-step on?" with `decodeSteps > 1`, but the worker keeps posting the *configured* K — 15 — long after the contract check routed decoding to stock single-step. So the one call documented as "what is switched **on** now" reported `multiStepDecoding: true` for the exact fault it exists to surface, and never exposed `multiStepOff` at all. `environment()` escaped it only by reading `state.decode.multiStepOff` itself rather than trusting `features()`. Now `multiStepDecoding` is `decodeSteps > 1 && !multiStepOff?.length`, and `multiStepOff` is returned beside it. `decodeSteps` deliberately keeps reporting the knob's value: dropping it to 1 would make `environment()` advise `configure({ decodeSteps: 15 })` for a fault no setting can fix. |
|
| 158 |
+
|
| 159 |
+
## Corrections to the record
|
| 160 |
+
|
| 161 |
+
Kept because each was stated confidently and was wrong; a future reader should not re-derive them.
|
| 162 |
+
|
| 163 |
+
- **"A second engine measured 1.06x, so batched decode is the only route to concurrent throughput."**
|
| 164 |
+
This framed two complementary mechanisms as substitutes. A second engine buys *task isolation* —
|
| 165 |
+
a translation and a ghost-text completion running at once — and never claimed aggregate
|
| 166 |
+
throughput; the GPU is already saturated by a 2B. Batched decode makes *one task's* many requests
|
| 167 |
+
faster. Neither substitutes for the other.
|
| 168 |
+
- **"Multi-model residency via `#pools` was not necessary."** Wrong. `reload()` unconditionally calls
|
| 169 |
+
`unload()` first, so `reload([a,b])` is all-or-nothing — adding a third model reloads the first two
|
| 170 |
+
(~51 s each). Additive residency does not exist upstream.
|
| 171 |
+
- **"No load time is measured in the repo."** It is: 51 s, [AI.md](AI.md) line 77. A grep for the
|
| 172 |
+
wrong phrasing missed the table row.
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
## `npm run e2e` verified the extraction on real hardware
|
| 176 |
+
|
| 177 |
+
First run against the post-extraction tree: real Firefox, real GPU (Apple Silicon, `shader-f16`),
|
| 178 |
+
real `Qwen3.5-0.8B-q4f16_1-MLC`, drag-and-drop ingestion through the production `src/engine/` and
|
| 179 |
+
`src/adapters/webext.js` paths. **`e2e PASS`.**
|
| 180 |
+
|
| 181 |
+
- Ingest: 443,129,354 bytes, 11 shards, 2531 ms. Load: 48.1 s (AI.md's 51 s figure is for the larger
|
| 182 |
+
2B; the 0.8B here loading faster is consistent with that being memory-bandwidth-bound).
|
| 183 |
+
- Decode: 27.4 tok/s over 127 tokens — inside AI.md's measured 16.6–27.9 tok/s range for this model.
|
| 184 |
+
Decode probe: 664 kernels/token (639 forward + 25 sampling), 16.1 flushes/token — the same shape
|
| 185 |
+
the compute-pass-batching patch targets, and it is still applying (41.3 kernels/flush).
|
| 186 |
+
- KV-reuse path exercised and correct: paged prefill and forced ragged re-prefill produced identical
|
| 187 |
+
output on a multi-round conversation. Re-prefill slope 2.29 ms/token, in the neighbourhood of
|
| 188 |
+
AI.md's 5.27 ms/token figure (different model, different history length — not a direct comparison).
|
| 189 |
+
- The scheduler's own two-tasks-two-engines check: 3.8 s concurrent vs 4.0 s sequential = **1.05x**,
|
| 190 |
+
consistent with AI.md's measured 1.06x. This is the number the "second engine buys isolation, not
|
| 191 |
+
throughput" framing rests on, now reconfirmed after the pool moved to `#pools: Map<modelId,
|
| 192 |
+
EnginePool>` — evidence the multi-model split did not regress the single-model scheduling behaviour
|
| 193 |
+
it was built on top of.
|
| 194 |
+
|
| 195 |
+
**One number worth a second look, not treated as a finding here:** this run reported
|
| 196 |
+
`storageBuffersPerStage=9`. It did not block anything — the KV-reuse path was exercised in the same
|
| 197 |
+
run and passed — but it is the exact threshold `probeDevice()`'s `NO_KV_REUSE` warning keys off, so a
|
| 198 |
+
future run reporting the same value is worth cross-checking against `device.test.mjs`'s assumptions
|
| 199 |
+
rather than assumed benign a second time.
|
| 200 |
+
|
| 201 |
+
> **[resolved] A second run reported 9 again, and the cross-check says 9 is the baseline, not an
|
| 202 |
+
> anomaly.** AI.md has said so all along — the Firefox Metal backend caps
|
| 203 |
+
> `maxStorageBuffersPerShaderStage` at 9, which is the entire reason the `storage-buffer-limit`
|
| 204 |
+
> patch exists. What the cross-check *did* surface is sharper than the original worry:
|
| 205 |
+
> `device.test.mjs` defines a healthy device as `storageBuffers = 10`, so the "good" default in the
|
| 206 |
+
> test matrix describes hardware nobody here has. On the reference M4, `probe.kvReuse` is
|
| 207 |
+
> **always** `false`, `engine-worker.js` forces `resetChat()` on every prefill, and
|
| 208 |
+
> **`batch_prefill_paged_kv_kernel` has therefore never executed on real hardware** — it is
|
| 209 |
+
> mock-tested only. It would first run on a >=10-buffer device, i.e. Chrome (ROADMAP, Gate B).
|
| 210 |
+
>
|
| 211 |
+
> That also made the e2e's own multiround check misleading: with reuse forced off, its "with KV
|
| 212 |
+
> reuse" and "forced reprefill" branches both ran the ragged kernel, so `identical` was guaranteed
|
| 213 |
+
> and the line `paged prefill is fine` claimed something the run had not tested. The check now
|
| 214 |
+
> reports `UNVERIFIED for paged prefill` on a sub-10-buffer device and still fails if two ragged
|
| 215 |
+
> re-prefills of the same history disagree.
|
| 216 |
+
|
| 217 |
+
The manifest.json restore left a diff — `restore()` round-trips the file through
|
| 218 |
+
`JSON.parse`/`stringify`, which turns `\uXXXX`-escaped em-dashes back into literal UTF-8. Cosmetic,
|
| 219 |
+
not a behaviour change, reverted with `git checkout`. Worth knowing before the next e2e run leaves the
|
| 220 |
+
same diff and it looks like something broke.
|
| 221 |
+
|
| 222 |
+
> **[fixed]** It did leave the same diff on the next run. The snapshot now only round-trips through
|
| 223 |
+
> JSON when the tree is *actually* dirty (a run killed mid-flight leaves the patch behind); a clean
|
| 224 |
+
> file is restored byte-for-byte. The cost of the old behaviour was not untidiness — it was training
|
| 225 |
+
> the reader to ignore a dirty tree after an e2e, which is precisely when a real diff matters.
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
## Lossless WebLLM upgrade — a bump is minutes, not an afternoon
|
| 229 |
+
|
| 230 |
+
`@mlc-ai/web-llm` is pinned exactly because `build.mjs` rewrites the bundle by matching source text.
|
| 231 |
+
"Lossless" was never meant as "automatic" — it means a bump *fails at the right line* instead of
|
| 232 |
+
somewhere deep in a half-patched loader. The standing runbook is in
|
| 233 |
+
[WEBLLM-SURFACE.md](WEBLLM-SURFACE.md), "Upgrading"; this is why each piece exists.
|
| 234 |
+
|
| 235 |
+
Three guards, because there are three distinct ways an upgrade breaks us:
|
| 236 |
+
|
| 237 |
+
| drift | caught by | the failure it prevents |
|
| 238 |
+
| --- | --- | --- |
|
| 239 |
+
| **surface** — code moved or reformatted | `build/patches.mjs` verify-then-write | a patch anchor silently landing in the wrong place, or the build half-applying and reporting only the first miss |
|
| 240 |
+
| **semantic** — a symbol survives, its meaning changed | `test/webllm-contract.test.mjs` | an export deleted, a field renamed, an enum gaining a case — none of which throw |
|
| 241 |
+
| **behavioural** — every name and shape intact, output wrong | `npm run e2e` | a tvmjs refactor that changes numerics |
|
| 242 |
+
|
| 243 |
+
**Contract tests (2a).** Makes WEBLLM-SURFACE.md executable — every export and shape the project
|
| 244 |
+
depends on, asserted statically against the bundle, GPU-free, first in `npm test`. The
|
| 245 |
+
highest-value piece: it catches semantic drift, which the patches cannot see. Two guards beyond the
|
| 246 |
+
obvious list: the monkeypatch member list is *derived from `multistep.js`'s own source* so it cannot
|
| 247 |
+
go stale, and `model_lib` unguessability is asserted rather than assumed (if it became derivable,
|
| 248 |
+
the "do not guess" rule in the verb-consolidation section should be revisited). Each assertion class
|
| 249 |
+
was mutation-tested — which found a real bug: `bundle.includes(name)` still passes when
|
| 250 |
+
`processNextToken` becomes `processNextTokenV2`, since the old name stays a substring. Now
|
| 251 |
+
word-bounded.
|
| 252 |
+
|
| 253 |
+
**Patch self-check and fuzzy diagnostics (2b).** Patches moved to `build/patches.mjs` as data,
|
| 254 |
+
applied by a shared verifier. Every anchor is checked before anything is rewritten. `patch-manifest.json`
|
| 255 |
+
records the version the anchors last held against, so a bump announces `0.2.84 -> 0.2.85: verifying
|
| 256 |
+
4 anchor(s)` rather than silently succeeding. Vanished identifiers are matched against survivors by
|
| 257 |
+
trigram overlap — simulated upstream renaming `requiredMaxStorageBuffersPerShaderStage`, the
|
| 258 |
+
diagnostic found the replacement at 85% similarity with its line. Ambiguity is a hard stop too: an
|
| 259 |
+
anchor matching 1995 sites refuses rather than rewriting one at random. Two corrections the
|
| 260 |
+
simulation forced: rank candidate lines by summed *rarity* not hit count (raw count returned
|
| 261 |
+
`const msg = {` — true and useless), and a rename needs a human to *approve* the new anchor, not to
|
| 262 |
+
*find* it.
|
| 263 |
+
|
| 264 |
+
**Structured patches (2c).** The corrected expectation held: AST parsing survives *formatting*
|
| 265 |
+
drift, not renames — an AST search by name fails exactly as a string match does. So the gain is
|
| 266 |
+
narrower than "structured = durable", and the work matched the correction rather than the original
|
| 267 |
+
proposal. `in: { enclosing }` scopes an anchor to the function a *sibling anchor* matched in — a
|
| 268 |
+
matched anchor, not a function name, so it adds no identifier upstream could rename. This removes the
|
| 269 |
+
false-failure class around `compute.end();`, a string generic enough that any unrelated new compute
|
| 270 |
+
pass in tvmjs failed the build. Anchors also match modulo whitespace and are word-bounded — the
|
| 271 |
+
latter not in the plan and found the same way 2a's bug was: `compute.end();` is a substring of
|
| 272 |
+
`precompute.end();`. The rebuilt bundle is byte-identical to the string-replacing applier's output.
|
| 273 |
+
`acorn` is a devDependency, ~565 KB unpacked (the original estimate was off 10x), never shipped.
|
| 274 |
+
|
| 275 |
+
**Runtime monkeypatch guard (2d).** `multistep.js` drives ~30 undocumented tvmjs internals; a rename
|
| 276 |
+
turns the fast path off *silently* — ~18 -> ~10 tok/s with nothing in the log. 2a covers the static
|
| 277 |
+
half. The runtime half: `PIPELINE_CONTRACT` + `missingPipelineMembers()`, checked against the live
|
| 278 |
+
pipeline at first decode (there is no pipeline at install time — the engine gets one per `reload()`),
|
| 279 |
+
verdict cached. Three buckets, because presence is not the failure that hurts: `calls` must be
|
| 280 |
+
callable (a rename throws — loud), `numbers` must be numbers (`x += 1` on an absent member creates a
|
| 281 |
+
property and the KV accounting drifts — silent), `reads` need only exist. Failure is loud once per
|
| 282 |
+
pipeline and posts `multiStepOff` to the host, which is otherwise indistinguishable from an idle
|
| 283 |
+
engine since `onBurst` is the only thing that reports stats. Found while building it: 2a's derivation
|
| 284 |
+
matched `\bpipeline\.` and missed members reached across a line break — the softmax at the heart of
|
| 285 |
+
the burst had no rename guard for as long as that test existed. Now whitespace-tolerant, comments
|
| 286 |
+
stripped first, and `PIPELINE_CONTRACT` is asserted to equal what the source actually reaches for.
|
| 287 |
+
|
| 288 |
+
**The flow, documented (2e).** Moved to WEBLLM-SURFACE.md so the doc you must revise on a bump is
|
| 289 |
+
the doc that tells you how.
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
## Verb consolidation — the ergonomic layer
|
| 293 |
+
|
| 294 |
+
`chat.completions.create()` is the compatibility layer and never changes. Everything here is
|
| 295 |
+
*additional* — the verbs a developer reaches for when they are not porting WebLLM code.
|
| 296 |
+
|
| 297 |
+
**`load(src, opts)` — one polymorphic entry.** Absorbs `load` + `registerModel` +
|
| 298 |
+
`ingestModelFolder`. Dispatch is a pure, synchronous `classifySource()` in
|
| 299 |
+
[src/engine/sources.js](src/engine/sources.js), so all four shapes — prebuilt id, HF/hosted URL,
|
| 300 |
+
`{model, modelLib}`, folder off disk — are testable with no GPU and no store. `registerModel` and
|
| 301 |
+
`ingestModelFolder` stay exported unchanged; `load()` composes them.
|
| 302 |
+
|
| 303 |
+
Two dispatch rules were **dropped after measuring**, both because a wrong guess surfaces as a 404
|
| 304 |
+
deep inside the loader:
|
| 305 |
+
|
| 306 |
+
- **`modelLib` is never guessed.** `<base><id>-webgpu.wasm` matches **0 of 163** prebuilt models
|
| 307 |
+
(real names carry a `_cs1k`-style suffix, drop `-MLC`) and **0 of 163** host the lib on the
|
| 308 |
+
weights' origin (they live on `raw.githubusercontent.com`). A remote source without `modelLib`
|
| 309 |
+
fails in the classifier with that sentence, before any fetch.
|
| 310 |
+
- **`/resolve/main/` is not derived for HF URLs.** WebLLM's `cleanModelUrl` already appends it; doing
|
| 311 |
+
it ourselves double-applies. A test asserts the stored URL is byte-identical to what was passed.
|
| 312 |
+
|
| 313 |
+
The id *is* derived from the URL's last segment — safe where `modelLib` is not, because an id is a
|
| 314 |
+
key in our own registry, never a path anything fetches, so a wrong guess is visible immediately and
|
| 315 |
+
free. `{ id }` overrides. `defer: true` on a bare prebuilt id is an **error**, not a silent load —
|
| 316 |
+
that silent load is the trap the whole section exists to avoid. Unknown ids get near-match hints.
|
| 317 |
+
|
| 318 |
+
Six mutation tests. One false pass worth remembering: the near-match hint appears at **two** error
|
| 319 |
+
sites and `String.replace` mutated only the first, so a working guard looked untested — a mutation
|
| 320 |
+
that does not apply is indistinguishable from a guard that does not work. Two latent crashes fixed
|
| 321 |
+
on the way: `filesFromInput`/`filesFromDataTransfer` spread their argument, so an array-like-but-not-
|
| 322 |
+
iterable `FileList`/`DataTransferItemList` died with `fileList is not iterable` three frames from
|
| 323 |
+
the caller's drop handler. Both use `Array.from` now.
|
| 324 |
+
|
| 325 |
+
**`unload(id, level)` — two depths, not two verbs.** `UNLOAD_LEVEL` is `"vram"` (default: free
|
| 326 |
+
VRAM, keep cache + record) or `"cache"` (also delete bytes, keep record — the old `evict()`).
|
| 327 |
+
`remove()` keeps its own verb: it is the one that cannot be undone without re-supplying the source.
|
| 328 |
+
An unrecognised level is refused with an error pointing at `remove()`, because "forget this model"
|
| 329 |
+
is the reading someone will try to spell as a level and it is the destructive one.
|
| 330 |
+
|
| 331 |
+
**[settled] A bare `unload()` frees only the current model**, with `unloadAll()` explicit for the
|
| 332 |
+
rest. The plan had the bare call free *everything*; shipping that silently would trap anyone already
|
| 333 |
+
calling `unload()`. "Free everything" is the more destructive reading and should be asked for by
|
| 334 |
+
name. `#evictBytes()` was split out of `evict()` so `unload(id, "cache")` reaches the bytes without
|
| 335 |
+
re-entering the class for a pool just torn down.
|
| 336 |
+
|
| 337 |
+
**`environment()` — read-only report; `.measure()` on it.** Absorbs `probe` + `features` +
|
| 338 |
+
`estimateSpeed` for the *reporting* half. [src/engine/environment.js](src/engine/environment.js), a
|
| 339 |
+
callable `engine.environment` cached like `chat`. Three open questions were all resolved by one
|
| 340 |
+
decision — **split read from write**: `environment()` reports only, writes go through `configure()`,
|
| 341 |
+
and passing a setting to `environment()` is an *error naming `configure()`*, not a silent no-op.
|
| 342 |
+
Implicit read/write dispatch by argument shape is the opposite of foolproof — the "reject or
|
| 343 |
+
write-then-report?" question had no intuitive answer precisely because one function was doing two
|
| 344 |
+
jobs.
|
| 345 |
+
|
| 346 |
+
Every report line carries `severity` · `affects` · `cause` · `fix` · `operable`, with **`fix: null`
|
| 347 |
+
⟺ `operable: false`** asserted for every line — hardware, build-time flags and browser settings
|
| 348 |
+
report a consequence with no remedy, which is still the difference between a bug report and an
|
| 349 |
+
informed decision. A **blocked device short-circuits the report**: "K=15 forward steps per GPU sync"
|
| 350 |
+
next to "no model can load" is true and useless. `configure()` grew `engineCount` because the report
|
| 351 |
+
advertises it as operable and a report naming a call that throws is worse than no report — it is
|
| 352 |
+
persisted, not hot, and `environment()` reports that gap rather than pretending. The `multiStepOff`
|
| 353 |
+
guard (§2d) finally has a consumer: a `degraded` line naming the missing internal, where before it
|
| 354 |
+
was posted by the worker and read by nothing.
|
| 355 |
+
|
| 356 |
+
Seven mutation tests. One real hole found: "`local` never fetches" was tested with a fetch counter,
|
| 357 |
+
but `load()` caches the model's size so `estimateSpeed()` short-circuits and *neither* scope fetches
|
| 358 |
+
after a load. The guarantee is structural — `local` never consults the model layer — and is tested
|
| 359 |
+
that way now.
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
## Engine capability — prefetch, embeddings, recipes
|
| 363 |
+
|
| 364 |
+
**`prefetch(modelId)`** — [src/engine/prefetch.js](src/engine/prefetch.js). Downloads a model with
|
| 365 |
+
no engine and **no WebGPU at all**: an app can warm the cache before it knows whether the machine
|
| 366 |
+
can run the model. Resumes; a second call is free.
|
| 367 |
+
|
| 368 |
+
The hard part: fetching the artifacts ourselves means deriving their URLs — the `/resolve/main/`
|
| 369 |
+
rule the verb-consolidation work above refused to derive. That refusal still holds; it was about not
|
| 370 |
+
deriving a URL *WebLLM will derive again at load*, which double-applies. Here WebLLM is not in the
|
| 371 |
+
loop — we are the loader. What makes it safe is not trusting the derivation: a key off by one
|
| 372 |
+
character writes a cache the loader never reads, and prefetch would report success while the user
|
| 373 |
+
downloads the model twice. So every prefetch ends by asking **WebLLM's own `hasModelInCache`** —
|
| 374 |
+
which derives through the very function we mirror — and throws if it says no. The contract test also
|
| 375 |
+
pulls `cleanModelUrl` out of the bundle and *runs* it against ours on six URL shapes, so an upstream
|
| 376 |
+
scheme change fails a test, not a download. Seven mutation tests, all caught.
|
| 377 |
+
|
| 378 |
+
**Embeddings (`engine.embed()`)** — a `kind` field on the job and one branch in
|
| 379 |
+
[pool.js](src/engine/pool.js) `#start`. **One pool, not two**: priority, supersession, preemption
|
| 380 |
+
and one-task-one-engine are identical for both kinds; only the call at the far end differs. A second
|
| 381 |
+
pool would have duplicated the scheduler to change one line. `embed()` returns bare vectors,
|
| 382 |
+
`embedRaw()` keeps WebLLM's envelope. **Known limit:** a running embedding cannot be interrupted —
|
| 383 |
+
`interruptGenerate()` works by making a decode loop break out, and one forward pass has no loop, so
|
| 384 |
+
a cancel that lands after the job starts marks it cancelled without stopping it. Stated in the
|
| 385 |
+
JSDoc, the README and a `[known limit]` test rather than left to be discovered. Six mutations, five
|
| 386 |
+
caught; the sixth was *equivalent* — `#start` decides on an explicit `=== EMBEDDING` and any unknown
|
| 387 |
+
kind routes to chat either way.
|
| 388 |
+
|
| 389 |
+
**Recipes — `ask()`, `conversation()`, `ghostText()`** — [src/engine/recipes.js](src/engine/recipes.js),
|
| 390 |
+
also methods on the engine. Scope grew on request: one command for each of the three things apps
|
| 391 |
+
actually want. The scheduling shipped as specified — one stable `session`, `interactive`, short
|
| 392 |
+
`max_tokens`, debounce, `cancel()` on blur, stale contexts dropped — and **prompts stayed with the
|
| 393 |
+
caller**: `ghostText({ prompt })` is required with no default; `ask`/`conversation` carry the
|
| 394 |
+
caller's text through. The engine authors nothing.
|
| 395 |
+
|
| 396 |
+
The piece worth keeping: `suggest()` **resolves `null` when stale**. The engine already superseded
|
| 397 |
+
the work; what a caller still had to remember was not to *paint* the answer that came back anyway.
|
| 398 |
+
Returning `null` removes the choice — the difference between a policy and a wrapper.
|
| 399 |
+
`conversation()` bounds history at 12 exchanges, derived from AI.md's numbers: with no cross-turn KV
|
| 400 |
+
reuse every turn re-prefills at ~5.27 ms/token, so unbounded history is quadratic and a turn near
|
| 401 |
+
the limit waits ~22 s. `keep: Infinity` opts out.
|
| 402 |
+
|
| 403 |
+
Found and fixed: a **promise leak in the debounce**. A newer keystroke called `clearTimeout` on the
|
| 404 |
+
previous waiter, whose `await` then had nothing to resolve it — every superseded keystroke leaked a
|
| 405 |
+
promise that never settled, and `Promise.all` over a burst hung forever. A superseded waiter has to
|
| 406 |
+
be woken and told it lost, not merely disarmed. Twelve mutation tests; two initially passed — one
|
| 407 |
+
equivalent, one genuinely vacuous: `sent[0].session === sent[1].session` also holds when *neither*
|
| 408 |
+
has a session, which is exactly the regression it was meant to catch. Presence is asserted before
|
| 409 |
+
equality now.
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
## Shipping 0.1.0 — installable, documented, on npm
|
| 413 |
+
|
| 414 |
+
The library was extracted, tested and complete, and served the project's goal — "make WebLLM easier
|
| 415 |
+
to use, foolproof to build on" — **for nobody**, because it was unpublished, undocumented as a whole
|
| 416 |
+
surface, and un-installable. This section is the gap between "the code is done" and "a developer can
|
| 417 |
+
`npm i` it and run four lines."
|
| 418 |
+
|
| 419 |
+
**Four-line target, met without an engine change.** `import` / `CreateScheduledEngine(id)` /
|
| 420 |
+
`engine.ask(prompt)` / read the string. Probing the shape found three things that stopped it being
|
| 421 |
+
*usable*:
|
| 422 |
+
|
| 423 |
+
1. **Un-installable.** `vendor/web-llm.js` is a build product and is gitignored; there was only a
|
| 424 |
+
`prepublishOnly`, and npm runs **`prepare`** for a git dependency. So `npm i` 404'd (`"private":
|
| 425 |
+
true`) and a git dependency installed but could not run, failing with `GENERATION_FAILED: Cannot
|
| 426 |
+
find module .../vendor/web-llm.js` — wrong twice, since nothing had begun generating and the path
|
| 427 |
+
named is ours. Now `"prepare": "node build.mjs"`, `private` removed. Verified by deleting
|
| 428 |
+
`vendor/` and running `npm install` — it comes back.
|
| 429 |
+
2. **Looked like a hung process.** No `initProgressCallback` meant zero output during a minutes-long
|
| 430 |
+
~0.8 GB download. `CreateScheduledEngine` now distinguishes three states: `undefined` → a
|
| 431 |
+
throttled console reporter (1 line/second; 58 shard callbacks → 2 lines; the 100% report is never
|
| 432 |
+
dropped), `null` → explicit silence, a function → unchanged. **`new ScheduledEngine()` stays
|
| 433 |
+
silent** — a library core that logs is wrong in a worker, an extension background page, or a
|
| 434 |
+
test. This is the getting-started facade only.
|
| 435 |
+
3. **The Vite worker-URL break.** Vite's dependency pre-bundler copies `new Worker(new
|
| 436 |
+
URL("./engine-worker.js", import.meta.url))` verbatim into `node_modules/.vite/deps/`, where the
|
| 437 |
+
sibling file does not exist — `vite dev` only, real (non-linked) install only, `vite build`
|
| 438 |
+
unaffected. `everything-webgpu/vite` ships a plugin (`optimizeDeps.exclude`, the manual
|
| 439 |
+
equivalent still documented). And if a consumer does neither, `load()` now fails with
|
| 440 |
+
`PACKAGE_INCOMPLETE` naming the fix, because `new Worker()` does not throw on a 404 — it fires one
|
| 441 |
+
`error` event and goes quiet, so the handshake is raced against it.
|
| 442 |
+
|
| 443 |
+
**`PACKAGE_INCOMPLETE` is one code for two causes** (`detail.cause` separates them). No caller
|
| 444 |
+
writes a different `catch` branch: both mean "your build is wrong, this app has not shipped," both
|
| 445 |
+
are fixed in config. A second code would grow the table a caller reads without giving them anything
|
| 446 |
+
to do.
|
| 447 |
+
|
| 448 |
+
**`verify-consumer` — the only test that can see the consumer's world.** Everything under `test/`
|
| 449 |
+
and every `examples/` project reaches the package through a *linked* path, and Vite never
|
| 450 |
+
pre-bundles a linked package — so none of them can exercise the one failure that reaches users. This
|
| 451 |
+
blind spot produced a **wrong claim in the docs**: that `optimizeDeps.exclude` was needed for `vite
|
| 452 |
+
build` and that the examples proved it. Measured on a real tarball install, neither holds — build
|
| 453 |
+
output is byte-identical with and without it. `npm run verify-consumer` packs the tarball, installs
|
| 454 |
+
it for real, and asserts three outcomes separately: `vite build` emits the worker chunk and keeps
|
| 455 |
+
WebLLM lazy; `vite dev` **without** the plugin still 404s the worker; `vite dev` with it resolves.
|
| 456 |
+
The middle one is asserted as a *failure* on purpose — a fix whose absence changes nothing is not a
|
| 457 |
+
fix, and if Vite ever stops pre-bundling this package that assertion says the plugin is dead weight.
|
| 458 |
+
|
| 459 |
+
**`API.md` — every call form on one page,** asserted by
|
| 460 |
+
[test/api-doc.test.mjs](test/api-doc.test.mjs), derived from the source the way `readme.test.mjs`
|
| 461 |
+
is: every `engine.x(` named resolves to a real member, **no public member is left undocumented**
|
| 462 |
+
(the reverse direction the README test lacks), the error table equals `ERROR`, every export
|
| 463 |
+
appears, enum-value rows match the real objects, the subpath table equals `package.json` `exports`.
|
| 464 |
+
Writing it found `engine.store` undocumented and a regex reading enum rows as error codes.
|
| 465 |
+
|
| 466 |
+
**`examples/` — bare, react, webext,** each a standalone project depending on the package as
|
| 467 |
+
`file:../..` so it resolves through the **`exports` map** — an example importing
|
| 468 |
+
`../../src/engine/index.js` would still run and would still leave the exports map, the `files` list
|
| 469 |
+
and every entry path untested. [test/examples.test.mjs](test/examples.test.mjs) derives its checks
|
| 470 |
+
from the example sources, so a fourth example is covered the moment its directory exists. Also
|
| 471 |
+
closed a silent `files`/`exports` gap: a new export path that `files` would not publish resolves in
|
| 472 |
+
the checkout and 404s in the tarball. Asserted from `package.json` now.
|
| 473 |
+
|
| 474 |
+
**Bundle-size story, measured not estimated:** **53 kB (~19 kB gzip)** entry chunk before a model
|
| 475 |
+
loads; the 6 MB WebLLM bundle is a **lazy chunk** fetched on the first `load()` or
|
| 476 |
+
`listAvailableModels()` and never by a visitor who does neither; the IndexedDB adapter is a further
|
| 477 |
+
0.8 kB lazy chunk that vanishes when a host brings its own store — the webext build emits no `idb`
|
| 478 |
+
chunk at all, which is that claim tested by construction.
|
| 479 |
+
|
| 480 |
+
**Licence compliance.** Publishing `vendor/web-llm.js` redistributes WebLLM (Apache-2.0) and its
|
| 481 |
+
dependency `loglevel` (MIT), and the esbuild bundle was built `legalComments: "none"` — no notice
|
| 482 |
+
survived, a violation. `THIRD-PARTY-NOTICES.md` now carries the full texts, generated from the
|
| 483 |
+
installed packages; [test/license.test.mjs](test/license.test.mjs) fails the build if a bundled
|
| 484 |
+
dependency ever lacks a notice, catching a future web-llm bump that inlines a new dep. `build.mjs`
|
| 485 |
+
uses `legalComments: "eof"` now — upstream has already stripped every `@license` banner (the bundle
|
| 486 |
+
is byte-identical either way today), but `"none"` would silently drop one a future dep adds. `LICENSE`
|
| 487 |
+
added (ISC). `files` scopes `vendor` to `web-llm.js` — the stale, unreferenced `vendor/web-llm.d.ts`
|
| 488 |
+
was shipping and made the tarball depend on disk state.
|
| 489 |
+
|
| 490 |
+
**Published:** `everything-webgpu@0.1.0`, 46 files, 2.3 MB packed; `dist.integrity` matched the
|
| 491 |
+
dry-run exactly. Still deferred to a later version: the `demo` extension rebuilding on the package
|
| 492 |
+
(the source-tree acceptance test for the extraction), and a `repository` field once the repo has a
|
| 493 |
+
remote.
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ISC License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 nyaaorick
|
| 4 |
+
|
| 5 |
+
Permission to use, copy, modify, and/or distribute this software for any
|
| 6 |
+
purpose with or without fee is hereby granted, provided that the above
|
| 7 |
+
copyright notice and this permission notice appear in all copies.
|
| 8 |
+
|
| 9 |
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
| 10 |
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
| 11 |
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
| 12 |
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
| 13 |
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
| 14 |
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
| 15 |
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
This distribution includes a bundled build of WebLLM (@mlc-ai/web-llm,
|
| 20 |
+
Apache-2.0) at vendor/web-llm.js and its transitive dependency loglevel
|
| 21 |
+
(MIT). Their license texts are in THIRD-PARTY-NOTICES.md.
|
MLC-COMPILE.md
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Qwen3.8-2B-Distill → MLC/WebGPU: built, and what it cost
|
| 2 |
+
|
| 3 |
+
**Status: done.** `dist/Qwen3.8-2B-q4f16_1-MLC/` loads in Firefox and passes `npm run e2e`.
|
| 4 |
+
Everything labelled **measured** below was verified on this machine (M4 MacBook Air, 16 GB,
|
| 5 |
+
Firefox 154, macOS 26.5.2) during the session that built it.
|
| 6 |
+
|
| 7 |
+
The compile itself was the easy part. The toolchain was not: **no published pair of MLC nightlies
|
| 8 |
+
works together**, and the fix is not a version pin. See "The toolchain does not ship working" —
|
| 9 |
+
read it before touching the venv, because a fresh `pip install` reintroduces every one of these.
|
| 10 |
+
|
| 11 |
+
## What was produced
|
| 12 |
+
|
| 13 |
+
| | |
|
| 14 |
+
| --- | --- |
|
| 15 |
+
| weights | 26 shards, **1.059 GB** on disk (1,089,449,808 bytes materialized) |
|
| 16 |
+
| params | **1,881,825,088** at **4.503 bits/param** (0.987 GB post-quantization) |
|
| 17 |
+
| library | `Qwen3.8-2B-q4f16_1_cs1k-webgpu.wasm`, 6,889,261 bytes, 154 kernels |
|
| 18 |
+
| config | q4f16_1, context 4096, prefill chunk 1024, conv template `qwen3_5` |
|
| 19 |
+
|
| 20 |
+
Reproduce with `tools/setup-mlc-toolchain.sh`, then:
|
| 21 |
+
|
| 22 |
+
```sh
|
| 23 |
+
python3 tools/strip-vision.py --keep-prefix # 632 -> 320 tensors, 1.88B params
|
| 24 |
+
.venv-mlc/bin/python -m mlc_llm gen_config model/Qwen3.8-2B-text \
|
| 25 |
+
--quantization q4f16_1 --conv-template qwen3_5 \
|
| 26 |
+
--context-window-size 4096 --prefill-chunk-size 1024 -o dist/Qwen3.8-2B-q4f16_1-MLC
|
| 27 |
+
.venv-mlc/bin/python -m mlc_llm convert_weight model/Qwen3.8-2B-text \
|
| 28 |
+
--quantization q4f16_1 --device cpu -o dist/Qwen3.8-2B-q4f16_1-MLC
|
| 29 |
+
. ~/emsdk/emsdk_env.sh
|
| 30 |
+
export TVM_HOME=~/.cache/mlc-web-build/tvm
|
| 31 |
+
export MLC_LLM_SOURCE_DIR="$PWD/.venv-mlc/lib/python3.12/site-packages/mlc_llm"
|
| 32 |
+
.venv-mlc/bin/python -m mlc_llm compile dist/Qwen3.8-2B-q4f16_1-MLC/mlc-chat-config.json \
|
| 33 |
+
--device webgpu -o dist/Qwen3.8-2B-q4f16_1-MLC/Qwen3.8-2B-q4f16_1_cs1k-webgpu.wasm
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
`--device cpu` on `convert_weight` is not a preference. **Measured:** `--device metal` dies in
|
| 37 |
+
TVM's LLVM ORC JIT with `Unexpected definitions in module TVMMod-jitted-objectbuffer` on the first
|
| 38 |
+
quantize kernel. The CPU path quantizes all 1.88 B params in 7.4 s, so there is nothing to miss.
|
| 39 |
+
|
| 40 |
+
## Measured performance
|
| 41 |
+
|
| 42 |
+
```
|
| 43 |
+
decode 16.9 tok/s over 127 tokens (projection was 14-18 — held)
|
| 44 |
+
prefill 47.9 tok/s, ttft 0.71 s
|
| 45 |
+
load ready in 51.1 s (ingest 4.8 s of that)
|
| 46 |
+
decode probe 664 kernels/tok = 639 forward + 25 sampling
|
| 47 |
+
cpu-encode 3.9 ms/tok, gpu+tick 62.7 ms/tok
|
| 48 |
+
16.1 flushes/tok -> 41.3 kernels per flush
|
| 49 |
+
batch 4 prompts, 96 tok in 9.7 s = 9.9 tok/s aggregate at pool size 1
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
`639 forward kernels ÷ 24 layers = 26.6`, i.e. the same ~27 the 0.8B showed. dlight made the same
|
| 53 |
+
call here — more reduction threads over more work per thread — so the previously measured **1.83x
|
| 54 |
+
from going 2 -> 32 iters/thread is still on the table**, and the toolchain is now up to try it.
|
| 55 |
+
|
| 56 |
+
### The cost of having no KV reuse — now measured
|
| 57 |
+
|
| 58 |
+
This was the handoff's one explicitly-unmeasured risk ("measure it before assuming it is fine for
|
| 59 |
+
long chats"). It is not fine for long chats:
|
| 60 |
+
|
| 61 |
+
| history | wall | tok/s |
|
| 62 |
+
| --- | --- | --- |
|
| 63 |
+
| 29 tok | 624 ms | 71 |
|
| 64 |
+
| 249 tok | 2,604 ms | 104 |
|
| 65 |
+
| 915 tok | 9,251 ms | 101 |
|
| 66 |
+
| 2,249 tok | 12,318 ms | 186 |
|
| 67 |
+
|
| 68 |
+
**Slope: 5.27 ms per history token (190 tok/s marginal), floor 624 ms.** Every turn re-prefills the
|
| 69 |
+
whole conversation, so at the 4096-token context limit a late turn pays roughly `624 ms + 5.27 ms ×
|
| 70 |
+
4000 ≈ 22 s` before its first token. Short chats are unaffected; long ones are not usable. If this
|
| 71 |
+
model is meant for long conversations, restoring KV reuse (see below) is no longer optional.
|
| 72 |
+
|
| 73 |
+
## The storage-buffer goal: what actually happened
|
| 74 |
+
|
| 75 |
+
The aim was a build where nothing the engine dispatches binds more than 9 storage buffers, reached
|
| 76 |
+
**by deleting** dead kernels. Deletion turned out not to be available.
|
| 77 |
+
|
| 78 |
+
**Measured** — the 2B's histogram is identical to the verified 0.8B's, kernel for kernel:
|
| 79 |
+
|
| 80 |
+
| storage bindings | kernels |
|
| 81 |
+
| --- | --- |
|
| 82 |
+
| 1-6 | 146 |
|
| 83 |
+
| 8 | 1 |
|
| 84 |
+
| 9 | 3 |
|
| 85 |
+
| **10** | **4** |
|
| 86 |
+
|
| 87 |
+
The same four at 10: `batch_prefill_paged_kv_kernel`, `batch_prefill_paged_kv_sliding_window_kernel`,
|
| 88 |
+
`batch_tree_attn_kernel`, `tree_attn_paged_kv_kernel`.
|
| 89 |
+
|
| 90 |
+
**There is no flag, and there cannot be one.** In
|
| 91 |
+
`tvm/relax/frontend/nn/llm/kv_cache.py`, `_create_tir_paged_kv_cache` registers the sliding-window,
|
| 92 |
+
tree-attention and paged-prefill kernels unconditionally, as a **fixed positional tuple** handed to
|
| 93 |
+
the C++ `PagedKVCache` constructor. The runtime indexes that tuple by position, so omitting an entry
|
| 94 |
+
does not delete a kernel — it misaligns every kernel after it. This is the case the original handoff
|
| 95 |
+
anticipated ("if MLC emits the full set unconditionally, that is acceptable"), now confirmed at the
|
| 96 |
+
source rather than assumed.
|
| 97 |
+
|
| 98 |
+
So the position is unchanged from the 0.8B: three of the four are dead by config, and the live one,
|
| 99 |
+
`batch_prefill_paged_kv_kernel`, stays unreachable only because of the engine-side guard in
|
| 100 |
+
`src/background/engine-worker.js` (`resetChat()` before every prefill when
|
| 101 |
+
`maxStorageBuffersPerShaderStage < 10`). `tools/audit-wasm.mjs` still exits 1, correctly — it judges
|
| 102 |
+
the file, and it cannot see a guard that lives in the engine.
|
| 103 |
+
|
| 104 |
+
**Measured, and worth stating precisely:** the e2e's multi-round A/B came back *identical*. That
|
| 105 |
+
proves there is no silent garbage. It does **not** prove paged prefill works — the guard means both
|
| 106 |
+
branches of the A/B ran through the ragged kernel. The harness's own "paged prefill is fine" wording
|
| 107 |
+
overstates it.
|
| 108 |
+
|
| 109 |
+
### If you want KV reuse back
|
| 110 |
+
|
| 111 |
+
Unchanged from before, and now motivated by the measured 5.27 ms/token slope. Do not re-fuse
|
| 112 |
+
anything: `batch_prefill_paged_kv_kernel` binds six `array<i32>` metadata buffers whose uniform block
|
| 113 |
+
already carries `*_elem_offset` for each, and the body already indexes as `name[expr + name_elem_offset]`.
|
| 114 |
+
Packing them into one buffer with six offsets is a signature change, not an algorithmic one: 10 -> 5.
|
| 115 |
+
Merging any two reaches 9. Grep `q_indptr` / `page_values` / `k_rope_pos_offset` in TVM — with the
|
| 116 |
+
toolchain now standing up, this is a tractable change rather than a research project.
|
| 117 |
+
|
| 118 |
+
## The toolchain does not ship working
|
| 119 |
+
|
| 120 |
+
Five independent breakages, none of which is a version-pin problem. `tools/setup-mlc-toolchain.sh`
|
| 121 |
+
applies all of them; `tools/patch-mlc-nightly.py` and `tools/resign-venv.sh` are idempotent and must
|
| 122 |
+
be re-run after **any** `pip install` touching these packages.
|
| 123 |
+
|
| 124 |
+
**1. Every wheel dylib has an invalid signature.** `import tvm` is SIGKILLed by the kernel
|
| 125 |
+
(`CODESIGNING / Invalid Page`) inside `dlopen`. The ad-hoc signatures do not match the bytes.
|
| 126 |
+
`codesign --force --sign -` over each recomputes them. Silent — no Python traceback, just exit 137.
|
| 127 |
+
|
| 128 |
+
**2. `apache-tvm-ffi` must be pinned to exactly `0.1.13`.** The metadata says `>=0.1.13`, which
|
| 129 |
+
resolves to `0.1.13.post3`, whose ABI does not match the bundled runtime: it corrupts the heap in a
|
| 130 |
+
static initializer and aborts. (`0.1.13` is marked yanked on PyPI. It is still the one that works.)
|
| 131 |
+
|
| 132 |
+
**3. `psutil` is missing from the wheel's dependencies** but imported by `mlc_llm.serve`.
|
| 133 |
+
|
| 134 |
+
**4. The published nightlies are mutually incompatible — this is the big one.**
|
| 135 |
+
`mlc-llm-nightly-cpu 0.26.dev6` is written against an *unreleased* TVM in which `PrimFunc.params`
|
| 136 |
+
holds `Buffer`s directly and `tirx.is_buffer_var` exists. Every published `mlc-ai` wheel
|
| 137 |
+
(dev61/dev203/dev246) still has `params: Array<Var>` plus a separate `buffer_map` — verified in
|
| 138 |
+
`include/tvm/tirx/function.h` — and none defines `is_buffer_var`. Going the other way fails too:
|
| 139 |
+
`mlc-llm 0.20.dev162`'s Python *does* use `buffer_map`, but its dylib needs `TVMBackendParallelLaunch`,
|
| 140 |
+
which dev246 no longer exports.
|
| 141 |
+
|
| 142 |
+
dev6 is kept, because it has the newest `qwen3_5` model definition and the correct `qwen3_5`
|
| 143 |
+
conversation template. The places where it reaches for the unreleased API are rewritten against
|
| 144 |
+
`buffer_map` — **not invented**: each rewrite restores what `mlc-llm 0.20.dev162` does in that same
|
| 145 |
+
function, kept on dev6's relax spelling (`TensorType`/`ty_args`, where dev162 said
|
| 146 |
+
`TensorStructInfo`/`sinfo_args`). Affects `low_batch_specialization.py` and
|
| 147 |
+
`lift_global_buffer_alloc.py`.
|
| 148 |
+
|
| 149 |
+
Two more source-level fixes in the same script:
|
| 150 |
+
|
| 151 |
+
- **`nn/rnn_state.py` emits ill-formed TIR.** It binds the recurrent-state gather/scatter indices to
|
| 152 |
+
annotated locals (`seq_id: T.int32 = seq_slot_ids[vi]`); this TVM then infers a block read region
|
| 153 |
+
whose bound names a buffer `seq_id` that was never declared, and the **C++ verifier** rejects it —
|
| 154 |
+
so silencing the TVMScript parser's `check_well_formed` only defers the failure to a later pass.
|
| 155 |
+
Inlining each load at its single use site is the same computation and verifies clean. Qwen3.5 is
|
| 156 |
+
75% GatedDeltaNet, so RNNState is on the critical path and cannot be skipped.
|
| 157 |
+
- **Every loader passes `mlc_param.dtype` into `numpy.astype`**, but here that is a
|
| 158 |
+
`tvm.ir.type.PrimType` (`T.float16`), not the `str` numpy needs. `str()` yields exactly `float16`.
|
| 159 |
+
|
| 160 |
+
**5. `--device webgpu` needs `web/dist/wasm/*.bc`, which the wheels neither ship nor can build.**
|
| 161 |
+
Their header tree is pruned to inconsistency (`include/tvm/` has only `runtime/`, yet
|
| 162 |
+
`runtime/tensor.h` includes the absent `tvm/support/io.h`). The runtime is therefore built from an
|
| 163 |
+
`apache/tvm` checkout pinned to **the commit the wheel records** (`tvm._version.__commit_id__`,
|
| 164 |
+
here `837cb9de1`) — the linked runtime must come from the same commit as the codegen that produced
|
| 165 |
+
the kernels. Two further wrinkles:
|
| 166 |
+
|
| 167 |
+
- **Build it outside a path containing spaces.** TVM's `web/Makefile` interpolates `TVM_ROOT` into
|
| 168 |
+
unquoted `-I` flags, so "Everything WebGPU" splits and clang fails on phantom directories. Hence
|
| 169 |
+
`~/.cache/mlc-web-build`.
|
| 170 |
+
- **`mlc_wasm_runtime.cc` is now only `#define`s** — its actual runtime moved into TVM's
|
| 171 |
+
`wasm_runtime.cc` — so it compiles standalone with no include path at all.
|
| 172 |
+
|
| 173 |
+
### And the one that only shows up at runtime
|
| 174 |
+
|
| 175 |
+
TVM's `web/emcc/wasm_runtime.cc` pulls in tvm-ffi's sources one file at a time and **misses
|
| 176 |
+
`custom_allocator.cc`**. `TVMFFIGetCustomAllocator` is then left undefined and becomes a wasm
|
| 177 |
+
*import* — and no published `@mlc-ai/web-llm` supplies it (0.2.84 is latest; there is no newer
|
| 178 |
+
version to upgrade to). The model compiles, ships, ingests, and then dies at instantiation:
|
| 179 |
+
|
| 180 |
+
```
|
| 181 |
+
FAIL: load: LinkError: import object field 'TVMFFIGetCustomAllocator' is not a Function
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
Adding that one `#include` fixes it. The check that matters is not "does it compile" but **does the
|
| 185 |
+
import list match a known-good build** — compare against the reference wasm:
|
| 186 |
+
|
| 187 |
+
```sh
|
| 188 |
+
node tools/wasm-imports.mjs dist/Qwen3.8-2B-q4f16_1-MLC/*.wasm \
|
| 189 |
+
~/Downloads/Qwen3.5-0.8B-q4f16_1-MLC/*.wasm
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
Pass a reference as the second argument and it exits non-zero on any import the reference does not
|
| 193 |
+
need. This build now asks for 10, all of them in the reference's 11 (it does not need `proc_exit`).
|
| 194 |
+
Anything *extra* means the runtime is newer than the JS glue, and it will fail only at load time.
|
| 195 |
+
|
| 196 |
+
TVM also links the final `.wasm` with `emcc`, which under emscripten 6.x no longer pulls in the C++
|
| 197 |
+
stdlib for bitcode inputs — `wasm-ld` fails on `std::cerr` and the `ostringstream` vtables. Patched
|
| 198 |
+
to `em++`, which is emscripten's own suggestion in the error.
|
| 199 |
+
|
| 200 |
+
## Resolved unknowns from the original handoff
|
| 201 |
+
|
| 202 |
+
| question | answer |
|
| 203 |
+
| --- | --- |
|
| 204 |
+
| keep `model.language_model.` prefix or rewrite? | **Keep it.** `qwen35_loader.py` hardcodes `hf = "model.language_model"` and maps MLC `model.X` -> `model.language_model.X`. Use `--keep-prefix`. |
|
| 205 |
+
| is flattening `text_config` the step most likely to fail? | **No — it is handled upstream.** `Qwen35Config.__post_init__` already unwraps both `text_config` and nested `rope_parameters`. The strip script still flattens, which is equivalent. |
|
| 206 |
+
| flags to exclude tree-attn / sliding-window / paged-prefill? | **None exist**, and none can — fixed positional tuple, see above. |
|
| 207 |
+
| cost of no KV reuse? | **5.27 ms per history token**, floor 624 ms. Measured, and it matters. |
|
| 208 |
+
| does MLC support this architecture? | Yes — `qwen3_5` in the registry, `mlc_llm/model/qwen35/`, hybrid `kv_state_kind: 'hybrid'` (paged KV + RNN state). |
|
| 209 |
+
|
| 210 |
+
### Two things the reference 0.8B gets wrong that this build does not
|
| 211 |
+
|
| 212 |
+
- **Stop tokens.** The reference was built with `--conv-template qwen2`, so its `stop_token_ids` are
|
| 213 |
+
`[151643, 151645]` — **Qwen2 ids, which do not exist in this 248320-token vocab**. It survives
|
| 214 |
+
only on `stop_str` text matching. This build uses `--conv-template qwen3_5`, whose template
|
| 215 |
+
already carries the correct `[248046, 248044]` (`<|im_end|>`, `<|endoftext|>`), and `gen_config`
|
| 216 |
+
independently picked the same pair out of `generation_config.json`.
|
| 217 |
+
- **Thinking.** `qwen3_5` opens the assistant turn with `<think>`, which is what this checkpoint
|
| 218 |
+
expects — the model card says every answer opens with a `<think>` block. Confirmed in the e2e
|
| 219 |
+
output. Use `qwen3_5_nothink` to suppress it.
|
| 220 |
+
|
| 221 |
+
## Notes on the checkpoint
|
| 222 |
+
|
| 223 |
+
`empero-ai/Qwen3.8-2B-Distill`, at `model/Qwen3.8-2B-Distill/` (gitignored). **Measured** from the
|
| 224 |
+
safetensors header: 632 tensors, 2.27 B params, cleanly separated by prefix —
|
| 225 |
+
`model.language_model` (320 / 1.88 B, keep), `model.visual` (297 / 331 M, drop), `mtp` (15 / 61 M,
|
| 226 |
+
drop). The model card confirms the fine-tune is text-only with vision inherited from the base.
|
| 227 |
+
|
| 228 |
+
`tools/strip-vision.py` streams byte ranges out of the source safetensors rather than loading it, so
|
| 229 |
+
it runs in a few MB rather than the 4.5 GB the tensors occupy — worth keeping on a 16 GB machine.
|
| 230 |
+
It also flattens the config and hoists `rope_theta` / `partial_rotary_factor`, and folds both EOS
|
| 231 |
+
ids from `generation_config.json` into `eos_token_id`.
|
| 232 |
+
|
| 233 |
+
Architecture vs the verified 0.8B: only `hidden_size` (1024 -> 2048) and `intermediate_size`
|
| 234 |
+
(3584 -> 6144) differ. Layers 0-2 of each group of 4 are GatedDeltaNet linear attention
|
| 235 |
+
(`A_log`, `dt_bias`, `conv1d`, `in_proj_{qkv,a,b,z}`); every 4th is full attention with a gated
|
| 236 |
+
`q_proj` of `[4096, 2048]` = 2 x 8 heads x 256 (`attn_output_gate: true`).
|
| 237 |
+
|
| 238 |
+
**Keep `q4f16_1`.** Dequantisation is ~free (41 GB/s with unpack-and-scale vs 41-46 without), so a
|
| 239 |
+
wider format only adds bytes, and bytes are what decode pays for.
|
| 240 |
+
|
| 241 |
+
### Sampling
|
| 242 |
+
|
| 243 |
+
The model card recommends `temperature=0.6, top_p=0.95, top_k=20`, and warns that greedy decoding is
|
| 244 |
+
a repetition-loop failure mode for this class. `gen_config` writes MLC's defaults (1.0 / 1.0), so
|
| 245 |
+
**this build overwrites two of the three** in `mlc-chat-config.json`:
|
| 246 |
+
|
| 247 |
+
| | value | effective? |
|
| 248 |
+
| --- | --- | --- |
|
| 249 |
+
| `temperature` | 0.6 | **yes** — the engine default was moved to match |
|
| 250 |
+
| `top_p` | 0.95 | **yes** — nothing overrides it |
|
| 251 |
+
| `top_k` | 20 | **no** — compiled in, but unreachable |
|
| 252 |
+
|
| 253 |
+
`gen_config` rewrites this file, so a rebuild resets both. Re-apply after it:
|
| 254 |
+
|
| 255 |
+
```sh
|
| 256 |
+
python3 - <<'EOF'
|
| 257 |
+
import json, pathlib
|
| 258 |
+
p = pathlib.Path("dist/Qwen3.8-2B-q4f16_1-MLC/mlc-chat-config.json")
|
| 259 |
+
c = json.loads(p.read_text()); c["temperature"] = 0.6; c["top_p"] = 0.95
|
| 260 |
+
p.write_text(json.dumps(c, indent=2) + "\n")
|
| 261 |
+
EOF
|
| 262 |
+
```
|
| 263 |
+
|
| 264 |
+
**`top_k` has nowhere to land.** The library genuinely supports it — `get_renorm_prob` takes
|
| 265 |
+
`top_k: Tensor([batch, 1], int32)` and the WGSL applies a real top-k cutoff beside top-p — but
|
| 266 |
+
`top_k` appears **zero** times in `vendor/web-llm.js`, `src/background/multistep.js` and
|
| 267 |
+
`src/background/engine-worker.js`. The burst sampler calls `fsampleWithTopP` with temperature and
|
| 268 |
+
top_p only. So it is the same shape of problem as the 10-buffer kernels: present in the file, dead
|
| 269 |
+
on the live path. Reaching it means wiring a `top_k` through *both* the multistep burst sampler and
|
| 270 |
+
the vendored WebLLM path — partial wiring would make burst and non-burst decoding sample
|
| 271 |
+
differently, which is worse than leaving it. The reference 0.8B is in exactly the same position.
|
| 272 |
+
|
| 273 |
+
**`temperature` needed a second change.** `src/background/background.js` sets
|
| 274 |
+
`temperature: payload.temperature ?? settings.temperature` on every request, so
|
| 275 |
+
`DEFAULT_SETTINGS.temperature` in `src/lib/model-store.js` shadows whatever the model ships —
|
| 276 |
+
setting `mlc-chat-config.json` alone would have done nothing. That default is now `0.6` to match.
|
| 277 |
+
It is a single global, not per-model, so it applies to any other model loaded in the extension.
|
| 278 |
+
`top_p` is never injected by `buildParams`, which is why 0.95 takes effect straight from the model
|
| 279 |
+
config.
|
| 280 |
+
|
| 281 |
+
## Verify
|
| 282 |
+
|
| 283 |
+
```sh
|
| 284 |
+
node tools/audit-wasm.mjs dist/Qwen3.8-2B-q4f16_1-MLC # exits 1: see the buffer section above
|
| 285 |
+
node tools/wasm-imports.mjs dist/Qwen3.8-2B-q4f16_1-MLC/*.wasm
|
| 286 |
+
MODEL_DIR="$PWD/dist/Qwen3.8-2B-q4f16_1-MLC" ENGINE_COUNT=1 npm run e2e
|
| 287 |
+
```
|
| 288 |
+
|
| 289 |
+
The ingest contract (`src/lib/ingest.js`) is satisfied: `mlc-chat-config.json`, `tensor-cache.json`
|
| 290 |
+
with 26 `dataPath` records all present, `tokenizer.json` both present and listed in
|
| 291 |
+
`tokenizer_files`, and exactly one `.wasm`. `gen_config` also emits `tensor-cache-b16.json` (same 26
|
| 292 |
+
shards); it is ignored by ingest and harmless.
|
README.md
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: isc
|
| 3 |
+
library_name: everything-webgpu
|
| 4 |
+
pipeline_tag: text-generation
|
| 5 |
+
tags:
|
| 6 |
+
- webgpu
|
| 7 |
+
- mlc-ai
|
| 8 |
+
- web-llm
|
| 9 |
+
- browser
|
| 10 |
+
- on-device
|
| 11 |
+
- local-llm
|
| 12 |
+
- ghost-text
|
| 13 |
+
- scheduler
|
| 14 |
+
- typescript
|
| 15 |
+
- javascript
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
# everything-webgpu
|
| 19 |
+
|
| 20 |
+
[](https://www.npmjs.com/package/everything-webgpu) [](https://github.com/nyaaorick/everything-webgpu) [](https://developer.mozilla.org/en-US/docs/Web/API/WebGPU_API) [](https://opensource.org/licenses/ISC)
|
| 21 |
+
|
| 22 |
+
Run local MLC/WebLLM models on WebGPU, in a browser, with a scheduler in front of them.
|
| 23 |
+
|
| 24 |
+
**Migrating off `@mlc-ai/web-llm` is one line.**
|
| 25 |
+
|
| 26 |
+
```diff
|
| 27 |
+
-import { CreateMLCEngine } from "@mlc-ai/web-llm";
|
| 28 |
+
-const engine = await CreateMLCEngine(modelId, { initProgressCallback });
|
| 29 |
+
+import { CreateScheduledEngine } from "everything-webgpu";
|
| 30 |
+
+const engine = await CreateScheduledEngine(modelId, { initProgressCallback });
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
Everything after that line is unchanged. `engine.chat.completions.create()` takes and returns the
|
| 34 |
+
same shapes, streams the same chunk objects, and reports the same finish reasons. That call is the
|
| 35 |
+
compatibility layer and **it never changes** — see [Stability](#stability).
|
| 36 |
+
|
| 37 |
+
What the swap buys:
|
| 38 |
+
|
| 39 |
+
| | |
|
| 40 |
+
| --- | --- |
|
| 41 |
+
| **A scheduler** | `session`, `priority`, `task`, `preemptible` on any request. One engine per task, pool grows on demand, stale requests supersede themselves. |
|
| 42 |
+
| **Multi-step decoding** | K forward steps per GPU sync. On the 0.8B baseline: 9.6 → 25.9 tok/s. |
|
| 43 |
+
| **Two build-time patches** | Firefox's 9-storage-buffer cap, and one compute pass per flush instead of per kernel. |
|
| 44 |
+
| **Three model sources** | A prebuilt id, a URL you host, or **a folder off disk with no network at any point**. |
|
| 45 |
+
| **Typed errors** | Eight codes, each one because a caller does something different about it. |
|
| 46 |
+
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
## Status
|
| 50 |
+
|
| 51 |
+
**On npm** — `npm i everything-webgpu`. Pre-1.0, ESM only (`"type": "module"`).
|
| 52 |
+
|
| 53 |
+
```sh
|
| 54 |
+
npm i everything-webgpu
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
`vendor/web-llm.js` — the patched WebLLM bundle the engine loads — is a **build product and is not
|
| 58 |
+
checked in**, so every install route has to produce it. The published tarball ships it prebuilt;
|
| 59 |
+
`prepare` regenerates it for a git dependency or a clone. If it is ever missing you get
|
| 60 |
+
`PACKAGE_INCOMPLETE` naming the fix, rather than a module-resolution error pointing inside this
|
| 61 |
+
package.
|
| 62 |
+
|
| 63 |
+
Requires WebGPU. Verified end to end on Firefox 154 / macOS / M4; on Firefox you may need to set
|
| 64 |
+
`dom.webgpu.enabled` and the related flags first — [AI.md](AI.md) lists them and says what each one
|
| 65 |
+
does. Chrome is expected to be faster (it does not hit the storage-buffer cap that disables KV
|
| 66 |
+
reuse) but that is a prediction, not yet a measurement.
|
| 67 |
+
|
| 68 |
+
## Quick start — four lines
|
| 69 |
+
|
| 70 |
+
```js
|
| 71 |
+
import { CreateScheduledEngine } from "everything-webgpu"; // 1. import
|
| 72 |
+
const engine = await CreateScheduledEngine("Llama-3.2-1B-Instruct-q4f16_1-MLC"); // 2. load a model
|
| 73 |
+
const reply = await engine.ask("Name three primary colours."); // 3. ask
|
| 74 |
+
console.log(reply); // 4. the answer
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
`reply` is a plain string. Line 2 downloads ~0.8 GB the first time and prints throttled progress to
|
| 78 |
+
the console; pass `initProgressCallback` to render it yourself, or `initProgressCallback: null` to
|
| 79 |
+
silence it. On Vite, add one plugin — see [API.md § Bundlers](API.md#bundlers). Every other call
|
| 80 |
+
form — `conversation()`, `load()` from a URL or off disk, streaming, embeddings, the OpenAI shape —
|
| 81 |
+
is catalogued in **[API.md](API.md)**.
|
| 82 |
+
|
| 83 |
+
The WebLLM-compatible shape, unchanged:
|
| 84 |
+
|
| 85 |
+
```js
|
| 86 |
+
const reply = await engine.chat.completions.create({
|
| 87 |
+
messages: [{ role: "user", content: "hi" }],
|
| 88 |
+
session: "ghost-text", // added by this engine
|
| 89 |
+
priority: "interactive", // added by this engine
|
| 90 |
+
});
|
| 91 |
+
console.log(reply.choices[0].message.content);
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
Streaming is WebLLM's, passed through untouched:
|
| 95 |
+
|
| 96 |
+
```js
|
| 97 |
+
const stream = await engine.chat.completions.create({ messages, stream: true });
|
| 98 |
+
for await (const chunk of stream) process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
The first `load()` downloads the weights; after that it is cache-only and needs no network.
|
| 102 |
+
|
| 103 |
+
### Runnable
|
| 104 |
+
|
| 105 |
+
[examples/](examples/) has three, each a standalone project that consumes this package through its
|
| 106 |
+
`exports` map rather than reaching into the source tree:
|
| 107 |
+
|
| 108 |
+
```sh
|
| 109 |
+
cd examples/bare && npm install && npm run dev
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
| | |
|
| 113 |
+
| --- | --- |
|
| 114 |
+
| [bare/](examples/bare/) | Vanilla Vite, one page. ~100 lines. |
|
| 115 |
+
| [react/](examples/react/) | A chat UI over `conversation()`. |
|
| 116 |
+
| [webext/](examples/webext/) | MV2 Firefox — `browser.storage.local` as the store, and the CSP a wasm runtime needs. |
|
| 117 |
+
|
| 118 |
+
## What it costs your bundle
|
| 119 |
+
|
| 120 |
+
**53 kB** (~19 kB gzip) in the entry chunk, before a model is loaded. WebLLM's 6 MB bundle is a
|
| 121 |
+
**lazy chunk** — fetched on the first `load()` or `listAvailableModels()`, and never by a visitor
|
| 122 |
+
who does neither, so a page can decide whether this machine has a GPU before paying for it. The
|
| 123 |
+
IndexedDB adapter is another 0.8 kB lazy chunk, absent entirely if you pass your own store.
|
| 124 |
+
|
| 125 |
+
On Vite, add the plugin this package ships:
|
| 126 |
+
|
| 127 |
+
```js
|
| 128 |
+
import { everythingWebGPU } from "everything-webgpu/vite";
|
| 129 |
+
export default defineConfig({ plugins: [everythingWebGPU()] });
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
It exists for one reason: Vite's dependency pre-bundler otherwise rewrites the
|
| 133 |
+
decode worker's URL to a path that 404s — in `vite dev` only, and only on a real
|
| 134 |
+
install, which is why no example in this repo can catch it and
|
| 135 |
+
`npm run verify-consumer` exists instead. Skip it and `load()` fails with
|
| 136 |
+
`PACKAGE_INCOMPLETE` naming the fix, rather than hanging.
|
| 137 |
+
|
| 138 |
+
## Three ready-made shapes
|
| 139 |
+
|
| 140 |
+
Most apps want one of three things. Each is one call, with the scheduling already right:
|
| 141 |
+
|
| 142 |
+
```js
|
| 143 |
+
// 1. One question, nothing kept.
|
| 144 |
+
const answer = await engine.ask("Summarise this in one line:\n" + doc);
|
| 145 |
+
|
| 146 |
+
// 2. A conversation that remembers.
|
| 147 |
+
const chat = engine.conversation({ system: "You are terse." });
|
| 148 |
+
await chat.say("what's the capital of France?");
|
| 149 |
+
await chat.say("and its population?"); // remembers
|
| 150 |
+
|
| 151 |
+
// 3. Ghost text, debounced and superseding.
|
| 152 |
+
const ghost = engine.ghostText({ prompt: (before) => `Continue:\n${before}` });
|
| 153 |
+
editor.on("input", async () => {
|
| 154 |
+
const hint = await ghost.suggest(editor.textBefore());
|
| 155 |
+
if (hint !== null) render(hint); // null = a newer keystroke won
|
| 156 |
+
});
|
| 157 |
+
editor.on("blur", () => ghost.cancel());
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
`complete()` can express all three. These exist because the **scheduling** is the part that's easy
|
| 161 |
+
to get wrong and invisible when you do — every row of [Getting these wrong](#getting-these-wrong) is
|
| 162 |
+
a scheduling mistake, not a generation one. So each verb is a policy:
|
| 163 |
+
|
| 164 |
+
| | policy |
|
| 165 |
+
| --- | --- |
|
| 166 |
+
| `ask()` | its own task, no session — two in flight never supersede each other |
|
| 167 |
+
| `conversation()` | one stable task for every turn, turns serialised, history bounded |
|
| 168 |
+
| `ghostText()` | debounce + one session key + `interactive` + **resolves `null` when stale** |
|
| 169 |
+
|
| 170 |
+
That last one is the difference between a verb and a wrapper. The engine already drops superseded
|
| 171 |
+
work; what a caller still had to remember was not to *paint* the answer that came back anyway.
|
| 172 |
+
Returning `null` removes the choice.
|
| 173 |
+
|
| 174 |
+
**They author no prompts.** `ask()` and `conversation()` carry your text through; `ghostText()`
|
| 175 |
+
*requires* a `prompt` function and has no default. Prompts are model-specific — switching this
|
| 176 |
+
project's own build from a 0.8B to a 2B changed the conversation template and made every reply open
|
| 177 |
+
with a `<think>` block. A prompt that lives in your code survives that.
|
| 178 |
+
|
| 179 |
+
`conversation()` bounds history at 12 exchanges by default, because there is no cross-turn KV reuse
|
| 180 |
+
here: every turn re-prefills the whole history at ~5.27 ms/token, so an unbounded conversation gets
|
| 181 |
+
quadratically slower and a turn near the context limit waits ~22 s for its first token. Pass
|
| 182 |
+
`keep: Infinity` to opt out, having read that sentence.
|
| 183 |
+
|
| 184 |
+
## Where models come from
|
| 185 |
+
|
| 186 |
+
**One call covers all three.** `load()` works out what you handed it, registers whatever needs
|
| 187 |
+
registering, and brings the model up:
|
| 188 |
+
|
| 189 |
+
```js
|
| 190 |
+
// 1. Prebuilt — one of WebLLM's 163 HuggingFace models, or anything you registered earlier.
|
| 191 |
+
await engine.load("Llama-3.2-1B-Instruct-q4f16_1-MLC");
|
| 192 |
+
|
| 193 |
+
// 2. Remote — any base URL you host. `modelLib` is required; see below.
|
| 194 |
+
await engine.load("https://cdn.example/models/my-model/", {
|
| 195 |
+
modelLib: "https://cdn.example/models/my-model/my-model-webgpu.wasm",
|
| 196 |
+
});
|
| 197 |
+
await engine.load({ model: "/models/my-model/", modelLib: "/models/my-model/lib.wasm" });
|
| 198 |
+
|
| 199 |
+
// 3. Injected — a folder off disk. No network at any point.
|
| 200 |
+
await engine.load(dropEvent.dataTransfer); // a drop
|
| 201 |
+
await engine.load(input.files); // <input webkitdirectory>
|
| 202 |
+
await engine.load({ files }); // already-unpacked { path, file }[]
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
The model id is derived from the URL's last segment; pass `{ id }` to override it. Add
|
| 206 |
+
`{ defer: true }` to register a source **without** building a pool — it returns the registry record,
|
| 207 |
+
for a drop-now-load-later flow. `registerModel()` and `ingestModelFolder()` remain as the low-level
|
| 208 |
+
primitives; `load()` composes them rather than replacing them.
|
| 209 |
+
|
| 210 |
+
`listAvailableModels()` enumerates all three routes. Construct the engine with `{ prebuilt: false }`
|
| 211 |
+
for a build that must never fetch a model over the network. A misspelled id is told what it might
|
| 212 |
+
have meant.
|
| 213 |
+
|
| 214 |
+
### Warming the cache early
|
| 215 |
+
|
| 216 |
+
```js
|
| 217 |
+
await engine.prefetch(modelId, { onProgress: (p) => bar(p.done / p.total) });
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
Downloads the model **without building an engine and without WebGPU** — so an app can warm the cache
|
| 221 |
+
while the user is still reading the welcome screen, before it has decided whether this machine can
|
| 222 |
+
run the model at all. WebLLM cannot express this: `reload()` instantiates the wasm and needs a GPU
|
| 223 |
+
before it fetches a single shard. A later `load()` is then a cache read. Interrupted downloads
|
| 224 |
+
resume, and a second call is free.
|
| 225 |
+
|
| 226 |
+
## Embeddings
|
| 227 |
+
|
| 228 |
+
```js
|
| 229 |
+
await engine.load("snowflake-arctic-embed-s-q0f32-MLC", { keepResident: true });
|
| 230 |
+
|
| 231 |
+
const [q] = await engine.embed("how do I cancel a job?");
|
| 232 |
+
const docs = await engine.embed(paragraphs); // one vector per input, in order
|
| 233 |
+
```
|
| 234 |
+
|
| 235 |
+
Embedding models are separate models — WebLLM ships four `snowflake-arctic-embed-*` from 239 MB — so
|
| 236 |
+
this usually names `modelId` and holds it resident alongside a chat model. Embeddings go through the
|
| 237 |
+
**same scheduler** as completions: same priority bands, same `session` supersession, same
|
| 238 |
+
one-task-one-engine rule. `embedRaw()` returns WebLLM's OpenAI-shaped envelope if you are porting
|
| 239 |
+
code that expects `data[].embedding`.
|
| 240 |
+
|
| 241 |
+
One difference worth knowing: **a running embedding cannot be interrupted.** Cancellation works by
|
| 242 |
+
making a decode loop break out, and one forward pass has no loop — so a `cancel()` landing after the
|
| 243 |
+
job starts marks it cancelled without stopping it. Queued embeddings supersede normally.
|
| 244 |
+
|
| 245 |
+
**`modelLib` is required for a remote source and is not guessed.** It is genuinely underivable:
|
| 246 |
+
across all 163 prebuilt models, zero have a lib name derivable from the model id, and zero host the
|
| 247 |
+
lib on the same origin as the weights. A guess would be wrong every time and would surface as a
|
| 248 |
+
confusing 404 deep inside the loader, so the engine asks instead.
|
| 249 |
+
|
| 250 |
+
## The three shapes of work
|
| 251 |
+
|
| 252 |
+
What differs between these is *not* the call or the transport. It is **who owns an engine, and what
|
| 253 |
+
may interrupt what.**
|
| 254 |
+
|
| 255 |
+
| | call | priority | key field | why |
|
| 256 |
+
| --- | --- | --- | --- | --- |
|
| 257 |
+
| **Completion** (ghost text) | `complete` streaming | `interactive` | `session` | Each keystroke supersedes the last request; may preempt opted-in work. |
|
| 258 |
+
| **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. |
|
| 259 |
+
| **Reformat** (markdown) | `complete` | `background` | `preemptible: true` | Nobody is watching; let interactive work cut in. |
|
| 260 |
+
|
| 261 |
+
### Completion — latency is the whole product
|
| 262 |
+
|
| 263 |
+
```js
|
| 264 |
+
// On every keystroke. The previous request is cancelled, not queued behind.
|
| 265 |
+
const { text } = await engine.complete(
|
| 266 |
+
{
|
| 267 |
+
messages: [{ role: "user", content: prefix }],
|
| 268 |
+
session: "ghost-text", // supersession key — the important field
|
| 269 |
+
priority: "interactive", // may preempt jobs that opted in
|
| 270 |
+
max_tokens: 24, // ghost text is short; do not pay for more
|
| 271 |
+
},
|
| 272 |
+
(delta) => render(delta),
|
| 273 |
+
);
|
| 274 |
+
```
|
| 275 |
+
|
| 276 |
+
**`session` is what makes this work, not `cancel`.** Reusing one session key means the engine drops
|
| 277 |
+
the stale request itself. A caller that mints a fresh id per keystroke and calls `cancel` races its
|
| 278 |
+
own typing.
|
| 279 |
+
|
| 280 |
+
### Translation — throughput, one task
|
| 281 |
+
|
| 282 |
+
```js
|
| 283 |
+
// One batch, not a loop of `complete` calls.
|
| 284 |
+
const results = await engine.batch({
|
| 285 |
+
task: "translate-page", // optional; a batch is one task either way
|
| 286 |
+
requests: sentences.map((s) => ({
|
| 287 |
+
messages: [{ role: "user", content: `Translate to French, output only the translation:\n${s}` }],
|
| 288 |
+
})),
|
| 289 |
+
});
|
| 290 |
+
results.forEach((r) => apply(r.index, r.text));
|
| 291 |
+
```
|
| 292 |
+
|
| 293 |
+
Every item of one batch shares a task, and a task holds one engine, so a 200-sentence page occupies
|
| 294 |
+
exactly one engine and can never freeze ghost-text behind it. Items carry `engineIndex`, `startedAt`
|
| 295 |
+
and `finishedAt`, so you can check what actually ran where.
|
| 296 |
+
|
| 297 |
+
`batch` stays the right call rather than a loop: it is one scheduling unit the engine can reason
|
| 298 |
+
about, and when batched decode lands the same call gets faster with no change on your side.
|
| 299 |
+
|
| 300 |
+
### Reformat — cheap to interrupt
|
| 301 |
+
|
| 302 |
+
```js
|
| 303 |
+
await engine.complete({
|
| 304 |
+
messages: [{ role: "user", content: `Reformat as clean Markdown, no commentary:\n\n${doc}` }],
|
| 305 |
+
priority: "background",
|
| 306 |
+
preemptible: true, // the direction matters — see below
|
| 307 |
+
max_tokens: 2048,
|
| 308 |
+
});
|
| 309 |
+
```
|
| 310 |
+
|
| 311 |
+
**Set `preemptible` on the work that can afford to lose, not on the work you care about.** Only an
|
| 312 |
+
`interactive` request preempts, and only a job that opted in can be preempted. A preempted job
|
| 313 |
+
resolves with `preempted: true` and whatever text it had — never requeued, so it can never starve,
|
| 314 |
+
but you must be able to use or discard a partial result.
|
| 315 |
+
|
| 316 |
+
## Getting these wrong
|
| 317 |
+
|
| 318 |
+
| symptom | cause |
|
| 319 |
+
| --- | --- |
|
| 320 |
+
| Ghost text lags behind typing | Fresh `id` per keystroke with no `session`, so every stale request still runs. |
|
| 321 |
+
| 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. |
|
| 322 |
+
| Reformatting blocks completions | `preemptible` left off the background job, so `interactive` has nothing to take. |
|
| 323 |
+
| 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. |
|
| 324 |
+
|
| 325 |
+
## Prompts stay with you
|
| 326 |
+
|
| 327 |
+
The engine does not author prompts. They are model-specific — switching one build from
|
| 328 |
+
`Qwen3.5-0.8B` to `Qwen3.8-2B-Distill` changed the conversation template and made every reply open
|
| 329 |
+
with a `<think>` block. A prompt that lives in the caller survives that; a `translate` op baked into
|
| 330 |
+
the engine would have to be rewritten and re-shipped to every caller.
|
| 331 |
+
|
| 332 |
+
## When it does not work
|
| 333 |
+
|
| 334 |
+
Every failure carries a code. `message` stays human-readable and stays the thing you print; `detail`
|
| 335 |
+
carries structured context so you never parse the sentence.
|
| 336 |
+
|
| 337 |
+
```js
|
| 338 |
+
import { isEngineError, ERROR } from "everything-webgpu";
|
| 339 |
+
|
| 340 |
+
try {
|
| 341 |
+
await engine.load(id);
|
| 342 |
+
} catch (err) {
|
| 343 |
+
if (isEngineError(err, ERROR.CACHE_INCOMPLETE)) return reRegisterFolder();
|
| 344 |
+
throw err;
|
| 345 |
+
}
|
| 346 |
+
```
|
| 347 |
+
|
| 348 |
+
| code | what to do about it |
|
| 349 |
+
| --- | --- |
|
| 350 |
+
| `NO_WEBGPU` | Tell the user to check flags/hardware; retrying is futile. |
|
| 351 |
+
| `NO_MODEL` | Nothing registered at all — send them to your setup flow. |
|
| 352 |
+
| `UNKNOWN_MODEL` | That id is not resolvable; `listAvailableModels()` says what is. |
|
| 353 |
+
| `CACHE_INCOMPLETE` | A locally-registered model was evicted; re-register the folder. |
|
| 354 |
+
| `INVALID_MODEL_FOLDER` | Not a compiled MLC model; `detail` says what is missing. |
|
| 355 |
+
| `BAD_REQUEST` | The caller's arguments are wrong — a bug in the caller. |
|
| 356 |
+
| `ABORTED` | The caller cancelled it. Not a failure; do not report it as one. |
|
| 357 |
+
| `GENERATION_FAILED` | The model failed mid-generation. |
|
| 358 |
+
| `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. |
|
| 359 |
+
|
| 360 |
+
### Ask before you download, not after
|
| 361 |
+
|
| 362 |
+
`environment()` is the preflight: one call that says what this machine will do, and why.
|
| 363 |
+
|
| 364 |
+
```js
|
| 365 |
+
const report = await engine.environment();
|
| 366 |
+
if (!report.ok) console.error(report.lines[0].fix ?? report.lines[0].cause);
|
| 367 |
+
```
|
| 368 |
+
|
| 369 |
+
Every line carries the same five fields, so you can render the whole report without special-casing
|
| 370 |
+
any of it:
|
| 371 |
+
|
| 372 |
+
| field | |
|
| 373 |
+
| --- | --- |
|
| 374 |
+
| `severity` | `blocked` · `degraded` · `tune` · `info` · `ok` — lines come sorted worst-first |
|
| 375 |
+
| `affects` | what you lose, in your terms |
|
| 376 |
+
| `cause` | the measured fact behind the verdict |
|
| 377 |
+
| `fix` | the exact call to make, or `null` when nothing can be done |
|
| 378 |
+
| `operable` | whether this is reachable from JS at all |
|
| 379 |
+
|
| 380 |
+
`fix` is `null` for hardware, build-time flags, and browser settings JS cannot reach. Reporting a
|
| 381 |
+
consequence with no remedy is still the point: *"your second turn is slow because this device caps
|
| 382 |
+
storage buffers at 9"* is the difference between a bug report and an informed decision.
|
| 383 |
+
|
| 384 |
+
```js
|
| 385 |
+
await engine.environment({ scope: "local" }); // never touches the model layer — cheap, poll freely
|
| 386 |
+
await engine.environment({ scope: "device" }); // hardware only
|
| 387 |
+
await engine.environment.measure(); // one calibration generation → measured tok/s
|
| 388 |
+
```
|
| 389 |
+
|
| 390 |
+
**`environment()` only reports.** Writes go through `configure()`, and passing a setting to
|
| 391 |
+
`environment()` is an error that names the call you wanted — one function doing both, told apart by
|
| 392 |
+
argument shape, is how you get a call that silently does nothing.
|
| 393 |
+
|
| 394 |
+
```js
|
| 395 |
+
await engine.configure({ decodeSteps: 8 }); // hot, no reload
|
| 396 |
+
await engine.configure({ engineCount: 3 }); // persisted; applies to pools built after it
|
| 397 |
+
```
|
| 398 |
+
|
| 399 |
+
`canRun(modelId)` stays separate and answers the per-**model** question — will this one fit and run
|
| 400 |
+
here — where `environment()` is per-**device**.
|
| 401 |
+
|
| 402 |
+
## Freeing memory
|
| 403 |
+
|
| 404 |
+
A resident model is a full copy of its weights in VRAM, and nothing reports free VRAM to a page — so
|
| 405 |
+
residency is explicit rather than guessed at.
|
| 406 |
+
|
| 407 |
+
```js
|
| 408 |
+
await engine.unload(); // the current model's VRAM; cached bytes stay, so reloading is free
|
| 409 |
+
await engine.unload(id); // that model's VRAM
|
| 410 |
+
await engine.unload(id, "cache"); // and delete its cached bytes, keeping the registry entry
|
| 411 |
+
await engine.unloadAll(); // every resident model
|
| 412 |
+
await engine.remove(id); // bytes and entry; for an injected model, means re-supplying the folder
|
| 413 |
+
```
|
| 414 |
+
|
| 415 |
+
A bare `unload()` frees **only the current model** — `unloadAll()` is the explicit form, because
|
| 416 |
+
freeing everything is the more destructive reading and should have to be asked for by name.
|
| 417 |
+
`remove()` keeps its own verb for the same reason: it is the one that cannot be undone.
|
| 418 |
+
`evict(id)` remains as the low-level primitive `unload(id, "cache")` is built on.
|
| 419 |
+
|
| 420 |
+
`load()` unloads whatever else is up before bringing a model in, which is the safe default on a
|
| 421 |
+
16 GB machine. Pass `{ keepResident: true }` to hold two at once, having checked the budget with
|
| 422 |
+
`canRun()` first. `cacheState(modelId)` says what is already on disk.
|
| 423 |
+
|
| 424 |
+
## Choosing a store
|
| 425 |
+
|
| 426 |
+
`CreateScheduledEngine` defaults to IndexedDB, because a registry that dies with the page would
|
| 427 |
+
strand the weights — the bytes stay in Cache Storage but nothing remembers they are there. Pass one
|
| 428 |
+
explicitly in a worker or a test:
|
| 429 |
+
|
| 430 |
+
```js
|
| 431 |
+
import { ScheduledEngine, ModelStore } from "everything-webgpu";
|
| 432 |
+
import { memoryStorage } from "everything-webgpu/adapters/memory";
|
| 433 |
+
|
| 434 |
+
const engine = new ScheduledEngine({ store: new ModelStore(memoryStorage()) });
|
| 435 |
+
```
|
| 436 |
+
|
| 437 |
+
Adapters ship for `idb` (pages, plus `ensurePersistent()`), `memory` (tests), and `webext`
|
| 438 |
+
(`browser.storage.local` and the message/port router).
|
| 439 |
+
|
| 440 |
+
## Measured
|
| 441 |
+
|
| 442 |
+
M4 MacBook Air (16 GB), Firefox 154 release, macOS. `Qwen3.8-2B-q4f16_1` (1.06 GB) shipping,
|
| 443 |
+
`Qwen3.5-0.8B-q4f16_1-MLC` (443 MB) as the baseline most of the analysis was done on.
|
| 444 |
+
|
| 445 |
+
| | 2B | 0.8B |
|
| 446 |
+
| --- | --- | --- |
|
| 447 |
+
| Model load, cache only, zero network | 51 s | 48 s |
|
| 448 |
+
| Prefill | 48 tok/s short, 100–200 at length | 95–98 tok/s |
|
| 449 |
+
| **Decode** | **16.6–18.1 tok/s** | **25.9 tok/s** |
|
| 450 |
+
| Decode, stock single-step WebLLM | — | 9.6 tok/s |
|
| 451 |
+
| Second engine, 4-prompt batch | 1.06x | — |
|
| 452 |
+
|
| 453 |
+
Reproduce with `npm run e2e`. The full analysis — where every millisecond goes, why a second engine
|
| 454 |
+
buys isolation rather than throughput, and what is still open — is in [AI.md](AI.md).
|
| 455 |
+
|
| 456 |
+
## Stability
|
| 457 |
+
|
| 458 |
+
`chat.completions.create()` is the compatibility layer. **It does not change**, and any change to
|
| 459 |
+
its request or response shape is a major version. That is the whole point of the one-line migration:
|
| 460 |
+
if it drifted, the line would not be one line.
|
| 461 |
+
|
| 462 |
+
Everything else is pre-1.0 and may move. The ergonomic verbs (`load`, `unload`, `environment`) are
|
| 463 |
+
being consolidated — see [ROADMAP.md](ROADMAP.md).
|
| 464 |
+
|
| 465 |
+
## Docs
|
| 466 |
+
|
| 467 |
+
| | |
|
| 468 |
+
| --- | --- |
|
| 469 |
+
| [API.md](API.md) | Every way to call it — one page, asserted against the code. |
|
| 470 |
+
| [examples/](examples/) | Three runnable projects. Read one before reading the source. |
|
| 471 |
+
| [AI.md](AI.md) | What is true and measured. The reference. |
|
| 472 |
+
| [ROADMAP.md](ROADMAP.md) | The only list of open work. |
|
| 473 |
+
| [ARCHIVE.md](ARCHIVE.md) | What was done and *why* — decisions with their reasoning. |
|
| 474 |
+
| [WEBLLM-SURFACE.md](WEBLLM-SURFACE.md) | What WebLLM already does, where our line is, and the dependency-bump runbook. |
|
| 475 |
+
| [MLC-COMPILE.md](MLC-COMPILE.md) | How the model was compiled, and every toolchain breakage on the way. |
|
| 476 |
+
|
| 477 |
+
## Development
|
| 478 |
+
|
| 479 |
+
```sh
|
| 480 |
+
npm run build # bundle WebLLM + apply the two patches
|
| 481 |
+
npm test # contract, patches, README, examples, scheduler, multistep — GPU-free
|
| 482 |
+
npm run e2e # real Firefox + real GPU + real model
|
| 483 |
+
npm run verify-consumer # pack + install for real; proves the Vite plugin is needed and works
|
| 484 |
+
npm run verify-patches # check patch anchors without rebuilding
|
| 485 |
+
```
|
| 486 |
+
|
| 487 |
+
`@mlc-ai/web-llm` is pinned exactly, because the build rewrites its bundle at verified anchors.
|
| 488 |
+
Bumping it is a documented, guarded procedure — see WEBLLM-SURFACE.md, "Upgrading".
|
| 489 |
+
|
| 490 |
+
## Licence
|
| 491 |
+
|
| 492 |
+
This package is **ISC** ([LICENSE](LICENSE)). It ships a pre-built copy of WebLLM
|
| 493 |
+
(`vendor/web-llm.js`, **Apache-2.0**) and its dependency `loglevel` (**MIT**); their full licence
|
| 494 |
+
texts travel in [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md), and
|
| 495 |
+
[test/license.test.mjs](test/license.test.mjs) fails the build if a dependency is ever bundled
|
| 496 |
+
without one.
|
ROADMAP.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Roadmap — open work only
|
| 2 |
+
|
| 3 |
+
**This is the only list of open work.** Anything finished moves to [ARCHIVE.md](ARCHIVE.md) with the
|
| 4 |
+
reasoning that produced it. [AI.md](AI.md) is the reference for what is true and measured;
|
| 5 |
+
[API.md](API.md) is the full call surface, asserted against the code;
|
| 6 |
+
[WEBLLM-SURFACE.md](WEBLLM-SURFACE.md) is what WebLLM already does and must be read before adding a
|
| 7 |
+
capability.
|
| 8 |
+
|
| 9 |
+
Sections are named, not numbered. An earlier split across three files used "Track 1"/"Track 2" to
|
| 10 |
+
mean different things in each, which is how a reader ends up implementing the wrong item.
|
| 11 |
+
|
| 12 |
+
**Sequencing.** The whole project exists to make WebLLM *more compatible, easier to use, foolproof
|
| 13 |
+
to build on*. Every item below is ranked against that. `everything-webgpu@0.1.0` is on npm as of
|
| 14 |
+
this writing — the install path, the four-line demo, the ergonomic verbs, `API.md` and `examples/`
|
| 15 |
+
all shipped and are recorded in [ARCHIVE.md](ARCHIVE.md). Section 1 is what remains before the
|
| 16 |
+
extraction is fully proven: the extension itself rebuilding on the package. Section 4 (performance)
|
| 17 |
+
is frozen as a block, except for "Measure Ollama", until a real user calls it slow: it is the
|
| 18 |
+
highest-risk, highest-effort work and it re-touches the tvmjs internals the WebLLM-upgrade section
|
| 19 |
+
(also in [ARCHIVE.md](ARCHIVE.md)) just spent its time hardening.
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## 1. Ship it — the last mile
|
| 24 |
+
|
| 25 |
+
`everything-webgpu@0.1.0` is on npm. What shipped — the install path, the four-line demo, the
|
| 26 |
+
ergonomic verbs, [API.md](API.md), [examples/](examples/), the bundle-size story, licence
|
| 27 |
+
compliance — is in [ARCHIVE.md](ARCHIVE.md), "Shipping 0.1.0". What remains is proving the boundary
|
| 28 |
+
from the other side.
|
| 29 |
+
|
| 30 |
+
- [ ] **`demo` consumes the package**, and the popup + manager leave `main`. The acceptance test for
|
| 31 |
+
the whole extraction: if the extension rebuilds on the published package, the boundary is
|
| 32 |
+
right. `npm run e2e` passing is the same claim at the source-tree level.
|
| 33 |
+
- [~] **Gate A — a bare Vite page loads a prebuilt model and generates.**
|
| 34 |
+
[examples/bare/](examples/bare/) exists and the GPU-free half is proven: `vite build` resolves
|
| 35 |
+
through the `exports` map and splits into an entry chunk, the decode worker as its own chunk,
|
| 36 |
+
and the 6 MB WebLLM bundle as a lazy one. What remains is **one run in a WebGPU browser** to
|
| 37 |
+
confirm it generates. `npm run verify-consumer` already exercises a real install end to end,
|
| 38 |
+
short of the actual generation.
|
| 39 |
+
- [ ] **Gate A′ — the same page ingests a local folder.** `cache.put()` against the synthetic
|
| 40 |
+
`local-model.invalid` key is proven only on an extension origin. Gates the *offline* route,
|
| 41 |
+
not the library.
|
| 42 |
+
- [ ] **Gate B — Chrome.** Measure tok/s. Expected to beat Firefox because KV reuse is not disabled
|
| 43 |
+
there. Entirely a prediction today; `probeDevice()` is the instrument that makes it reportable.
|
| 44 |
+
- [ ] **Every `ERROR` code carries an actionable `fix`.** The preflight (`environment()`) and the
|
| 45 |
+
typed errors (9 codes now, `PACKAGE_INCOMPLETE` included) both exist; what is not yet done is
|
| 46 |
+
the systematic pass confirming each code's `message`/`detail` names a cause *and* a fix, and
|
| 47 |
+
that the README/API.md point at `environment()` as the "will this run here?" call it is meant
|
| 48 |
+
to be.
|
| 49 |
+
|
| 50 |
+
## 2. Verb consolidation — done
|
| 51 |
+
|
| 52 |
+
`load(src, opts)`, `unload(id, level)` / `remove()` / `unloadAll()`, and `environment()` +
|
| 53 |
+
`environment.measure()` all shipped. The reasoning — the two dropped `load()` dispatch rules, why a
|
| 54 |
+
bare `unload()` frees only the current model, why read and write are split — is in
|
| 55 |
+
[ARCHIVE.md](ARCHIVE.md), "Verb consolidation". `chat.completions.create()` is unchanged and stays
|
| 56 |
+
that way.
|
| 57 |
+
|
| 58 |
+
## 3. Engine capability
|
| 59 |
+
|
| 60 |
+
`prefetch()`, `embed()` / `embedRaw()`, and the `ask()` / `conversation()` / `ghostText()` recipes
|
| 61 |
+
shipped — see [ARCHIVE.md](ARCHIVE.md), "Engine capability". One item is still open:
|
| 62 |
+
|
| 63 |
+
- [ ] **LRU eviction** when quota is short. `cacheState()` and `evict()` exist; nothing yet decides
|
| 64 |
+
*what* to drop. Table stakes for "it just works" the moment a second model is cached — but the
|
| 65 |
+
eviction policy is a decision, not an implementation detail.
|
| 66 |
+
|
| 67 |
+
## 4. Performance — frozen, except the measurement
|
| 68 |
+
|
| 69 |
+
Model- and kernel-level work, independent of the library structure — and therefore independent of
|
| 70 |
+
everything this project is *for*. **Frozen as a block until a real user calls it slow.** The library
|
| 71 |
+
ships now, so "nobody can install it" is no longer the argument; the argument is that this is the
|
| 72 |
+
highest-risk, highest-effort work on the page, it re-touches the tvmjs internals the WebLLM-upgrade
|
| 73 |
+
section (in [ARCHIVE.md](ARCHIVE.md)) just spent its time hardening, and a speedup nobody has asked
|
| 74 |
+
for is risk without a return. The one exception is "Measure Ollama": it is cheap, and it decides
|
| 75 |
+
whether any of the rest is worth its risk.
|
| 76 |
+
|
| 77 |
+
- [ ] **Retune the dlight GEMV schedule** — more work per thread before the reduction. Measured
|
| 78 |
+
**1.83x** in isolation, which would put decode near the ~41 GB/s dequant ceiling. Confirmed
|
| 79 |
+
untaken on the 2B build. Needs a recompile; toolchain is stood up.
|
| 80 |
+
- [ ] **Batched decode.** The model lib exports `batch_decode` / `batch_prefill` / `batch_verify`
|
| 81 |
+
and a paged KV cache; WebLLM hardcodes `defaultMaxNumSequence = 1` and `numSamples = 1`
|
| 82 |
+
(bundle lines 15250, 15276). Lifting that reads the weights once per step for N sequences —
|
| 83 |
+
projected **~4x on the `batch` API**, which is the page-translation shape and the one workload
|
| 84 |
+
the engine currently makes no faster than a loop of `chat` calls. No recompilation needed.
|
| 85 |
+
**Not** a fix for the 1.06x second-engine measurement: a second engine buys task isolation,
|
| 86 |
+
never aggregate throughput, and the two are complementary rather than alternatives.
|
| 87 |
+
- [ ] **Interrupt granularity in `sampleBurst`.** `interruptGenerate()` only sets a flag the
|
| 88 |
+
caller's loop reads between `decode()` calls, and a burst runs all K forward steps without
|
| 89 |
+
checking it — so a preempted job finishes its whole burst first (~583 ms at K=15). It binds in
|
| 90 |
+
three cases: the seconds-long window while `#grow()` builds a second engine, a machine where
|
| 91 |
+
`#growthBlocked` is set, and any third concurrent task once the pool is at cap. Fix in our
|
| 92 |
+
`multistep.js`, not WebLLM; `discardLookahead` already handles the resulting state.
|
| 93 |
+
- [ ] **Auto-tune `decodeSteps` from the burst probe.** K=15 is a constant derived from one machine
|
| 94 |
+
and one model — Firefox on Apple Silicon, `Qwen3.5-0.8B` at ~7.3 ms/step. `multistep.js`'s own
|
| 95 |
+
header says the best K falls as the model grows (a 25 ms/step model wants K=4), so the shipped
|
| 96 |
+
default is wrong for every model but the one it was measured on, and wrong silently: a bad K
|
| 97 |
+
costs throughput and reports nothing.
|
| 98 |
+
|
| 99 |
+
**The infrastructure is already there.** `onBurst` delivers `{ steps, tokens, ms, encodeMs,
|
| 100 |
+
syncMs, dispatches, forwardDispatches, flushes }` per burst
|
| 101 |
+
([multistep.js:246](src/engine/multistep.js#L246)), `setSteps()` is hot
|
| 102 |
+
([multistep.js:259](src/engine/multistep.js#L259)), and `WORKER_CONFIGURE` already calls
|
| 103 |
+
`resetStats()` on every retune so "a sweep's points never bleed into each other"
|
| 104 |
+
([engine-worker.js:137](src/engine/engine-worker.js#L137)). It was built for this.
|
| 105 |
+
|
| 106 |
+
**The obvious derivation does not work.** `K = floor(tickMs / perStepMs)` needs `perStepMs`, and
|
| 107 |
+
the probe cannot separate it: `syncMs` is GPU execution *plus* the wait for the poll tick, which
|
| 108 |
+
is the quantity being solved for. Only `encodeMs` is clean — the K-step loop contains no `await`,
|
| 109 |
+
so it is content-process CPU alone — and it is the smaller half.
|
| 110 |
+
|
| 111 |
+
**What works instead** is a hill-climb on the one quantity directly observed, `tokens / ms`.
|
| 112 |
+
Throughput at K is `K / (ceil(K·p/tick)·tick)`; its peaks sit at `K = floor(n·tick/p)` and are
|
| 113 |
+
asymptotically equal in n, so the first peak is the target and climbing K until throughput drops
|
| 114 |
+
finds it without ever naming `p` or `tick`. That is also what makes it survive Chrome, where the
|
| 115 |
+
100 ms tick premise does not hold.
|
| 116 |
+
|
| 117 |
+
**Three subtleties that will bite.** (1) `onBurst.steps` is the *clamped* burst size —
|
| 118 |
+
`burstSize()` cuts it by `max_tokens` and the context window
|
| 119 |
+
([multistep.js:297](src/engine/multistep.js#L297)) — so the tuner must learn only from bursts
|
| 120 |
+
where `steps === config.steps`, or it learns from end-of-generation stubs. (2) The first burst on
|
| 121 |
+
a pipeline carries shader compilation; skip it. (3) `clampSteps("auto")` is **1** today
|
| 122 |
+
(`Number("auto")` → NaN → `|| 1`), so an `"auto"` sentinel silently *disables* multi-step unless
|
| 123 |
+
`clampSteps` and [API.md](API.md)'s documented `1`–`32` range change together —
|
| 124 |
+
`api-doc.test.mjs` asserts the two agree.
|
| 125 |
+
|
| 126 |
+
**Sequencing.** Composes with per-priority K below rather than replacing it: this finds the
|
| 127 |
+
throughput ceiling K_max for the device+model, per-priority then spends it (`interactive` takes
|
| 128 |
+
min(K_max, 4), `background` takes K_max). Do this one first — per-priority is written against the
|
| 129 |
+
15 this replaces. Unlike the rest of section 4 it touches **no new tvmjs internals**: it reads
|
| 130 |
+
telemetry that already flows and calls a setter that already exists, which is the argument for
|
| 131 |
+
lifting the freeze on this item alone. It cannot be *validated* here, though — the sawtooth only
|
| 132 |
+
exists on real hardware, and Gate B (Chrome) is what proves the controller is finding a real peak
|
| 133 |
+
rather than re-deriving 15.
|
| 134 |
+
|
| 135 |
+
- [ ] **Per-priority `decodeSteps`.** K=15 maximises throughput but emits 15 tokens every ~583 ms,
|
| 136 |
+
which reads as a stall. `interactive` should use K=2–4, `background`/`batch` K=32. Do this
|
| 137 |
+
*after* the interrupt fix — raising K for background work lengthens exactly the bursts that
|
| 138 |
+
preemption has to wait out.
|
| 139 |
+
- [ ] **Restore cross-turn KV reuse.** Every turn re-prefills the whole history at **5.27 ms/token**,
|
| 140 |
+
so a turn near the 4096 limit pays ~22 s before its first token. Pack
|
| 141 |
+
`batch_prefill_paged_kv_kernel`'s six i32 metadata buffers into one with offsets (10 bindings
|
| 142 |
+
→ 5); the offsets already exist in its uniform block.
|
| 143 |
+
|
| 144 |
+
## 5. Test and infrastructure
|
| 145 |
+
|
| 146 |
+
Both are "fix when it next bites", not scheduled work. The completed e2e run that used to sit here
|
| 147 |
+
is in [ARCHIVE.md](ARCHIVE.md), including the `storageBuffersPerStage=9` anomaly worth re-checking
|
| 148 |
+
on the next run.
|
| 149 |
+
|
| 150 |
+
- [ ] Fix `PROFILE_PATH` in [test/e2e/run.mjs](test/e2e/run.mjs): it passes `--profile-path`, but
|
| 151 |
+
web-ext 8 calls it `--firefox-profile` and exits with `Unknown arguments`.
|
| 152 |
+
- [~] **Isolate the bench's pass-sweep onto its own device** — done, and it **did not do what the
|
| 153 |
+
item assumed**. 2048 compute passes in one encoder loses the WebGPU device, and a lost device
|
| 154 |
+
does not throw: later calls silently no-op.
|
| 155 |
+
|
| 156 |
+
Two corrections from measuring it. First, the stated worry (later probes poisoned) was empty
|
| 157 |
+
— the sweep was already the last phase, so nothing ran after it. The real damage was to the
|
| 158 |
+
sweep's **own** numbers: the run that prompted this reported `perPass=-3.9us`, 512 passes
|
| 159 |
+
measured faster than one, which is the device dying mid-sweep and the remaining submits
|
| 160 |
+
becoming free. That half is fixed — `n512` now reads a plausible ~10us and `n2048` reports
|
| 161 |
+
`discarded (device lost during the 2048-pass encode)` instead of inventing a number.
|
| 162 |
+
|
| 163 |
+
Second, **isolation does not contain the loss.** With the sweep on its own device,
|
| 164 |
+
`deviceLostDuringBench` still reports the main device lost during this phase, on both runs. A
|
| 165 |
+
runaway command buffer on Metal appears to reset the whole adapter rather than one device on
|
| 166 |
+
it. So the honest state is: sweep numbers are now trustworthy, containment is not achieved,
|
| 167 |
+
and the bench is safe only because this is the last phase.
|
| 168 |
+
|
| 169 |
+
Still open, if containment is wanted: run the sweep in a dedicated worker or a separate page
|
| 170 |
+
so the reset cannot reach the measuring context at all. Also worth noting the attribution is
|
| 171 |
+
imprecise — `lostDuring` records the phase at which `device.lost` *resolves*, and the
|
| 172 |
+
16384-dispatch probe immediately above is another plausible culprit.
|
| 173 |
+
|
| 174 |
+
## 6. Deferred, with the condition that would reopen them
|
| 175 |
+
|
| 176 |
+
| Item | Reopens when |
|
| 177 |
+
| --- | --- |
|
| 178 |
+
| **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. |
|
| 179 |
+
| **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. |
|
| 180 |
+
| **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. |
|
| 181 |
+
| **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. |
|
THIRD-PARTY-NOTICES.md
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Third-party notices
|
| 2 |
+
|
| 3 |
+
`everything-webgpu` ships a pre-built, lightly-patched copy of WebLLM at
|
| 4 |
+
`vendor/web-llm.js` (see [WEBLLM-SURFACE.md](WEBLLM-SURFACE.md) and
|
| 5 |
+
[build/patches.mjs](build/patches.mjs) for what the patches are and why). That
|
| 6 |
+
file is a bundle: it contains the components listed below, and their licenses
|
| 7 |
+
travel with it.
|
| 8 |
+
|
| 9 |
+
The patches change two code paths for Firefox's WebGPU limits and compute-pass
|
| 10 |
+
batching; they add no third-party code. Everything bundled is enumerated here.
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## @mlc-ai/web-llm 0.2.84
|
| 15 |
+
|
| 16 |
+
- License: **Apache-2.0**
|
| 17 |
+
- Project: https://github.com/mlc-ai/web-llm
|
| 18 |
+
|
| 19 |
+
WebLLM's own LICENSE file (reproduced verbatim below) also carries a summary of
|
| 20 |
+
the components WebLLM itself vendors.
|
| 21 |
+
|
| 22 |
+
```
|
| 23 |
+
Apache License
|
| 24 |
+
Version 2.0, January 2004
|
| 25 |
+
http://www.apache.org/licenses/
|
| 26 |
+
|
| 27 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 28 |
+
|
| 29 |
+
1. Definitions.
|
| 30 |
+
|
| 31 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 32 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 33 |
+
|
| 34 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 35 |
+
the copyright owner that is granting the License.
|
| 36 |
+
|
| 37 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 38 |
+
other entities that control, are controlled by, or are under common
|
| 39 |
+
control with that entity. For the purposes of this definition,
|
| 40 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 41 |
+
direction or management of such entity, whether by contract or
|
| 42 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 43 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 44 |
+
|
| 45 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 46 |
+
exercising permissions granted by this License.
|
| 47 |
+
|
| 48 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 49 |
+
including but not limited to software source code, documentation
|
| 50 |
+
source, and configuration files.
|
| 51 |
+
|
| 52 |
+
"Object" form shall mean any form resulting from mechanical
|
| 53 |
+
transformation or translation of a Source form, including but
|
| 54 |
+
not limited to compiled object code, generated documentation,
|
| 55 |
+
and conversions to other media types.
|
| 56 |
+
|
| 57 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 58 |
+
Object form, made available under the License, as indicated by a
|
| 59 |
+
copyright notice that is included in or attached to the work
|
| 60 |
+
(an example is provided in the Appendix below).
|
| 61 |
+
|
| 62 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 63 |
+
form, that is based on (or derived from) the Work and for which the
|
| 64 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 65 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 66 |
+
of this License, Derivative Works shall not include works that remain
|
| 67 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 68 |
+
the Work and Derivative Works thereof.
|
| 69 |
+
|
| 70 |
+
"Contribution" shall mean any work of authorship, including
|
| 71 |
+
the original version of the Work and any modifications or additions
|
| 72 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 73 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 74 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 75 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 76 |
+
means any form of electronic, verbal, or written communication sent
|
| 77 |
+
to the Licensor or its representatives, including but not limited to
|
| 78 |
+
communication on electronic mailing lists, source code control systems,
|
| 79 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 80 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 81 |
+
excluding communication that is conspicuously marked or otherwise
|
| 82 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 83 |
+
|
| 84 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 85 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 86 |
+
subsequently incorporated within the Work.
|
| 87 |
+
|
| 88 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 89 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 90 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 91 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 92 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 93 |
+
Work and such Derivative Works in Source or Object form.
|
| 94 |
+
|
| 95 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 96 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 97 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 98 |
+
(except as stated in this section) patent license to make, have made,
|
| 99 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 100 |
+
where such license applies only to those patent claims licensable
|
| 101 |
+
by such Contributor that are necessarily infringed by their
|
| 102 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 103 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 104 |
+
institute patent litigation against any entity (including a
|
| 105 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 106 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 107 |
+
or contributory patent infringement, then any patent licenses
|
| 108 |
+
granted to You under this License for that Work shall terminate
|
| 109 |
+
as of the date such litigation is filed.
|
| 110 |
+
|
| 111 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 112 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 113 |
+
modifications, and in Source or Object form, provided that You
|
| 114 |
+
meet the following conditions:
|
| 115 |
+
|
| 116 |
+
(a) You must give any other recipients of the Work or
|
| 117 |
+
Derivative Works a copy of this License; and
|
| 118 |
+
|
| 119 |
+
(b) You must cause any modified files to carry prominent notices
|
| 120 |
+
stating that You changed the files; and
|
| 121 |
+
|
| 122 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 123 |
+
that You distribute, all copyright, patent, trademark, and
|
| 124 |
+
attribution notices from the Source form of the Work,
|
| 125 |
+
excluding those notices that do not pertain to any part of
|
| 126 |
+
the Derivative Works; and
|
| 127 |
+
|
| 128 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 129 |
+
distribution, then any Derivative Works that You distribute must
|
| 130 |
+
include a readable copy of the attribution notices contained
|
| 131 |
+
within such NOTICE file, excluding those notices that do not
|
| 132 |
+
pertain to any part of the Derivative Works, in at least one
|
| 133 |
+
of the following places: within a NOTICE text file distributed
|
| 134 |
+
as part of the Derivative Works; within the Source form or
|
| 135 |
+
documentation, if provided along with the Derivative Works; or,
|
| 136 |
+
within a display generated by the Derivative Works, if and
|
| 137 |
+
wherever such third-party notices normally appear. The contents
|
| 138 |
+
of the NOTICE file are for informational purposes only and
|
| 139 |
+
do not modify the License. You may add Your own attribution
|
| 140 |
+
notices within Derivative Works that You distribute, alongside
|
| 141 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 142 |
+
that such additional attribution notices cannot be construed
|
| 143 |
+
as modifying the License.
|
| 144 |
+
|
| 145 |
+
You may add Your own copyright statement to Your modifications and
|
| 146 |
+
may provide additional or different license terms and conditions
|
| 147 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 148 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 149 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 150 |
+
the conditions stated in this License.
|
| 151 |
+
|
| 152 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 153 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 154 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 155 |
+
this License, without any additional terms or conditions.
|
| 156 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 157 |
+
the terms of any separate license agreement you may have executed
|
| 158 |
+
with Licensor regarding such Contributions.
|
| 159 |
+
|
| 160 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 161 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 162 |
+
except as required for reasonable and customary use in describing the
|
| 163 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 164 |
+
|
| 165 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 166 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 167 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 168 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 169 |
+
implied, including, without limitation, any warranties or conditions
|
| 170 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 171 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 172 |
+
appropriateness of using or redistributing the Work and assume any
|
| 173 |
+
risks associated with Your exercise of permissions under this License.
|
| 174 |
+
|
| 175 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 176 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 177 |
+
unless required by applicable law (such as deliberate and grossly
|
| 178 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 179 |
+
liable to You for damages, including any direct, indirect, special,
|
| 180 |
+
incidental, or consequential damages of any character arising as a
|
| 181 |
+
result of this License or out of the use or inability to use the
|
| 182 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 183 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 184 |
+
other commercial damages or losses), even if such Contributor
|
| 185 |
+
has been advised of the possibility of such damages.
|
| 186 |
+
|
| 187 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 188 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 189 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 190 |
+
or other liability obligations and/or rights consistent with this
|
| 191 |
+
License. However, in accepting such obligations, You may act only
|
| 192 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 193 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 194 |
+
defend, and hold each Contributor harmless for any liability
|
| 195 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 196 |
+
of your accepting any such warranty or additional liability.
|
| 197 |
+
|
| 198 |
+
END OF TERMS AND CONDITIONS
|
| 199 |
+
|
| 200 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 201 |
+
|
| 202 |
+
To apply the Apache License to your work, attach the following
|
| 203 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
| 204 |
+
replaced with your own identifying information. (Don't include
|
| 205 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 206 |
+
comment syntax for the file format. We also recommend that a
|
| 207 |
+
file or class name and description of purpose be included on the
|
| 208 |
+
same "printed page" as the copyright notice for easier
|
| 209 |
+
identification within third-party archives.
|
| 210 |
+
|
| 211 |
+
Copyright [yyyy] [name of copyright owner]
|
| 212 |
+
|
| 213 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 214 |
+
you may not use this file except in compliance with the License.
|
| 215 |
+
You may obtain a copy of the License at
|
| 216 |
+
|
| 217 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 218 |
+
|
| 219 |
+
Unless required by applicable law or agreed to in writing, software
|
| 220 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 221 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 222 |
+
See the License for the specific language governing permissions and
|
| 223 |
+
limitations under the License.
|
| 224 |
+
|
| 225 |
+
------------------------------------------------------------------------------------
|
| 226 |
+
This product bundles various third-party components under other open source licenses.
|
| 227 |
+
This section summarizes those components and their licenses. See licenses/
|
| 228 |
+
for text of these licenses.
|
| 229 |
+
|
| 230 |
+
Apache Software Foundation License 2.0
|
| 231 |
+
--------------------------------------
|
| 232 |
+
|
| 233 |
+
src/openai_api_protocols
|
| 234 |
+
```
|
| 235 |
+
|
| 236 |
+
---
|
| 237 |
+
|
| 238 |
+
## loglevel 1.9.2
|
| 239 |
+
|
| 240 |
+
- License: **MIT**
|
| 241 |
+
- Project: https://github.com/pimterry/loglevel
|
| 242 |
+
- Bundled transitively as WebLLM's only runtime dependency.
|
| 243 |
+
|
| 244 |
+
```
|
| 245 |
+
Copyright (c) 2013 Tim Perry
|
| 246 |
+
|
| 247 |
+
Permission is hereby granted, free of charge, to any person
|
| 248 |
+
obtaining a copy of this software and associated documentation
|
| 249 |
+
files (the "Software"), to deal in the Software without
|
| 250 |
+
restriction, including without limitation the rights to use,
|
| 251 |
+
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 252 |
+
copies of the Software, and to permit persons to whom the
|
| 253 |
+
Software is furnished to do so, subject to the following
|
| 254 |
+
conditions:
|
| 255 |
+
|
| 256 |
+
The above copyright notice and this permission notice shall be
|
| 257 |
+
included in all copies or substantial portions of the Software.
|
| 258 |
+
|
| 259 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
| 260 |
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
| 261 |
+
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
| 262 |
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
| 263 |
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
| 264 |
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
| 265 |
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
| 266 |
+
OTHER DEALINGS IN THE SOFTWARE.
|
| 267 |
+
```
|
WEBLLM-SURFACE.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# WebLLM surface, and where the line is
|
| 2 |
+
|
| 3 |
+
`@mlc-ai/web-llm` **0.2.84**, pinned exactly (`build.mjs` rewrites the bundle at anchors it verifies
|
| 4 |
+
first, and throws on a miss, so a minor bump breaks the build for every consumer).
|
| 5 |
+
|
| 6 |
+
This file exists because it was needed and absent. Building "a superset of WebLLM" without a written
|
| 7 |
+
inventory of WebLLM meant reaching for `grep` one narrow question at a time, and three functions got
|
| 8 |
+
reimplemented that were exported all along — see "Removed", below. **Check this list before adding a
|
| 9 |
+
capability**, per AI.md's Reuse First. Re-check it on every dependency bump.
|
| 10 |
+
|
| 11 |
+
**Every dependency listed here is asserted by
|
| 12 |
+
[test/webllm-contract.test.mjs](test/webllm-contract.test.mjs)** (`npm run contract`). Prose does not
|
| 13 |
+
fail a build; that file does. Add an assertion there whenever you add a row here.
|
| 14 |
+
|
| 15 |
+
## Upgrading `@mlc-ai/web-llm`
|
| 16 |
+
|
| 17 |
+
The pin is exact, and three independent guards catch three different kinds of drift. A bump is a
|
| 18 |
+
checklist that fails loudly at the right line, not an afternoon of `grep`.
|
| 19 |
+
|
| 20 |
+
```sh
|
| 21 |
+
npm i @mlc-ai/web-llm@<new>
|
| 22 |
+
npm run build # 1. surface drift — patches verify before they rewrite
|
| 23 |
+
npm test # 2. semantic drift — the contract asserts every shape we depend on
|
| 24 |
+
npm run e2e # 3. behavioural drift — real GPU, byte-identical greedy generation
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
**1. Surface drift — `npm run build`.** [build/patches.mjs](build/patches.mjs) verifies all four
|
| 28 |
+
patch anchors against a fresh bundle *before* rewriting anything, so a bump reports every break at
|
| 29 |
+
once rather than the first over a half-patched file. A miss prints the anchor, the WebLLM version it
|
| 30 |
+
last held against, and the nearest lines ranked by identifier rarity; for a vanished identifier it
|
| 31 |
+
also names the closest surviving one by trigram overlap — an upstream rename of
|
| 32 |
+
`requiredMaxStorageBuffersPerShaderStage` shows up as an 85%-similar name with its line number.
|
| 33 |
+
Anchors match modulo whitespace and, where the literal string is generic (`compute.end();`), are
|
| 34 |
+
scoped to an enclosing function, so reflow and unrelated new code do not cause false failures.
|
| 35 |
+
`npm run verify-patches` runs just this check without rebuilding. Re-anchoring still needs a human to
|
| 36 |
+
*approve* the new location: edit `build/patches.mjs`, re-run, repeat until green.
|
| 37 |
+
|
| 38 |
+
**2. Semantic drift — `npm test`.** [test/webllm-contract.test.mjs](test/webllm-contract.test.mjs)
|
| 39 |
+
(`npm run contract` alone, GPU-free) asserts every export, field, enum value and finish-reason
|
| 40 |
+
literal in the tables below, plus:
|
| 41 |
+
|
| 42 |
+
- the ~30 undocumented tvmjs pipeline internals `multistep.js` reaches into — the list is *derived
|
| 43 |
+
from that file's own source* and pinned to `PIPELINE_CONTRACT`, so neither can fall behind the code
|
| 44 |
+
- `decode_tokens_per_s` still present in the bundle — `estimateSpeed()`'s measured path needs it
|
| 45 |
+
- `model_lib` still unguessable from base + id — the "require it for a remote source" rule in
|
| 46 |
+
ROADMAP.md's verb-consolidation section depends on this staying true
|
| 47 |
+
|
| 48 |
+
A failure here is the dangerous kind: every symbol still resolves and behaviour changed anyway. Read
|
| 49 |
+
it before touching anything else. `test/patches.test.mjs` and `test/integration.test.mjs` also run
|
| 50 |
+
here and guard the patch machinery and cache-scope names.
|
| 51 |
+
|
| 52 |
+
**3. Behavioural drift — `npm run e2e`.** Real Firefox, real GPU, real model, drag-and-drop
|
| 53 |
+
ingestion through the production `src/engine/` + `src/adapters/` paths. Asserts greedy generation is
|
| 54 |
+
byte-identical and the KV-reuse pipeline matches a forced re-prefill. The only guard that catches a
|
| 55 |
+
change where every name survives, every shape matches, and the tokens are still wrong.
|
| 56 |
+
|
| 57 |
+
**Then, by hand:**
|
| 58 |
+
|
| 59 |
+
- Bump the version line at the top of this file.
|
| 60 |
+
- Re-run the export list and diff it against the previous run:
|
| 61 |
+
```sh
|
| 62 |
+
node -e 'import("./vendor/web-llm.js").then(m=>console.log(Object.keys(m).sort().join("\n")))'
|
| 63 |
+
```
|
| 64 |
+
A new export is a candidate for "available, not used yet". A *removed* one that appears in the
|
| 65 |
+
tables below should already have failed the contract test — if it did not, add the assertion.
|
| 66 |
+
- Walk the tables here for anything the diff or the test output touched.
|
| 67 |
+
|
| 68 |
+
A large tvmjs refactor still needs a human to re-anchor and re-read. The goal is not an automatic
|
| 69 |
+
bump — it is that you find out what broke in seconds and know exactly where to look.
|
| 70 |
+
|
| 71 |
+
## Native — we call it
|
| 72 |
+
|
| 73 |
+
| Export | Used by | For |
|
| 74 |
+
| --- | --- | --- |
|
| 75 |
+
| `CreateWebWorkerMLCEngine` | `engine.js` | one engine per pool slot, each in its own realm |
|
| 76 |
+
| `WebWorkerMLCEngineHandler` | `engine-worker.js` | the worker side of that |
|
| 77 |
+
| `prebuiltAppConfig` | `engine.js` | 163 HuggingFace models, merged under our registry |
|
| 78 |
+
| `functionCallingModelIds` | `engine.js` | which prebuilt models do tool calling — `listAvailableModels()` tags them, `recommendModels({ needsToolCalling })` filters on them. Shipped in the list; not derivable from an id |
|
| 79 |
+
| `hasModelInCache` | `engine.cacheState()` | is a **remote/prebuilt** model on disk |
|
| 80 |
+
| `deleteModelAllInfoInCache` | `engine.evict()` | delete a **remote/prebuilt** model's bytes |
|
| 81 |
+
| `engine.chat.completions.create` | `pool.js` | the actual generation call |
|
| 82 |
+
| `engine.embeddings.create` | `pool.js` | `embed()` / `embedRaw()`. The OpenAI-shaped namespace (`Embeddings.prototype.create`), not the bare `MLCEngine.embedding()` — one forward pass, routed through the same scheduler as a completion |
|
| 83 |
+
| `usage.extra.decode_tokens_per_s` | `engine.#calibrate()` | measured throughput; calibrates projections |
|
| 84 |
+
| `finish_reason` (`stop`/`length`/`abort`) | `pool.js`, `chat.js` | truncation vs. natural stop vs. interrupt |
|
| 85 |
+
| `interruptGenerate()` | `pool.js` | cancellation and preemption |
|
| 86 |
+
| `unload()` → aborts `reloadController` | `engine.js` | **download cancellation is native**; no machinery of ours |
|
| 87 |
+
| `resetChat()` | `engine-worker.js` | forces re-prefill where paged KV cannot build |
|
| 88 |
+
| `ModelType` | `model-store.js` | `MODEL_TYPE`; a VLM must declare itself or images are refused |
|
| 89 |
+
|
| 90 |
+
## Native — available, not used yet
|
| 91 |
+
|
| 92 |
+
| Export | Why it matters |
|
| 93 |
+
| --- | --- |
|
| 94 |
+
| `CreateExtensionServiceWorkerMLCEngine` | **A native answer to the MV3 limit in AI.md.** Evaluate before hand-rolling a keep-alive. |
|
| 95 |
+
| `verifyIntegrity` / `isValidSRI` / `IntegrityError` | SRI checks on fetched artifacts — relevant once models are self-hosted. |
|
| 96 |
+
| `reload([a, b])` + routing on `request.model` | Multiple models in **one** engine. See "Overlapping by design". |
|
| 97 |
+
| `runtimeStatsText()` | Formatted prefill/decode stats. `usage.extra` is the structured form and is what we use. |
|
| 98 |
+
| `getGPUVendor()`, `getMaxStorageBufferBindingSize()` | Overlap `probeDevice()` but need a **loaded** engine, so they cannot answer "will this load". |
|
| 99 |
+
| `completion()`, `Completions` | Legacy non-chat completions. No caller has asked. |
|
| 100 |
+
|
| 101 |
+
## Ours — nothing upstream does this
|
| 102 |
+
|
| 103 |
+
| | Why it cannot be delegated |
|
| 104 |
+
| --- | --- |
|
| 105 |
+
| `pool.js` — priority bands, session supersession, opt-in preemption, one engine per task, demand-driven growth | WebLLM has no scheduler at all |
|
| 106 |
+
| `multistep.js` — K forward steps per GPU sync | 9.7 → 18.4 tok/s; not a WebLLM concept |
|
| 107 |
+
| `build.mjs` — compute-pass batching, storage-buffer clamp | 10.3 → 25.9 tok/s; patches *into* tvmjs |
|
| 108 |
+
| `ingest.js` — validate a folder, write Cache Storage directly | WebLLM only ever fetches; it cannot be handed bytes |
|
| 109 |
+
| `ModelStore.verify()` / `evictInjected()` | We wrote those artifacts and hold the only manifest. WebLLM's equivalents read `tensor-cache.json` to enumerate shards, so they break once *that* file is evicted — the exact case injection has to survive |
|
| 110 |
+
| `device.js` — `probeDevice`, `canRun`, `rankModels` | Preflight, before any download. WebLLM's device getters need a loaded engine |
|
| 111 |
+
| The engine-worker decode probe (`encodeMs`/`syncMs`) | Splits a burst into CPU-encode vs GPU-sync. Nothing upstream reports it, and the multi-step and pass-batching work is measured against it |
|
| 112 |
+
| `absolutize()` in `model-store.js` | WebLLM's `cleanModelUrl` ends in `new URL(url)` with no base, so a relative `/models/x/` throws deep in the loader. We resolve at registration |
|
| 113 |
+
| `errors.js`, the adapters, the wire protocol | Host and API concerns WebLLM has no opinion on |
|
| 114 |
+
|
| 115 |
+
## Rejected — looked delegable, was not
|
| 116 |
+
|
| 117 |
+
**Multi-model residency.** `reload(["a","b"])` holds N models in one engine and routes on
|
| 118 |
+
`request.model` — cheaper in memory, one realm instead of one worker per model. First presented here
|
| 119 |
+
as "not necessary in the way it was first presented", which was wrong: `reload()` calls `unload()`
|
| 120 |
+
unconditionally before loading, so it is **all-or-nothing** — adding a third model to `{A, B}` reloads
|
| 121 |
+
A and B too, ~51 s each on the 2B. There is no incremental residency upstream. Our `#pools:
|
| 122 |
+
Map<modelId, EnginePool>` is the only thing that provides additive residency, which is a hard
|
| 123 |
+
requirement (switching models must not cost re-downloading the ones already up). See ROADMAP.md,
|
| 124 |
+
"Deferred", for the reopening condition.
|
| 125 |
+
|
| 126 |
+
## Removed, having been found redundant
|
| 127 |
+
|
| 128 |
+
| Was | Now |
|
| 129 |
+
| --- | --- |
|
| 130 |
+
| `cleanModelUrl()` reimplemented in `model-store.js` | deleted — only existed to support the two below |
|
| 131 |
+
| `ModelStore.cacheKeysFor()` | deleted — WebLLM derives the keys it fetched |
|
| 132 |
+
| `ModelStore.cacheState()` | `engine.cacheState()`, delegating to `hasModelInCache` for remote |
|
| 133 |
+
| `ModelStore.evict()` (all sources) | `ModelStore.evictInjected()` + `engine.evict()` routing to `deleteModelAllInfoInCache` |
|
| 134 |
+
| speed re-derived from the worker probe | `usage.extra.decode_tokens_per_s`, which every response already carried |
|
package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "everything-webgpu",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"description": "Firefox WebExtension and embeddable library that runs local MLC/WebLLM models on WebGPU, with a priority scheduler, multi-step decoding, and models from HuggingFace, your own URL, or injected straight from disk.",
|
| 5 |
+
"scripts": {
|
| 6 |
+
"build": "node build.mjs",
|
| 7 |
+
"check": "node build.mjs --check-entries",
|
| 8 |
+
"test": "node --test test/webllm-contract.test.mjs test/patches.test.mjs test/readme.test.mjs test/api-doc.test.mjs test/examples.test.mjs test/license.test.mjs test/integration.test.mjs test/scheduler.test.mjs test/multistep.test.mjs test/environment.test.mjs test/sources.test.mjs test/prefetch.test.mjs test/embed.test.mjs test/recipes.test.mjs test/errors.test.mjs test/chat.test.mjs test/device.test.mjs test/manage.test.mjs",
|
| 9 |
+
"package": "npm run build && node build.mjs --zip",
|
| 10 |
+
"e2e": "node test/e2e/run.mjs",
|
| 11 |
+
"verify-consumer": "node test/consumer/verify.mjs",
|
| 12 |
+
"bench": "node test/e2e/bench.mjs",
|
| 13 |
+
"types": "tsc -p tsconfig.json",
|
| 14 |
+
"prepare": "node build.mjs",
|
| 15 |
+
"prepublishOnly": "npm run types && npm test",
|
| 16 |
+
"contract": "node --test test/webllm-contract.test.mjs",
|
| 17 |
+
"verify-patches": "node build.mjs --verify-patches"
|
| 18 |
+
},
|
| 19 |
+
"author": "nyaaorick",
|
| 20 |
+
"license": "ISC",
|
| 21 |
+
"repository": {
|
| 22 |
+
"type": "git",
|
| 23 |
+
"url": "git+https://github.com/nyaaorick/everything-webgpu.git"
|
| 24 |
+
},
|
| 25 |
+
"homepage": "https://github.com/nyaaorick/everything-webgpu#readme",
|
| 26 |
+
"bugs": "https://github.com/nyaaorick/everything-webgpu/issues",
|
| 27 |
+
"dependencies": {
|
| 28 |
+
"@mlc-ai/web-llm": "0.2.84"
|
| 29 |
+
},
|
| 30 |
+
"devDependencies": {
|
| 31 |
+
"acorn": "^8.18.0",
|
| 32 |
+
"esbuild": "^0.28.2",
|
| 33 |
+
"typescript": "^5.9.2"
|
| 34 |
+
},
|
| 35 |
+
"type": "module",
|
| 36 |
+
"types": "types/engine/index.d.ts",
|
| 37 |
+
"exports": {
|
| 38 |
+
".": {
|
| 39 |
+
"types": "./types/engine/index.d.ts",
|
| 40 |
+
"default": "./src/engine/index.js"
|
| 41 |
+
},
|
| 42 |
+
"./worker": "./src/engine/engine-worker.js",
|
| 43 |
+
"./vite": {
|
| 44 |
+
"types": "./types/vite.d.ts",
|
| 45 |
+
"default": "./src/vite.js"
|
| 46 |
+
},
|
| 47 |
+
"./adapters/idb": {
|
| 48 |
+
"types": "./types/adapters/idb.d.ts",
|
| 49 |
+
"default": "./src/adapters/idb.js"
|
| 50 |
+
},
|
| 51 |
+
"./adapters/memory": {
|
| 52 |
+
"types": "./types/adapters/memory.d.ts",
|
| 53 |
+
"default": "./src/adapters/memory.js"
|
| 54 |
+
},
|
| 55 |
+
"./adapters/webext": {
|
| 56 |
+
"types": "./types/adapters/webext.d.ts",
|
| 57 |
+
"default": "./src/adapters/webext.js"
|
| 58 |
+
},
|
| 59 |
+
"./adapters/protocol": {
|
| 60 |
+
"types": "./types/adapters/protocol.d.ts",
|
| 61 |
+
"default": "./src/adapters/protocol.js"
|
| 62 |
+
}
|
| 63 |
+
},
|
| 64 |
+
"files": [
|
| 65 |
+
"src/engine",
|
| 66 |
+
"src/adapters",
|
| 67 |
+
"src/vite.js",
|
| 68 |
+
"types",
|
| 69 |
+
"vendor/web-llm.js",
|
| 70 |
+
"THIRD-PARTY-NOTICES.md"
|
| 71 |
+
]
|
| 72 |
+
}
|
src/adapters/idb.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* IndexedDB StorageAdapter — the default for an ordinary page.
|
| 3 |
+
*
|
| 4 |
+
* IndexedDB rather than `localStorage` for two reasons that both matter here:
|
| 5 |
+
* `localStorage` is synchronous and blocks the thread that is also driving
|
| 6 |
+
* WebGPU, and it does not exist in a worker. The registry itself is small (one
|
| 7 |
+
* record per model, a few dozen cache URLs each), so this is a key/value store
|
| 8 |
+
* and nothing more.
|
| 9 |
+
*
|
| 10 |
+
* `persist()` is exported alongside because the page origin, unlike an
|
| 11 |
+
* extension with `unlimitedStorage`, holds a multi-GB model in *evictable*
|
| 12 |
+
* storage until persistence is granted. See ARCHIVE.md, "Zero-download".
|
| 13 |
+
*/
|
| 14 |
+
const DB_NAME = "everything-webgpu";
|
| 15 |
+
const DB_VERSION = 1;
|
| 16 |
+
const STORE = "kv";
|
| 17 |
+
|
| 18 |
+
function open() {
|
| 19 |
+
return new Promise((resolve, reject) => {
|
| 20 |
+
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
| 21 |
+
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
| 22 |
+
req.onsuccess = () => resolve(req.result);
|
| 23 |
+
req.onerror = () => reject(req.error);
|
| 24 |
+
});
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function run(db, mode, fn) {
|
| 28 |
+
return new Promise((resolve, reject) => {
|
| 29 |
+
const tx = db.transaction(STORE, mode);
|
| 30 |
+
const out = fn(tx.objectStore(STORE));
|
| 31 |
+
tx.oncomplete = () => resolve(out);
|
| 32 |
+
tx.onerror = () => reject(tx.error);
|
| 33 |
+
tx.onabort = () => reject(tx.error);
|
| 34 |
+
});
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const get1 = (store, key) =>
|
| 38 |
+
new Promise((resolve, reject) => {
|
| 39 |
+
const req = store.get(key);
|
| 40 |
+
req.onsuccess = () => resolve(req.result);
|
| 41 |
+
req.onerror = () => reject(req.error);
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
/** @returns {Promise<import("../engine/model-store.js").StorageAdapter>} */
|
| 45 |
+
export async function indexedDBStorage() {
|
| 46 |
+
const db = await open();
|
| 47 |
+
return {
|
| 48 |
+
async get(key) {
|
| 49 |
+
const keys = Array.isArray(key) ? key : [key];
|
| 50 |
+
const out = {};
|
| 51 |
+
await run(db, "readonly", async (store) => {
|
| 52 |
+
for (const k of keys) {
|
| 53 |
+
const value = await get1(store, k);
|
| 54 |
+
if (value !== undefined) out[k] = value;
|
| 55 |
+
}
|
| 56 |
+
});
|
| 57 |
+
return out;
|
| 58 |
+
},
|
| 59 |
+
async set(items) {
|
| 60 |
+
await run(db, "readwrite", (store) => {
|
| 61 |
+
for (const [k, v] of Object.entries(items)) store.put(v, k);
|
| 62 |
+
});
|
| 63 |
+
},
|
| 64 |
+
};
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/**
|
| 68 |
+
* Ask the browser to stop treating this origin's storage as evictable.
|
| 69 |
+
*
|
| 70 |
+
* Firefox prompts the user (or grants silently for a site with the permission
|
| 71 |
+
* already); Chrome decides from engagement heuristics without a prompt. Either
|
| 72 |
+
* way a caller must handle `persisted: false` — the model still works, it can
|
| 73 |
+
* just be dropped under disk pressure, which `ModelStore.verify()` will catch
|
| 74 |
+
* on the next load.
|
| 75 |
+
*
|
| 76 |
+
* @returns {Promise<{persisted: boolean, quota?: number, usage?: number}>}
|
| 77 |
+
*/
|
| 78 |
+
export async function ensurePersistent() {
|
| 79 |
+
const storage = globalThis.navigator?.storage;
|
| 80 |
+
if (!storage?.persist) return { persisted: false };
|
| 81 |
+
const persisted = (await storage.persisted?.()) || (await storage.persist());
|
| 82 |
+
const estimate = await storage.estimate?.().catch(() => ({}));
|
| 83 |
+
return { persisted: Boolean(persisted), quota: estimate?.quota, usage: estimate?.usage };
|
| 84 |
+
}
|
src/adapters/memory.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* In-memory StorageAdapter.
|
| 3 |
+
*
|
| 4 |
+
* For tests, and for a host that genuinely wants the registry to die with the
|
| 5 |
+
* page. Note that the *weights* still live in Cache Storage and survive — only
|
| 6 |
+
* the registry entry pointing at them is lost, which `ModelStore.verify()` will
|
| 7 |
+
* then never be asked about. Do not use this in production for that reason.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
/** @returns {import("../engine/model-store.js").StorageAdapter} */
|
| 11 |
+
export function memoryStorage(initial = {}) {
|
| 12 |
+
const store = new Map(Object.entries(initial));
|
| 13 |
+
return {
|
| 14 |
+
async get(key) {
|
| 15 |
+
const keys = Array.isArray(key) ? key : [key];
|
| 16 |
+
return Object.fromEntries(keys.filter((k) => store.has(k)).map((k) => [k, store.get(k)]));
|
| 17 |
+
},
|
| 18 |
+
async set(items) {
|
| 19 |
+
for (const [k, v] of Object.entries(items)) store.set(k, v);
|
| 20 |
+
},
|
| 21 |
+
};
|
| 22 |
+
}
|
src/adapters/protocol.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Wire protocol — one adapter's vocabulary, not the engine's interface.
|
| 3 |
+
*
|
| 4 |
+
* A host that holds a `ScheduledEngine` calls its methods directly and never
|
| 5 |
+
* loads this file. This exists for the case where the engine and the caller are
|
| 6 |
+
* in different processes: a WebExtension hosting the engine in its background
|
| 7 |
+
* page, reached from a popup, an options page, or another extension.
|
| 8 |
+
*
|
| 9 |
+
* Two transports, one vocabulary:
|
| 10 |
+
* - `browser.runtime.sendMessage(EXTENSION_ID, req)` for request/response ops.
|
| 11 |
+
* - `browser.runtime.connect(EXTENSION_ID, { name: PORT_NAME })` for streaming.
|
| 12 |
+
*
|
| 13 |
+
* Every message carries `protocol` so a stray message from another sender fails
|
| 14 |
+
* fast instead of being half-interpreted.
|
| 15 |
+
*
|
| 16 |
+
* The engine is one GPU with a hard budget (see AI.md, "The 10 tok/s ceiling"),
|
| 17 |
+
* shared by every caller. So requests carry scheduling metadata, and the engine
|
| 18 |
+
* — not the caller — decides what runs when.
|
| 19 |
+
*/
|
| 20 |
+
|
| 21 |
+
// Re-exported so a wire caller needs one import, not two. The definitions live
|
| 22 |
+
// with the scheduler, which is what actually acts on them.
|
| 23 |
+
export { ENGINE_STATE, PRIORITY, PRIORITY_ORDER } from "../engine/constants.js";
|
| 24 |
+
|
| 25 |
+
export const PROTOCOL = "everything-webgpu/v1";
|
| 26 |
+
export const PORT_NAME = PROTOCOL;
|
| 27 |
+
|
| 28 |
+
/** Request/response ops (runtime.sendMessage). */
|
| 29 |
+
export const OP = {
|
| 30 |
+
STATUS: "status",
|
| 31 |
+
LIST_MODELS: "listModels",
|
| 32 |
+
LOAD: "load",
|
| 33 |
+
UNLOAD: "unload",
|
| 34 |
+
CHAT: "chat",
|
| 35 |
+
/** Many independent prompts at once; the engine fans them across the pool. */
|
| 36 |
+
BATCH: "batch",
|
| 37 |
+
CANCEL: "cancel",
|
| 38 |
+
/** Retune a live engine (currently: `decodeSteps`) without reloading weights. */
|
| 39 |
+
CONFIGURE: "configure",
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
/** Port ops (runtime.connect). */
|
| 43 |
+
export const PORT_OP = {
|
| 44 |
+
CHAT_STREAM: "chat.stream",
|
| 45 |
+
BATCH_STREAM: "batch.stream",
|
| 46 |
+
ABORT: "abort",
|
| 47 |
+
SUBSCRIBE: "subscribe",
|
| 48 |
+
CHUNK: "chunk",
|
| 49 |
+
ITEM: "item",
|
| 50 |
+
DONE: "done",
|
| 51 |
+
ERROR: "error",
|
| 52 |
+
ENGINE_STATE: "engineState",
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
export function request(op, payload = {}) {
|
| 56 |
+
return { protocol: PROTOCOL, op, ...payload };
|
| 57 |
+
}
|
src/adapters/webext.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* WebExtension adapter: the storage passthrough, and the message/port router.
|
| 3 |
+
*
|
| 4 |
+
* This is the whole extension-specific half of what used to be
|
| 5 |
+
* `src/background/background.js`. The engine no longer knows it exists — it is
|
| 6 |
+
* one way to reach a `ScheduledEngine`, and a page reaches the same object by
|
| 7 |
+
* calling its methods.
|
| 8 |
+
*
|
| 9 |
+
* The wire format is unchanged byte-for-byte from `everything-webgpu/v1`, so
|
| 10 |
+
* the popup, the manager page, the e2e devtest page and any external extension
|
| 11 |
+
* keep working with no edits.
|
| 12 |
+
*/
|
| 13 |
+
import { asEngineError } from "../engine/errors.js";
|
| 14 |
+
import { OP, PORT_NAME, PORT_OP, PROTOCOL } from "./protocol.js";
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* `browser.storage.local` already *is* the StorageAdapter shape — `get(key)`
|
| 18 |
+
* and `set(obj)`. The two-method interface was chosen for that reason, so this
|
| 19 |
+
* is a passthrough rather than a translation layer.
|
| 20 |
+
*
|
| 21 |
+
* @returns {import("../engine/model-store.js").StorageAdapter}
|
| 22 |
+
*/
|
| 23 |
+
export function webExtensionStorage() {
|
| 24 |
+
return browser.storage.local;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/**
|
| 28 |
+
* Wires a ScheduledEngine onto this extension's runtime messaging.
|
| 29 |
+
*
|
| 30 |
+
* @param {import("../engine/engine.js").ScheduledEngine} engine
|
| 31 |
+
* @returns {() => void} detach
|
| 32 |
+
*/
|
| 33 |
+
export function attachWebExtensionTransport(engine) {
|
| 34 |
+
const subscribers = new Set();
|
| 35 |
+
|
| 36 |
+
const unsubscribe = engine.subscribe((state) => {
|
| 37 |
+
broadcast({ protocol: PROTOCOL, op: PORT_OP.ENGINE_STATE, state });
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
function broadcast(msg) {
|
| 41 |
+
for (const port of subscribers) {
|
| 42 |
+
try {
|
| 43 |
+
port.postMessage(msg);
|
| 44 |
+
} catch {
|
| 45 |
+
subscribers.delete(port);
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// ------------------------------------------------------------- routing ----
|
| 51 |
+
|
| 52 |
+
async function handle(msg) {
|
| 53 |
+
if (!msg || msg.protocol !== PROTOCOL) {
|
| 54 |
+
throw new Error(`Expected protocol "${PROTOCOL}", got "${msg && msg.protocol}".`);
|
| 55 |
+
}
|
| 56 |
+
switch (msg.op) {
|
| 57 |
+
case OP.STATUS:
|
| 58 |
+
return { ok: true, state: engine.state, webgpu: engine.hasWebGPU };
|
| 59 |
+
case OP.LIST_MODELS:
|
| 60 |
+
return { ok: true, models: await engine.listModels(), state: engine.state };
|
| 61 |
+
case OP.LOAD:
|
| 62 |
+
return { ok: true, state: await engine.load(msg.modelId) };
|
| 63 |
+
case OP.UNLOAD:
|
| 64 |
+
return { ok: true, state: await engine.unload() };
|
| 65 |
+
case OP.CHAT:
|
| 66 |
+
return { ok: true, ...(await engine.complete(msg)) };
|
| 67 |
+
case OP.BATCH:
|
| 68 |
+
return { ok: true, results: await engine.batch(msg) };
|
| 69 |
+
case OP.CANCEL:
|
| 70 |
+
return { ok: true, cancelled: engine.cancel(msg.id ?? msg.session) };
|
| 71 |
+
case OP.CONFIGURE:
|
| 72 |
+
return { ok: true, ...(await engine.configure(msg)) };
|
| 73 |
+
default:
|
| 74 |
+
throw new Error(`Unknown op "${msg.op}".`);
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/**
|
| 79 |
+
* `error` stays a plain string so every existing caller keeps working; `code`
|
| 80 |
+
* and `detail` are added beside it. Drop the string once consumers have
|
| 81 |
+
* moved — the wire protocol is versioned (`everything-webgpu/v1`) and this is
|
| 82 |
+
* an addition, not a break.
|
| 83 |
+
*/
|
| 84 |
+
const respond = (msg) =>
|
| 85 |
+
handle(msg).catch((err) => {
|
| 86 |
+
const e = asEngineError(err);
|
| 87 |
+
return { ok: false, error: e.message, code: e.code, ...(e.detail ? { detail: e.detail } : {}) };
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
/**
|
| 91 |
+
* The allowlist is a property of *this transport*, not of the engine: it
|
| 92 |
+
* answers "which other extension may send me messages", a question that has
|
| 93 |
+
* no meaning for a page that already holds the object.
|
| 94 |
+
*/
|
| 95 |
+
async function denyExternal(sender) {
|
| 96 |
+
const { allowedExternalIds } = await engine.store.getSettings();
|
| 97 |
+
const id = sender?.id;
|
| 98 |
+
if (allowedExternalIds.length === 0 || allowedExternalIds.includes(id)) return null;
|
| 99 |
+
return { ok: false, error: `Extension "${id}" is not on this engine's allowlist.` };
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function attachPort(port) {
|
| 103 |
+
if (port.name !== PORT_NAME) return;
|
| 104 |
+
subscribers.add(port);
|
| 105 |
+
port.onDisconnect.addListener(() => subscribers.delete(port));
|
| 106 |
+
port.postMessage({ protocol: PROTOCOL, op: PORT_OP.ENGINE_STATE, state: engine.state });
|
| 107 |
+
|
| 108 |
+
const send = (msg) => {
|
| 109 |
+
try {
|
| 110 |
+
port.postMessage(msg);
|
| 111 |
+
} catch {
|
| 112 |
+
/* port closed mid-stream */
|
| 113 |
+
}
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
port.onMessage.addListener(async (msg) => {
|
| 117 |
+
const id = msg?.id;
|
| 118 |
+
try {
|
| 119 |
+
if (!msg || msg.protocol !== PROTOCOL) throw new Error(`Expected protocol "${PROTOCOL}".`);
|
| 120 |
+
switch (msg.op) {
|
| 121 |
+
case PORT_OP.SUBSCRIBE:
|
| 122 |
+
return send({ protocol: PROTOCOL, op: PORT_OP.ENGINE_STATE, state: engine.state });
|
| 123 |
+
|
| 124 |
+
case PORT_OP.CHAT_STREAM: {
|
| 125 |
+
const result = await engine.complete(msg, (delta) =>
|
| 126 |
+
send({ protocol: PROTOCOL, op: PORT_OP.CHUNK, id, delta }),
|
| 127 |
+
);
|
| 128 |
+
return send({ protocol: PROTOCOL, op: PORT_OP.DONE, id, ...result });
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
case PORT_OP.BATCH_STREAM: {
|
| 132 |
+
const results = await engine.batch(msg, (item) =>
|
| 133 |
+
send({ protocol: PROTOCOL, op: PORT_OP.ITEM, id, ...item }),
|
| 134 |
+
);
|
| 135 |
+
return send({ protocol: PROTOCOL, op: PORT_OP.DONE, id, results });
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
case PORT_OP.ABORT:
|
| 139 |
+
return void engine.cancel(msg.session ?? id);
|
| 140 |
+
|
| 141 |
+
default:
|
| 142 |
+
return send({ protocol: PROTOCOL, op: msg.op, id, ...(await respond(msg)) });
|
| 143 |
+
}
|
| 144 |
+
} catch (err) {
|
| 145 |
+
const e = asEngineError(err);
|
| 146 |
+
send({
|
| 147 |
+
protocol: PROTOCOL,
|
| 148 |
+
op: PORT_OP.ERROR,
|
| 149 |
+
id,
|
| 150 |
+
error: e.message,
|
| 151 |
+
code: e.code,
|
| 152 |
+
...(e.detail ? { detail: e.detail } : {}),
|
| 153 |
+
});
|
| 154 |
+
}
|
| 155 |
+
});
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
const onMessage = (msg) => respond(msg);
|
| 159 |
+
const onMessageExternal = async (msg, sender) => (await denyExternal(sender)) ?? respond(msg);
|
| 160 |
+
const onConnectExternal = async (port) => {
|
| 161 |
+
const denial = await denyExternal(port.sender);
|
| 162 |
+
if (denial) {
|
| 163 |
+
port.postMessage({ protocol: PROTOCOL, op: PORT_OP.ERROR, error: denial.error });
|
| 164 |
+
port.disconnect();
|
| 165 |
+
return;
|
| 166 |
+
}
|
| 167 |
+
attachPort(port);
|
| 168 |
+
};
|
| 169 |
+
|
| 170 |
+
browser.runtime.onMessage.addListener(onMessage);
|
| 171 |
+
browser.runtime.onMessageExternal.addListener(onMessageExternal);
|
| 172 |
+
browser.runtime.onConnect.addListener(attachPort);
|
| 173 |
+
browser.runtime.onConnectExternal.addListener(onConnectExternal);
|
| 174 |
+
|
| 175 |
+
return () => {
|
| 176 |
+
browser.runtime.onMessage.removeListener(onMessage);
|
| 177 |
+
browser.runtime.onMessageExternal.removeListener(onMessageExternal);
|
| 178 |
+
browser.runtime.onConnect.removeListener(attachPort);
|
| 179 |
+
browser.runtime.onConnectExternal.removeListener(onConnectExternal);
|
| 180 |
+
unsubscribe();
|
| 181 |
+
subscribers.clear();
|
| 182 |
+
};
|
| 183 |
+
}
|
src/background/background.html
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<meta charset="utf-8" />
|
| 3 |
+
<title>Everything WebGPU — engine host</title>
|
| 4 |
+
<script type="module" src="background.js"></script>
|
src/background/background.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* The extension's engine host: build a ScheduledEngine, attach the transport.
|
| 3 |
+
*
|
| 4 |
+
* Everything that used to be in this file is now either engine (`src/engine/`)
|
| 5 |
+
* or transport (`src/adapters/webext.js`). What remains is the two lines that
|
| 6 |
+
* are genuinely specific to *this* host — which storage to use, and which
|
| 7 |
+
* transport to speak — and that is the whole shape a consumer of the library
|
| 8 |
+
* writes for their own app.
|
| 9 |
+
*
|
| 10 |
+
* The engine lives in the MV2 persistent background page because that is a real
|
| 11 |
+
* document on the extension origin, so it has both `navigator.gpu` and the same
|
| 12 |
+
* Cache Storage the manager page writes to. The model stays resident in VRAM
|
| 13 |
+
* across popup opens and across calls from other extensions.
|
| 14 |
+
*/
|
| 15 |
+
import { ScheduledEngine } from "../engine/index.js";
|
| 16 |
+
import { attachWebExtensionTransport, webExtensionStorage } from "../adapters/webext.js";
|
| 17 |
+
|
| 18 |
+
export const engine = new ScheduledEngine({ store: webExtensionStorage() });
|
| 19 |
+
|
| 20 |
+
attachWebExtensionTransport(engine);
|
| 21 |
+
|
| 22 |
+
console.info("[Everything WebGPU] engine host ready; WebGPU present:", engine.hasWebGPU);
|
src/engine/chat.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* `engine.chat.completions.create()` — the WebLLM-shaped facade.
|
| 3 |
+
*
|
| 4 |
+
* The point of this file is that migrating off `@mlc-ai/web-llm` costs one
|
| 5 |
+
* line. WebLLM is OpenAI-shaped, `buildParams()` already forwards `messages`,
|
| 6 |
+
* `temperature`, `max_tokens`, `response_format` and `extra_body` untouched, and
|
| 7 |
+
* everything this engine adds — the scheduler, multi-step decoding, the two
|
| 8 |
+
* build patches, the three model sources — sits behind that same call rather
|
| 9 |
+
* than beside it. So the facade is as thin as it can be: streamed chunks are
|
| 10 |
+
* WebLLM's own objects, passed through untouched, and only the non-streaming
|
| 11 |
+
* response is assembled here.
|
| 12 |
+
*
|
| 13 |
+
* `created` is therefore WebLLM's stable per-response value in milliseconds
|
| 14 |
+
* (OpenAI uses seconds; WebLLM does not, and the drop-in target is WebLLM), and
|
| 15 |
+
* the finish reasons are its own: `"stop" | "length" | "abort" | "tool_calls"`.
|
| 16 |
+
*
|
| 17 |
+
* `complete()` and `batch()` remain the direct API. They expose `cancelled` and
|
| 18 |
+
* `preempted` as first-class outcomes, which the OpenAI shape has no room for —
|
| 19 |
+
* both collapse to `finish_reason: "abort"` here, with the flags carried
|
| 20 |
+
* alongside for a caller that cares which happened.
|
| 21 |
+
*/
|
| 22 |
+
import { ERROR, EngineError } from "./errors.js";
|
| 23 |
+
|
| 24 |
+
/** @param {import("./engine.js").ScheduledEngine} engine */
|
| 25 |
+
export function chatFacade(engine) {
|
| 26 |
+
return {
|
| 27 |
+
completions: {
|
| 28 |
+
/**
|
| 29 |
+
* @param {import("./engine.js").CompletionRequest & {
|
| 30 |
+
* stream?: boolean, stream_options?: {include_usage?: boolean} }} req
|
| 31 |
+
* @returns {Promise<object | AsyncIterable<object>>} a completion, or a
|
| 32 |
+
* stream of chunks when `stream` is set — the same two shapes WebLLM
|
| 33 |
+
* returns, so `await`ing then `for await`ing works unchanged.
|
| 34 |
+
*/
|
| 35 |
+
async create(req) {
|
| 36 |
+
if (!Array.isArray(req?.messages) || req.messages.length === 0) {
|
| 37 |
+
throw new EngineError(ERROR.BAD_REQUEST, "`messages` must be a non-empty array.");
|
| 38 |
+
}
|
| 39 |
+
return req.stream ? streamCompletion(engine, req) : oneCompletion(engine, req);
|
| 40 |
+
},
|
| 41 |
+
},
|
| 42 |
+
};
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const envelope = (engine, id, object) => ({
|
| 46 |
+
id,
|
| 47 |
+
object,
|
| 48 |
+
created: Date.now(),
|
| 49 |
+
model: engine.state.modelId,
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
async function oneCompletion(engine, req) {
|
| 53 |
+
const id = req.id ?? crypto.randomUUID();
|
| 54 |
+
const result = await engine.completeRaw(req);
|
| 55 |
+
return {
|
| 56 |
+
...envelope(engine, id, "chat.completion"),
|
| 57 |
+
choices: [
|
| 58 |
+
{
|
| 59 |
+
index: 0,
|
| 60 |
+
// `content: null` beside tool_calls is WebLLM's own shape, not "".
|
| 61 |
+
message: result.toolCalls
|
| 62 |
+
? { role: "assistant", content: null, tool_calls: result.toolCalls }
|
| 63 |
+
: { role: "assistant", content: result.text },
|
| 64 |
+
finish_reason: result.finishReason ?? "stop",
|
| 65 |
+
logprobs: null,
|
| 66 |
+
},
|
| 67 |
+
],
|
| 68 |
+
usage: result.usage,
|
| 69 |
+
...(result.cancelled ? { cancelled: true } : {}),
|
| 70 |
+
...(result.preempted ? { preempted: true } : {}),
|
| 71 |
+
};
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/**
|
| 75 |
+
* Bridges the raw chunk callback to an async iterator.
|
| 76 |
+
*
|
| 77 |
+
* Chunks pass through **verbatim**. WebLLM's are already compliant OpenAI
|
| 78 |
+
* envelopes carrying `id`, `created`, `model`, `logprobs`,
|
| 79 |
+
* `system_fingerprint` and the terminal `tool_calls`; the previous version
|
| 80 |
+
* rebuilt them from a bare string and lost all of that.
|
| 81 |
+
*
|
| 82 |
+
* Chunks are queued rather than awaited, because the engine must not be made to
|
| 83 |
+
* wait on a slow consumer: a stalled `for await` would hold a pool slot, and a
|
| 84 |
+
* pool slot is the scarce resource the whole scheduler exists to allocate. The
|
| 85 |
+
* queue is bounded in practice by `max_tokens`.
|
| 86 |
+
*/
|
| 87 |
+
async function streamCompletion(engine, req) {
|
| 88 |
+
const id = req.id ?? crypto.randomUUID();
|
| 89 |
+
|
| 90 |
+
/** @type {object[]} */
|
| 91 |
+
const pending = [];
|
| 92 |
+
let wake = null;
|
| 93 |
+
/** @type {{result?: object, error?: unknown} | null} */
|
| 94 |
+
let settled = null;
|
| 95 |
+
const ping = () => {
|
| 96 |
+
const w = wake;
|
| 97 |
+
wake = null;
|
| 98 |
+
w?.();
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
engine
|
| 102 |
+
.completeRaw(req, (chunk) => {
|
| 103 |
+
pending.push(chunk);
|
| 104 |
+
ping();
|
| 105 |
+
})
|
| 106 |
+
.then(
|
| 107 |
+
(result) => {
|
| 108 |
+
settled = { result };
|
| 109 |
+
ping();
|
| 110 |
+
},
|
| 111 |
+
(error) => {
|
| 112 |
+
settled = { error };
|
| 113 |
+
ping();
|
| 114 |
+
},
|
| 115 |
+
);
|
| 116 |
+
|
| 117 |
+
return (async function* () {
|
| 118 |
+
for (;;) {
|
| 119 |
+
// Drain before checking `settled`, so the last chunks are never dropped
|
| 120 |
+
// by a generation that finished while they sat in the queue.
|
| 121 |
+
while (pending.length) yield pending.shift();
|
| 122 |
+
if (settled) break;
|
| 123 |
+
await new Promise((resolve) => (wake = resolve));
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
if (settled.error) throw settled.error;
|
| 127 |
+
const result = settled.result;
|
| 128 |
+
|
| 129 |
+
// Nothing is synthesized on the normal path: WebLLM emits its own terminal
|
| 130 |
+
// finish_reason chunk, and its own usage chunk when `include_usage` is set.
|
| 131 |
+
// An interrupted generation is the exception — the stream simply stops, so
|
| 132 |
+
// a consumer would otherwise never learn why.
|
| 133 |
+
if (result.cancelled || result.preempted) {
|
| 134 |
+
yield {
|
| 135 |
+
...envelope(engine, id, "chat.completion.chunk"),
|
| 136 |
+
choices: [{ index: 0, delta: {}, finish_reason: "abort" }],
|
| 137 |
+
...(result.cancelled ? { cancelled: true } : {}),
|
| 138 |
+
...(result.preempted ? { preempted: true } : {}),
|
| 139 |
+
};
|
| 140 |
+
}
|
| 141 |
+
})();
|
| 142 |
+
}
|
src/engine/constants.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Engine vocabulary — the words the scheduler itself is defined in.
|
| 3 |
+
*
|
| 4 |
+
* Deliberately transport-free. A caller holding a `ScheduledEngine` uses these
|
| 5 |
+
* directly; a caller reaching the engine over a message port gets them
|
| 6 |
+
* re-exported from `src/adapters/protocol.js` alongside the wire ops. Keeping
|
| 7 |
+
* the split visible is the point: the wire protocol is one adapter, not the
|
| 8 |
+
* interface.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Scheduling bands, highest first. `interactive` may preempt a running job that
|
| 13 |
+
* opted into `preemptible`; nothing else ever interrupts work in flight.
|
| 14 |
+
*/
|
| 15 |
+
export const PRIORITY = {
|
| 16 |
+
/** Ghost-text and anything else a human is waiting on keystroke-by-keystroke. */
|
| 17 |
+
INTERACTIVE: "interactive",
|
| 18 |
+
/** Default. Translation, one-shot answers. */
|
| 19 |
+
NORMAL: "normal",
|
| 20 |
+
/** Nobody is watching: reformatting, batch cleanup. Pair with `preemptible`. */
|
| 21 |
+
BACKGROUND: "background",
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
export const PRIORITY_ORDER = [PRIORITY.INTERACTIVE, PRIORITY.NORMAL, PRIORITY.BACKGROUND];
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* How far `unload()` goes.
|
| 28 |
+
*
|
| 29 |
+
* Two levels rather than two verbs because they are the same intention at
|
| 30 |
+
* different depths — "I am done with this model" — and the caller should not
|
| 31 |
+
* have to know that freeing VRAM and freeing disk are different subsystems.
|
| 32 |
+
* Forgetting the model *entirely* is `remove()`, which stays its own verb
|
| 33 |
+
* because it is the one that cannot be undone without re-supplying the source.
|
| 34 |
+
*/
|
| 35 |
+
export const UNLOAD_LEVEL = {
|
| 36 |
+
/** Default. Free VRAM, keep the cached bytes — so reloading costs no network. */
|
| 37 |
+
VRAM: "vram",
|
| 38 |
+
/** Also delete the cached bytes. The registry entry survives, so the model is still known. */
|
| 39 |
+
CACHE: "cache",
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
/**
|
| 43 |
+
* What a queued job asks its engine to do.
|
| 44 |
+
*
|
| 45 |
+
* One pool, not two: priority, session supersession, preemption and
|
| 46 |
+
* one-task-one-engine are identical for both, and the only thing that differs
|
| 47 |
+
* is the call at the far end. A second pool would have duplicated the
|
| 48 |
+
* scheduler to change one line.
|
| 49 |
+
*/
|
| 50 |
+
export const JOB_KIND = {
|
| 51 |
+
CHAT: "chat",
|
| 52 |
+
EMBEDDING: "embedding",
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
/** Engine lifecycle states. */
|
| 56 |
+
export const ENGINE_STATE = {
|
| 57 |
+
IDLE: "idle",
|
| 58 |
+
LOADING: "loading",
|
| 59 |
+
READY: "ready",
|
| 60 |
+
ERROR: "error",
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Out-of-band message kind for configuring an engine worker before WebLLM's own
|
| 65 |
+
* handshake starts. Distinct from every WebLLM `kind`, so the worker can route
|
| 66 |
+
* on it without parsing the rest.
|
| 67 |
+
*/
|
| 68 |
+
export const WORKER_CONFIGURE = "everything-webgpu/configure";
|
src/engine/create.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* `CreateScheduledEngine` — the one-line migration off WebLLM.
|
| 3 |
+
*
|
| 4 |
+
* ```js
|
| 5 |
+
* -import { CreateMLCEngine } from "@mlc-ai/web-llm";
|
| 6 |
+
* -const engine = await CreateMLCEngine(modelId, { initProgressCallback });
|
| 7 |
+
* +import { CreateScheduledEngine } from "everything-webgpu";
|
| 8 |
+
* +const engine = await CreateScheduledEngine(modelId, { initProgressCallback });
|
| 9 |
+
* ```
|
| 10 |
+
*
|
| 11 |
+
* Everything after that line is unchanged: `engine.chat.completions.create()`
|
| 12 |
+
* takes and returns the same shapes. What the swap buys is the scheduler
|
| 13 |
+
* (`session`, `priority`, `task`, `preemptible` on any request), multi-step
|
| 14 |
+
* decoding, the two build-time patches, and models from disk as well as the
|
| 15 |
+
* network.
|
| 16 |
+
*
|
| 17 |
+
* The signature mirrors `CreateMLCEngine`'s so nothing else has to move —
|
| 18 |
+
* including `initProgressCallback`, which is why this is a function rather than
|
| 19 |
+
* documentation telling people to construct a `ScheduledEngine` themselves.
|
| 20 |
+
*/
|
| 21 |
+
import { ScheduledEngine } from "./engine.js";
|
| 22 |
+
import { ERROR, EngineError } from "./errors.js";
|
| 23 |
+
import { ModelStore } from "./model-store.js";
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* @param {string} [modelId] loaded before returning, as `CreateMLCEngine` does.
|
| 27 |
+
* Omit to get an engine that loads later.
|
| 28 |
+
* @param {object} [opts]
|
| 29 |
+
* @param {ModelStore | import("./model-store.js").StorageAdapter} [opts.store]
|
| 30 |
+
* defaults to IndexedDB. Pass one explicitly in a worker or a test.
|
| 31 |
+
* @param {(report: {text: string, progress: number}) => void} [opts.initProgressCallback]
|
| 32 |
+
* @param {string | URL} [opts.workerUrl]
|
| 33 |
+
* @param {() => Promise<object>} [opts.loadWebLLM]
|
| 34 |
+
* @param {boolean} [opts.prebuilt]
|
| 35 |
+
* @returns {Promise<ScheduledEngine>}
|
| 36 |
+
*/
|
| 37 |
+
export async function CreateScheduledEngine(modelId, opts = {}) {
|
| 38 |
+
const { store, initProgressCallback, ...rest } = opts;
|
| 39 |
+
const engine = new ScheduledEngine({ ...rest, store: store ?? (await defaultStore()) });
|
| 40 |
+
if (!modelId) return engine;
|
| 41 |
+
|
| 42 |
+
// Three states, not two. `undefined` means the caller did not choose, and the
|
| 43 |
+
// right default for a call that blocks for minutes on a multi-hundred-MB
|
| 44 |
+
// download is to say so: the first run of the three-line example otherwise
|
| 45 |
+
// looks exactly like a hung process, which is the worst possible first
|
| 46 |
+
// impression and the one thing no amount of documentation undoes. `null` is
|
| 47 |
+
// how you ask for silence.
|
| 48 |
+
const report =
|
| 49 |
+
initProgressCallback === undefined ? defaultProgressReporter(modelId) : initProgressCallback;
|
| 50 |
+
|
| 51 |
+
if (!report) {
|
| 52 |
+
await engine.load(modelId);
|
| 53 |
+
return engine;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// The engine reports progress through its own lifecycle stream; WebLLM
|
| 57 |
+
// reports it through a callback. Forward only the load reports, so a
|
| 58 |
+
// caller's callback fires exactly when WebLLM's would have.
|
| 59 |
+
const stop = engine.subscribe((state) => {
|
| 60 |
+
if (state.progress) report(state.progress);
|
| 61 |
+
});
|
| 62 |
+
try {
|
| 63 |
+
await engine.load(modelId);
|
| 64 |
+
} finally {
|
| 65 |
+
stop();
|
| 66 |
+
report.done?.();
|
| 67 |
+
}
|
| 68 |
+
return engine;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/**
|
| 72 |
+
* What you get when you say nothing: a throttled line on the console.
|
| 73 |
+
*
|
| 74 |
+
* Deliberately not the engine's own behaviour — `new ScheduledEngine()` stays
|
| 75 |
+
* silent, because a library core that logs is wrong inside a worker, an
|
| 76 |
+
* extension background page or a test. This is the getting-started facade, and
|
| 77 |
+
* the person calling it has not yet decided how to render anything.
|
| 78 |
+
*
|
| 79 |
+
* Throttled to one line a second, because WebLLM's callback fires per shard and
|
| 80 |
+
* a 58-shard model would otherwise bury the console. The 100% report is never
|
| 81 |
+
* dropped, so the last line always reads as finished rather than as 97%.
|
| 82 |
+
*/
|
| 83 |
+
function defaultProgressReporter(modelId) {
|
| 84 |
+
if (typeof console === "undefined") return null;
|
| 85 |
+
|
| 86 |
+
const startedAt = Date.now();
|
| 87 |
+
let announced = false;
|
| 88 |
+
let lastAt = 0;
|
| 89 |
+
let sawProgress = false;
|
| 90 |
+
|
| 91 |
+
const emit = ({ text, progress }) => {
|
| 92 |
+
sawProgress = true;
|
| 93 |
+
if (!announced) {
|
| 94 |
+
announced = true;
|
| 95 |
+
console.info(
|
| 96 |
+
`[everything-webgpu] loading ${modelId} — the first run downloads the weights and can take ` +
|
| 97 |
+
"minutes; later loads read the cache and need no network. " +
|
| 98 |
+
"Pass initProgressCallback to render this yourself, or null to silence it.",
|
| 99 |
+
);
|
| 100 |
+
}
|
| 101 |
+
const now = Date.now();
|
| 102 |
+
const finished = (progress ?? 0) >= 1;
|
| 103 |
+
if (!finished && now - lastAt < 1000) return;
|
| 104 |
+
lastAt = now;
|
| 105 |
+
const pct = `${Math.round((progress ?? 0) * 100)}%`.padStart(4);
|
| 106 |
+
console.info(`[everything-webgpu] ${pct} ${text ?? ""}`.trimEnd());
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
// Called once when the load settles, so the common case ends on a line that
|
| 110 |
+
// says it worked rather than trailing off mid-progress.
|
| 111 |
+
emit.done = () => {
|
| 112 |
+
if (!sawProgress) return;
|
| 113 |
+
console.info(
|
| 114 |
+
`[everything-webgpu] ${modelId} ready in ${((Date.now() - startedAt) / 1000).toFixed(1)}s.`,
|
| 115 |
+
);
|
| 116 |
+
};
|
| 117 |
+
return emit;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/**
|
| 121 |
+
* IndexedDB, because a registry that dies with the page would strand the
|
| 122 |
+
* weights: the bytes stay in Cache Storage but nothing remembers they are
|
| 123 |
+
* there. Anywhere IndexedDB is missing, the caller has to say what to use —
|
| 124 |
+
* guessing would produce exactly that stranding, silently.
|
| 125 |
+
*/
|
| 126 |
+
async function defaultStore() {
|
| 127 |
+
if (typeof indexedDB === "undefined") {
|
| 128 |
+
throw new EngineError(
|
| 129 |
+
ERROR.BAD_REQUEST,
|
| 130 |
+
"No IndexedDB in this context, so there is no default store. " +
|
| 131 |
+
"Pass `store` — `memoryStorage()` for a test, or your own StorageAdapter.",
|
| 132 |
+
);
|
| 133 |
+
}
|
| 134 |
+
// Imported lazily so the engine core carries no static dependency on an
|
| 135 |
+
// adapter, and a host that brings its own store never bundles this one.
|
| 136 |
+
const { indexedDBStorage } = await import("../adapters/idb.js");
|
| 137 |
+
return new ModelStore(await indexedDBStorage());
|
| 138 |
+
}
|
src/engine/device.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* "Will this model run here, and if not, why not" — answered before a byte is
|
| 3 |
+
* fetched.
|
| 4 |
+
*
|
| 5 |
+
* Every input is readable up front: WebGPU presence, whether an adapter can be
|
| 6 |
+
* had at all, `shader-f16`, five adapter limits, and the storage quota. So the
|
| 7 |
+
* expensive failure — download a gigabyte, then fail at pipeline creation — is
|
| 8 |
+
* avoidable, and this file exists to avoid it.
|
| 9 |
+
*
|
| 10 |
+
* It is also where this project's hard-won platform knowledge is written down
|
| 11 |
+
* as code rather than prose. Each rule below is a failure that actually
|
| 12 |
+
* happened here: the blocklisted GPU, Firefox's 9-storage-buffer cap that kills
|
| 13 |
+
* paged prefill, `q4f16_1` on a device without f16.
|
| 14 |
+
*
|
| 15 |
+
* What it deliberately does **not** claim: how much VRAM is free. Firefox
|
| 16 |
+
* implements neither `navigator.deviceMemory` nor `performance.memory`, and
|
| 17 |
+
* `storage.estimate()` measures disk quota, not memory. Nothing reports free
|
| 18 |
+
* memory to a web page. So VRAM headroom is reported as a warning with the
|
| 19 |
+
* numbers that *are* known, never as a confident blocker — the same reason the
|
| 20 |
+
* pool probes for a second engine rather than predicting one (AI.md, "The pool
|
| 21 |
+
* grows, it is not sized").
|
| 22 |
+
*/
|
| 23 |
+
|
| 24 |
+
/** WebLLM's ModelType enum; see model-store.js. */
|
| 25 |
+
const MODEL_TYPE_LLM = 0;
|
| 26 |
+
const MODEL_TYPE_VLM = 2;
|
| 27 |
+
|
| 28 |
+
/** Bindings `batch_prefill_paged_kv_kernel` needs; below this, no KV reuse. */
|
| 29 |
+
const PAGED_PREFILL_STORAGE_BUFFERS = 10;
|
| 30 |
+
|
| 31 |
+
/**
|
| 32 |
+
* @typedef {object} DeviceProbe
|
| 33 |
+
* @property {boolean} webgpu
|
| 34 |
+
* @property {string} [reason] why WebGPU is unusable, when it is
|
| 35 |
+
* @property {object} [adapter] vendor / architecture / device, where exposed
|
| 36 |
+
* @property {{shaderF16: boolean}} [features]
|
| 37 |
+
* @property {object} [limits]
|
| 38 |
+
* @property {boolean} [kvReuse] whether cross-turn KV reuse can be used here
|
| 39 |
+
* @property {{quota?: number, usage?: number, persisted?: boolean}} storage
|
| 40 |
+
* @property {number} [deviceMemoryGB] Chrome only; absent is not "small"
|
| 41 |
+
*/
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* Reads what this machine will admit to. Never throws: an unusable device is a
|
| 45 |
+
* result, not an error — the caller's job is to explain it, not to crash.
|
| 46 |
+
*
|
| 47 |
+
* @returns {Promise<DeviceProbe>}
|
| 48 |
+
*/
|
| 49 |
+
export async function probeDevice() {
|
| 50 |
+
const storage = await probeStorage();
|
| 51 |
+
const gpu = globalThis.navigator?.gpu;
|
| 52 |
+
|
| 53 |
+
if (!gpu) {
|
| 54 |
+
return {
|
| 55 |
+
webgpu: false,
|
| 56 |
+
reason:
|
| 57 |
+
"navigator.gpu is absent. On Firefox set dom.webgpu.enabled=true in about:config and restart; " +
|
| 58 |
+
"on any browser, a non-secure context (plain http) also hides it.",
|
| 59 |
+
storage,
|
| 60 |
+
};
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// A `navigator.gpu` that is present but incomplete — a polyfill, a shim, a
|
| 64 |
+
// partially-enabled build — must be a result like any other, not a TypeError
|
| 65 |
+
// out of a function documented never to throw.
|
| 66 |
+
const adapter =
|
| 67 |
+
typeof gpu.requestAdapter === "function" ? await gpu.requestAdapter().catch(() => null) : null;
|
| 68 |
+
if (!adapter) {
|
| 69 |
+
return {
|
| 70 |
+
webgpu: false,
|
| 71 |
+
reason:
|
| 72 |
+
"WebGPU is present but no adapter was granted — usually a blocklisted GPU. On Firefox try " +
|
| 73 |
+
"gfx.webgpu.ignore-blocklist=true in about:config.",
|
| 74 |
+
storage,
|
| 75 |
+
};
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const limits = {};
|
| 79 |
+
for (const key of [
|
| 80 |
+
"maxBufferSize",
|
| 81 |
+
"maxStorageBufferBindingSize",
|
| 82 |
+
"maxStorageBuffersPerShaderStage",
|
| 83 |
+
"maxComputeInvocationsPerWorkgroup",
|
| 84 |
+
"maxComputeWorkgroupStorageSize",
|
| 85 |
+
]) {
|
| 86 |
+
limits[key] = adapter.limits?.[key];
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
return {
|
| 90 |
+
webgpu: true,
|
| 91 |
+
adapter: await adapterInfo(adapter),
|
| 92 |
+
features: { shaderF16: Boolean(adapter.features?.has?.("shader-f16")) },
|
| 93 |
+
limits,
|
| 94 |
+
// Below 10 bindings the paged-prefill pipeline cannot be built, so every
|
| 95 |
+
// turn re-prefills the whole history. See engine-worker.js.
|
| 96 |
+
kvReuse: (limits.maxStorageBuffersPerShaderStage ?? 0) >= PAGED_PREFILL_STORAGE_BUFFERS,
|
| 97 |
+
storage,
|
| 98 |
+
...(globalThis.navigator?.deviceMemory ? { deviceMemoryGB: navigator.deviceMemory } : {}),
|
| 99 |
+
};
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/** `adapter.info` is the current spec; `requestAdapterInfo()` was the old one. */
|
| 103 |
+
/**
|
| 104 |
+
* What the adapter will admit about itself, with blanks dropped.
|
| 105 |
+
*
|
| 106 |
+
* Firefox 154 exposes `adapter.info` but fills every field with `""`, so the
|
| 107 |
+
* naive shape is an object that *looks* populated and renders as "GPU: ". A
|
| 108 |
+
* caller cannot tell that from a real answer without checking each string, so
|
| 109 |
+
* empty fields are omitted and a browser that says nothing yields `{}` — the
|
| 110 |
+
* same thing the no-info path already returns.
|
| 111 |
+
*/
|
| 112 |
+
async function adapterInfo(adapter) {
|
| 113 |
+
const info = adapter.info ?? (await adapter.requestAdapterInfo?.().catch(() => null));
|
| 114 |
+
if (!info) return {};
|
| 115 |
+
const { vendor, architecture, device, description } = info;
|
| 116 |
+
return Object.fromEntries(
|
| 117 |
+
Object.entries({ vendor, architecture, device, description }).filter(([, v]) => v),
|
| 118 |
+
);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
async function probeStorage() {
|
| 122 |
+
const s = globalThis.navigator?.storage;
|
| 123 |
+
if (!s) return {};
|
| 124 |
+
const [estimate, persisted] = await Promise.all([
|
| 125 |
+
s.estimate?.().catch(() => ({})) ?? {},
|
| 126 |
+
s.persisted?.().catch(() => undefined) ?? undefined,
|
| 127 |
+
]);
|
| 128 |
+
return { quota: estimate?.quota, usage: estimate?.usage, persisted };
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
/**
|
| 132 |
+
* Whether a model can run on a probed device.
|
| 133 |
+
*
|
| 134 |
+
* `blockers` mean it will not work; `warnings` mean it will work worse, or
|
| 135 |
+
* might not fit. The split matters: a caller should refuse to start on a
|
| 136 |
+
* blocker and merely inform on a warning, and conflating the two is how you end
|
| 137 |
+
* up either crashing or refusing to run something that would have been fine.
|
| 138 |
+
*
|
| 139 |
+
* @param {{model_id: string, vram_required_MB?: number, sizeBytes?: number}} model
|
| 140 |
+
* @param {DeviceProbe} probe
|
| 141 |
+
* @returns {{ok: boolean, blockers: Array<{code: string, message: string}>,
|
| 142 |
+
* warnings: Array<{code: string, message: string}>}}
|
| 143 |
+
*/
|
| 144 |
+
export function canRun(model, probe) {
|
| 145 |
+
const blockers = [];
|
| 146 |
+
const warnings = [];
|
| 147 |
+
const say = (list, code, message) => list.push({ code, message });
|
| 148 |
+
|
| 149 |
+
if (!probe?.webgpu) {
|
| 150 |
+
say(blockers, "NO_WEBGPU", probe?.reason ?? "WebGPU is unavailable.");
|
| 151 |
+
return { ok: false, blockers, warnings };
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
// The quantisation is encoded in the model id by MLC convention
|
| 155 |
+
// (`…-q4f16_1-MLC`). It is a naming convention, not a manifest field, so this
|
| 156 |
+
// is a heuristic — but a wrong guess only costs a spurious warning, while not
|
| 157 |
+
// checking costs a gigabyte downloaded before a pipeline fails.
|
| 158 |
+
if (/f16/.test(model?.model_id ?? "") && !probe.features?.shaderF16) {
|
| 159 |
+
say(
|
| 160 |
+
blockers,
|
| 161 |
+
"NO_SHADER_F16",
|
| 162 |
+
`"${model.model_id}" is an f16 build and this device has no \`shader-f16\` feature. ` +
|
| 163 |
+
"Choose a q4f32_1 or q0f32 variant — they are larger and slower, but they will run.",
|
| 164 |
+
);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
if (!probe.kvReuse) {
|
| 168 |
+
say(
|
| 169 |
+
warnings,
|
| 170 |
+
"NO_KV_REUSE",
|
| 171 |
+
`This device allows ${probe.limits?.maxStorageBuffersPerShaderStage} storage buffers per shader ` +
|
| 172 |
+
`stage; paged prefill needs ${PAGED_PREFILL_STORAGE_BUFFERS}. Every turn re-prefills the whole ` +
|
| 173 |
+
"history, so long conversations get slow first tokens.",
|
| 174 |
+
);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
const vramMB = model?.vram_required_MB;
|
| 178 |
+
if (vramMB && probe.deviceMemoryGB && vramMB > probe.deviceMemoryGB * 1024 * 0.5) {
|
| 179 |
+
say(
|
| 180 |
+
warnings,
|
| 181 |
+
"TIGHT_MEMORY",
|
| 182 |
+
`The model wants ~${Math.round(vramMB)} MB and this device reports ${probe.deviceMemoryGB} GB of ` +
|
| 183 |
+
"memory. It may load and then run against swap, or fail to load a second engine.",
|
| 184 |
+
);
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
const bytes = model?.sizeBytes ?? (vramMB ? vramMB * 1024 * 1024 : 0);
|
| 188 |
+
const free = probe.storage?.quota != null ? probe.storage.quota - (probe.storage.usage ?? 0) : null;
|
| 189 |
+
if (bytes && free != null && bytes > free) {
|
| 190 |
+
say(
|
| 191 |
+
blockers,
|
| 192 |
+
"INSUFFICIENT_STORAGE",
|
| 193 |
+
`The model needs ~${Math.round(bytes / 1e6)} MB cached and only ~${Math.round(free / 1e6)} MB of ` +
|
| 194 |
+
"quota is free.",
|
| 195 |
+
);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
if (bytes && probe.storage?.persisted === false) {
|
| 199 |
+
say(
|
| 200 |
+
warnings,
|
| 201 |
+
"NOT_PERSISTED",
|
| 202 |
+
"Storage is not persisted, so the browser may evict the model under disk pressure. " +
|
| 203 |
+
"Call ensurePersistent() to ask for it.",
|
| 204 |
+
);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
return { ok: blockers.length === 0, blockers, warnings };
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
/**
|
| 211 |
+
* Rank a model list by what this device can actually run.
|
| 212 |
+
*
|
| 213 |
+
* The prebuilt list spans 239 MB to 31 GB, so "which model should I use" is the
|
| 214 |
+
* first question a developer has and the one they have least basis to answer.
|
| 215 |
+
* Runnable models come first, then fewest warnings; unrunnable ones are kept at
|
| 216 |
+
* the end carrying their reason rather than silently dropped, because "why
|
| 217 |
+
* can't I use that one" is the next question.
|
| 218 |
+
*
|
| 219 |
+
* **`prefer` is a real choice, not a default worth hiding.** Decode here is
|
| 220 |
+
* memory-bandwidth-bound — time per token scales with weight bytes (AI.md,
|
| 221 |
+
* "Why not llama.cpp/Ollama-class"), so the largest model that fits is also the
|
| 222 |
+
* slowest thing that fits. `"quality"` picks the biggest, `"speed"` the
|
| 223 |
+
* smallest. Neither is right for everyone, which is why it is a parameter.
|
| 224 |
+
*
|
| 225 |
+
* Vision models are excluded from a text ranking rather than merely deprioritised:
|
| 226 |
+
* a VLM answers text prompts perfectly well, but at several times the download
|
| 227 |
+
* for no benefit, so recommending one to a caller who did not ask is bad advice.
|
| 228 |
+
*
|
| 229 |
+
* @param {Array<object>} models `model_list` entries or registry records
|
| 230 |
+
* @param {{probe: DeviceProbe, maxVramMB?: number, needsVision?: boolean,
|
| 231 |
+
* prefer?: "quality" | "speed"}} opts
|
| 232 |
+
*/
|
| 233 |
+
export function rankModels(models, { probe, maxVramMB, needsVision = false, prefer = "quality" } = {}) {
|
| 234 |
+
const wanted = needsVision ? MODEL_TYPE_VLM : MODEL_TYPE_LLM;
|
| 235 |
+
return models
|
| 236 |
+
.filter((m) => (m.model_type ?? MODEL_TYPE_LLM) === wanted)
|
| 237 |
+
.filter((m) => !maxVramMB || !m.vram_required_MB || m.vram_required_MB <= maxVramMB)
|
| 238 |
+
.map((m) => ({ model: m, ...canRun(m, probe) }))
|
| 239 |
+
.sort((a, b) => {
|
| 240 |
+
if (a.ok !== b.ok) return a.ok ? -1 : 1;
|
| 241 |
+
if (a.warnings.length !== b.warnings.length) return a.warnings.length - b.warnings.length;
|
| 242 |
+
const sizeA = a.model.vram_required_MB ?? 0;
|
| 243 |
+
const sizeB = b.model.vram_required_MB ?? 0;
|
| 244 |
+
return prefer === "speed" ? sizeA - sizeB : sizeB - sizeA;
|
| 245 |
+
});
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/**
|
| 249 |
+
* Decode throughput is memory bandwidth divided by weight bytes.
|
| 250 |
+
*
|
| 251 |
+
* This project measured the whole chain: decode reaches ~16 GB/s of the M4's
|
| 252 |
+
* ~120 GB/s, and the 1.06 GB build runs 16.6–18.1 tok/s — which is that
|
| 253 |
+
* quotient. So a projection needs one number, the *achieved* bandwidth, and
|
| 254 |
+
* everything else follows from model size.
|
| 255 |
+
*
|
| 256 |
+
* The constant below is that machine's figure and is only a starting point. The
|
| 257 |
+
* moment this engine has decoded anything it knows the real number for the
|
| 258 |
+
* machine it is on, and `ScheduledEngine.estimateSpeed()` switches to it — so
|
| 259 |
+
* this is a cold-start default, not a claim about anyone's hardware.
|
| 260 |
+
*/
|
| 261 |
+
export const REFERENCE_DECODE_BYTES_PER_SECOND = 17e9;
|
| 262 |
+
export const REFERENCE_DEVICE = "M4 MacBook Air (16 GB), Firefox";
|
| 263 |
+
|
| 264 |
+
/**
|
| 265 |
+
* @param {number} modelBytes
|
| 266 |
+
* @param {number} [bytesPerSecond] this machine's measured rate, when known
|
| 267 |
+
* @returns {{tokensPerSecond: number, basis: "measured" | "extrapolated",
|
| 268 |
+
* modelBytes: number, bytesPerSecond: number, reference?: string}}
|
| 269 |
+
*/
|
| 270 |
+
export function projectSpeed(modelBytes, bytesPerSecond) {
|
| 271 |
+
const rate = bytesPerSecond || REFERENCE_DECODE_BYTES_PER_SECOND;
|
| 272 |
+
return {
|
| 273 |
+
tokensPerSecond: modelBytes > 0 ? rate / modelBytes : 0,
|
| 274 |
+
basis: bytesPerSecond ? "measured" : "extrapolated",
|
| 275 |
+
modelBytes,
|
| 276 |
+
bytesPerSecond: rate,
|
| 277 |
+
...(bytesPerSecond ? {} : { reference: REFERENCE_DEVICE }),
|
| 278 |
+
};
|
| 279 |
+
}
|
src/engine/engine-worker.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* One pool slot's engine, in its own JS realm.
|
| 3 |
+
*
|
| 4 |
+
* Several MLCEngines cannot share a realm. Isolated by running the same e2e
|
| 5 |
+
* three ways against Qwen3.5-0.8B:
|
| 6 |
+
*
|
| 7 |
+
* 1 engine, background page -> passes
|
| 8 |
+
* 2 engines, background page -> both load, the first generates
|
| 9 |
+
* fine, the second's first
|
| 10 |
+
* generation fails with
|
| 11 |
+
* "Expected null or instance of
|
| 12 |
+
* VectorInt, got an instance of
|
| 13 |
+
* VectorInt"
|
| 14 |
+
* 2 engines, one worker each -> passes
|
| 15 |
+
*
|
| 16 |
+
* So the trigger is a second engine generating in the same realm, not the pool
|
| 17 |
+
* or the engine count as such. That error is embind reporting a type-registry
|
| 18 |
+
* mismatch, and the bundle does carry module-scoped emscripten state
|
| 19 |
+
* (`var Module`, `var __wasmLib`) shared by every instance, which fits — but
|
| 20 |
+
* the fix rests on the isolation above, not on having traced the registry.
|
| 21 |
+
*
|
| 22 |
+
* Workers are viable because Firefox exposes WebGPU to dedicated workers and
|
| 23 |
+
* the 100 ms completion tick is shared across them, so the concurrency win
|
| 24 |
+
* survives the move off the main thread (measured: 4 workers, 36.3 syncs/s).
|
| 25 |
+
*
|
| 26 |
+
* The realm is also where the decode loop lives, so it is where multi-step
|
| 27 |
+
* decoding is installed — the background page only ever holds a proxy.
|
| 28 |
+
*/
|
| 29 |
+
import { WebWorkerMLCEngineHandler } from "../../vendor/web-llm.js";
|
| 30 |
+
import { WORKER_CONFIGURE } from "./constants.js";
|
| 31 |
+
import { DEFAULT_DECODE_STEPS, installMultiStepDecoding } from "./multistep.js";
|
| 32 |
+
|
| 33 |
+
const handler = new WebWorkerMLCEngineHandler();
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* Running totals for the decode probe (AI.md, "Where the 46 ms goes").
|
| 37 |
+
*
|
| 38 |
+
* `encodeMs` is content-process CPU — command encoding, `createBindGroup`, IPC.
|
| 39 |
+
* `syncMs` is GPU execution plus the wait for Firefox's 100 ms poll tick. They
|
| 40 |
+
* are measured on either side of the burst's single `await`, so together they
|
| 41 |
+
* partition the decode budget with nothing unaccounted for.
|
| 42 |
+
*/
|
| 43 |
+
const stats = {
|
| 44 |
+
bursts: 0,
|
| 45 |
+
tokens: 0,
|
| 46 |
+
encodeMs: 0,
|
| 47 |
+
syncMs: 0,
|
| 48 |
+
dispatches: 0,
|
| 49 |
+
forwardDispatches: 0,
|
| 50 |
+
flushes: 0,
|
| 51 |
+
};
|
| 52 |
+
const resetStats = () => Object.keys(stats).forEach((k) => (stats[k] = 0));
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* Set when a pipeline fails the multi-step contract, and never cleared — a
|
| 56 |
+
* retune resets the measurement window, not the fact that the fast path is off.
|
| 57 |
+
*/
|
| 58 |
+
let multiStepOff = null;
|
| 59 |
+
|
| 60 |
+
// No `kind` field: WebLLM's client handler ignores messages it cannot classify
|
| 61 |
+
// instead of throwing UnknownMessageKindError.
|
| 62 |
+
const postStats = () =>
|
| 63 |
+
postMessage({ ewgpuStats: { ...stats, steps: multiStep.steps, multiStepOff } });
|
| 64 |
+
|
| 65 |
+
const multiStep = installMultiStepDecoding(handler.engine, {
|
| 66 |
+
steps: DEFAULT_DECODE_STEPS,
|
| 67 |
+
onBurst: (b) => {
|
| 68 |
+
stats.bursts += 1;
|
| 69 |
+
stats.tokens += b.tokens;
|
| 70 |
+
stats.encodeMs += b.encodeMs ?? 0;
|
| 71 |
+
stats.syncMs += b.syncMs ?? 0;
|
| 72 |
+
stats.dispatches += b.dispatches ?? 0;
|
| 73 |
+
stats.forwardDispatches += b.forwardDispatches ?? 0;
|
| 74 |
+
stats.flushes += b.flushes ?? 0;
|
| 75 |
+
postStats();
|
| 76 |
+
},
|
| 77 |
+
// The only message that can ever report this. When the fast path is off there
|
| 78 |
+
// are no bursts, so `onBurst` never fires and the decode probe simply stops
|
| 79 |
+
// arriving — indistinguishable, from the host's side, from an idle engine.
|
| 80 |
+
onFallback: ({ missing }) => {
|
| 81 |
+
multiStepOff = missing;
|
| 82 |
+
postStats();
|
| 83 |
+
},
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
/**
|
| 87 |
+
* Force a full re-prefill instead of reusing the KV cache across rounds.
|
| 88 |
+
*
|
| 89 |
+
* Multi-round reuse routes attention through `batch_prefill_paged_kv_kernel`,
|
| 90 |
+
* which binds 10 storage buffers: q, pages, lse, output and six small i32
|
| 91 |
+
* metadata arrays. Firefox's Metal backend caps `maxStorageBuffersPerShaderStage`
|
| 92 |
+
* at 9, so that pipeline fails to build — and an invalid WebGPU pipeline is
|
| 93 |
+
* silent, its dispatches becoming no-ops. The symptom is a second turn that
|
| 94 |
+
* answers the *previous* question behind a garbage prefix that differs run to
|
| 95 |
+
* run, which is uninitialised memory being read.
|
| 96 |
+
*
|
| 97 |
+
* Resetting the conversation first makes WebLLM's own conversation comparison
|
| 98 |
+
* fail, so it re-prefills from scratch through `batch_prefill_ragged_kv_kernel`
|
| 99 |
+
* (9 bindings, works). The cost is re-reading the history each turn; prefill is
|
| 100 |
+
* one sync per chunk, so it is far cheaper than the garbage it replaces.
|
| 101 |
+
*
|
| 102 |
+
* Conditional on the limit, not on the browser: a device that allows 10 keeps
|
| 103 |
+
* the KV cache and the faster path.
|
| 104 |
+
*/
|
| 105 |
+
/** Bindings `batch_prefill_paged_kv_kernel` needs; see tools/audit-wasm.mjs. */
|
| 106 |
+
const PAGED_PREFILL_STORAGE_BUFFERS = 10;
|
| 107 |
+
|
| 108 |
+
/** Whether this device is too tight to build that pipeline. Probed once. */
|
| 109 |
+
const kvReuseUnsafe = (async () => {
|
| 110 |
+
const adapter = await navigator.gpu?.requestAdapter().catch(() => null);
|
| 111 |
+
const limit = adapter?.limits?.maxStorageBuffersPerShaderStage ?? 0;
|
| 112 |
+
const unsafe = limit < PAGED_PREFILL_STORAGE_BUFFERS;
|
| 113 |
+
if (unsafe) {
|
| 114 |
+
console.info(
|
| 115 |
+
`[everything-webgpu] KV reuse disabled: paged prefill needs ` +
|
| 116 |
+
`${PAGED_PREFILL_STORAGE_BUFFERS} storage buffers, this device allows ${limit}`,
|
| 117 |
+
);
|
| 118 |
+
}
|
| 119 |
+
return unsafe;
|
| 120 |
+
})();
|
| 121 |
+
|
| 122 |
+
// Wrapped synchronously at module load: deciding first and wrapping after the
|
| 123 |
+
// await would leave a window where an early prefill slips through unguarded.
|
| 124 |
+
const basePrefill = handler.engine.prefill.bind(handler.engine);
|
| 125 |
+
handler.engine.prefill = async (input, pipeline, chatConfig, genConfig) => {
|
| 126 |
+
if (await kvReuseUnsafe) pipeline.resetChat(/* keepStats= */ true);
|
| 127 |
+
return basePrefill(input, pipeline, chatConfig, genConfig);
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
// The engine is reachable before any model is loaded, so the host can set the
|
| 131 |
+
// step count on the very first message and never has to reload to change it.
|
| 132 |
+
self.onmessage = (msg) => {
|
| 133 |
+
if (msg.data?.kind === WORKER_CONFIGURE) {
|
| 134 |
+
if (msg.data.decodeSteps !== undefined) multiStep.setSteps(msg.data.decodeSteps);
|
| 135 |
+
// Every retune starts a fresh measurement window, so a sweep's points never
|
| 136 |
+
// bleed into each other.
|
| 137 |
+
resetStats();
|
| 138 |
+
postStats();
|
| 139 |
+
return;
|
| 140 |
+
}
|
| 141 |
+
handler.onmessage(msg);
|
| 142 |
+
};
|
src/engine/engine.js
ADDED
|
@@ -0,0 +1,1336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* The engine, with no transport attached.
|
| 3 |
+
*
|
| 4 |
+
* This is what a developer embedding a local model in their own page holds:
|
| 5 |
+
* one object, plain async methods, no message vocabulary. `browser.runtime` is
|
| 6 |
+
* not referenced anywhere in this file or anything it imports — the WebExtension
|
| 7 |
+
* message/port router that used to live here now sits behind
|
| 8 |
+
* `src/adapters/webext.js` and calls these same methods.
|
| 9 |
+
*
|
| 10 |
+
* Everything runs through one EnginePool, which owns priority, cancellation and
|
| 11 |
+
* fan-out. Nothing here decides what runs when.
|
| 12 |
+
*
|
| 13 |
+
* Three things are injected, and they are exactly the three places the host
|
| 14 |
+
* environment leaked into the engine:
|
| 15 |
+
*
|
| 16 |
+
* - `store` a ModelStore over a StorageAdapter, because
|
| 17 |
+
* `browser.storage.local` does not exist in a page.
|
| 18 |
+
* - `workerUrl` defaults to `new URL("./engine-worker.js", import.meta.url)`,
|
| 19 |
+
* which Vite, webpack 5 and esbuild all understand and which
|
| 20 |
+
* also resolves correctly on `moz-extension://`. It replaces
|
| 21 |
+
* `browser.runtime.getURL` rather than sitting beside it.
|
| 22 |
+
* - `loadWebLLM` defaults to a static relative `import()`, so bundlers can see
|
| 23 |
+
* it, and a host with its own patched build can override it.
|
| 24 |
+
* It stays *dynamic* on purpose: the ~6 MB WebLLM bundle is
|
| 25 |
+
* fetched when a model is loaded, not when the page paints.
|
| 26 |
+
*
|
| 27 |
+
* Weights arrive by any of three routes — prebuilt (WebLLM's HuggingFace list),
|
| 28 |
+
* remote (`registerModel` with any base URL you host), or injected
|
| 29 |
+
* (`ingestModelFolder`, no network at all). `load()` resolves across all three;
|
| 30 |
+
* see `model-store.js`. Pass `prebuilt: false` for a build that must never
|
| 31 |
+
* reach the network for a model.
|
| 32 |
+
*/
|
| 33 |
+
import { ENGINE_STATE, JOB_KIND, PRIORITY, UNLOAD_LEVEL, WORKER_CONFIGURE } from "./constants.js";
|
| 34 |
+
import { chatFacade } from "./chat.js";
|
| 35 |
+
import { environmentFacade } from "./environment.js";
|
| 36 |
+
import { canRun, probeDevice, projectSpeed, rankModels } from "./device.js";
|
| 37 |
+
import { ERROR, EngineError, asEngineError } from "./errors.js";
|
| 38 |
+
import { filesFromDataTransfer, filesFromInput, ingestModelFolder } from "./ingest.js";
|
| 39 |
+
import { ModelStore, SOURCE, groupKeysByScope, isInjected, toAppConfig } from "./model-store.js";
|
| 40 |
+
import { clampSteps } from "./multistep.js";
|
| 41 |
+
import { EnginePool } from "./pool.js";
|
| 42 |
+
import { prefetchModel } from "./prefetch.js";
|
| 43 |
+
import { ask, conversation, ghostText } from "./recipes.js";
|
| 44 |
+
import { SOURCE_KIND, classifySource, isDataTransfer, isFileList, nearMatches } from "./sources.js";
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* @typedef {object} CompletionRequest
|
| 48 |
+
* The OpenAI generation fields WebLLM already speaks, plus the scheduling
|
| 49 |
+
* fields that are what this engine adds over calling WebLLM directly.
|
| 50 |
+
* @property {Array<{role: string, content: string}>} messages
|
| 51 |
+
* @property {string} [modelId] load this model first if it is not the live one
|
| 52 |
+
* @property {string} [id] job id; also what `cancel(id)` takes
|
| 53 |
+
* @property {number} [temperature]
|
| 54 |
+
* @property {number} [max_tokens]
|
| 55 |
+
* @property {object} [response_format]
|
| 56 |
+
* @property {object} [extra_body]
|
| 57 |
+
* @property {string} [task] the unit that owns an engine; a whole batch shares one
|
| 58 |
+
* @property {string} [session] a later job with this key supersedes the earlier one
|
| 59 |
+
* @property {"interactive"|"normal"|"background"} [priority]
|
| 60 |
+
* @property {boolean} [preemptible] may be interrupted by an `interactive` job
|
| 61 |
+
*/
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* @typedef {object} CompletionResult
|
| 65 |
+
* @property {string} text
|
| 66 |
+
* @property {object} [usage]
|
| 67 |
+
* @property {"stop"|"length"|"abort"} [finishReason] WebLLM's own values
|
| 68 |
+
* @property {true} [cancelled] superseded or explicitly cancelled
|
| 69 |
+
* @property {true} [preempted] an `interactive` job took the slot; `text` is partial
|
| 70 |
+
*/
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* @typedef {CompletionRequest & {index: number, engineIndex: number,
|
| 74 |
+
* startedAt: number, finishedAt: number, error?: string}} BatchItem
|
| 75 |
+
*/
|
| 76 |
+
|
| 77 |
+
const DEFAULT_WORKER_URL = () => new URL("./engine-worker.js", import.meta.url);
|
| 78 |
+
const DEFAULT_LOAD_WEBLLM = () => import("../../vendor/web-llm.js");
|
| 79 |
+
|
| 80 |
+
/**
|
| 81 |
+
* Turn "the package is not wired into your build" into a sentence that says so.
|
| 82 |
+
*
|
| 83 |
+
* `vendor/web-llm.js` is a **build product**, not a checked-in file, so the two
|
| 84 |
+
* ways to arrive here are both install-shaped rather than runtime-shaped: a git
|
| 85 |
+
* dependency whose `prepare` never ran, or a source checkout where `npm run
|
| 86 |
+
* build` was never run. Left alone this surfaced as a bare
|
| 87 |
+
* `Cannot find module '.../vendor/web-llm.js'` under the code
|
| 88 |
+
* `GENERATION_FAILED` — wrong twice over, since nothing had begun generating
|
| 89 |
+
* and the path named is ours, not the caller's.
|
| 90 |
+
*/
|
| 91 |
+
async function loadBundle(loadWebLLM) {
|
| 92 |
+
try {
|
| 93 |
+
return await loadWebLLM();
|
| 94 |
+
} catch (err) {
|
| 95 |
+
const message = String(err?.message ?? err);
|
| 96 |
+
// Only a resolution failure means "not built". A bundle that throws while
|
| 97 |
+
// *evaluating* is a real crash and must keep its own stack.
|
| 98 |
+
if (!/Cannot find module|Failed to (fetch|resolve)|ERR_MODULE_NOT_FOUND|dynamically imported module/i.test(message)) {
|
| 99 |
+
throw err;
|
| 100 |
+
}
|
| 101 |
+
throw new EngineError(
|
| 102 |
+
ERROR.PACKAGE_INCOMPLETE,
|
| 103 |
+
"everything-webgpu is installed but its WebLLM bundle (vendor/web-llm.js) is missing. " +
|
| 104 |
+
"That file is generated, not checked in — run `npm run build` in the package, " +
|
| 105 |
+
"or reinstall so its `prepare` script runs.",
|
| 106 |
+
{ cause: "vendor-bundle-missing", underlying: message },
|
| 107 |
+
);
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
export class ScheduledEngine {
|
| 112 |
+
#store;
|
| 113 |
+
#workerUrl;
|
| 114 |
+
#loadWebLLM;
|
| 115 |
+
#prebuilt;
|
| 116 |
+
#chat = null;
|
| 117 |
+
#environment = null;
|
| 118 |
+
#probe = null;
|
| 119 |
+
/** This machine's achieved decode bandwidth, learned from the first generation. */
|
| 120 |
+
#decodeBytesPerSecond = 0;
|
| 121 |
+
/** modelId -> weight bytes, for projections. */
|
| 122 |
+
#modelBytes = new Map();
|
| 123 |
+
/**
|
| 124 |
+
* Resident models: modelId -> EnginePool. More than one may be up at once —
|
| 125 |
+
* a text model beside a vision model, say — which is why this is a map and
|
| 126 |
+
* not a field. Each entry holds a full copy of its weights, so residency is
|
| 127 |
+
* budget-gated in `load()`.
|
| 128 |
+
* @type {Map<string, EnginePool>}
|
| 129 |
+
*/
|
| 130 |
+
#pools = new Map();
|
| 131 |
+
/** Which resident model an unaddressed request goes to. */
|
| 132 |
+
#current = null;
|
| 133 |
+
/** In-flight loads, per model, so two models can come up concurrently. */
|
| 134 |
+
#loading = new Map();
|
| 135 |
+
/**
|
| 136 |
+
* Pools that are still loading. `load()` holds the pool in a local until it
|
| 137 |
+
* is ready, which left an in-flight download unreachable — so aborting one
|
| 138 |
+
* had nothing to tear down. See `load({ signal })`.
|
| 139 |
+
* @type {Map<string, EnginePool>}
|
| 140 |
+
*/
|
| 141 |
+
#loadingPools = new Map();
|
| 142 |
+
#listeners = new Set();
|
| 143 |
+
|
| 144 |
+
#state = {
|
| 145 |
+
status: ENGINE_STATE.IDLE,
|
| 146 |
+
modelId: null,
|
| 147 |
+
progress: null,
|
| 148 |
+
error: null,
|
| 149 |
+
pool: { size: 0, busy: 0, queued: 0 },
|
| 150 |
+
/** Model ids with a live pool. `modelId` is whichever of them is current. */
|
| 151 |
+
resident: [],
|
| 152 |
+
/** Latest decode probe from an engine worker; see multistep.js. */
|
| 153 |
+
decode: null,
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
/**
|
| 157 |
+
* @param {object} opts
|
| 158 |
+
* @param {ModelStore | import("./model-store.js").StorageAdapter} opts.store
|
| 159 |
+
* a ModelStore, or a bare StorageAdapter to wrap in one
|
| 160 |
+
* @param {string | URL} [opts.workerUrl]
|
| 161 |
+
* @param {() => Promise<object>} [opts.loadWebLLM]
|
| 162 |
+
* @param {boolean} [opts.prebuilt] expose WebLLM's 163 HuggingFace-hosted
|
| 163 |
+
* models, downloaded on first load. Default true. Set false for an
|
| 164 |
+
* offline-only build: `load()` then resolves registered models and nothing
|
| 165 |
+
* else, and an unknown id fails before the WebLLM bundle is even fetched.
|
| 166 |
+
*/
|
| 167 |
+
constructor({ store, workerUrl, loadWebLLM, prebuilt = true } = {}) {
|
| 168 |
+
if (!store) {
|
| 169 |
+
throw new EngineError(ERROR.BAD_REQUEST, "ScheduledEngine needs a `store` (ModelStore or StorageAdapter).");
|
| 170 |
+
}
|
| 171 |
+
this.#store = store instanceof ModelStore ? store : new ModelStore(store);
|
| 172 |
+
this.#workerUrl = workerUrl ?? DEFAULT_WORKER_URL();
|
| 173 |
+
// Wrapped once here rather than at each of the seven `#loadWebLLM()` call
|
| 174 |
+
// sites: a missing bundle is the same failure whichever verb reached it
|
| 175 |
+
// first, and a site added later gets the good error for free.
|
| 176 |
+
const load = loadWebLLM ?? DEFAULT_LOAD_WEBLLM;
|
| 177 |
+
this.#loadWebLLM = () => loadBundle(load);
|
| 178 |
+
this.#prebuilt = prebuilt;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
/** The ModelStore, so a host can drive the registry without a second handle. */
|
| 182 |
+
get store() {
|
| 183 |
+
return this.#store;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
/**
|
| 187 |
+
* `chat.completions.create()`, the WebLLM/OpenAI shape. See `chat.js`.
|
| 188 |
+
*
|
| 189 |
+
* Built once and cached: callers hold on to `engine.chat.completions` the way
|
| 190 |
+
* they did with WebLLM, and a fresh object each access would break that.
|
| 191 |
+
*/
|
| 192 |
+
get chat() {
|
| 193 |
+
this.#chat ??= chatFacade(this);
|
| 194 |
+
return this.#chat;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/**
|
| 198 |
+
* `environment()` — the read-only report, with `environment.measure()` on it.
|
| 199 |
+
*
|
| 200 |
+
* Cached like `chat` so a caller can hold on to it. Writes are `configure()`;
|
| 201 |
+
* see `environment.js` for why those are separate verbs.
|
| 202 |
+
*/
|
| 203 |
+
get environment() {
|
| 204 |
+
this.#environment ??= environmentFacade(this);
|
| 205 |
+
return this.#environment;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
get state() {
|
| 209 |
+
return { ...this.#state };
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
get hasWebGPU() {
|
| 213 |
+
return Boolean(globalThis.navigator?.gpu);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
/**
|
| 217 |
+
* @param {(state: object) => void} listener called immediately, then on change
|
| 218 |
+
* @returns {() => void} unsubscribe
|
| 219 |
+
*/
|
| 220 |
+
subscribe(listener) {
|
| 221 |
+
this.#listeners.add(listener);
|
| 222 |
+
listener(this.state);
|
| 223 |
+
return () => this.#listeners.delete(listener);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
/** The current model's pool, or null. */
|
| 227 |
+
get #pool() {
|
| 228 |
+
return this.#current ? (this.#pools.get(this.#current) ?? null) : null;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
/** Model ids with a live pool right now. */
|
| 232 |
+
get resident() {
|
| 233 |
+
return [...this.#pools.keys()];
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
/**
|
| 237 |
+
* Choose which resident model unaddressed requests go to.
|
| 238 |
+
*
|
| 239 |
+
* Distinct from `load()` on purpose: this is free and instant, because the
|
| 240 |
+
* weights are already up. `load()` is what costs.
|
| 241 |
+
*/
|
| 242 |
+
use(modelId) {
|
| 243 |
+
if (!this.#pools.has(modelId)) {
|
| 244 |
+
throw new EngineError(
|
| 245 |
+
ERROR.UNKNOWN_MODEL,
|
| 246 |
+
`"${modelId}" is not resident. Resident: ${this.resident.join(", ") || "none"}. Call load() first.`,
|
| 247 |
+
{ modelId, resident: this.resident },
|
| 248 |
+
);
|
| 249 |
+
}
|
| 250 |
+
this.#current = modelId;
|
| 251 |
+
this.#syncState();
|
| 252 |
+
return this.state;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
/** Registered models only — cheap, no bundle load. */
|
| 256 |
+
listModels() {
|
| 257 |
+
return this.#store.list();
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
/**
|
| 261 |
+
* Everything `load()` would accept, normalised: registered models first, then
|
| 262 |
+
* WebLLM's prebuilt list.
|
| 263 |
+
*
|
| 264 |
+
* Costs a WebLLM bundle fetch when `prebuilt` is on, because the list lives
|
| 265 |
+
* inside it. `listModels()` is the cheap call if you only care about what this
|
| 266 |
+
* app registered.
|
| 267 |
+
*
|
| 268 |
+
* @returns {Promise<Array<{modelId: string, source: string, model: string,
|
| 269 |
+
* contextWindow?: number, vramRequiredMB?: number, sizeBytes?: number}>>}
|
| 270 |
+
*/
|
| 271 |
+
async listAvailableModels() {
|
| 272 |
+
const registered = await this.#store.list();
|
| 273 |
+
const own = registered.map((r) => ({
|
| 274 |
+
modelId: r.model_id,
|
| 275 |
+
source: r.source ?? SOURCE.REMOTE,
|
| 276 |
+
model: r.model,
|
| 277 |
+
contextWindow: r.overrides?.context_window_size,
|
| 278 |
+
vramRequiredMB: r.vram_required_MB,
|
| 279 |
+
sizeBytes: r.sizeBytes,
|
| 280 |
+
}));
|
| 281 |
+
if (!this.#prebuilt) return own;
|
| 282 |
+
|
| 283 |
+
const { prebuiltAppConfig, functionCallingModelIds } = await this.#loadWebLLM();
|
| 284 |
+
const toolCalling = new Set(functionCallingModelIds ?? []);
|
| 285 |
+
const owned = new Set(own.map((m) => m.modelId));
|
| 286 |
+
const rest = prebuiltAppConfig.model_list
|
| 287 |
+
.filter((e) => !owned.has(e.model_id))
|
| 288 |
+
.map((e) => ({
|
| 289 |
+
modelId: e.model_id,
|
| 290 |
+
source: SOURCE.PREBUILT,
|
| 291 |
+
model: e.model,
|
| 292 |
+
contextWindow: e.overrides?.context_window_size,
|
| 293 |
+
vramRequiredMB: e.vram_required_MB,
|
| 294 |
+
// WebLLM ships the list; it is not derivable from the id.
|
| 295 |
+
toolCalling: toolCalling.has(e.model_id),
|
| 296 |
+
}));
|
| 297 |
+
return [...own, ...rest];
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
/**
|
| 301 |
+
* What this machine will admit to: WebGPU, adapter, `shader-f16`, the five
|
| 302 |
+
* limits that matter, storage quota. Cached — hardware does not change
|
| 303 |
+
* mid-session, and `requestAdapter()` is not free.
|
| 304 |
+
* @returns {Promise<import("./device.js").DeviceProbe>}
|
| 305 |
+
*/
|
| 306 |
+
probe() {
|
| 307 |
+
this.#probe ??= probeDevice();
|
| 308 |
+
return this.#probe;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
/**
|
| 312 |
+
* Whether a model will run here, before anything is downloaded.
|
| 313 |
+
* @param {string} modelId
|
| 314 |
+
* @returns {Promise<{ok: boolean, blockers: Array<object>, warnings: Array<object>}>}
|
| 315 |
+
*/
|
| 316 |
+
async canRun(modelId) {
|
| 317 |
+
const [probe, available] = await Promise.all([this.probe(), this.listAvailableModels()]);
|
| 318 |
+
const found = available.find((m) => m.modelId === modelId);
|
| 319 |
+
if (!found) {
|
| 320 |
+
throw new EngineError(ERROR.UNKNOWN_MODEL, `Model "${modelId}" is not one this engine can load.`, {
|
| 321 |
+
modelId,
|
| 322 |
+
});
|
| 323 |
+
}
|
| 324 |
+
return canRun(
|
| 325 |
+
{ model_id: found.modelId, vram_required_MB: found.vramRequiredMB, sizeBytes: found.sizeBytes },
|
| 326 |
+
probe,
|
| 327 |
+
);
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
/**
|
| 331 |
+
* Which models this device should actually be asked to run, best first.
|
| 332 |
+
*
|
| 333 |
+
* The prebuilt list spans 239 MB to 31 GB; this is the answer to the first
|
| 334 |
+
* question a developer has and the one they have least basis to answer.
|
| 335 |
+
*
|
| 336 |
+
* @param {{maxVramMB?: number, needsVision?: boolean, needsToolCalling?: boolean,
|
| 337 |
+
* prefer?: "quality" | "speed"}} [opts]
|
| 338 |
+
*/
|
| 339 |
+
async recommendModels({ needsToolCalling = false, ...opts } = {}) {
|
| 340 |
+
const [probe, appConfig] = await Promise.all([this.probe(), this.#appConfig()]);
|
| 341 |
+
let list = appConfig.model_list;
|
| 342 |
+
if (needsToolCalling) {
|
| 343 |
+
const { functionCallingModelIds } = await this.#loadWebLLM();
|
| 344 |
+
const ids = new Set(functionCallingModelIds ?? []);
|
| 345 |
+
list = list.filter((m) => ids.has(m.model_id));
|
| 346 |
+
}
|
| 347 |
+
return rankModels(list, { probe, ...opts });
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
/**
|
| 351 |
+
* Is this model's data on disk, so a load would need no network?
|
| 352 |
+
*
|
| 353 |
+
* Routes by who knows the keys. We wrote an injected model's artifacts and
|
| 354 |
+
* hold the manifest, so `verify()` answers exactly — including a `"partial"`
|
| 355 |
+
* verdict WebLLM cannot give. Everything else was fetched by WebLLM, which
|
| 356 |
+
* derives the keys as its loader did, so `hasModelInCache` is the answer.
|
| 357 |
+
*
|
| 358 |
+
* @returns {Promise<"cached" | "partial" | "absent">}
|
| 359 |
+
*/
|
| 360 |
+
async cacheState(modelId) {
|
| 361 |
+
const record = await this.#store.get(modelId);
|
| 362 |
+
if (isInjected(record)) {
|
| 363 |
+
const { ok, missing } = await this.#store.verify(record);
|
| 364 |
+
if (ok) return "cached";
|
| 365 |
+
const total = Object.values(groupKeysByScope(record)).flat().length;
|
| 366 |
+
return missing.length >= total ? "absent" : "partial";
|
| 367 |
+
}
|
| 368 |
+
const { hasModelInCache } = await this.#loadWebLLM();
|
| 369 |
+
return (await hasModelInCache(modelId, await this.#appConfig())) ? "cached" : "absent";
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
/**
|
| 373 |
+
* Download a model into the cache **without building an engine**.
|
| 374 |
+
*
|
| 375 |
+
* For warming during onboarding: the bytes land while the user is still
|
| 376 |
+
* reading, and the later `load()` is a cache read. WebLLM cannot express this
|
| 377 |
+
* — `reload()` instantiates the wasm and needs a GPU before it fetches a
|
| 378 |
+
* single shard — so this is ours. See `prefetch.js` for the URL-derivation
|
| 379 |
+
* risk and the oracle that closes it.
|
| 380 |
+
*
|
| 381 |
+
* Needs no WebGPU at all, which is the other half of the point: an app can
|
| 382 |
+
* warm the cache on a machine it has not yet decided can run the model.
|
| 383 |
+
*
|
| 384 |
+
* @param {string} modelId
|
| 385 |
+
* @param {{signal?: AbortSignal, onProgress?: Function}} [opts]
|
| 386 |
+
*/
|
| 387 |
+
async prefetch(modelId, { signal, onProgress } = {}) {
|
| 388 |
+
// Before anything, including the already-cached shortcut: a caller who
|
| 389 |
+
// aborted wants an abort, not a success they have to inspect to distrust.
|
| 390 |
+
if (signal?.aborted) {
|
| 391 |
+
throw new EngineError(ERROR.ABORTED, `Prefetch of "${modelId}" was aborted before it began.`, {
|
| 392 |
+
modelId,
|
| 393 |
+
});
|
| 394 |
+
}
|
| 395 |
+
const record = await this.#store.get(modelId);
|
| 396 |
+
|
| 397 |
+
// An injected model's bytes were written before it was ever registered;
|
| 398 |
+
// there is no URL to fetch from and nothing to do.
|
| 399 |
+
if (isInjected(record)) {
|
| 400 |
+
const { ok } = await this.#store.verify(record);
|
| 401 |
+
if (ok) return { modelId, files: 0, bytes: 0, alreadyCached: true };
|
| 402 |
+
throw new EngineError(
|
| 403 |
+
ERROR.CACHE_INCOMPLETE,
|
| 404 |
+
`"${modelId}" was injected from a folder, so it cannot be re-fetched. Re-register the folder.`,
|
| 405 |
+
{ modelId },
|
| 406 |
+
);
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
const appConfig = await this.#appConfig();
|
| 410 |
+
const entry = appConfig.model_list.find((m) => m.model_id === modelId);
|
| 411 |
+
if (!entry) {
|
| 412 |
+
const near = nearMatches(modelId, appConfig.model_list.map((m) => m.model_id));
|
| 413 |
+
throw new EngineError(
|
| 414 |
+
ERROR.UNKNOWN_MODEL,
|
| 415 |
+
`Cannot prefetch "${modelId}": it is neither registered nor prebuilt. ` +
|
| 416 |
+
(near.length ? `Did you mean ${near.map((id) => `"${id}"`).join(", ")}? ` : ""),
|
| 417 |
+
{ modelId, ...(near.length ? { near } : {}) },
|
| 418 |
+
);
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
const { hasModelInCache } = await this.#loadWebLLM();
|
| 422 |
+
if (await hasModelInCache(modelId, appConfig).catch(() => false)) {
|
| 423 |
+
return { modelId, files: 0, bytes: 0, alreadyCached: true };
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
const result = await prefetchModel({ modelId, record: entry, signal, onProgress });
|
| 427 |
+
|
| 428 |
+
// The oracle. `hasModelInCache` derives its keys through the very function
|
| 429 |
+
// `prefetch.js` mirrors, so this is the one check that can tell a correct
|
| 430 |
+
// prefetch from one that wrote a cache the loader will never read. Without
|
| 431 |
+
// it, a wrong key looks exactly like success and costs the user a second
|
| 432 |
+
// download of the whole model.
|
| 433 |
+
if (!(await hasModelInCache(modelId, appConfig).catch(() => false))) {
|
| 434 |
+
throw new EngineError(
|
| 435 |
+
ERROR.GENERATION_FAILED,
|
| 436 |
+
`Prefetch of "${modelId}" wrote ${result.files} artifacts, but WebLLM still reports the ` +
|
| 437 |
+
"model as uncached — the derived cache keys do not match the ones its loader looks for. " +
|
| 438 |
+
"Treat the cache as cold; load() will re-download. This is what a change to WebLLM's " +
|
| 439 |
+
"URL scheme looks like from here.",
|
| 440 |
+
{ modelId, files: result.files },
|
| 441 |
+
);
|
| 442 |
+
}
|
| 443 |
+
return result;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
/**
|
| 447 |
+
* Free a model's bytes and **keep the registry entry**, so it stays a model
|
| 448 |
+
* this engine knows how to get again — the distinction from
|
| 449 |
+
* `store.remove()`, which forgets the URL a remote model would need.
|
| 450 |
+
*
|
| 451 |
+
* Delegates for remote and prebuilt models: `deleteModelAllInfoInCache` is
|
| 452 |
+
* WebLLM's, covers tensors + wasm + config, and is maintained upstream.
|
| 453 |
+
*/
|
| 454 |
+
async evict(modelId) {
|
| 455 |
+
if (this.#pools.has(modelId)) await this.unload(modelId);
|
| 456 |
+
return this.#evictBytes(modelId);
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
/**
|
| 460 |
+
* The byte-freeing half of `evict()`, with no pool handling.
|
| 461 |
+
*
|
| 462 |
+
* Split out so `unload(id, "cache")` can reach it without going back through
|
| 463 |
+
* `evict()` → `unload()`, which would re-enter this class for a pool that has
|
| 464 |
+
* just been torn down.
|
| 465 |
+
*/
|
| 466 |
+
async #evictBytes(modelId) {
|
| 467 |
+
const record = await this.#store.get(modelId);
|
| 468 |
+
if (isInjected(record)) return this.#store.evictInjected(modelId);
|
| 469 |
+
|
| 470 |
+
const { deleteModelAllInfoInCache } = await this.#loadWebLLM();
|
| 471 |
+
await deleteModelAllInfoInCache(modelId, await this.#appConfig());
|
| 472 |
+
return { freedKeys: null };
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
/** The merged model list WebLLM's cache helpers key off. */
|
| 476 |
+
async #appConfig() {
|
| 477 |
+
const registered = await this.#store.list();
|
| 478 |
+
const prebuilt = this.#prebuilt ? (await this.#loadWebLLM()).prebuiltAppConfig : null;
|
| 479 |
+
return toAppConfig(registered, prebuilt);
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
/**
|
| 483 |
+
* Forget a model entirely: free its bytes **and** drop the registry entry.
|
| 484 |
+
*
|
| 485 |
+
* `evict()` first, because that is what knows how to reach the bytes for each
|
| 486 |
+
* source — and it has to happen before the record is deleted, since for a
|
| 487 |
+
* remote model the record holds the only URL those bytes can be derived from.
|
| 488 |
+
* Deleting the entry first would strand them in Cache Storage permanently.
|
| 489 |
+
*/
|
| 490 |
+
async remove(modelId) {
|
| 491 |
+
const freed = await this.evict(modelId);
|
| 492 |
+
await this.#store.remove(modelId);
|
| 493 |
+
return freed;
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
/**
|
| 497 |
+
* Projected decode throughput for a model, in tokens per second.
|
| 498 |
+
*
|
| 499 |
+
* `basis: "measured"` once anything has actually decoded on this machine —
|
| 500 |
+
* the engine then knows its own achieved bandwidth and every projection is
|
| 501 |
+
* device-specific. Before that, `basis: "extrapolated"` from a reference
|
| 502 |
+
* machine, which is a starting point and says so.
|
| 503 |
+
*
|
| 504 |
+
* Decode is memory-bandwidth-bound, so this is close to the whole story:
|
| 505 |
+
* time per token scales with weight bytes and little else.
|
| 506 |
+
*
|
| 507 |
+
* @param {string} [modelId] defaults to the current model
|
| 508 |
+
*/
|
| 509 |
+
async estimateSpeed(modelId = this.#current) {
|
| 510 |
+
let bytes = modelId ? this.#modelBytes.get(modelId) : 0;
|
| 511 |
+
if (!bytes && modelId) {
|
| 512 |
+
const found = (await this.listAvailableModels()).find((m) => m.modelId === modelId);
|
| 513 |
+
bytes = found?.sizeBytes ?? (found?.vramRequiredMB ?? 0) * 1024 * 1024;
|
| 514 |
+
}
|
| 515 |
+
return { modelId, ...projectSpeed(bytes, this.#decodeBytesPerSecond) };
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
/**
|
| 519 |
+
* What is actually switched on right now, as opposed to what the device could
|
| 520 |
+
* support.
|
| 521 |
+
*
|
| 522 |
+
* The distinction matters for KV reuse in particular: `probe().kvReuse` is a
|
| 523 |
+
* device capability, but the decision is taken inside the engine worker,
|
| 524 |
+
* which is the authority. A caller debugging "why is my second turn slow"
|
| 525 |
+
* needs the decision, not the capability.
|
| 526 |
+
*/
|
| 527 |
+
async features() {
|
| 528 |
+
const probe = await this.probe();
|
| 529 |
+
const settings = await this.#store.getSettings();
|
| 530 |
+
const pool = this.#pool?.status();
|
| 531 |
+
// The configured K, which is not the same question as whether bursts run.
|
| 532 |
+
// `decodeSteps` stays the knob's value even when the fast path is off: it is
|
| 533 |
+
// what `configure()` would be changing, and reporting 1 here would make
|
| 534 |
+
// `environment()` advise `configure({ decodeSteps: 15 })` for a fault that
|
| 535 |
+
// setting cannot fix.
|
| 536 |
+
const decodeSteps = this.#state.decode?.steps ?? settings.decodeSteps;
|
| 537 |
+
// Non-empty means the live pipeline failed the contract and decoding was
|
| 538 |
+
// routed to stock single-step. The worker keeps posting the *configured* K
|
| 539 |
+
// either way, so `steps > 1` cannot answer this — asking it alone reported
|
| 540 |
+
// a disabled fast path as healthy, which is the one case this call exists for.
|
| 541 |
+
const multiStepOff = this.#state.decode?.multiStepOff;
|
| 542 |
+
return {
|
| 543 |
+
kvReuse: Boolean(probe.kvReuse),
|
| 544 |
+
shaderF16: Boolean(probe.features?.shaderF16),
|
| 545 |
+
decodeSteps,
|
| 546 |
+
multiStepDecoding: decodeSteps > 1 && !multiStepOff?.length,
|
| 547 |
+
/** The missing tvmjs internals, or null when the fast path is intact. */
|
| 548 |
+
multiStepOff: multiStepOff?.length ? multiStepOff : null,
|
| 549 |
+
engines: pool?.size ?? 0,
|
| 550 |
+
maxEngines: pool?.maxSize ?? settings.engineCount,
|
| 551 |
+
resident: this.resident,
|
| 552 |
+
// dispatches per flush: >1 means consecutive kernel launches are sharing a
|
| 553 |
+
// compute pass, i.e. the build-time batching patch is in effect. Observed,
|
| 554 |
+
// not asserted — a build with NO_PASS_MERGE=1 reports ~1.
|
| 555 |
+
computePassBatching: this.#state.decode?.flushes
|
| 556 |
+
? this.#state.decode.dispatches / this.#state.decode.flushes
|
| 557 |
+
: null,
|
| 558 |
+
decode: this.#state.decode ?? null,
|
| 559 |
+
};
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
/**
|
| 563 |
+
* Register a model. Two shapes, one call, and the difference is only where
|
| 564 |
+
* the bytes come from:
|
| 565 |
+
*
|
| 566 |
+
* ```js
|
| 567 |
+
* // fetched from a base URL you host — an HF repo, a CDN, your own origin
|
| 568 |
+
* await engine.registerModel({
|
| 569 |
+
* modelId: "my-model",
|
| 570 |
+
* model: "/models/my-model/",
|
| 571 |
+
* modelLib: "/models/my-model/my-model-webgpu.wasm",
|
| 572 |
+
* });
|
| 573 |
+
*
|
| 574 |
+
* // read off disk. No network connection at any point, ever.
|
| 575 |
+
* await engine.registerModel({ modelId: "my-model", files: entries });
|
| 576 |
+
* ```
|
| 577 |
+
*
|
| 578 |
+
* Both end up as one `model_list` entry that WebLLM's own loader resolves the
|
| 579 |
+
* same way — the local one only differs in that its base URL is minted on
|
| 580 |
+
* `.invalid` and its cache is populated before the loader ever looks.
|
| 581 |
+
*
|
| 582 |
+
* That origin is the *mechanism* of the offline guarantee, not a marker of
|
| 583 |
+
* it: `.invalid` is reserved by RFC 6761 and can never resolve, so there is
|
| 584 |
+
* no code path — no bug, no eviction, no future refactor — by which a local
|
| 585 |
+
* model reaches the network. It fails with a DNS error instead.
|
| 586 |
+
*
|
| 587 |
+
* `files` is `{ path, file }[]`; `filesFromDataTransfer` and
|
| 588 |
+
* `filesFromInput` build it from a drop event or a directory picker.
|
| 589 |
+
*/
|
| 590 |
+
async registerModel(spec) {
|
| 591 |
+
if (spec?.files) {
|
| 592 |
+
if (spec.model || spec.modelLib) {
|
| 593 |
+
throw new EngineError(
|
| 594 |
+
ERROR.BAD_REQUEST,
|
| 595 |
+
"registerModel takes either `files` (local, never fetched) or `model`/`modelLib` (a base URL to fetch), not both.",
|
| 596 |
+
);
|
| 597 |
+
}
|
| 598 |
+
return ingestModelFolder(spec.files, {
|
| 599 |
+
store: this.#store,
|
| 600 |
+
modelId: spec.modelId,
|
| 601 |
+
modelType: spec.modelType,
|
| 602 |
+
onProgress: spec.onProgress,
|
| 603 |
+
});
|
| 604 |
+
}
|
| 605 |
+
return this.#store.registerModel(spec);
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
// ---------------------------------------------------------------- engine ---
|
| 609 |
+
|
| 610 |
+
#setState(patch) {
|
| 611 |
+
Object.assign(this.#state, patch);
|
| 612 |
+
const snapshot = this.state;
|
| 613 |
+
for (const listener of [...this.#listeners]) {
|
| 614 |
+
try {
|
| 615 |
+
listener(snapshot);
|
| 616 |
+
} catch {
|
| 617 |
+
/* a subscriber that throws must not stall the engine */
|
| 618 |
+
}
|
| 619 |
+
}
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
#assertWebGPU() {
|
| 623 |
+
if (!this.hasWebGPU) {
|
| 624 |
+
throw new EngineError(
|
| 625 |
+
ERROR.NO_WEBGPU,
|
| 626 |
+
"WebGPU is unavailable in this context. On macOS Firefox, set dom.webgpu.enabled=true " +
|
| 627 |
+
"(and gfx.webgpu.ignore-blocklist=true if your GPU is blocklisted) in about:config, then restart Firefox.",
|
| 628 |
+
);
|
| 629 |
+
}
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
/**
|
| 633 |
+
* Bring a model up, whatever form you have it in.
|
| 634 |
+
*
|
| 635 |
+
* One entry point for all three routes, because from a caller's side "load a
|
| 636 |
+
* model" is one intention and having to know which of `load`,
|
| 637 |
+
* `registerModel` and `ingestModelFolder` to reach for is a decision the
|
| 638 |
+
* library can make for them:
|
| 639 |
+
*
|
| 640 |
+
* ```js
|
| 641 |
+
* load("Llama-3.2-1B-Instruct-q4f16_1-MLC") // prebuilt or registered id
|
| 642 |
+
* load("https://huggingface.co/mlc-ai/Foo", { modelLib }) // a URL you host
|
| 643 |
+
* load({ model, modelLib }) // the same, explicit
|
| 644 |
+
* load({ files }) | load(fileList) | load(dataTransfer) // a folder, no network
|
| 645 |
+
* ```
|
| 646 |
+
*
|
| 647 |
+
* `registerModel` and `ingestModelFolder` remain, unchanged, as the low-level
|
| 648 |
+
* primitives — this composes them rather than replacing them.
|
| 649 |
+
*
|
| 650 |
+
* **A URL always needs `modelLib`.** It is not guessed; see `sources.js` for
|
| 651 |
+
* the measurement behind that. **`defer: true`** registers the source and
|
| 652 |
+
* stops there, returning the record instead of the state — the manager's
|
| 653 |
+
* drop-now-load-later flow.
|
| 654 |
+
*
|
| 655 |
+
* Additive residency: a model already resident stays resident, so switching
|
| 656 |
+
* back to it costs nothing. That is only safe while the weights fit, so
|
| 657 |
+
* `keepResident: false` (the default) unloads whatever else is up first —
|
| 658 |
+
* the old single-model behaviour, and the safe one on a 16 GB machine.
|
| 659 |
+
* Pass `keepResident: true` to hold both, having checked the budget yourself
|
| 660 |
+
* with `canRun()`.
|
| 661 |
+
*
|
| 662 |
+
* @param {string | object} src an id, a URL, `{model, modelLib}`, or a folder
|
| 663 |
+
* @param {{keepResident?: boolean, signal?: AbortSignal, defer?: boolean,
|
| 664 |
+
* id?: string, modelLib?: string, modelType?: string, contextWindow?: number,
|
| 665 |
+
* vramRequiredMB?: number, onProgress?: Function}} [opts]
|
| 666 |
+
* @returns {Promise<object>} the engine state, or the registry record when `defer`
|
| 667 |
+
*/
|
| 668 |
+
async load(src, opts = {}) {
|
| 669 |
+
const source = classifySource(src, opts);
|
| 670 |
+
|
| 671 |
+
if (source.kind === SOURCE_KIND.ID) {
|
| 672 |
+
if (opts.defer) {
|
| 673 |
+
throw new EngineError(
|
| 674 |
+
ERROR.BAD_REQUEST,
|
| 675 |
+
`\`defer\` registers a source without loading it, but "${source.modelId}" is an id — ` +
|
| 676 |
+
"there is nothing to register. Drop `defer`, or pass a URL or a folder.",
|
| 677 |
+
{ modelId: source.modelId },
|
| 678 |
+
);
|
| 679 |
+
}
|
| 680 |
+
return this.#loadById(source.modelId, opts);
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
const record = await this.#register(source, opts);
|
| 684 |
+
if (opts.defer) return record;
|
| 685 |
+
return this.#loadById(record.model_id, opts);
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
/** Turns a classified non-id source into a registry record. */
|
| 689 |
+
async #register(source, opts) {
|
| 690 |
+
if (source.kind === SOURCE_KIND.FILES) {
|
| 691 |
+
return ingestModelFolder(await toEntries(source.files), {
|
| 692 |
+
store: this.#store,
|
| 693 |
+
modelId: source.modelId,
|
| 694 |
+
modelType: opts.modelType,
|
| 695 |
+
onProgress: opts.onProgress,
|
| 696 |
+
});
|
| 697 |
+
}
|
| 698 |
+
return this.#store.registerModel({
|
| 699 |
+
modelId: source.modelId,
|
| 700 |
+
model: source.model,
|
| 701 |
+
modelLib: source.modelLib,
|
| 702 |
+
modelType: opts.modelType,
|
| 703 |
+
contextWindow: opts.contextWindow,
|
| 704 |
+
vramRequiredMB: opts.vramRequiredMB,
|
| 705 |
+
});
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
/**
|
| 709 |
+
* Bring a registered or prebuilt id up and make it current.
|
| 710 |
+
*
|
| 711 |
+
* Cancellation is WebLLM's, not ours: `unload()` aborts the `reloadController`
|
| 712 |
+
* whose signal it threads through every artifact fetch. And resume is free —
|
| 713 |
+
* WebLLM caches each artifact as it arrives and skips what is already present,
|
| 714 |
+
* so a re-`load()` picks up where the abort left off. Call `evict()` to
|
| 715 |
+
* discard a partial download instead.
|
| 716 |
+
*
|
| 717 |
+
* @param {string} modelId
|
| 718 |
+
* @param {{keepResident?: boolean, signal?: AbortSignal}} [opts]
|
| 719 |
+
*/
|
| 720 |
+
async #loadById(modelId, { keepResident = false, signal } = {}) {
|
| 721 |
+
if (signal?.aborted) {
|
| 722 |
+
throw new EngineError(ERROR.ABORTED, `Load of "${modelId}" was aborted before it began.`, {
|
| 723 |
+
modelId,
|
| 724 |
+
});
|
| 725 |
+
}
|
| 726 |
+
if (this.#pools.has(modelId)) return this.use(modelId);
|
| 727 |
+
|
| 728 |
+
const inFlight = this.#loading.get(modelId);
|
| 729 |
+
if (inFlight) {
|
| 730 |
+
await inFlight.catch(() => {});
|
| 731 |
+
return this.#pools.has(modelId) ? this.use(modelId) : this.state;
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
const loading = (async () => {
|
| 735 |
+
this.#assertWebGPU();
|
| 736 |
+
|
| 737 |
+
const models = await this.#store.list();
|
| 738 |
+
const registered = models.find((m) => m.model_id === modelId);
|
| 739 |
+
|
| 740 |
+
// Only an injected model can be *unrecoverably* broken by eviction, and
|
| 741 |
+
// checking costs nothing, so it gates the load — before the WebLLM bundle
|
| 742 |
+
// is fetched, so the common offline failure stays fast.
|
| 743 |
+
if (isInjected(registered)) {
|
| 744 |
+
const { ok, missing } = await this.#store.verify(registered);
|
| 745 |
+
if (!ok) {
|
| 746 |
+
throw new EngineError(
|
| 747 |
+
ERROR.CACHE_INCOMPLETE,
|
| 748 |
+
`Cache for "${modelId}" is incomplete (${missing.length} artifact(s) evicted, e.g. ${missing[0].split("/").pop()}). Re-register the model folder.`,
|
| 749 |
+
{ modelId, missing },
|
| 750 |
+
);
|
| 751 |
+
}
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
if (!registered && !this.#prebuilt) {
|
| 755 |
+
const near = nearMatches(modelId, models.map((m) => m.model_id));
|
| 756 |
+
throw new EngineError(
|
| 757 |
+
ERROR.UNKNOWN_MODEL,
|
| 758 |
+
`Model "${modelId}" is not registered, and prebuilt models are disabled. ` +
|
| 759 |
+
(near.length ? `Did you mean ${near.map((id) => `"${id}"`).join(", ")}? ` : "") +
|
| 760 |
+
`Call load(url, { modelLib }) or load({ files }) first.`,
|
| 761 |
+
{ modelId, prebuilt: false, ...(near.length ? { near } : {}) },
|
| 762 |
+
);
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
const { engineCount, decodeSteps } = await this.#store.getSettings();
|
| 766 |
+
this.#setState({
|
| 767 |
+
status: ENGINE_STATE.LOADING,
|
| 768 |
+
modelId,
|
| 769 |
+
error: null,
|
| 770 |
+
progress: { text: "Starting", progress: 0 },
|
| 771 |
+
});
|
| 772 |
+
|
| 773 |
+
// Each resident model is a full copy of its weights. Nothing reports free
|
| 774 |
+
// VRAM to a page (AI.md, "The pool grows, it is not sized"), so the
|
| 775 |
+
// default is to make room rather than to gamble on it fitting.
|
| 776 |
+
if (!keepResident) {
|
| 777 |
+
for (const id of [...this.#pools.keys()]) await this.#unloadOne(id);
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
const { CreateWebWorkerMLCEngine, prebuiltAppConfig } = await this.#loadWebLLM();
|
| 781 |
+
const appConfig = toAppConfig(models, this.#prebuilt ? prebuiltAppConfig : null);
|
| 782 |
+
|
| 783 |
+
if (!appConfig.model_list.some((m) => m.model_id === modelId)) {
|
| 784 |
+
// A typo'd id is the single most likely way to arrive here, and the
|
| 785 |
+
// fix is almost always visible in the list we are already holding.
|
| 786 |
+
const near = nearMatches(modelId, appConfig.model_list.map((m) => m.model_id));
|
| 787 |
+
throw new EngineError(
|
| 788 |
+
ERROR.UNKNOWN_MODEL,
|
| 789 |
+
`Model "${modelId}" is neither registered nor in WebLLM's prebuilt list. ` +
|
| 790 |
+
(near.length
|
| 791 |
+
? `Did you mean ${near.map((id) => `"${id}"`).join(", ")}? `
|
| 792 |
+
: "") +
|
| 793 |
+
`Use listAvailableModels() to see what this engine can load.`,
|
| 794 |
+
{ modelId, ...(near.length ? { near } : {}) },
|
| 795 |
+
);
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
const pool = new EnginePool({
|
| 799 |
+
size: engineCount,
|
| 800 |
+
createEngine: async (_index, onProgress) => {
|
| 801 |
+
const worker = new Worker(this.#workerUrl, { type: "module" });
|
| 802 |
+
// Listener, not `onmessage`: WebLLM claims `onmessage` for its own RPC.
|
| 803 |
+
worker.addEventListener("message", (event) => {
|
| 804 |
+
if (event.data?.ewgpuStats) this.#setState({ decode: event.data.ewgpuStats });
|
| 805 |
+
});
|
| 806 |
+
// A worker whose script 404s does not throw from `new Worker()` — it
|
| 807 |
+
// fires one `error` event and is then simply silent, so WebLLM's
|
| 808 |
+
// handshake below never resolves and the load hangs until the caller
|
| 809 |
+
// gives up. That is the exact shape of the Vite dep-optimizer bug this
|
| 810 |
+
// names: esbuild copies `new URL("./engine-worker.js",
|
| 811 |
+
// import.meta.url)` into `.vite/deps/` verbatim, where the sibling
|
| 812 |
+
// file does not exist. Racing the handshake against this turns a hang
|
| 813 |
+
// into a sentence.
|
| 814 |
+
const workerFailed = new Promise((_, reject) => {
|
| 815 |
+
worker.addEventListener("error", (event) => {
|
| 816 |
+
reject(
|
| 817 |
+
new EngineError(
|
| 818 |
+
ERROR.PACKAGE_INCOMPLETE,
|
| 819 |
+
`The decode worker failed to load from ${this.#workerUrl}. ` +
|
| 820 |
+
"If you are on Vite, its dependency pre-bundler rewrote the worker URL to a " +
|
| 821 |
+
"path that does not exist — add `optimizeDeps: { exclude: [\"everything-webgpu\"] }` " +
|
| 822 |
+
"to vite.config.js, or pass `workerUrl` yourself.",
|
| 823 |
+
{ cause: "worker-unreachable", workerUrl: String(this.#workerUrl), underlying: event.message },
|
| 824 |
+
),
|
| 825 |
+
);
|
| 826 |
+
});
|
| 827 |
+
});
|
| 828 |
+
// Sent before WebLLM's own handshake so the first token already decodes
|
| 829 |
+
// multi-step; worker message order guarantees it arrives first.
|
| 830 |
+
worker.postMessage({ kind: WORKER_CONFIGURE, decodeSteps });
|
| 831 |
+
const engine = await Promise.race([
|
| 832 |
+
CreateWebWorkerMLCEngine(worker, modelId, {
|
| 833 |
+
appConfig,
|
| 834 |
+
initProgressCallback: onProgress,
|
| 835 |
+
}),
|
| 836 |
+
workerFailed,
|
| 837 |
+
]);
|
| 838 |
+
// The worker owns the decode loop, so runtime knobs go straight to it
|
| 839 |
+
// rather than through WebLLM's request path.
|
| 840 |
+
engine.configure = (patch) => worker.postMessage({ kind: WORKER_CONFIGURE, ...patch });
|
| 841 |
+
// Tear the realm down with the engine, not just the model.
|
| 842 |
+
const unloadEngine = engine.unload.bind(engine);
|
| 843 |
+
engine.unload = async () => {
|
| 844 |
+
await unloadEngine().catch(() => {});
|
| 845 |
+
worker.terminate();
|
| 846 |
+
};
|
| 847 |
+
return engine;
|
| 848 |
+
},
|
| 849 |
+
onStateChange: () => {
|
| 850 |
+
if (this.#current === modelId) this.#syncState();
|
| 851 |
+
},
|
| 852 |
+
});
|
| 853 |
+
|
| 854 |
+
// Reachable from here on, so an abort has something to tear down. The
|
| 855 |
+
// listener bumps the pool's generation, which is what stops an engine
|
| 856 |
+
// that finishes building *after* the abort from installing itself.
|
| 857 |
+
this.#loadingPools.set(modelId, pool);
|
| 858 |
+
if (signal) signal.addEventListener("abort", () => void this.#unloadOne(modelId), { once: true });
|
| 859 |
+
|
| 860 |
+
const abortedNow = async () => {
|
| 861 |
+
await this.#unloadOne(modelId);
|
| 862 |
+
throw new EngineError(ERROR.ABORTED, `Load of "${modelId}" was aborted.`, { modelId });
|
| 863 |
+
};
|
| 864 |
+
|
| 865 |
+
// Checked on both sides of the load. Before: an abort that landed while
|
| 866 |
+
// the registry was being read must not start a download at all. After: an
|
| 867 |
+
// abort during the download has already torn the pool down, and this is
|
| 868 |
+
// what turns that into a rejection rather than a silent no-op.
|
| 869 |
+
if (signal?.aborted) return abortedNow();
|
| 870 |
+
await pool.load((progress) => this.#setState({ progress }));
|
| 871 |
+
if (signal?.aborted) return abortedNow();
|
| 872 |
+
|
| 873 |
+
const entry = appConfig.model_list.find((m) => m.model_id === modelId);
|
| 874 |
+
this.#modelBytes.set(
|
| 875 |
+
modelId,
|
| 876 |
+
registered?.sizeBytes ?? (entry?.vram_required_MB ?? 0) * 1024 * 1024,
|
| 877 |
+
);
|
| 878 |
+
this.#pools.set(modelId, pool);
|
| 879 |
+
this.#current = modelId;
|
| 880 |
+
this.#setState({ status: ENGINE_STATE.READY, progress: null, error: null });
|
| 881 |
+
this.#syncState();
|
| 882 |
+
return this.state;
|
| 883 |
+
})();
|
| 884 |
+
|
| 885 |
+
this.#loading.set(modelId, loading);
|
| 886 |
+
try {
|
| 887 |
+
return await loading;
|
| 888 |
+
} catch (err) {
|
| 889 |
+
const engineError = asEngineError(err);
|
| 890 |
+
this.#setState({
|
| 891 |
+
status: this.#pools.size ? ENGINE_STATE.READY : ENGINE_STATE.ERROR,
|
| 892 |
+
progress: null,
|
| 893 |
+
error: engineError.message,
|
| 894 |
+
errorCode: engineError.code,
|
| 895 |
+
});
|
| 896 |
+
// A failed load must not leave `modelId` pointing at the model that did
|
| 897 |
+
// not come up — it was set optimistically when LOADING began.
|
| 898 |
+
this.#syncState();
|
| 899 |
+
throw engineError;
|
| 900 |
+
} finally {
|
| 901 |
+
this.#loading.delete(modelId);
|
| 902 |
+
this.#loadingPools.delete(modelId);
|
| 903 |
+
}
|
| 904 |
+
}
|
| 905 |
+
|
| 906 |
+
/**
|
| 907 |
+
* Let a model go, at one of two depths.
|
| 908 |
+
*
|
| 909 |
+
* ```js
|
| 910 |
+
* unload() // the current model's VRAM; cached bytes stay
|
| 911 |
+
* unload(id) // that model's VRAM
|
| 912 |
+
* unload(id, "cache") // and delete its cached bytes, keeping the registry entry
|
| 913 |
+
* ```
|
| 914 |
+
*
|
| 915 |
+
* At `"vram"` the bytes stay on disk, so loading it again costs no network —
|
| 916 |
+
* that is what makes switching back cheap, and the difference between this
|
| 917 |
+
* and `remove()`.
|
| 918 |
+
*
|
| 919 |
+
* **A bare `unload()` frees only the current model**, not every resident one.
|
| 920 |
+
* `unloadAll()` is the explicit form for that: freeing everything is the more
|
| 921 |
+
* destructive of the two readings and should have to be asked for by name.
|
| 922 |
+
*
|
| 923 |
+
* @param {string} [modelId] defaults to the current model. Omit both this and
|
| 924 |
+
* any resident model to no-op.
|
| 925 |
+
* @param {"vram"|"cache"} [level]
|
| 926 |
+
*/
|
| 927 |
+
async unload(modelId = this.#current, level = UNLOAD_LEVEL.VRAM) {
|
| 928 |
+
if (!Object.values(UNLOAD_LEVEL).includes(level)) {
|
| 929 |
+
throw new EngineError(
|
| 930 |
+
ERROR.BAD_REQUEST,
|
| 931 |
+
`unload() level must be ${Object.values(UNLOAD_LEVEL).map((l) => `"${l}"`).join(" or ")}, ` +
|
| 932 |
+
`not "${level}". To forget the model entirely, use remove().`,
|
| 933 |
+
{ level },
|
| 934 |
+
);
|
| 935 |
+
}
|
| 936 |
+
if (modelId) {
|
| 937 |
+
await this.#unloadOne(modelId);
|
| 938 |
+
if (level === UNLOAD_LEVEL.CACHE) await this.#evictBytes(modelId);
|
| 939 |
+
}
|
| 940 |
+
this.#setState({
|
| 941 |
+
status: this.#pools.size ? ENGINE_STATE.READY : ENGINE_STATE.IDLE,
|
| 942 |
+
progress: null,
|
| 943 |
+
error: null,
|
| 944 |
+
});
|
| 945 |
+
this.#syncState();
|
| 946 |
+
return this.state;
|
| 947 |
+
}
|
| 948 |
+
|
| 949 |
+
/** Unload every resident model. */
|
| 950 |
+
async unloadAll() {
|
| 951 |
+
for (const id of [...this.#pools.keys()]) await this.#unloadOne(id);
|
| 952 |
+
this.#setState({ status: ENGINE_STATE.IDLE, progress: null, error: null });
|
| 953 |
+
this.#syncState();
|
| 954 |
+
return this.state;
|
| 955 |
+
}
|
| 956 |
+
|
| 957 |
+
async #unloadOne(modelId) {
|
| 958 |
+
const pool = this.#pools.get(modelId) ?? this.#loadingPools.get(modelId);
|
| 959 |
+
if (!pool) return;
|
| 960 |
+
this.#pools.delete(modelId);
|
| 961 |
+
this.#loadingPools.delete(modelId);
|
| 962 |
+
if (this.#current === modelId) this.#current = this.#pools.keys().next().value ?? null;
|
| 963 |
+
await pool.unload();
|
| 964 |
+
}
|
| 965 |
+
|
| 966 |
+
/**
|
| 967 |
+
* Learn this machine's achieved decode bandwidth from a finished generation.
|
| 968 |
+
*
|
| 969 |
+
* WebLLM already measures throughput and puts it on every response as
|
| 970 |
+
* `usage.extra.decode_tokens_per_s` — the pool asks for usage and was
|
| 971 |
+
* discarding it. Tokens per second times weight bytes is bytes per second,
|
| 972 |
+
* which is the one number a projection for *any other* model needs.
|
| 973 |
+
*
|
| 974 |
+
* The worker's own decode probe is not this and does not replace it: it
|
| 975 |
+
* splits a burst into CPU-encode and GPU-sync, which is what the multi-step
|
| 976 |
+
* and compute-pass work is measured against. Nothing upstream reports that.
|
| 977 |
+
*/
|
| 978 |
+
#calibrate(usage, modelId) {
|
| 979 |
+
const tps = usage?.extra?.decode_tokens_per_s;
|
| 980 |
+
const bytes = this.#modelBytes.get(modelId);
|
| 981 |
+
if (!tps || !bytes) return;
|
| 982 |
+
this.#decodeBytesPerSecond = tps * bytes;
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
/** Recomputes the parts of `state` that are views onto the current pool. */
|
| 986 |
+
#syncState() {
|
| 987 |
+
this.#setState({
|
| 988 |
+
modelId: this.#current,
|
| 989 |
+
resident: this.resident,
|
| 990 |
+
pool: this.#pool?.status() ?? { size: 0, busy: 0, queued: 0 },
|
| 991 |
+
});
|
| 992 |
+
}
|
| 993 |
+
|
| 994 |
+
/** Loads on demand so callers can just ask for a completion. */
|
| 995 |
+
async #ensurePool(modelId) {
|
| 996 |
+
// A request naming a resident model goes straight to it, without disturbing
|
| 997 |
+
// which model is current — routing is not switching.
|
| 998 |
+
if (modelId && this.#pools.has(modelId)) return this.#pools.get(modelId);
|
| 999 |
+
if (modelId) await this.load(modelId);
|
| 1000 |
+
if (!this.#pool) {
|
| 1001 |
+
const fallback = modelId ?? this.#current ?? (await this.#store.list())[0]?.model_id;
|
| 1002 |
+
if (!fallback) {
|
| 1003 |
+
throw new EngineError(
|
| 1004 |
+
ERROR.NO_MODEL,
|
| 1005 |
+
"No model is registered yet. Call registerModel(), or load() a prebuilt id.",
|
| 1006 |
+
);
|
| 1007 |
+
}
|
| 1008 |
+
await this.load(fallback);
|
| 1009 |
+
}
|
| 1010 |
+
return modelId ? (this.#pools.get(modelId) ?? this.#pool) : this.#pool;
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
async #buildParams(payload) {
|
| 1014 |
+
const settings = await this.#store.getSettings();
|
| 1015 |
+
const messages = Array.isArray(payload.messages) ? [...payload.messages] : [];
|
| 1016 |
+
if (messages.length === 0) {
|
| 1017 |
+
throw new EngineError(ERROR.BAD_REQUEST, "`messages` must be a non-empty array.");
|
| 1018 |
+
}
|
| 1019 |
+
if (settings.systemPrompt && !messages.some((m) => m.role === "system")) {
|
| 1020 |
+
messages.unshift({ role: "system", content: settings.systemPrompt });
|
| 1021 |
+
}
|
| 1022 |
+
return {
|
| 1023 |
+
messages,
|
| 1024 |
+
temperature: payload.temperature ?? settings.temperature,
|
| 1025 |
+
max_tokens: payload.max_tokens ?? settings.maxTokens,
|
| 1026 |
+
...(payload.response_format ? { response_format: payload.response_format } : {}),
|
| 1027 |
+
...(payload.extra_body ? { extra_body: payload.extra_body } : {}),
|
| 1028 |
+
};
|
| 1029 |
+
}
|
| 1030 |
+
|
| 1031 |
+
/**
|
| 1032 |
+
* One completion.
|
| 1033 |
+
*
|
| 1034 |
+
* Named `complete` rather than `chat` so `engine.chat.completions.create()`
|
| 1035 |
+
* — the WebLLM-shaped facade, Phase 2 — can take that name without a rename.
|
| 1036 |
+
*
|
| 1037 |
+
* @param {CompletionRequest} payload
|
| 1038 |
+
* @param {(delta: string) => void} [onChunk] called per streamed text delta
|
| 1039 |
+
* @returns {Promise<CompletionResult>}
|
| 1040 |
+
*/
|
| 1041 |
+
async complete(payload, onChunk) {
|
| 1042 |
+
// Adapts the raw chunk stream to the documented string callback. The guard
|
| 1043 |
+
// matters: raw chunks include a role-only first chunk and a finish-only
|
| 1044 |
+
// last chunk, which callers of this signature have never seen.
|
| 1045 |
+
return this.completeRaw(
|
| 1046 |
+
payload,
|
| 1047 |
+
onChunk &&
|
| 1048 |
+
((chunk) => {
|
| 1049 |
+
const delta = chunk?.choices?.[0]?.delta?.content;
|
| 1050 |
+
if (delta) onChunk(delta);
|
| 1051 |
+
}),
|
| 1052 |
+
);
|
| 1053 |
+
}
|
| 1054 |
+
|
| 1055 |
+
/**
|
| 1056 |
+
* `complete()`, but the callback receives WebLLM's chunk verbatim.
|
| 1057 |
+
*
|
| 1058 |
+
* Exists so the `chat.completions.create()` facade can pass chunks straight
|
| 1059 |
+
* through instead of rebuilding an envelope — which is what dropped
|
| 1060 |
+
* `tool_calls`, flattened `logprobs` and restamped `created`.
|
| 1061 |
+
*
|
| 1062 |
+
* @param {CompletionRequest} payload
|
| 1063 |
+
* @param {(chunk: object) => void} [onRawChunk]
|
| 1064 |
+
* @returns {Promise<CompletionResult & {toolCalls?: Array<object>}>}
|
| 1065 |
+
*/
|
| 1066 |
+
async completeRaw(payload, onRawChunk) {
|
| 1067 |
+
const pool = await this.#ensurePool(payload.modelId);
|
| 1068 |
+
const result = unwrap(
|
| 1069 |
+
await pool.submit({
|
| 1070 |
+
...scheduling(payload),
|
| 1071 |
+
id: payload.id,
|
| 1072 |
+
params: await this.#buildParams(payload),
|
| 1073 |
+
onChunk: onRawChunk,
|
| 1074 |
+
}),
|
| 1075 |
+
);
|
| 1076 |
+
this.#calibrate(result.usage, this.#current);
|
| 1077 |
+
return {
|
| 1078 |
+
text: result.text,
|
| 1079 |
+
usage: result.usage,
|
| 1080 |
+
finishReason: result.finishReason,
|
| 1081 |
+
...(result.toolCalls ? { toolCalls: result.toolCalls } : {}),
|
| 1082 |
+
...(result.cancelled ? { cancelled: true } : {}),
|
| 1083 |
+
...(result.preempted ? { preempted: true } : {}),
|
| 1084 |
+
};
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
// ------------------------------------------- the three shapes, as verbs ---
|
| 1088 |
+
//
|
| 1089 |
+
// `complete()` expresses all three. These exist because the scheduling is the
|
| 1090 |
+
// part that is easy to get wrong and invisible when you do — see recipes.js.
|
| 1091 |
+
|
| 1092 |
+
/**
|
| 1093 |
+
* One question, one answer, nothing kept.
|
| 1094 |
+
*
|
| 1095 |
+
* ```js
|
| 1096 |
+
* const answer = await engine.ask("Summarise this in one line:\n" + doc);
|
| 1097 |
+
* ```
|
| 1098 |
+
*
|
| 1099 |
+
* @param {string | Array<object>} input
|
| 1100 |
+
* @param {object} [opts] anything `complete()` takes, plus `onDelta` to stream
|
| 1101 |
+
* @returns {Promise<string>}
|
| 1102 |
+
*/
|
| 1103 |
+
ask(input, opts) {
|
| 1104 |
+
return ask(this, input, opts);
|
| 1105 |
+
}
|
| 1106 |
+
|
| 1107 |
+
/**
|
| 1108 |
+
* A multi-turn conversation that keeps its own history.
|
| 1109 |
+
*
|
| 1110 |
+
* ```js
|
| 1111 |
+
* const chat = engine.conversation({ system: "You are terse." });
|
| 1112 |
+
* await chat.say("hello");
|
| 1113 |
+
* await chat.say("and again?"); // remembers
|
| 1114 |
+
* ```
|
| 1115 |
+
*
|
| 1116 |
+
* @param {object} [opts] `system`, `keep`, plus `complete()` defaults
|
| 1117 |
+
*/
|
| 1118 |
+
conversation(opts) {
|
| 1119 |
+
return conversation(this, opts);
|
| 1120 |
+
}
|
| 1121 |
+
|
| 1122 |
+
/**
|
| 1123 |
+
* Ghost text, with the debounce/supersede/drop-if-stale discipline built in
|
| 1124 |
+
* and the prompt left to you.
|
| 1125 |
+
*
|
| 1126 |
+
* ```js
|
| 1127 |
+
* const ghost = engine.ghostText({ prompt: (before) => `Continue:\n${before}` });
|
| 1128 |
+
* editor.on("input", async () => {
|
| 1129 |
+
* const hint = await ghost.suggest(editor.textBefore());
|
| 1130 |
+
* if (hint !== null) render(hint); // null means a newer keystroke won
|
| 1131 |
+
* });
|
| 1132 |
+
* editor.on("blur", () => ghost.cancel());
|
| 1133 |
+
* ```
|
| 1134 |
+
*
|
| 1135 |
+
* @param {object} opts must include `prompt`
|
| 1136 |
+
*/
|
| 1137 |
+
ghostText(opts) {
|
| 1138 |
+
return ghostText(this, opts);
|
| 1139 |
+
}
|
| 1140 |
+
|
| 1141 |
+
/**
|
| 1142 |
+
* Embed text into vectors, through the same scheduler as everything else.
|
| 1143 |
+
*
|
| 1144 |
+
* ```js
|
| 1145 |
+
* const [vector] = await engine.embed("a sentence", { modelId: EMBED_MODEL });
|
| 1146 |
+
* const vectors = await engine.embed(["one", "two"], { modelId: EMBED_MODEL });
|
| 1147 |
+
* ```
|
| 1148 |
+
*
|
| 1149 |
+
* **Needs an embedding model**, not a chat model — `snowflake-arctic-embed-*`
|
| 1150 |
+
* in WebLLM's prebuilt list, from 239 MB. They are separate models, so this
|
| 1151 |
+
* usually names `modelId` explicitly and holds it resident alongside a chat
|
| 1152 |
+
* model with `load(id, { keepResident: true })`.
|
| 1153 |
+
*
|
| 1154 |
+
* Returns bare vectors because that is what a caller does arithmetic on; the
|
| 1155 |
+
* OpenAI envelope is available as `embedRaw()` for anyone porting code that
|
| 1156 |
+
* expects `data[].embedding`.
|
| 1157 |
+
*
|
| 1158 |
+
* **A running embedding cannot be interrupted.** Cancellation and preemption
|
| 1159 |
+
* work by making a decode loop break out; one forward pass has no loop, so a
|
| 1160 |
+
* `cancel()` that lands after the job starts marks it cancelled but does not
|
| 1161 |
+
* stop it. Queued embeddings supersede and cancel normally. This is tolerable
|
| 1162 |
+
* because an embedding is milliseconds where a completion is seconds — but it
|
| 1163 |
+
* is a weaker guarantee than `complete()` gives, so it is stated rather than
|
| 1164 |
+
* discovered.
|
| 1165 |
+
*
|
| 1166 |
+
* @param {string | string[]} input
|
| 1167 |
+
* @param {{modelId?: string, task?: string, session?: string,
|
| 1168 |
+
* priority?: string, preemptible?: boolean, id?: string}} [opts]
|
| 1169 |
+
* @returns {Promise<number[][]>} one vector per input, in order
|
| 1170 |
+
*/
|
| 1171 |
+
async embed(input, opts = {}) {
|
| 1172 |
+
const { data } = await this.embedRaw(input, opts);
|
| 1173 |
+
return data.map((d) => d.embedding);
|
| 1174 |
+
}
|
| 1175 |
+
|
| 1176 |
+
/** `embed()`, returning WebLLM's OpenAI-shaped envelope untouched. */
|
| 1177 |
+
async embedRaw(input, opts = {}) {
|
| 1178 |
+
const texts = Array.isArray(input) ? input : [input];
|
| 1179 |
+
if (texts.length === 0 || texts.some((t) => typeof t !== "string")) {
|
| 1180 |
+
throw new EngineError(
|
| 1181 |
+
ERROR.BAD_REQUEST,
|
| 1182 |
+
"embed() takes a string or a non-empty array of strings.",
|
| 1183 |
+
{ received: Array.isArray(input) ? `array of ${input.length}` : typeof input },
|
| 1184 |
+
);
|
| 1185 |
+
}
|
| 1186 |
+
|
| 1187 |
+
const pool = await this.#ensurePool(opts.modelId);
|
| 1188 |
+
const result = unwrap(
|
| 1189 |
+
await pool.submit({
|
| 1190 |
+
...scheduling(opts),
|
| 1191 |
+
id: opts.id,
|
| 1192 |
+
kind: JOB_KIND.EMBEDDING,
|
| 1193 |
+
params: { input: texts },
|
| 1194 |
+
}),
|
| 1195 |
+
);
|
| 1196 |
+
return { data: result.embeddings ?? [], usage: result.usage };
|
| 1197 |
+
}
|
| 1198 |
+
|
| 1199 |
+
/**
|
| 1200 |
+
* Independent prompts, fanned across the pool. This is the only way to beat
|
| 1201 |
+
* the ~10 tok/s single-stream ceiling, so anything embarrassingly parallel
|
| 1202 |
+
* (translating a page, labelling a list) should arrive here rather than as a
|
| 1203 |
+
* loop of `complete` calls.
|
| 1204 |
+
*
|
| 1205 |
+
* @param {CompletionRequest & {requests: Array<Partial<CompletionRequest>>}} payload
|
| 1206 |
+
* @param {(item: BatchItem) => void} [onItem] called as each item lands
|
| 1207 |
+
* @returns {Promise<Array<BatchItem>>}
|
| 1208 |
+
*/
|
| 1209 |
+
async batch(payload, onItem = () => {}) {
|
| 1210 |
+
const requests = payload.requests;
|
| 1211 |
+
if (!Array.isArray(requests) || requests.length === 0) {
|
| 1212 |
+
throw new EngineError(ERROR.BAD_REQUEST, "`requests` must be a non-empty array.");
|
| 1213 |
+
}
|
| 1214 |
+
const pool = await this.#ensurePool(payload.modelId);
|
| 1215 |
+
const sched = scheduling(payload);
|
| 1216 |
+
// One batch is one task, however many requests it is: "translate this page"
|
| 1217 |
+
// should hold one engine, not every engine. The pool reserves its last free
|
| 1218 |
+
// slot for a different task, so ghost-text never queues behind the page.
|
| 1219 |
+
const task = payload.task ?? `batch-${payload.id ?? crypto.randomUUID()}`;
|
| 1220 |
+
|
| 1221 |
+
return Promise.all(
|
| 1222 |
+
requests.map(async (req, index) => {
|
| 1223 |
+
const merged = { ...payload, ...req, requests: undefined };
|
| 1224 |
+
const result = await pool.submit({
|
| 1225 |
+
...sched,
|
| 1226 |
+
...scheduling(merged),
|
| 1227 |
+
session: req.session, // a batch shares no session unless an item names one
|
| 1228 |
+
// An item that names its own session is its own task again.
|
| 1229 |
+
task: req.task ?? req.session ?? task,
|
| 1230 |
+
params: await this.#buildParams(merged),
|
| 1231 |
+
});
|
| 1232 |
+
const item = {
|
| 1233 |
+
index,
|
| 1234 |
+
engineIndex: result.engineIndex,
|
| 1235 |
+
startedAt: result.startedAt,
|
| 1236 |
+
finishedAt: result.finishedAt,
|
| 1237 |
+
...(result.error
|
| 1238 |
+
? { error: result.error }
|
| 1239 |
+
: {
|
| 1240 |
+
text: result.text,
|
| 1241 |
+
usage: result.usage,
|
| 1242 |
+
finishReason: result.finishReason,
|
| 1243 |
+
...(result.toolCalls ? { toolCalls: result.toolCalls } : {}),
|
| 1244 |
+
...(result.cancelled ? { cancelled: true } : {}),
|
| 1245 |
+
}),
|
| 1246 |
+
};
|
| 1247 |
+
onItem(item);
|
| 1248 |
+
return item;
|
| 1249 |
+
}),
|
| 1250 |
+
);
|
| 1251 |
+
}
|
| 1252 |
+
|
| 1253 |
+
/**
|
| 1254 |
+
* Cancels by job id or by session key.
|
| 1255 |
+
* @param {string} idOrSession
|
| 1256 |
+
* @returns {number} how many jobs it stopped
|
| 1257 |
+
*/
|
| 1258 |
+
cancel(idOrSession) {
|
| 1259 |
+
let stopped = 0;
|
| 1260 |
+
for (const pool of this.#pools.values()) stopped += pool.cancel(idOrSession);
|
| 1261 |
+
return stopped;
|
| 1262 |
+
}
|
| 1263 |
+
|
| 1264 |
+
/**
|
| 1265 |
+
* Applies a runtime knob to the running pool and persists it as the default.
|
| 1266 |
+
*
|
| 1267 |
+
* `decodeSteps` is the multi-step decode width (AI.md, "Multi-step decoding").
|
| 1268 |
+
* It takes effect on the next burst — no reload — which is what makes sweeping
|
| 1269 |
+
* it to find this machine's tick boundary cheap.
|
| 1270 |
+
*/
|
| 1271 |
+
async configure(patch) {
|
| 1272 |
+
const applied = {};
|
| 1273 |
+
if (patch.decodeSteps !== undefined) applied.decodeSteps = clampSteps(patch.decodeSteps);
|
| 1274 |
+
if (patch.engineCount !== undefined) {
|
| 1275 |
+
const n = Math.round(Number(patch.engineCount));
|
| 1276 |
+
if (!Number.isFinite(n) || n < 1) {
|
| 1277 |
+
throw new EngineError(
|
| 1278 |
+
ERROR.BAD_REQUEST,
|
| 1279 |
+
`engineCount must be a positive integer, not ${JSON.stringify(patch.engineCount)}.`,
|
| 1280 |
+
{ engineCount: patch.engineCount },
|
| 1281 |
+
);
|
| 1282 |
+
}
|
| 1283 |
+
applied.engineCount = n;
|
| 1284 |
+
}
|
| 1285 |
+
if (Object.keys(applied).length === 0) {
|
| 1286 |
+
// Naming the knobs matters: this is the error a caller hits after
|
| 1287 |
+
// `environment()` told them something was operable, so it has to agree
|
| 1288 |
+
// with that report about what the operable things are.
|
| 1289 |
+
throw new EngineError(
|
| 1290 |
+
ERROR.BAD_REQUEST,
|
| 1291 |
+
"`configure` needs at least one setting. Operable: `decodeSteps`, `engineCount`.",
|
| 1292 |
+
{ operable: ["decodeSteps", "engineCount"] },
|
| 1293 |
+
);
|
| 1294 |
+
}
|
| 1295 |
+
await this.#store.setSettings(applied);
|
| 1296 |
+
// Only `decodeSteps` is hot. `engineCount` is persisted and read when a pool
|
| 1297 |
+
// is built, so a live pool keeps the size it came up with — `environment()`
|
| 1298 |
+
// reports that gap rather than pretending the change took effect.
|
| 1299 |
+
let engines = 0;
|
| 1300 |
+
if (applied.decodeSteps !== undefined) {
|
| 1301 |
+
for (const pool of this.#pools.values()) engines += pool.configure({ decodeSteps: applied.decodeSteps });
|
| 1302 |
+
}
|
| 1303 |
+
return { settings: applied, engines };
|
| 1304 |
+
}
|
| 1305 |
+
}
|
| 1306 |
+
|
| 1307 |
+
/**
|
| 1308 |
+
* Normalise every folder shape a caller might hold into `{path, file}[]`.
|
| 1309 |
+
*
|
| 1310 |
+
* A drop event gives a `DataTransfer`, `<input webkitdirectory>` gives a
|
| 1311 |
+
* `FileList`, and a caller who has already unpacked one gives the entries. All
|
| 1312 |
+
* three mean "this folder", so `load()` accepts all three rather than making
|
| 1313 |
+
* the caller find the right converter first.
|
| 1314 |
+
*/
|
| 1315 |
+
async function toEntries(files) {
|
| 1316 |
+
if (isDataTransfer(files)) return filesFromDataTransfer(files);
|
| 1317 |
+
if (isFileList(files)) return filesFromInput(files);
|
| 1318 |
+
return files;
|
| 1319 |
+
}
|
| 1320 |
+
|
| 1321 |
+
/** Scheduling metadata is per-request; the pool, not the caller, acts on it. */
|
| 1322 |
+
function scheduling(payload) {
|
| 1323 |
+
return {
|
| 1324 |
+
task: payload.task,
|
| 1325 |
+
session: payload.session,
|
| 1326 |
+
priority: payload.priority ?? PRIORITY.NORMAL,
|
| 1327 |
+
preemptible: payload.preemptible,
|
| 1328 |
+
};
|
| 1329 |
+
}
|
| 1330 |
+
|
| 1331 |
+
function unwrap(result) {
|
| 1332 |
+
// The pool resolves rather than rejects, so a failed generation arrives as a
|
| 1333 |
+
// string on the result. It has no code of its own by then.
|
| 1334 |
+
if (result.error) throw asEngineError(result.error, ERROR.GENERATION_FAILED);
|
| 1335 |
+
return result;
|
| 1336 |
+
}
|
src/engine/environment.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* `environment()` — one call that says what this machine will do, and why.
|
| 3 |
+
*
|
| 4 |
+
* It absorbs the *read* half of `probe()`, `features()` and `estimateSpeed()`.
|
| 5 |
+
* It is **read-only**. Writes go through `configure()`, which already existed
|
| 6 |
+
* and stays the one way to change a setting.
|
| 7 |
+
*
|
| 8 |
+
* That split was a deliberate correction to the original plan, which had one
|
| 9 |
+
* function do both and told them apart by argument shape. Implicit dispatch is
|
| 10 |
+
* the opposite of foolproof: `environment({ scope: "device", decodeSteps: 8 })`
|
| 11 |
+
* has no intuitive answer, and the reason it has none is that one function was
|
| 12 |
+
* being asked to do two jobs. So reading and writing are different verbs, and
|
| 13 |
+
* the report's `fix` field names the write to make instead of performing it.
|
| 14 |
+
*
|
| 15 |
+
* Every line carries the same five fields, because a caller should be able to
|
| 16 |
+
* render the whole report without special-casing any of it:
|
| 17 |
+
*
|
| 18 |
+
* severity blocked | degraded | tune | info | ok
|
| 19 |
+
* affects what the reader loses — in their terms, not the engine's
|
| 20 |
+
* cause the measured fact behind the verdict
|
| 21 |
+
* fix the exact call or instruction, or `null` when nothing can be done
|
| 22 |
+
* operable whether this is reachable from JS at all
|
| 23 |
+
*
|
| 24 |
+
* `fix` is `null` for anything the caller genuinely cannot change — hardware,
|
| 25 |
+
* build-time flags, browser settings that JS cannot reach. Reporting a
|
| 26 |
+
* consequence with no remedy is still worth doing: "your second turn is slow
|
| 27 |
+
* because this device caps storage buffers at 9" is the difference between a
|
| 28 |
+
* bug report and an informed decision.
|
| 29 |
+
*
|
| 30 |
+
* **`canRun(modelId)` stays where it is.** It is per-*model*; this is
|
| 31 |
+
* per-*device*, and model ranking belongs with model discovery. The two answer
|
| 32 |
+
* different questions and merging them would make both worse.
|
| 33 |
+
*/
|
| 34 |
+
import { ERROR, EngineError } from "./errors.js";
|
| 35 |
+
|
| 36 |
+
export const SEVERITY = {
|
| 37 |
+
/** Nothing will run until this is dealt with. */
|
| 38 |
+
BLOCKED: "blocked",
|
| 39 |
+
/** It runs, measurably worse. */
|
| 40 |
+
DEGRADED: "degraded",
|
| 41 |
+
/** It runs well; this is a dial worth turning. */
|
| 42 |
+
TUNE: "tune",
|
| 43 |
+
/** Worth knowing, nothing to do. */
|
| 44 |
+
INFO: "info",
|
| 45 |
+
/** Checked, and fine. */
|
| 46 |
+
OK: "ok",
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
/** Worst-first, so `ok` can be computed and lines sorted by urgency. */
|
| 50 |
+
const RANK = [SEVERITY.BLOCKED, SEVERITY.DEGRADED, SEVERITY.TUNE, SEVERITY.INFO, SEVERITY.OK];
|
| 51 |
+
|
| 52 |
+
/** Paged prefill's binding count; below this, cross-turn KV reuse cannot build. */
|
| 53 |
+
const PAGED_PREFILL_STORAGE_BUFFERS = 10;
|
| 54 |
+
|
| 55 |
+
const SCOPES = new Set(["full", "local", "device"]);
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* Builds the callable `engine.environment` — a function with `.measure()` on it.
|
| 59 |
+
*
|
| 60 |
+
* A function rather than an object because the common case is asking for the
|
| 61 |
+
* whole report, and `environment()` should be the short spelling of that.
|
| 62 |
+
*
|
| 63 |
+
* @param {import("./engine.js").ScheduledEngine} engine
|
| 64 |
+
*/
|
| 65 |
+
export function environmentFacade(engine) {
|
| 66 |
+
const environment = (opts = {}) => report(engine, opts);
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* One calibration generation, then the measured rate.
|
| 70 |
+
*
|
| 71 |
+
* Returns tok/s for the **current model** — the only one it can measure,
|
| 72 |
+
* since measuring means generating. The device-level bytes/sec figure it
|
| 73 |
+
* teaches the engine is what projects *other* models, and that projection is
|
| 74 |
+
* `estimateSpeed(id)`'s job, not this one's.
|
| 75 |
+
*/
|
| 76 |
+
environment.measure = async ({ tokens = 32 } = {}) => {
|
| 77 |
+
const modelId = engine.state.modelId;
|
| 78 |
+
if (!modelId || engine.resident.length === 0) {
|
| 79 |
+
throw new EngineError(
|
| 80 |
+
ERROR.NO_MODEL,
|
| 81 |
+
"environment.measure() generates in order to measure, so it needs a resident model. " +
|
| 82 |
+
"Call load() first, or use estimateSpeed(id) for a projection.",
|
| 83 |
+
);
|
| 84 |
+
}
|
| 85 |
+
await engine.complete({
|
| 86 |
+
messages: [{ role: "user", content: "Count from one to twenty." }],
|
| 87 |
+
max_tokens: tokens,
|
| 88 |
+
// Calibration must not jump a queue a real caller is waiting in.
|
| 89 |
+
priority: "background",
|
| 90 |
+
preemptible: true,
|
| 91 |
+
});
|
| 92 |
+
return { ...(await engine.estimateSpeed(modelId)), tokens };
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
return environment;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
async function report(engine, { scope = "full", ...rest } = {}) {
|
| 99 |
+
// The one thing an implicit read/write API could not do: notice that a caller
|
| 100 |
+
// meant to write. `configure()` is named in the error because that is the
|
| 101 |
+
// call they wanted.
|
| 102 |
+
const stray = Object.keys(rest);
|
| 103 |
+
if (stray.length) {
|
| 104 |
+
throw new EngineError(
|
| 105 |
+
ERROR.BAD_REQUEST,
|
| 106 |
+
`environment() reports; it does not change anything. ` +
|
| 107 |
+
`To set ${stray.map((k) => `\`${k}\``).join(", ")}, call configure({ ${stray.join(", ")} }).`,
|
| 108 |
+
{ keys: stray },
|
| 109 |
+
);
|
| 110 |
+
}
|
| 111 |
+
if (!SCOPES.has(scope)) {
|
| 112 |
+
throw new EngineError(
|
| 113 |
+
ERROR.BAD_REQUEST,
|
| 114 |
+
`environment() scope must be ${[...SCOPES].map((s) => `"${s}"`).join(", ")}, not "${scope}".`,
|
| 115 |
+
{ scope },
|
| 116 |
+
);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
const device = await engine.probe();
|
| 120 |
+
const lines = [...deviceLines(device)];
|
| 121 |
+
|
| 122 |
+
// A blocked device short-circuits everything below it. "K=15 forward steps
|
| 123 |
+
// per GPU sync" is true and completely useless next to "no model can load" —
|
| 124 |
+
// and burying the one line that matters under four that do not is exactly the
|
| 125 |
+
// failure this report exists to prevent.
|
| 126 |
+
const blocked = lines.some((l) => l.severity === SEVERITY.BLOCKED);
|
| 127 |
+
|
| 128 |
+
if (scope !== "device" && !blocked) {
|
| 129 |
+
const features = await engine.features();
|
| 130 |
+
const settings = await engine.store.getSettings();
|
| 131 |
+
lines.push(...runtimeLines(engine, device, features, settings));
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
// `full` is the only scope that may touch the network: `estimateSpeed` reads
|
| 135 |
+
// the model list, which pulls the ~6 MB WebLLM bundle when prebuilt models
|
| 136 |
+
// are on. `local` exists precisely so a caller can ask cheaply and often.
|
| 137 |
+
let speed = null;
|
| 138 |
+
if (scope === "full" && !blocked && engine.state.modelId) {
|
| 139 |
+
speed = await engine.estimateSpeed().catch(() => null);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
lines.sort((a, b) => RANK.indexOf(a.severity) - RANK.indexOf(b.severity));
|
| 143 |
+
const worst = lines.reduce(
|
| 144 |
+
(acc, l) => (RANK.indexOf(l.severity) < RANK.indexOf(acc) ? l.severity : acc),
|
| 145 |
+
SEVERITY.OK,
|
| 146 |
+
);
|
| 147 |
+
|
| 148 |
+
return {
|
| 149 |
+
scope,
|
| 150 |
+
ok: worst !== SEVERITY.BLOCKED,
|
| 151 |
+
severity: worst,
|
| 152 |
+
device,
|
| 153 |
+
...(speed ? { speed } : {}),
|
| 154 |
+
lines,
|
| 155 |
+
};
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
const line = (id, severity, affects, cause, fix = null) => ({
|
| 159 |
+
id,
|
| 160 |
+
severity,
|
| 161 |
+
affects,
|
| 162 |
+
cause,
|
| 163 |
+
fix,
|
| 164 |
+
operable: fix !== null,
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
function* deviceLines(device) {
|
| 168 |
+
if (!device.webgpu) {
|
| 169 |
+
// `reason` already carries the exact per-browser instruction, and it is not
|
| 170 |
+
// reachable from JS — so it is the fix text, not an operable one.
|
| 171 |
+
yield {
|
| 172 |
+
...line("webgpu", SEVERITY.BLOCKED, "everything — no model can load", device.reason ?? "navigator.gpu is absent"),
|
| 173 |
+
fix: device.reason ?? null,
|
| 174 |
+
operable: false,
|
| 175 |
+
};
|
| 176 |
+
return;
|
| 177 |
+
}
|
| 178 |
+
yield line("webgpu", SEVERITY.OK, null, "navigator.gpu is present and an adapter was granted");
|
| 179 |
+
|
| 180 |
+
const buffers = device.limits?.maxStorageBuffersPerShaderStage;
|
| 181 |
+
if (device.kvReuse === false) {
|
| 182 |
+
yield line(
|
| 183 |
+
"kvReuse",
|
| 184 |
+
SEVERITY.DEGRADED,
|
| 185 |
+
"every turn after the first re-prefills the whole history, so a long conversation waits seconds for its first token",
|
| 186 |
+
`paged prefill binds ${PAGED_PREFILL_STORAGE_BUFFERS} storage buffers per stage; this device allows ${buffers ?? "fewer"}`,
|
| 187 |
+
// Genuinely not operable: it is a driver/browser limit, not a setting.
|
| 188 |
+
null,
|
| 189 |
+
);
|
| 190 |
+
} else if (device.kvReuse) {
|
| 191 |
+
yield line("kvReuse", SEVERITY.OK, null, `storage buffers per stage: ${buffers}`);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
yield device.features?.shaderF16
|
| 195 |
+
? line("shaderF16", SEVERITY.OK, null, "shader-f16 is supported, so q4f16 models run at full speed")
|
| 196 |
+
: line(
|
| 197 |
+
"shaderF16",
|
| 198 |
+
SEVERITY.DEGRADED,
|
| 199 |
+
"q4f16 models fall back to f32 maths, roughly halving decode",
|
| 200 |
+
"the adapter does not expose shader-f16",
|
| 201 |
+
null,
|
| 202 |
+
);
|
| 203 |
+
|
| 204 |
+
const { quota, usage, persisted } = device.storage ?? {};
|
| 205 |
+
if (persisted === false) {
|
| 206 |
+
yield line(
|
| 207 |
+
"persist",
|
| 208 |
+
SEVERITY.TUNE,
|
| 209 |
+
"the browser may evict a multi-GB model under storage pressure, forcing a re-download",
|
| 210 |
+
"storage is not marked persistent",
|
| 211 |
+
"await ensurePersistent() — from everything-webgpu/adapters/idb. One-way, and may prompt.",
|
| 212 |
+
);
|
| 213 |
+
} else if (persisted) {
|
| 214 |
+
yield line("persist", SEVERITY.OK, null, "storage is persistent; models will not be evicted");
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
if (quota && usage !== undefined) {
|
| 218 |
+
const freeGB = (quota - usage) / 1e9;
|
| 219 |
+
yield freeGB < 2
|
| 220 |
+
? line(
|
| 221 |
+
"quota",
|
| 222 |
+
SEVERITY.DEGRADED,
|
| 223 |
+
"a model may fail to cache, or evict one already there",
|
| 224 |
+
`${freeGB.toFixed(1)} GB free of ${(quota / 1e9).toFixed(1)} GB`,
|
| 225 |
+
"Free space, or remove(id) a model you no longer need.",
|
| 226 |
+
)
|
| 227 |
+
: line("quota", SEVERITY.INFO, null, `${freeGB.toFixed(1)} GB free of ${(quota / 1e9).toFixed(1)} GB`);
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
function* runtimeLines(engine, device, features, settings) {
|
| 232 |
+
// The 2d guard's report. Nothing else surfaces it, and its whole point is
|
| 233 |
+
// that the failure is otherwise silent.
|
| 234 |
+
const off = engine.state.decode?.multiStepOff;
|
| 235 |
+
if (off?.length) {
|
| 236 |
+
yield line(
|
| 237 |
+
"multiStepDecoding",
|
| 238 |
+
SEVERITY.DEGRADED,
|
| 239 |
+
"decode fell back to one GPU sync per token — roughly half throughput",
|
| 240 |
+
`the live pipeline is missing ${off.length} tvmjs internal(s): ${off.slice(0, 3).join("; ")}`,
|
| 241 |
+
"This is what a WebLLM upgrade looks like. Run `npm test` (webllm-contract) to see whether the names are gone from the bundle too.",
|
| 242 |
+
);
|
| 243 |
+
} else if (features.multiStepDecoding) {
|
| 244 |
+
yield line("multiStepDecoding", SEVERITY.OK, null, `K=${features.decodeSteps} forward steps per GPU sync`);
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
yield features.decodeSteps === 1
|
| 248 |
+
? line(
|
| 249 |
+
"decodeSteps",
|
| 250 |
+
SEVERITY.TUNE,
|
| 251 |
+
"decode pays one GPU sync per token, which is the ~10 tok/s ceiling",
|
| 252 |
+
"decodeSteps is 1, so multi-step decoding is off",
|
| 253 |
+
"configure({ decodeSteps: 15 })",
|
| 254 |
+
)
|
| 255 |
+
: line(
|
| 256 |
+
"decodeSteps",
|
| 257 |
+
SEVERITY.INFO,
|
| 258 |
+
null,
|
| 259 |
+
`K=${features.decodeSteps}; the best value falls as the model grows, since it is ms/step that fills the 100 ms tick`,
|
| 260 |
+
"configure({ decodeSteps: n })",
|
| 261 |
+
);
|
| 262 |
+
|
| 263 |
+
yield line(
|
| 264 |
+
"engineCount",
|
| 265 |
+
SEVERITY.INFO,
|
| 266 |
+
null,
|
| 267 |
+
`pool cap ${features.maxEngines}, ${features.engines} built; it grows only when a second task waits`,
|
| 268 |
+
"configure({ engineCount: n }) — persisted, and applies to pools built after it",
|
| 269 |
+
);
|
| 270 |
+
|
| 271 |
+
// Observed, not asserted: a build with NO_PASS_MERGE=1 reports ~1.
|
| 272 |
+
const batching = features.computePassBatching;
|
| 273 |
+
if (batching !== null) {
|
| 274 |
+
yield batching > 1.5
|
| 275 |
+
? line("computePassBatching", SEVERITY.OK, null, `${batching.toFixed(1)} kernel launches per flush`)
|
| 276 |
+
: line(
|
| 277 |
+
"computePassBatching",
|
| 278 |
+
SEVERITY.DEGRADED,
|
| 279 |
+
"one compute pass per kernel launch, which measured ~2.5x slower",
|
| 280 |
+
`${batching.toFixed(1)} launches per flush — the build-time patch is not in effect`,
|
| 281 |
+
// Build-time, not runtime: nothing a caller holding the engine can do.
|
| 282 |
+
null,
|
| 283 |
+
);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
if (settings.engineCount && features.maxEngines && settings.engineCount !== features.maxEngines) {
|
| 287 |
+
yield line(
|
| 288 |
+
"engineCountPending",
|
| 289 |
+
SEVERITY.INFO,
|
| 290 |
+
null,
|
| 291 |
+
`engineCount is ${settings.engineCount} but the live pool was built with ${features.maxEngines}`,
|
| 292 |
+
"Reload the model to apply it.",
|
| 293 |
+
);
|
| 294 |
+
}
|
| 295 |
+
}
|
src/engine/errors.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Typed failures.
|
| 3 |
+
*
|
| 4 |
+
* Every error the engine raised used to be a bare string, so a caller wanting
|
| 5 |
+
* to tell "this device has no WebGPU" from "that folder was missing a shard"
|
| 6 |
+
* had to match on prose — which then silently broke whenever the prose
|
| 7 |
+
* improved. A code is the part of an error message that is allowed to be
|
| 8 |
+
* depended on.
|
| 9 |
+
*
|
| 10 |
+
* The codes are deliberately few. Each one exists because a caller does
|
| 11 |
+
* something *different* about it, not because it names a different line of
|
| 12 |
+
* code:
|
| 13 |
+
*
|
| 14 |
+
* NO_WEBGPU tell the user to check flags/hardware; retrying is futile
|
| 15 |
+
* NO_MODEL nothing registered at all — send them to your setup flow
|
| 16 |
+
* UNKNOWN_MODEL that id is not resolvable; `listAvailableModels()` says what is
|
| 17 |
+
* CACHE_INCOMPLETE a locally-registered model was evicted; re-register the folder
|
| 18 |
+
* INVALID_MODEL_FOLDER the folder is not a compiled MLC model; `detail` says what is missing
|
| 19 |
+
* BAD_REQUEST the caller's arguments are wrong; a bug in the caller
|
| 20 |
+
* ABORTED the caller cancelled it; not a failure, and not to be
|
| 21 |
+
* reported to a user as one
|
| 22 |
+
* GENERATION_FAILED the model failed mid-generation
|
| 23 |
+
* PACKAGE_INCOMPLETE this package is not wired into your build correctly —
|
| 24 |
+
* a missing `vendor/` bundle, or a decode worker the
|
| 25 |
+
* bundler did not emit. Fixed once, in the build, by
|
| 26 |
+
* the developer; never branched on at runtime.
|
| 27 |
+
*
|
| 28 |
+
* `PACKAGE_INCOMPLETE` covers two causes with one code on purpose. They are
|
| 29 |
+
* different sentences and `detail.cause` tells them apart, but no caller writes
|
| 30 |
+
* a different `catch` branch for them: both mean "your build is wrong, the app
|
| 31 |
+
* has not shipped yet", and both are fixed by editing config, not by handling
|
| 32 |
+
* an error path. A second code would have grown the table a caller reads
|
| 33 |
+
* without giving that caller anything new to do.
|
| 34 |
+
*
|
| 35 |
+
* `message` stays human-readable and stays the thing you print. `detail`
|
| 36 |
+
* carries whatever structured context the site had — the missing cache keys,
|
| 37 |
+
* the absent field — so a caller never has to parse the sentence.
|
| 38 |
+
*/
|
| 39 |
+
export const ERROR = {
|
| 40 |
+
NO_WEBGPU: "NO_WEBGPU",
|
| 41 |
+
NO_MODEL: "NO_MODEL",
|
| 42 |
+
UNKNOWN_MODEL: "UNKNOWN_MODEL",
|
| 43 |
+
CACHE_INCOMPLETE: "CACHE_INCOMPLETE",
|
| 44 |
+
INVALID_MODEL_FOLDER: "INVALID_MODEL_FOLDER",
|
| 45 |
+
BAD_REQUEST: "BAD_REQUEST",
|
| 46 |
+
ABORTED: "ABORTED",
|
| 47 |
+
GENERATION_FAILED: "GENERATION_FAILED",
|
| 48 |
+
PACKAGE_INCOMPLETE: "PACKAGE_INCOMPLETE",
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
export class EngineError extends Error {
|
| 52 |
+
/**
|
| 53 |
+
* @param {string} code one of ERROR
|
| 54 |
+
* @param {string} message human-readable, safe to show a user
|
| 55 |
+
* @param {object} [detail] structured context, so callers need not parse `message`
|
| 56 |
+
*/
|
| 57 |
+
constructor(code, message, detail) {
|
| 58 |
+
super(message);
|
| 59 |
+
this.name = "EngineError";
|
| 60 |
+
this.code = code;
|
| 61 |
+
if (detail !== undefined) this.detail = detail;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/** Wire form. `error` stays a plain string so existing callers keep working. */
|
| 65 |
+
toJSON() {
|
| 66 |
+
return { code: this.code, message: this.message, ...(this.detail ? { detail: this.detail } : {}) };
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/**
|
| 71 |
+
* Normalise anything thrown into an EngineError.
|
| 72 |
+
*
|
| 73 |
+
* Errors from WebLLM, the GPU stack and the structured-clone boundary arrive as
|
| 74 |
+
* plain Errors, DOMExceptions or strings, and a caller should not have to care
|
| 75 |
+
* which. Anything unrecognised becomes `GENERATION_FAILED` rather than being
|
| 76 |
+
* given a more specific code it has not earned.
|
| 77 |
+
*/
|
| 78 |
+
export function asEngineError(err, fallback = ERROR.GENERATION_FAILED) {
|
| 79 |
+
if (err instanceof EngineError) return err;
|
| 80 |
+
const message = String(err?.message ?? err);
|
| 81 |
+
return new EngineError(fallback, message);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export const isEngineError = (err, code) =>
|
| 85 |
+
err instanceof EngineError && (code === undefined || err.code === code);
|
src/engine/index.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Public entry point.
|
| 3 |
+
*
|
| 4 |
+
* Nothing reachable from here touches `browser.*`. A page, a worker, an
|
| 5 |
+
* extension background page and a Node test all import the same modules; only
|
| 6 |
+
* the StorageAdapter differs (`src/adapters/`).
|
| 7 |
+
*
|
| 8 |
+
* Migrating off `@mlc-ai/web-llm` is one line — the call below is unchanged:
|
| 9 |
+
*
|
| 10 |
+
* ```js
|
| 11 |
+
* import { CreateScheduledEngine } from "everything-webgpu";
|
| 12 |
+
*
|
| 13 |
+
* const engine = await CreateScheduledEngine("Llama-3.2-1B-Instruct-q4f16_1-MLC");
|
| 14 |
+
* const reply = await engine.chat.completions.create({
|
| 15 |
+
* messages: [{ role: "user", content: "hi" }],
|
| 16 |
+
* session: "ghost-text", // added by this engine
|
| 17 |
+
* priority: "interactive", // added by this engine
|
| 18 |
+
* });
|
| 19 |
+
* ```
|
| 20 |
+
*
|
| 21 |
+
* The longer form, when you want to choose the store or the model source:
|
| 22 |
+
*
|
| 23 |
+
* ```js
|
| 24 |
+
* import { ScheduledEngine, ModelStore, ingestModelFolder } from "everything-webgpu";
|
| 25 |
+
* import { indexedDBStorage } from "everything-webgpu/adapters/idb";
|
| 26 |
+
*
|
| 27 |
+
* const engine = new ScheduledEngine({ store: new ModelStore(await indexedDBStorage()) });
|
| 28 |
+
*
|
| 29 |
+
* // Weights arrive by any of three routes. One call covers the latter two.
|
| 30 |
+
* await engine.load("Llama-3.2-1B-Instruct-q4f16_1-MLC"); // prebuilt, from HuggingFace
|
| 31 |
+
*
|
| 32 |
+
* await engine.registerModel({ // a base URL you host
|
| 33 |
+
* modelId: "my-model",
|
| 34 |
+
* model: "/models/my-model/",
|
| 35 |
+
* modelLib: "/models/my-model/my-model-webgpu.wasm",
|
| 36 |
+
* });
|
| 37 |
+
* await engine.registerModel({ modelId: "my-model", files }); // off disk, never any network
|
| 38 |
+
*
|
| 39 |
+
* const { text } = await engine.complete({ messages: [{ role: "user", content: "hi" }] });
|
| 40 |
+
* ```
|
| 41 |
+
*
|
| 42 |
+
* `listAvailableModels()` enumerates all three. Pass `{ prebuilt: false }` to
|
| 43 |
+
* the engine for a build that must never fetch a model over the network.
|
| 44 |
+
*
|
| 45 |
+
* The scheduling fields — `task`, `session`, `priority`, `preemptible` — are
|
| 46 |
+
* what this adds over calling WebLLM directly. See AI.md, "The three shapes of
|
| 47 |
+
* work".
|
| 48 |
+
*/
|
| 49 |
+
export { ScheduledEngine } from "./engine.js";
|
| 50 |
+
export { CreateScheduledEngine } from "./create.js";
|
| 51 |
+
export {
|
| 52 |
+
canRun,
|
| 53 |
+
probeDevice,
|
| 54 |
+
projectSpeed,
|
| 55 |
+
rankModels,
|
| 56 |
+
REFERENCE_DECODE_BYTES_PER_SECOND,
|
| 57 |
+
} from "./device.js";
|
| 58 |
+
export { ERROR, EngineError, asEngineError, isEngineError } from "./errors.js";
|
| 59 |
+
export { EnginePool } from "./pool.js";
|
| 60 |
+
|
| 61 |
+
export {
|
| 62 |
+
ModelStore,
|
| 63 |
+
MODEL_TYPE,
|
| 64 |
+
SOURCE,
|
| 65 |
+
isInjected,
|
| 66 |
+
DEFAULT_SETTINGS,
|
| 67 |
+
CACHE_CONFIG,
|
| 68 |
+
CACHE_MODEL,
|
| 69 |
+
CACHE_WASM,
|
| 70 |
+
baseUrlFor,
|
| 71 |
+
groupKeysByScope,
|
| 72 |
+
toAppConfig,
|
| 73 |
+
formatBytes,
|
| 74 |
+
} from "./model-store.js";
|
| 75 |
+
|
| 76 |
+
export { filesFromDataTransfer, filesFromInput, ingestModelFolder } from "./ingest.js";
|
| 77 |
+
export { prefetchModel, resolveModelUrl } from "./prefetch.js";
|
| 78 |
+
export { ask, conversation, ghostText } from "./recipes.js";
|
| 79 |
+
|
| 80 |
+
export {
|
| 81 |
+
DEFAULT_DECODE_STEPS,
|
| 82 |
+
MAX_DECODE_STEPS,
|
| 83 |
+
burstSize,
|
| 84 |
+
clampSteps,
|
| 85 |
+
installMultiStepDecoding,
|
| 86 |
+
} from "./multistep.js";
|
| 87 |
+
|
| 88 |
+
export { ENGINE_STATE, PRIORITY, PRIORITY_ORDER, UNLOAD_LEVEL, WORKER_CONFIGURE } from "./constants.js";
|
| 89 |
+
export { SEVERITY } from "./environment.js";
|
src/engine/ingest.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Drag-and-drop ingestion: turn a folder of MLC-compiled model artifacts into
|
| 3 |
+
* populated Cache Storage entries, with no network involved at any point.
|
| 4 |
+
*
|
| 5 |
+
* Everything is validated before the first byte is written, so a folder that is
|
| 6 |
+
* missing a shard fails immediately instead of after copying 2 GB.
|
| 7 |
+
*/
|
| 8 |
+
import { ERROR, EngineError } from "./errors.js";
|
| 9 |
+
import { CACHE_CONFIG, CACHE_MODEL, CACHE_WASM, SOURCE, baseUrlFor, toModelType } from "./model-store.js";
|
| 10 |
+
|
| 11 |
+
/** WebLLM asks for this name; older MLC exports ship `ndarray-cache.json`. */
|
| 12 |
+
export const TENSOR_MANIFEST = "tensor-cache.json";
|
| 13 |
+
export const LEGACY_TENSOR_MANIFEST = "ndarray-cache.json";
|
| 14 |
+
export const CHAT_CONFIG = "mlc-chat-config.json";
|
| 15 |
+
|
| 16 |
+
export const CONTENT_TYPES = {
|
| 17 |
+
json: "application/json",
|
| 18 |
+
wasm: "application/wasm",
|
| 19 |
+
bin: "application/octet-stream",
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* Walk a DataTransfer from a drop event into flat `{ path, file }` entries.
|
| 24 |
+
* Uses the entries API so dropping a *folder* works, not just a file selection.
|
| 25 |
+
*/
|
| 26 |
+
export async function filesFromDataTransfer(dataTransfer) {
|
| 27 |
+
// `Array.from` throughout, for the reason `filesFromInput` gives: a
|
| 28 |
+
// DataTransferItemList and a FileList are array-like, and only sometimes
|
| 29 |
+
// iterable. Spreading them threw from inside here, three frames from the drop
|
| 30 |
+
// handler the caller actually wrote.
|
| 31 |
+
const roots = Array.from(dataTransfer.items)
|
| 32 |
+
.filter((item) => item.kind === "file")
|
| 33 |
+
.map((item) => (item.webkitGetAsEntry ? item.webkitGetAsEntry() : null));
|
| 34 |
+
|
| 35 |
+
if (roots.some((entry) => entry === null)) {
|
| 36 |
+
// No entries API: fall back to the flat file list (a folder drop yields nothing).
|
| 37 |
+
return Array.from(dataTransfer.files, (file) => ({
|
| 38 |
+
path: file.webkitRelativePath || file.name,
|
| 39 |
+
file,
|
| 40 |
+
}));
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const out = [];
|
| 44 |
+
await Promise.all(roots.filter(Boolean).map((entry) => walkEntry(entry, "", out)));
|
| 45 |
+
return out;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
async function walkEntry(entry, prefix, out) {
|
| 49 |
+
const path = prefix ? `${prefix}/${entry.name}` : entry.name;
|
| 50 |
+
if (entry.isFile) {
|
| 51 |
+
out.push({ path, file: await new Promise((res, rej) => entry.file(res, rej)) });
|
| 52 |
+
return;
|
| 53 |
+
}
|
| 54 |
+
const reader = entry.createReader();
|
| 55 |
+
// readEntries() returns at most ~100 entries per call; drain it.
|
| 56 |
+
for (;;) {
|
| 57 |
+
const batch = await new Promise((res, rej) => reader.readEntries(res, rej));
|
| 58 |
+
if (batch.length === 0) break;
|
| 59 |
+
await Promise.all(batch.map((child) => walkEntry(child, path, out)));
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Turns `<input webkitdirectory>` output into the same `{ path, file }` shape.
|
| 65 |
+
*
|
| 66 |
+
* `Array.from`, not spread: a real `FileList` is iterable, but plenty of things
|
| 67 |
+
* that behave like one are only array-like, and spreading those fails with
|
| 68 |
+
* "fileList is not iterable" — an error that names none of the three places it
|
| 69 |
+
* could have come from. Array.from accepts both.
|
| 70 |
+
*/
|
| 71 |
+
export function filesFromInput(fileList) {
|
| 72 |
+
return Array.from(fileList, (file) => ({
|
| 73 |
+
path: file.webkitRelativePath || file.name,
|
| 74 |
+
file,
|
| 75 |
+
}));
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/**
|
| 79 |
+
* @param {Array<{path: string, file: File}>} entries
|
| 80 |
+
* @param {object} opts
|
| 81 |
+
* @param {import("./model-store.js").ModelStore} opts.store where the registry entry lands
|
| 82 |
+
* @param {string} [opts.modelId] overrides the id inferred from the folder name
|
| 83 |
+
* @param {"llm"|"embedding"|"vlm"} [opts.modelType] declare a vision model, or WebLLM
|
| 84 |
+
* rejects every image sent to it
|
| 85 |
+
* @param {(p: {phase: string, done: number, total: number, label: string}) => void} [opts.onProgress]
|
| 86 |
+
* @returns {Promise<object>} the saved registry record
|
| 87 |
+
*/
|
| 88 |
+
export async function ingestModelFolder(entries, { store, modelId, modelType, onProgress = () => {} } = {}) {
|
| 89 |
+
if (!store) {
|
| 90 |
+
throw new EngineError(ERROR.BAD_REQUEST, "ingestModelFolder needs a `store` to save the registry entry into.");
|
| 91 |
+
}
|
| 92 |
+
if (!entries?.length) {
|
| 93 |
+
throw new EngineError(ERROR.INVALID_MODEL_FOLDER, "Nothing was dropped — expected a model folder.", {
|
| 94 |
+
reason: "empty",
|
| 95 |
+
});
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const byPath = new Map();
|
| 99 |
+
const byName = new Map();
|
| 100 |
+
for (const { path, file } of entries) {
|
| 101 |
+
const relative = stripRoot(path);
|
| 102 |
+
byPath.set(relative, file);
|
| 103 |
+
// Last writer wins; `find()` prefers the exact relative path anyway.
|
| 104 |
+
byName.set(basename(relative), file);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
const find = (name) => byPath.get(name) ?? byName.get(basename(name));
|
| 108 |
+
|
| 109 |
+
onProgress({ phase: "validating", done: 0, total: 1, label: "Reading manifests" });
|
| 110 |
+
|
| 111 |
+
const configFile = find(CHAT_CONFIG);
|
| 112 |
+
if (!configFile) {
|
| 113 |
+
throw new EngineError(
|
| 114 |
+
ERROR.INVALID_MODEL_FOLDER,
|
| 115 |
+
`Missing ${CHAT_CONFIG}. Use the folder produced by \`mlc_llm convert_weights\` + \`gen_config\`, not a raw HuggingFace checkpoint.`,
|
| 116 |
+
{ reason: "missing-config", missing: [CHAT_CONFIG] },
|
| 117 |
+
);
|
| 118 |
+
}
|
| 119 |
+
const chatConfig = await readJson(configFile, CHAT_CONFIG);
|
| 120 |
+
|
| 121 |
+
const tensorFile = find(TENSOR_MANIFEST) ?? find(LEGACY_TENSOR_MANIFEST);
|
| 122 |
+
if (!tensorFile) {
|
| 123 |
+
throw new EngineError(
|
| 124 |
+
ERROR.INVALID_MODEL_FOLDER,
|
| 125 |
+
`Missing ${TENSOR_MANIFEST} (or legacy ${LEGACY_TENSOR_MANIFEST}) — the weight shard index.`,
|
| 126 |
+
{ reason: "missing-manifest", missing: [TENSOR_MANIFEST] },
|
| 127 |
+
);
|
| 128 |
+
}
|
| 129 |
+
const isLegacyManifest = !find(TENSOR_MANIFEST);
|
| 130 |
+
const tensorManifest = await readJson(tensorFile, tensorFile.name);
|
| 131 |
+
|
| 132 |
+
const records = tensorManifest.records;
|
| 133 |
+
if (!Array.isArray(records) || records.length === 0) {
|
| 134 |
+
throw new EngineError(
|
| 135 |
+
ERROR.INVALID_MODEL_FOLDER,
|
| 136 |
+
`${tensorFile.name} has no "records" array — it is not an MLC weight index.`,
|
| 137 |
+
{ reason: "malformed-manifest" },
|
| 138 |
+
);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
const shardPaths = records.map((r) => r.dataPath).filter(Boolean);
|
| 142 |
+
if (shardPaths.length !== records.length) {
|
| 143 |
+
throw new EngineError(
|
| 144 |
+
ERROR.INVALID_MODEL_FOLDER,
|
| 145 |
+
`${tensorFile.name} has records without a "dataPath".`,
|
| 146 |
+
{ reason: "malformed-manifest" },
|
| 147 |
+
);
|
| 148 |
+
}
|
| 149 |
+
const missingShards = shardPaths.filter((p) => !find(p));
|
| 150 |
+
if (missingShards.length) {
|
| 151 |
+
throw new EngineError(
|
| 152 |
+
ERROR.INVALID_MODEL_FOLDER,
|
| 153 |
+
`${missingShards.length} weight shard(s) missing from the folder: ${missingShards.slice(0, 5).join(", ")}${missingShards.length > 5 ? ", …" : ""}`,
|
| 154 |
+
{ reason: "missing-shards", missing: missingShards },
|
| 155 |
+
);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
const tokenizerNames = Array.isArray(chatConfig.tokenizer_files) ? chatConfig.tokenizer_files : [];
|
| 159 |
+
const tokenizerName = ["tokenizer.json", "tokenizer.model"].find(
|
| 160 |
+
(name) => tokenizerNames.includes(name) && find(name),
|
| 161 |
+
);
|
| 162 |
+
if (!tokenizerName) {
|
| 163 |
+
throw new EngineError(
|
| 164 |
+
ERROR.INVALID_MODEL_FOLDER,
|
| 165 |
+
`No usable tokenizer. ${CHAT_CONFIG} lists [${tokenizerNames.join(", ") || "nothing"}], and neither tokenizer.json nor tokenizer.model is present in the folder.`,
|
| 166 |
+
{ reason: "missing-tokenizer", expected: tokenizerNames },
|
| 167 |
+
);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
const wasmEntries = [...byPath.entries()].filter(([p]) => p.endsWith(".wasm"));
|
| 171 |
+
if (wasmEntries.length === 0) {
|
| 172 |
+
throw new EngineError(
|
| 173 |
+
ERROR.INVALID_MODEL_FOLDER,
|
| 174 |
+
"No .wasm model library found. Add the matching `*-webgpu.wasm` from mlc-ai/binary-mlc-llm-libs to the folder.",
|
| 175 |
+
{ reason: "missing-wasm" },
|
| 176 |
+
);
|
| 177 |
+
}
|
| 178 |
+
if (wasmEntries.length > 1) {
|
| 179 |
+
throw new EngineError(
|
| 180 |
+
ERROR.INVALID_MODEL_FOLDER,
|
| 181 |
+
`Found ${wasmEntries.length} .wasm files (${wasmEntries.map(([p]) => p).join(", ")}); the folder must contain exactly one model library.`,
|
| 182 |
+
{ reason: "ambiguous-wasm", found: wasmEntries.map(([p]) => p) },
|
| 183 |
+
);
|
| 184 |
+
}
|
| 185 |
+
const [wasmPath, wasmFile] = wasmEntries[0];
|
| 186 |
+
const wasmName = basename(wasmPath);
|
| 187 |
+
|
| 188 |
+
const id = (modelId || inferModelId(entries) || wasmName.replace(/(-webgpu)?\.wasm$/, "")).trim();
|
| 189 |
+
if (!id) {
|
| 190 |
+
throw new EngineError(
|
| 191 |
+
ERROR.INVALID_MODEL_FOLDER,
|
| 192 |
+
"Could not determine a model id — name the folder after the model, or pass `modelId`.",
|
| 193 |
+
{ reason: "no-model-id" },
|
| 194 |
+
);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
const base = baseUrlFor(id);
|
| 198 |
+
|
| 199 |
+
// Everything validated: build the write plan.
|
| 200 |
+
const plan = [
|
| 201 |
+
{ scope: CACHE_CONFIG, url: base + CHAT_CONFIG, file: configFile, type: CONTENT_TYPES.json },
|
| 202 |
+
{ scope: CACHE_MODEL, url: base + TENSOR_MANIFEST, file: tensorFile, type: CONTENT_TYPES.json },
|
| 203 |
+
{
|
| 204 |
+
scope: CACHE_MODEL,
|
| 205 |
+
url: base + tokenizerName,
|
| 206 |
+
file: find(tokenizerName),
|
| 207 |
+
type: tokenizerName.endsWith(".json") ? CONTENT_TYPES.json : CONTENT_TYPES.bin,
|
| 208 |
+
},
|
| 209 |
+
{ scope: CACHE_WASM, url: base + wasmName, file: wasmFile, type: CONTENT_TYPES.wasm },
|
| 210 |
+
...shardPaths.map((p) => ({
|
| 211 |
+
scope: CACHE_MODEL,
|
| 212 |
+
url: new URL(p, base).href,
|
| 213 |
+
file: find(p),
|
| 214 |
+
type: CONTENT_TYPES.bin,
|
| 215 |
+
})),
|
| 216 |
+
];
|
| 217 |
+
if (isLegacyManifest) {
|
| 218 |
+
// Keep the original name addressable too, so a future WebLLM that reverts
|
| 219 |
+
// to `ndarray-cache.json` still hits cache.
|
| 220 |
+
plan.push({
|
| 221 |
+
scope: CACHE_MODEL,
|
| 222 |
+
url: base + LEGACY_TENSOR_MANIFEST,
|
| 223 |
+
file: tensorFile,
|
| 224 |
+
type: CONTENT_TYPES.json,
|
| 225 |
+
});
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
const openCaches = new Map();
|
| 229 |
+
let done = 0;
|
| 230 |
+
for (const item of plan) {
|
| 231 |
+
if (!openCaches.has(item.scope)) openCaches.set(item.scope, await caches.open(item.scope));
|
| 232 |
+
onProgress({ phase: "writing", done, total: plan.length, label: basename(item.url) });
|
| 233 |
+
await openCaches.get(item.scope).put(
|
| 234 |
+
new Request(item.url),
|
| 235 |
+
new Response(item.file, { status: 200, headers: { "Content-Type": item.type } }),
|
| 236 |
+
);
|
| 237 |
+
done += 1;
|
| 238 |
+
}
|
| 239 |
+
onProgress({ phase: "writing", done, total: plan.length, label: "done" });
|
| 240 |
+
|
| 241 |
+
const keys = { [CACHE_CONFIG]: [], [CACHE_MODEL]: [], [CACHE_WASM]: [] };
|
| 242 |
+
for (const item of plan) keys[item.scope].push(item.url);
|
| 243 |
+
|
| 244 |
+
return store.save({
|
| 245 |
+
model_id: id,
|
| 246 |
+
model: base,
|
| 247 |
+
model_lib: base + wasmName,
|
| 248 |
+
source: SOURCE.INJECTED,
|
| 249 |
+
...(toModelType(modelType) !== undefined ? { model_type: toModelType(modelType) } : {}),
|
| 250 |
+
...(chatConfig.context_window_size > 0
|
| 251 |
+
? { overrides: { context_window_size: chatConfig.context_window_size } }
|
| 252 |
+
: {}),
|
| 253 |
+
keys,
|
| 254 |
+
sizeBytes: plan.reduce((sum, item) => sum + item.file.size, 0),
|
| 255 |
+
fileCount: plan.length,
|
| 256 |
+
shardCount: shardPaths.length,
|
| 257 |
+
tokenizer: tokenizerName,
|
| 258 |
+
wasm: wasmName,
|
| 259 |
+
addedAt: new Date().toISOString(),
|
| 260 |
+
});
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
async function readJson(file, label) {
|
| 264 |
+
try {
|
| 265 |
+
return JSON.parse(await file.text());
|
| 266 |
+
} catch (err) {
|
| 267 |
+
throw new EngineError(ERROR.INVALID_MODEL_FOLDER, `${label} is not valid JSON: ${err.message}`, {
|
| 268 |
+
reason: "malformed-json",
|
| 269 |
+
file: label,
|
| 270 |
+
});
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
/** Drops the dropped-folder name so `Qwen3-4B/tokenizer.json` keys as `tokenizer.json`. */
|
| 275 |
+
function stripRoot(path) {
|
| 276 |
+
const parts = path.split("/");
|
| 277 |
+
return parts.length > 1 ? parts.slice(1).join("/") : path;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
function basename(path) {
|
| 281 |
+
return path.split("/").pop();
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
/**
|
| 285 |
+
* A dropped *folder* gives every entry the same first path segment; a flat
|
| 286 |
+
* multi-file selection gives each entry a bare filename. Only the former names
|
| 287 |
+
* the model.
|
| 288 |
+
*/
|
| 289 |
+
function inferModelId(entries) {
|
| 290 |
+
const roots = new Set(
|
| 291 |
+
entries.filter((e) => e.path.includes("/")).map((e) => e.path.split("/")[0]),
|
| 292 |
+
);
|
| 293 |
+
return roots.size === 1 ? [...roots][0] : "";
|
| 294 |
+
}
|
src/engine/model-store.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* The model registry, and the Cache Storage layout WebLLM expects.
|
| 3 |
+
*
|
| 4 |
+
* A model reaches the engine by one of three routes. They differ only in where
|
| 5 |
+
* the weights are fetched from; everything downstream is identical, because all
|
| 6 |
+
* three end up as one `model_list` entry WebLLM's own loader resolves.
|
| 7 |
+
*
|
| 8 |
+
* prebuilt one of the 163 entries in WebLLM's `prebuiltAppConfig`, on
|
| 9 |
+
* HuggingFace. Nothing to register: `load("Llama-3.2-1B-Instruct-
|
| 10 |
+
* q4f16_1-MLC")` just works.
|
| 11 |
+
* remote `registerModel({ model, modelLib })` with any base URL — an HF
|
| 12 |
+
* repo, your own CDN, a path on your own origin, localhost. This
|
| 13 |
+
* is how a developer points the engine at weights they host.
|
| 14 |
+
* injected `ingestModelFolder()` writes a local folder straight into Cache
|
| 15 |
+
* Storage. No network at any point, for offline or private builds.
|
| 16 |
+
*
|
| 17 |
+
* The injected route mints a synthetic https base URL and pre-populates the
|
| 18 |
+
* exact cache scopes/keys WebLLM's loader would have populated from the
|
| 19 |
+
* network, so `reload()` finds every artifact already cached and issues zero
|
| 20 |
+
* requests. That origin is `.invalid` on purpose: it can never resolve, so an
|
| 21 |
+
* injected model whose cache was evicted fails loudly instead of quietly
|
| 22 |
+
* pulling a gigabyte off the network. Enabling downloads for the other two
|
| 23 |
+
* routes cannot weaken that guarantee.
|
| 24 |
+
*
|
| 25 |
+
* Registry and settings hang off a `ModelStore` holding an injected
|
| 26 |
+
* `StorageAdapter`. That is the whole reason this file is no longer
|
| 27 |
+
* extension-bound: `browser.storage.local` was the only WebExtension API in the
|
| 28 |
+
* engine core outside the router.
|
| 29 |
+
*
|
| 30 |
+
* Cache Storage is deliberately *not* injected. `caches` exists in every secure
|
| 31 |
+
* context, and the cache keys are the contract with WebLLM's loader — putting
|
| 32 |
+
* an abstraction over them would hide the one thing that has to stay exact.
|
| 33 |
+
*
|
| 34 |
+
* Cache scopes (must stay in sync with @mlc-ai/web-llm):
|
| 35 |
+
* webllm/config -> <base>mlc-chat-config.json
|
| 36 |
+
* webllm/model -> <base>tensor-cache.json, tokenizer file, every shard
|
| 37 |
+
* webllm/wasm -> <base><lib>.wasm
|
| 38 |
+
*/
|
| 39 |
+
import { ERROR, EngineError } from "./errors.js";
|
| 40 |
+
|
| 41 |
+
export const CACHE_CONFIG = "webllm/config";
|
| 42 |
+
export const CACHE_MODEL = "webllm/model";
|
| 43 |
+
export const CACHE_WASM = "webllm/wasm";
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* `.invalid` is reserved by RFC 6761 and can never resolve, so a bug that skips
|
| 47 |
+
* the cache surfaces as a DNS failure instead of a silent download.
|
| 48 |
+
* The `/resolve/main/` suffix makes WebLLM's `cleanModelUrl()` a no-op.
|
| 49 |
+
*/
|
| 50 |
+
const VIRTUAL_ORIGIN = "https://local-model.invalid";
|
| 51 |
+
|
| 52 |
+
const STORAGE_KEY = "models";
|
| 53 |
+
const SETTINGS_KEY = "settings";
|
| 54 |
+
|
| 55 |
+
export const DEFAULT_SETTINGS = {
|
| 56 |
+
/** Empty list = every installed extension may call the API. Wire adapter only. */
|
| 57 |
+
allowedExternalIds: [],
|
| 58 |
+
/**
|
| 59 |
+
* Engines held in the pool. Each is a full copy of the weights in VRAM and a
|
| 60 |
+
* full load, but concurrent generations each get their own ~10 tok/s, so this
|
| 61 |
+
* is the only dial that raises total throughput. 2 is the smallest number
|
| 62 |
+
* that delivers any parallelism at all.
|
| 63 |
+
*/
|
| 64 |
+
engineCount: 2,
|
| 65 |
+
/**
|
| 66 |
+
* Forward steps per GPU->CPU sync (vLLM's `--num-scheduler-steps`). Decode is
|
| 67 |
+
* sync-bound, not compute-bound, so this is the only dial that raises
|
| 68 |
+
* *single-stream* throughput — `engineCount` raises aggregate throughput.
|
| 69 |
+
*
|
| 70 |
+
* 15 is vLLM's documented cap and this engine's default. Unlike vLLM the
|
| 71 |
+
* win here is quantized by Firefox's 100 ms poll, so the best value is the
|
| 72 |
+
* largest K whose burst still fits inside one tick, and it shrinks as the
|
| 73 |
+
* model grows. See src/engine/multistep.js and `npm run e2e -- --steps`.
|
| 74 |
+
*/
|
| 75 |
+
decodeSteps: 15,
|
| 76 |
+
/**
|
| 77 |
+
* `buildParams` puts this on every request, so it shadows whatever
|
| 78 |
+
* `mlc-chat-config.json` ships as the model's own default — unlike `top_p`,
|
| 79 |
+
* which is never injected and so comes from the model. 0.6 is what the
|
| 80 |
+
* Qwen3.8-2B-Distill card asks for; reasoning models in this class are prone
|
| 81 |
+
* to repetition loops when decoding is too close to greedy.
|
| 82 |
+
*/
|
| 83 |
+
temperature: 0.6,
|
| 84 |
+
maxTokens: 1024,
|
| 85 |
+
systemPrompt: "",
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
/**
|
| 89 |
+
* Make a base URL absolute, at registration rather than at load.
|
| 90 |
+
*
|
| 91 |
+
* WebLLM's `cleanModelUrl` ends in `new URL(url)` with no base, so it throws on
|
| 92 |
+
* a relative path — `/models/my-model/` fails deep inside the loader, long
|
| 93 |
+
* after the caller could tell why. Resolving here means a relative path works
|
| 94 |
+
* as documented, and a context with no page URL to resolve against says so
|
| 95 |
+
* immediately instead of at load time.
|
| 96 |
+
*/
|
| 97 |
+
function absolutize(url, field) {
|
| 98 |
+
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return url;
|
| 99 |
+
const base = globalThis.location?.href;
|
| 100 |
+
if (!base) {
|
| 101 |
+
throw new EngineError(
|
| 102 |
+
ERROR.BAD_REQUEST,
|
| 103 |
+
`\`${field}\` is relative ("${url}") and this context has no page URL to resolve it against. ` +
|
| 104 |
+
"Pass an absolute URL.",
|
| 105 |
+
{ field, value: url },
|
| 106 |
+
);
|
| 107 |
+
}
|
| 108 |
+
return new URL(url, base).href;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
export function baseUrlFor(modelId) {
|
| 112 |
+
return `${VIRTUAL_ORIGIN}/${encodeURIComponent(modelId)}/resolve/main/`;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/** Every cache key a record claims, keyed by cache scope. */
|
| 116 |
+
export function groupKeysByScope(record) {
|
| 117 |
+
return {
|
| 118 |
+
[CACHE_CONFIG]: record.keys?.[CACHE_CONFIG] ?? [],
|
| 119 |
+
[CACHE_MODEL]: record.keys?.[CACHE_MODEL] ?? [],
|
| 120 |
+
[CACHE_WASM]: record.keys?.[CACHE_WASM] ?? [],
|
| 121 |
+
};
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/**
|
| 125 |
+
* WebLLM's own `ModelType` enum, which it reads off the `model_list` entry.
|
| 126 |
+
*
|
| 127 |
+
* This matters for one reason: WebLLM refuses image content on anything not
|
| 128 |
+
* marked `VLM` (`UserMessageContentErrorForNonVLM`). It cannot be inferred —
|
| 129 |
+
* `mlc-chat-config.json` carries the architecture name, not this — so a
|
| 130 |
+
* locally compiled vision model has to declare it or every image is rejected
|
| 131 |
+
* with a confusing error.
|
| 132 |
+
*/
|
| 133 |
+
export const MODEL_TYPE = { llm: 0, embedding: 1, vlm: 2 };
|
| 134 |
+
|
| 135 |
+
/** Accepts `"vlm"`, `MODEL_TYPE.vlm`, or nothing. */
|
| 136 |
+
export function toModelType(value) {
|
| 137 |
+
if (value === undefined || value === null) return undefined;
|
| 138 |
+
if (typeof value === "number") return value;
|
| 139 |
+
const known = MODEL_TYPE[String(value).toLowerCase()];
|
| 140 |
+
if (known === undefined) {
|
| 141 |
+
throw new EngineError(
|
| 142 |
+
ERROR.BAD_REQUEST,
|
| 143 |
+
`Unknown modelType "${value}". Expected one of: ${Object.keys(MODEL_TYPE).join(", ")}.`,
|
| 144 |
+
{ modelType: value },
|
| 145 |
+
);
|
| 146 |
+
}
|
| 147 |
+
return known;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/** How a record's weights are obtained. See the header. */
|
| 151 |
+
export const SOURCE = {
|
| 152 |
+
PREBUILT: "prebuilt",
|
| 153 |
+
REMOTE: "remote",
|
| 154 |
+
INJECTED: "injected",
|
| 155 |
+
};
|
| 156 |
+
|
| 157 |
+
/**
|
| 158 |
+
* Whether this record's bytes live in Cache Storage and nowhere else.
|
| 159 |
+
*
|
| 160 |
+
* The distinction that matters: an injected model that loses its cache is
|
| 161 |
+
* unrecoverable and must be re-ingested, so `verify()` gates its load. A remote
|
| 162 |
+
* or prebuilt one just re-downloads, so eviction is a slow load, not an error.
|
| 163 |
+
*/
|
| 164 |
+
export const isInjected = (record) => record?.source === SOURCE.INJECTED;
|
| 165 |
+
|
| 166 |
+
function toModelListEntry(record) {
|
| 167 |
+
return {
|
| 168 |
+
model: record.model,
|
| 169 |
+
model_id: record.model_id,
|
| 170 |
+
model_lib: record.model_lib,
|
| 171 |
+
// Carried through, or WebLLM treats a locally registered VLM as text-only.
|
| 172 |
+
...(record.model_type !== undefined ? { model_type: record.model_type } : {}),
|
| 173 |
+
...(record.overrides ? { overrides: record.overrides } : {}),
|
| 174 |
+
...(record.vram_required_MB ? { vram_required_MB: record.vram_required_MB } : {}),
|
| 175 |
+
};
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
/**
|
| 179 |
+
* Shape WebLLM's `appConfig` from the registry, optionally over its own
|
| 180 |
+
* prebuilt list.
|
| 181 |
+
*
|
| 182 |
+
* Registered records win on a model_id collision, so a developer can shadow a
|
| 183 |
+
* prebuilt entry — point `Llama-3.2-1B-Instruct-q4f16_1-MLC` at their own
|
| 184 |
+
* mirror, say — without renaming it and breaking their callers.
|
| 185 |
+
*
|
| 186 |
+
* @param {Array<object>} models registered records
|
| 187 |
+
* @param {{model_list: Array<object>} | null} [prebuilt] WebLLM's `prebuiltAppConfig`
|
| 188 |
+
*/
|
| 189 |
+
export function toAppConfig(models, prebuilt = null) {
|
| 190 |
+
const own = models.map(toModelListEntry);
|
| 191 |
+
const owned = new Set(own.map((e) => e.model_id));
|
| 192 |
+
const rest = (prebuilt?.model_list ?? []).filter((e) => !owned.has(e.model_id));
|
| 193 |
+
return { model_list: [...own, ...rest], useIndexedDBCache: false };
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
export function formatBytes(bytes) {
|
| 197 |
+
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
| 198 |
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
| 199 |
+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
| 200 |
+
return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
/**
|
| 204 |
+
* @typedef {object} StorageAdapter
|
| 205 |
+
* @property {(key: string | string[]) => Promise<object>} get
|
| 206 |
+
* @property {(items: object) => Promise<void>} set
|
| 207 |
+
*
|
| 208 |
+
* Two methods, deliberately the exact shape of `browser.storage.local`, so the
|
| 209 |
+
* WebExtension adapter is a passthrough rather than a translation layer. See
|
| 210 |
+
* `src/adapters/` for the three implementations.
|
| 211 |
+
*/
|
| 212 |
+
|
| 213 |
+
export class ModelStore {
|
| 214 |
+
/** @type {StorageAdapter} */
|
| 215 |
+
#storage;
|
| 216 |
+
|
| 217 |
+
/** @param {StorageAdapter} storage */
|
| 218 |
+
constructor(storage) {
|
| 219 |
+
if (!storage?.get || !storage?.set) {
|
| 220 |
+
throw new EngineError(ERROR.BAD_REQUEST, "ModelStore needs a StorageAdapter with `get(key)` and `set(obj)`.");
|
| 221 |
+
}
|
| 222 |
+
this.#storage = storage;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
/** @returns {Promise<Array<object>>} registered model records, newest first. */
|
| 226 |
+
async list() {
|
| 227 |
+
const { [STORAGE_KEY]: models } = await this.#storage.get(STORAGE_KEY);
|
| 228 |
+
return Array.isArray(models) ? models : [];
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
async get(modelId) {
|
| 232 |
+
return (await this.list()).find((m) => m.model_id === modelId);
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
/**
|
| 236 |
+
* Register weights the engine should fetch rather than find already cached.
|
| 237 |
+
*
|
| 238 |
+
* This is the whole "developer configures a read path" surface: `model` is a
|
| 239 |
+
* base URL and nothing more, so an HF repo, your own CDN, a folder served off
|
| 240 |
+
* your own origin and a localhost dev server are all the same call.
|
| 241 |
+
*
|
| 242 |
+
* ```js
|
| 243 |
+
* await store.registerModel({
|
| 244 |
+
* modelId: "Qwen3.8-2B-q4f16_1-MLC",
|
| 245 |
+
* model: "/models/Qwen3.8-2B-q4f16_1-MLC/",
|
| 246 |
+
* modelLib: "/models/Qwen3.8-2B-q4f16_1-MLC/Qwen3.8-2B-q4f16_1-webgpu.wasm",
|
| 247 |
+
* });
|
| 248 |
+
* ```
|
| 249 |
+
*
|
| 250 |
+
* No validation of the URLs happens here — there is nothing to validate
|
| 251 |
+
* without fetching, and WebLLM's loader reports a bad base URL far better
|
| 252 |
+
* than a HEAD request would. Contrast `ingestModelFolder`, which validates
|
| 253 |
+
* exhaustively because it is about to write a gigabyte.
|
| 254 |
+
*/
|
| 255 |
+
async registerModel({ modelId, model, modelLib, contextWindow, vramRequiredMB, modelType }) {
|
| 256 |
+
const missing = [
|
| 257 |
+
["modelId", modelId],
|
| 258 |
+
["model", model],
|
| 259 |
+
["modelLib", modelLib],
|
| 260 |
+
].filter(([, v]) => !v);
|
| 261 |
+
if (missing.length) {
|
| 262 |
+
throw new EngineError(
|
| 263 |
+
ERROR.BAD_REQUEST,
|
| 264 |
+
`registerModel needs ${missing.map(([k]) => `\`${k}\``).join(", ")}. ` +
|
| 265 |
+
"Pass `files` instead to register a local folder.",
|
| 266 |
+
{ missing: missing.map(([k]) => k) },
|
| 267 |
+
);
|
| 268 |
+
}
|
| 269 |
+
return this.save({
|
| 270 |
+
model_id: modelId,
|
| 271 |
+
model: absolutize(model, "model"),
|
| 272 |
+
model_lib: absolutize(modelLib, "modelLib"),
|
| 273 |
+
source: SOURCE.REMOTE,
|
| 274 |
+
...(toModelType(modelType) !== undefined ? { model_type: toModelType(modelType) } : {}),
|
| 275 |
+
...(contextWindow > 0 ? { overrides: { context_window_size: contextWindow } } : {}),
|
| 276 |
+
...(vramRequiredMB > 0 ? { vram_required_MB: vramRequiredMB } : {}),
|
| 277 |
+
addedAt: new Date().toISOString(),
|
| 278 |
+
});
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
async save(record) {
|
| 282 |
+
const models = (await this.list()).filter((m) => m.model_id !== record.model_id);
|
| 283 |
+
models.unshift(record);
|
| 284 |
+
await this.#storage.set({ [STORAGE_KEY]: models });
|
| 285 |
+
return record;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
/**
|
| 289 |
+
* Frees an **injected** model's bytes; keeps the registry entry.
|
| 290 |
+
*
|
| 291 |
+
* Scoped to injected models deliberately. We wrote those artifacts, so we
|
| 292 |
+
* hold the only manifest of them — which also makes this stronger than
|
| 293 |
+
* WebLLM's equivalent *for this case*: `deleteModelAllInfoInCache` reads
|
| 294 |
+
* `tensor-cache.json` to enumerate shards, so it cannot clean up after that
|
| 295 |
+
* file has itself been evicted. An explicit key list has no such failure.
|
| 296 |
+
*
|
| 297 |
+
* Remote and prebuilt models are WebLLM's to delete — it fetched them and
|
| 298 |
+
* derives their keys exactly as its loader did. `ScheduledEngine.evict()`
|
| 299 |
+
* routes to whichever owns the model.
|
| 300 |
+
*
|
| 301 |
+
* @returns {Promise<{freedKeys: number}>}
|
| 302 |
+
*/
|
| 303 |
+
async evictInjected(modelId) {
|
| 304 |
+
const record = await this.get(modelId);
|
| 305 |
+
if (!record) throw new EngineError(ERROR.UNKNOWN_MODEL, `Unknown model "${modelId}"`, { modelId });
|
| 306 |
+
|
| 307 |
+
let freedKeys = 0;
|
| 308 |
+
for (const [scope, urls] of Object.entries(groupKeysByScope(record))) {
|
| 309 |
+
if (urls.length === 0) continue;
|
| 310 |
+
const cache = await caches.open(scope);
|
| 311 |
+
for (const url of urls) if (await cache.delete(url)) freedKeys += 1;
|
| 312 |
+
}
|
| 313 |
+
return { freedKeys };
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
/**
|
| 317 |
+
* The registry-only primitive: drops the entry, plus the cache keys an
|
| 318 |
+
* **injected** record lists.
|
| 319 |
+
*
|
| 320 |
+
* A remote record claims no keys, so this frees nothing for it — WebLLM
|
| 321 |
+
* downloaded those bytes and derives their names. Use
|
| 322 |
+
* `ScheduledEngine.remove()` for a full teardown; calling this directly on a
|
| 323 |
+
* remote model leaks its shards, because deleting the record also destroys
|
| 324 |
+
* the only record of where they came from.
|
| 325 |
+
*/
|
| 326 |
+
async remove(modelId) {
|
| 327 |
+
const record = await this.get(modelId);
|
| 328 |
+
if (!record) throw new EngineError(ERROR.UNKNOWN_MODEL, `Unknown model "${modelId}"`, { modelId });
|
| 329 |
+
|
| 330 |
+
for (const [scope, urls] of Object.entries(groupKeysByScope(record))) {
|
| 331 |
+
const cache = await caches.open(scope);
|
| 332 |
+
await Promise.all(urls.map((url) => cache.delete(url)));
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
const models = (await this.list()).filter((m) => m.model_id !== modelId);
|
| 336 |
+
await this.#storage.set({ [STORAGE_KEY]: models });
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
/**
|
| 340 |
+
* Confirms the caches still hold everything the record promised.
|
| 341 |
+
*
|
| 342 |
+
* Storage eviction is silent, so this is what stands between a stale registry
|
| 343 |
+
* entry and WebLLM trying to fetch `local-model.invalid` mid-load. Inside a
|
| 344 |
+
* WebExtension with `unlimitedStorage` this was a defensive check; on an
|
| 345 |
+
* ordinary page origin, where a multi-GB model is evictable unless
|
| 346 |
+
* `navigator.storage.persist()` was granted, it is a core mechanism.
|
| 347 |
+
*
|
| 348 |
+
* @returns {Promise<{ok: boolean, missing: string[]}>}
|
| 349 |
+
*/
|
| 350 |
+
async verify(record) {
|
| 351 |
+
const missing = [];
|
| 352 |
+
for (const [scope, urls] of Object.entries(groupKeysByScope(record))) {
|
| 353 |
+
if (urls.length === 0) continue;
|
| 354 |
+
const cache = await caches.open(scope);
|
| 355 |
+
const present = new Set((await cache.keys()).map((req) => req.url));
|
| 356 |
+
for (const url of urls) if (!present.has(url)) missing.push(url);
|
| 357 |
+
}
|
| 358 |
+
return { ok: missing.length === 0, missing };
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
async getSettings() {
|
| 362 |
+
const { [SETTINGS_KEY]: settings } = await this.#storage.get(SETTINGS_KEY);
|
| 363 |
+
return { ...DEFAULT_SETTINGS, ...(settings ?? {}) };
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
async setSettings(patch) {
|
| 367 |
+
const next = { ...(await this.getSettings()), ...patch };
|
| 368 |
+
await this.#storage.set({ [SETTINGS_KEY]: next });
|
| 369 |
+
return next;
|
| 370 |
+
}
|
| 371 |
+
}
|
src/engine/multistep.js
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Multi-step decoding: N forward steps per GPU->CPU sync.
|
| 3 |
+
*
|
| 4 |
+
* Why this exists: decode here is not compute-bound, it is *sync*-bound. Firefox
|
| 5 |
+
* resolves `onSubmittedWorkDone()` / `mapAsync()` only on a 100 ms poll tick
|
| 6 |
+
* (AI.md, "The 10 tok/s ceiling"), and stock WebLLM needs exactly one sync per
|
| 7 |
+
* token — it reads the sampled token id back to JS before it can build the next
|
| 8 |
+
* step's input. One token per tick = 9.6 tok/s, of which ~7 ms is real compute.
|
| 9 |
+
*
|
| 10 |
+
* The fix is the one vLLM ships as `--num-scheduler-steps`: run K steps before
|
| 11 |
+
* paying the per-batch cost once. What makes it possible here without touching
|
| 12 |
+
* the compiled model is that WebLLM's sampling path is *already* on the GPU —
|
| 13 |
+
* `softmax_with_temperature`, `argsort_probs` and `sample_with_top_p` hand back
|
| 14 |
+
* an int32[1] device tensor, and `Tensor.copyFrom(Tensor)` is a device-to-device
|
| 15 |
+
* copy. So the sampled id feeds straight back into `embed` without ever becoming
|
| 16 |
+
* a JS number:
|
| 17 |
+
*
|
| 18 |
+
* embed -> decode -> penalties -> softmax -> argsort -> sample -> embed -> ...
|
| 19 |
+
*
|
| 20 |
+
* Each step stages its id into its own CPU tensor, and the burst ends with
|
| 21 |
+
* **one** `device.sync()`. tvmjs queues GPU->CPU copies into `pendingGPUToCPUCopy`
|
| 22 |
+
* and only awaits them in `sync()`, so K readbacks still cost one tick.
|
| 23 |
+
*
|
| 24 |
+
* Two things follow from the 100 ms grid, and they are why `steps` is a dial:
|
| 25 |
+
*
|
| 26 |
+
* - The win is quantized, not linear. A burst costs `ceil(K * perStepMs / 100)`
|
| 27 |
+
* ticks, so throughput is a sawtooth and the good values of K are the ones
|
| 28 |
+
* landing just under a boundary. On a 0.8B at ~7.3 ms/step that is K=13
|
| 29 |
+
* (~130 tok/s); K=14 already spills into a second tick and halves it.
|
| 30 |
+
* - The best K shrinks as the model grows, because `perStepMs` grows. A model
|
| 31 |
+
* at 25 ms/step wants K=4, not K=15.
|
| 32 |
+
*
|
| 33 |
+
* Cost of the trick: the sampler cannot see its own output mid-burst. Repetition
|
| 34 |
+
* and presence/frequency penalties use the token history as it stood when the
|
| 35 |
+
* burst started, and stop conditions are only checked after the readback, so a
|
| 36 |
+
* burst can overshoot a stop token and must then be rewound. Both are the same
|
| 37 |
+
* trade vLLM makes. Anything needing per-token CPU feedback (grammar-constrained
|
| 38 |
+
* JSON, logprobs, a logit processor) falls back to single-step, where behaviour
|
| 39 |
+
* is identical to stock WebLLM.
|
| 40 |
+
*
|
| 41 |
+
* The other cost is that all of this drives ~30 undocumented tvmjs internals. A
|
| 42 |
+
* WebLLM upgrade that renames one does not break generation — it turns the fast
|
| 43 |
+
* path off and takes the throughput with it, silently. `PIPELINE_CONTRACT` below
|
| 44 |
+
* is that surface written down and checked against the live pipeline before the
|
| 45 |
+
* first burst, so the failure announces itself instead of being measured months
|
| 46 |
+
* later.
|
| 47 |
+
*/
|
| 48 |
+
|
| 49 |
+
/** vLLM's documented sweet spot, and the value this extension ships. */
|
| 50 |
+
export const DEFAULT_DECODE_STEPS = 15;
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Above this, the lookahead thrown away at a stop token outweighs the tick it
|
| 54 |
+
* saves, and the transient logits/argsort buffers stop being free.
|
| 55 |
+
*/
|
| 56 |
+
export const MAX_DECODE_STEPS = 32;
|
| 57 |
+
|
| 58 |
+
export const clampSteps = (n) => Math.max(1, Math.min(MAX_DECODE_STEPS, Math.round(Number(n)) || 1));
|
| 59 |
+
|
| 60 |
+
// -------------------------------------------------- the pipeline contract ----
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* Every tvmjs pipeline internal a burst drives, and how each must behave.
|
| 64 |
+
*
|
| 65 |
+
* None of these are documented, none are part of WebLLM's public surface, and
|
| 66 |
+
* nothing upstream promises they will keep their names. The contract test checks
|
| 67 |
+
* them against the *bundle* on every `npm test`; this checks them against the
|
| 68 |
+
* *live object*, which is a different question — a member can survive in the
|
| 69 |
+
* bundle and still not be on the pipeline handed to us, if upstream moves it to
|
| 70 |
+
* a subclass, a different pipeline type, or behind a factory.
|
| 71 |
+
*
|
| 72 |
+
* Split three ways because presence alone is not the failure that hurts:
|
| 73 |
+
*
|
| 74 |
+
* - **`calls`** must be callable. A rename here throws, which is the *good*
|
| 75 |
+
* case — it is loud.
|
| 76 |
+
* - **`numbers`** are read arithmetically or incremented in place. This is the
|
| 77 |
+
* silent one: `pipeline.filledKVCacheLength += 1` on a member that no longer
|
| 78 |
+
* exists creates a new property, nothing throws, and the KV cache accounting
|
| 79 |
+
* quietly drifts. A missing `contextWindowSize` makes `burstSize` NaN.
|
| 80 |
+
* - **`reads`** need only exist.
|
| 81 |
+
*
|
| 82 |
+
* `logitProcessor` is deliberately optional: `burstSize` tests it for
|
| 83 |
+
* `undefined`, so absent is the normal case, not a broken one.
|
| 84 |
+
*
|
| 85 |
+
* The list is not maintained by hand — `webllm-contract.test.mjs` derives the
|
| 86 |
+
* set this file actually reaches for from its own source and asserts it matches
|
| 87 |
+
* this declaration exactly, so adding a `pipeline.newThing` without declaring it
|
| 88 |
+
* fails the build.
|
| 89 |
+
*/
|
| 90 |
+
export const PIPELINE_CONTRACT = {
|
| 91 |
+
calls: [
|
| 92 |
+
"embed",
|
| 93 |
+
"fKVCacheBeginForward",
|
| 94 |
+
"fKVCacheEndForward",
|
| 95 |
+
"fapplyLogitBias",
|
| 96 |
+
"fapplyPenalty",
|
| 97 |
+
"fargsortProbs",
|
| 98 |
+
"fsampleWithTopP",
|
| 99 |
+
"fsoftmaxWithTemperature",
|
| 100 |
+
"getActiveKVStates",
|
| 101 |
+
"invokeDecode",
|
| 102 |
+
"processNextToken",
|
| 103 |
+
"resetChat",
|
| 104 |
+
"stopped",
|
| 105 |
+
],
|
| 106 |
+
numbers: [
|
| 107 |
+
"contextWindowSize",
|
| 108 |
+
"curRoundDecodingTotalTime",
|
| 109 |
+
"curRoundDecodingTotalTokens",
|
| 110 |
+
"decodingTotalTime",
|
| 111 |
+
"decodingTotalTokens",
|
| 112 |
+
"filledKVCacheLength",
|
| 113 |
+
"fullVocabSize",
|
| 114 |
+
"slidingWindowSize",
|
| 115 |
+
],
|
| 116 |
+
reads: [
|
| 117 |
+
"appearedTokensFreq",
|
| 118 |
+
"config",
|
| 119 |
+
"device",
|
| 120 |
+
"outputIds",
|
| 121 |
+
"params",
|
| 122 |
+
"sampleIndices",
|
| 123 |
+
"sampleIndicesDevice",
|
| 124 |
+
"topPDevice",
|
| 125 |
+
"tvm",
|
| 126 |
+
],
|
| 127 |
+
optional: ["logitProcessor"],
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
/**
|
| 131 |
+
* What this pipeline is missing, as sentences a reader can act on.
|
| 132 |
+
* Empty means a burst is safe to run.
|
| 133 |
+
*/
|
| 134 |
+
export function missingPipelineMembers(pipeline) {
|
| 135 |
+
if (!pipeline || typeof pipeline !== "object") return ["the pipeline itself is not an object"];
|
| 136 |
+
const missing = [];
|
| 137 |
+
for (const name of PIPELINE_CONTRACT.calls) {
|
| 138 |
+
if (typeof pipeline[name] !== "function") missing.push(`${name}() is not a function`);
|
| 139 |
+
}
|
| 140 |
+
for (const name of PIPELINE_CONTRACT.numbers) {
|
| 141 |
+
if (typeof pipeline[name] !== "number") missing.push(`${name} is not a number`);
|
| 142 |
+
}
|
| 143 |
+
for (const name of PIPELINE_CONTRACT.reads) {
|
| 144 |
+
if (pipeline[name] === undefined) missing.push(`${name} is missing`);
|
| 145 |
+
}
|
| 146 |
+
return missing;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/**
|
| 150 |
+
* Replaces `engine.decode` with a burst-and-drain version.
|
| 151 |
+
*
|
| 152 |
+
* `decode` stays a one-token call — the caller's loop still checks
|
| 153 |
+
* `pipeline.stopped()` between tokens and still emits one chunk per token — but
|
| 154 |
+
* only one call in K actually touches the GPU. The rest drain a buffer.
|
| 155 |
+
*
|
| 156 |
+
* @param {object} engine an MLCEngine; in this project the one inside the worker
|
| 157 |
+
* @param {object} [options]
|
| 158 |
+
* @param {number} [options.steps] forward steps per sync; 1 disables the path
|
| 159 |
+
* @param {(info: {steps: number, tokens: number, ms: number}) => void} [options.onBurst]
|
| 160 |
+
* @param {(info: {missing: string[]}) => void} [options.onFallback] fired once
|
| 161 |
+
* per pipeline that fails the contract, before it is routed to stock decoding
|
| 162 |
+
* @returns {{setSteps: (n: number) => void, readonly steps: number,
|
| 163 |
+
* readonly fallbacks: number}}
|
| 164 |
+
*/
|
| 165 |
+
export function installMultiStepDecoding(
|
| 166 |
+
engine,
|
| 167 |
+
{ steps = DEFAULT_DECODE_STEPS, onBurst, onFallback } = {},
|
| 168 |
+
) {
|
| 169 |
+
const config = { steps: clampSteps(steps), fallbacks: 0 };
|
| 170 |
+
const lookahead = new WeakMap();
|
| 171 |
+
const baseDecode = engine.decode.bind(engine);
|
| 172 |
+
const basePrefill = engine.prefill.bind(engine);
|
| 173 |
+
|
| 174 |
+
const stateFor = (pipeline) => {
|
| 175 |
+
let state = lookahead.get(pipeline);
|
| 176 |
+
if (!state) lookahead.set(pipeline, (state = { queue: [] }));
|
| 177 |
+
return state;
|
| 178 |
+
};
|
| 179 |
+
|
| 180 |
+
/** Contract verdict per pipeline; the check runs once, the answer is reused. */
|
| 181 |
+
const supported = new WeakMap();
|
| 182 |
+
|
| 183 |
+
/**
|
| 184 |
+
* Whether this pipeline may be burst, decided once and remembered.
|
| 185 |
+
*
|
| 186 |
+
* Checked at first decode rather than at install time because there is no
|
| 187 |
+
* pipeline yet when this function runs — the engine gets one per `reload()`,
|
| 188 |
+
* and hands it to us as an argument. So the guard lives at the first place a
|
| 189 |
+
* pipeline is ever seen.
|
| 190 |
+
*
|
| 191 |
+
* Failing here means an upgrade moved something and multi-step decoding is
|
| 192 |
+
* gone. Stock decoding still produces correct tokens, so the danger is not a
|
| 193 |
+
* crash but silence: ~18.4 -> ~9.7 tok/s with nothing in the log to explain
|
| 194 |
+
* it. Hence one loud report, and a `fallbacks` count the worker can surface.
|
| 195 |
+
*/
|
| 196 |
+
const canBurst = (pipeline) => {
|
| 197 |
+
const known = supported.get(pipeline);
|
| 198 |
+
if (known !== undefined) return known;
|
| 199 |
+
|
| 200 |
+
const missing = missingPipelineMembers(pipeline);
|
| 201 |
+
supported.set(pipeline, missing.length === 0);
|
| 202 |
+
if (missing.length > 0) {
|
| 203 |
+
config.fallbacks += 1;
|
| 204 |
+
console.error(
|
| 205 |
+
"[everything-webgpu] multi-step decoding disabled — falling back to stock " +
|
| 206 |
+
"single-step decode. Generation stays correct, throughput roughly halves.\n" +
|
| 207 |
+
` The pipeline is missing ${missing.length} of the internals a burst drives:\n` +
|
| 208 |
+
missing.map((line) => ` - ${line}`).join("\n") +
|
| 209 |
+
"\n This is what a WebLLM upgrade looks like from here. `npm test` " +
|
| 210 |
+
"(webllm-contract) says whether the names are gone from the bundle too.",
|
| 211 |
+
);
|
| 212 |
+
onFallback?.({ missing });
|
| 213 |
+
}
|
| 214 |
+
return missing.length === 0;
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
+
// A round can end with tokens still buffered — a stop token mid-burst, or an
|
| 218 |
+
// interrupt that breaks the caller's loop. Those tokens are already in the KV
|
| 219 |
+
// cache, so they must come back out before the next round reuses it.
|
| 220 |
+
engine.prefill = async (input, pipeline, chatConfig, genConfig) => {
|
| 221 |
+
discardLookahead(pipeline, stateFor(pipeline));
|
| 222 |
+
return basePrefill(input, pipeline, chatConfig, genConfig);
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
engine.decode = async (pipeline, genConfig) => {
|
| 226 |
+
const state = stateFor(pipeline);
|
| 227 |
+
|
| 228 |
+
if (state.queue.length === 0) {
|
| 229 |
+
// Before the first burst on this pipeline, not before every one: the
|
| 230 |
+
// verdict is cached, so a healthy pipeline pays one property scan for the
|
| 231 |
+
// whole conversation.
|
| 232 |
+
if (!canBurst(pipeline)) return baseDecode(pipeline, genConfig);
|
| 233 |
+
|
| 234 |
+
const burst = burstSize(pipeline, genConfig, config.steps);
|
| 235 |
+
if (burst <= 1) return baseDecode(pipeline, genConfig);
|
| 236 |
+
|
| 237 |
+
const probe = {};
|
| 238 |
+
const tstart = performance.now();
|
| 239 |
+
state.queue = await sampleBurst(pipeline, genConfig, burst, probe);
|
| 240 |
+
const ms = performance.now() - tstart;
|
| 241 |
+
|
| 242 |
+
// One burst is one wall-clock cost; its tokens are counted as they drain,
|
| 243 |
+
// so a rewound overshoot never inflates the reported rate.
|
| 244 |
+
pipeline.decodingTotalTime += ms / 1e3;
|
| 245 |
+
pipeline.curRoundDecodingTotalTime += ms / 1e3;
|
| 246 |
+
onBurst?.({ steps: burst, tokens: state.queue.length, ms, ...probe });
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
const token = state.queue.shift();
|
| 250 |
+
pipeline.decodingTotalTokens += 1;
|
| 251 |
+
pipeline.curRoundDecodingTotalTokens += 1;
|
| 252 |
+
pipeline.processNextToken(token, genConfig);
|
| 253 |
+
|
| 254 |
+
// The burst ran past a stop token; nothing after it was ever emitted.
|
| 255 |
+
if (pipeline.stopped() && state.queue.length > 0) discardLookahead(pipeline, state);
|
| 256 |
+
};
|
| 257 |
+
|
| 258 |
+
return {
|
| 259 |
+
setSteps: (n) => void (config.steps = clampSteps(n)),
|
| 260 |
+
get steps() {
|
| 261 |
+
return config.steps;
|
| 262 |
+
},
|
| 263 |
+
/** Pipelines that failed the contract. Non-zero means the fast path is off. */
|
| 264 |
+
get fallbacks() {
|
| 265 |
+
return config.fallbacks;
|
| 266 |
+
},
|
| 267 |
+
};
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
// ------------------------------------------------------------- burst size ---
|
| 271 |
+
|
| 272 |
+
/**
|
| 273 |
+
* How many steps may run before the next stop condition *has* to be checked.
|
| 274 |
+
*
|
| 275 |
+
* `max_tokens` and the context window are countable, so they are clamped rather
|
| 276 |
+
* than overshot — which leaves stop tokens as the only reason a burst is ever
|
| 277 |
+
* rewound. Returns 1 when multi-step cannot be used at all, routing the caller
|
| 278 |
+
* to stock single-step decoding.
|
| 279 |
+
*/
|
| 280 |
+
export function burstSize(pipeline, genConfig, steps) {
|
| 281 |
+
if (steps <= 1) return 1;
|
| 282 |
+
|
| 283 |
+
// Per-token CPU feedback: the next step's logits depend on a JS-side decision
|
| 284 |
+
// about this step's token, so there is nothing to overlap.
|
| 285 |
+
const format = genConfig?.response_format?.type;
|
| 286 |
+
if (format === "json_object" || format === "grammar" || format === "structural_tag") return 1;
|
| 287 |
+
if (genConfig?.logprobs) return 1;
|
| 288 |
+
if (pipeline.logitProcessor !== undefined) return 1;
|
| 289 |
+
|
| 290 |
+
const maxTokens = genConfig?.max_tokens;
|
| 291 |
+
const untilMax = maxTokens ? maxTokens - pipeline.outputIds.length : Infinity;
|
| 292 |
+
const untilContextEnd =
|
| 293 |
+
pipeline.slidingWindowSize === -1
|
| 294 |
+
? pipeline.contextWindowSize - pipeline.filledKVCacheLength
|
| 295 |
+
: Infinity;
|
| 296 |
+
|
| 297 |
+
return Math.max(1, Math.min(steps, untilMax, untilContextEnd));
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
// ----------------------------------------------------------------- burst ----
|
| 301 |
+
|
| 302 |
+
/**
|
| 303 |
+
* Runs `steps` forward+sample steps with no GPU->CPU sync between them, then
|
| 304 |
+
* pays exactly one.
|
| 305 |
+
*
|
| 306 |
+
* @returns {Promise<number[]>} the sampled token ids, in order
|
| 307 |
+
*/
|
| 308 |
+
async function sampleBurst(pipeline, genConfig, steps, out) {
|
| 309 |
+
const { tvm, device } = pipeline;
|
| 310 |
+
let probe = null;
|
| 311 |
+
const vocab = pipeline.fullVocabSize;
|
| 312 |
+
const sampling = resolveSampling(pipeline, genConfig);
|
| 313 |
+
|
| 314 |
+
tvm.beginScope();
|
| 315 |
+
let temperatures;
|
| 316 |
+
let bias;
|
| 317 |
+
let penalty;
|
| 318 |
+
/** The last committed token, which seeds step 0. Owned here, not by a scope. */
|
| 319 |
+
let seedTokens;
|
| 320 |
+
try {
|
| 321 |
+
temperatures = tvm.detachFromCurrentScope(
|
| 322 |
+
tvm.empty([1], "float32", device).copyFrom([Math.max(1e-6, sampling.temperature)]),
|
| 323 |
+
);
|
| 324 |
+
bias = makeLogitBias(pipeline, sampling);
|
| 325 |
+
penalty = makePenalty(pipeline, sampling);
|
| 326 |
+
// top_p lives in a tensor the pipeline owns and reuses, set up exactly as
|
| 327 |
+
// `sampleTokenFromLogits` does. It is constant for the whole burst.
|
| 328 |
+
const topPHost = new Float32Array(pipeline.topPDevice.shape[0]).fill(-1);
|
| 329 |
+
const topP = Math.max(sampling.top_p, 1e-5);
|
| 330 |
+
pipeline.sampleIndices.forEach((row) => (topPHost[row] = topP));
|
| 331 |
+
pipeline.topPDevice.copyFrom(topPHost);
|
| 332 |
+
seedTokens = tvm.detachFromCurrentScope(
|
| 333 |
+
tvm.empty([1], "int32", device).copyFrom([pipeline.outputIds[pipeline.outputIds.length - 1]]),
|
| 334 |
+
);
|
| 335 |
+
} finally {
|
| 336 |
+
tvm.endScope();
|
| 337 |
+
}
|
| 338 |
+
let tokens = seedTokens;
|
| 339 |
+
|
| 340 |
+
/**
|
| 341 |
+
* Sampled ids stay on the device for the whole loop; the host copies happen
|
| 342 |
+
* after it, never interleaved with compute.
|
| 343 |
+
*
|
| 344 |
+
* The order is load-bearing. `flushCommands()` nulls tvmjs's
|
| 345 |
+
* `pendingGPUToCPUCopy` whenever it submits an encoder, and every GPU->CPU
|
| 346 |
+
* copy calls it. Interleaving copies with compute therefore made each step
|
| 347 |
+
* discard the previous step's pending readback, leaving `device.sync()`
|
| 348 |
+
* awaiting only the last one — correct in practice only because the
|
| 349 |
+
* `mapAsync` promises happen to resolve in FIFO order. Doing all the copies
|
| 350 |
+
* after the loop means the first flushes and starts the chain while the rest
|
| 351 |
+
* find no pending encoder, so the chain accumulates intact.
|
| 352 |
+
*/
|
| 353 |
+
const sampledIds = [];
|
| 354 |
+
/** One CPU int32[1] per step. All of their reads land on the same poll tick. */
|
| 355 |
+
const staged = [];
|
| 356 |
+
|
| 357 |
+
// The decisive probe. The K-step loop below contains no `await`, so it is one
|
| 358 |
+
// synchronous JS turn: everything it costs is content-process CPU — command
|
| 359 |
+
// encoding, `createBindGroup`, IPC to the GPU process. The `await` after it is
|
| 360 |
+
// everything else: GPU execution plus the wait for the next 100 ms poll tick.
|
| 361 |
+
// Splitting the two says which one the budget actually goes to.
|
| 362 |
+
const gpuCtx = tvm.lib?.webGPUContext;
|
| 363 |
+
const dispatchesBefore = gpuCtx?.shaderSubmitCounter ?? 0;
|
| 364 |
+
const flushesBefore = countFlushes(gpuCtx);
|
| 365 |
+
let forwardDispatches = 0;
|
| 366 |
+
const tEncodeStart = performance.now();
|
| 367 |
+
|
| 368 |
+
try {
|
| 369 |
+
for (let step = 0; step < steps; step++) {
|
| 370 |
+
tvm.beginScope();
|
| 371 |
+
const stepStart = gpuCtx?.shaderSubmitCounter ?? 0;
|
| 372 |
+
try {
|
| 373 |
+
// `tokens` is owned by `sampledIds` (or is the seed), not by this scope.
|
| 374 |
+
const embeddings = pipeline.embed(tokens, pipeline.params);
|
| 375 |
+
const batched = embeddings.view([1].concat(embeddings.shape));
|
| 376 |
+
|
| 377 |
+
const states = pipeline.getActiveKVStates();
|
| 378 |
+
const seqIds = tvm.makeShapeTuple([0]);
|
| 379 |
+
const inputLen = tvm.makeShapeTuple([1]);
|
| 380 |
+
for (const state of states) pipeline.fKVCacheBeginForward(state, seqIds, inputLen);
|
| 381 |
+
const forwarded = pipeline.invokeDecode(batched);
|
| 382 |
+
for (let i = states.length - 1; i >= 0; i--) pipeline.fKVCacheEndForward(states[i]);
|
| 383 |
+
pipeline.filledKVCacheLength += 1;
|
| 384 |
+
|
| 385 |
+
// Split the launch count at the forward/sample boundary. The sampling
|
| 386 |
+
// tail is `argsort_probs` over the full vocab (248k here), which is a
|
| 387 |
+
// multi-pass sort and belongs to the runtime, not the model — so it is
|
| 388 |
+
// worth knowing how much of the per-token kernel budget it owns.
|
| 389 |
+
forwardDispatches += (gpuCtx?.shaderSubmitCounter ?? 0) - stepStart;
|
| 390 |
+
|
| 391 |
+
const logits = forwarded.get(0);
|
| 392 |
+
if (bias) {
|
| 393 |
+
pipeline.fapplyLogitBias(logits.view([1, vocab]), bias.pos2seqIds, bias.tokenIds, bias.values);
|
| 394 |
+
}
|
| 395 |
+
if (penalty) {
|
| 396 |
+
pipeline.fapplyPenalty(
|
| 397 |
+
logits.view([1, vocab]),
|
| 398 |
+
penalty.seqIds,
|
| 399 |
+
penalty.pos2seqIds,
|
| 400 |
+
penalty.tokenIds,
|
| 401 |
+
penalty.counts,
|
| 402 |
+
penalty.penalties,
|
| 403 |
+
);
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
const probs = pipeline
|
| 407 |
+
.fsoftmaxWithTemperature(logits.view([1, 1, vocab]), temperatures)
|
| 408 |
+
.view([1, vocab]);
|
| 409 |
+
const sorted = pipeline.fargsortProbs(probs);
|
| 410 |
+
const sampled = pipeline.fsampleWithTopP(
|
| 411 |
+
sorted.get(0),
|
| 412 |
+
sorted.get(1),
|
| 413 |
+
tvm.uniform([1], 0, 1, device),
|
| 414 |
+
pipeline.sampleIndicesDevice,
|
| 415 |
+
pipeline.topPDevice,
|
| 416 |
+
);
|
| 417 |
+
|
| 418 |
+
tokens = tvm.detachFromCurrentScope(sampled);
|
| 419 |
+
sampledIds.push(tokens);
|
| 420 |
+
} finally {
|
| 421 |
+
tvm.endScope();
|
| 422 |
+
}
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
// Every readback together, after all compute: one flush, one intact chain.
|
| 426 |
+
tvm.beginScope();
|
| 427 |
+
try {
|
| 428 |
+
for (const id of sampledIds) {
|
| 429 |
+
staged.push(tvm.detachFromCurrentScope(tvm.empty([1], "int32", tvm.cpu()).copyFrom(id)));
|
| 430 |
+
}
|
| 431 |
+
} finally {
|
| 432 |
+
tvm.endScope();
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
// Encoding the copies is still CPU work, so the boundary sits after them.
|
| 436 |
+
const tEncoded = performance.now();
|
| 437 |
+
|
| 438 |
+
// The one sync the whole burst pays for.
|
| 439 |
+
await device.sync();
|
| 440 |
+
|
| 441 |
+
probe = {
|
| 442 |
+
encodeMs: tEncoded - tEncodeStart,
|
| 443 |
+
syncMs: performance.now() - tEncoded,
|
| 444 |
+
dispatches: (gpuCtx?.shaderSubmitCounter ?? 0) - dispatchesBefore,
|
| 445 |
+
forwardDispatches,
|
| 446 |
+
flushes: countFlushes(gpuCtx) - flushesBefore,
|
| 447 |
+
};
|
| 448 |
+
return staged.map((host) => host.toArray()[0]);
|
| 449 |
+
} finally {
|
| 450 |
+
if (probe) Object.assign(out, probe);
|
| 451 |
+
for (const host of staged) host.dispose();
|
| 452 |
+
for (const id of sampledIds) id.dispose();
|
| 453 |
+
seedTokens?.dispose();
|
| 454 |
+
temperatures.dispose();
|
| 455 |
+
disposeAll(bias);
|
| 456 |
+
disposeAll(penalty);
|
| 457 |
+
}
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
/**
|
| 461 |
+
* Kernel launches per `flushCommands()`, which decides whether batching tvmjs's
|
| 462 |
+
* per-kernel compute passes into one pass is worth anything.
|
| 463 |
+
*
|
| 464 |
+
* `flushCommands()` submits the pending encoder — so it would also close a
|
| 465 |
+
* shared pass — and it fires from `deviceFreeDataSpace`, the buffer copies and
|
| 466 |
+
* `sync`. If TVM frees an intermediate between every op then flushes ≈ kernels,
|
| 467 |
+
* the pass stream is already chopped up, and there is nothing to merge. tvmjs
|
| 468 |
+
* keeps no counter of its own, so wrap the method once per context.
|
| 469 |
+
*/
|
| 470 |
+
function countFlushes(gpuCtx) {
|
| 471 |
+
if (!gpuCtx) return 0;
|
| 472 |
+
if (gpuCtx.__ewgpuFlushCount === undefined) {
|
| 473 |
+
const base = gpuCtx.flushCommands.bind(gpuCtx);
|
| 474 |
+
gpuCtx.__ewgpuFlushCount = 0;
|
| 475 |
+
gpuCtx.flushCommands = () => {
|
| 476 |
+
gpuCtx.__ewgpuFlushCount += 1;
|
| 477 |
+
base();
|
| 478 |
+
};
|
| 479 |
+
}
|
| 480 |
+
return gpuCtx.__ewgpuFlushCount;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
// ---------------------------------------------------------------- rewind ----
|
| 484 |
+
|
| 485 |
+
/**
|
| 486 |
+
* Drops un-emitted lookahead and takes it back out of the KV cache.
|
| 487 |
+
*
|
| 488 |
+
* `kv_state_popn` is the clean path. If the runtime has not registered it the
|
| 489 |
+
* cache cannot be trimmed, so it is thrown away instead: the next round pays a
|
| 490 |
+
* full re-prefill (one sync, not one per token) rather than attending over
|
| 491 |
+
* tokens the caller never saw.
|
| 492 |
+
*/
|
| 493 |
+
function discardLookahead(pipeline, state) {
|
| 494 |
+
const n = state.queue.length;
|
| 495 |
+
state.queue = [];
|
| 496 |
+
if (n === 0) return;
|
| 497 |
+
|
| 498 |
+
const { tvm } = pipeline;
|
| 499 |
+
try {
|
| 500 |
+
const popn = getPopN(pipeline);
|
| 501 |
+
if (popn) {
|
| 502 |
+
tvm.beginScope();
|
| 503 |
+
try {
|
| 504 |
+
for (const kvState of pipeline.getActiveKVStates()) {
|
| 505 |
+
popn(kvState, tvm.scalar(0, "int64"), tvm.scalar(n, "int32"));
|
| 506 |
+
}
|
| 507 |
+
} finally {
|
| 508 |
+
tvm.endScope();
|
| 509 |
+
}
|
| 510 |
+
pipeline.filledKVCacheLength -= n;
|
| 511 |
+
return;
|
| 512 |
+
}
|
| 513 |
+
} catch {
|
| 514 |
+
// Fall through: a trim that threw is handled the same as no trim at all.
|
| 515 |
+
}
|
| 516 |
+
pipeline.resetChat(/* keepStats= */ true);
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
const popNCache = new WeakMap();
|
| 520 |
+
|
| 521 |
+
function getPopN(pipeline) {
|
| 522 |
+
if (popNCache.has(pipeline)) return popNCache.get(pipeline);
|
| 523 |
+
let popn = null;
|
| 524 |
+
const { tvm } = pipeline;
|
| 525 |
+
tvm.beginScope();
|
| 526 |
+
try {
|
| 527 |
+
popn = tvm.detachFromCurrentScope(tvm.getGlobalFunc("vm.builtin.kv_state_popn"));
|
| 528 |
+
} catch {
|
| 529 |
+
popn = null;
|
| 530 |
+
} finally {
|
| 531 |
+
tvm.endScope();
|
| 532 |
+
}
|
| 533 |
+
popNCache.set(pipeline, popn);
|
| 534 |
+
return popn;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
// ------------------------------------------------------- sampling inputs ----
|
| 538 |
+
|
| 539 |
+
/**
|
| 540 |
+
* The subset of `sampleTokenFromLogits`'s config resolution a burst can honour,
|
| 541 |
+
* in the same precedence order: the request overrides `mlc-chat-config.json`.
|
| 542 |
+
*/
|
| 543 |
+
function resolveSampling(pipeline, genConfig) {
|
| 544 |
+
const has = (v) => v !== undefined && v !== null;
|
| 545 |
+
const pick = (key, fallback) => (has(genConfig?.[key]) ? genConfig[key] : fallback);
|
| 546 |
+
return {
|
| 547 |
+
temperature: pick("temperature", pipeline.config.temperature),
|
| 548 |
+
top_p: pick("top_p", pipeline.config.top_p) ?? 1,
|
| 549 |
+
repetition_penalty: pick("repetition_penalty", pipeline.config.repetition_penalty),
|
| 550 |
+
frequency_penalty: pick("frequency_penalty", pipeline.config.frequency_penalty) ?? 0,
|
| 551 |
+
presence_penalty: pick("presence_penalty", pipeline.config.presence_penalty) ?? 0,
|
| 552 |
+
logit_bias: pick("logit_bias", undefined),
|
| 553 |
+
};
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
/** Static for the whole request, so it is uploaded once and reused every step. */
|
| 557 |
+
function makeLogitBias(pipeline, { logit_bias }) {
|
| 558 |
+
const ids = Object.keys(logit_bias ?? {});
|
| 559 |
+
if (ids.length === 0) return null;
|
| 560 |
+
const { tvm, device } = pipeline;
|
| 561 |
+
const int32 = (values) =>
|
| 562 |
+
tvm.detachFromCurrentScope(tvm.empty([values.length], "int32", device).copyFrom(values));
|
| 563 |
+
return {
|
| 564 |
+
pos2seqIds: int32(new Int32Array(ids.length)),
|
| 565 |
+
tokenIds: int32(Int32Array.from(ids, (id) => parseInt(id, 10))),
|
| 566 |
+
values: tvm.detachFromCurrentScope(
|
| 567 |
+
tvm.empty([ids.length], "float32", device).copyFrom(Float32Array.from(ids, (id) => logit_bias[id])),
|
| 568 |
+
),
|
| 569 |
+
};
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
/**
|
| 573 |
+
* Frozen token history for the burst.
|
| 574 |
+
*
|
| 575 |
+
* This is the one place multi-step is not equivalent to single-step: tokens
|
| 576 |
+
* sampled *within* a burst are not penalised against each other, because their
|
| 577 |
+
* ids are still on the GPU. At K=15 the penalty state is at most 15 tokens
|
| 578 |
+
* stale. Anything that cannot tolerate that should run with `decodeSteps: 1`.
|
| 579 |
+
*/
|
| 580 |
+
function makePenalty(pipeline, { repetition_penalty, frequency_penalty, presence_penalty }) {
|
| 581 |
+
const active = frequency_penalty !== 0 || presence_penalty !== 0 || (repetition_penalty ?? 1) !== 1;
|
| 582 |
+
if (!active) return null;
|
| 583 |
+
|
| 584 |
+
const appeared = [...pipeline.appearedTokensFreq.keys()];
|
| 585 |
+
if (appeared.length === 0) return null;
|
| 586 |
+
const freqs = [...pipeline.appearedTokensFreq.values()];
|
| 587 |
+
|
| 588 |
+
const { tvm, device } = pipeline;
|
| 589 |
+
const int32 = (values) =>
|
| 590 |
+
tvm.detachFromCurrentScope(tvm.empty([values.length], "int32", device).copyFrom(values));
|
| 591 |
+
return {
|
| 592 |
+
seqIds: int32(new Int32Array(1)),
|
| 593 |
+
pos2seqIds: int32(new Int32Array(appeared.length)),
|
| 594 |
+
tokenIds: int32(Int32Array.from(appeared)),
|
| 595 |
+
counts: int32(Int32Array.from(freqs)),
|
| 596 |
+
penalties: tvm.detachFromCurrentScope(
|
| 597 |
+
tvm
|
| 598 |
+
.empty([1, 3], "float32", device)
|
| 599 |
+
.copyFrom(new Float32Array([presence_penalty, frequency_penalty, repetition_penalty ?? 1])),
|
| 600 |
+
),
|
| 601 |
+
};
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
function disposeAll(inputs) {
|
| 605 |
+
if (!inputs) return;
|
| 606 |
+
for (const tensor of Object.values(inputs)) tensor.dispose();
|
| 607 |
+
}
|
src/engine/pool.js
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Engine pool + priority scheduler.
|
| 3 |
+
*
|
| 4 |
+
* Why this exists: the GPU gives us ~10 completion ticks per second, shared by
|
| 5 |
+
* every caller (AI.md, "The 10 tok/s ceiling"). Two consequences drive the
|
| 6 |
+
* whole design:
|
| 7 |
+
*
|
| 8 |
+
* - A single generation can never beat ~10 tok/s. When decode is *sync-bound*
|
| 9 |
+
* - waiting on the ~100 ms tick rather than on the GPU - N concurrent ones
|
| 10 |
+
* each still get ~10 tok/s, because they all wake on the same tick. That
|
| 11 |
+
* held for the 0.8B (1.3-2.0x on two engines) and stops holding once the
|
| 12 |
+
* model is big enough to keep the GPU busy: measured 1.06x on the 2B, where
|
| 13 |
+
* two streams simply run at half speed each. Fan-out is worth having, but
|
| 14 |
+
* the second engine earns its memory through isolation, not throughput.
|
| 15 |
+
* - Every engine in the pool holds its own copy of the weights, so the pool is
|
| 16 |
+
* small and its slots are the scarce resource worth scheduling.
|
| 17 |
+
*
|
| 18 |
+
* The scheduler is therefore four mechanisms and no more:
|
| 19 |
+
* 1. Priority bands, FIFO within a band.
|
| 20 |
+
* 2. Session supersession - a new job with the same `session` cancels the
|
| 21 |
+
* previous one. This is the ghost-text primitive: every keystroke replaces
|
| 22 |
+
* the last request instead of queueing behind it.
|
| 23 |
+
* 3. Opt-in preemption - an `interactive` job with no free slot may interrupt
|
| 24 |
+
* a running job that declared `preemptible`. The victim resolves with what
|
| 25 |
+
* it produced so far, so it is never requeued and can never starve.
|
| 26 |
+
* 4. Demand-driven growth - the pool starts at one engine and earns another
|
| 27 |
+
* only when a *different* task is waiting on a busy pool. See `#grow`.
|
| 28 |
+
*
|
| 29 |
+
* Jobs carry a `task`: the unit that owns an engine. Every item of one batch
|
| 30 |
+
* shares it, so "translate this page" is one task however many requests it is.
|
| 31 |
+
* Slots are handed out per job, but the last free slot is reserved per task -
|
| 32 |
+
* otherwise one batch fills the pool and ghost-text starves behind it.
|
| 33 |
+
*
|
| 34 |
+
* `createEngine` is injected so the scheduler can be tested without a GPU.
|
| 35 |
+
*/
|
| 36 |
+
import { JOB_KIND, PRIORITY, PRIORITY_ORDER } from "./constants.js";
|
| 37 |
+
|
| 38 |
+
let nextJobId = 0;
|
| 39 |
+
|
| 40 |
+
export class EnginePool {
|
| 41 |
+
#maxSize;
|
| 42 |
+
#createEngine;
|
| 43 |
+
#onStateChange;
|
| 44 |
+
#slots = [];
|
| 45 |
+
#queues = new Map(PRIORITY_ORDER.map((p) => [p, []]));
|
| 46 |
+
#bySession = new Map();
|
| 47 |
+
#loading = null;
|
| 48 |
+
#growing = null;
|
| 49 |
+
/** Set to the failure reason once a grow attempt fails; growth then stops. */
|
| 50 |
+
#growthBlocked = null;
|
| 51 |
+
/**
|
| 52 |
+
* Bumped by `unload()`. An engine that finishes loading against a stale
|
| 53 |
+
* generation is torn down rather than installed — see `load()`.
|
| 54 |
+
*/
|
| 55 |
+
#generation = 0;
|
| 56 |
+
|
| 57 |
+
constructor({ size = 2, createEngine, onStateChange = () => {} }) {
|
| 58 |
+
this.#maxSize = Math.max(1, Math.min(4, size));
|
| 59 |
+
this.#createEngine = createEngine;
|
| 60 |
+
this.#onStateChange = onStateChange;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/** Engines that exist right now, which is not the same as the cap. */
|
| 64 |
+
get size() {
|
| 65 |
+
return this.#slots.length;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
get maxSize() {
|
| 69 |
+
return this.#maxSize;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
get loaded() {
|
| 73 |
+
return this.#slots.length > 0;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* Brings up the first engine, and only the first.
|
| 78 |
+
*
|
| 79 |
+
* The pool used to build every engine here. It no longer does, because an
|
| 80 |
+
* engine is a full copy of the weights - measured ~1.6 GB steady state for
|
| 81 |
+
* the 0.8B, ~2.4 GB for a 2B - and staging a second one costs that much host
|
| 82 |
+
* memory before the GPU ever sees it. On a 16 GB machine that is the
|
| 83 |
+
* difference between working and swapping, and it was being paid up front
|
| 84 |
+
* whether or not two tasks ever ran at once. `#grow` earns the rest.
|
| 85 |
+
*/
|
| 86 |
+
async load(onProgress = () => {}) {
|
| 87 |
+
if (this.#loading) return this.#loading;
|
| 88 |
+
|
| 89 |
+
const generation = this.#generation;
|
| 90 |
+
this.#loading = (async () => {
|
| 91 |
+
const engine = await this.#createEngine(0, (report) =>
|
| 92 |
+
onProgress({ ...report, engine: 1, engines: 1 }),
|
| 93 |
+
);
|
| 94 |
+
// `unload()` may have run while this was still building — a cancelled
|
| 95 |
+
// load, or a model switch. Installing it now would resurrect an engine
|
| 96 |
+
// nobody holds a reference to, leaking its worker and a full copy of the
|
| 97 |
+
// weights. `#grow()` has always guarded this; `load()` did not.
|
| 98 |
+
if (generation !== this.#generation) {
|
| 99 |
+
await engine.unload?.().catch(() => {});
|
| 100 |
+
return 0;
|
| 101 |
+
}
|
| 102 |
+
this.#slots = [{ engine, job: null }];
|
| 103 |
+
return this.#slots.length;
|
| 104 |
+
})().finally(() => {
|
| 105 |
+
this.#loading = null;
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
return this.#loading;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
async unload() {
|
| 112 |
+
this.#generation += 1;
|
| 113 |
+
for (const job of this.#allJobs()) this.#finish(job, { cancelled: true });
|
| 114 |
+
for (const p of this.#queues.values()) p.length = 0;
|
| 115 |
+
this.#bySession.clear();
|
| 116 |
+
// A smaller model may well fit where this one did not.
|
| 117 |
+
this.#growthBlocked = null;
|
| 118 |
+
const slots = this.#slots;
|
| 119 |
+
this.#slots = [];
|
| 120 |
+
await Promise.all(slots.map((s) => s.engine.unload?.().catch(() => {})));
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/**
|
| 124 |
+
* @param {object} spec
|
| 125 |
+
* @param {object} spec.params passed straight to `engine.chat.completions.create`
|
| 126 |
+
* @param {string} [spec.task] the unit that owns an engine; a whole batch shares one
|
| 127 |
+
* @param {string} [spec.session] later jobs with this session supersede earlier ones
|
| 128 |
+
* @param {string} [spec.priority] one of PRIORITY
|
| 129 |
+
* @param {boolean} [spec.preemptible] may be interrupted by an interactive job
|
| 130 |
+
* @param {(chunk: object) => void} [spec.onChunk] WebLLM's chunk, verbatim
|
| 131 |
+
* @returns {Promise<{text: string, usage?: object, cancelled?: boolean, preempted?: boolean}>}
|
| 132 |
+
*/
|
| 133 |
+
submit(spec) {
|
| 134 |
+
const priority = PRIORITY_ORDER.includes(spec.priority) ? spec.priority : PRIORITY.NORMAL;
|
| 135 |
+
const id = spec.id ?? `job-${++nextJobId}`;
|
| 136 |
+
const job = {
|
| 137 |
+
id,
|
| 138 |
+
// Unlabelled work is its own task, so two bare `chat` calls still compete
|
| 139 |
+
// for separate engines the way two different callers would.
|
| 140 |
+
task: spec.task ?? spec.session ?? id,
|
| 141 |
+
session: spec.session,
|
| 142 |
+
priority,
|
| 143 |
+
preemptible: Boolean(spec.preemptible),
|
| 144 |
+
// What the slot's engine should be asked to do. Everything else about a
|
| 145 |
+
// job — priority, supersession, preemption, one-task-one-engine — is the
|
| 146 |
+
// same either way, which is why this is a field and not a second pool.
|
| 147 |
+
kind: spec.kind === JOB_KIND.EMBEDDING ? JOB_KIND.EMBEDDING : JOB_KIND.CHAT,
|
| 148 |
+
params: spec.params,
|
| 149 |
+
onChunk: spec.onChunk ?? (() => {}),
|
| 150 |
+
text: "",
|
| 151 |
+
slot: null,
|
| 152 |
+
done: false,
|
| 153 |
+
};
|
| 154 |
+
job.promise = new Promise((resolve) => (job.resolve = resolve));
|
| 155 |
+
|
| 156 |
+
if (job.session) {
|
| 157 |
+
const previous = this.#bySession.get(job.session);
|
| 158 |
+
// Superseded, not queued behind: the keystroke that produced the old
|
| 159 |
+
// request is already stale.
|
| 160 |
+
if (previous) this.cancel(previous.id);
|
| 161 |
+
this.#bySession.set(job.session, job);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
this.#queues.get(priority).push(job);
|
| 165 |
+
this.#pump();
|
| 166 |
+
this.#emit();
|
| 167 |
+
return job.promise;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/** Cancels by job id or by session key. Returns how many jobs it stopped. */
|
| 171 |
+
cancel(idOrSession) {
|
| 172 |
+
let stopped = 0;
|
| 173 |
+
for (const job of this.#allJobs()) {
|
| 174 |
+
if (job.id !== idOrSession && job.session !== idOrSession) continue;
|
| 175 |
+
stopped += 1;
|
| 176 |
+
if (job.slot === null) {
|
| 177 |
+
this.#dequeue(job);
|
| 178 |
+
this.#finish(job, { cancelled: true });
|
| 179 |
+
} else {
|
| 180 |
+
job.cancelling = true;
|
| 181 |
+
this.#slots[job.slot].engine.interruptGenerate();
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
if (stopped) this.#emit();
|
| 185 |
+
return stopped;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
/**
|
| 189 |
+
* Pushes a runtime setting to every engine that accepts one.
|
| 190 |
+
*
|
| 191 |
+
* Separate from `load()` because the settings this carries — `decodeSteps` so
|
| 192 |
+
* far — are per-generation knobs, not per-model ones: retuning them must not
|
| 193 |
+
* cost a reload of the weights.
|
| 194 |
+
*/
|
| 195 |
+
configure(patch) {
|
| 196 |
+
let applied = 0;
|
| 197 |
+
for (const slot of this.#slots) {
|
| 198 |
+
if (typeof slot.engine.configure !== "function") continue;
|
| 199 |
+
slot.engine.configure(patch);
|
| 200 |
+
applied += 1;
|
| 201 |
+
}
|
| 202 |
+
return applied;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
status() {
|
| 206 |
+
return {
|
| 207 |
+
size: this.#slots.length,
|
| 208 |
+
maxSize: this.#maxSize,
|
| 209 |
+
growing: this.#growing !== null,
|
| 210 |
+
// Non-null once a second engine failed to come up; the UI can say why the
|
| 211 |
+
// pool is smaller than the cap instead of looking stuck.
|
| 212 |
+
growthBlocked: this.#growthBlocked,
|
| 213 |
+
busy: this.#slots.filter((s) => s.job !== null).length,
|
| 214 |
+
queued: PRIORITY_ORDER.reduce((n, p) => n + this.#queues.get(p).length, 0),
|
| 215 |
+
queuedByPriority: Object.fromEntries(
|
| 216 |
+
PRIORITY_ORDER.map((p) => [p, this.#queues.get(p).length]),
|
| 217 |
+
),
|
| 218 |
+
};
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
// ------------------------------------------------------------ internals ---
|
| 222 |
+
|
| 223 |
+
/**
|
| 224 |
+
* Adds one engine, but only when a second one would actually buy something.
|
| 225 |
+
*
|
| 226 |
+
* There is no budget to check against: Firefox implements neither
|
| 227 |
+
* `navigator.deviceMemory` nor `performance.memory`, and `storage.estimate()`
|
| 228 |
+
* measures disk quota, not RAM. Nothing reports free memory to an extension.
|
| 229 |
+
*
|
| 230 |
+
* So the pool does not predict, it probes - and it only probes when the
|
| 231 |
+
* answer matters. A failed load is taken as the answer and is not retried:
|
| 232 |
+
* the retry would cost another full staging pass to learn the same thing.
|
| 233 |
+
*/
|
| 234 |
+
#grow() {
|
| 235 |
+
if (this.#growing || this.#growthBlocked || this.#loading) return;
|
| 236 |
+
if (this.#slots.length >= this.#maxSize) return;
|
| 237 |
+
if (!this.#crossTaskDemand()) return;
|
| 238 |
+
|
| 239 |
+
const index = this.#slots.length;
|
| 240 |
+
this.#growing = (async () => {
|
| 241 |
+
try {
|
| 242 |
+
const engine = await this.#createEngine(index, () => {});
|
| 243 |
+
// `unload()` may have emptied the pool while this was still loading;
|
| 244 |
+
// pushing then would resurrect a slot for a model nobody asked for.
|
| 245 |
+
if (this.#slots.length === index) this.#slots.push({ engine, job: null });
|
| 246 |
+
else await engine.unload?.().catch(() => {});
|
| 247 |
+
} catch (err) {
|
| 248 |
+
// Almost always memory. Staying at the current size is the right
|
| 249 |
+
// outcome, not an error owed to whichever job happened to trigger it.
|
| 250 |
+
this.#growthBlocked = err?.message ?? String(err);
|
| 251 |
+
}
|
| 252 |
+
})().finally(() => {
|
| 253 |
+
this.#growing = null;
|
| 254 |
+
this.#pump();
|
| 255 |
+
this.#emit();
|
| 256 |
+
});
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
/**
|
| 260 |
+
* True when a queued job belongs to a task that is not already running.
|
| 261 |
+
*
|
| 262 |
+
* This is the entire growth policy. A second engine exists so that a
|
| 263 |
+
* translation and a ghost-text completion can run at once - not to make one
|
| 264 |
+
* batch finish sooner. Four queued items of the same batch keep the pool at
|
| 265 |
+
* one engine; one queued completion alongside them grows it.
|
| 266 |
+
*/
|
| 267 |
+
#crossTaskDemand() {
|
| 268 |
+
const running = this.#runningTasks();
|
| 269 |
+
if (running.size === 0) return false;
|
| 270 |
+
for (const job of this.#queued()) if (!running.has(job.task)) return true;
|
| 271 |
+
return false;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
#runningTasks() {
|
| 275 |
+
const tasks = new Set();
|
| 276 |
+
for (const slot of this.#slots) if (slot.job) tasks.add(slot.job.task);
|
| 277 |
+
return tasks;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
*#queued() {
|
| 281 |
+
for (const p of PRIORITY_ORDER) yield* this.#queues.get(p);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
*#allJobs() {
|
| 285 |
+
for (const slot of this.#slots) if (slot.job) yield slot.job;
|
| 286 |
+
yield* this.#queued();
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
#dequeue(job) {
|
| 290 |
+
const queue = this.#queues.get(job.priority);
|
| 291 |
+
const i = queue.indexOf(job);
|
| 292 |
+
if (i >= 0) queue.splice(i, 1);
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
#nextJob() {
|
| 296 |
+
for (const p of PRIORITY_ORDER) {
|
| 297 |
+
const queue = this.#queues.get(p);
|
| 298 |
+
if (queue.length) return queue[0];
|
| 299 |
+
}
|
| 300 |
+
return null;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
#pump() {
|
| 304 |
+
for (;;) {
|
| 305 |
+
const slot = this.#slots.findIndex((s) => s.job === null);
|
| 306 |
+
const job = slot >= 0 ? this.#pick() : this.#nextJob();
|
| 307 |
+
if (!job) break;
|
| 308 |
+
|
| 309 |
+
if (slot >= 0) {
|
| 310 |
+
this.#dequeue(job);
|
| 311 |
+
this.#start(slot, job);
|
| 312 |
+
continue;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
// No slot. Only an interactive job is allowed to take one by force, and
|
| 316 |
+
// only from a job that opted in.
|
| 317 |
+
if (job.priority !== PRIORITY.INTERACTIVE) break;
|
| 318 |
+
const victim = this.#slots.find((s) => s.job?.preemptible && !s.job.cancelling);
|
| 319 |
+
if (!victim) break;
|
| 320 |
+
victim.job.preempting = true;
|
| 321 |
+
victim.job.cancelling = true;
|
| 322 |
+
victim.engine.interruptGenerate();
|
| 323 |
+
break; // the freed slot re-enters #pump when the victim settles
|
| 324 |
+
}
|
| 325 |
+
this.#grow();
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
/**
|
| 329 |
+
* Which queued job takes a free slot: the first, by priority, whose task is
|
| 330 |
+
* not already running.
|
| 331 |
+
*
|
| 332 |
+
* **One task holds at most one engine.** Letting a batch spread over the pool
|
| 333 |
+
* used to be the point - it was worth 1.3-2.0x on the 0.8B, where decode was
|
| 334 |
+
* sync-bound and a second stream filled idle GPU. On the 2B it is worth
|
| 335 |
+
* 1.06x: the GPU is busy, so two streams of the same work just run at half
|
| 336 |
+
* speed each (measured; `engine scaling:` in the e2e). That buys nothing, and
|
| 337 |
+
* it costs the thing a second engine is actually for - a page translation
|
| 338 |
+
* would sit on both engines and ghost-text would wait behind it.
|
| 339 |
+
*
|
| 340 |
+
* So the rule is flat, and two runnable tasks are therefore always running at
|
| 341 |
+
* once whenever two engines exist. An engine may idle while one task has work
|
| 342 |
+
* queued; that is the ~6% being deliberately given up.
|
| 343 |
+
*
|
| 344 |
+
* `interactive` is the one exception: that band exists for work a human is
|
| 345 |
+
* waiting on keystroke-by-keystroke, and it takes a free engine regardless.
|
| 346 |
+
*/
|
| 347 |
+
#pick() {
|
| 348 |
+
const head = this.#nextJob();
|
| 349 |
+
if (!head || head.priority === PRIORITY.INTERACTIVE) return head;
|
| 350 |
+
|
| 351 |
+
const running = this.#runningTasks();
|
| 352 |
+
for (const job of this.#queued()) if (!running.has(job.task)) return job;
|
| 353 |
+
return null;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
#start(slotIndex, job) {
|
| 357 |
+
const slot = this.#slots[slotIndex];
|
| 358 |
+
slot.job = job;
|
| 359 |
+
job.slot = slotIndex;
|
| 360 |
+
job.startedAt = performance.now();
|
| 361 |
+
job.engineIndex = slotIndex;
|
| 362 |
+
this.#emit();
|
| 363 |
+
|
| 364 |
+
(async () => {
|
| 365 |
+
try {
|
| 366 |
+
if (job.kind === JOB_KIND.EMBEDDING) {
|
| 367 |
+
// One shot, no stream: an embedding is a single forward pass and
|
| 368 |
+
// WebLLM returns the whole OpenAI-shaped response at once. There is
|
| 369 |
+
// no decode loop here, so none of the streaming bookkeeping applies —
|
| 370 |
+
// but the queueing, priority and preemption above all still did.
|
| 371 |
+
const res = await slot.engine.embeddings.create({ ...job.params });
|
| 372 |
+
job.usage = res.usage;
|
| 373 |
+
job.embeddings = res.data;
|
| 374 |
+
this.#finish(job, { cancelled: Boolean(job.cancelling && !job.preempting) });
|
| 375 |
+
return;
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
const stream = await slot.engine.chat.completions.create({
|
| 379 |
+
...job.params,
|
| 380 |
+
stream: true,
|
| 381 |
+
stream_options: { include_usage: true },
|
| 382 |
+
});
|
| 383 |
+
for await (const chunk of stream) {
|
| 384 |
+
const choice = chunk.choices?.[0];
|
| 385 |
+
job.text += choice?.delta?.content ?? "";
|
| 386 |
+
// WebLLM's own "stop" | "length" | "abort" | "tool_calls". Kept
|
| 387 |
+
// because a caller cannot otherwise tell a natural stop from a
|
| 388 |
+
// `max_tokens` truncation.
|
| 389 |
+
if (choice?.finish_reason) job.finishReason = choice.finish_reason;
|
| 390 |
+
// Assigned, not merged: WebLLM parses the whole output message at the
|
| 391 |
+
// end and emits tool calls complete in one terminal chunk. It never
|
| 392 |
+
// streams the OpenAI-style fragments, so there is nothing to
|
| 393 |
+
// accumulate and a merge step would be machinery for a wire shape
|
| 394 |
+
// that is never produced.
|
| 395 |
+
if (choice?.delta?.tool_calls) job.toolCalls = choice.delta.tool_calls;
|
| 396 |
+
if (chunk.usage) job.usage = chunk.usage;
|
| 397 |
+
// The chunk goes on verbatim. It is already a compliant OpenAI
|
| 398 |
+
// envelope carrying id / created / model / logprobs /
|
| 399 |
+
// system_fingerprint; rebuilding one downstream only loses fields.
|
| 400 |
+
job.onChunk(chunk);
|
| 401 |
+
}
|
| 402 |
+
this.#finish(job, {
|
| 403 |
+
cancelled: Boolean(job.cancelling && !job.preempting),
|
| 404 |
+
preempted: Boolean(job.preempting),
|
| 405 |
+
});
|
| 406 |
+
} catch (err) {
|
| 407 |
+
this.#finish(job, { error: String(err?.message ?? err) });
|
| 408 |
+
} finally {
|
| 409 |
+
slot.job = null;
|
| 410 |
+
job.slot = null;
|
| 411 |
+
this.#pump();
|
| 412 |
+
this.#emit();
|
| 413 |
+
}
|
| 414 |
+
})();
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
#finish(job, extra) {
|
| 418 |
+
if (job.done) return;
|
| 419 |
+
job.done = true;
|
| 420 |
+
if (this.#bySession.get(job.session) === job) this.#bySession.delete(job.session);
|
| 421 |
+
job.resolve({
|
| 422 |
+
id: job.id,
|
| 423 |
+
text: job.text,
|
| 424 |
+
usage: job.usage,
|
| 425 |
+
...(job.embeddings ? { embeddings: job.embeddings } : {}),
|
| 426 |
+
...(job.toolCalls ? { toolCalls: job.toolCalls } : {}),
|
| 427 |
+
// Interrupted work reports "abort" whatever the stream last said.
|
| 428 |
+
finishReason: extra.cancelled || extra.preempted ? "abort" : job.finishReason,
|
| 429 |
+
engineIndex: job.engineIndex,
|
| 430 |
+
startedAt: job.startedAt,
|
| 431 |
+
finishedAt: performance.now(),
|
| 432 |
+
...extra,
|
| 433 |
+
});
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
#emit() {
|
| 437 |
+
this.#onStateChange(this.status());
|
| 438 |
+
}
|
| 439 |
+
}
|
src/engine/prefetch.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* `prefetch(modelId)` — fill the cache without building an engine.
|
| 3 |
+
*
|
| 4 |
+
* The gap this closes: WebLLM only ever downloads a model as a side effect of
|
| 5 |
+
* `reload()`, which instantiates the wasm and needs a GPU before it will fetch
|
| 6 |
+
* a single weight shard. So "warm the cache during onboarding, decide about the
|
| 7 |
+
* GPU later" is not expressible — and it is the thing an app wants to do while
|
| 8 |
+
* the user is still reading the welcome screen.
|
| 9 |
+
*
|
| 10 |
+
* We already write these exact caches for injected models (`ingest.js`); this is
|
| 11 |
+
* the same write plan with the bytes arriving over the network instead of off
|
| 12 |
+
* disk.
|
| 13 |
+
*
|
| 14 |
+
* ## The one dangerous part, and how it is closed
|
| 15 |
+
*
|
| 16 |
+
* To fetch the artifacts ourselves we must know their URLs, which means
|
| 17 |
+
* applying HuggingFace's `/resolve/main/` rule — the same rule
|
| 18 |
+
* [ARCHIVE.md](../../ARCHIVE.md) records *removing* a copy of, and that §2a of
|
| 19 |
+
* the roadmap says not to derive at registration time.
|
| 20 |
+
*
|
| 21 |
+
* Those decisions still hold and this does not contradict them: they are about
|
| 22 |
+
* not deriving a URL that WebLLM will derive again at load, which double-applies
|
| 23 |
+
* it. Here WebLLM is not in the loop at all — we are the loader — so there is
|
| 24 |
+
* nothing to double-apply.
|
| 25 |
+
*
|
| 26 |
+
* What makes it *safe* is that we do not trust our own derivation. A key that is
|
| 27 |
+
* off by one character writes a cache WebLLM's loader will never look in, and
|
| 28 |
+
* the symptom is the worst kind: prefetch reports success and the user downloads
|
| 29 |
+
* the model twice. So every prefetch ends by asking **WebLLM's own
|
| 30 |
+
* `hasModelInCache`** — which derives the URL through the very function we are
|
| 31 |
+
* mirroring — whether the model is really there. If it says no, this throws
|
| 32 |
+
* instead of claiming success.
|
| 33 |
+
*
|
| 34 |
+
* `webllm-contract.test.mjs` additionally pins the rule against the bundle, so
|
| 35 |
+
* an upstream change to the URL scheme fails a test rather than a user's
|
| 36 |
+
* download.
|
| 37 |
+
*/
|
| 38 |
+
import { ERROR, EngineError } from "./errors.js";
|
| 39 |
+
import {
|
| 40 |
+
CHAT_CONFIG,
|
| 41 |
+
CONTENT_TYPES,
|
| 42 |
+
LEGACY_TENSOR_MANIFEST,
|
| 43 |
+
TENSOR_MANIFEST,
|
| 44 |
+
} from "./ingest.js";
|
| 45 |
+
import { CACHE_CONFIG, CACHE_MODEL, CACHE_WASM, isInjected } from "./model-store.js";
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* WebLLM's `cleanModelUrl`, mirrored deliberately.
|
| 49 |
+
*
|
| 50 |
+
* Kept character-for-character with the bundle's version (see the contract
|
| 51 |
+
* test) because the whole point is to produce the same cache keys its loader
|
| 52 |
+
* will look for.
|
| 53 |
+
*/
|
| 54 |
+
export function resolveModelUrl(modelUrl) {
|
| 55 |
+
let url = modelUrl + (modelUrl.endsWith("/") ? "" : "/");
|
| 56 |
+
if (!url.match(/.+\/resolve\/.+\//)) url += "resolve/main/";
|
| 57 |
+
return new URL(url).href;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/**
|
| 61 |
+
* @param {object} opts
|
| 62 |
+
* @param {string} opts.modelId
|
| 63 |
+
* @param {object} opts.record the merged app-config entry: `model`, `model_lib`
|
| 64 |
+
* @param {(p: {phase: string, done: number, total: number, label: string}) => void} [opts.onProgress]
|
| 65 |
+
* @param {AbortSignal} [opts.signal]
|
| 66 |
+
* @param {(url: string, init?: object) => Promise<Response>} [opts.fetchImpl]
|
| 67 |
+
* @returns {Promise<{modelId: string, files: number, bytes: number, alreadyCached: boolean}>}
|
| 68 |
+
*/
|
| 69 |
+
export async function prefetchModel({
|
| 70 |
+
modelId,
|
| 71 |
+
record,
|
| 72 |
+
onProgress = () => {},
|
| 73 |
+
signal,
|
| 74 |
+
fetchImpl = globalThis.fetch,
|
| 75 |
+
}) {
|
| 76 |
+
if (!record?.model_lib) {
|
| 77 |
+
throw new EngineError(
|
| 78 |
+
ERROR.BAD_REQUEST,
|
| 79 |
+
`"${modelId}" has no \`model_lib\`, so there is nothing to prefetch from. ` +
|
| 80 |
+
"A remote source needs one; see load(url, { modelLib }).",
|
| 81 |
+
{ modelId },
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
const base = resolveModelUrl(record.model);
|
| 86 |
+
const get = async (url, what) => {
|
| 87 |
+
if (signal?.aborted) throw aborted(modelId);
|
| 88 |
+
const res = await fetchImpl(url, signal ? { signal } : undefined).catch((err) => {
|
| 89 |
+
throw new EngineError(ERROR.GENERATION_FAILED, `Prefetch could not reach ${what}: ${err?.message ?? err}`, {
|
| 90 |
+
modelId,
|
| 91 |
+
url,
|
| 92 |
+
});
|
| 93 |
+
});
|
| 94 |
+
if (!res.ok) {
|
| 95 |
+
throw new EngineError(
|
| 96 |
+
ERROR.UNKNOWN_MODEL,
|
| 97 |
+
`Prefetch got ${res.status} for ${what} at ${url}. ` +
|
| 98 |
+
"Check the model's base URL — a 404 here usually means the id or the URL is wrong.",
|
| 99 |
+
{ modelId, url, status: res.status },
|
| 100 |
+
);
|
| 101 |
+
}
|
| 102 |
+
return res;
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
// The config first: it names the tokenizer files, so the plan cannot be built
|
| 106 |
+
// without it. Same order `reload()` uses.
|
| 107 |
+
onProgress({ phase: "manifest", done: 0, total: 1, label: CHAT_CONFIG });
|
| 108 |
+
const configRes = await get(base + CHAT_CONFIG, CHAT_CONFIG);
|
| 109 |
+
const configBytes = await configRes.arrayBuffer();
|
| 110 |
+
const chatConfig = parseJson(configBytes, CHAT_CONFIG, modelId);
|
| 111 |
+
|
| 112 |
+
// `tensor-cache.json`, falling back to the legacy name, exactly as ingest does.
|
| 113 |
+
let manifestName = TENSOR_MANIFEST;
|
| 114 |
+
let manifestRes = await fetchImpl(base + TENSOR_MANIFEST, signal ? { signal } : undefined).catch(() => null);
|
| 115 |
+
if (!manifestRes?.ok) {
|
| 116 |
+
manifestName = LEGACY_TENSOR_MANIFEST;
|
| 117 |
+
manifestRes = await get(base + LEGACY_TENSOR_MANIFEST, "the weight index");
|
| 118 |
+
}
|
| 119 |
+
const manifestBytes = await manifestRes.arrayBuffer();
|
| 120 |
+
const manifest = parseJson(manifestBytes, manifestName, modelId);
|
| 121 |
+
|
| 122 |
+
const shards = (manifest.records ?? []).map((r) => r.dataPath).filter(Boolean);
|
| 123 |
+
if (shards.length === 0) {
|
| 124 |
+
throw new EngineError(
|
| 125 |
+
ERROR.UNKNOWN_MODEL,
|
| 126 |
+
`${manifestName} at ${base} lists no weight shards, so this is not an MLC model directory.`,
|
| 127 |
+
{ modelId, url: base + manifestName },
|
| 128 |
+
);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
const tokenizers = (Array.isArray(chatConfig.tokenizer_files) ? chatConfig.tokenizer_files : []).filter(
|
| 132 |
+
(n) => n === "tokenizer.json" || n === "tokenizer.model",
|
| 133 |
+
);
|
| 134 |
+
|
| 135 |
+
const plan = [
|
| 136 |
+
{ scope: CACHE_CONFIG, url: base + CHAT_CONFIG, body: configBytes, type: CONTENT_TYPES.json },
|
| 137 |
+
{ scope: CACHE_MODEL, url: base + manifestName, body: manifestBytes, type: CONTENT_TYPES.json },
|
| 138 |
+
...tokenizers.map((name) => ({
|
| 139 |
+
scope: CACHE_MODEL,
|
| 140 |
+
url: base + name,
|
| 141 |
+
type: name.endsWith(".json") ? CONTENT_TYPES.json : CONTENT_TYPES.bin,
|
| 142 |
+
})),
|
| 143 |
+
...shards.map((p) => ({ scope: CACHE_MODEL, url: new URL(p, base).href, type: CONTENT_TYPES.bin })),
|
| 144 |
+
// Verbatim, never derived — `model_lib` is a literal URL on the record and
|
| 145 |
+
// is not even on the same origin as the weights for any prebuilt model.
|
| 146 |
+
{ scope: CACHE_WASM, url: record.model_lib, type: CONTENT_TYPES.wasm },
|
| 147 |
+
];
|
| 148 |
+
|
| 149 |
+
const openCaches = new Map();
|
| 150 |
+
const cacheFor = async (scope) => {
|
| 151 |
+
if (!openCaches.has(scope)) openCaches.set(scope, await caches.open(scope));
|
| 152 |
+
return openCaches.get(scope);
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
let bytes = 0;
|
| 156 |
+
let done = 0;
|
| 157 |
+
for (const item of plan) {
|
| 158 |
+
if (signal?.aborted) throw aborted(modelId);
|
| 159 |
+
const cache = await cacheFor(item.scope);
|
| 160 |
+
onProgress({ phase: "downloading", done, total: plan.length, label: basename(item.url) });
|
| 161 |
+
|
| 162 |
+
// Skip what is already there: a resumed prefetch should cost only the
|
| 163 |
+
// remainder, the same way a resumed `load()` does.
|
| 164 |
+
if (item.body === undefined && (await cache.match(new Request(item.url)))) {
|
| 165 |
+
done += 1;
|
| 166 |
+
continue;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
const body = item.body ?? (await (await get(item.url, basename(item.url))).arrayBuffer());
|
| 170 |
+
bytes += body.byteLength;
|
| 171 |
+
await cache.put(
|
| 172 |
+
new Request(item.url),
|
| 173 |
+
new Response(body, { status: 200, headers: { "Content-Type": item.type } }),
|
| 174 |
+
);
|
| 175 |
+
done += 1;
|
| 176 |
+
}
|
| 177 |
+
onProgress({ phase: "downloading", done, total: plan.length, label: "done" });
|
| 178 |
+
|
| 179 |
+
return { modelId, files: plan.length, bytes, alreadyCached: false };
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
const aborted = (modelId) =>
|
| 183 |
+
new EngineError(ERROR.ABORTED, `Prefetch of "${modelId}" was aborted.`, { modelId });
|
| 184 |
+
|
| 185 |
+
function parseJson(buffer, what, modelId) {
|
| 186 |
+
try {
|
| 187 |
+
return JSON.parse(new TextDecoder().decode(buffer));
|
| 188 |
+
} catch {
|
| 189 |
+
throw new EngineError(
|
| 190 |
+
ERROR.UNKNOWN_MODEL,
|
| 191 |
+
`${what} for "${modelId}" is not valid JSON — the URL is probably not an MLC model directory.`,
|
| 192 |
+
{ modelId, what },
|
| 193 |
+
);
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
const basename = (url) => url.split("/").pop() || url;
|
| 198 |
+
|
| 199 |
+
/** Only for injected models: they are in the cache before they are ever registered. */
|
| 200 |
+
export const isAlreadyLocal = (record) => isInjected(record);
|
src/engine/recipes.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* The three shapes of work, as one call each.
|
| 3 |
+
*
|
| 4 |
+
* `complete()` and `chat.completions.create()` can already express all of this.
|
| 5 |
+
* What they cannot do is stop a caller getting the *scheduling* wrong, and the
|
| 6 |
+
* scheduling is the part that is easy to get wrong and invisible when you do:
|
| 7 |
+
* ghost text that lags a keystroke behind, a conversation whose turns fight
|
| 8 |
+
* each other for engines, a one-shot that superseded the last one because it
|
| 9 |
+
* reused a session key. Those are the bugs AI.md's "Getting these wrong" table
|
| 10 |
+
* is made of, and every row of it is a scheduling mistake rather than a
|
| 11 |
+
* generation one.
|
| 12 |
+
*
|
| 13 |
+
* So these are not wrappers that save typing. Each one is a *policy*:
|
| 14 |
+
*
|
| 15 |
+
* ask() one-shot, its own task, no session, nothing to supersede
|
| 16 |
+
* conversation() one stable task for every turn, history the caller can see
|
| 17 |
+
* ghostText() debounce + one session key + interactive + drop-if-stale
|
| 18 |
+
*
|
| 19 |
+
* ## What they deliberately do not do
|
| 20 |
+
*
|
| 21 |
+
* **They author no prompts.** `ask()` and `conversation()` carry the caller's
|
| 22 |
+
* own text through as a message; `ghostText()` *requires* a `prompt` function
|
| 23 |
+
* and has no default for it. This is AI.md's load-bearing rule, and the reason
|
| 24 |
+
* is not purity: prompts are model-specific, and switching this project's own
|
| 25 |
+
* build from `Qwen3.5-0.8B` to `Qwen3.8-2B-Distill` changed the conversation
|
| 26 |
+
* template and made every reply open with a `<think>` block. A prompt that
|
| 27 |
+
* lives in the caller survives that. One baked in here would have to be
|
| 28 |
+
* rewritten and re-shipped to every caller.
|
| 29 |
+
*/
|
| 30 |
+
import { ERROR, EngineError } from "./errors.js";
|
| 31 |
+
import { PRIORITY } from "./constants.js";
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* A single question, with nothing kept afterwards.
|
| 35 |
+
*
|
| 36 |
+
* Its own task and no session, so two `ask()`s in flight never supersede each
|
| 37 |
+
* other and never queue behind one another for the same engine — which is what
|
| 38 |
+
* would happen if this shared a session key with anything else.
|
| 39 |
+
*
|
| 40 |
+
* @param {import("./engine.js").ScheduledEngine} engine
|
| 41 |
+
* @param {string | Array<{role: string, content: string}>} input
|
| 42 |
+
* @param {object} [opts] anything `complete()` takes; `onDelta` to stream
|
| 43 |
+
* @returns {Promise<string>} the reply text
|
| 44 |
+
*/
|
| 45 |
+
export async function ask(engine, input, { onDelta, ...opts } = {}) {
|
| 46 |
+
const messages = toMessages(input, "ask");
|
| 47 |
+
const { text } = await engine.complete(
|
| 48 |
+
{ priority: PRIORITY.NORMAL, ...opts, messages },
|
| 49 |
+
onDelta,
|
| 50 |
+
);
|
| 51 |
+
return text;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* A multi-turn conversation that remembers its own history.
|
| 56 |
+
*
|
| 57 |
+
* Two things this gets right that hand-rolled history usually does not:
|
| 58 |
+
*
|
| 59 |
+
* **One task for the whole conversation.** Every turn carries the same `task`,
|
| 60 |
+
* so a conversation holds at most one engine and a long reply can never occupy
|
| 61 |
+
* the pool while another conversation waits. Turns within it are serialised,
|
| 62 |
+
* which is what a conversation means anyway.
|
| 63 |
+
*
|
| 64 |
+
* **A bounded history, by default.** There is no cross-turn KV reuse on this
|
| 65 |
+
* stack (AI.md: every turn re-prefills the whole history at ~5.27 ms/token), so
|
| 66 |
+
* an unbounded conversation gets quadratically slower and a turn near the 4096
|
| 67 |
+
* limit waits ~22 s for its first token. `keep` bounds it. Set `keep: Infinity`
|
| 68 |
+
* to opt out, having read that sentence.
|
| 69 |
+
*
|
| 70 |
+
* @param {import("./engine.js").ScheduledEngine} engine
|
| 71 |
+
* @param {object} [opts]
|
| 72 |
+
* @param {string} [opts.system] a system message, prepended and never trimmed
|
| 73 |
+
* @param {number} [opts.keep] how many *exchanges* of history to carry
|
| 74 |
+
* @param {string} [opts.task] defaults to a fresh id
|
| 75 |
+
*/
|
| 76 |
+
export function conversation(engine, { system, keep = 12, task, ...defaults } = {}) {
|
| 77 |
+
if (typeof keep !== "number" || (keep < 1 && keep !== Infinity)) {
|
| 78 |
+
throw new EngineError(
|
| 79 |
+
ERROR.BAD_REQUEST,
|
| 80 |
+
`conversation({ keep }) must be a positive number of exchanges, or Infinity. Got ${JSON.stringify(keep)}.`,
|
| 81 |
+
{ keep },
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
const id = task ?? `conversation-${++counter}`;
|
| 86 |
+
/** User/assistant messages only; `system` is held apart so trimming cannot eat it. */
|
| 87 |
+
let turns = [];
|
| 88 |
+
/** Turns are serialised: overlapping ones would interleave history. */
|
| 89 |
+
let inFlight = Promise.resolve();
|
| 90 |
+
|
| 91 |
+
async function turn(content, onDelta) {
|
| 92 |
+
const sent = [...api.messages, { role: "user", content }];
|
| 93 |
+
const { text, finishReason } = await engine.complete(
|
| 94 |
+
{ ...defaults, task: id, priority: defaults.priority ?? PRIORITY.NORMAL, messages: sent },
|
| 95 |
+
onDelta,
|
| 96 |
+
);
|
| 97 |
+
// Appended only on success: a failed turn must not leave the history
|
| 98 |
+
// holding a question the model never answered.
|
| 99 |
+
turns.push({ role: "user", content }, { role: "assistant", content: text });
|
| 100 |
+
trim();
|
| 101 |
+
return { text, finishReason };
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
const api = {
|
| 105 |
+
/** The messages as they would be sent, including the system message. */
|
| 106 |
+
get messages() {
|
| 107 |
+
return system ? [{ role: "system", content: system }, ...turns] : [...turns];
|
| 108 |
+
},
|
| 109 |
+
|
| 110 |
+
/** Exchanges currently retained. */
|
| 111 |
+
get length() {
|
| 112 |
+
return Math.ceil(turns.length / 2);
|
| 113 |
+
},
|
| 114 |
+
|
| 115 |
+
/**
|
| 116 |
+
* Say something and get the reply, with the exchange appended to history.
|
| 117 |
+
*
|
| 118 |
+
* Serialised against the previous turn: a conversation whose turns
|
| 119 |
+
* overlapped would interleave history and produce replies to the wrong
|
| 120 |
+
* question.
|
| 121 |
+
*
|
| 122 |
+
* @param {string} content
|
| 123 |
+
* @param {(delta: string) => void} [onDelta]
|
| 124 |
+
*/
|
| 125 |
+
async say(content, onDelta) {
|
| 126 |
+
if (typeof content !== "string" || content.length === 0) {
|
| 127 |
+
throw new EngineError(ERROR.BAD_REQUEST, "conversation.say() needs a non-empty string.", {
|
| 128 |
+
received: typeof content,
|
| 129 |
+
});
|
| 130 |
+
}
|
| 131 |
+
const run = inFlight.then(() => turn(content, onDelta));
|
| 132 |
+
// The chain must survive a failed turn: `.catch` here keeps the *queue*
|
| 133 |
+
// moving without swallowing the rejection the caller is awaiting.
|
| 134 |
+
inFlight = run.catch(() => {});
|
| 135 |
+
return run;
|
| 136 |
+
},
|
| 137 |
+
|
| 138 |
+
/** Forget the history. The system message and settings survive. */
|
| 139 |
+
reset() {
|
| 140 |
+
turns = [];
|
| 141 |
+
return api;
|
| 142 |
+
},
|
| 143 |
+
|
| 144 |
+
/** Drop the history in, e.g. when restoring a saved conversation. */
|
| 145 |
+
restore(messages) {
|
| 146 |
+
turns = messages.filter((m) => m.role !== "system").map((m) => ({ ...m }));
|
| 147 |
+
trim();
|
| 148 |
+
return api;
|
| 149 |
+
},
|
| 150 |
+
};
|
| 151 |
+
|
| 152 |
+
function trim() {
|
| 153 |
+
if (keep === Infinity) return;
|
| 154 |
+
const max = keep * 2;
|
| 155 |
+
if (turns.length > max) turns = turns.slice(turns.length - max);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
return api;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
/**
|
| 162 |
+
* Ghost text: the scheduling discipline, with the prompt left to the caller.
|
| 163 |
+
*
|
| 164 |
+
* Every part of this exists because of a specific way ghost text goes wrong:
|
| 165 |
+
*
|
| 166 |
+
* | | |
|
| 167 |
+
* | --- | --- |
|
| 168 |
+
* | one stable `session` | a fresh id per keystroke makes every stale request still run — AI.md's first "getting these wrong" row |
|
| 169 |
+
* | `interactive` priority | it is the one band that may preempt work that opted in |
|
| 170 |
+
* | debounce | a request per keystroke queues faster than the GPU drains |
|
| 171 |
+
* | short `max_tokens` | ghost text is a few words; paying for more is pure latency |
|
| 172 |
+
* | resolves `null` when stale | so a superseded suggestion **cannot** be rendered by mistake |
|
| 173 |
+
*
|
| 174 |
+
* That last one is the difference between this and a wrapper. The engine
|
| 175 |
+
* already supersedes stale requests; what a caller still has to remember is not
|
| 176 |
+
* to paint the answer that comes back. Returning `null` removes the choice.
|
| 177 |
+
*
|
| 178 |
+
* @param {import("./engine.js").ScheduledEngine} engine
|
| 179 |
+
* @param {object} opts
|
| 180 |
+
* @param {(context: any) => string | Array<object>} opts.prompt **required** —
|
| 181 |
+
* builds the messages. Never defaulted: see the module header.
|
| 182 |
+
* @param {number} [opts.debounceMs]
|
| 183 |
+
* @param {number} [opts.maxTokens]
|
| 184 |
+
* @param {string} [opts.session]
|
| 185 |
+
*/
|
| 186 |
+
export function ghostText(engine, { prompt, debounceMs = 120, maxTokens = 24, session, ...defaults } = {}) {
|
| 187 |
+
if (typeof prompt !== "function") {
|
| 188 |
+
throw new EngineError(
|
| 189 |
+
ERROR.BAD_REQUEST,
|
| 190 |
+
"ghostText({ prompt }) needs a function that turns your editor context into messages. " +
|
| 191 |
+
"It has no default on purpose: prompts are model-specific and belong to whoever owns the feature.",
|
| 192 |
+
{ received: typeof prompt },
|
| 193 |
+
);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
const key = session ?? `ghost-${++counter}`;
|
| 197 |
+
let generation = 0;
|
| 198 |
+
let timer = null;
|
| 199 |
+
/**
|
| 200 |
+
* Settles the debounce wait of the call currently holding the timer.
|
| 201 |
+
*
|
| 202 |
+
* Without this, cancelling that timer left its `await` with nothing to
|
| 203 |
+
* resolve it — every superseded keystroke leaked a promise that never
|
| 204 |
+
* settled, and `Promise.all` over a burst of them hung forever. A superseded
|
| 205 |
+
* waiter has to be *woken and told it lost*, not merely disarmed.
|
| 206 |
+
*/
|
| 207 |
+
let wake = null;
|
| 208 |
+
|
| 209 |
+
const stopWaiting = (quiet) => {
|
| 210 |
+
clearTimeout(timer);
|
| 211 |
+
timer = null;
|
| 212 |
+
const settle = wake;
|
| 213 |
+
wake = null;
|
| 214 |
+
settle?.(quiet);
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
+
const api = {
|
| 218 |
+
/**
|
| 219 |
+
* Ask for a suggestion. Debounced, superseding, and `null` when stale.
|
| 220 |
+
*
|
| 221 |
+
* @param {any} context whatever `prompt` takes
|
| 222 |
+
* @returns {Promise<string | null>} `null` if superseded or cancelled
|
| 223 |
+
*/
|
| 224 |
+
async suggest(context) {
|
| 225 |
+
const mine = ++generation;
|
| 226 |
+
|
| 227 |
+
if (debounceMs > 0) {
|
| 228 |
+
stopWaiting(false); // the previous waiter loses, and is told so
|
| 229 |
+
const quiet = await new Promise((resolve) => {
|
| 230 |
+
wake = resolve;
|
| 231 |
+
timer = setTimeout(() => stopWaiting(true), debounceMs);
|
| 232 |
+
});
|
| 233 |
+
// A newer keystroke landed while waiting; that request owns the session.
|
| 234 |
+
if (!quiet || mine !== generation) return null;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
const messages = toMessages(prompt(context), "ghostText's prompt()");
|
| 238 |
+
const result = await engine.complete({
|
| 239 |
+
max_tokens: maxTokens,
|
| 240 |
+
...defaults,
|
| 241 |
+
messages,
|
| 242 |
+
session: key,
|
| 243 |
+
priority: PRIORITY.INTERACTIVE,
|
| 244 |
+
});
|
| 245 |
+
|
| 246 |
+
// Two ways to be stale, and both must return null: the engine superseded
|
| 247 |
+
// us (`cancelled`), or a newer suggest() started while we generated.
|
| 248 |
+
if (result.cancelled || result.preempted || mine !== generation) return null;
|
| 249 |
+
return result.text;
|
| 250 |
+
},
|
| 251 |
+
|
| 252 |
+
/** On blur, or on accept. Cancels in flight and invalidates anything pending. */
|
| 253 |
+
cancel() {
|
| 254 |
+
generation += 1;
|
| 255 |
+
stopWaiting(false);
|
| 256 |
+
return engine.cancel(key);
|
| 257 |
+
},
|
| 258 |
+
};
|
| 259 |
+
|
| 260 |
+
return api;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
let counter = 0;
|
| 264 |
+
|
| 265 |
+
/** The one place caller text becomes a message, so no verb invents its own shape. */
|
| 266 |
+
function toMessages(input, who) {
|
| 267 |
+
if (typeof input === "string") {
|
| 268 |
+
if (input.length === 0) {
|
| 269 |
+
throw new EngineError(ERROR.BAD_REQUEST, `${who} was given an empty string.`, { who });
|
| 270 |
+
}
|
| 271 |
+
return [{ role: "user", content: input }];
|
| 272 |
+
}
|
| 273 |
+
if (Array.isArray(input) && input.length > 0 && input.every((m) => m?.role && m?.content !== undefined)) {
|
| 274 |
+
return input;
|
| 275 |
+
}
|
| 276 |
+
throw new EngineError(
|
| 277 |
+
ERROR.BAD_REQUEST,
|
| 278 |
+
`${who} needs a string, or a non-empty array of { role, content } messages.`,
|
| 279 |
+
{ who, received: Array.isArray(input) ? "array" : typeof input },
|
| 280 |
+
);
|
| 281 |
+
}
|
src/engine/sources.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* What `load()` was handed, decided before anything touches the network.
|
| 3 |
+
*
|
| 4 |
+
* `load` absorbs `registerModel` and `ingestModelFolder`, so one call has to
|
| 5 |
+
* tell four things apart: a model id, a URL to fetch from, an explicit remote
|
| 6 |
+
* spec, and a folder off disk. Keeping that decision here — pure, synchronous,
|
| 7 |
+
* no store and no engine — means it can be tested exhaustively without a GPU,
|
| 8 |
+
* and means `load()` reads as "classify, then act".
|
| 9 |
+
*
|
| 10 |
+
* Two rules this deliberately does **not** implement, both measured rather than
|
| 11 |
+
* assumed:
|
| 12 |
+
*
|
| 13 |
+
* - **`modelLib` is never guessed.** `<base><id>-webgpu.wasm` matches 0 of the
|
| 14 |
+
* 163 prebuilt models (real names carry a `_cs1k`-style suffix and drop the
|
| 15 |
+
* `-MLC`), and 0 of 163 host the lib on the same origin as the weights —
|
| 16 |
+
* they live on `raw.githubusercontent.com`. A guess would be wrong every
|
| 17 |
+
* time and would surface as a 404 deep inside WebLLM's loader, which is the
|
| 18 |
+
* worst place to learn it. So a URL source without `modelLib` fails here,
|
| 19 |
+
* immediately, with a sentence saying why.
|
| 20 |
+
* - **`/resolve/main/` is never derived for HuggingFace URLs.** WebLLM's
|
| 21 |
+
* `cleanModelUrl` already appends it when the URL does not match
|
| 22 |
+
* `.+/resolve/.+/`. Deriving it here would re-introduce exactly the
|
| 23 |
+
* duplication ARCHIVE.md records removing. The URL is passed through.
|
| 24 |
+
*/
|
| 25 |
+
import { ERROR, EngineError } from "./errors.js";
|
| 26 |
+
|
| 27 |
+
/** @typedef {"id"|"remote"|"register"|"files"} SourceKind */
|
| 28 |
+
|
| 29 |
+
export const SOURCE_KIND = {
|
| 30 |
+
ID: "id",
|
| 31 |
+
REMOTE: "remote",
|
| 32 |
+
REGISTER: "register",
|
| 33 |
+
FILES: "files",
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
/** A scheme, as `absolutize()` recognises one. */
|
| 37 |
+
const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* A string is a location rather than an id when it carries a scheme or is
|
| 41 |
+
* explicitly path-shaped. No prebuilt model id contains `/` or `:`, so this
|
| 42 |
+
* never steals a real id — and it means `load("/models/foo/")` is understood
|
| 43 |
+
* as the URL it obviously is instead of being looked up and reported missing.
|
| 44 |
+
*/
|
| 45 |
+
export const looksLikeUrl = (s) =>
|
| 46 |
+
HAS_SCHEME.test(s) || s.startsWith("/") || s.startsWith("./") || s.startsWith("../");
|
| 47 |
+
|
| 48 |
+
/** Duck-typed: `DataTransfer` and `FileList` do not exist in Node. */
|
| 49 |
+
export const isDataTransfer = (v) => !!v && typeof v === "object" && "items" in v && "files" in v;
|
| 50 |
+
export const isFileList = (v) =>
|
| 51 |
+
!!v &&
|
| 52 |
+
typeof v === "object" &&
|
| 53 |
+
typeof v.length === "number" &&
|
| 54 |
+
!Array.isArray(v) &&
|
| 55 |
+
(v.length === 0 || typeof v[0]?.name === "string");
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* The local label for a model loaded from a URL.
|
| 59 |
+
*
|
| 60 |
+
* Safe to derive, unlike `modelLib`: an id is a key in our own registry, never
|
| 61 |
+
* a path anything fetches, so a wrong guess is visible immediately and costs
|
| 62 |
+
* nothing. `.../mlc-ai/Foo-MLC` and `.../models/foo/` both give the last real
|
| 63 |
+
* segment.
|
| 64 |
+
*/
|
| 65 |
+
export function idFromUrl(url) {
|
| 66 |
+
const path = HAS_SCHEME.test(url) ? safeUrlPath(url) : url;
|
| 67 |
+
const segments = path.split("/").filter((s) => s && s !== "." && s !== "..");
|
| 68 |
+
const last = segments.at(-1);
|
| 69 |
+
return last ? decodeURIComponent(last) : null;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function safeUrlPath(url) {
|
| 73 |
+
try {
|
| 74 |
+
return new URL(url).pathname;
|
| 75 |
+
} catch {
|
| 76 |
+
return url;
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/**
|
| 81 |
+
* @param {string | object} src
|
| 82 |
+
* @param {{id?: string, modelLib?: string}} [opts]
|
| 83 |
+
* @returns {{kind: SourceKind, modelId?: string, model?: string, modelLib?: string, files?: unknown}}
|
| 84 |
+
*/
|
| 85 |
+
export function classifySource(src, opts = {}) {
|
| 86 |
+
if (src === null || src === undefined || src === "") {
|
| 87 |
+
throw new EngineError(
|
| 88 |
+
ERROR.BAD_REQUEST,
|
| 89 |
+
"load() needs a model id, a URL, `{ model, modelLib }`, or a folder " +
|
| 90 |
+
"(`{ files }`, a FileList, or a drop event's DataTransfer).",
|
| 91 |
+
);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
if (typeof src === "string") {
|
| 95 |
+
if (!looksLikeUrl(src)) return { kind: SOURCE_KIND.ID, modelId: src };
|
| 96 |
+
return remote(src, opts.modelLib, opts.id ?? idFromUrl(src));
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Folder routes first: a DataTransfer also has other properties, and `files`
|
| 100 |
+
// is the field that decides, exactly as `registerModel` already reads it.
|
| 101 |
+
if (isDataTransfer(src) || isFileList(src) || Array.isArray(src)) {
|
| 102 |
+
return { kind: SOURCE_KIND.FILES, files: src, modelId: opts.id };
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
if (typeof src === "object") {
|
| 106 |
+
if (src.files !== undefined) {
|
| 107 |
+
if (src.model || src.modelLib) {
|
| 108 |
+
throw new EngineError(
|
| 109 |
+
ERROR.BAD_REQUEST,
|
| 110 |
+
"load() takes either `files` (local, never fetched) or `model`/`modelLib` " +
|
| 111 |
+
"(a base URL to fetch), not both.",
|
| 112 |
+
);
|
| 113 |
+
}
|
| 114 |
+
return { kind: SOURCE_KIND.FILES, files: src.files, modelId: src.modelId ?? opts.id };
|
| 115 |
+
}
|
| 116 |
+
if (src.model !== undefined) {
|
| 117 |
+
// The id falls back to the URL exactly as the string form's does — the
|
| 118 |
+
// two spellings of "a model at this URL" must not disagree about it.
|
| 119 |
+
const spec = remote(
|
| 120 |
+
src.model,
|
| 121 |
+
src.modelLib ?? opts.modelLib,
|
| 122 |
+
src.modelId ?? opts.id ?? idFromUrl(String(src.model)),
|
| 123 |
+
);
|
| 124 |
+
return { ...spec, kind: SOURCE_KIND.REGISTER };
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
throw new EngineError(
|
| 129 |
+
ERROR.BAD_REQUEST,
|
| 130 |
+
"load() did not recognise that source. Pass a model id, a URL, " +
|
| 131 |
+
"`{ model, modelLib }`, or a folder (`{ files }`, a FileList, or a DataTransfer).",
|
| 132 |
+
{ received: typeof src },
|
| 133 |
+
);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/** The one place a URL source is validated, so both string and object forms agree. */
|
| 137 |
+
function remote(model, modelLib, modelId) {
|
| 138 |
+
if (!modelLib) {
|
| 139 |
+
throw new EngineError(
|
| 140 |
+
ERROR.BAD_REQUEST,
|
| 141 |
+
`Loading "${model}" from a URL needs \`modelLib\` — the compiled .wasm — and it cannot be ` +
|
| 142 |
+
"guessed: across all 163 prebuilt models, none has a lib name derivable from its id and " +
|
| 143 |
+
"none hosts the lib on the same origin as the weights. " +
|
| 144 |
+
"Pass load(url, { modelLib: \"https://.../foo-webgpu.wasm\" }).",
|
| 145 |
+
{ model },
|
| 146 |
+
);
|
| 147 |
+
}
|
| 148 |
+
if (!modelId) {
|
| 149 |
+
throw new EngineError(
|
| 150 |
+
ERROR.BAD_REQUEST,
|
| 151 |
+
`Could not derive a model id from "${model}". Pass one as load(src, { id }).`,
|
| 152 |
+
{ model },
|
| 153 |
+
);
|
| 154 |
+
}
|
| 155 |
+
return { kind: SOURCE_KIND.REMOTE, model, modelLib, modelId };
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/**
|
| 159 |
+
* Ids close enough to be a typo, so an unknown id can say "did you mean".
|
| 160 |
+
*
|
| 161 |
+
* Cheap on purpose — case-insensitive substring both ways, then a bounded edit
|
| 162 |
+
* distance. The list is at most a few hundred entries and this runs once, on a
|
| 163 |
+
* path that is already about to throw.
|
| 164 |
+
*/
|
| 165 |
+
export function nearMatches(wanted, available, limit = 3) {
|
| 166 |
+
const needle = wanted.toLowerCase();
|
| 167 |
+
const scored = available
|
| 168 |
+
.map((id) => {
|
| 169 |
+
const hay = id.toLowerCase();
|
| 170 |
+
if (hay === needle) return { id, score: 0 };
|
| 171 |
+
if (hay.includes(needle) || needle.includes(hay)) return { id, score: 1 };
|
| 172 |
+
return { id, score: 2 + editDistance(needle, hay) };
|
| 173 |
+
})
|
| 174 |
+
// Beyond this a "suggestion" is noise, and a wrong suggestion is worse than
|
| 175 |
+
// none — it sends the reader looking for a model that was never the point.
|
| 176 |
+
.filter(({ score, id }) => score <= 2 + Math.ceil(Math.max(id.length, needle.length) / 3))
|
| 177 |
+
.sort((a, b) => a.score - b.score || a.id.localeCompare(b.id));
|
| 178 |
+
|
| 179 |
+
return scored.slice(0, limit).map(({ id }) => id);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/** Levenshtein, two rows. */
|
| 183 |
+
function editDistance(a, b) {
|
| 184 |
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
| 185 |
+
for (let i = 1; i <= a.length; i++) {
|
| 186 |
+
const row = [i];
|
| 187 |
+
for (let j = 1; j <= b.length; j++) {
|
| 188 |
+
row[j] = Math.min(
|
| 189 |
+
prev[j] + 1,
|
| 190 |
+
row[j - 1] + 1,
|
| 191 |
+
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
| 192 |
+
);
|
| 193 |
+
}
|
| 194 |
+
prev = row;
|
| 195 |
+
}
|
| 196 |
+
return prev[b.length];
|
| 197 |
+
}
|
src/manager/manager.css
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.wrap { max-width: 820px; margin: 0 auto; padding: 28px 24px 72px; display: flex; flex-direction: column; gap: 30px; }
|
| 2 |
+
|
| 3 |
+
header { padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
| 4 |
+
section { display: flex; flex-direction: column; gap: 10px; }
|
| 5 |
+
|
| 6 |
+
/* ------------------------------------------------------------ engine API --- */
|
| 7 |
+
/* First section on the page: an integrator opening this page is here for the
|
| 8 |
+
ID and a snippet, not to re-read the model list. */
|
| 9 |
+
#api { padding: 18px; border: 1px solid var(--line-strong); border-radius: 12px; background: var(--panel); }
|
| 10 |
+
|
| 11 |
+
.idbar { display: flex; gap: 8px; align-items: center; }
|
| 12 |
+
.idbar > code {
|
| 13 |
+
padding: 7px 12px;
|
| 14 |
+
border: 1px solid var(--line);
|
| 15 |
+
border-radius: 8px;
|
| 16 |
+
background: var(--bg);
|
| 17 |
+
overflow-x: auto;
|
| 18 |
+
white-space: nowrap;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.tabs { display: flex; gap: 4px; margin-top: 4px; }
|
| 22 |
+
.tab { border-color: transparent; color: var(--muted); padding: 5px 11px; border-radius: 99px; }
|
| 23 |
+
.tab:hover:not(.active) { background: var(--panel-strong); }
|
| 24 |
+
.tab.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 500; }
|
| 25 |
+
|
| 26 |
+
.codewrap { position: relative; }
|
| 27 |
+
.codewrap .copy {
|
| 28 |
+
position: absolute;
|
| 29 |
+
top: 8px;
|
| 30 |
+
right: 8px;
|
| 31 |
+
padding: 4px 10px;
|
| 32 |
+
font-size: 12px;
|
| 33 |
+
background: var(--bg);
|
| 34 |
+
opacity: 0;
|
| 35 |
+
transition: opacity .12s ease;
|
| 36 |
+
}
|
| 37 |
+
.codewrap:hover .copy, .codewrap .copy:focus-visible { opacity: 1; }
|
| 38 |
+
|
| 39 |
+
/* ---------------------------------------------------------------- models --- */
|
| 40 |
+
#drop {
|
| 41 |
+
border: 1.5px dashed var(--line-strong);
|
| 42 |
+
border-radius: 12px;
|
| 43 |
+
padding: 28px 20px;
|
| 44 |
+
text-align: center;
|
| 45 |
+
display: flex;
|
| 46 |
+
flex-direction: column;
|
| 47 |
+
gap: 10px;
|
| 48 |
+
align-items: center;
|
| 49 |
+
transition: border-color .15s, background .15s;
|
| 50 |
+
}
|
| 51 |
+
#drop.over { border-color: var(--accent); background: var(--panel-strong); }
|
| 52 |
+
#drop p { margin: 0; max-width: 560px; }
|
| 53 |
+
|
| 54 |
+
table { width: 100%; border-collapse: collapse; }
|
| 55 |
+
th, td { text-align: left; padding: 8px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
| 56 |
+
th { color: var(--muted); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; }
|
| 57 |
+
td.mono { font-size: 12px; }
|
| 58 |
+
table.flags td { border: none; padding: 3px 12px 3px 0; }
|
| 59 |
+
|
| 60 |
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
|
| 61 |
+
label { display: flex; flex-direction: column; gap: 5px; color: var(--muted); font-size: 12px; }
|
| 62 |
+
label.block { width: 100%; }
|
| 63 |
+
label input, label textarea { color: var(--ink); background: var(--panel); }
|
| 64 |
+
textarea { resize: vertical; }
|
| 65 |
+
|
| 66 |
+
pre {
|
| 67 |
+
background: var(--bg);
|
| 68 |
+
border: 1px solid var(--line);
|
| 69 |
+
border-radius: 10px;
|
| 70 |
+
padding: 14px;
|
| 71 |
+
overflow-x: auto;
|
| 72 |
+
margin: 0;
|
| 73 |
+
line-height: 1.55;
|
| 74 |
+
}
|
| 75 |
+
code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
| 76 |
+
|
| 77 |
+
.pill { padding: 2px 8px; border-radius: 99px; font-size: 10px; border: 1px solid var(--line-strong); color: var(--muted); }
|
| 78 |
+
.pill.ok { color: var(--accent); border-color: var(--accent); }
|
| 79 |
+
.pill.bad { color: var(--ink); border-color: var(--ink); }
|
| 80 |
+
|
| 81 |
+
/* There is no fourth colour to warn in, so a warning is stated in the accent
|
| 82 |
+
and carried by the sentence rather than by hue alone. */
|
| 83 |
+
.warn { color: var(--accent); }
|
src/manager/manager.html
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<title>Everything WebGPU — Models</title>
|
| 6 |
+
<link rel="stylesheet" href="../ui.css" />
|
| 7 |
+
<link rel="stylesheet" href="manager.css" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div class="wrap">
|
| 11 |
+
|
| 12 |
+
<header class="row">
|
| 13 |
+
<span id="dot" class="dot"></span>
|
| 14 |
+
<h1 class="grow">Everything WebGPU</h1>
|
| 15 |
+
<span id="engineStatus" class="muted mono"></span>
|
| 16 |
+
<div class="bar" id="engineBar" hidden><i id="engineProgress"></i></div>
|
| 17 |
+
<div id="engineHint" class="muted hint" hidden></div>
|
| 18 |
+
</header>
|
| 19 |
+
|
| 20 |
+
<section id="gpu" class="banner" hidden></section>
|
| 21 |
+
|
| 22 |
+
<section id="api">
|
| 23 |
+
<h2>Engine API</h2>
|
| 24 |
+
<div class="idbar">
|
| 25 |
+
<code id="selfId" class="mono grow"></code>
|
| 26 |
+
<button id="copyId">Copy ID</button>
|
| 27 |
+
</div>
|
| 28 |
+
<p class="muted">
|
| 29 |
+
Any Firefox extension drives this engine by messaging that ID — <code>runtime.sendMessage</code> for
|
| 30 |
+
request/response, <code>runtime.connect</code> for streaming. Send raw requests: the engine schedules a
|
| 31 |
+
shared GPU, it does not author prompts, so prompts stay on your side and survive a model change.
|
| 32 |
+
</p>
|
| 33 |
+
|
| 34 |
+
<div class="tabs" role="tablist">
|
| 35 |
+
<button class="tab" data-tab="completion" role="tab">Completion</button>
|
| 36 |
+
<button class="tab" data-tab="translation" role="tab">Translation</button>
|
| 37 |
+
<button class="tab" data-tab="reformat" role="tab">Reformat</button>
|
| 38 |
+
</div>
|
| 39 |
+
<p id="apiWhy" class="muted hint"></p>
|
| 40 |
+
<div class="codewrap">
|
| 41 |
+
<button id="copyCode" class="copy">Copy</button>
|
| 42 |
+
<pre class="mono"><code id="apiSample"></code></pre>
|
| 43 |
+
</div>
|
| 44 |
+
<p class="muted hint">
|
| 45 |
+
One task holds one engine, so a whole <code>batch</code> occupies one and can never starve a completion.
|
| 46 |
+
A second engine is built only when a second task is waiting, and on this model it is worth ~1.06x — it
|
| 47 |
+
buys isolation, not speed. Full reference in <code>AI.md</code>.
|
| 48 |
+
</p>
|
| 49 |
+
</section>
|
| 50 |
+
|
| 51 |
+
<section>
|
| 52 |
+
<h2>Add a model</h2>
|
| 53 |
+
<div id="drop" tabindex="0">
|
| 54 |
+
<strong>Drop a compiled MLC model folder here</strong>
|
| 55 |
+
<p class="muted">
|
| 56 |
+
Files are copied straight into this extension's Cache Storage — nothing is downloaded.
|
| 57 |
+
The folder needs <code>mlc-chat-config.json</code>, <code>tensor-cache.json</code>
|
| 58 |
+
(or <code>ndarray-cache.json</code>), every <code>params_shard_*.bin</code>,
|
| 59 |
+
<code>tokenizer.json</code>, and exactly one <code>*-webgpu.wasm</code> model library.
|
| 60 |
+
</p>
|
| 61 |
+
<button id="pick">Choose folder…</button>
|
| 62 |
+
<input id="picker" type="file" webkitdirectory directory multiple hidden />
|
| 63 |
+
</div>
|
| 64 |
+
<div id="ingest" hidden>
|
| 65 |
+
<div class="bar"><i id="ingestBar"></i></div>
|
| 66 |
+
<div id="ingestStatus" class="muted mono"></div>
|
| 67 |
+
</div>
|
| 68 |
+
<div id="ingestError" class="banner error" hidden></div>
|
| 69 |
+
</section>
|
| 70 |
+
|
| 71 |
+
<section>
|
| 72 |
+
<h2>Local models</h2>
|
| 73 |
+
<table id="models">
|
| 74 |
+
<thead>
|
| 75 |
+
<tr><th>Model</th><th>Size</th><th>Shards</th><th>Library</th><th>Cache</th><th></th></tr>
|
| 76 |
+
</thead>
|
| 77 |
+
<tbody></tbody>
|
| 78 |
+
</table>
|
| 79 |
+
<p id="noModels" class="muted" hidden>Nothing registered yet.</p>
|
| 80 |
+
<p class="muted mono">Storage in use: <span id="quota">—</span></p>
|
| 81 |
+
</section>
|
| 82 |
+
|
| 83 |
+
<section>
|
| 84 |
+
<h2>Generation defaults</h2>
|
| 85 |
+
<div class="grid">
|
| 86 |
+
<label>Temperature <input id="temperature" type="number" min="0" max="2" step="0.05" /></label>
|
| 87 |
+
<label>Max tokens <input id="maxTokens" type="number" min="1" max="32768" step="1" /></label>
|
| 88 |
+
<label>Engines in pool <input id="engineCount" type="number" min="1" max="4" step="1" /></label>
|
| 89 |
+
<label>Decode steps per sync <input id="decodeSteps" type="number" min="1" max="32" step="1" /></label>
|
| 90 |
+
</div>
|
| 91 |
+
<p id="poolCost" class="muted mono"></p>
|
| 92 |
+
<p id="decodeCost" class="muted mono"></p>
|
| 93 |
+
<label class="block">System prompt
|
| 94 |
+
<textarea id="systemPrompt" rows="3" placeholder="(none)"></textarea>
|
| 95 |
+
</label>
|
| 96 |
+
<label class="block">External extension allowlist
|
| 97 |
+
<input id="allowedExternalIds" type="text" placeholder="empty = allow every installed extension" />
|
| 98 |
+
<span class="muted mono">Comma-separated extension IDs permitted to call the engine API.</span>
|
| 99 |
+
</label>
|
| 100 |
+
<div class="row">
|
| 101 |
+
<button id="save" class="primary">Save settings</button>
|
| 102 |
+
<span id="saved" class="muted" hidden>Saved.</span>
|
| 103 |
+
</div>
|
| 104 |
+
</section>
|
| 105 |
+
|
| 106 |
+
<section>
|
| 107 |
+
<h2>Firefox setup (macOS)</h2>
|
| 108 |
+
<p class="muted">WebGPU/Metal must be enabled in <code>about:config</code> before a model can load:</p>
|
| 109 |
+
<table class="flags">
|
| 110 |
+
<tbody>
|
| 111 |
+
<tr><td class="mono">dom.webgpu.enabled</td><td class="mono">true</td><td class="muted">Exposes <code>navigator.gpu</code>.</td></tr>
|
| 112 |
+
<tr><td class="mono">gfx.webgpu.ignore-blocklist</td><td class="mono">true</td><td class="muted">Only if your Mac's GPU is blocklisted.</td></tr>
|
| 113 |
+
<tr><td class="mono">dom.webgpu.service-workers.enabled</td><td class="mono">true</td><td class="muted">Harmless; needed on builds that gate worker contexts.</td></tr>
|
| 114 |
+
</tbody>
|
| 115 |
+
</table>
|
| 116 |
+
<p class="muted">Restart Firefox after changing these. Nightly on Apple Silicon is the best-tested combination.</p>
|
| 117 |
+
</section>
|
| 118 |
+
|
| 119 |
+
</div>
|
| 120 |
+
<script type="module" src="manager.js"></script>
|
| 121 |
+
</body>
|
| 122 |
+
</html>
|
src/manager/manager.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Manager page: model ingestion, registry maintenance, engine settings, setup help. */
|
| 2 |
+
import { ENGINE_STATE, OP, PORT_NAME, PORT_OP, PRIORITY, PROTOCOL, request } from "../adapters/protocol.js";
|
| 3 |
+
import { webExtensionStorage } from "../adapters/webext.js";
|
| 4 |
+
import { ModelStore, formatBytes } from "../engine/model-store.js";
|
| 5 |
+
import { filesFromDataTransfer, filesFromInput, ingestModelFolder } from "../engine/ingest.js";
|
| 6 |
+
import { clampSteps } from "../engine/multistep.js";
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* The manager page writes into the same Cache Storage and the same
|
| 10 |
+
* `browser.storage.local` the background page reads, so it builds its own
|
| 11 |
+
* ModelStore over the same adapter rather than routing registry edits through
|
| 12 |
+
* the engine. Only generation goes over the wire.
|
| 13 |
+
*/
|
| 14 |
+
const store = new ModelStore(webExtensionStorage());
|
| 15 |
+
|
| 16 |
+
const $ = (id) => document.getElementById(id);
|
| 17 |
+
|
| 18 |
+
const port = browser.runtime.connect({ name: PORT_NAME });
|
| 19 |
+
port.onMessage.addListener((msg) => {
|
| 20 |
+
if (msg?.protocol === PROTOCOL && msg.op === PORT_OP.ENGINE_STATE) renderEngine(msg.state);
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
let lastEngineState = {};
|
| 24 |
+
|
| 25 |
+
function renderEngine(state) {
|
| 26 |
+
lastEngineState = state;
|
| 27 |
+
$("dot").className = `dot ${state.status}`;
|
| 28 |
+
// `size` is engines that exist, which trails `maxSize` until a second task
|
| 29 |
+
// asks for one — so say both, or a pool of 1 under a cap of 2 reads as a bug.
|
| 30 |
+
const pool = state.pool?.size
|
| 31 |
+
? ` · pool ${state.pool.busy}/${state.pool.size} busy, ${state.pool.queued} queued` +
|
| 32 |
+
(state.pool.growthBlocked
|
| 33 |
+
? ` · stayed at ${state.pool.size} (${state.pool.growthBlocked})`
|
| 34 |
+
: state.pool.size < state.pool.maxSize
|
| 35 |
+
? ` · up to ${state.pool.maxSize} on demand`
|
| 36 |
+
: "")
|
| 37 |
+
: "";
|
| 38 |
+
const loading = state.status === ENGINE_STATE.LOADING;
|
| 39 |
+
// Show WebLLM's own report verbatim while loading — shard counts, MB and
|
| 40 |
+
// elapsed seconds are the only feedback there is during a ~48 s load, and a
|
| 41 |
+
// bare percentage hides all of it.
|
| 42 |
+
$("engineStatus").textContent =
|
| 43 |
+
state.error ? state.error :
|
| 44 |
+
state.status === ENGINE_STATE.READY ? `loaded: ${state.modelId}${pool}` :
|
| 45 |
+
loading ? state.progress?.text ?? `loading ${state.modelId}…` :
|
| 46 |
+
"idle";
|
| 47 |
+
|
| 48 |
+
$("engineBar").hidden = !loading;
|
| 49 |
+
$("engineProgress").style.width = `${Math.round((state.progress?.progress ?? 0) * 100)}%`;
|
| 50 |
+
const hint = $("engineHint");
|
| 51 |
+
if (!loading) {
|
| 52 |
+
hint.hidden = true;
|
| 53 |
+
} else {
|
| 54 |
+
const secs = state.progress?.timeElapsed;
|
| 55 |
+
// Not WebLLM's stock "first visit populates the cache" line: these weights
|
| 56 |
+
// were injected by drag-and-drop, so nothing is ever downloaded.
|
| 57 |
+
hint.textContent =
|
| 58 |
+
`${secs ? `${secs}s elapsed · ` : ""}reading from local cache, no network` +
|
| 59 |
+
((state.progress?.progress ?? 0) > 0.99 ? " · compiling WebGPU shaders" : "") +
|
| 60 |
+
".";
|
| 61 |
+
hint.hidden = false;
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// ------------------------------------------------------------ diagnostics ---
|
| 66 |
+
|
| 67 |
+
function renderGpu() {
|
| 68 |
+
const el = $("gpu");
|
| 69 |
+
if (navigator.gpu) {
|
| 70 |
+
el.hidden = false;
|
| 71 |
+
el.textContent = "WebGPU is available in this context.";
|
| 72 |
+
return;
|
| 73 |
+
}
|
| 74 |
+
el.hidden = false;
|
| 75 |
+
el.classList.add("error");
|
| 76 |
+
el.textContent =
|
| 77 |
+
"navigator.gpu is missing. Set dom.webgpu.enabled = true in about:config (see Firefox setup below) and restart Firefox — models cannot load until then.";
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/** Spells out what another engine actually costs, from the loaded model's own record. */
|
| 81 |
+
async function renderPoolCost() {
|
| 82 |
+
const { engineCount } = await store.getSettings();
|
| 83 |
+
const record = (await store.list()).find((m) => m.model_id === lastEngineState.modelId) ?? (await store.list())[0];
|
| 84 |
+
if (!record) return void ($("poolCost").textContent = "");
|
| 85 |
+
const weights = record.sizeBytes ?? 0;
|
| 86 |
+
const total = weights * engineCount;
|
| 87 |
+
// Measured on an M4 Air with a 0.8B model: 2 engines gave 1.6x aggregate
|
| 88 |
+
// throughput, 4 gave 0.3x - past the memory budget they starve each other.
|
| 89 |
+
const verdict =
|
| 90 |
+
engineCount === 1
|
| 91 |
+
? "no parallelism: batches run one at a time."
|
| 92 |
+
: engineCount === 2
|
| 93 |
+
? "measured ~1.6x aggregate throughput on a 0.8B model."
|
| 94 |
+
: "more is usually worse — 4 engines measured 3x SLOWER than 1. Verify with npm run e2e before keeping this.";
|
| 95 |
+
$("poolCost").textContent =
|
| 96 |
+
`${engineCount} engine(s) x ~${formatBytes(weights)} = ~${formatBytes(total)} VRAM. ${verdict}`;
|
| 97 |
+
$("poolCost").classList.toggle("warn", total > 6e9 || engineCount > 2);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/**
|
| 101 |
+
* Spells out the sawtooth, because "more steps" is not monotonically better.
|
| 102 |
+
*
|
| 103 |
+
* Firefox resolves a GPU sync only on a 100 ms tick, so a burst of K steps costs
|
| 104 |
+
* a whole number of ticks. The reference figure is the ~7.3 ms/token of real
|
| 105 |
+
* compute measured for a 0.8B (AI.md, "The 10 tok/s ceiling"); a bigger model
|
| 106 |
+
* costs more per step and wants a smaller K.
|
| 107 |
+
*/
|
| 108 |
+
const TICK_MS = 100;
|
| 109 |
+
const REFERENCE_STEP_MS = 7.3;
|
| 110 |
+
|
| 111 |
+
async function renderDecodeCost() {
|
| 112 |
+
const { decodeSteps } = await store.getSettings();
|
| 113 |
+
const ticks = Math.ceil((decodeSteps * REFERENCE_STEP_MS) / TICK_MS);
|
| 114 |
+
const rate = decodeSteps / ((ticks * TICK_MS) / 1000);
|
| 115 |
+
const perTick = Math.floor(TICK_MS / REFERENCE_STEP_MS);
|
| 116 |
+
const wastes = decodeSteps > perTick && decodeSteps % perTick !== 0;
|
| 117 |
+
$("decodeCost").textContent =
|
| 118 |
+
`${decodeSteps} step(s) per sync = ${decodeSteps} token(s) every ${ticks} tick(s) ` +
|
| 119 |
+
`≈ ${rate.toFixed(0)} tok/s on a 0.8B (vs 9.6 at 1 step). ` +
|
| 120 |
+
(wastes
|
| 121 |
+
? `${decodeSteps} spills past a 100 ms tick boundary — ${perTick} fits inside one tick and measures faster. Confirm with npm run e2e.`
|
| 122 |
+
: "Fits the tick grid. Re-check on a larger model: per-step compute grows, so the best K shrinks.");
|
| 123 |
+
$("decodeCost").classList.toggle("warn", wastes);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
async function renderQuota() {
|
| 127 |
+
if (!navigator.storage?.estimate) return;
|
| 128 |
+
const { usage, quota } = await navigator.storage.estimate();
|
| 129 |
+
$("quota").textContent = `${formatBytes(usage)} of ${formatBytes(quota)}`;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
// --------------------------------------------------------------- ingestion ---
|
| 133 |
+
|
| 134 |
+
const drop = $("drop");
|
| 135 |
+
|
| 136 |
+
for (const type of ["dragenter", "dragover"]) {
|
| 137 |
+
drop.addEventListener(type, (e) => {
|
| 138 |
+
e.preventDefault();
|
| 139 |
+
drop.classList.add("over");
|
| 140 |
+
});
|
| 141 |
+
}
|
| 142 |
+
for (const type of ["dragleave", "drop"]) {
|
| 143 |
+
drop.addEventListener(type, () => drop.classList.remove("over"));
|
| 144 |
+
}
|
| 145 |
+
drop.addEventListener("drop", async (e) => {
|
| 146 |
+
e.preventDefault();
|
| 147 |
+
await ingest(await filesFromDataTransfer(e.dataTransfer));
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
$("pick").addEventListener("click", () => $("picker").click());
|
| 151 |
+
$("picker").addEventListener("change", async (e) => {
|
| 152 |
+
await ingest(filesFromInput(e.target.files));
|
| 153 |
+
e.target.value = "";
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
async function ingest(entries) {
|
| 157 |
+
$("ingestError").hidden = true;
|
| 158 |
+
$("ingest").hidden = false;
|
| 159 |
+
$("ingestBar").style.width = "0%";
|
| 160 |
+
$("ingestStatus").textContent = "Validating…";
|
| 161 |
+
|
| 162 |
+
try {
|
| 163 |
+
const record = await ingestModelFolder(entries, {
|
| 164 |
+
store,
|
| 165 |
+
onProgress: ({ phase, done, total, label }) => {
|
| 166 |
+
$("ingestBar").style.width = `${Math.round((done / Math.max(total, 1)) * 100)}%`;
|
| 167 |
+
$("ingestStatus").textContent =
|
| 168 |
+
phase === "validating" ? label : `Caching ${done}/${total} — ${label}`;
|
| 169 |
+
},
|
| 170 |
+
});
|
| 171 |
+
$("ingestStatus").textContent = `Registered ${record.model_id} (${formatBytes(record.sizeBytes)}, ${record.shardCount} shards).`;
|
| 172 |
+
await Promise.all([renderModels(), renderQuota()]);
|
| 173 |
+
} catch (err) {
|
| 174 |
+
$("ingest").hidden = true;
|
| 175 |
+
$("ingestError").hidden = false;
|
| 176 |
+
$("ingestError").textContent = err.message;
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
// ---------------------------------------------------------------- registry ---
|
| 181 |
+
|
| 182 |
+
async function renderModels() {
|
| 183 |
+
const models = await store.list();
|
| 184 |
+
const tbody = $("models").querySelector("tbody");
|
| 185 |
+
tbody.replaceChildren();
|
| 186 |
+
$("models").hidden = models.length === 0;
|
| 187 |
+
$("noModels").hidden = models.length > 0;
|
| 188 |
+
|
| 189 |
+
for (const record of models) {
|
| 190 |
+
const tr = document.createElement("tr");
|
| 191 |
+
tr.innerHTML = `
|
| 192 |
+
<td><strong></strong><br /><span class="muted mono"></span></td>
|
| 193 |
+
<td class="mono"></td>
|
| 194 |
+
<td class="mono"></td>
|
| 195 |
+
<td class="mono"></td>
|
| 196 |
+
<td><span class="pill">checking…</span></td>
|
| 197 |
+
<td class="row"><button data-act="load">Load</button><button data-act="remove" class="danger">Remove</button></td>`;
|
| 198 |
+
|
| 199 |
+
tr.querySelector("strong").textContent = record.model_id;
|
| 200 |
+
tr.querySelector("td .muted").textContent = `added ${new Date(record.addedAt).toLocaleString()}`;
|
| 201 |
+
const cells = tr.querySelectorAll("td.mono");
|
| 202 |
+
cells[0].textContent = formatBytes(record.sizeBytes);
|
| 203 |
+
cells[1].textContent = record.shardCount ?? "—";
|
| 204 |
+
cells[2].textContent = record.wasm ?? "—";
|
| 205 |
+
|
| 206 |
+
tr.querySelector('[data-act="load"]').addEventListener("click", async (e) => {
|
| 207 |
+
e.target.disabled = true;
|
| 208 |
+
const res = await browser.runtime.sendMessage(request(OP.LOAD, { modelId: record.model_id }));
|
| 209 |
+
e.target.disabled = false;
|
| 210 |
+
if (!res?.ok) renderEngine({ status: ENGINE_STATE.ERROR, error: res?.error });
|
| 211 |
+
});
|
| 212 |
+
tr.querySelector('[data-act="remove"]').addEventListener("click", async () => {
|
| 213 |
+
if (!confirm(`Remove "${record.model_id}" and free ${formatBytes(record.sizeBytes)} of cache?`)) return;
|
| 214 |
+
await store.remove(record.model_id);
|
| 215 |
+
await Promise.all([renderModels(), renderQuota()]);
|
| 216 |
+
});
|
| 217 |
+
|
| 218 |
+
tbody.append(tr);
|
| 219 |
+
|
| 220 |
+
store.verify(record).then(({ ok, missing }) => {
|
| 221 |
+
const pill = tr.querySelector(".pill");
|
| 222 |
+
pill.classList.add(ok ? "ok" : "bad");
|
| 223 |
+
pill.textContent = ok ? "complete" : `${missing.length} missing`;
|
| 224 |
+
});
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// ---------------------------------------------------------------- settings ---
|
| 229 |
+
|
| 230 |
+
async function renderSettings() {
|
| 231 |
+
const s = await store.getSettings();
|
| 232 |
+
$("temperature").value = s.temperature;
|
| 233 |
+
$("maxTokens").value = s.maxTokens;
|
| 234 |
+
$("engineCount").value = s.engineCount;
|
| 235 |
+
$("decodeSteps").value = s.decodeSteps;
|
| 236 |
+
$("systemPrompt").value = s.systemPrompt;
|
| 237 |
+
$("allowedExternalIds").value = s.allowedExternalIds.join(", ");
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
$("save").addEventListener("click", async () => {
|
| 241 |
+
await store.setSettings({
|
| 242 |
+
temperature: Number($("temperature").value),
|
| 243 |
+
maxTokens: Number($("maxTokens").value),
|
| 244 |
+
engineCount: Math.max(1, Math.min(4, Number($("engineCount").value) || 1)),
|
| 245 |
+
decodeSteps: clampSteps($("decodeSteps").value),
|
| 246 |
+
systemPrompt: $("systemPrompt").value,
|
| 247 |
+
allowedExternalIds: $("allowedExternalIds").value.split(",").map((s) => s.trim()).filter(Boolean),
|
| 248 |
+
});
|
| 249 |
+
$("saved").hidden = false;
|
| 250 |
+
setTimeout(() => ($("saved").hidden = true), 1500);
|
| 251 |
+
await renderPoolCost();
|
| 252 |
+
await renderDecodeCost();
|
| 253 |
+
renderEngine({ ...lastEngineState, note: "reload the model for the pool size to take effect" });
|
| 254 |
+
});
|
| 255 |
+
|
| 256 |
+
// ------------------------------------------------------------- Engine API ---
|
| 257 |
+
|
| 258 |
+
/**
|
| 259 |
+
* The three shapes of work, as copy-paste starting points.
|
| 260 |
+
*
|
| 261 |
+
* They differ only in scheduling metadata, not in op — that is the whole point,
|
| 262 |
+
* and the reason there is no `translate` op to call. Each carries the one field
|
| 263 |
+
* that makes it behave correctly, because those are what callers get wrong.
|
| 264 |
+
*/
|
| 265 |
+
function apiSamples(id) {
|
| 266 |
+
return {
|
| 267 |
+
completion: {
|
| 268 |
+
why: "Latency is the product. `session` is what makes this work — reuse one key and the engine drops the stale request itself, so a fast typist never queues a request per keystroke.",
|
| 269 |
+
code: `const port = browser.runtime.connect("${id}", { name: "${PORT_NAME}" });
|
| 270 |
+
port.onMessage.addListener((m) => {
|
| 271 |
+
if (m.op === "${PORT_OP.CHUNK}") render(m.delta);
|
| 272 |
+
if (m.op === "${PORT_OP.DONE}") finish(m.text);
|
| 273 |
+
});
|
| 274 |
+
|
| 275 |
+
// On every keystroke. The previous request is superseded, not queued.
|
| 276 |
+
port.postMessage({
|
| 277 |
+
protocol: "${PROTOCOL}",
|
| 278 |
+
op: "${PORT_OP.CHAT_STREAM}",
|
| 279 |
+
id: crypto.randomUUID(),
|
| 280 |
+
session: "ghost-text", // supersession key — the field that matters
|
| 281 |
+
priority: "${PRIORITY.INTERACTIVE}", // may preempt work that opted in
|
| 282 |
+
max_tokens: 24,
|
| 283 |
+
messages: [{ role: "user", content: prefix }],
|
| 284 |
+
});`,
|
| 285 |
+
},
|
| 286 |
+
translation: {
|
| 287 |
+
why: "One `batch`, not a loop of `chat` calls: one round trip, and the engine schedules the whole page as a single task that can never occupy more than one engine.",
|
| 288 |
+
code: `const res = await browser.runtime.sendMessage("${id}", {
|
| 289 |
+
protocol: "${PROTOCOL}",
|
| 290 |
+
op: "${OP.BATCH}",
|
| 291 |
+
task: "translate-page", // optional; a batch is one task either way
|
| 292 |
+
requests: sentences.map((s) => ({
|
| 293 |
+
messages: [{ role: "user", content: \`Translate to French, output only the translation:\\n\${s}\` }],
|
| 294 |
+
})),
|
| 295 |
+
});
|
| 296 |
+
if (!res.ok) throw new Error(res.error);
|
| 297 |
+
res.results.forEach((r) => apply(r.index, r.text));`,
|
| 298 |
+
},
|
| 299 |
+
reformat: {
|
| 300 |
+
why: "Nobody is watching, so let interactive work cut in. Set `preemptible` on the job that can afford to lose — a preempted job resolves with `preempted: true` and its partial text, never requeued.",
|
| 301 |
+
code: `const res = await browser.runtime.sendMessage("${id}", {
|
| 302 |
+
protocol: "${PROTOCOL}",
|
| 303 |
+
op: "${OP.CHAT}",
|
| 304 |
+
priority: "${PRIORITY.BACKGROUND}",
|
| 305 |
+
preemptible: true, // the direction matters
|
| 306 |
+
max_tokens: 2048,
|
| 307 |
+
messages: [{ role: "user", content: \`Reformat as clean Markdown, no commentary:\\n\\n\${doc}\` }],
|
| 308 |
+
});
|
| 309 |
+
if (res.preempted) keepOrDiscard(res.text);`,
|
| 310 |
+
},
|
| 311 |
+
};
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
function renderApiSample() {
|
| 315 |
+
const id = browser.runtime.id;
|
| 316 |
+
const samples = apiSamples(id);
|
| 317 |
+
$("selfId").textContent = id;
|
| 318 |
+
|
| 319 |
+
const show = (name) => {
|
| 320 |
+
for (const tab of document.querySelectorAll(".tab")) {
|
| 321 |
+
tab.classList.toggle("active", tab.dataset.tab === name);
|
| 322 |
+
}
|
| 323 |
+
$("apiWhy").textContent = samples[name].why;
|
| 324 |
+
$("apiSample").textContent = samples[name].code;
|
| 325 |
+
};
|
| 326 |
+
|
| 327 |
+
for (const tab of document.querySelectorAll(".tab")) {
|
| 328 |
+
tab.addEventListener("click", () => show(tab.dataset.tab));
|
| 329 |
+
}
|
| 330 |
+
show("completion");
|
| 331 |
+
|
| 332 |
+
const flash = (btn, label) => {
|
| 333 |
+
const original = btn.textContent;
|
| 334 |
+
btn.textContent = label;
|
| 335 |
+
setTimeout(() => (btn.textContent = original), 1200);
|
| 336 |
+
};
|
| 337 |
+
$("copyId").addEventListener("click", () =>
|
| 338 |
+
navigator.clipboard.writeText(id).then(() => flash($("copyId"), "Copied")),
|
| 339 |
+
);
|
| 340 |
+
$("copyCode").addEventListener("click", () =>
|
| 341 |
+
navigator.clipboard.writeText($("apiSample").textContent).then(() => flash($("copyCode"), "Copied")),
|
| 342 |
+
);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
renderGpu();
|
| 346 |
+
renderApiSample();
|
| 347 |
+
await Promise.all([renderModels(), renderSettings(), renderQuota()]);
|
| 348 |
+
await renderPoolCost();
|
| 349 |
+
await renderDecodeCost();
|
| 350 |
+
browser.runtime.sendMessage(request(OP.STATUS)).then((res) => res?.ok && renderEngine(res.state));
|
src/popup/popup.css
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
body { width: 420px; height: 580px; display: flex; flex-direction: column; }
|
| 2 |
+
|
| 3 |
+
header, footer { padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
|
| 4 |
+
header { border-bottom: 1px solid var(--line); }
|
| 5 |
+
footer { border-top: 1px solid var(--line); gap: 8px; }
|
| 6 |
+
|
| 7 |
+
main {
|
| 8 |
+
flex: 1;
|
| 9 |
+
overflow-y: auto;
|
| 10 |
+
padding: 16px 14px;
|
| 11 |
+
display: flex;
|
| 12 |
+
flex-direction: column;
|
| 13 |
+
gap: 18px;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/* Claude does not put the assistant in a bubble: the reply is just text on the
|
| 17 |
+
page, and only the user's turn gets a surface. Keeping that asymmetry is most
|
| 18 |
+
of what makes a transcript read as Claude's rather than as a chat app's. */
|
| 19 |
+
.msg { white-space: pre-wrap; word-break: break-word; }
|
| 20 |
+
.msg.user {
|
| 21 |
+
align-self: flex-end;
|
| 22 |
+
max-width: 85%;
|
| 23 |
+
background: var(--panel-strong);
|
| 24 |
+
border-radius: 12px;
|
| 25 |
+
padding: 9px 13px;
|
| 26 |
+
}
|
| 27 |
+
.msg.assistant { align-self: stretch; }
|
| 28 |
+
.msg.error {
|
| 29 |
+
align-self: stretch;
|
| 30 |
+
border: 1px solid var(--accent);
|
| 31 |
+
border-radius: 8px;
|
| 32 |
+
padding: 9px 13px;
|
| 33 |
+
}
|
| 34 |
+
.msg .meta { display: block; margin-top: 6px; font-size: 11px; color: var(--muted); }
|
| 35 |
+
|
| 36 |
+
.empty { margin: auto; text-align: center; color: var(--muted); max-width: 260px; }
|
| 37 |
+
|
| 38 |
+
#input {
|
| 39 |
+
resize: none;
|
| 40 |
+
width: 100%;
|
| 41 |
+
background: var(--panel);
|
| 42 |
+
border-radius: 12px;
|
| 43 |
+
padding: 10px 12px;
|
| 44 |
+
}
|
| 45 |
+
#input:focus { border-color: var(--line-strong); }
|
| 46 |
+
|
| 47 |
+
#status { min-height: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
| 48 |
+
/* The load report is a sentence, not a label: let it wrap while it is showing,
|
| 49 |
+
otherwise it is silently truncated to "Loading model from cache[26/5…". */
|
| 50 |
+
#status.loading { white-space: normal; overflow: visible; text-overflow: clip; }
|
src/popup/popup.html
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<title>Everything WebGPU</title>
|
| 6 |
+
<link rel="stylesheet" href="../ui.css" />
|
| 7 |
+
<link rel="stylesheet" href="popup.css" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<header>
|
| 11 |
+
<div class="row">
|
| 12 |
+
<span id="dot" class="dot"></span>
|
| 13 |
+
<h1 class="grow">Everything WebGPU</h1>
|
| 14 |
+
<button id="manage" title="Manage local models">Models…</button>
|
| 15 |
+
</div>
|
| 16 |
+
<div class="row">
|
| 17 |
+
<select id="model" class="grow"></select>
|
| 18 |
+
<button id="load" class="primary">Load</button>
|
| 19 |
+
<button id="unload" title="Free VRAM">Eject</button>
|
| 20 |
+
</div>
|
| 21 |
+
<div class="bar"><i id="progress"></i></div>
|
| 22 |
+
<div id="status" class="muted mono">Checking engine…</div>
|
| 23 |
+
<div id="loadHint" class="muted hint" hidden></div>
|
| 24 |
+
</header>
|
| 25 |
+
|
| 26 |
+
<main id="log" aria-live="polite"></main>
|
| 27 |
+
|
| 28 |
+
<footer>
|
| 29 |
+
<textarea id="input" rows="2" placeholder="Ask the local model…"></textarea>
|
| 30 |
+
<div class="row">
|
| 31 |
+
<button id="clear">Clear</button>
|
| 32 |
+
<span class="grow"></span>
|
| 33 |
+
<button id="stop" class="danger" hidden>Stop</button>
|
| 34 |
+
<button id="send" class="primary">Send</button>
|
| 35 |
+
</div>
|
| 36 |
+
</footer>
|
| 37 |
+
|
| 38 |
+
<script type="module" src="popup.js"></script>
|
| 39 |
+
</body>
|
| 40 |
+
</html>
|
src/popup/popup.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Minimal test chat. Thin client over the background engine host's port protocol. */
|
| 2 |
+
import { ENGINE_STATE, OP, PORT_NAME, PORT_OP, PRIORITY, PROTOCOL, request } from "../adapters/protocol.js";
|
| 3 |
+
|
| 4 |
+
const $ = (id) => document.getElementById(id);
|
| 5 |
+
const els = {
|
| 6 |
+
dot: $("dot"), model: $("model"), load: $("load"), unload: $("unload"),
|
| 7 |
+
progress: $("progress"), status: $("status"), loadHint: $("loadHint"), log: $("log"), input: $("input"),
|
| 8 |
+
send: $("send"), stop: $("stop"), clear: $("clear"), manage: $("manage"),
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
const history = [];
|
| 12 |
+
let models = [];
|
| 13 |
+
let engineState = { status: ENGINE_STATE.IDLE, modelId: null, pool: null };
|
| 14 |
+
let streamId = null;
|
| 15 |
+
let streamEl = null;
|
| 16 |
+
|
| 17 |
+
/**
|
| 18 |
+
* The engine supersedes by `session`, so a cleared conversation has to become a
|
| 19 |
+
* *different* session — otherwise the next message looks to the scheduler like
|
| 20 |
+
* a continuation of the one just thrown away.
|
| 21 |
+
*/
|
| 22 |
+
const newSession = () => `popup-${crypto.randomUUID()}`;
|
| 23 |
+
let session = newSession();
|
| 24 |
+
|
| 25 |
+
const port = browser.runtime.connect({ name: PORT_NAME });
|
| 26 |
+
port.onMessage.addListener(onPortMessage);
|
| 27 |
+
|
| 28 |
+
// -------------------------------------------------------------- rendering ---
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* The second line of the load guide.
|
| 32 |
+
*
|
| 33 |
+
* Deliberately not WebLLM's stock wording. Its default hint is about populating
|
| 34 |
+
* a cache from the network on first visit, which is never what happens here:
|
| 35 |
+
* the weights were injected into Cache Storage by drag-and-drop, so nothing is
|
| 36 |
+
* downloaded and a "later refreshes are faster" promise would be wrong. What
|
| 37 |
+
* the user is actually waiting on, once the shards are read, is WebGPU shader
|
| 38 |
+
* compilation.
|
| 39 |
+
*/
|
| 40 |
+
function renderLoadHint(progress) {
|
| 41 |
+
if (!progress) return void (els.loadHint.hidden = true);
|
| 42 |
+
const secs = progress.timeElapsed;
|
| 43 |
+
const parts = [];
|
| 44 |
+
if (progress.engines > 1) parts.push(`${progress.engines} engines, loaded 2 at a time`);
|
| 45 |
+
parts.push("weights come from the local cache — nothing is downloaded");
|
| 46 |
+
if ((progress.progress ?? 0) > 0.99) {
|
| 47 |
+
parts.push("compiling WebGPU shaders, which is most of the wait");
|
| 48 |
+
}
|
| 49 |
+
els.loadHint.textContent = `${secs ? `${secs}s elapsed · ` : ""}${parts.join(" · ")}.`;
|
| 50 |
+
els.loadHint.hidden = false;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function renderState() {
|
| 54 |
+
const { status, modelId, progress, error, pool } = engineState;
|
| 55 |
+
els.dot.className = `dot ${status}`;
|
| 56 |
+
els.progress.style.width = `${Math.round((progress?.progress ?? (status === ENGINE_STATE.READY ? 1 : 0)) * 100)}%`;
|
| 57 |
+
|
| 58 |
+
els.status.textContent =
|
| 59 |
+
error ? error :
|
| 60 |
+
progress?.text ? progress.text :
|
| 61 |
+
status === ENGINE_STATE.READY ? `Ready — ${modelId}` :
|
| 62 |
+
status === ENGINE_STATE.LOADING ? `Loading ${modelId}…` :
|
| 63 |
+
"Idle — pick a model and press Load";
|
| 64 |
+
if (!error && status === ENGINE_STATE.READY && pool && (pool.busy || pool.queued)) {
|
| 65 |
+
const cap = pool.size < pool.maxSize && !pool.growthBlocked ? `/${pool.maxSize}` : "";
|
| 66 |
+
els.status.textContent += ` · ${pool.busy}/${pool.size}${cap} busy, ${pool.queued} queued`;
|
| 67 |
+
}
|
| 68 |
+
els.status.classList.toggle("banner", Boolean(error));
|
| 69 |
+
els.status.classList.toggle("error", Boolean(error));
|
| 70 |
+
|
| 71 |
+
const loading = status === ENGINE_STATE.LOADING;
|
| 72 |
+
// The report is a full sentence from WebLLM ("Loading model from cache[26/58]:
|
| 73 |
+
// 890MB loaded. 51% completed, 121 secs elapsed."). #status is a single
|
| 74 |
+
// ellipsised line by default, which cut it off, so unclip it while loading.
|
| 75 |
+
els.status.classList.toggle("loading", loading && Boolean(progress?.text));
|
| 76 |
+
renderLoadHint(loading ? progress : null);
|
| 77 |
+
els.load.disabled = loading || !els.model.value;
|
| 78 |
+
els.unload.disabled = loading || status !== ENGINE_STATE.READY;
|
| 79 |
+
els.model.disabled = loading;
|
| 80 |
+
els.send.disabled = loading || streamId !== null;
|
| 81 |
+
els.stop.hidden = streamId === null;
|
| 82 |
+
els.load.textContent = status === ENGINE_STATE.READY && modelId === els.model.value ? "Reload" : "Load";
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function renderEmpty() {
|
| 86 |
+
els.log.replaceChildren(
|
| 87 |
+
Object.assign(document.createElement("div"), {
|
| 88 |
+
className: "empty",
|
| 89 |
+
textContent: models.length
|
| 90 |
+
? "Load a model, then send a message."
|
| 91 |
+
: "No local models yet. Open Models… and drop a compiled MLC model folder.",
|
| 92 |
+
}),
|
| 93 |
+
);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function addMessage(role, text) {
|
| 97 |
+
if (els.log.querySelector(".empty")) els.log.replaceChildren();
|
| 98 |
+
const el = document.createElement("div");
|
| 99 |
+
el.className = `msg ${role}`;
|
| 100 |
+
el.textContent = text;
|
| 101 |
+
els.log.append(el);
|
| 102 |
+
els.log.scrollTop = els.log.scrollHeight;
|
| 103 |
+
return el;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// ---------------------------------------------------------------- actions ---
|
| 107 |
+
|
| 108 |
+
async function ask(op, payload) {
|
| 109 |
+
const res = await browser.runtime.sendMessage(request(op, payload));
|
| 110 |
+
if (!res?.ok) throw new Error(res?.error ?? "Engine did not respond.");
|
| 111 |
+
return res;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
async function refreshModels() {
|
| 115 |
+
const res = await ask(OP.LIST_MODELS);
|
| 116 |
+
models = res.models;
|
| 117 |
+
engineState = res.state;
|
| 118 |
+
els.model.replaceChildren(
|
| 119 |
+
...models.map((m) => new Option(`${m.model_id} · ${(m.sizeBytes / 1e9).toFixed(1)} GB`, m.model_id)),
|
| 120 |
+
);
|
| 121 |
+
if (res.state.modelId) els.model.value = res.state.modelId;
|
| 122 |
+
if (!els.log.children.length || els.log.querySelector(".empty")) renderEmpty();
|
| 123 |
+
renderState();
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function send() {
|
| 127 |
+
const text = els.input.value.trim();
|
| 128 |
+
if (!text) return;
|
| 129 |
+
els.input.value = "";
|
| 130 |
+
history.push({ role: "user", content: text });
|
| 131 |
+
addMessage("user", text);
|
| 132 |
+
|
| 133 |
+
streamId = crypto.randomUUID();
|
| 134 |
+
streamEl = addMessage("assistant", "");
|
| 135 |
+
renderState();
|
| 136 |
+
|
| 137 |
+
port.postMessage({
|
| 138 |
+
protocol: PROTOCOL,
|
| 139 |
+
op: PORT_OP.CHAT_STREAM,
|
| 140 |
+
id: streamId,
|
| 141 |
+
modelId: els.model.value || undefined,
|
| 142 |
+
messages: history,
|
| 143 |
+
priority: PRIORITY.INTERACTIVE,
|
| 144 |
+
session,
|
| 145 |
+
});
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function onPortMessage(msg) {
|
| 149 |
+
if (msg?.protocol !== PROTOCOL) return;
|
| 150 |
+
switch (msg.op) {
|
| 151 |
+
case PORT_OP.ENGINE_STATE:
|
| 152 |
+
engineState = msg.state;
|
| 153 |
+
if (msg.state.modelId && !els.model.value) els.model.value = msg.state.modelId;
|
| 154 |
+
renderState();
|
| 155 |
+
return;
|
| 156 |
+
case PORT_OP.CHUNK:
|
| 157 |
+
if (msg.id !== streamId) return;
|
| 158 |
+
streamEl.textContent += msg.delta;
|
| 159 |
+
els.log.scrollTop = els.log.scrollHeight;
|
| 160 |
+
return;
|
| 161 |
+
case PORT_OP.DONE: {
|
| 162 |
+
if (msg.id !== streamId) return;
|
| 163 |
+
const text = streamEl.textContent;
|
| 164 |
+
history.push({ role: "assistant", content: text });
|
| 165 |
+
if (msg.usage) {
|
| 166 |
+
const meta = document.createElement("span");
|
| 167 |
+
meta.className = "meta";
|
| 168 |
+
meta.textContent = `${msg.usage.completion_tokens} tok · ${(msg.usage.extra?.decode_tokens_per_s ?? 0).toFixed(1)} tok/s`;
|
| 169 |
+
streamEl.append(meta);
|
| 170 |
+
}
|
| 171 |
+
streamId = null;
|
| 172 |
+
streamEl = null;
|
| 173 |
+
renderState();
|
| 174 |
+
return;
|
| 175 |
+
}
|
| 176 |
+
case PORT_OP.ERROR:
|
| 177 |
+
if (streamEl && !streamEl.textContent) streamEl.remove();
|
| 178 |
+
addMessage("error", msg.error);
|
| 179 |
+
streamId = null;
|
| 180 |
+
streamEl = null;
|
| 181 |
+
renderState();
|
| 182 |
+
return;
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
// ------------------------------------------------------------------ wiring ---
|
| 187 |
+
|
| 188 |
+
els.load.addEventListener("click", () =>
|
| 189 |
+
ask(OP.LOAD, { modelId: els.model.value }).catch((err) => {
|
| 190 |
+
engineState = { ...engineState, status: ENGINE_STATE.ERROR, error: err.message };
|
| 191 |
+
renderState();
|
| 192 |
+
}),
|
| 193 |
+
);
|
| 194 |
+
els.unload.addEventListener("click", () => ask(OP.UNLOAD).catch(() => {}));
|
| 195 |
+
els.send.addEventListener("click", send);
|
| 196 |
+
els.stop.addEventListener("click", () =>
|
| 197 |
+
port.postMessage({ protocol: PROTOCOL, op: PORT_OP.ABORT, id: streamId, session }),
|
| 198 |
+
);
|
| 199 |
+
|
| 200 |
+
/**
|
| 201 |
+
* Discard the conversation and start a fresh session.
|
| 202 |
+
*
|
| 203 |
+
* All three parts matter. Dropping `history` alone left every rendered message
|
| 204 |
+
* on screen, which read as the button doing nothing; and leaving the old
|
| 205 |
+
* session key in place meant a request that was still streaming kept writing
|
| 206 |
+
* into a conversation the user had already thrown away.
|
| 207 |
+
*/
|
| 208 |
+
els.clear.addEventListener("click", () => {
|
| 209 |
+
if (streamId) {
|
| 210 |
+
port.postMessage({ protocol: PROTOCOL, op: PORT_OP.ABORT, id: streamId, session });
|
| 211 |
+
streamId = null;
|
| 212 |
+
streamEl = null;
|
| 213 |
+
}
|
| 214 |
+
history.length = 0;
|
| 215 |
+
session = newSession();
|
| 216 |
+
renderEmpty();
|
| 217 |
+
els.input.value = "";
|
| 218 |
+
renderState();
|
| 219 |
+
});
|
| 220 |
+
els.manage.addEventListener("click", () => {
|
| 221 |
+
browser.runtime.openOptionsPage();
|
| 222 |
+
window.close();
|
| 223 |
+
});
|
| 224 |
+
els.model.addEventListener("change", renderState);
|
| 225 |
+
els.input.addEventListener("keydown", (e) => {
|
| 226 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 227 |
+
e.preventDefault();
|
| 228 |
+
if (!els.send.disabled) send();
|
| 229 |
+
}
|
| 230 |
+
});
|
| 231 |
+
|
| 232 |
+
refreshModels().catch((err) => {
|
| 233 |
+
els.status.textContent = err.message;
|
| 234 |
+
els.status.classList.add("banner", "error");
|
| 235 |
+
});
|
src/ui.css
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Shared UI tokens, in Claude's palette.
|
| 3 |
+
*
|
| 4 |
+
* Exactly three colours are declared per theme — a warm off-white ground, a
|
| 5 |
+
* near-black ink, and Claude's coral accent. Everything that looks like a
|
| 6 |
+
* fourth colour (borders, muted text, panels) is one of those three at reduced
|
| 7 |
+
* alpha, so the whole surface stays in one family and nothing needs picking a
|
| 8 |
+
* new hue to add a component.
|
| 9 |
+
*
|
| 10 |
+
* Fonts are a system stack. Claude's own Styrene and Tiempos are licensed
|
| 11 |
+
* faces, and this extension does no network requests by design, so there is
|
| 12 |
+
* nothing to load them from — the stack below is the closest available match.
|
| 13 |
+
*/
|
| 14 |
+
:root {
|
| 15 |
+
--bg: #faf9f5;
|
| 16 |
+
--ink: #141413;
|
| 17 |
+
--accent: #d97757;
|
| 18 |
+
|
| 19 |
+
--panel: color-mix(in srgb, var(--ink) 4%, var(--bg));
|
| 20 |
+
--panel-strong: color-mix(in srgb, var(--ink) 7%, var(--bg));
|
| 21 |
+
--line: color-mix(in srgb, var(--ink) 10%, transparent);
|
| 22 |
+
--line-strong: color-mix(in srgb, var(--ink) 18%, transparent);
|
| 23 |
+
--muted: color-mix(in srgb, var(--ink) 52%, transparent);
|
| 24 |
+
--on-accent: #ffffff;
|
| 25 |
+
|
| 26 |
+
color-scheme: light;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
@media (prefers-color-scheme: dark) {
|
| 30 |
+
:root {
|
| 31 |
+
--bg: #262624;
|
| 32 |
+
--ink: #f5f4ef;
|
| 33 |
+
--accent: #d97757;
|
| 34 |
+
|
| 35 |
+
--panel: color-mix(in srgb, var(--ink) 6%, var(--bg));
|
| 36 |
+
--panel-strong: color-mix(in srgb, var(--ink) 10%, var(--bg));
|
| 37 |
+
--line: color-mix(in srgb, var(--ink) 14%, transparent);
|
| 38 |
+
--line-strong: color-mix(in srgb, var(--ink) 24%, transparent);
|
| 39 |
+
--muted: color-mix(in srgb, var(--ink) 55%, transparent);
|
| 40 |
+
|
| 41 |
+
color-scheme: dark;
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
* { box-sizing: border-box; }
|
| 46 |
+
|
| 47 |
+
body {
|
| 48 |
+
margin: 0;
|
| 49 |
+
background: var(--bg);
|
| 50 |
+
color: var(--ink);
|
| 51 |
+
font: 14px/1.6 ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
|
| 52 |
+
-webkit-font-smoothing: antialiased;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
h1, h2 { margin: 0; font-weight: 500; letter-spacing: -0.01em; }
|
| 56 |
+
h1 { font-size: 15px; }
|
| 57 |
+
h2 { font-size: 12px; color: var(--muted); font-weight: 500; letter-spacing: .04em; text-transform: uppercase; }
|
| 58 |
+
|
| 59 |
+
button, select, input, textarea {
|
| 60 |
+
font: inherit;
|
| 61 |
+
color: var(--ink);
|
| 62 |
+
background: transparent;
|
| 63 |
+
border: 1px solid var(--line-strong);
|
| 64 |
+
border-radius: 8px;
|
| 65 |
+
padding: 7px 12px;
|
| 66 |
+
transition: background .12s ease, border-color .12s ease, opacity .12s ease;
|
| 67 |
+
}
|
| 68 |
+
select { background: var(--panel); }
|
| 69 |
+
button { cursor: pointer; }
|
| 70 |
+
button:hover:not(:disabled) { background: var(--panel-strong); }
|
| 71 |
+
button:disabled { opacity: .35; cursor: default; }
|
| 72 |
+
|
| 73 |
+
button.primary {
|
| 74 |
+
background: var(--accent);
|
| 75 |
+
color: var(--on-accent);
|
| 76 |
+
border-color: var(--accent);
|
| 77 |
+
font-weight: 500;
|
| 78 |
+
}
|
| 79 |
+
button.primary:hover:not(:disabled) { background: color-mix(in srgb, var(--ink) 12%, var(--accent)); }
|
| 80 |
+
/* Destructive actions read as plain text until hovered — Claude does not spend
|
| 81 |
+
a colour on them, and there is no fourth colour to spend. */
|
| 82 |
+
button.danger { border-color: transparent; color: var(--muted); }
|
| 83 |
+
button.danger:hover:not(:disabled) { color: var(--accent); background: var(--panel-strong); }
|
| 84 |
+
|
| 85 |
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
| 86 |
+
|
| 87 |
+
.row { display: flex; gap: 8px; align-items: center; }
|
| 88 |
+
.grow { flex: 1; min-width: 0; }
|
| 89 |
+
.muted { color: var(--muted); }
|
| 90 |
+
.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px; }
|
| 91 |
+
|
| 92 |
+
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--line-strong); flex: none; }
|
| 93 |
+
.dot.ready { background: var(--accent); }
|
| 94 |
+
.dot.loading { background: var(--accent); animation: pulse 1.4s ease-in-out infinite; }
|
| 95 |
+
.dot.error { background: var(--ink); }
|
| 96 |
+
@keyframes pulse { 50% { opacity: .3; } }
|
| 97 |
+
|
| 98 |
+
.hint { font-size: 12px; line-height: 1.45; margin-top: 3px; }
|
| 99 |
+
.bar { height: 2px; background: var(--line); border-radius: 2px; overflow: hidden; }
|
| 100 |
+
.bar > i { display: block; height: 100%; background: var(--accent); width: 0; transition: width .2s; }
|
| 101 |
+
|
| 102 |
+
.banner {
|
| 103 |
+
padding: 10px 12px;
|
| 104 |
+
border-radius: 8px;
|
| 105 |
+
border: 1px solid var(--line-strong);
|
| 106 |
+
background: var(--panel);
|
| 107 |
+
}
|
| 108 |
+
.banner.error { border-color: var(--accent); }
|
src/vite.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* The Vite plugin, because one line of config you have to *remember* is one
|
| 3 |
+
* line of config you will forget.
|
| 4 |
+
*
|
| 5 |
+
* ```js
|
| 6 |
+
* import { everythingWebGPU } from "everything-webgpu/vite";
|
| 7 |
+
* export default defineConfig({ plugins: [everythingWebGPU()] });
|
| 8 |
+
* ```
|
| 9 |
+
*
|
| 10 |
+
* ## What it fixes, precisely
|
| 11 |
+
*
|
| 12 |
+
* The engine spawns its decode worker with
|
| 13 |
+
* `new Worker(new URL("./engine-worker.js", import.meta.url), { type: "module" })`.
|
| 14 |
+
* Vite's dependency pre-bundler runs esbuild over `node_modules` packages and
|
| 15 |
+
* copies that expression through **verbatim** — but the copy now lives in
|
| 16 |
+
* `node_modules/.vite/deps/everything-webgpu.js`, so `import.meta.url` points
|
| 17 |
+
* there and the worker resolves to `.vite/deps/engine-worker.js`, which does not
|
| 18 |
+
* exist. The real file is still at
|
| 19 |
+
* `node_modules/everything-webgpu/src/engine/engine-worker.js`.
|
| 20 |
+
*
|
| 21 |
+
* Measured, not assumed. On a real (non-linked) install of this package:
|
| 22 |
+
*
|
| 23 |
+
* | | worker resolves | notes |
|
| 24 |
+
* | --- | --- | --- |
|
| 25 |
+
* | `vite build` | ✅ | Rollup handles it; output is byte-identical with or without this plugin |
|
| 26 |
+
* | `vite dev`, excluded | ✅ | source served from `/@fs/`, `import.meta.url` is correct |
|
| 27 |
+
* | `vite dev`, not excluded | ❌ 404 | the case this exists for |
|
| 28 |
+
*
|
| 29 |
+
* Two consequences worth stating, because both mislead:
|
| 30 |
+
*
|
| 31 |
+
* 1. **`vite build` never reproduces the bug.** A green production build is
|
| 32 |
+
* not evidence that the dev server works.
|
| 33 |
+
* 2. **A linked (`file:`) dependency never reproduces it either**, because
|
| 34 |
+
* Vite does not pre-bundle linked packages. Every example in this repo is
|
| 35 |
+
* linked, so none of them can catch this — only a real install can. See
|
| 36 |
+
* `npm run verify-consumer`.
|
| 37 |
+
*
|
| 38 |
+
* The lazy `import("../../vendor/web-llm.js")` is *not* affected: esbuild
|
| 39 |
+
* rewrites that one correctly to a hashed chunk. Only the worker breaks.
|
| 40 |
+
*
|
| 41 |
+
* If you would rather not add a plugin, the equivalent is:
|
| 42 |
+
*
|
| 43 |
+
* ```js
|
| 44 |
+
* optimizeDeps: { exclude: ["everything-webgpu"] }
|
| 45 |
+
* ```
|
| 46 |
+
*
|
| 47 |
+
* and if you do neither, `load()` fails with `PACKAGE_INCOMPLETE` naming this
|
| 48 |
+
* fix rather than hanging on a worker that will never answer.
|
| 49 |
+
*/
|
| 50 |
+
|
| 51 |
+
/** The package's own name, so the exclusion cannot drift from it. */
|
| 52 |
+
const PACKAGE = "everything-webgpu";
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* @param {{exclude?: string[]}} [opts] `exclude` adds further specifiers, for a
|
| 56 |
+
* host that re-exports this engine from its own package and hits the same
|
| 57 |
+
* pre-bundling of the worker URL.
|
| 58 |
+
* @returns {import("vite").Plugin}
|
| 59 |
+
*/
|
| 60 |
+
export function everythingWebGPU({ exclude = [] } = {}) {
|
| 61 |
+
return {
|
| 62 |
+
name: "everything-webgpu",
|
| 63 |
+
// `config` rather than `configResolved`: this has to merge into the user's
|
| 64 |
+
// options before Vite computes the optimizer's entries, not after.
|
| 65 |
+
config: () => ({
|
| 66 |
+
optimizeDeps: { exclude: [PACKAGE, ...exclude] },
|
| 67 |
+
}),
|
| 68 |
+
};
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export default everythingWebGPU;
|
tsconfig.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"//": "Type declarations only. The source stays plain ESM JavaScript; the JSDoc it already carries is the type source, so `npm run types` adds a typed surface for consumers without a rewrite or a build step on the runtime path.",
|
| 3 |
+
"compilerOptions": {
|
| 4 |
+
"allowJs": true,
|
| 5 |
+
"checkJs": false,
|
| 6 |
+
"declaration": true,
|
| 7 |
+
"emitDeclarationOnly": true,
|
| 8 |
+
"outDir": "types",
|
| 9 |
+
"target": "ES2022",
|
| 10 |
+
"module": "ESNext",
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"lib": [
|
| 13 |
+
"ES2022",
|
| 14 |
+
"DOM",
|
| 15 |
+
"DOM.Iterable",
|
| 16 |
+
"WebWorker"
|
| 17 |
+
],
|
| 18 |
+
"skipLibCheck": true,
|
| 19 |
+
"strict": false,
|
| 20 |
+
"rootDir": "src"
|
| 21 |
+
},
|
| 22 |
+
"include": [
|
| 23 |
+
"src/engine/**/*.js",
|
| 24 |
+
"src/adapters/**/*.js",
|
| 25 |
+
"src/vite.js"
|
| 26 |
+
],
|
| 27 |
+
"exclude": [
|
| 28 |
+
"src/engine/engine-worker.js",
|
| 29 |
+
"vendor"
|
| 30 |
+
]
|
| 31 |
+
}
|
types/adapters/idb.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @returns {Promise<import("../engine/model-store.js").StorageAdapter>} */
|
| 2 |
+
export function indexedDBStorage(): Promise<import("../engine/model-store.js").StorageAdapter>;
|
| 3 |
+
/**
|
| 4 |
+
* Ask the browser to stop treating this origin's storage as evictable.
|
| 5 |
+
*
|
| 6 |
+
* Firefox prompts the user (or grants silently for a site with the permission
|
| 7 |
+
* already); Chrome decides from engagement heuristics without a prompt. Either
|
| 8 |
+
* way a caller must handle `persisted: false` — the model still works, it can
|
| 9 |
+
* just be dropped under disk pressure, which `ModelStore.verify()` will catch
|
| 10 |
+
* on the next load.
|
| 11 |
+
*
|
| 12 |
+
* @returns {Promise<{persisted: boolean, quota?: number, usage?: number}>}
|
| 13 |
+
*/
|
| 14 |
+
export function ensurePersistent(): Promise<{
|
| 15 |
+
persisted: boolean;
|
| 16 |
+
quota?: number;
|
| 17 |
+
usage?: number;
|
| 18 |
+
}>;
|
types/adapters/memory.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* In-memory StorageAdapter.
|
| 3 |
+
*
|
| 4 |
+
* For tests, and for a host that genuinely wants the registry to die with the
|
| 5 |
+
* page. Note that the *weights* still live in Cache Storage and survive — only
|
| 6 |
+
* the registry entry pointing at them is lost, which `ModelStore.verify()` will
|
| 7 |
+
* then never be asked about. Do not use this in production for that reason.
|
| 8 |
+
*/
|
| 9 |
+
/** @returns {import("../engine/model-store.js").StorageAdapter} */
|
| 10 |
+
export function memoryStorage(initial?: {}): import("../engine/model-store.js").StorageAdapter;
|
types/adapters/protocol.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function request(op: any, payload?: {}): {
|
| 2 |
+
protocol: string;
|
| 3 |
+
op: any;
|
| 4 |
+
};
|
| 5 |
+
export const PROTOCOL: "everything-webgpu/v1";
|
| 6 |
+
export const PORT_NAME: "everything-webgpu/v1";
|
| 7 |
+
export namespace OP {
|
| 8 |
+
let STATUS: string;
|
| 9 |
+
let LIST_MODELS: string;
|
| 10 |
+
let LOAD: string;
|
| 11 |
+
let UNLOAD: string;
|
| 12 |
+
let CHAT: string;
|
| 13 |
+
let BATCH: string;
|
| 14 |
+
let CANCEL: string;
|
| 15 |
+
let CONFIGURE: string;
|
| 16 |
+
}
|
| 17 |
+
export namespace PORT_OP {
|
| 18 |
+
let CHAT_STREAM: string;
|
| 19 |
+
let BATCH_STREAM: string;
|
| 20 |
+
let ABORT: string;
|
| 21 |
+
let SUBSCRIBE: string;
|
| 22 |
+
let CHUNK: string;
|
| 23 |
+
let ITEM: string;
|
| 24 |
+
let DONE: string;
|
| 25 |
+
let ERROR: string;
|
| 26 |
+
let ENGINE_STATE: string;
|
| 27 |
+
}
|
| 28 |
+
export { ENGINE_STATE, PRIORITY, PRIORITY_ORDER } from "../engine/constants.js";
|
types/adapters/webext.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* `browser.storage.local` already *is* the StorageAdapter shape — `get(key)`
|
| 3 |
+
* and `set(obj)`. The two-method interface was chosen for that reason, so this
|
| 4 |
+
* is a passthrough rather than a translation layer.
|
| 5 |
+
*
|
| 6 |
+
* @returns {import("../engine/model-store.js").StorageAdapter}
|
| 7 |
+
*/
|
| 8 |
+
export function webExtensionStorage(): import("../engine/model-store.js").StorageAdapter;
|
| 9 |
+
/**
|
| 10 |
+
* Wires a ScheduledEngine onto this extension's runtime messaging.
|
| 11 |
+
*
|
| 12 |
+
* @param {import("../engine/engine.js").ScheduledEngine} engine
|
| 13 |
+
* @returns {() => void} detach
|
| 14 |
+
*/
|
| 15 |
+
export function attachWebExtensionTransport(engine: import("../engine/engine.js").ScheduledEngine): () => void;
|
types/engine/chat.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @param {import("./engine.js").ScheduledEngine} engine */
|
| 2 |
+
export function chatFacade(engine: import("./engine.js").ScheduledEngine): {
|
| 3 |
+
completions: {
|
| 4 |
+
/**
|
| 5 |
+
* @param {import("./engine.js").CompletionRequest & {
|
| 6 |
+
* stream?: boolean, stream_options?: {include_usage?: boolean} }} req
|
| 7 |
+
* @returns {Promise<object | AsyncIterable<object>>} a completion, or a
|
| 8 |
+
* stream of chunks when `stream` is set — the same two shapes WebLLM
|
| 9 |
+
* returns, so `await`ing then `for await`ing works unchanged.
|
| 10 |
+
*/
|
| 11 |
+
create(req: import("./engine.js").CompletionRequest & {
|
| 12 |
+
stream?: boolean;
|
| 13 |
+
stream_options?: {
|
| 14 |
+
include_usage?: boolean;
|
| 15 |
+
};
|
| 16 |
+
}): Promise<object | AsyncIterable<object>>;
|
| 17 |
+
};
|
| 18 |
+
};
|
types/engine/constants.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export namespace PRIORITY {
|
| 2 |
+
let INTERACTIVE: string;
|
| 3 |
+
let NORMAL: string;
|
| 4 |
+
let BACKGROUND: string;
|
| 5 |
+
}
|
| 6 |
+
export const PRIORITY_ORDER: string[];
|
| 7 |
+
export namespace UNLOAD_LEVEL {
|
| 8 |
+
let VRAM: string;
|
| 9 |
+
let CACHE: string;
|
| 10 |
+
}
|
| 11 |
+
export namespace JOB_KIND {
|
| 12 |
+
let CHAT: string;
|
| 13 |
+
let EMBEDDING: string;
|
| 14 |
+
}
|
| 15 |
+
export namespace ENGINE_STATE {
|
| 16 |
+
let IDLE: string;
|
| 17 |
+
let LOADING: string;
|
| 18 |
+
let READY: string;
|
| 19 |
+
let ERROR: string;
|
| 20 |
+
}
|
| 21 |
+
/**
|
| 22 |
+
* Out-of-band message kind for configuring an engine worker before WebLLM's own
|
| 23 |
+
* handshake starts. Distinct from every WebLLM `kind`, so the worker can route
|
| 24 |
+
* on it without parsing the rest.
|
| 25 |
+
*/
|
| 26 |
+
export const WORKER_CONFIGURE: "everything-webgpu/configure";
|
types/engine/create.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @param {string} [modelId] loaded before returning, as `CreateMLCEngine` does.
|
| 3 |
+
* Omit to get an engine that loads later.
|
| 4 |
+
* @param {object} [opts]
|
| 5 |
+
* @param {ModelStore | import("./model-store.js").StorageAdapter} [opts.store]
|
| 6 |
+
* defaults to IndexedDB. Pass one explicitly in a worker or a test.
|
| 7 |
+
* @param {(report: {text: string, progress: number}) => void} [opts.initProgressCallback]
|
| 8 |
+
* @param {string | URL} [opts.workerUrl]
|
| 9 |
+
* @param {() => Promise<object>} [opts.loadWebLLM]
|
| 10 |
+
* @param {boolean} [opts.prebuilt]
|
| 11 |
+
* @returns {Promise<ScheduledEngine>}
|
| 12 |
+
*/
|
| 13 |
+
export function CreateScheduledEngine(modelId?: string, opts?: {
|
| 14 |
+
store?: ModelStore | import("./model-store.js").StorageAdapter;
|
| 15 |
+
initProgressCallback?: (report: {
|
| 16 |
+
text: string;
|
| 17 |
+
progress: number;
|
| 18 |
+
}) => void;
|
| 19 |
+
workerUrl?: string | URL;
|
| 20 |
+
loadWebLLM?: () => Promise<object>;
|
| 21 |
+
prebuilt?: boolean;
|
| 22 |
+
}): Promise<ScheduledEngine>;
|
| 23 |
+
import { ModelStore } from "./model-store.js";
|
| 24 |
+
import { ScheduledEngine } from "./engine.js";
|
types/engine/device.d.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @typedef {object} DeviceProbe
|
| 3 |
+
* @property {boolean} webgpu
|
| 4 |
+
* @property {string} [reason] why WebGPU is unusable, when it is
|
| 5 |
+
* @property {object} [adapter] vendor / architecture / device, where exposed
|
| 6 |
+
* @property {{shaderF16: boolean}} [features]
|
| 7 |
+
* @property {object} [limits]
|
| 8 |
+
* @property {boolean} [kvReuse] whether cross-turn KV reuse can be used here
|
| 9 |
+
* @property {{quota?: number, usage?: number, persisted?: boolean}} storage
|
| 10 |
+
* @property {number} [deviceMemoryGB] Chrome only; absent is not "small"
|
| 11 |
+
*/
|
| 12 |
+
/**
|
| 13 |
+
* Reads what this machine will admit to. Never throws: an unusable device is a
|
| 14 |
+
* result, not an error — the caller's job is to explain it, not to crash.
|
| 15 |
+
*
|
| 16 |
+
* @returns {Promise<DeviceProbe>}
|
| 17 |
+
*/
|
| 18 |
+
export function probeDevice(): Promise<DeviceProbe>;
|
| 19 |
+
/**
|
| 20 |
+
* Whether a model can run on a probed device.
|
| 21 |
+
*
|
| 22 |
+
* `blockers` mean it will not work; `warnings` mean it will work worse, or
|
| 23 |
+
* might not fit. The split matters: a caller should refuse to start on a
|
| 24 |
+
* blocker and merely inform on a warning, and conflating the two is how you end
|
| 25 |
+
* up either crashing or refusing to run something that would have been fine.
|
| 26 |
+
*
|
| 27 |
+
* @param {{model_id: string, vram_required_MB?: number, sizeBytes?: number}} model
|
| 28 |
+
* @param {DeviceProbe} probe
|
| 29 |
+
* @returns {{ok: boolean, blockers: Array<{code: string, message: string}>,
|
| 30 |
+
* warnings: Array<{code: string, message: string}>}}
|
| 31 |
+
*/
|
| 32 |
+
export function canRun(model: {
|
| 33 |
+
model_id: string;
|
| 34 |
+
vram_required_MB?: number;
|
| 35 |
+
sizeBytes?: number;
|
| 36 |
+
}, probe: DeviceProbe): {
|
| 37 |
+
ok: boolean;
|
| 38 |
+
blockers: Array<{
|
| 39 |
+
code: string;
|
| 40 |
+
message: string;
|
| 41 |
+
}>;
|
| 42 |
+
warnings: Array<{
|
| 43 |
+
code: string;
|
| 44 |
+
message: string;
|
| 45 |
+
}>;
|
| 46 |
+
};
|
| 47 |
+
/**
|
| 48 |
+
* Rank a model list by what this device can actually run.
|
| 49 |
+
*
|
| 50 |
+
* The prebuilt list spans 239 MB to 31 GB, so "which model should I use" is the
|
| 51 |
+
* first question a developer has and the one they have least basis to answer.
|
| 52 |
+
* Runnable models come first, then fewest warnings; unrunnable ones are kept at
|
| 53 |
+
* the end carrying their reason rather than silently dropped, because "why
|
| 54 |
+
* can't I use that one" is the next question.
|
| 55 |
+
*
|
| 56 |
+
* **`prefer` is a real choice, not a default worth hiding.** Decode here is
|
| 57 |
+
* memory-bandwidth-bound — time per token scales with weight bytes (AI.md,
|
| 58 |
+
* "Why not llama.cpp/Ollama-class"), so the largest model that fits is also the
|
| 59 |
+
* slowest thing that fits. `"quality"` picks the biggest, `"speed"` the
|
| 60 |
+
* smallest. Neither is right for everyone, which is why it is a parameter.
|
| 61 |
+
*
|
| 62 |
+
* Vision models are excluded from a text ranking rather than merely deprioritised:
|
| 63 |
+
* a VLM answers text prompts perfectly well, but at several times the download
|
| 64 |
+
* for no benefit, so recommending one to a caller who did not ask is bad advice.
|
| 65 |
+
*
|
| 66 |
+
* @param {Array<object>} models `model_list` entries or registry records
|
| 67 |
+
* @param {{probe: DeviceProbe, maxVramMB?: number, needsVision?: boolean,
|
| 68 |
+
* prefer?: "quality" | "speed"}} opts
|
| 69 |
+
*/
|
| 70 |
+
export function rankModels(models: Array<object>, { probe, maxVramMB, needsVision, prefer }?: {
|
| 71 |
+
probe: DeviceProbe;
|
| 72 |
+
maxVramMB?: number;
|
| 73 |
+
needsVision?: boolean;
|
| 74 |
+
prefer?: "quality" | "speed";
|
| 75 |
+
}): {
|
| 76 |
+
ok: boolean;
|
| 77 |
+
blockers: Array<{
|
| 78 |
+
code: string;
|
| 79 |
+
message: string;
|
| 80 |
+
}>;
|
| 81 |
+
warnings: Array<{
|
| 82 |
+
code: string;
|
| 83 |
+
message: string;
|
| 84 |
+
}>;
|
| 85 |
+
model: any;
|
| 86 |
+
}[];
|
| 87 |
+
/**
|
| 88 |
+
* @param {number} modelBytes
|
| 89 |
+
* @param {number} [bytesPerSecond] this machine's measured rate, when known
|
| 90 |
+
* @returns {{tokensPerSecond: number, basis: "measured" | "extrapolated",
|
| 91 |
+
* modelBytes: number, bytesPerSecond: number, reference?: string}}
|
| 92 |
+
*/
|
| 93 |
+
export function projectSpeed(modelBytes: number, bytesPerSecond?: number): {
|
| 94 |
+
tokensPerSecond: number;
|
| 95 |
+
basis: "measured" | "extrapolated";
|
| 96 |
+
modelBytes: number;
|
| 97 |
+
bytesPerSecond: number;
|
| 98 |
+
reference?: string;
|
| 99 |
+
};
|
| 100 |
+
/**
|
| 101 |
+
* Decode throughput is memory bandwidth divided by weight bytes.
|
| 102 |
+
*
|
| 103 |
+
* This project measured the whole chain: decode reaches ~16 GB/s of the M4's
|
| 104 |
+
* ~120 GB/s, and the 1.06 GB build runs 16.6–18.1 tok/s — which is that
|
| 105 |
+
* quotient. So a projection needs one number, the *achieved* bandwidth, and
|
| 106 |
+
* everything else follows from model size.
|
| 107 |
+
*
|
| 108 |
+
* The constant below is that machine's figure and is only a starting point. The
|
| 109 |
+
* moment this engine has decoded anything it knows the real number for the
|
| 110 |
+
* machine it is on, and `ScheduledEngine.estimateSpeed()` switches to it — so
|
| 111 |
+
* this is a cold-start default, not a claim about anyone's hardware.
|
| 112 |
+
*/
|
| 113 |
+
export const REFERENCE_DECODE_BYTES_PER_SECOND: 17000000000;
|
| 114 |
+
export const REFERENCE_DEVICE: "M4 MacBook Air (16 GB), Firefox";
|
| 115 |
+
export type DeviceProbe = {
|
| 116 |
+
webgpu: boolean;
|
| 117 |
+
/**
|
| 118 |
+
* why WebGPU is unusable, when it is
|
| 119 |
+
*/
|
| 120 |
+
reason?: string;
|
| 121 |
+
/**
|
| 122 |
+
* vendor / architecture / device, where exposed
|
| 123 |
+
*/
|
| 124 |
+
adapter?: object;
|
| 125 |
+
features?: {
|
| 126 |
+
shaderF16: boolean;
|
| 127 |
+
};
|
| 128 |
+
limits?: object;
|
| 129 |
+
/**
|
| 130 |
+
* whether cross-turn KV reuse can be used here
|
| 131 |
+
*/
|
| 132 |
+
kvReuse?: boolean;
|
| 133 |
+
storage: {
|
| 134 |
+
quota?: number;
|
| 135 |
+
usage?: number;
|
| 136 |
+
persisted?: boolean;
|
| 137 |
+
};
|
| 138 |
+
/**
|
| 139 |
+
* Chrome only; absent is not "small"
|
| 140 |
+
*/
|
| 141 |
+
deviceMemoryGB?: number;
|
| 142 |
+
};
|
types/engine/engine.d.ts
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export class ScheduledEngine {
|
| 2 |
+
/**
|
| 3 |
+
* @param {object} opts
|
| 4 |
+
* @param {ModelStore | import("./model-store.js").StorageAdapter} opts.store
|
| 5 |
+
* a ModelStore, or a bare StorageAdapter to wrap in one
|
| 6 |
+
* @param {string | URL} [opts.workerUrl]
|
| 7 |
+
* @param {() => Promise<object>} [opts.loadWebLLM]
|
| 8 |
+
* @param {boolean} [opts.prebuilt] expose WebLLM's 163 HuggingFace-hosted
|
| 9 |
+
* models, downloaded on first load. Default true. Set false for an
|
| 10 |
+
* offline-only build: `load()` then resolves registered models and nothing
|
| 11 |
+
* else, and an unknown id fails before the WebLLM bundle is even fetched.
|
| 12 |
+
*/
|
| 13 |
+
constructor({ store, workerUrl, loadWebLLM, prebuilt }?: {
|
| 14 |
+
store: ModelStore | import("./model-store.js").StorageAdapter;
|
| 15 |
+
workerUrl?: string | URL;
|
| 16 |
+
loadWebLLM?: () => Promise<object>;
|
| 17 |
+
prebuilt?: boolean;
|
| 18 |
+
});
|
| 19 |
+
/** The ModelStore, so a host can drive the registry without a second handle. */
|
| 20 |
+
get store(): ModelStore;
|
| 21 |
+
/**
|
| 22 |
+
* `chat.completions.create()`, the WebLLM/OpenAI shape. See `chat.js`.
|
| 23 |
+
*
|
| 24 |
+
* Built once and cached: callers hold on to `engine.chat.completions` the way
|
| 25 |
+
* they did with WebLLM, and a fresh object each access would break that.
|
| 26 |
+
*/
|
| 27 |
+
get chat(): any;
|
| 28 |
+
/**
|
| 29 |
+
* `environment()` — the read-only report, with `environment.measure()` on it.
|
| 30 |
+
*
|
| 31 |
+
* Cached like `chat` so a caller can hold on to it. Writes are `configure()`;
|
| 32 |
+
* see `environment.js` for why those are separate verbs.
|
| 33 |
+
*/
|
| 34 |
+
get environment(): any;
|
| 35 |
+
get state(): {
|
| 36 |
+
status: string;
|
| 37 |
+
modelId: any;
|
| 38 |
+
progress: any;
|
| 39 |
+
error: any;
|
| 40 |
+
pool: {
|
| 41 |
+
size: number;
|
| 42 |
+
busy: number;
|
| 43 |
+
queued: number;
|
| 44 |
+
};
|
| 45 |
+
/** Model ids with a live pool. `modelId` is whichever of them is current. */
|
| 46 |
+
resident: any[];
|
| 47 |
+
/** Latest decode probe from an engine worker; see multistep.js. */
|
| 48 |
+
decode: any;
|
| 49 |
+
};
|
| 50 |
+
get hasWebGPU(): boolean;
|
| 51 |
+
/**
|
| 52 |
+
* @param {(state: object) => void} listener called immediately, then on change
|
| 53 |
+
* @returns {() => void} unsubscribe
|
| 54 |
+
*/
|
| 55 |
+
subscribe(listener: (state: object) => void): () => void;
|
| 56 |
+
/** Model ids with a live pool right now. */
|
| 57 |
+
get resident(): string[];
|
| 58 |
+
/**
|
| 59 |
+
* Choose which resident model unaddressed requests go to.
|
| 60 |
+
*
|
| 61 |
+
* Distinct from `load()` on purpose: this is free and instant, because the
|
| 62 |
+
* weights are already up. `load()` is what costs.
|
| 63 |
+
*/
|
| 64 |
+
use(modelId: any): {
|
| 65 |
+
status: string;
|
| 66 |
+
modelId: any;
|
| 67 |
+
progress: any;
|
| 68 |
+
error: any;
|
| 69 |
+
pool: {
|
| 70 |
+
size: number;
|
| 71 |
+
busy: number;
|
| 72 |
+
queued: number;
|
| 73 |
+
};
|
| 74 |
+
/** Model ids with a live pool. `modelId` is whichever of them is current. */
|
| 75 |
+
resident: any[];
|
| 76 |
+
/** Latest decode probe from an engine worker; see multistep.js. */
|
| 77 |
+
decode: any;
|
| 78 |
+
};
|
| 79 |
+
/** Registered models only — cheap, no bundle load. */
|
| 80 |
+
listModels(): Promise<any[]>;
|
| 81 |
+
/**
|
| 82 |
+
* Everything `load()` would accept, normalised: registered models first, then
|
| 83 |
+
* WebLLM's prebuilt list.
|
| 84 |
+
*
|
| 85 |
+
* Costs a WebLLM bundle fetch when `prebuilt` is on, because the list lives
|
| 86 |
+
* inside it. `listModels()` is the cheap call if you only care about what this
|
| 87 |
+
* app registered.
|
| 88 |
+
*
|
| 89 |
+
* @returns {Promise<Array<{modelId: string, source: string, model: string,
|
| 90 |
+
* contextWindow?: number, vramRequiredMB?: number, sizeBytes?: number}>>}
|
| 91 |
+
*/
|
| 92 |
+
listAvailableModels(): Promise<Array<{
|
| 93 |
+
modelId: string;
|
| 94 |
+
source: string;
|
| 95 |
+
model: string;
|
| 96 |
+
contextWindow?: number;
|
| 97 |
+
vramRequiredMB?: number;
|
| 98 |
+
sizeBytes?: number;
|
| 99 |
+
}>>;
|
| 100 |
+
/**
|
| 101 |
+
* What this machine will admit to: WebGPU, adapter, `shader-f16`, the five
|
| 102 |
+
* limits that matter, storage quota. Cached — hardware does not change
|
| 103 |
+
* mid-session, and `requestAdapter()` is not free.
|
| 104 |
+
* @returns {Promise<import("./device.js").DeviceProbe>}
|
| 105 |
+
*/
|
| 106 |
+
probe(): Promise<import("./device.js").DeviceProbe>;
|
| 107 |
+
/**
|
| 108 |
+
* Whether a model will run here, before anything is downloaded.
|
| 109 |
+
* @param {string} modelId
|
| 110 |
+
* @returns {Promise<{ok: boolean, blockers: Array<object>, warnings: Array<object>}>}
|
| 111 |
+
*/
|
| 112 |
+
canRun(modelId: string): Promise<{
|
| 113 |
+
ok: boolean;
|
| 114 |
+
blockers: Array<object>;
|
| 115 |
+
warnings: Array<object>;
|
| 116 |
+
}>;
|
| 117 |
+
/**
|
| 118 |
+
* Which models this device should actually be asked to run, best first.
|
| 119 |
+
*
|
| 120 |
+
* The prebuilt list spans 239 MB to 31 GB; this is the answer to the first
|
| 121 |
+
* question a developer has and the one they have least basis to answer.
|
| 122 |
+
*
|
| 123 |
+
* @param {{maxVramMB?: number, needsVision?: boolean, needsToolCalling?: boolean,
|
| 124 |
+
* prefer?: "quality" | "speed"}} [opts]
|
| 125 |
+
*/
|
| 126 |
+
recommendModels({ needsToolCalling, ...opts }?: {
|
| 127 |
+
maxVramMB?: number;
|
| 128 |
+
needsVision?: boolean;
|
| 129 |
+
needsToolCalling?: boolean;
|
| 130 |
+
prefer?: "quality" | "speed";
|
| 131 |
+
}): Promise<{
|
| 132 |
+
ok: boolean;
|
| 133 |
+
blockers: Array<{
|
| 134 |
+
code: string;
|
| 135 |
+
message: string;
|
| 136 |
+
}>;
|
| 137 |
+
warnings: Array<{
|
| 138 |
+
code: string;
|
| 139 |
+
message: string;
|
| 140 |
+
}>;
|
| 141 |
+
model: any;
|
| 142 |
+
}[]>;
|
| 143 |
+
/**
|
| 144 |
+
* Is this model's data on disk, so a load would need no network?
|
| 145 |
+
*
|
| 146 |
+
* Routes by who knows the keys. We wrote an injected model's artifacts and
|
| 147 |
+
* hold the manifest, so `verify()` answers exactly — including a `"partial"`
|
| 148 |
+
* verdict WebLLM cannot give. Everything else was fetched by WebLLM, which
|
| 149 |
+
* derives the keys as its loader did, so `hasModelInCache` is the answer.
|
| 150 |
+
*
|
| 151 |
+
* @returns {Promise<"cached" | "partial" | "absent">}
|
| 152 |
+
*/
|
| 153 |
+
cacheState(modelId: any): Promise<"cached" | "partial" | "absent">;
|
| 154 |
+
/**
|
| 155 |
+
* Download a model into the cache **without building an engine**.
|
| 156 |
+
*
|
| 157 |
+
* For warming during onboarding: the bytes land while the user is still
|
| 158 |
+
* reading, and the later `load()` is a cache read. WebLLM cannot express this
|
| 159 |
+
* — `reload()` instantiates the wasm and needs a GPU before it fetches a
|
| 160 |
+
* single shard — so this is ours. See `prefetch.js` for the URL-derivation
|
| 161 |
+
* risk and the oracle that closes it.
|
| 162 |
+
*
|
| 163 |
+
* Needs no WebGPU at all, which is the other half of the point: an app can
|
| 164 |
+
* warm the cache on a machine it has not yet decided can run the model.
|
| 165 |
+
*
|
| 166 |
+
* @param {string} modelId
|
| 167 |
+
* @param {{signal?: AbortSignal, onProgress?: Function}} [opts]
|
| 168 |
+
*/
|
| 169 |
+
prefetch(modelId: string, { signal, onProgress }?: {
|
| 170 |
+
signal?: AbortSignal;
|
| 171 |
+
onProgress?: Function;
|
| 172 |
+
}): Promise<{
|
| 173 |
+
modelId: string;
|
| 174 |
+
files: number;
|
| 175 |
+
bytes: number;
|
| 176 |
+
alreadyCached: boolean;
|
| 177 |
+
}>;
|
| 178 |
+
/**
|
| 179 |
+
* Free a model's bytes and **keep the registry entry**, so it stays a model
|
| 180 |
+
* this engine knows how to get again — the distinction from
|
| 181 |
+
* `store.remove()`, which forgets the URL a remote model would need.
|
| 182 |
+
*
|
| 183 |
+
* Delegates for remote and prebuilt models: `deleteModelAllInfoInCache` is
|
| 184 |
+
* WebLLM's, covers tensors + wasm + config, and is maintained upstream.
|
| 185 |
+
*/
|
| 186 |
+
evict(modelId: any): Promise<{
|
| 187 |
+
freedKeys: number;
|
| 188 |
+
}>;
|
| 189 |
+
/**
|
| 190 |
+
* Forget a model entirely: free its bytes **and** drop the registry entry.
|
| 191 |
+
*
|
| 192 |
+
* `evict()` first, because that is what knows how to reach the bytes for each
|
| 193 |
+
* source — and it has to happen before the record is deleted, since for a
|
| 194 |
+
* remote model the record holds the only URL those bytes can be derived from.
|
| 195 |
+
* Deleting the entry first would strand them in Cache Storage permanently.
|
| 196 |
+
*/
|
| 197 |
+
remove(modelId: any): Promise<{
|
| 198 |
+
freedKeys: number;
|
| 199 |
+
}>;
|
| 200 |
+
/**
|
| 201 |
+
* Projected decode throughput for a model, in tokens per second.
|
| 202 |
+
*
|
| 203 |
+
* `basis: "measured"` once anything has actually decoded on this machine —
|
| 204 |
+
* the engine then knows its own achieved bandwidth and every projection is
|
| 205 |
+
* device-specific. Before that, `basis: "extrapolated"` from a reference
|
| 206 |
+
* machine, which is a starting point and says so.
|
| 207 |
+
*
|
| 208 |
+
* Decode is memory-bandwidth-bound, so this is close to the whole story:
|
| 209 |
+
* time per token scales with weight bytes and little else.
|
| 210 |
+
*
|
| 211 |
+
* @param {string} [modelId] defaults to the current model
|
| 212 |
+
*/
|
| 213 |
+
estimateSpeed(modelId?: string): Promise<{
|
| 214 |
+
tokensPerSecond: number;
|
| 215 |
+
basis: "measured" | "extrapolated";
|
| 216 |
+
modelBytes: number;
|
| 217 |
+
bytesPerSecond: number;
|
| 218 |
+
reference?: string;
|
| 219 |
+
modelId: string;
|
| 220 |
+
}>;
|
| 221 |
+
/**
|
| 222 |
+
* What is actually switched on right now, as opposed to what the device could
|
| 223 |
+
* support.
|
| 224 |
+
*
|
| 225 |
+
* The distinction matters for KV reuse in particular: `probe().kvReuse` is a
|
| 226 |
+
* device capability, but the decision is taken inside the engine worker,
|
| 227 |
+
* which is the authority. A caller debugging "why is my second turn slow"
|
| 228 |
+
* needs the decision, not the capability.
|
| 229 |
+
*/
|
| 230 |
+
features(): Promise<{
|
| 231 |
+
kvReuse: boolean;
|
| 232 |
+
shaderF16: boolean;
|
| 233 |
+
decodeSteps: any;
|
| 234 |
+
multiStepDecoding: boolean;
|
| 235 |
+
engines: number;
|
| 236 |
+
maxEngines: any;
|
| 237 |
+
resident: string[];
|
| 238 |
+
computePassBatching: number;
|
| 239 |
+
decode: any;
|
| 240 |
+
}>;
|
| 241 |
+
/**
|
| 242 |
+
* Register a model. Two shapes, one call, and the difference is only where
|
| 243 |
+
* the bytes come from:
|
| 244 |
+
*
|
| 245 |
+
* ```js
|
| 246 |
+
* // fetched from a base URL you host — an HF repo, a CDN, your own origin
|
| 247 |
+
* await engine.registerModel({
|
| 248 |
+
* modelId: "my-model",
|
| 249 |
+
* model: "/models/my-model/",
|
| 250 |
+
* modelLib: "/models/my-model/my-model-webgpu.wasm",
|
| 251 |
+
* });
|
| 252 |
+
*
|
| 253 |
+
* // read off disk. No network connection at any point, ever.
|
| 254 |
+
* await engine.registerModel({ modelId: "my-model", files: entries });
|
| 255 |
+
* ```
|
| 256 |
+
*
|
| 257 |
+
* Both end up as one `model_list` entry that WebLLM's own loader resolves the
|
| 258 |
+
* same way — the local one only differs in that its base URL is minted on
|
| 259 |
+
* `.invalid` and its cache is populated before the loader ever looks.
|
| 260 |
+
*
|
| 261 |
+
* That origin is the *mechanism* of the offline guarantee, not a marker of
|
| 262 |
+
* it: `.invalid` is reserved by RFC 6761 and can never resolve, so there is
|
| 263 |
+
* no code path — no bug, no eviction, no future refactor — by which a local
|
| 264 |
+
* model reaches the network. It fails with a DNS error instead.
|
| 265 |
+
*
|
| 266 |
+
* `files` is `{ path, file }[]`; `filesFromDataTransfer` and
|
| 267 |
+
* `filesFromInput` build it from a drop event or a directory picker.
|
| 268 |
+
*/
|
| 269 |
+
registerModel(spec: any): Promise<any>;
|
| 270 |
+
/**
|
| 271 |
+
* Bring a model up, whatever form you have it in.
|
| 272 |
+
*
|
| 273 |
+
* One entry point for all three routes, because from a caller's side "load a
|
| 274 |
+
* model" is one intention and having to know which of `load`,
|
| 275 |
+
* `registerModel` and `ingestModelFolder` to reach for is a decision the
|
| 276 |
+
* library can make for them:
|
| 277 |
+
*
|
| 278 |
+
* ```js
|
| 279 |
+
* load("Llama-3.2-1B-Instruct-q4f16_1-MLC") // prebuilt or registered id
|
| 280 |
+
* load("https://huggingface.co/mlc-ai/Foo", { modelLib }) // a URL you host
|
| 281 |
+
* load({ model, modelLib }) // the same, explicit
|
| 282 |
+
* load({ files }) | load(fileList) | load(dataTransfer) // a folder, no network
|
| 283 |
+
* ```
|
| 284 |
+
*
|
| 285 |
+
* `registerModel` and `ingestModelFolder` remain, unchanged, as the low-level
|
| 286 |
+
* primitives — this composes them rather than replacing them.
|
| 287 |
+
*
|
| 288 |
+
* **A URL always needs `modelLib`.** It is not guessed; see `sources.js` for
|
| 289 |
+
* the measurement behind that. **`defer: true`** registers the source and
|
| 290 |
+
* stops there, returning the record instead of the state — the manager's
|
| 291 |
+
* drop-now-load-later flow.
|
| 292 |
+
*
|
| 293 |
+
* Additive residency: a model already resident stays resident, so switching
|
| 294 |
+
* back to it costs nothing. That is only safe while the weights fit, so
|
| 295 |
+
* `keepResident: false` (the default) unloads whatever else is up first —
|
| 296 |
+
* the old single-model behaviour, and the safe one on a 16 GB machine.
|
| 297 |
+
* Pass `keepResident: true` to hold both, having checked the budget yourself
|
| 298 |
+
* with `canRun()`.
|
| 299 |
+
*
|
| 300 |
+
* @param {string | object} src an id, a URL, `{model, modelLib}`, or a folder
|
| 301 |
+
* @param {{keepResident?: boolean, signal?: AbortSignal, defer?: boolean,
|
| 302 |
+
* id?: string, modelLib?: string, modelType?: string, contextWindow?: number,
|
| 303 |
+
* vramRequiredMB?: number, onProgress?: Function}} [opts]
|
| 304 |
+
* @returns {Promise<object>} the engine state, or the registry record when `defer`
|
| 305 |
+
*/
|
| 306 |
+
load(src: string | object, opts?: {
|
| 307 |
+
keepResident?: boolean;
|
| 308 |
+
signal?: AbortSignal;
|
| 309 |
+
defer?: boolean;
|
| 310 |
+
id?: string;
|
| 311 |
+
modelLib?: string;
|
| 312 |
+
modelType?: string;
|
| 313 |
+
contextWindow?: number;
|
| 314 |
+
vramRequiredMB?: number;
|
| 315 |
+
onProgress?: Function;
|
| 316 |
+
}): Promise<object>;
|
| 317 |
+
/**
|
| 318 |
+
* Let a model go, at one of two depths.
|
| 319 |
+
*
|
| 320 |
+
* ```js
|
| 321 |
+
* unload() // the current model's VRAM; cached bytes stay
|
| 322 |
+
* unload(id) // that model's VRAM
|
| 323 |
+
* unload(id, "cache") // and delete its cached bytes, keeping the registry entry
|
| 324 |
+
* ```
|
| 325 |
+
*
|
| 326 |
+
* At `"vram"` the bytes stay on disk, so loading it again costs no network —
|
| 327 |
+
* that is what makes switching back cheap, and the difference between this
|
| 328 |
+
* and `remove()`.
|
| 329 |
+
*
|
| 330 |
+
* **A bare `unload()` frees only the current model**, not every resident one.
|
| 331 |
+
* `unloadAll()` is the explicit form for that: freeing everything is the more
|
| 332 |
+
* destructive of the two readings and should have to be asked for by name.
|
| 333 |
+
*
|
| 334 |
+
* @param {string} [modelId] defaults to the current model. Omit both this and
|
| 335 |
+
* any resident model to no-op.
|
| 336 |
+
* @param {"vram"|"cache"} [level]
|
| 337 |
+
*/
|
| 338 |
+
unload(modelId?: string, level?: "vram" | "cache"): Promise<{
|
| 339 |
+
status: string;
|
| 340 |
+
modelId: any;
|
| 341 |
+
progress: any;
|
| 342 |
+
error: any;
|
| 343 |
+
pool: {
|
| 344 |
+
size: number;
|
| 345 |
+
busy: number;
|
| 346 |
+
queued: number;
|
| 347 |
+
};
|
| 348 |
+
/** Model ids with a live pool. `modelId` is whichever of them is current. */
|
| 349 |
+
resident: any[];
|
| 350 |
+
/** Latest decode probe from an engine worker; see multistep.js. */
|
| 351 |
+
decode: any;
|
| 352 |
+
}>;
|
| 353 |
+
/** Unload every resident model. */
|
| 354 |
+
unloadAll(): Promise<{
|
| 355 |
+
status: string;
|
| 356 |
+
modelId: any;
|
| 357 |
+
progress: any;
|
| 358 |
+
error: any;
|
| 359 |
+
pool: {
|
| 360 |
+
size: number;
|
| 361 |
+
busy: number;
|
| 362 |
+
queued: number;
|
| 363 |
+
};
|
| 364 |
+
/** Model ids with a live pool. `modelId` is whichever of them is current. */
|
| 365 |
+
resident: any[];
|
| 366 |
+
/** Latest decode probe from an engine worker; see multistep.js. */
|
| 367 |
+
decode: any;
|
| 368 |
+
}>;
|
| 369 |
+
/**
|
| 370 |
+
* One completion.
|
| 371 |
+
*
|
| 372 |
+
* Named `complete` rather than `chat` so `engine.chat.completions.create()`
|
| 373 |
+
* — the WebLLM-shaped facade, Phase 2 — can take that name without a rename.
|
| 374 |
+
*
|
| 375 |
+
* @param {CompletionRequest} payload
|
| 376 |
+
* @param {(delta: string) => void} [onChunk] called per streamed text delta
|
| 377 |
+
* @returns {Promise<CompletionResult>}
|
| 378 |
+
*/
|
| 379 |
+
complete(payload: CompletionRequest, onChunk?: (delta: string) => void): Promise<CompletionResult>;
|
| 380 |
+
/**
|
| 381 |
+
* `complete()`, but the callback receives WebLLM's chunk verbatim.
|
| 382 |
+
*
|
| 383 |
+
* Exists so the `chat.completions.create()` facade can pass chunks straight
|
| 384 |
+
* through instead of rebuilding an envelope — which is what dropped
|
| 385 |
+
* `tool_calls`, flattened `logprobs` and restamped `created`.
|
| 386 |
+
*
|
| 387 |
+
* @param {CompletionRequest} payload
|
| 388 |
+
* @param {(chunk: object) => void} [onRawChunk]
|
| 389 |
+
* @returns {Promise<CompletionResult & {toolCalls?: Array<object>}>}
|
| 390 |
+
*/
|
| 391 |
+
completeRaw(payload: CompletionRequest, onRawChunk?: (chunk: object) => void): Promise<CompletionResult & {
|
| 392 |
+
toolCalls?: Array<object>;
|
| 393 |
+
}>;
|
| 394 |
+
/**
|
| 395 |
+
* One question, one answer, nothing kept.
|
| 396 |
+
*
|
| 397 |
+
* ```js
|
| 398 |
+
* const answer = await engine.ask("Summarise this in one line:\n" + doc);
|
| 399 |
+
* ```
|
| 400 |
+
*
|
| 401 |
+
* @param {string | Array<object>} input
|
| 402 |
+
* @param {object} [opts] anything `complete()` takes, plus `onDelta` to stream
|
| 403 |
+
* @returns {Promise<string>}
|
| 404 |
+
*/
|
| 405 |
+
ask(input: string | Array<object>, opts?: object): Promise<string>;
|
| 406 |
+
/**
|
| 407 |
+
* A multi-turn conversation that keeps its own history.
|
| 408 |
+
*
|
| 409 |
+
* ```js
|
| 410 |
+
* const chat = engine.conversation({ system: "You are terse." });
|
| 411 |
+
* await chat.say("hello");
|
| 412 |
+
* await chat.say("and again?"); // remembers
|
| 413 |
+
* ```
|
| 414 |
+
*
|
| 415 |
+
* @param {object} [opts] `system`, `keep`, plus `complete()` defaults
|
| 416 |
+
*/
|
| 417 |
+
conversation(opts?: object): {
|
| 418 |
+
readonly messages: any[];
|
| 419 |
+
readonly length: number;
|
| 420 |
+
say(content: string, onDelta?: (delta: string) => void): Promise<{
|
| 421 |
+
text: string;
|
| 422 |
+
finishReason: "length" | "stop" | "abort";
|
| 423 |
+
}>;
|
| 424 |
+
reset(): /*elided*/ any;
|
| 425 |
+
restore(messages: any): /*elided*/ any;
|
| 426 |
+
};
|
| 427 |
+
/**
|
| 428 |
+
* Ghost text, with the debounce/supersede/drop-if-stale discipline built in
|
| 429 |
+
* and the prompt left to you.
|
| 430 |
+
*
|
| 431 |
+
* ```js
|
| 432 |
+
* const ghost = engine.ghostText({ prompt: (before) => `Continue:\n${before}` });
|
| 433 |
+
* editor.on("input", async () => {
|
| 434 |
+
* const hint = await ghost.suggest(editor.textBefore());
|
| 435 |
+
* if (hint !== null) render(hint); // null means a newer keystroke won
|
| 436 |
+
* });
|
| 437 |
+
* editor.on("blur", () => ghost.cancel());
|
| 438 |
+
* ```
|
| 439 |
+
*
|
| 440 |
+
* @param {object} opts must include `prompt`
|
| 441 |
+
*/
|
| 442 |
+
ghostText(opts: object): {
|
| 443 |
+
suggest(context: any): Promise<string | null>;
|
| 444 |
+
cancel(): number;
|
| 445 |
+
};
|
| 446 |
+
/**
|
| 447 |
+
* Embed text into vectors, through the same scheduler as everything else.
|
| 448 |
+
*
|
| 449 |
+
* ```js
|
| 450 |
+
* const [vector] = await engine.embed("a sentence", { modelId: EMBED_MODEL });
|
| 451 |
+
* const vectors = await engine.embed(["one", "two"], { modelId: EMBED_MODEL });
|
| 452 |
+
* ```
|
| 453 |
+
*
|
| 454 |
+
* **Needs an embedding model**, not a chat model — `snowflake-arctic-embed-*`
|
| 455 |
+
* in WebLLM's prebuilt list, from 239 MB. They are separate models, so this
|
| 456 |
+
* usually names `modelId` explicitly and holds it resident alongside a chat
|
| 457 |
+
* model with `load(id, { keepResident: true })`.
|
| 458 |
+
*
|
| 459 |
+
* Returns bare vectors because that is what a caller does arithmetic on; the
|
| 460 |
+
* OpenAI envelope is available as `embedRaw()` for anyone porting code that
|
| 461 |
+
* expects `data[].embedding`.
|
| 462 |
+
*
|
| 463 |
+
* **A running embedding cannot be interrupted.** Cancellation and preemption
|
| 464 |
+
* work by making a decode loop break out; one forward pass has no loop, so a
|
| 465 |
+
* `cancel()` that lands after the job starts marks it cancelled but does not
|
| 466 |
+
* stop it. Queued embeddings supersede and cancel normally. This is tolerable
|
| 467 |
+
* because an embedding is milliseconds where a completion is seconds — but it
|
| 468 |
+
* is a weaker guarantee than `complete()` gives, so it is stated rather than
|
| 469 |
+
* discovered.
|
| 470 |
+
*
|
| 471 |
+
* @param {string | string[]} input
|
| 472 |
+
* @param {{modelId?: string, task?: string, session?: string,
|
| 473 |
+
* priority?: string, preemptible?: boolean, id?: string}} [opts]
|
| 474 |
+
* @returns {Promise<number[][]>} one vector per input, in order
|
| 475 |
+
*/
|
| 476 |
+
embed(input: string | string[], opts?: {
|
| 477 |
+
modelId?: string;
|
| 478 |
+
task?: string;
|
| 479 |
+
session?: string;
|
| 480 |
+
priority?: string;
|
| 481 |
+
preemptible?: boolean;
|
| 482 |
+
id?: string;
|
| 483 |
+
}): Promise<number[][]>;
|
| 484 |
+
/** `embed()`, returning WebLLM's OpenAI-shaped envelope untouched. */
|
| 485 |
+
embedRaw(input: any, opts?: {}): Promise<{
|
| 486 |
+
data: any;
|
| 487 |
+
usage: any;
|
| 488 |
+
}>;
|
| 489 |
+
/**
|
| 490 |
+
* Independent prompts, fanned across the pool. This is the only way to beat
|
| 491 |
+
* the ~10 tok/s single-stream ceiling, so anything embarrassingly parallel
|
| 492 |
+
* (translating a page, labelling a list) should arrive here rather than as a
|
| 493 |
+
* loop of `complete` calls.
|
| 494 |
+
*
|
| 495 |
+
* @param {CompletionRequest & {requests: Array<Partial<CompletionRequest>>}} payload
|
| 496 |
+
* @param {(item: BatchItem) => void} [onItem] called as each item lands
|
| 497 |
+
* @returns {Promise<Array<BatchItem>>}
|
| 498 |
+
*/
|
| 499 |
+
batch(payload: CompletionRequest & {
|
| 500 |
+
requests: Array<Partial<CompletionRequest>>;
|
| 501 |
+
}, onItem?: (item: BatchItem) => void): Promise<Array<BatchItem>>;
|
| 502 |
+
/**
|
| 503 |
+
* Cancels by job id or by session key.
|
| 504 |
+
* @param {string} idOrSession
|
| 505 |
+
* @returns {number} how many jobs it stopped
|
| 506 |
+
*/
|
| 507 |
+
cancel(idOrSession: string): number;
|
| 508 |
+
/**
|
| 509 |
+
* Applies a runtime knob to the running pool and persists it as the default.
|
| 510 |
+
*
|
| 511 |
+
* `decodeSteps` is the multi-step decode width (AI.md, "Multi-step decoding").
|
| 512 |
+
* It takes effect on the next burst — no reload — which is what makes sweeping
|
| 513 |
+
* it to find this machine's tick boundary cheap.
|
| 514 |
+
*/
|
| 515 |
+
configure(patch: any): Promise<{
|
| 516 |
+
settings: {
|
| 517 |
+
decodeSteps: number;
|
| 518 |
+
engineCount: number;
|
| 519 |
+
};
|
| 520 |
+
engines: number;
|
| 521 |
+
}>;
|
| 522 |
+
#private;
|
| 523 |
+
}
|
| 524 |
+
/**
|
| 525 |
+
* The OpenAI generation fields WebLLM already speaks, plus the scheduling
|
| 526 |
+
* fields that are what this engine adds over calling WebLLM directly.
|
| 527 |
+
*/
|
| 528 |
+
export type CompletionRequest = {
|
| 529 |
+
messages: Array<{
|
| 530 |
+
role: string;
|
| 531 |
+
content: string;
|
| 532 |
+
}>;
|
| 533 |
+
/**
|
| 534 |
+
* load this model first if it is not the live one
|
| 535 |
+
*/
|
| 536 |
+
modelId?: string;
|
| 537 |
+
/**
|
| 538 |
+
* job id; also what `cancel(id)` takes
|
| 539 |
+
*/
|
| 540 |
+
id?: string;
|
| 541 |
+
temperature?: number;
|
| 542 |
+
max_tokens?: number;
|
| 543 |
+
response_format?: object;
|
| 544 |
+
extra_body?: object;
|
| 545 |
+
/**
|
| 546 |
+
* the unit that owns an engine; a whole batch shares one
|
| 547 |
+
*/
|
| 548 |
+
task?: string;
|
| 549 |
+
/**
|
| 550 |
+
* a later job with this key supersedes the earlier one
|
| 551 |
+
*/
|
| 552 |
+
session?: string;
|
| 553 |
+
priority?: "interactive" | "normal" | "background";
|
| 554 |
+
/**
|
| 555 |
+
* may be interrupted by an `interactive` job
|
| 556 |
+
*/
|
| 557 |
+
preemptible?: boolean;
|
| 558 |
+
};
|
| 559 |
+
export type CompletionResult = {
|
| 560 |
+
text: string;
|
| 561 |
+
usage?: object;
|
| 562 |
+
/**
|
| 563 |
+
* WebLLM's own values
|
| 564 |
+
*/
|
| 565 |
+
finishReason?: "stop" | "length" | "abort";
|
| 566 |
+
/**
|
| 567 |
+
* superseded or explicitly cancelled
|
| 568 |
+
*/
|
| 569 |
+
cancelled?: true;
|
| 570 |
+
/**
|
| 571 |
+
* an `interactive` job took the slot; `text` is partial
|
| 572 |
+
*/
|
| 573 |
+
preempted?: true;
|
| 574 |
+
};
|
| 575 |
+
export type BatchItem = CompletionRequest & {
|
| 576 |
+
index: number;
|
| 577 |
+
engineIndex: number;
|
| 578 |
+
startedAt: number;
|
| 579 |
+
finishedAt: number;
|
| 580 |
+
error?: string;
|
| 581 |
+
};
|
| 582 |
+
import { ModelStore } from "./model-store.js";
|