From 1f919bec8a50c2555069225b99c9349f8e45fd52 Mon Sep 17 00:00:00 2001 From: daixu Date: Fri, 21 Aug 2026 22:46:05 +0000 Subject: [PATCH 01/10] [Test] Add fake-PG L0 placement tests pinning the legacy MoE EP/FSDP layout Single-process fake ProcessGroup tests asserting the mesh ranks and DTensor placements that MoE.fully_shard produces today for 8/16/64 ranks, plus the Phase-0 baseline snapshot and the decision log for the EP/FSDP decoupling work. --- tests/model/test_decoupled_ep_fsdp_mesh.py | 233 +++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 tests/model/test_decoupled_ep_fsdp_mesh.py diff --git a/tests/model/test_decoupled_ep_fsdp_mesh.py b/tests/model/test_decoupled_ep_fsdp_mesh.py new file mode 100644 index 0000000000..8fd9d3d67c --- /dev/null +++ b/tests/model/test_decoupled_ep_fsdp_mesh.py @@ -0,0 +1,233 @@ +"""L0 placement tests for the MoE EP/FSDP mesh layouts. + +These tests run in a single process on top of a fake ``ProcessGroup`` so that +8/16/64-rank layouts can be asserted without any multi-process launch. The +model stays on the ``meta`` device; only the mesh bookkeeping and the DTensor +placements produced by ``MoE.fully_shard`` are inspected. +""" + +from collections.abc import Iterator +from contextlib import contextmanager + +import pytest +import torch +from pydantic import ValidationError +import torch.distributed as dist +from torch.distributed.device_mesh import DeviceMesh, _mesh_resources +from torch.distributed.tensor import DTensor, Replicate, Shard +from torch.distributed.tensor.placement_types import _StridedShard +from torch.testing._internal.distributed.fake_pg import FakeStore + +from xtuner.v1.config import FSDPConfig +from xtuner.v1.model.base import BaseModel +from xtuner.v1.model.moe.moe import MoE, MoEConfig +from xtuner.v1.module.attention import MHAConfig +from xtuner.v1.module.router import NoAuxRouterConfig + + +PREFIX = "l0mesh" +HIDDEN = 128 +N_EXPERTS = 64 +MOE_INTER = 128 + +DENSE_PARAMS = ( + "embed_tokens.weight", + "layers.0.mlp.gate_proj.weight", + "layers.1.self_attn.q_proj.weight", + "layers.1.gate.weight", + "layers.1.shared_experts.gate_proj.weight", + "layers.1.input_layernorm.weight", + "norm.weight", + "lm_head.weight", +) +EXPERT_PARAMS = ( + "layers.1.experts.fused_w1w3.weight", + "layers.1.experts.fused_w2.weight", +) + + +def _reset_mesh_resources() -> None: + _mesh_resources.mesh_stack.clear() + _mesh_resources.child_to_root_mapping.clear() + _mesh_resources.root_to_flatten_mapping.clear() + _mesh_resources.flatten_name_to_root_dims.clear() + + +@contextmanager +def _fake_world(world_size: int, rank: int) -> Iterator[None]: + dist.init_process_group("fake", store=FakeStore(), rank=rank, world_size=world_size) + try: + yield + finally: + dist.destroy_process_group() + _reset_mesh_resources() + + +# `MoE.__init__` unconditionally creates CUDA streams and dispatcher buffers, so a +# CUDA context is required even though the fake ProcessGroup never communicates. +pytestmark = pytest.mark.skipif(not torch.cuda.is_available(), reason="MoE construction requires a CUDA device") + + +@pytest.fixture(autouse=True) +def _keep_model_on_meta(monkeypatch: pytest.MonkeyPatch) -> None: + # Placements are fully determined before materialization; skipping + # `_to_empty_meta` keeps the test free of device allocations. + monkeypatch.setattr(BaseModel, "_to_empty_meta", lambda self: None) + + +def _build_model(ep_size: int) -> MoE: + config = MoEConfig( + vocab_size=1024, + max_position_embeddings=256, + pad_token_id=0, + eos_token_id=0, + num_hidden_layers=2, + hidden_size=HIDDEN, + intermediate_size=256, + rms_norm_eps=1e-6, + rope_theta=1e6, + hidden_act="silu", + attention=MHAConfig(num_attention_heads=4, num_key_value_heads=4, head_dim=32), + tie_word_embeddings=False, + n_routed_experts=N_EXPERTS, + n_shared_experts=1, + num_experts_per_tok=2, + first_k_dense_replace=1, + hidden_factor=1.0, + moe_intermediate_size=MOE_INTER, + router=NoAuxRouterConfig( + scoring_func="sigmoid", + router_scaling_factor=1.0, + n_group=1, + topk_group=1, + norm_topk_prob=True, + ), + compile_cfg=False, + ep_size=ep_size, + dispatcher="all2all", + mesh_prefix=PREFIX, + ) + with torch.device("meta"): + return MoE(config) + + +def _shard_model(ep_size: int, **fsdp_kwargs) -> MoE: + model = _build_model(ep_size) + fsdp_config = FSDPConfig(ep_size=ep_size, torch_compile=False, **fsdp_kwargs) + return model.fully_shard(fsdp_config) + + +def _named_params(model: MoE) -> dict[str, DTensor]: + params: dict[str, DTensor] = {} + for name, param in model.named_parameters(): + params[name.replace("_checkpoint_wrapped_module.", "")] = param + return params + + +def _ranks(mesh: DeviceMesh) -> list: + return mesh.mesh.tolist() + + +def _legacy_ep_ranks(world_size: int, ep_size: int, rank: int) -> list[int]: + start = (rank // ep_size) * ep_size + return list(range(start, start + ep_size)) + + +def _legacy_fsdp_ranks(world_size: int, ep_size: int, rank: int) -> list[int]: + return [rank % ep_size + k * ep_size for k in range(world_size // ep_size)] + + +def _assert_placements(param: DTensor, mesh_dim_names: tuple[str, ...], placements: tuple) -> None: + assert isinstance(param, DTensor) + assert param.device_mesh.mesh_dim_names == mesh_dim_names + assert len(param.placements) == len(placements) + for actual, expected in zip(param.placements, placements): + assert type(actual) is type(expected), (actual, expected) + if isinstance(expected, _StridedShard): + assert actual.dim == expected.dim and actual.split_factor == expected.split_factor + elif isinstance(expected, Shard): + assert actual.dim == expected.dim + + +class TestLegacyMoEMeshLayout: + """Layout produced by the original EP-orthogonal-to-FSDP path. + + ``decouple_ep_fsdp`` is off by default, so these expectations are the + regression contract for the legacy path: ``root = (fsdp = world / ep, ep)`` + with ``ep`` on the innermost (contiguous-rank) dimension. + """ + + @pytest.mark.parametrize( + "world_size,ep_size,rank", + [(8, 8, 0), (8, 8, 5), (8, 4, 6), (8, 2, 3), (16, 8, 9), (64, 8, 37)], + ) + def test_mesh_ranks(self, world_size: int, ep_size: int, rank: int) -> None: + with _fake_world(world_size, rank): + model = _shard_model(ep_size) + + assert model.hsdp_mesh is None + assert model._world_mesh is not None + assert tuple(model._world_mesh.shape) == (world_size // ep_size, ep_size) + assert model._world_mesh.mesh_dim_names == (f"{PREFIX}.fsdp", f"{PREFIX}.ep") + + assert model.fsdp_mesh is not None and model.ep_mesh is not None + assert model.fsdp_mesh.mesh_dim_names == (f"{PREFIX}.fsdp",) + assert _ranks(model.fsdp_mesh) == _legacy_fsdp_ranks(world_size, ep_size, rank) + assert model.ep_mesh.mesh_dim_names == (f"{PREFIX}.ep",) + assert _ranks(model.ep_mesh) == _legacy_ep_ranks(world_size, ep_size, rank) + assert dist.get_process_group_ranks(model.ep_mesh.get_group()) == _legacy_ep_ranks( + world_size, ep_size, rank + ) + + @pytest.mark.parametrize("world_size,ep_size,rank", [(8, 8, 0), (16, 8, 9), (64, 8, 37)]) + def test_param_placements(self, world_size: int, ep_size: int, rank: int) -> None: + fsdp_size = world_size // ep_size + with _fake_world(world_size, rank): + model = _shard_model(ep_size) + params = _named_params(model) + mesh_dim_names = (f"{PREFIX}.fsdp", f"{PREFIX}.ep") + + for name in DENSE_PARAMS: + param = params[name] + # Dense params are replicated over ep and only sharded world/ep ways. + _assert_placements(param, mesh_dim_names, (Shard(0), Replicate())) + assert param.to_local().shape[0] == param.shape[0] // fsdp_size, name + + for name in EXPERT_PARAMS: + param = params[name] + _assert_placements(param, mesh_dim_names, (_StridedShard(0, split_factor=ep_size), Shard(0))) + assert param.to_local().shape[0] == param.shape[0] // world_size, name + + def test_ep1_is_plain_fsdp(self) -> None: + world_size, rank = 8, 3 + with _fake_world(world_size, rank): + model = _shard_model(ep_size=1) + params = _named_params(model) + + assert model.ep_mesh is not None and model.ep_mesh.size() == 1 + assert model.fsdp_mesh is not None and _ranks(model.fsdp_mesh) == list(range(world_size)) + for name in DENSE_PARAMS + EXPERT_PARAMS: + param = params[name] + _assert_placements(param, (f"{PREFIX}.fsdp",), (Shard(0),)) + assert param.to_local().shape[0] == param.shape[0] // world_size, name + + def test_hsdp_ep1_layout(self) -> None: + world_size, shard_size, rank = 16, 8, 11 + with _fake_world(world_size, rank): + model = _shard_model(ep_size=1, hsdp_sharding_size=shard_size) + params = _named_params(model) + + assert model.hsdp_mesh is not None + assert tuple(model.hsdp_mesh.shape) == (world_size // shard_size, shard_size) + assert model.hsdp_mesh.mesh_dim_names == (f"{PREFIX}.hsdp_replicate", f"{PREFIX}.hsdp_shard") + assert model.fsdp_mesh is not None + assert _ranks(model.fsdp_mesh) == list(range((rank // shard_size) * shard_size, (rank // shard_size + 1) * shard_size)) + for name in DENSE_PARAMS + EXPERT_PARAMS: + param = params[name] + _assert_placements(param, model.hsdp_mesh.mesh_dim_names, (Replicate(), Shard(0))) + assert param.to_local().shape[0] == param.shape[0] // shard_size, name + + def test_hsdp_with_ep_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="HSDP requires expert parallel size to be 1"): + FSDPConfig(ep_size=8, hsdp_sharding_size=8) + From da6612689d9be52f80a40be136fa170ab9c0471e Mon Sep 17 00:00:00 2001 From: daixu Date: Fri, 21 Aug 2026 22:59:46 +0000 Subject: [PATCH 02/10] [Feature] Decouple expert parallel from FSDP sharding (dp2ep layout) Add FSDPConfig.decouple_ep_fsdp. When enabled, MoE builds a single (replicate, efsdp, ep) root mesh with efsdp = dp_shard / ep, shards routed experts with their own FSDP group on efsdp and every other parameter over the full dp_shard mesh (no more ep-fold replication of dense params), and scales expert gradients by 1/ep instead of all-reducing dense gradients over EP. The legacy layout is untouched when the flag is off. Includes fake-PG L0 placement tests for 8/16/64 ranks, the 8-GPU numerics script used for the L1 report, and reports/L1.md. --- tests/model/run_decoupled_ep_fsdp_numerics.py | 260 ++++++++++++++++++ .../summarize_decoupled_ep_fsdp_numerics.py | 90 ++++++ tests/model/test_decoupled_ep_fsdp_mesh.py | 154 ++++++++++- xtuner/v1/config/fsdp.py | 16 +- xtuner/v1/model/moe/moe.py | 200 +++++++++++++- 5 files changed, 713 insertions(+), 7 deletions(-) create mode 100644 tests/model/run_decoupled_ep_fsdp_numerics.py create mode 100644 tests/model/summarize_decoupled_ep_fsdp_numerics.py diff --git a/tests/model/run_decoupled_ep_fsdp_numerics.py b/tests/model/run_decoupled_ep_fsdp_numerics.py new file mode 100644 index 0000000000..0b5e026292 --- /dev/null +++ b/tests/model/run_decoupled_ep_fsdp_numerics.py @@ -0,0 +1,260 @@ +"""Multi-GPU numerical-equivalence runs for the EP/FSDP decoupling (DESIGN.md §6 L1 / L2). + +Every mode trains the same tiny Qwen3-MoE (random HF checkpoint built once from a fixed seed) +on the same token stream, so loss / grad-norm curves of different layouts can be compared. + +Example (L1): + + torchrun --nproc-per-node 8 tests/model/run_decoupled_ep_fsdp_numerics.py \\ + --modes A:ep=1 B:ep=8 C:ep=8,decouple=1 --steps 50 --out reports/l1.json + +Mode spec: ``:key=value[,key=value...]`` with keys ``ep`` (int), ``decouple`` (0/1) and +``hsdp`` (hsdp_sharding_size, int). +""" + +import argparse +import gc +import json +import os +import time +from pathlib import Path +from typing import Any + +import torch +import torch.distributed as dist +from torch.distributed.tensor import DTensor + +from transformers import Qwen3MoeConfig, Qwen3MoeForCausalLM +from xtuner.v1.config import AdamWConfig, FSDPConfig +from xtuner.v1.engine.train_engine import TrainEngine +from xtuner.v1.loss.ce_loss import CELossConfig +from xtuner.v1.model.base import ModelItem +from xtuner.v1.model.moe.moe import SequenceContext +from xtuner.v1.model.moe.qwen3 import Qwen3MoEConfig +from xtuner.v1.utils.device import get_device +from xtuner.v1.utils.dtensor import cal_total_norm + + +DEVICE = get_device() +GRAD_NORM_STEPS = (0, 1, 25) + + +def parse_mode(spec: str) -> dict[str, Any]: + name, _, kv = spec.partition(":") + mode: dict[str, Any] = {"name": name, "ep": 1, "decouple": False, "hsdp": None} + for item in filter(None, kv.split(",")): + key, value = item.split("=") + if key == "ep": + mode["ep"] = int(value) + elif key == "decouple": + mode["decouple"] = bool(int(value)) + elif key == "hsdp": + mode["hsdp"] = int(value) + else: + raise ValueError(f"unknown mode key {key}") + return mode + + +MODEL_SIZES: dict[str, dict[str, int]] = { + # ~108M params: numerics (fast, 50 steps in < 10 s) + "tiny": { + "vocab_size": 4096, + "hidden_size": 512, + "intermediate_size": 1024, + "moe_intermediate_size": 256, + "num_hidden_layers": 4, + "num_attention_heads": 8, + "num_key_value_heads": 4, + "head_dim": 64, + "num_experts": 64, + "num_experts_per_tok": 4, + }, + # ~3.4B params (3.2B in routed experts): step-time / memory comparison + "medium": { + "vocab_size": 32768, + "hidden_size": 2048, + "intermediate_size": 4096, + "moe_intermediate_size": 1024, + "num_hidden_layers": 8, + "num_attention_heads": 16, + "num_key_value_heads": 4, + "head_dim": 128, + "num_experts": 64, + "num_experts_per_tok": 4, + }, +} + + +def build_hf_checkpoint(path: Path, seed: int, model_size: str) -> None: + config = Qwen3MoeConfig( + **MODEL_SIZES[model_size], + max_position_embeddings=8192, + norm_topk_prob=True, + tie_word_embeddings=False, + bos_token_id=1, + eos_token_id=2, + rope_theta=1000000.0, + use_sliding_window=False, + max_window_layers=MODEL_SIZES[model_size]["num_hidden_layers"], + ) + torch.manual_seed(seed) + model = Qwen3MoeForCausalLM(config) + model.save_pretrained(path, safe_serialization=True) + + +def make_batch(step: int, rank: int, vocab_size: int, seq_len: int) -> ModelItem: + generator = torch.Generator().manual_seed(100_000 + step * 1024 + rank) + tokens = torch.randint(0, vocab_size, (1, seq_len + 1), generator=generator) + input_ids = tokens[:, :-1] + labels = tokens[:, 1:].to(DEVICE) + seq_ctx = SequenceContext.from_input_ids((input_ids,), device=DEVICE) + loss_cfg = CELossConfig() + loss_ctx = loss_cfg.build(data={"shifted_labels": labels}, sp_mesh=None) + loss_ctx = loss_cfg.loss_ctx_cls.build_batches([loss_ctx])[0] + return ModelItem(seq_ctx=seq_ctx, loss_ctx={"lm": loss_ctx}) + + +def clean_name(name: str) -> str: + return name.replace("_checkpoint_wrapped_module.", "") + + +def param_memory(model: torch.nn.Module) -> dict[str, float]: + expert_bytes = 0 + dense_bytes = 0 + for name, param in model.named_parameters(): + local = param.to_local() if isinstance(param, DTensor) else param + nbytes = local.numel() * local.element_size() + if ".experts" in name: + expert_bytes += nbytes + else: + dense_bytes += nbytes + return {"expert_param_mib": expert_bytes / 2**20, "dense_param_mib": dense_bytes / 2**20} + + +def per_param_grad_norms(model: torch.nn.Module) -> dict[str, float]: + norms: dict[str, float] = {} + for name, param in model.named_parameters(): + if param.grad is None: + continue + if isinstance(param.grad, DTensor): + norm = cal_total_norm([param.grad], norm_type=2.0, foreach=True, dtype=torch.float32) + else: + norm = torch.linalg.vector_norm(param.grad, 2.0, dtype=torch.float32) + norms[clean_name(name)] = float(norm) + return norms + + +def run_mode( + mode: dict[str, Any], hf_path: Path, steps: int, seq_len: int, lr: float, dispatcher: str +) -> dict[str, Any]: + rank = dist.get_rank() + torch.cuda.reset_peak_memory_stats() + torch.cuda.synchronize() + base_allocated = torch.cuda.memory_allocated() + + model_cfg = Qwen3MoEConfig.from_hf(hf_path) + model_cfg.ep_size = mode["ep"] + model_cfg.dispatcher = dispatcher + model_cfg.compile_cfg = False + model_cfg.mesh_prefix = f"numerics_{mode['name']}" + fsdp_cfg = FSDPConfig( + ep_size=mode["ep"], + decouple_ep_fsdp=mode["decouple"], + hsdp_sharding_size=mode["hsdp"], + torch_compile=False, + ) + optim_cfg = AdamWConfig(lr=lr) + + engine = TrainEngine(model_cfg=model_cfg, optim_cfg=optim_cfg, fsdp_cfg=fsdp_cfg) + engine.from_hf(hf_path=hf_path, strict=True) + torch.cuda.synchronize() + result: dict[str, Any] = { + "mode": mode, + "memory": param_memory(engine.model), + "losses": [], + "grad_norms": [], + "param_grad_norms": {}, + "step_time_s": None, + } + result["memory"]["allocated_after_load_mib"] = (torch.cuda.memory_allocated() - base_allocated) / 2**20 + + step_times = [] + for step in range(steps): + torch.cuda.synchronize() + t0 = time.perf_counter() + batch = make_batch(step, rank, model_cfg.vocab_size, seq_len) + info = engine.train_step([batch]) + grad_norm = engine.clip_grad_norm() + if step in GRAD_NORM_STEPS: + result["param_grad_norms"][str(step)] = per_param_grad_norms(engine.model) + engine.step_optimizer(grad_norm) + torch.cuda.synchronize() + step_times.append(time.perf_counter() - t0) + result["losses"].append(float(info["logs_info"]["reduced_llm_loss"])) + result["grad_norms"].append(float(grad_norm)) + if step == 0: + result["memory"]["allocated_after_step0_mib"] = (torch.cuda.memory_allocated() - base_allocated) / 2**20 + + warm = step_times[5:] if len(step_times) > 10 else step_times + result["step_time_s"] = sum(warm) / len(warm) + result["memory"]["peak_allocated_mib"] = (torch.cuda.max_memory_allocated() - base_allocated) / 2**20 + result["memory"]["peak_reserved_mib"] = torch.cuda.max_memory_reserved() / 2**20 + + del engine + gc.collect() + torch.cuda.empty_cache() + return result + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--modes", nargs="+", required=True) + parser.add_argument("--steps", type=int, default=50) + parser.add_argument("--seq-len", type=int, default=2048) + parser.add_argument("--lr", type=float, default=1e-4) + parser.add_argument("--seed", type=int, default=0) + parser.add_argument("--model-size", choices=tuple(MODEL_SIZES), default="tiny") + parser.add_argument("--dispatcher", choices=("all2all", "deepep"), default="all2all") + parser.add_argument("--hf-dir", type=str, default=None, help="where to build / reuse the tiny HF checkpoint") + parser.add_argument("--out", type=str, required=True) + args = parser.parse_args() + + dist.init_process_group("nccl") + rank = dist.get_rank() + torch.cuda.set_device(int(os.environ["LOCAL_RANK"])) + + hf_dir = Path(args.hf_dir or Path(args.out).with_suffix("")).with_name(f"{args.model_size}_qwen3_moe_hf") + if rank == 0 and not (hf_dir / "config.json").exists(): + hf_dir.mkdir(parents=True, exist_ok=True) + build_hf_checkpoint(hf_dir, args.seed, args.model_size) + dist.barrier() + + results = [] + for spec in args.modes: + mode = parse_mode(spec) + if rank == 0: + print(f"===== running mode {mode}", flush=True) + results.append(run_mode(mode, hf_dir, args.steps, args.seq_len, args.lr, args.dispatcher)) + dist.barrier() + if rank == 0: + last = results[-1] + print( + f"[{mode['name']}] loss[0]={last['losses'][0]:.6f} loss[-1]={last['losses'][-1]:.6f} " + f"grad_norm[0]={last['grad_norms'][0]:.6f} step_time={last['step_time_s'] * 1000:.1f}ms " + f"mem={last['memory']}", + flush=True, + ) + + if rank == 0: + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text( + json.dumps({"args": vars(args), "world_size": dist.get_world_size(), "results": results}, indent=2) + ) + print(f"wrote {out}") + dist.barrier() + dist.destroy_process_group() + + +if __name__ == "__main__": + main() diff --git a/tests/model/summarize_decoupled_ep_fsdp_numerics.py b/tests/model/summarize_decoupled_ep_fsdp_numerics.py new file mode 100644 index 0000000000..516f5cf97c --- /dev/null +++ b/tests/model/summarize_decoupled_ep_fsdp_numerics.py @@ -0,0 +1,90 @@ +"""Turn the JSON written by ``run_decoupled_ep_fsdp_numerics.py`` into markdown tables.""" + +import argparse +import json +from pathlib import Path + + +def rel(a: float, b: float) -> float: + return abs(a - b) / max(abs(b), 1e-12) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("json_path") + parser.add_argument("--ref", default=None, help="reference mode name (default: first mode)") + args = parser.parse_args() + data = json.loads(Path(args.json_path).read_text()) + results = {r["mode"]["name"]: r for r in data["results"]} + names = list(results) + ref = args.ref or names[0] + steps = len(results[ref]["losses"]) + + print("## Modes\n") + print("| mode | ep | decouple | hsdp_sharding_size |") + print("|---|---|---|---|") + for n in names: + m = results[n]["mode"] + print(f"| {n} | {m['ep']} | {m['decouple']} | {m['hsdp']} |") + + print("\n## Loss curve (reduced_llm_loss)\n") + print("| step | " + " | ".join(names) + " | " + " | ".join(f"rel({n} vs {ref})" for n in names if n != ref) + " |") + print("|---|" + "---|" * (2 * len(names) - 1)) + for s in list(range(0, steps, 5)) + [steps - 1]: + row = [f"{results[n]['losses'][s]:.6f}" for n in names] + diffs = [f"{rel(results[n]['losses'][s], results[ref]['losses'][s]):.2e}" for n in names if n != ref] + print(f"| {s} | " + " | ".join(row) + " | " + " | ".join(diffs) + " |") + print("\n| pair | max rel loss diff over all steps | mean rel loss diff |") + print("|---|---|---|") + for n in names: + if n == ref: + continue + d = [rel(a, b) for a, b in zip(results[n]["losses"], results[ref]["losses"])] + print(f"| {n} vs {ref} | {max(d):.2e} | {sum(d) / len(d):.2e} |") + + print("\n## Total grad norm (pre-clip)\n") + print("| step | " + " | ".join(names) + " | " + " | ".join(f"rel({n} vs {ref})" for n in names if n != ref) + " |") + print("|---|" + "---|" * (2 * len(names) - 1)) + for s in list(range(0, steps, 5)) + [steps - 1]: + row = [f"{results[n]['grad_norms'][s]:.6f}" for n in names] + diffs = [f"{rel(results[n]['grad_norms'][s], results[ref]['grad_norms'][s]):.2e}" for n in names if n != ref] + print(f"| {s} | " + " | ".join(row) + " | " + " | ".join(diffs) + " |") + + print("\n## Per-parameter grad norms (max / mean relative diff vs reference)\n") + print("| step | pair | dense params | expert params | worst param |") + print("|---|---|---|---|---|") + for s in results[ref]["param_grad_norms"]: + ref_norms = results[ref]["param_grad_norms"][s] + for n in names: + if n == ref: + continue + norms = results[n]["param_grad_norms"][s] + dense, expert, worst = [], [], (0.0, "") + for k, v in ref_norms.items(): + d = rel(norms[k], v) + (expert if ".experts" in k else dense).append(d) + if d > worst[0]: + worst = (d, k) + print( + f"| {s} | {n} vs {ref} | max {max(dense):.2e} / mean {sum(dense) / len(dense):.2e} " + f"| max {max(expert):.2e} / mean {sum(expert) / len(expert):.2e} | {worst[1]} ({worst[0]:.2e}) |" + ) + + print("\n## Per-rank memory (MiB) and step time\n") + keys = [ + "dense_param_mib", + "expert_param_mib", + "allocated_after_load_mib", + "allocated_after_step0_mib", + "peak_allocated_mib", + "peak_reserved_mib", + ] + print("| mode | " + " | ".join(keys) + " | step time (ms) |") + print("|---|" + "---|" * (len(keys) + 1)) + for n in names: + mem = results[n]["memory"] + print(f"| {n} | " + " | ".join(f"{mem[k]:.1f}" for k in keys) + f" | {results[n]['step_time_s'] * 1000:.1f} |") + + +if __name__ == "__main__": + main() diff --git a/tests/model/test_decoupled_ep_fsdp_mesh.py b/tests/model/test_decoupled_ep_fsdp_mesh.py index 8fd9d3d67c..978ca26ae3 100644 --- a/tests/model/test_decoupled_ep_fsdp_mesh.py +++ b/tests/model/test_decoupled_ep_fsdp_mesh.py @@ -11,8 +11,8 @@ import pytest import torch -from pydantic import ValidationError import torch.distributed as dist +from pydantic import ValidationError from torch.distributed.device_mesh import DeviceMesh, _mesh_resources from torch.distributed.tensor import DTensor, Replicate, Shard from torch.distributed.tensor.placement_types import _StridedShard @@ -221,7 +221,9 @@ def test_hsdp_ep1_layout(self) -> None: assert tuple(model.hsdp_mesh.shape) == (world_size // shard_size, shard_size) assert model.hsdp_mesh.mesh_dim_names == (f"{PREFIX}.hsdp_replicate", f"{PREFIX}.hsdp_shard") assert model.fsdp_mesh is not None - assert _ranks(model.fsdp_mesh) == list(range((rank // shard_size) * shard_size, (rank // shard_size + 1) * shard_size)) + assert _ranks(model.fsdp_mesh) == list( + range((rank // shard_size) * shard_size, (rank // shard_size + 1) * shard_size) + ) for name in DENSE_PARAMS + EXPERT_PARAMS: param = params[name] _assert_placements(param, model.hsdp_mesh.mesh_dim_names, (Replicate(), Shard(0))) @@ -231,3 +233,151 @@ def test_hsdp_with_ep_is_rejected(self) -> None: with pytest.raises(ValidationError, match="HSDP requires expert parallel size to be 1"): FSDPConfig(ep_size=8, hsdp_sharding_size=8) + +def _decoupled_ranks(world_size: int, ep_size: int, dp_shard: int, rank: int) -> dict[str, list]: + replicate = world_size // dp_shard + efsdp = dp_shard // ep_size + block_start = (rank // dp_shard) * dp_shard + ep_start = (rank // ep_size) * ep_size + efsdp_in_block = [(rank % ep_size) + k * ep_size for k in range(efsdp)] + return { + "ep": list(range(ep_start, ep_start + ep_size)), + "efsdp": [block_start + r for r in efsdp_in_block], + "dp_shard": list(range(block_start, block_start + dp_shard)), + "hsdp": [[r * dp_shard + j for j in range(dp_shard)] for r in range(replicate)], + "expert_hsdp": [[r * dp_shard + j for j in efsdp_in_block] for r in range(replicate)], + } + + +class TestDecoupledMoEMeshShapes: + """Mesh bookkeeping of the decoupled (dp2ep) path: ``root = (replicate, efsdp, ep)``.""" + + @pytest.mark.parametrize( + "world_size,ep_size,rank", + [(8, 8, 5), (8, 4, 6), (16, 8, 9), (64, 8, 37), (64, 32, 50)], + ) + def test_mesh_without_hsdp(self, world_size: int, ep_size: int, rank: int) -> None: + expected = _decoupled_ranks(world_size, ep_size, world_size, rank) + with _fake_world(world_size, rank): + model = _build_model(ep_size) + model._init_device_mesh(FSDPConfig(ep_size=ep_size, torch_compile=False, decouple_ep_fsdp=True)) + + root = model._world_mesh + assert root is not None + assert tuple(root.shape) == (1, world_size // ep_size, ep_size) + assert root.mesh_dim_names == (f"{PREFIX}.replicate", f"{PREFIX}.efsdp", f"{PREFIX}.ep") + assert _mesh_resources.get_root_mesh(model.ep_mesh) is root + assert _mesh_resources.get_root_mesh(model.fsdp_mesh) is root + assert _mesh_resources.get_root_mesh(model.expert_fsdp_mesh) is root + + assert model.hsdp_mesh is None + assert model.ep_mesh.mesh_dim_names == (f"{PREFIX}.ep",) + assert _ranks(model.ep_mesh) == expected["ep"] + assert dist.get_process_group_ranks(model.ep_mesh.get_group()) == expected["ep"] + assert model.expert_fsdp_mesh.mesh_dim_names == (f"{PREFIX}.efsdp",) + assert _ranks(model.expert_fsdp_mesh) == expected["efsdp"] + assert model.fsdp_mesh.mesh_dim_names == (f"{PREFIX}.dp_shard",) + assert _ranks(model.fsdp_mesh) == expected["dp_shard"] + assert dist.get_process_group_ranks(model.fsdp_mesh.get_group()) == expected["dp_shard"] + + @pytest.mark.parametrize( + "world_size,ep_size,dp_shard,rank", + [(16, 8, 8, 11), (64, 8, 32, 37), (64, 8, 8, 3)], + ) + def test_mesh_with_hsdp(self, world_size: int, ep_size: int, dp_shard: int, rank: int) -> None: + expected = _decoupled_ranks(world_size, ep_size, dp_shard, rank) + replicate = world_size // dp_shard + with _fake_world(world_size, rank): + model = _build_model(ep_size) + model._init_device_mesh( + FSDPConfig(ep_size=ep_size, torch_compile=False, decouple_ep_fsdp=True, hsdp_sharding_size=dp_shard) + ) + + root = model._world_mesh + assert root is not None + assert tuple(root.shape) == (replicate, dp_shard // ep_size, ep_size) + assert _ranks(model.ep_mesh) == expected["ep"] + assert _ranks(model.fsdp_mesh) == expected["dp_shard"] + + assert model.hsdp_mesh is not None + assert model.hsdp_mesh.mesh_dim_names == (f"{PREFIX}.replicate", f"{PREFIX}.dp_shard") + assert _ranks(model.hsdp_mesh) == expected["hsdp"] + assert model.expert_fsdp_mesh.mesh_dim_names == (f"{PREFIX}.replicate", f"{PREFIX}.efsdp") + assert _ranks(model.expert_fsdp_mesh) == expected["expert_hsdp"] + assert _mesh_resources.get_root_mesh(model.hsdp_mesh) is root + assert _mesh_resources.get_root_mesh(model.expert_fsdp_mesh) is root + + def test_config_requires_ep_to_divide_shard_size(self) -> None: + FSDPConfig(ep_size=8, hsdp_sharding_size=8, decouple_ep_fsdp=True) + FSDPConfig(ep_size=4, hsdp_sharding_size=8, decouple_ep_fsdp=True) + with pytest.raises(ValidationError, match="divisible by `ep_size`"): + FSDPConfig(ep_size=8, hsdp_sharding_size=4, decouple_ep_fsdp=True) + with pytest.raises(ValidationError, match="divisible by `ep_size`"): + FSDPConfig(ep_size=3, hsdp_sharding_size=8, decouple_ep_fsdp=True) + + +class TestDecoupledMoEParamPlacements: + """DTensor placements after the two-level ``fully_shard`` of the decoupled path.""" + + @pytest.mark.parametrize("world_size,ep_size,rank", [(8, 8, 0), (16, 8, 9), (64, 8, 37)]) + def test_param_placements(self, world_size: int, ep_size: int, rank: int) -> None: + efsdp = world_size // ep_size + with _fake_world(world_size, rank): + model = _shard_model(ep_size, decouple_ep_fsdp=True) + params = _named_params(model) + + for name in DENSE_PARAMS: + param = params[name] + # Dense params: plain FSDP over the full dp_shard mesh, no EP replication. + _assert_placements(param, (f"{PREFIX}.dp_shard",), (Shard(0),)) + assert param.to_local().shape[0] == param.shape[0] // world_size, name + + for name in EXPERT_PARAMS: + param = params[name] + _assert_placements( + param, + (f"{PREFIX}.efsdp", f"{PREFIX}.ep"), + (_StridedShard(0, split_factor=ep_size), Shard(0)), + ) + assert param.device_mesh.size(0) == efsdp + assert param.to_local().shape[0] == param.shape[0] // world_size, name + + from torch.distributed.fsdp import FSDPModule + + layer = model.layers["1"] + assert isinstance(layer.experts, FSDPModule) + assert isinstance(layer, FSDPModule) + assert not isinstance(model.layers["0"].mlp, FSDPModule) + + @pytest.mark.parametrize("world_size,ep_size,dp_shard,rank", [(16, 8, 8, 11), (64, 8, 32, 37)]) + def test_param_placements_with_hsdp(self, world_size: int, ep_size: int, dp_shard: int, rank: int) -> None: + efsdp = dp_shard // ep_size + with _fake_world(world_size, rank): + model = _shard_model(ep_size, decouple_ep_fsdp=True, hsdp_sharding_size=dp_shard) + params = _named_params(model) + + for name in DENSE_PARAMS: + param = params[name] + _assert_placements(param, (f"{PREFIX}.replicate", f"{PREFIX}.dp_shard"), (Replicate(), Shard(0))) + assert param.to_local().shape[0] == param.shape[0] // dp_shard, name + + for name in EXPERT_PARAMS: + param = params[name] + _assert_placements( + param, + (f"{PREFIX}.replicate", f"{PREFIX}.efsdp", f"{PREFIX}.ep"), + (Replicate(), _StridedShard(0, split_factor=ep_size), Shard(0)), + ) + assert param.device_mesh.size(1) == efsdp + assert param.to_local().shape[0] == param.shape[0] // dp_shard, name + + def test_legacy_layout_is_untouched_by_default(self) -> None: + # Same construction as the decoupled tests, but with the flag left at its default. + world_size, ep_size, rank = 16, 8, 9 + with _fake_world(world_size, rank): + model = _shard_model(ep_size) + params = _named_params(model) + assert model.expert_fsdp_mesh is None + _assert_placements( + params["layers.1.self_attn.q_proj.weight"], (f"{PREFIX}.fsdp", f"{PREFIX}.ep"), (Shard(0), Replicate()) + ) diff --git a/xtuner/v1/config/fsdp.py b/xtuner/v1/config/fsdp.py index 7335d6d7a5..85d32887c5 100644 --- a/xtuner/v1/config/fsdp.py +++ b/xtuner/v1/config/fsdp.py @@ -41,10 +41,24 @@ class FSDPConfig(BaseModel): hsdp_sharding_size: Annotated[ Optional[int], Parameter(help="Sharding size for HSDP (Hybrid Sharding Data Parallel)") ] = None + # Decoupled EP/FSDP ("dp2ep") layout. When enabled, `ep_size` is a sub-dimension of the + # FSDP shard dimension instead of being orthogonal to it: routed experts are sharded + # `dp_shard / ep_size` ways on top of EP, while every other parameter is sharded over the + # full `dp_shard` (= `hsdp_sharding_size` or world size) without being replicated across EP + # ranks. Requires `dp_shard % ep_size == 0`. When disabled, the legacy layout is untouched. + decouple_ep_fsdp: Annotated[ + bool, Parameter(help="Decouple expert parallel from FSDP: shard dense params over the full FSDP mesh") + ] = False def model_post_init(self, __context: Any) -> None: if self.hsdp_sharding_size is not None: - assert self.ep_size == 1, "Currently, HSDP requires expert parallel size to be 1" + if self.decouple_ep_fsdp: + assert self.hsdp_sharding_size % self.ep_size == 0, ( + "`decouple_ep_fsdp` requires `hsdp_sharding_size` to be divisible by `ep_size`, " + f"got hsdp_sharding_size={self.hsdp_sharding_size}, ep_size={self.ep_size}" + ) + else: + assert self.ep_size == 1, "Currently, HSDP requires expert parallel size to be 1" @field_serializer("param_dtype", "reduce_dtype") def serialize_param_dtype(self, value: torch.dtype) -> str: diff --git a/xtuner/v1/model/moe/moe.py b/xtuner/v1/model/moe/moe.py index 15dc0d628e..44c61ef974 100644 --- a/xtuner/v1/model/moe/moe.py +++ b/xtuner/v1/model/moe/moe.py @@ -201,6 +201,9 @@ class MoE(BaseModel): ep_mesh: DeviceMesh | None = None expert_tp_mesh: DeviceMesh | None = None ep_tp_mesh: DeviceMesh | None = None + # Only set on the decoupled EP/FSDP path: mesh used to `fully_shard` routed experts, + # i.e. `(efsdp,)` or `(replicate, efsdp)` with `efsdp = dp_shard / ep_size`. + expert_fsdp_mesh: DeviceMesh | None = None dense_decoder_layer_cls = DenseDecoderLayer moe_decoder_layer_cls = MoEDecoderLayer mtp_layer_cls = MTPLayer @@ -1276,6 +1279,10 @@ def fully_shard( self._init_device_mesh(fsdp_config) if self.config.float8_cfg is not None: + if self.fsdp_config.decouple_ep_fsdp: + # Experts and dense params are sharded on meshes of different sizes; the fp8 + # padding / tile-wise reduce meshes need per-class shard sizes (PR-3). + raise NotImplementedError("float8 training with `decouple_ep_fsdp` is not supported yet") # As we modify the shape of the model's parameters, # we need to reinitialize the load spec mapping. Float8Handler.pad_for_fsdp(self, cast(DeviceMesh, self.fsdp_mesh), callback_after_pad=self._init_load_spec) @@ -1296,9 +1303,13 @@ def fully_shard( param.requires_grad = False tp_enabled = self.expert_tp_mesh is not None and self.expert_tp_mesh.size() > 1 - if self.ep_mesh.size() > 1 or tp_enabled: + decoupled = self.fsdp_config.decouple_ep_fsdp + if not decoupled and (self.ep_mesh.size() > 1 or tp_enabled): # 中文注释:不开 EP 但开启 expert TP 时,非 expert 参数仍是 TP rank 间的逻辑副本, # 需要显式放到 Replicate DTensor 上,后续梯度才会跨 expert TP 平均。 + # + # On the decoupled path dense params are NOT replicated over EP: they stay plain + # tensors and get sharded over the full `dp_shard` mesh by FSDP below. self._replicate_other_params(self) # Although rotary_emb was already constructed in __init__, it was built on the meta device. @@ -1313,6 +1324,18 @@ def fully_shard( for layer_idx, layer in tqdm(self.layers.items(), desc="[FSDP Sharding]"): layer_idx = int(layer_idx) + if decoupled: + # Routed experts get their own FSDP group on `expert_fsdp_mesh` (efsdp) before the + # layer is wrapped on the dense mesh; FSDP then excludes them from the layer group. + self._fully_shard_expert_blocks( + layer, + mp_policy=mp_policy, + reshard_after_forward=( + False + if layer_idx >= len(self.layers) - 1 and self.mtp_block is None + else self.fsdp_config.reshard_after_forward + ), + ) if self._should_recompute( layer_idx=layer_idx, mtp_idx=None, @@ -1340,7 +1363,17 @@ def fully_shard( list(self.layers.values())[:-1], list(self.layers.values())[1:], ): - layer_cur.set_modules_to_forward_prefetch([layer_next]) # type: ignore + if decoupled: + # Issue the expert all-gather of the current layer together with the next layer's + # dense all-gather so it overlaps with attention instead of stalling the MoE block. + layer_cur.set_modules_to_forward_prefetch( # type: ignore + [*self._expert_blocks(layer_cur), layer_next] + ) + else: + layer_cur.set_modules_to_forward_prefetch([layer_next]) # type: ignore + if decoupled: + last_layer = list(self.layers.values())[-1] + last_layer.set_modules_to_forward_prefetch(self._expert_blocks(last_layer)) # type: ignore self._fully_shard( mesh=self.fsdp_mesh if self.hsdp_mesh is None else self.hsdp_mesh, @@ -1379,6 +1412,10 @@ def fully_shard( self.mtp_block.layers[mtp_idx] = mtp_layer reshard_after_forward = mtp_idx != len(self.mtp_block.layers) - 1 + if decoupled: + self._fully_shard_expert_blocks( + mtp_layer, mp_policy=mp_policy, reshard_after_forward=reshard_after_forward + ) self._fully_shard( mesh=self.fsdp_mesh if self.hsdp_mesh is None else self.hsdp_mesh, mp_policy=mp_policy, @@ -1387,14 +1424,27 @@ def fully_shard( module=mtp_layer, ) if mtp_idx == 0: - layer_next.set_modules_to_forward_prefetch([mtp_layer]) # type: ignore + if decoupled: + layer_next.set_modules_to_forward_prefetch( # type: ignore + [*self._expert_blocks(layer_next), mtp_layer] + ) + else: + layer_next.set_modules_to_forward_prefetch([mtp_layer]) # type: ignore if self.config.mtp_config is not None and self.config.mtp_config.num_layers > 0: for prev_mtp_layer, next_mtp_layer in zip( list(self.mtp_block.layers)[:-1], list(self.mtp_block.layers)[1:], ): - prev_mtp_layer.set_modules_to_forward_prefetch([next_mtp_layer]) # type: ignore + if decoupled: + prev_mtp_layer.set_modules_to_forward_prefetch( # type: ignore + [*self._expert_blocks(prev_mtp_layer), next_mtp_layer] + ) + else: + prev_mtp_layer.set_modules_to_forward_prefetch([next_mtp_layer]) # type: ignore + if decoupled: + last_mtp_layer = list(self.mtp_block.layers)[-1] + last_mtp_layer.set_modules_to_forward_prefetch(self._expert_blocks(last_mtp_layer)) # type: ignore self._fully_shard( mesh=self.fsdp_mesh if self.hsdp_mesh is None else self.hsdp_mesh, @@ -1426,6 +1476,10 @@ def need_update_bias(self) -> bool: @torch.no_grad # type: ignore def scale_and_reduce_grad(self): + if self.fsdp_config is not None and self.fsdp_config.decouple_ep_fsdp: + self._scale_and_reduce_grad_decoupled() + return + # Bucket gradients that need a cross-rank reduction by their target process # group. Each bucket is reduced with a single coalesced NCCL all_reduce # instead of one launch per parameter, which used to dominate latency for @@ -1518,6 +1572,10 @@ def cal_grad_norm(self, grads: list[DTensor], dtype=torch.float32): def _init_device_mesh(self, fsdp_config: FSDPConfig): self.fsdp_config = fsdp_config + if self.fsdp_config.decouple_ep_fsdp: + self._init_decoupled_device_mesh(fsdp_config) + return + device = DEVICE world_size = dist.get_world_size() expert_tp_size = self.config.expert_tp_size if self.config.expert_tp_size > 1 else 1 @@ -1608,6 +1666,140 @@ def _init_device_mesh(self, fsdp_config: FSDPConfig): ) self.fsdp_mesh = self.hsdp_mesh[f"{self.config.mesh_prefix}.hsdp_shard"] + def _scale_and_reduce_grad_decoupled(self) -> None: + # Invariant (DESIGN §4.4): every gradient ends up as the mean over the full data-parallel + # world, independent of ep / efsdp. + # * dense params are FSDP-sharded over `dp_shard` (+ `replicate` with HSDP); FSDP's + # reduce-scatter / all-reduce already average them -> nothing to do; + # * routed experts are reduce-scattered over `efsdp = dp_shard / ep` only, while each + # expert already saw the tokens of its whole EP group -> divide by `ep`; + # * DTensors replicated on every mesh dim (fp32 params kept out of FSDP via + # `fp32_keys_pattern`) are averaged manually, as on the legacy path. + ep_size = self.ep_mesh.size() if self.ep_mesh is not None else 1 + grads_by_group: dict[dist.ProcessGroup, list[torch.Tensor]] = {} + + for name, param in self.trainable_parameters(): + if param.grad is None: + continue + + if ep_size > 1 and ".experts" in name: + param.grad.div_(ep_size) # type: ignore + continue + + if not isinstance(param, DTensor): + continue + + if any(isinstance(placement, Shard) for placement in param.placements): + # FSDP-managed (`_StridedShard` is a `Shard` subclass). + continue + + replicate_dim_names = tuple( + param.device_mesh.mesh_dim_names[i] for i, p in enumerate(param.placements) if isinstance(p, Replicate) + ) + if not replicate_dim_names: + continue + + if len(replicate_dim_names) > 1: + flat_mesh = param.device_mesh[replicate_dim_names]._flatten() + else: + flat_mesh = param.device_mesh[replicate_dim_names[0]] + + grad = param.grad.to_local() if isinstance(param.grad, DTensor) else param.grad + grad.div_(flat_mesh.size()) # type: ignore + grads_by_group.setdefault(flat_mesh.get_group(), []).append(grad) # type: ignore + + for group, grads in grads_by_group.items(): + with dist._coalescing_manager(group=group): + for grad in grads: + dist.all_reduce(grad, ReduceOp.SUM, group=group) + + @staticmethod + def _expert_blocks(module: nn.Module) -> list[nn.Module]: + return [submodule for submodule in module.modules() if isinstance(submodule, MoEBlock)] + + def _fully_shard_expert_blocks( + self, + module: nn.Module, + mp_policy: MixedPrecisionPolicy, + reshard_after_forward: bool, + ) -> None: + assert self.expert_fsdp_mesh is not None + assert self.fsdp_config is not None + for expert_block in self._expert_blocks(module): + self._fully_shard( + mesh=self.expert_fsdp_mesh, + mp_policy=mp_policy, + reshard_after_forward=reshard_after_forward, + offload_policy=CPUOffloadPolicy() if self.fsdp_config.cpu_offload else None, + module=expert_block, + ) + + def _init_decoupled_device_mesh(self, fsdp_config: FSDPConfig) -> None: + # Decoupled ("dp2ep") layout: EP is a sub-dimension of the FSDP shard dimension. + # + # root = (replicate, efsdp, ep) replicate = world / dp_shard (1 without HSDP) + # efsdp = dp_shard / ep + # dense params: FSDP over flatten(efsdp, ep) = dp_shard (+ replicate for HSDP) + # expert params: Shard(0) over ep (built in GroupedLinear) + FSDP over efsdp + # (+ replicate for HSDP) + # + # `ep` stays the innermost dimension so the EP group is still made of contiguous + # (intra-node) ranks, exactly like the legacy `(fsdp, ep)` root mesh. Every sub-mesh is + # derived from this single root so FSDP accepts the EP-sharded DTensor expert params. + # The `efsdp` dimension is kept even when its size is 1: the FSDP wrapping of the + # experts is what applies the mixed-precision policy to them. + if self.config.expert_tp_size > 1: + raise NotImplementedError("`decouple_ep_fsdp` with ExpertTP is not supported") + + device = DEVICE + world_size = dist.get_world_size() + ep_size = fsdp_config.ep_size + dp_shard = fsdp_config.hsdp_sharding_size if fsdp_config.hsdp_sharding_size is not None else world_size + assert world_size % dp_shard == 0, ( + f"world_size ({world_size}) must be divisible by hsdp_sharding_size ({dp_shard})" + ) + assert dp_shard % ep_size == 0, ( + f"`decouple_ep_fsdp` requires the FSDP shard size ({dp_shard}) to be divisible by ep_size ({ep_size})" + ) + replicate_size = world_size // dp_shard + efsdp_size = dp_shard // ep_size + + prefix = self.config.mesh_prefix + replicate_name = f"{prefix}.replicate" + efsdp_name = f"{prefix}.efsdp" + ep_name = f"{prefix}.ep" + dp_shard_name = f"{prefix}.dp_shard" + + root_mesh = init_device_mesh( + device, + (replicate_size, efsdp_size, ep_size), + mesh_dim_names=(replicate_name, efsdp_name, ep_name), + ) + self._world_mesh = root_mesh + + # Same requirement as the legacy path: the `ep_mesh` created in `__init__` must map to the + # `ep` dimension of this root mesh (see the comment in `_init_device_mesh`). + new_ep_mesh = root_mesh[ep_name] + if self.ep_mesh is not None: + assert new_ep_mesh.mesh_dim_names == self.ep_mesh.mesh_dim_names, ( + f"FSDP enabled, it requires the name of new created `ep_mesh`: {new_ep_mesh.mesh_dim_names}" + f"equals to the origin one: {self.ep_mesh.mesh_dim_names}" + ) + assert torch.equal(self.ep_mesh.mesh, new_ep_mesh.mesh), ( + "FSDP enabled, it requires the `ep_size` of model config equals to the `ep_size` of FSDPConfig." + ) + else: + self.ep_mesh = new_ep_mesh + + # `fsdp_mesh` keeps its meaning of "the 1D shard group of dense parameters". + self.fsdp_mesh = root_mesh[efsdp_name, ep_name]._flatten(dp_shard_name) + if replicate_size > 1: + self.hsdp_mesh = root_mesh[replicate_name, dp_shard_name] + self.expert_fsdp_mesh = root_mesh[replicate_name, efsdp_name] + else: + self.hsdp_mesh = None + self.expert_fsdp_mesh = root_mesh[efsdp_name] + def _replicate_other_params(self, model: nn.Module): def traverse(module: nn.Module) -> None: if isinstance(module, MoEBlock): From f101de11d50261edaee7b62da8b978b620738128 Mon Sep 17 00:00:00 2001 From: daixu Date: Fri, 21 Aug 2026 23:30:02 +0000 Subject: [PATCH 03/10] [Feature] Float8, HF/DCP and RL weight-sync support for the decoupled EP/FSDP layout - Float8Handler: per-parameter-class fp8 padding chunk counts and tile-wise reduce meshes (routed experts follow the efsdp shard stride, dense params the flattened dp_shard); legacy single-mesh path unchanged. - BaseModel._fsdp_foreach_allgather picks the FSDP gather group per LoadSpec so EP-local expert slices are still reconstructed for RL weight sync. - examples/v1/config/sft_glm5p2.py: DECOUPLE_EP_FSDP / HSDP_SHARDING_SIZE env switches. - L0 fake-PG tests for the fp8 meshes, the L3 DCP/HF checkpoint script and reports/L3.md (bit-exact HF export, DCP resume, cross-layout DCP reshard, fp8 numerics within the fp8 run-to-run noise floor). --- examples/v1/config/sft_glm5p2.py | 5 + tests/model/run_decoupled_ep_fsdp_ckpt.py | 222 ++++++++++++++++++ tests/model/run_decoupled_ep_fsdp_numerics.py | 23 +- tests/model/test_decoupled_ep_fsdp_mesh.py | 72 ++++++ xtuner/v1/float8/float8_handler.py | 121 +++++++++- xtuner/v1/float8/fsdp_utils.py | 6 +- xtuner/v1/model/base.py | 29 ++- xtuner/v1/model/moe/moe.py | 20 +- 8 files changed, 473 insertions(+), 25 deletions(-) create mode 100644 tests/model/run_decoupled_ep_fsdp_ckpt.py diff --git a/examples/v1/config/sft_glm5p2.py b/examples/v1/config/sft_glm5p2.py index 2b991b92c0..947f21a342 100644 --- a/examples/v1/config/sft_glm5p2.py +++ b/examples/v1/config/sft_glm5p2.py @@ -108,10 +108,15 @@ def _get_float8_config() -> Float8Config | None: else: raise ValueError(f"Unsupported OPTIMIZER={optimizer!r}. Use adamw or muon.") lr_cfg = LRConfig(lr_type=os.environ.get("LR_TYPE", "cosine"), warmup_ratio=float(os.environ.get("WARMUP_RATIO", "0"))) +hsdp_sharding_size = os.environ.get("HSDP_SHARDING_SIZE") fsdp_cfg = FSDPConfig( cpu_offload=_get_bool_env("CPU_OFFLOAD", False), ep_size=ep_size, torch_compile=_get_bool_env("TORCH_COMPILE", False), + # DECOUPLE_EP_FSDP=1: dp2ep layout — dense params sharded over the full FSDP mesh instead of + # being replicated `ep_size` times; experts sharded `dp_shard / ep_size` ways on top of EP. + decouple_ep_fsdp=_get_bool_env("DECOUPLE_EP_FSDP", False), + hsdp_sharding_size=int(hsdp_sharding_size) if hsdp_sharding_size else None, ) trainer = TrainerConfig( diff --git a/tests/model/run_decoupled_ep_fsdp_ckpt.py b/tests/model/run_decoupled_ep_fsdp_ckpt.py new file mode 100644 index 0000000000..06f505c66a --- /dev/null +++ b/tests/model/run_decoupled_ep_fsdp_ckpt.py @@ -0,0 +1,222 @@ +"""DCP round trip and HF export checks for the decoupled EP/FSDP layout (DESIGN.md §6 L3). + +For every mode: + +1. ``from_hf`` the tiny checkpoint and immediately ``save_hf`` → must be bit-identical to the source; +2. train 5 steps, ``save_dcp``, train 5 more steps, ``save_hf`` (the "continuous" run); +3. build a fresh engine, ``load_dcp`` the step-5 checkpoint, train steps 5-9 → losses must continue + the continuous curve, and its ``save_hf`` must match the continuous export; +4. the step-10 HF exports of all modes are compared against the ``ep=1`` baseline export. + +Optionally the step-5 DCP checkpoint of one layout is loaded into another layout +(``--cross-load SRC:DST``) to check DCP resharding across the switch. + + torchrun --nproc-per-node 8 tests/model/run_decoupled_ep_fsdp_ckpt.py \\ + --modes A:ep=1 C:ep=8,decouple=1 C4:ep=4,decouple=1 H41:ep=4,decouple=1,hsdp=4 --out /tmp/l3 +""" + +import argparse +import gc +import json +import os +import shutil +import sys +from pathlib import Path +from typing import Any + +import torch +import torch.distributed as dist +from safetensors.torch import load_file + + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from run_decoupled_ep_fsdp_numerics import build_hf_checkpoint, make_batch, parse_mode # noqa: E402 + +from xtuner.v1.config import AdamWConfig, FSDPConfig # noqa: E402 +from xtuner.v1.engine.train_engine import TrainEngine # noqa: E402 +from xtuner.v1.model.moe.qwen3 import Qwen3MoEConfig # noqa: E402 + + +def build_engine(mode: dict[str, Any], hf_path: Path, tag: str) -> TrainEngine: + model_cfg = Qwen3MoEConfig.from_hf(hf_path) + model_cfg.ep_size = mode["ep"] + model_cfg.dispatcher = "all2all" + model_cfg.compile_cfg = False + model_cfg.mesh_prefix = f"ckpt_{mode['name']}_{tag}" + fsdp_cfg = FSDPConfig( + ep_size=mode["ep"], + decouple_ep_fsdp=mode["decouple"], + hsdp_sharding_size=mode["hsdp"], + torch_compile=False, + ) + return TrainEngine(model_cfg=model_cfg, optim_cfg=AdamWConfig(lr=1e-4), fsdp_cfg=fsdp_cfg) + + +def train(engine: TrainEngine, steps: range, vocab_size: int, seq_len: int) -> list[float]: + losses = [] + for step in steps: + info = engine.train_step([make_batch(step, dist.get_rank(), vocab_size, seq_len)]) + grad_norm = engine.clip_grad_norm() + engine.step_optimizer(grad_norm) + losses.append(float(info["logs_info"]["reduced_llm_loss"])) + return losses + + +def load_hf_dir(path: Path) -> dict[str, torch.Tensor]: + tensors: dict[str, torch.Tensor] = {} + for file in sorted(path.glob("*.safetensors")): + tensors.update(load_file(str(file))) + return tensors + + +def compare_hf(lhs: Path, rhs: Path) -> dict[str, Any]: + a = load_hf_dir(lhs) + b = load_hf_dir(rhs) + missing = sorted(set(a) - set(b)) + extra = sorted(set(b) - set(a)) + max_abs = 0.0 + max_rel = 0.0 + mismatched = 0 + total = 0 + worst = "" + for key in sorted(set(a) & set(b)): + x = a[key].to(torch.float32) + y = b[key].to(torch.float32) + if x.shape != y.shape: + return {"error": f"shape mismatch for {key}: {tuple(x.shape)} vs {tuple(y.shape)}"} + diff = (x - y).abs() + cur = float(diff.max()) + if cur > max_abs: + max_abs, worst = cur, key + max_rel = max(max_rel, float((diff / x.abs().clamp_min(1e-6)).max())) + mismatched += int((diff > 0).sum()) + total += diff.numel() + return { + "keys": len(set(a) & set(b)), + "missing_in_rhs": missing[:5], + "extra_in_rhs": extra[:5], + "max_abs_diff": max_abs, + "max_rel_diff": max_rel, + "mismatched_elements": mismatched, + "total_elements": total, + "worst_key": worst, + } + + +def release(engine: TrainEngine) -> None: + del engine + gc.collect() + torch.cuda.empty_cache() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--modes", nargs="+", required=True) + parser.add_argument("--cross-load", nargs="*", default=[], help="SRC:DST mode-name pairs") + parser.add_argument("--seq-len", type=int, default=2048) + parser.add_argument("--hf-dir", type=str, default=None) + parser.add_argument("--out", type=str, required=True) + args = parser.parse_args() + + dist.init_process_group("nccl") + rank = dist.get_rank() + torch.cuda.set_device(int(os.environ["LOCAL_RANK"])) + out = Path(args.out) + hf_dir = Path(args.hf_dir) if args.hf_dir else out / "tiny_qwen3_moe_hf" + if rank == 0: + out.mkdir(parents=True, exist_ok=True) + if not (hf_dir / "config.json").exists(): + build_hf_checkpoint(hf_dir, 0, "tiny") + dist.barrier() + vocab_size = Qwen3MoEConfig.from_hf(hf_dir).vocab_size + + # Source checkpoint in bf16, the dtype `save_hf` writes. + src_bf16 = out / "source_bf16" + if rank == 0: + src_bf16.mkdir(exist_ok=True) + from safetensors.torch import save_file + + tensors = {k: v.to(torch.bfloat16).contiguous() for k, v in load_hf_dir(hf_dir).items()} + save_file(tensors, str(src_bf16 / "model.safetensors")) + dist.barrier() + + report: dict[str, Any] = {"modes": {}, "cross_hf_vs_baseline": {}, "cross_load": {}} + modes = [parse_mode(spec) for spec in args.modes] + for mode in modes: + name = mode["name"] + mode_dir = out / name + if rank == 0: + shutil.rmtree(mode_dir, ignore_errors=True) + mode_dir.mkdir(parents=True) + dist.barrier() + if rank == 0: + print(f"===== {mode}", flush=True) + + engine = build_engine(mode, hf_dir, "main") + engine.from_hf(hf_path=hf_dir, strict=True) + engine.save_hf(str(mode_dir / "hf_step0")) + dist.barrier() + entry: dict[str, Any] = {"mode": mode} + if rank == 0: + entry["hf_step0_vs_source"] = compare_hf(src_bf16, mode_dir / "hf_step0") + + losses_first = train(engine, range(0, 5), vocab_size, args.seq_len) + engine.save_dcp(mode_dir / "dcp_step5") + dist.barrier() + losses_cont = train(engine, range(5, 10), vocab_size, args.seq_len) + engine.save_hf(str(mode_dir / "hf_step10")) + dist.barrier() + release(engine) + + resumed = build_engine(mode, hf_dir, "resume") + resumed.load_dcp(mode_dir / "dcp_step5") + losses_resumed = train(resumed, range(5, 10), vocab_size, args.seq_len) + resumed.save_hf(str(mode_dir / "hf_step10_resumed")) + dist.barrier() + release(resumed) + + entry["losses_steps_0_4"] = losses_first + entry["losses_steps_5_9_continuous"] = losses_cont + entry["losses_steps_5_9_resumed"] = losses_resumed + entry["resume_max_rel_loss_diff"] = max( + abs(a - b) / abs(b) for a, b in zip(losses_resumed, losses_cont, strict=True) + ) + if rank == 0: + entry["hf_step10_resumed_vs_continuous"] = compare_hf( + mode_dir / "hf_step10", mode_dir / "hf_step10_resumed" + ) + print(json.dumps({k: v for k, v in entry.items() if k != "mode"}, indent=1), flush=True) + report["modes"][name] = entry + + if rank == 0: + baseline = modes[0]["name"] + for mode in modes[1:]: + report["cross_hf_vs_baseline"][mode["name"]] = compare_hf( + out / baseline / "hf_step10", out / mode["name"] / "hf_step10" + ) + + for pair in args.cross_load: + src, dst = pair.split(":") + dst_mode = next(m for m in modes if m["name"] == dst) + if rank == 0: + print(f"===== cross load {src} -> {dst}", flush=True) + engine = build_engine(dst_mode, hf_dir, f"cross_{src}") + engine.load_dcp(out / src / "dcp_step5") + losses = train(engine, range(5, 10), vocab_size, args.seq_len) + release(engine) + ref = report["modes"][src]["losses_steps_5_9_continuous"] + report["cross_load"][pair] = { + "losses_steps_5_9": losses, + "max_rel_loss_diff_vs_src_continuous": max(abs(a - b) / abs(b) for a, b in zip(losses, ref, strict=True)), + } + + if rank == 0: + (out / "l3.json").write_text(json.dumps(report, indent=2)) + print(json.dumps({k: report[k] for k in ("cross_hf_vs_baseline", "cross_load")}, indent=1), flush=True) + print(f"wrote {out / 'l3.json'}") + dist.barrier() + dist.destroy_process_group() + + +if __name__ == "__main__": + main() diff --git a/tests/model/run_decoupled_ep_fsdp_numerics.py b/tests/model/run_decoupled_ep_fsdp_numerics.py index 0b5e026292..ed819277c9 100644 --- a/tests/model/run_decoupled_ep_fsdp_numerics.py +++ b/tests/model/run_decoupled_ep_fsdp_numerics.py @@ -27,6 +27,7 @@ from transformers import Qwen3MoeConfig, Qwen3MoeForCausalLM from xtuner.v1.config import AdamWConfig, FSDPConfig from xtuner.v1.engine.train_engine import TrainEngine +from xtuner.v1.float8.config import Float8Config, ScalingGranularity from xtuner.v1.loss.ce_loss import CELossConfig from xtuner.v1.model.base import ModelItem from xtuner.v1.model.moe.moe import SequenceContext @@ -145,7 +146,7 @@ def per_param_grad_norms(model: torch.nn.Module) -> dict[str, float]: def run_mode( - mode: dict[str, Any], hf_path: Path, steps: int, seq_len: int, lr: float, dispatcher: str + mode: dict[str, Any], hf_path: Path, steps: int, seq_len: int, lr: float, dispatcher: str, fp8: bool ) -> dict[str, Any]: rank = dist.get_rank() torch.cuda.reset_peak_memory_stats() @@ -157,12 +158,16 @@ def run_mode( model_cfg.dispatcher = dispatcher model_cfg.compile_cfg = False model_cfg.mesh_prefix = f"numerics_{mode['name']}" - fsdp_cfg = FSDPConfig( - ep_size=mode["ep"], - decouple_ep_fsdp=mode["decouple"], - hsdp_sharding_size=mode["hsdp"], - torch_compile=False, - ) + if fp8: + model_cfg.float8_cfg = Float8Config( + scaling_granularity_gemm=ScalingGranularity.TILEWISE, + scaling_granularity_grouped_gemm=ScalingGranularity.TILEWISE, + ) + fsdp_kwargs: dict[str, Any] = {"ep_size": mode["ep"], "hsdp_sharding_size": mode["hsdp"], "torch_compile": False} + if mode["decouple"]: + # Only pass the flag when set so the script also runs against checkouts that predate it. + fsdp_kwargs["decouple_ep_fsdp"] = True + fsdp_cfg = FSDPConfig(**fsdp_kwargs) optim_cfg = AdamWConfig(lr=lr) engine = TrainEngine(model_cfg=model_cfg, optim_cfg=optim_cfg, fsdp_cfg=fsdp_cfg) @@ -197,6 +202,7 @@ def run_mode( warm = step_times[5:] if len(step_times) > 10 else step_times result["step_time_s"] = sum(warm) / len(warm) + result["step_times_s"] = step_times result["memory"]["peak_allocated_mib"] = (torch.cuda.max_memory_allocated() - base_allocated) / 2**20 result["memory"]["peak_reserved_mib"] = torch.cuda.max_memory_reserved() / 2**20 @@ -215,6 +221,7 @@ def main() -> None: parser.add_argument("--seed", type=int, default=0) parser.add_argument("--model-size", choices=tuple(MODEL_SIZES), default="tiny") parser.add_argument("--dispatcher", choices=("all2all", "deepep"), default="all2all") + parser.add_argument("--fp8", action="store_true", help="tile-wise float8 for linear and grouped linear") parser.add_argument("--hf-dir", type=str, default=None, help="where to build / reuse the tiny HF checkpoint") parser.add_argument("--out", type=str, required=True) args = parser.parse_args() @@ -234,7 +241,7 @@ def main() -> None: mode = parse_mode(spec) if rank == 0: print(f"===== running mode {mode}", flush=True) - results.append(run_mode(mode, hf_dir, args.steps, args.seq_len, args.lr, args.dispatcher)) + results.append(run_mode(mode, hf_dir, args.steps, args.seq_len, args.lr, args.dispatcher, args.fp8)) dist.barrier() if rank == 0: last = results[-1] diff --git a/tests/model/test_decoupled_ep_fsdp_mesh.py b/tests/model/test_decoupled_ep_fsdp_mesh.py index 978ca26ae3..06161848f6 100644 --- a/tests/model/test_decoupled_ep_fsdp_mesh.py +++ b/tests/model/test_decoupled_ep_fsdp_mesh.py @@ -381,3 +381,75 @@ def test_legacy_layout_is_untouched_by_default(self) -> None: _assert_placements( params["layers.1.self_attn.q_proj.weight"], (f"{PREFIX}.fsdp", f"{PREFIX}.ep"), (Shard(0), Replicate()) ) + + +class TestDecoupledFloat8Meshes: + """Tile-wise fp8 bookkeeping on the decoupled path: per-class FSDP chunk counts and reduce meshes.""" + + def test_reduce_meshes_follow_each_class_shard_stride(self) -> None: + from xtuner.v1.float8.config import Float8Config, ScalingGranularity + + world_size, ep_size, rank = 64, 8, 37 + with _fake_world(world_size, rank): + config = _build_model(ep_size).config.model_copy( + update={ + "hidden_size": 256, + "n_routed_experts": 16, + "moe_intermediate_size": 128, + "attention": MHAConfig(num_attention_heads=4, num_key_value_heads=4, head_dim=64), + "float8_cfg": Float8Config( + scaling_granularity_gemm=ScalingGranularity.TILEWISE, + scaling_granularity_grouped_gemm=ScalingGranularity.TILEWISE, + ), + } + ) + with torch.device("meta"): + model = MoE(config) + model.fully_shard(FSDPConfig(ep_size=ep_size, torch_compile=False, decouple_ep_fsdp=True)) + params = _named_params(model) + handler = model.float8_handler + assert handler is not None + + # dense q_proj (256, 256) is sharded 64 ways -> 4 local rows -> absmax reduced over 32 contiguous ranks + dense_local = tuple(params["layers.1.self_attn.q_proj.weight"].to_local().shape) + assert dense_local == (4, 256) + assert _ranks(handler.tilewise_reduce_mesh_mapping[dense_local]) == list(range(32, 64)) + # experts: 16 experts x 256 rows = 4096 rows sharded (efsdp=8) x (ep=8) ways -> 64 local rows + # -> absmax reduced over 2 ranks that are `ep` apart (neighbours on the efsdp dim) + expert_local = tuple(params["layers.1.experts.fused_w1w3.weight"].to_local().shape) + assert expert_local == (64, 256) + assert handler.expert_tilewise_reduce_mesh_mapping is not None + assert _ranks(handler.expert_tilewise_reduce_mesh_mapping[expert_local]) == [37, 45] + assert dense_local not in handler.expert_tilewise_reduce_mesh_mapping + assert expert_local not in handler.tilewise_reduce_mesh_mapping + + # "n*128+64" straddling blocks: dense neighbours are adjacent ranks, expert neighbours are `ep` apart + assert _ranks(handler.tilewise_reduce_mesh_devided_64) == [36, 37] + assert _ranks(handler.expert_tilewise_reduce_mesh_devided_64) == [37, 45] + + def test_legacy_float8_meshes_unchanged(self) -> None: + from xtuner.v1.float8.config import Float8Config, ScalingGranularity + + world_size, ep_size, rank = 64, 8, 37 + with _fake_world(world_size, rank): + config = _build_model(ep_size).config.model_copy( + update={ + "hidden_size": 256, + "n_routed_experts": 16, + "moe_intermediate_size": 128, + "attention": MHAConfig(num_attention_heads=4, num_key_value_heads=4, head_dim=64), + "float8_cfg": Float8Config( + scaling_granularity_gemm=ScalingGranularity.TILEWISE, + scaling_granularity_grouped_gemm=ScalingGranularity.TILEWISE, + ), + } + ) + with torch.device("meta"): + model = MoE(config) + model.fully_shard(FSDPConfig(ep_size=ep_size, torch_compile=False)) + handler = model.float8_handler + assert handler is not None + assert handler.expert_tilewise_reduce_mesh_mapping is None + assert handler.expert_tilewise_reduce_mesh_devided_64 is None + # legacy: every class is sharded on the same (fsdp=8, stride ep=8) mesh + assert _ranks(handler.tilewise_reduce_mesh_devided_64) == [37, 45] diff --git a/xtuner/v1/float8/float8_handler.py b/xtuner/v1/float8/float8_handler.py index c6ecd2c66a..510483995a 100644 --- a/xtuner/v1/float8/float8_handler.py +++ b/xtuner/v1/float8/float8_handler.py @@ -42,6 +42,11 @@ class Float8Handler: fsdp_mesh: Optional[DeviceMesh] = None tilewise_reduce_mesh_devided_64: Optional[DeviceMesh] = None tilewise_reduce_mesh_mapping: Dict[Tuple[int, int], DeviceMesh] = {} + # Decoupled EP/FSDP layout only: routed experts are FSDP-sharded on `expert_fsdp_mesh` + # (efsdp, strided by ep) instead of `fsdp_mesh`, so they need their own reduce meshes. + expert_fsdp_mesh: Optional[DeviceMesh] = None + expert_tilewise_reduce_mesh_devided_64: Optional[DeviceMesh] = None + expert_tilewise_reduce_mesh_mapping: Optional[Dict[Tuple[int, int], DeviceMesh]] = None def __init__( self, @@ -113,7 +118,12 @@ def get_shard_size_on_dim(tensor: torch.Tensor | DTensor, dim: int) -> int: return RuntimeLayout.from_dtensor(tensor).shard_size(dim) @staticmethod - def pad_for_fsdp(model: nn.Module, fsdp_mesh: DeviceMesh, callback_after_pad: Callable | None = None): + def pad_for_fsdp( + model: nn.Module, + fsdp_mesh: DeviceMesh, + callback_after_pad: Callable | None = None, + expert_fsdp_mesh: DeviceMesh | None = None, + ): from xtuner.v1.float8.float8_gmm_tile_wise import TileWiseFloat8GroupedLinear from xtuner.v1.float8.float8_linear_tensor_wise import TensorWiseFloat8Linear from xtuner.v1.float8.float8_linear_tile_wise import TileWiseFloat8Linear @@ -131,20 +141,102 @@ def pad_for_fsdp(model: nn.Module, fsdp_mesh: DeviceMesh, callback_after_pad: Ca else: tensor_size = module.weight.size() parallel_size = 1 - padded_out_features = Float8Handler.get_num_features_after_pad(tensor_size, 0, fsdp_mesh.size(-1)) + num_fsdp_chunks = fsdp_mesh.size(-1) + if expert_fsdp_mesh is not None and isinstance(module, TileWiseFloat8GroupedLinear): + # Decoupled EP/FSDP: routed experts are sharded `efsdp` ways, not `dp_shard` ways. + num_fsdp_chunks = expert_fsdp_mesh.size(-1) + padded_out_features = Float8Handler.get_num_features_after_pad(tensor_size, 0, num_fsdp_chunks) padded_out_features *= parallel_size module.pad_for_fsdp(padded_out_features=padded_out_features) if callback_after_pad is not None: callback_after_pad() - def build_reduce_mesh(self, model: nn.Module, fsdp_mesh: DeviceMesh): + def build_reduce_mesh(self, model: nn.Module, fsdp_mesh: DeviceMesh, expert_fsdp_mesh: DeviceMesh | None = None): self.fsdp_mesh = fsdp_mesh + self.expert_fsdp_mesh = expert_fsdp_mesh if self.is_tilewise_fp8: + if expert_fsdp_mesh is not None: + self._build_decoupled_reduce_meshes(model, fsdp_mesh, expert_fsdp_mesh) + return if fsdp_mesh.size(-1) >= 2: self._build_reduce_mesh_devided_64(fsdp_mesh) self._build_reduce_mesh_mapping(model, fsdp_mesh) + def _build_decoupled_reduce_meshes( + self, model: nn.Module, fsdp_mesh: DeviceMesh, expert_fsdp_mesh: DeviceMesh + ) -> None: + # Decoupled EP/FSDP layout (root mesh `(replicate, efsdp, ep)`): + # * dense fp8 weights are sharded over `dp_shard` = flatten(efsdp, ep): consecutive FSDP + # shards live on consecutive ranks (stride 1); + # * routed-expert fp8 weights are sharded over `efsdp`, whose ranks are `ep` apart. + # Each class gets its own "reduce max" meshes, built with the matching rank stride. + from xtuner.v1.float8.float8_gmm_tile_wise import TileWiseFloat8GroupedLinear + from xtuner.v1.float8.float8_linear_tile_wise import TileWiseFloat8Linear + + world_size = dist.get_world_size() + dense_shard_size = fsdp_mesh.size(-1) + expert_shard_size = expert_fsdp_mesh.size(-1) + expert_stride = world_size // expert_fsdp_mesh.size() # == ep_size + + self.tilewise_reduce_mesh_devided_64 = ( + self._build_strided_reduce_mesh(2, 1) if dense_shard_size >= 2 and dense_shard_size % 2 == 0 else None + ) + self.expert_tilewise_reduce_mesh_devided_64 = ( + self._build_strided_reduce_mesh(2, expert_stride) + if expert_shard_size >= 2 and expert_shard_size % 2 == 0 + else None + ) + self.tilewise_reduce_mesh_mapping = self._build_strided_reduce_mesh_mapping( + model, (TileWiseFloat8Linear,), dense_shard_size, 1 + ) + self.expert_tilewise_reduce_mesh_mapping = self._build_strided_reduce_mesh_mapping( + model, (TileWiseFloat8GroupedLinear,), expert_shard_size, expert_stride + ) + + @staticmethod + def _build_strided_reduce_mesh(num_ranks: int, stride: int) -> DeviceMesh: + # Groups of `num_ranks` ranks spaced `stride` apart, e.g. (r, r + stride, ...). + world_size = dist.get_world_size() + assert world_size % (num_ranks * stride) == 0, (world_size, num_ranks, stride) + return init_device_mesh( + "cuda", + (world_size // (num_ranks * stride), num_ranks, stride), + mesh_dim_names=("_", "tilewise_reduce", "ep_or_tp"), + )["tilewise_reduce"] + + def _build_strided_reduce_mesh_mapping( + self, model: nn.Module, module_types: Tuple[type, ...], shard_size: int, stride: int + ) -> Dict[Tuple[int, int], DeviceMesh]: + SHARD_DIM = 0 + mapping: Dict[Tuple[int, int], DeviceMesh] = {} + for module in model.modules(): + if not isinstance(module, module_types): + continue + assert isinstance(module.weight, DTensor), ( + "`build_reduce_mesh` should be called after apply fully_shard to the model." + ) + local_shape = module.weight._local_tensor.shape + if local_shape[SHARD_DIM] >= 128: + assert local_shape[SHARD_DIM] % 128 in (0, 64), ( + f"Currently only local_shape[SHARD_DIM] % 128 == 0 or " + f"local_shape[SHARD_DIM] % 128 == 64 is supported, got {local_shape}. Please contact us." + ) + continue + assert 128 % local_shape[SHARD_DIM] == 0, ( + f"Currently only local_shape[SHARD_DIM] % 128 == 0 is supported, got {local_shape}. Please contact us." + ) + reduce_world_size = 128 // local_shape[SHARD_DIM] + if local_shape in mapping: + assert mapping[local_shape].size() == reduce_world_size + continue + assert shard_size >= reduce_world_size and shard_size % reduce_world_size == 0, ( + f"Expect FSDP shard size >= reduce_world_size and shard size % reduce_world_size == 0, " + f"got shard size = {shard_size}, reduce_world_size = {reduce_world_size}. Please contact us." + ) + mapping[local_shape] = self._build_strided_reduce_mesh(reduce_world_size, stride) + return mapping + def _build_reduce_mesh_devided_64(self, fsdp_mesh: DeviceMesh): # 为了支持 moe 参数被 fsdp 和 ep 切成 dout = n * 128 + 64 (n >= 1) 的情况 # fsdp rank 0 的后 64 个 dim 要跟 fsdp rank 1 的前 64 个 dim 共同组成一个 block @@ -221,9 +313,26 @@ def precompute_float8_dynamic_scale_for_fsdp(self, model: Union[nn.Module, List[ for m in models: if self.is_tilewise_fp8: - precompute_tilewise_float8_scale_for_fsdp( - m, self.tilewise_reduce_mesh_mapping, self.tilewise_reduce_mesh_devided_64 - ) + if self.expert_tilewise_reduce_mesh_mapping is None: + precompute_tilewise_float8_scale_for_fsdp( + m, self.tilewise_reduce_mesh_mapping, self.tilewise_reduce_mesh_devided_64 + ) + else: + from xtuner.v1.float8.float8_gmm_tile_wise import TileWiseFloat8GroupedLinear + from xtuner.v1.float8.float8_linear_tile_wise import TileWiseFloat8Linear + + precompute_tilewise_float8_scale_for_fsdp( + m, + self.tilewise_reduce_mesh_mapping, + self.tilewise_reduce_mesh_devided_64, + module_types=(TileWiseFloat8Linear,), + ) + precompute_tilewise_float8_scale_for_fsdp( + m, + self.expert_tilewise_reduce_mesh_mapping, + self.expert_tilewise_reduce_mesh_devided_64, + module_types=(TileWiseFloat8GroupedLinear,), + ) if self.is_tensorwise_fp8: assert self.fsdp_mesh is not None, "FSDP mesh must be set for tensorwise float8 training." precompute_tensorwise_float8_scale_for_fsdp(m, self.fsdp_mesh) diff --git a/xtuner/v1/float8/fsdp_utils.py b/xtuner/v1/float8/fsdp_utils.py index 8444672fcb..75b8b8372c 100644 --- a/xtuner/v1/float8/fsdp_utils.py +++ b/xtuner/v1/float8/fsdp_utils.py @@ -124,13 +124,17 @@ def precompute_tilewise_float8_scale_for_fsdp( module: nn.Module, reduce_mesh_mapping: Dict[Tuple[int, int], DeviceMesh], # absmax need to be reduced in this group reduce_mesh_devided_64: Optional[DeviceMesh] = None, # All params share the same reduce mesh + module_types: Optional[Tuple[type, ...]] = None, # restrict to these fp8 module classes (default: all) ) -> None: from xtuner.v1.float8 import TileWiseFloat8GroupedLinear, TileWiseFloat8Linear + if module_types is None: + module_types = (TileWiseFloat8Linear, TileWiseFloat8GroupedLinear) + weights: List[WeightWithDynamicTilewiseFloat8CastTensor] = [] for m in module.modules(): if ( - isinstance(m, (TileWiseFloat8Linear, TileWiseFloat8GroupedLinear)) + isinstance(m, module_types) and isinstance(m.weight, DTensor) and isinstance(m.weight._local_tensor, WeightWithDynamicTilewiseFloat8CastTensor) ): diff --git a/xtuner/v1/model/base.py b/xtuner/v1/model/base.py index 50e4ee59dc..39352cbec9 100644 --- a/xtuner/v1/model/base.py +++ b/xtuner/v1/model/base.py @@ -564,6 +564,9 @@ class BaseModel(nn.Module): load_spec_mapping: dict[str, LoadSpec] = {} fsdp_mesh: DeviceMesh | None = None hsdp_mesh: DeviceMesh | None = None + # Only set by MoE models on the decoupled EP/FSDP path: the mesh routed experts are + # `fully_shard`ed on, `(efsdp,)` or `(replicate, efsdp)` with `efsdp = dp_shard / ep_size`. + expert_fsdp_mesh: DeviceMesh | None = None fsdp_config: FSDPConfig | None = None config: XTunerBaseModelConfig @@ -1009,7 +1012,7 @@ def float8_handler(self): self._float8_handler = self.config.float8_cfg.build() if self.fsdp_mesh is not None: - self._float8_handler.build_reduce_mesh(self, self.fsdp_mesh) + self._float8_handler.build_reduce_mesh(self, self.fsdp_mesh, expert_fsdp_mesh=self.expert_fsdp_mesh) return self._float8_handler @torch.no_grad() @@ -1917,9 +1920,31 @@ def _fsdp_foreach_allgather( return tensor_list fsdp_group = self.fsdp_mesh.get_group() - save_plan_list = [load_spec.plan_hf_save(gather_process_group=fsdp_group) for load_spec in load_spec_list] + expert_fsdp_group: dist.ProcessGroup | None = None + if self.expert_fsdp_mesh is not None: + # Decoupled EP/FSDP: routed experts are FSDP-sharded on the innermost (`efsdp`) dim of + # `expert_fsdp_mesh`, so their FSDP-only gather must use that group instead. + expert_fsdp_group = self.expert_fsdp_mesh.get_group(self.expert_fsdp_mesh.ndim - 1) + save_plan_list = [ + load_spec.plan_hf_save( + gather_process_group=self._fsdp_gather_group(load_spec, fsdp_group, expert_fsdp_group) + ) + for load_spec in load_spec_list + ] return unshard_tensors_for_hf_save(list(tensor_list), save_plan_list) + def _fsdp_gather_group( + self, + load_spec: LoadSpec, + fsdp_group: dist.ProcessGroup, + expert_fsdp_group: dist.ProcessGroup | None, + ) -> dist.ProcessGroup: + if expert_fsdp_group is None: + return fsdp_group + if any(self._is_same_process_group(shard.group, expert_fsdp_group) for shard in load_spec.shards): + return expert_fsdp_group + return fsdp_group + @staticmethod def _is_same_process_group(left: dist.ProcessGroup, right: dist.ProcessGroup) -> bool: if left is right: diff --git a/xtuner/v1/model/moe/moe.py b/xtuner/v1/model/moe/moe.py index 44c61ef974..3a48d05568 100644 --- a/xtuner/v1/model/moe/moe.py +++ b/xtuner/v1/model/moe/moe.py @@ -201,9 +201,6 @@ class MoE(BaseModel): ep_mesh: DeviceMesh | None = None expert_tp_mesh: DeviceMesh | None = None ep_tp_mesh: DeviceMesh | None = None - # Only set on the decoupled EP/FSDP path: mesh used to `fully_shard` routed experts, - # i.e. `(efsdp,)` or `(replicate, efsdp)` with `efsdp = dp_shard / ep_size`. - expert_fsdp_mesh: DeviceMesh | None = None dense_decoder_layer_cls = DenseDecoderLayer moe_decoder_layer_cls = MoEDecoderLayer mtp_layer_cls = MTPLayer @@ -1279,13 +1276,20 @@ def fully_shard( self._init_device_mesh(fsdp_config) if self.config.float8_cfg is not None: - if self.fsdp_config.decouple_ep_fsdp: - # Experts and dense params are sharded on meshes of different sizes; the fp8 - # padding / tile-wise reduce meshes need per-class shard sizes (PR-3). - raise NotImplementedError("float8 training with `decouple_ep_fsdp` is not supported yet") # As we modify the shape of the model's parameters, # we need to reinitialize the load spec mapping. - Float8Handler.pad_for_fsdp(self, cast(DeviceMesh, self.fsdp_mesh), callback_after_pad=self._init_load_spec) + if self.fsdp_config.decouple_ep_fsdp: + # Routed experts are padded for `efsdp` FSDP chunks, everything else for `dp_shard`. + Float8Handler.pad_for_fsdp( + self, + cast(DeviceMesh, self.fsdp_mesh), + callback_after_pad=self._init_load_spec, + expert_fsdp_mesh=self.expert_fsdp_mesh, + ) + else: + Float8Handler.pad_for_fsdp( + self, cast(DeviceMesh, self.fsdp_mesh), callback_after_pad=self._init_load_spec + ) # Just for narrowing the type of self.fsdp_mesh and self.ep_mesh assert self.fsdp_mesh is not None From d521615e99c71e5919dfe0e088f01637419a819d Mon Sep 17 00:00:00 2001 From: daixu Date: Thu, 3 Sep 2026 03:42:45 +0000 Subject: [PATCH 04/10] [Fix] Gather RL layer-wise weights with the submodule that owns them Root cause: `WeightIterator.iter_layer_batches` (IPC + Turbomind) took the parameters and `LoadSpec`s of a compose model's language tower but ran the FSDP-only all-gather through the outer compose model. The compose model is wrapped on the world mesh, has no `expert_fsdp_mesh`, and never builds a `load_spec_mapping` of its own, so on the decoupled EP/FSDP layout the language tower's `efsdp` expert shards (and, with HSDP, its `dp_shard` dense shards) were treated as shards to preserve and never gathered: Turbomind received rank-local fragments. The vision / projector / non-layer language parameters additionally hit the empty `load_spec_mapping` of the compose model. Fix: resolve the owning submodule (language_model / vision_tower / multi_modal_projector, or the model itself) for every parameter and run `_fsdp_foreach_allgather`, `_to_float8` and the `load_spec_mapping` lookup on that owner. Plain models are unaffected. Test plan: `tests/rl/test_weight_iterator.py::TestLayerBatchesGatherWithParamOwner` builds a compose model whose language tower is EP-sharded and FSDP-sharded on `efsdp` (with and without an HSDP replicate dim) on fake process groups and asserts every streamed tensor is complete (EP slice kept; `efsdp`, `dp_shard` and world shards gathered). It fails before the fix and passes after; the existing weight-iterator, load-spec and L0 mesh tests still pass. --- tests/rl/test_weight_iterator.py | 194 ++++++++++++++++++ xtuner/v1/rl/weight_update/weight_iterator.py | 31 ++- 2 files changed, 219 insertions(+), 6 deletions(-) diff --git a/tests/rl/test_weight_iterator.py b/tests/rl/test_weight_iterator.py index ec7a3f7884..83bdb19fe5 100644 --- a/tests/rl/test_weight_iterator.py +++ b/tests/rl/test_weight_iterator.py @@ -10,6 +10,7 @@ from xtuner.v1.float8.fsdp_utils import WeightWithDynamicTilewiseFloat8CastTensor from xtuner.v1.float8.triton_kernels.per_block_quant_gemm import per_block_quant_torch from xtuner.v1.model.base import BaseModel, HFSaveCfg, XTunerBaseModelConfig +from xtuner.v1.model.compose.base import BaseComposeConfig from xtuner.v1.rl.weight_update.data import RolloutBackend, RolloutWeightUpdateInfo, RolloutWeightUpdateTarget from xtuner.v1.rl.weight_update.weight_iterator import WeightIterator from xtuner.v1.utils import get_device @@ -231,3 +232,196 @@ def gather_train_ep_shards( state_dict[f"expert_{index}"].cpu(), torch.tensor([index], dtype=torch.bfloat16), ) + + +def _bf16_param(values: list[int]) -> nn.Parameter: + return nn.Parameter(torch.tensor(values, device=get_device(), dtype=torch.bfloat16)) + + +class _DecoupledLanguageTower(BaseModel): + """Language tower on the decoupled EP/FSDP layout as seen from rank 0. + + ``layers.0.experts.weight`` is EP-sharded and then FSDP-sharded on ``efsdp``; the dense + parameters are FSDP-sharded on ``dp_shard``. Every local block holds ``[0, 1]``. + """ + + def __init__(self, groups: dict[str, dist.ProcessGroup], expert_fsdp_ndim: int) -> None: + super().__init__(XTunerBaseModelConfig()) + layer = nn.Module() + layer.attn = nn.Module() + layer.attn.weight = _bf16_param([0, 1]) + layer.experts = nn.Module() + layer.experts.weight = _bf16_param([0, 1]) + self.layers = nn.ModuleDict({"0": layer}) + self.norm = nn.Module() + self.norm.weight = _bf16_param([0, 1]) + self.fsdp_mesh = SimpleNamespace(get_group=lambda: groups["dp_shard"]) + self.expert_fsdp_mesh = SimpleNamespace(ndim=expert_fsdp_ndim, get_group=lambda dim=None: groups["efsdp"]) + dense_shards = [ShardDescriptor(dim=0, group=groups["dp_shard"])] + self.load_spec_mapping = { + "layers.0.attn.weight": LoadSpec( + name="layers.0.attn.weight", + global_hf_keys=["attn"], + global_shape=(8,), + shards=dense_shards, + local_shape=(2,), + ), + "layers.0.experts.weight": LoadSpec( + name="layers.0.experts.weight", + global_hf_keys=[f"expert_{index}" for index in range(8)], + global_shape=(8,), + fused_dim=0, + shards=[ShardDescriptor(dim=0, group=groups["ep"]), ShardDescriptor(dim=0, group=groups["efsdp"])], + local_shape=(2,), + ), + "norm.weight": LoadSpec( + name="norm.weight", + global_hf_keys=["norm"], + global_shape=(8,), + shards=dense_shards, + local_shape=(2,), + ), + } + + def to_hf_key_list(self, key: str) -> list[str]: + return [key] + + +class _WorldShardedTower(BaseModel): + """Vision tower / projector stand-in: one parameter FSDP-sharded on the world mesh.""" + + def __init__(self, block_name: str, world_group: dist.ProcessGroup, world_size: int) -> None: + super().__init__(XTunerBaseModelConfig()) + block = nn.Module() + block.weight = _bf16_param(list(range(8 // world_size))) + setattr(self, block_name, block) + self.fsdp_mesh = SimpleNamespace(get_group=lambda: world_group) + self.load_spec_mapping = { + f"{block_name}.weight": LoadSpec( + name=f"{block_name}.weight", + global_hf_keys=[block_name], + global_shape=(8,), + shards=[ShardDescriptor(dim=0, group=world_group)], + local_shape=(8 // world_size,), + ) + } + + def to_hf_key_list(self, key: str) -> list[str]: + return [key] + + +class _ComposeModel(BaseModel): + """Compose model stand-in: like ``BaseComposeModel.fully_shard`` it is wrapped on the world + mesh and has neither an ``expert_fsdp_mesh`` nor a ``load_spec_mapping`` of its own.""" + + def __init__( + self, + language_model: BaseModel, + vision_tower: BaseModel, + projector: BaseModel, + world_group: dist.ProcessGroup, + ) -> None: + super().__init__(XTunerBaseModelConfig()) + # `iter_layer_batches` only inspects `isinstance(model.config, BaseComposeConfig)`. + self.config = BaseComposeConfig.model_construct() + self.language_model = language_model + self.vision_tower = vision_tower + self.multi_modal_projector = projector + self.fsdp_mesh = SimpleNamespace(get_group=lambda: world_group) + + +class TestLayerBatchesGatherWithParamOwner: + """``iter_layer_batches`` must gather every parameter with the module that owns it. + + Regression test for compose MoE models on the decoupled EP/FSDP layout with the IPC/Turbomind + transport: the outer compose model is wrapped on the world mesh, so gathering with it kept the + language tower's ``efsdp`` (and HSDP ``dp_shard``) shards local and streamed rank-local + fragments instead of complete weights. + """ + + @pytest.mark.parametrize( + ("world_size", "expert_fsdp_ndim"), + [ + pytest.param(4, 1, id="ep2-efsdp2"), + pytest.param(8, 2, id="hsdp-replicate2-ep2-efsdp2"), + ], + ) + def test_compose_layer_batches_are_gathered_per_owner( + self, + monkeypatch: pytest.MonkeyPatch, + world_size: int, + expert_fsdp_ndim: int, + ) -> None: + groups = {name: dist.ProcessGroup(dist.HashStore(), 0, 1) for name in ("world", "dp_shard", "ep", "efsdp")} + group_ranks = { + groups["world"]: list(range(world_size)), + groups["dp_shard"]: [0, 1, 2, 3], + groups["ep"]: [0, 1], + groups["efsdp"]: [0, 2], + } + monkeypatch.setattr( + dist, + "get_world_size", + lambda group=None: world_size if group is None else len(group_ranks[group]), + ) + monkeypatch.setattr(dist, "get_rank", lambda group=None: 0) + monkeypatch.setattr(dist, "get_process_group_ranks", lambda group: group_ranks[group]) + + gathered_groups: list[dist.ProcessGroup] = [] + + def gather_contiguous_blocks( + tensor_list: list[torch.Tensor], + group: dist.ProcessGroup, + ) -> list[list[torch.Tensor]]: + # Rank ``r`` of ``group`` holds the r-th contiguous block, each block the size of ours. + gathered_groups.append(group) + return [ + [tensor + rank * tensor.numel() for rank in range(len(group_ranks[group]))] for tensor in tensor_list + ] + + monkeypatch.setattr(load_spec_module, "foreach_all_gather", gather_contiguous_blocks) + + language_model = _DecoupledLanguageTower(groups, expert_fsdp_ndim) + vision_tower = _WorldShardedTower("patch", groups["world"], world_size) + projector = _WorldShardedTower("proj", groups["world"], world_size) + model = _ComposeModel(language_model, vision_tower, projector, groups["world"]) + rollout_info = RolloutWeightUpdateInfo( + rollout_config=cast(Any, SimpleNamespace()), + weight_update_targets=( + RolloutWeightUpdateTarget( + endpoint_rank=0, + update_ranks=(0,), + inference_engine_ranks=(0,), + server_url="http://rollout", + lifecycle_state="active", + ), + ), + train_rank=0, + transport_type="ipc", + backend="turbomind", + ) + iterator = WeightIterator( + config=SimpleNamespace(model_cfg=model.config), + engine=SimpleNamespace(model=model), + rollout_info=rollout_info, + global_hf_keys_mapping_cache={}, + ) + + state_dict = { + name: tensor.cpu() for batch in iterator.iter_layer_batches() for name, tensor in batch.state_dict.items() + } + + full = torch.arange(8, dtype=torch.bfloat16) + expected = { + # The EP shard stays local (experts 0-3 of this EP rank); only the `efsdp` shard is gathered. + "model.language_model.layers.0.mlp.experts.weight": full[:4], + "model.language_model.layers.0.attn.weight": full, + "model.language_model.norm.weight": full, + "model.vision_tower.patch.weight": full, + "model.multi_modal_projector.proj.weight": full, + } + assert set(state_dict) == set(expected) + for name, tensor in expected.items(): + torch.testing.assert_close(state_dict[name], tensor, rtol=0, atol=0) + assert {groups["efsdp"], groups["dp_shard"], groups["world"]} <= set(gathered_groups) + assert groups["ep"] not in gathered_groups diff --git a/xtuner/v1/rl/weight_update/weight_iterator.py b/xtuner/v1/rl/weight_update/weight_iterator.py index 02da41bc47..c908d761d7 100644 --- a/xtuner/v1/rl/weight_update/weight_iterator.py +++ b/xtuner/v1/rl/weight_update/weight_iterator.py @@ -139,12 +139,16 @@ def iter_layer_batches(self): else: dtype = torch.bfloat16 - def get_params(tensor_list, name_list, save_dtype): + def get_params(owner, tensor_list, name_list, save_dtype): + # Gather with the module that owns the parameters, not with the outer model. A + # compose model is wrapped on the world mesh, while its language tower may be + # sharded on its own meshes (EP, `dp_shard`, `efsdp`, HSDP shard); only the owner + # knows which shards are FSDP shards to gather and which EP shards to keep local. _tensor_list, _spec_list = list(zip(*tensor_list)) - fsdp_unshard_tensor_list = model._fsdp_foreach_allgather(_tensor_list, _spec_list) + fsdp_unshard_tensor_list = owner._fsdp_foreach_allgather(_tensor_list, _spec_list) if save_dtype == torch.float8_e4m3fn: runtime_is_float8_list = [is_float8_weight(tensor) for tensor in _tensor_list] - fsdp_unshard_tensor_list, name_list = model._to_float8( + fsdp_unshard_tensor_list, name_list = owner._to_float8( fsdp_unshard_tensor_list, name_list, runtime_is_float8_list, @@ -191,7 +195,7 @@ def get_params(tensor_list, name_list, save_dtype): name = name.replace(".gate.", ".mlp.gate.") name_list.append(name) tensor_list.append((local_tensor, load_spec)) - fsdp_unshard_tensor_list, name_list = get_params(tensor_list, name_list, dtype) + fsdp_unshard_tensor_list, name_list = get_params(language_model, tensor_list, name_list, dtype) state_dict = dict(zip(name_list, fsdp_unshard_tensor_list)) yield WeightUpdateBatch(state_dict) @@ -200,7 +204,10 @@ def get_params(tensor_list, name_list, save_dtype): continue local_tensor = param._local_tensor if isinstance(param, DTensor) else param local_tensor = local_tensor.bfloat16() - load_spec = model.load_spec_mapping.get(name) + owner, owner_name = self._param_owner(model, name) + load_spec = owner.load_spec_mapping.get(owner._clean_param_name(owner_name)) + if load_spec is None: + raise ValueError(f"Internal Error. Parameter {name} not found in load_spec_mapping.") if isinstance(model.config, BaseComposeConfig): if "vision_tower." in name: @@ -220,7 +227,7 @@ def get_params(tensor_list, name_list, save_dtype): name = "model.embed_tokens.weight" tensor_list = [(local_tensor, load_spec)] name_list = [name] - fsdp_unshard_tensor_list, name_list = get_params(tensor_list, name_list, dtype) + fsdp_unshard_tensor_list, name_list = get_params(owner, tensor_list, name_list, dtype) state_dict = dict(zip(name_list, fsdp_unshard_tensor_list)) yield WeightUpdateBatch(state_dict) @@ -228,3 +235,15 @@ def get_params(tensor_list, name_list, save_dtype): yield WeightUpdateBatch({}, finished=True) DEVICE_MODULE.empty_cache() + + @staticmethod + def _param_owner(model: Any, name: str) -> tuple[Any, str]: + # Map a `model.state_dict()` key to the submodule that owns the parameter and to the + # key relative to that submodule, which is how each owner's `load_spec_mapping` is keyed. + # Compose models never build a `load_spec_mapping` of their own; their children do. + if isinstance(model.config, BaseComposeConfig): + for submodule in ("language_model", "vision_tower", "multi_modal_projector"): + prefix = f"{submodule}." + if name.startswith(prefix): + return getattr(model, submodule), name[len(prefix) :] + return model, name From 7df5a7ddad0b2ab73f4b3263660ffb6f07a6e80a Mon Sep 17 00:00:00 2001 From: daixu Date: Thu, 3 Sep 2026 06:36:59 +0000 Subject: [PATCH 05/10] [Docs] Add the decoupled EP/FSDP design note for maintainers --- docs/design/decouple_ep_fsdp.md | 177 ++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/design/decouple_ep_fsdp.md diff --git a/docs/design/decouple_ep_fsdp.md b/docs/design/decouple_ep_fsdp.md new file mode 100644 index 0000000000..241ba7960a --- /dev/null +++ b/docs/design/decouple_ep_fsdp.md @@ -0,0 +1,177 @@ +# MoE EP 与 FSDP 解耦(dp2ep 布局) + +Expert Parallelism(EP)与 FSDP 解耦,是把 EP 从"与 FSDP 正交的 mesh 维度"改成"FSDP shard 维度的子维度"(torchtitan 称之为 dp2ep)。解耦后 dense 参数在完整的 data-parallel shard 域上分片、不再沿 EP 复制;routed expert 保持按 EP 切分专家,再只沿剩余的 `efsdp` 维做 FSDP。配置入口是 `FSDPConfig.decouple_ep_fsdp`,默认关闭,关闭时旧路径不受影响。 + +```mermaid +flowchart LR + R["root mesh (replicate, efsdp, ep)"] --> EP["ep_mesh = root[ep]
token dispatch / expert 归属"] + R --> DS["fsdp_mesh = flatten(efsdp, ep) = dp_shard
dense 参数的 FSDP 分片域"] + R --> EF["expert_fsdp_mesh = root[efsdp]
routed expert 在 EP 之上的 FSDP 分片域"] + R -. "replicate > 1 (HSDP)" .-> HS["hsdp_mesh = root[replicate, dp_shard]
expert_fsdp_mesh = root[replicate, efsdp]"] +``` + +## 1. 问题:EP 把 FSDP 分片度锁死 + +旧路径的 MoE 训练 mesh 是二维 `(fsdp, ep)`,且 `fsdp = world_size / ep_size`。两类参数的 data-parallel 语义不同,却共用这一张 mesh: + +| 参数 | 旧路径 placement | 后果 | +|---|---|---| +| routed expert | `Shard(0)` on `ep`,再 FSDP over `fsdp` | 正确,且与新路径数学同构 | +| dense(attention / router / shared expert / embedding / lm_head) | `Replicate()` on `ep`(`_replicate_other_params`),再 FSDP over `fsdp` | 参数、梯度、优化器状态在 EP 维复制 `ep` 份;每步一次额外的 coalesced all-reduce 补梯度 | +| HSDP | `assert ep_size == 1` | HSDP 与 EP 不能共存 | + +EP 越大,dense 侧越亏。以 GLM-5.2-30B 单机 8 卡为例,EP=8 时每卡保存一份完整的 dense 参数与 fp32 优化器状态,legacy 路径 EP=8 的 reserved 显存顶到约 132 GiB 的 allocator 上限(allocated 峰值 120 GiB),每步触发 allocator 释放重试,步时退化到 EP=4 的 7 倍(见 §5)。 + +## 2. 目标布局 + +```text +dp_shard = hsdp_sharding_size or world_size # dense 参数的 FSDP 分片数 +replicate = world_size / dp_shard # HSDP 副本数,无 HSDP 时为 1 +efsdp = dp_shard / ep # routed expert 在 EP 之上的 FSDP 分片数 + +root mesh = (replicate, efsdp, ep) # ep 保持最内维:EP group 仍是连续 rank +dense : FSDP over flatten(efsdp, ep) = dp_shard (+ replicate 上 HSDP) +expert : Shard(0) over ep + FSDP over efsdp (+ replicate 上 HSDP) +``` + +约束从 `fsdp = world / ep`(死)变成 `ep | dp_shard`(活)。routed expert 的数学与旧路径完全相同:旧路径在 `world/ep` 个 rank 上 FSDP,新路径在 `efsdp = dp_shard/ep` 个 rank 上 FSDP,无 HSDP 时二者就是同一组 rank。新旧差异全部集中在 dense 侧。 + +单机 8 卡的三个典型拓扑: + +| 配置 | root mesh | dense 每卡持有 | routed expert 每卡持有 | +|---|---|---|---| +| EP=8(旧) | `(fsdp=1, ep=8)` | 100%(8 份复制) | 1/8 的 expert | +| EP=8,解耦 | `(1, efsdp=1, ep=8)` | 1/8 | 1/8 的 expert(与旧路径相同) | +| EP=4(旧) | `(fsdp=2, ep=4)` | 1/2(4 份复制) | 1/4 的 expert,再在 rank r 与 r+4 之间 FSDP 对半 | +| EP=4,解耦 | `(1, efsdp=2, ep=4)` | 1/8 | 同上(rank r 与 r+4 构成 efsdp group) | +| 16 卡,EP=8,`hsdp_sharding_size=8` | `(replicate=2, efsdp=1, ep=8)` | 节点内 1/8,跨节点复制 | 节点内 EP=8,跨节点复制并由 HSDP 归约 | + +"EP=8 且 FSDP=8"不再是矛盾配置:对 dense 而言 FSDP shard 数是 8;对 expert 而言 `efsdp = 1`,因为 expert 已被 EP 切成 8 份。 + +## 3. 实现 + +### 3.1 配置与校验 + +`FSDPConfig.decouple_ep_fsdp: bool = False`。旧断言"HSDP 要求 `ep_size == 1`"只在开关关闭时保留;开关打开时改为要求 `hsdp_sharding_size % ep_size == 0`,即必须存在整数 `efsdp`。`MoE._init_decoupled_device_mesh` 再次校验 `world_size % dp_shard == 0` 与 `dp_shard % ep_size == 0`。ExpertTP(`expert_tp_size > 1`)与解耦同时开启时抛 `NotImplementedError`,ETP 的 `(Shard, InterleavedShard)` 二维 placement 需要单独设计 mesh 维。 + +### 3.2 单一 root mesh + +`MoE._init_decoupled_device_mesh` 一次性建立 `(replicate, efsdp, ep)` root,所有子 mesh 从它派生: + +- `ep_mesh = root[ep]`:dispatcher / DeepEP 拿到的 EP group 不变,仍是节点内连续 rank; +- `fsdp_mesh = root[efsdp, ep]._flatten("dp_shard")`:语义仍是"dense 参数的一维 shard group",`_fsdp_foreach_allgather`、`Float8Handler.build_reduce_mesh` 等既有消费者无需改动; +- `hsdp_mesh = root[replicate, dp_shard]`(仅 `replicate > 1`):dense 的 HSDP mesh,与旧路径契约一致; +- `expert_fsdp_mesh = root[efsdp]` 或 `root[replicate, efsdp]`:新增属性,只在解耦路径上非空,dense 模型恒为 `None`。 + +所有子 mesh 同源很重要:FSDP2 要求已是 DTensor 的参数与 FSDP mesh 共享同一个 root,expert 参数最终要同时带 EP shard 和 `efsdp` 上的 `_StridedShard`。`efsdp = 1` 时该维保留而不是省略——对 expert 做的那次 `fully_shard` 是 mixed precision policy 生效的地方(torchtitan #1324 的同一注释)。 + +### 3.3 两级 `fully_shard` + +解耦路径跳过 `_replicate_other_params`:dense 参数进入 `fully_shard` 时是普通 tensor,由 FSDP 在完整 `dp_shard` 上分片。每个 decoder layer 的包装顺序变为: + +1. `_fully_shard_expert_blocks`:找出该层的所有 `MoEBlock`(只含 routed expert 的 grouped linear 与激活),在 `expert_fsdp_mesh` 上先做一次 `fully_shard`; +2. 再在 dense 的 `fsdp_mesh` / `hsdp_mesh` 上包装整个 layer。FSDP2 会保留内层的 expert wrapper,把 expert 参数从外层 param group 中排除。 + +MTP layer 使用同样的两级包装。forward prefetch 列表同时包含下一层与其 expert block,让 expert 的 all-gather 与 attention 重叠,而不是串行等在 MoE block 前。 + +### 3.4 梯度归约 + +不变式:每个参数的最终梯度等于全 data-parallel world 上的均值,与 `ep` / `efsdp` 取值无关。`MoE._scale_and_reduce_grad_decoupled` 只做三件事: + +| 参数类 | 谁完成归约 | 解耦路径的额外动作 | +|---|---|---| +| dense FSDP 参数(任一 `Shard` placement) | FSDP 在 `dp_shard` 上 reduce-scatter;HSDP 再跨 `replicate` all-reduce | 无(旧路径的手工跨 EP all-reduce 删除) | +| routed expert | FSDP 在 `efsdp` 上 reduce-scatter,但每个 expert 已看过整个 EP group 路由来的 token | `grad.div_(ep)`,与 NeMo AutoModel 的 `ep_ratio = dp_shard / efsdp` 相同 | +| FSDP ignored、各维全 `Replicate` 的 fp32 参数(`fp32_keys_pattern` 命中的 dense 参数) | 无人归约 | 沿所有 `Replicate` 维 flatten 后做一次 coalesced all-reduce 求均值 | + +这也是解耦路径不能复用旧梯度逻辑的原因:旧 dense 是 EP replicate,需要手工跨 EP 归约;新 dense 已由 FSDP 在完整 `dp_shard` 上归约,再做一次就是重复归约。注意第三行与 HSDP 无关:无 HSDP 时 world mesh 仍是三维 `(1, efsdp, ep)`,ignored 参数在三个维度上都是 `Replicate`,仍需这次 all-reduce。 + +### 3.5 FP8 + +tile-wise FP8 需要知道每个本地权重会被多少个 FSDP rank 切分(决定 padding),以及 scale 的 reduce-max 应跨哪些 rank。解耦后 dense 与 expert 的答案不同: + +- dense linear 按 `dp_shard` 的 chunk 数 padding,reduce mesh 的 rank stride 为 1; +- grouped-expert linear 按 `efsdp` 的 chunk 数 padding,reduce mesh 的 rank stride 为 `ep`; +- `Float8Handler.pad_for_fsdp` 接受可选的 `expert_fsdp_mesh`,`build_reduce_mesh` 为两类参数各建一套 tile-wise reduce mesh,scale 预计算按类各执行一次。 + +旧路径 `expert_fsdp_mesh is None`,走原有的单 mesh 代码。tensor-wise FP8 只作用于 dense linear,shard group 仍是 `fsdp_mesh`,无需改动。 + +### 3.6 HF 权重与 DCP + +`LoadSpec.from_tensor` 直接从 DTensor placement 记录 shard 历史(EP 的 `Shard(0)`、`efsdp` 上的 `_StridedShard`、HSDP 的 `Replicate` 不产生 shard)。同步 HF save 按 shard 描述逆向 unshard 后再走模型已有的 HF key / fused-expert 映射;DCP 按 DTensor placement 保存并在载入时 reshard。因此没有为任何模型重写 `state_dict_adapter`,工作量在于让新 mesh 产出正确的 `LoadSpec`。 + +### 3.7 RL 权重同步 + +Turbomind 的 layer-wise 更新只 gather FSDP shard、保留 EP-local 的 expert 切片。`BaseModel._fsdp_foreach_allgather` 按每个 `LoadSpec` 选择 gather group:spec 含 `efsdp` 上的 shard 时用 `expert_fsdp_mesh` 的 group,否则用 `fsdp_mesh` 的 group。 + +compose 模型(如 Qwen3.5-VL MoE)曾有一个 owner 选择错误:`WeightIterator.iter_layer_batches` 从 language tower 取参数与 `LoadSpec`,却用外层 compose 模型的 mesh 做 gather。compose 模型本身在 world mesh 上包装、没有 `expert_fsdp_mesh`,也不维护自己的 `load_spec_mapping`,于是 language tower 的 `efsdp` shard(HSDP 时连 `dp_shard` shard)被当作"应保留"的 shard 而不 gather,Turbomind 收到的是 rank-local 碎片。现在 gather 由参数所属的子模块(language / vision / projector)执行,并从该子模块的 `load_spec_mapping` 取 spec;`tests/rl/test_weight_iterator.py::TestLayerBatchesGatherWithParamOwner` 用假 process group 固定了 `efsdp = 2` 与 HSDP 两种拓扑下每个 tensor 的完整形状与取值。 + +## 4. 与参考实现的对照 + +| 项目 | mesh | expert 的 FSDP 维 | 梯度修正 | HSDP + EP | +|---|---|---|---|---| +| torchtitan(#1324 起) | `(pp, dp_replicate, dp_shard_mod_ep, dp_shard_in_ep, cp, tp)` | `dp_shard_mod_ep = dp_shard·cp / ep`,派生 | FSDP gradient divide factor(#1551) | 首日支持 | +| NeMo AutoModel | `device_mesh(dp_replicate, dp_shard_cp)` + `moe_mesh(ep, ep_shard)` | `ep_shard`,派生 | 显式 `grad.div_(ep_ratio)` | 支持 | +| Megatron-Core(Megatron-FSDP) | expert 参数在 expert data-parallel group 上分片 | `dp·cp / ep`,派生 | 框架内部 | 支持 | +| XTuner 本方案 | `(replicate, efsdp, ep)` | `efsdp = dp_shard / ep`,派生 | 显式 `grad.div_(ep)` | mesh 与数学路径支持,多机未实测 | + +三个参考实现都把 expert 的 FSDP 维当作派生量而不是独立配置,本方案与之一致。实现上采用 torchtitan #1324 的"两次 `fully_shard`"法,对 torch 版本要求最低;`shard_placement_fn` 单次包装、expert dim-1 分片(#1561)留作后续优化。 + +## 5. 验证结论与边界 + +| 项目 | 证据 | 边界 | +|---|---|---| +| mesh / placement / 两级 FSDP | `tests/model/test_decoupled_ep_fsdp_mesh.py`,fake-PG 下 8/16/64 rank 共 29 例;旧布局在开关关闭时逐项固定 | 需要一张空闲 GPU 建 CUDA context,不通信 | +| BF16 训练语义(L1) | tiny Qwen3-MoE 50 步:解耦 EP8 vs 旧 EP8 loss 最大相对差 `1.9e-5`(旧 EP8 vs EP1 为 `2.5e-5`);3.4B 模型每卡 dense 参数 836 → 105 MiB,峰值 allocated 11861 → 8203 MiB,步时 170 → 158 ms | 单机;reduction order 不同,不是逐 bit 相同 | +| `efsdp > 1`、HSDP + EP(L2) | 单机缩比拓扑 `(1,2,4)`、`(2,1,4)`、`(2,2,2)` 均在噪声内;16/64 rank 形状由 L0 固定 | 真正的双节点 collective 未跑 | +| HF / DCP(L3) | 刚 `from_hf` 后导出 807 个 key 全部 bit-exact;DCP 同布局 resume 与跨布局 reshard 后 loss 连续 | 未覆盖 async save、FP8 checkpoint、GLM/MTP 导出、world size 变化 | +| FP8 | 3.4B 与 GLM-5.2 tile-wise FP8 训练 20 步稳定,差异量级接近同配置重复噪声 | FP8 + HSDP、FP8 + checkpoint 未测 | +| GLM-5.2-30B,8×H200 目标配方 | EP4:1.688 s / 99.5 GiB → 1.658 s / 83.5 GiB;EP8:11.8 s / 120.0 GiB → 1.64 s / 76.3 GiB | EP8 的加速来自离开 allocator 上限(legacy 每步 2–3 次 alloc retry,解耦为 0),不是 collective 本身快 7 倍 | +| RL 权重同步 | plain MoE 的 per-spec gather 逻辑成立;compose 的 owner 错误已修复并有单测 | 未做真实 Turbomind 端到端对拍 | +| legacy 兼容 | 开关默认关闭,旧分支执行逻辑未改动,L0 旧布局回归通过 | 未做 base-vs-branch 端到端逐 tensor 对照 | + +详细数据见 `reports/L1.md`、`reports/L2.md`、`reports/L3.md`、`reports/GLM52.md`(不随代码 PR 合入)。 + +## 6. 已知限制与后续工作 + +- `fp32_keys_pattern` 命中 routed expert 时(当前内置 pattern 均不命中),该 expert 只被 `ignored_params` 排除而不会沿 `efsdp` 分片,梯度路径只做 `/ep`,`efsdp > 1` 或 `replicate > 1` 时副本会分叉。短期应显式拒绝;完整方案见 §7.2,与"expert 不做 FSDP"模式是同一实现。 +- expert 的分类在 FSDP 包装时按 `MoEBlock` 类型,在梯度缩放时按参数名含 `.experts`,不是同一事实来源;应在包装时记录 expert 参数身份,梯度、FP8、save / RL 复用。 +- 新拓扑约束目前用 `assert`,应改为 Pydantic validator + `ValueError`。 +- 两次不同 mesh 的 `fully_shard` 与 `DeviceMesh._flatten(name)` 只在 torch 2.8 / 2.9 验证;`pyproject.toml` 声明 `torch>=2.6`,需要补 CI 或为该开关声明更高的最低版本。 +- L1–L3 是人工实验脚本(写 JSON、打印统计),不是会失败的 pytest gate。 +- ExpertTP 与解耦互斥;expert dim-1 分片、`shard_placement_fn` 单次包装留作优化。 + +## 7. 开放问题(请架构师决策) + +### 7.1 是否保留 HSDP 与 EP 的共存 + +保留的成本很低。`replicate` 维由同一个 `init_device_mesh` 调用产生,HSDP 专属代码只有 `hsdp_mesh` / `expert_fsdp_mesh` 的二维选择;`replicate` 上的 all-reduce 由 FSDP2 完成。梯度路径没有任何 HSDP 分支:§3.4 三行规则中,前两行与 HSDP 无关,第三行在无 HSDP 时同样需要(world mesh 仍是三维)。去掉 HSDP 能省的只是几行 mesh 选择和 L2 的两个验证拓扑,梯度处理不会因此变简单。 + +保留的收益是多机拓扑:EP 留在节点内时,`hsdp_sharding_size = 8` 让 dense 的 all-gather / reduce-scatter 也留在 NVLink 域内,跨机只剩一次梯度 all-reduce;这是 torchtitan、AutoModel 都支持的组合,也是 DESIGN 中 64 卡场景的前提。目前 XTuner 对 HSDP 使用确实不多(compose 模型的 `init_world_mesh` 还标着 TODO),且本方案的 HSDP + EP 只在单机缩比拓扑验证过。 + +建议:保留 mesh 与数学路径,在文档中标为"experimental,多机未验证";若维护者希望收窄支持面,只需在 `FSDPConfig` 中对 `decouple_ep_fsdp and hsdp_sharding_size is not None` 抛错,待双节点验证后再放开,代码不必删除。 + +### 7.2 `efsdp` 是否作为独立配置暴露 + +三个参考实现都不暴露:expert 的 data-parallel 语义要求 `ep × efsdp = dp_shard`,即每个 token 在整个 world 上恰好被计算一次,`efsdp` 因此只能是派生量。Megatron-Core 只暴露 `--expert-model-parallel-size`,expert 的 FSDP 分片域是 `dp·cp / ep`;torchtitan、AutoModel 同理。 + +接入 MoonEP / UltraEP 这类动态冗余 expert 的通信库时,需要的不是另一个 `efsdp` 数值,而是一种新模式:"expert 不做 FSDP"。从公开资料看,这两个库在每层 MoE 前按实时负载把热 expert 复制到其它 EP rank 并预取权重,要求 EP-local 的 expert 权重在 MoE 计算窗口之外也是完整可寻址的;而 FSDP 分片的 expert 只在 all-gather 窗口内完整,且 FSDP 单元是整个 `MoEBlock`。当 `ep == dp_shard` 时(如单机 EP=8)`efsdp = 1` 已自动满足;当 `ep < dp_shard` 时,"不做 FSDP"意味着 expert 在 `efsdp` 维上复制,这是 placement 的改变而不是分片数的改变: + +```text +expert_fsdp = True (现状): Shard(0) on ep + _StridedShard on efsdp FSDP reduce-scatter,再 /ep +expert_fsdp = False(新增): Shard(0) on ep + Replicate on (replicate, efsdp) 手工 all-reduce 求均值,再 /ep +``` + +后者的梯度处理正是 §3.4 第三行已经存在的"沿 Replicate 维 all-reduce"分支,也正是修复 §6 第一条(fp32 pattern 命中 expert)所需的实现,两件事可以一次做完。代价是 expert 参数与优化器状态在 `efsdp` 维复制、失去 FSDP wrapper 提供的 mixed precision(需手工 cast)。 + +建议:`efsdp` 保持派生;不新增整数配置。等 MoonEP / UltraEP 接入启动时,在 `FSDPConfig` 增加 `expert_fsdp: bool = True`(或 `expert_shard_mode: "fsdp" | "replicate"`)。root mesh 不变,`ep_mesh` 仍是最内层连续 rank 的 group,dispatcher 的替换与本方案正交。 + +## 8. 合入建议 + +按 torchtitan 的演进顺序拆分: + +1. 基础解耦:配置开关、root mesh、两级 `fully_shard`、梯度归约、L0 fake-PG 测试; +2. 生态:FP8 per-class padding / reduce mesh、`LoadSpec` 驱动的 HF / DCP、RL per-spec gather 与 compose owner 修复; +3. 文档与后续:本文、§6 的限制项。 + +`decouple_ep_fsdp` 默认 `False`,两个代码 PR 都不改动旧路径的执行逻辑,出问题时一键回退。 From 9f5eefa920b7f1a77ebdfa08d138b2c11ba532d3 Mon Sep 17 00:00:00 2001 From: daixu Date: Thu, 3 Sep 2026 06:59:42 +0000 Subject: [PATCH 06/10] [Docs] Expand the decoupled EP/FSDP design note: code map, evidence links, limitation details --- docs/design/decouple_ep_fsdp.md | 257 ++++++++++++++++++++++++++++---- 1 file changed, 229 insertions(+), 28 deletions(-) diff --git a/docs/design/decouple_ep_fsdp.md b/docs/design/decouple_ep_fsdp.md index 241ba7960a..4bfb9be056 100644 --- a/docs/design/decouple_ep_fsdp.md +++ b/docs/design/decouple_ep_fsdp.md @@ -50,33 +50,149 @@ expert : Shard(0) over ep + FSDP over efsdp (+ replicate 上 HS ## 3. 实现 +行号对应分支 `feat/decouple-ep-fsdp` 的代码提交 `5923dc1d`(2026-09-03)。改动的文件与位置: + +| 文件 | 行号 | 内容 | +|---|---|---| +| [`xtuner/v1/config/fsdp.py`](../../xtuner/v1/config/fsdp.py#L48-L65) | 48–65 | `decouple_ep_fsdp` 开关与校验 | +| [`xtuner/v1/model/moe/moe.py`](../../xtuner/v1/model/moe/moe.py#L1651-L1716) | 1651–1716 | `_init_decoupled_device_mesh`:root mesh 与子 mesh | +| | [1486–1492](../../xtuner/v1/model/moe/moe.py#L1486-L1492) | `_init_device_mesh` 分流到新路径 | +| | [1194–1201](../../xtuner/v1/model/moe/moe.py#L1194-L1201) | 解耦路径跳过 `_replicate_other_params` | +| | [1214–1225](../../xtuner/v1/model/moe/moe.py#L1214-L1225)、[1250–1260](../../xtuner/v1/model/moe/moe.py#L1250-L1260) | decoder layer 两级 `fully_shard` 与 forward prefetch | +| | [1328–1360](../../xtuner/v1/model/moe/moe.py#L1328-L1360) | MTP layer 的同样处理 | +| | [1631–1649](../../xtuner/v1/model/moe/moe.py#L1631-L1649) | `_expert_blocks` / `_fully_shard_expert_blocks` | +| | [1392–1396](../../xtuner/v1/model/moe/moe.py#L1392-L1396)、[1583–1629](../../xtuner/v1/model/moe/moe.py#L1583-L1629) | `scale_and_reduce_grad` 分流与 `_scale_and_reduce_grad_decoupled` | +| | [1165–1176](../../xtuner/v1/model/moe/moe.py#L1165-L1176) | FP8 padding 传入 `expert_fsdp_mesh` | +| [`xtuner/v1/model/base.py`](../../xtuner/v1/model/base.py#L567-L569) | 567–569 | 新增 `expert_fsdp_mesh` 属性(dense 模型恒为 `None`) | +| | [1015](../../xtuner/v1/model/base.py#L1015) | float8 handler 建 reduce mesh 时传入 expert mesh | +| | [1916–1947](../../xtuner/v1/model/base.py#L1916-L1947) | `_fsdp_foreach_allgather` / `_fsdp_gather_group`:RL 按 spec 选 gather group | +| [`xtuner/v1/float8/float8_handler.py`](../../xtuner/v1/float8/float8_handler.py#L121-L161) | 45–49、121–161 | `pad_for_fsdp` / `build_reduce_mesh` 的 expert 分支 | +| | [166–239](../../xtuner/v1/float8/float8_handler.py#L166-L239) | `_build_decoupled_reduce_meshes` 与 strided reduce mesh helper | +| | [316–335](../../xtuner/v1/float8/float8_handler.py#L316-L335) | tile-wise scale 预计算按类各执行一次 | +| [`xtuner/v1/float8/fsdp_utils.py`](../../xtuner/v1/float8/fsdp_utils.py#L127-L137) | 127–137 | `precompute_tilewise_float8_scale_for_fsdp` 增加 `module_types` | +| [`xtuner/v1/rl/weight_update/weight_iterator.py`](../../xtuner/v1/rl/weight_update/weight_iterator.py#L140-L149) | 140–149、196–208、228、236–247 | layer-wise 权重同步按参数 owner gather | +| [`examples/v1/config/sft_glm5p2.py`](../../examples/v1/config/sft_glm5p2.py#L107-L115) | 107–115 | `DECOUPLE_EP_FSDP` / `HSDP_SHARDING_SIZE` 环境变量 | +| [`tests/model/test_decoupled_ep_fsdp_mesh.py`](../../tests/model/test_decoupled_ep_fsdp_mesh.py) | 全文 | L0 fake-PG placement 测试(29 例) | +| [`tests/rl/test_weight_iterator.py`](../../tests/rl/test_weight_iterator.py#L234-L418) | 234–418 | compose owner 回归测试 | +| [`tests/model/run_decoupled_ep_fsdp_numerics.py`](../../tests/model/run_decoupled_ep_fsdp_numerics.py)、[`run_decoupled_ep_fsdp_ckpt.py`](../../tests/model/run_decoupled_ep_fsdp_ckpt.py)、[`summarize_decoupled_ep_fsdp_numerics.py`](../../tests/model/summarize_decoupled_ep_fsdp_numerics.py) | 全文 | L1–L3 实验脚本 | + +相关但**未改动**的文件:[`xtuner/v1/utils/load_spec.py`](../../xtuner/v1/utils/load_spec.py)(`LoadSpec.from_tensor`、`plan_hf_save`、`_preserved_shard_indices`,已能表达新 placement)、[`xtuner/v1/module/decoder_layer/moe_decoder_layer.py`](../../xtuner/v1/module/decoder_layer/moe_decoder_layer.py)(`MoEBlock` 定义,是 expert FSDP 的包装单元)、`xtuner/v1/module/grouped_linear/moe_group_linear.py`(expert 仍在 `ep_mesh` 上 `Shard(0)`)。 + ### 3.1 配置与校验 -`FSDPConfig.decouple_ep_fsdp: bool = False`。旧断言"HSDP 要求 `ep_size == 1`"只在开关关闭时保留;开关打开时改为要求 `hsdp_sharding_size % ep_size == 0`,即必须存在整数 `efsdp`。`MoE._init_decoupled_device_mesh` 再次校验 `world_size % dp_shard == 0` 与 `dp_shard % ep_size == 0`。ExpertTP(`expert_tp_size > 1`)与解耦同时开启时抛 `NotImplementedError`,ETP 的 `(Shard, InterleavedShard)` 二维 placement 需要单独设计 mesh 维。 +位置:`xtuner/v1/config/fsdp.py` 48–65,`xtuner/v1/model/moe/moe.py` 1665–1678。 + +```python +# xtuner/v1/config/fsdp.py +decouple_ep_fsdp: bool = False # 默认关闭,旧路径不受影响 + +def model_post_init(self, __context): + if self.hsdp_sharding_size is not None: + if self.decouple_ep_fsdp: + assert self.hsdp_sharding_size % self.ep_size == 0 # 必须存在整数 efsdp + else: + assert self.ep_size == 1 # 旧断言只在旧路径保留 +``` + +`MoE._init_decoupled_device_mesh` 再次校验 `world_size % dp_shard == 0` 与 `dp_shard % ep_size == 0`。ExpertTP(`expert_tp_size > 1`)与解耦同时开启时抛 `NotImplementedError`,ETP 的 `(Shard, InterleavedShard)` 二维 placement 需要单独设计 mesh 维。 ### 3.2 单一 root mesh -`MoE._init_decoupled_device_mesh` 一次性建立 `(replicate, efsdp, ep)` root,所有子 mesh 从它派生: +位置:`xtuner/v1/model/moe/moe.py` 1651–1716(`_init_decoupled_device_mesh`),1486–1492(分流)。 + +```python +# xtuner/v1/model/moe/moe.py::_init_decoupled_device_mesh(节选) +world_size = dist.get_world_size() +dp_shard = fsdp_config.hsdp_sharding_size or world_size +replicate_size = world_size // dp_shard +efsdp_size = dp_shard // ep_size + +root_mesh = init_device_mesh( + device, (replicate_size, efsdp_size, ep_size), + mesh_dim_names=(f"{prefix}.replicate", f"{prefix}.efsdp", f"{prefix}.ep"), +) +self._world_mesh = root_mesh +self.ep_mesh = root_mesh[ep_name] # dispatcher 用,接口不变 +self.fsdp_mesh = root_mesh[efsdp_name, ep_name]._flatten(dp_shard_name) # dense 的一维 dp_shard +if replicate_size > 1: # HSDP + self.hsdp_mesh = root_mesh[replicate_name, dp_shard_name] + self.expert_fsdp_mesh = root_mesh[replicate_name, efsdp_name] +else: + self.hsdp_mesh = None + self.expert_fsdp_mesh = root_mesh[efsdp_name] +``` + +所有子 mesh 从同一个 root 派生: -- `ep_mesh = root[ep]`:dispatcher / DeepEP 拿到的 EP group 不变,仍是节点内连续 rank; +- `ep_mesh = root[ep]`:dispatcher / DeepEP 拿到的 EP group 不变,仍是节点内连续 rank。`MoE.__init__` 里先建的 `ep_mesh` 通过 PyTorch 的 mesh 相等性 hash 被重新挂到这个 root 上,因此 `ep` 维必须沿用旧名字 `{prefix}.ep`; - `fsdp_mesh = root[efsdp, ep]._flatten("dp_shard")`:语义仍是"dense 参数的一维 shard group",`_fsdp_foreach_allgather`、`Float8Handler.build_reduce_mesh` 等既有消费者无需改动; - `hsdp_mesh = root[replicate, dp_shard]`(仅 `replicate > 1`):dense 的 HSDP mesh,与旧路径契约一致; - `expert_fsdp_mesh = root[efsdp]` 或 `root[replicate, efsdp]`:新增属性,只在解耦路径上非空,dense 模型恒为 `None`。 -所有子 mesh 同源很重要:FSDP2 要求已是 DTensor 的参数与 FSDP mesh 共享同一个 root,expert 参数最终要同时带 EP shard 和 `efsdp` 上的 `_StridedShard`。`efsdp = 1` 时该维保留而不是省略——对 expert 做的那次 `fully_shard` 是 mixed precision policy 生效的地方(torchtitan #1324 的同一注释)。 +同源很重要:FSDP2 要求已是 DTensor 的参数与 FSDP mesh 共享同一个 root,expert 参数最终要同时带 EP shard 和 `efsdp` 上的 `_StridedShard`。`efsdp = 1` 时该维保留而不是省略——对 expert 做的那次 `fully_shard` 是 mixed precision policy 生效的地方(torchtitan #1324 的同一注释)。 ### 3.3 两级 `fully_shard` -解耦路径跳过 `_replicate_other_params`:dense 参数进入 `fully_shard` 时是普通 tensor,由 FSDP 在完整 `dp_shard` 上分片。每个 decoder layer 的包装顺序变为: - -1. `_fully_shard_expert_blocks`:找出该层的所有 `MoEBlock`(只含 routed expert 的 grouped linear 与激活),在 `expert_fsdp_mesh` 上先做一次 `fully_shard`; -2. 再在 dense 的 `fsdp_mesh` / `hsdp_mesh` 上包装整个 layer。FSDP2 会保留内层的 expert wrapper,把 expert 参数从外层 param group 中排除。 +位置:`xtuner/v1/model/moe/moe.py` 1194–1225(decoder layer),1250–1260(prefetch),1328–1360(MTP),1631–1649(helper)。 + +```python +# xtuner/v1/model/moe/moe.py::fully_shard(节选) +decoupled = self.fsdp_config.decouple_ep_fsdp +if not decoupled and (self.ep_mesh.size() > 1 or tp_enabled): + self._replicate_other_params(self) # 旧路径:dense 在 ep 维 Replicate;解耦路径跳过 + +for layer_idx, layer in self.layers.items(): + if decoupled: # 1) 先包 MoEBlock -> expert_fsdp_mesh + self._fully_shard_expert_blocks(layer, mp_policy=mp_policy, reshard_after_forward=...) + ... + self._fully_shard( # 2) 再包整层 -> dense 的 fsdp_mesh / hsdp_mesh + mesh=self.fsdp_mesh if self.hsdp_mesh is None else self.hsdp_mesh, module=layer, ... + ) + +for layer_cur, layer_next in zip(layers[:-1], layers[1:]): + if decoupled: # expert 的 all-gather 与下一层 dense 一起预取 + layer_cur.set_modules_to_forward_prefetch([*self._expert_blocks(layer_cur), layer_next]) + +@staticmethod +def _expert_blocks(module): + return [m for m in module.modules() if isinstance(m, MoEBlock)] + +def _fully_shard_expert_blocks(self, module, mp_policy, reshard_after_forward): + for expert_block in self._expert_blocks(module): + self._fully_shard(mesh=self.expert_fsdp_mesh, module=expert_block, mp_policy=mp_policy, ...) +``` -MTP layer 使用同样的两级包装。forward prefetch 列表同时包含下一层与其 expert block,让 expert 的 all-gather 与 attention 重叠,而不是串行等在 MoE block 前。 +解耦路径跳过 `_replicate_other_params`:dense 参数进入 `fully_shard` 时是普通 tensor,由 FSDP 在完整 `dp_shard` 上分片。每个 decoder layer 先把所有 `MoEBlock`(只含 routed expert 的 grouped linear 与激活)在 `expert_fsdp_mesh` 上 `fully_shard`,再在 dense mesh 上包装整层;FSDP2 会保留内层的 expert wrapper,把 expert 参数从外层 param group 中排除。MTP layer 使用同样的两级包装。forward prefetch 列表同时包含下一层与其 expert block,让 expert 的 all-gather 与 attention 重叠,而不是串行等在 MoE block 前。 ### 3.4 梯度归约 -不变式:每个参数的最终梯度等于全 data-parallel world 上的均值,与 `ep` / `efsdp` 取值无关。`MoE._scale_and_reduce_grad_decoupled` 只做三件事: +位置:`xtuner/v1/model/moe/moe.py` 1392–1396(分流),1583–1629(`_scale_and_reduce_grad_decoupled`)。 + +不变式:每个参数的最终梯度等于全 data-parallel world 上的均值,与 `ep` / `efsdp` 取值无关。 + +```python +# xtuner/v1/model/moe/moe.py::_scale_and_reduce_grad_decoupled(节选) +for name, param in self.trainable_parameters(): + if param.grad is None: + continue + if ep_size > 1 and ".experts" in name: + param.grad.div_(ep_size) # expert:FSDP 已在 efsdp 上求均值,剩余因子是 ep + continue + if not isinstance(param, DTensor): + continue + if any(isinstance(p, Shard) for p in param.placements): + continue # FSDP 管理的参数(含 _StridedShard):FSDP 已归约 + replicate_dims = [names[i] for i, p in enumerate(param.placements) if isinstance(p, Replicate)] + flat_mesh = param.device_mesh[replicate_dims]._flatten() if len(replicate_dims) > 1 else param.device_mesh[replicate_dims[0]] + grad = param.grad.to_local() + grad.div_(flat_mesh.size()) # 全 Replicate 的 ignored fp32 参数:手工求均值 + grads_by_group.setdefault(flat_mesh.get_group(), []).append(grad) + +for group, grads in grads_by_group.items(): # 每个 group 一次 coalesced all-reduce + with dist._coalescing_manager(group=group): + for grad in grads: + dist.all_reduce(grad, ReduceOp.SUM, group=group) +``` | 参数类 | 谁完成归约 | 解耦路径的额外动作 | |---|---|---| @@ -88,23 +204,79 @@ MTP layer 使用同样的两级包装。forward prefetch 列表同时包含下 ### 3.5 FP8 +位置:`xtuner/v1/float8/float8_handler.py` 121–161、166–239、316–335;`xtuner/v1/float8/fsdp_utils.py` 127–137;`xtuner/v1/model/moe/moe.py` 1165–1176;`xtuner/v1/model/base.py` 1015。 + tile-wise FP8 需要知道每个本地权重会被多少个 FSDP rank 切分(决定 padding),以及 scale 的 reduce-max 应跨哪些 rank。解耦后 dense 与 expert 的答案不同: +```python +# xtuner/v1/float8/float8_handler.py::pad_for_fsdp(节选) +num_fsdp_chunks = fsdp_mesh.size(-1) # dense:dp_shard 份 +if expert_fsdp_mesh is not None and isinstance(module, TileWiseFloat8GroupedLinear): + num_fsdp_chunks = expert_fsdp_mesh.size(-1) # routed expert:efsdp 份 +padded_out_features = Float8Handler.get_num_features_after_pad(tensor_size, 0, num_fsdp_chunks) + +# ::_build_decoupled_reduce_meshes(节选) +dense_shard_size = fsdp_mesh.size(-1) # 连续 rank,stride 1 +expert_shard_size = expert_fsdp_mesh.size(-1) +expert_stride = world_size // expert_fsdp_mesh.size() # == ep_size +self.tilewise_reduce_mesh_mapping = self._build_strided_reduce_mesh_mapping( + model, (TileWiseFloat8Linear,), dense_shard_size, 1) +self.expert_tilewise_reduce_mesh_mapping = self._build_strided_reduce_mesh_mapping( + model, (TileWiseFloat8GroupedLinear,), expert_shard_size, expert_stride) +``` + - dense linear 按 `dp_shard` 的 chunk 数 padding,reduce mesh 的 rank stride 为 1; - grouped-expert linear 按 `efsdp` 的 chunk 数 padding,reduce mesh 的 rank stride 为 `ep`; -- `Float8Handler.pad_for_fsdp` 接受可选的 `expert_fsdp_mesh`,`build_reduce_mesh` 为两类参数各建一套 tile-wise reduce mesh,scale 预计算按类各执行一次。 +- scale 预计算(`precompute_tilewise_float8_scale_for_fsdp`)新增 `module_types` 参数,按 dense / expert 两类各执行一次。 旧路径 `expert_fsdp_mesh is None`,走原有的单 mesh 代码。tensor-wise FP8 只作用于 dense linear,shard group 仍是 `fsdp_mesh`,无需改动。 ### 3.6 HF 权重与 DCP -`LoadSpec.from_tensor` 直接从 DTensor placement 记录 shard 历史(EP 的 `Shard(0)`、`efsdp` 上的 `_StridedShard`、HSDP 的 `Replicate` 不产生 shard)。同步 HF save 按 shard 描述逆向 unshard 后再走模型已有的 HF key / fused-expert 映射;DCP 按 DTensor placement 保存并在载入时 reshard。因此没有为任何模型重写 `state_dict_adapter`,工作量在于让新 mesh 产出正确的 `LoadSpec`。 +位置:无代码改动。依赖 `xtuner/v1/utils/load_spec.py` 的 `LoadSpec.from_tensor`、`RuntimeLayout.from_dtensor`、`plan_hf_save`;验证脚本 `tests/model/run_decoupled_ep_fsdp_ckpt.py`。 + +`LoadSpec.from_tensor` 直接从 DTensor placement 记录 shard 历史,新 placement 无需特殊处理: + +```text +routed expert(解耦 + HSDP) + DTensor placement : (Replicate, _StridedShard(0), Shard(0)) on (replicate, efsdp, ep) + LoadSpec.shards : [ShardDescriptor(dim=0, group=ep), ShardDescriptor(dim=0, group=efsdp)] # Replicate 不产生 shard +dense(解耦 + HSDP) + DTensor placement : (Replicate, Shard(0)) on (replicate, dp_shard) + LoadSpec.shards : [ShardDescriptor(dim=0, group=dp_shard)] +``` + +同步 HF save 按 shard 描述逆向 unshard 后再走模型已有的 HF key / fused-expert 映射;DCP 按 DTensor placement 保存并在载入时 reshard。因此没有为任何模型重写 `state_dict_adapter`,工作量在于让新 mesh 产出正确的 `LoadSpec`。 ### 3.7 RL 权重同步 -Turbomind 的 layer-wise 更新只 gather FSDP shard、保留 EP-local 的 expert 切片。`BaseModel._fsdp_foreach_allgather` 按每个 `LoadSpec` 选择 gather group:spec 含 `efsdp` 上的 shard 时用 `expert_fsdp_mesh` 的 group,否则用 `fsdp_mesh` 的 group。 +位置:`xtuner/v1/model/base.py` 1916–1947;`xtuner/v1/rl/weight_update/weight_iterator.py` 140–149、196–208、236–247;测试 `tests/rl/test_weight_iterator.py` 234–418。 -compose 模型(如 Qwen3.5-VL MoE)曾有一个 owner 选择错误:`WeightIterator.iter_layer_batches` 从 language tower 取参数与 `LoadSpec`,却用外层 compose 模型的 mesh 做 gather。compose 模型本身在 world mesh 上包装、没有 `expert_fsdp_mesh`,也不维护自己的 `load_spec_mapping`,于是 language tower 的 `efsdp` shard(HSDP 时连 `dp_shard` shard)被当作"应保留"的 shard 而不 gather,Turbomind 收到的是 rank-local 碎片。现在 gather 由参数所属的子模块(language / vision / projector)执行,并从该子模块的 `load_spec_mapping` 取 spec;`tests/rl/test_weight_iterator.py::TestLayerBatchesGatherWithParamOwner` 用假 process group 固定了 `efsdp = 2` 与 HSDP 两种拓扑下每个 tensor 的完整形状与取值。 +Turbomind 的 layer-wise 更新只 gather FSDP shard、保留 EP-local 的 expert 切片。`BaseModel._fsdp_foreach_allgather` 按每个 `LoadSpec` 选择 gather group: + +```python +# xtuner/v1/model/base.py::_fsdp_gather_group +def _fsdp_gather_group(self, load_spec, fsdp_group, expert_fsdp_group): + if expert_fsdp_group is None: + return fsdp_group + if any(self._is_same_process_group(shard.group, expert_fsdp_group) for shard in load_spec.shards): + return expert_fsdp_group # spec 带 efsdp shard:只 gather efsdp,EP shard 保留 + return fsdp_group # dense:gather dp_shard +``` + +compose 模型(如 Qwen3.5-VL MoE)曾有一个 owner 选择错误:`WeightIterator.iter_layer_batches` 从 language tower 取参数与 `LoadSpec`,却用外层 compose 模型的 mesh 做 gather。compose 模型本身在 world mesh 上包装、没有 `expert_fsdp_mesh`,也不维护自己的 `load_spec_mapping`,于是 language tower 的 `efsdp` shard(HSDP 时连 `dp_shard` shard)被当作"应保留"的 shard 而不 gather,Turbomind 收到的是 rank-local 碎片。现在 gather 由参数所属的子模块执行,并从该子模块的 `load_spec_mapping` 取 spec: + +```python +# xtuner/v1/rl/weight_update/weight_iterator.py::_param_owner +def _param_owner(model, name): + if isinstance(model.config, BaseComposeConfig): + for submodule in ("language_model", "vision_tower", "multi_modal_projector"): + if name.startswith(f"{submodule}."): + return getattr(model, submodule), name[len(submodule) + 1 :] + return model, name # plain 模型:owner 就是自己,行为不变 +``` + +`tests/rl/test_weight_iterator.py::TestLayerBatchesGatherWithParamOwner` 用假 process group 固定了 `efsdp = 2` 与 HSDP 两种拓扑下每个 tensor 的完整形状与取值。 ## 4. 与参考实现的对照 @@ -119,23 +291,52 @@ compose 模型(如 Qwen3.5-VL MoE)曾有一个 owner 选择错误:`WeightI ## 5. 验证结论与边界 -| 项目 | 证据 | 边界 | -|---|---|---| -| mesh / placement / 两级 FSDP | `tests/model/test_decoupled_ep_fsdp_mesh.py`,fake-PG 下 8/16/64 rank 共 29 例;旧布局在开关关闭时逐项固定 | 需要一张空闲 GPU 建 CUDA context,不通信 | -| BF16 训练语义(L1) | tiny Qwen3-MoE 50 步:解耦 EP8 vs 旧 EP8 loss 最大相对差 `1.9e-5`(旧 EP8 vs EP1 为 `2.5e-5`);3.4B 模型每卡 dense 参数 836 → 105 MiB,峰值 allocated 11861 → 8203 MiB,步时 170 → 158 ms | 单机;reduction order 不同,不是逐 bit 相同 | -| `efsdp > 1`、HSDP + EP(L2) | 单机缩比拓扑 `(1,2,4)`、`(2,1,4)`、`(2,2,2)` 均在噪声内;16/64 rank 形状由 L0 固定 | 真正的双节点 collective 未跑 | -| HF / DCP(L3) | 刚 `from_hf` 后导出 807 个 key 全部 bit-exact;DCP 同布局 resume 与跨布局 reshard 后 loss 连续 | 未覆盖 async save、FP8 checkpoint、GLM/MTP 导出、world size 变化 | -| FP8 | 3.4B 与 GLM-5.2 tile-wise FP8 训练 20 步稳定,差异量级接近同配置重复噪声 | FP8 + HSDP、FP8 + checkpoint 未测 | -| GLM-5.2-30B,8×H200 目标配方 | EP4:1.688 s / 99.5 GiB → 1.658 s / 83.5 GiB;EP8:11.8 s / 120.0 GiB → 1.64 s / 76.3 GiB | EP8 的加速来自离开 allocator 上限(legacy 每步 2–3 次 alloc retry,解耦为 0),不是 collective 本身快 7 倍 | -| RL 权重同步 | plain MoE 的 per-spec gather 逻辑成立;compose 的 owner 错误已修复并有单测 | 未做真实 Turbomind 端到端对拍 | -| legacy 兼容 | 开关默认关闭,旧分支执行逻辑未改动,L0 旧布局回归通过 | 未做 base-vs-branch 端到端逐 tensor 对照 | +| 项目 | 证据 | 边界 | 链接 | +|---|---|---|---| +| mesh / placement / 两级 FSDP(L0) | fake-PG 下 8/16/64 rank 共 29 例;旧布局在开关关闭时逐项固定 | 需要一张空闲 GPU 建 CUDA context,不通信 | [test_decoupled_ep_fsdp_mesh.py](../../tests/model/test_decoupled_ep_fsdp_mesh.py)、[baseline.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/baseline.md) | +| BF16 训练语义(L1) | tiny Qwen3-MoE 50 步:解耦 EP8 vs 旧 EP8 loss 最大相对差 `1.9e-5`(旧 EP8 vs EP1 为 `2.5e-5`);3.4B 模型每卡 dense 参数 836 → 105 MiB,峰值 allocated 11861 → 8203 MiB,步时 170 → 158 ms | 单机;reduction order 不同,不是逐 bit 相同 | [L1.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/L1.md)、[run_decoupled_ep_fsdp_numerics.py](../../tests/model/run_decoupled_ep_fsdp_numerics.py) | +| `efsdp > 1`、HSDP + EP(L2) | 单机缩比拓扑 `(1,2,4)`、`(2,1,4)`、`(2,2,2)` 均在噪声内;16/64 rank 形状由 L0 固定 | 真正的双节点 collective 未跑 | [L2.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/L2.md)、[decisions.md D10](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/decisions.md) | +| HF / DCP(L3) | 刚 `from_hf` 后导出 807 个 key 全部 bit-exact;DCP 同布局 resume 与跨布局 reshard 后 loss 连续 | 未覆盖 async save、FP8 checkpoint、GLM/MTP 导出、world size 变化 | [L3.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/L3.md)、[run_decoupled_ep_fsdp_ckpt.py](../../tests/model/run_decoupled_ep_fsdp_ckpt.py) | +| FP8 | 3.4B 与 GLM-5.2 tile-wise FP8 训练 20 步稳定,差异量级接近同配置重复噪声 | FP8 + HSDP、FP8 + checkpoint 未测 | [L3.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/L3.md)、[GLM52.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/GLM52.md) | +| GLM-5.2-30B,8×H200 目标配方 | EP4:1.688 s / 99.5 GiB → 1.658 s / 83.5 GiB;EP8:11.8 s / 120.0 GiB → 1.64 s / 76.3 GiB | EP8 的加速来自离开 allocator 上限(legacy 每步 2–3 次 alloc retry,解耦为 0),不是 collective 本身快 7 倍 | [GLM52.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/GLM52.md) | +| RL 权重同步 | plain MoE 的 per-spec gather 逻辑成立;compose 的 owner 错误已修复并有单测 | 未做真实 Turbomind 端到端对拍 | [test_weight_iterator.py](../../tests/rl/test_weight_iterator.py#L234-L418) | +| legacy 兼容 | 开关默认关闭,旧分支执行逻辑未改动,L0 旧布局回归通过 | 未做 base-vs-branch 端到端逐 tensor 对照 | [baseline.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/baseline.md)、[decisions.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/decisions.md) | -详细数据见 `reports/L1.md`、`reports/L2.md`、`reports/L3.md`、`reports/GLM52.md`(不随代码 PR 合入)。 +`reports/` 下的报告不随代码 PR 合入,链接指向 fork 分支 `feat/decouple-ep-fsdp` 上的固定地址;测试与脚本是仓库内相对链接。 ## 6. 已知限制与后续工作 -- `fp32_keys_pattern` 命中 routed expert 时(当前内置 pattern 均不命中),该 expert 只被 `ignored_params` 排除而不会沿 `efsdp` 分片,梯度路径只做 `/ep`,`efsdp > 1` 或 `replicate > 1` 时副本会分叉。短期应显式拒绝;完整方案见 §7.2,与"expert 不做 FSDP"模式是同一实现。 -- expert 的分类在 FSDP 包装时按 `MoEBlock` 类型,在梯度缩放时按参数名含 `.experts`,不是同一事实来源;应在包装时记录 expert 参数身份,梯度、FP8、save / RL 复用。 +### 6.1 `fp32_keys_pattern` 命中 routed expert 时梯度不归约 + +`HFSaveCfg.fp32_keys_pattern` 用 HF key 正则把少量参数保留在 fp32 并排除在 FSDP 之外,内置用途是 Qwen3.5 linear-attention 的 `A_log`、norm 这类 dense 小参数。`BaseModel._fully_shard` 对命中的参数分两种处理:普通 tensor 先 `distribute_tensor(Replicate on world_mesh)` 再放进 `ignored_params`;已经是 DTensor 的参数原样放进 `ignored_params`。routed expert 在 `build_grouped_linear` 里就已经是 `Shard(0)` on `ep` 的 DTensor,走的是第二种。 + +于是一旦某条 pattern 命中 expert,会同时发生两件事: + +1. expert 不再被 `_fully_shard_expert_blocks` 沿 `efsdp` 分片,每个 `efsdp` rank(HSDP 时每个 replica)各持一份完整的 EP-local expert; +2. `_scale_and_reduce_grad_decoupled` 看到参数名含 `.experts`,只做 `/ep` 就 `continue`,不会走第三行的 Replicate 维 all-reduce。 + +每个 `efsdp` rank 的 expert 梯度只含自己 EP group 的 token 贡献,optimizer 一步之后同一个 expert 在不同 `efsdp` rank 上的权重就不再相同,训练静默出错。`efsdp = replicate = 1`(如单机 EP=8)时没有第二份副本,问题不显现;当前内置 pattern 也都只匹配 dense,GLM-5.2 没有 pattern,所以现有实验没有触发。旧路径同样存在这个边界:ignored expert 不会沿 `fsdp` 分片,`fsdp > 1` 时一样分叉。 + +修法:短期在解耦路径检测到 pattern 命中 `MoEBlock` 参数时直接报错;完整方案是把这类 expert 表达成 `Replicate(replicate), Replicate(efsdp), Shard(0)(ep)`,先走 §3.4 第三行的 Replicate 维 all-reduce 求均值,再 `/ep`。这与 §7.2 的 `expert_fsdp = False` 模式是同一套实现。 + +### 6.2 "什么是 expert 参数"有两套判定标准 + +| 环节 | 判定依据 | 位置 | +|---|---|---| +| FSDP 包装 | 模块类型 `isinstance(m, MoEBlock)` | `MoE._expert_blocks` | +| 梯度缩放 | 参数名 `".experts" in name` | `MoE._scale_and_reduce_grad_decoupled`(旧路径 `scale_and_reduce_grad` 同样) | +| FP8 padding / reduce mesh | 模块类型 `TileWiseFloat8GroupedLinear` | `Float8Handler.pad_for_fsdp` / `_build_decoupled_reduce_meshes` | +| RL gather | `LoadSpec.shards` 中是否有 `efsdp` group 的 shard | `BaseModel._fsdp_gather_group` | + +今天它们恰好一致,因为 `MoEDecoderLayer` 把 `MoEBlock` 挂在 `experts` 属性下(`shared_experts` 不含 `.experts` 子串,不会误伤)。两个方向都可能失效: + +- 新模型把 `MoEBlock` 挂到别的属性名(如 `moe`):expert 会被 `expert_fsdp_mesh` 分片,reduce-scatter 只在 `efsdp` 上求均值,但名字里没有 `.experts`,不做 `/ep`,梯度被放大 `ep` 倍,等价于 expert 的学习率乘以 `ep`,没有任何报错; +- 某个 dense 参数名恰好含 `.experts`(如 `experts_router`):会被 `/ep`,梯度缩小 `ep` 倍。 + +修法:在 `_fully_shard_expert_blocks` 包装时记录 expert 参数的 id / FQN 集合,梯度缩放、FP8、save / RL 都查这一份,删除按名字匹配的分支。 + +### 6.3 其它 + - 新拓扑约束目前用 `assert`,应改为 Pydantic validator + `ValueError`。 - 两次不同 mesh 的 `fully_shard` 与 `DeviceMesh._flatten(name)` 只在 torch 2.8 / 2.9 验证;`pyproject.toml` 声明 `torch>=2.6`,需要补 CI 或为该开关声明更高的最低版本。 - L1–L3 是人工实验脚本(写 JSON、打印统计),不是会失败的 pytest gate。 @@ -162,7 +363,7 @@ expert_fsdp = True (现状): Shard(0) on ep + _StridedShard on efsdp expert_fsdp = False(新增): Shard(0) on ep + Replicate on (replicate, efsdp) 手工 all-reduce 求均值,再 /ep ``` -后者的梯度处理正是 §3.4 第三行已经存在的"沿 Replicate 维 all-reduce"分支,也正是修复 §6 第一条(fp32 pattern 命中 expert)所需的实现,两件事可以一次做完。代价是 expert 参数与优化器状态在 `efsdp` 维复制、失去 FSDP wrapper 提供的 mixed precision(需手工 cast)。 +后者的梯度处理正是 §3.4 第三行已经存在的"沿 Replicate 维 all-reduce"分支,也正是修复 §6.1(fp32 pattern 命中 expert)所需的实现,两件事可以一次做完。代价是 expert 参数与优化器状态在 `efsdp` 维复制、失去 FSDP wrapper 提供的 mixed precision(需手工 cast)。 建议:`efsdp` 保持派生;不新增整数配置。等 MoonEP / UltraEP 接入启动时,在 `FSDPConfig` 增加 `expert_fsdp: bool = True`(或 `expert_shard_mode: "fsdp" | "replicate"`)。root mesh 不变,`ep_mesh` 仍是最内层连续 rank 的 group,dispatcher 的替换与本方案正交。 From 23751f427c9309812dea33a166984f1d7026d171 Mon Sep 17 00:00:00 2001 From: daixu Date: Thu, 3 Sep 2026 07:13:21 +0000 Subject: [PATCH 07/10] [Docs] Drop the per-file code map from the decoupled EP/FSDP design note --- docs/design/decouple_ep_fsdp.md | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/docs/design/decouple_ep_fsdp.md b/docs/design/decouple_ep_fsdp.md index 4bfb9be056..7d1a4476f2 100644 --- a/docs/design/decouple_ep_fsdp.md +++ b/docs/design/decouple_ep_fsdp.md @@ -50,33 +50,7 @@ expert : Shard(0) over ep + FSDP over efsdp (+ replicate 上 HS ## 3. 实现 -行号对应分支 `feat/decouple-ep-fsdp` 的代码提交 `5923dc1d`(2026-09-03)。改动的文件与位置: - -| 文件 | 行号 | 内容 | -|---|---|---| -| [`xtuner/v1/config/fsdp.py`](../../xtuner/v1/config/fsdp.py#L48-L65) | 48–65 | `decouple_ep_fsdp` 开关与校验 | -| [`xtuner/v1/model/moe/moe.py`](../../xtuner/v1/model/moe/moe.py#L1651-L1716) | 1651–1716 | `_init_decoupled_device_mesh`:root mesh 与子 mesh | -| | [1486–1492](../../xtuner/v1/model/moe/moe.py#L1486-L1492) | `_init_device_mesh` 分流到新路径 | -| | [1194–1201](../../xtuner/v1/model/moe/moe.py#L1194-L1201) | 解耦路径跳过 `_replicate_other_params` | -| | [1214–1225](../../xtuner/v1/model/moe/moe.py#L1214-L1225)、[1250–1260](../../xtuner/v1/model/moe/moe.py#L1250-L1260) | decoder layer 两级 `fully_shard` 与 forward prefetch | -| | [1328–1360](../../xtuner/v1/model/moe/moe.py#L1328-L1360) | MTP layer 的同样处理 | -| | [1631–1649](../../xtuner/v1/model/moe/moe.py#L1631-L1649) | `_expert_blocks` / `_fully_shard_expert_blocks` | -| | [1392–1396](../../xtuner/v1/model/moe/moe.py#L1392-L1396)、[1583–1629](../../xtuner/v1/model/moe/moe.py#L1583-L1629) | `scale_and_reduce_grad` 分流与 `_scale_and_reduce_grad_decoupled` | -| | [1165–1176](../../xtuner/v1/model/moe/moe.py#L1165-L1176) | FP8 padding 传入 `expert_fsdp_mesh` | -| [`xtuner/v1/model/base.py`](../../xtuner/v1/model/base.py#L567-L569) | 567–569 | 新增 `expert_fsdp_mesh` 属性(dense 模型恒为 `None`) | -| | [1015](../../xtuner/v1/model/base.py#L1015) | float8 handler 建 reduce mesh 时传入 expert mesh | -| | [1916–1947](../../xtuner/v1/model/base.py#L1916-L1947) | `_fsdp_foreach_allgather` / `_fsdp_gather_group`:RL 按 spec 选 gather group | -| [`xtuner/v1/float8/float8_handler.py`](../../xtuner/v1/float8/float8_handler.py#L121-L161) | 45–49、121–161 | `pad_for_fsdp` / `build_reduce_mesh` 的 expert 分支 | -| | [166–239](../../xtuner/v1/float8/float8_handler.py#L166-L239) | `_build_decoupled_reduce_meshes` 与 strided reduce mesh helper | -| | [316–335](../../xtuner/v1/float8/float8_handler.py#L316-L335) | tile-wise scale 预计算按类各执行一次 | -| [`xtuner/v1/float8/fsdp_utils.py`](../../xtuner/v1/float8/fsdp_utils.py#L127-L137) | 127–137 | `precompute_tilewise_float8_scale_for_fsdp` 增加 `module_types` | -| [`xtuner/v1/rl/weight_update/weight_iterator.py`](../../xtuner/v1/rl/weight_update/weight_iterator.py#L140-L149) | 140–149、196–208、228、236–247 | layer-wise 权重同步按参数 owner gather | -| [`examples/v1/config/sft_glm5p2.py`](../../examples/v1/config/sft_glm5p2.py#L107-L115) | 107–115 | `DECOUPLE_EP_FSDP` / `HSDP_SHARDING_SIZE` 环境变量 | -| [`tests/model/test_decoupled_ep_fsdp_mesh.py`](../../tests/model/test_decoupled_ep_fsdp_mesh.py) | 全文 | L0 fake-PG placement 测试(29 例) | -| [`tests/rl/test_weight_iterator.py`](../../tests/rl/test_weight_iterator.py#L234-L418) | 234–418 | compose owner 回归测试 | -| [`tests/model/run_decoupled_ep_fsdp_numerics.py`](../../tests/model/run_decoupled_ep_fsdp_numerics.py)、[`run_decoupled_ep_fsdp_ckpt.py`](../../tests/model/run_decoupled_ep_fsdp_ckpt.py)、[`summarize_decoupled_ep_fsdp_numerics.py`](../../tests/model/summarize_decoupled_ep_fsdp_numerics.py) | 全文 | L1–L3 实验脚本 | - -相关但**未改动**的文件:[`xtuner/v1/utils/load_spec.py`](../../xtuner/v1/utils/load_spec.py)(`LoadSpec.from_tensor`、`plan_hf_save`、`_preserved_shard_indices`,已能表达新 placement)、[`xtuner/v1/module/decoder_layer/moe_decoder_layer.py`](../../xtuner/v1/module/decoder_layer/moe_decoder_layer.py)(`MoEBlock` 定义,是 expert FSDP 的包装单元)、`xtuner/v1/module/grouped_linear/moe_group_linear.py`(expert 仍在 `ep_mesh` 上 `Shard(0)`)。 +各小节的"位置"行给出文件与行号,行号对应分支 `feat/decouple-ep-fsdp` 的代码提交 `5923dc1d`(2026-09-03)。 ### 3.1 配置与校验 From de4cc89c9fbece7ac38f59d13892b855fe6e6627 Mon Sep 17 00:00:00 2001 From: daixu Date: Thu, 17 Sep 2026 10:04:50 +0000 Subject: [PATCH 08/10] [Fix] Validate the decoupled EP/FSDP topology with a Pydantic validator - FSDPConfig: replace the `assert`s in `model_post_init` with a `@model_validator(mode="after")` that raises `ValueError` (surfaced as a pydantic `ValidationError`), so the checks survive `python -O`; also reject non-positive `ep_size` / `hsdp_sharding_size`. - MoE._init_decoupled_device_mesh: raise `ValueError` instead of asserting `world_size % dp_shard == 0` and `dp_shard % ep_size == 0`. - MoE._scale_and_reduce_grad_decoupled: narrow `mesh_dim_names` before indexing so the decoupled path type-checks. - L0 regression tests for the config validator and the runtime check. --- tests/model/test_decoupled_ep_fsdp_mesh.py | 16 +++++++++++++ xtuner/v1/config/fsdp.py | 26 +++++++++++++++------- xtuner/v1/model/moe/moe.py | 17 ++++++++------ 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/tests/model/test_decoupled_ep_fsdp_mesh.py b/tests/model/test_decoupled_ep_fsdp_mesh.py index 06161848f6..ae0c1d0480 100644 --- a/tests/model/test_decoupled_ep_fsdp_mesh.py +++ b/tests/model/test_decoupled_ep_fsdp_mesh.py @@ -315,6 +315,22 @@ def test_config_requires_ep_to_divide_shard_size(self) -> None: with pytest.raises(ValidationError, match="divisible by `ep_size`"): FSDPConfig(ep_size=3, hsdp_sharding_size=8, decouple_ep_fsdp=True) + def test_config_rejects_non_positive_sizes(self) -> None: + # The checks are pydantic validators raising `ValueError`, not `assert`s that `python -O` + # would drop; `ValidationError` is the `ValueError` subclass pydantic wraps them in. + with pytest.raises(ValidationError, match="`ep_size` must be a positive integer"): + FSDPConfig(ep_size=0) + with pytest.raises(ValidationError, match="`hsdp_sharding_size` must be a positive integer"): + FSDPConfig(hsdp_sharding_size=0, decouple_ep_fsdp=True) + assert issubclass(ValidationError, ValueError) + + def test_runtime_rejects_shard_size_not_dividing_world_size(self) -> None: + # `hsdp_sharding_size=3` passes the config validator (3 % ep_size == 0) but cannot tile + # an 8-rank world; the mesh construction must fail loudly instead of asserting. + with _fake_world(8, 0): + with pytest.raises(ValueError, match=r"world_size \(8\) must be divisible by hsdp_sharding_size \(3\)"): + _shard_model(1, hsdp_sharding_size=3, decouple_ep_fsdp=True) + class TestDecoupledMoEParamPlacements: """DTensor placements after the two-level ``fully_shard`` of the decoupled path.""" diff --git a/xtuner/v1/config/fsdp.py b/xtuner/v1/config/fsdp.py index 85d32887c5..f0d023c057 100644 --- a/xtuner/v1/config/fsdp.py +++ b/xtuner/v1/config/fsdp.py @@ -1,8 +1,8 @@ -from typing import Any, Optional +from typing import Optional import torch from cyclopts import Parameter -from pydantic import BaseModel, ConfigDict, field_serializer, field_validator +from pydantic import BaseModel, ConfigDict, field_serializer, field_validator, model_validator from typing_extensions import Annotated @@ -50,15 +50,25 @@ class FSDPConfig(BaseModel): bool, Parameter(help="Decouple expert parallel from FSDP: shard dense params over the full FSDP mesh") ] = False - def model_post_init(self, __context: Any) -> None: - if self.hsdp_sharding_size is not None: - if self.decouple_ep_fsdp: - assert self.hsdp_sharding_size % self.ep_size == 0, ( + @model_validator(mode="after") + def _validate_ep_fsdp_topology(self) -> "FSDPConfig": + # Explicit `ValueError`s instead of `assert`: the topology checks must survive `python -O`, + # and pydantic reports them as a `ValidationError` when the config is built. + if self.ep_size < 1: + raise ValueError(f"`ep_size` must be a positive integer, got {self.ep_size}") + if self.hsdp_sharding_size is None: + return self + if self.hsdp_sharding_size < 1: + raise ValueError(f"`hsdp_sharding_size` must be a positive integer, got {self.hsdp_sharding_size}") + if self.decouple_ep_fsdp: + if self.hsdp_sharding_size % self.ep_size != 0: + raise ValueError( "`decouple_ep_fsdp` requires `hsdp_sharding_size` to be divisible by `ep_size`, " f"got hsdp_sharding_size={self.hsdp_sharding_size}, ep_size={self.ep_size}" ) - else: - assert self.ep_size == 1, "Currently, HSDP requires expert parallel size to be 1" + elif self.ep_size != 1: + raise ValueError("Currently, HSDP requires expert parallel size to be 1") + return self @field_serializer("param_dtype", "reduce_dtype") def serialize_param_dtype(self, value: torch.dtype) -> str: diff --git a/xtuner/v1/model/moe/moe.py b/xtuner/v1/model/moe/moe.py index 3a48d05568..31ad44d967 100644 --- a/xtuner/v1/model/moe/moe.py +++ b/xtuner/v1/model/moe/moe.py @@ -1697,8 +1697,10 @@ def _scale_and_reduce_grad_decoupled(self) -> None: # FSDP-managed (`_StridedShard` is a `Shard` subclass). continue + mesh_dim_names = param.device_mesh.mesh_dim_names + assert mesh_dim_names is not None # every mesh of this model is built with named dims replicate_dim_names = tuple( - param.device_mesh.mesh_dim_names[i] for i, p in enumerate(param.placements) if isinstance(p, Replicate) + mesh_dim_names[i] for i, p in enumerate(param.placements) if isinstance(p, Replicate) ) if not replicate_dim_names: continue @@ -1759,12 +1761,13 @@ def _init_decoupled_device_mesh(self, fsdp_config: FSDPConfig) -> None: world_size = dist.get_world_size() ep_size = fsdp_config.ep_size dp_shard = fsdp_config.hsdp_sharding_size if fsdp_config.hsdp_sharding_size is not None else world_size - assert world_size % dp_shard == 0, ( - f"world_size ({world_size}) must be divisible by hsdp_sharding_size ({dp_shard})" - ) - assert dp_shard % ep_size == 0, ( - f"`decouple_ep_fsdp` requires the FSDP shard size ({dp_shard}) to be divisible by ep_size ({ep_size})" - ) + # Explicit exceptions (not `assert`): the mesh shape below is only valid with these. + if world_size % dp_shard != 0: + raise ValueError(f"world_size ({world_size}) must be divisible by hsdp_sharding_size ({dp_shard})") + if dp_shard % ep_size != 0: + raise ValueError( + f"`decouple_ep_fsdp` requires the FSDP shard size ({dp_shard}) to be divisible by ep_size ({ep_size})" + ) replicate_size = world_size // dp_shard efsdp_size = dp_shard // ep_size From cc1336292106ee40ae911b655d8ac0775a3c7c91 Mon Sep 17 00:00:00 2001 From: daixu Date: Thu, 17 Sep 2026 10:04:50 +0000 Subject: [PATCH 09/10] [Test] Gate the decoupled EP/FSDP L1-L3 checks in pytest - xtuner/_testing/decoupled_ep_fsdp.py: the tiny Qwen3-MoE checkpoint, token stream, per-layout training run and HF-checkpoint comparison shared by the gates and the manual experiment scripts. - tests/engine/test_decoupled_ep_fsdp_train_engine.py: 8-GPU `DeterministicDDPTestCase` gates. L1 / L2 compare the decoupled layouts (efsdp == 1, efsdp > 1, HSDP + EP) with the legacy ones on loss curves, total grad norms, per-parameter grad norms at step 0 and per-rank parameter memory; L3 checks bit-exact HF export after `from_hf`, DCP resume, the HF export after resume and cross-layout DCP resharding. CPU tests pin the checkpoint comparison itself. - run_decoupled_ep_fsdp_numerics.py / run_decoupled_ep_fsdp_ckpt.py now import the shared helpers; their CLI and JSON output are unchanged. --- .../test_decoupled_ep_fsdp_train_engine.py | 324 ++++++++++++ tests/model/run_decoupled_ep_fsdp_ckpt.py | 113 +---- tests/model/run_decoupled_ep_fsdp_numerics.py | 197 +------- xtuner/_testing/decoupled_ep_fsdp.py | 461 ++++++++++++++++++ 4 files changed, 812 insertions(+), 283 deletions(-) create mode 100644 tests/engine/test_decoupled_ep_fsdp_train_engine.py create mode 100644 xtuner/_testing/decoupled_ep_fsdp.py diff --git a/tests/engine/test_decoupled_ep_fsdp_train_engine.py b/tests/engine/test_decoupled_ep_fsdp_train_engine.py new file mode 100644 index 0000000000..f3cbe177c3 --- /dev/null +++ b/tests/engine/test_decoupled_ep_fsdp_train_engine.py @@ -0,0 +1,324 @@ +"""GPU gates for the decoupled EP/FSDP layout (``FSDPConfig.decouple_ep_fsdp``). + +Every test trains the same tiny random Qwen3-MoE on the same token stream under several EP/FSDP +layouts and fails when the decoupled layout departs from the legacy one: + +* ``TestDecoupledEpFsdpNumerics`` (L1 / L2): loss curves, total gradient norms, per-parameter + gradient norms at step 0 (identical weights and data, so they catch a wrong expert-gradient scale + that AdamW would hide from the loss curve; after the first update bf16 weight differences flip + individual top-k routing decisions and router gradients legitimately differ by percents) and + per-rank parameter memory, for ``efsdp == 1``, ``efsdp > 1`` and HSDP + EP. +* ``TestDecoupledEpFsdpCheckpoint`` (L3): bit-exact HF export right after ``from_hf``, DCP resume, + HF export after resume and cross-layout DCP resharding. + +The observed noise floor between layouts is ~1e-5 on losses and ≤ 2e-3 on per-parameter grad +norms (see ``docs/design/decouple_ep_fsdp.md`` §5); the gates sit 1-2 orders of magnitude above +it and orders of magnitude below any layout bug seen so far. +""" + +import shutil +import tempfile +import unittest +from pathlib import Path +from typing import Any + +import pytest +import torch +import torch.distributed as dist +from safetensors.torch import save_file + +from xtuner._testing import DeterministicDDPTestCase +from xtuner._testing.decoupled_ep_fsdp import ( + LayoutMode, + assert_hf_dirs_close, + build_engine, + build_hf_checkpoint, + load_hf_dir, + max_rel_diff, + parse_mode, + release, + run_mode, + train, +) +from xtuner.v1.model.moe.qwen3 import Qwen3MoEConfig + + +SEQ_LEN = 2048 +LR = 1e-4 +# Loss curves of two layouts: max relative difference over all steps (noise ~2e-5). +LOSS_RTOL = 1e-3 +# Total grad norms per step and per-parameter grad norms at step 0 (noise ≤ 2e-3 dense, ≤ 2e-5 expert). +GRAD_NORM_RTOL = 1e-2 +# Per-rank parameter memory vs. the value implied by the layout (FSDP padding slack). +MEMORY_RTOL = 0.25 +# HF exports: 1 bf16 ulp is 2**-7 relative; resumed vs. continuous differs by single ulps on a +# subset of elements, exports of different layouts by a few ulps. +HF_RESUME_RTOL, HF_RESUME_ATOL = 2**-6, 1e-3 +HF_LAYOUT_RTOL, HF_LAYOUT_ATOL = 2**-6, 2e-3 + + +class _SharedTmpDirMixin: + """Temp directory created by rank 0 and shared with every rank of the process group.""" + + def _make_shared_tmpdir(self) -> Path: + holder = [tempfile.mkdtemp(prefix="decoupled_ep_fsdp_") if dist.get_rank() == 0 else None] + dist.broadcast_object_list(holder, src=0) + assert holder[0] is not None + return Path(holder[0]) + + @staticmethod + def _cleanup_shared_tmpdir(path: Path) -> None: + dist.barrier() + if dist.get_rank() == 0: + shutil.rmtree(path, ignore_errors=True) + + +def _build_tiny_checkpoint(root: Path) -> Path: + hf_dir = root / "tiny_qwen3_moe_hf" + if dist.get_rank() == 0: + build_hf_checkpoint(hf_dir, seed=0, model_size="tiny") + dist.barrier() + return hf_dir + + +@unittest.skipUnless(torch.cuda.device_count() >= 8, "requires 8 CUDA devices") +class TestDecoupledEpFsdpNumerics(_SharedTmpDirMixin, DeterministicDDPTestCase): + def test_ep8_decoupled_matches_legacy_ep8_and_ep1(self) -> None: + # L1: `efsdp == 1`; dense params go from replicated over EP to sharded over all 8 ranks. + results = self._run_layouts(("A:ep=1", "B:ep=8", "C:ep=8,decouple=1"), steps=20) + self._assert_training_matches(results["C"], results["B"]) + self._assert_training_matches(results["C"], results["A"]) + # 中文注释:每卡参数显存断言。记 D / E 为 dense / expert 参数总量: + # B(legacy,ep=8):mesh 为 (fsdp=1, ep=8),dense 在 ep 维整份复制 → 每卡 D;expert 按 ep 分 8 份 → E/8。 + # C(解耦,ep=8):dense 在整个 dp_shard=8 上分片 → D/8;expert 按 ep×efsdp=8×1 分片 → E/8。 + # 所以 C 的 dense 应是 B 的 1/8,expert 相同。loss / 梯度对比区分不了"dense 真的分片了"和 + # "dense 仍在 EP 维复制"(两者数值完全一样),只有这条显存断言能证明解耦布局确实生效。 + self._assert_param_memory(results["C"], results["B"], dense_ratio=1 / 8, expert_ratio=1.0) + + def test_efsdp_and_hsdp_layouts_match_legacy_ep4(self) -> None: + # L2 on 8 ranks: `efsdp == 2` (C4), HSDP + EP with `efsdp == 1` (H41) and `efsdp == 2` (H22). + results = self._run_layouts( + ("B4:ep=4", "C4:ep=4,decouple=1", "H41:ep=4,decouple=1,hsdp=4", "H22:ep=2,decouple=1,hsdp=4"), + steps=10, + ) + for name in ("C4", "H41", "H22"): + self._assert_training_matches(results[name], results["B4"]) + # Per-rank shards. Legacy ep=4 on 8 ranks: dense over fsdp=2 (D/2), experts over ep=4 then + # fsdp=2 (E/8). Decoupled: dense over dp_shard (D/dp_shard), experts over ep * efsdp == + # dp_shard (E/dp_shard); dp_shard is 8 for C4 and 4 for the HSDP layouts H41 / H22. + # + # 中文注释:ratio = 被测布局的每卡参数量 / 参考布局 B4 的每卡参数量,按各布局的分片数推导: + # B4 (legacy,ep=4) :mesh (fsdp=2, ep=4);dense 在 ep 维复制、在 fsdp 维分 2 份 → D/2; + # expert 先按 ep 分 4 份、再按 fsdp 分 2 份 → E/8。 + # C4 (解耦,ep=4) :dp_shard=8,efsdp=8/4=2;dense → D/8;expert 按 ep×efsdp=8 → E/8。 + # H41(解耦,ep=4,hsdp=4):dp_shard=4,replicate=2,efsdp=1;dense → D/4;expert 按 4×1 → E/4。 + # H22(解耦,ep=2,hsdp=4):dp_shard=4,replicate=2,efsdp=2;dense → D/4;expert 按 2×2 → E/4。 + # 于是 C4 vs B4:dense (D/8)/(D/2)=1/4,expert (E/8)/(E/8)=1; + # H41、H22 vs B4:dense (D/4)/(D/2)=1/2,expert (E/4)/(E/8)=2。 + # 写成 (1/8)/(1/2) 而不是 0.25,是为了让"每卡份额 / 参考份额"的推导过程留在代码里。 + self._assert_param_memory(results["C4"], results["B4"], dense_ratio=(1 / 8) / (1 / 2), expert_ratio=1.0) + self._assert_param_memory(results["H41"], results["B4"], dense_ratio=(1 / 4) / (1 / 2), expert_ratio=2.0) + self._assert_param_memory(results["H22"], results["B4"], dense_ratio=(1 / 4) / (1 / 2), expert_ratio=2.0) + + @property + def world_size(self) -> int: + return 8 + + def _run_layouts(self, specs: tuple[str, ...], steps: int) -> dict[str, dict[str, Any]]: + self.create_pg("cuda") + root = self._make_shared_tmpdir() + try: + hf_dir = _build_tiny_checkpoint(root) + results: dict[str, dict[str, Any]] = {} + for spec in specs: + mode = parse_mode(spec) + results[mode["name"]] = run_mode( + mode, + hf_dir, + steps=steps, + seq_len=SEQ_LEN, + lr=LR, + dispatcher="all2all", + fp8=False, + grad_norm_steps=(0,), + tag=self._testMethodName, + ) + dist.barrier() + return results + finally: + self._cleanup_shared_tmpdir(root) + + def _assert_training_matches(self, result: dict[str, Any], reference: dict[str, Any]) -> None: + name, ref_name = result["mode"]["name"], reference["mode"]["name"] + loss_diff = max_rel_diff(result["losses"], reference["losses"]) + self.assertLessEqual(loss_diff, LOSS_RTOL, f"{name} vs {ref_name}: loss curves differ by {loss_diff:.2e}") + grad_diff = max_rel_diff(result["grad_norms"], reference["grad_norms"]) + self.assertLessEqual(grad_diff, GRAD_NORM_RTOL, f"{name} vs {ref_name}: grad norms differ by {grad_diff:.2e}") + for step, ref_norms in reference["param_grad_norms"].items(): + norms = result["param_grad_norms"][step] + self.assertEqual(set(norms), set(ref_norms), f"{name} vs {ref_name}: parameter sets differ at step {step}") + worst = max(ref_norms, key=lambda key: abs(norms[key] - ref_norms[key]) / max(abs(ref_norms[key]), 1e-12)) + diff = abs(norms[worst] - ref_norms[worst]) / max(abs(ref_norms[worst]), 1e-12) + self.assertLessEqual( + diff, + GRAD_NORM_RTOL, + f"{name} vs {ref_name}: grad norm of {worst} differs by {diff:.2e} at step {step} " + f"({norms[worst]:.6g} vs {ref_norms[worst]:.6g})", + ) + + def _assert_param_memory( + self, result: dict[str, Any], reference: dict[str, Any], dense_ratio: float, expert_ratio: float + ) -> None: + # 中文注释:比较两个布局训练时记录的"每卡本地参数字节数"(`param_memory`:对每个参数取 + # DTensor 的本地分片求和,按名字里是否含 `.experts` 分成 expert / dense 两类)。 + # 断言 result 的值 ≈ reference 的值 × ratio,容差 MEMORY_RTOL(±25%)留给 FSDP 的 padding。 + # 期望比值由调用方按布局的分片数给出,见各调用点的推导。 + name, ref_name = result["mode"]["name"], reference["mode"]["name"] + for key, ratio in (("dense_param_mib", dense_ratio), ("expert_param_mib", expert_ratio)): + expected = reference["memory"][key] * ratio + actual = result["memory"][key] + self.assertAlmostEqual( + actual, + expected, + delta=expected * MEMORY_RTOL, + msg=f"{name} vs {ref_name}: {key} {actual:.1f} MiB, expected {expected:.1f} MiB per rank", + ) + + +@unittest.skipUnless(torch.cuda.device_count() >= 8, "requires 8 CUDA devices") +class TestDecoupledEpFsdpCheckpoint(_SharedTmpDirMixin, DeterministicDDPTestCase): + def test_hf_export_dcp_resume_and_cross_layout_reshard(self) -> None: + self.create_pg("cuda") + root = self._make_shared_tmpdir() + try: + hf_dir = _build_tiny_checkpoint(root) + vocab_size = Qwen3MoEConfig.from_hf(hf_dir).vocab_size + src_bf16 = root / "source_bf16" + if dist.get_rank() == 0: + # `save_hf` writes bf16; compare against the bf16-cast source. + src_bf16.mkdir() + tensors = {k: v.to(torch.bfloat16).contiguous() for k, v in load_hf_dir(hf_dir).items()} + save_file(tensors, str(src_bf16 / "model.safetensors")) + dist.barrier() + + modes = [ + parse_mode(s) + for s in ("A:ep=1", "C:ep=8,decouple=1", "C4:ep=4,decouple=1", "H41:ep=4,decouple=1,hsdp=4") + ] + continuous: dict[str, list[float]] = {} + for mode in modes: + continuous[mode["name"]] = self._check_round_trip( + mode, hf_dir, src_bf16, root / mode["name"], vocab_size + ) + + # Step-10 exports of every layout vs. the ep=1 baseline (same data, same optimizer). + for mode in modes[1:]: + if dist.get_rank() == 0: + assert_hf_dirs_close( + root / "A" / "hf_step10", + root / mode["name"] / "hf_step10", + rtol=HF_LAYOUT_RTOL, + atol=HF_LAYOUT_ATOL, + label=f"hf_step10 {mode['name']} vs A", + ) + dist.barrier() + + # DCP resharding across layouts: the step-5 checkpoint of `src` continues under `dst`. + for src, dst in (("C", "A"), ("A", "C"), ("C4", "H41")): + dst_mode = next(m for m in modes if m["name"] == dst) + engine = build_engine(dst_mode, hf_dir, tag=f"cross_{src}") + engine.load_dcp(root / src / "dcp_step5") + losses = train(engine, range(5, 10), vocab_size, SEQ_LEN) + release(engine) + diff = max_rel_diff(losses, continuous[src]) + self.assertLessEqual(diff, LOSS_RTOL, f"cross load {src} -> {dst}: losses differ by {diff:.2e}") + dist.barrier() + finally: + self._cleanup_shared_tmpdir(root) + + @property + def world_size(self) -> int: + return 8 + + def _check_round_trip( + self, mode: LayoutMode, hf_dir: Path, src_bf16: Path, mode_dir: Path, vocab_size: int + ) -> list[float]: + name = mode["name"] + engine = build_engine(mode, hf_dir, tag="main") + engine.from_hf(hf_path=hf_dir, strict=True) + engine.save_hf(str(mode_dir / "hf_step0")) + dist.barrier() + if dist.get_rank() == 0: + assert_hf_dirs_close( + src_bf16, mode_dir / "hf_step0", rtol=0.0, atol=0.0, label=f"{name} hf_step0 vs source" + ) + + train(engine, range(0, 5), vocab_size, SEQ_LEN) + engine.save_dcp(mode_dir / "dcp_step5") + dist.barrier() + losses_continuous = train(engine, range(5, 10), vocab_size, SEQ_LEN) + engine.save_hf(str(mode_dir / "hf_step10")) + dist.barrier() + release(engine) + + resumed = build_engine(mode, hf_dir, tag="resume") + resumed.load_dcp(mode_dir / "dcp_step5") + losses_resumed = train(resumed, range(5, 10), vocab_size, SEQ_LEN) + resumed.save_hf(str(mode_dir / "hf_step10_resumed")) + dist.barrier() + release(resumed) + + diff = max_rel_diff(losses_resumed, losses_continuous) + self.assertLessEqual(diff, LOSS_RTOL, f"{name}: resumed losses differ from the continuous run by {diff:.2e}") + if dist.get_rank() == 0: + assert_hf_dirs_close( + mode_dir / "hf_step10", + mode_dir / "hf_step10_resumed", + rtol=HF_RESUME_RTOL, + atol=HF_RESUME_ATOL, + label=f"{name} hf_step10 resumed vs continuous", + ) + dist.barrier() + return losses_continuous + + +class TestHfDirComparison: + """CPU checks of the checkpoint comparison the GPU gates rely on.""" + + @staticmethod + def _write(path: Path, tensors: dict[str, torch.Tensor]) -> Path: + path.mkdir(parents=True, exist_ok=True) + save_file(tensors, str(path / "model.safetensors")) + return path + + def test_bit_exact_passes_and_one_ulp_fails(self, tmp_path: Path) -> None: + base = {"w": torch.linspace(-1, 1, 64, dtype=torch.bfloat16)} + lhs = self._write(tmp_path / "lhs", base) + rhs = self._write(tmp_path / "rhs", {"w": base["w"].clone()}) + assert_hf_dirs_close(lhs, rhs, rtol=0.0, atol=0.0, label="same") + + nudged = base["w"].clone() + # One bf16 ulp: bump the bit pattern (a float32 `nextafter` would round back to the same bf16). + nudged[3] = (nudged[3].view(torch.int16) + 1).view(torch.bfloat16) + rhs_ulp = self._write(tmp_path / "rhs_ulp", {"w": nudged}) + with pytest.raises(AssertionError, match="one ulp: w"): + assert_hf_dirs_close(lhs, rhs_ulp, rtol=0.0, atol=0.0, label="one ulp") + # The layout tolerance admits a few ulps. + assert_hf_dirs_close(lhs, rhs_ulp, rtol=HF_LAYOUT_RTOL, atol=HF_LAYOUT_ATOL, label="tolerant") + + def test_key_and_shape_mismatches_fail(self, tmp_path: Path) -> None: + lhs = self._write(tmp_path / "lhs", {"w": torch.ones(4, dtype=torch.bfloat16)}) + with pytest.raises(AssertionError, match="key sets differ"): + assert_hf_dirs_close( + lhs, self._write(tmp_path / "keys", {"v": torch.ones(4, dtype=torch.bfloat16)}), 0, 0, "keys" + ) + with pytest.raises(AssertionError, match="expected \\(4,\\)"): + assert_hf_dirs_close( + lhs, self._write(tmp_path / "shape", {"w": torch.ones(8, dtype=torch.bfloat16)}), 0, 0, "shape" + ) + + def test_max_rel_diff(self) -> None: + assert max_rel_diff([1.0, 2.0], [1.0, 2.0]) == 0.0 + assert max_rel_diff([1.0, 2.2], [1.0, 2.0]) == pytest.approx(0.1) + with pytest.raises(ValueError, match="length mismatch"): + max_rel_diff([1.0], [1.0, 2.0]) diff --git a/tests/model/run_decoupled_ep_fsdp_ckpt.py b/tests/model/run_decoupled_ep_fsdp_ckpt.py index 06f505c66a..50e6ffafe4 100644 --- a/tests/model/run_decoupled_ep_fsdp_ckpt.py +++ b/tests/model/run_decoupled_ep_fsdp_ckpt.py @@ -11,102 +11,35 @@ Optionally the step-5 DCP checkpoint of one layout is loaded into another layout (``--cross-load SRC:DST``) to check DCP resharding across the switch. +The pytest gate for these checks lives in ``tests/engine/test_decoupled_ep_fsdp_train_engine.py``; +this script writes the JSON behind the markdown report. + torchrun --nproc-per-node 8 tests/model/run_decoupled_ep_fsdp_ckpt.py \\ --modes A:ep=1 C:ep=8,decouple=1 C4:ep=4,decouple=1 H41:ep=4,decouple=1,hsdp=4 --out /tmp/l3 """ import argparse -import gc import json import os import shutil -import sys from pathlib import Path from typing import Any import torch import torch.distributed as dist -from safetensors.torch import load_file - - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from run_decoupled_ep_fsdp_numerics import build_hf_checkpoint, make_batch, parse_mode # noqa: E402 - -from xtuner.v1.config import AdamWConfig, FSDPConfig # noqa: E402 -from xtuner.v1.engine.train_engine import TrainEngine # noqa: E402 -from xtuner.v1.model.moe.qwen3 import Qwen3MoEConfig # noqa: E402 - - -def build_engine(mode: dict[str, Any], hf_path: Path, tag: str) -> TrainEngine: - model_cfg = Qwen3MoEConfig.from_hf(hf_path) - model_cfg.ep_size = mode["ep"] - model_cfg.dispatcher = "all2all" - model_cfg.compile_cfg = False - model_cfg.mesh_prefix = f"ckpt_{mode['name']}_{tag}" - fsdp_cfg = FSDPConfig( - ep_size=mode["ep"], - decouple_ep_fsdp=mode["decouple"], - hsdp_sharding_size=mode["hsdp"], - torch_compile=False, - ) - return TrainEngine(model_cfg=model_cfg, optim_cfg=AdamWConfig(lr=1e-4), fsdp_cfg=fsdp_cfg) - - -def train(engine: TrainEngine, steps: range, vocab_size: int, seq_len: int) -> list[float]: - losses = [] - for step in steps: - info = engine.train_step([make_batch(step, dist.get_rank(), vocab_size, seq_len)]) - grad_norm = engine.clip_grad_norm() - engine.step_optimizer(grad_norm) - losses.append(float(info["logs_info"]["reduced_llm_loss"])) - return losses - - -def load_hf_dir(path: Path) -> dict[str, torch.Tensor]: - tensors: dict[str, torch.Tensor] = {} - for file in sorted(path.glob("*.safetensors")): - tensors.update(load_file(str(file))) - return tensors - - -def compare_hf(lhs: Path, rhs: Path) -> dict[str, Any]: - a = load_hf_dir(lhs) - b = load_hf_dir(rhs) - missing = sorted(set(a) - set(b)) - extra = sorted(set(b) - set(a)) - max_abs = 0.0 - max_rel = 0.0 - mismatched = 0 - total = 0 - worst = "" - for key in sorted(set(a) & set(b)): - x = a[key].to(torch.float32) - y = b[key].to(torch.float32) - if x.shape != y.shape: - return {"error": f"shape mismatch for {key}: {tuple(x.shape)} vs {tuple(y.shape)}"} - diff = (x - y).abs() - cur = float(diff.max()) - if cur > max_abs: - max_abs, worst = cur, key - max_rel = max(max_rel, float((diff / x.abs().clamp_min(1e-6)).max())) - mismatched += int((diff > 0).sum()) - total += diff.numel() - return { - "keys": len(set(a) & set(b)), - "missing_in_rhs": missing[:5], - "extra_in_rhs": extra[:5], - "max_abs_diff": max_abs, - "max_rel_diff": max_rel, - "mismatched_elements": mismatched, - "total_elements": total, - "worst_key": worst, - } - - -def release(engine: TrainEngine) -> None: - del engine - gc.collect() - torch.cuda.empty_cache() +from safetensors.torch import save_file + +from xtuner._testing.decoupled_ep_fsdp import ( + build_engine, + build_hf_checkpoint, + compare_hf, + load_hf_dir, + max_rel_diff, + parse_mode, + release, + train, +) +from xtuner.v1.model.moe.qwen3 import Qwen3MoEConfig def main() -> None: @@ -134,8 +67,6 @@ def main() -> None: src_bf16 = out / "source_bf16" if rank == 0: src_bf16.mkdir(exist_ok=True) - from safetensors.torch import save_file - tensors = {k: v.to(torch.bfloat16).contiguous() for k, v in load_hf_dir(hf_dir).items()} save_file(tensors, str(src_bf16 / "model.safetensors")) dist.barrier() @@ -152,7 +83,7 @@ def main() -> None: if rank == 0: print(f"===== {mode}", flush=True) - engine = build_engine(mode, hf_dir, "main") + engine = build_engine(mode, hf_dir, "ckpt_main") engine.from_hf(hf_path=hf_dir, strict=True) engine.save_hf(str(mode_dir / "hf_step0")) dist.barrier() @@ -168,7 +99,7 @@ def main() -> None: dist.barrier() release(engine) - resumed = build_engine(mode, hf_dir, "resume") + resumed = build_engine(mode, hf_dir, "ckpt_resume") resumed.load_dcp(mode_dir / "dcp_step5") losses_resumed = train(resumed, range(5, 10), vocab_size, args.seq_len) resumed.save_hf(str(mode_dir / "hf_step10_resumed")) @@ -178,9 +109,7 @@ def main() -> None: entry["losses_steps_0_4"] = losses_first entry["losses_steps_5_9_continuous"] = losses_cont entry["losses_steps_5_9_resumed"] = losses_resumed - entry["resume_max_rel_loss_diff"] = max( - abs(a - b) / abs(b) for a, b in zip(losses_resumed, losses_cont, strict=True) - ) + entry["resume_max_rel_loss_diff"] = max_rel_diff(losses_resumed, losses_cont) if rank == 0: entry["hf_step10_resumed_vs_continuous"] = compare_hf( mode_dir / "hf_step10", mode_dir / "hf_step10_resumed" @@ -200,14 +129,14 @@ def main() -> None: dst_mode = next(m for m in modes if m["name"] == dst) if rank == 0: print(f"===== cross load {src} -> {dst}", flush=True) - engine = build_engine(dst_mode, hf_dir, f"cross_{src}") + engine = build_engine(dst_mode, hf_dir, f"ckpt_cross_{src}") engine.load_dcp(out / src / "dcp_step5") losses = train(engine, range(5, 10), vocab_size, args.seq_len) release(engine) ref = report["modes"][src]["losses_steps_5_9_continuous"] report["cross_load"][pair] = { "losses_steps_5_9": losses, - "max_rel_loss_diff_vs_src_continuous": max(abs(a - b) / abs(b) for a, b in zip(losses, ref, strict=True)), + "max_rel_loss_diff_vs_src_continuous": max_rel_diff(losses, ref), } if rank == 0: diff --git a/tests/model/run_decoupled_ep_fsdp_numerics.py b/tests/model/run_decoupled_ep_fsdp_numerics.py index ed819277c9..d8dc0d7494 100644 --- a/tests/model/run_decoupled_ep_fsdp_numerics.py +++ b/tests/model/run_decoupled_ep_fsdp_numerics.py @@ -1,7 +1,11 @@ """Multi-GPU numerical-equivalence runs for the EP/FSDP decoupling (DESIGN.md §6 L1 / L2). Every mode trains the same tiny Qwen3-MoE (random HF checkpoint built once from a fixed seed) -on the same token stream, so loss / grad-norm curves of different layouts can be compared. +on the same token stream, so loss / grad-norm curves of different layouts can be compared. The +pytest gate for the same comparison lives in ``tests/engine/test_decoupled_ep_fsdp_train_engine.py``; +this script exists for the larger ``--model-size medium`` memory / step-time runs, ``--fp8`` and +``--dispatcher deepep``, and for producing the JSON behind the markdown reports +(``summarize_decoupled_ep_fsdp_numerics.py``). Example (L1): @@ -13,203 +17,14 @@ """ import argparse -import gc import json import os -import time from pathlib import Path -from typing import Any import torch import torch.distributed as dist -from torch.distributed.tensor import DTensor -from transformers import Qwen3MoeConfig, Qwen3MoeForCausalLM -from xtuner.v1.config import AdamWConfig, FSDPConfig -from xtuner.v1.engine.train_engine import TrainEngine -from xtuner.v1.float8.config import Float8Config, ScalingGranularity -from xtuner.v1.loss.ce_loss import CELossConfig -from xtuner.v1.model.base import ModelItem -from xtuner.v1.model.moe.moe import SequenceContext -from xtuner.v1.model.moe.qwen3 import Qwen3MoEConfig -from xtuner.v1.utils.device import get_device -from xtuner.v1.utils.dtensor import cal_total_norm - - -DEVICE = get_device() -GRAD_NORM_STEPS = (0, 1, 25) - - -def parse_mode(spec: str) -> dict[str, Any]: - name, _, kv = spec.partition(":") - mode: dict[str, Any] = {"name": name, "ep": 1, "decouple": False, "hsdp": None} - for item in filter(None, kv.split(",")): - key, value = item.split("=") - if key == "ep": - mode["ep"] = int(value) - elif key == "decouple": - mode["decouple"] = bool(int(value)) - elif key == "hsdp": - mode["hsdp"] = int(value) - else: - raise ValueError(f"unknown mode key {key}") - return mode - - -MODEL_SIZES: dict[str, dict[str, int]] = { - # ~108M params: numerics (fast, 50 steps in < 10 s) - "tiny": { - "vocab_size": 4096, - "hidden_size": 512, - "intermediate_size": 1024, - "moe_intermediate_size": 256, - "num_hidden_layers": 4, - "num_attention_heads": 8, - "num_key_value_heads": 4, - "head_dim": 64, - "num_experts": 64, - "num_experts_per_tok": 4, - }, - # ~3.4B params (3.2B in routed experts): step-time / memory comparison - "medium": { - "vocab_size": 32768, - "hidden_size": 2048, - "intermediate_size": 4096, - "moe_intermediate_size": 1024, - "num_hidden_layers": 8, - "num_attention_heads": 16, - "num_key_value_heads": 4, - "head_dim": 128, - "num_experts": 64, - "num_experts_per_tok": 4, - }, -} - - -def build_hf_checkpoint(path: Path, seed: int, model_size: str) -> None: - config = Qwen3MoeConfig( - **MODEL_SIZES[model_size], - max_position_embeddings=8192, - norm_topk_prob=True, - tie_word_embeddings=False, - bos_token_id=1, - eos_token_id=2, - rope_theta=1000000.0, - use_sliding_window=False, - max_window_layers=MODEL_SIZES[model_size]["num_hidden_layers"], - ) - torch.manual_seed(seed) - model = Qwen3MoeForCausalLM(config) - model.save_pretrained(path, safe_serialization=True) - - -def make_batch(step: int, rank: int, vocab_size: int, seq_len: int) -> ModelItem: - generator = torch.Generator().manual_seed(100_000 + step * 1024 + rank) - tokens = torch.randint(0, vocab_size, (1, seq_len + 1), generator=generator) - input_ids = tokens[:, :-1] - labels = tokens[:, 1:].to(DEVICE) - seq_ctx = SequenceContext.from_input_ids((input_ids,), device=DEVICE) - loss_cfg = CELossConfig() - loss_ctx = loss_cfg.build(data={"shifted_labels": labels}, sp_mesh=None) - loss_ctx = loss_cfg.loss_ctx_cls.build_batches([loss_ctx])[0] - return ModelItem(seq_ctx=seq_ctx, loss_ctx={"lm": loss_ctx}) - - -def clean_name(name: str) -> str: - return name.replace("_checkpoint_wrapped_module.", "") - - -def param_memory(model: torch.nn.Module) -> dict[str, float]: - expert_bytes = 0 - dense_bytes = 0 - for name, param in model.named_parameters(): - local = param.to_local() if isinstance(param, DTensor) else param - nbytes = local.numel() * local.element_size() - if ".experts" in name: - expert_bytes += nbytes - else: - dense_bytes += nbytes - return {"expert_param_mib": expert_bytes / 2**20, "dense_param_mib": dense_bytes / 2**20} - - -def per_param_grad_norms(model: torch.nn.Module) -> dict[str, float]: - norms: dict[str, float] = {} - for name, param in model.named_parameters(): - if param.grad is None: - continue - if isinstance(param.grad, DTensor): - norm = cal_total_norm([param.grad], norm_type=2.0, foreach=True, dtype=torch.float32) - else: - norm = torch.linalg.vector_norm(param.grad, 2.0, dtype=torch.float32) - norms[clean_name(name)] = float(norm) - return norms - - -def run_mode( - mode: dict[str, Any], hf_path: Path, steps: int, seq_len: int, lr: float, dispatcher: str, fp8: bool -) -> dict[str, Any]: - rank = dist.get_rank() - torch.cuda.reset_peak_memory_stats() - torch.cuda.synchronize() - base_allocated = torch.cuda.memory_allocated() - - model_cfg = Qwen3MoEConfig.from_hf(hf_path) - model_cfg.ep_size = mode["ep"] - model_cfg.dispatcher = dispatcher - model_cfg.compile_cfg = False - model_cfg.mesh_prefix = f"numerics_{mode['name']}" - if fp8: - model_cfg.float8_cfg = Float8Config( - scaling_granularity_gemm=ScalingGranularity.TILEWISE, - scaling_granularity_grouped_gemm=ScalingGranularity.TILEWISE, - ) - fsdp_kwargs: dict[str, Any] = {"ep_size": mode["ep"], "hsdp_sharding_size": mode["hsdp"], "torch_compile": False} - if mode["decouple"]: - # Only pass the flag when set so the script also runs against checkouts that predate it. - fsdp_kwargs["decouple_ep_fsdp"] = True - fsdp_cfg = FSDPConfig(**fsdp_kwargs) - optim_cfg = AdamWConfig(lr=lr) - - engine = TrainEngine(model_cfg=model_cfg, optim_cfg=optim_cfg, fsdp_cfg=fsdp_cfg) - engine.from_hf(hf_path=hf_path, strict=True) - torch.cuda.synchronize() - result: dict[str, Any] = { - "mode": mode, - "memory": param_memory(engine.model), - "losses": [], - "grad_norms": [], - "param_grad_norms": {}, - "step_time_s": None, - } - result["memory"]["allocated_after_load_mib"] = (torch.cuda.memory_allocated() - base_allocated) / 2**20 - - step_times = [] - for step in range(steps): - torch.cuda.synchronize() - t0 = time.perf_counter() - batch = make_batch(step, rank, model_cfg.vocab_size, seq_len) - info = engine.train_step([batch]) - grad_norm = engine.clip_grad_norm() - if step in GRAD_NORM_STEPS: - result["param_grad_norms"][str(step)] = per_param_grad_norms(engine.model) - engine.step_optimizer(grad_norm) - torch.cuda.synchronize() - step_times.append(time.perf_counter() - t0) - result["losses"].append(float(info["logs_info"]["reduced_llm_loss"])) - result["grad_norms"].append(float(grad_norm)) - if step == 0: - result["memory"]["allocated_after_step0_mib"] = (torch.cuda.memory_allocated() - base_allocated) / 2**20 - - warm = step_times[5:] if len(step_times) > 10 else step_times - result["step_time_s"] = sum(warm) / len(warm) - result["step_times_s"] = step_times - result["memory"]["peak_allocated_mib"] = (torch.cuda.max_memory_allocated() - base_allocated) / 2**20 - result["memory"]["peak_reserved_mib"] = torch.cuda.max_memory_reserved() / 2**20 - - del engine - gc.collect() - torch.cuda.empty_cache() - return result +from xtuner._testing.decoupled_ep_fsdp import MODEL_SIZES, build_hf_checkpoint, parse_mode, run_mode def main() -> None: diff --git a/xtuner/_testing/decoupled_ep_fsdp.py b/xtuner/_testing/decoupled_ep_fsdp.py new file mode 100644 index 0000000000..fe1611dbd7 --- /dev/null +++ b/xtuner/_testing/decoupled_ep_fsdp.py @@ -0,0 +1,461 @@ +"""Shared helpers for the decoupled EP/FSDP ("dp2ep") numerics and checkpoint checks. + +The GPU pytest gates in ``tests/engine/test_decoupled_ep_fsdp_train_engine.py`` and the manual +experiment scripts under ``tests/model/`` (``run_decoupled_ep_fsdp_numerics.py`` and +``run_decoupled_ep_fsdp_ckpt.py``) build the same tiny random Qwen3-MoE, feed it the same token +stream and compare layouts with the same functions, so a number in a report and a gate threshold +mean the same thing. +""" + +from __future__ import annotations + +import gc +import time +from collections.abc import Iterable, Sequence +from pathlib import Path +from typing import Any, TypedDict + +import torch +import torch.distributed as dist +from safetensors.torch import load_file +from torch.distributed.tensor import DTensor + +from xtuner.v1.config import AdamWConfig, FSDPConfig +from xtuner.v1.engine.train_engine import TrainEngine +from xtuner.v1.float8.config import Float8Config, ScalingGranularity +from xtuner.v1.loss.ce_loss import CELossConfig +from xtuner.v1.model.base import ModelItem +from xtuner.v1.model.moe.moe import SequenceContext +from xtuner.v1.model.moe.qwen3 import Qwen3MoEConfig +from xtuner.v1.utils.device import get_device +from xtuner.v1.utils.dtensor import cal_total_norm + + +DEVICE = get_device() + +MODEL_SIZES: dict[str, dict[str, int]] = { + # ~108M params: numerics (fast, 50 steps in < 10 s) + "tiny": { + "vocab_size": 4096, + "hidden_size": 512, + "intermediate_size": 1024, + "moe_intermediate_size": 256, + "num_hidden_layers": 4, + "num_attention_heads": 8, + "num_key_value_heads": 4, + "head_dim": 64, + "num_experts": 64, + "num_experts_per_tok": 4, + }, + # ~3.4B params (3.2B in routed experts): step-time / memory comparison + "medium": { + "vocab_size": 32768, + "hidden_size": 2048, + "intermediate_size": 4096, + "moe_intermediate_size": 1024, + "num_hidden_layers": 8, + "num_attention_heads": 16, + "num_key_value_heads": 4, + "head_dim": 128, + "num_experts": 64, + "num_experts_per_tok": 4, + }, +} + + +class LayoutMode(TypedDict): + """One EP/FSDP layout under test. + + ``name`` labels the layout in results, ``ep`` is the expert-parallel size, ``decouple`` selects + ``FSDPConfig.decouple_ep_fsdp`` and ``hsdp`` is ``FSDPConfig.hsdp_sharding_size`` (``None`` for + plain FSDP over the world). + """ + + name: str + ep: int + decouple: bool + hsdp: int | None + + +def parse_mode(spec: str) -> LayoutMode: + """Parse a ``:key=value[,key=value...]`` layout spec. + + Args: + spec (str): Mode spec with keys ``ep`` (int), ``decouple`` (0/1) and ``hsdp`` (int). + + Returns: + LayoutMode: The parsed layout; unspecified keys default to ``ep=1, decouple=0, hsdp=None``. + """ + name, _, kv = spec.partition(":") + mode: LayoutMode = {"name": name, "ep": 1, "decouple": False, "hsdp": None} + for item in filter(None, kv.split(",")): + key, value = item.split("=") + if key == "ep": + mode["ep"] = int(value) + elif key == "decouple": + mode["decouple"] = bool(int(value)) + elif key == "hsdp": + mode["hsdp"] = int(value) + else: + raise ValueError(f"unknown mode key {key}") + return mode + + +def build_hf_checkpoint(path: Path, seed: int, model_size: str) -> None: + """Write a randomly initialised Qwen3-MoE HF checkpoint to ``path``. + + Args: + path (Path): Output directory (created by ``save_pretrained``). + seed (int): Seed for the random initialisation. + model_size (str): A key of :data:`MODEL_SIZES`. + """ + from transformers import Qwen3MoeConfig, Qwen3MoeForCausalLM + + config = Qwen3MoeConfig( + **MODEL_SIZES[model_size], + max_position_embeddings=8192, + norm_topk_prob=True, + tie_word_embeddings=False, + bos_token_id=1, + eos_token_id=2, + rope_theta=1000000.0, + use_sliding_window=False, + max_window_layers=MODEL_SIZES[model_size]["num_hidden_layers"], + ) + torch.manual_seed(seed) + model = Qwen3MoeForCausalLM(config) + model.save_pretrained(path, safe_serialization=True) + + +def make_batch(step: int, rank: int, vocab_size: int, seq_len: int) -> ModelItem: + """Build the deterministic random token batch of ``(step, rank)``. + + Args: + step (int): Training step; together with ``rank`` it seeds the batch. + rank (int): Data-parallel rank. + vocab_size (int): Vocabulary size to sample tokens from. + seq_len (int): Number of input tokens. + + Returns: + ModelItem: Sequence context and CE loss context on :data:`DEVICE`. + """ + generator = torch.Generator().manual_seed(100_000 + step * 1024 + rank) + tokens = torch.randint(0, vocab_size, (1, seq_len + 1), generator=generator) + input_ids = tokens[:, :-1] + labels = tokens[:, 1:].to(DEVICE) + seq_ctx = SequenceContext.from_input_ids((input_ids,), device=DEVICE) + loss_cfg = CELossConfig() + loss_ctx = loss_cfg.build(data={"shifted_labels": labels}, sp_mesh=None) + loss_ctx = loss_cfg.loss_ctx_cls.build_batches([loss_ctx])[0] + return ModelItem(seq_ctx=seq_ctx, loss_ctx={"lm": loss_ctx}) + + +def build_engine( + mode: LayoutMode, + hf_path: Path, + tag: str, + dispatcher: str = "all2all", + fp8: bool = False, + lr: float = 1e-4, +) -> TrainEngine: + """Build a ``TrainEngine`` for ``mode`` without loading weights. + + Args: + mode (LayoutMode): Layout under test. + hf_path (Path): HF checkpoint the model config is read from. + tag (str): Unique tag for the mesh prefix; every engine alive in a process needs its own. + dispatcher (str): MoE dispatcher (``"all2all"`` or ``"deepep"``). + fp8 (bool): Enable tile-wise float8 for linear and grouped linear layers. + lr (float): AdamW learning rate. + + Returns: + TrainEngine: Sharded engine with freshly built (uninitialised) parameters. + """ + model_cfg = Qwen3MoEConfig.from_hf(hf_path) + model_cfg.ep_size = mode["ep"] + model_cfg.dispatcher = dispatcher + model_cfg.compile_cfg = False + model_cfg.mesh_prefix = f"{tag}_{mode['name']}" + if fp8: + model_cfg.float8_cfg = Float8Config( + scaling_granularity_gemm=ScalingGranularity.TILEWISE, + scaling_granularity_grouped_gemm=ScalingGranularity.TILEWISE, + ) + fsdp_cfg = FSDPConfig( + ep_size=mode["ep"], + decouple_ep_fsdp=mode["decouple"], + hsdp_sharding_size=mode["hsdp"], + torch_compile=False, + ) + return TrainEngine(model_cfg=model_cfg, optim_cfg=AdamWConfig(lr=lr), fsdp_cfg=fsdp_cfg) + + +def train(engine: TrainEngine, steps: Iterable[int], vocab_size: int, seq_len: int) -> list[float]: + """Run ``steps`` optimizer steps on the shared token stream. + + Args: + engine (TrainEngine): Engine to train. + steps (Iterable[int]): Step indices; they select the batches via :func:`make_batch`. + vocab_size (int): Vocabulary size of the model. + seq_len (int): Tokens per batch. + + Returns: + list[float]: ``reduced_llm_loss`` of every step. + """ + losses = [] + for step in steps: + info = engine.train_step([make_batch(step, dist.get_rank(), vocab_size, seq_len)]) + grad_norm = engine.clip_grad_norm() + engine.step_optimizer(grad_norm) + losses.append(float(info["logs_info"]["reduced_llm_loss"])) + return losses + + +def run_mode( + mode: LayoutMode, + hf_path: Path, + steps: int, + seq_len: int, + lr: float, + dispatcher: str, + fp8: bool, + grad_norm_steps: Sequence[int] = (0, 1, 25), + tag: str = "numerics", +) -> dict[str, Any]: + """Train ``mode`` from the HF checkpoint and record losses, grad norms, memory and step time. + + Args: + mode (LayoutMode): Layout under test. + hf_path (Path): HF checkpoint to load. + steps (int): Number of optimizer steps. + seq_len (int): Tokens per batch. + lr (float): AdamW learning rate. + dispatcher (str): MoE dispatcher. + fp8 (bool): Enable tile-wise float8. + grad_norm_steps (Sequence[int]): Steps whose per-parameter grad norms are recorded. + tag (str): Mesh-prefix tag passed to :func:`build_engine`. + + Returns: + dict[str, Any]: ``losses``, ``grad_norms`` (per step), ``param_grad_norms`` (per recorded + step, keyed by parameter name), ``memory`` (per-rank MiB) and ``step_time_s``. + """ + rank = dist.get_rank() + torch.cuda.reset_peak_memory_stats() + torch.cuda.synchronize() + base_allocated = torch.cuda.memory_allocated() + + engine = build_engine(mode, hf_path, tag, dispatcher=dispatcher, fp8=fp8, lr=lr) + engine.from_hf(hf_path=hf_path, strict=True) + vocab_size = engine.model.config.vocab_size + torch.cuda.synchronize() + result: dict[str, Any] = { + "mode": mode, + "memory": param_memory(engine.model), + "losses": [], + "grad_norms": [], + "param_grad_norms": {}, + "step_time_s": None, + } + result["memory"]["allocated_after_load_mib"] = (torch.cuda.memory_allocated() - base_allocated) / 2**20 + + step_times = [] + for step in range(steps): + torch.cuda.synchronize() + t0 = time.perf_counter() + batch = make_batch(step, rank, vocab_size, seq_len) + info = engine.train_step([batch]) + grad_norm = engine.clip_grad_norm() + if step in grad_norm_steps: + result["param_grad_norms"][str(step)] = per_param_grad_norms(engine.model) + engine.step_optimizer(grad_norm) + torch.cuda.synchronize() + step_times.append(time.perf_counter() - t0) + result["losses"].append(float(info["logs_info"]["reduced_llm_loss"])) + result["grad_norms"].append(float(grad_norm)) + if step == 0: + result["memory"]["allocated_after_step0_mib"] = (torch.cuda.memory_allocated() - base_allocated) / 2**20 + + warm = step_times[5:] if len(step_times) > 10 else step_times + result["step_time_s"] = sum(warm) / len(warm) + result["step_times_s"] = step_times + result["memory"]["peak_allocated_mib"] = (torch.cuda.max_memory_allocated() - base_allocated) / 2**20 + result["memory"]["peak_reserved_mib"] = torch.cuda.max_memory_reserved() / 2**20 + + release(engine) + return result + + +def param_memory(model: torch.nn.Module) -> dict[str, float]: + """Per-rank parameter bytes of the local shards, split into routed experts and everything else. + + Args: + model (torch.nn.Module): Sharded model. + + Returns: + dict[str, float]: ``expert_param_mib`` and ``dense_param_mib``. + """ + expert_bytes = 0 + dense_bytes = 0 + for name, param in model.named_parameters(): + local = param.to_local() if isinstance(param, DTensor) else param + nbytes = local.numel() * local.element_size() + if ".experts" in name: + expert_bytes += nbytes + else: + dense_bytes += nbytes + return {"expert_param_mib": expert_bytes / 2**20, "dense_param_mib": dense_bytes / 2**20} + + +def per_param_grad_norms(model: torch.nn.Module) -> dict[str, float]: + """Global L2 norm of every parameter's gradient, keyed by the unwrapped parameter name. + + Args: + model (torch.nn.Module): Sharded model after ``backward``. + + Returns: + dict[str, float]: Parameter name (without ``_checkpoint_wrapped_module.``) to grad norm. + """ + norms: dict[str, float] = {} + for name, param in model.named_parameters(): + if param.grad is None: + continue + if isinstance(param.grad, DTensor): + norm = cal_total_norm([param.grad], norm_type=2.0, foreach=True, dtype=torch.float32) + else: + norm = torch.linalg.vector_norm(param.grad, 2.0, dtype=torch.float32) + norms[clean_name(name)] = float(norm) + return norms + + +def clean_name(name: str) -> str: + """Strip the activation-checkpoint wrapper prefix from a parameter name. + + Args: + name (str): Name from ``named_parameters``. + + Returns: + str: The name without ``_checkpoint_wrapped_module.``. + """ + return name.replace("_checkpoint_wrapped_module.", "") + + +def rel_diff(a: float, b: float) -> float: + """Relative difference ``|a - b| / |b|`` (``b`` is the reference). + + Args: + a (float): Value under test. + b (float): Reference value. + + Returns: + float: The relative difference; the denominator is clamped to ``1e-12``. + """ + return abs(a - b) / max(abs(b), 1e-12) + + +def max_rel_diff(values: Sequence[float], reference: Sequence[float]) -> float: + """Largest element-wise :func:`rel_diff` of two equally long sequences. + + Args: + values (Sequence[float]): Values under test. + reference (Sequence[float]): Reference values. + + Returns: + float: ``max(rel_diff(v, r))`` over the zipped sequences. + """ + if len(values) != len(reference): + raise ValueError(f"length mismatch: {len(values)} vs {len(reference)}") + return max(rel_diff(a, b) for a, b in zip(values, reference)) + + +def load_hf_dir(path: Path) -> dict[str, torch.Tensor]: + """Load every ``*.safetensors`` file of an HF checkpoint directory. + + Args: + path (Path): Checkpoint directory. + + Returns: + dict[str, torch.Tensor]: All tensors keyed by their checkpoint name. + """ + tensors: dict[str, torch.Tensor] = {} + for file in sorted(path.glob("*.safetensors")): + tensors.update(load_file(str(file))) + return tensors + + +def compare_hf(lhs: Path, rhs: Path) -> dict[str, Any]: + """Compare two HF checkpoint directories tensor by tensor. + + Args: + lhs (Path): Reference checkpoint. + rhs (Path): Checkpoint under test. + + Returns: + dict[str, Any]: Key counts, missing / extra keys, max absolute and relative differences, + the number of mismatched elements and the worst key; ``{"error": ...}`` on a shape mismatch. + """ + a = load_hf_dir(lhs) + b = load_hf_dir(rhs) + missing = sorted(set(a) - set(b)) + extra = sorted(set(b) - set(a)) + max_abs = 0.0 + max_rel = 0.0 + mismatched = 0 + total = 0 + worst = "" + for key in sorted(set(a) & set(b)): + x = a[key].to(torch.float32) + y = b[key].to(torch.float32) + if x.shape != y.shape: + return {"error": f"shape mismatch for {key}: {tuple(x.shape)} vs {tuple(y.shape)}"} + diff = (x - y).abs() + cur = float(diff.max()) + if cur > max_abs: + max_abs, worst = cur, key + max_rel = max(max_rel, float((diff / x.abs().clamp_min(1e-6)).max())) + mismatched += int((diff > 0).sum()) + total += diff.numel() + return { + "keys": len(set(a) & set(b)), + "missing_in_rhs": missing[:5], + "extra_in_rhs": extra[:5], + "max_abs_diff": max_abs, + "max_rel_diff": max_rel, + "mismatched_elements": mismatched, + "total_elements": total, + "worst_key": worst, + } + + +def assert_hf_dirs_close(lhs: Path, rhs: Path, rtol: float, atol: float, label: str) -> None: + """Fail unless two HF checkpoint directories hold the same keys, shapes and close values. + + Args: + lhs (Path): Reference checkpoint. + rhs (Path): Checkpoint under test. + rtol (float): Relative tolerance passed to ``torch.testing.assert_close``. + atol (float): Absolute tolerance passed to ``torch.testing.assert_close``. + label (str): Prefix for the failure message. + """ + a = load_hf_dir(lhs) + b = load_hf_dir(rhs) + if set(a) != set(b): + raise AssertionError( + f"{label}: key sets differ; missing in rhs {sorted(set(a) - set(b))[:5]}, " + f"extra in rhs {sorted(set(b) - set(a))[:5]}" + ) + for key in sorted(a): + x, y = a[key], b[key] + if x.shape != y.shape or x.dtype != y.dtype: + raise AssertionError(f"{label}: {key} has {tuple(y.shape)}/{y.dtype}, expected {tuple(x.shape)}/{x.dtype}") + torch.testing.assert_close(y.float(), x.float(), rtol=rtol, atol=atol, msg=lambda m: f"{label}: {key}: {m}") + + +def release(engine: TrainEngine) -> None: + """Drop an engine and return its device memory to the allocator. + + Args: + engine (TrainEngine): Engine to release; the caller must not use it afterwards. + """ + del engine + gc.collect() + torch.cuda.empty_cache() From 212c05ee7867a001b5962639dae5782d8f68776e Mon Sep 17 00:00:00 2001 From: daixu Date: Thu, 17 Sep 2026 10:04:50 +0000 Subject: [PATCH 10/10] [Docs] Record the topology validator and the pytest gates in the decoupled EP/FSDP design note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §3.1 shows the `model_validator`; §3.6 and the §5 evidence table link the 8-GPU gates next to the experiment scripts; the two resolved items leave §6.3. - reports/decoupled_ep_fsdp_review_zh.md: status note for N1 and W5. --- docs/design/decouple_ep_fsdp.md | 49 +++++++++++++++++---------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/docs/design/decouple_ep_fsdp.md b/docs/design/decouple_ep_fsdp.md index 7d1a4476f2..37603d20e7 100644 --- a/docs/design/decouple_ep_fsdp.md +++ b/docs/design/decouple_ep_fsdp.md @@ -18,7 +18,7 @@ flowchart LR |---|---|---| | routed expert | `Shard(0)` on `ep`,再 FSDP over `fsdp` | 正确,且与新路径数学同构 | | dense(attention / router / shared expert / embedding / lm_head) | `Replicate()` on `ep`(`_replicate_other_params`),再 FSDP over `fsdp` | 参数、梯度、优化器状态在 EP 维复制 `ep` 份;每步一次额外的 coalesced all-reduce 补梯度 | -| HSDP | `assert ep_size == 1` | HSDP 与 EP 不能共存 | +| HSDP | 要求 `ep_size == 1`(`FSDPConfig` 校验) | HSDP 与 EP 不能共存 | EP 越大,dense 侧越亏。以 GLM-5.2-30B 单机 8 卡为例,EP=8 时每卡保存一份完整的 dense 参数与 fp32 优化器状态,legacy 路径 EP=8 的 reserved 显存顶到约 132 GiB 的 allocator 上限(allocated 峰值 120 GiB),每步触发 allocator 释放重试,步时退化到 EP=4 的 7 倍(见 §5)。 @@ -50,29 +50,34 @@ expert : Shard(0) over ep + FSDP over efsdp (+ replicate 上 HS ## 3. 实现 -各小节的"位置"行给出文件与行号,行号对应分支 `feat/decouple-ep-fsdp` 的代码提交 `5923dc1d`(2026-09-03)。 +各小节的"位置"行给出文件与行号,行号对应分支 `feat/decouple-ep-fsdp` 的代码提交 `0a7c6523`(2026-09-17,基于 upstream/main `6c06f96e`)。 ### 3.1 配置与校验 -位置:`xtuner/v1/config/fsdp.py` 48–65,`xtuner/v1/model/moe/moe.py` 1665–1678。 +位置:`xtuner/v1/config/fsdp.py` 44–73,`xtuner/v1/model/moe/moe.py` 1764–1771。 ```python # xtuner/v1/config/fsdp.py decouple_ep_fsdp: bool = False # 默认关闭,旧路径不受影响 -def model_post_init(self, __context): +@model_validator(mode="after") +def _validate_ep_fsdp_topology(self): + # ValueError(pydantic 包装为 ValidationError),不是 assert:python -O 下同样生效 + if self.ep_size < 1 or (self.hsdp_sharding_size is not None and self.hsdp_sharding_size < 1): + raise ValueError(...) # 必须是正整数 if self.hsdp_sharding_size is not None: if self.decouple_ep_fsdp: - assert self.hsdp_sharding_size % self.ep_size == 0 # 必须存在整数 efsdp - else: - assert self.ep_size == 1 # 旧断言只在旧路径保留 + if self.hsdp_sharding_size % self.ep_size != 0: # 必须存在整数 efsdp + raise ValueError(...) + elif self.ep_size != 1: # 旧约束只在旧路径保留 + raise ValueError(...) ``` -`MoE._init_decoupled_device_mesh` 再次校验 `world_size % dp_shard == 0` 与 `dp_shard % ep_size == 0`。ExpertTP(`expert_tp_size > 1`)与解耦同时开启时抛 `NotImplementedError`,ETP 的 `(Shard, InterleavedShard)` 二维 placement 需要单独设计 mesh 维。 +`MoE._init_decoupled_device_mesh` 再次校验 `world_size % dp_shard == 0` 与 `dp_shard % ep_size == 0`,同样抛 `ValueError`。ExpertTP(`expert_tp_size > 1`)与解耦同时开启时抛 `NotImplementedError`,ETP 的 `(Shard, InterleavedShard)` 二维 placement 需要单独设计 mesh 维。 ### 3.2 单一 root mesh -位置:`xtuner/v1/model/moe/moe.py` 1651–1716(`_init_decoupled_device_mesh`),1486–1492(分流)。 +位置:`xtuner/v1/model/moe/moe.py` 1743–1808(`_init_decoupled_device_mesh`),1576–1581(分流)。 ```python # xtuner/v1/model/moe/moe.py::_init_decoupled_device_mesh(节选) @@ -107,7 +112,7 @@ else: ### 3.3 两级 `fully_shard` -位置:`xtuner/v1/model/moe/moe.py` 1194–1225(decoder layer),1250–1260(prefetch),1328–1360(MTP),1631–1649(helper)。 +位置:`xtuner/v1/model/moe/moe.py` 1310–1342(decoder layer),1370–1380(prefetch),1419–1451(MTP),1723–1741(helper)。 ```python # xtuner/v1/model/moe/moe.py::fully_shard(节选) @@ -140,7 +145,7 @@ def _fully_shard_expert_blocks(self, module, mp_policy, reshard_after_forward): ### 3.4 梯度归约 -位置:`xtuner/v1/model/moe/moe.py` 1392–1396(分流),1583–1629(`_scale_and_reduce_grad_decoupled`)。 +位置:`xtuner/v1/model/moe/moe.py` 1482–1486(分流),1673–1722(`_scale_and_reduce_grad_decoupled`)。 不变式:每个参数的最终梯度等于全 data-parallel world 上的均值,与 `ep` / `efsdp` 取值无关。 @@ -178,7 +183,7 @@ for group, grads in grads_by_group.items(): # 每个 group 一次 coalesced al ### 3.5 FP8 -位置:`xtuner/v1/float8/float8_handler.py` 121–161、166–239、316–335;`xtuner/v1/float8/fsdp_utils.py` 127–137;`xtuner/v1/model/moe/moe.py` 1165–1176;`xtuner/v1/model/base.py` 1015。 +位置:`xtuner/v1/float8/float8_handler.py` 121–164、166–238、316–335;`xtuner/v1/float8/fsdp_utils.py` 127–137;`xtuner/v1/model/moe/moe.py` 1281–1292;`xtuner/v1/model/base.py` 1015。 tile-wise FP8 需要知道每个本地权重会被多少个 FSDP rank 切分(决定 padding),以及 scale 的 reduce-max 应跨哪些 rank。解耦后 dense 与 expert 的答案不同: @@ -207,7 +212,7 @@ self.expert_tilewise_reduce_mesh_mapping = self._build_strided_reduce_mesh_mappi ### 3.6 HF 权重与 DCP -位置:无代码改动。依赖 `xtuner/v1/utils/load_spec.py` 的 `LoadSpec.from_tensor`、`RuntimeLayout.from_dtensor`、`plan_hf_save`;验证脚本 `tests/model/run_decoupled_ep_fsdp_ckpt.py`。 +位置:无代码改动。依赖 `xtuner/v1/utils/load_spec.py` 的 `LoadSpec.from_tensor`、`RuntimeLayout.from_dtensor`、`plan_hf_save`;验证用例 `tests/engine/test_decoupled_ep_fsdp_train_engine.py::TestDecoupledEpFsdpCheckpoint`,实验脚本 `tests/model/run_decoupled_ep_fsdp_ckpt.py`。 `LoadSpec.from_tensor` 直接从 DTensor placement 记录 shard 历史,新 placement 无需特殊处理: @@ -224,7 +229,7 @@ dense(解耦 + HSDP) ### 3.7 RL 权重同步 -位置:`xtuner/v1/model/base.py` 1916–1947;`xtuner/v1/rl/weight_update/weight_iterator.py` 140–149、196–208、236–247;测试 `tests/rl/test_weight_iterator.py` 234–418。 +位置:`xtuner/v1/model/base.py` 1916–1947;`xtuner/v1/rl/weight_update/weight_iterator.py` 142–151、198、207–230、240–249;测试 `tests/rl/test_weight_iterator.py` 237–427。 Turbomind 的 layer-wise 更新只 gather FSDP shard、保留 EP-local 的 expert 切片。`BaseModel._fsdp_foreach_allgather` 按每个 `LoadSpec` 选择 gather group: @@ -267,13 +272,13 @@ def _param_owner(model, name): | 项目 | 证据 | 边界 | 链接 | |---|---|---|---| -| mesh / placement / 两级 FSDP(L0) | fake-PG 下 8/16/64 rank 共 29 例;旧布局在开关关闭时逐项固定 | 需要一张空闲 GPU 建 CUDA context,不通信 | [test_decoupled_ep_fsdp_mesh.py](../../tests/model/test_decoupled_ep_fsdp_mesh.py)、[baseline.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/baseline.md) | -| BF16 训练语义(L1) | tiny Qwen3-MoE 50 步:解耦 EP8 vs 旧 EP8 loss 最大相对差 `1.9e-5`(旧 EP8 vs EP1 为 `2.5e-5`);3.4B 模型每卡 dense 参数 836 → 105 MiB,峰值 allocated 11861 → 8203 MiB,步时 170 → 158 ms | 单机;reduction order 不同,不是逐 bit 相同 | [L1.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/L1.md)、[run_decoupled_ep_fsdp_numerics.py](../../tests/model/run_decoupled_ep_fsdp_numerics.py) | -| `efsdp > 1`、HSDP + EP(L2) | 单机缩比拓扑 `(1,2,4)`、`(2,1,4)`、`(2,2,2)` 均在噪声内;16/64 rank 形状由 L0 固定 | 真正的双节点 collective 未跑 | [L2.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/L2.md)、[decisions.md D10](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/decisions.md) | -| HF / DCP(L3) | 刚 `from_hf` 后导出 807 个 key 全部 bit-exact;DCP 同布局 resume 与跨布局 reshard 后 loss 连续 | 未覆盖 async save、FP8 checkpoint、GLM/MTP 导出、world size 变化 | [L3.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/L3.md)、[run_decoupled_ep_fsdp_ckpt.py](../../tests/model/run_decoupled_ep_fsdp_ckpt.py) | +| mesh / placement / 两级 FSDP(L0) | fake-PG 下 8/16/64 rank 共 31 例(含配置校验与运行时校验的回归);旧布局在开关关闭时逐项固定 | 需要一张空闲 GPU 建 CUDA context,不通信 | [test_decoupled_ep_fsdp_mesh.py](../../tests/model/test_decoupled_ep_fsdp_mesh.py)、[baseline.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/baseline.md) | +| BF16 训练语义(L1) | tiny Qwen3-MoE 50 步:解耦 EP8 vs 旧 EP8 loss 最大相对差 `1.9e-5`(旧 EP8 vs EP1 为 `2.5e-5`);3.4B 模型每卡 dense 参数 836 → 105 MiB,峰值 allocated 11861 → 8203 MiB,步时 170 → 158 ms | 单机;reduction order 不同,不是逐 bit 相同 | [test_decoupled_ep_fsdp_train_engine.py](../../tests/engine/test_decoupled_ep_fsdp_train_engine.py)(8 卡 gate:loss 相对差 ≤ 1e-3、总/逐参数梯度范数 ≤ 1e-2、每卡参数显存 ±25%)、[L1.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/L1.md)、[run_decoupled_ep_fsdp_numerics.py](../../tests/model/run_decoupled_ep_fsdp_numerics.py) | +| `efsdp > 1`、HSDP + EP(L2) | 单机缩比拓扑 `(1,2,4)`、`(2,1,4)`、`(2,2,2)` 均在噪声内;16/64 rank 形状由 L0 固定 | 真正的双节点 collective 未跑 | [test_decoupled_ep_fsdp_train_engine.py](../../tests/engine/test_decoupled_ep_fsdp_train_engine.py)(同上阈值,拓扑 `(1,2,4)`、`(2,1,4)`、`(2,2,2)`)、[L2.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/L2.md)、[decisions.md D10](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/decisions.md) | +| HF / DCP(L3) | 刚 `from_hf` 后导出 807 个 key 全部 bit-exact;DCP 同布局 resume 与跨布局 reshard 后 loss 连续 | 未覆盖 async save、FP8 checkpoint、GLM/MTP 导出、world size 变化 | [test_decoupled_ep_fsdp_train_engine.py](../../tests/engine/test_decoupled_ep_fsdp_train_engine.py)(8 卡 gate:step-0 导出逐 bit 相同、resume 与跨布局 loss 相对差 ≤ 1e-3、导出差异 ≤ 数个 bf16 ulp)、[L3.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/L3.md)、[run_decoupled_ep_fsdp_ckpt.py](../../tests/model/run_decoupled_ep_fsdp_ckpt.py) | | FP8 | 3.4B 与 GLM-5.2 tile-wise FP8 训练 20 步稳定,差异量级接近同配置重复噪声 | FP8 + HSDP、FP8 + checkpoint 未测 | [L3.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/L3.md)、[GLM52.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/GLM52.md) | | GLM-5.2-30B,8×H200 目标配方 | EP4:1.688 s / 99.5 GiB → 1.658 s / 83.5 GiB;EP8:11.8 s / 120.0 GiB → 1.64 s / 76.3 GiB | EP8 的加速来自离开 allocator 上限(legacy 每步 2–3 次 alloc retry,解耦为 0),不是 collective 本身快 7 倍 | [GLM52.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/GLM52.md) | -| RL 权重同步 | plain MoE 的 per-spec gather 逻辑成立;compose 的 owner 错误已修复并有单测 | 未做真实 Turbomind 端到端对拍 | [test_weight_iterator.py](../../tests/rl/test_weight_iterator.py#L234-L418) | +| RL 权重同步 | plain MoE 的 per-spec gather 逻辑成立;compose 的 owner 错误已修复并有单测 | 未做真实 Turbomind 端到端对拍 | [test_weight_iterator.py](../../tests/rl/test_weight_iterator.py#L237-L427) | | legacy 兼容 | 开关默认关闭,旧分支执行逻辑未改动,L0 旧布局回归通过 | 未做 base-vs-branch 端到端逐 tensor 对照 | [baseline.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/baseline.md)、[decisions.md](https://github.com/silencelamb/xtuner/blob/feat/decouple-ep-fsdp/reports/decisions.md) | `reports/` 下的报告不随代码 PR 合入,链接指向 fork 分支 `feat/decouple-ep-fsdp` 上的固定地址;测试与脚本是仓库内相对链接。 @@ -311,9 +316,7 @@ def _param_owner(model, name): ### 6.3 其它 -- 新拓扑约束目前用 `assert`,应改为 Pydantic validator + `ValueError`。 - 两次不同 mesh 的 `fully_shard` 与 `DeviceMesh._flatten(name)` 只在 torch 2.8 / 2.9 验证;`pyproject.toml` 声明 `torch>=2.6`,需要补 CI 或为该开关声明更高的最低版本。 -- L1–L3 是人工实验脚本(写 JSON、打印统计),不是会失败的 pytest gate。 - ExpertTP 与解耦互斥;expert dim-1 分片、`shard_placement_fn` 单次包装留作优化。 ## 7. 开放问题(请架构师决策) @@ -343,10 +346,10 @@ expert_fsdp = False(新增): Shard(0) on ep + Replicate on (replicate, efsdp ## 8. 合入建议 -按 torchtitan 的演进顺序拆分: +PR 的提交按 torchtitan 的演进顺序排列,维护者若希望拆分,可按同一顺序切分: 1. 基础解耦:配置开关、root mesh、两级 `fully_shard`、梯度归约、L0 fake-PG 测试; 2. 生态:FP8 per-class padding / reduce mesh、`LoadSpec` 驱动的 HF / DCP、RL per-spec gather 与 compose owner 修复; 3. 文档与后续:本文、§6 的限制项。 -`decouple_ep_fsdp` 默认 `False`,两个代码 PR 都不改动旧路径的执行逻辑,出问题时一键回退。 +`decouple_ep_fsdp` 默认 `False`,所有代码提交都不改动旧路径的执行逻辑,出问题时一键回退。