Skip to content

Commit e130611

Browse files
authored
Merge pull request #180 from ausimian/feat/expr-compiler-training-convergence
Native training convergence tests (#174)
2 parents c2f1e8d + c2f5833 commit e130611

7 files changed

Lines changed: 425 additions & 6 deletions

File tree

RELEASE.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,15 @@
6868
the eager NIFs and the compiled replay share one implementation. A
6969
small-CNN **training step** (conv + maxpool forward and backward, grad,
7070
SGD) now lowers fully native under `native_fallback: :raise`, producing a
71-
loss bit-identical to the evaluator.
71+
loss bit-identical to the evaluator. Native training is now
72+
**convergence**-tested, not just verified-lowering: handwritten CNN
73+
(30 SGD steps) and MLP (50 steps) trajectories match the op-by-op
74+
evaluator bit-for-bit and a `BinaryBackend` oracle to f32 tolerance, and
75+
full **Axon** training drives native end-to-end — `Axon.Loop.run`
76+
forwards `native: true`/`native_fallback:` to the defn jit, so a LeNet
77+
CNN and a dense MLP train on real MNIST entirely through the single-NIF
78+
path (forward, categorical-cross-entropy, backward, Adam) and reach the
79+
same >97% / >96% accuracy as the evaluator (`:training_full`).
7280

7381
- **`Bumblebee.Text.generation` compiles fully native — greedy and sampling.**
7482
The headline result: an end-to-end Bumblebee generation (the transformer
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
defmodule Emily.Training.CnnNativeCurveTest do
2+
@moduledoc """
3+
Native single-NIF CNN training convergence (issue #174).
4+
5+
The training analogue of the conformance native lanes
6+
(`Emily.Conformance.CompilerNativeTest`): a full conv + maxpool
7+
training step — forward, backward, grad, and SGD update — is driven
8+
through `compiler: Emily.Compiler, native: true, native_fallback:
9+
:raise` for 30 steps and the per-step loss trajectory is checked
10+
against two references.
11+
12+
Why this exists. `cnn_curve_test.exs` already curve-matches the
13+
handwritten CNN, but only in **eval** mode (`Emily.Compiler` walking
14+
the Expr op-by-op via the Evaluator). Every other `training/*` test
15+
is eval-only too, so CNN training was verified-lowering (the
16+
`compiler_equivalence_test.exs` op gates) but never **convergence**-
17+
tested under the single-NIF replay. This closes that gap.
18+
19+
Three lanes, same deterministic init and data:
20+
21+
* **native** — `native: true, native_fallback: :raise`. The
22+
`:raise` makes this a no-fallback gate: if any op in the
23+
forward+backward+grad+SGD step fails to lower (the maxpool
24+
backward lands on `window_scatter_max` every step; the conv
25+
backward flips the kernel with `reverse`), the run raises here
26+
instead of silently degrading to the evaluator.
27+
* **eval** — `Emily.Compiler` op-by-op. Same MLX kernels in the
28+
same order as the native replay, so the two track **bit-
29+
identically** through training. A 1e-6 bar asserts the single-
30+
NIF lowering reproduces op-by-op exactly across 30 SGD updates.
31+
* **binary** — `Nx.Defn.Evaluator` on `Nx.BinaryBackend`, the
32+
non-MLX convergence oracle. Looser bar (1e-2 rtol, as in
33+
`cnn_curve_test.exs`) absorbs f32 reduction-order drift between
34+
MLX's parallel reductions and BinaryBackend's sequential ones.
35+
36+
No Axon — the handwritten path keeps the failure surface tiny (see
37+
`cnn_curve_test.exs`). The Axon CNN canary stays in
38+
`mnist_cnn_full_test.exs` (`:training_full`).
39+
"""
40+
41+
use ExUnit.Case, async: true
42+
43+
alias Emily.TrainingHelper, as: TH
44+
import TH, only: [close?: 4, flunk_trajectory: 5]
45+
46+
@native [compiler: Emily.Compiler, native: true, native_fallback: :raise]
47+
@eval [compiler: Emily.Compiler]
48+
49+
@input_shape {1, 10, 10}
50+
@batch 4
51+
@classes 3
52+
@steps 30
53+
@lr_val 0.05
54+
55+
test "per-step CNN loss trajectory matches under native single-NIF compile" do
56+
# Native single-NIF lane — the system under test. `native_fallback:
57+
# :raise` proves full native coverage of the training step.
58+
params_native = TH.init_cnn(@input_shape, @classes, 0, Emily.Backend)
59+
{x_native, y_native} = TH.cnn_batch({@batch, 10, 10}, @classes, Emily.Backend)
60+
lr_native = Nx.tensor(@lr_val, type: {:f, 32}, backend: Emily.Backend)
61+
62+
losses_native =
63+
TH.run_steps(
64+
&TH.cnn_step_with_loss/4,
65+
params_native,
66+
[x_native, y_native, lr_native],
67+
@steps,
68+
@native
69+
)
70+
71+
# Op-by-op Emily eval lane — same MLX kernels, isolates single-NIF
72+
# lowering bugs from backend numerics.
73+
params_eval = TH.init_cnn(@input_shape, @classes, 0, Emily.Backend)
74+
{x_eval, y_eval} = TH.cnn_batch({@batch, 10, 10}, @classes, Emily.Backend)
75+
lr_eval = Nx.tensor(@lr_val, type: {:f, 32}, backend: Emily.Backend)
76+
77+
losses_eval =
78+
TH.run_steps(
79+
&TH.cnn_step_with_loss/4,
80+
params_eval,
81+
[x_eval, y_eval, lr_eval],
82+
@steps,
83+
@eval
84+
)
85+
86+
# BinaryBackend oracle — the non-MLX convergence reference.
87+
params_bin = TH.init_cnn(@input_shape, @classes, 0, Nx.BinaryBackend)
88+
{x_bin, y_bin} = TH.cnn_batch({@batch, 10, 10}, @classes, Nx.BinaryBackend)
89+
lr_bin = Nx.tensor(@lr_val, type: {:f, 32}, backend: Nx.BinaryBackend)
90+
91+
losses_bin =
92+
TH.run_steps(
93+
&TH.cnn_step_with_loss/4,
94+
params_bin,
95+
[x_bin, y_bin, lr_bin],
96+
@steps,
97+
Nx.Defn.Evaluator
98+
)
99+
100+
assert length(losses_native) == @steps
101+
assert length(losses_eval) == @steps
102+
assert length(losses_bin) == @steps
103+
104+
# 1. Single-NIF native == op-by-op eval. Both are MLX in the same
105+
# order, so they track bit-identically; the tight bar makes a
106+
# divergent native trajectory a hard failure.
107+
for {{ln, le}, i} <- Enum.zip(losses_native, losses_eval) |> Enum.with_index() do
108+
close?(ln, le, 1.0e-6, 1.0e-6) ||
109+
flunk_trajectory(i, ln, le, losses_native, losses_eval)
110+
end
111+
112+
# 2. Native trajectory matches the BinaryBackend oracle within the
113+
# CNN tolerance — same bar as cnn_curve_test.exs.
114+
for {{ln, lb}, i} <- Enum.zip(losses_native, losses_bin) |> Enum.with_index() do
115+
close?(ln, lb, 1.0e-4, 1.0e-2) ||
116+
flunk_trajectory(i, ln, lb, losses_native, losses_bin)
117+
end
118+
119+
# 3. Convergence — the native loss actually decreased over the run.
120+
assert List.first(losses_native) > List.last(losses_native),
121+
"native loss did not decrease: first=#{List.first(losses_native)} " <>
122+
"last=#{List.last(losses_native)}"
123+
124+
# 4. Final loss agrees with the oracle (convergence correctness:
125+
# catches a run where per-step drift averaged out but the
126+
# optimizer ended up somewhere wrong).
127+
ln_final = List.last(losses_native)
128+
lb_final = List.last(losses_bin)
129+
130+
assert close?(ln_final, lb_final, 1.0e-4, 1.0e-2),
131+
"final loss divergence: native=#{ln_final} bin=#{lb_final} " <>
132+
"reldiff=#{abs(ln_final - lb_final) / abs(lb_final)}"
133+
end
134+
end
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
defmodule Emily.Training.MlpNativeCurveTest do
2+
@moduledoc """
3+
Native single-NIF MLP training convergence (issue #174).
4+
5+
The dense + SGD companion to `cnn_native_curve_test.exs`: a 2-layer
6+
ReLU MLP training step — forward, backward, grad, SGD update — is
7+
driven through `compiler: Emily.Compiler, native: true,
8+
native_fallback: :raise` for 50 steps and the per-step loss
9+
trajectory is checked against two references.
10+
11+
This closes the matmul-dominated half of the training-coverage gap
12+
the issue calls out: `mlp_curve_test.exs` already curve-matches this
13+
MLP, but only in eval mode. Here the same step replays through the
14+
single NIF, with `:raise` proving the dense forward/backward and SGD
15+
update lower with **zero** fallback.
16+
17+
Three lanes, same deterministic init and data:
18+
19+
* **native** — single-NIF replay, no-fallback gate.
20+
* **eval** — `Emily.Compiler` op-by-op; bit-identical to native
21+
(same MLX kernels, same order), asserted at a 1e-6 bar.
22+
* **binary** — `Nx.Defn.Evaluator` on `Nx.BinaryBackend`, the
23+
non-MLX oracle. The MLP is matmul-dominated, so the bar matches
24+
`mlp_curve_test.exs` (1e-3 per-step rtol, 1e-4 final).
25+
"""
26+
27+
use ExUnit.Case, async: true
28+
29+
alias Emily.TrainingHelper, as: TH
30+
import TH, only: [close?: 4, flunk_trajectory: 5]
31+
32+
@native [compiler: Emily.Compiler, native: true, native_fallback: :raise]
33+
@eval [compiler: Emily.Compiler]
34+
35+
@dims {4, 8, 3}
36+
@batch_shape {16, 4, 3}
37+
@steps 50
38+
@lr_val 0.5
39+
40+
test "per-step MLP loss trajectory matches under native single-NIF compile" do
41+
# Native single-NIF lane — the system under test.
42+
params_native = TH.init_mlp(@dims, 0, Emily.Backend)
43+
{x_native, y_native} = TH.mlp_batch(@batch_shape, Emily.Backend)
44+
lr_native = Nx.tensor(@lr_val, type: {:f, 32}, backend: Emily.Backend)
45+
46+
losses_native =
47+
TH.run_steps(
48+
&TH.mlp_step_with_loss/4,
49+
params_native,
50+
[x_native, y_native, lr_native],
51+
@steps,
52+
@native
53+
)
54+
55+
# Op-by-op Emily eval lane — same MLX kernels.
56+
params_eval = TH.init_mlp(@dims, 0, Emily.Backend)
57+
{x_eval, y_eval} = TH.mlp_batch(@batch_shape, Emily.Backend)
58+
lr_eval = Nx.tensor(@lr_val, type: {:f, 32}, backend: Emily.Backend)
59+
60+
losses_eval =
61+
TH.run_steps(
62+
&TH.mlp_step_with_loss/4,
63+
params_eval,
64+
[x_eval, y_eval, lr_eval],
65+
@steps,
66+
@eval
67+
)
68+
69+
# BinaryBackend oracle.
70+
params_bin = TH.init_mlp(@dims, 0, Nx.BinaryBackend)
71+
{x_bin, y_bin} = TH.mlp_batch(@batch_shape, Nx.BinaryBackend)
72+
lr_bin = Nx.tensor(@lr_val, type: {:f, 32}, backend: Nx.BinaryBackend)
73+
74+
losses_bin =
75+
TH.run_steps(
76+
&TH.mlp_step_with_loss/4,
77+
params_bin,
78+
[x_bin, y_bin, lr_bin],
79+
@steps,
80+
Nx.Defn.Evaluator
81+
)
82+
83+
assert length(losses_native) == @steps
84+
assert length(losses_eval) == @steps
85+
assert length(losses_bin) == @steps
86+
87+
# 1. Single-NIF native == op-by-op eval (bit-identical MLX path).
88+
for {{ln, le}, i} <- Enum.zip(losses_native, losses_eval) |> Enum.with_index() do
89+
close?(ln, le, 1.0e-6, 1.0e-6) ||
90+
flunk_trajectory(i, ln, le, losses_native, losses_eval)
91+
end
92+
93+
# 2. Native trajectory matches the BinaryBackend oracle — same bar
94+
# as mlp_curve_test.exs.
95+
for {{ln, lb}, i} <- Enum.zip(losses_native, losses_bin) |> Enum.with_index() do
96+
close?(ln, lb, 1.0e-4, 1.0e-3) ||
97+
flunk_trajectory(i, ln, lb, losses_native, losses_bin)
98+
end
99+
100+
# 3. Convergence — the native loss actually decreased over the run.
101+
assert List.first(losses_native) > List.last(losses_native),
102+
"native loss did not decrease: first=#{List.first(losses_native)} " <>
103+
"last=#{List.last(losses_native)}"
104+
105+
# 4. Final loss agrees with the oracle (convergence correctness).
106+
ln_final = List.last(losses_native)
107+
lb_final = List.last(losses_bin)
108+
109+
assert close?(ln_final, lb_final, 1.0e-5, 1.0e-4),
110+
"final loss divergence: native=#{ln_final} bin=#{lb_final} " <>
111+
"reldiff=#{abs(ln_final - lb_final) / abs(lb_final)}"
112+
end
113+
end
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
defmodule Emily.Training.MnistCnnNativeFullTest do
2+
@moduledoc """
3+
MNIST CNN convergence under the **native single-NIF compiler**
4+
(issue #174, `:training_full`).
5+
6+
The native-lane analogue of `mnist_cnn_full_test.exs`: the same
7+
LeNet-style Axon CNN trains on real MNIST, but the whole training
8+
step compiles through `compiler: Emily.Compiler, native: true,
9+
native_fallback: :raise` instead of the op-by-op evaluator.
10+
11+
Why `native_fallback: :raise` makes this self-proving. Once
12+
`native: true` reaches `Emily.Compiler`, the run is binary: the Expr
13+
lowers to one program and replays in a single NIF, or an op it can't
14+
lower raises (`Emily.Compiler.build_native/4` reraises the lowering
15+
`ArgumentError` under `:raise` — it can never silently degrade to the
16+
evaluator, that path only exists under `:eval`). So a training run
17+
that *completes* proves the entire step — forward (conv, ReLU,
18+
maxpool), categorical-cross-entropy loss, the backward
19+
(`window_scatter_max` for the maxpool grad, `reverse` for the conv
20+
kernel flip), and the Adam update — lowered fully native with zero
21+
fallback. `Axon.Loop.run` forwards the per-call jit options (it pops
22+
only `:jit_compile?`/`:force_garbage_collection?`), so the options
23+
genuinely reach the compiler.
24+
25+
Native replay is bit-identical to the evaluator (same MLX kernels in
26+
the same order), so the accuracy bar matches the eval canary exactly
27+
(>97%). The point isn't a different number — it's that training
28+
reaches it through the single-NIF path.
29+
30+
Opt-in — `mix test --only training_full` (downloads MNIST, multi-
31+
minute training).
32+
"""
33+
34+
use ExUnit.Case, async: true
35+
36+
alias Emily.MnistHelper
37+
38+
@moduletag :training_full
39+
@moduletag capture_log: true
40+
@moduletag timeout: 600_000
41+
42+
setup do
43+
Nx.default_backend(Emily.Backend)
44+
:ok
45+
end
46+
47+
@batch_size 64
48+
@epochs 5
49+
@target_accuracy 0.97
50+
51+
# Strict no-fallback native lane. `:raise` makes a completed run a
52+
# proof of full native lowering (see the moduledoc).
53+
@native [compiler: Emily.Compiler, native: true, native_fallback: :raise]
54+
55+
test "Axon CNN reaches >#{trunc(@target_accuracy * 100)}% accuracy via the native single-NIF compiler" do
56+
{train_batches, test_images, test_labels} = MnistHelper.load_mnist(@batch_size, :cnn)
57+
58+
# Channels-last (Axon default) — MnistHelper produces {N, 28, 28, 1}.
59+
model =
60+
Axon.input("input", shape: {nil, 28, 28, 1})
61+
|> Axon.conv(8, kernel_size: {3, 3}, activation: :relu)
62+
|> Axon.max_pool(kernel_size: {2, 2}, strides: [2, 2])
63+
|> Axon.conv(16, kernel_size: {3, 3}, activation: :relu)
64+
|> Axon.max_pool(kernel_size: {2, 2}, strides: [2, 2])
65+
|> Axon.flatten()
66+
|> Axon.dense(64, activation: :relu)
67+
|> Axon.dense(10, activation: :softmax)
68+
69+
# The whole training loop (init + step) compiles native: `Axon.Loop.run`
70+
# forwards `native:`/`native_fallback:` to the defn jit. Under `:raise`,
71+
# reaching the end proves every op lowered — no silent eval fallback.
72+
trained_state =
73+
model
74+
|> Axon.Loop.trainer(:categorical_cross_entropy, :adam)
75+
|> Axon.Loop.run(train_batches, %{}, [epochs: @epochs] ++ @native)
76+
77+
# Evaluate through the native path too, so the accuracy that gates the
78+
# test is itself produced by the single-NIF forward.
79+
accuracy = MnistHelper.evaluate(model, trained_state, test_images, test_labels, @native)
80+
81+
assert accuracy >= @target_accuracy,
82+
"native MNIST CNN accuracy #{Float.round(accuracy, 4)} below target #{@target_accuracy}"
83+
end
84+
end

0 commit comments

Comments
 (0)