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.
- package/README.md +57 -32
- package/docs/README.md +58 -42
- package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
- package/docs/archive/investigations/openjarvis-fit.md +198 -0
- package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
- package/docs/archive/planning/release-notes-v0.4.0.md +45 -0
- package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
- package/docs/design/batching.md +1535 -50
- package/docs/design/decode-speed-program.md +394 -1171
- package/docs/design/generic-model-support.md +195 -3
- package/docs/design/kv-cache.md +538 -225
- package/docs/design/speculative-decoding.md +552 -50
- package/docs/design/turboquant.md +140 -17
- package/docs/design/unified-engine-frontier-plan.md +259 -150
- package/docs/planning/PRODUCT_ROADMAP.md +21 -6
- package/docs/planning/public-readiness-handoff.md +244 -0
- package/docs/planning/public-readiness.md +149 -0
- package/docs/reference/benchmarks.md +3537 -1112
- package/docs/reference/cli.md +102 -2
- package/docs/reference/distribution.md +45 -13
- package/docs/reference/environment.md +159 -13
- package/docs/reference/library-api.md +18 -4
- package/docs/reference/memory.md +1 -1
- package/docs/reference/models.md +28 -7
- package/docs/reference/server-api.md +257 -33
- package/docs/reference/server-config.md +372 -151
- package/docs/reference/troubleshooting.md +13 -21
- package/package.json +2 -2
- package/src/audio/audiotoolbox.ts +112 -0
- package/src/audio/conformer.ts +8 -0
- package/src/audio/mic-capture.ts +98 -0
- package/src/audio/silero-vad.ts +379 -0
- package/src/audio/whisper-decode.ts +774 -0
- package/src/audio/whisper-fast.ts +732 -0
- package/src/audio/whisper-format.ts +73 -0
- package/src/audio/whisper-mel.ts +185 -0
- package/src/audio/whisper-timing.ts +274 -0
- package/src/audio/whisper-tokenizer.ts +239 -0
- package/src/audio/whisper-transcribe.ts +410 -0
- package/src/backends/mlx/assistant-target.ts +46 -0
- package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
- package/src/backends/mlx/autoregressive.ts +22 -1
- package/src/backends/mlx/batch-group.ts +464 -236
- package/src/backends/mlx/cache-attachment.ts +67 -0
- package/src/backends/mlx/cache-layout.ts +31 -0
- package/src/backends/mlx/checkpoint-state.ts +53 -0
- package/src/backends/mlx/continuation-persistence.ts +107 -0
- package/src/backends/mlx/continuation-request.ts +43 -0
- package/src/backends/mlx/continuation.ts +77 -0
- package/src/backends/mlx/draft-target.ts +7 -22
- package/src/backends/mlx/encoder-cache.ts +25 -0
- package/src/backends/mlx/fill-append.ts +21 -8
- package/src/backends/mlx/fill-group.ts +497 -0
- package/src/backends/mlx/full-kv-row-append.ts +32 -0
- package/src/backends/mlx/full-kv-row-donor.ts +54 -0
- package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
- package/src/backends/mlx/gateway-binding.ts +122 -19
- package/src/backends/mlx/kv-codec.ts +9 -0
- package/src/backends/mlx/kv-maintenance.ts +161 -0
- package/src/backends/mlx/kv-tensor-rows.ts +212 -0
- package/src/backends/mlx/mixed-iteration.ts +62 -0
- package/src/backends/mlx/model-prompt.ts +46 -32
- package/src/backends/mlx/model-serving.ts +5 -4
- package/src/backends/mlx/paged-cache-codec.ts +27 -0
- package/src/backends/mlx/prefill-cohort.ts +100 -0
- package/src/backends/mlx/prefill-policy.ts +26 -0
- package/src/backends/mlx/prefill-rows.ts +244 -0
- package/src/backends/mlx/prefill.ts +1 -1
- package/src/backends/mlx/projected-context-rows.ts +72 -0
- package/src/backends/mlx/prompt-input.ts +29 -0
- package/src/backends/mlx/qwen-prompt-input.ts +40 -0
- package/src/backends/mlx/request-state-policy.ts +54 -0
- package/src/backends/mlx/rollback.ts +45 -4
- package/src/backends/mlx/row-sampling.ts +49 -0
- package/src/backends/mlx/serial-executor.ts +51 -73
- package/src/backends/mlx/speculative-group.ts +438 -0
- package/src/backends/mlx/speculative-round.ts +161 -0
- package/src/backends/mlx/speculative.ts +24 -7
- package/src/backends/mlx/state-rows.ts +104 -0
- package/src/backends/mlx/state-views.ts +21 -1
- package/src/backends/mlx/target-layout-capability.ts +35 -0
- package/src/backends/mlx/transitioning-kv-rows.ts +173 -0
- package/src/cli.ts +333 -36
- package/src/contracts/execution.ts +21 -0
- package/src/contracts/object-cache.ts +8 -0
- package/src/contracts/pi-provider.ts +1 -1
- package/src/contracts/pi.ts +3 -1
- package/src/contracts/prefix-cache.ts +33 -0
- package/src/contracts/resources.ts +7 -0
- package/src/contracts/sampling.ts +23 -0
- package/src/contracts/scheduling.ts +27 -0
- package/src/engine/execution-plan.ts +38 -25
- package/src/engine/execution-tasks.ts +44 -0
- package/src/engine/namespaced-cache.ts +13 -0
- package/src/engine/resources.ts +12 -1
- package/src/engine/scheduler.ts +32 -7
- package/src/fill/fill-session.ts +14 -3
- package/src/fit.ts +29 -15
- package/src/generate.ts +105 -195
- package/src/grammar.ts +13 -0
- package/src/inference/draft-acceptance.ts +41 -0
- package/src/inference/draft-output.ts +49 -0
- package/src/inference/prefill.ts +6 -0
- package/src/inference/rollback.ts +2 -2
- package/src/kv-scheme.ts +92 -12
- package/src/kv-store.ts +183 -93
- package/src/lab/README.md +5 -1
- package/src/lab/paged-kv/paged-attention.ts +117 -0
- package/src/lab/paged-kv/paged-kv-rows.ts +111 -0
- package/src/lab/paged-kv/paged-kv.ts +126 -46
- package/src/md-modules.d.ts +5 -0
- package/src/mlx/array.ts +39 -1
- package/src/mlx/ffi.ts +19 -0
- package/src/mlx/normalized-argmax.ts +116 -0
- package/src/mlx/ops.ts +97 -10
- package/src/model/artifact-identity.ts +73 -0
- package/src/model/batched-kv.ts +235 -0
- package/src/model/batched-mask.ts +12 -4
- package/src/model/batched-quant.ts +8 -2
- package/src/model/batched-quantized-kv.ts +144 -0
- package/src/model/batched-rotating-quant.ts +76 -15
- package/src/model/batched-rotating-state.ts +76 -0
- package/src/model/batched-rotating.ts +64 -22
- package/src/model/batched-row-storage.ts +52 -0
- package/src/model/batched-ssm.ts +52 -0
- package/src/model/batched-turboquant-kv.ts +94 -0
- package/src/model/compiled-decode.ts +7 -1
- package/src/model/decoded-kv-donor.ts +12 -0
- package/src/model/delayed-quantized-kv.ts +72 -0
- package/src/model/delayed-rotating-quantized-kv.ts +93 -0
- package/src/model/delayed-turboquant-kv.ts +57 -0
- package/src/model/factory.ts +26 -0
- package/src/model/fingerprint.ts +12 -0
- package/src/model/full-prefill-padding.ts +56 -0
- package/src/model/full-prefill-row.ts +105 -0
- package/src/model/gemma4-base.ts +183 -82
- package/src/model/gemma4.ts +116 -28
- package/src/model/generated/gemma4-12b.ts +4 -1
- package/src/model/generated/gemma4-26b.ts +4 -1
- package/src/model/generated/gemma4-e4b.ts +4 -1
- package/src/model/glm52-cache.ts +80 -4
- package/src/model/glm52-mla.ts +12 -6
- package/src/model/glm52.ts +19 -40
- package/src/model/kv-attention-view.ts +70 -0
- package/src/model/minicpm5.ts +11 -7
- package/src/model/profile.ts +29 -4
- package/src/model/quantized-append-attention.ts +31 -0
- package/src/model/qwen3-delta-state.ts +74 -0
- package/src/model/qwen3-delta.ts +59 -36
- package/src/model/qwen3-mrope.ts +7 -9
- package/src/model/qwen3_5.ts +177 -75
- package/src/model/rotating-kv-donor.ts +35 -0
- package/src/model/rotating-kv-layout.ts +262 -0
- package/src/model/rotating-row-transaction.ts +35 -0
- package/src/model/speculative-rotating-kv.ts +101 -0
- package/src/model/ssm-prefill-padding.ts +39 -0
- package/src/model/support.ts +8 -0
- package/src/model/token-groups.ts +66 -0
- package/src/model/trellis-linear.ts +4 -4
- package/src/model/turboquant-codec.ts +89 -0
- package/src/model/universal/dense.ts +14 -13
- package/src/model/whisper.ts +435 -0
- package/src/native/mic_capture.swift +92 -0
- package/src/native-pack.ts +6 -5
- package/src/pi-web.ts +9 -1
- package/src/preflight.ts +31 -15
- package/src/prompt-cache.ts +306 -86
- package/src/runtime-config.ts +1 -5
- package/src/safetensors.ts +9 -2
- package/src/sampler.ts +37 -25
- package/src/serve/audio-routes.ts +323 -0
- package/src/serve/chat-request.ts +2 -0
- package/src/serve/chat-stage.ts +19 -14
- package/src/serve/completion-executor.ts +9 -1
- package/src/serve/discovery-routes.ts +17 -1
- package/src/serve/generated-token-history.ts +57 -0
- package/src/serve/generation-gateway.ts +88 -38
- package/src/serve/model-binding.ts +7 -1
- package/src/serve/model-host.ts +3 -4
- package/src/serve/preparation.ts +3 -3
- package/src/serve/prompt-response-trace.ts +14 -0
- package/src/serve/request-plan.ts +6 -3
- package/src/serve/request-prep.ts +40 -25
- package/src/serve/text-completion-stage.ts +2 -2
- package/src/serve/transcription-server.ts +64 -0
- package/src/serve/transcription-service.ts +502 -0
- package/src/server.ts +185 -261
- package/src/spec/assistant-rows.ts +93 -0
- package/src/spec/assistant-source.ts +28 -18
- package/src/spec/cache-identity.ts +22 -0
- package/src/spec/deepspec-rows.ts +29 -0
- package/src/spec/deepspec-source.ts +12 -10
- package/src/spec/dflash-source.ts +28 -4
- package/src/spec/drafter.ts +153 -142
- package/src/spec/dspark/deepspec-module.ts +95 -120
- package/src/spec/dspark/module-dflash.ts +126 -170
- package/src/spec/glm52-mtp-graph.ts +29 -0
- package/src/spec/glm52-mtp-rows.ts +132 -0
- package/src/spec/glm52-mtp-source.ts +54 -136
- package/src/spec/glm52-mtp-state.ts +33 -0
- package/src/spec/ngram-source.ts +115 -21
- package/src/spec/projected-draft-rows.ts +73 -0
- package/src/spec/qwen-mtp-module.ts +171 -0
- package/src/spec/qwen-mtp-rows.ts +230 -0
- package/src/spec/qwen-mtp-source.ts +84 -226
- package/src/spec/qwen-mtp-state.ts +30 -0
- package/src/spec/serve-loop.ts +73 -69
- package/src/spec/source.ts +93 -15
- package/src/spec/two-model-rows.ts +62 -0
- package/src/spec/two-model.ts +45 -103
- package/src/ssd-cache.ts +107 -36
- package/src/ssd-durability.ts +29 -27
- package/src/storage/host-buffer.ts +24 -0
- package/src/storage/kv-writer.ts +83 -0
- package/src/storage/kv-writer.worker.js +264 -0
- package/src/storage/retention-policy.ts +37 -0
- package/src/tiered-prompt-cache.ts +45 -0
- package/src/vision/embedder.ts +8 -0
- package/src/vision/prompt.ts +101 -65
- package/src/vision/qwen3vl-prompt.ts +101 -64
- package/src/vision/qwen3vl-tower.ts +11 -0
- package/src/vision/siglip.ts +8 -0
- package/src/web/app.html +12 -0
- package/src/web/app.js +217 -57
- package/src/web/src/chat.ts +4 -1
- package/src/web/src/composer.ts +3 -0
- package/src/web/src/voice.ts +141 -0
- package/src/web/sw.js +1 -1
- package/src/backends/mlx/memory-guard.ts +0 -40
- package/src/spec/prefix-state.ts +0 -45
- /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
package/src/vision/prompt.ts
CHANGED
|
@@ -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
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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}`);
|
package/src/vision/siglip.ts
CHANGED
|
@@ -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+/
|