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
@@ -0,0 +1,235 @@
1
+ import { MlxArray } from "../mlx/array";
2
+ import { Dtype } from "../mlx/ffi";
3
+ import * as ops from "../mlx/ops";
4
+ import { materializeCopy } from "../mlx/materialize";
5
+ import { KVCache, isPlainKvCache, type BatchableCache, type Cache, type Mask, type PaddedPrefillCache, type PrefillPadding } from "./gemma4-base";
6
+ import { FullPrefillPadding } from "./full-prefill-padding";
7
+
8
+ /** Full-attention storage with independently advancing row positions.
9
+ * Speculative rollback changes coverage, without moving the retained KV.
10
+ * Subsequent appends overwrite each row's rejected suffix in place when MLX
11
+ * can donate the buffer. Queue membership is outside this layout. */
12
+ export class BatchedKVCache implements BatchableCache, PaddedPrefillCache {
13
+ keys: MlxArray | null = null;
14
+ values: MlxArray | null = null;
15
+ rowOffsets: number[] = [];
16
+ leftPad: number[] = [];
17
+ #rope: MlxArray | undefined;
18
+ #beforeRound: number[] | undefined;
19
+ readonly #padding = new FullPrefillPadding();
20
+
21
+ restorePrefillEnds(ends: readonly number[] | undefined): void { this.#padding.restoreEnds(ends); }
22
+ preparePrefill(padding: PrefillPadding): void {
23
+ this.#padding.prepare(this, padding); this.#positionsChanged();
24
+ }
25
+ finalizePrefill(): void {
26
+ const next = this.#padding.finalize(this.state(), this);
27
+ if (!next) return;
28
+ this.keys?.dispose(); this.values?.dispose();
29
+ this.keys = next[0] ?? null; this.values = next[1] ?? null;
30
+ this.#positionsChanged();
31
+ }
32
+
33
+ captureDonorRows(): import("./gemma4-base").KvDonorRows {
34
+ const B = this.rowOffsets.length, width = this.offset;
35
+ const keys = this.keys!.slice([0,0,0,0], [B,this.keys!.shape[1]!,width,this.keys!.shape[3]!]);
36
+ let values: MlxArray;
37
+ try { values = this.values!.slice([0,0,0,0], [B,this.values!.shape[1]!,width,this.values!.shape[3]!]); }
38
+ catch (error) { keys.dispose(); throw error; }
39
+ return { keys, values, offsets: [...this.rowOffsets], starts: [...this.leftPad], ends: this.#ends() };
40
+ }
41
+
42
+ signature(): string { return "kv:batched-positions"; }
43
+ get batchSize(): number | null { return this.rowOffsets.length || null; }
44
+ get offset(): number { return Math.max(0, ...this.#ends()); }
45
+ #ends(): number[] { return this.rowOffsets.map((offset, row) => offset + this.leftPad[row]!); }
46
+ get ropeOffsetArr(): MlxArray | undefined {
47
+ // A common unpadded position uses the existing scalar RoPE operation.
48
+ if (this.leftPad.every(pad => pad === 0) && this.rowOffsets.every(offset => offset === this.rowOffsets[0])) return undefined;
49
+ return this.#rope ??= ops.fromInt32(this.rowOffsets, [this.rowOffsets.length]);
50
+ }
51
+ #positionsChanged(): void { this.#rope?.dispose(); this.#rope = undefined; }
52
+ makeEmptyBatch(): BatchedKVCache { return new BatchedKVCache(); }
53
+ bytesPerToken(): number {
54
+ if (!this.keys || !this.values) return 0;
55
+ return (this.keys.nbytes + this.values.nbytes) / (this.rowOffsets.length * this.keys.shape[2]!);
56
+ }
57
+ projectedBytes(tokens: number): number { return this.bytesPerToken() * tokens; }
58
+ state(): MlxArray[] { return this.keys && this.values ? [this.keys, this.values] : []; }
59
+ isTrimmable(): boolean { return true; }
60
+ trim(count: number): void {
61
+ this.rowOffsets = this.rowOffsets.map(offset => offset - count);
62
+ this.#positionsChanged();
63
+ }
64
+ specRoundBegin(): void { this.#beforeRound = [...this.rowOffsets]; }
65
+ specRoundCommit(): void { this.#beforeRound = undefined; }
66
+ specRoundRollback(keep: number | readonly number[]): void {
67
+ this.rowOffsets = this.#beforeRound!.map((offset, row) => offset + (typeof keep === "number" ? keep : keep[row]!));
68
+ this.#beforeRound = undefined;
69
+ this.#positionsChanged();
70
+ }
71
+
72
+ makeMask(N: number, window: number | null): Mask {
73
+ const B = this.rowOffsets.length, ends = this.#ends(), S = this.offset + N;
74
+ if (window === null && this.leftPad.every(pad => pad === 0) && ends.every(end => end === ends[0]))
75
+ return { mode: N === 1 ? "" : "causal", arr: null };
76
+ using starts = ops.fromInt32(ends, [B, 1, 1, 1]);
77
+ using relative = ops.arange(0, N, 1, Dtype.int32);
78
+ using queries = ops.reshape(relative, [1, 1, N, 1]);
79
+ using position = ops.add(starts, queries);
80
+ using indices = ops.arange(0, S, 1, Dtype.int32);
81
+ using keys = ops.reshape(indices, [1, 1, 1, S]);
82
+ using pads = ops.fromInt32(this.leftPad, [B, 1, 1, 1]);
83
+ using causal = ops.lessEqual(keys, position);
84
+ using valid = ops.greaterEqual(keys, pads);
85
+ const mask = ops.logicalAnd(causal, valid);
86
+ if (window === null) return { mode: "array", arr: mask };
87
+ using windowSize = ops.fromInt32([window], []);
88
+ using limit = ops.add(keys, windowSize);
89
+ using within = ops.less(position, limit);
90
+ try { return { mode: "array", arr: ops.logicalAnd(mask, within) }; }
91
+ finally { mask.dispose(); }
92
+ }
93
+
94
+ updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
95
+ const B = this.rowOffsets.length, N = k.shape[2]!, ends = this.#ends();
96
+ const needed = Math.max(...ends) + N;
97
+ const grow = (array: MlxArray) => {
98
+ const [rows, heads, capacity, width] = array.shape as [number, number, number, number];
99
+ if (capacity >= needed) return array;
100
+ using padding = ops.zeros([rows, heads, Math.ceil(needed / 256) * 256 - capacity, width], array.dtype);
101
+ const grown = ops.concatAxis([array, padding], 2);
102
+ array.dispose();
103
+ return grown;
104
+ };
105
+ const allocate = (input: MlxArray) => ops.zeros(
106
+ [B, input.shape[1]!, Math.ceil(needed / 256) * 256, input.shape[3]!], input.dtype);
107
+ this.keys = this.keys ? grow(this.keys) : allocate(k);
108
+ this.values = this.values ? grow(this.values) : allocate(v);
109
+ const common = ends.every(end => end === ends[0]);
110
+ using positions = common ? null : ops.fromInt32(ends.flatMap(end =>
111
+ Array.from({ length: N }, (_, position) => end + position)), [B, 1, N, 1]);
112
+ const append = (array: MlxArray, values: MlxArray) => common
113
+ ? ops.sliceUpdate(array, values, [0, 0, ends[0]!, 0], [B, array.shape[1]!, ends[0]! + N, array.shape[3]!])
114
+ : ops.putAlongAxis(array, positions!, values, 2);
115
+ const keys = append(this.keys, k);
116
+ let values: MlxArray;
117
+ try { values = append(this.values, v); }
118
+ catch (error) { keys.dispose(); throw error; }
119
+ this.keys.dispose(); this.values.dispose();
120
+ this.keys = keys; this.values = values;
121
+ this.rowOffsets = this.rowOffsets.map(offset => offset + N);
122
+ this.#positionsChanged();
123
+ return [keys.slice([0, 0, 0, 0], [B, keys.shape[1]!, needed, keys.shape[3]!]),
124
+ values.slice([0, 0, 0, 0], [B, values.shape[1]!, needed, values.shape[3]!])];
125
+ }
126
+
127
+ mergeRows(rows: readonly Cache[]): void {
128
+ const only = rows.length === 1 ? rows[0] : undefined;
129
+ if (only && (only instanceof BatchedKVCache || isPlainKvCache(only))) {
130
+ // Preserve allocated capacity when a request starts alone. Immutable
131
+ // aliases transfer ownership without copying its prefill storage.
132
+ const keys = only.keys ? ops.copyOf(only.keys) : null;
133
+ let values: MlxArray | null;
134
+ try { values = only.values ? ops.copyOf(only.values) : null; }
135
+ catch (error) { keys?.dispose(); throw error; }
136
+ const offsets = only instanceof BatchedKVCache ? [...only.rowOffsets] : [only.offset];
137
+ const pads = only instanceof BatchedKVCache ? [...only.leftPad] : [0];
138
+ this.dispose(); this.keys = keys; this.values = values;
139
+ this.rowOffsets = offsets; this.leftPad = pads;
140
+ return;
141
+ }
142
+ const chunks: Array<{ keys: MlxArray | null; values: MlxArray | null; offsets: number[]; pads: number[]; width: number }> = [];
143
+ const held: MlxArray[] = [];
144
+ try {
145
+ for (const cache of rows) {
146
+ let keys: MlxArray | null, values: MlxArray | null, offsets: number[], pads: number[];
147
+ if (cache instanceof BatchedKVCache && cache.offset === 0) {
148
+ keys = values = null; offsets = [...cache.rowOffsets]; pads = [...cache.leftPad];
149
+ } else if (cache instanceof BatchedKVCache) {
150
+ const B = cache.rowOffsets.length, width = cache.offset;
151
+ keys = cache.keys!.slice([0, 0, 0, 0], [B, cache.keys!.shape[1]!, width, cache.keys!.shape[3]!]);
152
+ held.push(keys);
153
+ values = cache.values!.slice([0, 0, 0, 0], [B, cache.values!.shape[1]!, width, cache.values!.shape[3]!]);
154
+ held.push(values);
155
+ offsets = [...cache.rowOffsets]; pads = [...cache.leftPad];
156
+ } else if (isPlainKvCache(cache)) {
157
+ if (cache.offset === 0) keys = values = null;
158
+ else { [keys, values] = cache.temporalView(); held.push(keys, values); }
159
+ offsets = [cache.offset]; pads = [0];
160
+ } else throw new Error(`full-attention batch cannot merge ${cache.signature()}`);
161
+ chunks.push({ keys, values, offsets, pads, width: keys?.shape[2] ?? 0 });
162
+ }
163
+ const width = Math.max(0, ...chunks.map(chunk => chunk.width));
164
+ const padded = (array: MlxArray, count: number) => {
165
+ if (!count) return array;
166
+ using padding = ops.zeros([array.shape[0]!, array.shape[1]!, count, array.shape[3]!], array.dtype);
167
+ const result = ops.concatAxis([padding, array], 2); held.push(result); return result;
168
+ };
169
+ const merge = (field: "keys" | "values") => {
170
+ if (!width) return null; // Geometry arrives with the first append.
171
+ const prototype = chunks.find(chunk => chunk[field])![field]!;
172
+ return ops.concatAxis(chunks.map(chunk => {
173
+ const array = chunk[field];
174
+ if (array) return padded(array, width - chunk.width);
175
+ const empty = ops.zeros([chunk.offsets.length, prototype.shape[1]!, width, prototype.shape[3]!], prototype.dtype);
176
+ held.push(empty); return empty;
177
+ }), 0);
178
+ };
179
+ const keys = merge("keys");
180
+ let values: MlxArray | null;
181
+ try { values = merge("values"); }
182
+ catch (error) { keys?.dispose(); throw error; }
183
+ this.dispose();
184
+ this.keys = keys; this.values = values;
185
+ this.rowOffsets = chunks.flatMap(chunk => chunk.offsets);
186
+ this.leftPad = chunks.flatMap(chunk => chunk.pads.map(pad => pad + width - chunk.width));
187
+ } finally { for (const array of held) array.dispose(); }
188
+ }
189
+
190
+ filterRows(rows: readonly number[]): void {
191
+ if (!rows.length) { this.dispose(); return; }
192
+ const pads = rows.map(row => this.leftPad[row]!);
193
+ const sharedPadding = Math.min(...pads);
194
+ using indices = this.keys ? ops.fromInt32([...rows], [rows.length]) : null;
195
+ const select = (array: MlxArray | null): MlxArray | null => {
196
+ if (!array) return null;
197
+ const selected = ops.takeAxis(array, indices!, 0);
198
+ if (!sharedPadding) return selected;
199
+ // mlx-lm BatchKVCache.filter removes padding common to all survivors.
200
+ // Keeping it changes attention reduction geometry after retirement.
201
+ try { return selected.slice([0, 0, sharedPadding, 0], [...selected.shape]); }
202
+ finally { selected.dispose(); }
203
+ };
204
+ const keys = select(this.keys);
205
+ let values: MlxArray | null;
206
+ try { values = select(this.values); }
207
+ catch (error) { keys?.dispose(); throw error; }
208
+ this.keys?.dispose(); this.values?.dispose();
209
+ this.keys = keys; this.values = values;
210
+ this.rowOffsets = rows.map(row => this.rowOffsets[row]!);
211
+ this.leftPad = pads.map(pad => pad - sharedPadding);
212
+ this.#padding.filter(rows);
213
+ this.#positionsChanged();
214
+ }
215
+
216
+ extractRow(row: number): KVCache {
217
+ const start = this.leftPad[row]!, count = this.rowOffsets[row]!;
218
+ if (count === 0) return new KVCache();
219
+ const copy = (array: MlxArray) => {
220
+ using view = array.slice([row, 0, start, 0], [row + 1, array.shape[1]!, start + count, array.shape[3]!]);
221
+ return materializeCopy(view);
222
+ };
223
+ const keys = copy(this.keys!);
224
+ let values: MlxArray;
225
+ try { values = copy(this.values!); }
226
+ catch (error) { keys.dispose(); throw error; }
227
+ const cache = new KVCache(); cache.restoreState(keys, values, count); return cache;
228
+ }
229
+
230
+ dispose(): void {
231
+ this.keys?.dispose(); this.values?.dispose(); this.#positionsChanged();
232
+ this.keys = this.values = null;
233
+ this.rowOffsets = []; this.leftPad = []; this.#beforeRound = undefined; this.#padding.clear();
234
+ }
235
+ }
@@ -259,13 +259,21 @@ export function extractKVRow(cache: KVCache, leftPad: number, i: number): KVCach
259
259
 
260
260
  /** mlx-lm `filter`: keep only `keep` (sorted row indices) along the batch
261
261
  * axis — eviction of finished sequences from a batched [B,H,S,D] buffer.
262
- * Caller owns the result; inputs are not disposed. */
262
+ * `trimLeft` removes padding shared by all survivors (the caller updates
263
+ * offsets and per-row padding). Caller owns the result; inputs stay owned. */
263
264
  export function filterKVRows(
264
- keys: MlxArray, values: MlxArray, keep: number[],
265
+ keys: MlxArray, values: MlxArray, keep: number[], trimLeft = 0,
265
266
  ): { keys: MlxArray; values: MlxArray } {
266
267
  const idx = MlxArray.fromInt32(Int32Array.from(keep), [keep.length]);
267
- const k = ops.takeAxis(keys, idx, 0);
268
- const v = ops.takeAxis(values, idx, 0);
268
+ const take = (a: MlxArray) => {
269
+ const rows = ops.takeAxis(a, idx, 0);
270
+ if (trimLeft === 0) return rows;
271
+ const trimmed = rows.slice([0, 0, trimLeft, 0], rows.shape);
272
+ rows.dispose();
273
+ return trimmed;
274
+ };
275
+ const k = take(keys);
276
+ const v = take(values);
269
277
  idx.dispose();
270
278
  return { keys: k, values: v };
271
279
  }
@@ -134,11 +134,17 @@ export function extractQuantRow(
134
134
  /** Quantized twin of filterKVRows: keep `keep` rows along the batch axis.
135
135
  * Caller owns the result; inputs are not disposed. */
136
136
  export function filterQuantRows(
137
- keys: ops.QuantizedTensor, values: ops.QuantizedTensor, keep: number[],
137
+ keys: ops.QuantizedTensor, values: ops.QuantizedTensor, keep: number[], trimLeft = 0,
138
138
  ): { keys: ops.QuantizedTensor; values: ops.QuantizedTensor } {
139
139
  const idx = MlxArray.fromInt32(Int32Array.from(keep), [keep.length]);
140
140
  const take = (t: ops.QuantizedTensor): ops.QuantizedTensor =>
141
- tripleMap(t, (a) => ops.takeAxis(a, idx, 0));
141
+ tripleMap(t, (a) => {
142
+ const rows = ops.takeAxis(a, idx, 0);
143
+ if (trimLeft === 0) return rows;
144
+ const trimmed = rows.slice([0, 0, trimLeft, 0], rows.shape);
145
+ rows.dispose();
146
+ return trimmed;
147
+ });
142
148
  const k = take(keys);
143
149
  const v = take(values);
144
150
  idx.dispose();
@@ -0,0 +1,144 @@
1
+ import type { MlxArray } from "../mlx/array";
2
+ import * as ops from "../mlx/ops";
3
+ import { cleanupFailure, disposeResources } from "../engine/resources";
4
+ import { BatchedKVCache } from "./batched-kv";
5
+ import { KVCache, QuantizedKVCache, isQuantizedKvCache,
6
+ type BatchableCache, type Cache, type Mask, type QuantizedAttentionState, type PaddedPrefillCache, type PrefillPadding } from "./gemma4-base";
7
+
8
+ import { KvTensorRows } from "../backends/mlx/kv-tensor-rows";
9
+ import { quantizedDonorAttention } from "./kv-attention-view";
10
+
11
+ const FIELDS = ["packed", "scales", "biases"] as const;
12
+
13
+ /** Affine KV uses the same row-position storage operations for its packed
14
+ * values, scales and biases. No dequantization or requantization is needed
15
+ * for merge, rollback, retirement or extraction. Inputs share this codec. */
16
+ export class BatchedQuantizedKVCache implements BatchableCache, QuantizedAttentionState, PaddedPrefillCache {
17
+ #planes = FIELDS.map(() => new BatchedKVCache());
18
+ #minimum: number[] = [];
19
+ get minimumReusableOffset(): number { return Math.max(0, ...this.#minimum); }
20
+ constructor(readonly groupSize: number, readonly bits: number) {}
21
+ restorePrefillEnds(ends: readonly number[] | undefined): void { for (const plane of this.#planes) plane.restorePrefillEnds(ends); }
22
+ preparePrefill(padding: PrefillPadding): void {
23
+ for (const plane of this.#planes) plane.preparePrefill(padding);
24
+ if (!this.#minimum.length) this.#minimum = padding.lengths.map(() => 0);
25
+ }
26
+ finalizePrefill(): void { for (const plane of this.#planes) plane.finalizePrefill(); }
27
+ get quantizedAttention(): QuantizedAttentionState { return this; }
28
+ captureDonorAttention(): import("./gemma4-base").KvDonorAttention {
29
+ const views: import("./gemma4-base").KvDonorRows[] = [];
30
+ try {
31
+ for (const plane of this.#planes) views.push(plane.captureDonorRows());
32
+ const field = (name: "keys" | "values") => ({
33
+ packed: views[0]![name], scales: views[1]![name], biases: views[2]![name],
34
+ });
35
+ const { offsets, starts, ends } = views[0]!;
36
+ return quantizedDonorAttention(field("keys"), field("values"), { offsets, starts, ends }, this.groupSize, this.bits);
37
+ } catch (error) { disposeResources(views.flatMap(view => [view.keys, view.values])); throw error; }
38
+ }
39
+ signature(): string { return `kv:batched-quant:${this.bits}:${this.groupSize}`; }
40
+ get rowOffsets(): readonly number[] { return this.#planes[0]!.rowOffsets; }
41
+ get leftPad(): readonly number[] { return this.#planes[0]!.leftPad; }
42
+ get batchSize(): number | null { return this.#planes[0]!.batchSize; }
43
+ get offset(): number { return this.#planes[0]!.offset; }
44
+ get ropeOffsetArr(): MlxArray | undefined { return this.#planes[0]!.ropeOffsetArr; }
45
+ makeEmptyBatch(): BatchedQuantizedKVCache { return new BatchedQuantizedKVCache(this.groupSize, this.bits); }
46
+ bytesPerToken(): number { return this.#planes.reduce((n, plane) => n + plane.bytesPerToken(), 0); }
47
+ projectedBytes(tokens: number): number { return this.bytesPerToken() * tokens; }
48
+ makeMask(tokens: number, window: number | null): Mask { return this.#planes[0]!.makeMask(tokens, window); }
49
+ isTrimmable(): boolean { return true; }
50
+ trim(count: number): void { for (const plane of this.#planes) plane.trim(count); }
51
+ specRoundBegin(): void { for (const plane of this.#planes) plane.specRoundBegin(); }
52
+ specRoundCommit(): void { for (const plane of this.#planes) plane.specRoundCommit(); }
53
+ specRoundRollback(keep: number | readonly number[]): void {
54
+ for (const plane of this.#planes) plane.specRoundRollback(keep);
55
+ }
56
+ updateAndFetch(): [MlxArray, MlxArray] { throw new Error("quantized attention uses updateAndFetchQuantized"); }
57
+
58
+ updateAndFetchQuantized(k: MlxArray, v: MlxArray): [ops.QuantizedTensor, ops.QuantizedTensor] {
59
+ using inputs = new DisposableStack();
60
+ const key = ops.quantize(k, this.groupSize, this.bits);
61
+ for (const field of FIELDS) inputs.use(key[field]);
62
+ const value = ops.quantize(v, this.groupSize, this.bits);
63
+ for (const field of FIELDS) inputs.use(value[field]);
64
+ const held: MlxArray[] = [], keys: Partial<ops.QuantizedTensor> = {}, values: Partial<ops.QuantizedTensor> = {};
65
+ try {
66
+ for (const [index, field] of FIELDS.entries()) {
67
+ const [kk, vv] = this.#planes[index]!.updateAndFetch(key[field], value[field]);
68
+ held.push(kk, vv); keys[field] = kk; values[field] = vv;
69
+ }
70
+ return [keys as ops.QuantizedTensor, values as ops.QuantizedTensor];
71
+ } catch (error) { return cleanupFailure(error, () => disposeResources(held)); }
72
+ }
73
+
74
+ /** Borrow prepared request state. All planes stage replacements before
75
+ * releasing the current group, preserving donor ownership on failure. */
76
+ mergeRows(rows: readonly Cache[]): void {
77
+ const next: BatchedKVCache[] = [];
78
+ try {
79
+ for (const [index, field] of FIELDS.entries()) {
80
+ const held: KVCache[] = [], inputs: Cache[] = [];
81
+ try {
82
+ for (const row of rows) {
83
+ if (row instanceof BatchedQuantizedKVCache) inputs.push(row.#planes[index]!);
84
+ else if (isQuantizedKvCache(row)) {
85
+ const view = new KVCache(); held.push(view);
86
+ if (row.offset > 0) {
87
+ const key = ops.copyOf(row.keys![field]);
88
+ let value: MlxArray;
89
+ try { value = ops.copyOf(row.values![field]); }
90
+ catch (error) { key.dispose(); throw error; }
91
+ view.restoreState(key, value, row.offset);
92
+ }
93
+ inputs.push(view);
94
+ } else throw new Error(`quantized row layout cannot merge ${row.signature()}`);
95
+ }
96
+ const plane = new BatchedKVCache(); next.push(plane); plane.mergeRows(inputs);
97
+ } finally { disposeResources(held); }
98
+ }
99
+ } catch (error) { return cleanupFailure(error, () => disposeResources(next)); }
100
+ const previous = this.#planes; this.#planes = next; disposeResources(previous);
101
+ this.#minimum = rows.flatMap(row => row instanceof BatchedQuantizedKVCache ? row.#minimum : [row.minimumReusableOffset ?? 0]);
102
+ }
103
+
104
+ alignRows(leftPad: readonly number[]): void {
105
+ if (leftPad.every((pad, row) => pad === this.leftPad[row])) return;
106
+ const aligned = new KvTensorRows();
107
+ try {
108
+ aligned.mergeRows([{ planes: this.#planes.flatMap(plane => plane.keys ? [plane.keys, plane.values!] : []),
109
+ rowOffsets: this.rowOffsets, leftPad: this.leftPad }]);
110
+ aligned.alignRows(leftPad);
111
+ for (const plane of this.#planes) {
112
+ plane.keys?.dispose(); plane.values?.dispose();
113
+ plane.keys = aligned.planes.shift() ?? null; plane.values = aligned.planes.shift() ?? null;
114
+ plane.leftPad = [...leftPad];
115
+ }
116
+ } finally { aligned.dispose(); }
117
+ }
118
+ filterRows(keep: readonly number[]): void {
119
+ for (const plane of this.#planes) plane.filterRows(keep);
120
+ this.#minimum = keep.map(row => this.#minimum[row]!);
121
+ }
122
+ state(): MlxArray[] {
123
+ if (!this.#planes[0]!.keys) return [];
124
+ return [...this.#planes.map(plane => plane.keys!), ...this.#planes.map(plane => plane.values!)];
125
+ }
126
+
127
+ /** Return the existing persisted representation, owning compact arrays. */
128
+ extractRow(row: number): QuantizedKVCache {
129
+ const offset = this.rowOffsets[row]!, result = new QuantizedKVCache(this.groupSize, this.bits);
130
+ if (this.#minimum[row]) result.minimumReusableOffset = this.#minimum[row];
131
+ if (offset === 0) return result;
132
+ const held: KVCache[] = [];
133
+ try {
134
+ for (const plane of this.#planes) held.push(plane.extractRow(row));
135
+ result.restoreState(
136
+ { packed: held[0]!.keys!, scales: held[1]!.keys!, biases: held[2]!.keys! },
137
+ { packed: held[0]!.values!, scales: held[1]!.values!, biases: held[2]!.values! }, offset);
138
+ for (const cache of held) cache.keys = cache.values = null;
139
+ return result;
140
+ } finally { disposeResources(held); }
141
+ }
142
+
143
+ dispose(): void { disposeResources(this.#planes); this.#minimum = []; }
144
+ }
@@ -20,7 +20,8 @@
20
20
  // untouched. PERSISTENT (like BatchedRotatingCache, unlike the ephemeral
21
21
  // per-step BatchedQuantDecodeMaskCache): the scheduler passes it through
22
22
  // every step; it self-tracks per-row offset/leftPad and the scalar ring
23
- // state. N=1 decode updates only (rows solo-prefill serially, then merge).
23
+ // state. Multi-token blocks use the same chronological storage operation as
24
+ // the bf16 layout; one-token updates keep the existing in-place ring kernel.
24
25
  //
25
26
  // Gates: tests/unit/batched-rotating-quant.test.ts (model-free byte-identity vs
26
27
  // the serial oracle per row, through ring wrap, B=1 and B=2) + the gemma
@@ -29,11 +30,13 @@
29
30
  import { MlxArray } from "../mlx/array";
30
31
  import * as ops from "../mlx/ops";
31
32
  import { Dtype } from "../mlx/ffi";
32
- import { RotatingQuantizedKVCache, type Mask } from "./gemma4-base";
33
+ import { RotatingQuantizedKVCache, type Mask, type PaddedPrefillCache, type PrefillPadding } from "./gemma4-base";
33
34
  import { buildBatchedRotatingMask } from "./batched-rotating";
34
35
  import type { QuantRow } from "./batched-quant";
35
- import { BatchedRotatingState } from "./batched-rotating-state";
36
+ import { BatchedRotatingState, type RotatingPositionSnapshot } from "./batched-rotating-state";
36
37
  import {
38
+ appendRotatingStorage,
39
+ rowRollIndices,
37
40
  mergeStorageRows,
38
41
  quantizedRowStorage,
39
42
  temporalStorageView,
@@ -54,7 +57,7 @@ const mapTriple = (
54
57
  * mechanics (see batched-rotating.ts) with RotatingQuantizedKVCache
55
58
  * storage. Scalar ring state (`ringIdx`/`offset` reuse the base fields);
56
59
  * per-row `offsetArr` (absolute positions → RoPE) and `leftPad`. */
57
- export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
60
+ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache implements PaddedPrefillCache {
58
61
  readonly #rows: BatchedRotatingState;
59
62
  /** Per-row RoPE positions. STABLE ACROSS A STEP — refreshed only at
60
63
  * releaseRopeArr() (the scheduler's post-dispatch hook), never inside
@@ -72,7 +75,34 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
72
75
  ) {
73
76
  super(maxSize, groupSize, bits);
74
77
  this.#rows = new BatchedRotatingState(maxSize, leftPad, offsets);
75
- this.ropeOffsetArr = MlxArray.fromInt32(Int32Array.from(offsets), [offsets.length]);
78
+ this.ropeOffsetArr = offsets.length ? MlxArray.fromInt32(Int32Array.from(offsets), [offsets.length]) : ops.zeros([0], Dtype.int32);
79
+ }
80
+
81
+ static empty(maxSize: number, groupSize: number, bits: number, leftPad: readonly number[]): BatchedRotatingQuantCache {
82
+ return new BatchedRotatingQuantCache(maxSize, groupSize, bits, [...leftPad], leftPad.map(pad => -pad));
83
+ }
84
+
85
+ get positionSnapshot(): RotatingPositionSnapshot { return this.#rows.snapshot(); }
86
+ validOffset(row: number): number { return this.#rows.validOffset(row); }
87
+ preparePrefill(padding: PrefillPadding, rightPaddedBatch?: boolean): void { this.#rows.preparePrefill(padding, rightPaddedBatch); this.releaseRopeArr(); }
88
+ finalizePrefill(): void {
89
+ const shifts = this.#rows.prefillRoll();
90
+ if (shifts?.some(shift => shift !== 0) && this.keys && this.values) {
91
+ using indices = rowRollIndices(this.keys.packed.shape[2]!, shifts);
92
+ const keys = quantizedRowStorage.rollRows(this.keys, indices);
93
+ let values: ops.QuantizedTensor;
94
+ try { values = quantizedRowStorage.rollRows(this.values, indices); }
95
+ catch (error) { disposeTriple(keys); throw error; }
96
+ disposeTriple(this.keys); disposeTriple(this.values); this.keys = keys; this.values = values;
97
+ }
98
+ this.#rows.finalizePrefill(); this.releaseRopeArr();
99
+ }
100
+ /** Adopt owned tensors without changing their physical ring columns. */
101
+ static adoptPhysical(keys: ops.QuantizedTensor | null, values: ops.QuantizedTensor | null, groupSize: number, bits: number, position: RotatingPositionSnapshot): BatchedRotatingQuantCache {
102
+ const cache = new BatchedRotatingQuantCache(position.maxSize, groupSize, bits, [...position.leftPad], [...position.offsets]);
103
+ cache.#rows.restore(position); cache.keys = keys; cache.values = values;
104
+ cache.offset = position.totalOffset; cache.ringIdx = position.ringIndex;
105
+ return cache;
76
106
  }
77
107
 
78
108
  get offsetArr(): number[] { return this.#rows.offsets; }
@@ -93,7 +123,7 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
93
123
  * old handle after this). */
94
124
  releaseRopeArr(): void {
95
125
  this.ropeOffsetArr.dispose();
96
- this.ropeOffsetArr = MlxArray.fromInt32(Int32Array.from(this.offsetArr), [this.#B]);
126
+ this.ropeOffsetArr = this.#B ? MlxArray.fromInt32(Int32Array.from(this.offsetArr), [this.#B]) : ops.zeros([0], Dtype.int32);
97
127
  }
98
128
 
99
129
  #allocTriple(B: number, H: number, T: number, dim: number, dtype: Dtype): ops.QuantizedTensor {
@@ -111,7 +141,7 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
111
141
  mode: "array",
112
142
  arr: buildBatchedRotatingMask(
113
143
  this.#B, N, this.leftPad, this.maxSize, window,
114
- this.#rows.ringIndex, this.#rows.totalOffset, this.#rows.rotated,
144
+ this.#rows.ringIndex, this.#rows.totalOffset, this.#rows.rotated, N > 1 || this.#rows.hasPendingPadding,
115
145
  ),
116
146
  };
117
147
  }
@@ -122,8 +152,7 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
122
152
  override updateAndFetchQuantized(k: MlxArray, v: MlxArray): [ops.QuantizedTensor, ops.QuantizedTensor] {
123
153
  const [B, H, S, D] = k.shape as [number, number, number, number];
124
154
  const vD = v.shape[3]!;
125
- if (S !== 1)
126
- throw new Error("BatchedRotatingQuantCache supports N=1 decode updates only (solo-prefill then merge)");
155
+ if (S !== 1 || this.#rows.hasPendingPadding) return this.#updateConcat(k, v);
127
156
  const prev = this.#rows.totalOffset;
128
157
 
129
158
  // Grow the buffer (in STEP chunks) until it reaches maxSize.
@@ -202,6 +231,29 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
202
231
  return [cut(this.keys!), cut(this.values!)];
203
232
  }
204
233
 
234
+ #updateConcat(k: MlxArray, v: MlxArray): [ops.QuantizedTensor, ops.QuantizedTensor] {
235
+ const kq = ops.quantize(k, this.groupSize, this.bits);
236
+ const vq = ops.quantize(v, this.groupSize, this.bits);
237
+ let keys: ops.QuantizedTensor | undefined;
238
+ let values: ops.QuantizedTensor;
239
+ try {
240
+ keys = appendRotatingStorage(quantizedRowStorage, this.keys, kq, this.#rows);
241
+ values = appendRotatingStorage(quantizedRowStorage, this.values, vq, this.#rows);
242
+ } catch (error) { if (keys) disposeTriple(keys); throw error; }
243
+ finally { disposeTriple(kq); disposeTriple(vq); }
244
+ const history = this.keys ? this.#rows.activeLength : 0;
245
+ if (this.keys) disposeTriple(this.keys);
246
+ if (this.values) disposeTriple(this.values);
247
+ this.keys = keys; this.values = values;
248
+ this.#rows.commitConcat(k.shape[2]!, history);
249
+ this.offset = this.#rows.totalOffset;
250
+ this.ringIdx = this.#rows.ringIndex;
251
+ return [
252
+ quantizedRowStorage.slice(keys, 0, this.#B, 0, this.ringIdx),
253
+ quantizedRowStorage.slice(values, 0, this.#B, 0, this.ringIdx),
254
+ ];
255
+ }
256
+
205
257
  #assign2(dst: MlxArray, src: MlxArray, S: number): MlxArray {
206
258
  const [B, H, , D] = dst.shape as [number, number, number, number];
207
259
  const out = ops.sliceUpdate(dst, src, [0, 0, this.ringIdx, 0], [B, H, this.ringIdx + S, D]);
@@ -214,8 +266,12 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
214
266
  override temporalView(): [ops.QuantizedTensor, ops.QuantizedTensor] {
215
267
  if (!this.keys || !this.values) throw new Error("cache is empty");
216
268
  return [
217
- temporalStorageView(quantizedRowStorage, this.keys, this.#rows),
218
- temporalStorageView(quantizedRowStorage, this.values, this.#rows),
269
+ temporalStorageView(quantizedRowStorage, this.keys, this.#rows, {
270
+ from: Math.max(0, this.#rows.activeLength - this.maxSize), to: this.#rows.activeLength,
271
+ }),
272
+ temporalStorageView(quantizedRowStorage, this.values, this.#rows, {
273
+ from: Math.max(0, this.#rows.activeLength - this.maxSize), to: this.#rows.activeLength,
274
+ }),
219
275
  ];
220
276
  }
221
277
 
@@ -228,15 +284,15 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
228
284
  * is byte-safe (packing along HEAD_DIM — file header); per-row byte
229
285
  * identity vs the serial oracle is the class invariant
230
286
  * (tests/batched-rotating-quant), extraction is a pure slice+copy. */
231
- extractRow(i: number): RotatingQuantizedKVCache | null {
287
+ extractRow(i: number, limit = Infinity): RotatingQuantizedKVCache | null {
232
288
  if (!this.keys || !this.values) return null;
233
- const pad = Math.max(0, this.leftPad[i]!);
289
+ const pad = Math.max(0, this.leftPad[i]!, this.#rows.activeLength - limit);
234
290
  const c = new RotatingQuantizedKVCache(this.maxSize, this.groupSize, this.bits);
235
291
  const k = temporalStorageView(quantizedRowStorage, this.keys, this.#rows, {
236
- row: i, from: pad, copy: true,
292
+ row: i, from: pad, to: this.#rows.activeLength, copy: true,
237
293
  });
238
294
  const v = temporalStorageView(quantizedRowStorage, this.values, this.#rows, {
239
- row: i, from: pad, copy: true,
295
+ row: i, from: pad, to: this.#rows.activeLength, copy: true,
240
296
  });
241
297
  c.restoreState(k, v, this.offsetArr[i]!, k.packed.shape[2]!);
242
298
  return c;
@@ -258,6 +314,11 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
258
314
 
259
315
  filterRows(keep: readonly number[]): void { this.filter([...keep]); }
260
316
 
317
+ override trim(count: number): void {
318
+ this.#rows.trim(count); this.offset = this.#rows.totalOffset; this.ringIdx = this.#rows.ringIndex;
319
+ this.releaseRopeArr();
320
+ }
321
+
261
322
  override isTrimmable(): boolean {
262
323
  return false; // batched rows never re-enter the prompt cache directly
263
324
  }