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,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.
|