diskformer 0.0.1 → 0.2.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/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, 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.1",
4
- "description": "A disk tier for in-browser inference: keep model weights in OPFS and page into WebGPU only what each step needs. Pre-release name placeholder; v0.1 is being extracted from LocalMind.",
3
+ "version": "0.2.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": { "type": "git", "url": "git+https://github.com/NakliTechie/diskformer.js.git" },
10
- "bugs": { "url": "https://github.com/NakliTechie/diskformer.js/issues" },
11
- "keywords": ["webgpu", "opfs", "llm", "inference", "offload", "moe", "browser", "transformers"],
12
- "files": ["README.md", "LICENSE"]
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,177 @@
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) → 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
+ // FNV-1a, for the resume identity: over bytes (the GGUF header) or a string (the plan's output layout).
37
+ 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)}`; };
38
+ const bytesId = (u8) => fnv((i) => u8[i], u8.length);
39
+ const textId = (s) => fnv((i) => s.charCodeAt(i), s.length);
40
+
41
+ // plan.layout(gguf) → { experts: { file, bytes, … }, dense: { file, bytes, tensors } }: where every kept byte goes.
42
+ // plan.units(gguf, layout) → sorted units { src, len, file: 'dense' | 'experts', raw } or { …, kind?: 'q4', q, s }:
43
+ // byte ranges of the GGUF, each small enough to transform in memory (raw copies, or Q8_0 / Q4_0 blocks split
44
+ // into a value plane at q and an f16-scale plane at s).
45
+ // An interrupted ingest resumes only when the GGUF header, the plan's output layout, and the source's size and ETag
46
+ // (when the server sends them) match its checkpoint; otherwise it starts over. `rangeBytes` and `io` are for tests:
47
+ // smaller range requests, and fake OPFS files.
48
+ export async function ingestGguf({ url, key, plan, root = ROOT, format = FORMAT, fetch: fetchFn = fetch, onProgress = () => {}, signal, source = {},
49
+ rangeBytes = 1 << 30, io = { OpfsWriter, readOpfsText, writeOpfsText } }) {
50
+ if (!plan || !plan.layout || !plan.units) throw new Error('ingestGguf: plan { layout, units } is required');
51
+ const t0 = performance.now();
52
+ const { gguf, bytes: headerBuf, file } = await readHeader(url, fetchFn, signal);
53
+ const layout = plan.layout(gguf);
54
+ const dir = `${root}/${key}`;
55
+ const storage = typeof navigator !== 'undefined' && navigator.storage;
56
+ const est = storage && storage.estimate ? await storage.estimate() : {};
57
+ const persisted = storage && storage.persist ? await storage.persist().catch(() => false) : false;
58
+ const needed = layout.experts.bytes + layout.dense.bytes;
59
+ onProgress({ status: 'ingest-plan', needed, quota: est.quota, usage: est.usage, persisted });
60
+ const quotaLog = [{ written: 0, quota: est.quota, usage: est.usage }];
61
+ const units = plan.units(gguf, layout);
62
+ const identity = [bytesId(headerBuf.subarray(0, gguf.dataStart)), textId(JSON.stringify([layout, units])), file.size ?? '', file.etag ?? ''].join('|');
63
+ let prior = null;
64
+ try { prior = JSON.parse(await io.readOpfsText(`${dir}/manifest.json`) || 'null'); } catch (_) { prior = null; }
65
+ const r0 = prior && !prior.complete && prior.format === format && prior.resume;
66
+ const startUnit = r0 && r0.identity === identity && r0.unit > 0 && r0.unit < units.length ? r0.unit : 0;
67
+ const checkpoint = (unit) => io.writeOpfsText(`${dir}/manifest.json`, JSON.stringify({ format, complete: false, resume: { identity, unit } }));
68
+
69
+ // Stream the data section in order. Units are filled from the response chunks, transformed,
70
+ // and written while the next bytes arrive (at most `maxWrites` writes in flight).
71
+ const first = units[0].src, last = units[units.length - 1].src + units[units.length - 1].len;
72
+ const resumedFrom = units[startUnit].src - first;
73
+ const maxUnit = units.reduce((m, u) => Math.max(m, u.len), 0);
74
+ let ui = startUnit, fill = 0, unitBuf = new Uint8Array(maxUnit);
75
+ const writers = {};
76
+ const release = async (name) => { const w = writers[name]; delete writers[name]; await w.close(); };
77
+ const inflight = new Set(); const maxWrites = 8;
78
+ const spare = [];
79
+ const getBuf = (n) => { const i = spare.findIndex((b) => b.byteLength >= n); return i >= 0 ? spare.splice(i, 1)[0] : new ArrayBuffer(n); };
80
+ const queue = async (w, buf, len, at) => {
81
+ while (inflight.size >= maxWrites) await Promise.race(inflight);
82
+ const p = w.writeOwned(buf, len, at).then((b) => { inflight.delete(p); if (spare.length < 16) spare.push(b); });
83
+ p.catch(() => {}); // a failed write surfaces through the race above or the final Promise.all
84
+ inflight.add(p);
85
+ };
86
+ let done = resumedFrom, written = 0;
87
+ const flushUnit = async (u) => {
88
+ const src = unitBuf.subarray(0, u.len);
89
+ const w = writers[u.file];
90
+ if (u.raw !== undefined) {
91
+ const b = getBuf(u.len); new Uint8Array(b, 0, u.len).set(src);
92
+ await queue(w, b, u.len, u.raw); written += u.len; return;
93
+ }
94
+ const q4 = u.kind === 'q4', blk = q4 ? Q4_BLOCK : Q8_BLOCK, qPer = q4 ? 16 : 32;
95
+ const nb = u.len / blk;
96
+ const qb = getBuf(nb * qPer), sb = getBuf(nb * 2);
97
+ (q4 ? splitQ4 : splitQ8)(src, new Uint8Array(qb, 0, nb * qPer), new Uint8Array(sb, 0, nb * 2));
98
+ await queue(w, qb, nb * qPer, u.q);
99
+ await queue(w, sb, nb * 2, u.s);
100
+ written += nb * blk;
101
+ };
102
+ try {
103
+ if (!startUnit) {
104
+ await checkpoint(0);
105
+ // Header bytes, for the tokenizer and metadata at every later load.
106
+ writers.header = await io.OpfsWriter.open(`${dir}/header.bin`, { truncate: true });
107
+ await writers.header.write(headerBuf.subarray(0, gguf.dataStart), 0);
108
+ await release('header');
109
+ } else {
110
+ onProgress({ status: 'ingest-resume', unit: startUnit, units: units.length, loaded: resumedFrom, total: last - first });
111
+ }
112
+ // No up-front truncate to full size: a fresh origin's quota is ~10 GiB and grows with what is
113
+ // actually written (measured 2026-10-02), so a single 30.8 GB extension would be refused. The
114
+ // writes below grow each file by at most one layer's records (~642 MB) at a time.
115
+ writers.dense = await io.OpfsWriter.open(`${dir}/dense.bin`, { truncate: !startUnit });
116
+ writers.experts = await io.OpfsWriter.open(`${dir}/experts.bin`, { truncate: !startUnit });
117
+ // Sequential range requests of rangeBytes (1 GiB) keep any one response bounded.
118
+ let pos = units[startUnit].src;
119
+ for (let start = pos; start < last; start += rangeBytes) {
120
+ const end = Math.min(last, start + rangeBytes) - 1;
121
+ const r = await fetchFn(url, { headers: { Range: `bytes=${start}-${end}` }, signal, cache: 'no-store' });
122
+ if (r.status !== 206) throw new Error(`GGUF range ${start}-${end}: HTTP ${r.status}`);
123
+ const reader = r.body.getReader();
124
+ for (;;) {
125
+ const { done: eof, value } = await reader.read();
126
+ if (eof) break;
127
+ let off = 0;
128
+ while (off < value.byteLength && ui < units.length) {
129
+ const u = units[ui];
130
+ if (pos < u.src) { const skip = Math.min(u.src - pos, value.byteLength - off); pos += skip; off += skip; continue; }
131
+ const take = Math.min(u.len - fill, value.byteLength - off);
132
+ unitBuf.set(value.subarray(off, off + take), fill);
133
+ fill += take; off += take; pos += take;
134
+ if (fill === u.len) { await flushUnit(u); ui++; fill = 0; }
135
+ }
136
+ done = pos - first;
137
+ onProgress({ status: 'ingest', loaded: done, total: last - first, resumedFrom, written, secs: (performance.now() - t0) / 1000 });
138
+ if (written - quotaLog[quotaLog.length - 1].written > 4 * 2 ** 30 && storage && storage.estimate) {
139
+ const e = await storage.estimate();
140
+ quotaLog.push({ written, quota: e.quota, usage: e.usage });
141
+ }
142
+ }
143
+ // Every unit before ui is written: flush, then record ui as the resume point.
144
+ if (ui < units.length) {
145
+ await Promise.all(inflight);
146
+ await writers.dense.flush(); await writers.experts.flush();
147
+ await checkpoint(ui);
148
+ }
149
+ }
150
+ if (ui !== units.length) throw new Error(`ingest ended early: ${ui} of ${units.length} units`);
151
+ await Promise.all(inflight);
152
+ await release('dense');
153
+ await release('experts');
154
+ } catch (e) {
155
+ // Release every file still open, so a retry in this page can reopen them and resume.
156
+ await Promise.allSettled([...inflight]);
157
+ await Promise.allSettled(Object.keys(writers).map(release));
158
+ throw e;
159
+ }
160
+ const manifest = {
161
+ format, complete: true, ingestedAt: new Date().toISOString(),
162
+ source: { url, ...source, dataStart: gguf.dataStart, headerBytes: gguf.headerBytes },
163
+ ingestSecs: (performance.now() - t0) / 1000, resumedAtUnit: startUnit || undefined, quotaBefore: est.quota, persisted, quotaLog,
164
+ ...layout,
165
+ };
166
+ await io.writeOpfsText(`${dir}/manifest.json`, JSON.stringify(manifest));
167
+ return manifest;
168
+ }
169
+
170
+ // Ingest progress as hosts read it: a 'weights' event with byte counts. The spread goes first, so the
171
+ // event's own status ('ingest') cannot overwrite 'weights' (LocalMind's 2026-10-04 bug: a host's load
172
+ // watchdog, seeing no progress, killed a 37 GB first download).
173
+ export function ingestProgress(e) {
174
+ return e && e.status === 'ingest' ? { ...e, status: 'weights', loaded: e.loaded, total: e.total } : e;
175
+ }
176
+
177
+ export async function removeStore(key, { root = ROOT } = {}) { await removeOpfs(`${root}/${key}`); }
package/src/measure.js ADDED
@@ -0,0 +1,61 @@
1
+ /* measure.js — how fast this machine reads an OPFS file the way a disk tier does. Run it where the tier will run
2
+ * (a page or a worker); OPFS speed varies a lot between machines and browsers.
3
+ *
4
+ * const r = await measure(); // writes a 256 MB probe, measures, removes it
5
+ * const r = await measure({ path: 'diskformer/<key>/experts.bin', recordBytes: 3345408 }); // an existing store
6
+ * // → { bytes, sequentialMBps, random: { recordBytes, reads, p50Ms, p90Ms, MBps }, burst8: { p50Ms, MBps } }
7
+ *
8
+ * A probe the call just wrote is likely in the OS page cache, so its numbers are an upper bound; point `path` at a
9
+ * store larger than RAM to see the disk itself.
10
+ */
11
+ import { OpfsReaderPool, OpfsWriter, removeOpfs } from './opfs-reader.js';
12
+
13
+ const pct = (a, q) => { const s = [...a].sort((x, y) => x - y); return s[Math.min(s.length - 1, Math.floor(q * s.length))]; };
14
+
15
+ export async function measure({ path = null, bytes = 256 << 20, recordBytes = 3 << 20, reads = 64, workers = 4, chunkBytes = 64 << 20 } = {}) {
16
+ const probe = !path;
17
+ if (probe) {
18
+ path = 'diskformer/.measure/probe.bin';
19
+ const w = await OpfsWriter.open(path, { truncate: true });
20
+ const chunk = new Uint8Array(Math.min(chunkBytes, bytes));
21
+ for (let i = 0; i < chunk.length; i += 4096) chunk[i] = i & 255;
22
+ for (let at = 0; at < bytes; at += chunk.length) await w.write(chunk.subarray(0, Math.min(chunk.length, bytes - at)), at);
23
+ await w.close();
24
+ }
25
+ const pool = await OpfsReaderPool.open(path, { workers });
26
+ try {
27
+ const size = pool.size;
28
+ // Sequential: the load pattern (64 MB chunks, one at a time per worker).
29
+ let t0 = performance.now(), done = 0, inflight = [];
30
+ for (let at = 0; at < size; at += chunkBytes) {
31
+ inflight.push(pool.read(at, Math.min(chunkBytes, size - at)).then((r) => { done += r.got; }));
32
+ if (inflight.length >= workers) { await Promise.all(inflight); inflight = []; }
33
+ }
34
+ await Promise.all(inflight);
35
+ const sequentialMBps = done / 1e6 / ((performance.now() - t0) / 1000);
36
+ // Random whole records: the per-token pattern, one at a time, then bursts of 8 (one MoE layer's misses).
37
+ const n = Math.max(1, Math.floor(size / recordBytes));
38
+ const at = () => Math.floor(Math.random() * n) * recordBytes;
39
+ // Throughput comes from the total time: browsers coarsen performance.now() (0.1 ms in a worker without
40
+ // cross-origin isolation), which rounds a single small read to zero.
41
+ const one = []; let t1 = performance.now();
42
+ for (let i = 0; i < reads; i++) { const s = performance.now(); await pool.read(at(), recordBytes); one.push(performance.now() - s); }
43
+ const oneMs = performance.now() - t1;
44
+ const burst = [], bursts = Math.max(1, Math.round(reads / 8)); t1 = performance.now();
45
+ for (let i = 0; i < bursts; i++) {
46
+ const s = performance.now();
47
+ await Promise.all(Array.from({ length: 8 }, () => pool.read(at(), recordBytes)));
48
+ burst.push(performance.now() - s);
49
+ }
50
+ const burstMs = performance.now() - t1;
51
+ const mbps = (count, ms) => Math.round((count * recordBytes) / 1e6 / (Math.max(ms, 0.1) / 1000));
52
+ return {
53
+ path, bytes: size, workers, sequentialMBps: Math.round(sequentialMBps),
54
+ random: { recordBytes, reads, p50Ms: +pct(one, 0.5).toFixed(2), p90Ms: +pct(one, 0.9).toFixed(2), MBps: mbps(reads, oneMs) },
55
+ burst8: { bursts, p50Ms: +pct(burst, 0.5).toFixed(2), MBps: mbps(8 * bursts, burstMs) },
56
+ };
57
+ } finally {
58
+ await pool.close();
59
+ if (probe) await removeOpfs('diskformer/.measure').catch(() => {});
60
+ }
61
+ }