mlx-bun 0.0.4 → 0.0.5

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 (50) hide show
  1. package/README.md +166 -93
  2. package/docs/design/adapters-end-to-end.md +50 -46
  3. package/docs/design/compat-cli-surface-design.md +10 -8
  4. package/docs/design/docs-reorg-plan.md +14 -1
  5. package/docs/design/hlg-sampling.md +12 -2
  6. package/docs/design/memory-system.md +1 -1
  7. package/docs/design/optimization_plan.md +4 -2
  8. package/docs/design/orpo-future-enhancements.md +298 -0
  9. package/docs/design/orpo-training.md +1199 -0
  10. package/docs/design/parallel-slots.md +34 -26
  11. package/docs/design/runtime-isolation.md +1 -1
  12. package/docs/design/segmented-backward-training.md +58 -39
  13. package/docs/design/tauri-desktop-app.md +127 -0
  14. package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
  15. package/docs/investigations/starter-model-port-handoff.md +32 -13
  16. package/docs/investigations/steel-flash-cce-handoff.md +431 -0
  17. package/docs/planning/IDEAS.md +29 -0
  18. package/docs/planning/PRODUCT_ROADMAP.md +147 -27
  19. package/docs/planning/chunk-task-roadmap.md +1 -1
  20. package/docs/planning/journal.md +161 -0
  21. package/docs/reference/distribution.md +61 -5
  22. package/docs/reference/library-api.md +29 -14
  23. package/docs/reference/orpo-quickstart.md +91 -0
  24. package/docs/reference/server-api.md +105 -7
  25. package/docs/reference/server-config.md +12 -5
  26. package/docs/reference/training.md +292 -0
  27. package/package.json +1 -1
  28. package/src/eval/ifeval.ts +138 -0
  29. package/src/eval/ultrafeedback.ts +60 -0
  30. package/src/lora.ts +12 -7
  31. package/src/mlx/metal-kernel.ts +18 -4
  32. package/src/model/flash-attention.ts +32 -16
  33. package/src/model/fused-geglu-kernel.ts +111 -0
  34. package/src/model/gemma4-base.ts +39 -1
  35. package/src/model/gemma4.ts +196 -21
  36. package/src/model/minicpm5.ts +44 -3
  37. package/src/server.ts +3 -3
  38. package/src/train/dataset.ts +18 -3
  39. package/src/train/flash-cce.ts +1162 -0
  40. package/src/train/job.ts +26 -2
  41. package/src/train/lora-params.ts +53 -4
  42. package/src/train/loss.ts +941 -0
  43. package/src/train/optimizer.ts +10 -3
  44. package/src/train/prefix-shared.ts +435 -0
  45. package/src/train/rank.ts +36 -0
  46. package/src/train/segmented.ts +1148 -3
  47. package/src/train/steel-qmm-header.ts +1461 -0
  48. package/src/train/trainer.ts +509 -52
  49. package/src/web/app.html +21 -5
  50. /package/{docs → src/assets}/curve-designer.html +0 -0
@@ -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
40
  import { writeFileSync } 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
@@ -75,6 +94,43 @@ export interface TrainConfig {
75
94
  dpoBeta: number;
76
95
  dpoWarmupIters: number;
77
96
  dpoLrSchedule: "constant" | "cosine";
97
+ // ORPO (reference-free; method-prefixed knobs, mirroring the dpo trio).
98
+ /** λ — weights ONLY the odds-ratio term (the SFT-NLL term stays unweighted). */
99
+ orpoLambda: number;
100
+ orpoWarmupIters: number;
101
+ orpoLrSchedule: "constant" | "cosine";
102
+ /** Token-chunk size for the ORPO head (0 = off). >0 computes the B=1
103
+ * response-only LM head in token-chunks, each rematerialized in the backward,
104
+ * bounding the dominant `[M, vocab]` logits term to `[chunkSize, vocab]`.
105
+ * Exact (numerically identical). Non-segmented path only (segmentSize takes
106
+ * precedence). See docs/design/orpo-training.md → chunked fused log-prob. */
107
+ orpoChunkSize: number;
108
+ /** Use the FUSED linear cross-entropy head (Liger/CCE-style): one CustomVjp with
109
+ * an analytic softmax−onehot backward — no autograd through the head, no
110
+ * retained `[M,vocab]` logits in either direction. Composes with
111
+ * `orpoChunkSize` as the token-chunk size (defaults to 512 when unset). B=1,
112
+ * non-segmented path only; Gemma + MiniCPM5 heads. Value bit-exact vs the
113
+ * full-logits head, grads in the bf16 class. See docs/design/orpo-training.md
114
+ * → fused linear-CE head. */
115
+ orpoFusedCe: boolean;
116
+ /** Route the fused linear-CE head through the flash-CCE Metal kernel: in-kernel
117
+ * quantized logits + online softmax (fwd) and dh accumulation (bwd) — neither
118
+ * `[M,vocab]` nor a dequantized head touches HBM. Lowest memory + fastest on
119
+ * large vocab (e4b 262k / CPM 130k). Implies the fused head; the Apple-CCE coeff
120
+ * filter is on by default in the backward. B=1, non-segmented path. */
121
+ orpoFlashCe: boolean;
122
+ /** Shared prompt-prefix ORPO (lever 7): one forward over [prompt; chosen; rejected]
123
+ * with a block-sparse mask + block-wise RoPE, so the shared prompt is encoded ONCE
124
+ * (token cost 2(P+R) → P+2R). B=1, non-segmented path; MiniCPM5 + Gemma4 (e4b).
125
+ * Falls back to the two-forward orpoLoss for rows whose chosen/rejected prompts
126
+ * differ. Composes with orpoFlashCe/orpoFusedCe (each branch routes through the
127
+ * [M,V]-free head). See src/train/prefix-shared.ts. */
128
+ orpoPrefixShared: boolean;
129
+ /** Warm-start: path to an existing adapter dir (with adapters.safetensors) whose
130
+ * LoRA weights initialize this run instead of the random/zero init. Continues
131
+ * training from a checkpoint's weights; the optimizer state and LR schedule restart.
132
+ * Rank/targets must match the checkpoint. "" = off (fresh init). */
133
+ warmStartAdapter: string;
78
134
  // output
79
135
  adapterPath: string;
80
136
  baseModel: string;
@@ -103,6 +159,10 @@ export const DEFAULT_TRAIN_CONFIG: TrainConfig = {
103
159
  stepsPerEval: 50,
104
160
  betas: [0.9, 0.999],
105
161
  weightDecay: 0.01,
162
+ loraDropout: 0.0, // off by default
163
+ rsLora: false, // off by default (opt-in; recommended when rank_scaling != constant)
164
+ loraPlusRatio: 1.0, // 1 = off
165
+ mlpSplit: false, // default off; only effective with gradCheckpoint (Gemma4)
106
166
  gradCheckpoint: false, // default off until validated; flip to true (optiq default) after
107
167
  segmentSize: 0, // default off; >0 enables segmented backward (MiniCPM5 SFT B=1)
108
168
  saveCheckpoints: false, // off by default; scripts opt in (keep-all-checkpoints + metrics.json)
@@ -110,6 +170,15 @@ export const DEFAULT_TRAIN_CONFIG: TrainConfig = {
110
170
  dpoBeta: 0.1,
111
171
  dpoWarmupIters: 0,
112
172
  dpoLrSchedule: "cosine",
173
+
174
+ orpoLambda: 0.1, // paper's primary value + TRL default; weights only L_OR
175
+ orpoWarmupIters: 0,
176
+ orpoLrSchedule: "cosine",
177
+ orpoChunkSize: 0, // default off; >0 = token-chunked head (bounds the [M,vocab] term)
178
+ orpoFusedCe: false, // default off; true = fused linear-CE head (analytic backward, no [M,vocab])
179
+ orpoFlashCe: false, // default off; true = flash-CCE Metal-kernel head (implies fused)
180
+ orpoPrefixShared: false, // default off; true = shared prompt-prefix single forward (B=1)
181
+ warmStartAdapter: "", // default off; path to an adapter dir to warm-start LoRA weights from
113
182
  adapterPath: "adapters",
114
183
  baseModel: "",
115
184
  };
@@ -127,16 +196,51 @@ export async function trainLora(
127
196
  ): Promise<TrainResult> {
128
197
  emit({ type: "stage", stage: "setup", progress: 0.02, message: "resolving ranks" });
129
198
 
199
+ // Mixed-precision rank scaling: feed the per-layer bits (from the loaded
200
+ // model's quant specs) and, for by_kl, the recorded KL sensitivities
201
+ // (optiq_metadata.json). Without these maps, by_bits/by_kl silently fell back
202
+ // to uniform rank — so the "give the sensitive (8-bit) layers more adapter
203
+ // capacity" policy (optiq: one sensitivity signal, two optimizations — the
204
+ // bit assignment AND the LoRA rank) never actually fired.
205
+ const bitsMap = bitsMapFromModel(model);
206
+ const klMap = cfg.rankScaling === "by_kl" ? await readPerLayerKl(cfg.baseModel) : undefined;
130
207
  const ranks = resolveRanks(model, {
131
208
  rank: cfg.rank,
132
209
  rankScaling: cfg.rankScaling,
133
210
  targetModules: cfg.targetModules,
134
211
  numLayers: cfg.numLayers,
212
+ bitsMap,
213
+ klMap,
135
214
  });
136
215
  const appliedRanks: Record<string, number> = Object.fromEntries(ranks);
137
216
 
138
- const lora = buildTrainableLora(model, ranks, cfg.scale, cfg.seed);
217
+ // Surface the rank spread so mixed-precision scaling is observable (e.g.
218
+ // "r8×140 r16×28" means 28 sensitive layers got the wider adapter).
219
+ const rankCounts = new Map<number, number>();
220
+ for (const r of ranks.values()) rankCounts.set(r, (rankCounts.get(r) ?? 0) + 1);
221
+ const rankSpread = [...rankCounts.entries()]
222
+ .sort((a, b) => a[0] - b[0])
223
+ .map(([r, n]) => `r${r}×${n}`)
224
+ .join(" ");
225
+ emit({ type: "stage", stage: "setup", progress: 0.03,
226
+ message: `ranks (${cfg.rankScaling}): ${rankSpread}` });
227
+
228
+ const lora = buildTrainableLora(model, ranks, cfg.scale, cfg.seed, cfg.rsLora);
229
+ // Warm-start: continue from a saved adapter/checkpoint's weights (optimizer + LR
230
+ // schedule restart fresh). Must run before the optimizer is built (below) so it
231
+ // tracks the loaded leaves. Rank/targets must match the checkpoint.
232
+ if (cfg.warmStartAdapter) {
233
+ const n = warmStartFromAdapter(lora, cfg.warmStartAdapter);
234
+ emit({ type: "stage", stage: "setup", progress: 0.035,
235
+ message: `warm-start: loaded ${n} LoRA targets from ${cfg.warmStartAdapter} (optimizer + LR schedule restart)` });
236
+ }
139
237
  attachForTraining(model, lora, "train");
238
+ model.loraState.dropoutRate = cfg.loraDropout; // per-step seed set inside the loop
239
+ // Training-mode fused GeGLU (Gemma e4b): kernel forward + hand-derived vjp, so
240
+ // autograd flows and the backward recomputes the gelu from the primal instead
241
+ // of retaining the spelled-out intermediates. No-op for non-Gemma models.
242
+ // Cleared in the finally below.
243
+ setFusedGeluTraining(true);
140
244
 
141
245
  const saveCfg: SaveAdapterConfig = {
142
246
  rank: cfg.rank,
@@ -146,6 +250,7 @@ export async function trainLora(
146
250
  numLayers: cfg.numLayers,
147
251
  method: cfg.method,
148
252
  baseModel: cfg.baseModel,
253
+ rsLora: cfg.rsLora,
149
254
  };
150
255
 
151
256
  // Keep-ALL-checkpoints + a durable metrics record. At each eval step we
@@ -158,7 +263,11 @@ export async function trainLora(
158
263
  // metrics, and the live LoRA leaves are the current step's weights at emit
159
264
  // time, so saving here captures exactly that checkpoint.
160
265
  const trainHistory: { step: number; loss: number }[] = [];
161
- const valHistory: { step: number; loss: number; checkpoint: string | null }[] = [];
266
+ // For preference methods (dpo/orpo) the val MARGIN (mean chosen−rejected
267
+ // log-odds) and accuracy are the meaningful early-stop signals — val loss is a
268
+ // weak proxy. Capture them when the loop emits them so metrics.json lets you
269
+ // pick the best-MARGIN checkpoint (overfit shows as train↑ / val-margin flat).
270
+ const valHistory: { step: number; loss: number; margin?: number; accuracy?: number; checkpoint: string | null }[] = [];
162
271
  const startedAt = Date.now();
163
272
  const collect: Emit = (e) => {
164
273
  if (e.type === "metric" && e.kind === "train") {
@@ -172,7 +281,8 @@ export async function trainLora(
172
281
  emit({ type: "stage", stage: "checkpoint", progress: e.step / cfg.iters,
173
282
  message: `checkpoint ${tag}` });
174
283
  }
175
- valHistory.push({ step: e.step, loss: e.loss, checkpoint });
284
+ const ev = e as { step: number; loss: number; margin?: number; accuracy?: number };
285
+ valHistory.push({ step: ev.step, loss: ev.loss, margin: ev.margin, accuracy: ev.accuracy, checkpoint });
176
286
  }
177
287
  emit(e);
178
288
  };
@@ -181,7 +291,9 @@ export async function trainLora(
181
291
  const result =
182
292
  cfg.method === "dpo"
183
293
  ? await dpoLoop(model, tok, tmpl, dataDir, cfg, lora, collect)
184
- : await sftLoop(model, tok, tmpl, dataDir, cfg, lora, collect);
294
+ : cfg.method === "orpo"
295
+ ? await orpoLoop(model, tok, tmpl, dataDir, cfg, lora, collect)
296
+ : await sftLoop(model, tok, tmpl, dataDir, cfg, lora, collect);
185
297
 
186
298
  // Final save (last adapter).
187
299
  detachTraining(model, lora);
@@ -193,18 +305,24 @@ export async function trainLora(
193
305
  if (cfg.saveCheckpoints) {
194
306
  let bestVal: { step: number; loss: number; checkpoint: string | null } | null = null;
195
307
  for (const v of valHistory) if (bestVal === null || v.loss < bestVal.loss) bestVal = v;
308
+ // Best by MARGIN too (preference methods) — the better checkpoint signal.
309
+ let bestMargin: typeof valHistory[number] | null = null;
310
+ for (const v of valHistory)
311
+ if (v.margin !== undefined && (bestMargin === null || v.margin > (bestMargin.margin ?? -Infinity))) bestMargin = v;
196
312
  writeFileSync(`${cfg.adapterPath}/metrics.json`, JSON.stringify({
197
313
  config: {
198
314
  method: cfg.method, rank: cfg.rank, scale: cfg.scale,
199
315
  learningRate: cfg.learningRate, maxSeqLen: cfg.maxSeqLen,
200
316
  segmentSize: cfg.segmentSize, iters: cfg.iters,
201
317
  numLayers: cfg.numLayers, baseModel: cfg.baseModel,
318
+ orpoLambda: cfg.method === "orpo" ? cfg.orpoLambda : undefined,
202
319
  },
203
320
  wallSeconds: (Date.now() - startedAt) / 1000,
204
321
  peakGb: peakMemory() / 1e9,
205
322
  finalTrainLoss: trainHistory.at(-1)?.loss ?? null,
206
323
  finalValLoss: valHistory.at(-1)?.loss ?? null,
207
324
  bestVal,
325
+ bestMargin, // null for SFT (no margin); the recommended pick for dpo/orpo
208
326
  valTrajectory: valHistory,
209
327
  trainLosses: trainHistory,
210
328
  }, null, 2));
@@ -220,6 +338,7 @@ export async function trainLora(
220
338
  });
221
339
  return { adapterPath: cfg.adapterPath, appliedRanks, numIters: result.numIters };
222
340
  } finally {
341
+ setFusedGeluTraining(false);
223
342
  disposeLora(lora);
224
343
  }
225
344
  }
@@ -290,22 +409,29 @@ async function sftLoop(
290
409
  // Checkpoints are created per forward and disposed after each step's backward.
291
410
  let ckptCtx: GradCheckpointCtx | null = null;
292
411
  if (!useSegmented && cfg.gradCheckpoint && model instanceof Gemma4Model) {
412
+ // Partition each layer's LoRA into the attention sub-block (self_attn.*) and
413
+ // the MLP sub-block (everything else in the layer: gate/up/down_proj +
414
+ // per_layer_*) so the split-MLP checkpoint can wrap them independently.
293
415
  const byLayer: GradCheckpointCtx["byLayer"] = new Map();
294
416
  for (const t of lora.targets) {
295
417
  const m = t.modulePath.match(/\.layers\.(\d+)\./);
296
418
  if (!m) continue;
297
419
  const li = Number(m[1]);
298
- (byLayer.get(li) ?? byLayer.set(li, []).get(li)!).push(t.lw);
420
+ const ll = byLayer.get(li) ?? byLayer.set(li, { attn: [], mlp: [] }).get(li)!;
421
+ (t.modulePath.includes(".self_attn.") ? ll.attn : ll.mlp).push(t.lw);
299
422
  }
300
- ckptCtx = { byLayer, keepAlive: [] };
423
+ ckptCtx = { byLayer, splitMlp: cfg.mlpSplit, keepAlive: [] };
301
424
  model.gradCkpt = ckptCtx;
425
+ if (cfg.mlpSplit) emit({ type: "stage", stage: "setup", progress: 0.04,
426
+ message: "grad checkpoint: intra-layer attn/MLP split" });
302
427
  }
303
428
 
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.
429
+ // Training attention. Default = mlx fused SDPA (ops.sdpa): exact dQ/dK/dV
430
+ // (finite-difference verified) and fast, but O(L²) backward memory. The
431
+ // hand-rolled flash kernel is an opt-in (MLX_BUN_TRAIN_ATTN=flash) O(L)-memory
432
+ // path for memory-bound long context — now correct (its dK-transpose and
433
+ // dQ-causal-barrier bugs were fixed; FD-validated in
434
+ // scripts/experiments/flash-fd-check.ts), though ~30× slower than ops.sdpa.
309
435
  // Cleared in finally.
310
436
  if (process.env.MLX_BUN_TRAIN_ATTN === "flash") setTrainingAttn("flash");
311
437
 
@@ -318,22 +444,27 @@ async function sftLoop(
318
444
 
319
445
  try {
320
446
  for (let step = 1; step <= cfg.iters; step++) {
321
- currentBatch = batches.next().value as SftBatch;
322
- const tokensThisStep = countResponseTokens(currentBatch);
323
-
324
447
  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();
448
+
449
+ // One optimizer step over cfg.gradAccumSteps micro-batches (pass-through
450
+ // when 1). afterMicroEval frees each micro-batch's gradient-checkpoint
451
+ // closures right after its backward so they don't pile up across
452
+ // accumulation.
453
+ let tokensThisStep = 0;
454
+ const { loss: lossVal, grads } = accumulateStep(
455
+ cfg.gradAccumSteps,
456
+ () => {
457
+ currentBatch = batches.next().value as SftBatch;
458
+ tokensThisStep += countResponseTokens(currentBatch);
459
+ return segmented ? segmented.step(currentBatch!) : vag!.apply(flatParams(lora));
460
+ },
461
+ ckptCtx
462
+ ? () => {
463
+ for (const ck of ckptCtx!.keepAlive) ck.dispose();
464
+ ckptCtx!.keepAlive.length = 0;
465
+ }
466
+ : undefined,
467
+ );
337
468
 
338
469
  const gradNorm = globalNorm(grads); // grads already materialized above
339
470
  opt.step(grads);
@@ -345,12 +476,6 @@ async function sftLoop(
345
476
  } else {
346
477
  clearCache();
347
478
  }
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
479
 
355
480
  if (step % cfg.stepsPerReport === 0 || step === 1) {
356
481
  const elapsed = (Date.now() - t0) / 1000;
@@ -467,28 +592,35 @@ async function dpoLoop(
467
592
 
468
593
  try {
469
594
  for (let step = 1; step <= cfg.iters; step++) {
470
- currentBatch = batches.next().value as DpoBatch;
471
595
  if (schedule) opt.lr = schedule(step);
472
596
 
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();
597
+ // One optimizer step over cfg.gradAccumSteps micro-batches (pass-through
598
+ // when 1). Reference log-probs are recomputed per micro-batch OUTSIDE the
599
+ // policy value_and_grad and read as detached constants inside it;
600
+ // afterMicroEval frees them once that micro-batch's grads are materialized.
601
+ const { loss: lossVal, grads } = accumulateStep(
602
+ cfg.gradAccumSteps,
603
+ () => {
604
+ currentBatch = batches.next().value as DpoBatch;
605
+ const refs = dpoRefLogps(model, lora, currentBatch);
606
+ refChosen = refs.refChosen;
607
+ refRejected = refs.refRejected;
608
+ return vag.apply(flatParams(lora));
609
+ },
610
+ () => {
611
+ refChosen?.dispose();
612
+ refRejected?.dispose();
613
+ refChosen = refRejected = null;
614
+ },
615
+ );
480
616
 
481
617
  const gradNorm = globalNorm(grads);
482
618
  opt.step(grads);
483
619
  opt.evalState();
484
-
485
- refChosen.dispose();
486
- refRejected.dispose();
487
- refChosen = refRejected = null;
488
620
  clearCache();
489
621
 
490
622
  if (step % cfg.stepsPerReport === 0 || step === 1) {
491
- const m = dpoMetrics(model, lora, currentBatch, cfg.dpoBeta);
623
+ const m = dpoMetrics(model, lora, currentBatch!, cfg.dpoBeta);
492
624
  const elapsed = (Date.now() - t0) / 1000;
493
625
  emit({
494
626
  type: "metric",
@@ -529,8 +661,250 @@ async function dpoLoop(
529
661
  } finally {
530
662
  vag.dispose();
531
663
  opt.dispose();
532
- refChosen?.dispose();
533
- refRejected?.dispose();
664
+ // Cast: refs are only ever assigned inside the accumulateStep closures, so
665
+ // TS's flow analysis narrows these to their `null` initializer here.
666
+ (refChosen as MlxArray | null)?.dispose();
667
+ (refRejected as MlxArray | null)?.dispose();
668
+ }
669
+ return { numIters: cfg.iters };
670
+ }
671
+
672
+ // ---------------------------------------------------------------------------
673
+ // ORPO loop (reference-free: 2 forwards/step, no reference pass)
674
+ // ---------------------------------------------------------------------------
675
+
676
+ async function orpoLoop(
677
+ model: RuntimeModel,
678
+ tok: LoadedTokenizer,
679
+ tmpl: ChatTemplate,
680
+ dataDir: string,
681
+ cfg: TrainConfig,
682
+ lora: TrainableLora,
683
+ emit: Emit,
684
+ ): Promise<{ numIters: number }> {
685
+ emit({ type: "stage", stage: "data", progress: 0.05, message: "loading preference dataset" });
686
+ const train = await loadDpoDataset(`${dataDir}/train.jsonl`, tok, tmpl, cfg.maxSeqLen);
687
+ const valid = (await fileExists(`${dataDir}/valid.jsonl`))
688
+ ? await loadDpoDataset(`${dataDir}/valid.jsonl`, tok, tmpl, cfg.maxSeqLen)
689
+ : [];
690
+
691
+ const params = flatParams(lora);
692
+ // LoRA+: B leaves (the second half of flatParams) get a higher LR than A.
693
+ const nT = lora.targets.length;
694
+ const lrScale = cfg.loraPlusRatio !== 1
695
+ ? params.map((_, i) => (i < nT ? 1 : cfg.loraPlusRatio))
696
+ : undefined;
697
+ if (lrScale) emit({ type: "stage", stage: "setup", progress: 0.04,
698
+ message: `LoRA+ : B leaves at ${cfg.loraPlusRatio}× LR` });
699
+ const opt = new AdamW(
700
+ params,
701
+ { lr: cfg.learningRate, betas: cfg.betas, eps: 1e-8, weightDecay: cfg.weightDecay, lrScale },
702
+ (i, p) => writeParam(lora, i, p),
703
+ );
704
+
705
+ const schedule =
706
+ cfg.orpoLrSchedule === "cosine" || cfg.orpoWarmupIters > 0
707
+ ? warmupCosineSchedule(cfg.learningRate, cfg.orpoWarmupIters, cfg.iters)
708
+ : null;
709
+
710
+ // Segmented backward for ORPO: stream chosen+rejected backwards segment-by-segment.
711
+ // MiniCPM5 + Gemma4 (e4b) — same model coverage as SFT segmented. B=1 only.
712
+ const useSegmented = cfg.segmentSize > 0;
713
+ // Prefix-sharing + segmented backward COMPOSE (M3-composition): the segmented
714
+ // backward streams the ONE prefix-shared concat forward [prompt; chosen; rejected]
715
+ // (block-sparse mask via PrefixSharedCache + block-wise RoPE via the prefix plan)
716
+ // segment-by-segment — so the prompt-encode-once saving holds AT long seq. Composed
717
+ // for BOTH MiniCPM5 and Gemma4/e4b (the e4b variant threads donor-KV + the
718
+ // logical-position sliding-window prefix mask through the segment boundaries).
719
+ const useSegmentedPrefix = cfg.orpoPrefixShared && useSegmented;
720
+ if (cfg.orpoPrefixShared && !(model instanceof MiniCPM5Model || model instanceof Gemma4Model))
721
+ throw new Error("orpoPrefixShared is only wired for MiniCPM5 and Gemma4");
722
+ // Fused linear-CE head INSIDE the segmented backward (orpo_fused_ce): bounds the
723
+ // head term to [chunk,V] alongside the per-segment layer savings. 0 = the
724
+ // full-[M,V] responseOnlyLogpMean head (unchanged). Reuses orpoChunkSize as the
725
+ // token-chunk (default 512), same as the non-segmented path.
726
+ // "flash implies fused": orpoFlashCe alone must still bound the segmented head to
727
+ // [chunk,V]. The non-prefix segmented class has no flash path, so flash routes
728
+ // through its fused/checkpoint chunked head (boundedHeadFromHidden) — never the
729
+ // full-[M,V] responseOnlyLogpMean, which would reintroduce [M,vocab] on e4b.
730
+ const segUsesFusedHead = cfg.orpoFusedCe || cfg.orpoFlashCe;
731
+ const segFusedChunk = segUsesFusedHead ? (cfg.orpoChunkSize > 0 ? cfg.orpoChunkSize : 512) : 0;
732
+ // The prefix segmented head gathers response positions then routes per-branch
733
+ // through the [M,V]-free flash/fused head (branchLogpMeanGathered, which takes a
734
+ // ChunkCtx). Its own sink is managed per-step inside the class.
735
+ const segPrefixChunk: ChunkCtx | undefined =
736
+ cfg.orpoFusedCe || cfg.orpoFlashCe
737
+ ? { chunkSize: cfg.orpoChunkSize > 0 ? cfg.orpoChunkSize : 512, fused: true, flash: cfg.orpoFlashCe, sink: [] }
738
+ : undefined;
739
+ let segmentedOrpo: SegmentedBackwardOrpo | SegmentedBackwardOrpoGemma4 | null = null;
740
+ let segmentedOrpoPrefix: SegmentedBackwardOrpoPrefix | SegmentedBackwardOrpoPrefixGemma4 | null = null;
741
+ if (useSegmented) {
742
+ const ranges = planSegmentsBySize(model.layers.length, cfg.segmentSize);
743
+ if (useSegmentedPrefix) {
744
+ // The composed prefix-shared segmented backward (MiniCPM5 or Gemma4/e4b), plus a
745
+ // plain segmented-ORPO fallback for rows whose chosen/rejected prompts differ
746
+ // (splitPrefixBatch returns null -> two-forward segmented step).
747
+ if (model instanceof MiniCPM5Model) {
748
+ segmentedOrpoPrefix = new SegmentedBackwardOrpoPrefix(model, lora, ranges, cfg.orpoLambda, segPrefixChunk);
749
+ segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
750
+ } else {
751
+ segmentedOrpoPrefix = new SegmentedBackwardOrpoPrefixGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segPrefixChunk);
752
+ segmentedOrpo = new SegmentedBackwardOrpoGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segFusedChunk);
753
+ }
754
+ emit({ type: "stage", stage: "setup", progress: 0.04,
755
+ 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)` });
756
+ } else if (model instanceof MiniCPM5Model)
757
+ segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
758
+ else if (model instanceof Gemma4Model)
759
+ segmentedOrpo = new SegmentedBackwardOrpoGemma4(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
760
+ else
761
+ throw new Error("segmented backward (segmentSize > 0) for ORPO is only wired for MiniCPM5 and Gemma4");
762
+ if (!useSegmentedPrefix) emit({ type: "stage", stage: "setup", progress: 0.04,
763
+ message: `orpo segmented backward: ${Math.ceil(model.layers.length / cfg.segmentSize)} segments of <=${cfg.segmentSize} layers${segFusedChunk > 0 ? ` + fused linear-CE head (${segFusedChunk}/chunk)` : ""}` });
764
+ }
765
+
766
+ // Token-chunked head (non-segmented path only): the per-chunk checkpoints are
767
+ // collected here and disposed after each micro-step's grads are eval'd (the
768
+ // afterMicroEval hook below) — that is when their backward recompute has run.
769
+ const chunkSink: Array<{ dispose(): void }> = [];
770
+ // Fused linear-CE head reuses orpoChunkSize as the token-chunk (default 512 when
771
+ // unset); otherwise the Checkpoint token-chunked head fires only when
772
+ // orpoChunkSize > 0. Both are non-segmented-path only.
773
+ const fusedChunkSize = cfg.orpoChunkSize > 0 ? cfg.orpoChunkSize : 512;
774
+ const chunk: ChunkCtx | undefined = useSegmented
775
+ ? undefined
776
+ : cfg.orpoFusedCe || cfg.orpoFlashCe
777
+ ? { chunkSize: fusedChunkSize, fused: true, flash: cfg.orpoFlashCe, sink: chunkSink }
778
+ : cfg.orpoChunkSize > 0
779
+ ? { chunkSize: cfg.orpoChunkSize, sink: chunkSink }
780
+ : undefined;
781
+ if (chunk) emit({ type: "stage", stage: "setup", progress: 0.04,
782
+ message: chunk.flash
783
+ ? `orpo flash-CCE (Metal kernel) head: ${chunk.chunkSize} tokens/chunk`
784
+ : chunk.fused
785
+ ? `orpo fused linear-CE head: ${chunk.chunkSize} tokens/chunk`
786
+ : `orpo chunked head: ${cfg.orpoChunkSize} tokens/chunk` });
787
+ if (cfg.orpoPrefixShared && !useSegmentedPrefix) emit({ type: "stage", stage: "setup", progress: 0.04,
788
+ message: `orpo prefix-sharing: single forward over [prompt; chosen; rejected] (two-forward fallback on prompt mismatch)` });
789
+ const disposeChunkSink = () => { for (const c of chunkSink) c.dispose(); chunkSink.length = 0; };
790
+
791
+ // No reference forward (the whole point of ORPO) — the closure just builds the
792
+ // monolithic L_NLL + λ·L_OR over the active batch against the swapped primals.
793
+ let currentBatch: DpoBatch | null = null;
794
+ const vag = useSegmented
795
+ ? null
796
+ : new ValueAndGrad((primals) => {
797
+ const saved = swapPrimals(lora, primals);
798
+ try {
799
+ // Prefix-sharing (B=1): one forward over [prompt; chosen; rejected] when the
800
+ // row's chosen/rejected share an identical prompt; else fall back two-forward.
801
+ if (cfg.orpoPrefixShared) {
802
+ const split = splitPrefixBatch(currentBatch!);
803
+ if (split) {
804
+ if (model instanceof MiniCPM5Model)
805
+ return orpoLossPrefixShared(model, split.promptIds, split.chosenResp, split.rejectedResp, cfg.orpoLambda, chunk);
806
+ return orpoLossPrefixSharedGemma(model as Gemma4Model, split.promptIds, split.chosenResp, split.rejectedResp, cfg.orpoLambda, chunk);
807
+ }
808
+ notePrefixFallback();
809
+ }
810
+ return orpoLoss(model, currentBatch!, cfg.orpoLambda, chunk);
811
+ } finally {
812
+ restorePrimals(lora, saved);
813
+ }
814
+ }, params.map((_, i) => i));
815
+
816
+ const padId = tok.eosTokenId ?? 0;
817
+ const batches = iterateDpoBatches(train, cfg.batchSize, cfg.seed, true, padId);
818
+ const t0 = Date.now();
819
+
820
+ let dropoutCounter = 0;
821
+ // Prefix-share falls back to the two-forward path for any row whose chosen/rejected
822
+ // prompts aren't byte-identical (splitPrefixBatch → null). Log the FIRST fallback +
823
+ // count them, so a silent loss of the prompt-encode-once saving is visible.
824
+ let prefixFallbacks = 0;
825
+ const notePrefixFallback = () => {
826
+ prefixFallbacks++;
827
+ if (prefixFallbacks === 1) emit({ type: "stage", stage: "train", progress: 0,
828
+ message: `prefix-share: row prompt mismatch → two-forward fallback for this row (saving lost only on mismatched rows; counting…)` });
829
+ };
830
+ try {
831
+ for (let step = 1; step <= cfg.iters; step++) {
832
+ if (schedule) opt.lr = schedule(step);
833
+
834
+ // One optimizer step over cfg.gradAccumSteps micro-batches (pass-through
835
+ // when 1) — the long-context lever for a larger effective batch without
836
+ // the B>1 activation memory (see docs/design/orpo-training.md, Batching).
837
+ const { loss: lossVal, grads } = accumulateStep(cfg.gradAccumSteps, () => {
838
+ currentBatch = batches.next().value as DpoBatch;
839
+ // Per-micro-step dropout seed: constant across this micro-step's forward
840
+ // AND its backward recompute (segmented/chunked), so the mask reproduces.
841
+ if (cfg.loraDropout > 0) model.loraState.dropoutSeed = ++dropoutCounter;
842
+ // Segmented + prefix-share: stream the single concat forward when the row's
843
+ // chosen/rejected share an identical prompt; else fall back to the plain
844
+ // two-forward segmented step (segmentedOrpo, built alongside).
845
+ if (segmentedOrpoPrefix) {
846
+ const split = splitPrefixBatch(currentBatch!);
847
+ if (split) return segmentedOrpoPrefix.stepPrefix(split.promptIds, split.chosenResp, split.rejectedResp);
848
+ notePrefixFallback();
849
+ return segmentedOrpo!.step(currentBatch!);
850
+ }
851
+ return segmentedOrpo ? segmentedOrpo.step(currentBatch!) : vag!.apply(flatParams(lora));
852
+ }, disposeChunkSink); // free this micro-step's head checkpoints once its grads are eval'd
853
+
854
+ model.loraState.dropoutSeed = null; // metrics/val below run dropout-free
855
+ const gradNorm = globalNorm(grads);
856
+ opt.step(grads);
857
+ opt.evalState();
858
+ clearCache();
859
+
860
+ if (step % cfg.stepsPerReport === 0 || step === 1) {
861
+ // Diagnostics recompute the two forwards (like dpoMetrics) — cheap at
862
+ // B=1 (response-only head, no full logits) and gated by stepsPerReport.
863
+ // Reusing the value_and_grad forward is a shared-with-DPO follow-on.
864
+ const m = orpoMetrics(model, currentBatch!, cfg.orpoLambda);
865
+ const elapsed = (Date.now() - t0) / 1000;
866
+ emit({
867
+ type: "metric",
868
+ kind: "train",
869
+ step,
870
+ loss: lossVal,
871
+ grad_norm: gradNorm,
872
+ learning_rate: opt.lr,
873
+ accuracy: m.accuracy,
874
+ margin: m.margin,
875
+ tokens_per_sec: 0,
876
+ progress: step / cfg.iters,
877
+ 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)}`,
878
+ });
879
+ clearCache();
880
+ }
881
+
882
+ if (valid.length > 0 && step % cfg.stepsPerEval === 0) {
883
+ let vl = 0, va = 0, vm = 0, vn = 0;
884
+ for (const ex of valid) {
885
+ const vb: DpoBatch = {
886
+ chosenIds: [ex.chosenIds], rejectedIds: [ex.rejectedIds],
887
+ chosenMask: [ex.chosenMask], rejectedMask: [ex.rejectedMask],
888
+ };
889
+ const m = orpoMetrics(model, vb, cfg.orpoLambda);
890
+ vl += m.loss; va += m.accuracy; vm += m.margin; vn++;
891
+ clearCache();
892
+ }
893
+ emit({
894
+ type: "metric", kind: "val", step,
895
+ loss: vn ? vl / vn : 0,
896
+ accuracy: vn ? va / vn : 0,
897
+ margin: vn ? vm / vn : 0,
898
+ progress: step / cfg.iters,
899
+ });
900
+ }
901
+ }
902
+ } finally {
903
+ disposeChunkSink(); // defensive: free any head checkpoints a throw left behind
904
+ segmentedOrpoPrefix?.dispose();
905
+ segmentedOrpo?.dispose();
906
+ vag?.dispose();
907
+ opt.dispose();
534
908
  }
535
909
  return { numIters: cfg.iters };
536
910
  }
@@ -570,6 +944,89 @@ function writeParam(lora: TrainableLora, i: number, p: MlxArray): void {
570
944
  else lora.targets[i - n]!.lw.b = p;
571
945
  }
572
946
 
947
+ /** Run one optimizer step's worth of gradient over `accumSteps` micro-batches:
948
+ * backward each micro-batch, mean-accumulate the per-micro grads (each scaled
949
+ * by 1/accumSteps), and return the mean loss + summed grads — a drop-in for the
950
+ * single value_and_grad the loops used to call inline. The caller passes the
951
+ * returned grads to opt.step (which takes ownership and disposes them).
952
+ *
953
+ * accumSteps <= 1 is a pass-through identical to the pre-accumulation path (one
954
+ * micro-batch, no scaling) so the proven B=1 loops stay byte-for-byte unchanged.
955
+ * For accumSteps > 1 the running accumulator (and that micro-batch's loss) is
956
+ * eval'd before the next micro-batch's forward builds, so only ONE micro-batch's
957
+ * activations are ever live — peak memory does NOT grow with accumSteps (the
958
+ * whole point: a larger effective batch without the B>1 activation memory).
959
+ *
960
+ * The combined `evalAll([value, ...grads])` per micro-batch is deliberate: a
961
+ * separate eval of `value` first would materialize then free the forward,
962
+ * forcing the backward to recompute and hold every layer's activations — which
963
+ * silently defeats gradient checkpointing (verified). Keep loss + grads in one
964
+ * eval.
965
+ *
966
+ * `afterMicroEval` runs once per micro-batch right after its grads are
967
+ * materialized (its backward is complete), in BOTH the pass-through and
968
+ * accumulating paths — it frees per-micro-batch state the loss closure captured
969
+ * (gradient-checkpoint closures for SFT, reference log-probs for DPO) so that
970
+ * state does not pile up across accumulation.
971
+ *
972
+ * `micro` returns the micro-batch loss scalar + grads in flatParams order
973
+ * ([...A, ...B] over targets); it transfers ownership of both to this helper,
974
+ * which disposes the per-micro value and grads. */
975
+ export function accumulateStep(
976
+ accumSteps: number,
977
+ micro: () => { value: MlxArray; grads: MlxArray[] },
978
+ afterMicroEval?: () => void,
979
+ ): { loss: number; grads: MlxArray[] } {
980
+ if (accumSteps <= 1) {
981
+ const { value, grads } = micro();
982
+ evalAll([value, ...grads]);
983
+ const loss = value.toFloat32()[0]!;
984
+ value.dispose();
985
+ afterMicroEval?.();
986
+ return { loss, grads };
987
+ }
988
+
989
+ const scale = 1 / accumSteps;
990
+ let acc: MlxArray[] | null = null;
991
+ let lossSum = 0;
992
+ try {
993
+ for (let i = 0; i < accumSteps; i++) {
994
+ const { value, grads } = micro();
995
+ if (acc === null) {
996
+ acc = grads.map((g) => {
997
+ const s = ops.mulScalar(g, scale);
998
+ g.dispose();
999
+ return s;
1000
+ });
1001
+ } else {
1002
+ const prev = acc;
1003
+ acc = grads.map((g, j) => {
1004
+ const s = ops.mulScalar(g, scale);
1005
+ const sum = ops.add(prev[j]!, s);
1006
+ s.dispose();
1007
+ prev[j]!.dispose();
1008
+ g.dispose();
1009
+ return sum;
1010
+ });
1011
+ }
1012
+ // Eval the running accumulator (+ this micro-batch's loss) now so the
1013
+ // micro-batch's forward/backward graph frees before the next one builds —
1014
+ // this is what keeps peak flat across accumSteps.
1015
+ evalAll([value, ...acc]);
1016
+ lossSum += value.toFloat32()[0]!;
1017
+ value.dispose();
1018
+ afterMicroEval?.();
1019
+ clearCache();
1020
+ }
1021
+ const grads = acc!;
1022
+ acc = null; // ownership handed to the caller
1023
+ return { loss: lossSum / accumSteps, grads };
1024
+ } finally {
1025
+ // Non-null only if a micro-batch threw mid-accumulation (nulled on success).
1026
+ if (acc) for (const a of acc) a.dispose();
1027
+ }
1028
+ }
1029
+
573
1030
  /** L2 norm over all grads (also forces eval). */
574
1031
  function globalNorm(grads: MlxArray[]): number {
575
1032
  const partial: MlxArray[] = grads.map((g) => {