mlx-bun 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +58 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/investigations/openjarvis-fit.md +198 -0
  5. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  6. package/docs/archive/planning/release-notes-v0.4.0.md +45 -0
  7. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  8. package/docs/design/batching.md +1535 -50
  9. package/docs/design/decode-speed-program.md +394 -1171
  10. package/docs/design/generic-model-support.md +195 -3
  11. package/docs/design/kv-cache.md +538 -225
  12. package/docs/design/speculative-decoding.md +552 -50
  13. package/docs/design/turboquant.md +140 -17
  14. package/docs/design/unified-engine-frontier-plan.md +259 -150
  15. package/docs/planning/PRODUCT_ROADMAP.md +21 -6
  16. package/docs/planning/public-readiness-handoff.md +244 -0
  17. package/docs/planning/public-readiness.md +149 -0
  18. package/docs/reference/benchmarks.md +3537 -1112
  19. package/docs/reference/cli.md +102 -2
  20. package/docs/reference/distribution.md +45 -13
  21. package/docs/reference/environment.md +159 -13
  22. package/docs/reference/library-api.md +18 -4
  23. package/docs/reference/memory.md +1 -1
  24. package/docs/reference/models.md +28 -7
  25. package/docs/reference/server-api.md +257 -33
  26. package/docs/reference/server-config.md +372 -151
  27. package/docs/reference/troubleshooting.md +13 -21
  28. package/package.json +2 -2
  29. package/src/audio/audiotoolbox.ts +112 -0
  30. package/src/audio/conformer.ts +8 -0
  31. package/src/audio/mic-capture.ts +98 -0
  32. package/src/audio/silero-vad.ts +379 -0
  33. package/src/audio/whisper-decode.ts +774 -0
  34. package/src/audio/whisper-fast.ts +732 -0
  35. package/src/audio/whisper-format.ts +73 -0
  36. package/src/audio/whisper-mel.ts +185 -0
  37. package/src/audio/whisper-timing.ts +274 -0
  38. package/src/audio/whisper-tokenizer.ts +239 -0
  39. package/src/audio/whisper-transcribe.ts +410 -0
  40. package/src/backends/mlx/assistant-target.ts +46 -0
  41. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  42. package/src/backends/mlx/autoregressive.ts +22 -1
  43. package/src/backends/mlx/batch-group.ts +464 -236
  44. package/src/backends/mlx/cache-attachment.ts +67 -0
  45. package/src/backends/mlx/cache-layout.ts +31 -0
  46. package/src/backends/mlx/checkpoint-state.ts +53 -0
  47. package/src/backends/mlx/continuation-persistence.ts +107 -0
  48. package/src/backends/mlx/continuation-request.ts +43 -0
  49. package/src/backends/mlx/continuation.ts +77 -0
  50. package/src/backends/mlx/draft-target.ts +7 -22
  51. package/src/backends/mlx/encoder-cache.ts +25 -0
  52. package/src/backends/mlx/fill-append.ts +21 -8
  53. package/src/backends/mlx/fill-group.ts +497 -0
  54. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  55. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  56. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  57. package/src/backends/mlx/gateway-binding.ts +122 -19
  58. package/src/backends/mlx/kv-codec.ts +9 -0
  59. package/src/backends/mlx/kv-maintenance.ts +161 -0
  60. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  61. package/src/backends/mlx/mixed-iteration.ts +62 -0
  62. package/src/backends/mlx/model-prompt.ts +46 -32
  63. package/src/backends/mlx/model-serving.ts +5 -4
  64. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  65. package/src/backends/mlx/prefill-cohort.ts +100 -0
  66. package/src/backends/mlx/prefill-policy.ts +26 -0
  67. package/src/backends/mlx/prefill-rows.ts +244 -0
  68. package/src/backends/mlx/prefill.ts +1 -1
  69. package/src/backends/mlx/projected-context-rows.ts +72 -0
  70. package/src/backends/mlx/prompt-input.ts +29 -0
  71. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  72. package/src/backends/mlx/request-state-policy.ts +54 -0
  73. package/src/backends/mlx/rollback.ts +45 -4
  74. package/src/backends/mlx/row-sampling.ts +49 -0
  75. package/src/backends/mlx/serial-executor.ts +51 -73
  76. package/src/backends/mlx/speculative-group.ts +438 -0
  77. package/src/backends/mlx/speculative-round.ts +161 -0
  78. package/src/backends/mlx/speculative.ts +24 -7
  79. package/src/backends/mlx/state-rows.ts +104 -0
  80. package/src/backends/mlx/state-views.ts +21 -1
  81. package/src/backends/mlx/target-layout-capability.ts +35 -0
  82. package/src/backends/mlx/transitioning-kv-rows.ts +173 -0
  83. package/src/cli.ts +333 -36
  84. package/src/contracts/execution.ts +21 -0
  85. package/src/contracts/object-cache.ts +8 -0
  86. package/src/contracts/pi-provider.ts +1 -1
  87. package/src/contracts/pi.ts +3 -1
  88. package/src/contracts/prefix-cache.ts +33 -0
  89. package/src/contracts/resources.ts +7 -0
  90. package/src/contracts/sampling.ts +23 -0
  91. package/src/contracts/scheduling.ts +27 -0
  92. package/src/engine/execution-plan.ts +38 -25
  93. package/src/engine/execution-tasks.ts +44 -0
  94. package/src/engine/namespaced-cache.ts +13 -0
  95. package/src/engine/resources.ts +12 -1
  96. package/src/engine/scheduler.ts +32 -7
  97. package/src/fill/fill-session.ts +14 -3
  98. package/src/fit.ts +29 -15
  99. package/src/generate.ts +105 -195
  100. package/src/grammar.ts +13 -0
  101. package/src/inference/draft-acceptance.ts +41 -0
  102. package/src/inference/draft-output.ts +49 -0
  103. package/src/inference/prefill.ts +6 -0
  104. package/src/inference/rollback.ts +2 -2
  105. package/src/kv-scheme.ts +92 -12
  106. package/src/kv-store.ts +183 -93
  107. package/src/lab/README.md +5 -1
  108. package/src/lab/paged-kv/paged-attention.ts +117 -0
  109. package/src/lab/paged-kv/paged-kv-rows.ts +111 -0
  110. package/src/lab/paged-kv/paged-kv.ts +126 -46
  111. package/src/md-modules.d.ts +5 -0
  112. package/src/mlx/array.ts +39 -1
  113. package/src/mlx/ffi.ts +19 -0
  114. package/src/mlx/normalized-argmax.ts +116 -0
  115. package/src/mlx/ops.ts +97 -10
  116. package/src/model/artifact-identity.ts +73 -0
  117. package/src/model/batched-kv.ts +235 -0
  118. package/src/model/batched-mask.ts +12 -4
  119. package/src/model/batched-quant.ts +8 -2
  120. package/src/model/batched-quantized-kv.ts +144 -0
  121. package/src/model/batched-rotating-quant.ts +76 -15
  122. package/src/model/batched-rotating-state.ts +76 -0
  123. package/src/model/batched-rotating.ts +64 -22
  124. package/src/model/batched-row-storage.ts +52 -0
  125. package/src/model/batched-ssm.ts +52 -0
  126. package/src/model/batched-turboquant-kv.ts +94 -0
  127. package/src/model/compiled-decode.ts +7 -1
  128. package/src/model/decoded-kv-donor.ts +12 -0
  129. package/src/model/delayed-quantized-kv.ts +72 -0
  130. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  131. package/src/model/delayed-turboquant-kv.ts +57 -0
  132. package/src/model/factory.ts +26 -0
  133. package/src/model/fingerprint.ts +12 -0
  134. package/src/model/full-prefill-padding.ts +56 -0
  135. package/src/model/full-prefill-row.ts +105 -0
  136. package/src/model/gemma4-base.ts +183 -82
  137. package/src/model/gemma4.ts +116 -28
  138. package/src/model/generated/gemma4-12b.ts +4 -1
  139. package/src/model/generated/gemma4-26b.ts +4 -1
  140. package/src/model/generated/gemma4-e4b.ts +4 -1
  141. package/src/model/glm52-cache.ts +80 -4
  142. package/src/model/glm52-mla.ts +12 -6
  143. package/src/model/glm52.ts +19 -40
  144. package/src/model/kv-attention-view.ts +70 -0
  145. package/src/model/minicpm5.ts +11 -7
  146. package/src/model/profile.ts +29 -4
  147. package/src/model/quantized-append-attention.ts +31 -0
  148. package/src/model/qwen3-delta-state.ts +74 -0
  149. package/src/model/qwen3-delta.ts +59 -36
  150. package/src/model/qwen3-mrope.ts +7 -9
  151. package/src/model/qwen3_5.ts +177 -75
  152. package/src/model/rotating-kv-donor.ts +35 -0
  153. package/src/model/rotating-kv-layout.ts +262 -0
  154. package/src/model/rotating-row-transaction.ts +35 -0
  155. package/src/model/speculative-rotating-kv.ts +101 -0
  156. package/src/model/ssm-prefill-padding.ts +39 -0
  157. package/src/model/support.ts +8 -0
  158. package/src/model/token-groups.ts +66 -0
  159. package/src/model/trellis-linear.ts +4 -4
  160. package/src/model/turboquant-codec.ts +89 -0
  161. package/src/model/universal/dense.ts +14 -13
  162. package/src/model/whisper.ts +435 -0
  163. package/src/native/mic_capture.swift +92 -0
  164. package/src/native-pack.ts +6 -5
  165. package/src/pi-web.ts +9 -1
  166. package/src/preflight.ts +31 -15
  167. package/src/prompt-cache.ts +306 -86
  168. package/src/runtime-config.ts +1 -5
  169. package/src/safetensors.ts +9 -2
  170. package/src/sampler.ts +37 -25
  171. package/src/serve/audio-routes.ts +323 -0
  172. package/src/serve/chat-request.ts +2 -0
  173. package/src/serve/chat-stage.ts +19 -14
  174. package/src/serve/completion-executor.ts +9 -1
  175. package/src/serve/discovery-routes.ts +17 -1
  176. package/src/serve/generated-token-history.ts +57 -0
  177. package/src/serve/generation-gateway.ts +88 -38
  178. package/src/serve/model-binding.ts +7 -1
  179. package/src/serve/model-host.ts +3 -4
  180. package/src/serve/preparation.ts +3 -3
  181. package/src/serve/prompt-response-trace.ts +14 -0
  182. package/src/serve/request-plan.ts +6 -3
  183. package/src/serve/request-prep.ts +40 -25
  184. package/src/serve/text-completion-stage.ts +2 -2
  185. package/src/serve/transcription-server.ts +64 -0
  186. package/src/serve/transcription-service.ts +502 -0
  187. package/src/server.ts +185 -261
  188. package/src/spec/assistant-rows.ts +93 -0
  189. package/src/spec/assistant-source.ts +28 -18
  190. package/src/spec/cache-identity.ts +22 -0
  191. package/src/spec/deepspec-rows.ts +29 -0
  192. package/src/spec/deepspec-source.ts +12 -10
  193. package/src/spec/dflash-source.ts +28 -4
  194. package/src/spec/drafter.ts +153 -142
  195. package/src/spec/dspark/deepspec-module.ts +95 -120
  196. package/src/spec/dspark/module-dflash.ts +126 -170
  197. package/src/spec/glm52-mtp-graph.ts +29 -0
  198. package/src/spec/glm52-mtp-rows.ts +132 -0
  199. package/src/spec/glm52-mtp-source.ts +54 -136
  200. package/src/spec/glm52-mtp-state.ts +33 -0
  201. package/src/spec/ngram-source.ts +115 -21
  202. package/src/spec/projected-draft-rows.ts +73 -0
  203. package/src/spec/qwen-mtp-module.ts +171 -0
  204. package/src/spec/qwen-mtp-rows.ts +230 -0
  205. package/src/spec/qwen-mtp-source.ts +84 -226
  206. package/src/spec/qwen-mtp-state.ts +30 -0
  207. package/src/spec/serve-loop.ts +73 -69
  208. package/src/spec/source.ts +93 -15
  209. package/src/spec/two-model-rows.ts +62 -0
  210. package/src/spec/two-model.ts +45 -103
  211. package/src/ssd-cache.ts +107 -36
  212. package/src/ssd-durability.ts +29 -27
  213. package/src/storage/host-buffer.ts +24 -0
  214. package/src/storage/kv-writer.ts +83 -0
  215. package/src/storage/kv-writer.worker.js +264 -0
  216. package/src/storage/retention-policy.ts +37 -0
  217. package/src/tiered-prompt-cache.ts +45 -0
  218. package/src/vision/embedder.ts +8 -0
  219. package/src/vision/prompt.ts +101 -65
  220. package/src/vision/qwen3vl-prompt.ts +101 -64
  221. package/src/vision/qwen3vl-tower.ts +11 -0
  222. package/src/vision/siglip.ts +8 -0
  223. package/src/web/app.html +12 -0
  224. package/src/web/app.js +217 -57
  225. package/src/web/src/chat.ts +4 -1
  226. package/src/web/src/composer.ts +3 -0
  227. package/src/web/src/voice.ts +141 -0
  228. package/src/web/sw.js +1 -1
  229. package/src/backends/mlx/memory-guard.ts +0 -40
  230. package/src/spec/prefix-state.ts +0 -45
  231. /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
@@ -16,13 +16,15 @@
16
16
  import { MlxArray } from "../mlx/array";
17
17
  import * as ops from "../mlx/ops";
18
18
  import { clearCache } from "../mlx/ffi";
19
- import { flagOn } from "../runtime-config";
19
+ import { runtimeFlag } from "../runtime-config";
20
20
  import { loadModelConfig } from "../config";
21
21
  import { Weights } from "../weights";
22
22
  import { createModel, type RuntimeModel } from "../model/factory";
23
- import type { Cache } from "../model/gemma4";
24
- import { toLogprobs } from "../sampler";
25
- import type { DraftProvider, DraftSource } from "./source";
23
+ import type { DraftProvider, DraftSource, GroupedDraftProvider } from "./source";
24
+ import { standaloneDraftGroups, openStandaloneDraftRows } from "./two-model-rows";
25
+ import { bindLegacyAutoregressiveModel } from "../backends/mlx/autoregressive";
26
+ import { artifactIdentity } from "../model/artifact-identity";
27
+ import { configFingerprint } from "../model/fingerprint";
26
28
 
27
29
  /** mlx-lm's prefill_step_size — the draft drain chunks at the same stride
28
30
  * as the target's (serve-loop.ts); chunking is numerically exact for
@@ -32,13 +34,16 @@ const PREFILL_CHUNK = 2048;
32
34
  export class TwoModelProvider implements DraftProvider {
33
35
  readonly id: string;
34
36
  readonly weightsBytes: number;
37
+ readonly grouped: GroupedDraftProvider;
35
38
 
36
39
  private constructor(
37
40
  readonly model: RuntimeModel,
38
41
  private readonly weights: Weights,
39
42
  id: string,
43
+ private readonly namespace: string,
40
44
  ) {
41
45
  this.id = id;
46
+ this.grouped = standaloneDraftGroups(bindLegacyAutoregressiveModel(model), namespace);
42
47
  this.weightsBytes = [...weights.shards.files.values()].reduce(
43
48
  (a, f) => a + f.mmap.size,
44
49
  0,
@@ -51,20 +56,25 @@ export class TwoModelProvider implements DraftProvider {
51
56
  static async load(modelDir: string, targetVocabSize?: number): Promise<TwoModelProvider> {
52
57
  const config = await loadModelConfig(modelDir);
53
58
  const weights = await Weights.open(modelDir);
54
- const model = createModel(weights, config);
55
- if (
56
- targetVocabSize !== undefined &&
57
- config.text.vocabSize !== targetVocabSize
58
- )
59
- console.warn(
60
- `draft model vocab size ${config.text.vocabSize} != target ${targetVocabSize} — ` +
61
- `speculation will accept nothing useful (mlx-lm warns identically)`,
62
- );
63
- return new TwoModelProvider(model, weights, modelDir.split("/").filter(Boolean).at(-1)!);
59
+ try {
60
+ const model = createModel(weights, config);
61
+ if (
62
+ targetVocabSize !== undefined &&
63
+ config.text.vocabSize !== targetVocabSize
64
+ )
65
+ console.warn(
66
+ `draft model vocab size ${config.text.vocabSize} != target ${targetVocabSize} — ` +
67
+ `speculation will accept nothing useful (mlx-lm warns identically)`,
68
+ );
69
+ const identity = await artifactIdentity(configFingerprint(config),
70
+ [...weights.shards.files].map(([name, shard]) => ({ name, path: shard.path })));
71
+ return new TwoModelProvider(model, weights, modelDir.split("/").filter(Boolean).at(-1)!,
72
+ `standalone-draft-v1:${identity}`);
73
+ } catch (error) { weights.dispose(); throw error; }
64
74
  }
65
75
 
66
76
  open(opts: Parameters<DraftProvider["open"]>[0]): DraftSource {
67
- return new TwoModelSource(this.model, opts.sampler);
77
+ return new TwoModelSource(this.model, opts.sampler, this.namespace);
68
78
  }
69
79
 
70
80
  dispose(): void {
@@ -72,100 +82,32 @@ export class TwoModelProvider implements DraftProvider {
72
82
  }
73
83
  }
74
84
 
85
+ /** Compatibility adapter: the existing request loop also uses the shared
86
+ * draft graph at B=1. Proposal generation and state recovery have one owner. */
75
87
  class TwoModelSource implements DraftSource {
76
- readonly weightsBytes = 0; // provider-owned weights; per-request adds caches only
77
- private caches: Cache[];
78
-
79
- constructor(
80
- private readonly model: RuntimeModel,
81
- private readonly sampler: (lp: MlxArray, step: number) => MlxArray,
82
- ) {
83
- this.caches = this.model.makeCache();
88
+ readonly weightsBytes = 0;
89
+ private readonly rows: ReturnType<typeof openStandaloneDraftRows>;
90
+ private processed = 0;
91
+ constructor(model: RuntimeModel, sampler: (lp: MlxArray, step: number) => MlxArray, namespace: string) {
92
+ this.rows = openStandaloneDraftRows(bindLegacyAutoregressiveModel(model), namespace,
93
+ { sample: (logprobs, steps) => sampler(logprobs, steps[0]!) }, [null]);
84
94
  }
85
-
86
- prefill(promptIds: number[]): void {
87
- // Oracle convention (mlx-lm speculative_generate_step._prefill, re-anchored
88
- // 2026-07-07): the DRAFT model also drains only to len-1 (`while y.size >
89
- // 1`) — the last prompt token stays unprocessed and is consumed by the
90
- // FIRST draft() round's feed ([pending] = that same token under the serve
91
- // loop's oracle shape), mirroring _draft_generate's first _step. Draft-side
92
- // full-prefill was the residual knife-edge flipper in the 2026-07-07 live
93
- // oracle gate (γ=2 haiku cell). MLX_BUN_PREFILL_TAIL_SPLIT=0 reverts.
94
- // (mlx-lm chunks its drain at prefill_step_size=2048; chunking is
95
- // numerically exact for causal attention — same KV, same positions —
96
- // so we chunk too. Pre-2026-07-07 this was a SINGLE forward of the
97
- // whole head: nothing enforced the "<2048 serve regime" the old
98
- // comment assumed, and a 32k prompt through the spec lane ran one
99
- // 32k-position draft forward — a large activation/mask transient on a
100
- // 24 GB box, and untested numerics vs the chunked oracle.)
101
- const tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
95
+ async prefill(promptIds: number[]): Promise<void> {
96
+ const tailSplit = runtimeFlag("MLX_BUN_PREFILL_TAIL_SPLIT", true);
102
97
  const upTo = tailSplit && promptIds.length > 1 ? promptIds.length - 1 : promptIds.length;
103
- for (let at = 0; at < upTo; at += PREFILL_CHUNK) {
98
+ for (let at = this.processed; at < upTo; at += PREFILL_CHUNK) {
104
99
  const end = Math.min(at + PREFILL_CHUNK, upTo);
105
- const ids = ops.fromInt32(promptIds.slice(at, end), [1, end - at]);
106
- const h = this.model.forwardHidden(ids, this.caches);
107
- ids.dispose();
108
- // The head's last-position logits are not needed: the serve loop's
109
- // first `feed` is consumed at the top of draft().
110
- h.dispose();
111
- clearCache();
112
- }
113
- }
114
-
115
- /** One forward over `feed`, sample the first draft from its last position,
116
- * then n-1 single-token steps — mirrors _draft_generate's autoregressive
117
- * chain (with async_eval pipelining upstream; eager here, the draft is
118
- * small and the serve loop overlaps nothing with it in v1). */
119
- draft(feed: number[], n: number, stepBase: number): number[] {
120
- const drafts: number[] = [];
121
- let input = feed;
122
- for (let k = 0; k < n; k++) {
123
- const ids = ops.fromInt32(input, [1, input.length]);
124
- const h = this.model.forwardHidden(ids, this.caches);
125
- ids.dispose();
126
- const L = h.shape[1]!;
127
- const H = h.shape[2]!;
128
- const hLast = h.slice([0, L - 1, 0], [1, L, H]);
129
- h.dispose();
130
- const logits = this.model.logitsFromHidden(hLast);
131
- hLast.dispose();
132
- const V = logits.shape[logits.shape.length - 1]!;
133
- const flat = ops.reshape(logits, [1, V]);
134
- logits.dispose();
135
- const lp = toLogprobs(flat);
136
- flat.dispose();
137
- const tokArr = this.sampler(lp, stepBase + k);
138
- lp.dispose();
139
- const tok = ops.itemUint32(tokArr);
140
- tokArr.dispose();
141
- drafts.push(tok);
142
- input = [tok];
100
+ using ids = ops.fromInt32(promptIds.slice(at, end), [1, end - at]);
101
+ await this.rows.prefill(ids); clearCache();
143
102
  }
144
- return drafts;
103
+ this.processed = upTo;
145
104
  }
146
-
147
- commit(n: number, kAccept: number): void {
148
- const rewind = Math.max(n - kAccept - 1, 0);
149
- if (rewind > 0) {
150
- for (const c of this.caches) {
151
- if (!c.isTrimmable()) {
152
- // The serve loop stops speculating on the TARGET-side trim check
153
- // before this can matter; a wrapped draft ring here just means the
154
- // source is done being useful. Recreate is the safe recovery.
155
- this.resetCaches();
156
- return;
157
- }
158
- c.trim(rewind);
159
- }
160
- }
161
- }
162
-
163
- private resetCaches(): void {
164
- for (const c of this.caches) c.dispose();
165
- this.caches = this.model.makeCache();
105
+ async draft(feed: number[], count: number, step: number): Promise<number[]> {
106
+ return (await this.rows.draft([feed.at(-1)!], count, [step]))[0]!;
166
107
  }
167
-
168
- dispose(): void {
169
- for (const c of this.caches) c.dispose();
108
+ async commit(_drafted: number, accepted: number): Promise<void> {
109
+ await this.rows.commit([accepted]);
110
+ this.processed += accepted + 1;
170
111
  }
112
+ dispose(): void { this.rows.dispose(); }
171
113
  }
package/src/ssd-cache.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { CheckpointAttachment } from "./backends/mlx/checkpoint-state";
1
2
  // SSD cold tier for the prompt/KV cache (docs/design/kv-cache.md).
2
3
  //
3
4
  // Files ARE the database: no sidecar index. Layout
@@ -17,17 +18,20 @@
17
18
  import {
18
19
  existsSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync,
19
20
  } from "node:fs";
20
- import { join } from "node:path";
21
+ import { kvWriter, type KvWriteRequest } from "./storage/kv-writer";
22
+ import { join, dirname } from "node:path";
21
23
  import { randomUUID } from "node:crypto";
22
24
  import { commonPrefixLength } from "./prompt-cache";
23
25
  import {
24
- saveKvCache, saveKvCacheAsync, loadKvCache, readKvHeader,
25
- cacheHeadersTrimmable, legacyCacheCodecs, type CacheCodecProvider,
26
+ saveKvCache, saveKvCacheAsync, loadKvCache, loadKvCacheAsync, readKvHeader,
27
+ cacheHeadersTrimmable, cacheHeadersMinimumReusableOffset, legacyCacheCodecs, type CacheCodecProvider,
26
28
  type KvSaveMeta, type KvLoadExpect, type LoadedKvCache,
27
29
  } from "./kv-store";
28
30
  import type { Cache } from "./model/gemma4-base";
31
+ import { minimumReusableOffset } from "./backends/mlx/state-views";
29
32
 
30
33
  export interface SsdIndexEntry {
34
+ blocks?: Array<{ hash: string; bytes: number }>;
31
35
  path: string;
32
36
  ns: string;
33
37
  tokens: number[];
@@ -39,10 +43,12 @@ export interface SsdIndexEntry {
39
43
  * over a shorter usable one (the 2026-07-06 restart-0 defect: the
40
44
  * [prompt+gen] file always won, always got rejected after restore). */
41
45
  trimmable: boolean;
46
+ minimumReusableOffset?: number;
42
47
  generationCheckpoint?: NonNullable<KvSaveMeta["generationCheckpoint"]>;
43
48
  }
44
49
 
45
50
  export interface SsdStoreOptions {
51
+ storage?: Pick<KvWriteRequest, "layout" | "blockBytes" | "segmented">;
46
52
  codecs?: CacheCodecProvider;
47
53
  dir: string;
48
54
  maxBytes: number;
@@ -62,6 +68,27 @@ export class SsdCacheStore {
62
68
  readonly #codecs: CacheCodecProvider;
63
69
  readonly #root: string; // <dir>/<configFingerprint>
64
70
  #index: SsdIndexEntry[] = [];
71
+ #exact = new Map<string, SsdIndexEntry>();
72
+ #tokenKeys = new WeakMap<number[], string>();
73
+ #key(tokens: number[], ns: string): string {
74
+ let hash = this.#tokenKeys.get(tokens);
75
+ if (!hash) {
76
+ hash = Bun.hash(new Uint32Array(tokens)).toString(16);
77
+ this.#tokenKeys.set(tokens, hash);
78
+ }
79
+ return JSON.stringify([ns, tokens.length, hash]);
80
+ }
81
+ #addIndex(entry: SsdIndexEntry): void {
82
+ this.#index.push(entry);
83
+ this.#exact.set(this.#key(entry.tokens, entry.ns), entry);
84
+ }
85
+ /** Exact checkpoint selection for a known session, independent of residency. */
86
+ findExact(tokens: number[], ns = ""): { entry: SsdIndexEntry; prefixLen: number } | null {
87
+ const entry = this.#exact.get(this.#key(tokens, ns));
88
+ return entry && commonPrefixLength(entry.tokens, tokens) === tokens.length
89
+ ? { entry, prefixLen: tokens.length } : null;
90
+ }
91
+
65
92
  #warnedWriteFailure = false;
66
93
  stats = { restores: 0, spills: 0, restoreMsLast: 0 };
67
94
 
@@ -75,8 +102,16 @@ export class SsdCacheStore {
75
102
  return this.#index.length;
76
103
  }
77
104
 
105
+ /** Header-only provenance for the serving tokenizer; no tensor restore. */
106
+ *tokenPrefixes(): Iterable<readonly number[]> {
107
+ for (const entry of this.#index) yield entry.tokens;
108
+ }
109
+
78
110
  get totalBytes(): number {
79
- return this.#index.reduce((a, e) => a + e.bytes, 0);
111
+ const blocks = new Map<string, number>();
112
+ for (const entry of this.#index) for (const block of entry.blocks ?? [])
113
+ blocks.set(`${dirname(entry.path)}/${block.hash}`, block.bytes);
114
+ return this.#index.reduce((a, e) => a + e.bytes, 0) + [...blocks.values()].reduce((a, b) => a + b, 0);
80
115
  }
81
116
 
82
117
  get maxBytes(): number {
@@ -92,7 +127,7 @@ export class SsdCacheStore {
92
127
  * trimmable descendant is equivalent to an exact boundary snapshot. */
93
128
  hasDurablePrefix(tokens: number[], ns = ""): boolean {
94
129
  return this.#index.some((entry) => {
95
- if (entry.ns !== ns || entry.tokens.length < tokens.length) return false;
130
+ if (entry.ns !== ns || entry.tokens.length < tokens.length || tokens.length < (entry.minimumReusableOffset ?? 0)) return false;
96
131
  if (commonPrefixLength(entry.tokens, tokens) !== tokens.length) return false;
97
132
  return entry.tokens.length === tokens.length || entry.trimmable;
98
133
  });
@@ -102,7 +137,7 @@ export class SsdCacheStore {
102
137
  * headers and metadata mismatches are unlinked; `.tmp` orphans reaped.
103
138
  * Only OUR fingerprint dir is touched. Returns entries indexed. */
104
139
  scan(): number {
105
- this.#index = [];
140
+ this.#index = []; this.#exact.clear();
106
141
  if (!existsSync(this.#root)) return 0;
107
142
  for (const nsDir of readdirSync(this.#root)) {
108
143
  const nsPath = join(this.#root, nsDir);
@@ -127,9 +162,11 @@ export class SsdCacheStore {
127
162
  throw new Error("metadata mismatch");
128
163
  if ((h.codecProvider ?? legacyCacheCodecs.id) !== this.#codecs.id) continue;
129
164
  const st = statSync(path);
130
- this.#index.push({
165
+ this.#addIndex({
166
+ blocks: [...h.caches, ...(h.attachments ?? [])].flatMap(e => e.tensors.flatMap(t => t.blocks ?? [])),
131
167
  path, ns: h.ns ?? "", tokens: h.tokens, bytes: st.size, mtimeMs: st.mtimeMs,
132
- trimmable: cacheHeadersTrimmable(h.caches, this.#codecs),
168
+ trimmable: !h.attachments?.length && cacheHeadersTrimmable(h.caches, this.#codecs),
169
+ minimumReusableOffset: cacheHeadersMinimumReusableOffset(h.caches),
133
170
  ...(h.generationCheckpoint
134
171
  ? { generationCheckpoint: h.generationCheckpoint }
135
172
  : {}),
@@ -139,6 +176,7 @@ export class SsdCacheStore {
139
176
  }
140
177
  }
141
178
  }
179
+ void kvWriter.collect(this.#root).catch(() => {});
142
180
  return this.#index.length;
143
181
  }
144
182
 
@@ -157,7 +195,7 @@ export class SsdCacheStore {
157
195
  for (const e of this.#index) {
158
196
  if (e.ns !== ns) continue;
159
197
  const p = Math.min(commonPrefixLength(e.tokens, prompt), prompt.length - 1);
160
- if (p === 0 || p <= bestLen) continue;
198
+ if (p === 0 || p <= bestLen || p < (e.minimumReusableOffset ?? 0)) continue;
161
199
  if (e.tokens.length - p > 0 && !e.trimmable) continue;
162
200
  bestLen = p;
163
201
  best = e;
@@ -194,6 +232,23 @@ export class SsdCacheStore {
194
232
  }
195
233
  }
196
234
 
235
+ /** Read on the dedicated CPU queue, then let the model codec adopt state. */
236
+ async restoreAsync(entry: SsdIndexEntry, model: { makeCache(): Cache[] }): Promise<LoadedKvCache | null> {
237
+ const t0 = performance.now();
238
+ try {
239
+ const loaded = await loadKvCacheAsync(entry.path, model, {
240
+ ...this.#meta(entry.ns), verify: this.#opts.verify,
241
+ }, this.#codecs);
242
+ const now = new Date();
243
+ try { utimesSync(entry.path, now, now); entry.mtimeMs = now.getTime(); } catch {}
244
+ this.stats.restores++; this.stats.restoreMsLast = performance.now() - t0;
245
+ return loaded;
246
+ } catch (error) {
247
+ console.warn(`[ssd-cache] restore failed, dropping ${entry.path}: ${error}`);
248
+ this.remove(entry.path); return null;
249
+ }
250
+ }
251
+
197
252
  /** Persist an entry. Synchronous (the tier calls it on the idle serial
198
253
  * lane between requests); atomic via kv-store's tmp+fsync+rename. An
199
254
  * entry bigger than the cap is refused; disk/write failure is a warn-once
@@ -201,13 +256,13 @@ export class SsdCacheStore {
201
256
  * the same ns whose tokens are a PREFIX of the new entry are superseded
202
257
  * (the agent-conversation pattern: one growing entry, not N generations
203
258
  * of it). Returns true if stored. */
204
- store(tokens: number[], caches: Cache[], ns = ""): boolean {
259
+ store(tokens: number[], caches: Cache[], ns = "", attachments?: CheckpointAttachment[]): boolean {
205
260
  const dir = join(this.#root, nsHash(ns));
206
261
  const path = join(dir, `${randomUUID()}.mlxkv`);
207
262
  try {
208
263
  mkdirSync(dir, { recursive: true });
209
- saveKvCache(path, tokens, caches, this.#meta(ns), this.#codecs);
210
- return this.#indexStored(path, tokens, caches, ns);
264
+ saveKvCache(path, tokens, caches, { ...this.#meta(ns), attachments }, this.#codecs);
265
+ return this.#indexStored(path, tokens, caches, ns, undefined, attachments);
211
266
  } catch (err) {
212
267
  return this.#storeFailed(path, err);
213
268
  }
@@ -218,19 +273,18 @@ export class SsdCacheStore {
218
273
  * yields the event loop between tensors so serving interleaves.
219
274
  * Caller passes zero-copy CLONES it owns (a consistent snapshot no
220
275
  * matter what the live entry does meanwhile) and disposes them after.
221
- * `runStep` owns every per-tensor step (see saveKvCacheAsync) — the
222
- * server passes the gateway's exclusive runner so a request cannot start
223
- * between an idle check and the blocking MLX readback. */
276
+ * `runStep` optionally wraps owner-thread snapshot preparation. Hashing,
277
+ * packing and disk writes execute on the CPU worker. */
224
278
  async storeAsync(
225
279
  tokens: number[], caches: Cache[], ns = "",
226
280
  runStep?: <T>(step: () => T) => Promise<T>,
281
+ attachments?: CheckpointAttachment[],
227
282
  ): Promise<boolean> {
228
283
  const dir = join(this.#root, nsHash(ns));
229
284
  const path = join(dir, `${randomUUID()}.mlxkv`);
230
285
  try {
231
- mkdirSync(dir, { recursive: true });
232
- await saveKvCacheAsync(path, tokens, caches, this.#meta(ns), runStep, this.#codecs);
233
- return this.#indexStored(path, tokens, caches, ns);
286
+ await saveKvCacheAsync(path, tokens, caches, { ...this.#meta(ns), attachments }, runStep, this.#codecs, this.#opts.storage);
287
+ return this.#indexStored(path, tokens, caches, ns, undefined, attachments);
234
288
  } catch (err) {
235
289
  return this.#storeFailed(path, err);
236
290
  }
@@ -260,25 +314,30 @@ export class SsdCacheStore {
260
314
  * its rename succeeds, so a crash during a write leaves the prior point. */
261
315
  async storeGenerationCheckpoint(
262
316
  tokens: number[], caches: Cache[], checkpoint: NonNullable<KvSaveMeta["generationCheckpoint"]>,
317
+ runStep?: <T>(step: () => T) => Promise<T>,
263
318
  ): Promise<boolean> {
264
319
  const ns = `__generation_checkpoint__:${checkpoint.key}`;
265
320
  const dir = join(this.#root, nsHash(ns));
266
321
  const path = join(dir, `${randomUUID()}.mlxkv`);
267
322
  try {
268
- mkdirSync(dir, { recursive: true });
323
+ if (runStep) await runStep(() => mkdirSync(dir, { recursive: true }));
324
+ else mkdirSync(dir, { recursive: true });
269
325
  await saveKvCacheAsync(path, tokens, caches, {
270
326
  ...this.#meta(ns), generationCheckpoint: checkpoint,
271
- }, undefined, this.#codecs);
272
- const stored = this.#indexStored(path, tokens, caches, ns, checkpoint);
273
- if (stored) {
274
- for (const entry of [...this.#index]) {
275
- if (entry.path === path) continue;
276
- if (entry.generationCheckpoint?.key === checkpoint.key) this.remove(entry.path);
327
+ }, runStep, this.#codecs, this.#opts.storage);
328
+ const index = () => {
329
+ const stored = this.#indexStored(path, tokens, caches, ns, checkpoint);
330
+ if (stored) {
331
+ for (const entry of [...this.#index]) {
332
+ if (entry.path === path) continue;
333
+ if (entry.generationCheckpoint?.key === checkpoint.key) this.remove(entry.path);
334
+ }
277
335
  }
278
- }
279
- return stored;
336
+ return stored;
337
+ };
338
+ return runStep ? await runStep(index) : index();
280
339
  } catch (err) {
281
- return this.#storeFailed(path, err);
340
+ return runStep ? await runStep(() => this.#storeFailed(path, err)) : this.#storeFailed(path, err);
282
341
  }
283
342
  }
284
343
 
@@ -299,14 +358,19 @@ export class SsdCacheStore {
299
358
  #indexStored(
300
359
  path: string, tokens: number[], caches: Cache[], ns: string,
301
360
  generationCheckpoint?: NonNullable<KvSaveMeta["generationCheckpoint"]>,
361
+ attachments?: CheckpointAttachment[],
302
362
  ): boolean {
303
363
  const st = statSync(path);
304
- if (st.size > this.#opts.maxBytes) {
305
- rmSync(path, { force: true });
306
- console.warn(`[ssd-cache] entry not stored: ${st.size} bytes exceeds the ${this.#opts.maxBytes}-byte cap`);
364
+ const header = readKvHeader(path);
365
+ const blocks = [...header.caches, ...(header.attachments ?? [])].flatMap(e => e.tensors.flatMap(t => t.blocks ?? []));
366
+ const unique = new Map(blocks.map(block => [block.hash, block.bytes]));
367
+ const entryBytes = st.size + [...unique.values()].reduce((a, b) => a + b, 0);
368
+ if (entryBytes > this.#opts.maxBytes) {
369
+ this.remove(path);
370
+ console.warn(`[ssd-cache] entry not stored: ${entryBytes} bytes exceeds the ${this.#opts.maxBytes}-byte cap`);
307
371
  return false;
308
372
  }
309
- const trimmable = caches.every((c) => c.isTrimmable());
373
+ const trimmable = !attachments?.length && caches.every((c) => c.isTrimmable());
310
374
  // Exact duplicates (same tokens) are replaced regardless of
311
375
  // trimmability — the new file serves exactly the old one's matches.
312
376
  for (const e of [...this.#index]) {
@@ -321,12 +385,13 @@ export class SsdCacheStore {
321
385
  // unrestorable one landed.
322
386
  if (trimmable) {
323
387
  for (const e of [...this.#index]) {
324
- if (e.ns !== ns || e.tokens.length >= tokens.length) continue;
388
+ if (e.ns !== ns || e.tokens.length >= tokens.length || e.tokens.length < minimumReusableOffset(caches)) continue;
325
389
  if (commonPrefixLength(e.tokens, tokens) === e.tokens.length) this.remove(e.path);
326
390
  }
327
391
  }
328
- this.#index.push({
329
- path, ns, tokens, bytes: st.size, mtimeMs: Date.now(), trimmable,
392
+ this.#addIndex({
393
+ blocks, path, ns, tokens, bytes: st.size, mtimeMs: Date.now(), trimmable,
394
+ minimumReusableOffset: minimumReusableOffset(caches),
330
395
  ...(generationCheckpoint ? { generationCheckpoint } : {}),
331
396
  });
332
397
  this.stats.spills++;
@@ -335,7 +400,8 @@ export class SsdCacheStore {
335
400
  }
336
401
 
337
402
  #storeFailed(path: string, err: unknown): boolean {
338
- try { rmSync(path, { force: true }); rmSync(`${path}.tmp`, { force: true }); } catch {}
403
+ this.remove(path);
404
+ try { rmSync(`${path}.tmp`, { force: true }); } catch {}
339
405
  if (!this.#warnedWriteFailure) {
340
406
  this.#warnedWriteFailure = true;
341
407
  console.warn(`[ssd-cache] store failed (disk full or unwritable?) — cold tier disabled for this entry: ${(err as Error).message}`);
@@ -355,6 +421,11 @@ export class SsdCacheStore {
355
421
 
356
422
  remove(path: string): void {
357
423
  try { rmSync(path, { force: true }); } catch {}
424
+ for (const entry of this.#index) if (entry.path === path) {
425
+ const key = this.#key(entry.tokens, entry.ns);
426
+ if (this.#exact.get(key) === entry) this.#exact.delete(key);
427
+ }
358
428
  this.#index = this.#index.filter((e) => e.path !== path);
429
+ void kvWriter.collect(this.#root).catch(() => {});
359
430
  }
360
431
  }
@@ -1,12 +1,9 @@
1
+ import { cloneAttachments } from "./backends/mlx/checkpoint-state";
2
+ import { cleanupFailure, disposeResources } from "./engine/resources";
1
3
  import type { Cache } from "./model/gemma4";
2
4
  import type { PromptCacheEntry } from "./prompt-cache";
3
5
  import type { SpillItem, SpillQueue } from "./kv-store";
4
6
 
5
- export interface DurabilityGateway {
6
- readonly busy: boolean;
7
- runExclusive<T>(fn: () => Promise<T>): Promise<T>;
8
- }
9
-
10
7
  export interface DurabilityPromptCache {
11
8
  findExact(tokens: number[], ns?: string): PromptCacheEntry | null;
12
9
  }
@@ -48,21 +45,23 @@ export class SsdDurabilityCoordinator {
48
45
  #flush: Promise<DurabilityFlushResult> | null = null;
49
46
 
50
47
  constructor(
51
- readonly gateway: DurabilityGateway,
52
48
  readonly promptCache: DurabilityPromptCache,
53
49
  readonly spillQueue: SpillQueue,
54
50
  readonly cloneCaches: (caches: Cache[]) => Cache[],
55
51
  readonly isAlreadyDurable: (tokens: number[], ns: string) => boolean = () => false,
56
- readonly debounceMs = 1_000,
57
- readonly busyRetryMs = 5_000,
52
+ readonly debounceMs = 0,
53
+ readonly retryMs = 5_000,
54
+ readonly onStored: () => void = () => {},
58
55
  ) {}
59
56
 
60
- schedule(tokens: number[], ns = ""): void {
61
- if (tokens.length === 0) return;
57
+ schedule(tokens: number[], ns = "", replace = true): void {
58
+ // Exact tensor objects use an empty history and still need persistence.
59
+ if (tokens.length === 0 && !this.promptCache.findExact(tokens, ns)?.attachments?.length) return;
62
60
  // PromptCache can hold unrelated entries with the same namespace and
63
61
  // length. Include the tokens so one conversation cannot cancel another
64
62
  // conversation's pending durability record.
65
63
  const key = `${ns.length}:${ns}:${tokens.join(",")}`;
64
+ if (!replace && this.#dirty.has(key)) return;
66
65
  const rec: DirtySnapshot = { key, tokens: [...tokens], ns };
67
66
  this.#dirty.set(key, rec);
68
67
  this.#arm(key, this.debounceMs);
@@ -106,10 +105,6 @@ export class SsdDurabilityCoordinator {
106
105
 
107
106
  const rec = this.#dirty.get(key);
108
107
  if (!rec) return "stored";
109
- if (!force && this.gateway.busy) {
110
- this.#arm(key, this.busyRetryMs);
111
- return "failed";
112
- }
113
108
 
114
109
  const task = this.#store(rec);
115
110
  this.#attempts.set(key, task);
@@ -118,7 +113,7 @@ export class SsdDurabilityCoordinator {
118
113
  if (outcome === "stored" && this.#dirty.get(key) === rec)
119
114
  this.#dirty.delete(key);
120
115
  if (outcome !== "stored" && !force && this.#dirty.get(key) === rec)
121
- this.#arm(key, this.busyRetryMs);
116
+ this.#arm(key, this.retryMs);
122
117
  return outcome;
123
118
  } finally {
124
119
  if (this.#attempts.get(key) === task) this.#attempts.delete(key);
@@ -128,22 +123,28 @@ export class SsdDurabilityCoordinator {
128
123
  async #store(rec: DirtySnapshot): Promise<StoreOutcome> {
129
124
  let snap: SpillItem | null = null;
130
125
  try {
131
- snap = await this.gateway.runExclusive(async () => {
126
+ snap = (() => {
132
127
  if (this.isAlreadyDurable(rec.tokens, rec.ns)) return null;
133
128
  const entry = this.promptCache.findExact(rec.tokens, rec.ns);
134
129
  if (!entry) return null;
135
- return {
136
- tokens: [...entry.tokens],
137
- caches: this.cloneCaches(entry.caches),
138
- ns: rec.ns,
139
- };
140
- });
130
+ const caches = this.cloneCaches(entry.caches);
131
+ try {
132
+ return { tokens: [...entry.tokens], caches, ns: rec.ns,
133
+ attachments: cloneAttachments(entry.attachments) };
134
+ } catch (error) {
135
+ return cleanupFailure(error, () => disposeResources(caches));
136
+ }
137
+ })();
141
138
  } catch {
142
139
  return "failed";
143
140
  }
144
141
  if (!snap)
145
142
  return this.isAlreadyDurable(rec.tokens, rec.ns) ? "stored" : "missing";
146
- return await this.spillQueue.enqueue(snap) ? "stored" : "failed";
143
+ const stored = await this.spillQueue.enqueue(snap);
144
+ // The queue releases its views before this continuation runs. Residency
145
+ // policy now sees memory without the completed writer's ownership.
146
+ if (stored) this.onStored();
147
+ return stored ? "stored" : "failed";
147
148
  }
148
149
 
149
150
  async #flushInner(started: number): Promise<DurabilityFlushResult> {
@@ -155,12 +156,13 @@ export class SsdDurabilityCoordinator {
155
156
  const droppedBefore = this.spillQueue.droppedCount;
156
157
  const failedBefore = this.spillQueue.failedCount;
157
158
 
158
- if (this.#attempts.size > 0)
159
- await Promise.allSettled([...this.#attempts.values()]);
159
+ if (this.#attempts.size > 0) {
160
+ const results = await Promise.allSettled([...this.#attempts.values()]);
161
+ flushedSnapshots += results.filter(result => result.status === "fulfilled" && result.value === "stored").length;
162
+ }
160
163
  await this.spillQueue.drain();
161
164
 
162
- // Flush one entry at a time. This prevents the queue cap from dropping a
163
- // boundary snapshot while a large final snapshot is already in flight.
165
+ // Flush dirty entries serially without a second queue of snapshots.
164
166
  // Attempt each record version once. Missing state stays dirty, so a
165
167
  // second flush cannot report success without a real durable snapshot.
166
168
  const attempted = new Set<DirtySnapshot>();
@@ -0,0 +1,24 @@
1
+ import { dlopen, ptr, read, type Pointer } from "bun:ffi";
2
+
3
+ // Page alignment permits Metal to use the restored allocation directly.
4
+ // The destructor is native free, so final release may occur on any thread.
5
+ const libc = dlopen("/usr/lib/libSystem.B.dylib", {
6
+ posix_memalign: { args: ["ptr", "u64", "u64"], returns: "i32" },
7
+ free: { args: ["ptr"], returns: "void" },
8
+ });
9
+ export const hostBufferDestructor = Number(Reflect.get(libc.symbols.free, "ptr"));
10
+
11
+ /** Unpublished CPU storage. A reader fills it before ownership moves to MLX. */
12
+ export class HostBuffer {
13
+ #pointer: number;
14
+ constructor(readonly bytes: number) {
15
+ const slot = new BigUint64Array(1), address = ptr(slot);
16
+ const status = libc.symbols.posix_memalign(address, 16384n, BigInt(Math.max(16384, Math.ceil(bytes / 16384) * 16384)));
17
+ if (status) throw new Error(`KV restore allocation failed (${status})`);
18
+ this.#pointer = Number(read.u64(address));
19
+ }
20
+ get pointer(): number { return this.#pointer; }
21
+ transfer(): number { const p = this.#pointer; this.#pointer = 0; return p; }
22
+ dispose(): void { if (this.#pointer) libc.symbols.free(this.transfer() as Pointer); }
23
+ [Symbol.dispose](): void { this.dispose(); }
24
+ }