mlx-bun 0.4.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 (158) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +55 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  5. package/docs/design/batching.md +220 -7
  6. package/docs/design/decode-speed-program.md +121 -66
  7. package/docs/design/generic-model-support.md +116 -0
  8. package/docs/design/kv-cache.md +380 -211
  9. package/docs/design/speculative-decoding.md +165 -26
  10. package/docs/design/turboquant.md +20 -7
  11. package/docs/design/unified-engine-frontier-plan.md +120 -13
  12. package/docs/planning/PRODUCT_ROADMAP.md +5 -6
  13. package/docs/planning/public-readiness-handoff.md +244 -0
  14. package/docs/planning/public-readiness.md +149 -0
  15. package/docs/reference/benchmarks.md +3463 -3489
  16. package/docs/reference/cli.md +62 -1
  17. package/docs/reference/distribution.md +29 -4
  18. package/docs/reference/environment.md +104 -8
  19. package/docs/reference/library-api.md +18 -4
  20. package/docs/reference/memory.md +1 -1
  21. package/docs/reference/models.md +17 -5
  22. package/docs/reference/server-api.md +181 -11
  23. package/docs/reference/server-config.md +232 -105
  24. package/package.json +2 -2
  25. package/src/audio/audiotoolbox.ts +112 -0
  26. package/src/audio/conformer.ts +8 -0
  27. package/src/audio/mic-capture.ts +98 -0
  28. package/src/audio/silero-vad.ts +379 -0
  29. package/src/audio/whisper-decode.ts +774 -0
  30. package/src/audio/whisper-fast.ts +732 -0
  31. package/src/audio/whisper-format.ts +73 -0
  32. package/src/audio/whisper-mel.ts +185 -0
  33. package/src/audio/whisper-timing.ts +274 -0
  34. package/src/audio/whisper-tokenizer.ts +239 -0
  35. package/src/audio/whisper-transcribe.ts +410 -0
  36. package/src/backends/mlx/autoregressive.ts +22 -1
  37. package/src/backends/mlx/batch-group.ts +111 -27
  38. package/src/backends/mlx/cache-layout.ts +3 -3
  39. package/src/backends/mlx/continuation-persistence.ts +5 -4
  40. package/src/backends/mlx/encoder-cache.ts +25 -0
  41. package/src/backends/mlx/fill-append.ts +21 -8
  42. package/src/backends/mlx/fill-group.ts +497 -0
  43. package/src/backends/mlx/gateway-binding.ts +56 -12
  44. package/src/backends/mlx/kv-maintenance.ts +30 -2
  45. package/src/backends/mlx/mixed-iteration.ts +62 -0
  46. package/src/backends/mlx/model-prompt.ts +46 -32
  47. package/src/backends/mlx/model-serving.ts +4 -3
  48. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  49. package/src/backends/mlx/prefill-cohort.ts +15 -4
  50. package/src/backends/mlx/prefill-policy.ts +26 -0
  51. package/src/backends/mlx/prefill-rows.ts +57 -13
  52. package/src/backends/mlx/prompt-input.ts +29 -0
  53. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  54. package/src/backends/mlx/request-state-policy.ts +18 -5
  55. package/src/backends/mlx/rollback.ts +6 -3
  56. package/src/backends/mlx/serial-executor.ts +16 -7
  57. package/src/backends/mlx/speculative-group.ts +105 -26
  58. package/src/backends/mlx/speculative.ts +24 -7
  59. package/src/backends/mlx/target-layout-capability.ts +1 -1
  60. package/src/backends/mlx/transitioning-kv-rows.ts +8 -0
  61. package/src/cli.ts +322 -0
  62. package/src/contracts/execution.ts +10 -0
  63. package/src/contracts/object-cache.ts +8 -0
  64. package/src/contracts/pi-provider.ts +1 -1
  65. package/src/contracts/pi.ts +3 -1
  66. package/src/contracts/prefix-cache.ts +13 -3
  67. package/src/contracts/scheduling.ts +9 -0
  68. package/src/engine/execution-plan.ts +23 -12
  69. package/src/engine/execution-tasks.ts +44 -0
  70. package/src/engine/namespaced-cache.ts +13 -0
  71. package/src/engine/scheduler.ts +22 -2
  72. package/src/fill/fill-session.ts +14 -3
  73. package/src/generate.ts +28 -22
  74. package/src/grammar.ts +13 -0
  75. package/src/inference/prefill.ts +6 -0
  76. package/src/kv-scheme.ts +22 -0
  77. package/src/kv-store.ts +115 -79
  78. package/src/lab/README.md +5 -1
  79. package/src/lab/paged-kv/paged-attention.ts +117 -0
  80. package/src/lab/paged-kv/paged-kv-rows.ts +33 -5
  81. package/src/lab/paged-kv/paged-kv.ts +108 -42
  82. package/src/md-modules.d.ts +5 -0
  83. package/src/mlx/array.ts +37 -0
  84. package/src/mlx/ffi.ts +19 -0
  85. package/src/mlx/normalized-argmax.ts +116 -0
  86. package/src/mlx/ops.ts +97 -10
  87. package/src/model/batched-turboquant-kv.ts +6 -5
  88. package/src/model/delayed-quantized-kv.ts +3 -3
  89. package/src/model/delayed-rotating-quantized-kv.ts +1 -1
  90. package/src/model/delayed-turboquant-kv.ts +6 -4
  91. package/src/model/factory.ts +26 -0
  92. package/src/model/fingerprint.ts +12 -0
  93. package/src/model/full-prefill-row.ts +1 -1
  94. package/src/model/gemma4-base.ts +25 -6
  95. package/src/model/gemma4.ts +82 -2
  96. package/src/model/kv-attention-view.ts +4 -1
  97. package/src/model/profile.ts +29 -4
  98. package/src/model/quantized-append-attention.ts +31 -0
  99. package/src/model/qwen3-mrope.ts +7 -9
  100. package/src/model/qwen3_5.ts +99 -37
  101. package/src/model/support.ts +8 -0
  102. package/src/model/token-groups.ts +66 -0
  103. package/src/model/trellis-linear.ts +2 -2
  104. package/src/model/turboquant-codec.ts +6 -0
  105. package/src/model/whisper.ts +435 -0
  106. package/src/native/mic_capture.swift +92 -0
  107. package/src/native-pack.ts +6 -5
  108. package/src/pi-web.ts +9 -1
  109. package/src/prompt-cache.ts +265 -43
  110. package/src/runtime-config.ts +1 -5
  111. package/src/safetensors.ts +9 -2
  112. package/src/sampler.ts +5 -4
  113. package/src/serve/audio-routes.ts +323 -0
  114. package/src/serve/chat-request.ts +2 -0
  115. package/src/serve/chat-stage.ts +17 -12
  116. package/src/serve/completion-executor.ts +9 -1
  117. package/src/serve/discovery-routes.ts +17 -1
  118. package/src/serve/generated-token-history.ts +57 -0
  119. package/src/serve/generation-gateway.ts +52 -11
  120. package/src/serve/model-binding.ts +7 -1
  121. package/src/serve/preparation.ts +3 -3
  122. package/src/serve/prompt-response-trace.ts +14 -0
  123. package/src/serve/request-plan.ts +2 -0
  124. package/src/serve/request-prep.ts +38 -21
  125. package/src/serve/transcription-server.ts +64 -0
  126. package/src/serve/transcription-service.ts +502 -0
  127. package/src/server.ts +132 -164
  128. package/src/spec/assistant-rows.ts +1 -0
  129. package/src/spec/deepspec-rows.ts +1 -1
  130. package/src/spec/dflash-source.ts +1 -1
  131. package/src/spec/glm52-mtp-source.ts +1 -0
  132. package/src/spec/ngram-source.ts +27 -5
  133. package/src/spec/projected-draft-rows.ts +2 -2
  134. package/src/spec/qwen-mtp-rows.ts +11 -0
  135. package/src/spec/qwen-mtp-source.ts +3 -0
  136. package/src/spec/serve-loop.ts +5 -3
  137. package/src/spec/source.ts +14 -1
  138. package/src/spec/two-model-rows.ts +1 -0
  139. package/src/spec/two-model.ts +2 -2
  140. package/src/ssd-cache.ts +76 -17
  141. package/src/ssd-durability.ts +20 -22
  142. package/src/storage/host-buffer.ts +24 -0
  143. package/src/storage/kv-writer.ts +83 -0
  144. package/src/storage/kv-writer.worker.js +264 -0
  145. package/src/storage/retention-policy.ts +37 -0
  146. package/src/tiered-prompt-cache.ts +45 -0
  147. package/src/vision/embedder.ts +8 -0
  148. package/src/vision/prompt.ts +101 -65
  149. package/src/vision/qwen3vl-prompt.ts +101 -64
  150. package/src/vision/qwen3vl-tower.ts +11 -0
  151. package/src/vision/siglip.ts +8 -0
  152. package/src/web/app.html +12 -0
  153. package/src/web/app.js +160 -0
  154. package/src/web/src/chat.ts +4 -1
  155. package/src/web/src/composer.ts +3 -0
  156. package/src/web/src/voice.ts +141 -0
  157. package/src/web/sw.js +1 -1
  158. /package/docs/{planning → archive/planning}/release-notes-v0.4.0.md +0 -0
@@ -47,6 +47,8 @@ const DRAFT_PREFILL_CHUNK = 2048;
47
47
 
48
48
  export class QwenMtpProvider implements DraftProvider {
49
49
  readonly grouped: GroupedDraftProvider = {
50
+ supportsExternalTokens: true,
51
+ checkpointNamespace: () => this.#checkpointNamespace,
50
52
  open: options => this.#openRows(options.target, options.sampling, options.checkpoints),
51
53
  openPrefill: options => this.#openRows(options.target, null, options.checkpoints),
52
54
  };
@@ -132,6 +134,7 @@ export class QwenMtpProvider implements DraftProvider {
132
134
  append, prepareAppend, prefill: (tokens, context) => rows.prefill(tokens, context!), materialize: rows.materialize.bind(rows),
133
135
  filterRows: rows.filterRows.bind(rows),
134
136
  draft: rows.draft.bind(rows), commit: rows.commit.bind(rows),
137
+ consume: rows.consume.bind(rows),
135
138
  capture(row) {
136
139
  const state = rows.extractRow(row);
137
140
  try { return captureQwenMtpState(state); }
@@ -133,7 +133,9 @@ async function specRunInner(
133
133
  sampler,
134
134
  });
135
135
  const gamma = Math.max(1, numDraftTokens);
136
+ const trace = runtimeConfig().value("MLX_BUN_SPEC_TRACE") === "1";
136
137
  const prefillChunk = options.prefillChunkSize ??
138
+ binding.prefillPolicy?.chunkSize(promptIds.length) ??
137
139
  runtimeConfig().number("MLX_BUN_RD_PREFILL_CHUNK", PREFILL_CHUNK);
138
140
 
139
141
  // Allocated INSIDE the try below (2026-07-07 review): provider.open()
@@ -216,7 +218,7 @@ async function specRunInner(
216
218
  kvGroupSize: options.kvGroupSize ?? 64, quantizedKvStart: options.quantizedKvStart ?? null,
217
219
  turboQuant: options.turboQuant ?? null });
218
220
  let cached = 0;
219
- const hit = prefixCache?.take(promptIds, prefixNamespace);
221
+ const hit = prefixCache?.take(promptIds, prefixNamespace, options.cacheSessionId);
220
222
  if (hit) {
221
223
  try {
222
224
  disposeResources(caches);
@@ -237,7 +239,7 @@ async function specRunInner(
237
239
  try {
238
240
  state = (services.cloneState ?? cloneKvCaches)(caches);
239
241
  attachments.push(checkpoint!.capture(tokens.length));
240
- prefixCache!.put(tokens, state, prefixNamespace, undefined, attachments);
242
+ prefixCache!.put(tokens, state, prefixNamespace, undefined, attachments, options.cacheSessionId);
241
243
  state = []; attachments = [];
242
244
  } finally {
243
245
  disposeResources([...state, { dispose: () => disposeAttachments(attachments) }]);
@@ -445,7 +447,7 @@ async function specRunInner(
445
447
  );
446
448
  const d = drafts.length;
447
449
  if (d < 0 || d > n) throw new Error(`DraftSource returned ${d} drafts (contract: 0..${n})`);
448
- if (Bun.env.MLX_BUN_SPEC_TRACE === "1") {
450
+ if (trace) {
449
451
  console.error(
450
452
  `[SPEC_TRACE] round=${extras.rounds + 1} ` +
451
453
  `feed=${feed.join(",")} drafts=${drafts.join(",")}`,
@@ -145,6 +145,12 @@ export interface DraftRowSampling {
145
145
  sample(logprobs: MlxArray, steps: readonly number[]): MlxArray;
146
146
  }
147
147
 
148
+ /** Request-owned constraints can propose known continuations without changing
149
+ * their committed state. The target verifier still decides every output. */
150
+ export interface DraftRowConstraints {
151
+ propose(row: number, maxTokens: number): Promise<number[]>;
152
+ }
153
+
148
154
  export interface DraftRowCheckpoint {
149
155
  readonly processedTokens: number;
150
156
  readonly attachment: CheckpointAttachment;
@@ -155,6 +161,10 @@ export interface DraftRowCheckpoint {
155
161
  * Membership changes and capture occur only between committed rounds.
156
162
  * The caller retains the provider's residency lease for the group's lifetime. */
157
163
  export interface DraftRowGroup extends MlxDraftRows {
164
+ /** Consume externally chosen target inputs at a committed round boundary.
165
+ * Context and tokens are borrowed rectangular windows; lengths retain each
166
+ * row's consumed prefix, excluding right padding and the pending output. */
167
+ consume?(tokens: MlxArray, context: MlxArray, lengths: readonly number[]): void | Promise<void>;
158
168
  readonly namespace: string;
159
169
  readonly tapLayers: readonly number[];
160
170
  readonly rowCount: number;
@@ -187,12 +197,15 @@ export interface DraftPrefillGroup {
187
197
  }
188
198
 
189
199
  export interface GroupedDraftProvider {
200
+ readonly supportsExternalTokens?: boolean;
201
+ /** Resolve persistence identity without allocating a draft row. */
202
+ checkpointNamespace?(): string;
190
203
  /** Draft state remains valid when target forwards run under a mounted
191
204
  * adapter context. Unqualified learned draft graphs leave this absent. */
192
205
  readonly supportsTargetAdapters?: boolean;
193
206
  openPrefill(options: { target: TargetView;
194
207
  checkpoints: readonly (DraftRowCheckpoint | null)[] }): DraftPrefillGroup;
195
- open(options: { target: TargetView; sampling: DraftRowSampling;
208
+ open(options: { target: TargetView; sampling: DraftRowSampling; constraints?: DraftRowConstraints;
196
209
  checkpoints: readonly DraftRowCheckpoint[] }): DraftRowGroup;
197
210
  }
198
211
 
@@ -55,6 +55,7 @@ export function openStandaloneDraftRows(binding: MlxAutoregressiveBinding, names
55
55
 
56
56
  export function standaloneDraftGroups(binding: MlxAutoregressiveBinding, namespace: string): GroupedDraftProvider {
57
57
  return {
58
+ checkpointNamespace: () => namespace,
58
59
  open: options => openStandaloneDraftRows(binding, namespace, options.sampling, options.checkpoints),
59
60
  openPrefill: options => openStandaloneDraftRows(binding, namespace, null, options.checkpoints),
60
61
  };
@@ -16,7 +16,7 @@
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";
@@ -93,7 +93,7 @@ class TwoModelSource implements DraftSource {
93
93
  { sample: (logprobs, steps) => sampler(logprobs, steps[0]!) }, [null]);
94
94
  }
95
95
  async prefill(promptIds: number[]): Promise<void> {
96
- const tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
96
+ const tailSplit = runtimeFlag("MLX_BUN_PREFILL_TAIL_SPLIT", true);
97
97
  const upTo = tailSplit && promptIds.length > 1 ? promptIds.length - 1 : promptIds.length;
98
98
  for (let at = this.processed; at < upTo; at += PREFILL_CHUNK) {
99
99
  const end = Math.min(at + PREFILL_CHUNK, upTo);
package/src/ssd-cache.ts CHANGED
@@ -18,11 +18,12 @@ import type { CheckpointAttachment } from "./backends/mlx/checkpoint-state";
18
18
  import {
19
19
  existsSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync,
20
20
  } from "node:fs";
21
- import { join } from "node:path";
21
+ import { kvWriter, type KvWriteRequest } from "./storage/kv-writer";
22
+ import { join, dirname } from "node:path";
22
23
  import { randomUUID } from "node:crypto";
23
24
  import { commonPrefixLength } from "./prompt-cache";
24
25
  import {
25
- saveKvCache, saveKvCacheAsync, loadKvCache, readKvHeader,
26
+ saveKvCache, saveKvCacheAsync, loadKvCache, loadKvCacheAsync, readKvHeader,
26
27
  cacheHeadersTrimmable, cacheHeadersMinimumReusableOffset, legacyCacheCodecs, type CacheCodecProvider,
27
28
  type KvSaveMeta, type KvLoadExpect, type LoadedKvCache,
28
29
  } from "./kv-store";
@@ -30,6 +31,7 @@ import type { Cache } from "./model/gemma4-base";
30
31
  import { minimumReusableOffset } from "./backends/mlx/state-views";
31
32
 
32
33
  export interface SsdIndexEntry {
34
+ blocks?: Array<{ hash: string; bytes: number }>;
33
35
  path: string;
34
36
  ns: string;
35
37
  tokens: number[];
@@ -46,6 +48,7 @@ export interface SsdIndexEntry {
46
48
  }
47
49
 
48
50
  export interface SsdStoreOptions {
51
+ storage?: Pick<KvWriteRequest, "layout" | "blockBytes" | "segmented">;
49
52
  codecs?: CacheCodecProvider;
50
53
  dir: string;
51
54
  maxBytes: number;
@@ -65,6 +68,27 @@ export class SsdCacheStore {
65
68
  readonly #codecs: CacheCodecProvider;
66
69
  readonly #root: string; // <dir>/<configFingerprint>
67
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
+
68
92
  #warnedWriteFailure = false;
69
93
  stats = { restores: 0, spills: 0, restoreMsLast: 0 };
70
94
 
@@ -78,8 +102,16 @@ export class SsdCacheStore {
78
102
  return this.#index.length;
79
103
  }
80
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
+
81
110
  get totalBytes(): number {
82
- 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);
83
115
  }
84
116
 
85
117
  get maxBytes(): number {
@@ -105,7 +137,7 @@ export class SsdCacheStore {
105
137
  * headers and metadata mismatches are unlinked; `.tmp` orphans reaped.
106
138
  * Only OUR fingerprint dir is touched. Returns entries indexed. */
107
139
  scan(): number {
108
- this.#index = [];
140
+ this.#index = []; this.#exact.clear();
109
141
  if (!existsSync(this.#root)) return 0;
110
142
  for (const nsDir of readdirSync(this.#root)) {
111
143
  const nsPath = join(this.#root, nsDir);
@@ -130,7 +162,8 @@ export class SsdCacheStore {
130
162
  throw new Error("metadata mismatch");
131
163
  if ((h.codecProvider ?? legacyCacheCodecs.id) !== this.#codecs.id) continue;
132
164
  const st = statSync(path);
133
- this.#index.push({
165
+ this.#addIndex({
166
+ blocks: [...h.caches, ...(h.attachments ?? [])].flatMap(e => e.tensors.flatMap(t => t.blocks ?? [])),
134
167
  path, ns: h.ns ?? "", tokens: h.tokens, bytes: st.size, mtimeMs: st.mtimeMs,
135
168
  trimmable: !h.attachments?.length && cacheHeadersTrimmable(h.caches, this.#codecs),
136
169
  minimumReusableOffset: cacheHeadersMinimumReusableOffset(h.caches),
@@ -143,6 +176,7 @@ export class SsdCacheStore {
143
176
  }
144
177
  }
145
178
  }
179
+ void kvWriter.collect(this.#root).catch(() => {});
146
180
  return this.#index.length;
147
181
  }
148
182
 
@@ -198,6 +232,23 @@ export class SsdCacheStore {
198
232
  }
199
233
  }
200
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
+
201
252
  /** Persist an entry. Synchronous (the tier calls it on the idle serial
202
253
  * lane between requests); atomic via kv-store's tmp+fsync+rename. An
203
254
  * entry bigger than the cap is refused; disk/write failure is a warn-once
@@ -222,9 +273,8 @@ export class SsdCacheStore {
222
273
  * yields the event loop between tensors so serving interleaves.
223
274
  * Caller passes zero-copy CLONES it owns (a consistent snapshot no
224
275
  * matter what the live entry does meanwhile) and disposes them after.
225
- * `runStep` owns every per-tensor step (see saveKvCacheAsync) — the
226
- * server passes the gateway's exclusive runner so a request cannot start
227
- * 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. */
228
278
  async storeAsync(
229
279
  tokens: number[], caches: Cache[], ns = "",
230
280
  runStep?: <T>(step: () => T) => Promise<T>,
@@ -233,8 +283,7 @@ export class SsdCacheStore {
233
283
  const dir = join(this.#root, nsHash(ns));
234
284
  const path = join(dir, `${randomUUID()}.mlxkv`);
235
285
  try {
236
- mkdirSync(dir, { recursive: true });
237
- await saveKvCacheAsync(path, tokens, caches, { ...this.#meta(ns), attachments }, runStep, this.#codecs);
286
+ await saveKvCacheAsync(path, tokens, caches, { ...this.#meta(ns), attachments }, runStep, this.#codecs, this.#opts.storage);
238
287
  return this.#indexStored(path, tokens, caches, ns, undefined, attachments);
239
288
  } catch (err) {
240
289
  return this.#storeFailed(path, err);
@@ -275,7 +324,7 @@ export class SsdCacheStore {
275
324
  else mkdirSync(dir, { recursive: true });
276
325
  await saveKvCacheAsync(path, tokens, caches, {
277
326
  ...this.#meta(ns), generationCheckpoint: checkpoint,
278
- }, runStep, this.#codecs);
327
+ }, runStep, this.#codecs, this.#opts.storage);
279
328
  const index = () => {
280
329
  const stored = this.#indexStored(path, tokens, caches, ns, checkpoint);
281
330
  if (stored) {
@@ -312,9 +361,13 @@ export class SsdCacheStore {
312
361
  attachments?: CheckpointAttachment[],
313
362
  ): boolean {
314
363
  const st = statSync(path);
315
- if (st.size > this.#opts.maxBytes) {
316
- rmSync(path, { force: true });
317
- 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`);
318
371
  return false;
319
372
  }
320
373
  const trimmable = !attachments?.length && caches.every((c) => c.isTrimmable());
@@ -336,8 +389,8 @@ export class SsdCacheStore {
336
389
  if (commonPrefixLength(e.tokens, tokens) === e.tokens.length) this.remove(e.path);
337
390
  }
338
391
  }
339
- this.#index.push({
340
- 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,
341
394
  minimumReusableOffset: minimumReusableOffset(caches),
342
395
  ...(generationCheckpoint ? { generationCheckpoint } : {}),
343
396
  });
@@ -347,7 +400,8 @@ export class SsdCacheStore {
347
400
  }
348
401
 
349
402
  #storeFailed(path: string, err: unknown): boolean {
350
- try { rmSync(path, { force: true }); rmSync(`${path}.tmp`, { force: true }); } catch {}
403
+ this.remove(path);
404
+ try { rmSync(`${path}.tmp`, { force: true }); } catch {}
351
405
  if (!this.#warnedWriteFailure) {
352
406
  this.#warnedWriteFailure = true;
353
407
  console.warn(`[ssd-cache] store failed (disk full or unwritable?) — cold tier disabled for this entry: ${(err as Error).message}`);
@@ -367,6 +421,11 @@ export class SsdCacheStore {
367
421
 
368
422
  remove(path: string): void {
369
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
+ }
370
428
  this.#index = this.#index.filter((e) => e.path !== path);
429
+ void kvWriter.collect(this.#root).catch(() => {});
371
430
  }
372
431
  }
@@ -4,11 +4,6 @@ import type { Cache } from "./model/gemma4";
4
4
  import type { PromptCacheEntry } from "./prompt-cache";
5
5
  import type { SpillItem, SpillQueue } from "./kv-store";
6
6
 
7
- export interface DurabilityGateway {
8
- readonly busy: boolean;
9
- runWhenIdle<T>(fn: () => Promise<T>): Promise<T>;
10
- }
11
-
12
7
  export interface DurabilityPromptCache {
13
8
  findExact(tokens: number[], ns?: string): PromptCacheEntry | null;
14
9
  }
@@ -50,21 +45,23 @@ export class SsdDurabilityCoordinator {
50
45
  #flush: Promise<DurabilityFlushResult> | null = null;
51
46
 
52
47
  constructor(
53
- readonly gateway: DurabilityGateway,
54
48
  readonly promptCache: DurabilityPromptCache,
55
49
  readonly spillQueue: SpillQueue,
56
50
  readonly cloneCaches: (caches: Cache[]) => Cache[],
57
51
  readonly isAlreadyDurable: (tokens: number[], ns: string) => boolean = () => false,
58
- readonly debounceMs = 1_000,
59
- readonly busyRetryMs = 5_000,
52
+ readonly debounceMs = 0,
53
+ readonly retryMs = 5_000,
54
+ readonly onStored: () => void = () => {},
60
55
  ) {}
61
56
 
62
- schedule(tokens: number[], ns = ""): void {
63
- 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;
64
60
  // PromptCache can hold unrelated entries with the same namespace and
65
61
  // length. Include the tokens so one conversation cannot cancel another
66
62
  // conversation's pending durability record.
67
63
  const key = `${ns.length}:${ns}:${tokens.join(",")}`;
64
+ if (!replace && this.#dirty.has(key)) return;
68
65
  const rec: DirtySnapshot = { key, tokens: [...tokens], ns };
69
66
  this.#dirty.set(key, rec);
70
67
  this.#arm(key, this.debounceMs);
@@ -108,10 +105,6 @@ export class SsdDurabilityCoordinator {
108
105
 
109
106
  const rec = this.#dirty.get(key);
110
107
  if (!rec) return "stored";
111
- if (!force && this.gateway.busy) {
112
- this.#arm(key, this.busyRetryMs);
113
- return "failed";
114
- }
115
108
 
116
109
  const task = this.#store(rec);
117
110
  this.#attempts.set(key, task);
@@ -120,7 +113,7 @@ export class SsdDurabilityCoordinator {
120
113
  if (outcome === "stored" && this.#dirty.get(key) === rec)
121
114
  this.#dirty.delete(key);
122
115
  if (outcome !== "stored" && !force && this.#dirty.get(key) === rec)
123
- this.#arm(key, this.busyRetryMs);
116
+ this.#arm(key, this.retryMs);
124
117
  return outcome;
125
118
  } finally {
126
119
  if (this.#attempts.get(key) === task) this.#attempts.delete(key);
@@ -130,7 +123,7 @@ export class SsdDurabilityCoordinator {
130
123
  async #store(rec: DirtySnapshot): Promise<StoreOutcome> {
131
124
  let snap: SpillItem | null = null;
132
125
  try {
133
- snap = await this.gateway.runWhenIdle(async () => {
126
+ snap = (() => {
134
127
  if (this.isAlreadyDurable(rec.tokens, rec.ns)) return null;
135
128
  const entry = this.promptCache.findExact(rec.tokens, rec.ns);
136
129
  if (!entry) return null;
@@ -141,13 +134,17 @@ export class SsdDurabilityCoordinator {
141
134
  } catch (error) {
142
135
  return cleanupFailure(error, () => disposeResources(caches));
143
136
  }
144
- });
137
+ })();
145
138
  } catch {
146
139
  return "failed";
147
140
  }
148
141
  if (!snap)
149
142
  return this.isAlreadyDurable(rec.tokens, rec.ns) ? "stored" : "missing";
150
- 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";
151
148
  }
152
149
 
153
150
  async #flushInner(started: number): Promise<DurabilityFlushResult> {
@@ -159,12 +156,13 @@ export class SsdDurabilityCoordinator {
159
156
  const droppedBefore = this.spillQueue.droppedCount;
160
157
  const failedBefore = this.spillQueue.failedCount;
161
158
 
162
- if (this.#attempts.size > 0)
163
- 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
+ }
164
163
  await this.spillQueue.drain();
165
164
 
166
- // Flush one entry at a time. This prevents the queue cap from dropping a
167
- // boundary snapshot while a large final snapshot is already in flight.
165
+ // Flush dirty entries serially without a second queue of snapshots.
168
166
  // Attempt each record version once. Missing state stays dirty, so a
169
167
  // second flush cannot report success without a real durable snapshot.
170
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
+ }
@@ -0,0 +1,83 @@
1
+ import { Worker } from "node:worker_threads";
2
+ import workerSource from "./kv-writer.worker.js" with { type: "text" };
3
+ import type { KvFileHeader } from "../kv-store";
4
+
5
+ /** Immutable, evaluated host-visible storage. The owner retains the native
6
+ * buffers until write() settles. No MLX handles cross the worker boundary. */
7
+ export interface StoredTensorView {
8
+ pointer: number;
9
+ shape: number[];
10
+ strides: number[];
11
+ itemSize: number;
12
+ }
13
+
14
+ export interface KvWriteRequest {
15
+ layout?: "whole" | "blocks";
16
+ blockBytes?: number;
17
+ segmented?: boolean;
18
+ path: string;
19
+ header: KvFileHeader;
20
+ tensors: StoredTensorView[];
21
+ }
22
+
23
+ /** Hashing, layout packing and atomic disk writes run on a dedicated CPU
24
+ * worker. Only metadata and pointers are posted; tensor bytes are not copied
25
+ * through structured clone. The RAM cache remains an independent owner. */
26
+ export class KvWriter {
27
+ #worker: Worker | null = null;
28
+ #next = 0;
29
+ #pending = new Map<number, { resolve(): void; reject(error: Error): void }>();
30
+
31
+ lastMetrics: { elapsedMs: number; writtenBytes?: number; reusedBytes?: number; copiedBytes?: number; scratchPeak?: number } | null = null;
32
+
33
+ write(request: KvWriteRequest): Promise<void> { return this.#send(request); }
34
+
35
+ read(request: { path: string; header: KvFileHeader & { dataStart: number }; pointers: number[]; verify?: boolean }): Promise<void> {
36
+ return this.#send({ ...request, operation: "read" });
37
+ }
38
+
39
+ collect(root: string): Promise<void> { return this.#send({ root, operation: "collect" }); }
40
+
41
+ #send(request: object): Promise<void> {
42
+ const worker = this.#worker ?? this.#start();
43
+ worker.ref();
44
+ const id = ++this.#next;
45
+ return new Promise<void>((resolve, reject) => {
46
+ this.#pending.set(id, { resolve, reject });
47
+ try { worker.postMessage({ id, ...request }); }
48
+ catch (error) {
49
+ this.#pending.delete(id);
50
+ if (!this.#pending.size) worker.unref();
51
+ reject(error);
52
+ }
53
+ });
54
+ }
55
+
56
+ #start(): Worker {
57
+ // Embed the worker in the single-file executable as well as source runs.
58
+ const worker = new Worker(workerSource, { eval: true });
59
+ worker.on("message", (message: { id: number; error?: string; metrics?: KvWriter["lastMetrics"] }) => {
60
+ const result = this.#pending.get(message.id);
61
+ if (!result) return;
62
+ this.#pending.delete(message.id);
63
+ if (message.error) result.reject(new Error(message.error));
64
+ else { this.lastMetrics = message.metrics ?? null; result.resolve(); }
65
+ if (!this.#pending.size) worker.unref();
66
+ });
67
+ // Job failures are acknowledged by the worker. A worker-level failure
68
+ // settles only at exit, after it can no longer read borrowed buffers.
69
+ worker.on("error", () => {});
70
+ worker.on("exit", () => {
71
+ for (const job of this.#pending.values()) job.reject(new Error("KV persistence worker exited"));
72
+ this.#pending.clear();
73
+ this.#worker = null;
74
+ });
75
+ this.#worker = worker;
76
+ return worker;
77
+ }
78
+ }
79
+
80
+ export const kvWriter = new KvWriter();
81
+
82
+ /** Reads have their own queue: a restore never waits behind persistence. */
83
+ export const kvReader = new KvWriter();