mlx-bun 0.0.4 → 0.0.6

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 (90) hide show
  1. package/README.md +175 -93
  2. package/docs/dag/training-inference-map.html +575 -0
  3. package/docs/design/adapters-end-to-end.md +50 -46
  4. package/docs/design/compat-cli-surface-design.md +10 -8
  5. package/docs/design/docs-reorg-plan.md +14 -1
  6. package/docs/design/hlg-sampling.md +12 -2
  7. package/docs/design/memory-system.md +193 -295
  8. package/docs/design/optimization_plan.md +4 -2
  9. package/docs/design/orpo-dynamic-lambda.md +179 -0
  10. package/docs/design/orpo-future-enhancements.md +340 -0
  11. package/docs/design/orpo-training.md +1212 -0
  12. package/docs/design/parallel-slots.md +34 -26
  13. package/docs/design/parity-tier-dag.md +143 -0
  14. package/docs/design/runtime-isolation.md +1 -1
  15. package/docs/design/segmented-backward-training.md +58 -39
  16. package/docs/design/tauri-desktop-app.md +127 -0
  17. package/docs/design/w4a16-compute-precision-spike.md +232 -0
  18. package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
  19. package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
  20. package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
  21. package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
  22. package/docs/investigations/starter-model-port-handoff.md +32 -13
  23. package/docs/investigations/steel-flash-cce-handoff.md +489 -0
  24. package/docs/investigations/trainer-validation-experiment.md +173 -0
  25. package/docs/planning/IDEAS.md +29 -0
  26. package/docs/planning/PRODUCT_ROADMAP.md +147 -27
  27. package/docs/planning/chunk-task-roadmap.md +1 -1
  28. package/docs/planning/journal.md +161 -0
  29. package/docs/reference/distribution.md +61 -5
  30. package/docs/reference/library-api.md +29 -14
  31. package/docs/reference/memory.md +77 -0
  32. package/docs/reference/orpo-quickstart.md +130 -0
  33. package/docs/reference/server-api.md +105 -7
  34. package/docs/reference/server-config.md +12 -5
  35. package/docs/reference/training.md +296 -0
  36. package/package.json +1 -1
  37. package/src/cli.ts +651 -18
  38. package/src/eval/ifeval.ts +138 -0
  39. package/src/eval/runner.ts +76 -6
  40. package/src/eval/tasks/bfcl.ts +49 -15
  41. package/src/eval/tasks/gsm8k.ts +21 -9
  42. package/src/eval/tasks/hashhop.ts +33 -4
  43. package/src/eval/tasks/humaneval.ts +30 -8
  44. package/src/eval/tasks/ifeval.ts +52 -18
  45. package/src/eval/tasks/mmlu.ts +85 -74
  46. package/src/eval/ultrafeedback.ts +60 -0
  47. package/src/jobs/runner.ts +6 -0
  48. package/src/lora.ts +12 -7
  49. package/src/memory/chunk.ts +25 -0
  50. package/src/memory/cluster.ts +24 -0
  51. package/src/memory/db.ts +114 -0
  52. package/src/memory/ingest.ts +22 -0
  53. package/src/memory/pipeline.ts +63 -0
  54. package/src/memory/prompts.ts +23 -0
  55. package/src/memory/schedule.ts +163 -0
  56. package/src/memory/synthesize.ts +29 -0
  57. package/src/memory/tools.ts +399 -0
  58. package/src/memory/vault.ts +694 -0
  59. package/src/memory/wikify.ts +23 -0
  60. package/src/mlx/metal-kernel.ts +31 -4
  61. package/src/model/flash-attention.ts +37 -19
  62. package/src/model/fused-geglu-kernel.ts +111 -0
  63. package/src/model/gemma4-base.ts +39 -1
  64. package/src/model/gemma4.ts +196 -21
  65. package/src/model/minicpm5.ts +44 -3
  66. package/src/pi-provider.ts +29 -13
  67. package/src/pi-session.ts +81 -0
  68. package/src/pi-terminal.ts +13 -24
  69. package/src/pi-web.ts +246 -166
  70. package/src/quantize/job.ts +17 -2
  71. package/src/quantize/quantizer.ts +26 -2
  72. package/src/server.ts +216 -47
  73. package/src/tokenizer.ts +6 -2
  74. package/src/train/dataset.ts +29 -6
  75. package/src/train/flash-cce.ts +1175 -0
  76. package/src/train/job.ts +64 -3
  77. package/src/train/lora-params.ts +53 -4
  78. package/src/train/loss.ts +947 -0
  79. package/src/train/optimizer.ts +10 -3
  80. package/src/train/prefix-shared.ts +435 -0
  81. package/src/train/rank.ts +36 -0
  82. package/src/train/segmented.ts +1148 -3
  83. package/src/train/steel-qmm-header.ts +1461 -0
  84. package/src/train/trainer.ts +570 -54
  85. package/src/train/watch.ts +542 -0
  86. package/src/web/app.html +285 -19
  87. package/src/web/skills/memory/SKILL.md +97 -0
  88. package/src/web/skills/web-research/SKILL.md +3 -1
  89. package/src/web/skills.ts +46 -8
  90. /package/{docs → src/assets}/curve-designer.html +0 -0
@@ -8,7 +8,7 @@
8
8
  // back into the LoraWeights so the next forward (and the next closure swap)
9
9
  // sees it. Base quantized weights are never in argnums — they stay frozen.
10
10
 
11
- import { MlxArray } from "../mlx/array";
11
+ import { MlxArray, pinnedBufferCount } from "../mlx/array";
12
12
  import { clearCache, peakMemory, activeMemory, cacheMemory, resetPeakMemory } from "../mlx/ffi";
13
13
 
14
14
  const MEM_LOG = process.env.MLX_BUN_MEM_LOG === "1";
@@ -20,7 +20,8 @@ import type { RuntimeModel } from "../model/factory";
20
20
  import { Gemma4Model, type GradCheckpointCtx } from "../model/gemma4";
21
21
  import { MiniCPM5Model } from "../model/minicpm5";
22
22
  import { setTrainingAttn } from "../model/flash-attention";
23
- import { SegmentedBackward, SegmentedBackwardGemma4, planSegmentsBySize } from "./segmented";
23
+ import { setFusedGeluTraining } from "../model/fused-geglu-kernel";
24
+ import { SegmentedBackward, SegmentedBackwardGemma4, SegmentedBackwardOrpo, SegmentedBackwardOrpoGemma4, SegmentedBackwardOrpoPrefix, SegmentedBackwardOrpoPrefixGemma4, planSegmentsBySize } from "./segmented";
24
25
  import type { LoadedTokenizer } from "../tokenizer";
25
26
  import type { ChatTemplate } from "../chat-template";
26
27
  import type { Emit } from "../jobs/types";
@@ -30,15 +31,16 @@ import {
30
31
  } from "./dataset";
31
32
  import {
32
33
  buildTrainableLora, attachForTraining, detachTraining, flatParams,
33
- saveAdapter, disposeLora, type TrainableLora, type SaveAdapterConfig,
34
+ saveAdapter, disposeLora, warmStartFromAdapter, type TrainableLora, type SaveAdapterConfig,
34
35
  } from "./lora-params";
35
- import { resolveRanks, DEFAULT_TARGET_MODULES, type RankScaling } from "./rank";
36
- import { sftLoss, dpoLoss, dpoRefLogps, dpoMetrics } from "./loss";
36
+ import { resolveRanks, bitsMapFromModel, readPerLayerKl, DEFAULT_TARGET_MODULES, type RankScaling } from "./rank";
37
+ import { sftLoss, dpoLoss, dpoRefLogps, dpoMetrics, orpoLoss, orpoMetrics, type ChunkCtx } from "./loss";
38
+ import { orpoLossPrefixShared, orpoLossPrefixSharedGemma, splitPrefixBatch } from "./prefix-shared";
37
39
  import { AdamW, warmupCosineSchedule } from "./optimizer";
38
- import { writeFileSync } from "node:fs";
40
+ import { writeFileSync, appendFileSync, mkdirSync } from "node:fs";
39
41
 
40
42
  export interface TrainConfig {
41
- method: "sft" | "dpo";
43
+ method: "sft" | "dpo" | "orpo";
42
44
  rank: number;
43
45
  scale: number;
44
46
  rankScaling: RankScaling;
@@ -54,10 +56,27 @@ export interface TrainConfig {
54
56
  stepsPerEval: number;
55
57
  betas: [number, number];
56
58
  weightDecay: number;
59
+ /** LoRA-input dropout (PEFT-style), 0 = off. Training-only regularizer for
60
+ * small preference sets. Recompute-safe: the mask is keyed by (step, layer)
61
+ * so segmented/checkpointed recompute reproduces it. */
62
+ loraDropout: number;
63
+ /** rsLoRA: scale the LoRA update by α/√rank instead of α, so per-layer rank
64
+ * scaling (by_bits/by_kl) changes capacity, not step size. Saved in the
65
+ * adapter config so inference applies the same per-layer scale. */
66
+ rsLora: boolean;
67
+ /** LoRA+: LR multiplier for the B leaves (A stays at the base LR). >1 speeds
68
+ * the B-driven early learning (B is zero-init). 1 = off. */
69
+ loraPlusRatio: number;
57
70
  /** Gradient checkpointing: recompute layer activations in the backward pass
58
71
  * to bound memory at long context (mlx-lm grad_checkpoint; default in optiq).
59
72
  * Numerically identical to off — pure memory↔compute trade. */
60
73
  gradCheckpoint: boolean;
74
+ /** Intra-layer MLP split (lever 4): with gradCheckpoint on (Gemma4 only),
75
+ * checkpoint the attention and MLP sub-blocks separately with the post-attn
76
+ * residual as the boundary, so the backward recompute holds max(attn,MLP)+hMid
77
+ * instead of the whole layer. Numerically identical to gradCheckpoint alone —
78
+ * a finer memory↔compute split. No effect unless gradCheckpoint is on. */
79
+ mlpSplit: boolean;
61
80
  /** Segmented backward: layers per segment (0 = off). Streams the SFT backward
62
81
  * segment-by-segment so only one segment's activations are live at a time —
63
82
  * beats per-layer checkpointing at long context, where naive checkpointing
@@ -71,10 +90,55 @@ export interface TrainConfig {
71
90
  * turns it on so the best-ON-TASK checkpoint can be chosen by the real eval
72
91
  * afterward (val is only a proxy — we keep them all and let the eval pick). */
73
92
  saveCheckpoints: boolean;
93
+ /** Gradient clipping: scale grads so their global L2 norm ≤ this before the
94
+ * optimizer step (0 = off). Standard ORPO/DPO stability guard — without it a
95
+ * single high-norm batch makes AdamW take a huge step and the loss diverges. */
96
+ gradClipNorm: number;
97
+ /** Cap validation examples evaluated per eval step (0 = all). Val accuracy is
98
+ * correct/total over a FIXED subset — bounds the denominator AND the per-eval
99
+ * cost (full val over thousands of pairs is minutes each). */
100
+ valMaxExamples: number;
74
101
  // DPO
75
102
  dpoBeta: number;
76
103
  dpoWarmupIters: number;
77
104
  dpoLrSchedule: "constant" | "cosine";
105
+ // ORPO (reference-free; method-prefixed knobs, mirroring the dpo trio).
106
+ /** λ — weights ONLY the odds-ratio term (the SFT-NLL term stays unweighted). */
107
+ orpoLambda: number;
108
+ orpoWarmupIters: number;
109
+ orpoLrSchedule: "constant" | "cosine";
110
+ /** Token-chunk size for the ORPO head (0 = off). >0 computes the B=1
111
+ * response-only LM head in token-chunks, each rematerialized in the backward,
112
+ * bounding the dominant `[M, vocab]` logits term to `[chunkSize, vocab]`.
113
+ * Exact (numerically identical). Non-segmented path only (segmentSize takes
114
+ * precedence). See docs/design/orpo-training.md → chunked fused log-prob. */
115
+ orpoChunkSize: number;
116
+ /** Use the FUSED linear cross-entropy head (Liger/CCE-style): one CustomVjp with
117
+ * an analytic softmax−onehot backward — no autograd through the head, no
118
+ * retained `[M,vocab]` logits in either direction. Composes with
119
+ * `orpoChunkSize` as the token-chunk size (defaults to 512 when unset). B=1,
120
+ * non-segmented path only; Gemma + MiniCPM5 heads. Value bit-exact vs the
121
+ * full-logits head, grads in the bf16 class. See docs/design/orpo-training.md
122
+ * → fused linear-CE head. */
123
+ orpoFusedCe: boolean;
124
+ /** Route the fused linear-CE head through the flash-CCE Metal kernel: in-kernel
125
+ * quantized logits + online softmax (fwd) and dh accumulation (bwd) — neither
126
+ * `[M,vocab]` nor a dequantized head touches HBM. Lowest memory + fastest on
127
+ * large vocab (e4b 262k / CPM 130k). Implies the fused head; the Apple-CCE coeff
128
+ * filter is on by default in the backward. B=1, non-segmented path. */
129
+ orpoFlashCe: boolean;
130
+ /** Shared prompt-prefix ORPO (lever 7): one forward over [prompt; chosen; rejected]
131
+ * with a block-sparse mask + block-wise RoPE, so the shared prompt is encoded ONCE
132
+ * (token cost 2(P+R) → P+2R). B=1, non-segmented path; MiniCPM5 + Gemma4 (e4b).
133
+ * Falls back to the two-forward orpoLoss for rows whose chosen/rejected prompts
134
+ * differ. Composes with orpoFlashCe/orpoFusedCe (each branch routes through the
135
+ * [M,V]-free head). See src/train/prefix-shared.ts. */
136
+ orpoPrefixShared: boolean;
137
+ /** Warm-start: path to an existing adapter dir (with adapters.safetensors) whose
138
+ * LoRA weights initialize this run instead of the random/zero init. Continues
139
+ * training from a checkpoint's weights; the optimizer state and LR schedule restart.
140
+ * Rank/targets must match the checkpoint. "" = off (fresh init). */
141
+ warmStartAdapter: string;
78
142
  // output
79
143
  adapterPath: string;
80
144
  baseModel: string;
@@ -103,13 +167,28 @@ export const DEFAULT_TRAIN_CONFIG: TrainConfig = {
103
167
  stepsPerEval: 50,
104
168
  betas: [0.9, 0.999],
105
169
  weightDecay: 0.01,
170
+ loraDropout: 0.0, // off by default
171
+ rsLora: false, // off by default (opt-in; recommended when rank_scaling != constant)
172
+ loraPlusRatio: 1.0, // 1 = off
173
+ mlpSplit: false, // default off; only effective with gradCheckpoint (Gemma4)
106
174
  gradCheckpoint: false, // default off until validated; flip to true (optiq default) after
107
175
  segmentSize: 0, // default off; >0 enables segmented backward (MiniCPM5 SFT B=1)
108
176
  saveCheckpoints: false, // off by default; scripts opt in (keep-all-checkpoints + metrics.json)
177
+ gradClipNorm: 1.0, // on by default (standard ORPO/DPO guard); 0 = off
178
+ valMaxExamples: 256, // fixed val subset → fast, meaningful correct/total
109
179
 
110
180
  dpoBeta: 0.1,
111
181
  dpoWarmupIters: 0,
112
182
  dpoLrSchedule: "cosine",
183
+
184
+ orpoLambda: 0.1, // paper's primary value + TRL default; weights only L_OR
185
+ orpoWarmupIters: 0,
186
+ orpoLrSchedule: "cosine",
187
+ orpoChunkSize: 0, // default off; >0 = token-chunked head (bounds the [M,vocab] term)
188
+ orpoFusedCe: false, // default off; true = fused linear-CE head (analytic backward, no [M,vocab])
189
+ orpoFlashCe: false, // default off; true = flash-CCE Metal-kernel head (implies fused)
190
+ orpoPrefixShared: false, // default off; true = shared prompt-prefix single forward (B=1)
191
+ warmStartAdapter: "", // default off; path to an adapter dir to warm-start LoRA weights from
113
192
  adapterPath: "adapters",
114
193
  baseModel: "",
115
194
  };
@@ -127,16 +206,51 @@ export async function trainLora(
127
206
  ): Promise<TrainResult> {
128
207
  emit({ type: "stage", stage: "setup", progress: 0.02, message: "resolving ranks" });
129
208
 
209
+ // Mixed-precision rank scaling: feed the per-layer bits (from the loaded
210
+ // model's quant specs) and, for by_kl, the recorded KL sensitivities
211
+ // (optiq_metadata.json). Without these maps, by_bits/by_kl silently fell back
212
+ // to uniform rank — so the "give the sensitive (8-bit) layers more adapter
213
+ // capacity" policy (optiq: one sensitivity signal, two optimizations — the
214
+ // bit assignment AND the LoRA rank) never actually fired.
215
+ const bitsMap = bitsMapFromModel(model);
216
+ const klMap = cfg.rankScaling === "by_kl" ? await readPerLayerKl(cfg.baseModel) : undefined;
130
217
  const ranks = resolveRanks(model, {
131
218
  rank: cfg.rank,
132
219
  rankScaling: cfg.rankScaling,
133
220
  targetModules: cfg.targetModules,
134
221
  numLayers: cfg.numLayers,
222
+ bitsMap,
223
+ klMap,
135
224
  });
136
225
  const appliedRanks: Record<string, number> = Object.fromEntries(ranks);
137
226
 
138
- const lora = buildTrainableLora(model, ranks, cfg.scale, cfg.seed);
227
+ // Surface the rank spread so mixed-precision scaling is observable (e.g.
228
+ // "r8×140 r16×28" means 28 sensitive layers got the wider adapter).
229
+ const rankCounts = new Map<number, number>();
230
+ for (const r of ranks.values()) rankCounts.set(r, (rankCounts.get(r) ?? 0) + 1);
231
+ const rankSpread = [...rankCounts.entries()]
232
+ .sort((a, b) => a[0] - b[0])
233
+ .map(([r, n]) => `r${r}×${n}`)
234
+ .join(" ");
235
+ emit({ type: "stage", stage: "setup", progress: 0.03,
236
+ message: `ranks (${cfg.rankScaling}): ${rankSpread}` });
237
+
238
+ const lora = buildTrainableLora(model, ranks, cfg.scale, cfg.seed, cfg.rsLora);
239
+ // Warm-start: continue from a saved adapter/checkpoint's weights (optimizer + LR
240
+ // schedule restart fresh). Must run before the optimizer is built (below) so it
241
+ // tracks the loaded leaves. Rank/targets must match the checkpoint.
242
+ if (cfg.warmStartAdapter) {
243
+ const n = warmStartFromAdapter(lora, cfg.warmStartAdapter);
244
+ emit({ type: "stage", stage: "setup", progress: 0.035,
245
+ message: `warm-start: loaded ${n} LoRA targets from ${cfg.warmStartAdapter} (optimizer + LR schedule restart)` });
246
+ }
139
247
  attachForTraining(model, lora, "train");
248
+ model.loraState.dropoutRate = cfg.loraDropout; // per-step seed set inside the loop
249
+ // Training-mode fused GeGLU (Gemma e4b): kernel forward + hand-derived vjp, so
250
+ // autograd flows and the backward recomputes the gelu from the primal instead
251
+ // of retaining the spelled-out intermediates. No-op for non-Gemma models.
252
+ // Cleared in the finally below.
253
+ setFusedGeluTraining(true);
140
254
 
141
255
  const saveCfg: SaveAdapterConfig = {
142
256
  rank: cfg.rank,
@@ -146,6 +260,7 @@ export async function trainLora(
146
260
  numLayers: cfg.numLayers,
147
261
  method: cfg.method,
148
262
  baseModel: cfg.baseModel,
263
+ rsLora: cfg.rsLora,
149
264
  };
150
265
 
151
266
  // Keep-ALL-checkpoints + a durable metrics record. At each eval step we
@@ -158,9 +273,36 @@ export async function trainLora(
158
273
  // metrics, and the live LoRA leaves are the current step's weights at emit
159
274
  // time, so saving here captures exactly that checkpoint.
160
275
  const trainHistory: { step: number; loss: number }[] = [];
161
- const valHistory: { step: number; loss: number; checkpoint: string | null }[] = [];
276
+ // For preference methods (dpo/orpo) the val MARGIN (mean chosen−rejected
277
+ // log-odds) and accuracy are the meaningful early-stop signals — val loss is a
278
+ // weak proxy. Capture them when the loop emits them so metrics.json lets you
279
+ // pick the best-MARGIN checkpoint (overfit shows as train↑ / val-margin flat).
280
+ const valHistory: { step: number; loss: number; margin?: number; accuracy?: number; checkpoint: string | null }[] = [];
162
281
  const startedAt = Date.now();
282
+
283
+ // Live, append-only metrics stream (the source `mlx-bun train-watch` tails).
284
+ // Always on — it's the durable per-step record, a few MB at most, single
285
+ // writer, append-only. The first line is a `meta` record (totals + config) so
286
+ // a viewer attaching mid-run has the denominators (iters) and run identity
287
+ // without replaying. Each subsequent line is a metric event + a wall-clock `t`
288
+ // (ms) so the viewer derives s/step and ETA itself. Best-effort: a logging
289
+ // failure must never kill a run.
290
+ mkdirSync(cfg.adapterPath, { recursive: true });
291
+ const metricsJsonl = `${cfg.adapterPath}/metrics.jsonl`;
292
+ const writeMetricLine = (o: Record<string, unknown>) => {
293
+ try { appendFileSync(metricsJsonl, JSON.stringify(o) + "\n"); } catch { /* never throw */ }
294
+ };
295
+ try { writeFileSync(metricsJsonl, ""); } catch { /* truncate prior run's stream */ }
296
+ writeMetricLine({
297
+ type: "meta", t: startedAt, method: cfg.method, model: cfg.baseModel,
298
+ iters: cfg.iters, learning_rate: cfg.learningRate, max_seq_length: cfg.maxSeqLen,
299
+ batch_size: cfg.batchSize, rank: cfg.rank, scale: cfg.scale,
300
+ orpo_lambda: cfg.method === "orpo" ? cfg.orpoLambda : undefined,
301
+ adapter_path: cfg.adapterPath,
302
+ });
303
+
163
304
  const collect: Emit = (e) => {
305
+ if (e.type === "metric") writeMetricLine({ ...e, t: Date.now() });
164
306
  if (e.type === "metric" && e.kind === "train") {
165
307
  trainHistory.push({ step: e.step, loss: e.loss });
166
308
  } else if (e.type === "metric" && e.kind === "val") {
@@ -172,7 +314,8 @@ export async function trainLora(
172
314
  emit({ type: "stage", stage: "checkpoint", progress: e.step / cfg.iters,
173
315
  message: `checkpoint ${tag}` });
174
316
  }
175
- valHistory.push({ step: e.step, loss: e.loss, checkpoint });
317
+ const ev = e as { step: number; loss: number; margin?: number; accuracy?: number };
318
+ valHistory.push({ step: ev.step, loss: ev.loss, margin: ev.margin, accuracy: ev.accuracy, checkpoint });
176
319
  }
177
320
  emit(e);
178
321
  };
@@ -181,7 +324,9 @@ export async function trainLora(
181
324
  const result =
182
325
  cfg.method === "dpo"
183
326
  ? await dpoLoop(model, tok, tmpl, dataDir, cfg, lora, collect)
184
- : await sftLoop(model, tok, tmpl, dataDir, cfg, lora, collect);
327
+ : cfg.method === "orpo"
328
+ ? await orpoLoop(model, tok, tmpl, dataDir, cfg, lora, collect)
329
+ : await sftLoop(model, tok, tmpl, dataDir, cfg, lora, collect);
185
330
 
186
331
  // Final save (last adapter).
187
332
  detachTraining(model, lora);
@@ -193,18 +338,24 @@ export async function trainLora(
193
338
  if (cfg.saveCheckpoints) {
194
339
  let bestVal: { step: number; loss: number; checkpoint: string | null } | null = null;
195
340
  for (const v of valHistory) if (bestVal === null || v.loss < bestVal.loss) bestVal = v;
341
+ // Best by MARGIN too (preference methods) — the better checkpoint signal.
342
+ let bestMargin: typeof valHistory[number] | null = null;
343
+ for (const v of valHistory)
344
+ if (v.margin !== undefined && (bestMargin === null || v.margin > (bestMargin.margin ?? -Infinity))) bestMargin = v;
196
345
  writeFileSync(`${cfg.adapterPath}/metrics.json`, JSON.stringify({
197
346
  config: {
198
347
  method: cfg.method, rank: cfg.rank, scale: cfg.scale,
199
348
  learningRate: cfg.learningRate, maxSeqLen: cfg.maxSeqLen,
200
349
  segmentSize: cfg.segmentSize, iters: cfg.iters,
201
350
  numLayers: cfg.numLayers, baseModel: cfg.baseModel,
351
+ orpoLambda: cfg.method === "orpo" ? cfg.orpoLambda : undefined,
202
352
  },
203
353
  wallSeconds: (Date.now() - startedAt) / 1000,
204
354
  peakGb: peakMemory() / 1e9,
205
355
  finalTrainLoss: trainHistory.at(-1)?.loss ?? null,
206
356
  finalValLoss: valHistory.at(-1)?.loss ?? null,
207
357
  bestVal,
358
+ bestMargin, // null for SFT (no margin); the recommended pick for dpo/orpo
208
359
  valTrajectory: valHistory,
209
360
  trainLosses: trainHistory,
210
361
  }, null, 2));
@@ -220,6 +371,7 @@ export async function trainLora(
220
371
  });
221
372
  return { adapterPath: cfg.adapterPath, appliedRanks, numIters: result.numIters };
222
373
  } finally {
374
+ setFusedGeluTraining(false);
223
375
  disposeLora(lora);
224
376
  }
225
377
  }
@@ -290,22 +442,29 @@ async function sftLoop(
290
442
  // Checkpoints are created per forward and disposed after each step's backward.
291
443
  let ckptCtx: GradCheckpointCtx | null = null;
292
444
  if (!useSegmented && cfg.gradCheckpoint && model instanceof Gemma4Model) {
445
+ // Partition each layer's LoRA into the attention sub-block (self_attn.*) and
446
+ // the MLP sub-block (everything else in the layer: gate/up/down_proj +
447
+ // per_layer_*) so the split-MLP checkpoint can wrap them independently.
293
448
  const byLayer: GradCheckpointCtx["byLayer"] = new Map();
294
449
  for (const t of lora.targets) {
295
450
  const m = t.modulePath.match(/\.layers\.(\d+)\./);
296
451
  if (!m) continue;
297
452
  const li = Number(m[1]);
298
- (byLayer.get(li) ?? byLayer.set(li, []).get(li)!).push(t.lw);
453
+ const ll = byLayer.get(li) ?? byLayer.set(li, { attn: [], mlp: [] }).get(li)!;
454
+ (t.modulePath.includes(".self_attn.") ? ll.attn : ll.mlp).push(t.lw);
299
455
  }
300
- ckptCtx = { byLayer, keepAlive: [] };
456
+ ckptCtx = { byLayer, splitMlp: cfg.mlpSplit, keepAlive: [] };
301
457
  model.gradCkpt = ckptCtx;
458
+ if (cfg.mlpSplit) emit({ type: "stage", stage: "setup", progress: 0.04,
459
+ message: "grad checkpoint: intra-layer attn/MLP split" });
302
460
  }
303
461
 
304
- // Training attention. Default = mlx fused SDPA (ops.sdpa): verified correct
305
- // dQ/dK/dV vs a hand-rolled reference (scripts/sdpa-vs-manual.ts: 0.00%) and
306
- // fast (one fused kernel). flash is opt-in via MLX_BUN_TRAIN_ATTN=flash for an
307
- // O(L) memory backward at very long context, BUT its dK kernel currently has
308
- // a bug (~100% off) and is ~30x slower here — so it is NOT the default.
462
+ // Training attention. Default = mlx fused SDPA (ops.sdpa): exact dQ/dK/dV
463
+ // (finite-difference verified) and fast, but O(L²) backward memory. The
464
+ // hand-rolled flash kernel is an opt-in (MLX_BUN_TRAIN_ATTN=flash) O(L)-memory
465
+ // path for memory-bound long context — now correct (its dK-transpose and
466
+ // dQ-causal-barrier bugs were fixed; FD-validated in
467
+ // scripts/experiments/flash-fd-check.ts), though ~30× slower than ops.sdpa.
309
468
  // Cleared in finally.
310
469
  if (process.env.MLX_BUN_TRAIN_ATTN === "flash") setTrainingAttn("flash");
311
470
 
@@ -318,24 +477,30 @@ async function sftLoop(
318
477
 
319
478
  try {
320
479
  for (let step = 1; step <= cfg.iters; step++) {
321
- currentBatch = batches.next().value as SftBatch;
322
- const tokensThisStep = countResponseTokens(currentBatch);
323
-
324
480
  if (MEM_LOG) resetPeakMemory();
325
- const { value, grads } = segmented
326
- ? segmented.step(currentBatch!)
327
- : vag!.apply(flatParams(lora));
328
- // Evaluate the loss AND grads in ONE mlx_eval so the backward shares the
329
- // forward pass. Evaluating `value` on its own first (e.g. via toFloat32)
330
- // materializes then frees the forward, forcing the backward to recompute
331
- // and hold ALL layer activations — which silently defeats gradient
332
- // checkpointing (verified: separate-eval ~0% memory savings, combined-eval
333
- // restores it). Keep this single combined eval before reading either.
334
- evalAll([value, ...grads]);
335
- const lossVal = value.toFloat32()[0]!;
336
- value.dispose();
481
+
482
+ // One optimizer step over cfg.gradAccumSteps micro-batches (pass-through
483
+ // when 1). afterMicroEval frees each micro-batch's gradient-checkpoint
484
+ // closures right after its backward so they don't pile up across
485
+ // accumulation.
486
+ let tokensThisStep = 0;
487
+ const { loss: lossVal, grads } = accumulateStep(
488
+ cfg.gradAccumSteps,
489
+ () => {
490
+ currentBatch = batches.next().value as SftBatch;
491
+ tokensThisStep += countResponseTokens(currentBatch);
492
+ return segmented ? segmented.step(currentBatch!) : vag!.apply(flatParams(lora));
493
+ },
494
+ ckptCtx
495
+ ? () => {
496
+ for (const ck of ckptCtx!.keepAlive) ck.dispose();
497
+ ckptCtx!.keepAlive.length = 0;
498
+ }
499
+ : undefined,
500
+ );
337
501
 
338
502
  const gradNorm = globalNorm(grads); // grads already materialized above
503
+ clipGradsByNorm(grads, gradNorm, cfg.gradClipNorm);
339
504
  opt.step(grads);
340
505
  opt.evalState();
341
506
  if (MEM_LOG) {
@@ -345,12 +510,6 @@ async function sftLoop(
345
510
  } else {
346
511
  clearCache();
347
512
  }
348
- if (ckptCtx) {
349
- // Grads are materialized (globalNorm/evalState), so the recompute is
350
- // done — free this step's checkpoint closures before the next forward.
351
- for (const ck of ckptCtx.keepAlive) ck.dispose();
352
- ckptCtx.keepAlive.length = 0;
353
- }
354
513
 
355
514
  if (step % cfg.stepsPerReport === 0 || step === 1) {
356
515
  const elapsed = (Date.now() - t0) / 1000;
@@ -467,28 +626,36 @@ async function dpoLoop(
467
626
 
468
627
  try {
469
628
  for (let step = 1; step <= cfg.iters; step++) {
470
- currentBatch = batches.next().value as DpoBatch;
471
629
  if (schedule) opt.lr = schedule(step);
472
630
 
473
- const refs = dpoRefLogps(model, lora, currentBatch);
474
- refChosen = refs.refChosen;
475
- refRejected = refs.refRejected;
476
-
477
- const { value, grads } = vag.apply(flatParams(lora));
478
- const lossVal = value.toFloat32()[0]!;
479
- value.dispose();
631
+ // One optimizer step over cfg.gradAccumSteps micro-batches (pass-through
632
+ // when 1). Reference log-probs are recomputed per micro-batch OUTSIDE the
633
+ // policy value_and_grad and read as detached constants inside it;
634
+ // afterMicroEval frees them once that micro-batch's grads are materialized.
635
+ const { loss: lossVal, grads } = accumulateStep(
636
+ cfg.gradAccumSteps,
637
+ () => {
638
+ currentBatch = batches.next().value as DpoBatch;
639
+ const refs = dpoRefLogps(model, lora, currentBatch);
640
+ refChosen = refs.refChosen;
641
+ refRejected = refs.refRejected;
642
+ return vag.apply(flatParams(lora));
643
+ },
644
+ () => {
645
+ refChosen?.dispose();
646
+ refRejected?.dispose();
647
+ refChosen = refRejected = null;
648
+ },
649
+ );
480
650
 
481
651
  const gradNorm = globalNorm(grads);
652
+ clipGradsByNorm(grads, gradNorm, cfg.gradClipNorm);
482
653
  opt.step(grads);
483
654
  opt.evalState();
484
-
485
- refChosen.dispose();
486
- refRejected.dispose();
487
- refChosen = refRejected = null;
488
655
  clearCache();
489
656
 
490
657
  if (step % cfg.stepsPerReport === 0 || step === 1) {
491
- const m = dpoMetrics(model, lora, currentBatch, cfg.dpoBeta);
658
+ const m = dpoMetrics(model, lora, currentBatch!, cfg.dpoBeta);
492
659
  const elapsed = (Date.now() - t0) / 1000;
493
660
  emit({
494
661
  type: "metric",
@@ -529,8 +696,260 @@ async function dpoLoop(
529
696
  } finally {
530
697
  vag.dispose();
531
698
  opt.dispose();
532
- refChosen?.dispose();
533
- refRejected?.dispose();
699
+ // Cast: refs are only ever assigned inside the accumulateStep closures, so
700
+ // TS's flow analysis narrows these to their `null` initializer here.
701
+ (refChosen as MlxArray | null)?.dispose();
702
+ (refRejected as MlxArray | null)?.dispose();
703
+ }
704
+ return { numIters: cfg.iters };
705
+ }
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // ORPO loop (reference-free: 2 forwards/step, no reference pass)
709
+ // ---------------------------------------------------------------------------
710
+
711
+ async function orpoLoop(
712
+ model: RuntimeModel,
713
+ tok: LoadedTokenizer,
714
+ tmpl: ChatTemplate,
715
+ dataDir: string,
716
+ cfg: TrainConfig,
717
+ lora: TrainableLora,
718
+ emit: Emit,
719
+ ): Promise<{ numIters: number }> {
720
+ emit({ type: "stage", stage: "data", progress: 0.05, message: "loading preference dataset" });
721
+ const train = await loadDpoDataset(`${dataDir}/train.jsonl`, tok, tmpl, cfg.maxSeqLen);
722
+ const valid = (await fileExists(`${dataDir}/valid.jsonl`))
723
+ ? await loadDpoDataset(`${dataDir}/valid.jsonl`, tok, tmpl, cfg.maxSeqLen)
724
+ : [];
725
+
726
+ const params = flatParams(lora);
727
+ // LoRA+: B leaves (the second half of flatParams) get a higher LR than A.
728
+ const nT = lora.targets.length;
729
+ const lrScale = cfg.loraPlusRatio !== 1
730
+ ? params.map((_, i) => (i < nT ? 1 : cfg.loraPlusRatio))
731
+ : undefined;
732
+ if (lrScale) emit({ type: "stage", stage: "setup", progress: 0.04,
733
+ message: `LoRA+ : B leaves at ${cfg.loraPlusRatio}× LR` });
734
+ const opt = new AdamW(
735
+ params,
736
+ { lr: cfg.learningRate, betas: cfg.betas, eps: 1e-8, weightDecay: cfg.weightDecay, lrScale },
737
+ (i, p) => writeParam(lora, i, p),
738
+ );
739
+
740
+ const schedule =
741
+ cfg.orpoLrSchedule === "cosine" || cfg.orpoWarmupIters > 0
742
+ ? warmupCosineSchedule(cfg.learningRate, cfg.orpoWarmupIters, cfg.iters)
743
+ : null;
744
+
745
+ // Segmented backward for ORPO: stream chosen+rejected backwards segment-by-segment.
746
+ // MiniCPM5 + Gemma4 (e4b) — same model coverage as SFT segmented. B=1 only.
747
+ const useSegmented = cfg.segmentSize > 0;
748
+ // Prefix-sharing + segmented backward COMPOSE (M3-composition): the segmented
749
+ // backward streams the ONE prefix-shared concat forward [prompt; chosen; rejected]
750
+ // (block-sparse mask via PrefixSharedCache + block-wise RoPE via the prefix plan)
751
+ // segment-by-segment — so the prompt-encode-once saving holds AT long seq. Composed
752
+ // for BOTH MiniCPM5 and Gemma4/e4b (the e4b variant threads donor-KV + the
753
+ // logical-position sliding-window prefix mask through the segment boundaries).
754
+ const useSegmentedPrefix = cfg.orpoPrefixShared && useSegmented;
755
+ if (cfg.orpoPrefixShared && !(model instanceof MiniCPM5Model || model instanceof Gemma4Model))
756
+ throw new Error("orpoPrefixShared is only wired for MiniCPM5 and Gemma4");
757
+ // Fused linear-CE head INSIDE the segmented backward (orpo_fused_ce): bounds the
758
+ // head term to [chunk,V] alongside the per-segment layer savings. 0 = the
759
+ // full-[M,V] responseOnlyLogpMean head (unchanged). Reuses orpoChunkSize as the
760
+ // token-chunk (default 512), same as the non-segmented path.
761
+ // "flash implies fused": orpoFlashCe alone must still bound the segmented head to
762
+ // [chunk,V]. The non-prefix segmented class has no flash path, so flash routes
763
+ // through its fused/checkpoint chunked head (boundedHeadFromHidden) — never the
764
+ // full-[M,V] responseOnlyLogpMean, which would reintroduce [M,vocab] on e4b.
765
+ const segUsesFusedHead = cfg.orpoFusedCe || cfg.orpoFlashCe;
766
+ const segFusedChunk = segUsesFusedHead ? (cfg.orpoChunkSize > 0 ? cfg.orpoChunkSize : 512) : 0;
767
+ // The prefix segmented head gathers response positions then routes per-branch
768
+ // through the [M,V]-free flash/fused head (branchLogpMeanGathered, which takes a
769
+ // ChunkCtx). Its own sink is managed per-step inside the class.
770
+ const segPrefixChunk: ChunkCtx | undefined =
771
+ cfg.orpoFusedCe || cfg.orpoFlashCe
772
+ ? { chunkSize: cfg.orpoChunkSize > 0 ? cfg.orpoChunkSize : 512, fused: true, flash: cfg.orpoFlashCe, sink: [] }
773
+ : undefined;
774
+ let segmentedOrpo: SegmentedBackwardOrpo | SegmentedBackwardOrpoGemma4 | null = null;
775
+ let segmentedOrpoPrefix: SegmentedBackwardOrpoPrefix | SegmentedBackwardOrpoPrefixGemma4 | null = null;
776
+ if (useSegmented) {
777
+ const ranges = planSegmentsBySize(model.layers.length, cfg.segmentSize);
778
+ if (useSegmentedPrefix) {
779
+ // The composed prefix-shared segmented backward (MiniCPM5 or Gemma4/e4b), plus a
780
+ // plain segmented-ORPO fallback for rows whose chosen/rejected prompts differ
781
+ // (splitPrefixBatch returns null -> two-forward segmented step).
782
+ if (model instanceof MiniCPM5Model) {
783
+ segmentedOrpoPrefix = new SegmentedBackwardOrpoPrefix(model, lora, ranges, cfg.orpoLambda, segPrefixChunk);
784
+ segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
785
+ } else {
786
+ segmentedOrpoPrefix = new SegmentedBackwardOrpoPrefixGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segPrefixChunk);
787
+ segmentedOrpo = new SegmentedBackwardOrpoGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segFusedChunk);
788
+ }
789
+ emit({ type: "stage", stage: "setup", progress: 0.04,
790
+ message: `orpo segmented + prefix-share (${model instanceof Gemma4Model ? "e4b" : "MiniCPM5"}): single concat forward streamed over ${Math.ceil(model.layers.length / cfg.segmentSize)} segments of <=${cfg.segmentSize} layers (two-forward fallback on prompt mismatch)` });
791
+ } else if (model instanceof MiniCPM5Model)
792
+ segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
793
+ else if (model instanceof Gemma4Model)
794
+ segmentedOrpo = new SegmentedBackwardOrpoGemma4(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
795
+ else
796
+ throw new Error("segmented backward (segmentSize > 0) for ORPO is only wired for MiniCPM5 and Gemma4");
797
+ if (!useSegmentedPrefix) emit({ type: "stage", stage: "setup", progress: 0.04,
798
+ message: `orpo segmented backward: ${Math.ceil(model.layers.length / cfg.segmentSize)} segments of <=${cfg.segmentSize} layers${segFusedChunk > 0 ? ` + fused linear-CE head (${segFusedChunk}/chunk)` : ""}` });
799
+ }
800
+
801
+ // Token-chunked head (non-segmented path only): the per-chunk checkpoints are
802
+ // collected here and disposed after each micro-step's grads are eval'd (the
803
+ // afterMicroEval hook below) — that is when their backward recompute has run.
804
+ const chunkSink: Array<{ dispose(): void }> = [];
805
+ // Fused linear-CE head reuses orpoChunkSize as the token-chunk (default 512 when
806
+ // unset); otherwise the Checkpoint token-chunked head fires only when
807
+ // orpoChunkSize > 0. Both are non-segmented-path only.
808
+ const fusedChunkSize = cfg.orpoChunkSize > 0 ? cfg.orpoChunkSize : 512;
809
+ const chunk: ChunkCtx | undefined = useSegmented
810
+ ? undefined
811
+ : cfg.orpoFusedCe || cfg.orpoFlashCe
812
+ ? { chunkSize: fusedChunkSize, fused: true, flash: cfg.orpoFlashCe, sink: chunkSink }
813
+ : cfg.orpoChunkSize > 0
814
+ ? { chunkSize: cfg.orpoChunkSize, sink: chunkSink }
815
+ : undefined;
816
+ if (chunk) emit({ type: "stage", stage: "setup", progress: 0.04,
817
+ message: chunk.flash
818
+ ? `orpo flash-CCE (Metal kernel) head: ${chunk.chunkSize} tokens/chunk`
819
+ : chunk.fused
820
+ ? `orpo fused linear-CE head: ${chunk.chunkSize} tokens/chunk`
821
+ : `orpo chunked head: ${cfg.orpoChunkSize} tokens/chunk` });
822
+ if (cfg.orpoPrefixShared && !useSegmentedPrefix) emit({ type: "stage", stage: "setup", progress: 0.04,
823
+ message: `orpo prefix-sharing: single forward over [prompt; chosen; rejected] (two-forward fallback on prompt mismatch)` });
824
+ const disposeChunkSink = () => { for (const c of chunkSink) c.dispose(); chunkSink.length = 0; };
825
+
826
+ // No reference forward (the whole point of ORPO) — the closure just builds the
827
+ // monolithic L_NLL + λ·L_OR over the active batch against the swapped primals.
828
+ let currentBatch: DpoBatch | null = null;
829
+ const vag = useSegmented
830
+ ? null
831
+ : new ValueAndGrad((primals) => {
832
+ const saved = swapPrimals(lora, primals);
833
+ try {
834
+ // Prefix-sharing (B=1): one forward over [prompt; chosen; rejected] when the
835
+ // row's chosen/rejected share an identical prompt; else fall back two-forward.
836
+ if (cfg.orpoPrefixShared) {
837
+ const split = splitPrefixBatch(currentBatch!);
838
+ if (split) {
839
+ if (model instanceof MiniCPM5Model)
840
+ return orpoLossPrefixShared(model, split.promptIds, split.chosenResp, split.rejectedResp, cfg.orpoLambda, chunk);
841
+ return orpoLossPrefixSharedGemma(model as Gemma4Model, split.promptIds, split.chosenResp, split.rejectedResp, cfg.orpoLambda, chunk);
842
+ }
843
+ notePrefixFallback();
844
+ }
845
+ return orpoLoss(model, currentBatch!, cfg.orpoLambda, chunk);
846
+ } finally {
847
+ restorePrimals(lora, saved);
848
+ }
849
+ }, params.map((_, i) => i));
850
+
851
+ const padId = tok.eosTokenId ?? 0;
852
+ const batches = iterateDpoBatches(train, cfg.batchSize, cfg.seed, true, padId);
853
+ const t0 = Date.now();
854
+
855
+ let dropoutCounter = 0;
856
+ // Prefix-share falls back to the two-forward path for any row whose chosen/rejected
857
+ // prompts aren't byte-identical (splitPrefixBatch → null). Log the FIRST fallback +
858
+ // count them, so a silent loss of the prompt-encode-once saving is visible.
859
+ let prefixFallbacks = 0;
860
+ const notePrefixFallback = () => {
861
+ prefixFallbacks++;
862
+ if (prefixFallbacks === 1) emit({ type: "stage", stage: "train", progress: 0,
863
+ message: `prefix-share: row prompt mismatch → two-forward fallback for this row (saving lost only on mismatched rows; counting…)` });
864
+ };
865
+ try {
866
+ for (let step = 1; step <= cfg.iters; step++) {
867
+ if (schedule) opt.lr = schedule(step);
868
+
869
+ // One optimizer step over cfg.gradAccumSteps micro-batches (pass-through
870
+ // when 1) — the long-context lever for a larger effective batch without
871
+ // the B>1 activation memory (see docs/design/orpo-training.md, Batching).
872
+ const { loss: lossVal, grads } = accumulateStep(cfg.gradAccumSteps, () => {
873
+ currentBatch = batches.next().value as DpoBatch;
874
+ // Per-micro-step dropout seed: constant across this micro-step's forward
875
+ // AND its backward recompute (segmented/chunked), so the mask reproduces.
876
+ if (cfg.loraDropout > 0) model.loraState.dropoutSeed = ++dropoutCounter;
877
+ // Segmented + prefix-share: stream the single concat forward when the row's
878
+ // chosen/rejected share an identical prompt; else fall back to the plain
879
+ // two-forward segmented step (segmentedOrpo, built alongside).
880
+ if (segmentedOrpoPrefix) {
881
+ const split = splitPrefixBatch(currentBatch!);
882
+ if (split) return segmentedOrpoPrefix.stepPrefix(split.promptIds, split.chosenResp, split.rejectedResp);
883
+ notePrefixFallback();
884
+ return segmentedOrpo!.step(currentBatch!);
885
+ }
886
+ return segmentedOrpo ? segmentedOrpo.step(currentBatch!) : vag!.apply(flatParams(lora));
887
+ }, disposeChunkSink); // free this micro-step's head checkpoints once its grads are eval'd
888
+
889
+ model.loraState.dropoutSeed = null; // metrics/val below run dropout-free
890
+ const gradNorm = globalNorm(grads);
891
+ clipGradsByNorm(grads, gradNorm, cfg.gradClipNorm);
892
+ opt.step(grads);
893
+ opt.evalState();
894
+ clearCache();
895
+
896
+ if (step % cfg.stepsPerReport === 0 || step === 1) {
897
+ // Diagnostics recompute the two forwards (like dpoMetrics) — cheap at
898
+ // B=1 (response-only head, no full logits) and gated by stepsPerReport.
899
+ // Reusing the value_and_grad forward is a shared-with-DPO follow-on.
900
+ const m = orpoMetrics(model, currentBatch!, cfg.orpoLambda);
901
+ const elapsed = (Date.now() - t0) / 1000;
902
+ emit({
903
+ type: "metric",
904
+ kind: "train",
905
+ step,
906
+ loss: lossVal,
907
+ grad_norm: gradNorm,
908
+ learning_rate: opt.lr,
909
+ accuracy: m.accuracy,
910
+ margin: m.margin,
911
+ tokens_per_sec: 0,
912
+ peak_gb: peakMemory() / 1e9,
913
+ active_gb: activeMemory() / 1e9,
914
+ pinned: pinnedBufferCount(),
915
+ progress: step / cfg.iters,
916
+ message: `step ${step}/${cfg.iters} loss=${lossVal.toFixed(4)} nll=${m.nll.toFixed(4)} or=${m.or.toFixed(4)} acc=${m.accuracy.toFixed(2)} margin=${m.margin.toFixed(3)}`,
917
+ });
918
+ clearCache();
919
+ }
920
+
921
+ if (valid.length > 0 && step % cfg.stepsPerEval === 0) {
922
+ // Val accuracy is correct/total over a FIXED subset (cap bounds both the
923
+ // denominator and the per-eval cost). Each val example is a single B=1
924
+ // pair, so m.accuracy ∈ {0,1} and va sums to the integer correct count.
925
+ const valSet = cfg.valMaxExamples > 0 ? valid.slice(0, cfg.valMaxExamples) : valid;
926
+ let vl = 0, va = 0, vm = 0, vn = 0;
927
+ for (const ex of valSet) {
928
+ const vb: DpoBatch = {
929
+ chosenIds: [ex.chosenIds], rejectedIds: [ex.rejectedIds],
930
+ chosenMask: [ex.chosenMask], rejectedMask: [ex.rejectedMask],
931
+ };
932
+ const m = orpoMetrics(model, vb, cfg.orpoLambda);
933
+ vl += m.loss; va += m.accuracy; vm += m.margin; vn++;
934
+ clearCache();
935
+ }
936
+ emit({
937
+ type: "metric", kind: "val", step,
938
+ loss: vn ? vl / vn : 0,
939
+ accuracy: vn ? va / vn : 0,
940
+ n_correct: Math.round(va),
941
+ n_total: vn,
942
+ margin: vn ? vm / vn : 0,
943
+ progress: step / cfg.iters,
944
+ });
945
+ }
946
+ }
947
+ } finally {
948
+ disposeChunkSink(); // defensive: free any head checkpoints a throw left behind
949
+ segmentedOrpoPrefix?.dispose();
950
+ segmentedOrpo?.dispose();
951
+ vag?.dispose();
952
+ opt.dispose();
534
953
  }
535
954
  return { numIters: cfg.iters };
536
955
  }
@@ -570,6 +989,89 @@ function writeParam(lora: TrainableLora, i: number, p: MlxArray): void {
570
989
  else lora.targets[i - n]!.lw.b = p;
571
990
  }
572
991
 
992
+ /** Run one optimizer step's worth of gradient over `accumSteps` micro-batches:
993
+ * backward each micro-batch, mean-accumulate the per-micro grads (each scaled
994
+ * by 1/accumSteps), and return the mean loss + summed grads — a drop-in for the
995
+ * single value_and_grad the loops used to call inline. The caller passes the
996
+ * returned grads to opt.step (which takes ownership and disposes them).
997
+ *
998
+ * accumSteps <= 1 is a pass-through identical to the pre-accumulation path (one
999
+ * micro-batch, no scaling) so the proven B=1 loops stay byte-for-byte unchanged.
1000
+ * For accumSteps > 1 the running accumulator (and that micro-batch's loss) is
1001
+ * eval'd before the next micro-batch's forward builds, so only ONE micro-batch's
1002
+ * activations are ever live — peak memory does NOT grow with accumSteps (the
1003
+ * whole point: a larger effective batch without the B>1 activation memory).
1004
+ *
1005
+ * The combined `evalAll([value, ...grads])` per micro-batch is deliberate: a
1006
+ * separate eval of `value` first would materialize then free the forward,
1007
+ * forcing the backward to recompute and hold every layer's activations — which
1008
+ * silently defeats gradient checkpointing (verified). Keep loss + grads in one
1009
+ * eval.
1010
+ *
1011
+ * `afterMicroEval` runs once per micro-batch right after its grads are
1012
+ * materialized (its backward is complete), in BOTH the pass-through and
1013
+ * accumulating paths — it frees per-micro-batch state the loss closure captured
1014
+ * (gradient-checkpoint closures for SFT, reference log-probs for DPO) so that
1015
+ * state does not pile up across accumulation.
1016
+ *
1017
+ * `micro` returns the micro-batch loss scalar + grads in flatParams order
1018
+ * ([...A, ...B] over targets); it transfers ownership of both to this helper,
1019
+ * which disposes the per-micro value and grads. */
1020
+ export function accumulateStep(
1021
+ accumSteps: number,
1022
+ micro: () => { value: MlxArray; grads: MlxArray[] },
1023
+ afterMicroEval?: () => void,
1024
+ ): { loss: number; grads: MlxArray[] } {
1025
+ if (accumSteps <= 1) {
1026
+ const { value, grads } = micro();
1027
+ evalAll([value, ...grads]);
1028
+ const loss = value.toFloat32()[0]!;
1029
+ value.dispose();
1030
+ afterMicroEval?.();
1031
+ return { loss, grads };
1032
+ }
1033
+
1034
+ const scale = 1 / accumSteps;
1035
+ let acc: MlxArray[] | null = null;
1036
+ let lossSum = 0;
1037
+ try {
1038
+ for (let i = 0; i < accumSteps; i++) {
1039
+ const { value, grads } = micro();
1040
+ if (acc === null) {
1041
+ acc = grads.map((g) => {
1042
+ const s = ops.mulScalar(g, scale);
1043
+ g.dispose();
1044
+ return s;
1045
+ });
1046
+ } else {
1047
+ const prev = acc;
1048
+ acc = grads.map((g, j) => {
1049
+ const s = ops.mulScalar(g, scale);
1050
+ const sum = ops.add(prev[j]!, s);
1051
+ s.dispose();
1052
+ prev[j]!.dispose();
1053
+ g.dispose();
1054
+ return sum;
1055
+ });
1056
+ }
1057
+ // Eval the running accumulator (+ this micro-batch's loss) now so the
1058
+ // micro-batch's forward/backward graph frees before the next one builds —
1059
+ // this is what keeps peak flat across accumSteps.
1060
+ evalAll([value, ...acc]);
1061
+ lossSum += value.toFloat32()[0]!;
1062
+ value.dispose();
1063
+ afterMicroEval?.();
1064
+ clearCache();
1065
+ }
1066
+ const grads = acc!;
1067
+ acc = null; // ownership handed to the caller
1068
+ return { loss: lossSum / accumSteps, grads };
1069
+ } finally {
1070
+ // Non-null only if a micro-batch threw mid-accumulation (nulled on success).
1071
+ if (acc) for (const a of acc) a.dispose();
1072
+ }
1073
+ }
1074
+
573
1075
  /** L2 norm over all grads (also forces eval). */
574
1076
  function globalNorm(grads: MlxArray[]): number {
575
1077
  const partial: MlxArray[] = grads.map((g) => {
@@ -589,6 +1091,20 @@ function globalNorm(grads: MlxArray[]): number {
589
1091
  return Math.sqrt(sumSq);
590
1092
  }
591
1093
 
1094
+ /** Clip grads in place to a global L2 norm of `maxNorm` (no-op if off or under
1095
+ * the cap). Scales every grad by maxNorm/normVal and disposes the originals so
1096
+ * the optimizer takes ownership of the clipped copies. `normVal` is the already-
1097
+ * computed global norm (avoids a second pass). */
1098
+ function clipGradsByNorm(grads: MlxArray[], normVal: number, maxNorm: number): void {
1099
+ if (maxNorm <= 0 || !(normVal > maxNorm)) return;
1100
+ const s = maxNorm / normVal;
1101
+ for (let i = 0; i < grads.length; i++) {
1102
+ const g = grads[i]!;
1103
+ grads[i] = ops.mulScalar(g, s);
1104
+ g.dispose();
1105
+ }
1106
+ }
1107
+
592
1108
  function countResponseTokens(batch: SftBatch): number {
593
1109
  const promptLen = batch.promptLens[0]!;
594
1110
  const len = batch.ids[0]!.length;