Files
lerobot/tests/configs/test_accelerator_config.py
T
Haoming Song ef88d4e52b feat(train): parallel training framework — FSDP2, HSDP, gradient accumulation, and DCP checkpoints (#4010)
* feat(train): parallel training engine with FSDP2, HSDP, and DCP checkpoints

Replace the FSDP1 training path with a config-owned parallel-training
engine:

- Topology and runtime configs (--parallelism.*, --accelerator.*):
  dp_replicate x dp_shard degrees select single-process, DDP (unchanged
  default), FSDP2, or HSDP; mixed precision, first-class gradient
  accumulation, and FSDP/DDP tuning knobs are mirrored as plain
  dataclasses that build the accelerate objects at runtime, so every
  run is reproducible from its train_config.json alone. Accelerate env
  vars are guarded against configuring the engine behind the config
  system's back.
- Declarative policy surface: policies declare FSDP2 wrap units
  (_fsdp_wrap_modules) and non-forward entry points
  (_fsdp_forward_methods); a shared engine resolves them around
  accelerator.prepare(). Context-parallel fields are reserved and
  validated to 1.
- Checkpoints: selectable --checkpoint_format (safetensors | dcp |
  safetensors_dcp); the sharded optimizer channel is always DCP;
  two-phase resume (step+RNG before prepare, DCP model/optimizer after)
  reshards across GPU-topology changes; lerobot-convert-dcp merges DCP
  shards into a distributable model.safetensors offline.
- Publishing: PreTrainedPolicy.push_model_to_hub is replaced by the
  free publish_trained_model (model + processors + card + train config,
  all-ranks gather with main-rank writes);
  PreTrainedPolicy._save_pretrained gathers state dicts internally,
  removing the state_dict= threading from save_pretrained.
- lerobot_train is restructured around the engine: optimizer built
  before the single prepare() call, deferred weight load on DCP
  resumes, collective save_checkpoint with no call-site rank branches,
  dp-world-size-based sample accounting.

Breaking changes: FSDP checkpoints from lerobot <= 0.6.x are not
resumable (weights stay loadable via from_pretrained; pin
lerobot==0.6.x to finish old runs); the `accelerate launch
--config_file` yaml flow is superseded by the config flags; training
autocast is owned exclusively by --accelerator.mixed_precision
(policy.dtype only casts parameters).

Also fixes: reward-model hub publishing crash (TypeError on extra
kwargs).

Verified by ~200 new CPU tests (config round-trips, checkpoint
round-trips per format, two-phase resume, publisher contracts,
converter equivalence, accelerate canaries), a 5-test 4-GPU suite
(FSDP2 save/resume bit-exactness, HSDP/DDP loss parity,
changed-topology resume, all-ranks save_pretrained, grad-accum
equivalence), and end-to-end ACT (1/4/8 GPUs) + FastWAM 6B
(FSDP2 + HSDP) training runs.
2026-08-06 19:16:41 +08:00

136 lines
5.4 KiB
Python

#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import draccus
import pytest
from lerobot.configs.accelerator import (
AcceleratorConfig,
ActivationCheckpointingConfig,
ActivationCheckpointingMode,
CompileConfig,
DDPConfig,
FSDPConfig,
GradientAccumulationConfig,
)
from lerobot.configs.parallelism import ParallelismConfig
class TestFieldValidation:
def test_wrap_policies_mutually_exclusive(self):
with pytest.raises(ValueError, match="mutually exclusive"):
FSDPConfig(wrap_modules=["Block"], min_num_params=1000)
def test_min_num_params_positive(self):
with pytest.raises(ValueError, match="min_num_params"):
FSDPConfig(min_num_params=0)
def test_mixed_precision_choices(self):
with pytest.raises(ValueError, match="mixed_precision"):
AcceleratorConfig(mixed_precision="tf32")
def test_gradient_accumulation_positive(self):
with pytest.raises(ValueError, match="gradient_accumulation.steps"):
GradientAccumulationConfig(steps=0)
class TestDraccusRoundTrip:
@pytest.mark.parametrize(
"cfg",
[
AcceleratorConfig(),
AcceleratorConfig(
mixed_precision="bf16",
gradient_accumulation=GradientAccumulationConfig(steps=4),
fsdp=FSDPConfig(
reshard_after_forward=False,
wrap_modules=["ACTEncoderLayer", "ACTDecoderLayer"],
cpu_offload=True,
ignored_modules=r".*pos_embed.*",
),
ddp=DDPConfig(find_unused_parameters=False, static_graph=True),
compile=CompileConfig(enabled=True, mode="max-autotune", regional=False),
activation_checkpointing=ActivationCheckpointingConfig(mode=ActivationCheckpointingMode.FULL),
),
AcceleratorConfig(fsdp=FSDPConfig(min_num_params=1_000_000)),
],
)
def test_encode_json_decode_identity(self, cfg):
payload = json.loads(json.dumps(draccus.encode(cfg)))
assert draccus.decode(AcceleratorConfig, payload) == cfg
def test_pre_existing_config_without_fields_gets_defaults(self):
assert draccus.decode(AcceleratorConfig, {}) == AcceleratorConfig()
class TestRuntimeBuilders:
"""The mirrors must translate into real accelerate objects (plugins built lazily)."""
@pytest.fixture(autouse=True)
def _requires_accelerate(self):
pytest.importorskip("accelerate", reason="accelerate is required (install lerobot[training])")
def test_fsdp_plugin_translation(self):
plugin = FSDPConfig(
reshard_after_forward=False, wrap_modules=["MyBlock"], cpu_offload=True
).build_plugin()
assert plugin.fsdp_version == 2
assert plugin.reshard_after_forward is False
assert plugin.transformer_cls_names_to_wrap == ["MyBlock"]
# bools are normalized into torch offload policies by the plugin itself
assert type(plugin.cpu_offload).__name__ == "CPUOffloadPolicy"
# LeRobot never switches state_dict_type: FSDP2's SHARDED default must hold
assert plugin.state_dict_type.name == "SHARDED_STATE_DICT"
assert not plugin.activation_checkpointing
def test_fsdp_plugin_size_based_policy(self):
plugin = FSDPConfig(min_num_params=1024).build_plugin()
assert plugin.min_num_params == 1024
assert plugin.transformer_cls_names_to_wrap is None
def test_ddp_kwargs_translation(self):
handler = DDPConfig(find_unused_parameters=False, gradient_as_bucket_view=True).build_kwargs_handler()
assert handler.find_unused_parameters is False
assert handler.gradient_as_bucket_view is True
def test_gradient_accumulation_plugin_translation(self):
plugin = GradientAccumulationConfig(steps=4).build_plugin()
assert plugin.num_steps == 4
assert plugin.sync_with_dataloader is False
def test_gradient_accumulation_never_syncs_with_dataloader(self, monkeypatch):
"""The loop cycles a finite dataloader, so accelerate's default
sync_with_dataloader=True would force an optimizer step at every dataset epoch
boundary instead of every num_steps micro-batches."""
captured = {}
class FakeAccelerator:
def __init__(self, **kwargs):
captured.update(kwargs)
monkeypatch.setattr("accelerate.Accelerator", FakeAccelerator)
parallelism = ParallelismConfig()
parallelism.resolve(1)
AcceleratorConfig(gradient_accumulation=GradientAccumulationConfig(steps=4)).build(
parallelism, cpu=True
)
ga_plugin = captured["gradient_accumulation_plugin"]
assert ga_plugin.num_steps == 4
assert ga_plugin.sync_with_dataloader is False
assert "gradient_accumulation_steps" not in captured