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