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.
- package/docs/design/diffusion-gemma-port.md +602 -0
- package/package.json +1 -1
- package/src/config.ts +50 -2
- package/src/diffusion/diffusion-generate.ts +436 -0
- package/src/eval/runner.ts +5 -2
- package/src/generate.ts +55 -1
- package/src/mlx/ffi.ts +8 -0
- package/src/mlx/ops.ts +67 -0
- package/src/model/diffusion-gemma.ts +1026 -0
- package/src/model/factory.ts +8 -2
- package/src/model/support.ts +9 -1
- package/src/serve/generation-gateway.ts +4 -0
- package/src/server.ts +31 -1
- package/src/train/diffusion-lora.ts +216 -0
- package/src/vision/diffusion-vision.ts +620 -0
package/src/model/factory.ts
CHANGED
|
@@ -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 {
|
|
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")
|
package/src/model/support.ts
CHANGED
|
@@ -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
|
+
}
|