Skip to content

Commit 51c5491

Browse files
committed
[FSDP][Loss] Switch gradient reduction to SUM
Flip gradient reduction from mean to pure SUM, so each parameter receives the sum of per-rank local-component gradients, i.e. the global loss gradient, with no compensating divides. This is the single atomic semantic change; the three cancelling x world_size factors and the reduce divides are removed together. - BaseModel/MoE/Dense.fully_shard call set_gradient_reduce_sum() at the end. - MoE.scale_and_reduce_grad drops the expert div_(ep_size) and the replicated div_(flat_mesh.size()); only the coalesced SUM all_reduce remains. - CE: drop the WORLD autograd all_reduce; the loss stays this rank's local component (display global value restored by the C2 detached pipeline). - Balancing: use local_gating_sum directly instead of all_reduce_autograd; the global detached statistics (tokens_global/seqlen_global/scale_global) are kept unchanged. - Z-loss: drop the x world_size in the global-average branch. Verified on torch 2.10 (bf16 force-sum): distributed full gradient reproduces a single-process full-batch token-mean CE reference at EP=1 and EP=2 (norm-ratio median 0.9998, EP-invariant); global display loss unchanged through the flip; balancing+z backward stays finite. Regression tests cover the bf16 reduce-sum mechanism, token-mean parity with grad-acc=2, aux-loss finite gradients, and an isolated fp32 balancing-only gate-gradient A/B (new local+SUM vs old all_reduce_autograd+AVG) asserting element-wise equality.
1 parent a13bca8 commit 51c5491

8 files changed

Lines changed: 443 additions & 32 deletions

File tree

tests/model/test_reduce_sum_grad.py

Lines changed: 383 additions & 2 deletions
Large diffs are not rendered by default.

xtuner/v1/loss/ce_loss.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import torch.nn.functional as F
77
from cyclopts import Parameter
88
from torch.distributed.device_mesh import DeviceMesh
9-
from torch.distributed.nn.functional import all_reduce
109

1110
from xtuner.v1.utils.device import get_device
1211

@@ -282,10 +281,10 @@ def forward(
282281

283282
extra_info["local_base_loss"] = loss.detach().clone()
284283

285-
# Step 2.c in the loss calculation: reduce the loss over all ranks using all_reduce with autograd support
286-
if dist.is_initialized():
287-
loss = all_reduce(loss, op=dist.ReduceOp.SUM, group=dist.group.WORLD)
288-
284+
# Under reduce-sum gradients the loss stays as this rank's local component (local token sum
285+
# over the global token denominator). Cross-rank aggregation happens on the gradients via the
286+
# FSDP SUM reduce-scatter, so no autograd WORLD all_reduce is injected here. The global loss
287+
# scalar for logging is restored separately by the detached display pipeline (§5.4).
289288
return loss, (logits, extra_info)
290289

291290

xtuner/v1/loss/moe_loss.py

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -155,22 +155,18 @@ def finalize(
155155
alpha = self.loss_cfg.balancing_loss_alpha
156156

157157
if self.loss_cfg.balancing_loss_global_average and dist.is_initialized():
158-
group = dist.group.WORLD
159-
assert group is not None
160158
tokens_global = tokens_per_expert_global.sum(-1)
161159
seqlen_global = tokens_global // num_experts_per_tok
162160
scale_global = n_routed_experts / tokens_global
163161

164-
routing_weights_sum_global = all_reduce_autograd(local_gating_sum, "sum", group)
165-
routing_weights_mean_global = routing_weights_sum_global / seqlen_global.unsqueeze(-1)
166-
loss_vec = scale_global * (tokens_per_expert_global * routing_weights_mean_global).sum(-1)
167-
168-
# Detached local component: same global denominators, but this rank's own gating sum in
169-
# place of the cross-rank sum. Because `all_reduce_autograd` is a plain SUM and every
170-
# other factor here (scale_global, seqlen_global, tokens_per_expert_global) is detached
171-
# and global, summing `local_vec` over ranks reproduces `loss_vec` exactly.
172-
routing_weights_mean_local = local_gating_sum.detach() / seqlen_global.unsqueeze(-1)
173-
local_vec = scale_global * (tokens_per_expert_global * routing_weights_mean_local).sum(-1)
162+
# Under reduce-sum gradients the loss stays as this rank's local component: use this
163+
# rank's own gating sum with the global denominators, without any cross-rank all_reduce.
164+
# Cross-rank aggregation happens on the gradients (FSDP / scale_and_reduce_grad SUM);
165+
# since every other factor here is detached and global, summing the loss over ranks
166+
# reproduces the global balancing loss.
167+
routing_weights_mean = local_gating_sum / seqlen_global.unsqueeze(-1)
168+
loss_vec = scale_global * (tokens_per_expert_global * routing_weights_mean).sum(-1)
169+
local_vec = loss_vec.detach()
174170
else:
175171
valid_tokens = max(non_pad_token, 1)
176172
scale_global = n_routed_experts / (valid_tokens * num_experts_per_tok)
@@ -298,20 +294,18 @@ def accumulate(
298294
denom_local = max(num_tokens_local, 1)
299295
base = torch.logsumexp(router_logits, dim=-1).square().sum() / denom_local
300296

301-
local_loss = base
302297
loss = base
303298
if self.loss_cfg.z_loss_global_average and num_tokens_global is not None:
304-
# Local component: this rank's share of the global-average z-loss, without the
305-
# `× world_size` factor. The backward path keeps `× world_size` (removed in the
306-
# reduce-sum switch); summing `local_loss` over ranks reproduces the global z-loss.
299+
# Under reduce-sum gradients the injected z-loss stays as this rank's local component
300+
# (its share of the global-average z-loss, WITHOUT any `× world_size`). Cross-rank
301+
# aggregation happens on the gradients via the FSDP SUM reduce-scatter; summing this
302+
# local component over ranks reproduces the global z-loss.
307303
denom_global = torch.clamp(num_tokens_global, min=1)
308-
local_loss = base * num_tokens_local / denom_global
309-
loss = local_loss * world_size
304+
loss = base * num_tokens_local / denom_global
310305

311-
local_loss = local_loss * self.loss_cfg.z_loss_alpha / self._batch_size
312306
loss = loss * self.loss_cfg.z_loss_alpha / self._batch_size
313307
self._update_running(loss.detach())
314-
self._update_local_running(local_loss.detach())
308+
self._update_local_running(loss.detach())
315309
return loss
316310

317311
def finalize(self) -> tuple[torch.Tensor, torch.Tensor]:

xtuner/v1/model/base.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,10 @@ def fully_shard(
676676
reshard_after_forward=fsdp_config.reshard_after_forward,
677677
offload_policy=CPUOffloadPolicy() if self.fsdp_config.cpu_offload else None,
678678
)
679+
# Reduce-scatter gradients with pure SUM (no divide). Combined with the loss forwards no
680+
# longer injecting x world_size, each param's gradient is the sum of per-rank local-component
681+
# gradients, i.e. the global loss gradient. Covers nested/child FSDP modules via self.modules().
682+
self.set_gradient_reduce_sum()
679683
return self
680684

681685
def _fully_shard(
@@ -1388,6 +1392,21 @@ def post_micro_batch_forward(self, batch_outputs: Sequence[ModelOutputs]) -> Bat
13881392
if "reduced_base_loss" in reduced_other_losses:
13891393
reduced_other_losses["reduced_llm_loss"] = reduced_other_losses.pop("reduced_base_loss")
13901394

1395+
# Safety net: every `*loss` tensor field an output exposes must have produced a display curve
1396+
# from a registered local component. Otherwise a newly added loss term would silently vanish
1397+
# from the logged curves while still driving backward, hiding the regression.
1398+
for output in batch_outputs:
1399+
for name in output.model_fields:
1400+
field_value = getattr(output, name, None)
1401+
if "loss" in name and isinstance(field_value, torch.Tensor):
1402+
expected = "reduced_llm_loss" if name == "loss" else f"reduced_{name}"
1403+
if expected not in reduced_other_losses:
1404+
raise RuntimeError(
1405+
f"Loss field '{name}' has no display curve ('{expected}' missing from reduced "
1406+
f"logs): register its detached local component on extra_info (see "
1407+
f"`_store_local_display_losses` / CE's `local_base_loss`)."
1408+
)
1409+
13911410
ret = BatchForwardInfo(
13921411
logs_info=reduced_other_losses,
13931412
extra_info=train_engine_extra_info,

xtuner/v1/model/compose/base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ def fully_shard(
138138
self.language_model.set_modules_to_forward_prefetch([self.language_model.layers["0"]]) # type: ignore
139139

140140
self._to_empty_meta()
141+
# Reduce-scatter gradients with pure SUM for every sharded submodule. The vision tower,
142+
# projector, and this compose root are sharded by their own fully_shard overrides / the root
143+
# wrap above, none of which set reduce-sum; a single root-level pass over self.modules()
144+
# covers them all (and is idempotent for the language model, already set). Without this the
145+
# vision/projector grads silently fall back to FSDP AVG and lose a 1/fsdp_size factor.
146+
self.set_gradient_reduce_sum()
141147
return self
142148

143149
def from_hf(self, hf_path: str | Path, strict=True):

xtuner/v1/model/compose/intern_s1/modeling_intern_s1.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ def fully_shard(
9898
self.language_model.set_modules_to_forward_prefetch([self.language_model.layers["0"]]) # type: ignore
9999

100100
self._to_empty_meta()
101+
# Reduce-scatter gradients with pure SUM for every sharded submodule (vision tower, projector,
102+
# compose root); their own fully_shard overrides do not set reduce-sum. One root-level pass
103+
# over self.modules() covers them all (idempotent for the already-set language model).
104+
self.set_gradient_reduce_sum()
101105
return self
102106

103107
def extract_feature(self, pixel_values):

xtuner/v1/model/dense/dense.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,10 @@ def fully_shard(
300300
# Make sure it works properly when using fsdp
301301
if self.config.tie_word_embeddings:
302302
self.lm_head.weight = self.embed_tokens.weight
303+
# Reduce-scatter gradients with pure SUM (no divide) for every sharded submodule; combined
304+
# with the loss forwards no longer injecting x world_size, this yields the global loss
305+
# gradient. See BaseModel.set_gradient_reduce_sum.
306+
self.set_gradient_reduce_sum()
303307
return self
304308

305309
# TODO: 支持 tp

xtuner/v1/model/moe/moe.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1193,6 +1193,10 @@ def fully_shard(
11931193
module.forward = types.MethodType(self.patched_emb_forward, module) # type: ignore
11941194

11951195
self._to_empty_meta()
1196+
# Reduce-scatter gradients with pure SUM (no divide) for every sharded submodule; the
1197+
# expert / replicated grads not covered by reduce-scatter are handled without division in
1198+
# scale_and_reduce_grad. See BaseModel.set_gradient_reduce_sum.
1199+
self.set_gradient_reduce_sum()
11961200
return self
11971201

11981202
@property
@@ -1222,10 +1226,10 @@ def scale_and_reduce_grad(self):
12221226
if param.grad is None:
12231227
continue
12241228

1225-
# Expert parameters live on a unique EP rank, so no cross-rank reduction
1226-
# is needed — just rescale by `ep_size` to keep the effective average.
1229+
# Expert parameters live on a unique EP rank; their FSDP sharding is only over the
1230+
# experts_fsdp sub-dim, already SUM-reduced by reduce-scatter. No cross-rank reduction
1231+
# and no rescaling: under reduce-sum the local-component gradient is what we keep.
12271232
if ep_enabled and ".experts" in name:
1228-
param.grad.div_(self.ep_mesh.size()) # type: ignore
12291233
continue
12301234

12311235
if not isinstance(param, DTensor):
@@ -1252,8 +1256,8 @@ def scale_and_reduce_grad(self):
12521256
flat_mesh = param.device_mesh[replicate_dim_names[0]]
12531257

12541258
grad = param.grad.to_local() if isinstance(param.grad, DTensor) else param.grad
1255-
# Pre-scale locally so the SUM all_reduce below yields the mean across replicas.
1256-
grad.div_(flat_mesh.size()) # type: ignore
1259+
# Replicated params get no reduce-scatter; SUM their per-rank local-component grads
1260+
# across the replicate group with NO pre-divide, matching the reduce-sum invariant.
12571261
grads_by_group.setdefault(flat_mesh.get_group(), []).append(grad) # type: ignore
12581262

12591263
# One coalesced all_reduce per process group covers all replicated grads.

0 commit comments

Comments
 (0)