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
@@ -0,0 +1,1199 @@
1
+ # ORPO training — design
2
+
3
+ Plan to add **ORPO** (Odds Ratio Preference Optimization, Hong et al. 2024)
4
+ as a third training `method` alongside `sft` and `dpo`. Status: **in production —
5
+ flash-CCE head (steel GEMM fwd+bwd) + prefix-sharing + segmented backward, composed
6
+ (see the STATUS callout below).**
7
+
8
+ ORPO folds the SFT objective and preference learning into a single
9
+ **reference-free** loss. Because it needs no reference model, it is a *smaller*
10
+ addition than DPO was — it reuses DPO's data format, loader, and batching, and
11
+ runs half the forwards per step.
12
+
13
+ > **STATUS (2026-06-19) — the ORPO stack is in PRODUCTION.** The flash-CCE head is
14
+ > live in both directions (verbatim MLX **steel** GEMM + ORPO epilogue; `[M,vocab]`-free;
15
+ > e4b backward **754 ms**, peak **0.93 GB flat @ M=8192**; `orpo_flash_ce`), prefix-sharing
16
+ > is trainer-wired (`orpo_prefix_shared`) and **composed with the flash head AND the
17
+ > segmented backward for both MiniCPM5 and e4b/Gemma4**, and adapter **warm-start**
18
+ > (`RESUME=`) landed — all default-on in [`scripts/train-orpo.ts`](../../scripts/train-orpo.ts).
19
+ > The ✅/🔬 entries + optimization log below are the **design/build history**; for the
20
+ > current usage, flags, and numbers see [training.md](../reference/training.md) +
21
+ > [orpo-quickstart.md](../reference/orpo-quickstart.md), and the live engineering record
22
+ > [steel-flash-cce-handoff.md](../investigations/steel-flash-cce-handoff.md).
23
+
24
+ > Oracle note: neither `mlx_lm` nor `optiq` ships ORPO, so — unlike DPO
25
+ > ([`loss.ts`](../../src/train/loss.ts) ported `optiq/lora/dpo.py`) — ORPO is
26
+ > an **L3 mlx-bun original**. Correctness is anchored to a standalone
27
+ > torch/numpy reference of the paper/TRL math (see [Parity](#parity)), not a
28
+ > bit-exact upstream port.
29
+
30
+ ## Implementation status
31
+
32
+ **Landed (naïve correctness path — the shippable unit + the oracle everything
33
+ else validates against):**
34
+ - ✅ Mixed-precision rank scaling wired (`bitsMapFromModel` / `readPerLayerKl` →
35
+ `resolveRanks`); unit-tested ([`tests/train-rank.test.ts`](../../tests/train-rank.test.ts)).
36
+ - ✅ ORPO loss: `branchLogpMean` (with the **response-only B=1 forward** — no
37
+ full `[1,T,V]` logits, mirroring the SFT path), `log1mexp`, `orpoLogOdds`,
38
+ `orpoLossFromLogps`, `orpoLoss`, `orpoMetrics` in
39
+ [`loss.ts`](../../src/train/loss.ts). Math validated vs a standalone JS
40
+ reference + autograd differentiability
41
+ ([`tests/train-orpo.test.ts`](../../tests/train-orpo.test.ts)).
42
+ - ✅ `orpoLoop` + dispatch + `orpoLambda`/`orpoWarmupIters`/`orpoLrSchedule`
43
+ config; `job.ts` submit surface + `1e-5` LR default; `/finetune` UI method
44
+ picker + λ/schedule panel.
45
+ - ✅ End-to-end verified on MiniCPM5-1B (gated
46
+ [`tests/train-orpo-e2e.test.ts`](../../tests/train-orpo-e2e.test.ts)): loss
47
+ improves, preference accuracy reported, adapter saved + mountable, adapted
48
+ generations differ.
49
+ - ✅ Docs: [training.md](../reference/training.md) updated.
50
+
51
+ **Also landed:**
52
+ - ✅ Dataset format renamed `"dpo"` → `"preference"` (with `"dpo"` kept as alias in
53
+ the type); [`tests/train-dataset.test.ts`](../../tests/train-dataset.test.ts) updated.
54
+ - ✅ Parity oracle script
55
+ [`scripts/experiments/parity-orpo.ts`](../../scripts/experiments/parity-orpo.ts):
56
+ 10/10 checks pass — scalar loss, log1mexp stability, gradient checks (finite
57
+ differences vs autograd) at B=1 and B=3, edge cases.
58
+ - ✅ `fusedGegluDifferentiable` in
59
+ [`src/model/fused-geglu-kernel.ts`](../../src/model/fused-geglu-kernel.ts):
60
+ `CustomVjp`-backed version with hand-derived backward (grad\_b = dc·gelu(a),
61
+ grad\_a = dc·b·gelu'(a)); tested in
62
+ [`tests/fused-geglu-vjp.test.ts`](../../tests/fused-geglu-vjp.test.ts).
63
+ - ✅ **Fused GeGLU wired into the e4b training path.** A training-mode flag
64
+ (`setFusedGeluTraining`, set by the trainer around the loop, cleared in its
65
+ finally) makes BOTH Gemma GeGLU sites — the MLP (`gelu(gate)·up`) and the
66
+ per-layer-input gate (`gelu(gate)·pli`) — use the differentiable wrapper during
67
+ training and the plain `fusedGeglu` (byte-for-byte unchanged) at inference.
68
+ **This removes the `MLX_BUN_FUSED_GELU=0` requirement for training** (the plain
69
+ kernel is a bare `CustomKernel` with no vjp; feeding it to autograd threw
70
+ `[Primitive::vjp] Not implemented for CustomKernel`). Bonus: the backward
71
+ recomputes the gelu from the primal instead of retaining the ~9 spelled-out
72
+ intermediates, lowering the MLP activation memory the backward holds (the
73
+ dominant resident at long context under `ops.sdpa`). **Validated** on real e4b
74
+ ([`scripts/experiments/fused-geglu-train-parity.ts`](../../scripts/experiments/fused-geglu-train-parity.ts)):
75
+ forward loss bit-identical to the spelled-out path (kernel is kl=0), both sites
76
+ dispatch (168 = 42 layers × 2 sites × 2 ORPO branches), spelled arm fused-free,
77
+ grads match in the kernel's bf16 class (~2%; the forward is exact, the backward
78
+ is two bf16 computations of the same gelu derivative — the hand-vjp is validated
79
+ to the closed form at tol 0.05 in the vjp test). Composes with the segmented
80
+ path (the fused `CustomVjp` nests inside the segmented `mlx_vjp`: segmented ORPO
81
+ still PASSES with fused on, peak 11.25 GB full → 7.58 GB segmented). Unit guard
82
+ ([`tests/fused-geglu-vjp.test.ts`](../../tests/fused-geglu-vjp.test.ts)): the
83
+ flag toggles and the differentiable forward bit-matches the plain kernel.
84
+ Training still needs `MLX_BUN_PERF_KERNEL=0` (a separate no-vjp perf kernel —
85
+ its own follow-on).
86
+ - ✅ `responseOnlyLogpMean` helper in `loss.ts` (post-finalNorm h → mean response
87
+ logp; used by the ORPO head VJP).
88
+ - ✅ `SegmentedBackwardOrpo` in
89
+ [`src/train/segmented.ts`](../../src/train/segmented.ts): MiniCPM5 segmented
90
+ backward for ORPO — runs chosen+rejected forwards segment-by-segment, two-input
91
+ head VJP (`new Vjp(fn, 1)` with `apply([hC, hR], [one])` → vjps[0]=dh\_c,
92
+ vjps[1]=dh\_r), backward passes sum LoRA grads from both branches. Wired into
93
+ `orpoLoop` behind the existing `segmentSize > 0` gate.
94
+ - ✅ **Token-chunked LM head (the large-vocab moat):** `chunkedLogpMeanB1` +
95
+ `ChunkCtx` in [`loss.ts`](../../src/train/loss.ts), gated by `orpoChunkSize`
96
+ (`TrainConfig` / `orpo_chunk_size`). Each token-chunk's head is wrapped in a
97
+ `Checkpoint` so its `[chunk, vocab]` logits are recomputed in the backward
98
+ instead of all `[M, vocab]` retained — bounding the dominant large-vocab term
99
+ to `[chunkSize, vocab]`. The per-chunk checkpoints, plus the hidden/slices they
100
+ read (a Checkpoint *drops* its input activations, so freeing them early
101
+ corrupts the result — found and fixed during bring-up), live in a `sink`
102
+ disposed by `orpoLoop` via `accumulateStep`'s `afterMicroEval` hook (after the
103
+ grads are eval'd). Verified gated
104
+ ([`tests/train-orpo-chunked.test.ts`](../../tests/train-orpo-chunked.test.ts)):
105
+ forward parity vs non-chunked within **bf16 tolerance** (the only divergence is
106
+ occasional single-ULP head-matmul rounding that tiles differently for `[M,V]`
107
+ vs `[chunk,V]` — sub-bf16, the established class), **exact** for a single
108
+ chunk, and the chunked backward trains + improves loss. This is **token-axis**
109
+ chunking — the high-impact win for our case (`M` up to thousands × 262k vocab).
110
+ **Vocab-axis** chunking + online-LSE (CCE-style) is unnecessary at 262k
111
+ (`[1, 262k]` bf16 ≈ 0.5 MB) and remains a future tier.
112
+
113
+ - ✅ **Fused linear-CE head (Liger `FusedLinearCrossEntropy` ported to MLX)** —
114
+ `fusedLogpMeanB1` (`loss.ts`), gated by `orpo_fused_ce`. A single `CustomVjp`
115
+ with an ANALYTIC `softmax−onehot` backward (no autograd through logsumexp+gather,
116
+ no retained `[M,V]` logits in either direction); `dh` via mlx's own
117
+ quantized-matmul x-vjp (`transpose=false`); Gemma softcap matched bit-exactly to
118
+ `logitSoftcap`. Value BIT-EXACT vs the full-logits head on MiniCPM5 **and** e4b;
119
+ grads 0.51%/1.97% bf16-class; peak `[chunk,V]`-bounded. See
120
+ [lever 1](#ranked-levers-with-our-setup-verdicts) for the full write-up. This is
121
+ the deeper engine the token-chunked Checkpoint head approximated.
122
+
123
+ - ✅ **Benchmark + parity harness** (the perf-tracking discipline):
124
+ [`scripts/bench-orpo.ts`](../../scripts/bench-orpo.ts) (ms/step + peak GB
125
+ across head/segment configs on synthetic long-response data) and
126
+ [`scripts/bench-attn-backward.ts`](../../scripts/bench-attn-backward.ts)
127
+ (attention-backward dQ/dK/dV parity vs a materialized reference + timing).
128
+ MiniCPM5-1B @ SEQ=1024: baseline 3191 ms / 9.34 GB · chunked-head(256) **2758
129
+ ms / 8.62 GB** (faster + lower) · segmented(4) 3633 ms / **2.45 GB** (the
130
+ dominant memory lever — layer activations, not the head, dominate at this
131
+ scale/vocab; the head win grows with vocab, e.g. Gemma's 262k).
132
+ - ✅ **SDPA backward — flash kernel FIXED to L2 parity (kept, not deleted).**
133
+ Parity oracle, made explicit: the default `ops.sdpa` training path is **L1**
134
+ (mlx-lm's tuner differentiates the same `mx.fast.sdpa`); the flash kernel is
135
+ **L2** — a port of mlx-optiq's `flash_attention_metal`, so **optiq is its
136
+ oracle**. Confirmed end to end: optiq's flash dK == `ops.sdpa` to f16
137
+ (`scripts/flash-optiq-check.py`, rel **0.0%**), and mlx-bun's fixed flash ==
138
+ `ops.sdpa` (`tests/flash-attention.test.ts`, ≤f16) ⟹ **mlx-bun flash == optiq
139
+ (L2 parity)**; `ops.sdpa` itself is finite-difference verified. The original
140
+ kernel diverged ~100% from optiq on dK — a genuine **port bug**: a spurious dK
141
+ transpose in `flashBackward` (corrupted dK for Tkv≠D) + a per-thread
142
+ (divergent) `threadgroup_barrier` in the dQ causal tile-skip; both fixed. The
143
+ real `ops.sdpa`-vs-flash tradeoff is **memory**: `ops.sdpa` backward is
144
+ **O(L²)** (0.02→0.33→1.40 GB at T=512/2048/4096), flash is **O(L)**
145
+ (0.03→0.23→0.90 GB) but ~30× slower → flash is the opt-in long-context path.
146
+ **Lessons:** (1) state parity *to a named oracle* (L1 mlx-lm / L2 mlx-optiq),
147
+ not "correct"; (2) "fix, don't delete" — the kernel had a localized, fixable
148
+ port bug + a real memory purpose; (3) a parity reference is trustworthy only if
149
+ read **contiguously** (`ops.contiguous` before `toFloat32`) with a
150
+ **non-uniform cotangent** — both pitfalls produced false "kernel is wrong"
151
+ signals; finite differences settled it.
152
+ - ✅ **Regularization knobs (the small-set overfitting toolkit):** **LoRA-input
153
+ dropout** (`lora_dropout`) — recompute-safe (mask keyed by step+layer so the
154
+ segmented/checkpoint backward reproduces it; verified by a finite+improving
155
+ loss through the segmented recompute path); **rsLoRA** (`rs_lora`, α/√rank,
156
+ wired train→save→load so inference applies the same per-layer scale); **LoRA+**
157
+ (`lora_plus_ratio`, per-param LR in AdamW — B leaves get the higher LR).
158
+ Unit-tested ([`tests/train-regularization.test.ts`](../../tests/train-regularization.test.ts))
159
+ + gated e2e with all three on through segmented recompute
160
+ ([`tests/train-regularization-e2e.test.ts`](../../tests/train-regularization-e2e.test.ts)).
161
+ The dangling `/finetune` dropout field is now wired (`lora_dropout`).
162
+ - ✅ **Segmented backward for ORPO on e4b** (`SegmentedBackwardOrpoGemma4`,
163
+ [`src/train/segmented.ts`](../../src/train/segmented.ts)): the cross of
164
+ `SegmentedBackwardGemma4` (per-layer-input boundary + cross-segment KV-shared
165
+ donor cotangent threading) and `SegmentedBackwardOrpo` (two branches +
166
+ reference-free ORPO head). Each branch streams forward saving detached
167
+ hidden + reused-donor K/V boundaries + its own masks/per-layer constant; the
168
+ ORPO head vjp over `[last_chosen, last_rejected]` yields `[dh_c, dh_r]`; each
169
+ branch walks backward in reverse over its segments (donor cotangents
170
+ accumulated in a per-branch `dKV`), and the two branches' LoRA grads are
171
+ summed. Wired into `orpoLoop` (`segment_size > 0`, B=1) alongside the MiniCPM5
172
+ path. **Validated** ([`scripts/experiments/segmented-grad-test-orpo-e4b.ts`](../../scripts/experiments/segmented-grad-test-orpo-e4b.ts),
173
+ grad-parity vs the monolithic ORPO `value_and_grad` on real e4b, 2026-06-18):
174
+ loss matches to ~6 decimals; at **λ=0 the path is byte-identical** to the SFT
175
+ `SegmentedBackwardGemma4` (relNorm 1.1341%, maxAbs 3.79e-3 — same numbers,
176
+ proving zero added error); the donor-KV recipients (layers 22/23 k/v_proj,
177
+ the direct dKV recipients) stay clean (<0.5%), proving the cross-segment donor
178
+ threading; overall match sits in the e4b segmented bf16-class (~1.1% flash /
179
+ ~2.0–2.3% sdpa, the same level the SFT path shows here). **Peak**: full
180
+ two-branch ORPO backward **12.67 GB → segmented 7.66 GB** at L=256 (saved
181
+ 5 GB — a bigger win than SFT's 2.4 GB, since the two-branch full backward
182
+ holds both branches at once); 8.71 GB at L=1024 where the full backward
183
+ won't fit.
184
+ - ✅ **Shared prompt-prefix reference (lever 7, MiniCPM5)** in
185
+ [`src/train/prefix-shared.ts`](../../src/train/prefix-shared.ts): one forward
186
+ over `[prompt; chosen; rejected]` with a block-sparse mask + block-wise RoPE.
187
+ Validated 2026-06-18 — **forward BIT-EXACT** vs the two-forward path, grads in
188
+ the bf16 class (~1.05%), **1.78× fewer token-passes**, peak 5.40 → 3.80 GB. See
189
+ [lever 7](#ranked-levers-with-our-setup-verdicts) for the full write-up.
190
+ **LANDED (2026-06-19):** wired into the trainer (`orpo_prefix_shared`), composed
191
+ with the flash-CCE head per branch AND with the segmented backward
192
+ (`SegmentedBackwardOrpoPrefix`); the e4b port is composed too (next entry).
193
+ - ✅ **e4b prefix-shared port — LANDED + composed with the segmented backward
194
+ (`SegmentedBackwardOrpoPrefixGemma4`, 2026-06-19); the length-sensitivity below is an
195
+ accepted numerical caveat for a fine-tune, not a blocker** (seg-vs-non-seg-same-prefix
196
+ grads match to 1.7–2.3%, loss bit-exact-class). Ported to Gemma e4b
197
+ (`setGemmaPrefixPlan` + block-wise RoPE in [`gemma4.ts`](../../src/model/gemma4.ts)
198
+ `Attention.ropeBlocks`; `blockSparsePrefixMaskGemma` with a LOGICAL-position
199
+ sliding-window cut + `Gemma4PrefixSharedCache` + `orpoLossPrefixSharedGemma` in
200
+ [`prefix-shared.ts`](../../src/train/prefix-shared.ts), reusing
201
+ `forwardHidden` for per-layer-input + donor-KV). **The construction is proven
202
+ correct**: the prefix-shared forward is BIT-EXACT to a plain forward over the
203
+ same concatenated sequence (`scripts/experiments/prefix-shared-e4b-localize.ts`:
204
+ block-wise-RoPE+block-mask path `(c)` == plain-concat path `(b)` to 0.00% at
205
+ every chosen position; per-layer-inputs bit-exact across lengths). **But it does
206
+ NOT numerically reproduce the two-forward path on e4b**: prefix-shared forward
207
+ loss diverges ~0.08% and the chosen/rejected hiddens up to ~3-14% from the two
208
+ separate `[prompt;resp]` forwards — because **the e4b full-forward is itself
209
+ length-sensitive**. Demonstrated model-only, independent of prefix-sharing:
210
+ `forward([prompt])` vs `forward([prompt;extra])` differ **1.7% at position 0**
211
+ (one attention key — softmax=1.0, cannot reassociate) up to **~14%** at later
212
+ prompt positions, even though those positions are causally independent of the
213
+ appended tokens, and even through the **production `TrainingCache`** path (fused
214
+ causal). Root cause = e4b's **scale=1.0 attention** (headDim 256, q/k normed →
215
+ q·k ~N(0,256) → sharply peaked softmax) amplifying sub-bf16 matmul-tiling
216
+ roundings that vary with the row count — the SAME amplification class documented
217
+ for the e4b SigLIP vision tower ([[siglip-vision-parity-cross-build]]). MiniCPM5
218
+ (standard `1/√d` scale) shows 0.00% length-sensitivity, which is why its
219
+ prefix-shared path is bit-exact to two-forward. **Consequence:** on e4b,
220
+ prefix-sharing is a *self-consistent* computation (correct gradients of a valid
221
+ single-concat loss; 1.78× fewer prompt-token passes, peak proportionally lower)
222
+ but is NOT a drop-in numerical equivalent of two-forward training the way it is
223
+ on MiniCPM5. **Decision pending** (Josh): land it framed via the
224
+ single-concat-forward oracle / defer / investigate the length-sensitivity
225
+ further. Code is committed-ready; the parity script's two-forward comparison is
226
+ expected to show the e4b-class divergence, not bit-exactness.
227
+
228
+ **Follow-on (each validated against the naïve path via the
229
+ [oracle ladder](#parity)):** **optimize** the (now-correct) flash kernel — it's
230
+ ~30× slower than `ops.sdpa` because it's a naive scalar Metal kernel (no
231
+ `simdgroup_matrix`, 32-thread threadgroups → ~0.5% of GPU peak), whereas
232
+ `ops.sdpa` is MLX's hand-tuned simdgroup-matrix flash. Its O(L) memory only pays
233
+ off when `ops.sdpa`'s O(L²) won't fit (≳16K context). **Note (future research
234
+ spike):** MLX is open source — we can read exactly how
235
+ `mx.fast.scaled_dot_product_attention` is implemented (simdgroup-matrix tiling),
236
+ match it, and then potentially **beat it for OUR specific use case** (fixed
237
+ head-dims, training-only backward, our seq-len/precision regime, the O(L)
238
+ memory constraint they don't optimize for). Not now — `ops.sdpa` is the fast
239
+ default and the current training (`ft-e4b-v2.sh`, no `MLX_BUN_TRAIN_ATTN`) uses
240
+ it for both forward and backward; the flash kernel is the dormant opt-in
241
+ fallback. Then: fused GeGLU wired into model training path,
242
+ intra-layer MLP split, shared prompt-prefix.
243
+ **End goal:** a before/after ORPO quality experiment on Gemma e4b or Qwen-4B
244
+ (curated preference set + a benchmark eval).
245
+
246
+ ## The objective
247
+
248
+ $$\mathcal{L}_{ORPO} = \mathcal{L}_{NLL}(y_w) + \lambda \cdot \mathcal{L}_{OR}$$
249
+
250
+ - $\mathcal{L}_{NLL}(y_w)$ — standard SFT cross-entropy on the **chosen**
251
+ response (prompt positions masked out), i.e. the negative mean response
252
+ log-prob $-\ell_w$.
253
+ - $\mathcal{L}_{OR} = -\log \sigma(\text{log\_odds})$, the odds-ratio term:
254
+
255
+ $$\text{log\_odds} = (\ell_w - \ell_r) - \big(\text{log1mexp}(\ell_w) - \text{log1mexp}(\ell_r)\big)$$
256
+
257
+ where $\text{log1mexp}(x) = \log(1 - e^{x})$, and $\ell_w, \ell_r$ are the
258
+ **length-normalized** (mean over response tokens) log-probabilities of the
259
+ chosen / rejected responses. $\lambda$ weights the preference term.
260
+
261
+ ### Two facts that shape the implementation
262
+
263
+ 1. **Length normalization (mean, not sum).** This is the one substantive
264
+ difference from our DPO code, whose `seqLogp` returns the per-row *sum* of
265
+ response log-probs. ORPO requires the *mean*: $\ell = (\sum_t \log p_t) /
266
+ (\#\text{response tokens})$. Faithful to the paper and TRL; summing instead
267
+ would bias toward shorter responses.
268
+
269
+ 2. **The chosen forward does double duty.** Since
270
+ $\mathcal{L}_{NLL}(y_w) = -\ell_w$, the same $\ell_w$ feeds both the SFT term
271
+ and the odds ratio. So ORPO is **2 forwards/step** (chosen + rejected),
272
+ versus DPO's **4** (2 reference at scale 0 + 2 policy). No reference model,
273
+ no `setLoraScale(0)` dance, no detached reference tensors — lighter on
274
+ compute and memory than DPO.
275
+
276
+ ## How it slots into the current trainer
277
+
278
+ Training is already method-dispatched, so ORPO is additive:
279
+
280
+ - [`trainLora`](../../src/train/trainer.ts) dispatches on `cfg.method`
281
+ (`dpo` → `dpoLoop`, else `sftLoop`). Add an `orpo` branch → `orpoLoop`.
282
+ - All loops share the same scaffold: `resolveRanks` → `buildTrainableLora` →
283
+ `AdamW` → a `ValueAndGrad` closure that swaps LoRA A/B primals so only those
284
+ leaves differentiate. ORPO reuses it unchanged.
285
+ - Data: ORPO consumes the existing **preference** format
286
+ `{prompt, chosen, rejected}` (today labeled `dpo`), via `loadDpoDataset` /
287
+ `iterateDpoBatches` / `DpoBatch`. No new dataset code.
288
+
289
+ ## File-by-file changes
290
+
291
+ ### `src/train/loss.ts` (the only real new logic)
292
+
293
+ - **`branchLogpMean(model, ids, mask, validLen)` → `[B]`** — like `branchLogp`
294
+ but returns the **mean** response log-prob per row (sum / mask-count).
295
+ - B=1 uses a **response-only forward** (mirrors `responseOnlyCe`):
296
+ `trainForwardHidden` → `sliceDynamic` the response span
297
+ `[promptLen-1, len-1)` → `logitsFromHidden` on just that slice → gather the
298
+ target log-probs → divide by `M` response tokens. This avoids
299
+ materializing the `[1, T, V]` logits (and its grad) — the dominant
300
+ long-context memory term — exactly as the SFT path already does.
301
+ - B>1 falls back to full-logits `branchLogp` + per-row mean (DPO/ORPO default
302
+ `batch_size` is 1, so this is the rare path).
303
+ - **`log1mexp(x)`** — stable `log(1 - exp(x))`. We have
304
+ `exp/sub/log/clip/softplus/where/less` but **no `expm1`/`log1p`**. With
305
+ length-normalized average log-probs, `x` is comfortably negative (per-token
306
+ mean log-prob is well below 0), so `log(clip(1 - exp(x), eps, 1))` with a
307
+ small `eps` is numerically safe; comment the near-zero edge case.
308
+ - **`orpoLoss(model, batch, lambda)` → scalar** —
309
+ `ℓw = branchLogpMean(chosen)`, `ℓr = branchLogpMean(rejected)`,
310
+ `nll = mean(-ℓw)`, `logOdds = (ℓw-ℓr) - (log1mexp(ℓw)-log1mexp(ℓr))`,
311
+ `or = mean(softplus(-logOdds))`, return `nll + lambda*or`. Caller owns.
312
+ - **`orpoMetrics(model, lora, batch, lambda)`** (no grad) — loss with its
313
+ `nll` / `or` split, preference accuracy (`ℓw > ℓr`), and reward margin. No
314
+ reference forward.
315
+
316
+ ### `src/train/trainer.ts`
317
+
318
+ - `TrainConfig`: add `orpoLambda: number`; extend `method` to
319
+ `"sft" | "dpo" | "orpo"`.
320
+ - `DEFAULT_TRAIN_CONFIG`: `orpoLambda: 0.1` (TRL default; paper uses 0.1–1.0).
321
+ - `trainLora`: dispatch `method === "orpo"` → `orpoLoop`.
322
+ - `orpoLoop` — a `dpoLoop` clone **minus** `dpoRefLogps` and the `refChosen`/
323
+ `refRejected` tensors. The `ValueAndGrad` closure calls `orpoLoss` directly.
324
+ Reuse `warmupCosineSchedule`, driven by **`orpo`-prefixed** config knobs (see
325
+ below). Emit the same train/val metrics, plus the nll/or split from
326
+ `orpoMetrics`.
327
+
328
+ **Method-prefixed params (decided).** Schedule/strength knobs are namespaced
329
+ per method so each set reads naturally for its method — `dpo_*` for DPO,
330
+ `orpo_*` for ORPO. Add to `TrainConfig`: `orpoLambda`, `orpoWarmupIters`,
331
+ `orpoLrSchedule` (mirroring the `dpo*` trio). `orpoLoop` reads the `orpo*`
332
+ knobs; `dpoLoop` keeps reading `dpo*`. No shared/renamed fields.
333
+
334
+ ### `src/train/job.ts`
335
+
336
+ - `FinetuneSubmit.method`: add `"orpo"`; add `orpo_lambda?: number`.
337
+ - `parseConfig`: pass `orpoLambda` / `orpoWarmupIters` / `orpoLrSchedule`; LR
338
+ default branch `method === "orpo" ? 1e-5 : ...` (see Recommended defaults —
339
+ **not** DPO's `5e-5`).
340
+
341
+ ### `src/train/dataset.ts`
342
+
343
+ - Minimal. ORPO loads via `loadDpoDataset`. Accept `probeFormat` returning the
344
+ preference shape for `method: "orpo"`. Optionally introduce a user-facing
345
+ `"preference"` label with `dpo` kept as an alias, so the format name is not
346
+ method-specific.
347
+
348
+ ### Surface
349
+
350
+ - [`docs/reference/training.md`](../reference/training.md): add ORPO to the
351
+ "SFT vs DPO" section (→ "SFT / DPO / ORPO"), the data-format table (the
352
+ preference format serves both), and the config table (`orpo_lambda`).
353
+ - `/finetune` web UI: add `orpo` to the method picker; show λ when selected.
354
+
355
+ ## Recommended defaults
356
+
357
+ From a hyperparameter spike on ORPO-for-LoRA-on-small-models (paper, TRL
358
+ `ORPOConfig`, and Labonne's Llama-3 ORPO-LoRA recipe). The one trap: the
359
+ paper's `8e-6` LR is for **full** fine-tuning — do not inherit it for LoRA, and
360
+ do not inherit DPO-LoRA's higher `5e-5` either, because ORPO's loss carries a
361
+ full SFT NLL term that a high LR destabilizes.
362
+
363
+ | Knob | Default | Why |
364
+ |---|---|---|
365
+ | `learning_rate` | **`1e-5`** | Matches our SFT recipe; lower than DPO-LoRA (`5e-5`) because the SFT NLL term is in the loss. Sweep range 5e-6–2e-5. |
366
+ | `orpo_lambda` (λ) | **`0.1`** | Paper's primary value + TRL default. Weights **only** `L_OR`; `L_SFT` stays unweighted. Stable range 0.1–0.5 (λ=1.0 also depresses chosen log-probs). |
367
+ | `orpo_lr_schedule` | **`cosine`** | Paper: linear warmup → cosine decay (linear fine for short runs). |
368
+ | `orpo_warmup_iters` | **≈3% of `iters`** | Paper warmup_ratio ≈0.03; ~10 steps on very short runs. |
369
+ | `scale` (LoRA α) | keep **`20`** (our recipe) | Labonne uses 32 (α=2·r); either is fine — no ORPO-specific reason to change ours. |
370
+ | `rank` | **`16`** | Matches our SFT recipe and Labonne's ORPO-LoRA. |
371
+ | epochs | **1** to start | ORPO is single-stage/data-efficient; eval loss rises fast past epoch 1–2 — early-stop on val rather than running long. |
372
+
373
+ Sources: [ORPO paper (EMNLP 2024)](https://aclanthology.org/2024.emnlp-main.626/),
374
+ [TRL `orpo_config.py`](https://github.com/huggingface/trl/blob/main/trl/trainer/orpo_config.py),
375
+ [Labonne, Fine-tune Llama 3 with ORPO](https://huggingface.co/blog/mlabonne/orpo-llama-3).
376
+
377
+ > Implementer correctness notes from the spike: `L = L_SFT + λ·L_OR` — the SFT
378
+ > term is **unweighted** (only `L_OR` carries λ); the odds ratio uses the
379
+ > **length-normalized mean** log-prob (summing reintroduces length bias and
380
+ > makes the `L_OR` gradient length-dependent); the `1/(1−P)` factor in the OR
381
+ > gradient amplifies low-likelihood updates (another reason to keep LR
382
+ > conservative); and `log1mexp` is the NaN hazard (clamp / two-branch).
383
+
384
+ ## Regularization & optimizer geometry
385
+
386
+ ORPO is data-efficient and usually run ~1 epoch on small preference sets, so
387
+ **overfitting and step-size geometry matter more than raw capacity**. The knobs,
388
+ ordered by leverage, with the non-obvious LoRA interactions called out.
389
+
390
+ ### Rank is the primary regularizer
391
+
392
+ Low rank *is* the capacity limit — the main implicit regularizer, and the
393
+ `rank_scaling` policy (`by_bits` / `by_kl`) is how capacity is *distributed*
394
+ across layers. Treat rank as the first dial; weight decay and dropout are
395
+ secondary. This is also why wiring the `bitsMap` (above) matters: it lets the
396
+ regularizer (capacity) track per-layer precision instead of silently going
397
+ uniform.
398
+
399
+ ### The rank ↔ scale ↔ init interaction (the one to get right)
400
+
401
+ Our effective update is `α·B A` with `α = scale` and **B zero-init, A
402
+ uniform(±1/√in)** (so `ΔW=0` at step 0; adapted == base). Two consequences:
403
+
404
+ 1. **At step 0 only B moves.** The LoRA backward is `dA = α·xᵀ(dy Bᵀ)`,
405
+ `dB = α·(xA)ᵀ dy`. With `B=0`, `dA ∝ dy Bᵀ = 0` — **A has zero gradient on
406
+ the first step**; B does all the early learning until it lifts off zero. This
407
+ is intended, but it means B's effective LR and A's init scale (which sets the
408
+ magnitude of `h=xA`, hence the first `dB`) dominate early dynamics. It's the
409
+ reason **LoRA+** (a higher LR for B than A, often ~16×) helps — B is on the
410
+ critical path. Worth exposing as an optional `orpo`/LoRA knob.
411
+ 2. **Fixed `α` distorts rank scaling.** With `by_bits`/`by_kl` varying rank per
412
+ layer, a *fixed* `α` makes the effective step size scale with rank — so
413
+ wider adapters get bigger *updates*, not just more *capacity*, defeating the
414
+ intent. **rsLoRA** (rank-stabilized: scale by `α/√r` instead of `α/r`-style)
415
+ decouples capacity from step size and keeps gradients stable as rank grows.
416
+ Given our rank-scaling story, this is a real correctness/stability fix, not a
417
+ nicety — flag it as a `scale` policy option to evaluate.
418
+
419
+ ### Weight decay — note the geometry, then probably lower it
420
+
421
+ AdamW already applies `weight_decay` (default `0.01`) to A/B. But decaying A and
422
+ B independently is **not** L2 on the effective update `ΔW=αBA` — minimizing
423
+ `‖A‖² + ‖B‖²` subject to a fixed `BA` is a **nuclear-norm-style** (low-rank)
424
+ penalty on `ΔW`, not Frobenius. So LoRA weight decay nudges toward
425
+ lower-effective-rank updates. For ORPO specifically the **SFT-NLL term already
426
+ anchors** the model to high chosen-likelihood (a built-in regularizer), so heavy
427
+ WD is often redundant — consider defaulting **lower (e.g. 0.0–0.01)** and tuning,
428
+ rather than inheriting the SFT `0.01` blindly.
429
+
430
+ ### LR schedule & warmup
431
+
432
+ Cosine decay with a short warmup (`orpo_lr_schedule` / `orpo_warmup_iters`,
433
+ ≈3%). Warmup matters *more* for ORPO than SFT: the odds-ratio gradient carries a
434
+ `1/(1−P)` factor that **amplifies low-likelihood updates**, so a cold high LR is
435
+ extra destabilizing. Reuse `warmupCosineSchedule` (already in the DPO loop).
436
+
437
+ ### Dropout
438
+
439
+ LoRA-input dropout (PEFT-style), default `0.0`, `~0.05` for small sets — see the
440
+ [Dropout subsection](#dropout) for wiring and the **recompute-determinism**
441
+ constraint (the mask must be replayed in the segmented/checkpointed/chunked
442
+ backward or grads diverge). ORPO's base has no dropout modules (TRL's
443
+ `disable_dropout` is moot for us); only the adapter dropout is in play.
444
+
445
+ > Net: rank (+ `bitsMap`-aware scaling) is the main regularizer; **rsLoRA**
446
+ > (`rs_lora`), **LoRA+** (`lora_plus_ratio`), and **LoRA dropout** (`lora_dropout`)
447
+ > are now implemented (default off); default weight decay **lower** than SFT (the
448
+ > SFT-NLL term anchors); cosine + warmup. The remaining open work is **empirical**
449
+ > — sweep `rs_lora`/`lora_plus_ratio`/`lora_dropout`/`weight_decay` on a real run
450
+ > and watch the held-out **val margin** (`bestMargin` in metrics.json) to pick
451
+ > the setting that resists the ~450-example overfit.
452
+
453
+ ## Parity
454
+
455
+ Anchor the loss math to a **standalone torch/numpy reference** (the paper /
456
+ TRL `odds_ratio_loss` + the NLL term) rather than an upstream port:
457
+
458
+ - `scripts/experiments/parity-orpo.ts` (or a `.py` companion run under the
459
+ oracle venv): feed fixed `(chosen_logits, rejected_logits, masks)` to both
460
+ our `orpoLoss` and a hand-written reference; assert the scalar loss,
461
+ `log_odds`, and the nll/or split match to tolerance.
462
+ - Add a focused unit test with a couple of toy `(ℓw, ℓr)` cases where
463
+ `log_odds` and the loss are computed by hand, guarding the `log1mexp`
464
+ stability path.
465
+
466
+ **Oracle ladder for the optimized path.** With no upstream ORPO to port, each
467
+ optimization tier is validated bit-exact (within bf16 tolerance) against the
468
+ tier below it, bottoming out at the math reference above:
469
+ - **naïve ORPO** = full-logits, two separate forwards, `orpoLoss` as specified
470
+ → validated against the torch/numpy math reference.
471
+ - **chunked fused log-prob engine** (lever 1) → loss *and* `∇A`/`∇B` vs the
472
+ naïve full-logits forward.
473
+ - **segmented / intra-layer split** (lever 4) → grads vs the unsegmented path
474
+ (the existing SFT segmented backward already holds to ~0.00% / bf16-class;
475
+ same harness).
476
+ - **shared prefix** (lever 7) → vs the two-sequence forward (the published
477
+ method is numerically identical; the prefix just accumulates both cotangents).
478
+
479
+ ## Memory & speed optimization
480
+
481
+ ORPO has no upstream bit-exact oracle (L3 original), which gives us latitude to
482
+ optimize the runtime aggressively as long as we stay self-consistent with a
483
+ naïve reference (see [Parity](#parity)). This section is the result of a
484
+ dedicated research spike across three areas — activation/segmentation memory,
485
+ large-vocab loss memory, and preference-training structure — ranked for *our*
486
+ constraints: MLX, Apple-Silicon **unified memory** (~24 GB), LoRA-only, B=1,
487
+ long context, two prefix-sharing branches, 262k vocab.
488
+
489
+ ### What dominates, and the one principle that orders everything
490
+
491
+ At long context two terms dominate, and **neither is the LoRA grads** (those are
492
+ tiny): (1) the LM-head logits `[1, T, 262k]` and their gradient — ~4.3/8.6 GB at
493
+ T=8192, bf16 — and (2) the per-layer activations held for the backward. ORPO
494
+ forwards *two* responses, so the logit term is effectively doubled vs SFT.
495
+
496
+ The ordering principle, specific to unified memory: **only reducing total live
497
+ footprint moves the 24 GB constraint — relocation does not.** On a discrete GPU,
498
+ offloading activations to host DRAM over PCIe is a real second lever (Unsloth's
499
+ offloaded checkpointing, ZeRO-Infinity). On Apple Silicon the CPU and GPU share
500
+ one physical pool ([MLX unified memory](https://ml-explore.github.io/mlx/build/html/usage/unified_memory.html)),
501
+ so "offload to CPU" frees **zero** bytes, risks double-residency (raising peak),
502
+ and burns the ~273 GB/s bandwidth that is the actually-scarce resource. **We do
503
+ not implement activation offloading.**
504
+
505
+ ### Ranked levers (with our-setup verdicts)
506
+
507
+ **1. Chunked / fused log-prob engine — build first, biggest unconditional win.**
508
+ CE and the per-token target log-prob ORPO needs are the *same* quantity:
509
+ `logp_t = (h_t·W_{y_t}) − logsumexp_v(h_t·W_v)`, `ce_t = −logp_t`. So one
510
+ memory-efficient engine serves both the SFT-NLL term and the odds-ratio
511
+ log-probs. The trick (Liger `FusedLinearCrossEntropy`, Apple
512
+ [Cut Cross-Entropy](https://arxiv.org/abs/2411.09009)): tile the vocab
513
+ projection, combine partial `logsumexp`s with the
514
+ [online-softmax](https://arxiv.org/abs/1805.02867) recurrence, gather the target
515
+ logit — so the full `[T, V]` logits never materialize, and the backward
516
+ reconstructs `softmax − onehot` from a saved per-token LSE scalar (O(T) stored,
517
+ not O(T·V)). CCE reports loss memory **24 GB → ~1 MB**, head-training **28 → ~1
518
+ GB** at this vocab scale. This **subsumes our current `responseOnlyCe` trick**
519
+ (which only shrinks the logits to the response span) and generalizes it.
520
+ - *MLX reality:* nothing fused/chunked exists in the MLX ecosystem today —
521
+ this is new work. Build it in **pure MLX** as a Liger-style **chunked**
522
+ log-prob (matmul + `logsumexp` + `takeAlongAxis` per chunk, driven by an
523
+ `mx.vjp` per chunk — *not* a surrogate `value_and_grad`, per our
524
+ [segmented-backward leak lesson](segmented-backward-training.md)). Pass
525
+ `(hidden, baseW, loraA, loraB, targets)` as **explicit** VJP args so the
526
+ LoRA-factored head backward (`∇A`, `∇B`) falls out per chunk — Apple's CCE
527
+ has no LoRA path, so even adopting it would mean deriving this ourselves.
528
+ Exact in forward and backward; no Metal kernel required. Reserve a
529
+ `mx.fast.metal_kernel` flash-CCE as an **L3 speed tier** only if per-chunk
530
+ logits are still too large in practice.
531
+
532
+ > ✅ **LANDED + validated (2026-06-18) — `fusedLogpMeanB1` in
533
+ > [`loss.ts`](../../src/train/loss.ts), gated by `orpo_fused_ce`.** A single
534
+ > `CustomVjp` (Liger `FusedLinearCrossEntropy` structure ported to MLX): forward
535
+ > token-chunks the response, materializes only `[chunk,V]` logits per chunk (=
536
+ > `quantizedMatmul(h_c, W, …, transpose=true)` + Gemma `logitSoftcap`), reduces to
537
+ > `logp = target_logit − logsumexp`, drops the logits; the **analytic** backward
538
+ > recomputes per chunk and forms `cotangent·(onehot − softmax)·sech²` directly
539
+ > (no autograd through logsumexp+gather), then `dh_c =
540
+ > quantizedMatmul(grad, W, …, transpose=false)` — mlx's own x-vjp of the
541
+ > transpose=true head (contracts vocab without dequantizing the 1.3 GB head).
542
+ > **Validated** ([`scripts/experiments/fused-ce-parity.ts`](../../scripts/experiments/fused-ce-parity.ts)
543
+ > + gated [`tests/train-orpo-fused-ce.test.ts`](../../tests/train-orpo-fused-ce.test.ts)):
544
+ > value **BIT-EXACT** vs the full-logits `branchLogpMeanB1` on BOTH MiniCPM5 (no
545
+ > softcap) and e4b (softcap=30 — matched by reusing `logitSoftcap`'s exact
546
+ > `div`-by-bf16-cap formula); LoRA grads in the bf16 class (**0.51%** MiniCPM5 /
547
+ > **1.97%** e4b, the documented scale=1.0 band); peak `[chunk,V]`-bounded both
548
+ > directions (MiniCPM5 M=2048: 13.32 → 12.80 GB, the win grows with M·V). Wired
549
+ > through `ChunkCtx.fused` → `branchLogpMean` (B=1, non-segmented; Gemma + CPM5
550
+ > heads); `orpo_fused_ce` reuses `orpo_chunk_size` as the token-chunk (default
551
+ > 512). Head is not a LoRA target so only `dh` flows back (head-LoRA `∇A/∇B` fold
552
+ > + vocab-blocked online-softmax CCE tier are the remaining follow-ons). The spec
553
+ > that guided it is below.
554
+ >
555
+ > ⚠️ **Segmented integration — investigated; head is NOT the segmented bottleneck
556
+ > (2026-06-18).** Wired a bounded head into `SegmentedBackwardOrpo` /
557
+ > `SegmentedBackwardOrpoGemma4` (constructor `fusedChunkSize`, gated by
558
+ > `orpo_fused_ce`; `boundedHeadFromHidden` in `segmented.ts`). Two findings:
559
+ > (1) the **analytic fused `CustomVjp` does NOT bound memory when nested inside the
560
+ > segmented `mlx_vjp` head** — its per-chunk `[chunk,V]` graph is not freed
561
+ > incrementally there (it IS at the top level, where peak scales with chunk size),
562
+ > so it *raised* peak, growing with M (+0.8 GB @L=3072, +1.1 GB @L=4096 on
563
+ > MiniCPM5). So the segmented bounded head uses the **Checkpoint recompute** variant
564
+ > (`chunkedLogpMeanFromHidden`) instead — bit-exact (0.00% grads, pure recompute),
565
+ > never increases peak. (2) But **bounding the head does not reduce the segmented
566
+ > peak at any reachable scale** — verified full-vs-bounded head identical peak on
567
+ > MiniCPM5 (130k vocab) L=2048/3072/4096 × SEG=2/6 AND e4b (262k vocab) L=2048
568
+ > (11.95 GB both). The per-segment backward recompute + the saved forward
569
+ > boundaries are the binding constraint; the head `[M,V]` is transient (computed in
570
+ > the head phase, freed before the segment-backward peak), so it never stacks with
571
+ > the peak. **Conclusion:** segmentation already removes the layer pressure, and at
572
+ > these scales the head was never the stacking term — so fused-CE-in-segmented is
573
+ > NOT a peak win (the cumulative-pressure hypothesis doesn't bind here). The
574
+ > Checkpoint bounded head is retained as a bit-exact, zero-cost safety for the
575
+ > extreme vocab×M regime (head would only bind well past what fits) and as the hook
576
+ > for a future Metal CCE kernel. Default segmented path (`orpo_fused_ce` off) is
577
+ > unchanged — full `responseOnlyLogpMean` head.
578
+ >
579
+ > ⚠️ **Vocab-blocked online-softmax (full CCE) — implemented + correct, but pure
580
+ > MLX can't bound its memory (2026-06-18).** `makeVocabBlockedHeadVjp` in
581
+ > `loss.ts` (reached via the `vocabBlock` param / experiment scripts) tiles the
582
+ > vocab into `vocabBlock`-wide blocks with the [online-softmax](https://arxiv.org/abs/1805.02867)
583
+ > running (max `m`, sumexp `d`) recurrence — the exact Cut Cross Entropy structure
584
+ > so `[chunk, V]` is never formed. **Numerically validated**
585
+ > ([`scripts/experiments/fused-ce-parity.ts`](../../scripts/experiments/fused-ce-parity.ts)
586
+ > `VBLOCK=…`): value bf16-class vs the whole-vocab head, grads **flat at ~0.46%
587
+ > across block sizes** (8192/2048/512) — once two precision fixes landed: the
588
+ > online `(m,d)` accumulation must be **f32** (bf16 across many blocks corrupts the
589
+ > exp-sensitive `lse`), and the **`dh` cross-block accumulation must be f32** (the
590
+ > dominant grad bug — bf16 summing hundreds of block-`dh`s gave 17%→45% error
591
+ > scaling with block count). **But it does NOT bound memory** — the head-isolated
592
+ > peak ([`scripts/experiments/fused-ce-headmem.ts`](../../scripts/experiments/fused-ce-headmem.ts),
593
+ > MiniCPM5 M=2048) goes the WRONG way: whole-vocab **3.4 GB → 14.1 GB at
594
+ > 512-wide blocks**, growing monotonically as blocks shrink. Root cause: the online
595
+ > softmax is a **sequentially-dependent chain** (`m`/`d` recurrence + the `dh`
596
+ > accumulation chain), and MLX's lazy eval **retains the whole chain** instead of
597
+ > freeing each `[chunk,Vblock]` incrementally — unlike the *independent*
598
+ > token-chunks (which free fine). Forcing an eval mid-chain breaks autodiff (a
599
+ > 35 GB blowup). **Conclusion:** the CCE residency bound (`[chunk,Vblock]` peak) is
600
+ > unreachable in pure MLX; it requires a `mx.fast.metal_kernel` with the vocab loop
601
+ > INSIDE the kernel (the L3 flash-CCE tier). The pure-MLX implementation is kept as
602
+ > a **validated correctness reference** for that kernel and is **gated off** (not
603
+ > wired to a trainer knob — it would only raise peak). So: token-chunking
604
+ > (`orpo_chunk_size`) + the analytic fused head (`orpo_fused_ce`) are the usable
605
+ > pure-MLX memory levers; vocab-blocked CCE is a Metal-kernel follow-on.
606
+ >
607
+ > 🔬 **flash-CCE Metal kernel — PROTOTYPED + working, scaling-limited at largest
608
+ > vocab (2026-06-18).** [`src/train/flash-cce.ts`](../../src/train/flash-cce.ts):
609
+ > `mx.fast.metal_kernel` forward (`logp` + `lse`) and backward (`dh`) that compute
610
+ > logits in-kernel from the quantized head (in-Metal 4/8-bit affine dequant via the
611
+ > qdot pattern from [`fused-decode-kernel.ts`](../../src/model/fused-decode-kernel.ts)),
612
+ > online softmax across vocab, target capture, softcap + sech², so the vocab loop
613
+ > lives INSIDE the kernel — neither `[M,V]` nor a dequantized `[V,hidden]` head
614
+ > touches HBM. **Validated** ([`scripts/experiments/flash-cce-parity.ts`](../../scripts/experiments/flash-cce-parity.ts)):
615
+ > forward `logp` bf16-class (~0.21%) vs the MLX whole-vocab head; backward `dh`
616
+ > ~bit-exact vs autograd (isolated 2e-4%; e4b M=512 0.40%); softcap correct on
617
+ > e4b. **Memory bound ACHIEVED** — peak ~flat in V: MiniCPM5 M=8192 forward
618
+ > reference **2.45 GB → kernel 0.34 GB**; e4b M=512 backward **2.06 → 1.17 GB**.
619
+ > **Scaling limit (v1):** one token = one threadgroup looping the WHOLE vocab
620
+ > serially. On e4b (262k vocab × 2560 hidden) each threadgroup runs ~3.3× longer
621
+ > than MiniCPM5 and **intermittently trips the GPU watchdog** at higher M (e4b ≤512
622
+ > solid; ≥1024 nondeterministic garbage — confirmed 89/95/100% across repeats; a
623
+ > timeout, not a logic bug — MiniCPM5 is solid to M=8192).
624
+ >
625
+ > ✅ **VOCAB-PARALLEL form LANDED — e4b now scales (2026-06-18).** Transpiled
626
+ > Apple CCE's `cce_lse_forward` / `cce_backward` grid: `grid=[TG, nVocabBlocks, M]`,
627
+ > each threadgroup handles a bounded `VOCAB_BLOCK`-row (8192) slice for one token,
628
+ > so no dispatch loops the whole vocab. **Forward** writes per-block partials
629
+ > (blockMax, blockΣexp, target logit) to `[M, NBLK]`, merged cross-block by cheap
630
+ > MLX ops (`gMax`/`Σexp(max−gMax)`/`lse`) — Apple uses a locked atomic logaddexp;
631
+ > the MLX merge is simpler, same result. **Backward** recomputes per (token,
632
+ > vocab-block) and **atomic-adds** its partial `dh` into the shared `[M,H]` output
633
+ > (`atomicOutputs` + `initValue 0`; NBLK-way contention per dim = Apple's locked
634
+ > atomic `dE` add — and we need only `dE`/`dh`, not `dC`, since the head is frozen).
635
+ > **Validated** ([`scripts/experiments/flash-cce-parity.ts`](../../scripts/experiments/flash-cce-parity.ts)):
636
+ > e4b M=1024 (NBLK=32) — watchdog GONE, deterministic, **logp 0.20% / dh 0.40%**
637
+ > (bf16-class), **bwd peak 3.41 → 1.46 GB**, fwd peak 1.27 → 0.82 GB; MiniCPM5
638
+ > unregressed. Dequant runs f32 in-kernel → arguably more accurate than the bf16
639
+ > MLX head.
640
+ >
641
+ > ✅ **SPEED — `BLOCK_B` token tiling landed (2026-06-18).** The v1 vocab-parallel
642
+ > kernel was correct + memory-bounded but ~1000× too slow (e4b M=512: fwd 5.1 s,
643
+ > bwd 16.8 s) — **ALU-bound on the scalar dequant** (71 GB/s effective ≪ 273
644
+ > peak), because one-token-per-threadgroup re-dequantizes the head M times.
645
+ > Transpiled Apple CCE's `BLOCK_B` token tiling: each thread dequantizes a vocab
646
+ > row ONCE and reuses it across BLOCK_B tokens. e4b M=512: **fwd 5.1→1.5 s (3.3×,
647
+ > BLOCK_B=8)**, **bwd 16.8→7.3 s (2.3×, BLOCK_B_BWD=4)**, total **2.5×**; grads +
648
+ > memory win unchanged (parity logp 0.20% / dh 0.40%, bwd peak 2.06→1.17 GB). Two
649
+ > findings: (a) past BLOCK_B≈8 the dequant is amortized and the SCALAR matmul
650
+ > dominates (BLOCK_B=16 ≈ BLOCK_B=8) → next tier **simdgroup_matrix**; (b) the
651
+ > backward is REGISTER-bound by `dacc[BLOCK_B·DPER]` (BLOCK_B=8 spills/faults;
652
+ > BLOCK_B_BWD=4 fits) → separate fwd/bwd tile widths. **Remaining:** simdgroup_matrix
653
+ > GEMM + D-tiling + CCE near-zero gradient filter (best case ≈ MLX matmul speed,
654
+ > same FLOPs); then `CustomVjp` wrap + `orpo_flash_ce` + trainer wiring.
655
+ >
656
+ > **Implementation spec — fused linear-CE log-prob head.** Concrete plan,
657
+ > grounded in the code. The head is `embed.asLinear(h)` =
658
+ > `quantizedMatmul(h, w, scales, biases, spec, /*transpose*/true)` for tied models
659
+ > (Gemma/CPM5-tied) or `lmHead` (`QuantizedLinear`) for untied; a **vocab chunk is
660
+ > an axis-0 row-slice** of `w`/`scales`/`biases` (the quant `spec` is one per head)
661
+ > fed to `quantizedMatmul` per chunk. **Gemma applies `final_logit_softcapping`
662
+ > (e4b = 30.0): `logit = cap·tanh(raw/cap)`** — must be folded into BOTH the
663
+ > online-LSE and the backward (`d logit/d raw = sech²(raw/cap) = 1 − tanh²(raw/cap)`);
664
+ > CPM5 has no softcap.
665
+ >
666
+ > - **New fn `fusedLogpMeanB1(model, ids, mask, validLen, vocabChunk)` → [1]** in
667
+ > [`loss.ts`](../../src/train/loss.ts), selected by a new `orpo_vocab_chunk`
668
+ > knob (the existing `orpo_chunk_size` stays the token-axis tier; the two
669
+ > compose — token-chunk the M response rows, vocab-chunk each row's head). It
670
+ > replaces the per-chunk `logitsFromHidden` (which materializes `[Cc, V]`) with
671
+ > the tiled engine below, wrapped in a single `CustomVjp` (our
672
+ > [`fused-geglu-kernel.ts`](../../src/model/fused-geglu-kernel.ts) pattern: a
673
+ > `mx.custom_function` with a hand `.vjp`, validated against `mx.vjp`/autograd —
674
+ > never a hand formula, per the standing rule).
675
+ > - **Explicit VJP inputs** `(hC [M,hidden], baseW, scales, biases, [loraA, loraB
676
+ > if the head is a LoRA target], targets [M])`; output = per-token `logp [M]`
677
+ > (or the summed scalar). The head is NOT a default LoRA target today
678
+ > (`DEFAULT_TARGET_MODULES` is attn/MLP), so v1 can treat the head as **frozen**
679
+ > (only `dh` flows back) and skip `∇A_head`/`∇B_head` — add the LoRA-head path
680
+ > only when the head becomes a target. `dh` is what seeds the layer-stack LoRA
681
+ > backward, so the frozen-head path already delivers the memory win.
682
+ > - **Forward (online-softmax over vocab tiles), per token-row block:** init
683
+ > `m = −inf [M]`, `s = 0 [M]`, `tgtLogit = 0 [M]`. For each vocab chunk `[v0,v1)`:
684
+ > `raw = quantizedMatmul(hC, w[v0:v1], scales[v0:v1], biases?[v0:v1], spec, true)`
685
+ > `[M, Cv]`; apply softcap if set; `mNew = max(m, max_v raw)`;
686
+ > `s = s·exp(m−mNew) + Σ_v exp(raw−mNew)`; `m = mNew`; where the target index
687
+ > falls in `[v0,v1)`, capture `tgtLogit`. After all chunks: `lse = m + log(s)`,
688
+ > `logp = tgtLogit − lse [M]`. **Store only `(m or lse, s, tgtLogit, targets)` —
689
+ > O(M), never `[M,V]`.**
690
+ > - **Backward (recompute vocab tiles from the saved LSE):** upstream cotangent
691
+ > `g [M]` (= `∂loss/∂logp_t`). For each vocab chunk recompute `raw`, apply
692
+ > softcap, `softmax_v = exp(softcap(raw) − lse)`; the per-logit cotangent is
693
+ > `g·(onehot_target − softmax)` (× `sech²` if softcap). Accumulate
694
+ > `dh += (that)·W_chunk` (through the quantized matmul's `x`-vjp, which MLX has)
695
+ > and, if head-LoRA, `∇A/∇B` via the factoring. **Never forms `[M,V]`.** Validate
696
+ > the `.vjp` against `mx.vjp` over `logitsFromHidden`+gather on a small vocab.
697
+ > - **ORPO-grad folding (later micro-opt):** ORPO needs `logp` then reduces via
698
+ > `orpoLossFromLogps`; with `logp` exposed as the `CustomVjp` output, autograd
699
+ > composes the odds-ratio grad outside the head kernel (correct, simplest).
700
+ > Folding `∂L_OR/∂logp` *inside* the kernel (Tier-1 fusion #3) is a speed-only
701
+ > refinement once the exact engine lands.
702
+ > - **Oracle ladder (per [Parity](#parity)):** `fusedLogpMeanB1` loss + `∇`(LoRA)
703
+ > vs the naïve full-logits `branchLogpMeanB1` (bf16-exact); then vs the
704
+ > token-chunked `chunkedLogpMeanB1` (same class). New gated test
705
+ > `tests/train-orpo-fused-ce.test.ts` mirroring `train-orpo-chunked.test.ts`;
706
+ > bench row in [`bench-orpo.ts`](../../scripts/bench-orpo.ts) (peak at 262k vocab).
707
+ > **Note:** at 262k a single row `[1,262k]` bf16 ≈ 0.5 MB, so the token-chunked
708
+ > head ALREADY bounds memory to `[chunkSize, 262k]`; the fused engine's win is
709
+ > (a) eliminating the `[chunk,V]` forward+backward materialization entirely and
710
+ > (b) the head-LoRA + ORPO-grad fusion — the moat (no MLX preference trainer
711
+ > has it), not a raw 24 GB→1 MB cliff at our vocab.
712
+
713
+ **2. Reference-free structure — already ours; keep it, and don't regress it.**
714
+ ORPO is **2 forwards/step** (chosen + rejected, both with grad) vs DPO's **4**
715
+ (2 reference + 2 policy). That is the single biggest reason ORPO fits 24 GB.
716
+ The chosen forward feeds **both** the NLL term and the chosen log-prob; the
717
+ rejected forward contributes only its scalar log-prob — never forward chosen
718
+ twice, never compute a rejected NLL.
719
+
720
+ **3. Two unpadded sequential forwards — NOT a padded concatenated forward.**
721
+ TRL's `concatenated_forward` pads chosen+rejected to a shared max and runs one
722
+ batch-of-2; its own docstring says this is to amortize **FSDP** all-gathers —
723
+ which we don't have. On single-device bandwidth-bound unified memory the padding
724
+ is pure waste, and we must retain both branches' activations for the backward
725
+ either way. So our two B=1 forwards (chosen, then rejected) are the *right*
726
+ shape, not a limitation.
727
+
728
+ **4. Segmented backward + intra-layer (attn/MLP) split — the per-layer AND
729
+ intra-layer ask.** Wire the preference path into the existing
730
+ [`SegmentedBackward`](../../src/train/segmented.ts) (currently SFT B=1 only),
731
+ then go finer. The residual-stream hidden `h` *between* the attention add and
732
+ the MLP add is a **graph min-cut of width one** `[1, T, d]` — the same kind of
733
+ object we already detach at segment edges. Detach `h`, run the **MLP sub-block**
734
+ VJP to get `dL/dh`, seed that into the **attention sub-block** VJP. Peak per unit
735
+ drops from `attn + MLP` to `max(attn, MLP) + h`.
736
+ - *The non-obvious part, specific to us:* because we train through `ops.sdpa`
737
+ (MLX's fused flash kernel), the O(T²) attention scores are **never
738
+ materialized** — so the **MLP intermediate** (SwiGLU/GeGLU gate+up at
739
+ `d_ff`) is the dominant resident tensor, the *reverse* of the
740
+ materialized-scores textbook case. So the high-value isolation is the **MLP
741
+ sub-block**, not attention. This nests strictly inside the current reverse
742
+ VJP loop (a segment of N blocks → 2N sub-VJP units), same detach-leaf
743
+ discipline, same e4b donor-KV threading (which lives entirely in the
744
+ attention sub-block). Refs: Megatron
745
+ [selective recomputation](https://arxiv.org/abs/2205.05198),
746
+ [Checkmate](https://arxiv.org/abs/1910.02653).
747
+ - ✅ **LANDED on the gradient-checkpoint path** (`config.mlpSplit`, Gemma4 only,
748
+ needs `gradCheckpoint` on). `DecoderLayer.forward` is refactored into
749
+ `forwardAttn` (inputNorm → attn → residual add, threading donor K/V) and
750
+ `forwardMlp` (FFN + per-layer gate); `runSplitCheckpointedLayer` wraps each in
751
+ its own `Checkpoint` with the post-attn residual `hMid` as the boundary. The
752
+ trainer partitions each layer's LoRA into attn (`self_attn.*`) and MLP
753
+ sub-blocks. **Validated bit-exact** on real e4b
754
+ ([`scripts/experiments/mlp-split-checkpoint-e4b.ts`](../../scripts/experiments/mlp-split-checkpoint-e4b.ts)):
755
+ split grads == single-checkpoint grads == no-checkpoint grads to **0.000000%
756
+ / maxAbs 0.0** (it is pure recompute), and the fused-GeGLU `CustomVjp` nests
757
+ cleanly inside the `Checkpoint`. **Honest peak finding:** on the *per-layer*
758
+ checkpoint path the marginal win is small — L=2048 no-ckpt **28.60 GB** →
759
+ single **23.40 GB** → split **23.35 GB** (the single checkpoint already
760
+ serializes layers, so the split only saves ~one layer's attn-sized
761
+ activations during its recompute window; under `ops.sdpa` the MLP, not attn,
762
+ dominates, so `max(attn,MLP) ≈ MLP`). The split's value grows with coarser
763
+ checkpoint units / inside the segmented backward; the bigger *MLP* memory
764
+ lever at long context is lever 5 (sequence-chunk), still the documented
765
+ follow-on. Off by default (numerically identical, a finer memory↔compute
766
+ knob).
767
+
768
+ **5. Sequence-chunk the MLP sub-block — finer still, free, add only if needed.**
769
+ The MLP and projections are **per-token independent**, so the `[1, T, d]`
770
+ boundary can be split into row-chunks and the MLP run one chunk at a time,
771
+ dropping its resident intermediate from `O(T·d_ff)` to `O(chunk·d_ff)` at
772
+ ~zero recompute. A third nesting axis (depth-segment → block → attn/MLP →
773
+ seq-chunk-of-MLP) that stacks multiplicatively. Add only if the MLP term is
774
+ still binding at 8K after lever 4. (Attention can't be token-chunked naïvely —
775
+ it's all-to-all — but `ops.sdpa` already handles it;
776
+ [Blockwise/Ring](https://arxiv.org/abs/2305.19370) is the general form.)
777
+
778
+ **6. Rotor-style segment planning — replace `planSegmentsBySize`.** The √n rule
779
+ ([Chen 2016](https://arxiv.org/abs/1604.06174)) says ~√(nLayers) segments
780
+ minimize peak at ~1× recompute (≈6 segments for e4b's 42 layers — matches its
781
+ natural `sssssF` period). For our *heterogeneous* stack (full vs sliding layers,
782
+ mixed quant, and eventually sub-blocks) the right tool is a **rotor DP**
783
+ ([Beaumont 2019](https://arxiv.org/abs/1911.13214)): given a peak-memory budget
784
+ it emits the recompute-minimizing split points, isolating the O(T²) full-
785
+ attention layers automatically instead of hand-tuning `segment_size`.
786
+
787
+ **7. Shared prompt-prefix — a gated, genuinely novel differentiator.** Chosen
788
+ and rejected share an identical prompt prefix; the causal mask guarantees the
789
+ prefix's hidden states/KV are independent of which response follows. So compute
790
+ the **prompt once** and reuse it for both response continuations — token cost
791
+ `2(P+R) → P + 2R`. This is published for DPO
792
+ ([Wang & Hegde, "Accelerating DPO with Prefix Sharing"](https://arxiv.org/abs/2410.20305))
793
+ but **no mainstream trainer does training-time prefix sharing** (TRL's concat
794
+ encodes the prompt twice) — real differentiator territory. Backprop is sound:
795
+ the prefix is one node with two consumers, so reverse-mode AD just **sums the
796
+ two branches' cotangents** into `∂L/∂prefix` — falls out of our VJP machinery,
797
+ and it saves the prefix *backward* too.
798
+ - *Verdict — gate on P/R.* Speedup → 2× as prompt ≫ response, → ~0 as prompt
799
+ ≪ response (measured 1.17–1.57× depending on data). Our chunk/document
800
+ fine-tunes are prompt-heavy → a real 1.3–1.5× win; short-prompt/long-answer
801
+ pairs get ~nothing. Implementation is **high** complexity (needs a
802
+ block-sparse mask or, the natural fit for our KV-cache machinery,
803
+ **prefix-KV-once + a per-response attention pass** reading the shared prefix
804
+ K/V, with RoPE position-ids reset to `prompt_len` for each response). Build
805
+ it **after** lever 1, behind a flag that activates above a P/R threshold.
806
+ - ✅ **MiniCPM5 reference BUILT + validated (2026-06-18).**
807
+ [`src/train/prefix-shared.ts`](../../src/train/prefix-shared.ts):
808
+ `orpoLossPrefixShared` runs ONE forward over the concat
809
+ `[prompt(P); chosenResp(Rc); rejectedResp(Rr)]` with (a) a **block-sparse
810
+ mask** (`blockSparseMask`: causal AND NOT rejected→chosen) carried via a
811
+ `PrefixSharedCache`, and (b) **block-wise RoPE** that resets each response to
812
+ offset `P` (`ropeBlocks` + `setMiniCpmPrefixPlan` in
813
+ [`minicpm5.ts`](../../src/model/minicpm5.ts)); the head gathers
814
+ `chosen[k]←H[P-1+k]`, `rejected[0]←H[P-1]` (shared prompt-last),
815
+ `rejected[k≥1]←H[P+Rc+k-1]`. `splitPrefixBatch` detects an identical-prompt
816
+ B=1 row and falls back to the two-forward `orpoLoss` otherwise.
817
+ **Validated** ([`scripts/experiments/prefix-shared-parity.ts`](../../scripts/experiments/prefix-shared-parity.ts),
818
+ P=512/Rc=64/Rr=80): the **FORWARD is BIT-EXACT** vs the two-forward path
819
+ (`rel=0.00e+0%`) — the construction proof; ℓw/ℓr are identical. **Grads agree
820
+ to ~1.05% (bf16 grad class, < 2%)** — NOT ULP-exact, and confirmed at λ=0
821
+ (where the two-consumer prefix-cotangent sum is inert yet the ~1% gap holds),
822
+ so the residual is **f32-over-bf16 reassociation** in the LoRA grad matmuls
823
+ reducing over the merged length `T` vs `P+Rc`/`P+Rr` separately — the same
824
+ band the e4b segmented ORPO path shows, not a construction error. **Token
825
+ saving 1.78×** (two-forward 1166 → shared 656 token-passes) and **peak
826
+ 5.40 → 3.80 GB**. **Not yet wired into `orpoLoop`** — it's the standalone
827
+ reference + correctness gate; trainer wiring (a P/R-gated `orpo_*` flag) and
828
+ the e4b port (sliding-window mask interaction + donor-KV + per-layer-input)
829
+ are the follow-ons.
830
+
831
+ **Explicitly dropped / deferred:** activation offloading (no-op on unified
832
+ memory — §principle); TRL padded concatenated forward (FSDP-motivated padding
833
+ tax — lever 3); sequence packing and length-grouped batching (SFT-only wins,
834
+ near-moot at B=1 — revisit if we add grad accumulation).
835
+
836
+ ### Build order for the optimized path
837
+
838
+ 1. **Chunked fused log-prob engine** (lever 1) — replaces `responseOnlyCe` /
839
+ full-logits `branchLogp` for both the NLL and odds-ratio terms. Biggest win,
840
+ unconditional, exact.
841
+ 2. **Two unpadded forwards** (lever 3) + keep reference-free (lever 2).
842
+ 3. **Preference path into `SegmentedBackward`** then the **intra-layer MLP
843
+ split** (lever 4); **rotor planner** (lever 6) to choose the cuts.
844
+ 4. **Seq-chunk the MLP sub-block** (lever 5) if still binding at 8K.
845
+ 5. **Shared prefix** (lever 7), flagged + P/R-gated, validated bit-exact against
846
+ the two-sequence forward.
847
+
848
+ > Sourcing caveats from the spike to re-verify before quoting in user docs:
849
+ > SimPO's "~10% memory / ~20% faster vs DPO" and the packing "2×" figures are
850
+ > paper-reported on other setups; the Griewank 1992 page cite needs a primary
851
+ > check (Algorithm 799 / 2000 is solid). None affect the design — only external
852
+ > numbers we might cite.
853
+
854
+ ## Kernel fusion — the flat-DAG payoff
855
+
856
+ The reason to generate a loop-unrolled, branch-deleted forward isn't just
857
+ readability: with no Python loop or `instanceof`/conditional control flow, the
858
+ whole forward (and its differentiated backward) is a **single straight-line
859
+ dataflow DAG**. That unlocks two things — (a) `mx.compile` can form one large
860
+ fusion region over it, and (b) static pattern-matching can swap hot subgraphs
861
+ for custom fused Metal kernels. Both apply to **forward and backward**.
862
+
863
+ ### Verified MLX facts (source line refs from `main`; re-grep against the pinned 0.31.2 before relying)
864
+
865
+ - **`mx.compile` fuses the autodiff graph, not just the forward** — but
866
+ narrowly. It fuses **contiguous chains of elementwise unary/binary ops** into
867
+ one Metal kernel (the canonical `gelu` example: ~5× on M1 Max) and "merges
868
+ common work" (dedups duplicate subgraphs); fusion **boundaries** are the
869
+ non-elementwise nodes — matmul, SDPA, reductions (softmax/`logsumexp`),
870
+ reshape/transpose. So it fuses the **norm/RoPE/residual/LoRA-add/activation
871
+ glue between matmuls**, both directions. Control flow executes at *trace* time
872
+ (loops unroll, branches resolve), so a straight-line unrolled DAG has **no
873
+ control-flow fusion barrier left** and the longest possible elementwise
874
+ chains — that is exactly why unrolling helps the fuser.
875
+ - **Implementation rule:** compile the **outermost** transformed function —
876
+ `mx.compile(value_and_grad(...))`, or the documented
877
+ `@partial(mx.compile, inputs=state, outputs=state)` step that fuses
878
+ fwd+bwd+optimizer into one graph — or the backward lands *outside* the fused
879
+ region. `shapeless=True` avoids recompiles across seq-len (but not ndim/dtype
880
+ changes, and shape-conditional graphs break under it).
881
+ - **`mx.fast` ships fused fwd+bwd VJP kernels for `rms_norm`, `layer_norm`,
882
+ `rope`** (`RMSNormVJP` / `LayerNormVJP`; `RoPE::vjp` cleverly re-runs the same
883
+ kernel inverted — zero extra kernel). **Use these; don't hand-roll them.**
884
+ - **SDPA is the exception, and it matters:** the forward is fused, but the
885
+ **backward is NYI on Metal** — `ScaledDotProductAttentionVJP::use_fallback()`
886
+ returns true unconditionally and `eval_gpu` throws, so MLX **decomposes** the
887
+ SDPA backward into matmul→softmax→matmul and **materializes the L×L attention
888
+ matrix** (the fused backward exists only for CUDA/cuDNN). This is precisely why
889
+ our `ops.sdpa` training path has **O(L²) backward memory** (see
890
+ [training.md](../reference/training.md)) and why segmentation is doing the
891
+ compensating. → A **fused FA2-style SDPA backward** (store only the per-row
892
+ `logsumexp` `L`; recompute `S=QKᵀ`, `P=exp(S−L)` in the backward) is therefore
893
+ a genuine, high-value custom-kernel target, not a free win.
894
+ - **Not covered by `mx.fast`** (need custom kernels): SDPA backward (above),
895
+ GeGLU/SwiGLU, cross-entropy / log-softmax+gather, the LoRA epilogue.
896
+ - **`mx.fast.metal_kernel` wrapped in `mx.custom_function` with a `.vjp` is
897
+ fully differentiable** (verified) — the mechanism for every custom fused
898
+ fwd+bwd kernel below. Validate each `.vjp` against `mx.vjp`/autograd, never a
899
+ hand-derived formula (our standing rule).
900
+
901
+ ### Ranked fusion opportunities
902
+
903
+ **Tier 0 — free, do first (table-stakes):**
904
+ 1. **Compile the outermost unrolled forward+grad.** One `mx.compile` region over
905
+ the straight-line DAG fuses the norm/RoPE/residual/LoRA-add/activation glue
906
+ and the matching backward, and merges duplicate work. The unrolling is what
907
+ makes the region large; compiling the outermost `value_and_grad` is what puts
908
+ the backward inside it.
909
+ 2. **Route training through `mx.fast.rms_norm` / `layer_norm` / `rope`** —
910
+ already fused both directions. (Note: `sdpa` forward only — its backward is
911
+ the custom-kernel target in Tier 1.)
912
+
913
+ **Tier 1 — custom fused fwd+bwd Metal kernels (the cutting-edge; L3 originals):**
914
+ 3. **Fused linear + log-softmax + gather loss (CCE/Liger-style) — THE moat.**
915
+ Lever 1 of the memory plan as a single kernel: fuse the (quantized,
916
+ vocab-chunked) head matmul + `logsumexp` + target gather so the `[T, 262k]`
917
+ logits never touch memory; the backward reconstructs `softmax − onehot` from a
918
+ saved per-token LSE (Liger writes the grad in-place; CCE skips near-zero
919
+ softmax elements). Returns the per-token log-prob ORPO needs — **and the
920
+ ORPO odds-ratio gradient must be folded into this kernel**, since the grad is
921
+ produced inside the forward, not via autograd over materialized logits.
922
+ **No MLX project has this** (see landscape) — biggest differentiator.
923
+ 4. **Fused SDPA backward (FA2-style).** Fills the Metal NYI gap above: store the
924
+ per-row `logsumexp` `L` in the forward, recompute `S=QKᵀ` / `P=exp(S−L)` in
925
+ the backward (5 matmuls, no stored L×L matrix) → O(L) attention-backward
926
+ memory instead of today's O(L²). High value because it removes the term
927
+ segmentation is currently compensating for. (FA3's extra tricks are
928
+ Hopper-specific; mirror FA2 on Metal.)
929
+ 5. **Fused GeGLU/SwiGLU with a vjp.** We already have a fused-GeGLU *forward*
930
+ kernel ([`fused-geglu-kernel.ts`](../../src/model/fused-geglu-kernel.ts))
931
+ **disabled in training because it has no gradient** (the
932
+ `MLX_BUN_FUSED_GELU=0` constraint). The backward math is known (Liger): for
933
+ `c = act(a)⊙b`, save `a`,`b`, recompute the activation — `db = dc·act(a)`,
934
+ `da = dc·b·act'(a)`. Writing this `.vjp` re-enables the kernel in training —
935
+ doubly valuable since the MLP intermediate is our **dominant resident tensor**
936
+ (the intra-layer split target, lever 4).
937
+ 6. **Fused dequant-GEMM + LoRA epilogue (+ manual backward).** MLX's
938
+ `quantized_matmul` already fuses dequant into the *base* GEMM; the missing
939
+ piece is folding the LoRA epilogue `scale·(x@A)@B` into that kernel and a
940
+ hand-derived fused backward — `h=x@A`; `dB=s·hᵀ@dy`; `dA=s·xᵀ@(dy@Bᵀ)`;
941
+ `dx=dy@Wᵀ + s·(dy@Bᵀ)@Aᵀ` (Unsloth's `matmul_lora` is the blueprint; rank
942
+ r≪d so `h` is cheap to recompute). MLX's stock LoRA is **three separate ops**
943
+ + generic autograd — no fused path exists in the ecosystem.
944
+
945
+ **Tier 2 — DAG-level passes the flat graph enables:**
946
+ 7. **Recompute↔backward fusion** in the segmented path: the recompute-forward
947
+ and its per-segment vjp share one compiled fused region (the reason to do
948
+ this in a compiler vs eager `mlx_vjp`). Plus the classic passes over the
949
+ static DAG — TVM-style anchor pattern-fusion (matmul/SDPA absorbs an
950
+ elementwise epilogue; two anchors never chain), Inductor-style vertical +
951
+ horizontal fusion (fuse QKV projections, share one RoPE angle), an
952
+ XLA-style don't-duplicate-expensive-ops guard, and cross-layer CSE/global
953
+ value numbering (the unrolled N-layer graph repeats RoPE tables, masks,
954
+ scale constants).
955
+ 8. **Min-cut rematerialization for segment boundaries.** AOTAutograd's
956
+ `min_cut_rematerialization_partition` is the directly-portable algorithm:
957
+ build a flow network (internal edge capacity = tensor byte-size), bias toward
958
+ recomputing fusion-friendly ops, and `min_cut` picks save-vs-recompute — a
959
+ formal replacement for hand-chosen `segment_size` that *is* fusion-aware.
960
+ [Checkmate](https://arxiv.org/abs/1910.02653)'s MILP is the exact-budget
961
+ upgrade when we must hit the 24 GB ceiling precisely.
962
+
963
+ ### Competitive landscape (why this is a flagship)
964
+
965
+ An ecosystem survey found **no MLX preference trainer fuses or chunks the
966
+ loss** — every one (`mlx-lm-lora`, `optiq`'s `dpo.py`, `mlx-tune`, SiLLM, the
967
+ `mlx-examples` DPO gist) materializes the full `[B, T, V]` logits and calls
968
+ stock `log_softmax`/`cross_entropy`. CCE and Liger are Triton/CUDA-only with **no
969
+ Metal path**. Two openings stand out:
970
+
971
+ - **Correctness:** the current MLX SOTA, `mlx-lm-lora`, ships a **non-canonical
972
+ ORPO** — it uses `log_sigmoid(chosen_logp − rejected_logp)` and **omits the
973
+ additive SFT-NLL term** ORPO requires. Our paper-faithful loss (true log-odds
974
+ + λ·NLL, length-normalized) is *already* more correct than anything shipping.
975
+ **Do not copy their loss.**
976
+ - **Efficiency:** a **Metal fused/chunked CE + fused LoRA** would be the first
977
+ fused preference loss in the MLX world, on a quantized base, with grad
978
+ checkpointing / segmented backward. Other gaps nobody fills: cached DPO
979
+ reference log-probs, a concatenated/prefix-shared chosen+rejected forward.
980
+
981
+ These fused kernels are **L3 originals** (mlx-bun's own tier) — validated
982
+ against the naïve path via the [oracle ladder](#parity), consistent with the
983
+ three-level fidelity model.
984
+
985
+ ## Model coverage, quantization, batching, dropout
986
+
987
+ Four cross-cutting requirements, each grounded in what the code already does.
988
+
989
+ ### Mixed-precision quantized base
990
+
991
+ **Already handled in the layer stack — ORPO inherits it free.** Each
992
+ [`QuantizedLinear`](../../src/model/gemma4-base.ts) carries its **own**
993
+ `QuantSpec` (bits, group size) from `quantFor(config.quantization, path)`, and
994
+ its `forward` is `quantizedMatmul(x, w, scales, biases, spec)` + the f32 LoRA
995
+ residual. mlx's `quantized_matmul` has a working vjp **w.r.t. `x`** (the entire
996
+ project trains LoRA on a 4-bit base today), and the base weights are frozen
997
+ (grads flow only through the f32 A/B). "Mixed precision" just means a different
998
+ `spec` per call — nothing special on the layer-stack backward.
999
+
1000
+ Two things ORPO must get right for mixed precision:
1001
+
1002
+ 1. **Vocab-chunked head over a quantized, possibly-mixed head.** The fused
1003
+ log-prob engine (lever 1) tiles the LM head along the vocab axis. The head is
1004
+ quantized in every model — tied `QuantizedEmbedding.asLinear`
1005
+ (Gemma, tied Qwen) or a separate `QuantizedLinear` `lmHead` (MiniCPM5,
1006
+ untied Qwen) — so a vocab chunk is a **row-slice of the quantized
1007
+ `w`/`scales`/`biases`** fed to `quantizedMatmul` per chunk. One spec covers
1008
+ the whole head, so chunking is a clean slice; this is the only quant-aware
1009
+ piece of new code.
1010
+ 2. **Wire the `bitsMap` so `by_bits`/`by_kl` actually fire. ✅ DONE.** This is
1011
+ optiq's mixed-precision mechanism, confirmed from its source: a calibration
1012
+ **sensitivity pass** measures each layer's KL divergence when quantized
1013
+ (`optiq/core/sensitivity.py`), the most-sensitive layers are kept at 8-bit,
1014
+ and **the same KL signal also sets the LoRA rank** — `by_bits`:
1015
+ `rank·(bits/4)` (4-bit→r8, 8-bit→r16), `by_kl`: `rank·clip(kl/median,0.5,2)`.
1016
+ optiq's words: *"the same layers kept at 8-bit also get more adapter rank —
1017
+ one signal, two optimizations."* It is **rank scaling, not LR/loss/gradient
1018
+ weighting** — "more signal to the 8-bit layers" = more adapter capacity
1019
+ there. Our [`rank.ts`](../../src/train/rank.ts) was already a faithful port of
1020
+ optiq's `sensitivity_rank.py`, but the trainer never supplied the maps, so it
1021
+ silently went uniform. Fixed: `bitsMapFromModel` reads each target's
1022
+ `linear.spec.bits` (authoritative, always present), `readPerLayerKl` reads
1023
+ `optiq_metadata.json` for `by_kl`; the trainer builds both and passes them to
1024
+ `resolveRanks`, and emits the resulting rank spread. Benefits SFT/DPO too;
1025
+ ORPO inherits it. (Unit-tested weight-free in
1026
+ [`tests/train-rank.test.ts`](../../tests/train-rank.test.ts).)
1027
+
1028
+ ### Per-model specialized loops vs the monolith
1029
+
1030
+ **Preference: a per-model loop-unrolled, branch-deleted training path, with a
1031
+ monolithic fallback** — and the codebase already has this exact pattern for the
1032
+ forward path. [`scripts/gen-model.ts`](../../scripts/gen-model.ts) generates
1033
+ specialized files in [`src/model/generated/`](../../src/model/generated/)
1034
+ (`gemma4-12b/e4b/26b`) that unroll the per-layer loop, bake per-layer constants
1035
+ (cache index, sliding/full window, **per-layer quant bits/group size**), and
1036
+ delete `instanceof`/conditional branches; [`factory.ts`](../../src/model/factory.ts)
1037
+ dispatches `GENERATED.get(configFingerprint(config)) ?? Gemma4Model` — generated
1038
+ if the fingerprint matches, **monolith otherwise**.
1039
+
1040
+ Plan, mirroring that structure for training:
1041
+
1042
+ - **Monolith first.** The generic `SegmentedBackward` (MiniCPM5) /
1043
+ `SegmentedBackwardGemma4` call stock `runLayerRange` — they cover Gemma + CPM5
1044
+ today. Qwen has no segmented backward yet (needs `SSMCache` + a gated-delta
1045
+ custom-kernel vjp), so **Qwen ORPO lands first on the full (un-segmented)
1046
+ monolithic backward**; segmented Qwen is a follow-on.
1047
+ - **Then generate the specialized segment-forward** per model (Qwen / Gemma /
1048
+ CPM5) — the unrolled, branch-deleted function the per-segment vjp calls —
1049
+ emitted by extending `gen-model.ts`, fingerprint-dispatched, with the monolith
1050
+ as the miss fallback. Same proven mechanism, applied to the training segment
1051
+ forward instead of the decode forward. (The `mx.compile` flat-DAG path in
1052
+ [`compiled-decode.ts`](../../src/model/compiled-decode.ts) is the other
1053
+ specialization lever, for reference.)
1054
+
1055
+ All three target families exist: [`gemma4.ts`](../../src/model/gemma4.ts),
1056
+ [`minicpm5.ts`](../../src/model/minicpm5.ts),
1057
+ [`qwen3_5.ts`](../../src/model/qwen3_5.ts) (gated-delta hybrid).
1058
+
1059
+ ### Batching
1060
+
1061
+ DPO batching already exists — [`iterateDpoBatches`](../../src/train/dataset.ts)
1062
+ B>1 pads each branch to its own batch-max with response masks, and
1063
+ [`forward.ts`](../../src/train/forward.ts) has the padding-aware `[B,1,L,L]`
1064
+ batched attention mask. ORPO reuses all of it: the **chunked fused log-prob
1065
+ engine tiles over the `(B · response-token)` axis**, so B>1 falls out, with the
1066
+ length-normalization done per row.
1067
+
1068
+ The real constraint is the **regime matrix** — the memory-optimized paths
1069
+ (segmented backward, response-only head) are **B=1 only** today:
1070
+
1071
+ | Regime | Path | Use when |
1072
+ |---|---|---|
1073
+ | **B>1 monolithic** | batched padded forward + chunked loss, no segmentation | short context, headroom for B rows |
1074
+ | **B=1 segmented** | segmented/intra-layer backward, response-only/chunked head | long context (the memory-bound case) |
1075
+
1076
+ At long context the intended lever is **`grad_accumulation_steps`** (raise
1077
+ effective batch with no extra peak) rather than true B>1. ✅ **Wired** (all three
1078
+ loops, sft/dpo/orpo): the shared `accumulateStep` helper in
1079
+ [`trainer.ts`](../../src/train/trainer.ts) runs `grad_accumulation_steps`
1080
+ micro-batches per optimizer step and mean-accumulates their grads (each scaled by
1081
+ `1/N`) before a single `opt.step`. Each micro-batch's grads (and the running
1082
+ accumulator) are eval'd before the next micro-batch's forward builds, so only one
1083
+ micro-batch's activations are ever live — **peak memory does not grow with `N`**
1084
+ (measured ~1.02× at N=3 for both SFT and ORPO on MiniCPM5; composes with the
1085
+ segmented and gradient-checkpoint paths). `N=1` is a byte-for-byte pass-through.
1086
+ The accumulation math is proven deterministically in
1087
+ [`tests/train-gradaccum.test.ts`](../../tests/train-gradaccum.test.ts) and
1088
+ demonstrated end-to-end in
1089
+ [`scripts/experiments/parity-gradaccum.ts`](../../scripts/experiments/parity-gradaccum.ts).
1090
+ True batched segmented backward is a larger effort (pad-aware boundaries); not
1091
+ required for a first landing.
1092
+
1093
+ ### Dropout
1094
+
1095
+ **Currently a dangling stub:** the `/finetune` UI has an `f-dropout` field that
1096
+ is sent in the submit payload, but it is **not** in `FinetuneSubmit` / the
1097
+ trainer, and the saved config hardcodes `lora_dropout: 0.0`
1098
+ ([`lora-params.ts`](../../src/train/lora-params.ts)). Wire it:
1099
+ `lora_dropout` → `FinetuneSubmit` → `TrainConfig` → a **training-only** dropout
1100
+ on the LoRA input in the adapter forward (PEFT applies dropout to `x` before
1101
+ `A`). Default `0.0`; Labonne's ORPO-LoRA uses `0.05`.
1102
+
1103
+ - **ORPO note:** TRL sets `disable_dropout=True` on the *base* model so
1104
+ chosen/rejected log-probs are deterministic — our quantized base has no
1105
+ dropout modules, so only the **LoRA-adapter dropout** is in play. Keep it.
1106
+ - **Correctness constraint that must be flagged:** our segmented backward and
1107
+ gradient checkpointing **recompute the forward inside the vjp**. If dropout
1108
+ draws a fresh mask on the recompute, the gradient is **wrong**. So when
1109
+ dropout > 0, the dropout mask must be **deterministic per step** — draw an RNG
1110
+ key once per step and **replay the same key** in every recompute (forward and
1111
+ each per-segment vjp). Default `0.0` sidesteps this entirely; enabling dropout
1112
+ requires the key-replay plumbing. This also interacts with the
1113
+ chunked-loss recompute. Note it in the implementation.
1114
+
1115
+ ## Experiment: before/after ORPO on Gemma e4b (IFEval)
1116
+
1117
+ The end-goal quality experiment — does ORPO *meaningfully improve* the model?
1118
+ Train on **UltraFeedback** preference pairs, measure **IFEval**
1119
+ (instruction-following) before vs after. IFEval is the primary metric because
1120
+ its checks are **programmatically verifiable** — no LLM judge, fully
1121
+ reproducible (AlpacaEval/MT-Bench would need a GPT-4-class judge; a follow-on).
1122
+
1123
+ Pieces built (all unit-tested, judge-free):
1124
+ - [`src/eval/ifeval.ts`](../../src/eval/ifeval.ts) — verifiable-instruction
1125
+ scorer (keywords / length / format / case / start-end / punctuation; prompt-
1126
+ and instruction-level accuracy). [`tests/ifeval.test.ts`](../../tests/ifeval.test.ts).
1127
+ - [`src/eval/ultrafeedback.ts`](../../src/eval/ultrafeedback.ts) +
1128
+ [`scripts/curate-ultrafeedback.ts`](../../scripts/curate-ultrafeedback.ts) —
1129
+ binarized UltraFeedback → `{prompt, chosen, rejected}` with length filtering.
1130
+ [`tests/ultrafeedback.test.ts`](../../tests/ultrafeedback.test.ts).
1131
+ - [`scripts/run-ifeval.ts`](../../scripts/run-ifeval.ts) — generate + score, with
1132
+ or without an adapter (the before/after runner).
1133
+ - [`scripts/bench-orpo.ts`](../../scripts/bench-orpo.ts) — track step time/peak.
1134
+
1135
+ Runbook (the generate/train steps are yours to run — they're long / download):
1136
+ 1. **Curate:** export `HuggingFaceH4/ultrafeedback_binarized` to JSONL, then
1137
+ `bun scripts/curate-ultrafeedback.ts uf.jsonl data/uf-pref 2048`.
1138
+ 2. **Baseline:** `MODEL=<e4b> bun scripts/run-ifeval.ts ifeval.jsonl` → record.
1139
+ 3. **Fit check:** `MODEL=<e4b> CHUNK=512 bun scripts/bench-orpo.ts` to confirm
1140
+ the config fits 24 GB (e4b's 262k vocab makes the chunked head matter).
1141
+ 4. **Train ORPO** (e4b is Gemma → set the no-vjp kernels off):
1142
+ `MLX_BUN_FUSED_GELU=0 MLX_BUN_PERF_KERNEL=0` + `method:"orpo"`,
1143
+ `learning_rate 1e-5`, `orpo_lambda 0.1`, `orpo_chunk_size 512`,
1144
+ `max_seq_length ~1024`, `rank 16`. ⚠️ Segmented backward for ORPO is
1145
+ **MiniCPM5-only** today (`SegmentedBackwardOrpoGemma4` is a follow-on), so
1146
+ e4b ORPO runs the non-segmented path — rely on the **chunked head** + a modest
1147
+ `max_seq_length` to fit; longer context needs the Gemma segmented port.
1148
+ 5. **After:** `MODEL=<e4b> bun scripts/run-ifeval.ts ifeval.jsonl --adapter <out>`
1149
+ → compare prompt/instruction accuracy to the baseline.
1150
+
1151
+ The curated set should bias toward instruction-following pairs so the IFEval
1152
+ signal moves (UltraFeedback is broad; a topic/length filter focused on
1153
+ formatting/constraint-following prompts sharpens the before/after delta).
1154
+
1155
+ **Prompt masking (verified):** the NLL / odds-ratio is computed on the
1156
+ **completion only** — `respMask` zeroes the prompt and the loss sums only
1157
+ `mask[t+1]==1` positions. This holds even for a huge prompt + tiny completion
1158
+ (e.g. a 13 KB policy/transcript prompt + a ~100-char JSON answer): left-
1159
+ truncation preserves the boundary (`promptLen = maxLen − completionLen`). The
1160
+ *only* way the prompt leaks in is a completion that is itself ≥ `max_seq_length`
1161
+ — `loadDpoDataset` warns loudly if that happens. Regression-tested in
1162
+ [`tests/dpo-masking.test.ts`](../../tests/dpo-masking.test.ts).
1163
+
1164
+ **Small sets over-fit fast** (~450 pairs): keep `orpo_lambda` modest (default
1165
+ `0.1`), carve a ~50-pair `valid.jsonl` (curate with `valFrac ≈ 0.1`), and watch
1166
+ the **val margin** (mean chosen−rejected log-odds) — it's emitted per
1167
+ `steps_per_eval` and persisted to `metrics.json` as `bestMargin` (a better
1168
+ checkpoint signal than val loss). `save_checkpoints: true` keeps every eval
1169
+ checkpoint so you can mount the best-margin one.
1170
+
1171
+ ## Resolved decisions
1172
+
1173
+ - **LR / λ defaults** — `learning_rate=1e-5`, `orpo_lambda=0.1`, cosine
1174
+ schedule, warmup ≈3% of iters (see Recommended defaults). Revisit after a
1175
+ real run.
1176
+ - **Schedule knobs** — **method-prefixed**: `orpo_*` for ORPO, `dpo_*` for DPO
1177
+ (no shared fields).
1178
+ - **Format naming** — rename the `dpo` dataset format to `preference` with
1179
+ `dpo` kept as an alias, now that two methods consume it.
1180
+ - **Mixed-precision quant** — inherited free in the layer stack (per-linear
1181
+ `QuantSpec` + working `quantized_matmul` vjp). New quant-aware code is limited
1182
+ to the vocab-chunked head. Also wire `bitsMap` from `linear.spec.bits` so
1183
+ `by_bits` rank scaling fires (fixes a known silent fallback; helps SFT/DPO).
1184
+ - **Per-model paths** — monolith first (Gemma + CPM5 segmented; Qwen full
1185
+ backward), then generated per-model unrolled segment-forwards via
1186
+ `gen-model.ts`, fingerprint-dispatched with the monolith as fallback.
1187
+ - **Batching** — reuse DPO batching; chunked loss makes B>1 fall out.
1188
+ Memory-optimized paths stay B=1; `grad_accumulation_steps` is the intended
1189
+ long-context lever but is **not yet consumed by any loop** (tracked follow-on).
1190
+ True batched segmented backward is a follow-on.
1191
+ - **Dropout** — wire the existing UI stub through to a training-only LoRA-input
1192
+ dropout (default `0.0`). If > 0, the dropout key must be drawn per step and
1193
+ **replayed** in every recompute (segmented/checkpoint/chunked) or gradients
1194
+ diverge.
1195
+ - **Regularization (open — small sweep)** — rank is the primary regularizer;
1196
+ adopt **rsLoRA** (`α/√r`) so `rank_scaling` regularizes capacity, not step
1197
+ size; consider **LoRA+** (higher LR for B, which does the early learning under
1198
+ zero-init); default **weight decay lower than SFT** (the SFT-NLL term already
1199
+ anchors); cosine + warmup. See [Regularization & optimizer geometry](#regularization--optimizer-geometry).