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
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
/* qwen3_moe_ssd.js — Qwen3-MoE (Qwen3-30B-A3B) in a browser tab, larger than RAM: the dense
|
|
2
|
+
* weights, routers and KV cache live on the GPU; the 6,144 routed experts (≈31 GB at Q8_0)
|
|
3
|
+
* live in OPFS and stream into a GPU slot pool as the router asks for them.
|
|
4
|
+
*
|
|
5
|
+
* Rung 2a of the SSD-streaming ladder (plan/pending.md). From-scratch WebGPU engine; the
|
|
6
|
+
* kernels are ports of browser-big-fast-lab's custom-kernels engine (an Apache-2.0 fork of
|
|
7
|
+
* tylerstraub/gemma4-webgpu): in-shader Q8_0 GEMV (int8 + f16 scale per 32-block, f32
|
|
8
|
+
* accumulate), GPU softmax + top-k router, slot-indexed batched expert GEMVs, MoE combine.
|
|
9
|
+
*
|
|
10
|
+
* const m = await Qwen3MoeSsd.load('Qwen/Qwen3-30B-A3B-GGUF', { fetch, onProgress, poolBytes });
|
|
11
|
+
* for await (const { text } of m.generate(messages, { maxNewTokens })) …
|
|
12
|
+
*
|
|
13
|
+
* Load = (first time only) ingest the GGUF into OPFS in the engine's own layout, then upload
|
|
14
|
+
* the dense part to the GPU. OPFS layout under localmind-ssd/<key>/:
|
|
15
|
+
* header.bin the GGUF header bytes (metadata + tokenizer), re-parsed at every load
|
|
16
|
+
* dense.bin every non-expert tensor; Q8_0 split losslessly into int8 + f16-scale planes
|
|
17
|
+
* experts.bin one 5,013,504-byte record per (layer, expert), gate‖up‖down contiguous
|
|
18
|
+
* manifest.json source (repo, revision, sha256, size) + every offset above
|
|
19
|
+
* Q8_0 is never re-quantized: each block's f16 scale and 32 int8 values are copied bit-exact,
|
|
20
|
+
* so the engine computes the same dot products llama.cpp does, up to summation order.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { OpfsReaderPool, readOpfsText } from '../src/opfs-reader.js';
|
|
24
|
+
import { ExpertStreamer } from '../src/expert-stream.js';
|
|
25
|
+
// The store layer is diskformer.js's (github.com/NakliTechie/diskformer.js), byte-identical copies: gguf.js, ingest.js.
|
|
26
|
+
import { GGML, Q8_BLOCK, Q4_BLOCK, Q6K_BLOCK, parseGguf, tensorBytes } from '../src/gguf.js';
|
|
27
|
+
import { ingestGguf as ingestStore, ingestProgress, fileFetch, storeKey, removeStore } from '../src/ingest.js';
|
|
28
|
+
export { GGML, Q4_BLOCK, Q6K_BLOCK, parseGguf, tensorBytes, splitQ4 } from '../src/gguf.js';
|
|
29
|
+
export { ingestProgress } from '../src/ingest.js';
|
|
30
|
+
|
|
31
|
+
export const QWEN3_30B_A3B = {
|
|
32
|
+
repo: 'Qwen/Qwen3-30B-A3B-GGUF',
|
|
33
|
+
file: 'Qwen3-30B-A3B-Q8_0.gguf',
|
|
34
|
+
revision: 'e4d4bafdfb96a411a163846265362aceb0b9c63a',
|
|
35
|
+
sha256: '4ad960d180b16f56024f5b704697e5dd5b0837167c2e515ef0569abfc599743c',
|
|
36
|
+
size: 32483931648,
|
|
37
|
+
};
|
|
38
|
+
const FORMAT = 'localmind-qwen3moe-ssd/1';
|
|
39
|
+
const OPFS_ROOT = 'localmind-ssd';
|
|
40
|
+
|
|
41
|
+
// ── GGUF ────────────────────────────────────────────────────────────────────
|
|
42
|
+
// qwen3moe (rung 2a) and qwen35moe (Qwen3.5/3.6 MoE, rung 2b: qwen35_moe_ssd.js) share the
|
|
43
|
+
// expert layout; qwen35moe adds the Gated DeltaNet (ssm.*), partial RoPE and a shared expert.
|
|
44
|
+
export function configFromGguf(kv) {
|
|
45
|
+
const a = kv['general.architecture'];
|
|
46
|
+
if (a !== 'qwen3moe' && a !== 'qwen35moe') throw new Error(`expected a qwen3moe or qwen35moe GGUF, got ${a}`);
|
|
47
|
+
const g = (k) => kv[`${a}.${k}`];
|
|
48
|
+
const extra = a !== 'qwen35moe' ? {} : {
|
|
49
|
+
ropeDims: g('rope.dimension_count'), shexpFf: g('expert_shared_feed_forward_length'),
|
|
50
|
+
attnInterval: g('full_attention_interval') || 4,
|
|
51
|
+
ssm: { dConv: g('ssm.conv_kernel'), dInner: g('ssm.inner_size'), dState: g('ssm.state_size'), vHeads: g('ssm.time_step_rank'), kHeads: g('ssm.group_count') },
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
...extra,
|
|
55
|
+
arch: a,
|
|
56
|
+
layers: g('block_count'), hidden: g('embedding_length'),
|
|
57
|
+
heads: g('attention.head_count'), kvHeads: g('attention.head_count_kv'),
|
|
58
|
+
headDim: g('attention.key_length') || g('embedding_length') / g('attention.head_count'),
|
|
59
|
+
experts: g('expert_count'), topK: g('expert_used_count'), expertFf: g('expert_feed_forward_length'),
|
|
60
|
+
ropeTheta: g('rope.freq_base'), eps: g('attention.layer_norm_rms_epsilon'),
|
|
61
|
+
contextLength: g('context_length'),
|
|
62
|
+
vocab: kv['tokenizer.ggml.tokens'].length,
|
|
63
|
+
bos: kv['tokenizer.ggml.bos_token_id'], eos: kv['tokenizer.ggml.eos_token_id'],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The engine's OPFS layout, derived from the header alone (ingest and load agree on it).
|
|
68
|
+
export function planLayout(gguf) {
|
|
69
|
+
const cfg = configFromGguf(gguf.kv);
|
|
70
|
+
const H = cfg.hidden, F = cfg.expertFf;
|
|
71
|
+
const qBytes = F * H; // one projection's int8 plane
|
|
72
|
+
const sBytes = (F * H / 32) * 2; // its f16 scale plane
|
|
73
|
+
const parts = {
|
|
74
|
+
guQ: { off: 0, bytes: 2 * qBytes }, // gate rows 0..F-1, up rows F..2F-1
|
|
75
|
+
guS: { off: 2 * qBytes, bytes: 2 * sBytes },
|
|
76
|
+
dQ: { off: 2 * qBytes + 2 * sBytes, bytes: qBytes },
|
|
77
|
+
dS: { off: 3 * qBytes + 2 * sBytes, bytes: sBytes },
|
|
78
|
+
};
|
|
79
|
+
const record = 3 * qBytes + 3 * sBytes;
|
|
80
|
+
const dense = {};
|
|
81
|
+
let off = 0;
|
|
82
|
+
const take = (n) => { const o = off; off += Math.ceil(n / 256) * 256; return o; };
|
|
83
|
+
for (const t of gguf.tensors) {
|
|
84
|
+
if (/_exps\.weight$/.test(t.name)) continue;
|
|
85
|
+
const n = t.dims.reduce((a, b) => a * b, 1);
|
|
86
|
+
if (t.type === GGML.Q8_0) {
|
|
87
|
+
dense[t.name] = { type: 'q8', dims: t.dims, q: { off: take(n), bytes: n }, s: { off: take(n / 16), bytes: n / 16 } };
|
|
88
|
+
} else if (t.type === GGML.F32) {
|
|
89
|
+
dense[t.name] = { type: 'f32', dims: t.dims, raw: { off: take(n * 4), bytes: n * 4 } };
|
|
90
|
+
} else throw new Error(`dense tensor ${t.name}: unsupported type ${t.type}`);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
config: cfg,
|
|
94
|
+
experts: { file: 'experts.bin', record, parts, layers: cfg.layers, perLayer: cfg.experts, bytes: record * cfg.layers * cfg.experts },
|
|
95
|
+
dense: { file: 'dense.bin', bytes: off, tensors: dense },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Ingest: GGUF (over HTTP Range) → OPFS, in the engine layout ─────────────
|
|
100
|
+
// Every byte range of the GGUF the engine keeps, as units small enough to transform in
|
|
101
|
+
// memory: one unit per expert slice, row bands for dense Q8_0, whole F32 tensors.
|
|
102
|
+
function planUnits(gguf, layout) {
|
|
103
|
+
const units = [];
|
|
104
|
+
const { record, parts, perLayer } = layout.experts;
|
|
105
|
+
const qBytes = parts.dQ.bytes, sBytes = parts.dS.bytes;
|
|
106
|
+
for (const t of gguf.tensors) {
|
|
107
|
+
const abs = gguf.dataStart + t.offset;
|
|
108
|
+
const m = /^blk\.(\d+)\.ffn_(gate|up|down)_exps\.weight$/.exec(t.name);
|
|
109
|
+
if (m) {
|
|
110
|
+
const layer = Number(m[1]), which = m[2];
|
|
111
|
+
if (t.type !== GGML.Q8_0) throw new Error(`${t.name}: experts must be Q8_0`);
|
|
112
|
+
const slice = tensorBytes(t) / perLayer;
|
|
113
|
+
const qOff = which === 'gate' ? parts.guQ.off : which === 'up' ? parts.guQ.off + qBytes : parts.dQ.off;
|
|
114
|
+
const sOff = which === 'gate' ? parts.guS.off : which === 'up' ? parts.guS.off + sBytes : parts.dS.off;
|
|
115
|
+
for (let e = 0; e < perLayer; e++) {
|
|
116
|
+
const rec = (layer * perLayer + e) * record;
|
|
117
|
+
units.push({ src: abs + e * slice, len: slice, file: 'experts', q: rec + qOff, s: rec + sOff });
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const d = layout.dense.tensors[t.name];
|
|
122
|
+
if (d.type === 'f32') { units.push({ src: abs, len: d.raw.bytes, file: 'dense', raw: d.raw.off }); continue; }
|
|
123
|
+
const cols = t.dims[0], rows = d.q.bytes / cols, rowBytes = (cols / 32) * Q8_BLOCK;
|
|
124
|
+
const band = Math.max(1, Math.floor((8 << 20) / rowBytes));
|
|
125
|
+
for (let r = 0; r < rows; r += band) {
|
|
126
|
+
const nr = Math.min(band, rows - r);
|
|
127
|
+
units.push({ src: abs + r * rowBytes, len: nr * rowBytes, file: 'dense', q: d.q.off + r * cols, s: d.s.off + r * cols / 16 });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
units.sort((a, b) => a.src - b.src);
|
|
131
|
+
return units;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The ingest is diskformer's ingestGguf with this engine's store folder and layout tag (kept, so stores written
|
|
135
|
+
// before the split stay valid); `plan` lets another architecture supply its own layout (Gemma 4: gemma4_moe_ssd.js).
|
|
136
|
+
export function ingestGguf(opts) {
|
|
137
|
+
return ingestStore({ ...opts, root: opts.root || OPFS_ROOT, format: FORMAT, plan: opts.plan || { layout: planLayout, units: planUnits } });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Tokenizer: byte-level BPE from the GGUF vocab (tokenizer.ggml.model = gpt2, pre = qwen2) ──
|
|
141
|
+
// Same pre-tokenizer regex llama.cpp uses for LLAMA_VOCAB_PRE_TYPE_QWEN2.
|
|
142
|
+
const QWEN2_PRE = /(?:'[sS]|'[tT]|'[rR][eE]|'[vV][eE]|'[mM]|'[lL][lL]|'[dD])|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+/gu;
|
|
143
|
+
// LLAMA_VOCAB_PRE_TYPE_QWEN35 (Qwen3.5/3.6): combining marks (\p{M}) count as part of a word.
|
|
144
|
+
const QWEN35_PRE = /(?:'[sS]|'[tT]|'[rR][eE]|'[vV][eE]|'[mM]|'[lL][lL]|'[dD])|[^\r\n\p{L}\p{N}]?[\p{L}\p{M}]+|\p{N}| ?[^\s\p{L}\p{M}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+/gu;
|
|
145
|
+
|
|
146
|
+
function byteToUnicode() {
|
|
147
|
+
const bs = [];
|
|
148
|
+
for (let i = 33; i <= 126; i++) bs.push(i);
|
|
149
|
+
for (let i = 161; i <= 172; i++) bs.push(i);
|
|
150
|
+
for (let i = 174; i <= 255; i++) bs.push(i);
|
|
151
|
+
const cs = bs.slice();
|
|
152
|
+
let n = 0;
|
|
153
|
+
for (let b = 0; b < 256; b++) if (!bs.includes(b)) { bs.push(b); cs.push(256 + n); n++; }
|
|
154
|
+
const enc = new Array(256), dec = new Map();
|
|
155
|
+
bs.forEach((b, i) => { enc[b] = String.fromCodePoint(cs[i]); dec.set(String.fromCodePoint(cs[i]), b); });
|
|
156
|
+
return { enc, dec };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export class BpeTokenizer {
|
|
160
|
+
constructor(kv) {
|
|
161
|
+
this.tokens = kv['tokenizer.ggml.tokens'];
|
|
162
|
+
this.preRe = kv['tokenizer.ggml.pre'] === 'qwen35' ? QWEN35_PRE : QWEN2_PRE;
|
|
163
|
+
const types = kv['tokenizer.ggml.token_type'] || [];
|
|
164
|
+
this.ids = new Map(this.tokens.map((t, i) => [t, i]));
|
|
165
|
+
this.ranks = new Map((kv['tokenizer.ggml.merges'] || []).map((m, i) => [m, i]));
|
|
166
|
+
this.special = [];
|
|
167
|
+
this.isSpecial = new Uint8Array(this.tokens.length);
|
|
168
|
+
for (let i = 0; i < this.tokens.length; i++) if (types[i] === 3 || types[i] === 4) { this.special.push(this.tokens[i]); this.isSpecial[i] = 1; }
|
|
169
|
+
this.special.sort((a, b) => b.length - a.length);
|
|
170
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
171
|
+
this.specialRe = this.special.length ? new RegExp(`(${this.special.map(esc).join('|')})`) : null;
|
|
172
|
+
const { enc, dec } = byteToUnicode();
|
|
173
|
+
this.byteEnc = enc; this.byteDec = dec;
|
|
174
|
+
this.cache = new Map();
|
|
175
|
+
this.utf8 = new TextEncoder();
|
|
176
|
+
}
|
|
177
|
+
bpe(word) {
|
|
178
|
+
const hit = this.cache.get(word);
|
|
179
|
+
if (hit) return hit;
|
|
180
|
+
let parts = Array.from(word);
|
|
181
|
+
while (parts.length > 1) {
|
|
182
|
+
let best = -1, bestRank = Infinity;
|
|
183
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
184
|
+
const r = this.ranks.get(parts[i] + ' ' + parts[i + 1]);
|
|
185
|
+
if (r !== undefined && r < bestRank) { bestRank = r; best = i; }
|
|
186
|
+
}
|
|
187
|
+
if (best < 0) break;
|
|
188
|
+
parts = [...parts.slice(0, best), parts[best] + parts[best + 1], ...parts.slice(best + 2)];
|
|
189
|
+
}
|
|
190
|
+
const ids = parts.map((p) => {
|
|
191
|
+
const id = this.ids.get(p);
|
|
192
|
+
if (id === undefined) throw new Error(`BPE: no token for ${JSON.stringify(p)}`);
|
|
193
|
+
return id;
|
|
194
|
+
});
|
|
195
|
+
if (this.cache.size < 50000) this.cache.set(word, ids);
|
|
196
|
+
return ids;
|
|
197
|
+
}
|
|
198
|
+
// parseSpecial: special-token text (e.g. <|im_start|>) maps to its single id.
|
|
199
|
+
encode(text, { parseSpecial = true } = {}) {
|
|
200
|
+
const out = [];
|
|
201
|
+
const pieces = parseSpecial && this.specialRe ? text.split(this.specialRe) : [text];
|
|
202
|
+
for (let i = 0; i < pieces.length; i++) {
|
|
203
|
+
const piece = pieces[i];
|
|
204
|
+
if (!piece) continue;
|
|
205
|
+
if (parseSpecial && i % 2 === 1) { out.push(this.ids.get(piece)); continue; }
|
|
206
|
+
for (const m of piece.matchAll(this.preRe)) {
|
|
207
|
+
let s = '';
|
|
208
|
+
for (const b of this.utf8.encode(m[0])) s += this.byteEnc[b];
|
|
209
|
+
out.push(...this.bpe(s));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
tokenBytes(id) {
|
|
215
|
+
const t = this.tokens[id];
|
|
216
|
+
if (this.isSpecial[id]) return this.utf8.encode(t);
|
|
217
|
+
const bytes = [];
|
|
218
|
+
for (const ch of t) { const b = this.byteDec.get(ch); if (b !== undefined) bytes.push(b); }
|
|
219
|
+
return Uint8Array.from(bytes);
|
|
220
|
+
}
|
|
221
|
+
decode(ids) {
|
|
222
|
+
const chunks = ids.map((id) => this.tokenBytes(id));
|
|
223
|
+
const n = chunks.reduce((a, c) => a + c.length, 0), all = new Uint8Array(n);
|
|
224
|
+
let o = 0; for (const c of chunks) { all.set(c, o); o += c.length; }
|
|
225
|
+
return new TextDecoder().decode(all);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function chatPrompt(messages, { enableThinking = true } = {}) {
|
|
230
|
+
let s = '';
|
|
231
|
+
for (const m of messages) s += `<|im_start|>${m.role}\n${m.content}<|im_end|>\n`;
|
|
232
|
+
s += '<|im_start|>assistant\n';
|
|
233
|
+
if (!enableThinking) s += '<think>\n\n</think>\n\n';
|
|
234
|
+
return s;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── WGSL ────────────────────────────────────────────────────────────────────
|
|
238
|
+
// Activations f32 throughout; Q8_0 weights as an int8 plane (u32-packed) + an f16 scale per
|
|
239
|
+
// 32 values, dequantized exactly in f32 inside the GEMV; KV cache f16 (llama.cpp's default).
|
|
240
|
+
export const TOK = 'struct Tok { token: u32, pos: u32, seqLen: u32, pad: u32 }';
|
|
241
|
+
export const I8 = `fn i8at(w: u32, lane: u32) -> f32 { let b = (w >> (lane * 8u)) & 0xffu; return f32(select(i32(b), i32(b) - 256, b >= 128u)); }`;
|
|
242
|
+
const WGSL = {
|
|
243
|
+
embedQ8: `enable f16;
|
|
244
|
+
${TOK}
|
|
245
|
+
struct P { n: u32 }
|
|
246
|
+
@group(0) @binding(0) var<storage, read> q: array<u32>;
|
|
247
|
+
@group(0) @binding(1) var<storage, read> s: array<f16>;
|
|
248
|
+
@group(0) @binding(2) var<storage, read_write> y: array<f32>;
|
|
249
|
+
@group(0) @binding(3) var<uniform> tok: Tok;
|
|
250
|
+
@group(0) @binding(4) var<uniform> p: P;
|
|
251
|
+
${I8}
|
|
252
|
+
@compute @workgroup_size(256) fn main(@builtin(global_invocation_id) g: vec3<u32>) {
|
|
253
|
+
let i = g.x; if (i >= p.n) { return; }
|
|
254
|
+
let e = tok.token * p.n + i;
|
|
255
|
+
y[i] = i8at(q[e >> 2u], e & 3u) * f32(s[e >> 5u]);
|
|
256
|
+
}`,
|
|
257
|
+
rmsnorm: `
|
|
258
|
+
struct P { n: u32, eps: f32 }
|
|
259
|
+
@group(0) @binding(0) var<storage, read> x: array<f32>;
|
|
260
|
+
@group(0) @binding(1) var<storage, read> w: array<f32>;
|
|
261
|
+
@group(0) @binding(2) var<storage, read_write> y: array<f32>;
|
|
262
|
+
@group(0) @binding(3) var<uniform> p: P;
|
|
263
|
+
var<workgroup> red: array<f32, 256>;
|
|
264
|
+
@compute @workgroup_size(256) fn main(@builtin(local_invocation_id) l: vec3<u32>) {
|
|
265
|
+
let t = l.x; var acc = 0.0;
|
|
266
|
+
for (var i = t; i < p.n; i += 256u) { let v = x[i]; acc += v * v; }
|
|
267
|
+
red[t] = acc; workgroupBarrier();
|
|
268
|
+
for (var s = 128u; s > 0u; s >>= 1u) { if (t < s) { red[t] += red[t + s]; } workgroupBarrier(); }
|
|
269
|
+
let scale = 1.0 / sqrt(red[0] / f32(p.n) + p.eps);
|
|
270
|
+
for (var i = t; i < p.n; i += 256u) { y[i] = (x[i] * scale) * w[i]; }
|
|
271
|
+
}`,
|
|
272
|
+
// 4 output rows per workgroup; input read once per k. Dispatch ceil(M/4) workgroups (2-D ok).
|
|
273
|
+
matmulQ8: `enable f16;
|
|
274
|
+
struct P { M: u32, N: u32 }
|
|
275
|
+
@group(0) @binding(0) var<storage, read> x: array<f32>;
|
|
276
|
+
@group(0) @binding(1) var<storage, read> qw: array<u32>;
|
|
277
|
+
@group(0) @binding(2) var<storage, read> qs: array<f16>;
|
|
278
|
+
@group(0) @binding(3) var<storage, read_write> y: array<f32>;
|
|
279
|
+
@group(0) @binding(4) var<uniform> p: P;
|
|
280
|
+
${I8}
|
|
281
|
+
var<workgroup> part: array<f32, 1024>;
|
|
282
|
+
@compute @workgroup_size(256) fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(num_workgroups) ng: vec3<u32>, @builtin(local_invocation_id) l: vec3<u32>) {
|
|
283
|
+
let m0 = (wg.y * ng.x + wg.x) * 4u; let t = l.x; let N = p.N; let M = p.M; let nb = N / 32u;
|
|
284
|
+
if (m0 >= M) { return; }
|
|
285
|
+
let r0 = min(m0, M - 1u); let r1 = min(m0 + 1u, M - 1u); let r2 = min(m0 + 2u, M - 1u); let r3 = min(m0 + 3u, M - 1u);
|
|
286
|
+
let q0 = (r0 * N) >> 2u; let q1 = (r1 * N) >> 2u; let q2 = (r2 * N) >> 2u; let q3 = (r3 * N) >> 2u;
|
|
287
|
+
var a0 = 0.0; var a1 = 0.0; var a2 = 0.0; var a3 = 0.0;
|
|
288
|
+
for (var k = t; k < N; k += 256u) {
|
|
289
|
+
let xv = x[k]; let wi = k >> 2u; let ln = k & 3u; let b = k >> 5u;
|
|
290
|
+
a0 += i8at(qw[q0 + wi], ln) * f32(qs[r0 * nb + b]) * xv;
|
|
291
|
+
a1 += i8at(qw[q1 + wi], ln) * f32(qs[r1 * nb + b]) * xv;
|
|
292
|
+
a2 += i8at(qw[q2 + wi], ln) * f32(qs[r2 * nb + b]) * xv;
|
|
293
|
+
a3 += i8at(qw[q3 + wi], ln) * f32(qs[r3 * nb + b]) * xv;
|
|
294
|
+
}
|
|
295
|
+
part[t] = a0; part[256u + t] = a1; part[512u + t] = a2; part[768u + t] = a3;
|
|
296
|
+
workgroupBarrier();
|
|
297
|
+
for (var s = 128u; s > 0u; s >>= 1u) {
|
|
298
|
+
if (t < s) { part[t] += part[t + s]; part[256u + t] += part[256u + t + s]; part[512u + t] += part[512u + t + s]; part[768u + t] += part[768u + t + s]; }
|
|
299
|
+
workgroupBarrier();
|
|
300
|
+
}
|
|
301
|
+
if (t < 4u && m0 + t < M) { y[m0 + t] = part[t * 256u]; }
|
|
302
|
+
}`,
|
|
303
|
+
matmulF32: `
|
|
304
|
+
struct P { M: u32, N: u32 }
|
|
305
|
+
@group(0) @binding(0) var<storage, read> x: array<f32>;
|
|
306
|
+
@group(0) @binding(1) var<storage, read> w: array<f32>;
|
|
307
|
+
@group(0) @binding(2) var<storage, read_write> y: array<f32>;
|
|
308
|
+
@group(0) @binding(3) var<uniform> p: P;
|
|
309
|
+
var<workgroup> red: array<f32, 256>;
|
|
310
|
+
@compute @workgroup_size(256) fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_id) l: vec3<u32>) {
|
|
311
|
+
let m = wg.x; let t = l.x; var acc = 0.0;
|
|
312
|
+
for (var k = t; k < p.N; k += 256u) { acc += w[m * p.N + k] * x[k]; }
|
|
313
|
+
red[t] = acc; workgroupBarrier();
|
|
314
|
+
for (var s = 128u; s > 0u; s >>= 1u) { if (t < s) { red[t] += red[t + s]; } workgroupBarrier(); }
|
|
315
|
+
if (t == 0u) { y[m] = red[0]; }
|
|
316
|
+
}`,
|
|
317
|
+
// Per-head RMSNorm (QK-norm) then NeoX RoPE (pairs i, i+hd/2), in place. One workgroup per head.
|
|
318
|
+
// rope[0..hd/2) = cos, rope[hd/2..hd) = sin for this token's position (computed on the CPU).
|
|
319
|
+
qkNormRope: `
|
|
320
|
+
struct P { hd: u32, eps: f32 }
|
|
321
|
+
@group(0) @binding(0) var<storage, read_write> x: array<f32>;
|
|
322
|
+
@group(0) @binding(1) var<storage, read> w: array<f32>;
|
|
323
|
+
@group(0) @binding(2) var<storage, read> rope: array<f32>;
|
|
324
|
+
@group(0) @binding(3) var<uniform> p: P;
|
|
325
|
+
var<workgroup> red: array<f32, 64>;
|
|
326
|
+
@compute @workgroup_size(64) fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_id) l: vec3<u32>) {
|
|
327
|
+
let base = wg.x * p.hd; let t = l.x; let half = p.hd / 2u;
|
|
328
|
+
var acc = 0.0;
|
|
329
|
+
for (var i = t; i < p.hd; i += 64u) { let v = x[base + i]; acc += v * v; }
|
|
330
|
+
red[t] = acc; workgroupBarrier();
|
|
331
|
+
for (var s = 32u; s > 0u; s >>= 1u) { if (t < s) { red[t] += red[t + s]; } workgroupBarrier(); }
|
|
332
|
+
let scale = 1.0 / sqrt(red[0] / f32(p.hd) + p.eps);
|
|
333
|
+
for (var i = t; i < half; i += 64u) {
|
|
334
|
+
let a = (x[base + i] * scale) * w[i];
|
|
335
|
+
let b = (x[base + i + half] * scale) * w[i + half];
|
|
336
|
+
let c = rope[i]; let sn = rope[half + i];
|
|
337
|
+
x[base + i] = a * c - b * sn;
|
|
338
|
+
x[base + i + half] = a * sn + b * c;
|
|
339
|
+
}
|
|
340
|
+
}`,
|
|
341
|
+
kvStore: `enable f16;
|
|
342
|
+
${TOK}
|
|
343
|
+
struct P { n: u32 }
|
|
344
|
+
@group(0) @binding(0) var<storage, read> k: array<f32>;
|
|
345
|
+
@group(0) @binding(1) var<storage, read> v: array<f32>;
|
|
346
|
+
@group(0) @binding(2) var<storage, read_write> kc: array<f16>;
|
|
347
|
+
@group(0) @binding(3) var<storage, read_write> vc: array<f16>;
|
|
348
|
+
@group(0) @binding(4) var<uniform> tok: Tok;
|
|
349
|
+
@group(0) @binding(5) var<uniform> p: P;
|
|
350
|
+
@compute @workgroup_size(256) fn main(@builtin(global_invocation_id) g: vec3<u32>) {
|
|
351
|
+
let i = g.x; if (i >= p.n) { return; }
|
|
352
|
+
kc[tok.pos * p.n + i] = f16(k[i]);
|
|
353
|
+
vc[tok.pos * p.n + i] = f16(v[i]);
|
|
354
|
+
}`,
|
|
355
|
+
attnScore: `enable f16;
|
|
356
|
+
${TOK}
|
|
357
|
+
struct P { heads: u32, kvHeads: u32, hd: u32, scale: f32 }
|
|
358
|
+
@group(0) @binding(0) var<storage, read> q: array<f32>;
|
|
359
|
+
@group(0) @binding(1) var<storage, read> kc: array<f16>;
|
|
360
|
+
@group(0) @binding(2) var<storage, read_write> sc: array<f32>;
|
|
361
|
+
@group(0) @binding(3) var<uniform> tok: Tok;
|
|
362
|
+
@group(0) @binding(4) var<uniform> p: P;
|
|
363
|
+
@compute @workgroup_size(256) fn main(@builtin(global_invocation_id) g: vec3<u32>) {
|
|
364
|
+
let n = tok.seqLen; let idx = g.x; if (idx >= p.heads * n) { return; }
|
|
365
|
+
let h = idx / n; let pos = idx % n; let kvh = h / (p.heads / p.kvHeads);
|
|
366
|
+
let qo = h * p.hd; let ko = pos * p.kvHeads * p.hd + kvh * p.hd;
|
|
367
|
+
var acc = 0.0;
|
|
368
|
+
for (var d = 0u; d < p.hd; d++) { acc += q[qo + d] * f32(kc[ko + d]); }
|
|
369
|
+
sc[h * n + pos] = acc * p.scale;
|
|
370
|
+
}`,
|
|
371
|
+
softmax: `
|
|
372
|
+
${TOK}
|
|
373
|
+
@group(0) @binding(0) var<storage, read_write> sc: array<f32>;
|
|
374
|
+
@group(0) @binding(1) var<uniform> tok: Tok;
|
|
375
|
+
var<workgroup> red: array<f32, 256>;
|
|
376
|
+
@compute @workgroup_size(256) fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_id) l: vec3<u32>) {
|
|
377
|
+
let n = tok.seqLen; let base = wg.x * n; let t = l.x;
|
|
378
|
+
var mx = -3.4e38;
|
|
379
|
+
for (var i = t; i < n; i += 256u) { mx = max(mx, sc[base + i]); }
|
|
380
|
+
red[t] = mx; workgroupBarrier();
|
|
381
|
+
for (var s = 128u; s > 0u; s >>= 1u) { if (t < s) { red[t] = max(red[t], red[t + s]); } workgroupBarrier(); }
|
|
382
|
+
let m = red[0]; workgroupBarrier();
|
|
383
|
+
var sum = 0.0;
|
|
384
|
+
for (var i = t; i < n; i += 256u) { let e = exp(sc[base + i] - m); sc[base + i] = e; sum += e; }
|
|
385
|
+
red[t] = sum; workgroupBarrier();
|
|
386
|
+
for (var s = 128u; s > 0u; s >>= 1u) { if (t < s) { red[t] += red[t + s]; } workgroupBarrier(); }
|
|
387
|
+
let inv = 1.0 / red[0];
|
|
388
|
+
for (var i = t; i < n; i += 256u) { sc[base + i] = sc[base + i] * inv; }
|
|
389
|
+
}`,
|
|
390
|
+
attnOut: `enable f16;
|
|
391
|
+
${TOK}
|
|
392
|
+
struct P { heads: u32, kvHeads: u32, hd: u32, pad: u32 }
|
|
393
|
+
@group(0) @binding(0) var<storage, read> pr: array<f32>;
|
|
394
|
+
@group(0) @binding(1) var<storage, read> vc: array<f16>;
|
|
395
|
+
@group(0) @binding(2) var<storage, read_write> y: array<f32>;
|
|
396
|
+
@group(0) @binding(3) var<uniform> tok: Tok;
|
|
397
|
+
@group(0) @binding(4) var<uniform> p: P;
|
|
398
|
+
@compute @workgroup_size(256) fn main(@builtin(global_invocation_id) g: vec3<u32>) {
|
|
399
|
+
let idx = g.x; if (idx >= p.heads * p.hd) { return; }
|
|
400
|
+
let n = tok.seqLen; let h = idx / p.hd; let d = idx % p.hd; let kvh = h / (p.heads / p.kvHeads);
|
|
401
|
+
let stride = p.kvHeads * p.hd;
|
|
402
|
+
var acc = 0.0;
|
|
403
|
+
for (var pos = 0u; pos < n; pos++) { acc += pr[h * n + pos] * f32(vc[pos * stride + kvh * p.hd + d]); }
|
|
404
|
+
y[idx] = acc;
|
|
405
|
+
}`,
|
|
406
|
+
addInPlace: `
|
|
407
|
+
struct P { n: u32 }
|
|
408
|
+
@group(0) @binding(0) var<storage, read_write> x: array<f32>;
|
|
409
|
+
@group(0) @binding(1) var<storage, read> y: array<f32>;
|
|
410
|
+
@group(0) @binding(2) var<uniform> p: P;
|
|
411
|
+
@compute @workgroup_size(256) fn main(@builtin(global_invocation_id) g: vec3<u32>) {
|
|
412
|
+
let i = g.x; if (i < p.n) { x[i] = x[i] + y[i]; }
|
|
413
|
+
}`,
|
|
414
|
+
// Router: softmax over n logits (n ≤ 256), greedy top-k (ties → lowest index), weights
|
|
415
|
+
// renormalized to sum 1 (Qwen3-MoE norm_topk_prob). sel = k ids then k f32 weights (bitcast).
|
|
416
|
+
// One workgroup; each of the k picks is a parallel argmax over the per-thread probabilities.
|
|
417
|
+
// Every probability is the same f32 expression the serial version evaluated, so the picks and
|
|
418
|
+
// weights are the same; the serial version cost ~0.1 ms per call on an M4 Pro.
|
|
419
|
+
topk: `
|
|
420
|
+
struct P { n: u32, k: u32 }
|
|
421
|
+
@group(0) @binding(0) var<storage, read> lg: array<f32>;
|
|
422
|
+
@group(0) @binding(1) var<storage, read_write> sel: array<u32>;
|
|
423
|
+
@group(0) @binding(2) var<uniform> p: P;
|
|
424
|
+
var<workgroup> red: array<f32, 256>;
|
|
425
|
+
var<workgroup> bi: array<u32, 256>;
|
|
426
|
+
var<workgroup> gmax: f32;
|
|
427
|
+
var<workgroup> gsum: f32;
|
|
428
|
+
var<workgroup> picks: array<u32, 32>;
|
|
429
|
+
var<workgroup> probs: array<f32, 32>;
|
|
430
|
+
@compute @workgroup_size(256) fn main(@builtin(local_invocation_id) l: vec3<u32>) {
|
|
431
|
+
let t = l.x; let n = p.n;
|
|
432
|
+
red[t] = select(-3.4e38, lg[min(t, n - 1u)], t < n); workgroupBarrier();
|
|
433
|
+
for (var s = 128u; s > 0u; s >>= 1u) { if (t < s) { red[t] = max(red[t], red[t + s]); } workgroupBarrier(); }
|
|
434
|
+
if (t == 0u) { gmax = red[0]; } workgroupBarrier();
|
|
435
|
+
red[t] = select(0.0, exp(lg[min(t, n - 1u)] - gmax), t < n); workgroupBarrier();
|
|
436
|
+
for (var s = 128u; s > 0u; s >>= 1u) { if (t < s) { red[t] += red[t + s]; } workgroupBarrier(); }
|
|
437
|
+
if (t == 0u) { gsum = red[0]; } workgroupBarrier();
|
|
438
|
+
var mine = select(-1.0, exp(lg[min(t, n - 1u)] - gmax) / gsum, t < n);
|
|
439
|
+
for (var j = 0u; j < p.k; j++) {
|
|
440
|
+
red[t] = mine; bi[t] = t; workgroupBarrier();
|
|
441
|
+
for (var s = 128u; s > 0u; s >>= 1u) {
|
|
442
|
+
if (t < s) { let o = red[t + s]; if (o > red[t] || (o == red[t] && bi[t + s] < bi[t])) { red[t] = o; bi[t] = bi[t + s]; } }
|
|
443
|
+
workgroupBarrier();
|
|
444
|
+
}
|
|
445
|
+
if (t == 0u) { picks[j] = bi[0]; probs[j] = red[0]; }
|
|
446
|
+
workgroupBarrier();
|
|
447
|
+
if (t == picks[j]) { mine = -1.0; }
|
|
448
|
+
workgroupBarrier();
|
|
449
|
+
}
|
|
450
|
+
if (t == 0u) {
|
|
451
|
+
var wsum = 0.0;
|
|
452
|
+
for (var j = 0u; j < p.k; j++) { wsum += probs[j]; }
|
|
453
|
+
for (var j = 0u; j < p.k; j++) { sel[j] = picks[j]; sel[p.k + j] = bitcast<u32>(probs[j] / wsum); }
|
|
454
|
+
}
|
|
455
|
+
}`,
|
|
456
|
+
// Routed experts, all k at once: workgroup z = top-k position, slot = slots[z] picks the
|
|
457
|
+
// expert's rows in the pool buffer (rows_per_expert per slot). Input is shared (gate/up) or
|
|
458
|
+
// per-position (down). Output region z × M.
|
|
459
|
+
expertQ8: `enable f16;
|
|
460
|
+
struct P { M: u32, N: u32, rowsPerExpert: u32, inputPerSlot: u32 }
|
|
461
|
+
@group(0) @binding(0) var<storage, read> x: array<f32>;
|
|
462
|
+
@group(0) @binding(1) var<storage, read> qw: array<u32>;
|
|
463
|
+
@group(0) @binding(2) var<storage, read> qs: array<f16>;
|
|
464
|
+
@group(0) @binding(3) var<storage, read_write> y: array<f32>;
|
|
465
|
+
@group(0) @binding(4) var<uniform> p: P;
|
|
466
|
+
@group(0) @binding(5) var<storage, read> slots: array<u32>;
|
|
467
|
+
${I8}
|
|
468
|
+
var<workgroup> part: array<f32, 1024>;
|
|
469
|
+
@compute @workgroup_size(256) fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(num_workgroups) ng: vec3<u32>, @builtin(local_invocation_id) l: vec3<u32>) {
|
|
470
|
+
let z = wg.z; let m0 = (wg.y * ng.x + wg.x) * 4u; let t = l.x; let N = p.N; let M = p.M; let nb = N / 32u;
|
|
471
|
+
if (m0 >= M) { return; }
|
|
472
|
+
let rb = slots[z] * p.rowsPerExpert;
|
|
473
|
+
let xb = select(0u, z * N, p.inputPerSlot == 1u);
|
|
474
|
+
let r0 = rb + min(m0, M - 1u); let r1 = rb + min(m0 + 1u, M - 1u); let r2 = rb + min(m0 + 2u, M - 1u); let r3 = rb + min(m0 + 3u, M - 1u);
|
|
475
|
+
let q0 = (r0 * N) >> 2u; let q1 = (r1 * N) >> 2u; let q2 = (r2 * N) >> 2u; let q3 = (r3 * N) >> 2u;
|
|
476
|
+
var a0 = 0.0; var a1 = 0.0; var a2 = 0.0; var a3 = 0.0;
|
|
477
|
+
for (var k = t; k < N; k += 256u) {
|
|
478
|
+
let xv = x[xb + k]; let wi = k >> 2u; let ln = k & 3u; let b = k >> 5u;
|
|
479
|
+
a0 += i8at(qw[q0 + wi], ln) * f32(qs[r0 * nb + b]) * xv;
|
|
480
|
+
a1 += i8at(qw[q1 + wi], ln) * f32(qs[r1 * nb + b]) * xv;
|
|
481
|
+
a2 += i8at(qw[q2 + wi], ln) * f32(qs[r2 * nb + b]) * xv;
|
|
482
|
+
a3 += i8at(qw[q3 + wi], ln) * f32(qs[r3 * nb + b]) * xv;
|
|
483
|
+
}
|
|
484
|
+
part[t] = a0; part[256u + t] = a1; part[512u + t] = a2; part[768u + t] = a3;
|
|
485
|
+
workgroupBarrier();
|
|
486
|
+
for (var s = 128u; s > 0u; s >>= 1u) {
|
|
487
|
+
if (t < s) { part[t] += part[t + s]; part[256u + t] += part[256u + t + s]; part[512u + t] += part[512u + t + s]; part[768u + t] += part[768u + t + s]; }
|
|
488
|
+
workgroupBarrier();
|
|
489
|
+
}
|
|
490
|
+
if (t < 4u && m0 + t < M) { y[z * M + m0 + t] = part[t * 256u]; }
|
|
491
|
+
}`,
|
|
492
|
+
// gu = k × [gate F | up F] → act = k × F of SiLU(gate)·up.
|
|
493
|
+
siluMulMoe: `
|
|
494
|
+
struct P { F: u32, k: u32 }
|
|
495
|
+
@group(0) @binding(0) var<storage, read> gu: array<f32>;
|
|
496
|
+
@group(0) @binding(1) var<storage, read_write> act: array<f32>;
|
|
497
|
+
@group(0) @binding(2) var<uniform> p: P;
|
|
498
|
+
@compute @workgroup_size(256) fn main(@builtin(global_invocation_id) g: vec3<u32>) {
|
|
499
|
+
let i = g.x; if (i >= p.k * p.F) { return; }
|
|
500
|
+
let z = i / p.F; let j = i % p.F; let g0 = gu[z * 2u * p.F + j];
|
|
501
|
+
act[i] = (g0 / (1.0 + exp(-g0))) * gu[z * 2u * p.F + p.F + j];
|
|
502
|
+
}`,
|
|
503
|
+
// x[h] += Σ_j w_j · out[j·H + h] (the expert sum, then the residual add — llama.cpp's order)
|
|
504
|
+
moeAccum: `
|
|
505
|
+
struct P { H: u32, k: u32 }
|
|
506
|
+
@group(0) @binding(0) var<storage, read> out: array<f32>;
|
|
507
|
+
@group(0) @binding(1) var<storage, read> sel: array<u32>;
|
|
508
|
+
@group(0) @binding(2) var<storage, read_write> x: array<f32>;
|
|
509
|
+
@group(0) @binding(3) var<uniform> p: P;
|
|
510
|
+
@compute @workgroup_size(256) fn main(@builtin(global_invocation_id) g: vec3<u32>) {
|
|
511
|
+
let h = g.x; if (h >= p.H) { return; }
|
|
512
|
+
var acc = 0.0;
|
|
513
|
+
for (var j = 0u; j < p.k; j++) { acc += bitcast<f32>(sel[p.k + j]) * out[j * p.H + h]; }
|
|
514
|
+
x[h] = x[h] + acc;
|
|
515
|
+
}`,
|
|
516
|
+
argmax: `
|
|
517
|
+
struct P { n: u32 }
|
|
518
|
+
@group(0) @binding(0) var<storage, read> lg: array<f32>;
|
|
519
|
+
@group(0) @binding(1) var<storage, read_write> res: array<u32>;
|
|
520
|
+
@group(0) @binding(2) var<uniform> p: P;
|
|
521
|
+
var<workgroup> bv: array<f32, 256>;
|
|
522
|
+
var<workgroup> bi: array<u32, 256>;
|
|
523
|
+
@compute @workgroup_size(256) fn main(@builtin(local_invocation_id) l: vec3<u32>) {
|
|
524
|
+
let t = l.x; var v = -3.4e38; var ix = 0u;
|
|
525
|
+
for (var i = t; i < p.n; i += 256u) { let x = lg[i]; if (x > v) { v = x; ix = i; } }
|
|
526
|
+
bv[t] = v; bi[t] = ix; workgroupBarrier();
|
|
527
|
+
for (var s = 128u; s > 0u; s >>= 1u) {
|
|
528
|
+
if (t < s) { let o = bv[t + s]; if (o > bv[t] || (o == bv[t] && bi[t + s] < bi[t])) { bv[t] = o; bi[t] = bi[t + s]; } }
|
|
529
|
+
workgroupBarrier();
|
|
530
|
+
}
|
|
531
|
+
if (t == 0u) { res[0] = bi[0]; }
|
|
532
|
+
}`,
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
export const STORAGE = 0x80, COPY_SRC = 0x04, COPY_DST = 0x08, UNIFORM = 0x40, MAP_READ = 0x01;
|
|
536
|
+
|
|
537
|
+
// ── Engine ──────────────────────────────────────────────────────────────────
|
|
538
|
+
export class Qwen3MoeSsd {
|
|
539
|
+
// A subclass for another architecture overrides these (gemma4_moe_ssd.js).
|
|
540
|
+
static configFrom(kv) { return configFromGguf(kv); }
|
|
541
|
+
static get ingestPlan() { return undefined; }
|
|
542
|
+
static tokenizerFrom(kv) { return new BpeTokenizer(kv); }
|
|
543
|
+
|
|
544
|
+
static async load(modelId = QWEN3_30B_A3B.repo, opts = {}) {
|
|
545
|
+
const { onProgress = () => {}, signal, localFile } = opts;
|
|
546
|
+
const src = opts.source || (modelId === QWEN3_30B_A3B.repo || !modelId ? QWEN3_30B_A3B : { repo: modelId, file: opts.file, revision: opts.revision || 'main' });
|
|
547
|
+
// localFile: the same GGUF already on disk (a File the user picked), ingested instead of downloaded. A size
|
|
548
|
+
// other than the pinned file's is refused before any write; the GGUF header check in the ingest plan follows.
|
|
549
|
+
if (localFile && src.size && localFile.size !== src.size) {
|
|
550
|
+
throw new Error(`${localFile.name} is ${localFile.size.toLocaleString()} bytes, not ${src.file} (${src.size.toLocaleString()} bytes). Pick that file.`);
|
|
551
|
+
}
|
|
552
|
+
const fetchFn = localFile ? fileFetch(localFile) : (opts.fetch || ((u, i) => fetch(u, i)));
|
|
553
|
+
const url = opts.url || `https://huggingface.co/${src.repo}/resolve/${src.revision}/${src.file}`;
|
|
554
|
+
const key = opts.key || storeKey(opts.file || src.file || url.split('/').pop());
|
|
555
|
+
const root = opts.root || OPFS_ROOT; // the OPFS folder for this engine's stores
|
|
556
|
+
const dir = `${root}/${key}`;
|
|
557
|
+
|
|
558
|
+
onProgress({ status: 'init' });
|
|
559
|
+
if (!navigator.gpu) throw new Error('WebGPU is not available');
|
|
560
|
+
const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
|
|
561
|
+
if (!adapter) throw new Error('no WebGPU adapter');
|
|
562
|
+
if (!adapter.features.has('shader-f16')) throw new Error('this GPU lacks shader-f16');
|
|
563
|
+
const L = adapter.limits;
|
|
564
|
+
// subgroups: optional; an engine may use subgroup kernels when the adapter offers them.
|
|
565
|
+
const features = ['shader-f16', ...(adapter.features.has('subgroups') ? ['subgroups'] : [])];
|
|
566
|
+
const device = await adapter.requestDevice({
|
|
567
|
+
requiredFeatures: features,
|
|
568
|
+
requiredLimits: { maxBufferSize: L.maxBufferSize, maxStorageBufferBindingSize: L.maxStorageBufferBindingSize, maxStorageBuffersPerShaderStage: L.maxStorageBuffersPerShaderStage, maxComputeWorkgroupsPerDimension: L.maxComputeWorkgroupsPerDimension },
|
|
569
|
+
});
|
|
570
|
+
device.lost.then((info) => console.warn('qwen3-moe-ssd: GPU device lost:', info.message));
|
|
571
|
+
|
|
572
|
+
let manifest = null;
|
|
573
|
+
try { manifest = JSON.parse(await readOpfsText(`${dir}/manifest.json`) || 'null'); } catch (_) { manifest = null; }
|
|
574
|
+
if (opts.reingest || !manifest || !manifest.complete || manifest.format !== FORMAT) {
|
|
575
|
+
manifest = await ingestGguf({
|
|
576
|
+
url, key, root, fetch: fetchFn, signal, source: { repo: src.repo, file: src.file, revision: src.revision, sha256: src.sha256, size: src.size, ...(localFile ? { localFile: localFile.name } : {}) },
|
|
577
|
+
onProgress: (e) => onProgress(ingestProgress(e)),
|
|
578
|
+
plan: this.ingestPlan,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
const headerFile = await (await (await navigator.storage.getDirectory()).getDirectoryHandle(root)).getDirectoryHandle(key);
|
|
582
|
+
const headerBytes = new Uint8Array(await (await (await headerFile.getFileHandle('header.bin')).getFile()).arrayBuffer());
|
|
583
|
+
const gguf = parseGguf(headerBytes);
|
|
584
|
+
const engine = new this(device, manifest, gguf, { ...opts, dir, key, url, adapterInfo: adapter.info || {} });
|
|
585
|
+
await engine.init(onProgress);
|
|
586
|
+
return engine;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
constructor(device, manifest, gguf, opts) {
|
|
590
|
+
this.device = device;
|
|
591
|
+
this.manifest = manifest;
|
|
592
|
+
this.cfg = this.constructor.configFrom(gguf.kv);
|
|
593
|
+
this.tokenizer = this.constructor.tokenizerFrom(gguf.kv);
|
|
594
|
+
this.opts = opts;
|
|
595
|
+
this.maxCtx = Math.min(opts.maxCtx || 4096, this.cfg.contextLength);
|
|
596
|
+
this.gpuBytes = { dense: 0, pool: 0, kv: 0, act: 0 };
|
|
597
|
+
this.pipelines = {};
|
|
598
|
+
this.position = 0;
|
|
599
|
+
this.cached = []; // token ids whose K/V are in the cache, in order
|
|
600
|
+
// Prefetch: routers of the next `lookahead` layers guess their experts from this layer's
|
|
601
|
+
// post-attention state. Measured 2026-10-04 (full model, 4 GB pool, M4 Pro): off 4.41,
|
|
602
|
+
// lookahead 1 5.02, 2 5.48, 4 4.56, 6 3.90 tok/s — guesses beyond 2 layers waste SSD bandwidth.
|
|
603
|
+
this.prefetch = opts.prefetch ?? true;
|
|
604
|
+
this.lookahead = Math.max(1, Math.floor(opts.lookahead ?? 2));
|
|
605
|
+
this.resetCounters();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
buffer(size, usage, cat = 'act') {
|
|
609
|
+
const b = this.device.createBuffer({ size: Math.ceil(size / 4) * 4, usage });
|
|
610
|
+
this.gpuBytes[cat] = (this.gpuBytes[cat] || 0) + b.size;
|
|
611
|
+
return b;
|
|
612
|
+
}
|
|
613
|
+
uniform(words) {
|
|
614
|
+
const b = this.buffer(Math.max(16, words.length * 4), UNIFORM | COPY_DST, 'act');
|
|
615
|
+
const ab = new ArrayBuffer(b.size), u = new Uint32Array(ab), f = new Float32Array(ab);
|
|
616
|
+
words.forEach((w, i) => { if (typeof w === 'object') f[i] = w.f; else u[i] = w; });
|
|
617
|
+
this.device.queue.writeBuffer(b, 0, ab);
|
|
618
|
+
return b;
|
|
619
|
+
}
|
|
620
|
+
// The kernel sources; a subclass adds its own.
|
|
621
|
+
get kernels() { return WGSL; }
|
|
622
|
+
pipeline(name) {
|
|
623
|
+
if (!this.pipelines[name]) {
|
|
624
|
+
const code = this.kernels[name];
|
|
625
|
+
if (!code) throw new Error(`no kernel ${name}`);
|
|
626
|
+
const module = this.device.createShaderModule({ code, label: name });
|
|
627
|
+
this.pipelines[name] = this.device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'main' }, label: name });
|
|
628
|
+
}
|
|
629
|
+
return this.pipelines[name];
|
|
630
|
+
}
|
|
631
|
+
bind(name, buffers) {
|
|
632
|
+
return this.device.createBindGroup({
|
|
633
|
+
layout: this.pipeline(name).getBindGroupLayout(0),
|
|
634
|
+
entries: buffers.map((b, i) => ({ binding: i, resource: { buffer: b } })),
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async init(onProgress) {
|
|
639
|
+
const dev = this.device;
|
|
640
|
+
// Fail early on a shader that does not compile.
|
|
641
|
+
dev.pushErrorScope('validation');
|
|
642
|
+
for (const k of Object.keys(this.kernels)) this.pipeline(k);
|
|
643
|
+
const err = await dev.popErrorScope();
|
|
644
|
+
if (err) throw new Error(`WGSL: ${err.message}`);
|
|
645
|
+
await this.uploadDense(onProgress);
|
|
646
|
+
await this.initPool();
|
|
647
|
+
this.initBuffers();
|
|
648
|
+
await dev.queue.onSubmittedWorkDone();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Dense weights: OPFS → GPU (this.w[name] = { q, s, rows, cols } or { raw }).
|
|
652
|
+
async uploadDense(onProgress) {
|
|
653
|
+
const dev = this.device, m = this.manifest;
|
|
654
|
+
const t0 = performance.now();
|
|
655
|
+
const reader = await OpfsReaderPool.open(`${this.opts.dir}/${m.dense.file}`, { workers: 4 });
|
|
656
|
+
const names = Object.keys(m.dense.tensors);
|
|
657
|
+
this.w = {};
|
|
658
|
+
let doneT = 0;
|
|
659
|
+
const CH = 64 << 20;
|
|
660
|
+
const upload = async (gbuf, part) => {
|
|
661
|
+
for (let o = 0; o < part.bytes; o += CH) {
|
|
662
|
+
const n = Math.min(CH, part.bytes - o);
|
|
663
|
+
const r = await reader.read(part.off + o, n);
|
|
664
|
+
if (r.got !== n) throw new Error(`dense read short at ${part.off + o}`);
|
|
665
|
+
dev.queue.writeBuffer(gbuf, o, r.buf, 0, n);
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
const queue = [];
|
|
669
|
+
for (const name of names) {
|
|
670
|
+
const d = m.dense.tensors[name];
|
|
671
|
+
if (d.type === 'q8' || d.type === 'q4') { // q4: Gemma 4 (two values per byte)
|
|
672
|
+
const q = this.buffer(d.q.bytes, STORAGE | COPY_DST, 'dense'), s = this.buffer(d.s.bytes, STORAGE | COPY_DST, 'dense');
|
|
673
|
+
this.w[name] = { q, s, rows: (d.type === 'q4' ? 2 * d.q.bytes : d.q.bytes) / d.dims[0], cols: d.dims[0] };
|
|
674
|
+
queue.push(() => upload(q, d.q), () => upload(s, d.s));
|
|
675
|
+
} else {
|
|
676
|
+
const raw = this.buffer(d.raw.bytes, STORAGE | COPY_DST, 'dense');
|
|
677
|
+
this.w[name] = { raw };
|
|
678
|
+
queue.push(() => upload(raw, d.raw));
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
let qi = 0;
|
|
682
|
+
await Promise.all([0, 1, 2, 3].map(async () => {
|
|
683
|
+
while (qi < queue.length) { await queue[qi++](); doneT++; onProgress({ status: 'weights', kind: 'tensors', loaded: doneT, total: queue.length }); }
|
|
684
|
+
}));
|
|
685
|
+
await reader.close();
|
|
686
|
+
await dev.queue.onSubmittedWorkDone();
|
|
687
|
+
this.denseUploadSecs = (performance.now() - t0) / 1000;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Bytes for the expert cache. With opts.gpuBudgetBytes, the cache gets what the budget leaves after the weights
|
|
691
|
+
// that stay on the GPU, the KV cache and working buffers; a budget too small for those is refused. Otherwise
|
|
692
|
+
// opts.poolBytes, default 4 GiB.
|
|
693
|
+
poolBudget() {
|
|
694
|
+
const o = this.opts;
|
|
695
|
+
if (!o.gpuBudgetBytes) return o.poolBytes || 4 * 2 ** 30;
|
|
696
|
+
const gb = (n) => (n / 1e9).toFixed(2) + ' GB';
|
|
697
|
+
const fixed = this.gpuBytes.dense + this.kvBytes() + (o.workBytes ?? 512 * 2 ** 20);
|
|
698
|
+
const rest = o.gpuBudgetBytes - fixed;
|
|
699
|
+
const min = this.cfg.topK * 2 * this.manifest.experts.record;
|
|
700
|
+
if (rest < min) throw new Error(`GPU budget ${gb(o.gpuBudgetBytes)} is too small: weights that stay on the GPU, the KV cache and working buffers need ${gb(fixed)}, and the expert cache at least ${gb(min)} more`);
|
|
701
|
+
return rest;
|
|
702
|
+
}
|
|
703
|
+
// KV cache bytes at this.maxCtx (f16 K and V for every layer); engines with other layouts override it.
|
|
704
|
+
kvBytes() { const c = this.cfg; return 2 * 2 * c.layers * this.maxCtx * c.kvHeads * c.headDim; }
|
|
705
|
+
|
|
706
|
+
// Expert slot pool + the streamer that feeds it.
|
|
707
|
+
async initPool() {
|
|
708
|
+
const c = this.cfg, dev = this.device, xp = this.manifest.experts;
|
|
709
|
+
const maxSlotsByBinding = Math.floor(dev.limits.maxStorageBufferBindingSize / xp.parts.guQ.bytes);
|
|
710
|
+
this.poolSlots = Math.max(c.topK * 2, Math.min(maxSlotsByBinding, Math.floor(this.poolBudget() / xp.record), c.layers * c.experts));
|
|
711
|
+
this.pool = {};
|
|
712
|
+
for (const [k, part] of Object.entries(xp.parts)) this.pool[k] = this.buffer(this.poolSlots * part.bytes, STORAGE | COPY_DST, 'pool');
|
|
713
|
+
this.expertReader = await OpfsReaderPool.open(`${this.opts.dir}/${xp.file}`, { workers: this.opts.readers || 4 });
|
|
714
|
+
this.xs = new ExpertStreamer({
|
|
715
|
+
device: dev, reader: this.expertReader, recordBytes: xp.record,
|
|
716
|
+
recordOffset: (layer, e) => (layer * xp.perLayer + e) * xp.record,
|
|
717
|
+
parts: ['guQ', 'guS', 'dQ', 'dS'].map((k) => ({ buffer: this.pool[k], srcOffset: xp.parts[k].off, bytes: xp.parts[k].bytes })),
|
|
718
|
+
slots: this.poolSlots, numLayers: c.layers, numExperts: c.experts,
|
|
719
|
+
...(this.opts.uploadRing !== undefined ? { uploadRing: this.opts.uploadRing } : {}),
|
|
720
|
+
...(this.opts.evict ? { evict: this.opts.evict, hotHalfLife: this.opts.hotHalfLife } : {}),
|
|
721
|
+
});
|
|
722
|
+
this.gpuBytes.staging = this.xs.ringBytes();
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// KV cache (f16), activations, uniforms and every layer's bind groups.
|
|
726
|
+
initBuffers() {
|
|
727
|
+
const c = this.cfg;
|
|
728
|
+
const kvn = c.kvHeads * c.headDim;
|
|
729
|
+
this.kc = []; this.vc = [];
|
|
730
|
+
for (let l = 0; l < c.layers; l++) {
|
|
731
|
+
this.kc.push(this.buffer(this.maxCtx * kvn * 2, STORAGE, 'kv'));
|
|
732
|
+
this.vc.push(this.buffer(this.maxCtx * kvn * 2, STORAGE, 'kv'));
|
|
733
|
+
}
|
|
734
|
+
const H = c.hidden, QN = c.heads * c.headDim, F = c.expertFf, K = c.topK;
|
|
735
|
+
if (2 * K * 4 > 256) throw new Error(`top-k ${K} > 32: selections would overlap their 256-byte readback regions`);
|
|
736
|
+
if (c.experts > 256) throw new Error(`${c.experts} experts: the router top-k kernel handles at most 256`);
|
|
737
|
+
const A = (n) => this.buffer(n * 4, STORAGE | COPY_SRC | COPY_DST);
|
|
738
|
+
this.a = {
|
|
739
|
+
x: A(H), xn: A(H), q: A(QN), k: A(kvn), v: A(kvn), att: A(QN), o: A(H), sc: A(c.heads * this.maxCtx),
|
|
740
|
+
rl: A(c.experts), sel: A(2 * K), pxn: A(H), prl: A(c.experts), psel: A(64 * this.lookahead), slots: A(K),
|
|
741
|
+
gu: A(K * 2 * F), act: A(K * F), dn: A(K * H), logits: A(c.vocab), am: A(4),
|
|
742
|
+
rope: A(c.headDim),
|
|
743
|
+
};
|
|
744
|
+
this.tok = this.buffer(16, UNIFORM | COPY_DST);
|
|
745
|
+
// Readback: this layer's selection at 0, guess d (layers ahead) at 256 × d bytes.
|
|
746
|
+
this.rbSel = this.buffer(256 * (1 + this.lookahead), MAP_READ | COPY_DST);
|
|
747
|
+
this.rbLogits = this.buffer(c.vocab * 4, MAP_READ | COPY_DST);
|
|
748
|
+
this.rbArg = this.buffer(16, MAP_READ | COPY_DST);
|
|
749
|
+
|
|
750
|
+
const u = {
|
|
751
|
+
nH: this.uniform([H]), rmsH: this.uniform([H, { f: c.eps }]), mmQ: this.uniform([QN, H]), mmKV: this.uniform([kvn, H]),
|
|
752
|
+
mmO: this.uniform([H, QN]), qk: this.uniform([c.headDim, { f: c.eps }]), kvn: this.uniform([kvn]),
|
|
753
|
+
att: this.uniform([c.heads, c.kvHeads, c.headDim, { f: 1 / Math.sqrt(c.headDim) }]), attOut: this.uniform([c.heads, c.kvHeads, c.headDim, 0]),
|
|
754
|
+
router: this.uniform([c.experts, H]), topk: this.uniform([c.experts, K]),
|
|
755
|
+
gu: this.uniform([2 * F, H, 2 * F, 0]), dn: this.uniform([H, F, H, 1]), silu: this.uniform([F, K]), acc: this.uniform([H, K]),
|
|
756
|
+
lm: this.uniform([c.vocab, H]), am: this.uniform([c.vocab]),
|
|
757
|
+
};
|
|
758
|
+
const a = this.a, W = this.w;
|
|
759
|
+
this.g = { embed: this.bind('embedQ8', [W['token_embd.weight'].q, W['token_embd.weight'].s, a.x, this.tok, u.nH]) };
|
|
760
|
+
this.layers = [];
|
|
761
|
+
for (let l = 0; l < c.layers; l++) {
|
|
762
|
+
const p = (n) => W[`blk.${l}.${n}.weight`];
|
|
763
|
+
this.layers.push({
|
|
764
|
+
rmsA: this.bind('rmsnorm', [a.x, p('attn_norm').raw, a.xn, u.rmsH]),
|
|
765
|
+
q: this.bind('matmulQ8', [a.xn, p('attn_q').q, p('attn_q').s, a.q, u.mmQ]),
|
|
766
|
+
k: this.bind('matmulQ8', [a.xn, p('attn_k').q, p('attn_k').s, a.k, u.mmKV]),
|
|
767
|
+
v: this.bind('matmulQ8', [a.xn, p('attn_v').q, p('attn_v').s, a.v, u.mmKV]),
|
|
768
|
+
ropeQ: this.bind('qkNormRope', [a.q, p('attn_q_norm').raw, a.rope, u.qk]),
|
|
769
|
+
ropeK: this.bind('qkNormRope', [a.k, p('attn_k_norm').raw, a.rope, u.qk]),
|
|
770
|
+
kv: this.bind('kvStore', [a.k, a.v, this.kc[l], this.vc[l], this.tok, u.kvn]),
|
|
771
|
+
score: this.bind('attnScore', [a.q, this.kc[l], a.sc, this.tok, u.att]),
|
|
772
|
+
soft: this.bind('softmax', [a.sc, this.tok]),
|
|
773
|
+
attOut: this.bind('attnOut', [a.sc, this.vc[l], a.att, this.tok, u.attOut]),
|
|
774
|
+
o: this.bind('matmulQ8', [a.att, p('attn_output').q, p('attn_output').s, a.o, u.mmO]),
|
|
775
|
+
addO: this.bind('addInPlace', [a.x, a.o, u.nH]),
|
|
776
|
+
rmsF: this.bind('rmsnorm', [a.x, p('ffn_norm').raw, a.xn, u.rmsH]),
|
|
777
|
+
router: this.bind('matmulF32', [a.xn, p('ffn_gate_inp').raw, a.rl, u.router]),
|
|
778
|
+
topk: this.bind('topk', [a.rl, a.sel, u.topk]),
|
|
779
|
+
// Routers of layers l+1..l+lookahead on this layer's post-attention state: the prefetch
|
|
780
|
+
// guesses, each written to its own 256-byte region of psel.
|
|
781
|
+
pf: Array.from({ length: Math.min(this.lookahead, c.layers - 1 - l) }, (_, i) => {
|
|
782
|
+
const at = (n) => W[`blk.${l + 1 + i}.${n}.weight`];
|
|
783
|
+
return {
|
|
784
|
+
rms: this.bind('rmsnorm', [a.x, at('ffn_norm').raw, a.pxn, u.rmsH]),
|
|
785
|
+
router: this.bind('matmulF32', [a.pxn, at('ffn_gate_inp').raw, a.prl, u.router]),
|
|
786
|
+
topk: this.device.createBindGroup({ layout: this.pipeline('topk').getBindGroupLayout(0), entries: [
|
|
787
|
+
{ binding: 0, resource: { buffer: a.prl } },
|
|
788
|
+
{ binding: 1, resource: { buffer: a.psel, offset: 256 * i, size: 8 * K } },
|
|
789
|
+
{ binding: 2, resource: { buffer: u.topk } },
|
|
790
|
+
] }),
|
|
791
|
+
};
|
|
792
|
+
}),
|
|
793
|
+
gu: this.bind('expertQ8', [a.xn, this.pool.guQ, this.pool.guS, a.gu, u.gu, a.slots]),
|
|
794
|
+
silu: this.bind('siluMulMoe', [a.gu, a.act, u.silu]),
|
|
795
|
+
dn: this.bind('expertQ8', [a.act, this.pool.dQ, this.pool.dS, a.dn, u.dn, a.slots]),
|
|
796
|
+
acc: this.bind('moeAccum', [a.dn, a.sel, a.x, u.acc]),
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
this.g.rmsOut = this.bind('rmsnorm', [a.x, W['output_norm.weight'].raw, a.xn, u.rmsH]);
|
|
800
|
+
this.g.lm = this.bind('matmulQ8', [a.xn, W['output.weight'].q, W['output.weight'].s, a.logits, u.lm]);
|
|
801
|
+
this.g.am = this.bind('argmax', [a.logits, a.am, u.am]);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ── forward ───────────────────────────────────────────────────────────────
|
|
805
|
+
dispatch(pass, name, group, x, y = 1, z = 1) {
|
|
806
|
+
pass.setPipeline(this.pipeline(name));
|
|
807
|
+
pass.setBindGroup(0, group);
|
|
808
|
+
if (x > 65535) { y = Math.ceil(x / 32768); x = 32768; }
|
|
809
|
+
pass.dispatchWorkgroups(x, y, z);
|
|
810
|
+
}
|
|
811
|
+
encodeAttention(pass, l, seqLen) {
|
|
812
|
+
const c = this.cfg, g = this.layers[l], d = (n, gr, x, y, z) => this.dispatch(pass, n, gr, x, y, z);
|
|
813
|
+
const QN = c.heads * c.headDim, kvn = c.kvHeads * c.headDim;
|
|
814
|
+
d('rmsnorm', g.rmsA, 1);
|
|
815
|
+
d('matmulQ8', g.q, Math.ceil(QN / 4)); d('matmulQ8', g.k, Math.ceil(kvn / 4)); d('matmulQ8', g.v, Math.ceil(kvn / 4));
|
|
816
|
+
d('qkNormRope', g.ropeQ, c.heads); d('qkNormRope', g.ropeK, c.kvHeads);
|
|
817
|
+
d('kvStore', g.kv, Math.ceil(kvn / 256));
|
|
818
|
+
d('attnScore', g.score, Math.ceil(c.heads * seqLen / 256));
|
|
819
|
+
d('softmax', g.soft, c.heads);
|
|
820
|
+
d('attnOut', g.attOut, Math.ceil(QN / 256));
|
|
821
|
+
d('matmulQ8', g.o, Math.ceil(c.hidden / 4));
|
|
822
|
+
d('addInPlace', g.addO, Math.ceil(c.hidden / 256));
|
|
823
|
+
if (this.prefetch) for (const p of g.pf) { d('rmsnorm', p.rms, 1); d('matmulF32', p.router, c.experts); d('topk', p.topk, 1); }
|
|
824
|
+
d('rmsnorm', g.rmsF, 1);
|
|
825
|
+
d('matmulF32', g.router, c.experts);
|
|
826
|
+
d('topk', g.topk, 1);
|
|
827
|
+
}
|
|
828
|
+
// The token's embedding into a.x, and the head (final norm, logits, argmax when wanted): a subclass
|
|
829
|
+
// with another embedding or head format overrides these two.
|
|
830
|
+
encodeEmbed(pass) { this.dispatch(pass, 'embedQ8', this.g.embed, Math.ceil(this.cfg.hidden / 256)); }
|
|
831
|
+
encodeHead(pass, want) {
|
|
832
|
+
this.dispatch(pass, 'rmsnorm', this.g.rmsOut, 1);
|
|
833
|
+
this.dispatch(pass, 'matmulQ8', this.g.lm, Math.ceil(this.cfg.vocab / 4));
|
|
834
|
+
if (want === 'argmax') this.dispatch(pass, 'argmax', this.g.am, 1);
|
|
835
|
+
}
|
|
836
|
+
encodeExperts(pass, l) {
|
|
837
|
+
const c = this.cfg, g = this.layers[l], d = (n, gr, x, y, z) => this.dispatch(pass, n, gr, x, y, z);
|
|
838
|
+
d('expertQ8', g.gu, Math.ceil(2 * c.expertFf / 4), 1, c.topK);
|
|
839
|
+
d('siluMulMoe', g.silu, Math.ceil(c.topK * c.expertFf / 256));
|
|
840
|
+
d('expertQ8', g.dn, Math.ceil(c.hidden / 4), 1, c.topK);
|
|
841
|
+
d('moeAccum', g.acc, Math.ceil(c.hidden / 256));
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
writeTokenUniforms(token, pos) {
|
|
845
|
+
const c = this.cfg;
|
|
846
|
+
this.device.queue.writeBuffer(this.tok, 0, new Uint32Array([token, pos, pos + 1, 0]));
|
|
847
|
+
// NeoX RoPE angles the way llama.cpp's Metal kernel forms them: f32 pos × base^(-2i/hd).
|
|
848
|
+
const half = c.headDim / 2, r = new Float32Array(c.headDim), inv = Math.fround(-1 / c.headDim);
|
|
849
|
+
for (let i = 0; i < half; i++) {
|
|
850
|
+
const th = Math.fround(pos * Math.fround(Math.pow(c.ropeTheta, Math.fround(inv * 2 * i))));
|
|
851
|
+
r[i] = Math.cos(th); r[half + i] = Math.sin(th);
|
|
852
|
+
}
|
|
853
|
+
this.device.queue.writeBuffer(this.a.rope, 0, r);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
resetCounters() {
|
|
857
|
+
this.counters = { tokens: 0, gpuWaitMs: 0, ensureMs: 0, encodeMs: 0, wallMs: 0, predHits: 0, predTotal: 0 };
|
|
858
|
+
if (this.xs) this.xs.resetStats();
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// → { ids, preds }: this layer's top-k ids and `nPred` guesses for the layers after it.
|
|
862
|
+
async readSel(nPred) {
|
|
863
|
+
const K = this.cfg.topK, bytes = 256 * (1 + nPred);
|
|
864
|
+
const t0 = performance.now();
|
|
865
|
+
await this.rbSel.mapAsync(1, 0, bytes);
|
|
866
|
+
this.counters.gpuWaitMs += performance.now() - t0;
|
|
867
|
+
const u = new Uint32Array(this.rbSel.getMappedRange(0, bytes).slice(0));
|
|
868
|
+
this.rbSel.unmap();
|
|
869
|
+
const preds = [];
|
|
870
|
+
for (let i = 1; i <= nPred; i++) preds.push(u.subarray(64 * i, 64 * i + K));
|
|
871
|
+
return { ids: u.subarray(0, K), preds };
|
|
872
|
+
}
|
|
873
|
+
// Encodes the readback of layer l's selection and its guesses.
|
|
874
|
+
copySel(enc, l) {
|
|
875
|
+
enc.copyBufferToBuffer(this.a.sel, 0, this.rbSel, 0, 8 * this.cfg.topK);
|
|
876
|
+
const n = this.prefetch ? this.layers[l].pf.length : 0;
|
|
877
|
+
if (n) enc.copyBufferToBuffer(this.a.psel, 0, this.rbSel, 256, 256 * n);
|
|
878
|
+
return n;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// One token through all layers at position this.position. want: 'none' | 'argmax' | 'logits'.
|
|
882
|
+
async step(token, want = 'argmax') {
|
|
883
|
+
const c = this.cfg, dev = this.device, K = c.topK;
|
|
884
|
+
if (this.position >= this.maxCtx) throw new Error(`context full (${this.maxCtx} tokens)`);
|
|
885
|
+
const tStart = performance.now();
|
|
886
|
+
const pos = this.position, seqLen = pos + 1;
|
|
887
|
+
this.writeTokenUniforms(token, pos);
|
|
888
|
+
let te = performance.now();
|
|
889
|
+
let enc = dev.createCommandEncoder(), pass = enc.beginComputePass();
|
|
890
|
+
this.encodeEmbed(pass);
|
|
891
|
+
this.encodeAttention(pass, 0, seqLen);
|
|
892
|
+
pass.end();
|
|
893
|
+
let nPred = this.copySel(enc, 0);
|
|
894
|
+
dev.queue.submit([enc.finish()]);
|
|
895
|
+
this.counters.encodeMs += performance.now() - te;
|
|
896
|
+
let predIds = null;
|
|
897
|
+
for (let l = 0; l < c.layers; l++) {
|
|
898
|
+
const { ids, preds } = await this.readSel(nPred);
|
|
899
|
+
if (predIds) { // accuracy of the one-layer-ahead guess made one layer earlier
|
|
900
|
+
let hit = 0; for (const e of ids) if (predIds.includes(e)) hit++;
|
|
901
|
+
this.counters.predHits += hit; this.counters.predTotal += K;
|
|
902
|
+
}
|
|
903
|
+
const tq = performance.now();
|
|
904
|
+
const ensuring = this.xs.ensure(l, ids);
|
|
905
|
+
preds.forEach((p, i) => this.xs.prefetch(l + 1 + i, p)); // nearest layer first
|
|
906
|
+
predIds = preds.length ? Array.from(preds[0]) : null;
|
|
907
|
+
const slots = await ensuring;
|
|
908
|
+
this.counters.ensureMs += performance.now() - tq;
|
|
909
|
+
dev.queue.writeBuffer(this.a.slots, 0, slots);
|
|
910
|
+
te = performance.now();
|
|
911
|
+
enc = dev.createCommandEncoder(); pass = enc.beginComputePass();
|
|
912
|
+
this.encodeExperts(pass, l);
|
|
913
|
+
if (l + 1 < c.layers) {
|
|
914
|
+
this.encodeAttention(pass, l + 1, seqLen);
|
|
915
|
+
pass.end();
|
|
916
|
+
nPred = this.copySel(enc, l + 1);
|
|
917
|
+
} else {
|
|
918
|
+
if (want !== 'none') this.encodeHead(pass, want);
|
|
919
|
+
pass.end();
|
|
920
|
+
if (want === 'argmax') enc.copyBufferToBuffer(this.a.am, 0, this.rbArg, 0, 4);
|
|
921
|
+
if (want === 'logits') enc.copyBufferToBuffer(this.a.logits, 0, this.rbLogits, 0, c.vocab * 4);
|
|
922
|
+
}
|
|
923
|
+
dev.queue.submit([enc.finish()]);
|
|
924
|
+
this.counters.encodeMs += performance.now() - te;
|
|
925
|
+
this.xs.release(slots);
|
|
926
|
+
}
|
|
927
|
+
this.position++;
|
|
928
|
+
this.cached.push(token);
|
|
929
|
+
let result = null;
|
|
930
|
+
const tw = performance.now();
|
|
931
|
+
if (want === 'argmax') {
|
|
932
|
+
await this.rbArg.mapAsync(1, 0, 4);
|
|
933
|
+
result = new Uint32Array(this.rbArg.getMappedRange(0, 4))[0];
|
|
934
|
+
this.rbArg.unmap();
|
|
935
|
+
} else if (want === 'logits') {
|
|
936
|
+
await this.rbLogits.mapAsync(1);
|
|
937
|
+
result = new Float32Array(this.rbLogits.getMappedRange().slice(0));
|
|
938
|
+
this.rbLogits.unmap();
|
|
939
|
+
} else {
|
|
940
|
+
await dev.queue.onSubmittedWorkDone();
|
|
941
|
+
}
|
|
942
|
+
this.counters.gpuWaitMs += performance.now() - tw;
|
|
943
|
+
this.counters.tokens++;
|
|
944
|
+
this.counters.wallMs += performance.now() - tStart;
|
|
945
|
+
return result;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
reset() { this.position = 0; this.cached = []; }
|
|
949
|
+
|
|
950
|
+
// Feeds `ids` after the cached prefix they share (re-prefilling only what changed); returns
|
|
951
|
+
// the logits/argmax of the last token.
|
|
952
|
+
async prefill(ids, want = 'argmax') {
|
|
953
|
+
let common = 0;
|
|
954
|
+
while (common < ids.length - 1 && common < this.cached.length && this.cached[common] === ids[common]) common++;
|
|
955
|
+
if (common < this.cached.length) { this.position = common; this.cached.length = common; }
|
|
956
|
+
let r = null;
|
|
957
|
+
for (let i = common; i < ids.length; i++) r = await this.step(ids[i], i === ids.length - 1 ? want : 'none');
|
|
958
|
+
return r;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Diagnostics for the llama.cpp comparison: greedy continuation of raw token ids, with the
|
|
962
|
+
// top-n logits at every generated position.
|
|
963
|
+
async greedy(ids, n, { top = 5 } = {}) {
|
|
964
|
+
this.reset();
|
|
965
|
+
const out = [], tops = [];
|
|
966
|
+
let logits = await this.prefill(ids, 'logits');
|
|
967
|
+
for (let i = 0; i < n; i++) {
|
|
968
|
+
const order = topN(logits, top);
|
|
969
|
+
let mx = -Infinity, sum = 0;
|
|
970
|
+
for (let j = 0; j < logits.length; j++) if (logits[j] > mx) mx = logits[j];
|
|
971
|
+
for (let j = 0; j < logits.length; j++) sum += Math.exp(logits[j] - mx);
|
|
972
|
+
const lse = mx + Math.log(sum);
|
|
973
|
+
for (const o of order) o.logprob = o.logit - lse;
|
|
974
|
+
tops.push(order);
|
|
975
|
+
const next = order[0].id;
|
|
976
|
+
out.push(next);
|
|
977
|
+
if (i + 1 < n) logits = await this.step(next, 'logits');
|
|
978
|
+
}
|
|
979
|
+
return { ids: out, tops };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
async *generate(messages, { maxNewTokens = 512, signal, enableThinking = true } = {}) {
|
|
983
|
+
const ids = this.tokenizer.encode(this.chatPrompt(messages, { enableThinking }));
|
|
984
|
+
const stops = new Set(this.stopTokenIds);
|
|
985
|
+
let next = await this.prefill(ids, 'argmax');
|
|
986
|
+
const outIds = [];
|
|
987
|
+
for (let i = 0; i < maxNewTokens; i++) {
|
|
988
|
+
if (signal && signal.aborted) break;
|
|
989
|
+
if (stops.has(next)) break;
|
|
990
|
+
outIds.push(next);
|
|
991
|
+
yield { text: this.tokenizer.decode(outIds), token: next, tokens: outIds.length };
|
|
992
|
+
if (this.position >= this.maxCtx) break;
|
|
993
|
+
next = await this.step(next, 'argmax');
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
chatPrompt(messages, opts) { return chatPrompt(messages, opts); }
|
|
998
|
+
get stopTokenIds() { return [this.cfg.eos, this.tokenizer.ids.get('<|im_end|>'), this.tokenizer.ids.get('<|endoftext|>')].filter((t) => t !== undefined); }
|
|
999
|
+
|
|
1000
|
+
// Raw ids of the reasoning delimiters, for hosts that re-mark the thought block.
|
|
1001
|
+
get thinkOpenTokenId() { return this.tokenizer.ids.get('<think>') ?? null; }
|
|
1002
|
+
get thinkCloseTokenId() { return this.tokenizer.ids.get('</think>') ?? null; }
|
|
1003
|
+
|
|
1004
|
+
stats() {
|
|
1005
|
+
const s = this.xs.stats, c = this.counters, n = Math.max(1, c.tokens);
|
|
1006
|
+
return {
|
|
1007
|
+
tokens: c.tokens, tokPerSec: c.tokens / (c.wallMs / 1000),
|
|
1008
|
+
msPerToken: c.wallMs / n, gpuWaitMsPerToken: c.gpuWaitMs / n, ensureMsPerToken: c.ensureMs / n, encodeMsPerToken: c.encodeMs / n,
|
|
1009
|
+
readMsPerToken: s.readMs / n, uploadMsPerToken: s.uploadMs / n,
|
|
1010
|
+
hitRate: (s.hits + s.lateHits) / Math.max(1, s.hits + s.lateHits + s.misses),
|
|
1011
|
+
hits: s.hits, lateHits: s.lateHits, misses: s.misses, evictions: s.evictions,
|
|
1012
|
+
bytesReadPerToken: s.bytesRead / n, prefetchIssued: s.prefetchIssued, prefetchUsed: s.prefetchUsed,
|
|
1013
|
+
prefetchAccuracy: c.predTotal ? c.predHits / c.predTotal : null,
|
|
1014
|
+
hitRateByLayer: Array.from(s.hitsByLayer, (h, l) => +(h / Math.max(1, h + s.missesByLayer[l])).toFixed(3)),
|
|
1015
|
+
poolSlots: this.poolSlots, resident: this.xs.resident(), gpuBytes: { ...this.gpuBytes },
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async warmup() {}
|
|
1020
|
+
|
|
1021
|
+
async dispose() {
|
|
1022
|
+
try { await this.xs.drain(); } catch (_) {}
|
|
1023
|
+
try { await this.expertReader.close(); } catch (_) {}
|
|
1024
|
+
try { this.device.destroy(); } catch (_) {}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
export function topN(logits, n) {
|
|
1029
|
+
const best = [];
|
|
1030
|
+
for (let i = 0; i < logits.length; i++) {
|
|
1031
|
+
const v = logits[i];
|
|
1032
|
+
if (best.length < n || v > best[best.length - 1].logit) {
|
|
1033
|
+
best.push({ id: i, logit: v });
|
|
1034
|
+
best.sort((a, b) => b.logit - a.logit || a.id - b.id);
|
|
1035
|
+
if (best.length > n) best.pop();
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
return best;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
export async function removeIngest(key) { await removeStore(key, { root: OPFS_ROOT }); }
|