mlx-bun 0.0.9 → 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 (179) 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 +255 -0
  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 +98 -0
  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 +18 -2
  27. package/docs/design/ssd-kv-cold-tier.md +288 -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/decode-roofline-lookagain.md +92 -6
  35. package/docs/investigations/dspark-handoff.md +182 -16
  36. package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
  37. package/docs/investigations/lab-build-journal.md +2 -2
  38. package/docs/investigations/openwiki-evaluation.md +249 -0
  39. package/docs/planning/IDEAS.md +14 -0
  40. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  41. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  42. package/docs/planning/chat-ui-vision.md +50 -0
  43. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  44. package/docs/planning/release-notes-v0.0.10.md +57 -0
  45. package/docs/planning/release-notes-v0.0.11.md +239 -0
  46. package/docs/reference/cli.md +35 -16
  47. package/docs/reference/distribution.md +13 -6
  48. package/docs/reference/embedding.md +35 -14
  49. package/docs/reference/features-matrix.md +97 -0
  50. package/docs/reference/library-api.md +30 -9
  51. package/docs/reference/memory.md +26 -6
  52. package/docs/reference/models.md +11 -1
  53. package/docs/reference/orpo-quickstart.md +3 -4
  54. package/docs/reference/server-api.md +634 -22
  55. package/docs/reference/server-config.md +251 -119
  56. package/docs/reference/training.md +20 -16
  57. package/package.json +5 -3
  58. package/src/anthropic.ts +13 -0
  59. package/src/audio/conformer.ts +647 -0
  60. package/src/audio/decode.ts +133 -0
  61. package/src/audio/features.ts +202 -0
  62. package/src/audio/transcode.ts +67 -0
  63. package/src/cli.ts +402 -107
  64. package/src/config.ts +49 -2
  65. package/src/download.ts +40 -0
  66. package/src/eval/README.md +7 -6
  67. package/src/eval/kl.ts +59 -3
  68. package/src/eval/quality-db.ts +2 -2
  69. package/src/eval/runner.ts +3 -3
  70. package/src/expert-offload.ts +15 -2
  71. package/src/flags.ts +13 -0
  72. package/src/generate.ts +330 -16
  73. package/src/grammar.ts +585 -0
  74. package/src/hub-rest.ts +242 -0
  75. package/src/kv-store.ts +618 -122
  76. package/src/lora.ts +8 -0
  77. package/src/md-modules.d.ts +42 -0
  78. package/src/media-fetch.ts +284 -0
  79. package/src/memory/rest.ts +245 -0
  80. package/src/memory/vault.ts +74 -0
  81. package/src/mlx/array.ts +127 -19
  82. package/src/mlx/autograd.ts +2 -2
  83. package/src/mlx/ffi.ts +27 -3
  84. package/src/mlx/metal-kernel.ts +7 -4
  85. package/src/mlx/ops.ts +179 -23
  86. package/src/mlx/turboquant-ops.ts +831 -0
  87. package/src/mlx/turboquant-tables.ts +237 -0
  88. package/src/model/batched-mask.ts +62 -1
  89. package/src/model/batched-quant.ts +206 -0
  90. package/src/model/batched-rotating-quant.ts +346 -0
  91. package/src/model/batched-rotating.ts +46 -1
  92. package/src/model/compiled-decode.ts +4 -15
  93. package/src/model/factory.ts +10 -4
  94. package/src/model/gemma4-base.ts +391 -34
  95. package/src/model/gemma4.ts +122 -33
  96. package/src/model/generated/gemma4-12b.ts +9 -13
  97. package/src/model/generated/gemma4-26b.ts +10 -5
  98. package/src/model/generated/gemma4-e4b.ts +14 -9
  99. package/src/model/minicpm5.ts +58 -102
  100. package/src/model/paged-kv.ts +253 -0
  101. package/src/model/qwen3-delta.ts +123 -15
  102. package/src/model/qwen3-moe.ts +404 -0
  103. package/src/model/qwen3.ts +9 -8
  104. package/src/model/qwen3_5.ts +99 -25
  105. package/src/model/support.ts +12 -1
  106. package/src/model/universal/dense.ts +40 -5
  107. package/src/model/universal/rope.ts +36 -21
  108. package/src/pi-web.ts +1299 -82
  109. package/src/preflight.ts +27 -8
  110. package/src/prompt-cache.ts +338 -32
  111. package/src/quantize/quantizer.ts +16 -1
  112. package/src/registry.ts +61 -3
  113. package/src/serve/batch-scheduler.ts +1001 -76
  114. package/src/serve/generation-gateway.ts +292 -30
  115. package/src/serve/isolate.ts +417 -0
  116. package/src/serve/lane-registry.ts +44 -0
  117. package/src/serve/session-search.ts +326 -0
  118. package/src/server.ts +1460 -105
  119. package/src/spec/assistant-source.ts +150 -0
  120. package/src/spec/deepspec-source.ts +143 -0
  121. package/src/spec/dflash-source.ts +124 -0
  122. package/src/spec/dspark/ab-stats.ts +136 -0
  123. package/src/spec/dspark/calibration.ts +112 -0
  124. package/src/spec/dspark/deepspec-module.ts +801 -0
  125. package/src/spec/dspark/generate-dflash.ts +72 -20
  126. package/src/spec/dspark/loader.ts +23 -0
  127. package/src/spec/dspark/module-dflash.ts +278 -36
  128. package/src/spec/dspark/quantize-drafter.ts +91 -0
  129. package/src/spec/generate.ts +59 -25
  130. package/src/spec/ngram-source.ts +132 -0
  131. package/src/spec/serve-loop.ts +536 -0
  132. package/src/spec/source.ts +93 -0
  133. package/src/spec/two-model.ts +171 -0
  134. package/src/ssd-cache.ts +285 -0
  135. package/src/tokenizer.ts +288 -2
  136. package/src/tool-approvals.ts +131 -0
  137. package/src/tool-call.ts +408 -20
  138. package/src/train/flash-cce.ts +29 -26
  139. package/src/train/job.ts +0 -2
  140. package/src/train/loss.ts +53 -11
  141. package/src/train/prefix-shared.ts +30 -7
  142. package/src/train/segmented.ts +150 -46
  143. package/src/train/trainer.ts +22 -15
  144. package/src/vision/prompt.ts +217 -48
  145. package/src/web/app.html +1184 -1789
  146. package/src/web/app.js +5667 -0
  147. package/src/web/icon.svg +13 -0
  148. package/src/web/manifest.webmanifest +24 -0
  149. package/src/web/src/adapters-panel.ts +288 -0
  150. package/src/web/src/api.ts +57 -0
  151. package/src/web/src/assistant.ts +485 -0
  152. package/src/web/src/chat.ts +1050 -0
  153. package/src/web/src/composer.ts +959 -0
  154. package/src/web/src/dataset.ts +132 -0
  155. package/src/web/src/finetune.ts +267 -0
  156. package/src/web/src/hub.ts +428 -0
  157. package/src/web/src/main.ts +98 -0
  158. package/src/web/src/markdown.ts +0 -0
  159. package/src/web/src/memory-panel.ts +572 -0
  160. package/src/web/src/model-picker.ts +159 -0
  161. package/src/web/src/palette.ts +346 -0
  162. package/src/web/src/protocol.ts +62 -0
  163. package/src/web/src/quantize.ts +167 -0
  164. package/src/web/src/rag.ts +274 -0
  165. package/src/web/src/sessions.ts +469 -0
  166. package/src/web/src/shell.ts +804 -0
  167. package/src/web/src/status.ts +252 -0
  168. package/src/web/src/ui-catalog.ts +92 -0
  169. package/src/web/sw.js +71 -0
  170. package/src/web/vendor/README.md +61 -0
  171. package/src/web/vendor/hljs-LICENSE +29 -0
  172. package/src/web/vendor/hljs-theme.css +59 -0
  173. package/src/web/vendor/hljs.js +26 -0
  174. package/src/model/fused-decode-kernel.ts +0 -312
  175. package/src/model/fused-geglu-kernel.ts +0 -198
  176. package/src/model/fused-mlp-kernel.ts +0 -169
  177. package/src/model/fused-swiglu-kernel.ts +0 -240
  178. package/src/model/steel-linear-kernel.ts +0 -96
  179. 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),
@@ -590,6 +600,30 @@ export class QuantizedKVCache implements Cache {
590
600
  this.offset = Math.max(0, this.offset - n);
591
601
  }
592
602
 
603
+ /** Adopt persisted state (takes ownership of the triples' arrays) —
604
+ * the quantized twin of KVCache.restoreState (kv-store persistence). */
605
+ restoreState(keys: ops.QuantizedTensor, values: ops.QuantizedTensor, offset: number): void {
606
+ this.dispose();
607
+ this.keys = keys;
608
+ this.values = values;
609
+ this.offset = offset;
610
+ }
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
+
593
627
  dispose(): void {
594
628
  for (const a of this.state()) a.dispose();
595
629
  this.keys = this.values = null;
@@ -1162,6 +1196,35 @@ export class RotatingQuantizedKVCache implements Cache {
1162
1196
  }
1163
1197
  }
1164
1198
 
1199
+ /** Adopt persisted state (takes ownership of the triples' arrays) —
1200
+ * ring order as-laid-out, ringIdx carried with it (kv-store persistence). */
1201
+ restoreState(keys: ops.QuantizedTensor, values: ops.QuantizedTensor, offset: number, idx: number): void {
1202
+ this.dispose();
1203
+ this.keys = keys;
1204
+ this.values = values;
1205
+ this.offset = offset;
1206
+ this.ringIdx = idx;
1207
+ }
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
+
1165
1228
  /** Oracle: to_quantized on an already-quantized rotating cache is
1166
1229
  * idempotent. */
1167
1230
  toQuantized(): RotatingQuantizedKVCache {
@@ -1260,6 +1323,297 @@ export class RotatingQuantizedKVCache implements Cache {
1260
1323
  }
1261
1324
  }
1262
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
+
1263
1617
  export const FINFO_MIN: Partial<Record<Dtype, number>> = {
1264
1618
  [Dtype.bfloat16]: -3.3895313892515355e38,
1265
1619
  [Dtype.float16]: -65504,
@@ -1319,6 +1673,14 @@ export function quantizedSdpaUnfused(
1319
1673
  for (const a of [qIdx, kIdx, qCol, kRow]) a.dispose();
1320
1674
  } else if (mask.mode === "array") {
1321
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
+ }
1322
1684
  }
1323
1685
  if (maskArr) {
1324
1686
  const ninf = ops.scalarLike(FINFO_MIN[scores.dtype] ?? -3.4e38, scores);
@@ -1549,31 +1911,26 @@ function fusedSdpaSupported(q: MlxArray, mask: Mask, groupSize: number, bits: nu
1549
1911
  /** Quantized-cache SDPA dispatch: L > 1 (prefill/continuation) with a
1550
1912
  * supported config goes through the N-tiled fused path; decode (L = 1)
1551
1913
  * and unsupported configs stay on the stock unfused port. Exported for
1552
- * the dispatch-gate tests.
1553
- *
1554
- * MLX_BUN_FUSED_DECODE=1 (NEXT UP 1b experiment): tile decode too,
1555
- * matching optiq's wrapper, which has no L gate — its serving decode
1556
- * over quantized caches runs N tiles per step, and Phase 15 measured
1557
- * its kv-mixed decode tax @8k as ~free where ours is ~3%. Off by
1558
- * default until a cleared-machine A/B shows it pays; directional
1559
- * numbers via scripts/bench-fused-decode.ts. */
1914
+ * the dispatch-gate tests. */
1560
1915
  export function quantizedSdpa(
1561
1916
  q: MlxArray, kq: ops.QuantizedTensor, vq: ops.QuantizedTensor,
1562
1917
  scale: number, mask: Mask, groupSize: number, bits: number,
1563
1918
  ): MlxArray {
1564
- // Perf mode (Phase E): the fused decode kernel takes supported L=1
1565
- // dispatches. NOT bit-exact (online softmax) — gated against the
1566
- // frozen perf oracle; compat (MLX_BUN_PERF_KERNEL=0) stays the -O0
1567
- // reference. Never inside a compiled trace: CustomKernel has no
1568
- // output_shapes, so it cannot live in a (shapeless) closure.
1569
- if (
1570
- mask.mode === "" && scale === 1.0 && perfKernelEnabled() &&
1571
- !isCompiledTrace() && fusedDecodeKernelSupported(q, bits, groupSize)
1572
- )
1573
- return fusedDecodeSdpa(q, kq, vq, groupSize, bits);
1574
- const tile = q.shape[2]! > 1 || process.env.MLX_BUN_FUSED_DECODE === "1";
1575
- if (tile && fusedSdpaSupported(q, mask, groupSize, bits))
1919
+ const tile = q.shape[2]! > 1;
1920
+ if (tile && fusedSdpaSupported(q, mask, groupSize, bits)) {
1921
+ // The N-tiled loop CANNOT be traced: its tile count and slice extents
1922
+ // bake at trace-time N, so under shapeless replay a growing (concat-
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.
1927
+ if (isCompiledTrace())
1928
+ throw new Error(
1929
+ "quantizedSdpaTiled inside a compiled trace: the tile loop bakes trace-time N " +
1930
+ "(growing KV rows would be silently dropped) — run this generation uncompiled",
1931
+ );
1576
1932
  return quantizedSdpaTiled(q, kq, vq, scale, mask, groupSize, bits);
1933
+ }
1577
1934
  return quantizedSdpaUnfused(q, kq, vq, scale, mask, groupSize, bits);
1578
1935
  }
1579
1936