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
package/src/kv-store.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type CheckpointAttachment, attachmentBytes, disposeAttachments } from "./backends/mlx/checkpoint-state";
1
2
  import { cleanupFailure, disposeResources } from "./engine/resources";
2
3
  // KV-cache persistence: save prompt caches to disk, reload by streamed copy.
3
4
  // The serialization core of the SSD cold tier (docs/design/kv-cache.md).
@@ -42,8 +43,14 @@ import { cleanupFailure, disposeResources } from "./engine/resources";
42
43
  // whole entry again.
43
44
 
44
45
  import { openSync, writeSync, readSync, closeSync, fsyncSync, renameSync, rmSync } from "node:fs";
46
+ import { pagedCacheCodec } from "./backends/mlx/paged-cache-codec";
47
+ import { HostBuffer } from "./storage/host-buffer";
48
+ import { readFileSync } from "node:fs";
49
+ import { join, dirname } from "node:path";
50
+ import { createHash } from "node:crypto";
45
51
  import { MmapFile, MADV_DONTNEED } from "./mmap";
46
52
  import { MlxArray } from "./mlx/array";
53
+ import { kvWriter, kvReader, type KvWriteRequest } from "./storage/kv-writer";
47
54
  import type { Dtype } from "./mlx/ffi";
48
55
  import * as ops from "./mlx/ops";
49
56
  import {
@@ -66,6 +73,7 @@ const hash64 = (bytes: Uint8Array): string => Bun.hash(bytes).toString(16).padSt
66
73
 
67
74
  export type CacheKind =
68
75
  | "kv"
76
+ | "paged"
69
77
  | "rotating"
70
78
  | "qkv"
71
79
  | "rotating-qkv"
@@ -75,7 +83,8 @@ export type CacheKind =
75
83
  | "mla-dsa"
76
84
  | "mtp-mla";
77
85
 
78
- interface TensorSlot {
86
+ export interface TensorSlot {
87
+ blocks?: Array<{ hash: string; bytes: number }>;
79
88
  off: number;
80
89
  bytes: number;
81
90
  shape: number[];
@@ -84,8 +93,11 @@ interface TensorSlot {
84
93
  }
85
94
 
86
95
  export interface CacheHeaderEntry {
96
+ paged?: { capacityTokens: number; blockSize: number; blockTable: number[]; numBlocks: number;
97
+ headDim: number; vHeadDim: number; dtype: Dtype; direct: boolean };
87
98
  kind: CacheKind;
88
99
  offset: number;
100
+ minimumReusableOffset?: number;
89
101
  /** rotating variants: ring write index */
90
102
  idx?: number;
91
103
  /** rotating variants: window size */
@@ -114,6 +126,7 @@ export interface CacheHeaderEntry {
114
126
  }
115
127
 
116
128
  export interface KvSaveMeta {
129
+ attachments?: CheckpointAttachment[];
117
130
  modelId?: string;
118
131
  /** configFingerprint(config) — covers every graph-shaping field incl.
119
132
  * the kv-quant scheme, so a scheme flip invalidates naturally. */
@@ -139,10 +152,11 @@ export interface KvSaveMeta {
139
152
  };
140
153
  }
141
154
 
142
- export interface KvFileHeader extends KvSaveMeta {
155
+ export interface KvFileHeader extends Omit<KvSaveMeta, "attachments"> {
156
+ attachments?: Array<Omit<CheckpointAttachment, "tensors"> & { tensors: TensorSlot[] }>;
143
157
  /** Absent in earlier v3 files, which used mlx-cache-v3. */
144
158
  codecProvider?: string;
145
- formatVersion: 3;
159
+ formatVersion: 3 | 4 | 5;
146
160
  createdAt: number;
147
161
  tokens: number[];
148
162
  caches: CacheHeaderEntry[];
@@ -273,8 +287,8 @@ const CACHE_CODECS = {
273
287
  },
274
288
  clone: (cache, context) => {
275
289
  const c = cache as KVCache;
276
- if (!c.keys || !c.values) throw new Error("cannot clone an empty cache");
277
290
  const clone = new KVCache();
291
+ if (!c.keys || !c.values) return clone;
278
292
  clone.restoreState(
279
293
  context.liveView(c.keys, c.offset),
280
294
  context.liveView(c.values, c.offset),
@@ -305,8 +319,8 @@ const CACHE_CODECS = {
305
319
  },
306
320
  clone: (cache, context) => {
307
321
  const c = cache as RotatingKVCache;
308
- if (!c.keys || !c.values) throw new Error("cannot clone an empty cache");
309
322
  const clone = new RotatingKVCache(c.maxSize);
323
+ if (!c.keys || !c.values) return clone;
310
324
  clone.restoreState(context.view(c.keys), context.view(c.values), c.offset, c.ringIdx);
311
325
  return clone;
312
326
  },
@@ -332,8 +346,8 @@ const CACHE_CODECS = {
332
346
  },
333
347
  clone: (cache, context) => {
334
348
  const c = cache as QuantizedKVCache;
335
- if (!c.keys || !c.values) throw new Error("cannot clone an empty cache");
336
349
  const clone = new QuantizedKVCache(c.groupSize, c.bits);
350
+ if (!c.keys || !c.values) return clone;
337
351
  clone.restoreState(
338
352
  context.tripleView(c.keys, c.offset),
339
353
  context.tripleView(c.values, c.offset),
@@ -363,8 +377,8 @@ const CACHE_CODECS = {
363
377
  },
364
378
  clone: (cache, context) => {
365
379
  const c = cache as RotatingQuantizedKVCache;
366
- if (!c.keys || !c.values) throw new Error("cannot clone an empty cache");
367
380
  const clone = new RotatingQuantizedKVCache(c.maxSize, c.groupSize, c.bits);
381
+ if (!c.keys || !c.values) return clone;
368
382
  clone.restoreState(
369
383
  context.tripleView(c.keys, null), context.tripleView(c.values, null),
370
384
  c.offset, c.ringIdx,
@@ -426,7 +440,7 @@ const CACHE_CODECS = {
426
440
  if (state.length === 0) throw new Error("cannot clone an empty cache");
427
441
  try {
428
442
  const [kIdx, kScales, kZeros, vPacked, vScales] = state;
429
- const clone = new TurboQuantKVCache(c.kBits, c.vBits);
443
+ const clone = new TurboQuantKVCache(c.kBits, c.vBits, c.fusedDecode);
430
444
  clone.restoreState({
431
445
  kIdx: context.view(kIdx!),
432
446
  kScales: context.view(kScales!),
@@ -453,6 +467,7 @@ const CACHE_CODECS = {
453
467
  },
454
468
  headerTrimmable: () => true,
455
469
  },
470
+ paged: pagedCacheCodec,
456
471
  mla: {
457
472
  matches: (cache) => cacheSignature(cache) === "kv:mla:target",
458
473
  snapshot: (cache, context) => snapshotMla("mla", cache, context),
@@ -515,6 +530,17 @@ export function createCacheCodecProvider(
515
530
 
516
531
  export const legacyCacheCodecs = createCacheCodecProvider("mlx-cache-v3", CACHE_CODECS);
517
532
 
533
+ /** Old TQ files lack the conversion boundary. Keep them reusable at their
534
+ * stored offset, but never infer that an earlier precision state is valid. */
535
+ export function cacheHeaderMinimumReusableOffset(entry: CacheHeaderEntry): number {
536
+ const offset = entry.minimumReusableOffset ?? (entry.kind === "turboquant" ? entry.offset : 0);
537
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > entry.offset)
538
+ throw new Error("invalid cache minimum reusable offset");
539
+ return offset;
540
+ }
541
+ export const cacheHeadersMinimumReusableOffset = (caches: readonly CacheHeaderEntry[]): number =>
542
+ Math.max(0, ...caches.map(cacheHeaderMinimumReusableOffset));
543
+
518
544
  export const cacheHeadersTrimmable = (
519
545
  caches: readonly CacheHeaderEntry[], codecs: CacheCodecProvider = legacyCacheCodecs,
520
546
  ): boolean => caches.every((entry) => codecs.forHeader(entry).headerTrimmable(entry));
@@ -547,6 +573,7 @@ function snapshotCache(c: Cache, dataOffset: number, codecs: CacheCodecProvider)
547
573
 
548
574
  try {
549
575
  const entry = codecs.forCache(c).snapshot(c, { slots, push, liveSlice, liveMlaSlice, pushTriple });
576
+ if (c.minimumReusableOffset !== undefined) entry.minimumReusableOffset = c.minimumReusableOffset;
550
577
  return { entry, sources, next: cursor };
551
578
  } catch (error) {
552
579
  for (const source of sources) if (source.disposeAfter) source.arr.dispose();
@@ -588,7 +615,9 @@ export function cloneKvCaches(caches: Cache[], codecs: CacheCodecProvider = lega
588
615
  const out: Cache[] = [];
589
616
  try {
590
617
  for (const c of caches) {
591
- out.push(codecs.forCache(c).clone(c, { view, liveView, mlaView, tripleView }));
618
+ const clone = codecs.forCache(c).clone(c, { view, liveView, mlaView, tripleView });
619
+ if (c.minimumReusableOffset !== undefined) clone.minimumReusableOffset = c.minimumReusableOffset;
620
+ out.push(clone);
592
621
  pending.length = 0; // transferred to the returned cache
593
622
  }
594
623
  } catch (err) {
@@ -597,12 +626,8 @@ export function cloneKvCaches(caches: Cache[], codecs: CacheCodecProvider = lega
597
626
  return out;
598
627
  }
599
628
 
600
- /** Shared v3 writer core, one `yield` after each tensor write. The sync
601
- * wrapper drains it in a tight loop; the async wrapper awaits a macrotask
602
- * between yields so the EVENT LOOP KEEPS SERVING while a multi-hundred-MB
603
- * entry flushes (the write-behind persistence contract: durability never
604
- * blocks a request). try/finally still runs on early generator close. */
605
- function* saveKvCacheSteps(path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta, codecs: CacheCodecProvider): Generator<void, void, void> {
629
+ /** Shared file layout and codec planning for synchronous and worker writes. */
630
+ function planKvCache(tokens: number[], caches: Cache[], meta: KvSaveMeta, codecs: CacheCodecProvider): { header: KvFileHeader; sources: TensorSource[] } {
606
631
  // Plan pass: header entries + lazy tensor sources, NO bytes materialized.
607
632
  const entries: CacheHeaderEntry[] = [];
608
633
  const sources: TensorSource[] = [];
@@ -615,11 +640,36 @@ function* saveKvCacheSteps(path: string, tokens: number[], caches: Cache[], meta
615
640
  dataOffset = s.next;
616
641
  }
617
642
 
643
+ const { attachments: liveAttachments, ...identity } = meta;
644
+ const attachments = liveAttachments?.map((attachment) => ({
645
+ schema: attachment.schema,
646
+ metadata: { ...attachment.metadata },
647
+ tensors: attachment.tensors.map((arr) => {
648
+ const slot: TensorSlot = { off: dataOffset, bytes: arr.nbytes,
649
+ shape: [...arr.shape], dtype: arr.dtype, hash: "0000000000000000" };
650
+ dataOffset = alignUp(dataOffset + slot.bytes);
651
+ sources.push({ arr, disposeAfter: false });
652
+ return slot;
653
+ }),
654
+ }));
655
+
618
656
  // Header size is final NOW: hashes are fixed-width placeholders that get
619
657
  // patched in place (same byte length) after the data pass computes them.
620
658
  const header: KvFileHeader = {
621
- formatVersion: 3, createdAt: Date.now(), ...meta, codecProvider: codecs.id, tokens, caches: entries,
659
+ formatVersion: attachments?.length ? 4 : 3, createdAt: Date.now(), ...identity,
660
+ ...(attachments?.length ? { attachments } : {}), codecProvider: codecs.id, tokens, caches: entries,
622
661
  };
662
+ return { header, sources };
663
+ } catch (error) {
664
+ for (const source of sources) if (source.disposeAfter) source.arr.dispose();
665
+ throw error;
666
+ }
667
+ }
668
+
669
+ function* saveKvCacheSteps(path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta, codecs: CacheCodecProvider): Generator<void, void, void> {
670
+ const { header, sources } = planKvCache(tokens, caches, meta, codecs);
671
+ const entries = header.caches, attachments = header.attachments;
672
+ try {
623
673
  const headerLen = new TextEncoder().encode(JSON.stringify(header)).length;
624
674
  const dataStart = alignUp(PREFIX_LEN + headerLen);
625
675
 
@@ -639,7 +689,7 @@ function* saveKvCacheSteps(path: string, tokens: number[], caches: Cache[], meta
639
689
  // pages, which makes them VISIBLE to ps RSS — accounting, not an
640
690
  // allocation (see bench-serve.ts' per-leg RSS note).
641
691
  let srcIdx = 0;
642
- for (const e of entries) {
692
+ for (const e of [...entries, ...(attachments ?? [])]) {
643
693
  for (const slot of e.tensors) {
644
694
  const src = sources[srcIdx++]!;
645
695
  const c = ops.contiguous(src.arr);
@@ -687,58 +737,38 @@ export function saveKvCache(path: string, tokens: number[], caches: Cache[], met
687
737
  for (const _ of saveKvCacheSteps(path, tokens, caches, meta, codecs)) { /* drain */ }
688
738
  }
689
739
 
690
- /** Non-blocking variant: yields the event loop after every tensor write so
691
- * serving interleaves with the flush. Caller owns `caches` lifetime for
692
- * the duration (pass zero-copy clones, dispose after).
693
- *
694
- * `runStep` (optional) runs each blocking tensor step inside the caller's
695
- * exclusion domain. Waiting for an idle observation is not sufficient: a
696
- * request can acquire the engine between that check and `steps.next()`,
697
- * putting rawBytesView's GPU sync on the decode stream during prefill. */
740
+ /** Prepare immutable native storage on the owner thread, then pack, hash and
741
+ * write it on the CPU worker. The optional runner covers preparation only.
742
+ * Callers retain cache and attachment ownership until this promise settles. */
698
743
  export async function saveKvCacheAsync(
699
744
  path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta = {},
700
745
  runStep?: <T>(step: () => T) => Promise<T>, codecs: CacheCodecProvider = legacyCacheCodecs,
746
+ storage: Pick<KvWriteRequest, "layout" | "blockBytes" | "segmented"> = {},
701
747
  ): Promise<void> {
702
- const steps = saveKvCacheSteps(path, tokens, caches, meta, codecs);
748
+ const { header, sources } = planKvCache(tokens, caches, meta, codecs);
703
749
  try {
704
- while (true) {
705
- const next = runStep ? await runStep(() => steps.next()) : steps.next();
706
- if (next.done) break;
707
- await new Promise<void>((r) => setImmediate(r));
708
- }
709
- } catch (error) {
710
- // A rejected execution lease can leave the writer suspended with an
711
- // open fd. Throw through it so its existing failure cleanup runs.
712
- steps.throw(error);
713
- throw error;
750
+ // Snapshot preparation belongs to the MLX owner. After publication the
751
+ // worker sees only immutable bytes and never evaluates or calls MLX.
752
+ const prepare = () => sources.map(source => source.arr.storageView());
753
+ const tensors = runStep ? await runStep(prepare) : prepare();
754
+ await kvWriter.write({ path, header, tensors, ...storage });
755
+ } finally {
756
+ for (const source of sources) if (source.disposeAfter) source.arr.dispose();
714
757
  }
715
758
  }
716
759
 
717
- /** One pending write-behind item: an entry's copied tokens + OWNED
718
- * zero-copy cache clones (the queue disposes them on every exit path). */
760
+ /** Queued immutable snapshot; the queue releases its shared views on settle. */
719
761
  export interface SpillItem {
762
+ attachments?: CheckpointAttachment[];
720
763
  tokens: number[];
721
764
  caches: Cache[];
722
765
  ns: string;
723
766
  }
724
767
 
725
- /** Bounded write-behind queue (2026-07-07 post-merge review fix).
726
- *
727
- * Pending spill/snapshot clones pin their entries' GPU buffers until the
728
- * generation-locked flush gets a turn; the old bare promise chain queued them
729
- * WITHOUT BOUND, so under sustained traffic (gate starved, evictions
730
- * ongoing) resident memory = prompt-cache cap + every queued clone —
731
- * allocator pressure exactly under the load that caused the evictions.
732
- *
733
- * Policy: queued bytes are capped. Over cap, the OLDEST not-in-flight
734
- * item is dropped and its clones disposed immediately — cache semantics
735
- * (a dropped spill is a future cache miss, never a wrong result; the
736
- * contention-free alternative to letting the flush cut into decode).
737
- * The item being enqueued is never its own victim (soft cap: one
738
- * oversized entry may exceed the cap alone rather than never spilling).
739
- * Items run strictly serially in enqueue order via an internal chain;
740
- * store failures are swallowed (cold tier is best-effort) but clones
741
- * are disposed on every settle path. */
768
+ /** Serial snapshot queue with optional capacity shedding. TieredPromptCache
769
+ * disables shedding and owns residency itself. Interrupted-generation
770
+ * persistence uses the cap for supersedable intervals. Queue ownership ends
771
+ * after each write settles; the cache can retain independent shared views. */
742
772
  type SpillRec = SpillItem & {
743
773
  bytes: number;
744
774
  dropped: boolean;
@@ -757,10 +787,12 @@ export class SpillQueue {
757
787
  readonly capBytes: number,
758
788
  /** Byte size of a clone set (prompt-cache's cacheBytes). */
759
789
  readonly bytesOf: (caches: Cache[]) => number,
760
- /** The actual write (server passes storeAsync + the idle gate). */
790
+ /** The asynchronous storage operation. */
761
791
  readonly store: (item: SpillItem) => Promise<unknown>,
762
792
  /** Clone disposal — frees the pinned GPU memory. */
763
793
  readonly disposeClones: (caches: Cache[]) => void,
794
+ /** Zero-byte control barriers may need to survive capacity shedding. */
795
+ readonly canDrop: (item: SpillItem) => boolean = () => true,
764
796
  ) {}
765
797
 
766
798
  /** Bytes pinned by queued (not yet flushed) clones. */
@@ -776,7 +808,7 @@ export class SpillQueue {
776
808
  const result = new Promise<boolean>((resolve) => { settle = resolve; });
777
809
  const rec: SpillRec = {
778
810
  ...item,
779
- bytes: this.bytesOf(item.caches),
811
+ bytes: this.bytesOf(item.caches) + attachmentBytes(item.attachments),
780
812
  dropped: false,
781
813
  settle,
782
814
  };
@@ -784,7 +816,7 @@ export class SpillQueue {
784
816
  this.#bytes += rec.bytes;
785
817
  while (this.#bytes > this.capBytes) {
786
818
  const victim = this.#queue.find(
787
- (p) => !p.dropped && p !== this.#inFlight && p !== rec,
819
+ (p) => !p.dropped && p !== this.#inFlight && p !== rec && this.canDrop(p),
788
820
  );
789
821
  if (!victim) break; // only the new item (or in-flight) left — soft cap
790
822
  this.#drop(victim);
@@ -803,12 +835,19 @@ export class SpillQueue {
803
835
  } finally {
804
836
  this.#inFlight = null;
805
837
  this.#remove(rec);
806
- this.disposeClones(rec.caches);
838
+ disposeResources([{ dispose: () => this.disposeClones(rec.caches) },
839
+ { dispose: () => disposeAttachments(rec.attachments) }]);
807
840
  }
808
841
  });
809
842
  return result;
810
843
  }
811
844
 
845
+ /** Cancel queued ownership, excluding the write already in flight. */
846
+ cancelWhere(matches: (item: SpillItem) => boolean): void {
847
+ for (const rec of [...this.#queue])
848
+ if (!rec.dropped && rec !== this.#inFlight && matches(rec)) this.#drop(rec);
849
+ }
850
+
812
851
  /** Settles when everything enqueued so far has flushed or dropped
813
852
  * (tests and graceful shutdown). */
814
853
  drain(): Promise<void> { return this.#chain; }
@@ -818,7 +857,8 @@ export class SpillQueue {
818
857
  this.#dropped++;
819
858
  rec.settle(false);
820
859
  this.#remove(rec);
821
- this.disposeClones(rec.caches);
860
+ disposeResources([{ dispose: () => this.disposeClones(rec.caches) },
861
+ { dispose: () => disposeAttachments(rec.attachments) }]);
822
862
  }
823
863
 
824
864
  #remove(rec: SpillRec): void {
@@ -848,7 +888,7 @@ export function readKvHeader(path: string): KvFileHeader & { dataStart: number }
848
888
  if (BigInt(Bun.hash(body)) !== expectHash)
849
889
  throw new Error(`${path}: header hash mismatch (truncated or corrupt)`);
850
890
  const header = JSON.parse(new TextDecoder().decode(body)) as KvFileHeader;
851
- if (header.formatVersion !== 3)
891
+ if (header.formatVersion !== 3 && header.formatVersion !== 4 && header.formatVersion !== 5)
852
892
  throw new Error(`${path}: unsupported formatVersion ${header.formatVersion}`);
853
893
  return { ...header, dataStart };
854
894
  } finally {
@@ -857,6 +897,7 @@ export function readKvHeader(path: string): KvFileHeader & { dataStart: number }
857
897
  }
858
898
 
859
899
  export interface LoadedKvCache {
900
+ attachments?: CheckpointAttachment[];
860
901
  tokens: number[];
861
902
  header: KvFileHeader;
862
903
  caches: Cache[];
@@ -953,19 +994,8 @@ function withStepCapacity(a: MlxArray, offset: number): MlxArray {
953
994
  }
954
995
  }
955
996
 
956
- /** Reload by STREAMED COPY (see the header note): every tensor is copied
957
- * into an mlx-owned leaf; the mapping is read-only, its pages dropped
958
- * per-tensor, and unmapped before returning — the caches own their bytes
959
- * outright (no dtor contract, no pinned mapping, nothing to retain).
960
- * `model` is anything with makeCache() — the entry count is validated
961
- * against the DONOR cache list (model.layers.length was wrong for
962
- * KV-shared models like e4b, whose makeCache() returns donors only). */
963
- export function loadKvCache(
964
- path: string,
965
- model: { makeCache(): Cache[] },
966
- expect: KvLoadExpect = {}, codecs: CacheCodecProvider = legacyCacheCodecs,
967
- ): LoadedKvCache {
968
- const header = readKvHeader(path);
997
+ function validateKvHeader(path: string, header: KvFileHeader, model: { makeCache(): Cache[] },
998
+ expect: KvLoadExpect, codecs: CacheCodecProvider): void {
969
999
  if ((header.codecProvider ?? legacyCacheCodecs.id) !== codecs.id)
970
1000
  throw new Error(`${path}: cache codec provider mismatch`);
971
1001
  for (const entry of header.caches) codecs.forHeader(entry);
@@ -992,8 +1022,11 @@ export function loadKvCache(
992
1022
  for (const c of proto) c.dispose();
993
1023
  }
994
1024
 
995
- const mmap = MmapFile.open(path, "ro");
996
- const dataStart = header.dataStart;
1025
+ }
1026
+
1027
+ /** Decode cache layouts independently of the transport supplying arrays. */
1028
+ function restoreKvArrays(path: string, header: KvFileHeader, codecs: CacheCodecProvider,
1029
+ readArray: (slot: TensorSlot) => MlxArray): LoadedKvCache {
997
1030
  // Every tensor materialized for the CURRENT entry, drained on any throw
998
1031
  // (2026-07-07 review: a hash mismatch under --ssd-cache-verify — or any
999
1032
  // per-tensor failure — orphaned the entry's already-built tensors: the
@@ -1002,19 +1035,7 @@ export function loadKvCache(
1002
1035
  // is safe (dispose is idempotent).
1003
1036
  const pending: MlxArray[] = [];
1004
1037
  const arr = (slot: TensorSlot): MlxArray => {
1005
- const view = mmap.view(dataStart + slot.off, slot.bytes);
1006
- if (expect.verify && hash64(view) !== slot.hash)
1007
- throw new Error(`${path}: tensor hash mismatch at offset ${slot.off}`);
1008
- // mlx_array_new_data COPIES synchronously — the view only has to
1009
- // outlive this call, so the mapping can be advised/unmapped freely.
1010
- const a = MlxArray.fromBytesCopy(view, slot.shape, slot.dtype as Dtype);
1011
- pending.push(a);
1012
- // Drop the just-read clean pages so the load's transient stays at
1013
- // live entry + one tensor (ALIGN = 16 KiB == the arm64 page size, so
1014
- // every tensor offset is page-aligned by construction).
1015
- const len = Math.min(alignUp(slot.bytes), mmap.size - (dataStart + slot.off));
1016
- mmap.advise(dataStart + slot.off, len, MADV_DONTNEED);
1017
- return a;
1038
+ const a = readArray(slot); pending.push(a); return a;
1018
1039
  };
1019
1040
  /** withStepCapacity whose (possibly fresh) result is pending-tracked. */
1020
1041
  const grownArr = (slot: TensorSlot, offset: number): MlxArray => {
@@ -1026,20 +1047,89 @@ export function loadKvCache(
1026
1047
  ({ packed: arr(slots[at]!), scales: arr(slots[at + 1]!), biases: arr(slots[at + 2]!) });
1027
1048
 
1028
1049
  const caches: Cache[] = [];
1050
+ const attachments: CheckpointAttachment[] = [];
1029
1051
  try {
1030
1052
  for (const e of header.caches) {
1031
1053
  const codec = codecs.forHeader(e);
1032
- caches.push(codec.load(e, { path, arr, grownArr, triple }));
1054
+ const minimumOffset = cacheHeaderMinimumReusableOffset(e);
1055
+ const cache = codec.load(e, { path, arr, grownArr, triple });
1056
+ cache.minimumReusableOffset = minimumOffset;
1057
+ caches.push(cache);
1033
1058
  // This entry's tensors are now owned by its cache — stop tracking them
1034
1059
  // (the catch must not double-free through both pending AND caches).
1035
1060
  pending.length = 0;
1036
1061
  }
1062
+ for (const attachment of header.attachments ?? []) {
1063
+ attachments.push({ schema: attachment.schema, metadata: { ...attachment.metadata },
1064
+ tensors: attachment.tensors.map(arr) });
1065
+ pending.length = 0;
1066
+ }
1037
1067
  } catch (err) {
1068
+ disposeAttachments(attachments);
1038
1069
  for (const a of pending) a.dispose(); // the mid-entry orphans
1039
1070
  for (const c of caches) c.dispose();
1040
1071
  throw err;
1041
- } finally {
1042
- mmap.unmap(); // every tensor was copied out — nothing aliases the file
1043
1072
  }
1044
- return { tokens: header.tokens, header, caches };
1073
+ return { tokens: header.tokens, header, caches, ...(attachments.length ? { attachments } : {}) };
1074
+ }
1075
+
1076
+ /** CPU-only asynchronous reads into unpublished allocations; no tensor copy
1077
+ * crosses the event loop. Native array construction uses the shared codecs. */
1078
+ export async function loadKvCacheAsync(path: string, model: { makeCache(): Cache[] },
1079
+ expect: KvLoadExpect = {}, codecs: CacheCodecProvider = legacyCacheCodecs): Promise<LoadedKvCache> {
1080
+ const header = readKvHeader(path);
1081
+ validateKvHeader(path, header, model, expect, codecs);
1082
+ const slots = [...header.caches, ...(header.attachments ?? [])].flatMap(e => e.tensors);
1083
+ const buffers = new Map<TensorSlot, HostBuffer>();
1084
+ try {
1085
+ for (const slot of slots) buffers.set(slot, new HostBuffer(slot.bytes));
1086
+ await kvReader.read({ path, header, pointers: slots.map(slot => buffers.get(slot)!.pointer), verify: expect.verify });
1087
+ return restoreKvArrays(path, header, codecs, slot =>
1088
+ MlxArray.adoptHostBuffer(buffers.get(slot)!, slot.shape, slot.dtype as Dtype));
1089
+ } finally { for (const buffer of buffers.values()) buffer.dispose(); }
1090
+ }
1091
+
1092
+ /** Reload by STREAMED COPY (see the header note): every tensor is copied
1093
+ * into an mlx-owned leaf; the mapping is read-only, its pages dropped
1094
+ * per-tensor, and unmapped before returning — the caches own their bytes
1095
+ * outright (no dtor contract, no pinned mapping, nothing to retain).
1096
+ * `model` is anything with makeCache() — the entry count is validated
1097
+ * against the DONOR cache list (model.layers.length was wrong for
1098
+ * KV-shared models like e4b, whose makeCache() returns donors only). */
1099
+ export function loadKvCache(
1100
+ path: string,
1101
+ model: { makeCache(): Cache[] },
1102
+ expect: KvLoadExpect = {}, codecs: CacheCodecProvider = legacyCacheCodecs,
1103
+ ): LoadedKvCache {
1104
+ const header = readKvHeader(path);
1105
+ validateKvHeader(path, header, model, expect, codecs);
1106
+ if (header.formatVersion === 5) {
1107
+ return restoreKvArrays(path, header, codecs, slot => {
1108
+ const bytes = new Uint8Array(slot.bytes);
1109
+ let offset = 0;
1110
+ for (const block of slot.blocks ?? []) {
1111
+ if (!/^[a-f0-9]{64}$/.test(block.hash) || block.bytes <= 0 || offset + block.bytes > bytes.length)
1112
+ throw new Error(`${path}: invalid KV block reference`);
1113
+ const part = readFileSync(join(dirname(path), "blocks", block.hash));
1114
+ if (part.length !== block.bytes || (expect.verify && createHash("sha256").update(part).digest("hex") !== block.hash))
1115
+ throw new Error(`${path}: KV block mismatch`);
1116
+ bytes.set(part, offset); offset += part.length;
1117
+ }
1118
+ if (offset !== bytes.length)
1119
+ throw new Error(`${path}: KV tensor mismatch`);
1120
+ return MlxArray.fromBytesCopy(bytes, slot.shape, slot.dtype as Dtype);
1121
+ });
1122
+ }
1123
+ const mmap = MmapFile.open(path, "ro"), dataStart = header.dataStart;
1124
+ try {
1125
+ return restoreKvArrays(path, header, codecs, slot => {
1126
+ const view = mmap.view(dataStart + slot.off, slot.bytes);
1127
+ if (expect.verify && hash64(view) !== slot.hash)
1128
+ throw new Error(`${path}: tensor hash mismatch at offset ${slot.off}`);
1129
+ const array = MlxArray.fromBytesCopy(view, slot.shape, slot.dtype as Dtype);
1130
+ const len = Math.min(alignUp(slot.bytes), mmap.size - (dataStart + slot.off));
1131
+ mmap.advise(dataStart + slot.off, len, MADV_DONTNEED);
1132
+ return array;
1133
+ });
1134
+ } finally { mmap.unmap(); }
1045
1135
  }
package/src/lab/README.md CHANGED
@@ -14,5 +14,9 @@ fate (PLAN.md "Maintainability program", D6 darlings). Rules, gate-enforced by
14
14
  | module | flag / entry | oracle |
15
15
  |---|---|---|
16
16
  | `curve/` (curve sampler + designer page) | `--curve-*`, `/curves` | none (Lab) |
17
- | `paged-kv/` | `--paged-kv` (serial-only) | parity vs plain KVCache (bit-exact), no measured win |
17
+ | `paged-kv/` | `--paged-kv`; direct read via `MLX_BUN_PAGED_ATTN` | gathered storage is bit-exact; direct Metal is tolerance-tested, with paired M1/M4 measurements in benchmarks.md |
18
18
  | `expert-trace/` | `MLX_BUN_EXPERT_TRACE` hook in gemma4 | closed study (expert-offload) |
19
+
20
+ The optional paged layout has a separate backend persistence codec. Its
21
+ allowlisted import keeps `kv-store` independent of row-execution modules and
22
+ preserves RAM/SSD reuse without promoting direct-attention numerics to L1.
@@ -0,0 +1,117 @@
1
+ import { MlxArray } from "../../mlx/array";
2
+ import { Dtype } from "../../mlx/ffi";
3
+ import { MetalKernel } from "../../mlx/metal-kernel";
4
+ import * as ops from "../../mlx/ops";
5
+ import type { KvAttentionView, Mask } from "../../model/gemma4-base";
6
+ import { disposeResources } from "../../engine/resources";
7
+ import type { BlockPool } from "./paged-kv";
8
+
9
+ // Each SIMD group reduces one query/head over a bounded sequence partition.
10
+ // Only partial output vectors and normalizers materialize, never gathered KV.
11
+ const partialSpec = { name: "paged_attention_partial", ensureRowContiguous: true,
12
+ inputNames: ["q", "keys", "values", "table", "scale"], outputNames: ["out", "normal"], source: `
13
+ uint lane = thread_position_in_threadgroup.x;
14
+ uint hq = threadgroup_position_in_grid.y % HQ;
15
+ uint query = threadgroup_position_in_grid.y / HQ;
16
+ uint part = threadgroup_position_in_grid.z;
17
+ uint hk = hq / (HQ / HK);
18
+ float accum[(DV + 31) / 32];
19
+ for (uint j = 0; j < (DV + 31) / 32; ++j) accum[j] = 0;
20
+ float maximum = -INFINITY, total = 0;
21
+ uint length = uint(scale[1]);
22
+ uint count = (length + PART - 1) / PART;
23
+ uint end = min(uint(length - N + query + 1), (part + 1) * PART);
24
+ for (uint token = part * PART; token < end; ++token) {
25
+ uint physical = table[token / BS];
26
+ uint keyBase = ((physical * HK + hk) * BS + token % BS) * DK;
27
+ float score = 0;
28
+ for (uint d = lane; d < DK; d += 32)
29
+ score += float(q[(hq * N + query) * DK + d]) * float(keys[keyBase + d]);
30
+ score = simd_sum(score) * scale[0];
31
+ float next = max(maximum, score);
32
+ float previous = exp(maximum - next), weight = exp(score - next);
33
+ uint valueBase = ((physical * HK + hk) * BS + token % BS) * DV;
34
+ for (uint j = 0; j < (DV + 31) / 32; ++j) {
35
+ uint d = j * 32 + lane;
36
+ if (d < DV) accum[j] = accum[j] * previous + weight * float(values[valueBase + d]);
37
+ }
38
+ total = total * previous + weight; maximum = next;
39
+ }
40
+ uint base = ((hq * N + query) * count + part);
41
+ for (uint j = 0; j < (DV + 31) / 32; ++j) {
42
+ uint d = j * 32 + lane;
43
+ if (d < DV) out[base * DV + d] = accum[j];
44
+ }
45
+ if (lane == 0) { normal[base * 2] = maximum; normal[base * 2 + 1] = total; }
46
+ ` };
47
+ const partial = new MetalKernel(partialSpec);
48
+ const quantizedPartial = new MetalKernel({ ...partialSpec, name: "paged_attention_affine_partial",
49
+ inputNames: [...partialSpec.inputNames, "ks", "kb", "vs", "vb"],
50
+ source: partialSpec.source
51
+ .replace("float(keys[keyBase + d])", "float(InT(float(ks[(keyBase+d)/GROUP]) * float((keys[(keyBase+d)/PACK] >> (((keyBase+d)%PACK)*BITS)) & ((1u<<BITS)-1u)) + float(kb[(keyBase+d)/GROUP])))")
52
+ .replace("float(values[valueBase + d])", "float(InT(float(vs[(valueBase+d)/GROUP]) * float((values[(valueBase+d)/PACK] >> (((valueBase+d)%PACK)*BITS)) & ((1u<<BITS)-1u)) + float(vb[(valueBase+d)/GROUP])))"),
53
+ });
54
+ const merge = new MetalKernel({ name: "paged_attention_merge", inputNames: ["parts", "normal"], outputNames: ["out"], source: `
55
+ uint d = thread_position_in_grid.x;
56
+ uint row = thread_position_in_grid.y;
57
+ if (d >= DV) return;
58
+ uint count = normal_shape[2];
59
+ float maximum = -INFINITY;
60
+ for (uint part = 0; part < count; ++part) maximum = max(maximum, normal[(row * count + part) * 2]);
61
+ float sum = 0, value = 0;
62
+ for (uint part = 0; part < count; ++part) {
63
+ uint base = row * count + part;
64
+ float weight = exp(normal[base * 2] - maximum);
65
+ sum += normal[base * 2 + 1] * weight;
66
+ value += parts[base * DV + d] * weight;
67
+ }
68
+ out[row * DV + d] = OutT(value / sum);
69
+ ` });
70
+
71
+ /** Independent immutable view: later appends or row retirement cannot alter it. */
72
+ export function pagedAttentionView(pool: BlockPool, blockTable: number[], length: number,
73
+ direct: boolean): KvAttentionView {
74
+ const keys = ops.contiguous(pool.keys), values = ops.contiguous(pool.values);
75
+ const table = MlxArray.fromInt32(Int32Array.from(blockTable), [blockTable.length]);
76
+ const bs = pool.blockSize, quantization = pool.quantization;
77
+ const extra = quantization ? [pool.keyScales!, pool.keyBiases!, pool.valueScales!, pool.valueBiases!].map(a => ops.contiguous(a)) : [];
78
+ const dkStored = pool.headDim, dvStored = pool.vHeadDim;
79
+ const dtype = pool.dtype;
80
+ return {
81
+ attend(q: MlxArray, scale: number, mask: Mask): MlxArray {
82
+ const [, hq, n, dk] = q.shape as [number, number, number, number];
83
+ const hk = keys.shape[1]!, dv = dvStored;
84
+ // Long prefill and explicit masks use the established attention oracle.
85
+ // This is shape dispatch within one attention interface, not scheduling.
86
+ if (!direct || n > 8 || mask.mode === "array") {
87
+ const gather = (tensor: MlxArray) => {
88
+ using picked = ops.takeAxis(tensor, table, 0);
89
+ using transposed = ops.transposeAxes(picked, [1, 0, 2, 3]);
90
+ using flat = ops.reshape(transposed, [1, hk, blockTable.length * bs, tensor.shape[3]!]);
91
+ return flat.slice([0, 0, 0, 0], [1, hk, length, tensor.shape[3]!]);
92
+ };
93
+ using k = gather(keys), v = gather(values);
94
+ if (!quantization) return ops.sdpa(q, k, v, scale, mask.mode, mask.arr);
95
+ using ks = gather(extra[0]!), kb = gather(extra[1]!), vs = gather(extra[2]!), vb = gather(extra[3]!);
96
+ using kd = ops.dequantize(k, ks, kb, { ...quantization, mode: "affine" });
97
+ using vd = ops.dequantize(v, vs, vb, { ...quantization, mode: "affine" });
98
+ return ops.sdpa(q, kd, vd, scale, mask.mode, mask.arr);
99
+ }
100
+ const part = 64, parts = Math.ceil(length / part);
101
+ using multiplier = MlxArray.fromFloat32(new Float32Array([scale, length]), [2]);
102
+ const [out, normal] = (quantization ? quantizedPartial : partial).apply([q, keys, values, table, multiplier, ...extra], {
103
+ outputs: [{ shape: [hq, n, parts, dv], dtype: Dtype.float32 }, { shape: [hq, n, parts, 2], dtype: Dtype.float32 }],
104
+ grid: [32, hq * n, parts], threadGroup: [32, 1, 1],
105
+ templateDtypes: quantization ? { InT: dtype } : {},
106
+ templateInts: { HQ: hq, HK: hk, N: n, DK: dkStored, DV: dv, BS: bs, PART: part,
107
+ ...(quantization ? { BITS: quantization.bits, GROUP: quantization.groupSize, PACK: 32 / quantization.bits } : {}) },
108
+ });
109
+ try {
110
+ return merge.apply([out!, normal!], { outputs: [{ shape: [1, hq, n, dv], dtype: q.dtype }],
111
+ grid: [Math.ceil(dv / 32) * 32, hq * n, 1], threadGroup: [32, 1, 1],
112
+ templateDtypes: { OutT: q.dtype }, templateInts: { DV: dv } })[0]!;
113
+ } finally { disposeResources([out!, normal!]); }
114
+ },
115
+ dispose() { disposeResources([keys, values, table, ...extra]); },
116
+ };
117
+ }