mlx-bun 0.3.0 → 0.5.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 (231) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +58 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/investigations/openjarvis-fit.md +198 -0
  5. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  6. package/docs/archive/planning/release-notes-v0.4.0.md +45 -0
  7. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  8. package/docs/design/batching.md +1535 -50
  9. package/docs/design/decode-speed-program.md +394 -1171
  10. package/docs/design/generic-model-support.md +195 -3
  11. package/docs/design/kv-cache.md +538 -225
  12. package/docs/design/speculative-decoding.md +552 -50
  13. package/docs/design/turboquant.md +140 -17
  14. package/docs/design/unified-engine-frontier-plan.md +259 -150
  15. package/docs/planning/PRODUCT_ROADMAP.md +21 -6
  16. package/docs/planning/public-readiness-handoff.md +244 -0
  17. package/docs/planning/public-readiness.md +149 -0
  18. package/docs/reference/benchmarks.md +3537 -1112
  19. package/docs/reference/cli.md +102 -2
  20. package/docs/reference/distribution.md +45 -13
  21. package/docs/reference/environment.md +159 -13
  22. package/docs/reference/library-api.md +18 -4
  23. package/docs/reference/memory.md +1 -1
  24. package/docs/reference/models.md +28 -7
  25. package/docs/reference/server-api.md +257 -33
  26. package/docs/reference/server-config.md +372 -151
  27. package/docs/reference/troubleshooting.md +13 -21
  28. package/package.json +2 -2
  29. package/src/audio/audiotoolbox.ts +112 -0
  30. package/src/audio/conformer.ts +8 -0
  31. package/src/audio/mic-capture.ts +98 -0
  32. package/src/audio/silero-vad.ts +379 -0
  33. package/src/audio/whisper-decode.ts +774 -0
  34. package/src/audio/whisper-fast.ts +732 -0
  35. package/src/audio/whisper-format.ts +73 -0
  36. package/src/audio/whisper-mel.ts +185 -0
  37. package/src/audio/whisper-timing.ts +274 -0
  38. package/src/audio/whisper-tokenizer.ts +239 -0
  39. package/src/audio/whisper-transcribe.ts +410 -0
  40. package/src/backends/mlx/assistant-target.ts +46 -0
  41. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  42. package/src/backends/mlx/autoregressive.ts +22 -1
  43. package/src/backends/mlx/batch-group.ts +464 -236
  44. package/src/backends/mlx/cache-attachment.ts +67 -0
  45. package/src/backends/mlx/cache-layout.ts +31 -0
  46. package/src/backends/mlx/checkpoint-state.ts +53 -0
  47. package/src/backends/mlx/continuation-persistence.ts +107 -0
  48. package/src/backends/mlx/continuation-request.ts +43 -0
  49. package/src/backends/mlx/continuation.ts +77 -0
  50. package/src/backends/mlx/draft-target.ts +7 -22
  51. package/src/backends/mlx/encoder-cache.ts +25 -0
  52. package/src/backends/mlx/fill-append.ts +21 -8
  53. package/src/backends/mlx/fill-group.ts +497 -0
  54. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  55. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  56. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  57. package/src/backends/mlx/gateway-binding.ts +122 -19
  58. package/src/backends/mlx/kv-codec.ts +9 -0
  59. package/src/backends/mlx/kv-maintenance.ts +161 -0
  60. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  61. package/src/backends/mlx/mixed-iteration.ts +62 -0
  62. package/src/backends/mlx/model-prompt.ts +46 -32
  63. package/src/backends/mlx/model-serving.ts +5 -4
  64. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  65. package/src/backends/mlx/prefill-cohort.ts +100 -0
  66. package/src/backends/mlx/prefill-policy.ts +26 -0
  67. package/src/backends/mlx/prefill-rows.ts +244 -0
  68. package/src/backends/mlx/prefill.ts +1 -1
  69. package/src/backends/mlx/projected-context-rows.ts +72 -0
  70. package/src/backends/mlx/prompt-input.ts +29 -0
  71. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  72. package/src/backends/mlx/request-state-policy.ts +54 -0
  73. package/src/backends/mlx/rollback.ts +45 -4
  74. package/src/backends/mlx/row-sampling.ts +49 -0
  75. package/src/backends/mlx/serial-executor.ts +51 -73
  76. package/src/backends/mlx/speculative-group.ts +438 -0
  77. package/src/backends/mlx/speculative-round.ts +161 -0
  78. package/src/backends/mlx/speculative.ts +24 -7
  79. package/src/backends/mlx/state-rows.ts +104 -0
  80. package/src/backends/mlx/state-views.ts +21 -1
  81. package/src/backends/mlx/target-layout-capability.ts +35 -0
  82. package/src/backends/mlx/transitioning-kv-rows.ts +173 -0
  83. package/src/cli.ts +333 -36
  84. package/src/contracts/execution.ts +21 -0
  85. package/src/contracts/object-cache.ts +8 -0
  86. package/src/contracts/pi-provider.ts +1 -1
  87. package/src/contracts/pi.ts +3 -1
  88. package/src/contracts/prefix-cache.ts +33 -0
  89. package/src/contracts/resources.ts +7 -0
  90. package/src/contracts/sampling.ts +23 -0
  91. package/src/contracts/scheduling.ts +27 -0
  92. package/src/engine/execution-plan.ts +38 -25
  93. package/src/engine/execution-tasks.ts +44 -0
  94. package/src/engine/namespaced-cache.ts +13 -0
  95. package/src/engine/resources.ts +12 -1
  96. package/src/engine/scheduler.ts +32 -7
  97. package/src/fill/fill-session.ts +14 -3
  98. package/src/fit.ts +29 -15
  99. package/src/generate.ts +105 -195
  100. package/src/grammar.ts +13 -0
  101. package/src/inference/draft-acceptance.ts +41 -0
  102. package/src/inference/draft-output.ts +49 -0
  103. package/src/inference/prefill.ts +6 -0
  104. package/src/inference/rollback.ts +2 -2
  105. package/src/kv-scheme.ts +92 -12
  106. package/src/kv-store.ts +183 -93
  107. package/src/lab/README.md +5 -1
  108. package/src/lab/paged-kv/paged-attention.ts +117 -0
  109. package/src/lab/paged-kv/paged-kv-rows.ts +111 -0
  110. package/src/lab/paged-kv/paged-kv.ts +126 -46
  111. package/src/md-modules.d.ts +5 -0
  112. package/src/mlx/array.ts +39 -1
  113. package/src/mlx/ffi.ts +19 -0
  114. package/src/mlx/normalized-argmax.ts +116 -0
  115. package/src/mlx/ops.ts +97 -10
  116. package/src/model/artifact-identity.ts +73 -0
  117. package/src/model/batched-kv.ts +235 -0
  118. package/src/model/batched-mask.ts +12 -4
  119. package/src/model/batched-quant.ts +8 -2
  120. package/src/model/batched-quantized-kv.ts +144 -0
  121. package/src/model/batched-rotating-quant.ts +76 -15
  122. package/src/model/batched-rotating-state.ts +76 -0
  123. package/src/model/batched-rotating.ts +64 -22
  124. package/src/model/batched-row-storage.ts +52 -0
  125. package/src/model/batched-ssm.ts +52 -0
  126. package/src/model/batched-turboquant-kv.ts +94 -0
  127. package/src/model/compiled-decode.ts +7 -1
  128. package/src/model/decoded-kv-donor.ts +12 -0
  129. package/src/model/delayed-quantized-kv.ts +72 -0
  130. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  131. package/src/model/delayed-turboquant-kv.ts +57 -0
  132. package/src/model/factory.ts +26 -0
  133. package/src/model/fingerprint.ts +12 -0
  134. package/src/model/full-prefill-padding.ts +56 -0
  135. package/src/model/full-prefill-row.ts +105 -0
  136. package/src/model/gemma4-base.ts +183 -82
  137. package/src/model/gemma4.ts +116 -28
  138. package/src/model/generated/gemma4-12b.ts +4 -1
  139. package/src/model/generated/gemma4-26b.ts +4 -1
  140. package/src/model/generated/gemma4-e4b.ts +4 -1
  141. package/src/model/glm52-cache.ts +80 -4
  142. package/src/model/glm52-mla.ts +12 -6
  143. package/src/model/glm52.ts +19 -40
  144. package/src/model/kv-attention-view.ts +70 -0
  145. package/src/model/minicpm5.ts +11 -7
  146. package/src/model/profile.ts +29 -4
  147. package/src/model/quantized-append-attention.ts +31 -0
  148. package/src/model/qwen3-delta-state.ts +74 -0
  149. package/src/model/qwen3-delta.ts +59 -36
  150. package/src/model/qwen3-mrope.ts +7 -9
  151. package/src/model/qwen3_5.ts +177 -75
  152. package/src/model/rotating-kv-donor.ts +35 -0
  153. package/src/model/rotating-kv-layout.ts +262 -0
  154. package/src/model/rotating-row-transaction.ts +35 -0
  155. package/src/model/speculative-rotating-kv.ts +101 -0
  156. package/src/model/ssm-prefill-padding.ts +39 -0
  157. package/src/model/support.ts +8 -0
  158. package/src/model/token-groups.ts +66 -0
  159. package/src/model/trellis-linear.ts +4 -4
  160. package/src/model/turboquant-codec.ts +89 -0
  161. package/src/model/universal/dense.ts +14 -13
  162. package/src/model/whisper.ts +435 -0
  163. package/src/native/mic_capture.swift +92 -0
  164. package/src/native-pack.ts +6 -5
  165. package/src/pi-web.ts +9 -1
  166. package/src/preflight.ts +31 -15
  167. package/src/prompt-cache.ts +306 -86
  168. package/src/runtime-config.ts +1 -5
  169. package/src/safetensors.ts +9 -2
  170. package/src/sampler.ts +37 -25
  171. package/src/serve/audio-routes.ts +323 -0
  172. package/src/serve/chat-request.ts +2 -0
  173. package/src/serve/chat-stage.ts +19 -14
  174. package/src/serve/completion-executor.ts +9 -1
  175. package/src/serve/discovery-routes.ts +17 -1
  176. package/src/serve/generated-token-history.ts +57 -0
  177. package/src/serve/generation-gateway.ts +88 -38
  178. package/src/serve/model-binding.ts +7 -1
  179. package/src/serve/model-host.ts +3 -4
  180. package/src/serve/preparation.ts +3 -3
  181. package/src/serve/prompt-response-trace.ts +14 -0
  182. package/src/serve/request-plan.ts +6 -3
  183. package/src/serve/request-prep.ts +40 -25
  184. package/src/serve/text-completion-stage.ts +2 -2
  185. package/src/serve/transcription-server.ts +64 -0
  186. package/src/serve/transcription-service.ts +502 -0
  187. package/src/server.ts +185 -261
  188. package/src/spec/assistant-rows.ts +93 -0
  189. package/src/spec/assistant-source.ts +28 -18
  190. package/src/spec/cache-identity.ts +22 -0
  191. package/src/spec/deepspec-rows.ts +29 -0
  192. package/src/spec/deepspec-source.ts +12 -10
  193. package/src/spec/dflash-source.ts +28 -4
  194. package/src/spec/drafter.ts +153 -142
  195. package/src/spec/dspark/deepspec-module.ts +95 -120
  196. package/src/spec/dspark/module-dflash.ts +126 -170
  197. package/src/spec/glm52-mtp-graph.ts +29 -0
  198. package/src/spec/glm52-mtp-rows.ts +132 -0
  199. package/src/spec/glm52-mtp-source.ts +54 -136
  200. package/src/spec/glm52-mtp-state.ts +33 -0
  201. package/src/spec/ngram-source.ts +115 -21
  202. package/src/spec/projected-draft-rows.ts +73 -0
  203. package/src/spec/qwen-mtp-module.ts +171 -0
  204. package/src/spec/qwen-mtp-rows.ts +230 -0
  205. package/src/spec/qwen-mtp-source.ts +84 -226
  206. package/src/spec/qwen-mtp-state.ts +30 -0
  207. package/src/spec/serve-loop.ts +73 -69
  208. package/src/spec/source.ts +93 -15
  209. package/src/spec/two-model-rows.ts +62 -0
  210. package/src/spec/two-model.ts +45 -103
  211. package/src/ssd-cache.ts +107 -36
  212. package/src/ssd-durability.ts +29 -27
  213. package/src/storage/host-buffer.ts +24 -0
  214. package/src/storage/kv-writer.ts +83 -0
  215. package/src/storage/kv-writer.worker.js +264 -0
  216. package/src/storage/retention-policy.ts +37 -0
  217. package/src/tiered-prompt-cache.ts +45 -0
  218. package/src/vision/embedder.ts +8 -0
  219. package/src/vision/prompt.ts +101 -65
  220. package/src/vision/qwen3vl-prompt.ts +101 -64
  221. package/src/vision/qwen3vl-tower.ts +11 -0
  222. package/src/vision/siglip.ts +8 -0
  223. package/src/web/app.html +12 -0
  224. package/src/web/app.js +217 -57
  225. package/src/web/src/chat.ts +4 -1
  226. package/src/web/src/composer.ts +3 -0
  227. package/src/web/src/voice.ts +141 -0
  228. package/src/web/sw.js +1 -1
  229. package/src/backends/mlx/memory-guard.ts +0 -40
  230. package/src/spec/prefix-state.ts +0 -45
  231. /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
@@ -7,15 +7,19 @@
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";
13
14
  import { MlxArray } from "../mlx/array";
14
- import { Dtype } from "../mlx/ffi";
15
+ import { Dtype, deviceArchitecture } from "../mlx/ffi";
15
16
  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, turboQuantFusedDecode, 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,90 @@ 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
+ /** Qualified committed spans retain the single-position reduction order. */
301
+ attend(q: MlxArray, scale: number, mask: Mask, independentPositions?: boolean): MlxArray;
302
+ dispose(): void;
303
+ }
304
+
305
+ /** Storage appends once and hands every attention consumer the same view. */
306
+ export interface KvAttentionState {
307
+ appendAndFetch(k: MlxArray, v: MlxArray): KvAttentionView;
308
+ }
309
+
310
+ /** Owned read-only planes with validity separate from physical padding. */
311
+ export interface KvDonorRows {
312
+ readonly keys: MlxArray;
313
+ readonly values: MlxArray;
314
+ readonly offsets: readonly number[];
315
+ readonly starts: readonly number[];
316
+ readonly ends: readonly number[];
317
+ }
318
+
319
+ /** Captured Q-only attention hides plain or encoded donor storage. */
320
+ export interface KvDonorAttention extends KvAttentionView {
321
+ readonly width: number;
322
+ readonly dtype: Dtype;
323
+ readonly offsets: readonly number[];
324
+ readonly starts: readonly number[];
325
+ readonly ends: readonly number[];
326
+ }
266
327
 
267
328
  export interface Cache {
329
+ /** Maximum committed positions before this state changes precision. */
330
+ maxAppendTokens?(): number;
331
+ captureDonorRows?(): KvDonorRows;
332
+ captureDonorAttention?(): KvDonorAttention;
333
+ /** A preparation method commits each request's own precision boundary.
334
+ * Splitting a forward for a sibling must not move that boundary. */
335
+ readonly prefillMaintenance?: {
336
+ beginPrefill(): void;
337
+ commitPrefill(rows: readonly number[]): void;
338
+ endPrefill(): void;
339
+ };
340
+ /** Optional representation-owned affine conversion. Logical position can
341
+ * differ from the physical write head in a padded rotating layout. */
342
+ readonly affineConversion?: { readonly offset: number; toQuantized(groupSize: number, bits: number): Cache };
343
+ readonly turboConversion?: { readonly offset: number; toTurboQuantized(kBits: number, vBits: number): Cache };
344
+ readonly attentionState?: KvAttentionState;
345
+ /** Earliest prefix whose retained representation can resume generation.
346
+ * Irreversible transitions may preserve bytes while invalidating older
347
+ * precision boundaries. Reuse policy must not trim below this offset. */
348
+ minimumReusableOffset?: number;
349
+ readonly rotatedValueAttention?: RotatedValueAttentionState;
350
+ readonly quantizedAttention?: QuantizedAttentionState;
268
351
  offset: number;
269
352
  /** Stable storage identity for compatibility guards and persistence.
270
353
  * REQUIRED: every capability predicate (isPlainKvCache, isRotating*,
@@ -364,6 +447,19 @@ export interface BatchableCache extends Cache {
364
447
  projectedBytes(tokens: number): number;
365
448
  }
366
449
 
450
+ /** Padding is interpreted by cache geometry, independently of scheduling.
451
+ * Left padding initializes an empty group; lengths count input columns before
452
+ * trailing padding. Finalize before ordinary decode or publishing checkpoints. */
453
+ export interface PrefillPadding {
454
+ readonly leftPadding?: readonly number[];
455
+ readonly lengths: readonly number[];
456
+ readonly rightPadding?: readonly number[];
457
+ }
458
+ export interface PaddedPrefillCache extends Cache {
459
+ preparePrefill(padding: PrefillPadding): void;
460
+ finalizePrefill(): void;
461
+ }
462
+
367
463
  export function isBatchableCache(cache: Cache): cache is BatchableCache {
368
464
  const candidate = cache as Partial<BatchableCache>;
369
465
  return (
@@ -487,6 +583,13 @@ export class KVCache implements Cache {
487
583
  }
488
584
 
489
585
  /** Chronological (K, V) view sliced to offset (drafter donor read). */
586
+ captureDonorRows(): KvDonorRows {
587
+ const [keys, values] = this.temporalView();
588
+ const B = keys.shape[0]!, width = keys.shape[2]!;
589
+ return { keys, values, offsets: Array(B).fill(this.offset),
590
+ starts: Array(B).fill(0), ends: Array(B).fill(width) };
591
+ }
592
+
490
593
  temporalView(): [MlxArray, MlxArray] {
491
594
  if (!this.keys || !this.values) throw new Error("cache is empty");
492
595
  const [B, H, , D] = this.keys.shape as [number, number, number, number];
@@ -578,6 +681,7 @@ export class KVCache implements Cache {
578
681
  * quantization is NYI upstream; sliding layers are window-capped
579
682
  * anyway). Attention dispatches to quantizedSdpa for these. */
580
683
  export class QuantizedKVCache implements Cache {
684
+ minimumReusableOffset?: number;
581
685
  static readonly STEP = 256;
582
686
  /** Set only by compiled-decode trace adapters (see Cache). */
583
687
  readonly ropeOffsetArr?: MlxArray;
@@ -587,6 +691,8 @@ export class QuantizedKVCache implements Cache {
587
691
 
588
692
  constructor(readonly groupSize: number, readonly bits: number) {}
589
693
 
694
+ get quantizedAttention(): QuantizedAttentionState { return this; }
695
+
590
696
  signature(): string { return `kv:quant:${this.bits}:${this.groupSize}`; }
591
697
 
592
698
  bytesPerToken(): number {
@@ -774,6 +880,16 @@ export class QuantizedKVCache implements Cache {
774
880
  /** Chronological (K, V) triples sliced to offset — the quantized twin of
775
881
  * KVCache.temporalView (batched merge/extend/filter reads). Caller owns
776
882
  * the returned views. */
883
+ captureDonorAttention(): KvDonorAttention {
884
+ const [keys, values] = this.temporalView();
885
+ const B = keys.packed.shape[0]!, width = keys.packed.shape[2]!;
886
+ return { width, dtype: keys.scales.dtype, offsets: Array(B).fill(this.offset),
887
+ starts: Array(B).fill(0), ends: Array(B).fill(width),
888
+ attend: (q, scale, mask) => quantizedSdpa(q, keys, values, scale, mask, this.groupSize, this.bits),
889
+ dispose() { disposeTriple(keys); disposeTriple(values); },
890
+ };
891
+ }
892
+
777
893
  temporalView(): [ops.QuantizedTensor, ops.QuantizedTensor] {
778
894
  if (!this.keys || !this.values) throw new Error("cache is empty");
779
895
  const cut = (t: ops.QuantizedTensor): ops.QuantizedTensor => {
@@ -798,6 +914,7 @@ export class QuantizedKVCache implements Cache {
798
914
  * entries, so decode attends over at most the window. RoPE offsets use
799
915
  * the true position; masks use the buffer-clamped offset. */
800
916
  export class RotatingKVCache implements Cache {
917
+ declare minimumReusableOffset?: number;
801
918
  static readonly STEP = 256;
802
919
  /** Set only by compiled-decode trace adapters (see Cache). */
803
920
  readonly ropeOffsetArr?: MlxArray;
@@ -1065,6 +1182,13 @@ export class RotatingKVCache implements Cache {
1065
1182
 
1066
1183
  /** Chronological (K, V) view, valid length min(offset, maxSize)
1067
1184
  * (port of optiq kv_view._read_cache_temporal). */
1185
+ captureDonorRows(): KvDonorRows {
1186
+ const [keys, values] = this.temporalView();
1187
+ const B = keys.shape[0]!, width = keys.shape[2]!;
1188
+ return { keys, values, offsets: Array(B).fill(this.offset),
1189
+ starts: Array(B).fill(0), ends: Array(B).fill(width) };
1190
+ }
1191
+
1068
1192
  temporalView(): [MlxArray, MlxArray] {
1069
1193
  if (!this.keys || !this.values) throw new Error("cache is empty");
1070
1194
  const tk = this.#temporalOrder(this.keys);
@@ -1133,6 +1257,8 @@ export const disposeTriple = (t: ops.QuantizedTensor): void => {
1133
1257
  * + SDPA patches are unnecessary here: our SharedKv carries
1134
1258
  * groupSize/bits through the donor→sharer plumbing explicitly. */
1135
1259
  export class RotatingQuantizedKVCache implements Cache {
1260
+ get quantizedAttention(): QuantizedAttentionState { return this; }
1261
+ declare minimumReusableOffset?: number;
1136
1262
  static readonly STEP = 256;
1137
1263
  /** Set only by compiled-decode trace adapters (see Cache). */
1138
1264
  readonly ropeOffsetArr?: MlxArray;
@@ -1390,6 +1516,16 @@ export class RotatingQuantizedKVCache implements Cache {
1390
1516
  /** Chronological (K, V) triples cut to the valid window — the quantized
1391
1517
  * twin of RotatingKVCache.temporalView (batched merge reads, Phase 3
1392
1518
  * milestone 2). Caller owns the returned views. */
1519
+ captureDonorAttention(): KvDonorAttention {
1520
+ const [keys, values] = this.temporalView();
1521
+ const B = keys.packed.shape[0]!, width = keys.packed.shape[2]!;
1522
+ return { width, dtype: keys.scales.dtype, offsets: Array(B).fill(this.offset),
1523
+ starts: Array(B).fill(0), ends: Array(B).fill(width),
1524
+ attend: (q, scale, mask) => quantizedSdpa(q, keys, values, scale, mask, this.groupSize, this.bits),
1525
+ dispose() { disposeTriple(keys); disposeTriple(values); },
1526
+ };
1527
+ }
1528
+
1393
1529
  temporalView(): [ops.QuantizedTensor, ops.QuantizedTensor] {
1394
1530
  if (!this.keys || !this.values) throw new Error("cache is empty");
1395
1531
  const valid = Math.min(this.offset, this.maxSize);
@@ -1504,28 +1640,6 @@ export class RotatingQuantizedKVCache implements Cache {
1504
1640
  }
1505
1641
  }
1506
1642
 
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
1643
  /** TurboQuant KV cache — v1 (docs/design/turboquant.md): dequantize-
1530
1644
  * on-fetch. Deliberately does NOT subclass KVCache/RotatingKVCache (or
1531
1645
  * QuantizedKVCache) — a novel class fails every generated-file
@@ -1545,6 +1659,7 @@ export const disposeTurboQuant = (t: TurboQuantTensor): void => {
1545
1659
  * a full-window dequant every step; the deferred-InvFWHT trick is a
1546
1660
  * documented non-goal until the quality gate passes. */
1547
1661
  export class TurboQuantKVCache implements Cache {
1662
+ minimumReusableOffset = 0;
1548
1663
  readonly stateNeedsDispose = true;
1549
1664
  static readonly STEP = 256;
1550
1665
  /** Set only by compiled-decode trace adapters (see Cache) — TurboQuant
@@ -1557,10 +1672,23 @@ export class TurboQuantKVCache implements Cache {
1557
1672
  /** Validated lazily on first update, once head_dim is known. */
1558
1673
  #headDim: number | null = null;
1559
1674
  /** Experimental operation selection, captured once for this cache. */
1560
- readonly #fusedDecode = process.env.MLX_BUN_TURBOQUANT_FUSED_DECODE === "1";
1675
+ readonly #codec: KvCodec<TurboQuantTensor>;
1561
1676
 
1562
- constructor(readonly kBits: number, readonly vBits: number) {}
1677
+ constructor(readonly kBits: number, readonly vBits: number,
1678
+ readonly fusedDecode = turboQuantFusedDecode()) {
1679
+ this.#codec = new TurboQuantCodec(kBits, vBits, fusedDecode);
1680
+ }
1681
+
1682
+ captureDonorRows(): KvDonorRows {
1683
+ if (!this.#kv || this.#headDim === null) throw new Error("cache is empty");
1684
+ const [keys, values] = this.#decode(this.#kv, this.offset, this.#headDim, false);
1685
+ const B = keys.shape[0]!;
1686
+ return { keys, values, offsets: Array(B).fill(this.offset),
1687
+ starts: Array(B).fill(0), ends: Array(B).fill(this.offset) };
1688
+ }
1689
+ captureDonorAttention(): KvDonorAttention { return decodedKvDonorAttention(this.captureDonorRows()); }
1563
1690
 
1691
+ get rotatedValueAttention(): RotatedValueAttentionState { return this; }
1564
1692
  signature(): string { return `kv:turboquant:${this.kBits}:${this.vBits}`; }
1565
1693
 
1566
1694
  bytesPerToken(): number {
@@ -1612,55 +1740,9 @@ export class TurboQuantKVCache implements Cache {
1612
1740
  return this.#kv ? this.#kv.kIdx.shape[2]! : 0;
1613
1741
  }
1614
1742
 
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). */
1743
+ #encode(k: MlxArray, v: MlxArray): TurboQuantTensor { return this.#codec.encode(k, v); }
1634
1744
  #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];
1745
+ return this.#codec.decode(t, upTo, headDim, deferV);
1664
1746
  }
1665
1747
 
1666
1748
  /** Quantize the newly-appended k/v, append into growable packed
@@ -1805,6 +1887,7 @@ export class TurboQuantKVCache implements Cache {
1805
1887
  q.#headDim = D;
1806
1888
  }
1807
1889
  q.offset = cache.offset;
1890
+ q.minimumReusableOffset = (cache as Cache).minimumReusableOffset ?? 0;
1808
1891
  cache.dispose();
1809
1892
  return q;
1810
1893
  }
@@ -1813,6 +1896,7 @@ export class TurboQuantKVCache implements Cache {
1813
1896
  if (this.#kv) disposeTurboQuant(this.#kv);
1814
1897
  this.#kv = null;
1815
1898
  this.offset = 0;
1899
+ this.minimumReusableOffset = 0;
1816
1900
  }
1817
1901
  }
1818
1902
 
@@ -1860,8 +1944,23 @@ export function quantizedSdpaUnfused(
1860
1944
  vT = expand(vq);
1861
1945
  }
1862
1946
 
1863
- let scores = ops.quantizedMatmulQT(queries, kT, true, groupSize, bits);
1947
+ // M4 Pro's native key matvec retains exact arithmetic when three GQA
1948
+ // heads share a batch. Restore score geometry before softmax/value work.
1949
+ const groupHeads = B <= 2 && H === 24 && KV === 4 && L === 3 && D === 256 &&
1950
+ N >= 8192 && groupSize === 64 && bits === 4 &&
1951
+ (q.dtype === Dtype.bfloat16 || q.dtype === Dtype.float32) &&
1952
+ deviceArchitecture() === "applegpu_g16s";
1953
+ let keyQueries = queries;
1954
+ if (groupHeads) {
1955
+ keyQueries = ops.reshape(queries, [B, KV, 2, 9, D]);
1956
+ owned.push(keyQueries);
1957
+ }
1958
+ let scores = ops.quantizedMatmulQT(keyQueries, kT, true, groupSize, bits);
1864
1959
  owned.push(scores);
1960
+ if (groupHeads) {
1961
+ scores = ops.reshape(scores, [B, KV, nRep, L, N]);
1962
+ owned.push(scores);
1963
+ }
1865
1964
 
1866
1965
  let maskArr: MlxArray | null = null;
1867
1966
  let ownsMask = false;
@@ -1885,9 +1984,11 @@ export function quantizedSdpaUnfused(
1885
1984
  }
1886
1985
  }
1887
1986
  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();
1987
+ let masked: MlxArray;
1988
+ if (maskArr.dtype === Dtype.bool) {
1989
+ using ninf = ops.scalarLike(FINFO_MIN[scores.dtype] ?? -3.4e38, scores);
1990
+ masked = ops.where(maskArr, scores, ninf);
1991
+ } else masked = ops.add(scores, maskArr);
1891
1992
  if (ownsMask) maskArr.dispose();
1892
1993
  owned.push(masked);
1893
1994
  scores = masked;
@@ -45,18 +45,19 @@ 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
+ import { mapPackedTokens, type TokenGroup } from "./token-groups";
55
56
  import { Checkpoint } from "../mlx/checkpoint";
56
57
  import { flashAttention, getTrainingAttn, flashSupported } from "./flash-attention";
57
58
  import { unrotateValues as tqUnrotateValues } from "../mlx/turboquant-ops";
58
59
  import { CompiledFunction } from "../mlx/compile";
59
- import { flagOn } from "../runtime-config";
60
+ import { runtimeFlag } from "../runtime-config";
60
61
 
61
62
  /** Verbatim port of mlx_lm/models/gemma4_text.py:
62
63
  * `@partial(mx.compile, shapeless=True) def geglu(gate, x): return nn.gelu_approx(gate) * x`
@@ -77,7 +78,7 @@ import { flagOn } from "../runtime-config";
77
78
  * ~9-op path. `MLX_BUN_COMPILED_GEGLU=0` (--compiled-activations off) selects the
78
79
  * uncompiled composition — same L1 parity, slower (the A/B opt-out). */
79
80
  export function compiledGegluActive(): boolean {
80
- return flagOn("MLX_BUN_COMPILED_GEGLU", true);
81
+ return runtimeFlag("MLX_BUN_COMPILED_GEGLU", true);
81
82
  }
82
83
 
83
84
  let _gegluClosure: CompiledFunction | null = null;
@@ -304,15 +305,11 @@ class Attention {
304
305
  }
305
306
 
306
307
  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;
308
+ // Own the pre-write positions through every query and shared-KV
309
+ // consumer. Advancing a row cache may replace and release its borrowed
310
+ // offset array before Q's RoPE is built. The fetched shared state owns
311
+ // this retained handle and releases it with K/V at the end of the pass.
312
+ const offsetArr = captureRopeOffsets(cache);
316
313
 
317
314
  const kNormed = this.kNorm!.forward(k);
318
315
  const kT = ops.transposeAxes(kNormed, [0, 2, 1, 3]);
@@ -328,23 +325,29 @@ class Attention {
328
325
  if (v !== k) v.dispose();
329
326
  k.dispose();
330
327
 
331
- if (cache instanceof QuantizedKVCache || cache instanceof RotatingQuantizedKVCache) {
332
- const [kq, vq] = cache.updateAndFetchQuantized(kRoped, vT);
328
+ if (cache.attentionState) {
329
+ const attention = cache.attentionState.appendAndFetch(kRoped, vT);
330
+ kRoped.dispose(); vT.dispose();
331
+ shared = { kind: "view", attention, offset, offsetArr };
332
+ } else if (cache.quantizedAttention) {
333
+ const quantized = cache.quantizedAttention;
334
+ const [kq, vq] = quantized.updateAndFetchQuantized(kRoped, vT);
333
335
  kRoped.dispose();
334
336
  vT.dispose();
335
337
  shared = {
336
338
  kind: "quant", keys: kq, values: vq, offset,
337
- groupSize: cache.groupSize, bits: cache.bits,
339
+ groupSize: quantized.groupSize, bits: quantized.bits,
338
340
  offsetArr,
339
341
  };
340
- } else if (cache instanceof TurboQuantKVCache) {
342
+ } else if (cache.rotatedValueAttention) {
341
343
  // Deferred-V read: values stay rotated; the attention output is
342
344
  // un-rotated below (shared.vRotated) — including by KV-shared
343
345
  // consumer layers, which see the flag through sharedIn.
344
- const [keys, values] = cache.updateAndFetchDeferredV(kRoped, vT);
346
+ const [keys, values] = cache.rotatedValueAttention.updateAndFetchDeferredV(kRoped, vT);
345
347
  kRoped.dispose();
346
348
  vT.dispose();
347
- shared = { kind: "plain", keys, values, offset, offsetArr, vRotated: true };
349
+ shared = { kind: "plain", keys, values, offset, offsetArr, vRotated: true,
350
+ restoreValues: cache.rotatedValueAttention.captureValueTransform?.() };
348
351
  } else {
349
352
  const [keys, values] = cache.updateAndFetch(kRoped, vT);
350
353
  kRoped.dispose();
@@ -360,7 +363,9 @@ class Attention {
360
363
 
361
364
  let attn: MlxArray;
362
365
  const ta = getTrainingAttn();
363
- if (shared.kind === "quant") {
366
+ if (shared.kind === "view") {
367
+ attn = shared.attention.attend(q, 1.0, mask);
368
+ } else if (shared.kind === "quant") {
364
369
  attn = quantizedSdpa(q, shared.keys, shared.values, 1.0, mask, shared.groupSize, shared.bits);
365
370
  } else if (
366
371
  ta === "flash" && flashSupported(q) && (mask.mode === "causal" || mask.mode === "array")
@@ -383,7 +388,7 @@ class Attention {
383
388
  if (shared.kind === "plain" && shared.vRotated) {
384
389
  // Attention is linear in V: one InvFWHT on the [B,H,L,D] output
385
390
  // replaces one per cached token (TurboQuant deferred-V).
386
- attn = disposing(attn, tqUnrotateValues(attn));
391
+ attn = disposing(attn, shared.restoreValues ? shared.restoreValues(attn) : tqUnrotateValues(attn));
387
392
  }
388
393
  const attnT = ops.transposeAxes(attn, [0, 2, 1, 3]);
389
394
  attn.dispose();
@@ -851,6 +856,13 @@ export class Gemma4Model {
851
856
  );
852
857
  }
853
858
 
859
+ /** Computed model constants must retain their constructor arithmetic when
860
+ * a restored request enters compiled decode without a prefill first. */
861
+ materializeGraphConstants(): void {
862
+ const constants = this.layers.flatMap(layer => layer.attn.ropeFreqs ? [layer.attn.ropeFreqs] : []);
863
+ if (constants.length) ops.evalAll(constants);
864
+ }
865
+
854
866
  /** ids [1, L] → final-norm hidden states [1, L, hidden]. */
855
867
  forwardHidden(ids: MlxArray, cache: Cache[]): MlxArray {
856
868
  let h = this.embed.encode(ids);
@@ -1000,13 +1012,7 @@ export class Gemma4Model {
1000
1012
  for (let i = 0; i < this.numDonors; i++) {
1001
1013
  const s = intermediates[i];
1002
1014
  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
- }
1015
+ Gemma4Model.disposeSharedKv(s);
1010
1016
  }
1011
1017
  for (const m of masks.values()) m.arr?.dispose();
1012
1018
 
@@ -1015,6 +1021,85 @@ export class Gemma4Model {
1015
1021
  return h;
1016
1022
  }
1017
1023
 
1024
+ /** Mixed token execution: attention retains each group's cache, positions and
1025
+ * SDPA shape; feed-forward work packs only real tokens. Kernel selection for
1026
+ * packed matmuls can differ from a solo GEMV, so this is an explicit Lab port
1027
+ * until its matching packed oracle and serving comparison are accepted. */
1028
+ forwardHiddenMixed(work: readonly TokenGroup[]): MlxArray[] {
1029
+ if (work.length === 1 && !work[0]!.captureLayer) return [this.forwardHidden(work[0]!.ids, work[0]!.cache)];
1030
+ const groups: Array<{ ids: MlxArray; cache: Cache[]; h: MlxArray;
1031
+ masks: Map<string, Mask>; perLayer: MlxArray | null; intermediates: (SharedKv | null)[] }> = [];
1032
+ const results: MlxArray[] = [];
1033
+ const packedMlp = runtimeFlag("MLX_BUN_MIXED_PACKED_MLP", true) && !work.some(group => group.preserveTokenGeometry);
1034
+ try {
1035
+ for (const { ids, cache } of work) {
1036
+ const group = { ids, cache, h: this.embed.encode(ids), masks: new Map<string, Mask>(),
1037
+ perLayer: null as MlxArray | null, intermediates: Array<SharedKv | null>(this.layers.length).fill(null) };
1038
+ groups.push(group);
1039
+ group.h = disposing(group.h, ops.mulScalar(group.h, this.embedScale));
1040
+ for (let i = 0; i < this.numDonors; i++) {
1041
+ const type = this.layers[i]!.layerType;
1042
+ if (!group.masks.has(type)) group.masks.set(type,
1043
+ cache[i]!.makeMask(ids.shape[1]!, type === "sliding_attention" ? this.windowSize : null));
1044
+ }
1045
+ if (this.perLayerWidth > 0) group.perLayer = this.computePerLayerInputs(ids, group.h);
1046
+ }
1047
+ for (let i = 0; i < this.layers.length; i++) {
1048
+ const layer = this.layers[i]!, ci = this.cacheIndex[i]!;
1049
+ const mids: MlxArray[] = [], inputs: MlxArray[] = [];
1050
+ try {
1051
+ for (const group of groups) {
1052
+ const sharedIn = ci === -1 ? group.intermediates[this.previousKvs[i]!]! : null;
1053
+ const { h, shared } = layer.forwardAttn(group.h, group.masks.get(layer.layerType)!,
1054
+ ci === -1 ? null : group.cache[ci]!, sharedIn);
1055
+ mids.push(h); group.intermediates[i] = shared;
1056
+ if (group.perLayer) {
1057
+ const [B, L] = group.ids.shape;
1058
+ using part = group.perLayer.slice([0, 0, i, 0], [B!, L!, i + 1, this.perLayerWidth]);
1059
+ inputs.push(ops.reshape(part, [B!, L!, this.perLayerWidth]));
1060
+ }
1061
+ }
1062
+ // Per-layer inputs use the same token order as the hidden states.
1063
+ let packedInput: MlxArray | null = null;
1064
+ if (packedMlp && inputs.length) {
1065
+ const flat = inputs.map(input => ops.reshape(input, [1, -1, this.perLayerWidth]));
1066
+ try { packedInput = ops.concatAxis(flat, 1); }
1067
+ finally { for (const input of flat) input.dispose(); }
1068
+ }
1069
+ using pli = packedInput;
1070
+ const outputs: MlxArray[] = [];
1071
+ try {
1072
+ if (packedMlp) outputs.push(...mapPackedTokens(mids, packed => layer.forwardMlp(packed, pli)));
1073
+ else for (const [row, mid] of mids.entries()) outputs.push(layer.forwardMlp(mid, inputs[row] ?? null));
1074
+ } catch (error) { for (const output of outputs) output.dispose(); throw error; }
1075
+ for (const [index, group] of groups.entries()) {
1076
+ group.h.dispose(); group.h = outputs[index]!;
1077
+ }
1078
+ for (const [index, group] of groups.entries()) work[index]!.captureLayer?.(i, group.h);
1079
+ } finally {
1080
+ for (const mid of mids) mid.dispose();
1081
+ for (const input of inputs) input.dispose();
1082
+ }
1083
+ }
1084
+ for (const [index, group] of groups.entries()) {
1085
+ const hidden = this.finalNorm.forward(group.h); results.push(hidden);
1086
+ work[index]!.captureLayer?.(this.layers.length, hidden);
1087
+ }
1088
+ return results;
1089
+ } catch (error) { for (const result of results) result.dispose(); throw error; }
1090
+ finally {
1091
+ for (const group of groups) {
1092
+ group.h.dispose(); group.perLayer?.dispose();
1093
+ for (let i = 0; i < this.numDonors; i++) {
1094
+ const shared = group.intermediates[i];
1095
+ if (shared) Gemma4Model.disposeSharedKv(shared);
1096
+ }
1097
+ for (const mask of group.masks.values()) mask.arr?.dispose();
1098
+ }
1099
+ }
1100
+ }
1101
+
1102
+
1018
1103
  // --- Segmented-backward support (docs/design/orpo-training.md
1019
1104
  // §4 Phase B). These are ADDITIVE — forwardLayers is untouched. The segmented
1020
1105
  // driver builds masks + per-layer inputs ONCE, then drives runLayerRange per
@@ -1056,7 +1141,10 @@ export class Gemma4Model {
1056
1141
  /** Dispose a fetched donor K/V (plain or quantized) — mirrors forwardLayers'
1057
1142
  * donor cleanup. */
1058
1143
  private static disposeSharedKv(s: SharedKv): void {
1059
- if (s.kind === "plain") {
1144
+ s.offsetArr?.dispose();
1145
+ if (s.kind === "view") {
1146
+ s.attention.dispose();
1147
+ } else if (s.kind === "plain") {
1060
1148
  s.keys.dispose();
1061
1149
  s.values.dispose();
1062
1150
  } 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 {