mlx-bun 0.0.4 → 0.0.6

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