mlx-bun 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +66 -21
  2. package/THIRD_PARTY_LICENSES.md +1 -0
  3. package/docs/archive/mlx-bun-lab-report.html +275 -0
  4. package/docs/archive/wwdc-mlx-bun.html +848 -0
  5. package/docs/design/audio-input-plan.md +356 -0
  6. package/docs/design/batching-perf-path.md +255 -0
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/compat-cli-surface-design.md +4 -3
  10. package/docs/design/decode-speed-program.md +172 -0
  11. package/docs/design/docs-reorg-plan.md +1 -1
  12. package/docs/design/dspark-serving-program.md +371 -0
  13. package/docs/design/dspark-speculative-decoding.md +167 -5
  14. package/docs/design/faithful-l1-consolidation.md +310 -0
  15. package/docs/design/generic-model-support.md +8 -5
  16. package/docs/design/grammar-spec-batching-integration.md +301 -0
  17. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  18. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  19. package/docs/design/omlx-adoption-map.md +98 -0
  20. package/docs/design/orpo-training.md +34 -30
  21. package/docs/design/paged-kv-cache.md +214 -0
  22. package/docs/design/parallel-slots.md +82 -49
  23. package/docs/design/parity-tier-dag.md +43 -33
  24. package/docs/design/repo-cleanup-plan.md +189 -0
  25. package/docs/design/runtime-isolation.md +19 -3
  26. package/docs/design/segmented-backward-training.md +18 -2
  27. package/docs/design/ssd-kv-cold-tier.md +288 -0
  28. package/docs/design/structured-output.md +484 -0
  29. package/docs/design/the-dreaming-master-plan.md +2 -3
  30. package/docs/design/turboquant-kv.md +235 -0
  31. package/docs/design/unified-engine-frontier-plan.md +774 -0
  32. package/docs/design/web-chat-beat-matrix.md +315 -0
  33. package/docs/design/web-chat-redesign.md +1112 -0
  34. package/docs/investigations/decode-roofline-lookagain.md +92 -6
  35. package/docs/investigations/dspark-handoff.md +182 -16
  36. package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
  37. package/docs/investigations/lab-build-journal.md +2 -2
  38. package/docs/investigations/openwiki-evaluation.md +249 -0
  39. package/docs/planning/IDEAS.md +14 -0
  40. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  41. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  42. package/docs/planning/chat-ui-vision.md +50 -0
  43. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  44. package/docs/planning/release-notes-v0.0.10.md +57 -0
  45. package/docs/planning/release-notes-v0.0.11.md +239 -0
  46. package/docs/reference/cli.md +35 -16
  47. package/docs/reference/distribution.md +13 -6
  48. package/docs/reference/embedding.md +35 -14
  49. package/docs/reference/features-matrix.md +97 -0
  50. package/docs/reference/library-api.md +30 -9
  51. package/docs/reference/memory.md +26 -6
  52. package/docs/reference/models.md +11 -1
  53. package/docs/reference/orpo-quickstart.md +3 -4
  54. package/docs/reference/server-api.md +634 -22
  55. package/docs/reference/server-config.md +251 -119
  56. package/docs/reference/training.md +20 -16
  57. package/package.json +5 -3
  58. package/src/anthropic.ts +13 -0
  59. package/src/audio/conformer.ts +647 -0
  60. package/src/audio/decode.ts +133 -0
  61. package/src/audio/features.ts +202 -0
  62. package/src/audio/transcode.ts +67 -0
  63. package/src/cli.ts +402 -107
  64. package/src/config.ts +49 -2
  65. package/src/download.ts +40 -0
  66. package/src/eval/README.md +7 -6
  67. package/src/eval/kl.ts +59 -3
  68. package/src/eval/quality-db.ts +2 -2
  69. package/src/eval/runner.ts +3 -3
  70. package/src/expert-offload.ts +15 -2
  71. package/src/flags.ts +13 -0
  72. package/src/generate.ts +330 -16
  73. package/src/grammar.ts +585 -0
  74. package/src/hub-rest.ts +242 -0
  75. package/src/kv-store.ts +618 -122
  76. package/src/lora.ts +8 -0
  77. package/src/md-modules.d.ts +42 -0
  78. package/src/media-fetch.ts +284 -0
  79. package/src/memory/rest.ts +245 -0
  80. package/src/memory/vault.ts +74 -0
  81. package/src/mlx/array.ts +127 -19
  82. package/src/mlx/autograd.ts +2 -2
  83. package/src/mlx/ffi.ts +27 -3
  84. package/src/mlx/metal-kernel.ts +7 -4
  85. package/src/mlx/ops.ts +179 -23
  86. package/src/mlx/turboquant-ops.ts +831 -0
  87. package/src/mlx/turboquant-tables.ts +237 -0
  88. package/src/model/batched-mask.ts +62 -1
  89. package/src/model/batched-quant.ts +206 -0
  90. package/src/model/batched-rotating-quant.ts +346 -0
  91. package/src/model/batched-rotating.ts +46 -1
  92. package/src/model/compiled-decode.ts +4 -15
  93. package/src/model/factory.ts +10 -4
  94. package/src/model/gemma4-base.ts +391 -34
  95. package/src/model/gemma4.ts +122 -33
  96. package/src/model/generated/gemma4-12b.ts +9 -13
  97. package/src/model/generated/gemma4-26b.ts +10 -5
  98. package/src/model/generated/gemma4-e4b.ts +14 -9
  99. package/src/model/minicpm5.ts +58 -102
  100. package/src/model/paged-kv.ts +253 -0
  101. package/src/model/qwen3-delta.ts +123 -15
  102. package/src/model/qwen3-moe.ts +404 -0
  103. package/src/model/qwen3.ts +9 -8
  104. package/src/model/qwen3_5.ts +99 -25
  105. package/src/model/support.ts +12 -1
  106. package/src/model/universal/dense.ts +40 -5
  107. package/src/model/universal/rope.ts +36 -21
  108. package/src/pi-web.ts +1299 -82
  109. package/src/preflight.ts +27 -8
  110. package/src/prompt-cache.ts +338 -32
  111. package/src/quantize/quantizer.ts +16 -1
  112. package/src/registry.ts +61 -3
  113. package/src/serve/batch-scheduler.ts +1001 -76
  114. package/src/serve/generation-gateway.ts +292 -30
  115. package/src/serve/isolate.ts +417 -0
  116. package/src/serve/lane-registry.ts +44 -0
  117. package/src/serve/session-search.ts +326 -0
  118. package/src/server.ts +1460 -105
  119. package/src/spec/assistant-source.ts +150 -0
  120. package/src/spec/deepspec-source.ts +143 -0
  121. package/src/spec/dflash-source.ts +124 -0
  122. package/src/spec/dspark/ab-stats.ts +136 -0
  123. package/src/spec/dspark/calibration.ts +112 -0
  124. package/src/spec/dspark/deepspec-module.ts +801 -0
  125. package/src/spec/dspark/generate-dflash.ts +72 -20
  126. package/src/spec/dspark/loader.ts +23 -0
  127. package/src/spec/dspark/module-dflash.ts +278 -36
  128. package/src/spec/dspark/quantize-drafter.ts +91 -0
  129. package/src/spec/generate.ts +59 -25
  130. package/src/spec/ngram-source.ts +132 -0
  131. package/src/spec/serve-loop.ts +536 -0
  132. package/src/spec/source.ts +93 -0
  133. package/src/spec/two-model.ts +171 -0
  134. package/src/ssd-cache.ts +285 -0
  135. package/src/tokenizer.ts +288 -2
  136. package/src/tool-approvals.ts +131 -0
  137. package/src/tool-call.ts +408 -20
  138. package/src/train/flash-cce.ts +29 -26
  139. package/src/train/job.ts +0 -2
  140. package/src/train/loss.ts +53 -11
  141. package/src/train/prefix-shared.ts +30 -7
  142. package/src/train/segmented.ts +150 -46
  143. package/src/train/trainer.ts +22 -15
  144. package/src/vision/prompt.ts +217 -48
  145. package/src/web/app.html +1184 -1789
  146. package/src/web/app.js +5667 -0
  147. package/src/web/icon.svg +13 -0
  148. package/src/web/manifest.webmanifest +24 -0
  149. package/src/web/src/adapters-panel.ts +288 -0
  150. package/src/web/src/api.ts +57 -0
  151. package/src/web/src/assistant.ts +485 -0
  152. package/src/web/src/chat.ts +1050 -0
  153. package/src/web/src/composer.ts +959 -0
  154. package/src/web/src/dataset.ts +132 -0
  155. package/src/web/src/finetune.ts +267 -0
  156. package/src/web/src/hub.ts +428 -0
  157. package/src/web/src/main.ts +98 -0
  158. package/src/web/src/markdown.ts +0 -0
  159. package/src/web/src/memory-panel.ts +572 -0
  160. package/src/web/src/model-picker.ts +159 -0
  161. package/src/web/src/palette.ts +346 -0
  162. package/src/web/src/protocol.ts +62 -0
  163. package/src/web/src/quantize.ts +167 -0
  164. package/src/web/src/rag.ts +274 -0
  165. package/src/web/src/sessions.ts +469 -0
  166. package/src/web/src/shell.ts +804 -0
  167. package/src/web/src/status.ts +252 -0
  168. package/src/web/src/ui-catalog.ts +92 -0
  169. package/src/web/sw.js +71 -0
  170. package/src/web/vendor/README.md +61 -0
  171. package/src/web/vendor/hljs-LICENSE +29 -0
  172. package/src/web/vendor/hljs-theme.css +59 -0
  173. package/src/web/vendor/hljs.js +26 -0
  174. package/src/model/fused-decode-kernel.ts +0 -312
  175. package/src/model/fused-geglu-kernel.ts +0 -198
  176. package/src/model/fused-mlp-kernel.ts +0 -169
  177. package/src/model/fused-swiglu-kernel.ts +0 -240
  178. package/src/model/steel-linear-kernel.ts +0 -96
  179. package/src/status-page.html +0 -497
package/src/kv-store.ts CHANGED
@@ -1,146 +1,505 @@
1
- // KV-cache persistence: save prompt caches to disk, reload zero-copy.
1
+ // KV-cache persistence: save prompt caches to disk, reload by streamed copy.
2
+ // The serialization core of the SSD cold tier (docs/design/ssd-kv-cold-tier.md).
2
3
  //
3
4
  // File layout (every tensor PAGE-ALIGNED — the Phase 1 corollary: files
4
5
  // we write can be mmap'd and handed to the GPU without copies):
5
- // [magic "MLXBUNKV1\n"][u32 LE header length][JSON header][padding]
6
+ // [magic "MLXBUNKV2\n"][u32 LE header length][u32 LE dataStart]
7
+ // [u64 LE header hash][JSON header][padding]
6
8
  // [tensor data at 16 KiB-aligned offsets]
7
- // Header: { tokens, caches: [{ kind, offset, idx?, maxSize?, dtype,
8
- // kShape, vShape, kOffset, vOffset }] }
9
+ // Header: { formatVersion, modelId, configFingerprint, ns, tokenizerHash,
10
+ // createdAt, tokens, caches: [{ kind, offset, idx?, maxSize?,
11
+ // groupSize?, bits?, kBits?, vBits?, headDim?,
12
+ // tensors: [{ off, bytes, shape, dtype, hash }] }] }
9
13
  //
10
- // Reload mmaps copy-on-write (MAP_PRIVATE): if mlx ever donates one of
11
- // these buffers, writes hit private pages, never the file.
14
+ // v2 over v1: quantized cache kinds (kv_config quantization — v1 could not
15
+ // persist it; NOTE bf16 is the serving default since the 2026-07-05 L1
16
+ // decision, quantized KV is opt-in), SSM kind (Qwen3.5 hybrid),
17
+ // invalidation metadata (configFingerprint covers the kv-quant scheme;
18
+ // tokenizerHash guards vocab drift; ns = adapter spec), per-tensor hashes
19
+ // (verified opt-in — the hash pass roughly doubles restore reads),
20
+ // a header hash (always verified — cheap), and atomic tmp+fsync+rename
21
+ // writes. v1 files are not migrated: nothing shipped wrote them (the
22
+ // format was test/experiment-only) — they read as "not a v2 file" and the
23
+ // SSD tier deletes-and-regenerates.
24
+ //
25
+ // v3 over v2: fixed-width (zero-padded 16-hex) tensor hashes, which let the
26
+ // writer STREAM — header sized up front, tensors materialized/hashed/written
27
+ // one at a time, header patched in last (peak host transient = one tensor,
28
+ // not the whole entry). Same magic; v2 files read as unsupported and the
29
+ // SSD tier deletes-and-regenerates (machine-local, cheap).
30
+ //
31
+ // Reload (2026-07-07, A7-restore) is a STREAMED COPY — the read-side twin
32
+ // of the v3 streaming writer: each tensor is copied out of a read-only mmap
33
+ // into an mlx-owned leaf (MlxArray.fromBytesCopy), its clean file pages
34
+ // dropped (MADV_DONTNEED) right after, and the mapping unmapped before
35
+ // loadKvCache returns. Peak host transient = the live entry + one tensor,
36
+ // and NOTHING outlives the call: the old zero-copy wrap (COW mmap +
37
+ // fromPointer) required pinning every restore mapping for the PROCESS
38
+ // lifetime once the FFI-dtor fix removed the unmap signal (2026-07-06) —
39
+ // one leaked full-entry mapping per restore — and the exactly-offset-sized
40
+ // restored buffers made the first post-restore decode step concat-copy the
41
+ // whole entry again.
12
42
 
13
- import { openSync, writeSync, closeSync } from "node:fs";
14
- import { MmapFile } from "./mmap";
43
+ import { openSync, writeSync, readSync, closeSync, fsyncSync, renameSync, rmSync } from "node:fs";
44
+ import { MmapFile, MADV_DONTNEED } from "./mmap";
15
45
  import { MlxArray } from "./mlx/array";
16
46
  import type { Dtype } from "./mlx/ffi";
17
47
  import * as ops from "./mlx/ops";
18
48
  import {
19
- type Cache, KVCache, RotatingKVCache, Gemma4Model,
20
- } from "./model/gemma4";
21
-
22
- /** Contiguous raw bytes of (possibly a view of) an array. */
23
- function contiguousBytes(a: MlxArray): Uint8Array {
24
- const c = ops.contiguous(a);
25
- const bytes = c.rawBytes();
26
- c.dispose();
27
- return bytes;
28
- }
49
+ type Cache, KVCache, RotatingKVCache,
50
+ QuantizedKVCache, RotatingQuantizedKVCache,
51
+ TurboQuantKVCache, type TurboQuantTensor,
52
+ } from "./model/gemma4-base";
53
+ import { SSMCache } from "./model/qwen3-delta";
29
54
 
30
- const MAGIC = "MLXBUNKV1\n";
55
+ const MAGIC = "MLXBUNKV2\n";
31
56
  const ALIGN = 16384;
57
+ /** magic + u32 headerLen + u32 dataStart + u64 headerHash */
58
+ const PREFIX_LEN = MAGIC.length + 4 + 4 + 8;
59
+
60
+ // Fixed-width (16 hex chars): the v3 streaming writer sizes the header
61
+ // BEFORE materializing any tensor bytes, so hash strings must not change
62
+ // the header's byte length when the real values replace the placeholders.
63
+ const hash64 = (bytes: Uint8Array): string => Bun.hash(bytes).toString(16).padStart(16, "0");
64
+
65
+ export type CacheKind = "kv" | "rotating" | "qkv" | "rotating-qkv" | "ssm" | "turboquant";
66
+
67
+ interface TensorSlot {
68
+ off: number;
69
+ bytes: number;
70
+ shape: number[];
71
+ dtype: number;
72
+ hash: string;
73
+ }
32
74
 
33
- interface CacheHeaderEntry {
34
- kind: "kv" | "rotating";
75
+ export interface CacheHeaderEntry {
76
+ kind: CacheKind;
35
77
  offset: number;
78
+ /** rotating variants: ring write index */
36
79
  idx?: number;
80
+ /** rotating variants: window size */
37
81
  maxSize?: number;
38
- dtype: number;
39
- kShape: number[];
40
- vShape: number[];
41
- kOffset: number;
42
- vOffset: number;
43
- kBytes: number;
44
- vBytes: number;
82
+ /** quantized variants (qkv/rotating-qkv: mlx affine scheme) */
83
+ groupSize?: number;
84
+ bits?: number;
85
+ /** turboquant: per-side bit widths (reuses the `bits` field's slot class
86
+ * but needs both — asymmetric key/value bit widths, unlike qkv's single
87
+ * `bits`). `groupSize` is unused for turboquant (fixed at 32, the
88
+ * BLOCK_SIZE constant — not a configurable field like qkv's). */
89
+ kBits?: number;
90
+ vBits?: number;
91
+ /** turboquant: head_dim, needed to unpack kIdx/vPacked on restore
92
+ * (packed byte width alone doesn't recover the original element count). */
93
+ headDim?: number;
94
+ /** kv/rotating: [k, v] · qkv/rotating-qkv: [kPacked, kScales, kBiases,
95
+ * vPacked, vScales, vBiases] · ssm: [conv, recurrent] · turboquant:
96
+ * [kIdx, kScales, kZeros, vPacked, vScales] */
97
+ tensors: TensorSlot[];
98
+ }
99
+
100
+ export interface KvSaveMeta {
101
+ modelId?: string;
102
+ /** configFingerprint(config) — covers every graph-shaping field incl.
103
+ * the kv-quant scheme, so a scheme flip invalidates naturally. */
104
+ configFingerprint?: string;
105
+ /** Adapter namespace (PromptCache ns — adapters joined with "+"). */
106
+ ns?: string;
107
+ /** sha256 of tokenizer.json: identical ids must mean identical text. */
108
+ tokenizerHash?: string;
45
109
  }
46
110
 
47
- export interface KvFileHeader {
111
+ export interface KvFileHeader extends KvSaveMeta {
112
+ formatVersion: 3;
113
+ createdAt: number;
48
114
  tokens: number[];
49
115
  caches: CacheHeaderEntry[];
50
116
  }
51
117
 
52
118
  const alignUp = (n: number) => Math.ceil(n / ALIGN) * ALIGN;
53
119
 
54
- export function saveKvCache(path: string, tokens: number[], caches: Cache[]): void {
55
- const entries: CacheHeaderEntry[] = [];
56
- const blobs: Uint8Array[] = [];
120
+ /** Snapshot one cache into header entry + LAZY tensor sources (v3 streaming
121
+ * writer: sizes/shapes are metadata, bytes materialize ONE tensor at a
122
+ * time at write — the old all-blobs-up-front path doubled RSS by the whole
123
+ * entry, ~390 MB for a 16k cpm5 entry, on every write-behind). Quantized
124
+ * full caches are sliced to the live [.., :offset, :] region (token axis —
125
+ * quantization groups run along the FEATURE dim, so a sequence-axis cut
126
+ * never splits a group); rotating caches persist the whole ring
127
+ * as-laid-out with ringIdx. Slice handles are lazy graph nodes (no GPU
128
+ * materialization until rawBytes at write time). */
129
+ interface TensorSource { arr: MlxArray; disposeAfter: boolean }
130
+ function snapshotCache(c: Cache, dataOffset: number): { entry: CacheHeaderEntry; sources: TensorSource[]; next: number } {
131
+ const slots: TensorSlot[] = [];
132
+ const sources: TensorSource[] = [];
133
+ let cursor = dataOffset;
134
+ const HASH_PLACEHOLDER = "0".repeat(16); // fixed-width; patched at write
135
+ const push = (a: MlxArray, disposeAfter: boolean): void => {
136
+ const off = alignUp(cursor);
137
+ slots.push({ off, bytes: a.nbytes, shape: a.shape, dtype: a.dtype, hash: HASH_PLACEHOLDER });
138
+ sources.push({ arr: a, disposeAfter });
139
+ cursor = off + a.nbytes;
140
+ };
141
+ const liveSlice = (a: MlxArray, upTo: number): MlxArray => {
142
+ const [B, H, , D] = a.shape as [number, number, number, number];
143
+ return a.slice([0, 0, 0, 0], [B, H, upTo, D]);
144
+ };
145
+ const pushTriple = (t: ops.QuantizedTensor, upTo: number | null): void => {
146
+ for (const a of [t.packed, t.scales, t.biases]) {
147
+ if (upTo === null) push(a, false);
148
+ else push(liveSlice(a, upTo), true);
149
+ }
150
+ };
151
+
152
+ let entry: CacheHeaderEntry;
153
+ if (c instanceof TurboQuantKVCache) {
154
+ const t = c.state();
155
+ if (t.length === 0) throw new Error("cannot persist an empty cache");
156
+ const [kIdx, kScales, kZeros, vPacked, vScales] = t as [MlxArray, MlxArray, MlxArray, MlxArray, MlxArray];
157
+ // state() already trims to offset — dispose these lazy slice views
158
+ // once written (same disposeAfter contract as liveSlice elsewhere).
159
+ for (const a of [kIdx, kScales, kZeros, vPacked, vScales]) push(a, true);
160
+ entry = { kind: "turboquant", offset: c.offset, kBits: c.kBits, vBits: c.vBits,
161
+ headDim: c.headDim ?? 0, tensors: slots };
162
+ } else if (c instanceof RotatingQuantizedKVCache) {
163
+ if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
164
+ pushTriple(c.keys, null);
165
+ pushTriple(c.values, null);
166
+ entry = { kind: "rotating-qkv", offset: c.offset, idx: c.ringIdx, maxSize: c.maxSize,
167
+ groupSize: c.groupSize, bits: c.bits, tensors: slots };
168
+ } else if (c instanceof QuantizedKVCache) {
169
+ if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
170
+ pushTriple(c.keys, c.offset);
171
+ pushTriple(c.values, c.offset);
172
+ entry = { kind: "qkv", offset: c.offset, groupSize: c.groupSize, bits: c.bits, tensors: slots };
173
+ } else if (c instanceof RotatingKVCache) {
174
+ if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
175
+ push(c.keys, false);
176
+ push(c.values, false);
177
+ entry = { kind: "rotating", offset: c.offset, idx: c.ringIdx, maxSize: c.maxSize, tensors: slots };
178
+ } else if (c instanceof SSMCache) {
179
+ if (!c.conv || !c.recurrent) throw new Error("cannot persist an empty cache");
180
+ push(c.conv, false);
181
+ push(c.recurrent, false);
182
+ entry = { kind: "ssm", offset: c.offset, tensors: slots };
183
+ } else if (c instanceof KVCache) {
184
+ if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
185
+ push(liveSlice(c.keys, c.offset), true);
186
+ push(liveSlice(c.values, c.offset), true);
187
+ entry = { kind: "kv", offset: c.offset, tensors: slots };
188
+ } else {
189
+ throw new Error("unknown cache type");
190
+ }
191
+ return { entry, sources, next: cursor };
192
+ }
193
+
194
+ /** Persist `caches` (+ the exact token prefix they encode) to `path`.
195
+ * ATOMIC: written to `<path>.tmp`, fsync'd, renamed — a crash mid-write
196
+ * leaves only a .tmp orphan (ignored + reaped by SsdCacheStore.scan). */
197
+ /** Zero-copy in-memory clone of a cache list at its CURRENT state — new
198
+ * cache instances holding slice VIEWS of the live tensors (mlx arrays are
199
+ * immutable: continued generation replaces the source caches' internal
200
+ * arrays, it never mutates the shared buffers, so views are stable).
201
+ * Built for the prompt-boundary prompt-cache snapshot (multi-turn agent
202
+ * traffic: the reply's decode→encode roundtrip drift makes prompt+gen
203
+ * entries untrimmable at context > sliding window — a prompt-only entry
204
+ * is ALWAYS an exact prefix of the next turn, immune to drift). Must be
205
+ * called while the caches hold EXACTLY the state to snapshot (post-wrap
206
+ * rings cannot be rewound later). Same per-kind dispatch as
207
+ * snapshotCache/loadKvCache. */
208
+ export function cloneKvCaches(caches: Cache[]): Cache[] {
209
+ const view = (a: MlxArray): MlxArray => a.slice(a.shape.map(() => 0), [...a.shape]);
210
+ const liveView = (a: MlxArray, upTo: number): MlxArray => {
211
+ const [B, H, , D] = a.shape as [number, number, number, number];
212
+ return a.slice([0, 0, 0, 0], [B, H, upTo, D]);
213
+ };
214
+ const tripleView = (t: ops.QuantizedTensor, upTo: number | null): ops.QuantizedTensor => ({
215
+ packed: upTo === null ? view(t.packed) : liveView(t.packed, upTo),
216
+ scales: upTo === null ? view(t.scales) : liveView(t.scales, upTo),
217
+ biases: upTo === null ? view(t.biases) : liveView(t.biases, upTo),
218
+ });
219
+ const out: Cache[] = [];
220
+ try {
221
+ for (const c of caches) {
222
+ if (c instanceof TurboQuantKVCache) {
223
+ const state = c.state();
224
+ if (state.length === 0) throw new Error("cannot clone an empty cache");
225
+ const [kIdx, kScales, kZeros, vPacked, vScales] = state;
226
+ const n = new TurboQuantKVCache(c.kBits, c.vBits);
227
+ n.restoreState(
228
+ { kIdx: view(kIdx!), kScales: view(kScales!), kZeros: view(kZeros!),
229
+ vPacked: view(vPacked!), vScales: view(vScales!) },
230
+ c.offset, c.headDim!,
231
+ );
232
+ for (const a of state) a.dispose();
233
+ out.push(n);
234
+ } else if (c instanceof RotatingQuantizedKVCache) {
235
+ if (!c.keys || !c.values) throw new Error("cannot clone an empty cache");
236
+ const n = new RotatingQuantizedKVCache(c.maxSize, c.groupSize, c.bits);
237
+ n.restoreState(tripleView(c.keys, null), tripleView(c.values, null), c.offset, c.ringIdx);
238
+ out.push(n);
239
+ } else if (c instanceof QuantizedKVCache) {
240
+ if (!c.keys || !c.values) throw new Error("cannot clone an empty cache");
241
+ const n = new QuantizedKVCache(c.groupSize, c.bits);
242
+ n.restoreState(tripleView(c.keys, c.offset), tripleView(c.values, c.offset), c.offset);
243
+ out.push(n);
244
+ } else if (c instanceof RotatingKVCache) {
245
+ if (!c.keys || !c.values) throw new Error("cannot clone an empty cache");
246
+ const n = new RotatingKVCache(c.maxSize);
247
+ n.restoreState(view(c.keys), view(c.values), c.offset, c.ringIdx);
248
+ out.push(n);
249
+ } else if (c instanceof SSMCache) {
250
+ if (!c.conv || !c.recurrent) throw new Error("cannot clone an empty cache");
251
+ const n = new SSMCache();
252
+ n.conv = view(c.conv);
253
+ n.recurrent = view(c.recurrent);
254
+ n.offset = c.offset;
255
+ out.push(n);
256
+ } else if (c instanceof KVCache) {
257
+ if (!c.keys || !c.values) throw new Error("cannot clone an empty cache");
258
+ const n = new KVCache();
259
+ n.restoreState(liveView(c.keys, c.offset), liveView(c.values, c.offset), c.offset);
260
+ out.push(n);
261
+ } else {
262
+ throw new Error("cloneKvCaches: unknown cache type");
263
+ }
264
+ }
265
+ } catch (err) {
266
+ for (const c of out) c.dispose();
267
+ throw err;
268
+ }
269
+ return out;
270
+ }
57
271
 
58
- // compact each cache to its live region and snapshot bytes
272
+ /** Shared v3 writer core, one `yield` after each tensor write. The sync
273
+ * wrapper drains it in a tight loop; the async wrapper awaits a macrotask
274
+ * between yields so the EVENT LOOP KEEPS SERVING while a multi-hundred-MB
275
+ * entry flushes (the write-behind persistence contract: durability never
276
+ * blocks a request). try/finally still runs on early generator close. */
277
+ function* saveKvCacheSteps(path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta): Generator<void, void, void> {
278
+ // Plan pass: header entries + lazy tensor sources, NO bytes materialized.
279
+ const entries: CacheHeaderEntry[] = [];
280
+ const sources: TensorSource[] = [];
59
281
  let dataOffset = 0; // relative; rebased after header is sized
60
- for (const c of caches) {
61
- let keys: MlxArray;
62
- let values: MlxArray;
63
- let kind: "kv" | "rotating";
64
- let idx: number | undefined;
65
- let maxSize: number | undefined;
66
- if (c instanceof RotatingKVCache) {
67
- kind = "rotating";
68
- idx = c.ringIdx;
69
- maxSize = c.maxSize;
70
- if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
71
- keys = c.keys;
72
- values = c.values;
73
- } else if (c instanceof KVCache) {
74
- kind = "kv";
75
- if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
76
- // store only the live [.., :offset, :] region
77
- const [B, H, , D] = c.keys.shape as [number, number, number, number];
78
- const vD = c.values.shape[3]!;
79
- keys = c.keys.slice([0, 0, 0, 0], [B, H, c.offset, D]);
80
- values = c.values.slice([0, 0, 0, 0], [B, H, c.offset, vD]);
81
- } else {
82
- throw new Error("unknown cache type");
282
+ try {
283
+ for (const c of caches) {
284
+ const s = snapshotCache(c, dataOffset);
285
+ entries.push(s.entry);
286
+ sources.push(...s.sources);
287
+ dataOffset = s.next;
83
288
  }
84
289
 
85
- const kBytesArr = contiguousBytes(keys);
86
- const vBytesArr = contiguousBytes(values);
87
- const kOffset = alignUp(dataOffset);
88
- const vOffset = alignUp(kOffset + kBytesArr.length);
89
- dataOffset = vOffset + vBytesArr.length;
90
-
91
- entries.push({
92
- kind, offset: c.offset, idx, maxSize,
93
- dtype: keys.dtype,
94
- kShape: keys.shape, vShape: values.shape,
95
- kOffset, vOffset,
96
- kBytes: kBytesArr.length, vBytes: vBytesArr.length,
97
- });
98
- blobs.push(kBytesArr, vBytesArr);
99
- if (c instanceof KVCache) {
100
- keys.dispose();
101
- values.dispose();
290
+ // Header size is final NOW: hashes are fixed-width placeholders that get
291
+ // patched in place (same byte length) after the data pass computes them.
292
+ const header: KvFileHeader = {
293
+ formatVersion: 3, createdAt: Date.now(), ...meta, tokens, caches: entries,
294
+ };
295
+ const headerLen = new TextEncoder().encode(JSON.stringify(header)).length;
296
+ const dataStart = alignUp(PREFIX_LEN + headerLen);
297
+
298
+ const tmp = `${path}.tmp`;
299
+ const fd = openSync(tmp, "w");
300
+ try {
301
+ // Data pass: materialize → hash → write → drop, ONE tensor at a time.
302
+ // The hash+write read a ZERO-COPY view of the contiguous mlx buffer
303
+ // (rawBytesView) — never a JS-heap copy. rawBytes() here looked
304
+ // "streamed" but each call allocated a dead per-tensor JS-heap copy
305
+ // whose reclamation is GC-timing-dependent — up to a whole extra
306
+ // entry of heap on a busy server (the A7 residual). Now zero JS
307
+ // allocations; the only real transient is the one mlx-side
308
+ // contiguous copy (allocator-pooled; none at all for contiguous
309
+ // sources — mlx save-transient measured 0 bytes, 2026-07-07). NOTE:
310
+ // the hash+write still CPU-touch the live entry's unified-memory
311
+ // pages, which makes them VISIBLE to ps RSS — accounting, not an
312
+ // allocation (see bench-serve.ts' per-leg RSS note).
313
+ let srcIdx = 0;
314
+ for (const e of entries) {
315
+ for (const slot of e.tensors) {
316
+ const src = sources[srcIdx++]!;
317
+ const c = ops.contiguous(src.arr);
318
+ try {
319
+ const bytes = c.rawBytesView(); // evals; aliases the mlx buffer
320
+ if (bytes.length !== slot.bytes)
321
+ throw new Error(`tensor byte-length drift: planned ${slot.bytes}, got ${bytes.length}`);
322
+ slot.hash = hash64(bytes);
323
+ writeSync(fd, bytes, 0, bytes.length, dataStart + slot.off);
324
+ } finally {
325
+ c.dispose(); // the view dies with the buffer — nothing retains it
326
+ }
327
+ if (src.disposeAfter) { src.arr.dispose(); src.disposeAfter = false; }
328
+ yield;
329
+ }
330
+ }
331
+
332
+ // Header pass: real hashes in, byte length unchanged by construction.
333
+ const headerJson = new TextEncoder().encode(JSON.stringify(header));
334
+ if (headerJson.length !== headerLen)
335
+ throw new Error(`header length drift: planned ${headerLen}, got ${headerJson.length}`);
336
+ const pre = new Uint8Array(dataStart);
337
+ pre.set(new TextEncoder().encode(MAGIC), 0);
338
+ const dv = new DataView(pre.buffer);
339
+ dv.setUint32(MAGIC.length, headerJson.length, true);
340
+ dv.setUint32(MAGIC.length + 4, dataStart, true);
341
+ dv.setBigUint64(MAGIC.length + 8, BigInt(Bun.hash(headerJson)), true);
342
+ pre.set(headerJson, PREFIX_LEN);
343
+ writeSync(fd, pre, 0, pre.length, 0);
344
+ fsyncSync(fd);
345
+ } catch (err) {
346
+ closeSync(fd);
347
+ try { rmSync(tmp, { force: true }); } catch {}
348
+ throw err;
102
349
  }
350
+ closeSync(fd);
351
+ renameSync(tmp, path);
352
+ } finally {
353
+ // Slices created in the plan pass are ours to free on EVERY path.
354
+ for (const s of sources) if (s.disposeAfter) s.arr.dispose();
103
355
  }
356
+ }
104
357
 
105
- const header: KvFileHeader = { tokens, caches: entries };
106
- const headerJson = new TextEncoder().encode(JSON.stringify(header));
107
- const dataStart = alignUp(MAGIC.length + 8 + headerJson.length);
358
+ export function saveKvCache(path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta = {}): void {
359
+ for (const _ of saveKvCacheSteps(path, tokens, caches, meta)) { /* drain */ }
360
+ }
108
361
 
109
- const fd = openSync(path, "w");
110
- try {
111
- const pre = new Uint8Array(dataStart);
112
- pre.set(new TextEncoder().encode(MAGIC), 0);
113
- const dv = new DataView(pre.buffer);
114
- dv.setUint32(MAGIC.length, headerJson.length, true);
115
- dv.setUint32(MAGIC.length + 4, dataStart, true);
116
- pre.set(headerJson, MAGIC.length + 8);
117
- writeSync(fd, pre, 0, pre.length, 0);
118
-
119
- let blobIdx = 0;
120
- for (const e of entries) {
121
- writeSync(fd, blobs[blobIdx]!, 0, e.kBytes, dataStart + e.kOffset);
122
- writeSync(fd, blobs[blobIdx + 1]!, 0, e.vBytes, dataStart + e.vOffset);
123
- blobIdx += 2;
362
+ /** Non-blocking variant: yields the event loop after every tensor write so
363
+ * serving interleaves with the flush. Caller owns `caches` lifetime for
364
+ * the duration (pass zero-copy clones, dispose after).
365
+ *
366
+ * `waitTurn` (optional) is awaited BEFORE every step — including the first —
367
+ * so the caller can gate the flush's schedule (the server passes the
368
+ * gateway's onIdle: each tensor step is a blocking GPU sync on the decode
369
+ * stream + a synchronous multi-MB writeSync, and the old unconditional
370
+ * setImmediate pacing interleaved those slices exactly between decode
371
+ * tokens — the 2026-07-07 decode@ctx contamination. A request arriving
372
+ * MID-flush pauses the remaining tensors until idle again). Gate failures
373
+ * are swallowed: scheduling advice must never corrupt the write path
374
+ * (an early generator close inside the fd-open section would leak the fd). */
375
+ export async function saveKvCacheAsync(
376
+ path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta = {},
377
+ waitTurn?: () => Promise<void>,
378
+ ): Promise<void> {
379
+ const steps = saveKvCacheSteps(path, tokens, caches, meta);
380
+ while (true) {
381
+ if (waitTurn) await waitTurn().catch(() => {});
382
+ if (steps.next().done) break;
383
+ await new Promise<void>((r) => setImmediate(r));
384
+ }
385
+ }
386
+
387
+ /** One pending write-behind item: an entry's copied tokens + OWNED
388
+ * zero-copy cache clones (the queue disposes them on every exit path). */
389
+ export interface SpillItem {
390
+ tokens: number[];
391
+ caches: Cache[];
392
+ ns: string;
393
+ }
394
+
395
+ /** Bounded write-behind queue (2026-07-07 post-merge review fix).
396
+ *
397
+ * Pending spill/snapshot clones pin their entries' GPU buffers until the
398
+ * idle-gated flush gets a turn; the old bare promise chain queued them
399
+ * WITHOUT BOUND, so under sustained traffic (gate starved, evictions
400
+ * ongoing) resident memory = prompt-cache cap + every queued clone —
401
+ * allocator pressure exactly under the load that caused the evictions.
402
+ *
403
+ * Policy: queued bytes are capped. Over cap, the OLDEST not-in-flight
404
+ * item is dropped and its clones disposed immediately — cache semantics
405
+ * (a dropped spill is a future cache miss, never a wrong result; the
406
+ * contention-free alternative to letting the flush cut into decode).
407
+ * The item being enqueued is never its own victim (soft cap: one
408
+ * oversized entry may exceed the cap alone rather than never spilling).
409
+ * Items run strictly serially in enqueue order via an internal chain;
410
+ * store failures are swallowed (cold tier is best-effort) but clones
411
+ * are disposed on every settle path. */
412
+ type SpillRec = SpillItem & { bytes: number; dropped: boolean };
413
+
414
+ export class SpillQueue {
415
+ #queue: SpillRec[] = [];
416
+ #bytes = 0;
417
+ #dropped = 0;
418
+ #inFlight: object | null = null;
419
+ #chain: Promise<void> = Promise.resolve();
420
+
421
+ constructor(
422
+ readonly capBytes: number,
423
+ /** Byte size of a clone set (prompt-cache's cacheBytes). */
424
+ readonly bytesOf: (caches: Cache[]) => number,
425
+ /** The actual write (server passes storeAsync + the idle gate). */
426
+ readonly store: (item: SpillItem) => Promise<unknown>,
427
+ /** Clone disposal — frees the pinned GPU memory. */
428
+ readonly disposeClones: (caches: Cache[]) => void,
429
+ ) {}
430
+
431
+ /** Bytes pinned by queued (not yet flushed) clones. */
432
+ get pendingBytes(): number { return this.#bytes; }
433
+ get pendingCount(): number { return this.#queue.length; }
434
+ /** Spills dropped by the cap since start (each = one future cache miss). */
435
+ get droppedCount(): number { return this.#dropped; }
436
+
437
+ enqueue(item: SpillItem): void {
438
+ const rec = { ...item, bytes: this.bytesOf(item.caches), dropped: false };
439
+ this.#queue.push(rec);
440
+ this.#bytes += rec.bytes;
441
+ while (this.#bytes > this.capBytes) {
442
+ const victim = this.#queue.find(
443
+ (p) => !p.dropped && p !== this.#inFlight && p !== rec,
444
+ );
445
+ if (!victim) break; // only the new item (or in-flight) left — soft cap
446
+ this.#drop(victim);
447
+ }
448
+ this.#chain = this.#chain.then(async () => {
449
+ if (rec.dropped) return; // clones already disposed at drop time
450
+ this.#inFlight = rec;
451
+ try {
452
+ await this.store(rec);
453
+ } catch {
454
+ // best-effort tier — the entry simply won't restore
455
+ } finally {
456
+ this.#inFlight = null;
457
+ this.#remove(rec);
458
+ this.disposeClones(rec.caches);
459
+ }
460
+ });
461
+ }
462
+
463
+ /** Settles when everything enqueued so far has flushed or dropped
464
+ * (tests; a future shutdown hook would await this). */
465
+ drain(): Promise<void> { return this.#chain; }
466
+
467
+ #drop(rec: SpillRec): void {
468
+ rec.dropped = true;
469
+ this.#dropped++;
470
+ this.#remove(rec);
471
+ this.disposeClones(rec.caches);
472
+ }
473
+
474
+ #remove(rec: SpillRec): void {
475
+ const i = this.#queue.indexOf(rec);
476
+ if (i >= 0) {
477
+ this.#queue.splice(i, 1);
478
+ this.#bytes -= rec.bytes;
124
479
  }
125
- } finally {
126
- closeSync(fd);
127
480
  }
128
481
  }
129
482
 
130
- /** Read only the header (cheap — for prefix matching across many files). */
483
+ /** Read only the header (cheap — for prefix matching across many files).
484
+ * Always verifies the header hash; throws on any structural mismatch. */
131
485
  export function readKvHeader(path: string): KvFileHeader & { dataStart: number } {
132
486
  const fd = openSync(path, "r");
133
487
  try {
134
- const head = new Uint8Array(MAGIC.length + 8);
135
- require("node:fs").readSync(fd, head, 0, head.length, 0);
488
+ const head = new Uint8Array(PREFIX_LEN);
489
+ readSync(fd, head, 0, head.length, 0);
136
490
  if (new TextDecoder().decode(head.subarray(0, MAGIC.length)) !== MAGIC)
137
- throw new Error(`${path}: not an mlx-bun KV cache file`);
491
+ throw new Error(`${path}: not an mlx-bun KV v2 cache file`);
138
492
  const dv = new DataView(head.buffer);
139
493
  const len = dv.getUint32(MAGIC.length, true);
140
494
  const dataStart = dv.getUint32(MAGIC.length + 4, true);
495
+ const expectHash = dv.getBigUint64(MAGIC.length + 8, true);
141
496
  const body = new Uint8Array(len);
142
- require("node:fs").readSync(fd, body, 0, len, MAGIC.length + 8);
497
+ readSync(fd, body, 0, len, PREFIX_LEN);
498
+ if (BigInt(Bun.hash(body)) !== expectHash)
499
+ throw new Error(`${path}: header hash mismatch (truncated or corrupt)`);
143
500
  const header = JSON.parse(new TextDecoder().decode(body)) as KvFileHeader;
501
+ if (header.formatVersion !== 3)
502
+ throw new Error(`${path}: unsupported formatVersion ${header.formatVersion}`);
144
503
  return { ...header, dataStart };
145
504
  } finally {
146
505
  closeSync(fd);
@@ -149,37 +508,174 @@ export function readKvHeader(path: string): KvFileHeader & { dataStart: number }
149
508
 
150
509
  export interface LoadedKvCache {
151
510
  tokens: number[];
511
+ header: KvFileHeader;
152
512
  caches: Cache[];
153
- /** Keep referenced as long as the caches live. */
154
- mmap: MmapFile;
155
513
  }
156
514
 
157
- export function loadKvCache(path: string, model: Gemma4Model): LoadedKvCache {
515
+ export interface KvLoadExpect {
516
+ /** Reject on metadata mismatch (pass what the server is running). */
517
+ configFingerprint?: string;
518
+ tokenizerHash?: string;
519
+ ns?: string;
520
+ /** Verify every tensor hash before copying it in (off by default,
521
+ * `--ssd-cache-verify` — the hash pass roughly doubles restore reads). */
522
+ verify?: boolean;
523
+ }
524
+
525
+ /** Copy-restore a full-attention KV tensor into STEP-rounded capacity with
526
+ * ≥1 token of slack (what a live mid-generation cache looks like) so the
527
+ * first post-restore updateAndFetch never takes the grow path — the old
528
+ * exactly-offset-sized restore made that first step concat-copy the ENTIRE
529
+ * entry into a fresh buffer. Zero padding is bit-safe: writes land at
530
+ * [offset..) before any read, and attention only reads [:offset+L).
531
+ * Evaluated EAGERLY so the host-copy leaf frees before the next tensor
532
+ * streams in (bounded transient). Plain KVCache only — rotating rings and
533
+ * quantized triples are position-exact layouts. */
534
+ function withStepCapacity(a: MlxArray, offset: number): MlxArray {
535
+ const [B, H, S, D] = a.shape as [number, number, number, number];
536
+ const cap = Math.ceil((offset + 1) / KVCache.STEP) * KVCache.STEP;
537
+ if (S >= cap) return a;
538
+ // Internals drained on throw (2026-07-07 review: a grown.eval() failure
539
+ // orphaned z/grown; `a` stays the CALLER's to free — loadKvCache's
540
+ // pending[] holds it).
541
+ let z: MlxArray | null = null;
542
+ let grown: MlxArray | null = null;
543
+ try {
544
+ z = ops.zeros([B, H, cap, D], a.dtype);
545
+ grown = ops.sliceUpdate(z, a, [0, 0, 0, 0], [B, H, S, D]);
546
+ z.dispose();
547
+ z = null;
548
+ grown.eval();
549
+ a.dispose();
550
+ const out = grown;
551
+ grown = null;
552
+ return out;
553
+ } finally {
554
+ z?.dispose();
555
+ grown?.dispose();
556
+ }
557
+ }
558
+
559
+ /** Reload by STREAMED COPY (see the header note): every tensor is copied
560
+ * into an mlx-owned leaf; the mapping is read-only, its pages dropped
561
+ * per-tensor, and unmapped before returning — the caches own their bytes
562
+ * outright (no dtor contract, no pinned mapping, nothing to retain).
563
+ * `model` is anything with makeCache() — the entry count is validated
564
+ * against the DONOR cache list (model.layers.length was wrong for
565
+ * KV-shared models like e4b, whose makeCache() returns donors only). */
566
+ export function loadKvCache(
567
+ path: string,
568
+ model: { makeCache(): Cache[] },
569
+ expect: KvLoadExpect = {},
570
+ ): LoadedKvCache {
158
571
  const header = readKvHeader(path);
159
- const mmap = MmapFile.open(path, "cow");
572
+ for (const key of ["configFingerprint", "tokenizerHash", "ns"] as const) {
573
+ if (expect[key] !== undefined && header[key] !== expect[key])
574
+ throw new Error(`${path}: ${key} mismatch (file ${header[key]}, expected ${expect[key]})`);
575
+ }
576
+ const proto = model.makeCache();
577
+ const cacheCount = proto.length;
578
+ for (const c of proto) c.dispose();
579
+ if (header.caches.length !== cacheCount)
580
+ throw new Error(`${path}: ${header.caches.length} cached layers but model has ${cacheCount}`);
581
+
582
+ const mmap = MmapFile.open(path, "ro");
160
583
  const dataStart = header.dataStart;
584
+ // Every tensor materialized for the CURRENT entry, drained on any throw
585
+ // (2026-07-07 review: a hash mismatch under --ssd-cache-verify — or any
586
+ // per-tensor failure — orphaned the entry's already-built tensors: the
587
+ // catch below only saw completed caches). Cleared once the entry's cache
588
+ // takes ownership; disposing an array withStepCapacity already consumed
589
+ // is safe (dispose is idempotent).
590
+ const pending: MlxArray[] = [];
591
+ const arr = (slot: TensorSlot): MlxArray => {
592
+ const view = mmap.view(dataStart + slot.off, slot.bytes);
593
+ if (expect.verify && hash64(view) !== slot.hash)
594
+ throw new Error(`${path}: tensor hash mismatch at offset ${slot.off}`);
595
+ // mlx_array_new_data COPIES synchronously — the view only has to
596
+ // outlive this call, so the mapping can be advised/unmapped freely.
597
+ const a = MlxArray.fromBytesCopy(view, slot.shape, slot.dtype as Dtype);
598
+ pending.push(a);
599
+ // Drop the just-read clean pages so the load's transient stays at
600
+ // live entry + one tensor (ALIGN = 16 KiB == the arm64 page size, so
601
+ // every tensor offset is page-aligned by construction).
602
+ const len = Math.min(alignUp(slot.bytes), mmap.size - (dataStart + slot.off));
603
+ mmap.advise(dataStart + slot.off, len, MADV_DONTNEED);
604
+ return a;
605
+ };
606
+ /** withStepCapacity whose (possibly fresh) result is pending-tracked. */
607
+ const grownArr = (slot: TensorSlot, offset: number): MlxArray => {
608
+ const g = withStepCapacity(arr(slot), offset);
609
+ pending.push(g);
610
+ return g;
611
+ };
612
+ const triple = (slots: TensorSlot[], at: number): ops.QuantizedTensor =>
613
+ ({ packed: arr(slots[at]!), scales: arr(slots[at + 1]!), biases: arr(slots[at + 2]!) });
161
614
 
162
615
  const caches: Cache[] = [];
163
- for (const e of header.caches) {
164
- const keys = MlxArray.fromPointer(
165
- mmap.pointer(dataStart + e.kOffset), e.kShape, e.dtype as Dtype,
166
- );
167
- const values = MlxArray.fromPointer(
168
- mmap.pointer(dataStart + e.vOffset), e.vShape, e.dtype as Dtype,
169
- );
170
- if (e.kind === "rotating") {
171
- const c = new RotatingKVCache(e.maxSize!);
172
- c.restoreState(keys, values, e.offset, e.idx!);
173
- caches.push(c);
174
- } else {
175
- const c = new KVCache();
176
- c.restoreState(keys, values, e.offset);
177
- caches.push(c);
616
+ try {
617
+ for (const e of header.caches) {
618
+ const t = e.tensors;
619
+ switch (e.kind) {
620
+ case "kv": {
621
+ const c = new KVCache();
622
+ c.restoreState(
623
+ grownArr(t[0]!, e.offset),
624
+ grownArr(t[1]!, e.offset),
625
+ e.offset,
626
+ );
627
+ caches.push(c);
628
+ break;
629
+ }
630
+ case "rotating": {
631
+ const c = new RotatingKVCache(e.maxSize!);
632
+ c.restoreState(arr(t[0]!), arr(t[1]!), e.offset, e.idx!);
633
+ caches.push(c);
634
+ break;
635
+ }
636
+ case "qkv": {
637
+ const c = new QuantizedKVCache(e.groupSize!, e.bits!);
638
+ c.restoreState(triple(t, 0), triple(t, 3), e.offset);
639
+ caches.push(c);
640
+ break;
641
+ }
642
+ case "rotating-qkv": {
643
+ const c = new RotatingQuantizedKVCache(e.maxSize!, e.groupSize!, e.bits!);
644
+ c.restoreState(triple(t, 0), triple(t, 3), e.offset, e.idx!);
645
+ caches.push(c);
646
+ break;
647
+ }
648
+ case "ssm": {
649
+ const c = new SSMCache();
650
+ c.conv = arr(t[0]!);
651
+ c.recurrent = arr(t[1]!);
652
+ c.offset = e.offset;
653
+ caches.push(c);
654
+ break;
655
+ }
656
+ case "turboquant": {
657
+ const c = new TurboQuantKVCache(e.kBits!, e.vBits!);
658
+ const kv: TurboQuantTensor = {
659
+ kIdx: arr(t[0]!), kScales: arr(t[1]!), kZeros: arr(t[2]!),
660
+ vPacked: arr(t[3]!), vScales: arr(t[4]!),
661
+ };
662
+ c.restoreState(kv, e.offset, e.headDim!);
663
+ caches.push(c);
664
+ break;
665
+ }
666
+ default:
667
+ throw new Error(`${path}: unknown cache kind ${(e as { kind: string }).kind}`);
668
+ }
669
+ // This entry's tensors are now owned by its cache — stop tracking them
670
+ // (the catch must not double-free through both pending AND caches).
671
+ pending.length = 0;
178
672
  }
673
+ } catch (err) {
674
+ for (const a of pending) a.dispose(); // the mid-entry orphans
675
+ for (const c of caches) c.dispose();
676
+ throw err;
677
+ } finally {
678
+ mmap.unmap(); // every tensor was copied out — nothing aliases the file
179
679
  }
180
- if (caches.length !== model.layers.length)
181
- throw new Error(
182
- `${path}: ${caches.length} cached layers but model has ${model.layers.length}`,
183
- );
184
- return { tokens: header.tokens, caches, mmap };
680
+ return { tokens: header.tokens, header, caches };
185
681
  }