mlx-bun 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/docs/README.md +4 -1
  2. package/docs/archive/investigations/openjarvis-fit.md +198 -0
  3. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  4. package/docs/design/batching.md +1321 -49
  5. package/docs/design/decode-speed-program.md +331 -1163
  6. package/docs/design/generic-model-support.md +79 -3
  7. package/docs/design/kv-cache.md +198 -54
  8. package/docs/design/speculative-decoding.md +387 -24
  9. package/docs/design/turboquant.md +123 -13
  10. package/docs/design/unified-engine-frontier-plan.md +144 -142
  11. package/docs/planning/PRODUCT_ROADMAP.md +16 -0
  12. package/docs/planning/release-notes-v0.4.0.md +45 -0
  13. package/docs/reference/benchmarks.md +2457 -6
  14. package/docs/reference/cli.md +41 -2
  15. package/docs/reference/distribution.md +16 -9
  16. package/docs/reference/environment.md +55 -5
  17. package/docs/reference/models.md +13 -4
  18. package/docs/reference/server-api.md +79 -25
  19. package/docs/reference/server-config.md +178 -84
  20. package/docs/reference/troubleshooting.md +13 -21
  21. package/package.json +1 -1
  22. package/src/backends/mlx/assistant-target.ts +46 -0
  23. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  24. package/src/backends/mlx/batch-group.ts +368 -224
  25. package/src/backends/mlx/cache-attachment.ts +67 -0
  26. package/src/backends/mlx/cache-layout.ts +31 -0
  27. package/src/backends/mlx/checkpoint-state.ts +53 -0
  28. package/src/backends/mlx/continuation-persistence.ts +106 -0
  29. package/src/backends/mlx/continuation-request.ts +43 -0
  30. package/src/backends/mlx/continuation.ts +77 -0
  31. package/src/backends/mlx/draft-target.ts +7 -22
  32. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  33. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  34. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  35. package/src/backends/mlx/gateway-binding.ts +77 -18
  36. package/src/backends/mlx/kv-codec.ts +9 -0
  37. package/src/backends/mlx/kv-maintenance.ts +133 -0
  38. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  39. package/src/backends/mlx/model-serving.ts +1 -1
  40. package/src/backends/mlx/prefill-cohort.ts +89 -0
  41. package/src/backends/mlx/prefill-rows.ts +200 -0
  42. package/src/backends/mlx/prefill.ts +1 -1
  43. package/src/backends/mlx/projected-context-rows.ts +72 -0
  44. package/src/backends/mlx/request-state-policy.ts +41 -0
  45. package/src/backends/mlx/rollback.ts +41 -3
  46. package/src/backends/mlx/row-sampling.ts +49 -0
  47. package/src/backends/mlx/serial-executor.ts +35 -66
  48. package/src/backends/mlx/speculative-group.ts +359 -0
  49. package/src/backends/mlx/speculative-round.ts +161 -0
  50. package/src/backends/mlx/state-rows.ts +104 -0
  51. package/src/backends/mlx/state-views.ts +21 -1
  52. package/src/backends/mlx/target-layout-capability.ts +35 -0
  53. package/src/backends/mlx/transitioning-kv-rows.ts +165 -0
  54. package/src/cli.ts +11 -36
  55. package/src/contracts/execution.ts +11 -0
  56. package/src/contracts/prefix-cache.ts +23 -0
  57. package/src/contracts/resources.ts +7 -0
  58. package/src/contracts/sampling.ts +23 -0
  59. package/src/contracts/scheduling.ts +18 -0
  60. package/src/engine/execution-plan.ts +18 -16
  61. package/src/engine/resources.ts +12 -1
  62. package/src/engine/scheduler.ts +10 -5
  63. package/src/fit.ts +29 -15
  64. package/src/generate.ts +77 -173
  65. package/src/inference/draft-acceptance.ts +41 -0
  66. package/src/inference/draft-output.ts +49 -0
  67. package/src/inference/rollback.ts +2 -2
  68. package/src/kv-scheme.ts +70 -12
  69. package/src/kv-store.ts +70 -16
  70. package/src/lab/paged-kv/paged-kv-rows.ts +83 -0
  71. package/src/lab/paged-kv/paged-kv.ts +25 -11
  72. package/src/mlx/array.ts +2 -1
  73. package/src/model/artifact-identity.ts +73 -0
  74. package/src/model/batched-kv.ts +235 -0
  75. package/src/model/batched-mask.ts +12 -4
  76. package/src/model/batched-quant.ts +8 -2
  77. package/src/model/batched-quantized-kv.ts +144 -0
  78. package/src/model/batched-rotating-quant.ts +76 -15
  79. package/src/model/batched-rotating-state.ts +76 -0
  80. package/src/model/batched-rotating.ts +64 -22
  81. package/src/model/batched-row-storage.ts +52 -0
  82. package/src/model/batched-ssm.ts +52 -0
  83. package/src/model/batched-turboquant-kv.ts +93 -0
  84. package/src/model/compiled-decode.ts +7 -1
  85. package/src/model/decoded-kv-donor.ts +12 -0
  86. package/src/model/delayed-quantized-kv.ts +72 -0
  87. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  88. package/src/model/delayed-turboquant-kv.ts +55 -0
  89. package/src/model/full-prefill-padding.ts +56 -0
  90. package/src/model/full-prefill-row.ts +105 -0
  91. package/src/model/gemma4-base.ts +162 -80
  92. package/src/model/gemma4.ts +34 -26
  93. package/src/model/generated/gemma4-12b.ts +4 -1
  94. package/src/model/generated/gemma4-26b.ts +4 -1
  95. package/src/model/generated/gemma4-e4b.ts +4 -1
  96. package/src/model/glm52-cache.ts +80 -4
  97. package/src/model/glm52-mla.ts +12 -6
  98. package/src/model/glm52.ts +19 -40
  99. package/src/model/kv-attention-view.ts +67 -0
  100. package/src/model/minicpm5.ts +11 -7
  101. package/src/model/qwen3-delta-state.ts +74 -0
  102. package/src/model/qwen3-delta.ts +59 -36
  103. package/src/model/qwen3_5.ts +82 -42
  104. package/src/model/rotating-kv-donor.ts +35 -0
  105. package/src/model/rotating-kv-layout.ts +262 -0
  106. package/src/model/rotating-row-transaction.ts +35 -0
  107. package/src/model/speculative-rotating-kv.ts +101 -0
  108. package/src/model/ssm-prefill-padding.ts +39 -0
  109. package/src/model/trellis-linear.ts +2 -2
  110. package/src/model/turboquant-codec.ts +83 -0
  111. package/src/model/universal/dense.ts +14 -13
  112. package/src/preflight.ts +31 -15
  113. package/src/prompt-cache.ts +42 -44
  114. package/src/sampler.ts +36 -25
  115. package/src/serve/chat-stage.ts +2 -2
  116. package/src/serve/generation-gateway.ts +39 -30
  117. package/src/serve/model-host.ts +3 -4
  118. package/src/serve/request-plan.ts +4 -3
  119. package/src/serve/request-prep.ts +2 -4
  120. package/src/serve/text-completion-stage.ts +2 -2
  121. package/src/server.ts +64 -108
  122. package/src/spec/assistant-rows.ts +92 -0
  123. package/src/spec/assistant-source.ts +28 -18
  124. package/src/spec/cache-identity.ts +22 -0
  125. package/src/spec/deepspec-rows.ts +29 -0
  126. package/src/spec/deepspec-source.ts +12 -10
  127. package/src/spec/dflash-source.ts +28 -4
  128. package/src/spec/drafter.ts +153 -142
  129. package/src/spec/dspark/deepspec-module.ts +95 -120
  130. package/src/spec/dspark/module-dflash.ts +126 -170
  131. package/src/spec/glm52-mtp-graph.ts +29 -0
  132. package/src/spec/glm52-mtp-rows.ts +132 -0
  133. package/src/spec/glm52-mtp-source.ts +53 -136
  134. package/src/spec/glm52-mtp-state.ts +33 -0
  135. package/src/spec/ngram-source.ts +92 -20
  136. package/src/spec/projected-draft-rows.ts +73 -0
  137. package/src/spec/qwen-mtp-module.ts +171 -0
  138. package/src/spec/qwen-mtp-rows.ts +219 -0
  139. package/src/spec/qwen-mtp-source.ts +81 -226
  140. package/src/spec/qwen-mtp-state.ts +30 -0
  141. package/src/spec/serve-loop.ts +70 -68
  142. package/src/spec/source.ts +80 -15
  143. package/src/spec/two-model-rows.ts +61 -0
  144. package/src/spec/two-model.ts +43 -101
  145. package/src/ssd-cache.ts +33 -21
  146. package/src/ssd-durability.ts +11 -7
  147. package/src/web/app.js +57 -57
  148. package/src/backends/mlx/memory-guard.ts +0 -40
  149. package/src/spec/prefix-state.ts +0 -45
  150. /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
@@ -5,8 +5,8 @@
5
5
  // default (use_kernel = not training), and its float accumulation / simd_sum
6
6
  // reduction order differ from the pure-ops fallback — so BIT-EXACT parity with
7
7
  // mlx-lm requires the SAME kernel, dispatched with the SAME grid/threadgroup.
8
- // We port the non-vectorized, non-masked variant (g.ndim == 3, mask is None),
9
- // which is the B=1 single-stream path (ssm_mask is None at batch 1).
8
+ // We port the non-vectorized variant (g.ndim == 3). Padded prefill selects
9
+ // the oracle masked specialization; unpadded execution retains its kernel.
10
10
  //
11
11
  // Numerics (must match the reference dtypes exactly — mlx infers the kernel's
12
12
  // pointer element types from the input arrays):
@@ -20,10 +20,12 @@
20
20
  // GQA is handled inside the kernel (hk_idx = hv_idx / (Hv/Hk)); q/k stay at Hk.
21
21
 
22
22
  import { MlxArray } from "../mlx/array";
23
+ import { materializeCopy } from "../mlx/materialize";
23
24
  import { Dtype } from "../mlx/ffi";
24
25
  import { MetalKernel } from "../mlx/metal-kernel";
25
26
  import { CompiledFunction } from "../mlx/compile";
26
27
  import * as ops from "../mlx/ops";
28
+ import type { SsmPrefillPadding } from "./ssm-prefill-padding";
27
29
  import type { Cache, Mask } from "./gemma4-base";
28
30
 
29
31
  // Verbatim body of mlx-lm's gated_delta_step (has_mask=False, vectorized=False),
@@ -104,7 +106,15 @@ const SOURCE = String.raw`
104
106
  `;
105
107
 
106
108
  let kernel: MetalKernel | null = null;
107
- function getKernel(): MetalKernel {
109
+ let maskedKernel: MetalKernel | null = null;
110
+ function getKernel(masked = false): MetalKernel {
111
+ if (masked) return maskedKernel ??= new MetalKernel({
112
+ name: "gated_delta_step_mask",
113
+ inputNames: ["q", "k", "v", "g", "beta", "state_in", "Tin", "mask"],
114
+ outputNames: ["y", "state_out"],
115
+ source: SOURCE.replace("if (true)", "if (mask[b_idx * T + t])"),
116
+ ensureRowContiguous: true,
117
+ });
108
118
  if (!kernel)
109
119
  kernel = new MetalKernel({
110
120
  name: "gated_delta_step",
@@ -154,7 +164,7 @@ export function computeG(aLog: MlxArray, a: MlxArray, dtBias: MlxArray): MlxArra
154
164
  * state: [B, Hv, Dv, Dk] f32 (or null → zeros) */
155
165
  export function gatedDeltaUpdate(
156
166
  q: MlxArray, k: MlxArray, v: MlxArray, a: MlxArray, b: MlxArray,
157
- aLog: MlxArray, dtBias: MlxArray, state: MlxArray | null,
167
+ aLog: MlxArray, dtBias: MlxArray, state: MlxArray | null, mask: MlxArray | null = null,
158
168
  ): [MlxArray, MlxArray] {
159
169
  const [B, , Hk, Dk] = q.shape as [number, number, number, number];
160
170
  const [, , Hv, Dv] = v.shape as [number, number, number, number];
@@ -171,7 +181,9 @@ export function gatedDeltaUpdate(
171
181
 
172
182
  const T = q.shape[1]!;
173
183
  const tArr = MlxArray.fromInt32(new Int32Array([T]), [1]);
174
- const [y, stateOut] = getKernel().apply([q, k, v, g, beta, stateIn, tArr], {
184
+ const inputs = [q, k, v, g, beta, stateIn, tArr];
185
+ if (mask) inputs.push(mask);
186
+ const [y, stateOut] = getKernel(mask !== null).apply(inputs, {
175
187
  outputs: [
176
188
  { shape: [B, T, Hv, Dv], dtype: q.dtype },
177
189
  { shape: [B, Hv, Dv, Dk], dtype: Dtype.float32 },
@@ -207,6 +219,7 @@ export interface SsmSpecRound {
207
219
  prevConv: MlxArray | null;
208
220
  prevRecurrent: MlxArray | null;
209
221
  prevOffset: number;
222
+ prevOffsets: number[] | null;
210
223
  /** Pre-conv in_proj output [B,S,convDim] — position-local. */
211
224
  qkv: MlxArray | null;
212
225
  /** Gate/beta projections [B,S,Hv] — position-local. */
@@ -216,14 +229,15 @@ export interface SsmSpecRound {
216
229
  S: number;
217
230
  /** Layer-bound prefix replay: called AFTER the snapshot is restored onto
218
231
  * the cache; advances conv/recurrent/offset by `keep` tokens. */
219
- replay: ((cache: SSMCache, keep: number) => void) | null;
232
+ replay: ((cache: SSMCache, keep: number | readonly number[]) => void) | null;
220
233
  }
221
234
 
222
235
  export class SSMCache implements Cache {
223
236
  conv: MlxArray | null = null;
224
237
  recurrent: MlxArray | null = null;
225
238
  offset = 0;
226
- /** Live speculative verify round (serial spec lane only; null otherwise). */
239
+ prefillPadding: SsmPrefillPadding | null = null;
240
+ /** Live speculative verify round; null outside a verify transaction. */
227
241
  specRound: SsmSpecRound | null = null;
228
242
  /** Per-row token coverage (batch lane only; null on serial B=1 caches,
229
243
  * where `offset` IS the row's count). mlx-lm's ArraysCache tracks no
@@ -248,15 +262,13 @@ export class SSMCache implements Cache {
248
262
  throw new Error("SSMCache has no KV updateAndFetch (gated-DeltaNet layer)");
249
263
  }
250
264
 
251
- makeMask(_N: number, _windowSize: number | null): Mask {
252
- // ssm_mask is None both single-stream and in the batch lane: rows
253
- // solo-prefill UNPADDED at B=1 (state never sees pad tokens), and
254
- // batched decode feeds one real token per row — so unlike mlx-lm's
255
- // left-padded batch-prefill there is never a pad position to mask.
256
- return { mode: "", arr: null };
265
+ makeMask(N: number, _windowSize: number | null): Mask {
266
+ const arr = this.prefillPadding?.makeMask(N) ?? null;
267
+ return { mode: arr ? "array" : "", arr };
257
268
  }
258
269
 
259
270
  advance(n: number): void {
271
+ if (this.prefillPadding) { this.advanceRows(this.prefillPadding.advance(n)); return; }
260
272
  this.offset += n;
261
273
  // Batched rows step together (one forward advances every row), so the
262
274
  // per-row counts move in lockstep; they DIFFER only in their merge-time
@@ -264,6 +276,13 @@ export class SSMCache implements Cache {
264
276
  if (this.offsets) for (let i = 0; i < this.offsets.length; i++) this.offsets[i]! += n;
265
277
  }
266
278
 
279
+ /** Methods may retain different verified prefixes for each active request. */
280
+ advanceRows(counts: readonly number[]): void {
281
+ if (!this.offsets) { this.offset += counts[0]!; return; }
282
+ for (let row = 0; row < this.offsets.length; row++) this.offsets[row]! += counts[row]!;
283
+ this.offset = Math.max(...this.offsets);
284
+ }
285
+
267
286
  state(): MlxArray[] {
268
287
  const out: MlxArray[] = [];
269
288
  if (this.conv) out.push(this.conv);
@@ -279,7 +298,7 @@ export class SSMCache implements Cache {
279
298
  throw new Error("SSMCache is not trimmable");
280
299
  }
281
300
 
282
- // --- speculative verify-round support (serial spec lane, B=1) ------------
301
+ // --- speculative verify-round support -----------------------------------
283
302
  // The serve loop arms a round before the verify forward and resolves it
284
303
  // after the accept walk. The layer's forward, seeing an armed round, hands
285
304
  // its replaced state slots to the round instead of disposing them, records
@@ -294,6 +313,7 @@ export class SSMCache implements Cache {
294
313
  prevConv: null,
295
314
  prevRecurrent: null,
296
315
  prevOffset: this.offset,
316
+ prevOffsets: this.offsets ? [...this.offsets] : null,
297
317
  qkv: null,
298
318
  a: null,
299
319
  b: null,
@@ -306,12 +326,12 @@ export class SSMCache implements Cache {
306
326
  this.#dropSpecRound();
307
327
  }
308
328
 
309
- specRoundRollback(keep: number): void {
329
+ specRoundRollback(keep: number | readonly number[]): void {
310
330
  const r = this.specRound;
311
331
  if (!r) throw new Error("SSMCache.specRoundRollback without an armed round");
312
332
  if (r.armed)
313
333
  throw new Error("SSMCache.specRoundRollback before the verify forward recorded");
314
- if (keep < 0 || keep > r.S)
334
+ if (typeof keep === "number" ? keep < 0 || keep > r.S : keep.some(count => count < 0 || count > r.S))
315
335
  throw new Error(`SSMCache.specRoundRollback keep=${keep} outside window S=${r.S}`);
316
336
  // Restore the pre-round snapshot (ownership moves back to the cache).
317
337
  this.conv?.dispose();
@@ -321,9 +341,8 @@ export class SSMCache implements Cache {
321
341
  r.prevConv = null;
322
342
  r.prevRecurrent = null;
323
343
  this.offset = r.prevOffset;
324
- if (this.offsets)
325
- throw new Error("SSMCache.specRoundRollback on a batched cache (serial lane only)");
326
- if (keep > 0) {
344
+ this.offsets = r.prevOffsets;
345
+ if (typeof keep === "number" ? keep > 0 : keep.some(count => count > 0)) {
327
346
  if (!r.replay) throw new Error("SSMCache.specRoundRollback with no recorded replay");
328
347
  r.replay(this, keep); // advances conv/recurrent/offset by `keep`
329
348
  }
@@ -347,6 +366,7 @@ export class SSMCache implements Cache {
347
366
  this.recurrent?.dispose();
348
367
  this.conv = null;
349
368
  this.recurrent = null;
369
+ this.prefillPadding = null;
350
370
  }
351
371
 
352
372
  // --- batch-lane dynamic-B ops (BatchScheduler only) ----------------------
@@ -386,25 +406,30 @@ export class SSMCache implements Cache {
386
406
 
387
407
  /** Evict rows not in `keep` (ascending row indices), in place. */
388
408
  filter(keep: number[]): void {
389
- if (!this.conv || !this.recurrent) return;
390
- const idx = ops.fromInt32(keep, [keep.length]);
391
- const conv = ops.takeAxis(this.conv, idx, 0);
392
- const recurrent = ops.takeAxis(this.recurrent, idx, 0);
393
- idx.dispose();
394
- this.conv.dispose();
395
- this.recurrent.dispose();
396
- this.conv = conv;
397
- this.recurrent = recurrent;
398
- if (this.offsets) this.offsets = keep.map((i) => this.offsets![i]!);
409
+ if (this.conv && this.recurrent) {
410
+ const idx = ops.fromInt32(keep, [keep.length]);
411
+ const conv = ops.takeAxis(this.conv, idx, 0);
412
+ const recurrent = ops.takeAxis(this.recurrent, idx, 0);
413
+ idx.dispose();
414
+ this.conv.dispose();
415
+ this.recurrent.dispose();
416
+ this.conv = conv;
417
+ this.recurrent = recurrent;
418
+ }
419
+ this.prefillPadding?.filter(keep);
420
+ if (this.offsets) {
421
+ this.offsets = keep.map((i) => this.offsets![i]!);
422
+ this.offset = Math.max(0, ...this.offsets);
423
+ }
399
424
  }
400
425
 
401
426
  filterRows(keep: readonly number[]): void { this.filter([...keep]); }
402
427
 
403
428
  /** Row `i` as a fresh SERIAL cache — port of mlx-lm ArraysCache.extract
404
429
  * (cache.py:673-676: `cache.cache = [c[idx : idx + 1] for c in self.cache]`,
405
- * a B-axis slice of every state slot). Ours are OWNED contiguous copies
406
- * (the entry outlives the batch; the batched buffers must free when the
407
- * batch moves on — same rule as extractKVRow, batched-mask.ts). Offset =
430
+ * a B-axis slice of every state slot). A proper subset gets compact storage
431
+ * so retaining one row does not pin the other rows. A singleton retains an
432
+ * independent handle over its complete allocation. Offset =
408
433
  * the row's OWN coverage, not the shared max. */
409
434
  extractRow(i: number): SSMCache {
410
435
  if (!this.conv || !this.recurrent)
@@ -414,10 +439,8 @@ export class SSMCache implements Cache {
414
439
  lo[0] = i;
415
440
  const hi = [...a.shape];
416
441
  hi[0] = i + 1;
417
- const view = a.slice(lo, hi);
418
- const own = ops.copyOf(view); // TRUE copy: contiguous(view) is a no-op VIEW when already contiguous — pins the source buffer (2026-08-20 DeltaNet conv leak class)
419
- view.dispose();
420
- return own;
442
+ using view = a.slice(lo, hi);
443
+ return a.shape[0] === 1 ? ops.contiguous(view) : materializeCopy(view);
421
444
  };
422
445
  const out = new SSMCache();
423
446
  out.conv = cut(this.conv);
@@ -10,6 +10,7 @@
10
10
  // shared maybeQuantizeKv path).
11
11
 
12
12
  import type { ModelConfig } from "../config";
13
+ import { gatedDeltaState } from "./qwen3-delta-state";
13
14
  import type { Weights } from "../weights";
14
15
  import { MlxArray } from "../mlx/array";
15
16
  import { Dtype, deviceArchitecture } from "../mlx/ffi";
@@ -23,7 +24,6 @@ import {
23
24
  disposing,
24
25
  KVCache,
25
26
  LoraState,
26
- QuantizedKVCache,
27
27
  QuantizedEmbedding,
28
28
  QuantizedLinear,
29
29
  quantizedSdpa,
@@ -155,16 +155,20 @@ export class GatedDeltaNet {
155
155
  this.normWeight = weights.tensor(`${prefix}.norm.weight`);
156
156
  }
157
157
 
158
- #convolve(qkv: MlxArray, state: MlxArray): [MlxArray, MlxArray] {
159
- if (this.convolution) return this.convolution(qkv, state, this.convWeight);
160
- const [B, S, D] = qkv.shape as [number, number, number];
161
- const nKeep = this.convKernel - 1;
158
+ #convolve(qkv: MlxArray, state: MlxArray, rowLengths?: readonly number[]): [MlxArray, MlxArray] {
159
+ if (this.convolution) {
160
+ const result = this.convolution(qkv, state, this.convWeight);
161
+ if (!rowLengths) return result;
162
+ using input = ops.concatAxis([state, qkv], 1);
163
+ const tail = this.#convTail(input, qkv.shape[1]!, rowLengths);
164
+ result[1].dispose();
165
+ return [result[0], tail];
166
+ }
167
+ const [, S, D] = qkv.shape as [number, number, number];
162
168
  const input = ops.concatAxis([state, qkv], 1);
163
169
  // MLX copy and contiguous can both alias the whole prefill buffer.
164
170
  // Materialize only the tail so cache residency follows its logical size.
165
- const view = input.slice([0, S, 0], [B, S + nKeep, D]);
166
- const tail = materializeCopy(view);
167
- view.dispose();
171
+ const tail = this.#convTail(input, S, rowLengths);
168
172
  const conv = ops.conv1d(input, this.convWeight, 1, 0, 1, D);
169
173
  input.dispose();
170
174
  const out = compiledSilu(conv);
@@ -172,7 +176,19 @@ export class GatedDeltaNet {
172
176
  return [out, tail];
173
177
  }
174
178
 
175
- forward(x: MlxArray, cache: SSMCache, independentRows = false): MlxArray {
179
+ #convTail(input: MlxArray, processed: number, rowLengths?: readonly number[]): MlxArray {
180
+ const [B, , D] = input.shape as [number, number, number];
181
+ const nKeep = this.convKernel - 1;
182
+ if (rowLengths) {
183
+ using indices = ops.fromInt32(rowLengths.flatMap(length =>
184
+ Array.from({ length: nKeep }, (_, position) => length + position)), [B, nKeep, 1]);
185
+ return ops.takeAlongAxis(input, indices, 1);
186
+ }
187
+ using view = input.slice([0, processed, 0], [B, processed + nKeep, D]);
188
+ return materializeCopy(view);
189
+ }
190
+
191
+ forward(x: MlxArray, cache: SSMCache, independentRows = false, mask?: MlxArray | null): MlxArray {
176
192
  const [B, S] = x.shape as [number, number, number];
177
193
  const convDim = this.keyDim * 2 + this.valueDim;
178
194
  const nKeep = this.convKernel - 1;
@@ -194,7 +210,7 @@ export class GatedDeltaNet {
194
210
  }
195
211
 
196
212
  const t0 = prof ? performance.now() : 0;
197
- const qkv = this.inProjQkv.forward(x, independentRows); // [B,S,convDim]
213
+ let qkv = this.inProjQkv.forward(x, independentRows); // [B,S,convDim]
198
214
  let z = this.inProjZ.forward(x, independentRows);
199
215
  z = disposing(z, ops.reshape(z, [B, S, this.numVHeads, this.headVDim]));
200
216
  const b = this.inProjB.forward(x, independentRows); // [B,S,numVHeads]
@@ -202,10 +218,19 @@ export class GatedDeltaNet {
202
218
  if (prof) { ops.evalAll([qkv, z, b, a]); prof.proj = (prof.proj ?? 0) + performance.now() - t0; }
203
219
  const tc = prof ? performance.now() : 0; // [B,S,numVHeads]
204
220
 
205
- // Causal depthwise conv with the conv-state prefix (B=1: no ssm mask).
221
+ // The model shares one padding mask across recurrent layers. Direct layer
222
+ // callers can request the same cache-owned mask without a model wrapper.
223
+ using localMask = mask === undefined ? cache.prefillPadding?.makeMask(S) ?? null : null;
224
+ const ssmMask = mask ?? localMask;
225
+ if (ssmMask) {
226
+ using expanded = ops.reshape(ssmMask, [B, S, 1]);
227
+ using zero = MlxArray.fromBytesCopy(new Uint8Array(qkv.dtype === Dtype.float32 ? 4 : 2), [], qkv.dtype);
228
+ qkv = disposing(qkv, ops.where(expanded, qkv, zero));
229
+ }
230
+ // Convolution keeps each row's last real-token tail, including empty rows.
206
231
  const convState =
207
232
  cache.conv ?? ops.zeros([B, nKeep, convDim], x.dtype);
208
- const [convOut, newConv] = this.#convolve(qkv, convState);
233
+ const [convOut, newConv] = this.#convolve(qkv, convState, cache.prefillPadding?.convolutionLengths(S));
209
234
  if (!cache.conv) convState.dispose();
210
235
  if (spec) spec.qkv = qkv;
211
236
  else qkv.dispose();
@@ -236,7 +261,7 @@ export class GatedDeltaNet {
236
261
  const tk = prof ? performance.now() : 0;
237
262
 
238
263
  const [out, newState] = gatedDeltaUpdate(
239
- q, k, v, a, b, this.aLog, this.dtBias, cache.recurrent,
264
+ q, k, v, a, b, this.aLog, this.dtBias, cache.recurrent, ssmMask,
240
265
  );
241
266
  if (prof) { ops.evalAll([out]); prof.kernel = (prof.kernel ?? 0) + performance.now() - tk; }
242
267
  const to = prof ? performance.now() : 0;
@@ -275,18 +300,21 @@ export class GatedDeltaNet {
275
300
  * recurrent state is BIT-EXACTLY what a forward over only the accepted
276
301
  * prefix would have produced. Output projections (z / out_proj) are state-
277
302
  * free and skipped: only the states matter here. */
278
- #replaySpecPrefix(cache: SSMCache, keep: number): void {
303
+ #replaySpecPrefix(cache: SSMCache, keep: number | readonly number[]): void {
279
304
  const r = cache.specRound;
280
- if (!r || !r.qkv || !r.a || !r.b) throw new Error("GatedDeltaNet replay without recorded round inputs");
305
+ if (!r || !r.qkv || !r.a || !r.b)
306
+ throw new Error("GatedDeltaNet replay without recorded round inputs");
281
307
  const B = r.qkv.shape[0]!;
308
+ const count = typeof keep === "number" ? keep : Math.max(...keep);
309
+ const rowLengths = typeof keep === "number" ? undefined : keep;
282
310
  const convDim = this.keyDim * 2 + this.valueDim;
283
311
  const nKeep = this.convKernel - 1;
284
312
 
285
- const qkvPfxView = r.qkv.slice([0, 0, 0], [B, keep, convDim]);
313
+ const qkvPfxView = r.qkv.slice([0, 0, 0], [B, count, convDim]);
286
314
  const qkvPfx = ops.contiguous(qkvPfxView);
287
315
  qkvPfxView.dispose();
288
316
  const convState = cache.conv ?? ops.zeros([B, nKeep, convDim], qkvPfx.dtype);
289
- const [convOut, newConv] = this.#convolve(qkvPfx, convState);
317
+ const [convOut, newConv] = this.#convolve(qkvPfx, convState, rowLengths);
290
318
  if (!cache.conv) convState.dispose();
291
319
  qkvPfx.dispose();
292
320
  cache.conv?.dispose();
@@ -296,36 +324,45 @@ export class GatedDeltaNet {
296
324
  convOut, [this.keyDim, 2 * this.keyDim], -1,
297
325
  ) as [MlxArray, MlxArray, MlxArray];
298
326
  convOut.dispose();
299
- let q = ops.reshape(qFlat, [B, keep, this.numKHeads, this.headKDim]);
327
+ let q = rowLengths ? null : ops.reshape(qFlat, [B, count, this.numKHeads, this.headKDim]);
300
328
  qFlat.dispose();
301
- let k = ops.reshape(kFlat, [B, keep, this.numKHeads, this.headKDim]);
329
+ let k = ops.reshape(kFlat, [B, count, this.numKHeads, this.headKDim]);
302
330
  kFlat.dispose();
303
- const v = disposing(vFlat, ops.reshape(vFlat, [B, keep, this.numVHeads, this.headVDim]));
331
+ const v = disposing(vFlat, ops.reshape(vFlat, [B, count, this.numVHeads, this.headVDim]));
304
332
  const invScale = Math.pow(this.headKDim, -0.5);
305
- q = disposing(q, ops.rmsNorm(q, null, 1e-6));
306
- q = disposing(q, ops.mulScalar(q, invScale * invScale));
333
+ if (q) {
334
+ q = disposing(q, ops.rmsNorm(q, null, 1e-6));
335
+ q = disposing(q, ops.mulScalar(q, invScale * invScale));
336
+ }
307
337
  k = disposing(k, ops.rmsNorm(k, null, 1e-6));
308
338
  k = disposing(k, ops.mulScalar(k, invScale));
309
339
 
310
- const aPfxView = r.a.slice([0, 0, 0], [B, keep, this.numVHeads]);
340
+ const aPfxView = r.a.slice([0, 0, 0], [B, count, this.numVHeads]);
311
341
  const aPfx = ops.contiguous(aPfxView);
312
342
  aPfxView.dispose();
313
- const bPfxView = r.b.slice([0, 0, 0], [B, keep, this.numVHeads]);
343
+ const bPfxView = r.b.slice([0, 0, 0], [B, count, this.numVHeads]);
314
344
  const bPfx = ops.contiguous(bPfxView);
315
345
  bPfxView.dispose();
316
346
 
317
- const [y, newState] = gatedDeltaUpdate(
318
- q, k, v, aPfx, bPfx, this.aLog, this.dtBias, cache.recurrent,
319
- );
320
- q.dispose();
347
+ let newState: MlxArray;
348
+ if (rowLengths) {
349
+ // Unequal prefixes need a row-length-aware recurrence. The state-only
350
+ // kernel omits query/output work. Uniform replay retains its measured
351
+ // kernel until the separate optimization demonstrates a serving win.
352
+ newState = gatedDeltaState(k, v, aPfx, bPfx, this.aLog, this.dtBias, cache.recurrent, rowLengths);
353
+ } else {
354
+ const [output, state] = gatedDeltaUpdate(q!, k, v, aPfx, bPfx, this.aLog, this.dtBias, cache.recurrent);
355
+ output.dispose(); newState = state;
356
+ }
357
+ q?.dispose();
321
358
  k.dispose();
322
359
  v.dispose();
323
360
  aPfx.dispose();
324
361
  bPfx.dispose();
325
- y.dispose(); // only the state advance matters on the rollback path
326
362
  cache.recurrent?.dispose();
327
363
  cache.recurrent = newState;
328
- cache.advance(keep);
364
+ if (typeof keep === "number") cache.advance(keep);
365
+ else cache.advanceRows(keep);
329
366
  }
330
367
 
331
368
  private rmsNormGated(hidden: MlxArray, gate: MlxArray): MlxArray {
@@ -415,11 +452,16 @@ export class Qwen3Attention {
415
452
  }
416
453
 
417
454
  let attn: MlxArray;
418
- if (cache instanceof QuantizedKVCache) {
419
- const [keys, values] = cache.updateAndFetchQuantized(k, v);
455
+ const quantized = cache.quantizedAttention;
456
+ if (cache.attentionState) {
457
+ const view = cache.attentionState.appendAndFetch(k, v);
458
+ k.dispose(); v.dispose();
459
+ try { attn = view.attend(q, this.scale, mask); } finally { view.dispose(); }
460
+ } else if (quantized) {
461
+ const [keys, values] = quantized.updateAndFetchQuantized(k, v);
420
462
  k.dispose();
421
463
  v.dispose();
422
- attn = quantizedSdpa(q, keys, values, this.scale, mask, cache.groupSize, cache.bits);
464
+ attn = quantizedSdpa(q, keys, values, this.scale, mask, quantized.groupSize, quantized.bits);
423
465
  disposeTriple(keys);
424
466
  disposeTriple(values);
425
467
  } else {
@@ -517,10 +559,10 @@ export class Qwen3Layer {
517
559
  this.postAttnNorm = new RMSNorm(weights.tensor(`${prefix}.post_attention_layernorm.weight`), config.text.rmsNormEps);
518
560
  }
519
561
 
520
- forward(x: MlxArray, faMask: Mask, cache: Cache, independentRows = false): MlxArray {
562
+ forward(x: MlxArray, faMask: Mask, cache: Cache, independentRows = false, ssmMask?: MlxArray | null): MlxArray {
521
563
  const xn = this.inputNorm.forward(x);
522
564
  const r = this.isLinear
523
- ? this.linearAttn!.forward(xn, cache as SSMCache, independentRows)
565
+ ? this.linearAttn!.forward(xn, cache as SSMCache, independentRows, ssmMask)
524
566
  : this.selfAttn!.forward(xn, faMask, cache, independentRows);
525
567
  xn.dispose();
526
568
  const h = ops.add(x, r);
@@ -647,8 +689,6 @@ export class Qwen35Model {
647
689
  * the bit-exact fast-rope path). While set, forwardLayers installs the
648
690
  * per-forward interleaved cos/sin consumed by every full-attn layer. */
649
691
  mrope: MropeRequestState | null = null;
650
- /** Serving's memory-pressure check, scoped to the serial request. */
651
- prefillMemoryGuard: (() => void) | null = null;
652
692
  #mropeInvFreq: MlxArray | null = null;
653
693
 
654
694
  /** Vision prefill: spliced input embeddings [1, L, H] (image features
@@ -687,6 +727,7 @@ export class Qwen35Model {
687
727
  // One full-attention mask shared by all full layers (same offset); linear
688
728
  // layers see no ssm mask at B=1.
689
729
  const faMask = cache[this.faIdx]!.makeMask(L, null);
730
+ using ssmMask = (cache[0] as SSMCache).prefillPadding?.makeMask(L) ?? null;
690
731
  // Vision requests: one interleaved-mRoPE cos/sin table per forward,
691
732
  // shared by all 12 full-attention layers (positions are layer-invariant).
692
733
  let mropeFwd: ReturnType<typeof buildMropePositions> | null = null;
@@ -706,14 +747,13 @@ export class Qwen35Model {
706
747
  | undefined;
707
748
  try {
708
749
  for (let i = 0; i < this.layers.length; i++) {
709
- if (L > 1) this.prefillMemoryGuard?.();
710
750
  const tl = prof ? performance.now() : 0;
711
- const next = this.layers[i]!.forward(h, faMask, cache[i]!, independentRows);
751
+ const next = this.layers[i]!.forward(h, faMask, cache[i]!, independentRows, ssmMask);
712
752
  h.dispose();
713
753
  h = next;
714
- const mlp = this.layers[i]!.mlp;
715
- if (L > TRELLIS_MATVEC_MAX_M &&
716
- [mlp.gate, mlp.up, mlp.down].some((p) => p instanceof TrellisLinear && !p.fallback)) {
754
+ if (L > TRELLIS_MATVEC_MAX_M) {
755
+ // Affine prefills also need bounded evaluation: a retained prefix
756
+ // history can leave too little room for the entire deferred graph.
717
757
  // Materialize the cache outputs too. In particular, the tiny
718
758
  // copied conv tail is not a dependency of `h`; leaving it lazy
719
759
  // pins the whole prefill conv buffer until the chunk ends.
@@ -0,0 +1,35 @@
1
+ import type { KvDonorAttention } from "./gemma4-base";
2
+ import { BatchedRotatingState } from "./batched-rotating-state";
3
+ import { BatchedRotatingQuantCache } from "./batched-rotating-quant";
4
+ import { plainRowStorage, quantizedRowStorage, temporalStorageView } from "./batched-row-storage";
5
+ import { quantizedDonorAttention } from "./kv-attention-view";
6
+ import { decodedKvDonorAttention } from "./decoded-kv-donor";
7
+ import type { RotatingRing } from "./rotating-row-transaction";
8
+
9
+ /** Capture the same chronological donor rectangle for plain and encoded rings. */
10
+ export function captureRotatingDonorAttention(ring: RotatingRing): KvDonorAttention {
11
+ const position = new BatchedRotatingState(ring.maxSize, []);
12
+ position.restore(ring.positionSnapshot);
13
+ const offsets = position.offsets.map((_, row) => position.validOffset(row));
14
+ const ends = offsets.map((offset, row) => position.activeLength - (position.offsets[row]! - offset));
15
+ // Pending prefill keeps a shared physical rectangle, including when mixed
16
+ // rows are captured separately. Cropping from each row's valid end would
17
+ // give siblings different widths and can discard an earlier valid window.
18
+ const from = position.hasPendingPadding ? 0 : Math.max(0, position.activeLength - ring.maxSize);
19
+ const range = { from, to: position.activeLength };
20
+ const validity = { offsets,
21
+ starts: position.leftPad.map((pad, row) => Math.max(0, pad - from, ends[row]! - from - ring.maxSize)),
22
+ ends: ends.map(end => Math.max(0, end - from)) };
23
+ if (ring instanceof BatchedRotatingQuantCache) {
24
+ const keys = temporalStorageView(quantizedRowStorage, ring.keys!, position, range);
25
+ let values: import("../mlx/ops").QuantizedTensor;
26
+ try { values = temporalStorageView(quantizedRowStorage, ring.values!, position, range); }
27
+ catch (error) { quantizedRowStorage.dispose(keys); throw error; }
28
+ return quantizedDonorAttention(keys, values, validity, ring.groupSize, ring.bits);
29
+ }
30
+ const keys = temporalStorageView(plainRowStorage, ring.keys!, position, range);
31
+ let values: import("../mlx/array").MlxArray;
32
+ try { values = temporalStorageView(plainRowStorage, ring.values!, position, range); }
33
+ catch (error) { keys.dispose(); throw error; }
34
+ return decodedKvDonorAttention({ keys, values, ...validity });
35
+ }