Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
63beff7
feat(dynamics): separate NeRD models, data generation and training
yuecideng Oct 24, 2026
a58de92
docs(dynamics): register the offline workflow in architecture navigation
yuecideng Oct 24, 2026
cc1a67e
feat(sl): add shared supervised batch and optimizer update contracts
yuecideng Oct 24, 2026
dd52660
feat(sl): add shared supervised batch and optimizer update contracts
yuecideng Oct 24, 2026
8408d9f
feat(sl): add offline run recovery and recipe-based train-sl CLI
yuecideng Oct 24, 2026
11142d5
test(sl): register the supervised learning context domain
yuecideng Oct 24, 2026
9430346
Merge standalone NeRD foundation for the supervised learning stack
yuecideng Oct 24, 2026
77869ee
fix(sl): accept argument lists from the unified CLI dispatcher
yuecideng Oct 24, 2026
3af58e9
Merge shared SL foundation into NeRD integration
yuecideng Oct 24, 2026
736a127
test(sl): isolate supervised test modules from dynamics tests
yuecideng Oct 24, 2026
1c1441f
feat(sl): integrate NeRD fitting and reference acceptance
yuecideng Oct 24, 2026
00b6a56
Merge branch 'codex/sl-foundation' into codex/sl-nerd
yuecideng Oct 24, 2026
1395871
fix(sl): isolate metadata and validate optimizer recovery before muta…
yuecideng Oct 24, 2026
23b4519
Merge branch 'codex/sl-foundation' into codex/sl-nerd
yuecideng Oct 24, 2026
8ed70f1
docs(sl): link the standalone NeRD training integration
yuecideng Oct 24, 2026
6e5a861
fix(sl): bind scheduler and tensor-tree semantics during recovery
yuecideng Oct 24, 2026
e1b3cb1
Merge branch 'codex/sl-foundation' into codex/sl-nerd
yuecideng Oct 24, 2026
d591e78
fix(sl): reject duplicate NeRD acceptance seeds before startup
yuecideng Oct 25, 2026
a7668ad
Add shared SL updates, offline training and recovery (#857)
yuecideng Oct 25, 2026
abb6d43
feat(sl): add single-node DDP training and exact rank recovery
yuecideng Oct 25, 2026
9fd69c7
Merge latest main into NeRD and SL foundations
yuecideng Oct 25, 2026
31e2b90
Merge latest main before NeRD training hardening
yuecideng Oct 25, 2026
f03af34
Integrate NeRD with the shared supervised learner
yuecideng Oct 25, 2026
6dc47c3
fix(sl): preserve run identity, best models and NeRD recovery
yuecideng Oct 25, 2026
d9cd707
docs(sl): render NeRD adapters with explicit API directives
yuecideng Oct 25, 2026
2e99302
fix(nerd): validate statistics and preserve unnormalized model precision
yuecideng Oct 25, 2026
bed4b69
Merge latest main while preserving NeRD precision fixes
yuecideng Oct 25, 2026
60425ca
Merge latest main before moving NeRD out of the core
yuecideng Oct 25, 2026
048d147
refactor(models): move NeRD into extension and downloadable showcase
yuecideng Oct 25, 2026
fa16c60
Merge latest main after NeRD model extension migration
yuecideng Oct 25, 2026
60f9b07
fix(nerd): retain experiment root for comparison receipts
yuecideng Oct 25, 2026
2c9ee88
fix(nerd): repair asset downloads and remove dedicated unit tests
yuecideng Oct 25, 2026
f850a5e
docs(models): define policy and dynamics domain boundaries
yuecideng Oct 25, 2026
a1a77ce
Merge latest model extension and SL recovery base into DDP
yuecideng Oct 25, 2026
ba89775
fix(sl): preserve distributed NeRD export and run ownership coverage
yuecideng Oct 25, 2026
8dae1b3
merge: synchronize SL DDP with current main
yuecideng Oct 25, 2026
b1d8ab4
test: align robot catalog checks with vendor layout
yuecideng Oct 25, 2026
f372f20
Include vendor catalog test compatibility for main CI
yuecideng Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agent_context/MAP.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ topics:
- id: supervised-learning
title: Supervised Learning
aliases: [supervised learning, SL learning, 监督学习, 统一监督学习]
keywords: [train-sl, SupervisedBatch, LossResult, SupervisedObjective, SupervisedLearner, SupervisedTrainer, TensorBatchSource]
keywords: [train-sl, SupervisedBatch, LossResult, SupervisedObjective, SupervisedLearner, SupervisedTrainer, TensorBatchSource, distributed_run, torchrun, DDP, 单机多卡, 分布式监督学习]
paths: [topics/supervised-learning/supervised-learning.md]
source_of_truth:
- embodichain/learning/sl/
Expand Down
19 changes: 18 additions & 1 deletion agent_context/topics/supervised-learning/supervised-learning.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,30 @@ operations remain in `compute`; source storage belongs to data adapters.
- `embodichain train-sl --config FILE [--resume FILE]` lazily resolves a
`factory: module:function`. The factory receives the full model/data/objective/
optimizer/trainer/evaluation configuration and returns a `SupervisedTrainer`.
Under single-node `torchrun`, the workflow initializes a process group before
the factory and resolves `trainer.device` to CPU/Gloo or `cuda:LOCAL_RANK`/NCCL.
`distributed_run(device)` provides the same scoped setup for programmatic
callers; it cleans up only groups it creates. Launch commands and a complete
masked-regression factory live in `examples/learning/sl_ddp/`.
- The [NeRD recipe](../neural-dynamics/neural-dynamics.md) is an installable factory
at `embodichain_models.dynamics.nerd.sl_recipe:build_supervised_trainer`.
The core does not import the model extension or example helpers. The NeRD
showcase's default `train` command uses this same common recipe.

Objectives own loss masks, normalization, integration and multi-step graph
construction. The optimizer does not infer action semantics or detach histories.
Under DDP, `LossResult.loss` is a local mean and `weight` is its effective valid
supervision count; the learner forms the global weighted mean. Batch size is per
rank, the learning rate is unchanged, and rank-local sampling streams draw with
replacement, so samples may overlap. All ranks call fitting, saving and recovery;
only rank 0 writes artifacts or runs validation through the unwrapped model.
Schema, action/frame/timestep and numerical-policy identifiers belong in each
recipe's checkpoint signature. Tensor sources additionally bind content identity.
Distributed checkpoints retain each rank's RNG, sampler and buffers and require
the same world size for exact recovery. Inside rank 0's validation callback only,
a save uses the previously gathered committed-update snapshot without a new
collective; ordinary save calls require every rank to participate.

`learning/sl/_artifacts.py` owns run identities and output-history admission for
both SL and historical NeRD trainers. A fresh run claims `run.json`; resuming an
occupied directory requires the checkpoint's matching run identity. Checkpoints
Expand All @@ -42,4 +57,6 @@ Acquisition and producer lifecycles remain application-owned.
`tests/learning/sl/` checks independent masked updates, graph propagation,
nonfinite update boundaries, owned samples, simulator-free imports and exact
CPU recovery with dropout, optimizer and sampling state. Model-specific
qualification belongs to each model's acceptance recipe.
qualification belongs to each model's acceptance recipe. The distributed tests
exercise weighted global updates and fixed-world-size recovery with CPU/Gloo;
the example is validated by launch smoke runs rather than example-specific tests.
97 changes: 97 additions & 0 deletions docs/source/api_reference/embodichain/embodichain.learning.sl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,89 @@ offline run lifecycle. Batches retain their tensor-tree structure and causal
masks; source adapters, objectives and downstream execution define their semantics.
Importing this package does not initialize simulation or reinforcement learning.

Single-node launch
------------------

Run the same recipe on one CPU or multiple workers from an installed repository
checkout. Each fresh run needs a separate, artifact-free ``trainer.output_dir``.
The example factory uses a small masked MLP regression corpus generated locally.

.. code-block:: bash

# One CPU worker.
python -m embodichain train-sl --config examples/learning/sl_ddp/cpu.yaml

# Two CPU workers using Gloo; choose a fresh output directory in the recipe.
OMP_NUM_THREADS=1 torchrun --standalone --nnodes=1 --nproc-per-node=2 \
-m embodichain train-sl --config examples/learning/sl_ddp/cpu.yaml

# Two GPUs using NCCL, one worker per visible GPU.
CUDA_VISIBLE_DEVICES=0,1 OMP_NUM_THREADS=1 \
torchrun --standalone --nnodes=1 --nproc-per-node=2 \
-m embodichain train-sl --config examples/learning/sl_ddp/cuda.yaml

For ``WORLD_SIZE > 1``, the workflow initializes the process group before invoking
the recipe factory. It resolves ``trainer.device`` to ``cpu`` or
``cuda:LOCAL_RANK``. ``trainer.batch_size`` is per worker; the global number of
drawn examples is ``batch_size * world_size``. Each worker samples independently
with replacement, so examples can overlap. The learning rate is not scaled
automatically.

Objectives return a local mean loss and its valid supervision count in
``LossResult.weight``. The distributed learner differentiates the global
weighted mean ``sum(weight_rank * loss_rank) / sum(weight_rank)``. For masked
regression, that weight is the number of valid scalar targets, rather than the
number of padded tensor elements or an unweighted rank count.

Only rank 0 writes training artifacts and runs validation. Validation receives
``learner.model`` as an unwrapped model so its forward pass does not require other
ranks. Each rank participates in ordinary ``fit()``, ``save()`` and ``resume()``
calls. A save within rank 0's validation callback uses the already gathered
committed-update snapshot and does not start another collective.

Distributed checkpoints preserve common optimizer/update state together with
each rank's RNG, source-sampling state and model buffers. Resume with the same
world size, recipe, source and training configuration:

.. code-block:: bash

OMP_NUM_THREADS=1 torchrun --standalone --nnodes=1 --nproc-per-node=2 \
-m embodichain train-sl --config /tmp/sl-ddp-resume.yaml \
--resume outputs/sl_ddp_cpu/step_100.pt

In this example, copy ``cpu.yaml`` to ``/tmp/sl-ddp-resume.yaml`` and change only
``trainer.output_dir`` to a fresh destination. This preserves the completed
original run when demonstrating continuation from its older update 100 checkpoint.
An interrupted run can use its original directory when no later metrics would
be overwritten. See ``examples/learning/sl_ddp/README.md`` for the full recipe.

Programmatic process-group scope
--------------------------------

The CLI owns process-group setup and cleanup automatically. Programmatic callers
can use ``distributed_run(device)`` around recipe construction and training; every
worker enters the scope. It yields the resolved ``torch.device`` and destroys
only a process group that it created. An already initialized group remains owned
by its caller.

.. code-block:: python

from pathlib import Path
import yaml
from embodichain.learning.sl import distributed_run
from examples.learning.sl_ddp.recipe import build_trainer

config = yaml.safe_load(Path("examples/learning/sl_ddp/cpu.yaml").read_text())
with distributed_run(config["trainer"]["device"]) as device:
config["trainer"]["device"] = str(device)
trainer = build_trainer(config)
trainer.fit()

The corresponding PyTorch 2.7 references are
`torchrun <https://github.com/pytorch/pytorch/blob/v2.7.0/torch/distributed/run.py>`_
and `DistributedDataParallel
<https://docs.pytorch.org/docs/2.7/generated/torch.nn.parallel.DistributedDataParallel.html>`_.

embodichain.learning.sl
-----------------------

Expand All @@ -21,6 +104,7 @@ embodichain.learning.sl
TensorBatchSource
SupervisedTrainer
SupervisedTrainerCfg
distributed_run

.. autoclass:: LossResult
:members:
Expand All @@ -46,6 +130,19 @@ embodichain.learning.sl
.. autoclass:: SupervisedTrainerCfg
:members:

.. autofunction:: distributed_run

embodichain.learning.sl.distributed
-----------------------------------

.. currentmodule:: embodichain.learning.sl.distributed

.. autosummary::

distributed_run

.. autofunction:: distributed_run

embodichain.learning.sl.learner
-------------------------------

Expand Down
2 changes: 2 additions & 0 deletions embodichain/learning/sl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from __future__ import annotations

from .types import LossResult, SupervisedBatch, SupervisedObjective
from .distributed import distributed_run
from .learner import SupervisedLearner
from .sources import SupervisedSource, TensorBatchSource
from .trainer import SupervisedTrainer, SupervisedTrainerCfg
Expand All @@ -32,4 +33,5 @@
"TensorBatchSource",
"SupervisedTrainer",
"SupervisedTrainerCfg",
"distributed_run",
]
166 changes: 166 additions & 0 deletions embodichain/learning/sl/distributed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# ----------------------------------------------------------------------------
# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
#
# 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.
# ----------------------------------------------------------------------------

"""Single-node process-group lifecycle and small SL control messages."""

from __future__ import annotations

from collections.abc import Callable, Iterator
from contextlib import contextmanager
from datetime import timedelta
import math
import os
from typing import Any, TypeVar

import torch
import torch.distributed as dist

__all__ = ["distributed_run"]

_T = TypeVar("_T")


@contextmanager
def distributed_run(
device: str | torch.device, *, timeout_seconds: float = 1800.0
) -> Iterator[torch.device]:
"""Bind one device and own a process group when launched by ``torchrun``.

CUDA jobs use NCCL and ``cuda:LOCAL_RANK``; CPU jobs use Gloo. Ordinary
single-process calls need no process group. An existing group is borrowed
and is never destroyed here. All ranks must enter this context together.

Args:
device: Recipe device. Under torchrun a CUDA index is replaced by the
local rank within ``CUDA_VISIBLE_DEVICES``.
timeout_seconds: Positive collective timeout, including rank-zero
validation time. It does not enable elastic world-size recovery.

Yields:
The device to use before constructing a model and optimizer.
"""
if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
raise ValueError("Distributed timeout must be finite and positive")
resolved = torch.device(device)
requested = int(os.environ.get("WORLD_SIZE", "1"))
if requested < 1:
raise ValueError("WORLD_SIZE must be positive")
if requested > 1:
if int(os.environ.get("LOCAL_WORLD_SIZE", str(requested))) != requested:
raise ValueError("SL torchrun currently supports a single node")
if resolved.type not in ("cpu", "cuda"):
raise ValueError("SL DDP supports CPU/Gloo or CUDA/NCCL")
if resolved.type == "cuda":
resolved = torch.device("cuda", int(os.environ["LOCAL_RANK"]))
if resolved.type == "cuda":
resolved = torch.device(
"cuda",
(
resolved.index
if resolved.index is not None
else torch.cuda.current_device()
),
)
torch.cuda.set_device(resolved)
owned = requested > 1 and not dist.is_initialized()
if owned:
dist.init_process_group(
backend="nccl" if resolved.type == "cuda" else "gloo",
timeout=timedelta(seconds=timeout_seconds),
)
try:
yield resolved
finally:
if owned and dist.is_initialized():
dist.destroy_process_group()


class _DistributedContext:
"""Coordinate control boundaries; model gradients use native Torch DDP."""

def __init__(self, device: str | torch.device) -> None:
self.device = torch.device(device)
self.enabled = dist.is_initialized() and dist.get_world_size() > 1
self.rank = dist.get_rank() if self.enabled else 0
self.world_size = dist.get_world_size() if self.enabled else 1
self.control_device = (
self.device
if self.enabled and dist.get_backend() == "nccl"
else torch.device("cpu")
)

def check(self, error: Exception | None, stage: str) -> None:
"""Propagate reachable local failures before the next collective phase."""
if not self.enabled:
if error is not None:
raise error
return
failed = torch.tensor(
int(error is not None), dtype=torch.int32, device=self.control_device
)
dist.all_reduce(failed, op=dist.ReduceOp.MAX)
if not failed.item():
return
message = None if error is None else f"{type(error).__name__}: {error}"
messages: list[Any] = [None] * self.world_size
dist.all_gather_object(messages, message)
for rank, failure in enumerate(messages):
if failure is not None:
raise RuntimeError(
f"Distributed SL {stage} failed on rank {rank}: {failure}"
) from error

def run(self, function: Callable[[], _T], stage: str) -> _T:
error = None
result: Any = None
try:
result = function()
except Exception as caught:
error = caught
self.check(error, stage)
return result

def root(self, function: Callable[[], _T], stage: str) -> _T:
if not self.enabled:
return function()
payload: list[Any] = [None]
if self.rank == 0:
try:
payload[0] = (function(), None)
except Exception as error:
payload[0] = (None, f"{type(error).__name__}: {error}")
dist.broadcast_object_list(payload, src=0)
value, failure = payload[0]
if failure is not None:
raise RuntimeError(f"Distributed SL {stage} failed on rank 0: {failure}")
return value

def agree(self, value: Any, stage: str) -> None:
if not self.enabled:
return
values: list[Any] = [None] * self.world_size
dist.all_gather_object(values, value)
if any(item != values[0] for item in values[1:]):
raise ValueError(f"Distributed SL {stage} differs between ranks")

def gather(self, value: Any) -> list[Any] | None:
if not self.enabled:
return [value]
# Keep the small control-state path on all-gather collectives, which
# are supported by both NCCL and Gloo. Non-root copies are temporary.
values: list[Any] = [None] * self.world_size
dist.all_gather_object(values, value)
return values if self.rank == 0 else None
Loading
Loading