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/src/config.ts CHANGED
@@ -55,6 +55,8 @@ export interface TextConfig {
55
55
  tieWordEmbeddings: boolean;
56
56
  bosTokenId: number;
57
57
  eosTokenId: number | number[];
58
+ /** DiffusionGemma: fixed denoising-canvas length (256). Undefined elsewhere. */
59
+ canvasLength?: number;
58
60
  }
59
61
 
60
62
  export interface QuantSpec {
@@ -132,11 +134,56 @@ export function quantFor(q: QuantizationConfig | null, modulePath: string): Quan
132
134
  return spec.mode === "none" ? null : spec;
133
135
  }
134
136
 
137
+ /** DiffusionGemma's config.json ships only token ids + canvas_length + the
138
+ * quant map — the architecture dims live in optiq's `config.py` TextConfig
139
+ * defaults (and layer_types / rope_parameters are computed in __post_init__).
140
+ * We reproduce those defaults in snake_case so the generic parser below picks
141
+ * them up; any field actually present in config.json still overrides. Source:
142
+ * optiq/vlm/_mlxvlm/models/diffusion_gemma/config.py. */
143
+ function diffusionGemmaRawDefaults(): Record<string, any> {
144
+ const numLayers = 30;
145
+ const pattern = ["sliding_attention", "sliding_attention", "sliding_attention",
146
+ "sliding_attention", "sliding_attention", "full_attention"];
147
+ const layer_types = Array.from({ length: numLayers }, (_, i) => pattern[i % pattern.length]);
148
+ layer_types[numLayers - 1] = "full_attention"; // last forced full
149
+ return {
150
+ hidden_size: 2816,
151
+ num_hidden_layers: numLayers,
152
+ num_attention_heads: 16,
153
+ num_key_value_heads: 8,
154
+ num_global_key_value_heads: 2,
155
+ head_dim: 256,
156
+ global_head_dim: 512,
157
+ intermediate_size: 2112,
158
+ moe_intermediate_size: 704,
159
+ hidden_activation: "gelu_pytorch_tanh",
160
+ rms_norm_eps: 1e-6,
161
+ vocab_size: 262144,
162
+ max_position_embeddings: 262144,
163
+ sliding_window: 1024,
164
+ layer_types,
165
+ enable_moe_block: true,
166
+ num_experts: 128,
167
+ top_k_experts: 8,
168
+ final_logit_softcapping: 30.0,
169
+ tie_word_embeddings: true,
170
+ bos_token_id: 2,
171
+ eos_token_id: 1,
172
+ rope_parameters: {
173
+ sliding_attention: { rope_type: "default", rope_theta: 10000.0 },
174
+ full_attention: { rope_type: "proportional", partial_rotary_factor: 0.25, rope_theta: 1000000.0 },
175
+ },
176
+ };
177
+ }
178
+
135
179
  export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
136
180
  const raw = (await Bun.file(`${modelDir}/config.json`).json()) as Record<string, any>;
137
- // Gemma 4 unified nests the LM config; plain text models keep it at top level.
138
- const t = (raw.text_config ?? raw) as Record<string, any>;
139
181
  const modelType = raw.model_type;
182
+ const isDiffusion = modelType === "diffusion_gemma";
183
+ // Gemma 4 unified nests the LM config; plain text models keep it at top level.
184
+ // DiffusionGemma keeps it flat too, but omits all dims — backfill from defaults.
185
+ const baseT = (raw.text_config ?? raw) as Record<string, any>;
186
+ const t = isDiffusion ? { ...diffusionGemmaRawDefaults(), ...baseT } : baseT;
140
187
  const isLlama = modelType === "llama";
141
188
  const isQwen35 = typeof modelType === "string" && modelType.startsWith("qwen3_5");
142
189
  // Qwen3.5 rope_parameters is a flat dict ({type, rope_theta, mrope_section,
@@ -201,6 +248,7 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
201
248
  tieWordEmbeddings: t.tie_word_embeddings ?? raw.tie_word_embeddings ?? false,
202
249
  bosTokenId: t.bos_token_id ?? raw.bos_token_id,
203
250
  eosTokenId: t.eos_token_id ?? raw.eos_token_id,
251
+ canvasLength: raw.canvas_length ?? t.canvas_length,
204
252
  };
205
253
 
206
254
  let kvQuant: KvQuantSpec[] | null = null;
@@ -0,0 +1,436 @@
1
+ // DiffusionGemma denoising generation engine (port D2). Block-diffusion, NOT
2
+ // autoregressive: prefill the prompt once, then for each canvas block fill a
3
+ // fixed-length canvas with uniform-random token ids and un-mask it over <=48
4
+ // denoising steps, feeding the previous step's soft embeddings back in
5
+ // (self-conditioning). Ported verbatim from
6
+ // optiq/vlm/_mlxvlm/generate/diffusion.py (stream_diffusion_generate).
7
+ //
8
+ // RNG parity: canvas init + per-step re-noise call ops.randint with the GLOBAL
9
+ // mlx key (ops.randomSeed). Calling them in the same order as the reference
10
+ // reproduces its draws bit-for-bit → token-for-token parity on a fixed seed.
11
+ //
12
+ // Implements the public-API default sampler (confidence-threshold) AND the
13
+ // model-default (entropy-bound). temperature 0 (greedy) is the default and the
14
+ // only RNG consumers then are the canvas draws.
15
+
16
+ import type { DiffusionGemmaModel } from "../model/diffusion-gemma";
17
+ import { MlxArray } from "../mlx/array";
18
+ import { Dtype } from "../mlx/ffi";
19
+ import * as ops from "../mlx/ops";
20
+ import { disposing } from "../model/gemma4-base";
21
+
22
+ export interface DiffusionGenOptions {
23
+ maxTokens: number;
24
+ maxDenoisingSteps?: number; // default 48
25
+ minCanvasLength?: number; // default 64
26
+ maxCanvasLength?: number; // default model canvas (256)
27
+ fullCanvas?: boolean; // force full model canvas each block
28
+ sampler?: "confidence-threshold" | "entropy-bound"; // default confidence-threshold
29
+ threshold?: number; // confidence accept threshold (default 0.9)
30
+ entropyBound?: number; // entropy sampler bound (default 0.1)
31
+ temperature?: number; // default 0 (greedy)
32
+ tMin?: number; // schedule (default 0.4)
33
+ tMax?: number; // schedule (default 0.8)
34
+ // stable+confident early stop. The shipped checkpoint loads
35
+ // generation_config=None in optiq, so this is OFF in the oracle (entropy runs
36
+ // all steps). Only enabled when BOTH are provided (mirrors stopping_config).
37
+ stabilityThreshold?: number;
38
+ confidenceThreshold?: number;
39
+ eosTokenIds?: number[]; // default [1, 106] (the tokenizer stopping set; NOT 50)
40
+ seed?: bigint; // global RNG seed (default 0)
41
+ /** Image-text-to-text: when set, `promptIds` are the SPLICED ids (with the
42
+ * <|image|> run) and the encoder prefills with the merged vision features +
43
+ * bidirectional overlay instead of the plain text path. Channel-first
44
+ * [1,3,H,W]. Caller keeps ownership. */
45
+ visionPixels?: MlxArray;
46
+ }
47
+
48
+ export interface DiffusionGenResult {
49
+ tokens: number[]; // emitted tokens (EOS-trimmed, EOS excluded)
50
+ blocks: number[][]; // per-block final argmax canvases (full, pre-trim)
51
+ steps: number; // total denoising steps run
52
+ finishReason: "stop" | "length";
53
+ }
54
+
55
+ const I32 = Dtype.int32;
56
+
57
+ /** logits / schedule_temperature (the diffusion linear temperature schedule). */
58
+ function linearTemp(curStep: number, maxSteps: number, tMin: number, tMax: number): number {
59
+ return tMin + (tMax - tMin) * (curStep / maxSteps);
60
+ }
61
+
62
+ /** confidence = exp(token_logit - logsumexp(logits)) in fp32 (per position). */
63
+ function tokenProbability(processed: MlxArray, tokenIds: MlxArray): MlxArray {
64
+ const lf = processed.astype(Dtype.float32);
65
+ const tokExp = ops.expandDims(tokenIds, -1); // [1, L, 1]
66
+ const gathered = ops.takeAlongAxis(lf, tokExp, -1); // [1, L, 1]
67
+ tokExp.dispose();
68
+ const [b, l] = gathered.shape as [number, number, number];
69
+ const tokLogits = ops.reshape(gathered, [b, l]); // squeeze(-1)
70
+ gathered.dispose();
71
+ const lse = ops.logsumexpAxis(lf, -1, false); // [1, L]
72
+ lf.dispose();
73
+ const diff = ops.sub(tokLogits, lse);
74
+ tokLogits.dispose();
75
+ lse.dispose();
76
+ const conf = ops.exp(diff);
77
+ diff.dispose();
78
+ return conf;
79
+ }
80
+
81
+ /** entropy chain (reference _diffusion_entropy_probs_chain): returns
82
+ * { probs, entropy } in fp32. probs = exp(logits - logsumexp). */
83
+ function entropyProbsChain(processed: MlxArray): { probs: MlxArray; entropy: MlxArray } {
84
+ const lf = processed.astype(Dtype.float32);
85
+ const lse = ops.logsumexpAxis(lf, -1, true); // [1, L, 1]
86
+ const logProbs = ops.sub(lf, lse);
87
+ lf.dispose();
88
+ lse.dispose();
89
+ const probs = ops.exp(logProbs);
90
+ const prod = ops.mul(probs, logProbs);
91
+ logProbs.dispose();
92
+ const summed = ops.sumAxis(prod, -1, false); // [1, L]
93
+ prod.dispose();
94
+ const entropy = ops.mulScalar(summed, -1);
95
+ summed.dispose();
96
+ return { probs, entropy };
97
+ }
98
+
99
+ /** per-position entropy -sum(p*logp) over vocab, fp32. */
100
+ function tokenEntropy(processed: MlxArray): MlxArray {
101
+ const { probs, entropy } = entropyProbsChain(processed);
102
+ probs.dispose();
103
+ return entropy;
104
+ }
105
+
106
+ /** soft embeddings from precomputed probs (entropy sampler path):
107
+ * (probs.astype(w) @ w).astype(w) * embed_scale. */
108
+ function softEmbeddingsFromProbs(probs: MlxArray, weight: MlxArray, embedScale: number): MlxArray {
109
+ const pc = probs.astype(weight.dtype);
110
+ const m = ops.matmul(pc, weight);
111
+ pc.dispose();
112
+ const mc = m.astype(weight.dtype);
113
+ m.dispose();
114
+ const out = ops.mulScalar(mc, embedScale);
115
+ mc.dispose();
116
+ return out;
117
+ }
118
+
119
+ /** confidence-threshold acceptance mask (reference _diffusion_confidence_transfer_mask). */
120
+ function confidenceTransferMask(
121
+ confidence: MlxArray,
122
+ unrevealed: MlxArray,
123
+ threshold: number,
124
+ forceAll: boolean,
125
+ ): MlxArray {
126
+ if (forceAll) return ops.logicalNot(ops.logicalNot(unrevealed)); // copy of unrevealed
127
+ const thr = ops.scalarLike(threshold, confidence);
128
+ const ge = ops.greaterEqual(confidence, thr);
129
+ thr.dispose();
130
+ const transfer = ops.logicalAnd(unrevealed, ge);
131
+ ge.dispose();
132
+ const hasUnrevealed = ops.anyAxis(unrevealed, -1, true); // [1, 1]
133
+ const hasTransfer = ops.anyAxis(transfer, -1, true); // [1, 1]
134
+ const notTransfer = ops.logicalNot(hasTransfer);
135
+ hasTransfer.dispose();
136
+ const needsForce = ops.logicalAnd(hasUnrevealed, notTransfer); // [1, 1]
137
+ hasUnrevealed.dispose();
138
+ notTransfer.dispose();
139
+ const negInf = ops.scalarLike(-Infinity, confidence);
140
+ const maskedConf = ops.where(unrevealed, confidence, negInf); // [1, L]
141
+ negInf.dispose();
142
+ const best = ops.argmaxAxis(maskedConf, -1); // [1]
143
+ maskedConf.dispose();
144
+ const L = confidence.shape[confidence.shape.length - 1]!;
145
+ const positions = ops.arange(0, L, 1, I32); // [L]
146
+ const posRow = ops.reshape(positions, [1, L]);
147
+ positions.dispose();
148
+ const bestCol = ops.reshape(best, [1, 1]);
149
+ best.dispose();
150
+ const eq = ops.equal(posRow, bestCol); // [1, L]
151
+ posRow.dispose();
152
+ bestCol.dispose();
153
+ const forced = ops.logicalAnd(eq, needsForce); // broadcast [1, L]
154
+ eq.dispose();
155
+ needsForce.dispose();
156
+ const out = ops.logicalOr(transfer, forced);
157
+ transfer.dispose();
158
+ forced.dispose();
159
+ return out;
160
+ }
161
+
162
+ /** entropy-bound acceptance mask (reference _diffusion_entropy_transfer_mask):
163
+ * sort positions by ascending entropy; accept the lowest-entropy prefix while
164
+ * cumsum - cummax <= bound; unsort. */
165
+ function entropyTransferMask(entropy: MlxArray, bound: number): MlxArray {
166
+ const sortedIdx = ops.argsortAxis(entropy, -1); // [1, L]
167
+ const sortedEnt = ops.takeAlongAxis(entropy, sortedIdx, -1);
168
+ const cum = ops.cumsum(sortedEnt, -1);
169
+ const cmax = ops.cummax(sortedEnt, -1);
170
+ sortedEnt.dispose();
171
+ const delta = ops.sub(cum, cmax);
172
+ cum.dispose();
173
+ cmax.dispose();
174
+ const boundArr = ops.scalarLike(bound, delta);
175
+ const sortedSel = ops.lessEqual(delta, boundArr); // [1, L] bool
176
+ delta.dispose();
177
+ boundArr.dispose();
178
+ // put_along_axis: scatter sortedSel back via sortedIdx into a zero mask.
179
+ const zeros = ops.zeros(sortedSel.shape, sortedSel.dtype);
180
+ const out = ops.putAlongAxis(zeros, sortedIdx, sortedSel, -1);
181
+ zeros.dispose();
182
+ sortedIdx.dispose();
183
+ sortedSel.dispose();
184
+ return out;
185
+ }
186
+
187
+ export function diffusionGenerate(
188
+ model: DiffusionGemmaModel,
189
+ promptIds: number[],
190
+ opts: DiffusionGenOptions,
191
+ ): DiffusionGenResult {
192
+ const t = model.config.text;
193
+ const vocab = t.vocabSize;
194
+ const modelCanvas = model.canvasLength;
195
+ const maxSteps = opts.maxDenoisingSteps ?? 48;
196
+ const fullCanvas = opts.fullCanvas ?? false;
197
+ const maxCanvas = fullCanvas ? modelCanvas : Math.min(modelCanvas, opts.maxCanvasLength ?? modelCanvas);
198
+ const minCanvas = Math.min(maxCanvas, opts.minCanvasLength ?? 64);
199
+ const sampler = opts.sampler ?? "confidence-threshold";
200
+ const threshold = opts.threshold ?? 0.9;
201
+ const entropyBound = opts.entropyBound ?? 0.1;
202
+ const temperature = opts.temperature ?? 0;
203
+ const tMin = opts.tMin ?? 0.4;
204
+ const tMax = opts.tMax ?? 0.8;
205
+ // Stable-stop is OFF unless both thresholds are explicitly provided (the
206
+ // oracle ships generation_config=None → stopping_config None → never stops).
207
+ const stableStop = opts.stabilityThreshold !== undefined && opts.confidenceThreshold !== undefined;
208
+ const stabilityThreshold = opts.stabilityThreshold ?? 1;
209
+ const stopConfidence = opts.confidenceThreshold ?? 0.005;
210
+ const eosIds = new Set(opts.eosTokenIds ?? [1, 106]);
211
+ const maxNewTokens = opts.maxTokens;
212
+
213
+ if (temperature > 0) throw new Error("diffusion temperature>0 (categorical sampling) not wired yet");
214
+
215
+ ops.randomSeed(opts.seed ?? 0n);
216
+
217
+ const cache = opts.visionPixels
218
+ ? model.prefillVision(promptIds, opts.visionPixels)
219
+ : model.prefill(promptIds);
220
+ const dequantWeight = model.dequantEmbedWeight();
221
+
222
+ const emitted: number[] = [];
223
+ const blocks: number[][] = [];
224
+ let totalSteps = 0;
225
+ let generated = 0;
226
+ let finishReason: "stop" | "length" = "length";
227
+ let stopped = false;
228
+ let isFirstBlock = true;
229
+
230
+ try {
231
+ while (generated < maxNewTokens) {
232
+ const remaining = maxNewTokens - generated;
233
+ const canvasLength = fullCanvas
234
+ ? modelCanvas
235
+ : Math.min(maxCanvas, Math.max(remaining, minCanvas));
236
+
237
+ if (!isFirstBlock) {
238
+ // Continuation: prefill the encoder over the previous accepted block.
239
+ const prev = blocks[blocks.length - 1]!;
240
+ const prevArr = MlxArray.fromInt32(Int32Array.from(prev), [1, prev.length]);
241
+ model.extendPrefill(prevArr, cache);
242
+ prevArr.dispose();
243
+ }
244
+ isFirstBlock = false;
245
+
246
+ let currentCanvas = ops.randint(0, vocab, [1, canvasLength], I32); // RNG: init
247
+ let draftReveal = ops.zeros([1, canvasLength], Dtype.bool);
248
+ let draftCanvas: MlxArray = ops.logicalNot(ops.logicalNot(draftReveal)); // placeholder, replaced below
249
+ draftCanvas.dispose();
250
+ draftCanvas = ops.reshape(currentCanvas, [1, canvasLength]); // alias copy
251
+ let argmaxCanvas = ops.reshape(currentCanvas, [1, canvasLength]);
252
+ let scEmbeddings: MlxArray | null = null;
253
+ const history: MlxArray[] = [];
254
+ let stepsThisCanvas = 0;
255
+
256
+ for (let curStep = maxSteps; curStep >= 1; curStep--) {
257
+ stepsThisCanvas++;
258
+ const logits = model.decoderLogits(currentCanvas, cache, scEmbeddings);
259
+ const schedT = linearTemp(curStep, maxSteps, tMin, tMax);
260
+ // Match the reference EXACTLY: divide (not reciprocal-multiply) — the
261
+ // f32 rounding differs and the confidence threshold is a hard cutoff
262
+ // that flips acceptance on a 1-ULP difference, diverging the trajectory.
263
+ const schedArr = ops.scalarLike(schedT, logits);
264
+ const processed = ops.div(logits, schedArr);
265
+ schedArr.dispose();
266
+ logits.dispose();
267
+
268
+ argmaxCanvas.dispose();
269
+ argmaxCanvas = ops.argmaxAxis(processed, -1).astype(I32); // [1, L]
270
+
271
+ if (curStep === 1) {
272
+ processed.dispose();
273
+ break;
274
+ }
275
+
276
+ // temperature 0 → denoiser canvas is the argmax.
277
+ const denoiser = argmaxCanvas;
278
+
279
+ let acceptance: MlxArray;
280
+ let nextSc: MlxArray | null = null;
281
+ if (sampler === "entropy-bound") {
282
+ // cur_step > 1 always here (cur_step==1 breaks before the sampler):
283
+ // entropy AND the next soft embeddings come from the SAME entropy
284
+ // chain (reference _diffusion_entropy_and_soft_embeddings) — NOT the
285
+ // softmax-precise path the confidence sampler uses.
286
+ const { probs, entropy } = entropyProbsChain(processed);
287
+ acceptance = entropyTransferMask(entropy, entropyBound);
288
+ entropy.dispose();
289
+ nextSc = softEmbeddingsFromProbs(probs, dequantWeight, model.embedScale);
290
+ probs.dispose();
291
+ // accepted/current update (entropy variant)
292
+ const accepted = ops.where(acceptance, denoiser, currentCanvas);
293
+ const noise = ops.randint(0, vocab, [1, canvasLength], I32); // RNG: re-noise
294
+ const newCurrent = ops.where(acceptance, accepted, noise);
295
+ noise.dispose();
296
+ currentCanvas.dispose();
297
+ currentCanvas = newCurrent;
298
+ draftReveal.dispose();
299
+ draftReveal = ops.logicalNot(ops.logicalNot(acceptance)); // = acceptance
300
+ draftCanvas.dispose();
301
+ draftCanvas = ops.reshape(argmaxCanvas, [1, canvasLength]);
302
+ accepted.dispose();
303
+ } else {
304
+ const unrevealed = ops.logicalNot(draftReveal);
305
+ const confidence = tokenProbability(processed, denoiser);
306
+ acceptance = confidenceTransferMask(confidence, unrevealed, threshold, false);
307
+ confidence.dispose();
308
+ const accepted = ops.where(acceptance, denoiser, draftCanvas);
309
+ const revealOrAccept = ops.logicalOr(draftReveal, acceptance);
310
+ const noise = ops.randint(0, vocab, [1, canvasLength], I32); // RNG: re-noise
311
+ const newCurrent = ops.where(revealOrAccept, accepted, noise);
312
+ noise.dispose();
313
+ currentCanvas.dispose();
314
+ currentCanvas = newCurrent;
315
+ const newReveal = ops.logicalOr(draftReveal, acceptance);
316
+ draftReveal.dispose();
317
+ draftReveal = newReveal;
318
+ const newDraft = ops.where(acceptance, accepted, draftCanvas);
319
+ draftCanvas.dispose();
320
+ draftCanvas = newDraft;
321
+ unrevealed.dispose();
322
+ revealOrAccept.dispose();
323
+ accepted.dispose();
324
+ }
325
+ acceptance.dispose();
326
+
327
+ // Early stop 1 (confidence sampler): all positions revealed.
328
+ if (sampler === "confidence-threshold") {
329
+ const allRevealed = ops.allReduce(draftReveal);
330
+ const done = ops.itemBool(allRevealed);
331
+ allRevealed.dispose();
332
+ if (done) {
333
+ processed.dispose();
334
+ break;
335
+ }
336
+ }
337
+
338
+ // Early stop 2: stable + confident (only when explicitly configured;
339
+ // the as-loaded oracle has stopping_config None and never stops here).
340
+ if (
341
+ stableStop &&
342
+ stableAndConfident(argmaxCanvas, processed, history, stabilityThreshold, stopConfidence)
343
+ ) {
344
+ processed.dispose();
345
+ break;
346
+ }
347
+
348
+ // Self-conditioning feedback for the next step.
349
+ if (curStep > 1) {
350
+ if (nextSc === null) nextSc = model.softEmbeddings(processed, dequantWeight);
351
+ if (scEmbeddings) scEmbeddings.dispose();
352
+ scEmbeddings = nextSc;
353
+ }
354
+ processed.dispose();
355
+ }
356
+
357
+ if (scEmbeddings) scEmbeddings.dispose();
358
+ for (const hh of history) hh.dispose();
359
+ currentCanvas.dispose();
360
+ draftReveal.dispose();
361
+ draftCanvas.dispose();
362
+
363
+ // Emit from the final argmax canvas.
364
+ const blockTokens = [...argmaxCanvas.astype(Dtype.float32).toFloat32()].map((x) =>
365
+ Math.round(x),
366
+ );
367
+ argmaxCanvas.dispose();
368
+ blocks.push(blockTokens);
369
+ totalSteps += stepsThisCanvas;
370
+
371
+ for (const tok of blockTokens) {
372
+ generated++;
373
+ if (eosIds.has(tok)) {
374
+ stopped = true;
375
+ finishReason = "stop";
376
+ break;
377
+ }
378
+ emitted.push(tok);
379
+ if (generated >= maxNewTokens) {
380
+ stopped = true;
381
+ finishReason = "length";
382
+ break;
383
+ }
384
+ }
385
+ if (stopped) break;
386
+ }
387
+ } finally {
388
+ dequantWeight.dispose();
389
+ for (const c of cache) c.dispose();
390
+ }
391
+
392
+ return { tokens: emitted, blocks, steps: totalSteps, finishReason };
393
+ }
394
+
395
+ /** stable (last `stabilityThreshold` argmax canvases identical) AND confident
396
+ * (mean token entropy < bound). Mutates `history` like the reference. */
397
+ function stableAndConfident(
398
+ argmaxCanvas: MlxArray,
399
+ processed: MlxArray,
400
+ history: MlxArray[],
401
+ stabilityThreshold: number,
402
+ confidenceThreshold: number,
403
+ ): boolean {
404
+ let stable: boolean;
405
+ if (history.length === stabilityThreshold) {
406
+ stable = history.every((canvas) => {
407
+ const eq = ops.equal(argmaxCanvas, canvas);
408
+ const all = ops.allReduce(eq);
409
+ eq.dispose();
410
+ const v = ops.itemBool(all);
411
+ all.dispose();
412
+ return v;
413
+ });
414
+ } else {
415
+ stable = false;
416
+ }
417
+ // Push an INDEPENDENT copy (add-zero forces a fresh buffer): a reshape/view
418
+ // would alias argmaxCanvas and read freed/overwritten data once the caller
419
+ // disposes argmaxCanvas next step → false "stable" → premature stop.
420
+ const zero = ops.scalarLike(0, argmaxCanvas);
421
+ history.push(ops.add(argmaxCanvas, zero));
422
+ zero.dispose();
423
+ if (history.length > stabilityThreshold) history.shift()!.dispose();
424
+ if (!stable) return false;
425
+
426
+ const entropy = tokenEntropy(processed);
427
+ const meanEnt = ops.meanAll(entropy, false);
428
+ entropy.dispose();
429
+ const thr = ops.scalarLike(confidenceThreshold, meanEnt);
430
+ const lt = ops.less(meanEnt, thr);
431
+ meanEnt.dispose();
432
+ thr.dispose();
433
+ const confident = ops.itemBool(lt);
434
+ lt.dispose();
435
+ return confident;
436
+ }
@@ -11,6 +11,7 @@ import { ChatTemplate } from "../chat-template";
11
11
  import { loadModelConfig, type ModelConfig } from "../config";
12
12
  import { Weights } from "../weights";
13
13
  import { createModel, type RuntimeModel } from "../model/factory";
14
+ import { DiffusionGemmaModel } from "../model/diffusion-gemma";
14
15
  import { loadTokenizer, type LoadedTokenizer } from "../tokenizer";
15
16
  import { resolveModelDir } from "./kl";
16
17
 
@@ -153,8 +154,10 @@ export async function generateText(tm: TaskModel, body: string, opts: GenOpts =
153
154
 
154
155
  // Parity default — greedy + full-precision KV + no sampler arm: decode bit-exactly
155
156
  // via the raw forward (matches mlx-lm token-for-token). Sampler arms / kv-quant fall
156
- // through to the product generate().
157
- if (!kv && !opts.sampler && !tm.samplerOverride) {
157
+ // through to the product generate(). DiffusionGemma is non-autoregressive (no AR
158
+ // forward) — always go through generate(), which routes it to the denoising engine.
159
+ const isDiffusion = tm.model instanceof DiffusionGemmaModel;
160
+ if (!isDiffusion && !kv && !opts.sampler && !tm.samplerOverride) {
158
161
  return greedyDecodeBitExact(tm, ids, maxTokens);
159
162
  }
160
163
  const gen = generate(tm.model, ids, {
package/src/generate.ts CHANGED
@@ -16,6 +16,8 @@ import {
16
16
  import * as ops from "./mlx/ops";
17
17
  import { CompiledDecode } from "./model/compiled-decode";
18
18
  import { Gemma4Model, KVCache, RotatingKVCache, type Cache } from "./model/gemma4";
19
+ import { DiffusionGemmaModel } from "./model/diffusion-gemma";
20
+ import { diffusionGenerate } from "./diffusion/diffusion-generate";
19
21
  import type { RuntimeModel } from "./model/factory";
20
22
  import type { KvQuantSpec } from "./config";
21
23
  import {
@@ -55,6 +57,10 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
55
57
  * model's LoraState for exactly the duration of this generation —
56
58
  * a plain field, safe because the generation queue is serialized. */
57
59
  adapters?: string[];
60
+ /** DiffusionGemma image-text-to-text: channel-first pixel_values [1,3,H,W].
61
+ * When set, `promptTokens` are the spliced (<|image|>-expanded) ids and the
62
+ * denoising engine prefills the merged vision features. Caller keeps ownership. */
63
+ visionPixels?: MlxArray;
58
64
  }
59
65
 
60
66
  /** Port of mlx-lm maybe_quantize_kv_cache + optiq serve's per-layer
@@ -170,7 +176,13 @@ export function generate(
170
176
  promptTokens: number[],
171
177
  options: GenerateOptions = {},
172
178
  ): Generation {
173
- let inner = generateInner(model, promptTokens, options);
179
+ // DiffusionGemma is non-autoregressive: route to the denoising engine instead
180
+ // of the AR decode loop. Same Generation/GenerateStats contract so the CLI and
181
+ // server stream it through the existing token machinery.
182
+ let inner =
183
+ model instanceof DiffusionGemmaModel
184
+ ? generateDiffusionInner(model, promptTokens, options)
185
+ : generateInner(model, promptTokens, options);
174
186
  if (options.adapters?.length) inner = adapterScoped(model, options.adapters, inner);
175
187
  const wire =
176
188
  process.env.MLX_BUN_FORCE_WIRE === "1" ||
@@ -178,6 +190,48 @@ export function generate(
178
190
  return new Generation(wire ? wiredScoped(inner) : inner);
179
191
  }
180
192
 
193
+ /** Non-autoregressive diffusion generation, adapted to the AR Generation
194
+ * contract. Runs the denoising engine (its own prefill + canvas loop) and
195
+ * streams the emitted tokens. v1: greedy (temperature 0, confidence-threshold
196
+ * sampler — the OptiQ default); per-block intra-stream + temperature>0
197
+ * (categorical) are follow-ups. */
198
+ async function* generateDiffusionInner(
199
+ model: DiffusionGemmaModel,
200
+ promptTokens: number[],
201
+ options: GenerateOptions,
202
+ ): AsyncGenerator<GeneratedToken, GenerateStats> {
203
+ const t0 = performance.now();
204
+ const maxTokens = options.maxTokens ?? 256;
205
+ // A fresh random canvas seed per request unless the caller pins one.
206
+ const seed =
207
+ options.seed !== undefined
208
+ ? BigInt(options.seed)
209
+ : BigInt(Math.floor(Math.random() * 0x7fffffff));
210
+ // The shipped checkpoint's tokenizer stops on {1, 106}; union any caller eos.
211
+ const eos = [...new Set([1, 106, ...(options.eosTokenIds ?? [])])];
212
+ const result = diffusionGenerate(model, promptTokens, {
213
+ maxTokens,
214
+ sampler: "confidence-threshold",
215
+ temperature: 0,
216
+ eosTokenIds: eos,
217
+ seed,
218
+ visionPixels: options.visionPixels,
219
+ });
220
+ const decodeMs = performance.now() - t0;
221
+ let index = 0;
222
+ for (const token of result.tokens) yield { token, index: index++ };
223
+ return {
224
+ promptTokens: promptTokens.length,
225
+ cachedTokens: 0,
226
+ generatedTokens: result.tokens.length,
227
+ prefillTps: 0,
228
+ decodeTps: result.tokens.length / Math.max(decodeMs / 1000, 1e-9),
229
+ prefillMs: 0,
230
+ decodeMs,
231
+ cacheTokens: [],
232
+ };
233
+ }
234
+
181
235
  /** Hold the model's active-adapter list for exactly this generation. */
182
236
  async function* adapterScoped(
183
237
  model: RuntimeModel,
package/src/mlx/ffi.ts CHANGED
@@ -142,11 +142,17 @@ export const C = dlopen(LIBMLXC_PATH, {
142
142
  mlx_take_along_axis: { args: [P, u64, u64, i32, u64], returns: i32 },
143
143
  mlx_put_along_axis: { args: [P, u64, u64, u64, i32, u64], returns: i32 },
144
144
  mlx_cumsum: { args: [P, u64, i32, FFIType.bool, FFIType.bool, u64], returns: i32 },
145
+ mlx_cummax: { args: [P, u64, i32, FFIType.bool, FFIType.bool, u64], returns: i32 },
145
146
  mlx_where: { args: [P, u64, u64, u64, u64], returns: i32 },
146
147
  mlx_logsumexp_axis: { args: [P, u64, i32, FFIType.bool, u64], returns: i32 },
147
148
  mlx_greater_equal: { args: [P, u64, u64, u64], returns: i32 },
148
149
  mlx_less: { args: [P, u64, u64, u64], returns: i32 },
150
+ mlx_less_equal: { args: [P, u64, u64, u64], returns: i32 },
149
151
  mlx_logical_and: { args: [P, u64, u64, u64], returns: i32 },
152
+ mlx_logical_not: { args: [P, u64, u64], returns: i32 },
153
+ mlx_equal: { args: [P, u64, u64, u64], returns: i32 },
154
+ mlx_all: { args: [P, u64, FFIType.bool, u64], returns: i32 },
155
+ mlx_any_axis: { args: [P, u64, i32, FFIType.bool, u64], returns: i32 },
150
156
  mlx_random_key: { args: [P, u64], returns: i32 },
151
157
  mlx_random_categorical: { args: [P, u64, i32, u64, u64], returns: i32 },
152
158
  mlx_astype: { args: [P, u64, i32, u64], returns: i32 },
@@ -181,6 +187,8 @@ export const C = dlopen(LIBMLXC_PATH, {
181
187
  // (res, low, high, shape*, n, dtype, key may-be-null, stream) — low/high are
182
188
  // scalar mlx_arrays. Used for mlx-lm-faithful LoRA A init (uniform).
183
189
  mlx_random_uniform: { args: [P, u64, u64, P, u64, i32, u64, u64], returns: i32 },
190
+ mlx_random_randint: { args: [P, u64, u64, P, u64, i32, u64, u64], returns: i32 },
191
+ mlx_random_seed: { args: [u64], returns: i32 },
184
192
  mlx_random_split_num: { args: [P, u64, i32, u64], returns: i32 },
185
193
  // --- training: loss/optimizer elementwise + reductions
186
194
  mlx_stop_gradient: { args: [P, u64, u64], returns: i32 },