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
@@ -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
 
@@ -17,6 +17,8 @@
17
17
  // multimodal soft tokens (image | audio) — multimodalMask, decoupled
18
18
  // from the bidirectional mask.
19
19
 
20
+ import type { EncoderCache } from "../backends/mlx/encoder-cache";
21
+ import type { PromptNativeWork } from "../serve/model-binding";
20
22
  import { MlxArray } from "../mlx/array";
21
23
  import { Dtype } from "../mlx/ffi";
22
24
  import * as ops from "../mlx/ops";
@@ -35,6 +37,7 @@ import { fetchMediaBytes, videoMediaFetchPolicy } from "../media-fetch";
35
37
  * embed_scale). The two own different preprocessing, so the tower carries
36
38
  * its own. */
37
39
  export interface VisionEncoder<P extends { softTokens: number } = { softTokens: number }> {
40
+ readonly cacheIdentity?: string;
38
41
  preprocess(bytes: Uint8Array): Promise<P>;
39
42
  features(pre: P): MlxArray;
40
43
  dispose?(): void;
@@ -42,6 +45,7 @@ export interface VisionEncoder<P extends { softTokens: number } = { softTokens:
42
45
 
43
46
  export interface VisionPrompt {
44
47
  ids: number[];
48
+ prefixIdentity?: string;
45
49
  /** Unscaled merged embeddings [1, L, hidden] — caller disposes. */
46
50
  embeddings: MlxArray;
47
51
  /** bool [L] image-token mask — caller disposes. */
@@ -50,6 +54,8 @@ export interface VisionPrompt {
50
54
 
51
55
  export interface MultimodalPrompt {
52
56
  ids: number[];
57
+ /** Includes every media item and the exact token prefix through the last one. */
58
+ prefixIdentity?: string;
53
59
  /** Unscaled merged embeddings [1, L, hidden] — caller disposes. */
54
60
  embeddings: MlxArray;
55
61
  /** bool [L] image-token mask for the bidirectional attention overlay.
@@ -76,8 +82,8 @@ export interface AudioTokenIds {
76
82
  /** The towers a multimodal prompt may need; pass only what the request uses
77
83
  * (vision-only and audio-only fall out as special cases). */
78
84
  export interface MultimodalTowers<P extends { softTokens: number }> {
79
- vision?: { tower: VisionEncoder<P>; tokenIds: VisionTokenIds };
80
- audio?: { tower: AudioTower; tokenIds: AudioTokenIds };
85
+ vision?: { tower: VisionEncoder<P>; tokenIds: VisionTokenIds; cache?: EncoderCache };
86
+ audio?: { tower: AudioTower; tokenIds: AudioTokenIds; cache?: EncoderCache };
81
87
  }
82
88
 
83
89
  /** Extract image bytes from OpenAI-style content parts, rewriting the
@@ -223,6 +229,7 @@ export async function buildMultimodalPrompt<P extends { softTokens: number }>(
223
229
  images: Uint8Array[],
224
230
  audio: Uint8Array[],
225
231
  tools: ToolDefinition[] | null = null,
232
+ nativeWork: PromptNativeWork = (work) => work(),
226
233
  ): Promise<MultimodalPrompt> {
227
234
  if (images.length > 0 && !towers.vision)
228
235
  throw new Error("images present but no vision tower configured");
@@ -280,70 +287,97 @@ export async function buildMultimodalPrompt<P extends { softTokens: number }>(
280
287
  if (audIdx !== audio.length)
281
288
  throw new Error(`${audio.length} audio clips but ${audIdx} <|audio|> markers`);
282
289
 
283
- // embed text tokens, then overwrite soft-token rows with tower features
284
- const idsArr = ops.fromInt32(spliced, [1, spliced.length]);
285
- let embeds = model.embed.encode(idsArr);
286
- idsArr.dispose();
287
- const hidden = embeds.shape[2]!;
288
- for (const run of runs) {
289
- let cast: MlxArray;
290
- if (run.kind === "image") {
291
- // vision towers return pre-divided by embed_scale (their oracle's
292
- // frontend convention) — cast to the embedding dtype and splice.
293
- const feats = towers.vision!.tower.features(pre[run.index]!); // [1, soft, hidden]
294
- cast = feats.astype(embeds.dtype);
295
- feats.dispose();
296
- } else {
297
- // audio mirrors gen-e4b-audio-golden.py EXACTLY:
298
- // features.astype(embeds.dtype) / embed_scale
299
- // — raw f32 embed_audio output, cast to bf16 FIRST, then divided by a
300
- // weak (dtype-following, i.e. bf16) embed_scale scalar. The order is
301
- // load-bearing for the bit-exact greedy gate (see AudioTower.features).
302
- const clip = clips[run.index]!;
303
- const raw = towers.audio!.tower.features(clip.mel, clip.frames, false); // [1, n, hidden] f32
304
- if (raw.shape[1] !== clip.softTokens) {
305
- const n = raw.shape[1];
306
- raw.dispose();
307
- embeds.dispose();
308
- throw new Error(`audio tower produced ${n} frames but splice expects ${clip.softTokens}`);
309
- }
310
- const bf = raw.astype(embeds.dtype);
311
- raw.dispose();
312
- const scale = ops.scalarLike(towers.audio!.tower.embedScale, bf);
313
- cast = ops.div(bf, scale);
314
- bf.dispose();
315
- scale.dispose();
290
+ // Exact encoded objects contain no request positions or template state.
291
+ // CPU preprocessing and SSD reads run before acquiring native execution.
292
+ const keys = runs.map(run => towers[run.kind === "image" ? "vision" : "audio"]?.cache
293
+ ? new Bun.CryptoHasher("sha256").update(`gemma-${run.kind}-input-v1`)
294
+ .update(run.kind === "image" ? images[run.index]! : audio[run.index]!).digest("hex")
295
+ : "");
296
+ const lastMedia = runs.at(-1);
297
+ // A matching namespace guarantees any reused prefix includes all media.
298
+ // The audio-presence bit also identifies Gemma's causal/bidirectional policy.
299
+ const prefixIdentity = lastMedia && keys.every(Boolean)
300
+ ? new Bun.CryptoHasher("sha256").update("gemma-prepared-prefix-v1")
301
+ .update(JSON.stringify({ audio: audio.length > 0,
302
+ tokens: spliced.slice(0, lastMedia.start + lastMedia.length),
303
+ media: runs.map((run, i) => [run.kind, keys[i],
304
+ (run.kind === "image" ? towers.vision : towers.audio)!.cache!.identity]),
305
+ })).digest("hex") : undefined;
306
+ const reused: (MlxArray | null)[] = [];
307
+ try {
308
+ for (const [index, run] of runs.entries()) {
309
+ const cache = run.kind === "image" ? towers.vision?.cache : towers.audio?.cache;
310
+ reused.push(cache ? await cache.take(keys[index]!) : null);
316
311
  }
317
- const { start, length } = run;
318
- const updated = ops.sliceUpdate(embeds, cast, [0, start, 0], [1, start + length, hidden]);
319
- cast.dispose();
320
- embeds.dispose();
321
- embeds = updated;
322
- }
312
+ return await nativeWork(async () => {
313
+ // embed text tokens, then overwrite soft-token rows with tower features
314
+ using idsArr = ops.fromInt32(spliced, [1, spliced.length]);
315
+ let embeds = model.embed.encode(idsArr);
316
+ try {
317
+ const hidden = embeds.shape[2]!;
318
+ for (const [runIndex, run] of runs.entries()) {
319
+ let cast: MlxArray;
320
+ if (run.kind === "image") {
321
+ // vision towers return pre-divided by embed_scale (their oracle's
322
+ // frontend convention) — cast to the embedding dtype and splice.
323
+ const cached = reused[runIndex];
324
+ using feats = cached ?? towers.vision!.tower.features(pre[run.index]!); // [1, soft, hidden]
325
+ reused[runIndex] = null;
326
+ if (!cached) towers.vision!.cache?.put(keys[runIndex]!, feats);
327
+ cast = feats.astype(embeds.dtype);
328
+ } else {
329
+ // audio mirrors gen-e4b-audio-golden.py EXACTLY:
330
+ // features.astype(embeds.dtype) / embed_scale
331
+ // — raw f32 embed_audio output, cast to bf16 FIRST, then divided by a
332
+ // weak (dtype-following, i.e. bf16) embed_scale scalar. The order is
333
+ // load-bearing for the bit-exact greedy gate (see AudioTower.features).
334
+ const clip = clips[run.index]!;
335
+ const cached = reused[runIndex];
336
+ using raw = cached ?? towers.audio!.tower.features(clip.mel, clip.frames, false); // [1, n, hidden] f32
337
+ reused[runIndex] = null;
338
+ if (raw.shape[1] !== clip.softTokens) {
339
+ const n = raw.shape[1];
340
+ throw new Error(`audio tower produced ${n} frames but splice expects ${clip.softTokens}`);
341
+ }
342
+ if (!cached) towers.audio!.cache?.put(keys[runIndex]!, raw);
343
+ using bf = raw.astype(embeds.dtype);
344
+ using scale = ops.scalarLike(towers.audio!.tower.embedScale, bf);
345
+ cast = ops.div(bf, scale);
346
+ }
347
+ const { start, length } = run;
348
+ try {
349
+ const updated = ops.sliceUpdate(embeds, cast, [0, start, 0], [1, start + length, hidden]);
350
+ embeds.dispose();
351
+ embeds = updated;
352
+ } finally { cast.dispose(); }
353
+ }
323
354
 
324
- // masks over the spliced sequence: union multimodal soft tokens (per-layer
325
- // id zeroing) always; image-only bidirectional overlay only when NO audio
326
- // is present (§3.3 Q1).
327
- const mmInts = new Int32Array(spliced.length);
328
- for (let i = 0; i < spliced.length; i++) {
329
- const id = spliced[i]!;
330
- mmInts[i] = (vIds && id === vIds.imageTokenId) || (aIds && id === aIds.audioTokenId) ? 1 : 0;
331
- }
332
- const mmI32 = MlxArray.fromInt32(mmInts, [spliced.length]);
333
- const multimodalMask = mmI32.astype(Dtype.bool);
334
- mmI32.dispose();
355
+ // masks over the spliced sequence: union multimodal soft tokens (per-layer
356
+ // id zeroing) always; image-only bidirectional overlay only when NO audio
357
+ // is present (§3.3 Q1).
358
+ const mmInts = new Int32Array(spliced.length);
359
+ for (let i = 0; i < spliced.length; i++) {
360
+ const id = spliced[i]!;
361
+ mmInts[i] = (vIds && id === vIds.imageTokenId) || (aIds && id === aIds.audioTokenId) ? 1 : 0;
362
+ }
363
+ const mmI32 = MlxArray.fromInt32(mmInts, [spliced.length]);
364
+ const multimodalMask = mmI32.astype(Dtype.bool);
365
+ mmI32.dispose();
335
366
 
336
- let bidirMask: MlxArray | null = null;
337
- if (images.length > 0 && audio.length === 0) {
338
- const imgInts = new Int32Array(spliced.length);
339
- for (let i = 0; i < spliced.length; i++)
340
- imgInts[i] = spliced[i] === vIds!.imageTokenId ? 1 : 0;
341
- const imgI32 = MlxArray.fromInt32(imgInts, [spliced.length]);
342
- bidirMask = imgI32.astype(Dtype.bool);
343
- imgI32.dispose();
344
- }
367
+ let bidirMask: MlxArray | null = null;
368
+ if (images.length > 0 && audio.length === 0) {
369
+ const imgInts = new Int32Array(spliced.length);
370
+ for (let i = 0; i < spliced.length; i++)
371
+ imgInts[i] = spliced[i] === vIds!.imageTokenId ? 1 : 0;
372
+ const imgI32 = MlxArray.fromInt32(imgInts, [spliced.length]);
373
+ bidirMask = imgI32.astype(Dtype.bool);
374
+ imgI32.dispose();
375
+ }
345
376
 
346
- return { ids: spliced, embeddings: embeds, bidirMask, multimodalMask };
377
+ return { ids: spliced, embeddings: embeds, bidirMask, multimodalMask, prefixIdentity };
378
+ } catch (error) { embeds.dispose(); throw error; }
379
+ });
380
+ } finally { for (const tensor of reused) tensor?.dispose(); }
347
381
  }
348
382
 
349
383
  /** Vision-only prompt (back-compat wrapper over buildMultimodalPrompt —
@@ -360,14 +394,16 @@ export async function buildVisionPrompt<P extends { softTokens: number }>(
360
394
  images: Uint8Array[],
361
395
  tokenIds: VisionTokenIds,
362
396
  tools: ToolDefinition[] | null = null,
397
+ nativeWork: PromptNativeWork = (work) => work(),
398
+ cache?: EncoderCache,
363
399
  ): Promise<VisionPrompt> {
364
400
  const mp = await buildMultimodalPrompt(
365
- model, { vision: { tower, tokenIds } }, tokenizer, template,
366
- messages, images, [], tools,
401
+ model, { vision: { tower, tokenIds, cache } }, tokenizer, template,
402
+ messages, images, [], tools, nativeWork,
367
403
  );
368
404
  // no audio was passed, so the union mask IS the image mask — reuse it for
369
405
  // the zero-image edge (the original builder returned an all-false mask).
370
406
  const imageMask = mp.bidirMask ?? mp.multimodalMask;
371
407
  if (mp.bidirMask) mp.multimodalMask.dispose();
372
- return { ids: mp.ids, embeddings: mp.embeddings, imageMask };
408
+ return { ids: mp.ids, embeddings: mp.embeddings, imageMask, prefixIdentity: mp.prefixIdentity };
373
409
  }