mlx-bun 0.0.9 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/kv-store.ts CHANGED
@@ -1,23 +1,38 @@
1
1
  // KV-cache persistence: save prompt caches to disk, reload zero-copy.
2
+ // The serialization core of the SSD cold tier (docs/design/ssd-kv-cold-tier.md).
2
3
  //
3
4
  // File layout (every tensor PAGE-ALIGNED — the Phase 1 corollary: files
4
5
  // we write can be mmap'd and handed to the GPU without copies):
5
- // [magic "MLXBUNKV1\n"][u32 LE header length][JSON header][padding]
6
+ // [magic "MLXBUNKV2\n"][u32 LE header length][u32 LE dataStart]
7
+ // [u64 LE header hash][JSON header][padding]
6
8
  // [tensor data at 16 KiB-aligned offsets]
7
- // Header: { tokens, caches: [{ kind, offset, idx?, maxSize?, dtype,
8
- // kShape, vShape, kOffset, vOffset }] }
9
+ // Header: { formatVersion, modelId, configFingerprint, ns, tokenizerHash,
10
+ // createdAt, tokens, caches: [{ kind, offset, idx?, maxSize?,
11
+ // groupSize?, bits?, tensors: [{ off, bytes, shape, dtype, hash }] }] }
12
+ //
13
+ // v2 over v1: quantized cache kinds (the serving DEFAULT is kv_config
14
+ // quantization, which v1 could not persist), SSM kind (Qwen3.5 hybrid),
15
+ // invalidation metadata (configFingerprint covers the kv-quant scheme;
16
+ // tokenizerHash guards vocab drift; ns = adapter spec), per-tensor hashes
17
+ // (verified opt-in — eager verification would defeat lazy page fault-in),
18
+ // a header hash (always verified — cheap), and atomic tmp+fsync+rename
19
+ // writes. v1 files are not migrated: nothing shipped wrote them (the
20
+ // format was test/experiment-only) — they read as "not a v2 file" and the
21
+ // SSD tier deletes-and-regenerates.
9
22
  //
10
23
  // Reload mmaps copy-on-write (MAP_PRIVATE): if mlx ever donates one of
11
24
  // these buffers, writes hit private pages, never the file.
12
25
 
13
- import { openSync, writeSync, closeSync } from "node:fs";
26
+ import { openSync, writeSync, readSync, closeSync, fsyncSync, renameSync, rmSync } from "node:fs";
14
27
  import { MmapFile } from "./mmap";
15
28
  import { MlxArray } from "./mlx/array";
16
29
  import type { Dtype } from "./mlx/ffi";
17
30
  import * as ops from "./mlx/ops";
18
31
  import {
19
- type Cache, KVCache, RotatingKVCache, Gemma4Model,
20
- } from "./model/gemma4";
32
+ type Cache, KVCache, RotatingKVCache,
33
+ QuantizedKVCache, RotatingQuantizedKVCache,
34
+ } from "./model/gemma4-base";
35
+ import { SSMCache } from "./model/qwen3-delta";
21
36
 
22
37
  /** Contiguous raw bytes of (possibly a view of) an array. */
23
38
  function contiguousBytes(a: MlxArray): Uint8Array {
@@ -27,120 +42,184 @@ function contiguousBytes(a: MlxArray): Uint8Array {
27
42
  return bytes;
28
43
  }
29
44
 
30
- const MAGIC = "MLXBUNKV1\n";
45
+ const MAGIC = "MLXBUNKV2\n";
31
46
  const ALIGN = 16384;
47
+ /** magic + u32 headerLen + u32 dataStart + u64 headerHash */
48
+ const PREFIX_LEN = MAGIC.length + 4 + 4 + 8;
49
+
50
+ const hash64 = (bytes: Uint8Array): string => Bun.hash(bytes).toString(16);
32
51
 
33
- interface CacheHeaderEntry {
34
- kind: "kv" | "rotating";
52
+ export type CacheKind = "kv" | "rotating" | "qkv" | "rotating-qkv" | "ssm";
53
+
54
+ interface TensorSlot {
55
+ off: number;
56
+ bytes: number;
57
+ shape: number[];
58
+ dtype: number;
59
+ hash: string;
60
+ }
61
+
62
+ export interface CacheHeaderEntry {
63
+ kind: CacheKind;
35
64
  offset: number;
65
+ /** rotating variants: ring write index */
36
66
  idx?: number;
67
+ /** rotating variants: window size */
37
68
  maxSize?: number;
38
- dtype: number;
39
- kShape: number[];
40
- vShape: number[];
41
- kOffset: number;
42
- vOffset: number;
43
- kBytes: number;
44
- vBytes: number;
69
+ /** quantized variants */
70
+ groupSize?: number;
71
+ bits?: number;
72
+ /** kv/rotating: [k, v] · qkv/rotating-qkv: [kPacked, kScales, kBiases,
73
+ * vPacked, vScales, vBiases] · ssm: [conv, recurrent] */
74
+ tensors: TensorSlot[];
45
75
  }
46
76
 
47
- export interface KvFileHeader {
77
+ export interface KvSaveMeta {
78
+ modelId?: string;
79
+ /** configFingerprint(config) — covers every graph-shaping field incl.
80
+ * the kv-quant scheme, so a scheme flip invalidates naturally. */
81
+ configFingerprint?: string;
82
+ /** Adapter namespace (PromptCache ns — adapters joined with "+"). */
83
+ ns?: string;
84
+ /** sha256 of tokenizer.json: identical ids must mean identical text. */
85
+ tokenizerHash?: string;
86
+ }
87
+
88
+ export interface KvFileHeader extends KvSaveMeta {
89
+ formatVersion: 2;
90
+ createdAt: number;
48
91
  tokens: number[];
49
92
  caches: CacheHeaderEntry[];
50
93
  }
51
94
 
52
95
  const alignUp = (n: number) => Math.ceil(n / ALIGN) * ALIGN;
53
96
 
54
- export function saveKvCache(path: string, tokens: number[], caches: Cache[]): void {
55
- const entries: CacheHeaderEntry[] = [];
97
+ /** Snapshot one cache into header entry + blobs. Quantized full caches are
98
+ * sliced to the live [.., :offset, :] region (token axis — quantization
99
+ * groups run along the FEATURE dim, so a sequence-axis cut never splits a
100
+ * group); rotating caches persist the whole ring as-laid-out with ringIdx. */
101
+ function snapshotCache(c: Cache, dataOffset: number): { entry: CacheHeaderEntry; blobs: Uint8Array[]; next: number } {
102
+ const slots: TensorSlot[] = [];
56
103
  const blobs: Uint8Array[] = [];
104
+ let cursor = dataOffset;
105
+ const push = (a: MlxArray, disposeAfter: boolean): void => {
106
+ const bytes = contiguousBytes(a);
107
+ const off = alignUp(cursor);
108
+ slots.push({ off, bytes: bytes.length, shape: a.shape, dtype: a.dtype, hash: hash64(bytes) });
109
+ blobs.push(bytes);
110
+ cursor = off + bytes.length;
111
+ if (disposeAfter) a.dispose();
112
+ };
113
+ const liveSlice = (a: MlxArray, upTo: number): MlxArray => {
114
+ const [B, H, , D] = a.shape as [number, number, number, number];
115
+ return a.slice([0, 0, 0, 0], [B, H, upTo, D]);
116
+ };
117
+ const pushTriple = (t: ops.QuantizedTensor, upTo: number | null): void => {
118
+ for (const a of [t.packed, t.scales, t.biases]) {
119
+ if (upTo === null) push(a, false);
120
+ else push(liveSlice(a, upTo), true);
121
+ }
122
+ };
123
+
124
+ let entry: CacheHeaderEntry;
125
+ if (c instanceof RotatingQuantizedKVCache) {
126
+ if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
127
+ pushTriple(c.keys, null);
128
+ pushTriple(c.values, null);
129
+ entry = { kind: "rotating-qkv", offset: c.offset, idx: c.ringIdx, maxSize: c.maxSize,
130
+ groupSize: c.groupSize, bits: c.bits, tensors: slots };
131
+ } else if (c instanceof QuantizedKVCache) {
132
+ if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
133
+ pushTriple(c.keys, c.offset);
134
+ pushTriple(c.values, c.offset);
135
+ entry = { kind: "qkv", offset: c.offset, groupSize: c.groupSize, bits: c.bits, tensors: slots };
136
+ } else if (c instanceof RotatingKVCache) {
137
+ if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
138
+ push(c.keys, false);
139
+ push(c.values, false);
140
+ entry = { kind: "rotating", offset: c.offset, idx: c.ringIdx, maxSize: c.maxSize, tensors: slots };
141
+ } else if (c instanceof SSMCache) {
142
+ if (!c.conv || !c.recurrent) throw new Error("cannot persist an empty cache");
143
+ push(c.conv, false);
144
+ push(c.recurrent, false);
145
+ entry = { kind: "ssm", offset: c.offset, tensors: slots };
146
+ } else if (c instanceof KVCache) {
147
+ if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
148
+ push(liveSlice(c.keys, c.offset), true);
149
+ push(liveSlice(c.values, c.offset), true);
150
+ entry = { kind: "kv", offset: c.offset, tensors: slots };
151
+ } else {
152
+ throw new Error("unknown cache type");
153
+ }
154
+ return { entry, blobs, next: cursor };
155
+ }
57
156
 
58
- // compact each cache to its live region and snapshot bytes
157
+ /** Persist `caches` (+ the exact token prefix they encode) to `path`.
158
+ * ATOMIC: written to `<path>.tmp`, fsync'd, renamed — a crash mid-write
159
+ * leaves only a .tmp orphan (ignored + reaped by SsdCacheStore.scan). */
160
+ export function saveKvCache(path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta = {}): void {
161
+ const entries: CacheHeaderEntry[] = [];
162
+ const blobs: Uint8Array[] = [];
59
163
  let dataOffset = 0; // relative; rebased after header is sized
60
164
  for (const c of caches) {
61
- let keys: MlxArray;
62
- let values: MlxArray;
63
- let kind: "kv" | "rotating";
64
- let idx: number | undefined;
65
- let maxSize: number | undefined;
66
- if (c instanceof RotatingKVCache) {
67
- kind = "rotating";
68
- idx = c.ringIdx;
69
- maxSize = c.maxSize;
70
- if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
71
- keys = c.keys;
72
- values = c.values;
73
- } else if (c instanceof KVCache) {
74
- kind = "kv";
75
- if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
76
- // store only the live [.., :offset, :] region
77
- const [B, H, , D] = c.keys.shape as [number, number, number, number];
78
- const vD = c.values.shape[3]!;
79
- keys = c.keys.slice([0, 0, 0, 0], [B, H, c.offset, D]);
80
- values = c.values.slice([0, 0, 0, 0], [B, H, c.offset, vD]);
81
- } else {
82
- throw new Error("unknown cache type");
83
- }
84
-
85
- const kBytesArr = contiguousBytes(keys);
86
- const vBytesArr = contiguousBytes(values);
87
- const kOffset = alignUp(dataOffset);
88
- const vOffset = alignUp(kOffset + kBytesArr.length);
89
- dataOffset = vOffset + vBytesArr.length;
90
-
91
- entries.push({
92
- kind, offset: c.offset, idx, maxSize,
93
- dtype: keys.dtype,
94
- kShape: keys.shape, vShape: values.shape,
95
- kOffset, vOffset,
96
- kBytes: kBytesArr.length, vBytes: vBytesArr.length,
97
- });
98
- blobs.push(kBytesArr, vBytesArr);
99
- if (c instanceof KVCache) {
100
- keys.dispose();
101
- values.dispose();
102
- }
165
+ const s = snapshotCache(c, dataOffset);
166
+ entries.push(s.entry);
167
+ blobs.push(...s.blobs);
168
+ dataOffset = s.next;
103
169
  }
104
170
 
105
- const header: KvFileHeader = { tokens, caches: entries };
171
+ const header: KvFileHeader = {
172
+ formatVersion: 2, createdAt: Date.now(), ...meta, tokens, caches: entries,
173
+ };
106
174
  const headerJson = new TextEncoder().encode(JSON.stringify(header));
107
- const dataStart = alignUp(MAGIC.length + 8 + headerJson.length);
175
+ const dataStart = alignUp(PREFIX_LEN + headerJson.length);
108
176
 
109
- const fd = openSync(path, "w");
177
+ const tmp = `${path}.tmp`;
178
+ const fd = openSync(tmp, "w");
110
179
  try {
111
180
  const pre = new Uint8Array(dataStart);
112
181
  pre.set(new TextEncoder().encode(MAGIC), 0);
113
182
  const dv = new DataView(pre.buffer);
114
183
  dv.setUint32(MAGIC.length, headerJson.length, true);
115
184
  dv.setUint32(MAGIC.length + 4, dataStart, true);
116
- pre.set(headerJson, MAGIC.length + 8);
185
+ dv.setBigUint64(MAGIC.length + 8, BigInt(Bun.hash(headerJson)), true);
186
+ pre.set(headerJson, PREFIX_LEN);
117
187
  writeSync(fd, pre, 0, pre.length, 0);
118
188
 
119
189
  let blobIdx = 0;
120
- for (const e of entries) {
121
- writeSync(fd, blobs[blobIdx]!, 0, e.kBytes, dataStart + e.kOffset);
122
- writeSync(fd, blobs[blobIdx + 1]!, 0, e.vBytes, dataStart + e.vOffset);
123
- blobIdx += 2;
124
- }
125
- } finally {
190
+ for (const e of entries)
191
+ for (const slot of e.tensors)
192
+ writeSync(fd, blobs[blobIdx++]!, 0, slot.bytes, dataStart + slot.off);
193
+ fsyncSync(fd);
194
+ } catch (err) {
126
195
  closeSync(fd);
196
+ try { rmSync(tmp, { force: true }); } catch {}
197
+ throw err;
127
198
  }
199
+ closeSync(fd);
200
+ renameSync(tmp, path);
128
201
  }
129
202
 
130
- /** Read only the header (cheap — for prefix matching across many files). */
203
+ /** Read only the header (cheap — for prefix matching across many files).
204
+ * Always verifies the header hash; throws on any structural mismatch. */
131
205
  export function readKvHeader(path: string): KvFileHeader & { dataStart: number } {
132
206
  const fd = openSync(path, "r");
133
207
  try {
134
- const head = new Uint8Array(MAGIC.length + 8);
135
- require("node:fs").readSync(fd, head, 0, head.length, 0);
208
+ const head = new Uint8Array(PREFIX_LEN);
209
+ readSync(fd, head, 0, head.length, 0);
136
210
  if (new TextDecoder().decode(head.subarray(0, MAGIC.length)) !== MAGIC)
137
- throw new Error(`${path}: not an mlx-bun KV cache file`);
211
+ throw new Error(`${path}: not an mlx-bun KV v2 cache file`);
138
212
  const dv = new DataView(head.buffer);
139
213
  const len = dv.getUint32(MAGIC.length, true);
140
214
  const dataStart = dv.getUint32(MAGIC.length + 4, true);
215
+ const expectHash = dv.getBigUint64(MAGIC.length + 8, true);
141
216
  const body = new Uint8Array(len);
142
- require("node:fs").readSync(fd, body, 0, len, MAGIC.length + 8);
217
+ readSync(fd, body, 0, len, PREFIX_LEN);
218
+ if (BigInt(Bun.hash(body)) !== expectHash)
219
+ throw new Error(`${path}: header hash mismatch (truncated or corrupt)`);
143
220
  const header = JSON.parse(new TextDecoder().decode(body)) as KvFileHeader;
221
+ if (header.formatVersion !== 2)
222
+ throw new Error(`${path}: unsupported formatVersion ${header.formatVersion}`);
144
223
  return { ...header, dataStart };
145
224
  } finally {
146
225
  closeSync(fd);
@@ -149,37 +228,100 @@ export function readKvHeader(path: string): KvFileHeader & { dataStart: number }
149
228
 
150
229
  export interface LoadedKvCache {
151
230
  tokens: number[];
231
+ header: KvFileHeader;
152
232
  caches: Cache[];
153
233
  /** Keep referenced as long as the caches live. */
154
234
  mmap: MmapFile;
155
235
  }
156
236
 
157
- export function loadKvCache(path: string, model: Gemma4Model): LoadedKvCache {
237
+ export interface KvLoadExpect {
238
+ /** Reject on metadata mismatch (pass what the server is running). */
239
+ configFingerprint?: string;
240
+ tokenizerHash?: string;
241
+ ns?: string;
242
+ /** Verify every tensor hash (reads all bytes — defeats lazy fault-in;
243
+ * off by default, `--ssd-cache-verify`). */
244
+ verify?: boolean;
245
+ }
246
+
247
+ /** Reload zero-copy. `model` is anything with makeCache() — the entry count
248
+ * is validated against the DONOR cache list (model.layers.length was wrong
249
+ * for KV-shared models like e4b, whose makeCache() returns donors only). */
250
+ export function loadKvCache(
251
+ path: string,
252
+ model: { makeCache(): Cache[] },
253
+ expect: KvLoadExpect = {},
254
+ ): LoadedKvCache {
158
255
  const header = readKvHeader(path);
256
+ for (const key of ["configFingerprint", "tokenizerHash", "ns"] as const) {
257
+ if (expect[key] !== undefined && header[key] !== expect[key])
258
+ throw new Error(`${path}: ${key} mismatch (file ${header[key]}, expected ${expect[key]})`);
259
+ }
260
+ const proto = model.makeCache();
261
+ const cacheCount = proto.length;
262
+ for (const c of proto) c.dispose();
263
+ if (header.caches.length !== cacheCount)
264
+ throw new Error(`${path}: ${header.caches.length} cached layers but model has ${cacheCount}`);
265
+
159
266
  const mmap = MmapFile.open(path, "cow");
160
267
  const dataStart = header.dataStart;
268
+ const arr = (slot: TensorSlot): MlxArray => {
269
+ if (expect.verify) {
270
+ const view = mmap.view(dataStart + slot.off, slot.bytes);
271
+ if (hash64(view) !== slot.hash) {
272
+ mmap.unmap();
273
+ throw new Error(`${path}: tensor hash mismatch at offset ${slot.off}`);
274
+ }
275
+ }
276
+ return MlxArray.fromPointer(mmap.pointer(dataStart + slot.off), slot.shape, slot.dtype as Dtype);
277
+ };
278
+ const triple = (slots: TensorSlot[], at: number): ops.QuantizedTensor =>
279
+ ({ packed: arr(slots[at]!), scales: arr(slots[at + 1]!), biases: arr(slots[at + 2]!) });
161
280
 
162
281
  const caches: Cache[] = [];
163
- for (const e of header.caches) {
164
- const keys = MlxArray.fromPointer(
165
- mmap.pointer(dataStart + e.kOffset), e.kShape, e.dtype as Dtype,
166
- );
167
- const values = MlxArray.fromPointer(
168
- mmap.pointer(dataStart + e.vOffset), e.vShape, e.dtype as Dtype,
169
- );
170
- if (e.kind === "rotating") {
171
- const c = new RotatingKVCache(e.maxSize!);
172
- c.restoreState(keys, values, e.offset, e.idx!);
173
- caches.push(c);
174
- } else {
175
- const c = new KVCache();
176
- c.restoreState(keys, values, e.offset);
177
- caches.push(c);
282
+ try {
283
+ for (const e of header.caches) {
284
+ const t = e.tensors;
285
+ switch (e.kind) {
286
+ case "kv": {
287
+ const c = new KVCache();
288
+ c.restoreState(arr(t[0]!), arr(t[1]!), e.offset);
289
+ caches.push(c);
290
+ break;
291
+ }
292
+ case "rotating": {
293
+ const c = new RotatingKVCache(e.maxSize!);
294
+ c.restoreState(arr(t[0]!), arr(t[1]!), e.offset, e.idx!);
295
+ caches.push(c);
296
+ break;
297
+ }
298
+ case "qkv": {
299
+ const c = new QuantizedKVCache(e.groupSize!, e.bits!);
300
+ c.restoreState(triple(t, 0), triple(t, 3), e.offset);
301
+ caches.push(c);
302
+ break;
303
+ }
304
+ case "rotating-qkv": {
305
+ const c = new RotatingQuantizedKVCache(e.maxSize!, e.groupSize!, e.bits!);
306
+ c.restoreState(triple(t, 0), triple(t, 3), e.offset, e.idx!);
307
+ caches.push(c);
308
+ break;
309
+ }
310
+ case "ssm": {
311
+ const c = new SSMCache();
312
+ c.conv = arr(t[0]!);
313
+ c.recurrent = arr(t[1]!);
314
+ c.offset = e.offset;
315
+ caches.push(c);
316
+ break;
317
+ }
318
+ default:
319
+ throw new Error(`${path}: unknown cache kind ${(e as { kind: string }).kind}`);
320
+ }
178
321
  }
322
+ } catch (err) {
323
+ for (const c of caches) c.dispose();
324
+ throw err;
179
325
  }
180
- if (caches.length !== model.layers.length)
181
- throw new Error(
182
- `${path}: ${caches.length} cached layers but model has ${model.layers.length}`,
183
- );
184
- return { tokens: header.tokens, caches, mmap };
326
+ return { tokens: header.tokens, header, caches, mmap };
185
327
  }
package/src/mlx/array.ts CHANGED
@@ -108,6 +108,27 @@ export class MlxArray {
108
108
  );
109
109
  }
110
110
 
111
+ /** Graph-free LEAF with this array's exact bytes — ONE copy, straight from
112
+ * the (evaluated) mlx buffer into a fresh mlx-owned array, no JS-heap
113
+ * intermediate (`fromBytesCopy(rawBytes())` is TWO copies: device→JS heap
114
+ * →new leaf). mlx_array_new_data copies synchronously, so the source only
115
+ * has to outlive this call. The buffer is read LINEARLY — the caller must
116
+ * pass a row-major contiguous array (ops.contiguous first for views), same
117
+ * contract as rawBytes. Segmented-backward boundary detach hot path. */
118
+ detachCopy(): MlxArray {
119
+ this.eval();
120
+ const dt = this.dtype;
121
+ const p =
122
+ dt === Dtype.float32 ? C.mlx_array_data_float32(this.handle)
123
+ : dt === Dtype.float16 ? C.mlx_array_data_float16(this.handle)
124
+ : dt === Dtype.bfloat16 ? C.mlx_array_data_bfloat16(this.handle)
125
+ : dt === Dtype.uint32 ? C.mlx_array_data_uint32(this.handle)
126
+ : null;
127
+ if (p === null) throw new Error(`detachCopy: unsupported dtype ${this.dtypeName}`);
128
+ const sb = shapeBuf(this.shape);
129
+ return new MlxArray(C.mlx_array_new_data(p as never, ptr(sb), this.ndim, dt));
130
+ }
131
+
111
132
  get handle(): MlxHandle {
112
133
  if (this.#disposed) throw new Error("MlxArray used after dispose");
113
134
  return this.#handle;
package/src/mlx/ops.ts CHANGED
@@ -172,6 +172,32 @@ export function quantizedMatmul(
172
172
  x: MlxArray, w: MlxArray, scales: MlxArray, biases: MlxArray | null,
173
173
  spec: QuantSpec, transpose = true, s: S = gpuStream,
174
174
  ): MlxArray {
175
+ // UPSTREAM MLX BUG WORKAROUND (found 2026-07-02, reproduced on stock mlx
176
+ // 0.31.2): quantized_matmul with transpose=false returns WRONG VALUES when
177
+ // the row count is 2 or 3 (relnorm ~0.3-1.1 vs the dequantized matmul);
178
+ // M=1 and M>=4 are correct, transpose=true is correct at every M. Pad the
179
+ // row axis to 4 with zeros and slice the result back. NOTE this wrapper
180
+ // cannot protect mlx's INTERNAL vjp of a transpose=true qmm (C++ calls the
181
+ // primitive directly) — training heads pad the span through the head
182
+ // instead (loss.ts logitsFromHiddenPadM).
183
+ const rowAxis = x.shape.length - 2;
184
+ const rows = x.shape[rowAxis]!;
185
+ if (!transpose && (rows === 2 || rows === 3)) {
186
+ const padShape = [...x.shape];
187
+ padShape[rowAxis] = 4 - rows;
188
+ const pad = zeros(padShape, x.dtype, s);
189
+ const xPad = concatAxis([x, pad], rowAxis, s);
190
+ pad.dispose();
191
+ const yPad = quantizedMatmul(xPad, w, scales, biases, spec, transpose, s);
192
+ xPad.dispose();
193
+ const start = MlxArray.fromInt32(new Int32Array([0]), [1]);
194
+ const outShape = [...yPad.shape];
195
+ outShape[rowAxis] = rows;
196
+ const y = sliceDynamic(yPad, start, [rowAxis], outShape, s);
197
+ start.dispose();
198
+ yPad.dispose();
199
+ return y;
200
+ }
175
201
  return new MlxArray(
176
202
  outArray("quantized_matmul", (o) =>
177
203
  C.mlx_quantized_matmul(
@@ -590,6 +590,15 @@ export class QuantizedKVCache implements Cache {
590
590
  this.offset = Math.max(0, this.offset - n);
591
591
  }
592
592
 
593
+ /** Adopt persisted state (takes ownership of the triples' arrays) —
594
+ * the quantized twin of KVCache.restoreState (kv-store persistence). */
595
+ restoreState(keys: ops.QuantizedTensor, values: ops.QuantizedTensor, offset: number): void {
596
+ this.dispose();
597
+ this.keys = keys;
598
+ this.values = values;
599
+ this.offset = offset;
600
+ }
601
+
593
602
  dispose(): void {
594
603
  for (const a of this.state()) a.dispose();
595
604
  this.keys = this.values = null;
@@ -1162,6 +1171,16 @@ export class RotatingQuantizedKVCache implements Cache {
1162
1171
  }
1163
1172
  }
1164
1173
 
1174
+ /** Adopt persisted state (takes ownership of the triples' arrays) —
1175
+ * ring order as-laid-out, ringIdx carried with it (kv-store persistence). */
1176
+ restoreState(keys: ops.QuantizedTensor, values: ops.QuantizedTensor, offset: number, idx: number): void {
1177
+ this.dispose();
1178
+ this.keys = keys;
1179
+ this.values = values;
1180
+ this.offset = offset;
1181
+ this.ringIdx = idx;
1182
+ }
1183
+
1165
1184
  /** Oracle: to_quantized on an already-quantized rotating cache is
1166
1185
  * idempotent. */
1167
1186
  toQuantized(): RotatingQuantizedKVCache {
@@ -1572,8 +1591,22 @@ export function quantizedSdpa(
1572
1591
  )
1573
1592
  return fusedDecodeSdpa(q, kq, vq, groupSize, bits);
1574
1593
  const tile = q.shape[2]! > 1 || process.env.MLX_BUN_FUSED_DECODE === "1";
1575
- if (tile && fusedSdpaSupported(q, mask, groupSize, bits))
1594
+ if (tile && fusedSdpaSupported(q, mask, groupSize, bits)) {
1595
+ // The N-tiled loop CANNOT be traced: its tile count and slice extents
1596
+ // bake at trace-time N, so under shapeless replay a growing (concat-
1597
+ // phase) quantized cache's newest rows are silently never attended —
1598
+ // reproduced on e4b (whole-graph form, FUSED_DECODE=1: diverges within
1599
+ // 3 tokens, then repetition-loops). generate() refuses to compile when
1600
+ // MLX_BUN_FUSED_DECODE=1; this throw is the backstop for any other
1601
+ // compiled caller — it lands in the trace's transactional fallback
1602
+ // instead of producing silently wrong attention.
1603
+ if (isCompiledTrace())
1604
+ throw new Error(
1605
+ "quantizedSdpaTiled inside a compiled trace: the tile loop bakes trace-time N " +
1606
+ "(growing KV rows would be silently dropped) — run this generation uncompiled",
1607
+ );
1576
1608
  return quantizedSdpaTiled(q, kq, vq, scale, mask, groupSize, bits);
1609
+ }
1577
1610
  return quantizedSdpaUnfused(q, kq, vq, scale, mask, groupSize, bits);
1578
1611
  }
1579
1612
 
@@ -192,7 +192,10 @@ export class SSMCache implements Cache {
192
192
  }
193
193
 
194
194
  makeMask(_N: number, _windowSize: number | null): Mask {
195
- // B=1 single-stream: ssm_mask is None (no left-pad / variable lengths).
195
+ // ssm_mask is None both single-stream and in the batch lane: rows
196
+ // solo-prefill UNPADDED at B=1 (state never sees pad tokens), and
197
+ // batched decode feeds one real token per row — so unlike mlx-lm's
198
+ // left-padded batch-prefill there is never a pad position to mask.
196
199
  return { mode: "", arr: null };
197
200
  }
198
201
 
@@ -221,4 +224,48 @@ export class SSMCache implements Cache {
221
224
  this.conv = null;
222
225
  this.recurrent = null;
223
226
  }
227
+
228
+ // --- batch-lane dynamic-B ops (BatchScheduler only) ----------------------
229
+ // Both state slots are plain [B, ...] tensors with no temporal axis and no
230
+ // left-padding, so the batched twins of mergeKVRows/filterKVRows collapse
231
+ // to B-axis concat/take. `offset` is bookkeeping only for SSM layers (RoPE
232
+ // reads the FULL-attention caches; the linear-attn forward never reads it),
233
+ // so merged rows carrying different token counts share the max.
234
+
235
+ /** Merge a fully-prefilled solo row into a running batched cache (`prev`
236
+ * null ⇒ the solo row becomes the batch). The returned cache owns fresh
237
+ * arrays — or, when there is nothing to concat, arrays STOLEN from `solo`
238
+ * (nulled there so the scheduler's unconditional dispose of the solo
239
+ * caches stays safe). `prev`/`solo` disposal remains the caller's job. */
240
+ static mergeRows(prev: SSMCache | null, solo: SSMCache): SSMCache {
241
+ if (!solo.conv || !solo.recurrent)
242
+ throw new Error("SSMCache.mergeRows: solo row has no state (prefill first)");
243
+ const out = new SSMCache();
244
+ if (prev) {
245
+ if (!prev.conv || !prev.recurrent)
246
+ throw new Error("SSMCache.mergeRows: batched cache has no state");
247
+ out.conv = ops.concatAxis([prev.conv, solo.conv], 0);
248
+ out.recurrent = ops.concatAxis([prev.recurrent, solo.recurrent], 0);
249
+ } else {
250
+ out.conv = solo.conv;
251
+ out.recurrent = solo.recurrent;
252
+ solo.conv = null;
253
+ solo.recurrent = null;
254
+ }
255
+ out.offset = Math.max(prev?.offset ?? 0, solo.offset);
256
+ return out;
257
+ }
258
+
259
+ /** Evict rows not in `keep` (ascending row indices), in place. */
260
+ filter(keep: number[]): void {
261
+ if (!this.conv || !this.recurrent) return;
262
+ const idx = ops.fromInt32(keep, [keep.length]);
263
+ const conv = ops.takeAxis(this.conv, idx, 0);
264
+ const recurrent = ops.takeAxis(this.recurrent, idx, 0);
265
+ idx.dispose();
266
+ this.conv.dispose();
267
+ this.recurrent.dispose();
268
+ this.conv = conv;
269
+ this.recurrent = recurrent;
270
+ }
224
271
  }
@@ -212,8 +212,17 @@ class Qwen3Attention {
212
212
  v = disposing(v, ops.reshape(v, [B, L, this.nKvHeads, this.headDim]));
213
213
  v = disposing(v, ops.transposeAxes(v, [0, 2, 1, 3]));
214
214
 
215
- q = disposing(q, ops.rope(q, this.ropeDims, this.ropeBase, cache.offset, null));
216
- k = disposing(k, ops.rope(k, this.ropeDims, this.ropeBase, cache.offset, null));
215
+ // Batched decode: the scheduler's mask wrapper exposes each row's REAL
216
+ // position as ropeOffsetArr (rows have different prompt lengths); the
217
+ // dynamic-offset kernel is the same fast::rope, bit-exact vs the static
218
+ // form (tests/compile.test.ts). Serial lane: scalar offset, unchanged.
219
+ const offArr = (cache as { ropeOffsetArr?: MlxArray }).ropeOffsetArr;
220
+ q = disposing(q, offArr
221
+ ? ops.ropeDynamic(q, this.ropeDims, this.ropeBase, offArr, null)
222
+ : ops.rope(q, this.ropeDims, this.ropeBase, cache.offset, null));
223
+ k = disposing(k, offArr
224
+ ? ops.ropeDynamic(k, this.ropeDims, this.ropeBase, offArr, null)
225
+ : ops.rope(k, this.ropeDims, this.ropeBase, cache.offset, null));
217
226
 
218
227
  let attn: MlxArray;
219
228
  if (cache instanceof QuantizedKVCache) {