Fix NaN in scale_by_adam on float16 zero-gradient steps - #1755
Fix NaN in scale_by_adam on float16 zero-gradient steps#1755kartik1pandey wants to merge 3 commits into
Conversation
eps/eps_root were added in v's own dtype, so the default eps=1e-8 silently underflowed to 0.0 in float16 (smallest subnormal ~6e-8), turning the division-by-zero guard into a 0/0 NaN whenever a step's gradient was exactly zero. Fixes google-deepmind#1754.
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
Four pre-existing pyrefly errors on main, each a type-stub precision gap rather than a real bug (verified by running the affected test suites): a while_loop carry variable typed as a union across all carry slots, ArrayLike including complex in a real-only comparison, a None baseline only read behind an if-guard, and ravel_pytree's unravel_fn return type. Fixes google-deepmind#1756.
|
@googlebot I signed it! |
winklemad
left a comment
There was a problem hiding this comment.
The float32-promotion approach here looks right to me — computing the denominator in at least float32 keeps eps from rounding to 0.0 under float16 weak-type promotion, which is the actual cause.
One thing worth folding in before this merges: the same ... + eps denominator (the + eps outside the sqrt) is copy-pasted across several sibling optimizers in transform.py, and they underflow the same way, so a scale_by_adam-only fix leaves them NaN on the same input. I reproduced it against released optax 0.2.8:
import jax.numpy as jnp, optax
p = jnp.zeros((3,), jnp.float16); g = jnp.zeros((3,), jnp.float16)
for name, tx in [
('adam', optax.scale_by_adam()),
('amsgrad', optax.scale_by_amsgrad()),
('belief', optax.scale_by_belief()),
('rms', optax.scale_by_rms(eps_in_sqrt=False)),
('stddev', optax.scale_by_stddev(eps_in_sqrt=False)),
]:
st = tx.init(p); u, _ = tx.update(g, st, p)
print(name, bool(jnp.isnan(u).any()))
# adam/amsgrad/belief/rms/stddev -> all TrueThe matching denominators are:
scale_by_amsgrad—m / (jnp.sqrt(v + eps_root) + eps)(same line as adam's)scale_by_belief—m / (jnp.sqrt(v) + eps)scale_by_rms/scale_by_stddev—1 / (jnp.sqrt(...) + eps)on theeps_in_sqrt=Falsebranch
Since it's the same fix each time, it might be cleanest to pull the safe-dtype denominator into a small shared helper and use it at each site, rather than patching adam alone — and then the test could parametrize over the affected optimizers. Happy to help with that if useful. Nice catch on the root cause either way.
The same eps-underflow bug fixed for scale_by_adam is copy-pasted across scale_by_amsgrad, scale_by_belief, scale_by_rms, and scale_by_stddev (both eps_in_sqrt branches for the latter two) -- each adds eps/eps_root ahead of a sqrt/rsqrt safety denominator, which silently underflows to 0 in float16, producing a 0/0 (or inf*0) NaN on a zero-gradient step. Pulled the safe-dtype promotion into a shared helper, numerics.add_eps_in_safe_dtype, and applied it at every affected site, including refactoring scale_by_adam's own inline fix to use it. rms/stddev also needed an explicit downcast after their scaling multiply, since promoting the scaling factor would otherwise silently upcast float16 updates to float32. Verified bit-for-bit identical output on float32/float64 vs. the pre-fix implementation. test_no_nan_on_float16_zero_grad is now parametrized over all 7 affected cases.
|
Pulled the safe-dtype denominator into a shared One addition beyond your repro: the Verified: repro now passes on all 7 cases, bit-for-bit identical Note: |
|
Nice — this is a cleaner home for it than my per-call change. Folding it into |
|
CI's green now all checks passing. |
Summary
Fixes NaN in
scale_by_adamwhen used withfloat16params on a zero-gradient step.Changes
scale_by_adam's update now addseps/eps_rootin at leastfloat32precision (jnp.promote_types(v.dtype, jnp.float32)) before casting the result back to the original dtype.test_adam_no_nan_on_float16_zero_gradregression test (float16 params, 3 steps of all-zero gradients, asserts every leaf stays finite).Why
eps(a plain Python float, default1e-8) is silently rounded to0.0when added to afloat16array, since float16's smallest representable subnormal is~6e-8:On a step with an exactly-zero gradient (masked tokens, a frozen-then-unfrozen layer, etc.), both moment estimates are
0, so thesqrt(v) + epssafety denominator collapses to0, producing0 / 0 = NaN:Since NaN propagates through every subsequent step via
apply_updates, this silently poisons the rest of training. The fix is a no-op forfloat32/float64(verified bit-for-bit identical output before/after); onlyfloat16/bfloat16behavior changes.Fixes #1754.
Scoped to
scale_by_adamonly —scale_by_amsgrad,scale_by_belief, andscale_by_yogishare the same pattern (tracked in the linked issue); happy to follow up separately.Testing
ruff checkis clean on both changed files.