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.
- package/README.md +175 -93
- package/docs/dag/training-inference-map.html +575 -0
- package/docs/design/adapters-end-to-end.md +50 -46
- package/docs/design/compat-cli-surface-design.md +10 -8
- package/docs/design/docs-reorg-plan.md +14 -1
- package/docs/design/hlg-sampling.md +12 -2
- package/docs/design/memory-system.md +193 -295
- package/docs/design/optimization_plan.md +4 -2
- package/docs/design/orpo-dynamic-lambda.md +179 -0
- package/docs/design/orpo-future-enhancements.md +340 -0
- package/docs/design/orpo-training.md +1212 -0
- package/docs/design/parallel-slots.md +34 -26
- package/docs/design/parity-tier-dag.md +143 -0
- package/docs/design/runtime-isolation.md +1 -1
- package/docs/design/segmented-backward-training.md +58 -39
- package/docs/design/tauri-desktop-app.md +127 -0
- package/docs/design/w4a16-compute-precision-spike.md +232 -0
- package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
- package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
- package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
- package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
- package/docs/investigations/starter-model-port-handoff.md +32 -13
- package/docs/investigations/steel-flash-cce-handoff.md +489 -0
- package/docs/investigations/trainer-validation-experiment.md +173 -0
- package/docs/planning/IDEAS.md +29 -0
- package/docs/planning/PRODUCT_ROADMAP.md +147 -27
- package/docs/planning/chunk-task-roadmap.md +1 -1
- package/docs/planning/journal.md +161 -0
- package/docs/reference/distribution.md +61 -5
- package/docs/reference/library-api.md +29 -14
- package/docs/reference/memory.md +77 -0
- package/docs/reference/orpo-quickstart.md +130 -0
- package/docs/reference/server-api.md +105 -7
- package/docs/reference/server-config.md +12 -5
- package/docs/reference/training.md +296 -0
- package/package.json +1 -1
- package/src/cli.ts +651 -18
- package/src/eval/ifeval.ts +138 -0
- package/src/eval/runner.ts +76 -6
- package/src/eval/tasks/bfcl.ts +49 -15
- package/src/eval/tasks/gsm8k.ts +21 -9
- package/src/eval/tasks/hashhop.ts +33 -4
- package/src/eval/tasks/humaneval.ts +30 -8
- package/src/eval/tasks/ifeval.ts +52 -18
- package/src/eval/tasks/mmlu.ts +85 -74
- package/src/eval/ultrafeedback.ts +60 -0
- package/src/jobs/runner.ts +6 -0
- package/src/lora.ts +12 -7
- package/src/memory/chunk.ts +25 -0
- package/src/memory/cluster.ts +24 -0
- package/src/memory/db.ts +114 -0
- package/src/memory/ingest.ts +22 -0
- package/src/memory/pipeline.ts +63 -0
- package/src/memory/prompts.ts +23 -0
- package/src/memory/schedule.ts +163 -0
- package/src/memory/synthesize.ts +29 -0
- package/src/memory/tools.ts +399 -0
- package/src/memory/vault.ts +694 -0
- package/src/memory/wikify.ts +23 -0
- package/src/mlx/metal-kernel.ts +31 -4
- package/src/model/flash-attention.ts +37 -19
- package/src/model/fused-geglu-kernel.ts +111 -0
- package/src/model/gemma4-base.ts +39 -1
- package/src/model/gemma4.ts +196 -21
- package/src/model/minicpm5.ts +44 -3
- package/src/pi-provider.ts +29 -13
- package/src/pi-session.ts +81 -0
- package/src/pi-terminal.ts +13 -24
- package/src/pi-web.ts +246 -166
- package/src/quantize/job.ts +17 -2
- package/src/quantize/quantizer.ts +26 -2
- package/src/server.ts +216 -47
- package/src/tokenizer.ts +6 -2
- package/src/train/dataset.ts +29 -6
- package/src/train/flash-cce.ts +1175 -0
- package/src/train/job.ts +64 -3
- package/src/train/lora-params.ts +53 -4
- package/src/train/loss.ts +947 -0
- package/src/train/optimizer.ts +10 -3
- package/src/train/prefix-shared.ts +435 -0
- package/src/train/rank.ts +36 -0
- package/src/train/segmented.ts +1148 -3
- package/src/train/steel-qmm-header.ts +1461 -0
- package/src/train/trainer.ts +570 -54
- package/src/train/watch.ts +542 -0
- package/src/web/app.html +285 -19
- package/src/web/skills/memory/SKILL.md +97 -0
- package/src/web/skills/web-research/SKILL.md +3 -1
- package/src/web/skills.ts +46 -8
- /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).
|