diskformer 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +21 -21
- package/engines/gemma4_moe_ssd.js +1 -1
- package/engines/qwen35_moe_ssd.js +2 -2
- package/engines/qwen3_moe_ssd.js +82 -27
- package/index.js +3 -2
- package/package.json +1 -1
- package/src/errors.js +36 -0
- package/src/ingest.js +45 -2
- package/src/opfs-reader.js +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 (2026-10-13)
|
|
4
|
+
- Changed: errors are now `DiskformerError`s with a `code` (see below). A caller that checked `e.name === 'AbortError'`
|
|
5
|
+
after an aborted ingest should check `isDiskformerError(e, 'LOAD_ABORTED')`; messages are unchanged.
|
|
6
|
+
- Coded errors (`src/errors.js`): `DiskformerError` with a `code`, checked by name and code through
|
|
7
|
+
`isDiskformerError(e, code)`. `ingestGguf` throws `LOAD_ABORTED` when its signal aborted, else `LOAD_FAILED` with the
|
|
8
|
+
original as `cause`.
|
|
9
|
+
- `readStore(key)` and `listStores()`: a store is complete only when every file it names is on disk at full size;
|
|
10
|
+
zero-byte or short files left by a crash make it `invalid`. `examples/chat` lists the stored models and removes one
|
|
11
|
+
at a time.
|
|
12
|
+
- `engines/`: `UNSUPPORTED_DEVICE`, `BUDGET_TOO_SMALL` (with the bytes in `detail`), `CONTEXT_FULL`,
|
|
13
|
+
`EXECUTION_FAILED`, `RESOURCE_DISPOSED`. `generate()` and `greedy()` on one engine run one at a time (they share the
|
|
14
|
+
KV cache); `dispose()` waits for the step in flight. A load that fails destroys its GPU device. An invalid store is
|
|
15
|
+
ingested again instead of loaded.
|
|
16
|
+
- Gates that run from this repo: `test/browser/greedy.html` (token by token vs llama.cpp, references in `test/refs/`),
|
|
17
|
+
`test/browser/states.html` + `test/state-check.py` (per-layer states vs LocalMind's numpy pass).
|
|
18
|
+
- New helpers: `opfsFileSize(path)`, `opfsSubdirs(path)`.
|
|
19
|
+
- Not in the npm package, but new in the repo: the demo at diskformer.naklitechie.com now runs llama.cpp's WebGPU
|
|
20
|
+
backend with experts paged from OPFS (`ggml/`; LFM2 8B-A1B, OLMoE 1B-7B, Qwen3 30B-A3B, Qwen3-Next 80B-A3B); the
|
|
21
|
+
hand-engine page moved to `/classic/`.
|
|
22
|
+
|
|
3
23
|
## 0.3.0 (2026-10-07)
|
|
4
24
|
- `fileFetch(blob)`: ingest a GGUF that is already on disk (a `File` the user picked, e.g. from a Hugging Face or
|
|
5
25
|
LM Studio cache) through `ingestGguf({ fetch: fileFetch(file) })`, with no download. The store is byte-identical to a
|
package/README.md
CHANGED
|
@@ -10,6 +10,8 @@ Plain ES modules, no dependencies, no build step. Chromium with WebGPU. Weights
|
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
[](package.json)
|
|
12
12
|
|
|
13
|
+
[](https://diskformer.naklitechie.com/)
|
|
14
|
+
|
|
13
15
|
## Install
|
|
14
16
|
|
|
15
17
|
| How | Command |
|
|
@@ -20,8 +22,8 @@ Plain ES modules, no dependencies, no build step. Chromium with WebGPU. Weights
|
|
|
20
22
|
|
|
21
23
|
## Try it: a 14.4 GB model in 2.5 GB of GPU memory
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
[diskformer.naklitechie.com](https://diskformer.naklitechie.com/) (also a [Hugging Face Space](https://huggingface.co/spaces/naklitechie/diskformer-chat)) runs llama.cpp's WebGPU backend with paged experts: LFM2 8B-A1B, OLMoE, Qwen3 30B-A3B, Qwen3-Next 80B (source in `ggml/`; references in `examples/chat/refs/`).
|
|
26
|
+
The hand-written engines measured below are at [/classic/](https://diskformer.naklitechie.com/classic/); serve `examples/chat` to run them locally. Each page downloads a model once into OPFS, resumably.
|
|
25
27
|
|
|
26
28
|
```bash
|
|
27
29
|
python3 -m http.server 8000 # then open http://localhost:8000/examples/chat/
|
|
@@ -36,10 +38,9 @@ Measured on a MacBook M4 Pro with 24 GB, Chrome 154, on 2026-10-05:
|
|
|
36
38
|
| Gemma 4 26B-A4B, QAT Q4_0 | 14.4 GB | 7 GB | 6.63 GB | 17.9 tok/s | 9/9 |
|
|
37
39
|
| Qwen3.6 35B-A3B, Q8_0 | 36.9 GB | 4 GB | 3.52 GB | 9.2 tok/s | not compared |
|
|
38
40
|
|
|
39
|
-
"Identical" means the reply text matches llama.cpp b9830's Metal path character for character
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
hold 36.9 GB on this Mac.
|
|
41
|
+
"Identical" means the reply text matches llama.cpp b9830's Metal path character for character on the same GGUF. The
|
|
42
|
+
gate first drops the empty thought-channel marker llama-server echoes before each Gemma reply. 9 conversations,
|
|
43
|
+
greedy, up to 64 tokens each. Qwen3.6 is not compared: llama.cpp's Metal path cannot hold 36.9 GB here.
|
|
43
44
|
|
|
44
45
|
Each row is `node examples/run-demo.mjs chat --budget <GB>`; decode is the median over the 9 replies. A larger budget
|
|
45
46
|
caches more experts. Only Apple silicon is tested; discrete GPUs and 8–16 GB machines are not.
|
|
@@ -53,14 +54,12 @@ const engine = await Gemma4MoeSsd.load(null, { gpuBudgetBytes: 3e9, onProgress:
|
|
|
53
54
|
for await (const { text } of engine.generate([{ role: 'user', content: 'Hello' }], { maxNewTokens: 128 })) show(text);
|
|
54
55
|
```
|
|
55
56
|
|
|
56
|
-
The first load downloads the GGUF from Hugging Face
|
|
57
|
-
seconds.
|
|
57
|
+
The first load downloads the GGUF from Hugging Face into OPFS, in the engine's layout. Later loads take seconds.
|
|
58
58
|
|
|
59
59
|
## Why
|
|
60
60
|
|
|
61
|
-
Your model does not fit on the GPU, or
|
|
62
|
-
|
|
63
|
-
tenth of its weights per token. Gemma 4's per-layer embedding table is read one row per token.
|
|
61
|
+
Your model does not fit on the GPU, or fits only by crowding out everything else. Browser engines load every weight
|
|
62
|
+
at load time, though a mixture-of-experts model uses under a tenth of its weights per token.
|
|
64
63
|
|
|
65
64
|
diskformer keeps those weights in OPFS, read through sync access handles in workers. Only what each step needs
|
|
66
65
|
moves into a GPU cache. The core is the store and the residency layer: your engine keeps its own kernels and decides
|
|
@@ -71,9 +70,8 @@ what a step needs. `engines/` shows two complete engines built on it.
|
|
|
71
70
|
`engines/gemma4_moe_ssd.js` runs Gemma 4 26B-A4B. `engines/qwen35_moe_ssd.js` runs Qwen3.6 35B-A3B, and
|
|
72
71
|
`engines/qwen3_moe_ssd.js` runs Qwen3-30B-A3B as their base. They are from-scratch WebGPU engines. The dense layers and
|
|
73
72
|
KV cache live on the GPU. The experts stream from OPFS into a GPU cache, sized by `gpuBudgetBytes`. A budget that
|
|
74
|
-
cannot hold the dense part and two tokens' experts is refused with the numbers. Decoding is greedy
|
|
75
|
-
|
|
76
|
-
`scripts/sync-localmind.mjs`.
|
|
73
|
+
cannot hold the dense part and two tokens' experts is refused with the numbers. Decoding is greedy, one `generate()`
|
|
74
|
+
at a time per engine. Kernels: gemma4-webgpu (Apache-2.0, `engines/NOTICE`); files kept in step with LocalMind.
|
|
77
75
|
|
|
78
76
|
## A table on disk
|
|
79
77
|
|
|
@@ -90,6 +88,10 @@ as `fetch: fileFetch(file)`: no download, same store. `ExpertStreamer` (also `Re
|
|
|
90
88
|
of expert records: `ensure(layer, ids)` returns pinned slots, `prefetch(layer, guesses)` never blocks, and uploads go
|
|
91
89
|
through a mapped staging ring.
|
|
92
90
|
|
|
91
|
+
`listStores()` lists the stored models with size and state; a store with zero-byte or short files (a crash) is
|
|
92
|
+
`invalid` and is ingested again, never loaded. `removeStore(key)` removes one and keeps the rest. Errors carry a `code`
|
|
93
|
+
(`LOAD_ABORTED`, `BUDGET_TOO_SMALL`, … in `src/errors.js`), checked with `isDiskformerError(e, code)`.
|
|
94
|
+
|
|
93
95
|
`measure({ path, recordBytes })` reports sequential MB/s and random record reads; OPFS speed varies by machine.
|
|
94
96
|
|
|
95
97
|
## Commands
|
|
@@ -101,17 +103,15 @@ node examples/run-demo.mjs embedding # lookup → slots → a WGSL gathe
|
|
|
101
103
|
node examples/run-demo.mjs chat --budget 2.5 # downloads Gemma once (14.4 GB) to a fixed profile, replays 9 llama.cpp replies, fails on any difference
|
|
102
104
|
node examples/run-demo.mjs test/browser/ingest-resume.html --models <dir> --file <gguf> # abort, resume, compare bytes
|
|
103
105
|
node examples/run-demo.mjs test/browser/server.html --models <dir> --file <any file> # the runner's file server rejects bad requests
|
|
106
|
+
node examples/run-demo.mjs test/browser/greedy.html --models <dir> --file <gguf> --ref test/refs/<ref>.json # token by token vs llama.cpp
|
|
107
|
+
node examples/run-demo.mjs test/browser/states.html --models <dir> --file g4-6l.gguf # per-layer states; then test/state-check.py
|
|
104
108
|
npm run sync # the files shared with LocalMind match their sources
|
|
105
109
|
```
|
|
106
110
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
`node examples/run-demo.mjs chat` runs the chat page in headless Chrome under a GPU budget and replays
|
|
110
|
-
`examples/chat/refs/gemma.json` (llama.cpp's replies); any differing reply exits 1. `--models <dir>` serves a local
|
|
111
|
-
GGUF instead of downloading it; `--site <url>` gates a deployed copy, such as the Space.
|
|
111
|
+
The chat gate replays `examples/chat/refs/gemma.json` (llama.cpp's replies) under a GPU budget; any differing reply
|
|
112
|
+
exits 1. `--models <dir>` serves a local GGUF; `--site <url>` gates a deployed copy, such as the Space.
|
|
112
113
|
|
|
113
|
-
The code runs in [LocalMind](https://github.com/NakliTechie/LocalMind)
|
|
114
|
-
and `engines/` are byte-identical to LocalMind's copies, apart from the engines' import paths.
|
|
114
|
+
The code runs in [LocalMind](https://github.com/NakliTechie/LocalMind); `src/` and `engines/` are its copies.
|
|
115
115
|
|
|
116
116
|
## License
|
|
117
117
|
|
|
@@ -1119,7 +1119,7 @@ export class Gemma4MoeSsd extends Qwen3MoeSsd {
|
|
|
1119
1119
|
async prefillChunk(ids, want = 'none') {
|
|
1120
1120
|
const c = this.cfg, dev = this.device, K = c.topK, H = c.hidden, T = ids.length, pos0 = this.position, S = pos0 + T;
|
|
1121
1121
|
if (T > this.chunkTokens) throw new Error(`chunk of ${T} > ${this.chunkTokens}`);
|
|
1122
|
-
if (S > this.maxCtx) throw
|
|
1122
|
+
if (S > this.maxCtx) throw this.contextFull();
|
|
1123
1123
|
const tStart = performance.now();
|
|
1124
1124
|
dev.queue.writeBuffer(this.b.ids, 0, Uint32Array.from(ids));
|
|
1125
1125
|
dev.queue.writeBuffer(this.qb, 0, new Uint32Array([T, pos0, S, 0]));
|
|
@@ -964,7 +964,7 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
964
964
|
async prefillChunk(ids, want = 'none') {
|
|
965
965
|
const c = this.cfg, dev = this.device, K = c.topK, H = c.hidden, T = ids.length, pos0 = this.position, S = pos0 + T;
|
|
966
966
|
if (T > this.chunkTokens) throw new Error(`chunk of ${T} > ${this.chunkTokens}`);
|
|
967
|
-
if (S > this.maxCtx) throw
|
|
967
|
+
if (S > this.maxCtx) throw this.contextFull();
|
|
968
968
|
const tStart = performance.now();
|
|
969
969
|
dev.queue.writeBuffer(this.a.stop, 0, new Uint32Array(4)); // the head below uses guarded kernels
|
|
970
970
|
dev.queue.writeBuffer(this.b.ids, 0, Uint32Array.from(ids));
|
|
@@ -1099,7 +1099,7 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
1099
1099
|
async step(token, want = 'argmax') {
|
|
1100
1100
|
if (!this.gpuRouting) return super.step(token, want);
|
|
1101
1101
|
const c = this.cfg, dev = this.device, K = c.topK, L = c.layers, seg = this.seg;
|
|
1102
|
-
if (this.position >= this.maxCtx) throw
|
|
1102
|
+
if (this.position >= this.maxCtx) throw this.contextFull();
|
|
1103
1103
|
const tStart = performance.now();
|
|
1104
1104
|
const pos = this.position, seqLen = pos + 1;
|
|
1105
1105
|
this.writeTokenUniforms(token, pos);
|
package/engines/qwen3_moe_ssd.js
CHANGED
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
* so the engine computes the same dot products llama.cpp does, up to summation order.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { OpfsReaderPool
|
|
23
|
+
import { OpfsReaderPool } from '../src/opfs-reader.js';
|
|
24
24
|
import { ExpertStreamer } from '../src/expert-stream.js';
|
|
25
25
|
// The store layer is diskformer.js's (github.com/NakliTechie/diskformer.js), byte-identical copies: gguf.js, ingest.js.
|
|
26
26
|
import { GGML, Q8_BLOCK, Q4_BLOCK, Q6K_BLOCK, parseGguf, tensorBytes } from '../src/gguf.js';
|
|
27
|
-
import { ingestGguf as ingestStore, ingestProgress, fileFetch, storeKey, removeStore } from '../src/ingest.js';
|
|
27
|
+
import { ingestGguf as ingestStore, ingestProgress, fileFetch, storeKey, readStore, removeStore } from '../src/ingest.js';
|
|
28
|
+
import { DiskformerError, isDiskformerError, codedError } from '../src/errors.js';
|
|
28
29
|
export { GGML, Q4_BLOCK, Q6K_BLOCK, parseGguf, tensorBytes, splitQ4 } from '../src/gguf.js';
|
|
29
30
|
export { ingestProgress } from '../src/ingest.js';
|
|
30
31
|
|
|
@@ -541,7 +542,13 @@ export class Qwen3MoeSsd {
|
|
|
541
542
|
static get ingestPlan() { return undefined; }
|
|
542
543
|
static tokenizerFrom(kv) { return new BpeTokenizer(kv); }
|
|
543
544
|
|
|
545
|
+
// Errors are coded (errors.js): UNSUPPORTED_DEVICE, BUDGET_TOO_SMALL, LOAD_ABORTED when opts.signal aborted, else
|
|
546
|
+
// LOAD_FAILED with the original as `cause`. A failed load destroys the GPU device it opened.
|
|
544
547
|
static async load(modelId = QWEN3_30B_A3B.repo, opts = {}) {
|
|
548
|
+
try { return await this.open(modelId, opts); } catch (e) { throw codedError(e, { signal: opts.signal }); }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
static async open(modelId, opts) {
|
|
545
552
|
const { onProgress = () => {}, signal, localFile } = opts;
|
|
546
553
|
const src = opts.source || (modelId === QWEN3_30B_A3B.repo || !modelId ? QWEN3_30B_A3B : { repo: modelId, file: opts.file, revision: opts.revision || 'main' });
|
|
547
554
|
// localFile: the same GGUF already on disk (a File the user picked), ingested instead of downloaded. A size
|
|
@@ -556,34 +563,39 @@ export class Qwen3MoeSsd {
|
|
|
556
563
|
const dir = `${root}/${key}`;
|
|
557
564
|
|
|
558
565
|
onProgress({ status: 'init' });
|
|
559
|
-
|
|
566
|
+
const unsupported = (m) => new DiskformerError('UNSUPPORTED_DEVICE', m);
|
|
567
|
+
if (typeof navigator === 'undefined' || !navigator.gpu) throw unsupported('WebGPU is not available');
|
|
560
568
|
const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
|
|
561
|
-
if (!adapter) throw
|
|
562
|
-
if (!adapter.features.has('shader-f16')) throw
|
|
569
|
+
if (!adapter) throw unsupported('no WebGPU adapter');
|
|
570
|
+
if (!adapter.features.has('shader-f16')) throw unsupported('this GPU lacks shader-f16');
|
|
563
571
|
const L = adapter.limits;
|
|
564
|
-
// subgroups: optional; an engine may use subgroup kernels when the adapter offers them.
|
|
565
|
-
const features = ['shader-f16', ...
|
|
572
|
+
// subgroups, subgroup matrices: optional; an engine may use subgroup (matrix) kernels when the adapter offers them.
|
|
573
|
+
const features = ['shader-f16', ...['subgroups', 'chromium-experimental-subgroup-matrix'].filter((f) => adapter.features.has(f))];
|
|
566
574
|
const device = await adapter.requestDevice({
|
|
567
575
|
requiredFeatures: features,
|
|
568
|
-
requiredLimits: { maxBufferSize: L.maxBufferSize, maxStorageBufferBindingSize: L.maxStorageBufferBindingSize, maxStorageBuffersPerShaderStage: L.maxStorageBuffersPerShaderStage, maxComputeWorkgroupsPerDimension: L.maxComputeWorkgroupsPerDimension },
|
|
576
|
+
requiredLimits: { maxBufferSize: L.maxBufferSize, maxStorageBufferBindingSize: L.maxStorageBufferBindingSize, maxStorageBuffersPerShaderStage: L.maxStorageBuffersPerShaderStage, maxComputeWorkgroupsPerDimension: L.maxComputeWorkgroupsPerDimension, maxComputeWorkgroupStorageSize: L.maxComputeWorkgroupStorageSize },
|
|
569
577
|
});
|
|
570
578
|
device.lost.then((info) => console.warn('qwen3-moe-ssd: GPU device lost:', info.message));
|
|
571
579
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
580
|
+
try {
|
|
581
|
+
// Only a complete store with every file at full size is used; a partial one resumes, an invalid one (zero-byte
|
|
582
|
+
// or short files left by a crash) is ingested again.
|
|
583
|
+
const store = await readStore(key, { root, format: FORMAT });
|
|
584
|
+
let manifest = store.state === 'complete' ? store.manifest : null;
|
|
585
|
+
if (opts.reingest || !manifest) {
|
|
586
|
+
manifest = await ingestGguf({
|
|
587
|
+
url, key, root, fetch: fetchFn, signal, source: { repo: src.repo, file: src.file, revision: src.revision, sha256: src.sha256, size: src.size, ...(localFile ? { localFile: localFile.name } : {}) },
|
|
588
|
+
onProgress: (e) => onProgress(ingestProgress(e)),
|
|
589
|
+
plan: this.ingestPlan,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
const headerFile = await (await (await navigator.storage.getDirectory()).getDirectoryHandle(root)).getDirectoryHandle(key);
|
|
593
|
+
const headerBytes = new Uint8Array(await (await (await headerFile.getFileHandle('header.bin')).getFile()).arrayBuffer());
|
|
594
|
+
const gguf = parseGguf(headerBytes);
|
|
595
|
+
const engine = new this(device, manifest, gguf, { ...opts, dir, key, url, adapterInfo: adapter.info || {} });
|
|
596
|
+
await engine.init(onProgress);
|
|
597
|
+
return engine;
|
|
598
|
+
} catch (e) { try { device.destroy(); } catch (_) {} throw e; }
|
|
587
599
|
}
|
|
588
600
|
|
|
589
601
|
constructor(device, manifest, gguf, opts) {
|
|
@@ -697,7 +709,7 @@ export class Qwen3MoeSsd {
|
|
|
697
709
|
const fixed = this.gpuBytes.dense + this.kvBytes() + (o.workBytes ?? 512 * 2 ** 20);
|
|
698
710
|
const rest = o.gpuBudgetBytes - fixed;
|
|
699
711
|
const min = this.cfg.topK * 2 * this.manifest.experts.record;
|
|
700
|
-
if (rest < min) throw new
|
|
712
|
+
if (rest < min) throw new DiskformerError('BUDGET_TOO_SMALL', `GPU budget ${gb(o.gpuBudgetBytes)} is too small: weights that stay on the GPU, the KV cache and working buffers need ${gb(fixed)}, and the expert cache at least ${gb(min)} more`, { detail: { budget: o.gpuBudgetBytes, fixed, minCache: min } });
|
|
701
713
|
return rest;
|
|
702
714
|
}
|
|
703
715
|
// KV cache bytes at this.maxCtx (f16 K and V for every layer); engines with other layouts override it.
|
|
@@ -881,7 +893,7 @@ export class Qwen3MoeSsd {
|
|
|
881
893
|
// One token through all layers at position this.position. want: 'none' | 'argmax' | 'logits'.
|
|
882
894
|
async step(token, want = 'argmax') {
|
|
883
895
|
const c = this.cfg, dev = this.device, K = c.topK;
|
|
884
|
-
if (this.position >= this.maxCtx) throw
|
|
896
|
+
if (this.position >= this.maxCtx) throw this.contextFull();
|
|
885
897
|
const tStart = performance.now();
|
|
886
898
|
const pos = this.position, seqLen = pos + 1;
|
|
887
899
|
this.writeTokenUniforms(token, pos);
|
|
@@ -960,7 +972,11 @@ export class Qwen3MoeSsd {
|
|
|
960
972
|
|
|
961
973
|
// Diagnostics for the llama.cpp comparison: greedy continuation of raw token ids, with the
|
|
962
974
|
// top-n logits at every generated position.
|
|
963
|
-
async greedy(ids, n,
|
|
975
|
+
async greedy(ids, n, opts) {
|
|
976
|
+
const release = await this.acquire();
|
|
977
|
+
try { this.inflight = this.greedyTurn(ids, n, opts); return await this.inflight; } catch (e) { throw this.stepError(e); } finally { this.inflight = null; release(); }
|
|
978
|
+
}
|
|
979
|
+
async greedyTurn(ids, n, { top = 5 } = {}) {
|
|
964
980
|
this.reset();
|
|
965
981
|
const out = [], tops = [];
|
|
966
982
|
let logits = await this.prefill(ids, 'logits');
|
|
@@ -979,7 +995,45 @@ export class Qwen3MoeSsd {
|
|
|
979
995
|
return { ids: out, tops };
|
|
980
996
|
}
|
|
981
997
|
|
|
982
|
-
|
|
998
|
+
// generate() and greedy() share one KV cache, so calls on one engine run one at a time. A generate() takes its turn
|
|
999
|
+
// on its first next() (an async generator runs nothing before that), so turns follow iteration order. A caller
|
|
1000
|
+
// that stops iterating early must end the loop (break / return()) so the next call can start. Errors are coded:
|
|
1001
|
+
// EXECUTION_FAILED (original as `cause`), CONTEXT_FULL, RESOURCE_DISPOSED after dispose().
|
|
1002
|
+
async *generate(messages, opts) {
|
|
1003
|
+
const release = await this.acquire();
|
|
1004
|
+
const it = this.generateTurn(messages, opts);
|
|
1005
|
+
try {
|
|
1006
|
+
for (;;) {
|
|
1007
|
+
if (this.disposed) throw this.disposedError();
|
|
1008
|
+
this.inflight = it.next();
|
|
1009
|
+
const r = await this.inflight;
|
|
1010
|
+
this.inflight = null;
|
|
1011
|
+
if (r.done) return;
|
|
1012
|
+
yield r.value;
|
|
1013
|
+
}
|
|
1014
|
+
} catch (e) { throw this.stepError(e); } finally { this.inflight = null; await it.return(); release(); }
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// The turn lock: resolves with a release function once every earlier call has finished.
|
|
1018
|
+
async acquire() {
|
|
1019
|
+
if (this.disposed) throw this.disposedError();
|
|
1020
|
+
const prev = this.turn;
|
|
1021
|
+
let release;
|
|
1022
|
+
this.turn = new Promise((r) => { release = r; });
|
|
1023
|
+
await prev;
|
|
1024
|
+
if (this.disposed) { release(); throw this.disposedError(); }
|
|
1025
|
+
return release;
|
|
1026
|
+
}
|
|
1027
|
+
disposedError() { return new DiskformerError('RESOURCE_DISPOSED', 'this engine was disposed'); }
|
|
1028
|
+
contextFull() { return new DiskformerError('CONTEXT_FULL', `context full (${this.maxCtx} tokens)`, { detail: { maxCtx: this.maxCtx } }); }
|
|
1029
|
+
stepError(e) { return isDiskformerError(e) ? e : new DiskformerError('EXECUTION_FAILED', `generate failed: ${(e && e.message) || e}`, { cause: e }); }
|
|
1030
|
+
// Marks the engine disposed and waits for a step in flight, so the device is never destroyed under a running step.
|
|
1031
|
+
async retire() {
|
|
1032
|
+
this.disposed = true;
|
|
1033
|
+
if (this.inflight) await this.inflight.catch(() => {});
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async *generateTurn(messages, { maxNewTokens = 512, signal, enableThinking = true } = {}) {
|
|
983
1037
|
const ids = this.tokenizer.encode(this.chatPrompt(messages, { enableThinking }));
|
|
984
1038
|
const stops = new Set(this.stopTokenIds);
|
|
985
1039
|
let next = await this.prefill(ids, 'argmax');
|
|
@@ -1019,6 +1073,7 @@ export class Qwen3MoeSsd {
|
|
|
1019
1073
|
async warmup() {}
|
|
1020
1074
|
|
|
1021
1075
|
async dispose() {
|
|
1076
|
+
await this.retire();
|
|
1022
1077
|
try { await this.xs.drain(); } catch (_) {}
|
|
1023
1078
|
try { await this.expertReader.close(); } catch (_) {}
|
|
1024
1079
|
try { this.device.destroy(); } catch (_) {}
|
package/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// diskformer — a disk tier for in-browser inference. Keep model weights in OPFS and page into WebGPU only
|
|
2
2
|
// what each step needs. Plain ESM, no dependencies, no build step.
|
|
3
|
-
export { OpfsReaderPool, OpfsWriter, canInline, opfsDir, readOpfsText, writeOpfsText, removeOpfs } from './src/opfs-reader.js';
|
|
3
|
+
export { OpfsReaderPool, OpfsWriter, canInline, opfsDir, readOpfsText, writeOpfsText, removeOpfs, opfsFileSize, opfsSubdirs } from './src/opfs-reader.js';
|
|
4
4
|
export { ExpertStreamer, ExpertStreamer as RecordPool } from './src/expert-stream.js';
|
|
5
5
|
export { RowFile, RowCache, fingerprint } from './src/rows.js';
|
|
6
6
|
export { parseGguf, tensorBytes, splitQ8, splitQ4, GGML, Q8_BLOCK, Q4_BLOCK, Q6K_BLOCK } from './src/gguf.js';
|
|
7
|
-
export { ingestGguf, ingestProgress, readHeader, fileFetch, storeKey, removeStore } from './src/ingest.js';
|
|
7
|
+
export { ingestGguf, ingestProgress, readHeader, fileFetch, storeKey, readStore, listStores, removeStore } from './src/ingest.js';
|
|
8
|
+
export { DiskformerError, isDiskformerError, ERROR_CODES } from './src/errors.js';
|
|
8
9
|
export { measure } from './src/measure.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "diskformer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Run a model larger than your GPU memory in a browser tab: weights stay in OPFS, WebGPU holds only what each step needs. A disk tier plus two reference MoE engines.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/* errors.js — coded errors for the store, ingest and the engines. A host branches on `code`, never on the
|
|
2
|
+
* message text and never on `instanceof` (two copies of this module, e.g. a CDN copy and a vendored one, make
|
|
3
|
+
* instanceof false for the same error). Check with isDiskformerError(e, code).
|
|
4
|
+
*
|
|
5
|
+
* try { await Engine.load(…) } catch (e) { if (isDiskformerError(e, 'LOAD_ABORTED')) … }
|
|
6
|
+
*
|
|
7
|
+
* Codes:
|
|
8
|
+
* LOAD_FAILED ingest or load failed (HTTP, quota, a GGUF the engine cannot run); `cause` holds the original
|
|
9
|
+
* LOAD_ABORTED the caller's signal aborted a load or ingest; decided from `signal.aborted`, not the error name
|
|
10
|
+
* UNSUPPORTED_DEVICE no WebGPU, no adapter, or a missing required feature (shader-f16)
|
|
11
|
+
* BUDGET_TOO_SMALL gpuBudgetBytes cannot hold the fixed GPU part plus the smallest expert cache; `detail` has the bytes
|
|
12
|
+
* CONTEXT_FULL the conversation outgrew the engine's context
|
|
13
|
+
* EXECUTION_FAILED a prefill or decode step failed; `cause` holds the original
|
|
14
|
+
* RESOURCE_DISPOSED the engine was disposed: no further generate()
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const ERROR_CODES = Object.freeze(['LOAD_FAILED', 'LOAD_ABORTED', 'UNSUPPORTED_DEVICE', 'BUDGET_TOO_SMALL', 'CONTEXT_FULL', 'EXECUTION_FAILED', 'RESOURCE_DISPOSED']);
|
|
18
|
+
|
|
19
|
+
export class DiskformerError extends Error {
|
|
20
|
+
constructor(code, message, { cause, detail } = {}) {
|
|
21
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
22
|
+
this.name = 'DiskformerError';
|
|
23
|
+
this.code = code;
|
|
24
|
+
if (detail !== undefined) this.detail = detail;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const isDiskformerError = (e, code) => !!e && e.name === 'DiskformerError' && typeof e.code === 'string' && (code === undefined || e.code === code);
|
|
29
|
+
|
|
30
|
+
// Re-code an error from a load or ingest: an aborted signal wins over whatever the abort threw; a coded error
|
|
31
|
+
// passes through; anything else becomes `fallback` with the original as `cause`.
|
|
32
|
+
export function codedError(e, { signal, fallback = 'LOAD_FAILED', what = 'load' } = {}) {
|
|
33
|
+
if (signal && signal.aborted) return isDiskformerError(e, 'LOAD_ABORTED') ? e : new DiskformerError('LOAD_ABORTED', `${what} aborted`, { cause: e });
|
|
34
|
+
if (isDiskformerError(e)) return e;
|
|
35
|
+
return new DiskformerError(fallback, `${what} failed: ${(e && e.message) || e}`, { cause: e });
|
|
36
|
+
}
|
package/src/ingest.js
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* // OPFS: <root>/<key>/{header.bin, dense.bin, experts.bin, manifest.json}
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { OpfsWriter, readOpfsText, writeOpfsText, removeOpfs } from './opfs-reader.js';
|
|
14
|
+
import { OpfsWriter, readOpfsText, writeOpfsText, removeOpfs, opfsFileSize, opfsSubdirs } from './opfs-reader.js';
|
|
15
15
|
import { parseGguf, splitQ8, splitQ4, Q8_BLOCK, Q4_BLOCK } from './gguf.js';
|
|
16
|
+
import { codedError } from './errors.js';
|
|
16
17
|
|
|
17
18
|
export const ROOT = 'diskformer';
|
|
18
19
|
export const FORMAT = 'diskformer-gguf/1';
|
|
@@ -59,7 +60,12 @@ const textId = (s) => fnv((i) => s.charCodeAt(i), s.length);
|
|
|
59
60
|
// An interrupted ingest resumes only when the GGUF header, the plan's output layout, and the source's size and ETag
|
|
60
61
|
// (when the server sends them) match its checkpoint; otherwise it starts over. `rangeBytes` and `io` are for tests:
|
|
61
62
|
// smaller range requests, and fake OPFS files.
|
|
62
|
-
|
|
63
|
+
// Errors are coded (errors.js): LOAD_ABORTED when `signal` aborted, else LOAD_FAILED with the original as `cause`.
|
|
64
|
+
export async function ingestGguf(opts) {
|
|
65
|
+
try { return await ingest(opts); } catch (e) { throw codedError(e, { signal: opts && opts.signal, what: 'ingest' }); }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function ingest({ url, key, plan, root = ROOT, format = FORMAT, fetch: fetchFn = fetch, onProgress = () => {}, signal, source = {},
|
|
63
69
|
rangeBytes = 1 << 30, io = { OpfsWriter, readOpfsText, writeOpfsText } }) {
|
|
64
70
|
if (!plan || !plan.layout || !plan.units) throw new Error('ingestGguf: plan { layout, units } is required');
|
|
65
71
|
const t0 = performance.now();
|
|
@@ -188,4 +194,41 @@ export function ingestProgress(e) {
|
|
|
188
194
|
return e && e.status === 'ingest' ? { ...e, status: 'weights', loaded: e.loaded, total: e.total } : e;
|
|
189
195
|
}
|
|
190
196
|
|
|
197
|
+
// ── The stores on disk: which are usable, which to remove ──
|
|
198
|
+
// A store is usable only when its manifest is complete, in `format`, and every file it names is on disk at its full
|
|
199
|
+
// size. A crash can leave zero-byte or short files behind a complete manifest; those count as invalid, never as a
|
|
200
|
+
// model. Dense files end with up to 255 bytes of alignment padding that is never written, hence the slack.
|
|
201
|
+
// `format` null skips the format check. → { key, state: 'complete' | 'partial' | 'invalid' | 'missing', manifest, bytes, problem }
|
|
202
|
+
const PAD = 255;
|
|
203
|
+
export async function readStore(key, { root = ROOT, format = FORMAT, io = { readOpfsText, opfsFileSize } } = {}) {
|
|
204
|
+
const dir = `${root}/${key}`;
|
|
205
|
+
let manifest = null;
|
|
206
|
+
try { manifest = JSON.parse(await io.readOpfsText(`${dir}/manifest.json`) || 'null'); } catch (_) { manifest = null; }
|
|
207
|
+
// [file, expected bytes, slack]: the header, then every layout entry the manifest names ({ file, bytes }).
|
|
208
|
+
const files = manifest
|
|
209
|
+
? [['header.bin', manifest.source && manifest.source.dataStart, 0],
|
|
210
|
+
...Object.values(manifest).filter((v) => v && typeof v.file === 'string' && typeof v.bytes === 'number').map((v) => [v.file, v.bytes, v.file === 'dense.bin' ? PAD : 0])]
|
|
211
|
+
: [['header.bin'], ['dense.bin'], ['experts.bin']];
|
|
212
|
+
const sizes = await Promise.all(files.map(([name]) => io.opfsFileSize(`${dir}/${name}`)));
|
|
213
|
+
const bytes = sizes.reduce((a, b) => a + (b || 0), 0);
|
|
214
|
+
const out = (state, problem) => ({ key, state, manifest, bytes, problem });
|
|
215
|
+
if (!manifest) return out(bytes ? 'invalid' : 'missing', bytes ? 'no readable manifest' : null);
|
|
216
|
+
if (!manifest.complete) return out('partial', 'ingest not finished');
|
|
217
|
+
if (format && manifest.format !== format) return out('invalid', `format ${manifest.format}, expected ${format}`);
|
|
218
|
+
for (let i = 0; i < files.length; i++) {
|
|
219
|
+
const [name, want, slack] = files[i], have = sizes[i];
|
|
220
|
+
if (want === 0 && have !== null) continue; // a layout entry with no bytes (a dense model's experts.bin)
|
|
221
|
+
if (!have) return out('invalid', `${name} is ${have === null ? 'missing' : 'empty'}`);
|
|
222
|
+
if (typeof want === 'number' && have < want - slack) return out('invalid', `${name} is ${have} bytes, expected ${want}`);
|
|
223
|
+
}
|
|
224
|
+
return out('complete', null);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Every store under `root`, for a "models in this browser" list where the user removes one and keeps the rest.
|
|
228
|
+
// `format` null accepts any format (engines write their own; the chat page lists them all).
|
|
229
|
+
export async function listStores({ root = ROOT, format = null, io = { readOpfsText, opfsFileSize, opfsSubdirs } } = {}) {
|
|
230
|
+
return Promise.all((await io.opfsSubdirs(root)).map((key) => readStore(key, { root, format, io })));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Removes one store by key; the others under `root` stay.
|
|
191
234
|
export async function removeStore(key, { root = ROOT } = {}) { await removeOpfs(`${root}/${key}`); }
|
package/src/opfs-reader.js
CHANGED
|
@@ -256,3 +256,21 @@ export async function removeOpfs(path) {
|
|
|
256
256
|
const dir = await opfsDir(parts.slice(0, -1).join('/'));
|
|
257
257
|
await dir.removeEntry(parts[parts.length - 1], { recursive: true });
|
|
258
258
|
}
|
|
259
|
+
|
|
260
|
+
// Size in bytes of an OPFS file, or null when it does not exist.
|
|
261
|
+
export async function opfsFileSize(path) {
|
|
262
|
+
const parts = path.split('/').filter(Boolean);
|
|
263
|
+
try {
|
|
264
|
+
const dir = await opfsDir(parts.slice(0, -1).join('/'));
|
|
265
|
+
return (await (await dir.getFileHandle(parts[parts.length - 1])).getFile()).size;
|
|
266
|
+
} catch (_) { return null; }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Names of the directories directly under an OPFS path; [] when the path does not exist.
|
|
270
|
+
export async function opfsSubdirs(path) {
|
|
271
|
+
let dir;
|
|
272
|
+
try { dir = await opfsDir(path); } catch (_) { return []; }
|
|
273
|
+
const out = [];
|
|
274
|
+
for await (const [name, h] of dir.entries()) if (h.kind === 'directory') out.push(name);
|
|
275
|
+
return out.sort();
|
|
276
|
+
}
|