Update Wall-X to 1.1.0 (#104)

This commit is contained in:
Starrick Liu
2026-06-15 11:40:00 +08:00
committed by GitHub
parent e23a586846
commit 72834e7de5
200 changed files with 33916 additions and 16771 deletions
+34
View File
@@ -0,0 +1,34 @@
"""Distributed trainer package exports."""
from wall_x.trainer.fsdp_trainer.base_trainer import (
BaseDistributedTrainer,
DistributedTrainer,
all_gather,
all_reduce,
barrier,
cleanup_distributed,
get_local_rank,
get_rank,
get_world_size,
is_main_process,
setup_distributed,
)
from wall_x.trainer.fsdp_trainer.fsdp_trainer import FSDPTrainer
__all__ = [
# Base classes and utilities
"BaseDistributedTrainer",
"DistributedTrainer",
"setup_distributed",
"cleanup_distributed",
"get_rank",
"get_world_size",
"get_local_rank",
"is_main_process",
"barrier",
"all_reduce",
"all_gather",
# Trainers
# "DDPTrainer",
"FSDPTrainer",
]
+430
View File
@@ -0,0 +1,430 @@
"""
Base Trainer for Pure PyTorch Distributed Training (DDP/FSDP)
No accelerate dependency - can be launched directly with torchrun
"""
import contextlib
import gc
import logging
import os
from abc import ABC, abstractmethod
from datetime import timedelta
from typing import Any, Optional
import torch
import torch.distributed as dist
from wall_x.config.hyperparams_config import AdamWConfig
from wall_x.config.train_config import TrainConfig
from wall_x.trainer.trainer_utils import seed_all
from wall_x.utils.logger import DistributedLogger
from wall_x.utils.timers import Timers
def setup_distributed():
"""Initialize distributed training environment.
NCCL watchdog default is 10 min - some dataset backends (e.g. lerobot's
per-rank sequential LeRobotDataset indexing) can exceed that during the
first build, causing barrier #2 to time out on the waiting rank. Allow
the timeout to be raised via ``WALLX_DIST_TIMEOUT_MINUTES`` (default 30).
"""
if not dist.is_initialized():
timeout_min = int(os.environ.get("WALLX_DIST_TIMEOUT_MINUTES", "30"))
dist.init_process_group(
backend="nccl",
timeout=timedelta(minutes=timeout_min),
)
local_rank = int(os.environ.get("LOCAL_RANK", 0))
torch.cuda.set_device(local_rank)
return local_rank
def cleanup_distributed():
"""Clean up distributed training environment"""
if dist.is_initialized():
dist.destroy_process_group()
def get_rank() -> int:
"""Get current process rank"""
if dist.is_initialized():
return dist.get_rank()
return 0
def get_world_size() -> int:
"""Get total number of processes"""
if dist.is_initialized():
return dist.get_world_size()
return 1
def get_local_rank() -> int:
"""Get local rank (GPU index on current node)"""
return int(os.environ.get("LOCAL_RANK", 0))
def is_main_process() -> bool:
"""Check if current process is the main process (rank 0)"""
return get_rank() == 0
# Trainer-level collectives should use an explicit global process group.
# FSDP's mesh groups may be 1D or 2D and have sharding-specific semantics,
# while trainer barriers/metric reductions need all-rank semantics.
_trainer_process_group = None
def set_trainer_process_group(pg):
global _trainer_process_group
_trainer_process_group = pg
def barrier():
"""Synchronize all processes"""
if dist.is_initialized():
dist.barrier(group=_trainer_process_group)
def all_reduce(tensor: torch.Tensor, op=dist.ReduceOp.SUM) -> torch.Tensor:
"""All-reduce tensor across all processes"""
if dist.is_initialized():
dist.all_reduce(tensor, op=op, group=_trainer_process_group)
return tensor
def all_gather(tensor: torch.Tensor) -> torch.Tensor:
"""All-gather tensor from all processes"""
if not dist.is_initialized():
return tensor
group = _trainer_process_group
world_size = dist.get_world_size(group=group)
gathered = [torch.zeros_like(tensor) for _ in range(world_size)]
dist.all_gather(gathered, tensor, group=group)
return torch.stack(gathered)
class BaseDistributedTrainer(ABC):
"""
Abstract base class for distributed training without accelerate.
Supports both DDP and FSDP through subclasses.
Launch with torchrun:
torchrun --nproc_per_node=8 train.py --config config.yaml
"""
@abstractmethod
def train_loop(self, epoch: int):
"""Training loop for one epoch"""
raise NotImplementedError("train_loop must be implemented")
@abstractmethod
def val_loop(self):
"""Validation loop"""
raise NotImplementedError("val_loop must be implemented")
@abstractmethod
def save_checkpoint(self, epoch: int, step: int = 0):
"""Save model checkpoint"""
raise NotImplementedError("save_checkpoint must be implemented")
@abstractmethod
def load_model(self):
"""Load model"""
raise NotImplementedError("load_model must be implemented")
@abstractmethod
def load_dataset(self):
"""Load dataset"""
raise NotImplementedError("load_dataset must be implemented")
@abstractmethod
def backward(self, loss: torch.Tensor):
"""Perform backward pass"""
raise NotImplementedError("backward must be implemented")
@abstractmethod
def clip_grad_norm(self, max_norm: float) -> torch.Tensor:
"""Clip gradient norm"""
raise NotImplementedError("clip_grad_norm must be implemented")
class DistributedTrainer(BaseDistributedTrainer):
"""
Concrete base trainer with common functionality for DDP/FSDP.
Subclasses should implement wrap_model, backward, clip_grad_norm, etc.
"""
def __init__(
self,
train_config: TrainConfig,
wandb_run: Optional[Any] = None,
):
self.cfg = train_config
# wandb Run object (metric recorder) - None on non-main ranks and
# when use_wandb is disabled. Distinct from self.logger below.
self.wandb_run = wandb_run
# Initialize distributed environment
self.local_rank = setup_distributed()
self.rank = get_rank()
self.world_size = get_world_size()
self.device = torch.device(f"cuda:{self.local_rank}")
# Dataset config: optional backend-provided trainer config.
from wall_x.trainer.adapters.base_adapter import load_trainer_data_config
self.data_config = load_trainer_data_config(self.cfg)
# Text logger
self.logger = DistributedLogger(
name=self.__class__.__name__,
save_path=self.cfg.checkpoint.save_path,
)
# Training state
self.seed = self.cfg.hyperparams.seed
self.logger.info(f"seed {self.seed}")
seed_all(self.seed)
self.start_epoch = 0
self.global_step = 0
self.micro_step = 0
self.num_epoch = self.cfg.hyperparams.num_epoch
self.dataset_config_path = getattr(self.cfg.data, "dataset_config_path", None)
self.initial_step = 0
# Optimizer hyperparameters (betas / weight_decay / eps)
# used by build_optimizer for both AdamW and Muon paths.
opt = self.cfg.hyperparams.optimizer
if isinstance(opt, AdamWConfig):
self.adamw_betas = tuple(opt.betas)
self.adamw_weight_decay = opt.weight_decay
self.adamw_eps = opt.eps
elif getattr(opt, "optimizer_type", None) == "muon":
self.adamw_betas = (opt.beta_1, opt.beta_2)
self.adamw_weight_decay = opt.weight_decay
self.adamw_eps = opt.eps
else:
self.adamw_betas = (0.9, 0.98)
self.adamw_weight_decay = 1e-8
self.adamw_eps = 1e-8
# Performance options
self.nvtx = self.cfg.debug.nvtx
self.timers = Timers(log_level=0, log_option="minmax")
# Training hyperparameters
self.max_grad_norm = opt.max_grad_norm
self.grad_accum_steps = self.cfg.hyperparams.gradient_accumulation_steps
self.log_interval = self.cfg.logging.log_interval
self.log_stats_buffer = []
# Model and optimizer (to be set by subclass)
self.model = None
self.optimizer = None
self.lr_scheduler = None
# Mixed precision
dist_cfg = self.cfg.distributed
self.use_amp = dist_cfg.use_amp
self.amp_dtype = torch.bfloat16 if dist_cfg.bf16 else torch.float16
self.grad_scaler = None
if self.use_amp and self.amp_dtype == torch.float16:
self.grad_scaler = torch.amp.GradScaler("cuda")
def log(
self, message: str, level: int = logging.INFO, main_process_only: bool = True
):
"""Log a text message via self.logger.
Kept for backward compat with call sites that still use ``self.log(...)``
(new code should call ``self.logger.info/warning/error(...)`` directly).
Under the new DistributedLogger the ``main_process_only`` flag is
redundant for stdout (stdout is rank-0-only by construction); it still
controls whether non-zero ranks write the message to their file log.
"""
if main_process_only and not is_main_process():
return
self.logger.log(message, level=level, main_process_only=main_process_only)
def fit(self):
"""Main training loop"""
barrier()
# Optional: validate before training
if self.cfg.checkpoint.validate_first:
self.val_loop()
barrier()
if self.nvtx:
torch.cuda.cudart().cudaProfilerStart()
# Profiler setup
enable_profiling = self.cfg.debug.profile
profiler = (
self._setup_profiler() if enable_profiling else contextlib.nullcontext()
)
for epoch in range(self.start_epoch, self.num_epoch):
# Training
self.train_loop(epoch, profiler=profiler)
barrier()
num_training_steps = getattr(self, "num_training_steps", 0)
if num_training_steps > 0 and self.global_step >= num_training_steps:
break
# Save checkpoint
if (epoch + 1) % self.cfg.logging.epoch_save_interval == 0:
self.save_checkpoint(epoch)
# Validation
self.val_loop()
barrier()
gc.collect()
torch.cuda.empty_cache()
if self.nvtx:
torch.cuda.cudart().cudaProfilerStop()
def _setup_profiler(self):
"""Setup PyTorch profiler"""
return torch.profiler.profile(
activities=[
torch.profiler.ProfilerActivity.CPU,
torch.profiler.ProfilerActivity.CUDA,
],
schedule=torch.profiler.schedule(
wait=self.cfg.debug.profile_wait_iters,
warmup=self.cfg.debug.profile_warmup_iters,
active=self.cfg.debug.profile_active_iters,
),
on_trace_ready=torch.profiler.tensorboard_trace_handler(
self.cfg.debug.profile_save_path,
worker_name=f"worker{self.rank}",
),
record_shapes=True,
profile_memory=True,
with_stack=True,
)
def gather_tensor(self, tensor: torch.Tensor) -> torch.Tensor:
"""Gather tensor from all processes"""
return all_gather(tensor)
def reduce_tensor(self, tensor: torch.Tensor, average: bool = True) -> torch.Tensor:
"""Reduce tensor across all processes"""
tensor = tensor.clone()
all_reduce(tensor)
if average:
tensor = tensor / self.world_size
return tensor
def sync_gradients(self) -> bool:
"""Check if gradients should be synchronized (for gradient accumulation)"""
return (self.micro_step + 1) % self.grad_accum_steps == 0
def optimizer_zero_grad(self):
"""Zero optimizer gradients"""
self.optimizer.zero_grad(set_to_none=True)
def optimizer_step(self):
"""Perform optimizer step"""
if self.grad_scaler is not None:
self.grad_scaler.step(self.optimizer)
self.grad_scaler.update()
else:
self.optimizer.step()
def lr_scheduler_step(self):
"""Perform learning rate scheduler step"""
if self.lr_scheduler is not None:
self.lr_scheduler.step()
def get_lr(self) -> float:
"""Get current learning rate"""
if self.lr_scheduler is not None:
return self.lr_scheduler.get_last_lr()[0]
return self.optimizer.param_groups[0]["lr"]
def autocast_context(self):
"""Get autocast context for mixed precision"""
if self.use_amp:
return torch.amp.autocast("cuda", dtype=self.amp_dtype)
return contextlib.nullcontext()
def training_log(
self,
current_epoch: int,
total_epoch: int,
current_iter: int,
total_iter: int,
loss: torch.Tensor,
lr: float,
time_per_step: float,
show_time_details: bool = False,
):
"""Log training progress"""
if not is_main_process():
return
# Prefer the cross-rank reduced loss (train_loss) from
# _current_step_stats so the console line matches what wall_x_2509's
# fsdp_trainer prints and what wandb sees. Falls back to the raw
# rank-0 local tensor when stats haven't been populated yet.
_smoothed = getattr(self, "_current_step_stats", None) or {}
_loss_to_print = _smoothed.get("train_loss", float(loss))
log_string = ""
log_string += f" epoch {current_epoch:3d}/{total_epoch:3d} |"
log_string += f" iter {current_iter:6d}/{total_iter:6d} |"
log_string += f" loss {_loss_to_print:.10f} |"
log_string += f" lr {lr:.6f} |"
log_string += f" time {time_per_step:.4f}s |"
self.log(log_string)
if show_time_details:
timers_to_log = [
"interval-time",
"data-load",
"forward-compute",
"backward-compute",
"optimizer",
]
self.timers.log(timers_to_log, normalizer=1)
def true_gather(self, value):
"""Gather values across all processes and compute mean over non-None values."""
device = next(self.model.parameters()).device
# CRITICAL: must clone() here, not just detach().
# detach() only disconnects the computation graph but still shares the
# same underlying storage. dist.all_reduce() is **in-place** - it
# overwrites that storage with the sum across all ranks. If the caller
# passes a tensor that is also referenced elsewhere (e.g. outputs["loss"]
# and outputs["flow_loss"] pointing to the same scalar_loss), the
# in-place all_reduce silently mutates the original, causing downstream
# readers (like training_log) to see a value multiplied by world_size.
value_to_gather = (
value.detach().clone()
if value is not None
else torch.tensor(0.0, device=device)
)
count_to_gather = torch.tensor(1.0 if value is not None else 0.0, device=device)
# all_reduce is cheaper than all_gather because only the sum is needed.
dist.all_reduce(value_to_gather, op=dist.ReduceOp.SUM)
dist.all_reduce(count_to_gather, op=dist.ReduceOp.SUM)
total_processes = count_to_gather.item()
if total_processes > 0:
return value_to_gather.item() / total_processes
else:
return None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,400 @@
"""Distribution strategies for model wrapping and gradient coordination."""
from __future__ import annotations
import logging
import os
from abc import ABC, abstractmethod
from contextlib import contextmanager
from importlib import import_module
from typing import NamedTuple, Optional
import torch
import torch.distributed as dist
from torch.distributed.device_mesh import DeviceMesh, init_device_mesh
from torch.distributed.fsdp import (
MixedPrecisionPolicy,
OffloadPolicy,
)
from torch.nn.parallel import DistributedDataParallel as DDP
_logger = logging.getLogger(__name__)
class FSDP2Layout(NamedTuple):
"""Per-call FSDP2 wrap configuration produced by ``FSDPStrategy``."""
mesh: DeviceMesh
dp_process_group: Optional[dist.ProcessGroup]
shard_process_group: Optional[dist.ProcessGroup]
replicate_process_group: Optional[dist.ProcessGroup]
trainer_process_group: Optional[dist.ProcessGroup]
mp_policy: Optional[MixedPrecisionPolicy]
offload_policy: Optional[OffloadPolicy]
reshard_after_forward: bool
class DistributionStrategy(ABC):
"""Common interface for FSDP2 / DDP wrapping + gradient operations."""
_cfg: dict
last_grad_clip_stats: Optional[dict] = None
def _maybe_enable_grad_ckpt(self, model: torch.nn.Module) -> None:
"""Call ``gradient_checkpointing_enable`` if the model exposes it."""
if not self._cfg.get("use_gradient_checkpointing", False):
return
method = getattr(model, "gradient_checkpointing_enable", None)
if method is None:
_logger.warning(
"use_gradient_checkpointing=true but %s has no "
"gradient_checkpointing_enable method. If this model "
"routes recomputation through its config the request may "
"still be honored; otherwise "
"gradient checkpointing is effectively disabled for "
"this run.",
type(model).__name__,
)
return
method()
@abstractmethod
def wrap(self, model: torch.nn.Module) -> torch.nn.Module:
"""Wrap *model* with the active distribution strategy."""
@abstractmethod
def clip_grad_norm(
self,
model: torch.nn.Module,
max_norm: float,
*,
optimizer=None,
) -> torch.Tensor:
"""Clip gradient L2 norm; return total norm before clipping."""
@abstractmethod
def no_sync(self, model: torch.nn.Module):
"""Context manager that disables cross-rank gradient sync.
Used by the trainer for gradient accumulation micro-batches.
FSDP2 toggles ``set_requires_gradient_sync``; DDP yields
``model.no_sync()``.
"""
@property
@abstractmethod
def params_sharded(self) -> bool:
"""Whether parameters are actually sharded across ranks.
FSDP2 always shards (every ``fully_shard`` unit is sharded across
the mesh). DDP never shards. Used by adapters to decide whether
per-rank norm contributions need an all-reduce.
"""
class FSDPStrategy(DistributionStrategy):
def __init__(self, config: dict):
self._cfg = config
self._layout: Optional[FSDP2Layout] = None
self.last_grad_clip_stats = None
@property
def trainer_process_group(self) -> Optional[dist.ProcessGroup]:
"""Process group for trainer-level collectives.
Returns `dist.group.WORLD` (the implicit all-rank group created
by `init_process_group`) so that trainer barriers / metric
reductions behave consistently regardless of FSDP mesh shape
(1D for full_shard / shard_grad_op, 2D for hybrid_shard /
_hybrid_shard_zero2). No extra NCCL communicator is allocated.
"""
return self._layout.trainer_process_group if self._layout else None
@staticmethod
def _build_trainer_process_group(
world_size: int,
) -> Optional[dist.ProcessGroup]:
if not dist.is_initialized() or world_size <= 1:
return None
return dist.group.WORLD
def _build_fsdp2_layout(self) -> FSDP2Layout:
"""Construct the FSDP2 wrap layout from yaml config.
Builds the device mesh (1D for full_shard / shard_grad_op, 2D
for hybrid_shard / _hybrid_shard_zero2), explicit FSDP mesh process
groups, a trainer-level global process group, the matching
mixed-precision policy, offload policy, and reshard_after_forward flag.
"""
name = self._cfg.get("fsdp_sharding_strategy", "full_shard")
world_size = dist.get_world_size() if dist.is_initialized() else 1
dp_pg: Optional[dist.ProcessGroup] = None
shard_pg: Optional[dist.ProcessGroup] = None
replicate_pg: Optional[dist.ProcessGroup] = None
# 2D mesh for HSDP variants; 1D for full_shard / shard_grad_op.
if name in ("hybrid_shard", "_hybrid_shard_zero2"):
replicate_size = self._cfg.get("fsdp_hsdp_replicate_size") or int(
os.environ.get("LOCAL_WORLD_SIZE", "0")
)
if replicate_size <= 0:
# Fallback: assume one replica per node, equal-sized shard groups.
# Caller should set fsdp_hsdp_replicate_size explicitly when this
# default is wrong (e.g. uneven node sizes).
replicate_size = max(1, world_size // 8) if world_size >= 8 else 1
if world_size % replicate_size != 0:
raise ValueError(
f"world_size ({world_size}) not divisible by HSDP replicate "
f"size ({replicate_size}); set fsdp_hsdp_replicate_size."
)
shard_size = world_size // replicate_size
mesh = init_device_mesh(
"cuda",
(replicate_size, shard_size),
mesh_dim_names=("replicate", "shard"),
)
replicate_pg = mesh.get_group("replicate")
shard_pg = mesh.get_group("shard")
else:
mesh = init_device_mesh("cuda", (world_size,), mesh_dim_names=("dp",))
dp_pg = mesh.get_group("dp")
trainer_pg = self._build_trainer_process_group(world_size)
reshard_after_forward = name not in (
"shard_grad_op",
"_hybrid_shard_zero2",
)
offload_policy: Optional[OffloadPolicy] = (
OffloadPolicy(pin_memory=True)
if self._cfg.get("fsdp_cpu_offload", False)
else None
)
mp_policy: Optional[MixedPrecisionPolicy]
if self._cfg.get("use_mixed_precision", True):
dtype = torch.bfloat16 if self._cfg.get("bf16", True) else torch.float16
reduce_dtype = (
torch.float32
if self._cfg.get("fsdp_reduce_dtype", "bf16") == "fp32"
else dtype
)
mp_policy = MixedPrecisionPolicy(
param_dtype=dtype,
reduce_dtype=reduce_dtype,
cast_forward_inputs=False,
)
else:
mp_policy = None
assert mp_policy is not None, (
"FSDP2 requires a MixedPrecisionPolicy in this torch version. "
"Set distributed.use_mixed_precision=true."
)
return FSDP2Layout(
mesh=mesh,
dp_process_group=dp_pg,
shard_process_group=shard_pg,
replicate_process_group=replicate_pg,
trainer_process_group=trainer_pg,
mp_policy=mp_policy,
offload_policy=offload_policy,
reshard_after_forward=reshard_after_forward,
)
def _wrap_fsdp(
self,
model: torch.nn.Module,
*,
use_dmuon: bool = False,
) -> torch.nn.Module:
if not hasattr(model, "convert_to_fsdp"):
raise NotImplementedError(
f"Model {model.__class__.__name__} has no convert_to_fsdp method"
)
self._maybe_enable_grad_ckpt(model)
layout = self._build_fsdp2_layout()
self._layout = layout
wrapped = model.convert_to_fsdp(
mesh=layout.mesh,
mp_policy=layout.mp_policy,
offload_policy=layout.offload_policy,
reshard_after_forward=layout.reshard_after_forward,
use_dmuon=use_dmuon,
)
torch.cuda.empty_cache()
return wrapped
def wrap(self, model: torch.nn.Module) -> torch.nn.Module:
return self._wrap_fsdp(model, use_dmuon=False)
def clip_grad_norm(
self,
model: torch.nn.Module,
max_norm: float,
*,
optimizer=None,
) -> torch.Tensor:
del optimizer
self.last_grad_clip_stats = None
# Separate DTensor (FSDP2) from regular tensor params - PyTorch's
# foreach_mul_ in clip_grad_norm_ can't mix them.
try:
from torch.distributed.tensor import DTensor
except ImportError:
DTensor = None
dtensor_params = []
regular_params = []
for p in model.parameters():
if p.grad is None:
continue
if DTensor is not None and isinstance(p, DTensor):
dtensor_params.append(p)
else:
regular_params.append(p)
device = next(iter(p for p in model.parameters())).device
total_sq = torch.tensor(0.0, device=device)
if dtensor_params:
n = torch.nn.utils.clip_grad_norm_(dtensor_params, max_norm)
total_sq = total_sq + n.to(device).pow(2)
if regular_params:
n = torch.nn.utils.clip_grad_norm_(regular_params, max_norm)
total_sq = total_sq + n.to(device).pow(2)
return total_sq.sqrt()
@contextmanager
def no_sync(self, model: torch.nn.Module):
toggled = hasattr(model, "set_requires_gradient_sync")
if toggled:
model.set_requires_gradient_sync(False)
try:
yield
finally:
if toggled:
model.set_requires_gradient_sync(True)
@property
def params_sharded(self) -> bool:
return True
class DMuonFSDPStrategy(FSDPStrategy):
"""FSDP2 plus the DMuon dedicated-parameter runtime."""
@staticmethod
def _runtime():
return import_module("dmuon")
def wrap(self, model: torch.nn.Module) -> torch.nn.Module:
return self._wrap_fsdp(model, use_dmuon=True)
def clip_grad_norm(
self,
model: torch.nn.Module,
max_norm: float,
*,
optimizer=None,
) -> torch.Tensor:
total_norm = super().clip_grad_norm(model, max_norm, optimizer=optimizer)
self.last_grad_clip_stats = None
if optimizer is None:
return total_norm
clip_stats = self._runtime().clip_grad_norm_(optimizer, max_norm)
self.last_grad_clip_stats = clip_stats.as_dict()
device = next(iter(p for p in model.parameters())).device
total_norm_t = (
total_norm.to(device)
if torch.is_tensor(total_norm)
else torch.tensor(float(total_norm), device=device)
)
dedicated_norm_t = torch.tensor(float(clip_stats.total_norm), device=device)
return (total_norm_t.pow(2) + dedicated_norm_t.pow(2)).sqrt()
@contextmanager
def no_sync(self, model: torch.nn.Module):
with self._runtime().no_sync(model):
yield
class DDPStrategy(DistributionStrategy):
def __init__(
self,
config: dict,
*,
device: torch.device,
local_rank: int,
):
self._cfg = config
self._device = device
self._local_rank = local_rank
self.find_unused_parameters = config.get("find_unused_parameters", False)
self.broadcast_buffers = config.get("broadcast_buffers", True)
self.bucket_cap_mb = config.get("bucket_cap_mb", 25)
self.last_grad_clip_stats = None
def wrap(self, model: torch.nn.Module) -> torch.nn.Module:
# Adapter.convert_to_mix_precision_hint already placed the model on
# self._device; this .to() is a defensive no-op (same as the original
# wrap_ddp_model).
model = model.to(self._device)
self._maybe_enable_grad_ckpt(model)
return DDP(
model,
device_ids=[self._local_rank],
output_device=self._local_rank,
find_unused_parameters=self.find_unused_parameters,
broadcast_buffers=self.broadcast_buffers,
bucket_cap_mb=self.bucket_cap_mb,
)
def clip_grad_norm(
self,
model: torch.nn.Module,
max_norm: float,
*,
optimizer=None,
) -> torch.Tensor:
del optimizer
self.last_grad_clip_stats = None
return torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)
@contextmanager
def no_sync(self, model: torch.nn.Module):
with model.no_sync():
yield
@property
def params_sharded(self) -> bool:
return False
def build_strategy(
config: dict,
*,
device: torch.device,
local_rank: int,
use_dmuon: bool = False,
) -> DistributionStrategy:
"""Pick FSDP vs DDP based on ``use_fsdp`` flag.
``use_gradient_checkpointing`` is read from the config dict by
each strategy's ``_maybe_enable_grad_ckpt``; not a separate kwarg here.
"""
if config.get("use_fsdp", False):
if use_dmuon:
return DMuonFSDPStrategy(config)
return FSDPStrategy(config)
if use_dmuon:
raise NotImplementedError(
"DMuon dedicated-parameter training requires "
"distributed.use_fsdp=true in this trainer."
)
return DDPStrategy(
config,
device=device,
local_rank=local_rank,
)
+963
View File
@@ -0,0 +1,963 @@
"""FSDP trainer implementation."""
import contextlib
import dataclasses
import gc
import logging
import os
import time
from typing import Any, Optional, Tuple
import torch
from torch.nn.parallel import DistributedDataParallel as DDP
from wall_x.model.core.action.normalizer import create_normalizers
from wall_x.trainer.adapters import ADAPTER_REGISTRY, format_adapter_error
from wall_x.trainer.fsdp_trainer import checkpoint_io as _ckpt_io
from wall_x.trainer.fsdp_trainer.base_trainer import (
DistributedTrainer,
barrier,
is_main_process,
)
from wall_x.trainer.fsdp_trainer.distribution_strategy import build_strategy
from wall_x.trainer.fsdp_trainer.metrics_logger import MetricsLogger
from wall_x.trainer.optimizer import get_optimizer
from wall_x.trainer.scheduler.scheduler import get_scheduler
from wall_x.trainer.utils import move_batch_to_device
class FSDPTrainer(DistributedTrainer):
"""
FSDP-based Trainer using pure PyTorch FSDP2 (``fully_shard``).
Features:
- No accelerate dependency
- Direct torchrun launch
- Mixed precision training support (bfloat16/float16)
- Gradient accumulation
- Full/Sharded state dict saving
- CPU offload support
- Activation checkpointing
Launch:
torchrun --nproc_per_node=8 --master_port=29500 train_fsdp.py --config config.yaml
"""
def __init__(
self,
train_config,
wandb_run: Optional[Any] = None,
):
if train_config is None:
raise ValueError("train_config is required")
self.model_type = train_config.model_type
super().__init__(train_config, wandb_run)
if self.model_type not in ADAPTER_REGISTRY:
raise ValueError(format_adapter_error(self.model_type))
adapter_cls = ADAPTER_REGISTRY[self.model_type]
self.adapter = adapter_cls(
cfg=self.cfg,
logger=self.logger,
model_type=self.model_type,
)
self.strategy = build_strategy(
dataclasses.asdict(self.cfg.distributed),
device=self.device,
local_rank=self.local_rank,
use_dmuon=self.cfg.hyperparams.optimizer.optimizer_type == "dmuon",
)
# Model config
self.action_dim = self.cfg.action_dim
self.use_selective_recompute = self.cfg.distributed.use_selective_recompute
self.show_time_details = self.cfg.debug.show_time_details
# num_training_steps: read from any scheduler that exposes it
# (CosineSchedulerConfig currently; future schedulers may add it).
# Used in train_loop to trigger loss_guard_should_stop - works with
# constant scheduler too once the field is set.
sched = self.cfg.hyperparams.scheduler
self.num_training_steps = int(getattr(sched, "num_training_steps", 0) or 0)
self.metrics_logger = MetricsLogger(
wandb_run=self.wandb_run,
log_interval=self.log_interval,
smooth_window=self.cfg.logging.loss_log_smooth_window,
)
# Loss tracking
self.base_l1_loss = None
self.base_l1_loss_detail = {}
self.load_normalizer()
self.load_processor()
self.load_model()
# DDP needs to see correct requires_grad at wrap time, so freeze first.
self._freeze_params_if_needed(self.model)
# Frozen-submodule prefixes for checkpoint filtering. Must be computed
# before FSDP wrapping, while child modules still have stable names.
self._frozen_prefixes = self._compute_frozen_prefixes()
# Original shapes captured before FSDP flattening.
self.model._orig_param_shapes = {
name: p.shape for name, p in self.model.named_parameters()
}
if self._resume_from_single_file():
self.load_state_dict(
self.model,
{"ckpt": self.cfg.checkpoint.resume_from},
)
self._wrap_model(self.model)
self._create_optimizer()
self._create_scheduler()
if self._resume_from_training_checkpoint():
self.resume_from_checkpoint()
self.load_dataset()
self.adapter.init_validation(self.normalizer_action, self.normalizer_propri)
def load_normalizer(self):
norm_cfg = self.cfg.data.normalizer_config or {}
custom_stats_path = norm_cfg.get("customized_action_statistic_dof")
if not custom_stats_path and self.cfg.data.dataset_type == "lerobot":
from wall_x.data.backends.lerobot.build import load_lerobot_normalizers
loaded = load_lerobot_normalizers(self.cfg)
if loaded is not None:
self.normalizer_action, self.normalizer_propri = (
loaded[0],
loaded[1],
)
self._action_statistic_dof = None
self.logger.info(
"Loaded LeRobot normalizers from %s with dataset key %s",
loaded[2],
loaded[3],
)
return
merged = {
"dof_config": self.cfg.task.dof_config,
"agent_pos_config": self.cfg.task.agent_pos_config,
"customized_action_statistic_dof": custom_stats_path,
"min_key": norm_cfg.get("min_key", "min"),
"delta_key": norm_cfg.get("delta_key", "delta"),
}
self.normalizer_action, self.normalizer_propri, self._action_statistic_dof = (
create_normalizers(merged)
)
def backward(self, loss: torch.Tensor):
"""Perform backward pass."""
if self.grad_scaler is not None:
self.grad_scaler.scale(loss).backward()
else:
loss.backward()
def clip_grad_norm(self, max_norm: float) -> torch.Tensor:
"""Unscale and clip gradient norm via the active distribution strategy."""
if self.grad_scaler is not None:
self.grad_scaler.unscale_(self.optimizer)
total_norm = self.strategy.clip_grad_norm(
self.model,
max_norm,
optimizer=self.optimizer,
)
self._dedicated_param_grad_clip_stats = getattr(
self.strategy,
"last_grad_clip_stats",
None,
)
return total_norm
def load_model(self):
"""Load and prepare model, optimizer, and scheduler"""
self.model_config = self.adapter.build_model_config()
self.model = self.adapter.create_model(
self.processor, self.tokenizer_mixin, self.model_config
)
type(self.adapter).log_attention_implementation(self.logger, self.model)
self.adapter.load_weights(
self.model,
self.normalizer_action,
self.normalizer_propri,
processor=self.processor,
)
def load_processor(self):
"""Load processor and tokenizers"""
self.adapter.normalizer_action = self.normalizer_action
self.adapter.normalizer_propri = self.normalizer_propri
processors_dict = self.adapter.load_processor(self._action_statistic_dof)
self.processor = processors_dict["processor"]
self.train_action_tokenizer = processors_dict["train_action_tokenizer"]
self.val_action_tokenizer = processors_dict["val_action_tokenizer"]
self.action_mapper = processors_dict["action_mapper"]
self.tokenizer_mixin = processors_dict.get("tokenizer_mixin")
def _freeze_params_if_needed(self, model: torch.nn.Module):
"""Freeze non-action parameters when train_action_expert_only is set.
Must be called BEFORE wrapping with DDP/FSDP so the wrapper sees the
correct requires_grad flags and does not expect gradients for frozen params.
"""
from wall_x.trainer.optimizer.utils import resolve_lr_group_configs
opt = self.cfg.hyperparams.optimizer
if not opt.train_action_expert_only:
return
lr_groups = resolve_lr_group_configs(
opt, self.adapter.default_action_lr_keywords
)
if not lr_groups:
self.log(
"WARNING: train_action_expert_only is True but no optimizer LR "
"group is set. No parameters will be frozen.",
level=logging.WARNING,
)
return
frozen_count = 0
grouped_count = 0
for name, param in model.named_parameters():
if not param.requires_grad:
continue
matches_group = any(
any(keyword in name for keyword in group.include) for group in lr_groups
)
if matches_group:
grouped_count += 1
else:
param.requires_grad = False
frozen_count += 1
if grouped_count == 0:
raise ValueError(
"No grouped params found for train_action_expert_only. "
"Please check optimizer.lr_groups or legacy action_lr_keywords."
)
self.log(
f"*** train_action_expert_only: frozen {frozen_count} base params, "
f"keeping {grouped_count} grouped params trainable ***"
)
def _wrap_model(self, model: torch.nn.Module):
"""Prepare model dtype/placement via adapter, then wrap via strategy.
If the strategy created an explicit trainer process group, register
it so trainer-level barriers and metric collectives keep all-rank
semantics independent of the FSDP mesh topology.
"""
self.adapter.convert_to_mix_precision_hint(
model,
device=self.device,
use_fsdp=self.cfg.distributed.use_fsdp,
log_fn=self.log,
)
self.model = self.strategy.wrap(model)
trainer_pg = getattr(self.strategy, "trainer_process_group", None)
if trainer_pg is not None:
from wall_x.trainer.fsdp_trainer.base_trainer import (
set_trainer_process_group,
)
set_trainer_process_group(trainer_pg)
self.log("[FSDP2] routing trainer collectives through trainer PG")
def _create_optimizer(self):
"""Create optimizer for FSDP wrapped model."""
from wall_x.trainer.optimizer.utils import (
build_lr_param_groups,
resolve_lr_group_configs,
uses_legacy_action_lr_groups,
)
opt_cfg = self.cfg.hyperparams.optimizer
param_groups = None
lr_group_configs = resolve_lr_group_configs(
opt_cfg, self.adapter.default_action_lr_keywords
)
if lr_group_configs:
if opt_cfg.optimizer_type not in ("adamw", "muon", "dmuon"):
raise ValueError(
"optimizer.lr_groups are only supported with adamw, muon, "
"or dmuon"
)
base_group_name = (
"base_lr_group" if uses_legacy_action_lr_groups(opt_cfg) else "base"
)
param_groups = build_lr_param_groups(
self.model,
opt_cfg,
lr_group_configs,
base_group_name=base_group_name,
)
summary = ", ".join(
f"{group.name}={group.lr} include={group.include}"
for group in lr_group_configs
)
self.log(
f"setting optimizer LR groups: base={opt_cfg.learning_rate}; "
f"{summary} ({opt_cfg.optimizer_type})"
)
optimizer_kwargs = {
"opt_cfg": opt_cfg,
"param_groups": param_groups,
}
if opt_cfg.optimizer_type == "dmuon":
optimizer_kwargs["log_fn"] = self.log
self.optimizer = get_optimizer(
opt_cfg.optimizer_type,
self.model,
**optimizer_kwargs,
)
def _create_scheduler(self):
"""Create learning rate scheduler"""
from wall_x.config.hyperparams_config import (
ConstantSchedulerConfig,
CosineSchedulerConfig,
)
sched = self.cfg.hyperparams.scheduler
lr = self.cfg.hyperparams.optimizer.learning_rate
if isinstance(sched, ConstantSchedulerConfig):
self.lr_scheduler = torch.optim.lr_scheduler.ConstantLR(
self.optimizer, factor=1.0, total_iters=0
)
elif isinstance(sched, CosineSchedulerConfig):
if sched.num_training_steps <= 0:
raise ValueError(
"num_training_steps must be > 0 for cosine scheduler. "
"Please set it explicitly in the config."
)
min_lr = sched.min_lr if sched.min_lr is not None else 0.1 * lr
self.lr_scheduler = get_scheduler(
optimizer=self.optimizer,
lr_scheduler_type="cosine",
num_warmup_steps=sched.num_warmup_steps,
num_training_steps=sched.num_training_steps,
peak_lr=lr,
end_lr=min_lr,
)
else:
raise ValueError(f"Unsupported scheduler type: {type(sched).__name__}")
def load_dataset(self):
"""Load training and validation datasets"""
torch.cuda.empty_cache()
barrier()
# Dispatch to adapter with unified signature (supports model-specific kwargs)
extra_dataset_kwargs = dict(
model_config=self.model_config,
tokenizer_mixin=self.tokenizer_mixin,
normalizer_action=self.normalizer_action,
normalizer_propri=self.normalizer_propri,
)
resume_path = self.cfg.checkpoint.resume_from
if resume_path is not None and os.path.isdir(resume_path):
# Step and epoch restore are generic. Backend-owned episode
# metadata is optional and only loaded when the adapter exposes
# the hook.
is_incomplete_epoch = False
if hasattr(self.adapter, "_load_episode_indices"):
resume_indices = self.adapter._load_episode_indices(
resume_path, self.rank
)
if resume_indices is not None:
is_incomplete_epoch = resume_indices["is_incomplete_epoch"]
extra_dataset_kwargs["resume_indices"] = resume_indices
train_state = self.adapter.load_step_and_epoch(
resume_path, is_incomplete_epoch
)
self.global_step = train_state["global_step"]
self.start_epoch = train_state["start_epoch"]
self.log(
f"global_step: {self.global_step}, start_epoch: "
f"{self.start_epoch} from {resume_path}"
)
# global_step records completed steps, so resume from the next step
# to avoid triggering _should_save_checkpoint() immediately.
self.global_step += 1
(
self.dataset,
self.train_dataloader,
self.train_num,
) = self.adapter.load_dataset(
self.data_config,
self.processor,
self.rank,
self.world_size,
**extra_dataset_kwargs,
)
def train_loop(self, epoch: int, profiler=contextlib.nullcontext()):
"""Execute training for a single epoch"""
self.model.train()
# VGDynamicRobotDataset (v1 path) doesn't expose set_epoch; other
# dataset backends (v2, lerobot) do. Dispatch conditionally.
if hasattr(self.dataset, "set_epoch"):
self.dataset.set_epoch(epoch)
total = len(self.train_dataloader)
# Drop last
stop_step = total - total % self.grad_accum_steps
# Disable automatic GC to prevent random ~200ms stalls during compute
gc_interval = self.cfg.logging.gc_interval_steps
gc.disable()
t0 = time.time()
with profiler:
self.timers("interval-time", log_level=0).start(barrier=True)
self.timers("data-load", log_level=0).start(barrier=True)
for i, batch in enumerate(self.train_dataloader, self.initial_step):
self.timers("data-load").stop()
# Save first batch for offline profiling (rank 0 only)
debug_batch_path = self.cfg.debug.save_debug_batch_path
if debug_batch_path and i == self.initial_step and self.rank == 0:
os.makedirs(os.path.dirname(debug_batch_path) or ".", exist_ok=True)
torch.save({"batch": batch}, debug_batch_path)
self.log(f"Saved debug batch to {debug_batch_path}")
# Move batch to device
batch = move_batch_to_device(batch, self.device)
if self.cfg.debug.enable_mfu_profile and self.global_step == 0:
self.log(
"[MFU] Forward FLOPs profiling is not available in the public package.",
level=logging.INFO,
)
# Forward pass with autocast
self.timers("forward-compute", log_level=0).start(barrier=False)
with self.autocast_context():
outputs = self.adapter.forward(
self.model, batch, global_step=self.global_step
)
self.timers("forward-compute").stop()
loss = self.adapter.extract_loss(outputs)
# Check for NaN loss -- replace with 0 instead of `continue`
# to avoid skipping collective ops (backward, all_reduce, etc.)
# which would cause NCCL deadlock across ranks.
nan_loss = torch.isnan(loss)
if nan_loss:
self.log(
f"Warning: nan in loss at epoch: {epoch}, step: {i}",
level=logging.WARNING,
)
loss = torch.zeros_like(loss)
# Backward pass
self.timers("backward-compute", log_level=0).start(barrier=True)
context = (
contextlib.nullcontext()
if self.sync_gradients()
else self.strategy.no_sync(self.model)
)
with context:
scaled_loss = loss / self.grad_accum_steps
self.backward(scaled_loss)
self.timers("backward-compute").stop()
# Gradient sync and optimizer step
if self.sync_gradients():
self.timers("optimizer", log_level=0).start(barrier=True)
# Per-component grad norms (before clipping)
self.timers("optimizer-grad-norms", log_level=0).start(
barrier=False
)
self._component_grad_norms = {}
self.adapter.collect_grad_norms(
self.model,
self._component_grad_norms,
device=self.device,
reduce_tensor_fn=self.reduce_tensor,
params_sharded=self.strategy.params_sharded,
)
self.timers("optimizer-grad-norms").stop()
# Clip gradients
self.timers("optimizer-clip", log_level=0).start(barrier=False)
if self.cfg.hyperparams.optimizer.enable_grad_clip:
total_norm = self.clip_grad_norm(self.max_grad_norm)
else:
self._dedicated_param_grad_clip_stats = None
total_norm = 0
self.timers("optimizer-clip").stop()
self.timers("optimizer-step", log_level=0).start(barrier=False)
self.optimizer_step()
self.timers("optimizer-step").stop()
self.timers("optimizer-zero-grad", log_level=0).start(barrier=False)
self.optimizer_zero_grad()
self.timers("optimizer-zero-grad").stop()
self.timers("optimizer").stop()
# Scheduler step
self.lr_scheduler_step()
# Logging
self.timers("logging", log_level=0).start(barrier=True)
_t_metrics_start = time.time()
self._log_training_metrics(
epoch, i, total, loss, total_norm, outputs
)
_t_metrics_ms = (time.time() - _t_metrics_start) * 1000
t1 = time.time()
self.training_log(
epoch,
self.num_epoch,
i,
total,
loss,
self.get_lr(),
t1 - t0,
self.show_time_details,
)
self.timers("logging").stop()
t0 = time.time()
# Optional: log per-step breakdown to pinpoint spikes (e.g. param_norms every 100 steps)
if (
self.show_time_details
and _t_metrics_ms > 5000
and is_main_process()
):
self.log(
f"[Step time breakdown] _log_training_metrics took {_t_metrics_ms:.0f} ms at global_step={self.global_step}",
level=logging.INFO,
)
# Checkpoint saving (FSDP full state_dict can take 10-20s every save_interval steps)
if self._should_save_checkpoint():
_t_save_start = time.time()
self.save_checkpoint(epoch, self.global_step)
if is_main_process():
self.log(
f"[Step time breakdown] save_checkpoint took {(time.time() - _t_save_start):.1f} s at global_step={self.global_step}",
level=logging.INFO,
)
# Validation
if self._should_validate():
self.val_loop()
self.global_step += 1
self.micro_step = 0
# Manual GC outside timing window to avoid random stalls (can add 1-5s every gc_interval_steps)
if self.global_step % gc_interval == 0:
_t_gc_start = time.time()
gc.collect()
if is_main_process() and self.show_time_details:
self.log(
f"[Step time breakdown] gc.collect took {(time.time() - _t_gc_start):.1f} s at global_step={self.global_step}",
level=logging.INFO,
)
else:
self.micro_step += 1
del batch
self.timers("interval-time").stop()
# Drop last
if i == stop_step:
break
if not isinstance(profiler, contextlib.nullcontext):
profiler.step()
if (
self.num_training_steps > 0
and self.global_step >= self.num_training_steps
):
break
# Setup timers for next iteration
if i < total - 1:
self.timers("interval-time", log_level=0).start(barrier=True)
self.timers("data-load", log_level=0).start(barrier=True)
# Re-enable automatic GC after training loop
gc.enable()
# Reset dataloader for next epoch
self.train_dataloader = self.dataset.get_train_dataloader()
def _log_training_metrics(self, epoch, step, total, loss, total_norm, outputs):
"""Collect per-step stats, delegate buffering + wandb emission."""
lr = self.get_lr()
train_loss = self.reduce_tensor(loss.detach()).item()
step_stats = {
"lr": lr,
"train_loss": train_loss,
"grad_norm": (
total_norm.item() if torch.is_tensor(total_norm) else float(total_norm)
),
}
for idx, group in enumerate(self.optimizer.param_groups):
group_name = group.get("group_name", f"group_{idx}")
step_stats[f"lr_group/{group_name}"] = float(group["lr"])
# Model-family-specific auxiliary losses / accuracies.
self.adapter.collect_output_stats(
outputs,
step_stats,
reduce_tensor_fn=self.reduce_tensor,
true_gather_fn=self.true_gather,
tokenizer_mixin=self.tokenizer_mixin,
)
# Per-component grad norms (captured before clip & optimizer.zero_grad).
if hasattr(self, "_component_grad_norms"):
step_stats.update(self._component_grad_norms)
dedicated_clip_stats = getattr(self, "_dedicated_param_grad_clip_stats", None)
if dedicated_clip_stats is not None:
step_stats.update(
{
"muon_grad_norm": dedicated_clip_stats["total_norm"],
"muon_grad_clip_coef": dedicated_clip_stats["clip_coef"],
"muon_grad_clipped": float(dedicated_clip_stats["clipped"]),
}
)
if self.global_step % 100 == 0:
self.adapter.collect_param_norms(
self.model,
step_stats,
device=self.device,
reduce_tensor_fn=self.reduce_tensor,
params_sharded=self.strategy.params_sharded,
)
self._current_step_raw_stats = step_stats
# Display-smoothing rolling window (DZ-style)
self._current_step_stats = self.metrics_logger.smooth(step_stats)
def training_log(
self,
current_epoch,
total_epoch,
current_train_iter,
total_train_iter,
loss,
lr,
time_per_step,
show_time_details=False,
):
# timers.log() contains all_gather - must run on ALL ranks before the
# is_main_process() guard to avoid NCCL deadlock.
if show_time_details:
self.timers.log(
[
"interval-time",
"data-load",
"forward-compute",
"backward-compute",
"optimizer",
"optimizer-grad-norms",
"optimizer-clip",
"optimizer-step",
"optimizer-zero-grad",
"logging",
],
normalizer=1,
)
main = is_main_process()
stats = getattr(self, "_current_step_stats", None) or {}
# MFU is computed after step time is known, then merged into both the
# console stats and the MetricsLogger buffer so wandb records it.
mfu_info = None
if self.cfg.debug.enable_mfu and hasattr(self.adapter, "compute_mfu"):
unwrapped = (
self.model.module if hasattr(self.model, "module") else self.model
)
mfu_info = self.adapter.compute_mfu(unwrapped, time_per_step)
if mfu_info is not None:
self.log(
"[MFU-rank] rank={} mfu={:.2f}% step={:.3f}s "
"flops_step={:.3f}T flops_fwd={:.3f}T "
"seq={} latent={} source={}".format(
getattr(self, "rank", 0),
mfu_info["mfu"] * 100.0,
time_per_step,
mfu_info["flops_per_step_T"],
mfu_info.get("flops_forward_T", 0.0) or 0.0,
mfu_info.get("seq_dims"),
mfu_info.get("latent_dims"),
mfu_info.get("latent_source"),
),
main_process_only=False,
)
if main and mfu_info is not None and stats is not None:
mfu_stats = {
"mfu": mfu_info["mfu"],
"flops_per_step_T": mfu_info["flops_per_step_T"],
"flops_forward_T": mfu_info.get("flops_forward_T"),
}
profile_fwd_flops = mfu_info.get("profile_fwd_flops_T")
if profile_fwd_flops is not None:
mfu_stats["profile_fwd_flops_T"] = profile_fwd_flops
stats.update(mfu_stats)
raw_stats = getattr(self, "_current_step_raw_stats", None)
if raw_stats is not None:
raw_stats.update(mfu_stats)
if main:
raw_stats = getattr(self, "_current_step_raw_stats", None)
if raw_stats is not None:
self.metrics_logger.record_step(raw_stats, is_main=True)
avg_stats = self.metrics_logger.flush_if_due(self.global_step)
if avg_stats is not None:
self.logger.info(
f"[FSDP Train] Step {self.global_step}: {avg_stats}"
)
if not main:
return
loss_to_print = stats.get(
"train_loss", loss.item() if torch.is_tensor(loss) else float(loss)
)
fields = self.adapter.console_fields(tokenizer_mixin=self.tokenizer_mixin)
line = self.metrics_logger.format_training_line(
epoch=current_epoch,
total_epoch=total_epoch,
current_iter=current_train_iter,
total_iter=total_train_iter,
loss=loss_to_print,
lr=lr,
time_per_step=time_per_step,
stats=stats,
fields=fields,
mfu_info=mfu_info,
)
self.log(line)
def _should_save_checkpoint(self) -> bool:
"""Check if checkpoint should be saved"""
log = self.cfg.logging
return (
self.global_step >= log.ignore_until_interval
and self.global_step % log.save_interval == 0
and self.global_step != 0
)
def _should_validate(self) -> bool:
"""Check if validation should be performed"""
log = self.cfg.logging
return (
self.global_step >= log.ignore_until_interval
and self.global_step % log.val_interval == 0
and self.global_step > 0
)
@torch.no_grad()
def val_loop(self):
"""Delegate the full validation run to the adapter.
Skips silently when the dataset has no val split - adapter's
run_validation iterates ``val_dataloader`` with ``tqdm(...,
total=len(...))`` and cannot accept None. v2 returns None here
when the YAML only declares a train split.
"""
self.val_dataloader = self.dataset.get_val_dataloader()
if self.val_dataloader is None:
if is_main_process():
self.logger.info("No val split configured, skipping validation.")
return
save_path = self.cfg.checkpoint.save_path
self.adapter.run_validation(
model=self.model,
val_dataloader=self.val_dataloader,
rank=self.rank,
world_size=self.world_size,
device=self.device,
autocast_context=self.autocast_context,
reduce_fn=self.reduce_tensor,
gather_fn=self.true_gather,
logger=self.wandb_run if is_main_process() else None,
global_step=self.global_step,
output_path=os.path.join(save_path, f"val_rank_{self.rank}"),
tokenizer_mixin=self.tokenizer_mixin,
)
barrier()
def _compute_frozen_prefixes(self) -> Optional[Tuple[str, ...]]:
"""Return state-dict key prefixes for submodules that are fully frozen.
For models with a ``pipe`` container, any child not present in
``cfg.model.trainable_models`` is treated as frozen. The matching key
prefixes let checkpoint_io drop those entries from the saved file.
Empty / missing ``trainable_models`` disables filtering and preserves
"save everything" behavior.
"""
if not hasattr(self.model, "pipe"):
return None
trainable_models = getattr(self.cfg.model, "trainable_models", None)
if trainable_models is None:
return None
if isinstance(trainable_models, str):
trainable_set = {
s.strip() for s in trainable_models.split(",") if s.strip()
}
else:
trainable_set = set(trainable_models)
if not trainable_set:
self.log(
"WARNING: trainable_models is empty; not filtering frozen "
"entries from checkpoint to avoid saving an empty file."
)
return None
frozen_prefixes = tuple(
f"pipe.{name}."
for name, _ in self.model.pipe.named_children()
if name not in trainable_set
)
if frozen_prefixes:
self.log(
f"Frozen submodule prefixes excluded from checkpoints: "
f"{list(frozen_prefixes)}"
)
return frozen_prefixes
return None
def save_checkpoint(self, epoch: int, step: int = 0):
"""Save model checkpoint via checkpoint_io (dispatches on model wrapper type)."""
save_path = self.cfg.checkpoint.save_path
ckpt_path = f"{save_path}/{epoch}_{step}" if step else f"{save_path}/{epoch}"
_ckpt_io.save_checkpoint(
ckpt_path=ckpt_path,
model=self.model,
optimizer=self.optimizer,
lr_scheduler=self.lr_scheduler,
config=dataclasses.asdict(self.cfg),
rank=self.rank,
is_main=is_main_process(),
epoch=epoch,
global_step=self.global_step,
seed=self.seed,
normalizer_action=self.normalizer_action,
normalizer_propri=self.normalizer_propri,
dataset=getattr(self, "dataset", None) if step != 0 else None,
grad_scaler=self.grad_scaler,
log_fn=self.log,
frozen_prefixes=self._frozen_prefixes,
)
_ckpt_io.finalize_save()
self.log(f"Saved checkpoint to {ckpt_path}")
def load_state_dict(self, model, resume_config):
"""Load state dict with fused-weight conversion + try_harder support."""
return _ckpt_io.load_weights(
model=model,
resume_config=resume_config,
model_class=self._checkpoint_model_class(),
log_fn=self.log,
)
def _checkpoint_model_class(self):
model_class = getattr(self, "ModelClass", None)
if model_class is None and hasattr(type(self.adapter), "model_class"):
model_class = type(self.adapter).model_class()
return model_class
def _resume_from_single_file(self) -> bool:
path = self.cfg.checkpoint.resume_from
return bool(path) and str(path).endswith((".safetensors", ".pth"))
def _resume_from_training_checkpoint(self) -> bool:
path = self.cfg.checkpoint.resume_from
return bool(path) and not self._resume_from_single_file()
def resume_from_checkpoint(self):
"""Resume training from checkpoint via checkpoint_io."""
_ckpt_io.resume_from_checkpoint(
model=self.model,
optimizer=self.optimizer,
lr_scheduler=self.lr_scheduler,
resume_config={"ckpt": self.cfg.checkpoint.resume_from},
rank=self.rank,
grad_scaler=self.grad_scaler,
model_class=self._checkpoint_model_class(),
log_fn=self.log,
)
barrier()
def predict_action_loop(
self,
current_step=0,
max_iteration=None,
prediction_type="flow_action",
mode="acc",
):
"""Delegate action prediction to the adapter."""
del mode # unused in current dispatch; kept for signature compatibility
return self.adapter.predict(
prediction_type,
model=self._unwrapped_model_for_inference(),
val_dataloader=self.dataset.get_val_dataloader(),
rank=self.rank,
world_size=self.world_size,
device=self.device,
processor=self.processor,
tokenizer_mixin=self.tokenizer_mixin,
logger=self.wandb_run,
current_step=current_step,
max_iteration=max_iteration,
)
def predict_text_loop(self, current_step=0, max_samples=None, save_dir="./"):
"""Delegate text prediction to the adapter."""
return self.adapter.predict(
"text",
model=self._unwrapped_model_for_inference(),
val_dataloader=self.dataset.get_val_dataloader(),
rank=self.rank,
world_size=self.world_size,
device=self.device,
processor=self.processor,
tokenizer_mixin=self.tokenizer_mixin,
logger=self.wandb_run,
current_step=current_step,
max_samples=max_samples,
save_dir=save_dir,
)
def _unwrapped_model_for_inference(self):
"""Return the underlying module for inference: unwrap DDP, pass FSDP through."""
if isinstance(self.model, DDP):
return self.model.module
return self.model
@@ -0,0 +1,3 @@
from wall_x.trainer.fsdp_trainer.metrics.norms import DistributedNormAccumulator
__all__ = ["DistributedNormAccumulator"]
@@ -0,0 +1,123 @@
from __future__ import annotations
from collections import defaultdict
from typing import Callable, DefaultDict, Optional
import torch
from torch.distributed.tensor import DTensor, Replicate
class DistributedNormAccumulator:
"""Accumulate L2 norms without leaking parallel-layout branches to adapters."""
def __init__(
self,
*,
device: torch.device,
reduce_tensor_fn: Optional[Callable[[torch.Tensor, bool], torch.Tensor]],
):
self.device = device
self.reduce_tensor_fn = reduce_tensor_fn
self._regular: DefaultDict[str, list[torch.Tensor]] = defaultdict(list)
self._dtensor: DefaultDict[str, list[torch.Tensor]] = defaultdict(list)
self._owner_only: DefaultDict[str, list[torch.Tensor]] = defaultdict(list)
@staticmethod
def _is_dtensor(tensor: torch.Tensor) -> bool:
return isinstance(tensor, DTensor)
def add(self, group: Optional[str], tensor: Optional[torch.Tensor]) -> None:
if group is None or tensor is None or tensor.numel() == 0:
return
tensor = tensor.detach()
if self._is_dtensor(tensor):
self._dtensor[group].append(tensor)
else:
self._regular[group].append(tensor)
def add_owner_only(
self, group: Optional[str], tensor: Optional[torch.Tensor]
) -> None:
if group is None or tensor is None or tensor.numel() == 0:
return
self._owner_only[group].append(tensor.detach())
def squared_norm(self, group: str) -> torch.Tensor:
with torch.no_grad():
total = torch.zeros((), dtype=torch.float32, device=self.device)
total = total + self._regular_squared_norm(self._regular[group])
total = total + self._owner_only_squared_norm(self._owner_only[group])
total = total + self._dtensor_squared_norm(self._dtensor[group])
return total
def norm(self, group: str) -> torch.Tensor:
return self.squared_norm(group).sqrt()
def _regular_squared_norm(self, tensors: list[torch.Tensor]) -> torch.Tensor:
if not tensors:
return torch.zeros((), dtype=torch.float32, device=self.device)
per = self._foreach_norm(tensors)
return self._sum_norms_squared(per)
def _owner_only_squared_norm(self, tensors: list[torch.Tensor]) -> torch.Tensor:
local_sq = self._regular_squared_norm(tensors)
if self.reduce_tensor_fn is None:
return local_sq
return self.reduce_tensor_fn(local_sq, average=False)
def _dtensor_squared_norm(self, tensors: list[torch.Tensor]) -> torch.Tensor:
if not tensors:
return torch.zeros((), dtype=torch.float32, device=self.device)
per = self._foreach_norm(tensors)
# Group by (mesh, placements) so each unique layout costs only one
# collective (redistribute).
groups: DefaultDict[object, list[torch.Tensor]] = defaultdict(list)
for norm in per:
if self._is_dtensor(norm):
key = (id(norm.device_mesh), tuple(norm.placements))
else:
key = None
groups[key].append(norm)
parts: list[torch.Tensor] = []
for group_tensors in groups.values():
try:
stacked = torch.stack(group_tensors)
local = self._to_local_replicated(stacked).to(
device=self.device, dtype=torch.float32
)
parts.append(local.pow(2).sum())
except (RuntimeError, NotImplementedError):
# Stack/redistribute not supported for this layout in the
# current PyTorch version; fall back to per-tensor for safety.
for norm in group_tensors:
local = self._to_local_replicated(norm).to(
device=self.device, dtype=torch.float32
)
parts.append(local.pow(2).sum())
return torch.stack(parts).sum()
def _foreach_norm(self, tensors: list[torch.Tensor]) -> list[torch.Tensor]:
try:
return torch._foreach_norm(tensors, 2.0)
except (RuntimeError, NotImplementedError):
return [torch.linalg.vector_norm(t, 2.0) for t in tensors]
def _sum_norms_squared(self, norms: list[torch.Tensor]) -> torch.Tensor:
if not norms:
return torch.zeros((), dtype=torch.float32, device=self.device)
local_norms = [
self._to_local_replicated(norm).to(device=self.device, dtype=torch.float32)
for norm in norms
]
return torch.stack(local_norms).pow(2).sum()
def _to_local_replicated(self, tensor: torch.Tensor) -> torch.Tensor:
if not self._is_dtensor(tensor):
return tensor.to(device=self.device)
mesh = tensor.device_mesh
tensor = tensor.redistribute(placements=[Replicate()] * mesh.ndim)
return tensor.to_local().to(device=self.device)
@@ -0,0 +1,111 @@
"""Training metric buffering and wandb emission helpers."""
from __future__ import annotations
from collections import deque
from typing import Any, Deque, Dict, List, Optional, Tuple
class MetricsLogger:
"""Buffer per-step stats and emit averaged batches to wandb.
Also formats the per-step console line via ``format_training_line``,
which consumes an ``(stat_key, pretty_label, fmt_spec)`` triple list
from ``adapter.console_fields()``.
``smooth_window`` controls a separate rolling-mean buffer used purely
for display smoothing (console + tqdm). It is independent of the
``log_interval`` wandb-flush buffer; the smoothed dict is returned by
:meth:`smooth` for the caller to assign to ``_current_step_stats``.
1 (default) preserves historical per-step values.
"""
def __init__(self, *, wandb_run, log_interval: int, smooth_window: int = 1):
self._wandb_run = wandb_run
self._log_interval = log_interval
self._buffer: List[Dict[str, Any]] = []
self._smooth_window = max(1, int(smooth_window))
self._smooth_buffers: Dict[str, Deque[float]] = {}
def record_step(self, step_stats: Dict[str, Any], *, is_main: bool) -> None:
"""Push a per-step stats dict into the rank-0 buffer (no-op off rank-0)."""
if is_main:
self._buffer.append(step_stats)
def flush_if_due(self, global_step: int) -> Optional[Dict[str, Any]]:
"""At log_interval boundaries, average + emit; return avg dict or None."""
if global_step % self._log_interval != 0 or not self._buffer:
return None
avg = self._average(self._buffer)
if self._wandb_run is not None and hasattr(self._wandb_run, "log"):
self._wandb_run.log(avg, step=global_step)
self._buffer = []
return avg
def smooth(self, stats: Dict[str, Any]) -> Dict[str, Any]:
"""Return *stats* with numeric entries replaced by their rolling mean.
Uses one ``deque(maxlen=smooth_window)`` per metric key. Non-numeric
entries pass through unchanged. When ``smooth_window <= 1`` this is
the identity (historical behavior).
"""
if self._smooth_window <= 1:
return stats
out: Dict[str, Any] = {}
for key, val in stats.items():
if not isinstance(val, (int, float)):
out[key] = val
continue
buf = self._smooth_buffers.get(key)
if buf is None or buf.maxlen != self._smooth_window:
buf = deque(maxlen=self._smooth_window)
self._smooth_buffers[key] = buf
buf.append(float(val))
out[key] = sum(buf) / len(buf)
return out
@staticmethod
def _average(buf: List[Dict[str, Any]]) -> Dict[str, Any]:
all_keys: set = set()
for stats in buf:
all_keys.update(stats.keys())
avg: Dict[str, Any] = {}
for key in all_keys:
values = [s[key] for s in buf if key in s]
if values:
avg[key] = sum(values) / len(values)
return avg
def format_training_line(
self,
*,
epoch: int,
total_epoch: int,
current_iter: int,
total_iter: int,
loss: float,
lr: float,
time_per_step: float,
stats: Dict[str, Any],
fields: List[Tuple[str, str, str]],
mfu_info: Optional[Dict[str, Any]] = None,
) -> str:
"""Render a line matching pre-refactor training_log output exactly.
Layout: " epoch E/T | iter I/T | loss L | [adapter fields] | lr L |
time_current_backward_step Ts | [MFU P% |]"
"""
parts = [
" epoch {:3d}/{:3d} |".format(epoch, total_epoch),
" iter {:6d}/{:6d} |".format(current_iter, total_iter),
" loss {:.6f} |".format(loss),
]
for key, label, fmt in fields:
value = stats.get(key)
if value is not None:
parts.append(" {} {:{fmt}} |".format(label, value, fmt=fmt))
parts.append(" lr {:.6f} |".format(lr))
parts.append(" time_current_backward_step {:.6f}s |".format(time_per_step))
if mfu_info is not None:
parts.append(" MFU {:.2f}% |".format(mfu_info["mfu"] * 100))
return "".join(parts)
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
FSDP Training Entry Point
Launch with torchrun:
torchrun --nproc_per_node=8 --master_port=29500 train_fsdp.py --config config.yaml
"""
import argparse
import dataclasses
import logging
import os
import sys
from datetime import datetime
import torch
import wandb
from wall_x.config.loader import load_config as load_typed_config
from wall_x.trainer.fsdp_trainer import (
FSDPTrainer,
cleanup_distributed,
is_main_process,
)
logger = logging.getLogger(__name__)
class TeeOutput:
"""Write output to both the terminal and a file."""
def __init__(self, file_path, mode="w"):
self.terminal = sys.stdout
self.log = open(file_path, mode)
def write(self, message):
self.terminal.write(message)
self.log.write(message)
self.log.flush()
def flush(self):
self.terminal.flush()
self.log.flush()
def parse_args():
parser = argparse.ArgumentParser(description="FSDP Training Script")
parser.add_argument(
"--config",
type=str,
required=True,
help="Path to training config YAML file",
)
# FSDP specific overrides
parser.add_argument(
"--fsdp_sharding_strategy",
type=str,
default=None,
choices=["full_shard", "shard_grad_op", "no_shard", "hybrid_shard"],
help="FSDP sharding strategy (overrides config)",
)
parser.add_argument(
"--debug",
action="store_true",
default=False,
help="Enable debug mode: reduces buffer size, sets log/save path to debug.",
)
parser.add_argument(
"--wandb_offline",
type=str,
default=None,
help="Whether to run wandb in offline mode (overrides config).",
)
parser.add_argument(
"--visualize",
action="store_true",
default=False,
help="Whether to visualize samples during training.",
)
parser.add_argument(
"--log_to_file",
action="store_true",
default=False,
help="Whether to redirect stdout and stderr to log files.",
)
return parser.parse_args()
def load_config(config_path: str, cli_args=None):
"""Load configuration from YAML file into TrainConfig."""
return load_typed_config(config_path, cli_args=cli_args)
def setup_logger(cfg):
"""Setup wandb logger if enabled"""
log = cfg.logging
if log.use_wandb and is_main_process():
logger.info(
"rank %s is initializing wandb , is main process %s",
torch.distributed.get_rank(),
is_main_process(),
)
wandb_run = wandb.init(
project=log.log_project,
name=log.log_name,
entity=log.log_entity,
config=dataclasses.asdict(cfg),
save_code=False,
force=False,
mode="offline" if log.wandb_offline else "online",
)
logger.info("Wandb Initialized")
return wandb_run
return None
def print_fsdp_config(cfg):
"""Print FSDP configuration"""
if is_main_process():
dist = cfg.distributed
logger.info("%s", "=" * 60)
logger.info("FSDP Configuration:")
logger.info(" use_fsdp: %s", dist.use_fsdp)
logger.info("%s", "=" * 60)
def main():
args = parse_args()
# Redirect logs to files only on the main process to avoid write races.
if args.log_to_file:
import yaml as _yaml
with open(args.config, "r") as f:
_tmp = _yaml.safe_load(f)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# support both old (save_path) and new (checkpoint.save_path) schema
log_dir = _tmp.get("save_path") or _tmp.get("checkpoint", {}).get(
"save_path", "./ckpt"
)
if args.debug:
log_dir = "./ckpt/debug"
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, f"training_log_{timestamp}.log")
sys.stdout = TeeOutput(log_file, mode="w")
sys.stderr = TeeOutput(log_file.replace(".log", "_stderr.log"), mode="w")
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)
logger.info("\n%s", "=" * 80)
logger.info("LOG TO FILE MODE: All output will be saved to:")
logger.info(" STDOUT: %s", log_file)
logger.info(" STDERR: %s", log_file.replace(".log", "_stderr.log"))
logger.info("%s\n", "=" * 80)
else:
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)
torch.cuda.init()
local_rank = int(os.environ.get("LOCAL_RANK", 0))
device = torch.device(f"cuda:{local_rank}")
torch.cuda.set_device(device)
torch.distributed.init_process_group("nccl", device_id=device)
cfg = load_config(args.config, cli_args=args)
wandb_run = setup_logger(cfg)
print_fsdp_config(cfg)
try:
trainer = FSDPTrainer(
train_config=cfg,
wandb_run=wandb_run,
)
trainer.fit()
except Exception as e:
logger.exception("Training failed with error: %s", e)
raise
finally:
cleanup_distributed()
if wandb_run is not None:
wandb_run.finish()
if __name__ == "__main__":
main()