Skip to content

Commit 360479b

Browse files
committed
M17: native window ops (conv-pool training)
Lift window_sum, window_max, window_min, window_product, window_scatter_max, window_scatter_min off via_binary onto native MLX. MLX has no direct primitives — each op is composed as mx::pad → mx::as_strided (sliding-window view) → reduce, mirroring nn/layers/pooling.py generalised to N-D so Nx's per-axis window/stride/padding/dilation all lower cleanly. Scatter variants add argmax-with-last-occurrence-tie-break (mask * arange(K) trick, since mx::argmax is first-occurrence) → per-axis absolute-index computation → mx::scatter_add into a mx::full(init_value) buffer → slice back. Closes the training primitive set: grad(window_max) in Nx rewrites to window_scatter_max, so every MaxPool backward pass now stays on MLX instead of round-tripping through BinaryBackend. Tests: backend_window_test (19 unit — shape × stride × padding × dilation × f32/bf16/s32/u8), backend_window_scatter_test (11 — overlapping windows, tie-break, non-zero init, 1-D/3-D), grad-equivalence extensions (3 new zoo fns: window_sum, window_max_pool, window_avg_pool; bf16 equivalence auto-picks-up; EXLA oracle skips un-regenerated entries via ExlaGoldenData.has_golden?/1), cnn_curve_test (handwritten 2-conv + max-pool CNN, 30 steps), mnist_cnn_full_test (:training_full, LeNet ≥97%, observed 1.60 → 0.12 loss over 5 epochs).
1 parent 5b23df1 commit 360479b

15 files changed

Lines changed: 1315 additions & 29 deletions

PLAN.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,43 @@ cover the new ops, plus a small-CNN MNIST run in `:training_full`.
855855
**Exit:** grad-equivalence on window ops green; small-CNN MNIST
856856
training converges in `:training_full`.
857857

858+
**Shipped.**
859+
860+
- `c_src/ops/pooling.cpp` — six new NIFs. Reductions composed as
861+
`mx::pad``mx::as_strided` (sliding-window view) → reduce, mirroring
862+
`vendor/mlx/python/mlx/nn/layers/pooling.py` generalised to N-D.
863+
Scatter variants add argmax-with-last-occurrence-tie-break
864+
(`mask * arange(K)` + argmax, since MLX's native argmax is
865+
first-occurrence) → per-axis absolute-index computation →
866+
`mx::scatter_add` into a `mx::full(init_value)` padded buffer →
867+
slice back to the input shape.
868+
- `Emily.Native`: 6 new NIF stubs
869+
(`window_sum`/`max`/`min`/`product`/`scatter_max`/`scatter_min`).
870+
- `Emily.Backend` (`backend.ex:1101-1225`): `apply_window_reduce/5` and
871+
`apply_window_scatter/6` helpers; `:valid`/`:same` padding resolution
872+
and dtype-specific identity (0/1/±∞, plus `{:s, _}` / `{:u, _}`
873+
min/max) done in Elixir before the NIF.
874+
- Forward-parity coverage:
875+
`test/emily/backend_window_test.exs` (19 unit tests across shape ×
876+
stride × padding × dilation × f32/bf16/s32/u8),
877+
`test/emily/backend_window_scatter_test.exs` (11 tests including
878+
overlapping windows, tie-break, non-zero `init_value`, 1-D/3-D).
879+
- Grad-equivalence extensions (`test/support/grad_zoo.ex` +
880+
`test/emily/grad/grad_equivalence_test.exs`): three new zoo fns —
881+
`grad_window_sum`, `grad_window_max_pool` (lands on
882+
`window_scatter_max` via Nx's grad rule), and `grad_window_avg_pool`.
883+
`bf16_grad_equivalence_test.exs` auto-picks-up the new zoo entries.
884+
The EXLA oracle skips un-regenerated zoo entries via
885+
`ExlaGoldenData.has_golden?/1` — run `mix run
886+
bench/exla_golden_gen.exs` to add window goldens in a follow-up.
887+
- `test/emily/training/cnn_curve_test.exs` — handwritten 2-conv +
888+
max-pool CNN, 30-step SGD, per-step loss trajectory match vs
889+
BinaryBackend within rtol 1e-2 (looser than the MLP curve test
890+
because the CNN stacks four reductions).
891+
- `test/emily/training/mnist_cnn_full_test.exs``:training_full`
892+
LeNet-style Axon CNN on MNIST, ≥ 97% test accuracy. Validated:
893+
5-epoch loss 1.60 → 0.12, test accuracy ≥ 97% on a 64-batch run.
894+
858895
### M18 — Observability & fallback telemetry
859896

860897
Hitting `via_binary` is ~100× slower than native and emits no signal.

RELEASE.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,37 @@
88

99
## Added
1010

11+
- M17 — Conv-pool training (native window ops). Lifted `window_sum`,
12+
`window_max`, `window_min`, `window_product`, `window_scatter_max`,
13+
and `window_scatter_min` off the `via_binary` fallback onto native
14+
MLX. No direct MLX primitives exist — each op is composed as
15+
`mx::pad``mx::as_strided` (sliding-window view) → reduce (for
16+
the four reductions) or → argmax-with-last-occurrence-tie-break →
17+
`mx::scatter_add` (for the two scatters). Mirrors MLX's own
18+
`nn/layers/pooling.py` but generalised to N-D so Nx's per-axis
19+
window/stride/padding/dilation all lower cleanly.
20+
- **Closes the training primitive set** — Nx rewrites
21+
`grad(window_max)` to `window_scatter_max`, so every CNN with
22+
MaxPool2d lands on the new scatter path on its backward pass. No
23+
more `via_binary` round-trips during CNN training.
24+
- **Tie-break** — Nx picks the last-occurrence argmax/min inside a
25+
window; MLX's `argmax` is first-occurrence. The scatter path
26+
uses a `mask * arange(K)` trick to recover last-occurrence.
27+
- **New NIFs** in `c_src/ops/pooling.cpp`: `window_sum`,
28+
`window_max`, `window_min`, `window_product`,
29+
`window_scatter_max`, `window_scatter_min`. Corresponding
30+
stubs in `Emily.Native`; `Emily.Backend` resolves `:valid`/`:same`
31+
padding and dtype-specific identity values (0/1/±∞ or integer
32+
min/max) in Elixir before dispatch.
33+
- **Tests**: `backend_window_test.exs` (19 unit tests —
34+
reductions × shape × stride × padding × dilation × f32/bf16/s32/u8),
35+
`backend_window_scatter_test.exs` (11 unit tests — overlapping
36+
windows + tie-break + non-zero init + 1-D/3-D), grad-equivalence
37+
extensions for `window_sum`, `window_max` (max-pool grad),
38+
`window_avg` (3 new zoo fns), `cnn_curve_test.exs` (handwritten
39+
2-conv + max-pool CNN, 30 steps, per-step loss match), LeNet
40+
MNIST canary (`:training_full`, target ≥ 97%).
41+
1142
- M16 — Mixed-precision training. `Emily.MixedPrecision` delivers the
1243
standard bf16 recipe: `cast_params/2` (downcast f32 → bf16 for the
1344
forward pass), `accumulate_grad/2` (upcast bf16 grads → f32 for the

0 commit comments

Comments
 (0)