mirror of
https://github.com/huggingface/lerobot.git
synced 2026-08-08 17:39:44 +00:00
ef88d4e52b
* 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.
149 lines
6.1 KiB
Python
149 lines
6.1 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.
|
|
"""publish_trained_model: commit set, card, log-line contract, PEFT branch (hub fully mocked)."""
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
import lerobot.common.train_utils as train_utils
|
|
import lerobot.utils.hub as hub
|
|
from lerobot.common.train_utils import generate_model_card, publish_trained_model
|
|
from lerobot.configs.default import DatasetConfig
|
|
from lerobot.configs.train import TrainPipelineConfig
|
|
from tests.fixtures.dummy_checkpoint_policy import make_dummy_policy
|
|
|
|
|
|
class FakeHfApi:
|
|
"""Records every repo/upload interaction; shared across both HfApi import sites."""
|
|
|
|
calls: list[dict] = []
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
def create_repo(self, repo_id, private=None, exist_ok=False, **kwargs):
|
|
return SimpleNamespace(repo_id=repo_id)
|
|
|
|
def upload_folder(self, *, repo_id, folder_path, commit_message, **kwargs):
|
|
FakeHfApi.calls.append(
|
|
{
|
|
"repo_id": repo_id,
|
|
"commit_message": commit_message,
|
|
"files": sorted(p.name for p in Path(folder_path).iterdir()),
|
|
"ignore_patterns": kwargs.get("ignore_patterns"),
|
|
}
|
|
)
|
|
return SimpleNamespace(repo_url=SimpleNamespace(url=f"https://huggingface.co/{repo_id}"))
|
|
|
|
|
|
@pytest.fixture
|
|
def mocked_hub(monkeypatch):
|
|
FakeHfApi.calls = []
|
|
monkeypatch.setattr(train_utils, "HfApi", FakeHfApi)
|
|
monkeypatch.setattr(hub, "HfApi", FakeHfApi)
|
|
# card.validate() hits the Hub; publishing must work offline in tests
|
|
monkeypatch.setattr(train_utils.ModelCard, "validate", lambda self: None)
|
|
return FakeHfApi
|
|
|
|
|
|
def make_cfg() -> TrainPipelineConfig:
|
|
cfg = TrainPipelineConfig(dataset=DatasetConfig(repo_id="user/dataset"))
|
|
cfg.parallelism.resolve(1)
|
|
return cfg
|
|
|
|
|
|
class RecordingProcessor:
|
|
def __init__(self):
|
|
self.pushed_to = None
|
|
|
|
def push_to_hub(self, repo_id, **kwargs):
|
|
self.pushed_to = repo_id
|
|
|
|
|
|
class TestPublishTrainedModel:
|
|
def test_commit_set_and_log_contract(self, mocked_hub, caplog):
|
|
policy = make_dummy_policy(repo_id="user/policy")
|
|
pre, post = RecordingProcessor(), RecordingProcessor()
|
|
with caplog.at_level(logging.INFO):
|
|
publish_trained_model(make_cfg(), policy, pre, post, dataset_meta=None)
|
|
|
|
# commit 1: the model through HubMixin (config.json + model.safetensors in a tmpdir)
|
|
model_commit = mocked_hub.calls[0]
|
|
assert {"config.json", "model.safetensors"} <= set(model_commit["files"])
|
|
# commits 2-3: processors
|
|
assert pre.pushed_to == "user/policy" and post.pushed_to == "user/policy"
|
|
# commit 4: the bundle sidecar
|
|
bundle = mocked_hub.calls[-1]
|
|
assert {"README.md", "train_config.json"} <= set(bundle["files"])
|
|
# the exact line lerobot.jobs.hf watches to end remote runs early
|
|
assert any(
|
|
m.startswith("Model pushed to https://huggingface.co/user/policy") for m in caplog.messages
|
|
)
|
|
|
|
def test_peft_branch_skips_model_commit(self, mocked_hub):
|
|
policy = make_dummy_policy(repo_id="user/policy")
|
|
|
|
class FakePeftModel:
|
|
def save_pretrained(self, path):
|
|
(Path(path) / "adapter_model.safetensors").write_bytes(b"x")
|
|
|
|
publish_trained_model(make_cfg(), policy, None, None, dataset_meta=None, peft_model=FakePeftModel())
|
|
assert len(mocked_hub.calls) == 1 # only the bundle commit
|
|
bundle = mocked_hub.calls[0]
|
|
# adapter weights + the wrapped policy's config + card + train config, no full weights
|
|
assert {"README.md", "adapter_model.safetensors", "config.json", "train_config.json"} <= set(
|
|
bundle["files"]
|
|
)
|
|
assert "model.safetensors" not in bundle["files"]
|
|
|
|
def test_missing_repo_id_fails_loudly(self, mocked_hub):
|
|
policy = make_dummy_policy(repo_id=None)
|
|
with pytest.raises(ValueError, match="repo id"):
|
|
publish_trained_model(make_cfg(), policy, None, None, dataset_meta=None)
|
|
|
|
|
|
class TestGenerateModelCard:
|
|
def test_free_function_renders_from_arguments(self, monkeypatch):
|
|
monkeypatch.setattr(train_utils.ModelCard, "validate", lambda self: None)
|
|
policy = make_dummy_policy(repo_id="user/policy")
|
|
card = generate_model_card(policy.config, cfg=make_cfg(), dataset_meta=None)
|
|
assert card.data.library_name == "lerobot"
|
|
assert card.data.datasets == "user/dataset"
|
|
assert "lerobot" in card.data.tags
|
|
|
|
|
|
class TestDeprecatedPushModelToHub:
|
|
"""`push_model_to_hub` stays callable for external scripts, delegating to the publisher."""
|
|
|
|
def test_policy_shim_warns_and_publishes(self, mocked_hub):
|
|
policy = make_dummy_policy(repo_id="user/policy")
|
|
with pytest.warns(FutureWarning, match="push_model_to_hub is deprecated"):
|
|
policy.push_model_to_hub(make_cfg())
|
|
|
|
# Same artifacts the method produced before: weights + config, then card + train config.
|
|
model_commit = mocked_hub.calls[0]
|
|
assert {"config.json", "model.safetensors"} <= set(model_commit["files"])
|
|
bundle = mocked_hub.calls[-1]
|
|
assert {"README.md", "train_config.json"} <= set(bundle["files"])
|
|
|
|
def test_policy_shim_warns_that_state_dict_is_ignored(self, mocked_hub):
|
|
policy = make_dummy_policy(repo_id="user/policy")
|
|
with pytest.warns(FutureWarning, match="`state_dict` argument is ignored"):
|
|
policy.push_model_to_hub(make_cfg(), state_dict=policy.state_dict())
|