Skip to content

Commit 1e9b702

Browse files
authored
Merge pull request #31 from ausimian/m16-mixed-precision
M16: mixed-precision training
2 parents ca8665b + d68c745 commit 1e9b702

15 files changed

Lines changed: 893 additions & 89 deletions

PLAN.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,31 @@ Promotes "mixed-precision master weights" out of v1 non-goals.
809809
0.5% of the f32 baseline; loss-scaling primitives documented with a
810810
worked example in the moduledoc.
811811

812+
**Shipped.**
813+
814+
- `Emily.MixedPrecision` (`lib/emily/mixed_precision.ex`) — `cast_params/2`,
815+
`accumulate_grad/2`, `loss_scale/1`, `scale_loss/2`, `unscale/2`,
816+
`update/2`, `has_overflow?/1`. Nested `LossScaler` struct with dynamic
817+
scaling (halve on overflow, double every N successful steps, floor at
818+
`min_scale`). Moduledoc includes a complete mixed-precision training
819+
step as a worked example. Traversal covers maps, tuples, lists, and
820+
`%Nx.Tensor{}` leaves; `Nx.Container` structs (e.g. `Axon.ModelState`)
821+
are not traversed — documented.
822+
- bf16 grad equivalence (`test/emily/grad/bf16_grad_equivalence_test.exs`)
823+
— all 8 zoo functions pass under Emily.Compiler vs BinaryBackend
824+
Evaluator, both in bf16, within 1e-2 tolerance.
825+
- Mixed-precision MLP curve-matching
826+
(`test/emily/training/bf16_mlp_curve_test.exs`) — 50-step training
827+
loop with f32 master weights, bf16 forward pass, loss scaling, and f32
828+
gradient accumulation. Emily vs BinaryBackend within rtol 5e-2.
829+
- bf16 MNIST convergence canary
830+
(`test/emily/training/mnist_bf16_full_test.exs`) — `:training_full`,
831+
uses `Axon.MixedPrecision.create_policy`. Target ≥ 95.5%.
832+
- Backend `coerce` fix: `Emily.Backend.wrap` now checks `Native.dtype(ref)`
833+
and casts if the MLX buffer dtype disagrees with the declared Nx output
834+
type. Previously only handled pred→u8; now handles all type mismatches
835+
(e.g. bf16 buffer with f32 metadata from `Nx.Defn.grad` type promotion).
836+
812837
### M17 — Conv-pool training (was M10)
813838

814839
Originally scoped as M10 in the pre-review plan. Re-prioritized below

RELEASE.md

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

99
## Added
1010

11+
- M16 — Mixed-precision training. `Emily.MixedPrecision` delivers the
12+
standard bf16 recipe: `cast_params/2` (downcast f32 → bf16 for the
13+
forward pass), `accumulate_grad/2` (upcast bf16 grads → f32 for the
14+
optimizer), `loss_scale/1` / `scale_loss/2` / `unscale/2` / `update/2`
15+
(dynamic loss scaling with overflow detection). `LossScaler` struct
16+
halves the scale on inf/nan overflow, doubles every N successful steps,
17+
floors at a configurable minimum. Moduledoc includes a complete
18+
worked example.
19+
- **Backend `coerce` fix**: `Emily.Backend.wrap` now checks
20+
`Native.dtype(ref)` and casts when the MLX buffer dtype disagrees
21+
with the declared Nx output type. Fixes bf16 grads where
22+
`Nx.Defn.grad` promotes the output type metadata to f32 but the
23+
MLX buffer stays bf16.
24+
- **Tests**: `mixed_precision_test.exs` (33 unit tests), bf16 grad
25+
equivalence for all 8 zoo functions, bf16 mixed-precision MLP
26+
curve-matching (50 steps, rtol 5e-2), bf16 MNIST convergence canary
27+
(`:training_full`, target ≥ 95.5%).
28+
1129
- M15 — Native linalg. `lu`, `svd`, `qr` (reduced), `cholesky`, `eigh`,
1230
`solve`, and `triangular_solve` now dispatch directly to
1331
`mx::linalg::*` instead of round-tripping through `Nx.BinaryBackend`.

lib/emily/backend.ex

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,17 @@ defmodule Emily.Backend do
5555
%{out | data: %B{ref: coerce(ref, type, w)}}
5656
end
5757

58+
# Fast path: pred→u8 is the most common mismatch (MLX comparison/logical
59+
# ops yield bool; Nx expects u8). The general path covers bf16→f32 type
60+
# promotion from Nx.Defn.grad and any other MLX/Nx dtype disagreement.
5861
defp coerce(ref, {:u, 8}, w), do: Native.astype(w, ref, {:u, 8})
59-
defp coerce(ref, _, _w), do: ref
62+
63+
defp coerce(ref, type, w) do
64+
case Native.dtype(ref) do
65+
^type -> ref
66+
_ -> Native.astype(w, ref, type)
67+
end
68+
end
6069

6170
defp shape_list(shape) when is_tuple(shape), do: Tuple.to_list(shape)
6271

lib/emily/mixed_precision.ex

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
defmodule Emily.MixedPrecision do
2+
@moduledoc """
3+
Mixed-precision training utilities.
4+
5+
Standard recipe for memory-efficient training: bf16 activations with
6+
f32 master weights and dynamic loss scaling. Keeps the full-precision
7+
copy of parameters for numerically stable optimizer updates while
8+
running the forward and backward pass in half precision.
9+
10+
## Worked example
11+
12+
alias Emily.MixedPrecision, as: MP
13+
alias Emily.MixedPrecision.LossScaler
14+
15+
# f32 master weights — the optimizer's ground truth.
16+
master_params = init_params()
17+
scaler = MP.loss_scale()
18+
19+
for {x, y} <- batches, reduce: {master_params, scaler} do
20+
{params, scaler} ->
21+
# Forward pass in bf16.
22+
bf16_params = MP.cast_params(params, {:bf, 16})
23+
24+
# Backward pass: grad w.r.t. f32 master params, but the
25+
# forward graph runs in bf16 thanks to the as_type casts
26+
# inside the closure.
27+
grads =
28+
Nx.Defn.grad(params, fn p ->
29+
p
30+
|> MP.cast_params({:bf, 16})
31+
|> forward(x, y)
32+
|> MP.scale_loss(scaler)
33+
end)
34+
35+
# Unscale, detect overflow, adjust scaler.
36+
{grads, overflow?} = MP.unscale(grads, scaler)
37+
scaler = MP.update(scaler, overflow?)
38+
39+
if overflow? do
40+
{params, scaler}
41+
else
42+
f32_grads = MP.accumulate_grad(grads, {:f, 32})
43+
{sgd_step(params, f32_grads, lr), scaler}
44+
end
45+
end
46+
47+
## Container traversal
48+
49+
`cast_params/2`, `accumulate_grad/2`, and `has_overflow?/1` traverse
50+
plain maps, tuples, and lists of `Nx.Tensor` leaves. For
51+
`Axon.ModelState`, access the `.data` field first:
52+
53+
MP.cast_params(model_state.data, {:bf, 16})
54+
"""
55+
56+
defmodule LossScaler do
57+
@moduledoc """
58+
Dynamic loss-scaler state for mixed-precision training.
59+
60+
Tracks the current scale factor and the number of consecutive
61+
successful (non-overflow) steps. On overflow the scale is halved;
62+
after `growth_interval` successful steps it doubles.
63+
"""
64+
65+
@default_scale 65_536.0
66+
67+
@enforce_keys [:scale]
68+
defstruct scale: @default_scale,
69+
growth_factor: 2.0,
70+
backoff_factor: 0.5,
71+
growth_interval: 2000,
72+
min_scale: 1.0,
73+
counter: 0
74+
75+
@doc "Create a new loss scaler."
76+
def new(opts \\ []) do
77+
fields =
78+
opts
79+
|> Keyword.validate!([
80+
:scale,
81+
:growth_factor,
82+
:backoff_factor,
83+
:growth_interval,
84+
:min_scale
85+
])
86+
|> then(&Keyword.merge(Map.to_list(%__MODULE__{scale: @default_scale}), &1))
87+
88+
struct!(__MODULE__, fields)
89+
end
90+
end
91+
92+
@doc """
93+
Downcast float tensors in a nested structure to `type`.
94+
95+
Integer and predicate tensors are left unchanged.
96+
"""
97+
def cast_params(params, type), do: deep_apply(params, &Nx.as_type(&1, type))
98+
99+
@doc """
100+
Upcast float tensors in a nested gradient structure to `type`.
101+
102+
Semantically identical to `cast_params/2` — exists for readability
103+
at the call site (the direction of the cast is part of the name).
104+
"""
105+
def accumulate_grad(grads, type), do: deep_apply(grads, &Nx.as_type(&1, type))
106+
107+
@doc """
108+
Create a new dynamic loss scaler.
109+
110+
## Options
111+
112+
* `:scale` — initial scale factor (default `65_536.0`)
113+
* `:growth_factor` — multiply scale by this on growth (default `2.0`)
114+
* `:backoff_factor` — multiply scale by this on overflow (default `0.5`)
115+
* `:growth_interval` — successful steps before growing (default `2000`)
116+
* `:min_scale` — floor for the scale (default `1.0`)
117+
"""
118+
def loss_scale(opts \\ []), do: LossScaler.new(opts)
119+
120+
@doc """
121+
Scale the loss by the scaler's current factor.
122+
123+
Call this inside the function passed to `Nx.Defn.grad` so that the
124+
backward pass produces scaled gradients.
125+
"""
126+
def scale_loss(loss, %LossScaler{scale: scale}) do
127+
Nx.multiply(loss, scale)
128+
end
129+
130+
@doc """
131+
Unscale gradients and detect overflow.
132+
133+
Divides every float tensor in `grads` by `scaler.scale`, then checks
134+
for inf/nan. Returns `{unscaled_grads, overflow?}`.
135+
"""
136+
def unscale(grads, %LossScaler{scale: scale}) do
137+
inv_scale = 1.0 / scale
138+
unscaled = deep_apply(grads, &Nx.multiply(&1, inv_scale))
139+
{unscaled, has_overflow?(unscaled)}
140+
end
141+
142+
@doc """
143+
Update the scaler after a training step.
144+
145+
On overflow: halves the scale (floored at `min_scale`), resets the
146+
counter. On success: increments the counter; doubles the scale after
147+
`growth_interval` consecutive successes.
148+
"""
149+
def update(%LossScaler{} = scaler, true = _overflow) do
150+
%{scaler | scale: max(scaler.min_scale, scaler.scale * scaler.backoff_factor), counter: 0}
151+
end
152+
153+
def update(%LossScaler{} = scaler, false = _overflow) do
154+
counter = scaler.counter + 1
155+
156+
if counter >= scaler.growth_interval do
157+
%{scaler | scale: scaler.scale * scaler.growth_factor, counter: 0}
158+
else
159+
%{scaler | counter: counter}
160+
end
161+
end
162+
163+
@doc """
164+
Check whether any tensor in a nested structure contains inf or nan.
165+
"""
166+
def has_overflow?(structure), do: deep_overflow?(structure)
167+
168+
defp float_type?({kind, _}) when kind in [:f, :bf], do: true
169+
defp float_type?(_), do: false
170+
171+
defp deep_apply(%Nx.Tensor{} = t, fun) do
172+
if float_type?(t.type), do: fun.(t), else: t
173+
end
174+
175+
defp deep_apply(map, fun) when is_map(map) and not is_struct(map) do
176+
Map.new(map, fn {k, v} -> {k, deep_apply(v, fun)} end)
177+
end
178+
179+
defp deep_apply(tuple, fun) when is_tuple(tuple) do
180+
tuple |> Tuple.to_list() |> Enum.map(&deep_apply(&1, fun)) |> List.to_tuple()
181+
end
182+
183+
defp deep_apply(list, fun) when is_list(list) do
184+
Enum.map(list, &deep_apply(&1, fun))
185+
end
186+
187+
defp deep_apply(other, _fun), do: other
188+
189+
defp deep_overflow?(%Nx.Tensor{} = t) do
190+
if float_type?(t.type) do
191+
t
192+
|> Nx.is_nan()
193+
|> Nx.logical_or(Nx.is_infinity(t))
194+
|> Nx.any()
195+
|> Nx.backend_transfer(Nx.BinaryBackend)
196+
|> Nx.to_number() == 1
197+
else
198+
false
199+
end
200+
end
201+
202+
defp deep_overflow?(map) when is_map(map) and not is_struct(map) do
203+
Enum.any?(map, fn {_k, v} -> deep_overflow?(v) end)
204+
end
205+
206+
defp deep_overflow?(tuple) when is_tuple(tuple) do
207+
tuple |> Tuple.to_list() |> Enum.any?(&deep_overflow?/1)
208+
end
209+
210+
defp deep_overflow?(list) when is_list(list) do
211+
Enum.any?(list, &deep_overflow?/1)
212+
end
213+
214+
defp deep_overflow?(_), do: false
215+
end
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
defmodule Emily.Grad.Bf16EquivalenceTest do
2+
@moduledoc """
3+
bf16 gradient equivalence tests (M16).
4+
5+
For each function in the grad zoo, casts the fixed inputs to bf16 and
6+
asserts that `grad` computed under `Emily.Compiler` matches the same
7+
grad computed under `Nx.Defn.Evaluator` on `Nx.BinaryBackend` — both
8+
sides in bf16. The oracle is BinaryBackend's software bf16, not f32.
9+
10+
bf16 has ~3 decimal digits of precision, so tolerances are 1e-2
11+
(matching `BackendGenerators.tol_for({:bf, _})`).
12+
13+
## Note on grad type promotion
14+
15+
`Nx.Defn.grad` may promote the output type from bf16 to f32 in
16+
certain backward ops. Both the Emily and BinaryBackend results are
17+
cast to f32 before comparison to normalise the binary
18+
representation — the bf16-level tolerance still applies because
19+
the computation itself ran in bf16.
20+
"""
21+
22+
use ExUnit.Case, async: true
23+
24+
import Emily.BackendGenerators, only: [assert_close: 3, to_emily: 1]
25+
import Emily.GradZoo
26+
27+
@bf16_tol 1.0e-2
28+
29+
for name <- Emily.GradZoo.all_functions() do
30+
test "#{name} — bf16 grad matches BinaryBackend bf16 oracle" do
31+
name = unquote(name)
32+
fun = grad_function(name)
33+
inputs = fixed_inputs_bf16(name)
34+
35+
emily_inputs = Enum.map(inputs, &to_emily/1)
36+
37+
emily =
38+
Nx.Defn.jit_apply(fun, emily_inputs, compiler: Emily.Compiler)
39+
|> Nx.as_type({:f, 32})
40+
41+
ref =
42+
Nx.Defn.jit_apply(fun, inputs, compiler: Nx.Defn.Evaluator)
43+
|> Nx.as_type({:f, 32})
44+
45+
assert_close(emily, ref, tol: @bf16_tol)
46+
end
47+
end
48+
end

0 commit comments

Comments
 (0)