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
@@ -1,3 +1,27 @@
1
+ import type { PrefillPadding } from "./gemma4-base";
2
+
3
+ export interface RotatingPositionSnapshot {
4
+ readonly maxSize: number;
5
+ readonly offsets: readonly number[];
6
+ readonly leftPad: readonly number[];
7
+ readonly ringIndex: number;
8
+ readonly totalOffset: number;
9
+ readonly rotated: boolean;
10
+ readonly prefillEnds?: readonly number[];
11
+ }
12
+
13
+ /** Acceptance changes row positions, independently of plain or encoded planes. */
14
+ export function rotatingRollbackPlan(snapshot: RotatingPositionSnapshot, before: readonly number[],
15
+ keep: number | readonly number[], preserveColumns = false): { trim: number } | { shifts: number[]; position: RotatingPositionSnapshot } {
16
+ const counts = before.map((_, row) => typeof keep === "number" ? keep : keep[row]!);
17
+ if (!preserveColumns && before.length === 1 && snapshot.totalOffset < snapshot.maxSize)
18
+ return { trim: snapshot.offsets[0]! - before[0]! - counts[0]! };
19
+ const offsets = before.map((offset, row) => offset + counts[row]!);
20
+ const shifts = snapshot.offsets.map((offset, row) => offset - offsets[row]!);
21
+ return { shifts, position: { ...snapshot, offsets,
22
+ leftPad: snapshot.leftPad.map((pad, row) => pad + shifts[row]!) } };
23
+ }
24
+
1
25
  /** Host-side row and ring bookkeeping shared by plain and quantized storage. */
2
26
  export class BatchedRotatingState {
3
27
  readonly maxSize: number;
@@ -6,6 +30,7 @@ export class BatchedRotatingState {
6
30
  ringIndex = 0;
7
31
  totalOffset = 0;
8
32
  rotated = false;
33
+ #prefillEnds?: number[];
9
34
 
10
35
  constructor(maxSize: number, leftPad: readonly number[], offsets?: readonly number[]) {
11
36
  this.maxSize = maxSize;
@@ -13,8 +38,45 @@ export class BatchedRotatingState {
13
38
  this.offsets = offsets ? [...offsets] : leftPad.map((pad) => -pad);
14
39
  }
15
40
 
41
+ snapshot(): RotatingPositionSnapshot {
42
+ return { maxSize: this.maxSize, offsets: [...this.offsets], leftPad: [...this.leftPad],
43
+ ringIndex: this.ringIndex, totalOffset: this.totalOffset, rotated: this.rotated,
44
+ ...(this.#prefillEnds ? { prefillEnds: [...this.#prefillEnds] } : {}) };
45
+ }
46
+ restore(snapshot: RotatingPositionSnapshot): void {
47
+ this.offsets = [...snapshot.offsets]; this.leftPad = [...snapshot.leftPad];
48
+ this.ringIndex = snapshot.ringIndex; this.totalOffset = snapshot.totalOffset; this.rotated = snapshot.rotated;
49
+ this.#prefillEnds = snapshot.prefillEnds ? [...snapshot.prefillEnds] : undefined;
50
+ }
51
+
52
+ preparePrefill(padding: PrefillPadding, rightPaddedBatch = padding.rightPadding?.some(pad => pad > 0)): void {
53
+ if (!this.batchSize) { this.leftPad = padding.lengths.map(() => 0); this.offsets = [...this.leftPad]; }
54
+ if (padding.leftPadding) {
55
+ this.leftPad = this.leftPad.map((pad, row) => pad + padding.leftPadding![row]!);
56
+ this.offsets = this.offsets.map((offset, row) => offset - padding.leftPadding![row]!);
57
+ }
58
+ this.#prefillEnds = rightPaddedBatch
59
+ ? padding.lengths.map((length, row) => length + this.offsets[row]!) : undefined;
60
+ }
61
+ get hasPendingPadding(): boolean { return this.#prefillEnds !== undefined; }
62
+ validOffset(row: number): number {
63
+ return Math.min(this.offsets[row]!, this.#prefillEnds?.[row] ?? this.offsets[row]!);
64
+ }
65
+
66
+ prefillRoll(): number[] | undefined {
67
+ return this.#prefillEnds?.map((end, row) => Math.max(0, this.offsets[row]! - end));
68
+ }
69
+ #rollPadding(): void {
70
+ const shifts = this.prefillRoll();
71
+ if (!shifts) return;
72
+ this.leftPad = this.leftPad.map((pad, row) => pad + shifts[row]!);
73
+ this.offsets = this.offsets.map((offset, row) => offset - shifts[row]!);
74
+ }
75
+ finalizePrefill(): void { this.#rollPadding(); this.#prefillEnds = undefined; }
76
+
16
77
  get batchSize(): number { return this.leftPad.length; }
17
78
  get validLength(): number { return Math.min(this.totalOffset, this.maxSize); }
79
+ get activeLength(): number { return this.rotated ? this.maxSize : this.ringIndex; }
18
80
  get trimmable(): boolean { return this.totalOffset < this.maxSize; }
19
81
 
20
82
  markGrown(previousOffset: number): void {
@@ -43,9 +105,22 @@ export class BatchedRotatingState {
43
105
  this.ringIndex += tokens;
44
106
  }
45
107
 
108
+ /** mlx-lm BatchRotatingKVCache._update_concat: the block keeps up to
109
+ * maxSize - 1 historical columns so each query has a complete window. */
110
+ commitConcat(tokens: number, historicalLength: number): void {
111
+ if (historicalLength) this.#rollPadding();
112
+ const trim = Math.max(0, historicalLength - this.maxSize + 1);
113
+ this.leftPad = this.leftPad.map(pad => pad - trim);
114
+ this.totalOffset += tokens;
115
+ this.offsets = this.offsets.map(offset => offset + tokens);
116
+ this.ringIndex = historicalLength - trim + tokens;
117
+ this.rotated = false;
118
+ }
119
+
46
120
  filter(keep: readonly number[]): void {
47
121
  this.offsets = keep.map((row) => this.offsets[row]!);
48
122
  this.leftPad = keep.map((row) => this.leftPad[row]!);
123
+ if (this.#prefillEnds) this.#prefillEnds = keep.map(row => this.#prefillEnds![row]!);
49
124
  }
50
125
 
51
126
  trim(tokens: number): number {
@@ -61,6 +136,7 @@ export class BatchedRotatingState {
61
136
  this.ringIndex = width;
62
137
  this.offsets = [...offsets];
63
138
  this.rotated = false;
139
+ this.#prefillEnds = undefined;
64
140
  }
65
141
 
66
142
  /** Physical ranges that expose a ring buffer in temporal order. */
@@ -6,12 +6,10 @@
6
6
  // column is the same for every row), with PER-ROW `offset` (absolute position,
7
7
  // drives RoPE) and `leftPad` (padding columns in the buffer, masked out).
8
8
  //
9
- // SCOPE: only the pieces the scheduler needs — `merge` (assemble from solo
10
- // prefills), the N=1 decode update (`_update_in_place`), `make_mask`, `filter`,
11
- // and temporalView (extract). The N>1 batched-PREFILL path
12
- // (`_update_concat`/`finalize`/`_lengths`) is NOT ported: the scheduler
13
- // solo-prefills each request (single-stream RotatingKVCache) then merges, so
14
- // this cache only ever sees N=1 updates. Positions (offset/leftPad/_idx/_offset/
9
+ // Includes merge, one-token writes, block prefill, padding finalization, masks
10
+ // and row extraction. Prefill preparation is a storage capability; the serving
11
+ // scheduler's use of whole prompt cohorts is a separate concern.
12
+ // Positions (offset/leftPad/_idx/_offset/
15
13
  // rotated) are tracked on the HOST (small deterministic ints) so make_mask is
16
14
  // built in JS like buildBatchedDecodeMask; only K/V live on device.
17
15
  //
@@ -24,12 +22,14 @@
24
22
 
25
23
  import { MlxArray } from "../mlx/array";
26
24
  import * as ops from "../mlx/ops";
27
- import { RotatingKVCache, type Cache, type Mask } from "./gemma4-base";
28
- import { BatchedRotatingState } from "./batched-rotating-state";
25
+ import { RotatingKVCache, type Cache, type Mask, type PaddedPrefillCache, type PrefillPadding } from "./gemma4-base";
26
+ import { BatchedRotatingState, type RotatingPositionSnapshot } from "./batched-rotating-state";
29
27
  import {
28
+ appendRotatingStorage,
30
29
  mergeStorageRows,
31
30
  plainRowStorage,
32
31
  temporalStorageView,
32
+ rowRollIndices,
33
33
  } from "./batched-row-storage";
34
34
 
35
35
  const STEP = 256;
@@ -45,14 +45,14 @@ const STEP = 256;
45
45
  * update's decrement is the persistent one, applied separately by the cache). */
46
46
  export function buildBatchedRotatingMask(
47
47
  B: number, N: number, leftPad: number[],
48
- maxSize: number, window: number, idx: number, offsetScalar: number, rotated: boolean,
48
+ maxSize: number, window: number, idx: number, offsetScalar: number, rotated: boolean, block = N > 1,
49
49
  ): MlxArray {
50
50
  const off = Math.min(maxSize - 1, offsetScalar);
51
51
  const S = off + N;
52
52
 
53
53
  // Local (non-persisted) leftPad for the mask: trim + rotation shrink it.
54
- const trimSize = idx - maxSize + (N > 1 ? 1 : 0);
55
- const isRot = N === 1 && (rotated || idx >= maxSize);
54
+ const trimSize = idx - maxSize + (block ? 1 : 0);
55
+ const isRot = !block && (rotated || idx >= maxSize);
56
56
  const lp = leftPad.map((x) => x - (trimSize > 0 ? trimSize : 0) - (isRot ? 1 : 0));
57
57
 
58
58
  // roll(shift): physical column = (temporal column + shift) mod S.
@@ -80,8 +80,9 @@ export function buildBatchedRotatingMask(
80
80
  return mask;
81
81
  }
82
82
 
83
- /** Faithful port of mlx-lm BatchRotatingKVCache (decode-only — see file header). */
84
- export class BatchedRotatingCache implements Cache {
83
+ /** Port of mlx-lm BatchRotatingKVCache; padded singleton chunks use its
84
+ * concat operation with the matching block mask until finalization. */
85
+ export class BatchedRotatingCache implements Cache, PaddedPrefillCache {
85
86
  keys: MlxArray | null = null;
86
87
  values: MlxArray | null = null;
87
88
  readonly #rows: BatchedRotatingState;
@@ -103,6 +104,29 @@ export class BatchedRotatingCache implements Cache {
103
104
  * misroutes a batched cache into a serial-only path. */
104
105
  signature(): string { return "kv:rotating-plain"; }
105
106
 
107
+ get positionSnapshot(): RotatingPositionSnapshot { return this.#rows.snapshot(); }
108
+ validOffset(row: number): number { return this.#rows.validOffset(row); }
109
+ preparePrefill(padding: PrefillPadding, rightPaddedBatch?: boolean): void { this.#rows.preparePrefill(padding, rightPaddedBatch); this.releaseRopeArr(); }
110
+ finalizePrefill(): void {
111
+ const shifts = this.#rows.prefillRoll();
112
+ if (shifts?.some(shift => shift !== 0) && this.keys && this.values) {
113
+ using indices = rowRollIndices(this.keys.shape[2]!, shifts);
114
+ const keys = plainRowStorage.rollRows(this.keys, indices);
115
+ let values: MlxArray;
116
+ try { values = plainRowStorage.rollRows(this.values, indices); }
117
+ catch (error) { keys.dispose(); throw error; }
118
+ this.keys.dispose(); this.values.dispose(); this.keys = keys; this.values = values;
119
+ }
120
+ this.#rows.finalizePrefill(); this.releaseRopeArr();
121
+ }
122
+ /** Adopt owned tensors without changing their physical ring columns. */
123
+ static adoptPhysical(keys: MlxArray | null, values: MlxArray | null, position: RotatingPositionSnapshot): BatchedRotatingCache {
124
+ const cache = new BatchedRotatingCache(position.maxSize, [...position.leftPad]);
125
+ cache.#rows.restore(position); cache.keys = keys; cache.values = values;
126
+
127
+ return cache;
128
+ }
129
+
106
130
  get offsetArr(): number[] { return this.#rows.offsets; }
107
131
  get leftPad(): number[] { return this.#rows.leftPad; }
108
132
  get batchSize(): number { return this.#rows.batchSize; }
@@ -133,7 +157,7 @@ export class BatchedRotatingCache implements Cache {
133
157
  mode: "array",
134
158
  arr: buildBatchedRotatingMask(
135
159
  this.#B, N, this.leftPad, this.maxSize, window,
136
- this.#rows.ringIndex, this.offset, this.#rows.rotated,
160
+ this.#rows.ringIndex, this.offset, this.#rows.rotated, N > 1 || this.#rows.hasPendingPadding,
137
161
  ),
138
162
  };
139
163
  }
@@ -142,8 +166,7 @@ export class BatchedRotatingCache implements Cache {
142
166
  updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
143
167
  const [B, H, S, D] = k.shape as [number, number, number, number];
144
168
  const vD = v.shape[3]!;
145
- if (S !== 1)
146
- throw new Error("BatchedRotatingCache supports N=1 decode updates only (solo-prefill then merge)");
169
+ if (S !== 1 || this.#rows.hasPendingPadding) return this.#updateConcat(k, v);
147
170
  const prev = this.offset;
148
171
 
149
172
  // Grow the buffer (in STEP chunks) until it reaches maxSize.
@@ -202,6 +225,21 @@ export class BatchedRotatingCache implements Cache {
202
225
  ];
203
226
  }
204
227
 
228
+ #updateConcat(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
229
+ const keys = appendRotatingStorage(plainRowStorage, this.keys, k, this.#rows);
230
+ let values: MlxArray;
231
+ try { values = appendRotatingStorage(plainRowStorage, this.values, v, this.#rows); }
232
+ catch (error) { keys.dispose(); throw error; }
233
+ const history = this.keys ? this.#rows.activeLength : 0;
234
+ this.keys?.dispose(); this.values?.dispose();
235
+ this.keys = keys; this.values = values;
236
+ this.#rows.commitConcat(k.shape[2]!, history);
237
+ return [
238
+ plainRowStorage.slice(keys, 0, this.#B, 0, this.#rows.ringIndex),
239
+ plainRowStorage.slice(values, 0, this.#B, 0, this.#rows.ringIndex),
240
+ ];
241
+ }
242
+
205
243
  /** Free the per-step RoPE array without disposing KV (wrapper-rebuild path). */
206
244
  releaseRopeArr(): void {
207
245
  this.#ropeArr?.dispose();
@@ -213,8 +251,12 @@ export class BatchedRotatingCache implements Cache {
213
251
  temporalView(): [MlxArray, MlxArray] {
214
252
  if (!this.keys || !this.values) throw new Error("cache is empty");
215
253
  return [
216
- temporalStorageView(plainRowStorage, this.keys, this.#rows),
217
- temporalStorageView(plainRowStorage, this.values, this.#rows),
254
+ temporalStorageView(plainRowStorage, this.keys, this.#rows, {
255
+ from: Math.max(0, this.#rows.activeLength - this.maxSize), to: this.#rows.activeLength,
256
+ }),
257
+ temporalStorageView(plainRowStorage, this.values, this.#rows, {
258
+ from: Math.max(0, this.#rows.activeLength - this.maxSize), to: this.#rows.activeLength,
259
+ }),
218
260
  ];
219
261
  }
220
262
 
@@ -228,15 +270,15 @@ export class BatchedRotatingCache implements Cache {
228
270
  * oracle's `cache._idx = cache.keys.shape[2]`. Bit-exact vs a solo run:
229
271
  * merge/decode/filter keep each row's ring bytes identical to the serial
230
272
  * cache's (tests/batched-rotating) and this is a pure slice+copy. */
231
- extractRow(i: number): RotatingKVCache | null {
273
+ extractRow(i: number, limit?: number): RotatingKVCache | null {
232
274
  if (!this.keys || !this.values) return null;
233
- const pad = Math.max(0, this.leftPad[i]!);
275
+ const pad = Math.max(0, this.leftPad[i]!, limit === undefined ? 0 : this.#rows.activeLength - limit);
234
276
  const c = new RotatingKVCache(this.maxSize);
235
277
  const k = temporalStorageView(plainRowStorage, this.keys, this.#rows, {
236
- row: i, from: pad, copy: true,
278
+ row: i, from: pad, to: this.#rows.activeLength, copy: true,
237
279
  });
238
280
  const v = temporalStorageView(plainRowStorage, this.values, this.#rows, {
239
- row: i, from: pad, copy: true,
281
+ row: i, from: pad, to: this.#rows.activeLength, copy: true,
240
282
  });
241
283
  c.restoreState(k, v, this.offsetArr[i]!, k.shape[2]!);
242
284
  return c;
@@ -1,6 +1,7 @@
1
1
  import { MlxArray } from "../mlx/array";
2
2
  import * as ops from "../mlx/ops";
3
3
  import { BatchedRotatingState } from "./batched-rotating-state";
4
+ import { Dtype } from "../mlx/ffi";
4
5
 
5
6
  export interface RowStorage<T> {
6
7
  shape(value: T): readonly [number, number, number, number];
@@ -10,6 +11,7 @@ export interface RowStorage<T> {
10
11
  padLeft(value: T, tokens: number): T;
11
12
  takeRows(value: T, keep: readonly number[]): T;
12
13
  copy(value: T): T;
14
+ rollRows(value: T, indices: MlxArray): T;
13
15
  dispose(value: T): void;
14
16
  }
15
17
 
@@ -36,6 +38,7 @@ export const plainRowStorage: RowStorage<MlxArray> = {
36
38
  return out;
37
39
  },
38
40
  copy: (value) => ops.copyOf(value),
41
+ rollRows: (value, indices) => ops.takeAlongAxis(value, indices, 2),
39
42
  dispose: (value) => value.dispose(),
40
43
  };
41
44
 
@@ -77,6 +80,14 @@ export const quantizedRowStorage: RowStorage<ops.QuantizedTensor> = {
77
80
  biases: ops.copyOf(value.biases),
78
81
  };
79
82
  },
83
+ rollRows(value, indices) {
84
+ const packed = ops.takeAlongAxis(value.packed, indices, 2);
85
+ let scales: MlxArray | undefined;
86
+ try {
87
+ scales = ops.takeAlongAxis(value.scales, indices, 2);
88
+ return { packed, scales, biases: ops.takeAlongAxis(value.biases, indices, 2) };
89
+ } catch (error) { packed.dispose(); scales?.dispose(); throw error; }
90
+ },
80
91
  dispose(value) {
81
92
  value.packed.dispose();
82
93
  value.scales.dispose();
@@ -84,6 +95,19 @@ export const quantizedRowStorage: RowStorage<ops.QuantizedTensor> = {
84
95
  },
85
96
  };
86
97
 
98
+ /** Per-row cyclic shift, shared by plain and encoded storage planes.
99
+ * This is mlx-lm's dynamic_roll indexing; only positions cross the FFI. */
100
+ export function rowRollIndices(length: number, shifts: readonly number[]): MlxArray {
101
+ using columns = ops.arange(0, length, 1, Dtype.int32);
102
+ using positions = ops.reshape(columns, [1, 1, length, 1]);
103
+ using starts = ops.fromInt32(shifts.map(shift => ((-shift % length) + length) % length), [shifts.length, 1, 1, 1]);
104
+ using raw = ops.add(positions, starts);
105
+ using width = ops.fromInt32([length], []);
106
+ using wrapped = ops.sub(raw, width);
107
+ using over = ops.greaterEqual(raw, width);
108
+ return ops.where(over, wrapped, raw);
109
+ }
110
+
87
111
  export function mergeStorageRows<T>(
88
112
  storage: RowStorage<T>,
89
113
  rows: readonly T[],
@@ -125,3 +149,31 @@ export function temporalStorageView<T>(
125
149
  storage.dispose(cut);
126
150
  return owned;
127
151
  }
152
+
153
+ /** Borrow storage, return an owned chronological block without decoding the
154
+ * representation. Port of mlx-lm's BatchRotatingKVCache._update_concat
155
+ * (MIT): discard spare capacity, retain the full window for the first query. */
156
+ export function appendRotatingStorage<T>(
157
+ storage: RowStorage<T>, previous: T | null, incoming: T, state: BatchedRotatingState,
158
+ ): T {
159
+ if (previous === null) return storage.slice(incoming, 0, storage.shape(incoming)[0], 0, storage.shape(incoming)[2]);
160
+ const active = state.activeLength;
161
+ const shifts = state.prefillRoll();
162
+ if (shifts?.some(shift => shift !== 0)) {
163
+ const chronological = temporalStorageView(storage, previous, state, { to: active });
164
+ let rolled: T | undefined, history: T | undefined;
165
+ try {
166
+ using indices = rowRollIndices(active, shifts);
167
+ rolled = storage.rollRows(chronological, indices);
168
+ history = storage.slice(rolled, 0, state.batchSize, Math.max(0, active - state.maxSize + 1), active);
169
+ return storage.concatTokens([history, incoming]);
170
+ } finally {
171
+ storage.dispose(chronological); if (rolled) storage.dispose(rolled); if (history) storage.dispose(history);
172
+ }
173
+ }
174
+ const history = temporalStorageView(storage, previous, state, {
175
+ from: Math.max(0, active - state.maxSize + 1), to: active,
176
+ });
177
+ try { return storage.concatTokens([history, incoming]); }
178
+ finally { storage.dispose(history); }
179
+ }
@@ -0,0 +1,52 @@
1
+ import { SsmPrefillPadding } from "./ssm-prefill-padding";
2
+ import type { PaddedPrefillCache, PrefillPadding } from "./gemma4-base";
3
+ import * as ops from "../mlx/ops";
4
+ import type { MlxArray } from "../mlx/array";
5
+ import type { BatchableCache, Cache } from "./gemma4-base";
6
+ import { SSMCache } from "./qwen3-delta";
7
+
8
+ /** Recurrent storage behind the same row-layout port as attention KV.
9
+ * Inputs to merge are borrowed; request membership is owned by the caller. */
10
+ export class BatchedSSMCache extends SSMCache implements BatchableCache, PaddedPrefillCache {
11
+ constructor() { super(); this.offsets = []; }
12
+ preparePrefill(padding: PrefillPadding): void {
13
+ if (!this.offsets!.length) this.offsets = padding.lengths.map(() => 0);
14
+ const padded = padding.leftPadding?.some(n => n > 0) || padding.rightPadding?.some(n => n > 0);
15
+ this.prefillPadding = padded ? new SsmPrefillPadding(padding) : null;
16
+ }
17
+ finalizePrefill(): void { this.prefillPadding = null; }
18
+ get rowOffsets(): readonly number[] { return this.offsets!; }
19
+ get leftPad(): readonly number[] { return this.rowOffsets.map(() => 0); }
20
+ makeEmptyBatch(): BatchedSSMCache { return new BatchedSSMCache(); }
21
+ projectedBytes(_tokens: number): number {
22
+ return this.batchSize ? this.state().reduce((bytes, array) => bytes + array.nbytes, 0) / this.batchSize : 0;
23
+ }
24
+
25
+ mergeRows(rows: readonly Cache[]): void {
26
+ if (!rows.length) { this.dispose(); return; }
27
+ const sources = rows as readonly SSMCache[];
28
+ const offsets = sources.flatMap(source => source.offsets ?? [source.offset]);
29
+ const merge = (field: "conv" | "recurrent"): MlxArray | null => {
30
+ const prototype = sources.find(source => source[field])?.[field];
31
+ if (!prototype) return null;
32
+ if (sources.length === 1) return ops.copyOf(prototype);
33
+ const empty: MlxArray[] = [];
34
+ try {
35
+ return ops.concatAxis(sources.map(source => {
36
+ if (source[field]) return source[field]!;
37
+ const zeros = ops.zeros([source.offsets?.length ?? 1, ...prototype.shape.slice(1)], prototype.dtype);
38
+ empty.push(zeros); return zeros;
39
+ }), 0);
40
+ } finally { for (const array of empty) array.dispose(); }
41
+ };
42
+ const conv = merge("conv");
43
+ let recurrent;
44
+ try { recurrent = merge("recurrent"); }
45
+ catch (error) { conv?.dispose(); throw error; }
46
+ this.dispose();
47
+ this.conv = conv; this.recurrent = recurrent;
48
+ this.offsets = offsets; this.offset = Math.max(0, ...offsets);
49
+ }
50
+
51
+ override dispose(): void { super.dispose(); this.offset = 0; this.offsets = []; }
52
+ }
@@ -0,0 +1,93 @@
1
+ import { decodedKvDonorAttention } from "./decoded-kv-donor";
2
+ import type { MlxArray } from "../mlx/array";
3
+ import { KvTensorRows, type KvTensorRowView } from "../backends/mlx/kv-tensor-rows";
4
+ import { disposeResources } from "../engine/resources";
5
+ import { TurboQuantKVCache, type BatchableCache, type Cache, type Mask, type RotatedValueAttentionState, type PaddedPrefillCache, type PrefillPadding } from "./gemma4-base";
6
+ import { TurboQuantCodec, disposeTurboQuant, type TurboQuantTensor } from "./turboquant-codec";
7
+
8
+ const fields = ["kIdx", "kScales", "kZeros", "vPacked", "vScales"] as const;
9
+ function encoded(planes: readonly MlxArray[]): TurboQuantTensor {
10
+ return { kIdx: planes[0]!, kScales: planes[1]!, kZeros: planes[2]!, vPacked: planes[3]!, vScales: planes[4]! };
11
+ }
12
+
13
+ /** The existing TurboQuant codec over shared tensor-row storage. Row changes
14
+ * copy encoded bytes without decoding, rotating or requantizing their values. */
15
+ export class BatchedTurboQuantKVCache implements BatchableCache, PaddedPrefillCache {
16
+ readonly #storage = new KvTensorRows();
17
+ readonly #codec: TurboQuantCodec;
18
+ #reuseOffsets: number[] = [];
19
+ #headDim: number | null = null;
20
+ constructor(readonly kBits: number, readonly vBits: number) {
21
+ this.#codec = new TurboQuantCodec(kBits, vBits, process.env.MLX_BUN_TURBOQUANT_FUSED_DECODE === "1");
22
+ }
23
+ restorePrefillEnds(ends: readonly number[] | undefined): void { this.#storage.restorePrefillEnds(ends); }
24
+ preparePrefill(padding: PrefillPadding): void {
25
+ this.#storage.preparePrefill(padding);
26
+ if (!this.#reuseOffsets.length) this.#reuseOffsets = padding.lengths.map(() => 0);
27
+ }
28
+ finalizePrefill(): void { this.#storage.finalizePrefill(); }
29
+ captureDonorRows(): import("./gemma4-base").KvDonorRows {
30
+ if (!this.#storage.planes.length || this.#headDim === null) throw new Error("cache is empty");
31
+ const [keys, values] = this.#codec.decode(encoded(this.#storage.planes), this.offset, this.#headDim, false);
32
+ return { keys, values, ...this.#storage.captureDonorValidity() };
33
+ }
34
+ captureDonorAttention(): import("./gemma4-base").KvDonorAttention {
35
+ return decodedKvDonorAttention(this.captureDonorRows());
36
+ }
37
+ get rotatedValueAttention(): RotatedValueAttentionState { return this; }
38
+ signature(): string { return `kv:batched-turboquant:${this.kBits}:${this.vBits}`; }
39
+ get minimumReusableOffset(): number { return Math.max(0, ...this.#reuseOffsets); }
40
+ get headDim(): number | null { return this.#headDim; }
41
+ get rowOffsets(): readonly number[] { return this.#storage.rowOffsets; }
42
+ get leftPad(): readonly number[] { return this.#storage.leftPad; }
43
+ get batchSize(): number | null { return this.#storage.batchSize; }
44
+ get offset(): number { return this.#storage.offset; }
45
+ get ropeOffsetArr(): MlxArray | undefined { return this.#storage.ropeOffsetArr; }
46
+ makeEmptyBatch(): BatchedTurboQuantKVCache { return new BatchedTurboQuantKVCache(this.kBits, this.vBits); }
47
+ bytesPerToken(): number { return this.#storage.bytesPerToken(); }
48
+ projectedBytes(tokens: number): number { return this.bytesPerToken() * tokens; }
49
+ makeMask(tokens: number, window: number | null): Mask { return this.#storage.makeMask(tokens, window); }
50
+ isTrimmable(): boolean { return true; }
51
+ trim(count: number): void { this.#storage.trim(count); }
52
+ specRoundBegin(): void { this.#storage.specRoundBegin(); }
53
+ specRoundCommit(): void { this.#storage.specRoundCommit(); }
54
+ specRoundRollback(keep: number | readonly number[]): void { this.#storage.specRoundRollback(keep); }
55
+ #append(k: MlxArray, v: MlxArray, defer: boolean): [MlxArray, MlxArray] {
56
+ const packed = this.#codec.encode(k, v);
57
+ try {
58
+ this.#storage.append(fields.map(field => packed[field]));
59
+ this.#headDim = k.shape[3]!;
60
+ return this.#codec.decode(encoded(this.#storage.planes), this.offset, this.#headDim, defer);
61
+ } finally { disposeTurboQuant(packed); }
62
+ }
63
+ updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] { return this.#append(k, v, false); }
64
+ updateAndFetchDeferredV(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] { return this.#append(k, v, true); }
65
+ mergeRows(rows: readonly Cache[]): void {
66
+ const views: KvTensorRowView[] = [], held: MlxArray[] = [];
67
+ const reuseOffsets: number[] = [];
68
+ let dim = this.#headDim;
69
+ try {
70
+ for (const row of rows) {
71
+ if (row instanceof BatchedTurboQuantKVCache) {
72
+ views.push(row.#storage); dim ??= row.headDim; reuseOffsets.push(...row.#reuseOffsets);
73
+ } else if (row instanceof TurboQuantKVCache) {
74
+ const planes = row.state(); held.push(...planes); reuseOffsets.push(row.minimumReusableOffset);
75
+ views.push({ planes, rowOffsets: [row.offset], leftPad: [0] }); dim ??= row.headDim;
76
+ } else throw new Error(`TurboQuant row layout cannot merge ${row.signature()}`);
77
+ }
78
+ this.#storage.mergeRows(views); this.#headDim = dim; this.#reuseOffsets = reuseOffsets;
79
+ } finally { disposeResources(held); }
80
+ }
81
+ alignRows(leftPad: readonly number[]): void { this.#storage.alignRows(leftPad); }
82
+ filterRows(keep: readonly number[]): void {
83
+ this.#storage.filterRows(keep); this.#reuseOffsets = keep.map(row => this.#reuseOffsets[row]!);
84
+ }
85
+ state(): MlxArray[] { return this.#storage.planes; }
86
+ extractRow(row: number): TurboQuantKVCache {
87
+ const result = new TurboQuantKVCache(this.kBits, this.vBits), count = this.rowOffsets[row]!;
88
+ if (count) result.restoreState(encoded(this.#storage.extractRow(row)), count, this.#headDim!);
89
+ result.minimumReusableOffset = this.#reuseOffsets[row] ?? 0;
90
+ return result;
91
+ }
92
+ dispose(): void { this.#storage.dispose(); this.#headDim = null; this.#reuseOffsets = []; }
93
+ }
@@ -277,7 +277,10 @@ function decodePhase(c: AnyCache): "concat" | "ring" {
277
277
  }
278
278
 
279
279
  function disposeShared(s: SharedKv): void {
280
- if (s.kind === "plain") {
280
+ if (s.kind === "view") {
281
+ s.attention.dispose();
282
+ s.offsetArr?.dispose();
283
+ } else if (s.kind === "plain") {
281
284
  s.keys.dispose();
282
285
  s.values.dispose();
283
286
  } else {
@@ -381,6 +384,9 @@ export class CompiledDecode {
381
384
  readonly #segmented: boolean;
382
385
 
383
386
  private constructor(readonly model: Gemma4Model) {
387
+ // A fresh resume has no prefill to evaluate lazy FP32 constants. Capture
388
+ // their values, not their construction graph, in every compiled closure.
389
+ model.materializeGraphConstants();
384
390
  const t = model.config.text;
385
391
  this.#segmented =
386
392
  t.numKvSharedLayers === 0 && !t.enableMoeBlock && model.perLayerWidth === 0;
@@ -0,0 +1,12 @@
1
+ import * as ops from "../mlx/ops";
2
+ import type { KvDonorAttention, KvDonorRows } from "./gemma4-base";
3
+
4
+ /** Takes ownership of decoded donor planes. Assistant attention uses decoded
5
+ * values, preserving its SDPA arithmetic rather than moving inverse rotation. */
6
+ export function decodedKvDonorAttention(view: KvDonorRows): KvDonorAttention {
7
+ const { keys, values, offsets, starts, ends } = view;
8
+ return { width: keys.shape[2]!, dtype: keys.dtype, offsets, starts, ends,
9
+ attend: (q, scale, mask) => ops.sdpa(q, keys, values, scale, mask.mode, mask.arr),
10
+ dispose() { keys.dispose(); values.dispose(); },
11
+ };
12
+ }
@@ -0,0 +1,72 @@
1
+ import { captureFullKvDonorAttention } from "../backends/mlx/full-kv-row-donor";
2
+ import { appendFullKvRows } from "../backends/mlx/full-kv-row-append";
3
+ import { fullRowPadding, fullRowPhysicalLength } from "./full-prefill-row";
4
+ import { captureKvAttention } from "./kv-attention-view";
5
+ import type { MlxArray } from "../mlx/array";
6
+ import * as ops from "../mlx/ops";
7
+ import { FullTransitioningKvRows } from "../backends/mlx/full-transitioning-kv-rows";
8
+ import { BatchedQuantizedKVCache } from "./batched-quantized-kv";
9
+ import { QuantizedKVCache, type Cache, type Mask, type KvAttentionState, type KvAttentionView } from "./gemma4-base";
10
+
11
+ /** Affine storage keeps its native quantized-attention arithmetic while rows
12
+ * cross the conversion boundary independently. Once all rows convert, their
13
+ * packed planes use the existing batched attention implementation. */
14
+ export class DelayedQuantizedKVCache extends FullTransitioningKvRows<BatchedQuantizedKVCache> implements KvAttentionState {
15
+ constructor(readonly groupSize: number, readonly bits: number, readonly start: number,
16
+ readonly maintain: (rows: Cache[]) => void, row?: Cache) {
17
+ super({ signature: `kv:delayed-quant:${bits}:${groupSize}:${start}`, maintain,
18
+ converted: row => row instanceof QuantizedKVCache,
19
+ makeLayout: () => new BatchedQuantizedKVCache(groupSize, bits) }, row);
20
+ }
21
+ get attentionState(): KvAttentionState { return this; }
22
+ captureDonorAttention() {
23
+ return this.packed?.captureDonorAttention() ?? captureFullKvDonorAttention(this.rows, this.leftPad, this.offset);
24
+ }
25
+ makeEmptyBatch(): DelayedQuantizedKVCache { return new DelayedQuantizedKVCache(this.groupSize, this.bits, this.start, this.maintain); }
26
+ updateAndFetch(): [MlxArray, MlxArray] { throw new Error("mixed precision rows use their attention state"); }
27
+ appendAndFetch(k: MlxArray, v: MlxArray): KvAttentionView {
28
+ this.advance();
29
+ if (this.packed) return captureKvAttention(this.packed, k, v);
30
+ if (this.rows.every(row => !row.quantizedAttention)) {
31
+ const [keys, values] = appendFullKvRows(this.rows, this.leftPad, this.offset + k.shape[2]!, k, v);
32
+ try { this.syncPositions(true); } catch (error) { keys.dispose(); values.dispose(); throw error; }
33
+ return { attend: (q, scale, mask) => ops.sdpa(q, keys, values, scale, mask.mode, mask.arr),
34
+ dispose() { keys.dispose(); values.dispose(); } };
35
+ }
36
+ const views: KvAttentionView[] = [], pads = this.rows.map((row, index) => this.leftPad[index]! - fullRowPadding(row));
37
+ const lengths = this.rows.map(cache => fullRowPhysicalLength(cache) + k.shape[2]!);
38
+ try {
39
+ for (const [row, cache] of this.rows.entries()) {
40
+ const slice = (a: MlxArray) => a.slice([row, 0, 0, 0], [row + 1, a.shape[1]!, a.shape[2]!, a.shape[3]!]);
41
+ using kr = slice(k), vr = slice(v);
42
+ views.push(captureKvAttention(cache, kr, vr));
43
+ }
44
+ this.syncPositions(true);
45
+ } catch (error) { for (const view of views) view.dispose(); throw error; }
46
+ return {
47
+ attend(q, scale, mask) {
48
+ const outputs: MlxArray[] = [];
49
+ try {
50
+ for (const [row, view] of views.entries()) {
51
+ using qr = q.slice([row, 0, 0, 0], [row + 1, q.shape[1]!, q.shape[2]!, q.shape[3]!]);
52
+ // Capture alignment with the KV tensors, never consult live rows.
53
+ const start = mask.arr?.shape.map(() => 0), end = mask.arr ? [...mask.arr.shape] : undefined;
54
+ if (start && end) {
55
+ if (end.length === 4 && end[0]! > 1) { start[0] = row; end[0] = row + 1; }
56
+ start[start.length - 1] = pads[row]!;
57
+ end[end.length - 1] = pads[row]! + lengths[row]!;
58
+ }
59
+ using arr = mask.arr ? mask.arr.slice(start!, end!) : null;
60
+ outputs.push(view.attend(qr, scale, { mode: mask.mode, arr }));
61
+ }
62
+ return outputs.length === 1 ? ops.contiguous(outputs[0]!) : ops.concatAxis(outputs, 0);
63
+ } finally { for (const output of outputs) output.dispose(); }
64
+ },
65
+ dispose() { for (const view of views) view.dispose(); },
66
+ };
67
+ }
68
+ updateAndAttend(q: MlxArray, k: MlxArray, v: MlxArray, scale: number, mask: Mask): MlxArray {
69
+ const view = this.appendAndFetch(k, v);
70
+ try { return view.attend(q, scale, mask); } finally { view.dispose(); }
71
+ }
72
+ }