diskformer 0.0.1 → 0.3.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 +33 -0
- package/README.md +99 -23
- package/engines/LICENSE-APACHE +201 -0
- package/engines/NOTICE +17 -0
- package/engines/gemma4_moe_ssd.js +1291 -0
- package/engines/qwen35_moe_ssd.js +1231 -0
- package/engines/qwen3_moe_ssd.js +1041 -0
- package/index.js +8 -0
- package/package.json +40 -6
- package/src/expert-stream.js +265 -0
- package/src/gguf.js +96 -0
- package/src/ingest.js +191 -0
- package/src/measure.js +61 -0
- package/src/opfs-reader.js +258 -0
- package/src/rows.js +230 -0
package/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// diskformer — a disk tier for in-browser inference. Keep model weights in OPFS and page into WebGPU only
|
|
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';
|
|
4
|
+
export { ExpertStreamer, ExpertStreamer as RecordPool } from './src/expert-stream.js';
|
|
5
|
+
export { RowFile, RowCache, fingerprint } from './src/rows.js';
|
|
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';
|
|
8
|
+
export { measure } from './src/measure.js';
|
package/package.json
CHANGED
|
@@ -1,13 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "diskformer",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
7
7
|
"author": "Chirag Patnaik",
|
|
8
8
|
"homepage": "https://github.com/NakliTechie/diskformer.js#readme",
|
|
9
|
-
"repository": {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/NakliTechie/diskformer.js.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/NakliTechie/diskformer.js/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"webgpu",
|
|
18
|
+
"opfs",
|
|
19
|
+
"llm",
|
|
20
|
+
"inference",
|
|
21
|
+
"offload",
|
|
22
|
+
"moe",
|
|
23
|
+
"browser",
|
|
24
|
+
"transformers"
|
|
25
|
+
],
|
|
26
|
+
"files": [
|
|
27
|
+
"index.js",
|
|
28
|
+
"src/",
|
|
29
|
+
"engines/",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE",
|
|
32
|
+
"CHANGELOG.md"
|
|
33
|
+
],
|
|
34
|
+
"main": "index.js",
|
|
35
|
+
"exports": {
|
|
36
|
+
".": "./index.js",
|
|
37
|
+
"./engines/*": "./engines/*"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "node test/run.mjs",
|
|
41
|
+
"demo": "node examples/run-demo.mjs",
|
|
42
|
+
"sync": "node scripts/sync-localmind.mjs"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20"
|
|
46
|
+
}
|
|
13
47
|
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/* moe-expert-stream.js — keep a mixture-of-experts model's routed experts on SSD (OPFS) and
|
|
2
|
+
* page them into a fixed GPU slot pool on demand. Model-agnostic: an engine describes one
|
|
3
|
+
* expert record (its byte layout in the OPFS file and which GPU pool buffer each part goes
|
|
4
|
+
* to); this module owns the slot LRU, the reads, the uploads, the pins and the counters.
|
|
5
|
+
*
|
|
6
|
+
* const xs = new ExpertStreamer({ device, reader, recordBytes, recordOffset, parts, slots,
|
|
7
|
+
* numLayers, numExperts });
|
|
8
|
+
* const slots = await xs.ensure(layer, expertIds); // resident + pinned, in id order
|
|
9
|
+
* …write `slots` into the kernel's slot table, encode, queue.submit()…
|
|
10
|
+
* xs.release(slots); // after the submit
|
|
11
|
+
* xs.prefetch(layer + 1, predictedIds); // optional, never blocks
|
|
12
|
+
*
|
|
13
|
+
* `parts`: [{ buffer: GPUBuffer, srcOffset, bytes }]. Slot s of a part lives at s × bytes in
|
|
14
|
+
* that part's buffer, so a kernel finds expert row r of slot s at s × rowsPerExpert + r.
|
|
15
|
+
*
|
|
16
|
+
* Safety of reuse: an upload (a staging-ring copy, or queue.writeBuffer) is ordered after every
|
|
17
|
+
* earlier queue.submit, so a slot may be overwritten as soon as the work that reads it has been
|
|
18
|
+
* submitted, even if the GPU has not run it yet. Until then the slot is pinned and never chosen
|
|
19
|
+
* for eviction.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export class ExpertStreamer {
|
|
23
|
+
constructor({ device, reader, recordBytes, recordOffset, parts, slots, numLayers, numExperts, maxStaging = 32, uploadRing = 16, evict = 'lru', hotHalfLife = 8, maxPrefetchInflight = 8 }) {
|
|
24
|
+
Object.assign(this, { device, reader, recordBytes, recordOffset, parts, slots, numLayers, numExperts, maxStaging, evict, maxPrefetchInflight });
|
|
25
|
+
// Prefetches wait in pfQueue (nearest layer first) and at most maxPrefetchInflight of them
|
|
26
|
+
// read at once, so a demand read never queues behind a long run of guesses in the workers.
|
|
27
|
+
this.pfQueue = [];
|
|
28
|
+
this.pfInflight = 0;
|
|
29
|
+
// evict 'hot': drop the unpinned expert with the lowest decaying route count, oldest first
|
|
30
|
+
// on ties (llama.cpp PR #25294's policy). One tick per ensure() call, so the half-life is in
|
|
31
|
+
// tokens × layers. 'lru' (default): plain least recently used.
|
|
32
|
+
this.heat = new Float32Array(numLayers * numExperts);
|
|
33
|
+
this.heatTick = new Float64Array(numLayers * numExperts);
|
|
34
|
+
this.tick = 0;
|
|
35
|
+
this.decayPerTick = Math.pow(0.5, 1 / Math.max(1, hotHalfLife * numLayers));
|
|
36
|
+
// Uploads go through a ring of mapped MAP_WRITE buffers + copyBufferToBuffer: measured
|
|
37
|
+
// 2026-10-04 on an M4 Pro (Chrome 154) at 27.5 GB/s for 5 MB records with depth 4, against
|
|
38
|
+
// 1.88 GB/s for queue.writeBuffer. uploadRing = 0 keeps writeBuffer (A/B measurement only).
|
|
39
|
+
this.ring = [];
|
|
40
|
+
for (let i = 0; i < uploadRing; i++) {
|
|
41
|
+
this.ring.push({ buf: device.createBuffer({ size: Math.ceil(recordBytes / 4) * 4, usage: 0x02 | 0x04, mappedAtCreation: true }), ready: null });
|
|
42
|
+
}
|
|
43
|
+
this.ringNext = 0;
|
|
44
|
+
this.slotKey = new Int32Array(slots).fill(-1);
|
|
45
|
+
this.pinCount = new Int32Array(slots);
|
|
46
|
+
this.free = [];
|
|
47
|
+
for (let s = slots - 1; s >= 0; s--) this.free.push(s);
|
|
48
|
+
this.lru = new Map(); // key → slot, least recently used first
|
|
49
|
+
this.inflight = new Map(); // key → { p: Promise<slot>, pins }
|
|
50
|
+
this.staging = [];
|
|
51
|
+
this.resetStats();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
resetStats() {
|
|
55
|
+
const L = this.numLayers;
|
|
56
|
+
this.stats = {
|
|
57
|
+
hits: 0, misses: 0, lateHits: 0, prefetchIssued: 0, prefetchUsed: 0, evictions: 0,
|
|
58
|
+
bytesRead: 0, readMs: 0, uploadMs: 0, waitMs: 0,
|
|
59
|
+
hitsByLayer: new Uint32Array(L), missesByLayer: new Uint32Array(L),
|
|
60
|
+
};
|
|
61
|
+
this.prefetched = new Set(); // keys loaded by prefetch() and not yet demanded
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
key(layer, expert) { return layer * this.numExperts + expert; }
|
|
65
|
+
resident() { return this.lru.size; }
|
|
66
|
+
|
|
67
|
+
pin(s) { this.pinCount[s]++; }
|
|
68
|
+
release(slots) { for (const s of slots) if (this.pinCount[s] > 0) this.pinCount[s]--; }
|
|
69
|
+
|
|
70
|
+
takeStaging() { return this.staging.pop() || new ArrayBuffer(this.recordBytes); }
|
|
71
|
+
giveStaging(buf) { if (this.staging.length < this.maxStaging && buf.byteLength >= this.recordBytes) this.staging.push(buf); }
|
|
72
|
+
|
|
73
|
+
heatOf(key) { return this.heat[key] * Math.pow(this.decayPerTick, this.tick - this.heatTick[key]); }
|
|
74
|
+
touch(key) { this.heat[key] = this.heatOf(key) + 1; this.heatTick[key] = this.tick; }
|
|
75
|
+
|
|
76
|
+
allocSlot() {
|
|
77
|
+
if (this.free.length) return this.free.pop();
|
|
78
|
+
if (this.evict === 'hot') {
|
|
79
|
+
let bestK = -1, bestS = -1, bestH = Infinity;
|
|
80
|
+
for (const [k, s] of this.lru) { // LRU order, so the first minimum is the oldest
|
|
81
|
+
if (this.pinCount[s] !== 0) continue;
|
|
82
|
+
const h = this.heatOf(k);
|
|
83
|
+
if (h < bestH) { bestH = h; bestK = k; bestS = s; }
|
|
84
|
+
}
|
|
85
|
+
if (bestS >= 0) {
|
|
86
|
+
this.lru.delete(bestK); this.prefetched.delete(bestK); this.slotKey[bestS] = -1; this.stats.evictions++;
|
|
87
|
+
return bestS;
|
|
88
|
+
}
|
|
89
|
+
throw new Error(`expert pool exhausted: all ${this.slots} slots pinned — raise the pool size`);
|
|
90
|
+
}
|
|
91
|
+
for (const [k, s] of this.lru) {
|
|
92
|
+
if (this.pinCount[s] === 0) {
|
|
93
|
+
this.lru.delete(k);
|
|
94
|
+
this.prefetched.delete(k);
|
|
95
|
+
this.slotKey[s] = -1;
|
|
96
|
+
this.stats.evictions++;
|
|
97
|
+
return s;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`expert pool exhausted: all ${this.slots} slots pinned — raise the pool size`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Starts the read + upload of one expert into a newly allocated slot. The slot stays pinned
|
|
104
|
+
// while the load is in flight; `entry.pins` demands registered meanwhile are converted into
|
|
105
|
+
// pins in the same synchronous step that publishes the slot, so no eviction can slip
|
|
106
|
+
// between "loaded" and "pinned by its user". Returns the in-flight entry { p, pins }.
|
|
107
|
+
load(layer, expert, pins = 0) {
|
|
108
|
+
const key = this.key(layer, expert);
|
|
109
|
+
const s = this.allocSlot();
|
|
110
|
+
this.pin(s);
|
|
111
|
+
const entry = { pins, p: null };
|
|
112
|
+
entry.p = (async () => {
|
|
113
|
+
let ok = false;
|
|
114
|
+
try {
|
|
115
|
+
const r = await this.reader.read(this.recordOffset(layer, expert), this.recordBytes, this.takeStaging());
|
|
116
|
+
if (r.got !== this.recordBytes) throw new Error(`short expert read L${layer} E${expert}: ${r.got} of ${this.recordBytes} bytes`);
|
|
117
|
+
this.stats.readMs += r.ms;
|
|
118
|
+
this.stats.bytesRead += r.got;
|
|
119
|
+
const t0 = performance.now();
|
|
120
|
+
await this.upload(r.buf, s);
|
|
121
|
+
this.stats.uploadMs += performance.now() - t0;
|
|
122
|
+
this.giveStaging(r.buf);
|
|
123
|
+
this.slotKey[s] = key;
|
|
124
|
+
this.lru.set(key, s);
|
|
125
|
+
this.pinCount[s] += entry.pins;
|
|
126
|
+
ok = true;
|
|
127
|
+
return s;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (err && err.buf) this.giveStaging(err.buf);
|
|
130
|
+
throw err;
|
|
131
|
+
} finally {
|
|
132
|
+
this.inflight.delete(key);
|
|
133
|
+
this.pinCount[s]--;
|
|
134
|
+
if (!ok) { this.pinCount[s] = 0; this.free.push(s); }
|
|
135
|
+
}
|
|
136
|
+
})();
|
|
137
|
+
this.inflight.set(key, entry);
|
|
138
|
+
return entry;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Copies one record into slot `s` of every part. Queue order makes this as safe as
|
|
142
|
+
// writeBuffer: the copy is submitted after the work that last read the slot and before the
|
|
143
|
+
// work that will read the new expert.
|
|
144
|
+
async upload(buf, s) {
|
|
145
|
+
if (!this.ring.length) {
|
|
146
|
+
for (const part of this.parts) this.device.queue.writeBuffer(part.buffer, s * part.bytes, buf, part.srcOffset, part.bytes);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const r = this.ring[this.ringNext];
|
|
150
|
+
this.ringNext = (this.ringNext + 1) % this.ring.length;
|
|
151
|
+
while (r.ready) { const p = r.ready; await p; if (r.ready === p) r.ready = null; }
|
|
152
|
+
new Uint8Array(r.buf.getMappedRange(0, r.buf.size)).set(new Uint8Array(buf, 0, this.recordBytes));
|
|
153
|
+
r.buf.unmap();
|
|
154
|
+
const enc = this.device.createCommandEncoder();
|
|
155
|
+
for (const part of this.parts) enc.copyBufferToBuffer(r.buf, part.srcOffset, part.buffer, s * part.bytes, part.bytes);
|
|
156
|
+
this.device.queue.submit([enc.finish()]);
|
|
157
|
+
r.ready = r.buf.mapAsync(2);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Makes every expert in `ids` resident for `layer` and pins its slot. Returns the slots in
|
|
161
|
+
// the same order as `ids`. The caller must release() them after submitting the GPU work.
|
|
162
|
+
async ensure(layer, ids) {
|
|
163
|
+
this.tick++;
|
|
164
|
+
if (this.pfQueue.length) this.pfQueue = this.pfQueue.filter((q) => q.layer > layer); // not sorted by layer once lookahead >= 3
|
|
165
|
+
const out = new Uint32Array(ids.length);
|
|
166
|
+
const waits = [];
|
|
167
|
+
const st = this.stats;
|
|
168
|
+
for (let i = 0; i < ids.length; i++) {
|
|
169
|
+
const key = this.key(layer, ids[i]);
|
|
170
|
+
this.touch(key);
|
|
171
|
+
if (this.prefetched.delete(key)) st.prefetchUsed++;
|
|
172
|
+
const s = this.lru.get(key);
|
|
173
|
+
if (s !== undefined) {
|
|
174
|
+
this.lru.delete(key); this.lru.set(key, s); // most recently used
|
|
175
|
+
this.pin(s);
|
|
176
|
+
out[i] = s;
|
|
177
|
+
st.hits++; st.hitsByLayer[layer]++;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const flying = this.inflight.get(key);
|
|
181
|
+
if (flying) {
|
|
182
|
+
st.lateHits++; st.hitsByLayer[layer]++;
|
|
183
|
+
flying.pins++;
|
|
184
|
+
waits.push(flying.p.then((slot) => { out[i] = slot; }));
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
st.misses++; st.missesByLayer[layer]++;
|
|
188
|
+
waits.push(this.load(layer, ids[i], 1).p.then((slot) => { out[i] = slot; }));
|
|
189
|
+
}
|
|
190
|
+
if (waits.length) {
|
|
191
|
+
const t0 = performance.now();
|
|
192
|
+
await Promise.all(waits);
|
|
193
|
+
st.waitMs += performance.now() - t0;
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// For an engine that routes on the GPU against a residency map: `ids` of `layer` were found
|
|
199
|
+
// resident and used without an ensure(). Refreshes their recency and counts them as hits; an
|
|
200
|
+
// expert evicted since (its slot was reused after that work was submitted) is skipped.
|
|
201
|
+
touchUsed(layer, ids) {
|
|
202
|
+
this.tick++;
|
|
203
|
+
const st = this.stats;
|
|
204
|
+
for (const e of ids) {
|
|
205
|
+
const key = this.key(layer, e);
|
|
206
|
+
const s = this.lru.get(key);
|
|
207
|
+
if (s === undefined) continue;
|
|
208
|
+
this.touch(key);
|
|
209
|
+
if (this.prefetched.delete(key)) st.prefetchUsed++;
|
|
210
|
+
this.lru.delete(key); this.lru.set(key, s);
|
|
211
|
+
st.hits++; st.hitsByLayer[layer]++;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Queues loads for experts that are neither resident, in flight nor queued; never awaits.
|
|
216
|
+
// Call with the nearest layer first: the queue is served in order, and entries for a layer
|
|
217
|
+
// are dropped once that layer's demand (ensure) arrives. Returns how many loads it started.
|
|
218
|
+
prefetch(layer, ids) {
|
|
219
|
+
const before = this.stats.prefetchIssued;
|
|
220
|
+
for (const e of ids) {
|
|
221
|
+
const key = this.key(layer, e);
|
|
222
|
+
if (this.lru.has(key) || this.inflight.has(key) || this.pfQueue.some((q) => q.key === key)) continue;
|
|
223
|
+
this.pfQueue.push({ layer, e, key });
|
|
224
|
+
}
|
|
225
|
+
this.pump();
|
|
226
|
+
return this.stats.prefetchIssued - before;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Starts queued prefetches while fewer than maxPrefetchInflight are reading. Stops when the
|
|
230
|
+
// pool has nothing evictable left.
|
|
231
|
+
pump() {
|
|
232
|
+
while (this.pfInflight < this.maxPrefetchInflight && this.pfQueue.length) {
|
|
233
|
+
const { layer, e, key } = this.pfQueue.shift();
|
|
234
|
+
if (this.lru.has(key) || this.inflight.has(key)) continue;
|
|
235
|
+
let entry;
|
|
236
|
+
try { entry = this.load(layer, e); } catch (_) { this.pfQueue.length = 0; break; }
|
|
237
|
+
this.prefetched.add(key);
|
|
238
|
+
this.stats.prefetchIssued++;
|
|
239
|
+
this.pfInflight++;
|
|
240
|
+
entry.p.catch(() => {}).finally(() => { this.pfInflight--; this.pump(); });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Waits for every in-flight load and staging-ring map (before teardown and between runs).
|
|
245
|
+
async drain() {
|
|
246
|
+
await Promise.allSettled([...this.inflight.values()].map((f) => f.p));
|
|
247
|
+
await Promise.allSettled(this.ring.map((r) => r.ready).filter(Boolean));
|
|
248
|
+
}
|
|
249
|
+
ringBytes() { return this.ring.reduce((a, r) => a + r.buf.size, 0); }
|
|
250
|
+
|
|
251
|
+
// Empties the pool. Waits for loads still reading first: a load that finished after the
|
|
252
|
+
// reset would claim a slot that is back on the free list.
|
|
253
|
+
async clear() {
|
|
254
|
+
this.pfQueue = [];
|
|
255
|
+
await this.drain();
|
|
256
|
+
this.lru.clear();
|
|
257
|
+
this.inflight.clear();
|
|
258
|
+
this.prefetched.clear();
|
|
259
|
+
this.slotKey.fill(-1);
|
|
260
|
+
this.pinCount.fill(0);
|
|
261
|
+
this.free = [];
|
|
262
|
+
for (let s = this.slots - 1; s >= 0; s--) this.free.push(s);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
}
|
package/src/gguf.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/* gguf.js — read a GGUF header and split quantized blocks into GPU-friendly planes. No dependencies.
|
|
2
|
+
* Extracted unchanged from LocalMind's qwen3_moe_ssd.js (2026-10-05).
|
|
3
|
+
*
|
|
4
|
+
* const { kv, tensors, dataStart } = parseGguf(headerBytes); // throws { needBytes } if too short
|
|
5
|
+
* tensorBytes(tensor) // bytes of one tensor in the file
|
|
6
|
+
* splitQ8(src, q, s) / splitQ4(src, q, s) // blocks → value plane + f16-scale plane, bit-exact
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const GGML = { F32: 0, F16: 1, Q4_0: 2, Q8_0: 8, Q6_K: 14 };
|
|
10
|
+
export const Q8_BLOCK = 34; // f16 scale + 32 × int8
|
|
11
|
+
export const Q4_BLOCK = 18; // f16 scale + 32 × 4-bit (16 bytes)
|
|
12
|
+
export const Q6K_BLOCK = 210; // 256 values: ql[128] + qh[64] + scales[16] + f16 d
|
|
13
|
+
|
|
14
|
+
// Parses a GGUF header. Throws { needBytes } when `u8` stops before the header ends.
|
|
15
|
+
export function parseGguf(u8) {
|
|
16
|
+
const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
|
17
|
+
let p = 0;
|
|
18
|
+
const need = (n) => { if (p + n > u8.byteLength) { const e = new Error('GGUF header truncated'); e.needBytes = Math.max(u8.byteLength * 2, p + n + (1 << 20)); throw e; } };
|
|
19
|
+
const u32 = () => { need(4); const v = dv.getUint32(p, true); p += 4; return v; };
|
|
20
|
+
const u64 = () => { need(8); const v = Number(dv.getBigUint64(p, true)); p += 8; return v; };
|
|
21
|
+
// ignoreBOM: keep a leading U+FEFF (Gemma 4's vocab has '#', '//' and '<?' twice, once with a byte-order mark).
|
|
22
|
+
const dec = new TextDecoder('utf-8', { ignoreBOM: true });
|
|
23
|
+
const str = () => { const n = u64(); need(n); const s = dec.decode(u8.subarray(p, p + n)); p += n; return s; };
|
|
24
|
+
const scalar = {
|
|
25
|
+
0: () => { need(1); return dv.getUint8(p++); }, 1: () => { need(1); return dv.getInt8(p++); },
|
|
26
|
+
2: () => { need(2); const v = dv.getUint16(p, true); p += 2; return v; }, 3: () => { need(2); const v = dv.getInt16(p, true); p += 2; return v; },
|
|
27
|
+
4: u32, 5: () => { need(4); const v = dv.getInt32(p, true); p += 4; return v; },
|
|
28
|
+
6: () => { need(4); const v = dv.getFloat32(p, true); p += 4; return v; },
|
|
29
|
+
7: () => { need(1); return dv.getUint8(p++) !== 0; },
|
|
30
|
+
10: u64, 11: () => { need(8); const v = Number(dv.getBigInt64(p, true)); p += 8; return v; },
|
|
31
|
+
12: () => { need(8); const v = dv.getFloat64(p, true); p += 8; return v; },
|
|
32
|
+
};
|
|
33
|
+
const value = (t) => {
|
|
34
|
+
if (t === 8) return str();
|
|
35
|
+
if (t === 9) { const at = u32(), n = u64(); const a = new Array(n); for (let i = 0; i < n; i++) a[i] = value(at); return a; }
|
|
36
|
+
const f = scalar[t];
|
|
37
|
+
if (!f) throw new Error(`GGUF: unknown value type ${t}`);
|
|
38
|
+
return f();
|
|
39
|
+
};
|
|
40
|
+
need(4);
|
|
41
|
+
if (dec.decode(u8.subarray(0, 4)) !== 'GGUF') throw new Error('not a GGUF file');
|
|
42
|
+
p = 4;
|
|
43
|
+
const version = u32(), nTensors = u64(), nKv = u64();
|
|
44
|
+
const kv = {};
|
|
45
|
+
for (let i = 0; i < nKv; i++) { const k = str(); kv[k] = value(u32()); }
|
|
46
|
+
const tensors = [];
|
|
47
|
+
for (let i = 0; i < nTensors; i++) {
|
|
48
|
+
const name = str(), nd = u32(), dims = [];
|
|
49
|
+
for (let d = 0; d < nd; d++) dims.push(u64());
|
|
50
|
+
const type = u32(), offset = u64();
|
|
51
|
+
tensors.push({ name, dims, type, offset });
|
|
52
|
+
}
|
|
53
|
+
const align = kv['general.alignment'] || 32;
|
|
54
|
+
const dataStart = Math.ceil(p / align) * align;
|
|
55
|
+
return { version, kv, tensors, headerBytes: p, dataStart };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function tensorBytes(t) {
|
|
59
|
+
const n = t.dims.reduce((a, b) => a * b, 1);
|
|
60
|
+
if (t.type === GGML.F32) return n * 4;
|
|
61
|
+
if (t.type === GGML.F16) return n * 2;
|
|
62
|
+
if (t.type === GGML.Q8_0) return (n / 32) * Q8_BLOCK;
|
|
63
|
+
if (t.type === GGML.Q4_0) return (n / 32) * Q4_BLOCK;
|
|
64
|
+
if (t.type === GGML.Q6_K) return (n / 256) * Q6K_BLOCK;
|
|
65
|
+
throw new Error(`unsupported tensor type ${t.type} (${t.name})`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Q8_0 blocks → an int8 plane + an f16-scale plane, bit-exact. `src` holds whole blocks and
|
|
69
|
+
// starts at an even byte offset, so both planes copy as u16 lanes.
|
|
70
|
+
export function splitQ8(src, q, s) {
|
|
71
|
+
const nb = src.byteLength / Q8_BLOCK;
|
|
72
|
+
const a = new Uint16Array(src.buffer, src.byteOffset, nb * 17);
|
|
73
|
+
const qd = new Uint16Array(q.buffer, q.byteOffset, nb * 16);
|
|
74
|
+
const sd = new Uint16Array(s.buffer, s.byteOffset, nb);
|
|
75
|
+
for (let b = 0, i = 0, o = 0; b < nb; b++, i += 17, o += 16) {
|
|
76
|
+
sd[b] = a[i];
|
|
77
|
+
qd[o] = a[i + 1]; qd[o + 1] = a[i + 2]; qd[o + 2] = a[i + 3]; qd[o + 3] = a[i + 4];
|
|
78
|
+
qd[o + 4] = a[i + 5]; qd[o + 5] = a[i + 6]; qd[o + 6] = a[i + 7]; qd[o + 7] = a[i + 8];
|
|
79
|
+
qd[o + 8] = a[i + 9]; qd[o + 9] = a[i + 10]; qd[o + 10] = a[i + 11]; qd[o + 11] = a[i + 12];
|
|
80
|
+
qd[o + 12] = a[i + 13]; qd[o + 13] = a[i + 14]; qd[o + 14] = a[i + 15]; qd[o + 15] = a[i + 16];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Q4_0 blocks → a nibble plane (16 bytes per 32 values, ggml's order: low nibbles are values 0..15,
|
|
85
|
+
// high nibbles 16..31) + an f16-scale plane, bit-exact.
|
|
86
|
+
export function splitQ4(src, q, s) {
|
|
87
|
+
const nb = src.byteLength / Q4_BLOCK;
|
|
88
|
+
const a = new Uint16Array(src.buffer, src.byteOffset, nb * 9);
|
|
89
|
+
const qd = new Uint16Array(q.buffer, q.byteOffset, nb * 8);
|
|
90
|
+
const sd = new Uint16Array(s.buffer, s.byteOffset, nb);
|
|
91
|
+
for (let b = 0, i = 0, o = 0; b < nb; b++, i += 9, o += 8) {
|
|
92
|
+
sd[b] = a[i];
|
|
93
|
+
qd[o] = a[i + 1]; qd[o + 1] = a[i + 2]; qd[o + 2] = a[i + 3]; qd[o + 3] = a[i + 4];
|
|
94
|
+
qd[o + 4] = a[i + 5]; qd[o + 5] = a[i + 6]; qd[o + 6] = a[i + 7]; qd[o + 7] = a[i + 8];
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/ingest.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/* ingest.js — copy a GGUF from a URL into OPFS in an engine's own layout, streaming: HTTP Range requests of
|
|
2
|
+
* ≤1 GiB, each unit transformed and written while the next bytes arrive (at most 8 writes in flight). OPFS quota
|
|
3
|
+
* grows with what is written, so files grow by writes rather than one up-front truncate. The manifest is written
|
|
4
|
+
* incomplete first and complete last, so an interrupted ingest is never taken as valid. After each range request the
|
|
5
|
+
* manifest records the first unit not yet written; a later call with the same key and the same GGUF header resumes
|
|
6
|
+
* from there instead of from byte 0.
|
|
7
|
+
* Extracted from LocalMind's qwen3_moe_ssd.js (2026-10-05); `root` and `format` are new options (LocalMind
|
|
8
|
+
* passes 'localmind-ssd' and its own format string).
|
|
9
|
+
*
|
|
10
|
+
* const manifest = await ingestGguf({ url, key, plan: { layout, units }, onProgress });
|
|
11
|
+
* // OPFS: <root>/<key>/{header.bin, dense.bin, experts.bin, manifest.json}
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { OpfsWriter, readOpfsText, writeOpfsText, removeOpfs } from './opfs-reader.js';
|
|
15
|
+
import { parseGguf, splitQ8, splitQ4, Q8_BLOCK, Q4_BLOCK } from './gguf.js';
|
|
16
|
+
|
|
17
|
+
export const ROOT = 'diskformer';
|
|
18
|
+
export const FORMAT = 'diskformer-gguf/1';
|
|
19
|
+
|
|
20
|
+
// ── Ingest: GGUF (over HTTP Range, or a local file) → OPFS, in the engine layout ──
|
|
21
|
+
export const storeKey = (file) => file.replace(/\.gguf$/i, '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-');
|
|
22
|
+
|
|
23
|
+
export async function readHeader(url, fetchFn, signal) {
|
|
24
|
+
let n = 16 << 20;
|
|
25
|
+
for (;;) {
|
|
26
|
+
const r = await fetchFn(url, { headers: { Range: `bytes=0-${n - 1}` }, signal, cache: 'no-store' });
|
|
27
|
+
if (!r.ok) throw new Error(`GGUF header fetch: HTTP ${r.status}`);
|
|
28
|
+
const h = (name) => (r.headers && r.headers.get ? r.headers.get(name) : null);
|
|
29
|
+
const total = /\/(\d+)\s*$/.exec(h('content-range') || '');
|
|
30
|
+
const file = { size: total ? Number(total[1]) : null, etag: h('etag') }; // for the resume identity
|
|
31
|
+
const u8 = new Uint8Array(await r.arrayBuffer());
|
|
32
|
+
try { return { gguf: parseGguf(u8), bytes: u8, file }; } catch (e) { if (!e.needBytes) throw e; n = e.needBytes; }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A local file as the source: a fetch-shaped function that answers ingestGguf's Range requests from a Blob (a GGUF
|
|
37
|
+
// the user picked, e.g. from a Hugging Face or LM Studio cache), so nothing is downloaded. Pass it as `fetch`.
|
|
38
|
+
// No ETag: an interrupted ingest resumes from the same file (same header and size), never from a download's state.
|
|
39
|
+
export function fileFetch(blob) {
|
|
40
|
+
return async (_url, { headers = {}, signal } = {}) => {
|
|
41
|
+
if (signal) signal.throwIfAborted();
|
|
42
|
+
const m = /^bytes=(\d+)-(\d+)$/.exec(headers.Range || '');
|
|
43
|
+
if (!m) throw new Error('fileFetch: only Range requests (bytes=a-b) are supported');
|
|
44
|
+
const start = Number(m[1]), end = Math.min(Number(m[2]), blob.size - 1);
|
|
45
|
+
if (start > end) return new Response(null, { status: 416, headers: { 'content-range': `bytes */${blob.size}` } });
|
|
46
|
+
return new Response(blob.slice(start, end + 1), { status: 206, headers: { 'content-range': `bytes ${start}-${end}/${blob.size}` } });
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// FNV-1a, for the resume identity: over bytes (the GGUF header) or a string (the plan's output layout).
|
|
51
|
+
const fnv = (at, n) => { let h = 0x811c9dc5; for (let i = 0; i < n; i++) h = Math.imul(h ^ at(i), 0x01000193) >>> 0; return `${n}:${h.toString(16)}`; };
|
|
52
|
+
const bytesId = (u8) => fnv((i) => u8[i], u8.length);
|
|
53
|
+
const textId = (s) => fnv((i) => s.charCodeAt(i), s.length);
|
|
54
|
+
|
|
55
|
+
// plan.layout(gguf) → { experts: { file, bytes, … }, dense: { file, bytes, tensors } }: where every kept byte goes.
|
|
56
|
+
// plan.units(gguf, layout) → sorted units { src, len, file: 'dense' | 'experts', raw } or { …, kind?: 'q4', q, s }:
|
|
57
|
+
// byte ranges of the GGUF, each small enough to transform in memory (raw copies, or Q8_0 / Q4_0 blocks split
|
|
58
|
+
// into a value plane at q and an f16-scale plane at s).
|
|
59
|
+
// An interrupted ingest resumes only when the GGUF header, the plan's output layout, and the source's size and ETag
|
|
60
|
+
// (when the server sends them) match its checkpoint; otherwise it starts over. `rangeBytes` and `io` are for tests:
|
|
61
|
+
// smaller range requests, and fake OPFS files.
|
|
62
|
+
export async function ingestGguf({ url, key, plan, root = ROOT, format = FORMAT, fetch: fetchFn = fetch, onProgress = () => {}, signal, source = {},
|
|
63
|
+
rangeBytes = 1 << 30, io = { OpfsWriter, readOpfsText, writeOpfsText } }) {
|
|
64
|
+
if (!plan || !plan.layout || !plan.units) throw new Error('ingestGguf: plan { layout, units } is required');
|
|
65
|
+
const t0 = performance.now();
|
|
66
|
+
const { gguf, bytes: headerBuf, file } = await readHeader(url, fetchFn, signal);
|
|
67
|
+
const layout = plan.layout(gguf);
|
|
68
|
+
const dir = `${root}/${key}`;
|
|
69
|
+
const storage = typeof navigator !== 'undefined' && navigator.storage;
|
|
70
|
+
const est = storage && storage.estimate ? await storage.estimate() : {};
|
|
71
|
+
const persisted = storage && storage.persist ? await storage.persist().catch(() => false) : false;
|
|
72
|
+
const needed = layout.experts.bytes + layout.dense.bytes;
|
|
73
|
+
onProgress({ status: 'ingest-plan', needed, quota: est.quota, usage: est.usage, persisted });
|
|
74
|
+
const quotaLog = [{ written: 0, quota: est.quota, usage: est.usage }];
|
|
75
|
+
const units = plan.units(gguf, layout);
|
|
76
|
+
const identity = [bytesId(headerBuf.subarray(0, gguf.dataStart)), textId(JSON.stringify([layout, units])), file.size ?? '', file.etag ?? ''].join('|');
|
|
77
|
+
let prior = null;
|
|
78
|
+
try { prior = JSON.parse(await io.readOpfsText(`${dir}/manifest.json`) || 'null'); } catch (_) { prior = null; }
|
|
79
|
+
const r0 = prior && !prior.complete && prior.format === format && prior.resume;
|
|
80
|
+
const startUnit = r0 && r0.identity === identity && r0.unit > 0 && r0.unit < units.length ? r0.unit : 0;
|
|
81
|
+
const checkpoint = (unit) => io.writeOpfsText(`${dir}/manifest.json`, JSON.stringify({ format, complete: false, resume: { identity, unit } }));
|
|
82
|
+
|
|
83
|
+
// Stream the data section in order. Units are filled from the response chunks, transformed,
|
|
84
|
+
// and written while the next bytes arrive (at most `maxWrites` writes in flight).
|
|
85
|
+
const first = units[0].src, last = units[units.length - 1].src + units[units.length - 1].len;
|
|
86
|
+
const resumedFrom = units[startUnit].src - first;
|
|
87
|
+
const maxUnit = units.reduce((m, u) => Math.max(m, u.len), 0);
|
|
88
|
+
let ui = startUnit, fill = 0, unitBuf = new Uint8Array(maxUnit);
|
|
89
|
+
const writers = {};
|
|
90
|
+
const release = async (name) => { const w = writers[name]; delete writers[name]; await w.close(); };
|
|
91
|
+
const inflight = new Set(); const maxWrites = 8;
|
|
92
|
+
const spare = [];
|
|
93
|
+
const getBuf = (n) => { const i = spare.findIndex((b) => b.byteLength >= n); return i >= 0 ? spare.splice(i, 1)[0] : new ArrayBuffer(n); };
|
|
94
|
+
const queue = async (w, buf, len, at) => {
|
|
95
|
+
while (inflight.size >= maxWrites) await Promise.race(inflight);
|
|
96
|
+
const p = w.writeOwned(buf, len, at).then((b) => { inflight.delete(p); if (spare.length < 16) spare.push(b); });
|
|
97
|
+
p.catch(() => {}); // a failed write surfaces through the race above or the final Promise.all
|
|
98
|
+
inflight.add(p);
|
|
99
|
+
};
|
|
100
|
+
let done = resumedFrom, written = 0;
|
|
101
|
+
const flushUnit = async (u) => {
|
|
102
|
+
const src = unitBuf.subarray(0, u.len);
|
|
103
|
+
const w = writers[u.file];
|
|
104
|
+
if (u.raw !== undefined) {
|
|
105
|
+
const b = getBuf(u.len); new Uint8Array(b, 0, u.len).set(src);
|
|
106
|
+
await queue(w, b, u.len, u.raw); written += u.len; return;
|
|
107
|
+
}
|
|
108
|
+
const q4 = u.kind === 'q4', blk = q4 ? Q4_BLOCK : Q8_BLOCK, qPer = q4 ? 16 : 32;
|
|
109
|
+
const nb = u.len / blk;
|
|
110
|
+
const qb = getBuf(nb * qPer), sb = getBuf(nb * 2);
|
|
111
|
+
(q4 ? splitQ4 : splitQ8)(src, new Uint8Array(qb, 0, nb * qPer), new Uint8Array(sb, 0, nb * 2));
|
|
112
|
+
await queue(w, qb, nb * qPer, u.q);
|
|
113
|
+
await queue(w, sb, nb * 2, u.s);
|
|
114
|
+
written += nb * blk;
|
|
115
|
+
};
|
|
116
|
+
try {
|
|
117
|
+
if (!startUnit) {
|
|
118
|
+
await checkpoint(0);
|
|
119
|
+
// Header bytes, for the tokenizer and metadata at every later load.
|
|
120
|
+
writers.header = await io.OpfsWriter.open(`${dir}/header.bin`, { truncate: true });
|
|
121
|
+
await writers.header.write(headerBuf.subarray(0, gguf.dataStart), 0);
|
|
122
|
+
await release('header');
|
|
123
|
+
} else {
|
|
124
|
+
onProgress({ status: 'ingest-resume', unit: startUnit, units: units.length, loaded: resumedFrom, total: last - first });
|
|
125
|
+
}
|
|
126
|
+
// No up-front truncate to full size: a fresh origin's quota is ~10 GiB and grows with what is
|
|
127
|
+
// actually written (measured 2026-10-02), so a single 30.8 GB extension would be refused. The
|
|
128
|
+
// writes below grow each file by at most one layer's records (~642 MB) at a time.
|
|
129
|
+
writers.dense = await io.OpfsWriter.open(`${dir}/dense.bin`, { truncate: !startUnit });
|
|
130
|
+
writers.experts = await io.OpfsWriter.open(`${dir}/experts.bin`, { truncate: !startUnit });
|
|
131
|
+
// Sequential range requests of rangeBytes (1 GiB) keep any one response bounded.
|
|
132
|
+
let pos = units[startUnit].src;
|
|
133
|
+
for (let start = pos; start < last; start += rangeBytes) {
|
|
134
|
+
const end = Math.min(last, start + rangeBytes) - 1;
|
|
135
|
+
const r = await fetchFn(url, { headers: { Range: `bytes=${start}-${end}` }, signal, cache: 'no-store' });
|
|
136
|
+
if (r.status !== 206) throw new Error(`GGUF range ${start}-${end}: HTTP ${r.status}`);
|
|
137
|
+
const reader = r.body.getReader();
|
|
138
|
+
for (;;) {
|
|
139
|
+
const { done: eof, value } = await reader.read();
|
|
140
|
+
if (eof) break;
|
|
141
|
+
let off = 0;
|
|
142
|
+
while (off < value.byteLength && ui < units.length) {
|
|
143
|
+
const u = units[ui];
|
|
144
|
+
if (pos < u.src) { const skip = Math.min(u.src - pos, value.byteLength - off); pos += skip; off += skip; continue; }
|
|
145
|
+
const take = Math.min(u.len - fill, value.byteLength - off);
|
|
146
|
+
unitBuf.set(value.subarray(off, off + take), fill);
|
|
147
|
+
fill += take; off += take; pos += take;
|
|
148
|
+
if (fill === u.len) { await flushUnit(u); ui++; fill = 0; }
|
|
149
|
+
}
|
|
150
|
+
done = pos - first;
|
|
151
|
+
onProgress({ status: 'ingest', loaded: done, total: last - first, resumedFrom, written, secs: (performance.now() - t0) / 1000 });
|
|
152
|
+
if (written - quotaLog[quotaLog.length - 1].written > 4 * 2 ** 30 && storage && storage.estimate) {
|
|
153
|
+
const e = await storage.estimate();
|
|
154
|
+
quotaLog.push({ written, quota: e.quota, usage: e.usage });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Every unit before ui is written: flush, then record ui as the resume point.
|
|
158
|
+
if (ui < units.length) {
|
|
159
|
+
await Promise.all(inflight);
|
|
160
|
+
await writers.dense.flush(); await writers.experts.flush();
|
|
161
|
+
await checkpoint(ui);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (ui !== units.length) throw new Error(`ingest ended early: ${ui} of ${units.length} units`);
|
|
165
|
+
await Promise.all(inflight);
|
|
166
|
+
await release('dense');
|
|
167
|
+
await release('experts');
|
|
168
|
+
} catch (e) {
|
|
169
|
+
// Release every file still open, so a retry in this page can reopen them and resume.
|
|
170
|
+
await Promise.allSettled([...inflight]);
|
|
171
|
+
await Promise.allSettled(Object.keys(writers).map(release));
|
|
172
|
+
throw e;
|
|
173
|
+
}
|
|
174
|
+
const manifest = {
|
|
175
|
+
format, complete: true, ingestedAt: new Date().toISOString(),
|
|
176
|
+
source: { url, ...source, dataStart: gguf.dataStart, headerBytes: gguf.headerBytes },
|
|
177
|
+
ingestSecs: (performance.now() - t0) / 1000, resumedAtUnit: startUnit || undefined, quotaBefore: est.quota, persisted, quotaLog,
|
|
178
|
+
...layout,
|
|
179
|
+
};
|
|
180
|
+
await io.writeOpfsText(`${dir}/manifest.json`, JSON.stringify(manifest));
|
|
181
|
+
return manifest;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Ingest progress as hosts read it: a 'weights' event with byte counts. The spread goes first, so the
|
|
185
|
+
// event's own status ('ingest') cannot overwrite 'weights' (LocalMind's 2026-10-04 bug: a host's load
|
|
186
|
+
// watchdog, seeing no progress, killed a 37 GB first download).
|
|
187
|
+
export function ingestProgress(e) {
|
|
188
|
+
return e && e.status === 'ingest' ? { ...e, status: 'weights', loaded: e.loaded, total: e.total } : e;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function removeStore(key, { root = ROOT } = {}) { await removeOpfs(`${root}/${key}`); }
|