mlx-bun 0.0.7 → 0.0.9

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.
Files changed (138) hide show
  1. package/README.md +112 -20
  2. package/docs/design/batching-v2-plan.md +515 -0
  3. package/docs/design/bucketing-stage.md +196 -0
  4. package/docs/design/compat-cli-surface-design.md +11 -11
  5. package/docs/design/diffusion-gemma-port.md +602 -0
  6. package/docs/design/docs-reorg-plan.md +1 -1
  7. package/docs/design/dreaming-nightly-pipeline.md +291 -0
  8. package/docs/design/dspark-speculative-decoding.md +106 -0
  9. package/docs/design/generic-model-support.md +381 -0
  10. package/docs/design/memory-inference-path.md +422 -0
  11. package/docs/design/memory-synthesis.md +287 -0
  12. package/docs/design/minicpm5-decode-megakernel.md +484 -0
  13. package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
  14. package/docs/design/orpo-dynamic-lambda.md +41 -0
  15. package/docs/design/orpo-training.md +35 -5
  16. package/docs/design/parallel-slots.md +12 -1
  17. package/docs/design/parity-tier-dag.md +13 -5
  18. package/docs/design/spec-decode-larger-targets.md +229 -0
  19. package/docs/design/tauri-desktop-app.md +2 -2
  20. package/docs/design/the-dreaming-handoff.md +141 -0
  21. package/docs/design/the-dreaming-master-plan.md +446 -0
  22. package/docs/design/write-pipeline-entity.md +468 -0
  23. package/docs/investigations/curve-bisector-routing.md +1 -1
  24. package/docs/investigations/decode-roofline-lookagain.md +262 -0
  25. package/docs/investigations/dspark-handoff.md +58 -0
  26. package/docs/investigations/kernel-perf-review-2026-07.md +92 -0
  27. package/docs/planning/PRODUCT_ROADMAP.md +2 -2
  28. package/docs/planning/curve-sampler-research-plan.md +331 -0
  29. package/docs/planning/memory-docs-and-dag-plan.md +185 -0
  30. package/docs/planning/pi-integration-review.md +178 -0
  31. package/docs/planning/release-notes-v0.0.9.md +122 -0
  32. package/docs/planning/web-ui-pass-plan.md +213 -0
  33. package/docs/planning/website-readme-pass-plan.md +224 -0
  34. package/docs/reference/cli.md +255 -0
  35. package/docs/reference/embedding.md +7 -2
  36. package/docs/reference/library-api.md +22 -3
  37. package/docs/reference/memory.md +5 -5
  38. package/docs/reference/models.md +137 -0
  39. package/docs/reference/orpo-quickstart.md +6 -0
  40. package/docs/reference/server-api.md +187 -9
  41. package/docs/reference/server-config.md +40 -9
  42. package/docs/reference/training.md +25 -11
  43. package/package.json +1 -1
  44. package/src/anthropic.ts +22 -0
  45. package/src/assets/curve-designer.html +1 -1
  46. package/src/chat-template.ts +25 -7
  47. package/src/cli.ts +1029 -86
  48. package/src/config.ts +54 -3
  49. package/src/diffusion/diffusion-generate.ts +436 -0
  50. package/src/download.ts +99 -8
  51. package/src/embed.ts +77 -0
  52. package/src/eval/perplexity.ts +164 -0
  53. package/src/eval/runner.ts +19 -7
  54. package/src/eval/tasks/bfcl.ts +1 -1
  55. package/src/generate.ts +196 -19
  56. package/src/harness-pi.ts +43 -17
  57. package/src/index.ts +2 -0
  58. package/src/lora.ts +2 -2
  59. package/src/memory/article.ts +558 -0
  60. package/src/memory/chunk-validation.ts +150 -0
  61. package/src/memory/chunk.ts +305 -12
  62. package/src/memory/cluster.ts +344 -17
  63. package/src/memory/coverage.ts +134 -0
  64. package/src/memory/crosslink.ts +0 -0
  65. package/src/memory/db.ts +184 -45
  66. package/src/memory/entity.ts +203 -0
  67. package/src/memory/gate.ts +187 -0
  68. package/src/memory/history.ts +152 -0
  69. package/src/memory/ingest.ts +324 -11
  70. package/src/memory/model.ts +321 -0
  71. package/src/memory/normalize.ts +671 -0
  72. package/src/memory/parse.ts +22 -0
  73. package/src/memory/pipeline.ts +242 -30
  74. package/src/memory/prompts.ts +42 -0
  75. package/src/memory/query.ts +334 -0
  76. package/src/memory/reconcile.ts +734 -0
  77. package/src/memory/redirect.ts +108 -0
  78. package/src/memory/reindex.ts +325 -0
  79. package/src/memory/resolve.ts +307 -0
  80. package/src/memory/route.ts +272 -0
  81. package/src/memory/schedule.ts +6 -4
  82. package/src/memory/stages.ts +774 -0
  83. package/src/memory/synthesize.ts +1629 -20
  84. package/src/memory/tools.ts +446 -96
  85. package/src/memory/vault.ts +462 -35
  86. package/src/memory/wikify.ts +1057 -16
  87. package/src/mlx/ffi.ts +11 -0
  88. package/src/mlx/ops.ts +113 -0
  89. package/src/model/compiled-decode.ts +43 -8
  90. package/src/model/diffusion-gemma.ts +1026 -0
  91. package/src/model/factory.ts +35 -8
  92. package/src/model/flash-attention.ts +4 -2
  93. package/src/model/fused-decode-kernel.ts +14 -7
  94. package/src/model/fused-mlp-kernel.ts +169 -0
  95. package/src/model/fused-swiglu-kernel.ts +240 -0
  96. package/src/model/gemma4-base.ts +62 -6
  97. package/src/model/gemma4.ts +33 -2
  98. package/src/model/minicpm5.ts +106 -8
  99. package/src/model/qwen3.ts +276 -0
  100. package/src/model/steel-linear-kernel.ts +96 -0
  101. package/src/model/support.ts +45 -7
  102. package/src/model/universal/archs.ts +332 -0
  103. package/src/model/universal/dense.ts +576 -0
  104. package/src/model/universal/modules.ts +163 -0
  105. package/src/model/universal/rope.ts +366 -0
  106. package/src/pi-provider.ts +1 -1
  107. package/src/pi-terminal.ts +29 -8
  108. package/src/pi-web.ts +19 -2
  109. package/src/quantize/config-writer.ts +1 -1
  110. package/src/registry.ts +215 -7
  111. package/src/responses.ts +23 -0
  112. package/src/sampler.ts +220 -9
  113. package/src/serve/batch-scheduler.ts +272 -80
  114. package/src/serve/generation-gateway.ts +90 -18
  115. package/src/server.ts +643 -50
  116. package/src/spec/drafter.ts +81 -49
  117. package/src/spec/dspark/data-dflash.ts +156 -0
  118. package/src/spec/dspark/data.ts +201 -0
  119. package/src/spec/dspark/generate-dflash.ts +193 -0
  120. package/src/spec/dspark/generate.ts +290 -0
  121. package/src/spec/dspark/loss.ts +213 -0
  122. package/src/spec/dspark/module-dflash.ts +361 -0
  123. package/src/spec/dspark/module.ts +540 -0
  124. package/src/spec/dspark/sample.ts +112 -0
  125. package/src/spec/generate.ts +40 -19
  126. package/src/status-page.html +1 -1
  127. package/src/tokenizer.ts +7 -0
  128. package/src/train/diffusion-lora.ts +216 -0
  129. package/src/train/flash-cce.ts +70 -15
  130. package/src/train/fuse.ts +243 -0
  131. package/src/train/index.ts +3 -0
  132. package/src/train/job.ts +8 -0
  133. package/src/train/loss.ts +289 -30
  134. package/src/train/prefix-shared.ts +33 -5
  135. package/src/train/segmented.ts +67 -11
  136. package/src/train/trainer.ts +92 -28
  137. package/src/vision/diffusion-vision.ts +620 -0
  138. package/src/web/skills/memory/SKILL.md +40 -17
package/src/embed.ts ADDED
@@ -0,0 +1,77 @@
1
+ // Text-embedding entry point. Wraps Qwen3Model.embedPooled (last-token hidden →
2
+ // L2-normalized vector) with the Qwen3-Embedding input convention so the CLI
3
+ // (`mlx-bun embed`), the server (`/v1/embeddings`), and in-repo experiments all
4
+ // produce the SAME vectors — the ones verified bit-exact vs mlx-lm in
5
+ // tests/qwen3-embed-parity.test.ts.
6
+
7
+ import { Qwen3Model } from "./model/qwen3";
8
+ import type { RuntimeModel } from "./model/factory";
9
+ import type { LoadedTokenizer } from "./tokenizer";
10
+ import * as ops from "./mlx/ops";
11
+
12
+ /** <|endoftext|> — the token Qwen3-Embedding terminates every input with and
13
+ * pools the last position of. */
14
+ export const EMBED_EOD = 151643;
15
+
16
+ /** Only plain-Qwen3 (the Qwen3-Embedding backbone) exposes a pooled-embedding
17
+ * path today. Narrow the served RuntimeModel before embedding. */
18
+ export function isEmbeddingModel(model: RuntimeModel): model is Qwen3Model {
19
+ return model instanceof Qwen3Model;
20
+ }
21
+
22
+ /** Qwen3-Embedding query format: a task instruction steers WHICH similarity axis
23
+ * the geometry reflects ("represent this for topic routing" vs "...for
24
+ * sentiment"). Documents are embedded raw; only queries get an instruction. */
25
+ export function withInstruction(text: string, instruction?: string): string {
26
+ return instruction ? `Instruct: ${instruction}\nQuery:${text}` : text;
27
+ }
28
+
29
+ /** Tripwire: counts embedOne invocations (embedMany accrues via embedOne) so
30
+ * callers can assert the pipeline embeds exactly as many times as expected. */
31
+ let embedCallCount = 0;
32
+
33
+ /** Reset the embed tripwire counter to 0. */
34
+ export function resetEmbedCounter(): void {
35
+ embedCallCount = 0;
36
+ }
37
+
38
+ /** Read the embed tripwire counter (total embedOne calls since last reset). */
39
+ export function getEmbedCounter(): number {
40
+ return embedCallCount;
41
+ }
42
+
43
+ export interface EmbedResult {
44
+ /** L2-normalized embedding (length = model hidden size). */
45
+ vector: Float32Array;
46
+ /** Token count of the embedded input (incl. the EOD pooling token). */
47
+ tokens: number;
48
+ }
49
+
50
+ /** Embed one text. `instruction` (optional) applies the query format. */
51
+ export function embedOne(
52
+ model: Qwen3Model,
53
+ tok: LoadedTokenizer,
54
+ text: string,
55
+ instruction?: string,
56
+ ): EmbedResult {
57
+ embedCallCount++;
58
+ // addSpecialTokens=false matches the bit-exact parity path (Qwen3 has no BOS;
59
+ // we append the EOD pooling token ourselves).
60
+ const ids = [...tok.encode(withInstruction(text, instruction), false), EMBED_EOD];
61
+ const idArr = ops.fromInt32(ids, [1, ids.length]);
62
+ const vec = model.embedPooled(idArr);
63
+ idArr.dispose();
64
+ const out = vec.toFloat32();
65
+ vec.dispose();
66
+ return { vector: out, tokens: ids.length };
67
+ }
68
+
69
+ /** Embed many texts (one forward each — the runtime is single-sequence). */
70
+ export function embedMany(
71
+ model: Qwen3Model,
72
+ tok: LoadedTokenizer,
73
+ texts: string[],
74
+ instruction?: string,
75
+ ): EmbedResult[] {
76
+ return texts.map((t) => embedOne(model, tok, t, instruction));
77
+ }
@@ -0,0 +1,164 @@
1
+ // Perplexity over a local text/JSONL dataset — mlx_lm.perplexity methodology.
2
+ //
3
+ // The reference (mlx_lm/perplexity.py, mlx-lm 0.31.3) does:
4
+ // 1. tokenize dataset samples, visit them in a seeded random permutation,
5
+ // 2. concatenate tokens into one stream and cut it into NON-OVERLAPPING
6
+ // rows of `sequence_length` (no sliding-window stride, no BOS re-seeding
7
+ // per row), keeping at most `num_samples` rows,
8
+ // 3. per batch: logits = model(batch[:, :-1]) in f32, per-token
9
+ // cross-entropy vs batch[:, 1:] — EVERY position counts,
10
+ // 4. ppl = exp(mean loss); SE via the delta method:
11
+ // se_ppl = ppl · std(losses, ddof=1) / √N.
12
+ //
13
+ // We reproduce 2-4 exactly. Step 1 differs only in the data source: the
14
+ // reference pulls a Hugging Face dataset (network); we take a LOCAL .txt or
15
+ // .jsonl file — never a download. The permutation RNG is a seeded
16
+ // deterministic shuffle (not NumPy's), so row selection is reproducible per
17
+ // seed but not bit-matched to the Python tool's sampling.
18
+ //
19
+ // The forward reuses the existing full-sequence machinery
20
+ // (src/train/forward.ts trainForward → model.forwardHidden + logitsFromHidden).
21
+
22
+ import { clearCache, Dtype } from "../mlx/ffi";
23
+ import * as ops from "../mlx/ops";
24
+ import { trainForward } from "../train/forward";
25
+ import type { RuntimeModel } from "../model/factory";
26
+
27
+ export interface PplDataOptions {
28
+ sequenceLength: number;
29
+ /** Max rows to keep (-1 / 0 = all available). */
30
+ numSamples: number;
31
+ /** Seed for the sample-order shuffle (reference default: 123). */
32
+ seed: number;
33
+ }
34
+
35
+ export interface PplResult {
36
+ ppl: number;
37
+ /** Delta-method standard error of the perplexity. */
38
+ standardError: number;
39
+ meanLoss: number;
40
+ tokens: number;
41
+ rows: number;
42
+ }
43
+
44
+ /** Deterministic PRNG (mulberry32) for the seeded sample shuffle. */
45
+ function mulberry32(seed: number): () => number {
46
+ let s = seed >>> 0;
47
+ return () => {
48
+ s = (s + 0x6d2b79f5) >>> 0;
49
+ let t = s;
50
+ t = Math.imul(t ^ (t >>> 15), t | 1);
51
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
52
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
53
+ };
54
+ }
55
+
56
+ /** Split a raw dataset file into text samples: `.jsonl` → one sample per
57
+ * line from the `text` field; anything else → the whole file is one sample. */
58
+ export function parseSamples(raw: string, path: string): string[] {
59
+ if (path.endsWith(".jsonl") || path.endsWith(".ndjson")) {
60
+ const samples: string[] = [];
61
+ for (const [i, line] of raw.split("\n").entries()) {
62
+ if (!line.trim()) continue;
63
+ let row: unknown;
64
+ try {
65
+ row = JSON.parse(line);
66
+ } catch {
67
+ throw new Error(`${path}:${i + 1}: not valid JSON`);
68
+ }
69
+ const text = (row as { text?: unknown }).text;
70
+ if (typeof text !== "string")
71
+ throw new Error(`${path}:${i + 1}: expected a {"text": "..."} row`);
72
+ samples.push(text);
73
+ }
74
+ return samples;
75
+ }
76
+ return raw.length > 0 ? [raw] : [];
77
+ }
78
+
79
+ /** Tokenize samples (seeded shuffle), concatenate, and pack into
80
+ * non-overlapping rows of `sequenceLength` tokens (reference load_data). */
81
+ export function packRows(
82
+ sampleTokens: number[][],
83
+ opts: PplDataOptions,
84
+ ): Int32Array[] {
85
+ const { sequenceLength: L, numSamples, seed } = opts;
86
+ const order = sampleTokens.map((_, i) => i);
87
+ const rnd = mulberry32(seed);
88
+ for (let i = order.length - 1; i > 0; i--) {
89
+ const j = Math.floor(rnd() * (i + 1));
90
+ [order[i], order[j]] = [order[j]!, order[i]!];
91
+ }
92
+
93
+ const wanted = numSamples > 0 ? L * numSamples : Infinity;
94
+ const stream: number[] = [];
95
+ for (const idx of order) {
96
+ if (stream.length >= wanted) break;
97
+ stream.push(...sampleTokens[idx]!);
98
+ }
99
+
100
+ const nRows = Math.min(
101
+ Math.floor(stream.length / L),
102
+ numSamples > 0 ? numSamples : Infinity,
103
+ );
104
+ const rows: Int32Array[] = [];
105
+ for (let r = 0; r < nRows; r++)
106
+ rows.push(Int32Array.from(stream.slice(r * L, (r + 1) * L)));
107
+ return rows;
108
+ }
109
+
110
+ /** Evaluate perplexity over packed rows. Per batch (all rows are the same
111
+ * length, so the stock unpadded forward applies): f32 logits over
112
+ * batch[:, :-1], per-token CE vs batch[:, 1:], every position counted. */
113
+ export function evalPpl(
114
+ model: RuntimeModel,
115
+ rows: Int32Array[],
116
+ batchSize: number,
117
+ onBatch?: (done: number, total: number) => void,
118
+ ): PplResult {
119
+ const L = rows[0]!.length;
120
+ const allLosses: number[] = [];
121
+ const numBatches = Math.ceil(rows.length / batchSize);
122
+
123
+ for (let s = 0, bi = 0; s < rows.length; s += batchSize, bi++) {
124
+ const batch = rows.slice(s, s + batchSize);
125
+ const B = batch.length;
126
+ const inputs: number[] = [];
127
+ const targets: number[] = [];
128
+ for (const row of batch) {
129
+ for (let i = 0; i < L - 1; i++) inputs.push(row[i]!);
130
+ for (let i = 1; i < L; i++) targets.push(row[i]!);
131
+ }
132
+ const ids = ops.fromInt32(inputs, [B, L - 1]);
133
+ const tgt = ops.fromInt32(targets, [B, L - 1, 1]);
134
+ const logits = trainForward(model, ids);
135
+ const f32 = logits.astype(Dtype.float32);
136
+ logits.dispose();
137
+ // CE[t] = logsumexp(logits[t]) − logits[t, target] (f32, like nn.losses)
138
+ const lse = ops.logsumexpAxis(f32, 2, false); // [B, L-1]
139
+ const picked = ops.takeAlongAxis(f32, tgt, 2); // [B, L-1, 1]
140
+ f32.dispose();
141
+ const picked2d = ops.reshape(picked, [B, L - 1]);
142
+ picked.dispose();
143
+ const losses = ops.sub(lse, picked2d);
144
+ lse.dispose();
145
+ picked2d.dispose();
146
+ const vals = losses.toFloat32();
147
+ losses.dispose();
148
+ ids.dispose();
149
+ tgt.dispose();
150
+ for (const v of vals) allLosses.push(v);
151
+ clearCache();
152
+ onBatch?.(bi + 1, numBatches);
153
+ }
154
+
155
+ const n = allLosses.length;
156
+ const mean = allLosses.reduce((a, b) => a + b, 0) / n;
157
+ const ppl = Math.exp(mean);
158
+ // Sample std (ddof=1) → delta-method SE of the perplexity.
159
+ const varSum = allLosses.reduce((a, b) => a + (b - mean) ** 2, 0);
160
+ const std = n > 1 ? Math.sqrt(varSum / (n - 1)) : 0;
161
+ const standardError = ppl * (std / Math.sqrt(n));
162
+
163
+ return { ppl, standardError, meanLoss: mean, tokens: n, rows: rows.length };
164
+ }
@@ -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
 
@@ -98,6 +99,11 @@ export interface GenOpts {
98
99
  * (its capability numbers are non-thinking; e.g. MiniCPM5 IFEval 64.7 is
99
100
  * non-thinking). Set MLX_BUN_EVAL_THINK=1 or pass true to evaluate thinking mode. */
100
101
  enableThinking?: boolean;
102
+ /** Explicit KV-quant scheme (cli `generate --l2/--l3/--kv-quant`, resolved with
103
+ * serve's semantics). Overrides the MLX_BUN_EVAL_KV_QUANT env default; an empty
104
+ * object forces bf16. When it names quantized KV, generation runs the product
105
+ * generate() path (same tokens as `serve` with the same scheme). */
106
+ kvScheme?: Pick<GenerateOptions, "kvBits" | "kvConfig" | "quantizedKvStart">;
101
107
  }
102
108
 
103
109
  /** Bit-exact greedy decode: raw `model.forward` + argmax loop, which matches mlx-lm
@@ -105,7 +111,7 @@ export interface GenOpts {
105
111
  * (forwardHidden/logitsFromHidden + pipelined sampler) diverges from the raw forward
106
112
  * on near-ties past ~32 tokens, so the EVAL decodes bit-exactly itself rather than
107
113
  * alter the serving path. Greedy + full-precision KV only. */
108
- function greedyDecodeBitExact(tm: TaskModel, ids: number[], maxTokens: number): string {
114
+ export function greedyDecodeBitExact(tm: TaskModel, ids: number[], maxTokens: number): string {
109
115
  if (tm.activeAdapters) tm.model.loraState.active = tm.activeAdapters; // apply the mounted adapter
110
116
  const cache = tm.model.makeCache();
111
117
  try {
@@ -147,21 +153,27 @@ export async function generateText(tm: TaskModel, body: string, opts: GenOpts =
147
153
  // plain mlx_lm.generate (kv-quant lives in the serving runtime, not the eval). So
148
154
  // for like-for-like parity we default the eval to UNQUANTIZED KV; MMLU's argmax
149
155
  // path was already unquantized, which is why it matched while generation didn't.
150
- // MLX_BUN_EVAL_KV_QUANT=1 generates through the model's quantized KV (serving config).
151
- const kv = (process.env.MLX_BUN_EVAL_KV_QUANT === "1" && tm.config.kvQuant?.length)
152
- ? tm.config.kvQuant : undefined;
156
+ // MLX_BUN_EVAL_KV_QUANT=1 generates through the model's quantized KV (serving
157
+ // config); an explicit opts.kvScheme (cli generate's resolved tier route) wins.
158
+ const envKv: GenOpts["kvScheme"] | undefined =
159
+ (process.env.MLX_BUN_EVAL_KV_QUANT === "1" && tm.config.kvQuant?.length)
160
+ ? { kvConfig: tm.config.kvQuant, quantizedKvStart: 0 } : undefined;
161
+ const kvScheme = opts.kvScheme ?? envKv;
162
+ const kvActive = !!(kvScheme && (kvScheme.kvBits || kvScheme.kvConfig?.length));
153
163
 
154
164
  // Parity default — greedy + full-precision KV + no sampler arm: decode bit-exactly
155
165
  // 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) {
166
+ // through to the product generate(). DiffusionGemma is non-autoregressive (no AR
167
+ // forward) — always go through generate(), which routes it to the denoising engine.
168
+ const isDiffusion = tm.model instanceof DiffusionGemmaModel;
169
+ if (!isDiffusion && !kvActive && !opts.sampler && !tm.samplerOverride) {
158
170
  return greedyDecodeBitExact(tm, ids, maxTokens);
159
171
  }
160
172
  const gen = generate(tm.model, ids, {
161
173
  maxTokens,
162
174
  temperature: 0, // greedy default — deterministic head-to-head arms
163
175
  adapters: tm.activeAdapters ?? [], // per-request activation of the mounted eval-adapter
164
- ...(kv ? { kvConfig: kv, quantizedKvStart: 0 } : {}),
176
+ ...(kvActive ? kvScheme : {}),
165
177
  ...(opts.sampler ?? {}), // overrides temperature when provided
166
178
  ...(tm.samplerOverride ?? {}), // arm override wins over the task's own sampler
167
179
  });
@@ -15,7 +15,7 @@
15
15
  // of the task — is ported exactly. (See summary for this one note.)
16
16
 
17
17
  import { generateText, loadJsonl, sampleIndices, type TaskModel } from "../runner";
18
- import { type ToolDefinition } from "../chat-template";
18
+ import { type ToolDefinition } from "../../chat-template";
19
19
 
20
20
  // ---------------------------------------------------------------------------
21
21
  // Data shapes (the merged jsonl: each line = {query, answer}).
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,15 +57,47 @@ 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;
64
+ /** Capture each emitted token's log-probability (mlx_lm.server `logprobs`).
65
+ * The distribution matches mlx-lm generate_step exactly: full-vocab
66
+ * log-softmax of the logits AFTER logits processors, BEFORE the sampler's
67
+ * temperature/top-p/top-k/min-p/XTC (generate.py L409-422). Off by default —
68
+ * the hot path pays nothing when unset. */
69
+ logprobs?: boolean;
70
+ /** Capture the top-k (token id, logprob) pairs per emitted token from the
71
+ * same distribution (mlx_lm.server `top_logprobs`). 0/unset = off. */
72
+ topLogprobs?: number;
58
73
  }
59
74
 
60
- /** Port of mlx-lm maybe_quantize_kv_cache + optiq serve's per-layer
61
- * patched variant (incl. patch_rotating_to_quantized: rotating caches
62
- * convert too — Phase 9). kvConfig overrides kvBits, matching optiq's
63
- * --kv-config precedence; shipped kv_config.json files list
64
- * full-attention layers only, so rotating quantization engages through
65
- * uniform kvBits (like optiq --kv-bits) or a config that names sliding
66
- * layers. */
75
+ /** Per-token logprob info (only present when GenerateOptions requested it). */
76
+ export interface TokenLogprobs {
77
+ /** logprob of the emitted token (requires options.logprobs). */
78
+ logprob?: number;
79
+ /** top-k (id, logprob) pairs, sorted by logprob descending (requires
80
+ * options.topLogprobs > 0). mlx-lm leaves argpartition order unspecified;
81
+ * sorting is the deterministic reading of the same set. */
82
+ top?: { id: number; logprob: number }[];
83
+ }
84
+
85
+ /** Port of mlx-lm maybe_quantize_kv_cache + BOTH halves of optiq serve's
86
+ * per-layer patched variant (incl. patch_rotating_to_quantized: rotating
87
+ * caches convert too — Phase 9):
88
+ * - per-layer bits/group_size selection (kvConfig overrides kvBits,
89
+ * matching optiq's --kv-config precedence; shipped kv_config.json
90
+ * files list full-attention layers only, so rotating quantization
91
+ * engages through uniform kvBits — like optiq --kv-bits — or a config
92
+ * that names sliding layers), and
93
+ * - STREAMING conversion (optiq streaming_kv_quant / serve.py
94
+ * patched_maybe_quantize): eval each layer's quantized triples and
95
+ * clear the buffer pool before building the next layer's conversion.
96
+ * Lazily batching every layer's toQuantized into one eval pins ALL
97
+ * layers' bf16 K/V as graph inputs alongside ALL quantized outputs —
98
+ * the exact transient optiq's fix kills (16.35 → 7.60 GB at 32k on a
99
+ * 24 GB Mac). Numerics untouched: same quantize math, only the eval
100
+ * ordering is forced (tests/kv-quant.test.ts, tests/rotating-kvq.test.ts). */
67
101
  export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void {
68
102
  const { kvBits, kvConfig } = options;
69
103
  if (!kvBits && !kvConfig?.length) return;
@@ -81,10 +115,16 @@ export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void
81
115
  if (c.offset === 0) continue;
82
116
  if (byLayer) {
83
117
  const e = byLayer.get(i);
84
- if (e) cache[i] = c.toQuantized(e.groupSize, e.bits);
118
+ if (!e) continue;
119
+ cache[i] = c.toQuantized(e.groupSize, e.bits);
85
120
  } else {
86
121
  cache[i] = c.toQuantized(options.kvGroupSize ?? 64, kvBits!);
87
122
  }
123
+ // Streaming half: materialize THIS layer's conversion now so its bf16
124
+ // source (already released by toQuantized) frees before the next layer
125
+ // converts — the transient stays ~one layer, not the whole cache.
126
+ ops.evalAll(cache[i]!.state());
127
+ clearCache();
88
128
  }
89
129
  }
90
130
 
@@ -106,6 +146,8 @@ export interface GenerateStats {
106
146
  export interface GeneratedToken {
107
147
  token: number;
108
148
  index: number;
149
+ /** Present only when GenerateOptions.logprobs / topLogprobs requested it. */
150
+ logprobs?: TokenLogprobs;
109
151
  }
110
152
 
111
153
  export class Generation implements AsyncIterable<GeneratedToken> {
@@ -170,7 +212,13 @@ export function generate(
170
212
  promptTokens: number[],
171
213
  options: GenerateOptions = {},
172
214
  ): Generation {
173
- let inner = generateInner(model, promptTokens, options);
215
+ // DiffusionGemma is non-autoregressive: route to the denoising engine instead
216
+ // of the AR decode loop. Same Generation/GenerateStats contract so the CLI and
217
+ // server stream it through the existing token machinery.
218
+ let inner =
219
+ model instanceof DiffusionGemmaModel
220
+ ? generateDiffusionInner(model, promptTokens, options)
221
+ : generateInner(model, promptTokens, options);
174
222
  if (options.adapters?.length) inner = adapterScoped(model, options.adapters, inner);
175
223
  const wire =
176
224
  process.env.MLX_BUN_FORCE_WIRE === "1" ||
@@ -178,6 +226,48 @@ export function generate(
178
226
  return new Generation(wire ? wiredScoped(inner) : inner);
179
227
  }
180
228
 
229
+ /** Non-autoregressive diffusion generation, adapted to the AR Generation
230
+ * contract. Runs the denoising engine (its own prefill + canvas loop) and
231
+ * streams the emitted tokens. v1: greedy (temperature 0, confidence-threshold
232
+ * sampler — the OptiQ default); per-block intra-stream + temperature>0
233
+ * (categorical) are follow-ups. */
234
+ async function* generateDiffusionInner(
235
+ model: DiffusionGemmaModel,
236
+ promptTokens: number[],
237
+ options: GenerateOptions,
238
+ ): AsyncGenerator<GeneratedToken, GenerateStats> {
239
+ const t0 = performance.now();
240
+ const maxTokens = options.maxTokens ?? 256;
241
+ // A fresh random canvas seed per request unless the caller pins one.
242
+ const seed =
243
+ options.seed !== undefined
244
+ ? BigInt(options.seed)
245
+ : BigInt(Math.floor(Math.random() * 0x7fffffff));
246
+ // The shipped checkpoint's tokenizer stops on {1, 106}; union any caller eos.
247
+ const eos = [...new Set([1, 106, ...(options.eosTokenIds ?? [])])];
248
+ const result = diffusionGenerate(model, promptTokens, {
249
+ maxTokens,
250
+ sampler: "confidence-threshold",
251
+ temperature: 0,
252
+ eosTokenIds: eos,
253
+ seed,
254
+ visionPixels: options.visionPixels,
255
+ });
256
+ const decodeMs = performance.now() - t0;
257
+ let index = 0;
258
+ for (const token of result.tokens) yield { token, index: index++ };
259
+ return {
260
+ promptTokens: promptTokens.length,
261
+ cachedTokens: 0,
262
+ generatedTokens: result.tokens.length,
263
+ prefillTps: 0,
264
+ decodeTps: result.tokens.length / Math.max(decodeMs / 1000, 1e-9),
265
+ prefillMs: 0,
266
+ decodeMs,
267
+ cacheTokens: [],
268
+ };
269
+ }
270
+
181
271
  /** Hold the model's active-adapter list for exactly this generation. */
182
272
  async function* adapterScoped(
183
273
  model: RuntimeModel,
@@ -219,6 +309,14 @@ async function* generateInner(
219
309
  const processors = makeLogitsProcessors(options);
220
310
  const needsTokenHistory = processors.length > 0;
221
311
 
312
+ // logprobs capture (mlx_lm.server semantics — see GenerateOptions.logprobs).
313
+ // Everything below is gated: when neither flag is set, no extra ops, evals,
314
+ // or readbacks happen on the hot path.
315
+ const captureSelLp = options.logprobs === true;
316
+ const captureTopK =
317
+ options.topLogprobs && options.topLogprobs > 0 ? options.topLogprobs : 0;
318
+ const capture = captureSelLp || captureTopK > 0;
319
+
222
320
  const ownsCache = !options.cache;
223
321
  const cache = options.cache ?? model.makeCache();
224
322
  const cachedTokens = cache[0]!.offset;
@@ -229,16 +327,73 @@ async function* generateInner(
229
327
  /** device-side token history (only maintained when processors need it) */
230
328
  let history: MlxArray | null = null;
231
329
 
232
- // logits [1,1,V] → sampled token array [1] (all on-device)
233
- const sampleStep = (logits3d: MlxArray, step: number): MlxArray => {
330
+ /** Device-side logprob capture for one step, computed from the SAME lp the
331
+ * sampler saw (post-processors, pre-truncation) — read back lazily with the
332
+ * token so decode pipelining is preserved. */
333
+ interface StepExtras {
334
+ sel: MlxArray | null; // [1,1] emitted token's logprob
335
+ topIdx: MlxArray | null; // [1,k] top-k token ids
336
+ topVals: MlxArray | null; // [1,k] their logprobs
337
+ }
338
+ const extrasArrays = (e: StepExtras | null): MlxArray[] =>
339
+ e ? [e.sel, e.topIdx, e.topVals].filter((a): a is MlxArray => a !== null) : [];
340
+ const disposeExtras = (e: StepExtras | null): void => {
341
+ if (e) for (const a of [e.sel, e.topIdx, e.topVals]) a?.dispose();
342
+ };
343
+ /** Read extras back to JS (forces eval — they were async-dispatched with the
344
+ * token, so this normally just copies) and dispose the device arrays. */
345
+ const readExtras = (e: StepExtras | null): TokenLogprobs | undefined => {
346
+ if (!e) return undefined;
347
+ const out: TokenLogprobs = {};
348
+ if (e.sel) out.logprob = e.sel.toFloat32()[0]!;
349
+ if (e.topIdx && e.topVals) {
350
+ // uint32 ids read via the f32 cast: exact for ids < 2^24 (vocab ≪ 16M)
351
+ const ids = e.topIdx.toFloat32();
352
+ const vals = e.topVals.toFloat32();
353
+ out.top = Array.from(ids, (id, i) => ({ id, logprob: vals[i]! })).sort(
354
+ (a, b) => b.logprob - a.logprob,
355
+ );
356
+ }
357
+ disposeExtras(e);
358
+ return out;
359
+ };
360
+
361
+ // logits [1,1,V] → sampled token array [1] (+ optional logprob capture,
362
+ // all on-device)
363
+ const sampleStep = (
364
+ logits3d: MlxArray,
365
+ step: number,
366
+ ): { tok: MlxArray; extras: StepExtras | null } => {
234
367
  const V = logits3d.shape[2]!;
235
368
  let logits = ops.reshape(logits3d, [1, V]);
236
369
  for (const p of processors) logits = disposing(logits, p(history, logits));
237
370
  const lp = toLogprobs(logits);
238
371
  logits.dispose();
239
372
  const tok = sampler(lp, step);
373
+ let extras: StepExtras | null = null;
374
+ if (capture) {
375
+ let sel: MlxArray | null = null;
376
+ let topIdx: MlxArray | null = null;
377
+ let topVals: MlxArray | null = null;
378
+ if (captureSelLp) {
379
+ const idx = ops.reshape(tok, [1, 1]);
380
+ sel = ops.takeAlongAxis(lp, idx, -1);
381
+ idx.dispose();
382
+ }
383
+ if (captureTopK > 0) {
384
+ // mlx-lm _format_top_logprobs: argpartition(-logprobs, kth=k-1)[:k]
385
+ const k = Math.min(captureTopK, V);
386
+ const negLp = ops.neg(lp);
387
+ const part = ops.argpartitionAxis(negLp, k - 1, -1);
388
+ const idxView = part.slice([0, 0], [1, k]);
389
+ topIdx = ops.contiguous(idxView); // slices are strided views; raw readback needs contiguous
390
+ topVals = ops.takeAlongAxis(lp, topIdx, -1);
391
+ for (const a of [negLp, part, idxView]) a.dispose();
392
+ }
393
+ extras = { sel, topIdx, topVals };
394
+ }
240
395
  lp.dispose();
241
- return tok;
396
+ return { tok, extras };
242
397
  };
243
398
 
244
399
  const pushHistory = (tok: MlxArray) => {
@@ -263,6 +418,8 @@ async function* generateInner(
263
418
  const forwarded: number[] = [];
264
419
  let pending: MlxArray | null = null;
265
420
  let nextPending: MlxArray | null = null;
421
+ let pendingExtras: StepExtras | null = null;
422
+ let nextExtras: StepExtras | null = null;
266
423
  let finished = false;
267
424
  let threw = false;
268
425
  const makeStats = (): GenerateStats => ({
@@ -325,7 +482,9 @@ async function* generateInner(
325
482
  h0.dispose();
326
483
  const logits0 = model.logitsFromHidden(hLast);
327
484
  hLast.dispose();
328
- pending = sampleStep(logits0, 0); // token array [1]
485
+ const s0 = sampleStep(logits0, 0); // token array [1] (+ optional extras)
486
+ pending = s0.tok;
487
+ pendingExtras = s0.extras;
329
488
  logits0.dispose();
330
489
  // mirror mlx-lm generate_step: async-dispatch the first token's
331
490
  // compute; the prefill clock keeps running until the token ARRIVES
@@ -335,7 +494,7 @@ async function* generateInner(
335
494
  // to prompt_time, not decode — replicated so cross-stack decode
336
495
  // tok/s measure the same quantity. The boundary cost is real and
337
496
  // scales with prompt length; it belongs to "having prefilled".
338
- ops.asyncEvalAll([pending]);
497
+ ops.asyncEvalAll([pending, ...extrasArrays(pendingExtras)]);
339
498
 
340
499
  // ---- decode (pipelined) ----
341
500
  // Compiled decode (docs/design/optimization_plan.md Phase A): replay the per-step
@@ -359,8 +518,11 @@ async function* generateInner(
359
518
  let stop = false;
360
519
  while (!stop) {
361
520
  const cur = pending!;
521
+ const curExtras = pendingExtras;
522
+ pendingExtras = null;
362
523
  // build step n+1's graph from the *unread* pending token
363
524
  nextPending = null;
525
+ nextExtras = null;
364
526
  if (generated + 1 < maxTokens) {
365
527
  maybeQuantizeKv(cache, options);
366
528
  pushHistory(cur);
@@ -372,8 +534,11 @@ async function* generateInner(
372
534
  logits = r.logits;
373
535
  evalWith = r.evalWith;
374
536
  } catch (e) {
375
- // growth done by a partial prepare is benign for the
376
- // uncompiled path (updateAndFetch re-checks capacity)
537
+ // Safe to re-forward the SAME token below: a failed step is
538
+ // transactional — segmented mode stages ring adopts and rolls
539
+ // back committed js-layer writes on throw (see #stepSegmented),
540
+ // and buffer growth done by a partial prepare is benign for the
541
+ // uncompiled path (updateAndFetch re-checks capacity).
377
542
  compiled = null;
378
543
  console.warn(`compiled decode disabled for this generation: ${e}`);
379
544
  }
@@ -385,9 +550,11 @@ async function* generateInner(
385
550
  logits = model.logitsFromHidden(h);
386
551
  h.dispose();
387
552
  }
388
- nextPending = sampleStep(logits, generated + 1);
553
+ const sn = sampleStep(logits, generated + 1);
554
+ nextPending = sn.tok;
555
+ nextExtras = sn.extras;
389
556
  logits.dispose();
390
- ops.asyncEvalAll([nextPending, ...evalWith]);
557
+ ops.asyncEvalAll([nextPending, ...extrasArrays(nextExtras), ...evalWith]);
391
558
  }
392
559
 
393
560
  // sync-read step n's token while n+1 computes
@@ -406,11 +573,17 @@ async function* generateInner(
406
573
  if (nextPending !== null) forwarded.push(token);
407
574
 
408
575
  if (eosTokenIds.includes(token)) {
576
+ disposeExtras(curExtras);
409
577
  nextPending?.dispose();
410
578
  nextPending = null;
579
+ disposeExtras(nextExtras);
580
+ nextExtras = null;
411
581
  stop = true;
412
582
  } else {
413
- yield { token, index: generated - 1 };
583
+ // readExtras before the yield: if the consumer breaks at this yield,
584
+ // the extras are already read and disposed.
585
+ const logprobs = readExtras(curExtras);
586
+ yield { token, index: generated - 1, ...(logprobs ? { logprobs } : {}) };
414
587
  // mlx-lm generate_step: clear_cache after token 0 (drops the
415
588
  // remaining prefill transients) and every 256 tokens after
416
589
  if ((generated - 1) % 256 === 0) clearCache();
@@ -419,6 +592,8 @@ async function* generateInner(
419
592
  } else {
420
593
  pending = nextPending;
421
594
  nextPending = null;
595
+ pendingExtras = nextExtras;
596
+ nextExtras = null;
422
597
  }
423
598
  }
424
599
  }
@@ -432,6 +607,8 @@ async function* generateInner(
432
607
  if (!finished) {
433
608
  pending?.dispose();
434
609
  nextPending?.dispose();
610
+ disposeExtras(pendingExtras);
611
+ disposeExtras(nextExtras);
435
612
  }
436
613
  if (ownsCache) for (const c of cache) c.dispose();
437
614
  history?.dispose();