Skip to content

Commit 8833c91

Browse files
3outeilleSunMarc
andauthored
[distributed] Trainer: reduce FSDP/HSDP gradients on the last micro-batch only (#50286)
* Reduce the gradients of FSDP/HSDP-sharded models on the last micro-batch only The Trainer runs the non-last micro-batches of a gradient accumulation window under `accelerator.no_sync`, which does nothing for a model sharded at load time by `DistributedConfig`: Accelerate did not wrap it, so it reduced its gradients on every micro-batch. Defer the FSDP2 reduction to the last micro-batch instead. With HSDP, only the all-reduce across replicas waits (`set_requires_all_reduce`), so the gradients stay sharded. With FSDP, the reduce-scatter waits too (`set_requires_gradient_sync`), like Accelerate's FSDP2 `no_sync`. * Update tests/trainer/distributed/scripts/grad_accum_distributed_config.py Co-authored-by: Marc Sun <57196510+SunMarc@users.noreply.github.com> * Use Trainer.end() and drop the redundant device binding in the grad accumulation test --------- Co-authored-by: Marc Sun <57196510+SunMarc@users.noreply.github.com>
1 parent bbe484b commit 8833c91

3 files changed

Lines changed: 107 additions & 1 deletion

File tree

‎src/transformers/trainer.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
from .configuration_utils import PreTrainedConfig
5757
from .data.data_collator import DataCollator, DataCollatorWithPadding, default_data_collator
5858
from .debug_utils import DebugOption, DebugUnderflowOverflow
59-
from .distributed.fsdp import get_fsdp_ckpt_kwargs, update_fsdp_plugin_peft
59+
from .distributed.fsdp import get_fsdp_ckpt_kwargs, is_fsdp_managed_module, update_fsdp_plugin_peft
6060
from .feature_extraction_sequence_utils import SequenceFeatureExtractor
6161
from .feature_extraction_utils import FeatureExtractionMixin
6262
from .hyperparameter_search import ALL_HYPERPARAMETER_SEARCH_BACKENDS, default_hp_search_backend
@@ -1900,6 +1900,9 @@ def _run_epoch(
19001900
or i == len(batch_samples) - 1
19011901
):
19021902
sync_context = contextlib.nullcontext
1903+
elif self.is_distributed_loading_by_transformers and is_fsdp_managed_module(self.model):
1904+
# Accelerate did not wrap a model sharded at load time, so its `no_sync` does not apply
1905+
sync_context = self._defer_fsdp_gradient_reduction
19031906
else:
19041907
sync_context = functools.partial(self.accelerator.no_sync, model=model)
19051908
with sync_context():
@@ -2603,6 +2606,21 @@ def get_tp_size(self) -> int:
26032606
# 4. Default fallback
26042607
return 1
26052608

2609+
@contextlib.contextmanager
2610+
def _defer_fsdp_gradient_reduction(self):
2611+
"""
2612+
`no_sync` for a model sharded at load time with FSDP2. With HSDP, only the all-reduce across replicas waits for
2613+
the last micro-batch: the reduce-scatter inside each replica keeps the gradients sharded.
2614+
"""
2615+
if self.model.config.distributed_config.dp_replicate_size > 1:
2616+
self.model.set_requires_all_reduce(False)
2617+
else:
2618+
self.model.set_requires_gradient_sync(False)
2619+
try:
2620+
yield
2621+
finally:
2622+
self.model.set_requires_gradient_sync(True)
2623+
26062624
def _sync_replicated_trainable_parameters(self, model: nn.Module) -> None:
26072625
"""
26082626
Keep the trainable parameters that FSDP2 does not manage identical across ranks.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Copyright 2026 The HuggingFace Team. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Worker script: with gradient accumulation, the gradients of a model sharded at load time by `DistributedConfig` are
17+
only reduced on the last micro-batch. FSDP2 only sets the sharded `.grad` of a parameter once it reduced it.
18+
19+
Launched via ``torchrun`` from ``test_trainer_distributed_transformers.py``.
20+
"""
21+
22+
import argparse
23+
from datetime import timedelta
24+
25+
import torch.distributed as dist
26+
from datasets import Dataset
27+
28+
from transformers import AutoModelForCausalLM, Trainer, TrainerCallback, TrainingArguments
29+
from transformers.distributed import DistributedConfig
30+
from transformers.distributed.utils import get_distributed_backend
31+
from transformers.testing_utils import torch_device
32+
33+
34+
class GradientReductionCallback(TrainerCallback):
35+
def __init__(self):
36+
self.reduced = []
37+
38+
def on_substep_end(self, args, state, control, model=None, **kwargs):
39+
self.reduced.append(any(p.grad is not None for p in model.parameters()))
40+
41+
def on_pre_optimizer_step(self, args, state, control, model=None, **kwargs):
42+
self.reduced.append(all(p.grad is not None for p in model.parameters()))
43+
44+
45+
def main():
46+
parser = argparse.ArgumentParser()
47+
parser.add_argument("--output_dir", required=True)
48+
parser.add_argument("--dp_replicate_size", type=int, default=1)
49+
script_args = parser.parse_args()
50+
51+
dist.init_process_group(backend=get_distributed_backend(torch_device), timeout=timedelta(seconds=60))
52+
distributed_config = DistributedConfig(
53+
dp_replicate_size=script_args.dp_replicate_size,
54+
fsdp_size=dist.get_world_size() // script_args.dp_replicate_size,
55+
)
56+
model = AutoModelForCausalLM.from_pretrained(
57+
"trl-internal-testing/tiny-Qwen3ForCausalLM", distributed_config=distributed_config
58+
)
59+
input_ids = [[i + 10] * 8 for i in range(32)]
60+
args = TrainingArguments(
61+
output_dir=script_args.output_dir,
62+
max_steps=2,
63+
per_device_train_batch_size=1,
64+
gradient_accumulation_steps=4,
65+
save_strategy="no",
66+
)
67+
callback = GradientReductionCallback()
68+
dataset = Dataset.from_dict({"input_ids": input_ids, "labels": input_ids})
69+
trainer = Trainer(model=model, args=args, train_dataset=dataset, callbacks=[callback])
70+
trainer.train()
71+
72+
# 3 micro-batches without reduction, then the reduced gradients of the optimizer step, twice
73+
expected = [False, False, False, True] * 2
74+
assert callback.reduced == expected, f"rank {dist.get_rank()}: {callback.reduced} != {expected}"
75+
trainer.end()
76+
77+
78+
if __name__ == "__main__":
79+
main()

‎tests/trainer/distributed/test_trainer_distributed_transformers.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
import os
2020

21+
from parameterized import parameterized
22+
2123
from transformers.testing_utils import (
2224
TestCasePlus,
2325
execute_subprocess_async,
@@ -31,6 +33,7 @@
3133

3234

3335
PEFT_SAVE_SCRIPT = os.path.join(SCRIPTS_DIR, "peft_save_distributed_config.py")
36+
GRAD_ACCUM_SCRIPT = os.path.join(SCRIPTS_DIR, "grad_accum_distributed_config.py")
3437

3538

3639
@require_torch_greater_or_equal("2.7")
@@ -73,3 +76,9 @@ def test_peft_save_trained_bias(self):
7376
],
7477
)
7578
execute_subprocess_async(cmd, env=self.get_env())
79+
80+
@parameterized.expand([("fsdp", 1), ("hsdp", 2)])
81+
def test_grad_accumulation_reduces_on_last_micro_batch(self, _, dp_replicate_size):
82+
"""Gradients of a model sharded at load time are only reduced on the last micro-batch."""
83+
script_args = ["--output_dir", self.get_auto_remove_tmp_dir(), "--dp_replicate_size", str(dp_replicate_size)]
84+
execute_subprocess_async(self.get_torchrun_cmd(GRAD_ACCUM_SCRIPT, script_args), env=self.get_env())

0 commit comments

Comments
 (0)