Skip to content

Commit f069be8

Browse files
authored
Merge pull request #18 from ausimian/m9-gradient-training
M9: gradient conformance and training primitives
2 parents 05c5a49 + 64e764b commit f069be8

18 files changed

Lines changed: 1677 additions & 15 deletions

PLAN.md

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ checklist so future-us understands the trade-offs.
2121

2222
- Ahead-of-time compilation (IREE-style). Complementary, separate effort.
2323
- Windows or non-Apple-Silicon Linux GPU. CPU-only Linux is a nice-to-have for CI.
24-
- Training / gradients beyond what `Nx.Defn` gives for free. Inference is the priority.
24+
- Training framework features beyond `Nx.Defn.grad`: distributed training,
25+
mixed-precision master weights, a native optimizer library. (Autodiff +
26+
small-scale training loops: in scope from M9.)
2527
- Drop-in replacement for EMLX. We borrow where it's clearly right, but
2628
we're not constrained by its API.
2729
- `Emily.Stream` as a public API — MLX streams stay internal in v1.
@@ -174,7 +176,7 @@ throughput number.
174176

175177
**Exit:** Axon MLPs forward with `compiler: Emily.Compiler`; results
176178
match `Nx.Defn.Evaluator` running on the same backend within float
177-
tolerance. (Training is out of scope for v1.)
179+
tolerance. (Training via `Nx.Defn.grad` lands in M9.)
178180

179181
### M6 — `mlx::core::compile` wrapping — **dropped**
180182

@@ -254,7 +256,87 @@ exists; only the Backend callback still routes through the
254256
BinaryBackend fallback). Gated on the M7 ViT and Whisper suites
255257
staying green through the switchover.
256258

257-
### M9 — 1.0 release
259+
### M9 — Gradient conformance and training primitives
260+
261+
Training on Emily has been technically possible since M2 —
262+
`Nx.Defn.grad` is pure symbolic differentiation in Elixir and lowers
263+
to the same ops the forward pass uses. M9 turns "possible" into
264+
"usable" by (a) lifting the training-hot indexing ops off the
265+
`via_binary` fallback and (b) building the test scaffolding needed
266+
to trust a gradient.
267+
268+
**Primitives.** `Nx.Defn.grad` of indexing-shaped ops lands on
269+
`indexed_add`; every such backward currently ships to BinaryBackend
270+
and back. Lift to native MLX:
271+
272+
- `indexed_add``mlx::core::scatter_add`
273+
- `indexed_put``mlx::core::scatter`
274+
- `gather``mlx::core::gather`
275+
276+
Window reductions stay on `via_binary` in M9 — pool-based conv
277+
training is scoped to M10.
278+
279+
**Testing — Layers 4 (Grad) and 5 (Training):**
280+
281+
1. **Grad-equivalence property tests** — for a zoo of `defn`-expressible
282+
functions f, assert `Nx.Defn.grad(f)` on `Emily.Backend` matches the
283+
same grad on `Nx.BinaryBackend` within dtype-appropriate tolerance.
284+
Reuses M2's StreamData harness; the zoo excludes non-differentiable
285+
ops (`argmax`, `argmin`, `floor`, `sign`, comparisons).
286+
2. **Numerical finite-difference oracle** — for the differentiable
287+
subset, assert `(f(x+ε) - f(x-ε)) / 2ε ≈ grad(f)(x)`. Tolerance is
288+
per-op and documented; f32 central differences bottom out around
289+
1e-3 relative, so symbolic-grad tolerance must be relaxed
290+
accordingly where this is the oracle. Pilot on 3–4 ops before
291+
scaling the harness.
292+
3. **Training curve-matching** — handwritten MLP and handwritten
293+
transformer-block training step, fixed seed, 50–200 steps; assert
294+
per-step loss trajectory matches `Nx.BinaryBackend` within
295+
tolerance. No Axon dependency in this tier — fewer moving parts
296+
when a test goes red.
297+
4. **Training memory soak** (`test/soak/training_test.exs`,
298+
`@tag :soak`) — 1k training steps; MLX memory returns to baseline
299+
after `clear_cache/0`. Training exercises a different allocator
300+
pattern than inference (param-grad pairs, optimizer state,
301+
long-lived activation caches).
302+
5. **`:training_full`** (opt-in via `--only training_full`, **not**
303+
on default CI) — Axon MLP on MNIST → >97% test accuracy. Catches
304+
systemic numerical drift that curve-matching misses because both
305+
sides use `Nx.BinaryBackend` as the oracle.
306+
307+
Axon is added as a **test-only** dependency, used only by the
308+
`:training_full` tier.
309+
310+
**Risks specific to this milestone:**
311+
312+
- f32 tolerance calibration for oracle (2) is per-op; the harness
313+
must support per-op tolerance tables, not a single global epsilon.
314+
- Random-key flow through `Emily.Compiler` needs an explicit test:
315+
grad through `dropout` with threaded keys, two invocations of the
316+
same compiled function must advance the RNG correctly.
317+
- MLX scatter semantics (out-of-bounds handling, tie-breaking) may
318+
differ from Nx expectations. Document divergence; encode property
319+
exclusions if needed.
320+
321+
**Exit:** oracles (1)–(3) green in default CI; (4) and (5) green in
322+
opt-in CI job.
323+
324+
### M10 — Conv-pool training
325+
326+
Lift window reductions (`window_sum`, `window_max`, `window_min`,
327+
`window_product`, `window_scatter_max`, `window_scatter_min`) off
328+
`via_binary` onto their native MLX counterparts. This closes the
329+
last gap in the training primitive set and unblocks pool-based conv
330+
models (small CNNs, ViT classifier heads trained from scratch).
331+
332+
Scope is narrow: the lifts are mechanical per-op changes. Test
333+
coverage extends the M9 grad-equivalence and curve-matching zoo to
334+
cover the new ops, plus a small-CNN MNIST run in `:training_full`.
335+
336+
**Exit:** grad-equivalence on window ops green; small-CNN MNIST
337+
training converges in `:training_full`.
338+
339+
### M11 — 1.0 release
258340

259341
- API docs, HexDocs, README with a worked Bumblebee example
260342
- Hex release (public), versioned per conventions (`@version` in mix.exs)
@@ -267,6 +349,8 @@ staying green through the switchover.
267349
| Native | Hand-computed expected values | ExUnit unit tests |
268350
| Backend | `Nx.BinaryBackend` on the same inputs | StreamData property tests + Nx conformance |
269351
| Compiler | `Emily.Backend` in non-defn mode | Equivalence tests (same function, two modes) |
352+
| Grad | `Nx.BinaryBackend` grad + finite differences | StreamData property tests + numerical oracle |
353+
| Training | `Nx.BinaryBackend` loss trajectory | Curve-matching; MNIST convergence (`:training_full`, opt-in) |
270354
| E2E | EXLA-produced golden outputs | Conformance tests with cached weights |
271355

272356
A bug can only be introduced in the layer where its test fails — no
@@ -275,13 +359,19 @@ cross-layer mystery bugs.
275359
Additional harnesses:
276360
- **Memory soak** (`test/soak/memory_test.exs`, `@tag :soak`): 10k
277361
iterations; MLX memory stats asserted to return to baseline.
362+
- **Training memory soak** (`test/soak/training_test.exs`,
363+
`@tag :soak`, from M9): 1k training steps; baseline restored after
364+
`clear_cache/0`.
278365
- **Concurrency** (`test/soak/concurrency_test.exs`, `@tag :soak`):
279366
parallel inference; determinism + no crashes.
280367
- **Benchmarks** (`bench/`): Benchee scripts; results logged in
281368
`RELEASE.md` per version.
282369
- **Conformance vs EXLA** (CI matrix, Mac for Emily + Linux+CUDA for
283370
EXLA oracle): same model, same input; runs on every PR touching
284371
Backend.
372+
- **Convergence** (`:training_full`, from M9; opt-in CI job): Axon
373+
training loop on MNIST; catches numerical drift the curve-matching
374+
oracle can't see.
285375

286376
## Risks and mitigations
287377

RELEASE.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,91 @@
22

33
## Added
44

5+
- M9 — Gradient conformance and training primitives. Makes
6+
`Nx.Defn.grad` usable on Emily by lifting the three ops that grad
7+
lands on most heavily off the `via_binary` fallback, and adds the
8+
test scaffolding to trust a gradient. Training on Emily has been
9+
technically possible since M2 (grad is symbolic in Elixir and
10+
lowers to forward ops), but every embedding-style backward was
11+
round-tripping to `Nx.BinaryBackend`.
12+
- **Native `Native.gather/4`, `Native.scatter/4`, `Native.scatter_add/4`**
13+
backing `mx::gather`, `mx::scatter`, `mx::scatter_add`. List-of-
14+
per-axis-indices form (MLX's native shape) rather than the
15+
`{N, rank}` tensor Nx passes around — the Backend layer does the
16+
translation.
17+
- **`Emily.Backend.gather/4`, `indexed_add/5`, `indexed_put/5`**
18+
rewired. `gather` retains the single-axis `Native.take` fast path
19+
(now with an explicit output reshape — fixes a latent shape lie
20+
exposed when downstream MLX ops inspect the ref shape directly,
21+
e.g. `Nx.gather` followed by `Nx.dot` in a grad); the multi-axis
22+
path is native. Shared `apply_scatter/6` helper handles index
23+
splitting (`split_indices_per_axis/3`) and MLX's updates-shape
24+
rewrap — Nx ships `{batch ++ non_indexed_dims}` but MLX requires
25+
`{batch ++ per_axis_slot}` with a length-1 dim at every indexed-
26+
axis position (`updates_shape_for_scatter/3`). Fallback to
27+
`via_binary` on shapes outside the covered contract.
28+
- **Duplicate-index divergence.** MLX `scatter` is parallel and
29+
unordered on duplicate indices; `Nx.indexed_put` is deterministic
30+
last-write. Documented in the NIF and Backend; grad property
31+
generators dedupe. `scatter_add` is commutative so duplicates
32+
accumulate correctly either way.
33+
- **`test/emily/native_test.exs`** (+5 cases) — scalar-write,
34+
partial-axis slice write, and the load-bearing `{B, L, D}` target
35+
with `axes: [0, 1]` rewrap case.
36+
- **`test/emily/backend_test.exs`** (+8 tests) — targeted cases for
37+
all three ops exercising the Backend translation paths end-to-end
38+
via Nx.
39+
- **`test/emily/grad/grad_equivalence_test.exs`** — property zoo
40+
covering sum, dot, reshape∘transpose, broadcast, gather,
41+
indexed_add, plus two composition cases (gather→dot→softmax and
42+
a mini-attention block). Each case runs under `compiler:
43+
Emily.Compiler` on Emily.Backend and `compiler:
44+
Nx.Defn.Evaluator` on `Nx.BinaryBackend`, asserting the
45+
grad matches within a grad-scaled tolerance. Also covers the
46+
M9 PRNG-key-threading risk called out in `PLAN.md`: grad through
47+
a `Nx.Random.uniform_split`-driven dropout with fixed keys
48+
produces bit-identical results across repeat runs.
49+
- **`test/emily/grad/finite_diff_test.exs`** +
50+
**`test/support/grad_helper.ex`** — finite-difference numerical-
51+
gradient oracle. Pilot of four ops (`sum`, `dot`, `logsumexp`,
52+
`sigmoid`) with per-op tolerance tables; the harness catches the
53+
class of bug where symbolic-grad-on-Emily and symbolic-grad-on-
54+
BinaryBackend *agree* but are both wrong.
55+
- **`test/emily/training/mlp_curve_test.exs`** +
56+
**`test/emily/training/transformer_block_curve_test.exs`** +
57+
**`test/support/training_helper.ex`** — handwritten MLP and
58+
single transformer block (attention + FFN + residuals) trained
59+
with vanilla SGD for 50 steps on a fixed synthetic batch. Two
60+
tolerance bands asserted in each: per-step loss `rtol = 1e-3`
61+
(silent-drift canary — MLX parallel reductions diverge from
62+
BinaryBackend sequential reductions, so strict bit-match would
63+
be flaky) and final-loss `rtol = 1e-4` (convergence
64+
correctness). No Axon dep at this tier; the training loop is
65+
handwritten so a red test points at backend/grad numerics.
66+
- **`test/soak/training_test.exs`** — 1k-iteration training-loop
67+
memory soak, `@moduletag :soak` (default suite). Reuses the
68+
handwritten MLP; asserts active memory returns within 2 MB of
69+
baseline after `Native.clear_cache/0`. Training exercises a
70+
different allocator pattern than inference (param–grad pairs,
71+
activations), hence a dedicated soak alongside
72+
`memory_test.exs`.
73+
- **`test/emily/training/mnist_full_test.exs`** — opt-in MNIST
74+
convergence canary, `@moduletag :training_full` (excluded by
75+
default; run via `mix test --only training_full`). Loads MNIST
76+
through `scidata`, trains an Axon MLP on `Emily.Compiler` for 5
77+
epochs, asserts >97% test accuracy. Catches systemic grad drift
78+
that curve-matching misses because curve-matching uses
79+
BinaryBackend as its own oracle — MNIST convergence is an
80+
independent cross-check against real-world training dynamics.
81+
Typical wall time ~10 s on Apple Silicon.
82+
- **`{:scidata, "~> 0.1", only: :test}`** added for MNIST loading.
83+
Kept test-only; Emily itself has no dataset-loading dep.
84+
- **`test/test_helper.exs`**`:training_full` added to the
85+
default-exclude list, documented alongside the other heavyweight
86+
tags.
87+
- **`PLAN.md`** — M9 scope formalized; M10 (conv-pool training)
88+
and M11 (1.0 release) renumbered.
89+
590
- M8 — Native `conv`. `Emily.Backend.conv/4` now dispatches directly
691
to `Native.conv_general` (already bound to `mlx::core::conv_general`
792
since M1) instead of round-tripping through `Nx.BinaryBackend`.

c_src/ops/index.cpp

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010

1111
namespace mx = mlx::core;
1212
using emily::Tensor;
13+
using emily::to_int_vec;
1314
using emily::to_mlx_shape;
15+
using emily::unwrap_all;
1416
using emily::wrap;
1517

1618
namespace {
@@ -109,4 +111,59 @@ fine::ResourcePtr<Tensor> scatter_add_axis(
109111
}
110112
FINE_NIF(scatter_add_axis, 0);
111113

114+
// gather/4 — multi-axis fancy-indexing gather. `indices` is a list of
115+
// integer index tensors (one per axis in `axes`); all index tensors
116+
// share a common leading batch shape. `slice_sizes` has length
117+
// rank(a); entries for the indexed axes are 1 and entries for the
118+
// remaining axes equal a.shape()[axis] (the window size per gather).
119+
// Result shape is `batch_shape ++ slice_sizes`.
120+
fine::ResourcePtr<Tensor> gather(
121+
ErlNifEnv *,
122+
fine::ResourcePtr<Tensor> a,
123+
std::vector<fine::ResourcePtr<Tensor>> indices,
124+
std::vector<int64_t> axes,
125+
std::vector<int64_t> slice_sizes) {
126+
return wrap(mx::gather(
127+
a->array,
128+
unwrap_all(indices),
129+
to_int_vec(axes),
130+
to_mlx_shape(slice_sizes)));
131+
}
132+
FINE_NIF(gather, 0);
133+
134+
// scatter/4 — multi-axis fancy-indexing scatter (overwrite). MLX
135+
// requires `updates.ndim() == indices[0].ndim() + a.ndim()`; each
136+
// update is a slice of a.ndim() dims written at the index site. The
137+
// Backend layer is responsible for reshaping Nx-shaped updates into
138+
// MLX's expected shape.
139+
//
140+
// Note: MLX scatter with duplicate indices has unordered semantics
141+
// (parallel write) — it is not last-write-wins like Nx.indexed_put.
142+
// Callers requiring deterministic duplicate handling must dedupe
143+
// beforehand.
144+
fine::ResourcePtr<Tensor> scatter(
145+
ErlNifEnv *,
146+
fine::ResourcePtr<Tensor> a,
147+
std::vector<fine::ResourcePtr<Tensor>> indices,
148+
fine::ResourcePtr<Tensor> updates,
149+
std::vector<int64_t> axes) {
150+
return wrap(mx::scatter(
151+
a->array, unwrap_all(indices), updates->array, to_int_vec(axes)));
152+
}
153+
FINE_NIF(scatter, 0);
154+
155+
// scatter_add/4 — multi-axis scatter-accumulate. Same shape contract
156+
// as scatter; duplicate indices accumulate deterministically (add is
157+
// commutative, so parallel scatter order doesn't affect the result).
158+
fine::ResourcePtr<Tensor> scatter_add(
159+
ErlNifEnv *,
160+
fine::ResourcePtr<Tensor> a,
161+
std::vector<fine::ResourcePtr<Tensor>> indices,
162+
fine::ResourcePtr<Tensor> updates,
163+
std::vector<int64_t> axes) {
164+
return wrap(mx::scatter_add(
165+
a->array, unwrap_all(indices), updates->array, to_int_vec(axes)));
166+
}
167+
FINE_NIF(scatter_add, 0);
168+
112169
} // namespace

0 commit comments

Comments
 (0)