mlx-bun 0.0.5 → 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 +11 -2
- package/docs/dag/training-inference-map.html +575 -0
- package/docs/design/memory-system.md +193 -295
- package/docs/design/orpo-dynamic-lambda.md +179 -0
- package/docs/design/orpo-future-enhancements.md +42 -0
- package/docs/design/orpo-training.md +13 -0
- package/docs/design/parity-tier-dag.md +143 -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/investigations/steel-flash-cce-handoff.md +58 -0
- package/docs/investigations/trainer-validation-experiment.md +173 -0
- package/docs/reference/memory.md +77 -0
- package/docs/reference/orpo-quickstart.md +44 -5
- package/docs/reference/training.md +9 -5
- package/package.json +1 -1
- package/src/cli.ts +651 -18
- 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/jobs/runner.ts +6 -0
- 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 +13 -0
- package/src/model/flash-attention.ts +5 -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 +213 -44
- package/src/tokenizer.ts +6 -2
- package/src/train/dataset.ts +11 -3
- package/src/train/flash-cce.ts +18 -5
- package/src/train/job.ts +41 -4
- package/src/train/loss.ts +10 -4
- package/src/train/trainer.ts +62 -3
- package/src/train/watch.ts +542 -0
- package/src/web/app.html +264 -14
- 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
|
@@ -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).
|
|
@@ -14,6 +14,48 @@ comfortable.
|
|
|
14
14
|
> [steel-flash-cce-handoff.md](../investigations/steel-flash-cce-handoff.md). The
|
|
15
15
|
> remaining sections (§2, §4–§9, the objective variants) are still genuinely future.
|
|
16
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
|
+
|
|
17
59
|
Product relevance: ORPO is not only a researcher feature. It is the path to
|
|
18
60
|
small, downloadable task adapters that make the default local assistant better
|
|
19
61
|
without forcing every user onto a larger base model. The performance work below
|
|
@@ -27,6 +27,19 @@ runs half the forwards per step.
|
|
|
27
27
|
> torch/numpy reference of the paper/TRL math (see [Parity](#parity)), not a
|
|
28
28
|
> bit-exact upstream port.
|
|
29
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
|
+
|
|
30
43
|
## Implementation status
|
|
31
44
|
|
|
32
45
|
**Landed (naïve correctness path — the shippable unit + the oracle everything
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Parity-tier DAG → meaningful CLI flags
|
|
2
|
+
|
|
3
|
+
**Status:** vision / roadmap (2026-06-21). The first artifact exists — a zoomable,
|
|
4
|
+
tier-tagged map of the training + inference DAGs at
|
|
5
|
+
[`docs/dag/training-inference-map.html`](../dag/training-inference-map.html). This
|
|
6
|
+
doc records where it goes and *why*: to turn a sprawling pile of CLI flags into a
|
|
7
|
+
small, coherent, tier-organized surface.
|
|
8
|
+
|
|
9
|
+
## The framework (recap)
|
|
10
|
+
|
|
11
|
+
Every node in the compute graph sits in the **lowest parity tier whose oracle it
|
|
12
|
+
matches bit-for-bit**:
|
|
13
|
+
|
|
14
|
+
- **L1** — drop-in for **mlx-lm** (its op is the oracle; bit-exact or it's a bug).
|
|
15
|
+
- **L2** — drop-in for **mlx-optiq** (optiq's behavior is the oracle).
|
|
16
|
+
- **L3** — **our originals** (flash-CCE head, ORPO loss, prefix-share, segmented
|
|
17
|
+
backward, HLG sampler, expert offload). **No oracle ships**, so correctness is
|
|
18
|
+
gated by finite-difference / KL / quality, *not* parity.
|
|
19
|
+
|
|
20
|
+
**The gate is the oracle.** A node can only live in a lower tier if that oracle
|
|
21
|
+
exists AND it matches bit-exactly; otherwise it bubbles up. Note the subtlety the
|
|
22
|
+
map already shows: an *optimization* that still matches the oracle stays low —
|
|
23
|
+
`compiled-decode` is ours but is L1 because it replays the same ops bit-for-bit.
|
|
24
|
+
Only nodes with **no** oracle (flash-CCE) are *forced* to L3. (Background +
|
|
25
|
+
post-mortem: [orpo-flash-cce-pin-leak.md](../investigations/orpo-flash-cce-pin-leak.md).)
|
|
26
|
+
|
|
27
|
+
## Two axes: parity gates performance
|
|
28
|
+
|
|
29
|
+
Every optimization has **two independent coordinates**, and conflating them is what
|
|
30
|
+
made the flag surface unreadable:
|
|
31
|
+
|
|
32
|
+
1. **Parity** — *which reference does it reproduce bit-for-bit?* This sets the
|
|
33
|
+
**lowest level** it may live in: matches mlx-lm → L1; matches mlx-optiq but not
|
|
34
|
+
mlx-lm → L2; matches neither → L3 (gated by KL + tests, no oracle).
|
|
35
|
+
2. **Performance** — *is it the fastest correct way?* This sets whether it's the
|
|
36
|
+
**default (on)** within its level, or a kept-but-**off** opt-in.
|
|
37
|
+
|
|
38
|
+
**Parity gates performance.** A faster kernel that breaks mlx-lm parity is simply
|
|
39
|
+
*not in L1* — it bubbles up to whatever level its parity allows. You never trade the
|
|
40
|
+
guarantee for speed silently; you change levels, and the user opts in. The level is
|
|
41
|
+
your floor of trust; the default within it is the best way to honor that floor.
|
|
42
|
+
|
|
43
|
+
### Decision procedure for any new optimization
|
|
44
|
+
|
|
45
|
+
1. **Measure its parity** (run it both ways, compare — "one flag, one measurement
|
|
46
|
+
per lever"). The lowest oracle it matches bit-for-bit is its level ceiling.
|
|
47
|
+
2. **Measure its performance** vs that level's current default:
|
|
48
|
+
- **faster AND holds the guarantee** → it becomes the **default (on)** for that level.
|
|
49
|
+
- **slower but still correct** → **keep it as a documented default-off flag**
|
|
50
|
+
(optionality for A/B; never the default, never deleted).
|
|
51
|
+
- **breaks the guarantee but offers a real tradeoff** (e.g. 2× tok/s at KL
|
|
52
|
+
0.0015) → an **L3 opt-in**; the user chooses exact vs fast.
|
|
53
|
+
|
|
54
|
+
Worked examples:
|
|
55
|
+
- *5% less memory, still bit-for-bit with mlx-lm* → **default-on in L1.**
|
|
56
|
+
- *2× tok/s, but KL 0.0015 to mlx-lm* → **L3 opt-in** (user picks exact or 2×).
|
|
57
|
+
- *another way, 3% slower, still correct* → **kept, off in every level.**
|
|
58
|
+
|
|
59
|
+
### Our decode levers under this rule (today)
|
|
60
|
+
|
|
61
|
+
| lever | parity | perf | placement |
|
|
62
|
+
|---|---|---|---|
|
|
63
|
+
| `compiled-decode` | bit-exact w/ uncompiled (`compiled-decode.test`) | faster | **default-on, every level** |
|
|
64
|
+
| `perf-kernel` | matches optiq, not mlx-lm (`perf-kernel-oracle.test`) | faster | **default-on from L2** (the L2 bridge) |
|
|
65
|
+
| `fused-sdpa` | matches optiq, not mlx-lm (`fused-sdpa.test`) | faster | **default-on from L2** (the L2 bridge) |
|
|
66
|
+
| `fused-decode` | (would be L3) | ~3% slower (`optimization_plan.md`) | **kept, off everywhere** |
|
|
67
|
+
|
|
68
|
+
So `--l1/--l2/--l3` is the **parity** axis (the guarantee), and the defaults within
|
|
69
|
+
each tier are the **performance** axis (the fastest kernel that still holds it). For
|
|
70
|
+
the *decode path* L2 == L3 today (every decode kernel matches mlx-lm or optiq); L3's
|
|
71
|
+
real territory is the no-oracle **features** — HLG sampler, expert offload, batched
|
|
72
|
+
mixed-precision — which live off the decode axis and are gated by KL + quality.
|
|
73
|
+
|
|
74
|
+
## Why this matters for CLI flags
|
|
75
|
+
|
|
76
|
+
We are accumulating a **lot** of flags (`--grad-accum`, `--grad-clip`, `--seg`,
|
|
77
|
+
`--no-flash`, `--no-prefix`, `--no-segment`, `--lambda`, `--val-size`, kv-quant
|
|
78
|
+
knobs, sampler knobs, …). The problem isn't any one flag — it's the **combinatorics**
|
|
79
|
+
and the fact that **some are never really a choice** (always-on guards, or the only
|
|
80
|
+
viable route). The tier-tagged DAG makes each flag legible by asking: **what route
|
|
81
|
+
does this flag push the computation down, and what tier is that route?**
|
|
82
|
+
|
|
83
|
+
A flag falls into exactly one of three buckets:
|
|
84
|
+
|
|
85
|
+
| flag selects… | tier effect | what it IS | action |
|
|
86
|
+
|---|---|---|---|
|
|
87
|
+
| the only viable route (no alternative node) | — | not a choice | **always-on default; drop the flag** |
|
|
88
|
+
| between two routes at the SAME tier / same oracle | none | cosmetic / safety guard | **default it; expose only if it ever fails** |
|
|
89
|
+
| between a **lower-tier** route and an **L3** route | **L1/L2 ↔ L3** | a real **parity ⇄ optimization** knob | **keep, document as such, group by tier** |
|
|
90
|
+
|
|
91
|
+
So the DAG lets us answer "**what flags push down THAT route**" for any path, and
|
|
92
|
+
from that:
|
|
93
|
+
|
|
94
|
+
1. **Prune always-on flags to defaults** — anything whose alternative route is
|
|
95
|
+
never selected, or whose toggle doesn't change the tier, shouldn't be a flag.
|
|
96
|
+
2. **Surface the real axis.** The flags that genuinely matter all toggle
|
|
97
|
+
*bit-exact-lower-tier ⇄ our-L3-optimization*. Those could collapse into a small
|
|
98
|
+
number of intent-level switches (e.g. a single `--exact` that forces every node
|
|
99
|
+
down to its lowest tier, vs the optimized default) instead of N independent
|
|
100
|
+
kernel toggles the user has to understand individually.
|
|
101
|
+
3. **Group the rest by what they trade** — memory (`--seg`, all bit-exact → not a
|
|
102
|
+
parity choice, a memory↔compute knob), hyperparameters (`--lambda`, `--lr` →
|
|
103
|
+
training knobs, orthogonal to tier), etc.
|
|
104
|
+
|
|
105
|
+
### Worked classification (current training flags)
|
|
106
|
+
|
|
107
|
+
- `--grad-clip 1.0` → standard guard, L1, **always-on default** (not a route choice).
|
|
108
|
+
- `--seg N` / `--no-segment` → memory↔compute, **bit-exact either way** → a *memory*
|
|
109
|
+
knob, **not** a parity choice. Should read as "memory budget," not "exactness."
|
|
110
|
+
- `--grad-accum N` → changes the accumulation route (L1; smoother grads) → a real
|
|
111
|
+
*training-quality* knob.
|
|
112
|
+
- `--no-flash` → **L3 flash-CCE → L2 MLX fused head** → a genuine *parity ⇄ perf*
|
|
113
|
+
fallback (the one place the head leaves L3).
|
|
114
|
+
- `--no-prefix` → **L3 prefix-share → bit-exact two-forward** → a *perf* knob with no
|
|
115
|
+
numerical cost.
|
|
116
|
+
- `--lambda` → ORPO hyperparameter (pure L3) → a *training* knob.
|
|
117
|
+
|
|
118
|
+
The pattern: most flags are **memory/training knobs or always-on guards**; only a
|
|
119
|
+
couple are true **parity ⇄ optimization** toggles. Today they're a flat list the
|
|
120
|
+
user must reason about individually; tier-tagging lets us present them as what they
|
|
121
|
+
actually are.
|
|
122
|
+
|
|
123
|
+
## Roadmap to make it executable
|
|
124
|
+
|
|
125
|
+
The map is hand-authored today. To make the framework *enforce* itself:
|
|
126
|
+
|
|
127
|
+
1. **Derive the graph from code** — extract the call graph + per-node op/oracle tags
|
|
128
|
+
so it stays in sync and is **queryable** ("every L3 node", "paths that cross into
|
|
129
|
+
L3", "the pure-L1 subgraph", "flags that select route X").
|
|
130
|
+
2. **CI-enforce the gate** — a node tagged L1 must have a passing bit-exact parity
|
|
131
|
+
test vs mlx-lm; if it can't, it's a bug or must be re-tagged L3. "The oracle is
|
|
132
|
+
the gate" becomes a check, not a principle.
|
|
133
|
+
3. **Shrink the L3 surface** — audit whether anything in the L3 island is actually
|
|
134
|
+
bit-exact-able and just unproven (e.g. the prefix-shared *forward* is already
|
|
135
|
+
bit-exact — `prefix-shared-parity.ts` — so only the encode-once *fold* is truly
|
|
136
|
+
L3). Minimizing L3 minimizes what needs KL/quality gates.
|
|
137
|
+
4. **Generate the flag surface from the graph** — once flags map to routes, the CLI
|
|
138
|
+
help + defaults can be derived: always-on routes have no flag, parity⇄opt routes
|
|
139
|
+
collapse to intent switches, knobs group by what they trade.
|
|
140
|
+
|
|
141
|
+
End state: instead of a growing flat list of flags the user memorizes, a small set
|
|
142
|
+
of intent-level switches whose meaning is exactly "which tier route does the compute
|
|
143
|
+
take," backed by a DAG that's checked in CI.
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Research spike — W4A16 head GEMM (compute precision, not storage)
|
|
2
|
+
|
|
3
|
+
**Status:** proposed (not started) · **Opened:** 2026-06-19 · **Owner:** Josh
|
|
4
|
+
**Surface:** `src/train/flash-cce.ts` (steel + SG kernels) · **Gate:** the existing
|
|
5
|
+
1e-5 CCE parity gate + greedy-prefix logit parity.
|
|
6
|
+
|
|
7
|
+
> One-line thesis (Josh): *we've maxed **storage** quant (4-bit weights), but we
|
|
8
|
+
> dequant into fp32 and run the matmul in fp32 — so the head GEMM is **W4A32**.
|
|
9
|
+
> The untouched lever is **compute** precision: dequant 4-bit → half, matmul in
|
|
10
|
+
> `half` operands with an fp32 accumulator (**W4A16**), gate on parity. Same 4-bit
|
|
11
|
+
> storage, untouched. The only change is the float width we dequant into and
|
|
12
|
+
> multiply in.*
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## TL;DR
|
|
17
|
+
|
|
18
|
+
- The flash-CCE head (forward logit GEMM `E@Wᵀ` and backward phase-2 `coeffᵀ@W`)
|
|
19
|
+
currently runs **W4A32**: 4-bit weights are dequantized into **fp32** threadgroup
|
|
20
|
+
tiles and multiplied with **fp32** simdgroup matrices. Verified in source (below).
|
|
21
|
+
- The 4-bit packing bought the cheap HBM read; the fp32 matmul throws away the
|
|
22
|
+
compute advantage. Apple GPUs run **fp16 FMA at ~2× fp32** on the regular SIMD
|
|
23
|
+
ALUs (no tensor cores). Both head GEMMs are **compute-bound** (see roofline note),
|
|
24
|
+
so half operands are the lever.
|
|
25
|
+
- **Industry-standard for the forward** (MLX `quantized_matmul`, llama.cpp, every
|
|
26
|
+
on-device 4-bit stack do exactly W4A16). Low risk; do it first.
|
|
27
|
+
- **Backward needs the mixed-precision caveat** — fp32 accumulate is non-negotiable
|
|
28
|
+
(already planned), and the **operands** (`coeffᵀ`, `h`) need a dynamic-range probe
|
|
29
|
+
before trusting fp16, specifically the near-zero coeff tails after the skips fire.
|
|
30
|
+
- **M1 Max constraint:** no `bf16` simdgroup_matrix (M2+ only), so **fp16 is the
|
|
31
|
+
only half option** — which makes the range-probe matter more here than on M3.
|
|
32
|
+
- Gate exactly like the existing skip epsilons: **fp32 stays the default-exact
|
|
33
|
+
parity mode**; half is the training default; the 1e-5 gate test runs against fp32.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Current state — verified W4A32
|
|
38
|
+
|
|
39
|
+
The default production path for **both** forward and backward is the **steel**
|
|
40
|
+
kernel (MLX's verbatim `BlockMMA` GEMM, `steel-qmm-header.ts`), selected when
|
|
41
|
+
`H,V,blockV0 % 32 == 0` (`flash-cce.ts:1034`, `:1130`). The `simdgroup_float8x8`
|
|
42
|
+
SG kernels and the scalar/lane kernels are fallbacks for non-32-divisible shapes or
|
|
43
|
+
explicit `MLX_BUN_CCE_*` overrides.
|
|
44
|
+
|
|
45
|
+
Every one of these instantiations is fp32-operand:
|
|
46
|
+
|
|
47
|
+
| path | site | instantiation | operand width |
|
|
48
|
+
|---|---|---|---|
|
|
49
|
+
| steel fwd (default) | `flash-cce.ts:950` | `BlockMMA<float,float,…>` | fp32 |
|
|
50
|
+
| steel bwd phase-1 (default) | `flash-cce.ts:799` | `BlockMMA<float,float,…>` (lmma_t) | fp32 |
|
|
51
|
+
| steel bwd phase-2 (default) | `flash-cce.ts:800` | `BlockMMA<float,float,…>` (dmma_t) | fp32 |
|
|
52
|
+
| steel staging tiles | `:596–598`, loaders `:801` | `threadgroup float` / `BlockLoader<float,…>` | fp32 |
|
|
53
|
+
| SG fwd (fallback) | `:251–254` | `simdgroup_float8x8`, `Csh`/`Esh` = `threadgroup float` | fp32 |
|
|
54
|
+
| SG bwd (fallback) | `:617`, `:684–704` | `simdgroup_float8x8`, `Wsh` = `threadgroup float` | fp32 |
|
|
55
|
+
|
|
56
|
+
The dequant itself (`Csh[…] = sc * q + bi`, `flash-cce.ts:244`) computes into fp32
|
|
57
|
+
and stores fp32. The dh accumulator is already fp32 (`MMATile<float,…>`,
|
|
58
|
+
`flash-cce.ts:828`) — i.e. **the accumulator is already where it should be**; only
|
|
59
|
+
the matmul *operands* are wider than they need to be.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## The lever — what actually changes
|
|
64
|
+
|
|
65
|
+
W4A16 = **same 4-bit storage, half-width operands, fp32 accumulate.** Concretely:
|
|
66
|
+
|
|
67
|
+
1. **Steel operand type** — the `BlockMMA<float,float,…>` first template param is the
|
|
68
|
+
GEMM input/operand type; flip operand to `half` while keeping the
|
|
69
|
+
accumulator/fragment in `float`. (**Spike task 0: confirm the exact MLX steel
|
|
70
|
+
template param that selects operand vs accumulator width** in
|
|
71
|
+
`steel-qmm-header.ts` before editing — don't assume the param order.)
|
|
72
|
+
2. **Staging tiles** — `Esh`, `Cstg`, `Wsh`, `Csh` go `threadgroup half`; dequant
|
|
73
|
+
arithmetic can stay fp32 then narrow on store, or compute in half.
|
|
74
|
+
3. **Loaders** — `BlockLoader<float,…>` → `BlockLoader<half,…>` to stage half tiles.
|
|
75
|
+
4. **SG fallback** — `simdgroup_float8x8` → `simdgroup_half8x8` for the operands,
|
|
76
|
+
keep an `simdgroup_float8x8` accumulator (Apple supports the mixed `half×half→float`
|
|
77
|
+
`simdgroup_multiply_accumulate`).
|
|
78
|
+
|
|
79
|
+
Everything reduction-shaped (the online-softmax `m`/`d` recurrence, the dh accum,
|
|
80
|
+
logsumexp) **stays fp32**.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## First thoughts (mine) — what to add to Josh's analysis
|
|
85
|
+
|
|
86
|
+
The analysis is right and the change is well-targeted. Five additions, each grounded
|
|
87
|
+
in the current source:
|
|
88
|
+
|
|
89
|
+
**1. Aim at the steel kernel, not the SG snippet.** Josh's note led with the SG path
|
|
90
|
+
(`simdgroup_float8x8`, `Csh`). That path is a *fallback*. The lever that moves the
|
|
91
|
+
measured 180 ms fwd / 754 ms bwd (e4b) numbers is the **steel `BlockMMA<float,float>`**
|
|
92
|
+
instantiations (`:950`, `:799`, `:800`) and their `BlockLoader<float>`/staging tiles.
|
|
93
|
+
Do the SG path too for completeness, but the steel path is where the production time is.
|
|
94
|
+
|
|
95
|
+
**2. The 2× is an upper bound gated by the FMA fraction — get the roofline split
|
|
96
|
+
first.** The whole flash-CCE design is `[M,V]`-free and amortizes dequant across
|
|
97
|
+
`BLOCK_B` tokens specifically because the **dequant ALU** was "the dominant cost in
|
|
98
|
+
v1" (`flash-cce.ts:59`). The runtime splits into (a) nibble-unpack + `sc·q+bi`
|
|
99
|
+
dequant and (b) the FMA matmul. **W4A16 only speeds up (b).** The kernel is
|
|
100
|
+
unambiguously compute-bound, not BW-bound — **confirmed live** (see the measured box
|
|
101
|
+
below), and Josh flagged it from `mactop` before this doc existed. *But* the win
|
|
102
|
+
ceiling is the FMA share of that ALU time. The dequant-amortization work was done
|
|
103
|
+
precisely to shrink (a)'s share, which *should* leave (b) dominant and favorable — but
|
|
104
|
+
**measure the (a)/(b) split before predicting 2×.** The live numbers prove the
|
|
105
|
+
*whole kernel* is ALU-bound; they do **not** resolve the intra-kernel (a)/(b) split,
|
|
106
|
+
which is what actually bounds the reclaim. If the kernel is still dequant-ALU-bound,
|
|
107
|
+
half operands move it less.
|
|
108
|
+
|
|
109
|
+
> **Live roofline — measured (M1 Max 32 GB, MiniCPM5-1B ORPO, `--no-segment`, seq 4096):**
|
|
110
|
+
> GPU **100% @ 1296 MHz, 81 °C, Nominal** (sustained, not throttling) · memory
|
|
111
|
+
> **BW 129.2 GB/s ≈ ⅓ of the M1 Max's ~400 GB/s ceiling** (bus two-thirds idle) ·
|
|
112
|
+
> mem **17.37 / 32 GB (54%)**, swap 0 · CPU ~5%, ANE 0%. The training `bun` proc is
|
|
113
|
+
> 92.6% GPU. **GPU pegged + memory bus mostly idle = ALU-bound.** This is the regime
|
|
114
|
+
> where W4A16 pays; if it were BW-bound (BW near 400, GPU spiky) the spike would be
|
|
115
|
+
> moot. The 4-bit storage already did its job — *that's why BW is low* — and the GPU
|
|
116
|
+
> is now spending its cycles on the fp32 FMAs.
|
|
117
|
+
|
|
118
|
+
**3. Threadgroup memory is a free secondary win — possibly a compounding one.** The
|
|
119
|
+
staging tiles (`Esh`/`Cstg`/`Wsh`/`Csh`) are the kernel's threadgroup-memory pressure,
|
|
120
|
+
and tile sizing is *already* occupancy-constrained — see the note at `flash-cce.ts:71`
|
|
121
|
+
("the 8KB `Csh` tile measured SLOWER"). Half-width staging **halves that footprint**,
|
|
122
|
+
which can either raise occupancy or admit larger tiles. So W4A16 may buy more than the
|
|
123
|
+
raw 2× FMA: it could relieve the exact constraint that capped the current tiling.
|
|
124
|
+
Worth re-sweeping `BLOCK_B`/tile dims *after* the precision change, not just measuring
|
|
125
|
+
the drop-in.
|
|
126
|
+
|
|
127
|
+
**4. SDPA in the segmented forward is probably already half — don't chase it.** Josh's
|
|
128
|
+
aside ("if your SDPA tiles are also fp32, same logic"). The segmented forward uses
|
|
129
|
+
`ops.sdpa` — MLX's own fused flash kernel ([[opssdpa-dk-vjp-bug]]) — which runs in the
|
|
130
|
+
model's dtype (bf16), **not** our fp32 tiles. So that lever is likely already pulled by
|
|
131
|
+
delegating to MLX. One-line confirm, then drop it; the head GEMM is the real target.
|
|
132
|
+
|
|
133
|
+
**5. Free instrumentation hooks already exist for the backward range-probe.** The
|
|
134
|
+
backward already computes `cmax = simd_max(coeff)` for the FILTER_EPS skip
|
|
135
|
+
(`flash-cce.ts:682`). That same reduction is the natural hook to log the coeff dynamic
|
|
136
|
+
range (min nonzero |coeff|) across a real run *before* committing the operand to fp16 —
|
|
137
|
+
the underflow-to-zero case Josh flagged. Pair it with a max-abs probe on `h`: at e4b's
|
|
138
|
+
`scale=1.0` peaked attention the residual stream can carry outliers
|
|
139
|
+
([[e4b-forward-length-sensitivity]], [[siglip-vision-parity-cross-build]]), and with no
|
|
140
|
+
bf16 on M1 Max we can't lean on a wider exponent to eat them.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Risk analysis (preserving the forward/backward split)
|
|
145
|
+
|
|
146
|
+
**Forward — low risk, boring industry default.** Logits are well-scaled; the
|
|
147
|
+
online-softmax max-subtraction bounds the `exp` args. W4A16 here is what the dense
|
|
148
|
+
path already assumes. Validate against the 1e-5 gate (fp16 operands won't hold 1e-5 —
|
|
149
|
+
that's expected; the **fp32 path stays the exact-parity gate**, half is graded by
|
|
150
|
+
greedy-prefix logit agreement, same as the vision tier-a bar).
|
|
151
|
+
|
|
152
|
+
**Backward — fp16 matmul is mainstream, but as *mixed precision*, with eyes on
|
|
153
|
+
operand range.** What makes the forward safe (well-scaled, max-subtracted) is **not**
|
|
154
|
+
automatically true here:
|
|
155
|
+
- `coeff_v = g·(onehot − softmax)·sech²` — after the skips fire, the survivors are the
|
|
156
|
+
**near-zero tails**. Small magnitudes are exactly where fp16's ~3-digit mantissa and
|
|
157
|
+
narrow range (subnormals < ~6e-5) bite. **`coeffᵀ` as an fp16 operand is the highest
|
|
158
|
+
risk** — tail coeffs underflowing to zero silently drop gradient contribution. Probe
|
|
159
|
+
the range first (task 5 above).
|
|
160
|
+
- `h` (activations) as fp16 operand — residual outliers; the classic bf16-beats-fp16
|
|
161
|
+
case, but bf16 isn't available on M1 Max. Range-check before trusting.
|
|
162
|
+
- **fp32 accumulate is non-negotiable** (already planned; `MMATile<float>` already is).
|
|
163
|
+
- **Validate grads against autograd, not a hand-derived backward** ([[opssdpa-dk-vjp-bug]]).
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Spike plan
|
|
168
|
+
|
|
169
|
+
Sequenced so each step is independently measurable and revertible (Josh: *"Don't flip
|
|
170
|
+
both at once — you'll learn more, and if parity drifts you'll know which GEMM did it."*).
|
|
171
|
+
|
|
172
|
+
- [ ] **0. Confirm the steel template semantics.** In `steel-qmm-header.ts`, identify
|
|
173
|
+
which `BlockMMA`/`BlockLoader` template params are operand vs accumulator width.
|
|
174
|
+
Exit: a one-line mapping, no code change.
|
|
175
|
+
- [x] **1a. Whole-kernel roofline — DONE (live).** GPU 100% / BW 129 GB/s (~⅓ of 400)
|
|
176
|
+
on M1 Max confirms the kernel is ALU-bound, not BW-bound (see the measured box above).
|
|
177
|
+
The spike is worth chasing.
|
|
178
|
+
- [ ] **1b. Intra-kernel split (still open).** Profile the steel fwd (180 ms) and bwd
|
|
179
|
+
(754 ms, e4b) into dequant-ALU vs FMA fractions — 1a says the GPU is the wall, this
|
|
180
|
+
says *which part* of the GPU work. Exit: a number that bounds the expected win. *If
|
|
181
|
+
FMA < ~40% of either kernel, reset expectations before coding.*
|
|
182
|
+
- [ ] **2. Add the precision gate.** New codegen `#define` (e.g. `CCE_A_HALF`) +
|
|
183
|
+
env flag (e.g. `MLX_BUN_CCE_A16`), mirroring `CCE_BWD_FILTER_EPS`/`CCE_BWD_BLOCK_EPS`
|
|
184
|
+
(`flash-cce.ts:732`,`:739`). Default: fp32 (exact) for the parity suite; half is the
|
|
185
|
+
training default. Per [[three-level-fidelity-tree-model]] the exact path must stay
|
|
186
|
+
selectable.
|
|
187
|
+
- [ ] **3. Forward → half operands.** Steel fwd `BlockMMA`/loader/staging to half.
|
|
188
|
+
Validate: 1e-5 gate (fp32 path) + greedy-prefix logit parity (half path) + measure
|
|
189
|
+
the fwd GEMM delta. Land as its own change.
|
|
190
|
+
- [ ] **4. Backward range probe.** Instrument coeff (via the `simd_max` hook) and `h`
|
|
191
|
+
dynamic range across a real MiniCPM5/e4b ORPO step. Exit: min-nonzero-coeff and
|
|
192
|
+
max-|h| numbers + a go/no-go on fp16 operands.
|
|
193
|
+
- [ ] **5. Backward → half operands** (only if 4 says go). `coeffᵀ`/`h`/`W` operands
|
|
194
|
+
half, dh accumulator stays fp32. Validate grads **vs autograd** + measure bwd delta.
|
|
195
|
+
- [ ] **6. Re-sweep tiling.** With half-width staging, re-check `BLOCK_B`/tile dims and
|
|
196
|
+
occupancy (first-thought #3) — the precision win may unlock a tiling win.
|
|
197
|
+
|
|
198
|
+
**Exit criteria for the spike:** a measured fwd/bwd speedup on e4b (V=262144) and
|
|
199
|
+
MiniCPM5 (V=130560) on the bench machine (M4 Pro 24 GB; numbers verified against
|
|
200
|
+
`~/.cache/mlx-bun/evals.sqlite`, not docs — [[verify-benchmarks-against-evaldb-not-docs]]),
|
|
201
|
+
with the fp32 exact path still passing the 1e-5 gate and the half training path inside
|
|
202
|
+
the greedy-prefix parity bar. Either it lands as the training default behind the gate,
|
|
203
|
+
or the roofline/range data explains why it didn't pay and the spike closes with that.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Open questions
|
|
208
|
+
|
|
209
|
+
- Steel `BlockMMA` mixed operand/accum: does MLX's steel already support
|
|
210
|
+
`half`-operand / `float`-accum, or does it need a local template tweak? (task 0)
|
|
211
|
+
- Is the dequant cheaper to do **in half** (fewer ALU bits) or **in fp32 then narrow**
|
|
212
|
+
(preserves the scale/bias precision)? A/B inside task 3.
|
|
213
|
+
- Does the half staging change the `+4-float` padding trick (`flash-cce.ts:197`, the
|
|
214
|
+
BK_padded bank-conflict avoidance)? Half-width may re-introduce or shift bank
|
|
215
|
+
conflicts — re-derive the pad.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## References
|
|
220
|
+
|
|
221
|
+
**Code:** `src/train/flash-cce.ts` — steel fwd `:937–1028`, steel bwd `:763–935`, SG
|
|
222
|
+
fwd `:174–270`, SG bwd `:561–726`, gates `:732`/`:739`/`:1034`/`:1130`; staging tiles
|
|
223
|
+
`:596–598`; dequant `:244`; coeff `simd_max` hook `:682`; dh fp32 accum `:828`.
|
|
224
|
+
`src/train/steel-qmm-header.ts` (the verbatim MLX BlockMMA). `src/mlx/metal-kernel.ts`
|
|
225
|
+
(kernel infra).
|
|
226
|
+
|
|
227
|
+
**Cross-links:** [[flash-cce-metal-kernel-prototype]] · [[three-level-fidelity-tree-model]]
|
|
228
|
+
· [[opssdpa-dk-vjp-bug]] · [[e4b-forward-length-sensitivity]] ·
|
|
229
|
+
[[verify-benchmarks-against-evaldb-not-docs]] · `docs/investigations/steel-flash-cce-handoff.md`.
|
|
230
|
+
|
|
231
|
+
**Sibling deferred lever** (queued together, post-run): `docs/design/orpo-future-enhancements.md`
|
|
232
|
+
→ "Deferred levers" (expose `--grad-accum`).
|