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.
Files changed (57) hide show
  1. package/README.md +11 -2
  2. package/docs/dag/training-inference-map.html +575 -0
  3. package/docs/design/memory-system.md +193 -295
  4. package/docs/design/orpo-dynamic-lambda.md +179 -0
  5. package/docs/design/orpo-future-enhancements.md +42 -0
  6. package/docs/design/orpo-training.md +13 -0
  7. package/docs/design/parity-tier-dag.md +143 -0
  8. package/docs/design/w4a16-compute-precision-spike.md +232 -0
  9. package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
  10. package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
  11. package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
  12. package/docs/investigations/steel-flash-cce-handoff.md +58 -0
  13. package/docs/investigations/trainer-validation-experiment.md +173 -0
  14. package/docs/reference/memory.md +77 -0
  15. package/docs/reference/orpo-quickstart.md +44 -5
  16. package/docs/reference/training.md +9 -5
  17. package/package.json +1 -1
  18. package/src/cli.ts +651 -18
  19. package/src/eval/runner.ts +76 -6
  20. package/src/eval/tasks/bfcl.ts +49 -15
  21. package/src/eval/tasks/gsm8k.ts +21 -9
  22. package/src/eval/tasks/hashhop.ts +33 -4
  23. package/src/eval/tasks/humaneval.ts +30 -8
  24. package/src/eval/tasks/ifeval.ts +52 -18
  25. package/src/eval/tasks/mmlu.ts +85 -74
  26. package/src/jobs/runner.ts +6 -0
  27. package/src/memory/chunk.ts +25 -0
  28. package/src/memory/cluster.ts +24 -0
  29. package/src/memory/db.ts +114 -0
  30. package/src/memory/ingest.ts +22 -0
  31. package/src/memory/pipeline.ts +63 -0
  32. package/src/memory/prompts.ts +23 -0
  33. package/src/memory/schedule.ts +163 -0
  34. package/src/memory/synthesize.ts +29 -0
  35. package/src/memory/tools.ts +399 -0
  36. package/src/memory/vault.ts +694 -0
  37. package/src/memory/wikify.ts +23 -0
  38. package/src/mlx/metal-kernel.ts +13 -0
  39. package/src/model/flash-attention.ts +5 -3
  40. package/src/pi-provider.ts +29 -13
  41. package/src/pi-session.ts +81 -0
  42. package/src/pi-terminal.ts +13 -24
  43. package/src/pi-web.ts +246 -166
  44. package/src/quantize/job.ts +17 -2
  45. package/src/quantize/quantizer.ts +26 -2
  46. package/src/server.ts +213 -44
  47. package/src/tokenizer.ts +6 -2
  48. package/src/train/dataset.ts +11 -3
  49. package/src/train/flash-cce.ts +18 -5
  50. package/src/train/job.ts +41 -4
  51. package/src/train/loss.ts +10 -4
  52. package/src/train/trainer.ts +62 -3
  53. package/src/train/watch.ts +542 -0
  54. package/src/web/app.html +264 -14
  55. package/src/web/skills/memory/SKILL.md +97 -0
  56. package/src/web/skills/web-research/SKILL.md +3 -1
  57. 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`).