Files
lerobot/tests/common/test_checkpoint_save_resume.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

187 lines
7.9 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.
"""Checkpoint save/resume round-trips on the non-sharded paths."""
from types import SimpleNamespace
import pytest
import torch
from safetensors.torch import load_file
from lerobot.common.train_utils import (
load_training_metadata,
resume_after_prepare,
resume_before_prepare,
save_checkpoint,
)
from lerobot.configs.default import DatasetConfig
from lerobot.configs.train import CheckpointFormat, TrainPipelineConfig
from lerobot.utils.constants import PRETRAINED_MODEL_DIR, TRAINING_STATE_DIR, TRAINING_STEP
from lerobot.utils.io_utils import load_json, write_json
from tests.fixtures.dummy_checkpoint_policy import make_dummy_policy
def make_cfg(**overrides) -> TrainPipelineConfig:
cfg = TrainPipelineConfig(dataset=DatasetConfig(repo_id="lerobot/dummy"), batch_size=3)
cfg.parallelism.resolve(1)
for name, value in overrides.items():
setattr(cfg, name, value)
return cfg
def passthrough_accelerator() -> SimpleNamespace:
"""The accelerator surface save/resume touches on non-sharded runs."""
return SimpleNamespace(unwrap_model=lambda m: m, wait_for_everyone=lambda: None)
class TestSaveCheckpoint:
def test_non_sharded_layout(self, tmp_path):
policy = make_dummy_policy()
optimizer = torch.optim.Adam(policy.parameters())
save_checkpoint(
tmp_path,
step=7,
cfg=make_cfg(),
policy=policy,
optimizer=optimizer,
accelerator=passthrough_accelerator(),
)
pretrained = tmp_path / PRETRAINED_MODEL_DIR
state = tmp_path / TRAINING_STATE_DIR
assert (pretrained / "model.safetensors").is_file()
assert (pretrained / "config.json").is_file()
assert (pretrained / "train_config.json").is_file()
assert (state / TRAINING_STEP).is_file()
assert (state / "rng_state.safetensors").is_file()
assert (state / "optimizer_state.safetensors").is_file()
# single-file artifact, no index, weights intact
weights = load_file(pretrained / "model.safetensors")
assert torch.allclose(weights["net.weight"], torch.full_like(weights["net.weight"], 0.5))
assert not list(pretrained.glob("*.index.json"))
def test_training_step_records_topology(self, tmp_path):
cfg = make_cfg()
cfg.accelerator.gradient_accumulation.steps = 4
policy = make_dummy_policy()
save_checkpoint(
tmp_path,
step=11,
cfg=cfg,
policy=policy,
optimizer=torch.optim.Adam(policy.parameters()),
accelerator=passthrough_accelerator(),
)
metadata = load_training_metadata(tmp_path / TRAINING_STATE_DIR)
assert metadata["dp_world_size"] == 1
assert metadata["batch_size"] == 3
assert metadata["grad_accum_steps"] == 4
def test_dp_world_size_legacy_fallback(self, tmp_path):
"""Pre-v0.7 checkpoints recorded num_processes; the reader falls back to it."""
state_dir = tmp_path / TRAINING_STATE_DIR
state_dir.mkdir(parents=True)
write_json({"step": 5, "num_processes": 4}, state_dir / TRAINING_STEP)
metadata = load_training_metadata(tmp_path / TRAINING_STATE_DIR)
assert metadata["dp_world_size"] == 4
assert metadata["batch_size"] is None
class TestResume:
def _checkpointed_run(self, tmp_path):
policy = make_dummy_policy()
optimizer = torch.optim.Adam(policy.parameters(), lr=0.123)
# give the optimizer real state
policy.forward({"observation.state": torch.randn(2, 4)})[0].backward()
optimizer.step()
cfg = make_cfg()
save_checkpoint(
tmp_path,
step=42,
cfg=cfg,
policy=policy,
optimizer=optimizer,
accelerator=passthrough_accelerator(),
)
cfg.checkpoint_path = tmp_path
return cfg, policy, optimizer
def test_two_phase_resume_round_trip(self, tmp_path):
cfg, _, optimizer = self._checkpointed_run(tmp_path)
assert resume_before_prepare(cfg) == 42
fresh_policy = make_dummy_policy()
fresh_optimizer = torch.optim.Adam(fresh_policy.parameters(), lr=0.999)
resume_after_prepare(cfg, passthrough_accelerator(), fresh_policy, fresh_optimizer, None)
restored = fresh_optimizer.state_dict()
original = optimizer.state_dict()
assert restored["param_groups"][0]["lr"] == original["param_groups"][0]["lr"]
for key, tensor in original["state"][0].items():
assert torch.equal(restored["state"][0][key], tensor), key
def test_resume_warns_on_changed_cadence_and_topology(self, tmp_path, caplog):
"""The recorded grad-accum factor and parallelism snapshot must be compared on
resume, with one warning naming the diff."""
import logging
cfg, _, _ = self._checkpointed_run(tmp_path)
cfg.accelerator.gradient_accumulation.steps = 4
cfg.parallelism.dp_replicate = 2 # same dp_world_size story is irrelevant here
with caplog.at_level(logging.WARNING):
assert resume_before_prepare(cfg) == 42
warning = next(m for m in caplog.messages if "differ from the checkpoint" in m)
assert "grad_accum_steps: 1 -> 4" in warning
assert "dp_replicate: 1 -> 2" in warning
def test_resume_unchanged_settings_stay_silent(self, tmp_path, caplog):
import logging
cfg, _, _ = self._checkpointed_run(tmp_path)
with caplog.at_level(logging.WARNING):
resume_before_prepare(cfg)
assert not [m for m in caplog.messages if "differ from the checkpoint" in m]
def test_resume_rejects_non_sharded_checkpoint_on_sharded_run(self, tmp_path):
"""Resharding works across sizes, not across kinds: non-sharded -> sharded is rejected."""
cfg, _, _ = self._checkpointed_run(tmp_path)
cfg.parallelism.dp_shard = 2
with pytest.raises(ValueError, match="Cannot resume"):
resume_before_prepare(cfg)
def test_resume_rejects_sharded_checkpoint_on_non_sharded_run(self, tmp_path):
"""The symmetric direction: a checkpoint recorded sharded cannot resume non-sharded."""
cfg, _, _ = self._checkpointed_run(tmp_path)
state_file = tmp_path / TRAINING_STATE_DIR / TRAINING_STEP
state = load_json(state_file)
state["parallelism"]["dp_shard"] = 2
write_json(state, state_file)
with pytest.raises(ValueError, match="Cannot resume"):
resume_before_prepare(cfg)
def test_resume_before_prepare_requires_training_state(self, tmp_path):
cfg = make_cfg()
cfg.checkpoint_path = tmp_path
with pytest.raises(NotADirectoryError):
resume_before_prepare(cfg)
def test_dcp_format_integrity_preflight(self, tmp_path):
"""A checkpoint declaring DCP shards without the shard dir fails with the converter hint."""
pytest.importorskip("accelerate", reason="accelerate is required (install lerobot[training])")
cfg, policy, optimizer = self._checkpointed_run(tmp_path)
cfg.parallelism.dp_shard = 2 # pretend the recorded run was sharded
cfg.checkpoint_format = CheckpointFormat.DCP
with pytest.raises(FileNotFoundError, match="lerobot-convert-dcp"):
resume_after_prepare(cfg, passthrough_accelerator(), policy, optimizer, None)