Files
lerobot/tests/utils/test_train_utils.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

183 lines
7.2 KiB
Python

#!/usr/bin/env python
# Copyright 2025 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.
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from lerobot.common.train_utils import (
get_step_checkpoint_dir,
get_step_identifier,
load_training_metadata,
push_checkpoint_to_hub,
save_training_metadata,
save_training_state,
should_save_checkpoint,
update_last_checkpoint,
)
from lerobot.configs.default import DatasetConfig
from lerobot.configs.train import TrainPipelineConfig
from lerobot.utils.constants import (
CHECKPOINTS_DIR,
LAST_CHECKPOINT_LINK,
OPTIMIZER_PARAM_GROUPS,
OPTIMIZER_STATE,
RNG_STATE,
SCHEDULER_STATE,
TRAINING_STATE_DIR,
TRAINING_STEP,
)
def test_get_step_identifier():
assert get_step_identifier(5, 1000) == "000005"
assert get_step_identifier(123, 100_000) == "000123"
assert get_step_identifier(456789, 1_000_000) == "0456789"
def test_should_save_checkpoint():
# Periodic checkpoints land on multiples of save_freq.
assert should_save_checkpoint(10, save_freq=10, total_steps=100) is True
assert should_save_checkpoint(5, save_freq=10, total_steps=100) is False
# The final step always saves, even when it is not a multiple of save_freq.
assert should_save_checkpoint(100, save_freq=30, total_steps=100) is True
# save_freq <= 0 disables periodic saving without raising ZeroDivisionError.
assert should_save_checkpoint(1, save_freq=0, total_steps=100) is False
assert should_save_checkpoint(100, save_freq=0, total_steps=100) is True
assert should_save_checkpoint(1, save_freq=-1, total_steps=100) is False
def test_get_step_checkpoint_dir():
output_dir = Path("/checkpoints")
step_dir = get_step_checkpoint_dir(output_dir, 1000, 5)
assert step_dir == output_dir / CHECKPOINTS_DIR / "000005"
def make_cfg(batch_size: int = 32) -> TrainPipelineConfig:
cfg = TrainPipelineConfig(dataset=DatasetConfig(repo_id="lerobot/dummy"), batch_size=batch_size)
cfg.parallelism.resolve(1)
return cfg
def test_save_training_metadata_writes_the_step_file(tmp_path):
save_training_metadata(5000, tmp_path, make_cfg())
assert (tmp_path / TRAINING_STEP).is_file()
def test_save_training_state_records_topology(tmp_path, optimizer, scheduler):
save_training_state(tmp_path, 10, make_cfg(batch_size=32), optimizer, scheduler)
metadata = load_training_metadata(tmp_path / TRAINING_STATE_DIR)
assert metadata["step"] == 10
assert metadata["dp_world_size"] == 1
assert metadata["batch_size"] == 32
def test_update_last_checkpoint(tmp_path):
checkpoint = tmp_path / "0005"
checkpoint.mkdir()
update_last_checkpoint(checkpoint)
last_checkpoint = tmp_path / LAST_CHECKPOINT_LINK
assert last_checkpoint.is_symlink()
assert last_checkpoint.resolve() == checkpoint
# save_checkpoint round-trips (all formats, real policies) live in
# tests/common/test_checkpoint_save_resume.py.
def test_save_training_state_layout(tmp_path, optimizer, scheduler):
save_training_state(tmp_path, 10, make_cfg(), optimizer, scheduler)
assert (tmp_path / TRAINING_STATE_DIR).is_dir()
assert (tmp_path / TRAINING_STATE_DIR / TRAINING_STEP).is_file()
assert (tmp_path / TRAINING_STATE_DIR / RNG_STATE).is_file()
assert (tmp_path / TRAINING_STATE_DIR / OPTIMIZER_STATE).is_file()
assert (tmp_path / TRAINING_STATE_DIR / OPTIMIZER_PARAM_GROUPS).is_file()
assert (tmp_path / TRAINING_STATE_DIR / SCHEDULER_STATE).is_file()
# The two-phase resume (resume_before_prepare / resume_after_prepare) is covered in
# tests/common/test_checkpoint_save_resume.py with real policies and optimizer state.
def test_push_checkpoint_to_hub_creates_repo_and_uploads(tmp_path, monkeypatch):
ckpt = tmp_path / "010000"
(ckpt / "pretrained_model").mkdir(parents=True)
api = MagicMock()
monkeypatch.setattr("lerobot.common.train_utils.HfApi", lambda *a, **k: api)
push_checkpoint_to_hub(ckpt, "user/run", private=True)
api.create_repo.assert_called_once()
assert api.create_repo.call_args.kwargs["private"] is True
assert api.create_repo.call_args.kwargs["repo_type"] == "model"
api.upload_folder.assert_called_once()
kwargs = api.upload_folder.call_args.kwargs
assert kwargs["repo_id"] == "user/run"
assert kwargs["repo_type"] == "model"
assert kwargs["path_in_repo"] == "checkpoints/010000"
assert kwargs["folder_path"] == str(ckpt)
assert kwargs["commit_message"] == "checkpoint 010000"
# A tag named after the checkpoint step is created so the checkpoint can be
# recovered with --policy.pretrained_revision instead of a commit sha.
api.create_tag.assert_called_once()
tag_kwargs = api.create_tag.call_args.kwargs
assert tag_kwargs["tag"] == "010000"
assert tag_kwargs["revision"] == api.upload_folder.return_value.oid
assert tag_kwargs["repo_type"] == "model"
assert tag_kwargs["exist_ok"] is True
def test_push_checkpoint_to_hub_defaults_to_hub_default_visibility(tmp_path, monkeypatch):
ckpt = tmp_path / "010000"
(ckpt / "pretrained_model").mkdir(parents=True)
api = MagicMock()
monkeypatch.setattr("lerobot.common.train_utils.HfApi", lambda *a, **k: api)
push_checkpoint_to_hub(ckpt, "user/run")
api.create_repo.assert_called_once()
assert api.create_repo.call_args.kwargs["private"] is None
def test_resolve_resume_checkpoint_downloads_latest_and_links(tmp_path, monkeypatch):
from lerobot.common import train_utils
out = tmp_path / "run"
def fake_snapshot_download(repo_id, repo_type, allow_patterns, local_dir):
# Mimic the Hub layout the real download materializes locally.
assert allow_patterns == "checkpoints/020000/*"
(Path(local_dir) / "checkpoints" / "020000" / "pretrained_model").mkdir(parents=True)
return local_dir
monkeypatch.setattr("lerobot.common.train_utils.snapshot_download", fake_snapshot_download)
monkeypatch.setattr(
"lerobot.common.train_utils.find_latest_hub_checkpoint", lambda repo_id: "checkpoints/020000"
)
checkpoint_dir = train_utils.resolve_resume_checkpoint("u/run", out)
assert checkpoint_dir == out / CHECKPOINTS_DIR / "020000"
last = out / CHECKPOINTS_DIR / LAST_CHECKPOINT_LINK
assert last.is_symlink()
# `last` points at the downloaded step dir.
assert (last.parent / last.readlink()).resolve() == checkpoint_dir.resolve()
def test_resolve_resume_checkpoint_raises_without_checkpoints(tmp_path, monkeypatch):
from lerobot.common import train_utils
monkeypatch.setattr("lerobot.common.train_utils.find_latest_hub_checkpoint", lambda repo_id: None)
with pytest.raises(FileNotFoundError, match="No checkpoint"):
train_utils.resolve_resume_checkpoint("u/run", tmp_path / "run")