diskformer 0.5.2 → 0.5.4

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.
@@ -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 { Qwen3MoeSsd, STORAGE, COPY_SRC, COPY_DST, MAP_READ, UNIFORM, I8 } from './qwen3_moe_ssd.js';
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 k = { ...super.kernels, ...KERNELS, ...(this.subgroupKernels ? SG_KERNELS : {}) };
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 q8 = (name, x, y, uni) => { const t = W[name]; if (!t || !t.q) throw new Error(`missing Q8_0 tensor ${name}`); return this.bind('matmulQ8', [x, t.q, t.s, y, uni]); };
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('embedQ8', [W['token_embd.weight'].q, W['token_embd.weight'].s, a.x, this.tok, u.nH]) };
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('matmulQ8', [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]),
829
- shu: this.bindR('matmulQ8', [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]),
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('expertQ8', [a.xn, this.pool.guQ, this.pool.guS, a.gu, u.gu, a.slots]),
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('expertQ8', [a.act, this.pool.dQ, this.pool.dS, a.dn, u.dn, a.slots]),
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 = this.bind('matmulQ8', [a.xn, W['output.weight'].q, W['output.weight'].s, a.logits, u.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 q8 = (name, x, y, uni) => this.bind('matmulQ8T', [x, W[name].q, W[name].s, y, uni, qb]);
877
- this.gB = { embed: this.bind('embedQ8B', [W['token_embd.weight'].q, W['token_embd.weight'].s, b.ids, b.x, u.nH, qb]) };
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('attnScoreB', [b.q, this.kc[l], b.sc, u.att, qb]),
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('attnOutB', [b.sc, this.vc[l], b.att, u.attOut, qb]),
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('expertQ8B', [b.xn, this.pool.guQ, this.pool.guS, b.gu, u.gu, b.slots]),
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('expertQ8B', [b.act, this.pool.dQ, this.pool.dS, b.dn, u.dn, b.slots]),
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('matmulQ8T', g.qkv, mm(convDim), tt); d('matmulQ8T', g.z, mm(s.dInner), tt);
928
- d('matmulQ8T', g.beta, mm(s.vHeads), tt); d('matmulQ8T', g.alpha, mm(s.vHeads), tt);
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('matmulQ8T', g.out, mm(H), tt);
1051
+ d(this.qk.mmT, g.out, mm(H), tt);
933
1052
  } else {
934
- d('matmulQ8T', g.q, mm(2 * QN), tt); d('matmulQ8T', g.k, mm(kvn), tt); d('matmulQ8T', g.v, mm(kvn), tt);
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('attnScoreB', g.score, Math.ceil(c.heads * T * S / 256));
1056
+ d('attnScorePosB', g.score, Math.ceil(c.heads * T * S / 256));
938
1057
  d('softmaxB', g.soft, c.heads * T);
939
- d('attnOutB', g.attOut, Math.ceil(QN / 256), T);
1058
+ d('attnOutPosB', g.attOut, Math.ceil(QN / 256), T);
940
1059
  d('sigmoidMulB', g.gate, Math.ceil(QN / 256), T);
941
- d('matmulQ8T', g.o, mm(H), tt);
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('matmulQ8T', g.shg, mm(c.shexpFf), tt); d('matmulQ8T', g.shu, mm(c.shexpFf), tt);
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('matmulQ8T', g.shd, mm(H), tt);
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('expertQ8B', g.gu, Math.ceil(2 * c.expertFf / 4), 1, T * c.topK);
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('expertQ8B', g.dn, Math.ceil(c.hidden / 4), 1, T * c.topK);
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
- async prefillChunk(ids, want = 'none') {
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 nRot = c.ropeDims, half = nRot / 2, r = new Float32Array(T * nRot), stepR = Math.fround(Math.pow(c.ropeTheta, -2 / nRot));
973
- for (let t = 0; t < T; t++) {
974
- let th = Math.fround(pos0 + t);
975
- for (let i = 0; i < half; i++) { r[t * nRot + i] = Math.cos(th); r[t * nRot + half + i] = Math.sin(th); th = Math.fround(th * stepR); }
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, 'embedQ8B', this.gB.embed, Math.ceil(H / 256), T);
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, 'matmulQ8', this.g.lm, Math.ceil(c.vocab / 4));
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('matmulQ8', g.qkv, Math.ceil(convDim / 4)); d('matmulQ8', g.z, Math.ceil(s.dInner / 4));
1049
- d('matmulQ8', g.beta, Math.ceil(s.vHeads / 4)); d('matmulQ8', g.alpha, Math.ceil(s.vHeads / 4));
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('matmulQ8', g.out, Math.ceil(H / 4));
1174
+ d(this.qk.mm, g.out, Math.ceil(H / 4));
1054
1175
  } else {
1055
- d('matmulQ8', g.q, Math.ceil(2 * QN / 4)); d('matmulQ8', g.k, Math.ceil(kvn / 4)); d('matmulQ8', g.v, Math.ceil(kvn / 4));
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('matmulQ8', g.o, Math.ceil(H / 4));
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('matmulQ8', g.shg, Math.ceil(c.shexpFf / 4)); d('matmulQ8', g.shu, Math.ceil(c.shexpFf / 4));
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('matmulQ8', g.shd, Math.ceil(H / 4));
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('expertQ8', g.gu, Math.ceil(2 * c.expertFf / 4), 1, c.topK);
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('expertQ8', g.dn, Math.ceil(c.hidden / 4), 1, c.topK);
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, 'embedQ8', this.g.embed, Math.ceil(c.hidden / 256));
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, 'matmulQ8', this.g.lm, Math.ceil(c.vocab / 4));
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 r = new Float32Array(nRot), step = Math.fround(Math.pow(c.ropeTheta, -2 / nRot));
1194
- let th = Math.fround(pos);
1195
- for (let i = 0; i < half; i++) { r[i] = Math.cos(th); r[half + i] = Math.sin(th); th = Math.fround(th * step); }
1196
- this.device.queue.writeBuffer(this.a.rope, 0, r);
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 any change before the cached
1207
- // end restarts from token 0.
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
- async prefill(ids, want = 'argmax') {
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 < ids.length - 1 && common < this.cached.length && this.cached[common] === ids[common]) common++;
1219
- if (common < this.cached.length) this.reset();
1220
- const rest = ids.slice(this.cached.length);
1221
- if (!this.batchPrefill || rest.length < 2) return super.prefill(ids, want);
1222
- let r = null;
1223
- for (let i = 0; i < rest.length; i += this.chunkTokens) {
1224
- r = await this.prefillChunk(rest.slice(i, i + this.chunkTokens), i + this.chunkTokens >= rest.length ? want : 'none');
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 };