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

453 lines
21 KiB
Python

# Copyright (c) DeepSpeed Team.
# SPDX-License-Identifier: Apache-2.0
# DeepSpeed Team
"""Route-full / partition-dispatch helpers for AutoEP + AutoTP folding."""
from __future__ import annotations
from dataclasses import dataclass
import os
import torch
import deepspeed.comm as dist
_FOLDING_DIGEST_MOD_A = 2147483647
_FOLDING_DIGEST_MOD_B = 2147483629
@dataclass
class RoutedAssignmentPayload:
token_indices: torch.Tensor
expert_indices: torch.Tensor
assignment_indices: torch.Tensor
capacity_slots: torch.Tensor
combine_weights: torch.Tensor
drop_mask: torch.Tensor
pad_mask: torch.Tensor
input_splits: list[int]
output_splits: list[int]
extra: dict[str, torch.Tensor]
@dataclass
class RestoreContext:
original_payload: RoutedAssignmentPayload
local_indices: torch.Tensor
tp_rank: int
tp_size: int
num_tokens: int
counters: dict[str, int]
def assignment_ordinals_by_expert(expert_indices: torch.Tensor) -> torch.Tensor:
"""Return stable ordinals within each contiguous expert segment."""
if expert_indices.numel() == 0:
return expert_indices.to(torch.long)
positions = torch.arange(expert_indices.numel(), device=expert_indices.device, dtype=torch.long)
starts = torch.zeros_like(positions)
starts[0] = 0
segment_start = torch.zeros(expert_indices.numel(), device=expert_indices.device, dtype=torch.bool)
segment_start[0] = True
segment_start[1:] = expert_indices[1:] != expert_indices[:-1]
starts = torch.where(segment_start, positions, starts)
starts = torch.cummax(starts, dim=0).values
return positions - starts
def _take(payload: RoutedAssignmentPayload, indices: torch.Tensor) -> RoutedAssignmentPayload:
extra = {
key:
value.index_select(0, indices)
if torch.is_tensor(value) and value.shape[:1] == payload.token_indices.shape[:1] else value
for key, value in payload.extra.items()
}
return RoutedAssignmentPayload(
token_indices=payload.token_indices.index_select(0, indices),
expert_indices=payload.expert_indices.index_select(0, indices),
assignment_indices=payload.assignment_indices.index_select(0, indices),
capacity_slots=payload.capacity_slots.index_select(0, indices),
combine_weights=payload.combine_weights.index_select(0, indices),
drop_mask=payload.drop_mask.index_select(0, indices),
pad_mask=payload.pad_mask.index_select(0, indices),
input_splits=list(payload.input_splits),
output_splits=list(payload.output_splits),
extra=extra,
)
def _recompute_input_splits(payload: RoutedAssignmentPayload) -> list[int]:
destinations = payload.extra.get("destination_ranks")
if destinations is None:
return list(payload.input_splits)
if len(payload.input_splits) == 0:
return []
counts = torch.bincount(destinations.to(torch.long), minlength=len(payload.input_splits))
return [int(value) for value in counts[:len(payload.input_splits)].cpu().tolist()]
def _tensor_digest_words(tensor: torch.Tensor) -> torch.Tensor:
tensor = tensor.detach()
if tensor.is_floating_point():
words = torch.nan_to_num(tensor.float(), nan=0.0, posinf=3.4028235e38,
neginf=-3.4028235e38).mul(1000003.0).round().to(torch.long)
else:
words = tensor.to(torch.long)
return words.reshape(-1)
def _digest_words(words: torch.Tensor, *, salt: int, modulus: int) -> torch.Tensor:
if words.numel() == 0:
return torch.tensor(salt, device=words.device, dtype=torch.long)
positions = torch.arange(1, words.numel() + 1, device=words.device, dtype=torch.long)
positions = positions.add_(salt).remainder_(modulus)
values = words.remainder(modulus)
return (values.mul(positions).remainder_(modulus).sum().add_(words.numel() * salt).remainder_(modulus))
def _payload_digest(payload: RoutedAssignmentPayload) -> torch.Tensor:
device = payload.token_indices.device
active = (~payload.drop_mask & ~payload.pad_mask).to(torch.long)
digest = torch.tensor(
[payload.token_indices.numel(),
int(sum(payload.input_splits)),
int(sum(payload.output_splits)), 0, 0],
device=device,
dtype=torch.long)
fields = (
payload.token_indices,
payload.expert_indices,
payload.assignment_indices,
payload.capacity_slots,
payload.combine_weights,
payload.drop_mask,
payload.pad_mask,
active,
payload.extra.get("destination_ranks", torch.empty(0, device=device, dtype=torch.long)),
)
for index, field in enumerate(fields, start=1):
if not torch.is_tensor(field):
continue
words = _tensor_digest_words(field)
digest[3] = digest[3].add(_digest_words(words, salt=17 * index,
modulus=_FOLDING_DIGEST_MOD_A)).remainder_(_FOLDING_DIGEST_MOD_A)
digest[4] = digest[4].add(_digest_words(words, salt=31 * index,
modulus=_FOLDING_DIGEST_MOD_B)).remainder_(_FOLDING_DIGEST_MOD_B)
return digest
def _payload_digest_components(payload: RoutedAssignmentPayload) -> dict[str, torch.Tensor]:
device = payload.token_indices.device
active = (~payload.drop_mask & ~payload.pad_mask).to(torch.long)
fields = {
"token_indices": payload.token_indices,
"expert_indices": payload.expert_indices,
"assignment_indices": payload.assignment_indices,
"capacity_slots": payload.capacity_slots,
"combine_weights": payload.combine_weights,
"drop_mask": payload.drop_mask,
"pad_mask": payload.pad_mask,
"active": active,
"destination_ranks": payload.extra.get("destination_ranks", torch.empty(0, device=device, dtype=torch.long)),
}
components: dict[str, torch.Tensor] = {}
for index, (name, field) in enumerate(fields.items(), start=1):
if not torch.is_tensor(field):
continue
words = _tensor_digest_words(field)
components[name] = torch.stack((
torch.tensor(words.numel(), device=device, dtype=torch.long),
_digest_words(words, salt=17 * index, modulus=_FOLDING_DIGEST_MOD_A),
_digest_words(words, salt=31 * index, modulus=_FOLDING_DIGEST_MOD_B),
))
return components
def _format_payload_debug(payload: RoutedAssignmentPayload, *, digest: torch.Tensor, max_digest: torch.Tensor,
min_digest: torch.Tensor, tp_group) -> str:
if os.environ.get("AUTOEP_FOLDING_DEBUG_PAYLOAD", "0") not in {"1", "true", "TRUE", "yes"}:
return ""
differing_fields = []
for name, component in _payload_digest_components(payload).items():
component_max = component.clone()
component_min = component.clone()
dist.all_reduce(component_max, op=dist.ReduceOp.MAX, group=tp_group)
dist.all_reduce(component_min, op=dist.ReduceOp.MIN, group=tp_group)
if not torch.equal(component_max, component_min):
differing_fields.append({
"field": name,
"local": [int(value) for value in component.detach().cpu().tolist()],
"min": [int(value) for value in component_min.detach().cpu().tolist()],
"max": [int(value) for value in component_max.detach().cpu().tolist()],
})
sample_limit = int(os.environ.get("AUTOEP_FOLDING_DEBUG_SAMPLE_LIMIT", "12"))
samples = {
"token_indices": payload.token_indices[:sample_limit].detach().cpu().tolist(),
"expert_indices": payload.expert_indices[:sample_limit].detach().cpu().tolist(),
"assignment_indices": payload.assignment_indices[:sample_limit].detach().cpu().tolist(),
"capacity_slots": payload.capacity_slots[:sample_limit].detach().cpu().tolist(),
"combine_weights": payload.combine_weights[:sample_limit].detach().float().cpu().tolist(),
}
try:
tp_group_ranks = dist.get_all_ranks_from_group(tp_group)
except Exception:
tp_group_ranks = []
details = {
"rank": dist.get_rank(),
"tp_rank": dist.get_rank(group=tp_group),
"tp_group_ranks": tp_group_ranks,
"digest": [int(value) for value in digest.detach().cpu().tolist()],
"digest_min": [int(value) for value in min_digest.detach().cpu().tolist()],
"digest_max": [int(value) for value in max_digest.detach().cpu().tolist()],
"differing_fields": differing_fields,
"samples": samples,
}
return f" Debug details: {details}"
def assert_tp_payload_consistent(payload: RoutedAssignmentPayload, *, tp_group, tp_size: int) -> None:
if tp_size <= 1 or not dist.is_initialized():
return
digest = _payload_digest(payload)
max_digest = digest.clone()
min_digest = digest.clone()
dist.all_reduce(max_digest, op=dist.ReduceOp.MAX, group=tp_group)
dist.all_reduce(min_digest, op=dist.ReduceOp.MIN, group=tp_group)
if not torch.equal(max_digest, min_digest):
debug_details = _format_payload_debug(payload,
digest=digest,
max_digest=max_digest,
min_digest=min_digest,
tp_group=tp_group)
raise RuntimeError("AutoEP+AutoTP routing decisions differ across tensor-parallel lanes. "
"Folded dispatch requires identical routed-token payloads before TP partitioning."
f"{debug_details}")
def partition_assignments(
payload: RoutedAssignmentPayload,
*,
tp_group,
tp_rank: int,
tp_size: int,
) -> tuple[RoutedAssignmentPayload, RestoreContext]:
"""Partition routed assignments across TP peers by stable per-expert ordinal.
Each peer keeps only ``assignment_index % tp_size == tp_rank`` of the
(token, expert) assignments and drops the rest *before* the EP dispatch
all-to-all, so the dispatch carries the full token set exactly once (split
across peers) instead of ``tp_size`` redundant copies. The dropped work is
reconstructed afterwards by ``restore_combined``'s all-gather; that
reconstruction is what makes the folded router/gate gradient replicated
(AVERAGE) rather than a true SUM partial -- see ``_AllGatherVariableRows``.
"""
active = ~payload.drop_mask & ~payload.pad_mask
if tp_size <= 1:
keep = active
else:
keep = (payload.assignment_indices.remainder(tp_size) == tp_rank) & active
local_indices = torch.nonzero(keep, as_tuple=False).flatten()
local = _take(payload, local_indices)
local.input_splits = _recompute_input_splits(local)
local.output_splits = list(local.input_splits)
ctx = RestoreContext(
original_payload=payload,
local_indices=local_indices,
tp_rank=tp_rank,
tp_size=tp_size,
num_tokens=int(payload.extra.get("num_tokens", torch.tensor(0)).item()) if torch.is_tensor(
payload.extra.get("num_tokens")) else int(payload.extra.get("num_tokens", 0)),
counters={
"assignments_total": int((~payload.drop_mask & ~payload.pad_mask).sum().item()),
"assignments_local": int(local_indices.numel()),
"padded": int(payload.pad_mask.sum().item()),
"dropped": int(payload.drop_mask.sum().item()),
"split_sum_in": int(sum(local.input_splits)),
"split_sum_out": int(sum(local.output_splits)),
},
)
return local, ctx
def _pad_rows(tensor: torch.Tensor, rows: int) -> torch.Tensor:
if tensor.shape[0] == rows:
return tensor
pad_shape = (rows - tensor.shape[0], *tensor.shape[1:])
return torch.cat((tensor, tensor.new_zeros(pad_shape)), dim=0)
class _AllGatherVariableRows(torch.autograd.Function):
"""Differentiable all-gather of row-variable tensors across the TP folding group.
Forward concatenates every TP peer's local rows into one tensor that is
identical on every peer: a replicated full view of the rows that
``partition_assignments`` had split across peers before the EP dispatch.
Backward is the matching reduce-scatter. Because the forward output is
consumed identically on every peer, each peer holds the same ``grad_output``;
summing those replicas with ``all_reduce`` and keeping this peer's own
row-slice is the correct vector-Jacobian product.
Gradient-reduction consequence (important -- this is why the folded
router/gate uses AVERAGE, not SUM): the ``all_reduce`` in backward scales
each peer's slice gradient by ``tp_size``. A parameter whose gradient flows
through this restore all-gather -- the folded router/gate scores, see
``restore_combined`` -- therefore reaches the optimizer's TP reducer carrying
``tp_size`` times its own routed-token slice. The TP reducer all_reduce then
produces ``tp_size * full_grad``, and the AVERAGE strategy in
``auto_ep_folding.autoep_folding_gradient_reduction_strategy`` divides by
``tp_size`` to recover the true gradient. Reducing with SUM instead leaves
the uncancelled ``tp_size`` factor -- exactly the 2.0x router/gate gradient
regression the CPU/Gloo parity tests guard against. The partition is
reconstructed into a replicated full view here, so it is not a genuine SUM
partial; a future true-SP path that kept the shard to the loss would be.
"""
@staticmethod
def forward(ctx, tensor, group, counts, max_rows):
ctx.group = group
ctx.counts = tuple(counts)
ctx.max_rows = max_rows
ctx.group_rank = dist.get_rank(group=group)
if max_rows == 0:
return tensor.new_empty((0, *tensor.shape[1:]))
padded = _pad_rows(tensor, max_rows)
gathered = [torch.zeros_like(padded) for _ in counts]
dist.all_gather(gathered, padded, group=group)
return torch.cat([chunk[:count] for chunk, count in zip(gathered, counts)], dim=0)
@staticmethod
def backward(ctx, grad_output):
local_count = ctx.counts[ctx.group_rank]
if ctx.max_rows == 0:
return grad_output.new_empty((0, *grad_output.shape[1:])), None, None, None
reduced_chunks = []
for chunk, count in zip(torch.split(grad_output, ctx.counts, dim=0), ctx.counts):
grad_padded = grad_output.new_zeros((ctx.max_rows, *grad_output.shape[1:]))
if count:
grad_padded[:count].copy_(chunk)
# grad_output is replicated across TP peers (the gathered full view
# is consumed identically), so this all_reduce sums tp_size copies
# and injects the tp_size factor documented in the class docstring.
dist.all_reduce(grad_padded, group=ctx.group)
reduced_chunks.append(grad_padded)
grad_padded = reduced_chunks[ctx.group_rank]
return grad_padded[:local_count].contiguous(), None, None, None
def _all_gather_variable_rows(tensor: torch.Tensor,
group,
tp_size: int,
*,
preserve_grad: bool = False) -> torch.Tensor:
if tp_size <= 1 or not dist.is_initialized():
return tensor
local_rows = torch.tensor([tensor.shape[0]], dtype=torch.long, device=tensor.device)
row_counts = [torch.zeros_like(local_rows) for _ in range(tp_size)]
dist.all_gather(row_counts, local_rows, group=group)
counts = [int(item.item()) for item in row_counts]
max_rows = max(counts) if counts else tensor.shape[0]
if preserve_grad:
return _AllGatherVariableRows.apply(tensor, group, tuple(counts), max_rows)
else:
padded = _pad_rows(tensor, max_rows)
gathered = [torch.zeros_like(padded) for _ in range(tp_size)]
dist.all_gather(gathered, padded, group=group)
return torch.cat([chunk[:count] for chunk, count in zip(gathered, counts)], dim=0)
def _debug_validate_restore_coverage(payload: RoutedAssignmentPayload, ctx: RestoreContext,
all_token_indices: torch.Tensor, all_expert_indices: torch.Tensor,
all_assignment_indices: torch.Tensor, all_capacity_slots: torch.Tensor) -> None:
active = ~payload.drop_mask & ~payload.pad_mask
expected_rows = torch.stack((
payload.token_indices[active].to(torch.long),
payload.expert_indices[active].to(torch.long),
payload.assignment_indices[active].to(torch.long),
payload.capacity_slots[active].to(torch.long),
),
dim=1)
observed_rows = torch.stack((
all_token_indices.to(torch.long),
all_expert_indices.to(torch.long),
all_assignment_indices.to(torch.long),
all_capacity_slots.to(torch.long),
),
dim=1)
if expected_rows.numel() == 0 and observed_rows.numel() == 0:
return
if observed_rows.shape[0] != expected_rows.shape[0]:
raise RuntimeError("AutoEP+AutoTP restore coverage mismatch: gathered assignment count "
f"{observed_rows.shape[0]} != expected {expected_rows.shape[0]}")
if observed_rows.shape[0] <= 4096:
expected = {tuple(row) for row in expected_rows.detach().cpu().tolist()}
observed = {tuple(row) for row in observed_rows.detach().cpu().tolist()}
if observed != expected:
missing = sorted(expected - observed)[:5]
duplicate_or_stale = sorted(observed - expected)[:5]
raise RuntimeError("AutoEP+AutoTP restore coverage mismatch: "
f"missing={missing} unexpected={duplicate_or_stale}")
def restore_combined(local_combined: torch.Tensor,
ctx: RestoreContext,
*,
tp_group,
validate_coverage: bool = False) -> torch.Tensor:
"""Gather TP-partitioned assignment outputs and combine back by token index.
The all-gather rebuilds an identical full output on every TP peer, so all
downstream compute (and the router/gate score gradient) is replicated across
the folding group. Its differentiable backward injects a ``tp_size`` factor
(see ``_AllGatherVariableRows``) that the optimizer's TP gradient reducer
cancels with the AVERAGE strategy. A future true-SP path that kept
activations sequence-sharded instead of gathering them here would make those
parameters genuine SUM partials -- the reason the SUM family markers exist
in ``deepspeed.module_inject.auto_ep_folding``.
"""
payload = ctx.original_payload
local_token_indices = payload.token_indices.index_select(0, ctx.local_indices)
local_capacity_slots = payload.capacity_slots.index_select(0, ctx.local_indices)
local_weights = payload.combine_weights.index_select(0, ctx.local_indices).to(local_combined.dtype)
all_outputs = _all_gather_variable_rows(local_combined,
tp_group,
ctx.tp_size,
preserve_grad=local_combined.requires_grad)
all_token_indices = _all_gather_variable_rows(local_token_indices, tp_group, ctx.tp_size).to(torch.long)
all_capacity_slots = _all_gather_variable_rows(local_capacity_slots, tp_group, ctx.tp_size).to(torch.long)
all_weights = _all_gather_variable_rows(local_weights,
tp_group,
ctx.tp_size,
preserve_grad=local_weights.requires_grad).to(local_combined.dtype)
if validate_coverage:
local_expert_indices = payload.expert_indices.index_select(0, ctx.local_indices)
local_assignment_indices = payload.assignment_indices.index_select(0, ctx.local_indices)
all_expert_indices = _all_gather_variable_rows(local_expert_indices, tp_group, ctx.tp_size).to(torch.long)
all_assignment_indices = _all_gather_variable_rows(local_assignment_indices, tp_group,
ctx.tp_size).to(torch.long)
_debug_validate_restore_coverage(payload, ctx, all_token_indices, all_expert_indices, all_assignment_indices,
all_capacity_slots)
if ctx.num_tokens <= 0:
ctx.num_tokens = int(payload.token_indices.max().item()) + 1 if payload.token_indices.numel() else 0
output = local_combined.new_zeros((ctx.num_tokens, local_combined.shape[-1]))
if all_outputs.numel() > 0:
weight_shape = (-1, ) + (1, ) * (all_outputs.dim() - 1)
weighted_outputs = all_outputs * all_weights.reshape(weight_shape)
# Add one top-k slot at a time so token accumulation order stays stable
# without materializing a [tokens, top_k, hidden] buffer.
for slot in torch.unique(all_capacity_slots, sorted=True).tolist():
rows = all_capacity_slots == int(slot)
output.index_add_(0, all_token_indices[rows], weighted_outputs[rows])
return output
def dispatch_counters(ctx: RestoreContext) -> dict[str, int]:
return dict(ctx.counters)