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/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
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/* opfs-reader.js — read model weights from the Origin Private File System (OPFS) fast.
|
|
2
|
+
*
|
|
3
|
+
* Only OPFS has the fast path: FileSystemSyncAccessHandle, which exists only in dedicated
|
|
4
|
+
* workers. Measured 2026-10-02 on an M4 Pro, Chrome 153: 1.51 GB/s per worker for 1.3 MB
|
|
5
|
+
* random reads, 4.3–5.9 GB/s with 4 read-only workers; File.slice().arrayBuffer() reached
|
|
6
|
+
* only 0.39 GB/s. (~/Code/knowledge/notes/browser-tab-can-stream-weights-from-ssd.md)
|
|
7
|
+
*
|
|
8
|
+
* Shared by the SSD-streaming engines (Qwen3-MoE experts, Gemma 4 PLE rows). No engine
|
|
9
|
+
* dependencies. Works from a page or from inside an engine worker: the reader and writer
|
|
10
|
+
* workers are spawned from inlined Blob URLs, so there is no extra file to vendor.
|
|
11
|
+
*
|
|
12
|
+
* const pool = await OpfsReaderPool.open('localmind-ssd/<model>/experts.bin', { workers: 4 });
|
|
13
|
+
* const { buf } = await pool.read(offset, length, recycled); // one contiguous range
|
|
14
|
+
* const { buf } = await pool.readv([{ offset, length }, …], into); // many small ranges, packed
|
|
15
|
+
* pool.close();
|
|
16
|
+
*
|
|
17
|
+
* const w = await OpfsWriter.open('localmind-ssd/<model>/experts.bin', { truncate: true });
|
|
18
|
+
* await w.write(u8, at); await w.close(); // a read-write handle is exclusive: close it first
|
|
19
|
+
*
|
|
20
|
+
* Buffers move by transfer, never by copy; pass a previously returned buffer back in to
|
|
21
|
+
* recycle it.
|
|
22
|
+
*
|
|
23
|
+
* Inside a dedicated worker, `{ inline: true }` (see canInline()) skips the worker hop: the
|
|
24
|
+
* sync handle lives in the calling thread, and the pool gains readSync() for a per-token
|
|
25
|
+
* critical path where a postMessage round trip would cost more than the read.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// The file operations, shared by the worker and the inline (same-thread) mode. Kept
|
|
29
|
+
// self-contained: it is stringified into the worker's Blob source.
|
|
30
|
+
function makeOps() {
|
|
31
|
+
let handle = null;
|
|
32
|
+
const openFile = async (path, create) => {
|
|
33
|
+
const parts = path.split('/').filter(Boolean);
|
|
34
|
+
let dir = await navigator.storage.getDirectory();
|
|
35
|
+
for (const p of parts.slice(0, -1)) dir = await dir.getDirectoryHandle(p, { create });
|
|
36
|
+
return dir.getFileHandle(parts[parts.length - 1], { create });
|
|
37
|
+
};
|
|
38
|
+
const readFull = (u8, at) => {
|
|
39
|
+
let got = 0;
|
|
40
|
+
while (got < u8.byteLength) {
|
|
41
|
+
const n = handle.read(u8.subarray(got), { at: at + got });
|
|
42
|
+
if (n <= 0) break;
|
|
43
|
+
got += n;
|
|
44
|
+
}
|
|
45
|
+
return got;
|
|
46
|
+
};
|
|
47
|
+
// → [reply, transferList]
|
|
48
|
+
const run = async (m) => {
|
|
49
|
+
try {
|
|
50
|
+
if (m.op === 'open') {
|
|
51
|
+
const fh = await openFile(m.path, !!m.create);
|
|
52
|
+
handle = await fh.createSyncAccessHandle(m.mode === 'readwrite' ? {} : { mode: 'read-only' });
|
|
53
|
+
if (m.truncate) handle.truncate(0);
|
|
54
|
+
return [{ id: m.id, size: handle.getSize() }, []];
|
|
55
|
+
}
|
|
56
|
+
if (m.op === 'read') {
|
|
57
|
+
const t0 = performance.now();
|
|
58
|
+
const got = readFull(new Uint8Array(m.buf, 0, m.length), m.at);
|
|
59
|
+
return [{ id: m.id, buf: m.buf, got, ms: performance.now() - t0 }, [m.buf]];
|
|
60
|
+
}
|
|
61
|
+
if (m.op === 'readv') {
|
|
62
|
+
const t0 = performance.now();
|
|
63
|
+
let pos = 0, got = 0;
|
|
64
|
+
for (let i = 0; i < m.ranges.length; i += 2) {
|
|
65
|
+
const len = m.ranges[i + 1];
|
|
66
|
+
got += readFull(new Uint8Array(m.buf, pos, len), m.ranges[i]);
|
|
67
|
+
pos += len;
|
|
68
|
+
}
|
|
69
|
+
return [{ id: m.id, buf: m.buf, got, ms: performance.now() - t0 }, [m.buf]];
|
|
70
|
+
}
|
|
71
|
+
if (m.op === 'write') {
|
|
72
|
+
const u8 = new Uint8Array(m.buf, 0, m.length);
|
|
73
|
+
let put = 0;
|
|
74
|
+
while (put < u8.byteLength) put += handle.write(u8.subarray(put), { at: m.at + put });
|
|
75
|
+
return [{ id: m.id, buf: m.buf, put }, [m.buf]];
|
|
76
|
+
}
|
|
77
|
+
if (m.op === 'truncate') { handle.truncate(m.size); return [{ id: m.id, size: handle.getSize() }, []]; }
|
|
78
|
+
if (m.op === 'flush') { handle.flush(); return [{ id: m.id, size: handle.getSize() }, []]; }
|
|
79
|
+
if (m.op === 'close') {
|
|
80
|
+
if (handle) { if (m.flush) handle.flush(); handle.close(); }
|
|
81
|
+
handle = null;
|
|
82
|
+
return [{ id: m.id, closed: true }, []];
|
|
83
|
+
}
|
|
84
|
+
return [{ id: m.id, error: `unknown op ${m.op}` }, []];
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return [{ id: m.id, error: `${(err && err.name) || 'Error'}: ${(err && err.message) || err}`, buf: m.buf }, m.buf ? [m.buf] : []];
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
run.sync = {
|
|
90
|
+
read: (buf, length, at) => readFull(new Uint8Array(buf, 0, length), at),
|
|
91
|
+
write: (u8, at) => { let put = 0; while (put < u8.byteLength) put += handle.write(u8.subarray(put), { at: at + put }); return put; },
|
|
92
|
+
};
|
|
93
|
+
return run;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let workerUrl = null;
|
|
97
|
+
const spawn = () => {
|
|
98
|
+
if (!workerUrl) {
|
|
99
|
+
const src = `const run = (${makeOps.toString()})();\nself.onmessage = async (e) => { const [r, t] = await run(e.data); self.postMessage(r, t); };`;
|
|
100
|
+
workerUrl = URL.createObjectURL(new Blob([src], { type: 'text/javascript' }));
|
|
101
|
+
}
|
|
102
|
+
return new Worker(workerUrl);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Inline mode needs a sync access handle in this thread: only a dedicated worker has one.
|
|
106
|
+
export const canInline = () => typeof FileSystemSyncAccessHandle !== 'undefined'
|
|
107
|
+
&& typeof DedicatedWorkerGlobalScope !== 'undefined' && self instanceof DedicatedWorkerGlobalScope;
|
|
108
|
+
|
|
109
|
+
// Same interface as Channel, but the file operations run in this thread.
|
|
110
|
+
class InlineChannel {
|
|
111
|
+
constructor() { this.run = makeOps(); this.inflight = 0; this.nextId = 1; }
|
|
112
|
+
async call(msg) {
|
|
113
|
+
this.inflight++;
|
|
114
|
+
try {
|
|
115
|
+
const [r] = await this.run({ ...msg, id: this.nextId++ });
|
|
116
|
+
if (r.error) { const err = new Error(r.error); err.buf = r.buf; throw err; }
|
|
117
|
+
return r;
|
|
118
|
+
} finally { this.inflight--; }
|
|
119
|
+
}
|
|
120
|
+
terminate() {}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// One worker, request/response matched by id; requests run in arrival order.
|
|
124
|
+
class Channel {
|
|
125
|
+
constructor() {
|
|
126
|
+
this.worker = spawn();
|
|
127
|
+
this.pending = new Map();
|
|
128
|
+
this.nextId = 1;
|
|
129
|
+
this.inflight = 0;
|
|
130
|
+
this.worker.onmessage = (e) => {
|
|
131
|
+
const m = e.data, p = this.pending.get(m.id);
|
|
132
|
+
if (!p) return;
|
|
133
|
+
this.pending.delete(m.id);
|
|
134
|
+
this.inflight--;
|
|
135
|
+
if (m.error) { const err = new Error(m.error); err.buf = m.buf; p.reject(err); } else p.resolve(m);
|
|
136
|
+
};
|
|
137
|
+
this.worker.onerror = (e) => {
|
|
138
|
+
for (const p of this.pending.values()) p.reject(new Error(`OPFS worker failed: ${e.message || e}`));
|
|
139
|
+
this.pending.clear();
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
call(msg, transfer = []) {
|
|
143
|
+
const id = this.nextId++;
|
|
144
|
+
this.inflight++;
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
this.pending.set(id, { resolve, reject });
|
|
147
|
+
this.worker.postMessage({ ...msg, id }, transfer);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
terminate() { this.worker.terminate(); }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const bufferFor = (into, length) => (into && into.byteLength >= length ? into : new ArrayBuffer(length));
|
|
154
|
+
|
|
155
|
+
export class OpfsReaderPool {
|
|
156
|
+
// inline: true → no workers; reads run on this thread's own sync handle (dedicated
|
|
157
|
+
// worker only, see canInline()), and readSync() is available for a critical path.
|
|
158
|
+
static async open(path, { workers = 4, inline = false } = {}) {
|
|
159
|
+
const pool = new OpfsReaderPool();
|
|
160
|
+
pool.path = path;
|
|
161
|
+
pool.inline = inline;
|
|
162
|
+
pool.channels = [];
|
|
163
|
+
if (inline) pool.channels.push(new InlineChannel());
|
|
164
|
+
else for (let i = 0; i < workers; i++) pool.channels.push(new Channel());
|
|
165
|
+
const sizes = await Promise.all(pool.channels.map((c) => c.call({ op: 'open', path })));
|
|
166
|
+
pool.size = sizes[0].size;
|
|
167
|
+
return pool;
|
|
168
|
+
}
|
|
169
|
+
pick() {
|
|
170
|
+
let best = this.channels[0];
|
|
171
|
+
for (const c of this.channels) if (c.inflight < best.inflight) best = c;
|
|
172
|
+
return best;
|
|
173
|
+
}
|
|
174
|
+
// → { buf, got, ms }; `buf` is `into` when it was large enough, else a new ArrayBuffer.
|
|
175
|
+
read(offset, length, into) {
|
|
176
|
+
const buf = bufferFor(into, length);
|
|
177
|
+
return this.pick().call({ op: 'read', at: offset, length, buf }, [buf]);
|
|
178
|
+
}
|
|
179
|
+
// Inline mode only: a synchronous read, no promise. → bytes read.
|
|
180
|
+
readSync(offset, length, into) {
|
|
181
|
+
if (!this.inline) throw new Error('readSync needs OpfsReaderPool.open(path, { inline: true })');
|
|
182
|
+
return this.channels[0].run.sync.read(into, length, offset);
|
|
183
|
+
}
|
|
184
|
+
// ranges: [{ offset, length }]. The bytes land back-to-back in `buf`, in order.
|
|
185
|
+
readv(ranges, into) {
|
|
186
|
+
let total = 0;
|
|
187
|
+
const flat = new Array(ranges.length * 2);
|
|
188
|
+
ranges.forEach((r, i) => { flat[2 * i] = r.offset; flat[2 * i + 1] = r.length; total += r.length; });
|
|
189
|
+
const buf = bufferFor(into, total);
|
|
190
|
+
return this.pick().call({ op: 'readv', ranges: flat, buf }, [buf]);
|
|
191
|
+
}
|
|
192
|
+
async close() {
|
|
193
|
+
await Promise.all(this.channels.map((c) => c.call({ op: 'close' }).catch(() => {})));
|
|
194
|
+
for (const c of this.channels) c.terminate();
|
|
195
|
+
this.channels = [];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export class OpfsWriter {
|
|
200
|
+
// inline: true → the read-write sync handle lives in this thread (dedicated worker only).
|
|
201
|
+
static async open(path, { truncate = false, inline = false } = {}) {
|
|
202
|
+
const w = new OpfsWriter();
|
|
203
|
+
w.path = path;
|
|
204
|
+
w.ch = inline ? new InlineChannel() : new Channel();
|
|
205
|
+
w.size = (await w.ch.call({ op: 'open', path, create: true, mode: 'readwrite', truncate })).size;
|
|
206
|
+
return w;
|
|
207
|
+
}
|
|
208
|
+
// Copies `u8` into a transferable buffer only when it is a view on part of a larger one.
|
|
209
|
+
// Resolves with the (recycled) ArrayBuffer once the bytes are written.
|
|
210
|
+
async write(u8, at) {
|
|
211
|
+
let buf = u8.buffer, length = u8.byteLength;
|
|
212
|
+
if (this.ch instanceof InlineChannel) { this.ch.run.sync.write(u8, at); return buf; }
|
|
213
|
+
if (u8.byteOffset !== 0 || buf.byteLength !== length || (typeof SharedArrayBuffer !== 'undefined' && buf instanceof SharedArrayBuffer)) {
|
|
214
|
+
buf = new ArrayBuffer(length);
|
|
215
|
+
new Uint8Array(buf).set(u8);
|
|
216
|
+
}
|
|
217
|
+
const r = await this.ch.call({ op: 'write', at, length, buf }, [buf]);
|
|
218
|
+
return r.buf;
|
|
219
|
+
}
|
|
220
|
+
// Like write(), but takes ownership of `buf` (no copy) — the caller gets it back when done.
|
|
221
|
+
async writeOwned(buf, length, at) {
|
|
222
|
+
const r = await this.ch.call({ op: 'write', at, length, buf }, [buf]);
|
|
223
|
+
return r.buf;
|
|
224
|
+
}
|
|
225
|
+
async truncate(size) { return (await this.ch.call({ op: 'truncate', size })).size; }
|
|
226
|
+
async flush() { return (await this.ch.call({ op: 'flush' })).size; }
|
|
227
|
+
async close() { await this.ch.call({ op: 'close', flush: true }); this.ch.terminate(); }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function opfsDir(path, { create = false } = {}) {
|
|
231
|
+
let dir = await navigator.storage.getDirectory();
|
|
232
|
+
for (const p of path.split('/').filter(Boolean)) dir = await dir.getDirectoryHandle(p, { create });
|
|
233
|
+
return dir;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function readOpfsText(path) {
|
|
237
|
+
const parts = path.split('/').filter(Boolean);
|
|
238
|
+
try {
|
|
239
|
+
const dir = await opfsDir(parts.slice(0, -1).join('/'));
|
|
240
|
+
const f = await (await dir.getFileHandle(parts[parts.length - 1])).getFile();
|
|
241
|
+
return await f.text();
|
|
242
|
+
} catch (_) { return null; }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function writeOpfsText(path, text) {
|
|
246
|
+
const parts = path.split('/').filter(Boolean);
|
|
247
|
+
const dir = await opfsDir(parts.slice(0, -1).join('/'), { create: true });
|
|
248
|
+
const fh = await dir.getFileHandle(parts[parts.length - 1], { create: true });
|
|
249
|
+
const w = await fh.createWritable();
|
|
250
|
+
await w.write(text);
|
|
251
|
+
await w.close();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function removeOpfs(path) {
|
|
255
|
+
const parts = path.split('/').filter(Boolean);
|
|
256
|
+
const dir = await opfsDir(parts.slice(0, -1).join('/'));
|
|
257
|
+
await dir.removeEntry(parts[parts.length - 1], { recursive: true });
|
|
258
|
+
}
|
package/src/rows.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/* rows.js — rows of a large lookup table (an embedding-style table) served from OPFS into a small GPU-resident
|
|
2
|
+
* cache. Extracted from LocalMind's ple-opfs.js (2026-10-05), where it serves Gemma 4 E2B/E4B's per-layer embedding
|
|
3
|
+
* table from disk; the Gemma-specific helpers stay in LocalMind. `root` is a new option (LocalMind passes
|
|
4
|
+
* 'localmind-ssd').
|
|
5
|
+
*
|
|
6
|
+
* RowFile fixed-size rows in one OPFS file plus manifest.json; synchronous reads through opfs-reader.js in
|
|
7
|
+
* inline mode, so it must live in a dedicated worker.
|
|
8
|
+
* RowCache `slots` rows on the GPU, split into planes (byte ranges of a row that live in separate GPU buffers),
|
|
9
|
+
* an O(1) LRU, and a CPU id→slot mirror (optionally a GPU u32 map). lookup(ids) makes every id
|
|
10
|
+
* resident and returns its slot, writing missing rows with queue.writeBuffer.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { OpfsReaderPool, OpfsWriter, canInline, readOpfsText, writeOpfsText, removeOpfs } from './opfs-reader.js';
|
|
14
|
+
|
|
15
|
+
export const ROOT = 'diskformer';
|
|
16
|
+
const FORMAT = 'localmind-rows/1'; // unchanged from LocalMind, so a store it wrote stays valid
|
|
17
|
+
|
|
18
|
+
// FNV-1a over a few samples of each source buffer and their lengths: cheap enough to run on every
|
|
19
|
+
// load, and it changes when the model's weights change.
|
|
20
|
+
export function fingerprint(buffers, sample = 1 << 16) {
|
|
21
|
+
let h = 0x811c9dc5;
|
|
22
|
+
const mix = (b) => { h ^= b; h = Math.imul(h, 0x01000193) >>> 0; };
|
|
23
|
+
for (const u8 of buffers) {
|
|
24
|
+
for (const v of [u8.length & 0xff, (u8.length >>> 8) & 0xff, (u8.length >>> 16) & 0xff, (u8.length >>> 24) & 0xff]) mix(v);
|
|
25
|
+
const starts = [0, Math.max(0, (u8.length >> 1) - (sample >> 1)), Math.max(0, u8.length - sample)];
|
|
26
|
+
for (const s of starts) for (let i = s, e = Math.min(u8.length, s + sample); i < e; i++) mix(u8[i]);
|
|
27
|
+
}
|
|
28
|
+
return h.toString(16).padStart(8, '0');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Fixed-size rows in one OPFS file, <root>/<key>/<name>, with manifest.json beside it.
|
|
32
|
+
// The file I/O is opfs-reader.js in inline mode: the sync handle lives in this dedicated worker,
|
|
33
|
+
// so a row read is a plain synchronous call with no worker hop.
|
|
34
|
+
export class RowFile {
|
|
35
|
+
#reader = null;
|
|
36
|
+
|
|
37
|
+
constructor({ key, name, rowBytes, rows, root = ROOT }) {
|
|
38
|
+
this.path = `${root}/${key}/${name}`;
|
|
39
|
+
this.manifestPath = `${root}/${key}/manifest.json`;
|
|
40
|
+
this.name = name;
|
|
41
|
+
this.rowBytes = rowBytes;
|
|
42
|
+
this.rows = rows;
|
|
43
|
+
this.manifest = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static async open({ key, name = 'rows.bin', rowBytes, rows, root = ROOT }) {
|
|
47
|
+
if (!canInline()) throw new Error('RowFile needs a dedicated worker (FileSystemSyncAccessHandle)');
|
|
48
|
+
const f = new RowFile({ key, name, rowBytes, rows, root });
|
|
49
|
+
try { f.manifest = JSON.parse(await readOpfsText(f.manifestPath)); } catch (_) { f.manifest = null; }
|
|
50
|
+
return f;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
matches(fp) {
|
|
54
|
+
const m = this.manifest;
|
|
55
|
+
return !!m && m.format === FORMAT && m.complete === true && m.name === this.name &&
|
|
56
|
+
m.rowBytes === this.rowBytes && m.rows === this.rows && m.fingerprint === fp;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// fill(row0, count, dst) writes `count` rows starting at `row0` into dst (count * rowBytes bytes).
|
|
60
|
+
// The manifest is removed first and written last, so an interrupted write is never taken as valid.
|
|
61
|
+
async write(fp, fill, { batchRows = 8192, extra = {} } = {}) {
|
|
62
|
+
this.close();
|
|
63
|
+
await removeOpfs(this.manifestPath).catch(() => {});
|
|
64
|
+
this.manifest = null;
|
|
65
|
+
const total = this.rows * this.rowBytes;
|
|
66
|
+
const t0 = performance.now();
|
|
67
|
+
const w = await OpfsWriter.open(this.path, { truncate: true, inline: true });
|
|
68
|
+
try {
|
|
69
|
+
const buf = new Uint8Array(batchRows * this.rowBytes);
|
|
70
|
+
for (let row0 = 0; row0 < this.rows; row0 += batchRows) {
|
|
71
|
+
const n = Math.min(batchRows, this.rows - row0);
|
|
72
|
+
const view = buf.subarray(0, n * this.rowBytes);
|
|
73
|
+
fill(row0, n, view);
|
|
74
|
+
await w.write(view, row0 * this.rowBytes);
|
|
75
|
+
}
|
|
76
|
+
} finally { await w.close(); }
|
|
77
|
+
const writeMs = performance.now() - t0;
|
|
78
|
+
this.manifest = { format: FORMAT, name: this.name, rowBytes: this.rowBytes, rows: this.rows, bytes: total,
|
|
79
|
+
fingerprint: fp, complete: true, writtenAt: new Date().toISOString(), writeMs: Math.round(writeMs), ...extra };
|
|
80
|
+
await writeOpfsText(this.manifestPath, JSON.stringify(this.manifest, null, 1));
|
|
81
|
+
return writeMs;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async openRead() {
|
|
85
|
+
if (this.#reader) return;
|
|
86
|
+
const r = await OpfsReaderPool.open(this.path, { inline: true });
|
|
87
|
+
if (r.size !== this.rows * this.rowBytes) {
|
|
88
|
+
await r.close();
|
|
89
|
+
throw new Error(`RowFile ${this.name}: size ${r.size} != ${this.rows * this.rowBytes}`);
|
|
90
|
+
}
|
|
91
|
+
this.#reader = r;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Reads rows [row, row + count) to the start of dst (a Uint8Array that starts its buffer).
|
|
95
|
+
readRows(row, count, dst) {
|
|
96
|
+
if (dst.byteOffset !== 0) throw new Error('RowFile.readRows: dst must start at offset 0 of its buffer');
|
|
97
|
+
const len = count * this.rowBytes;
|
|
98
|
+
const got = this.#reader.readSync(row * this.rowBytes, len, dst.buffer);
|
|
99
|
+
if (got !== len) throw new Error(`RowFile ${this.name}: read ${got} of ${len} bytes at row ${row}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Closes the read handle. In inline mode the handle closes synchronously inside this call.
|
|
103
|
+
close() {
|
|
104
|
+
if (this.#reader) { this.#reader.close(); this.#reader = null; }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class RowCache {
|
|
109
|
+
// file: an open RowFile. slots: rows held on the GPU. planes: [{ offset, bytes, buffer }], each
|
|
110
|
+
// row byte range [offset, offset + bytes) is stored at buffer[slot * bytes]. queue: GPUQueue.
|
|
111
|
+
// mapBuffer (optional): a GPU u32[file.rows] copy of the id->slot map, 0xFFFFFFFF when absent.
|
|
112
|
+
constructor({ file, slots, planes, queue, mapBuffer = null }) {
|
|
113
|
+
this.file = file;
|
|
114
|
+
this.slots = slots;
|
|
115
|
+
this.planes = planes;
|
|
116
|
+
this.queue = queue;
|
|
117
|
+
this.mapBuffer = mapBuffer;
|
|
118
|
+
this.slotOf = new Int32Array(file.rows).fill(-1);
|
|
119
|
+
this.idOf = new Int32Array(slots).fill(-1);
|
|
120
|
+
// LRU as a doubly linked list over slots; head = least recently used. Starts as 0..slots-1.
|
|
121
|
+
this.prev = new Int32Array(slots);
|
|
122
|
+
this.next = new Int32Array(slots);
|
|
123
|
+
for (let s = 0; s < slots; s++) { this.prev[s] = s - 1; this.next[s] = s + 1 < slots ? s + 1 : -1; }
|
|
124
|
+
this.head = 0;
|
|
125
|
+
this.tail = slots - 1;
|
|
126
|
+
this.stamp = new Uint32Array(slots);
|
|
127
|
+
this.clock = 0;
|
|
128
|
+
this.row = new Uint8Array(file.rowBytes);
|
|
129
|
+
this.u32 = new Uint32Array(1);
|
|
130
|
+
this.stats = { lookups: 0, ids: 0, misses: 0, readMs: 0, replays: 0 };
|
|
131
|
+
this.missLog = []; // the first 4,096 ids installed after load, for analysis
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#touch(s) {
|
|
135
|
+
if (s === this.tail) return;
|
|
136
|
+
const p = this.prev[s], n = this.next[s];
|
|
137
|
+
if (p >= 0) this.next[p] = n; else this.head = n;
|
|
138
|
+
this.prev[n] = p;
|
|
139
|
+
this.prev[s] = this.tail;
|
|
140
|
+
this.next[s] = -1;
|
|
141
|
+
this.next[this.tail] = s;
|
|
142
|
+
this.tail = s;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#mapWrite(id, slot) {
|
|
146
|
+
if (!this.mapBuffer) return;
|
|
147
|
+
this.u32[0] = slot >>> 0;
|
|
148
|
+
this.queue.writeBuffer(this.mapBuffer, id * 4, this.u32);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#install(id, s) {
|
|
152
|
+
const old = this.idOf[s];
|
|
153
|
+
if (old >= 0) { this.slotOf[old] = -1; this.#mapWrite(old, 0xffffffff); }
|
|
154
|
+
const t0 = performance.now();
|
|
155
|
+
this.file.readRows(id, 1, this.row);
|
|
156
|
+
this.stats.readMs += performance.now() - t0;
|
|
157
|
+
for (const p of this.planes) this.queue.writeBuffer(p.buffer, s * p.bytes, this.row, p.offset, p.bytes);
|
|
158
|
+
this.idOf[s] = id;
|
|
159
|
+
this.slotOf[id] = s;
|
|
160
|
+
this.#mapWrite(id, s);
|
|
161
|
+
this.stats.misses++;
|
|
162
|
+
if (this.missLog.length < 4096) this.missLog.push(id);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Makes every id resident and returns its slot. Rows one call uses are never evicted by the same
|
|
166
|
+
// call, so a call may name at most `slots` distinct ids.
|
|
167
|
+
lookup(ids, out = new Uint32Array(ids.length)) {
|
|
168
|
+
const call = ++this.clock;
|
|
169
|
+
this.stats.lookups++;
|
|
170
|
+
this.stats.ids += ids.length;
|
|
171
|
+
for (let i = 0; i < ids.length; i++) {
|
|
172
|
+
const id = ids[i];
|
|
173
|
+
let s = this.slotOf[id];
|
|
174
|
+
if (s < 0) {
|
|
175
|
+
s = this.head;
|
|
176
|
+
if (this.stamp[s] === call) throw new Error(`RowCache: one lookup needs more than ${this.slots} rows`);
|
|
177
|
+
this.#install(id, s);
|
|
178
|
+
}
|
|
179
|
+
this.stamp[s] = call;
|
|
180
|
+
this.#touch(s);
|
|
181
|
+
out[i] = s;
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
has(id) { return this.slotOf[id] >= 0; }
|
|
187
|
+
|
|
188
|
+
// Loads rows [row0, row0 + count) into the least recently used slots in large reads (for a warm
|
|
189
|
+
// set at load), then uploads the whole id->slot map once. Returns the milliseconds spent.
|
|
190
|
+
warm(row0, count, batch = 4096) {
|
|
191
|
+
const t0 = performance.now();
|
|
192
|
+
count = Math.min(count, this.slots);
|
|
193
|
+
const rb = this.file.rowBytes;
|
|
194
|
+
const buf = new Uint8Array(batch * rb);
|
|
195
|
+
const planeBufs = this.planes.map((p) => new Uint8Array(batch * p.bytes));
|
|
196
|
+
for (let r = row0; r < row0 + count; r += batch) {
|
|
197
|
+
const n = Math.min(batch, row0 + count - r);
|
|
198
|
+
this.file.readRows(r, n, buf);
|
|
199
|
+
// Fresh slots come from the LRU head in order; collect them and write contiguous runs.
|
|
200
|
+
const slots = new Int32Array(n);
|
|
201
|
+
for (let i = 0; i < n; i++) {
|
|
202
|
+
const id = r + i;
|
|
203
|
+
let s = this.slotOf[id];
|
|
204
|
+
if (s < 0) {
|
|
205
|
+
s = this.head;
|
|
206
|
+
const old = this.idOf[s];
|
|
207
|
+
if (old >= 0) this.slotOf[old] = -1;
|
|
208
|
+
this.idOf[s] = id;
|
|
209
|
+
this.slotOf[id] = s;
|
|
210
|
+
}
|
|
211
|
+
this.#touch(s);
|
|
212
|
+
slots[i] = s;
|
|
213
|
+
}
|
|
214
|
+
for (let pi = 0; pi < this.planes.length; pi++) {
|
|
215
|
+
const p = this.planes[pi], dst = planeBufs[pi];
|
|
216
|
+
for (let i = 0; i < n; i++) dst.set(buf.subarray(i * rb + p.offset, i * rb + p.offset + p.bytes), i * p.bytes);
|
|
217
|
+
let i = 0;
|
|
218
|
+
while (i < n) {
|
|
219
|
+
let j = i + 1;
|
|
220
|
+
while (j < n && slots[j] === slots[j - 1] + 1) j++;
|
|
221
|
+
this.queue.writeBuffer(p.buffer, slots[i] * p.bytes, dst, i * p.bytes, (j - i) * p.bytes);
|
|
222
|
+
i = j;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// slotOf's bytes are the GPU map: -1 as an Int32 is 0xFFFFFFFF as a u32.
|
|
227
|
+
if (this.mapBuffer) this.queue.writeBuffer(this.mapBuffer, 0, this.slotOf);
|
|
228
|
+
return performance.now() - t0;
|
|
229
|
+
}
|
|
230
|
+
}
|