Skip to content

Commit 8b440df

Browse files
Merge pull request baidu-baige#152 from NeosZhang/fix/clip-grad-get-grad-norm
[vla, train] fix: correct FSDP2 gradient norm and clipping
2 parents dcb293b + 36f13e3 commit 8b440df

1 file changed

Lines changed: 75 additions & 94 deletions

File tree

loongforge/embodied/optimizer/clip_gradients.py

Lines changed: 75 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -3,117 +3,98 @@
33

44
"""Gradient clipping and NaN cleaning."""
55

6-
from collections import defaultdict
7-
86
import torch
7+
import torch.distributed.fsdp as FSDP1
98
import torch.nn as nn
10-
from torch.distributed.fsdp import FSDPModule
11-
import torch.distributed as dist
129
from torch.distributed.tensor import DTensor
1310

1411

15-
def _local_gradient(gradient: torch.Tensor) -> torch.Tensor:
16-
"""Return the local tensor backing an FSDP2 DTensor gradient."""
17-
if isinstance(gradient, DTensor):
18-
return gradient._local_tensor
19-
return gradient
20-
21-
22-
def _local_gradient_groups(model: nn.Module) -> list[list[torch.Tensor]]:
23-
"""Group mutable local gradients by device and dtype."""
24-
# The global L2 norm is the sum of each gradient's squared norm, and one
25-
# clip coefficient scales every gradient. Compatible tensors can therefore
26-
# share foreach kernels without changing the result; grouping by device and
27-
# dtype satisfies foreach constraints while reducing per-tensor launches.
28-
groups = defaultdict(list)
29-
for parameter in model.parameters():
30-
if parameter.grad is not None:
31-
gradient = _local_gradient(parameter.grad)
32-
groups[(gradient.device, gradient.dtype)].append(gradient)
33-
return list(groups.values())
34-
35-
36-
def _local_norm_sq(
37-
gradient_groups: list[list[torch.Tensor]],
38-
device: torch.device,
39-
) -> torch.Tensor:
40-
"""Compute the local squared L2 norm in float32."""
41-
total_norm_sq = torch.zeros((), device=device)
42-
for gradients in gradient_groups:
43-
norms = torch._foreach_norm(gradients, 2.0, dtype=torch.float32)
44-
total_norm_sq += torch.stack(norms).pow(2).sum()
45-
return total_norm_sq
46-
47-
4812
def get_grad_norm(model: nn.Module) -> float:
49-
"""Compute global gradient norm, accounting for FSDP sharding.
50-
51-
For FSDP models, gradients are sharded across ranks. Each rank computes
52-
its local norm squared, then all-reduce sums them to get the global norm.
53-
For non-FSDP models, computes the norm directly.
54-
55-
Args:
56-
model: The model whose gradients to analyze. Can be a vanilla PyTorch
57-
module, FSDP-wrapped module, or module with FSDP sub-modules.
58-
59-
Returns:
60-
The L2 norm of all model gradients (global norm for distributed).
13+
"""Return the total gradient L2 norm.
14+
15+
FSDP1 delegates to ``FSDP.clip_grad_norm_`` with an infinite threshold so
16+
that FSDP aggregates parameter shards over its process group. Although this
17+
does not clip finite gradients, the PyTorch API may still perform an
18+
in-place multiplication by the clamped coefficient.
19+
20+
The non-FSDP1 branch handles FSDP2 and DDP models with ``get_total_norm``.
21+
FSDP2 gradients remain DTensors, allowing the operation to dispatch the
22+
required collectives from their device mesh and placements; DDP gradients
23+
are already synchronized and can be treated as regular tensors. Unwrapped
24+
models use the same path. A DTensor result is materialized as a replicated
25+
scalar before conversion to ``float``.
6126
"""
27+
if isinstance(model, FSDP1.FullyShardedDataParallel):
28+
total_norm = model.clip_grad_norm_(max_norm=float("inf"), norm_type=2.0)
29+
return float(total_norm)
6230

63-
is_fsdp = isinstance(model, FSDPModule)
64-
gradient_groups = _local_gradient_groups(model)
65-
total_norm_sq = _local_norm_sq(
66-
gradient_groups,
67-
next(model.parameters()).device,
68-
)
69-
70-
if is_fsdp and dist.is_initialized():
71-
dist.all_reduce(total_norm_sq, op=dist.ReduceOp.SUM)
72-
return total_norm_sq.sqrt().item()
31+
else:
32+
gradients = [
33+
parameter.grad
34+
for parameter in model.parameters()
35+
if parameter.grad is not None
36+
]
37+
if not gradients:
38+
return 0.0
39+
40+
total_norm = torch.nn.utils.get_total_norm(gradients, norm_type=2.0)
41+
if isinstance(total_norm, DTensor):
42+
total_norm = total_norm.full_tensor()
43+
return float(total_norm)
7344

7445

7546
def clip_gradients(model: nn.Module, max_norm: float) -> float:
76-
"""Gradient clipping for FSDP with mixed-dtype gradients (fp32 + bf16).
77-
78-
FSDP shards parameters across ranks, so each rank only holds a shard of
79-
gradients. We compute local norm in float32, all-reduce to get global norm,
80-
then clip.
81-
82-
Returns:
83-
The global gradient L2 norm computed *before* clipping. Reuse this for
84-
logging instead of recomputing the (post-clip) norm separately.
47+
"""Clip gradients in place and return their pre-clipping total L2 norm.
48+
49+
FSDP1 uses ``FSDP.clip_grad_norm_`` so local parameter shards are aggregated
50+
over the FSDP process group before one global clipping coefficient is
51+
applied. The non-FSDP1 branch handles FSDP2 and DDP models with
52+
``torch.nn.utils.clip_grad_norm_``. FSDP2 derives the required collectives
53+
from each gradient's DTensor mesh and placements, while DDP gradients are
54+
already synchronized before clipping. Unwrapped models use the same path.
8555
"""
8656

87-
is_fsdp = isinstance(model, FSDPModule)
88-
if not is_fsdp:
89-
# clip_grad_norm_ returns the total norm *before* clipping. Under DDP
90-
# grads are already all-reduced, so the local norm equals the global one.
57+
if isinstance(model, FSDP1.FullyShardedDataParallel):
58+
total_norm = model.clip_grad_norm_(max_norm)
59+
return float(total_norm)
60+
else:
9161
total_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)
9262
return float(total_norm)
9363

94-
# Compute local sharded norm in float32 (handles mixed dtype)
95-
gradient_groups = _local_gradient_groups(model)
96-
local_norm_sq = _local_norm_sq(
97-
gradient_groups,
98-
next(model.parameters()).device,
99-
)
100-
101-
# All-reduce to get global norm across all ranks
102-
if dist.is_initialized():
103-
torch.distributed.all_reduce(local_norm_sq)
104-
total_norm = local_norm_sq.sqrt()
105-
106-
clip_coef = max_norm / (total_norm + 1e-6)
107-
clip_coef = torch.clamp(clip_coef, max=1.0)
108-
for gradients in gradient_groups:
109-
torch._foreach_mul_(gradients, clip_coef)
110-
111-
return total_norm.item()
112-
11364

11465
def clean_nan_gradients(model: nn.Module):
115-
"""Replace NaN/Inf gradients with 0."""
66+
"""Replace NaN/Inf gradients with 0.
67+
68+
Args:
69+
model: Model after backward. Gradients are rewritten in place (``out=``),
70+
so DTensor gradients are cleaned through their local shard and no
71+
tensor is reallocated.
72+
73+
Note:
74+
Returns ``None`` and reports nothing: there is no signal about how much was
75+
replaced, which makes this a silent mask over divergence. A run that needs
76+
this every step is broken somewhere upstream (learning rate, loss scaling,
77+
bad batch) and zeroing gradients only postpones the diagnosis.
78+
79+
Both ``+inf`` and ``-inf`` become 0.0 rather than a large finite value —
80+
the intent is to drop the offending contribution, not to clip it.
81+
82+
Purely local, no collectives, so ranks may clean different amounts. That is
83+
consistent for sharded gradients (each rank owns a distinct shard), but it
84+
means a replicated gradient could in principle be cleaned on one rank only;
85+
DDP has already all-reduced by this point, so in practice the ranks see the
86+
same values.
87+
88+
Order matters: cleaning before ``clip_gradients`` keeps a single NaN from
89+
poisoning the whole model through the shared clip coefficient, while
90+
cleaning after leaves the coefficient already NaN and the gradients all
91+
zero.
92+
"""
11693
for param in model.parameters():
11794
if param.grad is not None:
118-
grad = _local_gradient(param.grad)
119-
torch.nan_to_num(grad, nan=0.0, posinf=0.0, neginf=0.0, out=grad)
95+
grad = (
96+
param.grad.to_local()
97+
if isinstance(param.grad, DTensor)
98+
else param.grad
99+
)
100+
torch.nan_to_num(grad, nan=0.0, posinf=0.0, neginf=0.0, out=grad)

0 commit comments

Comments
 (0)