Files
2026-07-13 13:18:33 +08:00

514 lines
24 KiB
Python

# Copyright (c) DeepSpeed Team.
# SPDX-License-Identifier: Apache-2.0
# DeepSpeed Team
"""AutoEP + AutoTP folding topology helpers.
The functions in this module are pure topology math unless a caller passes
runtime process-group handles into :class:`FoldingGroupHandles`.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
import torch
AUTOEP_FOLDING_PARAM_FAMILY_ATTR = "ds_autoep_folding_param_family"
AUTOEP_FOLDING_ROUTER_GATE_REPLICATED_PARAM = "router_gate_replicated"
AUTOEP_FOLDING_ROUTER_GATE_PARTIAL_PARAM = "router_gate_partial"
AUTOEP_FOLDING_SP_SHARDED_LAYERNORM_PARAM = "sp_sharded_layernorm"
AUTOEP_FOLDING_GRAD_CORRECTED_ATTR = "ds_autoep_folding_grad_corrected"
AUTOEP_FOLDING_GRAD_REDUCE_SKIP = "skip"
AUTOEP_FOLDING_GRAD_REDUCE_SUM = "sum"
AUTOEP_FOLDING_GRAD_REDUCE_AVERAGE = "average"
# Divide by tp_size with NO TP all_reduce. Used for routed-expert parameters: the
# folded forward all-gathers expert outputs into a replicated full view in
# ``restore_combined``, whose backward injects a ``tp_size`` factor (same factor the
# replicated router cancels via AVERAGE). Routed experts are not TP-replicated, so
# they must not be TP all_reduced; they only need that spurious ``tp_size`` factor
# divided out. The remaining data-parallel reduction is owned by the expert-data
# -parallel (EDP) path, and ``/tp_size`` is linear so it composes with that EDP
# all_reduce in either order.
AUTOEP_FOLDING_GRAD_REDUCE_EXPERT_TP_CANCEL = "expert_tp_cancel"
@dataclass(frozen=True)
class ParallelFoldingSpec:
world_size: int
pp_size: int
stage_size: int
tp_size: int
dp_size: int
ep_size: int
etp_size: int
edp_size: int
mp_mode: str = "tp"
@dataclass(frozen=True)
class FoldingGroupTables:
tp_groups: tuple[tuple[int, ...], ...]
dense_dp_groups: tuple[tuple[int, ...], ...]
ep_groups: tuple[tuple[int, ...], ...]
edp_groups: tuple[tuple[int, ...], ...]
@dataclass(frozen=True)
class FoldingGroupHandles:
spec: ParallelFoldingSpec
tp_group: object
dense_dp_group: object
ep_group: object
edp_group: object
ep_group_name: str
tp_ranks: tuple[int, ...]
dense_dp_ranks: tuple[int, ...]
ep_ranks: tuple[int, ...]
edp_ranks: tuple[int, ...]
def _divisors(value: int) -> list[int]:
return [candidate for candidate in range(1, value + 1) if value % candidate == 0]
def _require_positive(name: str, value: int) -> None:
if not isinstance(value, int) or value < 1:
raise ValueError(f"{name} must be a positive integer, got {value!r}")
def build_folding_spec(
*,
world_size: int,
pp_size: int,
tp_size: int,
ep_size: int,
etp_size: int = 1,
mp_mode: str = "tp",
) -> ParallelFoldingSpec:
"""Build the immutable per-stage folding spec from public config sizes."""
for name, value in (
("world_size", world_size),
("pp_size", pp_size),
("tensor_parallel.autotp_size", tp_size),
("expert_parallel.autoep_size", ep_size),
("expert_parallel.expert_tensor_parallel_size", etp_size),
):
_require_positive(name, value)
if world_size % pp_size != 0:
raise ValueError(f"pp_size={pp_size} must divide world_size={world_size}. "
f"Valid pp_size values: {_divisors(world_size)}")
stage_size = world_size // pp_size
if stage_size % tp_size != 0:
raise ValueError(f"tensor_parallel.autotp_size={tp_size} must divide the stage size "
f"(world_size={world_size} / pp_size={pp_size} = {stage_size}). "
f"Computed dp would be non-integral. Valid autotp_size values: {_divisors(stage_size)}")
expert_width = ep_size * etp_size
if stage_size % expert_width != 0:
raise ValueError(f"expert_parallel.autoep_size * expert_parallel.expert_tensor_parallel_size "
f"({ep_size} * {etp_size} = {expert_width}) must divide the stage size "
f"(world_size={world_size} / pp_size={pp_size} = {stage_size}). "
f"Computed edp would be non-integral. Valid expert-width values: {_divisors(stage_size)}")
return ParallelFoldingSpec(
world_size=world_size,
pp_size=pp_size,
stage_size=stage_size,
tp_size=tp_size,
dp_size=stage_size // tp_size,
ep_size=ep_size,
etp_size=etp_size,
edp_size=stage_size // expert_width,
mp_mode=mp_mode,
)
def expected_folding_group_tables(spec: ParallelFoldingSpec) -> FoldingGroupTables:
"""Derive TP, dense-DP, EP, and EDP rank tables without process groups."""
tp_groups: list[tuple[int, ...]] = []
dense_dp_groups: list[tuple[int, ...]] = []
ep_groups: list[tuple[int, ...]] = []
edp_groups: list[tuple[int, ...]] = []
for stage_start in range(0, spec.world_size, spec.stage_size):
stage_ranks = list(range(stage_start, stage_start + spec.stage_size))
for dp_idx in range(spec.dp_size):
start = dp_idx * spec.tp_size
tp_groups.append(tuple(stage_ranks[start:start + spec.tp_size]))
for tp_lane in range(spec.tp_size):
dense_dp_groups.append(tuple(stage_ranks[tp_lane::spec.tp_size]))
local_ep_groups = [
tuple(stage_ranks[start:start + spec.ep_size]) for start in range(0, len(stage_ranks), spec.ep_size)
]
ep_groups.extend(local_ep_groups)
for pos in range(spec.ep_size):
edp_groups.append(tuple(group[pos] for group in local_ep_groups))
return FoldingGroupTables(
tp_groups=tuple(tp_groups),
dense_dp_groups=tuple(dense_dp_groups),
ep_groups=tuple(ep_groups),
edp_groups=tuple(edp_groups),
)
def local_folding_ranks(global_rank: int, spec: ParallelFoldingSpec) -> dict[str, tuple[int, ...]]:
tables = expected_folding_group_tables(spec)
result = {}
for name, groups in (
("tp", tables.tp_groups),
("dense_dp", tables.dense_dp_groups),
("ep", tables.ep_groups),
("edp", tables.edp_groups),
):
result[name] = next(group for group in groups if global_rank in group)
return result
def _mpu_world_size(mpu, *names: str) -> int | None:
if mpu is None:
return None
for name in names:
getter = getattr(mpu, name, None)
if getter is not None:
return getter()
return None
def validate_folding_global(
spec: ParallelFoldingSpec,
*,
zero_stage: int = 0,
sp_size: int = 1,
deepcompile_enabled: bool = False,
use_data_before_expert_parallel: bool = False,
mpu=None,
autoep_enabled: bool = True,
tp_preset: str | None = None,
ep_preset: str | None = None,
zero_offload_optimizer: bool = False,
zero_offload_param: bool = False,
) -> None:
"""Validate global folding policy before any process group is created."""
if not autoep_enabled:
return
if deepcompile_enabled and spec.tp_size > 1:
raise ValueError("DeepCompile with AutoEP+AutoTP folding is not supported; "
"disable compile.deepcompile or use non-folded AutoEP with tensor_parallel.autotp_size=1.")
if spec.tp_size > 1 and spec.pp_size > 1:
raise ValueError("AutoEP+AutoTP folding currently supports pp_size=1 only; "
f"got pp_size={spec.pp_size}. Pipeline-parallel validation is planned separately.")
if spec.tp_size > 1 and sp_size > 1:
raise ValueError("tensor_parallel.autotp_size and Ulysses sequence parallelism are mutually exclusive "
f"for AutoEP folding (autotp_size={spec.tp_size}, sp_size={sp_size}).")
if spec.etp_size != 1:
raise ValueError(f"expert_parallel.expert_tensor_parallel_size={spec.etp_size} is reserved for "
"expert-internal tensor parallelism and is not supported yet. Use "
"expert_tensor_parallel_size=1; ETP support is planned as follow-up work.")
# Cross-lane expert parallelism (expert_width = ep * etp need NOT be a subset of
# the dense data-parallel size) is supported: ``expected_folding_group_tables``
# lays EP groups across consecutive stage ranks while dense DP remains TP-lane
# strided, so an EP group may span TP lanes and dense-DP ranks while preserving
# node-local EP groups under node-contiguous rank mappings. The only structural
# requirement is that the expert width tiles the stage cleanly, which
# ``build_folding_spec`` already enforces (``stage_size % expert_width == 0``,
# so ``edp`` is integral). The gradient convention holds across the pool
# because each family's reduction is keyed to its replication structure, not
# the EP layout: router/gate and dense/LayerNorm AVERAGE over the TP
# (token-replication) group; routed experts cancel the restore ``tp_size``
# factor (EXPERT_TP_CANCEL) and reduce data-parallel over
# the EDP group. The earlier ``expert_width <= dp`` / ``dp % expert_width == 0``
# fail-fast limitation is therefore removed; only genuinely non-tiling shapes are
# rejected above (in ``build_folding_spec``).
if tp_preset is not None and ep_preset is not None and tp_preset != ep_preset:
raise ValueError("tensor_parallel.preset_model and expert_parallel.preset_model must match when both "
f"are set (tensor_parallel.preset_model={tp_preset!r}, "
f"expert_parallel.preset_model={ep_preset!r}).")
if spec.tp_size > 1 and spec.ep_size == 1:
raise ValueError("AutoEP+AutoTP folding requires expert_parallel.autoep_size > 1. "
"The ep=1 local-computation path would duplicate routed-token gradients across TP lanes.")
if spec.tp_size > 1 and use_data_before_expert_parallel:
raise ValueError("expert_parallel with use_data_before_expert_parallel_ is not supported with "
"AutoEP+AutoTP folding. Disable use_data_before_expert_parallel_.")
if spec.tp_size > 1 and zero_stage == 3:
raise ValueError("AutoEP+AutoTP with ZeRO stage 3 is reserved for the separate ZeRO-3 composition lane. "
"Use ZeRO stage 0, 1, or 2 for this folding MVP.")
if spec.tp_size > 1 and (zero_offload_optimizer or zero_offload_param):
raise ValueError("ZeRO optimizer/parameter offload with AutoEP+AutoTP folding is not validated yet. "
"Disable offload or run a follow-up proof for per-family replica groups.")
mpu_tp = _mpu_world_size(mpu, "get_tensor_model_parallel_world_size", "get_model_parallel_world_size")
if mpu_tp not in (None, 1, spec.tp_size):
raise ValueError(f"mpu tensor/model parallel world size ({mpu_tp}) conflicts with "
f"tensor_parallel.autotp_size={spec.tp_size}.")
mpu_pp = _mpu_world_size(mpu, "get_pipeline_model_parallel_world_size", "get_pipeline_parallel_world_size")
if mpu_pp not in (None, spec.pp_size):
raise ValueError(f"mpu pipeline parallel world size ({mpu_pp}) conflicts with pp_size={spec.pp_size}.")
def mark_autoep_folding_router_parameter(param) -> None:
"""Tag a router/gate parameter as the *replicated* folded family (AVERAGE).
This is the ONLY family marker applied on the live forward path today:
``AutoEPMoELayer.__init__`` marks every ``router.*`` parameter with it. The
folded router runs redundantly on every TP peer (same tokens, same routing)
and its gradient is reconstructed into a replicated full view by the restore
all-gather (see ``deepspeed.moe.ep_tp_dispatch._AllGatherVariableRows`` and
``restore_combined``). That all-gather backward scales each peer's slice by
``tp_size``, so the extra TP reduction must AVERAGE (all_reduce then divide
by ``tp_size``); SUM would leave the ``tp_size`` factor, i.e. the 2.0x
parity regression the CPU/Gloo tests guard.
"""
setattr(param, AUTOEP_FOLDING_PARAM_FAMILY_ATTR, AUTOEP_FOLDING_ROUTER_GATE_REPLICATED_PARAM)
def mark_autoep_folding_partial_router_parameter(param) -> None:
"""Tag a router/gate parameter as a *routed-token partial* family (SUM).
Forward-looking contract; NOT used on the current forward path -- only the
unit tests in ``tests/unit/v1/moe/test_autoep_autotp_grad_parity.py`` set
it. Use it only for a future design where the router's per-token work is
genuinely partitioned across peers and the slices are NOT all-gathered back
into a replicated full view, so each peer holds a real partial gradient that
must be SUMed. Such a router is a SUM partial in any token-partitioned mode
(``mp_mode in {"tp", "sp"}``) because its partition can ride the existing
expert-dispatch all-to-all without changing the dense activation layout.
Prove the SUM with a parity test (like the existing router/gate cases)
before enabling it on a real forward path.
"""
setattr(param, AUTOEP_FOLDING_PARAM_FAMILY_ATTR, AUTOEP_FOLDING_ROUTER_GATE_PARTIAL_PARAM)
def mark_autoep_folding_sp_sharded_layernorm_parameter(param) -> None:
"""Tag a LayerNorm parameter as *SP-sequence-sharded* family (SUM under SP).
Forward-looking contract; NOT used on the current forward path -- only the
unit tests set it. Unlike the router, a LayerNorm has no adjacent dispatch
all-to-all to ride on, so the only way to token-partition it is to shard the
sequence dimension of the dense activations, which is Sequence Parallel by
definition. It therefore becomes a SUM partial only when ``mp_mode == "sp"``
and otherwise falls back to the replicated AVERAGE. Today ``tp_size > 1``
with sequence parallelism is rejected in ``validate_folding_global``; this
marker is the explicit contract for when that restriction is lifted, and
must be backed by a parity test before use.
"""
setattr(param, AUTOEP_FOLDING_PARAM_FAMILY_ATTR, AUTOEP_FOLDING_SP_SHARDED_LAYERNORM_PARAM)
def _is_moe_param_marker(param) -> bool:
return hasattr(param, "allreduce") and not param.allreduce
def _is_model_parallel_param_marker(param) -> bool:
return bool(getattr(param, "model_parallel", False) or getattr(param, "tensor_model_parallel", False))
def _autoep_folding_param_family(param, *, param_name: str | None = None) -> str | None:
"""Resolve a parameter's folded reduction family.
An explicit ``mark_autoep_folding_*`` tag always wins. The ``.router.`` name
match is only a redundant safety net: ``AutoEPMoELayer`` already tags router
params, so this fallback merely keeps the conservative *replicated* (AVERAGE)
classification if some router param ever reaches the reducer untagged. It
never returns a SUM family by name -- SUM families are opt-in via explicit
markers only, so any unrecognized replicated/dense/LayerNorm param falls
through to the AVERAGE default rather than being silently over-scaled.
"""
family = getattr(param, AUTOEP_FOLDING_PARAM_FAMILY_ATTR, None)
if family is not None:
return family
if param_name is not None and ".router." in param_name:
return AUTOEP_FOLDING_ROUTER_GATE_REPLICATED_PARAM
return None
def autoep_folding_gradient_reduction_strategy(
folding_spec: ParallelFoldingSpec | None,
param,
*,
param_name: str | None = None,
) -> str:
"""Classify one folded TP/SP gradient as ``sum``, ``average``, or ``skip``.
TP means Tensor Parallel and SP means Sequence Parallel. The parallel mode
alone is not a safe SUM-vs-AVG selector because different parameter
families see different backward semantics:
- Router/gate parameters that are explicitly marked as routed-token
partials in TP/SP token-partitioned modes receive one partial gradient per
lane, so their TP/SP reduction is a SUM. The current AutoEP folded router
gate is marked ``router_gate_replicated`` because the full-flow backward
reaches this reducer as a lane-replicated gradient; that family uses the
same AVERAGE normalization as other replicated parameters.
- Dense and LayerNorm parameters that are merely replicated by TP folding
are not routed-token partials; blindly SUMing them scales gradients by
the TP size, so their extra TP reduction is an AVERAGE.
- A true SP-sharded LayerNorm would be a partial-gradient parameter and
should SUM. The current AutoEP folding path does not mark runtime
LayerNorm parameters that way; the marker and strategy boundary exist so
future SP support has an explicit contract instead of reusing the dense
replicated default by accident.
- Model-parallel (genuinely TP-sharded) parameters are SKIP because the
TP-specific path owns their reduction.
- Routed-expert parameters are EXPERT_TP_CANCEL: their data-parallel
reduction is owned by the EP/EDP path, but the folded forward all-gathers
their outputs into a replicated full view in ``restore_combined`` (whose
backward injects a ``tp_size`` factor), so the expert-weight gradient
reaches the optimizer ``tp_size`` times too large. Experts are not
TP-replicated, so the fix is a plain ``/tp_size`` (no TP all_reduce), which
is linear and composes with the EDP all_reduce in any order. Without this,
folded expert gradients are over-scaled by ``tp_size`` -- invisible to
scale-invariant Adam but real for SGD/Lion/Muon and for gradient clipping
(it inflates the expert contribution to the global grad norm).
Underlying rule and mechanism: a folded parameter is replicated (AVERAGE)
when the forward reconstructs its partitioned work into an identical full
view inside the layer, and a genuine partial (SUM) only when the shard is
kept all the way to the loss. Today the router/gate is partitioned across
TP peers for dispatch but then all-gathered back by ``restore_combined``
(see ``deepspeed.moe.ep_tp_dispatch``), whose backward scales each peer's
gradient by ``tp_size``; the TP all_reduce then yields ``tp_size *
full_grad`` and AVERAGE divides it out. Reducing with SUM would leave that
factor -- the 2.0x router/gate parity regression the CPU/Gloo tests guard.
The router can be a SUM partial in either ``tp`` or ``sp`` mode because its
token partition can ride the existing dispatch all-to-all, whereas a
LayerNorm becomes a partial only under true ``sp`` (sequence sharding): it
has no adjacent all-to-all, so partitioning it requires changing the dense
activation layout, which is Sequence Parallel by definition.
Both the DeepSpeedEngine path and the ZeRO-2 path call this helper so the
policy cannot silently drift between optimizers.
"""
if folding_spec is None or getattr(folding_spec, "tp_size", 1) <= 1:
return AUTOEP_FOLDING_GRAD_REDUCE_SKIP
if _is_model_parallel_param_marker(param):
# Genuinely TP-sharded (column/row-parallel) params: the TP-specific path
# owns their reduction. Not produced by the folded skip-partition MVP.
return AUTOEP_FOLDING_GRAD_REDUCE_SKIP
if _is_moe_param_marker(param):
# Routed-expert params. Their EP/EDP data-parallel reduction is owned by
# the expert path, but the folded forward routes their outputs through the
# ``restore_combined`` all-gather, whose backward leaves a ``tp_size``
# factor on the expert-weight gradient (the same factor the replicated
# router cancels with AVERAGE). Experts are NOT TP-replicated, so they must
# not be TP all_reduced; the factor is cancelled with a plain ``/tp_size``.
return AUTOEP_FOLDING_GRAD_REDUCE_EXPERT_TP_CANCEL
family = _autoep_folding_param_family(param, param_name=param_name)
mp_mode = getattr(folding_spec, "mp_mode", "tp")
token_partitioned_mode = mp_mode in ("tp", "sp")
if family == AUTOEP_FOLDING_ROUTER_GATE_PARTIAL_PARAM:
return AUTOEP_FOLDING_GRAD_REDUCE_SUM if token_partitioned_mode else AUTOEP_FOLDING_GRAD_REDUCE_AVERAGE
if family == AUTOEP_FOLDING_ROUTER_GATE_REPLICATED_PARAM:
return AUTOEP_FOLDING_GRAD_REDUCE_AVERAGE
if family == AUTOEP_FOLDING_SP_SHARDED_LAYERNORM_PARAM and mp_mode == "sp":
return AUTOEP_FOLDING_GRAD_REDUCE_SUM
return AUTOEP_FOLDING_GRAD_REDUCE_AVERAGE
def reduce_autoep_folding_gradient(
folding_spec: ParallelFoldingSpec | None,
param,
grad,
*,
tp_group,
param_name: str | None = None,
) -> str:
strategy = autoep_folding_gradient_reduction_strategy(folding_spec, param, param_name=param_name)
if strategy == AUTOEP_FOLDING_GRAD_REDUCE_SKIP or grad is None or grad.data.is_sparse:
return strategy
from deepspeed import comm as dist
grad_data = grad.data
tp_world_size = dist.get_world_size(group=tp_group)
# Routed experts: cancel the ``tp_size`` factor the restore all-gather leaves,
# WITHOUT a TP all_reduce (experts are not TP-replicated; cross-TP summation of
# disjoint expert-token slices is owned by the EDP all_reduce). ``/tp_size`` is
# linear, so it composes with that EDP reduction in either order.
if strategy == AUTOEP_FOLDING_GRAD_REDUCE_EXPERT_TP_CANCEL:
if tp_world_size > 1:
grad_data.div_(tp_world_size)
return strategy
if grad_data.dtype != torch.float32:
reduced = grad_data.float()
dist.all_reduce(reduced, group=tp_group)
if strategy == AUTOEP_FOLDING_GRAD_REDUCE_AVERAGE:
reduced.div_(tp_world_size)
grad_data.copy_(reduced.to(grad_data.dtype))
return strategy
dist.all_reduce(grad_data, group=tp_group)
if strategy == AUTOEP_FOLDING_GRAD_REDUCE_AVERAGE:
grad_data.div_(tp_world_size)
return strategy
def is_autoep_folding_gradient_corrected(param) -> bool:
return bool(getattr(param, AUTOEP_FOLDING_GRAD_CORRECTED_ATTR, False))
def clear_autoep_folding_gradient_corrected(param) -> None:
if hasattr(param, AUTOEP_FOLDING_GRAD_CORRECTED_ATTR):
setattr(param, AUTOEP_FOLDING_GRAD_CORRECTED_ATTR, False)
def apply_folding_correction_to_grad_buffer(
folding_spec: ParallelFoldingSpec | None,
param,
grad,
*,
tp_group,
param_name: str | None = None,
use_correction_marker: bool = True,
) -> str:
if use_correction_marker and is_autoep_folding_gradient_corrected(param):
return AUTOEP_FOLDING_GRAD_REDUCE_SKIP
strategy = reduce_autoep_folding_gradient(folding_spec, param, grad, tp_group=tp_group, param_name=param_name)
if use_correction_marker and strategy != AUTOEP_FOLDING_GRAD_REDUCE_SKIP:
setattr(param, AUTOEP_FOLDING_GRAD_CORRECTED_ATTR, True)
return strategy
def _normalize_rank_groups(groups: Iterable[Iterable[int]]) -> set[tuple[int, ...]]:
return {tuple(int(rank) for rank in group) for group in groups}
def assert_group_matches_spec(existing_rank_lists, spec: ParallelFoldingSpec, *, group_kind: str = "ep_edp") -> None:
"""Ensure cached ``ep_size_N`` rank lists match the requested folding spec."""
tables = expected_folding_group_tables(spec)
expected_ep = _normalize_rank_groups(tables.ep_groups)
expected_edp = _normalize_rank_groups(tables.edp_groups)
if isinstance(existing_rank_lists, dict):
observed_ep = existing_rank_lists.get("ep", [])
observed_edp = existing_rank_lists.get("edp", [])
else:
observed_ep, observed_edp = existing_rank_lists
for group in _normalize_rank_groups(observed_ep):
if group not in expected_ep:
raise RuntimeError(f"Cached expert-parallel group {group} does not match folding spec {spec}.")
for group in _normalize_rank_groups(observed_edp):
if group not in expected_edp:
raise RuntimeError(f"Cached expert-data-parallel group {group} does not match folding spec {spec}.")