mlx-bun 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +175 -93
  2. package/docs/dag/training-inference-map.html +575 -0
  3. package/docs/design/adapters-end-to-end.md +50 -46
  4. package/docs/design/compat-cli-surface-design.md +10 -8
  5. package/docs/design/docs-reorg-plan.md +14 -1
  6. package/docs/design/hlg-sampling.md +12 -2
  7. package/docs/design/memory-system.md +193 -295
  8. package/docs/design/optimization_plan.md +4 -2
  9. package/docs/design/orpo-dynamic-lambda.md +179 -0
  10. package/docs/design/orpo-future-enhancements.md +340 -0
  11. package/docs/design/orpo-training.md +1212 -0
  12. package/docs/design/parallel-slots.md +34 -26
  13. package/docs/design/parity-tier-dag.md +143 -0
  14. package/docs/design/runtime-isolation.md +1 -1
  15. package/docs/design/segmented-backward-training.md +58 -39
  16. package/docs/design/tauri-desktop-app.md +127 -0
  17. package/docs/design/w4a16-compute-precision-spike.md +232 -0
  18. package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
  19. package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
  20. package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
  21. package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
  22. package/docs/investigations/starter-model-port-handoff.md +32 -13
  23. package/docs/investigations/steel-flash-cce-handoff.md +489 -0
  24. package/docs/investigations/trainer-validation-experiment.md +173 -0
  25. package/docs/planning/IDEAS.md +29 -0
  26. package/docs/planning/PRODUCT_ROADMAP.md +147 -27
  27. package/docs/planning/chunk-task-roadmap.md +1 -1
  28. package/docs/planning/journal.md +161 -0
  29. package/docs/reference/distribution.md +61 -5
  30. package/docs/reference/library-api.md +29 -14
  31. package/docs/reference/memory.md +77 -0
  32. package/docs/reference/orpo-quickstart.md +130 -0
  33. package/docs/reference/server-api.md +105 -7
  34. package/docs/reference/server-config.md +12 -5
  35. package/docs/reference/training.md +296 -0
  36. package/package.json +1 -1
  37. package/src/cli.ts +651 -18
  38. package/src/eval/ifeval.ts +138 -0
  39. package/src/eval/runner.ts +76 -6
  40. package/src/eval/tasks/bfcl.ts +49 -15
  41. package/src/eval/tasks/gsm8k.ts +21 -9
  42. package/src/eval/tasks/hashhop.ts +33 -4
  43. package/src/eval/tasks/humaneval.ts +30 -8
  44. package/src/eval/tasks/ifeval.ts +52 -18
  45. package/src/eval/tasks/mmlu.ts +85 -74
  46. package/src/eval/ultrafeedback.ts +60 -0
  47. package/src/jobs/runner.ts +6 -0
  48. package/src/lora.ts +12 -7
  49. package/src/memory/chunk.ts +25 -0
  50. package/src/memory/cluster.ts +24 -0
  51. package/src/memory/db.ts +114 -0
  52. package/src/memory/ingest.ts +22 -0
  53. package/src/memory/pipeline.ts +63 -0
  54. package/src/memory/prompts.ts +23 -0
  55. package/src/memory/schedule.ts +163 -0
  56. package/src/memory/synthesize.ts +29 -0
  57. package/src/memory/tools.ts +399 -0
  58. package/src/memory/vault.ts +694 -0
  59. package/src/memory/wikify.ts +23 -0
  60. package/src/mlx/metal-kernel.ts +31 -4
  61. package/src/model/flash-attention.ts +37 -19
  62. package/src/model/fused-geglu-kernel.ts +111 -0
  63. package/src/model/gemma4-base.ts +39 -1
  64. package/src/model/gemma4.ts +196 -21
  65. package/src/model/minicpm5.ts +44 -3
  66. package/src/pi-provider.ts +29 -13
  67. package/src/pi-session.ts +81 -0
  68. package/src/pi-terminal.ts +13 -24
  69. package/src/pi-web.ts +246 -166
  70. package/src/quantize/job.ts +17 -2
  71. package/src/quantize/quantizer.ts +26 -2
  72. package/src/server.ts +216 -47
  73. package/src/tokenizer.ts +6 -2
  74. package/src/train/dataset.ts +29 -6
  75. package/src/train/flash-cce.ts +1175 -0
  76. package/src/train/job.ts +64 -3
  77. package/src/train/lora-params.ts +53 -4
  78. package/src/train/loss.ts +947 -0
  79. package/src/train/optimizer.ts +10 -3
  80. package/src/train/prefix-shared.ts +435 -0
  81. package/src/train/rank.ts +36 -0
  82. package/src/train/segmented.ts +1148 -3
  83. package/src/train/steel-qmm-header.ts +1461 -0
  84. package/src/train/trainer.ts +570 -54
  85. package/src/train/watch.ts +542 -0
  86. package/src/web/app.html +285 -19
  87. package/src/web/skills/memory/SKILL.md +97 -0
  88. package/src/web/skills/web-research/SKILL.md +3 -1
  89. package/src/web/skills.ts +46 -8
  90. /package/{docs → src/assets}/curve-designer.html +0 -0
@@ -0,0 +1,179 @@
1
+ # Dynamic λ controller for ORPO (adaptive preference pressure)
2
+
3
+ **Status:** proposed (design only — not built). **Opened:** 2026-06-20.
4
+ **Surface:** `src/train/loss.ts` (orpoMetrics/orpoLoss), `src/train/trainer.ts`
5
+ (orpoLoop, the λ config), the segmented ORPO classes in `src/train/segmented.ts`.
6
+
7
+ ## Motivation
8
+
9
+ ORPO's loss is `L = NLL(chosen) + λ·OR`. The two terms learn on different
10
+ timescales, and a **static λ** can't serve both:
11
+
12
+ - **The SFT/NLL half learns easily** — the model quickly raises `logp(chosen)`
13
+ (fits the response *style*). Observed live: val loss falls steadily while
14
+ preference accuracy/margin barely move.
15
+ - **The preference/OR half is hard** — on subtle data (UltraFeedback on a 1B) the
16
+ model doesn't generalize "which of two plausible answers is better."
17
+ - **Over-weighting OR backfires** via **likelihood displacement**: the model widens
18
+ the *relative* margin by **crushing `logp(rejected)`** (and sometimes dropping
19
+ `logp(chosen)` too) rather than genuinely preferring chosen — degrading fluency.
20
+
21
+ A fixed λ either under-pressures the preference (λ too low → never leans on OR) or
22
+ over-pressures it (λ too high → displacement/degeneration). The hypothesis: an
23
+ **adaptive, displacement-aware λ** — low while SFT learns, ramped once SFT
24
+ saturates, backed off the moment displacement appears — can find a generalization
25
+ regime a static λ sails past. (Static λ tuning was the blunt version we rejected;
26
+ this is the targeted version.)
27
+
28
+ ## Control signals (what we measure)
29
+
30
+ `orpoMetrics` (`loss.ts:1249`) already computes everything; we just don't surface it:
31
+
32
+ - **`lw`** = mean `logp(chosen)` (currently folded into `nll = mean(−lw)`).
33
+ - **`lr`** = mean `logp(rejected)` (currently only used inside the odds ratio).
34
+ - **`margin`** = mean log-odds (chosen vs rejected) — already emitted.
35
+ - **`accuracy`** = fraction `lw > lr` — already emitted.
36
+
37
+ The controller keys on the **decomposition of margin growth**:
38
+ - margin ↑ because **`lw` ↑** → *healthy* (genuinely preferring chosen).
39
+ - margin ↑ because **`lr` ↓** while `lw` flat/falling → *displacement* (the cheat).
40
+
41
+ So the load-bearing new instrumentation is **surfacing `lw` and `lr` separately**
42
+ per eval (not just `nll`/`margin`).
43
+
44
+ ## The controller (state machine)
45
+
46
+ Runs at **each val eval** (every `stepsPerEval` steps) — val (`lw`, `lr`) is the
47
+ stable signal; per-step B=1 train metrics are too noisy to control on. Maintains a
48
+ short window of recent val (`lw`, `lr`, margin, accuracy).
49
+
50
+ ```
51
+ state: { phase, lambda, window[] } # lambda starts at LAMBDA_MIN
52
+ on each val eval (lw, lr, margin, acc):
53
+ dLw = lw - lw_prev ; dLr = lr - lr_prev
54
+
55
+ PHASE 1 — SFT warmup (lambda = LAMBDA_MIN, low):
56
+ stay while the NLL is still improving (dLw > EPS_SFT, i.e. chosen logp rising).
57
+ when dLw plateaus (|dLw| < EPS_SFT over K evals) → SFT saturated → PHASE 2.
58
+
59
+ PHASE 2 — preference ramp:
60
+ each eval, lambda += RAMP (cap LAMBDA_MAX), applying OR pressure —
61
+ AS LONG AS chosen holds: dLw >= -EPS_DISP.
62
+ if accuracy/margin respond (margin widening with dLw >= 0) → keep ramping.
63
+ if displacement detected (dLw < -EPS_DISP, i.e. chosen logp dropping, or
64
+ dLr << 0 with dLw <= 0) → PHASE 3.
65
+
66
+ PHASE 3 — displacement backoff:
67
+ lambda -= BACKOFF (floor LAMBDA_MIN) until chosen recovers (dLw >= 0),
68
+ then hold (hysteresis) before considering another ramp.
69
+ ```
70
+
71
+ Knobs (all tunable): `LAMBDA_MIN≈0.05`, `LAMBDA_MAX≈0.5`, `RAMP/BACKOFF≈0.05`,
72
+ window `K≈3` evals, `EPS_SFT`/`EPS_DISP` set from the observed val-`lw` noise scale.
73
+ Hysteresis (require K consecutive evals before a phase flip) prevents oscillation.
74
+
75
+ **Degeneration guard (hard stop):** if `lw` falls below its phase-1 starting value
76
+ by more than a threshold, freeze λ at `LAMBDA_MIN` and log — the model is paying for
77
+ margin with chosen's likelihood, which a backoff alone isn't recovering.
78
+
79
+ ## Control-theory framing (PID / AIMD) — added 2026-06-23
80
+
81
+ The phase state-machine above is one instance of a more general and more honest
82
+ frame: **this is closed-loop control.** The **process variable** is the SFT signal
83
+ (NLL / `lw`), the **actuator** is the preference weight (ORPO's λ, *or* SimPO's
84
+ β/γ — the controller is objective-agnostic), and the **control law** is what maps the
85
+ measured degradation back to the actuator. Two laws worth naming:
86
+
87
+ - **AIMD** (additive-increase / multiplicative-decrease — TCP congestion control):
88
+ ramp the preference up *gently* while the SFT is healthy, **slam it down hard** the
89
+ moment degradation ("congestion") appears. Famously robust and forgiving of its own
90
+ knobs. The PHASE-2 ramp / PHASE-3 backoff is essentially AIMD.
91
+ - **PID** decomposes the response into three terms, each fixing a failure the others
92
+ can't:
93
+ - **P (proportional · the level):** how degraded is `lw` *right now* → back off in proportion. (Leaves a steady-state offset.)
94
+ - **D (derivative · the slope):** is degradation *accelerating* → a **leading indicator** that catches the helping→hurting **inflection before the level rises**, so you pull back *earlier* and leave less damage to heal. **Gotcha:** differentiating a noisy signal amplifies noise → use a *robust* slope (slope of the EMA, or a windowed regression), never raw step deltas. The slope is where most of the value is.
95
+ - **I (integral · accumulated error):** the "missing letter." P leaves a chronic offset and D ignores it (flat slope at steady-state) → the model can settle hovering with a *small persistent* NLL elevation = quiet permanent SFT damage. I accumulates that residual and drives `lw` **back to its floor**. **Gotcha — integral windup:** if the actuator is pinned (λ at floor) while error persists, the integral piles up and **overshoots** on recovery → needs **anti-windup** (clamp/stop accumulating when saturated).
96
+
97
+ **Build it incrementally — P → PD → PID** — each term earning its place against an
98
+ *observed* failure of the simpler controller (P oscillates/offsets → add D; chronic
99
+ offset remains → add I with anti-windup). That's both good control practice and an
100
+ **ablation that writes the experimental section**, and it stops us from tuning three
101
+ gains blind.
102
+
103
+ **Why a controller beats a sweep (the real argument):** the optimal preference
104
+ pressure is **non-stationary** — fragile early (push less), robust late (push more) —
105
+ so **no fixed λ is optimal.** A sweep finds the best *constant*; the controller tracks
106
+ the moving target and finds the best *trajectory*, which strictly dominates. The pitch
107
+ isn't just "one run instead of six," it's "**beats any fixed value** a sweep could
108
+ find." (The displacement backoff is also why this is *more* than auto-tuning — it
109
+ targets the degradation signal directly.)
110
+
111
+ **Degenerate failure to guard (beyond the §degeneration hard-stop):** if the law backs
112
+ off too eagerly it **collapses the preference term to ~0 → you've trained pure SFT.**
113
+ The AIMD/PID balance must **favor pushing** (aggressive-ish increase, retreat only on
114
+ *confirmed* degradation), and target a small **safety margin below the cliff** rather
115
+ than the absolute max-bearable (the edge is fragile).
116
+
117
+ **Synergy with the diffusion frontier:** all of this is noise-robustness machinery for
118
+ controlling off a noisy process variable — so it transfers *especially* well to a
119
+ diffusion LM, where the likelihood is a stochastic ELBO (even noisier). See
120
+ [orpo-base-uf-experiment-and-directions.md](../investigations/orpo-base-uf-experiment-and-directions.md) §6.7.
121
+
122
+ ## Wiring
123
+
124
+ 1. **Instrument (small):** add `lw`, `lr` to `orpoMetrics`'s return + the trainer's
125
+ `metric` emit (train and val). `pref-control.ts` / `metrics.json` log them so the
126
+ controller (and our plots) can see the decomposition.
127
+ 2. **Make λ dynamic:** today `cfg.orpoLambda` is a constant threaded into
128
+ `orpoLoss(model, batch, cfg.orpoLambda, …)` and **baked into the segmented ORPO
129
+ classes' constructors** (`new SegmentedBackwardOrpo(model, lora, ranges,
130
+ cfg.orpoLambda, …)`). Change to a **mutable λ source**:
131
+ - simplest: a `{ value: number }` box (or a getter) passed instead of the scalar;
132
+ the loss / segmented step reads `lambdaRef.value` each step.
133
+ - the segmented classes expose a `setLambda(x)` (or read the ref) so the
134
+ constructor-baked constant becomes a live field.
135
+ 3. **The controller** lives in `orpoLoop` (`trainer.ts`): after each val eval, feed
136
+ the val (`lw`, `lr`, margin, acc) to `updateLambda(state)` and write the new λ
137
+ into the ref. Log every λ change (`· λ 0.10 → 0.15 (SFT saturated, ramping)`).
138
+ 4. **Off by default:** gate behind a config flag (`orpoLambdaSchedule: "static" |
139
+ "adaptive"`); static = today's behavior, exact.
140
+
141
+ ## Experiment design (how we validate it)
142
+
143
+ Head-to-head, **same data / seed / everything else**, on the from-base UF setup:
144
+ - **Arm A:** static λ=0.1 (the current run — the baseline).
145
+ - **Arm B:** adaptive λ (this controller).
146
+
147
+ Compare:
148
+ - **val accuracy + margin** trajectories (does B clear the noise floor / widen
149
+ margin where A stalls?);
150
+ - **`lw` trajectory** (does B avoid displacement — `lw` non-decreasing — where a
151
+ high static λ would crater it?);
152
+ - the **downstream** numbers: Exp-1 capability x2 and Exp-2 win-rate (does B's model
153
+ actually win more head-to-head?).
154
+
155
+ Success = B generalizes measurably better (val acc clears ~0.55 with margin lifting,
156
+ and/or Exp-2 win-rate > A's) **without** degeneration (`lw` holds). Null result is
157
+ also informative: "even adaptive λ can't beat the 1B capacity ceiling on UF."
158
+
159
+ ## Open questions / risks
160
+
161
+ - **Cadence:** controlling on val every `stepsPerEval` may be too coarse (few control
162
+ steps per run). Option: a cheaper, more frequent "control eval" on a small fixed
163
+ probe set distinct from the reported val set.
164
+ - **Confounds the replication claim:** an adaptive-λ result is no longer "standard
165
+ ORPO" — it's our method. Keep Arm A (static 0.1) as the honest paper-replication;
166
+ Arm B is the "can we do better" research arm. Report both.
167
+ - **Tuning the controller's own knobs** risks the same trap (we'd be tuning a tuner).
168
+ Mitigate: set EPS thresholds from observed noise scale, not by hand-fitting to one
169
+ run; validate the controller on the *positive control* (prefer-uppercase) first —
170
+ it should ramp λ early (SFT saturates fast) and never trip displacement.
171
+
172
+ ## References
173
+
174
+ - `src/train/loss.ts:1249` `orpoMetrics` (computes `lw`,`lr`,`nll`,`or`,`accuracy`,`margin`).
175
+ - `src/train/trainer.ts` `orpoLoop` (λ config, val emit), `DEFAULT_TRAIN_CONFIG.orpoLambda`.
176
+ - `src/train/segmented.ts` segmented ORPO classes (λ baked in constructor).
177
+ - `scripts/experiments/pref-control.ts` (trajectory logging — extend with `lw`,`lr`).
178
+ - Context: [[opssdpa-dk-vjp-bug]] (validate grads vs autograd), the dynamic-λ
179
+ discussion + the live from-base UF val trajectory (2026-06-20).
@@ -0,0 +1,340 @@
1
+ # ORPO future enhancements
2
+
3
+ Performance-first follow-on ideas for mlx-bun's ORPO trainer, plus a short
4
+ appendix of objective variants that may be useful once the runtime path is
5
+ comfortable.
6
+
7
+ > **LANDED since this was written (2026-06-19):** the top items here are now in
8
+ > production — **Priority 1 (Cut Cross Entropy / fused linear CE)** and **§1
9
+ > (vocab-axis chunked CE)** shipped as the `[M,vocab]`-free **flash-CCE Metal head**
10
+ > (verbatim MLX steel GEMM, fwd+bwd; `orpo_flash_ce`), and **§3 (prefix sharing in the
11
+ > trainer)** shipped as `orpo_prefix_shared`, composed with the flash head AND the
12
+ > segmented backward for MiniCPM5 **and** e4b. See [training.md](../reference/training.md),
13
+ > [orpo-quickstart.md](../reference/orpo-quickstart.md), and the live record
14
+ > [steel-flash-cce-handoff.md](../investigations/steel-flash-cce-handoff.md). The
15
+ > remaining sections (§2, §4–§9, the objective variants) are still genuinely future.
16
+
17
+ ## Deferred levers — queued, do NOT start mid-run (2026-06-19)
18
+
19
+ Two optimizations identified during the MiniCPM5 UltraFeedback ORPO run. **Both are
20
+ deferred until the active run finishes** — the GPU is fully occupied, so these get
21
+ *measured* later, not "quickly tested" now (a quick probe steals the training GPU).
22
+
23
+ 1. **Expose `--grad-accum` (gradient accumulation).** The machinery already exists —
24
+ `accumulateStep` (`src/train/trainer.ts:975`) loops `gradAccumSteps` B=1 micro-steps,
25
+ **averages** their grads (`scale = 1/accumSteps`, line 989), and evals+frees each
26
+ micro-graph so **peak memory stays flat** (line 1015). It's wired into the ORPO loop
27
+ (`trainer.ts:837`) but `gradAccumSteps` defaults to 1 and has **no CLI flag**.
28
+ - **Why it helps us:** ORPO here is hard **B=1** (segmented + prefix-share assume a
29
+ single stream — `segmented.ts:636` throws on B>1), so every optimizer update is
30
+ computed from exactly **one** preference pair — the noisiest possible gradient (the
31
+ per-step loss swinging 0.4↔3.3 is that noise). Grad-accum gives an effective batch of
32
+ G — averaging G pairs/update, variance ↓ ~1/G, steadier descent, tolerates a higher
33
+ LR — at **B=1 memory**. It's the *only* way to a bigger effective batch when B>1 is
34
+ impossible.
35
+ - **What it is NOT:** not a speedup (G× passes per optimizer update → same total
36
+ passes, same wall-clock, same data seen); and not a guaranteed win (the prior run
37
+ converged fine at B=1, val 1.66→1.50). It's a **try-and-measure** lever — expose the
38
+ flag, A/B G=4/8 judged on **val-margin**, don't assume.
39
+ - **Note on prefix-share:** the existing `orpo_prefix_shared` is *already* a hand-built
40
+ batch-of-2-with-shared-prefix (1F + 1B over `[P; Rc; Rr]`, prompt deduped in **both**
41
+ directions — strictly cheaper than a generic 2-batch's `2F + 2B`). Generic N-way
42
+ batching would re-duplicate per-pair prefixes and need a block-diagonal mask layer
43
+ *on top of* the prefix-share mask — high friction, and the payoff is dominated by
44
+ grad-accum (effective batch, free) + W4A16 (the real GPU bottleneck). Park batching
45
+ unless a profile ever shows real short-row GPU idle.
46
+
47
+ 2. **W4A16 compute-precision spike.** The head GEMM dequants 4-bit weights into **fp32**
48
+ and matmuls in fp32 (W4A32); dropping the operands to `half` (fp32 accumulate) attacks
49
+ the actual bottleneck. Confirmed **compute-bound live** (GPU 100% / BW ~⅓ of 400 on
50
+ M1 Max). Full plan + parity gating: [w4a16-compute-precision-spike.md](w4a16-compute-precision-spike.md).
51
+
52
+ **Context — seg vs no-segment A/B (MiniCPM5, seq 4096, same-machine M1 Max):**
53
+ `--no-segment` measured **~1.25 s/step steady @ peak 9.26 GB** vs `--seg 2`
54
+ **~1.58 s/step @ 2.62 GB** — ~20% faster for 3.5× peak memory, **bit-identical output**
55
+ (segmenting is just gradient checkpointing). Dropping the segs removed the recompute (real
56
+ GPU work) → GPU busy but no longer pinned. Fine when memory is spare; the current 3-epoch
57
+ run uses `--no-segment`.
58
+
59
+ Product relevance: ORPO is not only a researcher feature. It is the path to
60
+ small, downloadable task adapters that make the default local assistant better
61
+ without forcing every user onto a larger base model. The performance work below
62
+ matters when it turns adapter training from a fragile lab run into a repeatable
63
+ part of the mlx-bun product loop.
64
+
65
+ ## Priority 1: Cut Cross Entropy / Liger Fused Linear CE
66
+
67
+ The top ORPO performance priority is a Cut Cross Entropy / Liger-style Fused
68
+ Linear Cross Entropy path for the LM head. This should be treated as the next
69
+ major kernel project before lower-impact objective variants.
70
+
71
+ Why it is first:
72
+
73
+ - Gemma's vocab is huge, so materializing `[tokens, vocab]` logits dominates
74
+ peak memory in ORPO.
75
+ - ORPO needs chosen and rejected response log-probs, so the head cost is paid
76
+ twice unless the trainer can share or fuse more work.
77
+ - Token-axis chunking already helps fit the run, but vocab-axis chunking cuts
78
+ the largest remaining logits allocation at the source.
79
+ - The same fused engine can support SFT, DPO, ORPO, SimPO, IPO, and hinge-style
80
+ preference losses once it exposes mean log-prob with a custom upstream
81
+ cotangent.
82
+ - A Metal fused version can avoid extra MLX dispatches and intermediate memory
83
+ traffic in the hot training path.
84
+
85
+ The implementation target is not just "chunk logits"; it is a fused log-prob
86
+ head that computes the linear projection, online vocab logsumexp, target-logit
87
+ gather, loss reduction, and backward replay without ever storing full logits.
88
+
89
+ ## Performance focus
90
+
91
+ ### 1. Vocab-axis chunked cross entropy
92
+
93
+ The current ORPO head optimization is **token-axis chunking**: split the response
94
+ positions into chunks, run the LM head on `[chunk, vocab]`, and rematerialize
95
+ those chunks in the backward. That bounds peak logits by `chunk_tokens * vocab`.
96
+
97
+ The complementary trick is **vocab-axis chunking**: split the vocabulary instead,
98
+ run the head on `[tokens, vocab_chunk]`, and combine chunks with an
99
+ online-logsumexp recurrence. This is the idea behind **Cut Cross Entropy** and
100
+ Liger-style **Fused Linear Cross Entropy**. This is priority 1 for the ORPO
101
+ performance roadmap.
102
+
103
+ Exact forward sketch:
104
+
105
+ 1. For each vocab chunk, compute partial logits `h @ W_chunk`.
106
+ 2. Gather the target logit only if the target id falls inside this chunk.
107
+ 3. Maintain per-token online `logsumexp` across vocab chunks.
108
+ 4. At the end, `logp = target_logit - lse`.
109
+
110
+ Backward reconstructs the softmax chunk-by-chunk from the saved per-token LSE
111
+ and never materializes full `[tokens, vocab]` logits. For Gemma's 262k vocab,
112
+ this is likely the next real moat after segmented backward.
113
+
114
+ Two implementation levels:
115
+
116
+ - Pure MLX chunked VJP first: exact, easier to validate, probably enough to fit
117
+ longer contexts.
118
+ - Metal fused kernel later: fuse quantized head matmul + online LSE + target
119
+ gather + backward. This is the high-performance version.
120
+
121
+ ### 2. Fold ORPO gradients into the fused CE path
122
+
123
+ ORPO needs two mean log-probs, `lw` and `lr`, then applies the odds-ratio loss.
124
+ For a fused CE/log-prob kernel, the clean design is to feed the upstream scalar
125
+ gradient for each branch back into the chunked head backward. In other words,
126
+ the fused head should expose "mean logp with custom upstream cotangent", not
127
+ only cross-entropy.
128
+
129
+ This avoids materializing logits while still letting ORPO, SimPO, and future
130
+ preference losses share the same head engine.
131
+
132
+ ### 3. Prefix sharing in the trainer
133
+
134
+ `src/train/prefix-shared.ts` already proves the MiniCPM5 shared-prefix idea:
135
+ one forward over `[prompt; chosen; rejected]` with block-sparse masking and
136
+ block-wise RoPE. It is not yet wired into `orpoLoop`.
137
+
138
+ Next useful path:
139
+
140
+ - Add an opt-in flag, for example `orpo_prefix_sharing`.
141
+ - Gate it by prompt/response ratio so short-prompt data stays on the simple
142
+ two-forward path.
143
+ - Wire MiniCPM5 first.
144
+ - Port e4b later, where sliding-window masks, donor-KV reuse, and per-layer
145
+ input make it trickier.
146
+
147
+ ### 4. Sequence-chunk the MLP sub-block
148
+
149
+ The MLP is per-token independent. Inside segmented backward, run MLP blocks over
150
+ sequence chunks so the GeGLU/SwiGLU intermediate is `chunk_tokens * d_ff` instead
151
+ of `seq_len * d_ff`.
152
+
153
+ This stacks with depth segmentation and vocab-axis CE:
154
+
155
+ `layer segment -> layer -> MLP seq chunk -> vocab chunk`
156
+
157
+ Attention cannot be chunked this naively, but the MLP can.
158
+
159
+ ### 5. Rotor / budget-aware segment planner
160
+
161
+ Replace fixed `segment_size` with a planner that chooses segment boundaries from
162
+ estimated activation bytes and a target memory budget. The planner should isolate
163
+ expensive full-attention layers and larger MLP regions instead of treating every
164
+ layer equally.
165
+
166
+ This is the practical version of rotor/checkpoint scheduling for this codebase:
167
+ less hand tuning, better fit at 8K+.
168
+
169
+ ### 6. Faster flash-attention backward
170
+
171
+ The custom flash backward is now correctness-useful but too slow. A serious
172
+ performance version should mimic MLX's tuned SDPA style: simdgroup tiling,
173
+ larger threadgroups, and FA2-style backward that stores only per-row LSE and
174
+ recomputes attention probabilities.
175
+
176
+ This matters most when `ops.sdpa` backward's O(L^2) memory is the blocker.
177
+
178
+ ### 7. Fused LoRA epilogue
179
+
180
+ Today LoRA adds multiple ops after the quantized base matmul:
181
+
182
+ `base(x) + scale * ((x @ A) @ B)`
183
+
184
+ A fused dequant-GEMM + LoRA epilogue would reduce dispatches and intermediate
185
+ traffic. Backward can recompute the cheap rank projection:
186
+
187
+ - `h = x @ A`
188
+ - `dB = scale * h.T @ dy`
189
+ - `dA = scale * x.T @ (dy @ B.T)`
190
+ - `dx = dy @ W.T + scale * (dy @ B.T) @ A.T`
191
+
192
+ This is more kernel work, but it benefits SFT, DPO, ORPO, and serving with
193
+ adapters.
194
+
195
+ ### 8. Shape-specialized compile for training graphs
196
+
197
+ Shapeless compile is mainly required when a dimension changes every replay, such
198
+ as autoregressive decode with a growing KV cache:
199
+
200
+ `active_kv_len = prompt_len + generated_tokens`
201
+
202
+ ORPO training is different. Each dataset row already has concrete lengths:
203
+
204
+ - `P`: prompt length
205
+ - `Rc`: chosen response length
206
+ - `Rr`: rejected response length
207
+ - `T`: prefix-shared length, `P + Rc + Rr`
208
+
209
+ That means ORPO does not need shapeless compile in the same way decode does. A
210
+ training graph can be compiled for an exact `(P, Rc, Rr)` shape, or for a padded
211
+ length bucket, and cached under a key that includes the model, flags, segment
212
+ plan, token/vocab chunk sizes, and objective mode.
213
+
214
+ This is especially relevant for e4b prefix-sharing: the proof path should stay
215
+ eager first, but a later L3 speed tier can use shape-specialized compiled graphs
216
+ for known training shapes instead of requiring every primitive to support
217
+ shapeless output-shape inference.
218
+
219
+ The deeper L3 framing is **model-checksum-specific specialization**. For a fixed
220
+ model snapshot, we know the architecture, layer count, hidden sizes, quantization
221
+ layout, RoPE geometry, vocab, layer types, KV-sharing pattern, and supported
222
+ context budget in advance. It is acceptable to spend much more work up front if
223
+ that buys a faster hot path later.
224
+
225
+ Practical version:
226
+
227
+ - Treat the model snapshot checksum as part of the compilation key.
228
+ - Generate/compile/cache a table of exact graph variants for the shapes and
229
+ modes we care about.
230
+ - Bucket or pad examples into those graph variants when helpful.
231
+ - Fall back to eager only for shapes/modes not in the table.
232
+
233
+ This is effectively a lookup table of specialized execution plans: more compile
234
+ and analysis cost up front, less branching, shape handling, and dispatch overhead
235
+ at runtime.
236
+
237
+ ### 9. Fixed-shape decode cache as a compile route
238
+
239
+ Decode is harder because the active KV length grows by one token per step. The
240
+ current shapeless-compile path exists to avoid recompiling for:
241
+
242
+ `L = 17, 18, 19, 20, ...`
243
+
244
+ A separate route is to make decode shapes fixed:
245
+
246
+ 1. Preallocate K/V buffers to a known maximum context length.
247
+ 2. Write the new token into a dynamic position.
248
+ 3. Carry `active_length` / `write_position` as scalar array inputs.
249
+ 4. Mask out inactive cache positions, or preferably use an
250
+ active-length-aware attention kernel that simply does not scan them.
251
+
252
+ The correctness version is "fixed cache + mask". The performance version is
253
+ "fixed cache + active-length-aware attention"; otherwise early decode steps may
254
+ pay for the full max context even when only a short prefix is active.
255
+
256
+ There is also a more aggressive specialization route: prebuild a graph table for
257
+ specific active lengths or length buckets. Even if that means many compiled graph
258
+ variants, the trade can be valid for a fixed model/checksum if compile cost is
259
+ paid once and reused. In that regime, the important design question is not "can
260
+ one shapeless graph infer every shape?" but "which finite set of exact shapes and
261
+ runtime states should we specialize for?"
262
+
263
+ If every op in a chosen route is compile-safe, the whole decode graph can become
264
+ a stable compiled graph. Custom Metal kernels still need to stay outside compiled
265
+ regions unless the route is intentionally eager/custom-kernel-only or MLX compile
266
+ can accept the primitive's output contract.
267
+
268
+ ## Quality and robustness ideas
269
+
270
+ These are lower priority if the current goal is performance, but they are useful
271
+ once ORPO can run comfortably.
272
+
273
+ ### SimPO
274
+
275
+ SimPO is a reference-free preference loss that uses the **average log-probability
276
+ of each response** as the reward and adds a target reward margin. It reuses the
277
+ same `lw` / `lr` values ORPO already computes.
278
+
279
+ Rough shape:
280
+
281
+ `loss = softplus(-beta * ((lw - lr) - gamma))`
282
+
283
+ where `gamma` is the desired chosen-vs-rejected margin. It should be cheap to add
284
+ as a sibling objective because the expensive part, response mean log-probs, is
285
+ already implemented.
286
+
287
+ ### IPO
288
+
289
+ IPO means **Identity Preference Optimization**. It was introduced as a more
290
+ overfit-resistant alternative to the original DPO logistic objective. Instead of
291
+ driving preference probabilities toward hard 0/1 labels indefinitely, IPO uses a
292
+ smoother identity-style objective over preference gaps. The practical point:
293
+ when preference labels are noisy, IPO is often less eager to push margins to
294
+ extremes.
295
+
296
+ For mlx-bun, IPO is interesting mainly as a loss-family option after the shared
297
+ logp engine exists.
298
+
299
+ ### Hinge / SLiC-style loss
300
+
301
+ The hinge loss is a margin-ranking objective:
302
+
303
+ `loss = max(0, margin - reward_gap)`
304
+
305
+ For preference training, `reward_gap` is usually the chosen score minus rejected
306
+ score. Once the gap exceeds the margin, that pair contributes no gradient. This
307
+ can be useful when you want to stop over-optimizing already-solved pairs and
308
+ focus compute on pairs whose preference margin is still too small.
309
+
310
+ ### Label smoothing and robust weighting
311
+
312
+ Preference pairs are noisy. Cheap robustness knobs:
313
+
314
+ - `label_smoothing`: treat a pair as, for example, 95% chosen / 5% rejected
315
+ rather than a hard label.
316
+ - Per-example `weight`: let curated data carry confidence or score gap.
317
+ - Loss clipping / winsorization: cap extreme high-loss pairs so noisy examples
318
+ do not dominate gradients.
319
+ - Ambiguity filtering: skip pairs with tiny initial margin or near-identical
320
+ chosen/rejected responses.
321
+
322
+ ### Better curation
323
+
324
+ The current UltraFeedback curation mostly extracts rows and filters by length.
325
+ Quality can likely improve by filtering for:
326
+
327
+ - Large enough chosen-vs-rejected score gap when available.
328
+ - Similar response length, or at least bounded length ratio.
329
+ - High semantic contrast but not unrelated answers.
330
+ - Instruction-following categories if IFEval is the target metric.
331
+
332
+ Data quality may beat objective cleverness on small ORPO runs.
333
+
334
+ ## Short answer to "token vs vocab"
335
+
336
+ The thing to look for is **vocab-axis chunked / fused cross entropy**, especially
337
+ under the names **Cut Cross Entropy** or **Liger Fused Linear Cross Entropy**.
338
+ Token chunking slices `[tokens, vocab]` by rows. Vocab chunking slices it by
339
+ columns and combines chunks with online logsumexp so the full logits matrix is
340
+ never live.