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
@@ -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
  }
@@ -18,10 +18,12 @@ import type { ChatMessage, ChatTemplate, ToolDefinition } from "../chat-template
18
18
  import type { LoadedTokenizer } from "../tokenizer";
19
19
  import type { Qwen35Model } from "../model/qwen3_5";
20
20
  import { qwenRopeIndex, type MropeRequestState } from "../model/qwen3-mrope";
21
- import { preprocessQwen3VLImage, preprocessQwen3VLVideoFrames, pyFixed1 } from "./qwen3vl-preprocess";
21
+ import { preprocessQwen3VLImage, preprocessQwen3VLVideoFrames, pyFixed1, type Qwen3VLPreprocessed } from "./qwen3vl-preprocess";
22
22
  import { extractVideoFrames } from "./video-frames";
23
23
  import { QWEN3VL_MERGE_SIZE } from "./qwen3vl-preprocess";
24
24
  import type { Qwen3VLVisionTower } from "./qwen3vl-tower";
25
+ import { createHash } from "node:crypto";
26
+ import type { EncoderCache } from "../backends/mlx/encoder-cache";
25
27
 
26
28
  export interface Qwen3VLTokenIds {
27
29
  imageTokenId: number; // <|image_pad|>
@@ -34,6 +36,7 @@ export interface Qwen3VLTokenIds {
34
36
 
35
37
  export interface Qwen3VLVisionPrompt {
36
38
  ids: number[];
39
+ prefixIdentity?: string;
37
40
  /** [1, L, H] bf16 spliced input embeddings (caller owns → generate()). */
38
41
  embeddings: MlxArray;
39
42
  /** Request mRoPE state for Qwen35Model.mrope. */
@@ -50,6 +53,8 @@ export async function buildQwen3VLVisionPrompt(
50
53
  tokenIds: Qwen3VLTokenIds,
51
54
  renderOptions: Parameters<ChatTemplate["render"]>[1] = {},
52
55
  videos: Uint8Array[] = [],
56
+ nativeWork: <T>(work: () => Promise<T>) => Promise<T> = work => work(),
57
+ encoderCache?: EncoderCache,
53
58
  ): Promise<Qwen3VLVisionPrompt> {
54
59
  // The request's full template options (tools, enableThinking,
55
60
  // reasoningEffort, preserveThinking) flow through — a media prompt must
@@ -71,9 +76,9 @@ export async function buildQwen3VLVisionPrompt(
71
76
 
72
77
  // Preprocess each medium in its own appearance order; videos decode to
73
78
  // sampled frames through the AVFoundation sidecar first.
74
- const imagePps = [];
79
+ const imagePps: Qwen3VLPreprocessed[] = [];
75
80
  for (const img of images) imagePps.push(await preprocessQwen3VLImage(img));
76
- const videoPps = [];
81
+ const videoPps: Qwen3VLPreprocessed[] = [];
77
82
  for (const vid of videos)
78
83
  videoPps.push(preprocessQwen3VLVideoFrames(await extractVideoFrames(vid)));
79
84
 
@@ -93,11 +98,13 @@ export async function buildQwen3VLVisionPrompt(
93
98
  const grids: [number, number, number][] = [];
94
99
  let imgIdx = 0;
95
100
  let vidIdx = 0;
101
+ let lastMediaEnd = 0;
96
102
  for (const t of rawIds) {
97
103
  if (t === tokenIds.imageTokenId) {
98
104
  const pp = imagePps[imgIdx++]!;
99
105
  grids.push(pp.gridThw);
100
106
  for (let i = 0; i < pp.imageTokens; i++) ids.push(tokenIds.imageTokenId);
107
+ lastMediaEnd = ids.length;
101
108
  } else if (t === tokenIds.videoTokenId) {
102
109
  const pp = videoPps[vidIdx++]!;
103
110
  const [gridT, gridH, gridW] = pp.gridThw;
@@ -111,6 +118,7 @@ export async function buildQwen3VLVisionPrompt(
111
118
  for (let i = 0; i < frameTokens; i++) ids.push(tokenIds.videoTokenId);
112
119
  ids.push(visionEndId);
113
120
  }
121
+ lastMediaEnd = ids.length;
114
122
  } else ids.push(t);
115
123
  }
116
124
 
@@ -126,71 +134,100 @@ export async function buildQwen3VLVisionPrompt(
126
134
  // path can't reach any of it (2026-08-18 review: repeated failing media
127
135
  // requests would otherwise strand un-admitted GPU memory on the GC
128
136
  // backstop).
129
- const idsArr = ops.fromInt32(ids, [1, ids.length]);
130
- const textEmb = model.embed.encode(idsArr); // [1, L, H]
131
- idsArr.dispose();
132
- const owned: MlxArray[] = [];
137
+ // Look up exact preprocessed content before entering the execution queue.
138
+ // URLs are not identities: their contents can change. Position/timestamp
139
+ // assembly above remains request-owned even when encoder features are reused.
140
+ const inputs = [...imagePps, ...videoPps];
141
+ const keys = encoderCache ? inputs.map(pp => createHash("sha256")
142
+ .update(`qwen3vl-v1:${JSON.stringify(pp.gridThw)}:${pp.rows}:${pp.cols}:`)
143
+ .update(new Uint8Array(pp.pixelValues.buffer, pp.pixelValues.byteOffset, pp.pixelValues.byteLength))
144
+ .digest("hex")) : [];
145
+ const prefixIdentity = encoderCache && lastMediaEnd > 0
146
+ ? createHash("sha256").update("qwen-prepared-prefix-v1").update(encoderCache.identity)
147
+ .update(JSON.stringify({ keys, tokens: ids.slice(0, lastMediaEnd), delta: mrope.delta,
148
+ positions: mrope.positions.map(axis => Array.from(axis.slice(0, lastMediaEnd))),
149
+ })).digest("hex") : undefined;
150
+ const reused: Array<MlxArray | null> = [];
133
151
  try {
134
- const H = textEmb.shape[2]!;
135
- const segments: MlxArray[] = [];
136
- let cursor = 0;
137
- imgIdx = 0;
138
- vidIdx = 0;
139
- // A video's tower features cover ALL its frame groups; consecutive pad
140
- // RUNS consume consecutive feature-row windows.
141
- let vidFeat: MlxArray | null = null;
142
- let vidRow = 0;
143
- for (let i = 0; i < ids.length; ) {
144
- const isImage = ids[i] === tokenIds.imageTokenId;
145
- const isVideo = ids[i] === tokenIds.videoTokenId;
146
- if (isImage || isVideo) {
147
- // Measure this contiguous pad run.
148
- let runEnd = i;
149
- while (runEnd < ids.length && ids[runEnd] === ids[i]) runEnd++;
150
- const runLen = runEnd - i;
151
- if (cursor < i) {
152
- const seg = textEmb.slice([0, cursor, 0], [1, i, H]);
152
+ for (const key of keys) reused.push(await encoderCache!.take(key));
153
+ return await nativeWork(async () => {
154
+ const encode = (index: number): MlxArray => {
155
+ const cached = reused[index];
156
+ if (cached) return cached.slice([0, 0], [...cached.shape]);
157
+ const features = tower.encode(inputs[index]!);
158
+ try { if (encoderCache) encoderCache.put(keys[index]!, features); }
159
+ catch (error) { features.dispose(); throw error; }
160
+ return features;
161
+ };
162
+ const idsArr = ops.fromInt32(ids, [1, ids.length]);
163
+ const textEmb = model.embed.encode(idsArr); // [1, L, H]
164
+ idsArr.dispose();
165
+ const owned: MlxArray[] = [];
166
+ try {
167
+ const H = textEmb.shape[2]!;
168
+ const segments: MlxArray[] = [];
169
+ let cursor = 0;
170
+ imgIdx = 0;
171
+ vidIdx = 0;
172
+ // A video's tower features cover ALL its frame groups; consecutive pad
173
+ // RUNS consume consecutive feature-row windows.
174
+ let vidFeat: MlxArray | null = null;
175
+ let vidRow = 0;
176
+ for (let i = 0; i < ids.length; ) {
177
+ const isImage = ids[i] === tokenIds.imageTokenId;
178
+ const isVideo = ids[i] === tokenIds.videoTokenId;
179
+ if (isImage || isVideo) {
180
+ // Measure this contiguous pad run.
181
+ let runEnd = i;
182
+ while (runEnd < ids.length && ids[runEnd] === ids[i]) runEnd++;
183
+ const runLen = runEnd - i;
184
+ if (cursor < i) {
185
+ const seg = textEmb.slice([0, cursor, 0], [1, i, H]);
186
+ segments.push(seg);
187
+ owned.push(seg);
188
+ }
189
+ let rows: MlxArray;
190
+ if (isImage) {
191
+ const feat = encode(imgIdx++); // [runLen, H]
192
+ owned.push(feat);
193
+ rows = feat;
194
+ } else {
195
+ if (!vidFeat) {
196
+ vidFeat = encode(imagePps.length + vidIdx); // [imageTokens, H]
197
+ owned.push(vidFeat);
198
+ vidRow = 0;
199
+ }
200
+ const sl = vidFeat.slice([vidRow, 0], [vidRow + runLen, H]);
201
+ owned.push(sl);
202
+ rows = sl;
203
+ vidRow += runLen;
204
+ if (vidRow >= videoPps[vidIdx]!.imageTokens) {
205
+ vidFeat = null;
206
+ vidIdx++;
207
+ }
208
+ }
209
+ const feat3 = ops.reshape(rows, [1, runLen, H]);
210
+ segments.push(feat3);
211
+ owned.push(feat3);
212
+ cursor = runEnd;
213
+ i = cursor;
214
+ } else i++;
215
+ }
216
+ if (cursor < ids.length) {
217
+ const seg = textEmb.slice([0, cursor, 0], [1, ids.length, H]);
153
218
  segments.push(seg);
154
219
  owned.push(seg);
155
220
  }
156
- let rows: MlxArray;
157
- if (isImage) {
158
- const feat = tower.encode(imagePps[imgIdx++]!); // [runLen, H]
159
- owned.push(feat);
160
- rows = feat;
161
- } else {
162
- if (!vidFeat) {
163
- vidFeat = tower.encode(videoPps[vidIdx]!); // [imageTokens, H]
164
- owned.push(vidFeat);
165
- vidRow = 0;
166
- }
167
- const sl = vidFeat.slice([vidRow, 0], [vidRow + runLen, H]);
168
- owned.push(sl);
169
- rows = sl;
170
- vidRow += runLen;
171
- if (vidRow >= videoPps[vidIdx]!.imageTokens) {
172
- vidFeat = null;
173
- vidIdx++;
174
- }
175
- }
176
- const feat3 = ops.reshape(rows, [1, runLen, H]);
177
- segments.push(feat3);
178
- owned.push(feat3);
179
- cursor = runEnd;
180
- i = cursor;
181
- } else i++;
182
- }
183
- if (cursor < ids.length) {
184
- const seg = textEmb.slice([0, cursor, 0], [1, ids.length, H]);
185
- segments.push(seg);
186
- owned.push(seg);
187
- }
188
- const embeddings = segments.length === 1
189
- ? ops.contiguous(segments[0]!)
190
- : ops.concatAxis(segments, 1);
191
- return { ids, embeddings, mrope };
221
+ const embeddings = segments.length === 1
222
+ ? ops.contiguous(segments[0]!)
223
+ : ops.concatAxis(segments, 1);
224
+ return { ids, embeddings, mrope, prefixIdentity };
225
+ } finally {
226
+ for (const s of owned) s.dispose();
227
+ textEmb.dispose();
228
+ }
229
+ });
192
230
  } finally {
193
- for (const s of owned) s.dispose();
194
- textEmb.dispose();
231
+ for (const features of reused) features?.dispose();
195
232
  }
196
233
  }
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { ptr, read } from "bun:ffi";
20
20
  import { existsSync, readFileSync } from "node:fs";
21
+ import { tensorFingerprint } from "../model/fingerprint";
21
22
  import { MlxArray, cpuStream } from "../mlx/array";
22
23
  import { C, Dtype } from "../mlx/ffi";
23
24
  import * as ops from "../mlx/ops";
@@ -55,6 +56,15 @@ interface Block {
55
56
  }
56
57
 
57
58
  export class Qwen3VLVisionTower {
59
+ #identity: string | undefined;
60
+ readonly #identityWeights: Array<[string, MlxArray]>;
61
+
62
+ /** Computed once, under native preparation. Exact weight content prevents
63
+ * cross-artifact SSD reuse even when model aliases and shapes are identical. */
64
+ get cacheIdentity(): string {
65
+ if (this.#identity) return this.#identity;
66
+ return this.#identity = tensorFingerprint(this.#identityWeights);
67
+ }
58
68
  #patchW: MlxArray; // [1536, 1152] — flattened conv, already transposed
59
69
  #patchB: MlxArray;
60
70
  #posEmbed: MlxArray; // [2304, 1152]
@@ -64,6 +74,7 @@ export class Qwen3VLVisionTower {
64
74
  #mergerFc2W: MlxArray; #mergerFc2B: MlxArray;
65
75
 
66
76
  private constructor(w: Map<string, MlxArray>) {
77
+ this.#identityWeights = [...w];
67
78
  const t = (n: string): MlxArray => {
68
79
  const a = w.get(`vision_tower.${n}`);
69
80
  if (!a) throw new Error(`vision sidecar missing tensor vision_tower.${n}`);
@@ -54,6 +54,7 @@
54
54
  // composition order. Left at tier-a for now (good enough — grounded, exact ids,
55
55
  // greedy prefix); revisit to drive the encoder to 0% by aligning the op order.
56
56
 
57
+ import { tensorFingerprint } from "../model/fingerprint";
57
58
  import { ptr, read } from "bun:ffi";
58
59
  import { MlxArray, cpuStream } from "../mlx/array";
59
60
  import { C, Dtype } from "../mlx/ffi";
@@ -189,6 +190,13 @@ export async function preprocessSiglip(
189
190
 
190
191
  export class SiglipVisionTower {
191
192
  #weights = new Map<string, MlxArray>();
193
+ #cacheIdentity?: string;
194
+ /** Immutable encoder weights and computation settings, hashed once on use. */
195
+ get cacheIdentity(): string {
196
+ return this.#cacheIdentity ??= new Bun.CryptoHasher("sha256")
197
+ .update("gemma-siglip-v1").update(JSON.stringify([this.cfg, this.embedScale]))
198
+ .update(tensorFingerprint(this.#weights)).digest("hex");
199
+ }
192
200
  readonly cfg: SiglipVisionConfig;
193
201
  readonly embedScale: number;
194
202
  #wdtype: Dtype = Dtype.bfloat16;
package/src/web/app.html CHANGED
@@ -1118,6 +1118,13 @@
1118
1118
  #chat-attach-btn{flex:0 0 auto;width:50px;height:50px;border-radius:14px;background:var(--card);
1119
1119
  border:1px solid var(--hairline);color:var(--ink);font-size:23px;line-height:1;cursor:pointer;transition:background .15s,border-color .15s}
1120
1120
  #chat-attach-btn:hover{background:var(--card-hover);border-color:rgba(10,132,255,.5)}
1121
+ /* hold-to-talk mic (voice.ts) */
1122
+ #chat-mic-btn{flex:0 0 auto;width:50px;height:50px;border-radius:14px;background:var(--card);
1123
+ border:1px solid var(--hairline);color:var(--ink);font-size:20px;line-height:1;cursor:pointer;user-select:none;-webkit-user-select:none;transition:background .15s,border-color .15s}
1124
+ #chat-mic-btn:hover{background:var(--card-hover);border-color:rgba(10,132,255,.5)}
1125
+ #chat-mic-btn.recording{background:rgba(255,69,58,.18);border-color:rgba(255,69,58,.7);animation:micpulse 1s ease-in-out infinite}
1126
+ #chat-mic-btn.busy{opacity:.6;cursor:progress}
1127
+ @keyframes micpulse{0%,100%{box-shadow:0 0 0 0 rgba(255,69,58,.35)}50%{box-shadow:0 0 0 8px rgba(255,69,58,0)}}
1121
1128
  .attach-row{display:none;flex-wrap:wrap;gap:8px;max-width:840px;width:100%;margin:0 auto 8px}
1122
1129
  .attach-chip{display:flex;align-items:center;gap:7px;background:var(--card);border:1px solid var(--hairline);
1123
1130
  border-radius:10px;padding:5px 9px;font-size:12px;max-width:240px}
@@ -1496,6 +1503,11 @@
1496
1503
  <div class="inputrow">
1497
1504
  <button id="chat-attach-btn" class="iconbtn" title="Attach files or images" aria-label="Attach files">+</button>
1498
1505
  <input type="file" id="chat-file-input" multiple style="display:none">
1506
+ <!-- Hold-to-talk dictation (voice.ts): shown only when the `ready`
1507
+ frame carries transcription=true (a Whisper checkpoint serves
1508
+ /v1/audio/sessions). Press and hold (mouse/touch, or Space/Enter
1509
+ while focused); release to transcribe into the box. -->
1510
+ <button id="chat-mic-btn" class="iconbtn" title="Hold to talk (Whisper)" aria-label="Hold to talk" style="display:none">🎙</button>
1499
1511
  <div id="chat-box-wrap">
1500
1512
  <!-- Keep the placeholder SHORT: the Enter/Shift+Enter AND "#"
1501
1513
  hints live in the chat-hint status line below (and the Cmd+/