Raise the per-tensor norms to norm_type when combining them - #8313
Raise the per-tensor norms to norm_type when combining them#8313vineethsaivs wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f05a068e1a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # The p-norm over every gradient is (sum_i ||g_i||_p ** p) ** (1/p), and the | ||
| # 1/norm_type root is taken below, so each per-parameter norm has to be raised | ||
| # to norm_type here. Squaring only matches that for norm_type == 2. | ||
| total_norm = torch.stack(all_norms).pow(norm_type).sum().float() |
There was a problem hiding this comment.
Add the required Signed-off-by trailer
This reviewed commit is non-merge (single parent 84fd92a) and its commit message has no Signed-off-by trailer. DeepSpeed's commit requirements apply to every non-merge commit, so this needs to be recreated with --signoff or an equivalent trailer before it can satisfy the repo policy.
AGENTS.md reference: AGENTS.md:L6-L8
Useful? React with 👍 / 👎.
The p-norm over a set of tensors is (sum_i ||g_i||_p ** p) ** (1/p), so combining
per-tensor norms means raising each to norm_type. Three sites take the
1/norm_type root but hardcode the exponent at 2, so they are only correct for
norm_type == 2:
runtime/utils.py clip_grad_norm_ .square().sum()
zero/stage_1_and_2.py get_grad_norm_direct .square().sum()
zero/stage3.py get_grad_norm_direct .norm(2), then pow(..., 2)
stage3 is doubly wrong: it takes an L2 norm per tensor whatever norm_type says,
then sums the squares, then takes the 1/norm_type root.
Measured against the p-norm of the concatenated gradients, for grads [3, -4]
and [2]:
p=1 p=2 p=3
truth 9.000 5.385 4.626
clip_grad_ 53.000 5.385 2.894
zero 1/2 53.000 5.385 2.894
zero 3 29.000 5.385 3.072
The returned norm is wrong, and so is the clip coefficient derived from it, so
the gradients get scaled by the wrong factor.
clip_grad_norm_ is a regression from deepspeedai#4915, which vectorized the accumulation:
the loop there read `total_norm += param_norm.item()**norm_type` before it, and
the rewrite replaced that with `.square()` while keeping the 1/norm_type root.
The four norm-combining sites that were not touched by that commit all still
raise to norm_type: get_flattened_grad_norm, get_weight_norm,
get_global_norm_of_tensors and get_norm_with_moe_layers.
norm_type is float()'d in every one of these functions, so the default path
becomes pow(2.0), which is bit-identical to square() on float32.
Signed-off-by: Vineeth Sai <vineethsai4444@gmail.com>
f05a068 to
f10cb80
Compare
|
Widened this after finding the same defect in two more places, so it is now one rule fixed at all three sites rather than one of three.
Measured against the p-norm of the concatenated gradients, on grads All three are reachable only through the Happy to split the ZeRO half back out into its own PR if you would rather review them separately. |
ebarkhordar
left a comment
There was a problem hiding this comment.
Reachability check on norm_type at f10cb80, parsed rather than grepped since the severity claim rests on it: nothing inside DeepSpeed calls the three functions you are fixing with anything but the default 2.
clip_grad_norm_ engine.py:3326 (parameters, max_norm, mpu)
clip_grad_norm_ engine.py:3339 (parameters, max_norm, mpu)
get_grad_norm_direct stage_1_and_2.py:2247 (2 positional args)
get_grad_norm_direct stage3.py:2397 (2 positional args)
It is not a config key either: absent from runtime/config.py, constants.py and config_utils.py. Both fp16 optimizers pin self.norm_type = 2, and BF16_Optimizer's norm_type=2 default is not passed at engine.py:2347. The threaded self.norm_type reaches only get_global_norm_of_tensors and get_norm_with_moe_layers, already in your correct list.
So p=2 is the only column of your table a run reaches today, and there pow(2.0) and .square() agree. The change still looks right to me, and stage3 taking a per-tensor L2 is a real defect for direct callers of runtime/utils.clip_grad_norm_, which include code outside this repo. But "the clip coefficient is roughly 6x too small and the gradients are crushed" reads as a live training bug when no config reaches it. Scoping that to direct callers would make the PR harder to argue with.
Adjacent, not a request: complete_grad_norm_calculation_for_cpu_offload (stage_1_and_2.py:1608, stage3.py:1857) takes the same root over squares but sets norm_type = 2.0 as a local, so it is self consistent L2. If the goal is that ZeRO honors norm_type, that is where it stops.
I have no GPU here and did not reproduce your numbers.
|
Your reachability audit matches mine and the criticism of the wording is fair, so I have rewritten that part of the description. "the clip coefficient is roughly 6x too small and the gradients are crushed" was accurate about the arithmetic and misleading about the blast radius. The body now says up front that no DeepSpeed config reaches this: On You are also right that this is where "ZeRO honors No GPU needed for the numbers, in case it is useful: |
|
Same false positive as before, now re-fired against The DCO check on this PR passes, which measures the same requirement independently. No change needed. |
|
The rewritten scope section says what I measured, and calling out the two |
Problem
The p-norm over a set of tensors is
so combining per-tensor norms means raising each one to
norm_type. Three functions take the1/norm_typeroot but hardcode the exponent at 2, so they are correct only whennorm_type == 2:runtime/utils.pyclip_grad_norm_torch.stack(all_norms).square().sum()runtime/zero/stage_1_and_2.pyget_grad_norm_directtorch.stack(all_norms).square().sum()runtime/zero/stage3.pyget_grad_norm_direct.norm(2)per tensor, thentorch.pow(..., 2)stage3is wrong twice over: it takes an L2 norm per tensor whatevernorm_typesays, sums the squares, and then takes the1/norm_typeroot, so it never computes a p-norm at all.norm_typeis a documented argument on all three ("type of the used p-norm"). Measured against the p-norm of the concatenated gradients, on grads[3, -4]and[2]:clip_grad_norm_At p=1
clip_grad_norm_reports7**2 + 2**2 = 53where the answer is7 + 2 = 9; at p=3 all three under-clip. The returned norm is wrong and so are the resulting gradients, not just the reported number.Scope, stated up front rather than buried. No DeepSpeed config reaches this.
norm_typeis not a config key (absent fromruntime/config.py,constants.pyandconfig_utils.py), and every in-tree call site passes the default 2:engine.pyL3326 and L3339 forclip_grad_norm_,stage_1_and_2.pyL2245 andstage3.pyL2395 forget_grad_norm_direct. Both fp16 optimizers pinself.norm_type = 2, andBF16_Optimizer'snorm_typedefault is not overridden; where a threadedself.norm_typedoes reach a norm helper it lands onget_global_norm_of_tensorsandget_norm_with_moe_layers, which are already correct.So p=2 is the only column a DeepSpeed run reaches today, and there
pow(2.0)and.square()agree exactly. What this fixes is the documentednorm_typeargument for direct callers ofruntime/utils.clip_grad_norm_and the twoget_grad_norm_directmethods, including code outside this repo, and it removes a defect that would become live the moment anything threads a non-defaultnorm_typethrough.Where it came from
clip_grad_norm_is a regression from #4915, which vectorized the accumulation. Before that commit the loop readand the rewrite replaced the
**norm_typewith.square()while keeping.pow(1. / norm_type)below. The norm-combining sites that commit did not touch all still raise tonorm_type, which is what makes the intended rule unambiguous rather than a matter of taste:get_flattened_grad_norm(L483),get_weight_norm(L579),get_global_norm_of_tensors(L927) andget_norm_with_moe_layers(L1144).scaled_global_norminstage_1_and_2.pytakes the other honest route and assertsnorm_type == 2outright.Fix
Follow
norm_typeat each of the three sites. Instage3that means the per-tensor.norm()as well as the exponent.norm_typeisfloat()-ed in all three functions, so the default becomespow(2.0), which is bit-identical tosquare()on float32; checked over 20000 random vectors spanning a wide dynamic range, zero differing elements. Combined with the call-site audit above, the default path is provably unchanged.Deliberately not touched:
complete_grad_norm_calculation_for_cpu_offloadin both ZeRO files takes the same1/norm_typeroot over squares, but it accepts nonorm_typeargument and setsnorm_type = 2.0as a local, so it is self-consistent L2 and correct as written. A repo-wide sweep finds nine sites taking a1/norm_typeroot: four already correct, those two self-consistent, and the three fixed here.Test
TestClipGradNormPNorm::test_matches_torchintests/unit/runtime/test_runtime_utils.py, parametrized overnorm_typein1, 2, 3againsttorch.nn.utils.clip_grad_norm_, asserting both the returned norm and the resulting gradients.TestZeroGradNormPNorm::test_matches_flat_normintests/unit/runtime/zero/test_zero_grad_clip.py, parametrized overzero_stagein1, 2, 3andnorm_typein1, 2, 3, assertingget_grad_norm_directequals the p-norm of the concatenated gradients on a single rank.norm_type=2is the control throughout: it passes on both sides.4 + 12 new = 16, so the net difference is exactly the twelve new cases and nothing else moved. The 6 skips are pre-existing and identical throughout.
Both new classes use
world_size = 1so they run wherever the suite runs; the defect is in the per-rank recombination and is independent of group size.yapf --style .style.yapf -dandflake8 --config .flake8are clean on all five files, with nothing reported on the clean-tree copies either.