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
@@ -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();
@@ -0,0 +1,264 @@
1
+ // CPU-only persistence worker. It deliberately imports no model, MLX,
2
+ // scheduler or cache manager. The sender owns all buffers through completion.
3
+ import { parentPort } from "node:worker_threads";
4
+ import { toArrayBuffer, ptr } from "bun:ffi";
5
+ import { openSync, writeSync, closeSync, fsyncSync, renameSync, rmSync, mkdirSync, existsSync, readSync, readFileSync, readdirSync } from "node:fs";
6
+ import { createHash } from "node:crypto";
7
+ import { dirname, join } from "node:path";
8
+
9
+ const MAGIC = "MLXBUNKV2\n";
10
+ const PREFIX_LEN = MAGIC.length + 16;
11
+ const alignUp = n => Math.ceil(n / 16384) * 16384;
12
+ const hash64 = bytes => Bun.hash(bytes).toString(16).padStart(16, "0");
13
+ let scratch = new Uint8Array(0);
14
+
15
+ function bytesOf(tensor) {
16
+ const { pointer, shape, strides, itemSize } = tensor;
17
+ const size = shape.reduce((a, b) => a * b, 1);
18
+ if (!size) return new Uint8Array(0);
19
+ // Coalesce the contiguous suffix. Most KV layouts need only a copy per
20
+ // head to omit capacity padding, never a GPU contiguous() operation.
21
+ let run = 1, axis = shape.length - 1;
22
+ while (axis >= 0 && (shape[axis] === 1 || strides[axis] === run)) {
23
+ run *= shape[axis--];
24
+ }
25
+ if (axis < 0) return new Uint8Array(toArrayBuffer(pointer, 0, size * itemSize));
26
+ let lo = 0, hi = 0;
27
+ for (let i = 0; i < shape.length; i++) {
28
+ const end = (shape[i] - 1) * strides[i];
29
+ lo += Math.min(0, end); hi += Math.max(0, end);
30
+ }
31
+ const source = new Uint8Array(toArrayBuffer(pointer + lo * itemSize, 0, (hi - lo + 1) * itemSize));
32
+ if (scratch.length < size * itemSize) scratch = new Uint8Array(size * itemSize);
33
+ const bytes = scratch.subarray(0, size * itemSize);
34
+ const runBytes = run * itemSize;
35
+ for (let row = 0; row < size / run; row++) {
36
+ let index = row, offset = -lo;
37
+ for (let i = axis; i >= 0; i--) {
38
+ offset += (index % shape[i]) * strides[i];
39
+ index = Math.floor(index / shape[i]);
40
+ }
41
+ bytes.set(source.subarray(offset * itemSize, offset * itemSize + runBytes), row * runBytes);
42
+ }
43
+ return bytes;
44
+ }
45
+
46
+ function writeAll(fd, bytes, position) {
47
+ let offset = 0;
48
+ while (offset < bytes.length) {
49
+ const n = writeSync(fd, bytes, offset, bytes.length - offset, position + offset);
50
+ if (!n) throw new Error("KV write made no progress");
51
+ offset += n;
52
+ }
53
+ }
54
+
55
+ function writeSnapshot({ path, header, tensors }) {
56
+ const slots = [...header.caches, ...(header.attachments ?? [])].flatMap(entry => entry.tensors);
57
+ const headerLen = new TextEncoder().encode(JSON.stringify(header)).length;
58
+ const dataStart = alignUp(PREFIX_LEN + headerLen);
59
+ mkdirSync(dirname(path), { recursive: true });
60
+ const tmp = `${path}.tmp`;
61
+ const fd = openSync(tmp, "w");
62
+ try {
63
+ for (let i = 0; i < slots.length; i++) {
64
+ const bytes = bytesOf(tensors[i]);
65
+ if (bytes.length !== slots[i].bytes) throw new Error("KV tensor byte-length drift");
66
+ slots[i].hash = hash64(bytes);
67
+ writeAll(fd, bytes, dataStart + slots[i].off);
68
+ }
69
+ const json = new TextEncoder().encode(JSON.stringify(header));
70
+ if (json.length !== headerLen) throw new Error("KV header byte-length drift");
71
+ const prefix = new Uint8Array(dataStart);
72
+ prefix.set(new TextEncoder().encode(MAGIC));
73
+ const view = new DataView(prefix.buffer);
74
+ view.setUint32(MAGIC.length, headerLen, true);
75
+ view.setUint32(MAGIC.length + 4, dataStart, true);
76
+ view.setBigUint64(MAGIC.length + 8, BigInt(Bun.hash(json)), true);
77
+ prefix.set(json, PREFIX_LEN);
78
+ writeAll(fd, prefix, 0);
79
+ fsyncSync(fd);
80
+ } catch (error) {
81
+ closeSync(fd);
82
+ rmSync(tmp, { force: true });
83
+ throw error;
84
+ }
85
+ closeSync(fd);
86
+ try { renameSync(tmp, path); }
87
+ catch (error) { rmSync(tmp, { force: true }); throw error; }
88
+ }
89
+
90
+ // The block boundary resets for each leading row (e.g. attention head).
91
+ // Growing a token axis therefore leaves preceding blocks byte-identical.
92
+ function* segments(tensor, blockBytes, metrics) {
93
+ const { pointer, shape, strides, itemSize } = tensor;
94
+ const size = shape.reduce((a, b) => a * b, 1);
95
+ const rowSize = shape.slice(-2).reduce((a, b) => a * b, 1);
96
+ if (!size) return;
97
+ let run = 1, axis = shape.length - 1;
98
+ while (axis >= 0 && (shape[axis] === 1 || strides[axis] === run)) run *= shape[axis--];
99
+ const limit = Math.max(1, Math.floor(blockBytes / itemSize));
100
+ let packing = null;
101
+ for (let start = 0; start < size;) {
102
+ const count = Math.min(limit, rowSize - start % rowSize, size - start);
103
+ let index = start, offset = 0;
104
+ for (let i = shape.length - 1; i >= 0; i--) {
105
+ offset += index % shape[i] * strides[i]; index = Math.floor(index / shape[i]);
106
+ }
107
+ if (count <= run - start % run) {
108
+ yield new Uint8Array(toArrayBuffer(pointer + offset * itemSize, 0, count * itemSize));
109
+ } else {
110
+ if (!packing) packing = new Uint8Array(limit * itemSize);
111
+ metrics.scratchPeak = Math.max(metrics.scratchPeak, packing.byteLength);
112
+ const bytes = packing.subarray(0, count * itemSize);
113
+ for (let copied = 0; copied < count;) {
114
+ index = start + copied; offset = 0;
115
+ for (let i = shape.length - 1; i >= 0; i--) {
116
+ offset += index % shape[i] * strides[i]; index = Math.floor(index / shape[i]);
117
+ }
118
+ const n = Math.min(count - copied, run - (start + copied) % run);
119
+ bytes.set(new Uint8Array(toArrayBuffer(pointer + offset * itemSize, 0, n * itemSize)), copied * itemSize);
120
+ copied += n;
121
+ }
122
+ metrics.copiedBytes += bytes.length;
123
+ yield bytes;
124
+ }
125
+ start += count;
126
+ }
127
+ }
128
+
129
+ function writeHeader(path, header) {
130
+ const json = new TextEncoder().encode(JSON.stringify(header));
131
+ const prefix = new Uint8Array(PREFIX_LEN + json.length);
132
+ prefix.set(new TextEncoder().encode(MAGIC));
133
+ const view = new DataView(prefix.buffer);
134
+ view.setUint32(MAGIC.length, json.length, true);
135
+ view.setUint32(MAGIC.length + 4, prefix.length, true);
136
+ view.setBigUint64(MAGIC.length + 8, BigInt(Bun.hash(json)), true);
137
+ prefix.set(json, PREFIX_LEN);
138
+ const tmp = path + ".tmp", fd = openSync(tmp, "w");
139
+ try { writeAll(fd, prefix, 0); fsyncSync(fd); }
140
+ finally { closeSync(fd); }
141
+ renameSync(tmp, path);
142
+ return prefix.length;
143
+ }
144
+
145
+ function writeBlocks({ path, header, tensors, blockBytes = 1024 * 1024, segmented = true }) {
146
+ const metrics = { writtenBytes: 0, reusedBytes: 0, copiedBytes: 0, scratchPeak: 0 };
147
+ const root = join(dirname(path), "blocks");
148
+ mkdirSync(root, { recursive: true });
149
+ const slots = [...header.caches, ...(header.attachments ?? [])].flatMap(entry => entry.tensors);
150
+ for (let i = 0; i < slots.length; i++) {
151
+ const slot = slots[i]; slot.blocks = [];
152
+ let bytes = 0;
153
+ let source = tensors[i];
154
+ let packed;
155
+ if (!segmented) {
156
+ packed = bytesOf(source);
157
+ metrics.copiedBytes += packed.buffer === scratch.buffer ? packed.length : 0;
158
+ metrics.scratchPeak = Math.max(metrics.scratchPeak, scratch.length);
159
+ // Contiguous strides, but retain row boundaries for identical blocks.
160
+ let stride = 1;
161
+ const strides = Array(source.shape.length);
162
+ for (let j = strides.length - 1; j >= 0; j--) { strides[j] = stride; stride *= source.shape[j]; }
163
+ // No second copy; the pointer remains live until this tensor finishes.
164
+ source = { ...source, pointer: packed.length ? Number(ptr(packed)) : 0, strides };
165
+ }
166
+ for (const chunk of segments(source, blockBytes, metrics)) {
167
+ const hash = createHash("sha256").update(chunk).digest("hex");
168
+ bytes += chunk.length;
169
+ slot.blocks.push({ hash, bytes: chunk.length });
170
+ const destination = join(root, hash);
171
+ if (existsSync(destination)) { metrics.reusedBytes += chunk.length; continue; }
172
+ const tmp = destination + ".tmp", fd = openSync(tmp, "w");
173
+ try { writeAll(fd, chunk, 0); fsyncSync(fd); }
174
+ finally { closeSync(fd); }
175
+ renameSync(tmp, destination);
176
+ metrics.writtenBytes += chunk.length;
177
+ }
178
+ if (bytes !== slot.bytes) throw new Error("KV tensor byte-length drift");
179
+ // The header authenticates block order; each block authenticates bytes.
180
+ // Avoid hashing the same tensor a second time.
181
+ slot.hash = "";
182
+ }
183
+ header.formatVersion = 5;
184
+ metrics.writtenBytes += writeHeader(path, header);
185
+ return metrics;
186
+ }
187
+
188
+ function readAll(fd, bytes, position) {
189
+ let offset = 0;
190
+ while (offset < bytes.length) {
191
+ const n = readSync(fd, bytes, offset, bytes.length - offset, position + offset);
192
+ if (!n) throw new Error("truncated KV tensor");
193
+ offset += n;
194
+ }
195
+ }
196
+
197
+ // Destinations are unpublished, independently owned host allocations. No
198
+ // MLX API is called here, and the owner retains them until acknowledgement.
199
+ function readSnapshot({ path, header, pointers, verify }) {
200
+ const slots = [...header.caches, ...(header.attachments ?? [])].flatMap(entry => entry.tensors);
201
+ const fd = header.formatVersion === 5 ? null : openSync(path, "r");
202
+ try {
203
+ for (let i = 0; i < slots.length; i++) {
204
+ const slot = slots[i];
205
+ const bytes = slot.bytes ? new Uint8Array(toArrayBuffer(pointers[i], 0, slot.bytes)) : new Uint8Array(0);
206
+ if (header.formatVersion === 5) {
207
+ let offset = 0;
208
+ for (const block of slot.blocks ?? []) {
209
+ if (!/^[a-f0-9]{64}$/.test(block.hash) || block.bytes <= 0 || offset + block.bytes > bytes.length)
210
+ throw new Error("invalid KV block reference");
211
+ const blockFd = openSync(join(dirname(path), "blocks", block.hash), "r");
212
+ const target = bytes.subarray(offset, offset + block.bytes);
213
+ try { readAll(blockFd, target, 0); } finally { closeSync(blockFd); }
214
+ if (verify && createHash("sha256").update(target).digest("hex") !== block.hash)
215
+ throw new Error("KV block hash mismatch");
216
+ offset += block.bytes;
217
+ }
218
+ if (offset !== bytes.length) throw new Error("incomplete KV tensor blocks");
219
+ } else {
220
+ readAll(fd, bytes, header.dataStart + slot.off);
221
+ if (verify && hash64(bytes) !== slot.hash) throw new Error("KV tensor hash mismatch");
222
+ }
223
+ }
224
+ } finally { if (fd !== null) closeSync(fd); }
225
+ }
226
+
227
+ // Serialized with writes: an in-flight manifest cannot lose its blocks.
228
+ // Only unreferenced content is collected; shared prefixes remain on SSD.
229
+ function collectBlocks({ root }) {
230
+ if (!existsSync(root)) return;
231
+ for (const ns of readdirSync(root, { withFileTypes: true })) {
232
+ if (!ns.isDirectory()) continue;
233
+ const dir = join(root, ns.name), blocks = join(dir, "blocks");
234
+ if (!existsSync(blocks)) continue;
235
+ const live = new Set();
236
+ for (const file of readdirSync(dir)) {
237
+ if (!file.endsWith(".mlxkv")) continue;
238
+ const fd = openSync(join(dir, file), "r");
239
+ try {
240
+ const prefix = new Uint8Array(PREFIX_LEN); readAll(fd, prefix, 0);
241
+ const view = new DataView(prefix.buffer), json = new Uint8Array(view.getUint32(MAGIC.length, true));
242
+ readAll(fd, json, PREFIX_LEN);
243
+ if (BigInt(Bun.hash(json)) !== view.getBigUint64(MAGIC.length + 8, true)) continue;
244
+ const header = JSON.parse(new TextDecoder().decode(json));
245
+ for (const entry of [...header.caches, ...(header.attachments ?? [])])
246
+ for (const slot of entry.tensors) for (const block of slot.blocks ?? []) live.add(block.hash);
247
+ } finally { closeSync(fd); }
248
+ }
249
+ for (const file of readdirSync(blocks))
250
+ if (!live.has(file)) rmSync(join(blocks, file), { force: true });
251
+ }
252
+ }
253
+
254
+ parentPort.on("message", data => {
255
+ try {
256
+ const started = performance.now();
257
+ const metrics = data.operation === "read" ? readSnapshot(data)
258
+ : data.operation === "collect" ? collectBlocks(data)
259
+ : data.layout === "blocks" ? writeBlocks(data) : writeSnapshot(data);
260
+ parentPort.postMessage({ id: data.id, metrics: { ...metrics, elapsedMs: performance.now() - started } });
261
+ } catch (error) {
262
+ parentPort.postMessage({ id: data.id, error: String(error) });
263
+ }
264
+ });
@@ -0,0 +1,37 @@
1
+ /** RAM policy sees reuse metadata, never tensors, storage or scheduling. */
2
+ export interface RetentionCandidate {
3
+ bytes: number;
4
+ cost: number;
5
+ lastUsed: number;
6
+ uses: number;
7
+ priority: number;
8
+ }
9
+ export interface RetentionPolicy {
10
+ readonly name: string;
11
+ accessed(entry: RetentionCandidate): void;
12
+ victim<T extends RetentionCandidate>(entries: readonly T[]): T;
13
+ evicted(entry: RetentionCandidate): void;
14
+ }
15
+ export class LruRetention implements RetentionPolicy {
16
+ readonly name = "lru";
17
+ accessed(_entry: RetentionCandidate): void {}
18
+ victim<T extends RetentionCandidate>(entries: readonly T[]): T {
19
+ return entries.reduce((a, b) => a.lastUsed < b.lastUsed ? a : b);
20
+ }
21
+ evicted(_entry: RetentionCandidate): void {}
22
+ }
23
+
24
+ /** GreedyDual size/frequency: saved prefill tokens are the cost proxy.
25
+ * Advancing age on eviction lets old popularity decay without wall timers. */
26
+ export class CostSizeRetention implements RetentionPolicy {
27
+ readonly name = "cost-size";
28
+ #age = 0;
29
+ accessed(entry: RetentionCandidate): void {
30
+ entry.priority = this.#age + entry.cost * entry.uses / Math.max(1, entry.bytes);
31
+ }
32
+ victim<T extends RetentionCandidate>(entries: readonly T[]): T {
33
+ return entries.reduce((a, b) => a.priority < b.priority ||
34
+ (a.priority === b.priority && a.lastUsed < b.lastUsed) ? a : b);
35
+ }
36
+ evicted(entry: RetentionCandidate): void { this.#age = Math.max(this.#age, entry.priority); }
37
+ }
@@ -0,0 +1,45 @@
1
+ import { PromptCache, cacheBytes, type ColdTier } from "./prompt-cache";
2
+ import { cloneKvCaches, SpillQueue } from "./kv-store";
3
+ import { SsdDurabilityCoordinator } from "./ssd-durability";
4
+ import { disposeResources } from "./engine/resources";
5
+ import type { SsdCacheStore } from "./ssd-cache";
6
+
7
+ /** One cache with RAM and SSD storage. Inference sees the same put/take
8
+ * interface. Recency and capacity choose RAM residents; a pending write is
9
+ * never treated as a completed demotion. */
10
+ export class TieredPromptCache extends PromptCache {
11
+ protected override promoteRestores = true;
12
+ readonly spillQueue: SpillQueue;
13
+ readonly durability: SsdDurabilityCoordinator;
14
+
15
+ constructor(
16
+ maxBytes: number,
17
+ storage: Pick<SsdCacheStore, "storeAsync" | "hasDurablePrefix">,
18
+ cold: ColdTier,
19
+ clone = cloneKvCaches,
20
+ readonly writeBehind = true,
21
+ ) {
22
+ super(maxBytes, null, cold, clone);
23
+ this.spillQueue = new SpillQueue(Infinity, cacheBytes, async item => {
24
+ return storage.hasDurablePrefix(item.tokens, item.ns) ||
25
+ await storage.storeAsync(item.tokens, item.caches, item.ns, undefined, item.attachments);
26
+ }, disposeResources, () => false);
27
+ this.durability = new SsdDurabilityCoordinator(this, this.spillQueue, clone,
28
+ (tokens, ns) => storage.hasDurablePrefix(tokens, ns), 0, 5_000, () => {
29
+ // Writing alone does not remove a RAM entry. Apply capacity and
30
+ // pressure policy after the completed writer releases its views.
31
+ this.evictToBudget();
32
+ this.reclaim();
33
+ });
34
+ this.canEvict = entry => {
35
+ if (storage.hasDurablePrefix(entry.tokens, entry.ns)) return true;
36
+ this.durability.schedule(entry.tokens, entry.ns, false);
37
+ return false;
38
+ };
39
+ }
40
+
41
+ override put(...args: Parameters<PromptCache["put"]>): void {
42
+ super.put(...args);
43
+ if (this.writeBehind) this.durability.schedule(args[0], args[2]);
44
+ }
45
+ }
@@ -7,6 +7,7 @@
7
7
  //
8
8
  // Sidecar weights stay bf16; features come out bf16 like text embeddings.
9
9
 
10
+ import { tensorFingerprint } from "../model/fingerprint";
10
11
  import { ptr, read } from "bun:ffi";
11
12
  import { MlxArray, cpuStream } from "../mlx/array";
12
13
  import { C } from "../mlx/ffi";
@@ -18,6 +19,13 @@ const cstr = (s: string) => Buffer.from(s + "\0", "utf8");
18
19
 
19
20
  export class VisionTower {
20
21
  #weights = new Map<string, MlxArray>();
22
+ #cacheIdentity?: string;
23
+ /** Immutable encoder weights and computation settings, hashed once on use. */
24
+ get cacheIdentity(): string {
25
+ return this.#cacheIdentity ??= new Bun.CryptoHasher("sha256")
26
+ .update("gemma-vision-v1").update(JSON.stringify([this.embedScale, this.rmsNormEps]))
27
+ .update(tensorFingerprint(this.#weights)).digest("hex");
28
+ }
21
29
  readonly embedScale: number;
22
30
  readonly rmsNormEps: number;
23
31