mlx-bun 0.0.10 → 0.0.12

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