|
| 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 |
0 commit comments