Fix clip_by_global_norm producing NaN when the global norm and max_norm are both zero - #1752
Conversation
|
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. |
|
@googlebot I signed it! |
|
For reviewers, on the two red checks:
|
|
@googlebot I signed it! |
The trigger uses a strict comparison, so at equality the clip branch is
selected and computes (t / g_norm) * max_norm. With all-zero updates and
max_norm=0 that is (0 / 0) * 0 = NaN for every leaf:
clipper = optax.clip_by_global_norm(0.0)
updates, _ = clipper.update(jax.tree.map(jnp.zeros_like, grads), state)
# every leaf is NaN
Zero gradients are common (frozen parameters, saturated units), and a
max_norm of exactly zero is reachable when the norm is driven by a
schedule through inject_hyperparams, so the combination NaN-poisons an
update mid-training rather than raising anywhere visible.
Use a non-strict comparison instead. Clipping updates to exactly their
own norm is the identity, so taking the pass-through branch at equality
is mathematically unchanged (and exact, where the clip branch is only
exact up to rounding) while avoiding the 0/0.
unitwise_clip in the same module already guards its division with
div_eps for exactly this reason; clip_by_global_norm was the remaining
unguarded one.
f301531 to
e897c05
Compare
|
Update — the CLA is now signed and green (the earlier failure was a stale check from before signing; re-pushing the commit re-ran it). Current state on
Everything this PR does touch is green, including the full test matrix: Happy to rebase if |
What
clip_by_global_normselects between pass-through and clipping with a strict comparison:At
g_norm == max_normthe clip branch is selected. When both are zero, that branch computes(0 / 0) * 0and every leaf of the update tree becomes NaN:The neighbouring cases are all fine — zero grads under a positive
max_normpass through as zeros, and real grads undermax_norm=0clip to zeros — so this is exactly the equality corner.Why it matters
Zero gradients are ordinary (frozen parameters, saturated units), and
max_norm == 0.0is reachable without anyone writing a literal zero: a schedule-drivenmax_normthroughinject_hyperparamsthat decays to zero hits this the moment gradients are zero. The failure mode is silent NaN-poisoning of an update mid-training rather than an error anywhere visible.Fix
Make the comparison non-strict (
<=). Clipping updates to exactly their own norm is the identity, so taking the pass-through branch at equality is mathematically unchanged — and strictly better numerically, since the pass-through branch returnstexactly while the clip branch only recoverstup to rounding from(t / g_norm) * max_norm.For precedent within the same module:
unitwise_clipalready guards its division withdiv_eps("just prevents division by zero");clip_by_global_normwas the remaining unguarded division.I kept the existing
lax.selectstructure untouched (the TODO about backprop-through-update / meta-gradients), sinceselect's VJP routes cotangents through the selected branch only — at equality the derivative now comes from the identity branch, which is the mathematically sensible one-sided choice.Tests
test_clip_by_global_norm_zero_norm_zero_max_norm— fails onmain(NaN), passes with the fix.test_clip_by_global_norm_at_equality_is_identity— pins the boundary semantics.