mlx-bun 0.0.10 → 0.0.12

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 (216) hide show
  1. package/README.md +107 -32
  2. package/THIRD_PARTY_LICENSES.md +10 -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/colibri-glm52-port.md +1479 -0
  10. package/docs/design/compat-cli-surface-design.md +4 -3
  11. package/docs/design/decode-speed-program.md +172 -0
  12. package/docs/design/docs-reorg-plan.md +1 -1
  13. package/docs/design/dspark-serving-program.md +371 -0
  14. package/docs/design/dspark-speculative-decoding.md +167 -5
  15. package/docs/design/faithful-l1-consolidation.md +310 -0
  16. package/docs/design/generic-model-support.md +8 -5
  17. package/docs/design/grammar-spec-batching-integration.md +301 -0
  18. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  19. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  20. package/docs/design/omlx-adoption-map.md +14 -6
  21. package/docs/design/orpo-training.md +34 -30
  22. package/docs/design/paged-kv-cache.md +214 -0
  23. package/docs/design/parallel-slots.md +82 -49
  24. package/docs/design/parity-tier-dag.md +43 -33
  25. package/docs/design/pre-colibri-stabilization.md +329 -0
  26. package/docs/design/repo-cleanup-plan.md +189 -0
  27. package/docs/design/runtime-isolation.md +19 -3
  28. package/docs/design/segmented-backward-training.md +3 -2
  29. package/docs/design/ssd-kv-cold-tier.md +130 -0
  30. package/docs/design/structured-output.md +484 -0
  31. package/docs/design/the-dreaming-master-plan.md +2 -3
  32. package/docs/design/turboquant-kv.md +235 -0
  33. package/docs/design/unified-engine-frontier-plan.md +774 -0
  34. package/docs/design/web-chat-beat-matrix.md +315 -0
  35. package/docs/design/web-chat-redesign.md +1112 -0
  36. package/docs/investigations/colibri-oracle-pin.md +1284 -0
  37. package/docs/investigations/dspark-handoff.md +182 -16
  38. package/docs/investigations/indexshare-performance-spike.md +357 -0
  39. package/docs/investigations/lab-build-journal.md +2 -2
  40. package/docs/investigations/openwiki-evaluation.md +249 -0
  41. package/docs/planning/IDEAS.md +14 -0
  42. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  43. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  44. package/docs/planning/chat-ui-vision.md +50 -0
  45. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  46. package/docs/planning/release-notes-v0.0.11.md +239 -0
  47. package/docs/planning/release-notes-v0.0.12.md +80 -0
  48. package/docs/reference/cli.md +63 -18
  49. package/docs/reference/distribution.md +37 -6
  50. package/docs/reference/embedding.md +40 -16
  51. package/docs/reference/features-matrix.md +99 -0
  52. package/docs/reference/library-api.md +69 -20
  53. package/docs/reference/memory.md +26 -6
  54. package/docs/reference/models.md +60 -3
  55. package/docs/reference/orpo-quickstart.md +3 -4
  56. package/docs/reference/server-api.md +717 -24
  57. package/docs/reference/server-config.md +265 -129
  58. package/docs/reference/training.md +24 -16
  59. package/package.json +5 -3
  60. package/src/anthropic.ts +67 -44
  61. package/src/audio/conformer.ts +647 -0
  62. package/src/audio/decode.ts +133 -0
  63. package/src/audio/features.ts +202 -0
  64. package/src/audio/transcode.ts +67 -0
  65. package/src/chat-template.ts +155 -10
  66. package/src/cli.ts +483 -113
  67. package/src/config.ts +116 -24
  68. package/src/diffusion/diffusion-generate.ts +14 -8
  69. package/src/download.ts +143 -12
  70. package/src/eval/README.md +7 -6
  71. package/src/eval/ifeval.ts +63 -120
  72. package/src/eval/kl.ts +59 -3
  73. package/src/eval/quality-db.ts +2 -2
  74. package/src/eval/runner.ts +17 -8
  75. package/src/eval/tasks/ifeval.ts +167 -31
  76. package/src/expert-io.ts +391 -0
  77. package/src/expert-offload.ts +15 -2
  78. package/src/expert-residency.ts +1134 -0
  79. package/src/expert-usage.ts +415 -0
  80. package/src/flags.ts +13 -0
  81. package/src/generate.ts +438 -35
  82. package/src/grammar.ts +585 -0
  83. package/src/hf-push.ts +1 -1
  84. package/src/hub-rest.ts +242 -0
  85. package/src/index.ts +14 -2
  86. package/src/jobs/runner.ts +37 -18
  87. package/src/kv-store.ts +586 -93
  88. package/src/lora.ts +85 -14
  89. package/src/md-modules.d.ts +42 -0
  90. package/src/media-fetch.ts +426 -0
  91. package/src/memory/rest.ts +245 -0
  92. package/src/memory/vault.ts +74 -0
  93. package/src/mlx/array.ts +149 -19
  94. package/src/mlx/autograd.ts +53 -16
  95. package/src/mlx/checkpoint.ts +46 -13
  96. package/src/mlx/custom-vjp.ts +83 -35
  97. package/src/mlx/ffi.ts +27 -3
  98. package/src/mlx/metal-kernel.ts +7 -4
  99. package/src/mlx/ops.ts +153 -23
  100. package/src/mlx/turboquant-ops.ts +831 -0
  101. package/src/mlx/turboquant-tables.ts +237 -0
  102. package/src/model/batched-mask.ts +62 -1
  103. package/src/model/batched-quant.ts +206 -0
  104. package/src/model/batched-rotating-quant.ts +346 -0
  105. package/src/model/batched-rotating.ts +46 -1
  106. package/src/model/compiled-decode.ts +4 -15
  107. package/src/model/factory.ts +103 -6
  108. package/src/model/gemma4-base.ts +395 -39
  109. package/src/model/gemma4.ts +122 -33
  110. package/src/model/generated/gemma4-12b.ts +9 -13
  111. package/src/model/generated/gemma4-26b.ts +10 -5
  112. package/src/model/generated/gemma4-e4b.ts +14 -9
  113. package/src/model/glm52-atlas.ts +511 -0
  114. package/src/model/glm52-cache.ts +706 -0
  115. package/src/model/glm52-config.ts +262 -0
  116. package/src/model/glm52-container.ts +305 -0
  117. package/src/model/glm52-coupling.ts +388 -0
  118. package/src/model/glm52-dsa.ts +602 -0
  119. package/src/model/glm52-expert-layout.ts +253 -0
  120. package/src/model/glm52-layout.ts +152 -0
  121. package/src/model/glm52-memory.ts +473 -0
  122. package/src/model/glm52-mla.ts +786 -0
  123. package/src/model/glm52-moe.ts +223 -0
  124. package/src/model/glm52-pilot.ts +537 -0
  125. package/src/model/glm52-reference.ts +445 -0
  126. package/src/model/glm52-residency.ts +557 -0
  127. package/src/model/glm52-resident-weights.ts +135 -0
  128. package/src/model/glm52-streamed-experts.ts +453 -0
  129. package/src/model/glm52-streamed-metal.ts +610 -0
  130. package/src/model/glm52-weights.ts +503 -0
  131. package/src/model/glm52.ts +1153 -0
  132. package/src/model/minicpm5.ts +58 -102
  133. package/src/model/paged-kv.ts +253 -0
  134. package/src/model/qwen3-delta.ts +76 -15
  135. package/src/model/qwen3-moe.ts +404 -0
  136. package/src/model/qwen3.ts +9 -8
  137. package/src/model/qwen3_5.ts +88 -23
  138. package/src/model/support.ts +19 -1
  139. package/src/model/universal/dense.ts +40 -5
  140. package/src/model/universal/rope.ts +36 -21
  141. package/src/native/expert_io.c +523 -0
  142. package/src/native-pack.ts +6 -4
  143. package/src/pi-web.ts +1299 -82
  144. package/src/preflight.ts +27 -8
  145. package/src/prompt-cache.ts +293 -33
  146. package/src/quantize/quantizer.ts +16 -1
  147. package/src/registry.ts +57 -2
  148. package/src/responses.ts +62 -49
  149. package/src/serve/batch-scheduler.ts +1050 -78
  150. package/src/serve/generation-gateway.ts +252 -24
  151. package/src/serve/isolate.ts +417 -0
  152. package/src/serve/lane-registry.ts +44 -0
  153. package/src/serve/session-search.ts +326 -0
  154. package/src/server.ts +1701 -175
  155. package/src/spec/assistant-source.ts +150 -0
  156. package/src/spec/deepspec-source.ts +143 -0
  157. package/src/spec/dflash-source.ts +124 -0
  158. package/src/spec/dspark/ab-stats.ts +136 -0
  159. package/src/spec/dspark/calibration.ts +112 -0
  160. package/src/spec/dspark/deepspec-module.ts +801 -0
  161. package/src/spec/dspark/generate-dflash.ts +72 -20
  162. package/src/spec/dspark/loader.ts +23 -0
  163. package/src/spec/dspark/module-dflash.ts +278 -36
  164. package/src/spec/dspark/quantize-drafter.ts +91 -0
  165. package/src/spec/generate.ts +59 -25
  166. package/src/spec/glm52-mtp-source.ts +392 -0
  167. package/src/spec/ngram-source.ts +132 -0
  168. package/src/spec/serve-loop.ts +617 -0
  169. package/src/spec/source.ts +118 -0
  170. package/src/spec/two-model.ts +171 -0
  171. package/src/ssd-cache.ts +111 -35
  172. package/src/tokenizer.ts +288 -2
  173. package/src/tool-approvals.ts +131 -0
  174. package/src/tool-call.ts +438 -22
  175. package/src/train/flash-cce.ts +2 -2
  176. package/src/train/job.ts +0 -12
  177. package/src/train/lora-params.ts +17 -6
  178. package/src/train/trainer.ts +60 -30
  179. package/src/vision/prompt.ts +217 -48
  180. package/src/web/app.html +1197 -1791
  181. package/src/web/app.js +5694 -0
  182. package/src/web/icon.svg +13 -0
  183. package/src/web/manifest.webmanifest +24 -0
  184. package/src/web/src/adapters-panel.ts +288 -0
  185. package/src/web/src/api.ts +57 -0
  186. package/src/web/src/assistant.ts +485 -0
  187. package/src/web/src/chat.ts +1050 -0
  188. package/src/web/src/composer.ts +959 -0
  189. package/src/web/src/dataset.ts +132 -0
  190. package/src/web/src/finetune.ts +267 -0
  191. package/src/web/src/hub.ts +428 -0
  192. package/src/web/src/main.ts +98 -0
  193. package/src/web/src/markdown.ts +0 -0
  194. package/src/web/src/memory-panel.ts +572 -0
  195. package/src/web/src/model-picker.ts +159 -0
  196. package/src/web/src/palette.ts +346 -0
  197. package/src/web/src/protocol.ts +62 -0
  198. package/src/web/src/quantize.ts +167 -0
  199. package/src/web/src/rag.ts +274 -0
  200. package/src/web/src/sessions.ts +469 -0
  201. package/src/web/src/shell.ts +804 -0
  202. package/src/web/src/status.ts +300 -0
  203. package/src/web/src/ui-catalog.ts +92 -0
  204. package/src/web/sw.js +71 -0
  205. package/src/web/vendor/README.md +61 -0
  206. package/src/web/vendor/hljs-LICENSE +29 -0
  207. package/src/web/vendor/hljs-theme.css +59 -0
  208. package/src/web/vendor/hljs.js +26 -0
  209. package/src/web-tools.ts +54 -2
  210. package/src/weights.ts +82 -19
  211. package/src/model/fused-decode-kernel.ts +0 -312
  212. package/src/model/fused-geglu-kernel.ts +0 -198
  213. package/src/model/fused-mlp-kernel.ts +0 -169
  214. package/src/model/fused-swiglu-kernel.ts +0 -240
  215. package/src/model/steel-linear-kernel.ts +0 -96
  216. 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
 
@@ -230,6 +234,38 @@ export interface Cache {
230
234
  dispose(): void;
231
235
  }
232
236
 
237
+ /**
238
+ * Capability contract for cache layouts that own their dynamic-row batching.
239
+ * The scheduler uses this for non-K/V state families (for example GLM's
240
+ * compressed MLA/DSA state) instead of teaching the scheduler their tensor
241
+ * layout. `mergeRows` writes into an empty cache and does not consume inputs;
242
+ * extracted rows are independent owned caches.
243
+ */
244
+ export interface BatchableCache extends Cache {
245
+ readonly batchSize: number | null;
246
+ readonly rowOffsets: readonly number[];
247
+ readonly leftPad: readonly number[];
248
+ readonly maxTokens?: number;
249
+ makeEmptyBatch(): BatchableCache;
250
+ mergeRows(rows: readonly Cache[]): void;
251
+ extractRow(row: number): Cache;
252
+ filterRows(keep: readonly number[]): void;
253
+ projectedBytes(tokens: number): number;
254
+ }
255
+
256
+ export function isBatchableCache(cache: Cache): cache is BatchableCache {
257
+ const candidate = cache as Partial<BatchableCache>;
258
+ return (
259
+ typeof candidate.makeEmptyBatch === "function" &&
260
+ typeof candidate.mergeRows === "function" &&
261
+ typeof candidate.extractRow === "function" &&
262
+ typeof candidate.filterRows === "function" &&
263
+ typeof candidate.projectedBytes === "function" &&
264
+ Array.isArray(candidate.rowOffsets) &&
265
+ Array.isArray(candidate.leftPad)
266
+ );
267
+ }
268
+
233
269
  /** How one decode step interacts with a cache under compiled decode
234
270
  * (returned by prepareDecodeStep, consumed by compiled-decode.ts):
235
271
  * - "concat": the compiled graph fetches concat(active prefix, new kv)
@@ -306,9 +342,16 @@ export class KVCache implements Cache {
306
342
 
307
343
  makeMask(N: number, windowSize: number | null): Mask {
308
344
  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 };
345
+ // Windowless multi-token chunks get the STRING "causal" at ANY offset —
346
+ // mlx-lm cache.py:114-125 create_attention_mask does exactly this, and
347
+ // mx.fast SDPA aligns the string bottom-right, i.e. the same values as
348
+ // our materialized matrix. Shipping the array instead (the old
349
+ // offset>0 branch) forced a materialize-and-add mask path: a bool
350
+ // [N, offset+N] built PER CHUNK and read by every full-attention layer
351
+ // — the e4b 16k prefill gap vs mlx-lm (2026-07-06b, hd=512 layers
352
+ // where the fallback dispatch can't block-skip an array mask).
353
+ if (windowSize === null) return { mode: "causal", arr: null };
354
+ if (this.offset === 0 && N <= windowSize) return { mode: "causal", arr: null };
312
355
  return { mode: "array", arr: createCausalMask(N, this.offset, windowSize) };
313
356
  }
314
357
 
@@ -508,14 +551,13 @@ export class QuantizedKVCache implements Cache {
508
551
 
509
552
  makeMask(N: number, windowSize: number | null): Mask {
510
553
  if (N === 1) return { mode: "", arr: null };
511
- if (this.offset === 0 && (windowSize === null || N <= windowSize))
554
+ // Same rule as KVCache.makeMask (mlx-lm cache.py:114-125): windowless
555
+ // multi-token chunks are the string "causal" at any offset. The old
556
+ // causalEquivalent escape hatch existed precisely because this case
557
+ // used to ship an array; only windowed continuations materialize now.
558
+ if (windowSize === null || (this.offset === 0 && N <= windowSize))
512
559
  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
- };
560
+ return { mode: "array", arr: createCausalMask(N, this.offset, windowSize) };
519
561
  }
520
562
 
521
563
  /** Compiled decode: growth half of updateAndFetchQuantized (L=1),
@@ -599,6 +641,21 @@ export class QuantizedKVCache implements Cache {
599
641
  this.offset = offset;
600
642
  }
601
643
 
644
+ /** Chronological (K, V) triples sliced to offset — the quantized twin of
645
+ * KVCache.temporalView (batched merge/extend/filter reads). Caller owns
646
+ * the returned views. */
647
+ temporalView(): [ops.QuantizedTensor, ops.QuantizedTensor] {
648
+ if (!this.keys || !this.values) throw new Error("cache is empty");
649
+ const cut = (t: ops.QuantizedTensor): ops.QuantizedTensor => {
650
+ const f = (a: MlxArray): MlxArray => {
651
+ const [b, h, , d] = a.shape as [number, number, number, number];
652
+ return a.slice([0, 0, 0, 0], [b, h, this.offset, d]);
653
+ };
654
+ return { packed: f(t.packed), scales: f(t.scales), biases: f(t.biases) };
655
+ };
656
+ return [cut(this.keys), cut(this.values)];
657
+ }
658
+
602
659
  dispose(): void {
603
660
  for (const a of this.state()) a.dispose();
604
661
  this.keys = this.values = null;
@@ -1181,6 +1238,25 @@ export class RotatingQuantizedKVCache implements Cache {
1181
1238
  this.ringIdx = idx;
1182
1239
  }
1183
1240
 
1241
+ /** Chronological (K, V) triples cut to the valid window — the quantized
1242
+ * twin of RotatingKVCache.temporalView (batched merge reads, Phase 3
1243
+ * milestone 2). Caller owns the returned views. */
1244
+ temporalView(): [ops.QuantizedTensor, ops.QuantizedTensor] {
1245
+ if (!this.keys || !this.values) throw new Error("cache is empty");
1246
+ const valid = Math.min(this.offset, this.maxSize);
1247
+ const cutValid = (t: ops.QuantizedTensor): ops.QuantizedTensor =>
1248
+ mapTriple(t, (a) => {
1249
+ const [B, H, , D] = a.shape as [number, number, number, number];
1250
+ return a.slice([0, 0, 0, 0], [B, H, valid, D]);
1251
+ });
1252
+ const tk = this.#temporalOrder(this.keys);
1253
+ const tv = this.#temporalOrder(this.values);
1254
+ const out: [ops.QuantizedTensor, ops.QuantizedTensor] = [cutValid(tk), cutValid(tv)];
1255
+ disposeTriple(tk);
1256
+ disposeTriple(tv);
1257
+ return out;
1258
+ }
1259
+
1184
1260
  /** Oracle: to_quantized on an already-quantized rotating cache is
1185
1261
  * idempotent. */
1186
1262
  toQuantized(): RotatingQuantizedKVCache {
@@ -1279,6 +1355,297 @@ export class RotatingQuantizedKVCache implements Cache {
1279
1355
  }
1280
1356
  }
1281
1357
 
1358
+ /** One TurboQuant-encoded (K, V) storage tuple — the 5 arrays kv-store.ts
1359
+ * and toQuantized/fromKVCache pass around. Not `ops.QuantizedTensor`
1360
+ * (mlx's affine int4/int8 scheme): this is the rotation + Lloyd-Max
1361
+ * layout (docs/design/turboquant-kv.md), asymmetric-affine for keys,
1362
+ * FWHT+Lloyd-Max for values. Field order is the kv-store.ts tensor-slot
1363
+ * contract — do not reorder without updating snapshotCache/loadKvCache. */
1364
+ export interface TurboQuantTensor {
1365
+ kIdx: MlxArray;
1366
+ kScales: MlxArray;
1367
+ kZeros: MlxArray;
1368
+ vPacked: MlxArray;
1369
+ vScales: MlxArray;
1370
+ }
1371
+
1372
+ export const disposeTurboQuant = (t: TurboQuantTensor): void => {
1373
+ t.kIdx.dispose();
1374
+ t.kScales.dispose();
1375
+ t.kZeros.dispose();
1376
+ t.vPacked.dispose();
1377
+ t.vScales.dispose();
1378
+ };
1379
+
1380
+ /** TurboQuant KV cache — v1 (docs/design/turboquant-kv.md): dequantize-
1381
+ * on-fetch. Deliberately does NOT subclass KVCache/RotatingKVCache (or
1382
+ * QuantizedKVCache) — a novel class fails every generated-file
1383
+ * `#matches()` guard and every batching `instanceof` allow-list, so it
1384
+ * gets solo-only routing and monolith fallback for free, the same way
1385
+ * QuantizedKVCache does today. Only full-attention (non-rotating)
1386
+ * layers use this in v1; sliding-window layers stay bf16.
1387
+ *
1388
+ * Storage: 5 arrays per the vllm-metal reference's `turbo_quant_encode`
1389
+ * tuple — kIdx (int8 unpacked at kBits=8, else bit-packed uint8),
1390
+ * kScales/kZeros (fp16, one pair per 32-group), vPacked (bit-packed
1391
+ * uint8, no-op at vBits=8), vScales (fp16, one per 32-group).
1392
+ * updateAndFetch quantizes ONLY the newly appended tokens (unlike
1393
+ * QuantizedKVCache's whole-buffer-toQuantized conversion path — the
1394
+ * streaming append is the whole point of a live cache) and returns the
1395
+ * DEQUANTIZED bf16 active window so ops.sdpa runs unmodified — v1 pays
1396
+ * a full-window dequant every step; the deferred-InvFWHT trick is a
1397
+ * documented non-goal until the quality gate passes. */
1398
+ export class TurboQuantKVCache implements Cache {
1399
+ static readonly STEP = 256;
1400
+ /** Set only by compiled-decode trace adapters (see Cache) — TurboQuant
1401
+ * is not a compiled-decode participant in v1 (novel class, monolith
1402
+ * fallback only), so this is always unset; captured once per step by
1403
+ * construction, never refreshed mid-step (batched-rotating-quant.ts:55-63). */
1404
+ readonly ropeOffsetArr?: MlxArray;
1405
+ #kv: TurboQuantTensor | null = null;
1406
+ offset = 0;
1407
+ /** Validated lazily on first update, once head_dim is known. */
1408
+ #headDim: number | null = null;
1409
+
1410
+ constructor(readonly kBits: number, readonly vBits: number) {}
1411
+
1412
+ get headDim(): number | null {
1413
+ return this.#headDim;
1414
+ }
1415
+
1416
+ #validateHeadDim(dim: number): void {
1417
+ if (this.#headDim !== null) return;
1418
+ const supported = [64, 128, 256, 512];
1419
+ if (dim % 32 !== 0 || !supported.includes(dim)) {
1420
+ throw new Error(
1421
+ `TurboQuantKVCache: head_dim ${dim} must be divisible by 32 and in {${supported.join(",")}}`,
1422
+ );
1423
+ }
1424
+ this.#headDim = dim;
1425
+ }
1426
+
1427
+ #packedKDim(dim: number): number {
1428
+ return tq.packedDim(dim, this.kBits);
1429
+ }
1430
+ #packedVDim(dim: number): number {
1431
+ return tq.packedDim(dim, this.vBits);
1432
+ }
1433
+ #nGroups(dim: number): number {
1434
+ return dim / tq.TURBOQUANT_BLOCK_SIZE;
1435
+ }
1436
+
1437
+ #alloc(B: number, H: number, T: number, dim: number): TurboQuantTensor {
1438
+ const kIdxDtype = this.kBits === 8 ? Dtype.int8 : Dtype.uint8;
1439
+ return {
1440
+ kIdx: ops.zeros([B, H, T, this.#packedKDim(dim)], kIdxDtype),
1441
+ kScales: ops.zeros([B, H, T, this.#nGroups(dim)], Dtype.float16),
1442
+ kZeros: ops.zeros([B, H, T, this.#nGroups(dim)], Dtype.float16),
1443
+ vPacked: ops.zeros([B, H, T, this.#packedVDim(dim)], Dtype.uint8),
1444
+ vScales: ops.zeros([B, H, T, this.#nGroups(dim)], Dtype.float16),
1445
+ };
1446
+ }
1447
+
1448
+ #seqLen(): number {
1449
+ return this.#kv ? this.#kv.kIdx.shape[2]! : 0;
1450
+ }
1451
+
1452
+ /** Encode k/v [B,H,L,D] into a fresh TurboQuantTensor (packed at rest). */
1453
+ #encode(k: MlxArray, v: MlxArray): TurboQuantTensor {
1454
+ const signed = this.kBits === 8;
1455
+ const kEnc = tq.encodeKeys(k, this.kBits, signed);
1456
+ const kIdxPacked = this.kBits < 8 ? tq.packBits(kEnc.indices, this.kBits) : kEnc.indices;
1457
+ if (kIdxPacked !== kEnc.indices) kEnc.indices.dispose();
1458
+ const vEnc = tq.encodeValues(v, this.vBits);
1459
+ const vIdxPacked = this.vBits < 8 ? tq.packBits(vEnc.indices, this.vBits) : vEnc.indices;
1460
+ if (vIdxPacked !== vEnc.indices) vEnc.indices.dispose();
1461
+ return {
1462
+ kIdx: kIdxPacked, kScales: kEnc.scales, kZeros: kEnc.zeros,
1463
+ vPacked: vIdxPacked, vScales: vEnc.scales,
1464
+ };
1465
+ }
1466
+
1467
+ /** Decode a TurboQuantTensor's active [B,H,upTo,*] window back to
1468
+ * bf16 [B,H,upTo,headDim] (k, v). Caller owns the returned arrays.
1469
+ * deferV leaves V in the rotated domain (decodeValuesRotated) — the
1470
+ * caller un-rotates its attention output instead (tq.unrotateValues). */
1471
+ #decode(t: TurboQuantTensor, upTo: number, headDim: number, deferV = false): [MlxArray, MlxArray] {
1472
+ const cut = (a: MlxArray): MlxArray => {
1473
+ const [B, H, , D] = a.shape as [number, number, number, number];
1474
+ return a.slice([0, 0, 0, 0], [B, H, upTo, D]);
1475
+ };
1476
+ const kIdxCut = cut(t.kIdx);
1477
+ const kIdxUnpacked = this.kBits < 8 ? tq.unpackBits(kIdxCut, this.kBits, headDim) : kIdxCut;
1478
+ if (kIdxUnpacked !== kIdxCut) kIdxCut.dispose();
1479
+ const kScalesCut = cut(t.kScales);
1480
+ const kZerosCut = cut(t.kZeros);
1481
+ const k = tq.decodeKeys(kIdxUnpacked, kScalesCut, kZerosCut);
1482
+ for (const a of [kIdxUnpacked, kScalesCut, kZerosCut]) a.dispose();
1483
+
1484
+ const vPackedCut = cut(t.vPacked);
1485
+ const vIdxUnpacked = this.vBits < 8 ? tq.unpackBits(vPackedCut, this.vBits, headDim) : vPackedCut;
1486
+ if (vIdxUnpacked !== vPackedCut) vPackedCut.dispose();
1487
+ const vScalesCut = cut(t.vScales);
1488
+ const v = deferV
1489
+ ? tq.decodeValuesRotated(vIdxUnpacked, vScalesCut, this.vBits)
1490
+ : tq.decodeValues(vIdxUnpacked, vScalesCut, this.vBits);
1491
+ for (const a of [vIdxUnpacked, vScalesCut]) a.dispose();
1492
+
1493
+ return [k, v];
1494
+ }
1495
+
1496
+ /** Quantize the newly-appended k/v, append into growable packed
1497
+ * storage (256-step growth, mirroring KVCache), advance offset, then
1498
+ * dequantize the WHOLE active window (v1 semantics) so the standard
1499
+ * ops.sdpa path runs unmodified. */
1500
+ updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
1501
+ const D = this.#append(k, v);
1502
+ return this.#decode(this.#kv!, this.offset, D);
1503
+ }
1504
+
1505
+ /** Deferred-inverse-FWHT read path: same append, but the returned V
1506
+ * window stays in the ROTATED domain. The caller MUST un-rotate its
1507
+ * attention output with tq.unrotateValues (see SharedKv.vRotated) —
1508
+ * same decode by linearity, O(q·d log d) per step instead of
1509
+ * O(T·d log d). Opt-in per attention site; sites that don't know about
1510
+ * rotation keep calling updateAndFetch and stay correct. */
1511
+ updateAndFetchDeferredV(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
1512
+ const D = this.#append(k, v);
1513
+ return this.#decode(this.#kv!, this.offset, D, true);
1514
+ }
1515
+
1516
+ /** Shared append: grow storage, encode the new tokens, slice-write them
1517
+ * at the previous offset, advance offset. Returns head_dim. */
1518
+ #append(k: MlxArray, v: MlxArray): number {
1519
+ const [B, H, L, D] = k.shape as [number, number, number, number];
1520
+ this.#validateHeadDim(D);
1521
+ const prev = this.offset;
1522
+
1523
+ if (!this.#kv || prev + L > this.#seqLen()) {
1524
+ const nSteps = Math.floor((TurboQuantKVCache.STEP + L - 1) / TurboQuantKVCache.STEP);
1525
+ const newSteps = nSteps * TurboQuantKVCache.STEP;
1526
+ if (this.#kv && prev % TurboQuantKVCache.STEP !== 0) {
1527
+ const trimTo = (t: TurboQuantTensor): TurboQuantTensor => {
1528
+ const cut = (a: MlxArray): MlxArray => {
1529
+ const [b, h, , d] = a.shape as [number, number, number, number];
1530
+ const s = a.slice([0, 0, 0, 0], [b, h, prev, d]);
1531
+ a.dispose();
1532
+ return s;
1533
+ };
1534
+ return { kIdx: cut(t.kIdx), kScales: cut(t.kScales), kZeros: cut(t.kZeros),
1535
+ vPacked: cut(t.vPacked), vScales: cut(t.vScales) };
1536
+ };
1537
+ this.#kv = trimTo(this.#kv);
1538
+ }
1539
+ const grown = this.#alloc(B, H, newSteps, D);
1540
+ if (this.#kv) {
1541
+ const cat = (a: MlxArray, b: MlxArray): MlxArray => {
1542
+ const out = ops.concatAxis([a, b], 2);
1543
+ a.dispose();
1544
+ b.dispose();
1545
+ return out;
1546
+ };
1547
+ this.#kv = {
1548
+ kIdx: cat(this.#kv.kIdx, grown.kIdx),
1549
+ kScales: cat(this.#kv.kScales, grown.kScales),
1550
+ kZeros: cat(this.#kv.kZeros, grown.kZeros),
1551
+ vPacked: cat(this.#kv.vPacked, grown.vPacked),
1552
+ vScales: cat(this.#kv.vScales, grown.vScales),
1553
+ };
1554
+ } else {
1555
+ this.#kv = grown;
1556
+ }
1557
+ }
1558
+
1559
+ this.offset += L;
1560
+ const enc = this.#encode(k, v);
1561
+ const write = (dst: MlxArray, src: MlxArray): MlxArray => {
1562
+ const [b, h, , d] = dst.shape as [number, number, number, number];
1563
+ const u = ops.sliceUpdate(dst, src, [0, 0, prev, 0], [b, h, this.offset, d]);
1564
+ dst.dispose();
1565
+ src.dispose();
1566
+ return u;
1567
+ };
1568
+ this.#kv = {
1569
+ kIdx: write(this.#kv!.kIdx, enc.kIdx),
1570
+ kScales: write(this.#kv!.kScales, enc.kScales),
1571
+ kZeros: write(this.#kv!.kZeros, enc.kZeros),
1572
+ vPacked: write(this.#kv!.vPacked, enc.vPacked),
1573
+ vScales: write(this.#kv!.vScales, enc.vScales),
1574
+ };
1575
+
1576
+ return D;
1577
+ }
1578
+
1579
+ /** Same rule as KVCache/QuantizedKVCache.makeMask (mlx-lm cache.py:114-125):
1580
+ * windowless multi-token chunks are the string "causal" at any offset —
1581
+ * TurboQuant v1 is full-attention-only, never windowed. */
1582
+ makeMask(N: number, windowSize: number | null): Mask {
1583
+ if (N === 1) return { mode: "", arr: null };
1584
+ if (windowSize === null) return { mode: "causal", arr: null };
1585
+ if (this.offset === 0 && N <= windowSize) return { mode: "causal", arr: null };
1586
+ return { mode: "array", arr: createCausalMask(N, this.offset, windowSize) };
1587
+ }
1588
+
1589
+ /** Trimmed-to-offset arrays in documented order: kIdx, kScales, kZeros,
1590
+ * vPacked, vScales (kv-store.ts tensor-slot ordering for kind
1591
+ * "turboquant"). Empty cache → []. */
1592
+ state(): MlxArray[] {
1593
+ if (!this.#kv) return [];
1594
+ const cut = (a: MlxArray): MlxArray => {
1595
+ const [B, H, , D] = a.shape as [number, number, number, number];
1596
+ return a.slice([0, 0, 0, 0], [B, H, this.offset, D]);
1597
+ };
1598
+ return [cut(this.#kv.kIdx), cut(this.#kv.kScales), cut(this.#kv.kZeros),
1599
+ cut(this.#kv.vPacked), cut(this.#kv.vScales)];
1600
+ }
1601
+
1602
+ isTrimmable(): boolean {
1603
+ return true;
1604
+ }
1605
+
1606
+ /** Drop the last n tokens (byte-safe: groups run along head_dim, so a
1607
+ * token-axis slice never splits a group). Same front/back convention
1608
+ * as KVCache.trim: offset shrinks, storage is overwritten on next write. */
1609
+ trim(n: number): void {
1610
+ this.offset = Math.max(0, this.offset - n);
1611
+ }
1612
+
1613
+ /** Adopt persisted/converted state (takes ownership of the arrays). */
1614
+ restoreState(t: TurboQuantTensor, offset: number, headDim: number): void {
1615
+ this.dispose();
1616
+ this.#kv = t;
1617
+ this.offset = offset;
1618
+ this.#headDim = headDim;
1619
+ }
1620
+
1621
+ /** Convert an existing bf16 KVCache/RotatingKVCache's live window in one
1622
+ * shot: quantize the whole [.., :offset, :] region, preserve offset,
1623
+ * dispose the source's arrays. Mirrors KVCache.toQuantized/
1624
+ * RotatingKVCache.toQuantized's contract (source cache is consumed). */
1625
+ static fromKVCache(cache: KVCache | RotatingKVCache, kBits: number, vBits: number): TurboQuantKVCache {
1626
+ const q = new TurboQuantKVCache(kBits, vBits);
1627
+ const [k, v] = cache.keys && cache.values ? cache.temporalView() : [null, null];
1628
+ if (k && v) {
1629
+ const D = k.shape[3]!;
1630
+ q.#validateHeadDim(D);
1631
+ const enc = q.#encode(k, v);
1632
+ k.dispose();
1633
+ v.dispose();
1634
+ q.#kv = enc;
1635
+ q.#headDim = D;
1636
+ }
1637
+ q.offset = cache.offset;
1638
+ cache.dispose();
1639
+ return q;
1640
+ }
1641
+
1642
+ dispose(): void {
1643
+ if (this.#kv) disposeTurboQuant(this.#kv);
1644
+ this.#kv = null;
1645
+ this.offset = 0;
1646
+ }
1647
+ }
1648
+
1282
1649
  export const FINFO_MIN: Partial<Record<Dtype, number>> = {
1283
1650
  [Dtype.bfloat16]: -3.3895313892515355e38,
1284
1651
  [Dtype.float16]: -65504,
@@ -1338,6 +1705,14 @@ export function quantizedSdpaUnfused(
1338
1705
  for (const a of [qIdx, kIdx, qCol, kRow]) a.dispose();
1339
1706
  } else if (mask.mode === "array") {
1340
1707
  maskArr = mask.arr;
1708
+ // Batched padding masks are [B,1,L,S] (buildBatchedDecodeMask). When GQA
1709
+ // reshaped the scores to 5-D [B,KV,nRep,L,S], trailing-dim broadcasting
1710
+ // would misalign the mask's B with KV — insert the unit axis explicitly:
1711
+ // [B,1,1,L,S] broadcasts correctly over (KV, nRep).
1712
+ if (maskArr && maskArr.ndim === 4 && nRep > 1) {
1713
+ maskArr = ops.expandDims(maskArr, 1);
1714
+ ownsMask = true;
1715
+ }
1341
1716
  }
1342
1717
  if (maskArr) {
1343
1718
  const ninf = ops.scalarLike(FINFO_MIN[scores.dtype] ?? -3.4e38, scores);
@@ -1568,38 +1943,19 @@ function fusedSdpaSupported(q: MlxArray, mask: Mask, groupSize: number, bits: nu
1568
1943
  /** Quantized-cache SDPA dispatch: L > 1 (prefill/continuation) with a
1569
1944
  * supported config goes through the N-tiled fused path; decode (L = 1)
1570
1945
  * 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. */
1946
+ * the dispatch-gate tests. */
1579
1947
  export function quantizedSdpa(
1580
1948
  q: MlxArray, kq: ops.QuantizedTensor, vq: ops.QuantizedTensor,
1581
1949
  scale: number, mask: Mask, groupSize: number, bits: number,
1582
1950
  ): 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";
1951
+ const tile = q.shape[2]! > 1;
1594
1952
  if (tile && fusedSdpaSupported(q, mask, groupSize, bits)) {
1595
1953
  // The N-tiled loop CANNOT be traced: its tile count and slice extents
1596
1954
  // 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.
1955
+ // phase) quantized cache's newest rows are silently never attended.
1956
+ // This throw is the backstop for any compiled caller — it lands in the
1957
+ // trace's transactional fallback instead of producing silently wrong
1958
+ // attention.
1603
1959
  if (isCompiledTrace())
1604
1960
  throw new Error(
1605
1961
  "quantizedSdpaTiled inside a compiled trace: the tile loop bakes trace-time N " +