diskformer 0.5.1 → 0.5.3
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 +16 -0
- package/README.md +43 -44
- package/engines/gemma4_moe_ssd.js +3 -286
- package/engines/minicpmv_vision.js +506 -0
- package/engines/qwen35_moe_ssd.js +478 -66
- package/engines/qwen3_moe_ssd.js +307 -9
- package/ggml/llama.js +10 -1
- package/package.json +1 -1
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* const m = await Qwen35MoeSsd.load(null, { url: '…/Qwen3.6-35B-A3B-Q8_0.gguf', poolBytes });
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { MiniCpmVVision, planImage, preprocessImage, roundEven } from './minicpmv_vision.js';
|
|
19
|
+
import { Qwen3MoeSsd, STORAGE, COPY_SRC, COPY_DST, MAP_READ, UNIFORM, I8, TOK, Q6K_BLOCK, Q4_KERNELS, Q4_SG_KERNELS, Q4_BATCH_KERNELS } from './qwen3_moe_ssd.js';
|
|
19
20
|
|
|
20
21
|
export const QWEN36_35B_A3B = {
|
|
21
22
|
repo: 'unsloth/Qwen3.6-35B-A3B-GGUF',
|
|
@@ -165,6 +166,26 @@ ${SIGMOID}
|
|
|
165
166
|
};
|
|
166
167
|
|
|
167
168
|
|
|
169
|
+
// Q4_0 token embedding (the Q4_0 files): value e of the table from the nibble plane (byte j of a 32-value block
|
|
170
|
+
// holds values j and j + 16, as matmulQ4T reads it) times its block's f16 scale.
|
|
171
|
+
const Q4AT = 'fn q4at(e: u32) -> f32 { let by = (e >> 5u) * 16u + (e & 15u); return f32((q[by >> 2u] >> ((by & 3u) * 8u + ((e >> 4u) & 1u) * 4u)) & 15u) - 8.0; }';
|
|
172
|
+
const Q4_EMBED = {
|
|
173
|
+
embedQ4: `enable f16;
|
|
174
|
+
${TOK}
|
|
175
|
+
struct P { n: u32 }
|
|
176
|
+
@group(0) @binding(0) var<storage, read> q: array<u32>;
|
|
177
|
+
@group(0) @binding(1) var<storage, read> s: array<f16>;
|
|
178
|
+
@group(0) @binding(2) var<storage, read_write> y: array<f32>;
|
|
179
|
+
@group(0) @binding(3) var<uniform> tok: Tok;
|
|
180
|
+
@group(0) @binding(4) var<uniform> p: P;
|
|
181
|
+
${Q4AT}
|
|
182
|
+
@compute @workgroup_size(256) fn main(@builtin(global_invocation_id) g: vec3<u32>) {
|
|
183
|
+
let i = g.x; if (i >= p.n) { return; }
|
|
184
|
+
let e = tok.token * p.n + i;
|
|
185
|
+
y[i] = q4at(e) * f32(s[e >> 5u]);
|
|
186
|
+
}`,
|
|
187
|
+
};
|
|
188
|
+
|
|
168
189
|
// ── Batched prefill: a chunk of T prompt tokens per layer ───────────────────────────────────
|
|
169
190
|
// Every batched buffer is token-major (row t holds token pos0 + t). Q is the per-chunk uniform.
|
|
170
191
|
const QB = 'struct Q { T: u32, pos0: u32, S: u32, pad: u32 }';
|
|
@@ -438,6 +459,29 @@ ${QB}
|
|
|
438
459
|
var acc = 0.0;
|
|
439
460
|
for (var d = 0u; d < p.hd; d++) { acc += q[qo + d] * f32(kc[ko + d]); }
|
|
440
461
|
sc[idx] = acc * p.scale;
|
|
462
|
+
}`,
|
|
463
|
+
// attnScoreB masked by position instead of slot (Qwen35MoeSsd: images; kpos holds each KV slot's position).
|
|
464
|
+
attnScorePosB: `enable f16;
|
|
465
|
+
struct P { heads: u32, kvHeads: u32, hd: u32, scale: f32 }
|
|
466
|
+
${QB}
|
|
467
|
+
@group(0) @binding(0) var<storage, read> q: array<f32>;
|
|
468
|
+
@group(0) @binding(1) var<storage, read> kc: array<f16>;
|
|
469
|
+
@group(0) @binding(2) var<storage, read_write> sc: array<f32>;
|
|
470
|
+
@group(0) @binding(3) var<uniform> p: P;
|
|
471
|
+
@group(0) @binding(4) var<uniform> qd: Q;
|
|
472
|
+
@group(0) @binding(5) var<storage, read> kpos: array<u32>;
|
|
473
|
+
@compute @workgroup_size(256) fn main(@builtin(global_invocation_id) g: vec3<u32>, @builtin(num_workgroups) ng: vec3<u32>) {
|
|
474
|
+
let idx = g.y * ng.x * 256u + g.x; let S = qd.S; let T = qd.T;
|
|
475
|
+
if (idx >= p.heads * T * S) { return; }
|
|
476
|
+
let h = idx / (T * S); let rem = idx % (T * S); let t = rem / S; let pos = rem % S;
|
|
477
|
+
// Masked by position, not by slot (llama.cpp's KV mask): an image tile's tokens share one position, so they see
|
|
478
|
+
// each other; for text, positions rise with the slot and this is the usual causal mask.
|
|
479
|
+
if (kpos[pos] > kpos[qd.pos0 + t]) { sc[idx] = -3.4e38; return; }
|
|
480
|
+
let kvh = h / (p.heads / p.kvHeads);
|
|
481
|
+
let qo = t * p.heads * p.hd + h * p.hd; let ko = pos * p.kvHeads * p.hd + kvh * p.hd;
|
|
482
|
+
var acc = 0.0;
|
|
483
|
+
for (var d = 0u; d < p.hd; d++) { acc += q[qo + d] * f32(kc[ko + d]); }
|
|
484
|
+
sc[idx] = acc * p.scale;
|
|
441
485
|
}`,
|
|
442
486
|
softmaxB: `
|
|
443
487
|
${QB}
|
|
@@ -474,6 +518,25 @@ ${QB}
|
|
|
474
518
|
var acc = 0.0;
|
|
475
519
|
for (var pos = 0u; pos <= last; pos++) { acc += pr[rb + pos] * f32(vc[pos * stride + kvh * p.hd + d]); }
|
|
476
520
|
y[t * QN + idx] = acc;
|
|
521
|
+
}`,
|
|
522
|
+
// attnOutB over every key of the chunk (attnScorePosB gives masked ones probability 0; a tile token sees later ones).
|
|
523
|
+
attnOutPosB: `enable f16;
|
|
524
|
+
struct P { heads: u32, kvHeads: u32, hd: u32, pad: u32 }
|
|
525
|
+
${QB}
|
|
526
|
+
@group(0) @binding(0) var<storage, read> pr: array<f32>;
|
|
527
|
+
@group(0) @binding(1) var<storage, read> vc: array<f16>;
|
|
528
|
+
@group(0) @binding(2) var<storage, read_write> y: array<f32>;
|
|
529
|
+
@group(0) @binding(3) var<uniform> p: P;
|
|
530
|
+
@group(0) @binding(4) var<uniform> qd: Q;
|
|
531
|
+
@compute @workgroup_size(256) fn main(@builtin(global_invocation_id) g: vec3<u32>) {
|
|
532
|
+
let QN = p.heads * p.hd; let idx = g.x; let t = g.y;
|
|
533
|
+
if (idx >= QN || t >= qd.T) { return; }
|
|
534
|
+
let h = idx / p.hd; let d = idx % p.hd; let kvh = h / (p.heads / p.kvHeads);
|
|
535
|
+
let stride = p.kvHeads * p.hd; let rb = (h * qd.T + t) * qd.S;
|
|
536
|
+
var acc = 0.0;
|
|
537
|
+
// Every key of the chunk: a masked one has probability 0 (attnScorePosB's mask), and a tile token sees later ones.
|
|
538
|
+
for (var pos = 0u; pos < qd.S; pos++) { acc += pr[rb + pos] * f32(vc[pos * stride + kvh * p.hd + d]); }
|
|
539
|
+
y[t * QN + idx] = acc;
|
|
477
540
|
}`,
|
|
478
541
|
sigmoidMulB: `
|
|
479
542
|
struct P { n: u32 }
|
|
@@ -566,6 +629,23 @@ ${SIGMOID}
|
|
|
566
629
|
x[r] = (acc + sh[r] * sigm(sg[t])) + x[r];
|
|
567
630
|
}`,
|
|
568
631
|
};
|
|
632
|
+
const Q4_EMBED_B = {
|
|
633
|
+
embedQ4B: `enable f16;
|
|
634
|
+
struct P { n: u32 }
|
|
635
|
+
${QB}
|
|
636
|
+
@group(0) @binding(0) var<storage, read> q: array<u32>;
|
|
637
|
+
@group(0) @binding(1) var<storage, read> s: array<f16>;
|
|
638
|
+
@group(0) @binding(2) var<storage, read> ids: array<u32>;
|
|
639
|
+
@group(0) @binding(3) var<storage, read_write> y: array<f32>;
|
|
640
|
+
@group(0) @binding(4) var<uniform> p: P;
|
|
641
|
+
@group(0) @binding(5) var<uniform> qd: Q;
|
|
642
|
+
${Q4AT}
|
|
643
|
+
@compute @workgroup_size(256) fn main(@builtin(global_invocation_id) g: vec3<u32>) {
|
|
644
|
+
let i = g.x; let t = g.y; if (i >= p.n || t >= qd.T) { return; }
|
|
645
|
+
let e = ids[t] * p.n + i;
|
|
646
|
+
y[t * p.n + i] = q4at(e) * f32(s[e >> 5u]);
|
|
647
|
+
}`,
|
|
648
|
+
};
|
|
569
649
|
|
|
570
650
|
// ── Subgroup Q8_0 GEMV (the LFM2.5 / Gemma 4 kernels' pattern) ──────────────────────────────
|
|
571
651
|
// One 32-lane subgroup per output row, 4 rows per 128-thread workgroup (the same dispatch
|
|
@@ -674,7 +754,24 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
674
754
|
if (c.arch !== 'qwen35moe') throw new Error(`Qwen35MoeSsd needs a qwen35moe GGUF, got ${c.arch}`);
|
|
675
755
|
if (c.ssm.dConv !== 4) throw new Error(`gdnConv assumes a 4-tap conv, got ${c.ssm.dConv}`);
|
|
676
756
|
if (c.ssm.dState !== 128 || c.ssm.dInner !== c.ssm.vHeads * c.ssm.dState) throw new Error('gdnStep assumes 128-wide heads');
|
|
757
|
+
// Weight type: Q8_0 (Qwen3.6, MiniCPM-V Q8_0) or Q4_0 (MiniCPM-V Q4_0: Q4_0 matrices and experts, Q6_K output).
|
|
758
|
+
// Every projection and the experts share one type, so one set of kernel names serves the whole graph.
|
|
759
|
+
const dt = manifest.dense.tensors, q4 = dt['token_embd.weight'].type === 'q4';
|
|
760
|
+
for (const [name, d] of Object.entries(dt)) {
|
|
761
|
+
if (d.type !== 'f32' && name !== 'output.weight' && d.type !== (q4 ? 'q4' : 'q8')) throw new Error(`${name}: ${d.type} in a ${q4 ? 'Q4_0' : 'Q8_0'} file`);
|
|
762
|
+
}
|
|
763
|
+
if (q4 !== (manifest.experts.parts.dQ.bytes * 2 === c.expertFf * c.hidden)) throw new Error('experts and dense weights differ in type');
|
|
764
|
+
const lm = dt['output.weight'].type;
|
|
765
|
+
if (!(q4 ? ['q4', 'q6k'] : ['q8']).includes(lm)) throw new Error(`output.weight: ${lm} is not supported in a ${q4 ? 'Q4_0' : 'Q8_0'} file`);
|
|
766
|
+
this.q4 = q4;
|
|
767
|
+
this.qk = q4
|
|
768
|
+
? { mm: 'matmulQ4', mmT: 'matmulQ4T', ex: 'expertQ4', exB: 'expertQ4B', emb: 'embedQ4', embB: 'embedQ4B', lm: lm === 'q6k' ? 'matmulQ6K' : 'matmulQ4' }
|
|
769
|
+
: { mm: 'matmulQ8', mmT: 'matmulQ8T', ex: 'expertQ8', exB: 'expertQ8B', emb: 'embedQ8', embB: 'embedQ8B', lm: 'matmulQ8' };
|
|
677
770
|
this.recurrent = Array.from({ length: c.layers }, (_, l) => (l + 1) % c.attnInterval !== 0);
|
|
771
|
+
// M-RoPE sections (t/y/x/extra pair counts); text uses one position in all of them.
|
|
772
|
+
const secs = gguf.kv['qwen35moe.rope.dimension_sections'];
|
|
773
|
+
this.ropeSections = Array.isArray(secs) && secs.length === 4 ? secs.map(Number) : [c.ropeDims / 2, 0, 0, 0];
|
|
774
|
+
this.tNext = 0;
|
|
678
775
|
// Prompts go through in chunks of up to chunkTokens tokens per layer (one routing readback per
|
|
679
776
|
// layer per chunk); decode stays one token at a time.
|
|
680
777
|
this.batchPrefill = opts.batchPrefill ?? true;
|
|
@@ -692,6 +789,22 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
692
789
|
this.routeWindow = Math.max(1, Math.floor(opts.routeWindow ?? 2));
|
|
693
790
|
}
|
|
694
791
|
|
|
792
|
+
// A Q6_K output matrix (the Q4_0 files) is split on the GPU into aligned planes for matmulQ6K, and the raw copy freed.
|
|
793
|
+
async uploadDense(onProgress) {
|
|
794
|
+
await super.uploadDense(onProgress);
|
|
795
|
+
const o = this.w['output.weight'];
|
|
796
|
+
if (!o.raw) return;
|
|
797
|
+
const dev = this.device, raw = o.raw, blocks = raw.size / Q6K_BLOCK | 0;
|
|
798
|
+
const P = (n) => this.buffer(n, STORAGE, 'dense');
|
|
799
|
+
this.q6 = { ql: P(blocks * 128), qh: P(blocks * 64), sc: P(blocks * 16), dd: P(blocks * 4) };
|
|
800
|
+
const enc = dev.createCommandEncoder(), pass = enc.beginComputePass();
|
|
801
|
+
const wg = Math.ceil(blocks / 256), x = Math.min(wg, 65535);
|
|
802
|
+
this.dispatch(pass, 'q6kPlanes', this.bind('q6kPlanes', [raw, this.q6.ql, this.q6.qh, this.q6.sc, this.q6.dd, this.uniform([blocks])]), x, Math.ceil(wg / x));
|
|
803
|
+
pass.end(); dev.queue.submit([enc.finish()]);
|
|
804
|
+
await dev.queue.onSubmittedWorkDone();
|
|
805
|
+
this.gpuBytes.dense -= raw.size; raw.destroy(); o.raw = null;
|
|
806
|
+
}
|
|
807
|
+
|
|
695
808
|
// Subgroup GEMVs replace the scalar ones when subgroups are exactly 32 wide (Apple, most others).
|
|
696
809
|
get subgroupKernels() {
|
|
697
810
|
const i = this.device.adapterInfo || this.opts.adapterInfo || {};
|
|
@@ -700,13 +813,14 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
700
813
|
// With gpuRouting, every one-token kernel carries the stop guard (chunked prefill's do not).
|
|
701
814
|
get kernels() {
|
|
702
815
|
if (!this._kernels) {
|
|
703
|
-
const
|
|
816
|
+
const q4 = this.q4 ? { ...Q4_KERNELS, ...Q4_EMBED, ...(this.subgroupKernels ? Q4_SG_KERNELS : {}) } : {};
|
|
817
|
+
const k = { ...super.kernels, ...KERNELS, ...(this.subgroupKernels ? SG_KERNELS : {}), ...q4 };
|
|
704
818
|
if (this.routeGuards) for (const n of Object.keys(k)) k[n] = guarded(k[n]);
|
|
705
|
-
this._kernels = { ...k, ...BATCH_KERNELS, route: ROUTE_KERNEL };
|
|
819
|
+
this._kernels = { ...k, ...BATCH_KERNELS, ...(this.q4 ? { ...Q4_BATCH_KERNELS, ...Q4_EMBED_B, q6kPlanes: Q4_KERNELS.q6kPlanes } : {}), route: ROUTE_KERNEL };
|
|
706
820
|
}
|
|
707
821
|
return this._kernels;
|
|
708
822
|
}
|
|
709
|
-
isGuarded(name) { return this.routeGuards && name !== 'route' && !(name in BATCH_KERNELS); }
|
|
823
|
+
isGuarded(name) { return this.routeGuards && name !== 'route' && !(name in BATCH_KERNELS) && !(name in Q4_BATCH_KERNELS) && !(name in Q4_EMBED_B) && name !== 'q6kPlanes'; }
|
|
710
824
|
|
|
711
825
|
bind(name, buffers) { return super.bind(name, this.isGuarded(name) ? [...buffers, this.a.stop] : buffers); }
|
|
712
826
|
// Like bind(), but a resource may be { buffer, offset, size }.
|
|
@@ -736,10 +850,11 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
736
850
|
this.kc = []; this.vc = []; this.conv = []; this.ssm = [];
|
|
737
851
|
for (let l = 0; l < c.layers; l++) {
|
|
738
852
|
if (this.recurrent[l]) {
|
|
739
|
-
this.conv[l] = this.buffer(3 * convDim * 4, STORAGE | COPY_DST, 'state');
|
|
740
|
-
this.ssm[l] = this.buffer(s.vHeads * s.dState * s.dState * 4, STORAGE | COPY_DST, 'state');
|
|
853
|
+
this.conv[l] = this.buffer(3 * convDim * 4, STORAGE | COPY_SRC | COPY_DST, 'state');
|
|
854
|
+
this.ssm[l] = this.buffer(s.vHeads * s.dState * s.dState * 4, STORAGE | COPY_SRC | COPY_DST, 'state');
|
|
741
855
|
} else {
|
|
742
856
|
this.kc[l] = this.buffer(this.maxCtx * kvn * 2, STORAGE, 'kv');
|
|
857
|
+
this.kpos ||= this.buffer(this.maxCtx * 4, STORAGE | COPY_DST, 'kv'); // position (t) of each KV slot
|
|
743
858
|
this.vc[l] = this.buffer(this.maxCtx * kvn * 2, STORAGE, 'kv');
|
|
744
859
|
}
|
|
745
860
|
}
|
|
@@ -778,9 +893,10 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
778
893
|
lm: U([c.vocab, H]), am: U([c.vocab]),
|
|
779
894
|
};
|
|
780
895
|
const a = this.a, W = this.w;
|
|
781
|
-
const
|
|
896
|
+
const QK = this.qk;
|
|
897
|
+
const q8 = (name, x, y, uni) => { const t = W[name]; if (!t || !t.q) throw new Error(`missing quantized tensor ${name}`); return this.bind(QK.mm, [x, t.q, t.s, y, uni]); };
|
|
782
898
|
const raw = (name) => { const t = W[name]; if (!t || !t.raw) throw new Error(`missing F32 tensor ${name}`); return t.raw; };
|
|
783
|
-
this.g = { embed: this.bind(
|
|
899
|
+
this.g = { embed: this.bind(QK.emb, [W['token_embd.weight'].q, W['token_embd.weight'].s, a.x, this.tok, u.nH]) };
|
|
784
900
|
this.layers = [];
|
|
785
901
|
for (let l = 0; l < c.layers; l++) {
|
|
786
902
|
const n = (t) => `blk.${l}.${t}`;
|
|
@@ -825,21 +941,23 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
825
941
|
};
|
|
826
942
|
}),
|
|
827
943
|
// Shared expert: [gate | up] into shgu, SiLU·up, down; its scalar gate logit into sg.
|
|
828
|
-
shg: this.bindR(
|
|
829
|
-
shu: this.bindR(
|
|
944
|
+
shg: this.bindR(QK.mm, [a.xn, W[n('ffn_gate_shexp.weight')].q, W[n('ffn_gate_shexp.weight')].s, { buffer: a.shgu, offset: 0, size: SF * 4 }, u.shgu]),
|
|
945
|
+
shu: this.bindR(QK.mm, [a.xn, W[n('ffn_up_shexp.weight')].q, W[n('ffn_up_shexp.weight')].s, { buffer: a.shgu, offset: SF * 4, size: SF * 4 }, u.shgu]),
|
|
830
946
|
shsilu: this.bind('siluMulMoe', [a.shgu, a.shact, u.shsilu]),
|
|
831
947
|
shd: q8(n('ffn_down_shexp.weight'), a.shact, a.sh, u.shd),
|
|
832
948
|
shgate: this.bind('matmulF32', [a.xn, raw(n('ffn_gate_inp_shexp.weight')), a.sg, u.shgate]),
|
|
833
|
-
gu: this.bind(
|
|
949
|
+
gu: this.bind(QK.ex, [a.xn, this.pool.guQ, this.pool.guS, a.gu, u.gu, a.slots]),
|
|
834
950
|
silu: this.bind('siluMulMoe', [a.gu, a.act, u.silu]),
|
|
835
|
-
dn: this.bind(
|
|
951
|
+
dn: this.bind(QK.ex, [a.act, this.pool.dQ, this.pool.dS, a.dn, u.dn, a.slots]),
|
|
836
952
|
acc: this.bind('moeAccumShared', [a.dn, a.sel, a.sh, a.sg, a.x, u.acc]),
|
|
837
953
|
route: this.bind('route', [a.sel, a.rmap, a.slots, a.stop, a.used, U([l, c.experts, K, 0])]),
|
|
838
954
|
});
|
|
839
955
|
this.layers.push(g);
|
|
840
956
|
}
|
|
841
957
|
this.g.rmsOut = this.bind('rmsnorm', [a.x, raw('output_norm.weight'), a.xn, u.rmsH]);
|
|
842
|
-
this.g.lm =
|
|
958
|
+
this.g.lm = QK.lm === 'matmulQ6K'
|
|
959
|
+
? this.bind('matmulQ6K', [this.q6.ql, this.q6.qh, this.q6.sc, this.q6.dd, a.xn, a.logits, u.lm])
|
|
960
|
+
: this.bind(QK.lm, [a.xn, W['output.weight'].q, W['output.weight'].s, a.logits, u.lm]);
|
|
843
961
|
this.g.am = this.bind('argmax', [a.logits, a.am, u.am]);
|
|
844
962
|
// A chunk pins min(experts, T·K) experts per layer at once; keep that under half the pool.
|
|
845
963
|
if (Math.min(c.experts, this.chunkTokens * K) > this.poolSlots / 2) this.chunkTokens = Math.max(2, Math.floor(this.poolSlots / 2 / K));
|
|
@@ -873,8 +991,9 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
873
991
|
shgu: U([SF, H]), shd: U([H, SF]), sh2: U([SF]), shgate: U([1, H]),
|
|
874
992
|
};
|
|
875
993
|
const raw = (name) => W[name].raw;
|
|
876
|
-
const
|
|
877
|
-
|
|
994
|
+
const QK = this.qk;
|
|
995
|
+
const q8 = (name, x, y, uni) => this.bind(QK.mmT, [x, W[name].q, W[name].s, y, uni, qb]);
|
|
996
|
+
this.gB = { embed: this.bind(QK.embB, [W['token_embd.weight'].q, W['token_embd.weight'].s, b.ids, b.x, u.nH, qb]) };
|
|
878
997
|
for (let l = 0; l < c.layers; l++) {
|
|
879
998
|
const n = (t) => `blk.${l}.${t}`, g = { rmsA: this.bind('rmsnormB', [b.x, raw(n('attn_norm.weight')), b.xn, u.rmsH]) };
|
|
880
999
|
if (this.recurrent[l]) {
|
|
@@ -892,9 +1011,9 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
892
1011
|
ropeQ: this.bind('qkNormRopePB', [b.qg, raw(n('attn_q_norm.weight')), b.rope, b.q, b.gate, u.ropeQ]),
|
|
893
1012
|
ropeK: this.bind('qkNormRopePB', [b.k, raw(n('attn_k_norm.weight')), b.rope, b.kr, b.dummy, u.ropeK]),
|
|
894
1013
|
kv: this.bind('kvStoreB', [b.kr, b.v, this.kc[l], this.vc[l], u.kvn, qb]),
|
|
895
|
-
score: this.bind('
|
|
1014
|
+
score: this.bind('attnScorePosB', [b.q, this.kc[l], b.sc, u.att, qb, this.kpos]),
|
|
896
1015
|
soft: this.bind('softmaxB', [b.sc, qb]),
|
|
897
|
-
attOut: this.bind('
|
|
1016
|
+
attOut: this.bind('attnOutPosB', [b.sc, this.vc[l], b.att, u.attOut, qb]),
|
|
898
1017
|
gate: this.bind('sigmoidMulB', [b.att, b.gate, u.gate, qb]),
|
|
899
1018
|
o: q8(n('attn_output.weight'), b.att, b.o, u.o),
|
|
900
1019
|
});
|
|
@@ -908,9 +1027,9 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
908
1027
|
shact: this.bind('siluMul2B', [b.shg, b.shu, b.shact, u.sh2, qb]),
|
|
909
1028
|
shd: q8(n('ffn_down_shexp.weight'), b.shact, b.sh, u.shd),
|
|
910
1029
|
shgate: this.bind('matmulF32T', [b.xn, raw(n('ffn_gate_inp_shexp.weight')), b.sg, u.shgate]),
|
|
911
|
-
gu: this.bind(
|
|
1030
|
+
gu: this.bind(QK.exB, [b.xn, this.pool.guQ, this.pool.guS, b.gu, u.gu, b.slots]),
|
|
912
1031
|
silu: this.bind('siluMulMoe', [b.gu, b.act, u.silu]),
|
|
913
|
-
dn: this.bind(
|
|
1032
|
+
dn: this.bind(QK.exB, [b.act, this.pool.dQ, this.pool.dS, b.dn, u.dn, b.slots]),
|
|
914
1033
|
acc: this.bind('moeAccumSharedB', [b.dn, b.sel, b.sh, b.sg, b.x, u.acc, qb]),
|
|
915
1034
|
});
|
|
916
1035
|
this.layers[l].B = g;
|
|
@@ -924,44 +1043,46 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
924
1043
|
const tt = Math.ceil(T / 16), mm = (n) => Math.ceil(n / 16);
|
|
925
1044
|
d('rmsnormB', g.rmsA, T);
|
|
926
1045
|
if (this.recurrent[l]) {
|
|
927
|
-
d(
|
|
928
|
-
d(
|
|
1046
|
+
d(this.qk.mmT, g.qkv, mm(convDim), tt); d(this.qk.mmT, g.z, mm(s.dInner), tt);
|
|
1047
|
+
d(this.qk.mmT, g.beta, mm(s.vHeads), tt); d(this.qk.mmT, g.alpha, mm(s.vHeads), tt);
|
|
929
1048
|
d('gdnConvB', g.conv, Math.ceil(convDim / 256));
|
|
930
1049
|
d('gdnStepB', g.step, s.vHeads);
|
|
931
1050
|
d('gdnNormGateB', g.norm, s.vHeads, T);
|
|
932
|
-
d(
|
|
1051
|
+
d(this.qk.mmT, g.out, mm(H), tt);
|
|
933
1052
|
} else {
|
|
934
|
-
d(
|
|
1053
|
+
d(this.qk.mmT, g.q, mm(2 * QN), tt); d(this.qk.mmT, g.k, mm(kvn), tt); d(this.qk.mmT, g.v, mm(kvn), tt);
|
|
935
1054
|
d('qkNormRopePB', g.ropeQ, c.heads, T); d('qkNormRopePB', g.ropeK, c.kvHeads, T);
|
|
936
1055
|
d('kvStoreB', g.kv, Math.ceil(kvn / 256), T);
|
|
937
|
-
d('
|
|
1056
|
+
d('attnScorePosB', g.score, Math.ceil(c.heads * T * S / 256));
|
|
938
1057
|
d('softmaxB', g.soft, c.heads * T);
|
|
939
|
-
d('
|
|
1058
|
+
d('attnOutPosB', g.attOut, Math.ceil(QN / 256), T);
|
|
940
1059
|
d('sigmoidMulB', g.gate, Math.ceil(QN / 256), T);
|
|
941
|
-
d(
|
|
1060
|
+
d(this.qk.mmT, g.o, mm(H), tt);
|
|
942
1061
|
}
|
|
943
1062
|
d('addInPlaceB', g.addO, Math.ceil(H / 256), T);
|
|
944
1063
|
d('rmsnormB', g.rmsF, T);
|
|
945
1064
|
d('matmulF32T', g.router, c.experts, T);
|
|
946
1065
|
d('topkB', g.topk, T);
|
|
947
|
-
d(
|
|
1066
|
+
d(this.qk.mmT, g.shg, mm(c.shexpFf), tt); d(this.qk.mmT, g.shu, mm(c.shexpFf), tt);
|
|
948
1067
|
d('siluMul2B', g.shact, Math.ceil(c.shexpFf / 256), T);
|
|
949
|
-
d(
|
|
1068
|
+
d(this.qk.mmT, g.shd, mm(H), tt);
|
|
950
1069
|
d('matmulF32T', g.shgate, 1, T);
|
|
951
1070
|
}
|
|
952
1071
|
|
|
953
1072
|
encodeExpertsB(pass, l, T) {
|
|
954
1073
|
const c = this.cfg, g = this.layers[l].B, d = (nm, gr, x, y, z) => this.dispatch(pass, nm, gr, x, y, z);
|
|
955
|
-
d(
|
|
1074
|
+
d(this.qk.exB, g.gu, Math.ceil(2 * c.expertFf / 4), 1, T * c.topK);
|
|
956
1075
|
d('siluMulMoe', g.silu, Math.ceil(T * c.topK * c.expertFf / 256));
|
|
957
|
-
d(
|
|
1076
|
+
d(this.qk.exB, g.dn, Math.ceil(c.hidden / 4), 1, T * c.topK);
|
|
958
1077
|
d('moeAccumSharedB', g.acc, Math.ceil(c.hidden / 256), T);
|
|
959
1078
|
}
|
|
960
1079
|
|
|
961
1080
|
// Feeds T = ids.length tokens at this.position through every layer: one routing readback per
|
|
962
1081
|
// layer, the union of the chunk's experts loaded once. want: 'none' | 'argmax' | 'logits' for
|
|
963
1082
|
// the last token, as in step().
|
|
964
|
-
|
|
1083
|
+
// ext (images): { embeds: Float32Array T × hidden (input rows instead of token embeddings), pos: Int32Array T × 4
|
|
1084
|
+
// [t, s0, s1, s2] (KV position and the three M-RoPE section positions), tNext: the next text position after it }.
|
|
1085
|
+
async prefillChunk(ids, want = 'none', ext = null) {
|
|
965
1086
|
const c = this.cfg, dev = this.device, K = c.topK, H = c.hidden, T = ids.length, pos0 = this.position, S = pos0 + T;
|
|
966
1087
|
if (T > this.chunkTokens) throw new Error(`chunk of ${T} > ${this.chunkTokens}`);
|
|
967
1088
|
if (S > this.maxCtx) throw this.contextFull();
|
|
@@ -969,15 +1090,14 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
969
1090
|
dev.queue.writeBuffer(this.a.stop, 0, new Uint32Array(4)); // the head below uses guarded kernels
|
|
970
1091
|
dev.queue.writeBuffer(this.b.ids, 0, Uint32Array.from(ids));
|
|
971
1092
|
dev.queue.writeBuffer(this.qb, 0, new Uint32Array([T, pos0, S, 0]));
|
|
972
|
-
const
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
dev.queue.writeBuffer(this.b.rope, 0, r);
|
|
1093
|
+
const pos = ext && ext.pos ? ext.pos : this.textPositions(T);
|
|
1094
|
+
dev.queue.writeBuffer(this.b.rope, 0, this.ropeRows(pos, T));
|
|
1095
|
+
const kp = new Uint32Array(T); for (let t = 0; t < T; t++) kp[t] = pos[t * 4];
|
|
1096
|
+
dev.queue.writeBuffer(this.kpos, pos0 * 4, kp);
|
|
1097
|
+
if (ext && ext.embeds) dev.queue.writeBuffer(this.b.x, 0, ext.embeds);
|
|
978
1098
|
const selBytes = T * 2 * K * 4;
|
|
979
1099
|
let enc = dev.createCommandEncoder(), pass = enc.beginComputePass();
|
|
980
|
-
this.dispatch(pass,
|
|
1100
|
+
if (!(ext && ext.embeds)) this.dispatch(pass, this.qk.embB, this.gB.embed, Math.ceil(H / 256), T);
|
|
981
1101
|
this.encodeTrunkB(pass, 0, T, S);
|
|
982
1102
|
pass.end();
|
|
983
1103
|
enc.copyBufferToBuffer(this.b.sel, 0, this.rbSelB, 0, selBytes);
|
|
@@ -1009,7 +1129,7 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
1009
1129
|
if (want !== 'none') {
|
|
1010
1130
|
pass = enc.beginComputePass();
|
|
1011
1131
|
this.dispatch(pass, 'rmsnorm', this.g.rmsOut, 1);
|
|
1012
|
-
this.dispatch(pass,
|
|
1132
|
+
this.dispatch(pass, this.qk.lm, this.g.lm, Math.ceil(c.vocab / 4));
|
|
1013
1133
|
if (want === 'argmax') this.dispatch(pass, 'argmax', this.g.am, 1);
|
|
1014
1134
|
pass.end();
|
|
1015
1135
|
if (want === 'argmax') enc.copyBufferToBuffer(this.a.am, 0, this.rbArg, 0, 4);
|
|
@@ -1020,7 +1140,8 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
1020
1140
|
this.xs.release(slotsU);
|
|
1021
1141
|
}
|
|
1022
1142
|
this.position += T;
|
|
1023
|
-
this.cached.push(...ids);
|
|
1143
|
+
this.cached.push(...(ext && ext.keys ? ext.keys : ids));
|
|
1144
|
+
this.tNext = ext && ext.tNext != null ? ext.tNext : kp[T - 1] + 1;
|
|
1024
1145
|
let result = null;
|
|
1025
1146
|
if (want === 'argmax') {
|
|
1026
1147
|
await this.rbArg.mapAsync(1, 0, 4);
|
|
@@ -1045,38 +1166,38 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
1045
1166
|
const H = c.hidden, QN = c.heads * c.headDim, kvn = c.kvHeads * c.headDim, convDim = 2 * s.kHeads * s.dState + s.dInner;
|
|
1046
1167
|
d('rmsnorm', g.rmsA, 1);
|
|
1047
1168
|
if (g.recur) {
|
|
1048
|
-
d(
|
|
1049
|
-
d(
|
|
1169
|
+
d(this.qk.mm, g.qkv, Math.ceil(convDim / 4)); d(this.qk.mm, g.z, Math.ceil(s.dInner / 4));
|
|
1170
|
+
d(this.qk.mm, g.beta, Math.ceil(s.vHeads / 4)); d(this.qk.mm, g.alpha, Math.ceil(s.vHeads / 4));
|
|
1050
1171
|
d('gdnConv', g.conv, Math.ceil(convDim / 256));
|
|
1051
1172
|
d('gdnStep', g.step, s.vHeads);
|
|
1052
1173
|
d('gdnNormGate', g.norm, s.vHeads);
|
|
1053
|
-
d(
|
|
1174
|
+
d(this.qk.mm, g.out, Math.ceil(H / 4));
|
|
1054
1175
|
} else {
|
|
1055
|
-
d(
|
|
1176
|
+
d(this.qk.mm, g.q, Math.ceil(2 * QN / 4)); d(this.qk.mm, g.k, Math.ceil(kvn / 4)); d(this.qk.mm, g.v, Math.ceil(kvn / 4));
|
|
1056
1177
|
d('qkNormRopeP', g.ropeQ, c.heads); d('qkNormRopeP', g.ropeK, c.kvHeads);
|
|
1057
1178
|
d('kvStore', g.kv, Math.ceil(kvn / 256));
|
|
1058
1179
|
d('attnScore', g.score, Math.ceil(c.heads * seqLen / 256));
|
|
1059
1180
|
d('softmax', g.soft, c.heads);
|
|
1060
1181
|
d('attnOut', g.attOut, Math.ceil(QN / 256));
|
|
1061
1182
|
d('sigmoidMul', g.gate, Math.ceil(QN / 256));
|
|
1062
|
-
d(
|
|
1183
|
+
d(this.qk.mm, g.o, Math.ceil(H / 4));
|
|
1063
1184
|
}
|
|
1064
1185
|
d('addInPlace', g.addO, Math.ceil(H / 256));
|
|
1065
1186
|
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); }
|
|
1066
1187
|
d('rmsnorm', g.rmsF, 1);
|
|
1067
1188
|
d('matmulF32', g.router, c.experts);
|
|
1068
1189
|
d('topk', g.topk, 1);
|
|
1069
|
-
d(
|
|
1190
|
+
d(this.qk.mm, g.shg, Math.ceil(c.shexpFf / 4)); d(this.qk.mm, g.shu, Math.ceil(c.shexpFf / 4));
|
|
1070
1191
|
d('siluMulMoe', g.shsilu, Math.ceil(c.shexpFf / 256));
|
|
1071
|
-
d(
|
|
1192
|
+
d(this.qk.mm, g.shd, Math.ceil(H / 4));
|
|
1072
1193
|
d('matmulF32', g.shgate, 1);
|
|
1073
1194
|
}
|
|
1074
1195
|
|
|
1075
1196
|
encodeExperts(pass, l) {
|
|
1076
1197
|
const c = this.cfg, g = this.layers[l], d = (nm, gr, x, y, z) => this.dispatch(pass, nm, gr, x, y, z);
|
|
1077
|
-
d(
|
|
1198
|
+
d(this.qk.ex, g.gu, Math.ceil(2 * c.expertFf / 4), 1, c.topK);
|
|
1078
1199
|
d('siluMulMoe', g.silu, Math.ceil(c.topK * c.expertFf / 256));
|
|
1079
|
-
d(
|
|
1200
|
+
d(this.qk.ex, g.dn, Math.ceil(c.hidden / 4), 1, c.topK);
|
|
1080
1201
|
d('moeAccumShared', g.acc, Math.ceil(c.hidden / 256));
|
|
1081
1202
|
}
|
|
1082
1203
|
|
|
@@ -1113,7 +1234,7 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
1113
1234
|
if (slots) dev.queue.writeBuffer(this.a.slots, 0, slots);
|
|
1114
1235
|
const enc = dev.createCommandEncoder(), pass = enc.beginComputePass();
|
|
1115
1236
|
if (resume < 0) {
|
|
1116
|
-
this.dispatch(pass,
|
|
1237
|
+
this.dispatch(pass, this.qk.emb, this.g.embed, Math.ceil(c.hidden / 256));
|
|
1117
1238
|
this.encodeAttention(pass, 0, seqLen);
|
|
1118
1239
|
this.dispatch(pass, 'route', this.layers[0].route, 1);
|
|
1119
1240
|
}
|
|
@@ -1124,7 +1245,7 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
1124
1245
|
}
|
|
1125
1246
|
if (last === L - 1 && want !== 'none') {
|
|
1126
1247
|
this.dispatch(pass, 'rmsnorm', this.g.rmsOut, 1);
|
|
1127
|
-
this.dispatch(pass,
|
|
1248
|
+
this.dispatch(pass, this.qk.lm, this.g.lm, Math.ceil(c.vocab / 4));
|
|
1128
1249
|
if (want === 'argmax') this.dispatch(pass, 'argmax', this.g.am, 1);
|
|
1129
1250
|
}
|
|
1130
1251
|
pass.end();
|
|
@@ -1187,13 +1308,40 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
1187
1308
|
|
|
1188
1309
|
// RoPE angles as ggml's CPU rope cache forms them for text positions (all M-RoPE sections at
|
|
1189
1310
|
// the token position): θ = pos, multiplied by base^(-2/nRot) once per pair, in f32.
|
|
1311
|
+
// pos is the KV slot; RoPE takes the text position tNext, which runs behind the slot after an image (a tile's
|
|
1312
|
+
// tokens share one position).
|
|
1190
1313
|
writeTokenUniforms(token, pos) {
|
|
1191
|
-
const c = this.cfg, nRot = c.ropeDims, half = nRot / 2;
|
|
1192
1314
|
this.device.queue.writeBuffer(this.tok, 0, new Uint32Array([token, pos, pos + 1, 0]));
|
|
1193
|
-
const
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
this.
|
|
1315
|
+
const t = this.tNext ?? pos;
|
|
1316
|
+
this.device.queue.writeBuffer(this.a.rope, 0, this.ropeRows(Int32Array.of(t, t, t, t), 1));
|
|
1317
|
+
if (this.kpos) this.device.queue.writeBuffer(this.kpos, pos * 4, Uint32Array.of(t));
|
|
1318
|
+
this.tNext = t + 1;
|
|
1319
|
+
}
|
|
1320
|
+
// Text positions for the next T tokens: [t, t, t, t] each.
|
|
1321
|
+
textPositions(T) {
|
|
1322
|
+
const t0 = this.tNext ?? this.position, p = new Int32Array(T * 4);
|
|
1323
|
+
for (let t = 0; t < T; t++) p.fill(t0 + t, t * 4, t * 4 + 4);
|
|
1324
|
+
return p;
|
|
1325
|
+
}
|
|
1326
|
+
// RoPE cos/sin rows for T tokens with positions [t, s0, s1, s2] each, as ggml's rope cache forms them for
|
|
1327
|
+
// interleaved M-RoPE (ggml_mrope_cache_init, is_imrope): rotary pair i takes section s1 when i % 3 == 1, s2 when
|
|
1328
|
+
// i % 3 == 2, else s0 (within 3 × the section sizes); each section's angle is its position × base^(-2i/n). For
|
|
1329
|
+
// text all three are t, which is plain RoPE, bit for bit what this engine computed before.
|
|
1330
|
+
ropeRows(pos, T) {
|
|
1331
|
+
const c = this.cfg, nRot = c.ropeDims, half = nRot / 2, sec = this.ropeSections;
|
|
1332
|
+
const step = Math.fround(Math.pow(c.ropeTheta, -2 / nRot)), nSec = sec[0] + sec[1] + sec[2] + sec[3];
|
|
1333
|
+
const r = new Float32Array(T * nRot);
|
|
1334
|
+
for (let t = 0; t < T; t++) {
|
|
1335
|
+
const th = [Math.fround(pos[t * 4 + 1]), Math.fround(pos[t * 4 + 2]), Math.fround(pos[t * 4 + 3]), 0];
|
|
1336
|
+
for (let i = 0; i < half; i++) {
|
|
1337
|
+
const sector = nSec ? i % nSec : 0;
|
|
1338
|
+
const k = !(sec[1] || sec[2]) ? 0 : sector % 3 === 1 && sector < 3 * sec[1] ? 1 : sector % 3 === 2 && sector < 3 * sec[2] ? 2 : sector % 3 === 0 && sector < 3 * sec[0] ? 0 : 3;
|
|
1339
|
+
const a = th[k];
|
|
1340
|
+
r[t * nRot + i] = Math.cos(a); r[t * nRot + half + i] = Math.sin(a);
|
|
1341
|
+
for (let j = 0; j < 4; j++) th[j] = Math.fround(th[j] * step);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return r;
|
|
1197
1345
|
}
|
|
1198
1346
|
|
|
1199
1347
|
// Qwen3.5/3.6's template opens the reasoning block in the generation prompt when thinking is on.
|
|
@@ -1203,29 +1351,293 @@ export class Qwen35MoeSsd extends Qwen3MoeSsd {
|
|
|
1203
1351
|
return p + '<|im_start|>assistant\n' + (enableThinking ? '<think>\n' : '<think>\n\n</think>\n\n');
|
|
1204
1352
|
}
|
|
1205
1353
|
|
|
1206
|
-
// The recurrent state cannot rewind to an earlier position, so
|
|
1207
|
-
//
|
|
1354
|
+
// The recurrent state cannot rewind to an earlier position, so a change before the cached end restarts from token
|
|
1355
|
+
// 0, or from the checkpoint below when the prompt still starts with it.
|
|
1208
1356
|
reset() {
|
|
1209
1357
|
super.reset();
|
|
1358
|
+
this.tNext = 0;
|
|
1210
1359
|
if (!this.conv) return;
|
|
1211
1360
|
const enc = this.device.createCommandEncoder();
|
|
1212
1361
|
for (let l = 0; l < this.cfg.layers; l++) if (this.recurrent[l]) { enc.clearBuffer(this.conv[l]); enc.clearBuffer(this.ssm[l]); }
|
|
1213
1362
|
this.device.queue.submit([enc.finish()]);
|
|
1214
1363
|
}
|
|
1215
1364
|
|
|
1216
|
-
|
|
1365
|
+
// The checkpoint: the state after the system turn (with tools on, ~1,900 tokens of tool instructions), so a new
|
|
1366
|
+
// chat, an edited history or an image turn re-reads only what follows it. KV rows below its length stay as they
|
|
1367
|
+
// were as long as nothing prefills over them; the DeltaNet conv and delta-rule state is copied out and back.
|
|
1368
|
+
saveCheckpoint(keys) {
|
|
1369
|
+
const enc = this.device.createCommandEncoder();
|
|
1370
|
+
this.cpConv ||= []; this.cpSsm ||= [];
|
|
1371
|
+
for (let l = 0; l < this.cfg.layers; l++) {
|
|
1372
|
+
if (!this.recurrent[l]) continue;
|
|
1373
|
+
this.cpConv[l] ||= this.buffer(this.conv[l].size, STORAGE | COPY_SRC | COPY_DST, 'state');
|
|
1374
|
+
this.cpSsm[l] ||= this.buffer(this.ssm[l].size, STORAGE | COPY_SRC | COPY_DST, 'state');
|
|
1375
|
+
enc.copyBufferToBuffer(this.conv[l], 0, this.cpConv[l], 0, this.conv[l].size);
|
|
1376
|
+
enc.copyBufferToBuffer(this.ssm[l], 0, this.cpSsm[l], 0, this.ssm[l].size);
|
|
1377
|
+
}
|
|
1378
|
+
this.device.queue.submit([enc.finish()]);
|
|
1379
|
+
this.checkpoint = { keys: keys.slice(), tNext: this.tNext };
|
|
1380
|
+
}
|
|
1381
|
+
restoreCheckpoint() {
|
|
1382
|
+
const cp = this.checkpoint, enc = this.device.createCommandEncoder();
|
|
1383
|
+
for (let l = 0; l < this.cfg.layers; l++) {
|
|
1384
|
+
if (!this.recurrent[l]) continue;
|
|
1385
|
+
enc.copyBufferToBuffer(this.cpConv[l], 0, this.conv[l], 0, this.conv[l].size);
|
|
1386
|
+
enc.copyBufferToBuffer(this.cpSsm[l], 0, this.ssm[l], 0, this.ssm[l].size);
|
|
1387
|
+
}
|
|
1388
|
+
this.device.queue.submit([enc.finish()]);
|
|
1389
|
+
this.position = cp.keys.length; this.cached = cp.keys.slice(); this.tNext = cp.tNext;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// Where the system turn ends in a prompt's ids (after its <|im_end|> and newline), or 0 without one.
|
|
1393
|
+
systemTurnEnd(ids) {
|
|
1394
|
+
const tk = this.tokenizer, start = tk.ids.get('<|im_start|>'), end = tk.ids.get('<|im_end|>');
|
|
1395
|
+
if (ids[0] !== start || tk.decode([ids[1]]) !== 'system') return 0;
|
|
1396
|
+
const i = ids.indexOf(end);
|
|
1397
|
+
return i < 0 ? 0 : i + (tk.decode([ids[i + 1]]) === '\n' ? 2 : 1);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Where to keep the checkpoint: after the tool list (`</tools>`) when the system turn has one, since LocalMind
|
|
1401
|
+
// appends the user's own system prompt and the memory recalled for each question after the tool instructions;
|
|
1402
|
+
// else after the system turn.
|
|
1403
|
+
checkpointEnd(ids) {
|
|
1404
|
+
const end = this.systemTurnEnd(ids), has = (n) => this.tokenizer.decode(ids.slice(0, n)).includes('</tools>');
|
|
1405
|
+
if (!end || !has(end)) return end;
|
|
1406
|
+
let lo = 1, hi = end;
|
|
1407
|
+
while (lo < hi) { const mid = (lo + hi) >> 1; if (has(mid)) hi = mid; else lo = mid + 1; }
|
|
1408
|
+
return lo;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
async prefill(ids, want = 'argmax') { return this.prefillSegments([{ ids }], want, { sysEnd: this.checkpointEnd(ids) }); }
|
|
1412
|
+
|
|
1413
|
+
// Reads a system prompt's stable part into the checkpoint before the first question (the SSD worker calls it after
|
|
1414
|
+
// load with the tool instructions), so that question starts after it. Returns the tokens read.
|
|
1415
|
+
async prime(system) {
|
|
1416
|
+
const release = await this.acquire();
|
|
1417
|
+
try {
|
|
1418
|
+
const ids = this.tokenizer.encode(this.chatPrompt([{ role: 'system', content: system }], { enableThinking: false }));
|
|
1419
|
+
const end = this.checkpointEnd(ids), keys = ids.slice(0, end);
|
|
1420
|
+
if (!end || (this.checkpoint && this.checkpoint.keys.length === end && keys.every((k, i) => k === this.checkpoint.keys[i]))) return 0;
|
|
1421
|
+
this.reset();
|
|
1422
|
+
await this.prefillSegments([{ ids: keys }], 'none', { sysEnd: end });
|
|
1423
|
+
return end;
|
|
1424
|
+
} catch (e) { throw this.stepError(e); } finally { release(); }
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// Prefills a prompt given as segments: { ids } text, or an image { keys, n, entries, rel, embeds: async () =>
|
|
1428
|
+
// Float32Array } whose keys stand for its tokens in the cache (they name the image, so a turn that repeats it
|
|
1429
|
+
// matches). Starts from the furthest of: the cached tokens when the prompt extends them, the checkpoint when the
|
|
1430
|
+
// prompt starts with it, or token 0; saves a new checkpoint at sysEnd when it reads the system turn.
|
|
1431
|
+
async prefillSegments(segs, want = 'argmax', { sysEnd = 0 } = {}) {
|
|
1432
|
+
const keys = segs.flatMap((sg) => sg.ids || sg.keys);
|
|
1217
1433
|
let common = 0;
|
|
1218
|
-
while (common <
|
|
1219
|
-
|
|
1220
|
-
const
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1434
|
+
while (common < keys.length - 1 && common < this.cached.length && this.cached[common] === keys[common]) common++;
|
|
1435
|
+
const cp = this.checkpoint;
|
|
1436
|
+
const cpLen = cp && cp.keys.length < keys.length && cp.keys.every((k, i) => k === keys[i]) ? cp.keys.length : -1;
|
|
1437
|
+
const extLen = common === this.cached.length ? common : -1;
|
|
1438
|
+
if (cpLen > extLen) this.restoreCheckpoint();
|
|
1439
|
+
else if (extLen < 0) this.reset();
|
|
1440
|
+
if (cp && cpLen < 0 && this.cached.length < cp.keys.length) this.checkpoint = null; // its KV rows get overwritten
|
|
1441
|
+
const from = this.cached.length;
|
|
1442
|
+
const cut = sysEnd > from && sysEnd <= keys.length ? sysEnd : -1;
|
|
1443
|
+
let r = null, at = 0;
|
|
1444
|
+
for (let si = 0; si < segs.length; si++) {
|
|
1445
|
+
const sg = segs[si], len = sg.ids ? sg.ids.length : sg.n, segEnd = at + len, last = si === segs.length - 1;
|
|
1446
|
+
if (segEnd <= from) { at = segEnd; continue; }
|
|
1447
|
+
if (sg.ids) {
|
|
1448
|
+
for (let i = Math.max(0, from - at); i < len;) {
|
|
1449
|
+
let n = Math.min(this.chunkTokens, len - i);
|
|
1450
|
+
if (cut > at + i && cut < at + i + n) n = cut - at - i; // end a chunk at the checkpoint
|
|
1451
|
+
const chunk = sg.ids.slice(i, i + n), w = last && i + n >= len ? want : 'none';
|
|
1452
|
+
if (!this.batchPrefill || n < 2) for (let j = 0; j < n; j++) r = await this.step(chunk[j], j === n - 1 ? w : 'none');
|
|
1453
|
+
else r = await this.prefillChunk(chunk, w);
|
|
1454
|
+
i += n;
|
|
1455
|
+
if (at + i === cut) this.saveCheckpoint(keys.slice(0, cut));
|
|
1456
|
+
}
|
|
1457
|
+
at = segEnd;
|
|
1458
|
+
continue;
|
|
1459
|
+
}
|
|
1460
|
+
if (from > at) throw new Error('the cache ends inside an image');
|
|
1461
|
+
// Whole tiles (with their separators) per chunk: a tile's tokens must see each other, so a tile never splits.
|
|
1462
|
+
const embeds = await sg.embeds();
|
|
1463
|
+
const pos0 = this.tNext, base = pos0 - 1, dim = this.cfg.hidden, { rel, nPos } = sg.rel;
|
|
1464
|
+
const spans = []; let o = 0;
|
|
1465
|
+
for (const e of sg.entries) { const n = e.gw * e.gh + e.seps; if (n > this.chunkTokens) throw new Error(`a tile of ${n} tokens > chunk ${this.chunkTokens}`); spans.push([o, n]); o += n; }
|
|
1466
|
+
let ci = 0;
|
|
1467
|
+
while (ci < spans.length) {
|
|
1468
|
+
const start = spans[ci][0]; let n = 0;
|
|
1469
|
+
while (ci < spans.length && n + spans[ci][1] <= this.chunkTokens) { n += spans[ci][1]; ci++; }
|
|
1470
|
+
const pos = new Int32Array(n * 4);
|
|
1471
|
+
for (let i = 0; i < n; i++) {
|
|
1472
|
+
const [dt, dy, dx, isSep] = rel[start + i];
|
|
1473
|
+
if (isSep) pos.fill(pos0 + dt, i * 4, i * 4 + 4); else pos.set([pos0 + dt, base, base + dy, base + dx], i * 4);
|
|
1474
|
+
}
|
|
1475
|
+
r = await this.prefillChunk(new Array(n).fill(0xffffffff), last && ci >= spans.length ? want : 'none',
|
|
1476
|
+
{ embeds: embeds.subarray(start * dim, (start + n) * dim), pos, tNext: pos0 + nPos, keys: sg.keys.slice(start, start + n) });
|
|
1477
|
+
}
|
|
1478
|
+
at = segEnd;
|
|
1225
1479
|
}
|
|
1226
1480
|
return r;
|
|
1227
1481
|
}
|
|
1228
1482
|
}
|
|
1229
1483
|
|
|
1484
|
+
// A 64-bit FNV-1a of a typed array's bytes, as hex: names an image in the prefix cache.
|
|
1485
|
+
function hashBytes(a) {
|
|
1486
|
+
const u = new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
|
|
1487
|
+
let h1 = 0x811c9dc5, h2 = 0x01000193 ^ u.length;
|
|
1488
|
+
for (let i = 0; i < u.length; i++) { h1 = Math.imul(h1 ^ u[i], 0x01000193); h2 = Math.imul(h2 ^ u[i], 0x5bd1e995); }
|
|
1489
|
+
return (h1 >>> 0).toString(16).padStart(8, '0') + (h2 >>> 0).toString(16).padStart(8, '0');
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// OpenBMB's MiniCPM-V 4.7 35B-A3B (2026-10): its language model is a qwen35moe with Qwen3.6's shapes, so it runs on
|
|
1493
|
+
// this engine; its Q4_0 GGUF (Q4_0 matrices and experts, a Q6_K output) on the Q4_0 kernels. Text only: the vision
|
|
1494
|
+
// encoder (mmproj, projector `minicpmv4_7`) is a separate file this engine does not read.
|
|
1495
|
+
export const MINICPM_V47_35B_A3B = {
|
|
1496
|
+
repo: 'openbmb/MiniCPM-V-4.7-35B-A3B-gguf',
|
|
1497
|
+
file: 'MiniCPM-V-4_7-35B-A3B-Q4_0.gguf',
|
|
1498
|
+
revision: '3c3f7e84f14a760ff26d17ab5932d4aa996227c0',
|
|
1499
|
+
sha256: 'fbc90b193ea8b7fefbb04f46af583e1dd80177be91671f624036c2ec77639488',
|
|
1500
|
+
size: 19714550688,
|
|
1501
|
+
// The vision encoder (projector minicpmv4_7), same repo and revision; loaded on the first image.
|
|
1502
|
+
mmproj: { file: 'mmproj-model-f16.gguf', size: 1119575840, sha256: 'bff3b688e14c300eca273092c37c50a0ddfcc2054e079af1a0480edeed5d20f1' },
|
|
1503
|
+
};
|
|
1504
|
+
|
|
1505
|
+
export class MiniCpmV47Ssd extends Qwen35MoeSsd {
|
|
1506
|
+
static async load(modelId = null, opts = {}) {
|
|
1507
|
+
return super.load(modelId, opts.source || opts.url ? opts : { ...opts, source: MINICPM_V47_35B_A3B });
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// OpenBMB's tokenizer.json splits words with Qwen2's rule (\p{L}+, no \p{M}); the GGUF header says `qwen35`, whose
|
|
1511
|
+
// rule keeps combining marks in the word. Follow the model's own tokenizer: Hindi and Tamil differ, 15/15 strings
|
|
1512
|
+
// match HF `tokenizers` this way (2026-10-14). llama.cpp follows the header, so its ids differ on those scripts.
|
|
1513
|
+
static tokenizerFrom(kv) { return super.tokenizerFrom({ ...kv, 'tokenizer.ggml.pre': 'qwen2' }); }
|
|
1514
|
+
|
|
1515
|
+
// MiniCPM-V's template: earlier replies carry an empty think block and lose their reasoning (the model's
|
|
1516
|
+
// own </think> and LocalMind's <|think|> … <|/think|> markers); the reply after the last question keeps it.
|
|
1517
|
+
// Content may be a list of parts: text, and images rendered as <|image_pad|> (joined by newlines, as the template's
|
|
1518
|
+
// render_content does); imageSegments() expands each pad.
|
|
1519
|
+
chatPrompt(messages, { enableThinking = true } = {}) {
|
|
1520
|
+
const lastUser = messages.map((m) => m.role).lastIndexOf('user');
|
|
1521
|
+
let p = '';
|
|
1522
|
+
messages.forEach((m, i) => {
|
|
1523
|
+
let c = (Array.isArray(m.content)
|
|
1524
|
+
? m.content.map((x) => (x && x.type === 'image' ? '<|image_pad|>' : x && x.type === 'text' ? x.text : '')).join('\n')
|
|
1525
|
+
: String(m.content ?? '')).trim();
|
|
1526
|
+
if (m.role === 'assistant' && i < lastUser) {
|
|
1527
|
+
c = c.replace(/<\|think\|>[\s\S]*?<\|\/think\|>/g, '');
|
|
1528
|
+
if (c.includes('</think>')) c = c.split('</think>').pop();
|
|
1529
|
+
c = '<think>\n\n</think>\n\n' + c.trim();
|
|
1530
|
+
}
|
|
1531
|
+
p += `<|im_start|>${m.role}\n${c}<|im_end|>\n`;
|
|
1532
|
+
});
|
|
1533
|
+
return p + '<|im_start|>assistant\n' + (enableThinking ? '<think>\n' : '<think>\n\n</think>\n\n');
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// ── Images (layer 1: one overview tile per image, width × height ≤ 448², sides multiples of 56) ──────────────
|
|
1537
|
+
// vision: a MiniCpmVVision (minicpmv_vision.js) on this engine's device.
|
|
1538
|
+
attachVision(vision) { this.vision = vision; }
|
|
1539
|
+
|
|
1540
|
+
// The vision encoder from the pinned repo (OPFS-cached after the first download), on this engine's device.
|
|
1541
|
+
async ensureVision({ onProgress = () => {} } = {}) {
|
|
1542
|
+
if (this.vision) return this.vision;
|
|
1543
|
+
const src = MINICPM_V47_35B_A3B, mm = src.mmproj;
|
|
1544
|
+
this.vision = await MiniCpmVVision.loadCached(this.device, {
|
|
1545
|
+
url: `https://huggingface.co/${src.repo}/resolve/${src.revision}/${mm.file}`, size: mm.size, name: 'minicpm-v-4_7-mmproj',
|
|
1546
|
+
source: { repo: src.repo, file: mm.file, revision: src.revision, sha256: mm.sha256, size: mm.size },
|
|
1547
|
+
fetch: this.opts.fetch, onProgress,
|
|
1548
|
+
});
|
|
1549
|
+
return this.vision;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// Prompt segments: text ids, and per image { keys, n, entries, grid, rel, embeds } (see imagePositions and
|
|
1553
|
+
// prefillSegments; embeds() runs the encoder, so an image already in the cache is not encoded again). As llama.cpp's
|
|
1554
|
+
// mtmd lays a MiniCPM-V 4.7 image out (mtmd.cpp add_canvas_chunk): the text "<image_id>N</image_id><image>", then
|
|
1555
|
+
// one canvas chunk: the overview tile and the slices row by row, each followed by its separator rows
|
|
1556
|
+
// (v.tok_embd_sep: 0 </image>, 1 <slice>, 2 </slice>, 3 \n). An image part is { type: 'image', rgb (packed RGB
|
|
1557
|
+
// bytes), width, height } (preprocessed here exactly as llama.cpp does), or { type: 'image', pixels, width, height },
|
|
1558
|
+
// one ready tile.
|
|
1559
|
+
async imageSegments(messages, opts) {
|
|
1560
|
+
const images = [];
|
|
1561
|
+
for (const m of messages) if (Array.isArray(m.content)) for (const x of m.content) if (x && x.type === 'image') images.push(x);
|
|
1562
|
+
const pieces = this.chatPrompt(messages, opts).split('<|image_pad|>');
|
|
1563
|
+
const dim = this.cfg.hidden, segs = [];
|
|
1564
|
+
for (let k = 0; k < pieces.length; k++) {
|
|
1565
|
+
const text = pieces[k] + (k < images.length ? `<image_id>${k}</image_id><image>` : '');
|
|
1566
|
+
if (text) segs.push({ ids: this.tokenizer.encode(text) });
|
|
1567
|
+
if (k >= images.length) continue;
|
|
1568
|
+
const img = images[k];
|
|
1569
|
+
const plan = img.rgb ? planImage(img.width, img.height) : null, grid = plan ? plan.grid : null;
|
|
1570
|
+
const sizes = plan ? [plan.overview, ...Array.from({ length: grid ? grid[0] * grid[1] : 0 }, () => plan.cell)] : [[img.width, img.height]];
|
|
1571
|
+
const entries = sizes.map(([w, h], e) => {
|
|
1572
|
+
if (w % 56 || h % 56) throw new Error(`image tile ${w}×${h}: sides must be multiples of 56`);
|
|
1573
|
+
const s = e - 1, last = e === sizes.length - 1;
|
|
1574
|
+
const seps = e === 0 ? (grid ? [0, 1] : [0]) : last ? [2] : (s % grid[0] === grid[0] - 1 ? [2, 3, 1] : [2, 1]);
|
|
1575
|
+
return { gw: w / 56, gh: h / 56, seps };
|
|
1576
|
+
});
|
|
1577
|
+
const n = entries.reduce((a, e) => a + e.gw * e.gh + e.seps.length, 0);
|
|
1578
|
+
const id = `img:${img.width}x${img.height}:${hashBytes(img.rgb || img.pixels)}:`;
|
|
1579
|
+
const embeds = async () => {
|
|
1580
|
+
const tiles = img.rgb ? preprocessImage(img.rgb, img.width, img.height).tiles : [img];
|
|
1581
|
+
const sep = await this.vision.separators(), out = new Float32Array(n * dim);
|
|
1582
|
+
let o = 0;
|
|
1583
|
+
for (let e = 0; e < tiles.length; e++) {
|
|
1584
|
+
const enc = await this.vision.encode(tiles[e].pixels, tiles[e].width, tiles[e].height);
|
|
1585
|
+
if (enc.dim !== dim) throw new Error(`vision output ${enc.dim} wide, text model ${dim}`);
|
|
1586
|
+
const rows = await this.vision.readback(enc.buffer, enc.tokens * enc.dim);
|
|
1587
|
+
enc.buffer.destroy();
|
|
1588
|
+
out.set(rows, o); o += rows.length;
|
|
1589
|
+
for (const r of entries[e].seps) { out.set(sep.subarray(r * dim, (r + 1) * dim), o); o += dim; }
|
|
1590
|
+
}
|
|
1591
|
+
return out;
|
|
1592
|
+
};
|
|
1593
|
+
const ents = entries.map((e) => ({ gw: e.gw, gh: e.gh, seps: e.seps.length }));
|
|
1594
|
+
segs.push({ keys: Array.from({ length: n }, (_, i) => id + i), n, entries: ents, grid, embeds, rel: MiniCpmV47Ssd.imagePositions(ents, grid) });
|
|
1595
|
+
}
|
|
1596
|
+
return segs;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// Relative positions for one image's tokens, as mtmd_canvas_decoder_pos gives them: per token [dt, dy, dx, sep]
|
|
1600
|
+
// where a tile token's position is t = pos0 + dt, z = base, y = base + dy, x = base + dx (base = pos0 - 1, the
|
|
1601
|
+
// <image> token), and a separator's (sep = 1) is pos0 + dt in every slot. Tiles share one t each; separators take
|
|
1602
|
+
// one each. The canvas is grid × the last tile's token grid; the overview is stretched over it (round(linspace),
|
|
1603
|
+
// ties to even) and slice s fills cell (s % cols, s / cols).
|
|
1604
|
+
static imagePositions(entries, grid) {
|
|
1605
|
+
const last = entries[entries.length - 1], nx = last.gw, ny = last.gh;
|
|
1606
|
+
const cw = grid ? grid[0] * nx : nx, ch = grid ? grid[1] * ny : ny;
|
|
1607
|
+
const scale = (c, g, cv) => (g <= 1 || cv <= 1 ? 0 : Math.min(roundEven((c * (cv - 1)) / (g - 1)), cv - 1));
|
|
1608
|
+
const rel = [];
|
|
1609
|
+
let t = 0;
|
|
1610
|
+
entries.forEach((e, k) => {
|
|
1611
|
+
for (let i = 0; i < e.gw * e.gh; i++) {
|
|
1612
|
+
const row = Math.floor(i / e.gw), col = i % e.gw;
|
|
1613
|
+
const [h, w] = k === 0 ? [scale(row, e.gh, ch), scale(col, e.gw, cw)] : [Math.floor((k - 1) / grid[0]) * ny + row, ((k - 1) % grid[0]) * nx + col];
|
|
1614
|
+
rel.push([t, h, w, 0]);
|
|
1615
|
+
}
|
|
1616
|
+
for (let j = 0; j < e.seps; j++) rel.push([t + 1 + j, 0, 0, 1]);
|
|
1617
|
+
t += 1 + e.seps;
|
|
1618
|
+
});
|
|
1619
|
+
return { rel, nPos: t };
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
async *generateTurn(messages, opts = {}) {
|
|
1623
|
+
const hasImage = messages.some((m) => Array.isArray(m.content) && m.content.some((x) => x && x.type === 'image'));
|
|
1624
|
+
if (!hasImage) { yield* super.generateTurn(messages, opts); return; }
|
|
1625
|
+
if (!this.vision) throw new Error('this message has an image, and no vision encoder is attached');
|
|
1626
|
+
const { maxNewTokens = 512, signal, enableThinking = true } = opts;
|
|
1627
|
+
const stops = new Set(this.stopTokenIds);
|
|
1628
|
+
const segs = await this.imageSegments(messages, { enableThinking });
|
|
1629
|
+
let next = await this.prefillSegments(segs, 'argmax', { sysEnd: segs[0].ids ? this.checkpointEnd(segs[0].ids) : 0 });
|
|
1630
|
+
const outIds = [];
|
|
1631
|
+
for (let i = 0; i < maxNewTokens; i++) {
|
|
1632
|
+
if (signal && signal.aborted) break;
|
|
1633
|
+
if (stops.has(next)) break;
|
|
1634
|
+
outIds.push(next);
|
|
1635
|
+
yield { text: this.tokenizer.decode(outIds), token: next, tokens: outIds.length };
|
|
1636
|
+
if (this.position >= this.maxCtx) break;
|
|
1637
|
+
next = await this.step(next, 'argmax');
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1230
1642
|
// For kernel tests (scripts): the WGSL this module adds.
|
|
1231
1643
|
export { KERNELS as QWEN35_KERNELS, BATCH_KERNELS as QWEN35_BATCH_KERNELS, SG_KERNELS as QWEN35_SG_KERNELS };
|