mlx-bun 0.0.7 → 0.0.8

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.
@@ -11,11 +11,17 @@ import { configFingerprint } from "./fingerprint";
11
11
  import { GENERATED } from "./generated";
12
12
  import { MiniCPM5Model } from "./minicpm5";
13
13
  import { Qwen35Model } from "./qwen3_5";
14
- import { isMiniCPM5Config, isQwen35Config } from "./support";
14
+ import { DiffusionGemmaModel } from "./diffusion-gemma";
15
+ import { isDiffusionGemmaConfig, isMiniCPM5Config, isQwen35Config } from "./support";
15
16
 
16
- export type RuntimeModel = Gemma4Model | MiniCPM5Model | Qwen35Model;
17
+ export type RuntimeModel = Gemma4Model | MiniCPM5Model | Qwen35Model | DiffusionGemmaModel;
17
18
 
18
19
  export function createModel(weights: Weights, config: ModelConfig): RuntimeModel {
20
+ // DiffusionGemma is non-autoregressive — generate() detects it and routes to
21
+ // the denoising engine instead of the AR decode loop. It exposes the shared
22
+ // RuntimeModel surface (config/weightsBytes/loraState/makeCache) with the
23
+ // AR-only methods as throwing stubs (never called on this model).
24
+ if (isDiffusionGemmaConfig(config)) return new DiffusionGemmaModel(weights, config);
19
25
  if (isMiniCPM5Config(config)) return new MiniCPM5Model(weights, config);
20
26
  if (isQwen35Config(config)) return new Qwen35Model(weights, config);
21
27
  if (config.modelType === "llama")
@@ -31,9 +31,17 @@ export function isDrafterModelType(modelType: string): boolean {
31
31
  return modelType.endsWith("_assistant");
32
32
  }
33
33
 
34
+ /** DiffusionGemma (model_type `diffusion_gemma`): block/masked-diffusion canvas
35
+ * model. Non-autoregressive — routed through the diffusion engine, not the AR
36
+ * loop. See docs/design/diffusion-gemma-port.md. */
37
+ export function isDiffusionGemmaConfig(config: ModelConfig): boolean {
38
+ return config.modelType === "diffusion_gemma";
39
+ }
40
+
34
41
  export function isSupportedModelRecord(modelType: string, repoId = ""): boolean {
35
42
  if (isDrafterModelType(modelType)) return false;
36
43
  if (modelType.startsWith("gemma4")) return true;
44
+ if (modelType === "diffusion_gemma") return true;
37
45
  // qwen3_5 / qwen3_5_text (dense hybrid). The MoE variant is a separate type.
38
46
  if (modelType === "qwen3_5" || modelType === "qwen3_5_text") return true;
39
47
  return modelType === "llama" && repoId.toLowerCase().includes("minicpm5-1b-optiq-4bit");
@@ -42,5 +50,5 @@ export function isSupportedModelRecord(modelType: string, repoId = ""): boolean
42
50
  export function isSupportedModelConfig(config: ModelConfig): boolean {
43
51
  if (isDrafterModelType(config.modelType)) return false;
44
52
  return config.modelType.startsWith("gemma4") || isMiniCPM5Config(config) ||
45
- isQwen35Config(config);
53
+ isQwen35Config(config) || isDiffusionGemmaConfig(config);
46
54
  }
@@ -27,6 +27,7 @@
27
27
 
28
28
  import { MlxArray } from "../mlx/array";
29
29
  import type { RuntimeModel } from "../model/factory";
30
+ import { DiffusionGemmaModel } from "../model/diffusion-gemma";
30
31
  import type { GenerateOptions, GenerateStats } from "../generate";
31
32
  import { makeSampler, toLogprobs } from "../sampler";
32
33
  import { BatchScheduler } from "./batch-scheduler";
@@ -93,6 +94,9 @@ export class GenerationGateway {
93
94
 
94
95
  /** Decide whether a request joins the batch or runs serially. */
95
96
  willBatch(shape: RequestShape): boolean {
97
+ // DiffusionGemma is non-autoregressive — the batch scheduler assumes the AR
98
+ // KV-cache decode path, so it always runs serially through generate().
99
+ if (this.model instanceof DiffusionGemmaModel) return false;
96
100
  return (
97
101
  this.batchingEnabled &&
98
102
  !shape.hasVision &&
package/src/server.ts CHANGED
@@ -23,6 +23,8 @@ const pkgVersion = (pkgJson as { version: string }).version;
23
23
  import { loadModelConfig, type KvQuantSpec, type ModelConfig } from "./config";
24
24
  import { Weights } from "./weights";
25
25
  import { Gemma4Model } from "./model/gemma4";
26
+ import { DiffusionGemmaModel } from "./model/diffusion-gemma";
27
+ import { spliceImageTokens } from "./vision/diffusion-vision";
26
28
  import { createModel, type RuntimeModel } from "./model/factory";
27
29
  import { isMiniCPM5Config, isQwen35Config, isSupportedModelRecord } from "./model/support";
28
30
  import { generate, type GenerateOptions } from "./generate";
@@ -839,6 +841,7 @@ export function createServer(
839
841
  } finally {
840
842
  vision?.embeddings.dispose();
841
843
  vision?.imageMask.dispose();
844
+ options.visionPixels?.dispose();
842
845
  }
843
846
  };
844
847
 
@@ -1402,8 +1405,33 @@ export function createServer(
1402
1405
  // Vision is Gemma4-only (no switchable thinking channel), so it's false.
1403
1406
  let startInThinking = false;
1404
1407
  let vision: Parameters<typeof runGeneration>[3];
1408
+ let diffusionPixels: import("./mlx/array").MlxArray | null = null;
1405
1409
  try {
1406
- if (hasImages) {
1410
+ if (hasImages && ctx.model instanceof DiffusionGemmaModel) {
1411
+ // DiffusionGemma image-text-to-text: its OWN dedicated SigLIP tower +
1412
+ // encoder vision merge feed the denoising engine (NOT the AR
1413
+ // forwardEmbeddings path). v1 supports a single image.
1414
+ const dm = ctx.model;
1415
+ if (!dm.visionTower)
1416
+ return Response.json(
1417
+ { error: { message: "this checkpoint has no vision tower" } }, { status: 400 },
1418
+ );
1419
+ const { messages, images } = await extractImages(normalizeMessages(body.messages));
1420
+ if (images.length !== 1)
1421
+ return Response.json(
1422
+ { error: { message: "DiffusionGemma image input supports exactly one image" } },
1423
+ { status: 400 },
1424
+ );
1425
+ const rendered = ctx.template.render(messages, { tools, addGenerationPrompt: true });
1426
+ const rawIds = ctx.tokenizer.encode(rendered, /* addSpecialTokens */ false);
1427
+ const { pixels, softTokens } = await dm.visionTower.preprocess(images[0]!);
1428
+ promptIds = spliceImageTokens(rawIds, [softTokens], {
1429
+ image: ctx.visionTokenIds.imageTokenId,
1430
+ boi: ctx.visionTokenIds.boiTokenId,
1431
+ eoi: ctx.visionTokenIds.eoiTokenId,
1432
+ });
1433
+ diffusionPixels = pixels;
1434
+ } else if (hasImages) {
1407
1435
  // Loads (and caches) the tower on first image request — text-only
1408
1436
  // sessions never pay for it.
1409
1437
  const tower = getVisionTower(ctx);
@@ -1432,6 +1460,7 @@ export function createServer(
1432
1460
  );
1433
1461
  }
1434
1462
  const options = toOptions(body);
1463
+ if (diffusionPixels) options.visionPixels = diffusionPixels;
1435
1464
  // Admission: reject what cannot finish within the memory budget
1436
1465
  // (the GPU OOM it would otherwise hit is uncatchable and kills
1437
1466
  // the process — Phase 6 finding).
@@ -1439,6 +1468,7 @@ export function createServer(
1439
1468
  if (requiredCtx > admission.maxSafeContext) {
1440
1469
  vision?.embeddings.dispose();
1441
1470
  vision?.imageMask.dispose();
1471
+ diffusionPixels?.dispose();
1442
1472
  return Response.json(
1443
1473
  {
1444
1474
  error: {
@@ -0,0 +1,216 @@
1
+ // DiffusionGemma LoRA fine-tuning (port D5). DiffusionGemma is a discrete
2
+ // block-diffusion LM, so the AR cross-entropy trainer doesn't apply — train LoRA
3
+ // with the model's NATIVE denoising objective (port of optiq
4
+ // vlm/diffusion_gemma/lora.py `diffusion_loss` + `train_diffusion_lora`):
5
+ // corrupt the target canvas to a random noise level t∈[t_min,t_max] (replace
6
+ // each token with prob t by a uniform-random token — the same corruption the
7
+ // inference canvas starts from), run one encoder+decoder forward, and minimise
8
+ // cross-entropy on the CORRUPTED positions (predict the clean token).
9
+ //
10
+ // LoRA is injected on the decoder blocks (DEFAULT_LORA_KEYS = attn q/k/v/o +
11
+ // dense MLP gate/up/down). The encoder reuses those same blocks (weight-tied,
12
+ // only per-layer scalars differ), so one injection trains both paths. Experts/
13
+ // router stay frozen (their routing indices are stop_gradient'd so the MoE
14
+ // backward differentiates only the activations). Adapter saved in the
15
+ // AdapterManager.mount layout (lora_a/lora_b), mountable for inference.
16
+
17
+ import * as ops from "../mlx/ops";
18
+ import { MlxArray } from "../mlx/array";
19
+ import { Dtype } from "../mlx/ffi";
20
+ import { ValueAndGrad } from "../mlx/autograd";
21
+ import { DiffusionGemmaModel } from "../model/diffusion-gemma";
22
+ import { AdamW } from "./optimizer";
23
+ import {
24
+ attachForTraining,
25
+ buildTrainableLora,
26
+ detachTraining,
27
+ flatParams,
28
+ type TrainableLora,
29
+ } from "./lora-params";
30
+
31
+ export const DEFAULT_LORA_KEYS = [
32
+ "self_attn.q_proj", "self_attn.k_proj", "self_attn.v_proj", "self_attn.o_proj",
33
+ "mlp.gate_proj", "mlp.up_proj", "mlp.down_proj",
34
+ ];
35
+
36
+ export interface DiffusionPair {
37
+ promptIds: number[];
38
+ targetIds: number[];
39
+ }
40
+
41
+ export interface DiffusionLoraConfig {
42
+ rank?: number; // default 8
43
+ scale?: number; // default 8.0 (diffusion is scale-sensitive; AR's 20 collapses)
44
+ iters?: number; // default 200
45
+ learningRate?: number; // default 1e-4
46
+ tMin?: number; // default 0.4
47
+ tMax?: number; // default 0.8
48
+ seed?: number; // default 0
49
+ reportEvery?: number; // default 10
50
+ onReport?: (iter: number, avgLoss: number) => void;
51
+ }
52
+
53
+ /** swap/restore the differentiated primals into the LoRA leaves (port of the
54
+ * trainer.ts private helpers — primals order is [...A, ...B]). */
55
+ function swapPrimals(lora: TrainableLora, primals: MlxArray[]): MlxArray[] {
56
+ const n = lora.targets.length;
57
+ const saved: MlxArray[] = [];
58
+ for (let i = 0; i < n; i++) {
59
+ saved.push(lora.targets[i]!.lw.a, lora.targets[i]!.lw.b);
60
+ lora.targets[i]!.lw.a = primals[i]!;
61
+ lora.targets[i]!.lw.b = primals[n + i]!;
62
+ }
63
+ return saved;
64
+ }
65
+ function restorePrimals(lora: TrainableLora, saved: MlxArray[]): void {
66
+ for (let i = 0; i < lora.targets.length; i++) {
67
+ lora.targets[i]!.lw.a = saved[2 * i]!;
68
+ lora.targets[i]!.lw.b = saved[2 * i + 1]!;
69
+ }
70
+ }
71
+ function writeParam(lora: TrainableLora, i: number, p: MlxArray): void {
72
+ const n = lora.targets.length;
73
+ if (i < n) lora.targets[i]!.lw.a = p;
74
+ else lora.targets[i - n]!.lw.b = p;
75
+ }
76
+
77
+ /** sum all elements -> scalar []. */
78
+ function sumAll(a: MlxArray): MlxArray {
79
+ const flat = ops.reshape(a, [a.size]);
80
+ const s = ops.sumAxis(flat, 0, false);
81
+ flat.dispose();
82
+ return s;
83
+ }
84
+
85
+ /** Denoising cross-entropy on corrupted canvas positions (the reference
86
+ * diffusion_loss). Returns a scalar loss MlxArray, differentiable w.r.t. the
87
+ * mounted LoRA. */
88
+ export function diffusionLoss(
89
+ model: DiffusionGemmaModel,
90
+ promptIds: number[],
91
+ targetIds: number[],
92
+ tMin: number,
93
+ tMax: number,
94
+ vocab: number,
95
+ ): MlxArray {
96
+ const L = targetIds.length;
97
+ // t ~ U(t_min, t_max) scalar; corrupt[i] = U(0,1) < t ; noise ~ randint
98
+ const t = ops.randomUniform([1], Dtype.float32, tMin, tMax, null);
99
+ const u = ops.randomUniform([1, L], Dtype.float32, 0, 1, null);
100
+ const corrupt = ops.less(u, t); // bool [1, L]
101
+ u.dispose();
102
+ t.dispose();
103
+ const noise = ops.randint(0, vocab, [1, L], Dtype.int32);
104
+ const target = MlxArray.fromInt32(Int32Array.from(targetIds), [1, L]);
105
+ const canvas = ops.where(corrupt, noise, target);
106
+ noise.dispose();
107
+
108
+ const logits = model.forwardCanvasLogitsArr(promptIds, canvas); // [1, L, vocab] f32
109
+ canvas.dispose();
110
+
111
+ // cross_entropy(reduction=none) = logsumexp(logits) - logit_at_target
112
+ const lse = ops.logsumexpAxis(logits, -1, false); // [1, L]
113
+ const tExp = ops.expandDims(target, -1); // [1, L, 1]
114
+ const gathered = ops.takeAlongAxis(logits, tExp, -1); // [1, L, 1]
115
+ tExp.dispose();
116
+ logits.dispose();
117
+ const tgt = ops.reshape(gathered, [1, L]);
118
+ gathered.dispose();
119
+ const ce = ops.sub(lse, tgt); // [1, L]
120
+ lse.dispose();
121
+ tgt.dispose();
122
+ target.dispose();
123
+
124
+ const corruptF = corrupt.astype(Dtype.float32);
125
+ corrupt.dispose();
126
+ const masked = ops.mul(ce, corruptF);
127
+ ce.dispose();
128
+ const num = sumAll(masked);
129
+ masked.dispose();
130
+ const cnt = sumAll(corruptF);
131
+ corruptF.dispose();
132
+ const one = ops.scalarLike(1, cnt);
133
+ const denom = ops.maximum(cnt, one);
134
+ cnt.dispose();
135
+ one.dispose();
136
+ const loss = ops.div(num, denom);
137
+ num.dispose();
138
+ denom.dispose();
139
+ return loss;
140
+ }
141
+
142
+ /** Train a LoRA adapter on DiffusionGemma with the denoising objective. Returns
143
+ * the TrainableLora (caller saves it via saveAdapter) + the loss history. */
144
+ export function trainDiffusionLora(
145
+ model: DiffusionGemmaModel,
146
+ pairs: DiffusionPair[],
147
+ cfg: DiffusionLoraConfig = {},
148
+ ): { lora: TrainableLora; losses: number[] } {
149
+ const rank = cfg.rank ?? 8;
150
+ const scale = cfg.scale ?? 8.0;
151
+ const iters = cfg.iters ?? 200;
152
+ const lr = cfg.learningRate ?? 1e-4;
153
+ const tMin = cfg.tMin ?? 0.4;
154
+ const tMax = cfg.tMax ?? 0.8;
155
+ const seed = cfg.seed ?? 0;
156
+ const reportEvery = cfg.reportEvery ?? 10;
157
+ const vocab = model.config.text.vocabSize;
158
+ if (pairs.length === 0) throw new Error("no training pairs");
159
+
160
+ ops.randomSeed(BigInt(seed >>> 0));
161
+
162
+ // LoRA on every decoder block's attn + dense MLP (skips full-layer v_proj
163
+ // automatically — buildTrainableLora filters to existing loraTargets).
164
+ const ranks = new Map<string, number>();
165
+ for (let i = 0; i < model.layers.length; i++)
166
+ for (const key of DEFAULT_LORA_KEYS) ranks.set(`model.decoder.layers.${i}.${key}`, rank);
167
+ const lora = buildTrainableLora(model, ranks, scale, seed);
168
+ attachForTraining(model, lora, "train");
169
+ model.loraState.active = ["train"];
170
+
171
+ const params = flatParams(lora);
172
+ const opt = new AdamW(
173
+ params,
174
+ { lr, betas: [0.9, 0.999], eps: 1e-8, weightDecay: 0.01 },
175
+ (i, p) => writeParam(lora, i, p),
176
+ );
177
+
178
+ let current: DiffusionPair = pairs[0]!;
179
+ const vag = new ValueAndGrad((primals) => {
180
+ const saved = swapPrimals(lora, primals);
181
+ try {
182
+ return diffusionLoss(model, current.promptIds, current.targetIds, tMin, tMax, vocab);
183
+ } finally {
184
+ restorePrimals(lora, saved);
185
+ }
186
+ }, params.map((_, i) => i));
187
+
188
+ const losses: number[] = [];
189
+ try {
190
+ for (let it = 0; it < iters; it++) {
191
+ current = pairs[it % pairs.length]!;
192
+ if (current.targetIds.length === 0) continue;
193
+ const { value, grads } = vag.apply(flatParams(lora));
194
+ opt.step(grads); // takes ownership of grads + writes via writeParam
195
+ ops.evalAll([...flatParams(lora), value]);
196
+ const lossNum = value.astype(Dtype.float32).toFloat32()[0]!;
197
+ value.dispose();
198
+ losses.push(lossNum);
199
+ if ((it + 1) % reportEvery === 0) {
200
+ const window = losses.slice(-reportEvery);
201
+ cfg.onReport?.(it + 1, window.reduce((a, b) => a + b, 0) / window.length);
202
+ }
203
+ }
204
+ } catch (e) {
205
+ // On failure, undo the mount so the model is left clean.
206
+ detachTraining(model, lora);
207
+ model.loraState.active = [];
208
+ vag.dispose();
209
+ throw e;
210
+ }
211
+ // On success leave the trained LoRA ATTACHED + ACTIVE so the model is
212
+ // immediately usable; the caller saves it (saveAdapter) and may detach.
213
+ vag.dispose();
214
+ model.loraState.active = ["train"];
215
+ return { lora, losses };
216
+ }