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
@@ -1,11 +1,17 @@
1
1
  // Speculative decoding loop — port of optiq runtime/spec/runtime.py
2
2
  // (γ-draft / verify-in-one-pass / accept-longest-prefix / rollback).
3
3
  //
4
- // Correctness contract: greedy spec output is token-for-token identical
5
- // to greedy non-spec decode — the verify step re-derives every emitted
6
- // token with the SAME pick function as the stock path (logprobs argmax
7
- // over [1, V]); drafts only ever skip target forwards, never change
8
- // what the target would emit.
4
+ // Correctness oracle: OPTIQ's spec_generate, NOT stock decode. Assistant-
5
+ // drafter speculation is an optiq feature (mlx-lm has only generic
6
+ // two-model spec, and can't drive a KV-borrowing drafter). Both optiq AND
7
+ // mlx-lm BATCH the verify lm-head (one matmul over the whole γ+1 window,
8
+ // then argmax per position) — so neither is bit-exact to stock token-at-
9
+ // a-time decode; they diverge from it at bf16 knife-edges. We do the same
10
+ // (picksBatched): greedy spec is bit-exact to optiq's spec, and agrees
11
+ // with stock only on tie-free prompts (where batched == per-position).
12
+ // An earlier version verified per-position to stay bit-exact to STOCK —
13
+ // that matched no real oracle and cost an extra γ× read of the lm-head
14
+ // weight per step; superseded. See docs/design/spec-decode-larger-targets.md.
9
15
  //
10
16
  // Limitation (same as the reference): partial-accept rollback requires
11
17
  // trimmable caches; RotatingKVCache loses trimability once its ring
@@ -14,7 +20,6 @@
14
20
  import { MlxArray } from "../mlx/array";
15
21
  import * as ops from "../mlx/ops";
16
22
  import { Gemma4Model, type Cache } from "../model/gemma4";
17
- import { toLogprobs } from "../sampler";
18
23
  import type { GemmaAssistantDrafter } from "./drafter";
19
24
 
20
25
  export interface SpecOptions {
@@ -39,12 +44,8 @@ export interface SpecResult {
39
44
  stats: SpecStats;
40
45
  }
41
46
 
42
- /** The stock path's greedy pick, bit-identical shapes: slice ONE hidden
43
- * position [1,1,H], lm-head on it (exactly what generate() does for a
44
- * decode step), then logprobs argmax over [1, V]. Computing the lm-head
45
- * per position (instead of batched over the verify window) keeps every
46
- * pick in the same kernel shapes as stock decode — batched lm-head
47
- * rounds differently and flips bf16 knife-edges. */
47
+ /** Single greedy pick from one hidden position [1,1,H] (lm-head + argmax).
48
+ * Used for the NON-speculative prefill token (matches stock there). */
48
49
  function pickFromHidden(model: Gemma4Model, hidden: MlxArray, pos: number): number {
49
50
  const H = hidden.shape[2]!;
50
51
  const hSl = hidden.slice([0, pos, 0], [1, pos + 1, H]);
@@ -53,15 +54,36 @@ function pickFromHidden(model: Gemma4Model, hidden: MlxArray, pos: number): numb
53
54
  const V = logits.shape[2]!;
54
55
  const flat = ops.reshape(logits, [1, V]);
55
56
  logits.dispose();
56
- const lp = toLogprobs(flat);
57
+ const am = ops.argmaxAxis(flat, -1);
57
58
  flat.dispose();
58
- const am = ops.argmaxAxis(lp, -1);
59
- lp.dispose();
60
59
  const t = ops.itemUint32(am);
61
60
  am.dispose();
62
61
  return t;
63
62
  }
64
63
 
64
+ /** optiq-faithful batched verify: ONE lm-head over the whole γ+1 window
65
+ * (reads the lm-head weight ONCE, as optiq and mlx-lm both do), then
66
+ * argmax per position. This is what makes greedy spec bit-exact to
67
+ * optiq's spec_generate. argmax is invariant to the logsumexp shift and
68
+ * to the monotone final-logit softcap, so raw-logit argmax here picks the
69
+ * same token optiq does. */
70
+ function picksBatched(model: Gemma4Model, hidden: MlxArray, count: number): number[] {
71
+ const logits = model.logitsFromHidden(hidden); // [1, L, V] — one matmul
72
+ const V = logits.shape[2]!;
73
+ const out: number[] = [];
74
+ for (let k = 0; k < count; k++) {
75
+ const sl = logits.slice([0, k, 0], [1, k + 1, V]);
76
+ const flat = ops.reshape(sl, [1, V]);
77
+ sl.dispose();
78
+ const am = ops.argmaxAxis(flat, -1);
79
+ flat.dispose();
80
+ out.push(ops.itemUint32(am));
81
+ am.dispose();
82
+ }
83
+ logits.dispose();
84
+ return out;
85
+ }
86
+
65
87
  /** Donor caches: the target's LAST sliding and LAST full cache owners
66
88
  * (port of optiq kv_view.find_donor_layers). */
67
89
  function donorIndices(model: Gemma4Model): { sliding: number; full: number } {
@@ -163,10 +185,9 @@ export function specGenerate(
163
185
  vIds.dispose();
164
186
  stats.targetCalls++;
165
187
 
166
- // 2c. accept the longest matching prefix (per-position lm-head —
167
- // see pickFromHidden)
168
- const gt: number[] = [];
169
- for (let k = 0; k <= gamma; k++) gt.push(pickFromHidden(model, vHidden, k));
188
+ // 2c. accept the longest matching prefix. Batched verify lm-head
189
+ // (optiq-faithful — see picksBatched).
190
+ const gt = picksBatched(model, vHidden, gamma + 1);
170
191
  let kAccept = 0;
171
192
  while (kAccept < gamma && drafts[kAccept] === gt[kAccept]) kAccept++;
172
193
  stats.accepted += kAccept;
@@ -285,7 +285,7 @@
285
285
  <thead><tr><th>Command</th><th style="text-align:left">What it does</th></tr></thead>
286
286
  <tbody>
287
287
  <tr><td><code>mlx-bun pi</code></td><td style="text-align:left">Agent session on a local model — picks/downloads the right model, serves, drops into pi. Flags: <code>--query</code>, <code>--port</code>; everything else passes to pi (<code>-p</code>, <code>--continue</code>, <code>@file</code>…)</td></tr>
288
- <tr><td><code>mlx-bun serve &lt;query&gt;</code></td><td style="text-align:left">Persistent server + this page. <code>--port 8090</code>, <code>--memory-budget GB</code></td></tr>
288
+ <tr><td><code>mlx-bun serve &lt;query&gt;</code></td><td style="text-align:left">Persistent server + this page. <code>--port 8080</code>, <code>--memory-budget GB</code></td></tr>
289
289
  <tr><td><code>mlx-bun get &lt;org/repo&gt;</code></td><td style="text-align:left">Resumable, sha256-verified model download</td></tr>
290
290
  <tr><td><code>mlx-bun ls</code> / <code>fit</code></td><td style="text-align:left">List downloaded models / will-it-fit assessment (<code>--ctx</code>, <code>--skus</code>)</td></tr>
291
291
  <tr><td><code>mlx-bun harness pi</code></td><td style="text-align:left">Register this server as a provider in your own pi install (<code>--remove</code> undoes)</td></tr>
package/src/tokenizer.ts CHANGED
@@ -11,6 +11,10 @@ export interface LoadedTokenizer {
11
11
  * you get a corrupting double-BOS `<s><s>…`). */
12
12
  encode(text: string, addSpecialTokens?: boolean): number[];
13
13
  decode(ids: number[], skipSpecialTokens?: boolean): string;
14
+ /** Token id → raw vocab token string (mlx-lm's convert_ids_to_tokens —
15
+ * the undecoded piece, e.g. "▁Hello", used in logprobs responses).
16
+ * Falls back to decode() for ids outside the base vocab (added tokens). */
17
+ idToToken(id: number): string;
14
18
  readonly bosTokenId: number | null;
15
19
  readonly eosTokenId: number | null;
16
20
  }
@@ -36,6 +40,9 @@ export async function loadTokenizer(modelDir: string): Promise<LoadedTokenizer>
36
40
  decode: (ids, skipSpecialTokens = false) =>
37
41
  // python's decode([]) === ""; the JS lib throws on empty input
38
42
  ids.length === 0 ? "" : tok.decode(ids, { skip_special_tokens: skipSpecialTokens }),
43
+ idToToken: (id) =>
44
+ (tok as unknown as { id_to_token(id: number): string | undefined })
45
+ .id_to_token(id) ?? tok.decode([id], { skip_special_tokens: false }),
39
46
  bosTokenId: idOf("bos_token"),
40
47
  eosTokenId: idOf("eos_token"),
41
48
  };
@@ -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
+ }
@@ -723,12 +723,16 @@ const BWD_SG_SOURCE = String.raw`
723
723
  }
724
724
  `;
725
725
 
726
- // Apple-CCE coeff filter epsilon (phase-2 per-tile skip). DEFAULT OFF ("0") — the
727
- // Apple-production shortcut skips vocab tiles whose whole coeff block < eps, which gave
728
- // the OLD (slow, phase-2-dominated) SG kernel ~3.5×. On the steel kernel — whose phase-2
729
- // is now QuantizedBlockLoader-fast — it is a BAD trade: measured e4b M=512 REAL it cut dh
730
- // accuracy 0.66%→2.7% for only ~7% speedup (the win was already captured by the fast
731
- // dequant). Opt in with MLX_BUN_CCE_BWD_FILTER_EPS=1e-5 only on genuinely peaked data.
726
+ // Apple-CCE coeff filter epsilon (phase-2 per-tile skip). DEFAULT OFF ("0") today —
727
+ // no production path sets the env — but the VERDICT is measured and positive: the old
728
+ // 0.66%→2.7% dh-accuracy scare was on RANDOM targets (flat softmax, the filter's
729
+ // worst case — see PLAN.md "filter-on-real-data"); the real-data gate
730
+ // (scripts/experiments/flash-cce-filter-realdata.ts, commit 3d259ef, 2026-06-22)
731
+ // measured eps 1e-5 = 0.16% dh (under the bf16 floor) for a 1.35× faster backward,
732
+ // and PLAN.md's recorded decision is "enable the filter at eps ~1e-5". (The ~3.5×
733
+ // figure floating around older notes was the retired SG kernel, not steel.) Flipping
734
+ // the default is the open backlog item; until then opt in with
735
+ // MLX_BUN_CCE_BWD_FILTER_EPS=1e-5 on real (peaked) training data.
732
736
  const BWD_FILTER_EPS = process.env.MLX_BUN_CCE_BWD_FILTER_EPS ?? "0";
733
737
  // blockMax vocab-block early-exit eps (skip whole cold (token-block,vocab-block) programs
734
738
  // — attacks BOTH phases). DEFAULT OFF ("0"). It is LOSSLESS when it fires (only skips
@@ -1005,6 +1009,22 @@ function fwdSteelKernel(gs: number, bits: number): MetalKernel {
1005
1009
  return k;
1006
1010
  }
1007
1011
 
1012
+ // One-time dispatch-route warnings. A failed tiling guard silently reroutes the
1013
+ // whole flash head onto the legacy kernels (~5× slower class: e4b M=512 backward
1014
+ // 754 → 3687+ ms) with no observable signal — the exact "forward passes are
1015
+ // flag-gated, verify in code" hazard. Name the failed guard once per route so a
1016
+ // new model port that misses steel is visible in the training log.
1017
+ const _steelFallbackWarned = new Set<string>();
1018
+ function warnSteelFallback(which: "forward" | "backward", detail: string, fallback: string): void {
1019
+ if (_steelFallbackWarned.has(which)) return;
1020
+ _steelFallbackWarned.add(which);
1021
+ console.warn(
1022
+ `[flash-cce] ${which}: steel kernel guard failed (${detail}) — dispatching the ` +
1023
+ `${fallback} instead (~5x-slower class). If this model should be steel-capable, ` +
1024
+ `check the tiling guards in flash-cce.ts.`,
1025
+ );
1026
+ }
1027
+
1008
1028
  export function flashCceForward(
1009
1029
  hResp: MlxArray, head: FlashCceHead, targets: number[],
1010
1030
  ): { logp: MlxArray; lse: MlxArray; blockMax: MlxArray } {
@@ -1039,6 +1059,15 @@ export function flashCceForward(
1039
1059
  const blockV0 = Math.ceil(V / NBLK);
1040
1060
  const useSteel = process.env.MLX_BUN_CCE_NOSTEEL !== "1" && H % 32 === 0 && V % 32 === 0 && blockV0 % 32 === 0;
1041
1061
  const useSimd = !useSteel && USE_SIMDGROUP_FWD && H % SG_BD === 0;
1062
+ if (!useSteel && process.env.MLX_BUN_CCE_NOSTEEL !== "1") {
1063
+ // NOTE blockV0 = ceil(V/NBLK) can violate %32 even when V%32==0 (e.g. V=50304)
1064
+ const fails = [
1065
+ H % 32 !== 0 ? `H%32!=0 (H=${H})` : null,
1066
+ V % 32 !== 0 ? `V%32!=0 (V=${V})` : null,
1067
+ blockV0 % 32 !== 0 ? `blockV0%32!=0 (blockV0=${blockV0}, V=${V}, NBLK=${NBLK})` : null,
1068
+ ].filter(Boolean).join(", ");
1069
+ warnSteelFallback("forward", fails, useSimd ? "simdgroup kernel" : "scalar/lane kernel");
1070
+ }
1042
1071
  const useLane = !useSteel && !useSimd && process.env.MLX_BUN_CCE_LANE === "1" && WPR % 32 === 0;
1043
1072
  const kernel = useSteel ? fwdSteelKernel(GS, head.bits) : useSimd ? fwdSgKernel() : useLane ? fwdLaneKernel() : fwdKernel();
1044
1073
  const tokTile = useSteel ? STEEL_BM : useSimd ? SG_BB : BLOCK_B;
@@ -1103,10 +1132,11 @@ export function flashCceForward(
1103
1132
  * (no atomics). Caller owns the result. */
1104
1133
  export function flashCceBackward(
1105
1134
  hResp: MlxArray, head: FlashCceHead, targets: number[], lse: MlxArray, cot: number[],
1106
- // Apple-CCE coeff filter eps. Default ON (BWD_FILTER_EPS) for the ORPO training
1107
- // path — a pretrained model's softmax is peaked, so skipping near-zero-coeff rows
1108
- // is near-lossless and ~3.5× faster. Pass "0" for exact gradients (the parity gate
1109
- // does this, since synthetic-flat softmax has no negligible rows to skip).
1135
+ // Apple-CCE coeff filter eps. Defaults to BWD_FILTER_EPS, which is OFF ("0")
1136
+ // unless MLX_BUN_CCE_BWD_FILTER_EPS is set — exact gradients by default. The
1137
+ // measured real-data case FOR enabling it (eps 1e-5 = 0.16% dh, 1.35×; PLAN.md
1138
+ // "filter-on-real-data") is a pending default flip — see BWD_FILTER_EPS's note.
1139
+ // Pass "0" explicitly for guaranteed-exact (the parity gate does).
1110
1140
  filterEps: string = BWD_FILTER_EPS,
1111
1141
  // The forward's `blockMax [M, NBLK]` enables the vocab-block early exit (skips
1112
1142
  // whole cold programs — attacks phase 1). Omit (or set blockEps "0") for exact.
@@ -1119,7 +1149,10 @@ export function flashCceBackward(
1119
1149
  const GS = head.groupSize;
1120
1150
  const WPR = head.w.shape[1]!;
1121
1151
  if (H > H_MAX) throw new Error(`flashCceBackward: hidden ${H} > H_MAX ${H_MAX}`);
1122
- if (H % TG !== 0) throw new Error(`flashCceBackward: hidden ${H} not divisible by TG ${TG}`);
1152
+ // NOTE: H % TG is a LEGACY-SCALAR-kernel requirement only (it owns DPER = H/TG
1153
+ // dims per thread — floor division, so a remainder would leave dims silently
1154
+ // zero). The steel/simdgroup kernels have their own (weaker) guards below; the
1155
+ // throw is applied only when dispatch actually lands on the scalar kernel.
1123
1156
 
1124
1157
  const hF = hResp.dtype === Dtype.float32 ? hResp : hResp.astype(Dtype.float32);
1125
1158
  const scF = head.scales.dtype === Dtype.float32 ? head.scales : head.scales.astype(Dtype.float32);
@@ -1133,10 +1166,12 @@ export function flashCceBackward(
1133
1166
 
1134
1167
  // STEEL is the DEFAULT (verbatim MLX BlockMMA GEMM for both phases, [M,V]-free) when
1135
1168
  // the tiling is clean (H,V,blockV all %32 AND H % HTw for the H-tiled accumulator).
1136
- // MLX_BUN_CCE_BWD_NOSTEEL=1 falls back to the old kernels. The steel backward now
1137
- // carries the SAME Apple-CCE skips as the SG kernel — coeff filter (phase-2 per-tile)
1138
- // + blockMax vocab-block early exit (whole-program) — both compiled out at eps=0
1139
- // (exact, the parity gate) and default-on for training (peaked softmax → ~all vocab cold).
1169
+ // MLX_BUN_CCE_BWD_NOSTEEL=1 falls back to the old kernels. The steel backward carries
1170
+ // the SAME Apple-CCE skips as the SG kernel — coeff filter (phase-2 per-tile) +
1171
+ // blockMax vocab-block early exit (whole-program) — both compiled out at eps=0.
1172
+ // BOTH skips are OFF by default (the envs default "0", nothing in production sets
1173
+ // them): exact gradients unless explicitly opted in. Enabling the coeff filter at
1174
+ // ~1e-5 is the measured-and-decided pending flip (PLAN.md "filter-on-real-data").
1140
1175
  const blockV0 = Math.ceil(V / NBLK);
1141
1176
  // HTw == group_size for the phase-2 QuantizedBlockLoader (one group per H-tile); needs
1142
1177
  // H % GS == 0 (always, GR*GS=H) and GS % 32 == 0 (TNv = GS/32 frags per H-tile integral).
@@ -1144,6 +1179,26 @@ export function flashCceBackward(
1144
1179
  // simdgroup_matrix phase-2 GEMM is the next tier (5512→3913 ms, 1.41×) when the H
1145
1180
  // tiling is clean; MLX_BUN_CCE_BWD_SCALAR=1 forces the float4 scalar kernel.
1146
1181
  const useSg = !useSteel && process.env.MLX_BUN_CCE_BWD_SCALAR !== "1" && H % (SGN * 8) === 0 && H % SG_BD === 0;
1182
+ if (!useSteel && process.env.MLX_BUN_CCE_BWD_NOSTEEL !== "1") {
1183
+ // NOTE blockV0 = ceil(V/NBLK) can violate %32 even when V%32==0 (e.g. V=50304)
1184
+ const fails = [
1185
+ H % 32 !== 0 ? `H%32!=0 (H=${H})` : null,
1186
+ V % 32 !== 0 ? `V%32!=0 (V=${V})` : null,
1187
+ blockV0 % 32 !== 0 ? `blockV0%32!=0 (blockV0=${blockV0}, V=${V}, NBLK=${NBLK})` : null,
1188
+ H % GS !== 0 ? `H%GS!=0 (H=${H}, GS=${GS})` : null,
1189
+ GS % 32 !== 0 ? `GS%32!=0 (GS=${GS})` : null,
1190
+ ].filter(Boolean).join(", ");
1191
+ warnSteelFallback("backward", fails, useSg ? "simdgroup kernel" : "scalar kernel");
1192
+ }
1193
+ // Legacy scalar kernel: each thread owns DPER = H/TG dims (floor) — a remainder
1194
+ // would leave the last H%TG dims silently zero, so it hard-requires H % TG == 0.
1195
+ // Only enforced when dispatch actually lands there (steel/SG have weaker guards).
1196
+ if (!useSteel && !useSg && H % TG !== 0)
1197
+ throw new Error(
1198
+ `flashCceBackward: hidden ${H} not divisible by TG ${TG} — required by the legacy ` +
1199
+ `scalar kernel, which is the selected route because the steel/simdgroup tiling ` +
1200
+ `guards also failed for this shape (see the [flash-cce] warning above).`,
1201
+ );
1147
1202
  // Block-skip is on (either kernel) only when blockMax is supplied AND blockEps>0 (the
1148
1203
  // kernel reads it as an extra input). The coeff filter needs no extra input (just eps).
1149
1204
  const useBlockSkip = (useSteel || useSg) && blockMax != null && Number.parseFloat(blockEps) > 0;