mlx-bun 0.0.10 → 0.0.11

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 (173) hide show
  1. package/README.md +66 -21
  2. package/THIRD_PARTY_LICENSES.md +1 -0
  3. package/docs/archive/mlx-bun-lab-report.html +275 -0
  4. package/docs/archive/wwdc-mlx-bun.html +848 -0
  5. package/docs/design/audio-input-plan.md +356 -0
  6. package/docs/design/batching-perf-path.md +58 -35
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/compat-cli-surface-design.md +4 -3
  10. package/docs/design/decode-speed-program.md +172 -0
  11. package/docs/design/docs-reorg-plan.md +1 -1
  12. package/docs/design/dspark-serving-program.md +371 -0
  13. package/docs/design/dspark-speculative-decoding.md +167 -5
  14. package/docs/design/faithful-l1-consolidation.md +310 -0
  15. package/docs/design/generic-model-support.md +8 -5
  16. package/docs/design/grammar-spec-batching-integration.md +301 -0
  17. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  18. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  19. package/docs/design/omlx-adoption-map.md +14 -6
  20. package/docs/design/orpo-training.md +34 -30
  21. package/docs/design/paged-kv-cache.md +214 -0
  22. package/docs/design/parallel-slots.md +82 -49
  23. package/docs/design/parity-tier-dag.md +43 -33
  24. package/docs/design/repo-cleanup-plan.md +189 -0
  25. package/docs/design/runtime-isolation.md +19 -3
  26. package/docs/design/segmented-backward-training.md +3 -2
  27. package/docs/design/ssd-kv-cold-tier.md +130 -0
  28. package/docs/design/structured-output.md +484 -0
  29. package/docs/design/the-dreaming-master-plan.md +2 -3
  30. package/docs/design/turboquant-kv.md +235 -0
  31. package/docs/design/unified-engine-frontier-plan.md +774 -0
  32. package/docs/design/web-chat-beat-matrix.md +315 -0
  33. package/docs/design/web-chat-redesign.md +1112 -0
  34. package/docs/investigations/dspark-handoff.md +182 -16
  35. package/docs/investigations/lab-build-journal.md +2 -2
  36. package/docs/investigations/openwiki-evaluation.md +249 -0
  37. package/docs/planning/IDEAS.md +14 -0
  38. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  39. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  40. package/docs/planning/chat-ui-vision.md +50 -0
  41. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  42. package/docs/planning/release-notes-v0.0.11.md +239 -0
  43. package/docs/reference/cli.md +35 -16
  44. package/docs/reference/distribution.md +13 -6
  45. package/docs/reference/embedding.md +35 -14
  46. package/docs/reference/features-matrix.md +97 -0
  47. package/docs/reference/library-api.md +30 -9
  48. package/docs/reference/memory.md +26 -6
  49. package/docs/reference/models.md +11 -1
  50. package/docs/reference/orpo-quickstart.md +3 -4
  51. package/docs/reference/server-api.md +634 -22
  52. package/docs/reference/server-config.md +250 -121
  53. package/docs/reference/training.md +14 -15
  54. package/package.json +5 -3
  55. package/src/anthropic.ts +13 -0
  56. package/src/audio/conformer.ts +647 -0
  57. package/src/audio/decode.ts +133 -0
  58. package/src/audio/features.ts +202 -0
  59. package/src/audio/transcode.ts +67 -0
  60. package/src/cli.ts +356 -105
  61. package/src/config.ts +49 -2
  62. package/src/download.ts +40 -0
  63. package/src/eval/README.md +7 -6
  64. package/src/eval/kl.ts +59 -3
  65. package/src/eval/quality-db.ts +2 -2
  66. package/src/eval/runner.ts +3 -3
  67. package/src/expert-offload.ts +15 -2
  68. package/src/flags.ts +13 -0
  69. package/src/generate.ts +330 -24
  70. package/src/grammar.ts +585 -0
  71. package/src/hub-rest.ts +242 -0
  72. package/src/kv-store.ts +442 -88
  73. package/src/lora.ts +8 -0
  74. package/src/md-modules.d.ts +42 -0
  75. package/src/media-fetch.ts +284 -0
  76. package/src/memory/rest.ts +245 -0
  77. package/src/memory/vault.ts +74 -0
  78. package/src/mlx/array.ts +106 -19
  79. package/src/mlx/autograd.ts +2 -2
  80. package/src/mlx/ffi.ts +27 -3
  81. package/src/mlx/metal-kernel.ts +7 -4
  82. package/src/mlx/ops.ts +153 -23
  83. package/src/mlx/turboquant-ops.ts +831 -0
  84. package/src/mlx/turboquant-tables.ts +237 -0
  85. package/src/model/batched-mask.ts +62 -1
  86. package/src/model/batched-quant.ts +206 -0
  87. package/src/model/batched-rotating-quant.ts +346 -0
  88. package/src/model/batched-rotating.ts +46 -1
  89. package/src/model/compiled-decode.ts +4 -15
  90. package/src/model/factory.ts +10 -4
  91. package/src/model/gemma4-base.ts +363 -39
  92. package/src/model/gemma4.ts +122 -33
  93. package/src/model/generated/gemma4-12b.ts +9 -13
  94. package/src/model/generated/gemma4-26b.ts +10 -5
  95. package/src/model/generated/gemma4-e4b.ts +14 -9
  96. package/src/model/minicpm5.ts +58 -102
  97. package/src/model/paged-kv.ts +253 -0
  98. package/src/model/qwen3-delta.ts +76 -15
  99. package/src/model/qwen3-moe.ts +404 -0
  100. package/src/model/qwen3.ts +9 -8
  101. package/src/model/qwen3_5.ts +88 -23
  102. package/src/model/support.ts +12 -1
  103. package/src/model/universal/dense.ts +40 -5
  104. package/src/model/universal/rope.ts +36 -21
  105. package/src/pi-web.ts +1299 -82
  106. package/src/preflight.ts +27 -8
  107. package/src/prompt-cache.ts +293 -33
  108. package/src/quantize/quantizer.ts +16 -1
  109. package/src/registry.ts +57 -2
  110. package/src/serve/batch-scheduler.ts +965 -72
  111. package/src/serve/generation-gateway.ts +214 -11
  112. package/src/serve/isolate.ts +417 -0
  113. package/src/serve/lane-registry.ts +44 -0
  114. package/src/serve/session-search.ts +326 -0
  115. package/src/server.ts +1380 -135
  116. package/src/spec/assistant-source.ts +150 -0
  117. package/src/spec/deepspec-source.ts +143 -0
  118. package/src/spec/dflash-source.ts +124 -0
  119. package/src/spec/dspark/ab-stats.ts +136 -0
  120. package/src/spec/dspark/calibration.ts +112 -0
  121. package/src/spec/dspark/deepspec-module.ts +801 -0
  122. package/src/spec/dspark/generate-dflash.ts +72 -20
  123. package/src/spec/dspark/loader.ts +23 -0
  124. package/src/spec/dspark/module-dflash.ts +278 -36
  125. package/src/spec/dspark/quantize-drafter.ts +91 -0
  126. package/src/spec/generate.ts +59 -25
  127. package/src/spec/ngram-source.ts +132 -0
  128. package/src/spec/serve-loop.ts +536 -0
  129. package/src/spec/source.ts +93 -0
  130. package/src/spec/two-model.ts +171 -0
  131. package/src/ssd-cache.ts +107 -35
  132. package/src/tokenizer.ts +288 -2
  133. package/src/tool-approvals.ts +131 -0
  134. package/src/tool-call.ts +408 -20
  135. package/src/train/flash-cce.ts +2 -2
  136. package/src/train/job.ts +0 -2
  137. package/src/train/trainer.ts +1 -4
  138. package/src/vision/prompt.ts +217 -48
  139. package/src/web/app.html +1184 -1789
  140. package/src/web/app.js +5667 -0
  141. package/src/web/icon.svg +13 -0
  142. package/src/web/manifest.webmanifest +24 -0
  143. package/src/web/src/adapters-panel.ts +288 -0
  144. package/src/web/src/api.ts +57 -0
  145. package/src/web/src/assistant.ts +485 -0
  146. package/src/web/src/chat.ts +1050 -0
  147. package/src/web/src/composer.ts +959 -0
  148. package/src/web/src/dataset.ts +132 -0
  149. package/src/web/src/finetune.ts +267 -0
  150. package/src/web/src/hub.ts +428 -0
  151. package/src/web/src/main.ts +98 -0
  152. package/src/web/src/markdown.ts +0 -0
  153. package/src/web/src/memory-panel.ts +572 -0
  154. package/src/web/src/model-picker.ts +159 -0
  155. package/src/web/src/palette.ts +346 -0
  156. package/src/web/src/protocol.ts +62 -0
  157. package/src/web/src/quantize.ts +167 -0
  158. package/src/web/src/rag.ts +274 -0
  159. package/src/web/src/sessions.ts +469 -0
  160. package/src/web/src/shell.ts +804 -0
  161. package/src/web/src/status.ts +252 -0
  162. package/src/web/src/ui-catalog.ts +92 -0
  163. package/src/web/sw.js +71 -0
  164. package/src/web/vendor/README.md +61 -0
  165. package/src/web/vendor/hljs-LICENSE +29 -0
  166. package/src/web/vendor/hljs-theme.css +59 -0
  167. package/src/web/vendor/hljs.js +26 -0
  168. package/src/model/fused-decode-kernel.ts +0 -312
  169. package/src/model/fused-geglu-kernel.ts +0 -198
  170. package/src/model/fused-mlp-kernel.ts +0 -169
  171. package/src/model/fused-swiglu-kernel.ts +0 -240
  172. package/src/model/steel-linear-kernel.ts +0 -96
  173. package/src/status-page.html +0 -497
@@ -14,9 +14,7 @@ import { MlxArray } from "../mlx/array";
14
14
  import { Dtype } from "../mlx/ffi";
15
15
  import * as ops from "../mlx/ops";
16
16
  import { expertOffloadArray } from "../expert-offload";
17
- import {
18
- fusedDecodeKernelSupported, fusedDecodeSdpa, perfKernelEnabled,
19
- } from "./fused-decode-kernel";
17
+ import * as tq from "../mlx/turboquant-ops";
20
18
 
21
19
  export type MaskMode = "" | "causal";
22
20
  export interface Mask {
@@ -206,7 +204,13 @@ export class QuantizedEmbedding {
206
204
  * position (see src/model/compiled-decode.ts). */
207
205
  export type SharedKv =
208
206
  | { kind: "plain"; keys: MlxArray; values: MlxArray; offset: number;
209
- offsetArr?: MlxArray }
207
+ offsetArr?: MlxArray;
208
+ /** TurboQuant deferred-V: `values` are still in the rotated (FWHT)
209
+ * domain — every consumer must un-rotate its attention OUTPUT
210
+ * (tq.unrotateValues) after sdpa. Attention is linear in V, so the
211
+ * result is the same decode, just transformed once per query row
212
+ * instead of once per cached token. */
213
+ vRotated?: boolean }
210
214
  | { kind: "quant"; keys: ops.QuantizedTensor; values: ops.QuantizedTensor;
211
215
  offset: number; groupSize: number; bits: number; offsetArr?: MlxArray };
212
216
 
@@ -306,9 +310,16 @@ export class KVCache implements Cache {
306
310
 
307
311
  makeMask(N: number, windowSize: number | null): Mask {
308
312
  if (N === 1) return { mode: "", arr: null };
309
- if (this.offset === 0 && windowSize === null) return { mode: "causal", arr: null };
310
- if (this.offset === 0 && windowSize !== null && N <= windowSize)
311
- return { mode: "causal", arr: null };
313
+ // Windowless multi-token chunks get the STRING "causal" at ANY offset —
314
+ // mlx-lm cache.py:114-125 create_attention_mask does exactly this, and
315
+ // mx.fast SDPA aligns the string bottom-right, i.e. the same values as
316
+ // our materialized matrix. Shipping the array instead (the old
317
+ // offset>0 branch) forced a materialize-and-add mask path: a bool
318
+ // [N, offset+N] built PER CHUNK and read by every full-attention layer
319
+ // — the e4b 16k prefill gap vs mlx-lm (2026-07-06b, hd=512 layers
320
+ // where the fallback dispatch can't block-skip an array mask).
321
+ if (windowSize === null) return { mode: "causal", arr: null };
322
+ if (this.offset === 0 && N <= windowSize) return { mode: "causal", arr: null };
312
323
  return { mode: "array", arr: createCausalMask(N, this.offset, windowSize) };
313
324
  }
314
325
 
@@ -508,14 +519,13 @@ export class QuantizedKVCache implements Cache {
508
519
 
509
520
  makeMask(N: number, windowSize: number | null): Mask {
510
521
  if (N === 1) return { mode: "", arr: null };
511
- if (this.offset === 0 && (windowSize === null || N <= windowSize))
522
+ // Same rule as KVCache.makeMask (mlx-lm cache.py:114-125): windowless
523
+ // multi-token chunks are the string "causal" at any offset. The old
524
+ // causalEquivalent escape hatch existed precisely because this case
525
+ // used to ship an array; only windowed continuations materialize now.
526
+ if (windowSize === null || (this.offset === 0 && N <= windowSize))
512
527
  return { mode: "causal", arr: null };
513
- return {
514
- mode: "array",
515
- arr: createCausalMask(N, this.offset, windowSize),
516
- // a windowless continuation mask is exactly mlx-lm's "causal"
517
- causalEquivalent: windowSize === null,
518
- };
528
+ return { mode: "array", arr: createCausalMask(N, this.offset, windowSize) };
519
529
  }
520
530
 
521
531
  /** Compiled decode: growth half of updateAndFetchQuantized (L=1),
@@ -599,6 +609,21 @@ export class QuantizedKVCache implements Cache {
599
609
  this.offset = offset;
600
610
  }
601
611
 
612
+ /** Chronological (K, V) triples sliced to offset — the quantized twin of
613
+ * KVCache.temporalView (batched merge/extend/filter reads). Caller owns
614
+ * the returned views. */
615
+ temporalView(): [ops.QuantizedTensor, ops.QuantizedTensor] {
616
+ if (!this.keys || !this.values) throw new Error("cache is empty");
617
+ const cut = (t: ops.QuantizedTensor): ops.QuantizedTensor => {
618
+ const f = (a: MlxArray): MlxArray => {
619
+ const [b, h, , d] = a.shape as [number, number, number, number];
620
+ return a.slice([0, 0, 0, 0], [b, h, this.offset, d]);
621
+ };
622
+ return { packed: f(t.packed), scales: f(t.scales), biases: f(t.biases) };
623
+ };
624
+ return [cut(this.keys), cut(this.values)];
625
+ }
626
+
602
627
  dispose(): void {
603
628
  for (const a of this.state()) a.dispose();
604
629
  this.keys = this.values = null;
@@ -1181,6 +1206,25 @@ export class RotatingQuantizedKVCache implements Cache {
1181
1206
  this.ringIdx = idx;
1182
1207
  }
1183
1208
 
1209
+ /** Chronological (K, V) triples cut to the valid window — the quantized
1210
+ * twin of RotatingKVCache.temporalView (batched merge reads, Phase 3
1211
+ * milestone 2). Caller owns the returned views. */
1212
+ temporalView(): [ops.QuantizedTensor, ops.QuantizedTensor] {
1213
+ if (!this.keys || !this.values) throw new Error("cache is empty");
1214
+ const valid = Math.min(this.offset, this.maxSize);
1215
+ const cutValid = (t: ops.QuantizedTensor): ops.QuantizedTensor =>
1216
+ mapTriple(t, (a) => {
1217
+ const [B, H, , D] = a.shape as [number, number, number, number];
1218
+ return a.slice([0, 0, 0, 0], [B, H, valid, D]);
1219
+ });
1220
+ const tk = this.#temporalOrder(this.keys);
1221
+ const tv = this.#temporalOrder(this.values);
1222
+ const out: [ops.QuantizedTensor, ops.QuantizedTensor] = [cutValid(tk), cutValid(tv)];
1223
+ disposeTriple(tk);
1224
+ disposeTriple(tv);
1225
+ return out;
1226
+ }
1227
+
1184
1228
  /** Oracle: to_quantized on an already-quantized rotating cache is
1185
1229
  * idempotent. */
1186
1230
  toQuantized(): RotatingQuantizedKVCache {
@@ -1279,6 +1323,297 @@ export class RotatingQuantizedKVCache implements Cache {
1279
1323
  }
1280
1324
  }
1281
1325
 
1326
+ /** One TurboQuant-encoded (K, V) storage tuple — the 5 arrays kv-store.ts
1327
+ * and toQuantized/fromKVCache pass around. Not `ops.QuantizedTensor`
1328
+ * (mlx's affine int4/int8 scheme): this is the rotation + Lloyd-Max
1329
+ * layout (docs/design/turboquant-kv.md), asymmetric-affine for keys,
1330
+ * FWHT+Lloyd-Max for values. Field order is the kv-store.ts tensor-slot
1331
+ * contract — do not reorder without updating snapshotCache/loadKvCache. */
1332
+ export interface TurboQuantTensor {
1333
+ kIdx: MlxArray;
1334
+ kScales: MlxArray;
1335
+ kZeros: MlxArray;
1336
+ vPacked: MlxArray;
1337
+ vScales: MlxArray;
1338
+ }
1339
+
1340
+ export const disposeTurboQuant = (t: TurboQuantTensor): void => {
1341
+ t.kIdx.dispose();
1342
+ t.kScales.dispose();
1343
+ t.kZeros.dispose();
1344
+ t.vPacked.dispose();
1345
+ t.vScales.dispose();
1346
+ };
1347
+
1348
+ /** TurboQuant KV cache — v1 (docs/design/turboquant-kv.md): dequantize-
1349
+ * on-fetch. Deliberately does NOT subclass KVCache/RotatingKVCache (or
1350
+ * QuantizedKVCache) — a novel class fails every generated-file
1351
+ * `#matches()` guard and every batching `instanceof` allow-list, so it
1352
+ * gets solo-only routing and monolith fallback for free, the same way
1353
+ * QuantizedKVCache does today. Only full-attention (non-rotating)
1354
+ * layers use this in v1; sliding-window layers stay bf16.
1355
+ *
1356
+ * Storage: 5 arrays per the vllm-metal reference's `turbo_quant_encode`
1357
+ * tuple — kIdx (int8 unpacked at kBits=8, else bit-packed uint8),
1358
+ * kScales/kZeros (fp16, one pair per 32-group), vPacked (bit-packed
1359
+ * uint8, no-op at vBits=8), vScales (fp16, one per 32-group).
1360
+ * updateAndFetch quantizes ONLY the newly appended tokens (unlike
1361
+ * QuantizedKVCache's whole-buffer-toQuantized conversion path — the
1362
+ * streaming append is the whole point of a live cache) and returns the
1363
+ * DEQUANTIZED bf16 active window so ops.sdpa runs unmodified — v1 pays
1364
+ * a full-window dequant every step; the deferred-InvFWHT trick is a
1365
+ * documented non-goal until the quality gate passes. */
1366
+ export class TurboQuantKVCache implements Cache {
1367
+ static readonly STEP = 256;
1368
+ /** Set only by compiled-decode trace adapters (see Cache) — TurboQuant
1369
+ * is not a compiled-decode participant in v1 (novel class, monolith
1370
+ * fallback only), so this is always unset; captured once per step by
1371
+ * construction, never refreshed mid-step (batched-rotating-quant.ts:55-63). */
1372
+ readonly ropeOffsetArr?: MlxArray;
1373
+ #kv: TurboQuantTensor | null = null;
1374
+ offset = 0;
1375
+ /** Validated lazily on first update, once head_dim is known. */
1376
+ #headDim: number | null = null;
1377
+
1378
+ constructor(readonly kBits: number, readonly vBits: number) {}
1379
+
1380
+ get headDim(): number | null {
1381
+ return this.#headDim;
1382
+ }
1383
+
1384
+ #validateHeadDim(dim: number): void {
1385
+ if (this.#headDim !== null) return;
1386
+ const supported = [64, 128, 256, 512];
1387
+ if (dim % 32 !== 0 || !supported.includes(dim)) {
1388
+ throw new Error(
1389
+ `TurboQuantKVCache: head_dim ${dim} must be divisible by 32 and in {${supported.join(",")}}`,
1390
+ );
1391
+ }
1392
+ this.#headDim = dim;
1393
+ }
1394
+
1395
+ #packedKDim(dim: number): number {
1396
+ return tq.packedDim(dim, this.kBits);
1397
+ }
1398
+ #packedVDim(dim: number): number {
1399
+ return tq.packedDim(dim, this.vBits);
1400
+ }
1401
+ #nGroups(dim: number): number {
1402
+ return dim / tq.TURBOQUANT_BLOCK_SIZE;
1403
+ }
1404
+
1405
+ #alloc(B: number, H: number, T: number, dim: number): TurboQuantTensor {
1406
+ const kIdxDtype = this.kBits === 8 ? Dtype.int8 : Dtype.uint8;
1407
+ return {
1408
+ kIdx: ops.zeros([B, H, T, this.#packedKDim(dim)], kIdxDtype),
1409
+ kScales: ops.zeros([B, H, T, this.#nGroups(dim)], Dtype.float16),
1410
+ kZeros: ops.zeros([B, H, T, this.#nGroups(dim)], Dtype.float16),
1411
+ vPacked: ops.zeros([B, H, T, this.#packedVDim(dim)], Dtype.uint8),
1412
+ vScales: ops.zeros([B, H, T, this.#nGroups(dim)], Dtype.float16),
1413
+ };
1414
+ }
1415
+
1416
+ #seqLen(): number {
1417
+ return this.#kv ? this.#kv.kIdx.shape[2]! : 0;
1418
+ }
1419
+
1420
+ /** Encode k/v [B,H,L,D] into a fresh TurboQuantTensor (packed at rest). */
1421
+ #encode(k: MlxArray, v: MlxArray): TurboQuantTensor {
1422
+ const signed = this.kBits === 8;
1423
+ const kEnc = tq.encodeKeys(k, this.kBits, signed);
1424
+ const kIdxPacked = this.kBits < 8 ? tq.packBits(kEnc.indices, this.kBits) : kEnc.indices;
1425
+ if (kIdxPacked !== kEnc.indices) kEnc.indices.dispose();
1426
+ const vEnc = tq.encodeValues(v, this.vBits);
1427
+ const vIdxPacked = this.vBits < 8 ? tq.packBits(vEnc.indices, this.vBits) : vEnc.indices;
1428
+ if (vIdxPacked !== vEnc.indices) vEnc.indices.dispose();
1429
+ return {
1430
+ kIdx: kIdxPacked, kScales: kEnc.scales, kZeros: kEnc.zeros,
1431
+ vPacked: vIdxPacked, vScales: vEnc.scales,
1432
+ };
1433
+ }
1434
+
1435
+ /** Decode a TurboQuantTensor's active [B,H,upTo,*] window back to
1436
+ * bf16 [B,H,upTo,headDim] (k, v). Caller owns the returned arrays.
1437
+ * deferV leaves V in the rotated domain (decodeValuesRotated) — the
1438
+ * caller un-rotates its attention output instead (tq.unrotateValues). */
1439
+ #decode(t: TurboQuantTensor, upTo: number, headDim: number, deferV = false): [MlxArray, MlxArray] {
1440
+ const cut = (a: MlxArray): MlxArray => {
1441
+ const [B, H, , D] = a.shape as [number, number, number, number];
1442
+ return a.slice([0, 0, 0, 0], [B, H, upTo, D]);
1443
+ };
1444
+ const kIdxCut = cut(t.kIdx);
1445
+ const kIdxUnpacked = this.kBits < 8 ? tq.unpackBits(kIdxCut, this.kBits, headDim) : kIdxCut;
1446
+ if (kIdxUnpacked !== kIdxCut) kIdxCut.dispose();
1447
+ const kScalesCut = cut(t.kScales);
1448
+ const kZerosCut = cut(t.kZeros);
1449
+ const k = tq.decodeKeys(kIdxUnpacked, kScalesCut, kZerosCut);
1450
+ for (const a of [kIdxUnpacked, kScalesCut, kZerosCut]) a.dispose();
1451
+
1452
+ const vPackedCut = cut(t.vPacked);
1453
+ const vIdxUnpacked = this.vBits < 8 ? tq.unpackBits(vPackedCut, this.vBits, headDim) : vPackedCut;
1454
+ if (vIdxUnpacked !== vPackedCut) vPackedCut.dispose();
1455
+ const vScalesCut = cut(t.vScales);
1456
+ const v = deferV
1457
+ ? tq.decodeValuesRotated(vIdxUnpacked, vScalesCut, this.vBits)
1458
+ : tq.decodeValues(vIdxUnpacked, vScalesCut, this.vBits);
1459
+ for (const a of [vIdxUnpacked, vScalesCut]) a.dispose();
1460
+
1461
+ return [k, v];
1462
+ }
1463
+
1464
+ /** Quantize the newly-appended k/v, append into growable packed
1465
+ * storage (256-step growth, mirroring KVCache), advance offset, then
1466
+ * dequantize the WHOLE active window (v1 semantics) so the standard
1467
+ * ops.sdpa path runs unmodified. */
1468
+ updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
1469
+ const D = this.#append(k, v);
1470
+ return this.#decode(this.#kv!, this.offset, D);
1471
+ }
1472
+
1473
+ /** Deferred-inverse-FWHT read path: same append, but the returned V
1474
+ * window stays in the ROTATED domain. The caller MUST un-rotate its
1475
+ * attention output with tq.unrotateValues (see SharedKv.vRotated) —
1476
+ * same decode by linearity, O(q·d log d) per step instead of
1477
+ * O(T·d log d). Opt-in per attention site; sites that don't know about
1478
+ * rotation keep calling updateAndFetch and stay correct. */
1479
+ updateAndFetchDeferredV(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
1480
+ const D = this.#append(k, v);
1481
+ return this.#decode(this.#kv!, this.offset, D, true);
1482
+ }
1483
+
1484
+ /** Shared append: grow storage, encode the new tokens, slice-write them
1485
+ * at the previous offset, advance offset. Returns head_dim. */
1486
+ #append(k: MlxArray, v: MlxArray): number {
1487
+ const [B, H, L, D] = k.shape as [number, number, number, number];
1488
+ this.#validateHeadDim(D);
1489
+ const prev = this.offset;
1490
+
1491
+ if (!this.#kv || prev + L > this.#seqLen()) {
1492
+ const nSteps = Math.floor((TurboQuantKVCache.STEP + L - 1) / TurboQuantKVCache.STEP);
1493
+ const newSteps = nSteps * TurboQuantKVCache.STEP;
1494
+ if (this.#kv && prev % TurboQuantKVCache.STEP !== 0) {
1495
+ const trimTo = (t: TurboQuantTensor): TurboQuantTensor => {
1496
+ const cut = (a: MlxArray): MlxArray => {
1497
+ const [b, h, , d] = a.shape as [number, number, number, number];
1498
+ const s = a.slice([0, 0, 0, 0], [b, h, prev, d]);
1499
+ a.dispose();
1500
+ return s;
1501
+ };
1502
+ return { kIdx: cut(t.kIdx), kScales: cut(t.kScales), kZeros: cut(t.kZeros),
1503
+ vPacked: cut(t.vPacked), vScales: cut(t.vScales) };
1504
+ };
1505
+ this.#kv = trimTo(this.#kv);
1506
+ }
1507
+ const grown = this.#alloc(B, H, newSteps, D);
1508
+ if (this.#kv) {
1509
+ const cat = (a: MlxArray, b: MlxArray): MlxArray => {
1510
+ const out = ops.concatAxis([a, b], 2);
1511
+ a.dispose();
1512
+ b.dispose();
1513
+ return out;
1514
+ };
1515
+ this.#kv = {
1516
+ kIdx: cat(this.#kv.kIdx, grown.kIdx),
1517
+ kScales: cat(this.#kv.kScales, grown.kScales),
1518
+ kZeros: cat(this.#kv.kZeros, grown.kZeros),
1519
+ vPacked: cat(this.#kv.vPacked, grown.vPacked),
1520
+ vScales: cat(this.#kv.vScales, grown.vScales),
1521
+ };
1522
+ } else {
1523
+ this.#kv = grown;
1524
+ }
1525
+ }
1526
+
1527
+ this.offset += L;
1528
+ const enc = this.#encode(k, v);
1529
+ const write = (dst: MlxArray, src: MlxArray): MlxArray => {
1530
+ const [b, h, , d] = dst.shape as [number, number, number, number];
1531
+ const u = ops.sliceUpdate(dst, src, [0, 0, prev, 0], [b, h, this.offset, d]);
1532
+ dst.dispose();
1533
+ src.dispose();
1534
+ return u;
1535
+ };
1536
+ this.#kv = {
1537
+ kIdx: write(this.#kv!.kIdx, enc.kIdx),
1538
+ kScales: write(this.#kv!.kScales, enc.kScales),
1539
+ kZeros: write(this.#kv!.kZeros, enc.kZeros),
1540
+ vPacked: write(this.#kv!.vPacked, enc.vPacked),
1541
+ vScales: write(this.#kv!.vScales, enc.vScales),
1542
+ };
1543
+
1544
+ return D;
1545
+ }
1546
+
1547
+ /** Same rule as KVCache/QuantizedKVCache.makeMask (mlx-lm cache.py:114-125):
1548
+ * windowless multi-token chunks are the string "causal" at any offset —
1549
+ * TurboQuant v1 is full-attention-only, never windowed. */
1550
+ makeMask(N: number, windowSize: number | null): Mask {
1551
+ if (N === 1) return { mode: "", arr: null };
1552
+ if (windowSize === null) return { mode: "causal", arr: null };
1553
+ if (this.offset === 0 && N <= windowSize) return { mode: "causal", arr: null };
1554
+ return { mode: "array", arr: createCausalMask(N, this.offset, windowSize) };
1555
+ }
1556
+
1557
+ /** Trimmed-to-offset arrays in documented order: kIdx, kScales, kZeros,
1558
+ * vPacked, vScales (kv-store.ts tensor-slot ordering for kind
1559
+ * "turboquant"). Empty cache → []. */
1560
+ state(): MlxArray[] {
1561
+ if (!this.#kv) return [];
1562
+ const cut = (a: MlxArray): MlxArray => {
1563
+ const [B, H, , D] = a.shape as [number, number, number, number];
1564
+ return a.slice([0, 0, 0, 0], [B, H, this.offset, D]);
1565
+ };
1566
+ return [cut(this.#kv.kIdx), cut(this.#kv.kScales), cut(this.#kv.kZeros),
1567
+ cut(this.#kv.vPacked), cut(this.#kv.vScales)];
1568
+ }
1569
+
1570
+ isTrimmable(): boolean {
1571
+ return true;
1572
+ }
1573
+
1574
+ /** Drop the last n tokens (byte-safe: groups run along head_dim, so a
1575
+ * token-axis slice never splits a group). Same front/back convention
1576
+ * as KVCache.trim: offset shrinks, storage is overwritten on next write. */
1577
+ trim(n: number): void {
1578
+ this.offset = Math.max(0, this.offset - n);
1579
+ }
1580
+
1581
+ /** Adopt persisted/converted state (takes ownership of the arrays). */
1582
+ restoreState(t: TurboQuantTensor, offset: number, headDim: number): void {
1583
+ this.dispose();
1584
+ this.#kv = t;
1585
+ this.offset = offset;
1586
+ this.#headDim = headDim;
1587
+ }
1588
+
1589
+ /** Convert an existing bf16 KVCache/RotatingKVCache's live window in one
1590
+ * shot: quantize the whole [.., :offset, :] region, preserve offset,
1591
+ * dispose the source's arrays. Mirrors KVCache.toQuantized/
1592
+ * RotatingKVCache.toQuantized's contract (source cache is consumed). */
1593
+ static fromKVCache(cache: KVCache | RotatingKVCache, kBits: number, vBits: number): TurboQuantKVCache {
1594
+ const q = new TurboQuantKVCache(kBits, vBits);
1595
+ const [k, v] = cache.keys && cache.values ? cache.temporalView() : [null, null];
1596
+ if (k && v) {
1597
+ const D = k.shape[3]!;
1598
+ q.#validateHeadDim(D);
1599
+ const enc = q.#encode(k, v);
1600
+ k.dispose();
1601
+ v.dispose();
1602
+ q.#kv = enc;
1603
+ q.#headDim = D;
1604
+ }
1605
+ q.offset = cache.offset;
1606
+ cache.dispose();
1607
+ return q;
1608
+ }
1609
+
1610
+ dispose(): void {
1611
+ if (this.#kv) disposeTurboQuant(this.#kv);
1612
+ this.#kv = null;
1613
+ this.offset = 0;
1614
+ }
1615
+ }
1616
+
1282
1617
  export const FINFO_MIN: Partial<Record<Dtype, number>> = {
1283
1618
  [Dtype.bfloat16]: -3.3895313892515355e38,
1284
1619
  [Dtype.float16]: -65504,
@@ -1338,6 +1673,14 @@ export function quantizedSdpaUnfused(
1338
1673
  for (const a of [qIdx, kIdx, qCol, kRow]) a.dispose();
1339
1674
  } else if (mask.mode === "array") {
1340
1675
  maskArr = mask.arr;
1676
+ // Batched padding masks are [B,1,L,S] (buildBatchedDecodeMask). When GQA
1677
+ // reshaped the scores to 5-D [B,KV,nRep,L,S], trailing-dim broadcasting
1678
+ // would misalign the mask's B with KV — insert the unit axis explicitly:
1679
+ // [B,1,1,L,S] broadcasts correctly over (KV, nRep).
1680
+ if (maskArr && maskArr.ndim === 4 && nRep > 1) {
1681
+ maskArr = ops.expandDims(maskArr, 1);
1682
+ ownsMask = true;
1683
+ }
1341
1684
  }
1342
1685
  if (maskArr) {
1343
1686
  const ninf = ops.scalarLike(FINFO_MIN[scores.dtype] ?? -3.4e38, scores);
@@ -1568,38 +1911,19 @@ function fusedSdpaSupported(q: MlxArray, mask: Mask, groupSize: number, bits: nu
1568
1911
  /** Quantized-cache SDPA dispatch: L > 1 (prefill/continuation) with a
1569
1912
  * supported config goes through the N-tiled fused path; decode (L = 1)
1570
1913
  * and unsupported configs stay on the stock unfused port. Exported for
1571
- * the dispatch-gate tests.
1572
- *
1573
- * MLX_BUN_FUSED_DECODE=1 (NEXT UP 1b experiment): tile decode too,
1574
- * matching optiq's wrapper, which has no L gate — its serving decode
1575
- * over quantized caches runs N tiles per step, and Phase 15 measured
1576
- * its kv-mixed decode tax @8k as ~free where ours is ~3%. Off by
1577
- * default until a cleared-machine A/B shows it pays; directional
1578
- * numbers via scripts/bench-fused-decode.ts. */
1914
+ * the dispatch-gate tests. */
1579
1915
  export function quantizedSdpa(
1580
1916
  q: MlxArray, kq: ops.QuantizedTensor, vq: ops.QuantizedTensor,
1581
1917
  scale: number, mask: Mask, groupSize: number, bits: number,
1582
1918
  ): MlxArray {
1583
- // Perf mode (Phase E): the fused decode kernel takes supported L=1
1584
- // dispatches. NOT bit-exact (online softmax) — gated against the
1585
- // frozen perf oracle; compat (MLX_BUN_PERF_KERNEL=0) stays the -O0
1586
- // reference. Never inside a compiled trace: CustomKernel has no
1587
- // output_shapes, so it cannot live in a (shapeless) closure.
1588
- if (
1589
- mask.mode === "" && scale === 1.0 && perfKernelEnabled() &&
1590
- !isCompiledTrace() && fusedDecodeKernelSupported(q, bits, groupSize)
1591
- )
1592
- return fusedDecodeSdpa(q, kq, vq, groupSize, bits);
1593
- const tile = q.shape[2]! > 1 || process.env.MLX_BUN_FUSED_DECODE === "1";
1919
+ const tile = q.shape[2]! > 1;
1594
1920
  if (tile && fusedSdpaSupported(q, mask, groupSize, bits)) {
1595
1921
  // The N-tiled loop CANNOT be traced: its tile count and slice extents
1596
1922
  // 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.
1923
+ // phase) quantized cache's newest rows are silently never attended.
1924
+ // This throw is the backstop for any compiled caller — it lands in the
1925
+ // trace's transactional fallback instead of producing silently wrong
1926
+ // attention.
1603
1927
  if (isCompiledTrace())
1604
1928
  throw new Error(
1605
1929
  "quantizedSdpaTiled inside a compiled trace: the tile loop bakes trace-time N " +