mlx-bun 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/docs/README.md +4 -1
  2. package/docs/archive/investigations/openjarvis-fit.md +198 -0
  3. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  4. package/docs/design/batching.md +1321 -49
  5. package/docs/design/decode-speed-program.md +331 -1163
  6. package/docs/design/generic-model-support.md +79 -3
  7. package/docs/design/kv-cache.md +198 -54
  8. package/docs/design/speculative-decoding.md +387 -24
  9. package/docs/design/turboquant.md +123 -13
  10. package/docs/design/unified-engine-frontier-plan.md +144 -142
  11. package/docs/planning/PRODUCT_ROADMAP.md +16 -0
  12. package/docs/planning/release-notes-v0.4.0.md +45 -0
  13. package/docs/reference/benchmarks.md +2457 -6
  14. package/docs/reference/cli.md +41 -2
  15. package/docs/reference/distribution.md +16 -9
  16. package/docs/reference/environment.md +55 -5
  17. package/docs/reference/models.md +13 -4
  18. package/docs/reference/server-api.md +79 -25
  19. package/docs/reference/server-config.md +178 -84
  20. package/docs/reference/troubleshooting.md +13 -21
  21. package/package.json +1 -1
  22. package/src/backends/mlx/assistant-target.ts +46 -0
  23. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  24. package/src/backends/mlx/batch-group.ts +368 -224
  25. package/src/backends/mlx/cache-attachment.ts +67 -0
  26. package/src/backends/mlx/cache-layout.ts +31 -0
  27. package/src/backends/mlx/checkpoint-state.ts +53 -0
  28. package/src/backends/mlx/continuation-persistence.ts +106 -0
  29. package/src/backends/mlx/continuation-request.ts +43 -0
  30. package/src/backends/mlx/continuation.ts +77 -0
  31. package/src/backends/mlx/draft-target.ts +7 -22
  32. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  33. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  34. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  35. package/src/backends/mlx/gateway-binding.ts +77 -18
  36. package/src/backends/mlx/kv-codec.ts +9 -0
  37. package/src/backends/mlx/kv-maintenance.ts +133 -0
  38. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  39. package/src/backends/mlx/model-serving.ts +1 -1
  40. package/src/backends/mlx/prefill-cohort.ts +89 -0
  41. package/src/backends/mlx/prefill-rows.ts +200 -0
  42. package/src/backends/mlx/prefill.ts +1 -1
  43. package/src/backends/mlx/projected-context-rows.ts +72 -0
  44. package/src/backends/mlx/request-state-policy.ts +41 -0
  45. package/src/backends/mlx/rollback.ts +41 -3
  46. package/src/backends/mlx/row-sampling.ts +49 -0
  47. package/src/backends/mlx/serial-executor.ts +35 -66
  48. package/src/backends/mlx/speculative-group.ts +359 -0
  49. package/src/backends/mlx/speculative-round.ts +161 -0
  50. package/src/backends/mlx/state-rows.ts +104 -0
  51. package/src/backends/mlx/state-views.ts +21 -1
  52. package/src/backends/mlx/target-layout-capability.ts +35 -0
  53. package/src/backends/mlx/transitioning-kv-rows.ts +165 -0
  54. package/src/cli.ts +11 -36
  55. package/src/contracts/execution.ts +11 -0
  56. package/src/contracts/prefix-cache.ts +23 -0
  57. package/src/contracts/resources.ts +7 -0
  58. package/src/contracts/sampling.ts +23 -0
  59. package/src/contracts/scheduling.ts +18 -0
  60. package/src/engine/execution-plan.ts +18 -16
  61. package/src/engine/resources.ts +12 -1
  62. package/src/engine/scheduler.ts +10 -5
  63. package/src/fit.ts +29 -15
  64. package/src/generate.ts +77 -173
  65. package/src/inference/draft-acceptance.ts +41 -0
  66. package/src/inference/draft-output.ts +49 -0
  67. package/src/inference/rollback.ts +2 -2
  68. package/src/kv-scheme.ts +70 -12
  69. package/src/kv-store.ts +70 -16
  70. package/src/lab/paged-kv/paged-kv-rows.ts +83 -0
  71. package/src/lab/paged-kv/paged-kv.ts +25 -11
  72. package/src/mlx/array.ts +2 -1
  73. package/src/model/artifact-identity.ts +73 -0
  74. package/src/model/batched-kv.ts +235 -0
  75. package/src/model/batched-mask.ts +12 -4
  76. package/src/model/batched-quant.ts +8 -2
  77. package/src/model/batched-quantized-kv.ts +144 -0
  78. package/src/model/batched-rotating-quant.ts +76 -15
  79. package/src/model/batched-rotating-state.ts +76 -0
  80. package/src/model/batched-rotating.ts +64 -22
  81. package/src/model/batched-row-storage.ts +52 -0
  82. package/src/model/batched-ssm.ts +52 -0
  83. package/src/model/batched-turboquant-kv.ts +93 -0
  84. package/src/model/compiled-decode.ts +7 -1
  85. package/src/model/decoded-kv-donor.ts +12 -0
  86. package/src/model/delayed-quantized-kv.ts +72 -0
  87. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  88. package/src/model/delayed-turboquant-kv.ts +55 -0
  89. package/src/model/full-prefill-padding.ts +56 -0
  90. package/src/model/full-prefill-row.ts +105 -0
  91. package/src/model/gemma4-base.ts +162 -80
  92. package/src/model/gemma4.ts +34 -26
  93. package/src/model/generated/gemma4-12b.ts +4 -1
  94. package/src/model/generated/gemma4-26b.ts +4 -1
  95. package/src/model/generated/gemma4-e4b.ts +4 -1
  96. package/src/model/glm52-cache.ts +80 -4
  97. package/src/model/glm52-mla.ts +12 -6
  98. package/src/model/glm52.ts +19 -40
  99. package/src/model/kv-attention-view.ts +67 -0
  100. package/src/model/minicpm5.ts +11 -7
  101. package/src/model/qwen3-delta-state.ts +74 -0
  102. package/src/model/qwen3-delta.ts +59 -36
  103. package/src/model/qwen3_5.ts +82 -42
  104. package/src/model/rotating-kv-donor.ts +35 -0
  105. package/src/model/rotating-kv-layout.ts +262 -0
  106. package/src/model/rotating-row-transaction.ts +35 -0
  107. package/src/model/speculative-rotating-kv.ts +101 -0
  108. package/src/model/ssm-prefill-padding.ts +39 -0
  109. package/src/model/trellis-linear.ts +2 -2
  110. package/src/model/turboquant-codec.ts +83 -0
  111. package/src/model/universal/dense.ts +14 -13
  112. package/src/preflight.ts +31 -15
  113. package/src/prompt-cache.ts +42 -44
  114. package/src/sampler.ts +36 -25
  115. package/src/serve/chat-stage.ts +2 -2
  116. package/src/serve/generation-gateway.ts +39 -30
  117. package/src/serve/model-host.ts +3 -4
  118. package/src/serve/request-plan.ts +4 -3
  119. package/src/serve/request-prep.ts +2 -4
  120. package/src/serve/text-completion-stage.ts +2 -2
  121. package/src/server.ts +64 -108
  122. package/src/spec/assistant-rows.ts +92 -0
  123. package/src/spec/assistant-source.ts +28 -18
  124. package/src/spec/cache-identity.ts +22 -0
  125. package/src/spec/deepspec-rows.ts +29 -0
  126. package/src/spec/deepspec-source.ts +12 -10
  127. package/src/spec/dflash-source.ts +28 -4
  128. package/src/spec/drafter.ts +153 -142
  129. package/src/spec/dspark/deepspec-module.ts +95 -120
  130. package/src/spec/dspark/module-dflash.ts +126 -170
  131. package/src/spec/glm52-mtp-graph.ts +29 -0
  132. package/src/spec/glm52-mtp-rows.ts +132 -0
  133. package/src/spec/glm52-mtp-source.ts +53 -136
  134. package/src/spec/glm52-mtp-state.ts +33 -0
  135. package/src/spec/ngram-source.ts +92 -20
  136. package/src/spec/projected-draft-rows.ts +73 -0
  137. package/src/spec/qwen-mtp-module.ts +171 -0
  138. package/src/spec/qwen-mtp-rows.ts +219 -0
  139. package/src/spec/qwen-mtp-source.ts +81 -226
  140. package/src/spec/qwen-mtp-state.ts +30 -0
  141. package/src/spec/serve-loop.ts +70 -68
  142. package/src/spec/source.ts +80 -15
  143. package/src/spec/two-model-rows.ts +61 -0
  144. package/src/spec/two-model.ts +43 -101
  145. package/src/ssd-cache.ts +33 -21
  146. package/src/ssd-durability.ts +11 -7
  147. package/src/web/app.js +57 -57
  148. package/src/backends/mlx/memory-guard.ts +0 -40
  149. package/src/spec/prefix-state.ts +0 -45
  150. /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
@@ -7,6 +7,7 @@
7
7
  // DecoderLayer, Gemma4Model) stays in gemma4.ts and becomes the
8
8
  // per-model generated code in Phase C.
9
9
 
10
+ import { decodedKvDonorAttention } from "./decoded-kv-donor";
10
11
  import type { ModelConfig } from "../config";
11
12
  import { quantFor } from "../config";
12
13
  import type { Weights } from "../weights";
@@ -16,6 +17,9 @@ import * as ops from "../mlx/ops";
16
17
  import { quantizedMatmulRows } from "../mlx/quantized-rows";
17
18
  import { expertOffloadArray } from "../expert-offload";
18
19
  import * as tq from "../mlx/turboquant-ops";
20
+ import type { KvCodec } from "../backends/mlx/kv-codec";
21
+ import { TurboQuantCodec, disposeTurboQuant, type TurboQuantTensor } from "./turboquant-codec";
22
+ export { disposeTurboQuant, type TurboQuantTensor } from "./turboquant-codec";
19
23
  import { runtimeValue } from "../runtime-config";
20
24
 
21
25
  export type MaskMode = "" | "causal";
@@ -249,9 +253,9 @@ export class QuantizedEmbedding {
249
253
 
250
254
  /** Fetched KV handed from a donor layer to its sharers (and to the
251
255
  * speculative drafter). Owned by forwardLayers for the pass.
252
- * `offsetArr` is set only under compiled decode (trace adapters): the
253
- * RoPE offset as an array VALUE so the compiled graph replays at any
254
- * position (see src/model/compiled-decode.ts). */
256
+ * `offsetArr` is an owned pre-write position snapshot. Donor and sharing
257
+ * attention layers keep it until the fetched KV is released, even if the
258
+ * cache advances or replaces its borrowed row-position array. */
255
259
  export type SharedKv =
256
260
  | { kind: "plain"; keys: MlxArray; values: MlxArray; offset: number;
257
261
  offsetArr?: MlxArray;
@@ -260,11 +264,87 @@ export type SharedKv =
260
264
  * (tq.unrotateValues) after sdpa. Attention is linear in V, so the
261
265
  * result is the same decode, just transformed once per query row
262
266
  * instead of once per cached token. */
263
- vRotated?: boolean }
267
+ vRotated?: boolean;
268
+ restoreValues?: (output: MlxArray) => MlxArray }
264
269
  | { kind: "quant"; keys: ops.QuantizedTensor; values: ops.QuantizedTensor;
265
- offset: number; groupSize: number; bits: number; offsetArr?: MlxArray };
270
+ offset: number; groupSize: number; bits: number; offsetArr?: MlxArray }
271
+ | { kind: "view"; attention: KvAttentionView; offset: number; offsetArr?: MlxArray };
272
+
273
+ /** Retain an independent array handle for positions used across cache writes.
274
+ * The contiguous view preserves the integer values and shares their storage;
275
+ * callers release it with the fetched attention state. */
276
+ export function captureRopeOffsets(cache: Pick<Cache, "ropeOffsetArr">): MlxArray | undefined {
277
+ const offsets = cache.ropeOffsetArr;
278
+ return offsets ? ops.contiguous(offsets) : undefined;
279
+ }
280
+
281
+ /** Quantized attention consumes a numerical storage port, independent of the
282
+ * concrete layout used for row positions, retention and persistence. */
283
+ export interface QuantizedAttentionState {
284
+ readonly groupSize: number;
285
+ readonly bits: number;
286
+ updateAndFetchQuantized(k: MlxArray, v: MlxArray): [ops.QuantizedTensor, ops.QuantizedTensor];
287
+ }
288
+
289
+ /** Values may stay in the codec's rotated domain until after attention. */
290
+ export interface RotatedValueAttentionState {
291
+ /** Capture a fetched value domain independently of later row changes. */
292
+ captureValueTransform?(): (output: MlxArray) => MlxArray;
293
+ updateAndFetchDeferredV(k: MlxArray, v: MlxArray): [MlxArray, MlxArray];
294
+ }
295
+
296
+ /** A captured numerical view supports multiple queries without appending KV
297
+ * again. It owns its tensor handles independently of later cache membership or
298
+ * precision changes. Query/mask inputs are borrowed; outputs are owned. */
299
+ export interface KvAttentionView {
300
+ attend(q: MlxArray, scale: number, mask: Mask): MlxArray;
301
+ dispose(): void;
302
+ }
303
+
304
+ /** Storage appends once and hands every attention consumer the same view. */
305
+ export interface KvAttentionState {
306
+ appendAndFetch(k: MlxArray, v: MlxArray): KvAttentionView;
307
+ }
308
+
309
+ /** Owned read-only planes with validity separate from physical padding. */
310
+ export interface KvDonorRows {
311
+ readonly keys: MlxArray;
312
+ readonly values: MlxArray;
313
+ readonly offsets: readonly number[];
314
+ readonly starts: readonly number[];
315
+ readonly ends: readonly number[];
316
+ }
317
+
318
+ /** Captured Q-only attention hides plain or encoded donor storage. */
319
+ export interface KvDonorAttention extends KvAttentionView {
320
+ readonly width: number;
321
+ readonly dtype: Dtype;
322
+ readonly offsets: readonly number[];
323
+ readonly starts: readonly number[];
324
+ readonly ends: readonly number[];
325
+ }
266
326
 
267
327
  export interface Cache {
328
+ captureDonorRows?(): KvDonorRows;
329
+ captureDonorAttention?(): KvDonorAttention;
330
+ /** A preparation method commits each request's own precision boundary.
331
+ * Splitting a forward for a sibling must not move that boundary. */
332
+ readonly prefillMaintenance?: {
333
+ beginPrefill(): void;
334
+ commitPrefill(rows: readonly number[]): void;
335
+ endPrefill(): void;
336
+ };
337
+ /** Optional representation-owned affine conversion. Logical position can
338
+ * differ from the physical write head in a padded rotating layout. */
339
+ readonly affineConversion?: { readonly offset: number; toQuantized(groupSize: number, bits: number): Cache };
340
+ readonly turboConversion?: { readonly offset: number; toTurboQuantized(kBits: number, vBits: number): Cache };
341
+ readonly attentionState?: KvAttentionState;
342
+ /** Earliest prefix whose retained representation can resume generation.
343
+ * Irreversible transitions may preserve bytes while invalidating older
344
+ * precision boundaries. Reuse policy must not trim below this offset. */
345
+ minimumReusableOffset?: number;
346
+ readonly rotatedValueAttention?: RotatedValueAttentionState;
347
+ readonly quantizedAttention?: QuantizedAttentionState;
268
348
  offset: number;
269
349
  /** Stable storage identity for compatibility guards and persistence.
270
350
  * REQUIRED: every capability predicate (isPlainKvCache, isRotating*,
@@ -364,6 +444,19 @@ export interface BatchableCache extends Cache {
364
444
  projectedBytes(tokens: number): number;
365
445
  }
366
446
 
447
+ /** Padding is interpreted by cache geometry, independently of scheduling.
448
+ * Left padding initializes an empty group; lengths count input columns before
449
+ * trailing padding. Finalize before ordinary decode or publishing checkpoints. */
450
+ export interface PrefillPadding {
451
+ readonly leftPadding?: readonly number[];
452
+ readonly lengths: readonly number[];
453
+ readonly rightPadding?: readonly number[];
454
+ }
455
+ export interface PaddedPrefillCache extends Cache {
456
+ preparePrefill(padding: PrefillPadding): void;
457
+ finalizePrefill(): void;
458
+ }
459
+
367
460
  export function isBatchableCache(cache: Cache): cache is BatchableCache {
368
461
  const candidate = cache as Partial<BatchableCache>;
369
462
  return (
@@ -487,6 +580,13 @@ export class KVCache implements Cache {
487
580
  }
488
581
 
489
582
  /** Chronological (K, V) view sliced to offset (drafter donor read). */
583
+ captureDonorRows(): KvDonorRows {
584
+ const [keys, values] = this.temporalView();
585
+ const B = keys.shape[0]!, width = keys.shape[2]!;
586
+ return { keys, values, offsets: Array(B).fill(this.offset),
587
+ starts: Array(B).fill(0), ends: Array(B).fill(width) };
588
+ }
589
+
490
590
  temporalView(): [MlxArray, MlxArray] {
491
591
  if (!this.keys || !this.values) throw new Error("cache is empty");
492
592
  const [B, H, , D] = this.keys.shape as [number, number, number, number];
@@ -578,6 +678,7 @@ export class KVCache implements Cache {
578
678
  * quantization is NYI upstream; sliding layers are window-capped
579
679
  * anyway). Attention dispatches to quantizedSdpa for these. */
580
680
  export class QuantizedKVCache implements Cache {
681
+ minimumReusableOffset?: number;
581
682
  static readonly STEP = 256;
582
683
  /** Set only by compiled-decode trace adapters (see Cache). */
583
684
  readonly ropeOffsetArr?: MlxArray;
@@ -587,6 +688,8 @@ export class QuantizedKVCache implements Cache {
587
688
 
588
689
  constructor(readonly groupSize: number, readonly bits: number) {}
589
690
 
691
+ get quantizedAttention(): QuantizedAttentionState { return this; }
692
+
590
693
  signature(): string { return `kv:quant:${this.bits}:${this.groupSize}`; }
591
694
 
592
695
  bytesPerToken(): number {
@@ -774,6 +877,16 @@ export class QuantizedKVCache implements Cache {
774
877
  /** Chronological (K, V) triples sliced to offset — the quantized twin of
775
878
  * KVCache.temporalView (batched merge/extend/filter reads). Caller owns
776
879
  * the returned views. */
880
+ captureDonorAttention(): KvDonorAttention {
881
+ const [keys, values] = this.temporalView();
882
+ const B = keys.packed.shape[0]!, width = keys.packed.shape[2]!;
883
+ return { width, dtype: keys.scales.dtype, offsets: Array(B).fill(this.offset),
884
+ starts: Array(B).fill(0), ends: Array(B).fill(width),
885
+ attend: (q, scale, mask) => quantizedSdpa(q, keys, values, scale, mask, this.groupSize, this.bits),
886
+ dispose() { disposeTriple(keys); disposeTriple(values); },
887
+ };
888
+ }
889
+
777
890
  temporalView(): [ops.QuantizedTensor, ops.QuantizedTensor] {
778
891
  if (!this.keys || !this.values) throw new Error("cache is empty");
779
892
  const cut = (t: ops.QuantizedTensor): ops.QuantizedTensor => {
@@ -798,6 +911,7 @@ export class QuantizedKVCache implements Cache {
798
911
  * entries, so decode attends over at most the window. RoPE offsets use
799
912
  * the true position; masks use the buffer-clamped offset. */
800
913
  export class RotatingKVCache implements Cache {
914
+ declare minimumReusableOffset?: number;
801
915
  static readonly STEP = 256;
802
916
  /** Set only by compiled-decode trace adapters (see Cache). */
803
917
  readonly ropeOffsetArr?: MlxArray;
@@ -1065,6 +1179,13 @@ export class RotatingKVCache implements Cache {
1065
1179
 
1066
1180
  /** Chronological (K, V) view, valid length min(offset, maxSize)
1067
1181
  * (port of optiq kv_view._read_cache_temporal). */
1182
+ captureDonorRows(): KvDonorRows {
1183
+ const [keys, values] = this.temporalView();
1184
+ const B = keys.shape[0]!, width = keys.shape[2]!;
1185
+ return { keys, values, offsets: Array(B).fill(this.offset),
1186
+ starts: Array(B).fill(0), ends: Array(B).fill(width) };
1187
+ }
1188
+
1068
1189
  temporalView(): [MlxArray, MlxArray] {
1069
1190
  if (!this.keys || !this.values) throw new Error("cache is empty");
1070
1191
  const tk = this.#temporalOrder(this.keys);
@@ -1133,6 +1254,8 @@ export const disposeTriple = (t: ops.QuantizedTensor): void => {
1133
1254
  * + SDPA patches are unnecessary here: our SharedKv carries
1134
1255
  * groupSize/bits through the donor→sharer plumbing explicitly. */
1135
1256
  export class RotatingQuantizedKVCache implements Cache {
1257
+ get quantizedAttention(): QuantizedAttentionState { return this; }
1258
+ declare minimumReusableOffset?: number;
1136
1259
  static readonly STEP = 256;
1137
1260
  /** Set only by compiled-decode trace adapters (see Cache). */
1138
1261
  readonly ropeOffsetArr?: MlxArray;
@@ -1390,6 +1513,16 @@ export class RotatingQuantizedKVCache implements Cache {
1390
1513
  /** Chronological (K, V) triples cut to the valid window — the quantized
1391
1514
  * twin of RotatingKVCache.temporalView (batched merge reads, Phase 3
1392
1515
  * milestone 2). Caller owns the returned views. */
1516
+ captureDonorAttention(): KvDonorAttention {
1517
+ const [keys, values] = this.temporalView();
1518
+ const B = keys.packed.shape[0]!, width = keys.packed.shape[2]!;
1519
+ return { width, dtype: keys.scales.dtype, offsets: Array(B).fill(this.offset),
1520
+ starts: Array(B).fill(0), ends: Array(B).fill(width),
1521
+ attend: (q, scale, mask) => quantizedSdpa(q, keys, values, scale, mask, this.groupSize, this.bits),
1522
+ dispose() { disposeTriple(keys); disposeTriple(values); },
1523
+ };
1524
+ }
1525
+
1393
1526
  temporalView(): [ops.QuantizedTensor, ops.QuantizedTensor] {
1394
1527
  if (!this.keys || !this.values) throw new Error("cache is empty");
1395
1528
  const valid = Math.min(this.offset, this.maxSize);
@@ -1504,28 +1637,6 @@ export class RotatingQuantizedKVCache implements Cache {
1504
1637
  }
1505
1638
  }
1506
1639
 
1507
- /** One TurboQuant-encoded (K, V) storage tuple — the 5 arrays kv-store.ts
1508
- * and toQuantized/fromKVCache pass around. Not `ops.QuantizedTensor`
1509
- * (mlx's affine int4/int8 scheme): this is the rotation + Lloyd-Max
1510
- * layout (docs/design/turboquant.md), asymmetric-affine for keys,
1511
- * FWHT+Lloyd-Max for values. Field order is the kv-store.ts tensor-slot
1512
- * contract — do not reorder without updating snapshotCache/loadKvCache. */
1513
- export interface TurboQuantTensor {
1514
- kIdx: MlxArray;
1515
- kScales: MlxArray;
1516
- kZeros: MlxArray;
1517
- vPacked: MlxArray;
1518
- vScales: MlxArray;
1519
- }
1520
-
1521
- export const disposeTurboQuant = (t: TurboQuantTensor): void => {
1522
- t.kIdx.dispose();
1523
- t.kScales.dispose();
1524
- t.kZeros.dispose();
1525
- t.vPacked.dispose();
1526
- t.vScales.dispose();
1527
- };
1528
-
1529
1640
  /** TurboQuant KV cache — v1 (docs/design/turboquant.md): dequantize-
1530
1641
  * on-fetch. Deliberately does NOT subclass KVCache/RotatingKVCache (or
1531
1642
  * QuantizedKVCache) — a novel class fails every generated-file
@@ -1545,6 +1656,7 @@ export const disposeTurboQuant = (t: TurboQuantTensor): void => {
1545
1656
  * a full-window dequant every step; the deferred-InvFWHT trick is a
1546
1657
  * documented non-goal until the quality gate passes. */
1547
1658
  export class TurboQuantKVCache implements Cache {
1659
+ minimumReusableOffset = 0;
1548
1660
  readonly stateNeedsDispose = true;
1549
1661
  static readonly STEP = 256;
1550
1662
  /** Set only by compiled-decode trace adapters (see Cache) — TurboQuant
@@ -1557,10 +1669,22 @@ export class TurboQuantKVCache implements Cache {
1557
1669
  /** Validated lazily on first update, once head_dim is known. */
1558
1670
  #headDim: number | null = null;
1559
1671
  /** Experimental operation selection, captured once for this cache. */
1560
- readonly #fusedDecode = process.env.MLX_BUN_TURBOQUANT_FUSED_DECODE === "1";
1672
+ readonly #codec: KvCodec<TurboQuantTensor>;
1673
+
1674
+ constructor(readonly kBits: number, readonly vBits: number) {
1675
+ this.#codec = new TurboQuantCodec(kBits, vBits, process.env.MLX_BUN_TURBOQUANT_FUSED_DECODE === "1");
1676
+ }
1561
1677
 
1562
- constructor(readonly kBits: number, readonly vBits: number) {}
1678
+ captureDonorRows(): KvDonorRows {
1679
+ if (!this.#kv || this.#headDim === null) throw new Error("cache is empty");
1680
+ const [keys, values] = this.#decode(this.#kv, this.offset, this.#headDim, false);
1681
+ const B = keys.shape[0]!;
1682
+ return { keys, values, offsets: Array(B).fill(this.offset),
1683
+ starts: Array(B).fill(0), ends: Array(B).fill(this.offset) };
1684
+ }
1685
+ captureDonorAttention(): KvDonorAttention { return decodedKvDonorAttention(this.captureDonorRows()); }
1563
1686
 
1687
+ get rotatedValueAttention(): RotatedValueAttentionState { return this; }
1564
1688
  signature(): string { return `kv:turboquant:${this.kBits}:${this.vBits}`; }
1565
1689
 
1566
1690
  bytesPerToken(): number {
@@ -1612,55 +1736,9 @@ export class TurboQuantKVCache implements Cache {
1612
1736
  return this.#kv ? this.#kv.kIdx.shape[2]! : 0;
1613
1737
  }
1614
1738
 
1615
- /** Encode k/v [B,H,L,D] into a fresh TurboQuantTensor (packed at rest). */
1616
- #encode(k: MlxArray, v: MlxArray): TurboQuantTensor {
1617
- const signed = this.kBits === 8;
1618
- const kEnc = tq.encodeKeys(k, this.kBits, signed);
1619
- const kIdxPacked = this.kBits < 8 ? tq.packBits(kEnc.indices, this.kBits) : kEnc.indices;
1620
- if (kIdxPacked !== kEnc.indices) kEnc.indices.dispose();
1621
- const vEnc = tq.encodeValues(v, this.vBits);
1622
- const vIdxPacked = this.vBits < 8 ? tq.packBits(vEnc.indices, this.vBits) : vEnc.indices;
1623
- if (vIdxPacked !== vEnc.indices) vEnc.indices.dispose();
1624
- return {
1625
- kIdx: kIdxPacked, kScales: kEnc.scales, kZeros: kEnc.zeros,
1626
- vPacked: vIdxPacked, vScales: vEnc.scales,
1627
- };
1628
- }
1629
-
1630
- /** Decode a TurboQuantTensor's active [B,H,upTo,*] window back to
1631
- * bf16 [B,H,upTo,headDim] (k, v). Caller owns the returned arrays.
1632
- * deferV leaves V in the rotated domain (decodeValuesRotated) — the
1633
- * caller un-rotates its attention output instead (tq.unrotateValues). */
1739
+ #encode(k: MlxArray, v: MlxArray): TurboQuantTensor { return this.#codec.encode(k, v); }
1634
1740
  #decode(t: TurboQuantTensor, upTo: number, headDim: number, deferV = false): [MlxArray, MlxArray] {
1635
- const cut = (a: MlxArray): MlxArray => {
1636
- const [B, H, , D] = a.shape as [number, number, number, number];
1637
- return a.slice([0, 0, 0, 0], [B, H, upTo, D]);
1638
- };
1639
- if (this.#fusedDecode) {
1640
- const inputs = [cut(t.kIdx), cut(t.kScales), cut(t.kZeros), cut(t.vPacked), cut(t.vScales)] as const;
1641
- try {
1642
- const decoded = tq.tryDecodePackedKv(inputs, this.kBits, this.vBits, headDim, deferV);
1643
- if (decoded) return decoded;
1644
- } finally { for (const input of inputs) input.dispose(); }
1645
- }
1646
- const kIdxCut = cut(t.kIdx);
1647
- const kIdxUnpacked = this.kBits < 8 ? tq.unpackBits(kIdxCut, this.kBits, headDim) : kIdxCut;
1648
- if (kIdxUnpacked !== kIdxCut) kIdxCut.dispose();
1649
- const kScalesCut = cut(t.kScales);
1650
- const kZerosCut = cut(t.kZeros);
1651
- const k = tq.decodeKeys(kIdxUnpacked, kScalesCut, kZerosCut);
1652
- for (const a of [kIdxUnpacked, kScalesCut, kZerosCut]) a.dispose();
1653
-
1654
- const vPackedCut = cut(t.vPacked);
1655
- const vIdxUnpacked = this.vBits < 8 ? tq.unpackBits(vPackedCut, this.vBits, headDim) : vPackedCut;
1656
- if (vIdxUnpacked !== vPackedCut) vPackedCut.dispose();
1657
- const vScalesCut = cut(t.vScales);
1658
- const v = deferV
1659
- ? tq.decodeValuesRotated(vIdxUnpacked, vScalesCut, this.vBits)
1660
- : tq.decodeValues(vIdxUnpacked, vScalesCut, this.vBits);
1661
- for (const a of [vIdxUnpacked, vScalesCut]) a.dispose();
1662
-
1663
- return [k, v];
1741
+ return this.#codec.decode(t, upTo, headDim, deferV);
1664
1742
  }
1665
1743
 
1666
1744
  /** Quantize the newly-appended k/v, append into growable packed
@@ -1805,6 +1883,7 @@ export class TurboQuantKVCache implements Cache {
1805
1883
  q.#headDim = D;
1806
1884
  }
1807
1885
  q.offset = cache.offset;
1886
+ q.minimumReusableOffset = (cache as Cache).minimumReusableOffset ?? 0;
1808
1887
  cache.dispose();
1809
1888
  return q;
1810
1889
  }
@@ -1813,6 +1892,7 @@ export class TurboQuantKVCache implements Cache {
1813
1892
  if (this.#kv) disposeTurboQuant(this.#kv);
1814
1893
  this.#kv = null;
1815
1894
  this.offset = 0;
1895
+ this.minimumReusableOffset = 0;
1816
1896
  }
1817
1897
  }
1818
1898
 
@@ -1885,9 +1965,11 @@ export function quantizedSdpaUnfused(
1885
1965
  }
1886
1966
  }
1887
1967
  if (maskArr) {
1888
- const ninf = ops.scalarLike(FINFO_MIN[scores.dtype] ?? -3.4e38, scores);
1889
- const masked = ops.where(maskArr, scores, ninf);
1890
- ninf.dispose();
1968
+ let masked: MlxArray;
1969
+ if (maskArr.dtype === Dtype.bool) {
1970
+ using ninf = ops.scalarLike(FINFO_MIN[scores.dtype] ?? -3.4e38, scores);
1971
+ masked = ops.where(maskArr, scores, ninf);
1972
+ } else masked = ops.add(scores, maskArr);
1891
1973
  if (ownsMask) maskArr.dispose();
1892
1974
  owned.push(masked);
1893
1975
  scores = masked;
@@ -45,11 +45,11 @@ import {
45
45
  RMSNorm,
46
46
  RotatingKVCache,
47
47
  RotatingQuantizedKVCache,
48
- TurboQuantKVCache,
49
48
  quantizedSdpa,
50
49
  type Cache,
51
50
  type Mask,
52
51
  type SharedKv,
52
+ captureRopeOffsets,
53
53
  type LoraWeights,
54
54
  } from "./gemma4-base";
55
55
  import { Checkpoint } from "../mlx/checkpoint";
@@ -304,15 +304,11 @@ class Attention {
304
304
  }
305
305
 
306
306
  const offset = cache.offset;
307
- // Capture the RoPE offset array ONCE (like `offset` above): the
308
- // updateAndFetch below advances cache.offset, so re-reading
309
- // cache.ropeOffsetArr for Q *after* the write would hand K and Q
310
- // different positions. Harmless today (real caches leave it unset;
311
- // compiled-decode passes a constant trace input), but a hard
312
- // prerequisite for batched decode, where ropeOffsetArr carries per-row
313
- // positions derived from the pre-write offset and MUST be identical for
314
- // this step's K and Q.
315
- const offsetArr = cache.ropeOffsetArr;
307
+ // Own the pre-write positions through every query and shared-KV
308
+ // consumer. Advancing a row cache may replace and release its borrowed
309
+ // offset array before Q's RoPE is built. The fetched shared state owns
310
+ // this retained handle and releases it with K/V at the end of the pass.
311
+ const offsetArr = captureRopeOffsets(cache);
316
312
 
317
313
  const kNormed = this.kNorm!.forward(k);
318
314
  const kT = ops.transposeAxes(kNormed, [0, 2, 1, 3]);
@@ -328,23 +324,29 @@ class Attention {
328
324
  if (v !== k) v.dispose();
329
325
  k.dispose();
330
326
 
331
- if (cache instanceof QuantizedKVCache || cache instanceof RotatingQuantizedKVCache) {
332
- const [kq, vq] = cache.updateAndFetchQuantized(kRoped, vT);
327
+ if (cache.attentionState) {
328
+ const attention = cache.attentionState.appendAndFetch(kRoped, vT);
329
+ kRoped.dispose(); vT.dispose();
330
+ shared = { kind: "view", attention, offset, offsetArr };
331
+ } else if (cache.quantizedAttention) {
332
+ const quantized = cache.quantizedAttention;
333
+ const [kq, vq] = quantized.updateAndFetchQuantized(kRoped, vT);
333
334
  kRoped.dispose();
334
335
  vT.dispose();
335
336
  shared = {
336
337
  kind: "quant", keys: kq, values: vq, offset,
337
- groupSize: cache.groupSize, bits: cache.bits,
338
+ groupSize: quantized.groupSize, bits: quantized.bits,
338
339
  offsetArr,
339
340
  };
340
- } else if (cache instanceof TurboQuantKVCache) {
341
+ } else if (cache.rotatedValueAttention) {
341
342
  // Deferred-V read: values stay rotated; the attention output is
342
343
  // un-rotated below (shared.vRotated) — including by KV-shared
343
344
  // consumer layers, which see the flag through sharedIn.
344
- const [keys, values] = cache.updateAndFetchDeferredV(kRoped, vT);
345
+ const [keys, values] = cache.rotatedValueAttention.updateAndFetchDeferredV(kRoped, vT);
345
346
  kRoped.dispose();
346
347
  vT.dispose();
347
- shared = { kind: "plain", keys, values, offset, offsetArr, vRotated: true };
348
+ shared = { kind: "plain", keys, values, offset, offsetArr, vRotated: true,
349
+ restoreValues: cache.rotatedValueAttention.captureValueTransform?.() };
348
350
  } else {
349
351
  const [keys, values] = cache.updateAndFetch(kRoped, vT);
350
352
  kRoped.dispose();
@@ -360,7 +362,9 @@ class Attention {
360
362
 
361
363
  let attn: MlxArray;
362
364
  const ta = getTrainingAttn();
363
- if (shared.kind === "quant") {
365
+ if (shared.kind === "view") {
366
+ attn = shared.attention.attend(q, 1.0, mask);
367
+ } else if (shared.kind === "quant") {
364
368
  attn = quantizedSdpa(q, shared.keys, shared.values, 1.0, mask, shared.groupSize, shared.bits);
365
369
  } else if (
366
370
  ta === "flash" && flashSupported(q) && (mask.mode === "causal" || mask.mode === "array")
@@ -383,7 +387,7 @@ class Attention {
383
387
  if (shared.kind === "plain" && shared.vRotated) {
384
388
  // Attention is linear in V: one InvFWHT on the [B,H,L,D] output
385
389
  // replaces one per cached token (TurboQuant deferred-V).
386
- attn = disposing(attn, tqUnrotateValues(attn));
390
+ attn = disposing(attn, shared.restoreValues ? shared.restoreValues(attn) : tqUnrotateValues(attn));
387
391
  }
388
392
  const attnT = ops.transposeAxes(attn, [0, 2, 1, 3]);
389
393
  attn.dispose();
@@ -851,6 +855,13 @@ export class Gemma4Model {
851
855
  );
852
856
  }
853
857
 
858
+ /** Computed model constants must retain their constructor arithmetic when
859
+ * a restored request enters compiled decode without a prefill first. */
860
+ materializeGraphConstants(): void {
861
+ const constants = this.layers.flatMap(layer => layer.attn.ropeFreqs ? [layer.attn.ropeFreqs] : []);
862
+ if (constants.length) ops.evalAll(constants);
863
+ }
864
+
854
865
  /** ids [1, L] → final-norm hidden states [1, L, hidden]. */
855
866
  forwardHidden(ids: MlxArray, cache: Cache[]): MlxArray {
856
867
  let h = this.embed.encode(ids);
@@ -1000,13 +1011,7 @@ export class Gemma4Model {
1000
1011
  for (let i = 0; i < this.numDonors; i++) {
1001
1012
  const s = intermediates[i];
1002
1013
  if (!s) continue;
1003
- if (s.kind === "plain") {
1004
- s.keys.dispose();
1005
- s.values.dispose();
1006
- } else {
1007
- for (const t of [s.keys, s.values])
1008
- for (const a of [t.packed, t.scales, t.biases]) a.dispose();
1009
- }
1014
+ Gemma4Model.disposeSharedKv(s);
1010
1015
  }
1011
1016
  for (const m of masks.values()) m.arr?.dispose();
1012
1017
 
@@ -1056,7 +1061,10 @@ export class Gemma4Model {
1056
1061
  /** Dispose a fetched donor K/V (plain or quantized) — mirrors forwardLayers'
1057
1062
  * donor cleanup. */
1058
1063
  private static disposeSharedKv(s: SharedKv): void {
1059
- if (s.kind === "plain") {
1064
+ s.offsetArr?.dispose();
1065
+ if (s.kind === "view") {
1066
+ s.attention.dispose();
1067
+ } else if (s.kind === "plain") {
1060
1068
  s.keys.dispose();
1061
1069
  s.values.dispose();
1062
1070
  } else {
@@ -34,7 +34,10 @@ export const FINGERPRINT = "9f812d2eb461fcbe";
34
34
  export let generatedForwardUses = 0;
35
35
 
36
36
  function disposeSharedKv(s: SharedKv): void {
37
- if (s.kind === "plain") {
37
+ if (s.kind === "view") {
38
+ s.attention.dispose();
39
+ s.offsetArr?.dispose();
40
+ } else if (s.kind === "plain") {
38
41
  s.keys.dispose();
39
42
  s.values.dispose();
40
43
  } else {
@@ -34,7 +34,10 @@ export const FINGERPRINT = "c9dd67ed5a525231";
34
34
  export let generatedForwardUses = 0;
35
35
 
36
36
  function disposeSharedKv(s: SharedKv): void {
37
- if (s.kind === "plain") {
37
+ if (s.kind === "view") {
38
+ s.attention.dispose();
39
+ s.offsetArr?.dispose();
40
+ } else if (s.kind === "plain") {
38
41
  s.keys.dispose();
39
42
  s.values.dispose();
40
43
  } else {
@@ -35,7 +35,10 @@ export const FINGERPRINT = "418e9adc386ea67c";
35
35
  export let generatedForwardUses = 0;
36
36
 
37
37
  function disposeSharedKv(s: SharedKv): void {
38
- if (s.kind === "plain") {
38
+ if (s.kind === "view") {
39
+ s.attention.dispose();
40
+ s.offsetArr?.dispose();
41
+ } else if (s.kind === "plain") {
39
42
  s.keys.dispose();
40
43
  s.values.dispose();
41
44
  } else {