mlx-bun 0.0.10 → 0.0.12
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 +107 -32
- package/THIRD_PARTY_LICENSES.md +10 -0
- package/docs/archive/mlx-bun-lab-report.html +275 -0
- package/docs/archive/wwdc-mlx-bun.html +848 -0
- package/docs/design/audio-input-plan.md +356 -0
- package/docs/design/batching-perf-path.md +58 -35
- package/docs/design/batching-v2-plan.md +23 -9
- package/docs/design/bucketing-stage.md +2 -1
- package/docs/design/colibri-glm52-port.md +1479 -0
- package/docs/design/compat-cli-surface-design.md +4 -3
- package/docs/design/decode-speed-program.md +172 -0
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dspark-serving-program.md +371 -0
- package/docs/design/dspark-speculative-decoding.md +167 -5
- package/docs/design/faithful-l1-consolidation.md +310 -0
- package/docs/design/generic-model-support.md +8 -5
- package/docs/design/grammar-spec-batching-integration.md +301 -0
- package/docs/design/minicpm5-decode-megakernel.md +16 -11
- package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
- package/docs/design/omlx-adoption-map.md +14 -6
- package/docs/design/orpo-training.md +34 -30
- package/docs/design/paged-kv-cache.md +214 -0
- package/docs/design/parallel-slots.md +82 -49
- package/docs/design/parity-tier-dag.md +43 -33
- package/docs/design/pre-colibri-stabilization.md +329 -0
- package/docs/design/repo-cleanup-plan.md +189 -0
- package/docs/design/runtime-isolation.md +19 -3
- package/docs/design/segmented-backward-training.md +3 -2
- package/docs/design/ssd-kv-cold-tier.md +130 -0
- package/docs/design/structured-output.md +484 -0
- package/docs/design/the-dreaming-master-plan.md +2 -3
- package/docs/design/turboquant-kv.md +235 -0
- package/docs/design/unified-engine-frontier-plan.md +774 -0
- package/docs/design/web-chat-beat-matrix.md +315 -0
- package/docs/design/web-chat-redesign.md +1112 -0
- package/docs/investigations/colibri-oracle-pin.md +1284 -0
- package/docs/investigations/dspark-handoff.md +182 -16
- package/docs/investigations/indexshare-performance-spike.md +357 -0
- package/docs/investigations/lab-build-journal.md +2 -2
- package/docs/investigations/openwiki-evaluation.md +249 -0
- package/docs/planning/IDEAS.md +14 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -3
- package/docs/planning/chat-ui-vision-memory-first.md +199 -0
- package/docs/planning/chat-ui-vision.md +50 -0
- package/docs/planning/memory-docs-and-dag-plan.md +15 -6
- package/docs/planning/release-notes-v0.0.11.md +239 -0
- package/docs/planning/release-notes-v0.0.12.md +80 -0
- package/docs/reference/cli.md +63 -18
- package/docs/reference/distribution.md +37 -6
- package/docs/reference/embedding.md +40 -16
- package/docs/reference/features-matrix.md +99 -0
- package/docs/reference/library-api.md +69 -20
- package/docs/reference/memory.md +26 -6
- package/docs/reference/models.md +60 -3
- package/docs/reference/orpo-quickstart.md +3 -4
- package/docs/reference/server-api.md +717 -24
- package/docs/reference/server-config.md +265 -129
- package/docs/reference/training.md +24 -16
- package/package.json +5 -3
- package/src/anthropic.ts +67 -44
- package/src/audio/conformer.ts +647 -0
- package/src/audio/decode.ts +133 -0
- package/src/audio/features.ts +202 -0
- package/src/audio/transcode.ts +67 -0
- package/src/chat-template.ts +155 -10
- package/src/cli.ts +483 -113
- package/src/config.ts +116 -24
- package/src/diffusion/diffusion-generate.ts +14 -8
- package/src/download.ts +143 -12
- package/src/eval/README.md +7 -6
- package/src/eval/ifeval.ts +63 -120
- package/src/eval/kl.ts +59 -3
- package/src/eval/quality-db.ts +2 -2
- package/src/eval/runner.ts +17 -8
- package/src/eval/tasks/ifeval.ts +167 -31
- package/src/expert-io.ts +391 -0
- package/src/expert-offload.ts +15 -2
- package/src/expert-residency.ts +1134 -0
- package/src/expert-usage.ts +415 -0
- package/src/flags.ts +13 -0
- package/src/generate.ts +438 -35
- package/src/grammar.ts +585 -0
- package/src/hf-push.ts +1 -1
- package/src/hub-rest.ts +242 -0
- package/src/index.ts +14 -2
- package/src/jobs/runner.ts +37 -18
- package/src/kv-store.ts +586 -93
- package/src/lora.ts +85 -14
- package/src/md-modules.d.ts +42 -0
- package/src/media-fetch.ts +426 -0
- package/src/memory/rest.ts +245 -0
- package/src/memory/vault.ts +74 -0
- package/src/mlx/array.ts +149 -19
- package/src/mlx/autograd.ts +53 -16
- package/src/mlx/checkpoint.ts +46 -13
- package/src/mlx/custom-vjp.ts +83 -35
- package/src/mlx/ffi.ts +27 -3
- package/src/mlx/metal-kernel.ts +7 -4
- package/src/mlx/ops.ts +153 -23
- package/src/mlx/turboquant-ops.ts +831 -0
- package/src/mlx/turboquant-tables.ts +237 -0
- package/src/model/batched-mask.ts +62 -1
- package/src/model/batched-quant.ts +206 -0
- package/src/model/batched-rotating-quant.ts +346 -0
- package/src/model/batched-rotating.ts +46 -1
- package/src/model/compiled-decode.ts +4 -15
- package/src/model/factory.ts +103 -6
- package/src/model/gemma4-base.ts +395 -39
- package/src/model/gemma4.ts +122 -33
- package/src/model/generated/gemma4-12b.ts +9 -13
- package/src/model/generated/gemma4-26b.ts +10 -5
- package/src/model/generated/gemma4-e4b.ts +14 -9
- package/src/model/glm52-atlas.ts +511 -0
- package/src/model/glm52-cache.ts +706 -0
- package/src/model/glm52-config.ts +262 -0
- package/src/model/glm52-container.ts +305 -0
- package/src/model/glm52-coupling.ts +388 -0
- package/src/model/glm52-dsa.ts +602 -0
- package/src/model/glm52-expert-layout.ts +253 -0
- package/src/model/glm52-layout.ts +152 -0
- package/src/model/glm52-memory.ts +473 -0
- package/src/model/glm52-mla.ts +786 -0
- package/src/model/glm52-moe.ts +223 -0
- package/src/model/glm52-pilot.ts +537 -0
- package/src/model/glm52-reference.ts +445 -0
- package/src/model/glm52-residency.ts +557 -0
- package/src/model/glm52-resident-weights.ts +135 -0
- package/src/model/glm52-streamed-experts.ts +453 -0
- package/src/model/glm52-streamed-metal.ts +610 -0
- package/src/model/glm52-weights.ts +503 -0
- package/src/model/glm52.ts +1153 -0
- package/src/model/minicpm5.ts +58 -102
- package/src/model/paged-kv.ts +253 -0
- package/src/model/qwen3-delta.ts +76 -15
- package/src/model/qwen3-moe.ts +404 -0
- package/src/model/qwen3.ts +9 -8
- package/src/model/qwen3_5.ts +88 -23
- package/src/model/support.ts +19 -1
- package/src/model/universal/dense.ts +40 -5
- package/src/model/universal/rope.ts +36 -21
- package/src/native/expert_io.c +523 -0
- package/src/native-pack.ts +6 -4
- package/src/pi-web.ts +1299 -82
- package/src/preflight.ts +27 -8
- package/src/prompt-cache.ts +293 -33
- package/src/quantize/quantizer.ts +16 -1
- package/src/registry.ts +57 -2
- package/src/responses.ts +62 -49
- package/src/serve/batch-scheduler.ts +1050 -78
- package/src/serve/generation-gateway.ts +252 -24
- package/src/serve/isolate.ts +417 -0
- package/src/serve/lane-registry.ts +44 -0
- package/src/serve/session-search.ts +326 -0
- package/src/server.ts +1701 -175
- package/src/spec/assistant-source.ts +150 -0
- package/src/spec/deepspec-source.ts +143 -0
- package/src/spec/dflash-source.ts +124 -0
- package/src/spec/dspark/ab-stats.ts +136 -0
- package/src/spec/dspark/calibration.ts +112 -0
- package/src/spec/dspark/deepspec-module.ts +801 -0
- package/src/spec/dspark/generate-dflash.ts +72 -20
- package/src/spec/dspark/loader.ts +23 -0
- package/src/spec/dspark/module-dflash.ts +278 -36
- package/src/spec/dspark/quantize-drafter.ts +91 -0
- package/src/spec/generate.ts +59 -25
- package/src/spec/glm52-mtp-source.ts +392 -0
- package/src/spec/ngram-source.ts +132 -0
- package/src/spec/serve-loop.ts +617 -0
- package/src/spec/source.ts +118 -0
- package/src/spec/two-model.ts +171 -0
- package/src/ssd-cache.ts +111 -35
- package/src/tokenizer.ts +288 -2
- package/src/tool-approvals.ts +131 -0
- package/src/tool-call.ts +438 -22
- package/src/train/flash-cce.ts +2 -2
- package/src/train/job.ts +0 -12
- package/src/train/lora-params.ts +17 -6
- package/src/train/trainer.ts +60 -30
- package/src/vision/prompt.ts +217 -48
- package/src/web/app.html +1197 -1791
- package/src/web/app.js +5694 -0
- package/src/web/icon.svg +13 -0
- package/src/web/manifest.webmanifest +24 -0
- package/src/web/src/adapters-panel.ts +288 -0
- package/src/web/src/api.ts +57 -0
- package/src/web/src/assistant.ts +485 -0
- package/src/web/src/chat.ts +1050 -0
- package/src/web/src/composer.ts +959 -0
- package/src/web/src/dataset.ts +132 -0
- package/src/web/src/finetune.ts +267 -0
- package/src/web/src/hub.ts +428 -0
- package/src/web/src/main.ts +98 -0
- package/src/web/src/markdown.ts +0 -0
- package/src/web/src/memory-panel.ts +572 -0
- package/src/web/src/model-picker.ts +159 -0
- package/src/web/src/palette.ts +346 -0
- package/src/web/src/protocol.ts +62 -0
- package/src/web/src/quantize.ts +167 -0
- package/src/web/src/rag.ts +274 -0
- package/src/web/src/sessions.ts +469 -0
- package/src/web/src/shell.ts +804 -0
- package/src/web/src/status.ts +300 -0
- package/src/web/src/ui-catalog.ts +92 -0
- package/src/web/sw.js +71 -0
- package/src/web/vendor/README.md +61 -0
- package/src/web/vendor/hljs-LICENSE +29 -0
- package/src/web/vendor/hljs-theme.css +59 -0
- package/src/web/vendor/hljs.js +26 -0
- package/src/web-tools.ts +54 -2
- package/src/weights.ts +82 -19
- package/src/model/fused-decode-kernel.ts +0 -312
- package/src/model/fused-geglu-kernel.ts +0 -198
- package/src/model/fused-mlp-kernel.ts +0 -169
- package/src/model/fused-swiglu-kernel.ts +0 -240
- package/src/model/steel-linear-kernel.ts +0 -96
- package/src/status-page.html +0 -497
package/src/train/trainer.ts
CHANGED
|
@@ -20,7 +20,6 @@ import type { RuntimeModel } from "../model/factory";
|
|
|
20
20
|
import { Gemma4Model, type GradCheckpointCtx } from "../model/gemma4";
|
|
21
21
|
import { MiniCPM5Model } from "../model/minicpm5";
|
|
22
22
|
import { setTrainingAttn } from "../model/flash-attention";
|
|
23
|
-
import { setFusedGeluTraining } from "../model/fused-geglu-kernel";
|
|
24
23
|
import { SegmentedBackward, SegmentedBackwardGemma4, SegmentedBackwardOrpo, SegmentedBackwardOrpoGemma4, SegmentedBackwardOrpoPrefix, SegmentedBackwardOrpoPrefixGemma4, planSegmentsBySize } from "./segmented";
|
|
25
24
|
import type { LoadedTokenizer } from "../tokenizer";
|
|
26
25
|
import type { ChatTemplate } from "../chat-template";
|
|
@@ -203,6 +202,51 @@ export const DEFAULT_TRAIN_CONFIG: TrainConfig = {
|
|
|
203
202
|
baseModel: "",
|
|
204
203
|
};
|
|
205
204
|
|
|
205
|
+
/** Build the per-leaf LoRA+ LR multipliers for flatParams' stable
|
|
206
|
+
* `[...A, ...B]` order. `undefined` keeps AdamW's uniform-LR fast path. */
|
|
207
|
+
export function loraPlusLrScale(
|
|
208
|
+
paramCount: number,
|
|
209
|
+
targetCount: number,
|
|
210
|
+
ratio: number,
|
|
211
|
+
): number[] | undefined {
|
|
212
|
+
if (!Number.isFinite(ratio) || ratio < 1)
|
|
213
|
+
throw new RangeError(`loraPlusRatio must be a finite number >= 1 (got ${ratio})`);
|
|
214
|
+
if (paramCount !== targetCount * 2)
|
|
215
|
+
throw new Error(
|
|
216
|
+
`LoRA+ expected ${targetCount * 2} A/B leaves for ${targetCount} targets, got ${paramCount}`,
|
|
217
|
+
);
|
|
218
|
+
if (ratio === 1) return undefined;
|
|
219
|
+
return Array.from({ length: paramCount }, (_, i) => (i < targetCount ? 1 : ratio));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** One optimizer policy for SFT, DPO, and ORPO. Keeping LoRA+ here prevents a
|
|
223
|
+
* method-specific loop from silently accepting the knob without applying it. */
|
|
224
|
+
function createLoraOptimizer(
|
|
225
|
+
params: MlxArray[],
|
|
226
|
+
lora: TrainableLora,
|
|
227
|
+
cfg: TrainConfig,
|
|
228
|
+
emit: Emit,
|
|
229
|
+
): AdamW {
|
|
230
|
+
const lrScale = loraPlusLrScale(params.length, lora.targets.length, cfg.loraPlusRatio);
|
|
231
|
+
if (lrScale) emit({
|
|
232
|
+
type: "stage",
|
|
233
|
+
stage: "setup",
|
|
234
|
+
progress: 0.04,
|
|
235
|
+
message: `LoRA+ : B leaves at ${cfg.loraPlusRatio}× LR`,
|
|
236
|
+
});
|
|
237
|
+
return new AdamW(
|
|
238
|
+
params,
|
|
239
|
+
{
|
|
240
|
+
lr: cfg.learningRate,
|
|
241
|
+
betas: cfg.betas,
|
|
242
|
+
eps: 1e-8,
|
|
243
|
+
weightDecay: cfg.weightDecay,
|
|
244
|
+
lrScale,
|
|
245
|
+
},
|
|
246
|
+
(i, p) => writeParam(lora, i, p),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
206
250
|
/** Run a LoRA fine-tune. Emits per-step train metrics, periodic val metrics,
|
|
207
251
|
* and a final stage:done with the adapter path + applied ranks. Saves the
|
|
208
252
|
* adapter (last + best-on-val) and returns where it landed. */
|
|
@@ -260,7 +304,6 @@ export async function trainLora(
|
|
|
260
304
|
// autograd flows and the backward recomputes the gelu from the primal instead
|
|
261
305
|
// of retaining the spelled-out intermediates. No-op for non-Gemma models.
|
|
262
306
|
// Cleared in the finally below.
|
|
263
|
-
setFusedGeluTraining(true);
|
|
264
307
|
|
|
265
308
|
const saveCfg: SaveAdapterConfig = {
|
|
266
309
|
rank: cfg.rank,
|
|
@@ -288,6 +331,7 @@ export async function trainLora(
|
|
|
288
331
|
// weak proxy. Capture them when the loop emits them so metrics.json lets you
|
|
289
332
|
// pick the best-MARGIN checkpoint (overfit shows as train↑ / val-margin flat).
|
|
290
333
|
const valHistory: { step: number; loss: number; margin?: number; accuracy?: number; checkpoint: string | null }[] = [];
|
|
334
|
+
const checkpointSaves: Promise<void>[] = [];
|
|
291
335
|
const startedAt = Date.now();
|
|
292
336
|
|
|
293
337
|
// Live, append-only metrics stream (the source `mlx-bun train-watch` tails).
|
|
@@ -321,7 +365,11 @@ export async function trainLora(
|
|
|
321
365
|
if (cfg.saveCheckpoints) {
|
|
322
366
|
const tag = `step-${String(e.step).padStart(5, "0")}-val${e.loss.toFixed(4)}`;
|
|
323
367
|
checkpoint = `${cfg.adapterPath}/checkpoints/${tag}`;
|
|
324
|
-
saveAdapter(lora, checkpoint, saveCfg, appliedRanks);
|
|
368
|
+
const save = saveAdapter(lora, checkpoint, saveCfg, appliedRanks);
|
|
369
|
+
checkpointSaves.push(save);
|
|
370
|
+
// Keep a handler attached while training continues. Promise.all below
|
|
371
|
+
// still surfaces the original rejection before the run can succeed.
|
|
372
|
+
void save.catch(() => {});
|
|
325
373
|
emit({ type: "stage", stage: "checkpoint", progress: e.step / cfg.iters,
|
|
326
374
|
message: `checkpoint ${tag}` });
|
|
327
375
|
}
|
|
@@ -339,9 +387,11 @@ export async function trainLora(
|
|
|
339
387
|
? await orpoLoop(model, tok, tmpl, dataDir, cfg, lora, collect)
|
|
340
388
|
: await sftLoop(model, tok, tmpl, dataDir, cfg, lora, collect);
|
|
341
389
|
|
|
390
|
+
await Promise.all(checkpointSaves);
|
|
391
|
+
|
|
342
392
|
// Final save (last adapter).
|
|
343
393
|
detachTraining(model, lora);
|
|
344
|
-
saveAdapter(lora, cfg.adapterPath, saveCfg, appliedRanks);
|
|
394
|
+
await saveAdapter(lora, cfg.adapterPath, saveCfg, appliedRanks);
|
|
345
395
|
|
|
346
396
|
// Durable, structured run record alongside the adapter (only when we kept
|
|
347
397
|
// checkpoints). Pick the best-val one for convenience, but ship ALL of them
|
|
@@ -383,7 +433,6 @@ export async function trainLora(
|
|
|
383
433
|
});
|
|
384
434
|
return { adapterPath: cfg.adapterPath, appliedRanks, numIters: result.numIters };
|
|
385
435
|
} finally {
|
|
386
|
-
setFusedGeluTraining(false);
|
|
387
436
|
disposeLora(lora);
|
|
388
437
|
}
|
|
389
438
|
}
|
|
@@ -418,11 +467,7 @@ async function sftLoop(
|
|
|
418
467
|
: [];
|
|
419
468
|
|
|
420
469
|
const params = flatParams(lora);
|
|
421
|
-
const opt =
|
|
422
|
-
params,
|
|
423
|
-
{ lr: cfg.learningRate, betas: cfg.betas, eps: 1e-8, weightDecay: cfg.weightDecay },
|
|
424
|
-
(i, p) => writeParam(lora, i, p),
|
|
425
|
-
);
|
|
470
|
+
const opt = createLoraOptimizer(params, lora, cfg, emit);
|
|
426
471
|
|
|
427
472
|
// Segmented backward: stream the SFT backward segment-by-segment (only one
|
|
428
473
|
// segment's activations live at a time). Phase A is MiniCPM5 SFT B=1 only;
|
|
@@ -489,7 +534,7 @@ async function sftLoop(
|
|
|
489
534
|
// scripts/experiments/flash-fd-check.ts), though ~30× slower than ops.sdpa.
|
|
490
535
|
// Cleared in finally.
|
|
491
536
|
//
|
|
492
|
-
// GEMMA GUARD (same convention as the
|
|
537
|
+
// GEMMA GUARD (same convention as the launcher's env sanitization in
|
|
493
538
|
// cli.ts / job.ts, but enforced at the trainer): e4b on this path SIGTRAPed
|
|
494
539
|
// (uncatchable native crash) at multi-K sequence lengths (>=2K, reproduced in
|
|
495
540
|
// scripts/experiments/segmented-grad-test-e4b.ts; docs/reference/training.md)
|
|
@@ -661,11 +706,7 @@ async function dpoLoop(
|
|
|
661
706
|
: [];
|
|
662
707
|
|
|
663
708
|
const params = flatParams(lora);
|
|
664
|
-
const opt =
|
|
665
|
-
params,
|
|
666
|
-
{ lr: cfg.learningRate, betas: cfg.betas, eps: 1e-8, weightDecay: cfg.weightDecay },
|
|
667
|
-
(i, p) => writeParam(lora, i, p),
|
|
668
|
-
);
|
|
709
|
+
const opt = createLoraOptimizer(params, lora, cfg, emit);
|
|
669
710
|
|
|
670
711
|
const schedule =
|
|
671
712
|
cfg.dpoLrSchedule === "cosine" || cfg.dpoWarmupIters > 0
|
|
@@ -792,18 +833,7 @@ async function orpoLoop(
|
|
|
792
833
|
: [];
|
|
793
834
|
|
|
794
835
|
const params = flatParams(lora);
|
|
795
|
-
|
|
796
|
-
const nT = lora.targets.length;
|
|
797
|
-
const lrScale = cfg.loraPlusRatio !== 1
|
|
798
|
-
? params.map((_, i) => (i < nT ? 1 : cfg.loraPlusRatio))
|
|
799
|
-
: undefined;
|
|
800
|
-
if (lrScale) emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
801
|
-
message: `LoRA+ : B leaves at ${cfg.loraPlusRatio}× LR` });
|
|
802
|
-
const opt = new AdamW(
|
|
803
|
-
params,
|
|
804
|
-
{ lr: cfg.learningRate, betas: cfg.betas, eps: 1e-8, weightDecay: cfg.weightDecay, lrScale },
|
|
805
|
-
(i, p) => writeParam(lora, i, p),
|
|
806
|
-
);
|
|
836
|
+
const opt = createLoraOptimizer(params, lora, cfg, emit);
|
|
807
837
|
|
|
808
838
|
const schedule =
|
|
809
839
|
cfg.orpoLrSchedule === "cosine" || cfg.orpoWarmupIters > 0
|
|
@@ -1175,11 +1205,11 @@ function clipGradsByNorm(grads: MlxArray[], normVal: number, maxNorm: number): v
|
|
|
1175
1205
|
|
|
1176
1206
|
/** Supervised (response) tokens across ALL rows of the batch — the count
|
|
1177
1207
|
* tokens/sec is reported over (mlx-lm's n_tokens counts every row too). */
|
|
1178
|
-
function countResponseTokens(batch: SftBatch): number {
|
|
1208
|
+
export function countResponseTokens(batch: SftBatch): number {
|
|
1179
1209
|
let total = 0;
|
|
1180
1210
|
for (let r = 0; r < batch.ids.length; r++) {
|
|
1181
1211
|
const promptLen = batch.promptLens[r]!;
|
|
1182
|
-
const len = batch
|
|
1212
|
+
const len = rowLength(batch, r);
|
|
1183
1213
|
total += Math.max(0, len - Math.max(promptLen, 1));
|
|
1184
1214
|
}
|
|
1185
1215
|
return total;
|
package/src/vision/prompt.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
// Multimodal prompt assembly: OpenAI
|
|
2
|
-
// token ids + merged input embeddings +
|
|
1
|
+
// Multimodal prompt assembly: OpenAI image/audio content parts → spliced
|
|
2
|
+
// token ids + merged input embeddings + attention/zeroing masks.
|
|
3
3
|
//
|
|
4
|
-
// The chat template renders one <|image|> token (258880) per image part
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// The chat template renders one <|image|> token (258880) per image part and
|
|
5
|
+
// one <|audio|> token (258881) per audio part; we expand each in document
|
|
6
|
+
// order — <boi> + image_token × softTokens + <eoi> (matching optiq's vision
|
|
7
|
+
// frontend) and <boa> + audio_token × n + <eoa> with
|
|
8
|
+
// n = min(ceil(duration_ms/40), 750) (processing_gemma4.py) — embed the full
|
|
9
|
+
// sequence, then overwrite the soft-token rows with the towers' features.
|
|
10
|
+
//
|
|
11
|
+
// Attention semantics (audio-input-plan.md §3.3, resolved vs the oracle):
|
|
12
|
+
// - image runs attend bidirectionally among themselves (bidirMask), BUT
|
|
13
|
+
// any audio token in the prompt disables the vision overlay entirely —
|
|
14
|
+
// mixed image+audio prompts run fully causal (bidirMask = null);
|
|
15
|
+
// - audio runs are strictly causal, always;
|
|
16
|
+
// - per-layer-input id zeroing (e2b/e4b) applies to the UNION of
|
|
17
|
+
// multimodal soft tokens (image | audio) — multimodalMask, decoupled
|
|
18
|
+
// from the bidirectional mask.
|
|
8
19
|
|
|
9
20
|
import { MlxArray } from "../mlx/array";
|
|
10
21
|
import { Dtype } from "../mlx/ffi";
|
|
@@ -12,6 +23,10 @@ import * as ops from "../mlx/ops";
|
|
|
12
23
|
import type { Gemma4Model } from "../model/gemma4";
|
|
13
24
|
import type { ChatTemplate, ChatMessage, ToolDefinition } from "../chat-template";
|
|
14
25
|
import type { LoadedTokenizer } from "../tokenizer";
|
|
26
|
+
import type { AudioTower } from "../audio/conformer";
|
|
27
|
+
import { audioSoftTokenCount, decodeAudio } from "../audio/decode";
|
|
28
|
+
import { extractMelFeatures } from "../audio/features";
|
|
29
|
+
import { fetchMediaBytes } from "../media-fetch";
|
|
15
30
|
|
|
16
31
|
/** Common contract for both vision towers (encoder-free gemma4_unified in
|
|
17
32
|
* ./embedder.ts and the SigLIP encoder in ./siglip.ts): preprocess image
|
|
@@ -33,12 +48,38 @@ export interface VisionPrompt {
|
|
|
33
48
|
imageMask: MlxArray;
|
|
34
49
|
}
|
|
35
50
|
|
|
51
|
+
export interface MultimodalPrompt {
|
|
52
|
+
ids: number[];
|
|
53
|
+
/** Unscaled merged embeddings [1, L, hidden] — caller disposes. */
|
|
54
|
+
embeddings: MlxArray;
|
|
55
|
+
/** bool [L] image-token mask for the bidirectional attention overlay.
|
|
56
|
+
* null when the prompt contains ANY audio (§3.3 Q1: mixed image+audio
|
|
57
|
+
* runs fully causal) or no images. Caller disposes when non-null. */
|
|
58
|
+
bidirMask: MlxArray | null;
|
|
59
|
+
/** bool [L] union multimodal soft-token mask (image | audio) for
|
|
60
|
+
* per-layer-input id zeroing (§3.3 Q2). Caller disposes. */
|
|
61
|
+
multimodalMask: MlxArray;
|
|
62
|
+
}
|
|
63
|
+
|
|
36
64
|
export interface VisionTokenIds {
|
|
37
65
|
imageTokenId: number;
|
|
38
66
|
boiTokenId: number;
|
|
39
67
|
eoiTokenId: number;
|
|
40
68
|
}
|
|
41
69
|
|
|
70
|
+
export interface AudioTokenIds {
|
|
71
|
+
audioTokenId: number;
|
|
72
|
+
boaTokenId: number;
|
|
73
|
+
eoaTokenId: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The towers a multimodal prompt may need; pass only what the request uses
|
|
77
|
+
* (vision-only and audio-only fall out as special cases). */
|
|
78
|
+
export interface MultimodalTowers<P extends { softTokens: number }> {
|
|
79
|
+
vision?: { tower: VisionEncoder<P>; tokenIds: VisionTokenIds };
|
|
80
|
+
audio?: { tower: AudioTower; tokenIds: AudioTokenIds };
|
|
81
|
+
}
|
|
82
|
+
|
|
42
83
|
/** Extract image bytes from OpenAI-style content parts, rewriting the
|
|
43
84
|
* parts to the template's {type:"image"} form. */
|
|
44
85
|
export async function extractImages(
|
|
@@ -57,7 +98,7 @@ export async function extractImages(
|
|
|
57
98
|
const url = (part.image_url as { url: string } | undefined)?.url
|
|
58
99
|
?? (part.image_url as unknown as string);
|
|
59
100
|
if (typeof url !== "string") throw new Error("image_url part missing url");
|
|
60
|
-
images.push(await
|
|
101
|
+
images.push(await fetchMediaBytes(url, "image"));
|
|
61
102
|
parts.push({ type: "image" });
|
|
62
103
|
} else if (part.type === "image") {
|
|
63
104
|
// already template-form: expect base64 `data` or pass-through marker
|
|
@@ -76,83 +117,211 @@ export async function extractImages(
|
|
|
76
117
|
return { messages: out, images };
|
|
77
118
|
}
|
|
78
119
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
120
|
+
/** Extract audio bytes from OpenAI-style content parts, rewriting the parts
|
|
121
|
+
* to the template's {type:"audio"} form. Accepts the OpenAI-canonical
|
|
122
|
+
* {type:"input_audio", input_audio:{data:<b64>, format:...}} plus optiq's
|
|
123
|
+
* {type:"audio"} (base64 `data`) and {type:"audio_url"} aliases. */
|
|
124
|
+
export async function extractAudio(
|
|
125
|
+
messages: ChatMessage[],
|
|
126
|
+
): Promise<{ messages: ChatMessage[]; audio: Uint8Array[] }> {
|
|
127
|
+
const audio: Uint8Array[] = [];
|
|
128
|
+
const out: ChatMessage[] = [];
|
|
129
|
+
for (const m of messages) {
|
|
130
|
+
if (!Array.isArray(m.content)) {
|
|
131
|
+
out.push(m);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const parts: Array<Record<string, unknown>> = [];
|
|
135
|
+
for (const part of m.content) {
|
|
136
|
+
if (part.type === "input_audio") {
|
|
137
|
+
const data = (part.input_audio as { data?: string } | undefined)?.data;
|
|
138
|
+
if (typeof data !== "string")
|
|
139
|
+
throw new Error("input_audio part requires base64 `input_audio.data`");
|
|
140
|
+
audio.push(Uint8Array.from(Buffer.from(data, "base64")));
|
|
141
|
+
parts.push({ type: "audio" });
|
|
142
|
+
} else if (part.type === "audio") {
|
|
143
|
+
if (typeof part.data === "string") {
|
|
144
|
+
audio.push(Uint8Array.from(Buffer.from(part.data, "base64")));
|
|
145
|
+
parts.push({ type: "audio" });
|
|
146
|
+
} else {
|
|
147
|
+
throw new Error("audio part requires base64 `data`");
|
|
148
|
+
}
|
|
149
|
+
} else if (part.type === "audio_url") {
|
|
150
|
+
const url = (part.audio_url as { url: string } | undefined)?.url
|
|
151
|
+
?? (part.audio_url as unknown as string);
|
|
152
|
+
if (typeof url !== "string") throw new Error("audio_url part missing url");
|
|
153
|
+
audio.push(await fetchMediaBytes(url, "audio"));
|
|
154
|
+
parts.push({ type: "audio" });
|
|
155
|
+
} else {
|
|
156
|
+
parts.push(part);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
out.push({ ...m, content: parts });
|
|
92
160
|
}
|
|
93
|
-
|
|
161
|
+
return { messages: out, audio };
|
|
94
162
|
}
|
|
95
163
|
|
|
96
|
-
|
|
164
|
+
/** Decoded + featurized audio clip, ready to splice. */
|
|
165
|
+
interface PreparedAudio {
|
|
166
|
+
mel: Float32Array;
|
|
167
|
+
frames: number;
|
|
168
|
+
softTokens: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function buildMultimodalPrompt<P extends { softTokens: number }>(
|
|
97
172
|
model: Gemma4Model,
|
|
98
|
-
|
|
173
|
+
towers: MultimodalTowers<P>,
|
|
99
174
|
tokenizer: LoadedTokenizer,
|
|
100
175
|
template: ChatTemplate,
|
|
101
176
|
messages: ChatMessage[],
|
|
102
177
|
images: Uint8Array[],
|
|
103
|
-
|
|
178
|
+
audio: Uint8Array[],
|
|
104
179
|
tools: ToolDefinition[] | null = null,
|
|
105
|
-
): Promise<
|
|
180
|
+
): Promise<MultimodalPrompt> {
|
|
181
|
+
if (images.length > 0 && !towers.vision)
|
|
182
|
+
throw new Error("images present but no vision tower configured");
|
|
183
|
+
if (audio.length > 0 && !towers.audio)
|
|
184
|
+
throw new Error("audio present but no audio tower configured");
|
|
185
|
+
|
|
106
186
|
const rendered = template.render(messages, { tools });
|
|
107
187
|
let ids = tokenizer.encode(rendered);
|
|
108
188
|
if (ids[0] === ids[1] && ids[0] === tokenizer.bosTokenId) ids = ids.slice(1);
|
|
109
189
|
|
|
110
|
-
// preprocess
|
|
111
|
-
//
|
|
112
|
-
const pre = await Promise.all(
|
|
190
|
+
// preprocess every clip/image up front (splice lengths depend on them);
|
|
191
|
+
// audio soft-token counts come from the DECODED samples, never metadata.
|
|
192
|
+
const pre = await Promise.all(
|
|
193
|
+
images.map((bytes) => towers.vision!.tower.preprocess(bytes)),
|
|
194
|
+
);
|
|
195
|
+
const clips: PreparedAudio[] = audio.map((bytes) => {
|
|
196
|
+
const samples = decodeAudio(bytes);
|
|
197
|
+
const softTokens = audioSoftTokenCount(samples.length);
|
|
198
|
+
const m = extractMelFeatures(samples);
|
|
199
|
+
return { mel: m.features, frames: m.frames, softTokens };
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const vIds = towers.vision?.tokenIds;
|
|
203
|
+
const aIds = towers.audio?.tokenIds;
|
|
113
204
|
|
|
114
|
-
// expand each <|image|> into <boi> + image_token×soft + <eoi>
|
|
205
|
+
// expand each <|image|> into <boi> + image_token×soft + <eoi> and each
|
|
206
|
+
// <|audio|> into <boa> + audio_token×n + <eoa>, in document order
|
|
115
207
|
const spliced: number[] = [];
|
|
116
|
-
const runs: { start: number; length: number }[] = [];
|
|
208
|
+
const runs: { start: number; length: number; kind: "image" | "audio"; index: number }[] = [];
|
|
117
209
|
let imgIdx = 0;
|
|
210
|
+
let audIdx = 0;
|
|
118
211
|
for (const id of ids) {
|
|
119
|
-
if (id ===
|
|
212
|
+
if (vIds && id === vIds.imageTokenId) {
|
|
120
213
|
const p = pre[imgIdx];
|
|
121
214
|
if (!p) throw new Error("more <|image|> markers than images");
|
|
122
|
-
spliced.push(
|
|
123
|
-
runs.push({ start: spliced.length, length: p.softTokens });
|
|
124
|
-
for (let k = 0; k < p.softTokens; k++) spliced.push(
|
|
125
|
-
spliced.push(
|
|
215
|
+
spliced.push(vIds.boiTokenId);
|
|
216
|
+
runs.push({ start: spliced.length, length: p.softTokens, kind: "image", index: imgIdx });
|
|
217
|
+
for (let k = 0; k < p.softTokens; k++) spliced.push(vIds.imageTokenId);
|
|
218
|
+
spliced.push(vIds.eoiTokenId);
|
|
126
219
|
imgIdx++;
|
|
220
|
+
} else if (aIds && id === aIds.audioTokenId) {
|
|
221
|
+
const clip = clips[audIdx];
|
|
222
|
+
if (!clip) throw new Error("more <|audio|> markers than audio clips");
|
|
223
|
+
spliced.push(aIds.boaTokenId);
|
|
224
|
+
runs.push({ start: spliced.length, length: clip.softTokens, kind: "audio", index: audIdx });
|
|
225
|
+
for (let k = 0; k < clip.softTokens; k++) spliced.push(aIds.audioTokenId);
|
|
226
|
+
spliced.push(aIds.eoaTokenId);
|
|
227
|
+
audIdx++;
|
|
127
228
|
} else {
|
|
128
229
|
spliced.push(id);
|
|
129
230
|
}
|
|
130
231
|
}
|
|
131
232
|
if (imgIdx !== images.length)
|
|
132
233
|
throw new Error(`${images.length} images but ${imgIdx} <|image|> markers`);
|
|
234
|
+
if (audIdx !== audio.length)
|
|
235
|
+
throw new Error(`${audio.length} audio clips but ${audIdx} <|audio|> markers`);
|
|
133
236
|
|
|
134
|
-
// embed text tokens, then overwrite
|
|
237
|
+
// embed text tokens, then overwrite soft-token rows with tower features
|
|
135
238
|
const idsArr = ops.fromInt32(spliced, [1, spliced.length]);
|
|
136
239
|
let embeds = model.embed.encode(idsArr);
|
|
137
240
|
idsArr.dispose();
|
|
138
241
|
const hidden = embeds.shape[2]!;
|
|
139
|
-
for (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
242
|
+
for (const run of runs) {
|
|
243
|
+
let cast: MlxArray;
|
|
244
|
+
if (run.kind === "image") {
|
|
245
|
+
// vision towers return pre-divided by embed_scale (their oracle's
|
|
246
|
+
// frontend convention) — cast to the embedding dtype and splice.
|
|
247
|
+
const feats = towers.vision!.tower.features(pre[run.index]!); // [1, soft, hidden]
|
|
248
|
+
cast = feats.astype(embeds.dtype);
|
|
249
|
+
feats.dispose();
|
|
250
|
+
} else {
|
|
251
|
+
// audio mirrors gen-e4b-audio-golden.py EXACTLY:
|
|
252
|
+
// features.astype(embeds.dtype) / embed_scale
|
|
253
|
+
// — raw f32 embed_audio output, cast to bf16 FIRST, then divided by a
|
|
254
|
+
// weak (dtype-following, i.e. bf16) embed_scale scalar. The order is
|
|
255
|
+
// load-bearing for the bit-exact greedy gate (see AudioTower.features).
|
|
256
|
+
const clip = clips[run.index]!;
|
|
257
|
+
const raw = towers.audio!.tower.features(clip.mel, clip.frames, false); // [1, n, hidden] f32
|
|
258
|
+
if (raw.shape[1] !== clip.softTokens) {
|
|
259
|
+
const n = raw.shape[1];
|
|
260
|
+
raw.dispose();
|
|
261
|
+
embeds.dispose();
|
|
262
|
+
throw new Error(`audio tower produced ${n} frames but splice expects ${clip.softTokens}`);
|
|
263
|
+
}
|
|
264
|
+
const bf = raw.astype(embeds.dtype);
|
|
265
|
+
raw.dispose();
|
|
266
|
+
const scale = ops.scalarLike(towers.audio!.tower.embedScale, bf);
|
|
267
|
+
cast = ops.div(bf, scale);
|
|
268
|
+
bf.dispose();
|
|
269
|
+
scale.dispose();
|
|
270
|
+
}
|
|
271
|
+
const { start, length } = run;
|
|
144
272
|
const updated = ops.sliceUpdate(embeds, cast, [0, start, 0], [1, start + length, hidden]);
|
|
145
273
|
cast.dispose();
|
|
146
274
|
embeds.dispose();
|
|
147
275
|
embeds = updated;
|
|
148
276
|
}
|
|
149
277
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
278
|
+
// masks over the spliced sequence: union multimodal soft tokens (per-layer
|
|
279
|
+
// id zeroing) always; image-only bidirectional overlay only when NO audio
|
|
280
|
+
// is present (§3.3 Q1).
|
|
281
|
+
const mmInts = new Int32Array(spliced.length);
|
|
282
|
+
for (let i = 0; i < spliced.length; i++) {
|
|
283
|
+
const id = spliced[i]!;
|
|
284
|
+
mmInts[i] = (vIds && id === vIds.imageTokenId) || (aIds && id === aIds.audioTokenId) ? 1 : 0;
|
|
285
|
+
}
|
|
286
|
+
const mmI32 = MlxArray.fromInt32(mmInts, [spliced.length]);
|
|
287
|
+
const multimodalMask = mmI32.astype(Dtype.bool);
|
|
288
|
+
mmI32.dispose();
|
|
156
289
|
|
|
157
|
-
|
|
290
|
+
let bidirMask: MlxArray | null = null;
|
|
291
|
+
if (images.length > 0 && audio.length === 0) {
|
|
292
|
+
const imgInts = new Int32Array(spliced.length);
|
|
293
|
+
for (let i = 0; i < spliced.length; i++)
|
|
294
|
+
imgInts[i] = spliced[i] === vIds!.imageTokenId ? 1 : 0;
|
|
295
|
+
const imgI32 = MlxArray.fromInt32(imgInts, [spliced.length]);
|
|
296
|
+
bidirMask = imgI32.astype(Dtype.bool);
|
|
297
|
+
imgI32.dispose();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { ids: spliced, embeddings: embeds, bidirMask, multimodalMask };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Vision-only prompt (back-compat wrapper over buildMultimodalPrompt —
|
|
304
|
+
* behavior byte-identical to the pre-audio builder: the returned imageMask
|
|
305
|
+
* serves as BOTH the bidirectional-attention mask and the per-layer-input
|
|
306
|
+
* zeroing mask, which forwardEmbeddings derives from `bidir` when no
|
|
307
|
+
* separate multimodal mask is passed). */
|
|
308
|
+
export async function buildVisionPrompt<P extends { softTokens: number }>(
|
|
309
|
+
model: Gemma4Model,
|
|
310
|
+
tower: VisionEncoder<P>,
|
|
311
|
+
tokenizer: LoadedTokenizer,
|
|
312
|
+
template: ChatTemplate,
|
|
313
|
+
messages: ChatMessage[],
|
|
314
|
+
images: Uint8Array[],
|
|
315
|
+
tokenIds: VisionTokenIds,
|
|
316
|
+
tools: ToolDefinition[] | null = null,
|
|
317
|
+
): Promise<VisionPrompt> {
|
|
318
|
+
const mp = await buildMultimodalPrompt(
|
|
319
|
+
model, { vision: { tower, tokenIds } }, tokenizer, template,
|
|
320
|
+
messages, images, [], tools,
|
|
321
|
+
);
|
|
322
|
+
// no audio was passed, so the union mask IS the image mask — reuse it for
|
|
323
|
+
// the zero-image edge (the original builder returned an all-false mask).
|
|
324
|
+
const imageMask = mp.bidirMask ?? mp.multimodalMask;
|
|
325
|
+
if (mp.bidirMask) mp.multimodalMask.dispose();
|
|
326
|
+
return { ids: mp.ids, embeddings: mp.embeddings, imageMask };
|
|
158
327
|
}
|