Initial import: grid-bot — grid trading bot for BTC-USDT on Cifra Markets

This commit is contained in:
Kolp
2026-09-24 13:22:23 +07:00
commit 642cc11a9f
18968 changed files with 5683248 additions and 0 deletions
@@ -0,0 +1,96 @@
# Copyright (c) Meta Platforms, Inc. and affiliates
import torch
import torch.distributed.tensor._ops # force import all built-in dtensor ops
from torch.distributed.device_mesh import DeviceMesh, init_device_mesh # noqa: F401
from torch.distributed.tensor._api import (
distribute_module,
distribute_tensor,
DTensor,
empty,
full,
ones,
rand,
randn,
zeros,
)
from torch.distributed.tensor.placement_types import (
_StridedShard,
Partial,
Placement,
Replicate,
Shard,
)
from torch.optim.optimizer import (
_foreach_supported_types as _optim_foreach_supported_types,
)
from torch.utils._foreach_utils import (
_foreach_supported_types as _util_foreach_supported_types,
)
# All public APIs from dtensor package
__all__ = [
"DTensor",
"distribute_tensor",
"distribute_module",
"Shard",
"Replicate",
"Partial",
"Placement",
"ones",
"empty",
"full",
"rand",
"randn",
"zeros",
]
# For weights_only torch.load
from ._dtensor_spec import (
DTensorSpec as _DTensorSpec,
ShardOrderEntry as _ShardOrderEntry,
TensorMeta as _TensorMeta,
)
torch.serialization.add_safe_globals(
[
DeviceMesh,
_DTensorSpec,
_TensorMeta,
_ShardOrderEntry,
DTensor,
Partial,
Replicate,
Shard,
_StridedShard,
]
)
# Append DTensor to the list of supported types for foreach implementation for optimizer
# and clip_grad_norm_ so that we will try to use foreach over the for-loop implementation on CUDA.
if DTensor not in _optim_foreach_supported_types:
_optim_foreach_supported_types.append(DTensor)
if DTensor not in _util_foreach_supported_types:
_util_foreach_supported_types.append(DTensor) # type: ignore[arg-type]
# Set namespace for exposed private names
DTensor.__module__ = "torch.distributed.tensor"
distribute_tensor.__module__ = "torch.distributed.tensor"
distribute_module.__module__ = "torch.distributed.tensor"
ones.__module__ = "torch.distributed.tensor"
empty.__module__ = "torch.distributed.tensor"
full.__module__ = "torch.distributed.tensor"
rand.__module__ = "torch.distributed.tensor"
randn.__module__ = "torch.distributed.tensor"
zeros.__module__ = "torch.distributed.tensor"
# Register DTensor dispatch for higher order operators
from torch._higher_order_ops.print import _register_dtensor_impl
_register_dtensor_impl()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,566 @@
# mypy: allow-untyped-defs
import logging
import math
from dataclasses import dataclass
from functools import lru_cache
from typing import Optional
import torch
import torch.distributed._functional_collectives as funcol
import torch.distributed.tensor._dtensor_spec as dtensor_spec
from torch._C._distributed_c10d import _resolve_process_group
from torch._logging import warning_once
from torch.distributed._functional_collectives import _are_we_tracing
from torch.distributed._local_tensor import (
local_tensor_mode,
maybe_run_for_local_tensor,
)
from torch.distributed.device_mesh import _mesh_resources, DeviceMesh
from torch.distributed.distributed_c10d import (
broadcast,
get_group_rank,
get_rank,
GroupName,
ProcessGroup,
scatter,
Work,
)
from torch.fx.experimental.symbolic_shapes import guard_or_false
from torch.types import IntLikeType
logger = logging.getLogger(__name__)
# Opaque types must be registered before defining schemas that reference them,
# so the schema parser recognizes the type names and uses PyObjectType (which
# wraps Python objects as ConcretePyObjectHolder) instead of AnyType (which
# calls toTypeInferredIValue and fails for Python-only opaque types).
from torch.distributed.device_mesh import _register_distributed_opaque_types
_register_distributed_opaque_types()
_dtensor_lib = torch.library.Library("_dtensor", "FRAGMENT")
_dtensor_lib.define(
"mesh_get_process_group("
"torch.distributed.device_mesh.DeviceMesh mesh, int dim"
") -> torch.distributed.distributed_c10d.ProcessGroup"
)
@torch.library.impl("_dtensor::mesh_get_process_group", "CompositeExplicitAutograd")
def _mesh_get_process_group_impl(mesh, dim):
return mesh.get_group(dim)
@torch.library.register_fake("_dtensor::mesh_get_process_group")
def _mesh_get_process_group_fake(mesh, dim):
from torch._library.fake_class_registry import maybe_unwrap_fake_script_object
real_mesh = maybe_unwrap_fake_script_object(mesh)
return real_mesh.get_group(dim)
@torch.library.register_fake("_dtensor::shard_dim_alltoall")
def _shard_dim_alltoall_meta(
input, gather_dim, shard_dim, group_name: GroupName | ProcessGroup
):
if isinstance(group_name, str):
# pyrefly: ignore[bad-argument-type] # pyrefly bug
group_name = _resolve_process_group(group_name)
group_size = group_name.size()
stacked_list = [torch.empty_like(input) for _ in range(group_size)]
group_rank = get_group_rank(group_name, get_rank())
cat_tensor = torch.cat(stacked_list, dim=gather_dim)
# pyrefly: ignore [unsupported-operation]
chunk_size = cat_tensor.size(shard_dim) // group_size
chunk = torch.narrow(cat_tensor, shard_dim, group_rank * chunk_size, chunk_size)
return chunk.contiguous()
def shard_dim_alltoall(input, gather_dim, shard_dim, mesh, mesh_dim):
if mesh.device_type == "cpu" and local_tensor_mode() is None:
# Gloo does not support alltoall, so falling back to allgather + chunk
warning_once(
logger,
"CPU process group does not support alltoall yet, falling back with allgather + chunk!",
)
out = funcol.all_gather_tensor(input, gather_dim, (mesh, mesh_dim))
if isinstance(out, funcol.AsyncCollectiveTensor):
# stick to the same behavior for the alltoall case, remove this once we enable alltoall async
out = out.wait()
from torch.distributed.tensor.placement_types import Shard
out = Shard._custom_chunk(out, mesh.size(mesh_dim), dim=shard_dim)[
mesh.get_local_rank(mesh_dim)
]
return out.contiguous()
group = funcol._resolve_group((mesh, mesh_dim))
# TODO: enable async op for shard_dim_alltoall
return torch.ops._dtensor.shard_dim_alltoall(
input, gather_dim, shard_dim, funcol._group_or_group_name(group)
)
def mesh_scatter(
output: torch.Tensor,
scatter_list: list[torch.Tensor],
mesh: DeviceMesh,
mesh_dim: int = 0,
async_op: bool = False,
*,
group_src: int = 0,
) -> Work | None:
"""
scatter a list of tensors to a device mesh dimension. We by default
use the first rank of the mesh dimension as the source of truth, i.e
for a 2d mesh [[0, 1], [2, 3]], if we scatter on mesh_dim = 1, we will
scatter the tensor list on rank 0 to rank 0/1, and tensor list on rank
2 to rank 2/3.
Args:
output (torch.Tensor): the tensor to receive the scattered list.
scatter_list (List[torch.Tensor]): the tensor list to be scattered.
mesh_dim (int, optional): indicate which mesh dimension we want
to scatter on, we by default choose the first rank on the
mesh dimension as source of truth.
Keyword args:
group_src (int, optional): the group rank of the source data for the
logical/global tensor, on the specific mesh dimension. By default, we
use ``group_rank=0`` on each DeviceMesh dimension as the source data
to preserve the single-device semantic. If passing ``None`` explicitly,
this method simply uses its local data with no communication.
Returns:
A :class:`Work` object
"""
# TODO: Ideally we should use the meta tensor way
# (to register a meta kernel for the collective op)
# so that it would avoid the communication. Need to
# remove the check below once that is done.
if output.is_meta:
return None
dim_group = mesh.get_group(mesh_dim)
if not isinstance(dim_group, ProcessGroup):
raise AssertionError
if group_src == get_rank(dim_group):
fut = scatter(
output,
scatter_list=scatter_list,
group=dim_group,
async_op=async_op,
group_src=group_src,
)
else:
fut = scatter(
output,
scatter_list=None,
group=dim_group,
async_op=async_op,
group_src=group_src,
)
return fut
def mesh_broadcast(
tensor: torch.Tensor,
mesh: DeviceMesh,
mesh_dim: int = 0,
async_op: bool = False,
*,
group_src: int = 0,
) -> Work | None:
"""
broadcast the tensor to a device mesh dimension. We by default
use the first rank of the mesh dimension as the source of truth, i.e
for a 2d mesh [[0, 1], [2, 3]], if we broadcast on mesh_dim = 1, we will
broadcast the tensor on rank 0 to rank 0/1, and tensor on rank 2
to rank 2/3.
Args:
tensor (torch.Tensor): tensor to broadcast.
mesh_dim (int, optional): indicate which mesh dimension we want
to scatter on, we by default choose the first rank on the
mesh dimension as source of truth.
Keyword args:
group_src (int, optional): the group rank of the source data for the
logical/global tensor, on the specific mesh dimension. By default, we
use ``group_rank=0`` on each DeviceMesh dimension as the source data
to preserve the single-device semantic. If passing ``None`` explicitly,
this method simply uses its local data with no communication.
Returns:
A :class:`Work` object
"""
# TODO: Ideally we should use the meta tensor way
# (to register a meta kernel for the collective op)
# so that it would avoid the communication. Need to
# remove the check below once that is done.
if tensor.is_meta:
return None
dim_group = mesh.get_group(mesh_dim)
if not isinstance(dim_group, ProcessGroup):
raise AssertionError
return broadcast(tensor, group=dim_group, async_op=async_op, group_src=group_src)
@maybe_run_for_local_tensor
def pad_tensor(
tensor: torch.Tensor, pad_dim: int, pad_size: IntLikeType
) -> torch.Tensor:
# During tracing, always emit the pad op even when pad_size=0 so all
# ranks produce identical FX graph structure (SPMD).
# guard_or_false returns False for symbolic sizes, so the pad is always
# emitted during tracing. In eager with concrete pad_size=0, it returns
# True and we skip the no-op pad.
if guard_or_false(pad_size == 0) and not _are_we_tracing():
return tensor
pad = [0, 0] * (tensor.ndim - pad_dim)
pad[-1] = pad_size # pyrefly: ignore[unsupported-operation]
return torch.nn.functional.pad(tensor, pad)
@maybe_run_for_local_tensor
def unpad_tensor(
tensor: torch.Tensor, pad_dim: int, pad_size: IntLikeType
) -> torch.Tensor:
# During tracing, always emit the narrow op even when pad_size=0 so all
# ranks produce identical FX graph structure (SPMD).
if guard_or_false(pad_size == 0) and not _are_we_tracing():
return tensor
return tensor.narrow(
pad_dim,
start=0,
length=tensor.size(pad_dim) - pad_size,
)
def fill_empty_tensor_to_shards(
shards: list[torch.Tensor], shard_dim: int, num_empty_tensors: int
) -> list[torch.Tensor]:
if num_empty_tensors == 0:
return shards
tensor_size = list(shards[0].size())
tensor_size[shard_dim] = 0
tensor = shards[0].new_zeros(tensor_size)
shards.extend(tensor for _ in range(num_empty_tensors))
return shards
def check_tensor_meta(
local_tensor, check_shape_stride=False
) -> Optional["dtensor_spec.TensorMeta"]:
local_metadata = {
"dtype": local_tensor.dtype,
"requires_grad": local_tensor.requires_grad,
}
if check_shape_stride:
local_metadata.update(
{"shape": local_tensor.shape, "stride": local_tensor.stride()}
)
gathered_metadata = [None for _ in range(torch.distributed.get_world_size())]
torch.distributed.all_gather_object(gathered_metadata, local_metadata)
# Check if metadata is consistent across ranks
if not all(meta == local_metadata for meta in gathered_metadata):
raise ValueError(
"Inconsistent tensor metadata (including shape and stride) across ranks."
)
return None
def spec_to_bytes(spec: "dtensor_spec.DTensorSpec") -> int:
if spec.tensor_meta is None:
raise AssertionError("spec should have tensor meta defined!")
return spec.tensor_meta.dtype.itemsize * math.prod(spec.shape)
@dataclass
class MeshTopoInfo:
"""
Mesh information for collective cost estimation
"""
mesh: DeviceMesh
mesh_dim_devices: list[int]
mesh_dim_bandwidth: list[float]
mesh_dim_latency: list[float]
@staticmethod
@lru_cache(None)
def build_from_mesh(mesh: DeviceMesh) -> "MeshTopoInfo":
# Generate mesh topology info for intra-host/inter-host communication pattern
# Note that we made bunch of assumptions for simplicity:
# 1. we assume the mesh is homogeneous, and it's gpu/nccl model
# 2. we assume gpu arch is Ampere or Hopper
# 3. we assume collectives are all ring base algo for now
num_devices_per_host = _mesh_resources.num_devices_per_host(mesh.device_type)
# the base bw number (intra-node), GB/s
base_bw = 87.7
mesh_dim_bandwidth = [base_bw] * mesh.ndim
# the latency in terms of us (intra-node, nv-link)
mesh_dim_latency = [0.6] * mesh.ndim
mesh_dim_devices = [1] * mesh.ndim
total_num_devices = 1
for mesh_dim in reversed(range(mesh.ndim)):
num_devices = mesh.size(mesh_dim)
mesh_dim_devices[mesh_dim] = num_devices
total_num_devices *= num_devices
if total_num_devices > num_devices_per_host:
# magic number for inter-host communication bandwidth/latency factor
# This number assumes latest GPU arch, i.e. Ampere or Hopper
# TODO: see if we need to tweak this or offer a way for user
# to specify the bandwidths/latency
mesh_dim_bandwidth[mesh_dim] *= 0.22
# set to ethernet latency for inter-host
mesh_dim_latency[mesh_dim] = 2.7
return MeshTopoInfo(
mesh, mesh_dim_devices, mesh_dim_bandwidth, mesh_dim_latency
)
def allgather_cost(bytes_gb: float, mesh_topo: MeshTopoInfo, mesh_dim: int) -> float:
num_devices_on_mesh_dim = mesh_topo.mesh_dim_devices[mesh_dim]
mesh_dim_bandwidth = mesh_topo.mesh_dim_bandwidth[mesh_dim]
num_hops = num_devices_on_mesh_dim - 1
# base latency + comm latency
latency = 6.6 + num_hops * mesh_topo.mesh_dim_latency[mesh_dim] # us
bw = (bytes_gb * num_hops / num_devices_on_mesh_dim) / mesh_dim_bandwidth # s
return latency + bw * 1e6 # rescale to us
def allreduce_cost(bytes_gb: float, mesh_topo: MeshTopoInfo, mesh_dim: int) -> float:
num_devices_on_mesh_dim = mesh_topo.mesh_dim_devices[mesh_dim]
mesh_dim_bandwidth = mesh_topo.mesh_dim_bandwidth[mesh_dim]
# allreduce have almost 2x comm bytes compare to allgather/reduce_scatter
num_hops = 2 * (num_devices_on_mesh_dim - 1)
latency = 6.6 + num_hops * mesh_topo.mesh_dim_latency[mesh_dim]
bw = (bytes_gb * num_hops / num_devices_on_mesh_dim) / mesh_dim_bandwidth
return latency + bw * 1e6
def reduce_scatter_cost(
bytes_gb: float,
mesh_topo: MeshTopoInfo,
mesh_dim: int,
) -> float:
num_devices_on_mesh_dim = mesh_topo.mesh_dim_devices[mesh_dim]
mesh_dim_bandwidth = mesh_topo.mesh_dim_bandwidth[mesh_dim]
num_hops = num_devices_on_mesh_dim - 1
# base latency + comm latency
latency = 6.6 + num_hops * mesh_topo.mesh_dim_latency[mesh_dim]
bw = (bytes_gb * num_hops / num_devices_on_mesh_dim) / mesh_dim_bandwidth
return latency + bw * 1e6
def _compute_placement_transition_cost(
current_placement: "dtensor_spec.Placement",
target_placement: "dtensor_spec.Placement",
mesh_topo: MeshTopoInfo,
mesh_dim: int,
comm_bytes_gb: float,
) -> tuple[float, float]:
"""
Compute the cost of transitioning from one placement to another on a single mesh dimension.
Args:
current_placement: The current placement on the mesh dimension.
target_placement: The target placement on the mesh dimension.
mesh_topo: Mesh topology information for cost estimation.
mesh_dim: The mesh dimension where the transition happens.
comm_bytes_gb: The communication bytes in GB for this step.
Returns:
A tuple of (cost, updated_comm_bytes_gb):
- cost: The communication cost for this transition (float("inf") if invalid).
- updated_comm_bytes_gb: The updated communication bytes after this step.
"""
if current_placement == target_placement:
return 0.0, comm_bytes_gb
num_devices_on_mesh_dim = mesh_topo.mesh_dim_devices[mesh_dim]
# NOTE: is_shard() does not match _StridedShard; see _is_shard_like().
# Safe today: redistribute_cost bails with inf when shard_order is None.
if current_placement.is_shard() and target_placement.is_replicate():
# allgather gives larger comm bytes
comm_bytes_gb *= num_devices_on_mesh_dim
return allgather_cost(comm_bytes_gb, mesh_topo, mesh_dim), comm_bytes_gb
elif current_placement.is_shard() and target_placement.is_shard():
# should be alltoall comm, since we haven't implement it yet, add 1.0 as penalty
# to favor allgather instead
# TODO: add alltoall_cost
return allgather_cost(comm_bytes_gb, mesh_topo, mesh_dim) + 1.0, comm_bytes_gb
elif current_placement.is_partial() and target_placement.is_replicate():
return allreduce_cost(comm_bytes_gb, mesh_topo, mesh_dim), comm_bytes_gb
elif current_placement.is_partial() and target_placement.is_shard():
cost = reduce_scatter_cost(comm_bytes_gb, mesh_topo, mesh_dim)
# after reduce_scatter the comm bytes for further collectives halved.
comm_bytes_gb /= num_devices_on_mesh_dim
return cost, comm_bytes_gb
elif current_placement.is_shard() and target_placement.is_partial():
# ban shard -> partial as it does not make sense to perform
# this redistribute
return float("inf"), comm_bytes_gb
elif current_placement.is_partial() and target_placement.is_partial():
# we already handled the == case at the top, and we ban converting between partial types.
return float("inf"), comm_bytes_gb
elif current_placement.is_replicate() and target_placement.is_shard():
comm_bytes_gb /= num_devices_on_mesh_dim
return 0.0, comm_bytes_gb
return 0.0, comm_bytes_gb
def one_step_redistribute_cost(
current_spec: "dtensor_spec.DTensorSpec",
target_spec: "dtensor_spec.DTensorSpec",
) -> float:
"""
Calculate the cost of a single redistribution step between two DTensorSpecs.
This function computes the communication cost for a one-step redistribution
where the current and target specs differ by exactly one placement on one
mesh dimension.
Args:
current_spec: The current DTensorSpec.
target_spec: The target DTensorSpec.
Returns:
The communication cost for this step (float("inf") if invalid).
"""
if current_spec.mesh != target_spec.mesh:
return float("inf")
if current_spec.placements == target_spec.placements:
return 0.0
# Find the mesh dimension that differs
mesh_dim = -1
current_placement = None
target_placement = None
for i, (cur, tgt) in enumerate(
zip(current_spec.placements, target_spec.placements)
):
if cur != tgt:
if mesh_dim != -1:
# More than one dimension differs - not a single step
raise ValueError(
"one_step_redistribute_cost expects specs that differ by exactly one placement"
)
mesh_dim = i
current_placement = cur
target_placement = tgt
if mesh_dim == -1:
return 0.0
if current_placement is None or target_placement is None:
raise AssertionError
mesh_topo = MeshTopoInfo.build_from_mesh(current_spec.mesh)
comm_bytes_gb = (
spec_to_bytes(current_spec) / current_spec.num_shards / 1024 / 1024 / 1024
)
cost, _ = _compute_placement_transition_cost(
current_placement, target_placement, mesh_topo, mesh_dim, comm_bytes_gb
)
return cost
def redistribute_cost(
current_spec: "dtensor_spec.DTensorSpec",
target_spec: "dtensor_spec.DTensorSpec",
) -> float:
"""
This function returns the cost of redistribute from current to target DTensorSpec.
NOTE:
1. Only consider communication cost here, since computation costs for redistribute
are quite trivial (i.e. we only need to narrow or simple division)
2. Only consider redistribute cost on same mesh, cross mesh communication cost is
not quite needed for operator strategy estimation/selection.
"""
if current_spec.mesh != target_spec.mesh:
# make infinite cost if meshes are not same
# TODO: see if we want to support this once there's cross mesh communication
return float("inf")
if current_spec.is_replicated():
# short-cut: comm cost is 0 if current spec is already full replication
return 0.0
# TODO(zpcore): test placements with _StridedShard if we replace shard_order
# with _StridedShard.
if (
current_spec.placements == target_spec.placements
and current_spec.shard_order == target_spec.shard_order
):
return 0.0
# For sub-meshes, ranks not participating in the mesh should not compute
# redistribution costs. Return 0 since they won't actually participate.
if not current_spec.mesh._is_current_rank_part_of_mesh():
return 0.0
mesh_topo = MeshTopoInfo.build_from_mesh(current_spec.mesh)
cost = 0.0
comm_bytes_gb = (
spec_to_bytes(current_spec) / current_spec.num_shards / 1024 / 1024 / 1024
)
# Transformation that considered for redistribute cost:
# 1. allgather 2. alltoall
# 3. allreduce 4. reduce_scatter
from torch.distributed._functional_collectives import _are_we_tracing
from torch.distributed.tensor._redistribute import (
_gen_transform_infos,
_gen_transform_infos_non_cached,
)
# TODO(zpcore): Support _StridedShard redistribution. Remove the temporary
# fix, which is to prevent StridedShard erroring out.
if current_spec.shard_order is None or target_spec.shard_order is None:
return float("inf")
# No redistribution needed when placements are already identical.
# This also prevents potential failures in _gen_transform_infos for certain configurations
# (e.g., sub-meshes) where finding a transform path between identical states may error out.
# TODO(zpcore): test placements with _StridedShard if we replace shard_order
# with _StridedShard.
if (
current_spec.placements == target_spec.placements
and current_spec.shard_order == target_spec.shard_order
):
return cost
if _are_we_tracing():
transform_infos = _gen_transform_infos_non_cached(current_spec, target_spec)
else:
transform_infos = _gen_transform_infos(current_spec, target_spec)
for transform_info in transform_infos:
if current_spec.tensor_meta is None:
raise AssertionError("spec should have tensor meta defined!")
current = transform_info.src_dst_placements[0]
target = transform_info.src_dst_placements[1]
mesh_dim = transform_info.mesh_dim
step_cost, comm_bytes_gb = _compute_placement_transition_cost(
current, target, mesh_topo, mesh_dim, comm_bytes_gb
)
if step_cost == float("inf"):
return float("inf")
cost += step_cost
return cost
@@ -0,0 +1,330 @@
# mypy: allow-untyped-defs
"""
Decomposition-based sharding propagation for DTensor.
When an operator doesn't have a registered sharding strategy, we derive one by
tracing through its decomposition. The decomposed ops (which do have strategies)
determine how placements propagate through the original op.
"""
from __future__ import annotations
import itertools
from typing import Any, TYPE_CHECKING
import torch
from torch._decomp import decomposition_table
from torch.distributed._functional_collectives import _are_we_tracing
from torch.distributed.device_mesh import DeviceMesh
from torch.distributed.tensor._dtensor_spec import DTensorSpec
from torch.distributed.tensor._op_schema import OpSchema, OpStrategy, RuntimeSchemaInfo
from torch.distributed.tensor._utils import try_find_mesh_from_args
from torch.distributed.tensor.placement_types import (
_StridedShard,
Placement,
Replicate,
Shard,
)
from torch.fx.experimental.symbolic_shapes import GuardOnDataDependentSymNode
from torch.utils._python_dispatch import TorchDispatchMode
def _infer_schema_info_from_op(op: OpOverload) -> RuntimeSchemaInfo:
"""Infer RuntimeSchemaInfo from an operator's schema for decomposition ops"""
schema = op._schema
# Find first non-tensor positional arg index
static_argnum = None
for i, arg in enumerate(schema.arguments):
if arg.kwarg_only:
break
if arg.type.kind() != "TensorType" and static_argnum is None:
static_argnum = i
break
# Find keyword-only args that aren't tensors
kwarg_only_names = []
for arg in schema.arguments:
if arg.kwarg_only and arg.type.kind() != "TensorType":
kwarg_only_names.append(arg.name)
kwargs = {}
if static_argnum is not None:
kwargs["static_argnum"] = static_argnum
if kwarg_only_names:
# pyrefly: ignore [unsupported-operation]
kwargs["static_kwargkey"] = kwarg_only_names
# pyrefly: ignore [bad-argument-type]
return RuntimeSchemaInfo(**kwargs)
from torch.utils._pytree import tree_any, tree_flatten, tree_map, tree_map_only
if TYPE_CHECKING:
from torch._ops import OpOverload
from torch.distributed.tensor._sharding_prop import ShardingPropagator
def _extract_input_specs(op_schema: OpSchema) -> tuple[DTensorSpec | object, ...]:
return op_schema.args_schema + tuple(op_schema.kwargs_schema.values())
class PlacementTrackingMode(TorchDispatchMode):
"""
TorchDispatchMode that tracks DTensor placements through op execution.
Used during decomposition tracing: intercepts each op, propagates sharding
via the ShardingPropagator, and records output placements on the result tensors.
"""
def __init__(self, sharding_prop: ShardingPropagator, mesh: DeviceMesh):
super().__init__()
self.sharding_prop = sharding_prop
self.mesh = mesh
def __torch_dispatch__(self, func, types, args=(), kwargs=None):
args_schema, kwargs_schema = tree_map(
lambda x: getattr(x, "_spec", x) if isinstance(x, torch.Tensor) else x,
(args, kwargs or {}),
)
if not tree_any(
lambda x: isinstance(x, DTensorSpec), (args_schema, kwargs_schema)
):
raise NotImplementedError(f"No DTensorSpec found in args/kwargs for {func}")
# Set schema_info so the LRU cache key includes static args
op_schema = OpSchema(func, args_schema, kwargs_schema)
schema_info = self.sharding_prop.op_to_schema_info.get(func)
if schema_info is None:
schema_info = (
self.sharding_prop.op_to_schema_info_for_single_dim_strategy.get(func)
)
if schema_info is not None:
op_schema.schema_info = schema_info
op_schema._recompute_comparison_key()
if _are_we_tracing():
output_sharding = self.sharding_prop.propagate_op_sharding_non_cached(
op_schema
)
else:
output_sharding = self.sharding_prop.propagate_op_sharding(op_schema)
if (
output_sharding.needs_redistribute # pyrefly: ignore [missing-attribute]
and (
redistribute_schema
:= output_sharding.redistribute_schema # pyrefly: ignore [missing-attribute]
)
is not None
):
# a pure .needs_redistribute check is too broad; we want to ban redistribution,
# but this flag is set for view ops that convert global shape -> local shape args.
# During decomposition tracing on meta tensors at global shape, the shape adjustment
# is irrelevant — only reject true redistribution.
for orig, desired in zip(
op_schema.args_spec,
redistribute_schema.args_spec, # pyrefly: ignore [missing-attribute]
):
if orig.placements != desired.placements:
raise RuntimeError(
f"Decomposition requires redistribution for {func}"
)
out = func(*args, **kwargs)
# pyrefly: ignore [missing-attribute]
self._record_output_specs(out, output_sharding.output_spec)
return out
def _record_output_specs(self, output: Any, output_spec: DTensorSpec | Any) -> None:
if isinstance(output, torch.Tensor) and output_spec is not None:
output._spec = output_spec # pyrefly: ignore [missing-attribute]
elif isinstance(output, (tuple, list)) and isinstance(
output_spec, (tuple, list)
):
for t, s in zip(output, output_spec):
self._record_output_specs(t, s)
class DecompShardingStrategy:
"""
Generates sharding strategies for ops by tracing through their decompositions.
For each candidate input placement combination, runs the decomposition on meta
tensors under PlacementTrackingMode to determine the output placement. These
single-dimension strategies are then expanded to the full mesh.
"""
def __init__(self, sharding_prop: ShardingPropagator):
self.sharding_prop = sharding_prop
# Cache fake meshes per device type to avoid repeated allocation.
# A fake size-1 mesh ensures identical strategy computation across all ranks
# during decomposition tracing, avoiding potential SPMD divergence.
# False negatives are avoided (all sizes % 1 == 0), while false positives
# are caught on expansion to the real, multi-dim device mesh.
self._fake_meshes: dict[str, DeviceMesh] = {}
def _get_fake_mesh(self, device_type: str) -> DeviceMesh:
fake_mesh = self._fake_meshes.get(device_type)
if fake_mesh is None:
fake_mesh = DeviceMesh(device_type, [0], _init_backend=False, _rank=0)
self._fake_meshes[device_type] = fake_mesh
return fake_mesh
@staticmethod
def has_decomp(op: OpOverload) -> bool:
# Check if op has a decomposition (explicit or CIA)
return op in decomposition_table or op._can_decompose()
def ensure_schema_info(self, op: OpOverload) -> None:
"""
Register schema_info for decomposition op on first invocation.
Needed for correct shard prop cache key.
"""
if op not in self.sharding_prop.op_to_schema_info:
schema_info = _infer_schema_info_from_op(op)
self.sharding_prop.op_to_schema_info[op] = schema_info
def propagate_strategy(
self,
op_schema: OpSchema,
) -> OpStrategy | None:
if not tree_any(
lambda x: isinstance(x, DTensorSpec),
(op_schema.args_schema, op_schema.kwargs_schema),
):
return None
candidate_placements = self._get_candidate_placements(op_schema)
mesh = try_find_mesh_from_args(
op_schema.op,
op_schema.args_schema + tuple(op_schema.kwargs_schema.values()),
)
fake_mesh = self._get_fake_mesh(mesh.device_type)
single_dim_strategies = []
output_placements: list[Placement | tuple[Placement, ...]] = []
for input_placements in candidate_placements:
try:
output = self._propagate_through_decomp(
op_schema,
input_placements,
fake_mesh,
)
except NotImplementedError:
return None
except GuardOnDataDependentSymNode:
return None
except (RuntimeError, KeyError, IndexError):
# TODO(pianpwk): RuntimeError is raised when redistribution is detected; switch to a custom error type
# Runtime/KeyError/IndexError can also occur in view ops
continue
output_placements = (
[output] if not isinstance(output, tuple) else list(output)
)
single_dim_strategies.append(output_placements + list(input_placements))
if not single_dim_strategies:
raise AssertionError(
"Sharding propagation should have produced at least Replicate() strategy"
)
n_outputs = len(output_placements)
strategy_schema = self.sharding_prop._wrap_with_op_strategy(op_schema)
# Import here to avoid circular import at module load time
from torch.distributed.tensor._ops.utils import ( # noqa: F811
expand_to_full_mesh_op_strategy,
)
return expand_to_full_mesh_op_strategy(
mesh, strategy_schema, single_dim_strategies, input_index=n_outputs
)
def _propagate_through_decomp(
self,
op_schema: OpSchema,
placement: tuple[Placement | None],
mesh: DeviceMesh,
) -> Placement | tuple[Placement, ...]:
op = op_schema.op
if op in decomposition_table:
decomp_fn = decomposition_table[op]
elif op._can_decompose():
decomp_fn = op.decompose
else:
raise NotImplementedError(f"No decomposition found for {op}")
placement_iter = iter(placement)
def to_meta(x):
p = next(placement_iter)
if isinstance(x, DTensorSpec):
# pyrefly: ignore [missing-attribute]
meta = torch.empty(x.shape, dtype=x.tensor_meta.dtype, device="meta")
# pyrefly: ignore [missing-attribute]
meta._spec = DTensorSpec(mesh, (p,), tensor_meta=x.tensor_meta)
return meta
return x
# Disable LocalTensorMode during decomposition tracing to prevent
# interference with meta tensor operations
from torch.distributed._local_tensor import maybe_disable_local_tensor_mode
with maybe_disable_local_tensor_mode():
# Create meta tensors and run decomposition outside LocalTensorMode
args_meta = tree_map(to_meta, op_schema.args_schema)
kwargs_meta = tree_map(to_meta, op_schema.kwargs_schema)
with PlacementTrackingMode(self.sharding_prop, mesh):
output = decomp_fn(*args_meta, **kwargs_meta)
def get_placement(t):
if isinstance(t, torch.Tensor):
spec = getattr(t, "_spec", None)
return spec.placements[0] if spec else None
return None
result = tree_map(get_placement, output)
if isinstance(result, (tuple, list)):
flat = [p for p in result if p is not None]
return flat[0] if len(flat) == 1 else tuple(flat)
return result
@staticmethod
def _get_candidate_placements(
op_schema: OpSchema,
) -> list[tuple[Placement | None]]:
tensor_specs = _extract_input_specs(op_schema)
flat_specs, _ = tree_flatten(list(tensor_specs))
# Step 1: Collect unique placements across all DTensorSpec inputs
all_placements: set[Placement] = {Replicate()}
tree_map_only(
DTensorSpec,
lambda spec: all_placements.update(spec.placements),
flat_specs,
)
# Step 2: For each input, use the placement set, but expand Shard/StridedShard to all tensor dims
candidates: list[list[Placement | None]] = []
for spec in flat_specs:
if not isinstance(spec, DTensorSpec):
candidates.append([None])
else:
options = set(all_placements)
for p in all_placements:
if isinstance(p, _StridedShard):
options |= {
_StridedShard(i, split_factor=p.split_factor)
for i in range(spec.ndim)
}
elif isinstance(p, Shard):
options |= {Shard(i) for i in range(spec.ndim)}
candidates.append(list(options))
# pyrefly: ignore [bad-argument-type, no-matching-overload]
return list(itertools.product(*candidates))
@@ -0,0 +1,802 @@
# Copyright (c) Meta Platforms, Inc. and affiliates
import contextlib
import logging
import warnings
from collections.abc import Sequence
from typing import cast
import torch
import torch.distributed as dist
import torch.distributed.tensor._api as dtensor
import torch.distributed.tensor._random as random
from torch._library.utils import fill_defaults
from torch._logging import LazyString
from torch._prims.rng_prims import run_dtensor_rng_op
from torch.distributed._functional_collectives import _are_we_tracing
from torch.distributed.device_mesh import DeviceMesh
from torch.distributed.tensor._dtensor_spec import DTensorSpec, TensorMeta
from torch.distributed.tensor._nonlinear_redux import argminmax_handler
from torch.distributed.tensor._op_schema import (
OpInfo,
OpSchema,
OutputSharding,
OutputSpecType,
)
from torch.distributed.tensor._random import is_rng_supported_mesh
from torch.distributed.tensor._redistribute import redistribute_local_tensor
from torch.distributed.tensor._sharding_prop import ShardingPropagator
from torch.distributed.tensor._tp_conv import (
convolution_backward_handler,
convolution_handler,
)
from torch.distributed.tensor._utils import (
_format_implicit_redistribution_msg,
ExplicitRedistributionContext,
try_find_mesh_from_args,
)
from torch.distributed.tensor.placement_types import Partial, Placement, Replicate
from torch.utils._debug_mode import get_active_debug_mode
from torch.utils._python_dispatch import return_and_correct_aliasing
try:
from torch.utils import _cxx_pytree as pytree
except ImportError:
from torch.utils import _pytree as pytree # type: ignore[no-redef]
aten = torch.ops.aten
logger = logging.getLogger(__name__)
# The C++ DTensor dispatch fast path caches whether debug logging is
# enabled. Wrap setLevel so the cached flag is reset automatically.
_orig_setLevel = logger.setLevel
def _setLevel_and_reinit(level: int) -> None:
_orig_setLevel(level)
torch._C._reinit_DTensor_dispatch_logger()
logger.setLevel = _setLevel_and_reinit # type: ignore[method-assign]
def as_strided_handler(
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
):
args, kwargs = fill_defaults(op_call._schema, args, kwargs)
if kwargs:
raise AssertionError
tensor, size, stride, storage_offset = args
if (
tensor.size() == tuple(size)
and tensor.stride() == tuple(stride)
and (storage_offset is None or tensor.storage_offset() == storage_offset)
):
return torch.ops.aten.alias.default(tensor)
raise RuntimeError("as_strided not supported with DTensor")
def is_same_size_handler(
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
) -> bool:
lhs = cast(torch.Tensor, args[0])
rhs = cast(torch.Tensor, args[1])
return lhs.shape == rhs.shape
def is_pinned_handler(
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
) -> bool:
tensor = cast(dtensor.DTensor, args[0])
return tensor._local_tensor.is_pinned()
def found_inf_reduce_handler(
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
) -> None:
op_info = dtensor.DTensor._op_dispatcher.unwrap_to_op_info(op_call, args, kwargs)
local_tensor_args = pytree.tree_unflatten(
cast(list[object], op_info.local_args),
op_info.args_tree_spec, # type: ignore[arg-type]
)
local_tensor_args = cast(tuple[object, ...], local_tensor_args)
op_call(*local_tensor_args, **op_info.local_kwargs)
grad_dtensor = cast(list[dtensor.DTensor], args[0])[0]
grad_placements = grad_dtensor.placements
mesh = grad_dtensor.device_mesh
found_inf_placements: list[Placement] = []
for placement in grad_placements:
if isinstance(placement, Replicate):
found_inf_placements.append(placement)
else:
found_inf_placements.append(Partial("max"))
target_tensor = cast(torch.Tensor, args[1])
spec = DTensorSpec(
mesh=mesh,
placements=tuple(found_inf_placements),
tensor_meta=TensorMeta(
shape=target_tensor.size(),
stride=target_tensor.stride(),
dtype=target_tensor.dtype,
),
)
# pyrefly: ignore [bad-argument-type]
found_inf_dtensor = dtensor.DTensor(
local_tensor=target_tensor, # pyrefly: ignore [unexpected-keyword]
spec=spec, # pyrefly: ignore [unexpected-keyword]
requires_grad=False, # pyrefly: ignore [unexpected-keyword]
)
found_inf = found_inf_dtensor.full_tensor()
target_tensor.copy_(found_inf)
class OpDispatcher:
"""
Op dispatching class instance to handle args/kwargs pre-processing (un-wrapping), sharding
propagation, redistribute local args, local compute, and post-processing (re-wrapping). It
also handles any op specific logic if necessary.
NOTE: Given the runtime overhead of Tensor subclass (__torch_dispatch__), the OpDispatcher
is designed to minimize the CPU overhead by using the tricks of proper unflattening, faster
pytree if needed, and leveraging various caching mechanisms implemented in the sharding
propagation and redistribute modules. The CPU overhead is critical to eager mode performance,
one need to carefully measure the CPU overhead when making significant changes to the
OpDispatcher and ShardingPropagator.
"""
def __init__(self) -> None:
self.sharding_propagator = ShardingPropagator()
# NOTE: must stay in sync with is_random_op in
# torch/csrc/autograd/python_variable.cpp
self._random_ops = {
aten.native_dropout.default,
aten.normal_.default,
aten.rand.default,
aten.rand_like.default,
aten.randn.default,
aten.randn_like.default,
aten.randint_like.default,
aten.randint_like.low_dtype,
aten.randint_like.low_dtype_out,
aten.uniform_.default,
aten.bernoulli.default,
aten.bernoulli_.float,
}
self._squeeze_inplace_ops = {
aten.squeeze_.dim,
aten.squeeze_.default,
aten.squeeze_.dims,
}
self._custom_op_handlers = {
aten.is_same_size.default: is_same_size_handler,
aten.is_pinned.default: is_pinned_handler,
aten.convolution.default: convolution_handler,
aten.convolution_backward.default: convolution_backward_handler,
aten._amp_foreach_non_finite_check_and_unscale_.default: found_inf_reduce_handler,
aten.as_strided.default: as_strided_handler,
aten.argmin.default: argminmax_handler,
aten.argmax.default: argminmax_handler,
}
# ********************************************************************************************
# def dispatch(...)
#
# NOTE: this class no longer contains the top-level dispatch entrypoint!
# See #167051 for details
#
# The entrypoint has been moved to C++, and it handles common cases and then calls back into
# OpDispatcher python to handle corner cases.
# See dispatchDTensorOp() defined in python_variable.cpp and called from python_arg_parser.cpp
# ********************************************************************************************
# This flag is used internally to control whether we treat the torch.Tensor(non-DTensor)
# as implicitly replicated or we throw error to user.
# NOTE: It is EXTREMELY UNSAFE to turn this flag on by default so we intentionally leave
# it as False by default.
@property
def _allow_implicit_replication(self) -> bool:
return torch._C._get_dtensor_allow_implicit_replication()
@_allow_implicit_replication.setter
def _allow_implicit_replication(self, value: bool) -> None:
return torch._C._set_dtensor_allow_implicit_replication(value)
def _propagate_op_sharding_dispatch_slow_path(
self,
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
op_info: OpInfo,
# The logic here is a bit messy. There are several reasons why the
# C++ fastpath may have bailed out. If we just cache missed, we will
# come here because we need to actually calculate the real thing.
# There's no need to have a SECOND Python cache lookup; the C++ native
# cache completely subsumes it. But sometimes, we will have failed
# to compute the cache key in C++ entirely. In this case, we DO need
# to do a cache lookup in Python, as the missing cache key in C++
# means we don't have access to it all. Furthermore, without duping
# this function, we need to do the try_cache test inside of the
# try-except block so that either case hits the inference mode /
# exception rewrapping case.
#
# This should be cleaned up. First, ensuring the C++ codepath can
# always compute a key will be a big help. Second, we should properly
# fastpath inference mode composite implicit autograd so that you
# don't have to throw an exception even in "fastpath".
try_cache: bool,
) -> object:
# NOTE: schema should always be populated when calling this function,
# as it's only called from C++ after unwrap_to_op_info (create_schema=True).
# See dispatchDTensorOp in python_variable.cpp line 1453-1460.
if op_info.schema is None:
raise AssertionError(
"op_info.schema should not be None in sharding propagation. "
"This function should only be called after unwrap_to_op_info."
)
try:
# We have basically inlined propagate() here, but WITHOUT the
# output_sharding assignment
if try_cache and not _are_we_tracing():
result = self.sharding_propagator.propagate_op_sharding(op_info.schema)
else:
result = self.sharding_propagator.propagate_op_sharding_non_cached(
op_info.schema
)
if logger.handlers and logger.isEnabledFor(logging.DEBUG):
logger.debug(
"sharding_prop MISS (C++ fast path): %s -> %s",
op_info.schema,
# pyrefly: ignore [missing-attribute]
result.output_spec,
)
return result
except NotImplementedError:
if torch._C._dispatch_has_kernel_for_dispatch_key(
op_call.name(), torch._C.DispatchKey.CompositeImplicitAutograd
):
# When running under inference mode, CompositeImplicitAutograd ops show up in __torch_dispatch__,
# so we manually decompose them, here
out = op_call.decompose(*args, **kwargs)
if out is NotImplemented:
raise AssertionError from None
return out
else:
raise
except Exception as e:
raise RuntimeError(
f"{e}\n\nSharding propagation failed for {op_info.schema or op_call}"
) from e
def _dispatch_get_local_results_slow_path(
self,
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
op_info: OpInfo,
) -> object:
output_sharding = op_info.output_sharding
if output_sharding is None:
raise AssertionError("output sharding should not be None")
if op_info is None:
raise AssertionError("op_info should never be None")
# Record output placements for debugging
debug_mode = get_active_debug_mode()
if debug_mode is not None and output_sharding.output_spec is not None:
debug_mode.record_output_placements(output_sharding.output_spec)
mesh = op_info.compute_mesh
participating = mesh._is_current_rank_part_of_mesh()
local_results = None
if participating:
# computation that happens in the current rank of the mesh, normal case
if output_sharding.needs_redistribute:
# If sharding propagation decision needs redistribute, perform redistribute
# on args first, which could potentially modify args (i.e. allgather certain arg)
if output_sharding.redistribute_schema is None:
raise AssertionError
self.redistribute_local_args(
op_info,
output_sharding.redistribute_schema,
output_sharding.use_val_from_redistribute_schema,
)
local_tensor_args = (
pytree.tree_unflatten(
cast(list[object], op_info.local_args),
# pyrefly: ignore [bad-argument-type]
op_info.args_tree_spec,
)
if op_info.args_tree_spec
else op_info.local_args
)
# run local op computation with potentially modified args/kwargs
local_tensor_args = cast(tuple[object, ...], local_tensor_args)
if op_call in self._random_ops:
if not random._rng_tracker and is_rng_supported_mesh(mesh):
# Default to `OffsetBasedRNGTracker` if the parallelism API did not already construct one
# Skip RNG state sync during tracing to avoid lazily initializing real RNG state under fake mode.
run_state_sync = not _are_we_tracing()
if not run_state_sync:
logger.info(
"DTensor RNG tracker is being lazily initialized during tracing. "
"RNG states may not be synchronized across ranks, which can lead "
"to silent incorrectness. Please call `torch.manual_seed()` with "
"the same seed on all ranks before compiling DTensor random ops.",
stacklevel=2,
)
random._rng_tracker = random.OffsetBasedRNGTracker(
mesh, run_state_sync
)
first_arg, first_local_arg = (
cast(dtensor.DTensor, args[0]),
cast(torch.Tensor, local_tensor_args[0]),
)
# If the user provided a generator, we hook it up to our RNG manager, but we also pop it from kwargs
# so the op_call does not directly use it (we want op_call to fall back to the 'default' which is
# our RNG manager)
maybe_user_generator = op_info.local_kwargs.pop("generator", None)
if not (
maybe_user_generator is None
or isinstance(maybe_user_generator, torch.Generator)
):
raise AssertionError
if (
random._rng_tracker
and not first_local_arg.is_meta
and random._rng_tracker.distribute_region_enabled
):
if (
maybe_user_generator is not None
or first_local_arg.device.type != "cuda"
or (
not _are_we_tracing()
and type(first_local_arg) is not torch.Tensor
)
):
with random._rng_tracker._distribute_region(
first_arg._spec, generator=maybe_user_generator
):
local_results = op_call(
*local_tensor_args, **op_info.local_kwargs
)
else:
# CUDA device without user generator, use HOP for traceability
if not isinstance(
random._rng_tracker, random.OffsetBasedRNGTracker
):
raise AssertionError
start_offset_incr, end_offset_incr = (
random._rng_tracker._compute_rng_offsets(first_arg._spec)
)
local_results = run_dtensor_rng_op(
start_offset_incr,
end_offset_incr,
op_call,
*local_tensor_args,
**op_info.local_kwargs,
)
else:
# No rng_tracker, meta tensor, or distribute_region disabled
local_results = op_call(*local_tensor_args, **op_info.local_kwargs)
else:
# normal case, run local sharded op computation
if (
output_sharding.needs_redistribute
and output_sharding.redistribute_schema is not None
and output_sharding.redistribute_schema.op != op_call
):
# Op was rewritten (e.g., squeeze.default → squeeze.dims)
local_results = output_sharding.redistribute_schema.op(
*local_tensor_args, **op_info.local_kwargs
)
else:
local_results = op_call(*local_tensor_args, **op_info.local_kwargs)
else:
# For a non-participating device (happens on rank that does not belong to
# the device mesh), we do:
# 1. if the return type is scalar, set the local result to None.
# 2. if the return type is Tensor or List[Tensor], return empty
# tensor(s) with correct dtype.
spec = output_sharding.output_spec
ret_list = op_call._schema.returns
if spec is None:
# For a scalar return type, the non-participating device has None
# as its local result
local_results = None
else:
def default_tensor(spec: DTensorSpec) -> torch.Tensor:
if spec.tensor_meta is not None:
shape = spec.tensor_meta.shape
dtype = spec.tensor_meta.dtype
if len(shape) == 0:
# scalar tensor
return torch.zeros((), dtype=dtype)
else:
# non-scalar tensor
return torch.tensor([], dtype=dtype)
else:
raise RuntimeError(f"{spec} has no tensor metadata.")
if isinstance(spec, DTensorSpec):
# return a Tensor value
local_results = default_tensor(spec)
elif isinstance(spec, Sequence):
# return a List[Tensor] value
local_results = [
default_tensor(s) if s is not None else None for s in spec
]
if not isinstance(local_results, list):
raise AssertionError
if None in local_results:
ret_type = str(ret_list[0].type)
raise NotImplementedError(
f"return type {ret_type} in DTensor op is not supported"
)
return local_results
def _dispatch_fast_path_python_tail(
self,
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
compute_mesh: DeviceMesh,
output_sharding: OutputSharding,
local_results: object,
participating: bool,
is_inplace_op: bool,
is_out_variant_op: bool,
) -> object:
"""
Tail of main dispatching logic, called from C++ fast path.
"""
# Record output placements for debugging
debug_mode = get_active_debug_mode()
if debug_mode is not None and output_sharding.output_spec is not None:
debug_mode.record_output_placements(output_sharding.output_spec)
if output_sharding.output_spec is None:
if op_call == aten.equal.default:
# The output of the equal op is a bool, by converting it into a
# a single value tensor, we can use all-reduce with min reduce op
# to simulate logical and.
if not (local_results is None or isinstance(local_results, bool)):
raise AssertionError
r = torch.tensor(
int(local_results) if local_results is not None else 1,
device=compute_mesh.device_type,
)
dist.all_reduce(r, op=dist.ReduceOp.MIN)
local_results = bool(r.item())
if is_inplace_op:
# inplace op should return self instead of re-wrapping
if output_sharding.output_spec is not None:
output_spec = output_sharding.output_spec
if not isinstance(output_spec, DTensorSpec):
raise AssertionError
if not isinstance(args[0], dtensor.DTensor):
raise AssertionError
# NOTE: squeeze_ inplace ops may change the tensor's metadata
# (shape/strides). We special-case them to update the spec.
if op_call in self._squeeze_inplace_ops:
# update the spec to handle tensor meta changes
args[0]._spec = output_spec
# use return_and_correct_aliasing to match the outer and the inner
# aliasing. See https://github.com/pytorch/pytorch/pull/158954
return return_and_correct_aliasing(op_call, args, kwargs, args[0])
else:
# For all other inplace ops, check if placement changes are required
# Inplace operations that change placement are not supported because
# they would require redistribution, which breaks aliasing semantics.
# If there are views into the tensor, the views would not be updated.
if args[0]._spec.placements != output_spec.placements:
raise RuntimeError(
f"{op_call}: in-place operations that require placement changes "
f"are not supported. The operation would change placement from "
f"{args[0]._spec.placements} to {output_spec.placements}, "
f"which requires redistribution and breaks aliasing semantics. "
f"Please use the out-of-place version of this operation instead."
)
# Most inplace ops don't change tensor meta, so no spec update needed
return args[0]
else:
return None
elif is_out_variant_op:
# out variant could possibly have multiple out args (i.e. lu_unpack.out)
output_specs = (
(output_sharding.output_spec,)
if not isinstance(output_sharding.output_spec, tuple)
else output_sharding.output_spec
)
out_dts = []
spec_idx = 0
for argument in op_call._schema.arguments:
if argument.is_out:
out_dt = cast(dtensor.DTensor, kwargs[argument.name])
out_dt._spec = cast(DTensorSpec, output_specs[spec_idx])
out_dts.append(out_dt)
spec_idx += 1
if len(out_dts) < 1:
raise AssertionError("out variant should have at least one out arg")
return tuple(out_dts) if len(out_dts) > 1 else out_dts[0]
else:
if op_call != aten.equal.default:
raise AssertionError(op_call)
ret = self.wrap(local_results, output_sharding.output_spec) # type: ignore[possibly-undefined]
if participating and op_call._schema._is_view_op():
return return_and_correct_aliasing(op_call, args, kwargs, ret)
else:
return ret
@staticmethod
def redistribute_local_args(
op_info: OpInfo,
suggested_input_schema: OpSchema,
use_val_from_redistribute_schema: bool,
) -> None:
debug_mode = get_active_debug_mode()
# NOTE: it's very rare that we need to reshard kwargs so we intentionally skip it
if op_info.args_tree_spec is not None:
flatten_args_schema_to_reshard = tuple(
pytree.tree_leaves(suggested_input_schema.args_schema)
)
else:
flatten_args_schema_to_reshard = suggested_input_schema.args_schema
new_local_args: list[object] = []
for i, arg_spec in enumerate(op_info.flat_args_schema):
reshard_arg_spec = flatten_args_schema_to_reshard[i]
if isinstance(arg_spec, DTensorSpec):
local_tensor = cast(torch.Tensor, op_info.local_args[i])
if arg_spec != reshard_arg_spec:
redistribute_context = (
debug_mode.record_redistribute_calls( # type: ignore[union-attr]
i, arg_spec, reshard_arg_spec
)
if debug_mode is not None
else contextlib.nullcontext()
)
ExplicitRedistributionContext.observe_redistribution(
arg_spec,
# pyrefly: ignore [bad-argument-type]
reshard_arg_spec,
LazyString(
_format_implicit_redistribution_msg,
op_info.schema or suggested_input_schema.op,
),
)
with redistribute_context:
resharded_local_tensor = redistribute_local_tensor(
local_tensor,
arg_spec,
# pyrefly: ignore [bad-argument-type]
reshard_arg_spec,
)
new_local_args.append(resharded_local_tensor)
else:
new_local_args.append(local_tensor)
else:
if use_val_from_redistribute_schema:
# args can be updated for view related ops, we refer to the
# update in redistribute_schema.
new_local_args.append(reshard_arg_spec)
else:
new_local_args.append(arg_spec)
# Append extra non-tensor args from rewritten schema (e.g., dims tuple).
if use_val_from_redistribute_schema:
for i in range(
len(op_info.flat_args_schema), len(flatten_args_schema_to_reshard)
):
new_local_args.append(flatten_args_schema_to_reshard[i])
op_info.local_args = tuple(new_local_args)
def unwrap_to_op_info(
self,
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
) -> OpInfo:
return self._unwrap_to_op_info_impl(op_call, args, kwargs, True)
def _unwrap_to_op_info_impl(
self,
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
create_schema: bool,
) -> OpInfo:
# get runtime schema info to determine whether to use pytree to flatten inputs
runtime_schema_info = self.sharding_propagator.op_to_schema_info.get(
op_call, None
)
if runtime_schema_info is None:
runtime_schema_info = (
self.sharding_propagator.op_to_schema_info_for_single_dim_strategy.get(
op_call, None
)
)
# Auto-detect needs_pytree if any arg is a list/tuple containing tensors
def _contains_tensor(arg: object) -> bool:
if isinstance(arg, (list, tuple)):
return any(isinstance(item, torch.Tensor) for item in arg)
return False
needs_pytree = (
runtime_schema_info is not None and runtime_schema_info.needs_pytree
) or any(_contains_tensor(arg) for arg in args)
if needs_pytree:
# flatten args/kwargs when op says necessary or args contain lists/tuples
tree_args, args_spec = pytree.tree_flatten(args)
args_list: Sequence[object] = tree_args
else:
args_list, args_spec = args, None
args_schema: list[object] = []
kwargs_schema: dict[str, object] = {}
local_args: list[object] = []
local_kwargs: dict[str, object] = {}
compute_mesh: DeviceMesh | None = None
for arg in args_list:
if isinstance(arg, dtensor.DTensor):
local_args.append(arg._local_tensor)
args_schema.append(arg._spec)
if compute_mesh is None:
# record the first compute device mesh from args
compute_mesh = arg.device_mesh
elif isinstance(arg, torch.Tensor):
compute_mesh = compute_mesh or try_find_mesh_from_args(
op_call, args_list
)
args_schema.append(
self._try_replicate_spec_for_scalar_tensor(
op_call, arg, compute_mesh
)
)
local_args.append(arg)
else:
# non DTensor/Tensor args (i.e. int/float/bool), just add to args_schema/local_args
args_schema.append(arg)
local_args.append(arg)
for k, v in kwargs.items():
if isinstance(v, dtensor.DTensor):
local_kwargs[k] = v._local_tensor
kwargs_schema[k] = v._spec
if compute_mesh is None:
# record the first compute device mesh from kwargs
compute_mesh = v.device_mesh
elif isinstance(v, torch.Tensor):
compute_mesh = compute_mesh or try_find_mesh_from_args(
op_call, args_list
)
kwargs_schema[k] = self._try_replicate_spec_for_scalar_tensor(
op_call,
v,
compute_mesh,
)
local_kwargs[k] = v
else:
# non DTensor/Tensor args (i.e. int/float/bool), just add to args_schema/local_args
kwargs_schema[k] = v
local_kwargs[k] = v
if compute_mesh is None:
raise AssertionError(
f"found no DeviceMesh from dtensor args for {op_call}!"
)
op_info = OpInfo(
compute_mesh,
OpSchema(
op_call,
(
# pyrefly: ignore [bad-argument-type]
pytree.tree_unflatten(args_schema, args_spec)
if args_spec
else tuple(args_schema)
),
kwargs_schema,
schema_info=runtime_schema_info,
)
if create_schema
else None, # type: ignore[arg-type]
args_schema,
tuple(local_args),
local_kwargs,
args_spec,
)
return op_info
@staticmethod
def wrap(res: object, spec: OutputSpecType) -> object:
if isinstance(res, torch.Tensor):
if spec is not None:
if not isinstance(spec, DTensorSpec):
raise AssertionError(
f"output spec does not match with output! Expected DTensorSpec, got {spec}."
)
# pyrefly: ignore [bad-argument-type, bad-argument-count, unexpected-keyword]
return dtensor.DTensor(res, spec, requires_grad=res.requires_grad)
else:
# if output does not have a DTensorSpec due to specific ops, it must be a scalar tensor
if res.ndim != 0:
raise AssertionError("output tensor should be scalar!")
return res
elif isinstance(res, (list, tuple)):
if not (spec is not None and isinstance(spec, (list, tuple))):
raise AssertionError(
f"output spec does not match with output! Expected list/tuple, got {spec}."
)
res_list = []
for e, s in zip(res, spec):
# pyrefly: ignore [bad-argument-type]
res_list.append(OpDispatcher.wrap(e, s))
return tuple(res_list) if isinstance(res, tuple) else res_list
else:
# if the res contains only non tensor values (i.e. int/float/none), we simply return it
# without rewrapping to DTensor.
return res
def _try_replicate_spec_for_scalar_tensor(
self,
op_call: torch._ops.OpOverload,
tensor_arg: torch.Tensor,
compute_mesh: DeviceMesh,
) -> DTensorSpec:
# util function to produce a replicate spec for a scalar tensor arg/kwarg
if tensor_arg.numel() == 1 and tensor_arg.ndim == 1:
warnings.warn(
"Found a non-scalar tensor with numel=1 and ndim!=0, "
"we are implicitly creating a replicated DTensor for it. "
"However, please consider changing it to a scalar tensor "
"or explicitly create a DTensor under distributed environment.",
stacklevel=2,
)
if tensor_arg.numel() == 1 or self._allow_implicit_replication:
# scalar tensor can be safely treated as replicated
replication_spec = DTensorSpec(
compute_mesh,
(Replicate(),) * compute_mesh.ndim,
tensor_meta=TensorMeta(
shape=tensor_arg.shape,
stride=tensor_arg.stride(),
dtype=tensor_arg.dtype,
),
)
else:
raise RuntimeError(
f"{op_call}: got mixed torch.Tensor and DTensor, need to convert all"
" torch.Tensor to DTensor before calling distributed operators!"
" Please see https://docs.pytorch.org/docs/main/distributed.tensor.html#mixed-tensor-and-dtensor-operations"
" for more details."
)
return replication_spec
@@ -0,0 +1,763 @@
import hashlib
import itertools
import math
from collections import defaultdict
from dataclasses import dataclass
from typing import Any, cast, NamedTuple
import torch
from torch.distributed.device_mesh import DeviceMesh
from torch.distributed.tensor.placement_types import (
_is_shard_like,
_MaskPartial,
_StridedShard,
Partial,
Placement,
Replicate,
Shard,
)
from torch.utils._debug_mode import _stringify_shape
from torch.utils._dtype_abbrs import dtype_abbrs
# Defined here (not in placement_types.py) because decoding split_factor into
# a shard order is a DTensorSpec concern — placement_types doesn't know about
# shard orders.
class _StridedShardNotDecodableError(ValueError):
"""Raised when _StridedShard split_factor cannot be decoded into a shard order."""
class ShardOrderEntry(NamedTuple):
"""
Represents how a single tensor dimension is sharded across mesh dimensions.
Attributes:
tensor_dim: The tensor dimension being sharded (e.g., 0, 1, 2 for a 3D tensor).
mesh_dims: Tuple of mesh dimensions across which this tensor dimension is sharded,
in execution order. The first mesh dim is applied first, second is applied
second, etc. This tuple is guaranteed to be non-empty.
Examples:
>>> # xdoctest: +REQUIRES(env:TORCH_DOCTEST_DISTRIBUTED)
>>> # Tensor dim 1 sharded across mesh dim 2, then mesh dim 0
>>> ShardOrderEntry(tensor_dim=1, mesh_dims=(2, 0))
>>> # Tensor dim 0 sharded only on mesh dim 1
>>> ShardOrderEntry(tensor_dim=0, mesh_dims=(1,))
"""
tensor_dim: int
mesh_dims: tuple[int, ...] # guaranteed to be non-empty
# Type alias for the complete shard order specification
# A tuple of ShardOrderEntry, one per sharded tensor dimension
#
# Example:
# shard_order = (
# ShardOrderEntry(tensor_dim=0, mesh_dims=(1,)),
# ShardOrderEntry(tensor_dim=2, mesh_dims=(0, 3)),
# )
# This means:
# - Tensor dimension 0 is sharded on mesh dimension 1
# - Tensor dimension 2 is sharded on mesh dimension 0 first, then mesh dimension 3
ShardOrder = tuple[ShardOrderEntry, ...]
class TensorMeta(NamedTuple):
# simple named tuple to represent tensor metadata
# intentionally to stay simple only for sharding
# propagation purposes.
shape: torch.Size
stride: tuple[int, ...]
dtype: torch.dtype
# used internally to propagate the placements
@dataclass
class DTensorSpec:
mesh: DeviceMesh
placements: tuple[Placement, ...]
# tensor meta will only be set during sharding propagation
tensor_meta: TensorMeta | None = None
# When a tensor dimension is sharded across multiple mesh axes,
# `shard_order` specifies the sequence in which these shardings are applied.
# This order determines how tensor shards are mapped and distributed across
# devices.
#
# Example:
# For a tensor of shape [8, 16] and a 3D device mesh, if dim 0 is sharded over
# mesh dim 1, and dim 1 is sharded over mesh dim 0 and then mesh dim 2,
# the shard_order would be:
# shard_order = (
# ShardOrderEntry(tensor_dim=0, mesh_dims=(1,)),
# ShardOrderEntry(tensor_dim=1, mesh_dims=(0, 2)),
# )
shard_order: ShardOrder = None # type: ignore[assignment]
# When True, _StridedShard placements encode the shard order, and the
# shard_order field must be left as None (it will be derived on demand).
# Set explicitly to False to treat _StridedShard as a regular Shard,
# e.g., in view propagation.
use_strided_shard_as_shard_order: bool | None = None
def __post_init__(self) -> None:
if not isinstance(self.placements, tuple):
self.placements = tuple(self.placements)
if self.use_strided_shard_as_shard_order is None:
if any(isinstance(p, _StridedShard) for p in self.placements):
self.use_strided_shard_as_shard_order = True
else:
self.use_strided_shard_as_shard_order = False
if self.use_strided_shard_as_shard_order:
if self.shard_order is not None:
raise ValueError(
"DTensorSpec doesn't allow specify shard_order when "
"use_strided_shard_as_shard_order is True. This may result "
"in conflicting shard order."
)
else:
if self.shard_order is None:
self.shard_order = self.compute_default_shard_order(self.placements)
self._hash: int | None = None
@staticmethod
def _normalize_placements_into_shard_order(
placements: tuple[Placement, ...],
mesh: DeviceMesh,
use_strided_shard_as_shard_order: bool = True,
) -> tuple[tuple[Placement, ...], ShardOrder]:
# If use_strided_shard_as_shard_order, it means the StridedShard/Shard
# combinations should be interpreted as shard order.
if use_strided_shard_as_shard_order:
# _StridedShard in placements, try check if it can be decoded as shard order
shard_order = DTensorSpec._maybe_convert_StridedShard_to_shard_order(
placements, mesh
)
if shard_order is None:
raise _StridedShardNotDecodableError(
f"_StridedShard placements {placements} cannot be decoded "
"into a corresponding shard_order"
)
normalized_placements = tuple(
[
p if not isinstance(p, _StridedShard) else Shard(p.dim)
for p in placements
]
)
return normalized_placements, shard_order
else:
return placements, DTensorSpec.compute_default_shard_order(placements)
@staticmethod
def compute_default_shard_order(
placements: tuple[Placement, ...],
) -> ShardOrder:
"""
Compute the default shard order from placements.
Returns a ShardOrder where each ShardOrderEntry maps a tensor dimension
to the mesh dimensions it's sharded on, in left-to-right order.
Args:
placements: Tuple of Placement objects representing how a tensor is
distributed across mesh dimensions.
"""
# follow default left-to-right device order if shard_order is not specified
tensor_dim_to_mesh_dims: defaultdict[int, list[int]] = defaultdict(list)
mesh_ndim = len(placements)
for mesh_dim in range(mesh_ndim):
if _is_shard_like(placements[mesh_dim]):
placement = placements[mesh_dim]
shard_dim = placement.dim # pyrefly: ignore [missing-attribute]
if shard_dim < 0:
raise AssertionError(
f"Shard dim {shard_dim} in placements {placements} must be normalized"
)
tensor_dim_to_mesh_dims[shard_dim].append(mesh_dim)
# Convert dict into ShardOrderEntry tuples
default_shard_order = tuple(
ShardOrderEntry(tensor_dim=key, mesh_dims=tuple(value))
for key, value in sorted(tensor_dim_to_mesh_dims.items())
if value
)
return default_shard_order
@staticmethod
def _convert_shard_order_to_StridedShard(
shard_order: ShardOrder, placements: tuple[Placement, ...], mesh: DeviceMesh
) -> tuple[Placement, ...]:
"""
Convert ShardOrder to placements with _StridedShard.
This function converts a ShardOrder specification into a tuple of Placement objects,
using _StridedShard when a tensor dimension is sharded across multiple mesh dimensions
in a non-default order. The split_factor of each _StridedShard is determined by the
product of mesh dimension sizes that appear earlier in the shard order but later in
the placement tuple.
Args:
shard_order: ShardOrder specification indicating which tensor dimensions are
sharded on which mesh dimensions and in what execution order.
placements: Tuple of Placement objects that does not contain _StridedShard.
mesh: DeviceMesh containing the size information for each mesh dimension.
Returns:
Updated tuple of Placement objects with Shard or _StridedShard placements.
Algorithm:
For each ShardOrderEntry in shard_order:
- For each mesh dimension in the entry's mesh_dims (in order):
- Calculate split_factor as the product of mesh sizes for all mesh dimensions
that appear:
1. Earlier in the shard order (lower index in mesh_dims), and
2. Later in the placement tuple (higher mesh dimension index)
- If split_factor == 1: use normal Shard
- Otherwise: use _StridedShard with the calculated split_factor
Example:
>>> # xdoctest: +SKIP("Requires DeviceMesh")
>>> # Tensor dimension 0 sharded on mesh dims [2, 0, 1] in that order
>>> # mesh = DeviceMesh([4, 3, 2]) # sizes: mesh[0]=4, mesh[1]=3, mesh[2]=2
>>> shard_order = (ShardOrderEntry(tensor_dim=0, mesh_dims=(2, 0, 1)),)
>>> placements = (Shard(0), Shard(0), Shard(0))
>>> # For mesh_dim=2 (index 0 in mesh_dims): no earlier dims, split_factor=1
>>> # -> placements[2] = Shard(0)
>>> # For mesh_dim=0 (index 1 in mesh_dims): mesh_dim=2 is earlier and has index 2>0
>>> # -> split_factor = mesh.size(2) = 2
>>> # -> placements[0] = _StridedShard(0, split_factor=2)
>>> # For mesh_dim=1 (index 2 in mesh_dims): mesh_dim=2 is earlier and has index 2>1
>>> # -> split_factor = mesh.size(2) = 2
>>> # -> placements[1] = _StridedShard(0, split_factor=2)
>>> # Result: (_StridedShard(0, sf=2), _StridedShard(0, sf=2), Shard(0))
"""
placements_list = list(placements)
for entry in shard_order:
tensor_dim = entry.tensor_dim
mesh_dims = entry.mesh_dims
for idx in range(len(mesh_dims)):
# TODO(zpcore): split_factor from `view` and `shard order`
# should be able to be multiplied into one. Need to loosen the
# condition here.
mesh_dim = mesh_dims[idx]
if type(placements[mesh_dim]) is not Shard:
raise ValueError(
f"Only Shard placement can be converted to _StridedShard, "
f"found {placements[mesh_dim]} in {placements=}."
)
split_factor = math.prod(
mesh.size(i) for i in mesh_dims[:idx] if i > mesh_dim
)
if split_factor == 1:
# use normal Shard
placements_list[mesh_dim] = Shard(tensor_dim)
else:
placements_list[mesh_dim] = _StridedShard(
tensor_dim, split_factor=split_factor
)
return tuple(placements_list)
@staticmethod
def _maybe_convert_StridedShard_to_shard_order(
placements: tuple[Placement, ...], mesh: DeviceMesh
) -> ShardOrder | None:
"""
Try to convert _StridedShard placements to ShardOrder.
This is the inverse of `_convert_shard_order_to_StridedShard`. It reconstructs the shard
order by examining the split_factor of each _StridedShard and determining its position
in the execution order. If the _StridedShard configuration cannot be represented as a
valid ShardOrder (i.e., there's no shard order that produces the observed split_factors),
this function returns None.
Args:
placements: Tuple of Placement objects that may contain _StridedShard.
mesh: DeviceMesh containing the size information for each mesh dimension.
Returns:
ShardOrder if conversion is possible, None otherwise. For placements without
_StridedShard, returns the default shard order.
Algorithm:
1. If no _StridedShard in placements, return default shard order
2. Create an empty list for each tensor dimension to represent mesh dim ordering
3. Iterate through placements in reverse order (right to left):
- For each Shard/_StridedShard on a tensor dimension:
- Extract its split_factor (1 for Shard, split_factor for _StridedShard)
- Find the position in mesh_dims_order where accumulated_sf equals split_factor
- accumulated_sf is the product of mesh sizes of mesh dimensions that appear
earlier in mesh_dims_order (lower indices)
- Insert mesh_dim at the found position
4. If no valid position found for any split_factor, return None (unable to convert)
5. Construct ShardOrderEntry for each tensor dimension from mesh_dims_order
Example:
>>> # xdoctest: +SKIP("Requires DeviceMesh")
>>> # mesh = DeviceMesh([4, 3, 2]) # sizes: mesh[0]=4, mesh[1]=3, mesh[2]=2
>>> # placements = (_StridedShard(0, sf=2), _StridedShard(0, sf=2), Shard(0))
>>> # Process tensor_dim=0 from right to left:
>>> # - mesh_dim=2: Shard(0) with sf=1
>>> # Try position 0: accumulated_sf=1, matches! Insert at position 0
>>> # Current mesh_dims_order order: [2]
>>> # - mesh_dim=1: _StridedShard(0, sf=2) with sf=2
>>> # Try position 0: accumulated_sf=1, no match
>>> # Try position 1: accumulated_sf=1*mesh.size(2)=2, matches! Insert at position 1
>>> # Current mesh_dims_order order: [2, 1]
>>> # - mesh_dim=0: _StridedShard(0, sf=2) with sf=2
>>> # Try position 0: accumulated_sf=1, no match
>>> # Try position 1: accumulated_sf=1*mesh.size(2)=2, matches! Insert at position 1
>>> # Final mesh_dims_order order: [2, 0, 1]
>>> # Result: ShardOrder((ShardOrderEntry(tensor_dim=0, mesh_dims=(2, 0, 1)),))
>>> # This means: first shard on mesh_dim=2, then mesh_dim=0, then mesh_dim=1
Note:
This function validates that _StridedShard can be represented as a ShardOrder.
Not all _StridedShard configurations are valid - the split_factor must match
the product of mesh sizes in some execution order.
"""
if not any(isinstance(p, _StridedShard) for p in placements):
return DTensorSpec.compute_default_shard_order(placements)
max_tensor_dim = max([i.dim for i in placements if _is_shard_like(i)]) + 1
shard_order = []
tensor_dim_to_mesh_dims_order: list[list[int]] = [
[] for i in range(max_tensor_dim)
]
for mesh_dim in reversed(range(len(placements))):
cur_placement = placements[mesh_dim]
if _is_shard_like(cur_placement):
tensor_dim = cur_placement.dim
mesh_dims_order = tensor_dim_to_mesh_dims_order[tensor_dim]
cur_sf = 1
if isinstance(cur_placement, _StridedShard):
cur_sf = cur_placement.split_factor
accumulated_sf = 1
find_order = False
for i in range(len(mesh_dims_order) + 1):
if accumulated_sf == cur_sf:
mesh_dims_order.insert(i, mesh_dim)
find_order = True
break
if i < len(mesh_dims_order):
accumulated_sf *= mesh.size(mesh_dims_order[i])
if not find_order:
# _StridedShard is not convertible to ShardOrder
return None
else:
if not isinstance(cur_placement, Replicate | Partial | _MaskPartial):
raise ValueError(
f"Unsupported placement type {type(cur_placement)} encountered in "
f"{placements}; expected Replicate, Partial, or _MaskPartial."
)
for tensor_dim in range(max_tensor_dim):
if len(tensor_dim_to_mesh_dims_order[tensor_dim]) > 0:
shard_order.append(
ShardOrderEntry(
tensor_dim=tensor_dim,
mesh_dims=tuple(tensor_dim_to_mesh_dims_order[tensor_dim]),
)
)
return tuple(shard_order)
def _verify_shard_order(self, shard_order: ShardOrder) -> None:
"""Verify that the shard_order is valid and matches the placements."""
total_shard = 0
if any(isinstance(p, _StridedShard) for p in self.placements):
# _StridedShard shard_order validation not yet supported;
# the Shard-only checks below (line 390, 394) would fail.
return
prev_tensor_dim = -1
for entry in shard_order:
tensor_dim = entry.tensor_dim
mesh_dims = entry.mesh_dims
if len(mesh_dims) <= 0:
raise AssertionError(f"shard_order {shard_order} has empty mesh dim")
if tensor_dim < 0:
raise AssertionError(
f"shard_order {shard_order} has invalid tensor dim {tensor_dim}"
)
if tensor_dim <= prev_tensor_dim:
raise AssertionError("tensor dim should be sorted in shard_order")
prev_tensor_dim = tensor_dim
total_shard += len(mesh_dims)
for mesh_dim in mesh_dims:
if not (0 <= mesh_dim < len(self.placements)):
raise AssertionError(
f"shard_order {shard_order} has invalid mesh dim {mesh_dims}"
)
if self.placements[mesh_dim] != Shard(tensor_dim):
raise AssertionError(
f"placement[{mesh_dim}] doesn't have a matching shard in shard_order"
)
if total_shard != sum(1 for p in self.placements if isinstance(p, Shard)):
raise AssertionError
def __setattr__(self, attr: str, value: Any) -> None:
if attr == "shard_order" and value is not None:
self._verify_shard_order(value)
super().__setattr__(attr, value)
# Make sure to recompute the hash in case any of the hashed attributes
# change (though we do not expect `mesh`, `placements` or `shard_order`
# to change)
if hasattr(self, "_hash") and attr in (
"mesh",
"placements",
"tensor_meta",
"shard_order",
):
self._hash = None
# This assert was triggered by buggy handling for dict outputs in some
# FX passes, where you accidentally iterate over a dict and try to put
# keys into TensorMeta. See https://github.com/pytorch/pytorch/issues/157919
if attr == "tensor_meta" and value is not None:
from torch.fx.passes.shape_prop import TensorMetadata
# TODO: the TensorMetadata arises from
# test/distributed/tensor/experimental/test_tp_transform.py::TensorParallelTest::test_tp_transform_e2e
# but I actually can't reproduce it, maybe it is also a bug!
if not isinstance(value, TensorMeta | TensorMetadata):
raise AssertionError(repr(value))
def _hash_key(self) -> tuple[Any, ...]:
"""Return the tuple used for hashing. Used by both __hash__ and _stable_hash."""
if self.tensor_meta is not None:
return (
self.mesh,
self.placements,
self.shard_order,
self.tensor_meta.shape,
self.tensor_meta.stride,
self.tensor_meta.dtype,
)
return (self.mesh, self.placements, self.shard_order)
def _hash_impl(self) -> int:
# hashing and equality check for DTensorSpec are used to cache the sharding
# propagation results. We only need to consider the mesh, placements, shape
# dtype and stride.
# Caveat: we need to keep this in mind and sync hash and eq if we add more
# fields to them.
return hash(self._hash_key())
def __hash__(self) -> int:
# We lazily cache the spec to avoid recomputing the hash upon each
# use, where we make sure to update the hash when the `tensor_meta`
# changes by overriding `__setattr__`. This must be lazy so that Dynamo
# does not try to hash non-singleton `SymInt`s for the stride.
if self._hash is None:
self._hash = self._hash_impl()
return self._hash
def _stable_hash(self) -> str:
"""
Return a stable hash for AOT autograd caching.
[See note: Tensor subclass stable hashing for AOT autograd cache]
"""
# Get hash key, but replace mesh with its stable hash
key = self._hash_key()
# First element is mesh, replace with its stable hash
stable_key = (self.mesh._stable_hash(),) + key[1:]
return hashlib.blake2b(repr(stable_key).encode(), digest_size=16).hexdigest()
def _check_equals(self, other: object, skip_shapes: bool = False) -> bool:
if not (
isinstance(other, DTensorSpec)
and self.mesh == other.mesh
and self.placements == other.placements
and self.shard_order == other.shard_order
):
return False
if self.tensor_meta is None or other.tensor_meta is None:
return self.tensor_meta == other.tensor_meta
if skip_shapes:
return self.tensor_meta.dtype == other.tensor_meta.dtype
return (
self.tensor_meta.shape == other.tensor_meta.shape # type: ignore[union-attr]
and self.tensor_meta.stride == other.tensor_meta.stride # type: ignore[union-attr]
and self.tensor_meta.dtype == other.tensor_meta.dtype # type: ignore[union-attr]
)
def __eq__(self, other: object, /) -> bool:
return self._check_equals(other)
def __str__(self) -> str:
"""
human readable representation of the DTensorSpec
"""
placement_str = self.format_shard_order_str(self.placements, self.shard_order)
if self.tensor_meta is not None:
tensor_shape = _stringify_shape(self.tensor_meta.shape)
tensor_dtype = dtype_abbrs[self.tensor_meta.dtype]
else:
tensor_shape = "unknown shape"
tensor_dtype = "unknown dtype"
return f"Spec({tensor_dtype}{tensor_shape}({placement_str}))"
@staticmethod
def is_default_device_order(shard_order: ShardOrder) -> bool:
"""
Check if the device order is the default left-to-right order.
"""
if shard_order is None:
# Missing `shard_order` attribute, possibly due to _StridedShard in
# placements.
return False
for entry in shard_order:
mesh_dims = entry.mesh_dims
is_increasing = all(
prev < nxt for prev, nxt in itertools.pairwise(mesh_dims)
)
if not is_increasing:
return False
return True
@staticmethod
def format_shard_order_str(
placements: tuple[Placement, ...],
shard_order: ShardOrder | None = None,
) -> str:
"""
Format DTensor sharding information as a human-readable string.
This method formats the sharding pattern in mesh-centric order, showing the placement
for each mesh dimension sequentially. When a tensor dimension is sharded across multiple
mesh dimensions, the order index indicates the execution sequence of the sharding operations.
Args:
placements: Tuple of placement objects for each mesh dimension.
shard_order: Optional ShardOrder specifying the sharding order.
Returns:
String representation of the sharding pattern in mesh-centric format.
Example:
For a 3D tensor on a 2x2x2x2 mesh (16 devices) with::
placements = [Partial(), Shard(1), Shard(1), Replicate()]
shard_order = (ShardOrderEntry(tensor_dim=1, mesh_dims=(2, 1)),)
Mesh configuration:
- mesh_dim_0: Partial reduction (sum)
- mesh_dim_1: Shard tensor dimension 1 (executed second, order index 1)
- mesh_dim_2: Shard tensor dimension 1 (executed first, order index 0)
- mesh_dim_3: Replicate
Output: ``"PS(1)[1]S(1)[0]R"``
Explanation:
- ``P``: mesh dimension 0 has partial reduction
- ``S(1)[1]``: mesh dimension 1 shards tensor dimension 1 (order index 1 means second)
- ``S(1)[0]``: mesh dimension 2 shards tensor dimension 1 (order index 0 means first)
- ``R``: mesh dimension 3 replicates
The format follows mesh dimension order (0, 1, 2, 3), and when a tensor dimension
is sharded across multiple mesh dimensions, the bracketed index shows the execution
order: ``[0]`` is executed first, ``[1]`` is executed second, etc.
"""
out_str = ""
# native dtensor-style sharding representation: map from mesh
# dim to tensor dim
for mesh_dim, placement in enumerate(placements):
if _is_shard_like(placement):
if shard_order is not None:
for entry in shard_order:
tensor_dim = entry.tensor_dim
mesh_dims = entry.mesh_dims
if placement.dim == tensor_dim:
if mesh_dim not in mesh_dims:
raise AssertionError
if len(mesh_dims) > 1:
out_str += f"{placement}[{mesh_dims.index(mesh_dim)}]"
else:
# no need to show device order if the tensor dim is
# only sharded in one mesh dim
out_str += str(placement)
break
else:
out_str += str(placement)
else:
out_str += str(placement)
return out_str
@property
def shape(self) -> torch.Size:
if self.tensor_meta is None:
raise ValueError("tensor_meta is not set")
return self.tensor_meta.shape
@property
def stride(self) -> tuple[int, ...]:
if self.tensor_meta is None:
raise ValueError("tensor_meta is not set")
return self.tensor_meta.stride
@property
def ndim(self) -> int:
if self.tensor_meta is None:
raise ValueError("tensor_meta is not set")
return len(self.tensor_meta.shape)
@property
def num_shards(self) -> int:
num_shards = 1
for i, placement in enumerate(self.placements):
if _is_shard_like(placement):
num_shards *= self.mesh.size(i)
return num_shards
@property
def device_mesh(self) -> DeviceMesh:
# simple aliasing for the mesh field, make some
# checks that mixes DTensor/DTensorSpec easier
return self.mesh
@property
def dim_map(self) -> list[int]:
"""
dim_map is a property we derive from `placements` of
the distributed tensor. It simply return a list of ints
where dim_map[i] denotes the sharding mapping to the mesh
dimension, and len(dim_map) == dist_tensor.ndim
dim_map[i] = -1: means tensor dim i replicate on mesh
dim_map[i] = j: means tensor dim i shard on mesh dim j
For example, we have a dist tensor that have the shape of
[18, 20, 30], and device_mesh([0, 1, 2, 3]), placements:
[Shard(1)], the dim_map of this placement would be:
[-1, 0, -1]. This representation is pretty helpful during
sharding propagation where we could know exactly each
tensor dimension is sharded or not.
Note that if placements contains `_Partial`, we have to
explicitly deal with it, so that when we create a DTensorSpec
with dim_map, we could properly record the pending sums.
"""
# dims mapping of dist tensor sharding
# return size of tensor ndim, -1 represent replicate
# and int >=0 represent shard on that device mesh dim
r = [-1] * self.ndim
for i, placement in enumerate(self.placements):
if _is_shard_like(placement):
shard_dim = placement.dim
if r[shard_dim] > -1:
raise ValueError(
f"Tensor dim {shard_dim} is already sharded on mesh dim {r[shard_dim]},"
" DTensor operator implementation does not support things like hybrid"
" sharding strategies yet (i.e. [Shard(0), Shard(0)])"
)
r[shard_dim] = i
return r
@property
def num_shards_map(self) -> list[int]:
"""
dim_map is a property we derive from `placements` of
the distributed tensor. Unlike `dim_map`, `num_shards_map`
denotes how many shards each tensor dim has. Like `dim_map`:
len(num_shards_map) == dist_tensor.ndim
num_shards_map[i] = 1: means tensor dim i is not sharded
num_shards_map[i] = j: means tensor dim i has j shards in total
For example, we have a dist tensor of shape [18, 20, 30],
a device_mesh ([[0, 1, 2, 3], [4, 5, 6, 7]]), and placements
([Shard(1), Shard(0)]), the num_shards_map of this distributed tensor
would be: [4, 2, 1].
"""
r = [1] * self.ndim
for i, placement in enumerate(self.placements):
if _is_shard_like(placement):
r[placement.dim] *= self.mesh.size(i)
return r
@property
def sums(self) -> list[int]:
"""
sums is a property we derive from `placements` of the
distributed tensor. It simply return a list of ints where
sums[i] denotes the pending sum (partial) on mesh dim i
"""
return [
idx
for idx, placement in enumerate(self.placements)
if placement.is_partial()
]
@classmethod
def from_dim_map(
cls,
mesh: DeviceMesh,
dim_map: list[int],
sums: list[int],
tensor_meta: TensorMeta | None = None,
) -> "DTensorSpec":
"""
Construct a DTensorSpec from dim_map list and pending sum.
Args:
mesh (class:`DeviceMesh`): device mesh to be used in the DTensorSpec
dim_map (List[int]): a list of integer that represents sharding on each
tensor dimension, see `dim_map` property doc for details
sums (List[int]): a list of integer that represents the dist tensor have
pending sum on which device mesh dimension.
tensor meta (TensorMeta): DTensor metadata
Return:
a class:`DTensorSpec` object
"""
# by default replicate on device mesh dims
placements: list[Placement] = [Replicate() for _ in range(mesh.ndim)]
# find all mesh dims that need pending reductions
for s in sums:
placements[s] = Partial()
for i, m in enumerate(dim_map):
if m >= 0:
placement = placements[m]
if placement.is_shard(): # dim_map only produces Shard placements
placement = cast(Shard, placement)
raise RuntimeError(
f"DeviceMesh dimension can't be mapped to two dimension of the same tensor: {i} and {placement.dim}"
)
elif placement.is_partial():
raise RuntimeError(
f"DeviceMesh dimension {m} cannot be both shard and partial!"
)
placements[m] = Shard(i)
return cls(mesh, tuple(placements), tensor_meta=tensor_meta)
def is_replicated(self) -> bool:
"""
return True if the current DTensorSpec replicates on all mesh dims (devices)
"""
return all(placement.is_replicate() for placement in self.placements)
def is_sharded(self) -> bool:
"""
return True if the current DTensorSpec uses Shard() or _StridedShard() placement on any mesh dims (devices)
"""
return any(_is_shard_like(placement) for placement in self.placements)
def shallow_copy_with_tensor_meta(
self, tensor_meta: TensorMeta | None
) -> "DTensorSpec":
"""
Shallow copy the DTensorSpec with a new tensor_meta.
"""
if tensor_meta is None:
raise AssertionError("shallow copy with no tensor_meta!")
return DTensorSpec(
self.mesh,
self.placements,
tensor_meta=tensor_meta,
use_strided_shard_as_shard_order=self.use_strided_shard_as_shard_order,
)
@@ -0,0 +1,305 @@
import operator
from functools import reduce
from typing import cast
import torch
import torch.distributed._functional_collectives as funcol
import torch.distributed.tensor._api as dtensor
from torch.distributed.tensor._op_schema import OutputSharding
from torch.distributed.tensor._utils import compute_local_shape_and_global_offset
from torch.distributed.tensor.placement_types import (
_StridedShard,
Partial,
Placement,
Replicate,
Shard,
)
# Mapping from argmin/argmax ops to their corresponding value ops (min/max)
_ARGMINMAX_REDUCTION_OPS = {
torch.ops.aten.argmax.default: torch.max,
torch.ops.aten.argmin.default: torch.min,
}
def _get_output_sharding(
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
) -> OutputSharding:
"""Get the output sharding for the given op."""
op_info = dtensor.DTensor._op_dispatcher.unwrap_to_op_info(op_call, args, kwargs)
dtensor.DTensor._op_dispatcher.sharding_propagator.propagate(op_info)
output_sharding = op_info.output_sharding
if output_sharding is None:
raise AssertionError("output sharding should not be None")
return output_sharding
def _prep_arguments(
op_call_repr: str,
args: tuple[object, ...],
kwargs: dict[str, object] | None,
) -> tuple[
torch.Tensor,
torch.Size,
"torch.distributed.device_mesh.DeviceMesh",
tuple[Placement, ...],
int | None,
bool,
]:
"""
Prepare arguments for nonlinear reduction ops.
Returns:
local_tensor: The local tensor to operate on
global_shape: The global shape of the DTensor
device_mesh: The device mesh
placements: The placements tuple
dim: The reduction dimension (can be None)
keepdim: Whether to keep the reduced dimension
"""
input_dtensor = cast(dtensor.DTensor, args[0])
dim: int | None = None
keepdim: bool = False
if not isinstance(input_dtensor, dtensor.DTensor):
raise NotImplementedError
if len(args) > 1:
dim = cast(int, args[1])
if len(args) > 2:
keepdim = cast(bool, args[2])
if kwargs:
if "dim" in kwargs:
dim = cast(int, kwargs["dim"])
if "keepdim" in kwargs:
keepdim = cast(bool, kwargs["keepdim"])
device_mesh = input_dtensor.device_mesh
placements = input_dtensor.placements
# check for partial placements and handle it as a replicate.
if any(isinstance(p, Partial) for p in placements):
target_placements = [
Replicate() if isinstance(p, Partial) else p for p in placements
]
input_dtensor = input_dtensor.redistribute(
device_mesh=device_mesh, placements=target_placements
)
placements = input_dtensor.placements
local_tensor = input_dtensor.to_local()
global_shape = input_dtensor.shape
return local_tensor, global_shape, device_mesh, placements, dim, keepdim
def _get_expected_shape(
local_tensor: torch.Tensor, dim: int | None, keepdim: bool
) -> torch.Size:
"""Compute the expected output shape after reduction."""
input_shape = list(local_tensor.shape)
if dim is None:
expected_shape = (
torch.Size([1] * len(input_shape)) if keepdim else torch.Size([])
)
elif keepdim:
if input_shape:
input_shape[dim] = 1
expected_shape = torch.Size(input_shape)
else:
if input_shape:
input_shape.pop(dim)
expected_shape = torch.Size(input_shape)
return expected_shape
def _collect_shard_mesh_dims(
op_call_repr: str,
local_tensor: torch.Tensor,
placements: tuple[Placement, ...],
dim: int | None,
) -> list[int]:
"""Collect mesh dimensions that are sharded along the reduction dimension."""
shard_mesh_dims: list[int] = []
for mesh_dim, p in enumerate(placements):
if isinstance(p, Shard):
if dim is None or p.dim == (dim if dim >= 0 else local_tensor.ndim + dim):
shard_mesh_dims.append(mesh_dim)
elif isinstance(p, _StridedShard):
raise NotImplementedError(f"{op_call_repr} does not support _StridedShard!")
return shard_mesh_dims
def _convert_to_global_idxs(
local_idx: torch.Tensor,
global_shape: torch.Size,
device_mesh: "torch.distributed.device_mesh.DeviceMesh",
placements: tuple[Placement, ...],
dim: int | None,
) -> tuple[int, torch.Tensor]:
"""Convert local indices to global indices."""
local_shape, global_offset = compute_local_shape_and_global_offset(
global_shape, device_mesh, placements
)
if dim is None:
# Convert flat local index → flat global index using arithmetic ops
# instead of torch.unravel_index, which doesn't support SymInt shapes.
gathered_idxs = torch.zeros_like(local_idx)
remaining = local_idx
for i in range(len(local_shape)):
local_stride = reduce(operator.mul, local_shape[i + 1 :], 1)
global_stride = reduce(operator.mul, global_shape[i + 1 :], 1)
coord = remaining // local_stride
remaining = remaining % local_stride
gathered_idxs = gathered_idxs + (coord + global_offset[i]) * global_stride
gather_dim = 0
else:
gather_dim = dim
gathered_idxs = local_idx + global_offset[dim]
return gather_dim, gathered_idxs
def _gather_tensors(
gather_dim: int,
gathered_idxs: torch.Tensor,
local_redux: torch.Tensor,
device_mesh: "torch.distributed.device_mesh.DeviceMesh",
shard_mesh_dims: list[int],
) -> tuple[torch.Tensor, torch.Tensor]:
"""
Gather the min or max of the tensors and their corresponding indices.
Args:
gather_dim: The dim to stack the collected min/max tensors.
gathered_idxs: The local tensor holding the corresponding indices.
local_redux: The local tensor holding the operator's value i.e. min/max.
device_mesh: Device mesh of the DTensor.
shard_mesh_dims: List of mesh dimensions that are sharded.
Returns:
All gathered tensors (gathered_redux, gathered_idxs) of the reducing operator.
"""
gathered_redux = local_redux
for mesh_dim in shard_mesh_dims:
gathered_redux = funcol.all_gather_tensor(
gathered_redux,
gather_dim=gather_dim,
group=(device_mesh, mesh_dim),
)
gathered_idxs = funcol.all_gather_tensor(
gathered_idxs,
gather_dim=gather_dim,
group=(device_mesh, mesh_dim),
)
return gathered_redux, gathered_idxs
def argminmax_handler(
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
) -> object:
"""
Handler for aten.argmin.default and aten.argmax.default ops.
This is a pure function handler that doesn't require instantiation.
"""
if op_call not in _ARGMINMAX_REDUCTION_OPS:
raise NotImplementedError(f"Unsupported reduction op: {op_call}")
local_tensor, global_shape, device_mesh, placements, dim, keepdim = _prep_arguments(
str(op_call), args, kwargs
)
output_sharding = _get_output_sharding(op_call, args, kwargs)
expected_shape = _get_expected_shape(local_tensor, dim, keepdim)
shard_mesh_dims = _collect_shard_mesh_dims(
str(op_call), local_tensor, placements, dim
)
# Compute local reduction
if dim is None:
val_op = _ARGMINMAX_REDUCTION_OPS[op_call]
# unsqueeze scalars to 1-d so they can be allgathered
local_redux = val_op(local_tensor).unsqueeze(0)
local_idx = op_call(local_tensor).unsqueeze(0)
else:
val_op = _ARGMINMAX_REDUCTION_OPS[op_call]
local_redux, local_idx = val_op(local_tensor, dim=dim, keepdim=True)
if not shard_mesh_dims:
return dtensor.DTensor._op_dispatcher.wrap(
local_idx.reshape(expected_shape), output_sharding.output_spec
)
gather_dim, gathered_idxs = _convert_to_global_idxs(
local_idx, global_shape, device_mesh, placements, dim
)
gathered_redux, gather_idxs = _gather_tensors(
gather_dim, gathered_idxs, local_redux, device_mesh, shard_mesh_dims
)
# Select the rank with the best value; use dim=0 when dim was None since
# the scalars were unsqueezed to 1-d for gathering
select_dim = 0 if dim is None else dim
rank_winner = op_call(gathered_redux, select_dim, True)
final_idx = torch.gather(gather_idxs, dim=gather_dim, index=rank_winner)
return dtensor.DTensor._op_dispatcher.wrap(
final_idx.reshape(expected_shape), output_sharding.output_spec
)
def minmax_dim_handler(
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
) -> object:
"""
Handler for aten.min.dim and aten.max.dim ops.
This is a pure function handler that doesn't require instantiation.
"""
local_tensor, global_shape, device_mesh, placements, dim, keepdim = _prep_arguments(
str(op_call), args, kwargs
)
output_sharding = _get_output_sharding(op_call, args, kwargs)
expected_shape = _get_expected_shape(local_tensor, dim, keepdim)
shard_mesh_dims = _collect_shard_mesh_dims(
str(op_call), local_tensor, placements, dim
)
# Compute local reduction - min/max with dim always requires dim
if dim is None:
raise AssertionError
local_redux, local_idx = op_call(local_tensor, dim=dim, keepdim=True)
if not shard_mesh_dims:
return dtensor.DTensor._op_dispatcher.wrap(
(
local_redux.reshape(expected_shape),
local_idx.reshape(expected_shape),
),
output_sharding.output_spec,
)
gather_dim, gathered_idxs = _convert_to_global_idxs(
local_idx, global_shape, device_mesh, placements, dim
)
gathered_redux, gather_idxs = _gather_tensors(
gather_dim, gathered_idxs, local_redux, device_mesh, shard_mesh_dims
)
# The op_call here is min/max with dim which returns (values, indices)
final_redux, rank_winner = op_call(gathered_redux, dim, True)
final_idx = torch.gather(gather_idxs, dim=gather_dim, index=rank_winner)
return dtensor.DTensor._op_dispatcher.wrap(
(
final_redux.reshape(expected_shape),
final_idx.reshape(expected_shape),
),
output_sharding.output_spec,
)
@@ -0,0 +1,719 @@
# mypy: allow-untyped-defs
"""
DTensor operator schema definitions and utilities.
This module defines the core data structures and utilities for describing and managing
distributed tensor operations in PyTorch's DTensor system. It provides the foundational
schema types used for sharding propagation, operator strategy selection, and distributed
execution planning.
Key components:
- OpSpec: Describes acceptable sharding placements for operations
- OpStrategy: Represents the possible sharding strategies for an operator
- TupleStrategy: Container for multiple strategies when ops have tuple/list of tensors input
- OpSchema: Describes operator input/output schemas with DTensorSpecs
- OutputSharding: Manages output sharding specifications and redistribution
- RuntimeSchemaInfo: Runtime execution metadata for operators
- OpInfo: Complete runtime operator execution information
These schema definitions enable the DTensor system to:
1. Propagate tensor sharding information to the operator outputs
2. Greedily select sharding strategies for distributed operations
3. Plan and execute tensor redistributions when needed
4. Cache sharding decisions for performance optimization
"""
from collections.abc import Sequence
from dataclasses import dataclass
from functools import cached_property
from typing import Any
from typing_extensions import deprecated
import torch
from torch._C import (
_DTensor_OpSchema_post_init,
_DTensor_OpSchema_recompute_comparison_key,
)
from torch._ops import OpOverload
from torch.distributed.device_mesh import DeviceMesh
from torch.distributed.tensor._dtensor_spec import DTensorSpec, TensorMeta
from torch.distributed.tensor.placement_types import Placement
try:
from torch.utils._cxx_pytree import (
register_pytree_node,
tree_leaves,
tree_map_only,
TreeSpec,
)
except ImportError:
from torch.utils._pytree import ( # type: ignore[no-redef, assignment]
register_pytree_node,
tree_leaves,
tree_map_only,
TreeSpec,
)
# Common type aliases
ArgsType = tuple[object, ...]
KwargsType = dict[str, object]
PlacementList = list[Placement | None]
# ATen op schemas could have Tensor, Tuple[Tensor] and List[Tensor], so output type should
# be the same set of possibilities.
OutputSpecType = DTensorSpec | Sequence[DTensorSpec | None] | None
def _rebuild_tensor_from_dtensor_meta(arg) -> object:
"""
This is used to propagate tensor metadata, must be under fake mode
"""
if arg.tensor_meta is None:
raise AssertionError("DTensorSpec does not contain tensor_meta.")
return torch.empty_strided(
arg.tensor_meta.shape,
arg.tensor_meta.stride,
dtype=arg.tensor_meta.dtype,
)
def _pretty_print_spec(spec: object) -> str:
if spec is None:
return "None"
elif isinstance(spec, DTensorSpec):
return "".join([str(p) for p in spec.placements])
elif isinstance(spec, Sequence):
return "(" + ", ".join([_pretty_print_spec(s) for s in spec]) + ")"
else:
raise RuntimeError(f"Unknown spec type to print: spec={spec}")
@dataclass
class OpSpec:
"""
An OpSpec describes an acceptable sharding placements of an operation, with the
specified DTensorSpecs for both the output and the inputs.
note: when the op return value is a single DTensor object, output_specs is
DTensorSpec; when the return value is a tuple of Optional[DTensor],
output_specs is a tuple of Optional[DTensorSpec].
note: we MUST produce an DTensorSpec for every output that is a Tensor. None
entries only occur for non-Tensor outputs (e.g., operators that return Optional[Tensor],
or non-Tensor outputs.)
invariant: the DeviceMesh on all DTensorSpec must be the same
"""
# output_specs and input_specs are related: for this op, given these input_specs,
# this is the way the output would look
# Note: output_specs can be None for ops that don't return tensors (e.g., _linalg_check_errors)
output_specs: DTensorSpec | tuple[DTensorSpec | None, ...] | None
input_specs: Sequence[DTensorSpec] | None = None
"""
redistribute_cost tells how expensive it is to redistribute a given input into the
placement specified in this OpSpec.
outer list: one entry (list) per (tensor) input in the op's arg schema
inner list: one entry (cost value) per possible sharding spec for that input
Example:
-------
another_op() -> tensor_a # another_op produces the output that becomes our first input
my_op(tensor_a)
Let's assume this OpSpec's input_specs are [Replicate()],
but another_op() supports 2 strategies (OpSpecs) which produce outputs of
Replicate()
Shard(0)
In this example, redistribute_costs would look like this
[
# one row representing "my_op's first input" (tensor_a)
[
# two entries, one for each strategies supported by another_op
0.0, # cost of redistributing tensor_a from 'Replicate()'
K, # cost of redistributing tensor_a from 'Shard(0)'
],
"""
redistribute_cost: list[list[float]] | None = None
@cached_property
def output_spec(self) -> DTensorSpec:
"""
This function requires that the strategy have exactly one DTensorSpec as the
output spec. If the output_specs is a tuple, we throw an exception.
"""
if isinstance(self.output_specs, DTensorSpec):
return self.output_specs
else:
raise ValueError(
f"function output_spec expects a single DTensorSpec but got: {self.output_specs}"
)
@cached_property
def mesh(self):
if isinstance(self.output_specs, DTensorSpec):
return self.output_specs.mesh
elif isinstance(self.output_specs, tuple):
out_spec = self.output_specs[0]
if not isinstance(out_spec, DTensorSpec):
raise AssertionError
return out_spec.mesh
elif self.output_specs is None:
# For no-output ops, get mesh from input_specs
if self.input_specs is None or len(self.input_specs) <= 0:
raise AssertionError(
"Cannot determine mesh: output_specs is None and input_specs is empty"
)
return self.input_specs[0].mesh
else:
raise ValueError(
f"function output_spec expects a single DTensorSpec or a tuple of DTensorSpec but got: {self.output_specs}"
)
def input_spec(self, index: int = 0) -> DTensorSpec:
if self.input_specs is None:
raise AssertionError("input_specs of OpSpec is None!")
if len(self.input_specs) <= index:
raise AssertionError(
f"Invalid index {index} for input_specs of length "
f"{len(self.input_specs)}: {self.input_specs}"
)
return self.input_specs[index]
def __str__(self) -> str:
if self.input_specs is not None:
input_specs_str = f"{_pretty_print_spec(self.input_specs)} -> "
else:
input_specs_str = ""
output_spec_str = _pretty_print_spec(self.output_specs)
return f"{input_specs_str}{output_spec_str}"
def __hash__(self) -> int:
if self.output_specs is None:
output_hash = hash(None)
elif isinstance(self.output_specs, DTensorSpec):
output_hash = hash(self.output_specs)
else:
output_hash = hash(tuple(self.output_specs))
input_hash = hash(tuple(self.input_specs)) if self.input_specs else 0
return hash((output_hash, input_hash))
def __eq__(self, other: object) -> bool:
if not isinstance(other, OpSpec):
return False
return (
self.output_specs == other.output_specs
and self.input_specs == other.input_specs
)
class StrategyType:
"""
Base class type for op strategy, We have two StrategyType:
OpStrategy and TupleStrategy
"""
class OpStrategy(StrategyType):
"""
OpStrategy that consists of a list of sharding strategies associated with the op,
where each strategy is an OpSpec that describes the acceptable input/output sharding.
invariant: the DeviceMesh on all OpSpec must be the same
"""
def __init__(self, strategies: list[OpSpec]) -> None:
super().__init__()
self.strategies: list[OpSpec] = strategies
def __str__(self) -> str:
strategy_list_str = ", ".join([str(strategy) for strategy in self.strategies])
mesh_shape = self.mesh_shape
return f"OpStrategy[{strategy_list_str}] @ mesh: {mesh_shape}"
def max_num_shards(self) -> int:
"""
Returns the max number of shards across all OpSpecs
"""
return max(strategy.output_spec.num_shards for strategy in self.strategies)
@property
def mesh(self):
return self.strategies[0].mesh
@property
def mesh_shape(self):
return self.strategies[0].mesh.shape
@property
def ndim(self):
return self.strategies[0].output_spec.ndim
@property
def shape(self):
return self.strategies[0].output_spec.shape
@property
def tensor_meta(self) -> TensorMeta:
# TODO upstream this assert to DTensorSpec itself and fill any missing TensorMetas
if self.strategies[0].output_spec.tensor_meta is None:
raise AssertionError
return self.strategies[0].output_spec.tensor_meta
def __hash__(self) -> int:
return hash(tuple(self.strategies))
def __eq__(self, other: object) -> bool:
return isinstance(other, OpStrategy) and self.strategies == other.strategies
class TupleStrategy(StrategyType):
"""
TupleStrategy is a special case for operators that are fundamentally compound or batched such that some subset
of the inputs and outputs are completely unrelated to some other subset.
Generally, foreach_* ops are the most common use-case for TupleStrategy, because they accept lists of inputs,
but operate independently on each input or tuple of zipped inputs.
For example, [out_a, out_b] = torch.foreach_add([a, b], scalar): input a's sharding only affects out_a's sharding,
independent of b and out_b.
An example of an operator that should NOT use TupleStrategy is torch.split. It produces a List[Tensor]
as its output, but the sharding decision of one output is bound together with the decision
of each other output and the common input.
"""
def __init__(
self,
children: Sequence[StrategyType],
) -> None:
super().__init__()
self.children: Sequence[StrategyType] = children
@property
@deprecated(
"TupleStrategy.childs is deprecated, use TupleStrategy.children instead.", # codespell:ignore childs
category=FutureWarning,
)
def childs(self) -> Sequence[StrategyType]: # codespell:ignore childs
"""
Alias for children, to maintain backward compatibility.
"""
return self.children
def child_mesh(self, index: int) -> DeviceMesh:
op_strategy = self.children[index]
if not isinstance(op_strategy, OpStrategy):
raise AssertionError
return op_strategy.mesh
def __str__(self) -> str:
child_strategies_str = ", ".join(
[f"{str(strat)}" for idx, strat in enumerate(self.children)]
)
return f"TupleStrategy({child_strategies_str})"
def __hash__(self) -> int:
return hash(tuple(self.children))
def __eq__(self, other: object) -> bool:
return isinstance(other, TupleStrategy) and self.children == other.children
try:
register_pytree_node(
TupleStrategy,
lambda node: (node.children, None),
lambda children, _: TupleStrategy(tuple(children)),
)
except ValueError:
# already registered TupleStrategy, skip
pass
@dataclass
class RuntimeSchemaInfo:
"""
RuntimeSchemaInfo stores the operator schema related information for runtime (eager)
execution. This is mainly used for two ways: 1. to generate hash for args to determine
whether to re-run sharding prop or not 2. to determine if we need pytree
"""
# This static_argnum records static arg "starting index" for ops that have non-tensor
# args/kwargs which would affect sharding propagation results. All args starting from
# this index would be hashed to our sharding cache.
# Note that only a few ops need this information, e.g. view, transpose, var.dim, etc.
static_argnum: int = 100
# This static_kwargkey records static kwarg names which would affect sharding prop
static_kwargkey: list[str] | None = None
# each op can decide if it wants to use pytree flatten/unflatten during operator
# eager execution, by default we don't need to do flatten/unflatten, only if the
# op indicate it needs to, this is to accelerate eager performance.
needs_pytree: bool = False
@dataclass
class OpSchema:
"""
OpSchema is a data class that describes an operator input schemas, it includes
DTensorSpecs/OpStrategies (instead of DTensor) and non-tensor args/kwargs (positional
order preserved). It is mainly used by the DTensor's dispatching logic to perform various
actions (i.e. sharding propagation, caching sharding decisions, redistribute, etc.)
NOTE: this must be used as a read only data class
TODO: make this a frozen dataclass
Args:
op: the operator overload we are intercepting
args_schema: contains args except that the DTensor args have been replaced
with its DTensorSpec or OpStrategy
kwargs_schema: contains kwargs except that the DTensor kwargs have been replaced
with its DTensorSpec or OpStrategy
"""
op: OpOverload
args_schema: ArgsType
kwargs_schema: KwargsType
schema_info: RuntimeSchemaInfo | None = None
_comparison_key: tuple[object, ...] | None = None
@property
def args_spec(self) -> tuple[DTensorSpec, ...]:
"""
args_spec: Tuple[DTensorSpec, ...]: contains a clean list of args spec list
with NO non-DTensor positional arguments (i.e. int/float/tuple, etc)
mainly used by sharding propagation to propagate the output spec
"""
args = (
tree_leaves(self.args_schema)
if self.schema_info is not None and self.schema_info.needs_pytree
else self.args_schema
)
return tuple(item for item in args if isinstance(item, DTensorSpec))
@property
def args_strategy(self) -> tuple[OpStrategy, ...]:
# filter out non-relevant values from args schema to get a clean OpStrategy list
# separate with args_spec for the ease of type annotation
# TODO: see if we should merge this with args_spec
args = (
tree_leaves(self.args_schema)
if self.schema_info is not None and self.schema_info.needs_pytree
else self.args_schema
)
return tuple(item for item in args if isinstance(item, OpStrategy))
@property
def kwargs_strategy(self) -> tuple[OpStrategy, ...]:
# returns OpStrategy items from kwargs_schema.
kwargs_vals = (
tree_leaves(self.kwargs_schema)
if self.schema_info is not None and self.schema_info.needs_pytree
else self.kwargs_schema.values()
)
return tuple(item for item in kwargs_vals if isinstance(item, OpStrategy))
@property
def args_meta(self) -> tuple[TensorMeta | Any, ...]:
# Used for calling single_dim strategy functions, which aren't allowed to see DTensorSpecs/Meshes
# like args_spec, but has OpStrategy replaced with corresponding TensorMeta,
# and TupleStrategy replaced with tuple of TensorMeta
# preserves the original pytree structure
# example:
# args_schema = (OpStrategy1, TupleStrategy([OpStrategy2, OpStrategy3]), OpStrategy4)
# args_meta: (TensorMeta1, (TensorMeta2, TensorMeta3), TensorMeta4)
def convert_to_meta(item):
if isinstance(item, OpStrategy):
return item.tensor_meta
elif isinstance(item, TupleStrategy):
return tuple(convert_to_meta(child) for child in item.children)
elif isinstance(item, (list, tuple)):
converted = [convert_to_meta(child) for child in item]
return type(item)(converted)
else:
return item
return tuple(convert_to_meta(arg) for arg in self.args_schema)
@property
def kwargs_meta(self) -> dict[str, object]:
# like args_meta, but for kwargs
def convert_to_meta(item):
if isinstance(item, OpStrategy):
return item.tensor_meta
elif isinstance(item, TupleStrategy):
return tuple(convert_to_meta(child) for child in item.children)
elif isinstance(item, (list, tuple)):
converted = [convert_to_meta(child) for child in item]
return type(item)(converted)
else:
return item
return {
key: convert_to_meta(value) for key, value in self.kwargs_schema.items()
}
def __repr__(self) -> str:
args_schema = ", ".join([str(arg_schema) for arg_schema in self.args_schema])
return (
f"OpSchema(op={self.op},"
f" args_schema=({args_schema}),"
f" kwargs_schema={self.kwargs_schema})"
)
def __str__(self) -> str:
args_schema: list[str] = []
device_mesh = None
for arg in self.args_schema:
if isinstance(arg, DTensorSpec):
args_schema.append(str(arg))
device_mesh = arg.mesh
elif isinstance(arg, OpStrategy):
if len(arg.strategies) != 1:
raise AssertionError
args_schema.append(_pretty_print_spec(arg.strategies[0].output_specs))
device_mesh = arg.mesh
elif isinstance(arg, TupleStrategy):
first_op_strategy = arg.children[0]
if not isinstance(first_op_strategy, OpStrategy):
raise AssertionError
device_mesh = first_op_strategy.mesh
args_schema.append(str(arg))
else:
args_schema.append(str(arg))
return f"{self.op}({', '.join(args_schema)}) on {device_mesh})"
def __post_init__(self) -> None:
_DTensor_OpSchema_post_init(self)
def arg_type_tensor_or_tensor_list_like(self, arg: object) -> bool:
is_tensor = isinstance(arg, DTensorSpec)
if is_tensor:
return True
if not isinstance(arg, list):
return False
return all(isinstance(e, DTensorSpec) or e is None for e in arg)
def return_type_tuple_tensor_like(self) -> bool:
# all dispatch ops could only return Tuple[Tensor] or have None/ints/floats
# in the tuple, but the first element must be a Tensor, so this check is enough
return_types = self.op._schema.returns
return len(return_types) > 1 and isinstance(
return_types[0].type, torch.TensorType
)
def return_type_list_tensor_like(self) -> bool:
# returns True if the return type is a List
return_types = self.op._schema.returns
return len(return_types) == 1 and isinstance(
return_types[0].type, torch.ListType
)
def return_type_tensor(self) -> bool:
return_types = self.op._schema.returns
# all dispatch ops only return Tensor or Tuple[Tensor] for tensor like
# return types, so this check is enough for tensor like types
return len(return_types) > 0 and isinstance(
return_types[0].type, torch.TensorType
)
def get_mesh_from_args(self, validate: bool = True) -> DeviceMesh:
"""
This util can be used to get a mesh from the OpSchema that contains multiple
DTensors as arguments. When `validate` is True, it will try to validate that all the
arguments have the same mesh to avoid unexpected cross mesh errors.
NOTE: this util currently does not handle TupleStrategy when `validate=True`,
this is because for TupleStrategy there could be different types of checks, i.e.:
- for stack and cat like op, we need to check within a TupleStrategy is every
input is on the same mesh
- for foreach like ops we need to check "zipped" inputs are on the same mesh
for each index.
"""
mesh = None
# Scan all args to find the first DTensorSpec/OpStrategy (not just the first arg)
for arg in self.args_schema:
if isinstance(arg, (DTensorSpec, OpStrategy)):
mesh = arg.mesh
break
elif isinstance(arg, (list, tuple, TupleStrategy)):
# Scan all elements in the list/tuple, not just the first one,
# to handle cases like List[Optional[Tensor]] where first elem may be None
elems = arg.children if isinstance(arg, TupleStrategy) else arg
for elem in elems:
if isinstance(elem, (DTensorSpec, OpStrategy)):
mesh = elem.mesh
break
if mesh is not None:
break
if mesh is None:
raise ValueError(f"Cannot find device mesh from args for op : {self.op}.")
if validate:
for arg in self.args_schema[1:]:
if isinstance(arg, (DTensorSpec, OpStrategy)) and arg.mesh != mesh:
raise RuntimeError(
f"DTensor does not support cross-mesh operation on {self.op}! "
f"Got meshes: {mesh} {arg.mesh}. "
f"Please make sure all the arguments have the same DeviceMesh."
)
return mesh
def is_inplace_op(self) -> bool:
# simple analysis of function schema to determine
# if this is an inplace variant, it might not
# be entirely correct, but it's good enough for now.
return self.op._schema.name[-1] == "_"
def is_out_variant_op(self) -> bool:
# simple analysis of function schema to determine
# if this is an out variant, it might not
# be entirely correct, but it's good enough for now.
return "out" in self.op._schema.overload_name
def is_view_op(self) -> bool:
return self.op._schema._is_view_op()
def _recompute_comparison_key(self) -> None:
_DTensor_OpSchema_recompute_comparison_key(self)
def __hash__(self) -> int:
return hash(self._comparison_key)
def __eq__(self, other: object) -> bool:
# early return checks
if not isinstance(other, OpSchema):
return False
if self.op != other.op:
return False
if len(self.args_schema) != len(other.args_schema):
return False
return self._comparison_key == other._comparison_key
def gen_fake_args(self) -> ArgsType:
"""
gen_fake_args: generate fake args for the operator, this is mainly used
by sharding propagation rules to generate fake args for the operator
to run the local tensor operator and get the output spec.
"""
return tree_map_only(
DTensorSpec,
_rebuild_tensor_from_dtensor_meta,
self.args_schema,
is_leaf=lambda x: isinstance(x, DTensorSpec),
)
def gen_fake_kwargs(self) -> KwargsType:
"""
gen_fake_kwargs: generate fake kwargs for the operator, this is mainly used
by sharding propagation rules to generate fake kwargs for the operator
to run the local tensor operator and get the output spec.
"""
return tree_map_only(
DTensorSpec,
_rebuild_tensor_from_dtensor_meta,
self.kwargs_schema,
is_leaf=lambda x: isinstance(x, DTensorSpec),
)
def _inplace_rewrap_schema_suggestion(self, origin_schema: "OpSchema") -> None:
suggestion_args_spec = self.args_spec
new_arg_schema: list[object] = []
idx_of_args_spec = 0
if (
origin_schema.schema_info is not None
and origin_schema.schema_info.needs_pytree
):
args_schema: Sequence[Any] = tree_leaves(origin_schema.args_schema)
else:
args_schema = origin_schema.args_schema
for arg in args_schema:
if isinstance(arg, DTensorSpec):
new_arg_schema.append(suggestion_args_spec[idx_of_args_spec])
idx_of_args_spec += 1
else:
new_arg_schema.append(arg)
self.args_schema = tuple(new_arg_schema)
self.kwargs_schema = origin_schema.kwargs_schema
self._recompute_comparison_key()
@dataclass
class OutputSharding:
"""
OutputSharding is a data class that is used by the sharding propagation,
it could set the output_spec upon successful propagation. If needs_redistribute
is set to True, a redistribute_schema would be returned together to indicate
the input arguments needs to be redistributed before the op execution.
NOTE: the redistribute_schema generated by sharding propagation should be
exactly the same as the operator OpSchema, except the DTensorSpecs
"""
# specifies the output sharding pattern
output_spec: OutputSpecType
# schema for redistribution if needed
redistribute_schema: OpSchema | None = None
# flag indicating if inputs need redistribution
needs_redistribute: bool = False
# flag to use values from `redistribute_schema`
use_val_from_redistribute_schema: bool = False
@cached_property
def mesh(self):
if isinstance(self.output_spec, DTensorSpec):
return self.output_spec.mesh
elif isinstance(self.output_spec, tuple):
out_spec = self.output_spec[0]
if isinstance(out_spec, DTensorSpec):
return out_spec.mesh
else:
raise ValueError(f"Unknown output spec type: {type(out_spec)}")
else:
raise ValueError(f"Unknown output spec type: {type(self.output_spec)}")
@dataclass
class OpInfo:
"""
All Runtime Op execution info are packed here
"""
# The first compute device mesh recorded from args
# NOTE: one op could have multiple meshes from its args. We just record the first
# mesh here to check if current rank should participate in computation or not.
compute_mesh: DeviceMesh
# compete runtime operator infos
# NOTE: schema can be None due to C++ fast path optimization. When the C++
# dispatch layer (dispatchDTensorOp in python_variable.cpp) finds a cached
# sharding decision, it skips creating the full OpSchema to reduce CPU overhead.
# In this case, OpInfo is created with create_schema=False, setting schema to None.
# The operator information is still available through output_sharding.redistribute_schema
# when redistribution is needed.
schema: OpSchema | None
flat_args_schema: list[object]
local_args: Sequence[object]
local_kwargs: dict[str, object]
args_tree_spec: TreeSpec | None = None
# the output sharding info
output_sharding: OutputSharding | None = None
@@ -0,0 +1,9 @@
# Copyright (c) Meta Platforms, Inc. and affiliates
from ._conv_ops import * # noqa: F403
from ._embedding_ops import * # noqa: F403
from ._math_ops import * # noqa: F403
from ._matrix_ops import * # noqa: F403
from ._pointwise_ops import * # noqa: F403
from ._random_ops import * # noqa: F403
from ._tensor_ops import * # noqa: F403
from ._view_ops import * # noqa: F403
@@ -0,0 +1,288 @@
# Copyright (c) Meta Platforms, Inc. and affiliates
import string
from typing import cast
import torch
from torch.distributed.tensor._dtensor_spec import DTensorSpec, TensorMeta
from torch.distributed.tensor._op_schema import OpSchema, OutputSharding
from torch.distributed.tensor._ops.utils import prod
from torch.distributed.tensor._utils import compute_local_shape_and_global_offset
def _replace_char_in_str(string: str, new_char: str, idx: int) -> str:
return string[:idx] + new_char + string[idx + 1 :]
def _gen_reshard_suggestions(
op_schema: OpSchema,
input_dims: list[str],
input_specs: tuple[DTensorSpec, ...],
dim_to_sharding: dict[str, int],
pending_sum: list[int],
) -> OutputSharding:
suggested_arg_specs: list[DTensorSpec] = []
for input_dim, input_spec in zip(input_dims, input_specs):
dim_map = [dim_to_sharding[dim] for dim in input_dim]
suggested_arg_specs.append(
DTensorSpec.from_dim_map(
mesh=input_spec.mesh,
dim_map=dim_map,
sums=pending_sum,
tensor_meta=input_spec.tensor_meta,
)
)
suggested_schema = OpSchema(op_schema.op, tuple(suggested_arg_specs), {})
suggested_schema._inplace_rewrap_schema_suggestion(op_schema)
return OutputSharding(
None,
redistribute_schema=suggested_schema,
)
def einop_rule(
equation: str,
op_schema: OpSchema,
*,
linearity: bool = False,
enforce_sharding: dict[str, int] | None = None,
) -> OutputSharding:
"""
Propagate the sharding of inputs to output for ops whose data moves according to einsum notation.
This is mostly borrowed from @zdevito's sharding simulator. Examples:
mk,kn->mn - einsum
ij,ij->ij - addition
ij,j->ij - broadcasted addition
ij->i - reduction
Other ops could use this propagation algorithm when applied, note
that einsum propagation only deal with list of specs (DTensor specs)
as it only works on list of tensors!
linearity in einop_rule means that the calling op `f` follows this rule:
f(a + b) = f(a) + f(b)
In this case we can propagate the partial sum, note that linearity in einop
only applies to partial sum, not other operations like min/max (which are
associative but not linear).
"""
# parse einop equation and extract arg specs
inputs, outputs = equation.split("->")
input_dims, output_dims = inputs.split(","), outputs.split(",")
input_specs = op_schema.args_spec
# NOTE: only support single output unless needed in future
output_dim = output_dims[0]
dim_to_sharding: dict[str, int] = {}
dim_to_size: dict[str, int] = {}
# record pending sum, key is mesh dimension, value is pending sum
# counter across input specs
pending_sums_counter: dict[int, int] = {}
seen_shardings: dict[int, str] = {}
needs_reshard = False
def merge_sharding(dim: str, a: int, b: int) -> int:
# merge the sharding of inputs if it's able to merge, i.e. we can merge
# replicate and shard to shard, but this will trigger an reshard operation
if a != b:
if a == -1 or b == -1:
# reshard the replicate to match the sharded one
nonlocal needs_reshard
needs_reshard = True
return a if a != -1 else b
else:
# TODO: further merge the sharding properly (i.e. reshard one input to replicate)
raise RuntimeError(
f"{equation}: dim {dim} sharded two different ways: {a} and {b}"
)
else:
return a
for input_dim, input_spec in zip(input_dims, input_specs):
# deal with partial sums
input_sums = input_spec.sums
for sum_dim in input_sums:
if sum_dim not in pending_sums_counter:
seen_shardings[sum_dim] = "+"
# update pending sum counter for pending sum mesh
# dimension with the occurrence from each input
pending_sums_counter[sum_dim] = pending_sums_counter.get(sum_dim, 0) + 1
for idx, (dim, mesh_dim) in enumerate(zip(input_dim, input_spec.dim_map)):
if enforce_sharding and dim in enforce_sharding:
if enforce_sharding[dim] != mesh_dim:
needs_reshard = True
dim_to_sharding[dim] = enforce_sharding[dim]
dim_to_size[dim] = input_spec.shape[idx]
elif dim not in dim_to_sharding:
dim_to_sharding[dim] = mesh_dim
dim_to_size[dim] = input_spec.shape[idx]
else:
dim_to_sharding[dim] = merge_sharding(
dim, dim_to_sharding[dim], mesh_dim
)
if dim_to_size[dim] != input_spec.shape[idx]:
raise AssertionError
# after merging sharding, we check if there're multiple
# sharding on the same mesh dim.
merged_sharding_for_dim = dim_to_sharding[dim]
if merged_sharding_for_dim != -1:
if (
merged_sharding_for_dim in seen_shardings
and dim != seen_shardings[merged_sharding_for_dim]
):
needs_reshard = True
seen_shardings[merged_sharding_for_dim] += dim
else:
seen_shardings[merged_sharding_for_dim] = dim
if pending_sums_counter and not linearity:
# return reshard suggestion with no pending sum, because we already properly
# merge the sharding, this reshard suggestion is legit to use
return _gen_reshard_suggestions(
op_schema, input_dims, input_specs, dim_to_sharding, []
)
else:
# It's a op that support linearity, but not all input arguments are partial
# we fail the sharding propagation with suggestion to make all inputs be
# partial on the corresponding mesh dim (all inputs should be partial for
# the mesh dims in order to execute locally and delay the sum reduction)
for value in pending_sums_counter.values():
if value != len(input_specs):
needs_reshard = True
for mesh_dim, dims in seen_shardings.items():
if len(dims) > 1:
# we found different input dims are being sharded on the same mesh dim
# in order to perform local op computation, we need to reshard inputs
# base on some simple heuristics, now we simply pick the one with least comm
# volume. (i.e. the input with least size)
# TODO: consider a more advanced heuristic to pick the best sharding
costs = []
for d in dims:
cost = 0
for input_dim, input_spec in zip(input_dims, input_specs):
if (
d in input_dim
and input_spec.dim_map[input_dim.index(d)] == mesh_dim
):
if input_spec.tensor_meta is None:
raise AssertionError
global_shape = input_spec.tensor_meta.shape
local_shape, _ = compute_local_shape_and_global_offset(
global_shape,
input_spec.mesh,
input_spec.placements,
skip_offset=True,
)
cost += prod(local_shape) * input_spec.mesh.size(mesh_dim)
costs.append(cost)
d_to_keep_sharding = dims[costs.index(max(costs))]
for d in dims:
# update dim_to_sharding to keep the sharding of the dim with
# highest comm and make the rest of the dims to replicate
if d != d_to_keep_sharding:
dim_to_sharding[d] = -1
pending_sums = list(pending_sums_counter.keys())
if needs_reshard:
return _gen_reshard_suggestions(
op_schema, input_dims, input_specs, dim_to_sharding, pending_sums
)
# generate output pending sum if a dim is sharded, and it appears in input
# but not output
for dim, shard_on_mesh in dim_to_sharding.items():
if dim not in output_dims[0] and shard_on_mesh != -1:
pending_sums.append(shard_on_mesh)
# if no need to reshard, we directly generate the output sharding
output_dim_map = []
output_shape = []
for dim in output_dim:
if dim == "1":
# find output dim that is a singleton dimension, mark sharding and shape
output_dim_map.append(-1)
output_shape.append(1)
else:
output_dim_map.append(dim_to_sharding[dim])
output_shape.append(dim_to_size[dim])
# XXX: since we still need to have intermediate shape calculation, we need
# to pass in the shape here. We should remove this once sharding decomp works
# for ops like addmm
if input_specs[0].tensor_meta is None:
raise AssertionError
tensor_meta = TensorMeta(
torch.Size(output_shape),
input_specs[0].tensor_meta.stride,
input_specs[0].tensor_meta.dtype,
)
return OutputSharding(
DTensorSpec.from_dim_map(
input_specs[0].mesh,
output_dim_map,
pending_sums,
tensor_meta=tensor_meta,
)
)
def pointwise_rule(op_schema: OpSchema, linearity: bool = False) -> OutputSharding:
"""
Propagate the sharding for pointwise operations.
Examples:
ij,ij->ij - addition/mul
ij,j->ij - broadcasted addition
"""
alphabet = string.ascii_lowercase
# find the max_dim first in case we need to broadcasting
input_specs = op_schema.args_spec
max_dim = max(input.ndim for input in input_specs)
dimchars = []
singleton_counter: list[int] = [0] * max_dim
for input in input_specs:
start_dim = max_dim - input.ndim
p = alphabet[start_dim:max_dim]
# handle the "broadcasting to a common shape case"
# see https://pytorch.org/docs/stable/notes/broadcasting.html
# If any of the dimensions is singleton dimension (i.e. 1).
# we mark the dim char as a special "1" to distinguish with
# the non-singleton dimension, so that sharding propagation
# should just ignore the singleton dimension.
if len(input_specs) > 1:
for i in range(max_dim):
if i < start_dim:
# treat the leading miss dim chars as singleton
singleton_counter[i] += 1
elif input.shape[i - start_dim] == 1:
# mark singleton dim char as a special "1" in einop rule
singleton_counter[i] += 1
p = _replace_char_in_str(p, "1", (i - start_dim))
dimchars.append(p)
out_dimchars = alphabet[:max_dim]
# check if we replace the all inputs dim char with singleton dimension,
# if we replace all inputs, we also need to replace the output dimension.
for output_dim_idx in range(len(out_dimchars)):
if singleton_counter[output_dim_idx] == len(input_specs):
out_dimchars = _replace_char_in_str(out_dimchars, "1", output_dim_idx)
fmt = f"{','.join(p for p in dimchars)}->{out_dimchars}"
enforce_sharding: dict[str, int] = {}
if op_schema.is_inplace_op():
follow_spec = op_schema.args_spec[0]
enforce_sharding.update(zip(out_dimchars, follow_spec.dim_map))
elif op_schema.is_out_variant_op():
follow_spec = cast(DTensorSpec, op_schema.kwargs_schema["out"])
enforce_sharding.update(zip(out_dimchars, follow_spec.dim_map))
return einop_rule(
fmt,
op_schema,
linearity=linearity,
enforce_sharding=enforce_sharding,
)
@@ -0,0 +1,202 @@
# Copyright (c) Meta Platforms, Inc. and affiliates
# implement matrix related ops for distributed tensor
from typing import Any
import torch
from torch.distributed.tensor._dtensor_spec import DTensorSpec, TensorMeta
from torch.distributed.tensor._op_schema import (
OpSchema,
OutputSharding,
RuntimeSchemaInfo,
)
from torch.distributed.tensor._ops.single_dim_strategy import (
_ShardingPlaceholder,
register_single_dim_strategy,
)
from torch.distributed.tensor._ops.utils import register_prop_rule
from torch.distributed.tensor.placement_types import Partial, Placement, Replicate
aten = torch.ops.aten
@register_prop_rule(aten.convolution.default)
def convolution_rules(op_schema: OpSchema) -> OutputSharding:
(
input_spec,
weight_spec,
bias_spec,
stride,
padding,
dilation,
_transposed,
_output_padding,
_groups,
) = op_schema.args_schema
if not isinstance(input_spec, DTensorSpec):
raise AssertionError
if not isinstance(weight_spec, DTensorSpec):
raise AssertionError
# bias_spec can be None (optional parameter in aten.convolution schema)
if bias_spec is not None:
if not isinstance(bias_spec, DTensorSpec):
raise AssertionError
if input_spec.tensor_meta is None:
raise AssertionError
if weight_spec.tensor_meta is None:
raise AssertionError
in_shape = input_spec.tensor_meta.shape
weight_shape = weight_spec.tensor_meta.shape
if not isinstance(stride, list):
raise AssertionError(f"stride must be list, got {type(stride)}")
if not isinstance(padding, list):
raise AssertionError(f"padding must be list, got {type(padding)}")
if not isinstance(dilation, list):
raise AssertionError(f"dilation must be list, got {type(dilation)}")
# weight_shape might not be torch.Size in all cases (e.g., SymIntArrayRef during tracing)
# so we don't assert its type, just use it
out_conv_shape = [
(d + 2 * padding[i] - dilation[i] * (weight_shape[i + 1] - 1) - 1) // stride[i]
+ 1
for (i, d) in enumerate(in_shape[2:])
]
output_shape = [in_shape[0], weight_shape[0]] + out_conv_shape
output_stride = [1]
for i in range(1, len(output_shape)):
output_stride.insert(0, output_stride[0] * output_shape[-i])
output_dim_map = input_spec.dim_map
pending_sums = input_spec.sums
tensor_meta = TensorMeta(
torch.Size(output_shape),
tuple(output_stride),
input_spec.tensor_meta.dtype,
)
return OutputSharding(
DTensorSpec.from_dim_map(
input_spec.mesh,
output_dim_map,
pending_sums,
tensor_meta=tensor_meta,
)
)
@register_prop_rule(aten.convolution_backward.default)
def convolution_backward_rules(op_schema: OpSchema) -> OutputSharding:
input_spec = op_schema.args_schema[0]
(
grad_output_spec,
input_spec,
weight_spec,
bias_shape_opt,
_stride,
_padding,
_dilation,
_transposed,
_output_padding,
_groups,
_output_mask,
) = op_schema.args_schema
if not isinstance(grad_output_spec, DTensorSpec):
raise AssertionError
if not isinstance(input_spec, DTensorSpec):
raise AssertionError
if not isinstance(weight_spec, DTensorSpec):
raise AssertionError
# bias_shape_opt can be None (optional parameter in aten.convolution_backward schema)
if bias_shape_opt is not None:
if not isinstance(bias_shape_opt, list):
raise AssertionError
if input_spec.tensor_meta is None:
raise AssertionError
weight_tensor_meta = weight_spec.tensor_meta
# Only create bias_tensor_meta if bias_shape_opt is not None
if bias_shape_opt is not None:
bias_tensor_meta = TensorMeta(
torch.Size(bias_shape_opt),
(1,),
input_spec.tensor_meta.dtype,
)
else:
bias_tensor_meta = None
grad_input_spec = input_spec
grad_weight_spec = DTensorSpec.from_dim_map(
input_spec.mesh,
[-1, -1, -1, -1],
[0],
tensor_meta=weight_tensor_meta,
)
# Only create grad_bias_spec if we have bias_tensor_meta
if bias_tensor_meta is not None:
grad_bias_spec = DTensorSpec.from_dim_map(
input_spec.mesh,
[-1],
[0],
tensor_meta=bias_tensor_meta,
)
else:
grad_bias_spec = None
# TODO: actually the output_mask is not respected here, we should
# set the corresponding spec to `None` if the output_mask is not `False`
# for a certain output Tensor. This also applies to the conv handler
# in torch/distributed/tensor/_tp_conv.py
return OutputSharding([grad_input_spec, grad_weight_spec, grad_bias_spec])
# Single-dim strategies for autoparallel optimizer support.
# These coexist with the prop_rules above — strategies take precedence
# in the propagation path, while the prop_rules + custom handlers in
# _tp_conv.py continue to handle runtime dispatch.
@register_single_dim_strategy(
[aten.convolution.default],
schema_info=RuntimeSchemaInfo(2),
)
def convolution_single_dim_strategy(
op: torch._ops.OpOverload,
args_schema: tuple[Any, ...],
kwargs_schema: dict[str, Any],
) -> list[list[Placement | _ShardingPlaceholder]]:
bias_meta = args_schema[2]
# [output, input, weight, (bias)]
rule: list[Placement | _ShardingPlaceholder] = [
_ShardingPlaceholder(0), # output
_ShardingPlaceholder(0), # input
Replicate(), # weight
]
if bias_meta is not None:
rule.append(Replicate()) # bias
return [rule]
@register_single_dim_strategy(
[aten.convolution_backward.default],
schema_info=RuntimeSchemaInfo(3),
)
def convolution_backward_single_dim_strategy(
op: torch._ops.OpOverload,
args_schema: tuple[Any, ...],
kwargs_schema: dict[str, Any],
) -> list[list[Placement | _ShardingPlaceholder | None]]:
bias_sizes = args_schema[3]
has_bias = bias_sizes is not None
# outputs: [grad_input, grad_weight, grad_bias]
# inputs: [grad_output, input, weight]
rule: list[Placement | _ShardingPlaceholder | None] = [
_ShardingPlaceholder(0), # grad_input
Partial("sum"), # grad_weight
Partial("sum") if has_bias else None, # grad_bias
_ShardingPlaceholder(0), # grad_output
_ShardingPlaceholder(0), # input
Replicate(), # weight
]
return [rule]
@@ -0,0 +1,195 @@
import itertools
from dataclasses import dataclass
from torch.distributed.device_mesh import DeviceMesh
from torch.distributed.tensor._dtensor_spec import DTensorSpec
from torch.distributed.tensor._op_schema import OpSpec, OpStrategy
from torch.distributed.tensor.placement_types import (
Partial,
Placement,
Replicate,
Shard,
)
@dataclass
class EinsumDims:
contracting_dims: list[str]
batch_dims: list[str]
lhs_out_only_dims: list[str]
rhs_out_only_dims: list[str]
@classmethod
def parse_equation(cls, equation: str) -> tuple[list[str], str]:
# parse einop equation and extract arg specs
"""
Parse the einsum equation str to input dim chars and output dim char
"""
inputs, outputs = equation.split("->")
input_dims, output_dims = inputs.split(","), outputs.split(",")
# NOTE: only support at most two inputs, and single output
# extend to support more inputs if needed in future
if len(input_dims) > 2:
raise AssertionError("Only support at most two inputs")
if len(output_dims) != 1:
raise AssertionError("Only support single output")
output_dim = output_dims[0]
return input_dims, output_dim
@classmethod
def parse_dims(cls, input_dims: list[str], output_dim: str) -> "EinsumDims":
"""
Parse the dims and extract the contracting, batch, and free dimensions
for the left and right hand sides.
"""
dim_char_set: set[str] = set()
for input_dim in input_dims:
dim_char_set.update(input_dim)
# get a deterministic order of all dim chars
all_dim_chars = sorted(dim_char_set)
# parse input and output dimensions
lhs_out_only_dims, rhs_out_only_dims = [], []
batch_dims, contracting_dims = [], []
for dim_char in all_dim_chars:
if dim_char not in output_dim:
contracting_dims.append(dim_char)
else:
is_batch_dim = True
for input_dim in input_dims:
is_batch_dim = is_batch_dim and dim_char in input_dim
if is_batch_dim:
batch_dims.append(dim_char)
else:
if len(input_dims) != 2:
raise AssertionError(
"free dimension only supported for two inputs!"
)
lhs, rhs = input_dims
if dim_char in lhs:
lhs_out_only_dims.append(dim_char)
elif dim_char in rhs:
rhs_out_only_dims.append(dim_char)
else:
raise RuntimeError("Invalid dimension character")
return cls(
contracting_dims=contracting_dims,
batch_dims=batch_dims,
lhs_out_only_dims=lhs_out_only_dims,
rhs_out_only_dims=rhs_out_only_dims,
)
def gen_einsum_strategies(
equation: str,
mesh: DeviceMesh,
*,
linearity: bool = False,
) -> OpStrategy:
"""
Generate a strategy list for the ops that follow einsum style notation.
In principle, each mesh dim is independent of other device mesh dim when we
generate strategies. So we generate strategy over each device mesh dim and
do product combination on all mesh dims. We basically follow the below rule
for each device mesh dim:
1. Shard on contracting dim: When both inputs shard on contracting dim over
the same device dim. The result will be Partial over that device dim.
2. Shard on noncontracting dim:
2.1: Shard on batch dim: output, both inputs all should shard on batch
dim.
2.2: Shard on lhs only dim or rhs only dim: both output and lhs or rhs
input should shard on this free dim.
3. Linearity (Partial): If enabled, set Partial on output and inputs over
the same device mesh dim.
"""
# parse einop equation and extract dims
input_dims, output_dim = EinsumDims.parse_equation(equation)
edims = EinsumDims.parse_dims(input_dims, output_dim)
all_mesh_dim_strategies = []
# generate strategies for each mesh dim and do cartesian product for final strategy. E.g., for a 2D mesh, we can have [P(),R,R]
strategies_over_one_mesh_dim = []
# placement list stores placements of [output, input1, input2, ...]
# first we always have replicate all for inputs and output
placement_list: list[Placement] = [Replicate()] * (len(input_dims) + 1)
strategies_over_one_mesh_dim.append(placement_list)
# split batch dim
for batch_dim in edims.batch_dims:
output_batch_dim = output_dim.index(batch_dim)
placement_list = [Shard(output_batch_dim)]
for input_dim in input_dims:
input_batch_dim = input_dim.index(batch_dim)
placement_list.append(Shard(input_batch_dim))
strategies_over_one_mesh_dim.append(placement_list)
# split contracting dim
# NOTE: This is the only strategy that produces a Partial output, and it
# hardcodes Partial("sum"). No strategy accepts Partial as an input
# placement, so Partial inputs are always redistributed to Shard or
# Replicate. This means Partial("avg") inputs cannot be preserved through
# the op. Use gen_single_dim_einsum_strategies with per-input linearity
# for proper Partial support.
for contracting_dim in edims.contracting_dims:
# Contracting dim can shard on same device axis for both inputs. This
# results in the output being Partial on that device axis. For example:
# bmk_{x},k_{x}n -> bmn{Ux} (becomes partial over device axis x)
placement_list = [Partial()]
for input_dim in input_dims:
input_contracting_dim = input_dim.index(contracting_dim)
placement_list.append(Shard(input_contracting_dim))
strategies_over_one_mesh_dim.append(placement_list)
# split lhs free dim
for lhs_dim in edims.lhs_out_only_dims:
lhs_free_dim_output = output_dim.index(lhs_dim)
lhs_free_dim_input = input_dims[0].index(lhs_dim)
# this means split the lhs input and output
# i.e. S(0), R -> S(0)
lhs_placement_list: list[Placement] = [
Shard(lhs_free_dim_output),
Shard(lhs_free_dim_input),
Replicate(),
]
strategies_over_one_mesh_dim.append(lhs_placement_list)
# split rhs free dim
for rhs_dim in edims.rhs_out_only_dims:
rhs_free_dim_output = output_dim.index(rhs_dim)
rhs_free_dim_input = input_dims[1].index(rhs_dim)
rhs_placement_list: list[Placement] = [
Shard(rhs_free_dim_output),
Replicate(),
Shard(rhs_free_dim_input),
]
strategies_over_one_mesh_dim.append(rhs_placement_list)
# linearity strategy
if linearity:
linearity_placement_list: list[Placement] = [Partial()]
for _ in input_dims:
linearity_placement_list.append(Partial())
strategies_over_one_mesh_dim.append(linearity_placement_list)
# generate strategies for entire mesh
all_mesh_dim_strategies = [strategies_over_one_mesh_dim] * mesh.ndim
strategy_combs = itertools.product(*all_mesh_dim_strategies)
all_strategies = []
for strategy_comb in strategy_combs:
spec_list = [DTensorSpec(mesh, tuple(specs)) for specs in zip(*strategy_comb)]
strat = OpSpec(output_specs=spec_list[0], input_specs=spec_list[1:])
all_strategies.append(strat)
return OpStrategy(all_strategies)
@@ -0,0 +1,117 @@
# mypy: allow-untyped-defs
# Copyright (c) Meta Platforms, Inc. and affiliates
# implement matrix related ops for distributed tensor
from typing import cast
import torch
from torch.distributed.tensor._op_schema import (
OpSchema,
OpStrategy,
PlacementList,
RuntimeSchemaInfo,
StrategyType,
)
from torch.distributed.tensor._ops.utils import (
expand_to_full_mesh_op_strategy,
register_op_strategy,
)
from torch.distributed.tensor.placement_types import (
_MaskPartial,
Partial,
Replicate,
Shard,
)
aten = torch.ops.aten
@register_op_strategy(aten.embedding.default)
def embedding_strategy(op_schema: OpSchema) -> StrategyType:
"""
This strategy handles embedding op. We have two possible embedding shardings:
rowwise and colwise
"""
weight_strategy = cast(OpStrategy, op_schema.args_schema[0])
indices_strategy = cast(OpStrategy, op_schema.args_schema[1])
mesh = op_schema.get_mesh_from_args()
weight_shape = weight_strategy.shape
indices_shape = indices_strategy.shape
output_emd_dim = len(indices_shape)
single_mesh_dim_strategies = []
# placement list stores placements of [output, weight, input_indices]
# first we always have replicate all for inputs and output
all_replicate: PlacementList = [Replicate()] * 3
single_mesh_dim_strategies.append(all_replicate)
# colwise sharding, output shard on last dim, weight shard on dim 1, input replicate
colwise_sharding: PlacementList = [Shard(output_emd_dim), Shard(1), Replicate()]
single_mesh_dim_strategies.append(colwise_sharding)
# rowwise sharding, output is embedding partial, weight shard on dim 0, input accepts embedding partial
embedding_partial_placement = _MaskPartial(offset_shape=weight_shape, offset_dim=0)
# NOTE we want to reuse the same mask partial placement so that we can reuse the same mask that generates
# from the input indices and use it for output reduction
rowwise_sharding: PlacementList = [
embedding_partial_placement,
Shard(0),
embedding_partial_placement,
]
single_mesh_dim_strategies.append(rowwise_sharding)
# batch dim sharding, weight replicated, input can shard on any dim, output follows input
for input_dim in range(len(indices_shape)):
batch_sharding: PlacementList = [
Shard(input_dim),
Replicate(),
Shard(input_dim),
]
single_mesh_dim_strategies.append(batch_sharding)
return expand_to_full_mesh_op_strategy(mesh, op_schema, single_mesh_dim_strategies)
@register_op_strategy(
aten.embedding_dense_backward.default,
schema_info=RuntimeSchemaInfo(static_argnum=2),
)
def embedding_dense_backward_strategy(op_schema: OpSchema) -> StrategyType:
"""
This strategy handles embedding op. We have two possible embedding shardings:
rowwise and colwise
"""
grad_out_strategy = cast(OpStrategy, op_schema.args_schema[0])
indices_strategy = cast(OpStrategy, op_schema.args_schema[1])
mesh = op_schema.get_mesh_from_args()
grad_out_shape = grad_out_strategy.shape
indices_shape = indices_strategy.shape
grad_out_ndim = len(grad_out_shape)
single_mesh_dim_strategies = []
# placement list stores placements of [output, weight, input_indices]
# first we always have replicate all for inputs and output
all_replicate: PlacementList = [Replicate()] * 3
single_mesh_dim_strategies.append(all_replicate)
# colwise sharding backward, grad_out shard on last dim, input replicate,
# weight grad shard colwise
colwise_sharding: PlacementList = [Shard(1), Shard(grad_out_ndim - 1), Replicate()]
single_mesh_dim_strategies.append(colwise_sharding)
# batch dim sharding, weight replicated, grad_out/input have same sharding
# that can shard on any dim, weight grad partial
for input_dim in range(len(indices_shape)):
batch_sharding: PlacementList = [Partial(), Shard(input_dim), Shard(input_dim)]
single_mesh_dim_strategies.append(batch_sharding)
# grad_out partial, input replicate, weight grad keep partial
partial_sharding: PlacementList = [Partial(), Partial(), Replicate()]
single_mesh_dim_strategies.append(partial_sharding)
return expand_to_full_mesh_op_strategy(mesh, op_schema, single_mesh_dim_strategies)
@@ -0,0 +1,44 @@
# mypy: allow-untyped-defs
# Copyright (c) Meta Platforms, Inc. and affiliates
from dataclasses import dataclass
import torch
@dataclass
class MaskBuffer:
data: torch.Tensor | None = None
# refcount allows shared usage of the MaskBuffer, as long as all users have the same data
refcount: int = 0
def materialize_mask(self, mask):
if self.refcount == 0:
self.data = mask
else:
if self.data is None:
raise AssertionError
if not torch.equal(self.data, mask):
raise RuntimeError(
"MaskBuffer has been materialized with conflicting data"
)
self.refcount += 1
def release_mask(self):
if self.refcount == 0 or self.data is None:
raise RuntimeError("MaskBuffer has not been materialized")
self.refcount -= 1
if self.refcount == 0:
self.data = None
def apply_mask(self, tensor):
if self.refcount == 0 or self.data is None:
raise RuntimeError("MaskBuffer has not been materialized")
# NOTE: MaskPartial is being used by the embedding op and the gather op.
# For gather, the mask has the same dimension as the output tensor, whereas
# the output of the embedding op has an additional dimension compare to the input,
# hence the output masking logic below having two different cases.
if tensor.ndim == self.data.ndim:
tensor[self.data] = 0.0
else:
tensor[self.data, :] = 0.0
@@ -0,0 +1,592 @@
# Copyright (c) Meta Platforms, Inc. and affiliates
from collections.abc import Callable
import torch
from torch._ops import OpOverload
from torch.distributed.tensor._dtensor_spec import TensorMeta
from torch.distributed.tensor._op_schema import ArgsType, KwargsType, RuntimeSchemaInfo
from torch.distributed.tensor._ops.single_dim_strategy import (
_ShardingPlaceholder,
register_single_dim_strategy,
)
from torch.distributed.tensor._ops.utils import infer_broadcast_dims_map
from torch.distributed.tensor.placement_types import Partial, Placement, Replicate
aten = torch.ops.aten
prims = torch.ops.prims
# Linear pointwise ops, split by linearity type.
unary_linear_ops = [aten.to.dtype]
def _common_pointwise_single_dim_strategy(
partial_extra_rules: list[list[Placement | _ShardingPlaceholder]] | None = None,
) -> Callable[
[OpOverload, ArgsType, KwargsType], list[list[Placement | _ShardingPlaceholder]]
]:
"""Factory for single-dim strategies that add partial placement rules.
Returns strategies shaped [output, *args] only. Tensor kwarg placements
(e.g. ``out``, ``lr``) are appended by the wrapper in
``_register_single_dim_pointwise``.
"""
def strategy(
op: OpOverload,
args_schema: ArgsType,
kwargs_schema: KwargsType,
) -> list[list[Placement | _ShardingPlaceholder]]:
tensor_arg_metas: list[TensorMeta] = [
arg for arg in args_schema if isinstance(arg, TensorMeta)
]
common_shape = torch.broadcast_shapes(
*[arg.shape for arg in args_schema if isinstance(arg, TensorMeta)]
)
# For multi-output ops (e.g. frexp), all outputs share the same
# pointwise sharding, so replicate the output placement.
num_outputs = sum(1 for r in op._schema.returns if "Tensor" in str(r.type))
placements: list[list[Placement | _ShardingPlaceholder]] = []
for i in range(len(common_shape)):
shard_placements: list[Placement | _ShardingPlaceholder] = [
_ShardingPlaceholder(i)
] * num_outputs
for arg in tensor_arg_metas:
common_dim_to_arg_dim = infer_broadcast_dims_map(
common_shape, arg.shape
)
# If the output shard dim maps to an input dim, shard that
# input dim; otherwise it was broadcast, so replicate.
if common_dim_to_arg_dim[i] >= 0:
shard_placements.append(
_ShardingPlaceholder(common_dim_to_arg_dim[i])
)
else:
shard_placements.append(Replicate())
placements.append(shard_placements)
if partial_extra_rules:
n_tensors = len(tensor_arg_metas)
expected_len = num_outputs + n_tensors
for rule in partial_extra_rules:
# Filter rather than assert: some ops (e.g. mul.Tensor) mix
# unary rules (len 2, for scalar promotion) and binary rules
# (len 3, for tensor-tensor), so mismatched lengths are expected.
# see _MUL_RULES to see how _UNARY_LINEAR_RULES handles the
# scalar promotion case
if len(rule) == expected_len:
placements.append(rule)
return placements
return strategy
def _is_list_op(op: OpOverload) -> bool:
"""Returns True if op is a foreach, amp_foreach, or fused op."""
name = op.name()
return name.startswith(("aten::_foreach_", "aten::_amp_foreach_", "aten::_fused_"))
# The state_steps arg of fused adam / adamw is a Replicate scalar tensor, which will be put on
# the compute_mesh of an op across all parameter groups, even when not all parameter groups
# are on the same device mesh. This idx will help avoid hitting exceptions or unnecessary
# redistribute during sharding propagation.
_FUSED_OP_SCALAR_IDX = 5
# Ops registered with extra Partial rules; populated by _register_single_dim_pointwise
# when partial_extra_rules is not None, to avoid double-registration from tag discovery.
_specially_registered_ops: set[OpOverload] = set()
def _register_single_dim_pointwise(
op: OpOverload,
partial_extra_rules: list[list[Placement]] | None = None,
static_argnum: int = 0,
) -> None:
if partial_extra_rules is not None:
_specially_registered_ops.add(op)
inner_fn = _common_pointwise_single_dim_strategy(
partial_extra_rules=partial_extra_rules # pyrefly: ignore[bad-argument-type]
)
# Wrap to append tensor kwarg placements in schema declaration order.
# out = output placement (s[0]); everything else (e.g. lr) = Replicate.
# TODO: move kwargs handling upstream if this works
def strategy_fn(
op: OpOverload,
args: ArgsType,
kwargs: KwargsType,
_fn: Callable = inner_fn,
) -> list[list[Placement | _ShardingPlaceholder]]:
strategies = _fn(op, args, kwargs)
kw_names = [k for k, v in kwargs.items() if isinstance(v, TensorMeta)]
if not kw_names:
return strategies
return [
s + [s[0] if name == "out" else Replicate() for name in kw_names]
for s in strategies
]
if _is_list_op(op):
schema_info = RuntimeSchemaInfo(needs_pytree=True)
else:
schema_info = RuntimeSchemaInfo(static_argnum, static_kwargkey=["out"])
# Fused ops (e.g. _fused_adam_) have state_steps on a potentially different
# mesh; see the note in expand_to_full_mesh_op_strategy for details.
different_mesh_args: list[int] | None = None
if op.name().startswith("aten::_fused_"):
different_mesh_args = [_FUSED_OP_SCALAR_IDX]
register_single_dim_strategy(
op,
schema_info=schema_info,
allow_uneven_sharding=True,
allow_unbacked_sharding=True,
different_mesh_args=different_mesh_args,
)(strategy_fn)
_UNARY_LINEAR_RULES: list[list[Placement]] = [
[Partial("sum"), Partial("sum")],
[Partial("avg"), Partial("avg")],
]
binary_additive_ops = [
aten.add.Tensor,
aten.add_.Tensor,
aten.add.out,
aten.sub.Tensor,
aten.sub_.Tensor,
aten.sub.out,
# foreach variants
aten._foreach_add.List,
aten._foreach_add_.List,
aten._foreach_sub.List,
aten._foreach_sub_.List,
]
_BINARY_ADDITIVE_RULES: list[list[Placement]] = [
[Partial("sum"), Partial("sum"), Partial("sum")],
[Partial("avg"), Partial("avg"), Partial("avg")],
# P(x), R -> P(x): adding/subtracting a replicated value preserves partial types
# avg, max, min. sum would result in R being added n times, n = num_ranks
# (the replicated value is constant across ranks, so reduce order is unaffected)
[Partial("avg"), Partial("avg"), Replicate()],
[Partial("max"), Partial("max"), Replicate()],
[Partial("min"), Partial("min"), Replicate()],
# R, P(avg) -> P(avg): avg is linear so this holds for any alpha
# (R, P(max/min) excluded: negative alpha would flip the ordering)
[Partial("avg"), Replicate(), Partial("avg")],
]
for op in binary_additive_ops:
_register_single_dim_pointwise(op, _BINARY_ADDITIVE_RULES)
# mul: partials propagate through either arg. div: only through numerator.
binary_mul_ops = [
aten.mul.Tensor,
aten.mul_.Tensor,
aten.mul.out,
# foreach variants
aten._foreach_mul.List,
aten._foreach_mul_.List,
aten._foreach_mul.Tensor,
aten._foreach_mul_.Tensor,
]
binary_div_ops = [
aten.div.Tensor,
aten.div_.Tensor,
aten.div.out,
# foreach variants
aten._foreach_div.List,
aten._foreach_div_.List,
aten._foreach_div.Tensor,
aten._foreach_div_.Tensor,
]
# _UNARY_LINEAR_RULES handles the scalar promotion case: Python's __mul__/__truediv__
# promote scalars to 0-dim tensors, so aten.mul.Scalar dispatches as aten.mul.Tensor
# with n_tensors=1, matching the length-2 unary rules.
_MUL_RULES: list[list[Placement]] = [
[Partial("sum"), Partial("sum"), Replicate()],
[Partial("avg"), Partial("avg"), Replicate()],
[Partial("sum"), Replicate(), Partial("sum")],
[Partial("avg"), Replicate(), Partial("avg")],
]
_DIV_RULES: list[list[Placement]] = [
[Partial("sum"), Partial("sum"), Replicate()],
[Partial("avg"), Partial("avg"), Replicate()],
]
for op in binary_mul_ops:
_register_single_dim_pointwise(op, _UNARY_LINEAR_RULES + _MUL_RULES)
for op in binary_div_ops:
_register_single_dim_pointwise(op, _UNARY_LINEAR_RULES + _DIV_RULES)
scalar_linear_ops = [
aten.div.Scalar,
aten.div_.Scalar,
aten.mul.Scalar,
aten.mul_.Scalar,
# foreach variants
aten._foreach_div.Scalar,
aten._foreach_div_.Scalar,
aten._foreach_mul.Scalar,
aten._foreach_mul_.Scalar,
aten._foreach_div.ScalarList,
aten._foreach_div_.ScalarList,
aten._foreach_mul.ScalarList,
aten._foreach_mul_.ScalarList,
]
for op in scalar_linear_ops:
_register_single_dim_pointwise(op, _UNARY_LINEAR_RULES, static_argnum=1)
# Non-decreasing unary ops: f(max(a,b)) = max(f(a),f(b)).
# Only ops that are non-decreasing on their ENTIRE domain belong here.
# Ops with restricted domains (e.g. log on (0,∞), asin on [-1,1]) do NOT qualify
# because P(max) offsets can push inputs outside the valid domain.
non_decreasing_unary_ops = [
aten.asinh.default,
aten.asinh_.default,
aten.asinh.out,
aten.atan.default,
aten.atan_.default,
aten.atan.out,
aten.ceil.default,
aten.ceil_.default,
aten.ceil.out,
aten.deg2rad.default,
aten.deg2rad_.default,
aten.deg2rad.out,
aten.erf.default,
aten.erf_.default,
aten.erf.out,
aten.exp.default,
aten.exp_.default,
aten.exp.out,
aten.exp2.default,
aten.exp2_.default,
aten.exp2.out,
aten.expm1.default,
aten.expm1_.default,
aten.expm1.out,
aten.floor.default,
aten.floor_.default,
aten.floor.out,
aten.rad2deg.default,
aten.rad2deg_.default,
aten.rad2deg.out,
aten.relu.default,
aten.relu_.default,
aten.round.decimals,
aten.round.default,
aten.round_.decimals,
aten.round_.default,
aten.round.decimals_out,
aten.round.out,
aten.sgn.default,
aten.sgn_.default,
aten.sgn.out,
aten.sigmoid.default,
aten.sigmoid_.default,
aten.sigmoid.out,
aten.sign.default,
aten.sign_.default,
aten.sign.out,
aten.sinh.default,
aten.sinh_.default,
aten.sinh.out,
aten.tanh.default,
aten.tanh_.default,
aten.tanh.out,
aten.trunc.default,
aten.trunc_.default,
aten.trunc.out,
# nan_to_num is non-decreasing on its entire domain (including nan/inf):
# it maps -inf→min, nan→0, inf→max, and is identity elsewhere.
aten.nan_to_num.default,
aten.nan_to_num_.default,
aten.nan_to_num.out,
# hardshrink: x if |x|>lambd else 0. Non-decreasing on entire domain.
aten.hardshrink.default,
# I1(x) is monotonically non-decreasing for all real x.
aten.special_modified_bessel_i1.default,
# threshold(x, t, v): x if x > t else v. Non-decreasing for v <= t (the
# common case, including the default v=0, t=0).
aten.threshold.default,
# foreach variants
aten._foreach_exp.default,
aten._foreach_exp_.default,
aten._foreach_clamp_max_.Scalar,
aten._foreach_clamp_min_.Scalar,
]
_NON_DECREASING_RULES: list[list[Placement]] = [
[Partial("max"), Partial("max")],
[Partial("min"), Partial("min")],
]
for op in non_decreasing_unary_ops:
_register_single_dim_pointwise(op, _NON_DECREASING_RULES)
# Non-increasing unary ops: f(max(a,b)) = min(f(a),f(b)).
# Note: acos excluded due to domain constraints [-1,1] causing validation failures
non_increasing_unary_ops: list[OpOverload] = [
aten.erfc.default,
aten.erfc_.default,
aten.erfc.out,
aten.special_erfcx.default,
aten.special_erfcx.out,
]
_NON_INCREASING_RULES: list[list[Placement]] = [
[Partial("min"), Partial("max")],
[Partial("max"), Partial("min")],
]
for op in non_increasing_unary_ops:
_register_single_dim_pointwise(op, _NON_INCREASING_RULES)
# Bessel K functions are strictly decreasing for x > 0 but undefined at x <= 0.
# Only P(min)->P(max) is safe: P(min) offsets add positive values to the
# non-holding rank, keeping all inputs positive. P(max) offsets subtract,
# which can push inputs to x <= 0 producing NaN.
_POSITIVE_DOMAIN_NON_INCREASING_RULES: list[list[Placement]] = [
[Partial("max"), Partial("min")],
]
for op in [
aten.special_modified_bessel_k0.default,
aten.special_modified_bessel_k1.default,
aten.special_scaled_modified_bessel_k0.default,
aten.special_scaled_modified_bessel_k1.default,
]:
_register_single_dim_pointwise(op, _POSITIVE_DOMAIN_NON_INCREASING_RULES)
# neg is linear: -(A1 + A2) = -A1 + -A2
neg_ops = [
aten.neg.default,
aten.neg_.default,
aten.neg.out,
# foreach variants
aten._foreach_neg.default,
aten._foreach_neg_.default,
]
_NEG_RULES: list[list[Placement]] = _UNARY_LINEAR_RULES + _NON_INCREASING_RULES
for op in neg_ops:
_register_single_dim_pointwise(op, _NEG_RULES)
# xlog1py(x, y) = x * log1p(y). Linear in x with y replicated:
# (a+b)*log1p(y) = a*log1p(y) + b*log1p(y).
_XLOG1PY_RULES: list[list[Placement]] = [
[Partial("sum"), Partial("sum"), Replicate()],
[Partial("avg"), Partial("avg"), Replicate()],
]
for op in [aten.special_xlog1py.default, aten.special_xlog1py.other_scalar]:
_register_single_dim_pointwise(op, _XLOG1PY_RULES)
# All-partial-preserving unary ops: P(x)->P(x) for all x.
# TODO: positive should be removed once CIA (Copy Is All) optimizes it away.
all_partial_preserving_unary_ops = [
aten.to.dtype,
aten.positive.default,
]
_ALL_PARTIAL_PRESERVING_RULES: list[list[Placement]] = [
[Partial(r), Partial(r)] for r in ("sum", "avg", "max", "min")
]
for op in all_partial_preserving_unary_ops:
_register_single_dim_pointwise(op, _ALL_PARTIAL_PRESERVING_RULES)
all_partial_preserving_binary_ops = [
aten.copy_.default,
prims.copy_to.default,
]
_ALL_PARTIAL_BINARY_PRESERVING_RULES: list[list[Placement]] = [
[Partial(r), Partial(r), Partial(r)] for r in ("sum", "avg", "max", "min")
]
for op in all_partial_preserving_binary_ops:
_register_single_dim_pointwise(op, _ALL_PARTIAL_BINARY_PRESERVING_RULES)
# Monotonic increasing in both args but don't preserve any specific partial type.
monotonic_binary_ops = [
aten.logaddexp.default,
aten.logaddexp.out,
aten.logaddexp2.default,
aten.logaddexp2.out,
]
_MONOTONE_BINARY_BASE_RULES: list[list[Placement]] = [
[Partial("max"), Partial("max"), Replicate()],
[Partial("max"), Replicate(), Partial("max")],
[Partial("min"), Partial("min"), Replicate()],
[Partial("min"), Replicate(), Partial("min")],
]
for op in monotonic_binary_ops:
_register_single_dim_pointwise(op, _MONOTONE_BINARY_BASE_RULES)
# Binary ops monotonically increasing in both arguments.
# max-preserving: P(max)+P(max)->P(max) because max(max(a),max(b)) = max(a,b)
monotonic_max_preserving_binary_ops = [
aten.clamp_min.Tensor,
aten.fmax.default,
aten.fmax.out,
aten.maximum.default,
aten.maximum.out,
prims.fmax.default,
# foreach variants
aten._foreach_maximum_.List,
]
_MONOTONE_MAX_PRESERVING_BINARY_BASE_RULES: list[list[Placement]] = [
*_MONOTONE_BINARY_BASE_RULES,
[Partial("max"), Partial("max"), Partial("max")],
]
for op in monotonic_max_preserving_binary_ops:
_register_single_dim_pointwise(op, _MONOTONE_MAX_PRESERVING_BINARY_BASE_RULES)
# min-preserving: P(min)+P(min)->P(min) because min(min(a),min(b)) = min(a,b)
monotonic_min_preserving_binary_ops = [
aten.clamp_max.Tensor,
aten.fmin.default,
aten.fmin.out,
aten.minimum.default,
aten.minimum.out,
prims.fmin.default,
]
_MONOTONE_MIN_PRESERVING_BINARY_BASE_RULES: list[list[Placement]] = [
*_MONOTONE_BINARY_BASE_RULES,
[Partial("min"), Partial("min"), Partial("min")],
]
for op in monotonic_min_preserving_binary_ops:
_register_single_dim_pointwise(op, _MONOTONE_MIN_PRESERVING_BINARY_BASE_RULES)
# Ops that are pointwise for DTensor purposes but lack torch.Tag.pointwise.
# TODO(pianpwk): add torch.Tag.pointwise to these ops in native_functions.yaml
# so this list can be removed.
_extra_pointwise_ops: list[OpOverload] = [
aten.__irshift__.Scalar,
aten.__irshift__.Tensor,
aten._conj.default,
aten.abs_.default,
aten.copysign_.Scalar,
aten.copysign_.Tensor,
aten.exponential_.default,
aten.float_power.Scalar,
aten.float_power.Scalar_out,
aten.float_power.Tensor_Scalar,
aten.float_power.Tensor_Scalar_out,
aten.float_power.Tensor_Tensor,
aten.float_power.Tensor_Tensor_out,
aten.masked_fill_.Scalar,
aten.native_dropout_backward.out,
aten.polygamma_.default,
aten.rrelu_with_noise.default,
aten.where.self_out,
aten.xlogy_.Scalar_Other,
prims.bessel_i0e.default,
prims.bessel_i1.default,
prims.bessel_i1e.default,
prims.bessel_j0.default,
prims.bessel_j1.default,
prims.div.default,
prims.erfcx.default,
prims.frexp.default,
prims.gcd.default,
prims.ndtri.default,
prims.ne.default,
prims.spherical_bessel_j0.default,
prims.zeta.default,
# foreach variants
aten._foreach_abs.default,
aten._foreach_abs_.default,
aten._foreach_addcdiv_.Scalar,
aten._foreach_addcdiv_.ScalarList,
aten._foreach_addcdiv_.Tensor,
aten._foreach_addcmul.Scalar,
aten._foreach_addcmul_.Scalar,
aten._foreach_addcmul_.ScalarList,
aten._foreach_addcmul_.Tensor,
aten._foreach_lerp_.Scalar,
aten._foreach_pow.List,
aten._foreach_pow.ScalarList,
aten._foreach_reciprocal_.default,
aten._foreach_sub.Scalar,
aten._foreach_sub_.Scalar,
aten._foreach_sub.ScalarList,
aten._foreach_sub_.ScalarList,
aten._foreach_sqrt.default,
aten._foreach_sqrt_.default,
aten._foreach_zero_.default,
aten._foreach_cos.default,
aten._foreach_cos_.default,
aten._foreach_log.default,
aten._foreach_log_.default,
aten._amp_foreach_non_finite_check_and_unscale_.default,
# foreach linearity variants
aten._foreach_add.Scalar,
aten._foreach_add_.Scalar,
aten._foreach_add_.ScalarList,
# fused optimizer ops
aten._fused_adam_.default,
aten._fused_adam.default,
aten._fused_adam.tensor_lr,
aten._fused_adam_.tensor_lr,
aten._fused_adamw_.default,
aten._fused_adamw.default,
aten._fused_adamw.tensor_lr,
aten._fused_adamw_.tensor_lr,
]
def _get_pointwise_ops_from_tag() -> list[OpOverload]:
"""
Auto-discover pointwise ops via torch.Tag.pointwise, from ops.aten, ops.prims.
"""
ops = []
for ns in [torch.ops.aten, torch.ops.prims]:
for attr_name in dir(ns):
attr = getattr(ns, attr_name)
if isinstance(attr, torch._ops.OpOverloadPacket):
for overload_name in attr.overloads():
op = getattr(attr, overload_name)
if torch.Tag.pointwise in op.tags:
ops.append(op)
return ops
pointwise_ops = [
op
for op in _get_pointwise_ops_from_tag() + _extra_pointwise_ops
if op not in _specially_registered_ops
]
for op in pointwise_ops:
_register_single_dim_pointwise(op)
def register_inductor_prims() -> None:
"""Register DTensor sharding strategies for inductor prims ops.
Called lazily because inductor prims are created via make_prim() in
torch._inductor.inductor_prims, which is imported after this module.
"""
# TODO: handle other inductor prims ops that may need DTensor sharding
# strategies (e.g. mul_rn, div_rn). Those are more complicated and not
# necessarily pointwise.
_register_single_dim_pointwise(prims.fma.default)
@@ -0,0 +1,43 @@
# Copyright (c) Meta Platforms, Inc. and affiliates
import torch
from torch.distributed.tensor._op_schema import (
OpSchema,
OpSpec,
OpStrategy,
StrategyType,
)
from torch.distributed.tensor._ops.utils import is_tensor_partial, register_op_strategy
aten = torch.ops.aten
@register_op_strategy(
[
aten.normal_.default,
aten.uniform_.default,
aten.native_dropout.default,
aten.bernoulli_.float,
aten.bernoulli.default,
]
)
def random_op_strategy(op_schema: OpSchema) -> StrategyType:
self_strategy = op_schema.args_schema[0]
if not isinstance(self_strategy, OpStrategy):
raise AssertionError
random_strategy = OpStrategy([])
for arg_strategy in self_strategy.strategies:
arg_spec = arg_strategy.output_spec
if is_tensor_partial(arg_spec):
# TODO: figure out how inplace random op should behave when it's partial
raise RuntimeError(f"{op_schema.op} with Partial is not supported yet!")
random_strategy.strategies.append(
OpSpec(
output_specs=arg_spec,
input_specs=(arg_spec,),
redistribute_cost=[[0.0] * len(self_strategy.strategies)],
)
)
return random_strategy
@@ -0,0 +1,671 @@
# mypy: allow-untyped-defs
# Copyright (c) Meta Platforms, Inc. and affiliates
import functools
import itertools
import operator
from collections.abc import Callable, Iterable, Sequence
from typing import TypeAlias, TypeVar
import torch
from torch._prims_common import DimsSequenceType, DimsType
from torch.distributed.tensor._api import DTensor
from torch.distributed.tensor._collective_utils import redistribute_cost
from torch.distributed.tensor._dtensor_spec import DTensorSpec, TensorMeta
from torch.distributed.tensor._op_schema import (
OpSchema,
OpSpec,
OpStrategy,
OutputSharding,
PlacementList,
RuntimeSchemaInfo,
StrategyType,
)
from torch.distributed.tensor.device_mesh import DeviceMesh
from torch.distributed.tensor.placement_types import (
_is_shard_like,
_StridedShard,
Partial,
Placement,
Replicate,
Shard,
)
def _get_registration_wrapper(
registration_fn,
op: torch._ops.OpOverload | list[torch._ops.OpOverload],
schema_info: RuntimeSchemaInfo | None,
arg_names_that_require_specializing_cache_strategy: list[str] | None,
):
def wrapper(impl):
overloads = op if isinstance(op, list) else [op]
for overload in overloads:
curr_schema_info = None
if (
schema_info is None
and arg_names_that_require_specializing_cache_strategy is not None
):
specialized_args = [
a.name
for a in overload._schema.arguments
if a.name in arg_names_that_require_specializing_cache_strategy
]
if any(specialized_args):
curr_schema_info = RuntimeSchemaInfo(
static_kwargkey=specialized_args
)
else:
curr_schema_info = schema_info
registration_fn(overload, impl, curr_schema_info)
return impl
return wrapper
# convenient wrapper to register sharding propagation rules
def register_prop_rule(
op: torch._ops.OpOverload | list[torch._ops.OpOverload],
schema_info: RuntimeSchemaInfo | None = None,
) -> Callable[
[Callable[[OpSchema], OutputSharding]], Callable[[OpSchema], OutputSharding]
]:
return _get_registration_wrapper(
DTensor._op_dispatcher.sharding_propagator.register_sharding_prop_rule,
op,
schema_info,
arg_names_that_require_specializing_cache_strategy=None,
)
# Note:
# using TypeVar here allows the registration decorator to preserve the specific type info of the wrapped strategy,
# while hardcoding the typing on the wrapper (e.g. Callable[[OpSchema], StrategyType]) would mean mypy would treat
# the return value of the wrapped strategy as always being a `StrategyType` even if it were a derived class like
# MyStrategyType(StrategyType).
_OpSchemaT = TypeVar("_OpSchemaT", bound=OpSchema)
_StrategyTypeT = TypeVar("_StrategyTypeT", bound=StrategyType)
_ShardingStrategyFunc: TypeAlias = Callable[[_OpSchemaT], _StrategyTypeT]
def register_op_strategy(
op: torch._ops.OpOverload | list[torch._ops.OpOverload],
schema_info: RuntimeSchemaInfo | None = None,
) -> Callable[[_ShardingStrategyFunc], _ShardingStrategyFunc]:
# For every ATen op that accepts any args in this list,
# the arg itself can impact the strides (and potentially the sharding strategy)
# of the output tensor.
# thus, we will detect ATen schemas with any of these args and ensure
# that they get specialized here.
arg_names_that_require_specializing_cache_strategy = [
"memory_format",
]
return _get_registration_wrapper(
DTensor._op_dispatcher.sharding_propagator.register_op_strategy,
op,
schema_info,
arg_names_that_require_specializing_cache_strategy,
)
def replicate_op_strategy(op_schema: OpSchema) -> StrategyType:
"""
Fallback strategy all use Replication()
"""
args_strategy = op_schema.args_strategy
kwargs_strategy = op_schema.kwargs_strategy
inputs_strategy = args_strategy + kwargs_strategy
output_type = [str(ret.type) for ret in op_schema.op._schema.returns]
output_len = output_type.count("Tensor")
# TODO(zpcore): Confirm if view op can be handle properly or not. Prevent
# handling view ops until confirmed.
if op_schema.op.is_view:
raise RuntimeError(
"fallback strategy is unable to handle view ops until confirmed"
)
if "List[Tensor]" in output_type:
raise RuntimeError(
"fallback strategy is unable to handle ops with List[Tensor] output "
"because size of the list may depend on the op's input value"
)
mesh = inputs_strategy[0].mesh
dim_sharding: PlacementList = [Replicate()] * (output_len + len(inputs_strategy))
single_dim_placement = [dim_sharding]
return expand_to_full_mesh_op_strategy(
mesh, op_schema, single_dim_placement, input_index=output_len
)
def as_list(
x: list[object] | object,
# pyre-fixme[11]: Annotation `immutable_list` is not defined as a type.
) -> list[object] | torch.fx.immutable_collections.immutable_list: # type: ignore[valid-type]
# During tracing, `aten.sum.dim_IntList` uses `immutable_list` for its args,
# which is an object but treated as a list by the tracer. Therefore, keep
# `immutable_list` intact here as well.
if type(x) is list or isinstance(x, torch.fx.immutable_collections.immutable_list):
return x
else:
return [x]
def normalize_dim(dim: int, ndim: int) -> int:
return dim if dim >= 0 else dim + ndim
def normalize_dims(dims: DimsType, ndim: int) -> DimsSequenceType:
"""Normalize a dim or a sequence of dims, so that they are all positive."""
if isinstance(dims, int):
dims = (normalize_dim(dims, ndim),)
elif isinstance(dims, list):
dims = [normalize_dim(dim, ndim) for dim in dims]
elif isinstance(dims, tuple):
dims = tuple(normalize_dim(dim, ndim) for dim in dims)
return dims
def prod(xs: Iterable[int]) -> int:
return functools.reduce(operator.mul, xs, 1)
def is_tensor_shardable(
shape: Sequence[int],
spec: DTensorSpec,
allow_unbacked_sharding: bool | None = None,
) -> bool:
"""
Check if the shape is shardable according to the spec.
This function handles both `Shard` and `_StridedShard` placements:
- For `Shard`: checks if the tensor dimension size >= number of shards
- For `_StridedShard`: additionally checks if the dimension is shardable after
splitting with the placement's `split_factor`
allow_unbacked_sharding: determines the fallback value if unbacked shapes are involved,
and the queried shape properties are not statically known.
e.g. when asking if u0 is shardable on num_shards, and u0 has generic bounds [0, inf],
the behavior of allow_unbacked_sharding is:
None: will data-dependent error
True: assumes shardability; we return True, allowing zero-size shards at runtime when u0 < num_shards.
False: returns False, and lower-bounding u0, e.g. torch._check(u0 >= num_shards), is needed to enable sharding.
"""
from torch.fx.experimental.symbolic_shapes import guard_or_false, guard_or_true
if allow_unbacked_sharding not in [None, True, False]:
raise AssertionError
guard_fn = {
None: bool,
True: guard_or_false,
False: guard_or_true,
}[allow_unbacked_sharding]
# number of shards in each tensor dimension
num_shards = [1] * len(shape)
for i, placement in enumerate(spec.placements):
if _is_shard_like(placement):
shard_dim = placement.dim
if shard_dim >= len(shape):
return False
num_shards[shard_dim] *= spec.mesh.size(i)
if isinstance(placement, _StridedShard):
# make sure tensor dim `shard_dim` is shardable after splitting
# with split_factor
if guard_fn(
shape[shard_dim] < num_shards[shard_dim] * placement.split_factor
):
return False
else:
if guard_fn(shape[shard_dim] < num_shards[shard_dim]):
return False
return True
def is_tensor_evenly_shardable(shape: Sequence[int], spec: DTensorSpec) -> bool:
"""Check if the shape is evenly shardable according to the spec."""
# number of shards in each tensor dimension
num_shards = [1] * len(shape)
for i, placement in enumerate(spec.placements):
if _is_shard_like(placement):
shard_dim = placement.dim
if shard_dim >= len(shape):
return False
num_shards[shard_dim] *= spec.mesh.size(i)
if isinstance(placement, _StridedShard):
if (
shape[shard_dim] % (placement.split_factor * num_shards[shard_dim])
!= 0
):
return False
else:
if shape[shard_dim] % num_shards[shard_dim] != 0:
return False
return True
def is_tensor_evenly_shardable_on_dim(
shape: Sequence[int], spec: DTensorSpec, dim: int
) -> bool:
"""Check if the shape is evenly shardable according to the spec on dim."""
dim = normalize_dim(dim, len(shape))
num_shards = 1
for i, placement in enumerate(spec.placements):
if _is_shard_like(placement) and placement.dim == dim:
num_shards *= spec.mesh.size(i)
if isinstance(placement, _StridedShard):
# _StridedShard._split_tensor first chunks into split_factor
# groups, then into num_shards within each group, so the dim
# must be divisible by the product of both. This is stricter
# than the final num_shards check and implies it. Note:
# num_shards already includes spec.mesh.size(i) from this
# iteration, so the check covers the full shard count.
if shape[dim] % (placement.split_factor * num_shards) != 0:
return False
return shape[dim] % num_shards == 0
def is_tensor_dim_sharded(spec: DTensorSpec, dim: int) -> bool:
"""Return True if tensor dim is sharded."""
return any(_is_shard_like(p) and p.dim == dim for p in spec.placements)
def is_tensor_partial(spec: DTensorSpec) -> bool:
"""Return True if tensor is partial on the mesh."""
return any(p.is_partial() for p in spec.placements)
def infer_broadcast_dims_map(
common_shape: torch.Size, input_shape: torch.Size
) -> list[int]:
# infer the broadcast dims map, where it maps from the common shape dim to the input shape dim
# this is aligned with the broadcast semantics
# e.g. if common_shape = [1, 2, 3, 4] and input_shape = [2, 3, 4],
# broadcast_dims_map will be [-1, 0, 1, 2]
# meaning that dim 0 in the output has no mapping to the input, and dim 1 in the output maps to dim 0 in the input
from torch.fx.experimental.symbolic_shapes import guard_or_false
common_ndim = len(common_shape)
input_ndim = len(input_shape)
broadcast_dims_map = [-1] * common_ndim
for idx in range(-1, -1 - input_ndim, -1):
if guard_or_false(input_shape[idx] == common_shape[idx]):
broadcast_dims_map[common_ndim + idx] = input_ndim + idx
return broadcast_dims_map
def map_placements_after_broadcast(
placements: tuple[Placement, ...],
shape: torch.Size,
broadcast_dims_map: list[int],
partial_to_replicate: bool = False,
) -> tuple[Placement, ...]:
"""Map each placement based on the output shape after broadcast."""
new_placements: list[Placement] = []
for placement in placements:
if isinstance(placement, Partial):
if partial_to_replicate:
# map the partial placement to replicate
new_placements.append(Replicate())
else:
new_placements.append(placement)
elif isinstance(placement, Replicate):
new_placements.append(placement)
else:
if not _is_shard_like(placement):
raise AssertionError
shard_dim = normalize_dim(placement.dim, len(shape))
new_shard_dim = broadcast_dims_map[shard_dim]
if new_shard_dim != -1:
# there's a map from the common shape shard dim to
# the input shape shard dim before broadcasting,
# use that instead
if isinstance(placement, _StridedShard):
new_placements.append(
_StridedShard(
new_shard_dim, split_factor=placement.split_factor
)
)
else:
new_placements.append(Shard(new_shard_dim))
else:
# there's no map between common shape shard dim and
# the input shape shard dim before broadcasting,
# in this case it means implicit broadcasting happen
# in this dim, so we can just mark it as replicate
# and implicit broadcast will broadcast automatically
# to the sharded shape
new_placements.append(Replicate())
return tuple(new_placements)
def generate_redistribute_costs(
src_strategy: OpStrategy, dst_spec: DTensorSpec
) -> list[float]:
"""Generates one row in the 'redistribute_costs' matrix in an OpSpec
The length of the returned list will match the number of strategies in 'src_strategy'.
Each value in the row is the cost of redistributing from a particular src_strategy to dst_spec.
"""
redistribute_costs: list[float] = [
redistribute_cost(strat.output_spec, dst_spec)
for strat in src_strategy.strategies
]
return redistribute_costs
def expand_to_full_mesh_op_strategy(
mesh: DeviceMesh,
op_schema: OpSchema,
single_mesh_dim_strategies: list[PlacementList],
*,
output_tensor_meta: TensorMeta | Sequence[TensorMeta | None] | None = None,
input_index: int = 1,
inplace_op: bool = False,
allow_unbacked_sharding: bool | None = None,
allow_uneven_sharding: bool = False,
is_valid_strategy_cb: Callable[
[list[DTensorSpec], DTensorSpec | tuple[DTensorSpec | None, ...]], bool
]
| None = None,
different_mesh_args: list[int] | None = None,
) -> OpStrategy:
"""
Convenience function to allow writing a sharding strategy considering only a single mesh dimension,
and have it expanded combinatorially to all mesh dimensions.
Args:
mesh (DeviceMesh): the device mesh to expand the strategy to
op_schema (OpSchema): the op schema
single_mesh_dim_strategies (list[PlacementList]): the sharding strategies to expand. The outer list is over
different strategies. The inner PlacementList is over the outputs and inputs of the op. If input_index is 1,
a PlacementList looks like [output_placement, input_placement1, input_placement2, ...].
output_tensor_meta: tensor metadata for the output(s), used to populate DTensorSpec.tensor_meta field
input_index: the number of outputs of the op, defaults to 1
inplace_op: whether the op is inplace or not, defaults to False
is_valid_strategy_cb: a callback function to filter out invalid sharding rules, defaults to None.
Example: Let's say `my_op(tensor_x, tensor_y) - > output_tensor` can support sharding or replicating tensor_x,
but always requires tensor_y to be replicated. We can specify these valid combinations ignoring mesh dims.
Then, we can rely on `expand_to_full_mesh_op_strategy` to create every possible combination of these shardings
over multiple mesh dimensions, filtering out any combinations that are invalid based on the actual mesh dim size.
single_mesh_dim_strategies = [
# first strategy: return output sharded on first dim, shard tensor_x on its first dim, replicate tensor_y
[Shard(0), Shard(0), Replicate()]
# second strategy: replicate output, and both inputs
[Replicate(), Replicate(), Replicate()]
]
"""
# Expand the single_mesh_dim_strategies to full mesh dim strategies.
all_mesh_dim_strategies = [single_mesh_dim_strategies] * mesh.ndim
strategy_combs = itertools.product(*all_mesh_dim_strategies)
args_strategy = op_schema.args_strategy
kwargs_strategy = op_schema.kwargs_strategy
input_args_strategy = args_strategy + kwargs_strategy
# Propagate use_strided_shard_as_shard_order from inputs so that
# strategy specs with _StridedShard get the correct flag (and thus
# correct shard_order) at construction time, avoiding shard_order
# mismatches in redistribute_cost computation.
_input_use_strided: bool | None = None
for input_strat in input_args_strategy:
input_spec = input_strat.strategies[0].output_spec
if any(isinstance(p, _StridedShard) for p in input_spec.placements):
_input_use_strided = input_spec.use_strided_shard_as_shard_order
break
all_strategies = []
# Track input placements if we skip strategies due to inplace placement mismatch
blocking_inplace_input_placements: tuple[Placement, ...] | None = None
for strategy_comb in strategy_combs:
spec_list: list[DTensorSpec | None] = []
# Track how many non-None output specs we've seen (for output_tensor_meta indexing).
# This is needed because output_tensor_meta may contain only non-None entries,
# so we can't use position directly when there are None entries in the output.
output_spec_count = 0
# Track input args separately since not all tensor inputs have OpStrategy
# (e.g., philox_seed/offset in SDPA are scalar tensors without OpStrategy)
input_strategy_counter = 0
for position, specs in enumerate(zip(*strategy_comb, strict=True)):
if specs[0] is not None:
# Populate tensor_meta field for both output and input specs,
# including for tuple output cases
tensor_meta = None
# Use position to determine output vs input territory
# (position includes None entries, unlike the old spec_index)
if position < input_index:
# This is an output position
if output_tensor_meta is not None:
if isinstance(output_tensor_meta, TensorMeta):
tensor_meta = output_tensor_meta
elif isinstance(output_tensor_meta, (tuple, list)):
if output_spec_count < len(output_tensor_meta):
tensor_meta = output_tensor_meta[output_spec_count]
output_spec_count += 1
else:
# This is an input position
# Only get tensor_meta if we have a corresponding input_args_strategy entry
if input_strategy_counter < len(input_args_strategy):
tensor_meta = input_args_strategy[
input_strategy_counter
].tensor_meta
input_strategy_counter += 1
# pyrefly: ignore [bad-argument-type]
use_strided = (
_input_use_strided
if _input_use_strided is not None
and any(isinstance(p, _StridedShard) for p in specs)
else None
)
spec_list.append(
DTensorSpec(
mesh,
specs,
tensor_meta=tensor_meta,
use_strided_shard_as_shard_order=use_strided,
)
)
else:
spec_list.append(None)
# Skip strategy combinations that would create mixed partial types
# (except sum+avg which commute with each other).
# We check (type, reduce_op) pairs rather than just reduce_op because
# Partial subclasses like _MaskPartial have different reduction semantics
# even when they share the same reduce_op string.
has_mixed_partial = False
for spec in spec_list:
if spec is not None:
partial_kinds = {
(type(p), p.reduce_op)
for p in spec.placements
if isinstance(p, Partial)
}
if len(partial_kinds) > 1:
reduce_ops = {ro for _, ro in partial_kinds}
types = {t for t, _ in partial_kinds}
if not (len(types) == 1 and reduce_ops == {"sum", "avg"}):
has_mixed_partial = True
break
if has_mixed_partial:
continue
input_specs: list[DTensorSpec] = [
s for s in spec_list[input_index:] if isinstance(s, DTensorSpec)
]
if len(input_specs) != len(input_args_strategy):
raise AssertionError(
f"input_specs({len(input_specs)}) != strategies({len(input_args_strategy)}: "
f"{len(args_strategy)} args + {len(kwargs_strategy)} kwargs)"
)
# Note [Multi-mesh args]
#
# Some ops accept args whose DTensor lives on a different DeviceMesh
# than the op's primary compute mesh. We call these "multi-mesh
# args". They arise in fused optimizer ops (e.g. _fused_adam_)
# where *state_steps* is a per-rank scalar counter allocated on a
# smaller sub-mesh (e.g. 1-D DP) while params and grads live on a
# larger mesh (e.g. 2-D DP × TP).
#
# Why must these args be Replicate?
# Sharding implies a specific partitioning of a tensor's data
# across the ranks of a mesh. If a tensor doesn't even *exist*
# on the compute mesh, there is no meaningful way to interpret a
# Shard placement for it. Replicate, on the other hand, is
# mesh-agnostic: every rank already holds the full data, so the
# op can simply read the value regardless of which mesh owns it.
#
# What we do here:
# We preserve the original mesh and Replicate placement for these
# args so the propagator does not try to redistribute them onto
# the compute mesh (which would fail or produce wrong results).
#
# This is distinct from the *element_mesh* handling in
# single_dim_strategy.py, which deals with foreach ops where
# different *elements* in a tensor list may live on different
# sub-meshes (e.g. param group A on 2-D mesh, param group B on
# 1-D mesh).
# TODO: refactor fused_ops handling so that there are no longer
# args on different meshes
if different_mesh_args is not None:
for idx in different_mesh_args:
if idx < len(input_args_strategy):
cross_mesh_input = input_args_strategy[idx]
original_spec = cross_mesh_input.strategies[0].output_spec
if original_spec.mesh != mesh:
if not all(p == Replicate() for p in original_spec.placements):
raise RuntimeError(
f"Cross-mesh input at index {idx} must be Replicate, "
f"but got {original_spec.placements}"
)
input_specs[idx] = DTensorSpec(
mesh=original_spec.mesh,
placements=original_spec.placements,
tensor_meta=original_spec.tensor_meta,
)
self_spec = input_args_strategy[0].strategies[0].output_spec
redistribute_input = self_spec.placements != input_specs[0].placements
mismatching_input_output = (
spec_list[0] is not None and spec_list[0].placements != self_spec.placements
)
if inplace_op and (redistribute_input or mismatching_input_output):
# For inplace ops, both the proposed input[0] and the output must
# match self's runtime placement: input[0] because self can't be
# redistributed, output because the result IS self.
if blocking_inplace_input_placements is None:
blocking_inplace_input_placements = self_spec.placements
continue
# For out= variant ops, output placement must match the "out" kwarg's placement
if (
op_schema.is_out_variant_op()
and "out" in op_schema.kwargs_schema
and isinstance(op_schema.kwargs_schema["out"], OpStrategy)
):
out_kwarg_spec = op_schema.kwargs_schema["out"].strategies[0].output_spec
# spec_list[0] is the output spec for this strategy combination
if spec_list[0] is not None:
if spec_list[0].placements != out_kwarg_spec.placements:
continue
output_specs: tuple[DTensorSpec | None, ...] | DTensorSpec | None
if input_index == 0:
# No outputs (e.g., _linalg_check_errors)
output_specs = None
elif input_index > 1:
output_specs = tuple(spec_list[:input_index])
else:
if spec_list[0] is not None:
output_specs = spec_list[0]
else:
raise RuntimeError("output spec is None")
# check all inputs are shardable
if not all(
is_tensor_shardable(
inp.shape, s, allow_unbacked_sharding=allow_unbacked_sharding
)
or (
allow_uneven_sharding
and inp.strategies[0].output_spec.placements == s.placements
)
for inp, s in zip(input_args_strategy, input_specs)
):
continue
# perform additional op-specific filtering
# Skip callback for no-output ops (output_specs is None)
if is_valid_strategy_cb is not None and output_specs is not None:
if not is_valid_strategy_cb(input_specs, output_specs):
continue
redistribute_cost = [
generate_redistribute_costs(input_strategy, input_spec)
for input_strategy, input_spec in zip(input_args_strategy, input_specs)
]
strategy = OpSpec(
output_specs=output_specs,
input_specs=input_specs,
redistribute_cost=redistribute_cost,
)
all_strategies.append(strategy)
# If all strategies were filtered out due to inplace placement mismatch,
# raise a clear error message instead of returning an empty OpStrategy
# (which would later cause a cryptic "min() arg is an empty sequence" error)
if not all_strategies and blocking_inplace_input_placements is not None:
raise RuntimeError(
f"{op_schema.op}: in-place operations that require placement changes "
f"are not supported. The input has placement {blocking_inplace_input_placements}, "
f"but no valid strategy preserves this placement. "
f"Please use the out-of-place version of this operation instead."
)
return OpStrategy(all_strategies)
def shift_shard_dims_after_insert(
placements: Sequence[Placement], insert_dim: int = 0
) -> Sequence[Placement]:
normalized_placements: list[Placement] = []
for placement in placements:
if isinstance(placement, _StridedShard) and placement.dim >= insert_dim:
normalized_placements.append(
_StridedShard(placement.dim + 1, split_factor=placement.split_factor)
)
elif isinstance(placement, Shard) and placement.dim >= insert_dim:
normalized_placements.append(Shard(placement.dim + 1))
else:
normalized_placements.append(placement)
return normalized_placements
def shift_shard_dims_after_remove(
placements: Sequence[Placement], remove_dim: int = 0
) -> Sequence[Placement]:
normalized_placements: list[Placement] = []
for placement in placements:
if isinstance(placement, _StridedShard) and placement.dim > remove_dim:
normalized_placements.append(
_StridedShard(placement.dim - 1, split_factor=placement.split_factor)
)
elif isinstance(placement, Shard) and placement.dim > remove_dim:
normalized_placements.append(Shard(placement.dim - 1))
else:
normalized_placements.append(placement)
return normalized_placements
@@ -0,0 +1,480 @@
# mypy: allow-untyped-defs
# Copyright (c) Meta Platforms, Inc. and affiliates
import contextlib
import warnings
from collections.abc import Sequence
from logging import getLogger
from typing import Optional
import torch
from torch.distributed._local_tensor import maybe_run_for_local_tensor
from torch.distributed.device_mesh import _get_device_handle, DeviceMesh
from torch.distributed.tensor._dtensor_spec import DTensorSpec
from torch.distributed.tensor.placement_types import _StridedShard, Shard
from torch.types import IntLikeType
logger = getLogger(__name__)
__all__ = [
"is_rng_supported_mesh",
"manual_seed",
"OffsetBasedRNGTracker",
]
_rng_tracker: Optional["_RNGStateTracker"] = None
def is_rng_supported_mesh(device_mesh: DeviceMesh) -> bool:
"""Checks if the current device of ``device_mesh`` supports DTensor's random APIs.
Currently DTensor Random APIs only supports cuda/cuda-like devices. We suggest
users call this API to test the availability before using our random APIs.
Args:
device_mesh (:class:`DeviceMesh`): The device mesh on which we check if the
random ops APIs are supported.
Returns:
A bool value. True if ``device_mesh`` supports DTensor Random APIs; False otherwise.
.. warning::
Currently we only support correct RNG on cuda/cuda-like devices.
"""
device_handle = _get_device_handle(device_mesh.device_type)
if device_handle and hasattr(device_handle, "set_rng_state"):
return True
else:
# TODO: Logs way too much
warnings.warn(
f"DTensor random operators may not have complete support on {device_mesh.device_type} device mesh",
stacklevel=2,
)
return False
def manual_seed(seed: int, device_mesh: DeviceMesh) -> None:
"""Sets the seed for generating random numbers for the calling rank.
Args:
seed (int): The desired seed.
device_mesh (:class:`DeviceMesh`): The device mesh to set the seed. It is
required that the ``device_mesh`` include the calling rank. This is
to ensure that the SPMD region maintains a synchronous RNG state, which
means no ranks should be initialized with values other than ``seed``.
Returns:
None
.. warning::
:func:`manual_seed` does not check the ``seed`` value correctness. Users must
ensure on their own that the value passed in is the desired ``seed`` for ranks
within ``device_mesh``.
If ``device_mesh`` is a sub-mesh and the calling rank is not a part of it,
``manual_seed`` will throw an error.
Current implementation only supports a GPU device mesh.
"""
if not is_rng_supported_mesh(device_mesh):
warnings.warn(
"DTensor manual_seed() may not have complete support "
f"on {device_mesh.device_type} device mesh",
stacklevel=2,
)
return
# TODO: deprecate this API, but also need to ensure we disable broadcast for PP case, and that's currently
# bundled together with this API. See torchtitan/distributed/utils.py:set_determinism
# warnings.warn(
# "DTensor manual_seed() is deprecated, since DTensor no longer maintains a separate copy of generator state. "
# "Use `torch.manual_seed` instead"
# )
# Note: we still need to ensure setting `run_state_sync=False` to support the pp case
# instantiate a RNG tracker if haven't. By default DTensor uses an
# OffsetBasedRNGTracker to perform random operators.
global _rng_tracker
if not _rng_tracker:
_rng_tracker = OffsetBasedRNGTracker(device_mesh, run_state_sync=False)
if device_mesh.get_coordinate() is None:
raise RuntimeError(
"manual_seed requires the current rank to be a part of the device mesh "
"otherwise DTensor RNG state on the rank will not be initialized and "
"the behavior of DTensor random ops is undefined."
)
# DTensor no longer maintains a copy of rng state. manual seed on dtensor is the same thing
# as manual seed on torch.
#
# torch.manual_seed will handle LocalTensor mode correctly by
# iterating through all ranks if seed is a LocalIntNode.
torch.manual_seed(seed)
class _PhiloxState:
"""
Convenience accessor for interpreting the packed bits of (seed: uint64, offset: uint64) in the philox state,
which for some reason is actually exposed as a size-16 uint8 tensor.
The state is always moved to .cpu since it is necessary for it to be on CPU before applying it back to a generator.
"""
def __init__(self, state: torch.Tensor):
self._state = state.to("cpu")
@property
def state(self):
return self._state
@property
def offset(self) -> torch.Tensor:
return self._state[8:].view(dtype=torch.int64)
@offset.setter
def offset(self, offset: torch.Tensor) -> None:
if offset.numel() != 1:
raise AssertionError
self._state[8:] = offset.view(torch.uint8)
@property
def seed(self) -> torch.Tensor:
return self._state[:8].view(dtype=torch.uint64)
@seed.setter
def seed(self, seed: torch.Tensor) -> None:
if seed.numel() != 1:
raise AssertionError
self._state[:8] = seed.view(torch.uint8)
class _RNGStateTracker:
"""
_RNGStateTracker stores Random Number Generator (RNG) state (a ByteTensor object)
in a dict, mapping from a corresponding tag to each state tensor. It also provides
a set of convenient utility methods to help access/modify the state tensors. The most
important interface is _distribute_region which will be used when DTensor executes
a random op (an operator that calls RNG).
"""
def __init__(self, device: torch.device):
self._device = device
self._device_handle = _get_device_handle(self._device.type)
if not (self._device_handle and self._device_handle.is_available()):
raise RuntimeError(
f"{self.__class__.__name__} instantiation requires the presence of "
f"{device.type} device but couldn't find."
)
self._use_distribute_region = True
@property
def distribute_region_enabled(self) -> bool:
return self._use_distribute_region
@distribute_region_enabled.setter
def distribute_region_enabled(self, value) -> None:
self._use_distribute_region = value
def _distribute_region(
self, spec: DTensorSpec, generator: torch.Generator | None = None
):
pass
def _manual_seed(self, parallel_seed: int) -> None:
pass
class OffsetBasedRNGTracker(_RNGStateTracker):
"""
This subclass of ``_RNGStateTracker`` defines the default policy of how RNG states
should be shared and synchronized among all ranks to respect the semantics of DTensor
random operators.
note: _RNGStateTracker only supports cuda/cuda-like device.
"""
def __init__(
self,
device_mesh: DeviceMesh,
run_state_sync: bool = True,
):
super().__init__(_resolve_device(device_mesh=device_mesh))
if self._device_handle is None:
raise AssertionError
# DTensor RNG tracker so far only supports CUDA/CUDA-like devices
if self._device.type == "cpu":
raise RuntimeError(
f"{self.__class__.__name__} instantiation requires the presence of "
f"CUDA/CUDA-like/XPU device. Got {self._device.type} instead."
)
if run_state_sync:
rng_state = self._get_device_state()
# synchronize RNG state using rank 0's current one
torch.distributed.broadcast(rng_state, 0)
my_rng_state = self._get_device_state()
if not all(my_rng_state == rng_state):
logger.warning(
"DTensor is synchronizing RNG states of every rank with the state from rank 0. "
"This behavior is deprecated. "
"Please call `torch.manual_seed()` on every rank that participates in SPMD DTensor Operations with "
"the same seed. If using Pipeline Parallelism, each pipeling state would use a different seed, "
"but all ranks belonging to one pipeline stage would use the same seed."
)
self._set_device_state(rng_state)
def _get_device_state(self) -> torch.Tensor:
if self._device.type == "hpu":
self._device_handle.set_rng_ctx("philox")
rng_state = self._device_handle.get_rng_state().to(self._device)
if self._device.type == "hpu":
self._device_handle.unset_rng_ctx("philox")
return rng_state
def _set_device_state(self, state: torch.Tensor):
# It seems that the underlying generator wants a cpu tensor but the dtensor code expects `_get_device_state`
# to convert to a 'device' tensor, probably because we may use it with our backend comms for sync/debug
# for now, we just convert back to cpu here to make sure it always works.
if self._device.type == "hpu":
self._device_handle.set_rng_ctx("philox")
self._device_handle.set_rng_state(state.to("cpu"))
if self._device.type == "hpu":
self._device_handle.unset_rng_ctx("philox")
@contextlib.contextmanager
def _distribute_region(
self, spec: DTensorSpec, generator: torch.Generator | None = None
):
from torch.distributed._local_tensor import maybe_enable_local_tracker
if local_tracker_context := maybe_enable_local_tracker(
self._device.type, self.distribute_region_enabled, spec, generator
):
with local_tracker_context:
yield
return
# regular (non-LocalTensor) mode
if generator is not None:
# This is a little hacky, but for any user-passed generator, we store its state under a unique key,
# not because we need to keep a copy of it but because its the easiest way to make it work with the
# existing set/get APIs. We also ensure we remove it from rng_states after each _distribute_region.
state = _PhiloxState(generator.get_state())
else:
state = _PhiloxState(self._get_device_state())
if self.distribute_region_enabled:
if self._device.type == "hpu":
self._device_handle.set_rng_ctx("philox")
old_offset = state.offset.clone()
self._set_pre_op_offset(state, spec)
with torch.random.fork_rng(
devices=[self._device], device_type=self._device.type
):
if self._device_handle is None:
raise AssertionError
self._device_handle.set_rng_state(state.state)
try:
yield # execute the region code
finally:
# update offset to synchronize among ranks
self._set_post_op_offset(state, spec, old_offset)
if self._device.type == "hpu":
self._device_handle.unset_rng_ctx("philox")
else:
yield
if generator is not None:
# ensure we (a) propagate the state advancement back to the user's RNG so its visible and impacts any future
# usage of that RNG (dtensor or non-dtensor), (b) drop it from our own cache so that if the user updates
# the seed value in their rng and uses it with DTensor again, we always use the latest value
generator.set_state(state.state)
else:
self._set_device_state(state.state)
def _set_pre_op_offset(self, state: _PhiloxState, spec: DTensorSpec) -> None:
"""Set the starting RNG offset for current device's local shard before actual
op execution. The pre_op_offset value should start from the current RNG offset
and increment by the size of local shard until it reaches the size of the whole
DTensor. For different ranks that hold the same DTensor shard, their pre_op_offset
will be the same.
Args:
state (:class:`Tensor`): The generator state to modify
spec (:class:`DTensorSpec`): the spec of the DTensor object on which
we prepare the offset for running random ops.
Returns:
None
.. warning::
Note that, current implementation does not consider DTensor's continguity.
Example:
take a DTensor of shape [8, 16] as an example. Assume that the DTensor
is placed on a device mesh with placements ([Shard(1), Replicate(), Shard(0)]),
and the mesh is:
[[[0, 1], [2, 3]], [[4, 5], [6, 7]]]
``spec.mesh.get_coordinate()`` provides the coordinate of the current rank
in the mesh. For example, the coordinate of rank 5 is (1, 0, 1).
Another concept to introduce besides rank coordinate is shard coordinate.
Each rank holds a local shard of the DTensor. In the example, the DTensor
is partitioned into 4 [4, 8] shards. The first shard has 2 replicas and
rank 0 (coord (0, 0, 0)) and rank 2 (coord (0, 1, 0)) have 1 replica each.
That being said, the local shard on rank 0 and rank 2 correspond to the same
shard of the DTensor. To denote each DTensor shard, we use a shard coordinate
(in the example, it will be a tuple (i, j) where shard (i, j) has the slice
DTensor[4 * i : 4 * (i + 1), 8 * j : 8 * (j + 1)], 0 <= i < 2, 0 <= j < 2).
Once we have rank coordinate and shard coordinate, we can calculate on each rank
what shard of the DTensor the rank holds, with the help of dim_map. The dim_map
of the above DTensor is [2, 0] so the shard coordinate of a rank with rank coord
(x, y, z) is simply (z, x) by taking(rank_coord[dim_map[0]],rank_coord[dim_map[1]]).
Following this calculation,
rank 0 and rank 2 holds the shard of coord (0, 0);
rank 1 and rank 3 holds the shard of coord (0, 1);
rank 4 and rank 6 holds the shard of coord (1, 0);
rank 5 and rank 7 holds the shard of coord (1, 1);
The last value to calculate before obtaining the starting offset is the shard linear index.
The starting offset for each rank will be its shard_linear_index * local_tensor_numel.
"""
start_offset_incr, _ = self._compute_rng_offsets(spec)
state.offset = state.offset + start_offset_incr
def _set_post_op_offset(
self, state: _PhiloxState, spec: DTensorSpec, old_offset: torch.Tensor
) -> None:
"""Sets the RNG to a synchronized state after running the local random op. Every
rank should set its RNG offset to `old_offset + DTensor.numel()` where old_offset is
the offset before calling `set_pre_op_offset` i.e. the offset before running DTensor
random ops.
Args:
state (:class:`Tensor`): The generator state to modify.
spec (:class:`DTensorSpec`): the spec of the DTensor object on which
we post-process the offset for running random ops.
Returns:
None
"""
_, end_offset_incr = self._compute_rng_offsets(spec)
state.offset = old_offset + end_offset_incr
def _compute_rng_offsets(self, spec: DTensorSpec) -> tuple[int, int]:
"""Compute the RNG offset increments for a distributed random op.
These values are derived from mesh topology, placements, and tensor shape,
and are static for a given compiled graph. They can be burned into the graph
as integer constants rather than keeping the DTensorSpec around at runtime.
Returns:
(start_offset_incr, end_offset_incr) — both aligned to multiples of 4.
"""
from torch.distributed.tensor._ops.utils import prod
mesh = spec.mesh
mesh_coordinate = [mesh._sym_get_coordinate(i) for i in range(mesh.ndim)]
shard_idx_by_dim, total_num_shards_by_dim = _calc_shard_info(
mesh_coordinate, spec
)
shard_linear_idx = self._calc_shard_linear_idx(
shard_idx_by_dim, total_num_shards_by_dim
)
local_size = prod(_calc_first_shard_size(spec))
# pytorch: offset must be multiple of 4
# source: aten/src/ATen/cuda/CUDAGeneratorImpl.cpp
start_offset_incr = (shard_linear_idx * local_size + 3) // 4 * 4
end_offset_incr = (prod(spec.shape) + 3) // 4 * 4
return start_offset_incr, end_offset_incr
def _calc_shard_linear_idx(
self, shard_coord: Sequence[IntLikeType], shard_size: Sequence[IntLikeType]
) -> IntLikeType:
return _calc_shard_linear_idx(shard_coord, shard_size)
def _calc_first_shard_size(spec: DTensorSpec) -> list[int]:
local_size_on_rank_0 = list(spec.shape)
for idx, placement in enumerate(spec.placements):
if isinstance(placement, Shard | _StridedShard):
mesh_dim_size = spec.mesh.size(idx)
shard_dim = placement.dim
local_size_on_rank_0[shard_dim], _ = placement._local_shard_size_and_offset(
spec.shape[shard_dim],
mesh_dim_size,
0,
)
return local_size_on_rank_0
def _calc_shard_info(
mesh_coordinate: Sequence[IntLikeType], spec: DTensorSpec
) -> tuple[list[IntLikeType], list[IntLikeType]]:
mesh = spec.mesh
# note: dim_map does not allow double sharding which is the FSDP(fully_shard)+TP
# case. Replace the custom logic with dim_map once we support it.
dim_map: list[int | list[int]] = [-1] * spec.ndim
for i, placement in enumerate(spec.placements):
if isinstance(placement, Shard | _StridedShard):
shard_dim = placement.dim
if dim_map[shard_dim] == -1:
dim_map[shard_dim] = [i]
else:
mesh_dim_list = dim_map[shard_dim]
if not isinstance(mesh_dim_list, list):
raise AssertionError
mesh_dim_list.append(i)
# Compute shard coordinate:
# The coordinate on each tensor dim is a tuple (idx, range)
# If a DTensor is partitioned on its dim i into n shards, and the current rank
# holds the j-th, then its shard coordinate will be (idx=j, range=n) on dim i
mesh_size = mesh.shape
shard_idx_by_dim = []
total_num_shards_by_dim: list[
IntLikeType
] = [] # total number of shards on each tensor dim
for mesh_dim in dim_map:
shard_idx: IntLikeType = 0
total_num_shards: IntLikeType = 1
# the tensor dim is sharded on more than 1 mesh dim
if isinstance(mesh_dim, list):
rank_coord = [mesh_coordinate[d] for d in mesh_dim]
num_shards = [mesh_size[d] for d in mesh_dim]
# compute the shard idx and total number of shards
for idx, size in zip(rank_coord, num_shards):
shard_idx = shard_idx * size + idx
total_num_shards *= size
shard_idx_by_dim.append(shard_idx)
total_num_shards_by_dim.append(total_num_shards)
return shard_idx_by_dim, total_num_shards_by_dim
def _calc_shard_linear_idx(
shard_coord: Sequence[IntLikeType], shard_size: Sequence[IntLikeType]
) -> IntLikeType:
# compute shard linear index
shard_linear_idx: IntLikeType = 0
shard_coord_stride: IntLikeType = 1
for idx, size in zip(reversed(shard_coord), reversed(shard_size)):
shard_linear_idx += idx * shard_coord_stride
shard_coord_stride *= size
return shard_linear_idx
def _resolve_device(device_mesh: DeviceMesh) -> torch.device:
device_type = device_mesh.device_type
device_handle = _get_device_handle(device_type)
if device_handle is None:
raise AssertionError
device_idx = device_mesh.get_rank() % device_handle.device_count()
@maybe_run_for_local_tensor
def get_device(device_idx):
return torch.device(f"{device_type}:{device_idx:d}")
return get_device(device_idx)
@@ -0,0 +1,362 @@
# mypy: allow-untyped-defs
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.
from typing import Any
import torch
from torch.distributed.checkpoint.metadata import (
ChunkStorageMetadata,
MetadataIndex,
TensorProperties,
TensorStorageMetadata,
)
from torch.distributed.checkpoint.planner import (
TensorWriteData,
WriteItem,
WriteItemType,
)
aten = torch.ops.aten
class LocalShardsWrapper(torch.Tensor):
"""
A wrapper class to hold local shards of a DTensor.
This class is used largely for checkpointing purposes and implicitly subtypes
the _Checkpointable protocol.
"""
__slots__ = ["_local_shards", "_storage_meta"]
_local_shards: list[torch.Tensor]
_storage_meta: TensorStorageMetadata
@staticmethod
def __new__(
cls, local_shards: list[torch.Tensor], local_offsets: list[tuple[int, ...]]
) -> "LocalShardsWrapper":
if not all(
tensor.device == local_shards[0].device for tensor in local_shards[1:]
):
raise AssertionError
# if empty shard, we create a empty tensor
if len(local_shards) == 0:
r = torch.Tensor._make_wrapper_subclass(
cls,
torch.Size([0, 0]),
)
r._local_shards = []
r._storage_meta = TensorStorageMetadata(
properties=TensorProperties(),
size=torch.Size([0, 0]),
chunks=[
ChunkStorageMetadata(
offsets=torch.Size([0, 0]), sizes=torch.Size([0, 0])
)
],
)
return r
# we calculate the total tensor size by "concat" on second tensor dimension
cat_tensor_shape = list(local_shards[0].size())
if len(local_shards) > 1 and local_shards[0].ndim == 2: # column-wise sharding
for shard in local_shards[1:]:
cat_tensor_shape[1] += shard.size()[1]
# in cases of sharding optimizer rowwise, we calculate total tensor size by "concat" on first tensor dimension
if len(local_shards) > 1 and local_shards[0].ndim == 1: # column-wise sharding
for shard in local_shards[1:]:
cat_tensor_shape[0] += shard.size()[0]
wrapper_properties = TensorProperties.create_from_tensor(local_shards[0])
wrapper_shape = torch.Size(cat_tensor_shape)
chunks_meta = [
ChunkStorageMetadata(
offsets=torch.Size(offset),
sizes=shard.size(),
)
for shard, offset in zip(local_shards, local_offsets)
]
r = torch.Tensor._make_wrapper_subclass(
cls,
torch.Size(cat_tensor_shape),
)
r._local_shards = local_shards
r._storage_meta = TensorStorageMetadata(
properties=wrapper_properties,
size=wrapper_shape,
chunks=chunks_meta,
)
return r
# necessary for ops dispatching from this subclass to its local shards
@classmethod
def __torch_dispatch__(cls, func, types, args=(), kwargs=None): # type: ignore[override]
kwargs = kwargs or {}
dispatcher = {
torch.ops._c10d_functional.all_gather_into_tensor.default: cls.handle_all_gather_into_tensor,
torch.ops._c10d_functional.wait_tensor.default: cls.handle_wait_tensor,
aten._to_copy.default: cls.handle_to_copy,
aten.view.default: cls.handle_view,
aten.equal.default: cls.handle_equal,
aten.detach.default: cls.handle_detach,
aten.clone.default: cls.handle_clone,
aten.new_empty.default: cls.handle_new_empty,
}
if func in dispatcher:
return dispatcher[func](args, kwargs)
else:
raise NotImplementedError(
f"{func} is not supported for LocalShardsWrapper!"
)
@staticmethod
def handle_all_gather_into_tensor(args, kwargs) -> torch.Tensor:
dim = args[0].local_sizes()[0][1]
cat_tensor = torch.cat(
[t.view(-1) for t in args[0].local_shards()], dim=0
).view(-1, dim)
return torch.ops._c10d_functional.all_gather_into_tensor.default(
cat_tensor, *args[1:], **kwargs
)
@staticmethod
def handle_wait_tensor(args, kwargs) -> torch.Tensor:
return torch.ops._c10d_functional.wait_tensor(args[0])
@staticmethod
def handle_to_copy(args, kwargs) -> torch.Tensor:
res_shards_list = [
aten._to_copy.default(shard, *args[1:], **kwargs)
for shard in args[0].local_shards()
]
return LocalShardsWrapper(res_shards_list, args[0].local_offsets())
@staticmethod
def handle_view(args, kwargs) -> "LocalShardsWrapper":
view_shape = args[1]
res_shards_list = []
if len(args[0].local_shards()) > 1:
if args[0].local_shards()[0].ndim == 2:
if not (
args[0].storage_metadata().size[0] == view_shape[0]
and args[0].storage_metadata().size[1] == view_shape[1]
):
raise AssertionError
# This accounts for a DTensor quirk, when multiple shards are present on a rank, DTensor on
# init calls view_as() on the global tensor shape
# will fail because the view shape is not applicable to individual shards.
res_shards_list = [
aten.view.default(shard, shard.shape, **kwargs)
for shard in args[0].local_shards()
]
elif args[0].local_shards()[0].ndim == 1:
if args[0].storage_metadata().size[0] != view_shape[0]:
raise AssertionError
# This case is for optimizer sharding as regardless of sharding type, optimizer state is row wise sharded
res_shards_list = [
aten.view.default(shard, shard.shape, **kwargs)
for shard in args[0].local_shards()
]
else:
raise NotImplementedError("No support for view on tensors ndim > 2")
else:
# view is called per shard
res_shards_list = [
aten.view.default(shard, args[1], **kwargs)
for shard in args[0].local_shards()
]
return LocalShardsWrapper(res_shards_list, args[0].local_offsets())
@staticmethod
def handle_equal(args, kwargs) -> bool:
"""
LocalShardsWrapper equal impl also checks for equality of storage metadata
and the order of shards
"""
a, b = args[0], args[1]
if len(a.local_shards()) != len(b.local_shards()):
return False
if not all(
aten.equal.default(x, y) for x, y in zip(a.local_shards(), b.local_shards())
):
return False
if a.storage_metadata() != b.storage_metadata():
return False
return True
@staticmethod
def handle_detach(args, kwargs) -> "LocalShardsWrapper":
self_ls = args[0]
deatched_local_shards = [
aten.detach.default(shard) for shard in self_ls.local_shards()
]
self_ls._local_shards = deatched_local_shards
self_ls._storage_meta.properties.requires_grad = False
return self_ls
@staticmethod
def handle_clone(args, kwargs) -> "LocalShardsWrapper":
self_ls = args[0]
desired_memory_format = kwargs.get("memory_format", None)
if desired_memory_format and desired_memory_format != torch.preserve_format:
raise NotImplementedError(
f"{desired_memory_format} is not supported for LocalShardsWrapper!"
)
cloned_local_shards = [
shard.clone(memory_format=desired_memory_format)
for shard in self_ls._local_shards
]
return LocalShardsWrapper(cloned_local_shards, self_ls.local_offsets())
@staticmethod
def handle_new_empty(args, kwargs) -> "LocalShardsWrapper":
self_ls = args[0]
return LocalShardsWrapper(
[torch.empty_like(shard) for shard in self_ls._local_shards],
self_ls.local_offsets(),
)
@property
def device(self) -> torch._C.device: # type: ignore[override]
return (
self._local_shards[0].device if self._local_shards else torch.device("meta")
)
@property
def is_meta(self) -> bool: # type: ignore[override]
return self._local_shards[0].is_meta if self._local_shards else True
def is_pinned(self) -> bool: # type: ignore[override]
return self._storage_meta.properties.pin_memory
def requires_grad_(self, requires_grad: bool = True) -> "LocalShardsWrapper":
self._storage_meta.properties.requires_grad = requires_grad
[shard.requires_grad_(requires_grad) for shard in self._local_shards]
return self
def local_shards(self) -> list[torch.Tensor]:
"""
Returns a list of :class:`torch.Tensor' corresponding to the
local shards for this rank. Returns an empty list if the current rank
does not host any shards for this Tensor.
"""
return self._local_shards
def local_sizes(self) -> list[torch.Size]:
"""
Returns a list of :class:`torch.Size' corresponding to the
local sizes for the shards on this rank. Returns an empty list if the current rank
does not host any shards for this Tensor.
"""
return [chunk.sizes for chunk in self._storage_meta.chunks]
def local_offsets(self) -> list[torch.Size]:
"""
Returns a list of :class:`torch.Size' corresponding to the
local offsets for the shards on this rank. Returns an empty list if the current rank
does not host any shards for this Tensor.
"""
return [chunk.offsets for chunk in self._storage_meta.chunks]
@property
def local_chunks(self) -> list[ChunkStorageMetadata]:
"""
Returns a :class:`list[ChunkStorageMetadata]` object corresponding to the
metadata for each tensor shard
"""
return self._storage_meta.chunks
def storage_metadata(self) -> TensorStorageMetadata:
"""
Returns a :class:`TensorStorageMetadata` object corresponding to the
metadata for the local tensor on current rank
"""
return self._storage_meta
def is_empty_shard(self) -> bool:
"""
Returns a :class:`bool` object indicating if the local tensor on current rank
is an empty tensor
"""
return self._storage_meta.size[0] == 0 and self._storage_meta.size[1] == 0
def __create_write_items__(self, fqn: str, object: Any) -> list[WriteItem]:
"""
For compatibility with DCP, we support creation of WriteItems
such that they can be saved properly.
"""
return [
WriteItem(
index=MetadataIndex(fqn, chunks.offsets),
type=WriteItemType.SHARD,
tensor_data=TensorWriteData(
chunk=ChunkStorageMetadata(
offsets=chunks.offsets,
sizes=chunks.sizes,
),
properties=self._storage_meta.properties,
size=object.size(),
),
)
for tensor, chunks in zip(self.local_shards(), self.local_chunks)
]
def __create_chunk_list__(self) -> list[ChunkStorageMetadata]:
"""
For compatibility with DCP, we support creation of chunk lists
such that they can be saved properly.
"""
return self._storage_meta.chunks
def __get_tensor_shard__(self, index: MetadataIndex) -> torch.Tensor:
"""
For compatibility with DCP, we support finding shard based on index
Return a 'torch.Tensor' shard based on 'MetadataIndex'.
"""
# Fast lookup path
if index.index is not None:
if (
len(self._local_shards) > index.index
and self._storage_meta.chunks[index.index].offsets == index.offset
):
return self._local_shards[index.index]
if index.offset is not None:
for shard, chunk in zip(self._local_shards, self._storage_meta.chunks):
if chunk.offsets == index.offset:
return shard
# Empty shard case
if len(self._local_shards) == 0 and self._storage_meta.chunks[
0
].sizes == torch.Size([0, 0]):
return torch.empty(0)
raise ValueError(
f"Could not find shard at '{index.offset}' for FQN: '{index.fqn}'"
)
def _get_tensor_size_bytes(self) -> int:
object_size = 0
for shard in self.local_shards():
object_size += shard.nelement() * shard.element_size()
return object_size
def __hash__(self) -> int:
return id(self)
def __repr__(self) -> str: # type: ignore[override]
return f"LocalShardsWrapper:{self._local_shards} {self._storage_meta}"
def __str__(self) -> str:
return f"LocalShardsWrapper:{self._local_shards} {self._storage_meta}"
@@ -0,0 +1,314 @@
# mypy: allow-untyped-defs
# Copyright (c) Meta Platforms, Inc. and affiliates
# implement matrix related ops for distributed tensor
from typing import cast
import torch
import torch.distributed as dist
import torch.distributed.tensor._api as dtensor
aten = torch.ops.aten
def _requires_data_exchange(padding, dim_map) -> bool:
# Data exchange is not need if only sharded across batch dim
if all(x == -1 for x in dim_map[1:]):
return False
# TODO: whether there requires data exchange is currently determined by padding
return padding[-1] != 0
def _is_supported(input_size, kernel_size, stride, padding, dilation):
if dilation[-1] != 1:
raise RuntimeError("Dilation must be 1 for tensor parallel convolution.")
if padding[-1] != 0:
if stride[-1] != 1:
raise RuntimeError(
"Stride must be 1 when there is padding for tensor parallel convolution."
)
if kernel_size[-1] // 2 > input_size[-1]:
raise RuntimeError(
"kernel_size[-1] // 2 should be less than or equal to input_size[-1] for tensor parallel convolution."
)
else:
if not (input_size[-1] % stride[-1] == 0 and stride[-1] == kernel_size[-1]):
raise RuntimeError(
"It requires that input_size[-1] is divisible by stride[-1] and stride[-1] equals kernel_size[-1] "
"when there is padding for tensor parallel convolution."
)
return True
def _ring_send_recv_construct(in_tensor, d1, d2, left, right, rank, size):
# dist comms and reconstruct local input tensor
send_to_right = in_tensor[..., -d1:].contiguous()
send_to_left = in_tensor[..., :d2].contiguous()
recv_from_right = torch.zeros_like(send_to_left)
recv_from_left = torch.zeros_like(send_to_right)
send_op_right = dist.P2POp(dist.isend, send_to_right, right)
send_op_left = dist.P2POp(dist.isend, send_to_left, left)
recv_op_right = dist.P2POp(dist.irecv, recv_from_right, right)
recv_op_left = dist.P2POp(dist.irecv, recv_from_left, left)
reqs = dist.batch_isend_irecv(
[send_op_right, send_op_left, recv_op_left, recv_op_right]
)
for req in reqs:
req.wait()
if rank == 0:
in_tensor = torch.cat([in_tensor, recv_from_right], dim=-1)
elif rank == size - 1:
in_tensor = torch.cat([recv_from_left, in_tensor], dim=-1)
else:
in_tensor = torch.cat([recv_from_left, in_tensor, recv_from_right], dim=-1)
return in_tensor
def _ring_send_recv_aggregate(grad_in_tensor, d1, d2, left, right, rank, size):
# dist comms and aggregate gradients for edge pixels
send_to_right = grad_in_tensor[:, :, :, -d2:].contiguous()
send_to_left = grad_in_tensor[:, :, :, :d1].contiguous()
recv_from_right = torch.zeros_like(send_to_left)
recv_from_left = torch.zeros_like(send_to_right)
send_op_right = dist.P2POp(dist.isend, send_to_right, right)
send_op_left = dist.P2POp(dist.isend, send_to_left, left)
recv_op_right = dist.P2POp(dist.irecv, recv_from_right, right)
recv_op_left = dist.P2POp(dist.irecv, recv_from_left, left)
reqs = dist.batch_isend_irecv(
[send_op_right, send_op_left, recv_op_left, recv_op_right]
)
for req in reqs:
req.wait()
if rank == 0:
grad_in_tensor = grad_in_tensor[:, :, :, :-d2]
grad_in_tensor[:, :, :, -d1:] = torch.add(
grad_in_tensor[:, :, :, -d1:], recv_from_right
)
elif rank == size - 1:
grad_in_tensor = grad_in_tensor[:, :, :, d1:]
grad_in_tensor[:, :, :, :d2] = torch.add(
grad_in_tensor[:, :, :, :d2], recv_from_left
)
else:
grad_in_tensor = grad_in_tensor[:, :, :, d1:-d2]
grad_in_tensor[:, :, :, -d1:] = torch.add(
grad_in_tensor[:, :, :, -d1:], recv_from_right
)
grad_in_tensor[:, :, :, :d2] = torch.add(
grad_in_tensor[:, :, :, :d2], recv_from_left
)
def tp_convolution(
op_call: torch._ops.OpOverload,
local_tensor_args: tuple[object, ...],
local_tensor_kwargs: dict[str, object],
dim_map: list[int],
) -> object:
if op_call != aten.convolution.default:
raise AssertionError
if len(local_tensor_args) != 9:
raise AssertionError
rank = dist.get_rank()
size = dist.get_world_size()
in_tensor = cast(torch.Tensor, local_tensor_args[0])
weight = cast(torch.Tensor, local_tensor_args[1])
stride, padding, dilation = local_tensor_args[3:6]
if not isinstance(padding, list):
raise AssertionError
if not _requires_data_exchange(padding, dim_map):
local_results = op_call(*local_tensor_args, **local_tensor_kwargs)
return local_results
else:
if not _is_supported(in_tensor.shape, weight.shape, stride, padding, dilation):
raise AssertionError(
"tp_convolution data exchange requires supported stride/padding/dilation"
)
# step 0 compute the overlap pixels of the input tensor
d = weight.shape[-1] - 1
d1 = d // 2
d2 = d - d1
if d1 + d2 != d:
raise AssertionError
right = (rank + 1) % size
left = (rank - 1 + size) % size
# step1 reconstruct local input tensor
in_tensor = _ring_send_recv_construct(
in_tensor, d1, d2, left, right, rank, size
)
# step2 feed local input tensor to op_call
local_tensor_args_list = list(local_tensor_args)
local_tensor_args_list[0] = in_tensor
local_tensor_args = cast(tuple[object, ...], local_tensor_args_list)
local_results = op_call(*local_tensor_args, **local_tensor_kwargs)
# step3 remove extra outputs from the results
padding_w = padding[-1]
w = local_results.size(-1)
if rank == 0:
local_results = local_results[..., : w - padding_w]
elif rank == size - 1:
local_results = local_results[..., padding_w:]
else:
local_results = local_results[..., padding_w : w - padding_w]
return local_results
def tp_convolution_backward(
op_call: torch._ops.OpOverload,
local_tensor_args: tuple[object, ...],
local_tensor_kwargs: dict[str, object],
dim_map: list[int],
) -> object:
if op_call != aten.convolution_backward.default:
raise AssertionError
if len(local_tensor_args) != 11:
raise AssertionError
rank = dist.get_rank()
size = dist.get_world_size()
grad_out_tensor = cast(torch.Tensor, local_tensor_args[0])
in_tensor = cast(torch.Tensor, local_tensor_args[1])
weight = cast(torch.Tensor, local_tensor_args[2])
stride, padding, dilation = local_tensor_args[4:7]
if not isinstance(padding, list):
raise AssertionError
if not _requires_data_exchange(padding, dim_map):
local_results = op_call(*local_tensor_args, **local_tensor_kwargs)
return local_results
else:
if not _is_supported(in_tensor.shape, weight.shape, stride, padding, dilation):
raise AssertionError(
"tp_convolution_backward data exchange requires supported stride/padding/dilation"
)
# step 0 compute the overlap pixels of the input tensor
d = weight.shape[3] - 1
d1 = d // 2
d2 = d - d1
if d1 + d2 != d:
raise AssertionError
right = (rank + 1) % size
left = (rank - 1 + size) % size
# step1 reconstruct local input tensor
in_tensor = _ring_send_recv_construct(
in_tensor, d1, d2, left, right, rank, size
)
# step2 reconstruct local gradient output tensor
padding_w = padding[1]
if rank == 0:
grad_out_tensor = torch.nn.functional.pad(
grad_out_tensor, (0, padding_w), "constant", 0
)
elif rank == size - 1:
grad_out_tensor = torch.nn.functional.pad(
grad_out_tensor, (padding_w, 0), "constant", 0
)
else:
grad_out_tensor = torch.nn.functional.pad(
grad_out_tensor, (padding_w, padding_w), "constant", 0
)
# step3 feed local input tensor to op_call
local_tensor_args_list = list(local_tensor_args)
local_tensor_args_list[0] = grad_out_tensor
local_tensor_args_list[1] = in_tensor
local_tensor_args = cast(tuple[object, ...], local_tensor_args_list)
local_results = op_call(*local_tensor_args, **local_tensor_kwargs)
# step4 aggregate gradients for edge pixels
grad_in_tensor = local_results[0]
if grad_in_tensor is not None:
grad_in_tensor = _ring_send_recv_aggregate(
grad_in_tensor, d1, d2, left, right, rank, size
)
local_results = list(local_results)
local_results[0] = grad_in_tensor
local_results = cast(tuple[object, ...], local_results)
return local_results
def convolution_handler(
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
) -> object:
# extract local tensor and sharding infos to a OpInfo
op_info = dtensor.DTensor._op_dispatcher.unwrap_to_op_info(op_call, args, kwargs)
# sharding propagation
dtensor.DTensor._op_dispatcher.sharding_propagator.propagate(op_info)
output_sharding = op_info.output_sharding
if output_sharding is None:
raise AssertionError("output sharding should not be None")
output_spec = output_sharding.output_spec
if not isinstance(output_spec, dtensor.DTensorSpec):
raise AssertionError
# local propagation
local_results = tp_convolution(
op_call,
tuple(op_info.local_args),
op_info.local_kwargs,
output_spec.dim_map,
)
return dtensor.DTensor._op_dispatcher.wrap(local_results, output_spec)
def convolution_backward_handler(
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
) -> object:
# Redistribute grad_output tensor to the same placement as input tensor
# pyrefly: ignore [bad-assignment]
args = list(args)
if not (
isinstance(args[0], dtensor.DTensor) and isinstance(args[1], dtensor.DTensor)
):
raise AssertionError
# pyrefly: ignore [unsupported-operation]
args[0] = args[0].redistribute(args[1].device_mesh, args[1].placements)
args = tuple(args)
# extract local tensor and sharding infos to a OpInfo
op_info = dtensor.DTensor._op_dispatcher.unwrap_to_op_info(op_call, args, kwargs)
# sharding propagation
dtensor.DTensor._op_dispatcher.sharding_propagator.propagate(op_info)
output_sharding = op_info.output_sharding
if output_sharding is None:
raise AssertionError("output sharding should not be None")
if not isinstance(op_info.flat_args_schema[0], dtensor.DTensorSpec):
raise AssertionError
# local propagation
local_results = tp_convolution_backward(
op_call,
tuple(op_info.local_args),
op_info.local_kwargs,
op_info.flat_args_schema[0].dim_map,
)
return dtensor.DTensor._op_dispatcher.wrap(
local_results, output_sharding.output_spec
)
@@ -0,0 +1,525 @@
import logging
import threading
from collections.abc import Callable, Sequence
from typing import Any
import torch
import torch.distributed._functional_collectives as funcol
import torch.distributed.tensor._api as dtensor
from torch._logging import LazyString
from torch._prims_common import ShapeType
from torch.distributed import RankType
from torch.distributed._local_tensor import maybe_run_for_local_tensor
from torch.distributed.device_mesh import DeviceMesh
from torch.distributed.tensor._collective_utils import redistribute_cost
from torch.distributed.tensor._dtensor_spec import DTensorSpec
from torch.distributed.tensor._op_schema import OpSchema
from torch.distributed.tensor.placement_types import (
_is_shard_like,
_StridedShard,
Partial,
Placement,
Replicate,
Shard,
)
logger = logging.getLogger(__name__)
def _format_implicit_redistribution_msg(schema: OpSchema) -> str:
return f"Implicit redistribution occurred for {schema} while ExplicitRedistributionContext was active"
class ExplicitRedistributionContext:
"""
Within this context manager, DTensor will refuse to perform implicit redistribution,
instead raising an error. Manual calls to ``redistribute()`` are required wherever a redistribution
must occur to avoid erroring. This can be used to ensure that the user is aware of all redistribution.
Note: it is easier to use this mode on just the forward pass of a typical DTensor program, as the backwards pass
may contain implicit redistribution calls that are not visible to the user and difficult to replace with manual
calls. Redistribution during backward can be made explicit by writing `autograd.Function`s that are no-op
during forward and perform a manual redistribution during backwards.
enable (bool) if False, disables the context manager. Can be used nested inside an enabled region.
strict (bool) if True, triggers on any redistribution. If False, only triggers on redistributions that perform
communication.
mode (str) Determines what happens when ExplicitRedistributionContext triggers:
"raise": raises an exception, "warn" issues a warning
"""
_local = threading.local()
def __init__(self, enable: bool = True, strict: bool = False, mode="raise"):
self._enable = enable
self._strict = strict
if mode not in ("raise", "warn"):
raise RuntimeError(f"Invalid mode {mode}")
self._raise_on_redistribution = mode == "raise"
@classmethod
def observe_redistribution(
cls,
src_spec: DTensorSpec,
dst_spec: DTensorSpec,
redistribution_msg: LazyString,
):
if instance := getattr(cls._local, "_active", None):
allowed = True
if instance._enable:
if instance._strict:
allowed = False
else:
allowed = redistribute_cost(src_spec, dst_spec) <= 0
if not allowed:
if instance._raise_on_redistribution:
raise RuntimeError(redistribution_msg)
else:
logger.warning(redistribution_msg)
def __enter__(self):
self._prev = getattr(ExplicitRedistributionContext._local, "_active", None)
ExplicitRedistributionContext._local._active = self
return self
def __exit__(self, exc_type, exc_val, exc_tb):
ExplicitRedistributionContext._local._active = self._prev
def compute_local_shape_and_global_offset(
global_shape: ShapeType,
mesh: DeviceMesh,
placements: Sequence[Placement],
skip_offset: bool = False,
) -> tuple[tuple[int, ...], tuple[int, ...]]:
"""
Compute the local tensor shape and the global offsets into the original tensor
of a DTensor on its current global rank. This is useful for checkpointing purpose.
Example:
global_tensor = [[0, 1, 2, 3, 4], sharded on mesh (DP=2, TP=2) with (Shard(1), Shard(1))
[10, 11, 12, 13, 14]]
This table shows the return value of local_shape and global_offset for each rank.
(`local_tensor` is for illustration only).
Note how the first coordinate of global_offset is always 0, corresponding to tensor dim 0 being replicated.
Rank local_tensor local_shape global_offset
-------------------------------------------------------------
0 [[0, 1], (2, 2) (0, 0)
[10, 11]]
1 [[2], (2, 1) (0, 2)
[12]]
2 [[3], (2, 1) (0, 3)
[13]]
3 [[4], (2, 1) (0, 4)
[14]]
Args:
global_shape (ShapeType): The global shape of the DTensor.
mesh (:class:`DeviceMesh`): The device mesh this DTensor is distributed on.
placements (Sequence[:class:`Placement`]]): The placements of the DTensor.
skip_offset (bool): If True, skip computing the global offsets and return an empty
tuple for global_offset. This can improve performance when only the local shape
is needed. Defaults to False.
Return:
local_shape: the shape of the DTensor's _local_tensor on the current rank.
global_offset: a tuple of offsets for each dimension of the global tensor shape,
identifying how this shard fits into the global tensor in each dimension. If
skip_offset is True, this will be an empty tuple.
"""
empty_offset = ()
if not mesh._is_current_rank_part_of_mesh():
# if rank not in the mesh, return empty offset
return ((0,), empty_offset)
return _compute_local_shape_and_global_offset(
global_shape, mesh.shape, mesh._sym_get_coordinate, placements, skip_offset
)
@maybe_run_for_local_tensor
def _get_shard_size_and_offsets(
curr_local_size: int,
mesh_dim_size: int,
rank: RankType,
placement: Shard | _StridedShard,
previous_offsets,
zero_global_offset: int,
skip_offset: bool,
) -> tuple[int, torch.Tensor | None]:
kwargs: dict[str, Any] = {
"curr_local_size": curr_local_size,
"num_chunks": mesh_dim_size,
"rank": rank,
}
if isinstance(placement, _StridedShard):
kwargs["return_first_offset"] = False
shard_size, shard_offsets = placement._local_shard_size_and_offset(**kwargs)
if skip_offset:
return shard_size, None
if shard_size == 0:
return shard_size, torch.arange(zero_global_offset, zero_global_offset + 1)
if isinstance(placement, Shard) and not isinstance(placement, _StridedShard):
if not isinstance(shard_offsets, int):
raise AssertionError
index = torch.arange(shard_offsets, shard_offsets + shard_size)
else:
if not isinstance(shard_offsets, list):
raise AssertionError
index = torch.tensor(shard_offsets)
if previous_offsets is None:
return shard_size, index
else:
return shard_size, previous_offsets[index]
@maybe_run_for_local_tensor
def _get_first_offset(offsets: torch.Tensor) -> int:
return int(offsets[0])
# accept 'plain data types' to enable simpler unit testing without creating device mesh
def _compute_local_shape_and_global_offset(
global_shape: ShapeType,
mesh_shape: ShapeType,
my_coordinate: list[int] | Callable[[int], RankType] | None,
placements: Sequence[Placement],
skip_offset: bool = False,
) -> tuple[tuple[int, ...], tuple[int, ...]]:
"""
Suppose you have a full tensor with size global_shape, and you have sharded
it according to placements for mesh_shape. This function returns, for a
specific coordinate my_coordinate in the device mesh:
- The size of your local shard WITHOUT padding (i.e., if you have
an uneven split, your size might be smaller than the other entries
in your dim), and
- Where the data for your shard begins, in the full tensor.
This function is fairly simple if your tensor is evenly sharded; the complication
is around uneven splits. There is also some complication for handling StridedShard,
which changes the order you should apply sharding.
Args:
global_shape (ShapeType): The global shape of the tensor.
mesh_shape (ShapeType): The shape of the device mesh.
my_coordinate (Optional[list[int]]): The coordinate of the current rank in the device mesh.
placements (Sequence[Placement]): The placements of the DTensor.
skip_offset (bool): If True, skip computing the global offsets and return an empty
tuple for global_offset. This can improve performance when only the local shape
is needed. Defaults to False.
Returns:
tuple: A tuple containing:
- local_shape (tuple[int, ...]): The shape of the local shard on the current rank.
- global_offset (tuple[int, ...]): The offsets for each dimension identifying where
this shard begins in the global tensor. If skip_offset is True, this will be an
empty tuple.
"""
if isinstance(my_coordinate, (list, tuple)):
_coord: list | tuple = my_coordinate
def coordinate_lookup(dim: int) -> RankType:
return _coord[dim]
else:
if my_coordinate is None:
raise AssertionError
coordinate_lookup = my_coordinate
local_shape = list(global_shape)
# Perform shard from left to right. For example,
# global tensor: [0, 1, 2, 3, 4, 5, 6, 7]
# placements: S(0), SS(0, split_factor=2)
# mesh_shape: (2, 2)
# After S(0), shard_dim_to_global_offsets are
# {0: [0, 1, 2, 3]} on my_coordinate [0, 0] [0, 1]
# {0: [4, 5, 6, 7]} on my_coordinate [1, 0] [1, 1]
# After SS(0, split_factor=2), shard_dim_to_global_offsets are
# {0: [0, 2]} on my_coordinate [0, 0]
# {0: [1, 3]} on my_coordinate [0, 1]
# {0: [4, 6]} on my_coordinate [1, 0]
# {0: [5, 7]} on my_coordinate [1, 1]
shard_dim_to_global_offsets = {}
for mesh_dim, placement in enumerate(placements):
if not isinstance(placement, (Shard, _StridedShard)):
continue
shard_dim = placement.dim
zero_global_offset = global_shape[shard_dim]
if shard_dim >= len(local_shape):
raise AssertionError(
f"Sharding dim {shard_dim} greater than tensor ndim {len(local_shape)}"
)
previous_offsets = shard_dim_to_global_offsets.get(shard_dim)
shard_size, shard_offsets = _get_shard_size_and_offsets(
local_shape[shard_dim],
mesh_shape[mesh_dim],
coordinate_lookup(mesh_dim),
placement,
previous_offsets,
zero_global_offset,
skip_offset,
)
local_shape[shard_dim] = shard_size
shard_dim_to_global_offsets[shard_dim] = shard_offsets
if skip_offset:
return tuple(local_shape), ()
global_offset = [0] * len(global_shape)
for shard_dim, global_offsets in shard_dim_to_global_offsets.items():
global_offset[shard_dim] = _get_first_offset(global_offsets)
return tuple(local_shape), tuple(global_offset)
compute_global_tensor_info = torch._C._DTensor_compute_global_tensor_info
def compute_local_tensor_info(
global_tensor: torch.Tensor,
mesh: DeviceMesh,
placements: Sequence[Placement],
) -> tuple[list[int], list[int]]:
"""
Compute the local size and stride of a DTensor from the given global tensor info.
For example, if we have a global tensor with size (4, 8, 4) and stride (32, 1, 8).
If the DTensor placements are [Shard(2)] and world_size is 2;
then the local size is (4, 8, 2) and stride is (16, 1, 8).
Args:
tensor (:class:`torch.Tensor`):
Global tensor which DTensor will distribute
mesh (:class:`DeviceMesh`):
Object which describes the mesh topology
of devices for the DTensor.
placements (Sequence[:class:`Placement`]):
The attribute of the DTensor that describes its layout
on the mesh topology.
Returns:
local_shape: A List of int which specifies the size of the local tensor.
local_stride: A List of int which specifies the stride of the local tensor.
"""
local_shape = list(global_tensor.size())
local_stride = list(global_tensor.stride())
for idx, placement in enumerate(placements):
mesh_dim_size = mesh.size(idx)
if _is_shard_like(placement):
if placement.dim < 0:
raise AssertionError(
"Shard placements should have negative dims normalized in "
f"the user-facing APIs: {placement}"
)
shard_dim = placement.dim
if shard_dim >= len(local_shape):
raise AssertionError(
f"Sharding dim {shard_dim} greater than tensor ndim {len(local_shape)} "
f"for placement number {idx}."
)
global_dim_size = local_shape[shard_dim]
if global_dim_size % mesh_dim_size != 0:
raise AssertionError(
f"Global dim {global_dim_size} not divisible by mesh size {mesh_dim_size}"
)
local_shape[shard_dim] = global_dim_size // mesh_dim_size
# shrink strides that were scaled up globally
for i in range(len(local_stride)):
if (
i != shard_dim
and local_stride[i] >= local_stride[shard_dim] * mesh_dim_size
):
local_stride[i] = local_stride[i] // mesh_dim_size
elif not isinstance(placement, (Replicate, Partial)):
raise RuntimeError(f"placement type {type(placement)} not supported!")
return local_shape, local_stride
def compute_global_tensor_shape(
shape: torch.Size, mesh: DeviceMesh, placements: Sequence[Placement]
) -> torch.Size:
"""
Compute the global size of a DTensor from the given local tensor shape,
the mesh and placements. Different from `compute_global_tensor_info`,
which assumes sharding is even, this util allgathers local shards' shapes
from all ranks and thus can support uneven sharding.
NOTE: Currently this function only supports 1D mesh.
Args:
shape (:class:`torch.Size`):
Shape of the local tensor
mesh (:class:`DeviceMesh`):
Object which describes the mesh topology
of devices for the DTensor.
placements (Sequence[:class:`Placement`]]):
The attribute of the DTensor that describes its layout
on the mesh topology.
Return:
tensor_shape: Shape of the global DTensor.
"""
if len(placements) != 1:
raise NotImplementedError(
"compute_global_tensor_shape only supports 1 placement for now."
)
if len(placements) != mesh.ndim:
raise RuntimeError(
"Expected one placement per mesh dim, "
f"but found {len(placements)} placements and {mesh.ndim} mesh dims."
)
if isinstance(placements[0], Replicate):
return shape
# NOTE: isinstance(_, Shard) does not match _StridedShard; see _is_shard_like().
elif isinstance(placements[0], Shard):
@maybe_run_for_local_tensor
def _create_local_shape_tensor(shape):
return torch.tensor(list(shape), device=mesh.device_type)
local_shape = _create_local_shape_tensor(shape)
gathered_shaped_tensors = [
torch.empty_like(local_shape, device=local_shape.device)
for _ in range(mesh.size())
]
funcol.all_gather_inplace(gathered_shaped_tensors, local_shape, mesh)
@maybe_run_for_local_tensor
def _validate_and_compute_global_shape(local_shape, gathered_shaped_tensors):
sharded_dim_sum = 0
shard_dim = placements[0].dim # type: ignore[union-attr]
other_dims = [d for d in range(len(shape)) if d != shard_dim]
for shape_tensor in gathered_shaped_tensors:
if not torch.equal(local_shape[other_dims], shape_tensor[other_dims]):
raise RuntimeError(
"Non-sharded dimensions should have identical size across ranks."
)
shape_tensor_list = shape_tensor.tolist()
sharded_dim_sum += shape_tensor_list[shard_dim]
return sharded_dim_sum
sharded_dim_sum = _validate_and_compute_global_shape(
local_shape, gathered_shaped_tensors
)
global_shape = list(shape)
global_shape[placements[0].dim] = sharded_dim_sum
return torch.Size(global_shape)
else:
raise NotImplementedError(
f"Placement type {type(placements[0])} not supported."
)
def try_find_mesh_from_args(
op_call: torch._ops.OpOverload, args: Sequence[object]
) -> DeviceMesh:
"""
Find the device mesh object from args.
It returns None if no mesh is found.
NOTE: we can optimize this search if needed
"""
for arg in args:
if isinstance(arg, (dtensor.DTensor, DTensorSpec)):
return arg.device_mesh
elif (
isinstance(arg, (list, tuple))
and len(arg) > 0
and isinstance(arg[0], (dtensor.DTensor, DTensorSpec))
):
return arg[0].device_mesh
raise ValueError(f"Cannot find device mesh from args for op : {op_call}.")
def compute_local_stride(
global_stride: ShapeType, local_shape: ShapeType
) -> tuple[int, ...]:
"""
Compute the stride of a local tensor shard, given the global stride and local shape.
Derives strides by preserving the memory layout (dimension ordering) implied
by the global strides, then computing contiguous strides for the local shape
in that order. Assumes the global tensor is non-overlapping and dense.
"""
ndim = len(global_stride)
# Sort dims by global stride descending to recover memory layout order.
# Stable sort preserves original dim order for ties, which only occur
# on size-1 dims where the stride value is semantically irrelevant.
perm = sorted(range(ndim), key=lambda d: global_stride[d], reverse=True)
local_strides = [0] * ndim
s = 1
for d in reversed(perm):
local_strides[d] = s
s *= local_shape[d]
return tuple(local_strides)
def normalize_to_torch_size(size) -> torch.Size: # type: ignore[no-untyped-def]
"""
Unify variable types of size argument to torch.Size
Acceptable types include:
int, Sequence[int], Tuple[int], Tuple[Sequence[int]],
or torch.Size
"""
if isinstance(size, torch.Size):
return size
if isinstance(size, int):
torch_size = [size]
elif len(size) == 1 and isinstance(size[0], Sequence):
torch_size = list(size[0])
else:
torch_size = list(size)
return torch.Size(torch_size)
def assert_no_mixed_partial_types(placements: Sequence[Placement]) -> None:
"""
Assert that a placement list doesn't contain mixed Partial reduce types.
Mixed Partial types (e.g., ``Partial("sum")`` and ``Partial("max")`` together in the
same placement list) are not supported and will raise a ``ValueError``. This restriction
exists because nonlinear reductions (e.g., max) don't commute with linear reductions
(e.g., sum), which means the relative ordering of different partial types would be
semantically critical during redistribution. Rather than introducing complex ordering
constraints, we prohibit mixing different Partial reduce types.
Note: Partial("sum") and Partial("avg") DO commute with each other, so they can be ordered
arbitrarily, and we allow this.
This function is called internally by public APIs like :meth:`DTensor.from_local` and
:func:`distribute_tensor` to validate placements early, before DTensor construction.
Args:
placements (Sequence[:class:`Placement`]): A sequence of placement specifications
to validate.
Raises:
ValueError: If the placements contain more than one distinct Partial reduce type.
"""
partial_reduce_ops: set[str] = set()
for p in placements:
if isinstance(p, Partial):
partial_reduce_ops.add(p.reduce_op)
if len(partial_reduce_ops) > 1 and partial_reduce_ops != {"sum", "avg"}:
raise ValueError(
f"Mixed Partial reduce types are not supported in the same placement list. "
f"Found reduce ops: {partial_reduce_ops}. "
f"Please ensure all Partial placements use the same reduce operation."
)
@@ -0,0 +1,70 @@
# mypy: allow-untyped-defs
import torch._C
from torch.distributed.tensor.debug._comm_mode import CommDebugMode
from torch.distributed.tensor.debug._visualize_sharding import visualize_sharding
__all__ = ["CommDebugMode", "visualize_sharding"]
def _get_python_sharding_prop_cache_info():
"""
Get the cache info for the Python sharding propagation cache, used for debugging purpose only.
This would return a named tuple showing hits, misses, maxsize and cursize of the sharding
propagator cache. Note that directly calling into the sharding propagator does not share cache
state with the DTensor dispatch fast path!
"""
from torch.distributed.tensor._api import DTensor
return (
DTensor._op_dispatcher.sharding_propagator.propagate_op_sharding.cache_info() # type:ignore[attr-defined]
)
def _get_fast_path_sharding_prop_cache_stats():
"""
Get a tuple (hits, misses) for the fast path sharding propagation cache, used for debugging
only.
"""
return torch._C._get_DTensor_sharding_propagator_cache_stats()
def _clear_python_sharding_prop_cache():
"""
Clears the cache for the Python sharding propagation cache, used for debugging purpose only.
"""
from torch.distributed.tensor._api import DTensor
return (
DTensor._op_dispatcher.sharding_propagator.propagate_op_sharding.cache_clear() # type:ignore[attr-defined]
)
def _clear_fast_path_sharding_prop_cache():
"""
Clears the cache for the fast path sharding propagation cache, used for debugging purpose only.
"""
torch._C._clear_DTensor_sharding_propagator_cache()
def _clear_sharding_prop_cache():
"""
Clears both the Python and fast path sharding propagation caches, used for debugging purpose only.
This is the recommended way to clear all sharding propagation caches.
"""
_clear_python_sharding_prop_cache()
_clear_fast_path_sharding_prop_cache()
def _reinit_dispatch_logger():
"""
Resets the cached DTensor dispatch logger state so that the next DTensor
dispatch re-checks whether debug logging is enabled. Call this after
changing the log level on the ``torch.distributed.tensor._dispatch`` logger.
"""
torch._C._reinit_DTensor_dispatch_logger()
# Set namespace for exposed private names
CommDebugMode.__module__ = "torch.distributed.tensor.debug"
visualize_sharding.__module__ = "torch.distributed.tensor.debug"
@@ -0,0 +1,747 @@
# mypy: allow-untyped-defs
import copy
import json
import re
import weakref
from collections import defaultdict
from typing import Any
import torch
import torch.nn
from torch._guards import detect_fake_mode
from torch.autograd.graph import register_multi_grad_hook
from torch.distributed._tools.mod_tracker import ModTracker
from torch.distributed.tensor._api import DTensor
from torch.nn.modules.module import (
register_module_forward_hook,
register_module_forward_pre_hook,
register_module_full_backward_pre_hook,
)
from torch.utils._python_dispatch import TorchDispatchMode
from torch.utils._pytree import tree_flatten
__all__ = ["CommDebugMode"]
funcol_native = torch.ops._c10d_functional
funcol_py = torch.ops.c10d_functional
funcol_autograd = torch.ops._c10d_functional_autograd
c10d_ops = torch.ops.c10d
NATIVE_TO_PY_MAPPING = {
funcol_native.all_gather_into_tensor: funcol_py.all_gather_into_tensor,
funcol_native.all_gather_into_tensor_coalesced: funcol_py.all_gather_into_tensor_coalesced,
funcol_native.all_reduce: funcol_py.all_reduce,
funcol_native.all_reduce_coalesced: funcol_py.all_reduce_coalesced,
funcol_native.all_to_all_single: funcol_py.all_to_all_single,
funcol_native.broadcast: funcol_py.broadcast,
funcol_native.reduce_scatter_tensor: funcol_py.reduce_scatter_tensor,
funcol_native.reduce_scatter_tensor_coalesced: funcol_py.reduce_scatter_tensor_coalesced,
# functional ops
funcol_autograd.all_to_all_single: funcol_py.all_to_all_single,
}
c10d_collective_ops = {
c10d_ops._allgather_base_,
c10d_ops._reduce_scatter_base_,
c10d_ops.allgather_,
c10d_ops.allgather_coalesced_,
c10d_ops.allgather_into_tensor_coalesced_,
c10d_ops.allreduce_,
c10d_ops.allreduce_coalesced_,
c10d_ops.alltoall_,
c10d_ops.alltoall_base_,
c10d_ops.broadcast_,
c10d_ops.gather_,
c10d_ops.scatter_,
c10d_ops.reduce_,
c10d_ops.reduce_scatter_,
c10d_ops.reduce_scatter_tensor_coalesced_,
}
trivial_ops = {
"aten.detach.default",
"aten.t.default",
"aten.view.default",
"aten._to_copy.default",
"aten.as_strided.default",
"aten.transpose.int",
}
class _CommModeModuleTracker(ModTracker):
"""
Inherits ModuleTracker and expands on its functionality to track the
parameters and sharding information of a model at a module-level
"""
def __init__(self):
super().__init__()
self.module_helper_dict = {}
self.module_parameters_dict = {}
self.module_parents_dict = {}
self.register_forward_hook_handles = {}
self.parent_dict = {}
self.parent_list = []
self.sharding_dict = {}
self.activation_checkpointing = False
self.name = ""
def _fw_set_module_hook(self, mod, input, output):
"""
Updates the current module after module finishes running and
all other hooks are resolved
"""
if self.is_bw:
self.activation_checkpointing = True
else:
self.activation_checkpointing = False
if not self.activation_checkpointing:
# module is no longer parent of next modules
self.parent_list.pop()
# set current module to previous parent module
self.name = self.parent_list[-1]
def _fw_pre_hook(self, mod, input):
"""
This function is called before the forward pass of a module. It
collects the parameters and sharding information of a module and
stores it in a dictionary.
"""
if self.is_bw:
self.activation_checkpointing = True
else:
self.activation_checkpointing = False
self.name = super()._get_mod_name(mod)
w_mod = weakref.ref(mod)
# adds current sub-module to module tracker parent class
super()._get_append_fn(w_mod, self.name, False)()
args, _ = tree_flatten(input)
tensors = [a for a in args if isinstance(a, torch.Tensor) and a.requires_grad]
if not self.is_bw and tensors:
register_multi_grad_hook(
tensors, super()._get_pop_fn(w_mod, self.name, True)
)
if not self.activation_checkpointing:
# contains information about module ordering and depth in the module tree
if self.name not in self.module_helper_dict:
self.module_helper_dict[self.name] = {}
self.module_helper_dict[self.name]["module_type"] = (
str(type(mod)).replace("<", "").replace(">", "")
)
self.module_helper_dict[self.name]["depth"] = len(self.parents) - 1
for param_name, param in mod.named_parameters(recurse=False):
if self.name not in self.module_parameters_dict:
self.module_parameters_dict[self.name] = {}
self.module_parameters_dict[self.name][param_name] = param.data
if isinstance(param.data, DTensor):
key_name = self.name + "." + param_name
self.sharding_dict[key_name] = param.data.placements
if "parameters" not in self.module_helper_dict[self.name]:
self.module_helper_dict[self.name]["parameters"] = {}
self.module_helper_dict[self.name]["parameters"][param_name] = str(
param.data.placements
)
# used to store module's parents to ensure correctness in backward pass/checkpointing
if self.name not in self.module_parents_dict:
self.module_parents_dict[self.name] = copy.deepcopy(self.parents)
# used to create parent-child module associations for json dumps
parent = self.parent_list[-1]
if parent not in self.parent_dict:
self.parent_dict[parent] = []
self.parent_dict[parent].append(self.name)
self.parent_list.append(self.name)
self.register_forward_hook_handles[self.name] = mod.register_forward_hook(
self._fw_set_module_hook
)
def _fw_post_hook(self, mod, input, output): # pylint: disable=useless-parent-delegation
"""
This function is called when the forward pass of a module is called.
It updates the module tracker and removes the module from parent data
"""
super()._fw_post_hook(mod, input, output)
def _bw_hook(self, mod, output):
"""
This function is called when the backward pass of a module is called. It
updates the current module for backward passes
"""
self.activation_checkpointing = False
self.name = super()._get_mod_name(mod)
def __enter__(self):
self.activation_checkpointing = False
self.module_parameters_dict.clear()
self.sharding_dict.clear()
self.parent_dict.clear()
self.parent_list = ["Global"]
self.module_helper_dict.clear()
self.module_helper_dict["Global"] = {"depth": 0}
self.module_parents_dict.clear()
self.module_parents_dict["Global"] = set()
self._fw_pre_handle = register_module_forward_pre_hook(self._fw_pre_hook)
self._fw_post_handle = register_module_forward_hook(self._fw_post_hook)
self.register_forward_hook_handles.clear()
self._bw_handle = register_module_full_backward_pre_hook(self._bw_hook)
self.name = "Global"
def __exit__(self, *args):
super().__exit__(*args)
self._bw_handle.remove()
# removes all forward_hook handles added in the pre-hook
for handle in self.register_forward_hook_handles.values():
handle.remove()
def print_paramater_info(self):
print(self.module_parameters_dict)
def print_sharding_info(self):
for key, value in self.sharding_dict.items():
print(key + ": " + str(value))
class CommDebugMode(TorchDispatchMode):
"""
:class:`CommDebugMode` is a context manager that counts the number of
functional collectives within its context. It does this using a
``TorchDispatchMode``.
.. note:: Not all collectives are supported yet.
Example usage
.. code-block:: python
mod = ...
comm_mode = CommDebugMode()
with comm_mode:
mod.sum().backward()
print(comm_mode.get_comm_counts())
"""
def __init__(self):
super().__init__()
self.supports_higher_order_operators = True
self.comm_counts: dict[Any, int] = defaultdict(int)
self.comm_module_counts = {}
self.comm_module_operation_counts = {}
self.comm_registry = set()
for native_op, py_op in NATIVE_TO_PY_MAPPING.items():
self.comm_registry.add(native_op)
self.comm_registry.add(py_op)
self.comm_registry.add(torch.ops._dtensor.shard_dim_alltoall)
self.advanced_module_tracker = _CommModeModuleTracker()
def generate_json_dump(self, file_name="comm_mode_log.json", noise_level=3):
"""
Creates json file used to build browser visual
0. prints module-level collective counts
1. prints dTensor operations not included in trivial operations
2. prints operations not included in trivial operations
3. prints all operations
"""
(
include_DTensor_ops,
include_module_data,
include_ops,
include_trivial_ops,
) = self._set_noise_parameters(noise_level)
# recursively builds json data
def add_json_information(json_dict, fqn):
json_dict["fqn"] = fqn
json_dict["module_type"] = ""
json_dict["parameters"] = []
json_dict["children"] = []
json_dict["collectives_forward"] = []
json_dict["collectives_backward"] = []
json_dict["operations_forward"] = []
json_dict["operations_backward"] = []
# adds module layer type and parameters, and their sharding
if (
"module_type" in self.advanced_module_tracker.module_helper_dict[fqn]
and include_module_data
):
json_dict["module_type"] = (
self.advanced_module_tracker.module_helper_dict[fqn]["module_type"]
)
if "parameters" in self.advanced_module_tracker.module_helper_dict[fqn]:
for (
param_name,
placement,
) in self.advanced_module_tracker.module_helper_dict[fqn][
"parameters"
].items():
json_dict["parameters"].append((param_name, placement))
# adds module collective information
if fqn in self.comm_module_counts:
for collective, count in self.comm_module_counts[fqn][
"forward"
].items():
json_dict["collectives_forward"].append((str(collective), count))
for collective, count in self.comm_module_counts[fqn][
"backward"
].items():
json_dict["collectives_backward"].append((str(collective), count))
# adds module operation information
forward_operations = []
backward_operations = []
checkpointing_operations = []
# only get operations if the minimum operation noise level is set to true
if include_DTensor_ops:
if fqn in self.comm_module_operation_counts:
(
forward_operations,
backward_operations,
checkpointing_operations,
) = self._get_operations_list(
self.comm_module_operation_counts[fqn]
)
# remove all operations who don't have DTensor inputs
if not include_ops:
forward_operations = [
op for op in forward_operations if len(op["input_sharding"])
]
backward_operations = [
op for op in backward_operations if len(op["input_sharding"])
]
checkpointing_operations = [
op for op in checkpointing_operations if len(op["input_sharding"])
]
# remove all operations in trivial operations set
if not include_trivial_ops:
forward_operations = [
op
for op in forward_operations
if str(op["name"]) not in trivial_ops
]
backward_operations = [
op
for op in backward_operations
if str(op["name"]) not in trivial_ops
]
checkpointing_operations = [
op
for op in checkpointing_operations
if str(op["name"]) not in trivial_ops
]
# converts operation information into string format for json.dumps()
forward_operations = copy.deepcopy(forward_operations)
for op in forward_operations:
op["name"] = str(op["name"])
for i in range(len(op["input_sharding"])):
op["input_sharding"][i] = str(op["input_sharding"][i])
op["input_shape"][i] = str(op["input_shape"][i])
backward_operations = copy.deepcopy(backward_operations)
for op in backward_operations:
op["name"] = str(op["name"])
for i in range(len(op["input_sharding"])):
op["input_sharding"][i] = str(op["input_sharding"][i])
op["input_shape"][i] = str(op["input_shape"][i])
checkpointing_operations = copy.deepcopy(checkpointing_operations)
for op in checkpointing_operations:
op["name"] = str(op["name"])
for i in range(len(op["input_sharding"])):
op["input_sharding"][i] = str(op["input_sharding"][i])
op["input_shape"][i] = str(op["input_shape"][i])
json_dict["operations_forward"] = forward_operations
json_dict["operations_backward"] = backward_operations
json_dict["operations_checkpointing"] = checkpointing_operations
if fqn not in self.advanced_module_tracker.parent_dict:
return json_dict
# recursively adds module's children
for ele in self.advanced_module_tracker.parent_dict[fqn]:
json_dict["children"].append(add_json_information({}, ele))
return json_dict
json_dict: dict[str, Any] = {}
add_json_information(json_dict, "Global")
# converts dictionary into json file
with open(file_name, "w") as json_file:
json.dump(json_dict, json_file, indent=4)
def generate_comm_debug_tracing_table(self, noise_level=3):
"""
Generates detailed table displaying operations and collective tracing information
on a module level. Amount of information is dependent on noise_level
0. prints module-level collective counts
1. prints dTensor operations not included in trivial operations, module information
2. prints operations not included in trivial operations
3. prints all operations
"""
(
include_DTensor_ops,
include_module_data,
include_ops,
include_trivial_ops,
) = self._set_noise_parameters(noise_level)
table = ""
for fqn in self.advanced_module_tracker.module_helper_dict:
# setting up indentations for table formatting
indent = " " * (
2 * self.advanced_module_tracker.module_helper_dict[fqn]["depth"]
)
table += f"{indent}{fqn}\n"
if include_module_data:
if (
"module_type"
in self.advanced_module_tracker.module_helper_dict[fqn]
):
module_type = self.advanced_module_tracker.module_helper_dict[fqn][
"module_type"
]
table += f"{indent}*module type: {module_type}\n"
if "parameters" in self.advanced_module_tracker.module_helper_dict[fqn]:
table += f"{indent}*Parameter List\n"
for (
param_name,
placement,
) in self.advanced_module_tracker.module_helper_dict[fqn][
"parameters"
].items():
table += f"{indent} *{param_name}: {placement}\n"
indent += " "
collective_indent = " " * (
2 * self.advanced_module_tracker.module_helper_dict[fqn]["depth"] + 2
)
operation_indent = " " * (
2 * self.advanced_module_tracker.module_helper_dict[fqn]["depth"] + 3
)
# separate the module's collective and operations by forward and backward
forward_collectives = {}
backward_collectives = {}
if fqn in self.comm_module_counts:
forward_collectives = self.comm_module_counts[fqn]["forward"]
backward_collectives = self.comm_module_counts[fqn]["backward"]
forward_operations = []
backward_operations = []
checkpointing_operations = []
if include_DTensor_ops:
if fqn in self.comm_module_operation_counts:
(
forward_operations,
backward_operations,
checkpointing_operations,
) = self._get_operations_list(
self.comm_module_operation_counts[fqn]
)
def add_tracing_information(table, collectives_dict, operation_list):
"""
adds tracing information for module's forward or backward
"""
for collective, count in collectives_dict.items():
table += (
f"\033[1;33m{collective_indent}*{collective}: {count}\033[0m\n"
)
def add_operations(
table, operation, collective_indent, operation_indent
):
"""
adds operation information to the table
"""
table += f"\033[1;33m{collective_indent}**{operation_name}\033[0m\n"
if len(operation["input_shape"]):
operation_shape = operation["input_shape"]
operation_sharding = operation["input_sharding"]
operation_device_mesh = operation["device_mesh"]
table += f"\033[1;31m{operation_indent}shape: {operation_shape}\033[0m\n"
table += f"\033[1;31m{operation_indent}sharding: {operation_sharding}\033[0m\n"
table += f"\033[1;31m{operation_indent}device mesh: {operation_device_mesh}\033[0m\n"
return table
for operation in operation_list:
operation_name = str(operation["name"])
# include all operations
if include_trivial_ops:
table = add_operations(
table, operation, collective_indent, operation_indent
)
# include all operations not in trivial operations
elif include_ops and operation_name not in trivial_ops:
table = add_operations(
table, operation, collective_indent, operation_indent
)
# only include dTensor operations not in trivial set
elif (
include_DTensor_ops
and (operation_name not in trivial_ops)
and len(operation["input_shape"])
):
table = add_operations(
table, operation, collective_indent, operation_indent
)
return table
if len(forward_collectives) or len(forward_operations):
table += f"{indent}FORWARD PASS\n"
table = add_tracing_information(
table, forward_collectives, forward_operations
)
if len(backward_collectives) or len(backward_operations):
table += f"{indent}BACKWARD PASS\n"
table = add_tracing_information(
table, backward_collectives, backward_operations
)
if len(checkpointing_operations):
table += f"{indent}ACTIVATION CHECKPOINTING\n"
table = add_tracing_information(table, {}, checkpointing_operations)
return table
def _get_operations_list(self, module_operation_counts):
forward_operations = [
op for op in module_operation_counts["operations_list"] if not op["is_bw"]
]
backward_operations = [
op
for op in module_operation_counts["operations_list"]
if op["is_bw"] and not op["is_activation_checkpointing"]
]
checkpointing_operations = [
op
for op in module_operation_counts["operations_list"]
if op["is_activation_checkpointing"]
]
return forward_operations, backward_operations, checkpointing_operations
def get_total_counts(self) -> int:
return sum(self.comm_counts.values())
def get_comm_counts(self) -> dict[Any, int]:
"""Returns the communication counts as a dictionary.
Returns:
Dict[Any, int]: The communication counts as a dictionary.
"""
return self.comm_counts
def get_parameter_info(self) -> dict[str, dict[str, Any]]:
return self.advanced_module_tracker.module_parameters_dict
def get_sharding_info(self) -> dict[str, dict[str, Any]]:
return self.advanced_module_tracker.sharding_dict
def __enter__(self):
self.comm_counts.clear()
self.comm_module_counts.clear()
self.comm_module_counts["Global"] = {}
self.comm_module_counts["Global"]["forward"] = defaultdict(int)
self.comm_module_counts["Global"]["backward"] = defaultdict(int)
self.comm_module_operation_counts.clear()
super().__enter__()
self.advanced_module_tracker.__enter__()
return self
# pyrefly: ignore [bad-override]
def __exit__(self, *args):
self.advanced_module_tracker.__exit__()
super().__exit__(*args)
def log_comm_debug_tracing_table_to_file(
self, file_name="comm_mode_log.txt", noise_level=3
):
"""
Alternative to console CommDebugMode output, writes to file specified by the user
"""
ansi_escape = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
table = ansi_escape.sub("", self.generate_comm_debug_tracing_table(noise_level))
with open(file_name, "w") as log_file:
log_file.write(table)
def _set_noise_parameters(self, noise_level):
"""
sets variables controlling what information displays based on noise level
"""
include_DTensor_ops = False
include_module_data = False
include_ops = False
include_trivial_ops = False
if noise_level > 0:
include_DTensor_ops = True
include_module_data = True
if noise_level > 1:
include_ops = True
if noise_level > 2:
include_trivial_ops = True
return (
include_DTensor_ops,
include_module_data,
include_ops,
include_trivial_ops,
)
def __torch_dispatch__(self, func, types, args=(), kwargs=None):
# When running this mode with DTensor, ordinarily all modes will
# run **before** subclasses get a chance to run.
# Returning NotImplemented here gives us a chance to let DTensor
# run and desugar into comms ops, before CommDebugMode sees them.
# Higher-order operators (e.g. run_dtensor_rng_op) don't have
# _overloadpacket and aren't collectives — just redispatch.
if isinstance(func, torch._ops.HigherOrderOperator):
kwargs = kwargs if kwargs else {}
return func(*args, **kwargs)
# sets up operation-level collective count
if self.advanced_module_tracker.name not in self.comm_module_operation_counts:
# dictionary should hold module input and output shape, operations list and collective counter
self.comm_module_operation_counts[self.advanced_module_tracker.name] = {
"operations_list": []
}
operation_dict = {}
operation_dict["name"] = func
operation_dict["input_shape"] = []
operation_dict["input_sharding"] = []
operation_dict["device_mesh"] = ""
# tracks if the operation is part of the backward pass
operation_dict["is_bw"] = self.advanced_module_tracker.is_bw
# tracks if the operation is part of activation checkpointing
operation_dict["is_activation_checkpointing"] = (
self.advanced_module_tracker.activation_checkpointing
)
if any(t == DTensor for t in types):
for ele in args:
if isinstance(ele, DTensor):
# saves shapes and placements of all DTensor args
operation_dict["input_shape"].append(ele.shape)
operation_dict["input_sharding"].append(ele.placements)
operation_dict["device_mesh"] = str(ele.device_mesh)
self.comm_module_operation_counts[self.advanced_module_tracker.name][
"operations_list"
].append(operation_dict)
return NotImplemented
kwargs = kwargs if kwargs else {}
out = func(*args, **kwargs)
func_packet = func._overloadpacket
# We have many tests that use CommDebugMode to verify the occurrence of
# collectives. These tests do so by querying comm_counts with legacy
# funcol ops as key. For the purpose of native funcol migration, we
# need these tests to work for both legacy and native funcol. To avoid
# the need to modify all tests to accommodate the two implementations,
# we make CommDebugMode translate native funcol ops into legacy funcol
# ops until the migration finishes.
if func_packet in self.comm_registry or func_packet in c10d_collective_ops:
if func_packet in NATIVE_TO_PY_MAPPING:
func_packet = NATIVE_TO_PY_MAPPING[func_packet]
self.comm_counts[func_packet] += 1
key = "forward"
if self.advanced_module_tracker.is_bw:
key = "backward"
# adds collective count to current module
if self.advanced_module_tracker.name not in self.comm_module_counts:
self.comm_module_counts[self.advanced_module_tracker.name] = {}
self.comm_module_counts[self.advanced_module_tracker.name][
"forward"
] = defaultdict(int)
self.comm_module_counts[self.advanced_module_tracker.name][
"backward"
] = defaultdict(int)
self.comm_module_counts[self.advanced_module_tracker.name][key][
func_packet
] += 1
# adds collective count to parent modules
for par in self.advanced_module_tracker.module_parents_dict[
self.advanced_module_tracker.name
]:
# makes sure we aren't double counting when current sub-module hasn't been removed from parents
if par != self.advanced_module_tracker.name:
if par not in self.comm_module_counts:
self.comm_module_counts[par] = {}
self.comm_module_counts[par]["forward"] = defaultdict(int)
self.comm_module_counts[par]["backward"] = defaultdict(int)
self.comm_module_counts[par][key][func_packet] += 1
# if tensor op uses fake tensors, return
if detect_fake_mode(args):
return out
# add tensor operation to module operation list
self.comm_module_operation_counts[self.advanced_module_tracker.name][
"operations_list"
].append(operation_dict)
return out
def __repr__(self):
return f"CommDebugMode(get_total_counts()={self.get_total_counts()})"
@@ -0,0 +1,105 @@
# mypy: allow-untyped-defs
from operator import itemgetter
import torch
import torch.fx
import torch.nn as nn
from functorch.compile import make_boxed_func
from torch._functorch.compilers import aot_module
from torch._inductor.decomposition import select_decomp_table
from torch.distributed.tensor import DTensor
inductor_decomps = select_decomp_table()
graphs: list[torch.fx.GraphModule] = []
def fwd_bwd_compiler(fx_g, _):
graphs.append(fx_g)
return make_boxed_func(fx_g)
def get_inductor_decomp_graphs(model: nn.Module, args, kwargs):
"""
Obtain forward and backward graphs of a model with inductor decompositions using tracing and aot_module.
Convenient util to get the fwd and bwd graphs of an arbitrary model
with inductor decompositions. Note that this would simply do tracing
with aot_module and don't ensure correctness. This is useful to track
the ops needed in DTensor.
"""
compiled_mod = aot_module(
model, fw_compiler=fwd_bwd_compiler, decompositions=inductor_decomps
)
output = compiled_mod(*args, **kwargs)
if output.ndim != 0:
# if output is not a scalar tensor, by default sum it in order to
# run backward
output = output.sum()
output.backward()
# one fwd, one bwd graph
if len(graphs) != 2:
raise AssertionError
return graphs
def print_op_coverage_summary(model: nn.Module, args, kwargs, *, output_csv=False):
"""
Util to print the operator coverage summary of a certain model with tabulute.
Must have tabulate module installed.
"""
# python module required for summary
import csv
from tabulate import tabulate
fwd_graph, bwd_graph = get_inductor_decomp_graphs(model, args, kwargs)
op_counts = {}
for node in fwd_graph.graph.nodes:
if node.op == "call_function" and isinstance(
node.target, torch._ops.OpOverload
):
if node.target not in op_counts:
op_counts[node.target] = 0
op_counts[node.target] += 1
for node in bwd_graph.graph.nodes:
if node.op == "call_function" and isinstance(
node.target, torch._ops.OpOverload
):
if node.target not in op_counts:
op_counts[node.target] = 0
op_counts[node.target] += 1
op_infos = []
for op, count in op_counts.items():
supported = op in DTensor._op_dispatcher.sharding_propagator.op_to_rules
op_infos.append([op, str(op._schema), count, supported])
# sort the op info base on the total count index
count_idx = 2
op_infos.sort(key=itemgetter(count_idx), reverse=True)
headers = ["Operator", "Schema", "Total Count", "Supported"]
print(tabulate(op_infos, headers=headers))
if output_csv:
# Open a CSV file for writing
with open("op_summary.csv", "w", newline="") as csv_file:
# Create a CSV writer object
csv_writer = csv.writer(csv_file)
csv_writer.writerow(headers)
# Write each table row to the CSV file
for row in op_infos:
csv_writer.writerow(row)
@@ -0,0 +1,227 @@
# mypy: allow-untyped-defs
import importlib.util
import numpy as np
from torch._prims_common import ShapeType
from torch.distributed.tensor._utils import _compute_local_shape_and_global_offset
__all__ = ["visualize_sharding"]
Color = tuple[float, float, float]
def _create_table(
shards: list[tuple[tuple[int, int], tuple[int, int], int]], device_kind: str = ""
):
"""
Creates a tabulate table given row and column ranges with device name
"""
from tabulate import tabulate
# Extract unique row and column ranges
row_ranges = sorted({block[0] for block in shards})
col_ranges = sorted({block[1] for block in shards})
# Create a matrix initialized with empty strings
matrix = [["" for _ in col_ranges] for _ in row_ranges]
# Fill the matrix with values
for block in shards:
row_index = row_ranges.index(block[0])
col_index = col_ranges.index(block[1])
if matrix[row_index][col_index] == "":
matrix[row_index][col_index] = device_kind + ":" + str(block[2])
else:
matrix[row_index][col_index] += "," + str(block[2])
# Prepare headers
row_headers = [f"Row {r[0]}-{r[1]}" for r in row_ranges]
col_headers = [f"Col {c[0]}-{c[1]}" for c in col_ranges]
return tabulate(matrix, headers=col_headers, showindex=row_headers)
def make_color_iter(color_map, num_rows, num_cols):
num_colors = num_rows * num_cols
for idx in range(num_colors):
yield color_map(idx)
def _canonicalize_color(color: Color) -> str:
if isinstance(color, str):
return color
r, g, b = (int(a * 255) for a in color)
return f"#{r:02X}{g:02X}{b:02X}"
def _get_text_color(color: str) -> str:
r, g, b = map(lambda x: int(x, 16), (color[1:3], color[3:5], color[5:7])) # noqa: C417
if (r * 0.299 + g * 0.587 + b * 0.114) > 186:
return "#000000"
return "#ffffff"
def _create_rich_table(
shape: ShapeType,
shards: list[tuple[tuple[int, int], tuple[int, int], int]],
device_kind: str = "",
scale: float = 1.0,
min_width: int = 9,
max_width: int = 80,
):
import matplotlib
import rich.align
import rich.box
import rich.console
import rich.padding
import rich.style
import rich.table
dtensor_height = shape[0]
dtensor_width = shape[1] if len(shape) == 2 else 1
row_ranges = sorted({s[0] for s in shards})
col_ranges = sorted({s[1] for s in shards})
num_rows, num_cols = len(row_ranges), len(col_ranges)
console = rich.console.Console(width=max_width)
use_color = console.color_system
color_iter = make_color_iter(matplotlib.colormaps["tab20b"], num_rows, num_cols)
base_height = int(10 * scale)
aspect_ratio = (shape[1] if len(shape) == 2 else 1) / shape[0]
base_width = int(base_height * aspect_ratio)
height_to_width_ratio = 2.5
table = rich.table.Table(
show_header=False,
show_lines=not use_color,
padding=0,
highlight=not use_color,
pad_edge=False,
box=rich.box.SQUARE if not use_color else None,
)
for row in range(num_rows):
table_row = []
for col in range(num_cols):
entry = (
device_kind
+ ":"
+ ",".join(
[
str(device_id)
for row_range, col_range, device_id in shards
if row_range == row_ranges[row] and col_range == col_ranges[col]
]
)
)
width = (col_ranges[col][1] - col_ranges[col][0]) / dtensor_width
width = int(width * base_width * height_to_width_ratio)
height = (row_ranges[row][1] - row_ranges[row][0]) / dtensor_height
height = int(height * base_height)
left_padding, remainder = divmod(width - len(entry) - 2, 2)
right_padding = left_padding + remainder
top_padding, remainder = divmod(height - 2, 2)
bottom_padding = top_padding + remainder
if use_color:
color = _canonicalize_color(next(color_iter)[:3])
text_color = _get_text_color(color)
top_padding += 1
bottom_padding += 1
left_padding += 1
right_padding += 1
else:
color = None
text_color = None
padding = (
max(top_padding, 0),
max(right_padding, 0),
max(bottom_padding, 0),
max(left_padding, 0),
)
table_row.append(
rich.padding.Padding(
rich.align.Align(entry, "center", vertical="middle"),
padding,
style=rich.style.Style(bgcolor=color, color=text_color),
)
)
table.add_row(*table_row)
console.print(table, end="\n\n")
def visualize_sharding(dtensor, header="", use_rich: bool = False):
"""
Visualizes sharding in the terminal for :class:`DTensor` that are 1D or 2D.
.. note:: This requires the ``tabulate`` package, or ``rich`` and ``matplotlib``.
No sharding info will be printed for empty tensors
"""
if dtensor.numel() == 0: # Do not print empty dtensors.
return
if len(dtensor.shape) >= 3:
raise RuntimeError("visualize sharding supports only 1D or 2D DTensor")
if dtensor.device_mesh.get_coordinate() is None: # current rank is not in the mesh
return
# Only display the visualization once for each DTensor, on the rank whose
# coordinate is 0 on all dimensions. For example, if the mesh is a full mesh,
# we will only print on rank 0.
local_rank_zero_on_all_dim = all(
dtensor.device_mesh.get_local_rank(mesh_dim=dim) == 0
for dim in range(dtensor.device_mesh.ndim)
)
if not local_rank_zero_on_all_dim:
return
device_coords = {
int(device_index.item()): list(coord)
for coord, device_index in np.ndenumerate(
np.array(dtensor.device_mesh.mesh.tolist())
)
}
device_shard_shape_and_offsets = {
device_index: _compute_local_shape_and_global_offset(
dtensor.shape,
dtensor.device_mesh.shape,
lambda i: device_coords[device_index][i],
dtensor.placements,
)
for device_index in device_coords
}
# Extend shards in a 1D tensor to 2D
device_shard_shape_and_offsets = {
device_index: (
shape if len(shape) == 2 else (shape[0], 1),
offset if len(offset) == 2 else (offset[0], 0),
)
for device_index, (shape, offset) in device_shard_shape_and_offsets.items()
}
shards = [
(
(offset[0], offset[0] + shape[0] - 1),
(offset[1], offset[1] + shape[1] - 1),
device_index,
)
for device_index, (shape, offset) in device_shard_shape_and_offsets.items()
]
if (
importlib.util.find_spec("rich")
and importlib.util.find_spec("matplotlib")
and use_rich
):
_create_rich_table(
dtensor.shape, shards, device_kind=dtensor.device_mesh.device_type
)
elif importlib.util.find_spec("tabulate"):
print(_create_table(shards, device_kind=dtensor.device_mesh.device_type))
else:
raise ValueError("`visualize_sharding` requires either `rich` or `tabulate`.")
@@ -0,0 +1,9 @@
from torch.distributed.device_mesh import ( # noqa: F401
_get_device_handle,
_mesh_resources,
DeviceMesh,
init_device_mesh,
)
__all__ = ["init_device_mesh", "DeviceMesh"]
@@ -0,0 +1,34 @@
# Copyright (c) Meta Platforms, Inc. and affiliates
from collections.abc import Iterator
from contextlib import contextmanager
from torch.distributed.tensor._api import DTensor
from torch.distributed.tensor.experimental._attention import context_parallel
from torch.distributed.tensor.experimental._func_map import local_map
from torch.distributed.tensor.experimental._register_sharding import register_sharding
__all__ = ["context_parallel", "implicit_replication", "local_map", "register_sharding"]
@contextmanager
def implicit_replication() -> Iterator[None]:
"""
This context manager allows :class:`DTensor` to implicitly treat all non-DTensors (``torch.Tensor``)
in the program be replicate :class:`DTensor` s during the operator computation.
.. warning:: This might possible lead to incorrect results if ``torch.Tensor`` s are not replicated
in practice, please use it at your discretion.
"""
try:
DTensor._op_dispatcher._allow_implicit_replication = True
yield
finally:
DTensor._op_dispatcher._allow_implicit_replication = False
# Set namespace for exposed private names
context_parallel.__module__ = "torch.distributed.tensor.experimental"
implicit_replication.__module__ = "torch.distributed.tensor.experimental"
local_map.__module__ = "torch.distributed.tensor.experimental"
register_sharding.__module__ = "torch.distributed.tensor.experimental"
@@ -0,0 +1,44 @@
# Copyright (c) Meta Platforms, Inc. and affiliates
# Backward compatibility stub - this module has been moved to _context_parallel/_attention.py
from ._context_parallel._attention import (
_CausalBehavior,
_context_parallel_shard,
_ContextParallel,
_cp_options,
_disable_context_parallel_dispatcher,
_enable_context_parallel_dispatcher,
_is_causal_behavior,
_RotateMethod,
_templated_ring_attention,
context_parallel,
context_parallel_unshard,
set_rotate_method,
)
from ._context_parallel._load_balancer import (
_HeadTailLoadBalancer,
_LoadBalancer,
_PerDocumentHeadTailLoadBalancer,
_PTRRLoadBalancer,
)
# TODO(fegin): add deprecation message once the final interfaces are concluded.
__all__ = [
"_CausalBehavior",
"_context_parallel_shard",
"_ContextParallel",
"_cp_options",
"_disable_context_parallel_dispatcher",
"_enable_context_parallel_dispatcher",
"_is_causal_behavior",
"_RotateMethod",
"_templated_ring_attention",
"context_parallel",
"context_parallel_unshard",
"set_rotate_method",
"_HeadTailLoadBalancer",
"_LoadBalancer",
"_PerDocumentHeadTailLoadBalancer",
"_PTRRLoadBalancer",
]
@@ -0,0 +1,46 @@
# Copyright (c) Meta Platforms, Inc. and affiliates
# Context Parallel components
from ._attention import (
_CausalBehavior,
_context_parallel_shard,
_ContextParallel,
_cp_options,
_disable_context_parallel_dispatcher,
_enable_context_parallel_dispatcher,
_is_causal_behavior,
_RotateMethod,
context_parallel,
context_parallel_unshard,
set_rotate_method,
)
from ._cp_custom_ops import flex_cp_allgather
from ._load_balancer import (
_HeadTailLoadBalancer,
_LoadBalancer,
_PerDocumentHeadTailLoadBalancer,
_PTRRLoadBalancer,
)
__all__ = [
# From _attention
"_CausalBehavior",
"_context_parallel_shard",
"_ContextParallel",
"_cp_options",
"_disable_context_parallel_dispatcher",
"_enable_context_parallel_dispatcher",
"_is_causal_behavior",
"_RotateMethod",
"context_parallel",
"context_parallel_unshard",
"set_rotate_method",
# From _cp_custom_ops
"flex_cp_allgather",
# From _load_balancer
"_HeadTailLoadBalancer",
"_LoadBalancer",
"_PerDocumentHeadTailLoadBalancer",
"_PTRRLoadBalancer",
]
@@ -0,0 +1,88 @@
from typing import Any
import torch
import torch.distributed._functional_collectives as funcol
import torch.distributed.distributed_c10d as c10d
@torch.library.custom_op("cplib::flex_cp_allgather", mutates_args=())
def flex_cp_allgather(
k: torch.Tensor, v: torch.Tensor, seq_dim: int, pg_name: c10d.GroupName
) -> tuple[torch.Tensor, torch.Tensor]:
k = k.contiguous()
v = v.contiguous()
k = funcol.all_gather_tensor(k, seq_dim, pg_name)
v = funcol.all_gather_tensor(v, seq_dim, pg_name)
if isinstance(k, funcol.AsyncCollectiveTensor):
k = k.wait()
if isinstance(v, funcol.AsyncCollectiveTensor):
v = v.wait()
return k, v
@flex_cp_allgather.register_fake
def _(
k: torch.Tensor, v: torch.Tensor, seq_dim: int, pg_name: c10d.GroupName
) -> tuple[torch.Tensor, torch.Tensor]:
shape_k = list(k.shape)
shape_v = list(v.shape)
shape_k[seq_dim] *= c10d._get_group_size_by_name(pg_name)
shape_v[seq_dim] *= c10d._get_group_size_by_name(pg_name)
new_k = torch.empty(shape_k, dtype=k.dtype, device=k.device)
new_v = torch.empty(shape_v, dtype=v.dtype, device=v.device)
return new_k, new_v
@torch.library.custom_op("cplib::flex_cp_allgather_backward", mutates_args=())
def flex_cp_allgather_backward(
grad_full_k: torch.Tensor,
grad_full_v: torch.Tensor,
seq_dim: int,
pg_name: c10d.GroupName,
) -> tuple[torch.Tensor, torch.Tensor]:
grad_k = funcol.reduce_scatter_tensor(grad_full_k, "sum", seq_dim, pg_name)
if isinstance(grad_k, funcol.AsyncCollectiveTensor):
grad_k = grad_k.wait()
grad_v = funcol.reduce_scatter_tensor(grad_full_v, "sum", seq_dim, pg_name)
if isinstance(grad_v, funcol.AsyncCollectiveTensor):
grad_v = grad_v.wait()
return grad_k, grad_v
@flex_cp_allgather_backward.register_fake
def _(
grad_full_k: torch.Tensor,
grad_full_v: torch.Tensor,
seq_dim: int,
pg_name: c10d.GroupName,
) -> tuple[torch.Tensor, torch.Tensor]:
shape_k = list(grad_full_k.shape)
shape_v = list(grad_full_v.shape)
shape_k[seq_dim] //= c10d._get_group_size_by_name(pg_name)
shape_v[seq_dim] //= c10d._get_group_size_by_name(pg_name)
new_grad_k = torch.empty(
shape_k, dtype=grad_full_k.dtype, device=grad_full_k.device
)
new_grad_v = torch.empty(
shape_v, dtype=grad_full_v.dtype, device=grad_full_v.device
)
return new_grad_k, new_grad_v
def _flex_cp_allgather_backward(
ctx: Any, grad_full_k: torch.Tensor, grad_full_v: torch.Tensor
) -> tuple[torch.Tensor, torch.Tensor, None, None]:
grad_k, grad_v = flex_cp_allgather_backward(
grad_full_k, grad_full_v, ctx.seq_dim, ctx.pg_name
)
return grad_k, grad_v, None, None
def _flex_cp_setup_context(ctx: Any, inputs: Any, output: Any) -> None:
_, _, ctx.seq_dim, ctx.pg_name = inputs
flex_cp_allgather.register_autograd(
_flex_cp_allgather_backward, setup_context=_flex_cp_setup_context
)
@@ -0,0 +1,478 @@
# this file contains the `_LoadBalancer` class and its family of implementation
# for different load-balancing strategies in tensor sharding.
import functools
from abc import ABC, abstractmethod
import torch
from torch import Tensor
from torch.nn.attention.flex_attention import BlockMask
# make it private since it's still a prototype
class _LoadBalancer(ABC):
@abstractmethod
def _generate_indices(self, restore: bool = False) -> Tensor | None:
"""
Generate indices for load balancing.
Args:
restore (bool):
Returns:
The generated indices of shape `(1, seq_len)` if the load-balancing is
identical within the batch, or `(batch_size, seq_len)` if the load-balancing
should vary within the batch.
Warning:
For Multi-Head Attention, we require the masks over the head dimension are identical
(i.e. the return value of `_generate_indices()` does not have `heads` dimension).
Example:
Here is the causal mask for attention where q_len == kv_len == 8:
KV_index
[1, 0, 0, 0, 0, 0, 0, 0]
[1, 1, 0, 0, 0, 0, 0, 0]
[1, 1, 1, 0, 0, 0, 0, 0]
Q_index [1, 1, 1, 1, 0, 0, 0, 0]
[1, 1, 1, 1, 1, 0, 0, 0]
[1, 1, 1, 1, 1, 1, 0, 0]
[1, 1, 1, 1, 1, 1, 1, 0]
[1, 1, 1, 1, 1, 1, 1, 1]
This mask matrix also represents the computation required to compute
the masked Q @ K^T by:
- mask[i, j] == 1: the computation of Q[i, :] dot K[j, :] is required
- mask[i, j] == 0: the computation should be skipped
Therefore the number of 1s in matrix represents the amount of computation
required.
Assume we want to distribute this Q @ K^T computation to 2 devices, then
the matrix is also distributed as:
KV_index
[1, 0, 0, 0, 0, 0, 0, 0]
[1, 1, 0, 0, 0, 0, 0, 0]
[1, 1, 1, 0, 0, 0, 0, 0] rank 0
[1, 1, 1, 1, 0, 0, 0, 0]
Q_index ------------------------
[1, 1, 1, 1, 1, 0, 0, 0]
[1, 1, 1, 1, 1, 1, 0, 0] rank 1
[1, 1, 1, 1, 1, 1, 1, 0]
[1, 1, 1, 1, 1, 1, 1, 1]
An imbalance of computation is observed on these 2 ranks and this could make
rank 1 the straggler when performing Context Parallel. In order to balance
the computation, we need to rearrange the QKV tensors before sharding in such a
way that the result mask matrix is evenly distributed over devices and each
rank has the number of 1s as close as possible.
This method defines the strategy of how to rearrange the QKV tensor for better
load-balance:
- when `restore == False`, this method returns an indices tensor `rearrange_idx`
such that Q[rearrange_idx] is the desired Q tensor after rearranging.
- when `restore == True`, this method returns an indices tensor `restore_idx`
such that Q[rearrange_idx][restore_idx] == Q, i.e. restoring the rearranged tensor
back to the original status before rearranging.
"""
class _HeadTailLoadBalancer(_LoadBalancer):
def __init__(self, seq_length: int, world_size: int, device: str | torch.device):
self.seq_length = seq_length
self.world_size = world_size
self.device = device
def _generate_indices(self, restore: bool = False) -> Tensor:
"""
Generate head-and-tail load balancing indices or restore indices.
Args:
restore:
If True, generate restore indices that map head-and-tail rearranged
positions back to original positions. If False, generate load
balance indices that rearrange original positions to head-and-tail pattern.
Returns:
The generated indices of shape `(1, seq_len)` because the load-balancing is
identical within the batch.
Warning:
For Multi-Head Attention, we require the masks over the head dimension are identical
(i.e. the return value of `_generate_indices()` does not have `heads` dimension).
Example:
Here is the causal mask for attention where q_len == kv_len == 8:
KV_index
[1, 0, 0, 0, 0, 0, 0, 0]
[1, 1, 0, 0, 0, 0, 0, 0]
[1, 1, 1, 0, 0, 0, 0, 0]
Q_index [1, 1, 1, 1, 0, 0, 0, 0]
[1, 1, 1, 1, 1, 0, 0, 0]
[1, 1, 1, 1, 1, 1, 0, 0]
[1, 1, 1, 1, 1, 1, 1, 0]
[1, 1, 1, 1, 1, 1, 1, 1]
Head-tail load-balance strategy rearranges the Q tensor by combining
Q[0:k] (on seq dim) and Q[-k:] for rank 0, Q[k:2k] and Q[-2k:-k] for
rank 1, and so on. In python code it looks like:
k = Q.size(0) // (2 * cp_world_size)
for rank in range(cp_world_size):
reordered_Q[rank * 2 * k : (rank + 1) * 2 * k] = torch.cat(
(Q[rank * k : (rank + 1) * k], Q[-(rank + 1) * k : -rank * k])
)
This can also be done by tensor slicing. For the above example, the indices
tensor for slicing is:
slice_indices = Tensor([0, 7, 1, 6, 2, 5, 3, 4])
After reordering QKV using the `slice_indices`, the corresponding mask matrix
distributing over 2 devices becomes well-balanced:
KV_index
[1, 0, 0, 0, 0, 0, 0, 0]
[1, 1, 1, 1, 1, 1, 1, 1]
[1, 1, 0, 0, 0, 0, 0, 0] rank 0
[1, 1, 1, 1, 1, 1, 1, 0]
Q_index ------------------------
[1, 1, 1, 0, 0, 0, 0, 0]
[1, 1, 1, 1, 1, 1, 0, 0] rank 1
[1, 1, 1, 1, 0, 0, 0, 0]
[1, 1, 1, 1, 1, 0, 0, 0]
To restore the reordering and putting the tensor back, slicing op can do the
trick with a `restore_indices` such that:
slice_indices[restore_indices] == Tensor([0, 1, 2, ...])
In this way, `reordered_Q[restore_indices]` will just be the original Q.
"""
seq_length = self.seq_length
world_size = self.world_size
if seq_length % (world_size * 2) != 0:
raise AssertionError
chunk_size = seq_length // (world_size * 2)
# Split sequence into 2*world_size chunks, then pair chunk r with
# chunk (2*world_size - 1 - r) for each rank.
indices = torch.arange(seq_length, dtype=torch.int, device=self.device)
chunks = indices.view(world_size * 2, chunk_size)
head_idx = torch.arange(world_size, device=self.device)
tail_idx = 2 * world_size - 1 - head_idx
paired = torch.stack([chunks[head_idx], chunks[tail_idx]], dim=1)
all_indices_tensor = paired.reshape(-1)
if restore:
all_indices_tensor = torch.argsort(all_indices_tensor)
return all_indices_tensor.unsqueeze(0) # add batch dim
class _PerDocumentHeadTailLoadBalancer(_LoadBalancer):
def __init__(
self,
seq_length_per_doc: list[list[int]],
world_size: int,
device: str | torch.device,
):
"""
`seq_length_per_doc` has size (B, seq_len) if the load-balancing should vary
within the batch. Otherwise `seq_length_per_doc` should have size (1, seq_len).
"""
self.seq_length_per_doc = seq_length_per_doc
self.world_size = world_size
self.device = device
def _generate_indices(self, restore: bool = False) -> Tensor:
"""
Generate the per-document head-and-tail rearrange indices so that after rearranging
the input is load-balanced in per-document head-and-tail style.
Args:
restore:
If True, generate restore indices that map per-document head-and-tail
rearranged positions back to original positions. If False, generate load
balance indices that rearrange original positions to per-document
head-and-tail pattern.
Returns:
The generated indices of shape `(batch_size, seq_len)` if the load-balancing
should vary within the batch. Otherwise, it should have shape `(1, seq_len)`.
Warning:
For Multi-Head Attention, we require the masks over the head dimension are identical
(i.e. `seq_length_per_doc` must have size (B, seq_len) or (1, seq_len)).
Example:
Here is the document causal mask for attention where q_len == kv_len == 16:
KV_index
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Q_index [0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0]
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0]
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0]
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0]
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1]
The per-document head-and-tail load-balancer will apply head-and-tail
reordering within each document. After load-balancing for context-parallel
on 2 devices, the above mask matrix will look like this:
KV_index
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0]
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0]
Q_index [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1]
------------------------------------------------
[1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0]
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0]
"""
return torch.stack(
[
self._generate_indices_for_batch(seq_lengths, restore)
for seq_lengths in self.seq_length_per_doc
]
)
def _generate_indices_for_batch(self, seq_length_per_doc, restore) -> Tensor: # type: ignore[no-untyped-def]
world_size = self.world_size
device = self.device
if not all(
seq_length % (2 * world_size) == 0 for seq_length in seq_length_per_doc
):
raise AssertionError
chunk_length_per_doc = [
seq_length // (2 * world_size) for seq_length in seq_length_per_doc
]
indices = []
document_start_idx = 0
for seq_length, chunk_length in zip(seq_length_per_doc, chunk_length_per_doc):
# Generate the indices for the current document
for rank in range(world_size):
head_chunk_start_idx = document_start_idx + chunk_length * rank
tail_chunk_end_idx = document_start_idx + chunk_length * (
2 * world_size - rank
)
indices.append(
torch.arange(
head_chunk_start_idx,
head_chunk_start_idx + chunk_length,
device=device,
)
)
indices.append(
torch.arange(
tail_chunk_end_idx - chunk_length,
tail_chunk_end_idx,
device=device,
)
)
document_start_idx += seq_length
indices_tensor = torch.cat(indices)
if restore:
indices_tensor = torch.argsort(indices_tensor)
return indices_tensor
class _PTRRLoadBalancer(_LoadBalancer):
"""
Processing-Time based Round-Robin (PTRR) load balancer. This load balancer should
only be used for flex_attention() since it leverages `BlockMask`.
"""
def __init__(
self,
block_mask: BlockMask,
world_size: int,
):
"""
`block_mask` must have shape (B, 1, seq_len, seq_len) or (1, 1, seq_len, seq_len).
"""
self.block_mask = block_mask
self.world_size = world_size
@staticmethod
def ptrr_scheduling(process_time: Tensor, group_size: int) -> Tensor:
"""
Separate the tasks into `group_size` groups using PTRR scheduling.
process_time:
1D tensor of size n, where n is the number of tasks. The value
is the process time of the task. Size `n` must be divisible by
`group_size`.
group_size:
the number of groups
Returns:
tasks_in_group (list[list[int]]):
A collection of list[int] and each list should have size `n // group_size`
(`group_size` lists in total). Each element is an index in the input
`process_time` (i.e. [0, len(process_time) - 1]).
Example:
process_time = [9, 14, 2, 20, 10, 15, 8, 14, 16, 19, 15, 3, 12, 1, 12, 10]
tasks_in_group = [
[3, 12, 13, 14], # values = [1, 12, 12, 20], sum = 45
[2, 4, 7, 9], # values = [2, 10, 14, 19], sum = 45
[1, 8, 11, 15], # values = [14, 16, 3, 10], sum = 43
[0, 5, 6, 10] # values = [9, 15, 8, 15], sum = 47
]
"""
if process_time.ndim != 1:
raise AssertionError
num_tasks = process_time.size(0)
if num_tasks % group_size != 0:
raise NotImplementedError(
f"num_tasks {num_tasks} must be divisible by group_size {group_size}"
)
device = process_time.device
_, sorted_indices_descending = torch.sort(
process_time, descending=True, stable=True
) # if process time is tied, the order is preserved
sorted_indices_descending_reversed = torch.flip(
sorted_indices_descending.view(-1, group_size), dims=[1]
).view(-1)
tasks_in_group = torch.where(
torch.arange(num_tasks, device=device) // group_size % 2 == 0,
sorted_indices_descending,
sorted_indices_descending_reversed,
)
tasks_in_group = tasks_in_group.view(-1, group_size).transpose(
0, 1
) # (group_size, n // group_size)
# sort each group. This step should not have impact on correctness
# nor execution run time, but it helps users visualize the mask
tasks_in_group, _ = torch.sort(tasks_in_group, dim=1)
return tasks_in_group
def _generate_indices(self, restore: bool = False) -> Tensor:
"""
Generate the PTRR reorder indices of shape `(1, seq_len)` or `(batch_size, seq_len)`.
Args:
restore:
If True, generate restore indices that map Processing-Time based Round-Robin
(PTRR) rearranged positions back to original positions. If False, generate
load balance indices that rearrange original positions to PTRR pattern.
Returns:
The generated indices of shape `(1, seq_len)` if the load-balancing is
identical within the batch (i.e. `BlockMask.shape[0] == 1`), or
`(batch_size, seq_len)` if the load-balancing should vary within the batch.
Warning:
For Multi-Head Attention, we require the masks over the head dimension are identical
(i.e. `self.block_mask` must have shape (B, 1, seq_len, seq_len) or (1, 1, seq_len, seq_len)).
Example:
Here is the document causal mask for attention whereq_len == kv_len == 16 * BLOCK_SIZE
(each entry is a block):
KV_index
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] -> row value = 1
[1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] -> row value = 2
[1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] -> row value = 3
[1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] -> row value = 4
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] -> row value = 1
[0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] -> row value = 2
[0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0] -> row value = 3
Q_index [0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0] -> row value = 4
[0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0] -> row value = 5
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0] -> row value = 6
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0] -> row value = 7
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0] -> row value = 8
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0] -> row value = 1
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0] -> row value = 2
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0] -> row value = 3
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1] -> row value = 4
The reorder indices will be: [2, 3, 5, 6, 8, 11, 12, 13, 0, 1, 4, 7, 9, 10, 14, 15] and
the mask matrix will look like:
KV_index
[1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] -> row value = 3
[1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] -> row value = 4
[0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] -> row value = 2
[0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0] -> row value = 3
[0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0] -> row value = 5 rank 0 (sum=28)
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0] -> row value = 8
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0] -> row value = 1
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0] -> row value = 2
------------------------------------------------
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] -> row value = 1
[1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] -> row value = 2
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] -> row value = 1
[0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0] -> row value = 4
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0] -> row value = 6 rank 1 (sum=28)
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0] -> row value = 7
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0] -> row value = 3
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1] -> row value = 4
"""
block_mask = self.block_mask
kv_num_blocks = block_mask.kv_num_blocks
full_kv_num_blocks = block_mask.full_kv_num_blocks
non_sparse_kv_num_blocks = (
kv_num_blocks + full_kv_num_blocks
if full_kv_num_blocks is not None
else kv_num_blocks
)
B, H, Q = non_sparse_kv_num_blocks.shape
# requirement: the masking is identical across heads (i.e. H == 1 in BlockMask)
non_sparse_kv_num_blocks = non_sparse_kv_num_blocks.view(-1, Q) # (B, Q_BLK)
batch_ptrr = torch.vmap(
functools.partial(
_PTRRLoadBalancer.ptrr_scheduling,
group_size=self.world_size,
)
)
ptrr_indices = batch_ptrr(
non_sparse_kv_num_blocks
) # (B, group_size, num_blks_in_group)
ptrr_indices = ptrr_indices.reshape(B, -1) # (B, num_blocks)
# NOTE: only support the case where the qkv block size are equal
q_blk_size, kv_blk_size = block_mask.BLOCK_SIZE
if q_blk_size != kv_blk_size:
raise AssertionError("for now only support q_blk_size == kv_blk_size")
indices = torch.arange(
q_blk_size * ptrr_indices.size(1), device=ptrr_indices.device
).view(-1, q_blk_size) # (NUM_BLOCKS, BLOCK_SIZE)
indices = indices[ptrr_indices].view(B, -1) # (B, qkv_size)
if restore:
# pyrefly: ignore[missing-argument]
indices = torch.vmap(torch.argsort)(indices)
return indices
def _create_default_load_balancer(
seq_length: int, world_size: int, device: str | torch.device
) -> _LoadBalancer | None:
from ._attention import _cp_options
if _cp_options.enable_load_balance:
return _HeadTailLoadBalancer(seq_length, world_size, device)
else:
return None
@@ -0,0 +1,408 @@
# Copyright (c) Meta Platforms, Inc. and affiliates
"""
Context Parallelism sharding rules for scaled_dot_product attention operators.
The sharding rules for CP cannot be embedded by default because Shard(2) is not
a valid sharding for SDPA without CP enabled. This module provides utilities to
dynamically install Shard(2) sharding rules when CP is activated.
"""
from contextlib import contextmanager
import torch
from torch.distributed.tensor._op_schema import (
OpSchema,
OpStrategy,
PlacementList,
RuntimeSchemaInfo,
)
from torch.distributed.tensor._ops.utils import (
expand_to_full_mesh_op_strategy,
register_op_strategy,
)
from torch.distributed.tensor.debug import (
_clear_fast_path_sharding_prop_cache,
_clear_python_sharding_prop_cache,
)
from torch.distributed.tensor.placement_types import Replicate, Shard
aten = torch.ops.aten
SEQ_DIM = 2
@contextmanager
def _op_strategy_context(op_overload, strategy_func, schema_info=None):
"""
Context manager for setting and clearing op strategies for Context Parallelism.
Args:
op_overload: The operator overload to set or clear the strategy for.
strategy_func: The strategy function to set for the operator overload.
schema_info: Optional schema information for the operator overload.
Yields:
None
"""
from torch.distributed.tensor import DTensor
propagator = DTensor._op_dispatcher.sharding_propagator
_origin_op_strategy_funcs = None
_origin_op_strategy_schema = None
try:
# Save original strategy if exists
if op_overload in propagator.op_strategy_funcs:
_origin_op_strategy_funcs = propagator.op_strategy_funcs[op_overload]
if op_overload in propagator.op_to_schema_info:
_origin_op_strategy_schema = propagator.op_to_schema_info[op_overload]
# Register the new op strategy
register_op_strategy(op_overload, schema_info=schema_info)(strategy_func)
yield (_origin_op_strategy_funcs, _origin_op_strategy_schema)
finally:
# Restore original strategy
if _origin_op_strategy_funcs is None:
if op_overload in propagator.op_strategy_funcs:
del propagator.op_strategy_funcs[op_overload]
else:
propagator.op_strategy_funcs[op_overload] = _origin_op_strategy_funcs
if _origin_op_strategy_schema is None:
if op_overload in propagator.op_to_schema_info:
del propagator.op_to_schema_info[op_overload]
else:
propagator.op_to_schema_info[op_overload] = _origin_op_strategy_schema
# Ideally, we should clear the cache, but it is too expensive.
# _clear_python_sharding_prop_cache()
# _clear_fast_path_sharding_prop_cache()
# ==================== Flash Attention Strategies ====================
def _scaled_dot_product_flash_attention_cp_strategy(op_schema: OpSchema) -> OpStrategy:
"""
Strategy for flash attention forward with Context Parallelism support.
This includes the base strategies plus CP-specific sequence dimension sharding.
"""
# Import here to avoid circular dependency
from torch.distributed.tensor._ops._matrix_ops import (
_scaled_dot_product_flash_attention_base_strategies,
)
# Get the base strategies (without CP modifications)
mesh = op_schema.get_mesh_from_args()
single_mesh_dim_strategies = _scaled_dot_product_flash_attention_base_strategies(
op_schema
)
# Add Context Parallelism strategy: shards on the sequence dim
return_debug_mask = len(op_schema.args_schema) >= 6 and op_schema.args_schema[5]
debug_attn_mask_sharding = Shard(SEQ_DIM) if return_debug_mask else Replicate()
cp_strategy: PlacementList = [
Shard(SEQ_DIM), # output
Shard(SEQ_DIM), # logsumexp
None, # cum_seq_q
None, # cum_seq_k
None, # max_q
None, # max_k
Replicate(), # rng_state
None, # unused
debug_attn_mask_sharding, # debugattn
Shard(SEQ_DIM), # q
Shard(SEQ_DIM), # k
Shard(SEQ_DIM), # v
]
single_mesh_dim_strategies.append(cp_strategy)
return expand_to_full_mesh_op_strategy(
mesh, op_schema, single_mesh_dim_strategies, input_index=9
)
def _scaled_dot_product_flash_attention_backward_cp_strategy(
op_schema: OpSchema,
) -> OpStrategy:
"""
Strategy for flash attention backward with Context Parallelism support.
"""
from torch.distributed.tensor._ops._matrix_ops import (
_scaled_dot_product_flash_attention_backward_base_strategies,
)
mesh = op_schema.get_mesh_from_args(validate=False)
single_mesh_dim_strategies = (
_scaled_dot_product_flash_attention_backward_base_strategies(op_schema)
)
tensor_input_indices = [
i
for i, arg_spec in enumerate(op_schema.args_schema)
if isinstance(arg_spec, OpStrategy)
]
num_tensor_inputs = len(tensor_input_indices)
# Context Parallelism: shards on the sequence dim
cp_strategy: PlacementList = [
Shard(SEQ_DIM), # grad_q
Shard(SEQ_DIM), # grad_k
Shard(SEQ_DIM), # grad_v
Shard(SEQ_DIM), # grad_output
Shard(SEQ_DIM), # q
Shard(SEQ_DIM), # k
Shard(SEQ_DIM), # v
Shard(SEQ_DIM), # output
Shard(SEQ_DIM), # logsumexp
]
cp_strategy.extend([Replicate()] * (num_tensor_inputs - 6))
single_mesh_dim_strategies.append(cp_strategy)
return expand_to_full_mesh_op_strategy(
mesh, op_schema, single_mesh_dim_strategies, input_index=3
)
# ==================== Efficient Attention Strategies ====================
def _scaled_dot_product_efficient_attention_cp_strategy(
op_schema: OpSchema,
) -> OpStrategy:
"""
Strategy for efficient attention forward with Context Parallelism support.
"""
from torch.distributed.tensor._ops._matrix_ops import (
_scaled_dot_product_efficient_attention_base_strategies,
)
mesh = op_schema.get_mesh_from_args()
single_mesh_dim_strategies = (
_scaled_dot_product_efficient_attention_base_strategies(op_schema)
)
# Add Context Parallelism strategy
has_attn_bias = op_schema.args_schema[3] is not None
cp_strategy: PlacementList = [
Shard(SEQ_DIM), # output
Shard(SEQ_DIM), # logsumexp
None, # philox_seed
None, # philox_offset
Shard(SEQ_DIM), # q
Shard(SEQ_DIM), # k
Shard(SEQ_DIM), # v
]
if has_attn_bias:
cp_strategy.append(Replicate()) # attn bias - not sharded for CP
single_mesh_dim_strategies.append(cp_strategy)
return expand_to_full_mesh_op_strategy(
mesh, op_schema, single_mesh_dim_strategies, input_index=4
)
def _scaled_dot_product_efficient_attention_backward_cp_strategy(
op_schema: OpSchema,
) -> OpStrategy:
"""
Strategy for efficient attention backward with Context Parallelism support.
"""
from torch.distributed.tensor._ops._matrix_ops import (
_scaled_dot_product_efficient_attention_backward_base_strategies,
)
mesh = op_schema.get_mesh_from_args(validate=False)
single_mesh_dim_strategies = (
_scaled_dot_product_efficient_attention_backward_base_strategies(op_schema)
)
has_attn_bias = op_schema.args_schema[4] is not None
# Context Parallelism: shards on the sequence dim
cp_strategy: PlacementList = [
Shard(SEQ_DIM), # grad_q
Shard(SEQ_DIM), # grad_k
Shard(SEQ_DIM), # grad_v
Shard(1) if has_attn_bias else None, # grad_bias
Shard(SEQ_DIM), # grad_output
Shard(SEQ_DIM), # q
Shard(SEQ_DIM), # k
Shard(SEQ_DIM), # v
Shard(SEQ_DIM), # output
Shard(SEQ_DIM), # logsumexp
]
if has_attn_bias:
cp_strategy.insert(8, Shard(1)) # attn_bias input
cp_strategy.extend([Replicate(), Replicate()])
single_mesh_dim_strategies.append(cp_strategy)
return expand_to_full_mesh_op_strategy(
mesh, op_schema, single_mesh_dim_strategies, input_index=4
)
# ==================== cuDNN Attention Strategies ====================
def _scaled_dot_product_cudnn_attention_cp_strategy(op_schema: OpSchema) -> OpStrategy:
"""
Strategy for cudnn attention forward with Context Parallelism support.
"""
from torch.distributed.tensor._ops._matrix_ops import (
_scaled_dot_product_cudnn_attention_base_strategies,
)
mesh = op_schema.get_mesh_from_args()
single_mesh_dim_strategies = _scaled_dot_product_cudnn_attention_base_strategies(
op_schema
)
(
query_strategy,
_,
_,
attn_bias_strategy,
compute_log_sumexp,
*rest_args,
) = op_schema.args_schema
return_debug_mask = len(op_schema.args_schema) >= 8 and rest_args[2]
has_attn_bias = attn_bias_strategy is not None
# Context Parallelism: shards on the sequence dim
logsumexp_sharding = Shard(SEQ_DIM) if compute_log_sumexp else Replicate()
debug_attn_mask_sharding = Shard(SEQ_DIM) if return_debug_mask else None
cp_strategy: PlacementList = [
Shard(SEQ_DIM), # output
logsumexp_sharding, # logsumexp
None, # cum_seq_q
None, # cum_seq_k
None, # max_q
None, # max_k
None, # philox_seed
None, # philox_offset
debug_attn_mask_sharding, # debug_attn_mask
Shard(SEQ_DIM), # q
Shard(SEQ_DIM), # k
Shard(SEQ_DIM), # v
]
if has_attn_bias:
cp_strategy.append(Replicate()) # attn_bias - not sharded for CP
single_mesh_dim_strategies.append(cp_strategy)
return expand_to_full_mesh_op_strategy(
mesh, op_schema, single_mesh_dim_strategies, input_index=9
)
def _scaled_dot_product_cudnn_attention_backward_cp_strategy(
op_schema: OpSchema,
) -> OpStrategy:
"""
Strategy for cudnn attention backward with Context Parallelism support.
"""
from torch.distributed.tensor._ops._matrix_ops import (
_scaled_dot_product_cudnn_attention_backward_base_strategies,
)
mesh = op_schema.get_mesh_from_args(validate=False)
single_mesh_dim_strategies = (
_scaled_dot_product_cudnn_attention_backward_base_strategies(op_schema)
)
has_attn_bias = op_schema.args_schema[8] is not None
has_scale = len(op_schema.args_schema) >= 16 and False
# Context Parallelism: shards on the sequence dim
cp_sharding_gout: PlacementList = [Shard(SEQ_DIM)] * 3 # grad_q, grad_k, grad_v
cp_sharding_ginp: PlacementList = [
Shard(SEQ_DIM)
] * 6 # grad_output, q, k, v, output, logsumexp
cp_sharding_ginp += [Replicate()] * 2 # philox_seed, philox_offset
cp_sharding_ginp += [Shard(SEQ_DIM) if has_attn_bias else None] # attn_bias
cp_sharding_ginp += [
None
] * 6 # cum_seq_q, cum_seq_k, max_q, max_k, dropout_p, is_causal
if has_scale:
cp_sharding_ginp.append(None)
cp_sharding = cp_sharding_gout + cp_sharding_ginp
single_mesh_dim_strategies.append(cp_sharding)
return expand_to_full_mesh_op_strategy(
mesh, op_schema, single_mesh_dim_strategies, input_index=3
)
# Store context managers and original strategies
_cp_strategy_contexts = {}
_original_strategies = {}
def register_cp_sharding_rules():
"""Register Context Parallelism sharding rules for all scaled_dot_product ops."""
global _cp_strategy_contexts, _original_strategies
# If already registered, don't register again
if _cp_strategy_contexts:
return
# Define ops and their corresponding CP strategy functions
cp_strategies = [
(
aten._scaled_dot_product_flash_attention.default,
_scaled_dot_product_flash_attention_cp_strategy,
RuntimeSchemaInfo(5),
),
(
aten._scaled_dot_product_flash_attention_backward.default,
_scaled_dot_product_flash_attention_backward_cp_strategy,
None,
),
(
aten._scaled_dot_product_efficient_attention.default,
_scaled_dot_product_efficient_attention_cp_strategy,
RuntimeSchemaInfo(4),
),
(
aten._scaled_dot_product_efficient_attention_backward.default,
_scaled_dot_product_efficient_attention_backward_cp_strategy,
None,
),
(
aten._scaled_dot_product_cudnn_attention.default,
_scaled_dot_product_cudnn_attention_cp_strategy,
RuntimeSchemaInfo(4),
),
(
aten._scaled_dot_product_cudnn_attention_backward.default,
_scaled_dot_product_cudnn_attention_backward_cp_strategy,
None,
),
]
# Register each strategy
for op_overload, strategy_func, schema_info in cp_strategies:
ctx = _op_strategy_context(op_overload, strategy_func, schema_info)
orig_funcs, orig_schema = ctx.__enter__()
_cp_strategy_contexts[op_overload] = ctx
_original_strategies[op_overload] = (orig_funcs, orig_schema)
def unregister_cp_sharding_rules(clear_the_cache=False):
"""Unregister Context Parallelism sharding rules and restore original strategies."""
global _cp_strategy_contexts, _original_strategies
# Exit all context managers
for ctx in _cp_strategy_contexts.values():
ctx.__exit__(None, None, None)
if clear_the_cache:
_clear_fast_path_sharding_prop_cache()
_clear_python_sharding_prop_cache()
_cp_strategy_contexts = {}
_original_strategies = {}
@@ -0,0 +1,285 @@
# mypy: allow-untyped-defs
# Copyright (c) Meta Platforms, Inc. and affiliates
import functools
from collections.abc import Callable, Sequence
import torch
from torch.distributed._functional_collectives import AsyncCollectiveTensor
from torch.distributed.tensor import DeviceMesh, DTensor
from torch.distributed.tensor.placement_types import Placement
try:
from torch.utils import _cxx_pytree as pytree
except ImportError:
from torch.utils import _pytree as pytree # type: ignore[no-redef]
__all__ = ["local_map"]
PlacementType = Sequence[Placement] | None
InputPlacements = tuple[PlacementType, ...] | None
OutputPlacements = PlacementType | tuple[PlacementType, ...]
def local_map(
func: Callable | None = None,
out_placements: OutputPlacements = None,
in_placements: InputPlacements = None,
in_grad_placements: InputPlacements = None,
device_mesh: DeviceMesh | None = None,
*,
redistribute_inputs: bool = False,
):
"""
:meth:`local_map` is an experimental API that allows users to pass :class:`DTensor` s
to a function that is written to be applied on ``torch.Tensor`` s. It is done by extracting
the local components of :class:`DTensor`, call the function, and wrap the outputs to
:class:`DTensor` according to the ``out_placements``.
Args:
func (Callable): the function to be applied on each local shard of
:class:`DTensor` s.
out_placements (Union[`PlacementType`, Tuple[`PlacementType`, ...]]):
the desired placements of the :class:`DTensor` s in ``func``'s flattened output.
If the flattened ``output`` is a single value, the ``out_placements`` should be
of type `PlacementType`. Otherwise if the flattened ``output`` has multiple
values, the ``out_placements`` should be a tuple of `PlacementType` values 1:1
mapping to the flattened ``output``.
Besides, for :class:`Tensor` output, we use `PlacementType` as its
placements (a `Tuple[Placement]` value). For non-Tensor output, the `PlacementType`
should be `None`.
Note that the only exception is when no :class:`DTensor` argument is passed
in. In this case, even if `out_placements` is not `None`, the result function
should ignore the desired placements because the function is not running with
:class:`DTensor` s.
in_placements (Tuple[`PlacementType`, ...], optional):
the required placements of the :class:`DTensor` s in the flattened inputs of ``func``.
If ``in_placements`` is specified, :meth:`local_map` would examine whether the
placements of each :class:`DTensor` argument is the same as the required
placements or not. If the placements are not the same and
``redistribute_inputs`` is ``False``, an exception will be raised. Otherwise if
``redistribute_inputs`` is ``True``, the argument will be first redistributed to
the required sharding placements before passing its local tensor to ``func``.
The only exception is when required placements are not ``None`` and the
argument is a :class:`torch.Tensor`. In this case, the placements examination
will be skipped and the argument will be directly passed to ``func``.
If ``in_placements`` is ``None``, no placements examination will be performed.
Default: None
in_grad_placements (Tuple[`PlacementType`, ...], optional):
the placements hint of the :class:`DTensor` s gradient corresponds
to the flattened input DTensor. This argument is the hint that user
can give to :meth:`to_local` in case the gradient layout of the
local tensor input does not match its :class:`DTensor` input layout.
If not specified, we will assume the gradient layout of the local
tensor input remains the same as the original :class:`DTensor` input
and use that for gradient computation. Default: None.
device_mesh (:class:`DeviceMesh`, optional):
the device mesh that the output :class:`DTensor` s are placed on. If not
specified, this will be inferred from the first input :class:`DTensor`'s device
mesh. Default: None.
Keyword Args:
redistribute_inputs (bool, optional):
the bool value indicating whether to reshard the input :class:`DTensor` s when
their placements are different from the required input placements. If this
value is ``False`` and some :class:`DTensor` input has a different placement,
an exception will be raised. Default: False.
Returns:
A ``Callable`` that applies ``func`` to each local shard of the input :class:`DTensor`
and returns a :class:`DTensor` constructed from the return value of ``func``.
Raises:
AssertionError: For any non-DTensor output, we require its corresponding
output placement in ``out_placements`` be None. An AssertionError will be raised
if this is not the case.
ValueError: If ``redistribute_inputs=False`` but the input :class:`DTensor` needs
a redistribution according to ``in_placements``.
Example:
>>> # xdoctest: +SKIP("distributed")
>>> def mm_allreduce_forward(device_mesh, W, X):
>>> partial_sum_tensor = torch.mm(W, X)
>>> reduced_tensor = funcol.all_reduce(partial_sum_tensor, "sum", device_mesh)
>>> return reduced_tensor
>>>
>>> W = torch.randn(12, 8, requires_grad=False)
>>> X = torch.randn(8, 16, requires_grad=False)
>>> Y = torch.mm(W, X)
>>> row_wise = [Shard(0)] # row-wise sharding placements on 1-d mesh
>>> col_wise = [Shard(1)] # col-wise sharding placements on 1-d mesh
>>>
>>> # local_mm_allreduce_forward is the function wrapped with DTensor/Tensor conversion
>>> local_mm_allreduce_forward = local_map(
>>> mm_allreduce_forward,
>>> out_placements=[Replicate()],
>>> in_placements=[col_wise, row_wise],
>>> device_mesh=device_mesh,
>>> )
>>>
>>> W_dt = distribute_tensor(
... W, device_mesh, (col_wise)
... ) # col-wisely sharded W tensor
>>> X_dt = distribute_tensor(
... X, device_mesh, (row_wise)
... ) # row-wisely sharded X tensor
>>> Y_dt = local_mm_allreduce_forward(
... device_mesh, W_dt, X_dt
... ) # apply local_mm_allreduce_forward to DTensors
.. note:: This API is currently experimental and subject to change
"""
if func is None:
# decorator mode
def decorated(func):
return local_map(
func=func,
out_placements=out_placements,
in_placements=in_placements,
in_grad_placements=in_grad_placements,
device_mesh=device_mesh,
redistribute_inputs=redistribute_inputs,
)
return decorated
return functools.partial(
_local_map_wrapped,
func,
out_placements,
in_placements,
in_grad_placements,
device_mesh,
redistribute_inputs,
)
def _local_map_wrapped(
func: Callable,
out_placements: OutputPlacements,
in_placements: InputPlacements,
in_grad_placements: InputPlacements,
device_mesh: DeviceMesh | None,
redistribute_inputs: bool,
*args,
**kwargs,
):
# process input args
flat_args, args_spec = pytree.tree_flatten(args)
if in_placements is not None:
if len(in_placements) != len(flat_args):
raise AssertionError(
f"in_placements length {len(in_placements)} does not match the number "
f"of input args {len(flat_args)}!"
)
# we assume every DTensor object is placed on the same device mesh
flat_local_args = []
seen_dtensor_arg = False
for idx, arg in enumerate(flat_args):
if isinstance(arg, DTensor):
# TODO: the current code doesn't consider the uneven sharding case
# Need to think about what the consequence is when the input DTensor
# is uneven sharded.
if device_mesh is None: # infer device mesh from the DTensor arg
device_mesh = arg.device_mesh
# this function is applied to at least one DTensor argument
seen_dtensor_arg = True
if in_placements is not None:
spec = in_placements[idx]
if spec is None:
raise AssertionError(
f"DTensor input {arg} expects placements but received {spec}!"
)
if not isinstance(spec, tuple):
spec = tuple(spec)
if arg.placements != spec:
if redistribute_inputs:
# redistribute to input placements
arg = arg.redistribute(placements=spec)
else:
raise ValueError(
f"arg {arg} in local_map has a mismatched placements: "
f"arg placements is {arg.placements} but the input "
f"placements is {spec}! "
"If redistribute_inputs is wanted, set "
"redistribute_inputs=True to local_map."
)
if in_grad_placements is not None:
spec = in_grad_placements[idx]
if spec is None:
raise AssertionError(
f"DTensor input {arg} expects in grad placements but received {spec}!"
)
if not isinstance(spec, tuple):
spec = tuple(spec)
local_arg = arg.to_local(grad_placements=spec)
else:
local_arg = arg.to_local()
if isinstance(local_arg, AsyncCollectiveTensor):
local_arg = local_arg.wait()
flat_local_args.append(local_arg)
else:
# Non-Tensor input must have None in `in_placements`
if in_placements is not None and not isinstance(arg, torch.Tensor):
spec = in_placements[idx]
if spec is not None:
raise AssertionError(
f"Non-Tensor input {arg} expects None placements "
f"but received {spec}!"
)
flat_local_args.append(arg)
# pyrefly: ignore [bad-argument-type]
local_args = pytree.tree_unflatten(flat_local_args, args_spec)
out = func(*local_args, **kwargs)
if seen_dtensor_arg:
# process output to be DTensor if we've seen DTensor inputs
flat_out, out_spec = pytree.tree_flatten(out)
flat_dist_out = []
out_placements_tuple = (
out_placements if isinstance(out_placements, tuple) else (out_placements,)
)
if len(flat_out) != len(out_placements_tuple):
raise AssertionError(
"local_map requires one PlacementType be provided for each output value,"
f" received {len(out_placements_tuple)} out_placements but"
f" {len(flat_out)} is expected!"
)
for out, spec in zip(flat_out, out_placements_tuple):
if isinstance(out, torch.Tensor):
if isinstance(out, DTensor):
raise AssertionError(
f"torch.Tensor output expected but received {type(out)}: {out}"
)
flat_dist_out.append(
# pyrefly: ignore [bad-argument-type]
DTensor.from_local(out, device_mesh, spec, run_check=False)
)
else:
if spec is not None:
raise AssertionError(
f"Non-tensor output {out} expects None placements but received {spec}!"
)
flat_dist_out.append(out)
# pyrefly: ignore [bad-argument-type]
return pytree.tree_unflatten(flat_dist_out, out_spec)
else:
return out
@@ -0,0 +1,136 @@
# mypy: allow-untyped-defs
# Copyright (c) Meta Platforms, Inc. and affiliates
from collections.abc import Callable, Sequence
from functools import partial
import torch
from torch._ops import OpOverload
from torch.distributed.tensor import DTensor
from torch.distributed.tensor._op_schema import (
OpSchema,
OpStrategy,
PlacementList,
RuntimeSchemaInfo,
StrategyType,
TupleStrategy,
)
from torch.distributed.tensor._ops.utils import expand_to_full_mesh_op_strategy
__all__ = ["register_sharding"]
def register_sharding(op: OpOverload | list[OpOverload]):
"""
:meth:`register_sharding` is an experimental API that allows users to register sharding
strategies for an operator when the tensor inputs and outputs are DTensor.
It can be useful when: (1) there doesn't exist a default sharding strategy for ``op``,
e.g. when ``op`` is a custom operator that is not supported by :class:`DTensor`; (2)
when users would like to overwrite default sharding strategies of existing operators.
Args:
op (Union[OpOverload, List[OpOverload]]):
An op or a list of ops to register the customized sharding function.
Returns:
A function decorator which can be used to wrap a function that defines the sharding
strategy for the operator specified in ``op``. The defined sharding strategy will be
registered to DTensor and will override the default sharding strategy if DTensor has
already implemented the operator. The customized sharding function takes the same inputs
as the original op (except that if an arg is a :class:`torch.Tensor`, it will be
replaced by a tensor-like object that DTensor uses internally). The function should
return a sequence of 2-tuples, each specifying acceptable output placements and its
corresponding input placements.
Example:
>>> # xdoctest: +SKIP("distributed")
>>> @register_sharding(aten._softmax.default)
>>> def custom_softmax_sharding(x, dim, half_to_float):
>>> softmax_dim = dim if dim >= 0 else dim + x.ndim
>>> acceptable_shardings = []
>>>
>>> all_replicate = ([Replicate()], [Replicate(), None, None])
>>> acceptable_shardings.append(all_replicate)
>>>
>>> for sharding_dim in range(x.ndim):
>>> if sharding_dim != softmax_dim:
>>> all_sharded = (
>>> [Shard(sharding_dim)],
>>> [Shard(sharding_dim), None, None],
>>> )
>>> acceptable_shardings.append(all_sharded)
>>>
>>> return acceptable_shardings
.. note:: This API is currently experimental and subject to change
"""
def custom_strategy(
custom_sharding_fn: Callable[
..., Sequence[tuple[PlacementList, PlacementList]]
],
op_schema: OpSchema,
) -> StrategyType:
def strategy_to_spec(strategy: object) -> object:
if isinstance(strategy, OpStrategy):
# take the output spec from the first strategy
return strategy.strategies[0].output_spec
elif isinstance(strategy, TupleStrategy):
return tuple(strategy_to_spec(s) for s in strategy.children)
else:
return strategy
mesh = op_schema.get_mesh_from_args()
args_schema = tuple(strategy_to_spec(i) for i in op_schema.args_schema)
kwargs_schema = {
k: strategy_to_spec(v) for k, v in op_schema.kwargs_schema.items()
}
acceptable_shardings = custom_sharding_fn(*args_schema, **kwargs_schema)
single_mesh_dim_strategies: list[PlacementList] = []
for output_specs, input_specs in acceptable_shardings:
single_mesh_dim_strategies.append(output_specs + input_specs)
# TODO: handle out variant ops
return expand_to_full_mesh_op_strategy(
mesh,
op_schema,
single_mesh_dim_strategies,
input_index=len(op_schema.op._schema.returns),
inplace_op=op_schema.is_inplace_op(),
)
def wrapper(custom_sharding_fn):
def derive_schema_info(op):
# NOTE: without user directly providing RuntimeSchemaInfo, for now
# we create it in a conservative fashion as follows:
# 1. let static_argnum be the first int argument
# 2. let static_kwargkey include all the int type kwargs
# 3. always set needs_pytree=True
static_argnum = 100
static_kwargkey: list[str] = []
for i, arg in enumerate(op._schema.arguments):
if isinstance(arg.type, torch.IntType) or (
isinstance(arg.type, torch.OptionalType)
and isinstance(arg.type.getElementType(), torch.IntType)
):
static_argnum = min(i, static_argnum)
if arg.kwarg_only:
static_kwargkey.append(arg.name)
return RuntimeSchemaInfo(
static_argnum, static_kwargkey or None, needs_pytree=True
)
overloads = op if isinstance(op, list) else [op]
for overload in overloads:
DTensor._op_dispatcher.sharding_propagator.register_op_strategy(
overload,
partial(custom_strategy, custom_sharding_fn),
derive_schema_info(overload),
)
return custom_sharding_fn
return wrapper
@@ -0,0 +1,576 @@
# mypy: allow-untyped-defs
import copy
import operator
from collections.abc import Sequence
from typing import Any, cast
import torch
from torch._subclasses.fake_tensor import FakeTensor
from torch.distributed.tensor import DeviceMesh, distribute_tensor, DTensor
from torch.distributed.tensor._dtensor_spec import DTensorSpec, TensorMeta
from torch.distributed.tensor._op_schema import (
OpSchema,
OpSpec,
OutputSharding,
OutputSpecType,
)
from torch.distributed.tensor._redistribute import redistribute_local_tensor
from torch.distributed.tensor.parallel.style import ColwiseParallel, ParallelStyle
from torch.distributed.tensor.placement_types import Placement, Replicate, Shard
from torch.export import ExportedProgram
from torch.export.exported_program import ExportGraphSignature
from torch.fx import GraphModule
from torch.fx.experimental.proxy_tensor import make_fx
from torch.fx.node import Node
from torch.fx.passes.infra.pass_base import PassBase, PassResult
from torch.fx.passes.shape_prop import _extract_tensor_metadata
from torch.utils import _pytree as pytree
__all__ = ["tensor_parallel_transformation"]
aten = torch.ops.aten
def tensor_parallel_transformation(
exported_program: ExportedProgram,
rank: int,
world_size: int,
device_type: str,
parallel_strategies: dict[str, ParallelStyle],
) -> ExportedProgram:
"""
The entry point function to perform graph transformations on an exported program
to transform a single-device graph into a tensor parallel graph.
.. warning::
This API is experimental and subject to change.
"""
gm = exported_program.graph_module
sig = copy.deepcopy(exported_program.graph_signature)
state_dict = copy.copy(exported_program.state_dict)
with gm._set_replace_hook(sig.get_replace_hook()):
res = _TensorParallelTransformPass(
rank,
world_size,
device_type,
state_dict,
exported_program.graph_signature,
parallel_strategies,
)(gm)
if res is None:
raise AssertionError
gm = res.graph_module
return exported_program._update(gm, sig, state_dict=state_dict)
class _TensorParallelTransformPass(PassBase):
"""
This pass is responsible for transforming a single-device graph into a tensor parallel
graph. It will mark the OpSpec of each node in the graph, partition the graph into
distributed graph, then shard the parameters/buffers accordingly.
"""
def __init__(
self,
rank: int,
world_size: int,
device_type: str,
state_dict: dict[str, torch.Tensor],
graph_signature: ExportGraphSignature,
parallel_strategies: dict[str, ParallelStyle],
) -> None:
super().__init__()
self.rank = rank
self.mesh = DeviceMesh(device_type, torch.arange(world_size))
self.state_dict: dict[str, torch.Tensor] = state_dict
self.graph_signature = graph_signature
self.parallel_strategies = parallel_strategies
def call(self, graph_module) -> PassResult:
gm = copy.deepcopy(graph_module)
parameter_placements = _generate_parameter_and_buffer_placements(
list(self.state_dict.keys()), self.parallel_strategies
)
placement_strategies = _mark_sharding(
gm, self.graph_signature, self.mesh, parameter_placements
)
_partitioner(gm)
_shard_state_dict(
self.state_dict, placement_strategies, self.graph_signature, self.mesh
)
return PassResult(gm, True)
def _generate_parameter_and_buffer_placements(
params_and_buffers: list[str],
parallel_strategies: dict[str, ParallelStyle],
) -> dict[str, Placement]:
"""
Build parameter placements based on the give parallel style of linear layers.
"""
parameter_placements: dict[str, Placement] = {}
for linear_fqn, parallel_style in parallel_strategies.items():
weight_fqn = f"{linear_fqn}.weight"
bias_fqn = f"{linear_fqn}.bias"
if weight_fqn not in params_and_buffers:
raise AssertionError
parameter_placements[weight_fqn] = (
Shard(0) if parallel_style == ColwiseParallel else Shard(1)
)
if bias_fqn in params_and_buffers:
parameter_placements[bias_fqn] = (
Shard(0) if parallel_style == ColwiseParallel else Replicate()
)
return parameter_placements
def _mark_tensor_parallel_shardings(
gm: GraphModule,
graph_signature: ExportGraphSignature,
mesh: DeviceMesh,
parameter_placements: dict[str, Placement],
) -> dict[Node, OpSpec]:
"""
Mark the placement strategies of the parameter and buffer placeholder nodes.
"""
placement_strategies: dict[Node, OpSpec] = {}
num_params_and_buffers = len(graph_signature.inputs_to_parameters) + len(
graph_signature.inputs_to_buffers
)
placeholder_idx: int = 0
for node in gm.graph.nodes:
if node.op == "placeholder":
if placeholder_idx < num_params_and_buffers:
fqn: str = _get_input_node_fqn(node.name, graph_signature)
placement: Placement = (
parameter_placements[fqn]
if fqn in parameter_placements
else Replicate()
)
placement_strategies[node] = _create_placement_strategy(
node,
mesh,
placements=(placement,),
)
placeholder_idx += 1
else:
placement_strategies[node] = _create_placement_strategy(
node,
mesh,
placements=(Replicate(),),
)
return placement_strategies
def _get_input_node_fqn(input_name: str, graph_signature: ExportGraphSignature) -> str:
"""
Return the FQN of an input node.
"""
if input_name in graph_signature.inputs_to_parameters:
return graph_signature.inputs_to_parameters[input_name]
elif input_name in graph_signature.inputs_to_buffers:
return graph_signature.inputs_to_buffers[input_name]
else:
raise ValueError(
f"{input_name} not found in inputs_to_parameters or inputs_to_buffers"
)
def _mark_sharding(
gm: GraphModule,
graph_signature: ExportGraphSignature,
mesh: DeviceMesh,
parameter_placements: dict[str, Placement],
) -> dict[Node, OpSpec]:
"""
Mark the sharding strategy for each node in the graph module.
"""
placement_strategies: dict[Node, OpSpec] = _mark_tensor_parallel_shardings(
gm,
graph_signature,
mesh,
parameter_placements,
)
for node in gm.graph.nodes:
if node.op == "placeholder":
if node not in placement_strategies:
placement_strategies[node] = _create_placement_strategy(
node, mesh, placements=(Replicate(),)
)
node.meta["sharding"] = placement_strategies[node]
elif node.op == "call_function":
if node.target is operator.getitem:
input_nodes = node.all_input_nodes
if len(input_nodes) != 1:
raise AssertionError(
f"non-compute op only support one input now, found node: {node} "
f"with length of inputs: {len(node.args)}"
)
arg_strategy = placement_strategies[input_nodes[0]]
placement_strategies[node] = _create_placement_strategy(
node,
mesh,
placements=arg_strategy.output_spec.placements,
input_specs=_get_input_node_specs(node, placement_strategies),
)
node.meta["sharding"] = placement_strategies[node]
else:
op_schema = _get_op_schema(node, placement_strategies)
# get DTensor specs for inputs and outputs
sharding_propagator = DTensor._op_dispatcher.sharding_propagator
if (
op_schema.op not in sharding_propagator.op_strategy_funcs
and op_schema.op not in sharding_propagator.op_to_rules
and op_schema.op
not in sharding_propagator.op_single_dim_strategy_funcs
):
# Mark all as replicated
output_sharding = _generate_default_output_sharding(
node,
mesh,
op_schema,
)
else:
output_sharding = DTensor._op_dispatcher.sharding_propagator.propagate_op_sharding( # type: ignore[assignment]
op_schema,
)
placement_strategies[node] = OpSpec(
# pyrefly: ignore [bad-argument-type]
output_specs=_get_output_spec_from_output_sharding(output_sharding),
# pyrefly: ignore [missing-attribute]
input_specs=output_sharding.redistribute_schema.args_spec
# pyrefly: ignore [missing-attribute]
if output_sharding.redistribute_schema is not None
else _get_input_node_specs(node, placement_strategies),
)
node.meta["sharding"] = placement_strategies[node]
elif node.op == "output":
node.meta["sharding"] = None
else:
raise RuntimeError(f"op code {node.op} not supported")
return placement_strategies
def _get_output_spec_from_output_sharding(
output_sharding: OutputSharding,
) -> DTensorSpec:
"""
Util function to extract output spec from output sharding.
"""
if isinstance(output_sharding.output_spec, DTensorSpec):
return output_sharding.output_spec
else:
# For ops that return multiple outputs, the outputs should have the same output spec
if not isinstance(output_sharding.output_spec, Sequence):
raise AssertionError
if output_sharding.output_spec[0] is None:
raise AssertionError
output_sharding.output_spec[0].tensor_meta = None
return output_sharding.output_spec[0]
def _create_placement_strategy(
node: Node,
mesh: DeviceMesh,
placements: tuple[Placement, ...],
input_specs: Sequence[DTensorSpec] | None = None,
) -> OpSpec:
"""
Util function to construct an OpSpec for a given node.
"""
placement = OpSpec(
input_specs=input_specs,
output_specs=DTensorSpec(
mesh=mesh,
placements=placements,
),
)
_populate_tensor_meta(node, placement.output_specs)
return placement
def _populate_tensor_meta(node: Node, output_spec: OutputSpecType) -> None:
"""
Util function to populate tensor meta of output_spec based on node metadata.
"""
if isinstance(node.meta["val"], Sequence):
if not isinstance(output_spec, Sequence):
raise AssertionError
for spec, fake_tensor in zip(output_spec, node.meta["val"]):
if spec is None:
raise AssertionError
spec.tensor_meta = TensorMeta(
shape=fake_tensor.shape,
stride=fake_tensor.stride(),
dtype=fake_tensor.dtype,
)
else:
if not isinstance(output_spec, DTensorSpec):
raise AssertionError
output_spec.tensor_meta = TensorMeta(
shape=node.meta["val"].shape,
stride=node.meta["val"].stride(),
dtype=node.meta["val"].dtype,
)
def _generate_default_output_sharding(
node: Node,
mesh: DeviceMesh,
op_schema: OpSchema,
) -> OutputSharding:
"""
Util function to create a default output sharding that suggests Replicate placement for both args and outputs.
"""
def update_arg_spec(arg_spec: DTensorSpec) -> DTensorSpec:
return DTensorSpec(
mesh=arg_spec.mesh,
placements=(Replicate(),),
tensor_meta=arg_spec.tensor_meta,
)
new_op_schema = OpSchema(
op=op_schema.op,
args_schema=pytree.tree_map_only(
DTensorSpec, update_arg_spec, op_schema.args_schema
),
kwargs_schema=op_schema.kwargs_schema,
)
def create_output_spec(tensor: FakeTensor) -> DTensorSpec:
return DTensorSpec(
mesh=mesh,
placements=(Replicate(),),
tensor_meta=TensorMeta(
shape=tensor.shape,
stride=tensor.stride(),
dtype=tensor.dtype,
),
)
return OutputSharding(
output_spec=pytree.tree_map_only(
FakeTensor, create_output_spec, node.meta["val"]
),
redistribute_schema=new_op_schema,
needs_redistribute=True,
)
def _partitioner(gm: torch.fx.GraphModule) -> torch.fx.GraphModule:
"""
Graph partitioner that partitions the single device graph
to distributed graph
"""
for node in gm.graph.nodes:
node_sharding = node.meta["sharding"]
if node.op == "placeholder":
out_spec = node_sharding.output_spec
local_val = _partition_val(node.meta["val"], out_spec)
# update node value
node.meta["val"] = local_val
elif node.op == "call_function":
out_spec = node_sharding.output_spec
# check if there's misaligned sharding, insert reshard if there is
expected_input_specs = node_sharding.input_specs
for idx, input_arg in enumerate(node.all_input_nodes):
input_arg_sharding = input_arg.meta["sharding"]
input_arg_spec = input_arg_sharding.output_spec
desired_spec = (
out_spec
if expected_input_specs is None
else expected_input_specs[idx]
)
if input_arg_spec != desired_spec:
_insert_reshard_gm(
gm, node, input_arg, input_arg_spec, desired_spec
)
# convert output val to its local component
output_val = node.meta["val"]
node.meta["val"] = _partition_val(output_val, out_spec)
elif node.op == "output":
for input_arg in node.all_input_nodes:
# input args of output should be Replicate, otherwise redistribution is needed.
input_args_to_check: Sequence[Node] = (
input_arg if isinstance(input_arg, Sequence) else [input_arg]
)
for arg in input_args_to_check:
arg_sharding = arg.meta["sharding"]
arg_spec = arg_sharding.output_spec
desired_spec = copy.copy(arg_spec)
desired_spec.placements = (Replicate(),)
if arg_spec != desired_spec:
_insert_reshard_gm(gm, node, arg, arg_spec, desired_spec)
else:
raise RuntimeError(f"op code {node} not supported")
_clean_up_graph_metadata(gm)
gm.graph.lint()
gm.recompile()
return gm
def _partition_val(val: Any, spec: DTensorSpec) -> Any:
"""
util function to convert a full tensor val to its local component
"""
if isinstance(val, torch.Tensor):
local_shard = val
if val.ndim == 0:
# If it's already a scalar tensor, it is already local, we don't
# need to do anything
return local_shard
for idx, placement in enumerate(spec.placements):
# NOTE: is_shard() does not match _StridedShard; see _is_shard_like().
if placement.is_shard():
placement = cast(Shard, placement)
num_chunks = spec.mesh.size(mesh_dim=idx)
my_coord = spec.mesh.get_coordinate()
if my_coord is None:
raise AssertionError("current rank not in mesh!")
my_coord_on_mesh_dim = my_coord[idx]
local_shard = placement._select_split_tensor(
local_shard,
num_chunks,
my_coord_on_mesh_dim,
with_padding=False,
contiguous=True,
clone=False,
)
return local_shard
elif isinstance(val, (list, tuple)):
return val.__class__(_partition_val(v, spec) for v in val)
else:
raise RuntimeError(f"val type {type(val)} not supported")
def _insert_reshard_gm(
gm: torch.fx.GraphModule,
node: Node,
input_arg: Node,
input_arg_spec: DTensorSpec,
desired_spec: DTensorSpec,
) -> None:
"""
Transform the graph for tensor redistribution.
"""
input_arg_spec.tensor_meta = input_arg.meta["tensor_meta"]
desired_spec.tensor_meta = input_arg.meta["tensor_meta"]
input_arg_tensor = input_arg.meta["val"]
# insert reshard operation
def reshard_fn(local_tensor: torch.Tensor) -> torch.Tensor:
return redistribute_local_tensor(
local_tensor,
input_arg_spec,
desired_spec,
)
reshard_gm = make_fx(reshard_fn)(input_arg_tensor)
reshard_gm_nodes = list(reshard_gm.graph.nodes)
input_node = reshard_gm_nodes[0]
with gm.graph.inserting_before(node):
# copy nn_module_stack metadata for output, all-reduce nodes
for reshard_node in reshard_gm.graph.nodes:
if reshard_node.op not in ["placeholder", "output"]:
reshard_node.meta["nn_module_stack"] = (
copy.copy(input_arg.meta["nn_module_stack"])
if input_arg.op != "placeholder"
else copy.copy(node.meta["nn_module_stack"])
)
output_node = gm.graph.graph_copy(
reshard_gm.graph,
val_map={
input_node: input_arg,
},
)
node.replace_input_with(input_arg, output_node) # type: ignore[arg-type]
def _clean_up_graph_metadata(gm: torch.fx.GraphModule) -> None:
"""
Clean up the graph by removing sharding and partitioning related metadata
"""
for node in gm.graph.nodes:
if "sharding" in node.meta:
del node.meta["sharding"]
if "val" in node.meta and isinstance(node.meta["val"], torch.Tensor):
local_tensor_meta = _extract_tensor_metadata(node.meta["val"])
node.meta["tensor_meta"] = local_tensor_meta
def _get_input_node_specs(
node: Node, placement_strategies: dict[Node, OpSpec]
) -> tuple[DTensorSpec, ...]:
"""
Get the input specs of a node.
"""
input_specs_list: list[DTensorSpec] = []
for input_arg in node.all_input_nodes:
if input_arg in placement_strategies:
output_spec = placement_strategies[input_arg].output_specs
if not isinstance(output_spec, DTensorSpec):
raise AssertionError
input_specs_list.append(output_spec)
else:
raise ValueError(f"{input_arg} does not have output_spec populated.")
return tuple(input_specs_list)
def _get_op_schema(node: Node, placement_strategies: dict[Node, OpSpec]) -> OpSchema:
"""
Util function to construct the operator schema of a node.
"""
args_schema_list = pytree.tree_map_only(
Node, lambda arg: placement_strategies[arg].output_specs, node.args
)
op_schema = OpSchema(
op=cast(torch._ops.OpOverload, node.target),
args_schema=tuple(args_schema_list),
kwargs_schema=cast(dict[str, object], node.kwargs),
)
return op_schema
def _shard_state_dict(
state_dict: dict[str, torch.Tensor],
placement_strategies: dict[Node, OpSpec],
graph_signature: ExportGraphSignature,
mesh: DeviceMesh,
) -> None:
"""
Inplace partition the weights based on the OpSpec
"""
for node, op_spec in placement_strategies.items():
if node.op != "placeholder":
continue
if node.name in graph_signature.inputs_to_parameters:
fqn = graph_signature.inputs_to_parameters[node.name]
elif node.name in graph_signature.inputs_to_buffers:
fqn = graph_signature.inputs_to_buffers[node.name]
else:
continue
if fqn not in state_dict:
raise AssertionError(f"{fqn} not found in state dict: {state_dict.keys()}")
original_param = state_dict[fqn]
dtensor_param = distribute_tensor(
original_param,
mesh,
op_spec.output_spec.placements,
)
local_param = dtensor_param.to_local()
state_dict[fqn] = (
torch.nn.Parameter(local_param)
if isinstance(original_param, torch.nn.Parameter)
else local_param
)
@@ -0,0 +1,25 @@
# Copyright (c) Meta Platforms, Inc. and affiliates
from torch.distributed.tensor.parallel.api import parallelize_module
from torch.distributed.tensor.parallel.loss import loss_parallel
from torch.distributed.tensor.parallel.style import (
ColwiseParallel,
ParallelStyle,
PrepareModuleInput,
PrepareModuleInputOutput,
PrepareModuleOutput,
RowwiseParallel,
SequenceParallel,
)
__all__ = [
"ColwiseParallel",
"ParallelStyle",
"PrepareModuleInput",
"PrepareModuleInputOutput",
"PrepareModuleOutput",
"RowwiseParallel",
"SequenceParallel",
"parallelize_module",
"loss_parallel",
]
@@ -0,0 +1,51 @@
from functools import partial
from typing import no_type_check
import torch
from torch.distributed._functional_collectives import AsyncCollectiveTensor
from torch.distributed.tensor import DTensor
from torch.distributed.tensor._dtensor_spec import DTensorSpec
@no_type_check
def sync_grad_hook(grad, *, device_handle=None, compute_stream=None):
if isinstance(grad, AsyncCollectiveTensor):
if compute_stream is not None:
with device_handle.stream(compute_stream):
grad = grad.wait()
else:
grad = grad.wait()
return grad
def _flatten_tensor(
tensor: torch.Tensor,
) -> tuple[torch.Tensor, DTensorSpec | None]:
if isinstance(tensor, DTensor):
tensor._local_tensor.requires_grad_()
return tensor._local_tensor, tensor._spec
return tensor, None
@no_type_check
def _unflatten_tensor(tensor, spec, *, device_handle=None, compute_stream=None):
# unflatten would mainly be called every time FSDP allgather parameters.
result = DTensor.from_local(
tensor,
spec.mesh,
spec.placements,
run_check=False,
shape=spec.shape,
stride=spec.stride,
)
if tensor.requires_grad:
# only register the hook if the tensor requires grad
tensor.register_hook(
partial(
sync_grad_hook,
device_handle=device_handle,
compute_stream=compute_stream,
)
)
return result
@@ -0,0 +1,142 @@
# Copyright (c) Meta Platforms, Inc. and affiliates
import warnings
from fnmatch import fnmatch
import torch
import torch.nn as nn
from torch.distributed.device_mesh import _mesh_resources, DeviceMesh
from torch.distributed.tensor.parallel.style import ParallelStyle
__all__ = ["parallelize_module"]
def parallelize_module( # type: ignore[return]
module: nn.Module,
device_mesh: DeviceMesh | None = None,
parallelize_plan: ParallelStyle | dict[str, ParallelStyle] | None = None,
*,
src_data_rank: int | None = 0,
) -> nn.Module:
"""
Apply Tensor Parallelism in PyTorch by parallelizing modules or sub-modules based on a user-specified plan.
We parallelize module or sub_modules based on a parallelize_plan. The parallelize_plan contains
:class:`ParallelStyle`, which indicates how user wants the module or sub_module
to be parallelized.
User can also specify different parallel style per module fully qualified name (FQN).
Note that ``parallelize_module`` only accepts a 1-D :class:`DeviceMesh`, if you have a 2-D or N-D :class:`DeviceMesh`,
slice the DeviceMesh to a 1-D sub DeviceMesh first then pass to this API(i.e. ``device_mesh[\"tp\"]``)
Args:
module (:class:`nn.Module`):
Module to be parallelized.
device_mesh (:class:`DeviceMesh`, optional):
Object which describes the mesh topology of devices for the DTensor.
If not specified, the call must be under a DeviceMesh context.
parallelize_plan (Union[:class:`ParallelStyle`, Dict[str, :class:`ParallelStyle`]], optional):
The plan used to parallelize the module. It can be either a
:class:`ParallelStyle` object which contains how we prepare
input/output for Tensor Parallelism or it can be a dict of module
FQN and its corresponding :class:`ParallelStyle` object. If not
specified, the call will do nothing at the moment.
Keyword args:
src_data_rank (int, optional): the rank of the source data for the logical/global tensor, it is used by
:meth:`distribute_tensor` to scatter/broadcast the shards/replicas to other ranks. By default,
we use ``group_rank=0`` on each DeviceMesh dimension as the source data to preserve the single-device
semantic. If passing ``None`` explicitly, :meth:`parallelize_module` simply uses its local data instead
of trying to preserve the single-device semantic via scatter/broadcast. Default: 0
Return:
A :class:`nn.Module` object parallelized.
Example::
>>> # xdoctest: +SKIP("distributed")
>>> from torch.distributed.tensor.parallel import parallelize_module, ColwiseParallel
>>> from torch.distributed.device_mesh import init_device_mesh
>>>
>>> # Define the module.
>>> m = Model(...)
>>> tp_mesh = init_device_mesh("cuda", (8,))
>>> m = parallelize_module(m, tp_mesh, {"w1": ColwiseParallel(), "w2": RowwiseParallel()})
>>>
.. note:: For complex module architecture like Attention, MLP layers, we recommend composing
different ParallelStyles together (i.e. ``ColwiseParallel`` and ``RowwiseParallel``) and pass
as a parallelize_plan, to achieves the desired sharding computation.
"""
torch._C._log_api_usage_once("torch.distributed.tensor.parallel.parallelize_module")
device_mesh = device_mesh or _mesh_resources.get_current_mesh()
if parallelize_plan is None:
warnings.warn(
"No parallelize_plan is provided and auto-parallel is not supported "
"at the moment, so this parallelize_module call will do nothing.",
stacklevel=2,
)
return module
# note: The RNG tracker will be initialized in distribute_tensor() call if it hasn't
# been initialized.
if isinstance(parallelize_plan, ParallelStyle):
parallelize_plan.src_data_rank = src_data_rank
return parallelize_plan._apply(module, device_mesh)
elif isinstance(parallelize_plan, dict):
for module_path, parallelize_style in parallelize_plan.items():
if module_path == "":
# shortcut: empty string means to apply the plan to the current module
parallelize_module(module, device_mesh, parallelize_style)
continue
path_splits = module_path.split(".")
# Instead of blindly popping tokens, first check the match,
# we only consume/pop the token if we found a match.
token = path_splits[0]
matched_children = list(
filter(
# `t[0]` is child name
lambda t: fnmatch(t[0], token),
module.named_children(),
)
)
if not matched_children:
# No match at this level. Log a warning and process next plan entry.
warnings.warn(
f"Parallelize plan key '{module_path}' could not be resolved: "
f"no submodule matching token '{token}' in module {module}, "
f"skipping this plan entry.",
stacklevel=2,
)
continue
# Now that we have a match, we can consume the token.
path_splits.pop(0)
# apply the plan to all matched submodules
for _, submodule in matched_children:
if path_splits:
# we haven't reached the leaf, apply in dict style
leaf_path = ".".join(path_splits) # rest of the path after `token`
parallelize_module(
submodule,
device_mesh,
{leaf_path: parallelize_style},
src_data_rank=src_data_rank,
)
else:
# otherwise, directly apply style to this submodule
parallelize_module(
submodule,
device_mesh,
parallelize_style,
src_data_rank=src_data_rank,
)
return module
else:
raise TypeError( # pyre-ignore[7]
"Expect Union[ParallelStyle, Dict[str, ParallelStyle]] for"
f" parallelize_plan, {type(parallelize_plan)} found!"
)
@@ -0,0 +1,105 @@
# mypy: allow-untyped-defs
from typing import Any
import torch.nn as nn
from torch.distributed.tensor.parallel._data_parallel_utils import (
_flatten_tensor,
_unflatten_tensor,
)
__all__ = [] # type: ignore[var-annotated]
def _get_submodule_n_params(module: nn.Module, path: str):
"""
Get submodule and the direct path of parameter from the module
"""
if "." in path:
path_list = path.split(".")
parent_module_path = ".".join(path_list[:-1])
module = module.get_submodule(parent_module_path)
path = path_list[-1]
return module, path
def _update_module_param(param_list: list[tuple[nn.Module, str, nn.Parameter]]):
"""
Update parameters within the module
"""
for item in param_list:
parent_module, module_path, t = item
if not hasattr(parent_module, module_path):
raise AssertionError
delattr(parent_module, module_path)
setattr(parent_module, module_path, t)
def _reconstruct_dtensor(module: nn.Module, _input: Any):
"""
Reconstruct DTensor parameters from local tensors
"""
param_list = []
# TODO: To add perf optimizations to this iterations
for name, t in module.named_parameters():
if hasattr(t, "_st_info"):
dtensor = _unflatten_tensor(t, t._st_info)
param_list.append((*_get_submodule_n_params(module, name), dtensor))
_update_module_param(param_list) # type: ignore[arg-type]
def _localize_dtensor(
module: nn.Module, *_: Any, ignored_params: set[nn.Parameter] | None = None
):
"""
Convert DTensor parameters to local tensors
"""
if ignored_params is None:
ignored_params = set()
param_list = []
for name, param in module.named_parameters():
if param in ignored_params:
continue
t, sharding_info = _flatten_tensor(param)
if sharding_info is not None:
t = nn.Parameter(t)
t._st_info = sharding_info # type: ignore[attr-defined]
param_list.append((*_get_submodule_n_params(module, name), t))
_update_module_param(param_list) # type: ignore[arg-type]
def _pre_dp_module_transform(module: nn.Module):
"""
Enable the composability between Tensor Parallelism (TP) and Data
Parallelism(DP) in PyTorch when using DDP. We need to convert Parameters which
are DTensors to local tensors before wrapping with data parallelism API.
We then register two hooks, one for converting local tensors back to DTensor
preforward and one to convert DTensors back to tensors after Forward. By
integrating this way, we avoid any special handling of DTensor parameters by DDP
and get DTensor's gradients propagated back to DP, e.g. gradient buckets of DDP.
For now, this API only works with ``DistributedDataParallel``. It will later support
other DP methods such as FSDP.
Args:
module (:class:`nn.Module`):
Module which has been applied TP on.
Example::
>>> # xdoctest: +SKIP("distributed")
>>> from torch.distributed.tensor.parallel import parallelize_module, PairwiseParallel
>>> from torch.nn.parallel import DistributedDataParallel as DDP
>>> from torch.distributed.tensor.parallel.ddp import pre_dp_module_transform
>>>
>>> # Define the module.
>>> m = module(...)
>>> parallelize_module(m, PairwiseParallel())
>>> m = pre_dp_module_transform(m)
>>> m = DDP(m)
>>>
"""
_localize_dtensor(module, None, None)
# TODO: To add test cases and ensure that it works for nested modules
module.register_forward_pre_hook(_reconstruct_dtensor)
module.register_forward_hook(_localize_dtensor)
@@ -0,0 +1,400 @@
# mypy: allow-untyped-defs
import copy
from typing import Any, cast
import torch
import torch.distributed as dist
import torch.distributed._shard.sharding_spec as shard_spec
import torch.distributed.distributed_c10d as c10d
from torch.distributed._shard.sharded_tensor import (
Shard,
ShardedTensor,
ShardedTensorMetadata,
TensorProperties,
)
from torch.distributed._shard.sharding_spec import ShardMetadata
from torch.distributed._shard.sharding_spec.chunk_sharding_spec import ChunkShardingSpec
from torch.distributed.fsdp._common_utils import _set_fsdp_flattened
from torch.distributed.fsdp._fsdp_extensions import FSDPExtensions
from torch.distributed.fsdp._shard_utils import _create_chunk_sharded_tensor
from torch.distributed.remote_device import _remote_device
from torch.distributed.tensor import DeviceMesh, DTensor, Replicate, Shard as DShard
from torch.distributed.tensor.parallel._data_parallel_utils import (
_flatten_tensor,
_unflatten_tensor,
)
__all__ = ["DTensorExtensions"]
def _get_box(tensor: DTensor) -> tuple[torch.Size, torch.Size]:
device_mesh = tensor.device_mesh
if device_mesh.ndim != 1:
raise AssertionError("Only 1D DeviceMeshes currently handled")
placement = tensor.placements[0]
offsets = [0] * len(tensor.size())
num_chunks = device_mesh.size(mesh_dim=0)
# NOTE: is_shard() does not match _StridedShard; see _is_shard_like().
if tensor.placements[0].is_shard():
shard_dim = cast(DShard, placement).dim
chunk_size = tensor.size(shard_dim) // num_chunks
offsets[shard_dim] = chunk_size
return (torch.Size(offsets), tensor._local_tensor.size())
def _get_box_for(tensor: DTensor, idx: int) -> tuple[torch.Size, torch.Size]:
offsets, size = _get_box(tensor)
return (torch.Size([val * idx for val in offsets]), size)
def _get_local_box(tensor: DTensor) -> tuple[torch.Size, torch.Size]:
device_mesh = tensor.device_mesh
coord = device_mesh.get_coordinate()
if coord is None:
raise AssertionError
return _get_box_for(tensor, coord[0])
def _create_shard_md_from_dt(dt: DTensor, current_rank: int) -> ShardMetadata:
mesh = dt.device_mesh
if mesh.ndim != 1:
raise AssertionError("Only 1D DeviceMeshes currently handled")
offsets, sizes = _get_local_box(dt)
return ShardMetadata(
shard_offsets=list(offsets),
shard_sizes=list(sizes),
placement=f"rank:{current_rank}/{dt._local_tensor.device}",
)
def _create_sharded_tensor_md_from_dt(
dt: DTensor, dt_pg: c10d.ProcessGroup
) -> ShardedTensorMetadata:
# This is where it gets tricky, we have to produce a ShardedTensor that has full coverage
# and yet has only one valid shard for the current rank.
shards_md = []
my_rank = dist.get_rank(dt_pg)
scapegoat_rank = 0 if my_rank > 0 else 1
# NOTE: is_shard() does not match _StridedShard; see _is_shard_like().
if dt.placements[0].is_shard():
shard_count = dt_pg.size()
else:
shard_count = 1
for i in range(shard_count):
offsets, sizes = _get_box_for(dt, i)
shards_md.append(
ShardMetadata(
shard_offsets=list(offsets),
shard_sizes=list(sizes),
placement=(
f"rank:{scapegoat_rank if i > 0 else my_rank}/{dt._local_tensor.device}"
),
)
)
return ShardedTensorMetadata(
shards_metadata=shards_md,
size=dt.size(),
tensor_properties=TensorProperties(
dtype=dt.dtype,
layout=dt.layout,
requires_grad=dt.requires_grad,
# ignore memory_format and pin_memory as those are not supported by DT
),
)
def _get_dt_pg(dt: DTensor) -> c10d.ProcessGroup:
mesh = dt.device_mesh
if mesh.ndim != 1:
raise AssertionError("Only 1D DeviceMeshes currently handled")
return mesh.get_group()
def _rewrite_spec_if_needed(
spec: shard_spec.ShardingSpec, tensor: torch.Tensor, rank: int
) -> shard_spec.ShardingSpec:
"""
Rewrite ``spec`` to match the device of ``tensor``.
FSDP.sharded_optim_state_dict sneakly ships optimizer state to CPU so if the original ShardingSpec
produces CUDA metadata, ST construction bombs.
"""
if not isinstance(spec, ChunkShardingSpec):
return spec
# let's see if we need
rewrite = False
for p in spec.placements:
p = cast(_remote_device, p)
if p.rank() == rank and p.device() != tensor.device:
rewrite = True
break
if rewrite:
spec = copy.deepcopy(spec)
# pyrefly: ignore [missing-attribute]
for i, placement in enumerate(spec.placements):
placement = cast(_remote_device, placement)
if placement.rank() == rank and placement.device() != tensor.device:
# pyrefly: ignore [missing-attribute]
spec.placements[i] = _remote_device(f"rank:{rank}/{tensor.device}")
return spec
def _chunk_tensor(
tensor: torch.Tensor,
rank: int,
world_size: int,
num_devices_per_node: int,
pg: dist.ProcessGroup,
) -> torch.Tensor:
if type(tensor) is ShardedTensor:
if len(tensor.local_shards()) != 1:
raise AssertionError
inner_param = tensor.local_tensor()
inner_st = _create_chunk_sharded_tensor(
inner_param,
rank,
world_size,
num_devices_per_node,
pg,
)
outer_local_shard = tensor.local_shards()[0]
shards: list[Shard] = [
Shard(inner_st, copy.deepcopy(outer_local_shard.metadata))
]
st_meta = copy.deepcopy(tensor.metadata())
st_meta.tensor_properties.requires_grad = False
st_outer = ShardedTensor._init_from_local_shards_and_global_metadata(
shards,
sharded_tensor_metadata=st_meta,
process_group=tensor._process_group,
init_rrefs=False,
)
return st_outer
elif type(tensor) is DTensor:
device_mesh = tensor.device_mesh
if device_mesh.ndim != 1:
raise AssertionError("Only 1D DeviceMeshes currently handled")
inner_param = tensor._local_tensor
inner_st = _create_chunk_sharded_tensor(
inner_param,
rank,
world_size,
torch.accelerator.device_count(),
pg,
)
dt_pg = _get_dt_pg(tensor)
# We do this differently here, we create a ST with no local shards then patch it
shards = [
Shard(inner_st, _create_shard_md_from_dt(tensor, dist.get_rank(dt_pg)))
]
st_meta = _create_sharded_tensor_md_from_dt(tensor, dt_pg)
st_meta.tensor_properties.requires_grad = False
st_outer = ShardedTensor._init_from_local_shards_and_global_metadata(
shards,
sharded_tensor_metadata=st_meta,
process_group=dt_pg,
init_rrefs=False,
)
return st_outer
else:
return _create_chunk_sharded_tensor(
tensor,
rank,
world_size,
num_devices_per_node,
pg,
)
def _chunk_dtensor(
tensor: torch.Tensor,
rank: int,
device_mesh: DeviceMesh,
) -> DTensor:
"""
Shard a tensor to chunks along the first dimension.
The local rank will gets its corresponding chunk as the local tensor to create a DTensor.
"""
root_mesh = device_mesh._get_root_mesh() if device_mesh is not None else None
if root_mesh is None:
raise RuntimeError("No parent device_mesh is found for FSDP device_mesh.")
if root_mesh.ndim < 2:
raise RuntimeError(
f"Found parent device_mesh of ndim={root_mesh.ndim},",
"but meshes must be at least 2D.",
)
# We need to explicitly call .detach() to return a new tensor detached from the current graph.
tensor = tensor.detach().clone()
# When a layer is not involved in TP, then the tensor will not be a DTensor.
# e.g. When a layer is not sppecified in the parallelize_plan, TP will have no effect on the layer.
# e.g. When you do PairwiseParallel on a 3 layer model, TP will have no effect on the third layer.
if isinstance(tensor, torch.Tensor) and not isinstance(tensor, DTensor):
# For tensors, it is replicated across tp dimension and sharded across FSDP dimension.
# TP is the inner dimension and FSDP is the outer dimension.
# Therefore, shard placements for tensor is (Shard(0), Replicate()).
replicate_placements = [Replicate() for _ in range(root_mesh.ndim)]
shard_placements = [Replicate() for _ in range(root_mesh.ndim)]
shard_placements[0] = DShard(0) # type: ignore[call-overload]
return DTensor.from_local(
tensor, root_mesh, replicate_placements, run_check=False
).redistribute(
device_mesh=root_mesh,
placements=shard_placements,
)
else:
tp_placements = tensor.placements
tp_placement = tp_placements[0]
tensor = tensor.to_local()
# For DTensors, it is sharded across tp dimension first and then sharded across FSDP dimension.
# TP is the inner dimension and FSDP is the outer dimension.
# Therefore, shard placements for tensor is (Shard(0), tp_placement).
# For higher dimensional meshes, it is replicated across other dimensions. For example, with
# HSDP the shard placements for tensor is (Replicate, Shard(0), tp_placement).
replicate_placements = [Replicate() for _ in range(root_mesh.ndim)]
replicate_placements[-1] = tp_placement # type: ignore[call-overload]
shard_placements = [Replicate() for i in range(root_mesh.ndim)] # type: ignore[misc]
shard_placements[-2] = DShard(0) # type: ignore[call-overload]
shard_placements[-1] = tp_placement # type: ignore[call-overload]
return DTensor.from_local(
tensor, root_mesh, replicate_placements, run_check=False
).redistribute(
device_mesh=root_mesh,
placements=shard_placements,
)
def _pre_load_state_dict(
tensor: torch.Tensor,
) -> tuple[torch.Tensor, list[Shard]]:
shards = cast(ShardedTensor, tensor).local_shards()
if len(shards) == 1 and type(shards[0].tensor) is ShardedTensor:
inner_tensor = shards[0].tensor
shards = inner_tensor.local_shards() # pyre-ignore[16]
tensor = inner_tensor
return (tensor, shards if len(shards) > 0 else [])
def _all_gather_dtensor(
tensor: DTensor,
parent_mesh: DeviceMesh | None,
) -> torch.Tensor:
"""All gather a DTensor in its FSDP dimension and return the local tensor."""
if parent_mesh != tensor.device_mesh:
raise AssertionError
placements = list(copy.deepcopy(tensor.placements))
# FSDP + TP: [Shard(0), tp_placement] -> [Replicate(), tp_placement]
# HSDP + TP: [Replicate(), Shard(0), tp_placement] -> [Replicate(), Replicate(), tp_placement]
for i in range(len(placements) - 1):
placements[i] = Replicate()
tensor = tensor.redistribute(
device_mesh=tensor.device_mesh,
placements=placements,
)
return tensor.to_local()
class DTensorExtensions(FSDPExtensions):
"""
DTensorExtension is the TensorFlattener extension needed for 2D FSDP + TP.
This is the implementation for FSDPExtensions defined in
https://github.com/pytorch/pytorch/blob/main/torch/distributed/fsdp/_fsdp_extensions.py
"""
def __init__(self, device_handle) -> None:
super().__init__()
self.compute_stream = None
self.device_handle = device_handle
# we have to use the dynamo disable this way to disable dynamo as the decorator way would
# trigger build failure with torch deploy...
self.post_unflatten_transform = torch._dynamo.disable( # type: ignore[method-assign]
self.post_unflatten_transform
)
def pre_flatten_transform(
self,
tensor: torch.Tensor,
) -> tuple[torch.Tensor, Any | None]:
return _flatten_tensor(tensor)
def post_unflatten_transform(
self, tensor: torch.Tensor, param_extension: Any
) -> torch.Tensor:
stream = self.compute_stream or self.device_handle.current_stream()
with self.device_handle.stream(stream):
# runtime we put the unflattened tensor call on the compute stream since
# the unflattened tensor might contain computations in fwd/bwd where we
# need to sync properly.
# TODO: this is a short term fix and we should make the get_unflat_views
# directly happen in the compute stream.
result = _unflatten_tensor(
tensor,
param_extension,
device_handle=self.device_handle,
compute_stream=self.compute_stream,
)
_set_fsdp_flattened(result)
return result
def chunk_tensor(
self,
tensor: torch.Tensor,
rank: int,
world_size: int,
num_devices_per_node: int,
pg: dist.ProcessGroup,
device: torch.device | None = None,
) -> torch.Tensor:
return _chunk_tensor(tensor, rank, world_size, num_devices_per_node, pg)
def chunk_dtensor(
self,
tensor: torch.Tensor,
rank: int,
device_mesh: DeviceMesh,
) -> torch.Tensor:
return _chunk_dtensor(tensor, rank, device_mesh)
def pre_load_state_dict_transform(
self,
tensor: torch.Tensor,
) -> tuple[torch.Tensor, list[Shard]]:
return _pre_load_state_dict(tensor)
def all_gather_dtensor(
self,
tensor: DTensor,
parent_mesh: DeviceMesh | None,
) -> torch.Tensor:
return _all_gather_dtensor(tensor, parent_mesh)
@@ -0,0 +1,107 @@
# Copyright (c) Meta Platforms, Inc. and affiliates
from functools import partial
from typing import Any
import torch
from torch.distributed.tensor import DeviceMesh, DTensor, Replicate, Shard
from torch.distributed.tensor.placement_types import _is_shard_like
__all__ = [
"input_reshard",
]
def input_reshard(
module: torch.nn.Module,
tp_device_mesh: DeviceMesh,
input_reshard_dim: int | None = None,
) -> torch.nn.Module:
"""
Register hooks to an nn.Module for input resharding, enabling sharding and restoration during backward computation.
Register hooks to an nn.Module with input resharding so that we can shard
per the given `tp_device_mesh` and `input_reshard_dim` and restore the
input back when recomputing the activations in the backward. The reason
why we can do this is that for Tensor Parallel(TP), the input are same
across all TP ranks.
Args:
module (:class:`nn.Module`):
Module to be registered with input resharding.
tp_device_mesh (:class:`DeviceMesh`):
Object which describes the mesh topology
of devices for Tensor Parallel.
input_reshard_dim (Optional[int]):
The dimension of where we perform the sharding
of input. If set None, there is no sharding of input.
Default: None
Return:
A :class:`nn.Module` object registered with TP input resharding.
"""
if input_reshard_dim is None:
return module
cx: torch.autograd.graph.saved_tensors_hooks | None = None
def input_reshard_forward_pre_hook(_: torch.nn.Module, _i: tuple[Any, ...]) -> None:
saved_tensor_hooks = torch.autograd.graph.saved_tensors_hooks(
partial(_pack_hook_tp, tp_device_mesh, input_reshard_dim),
partial(_unpack_hook_tp, tp_device_mesh, input_reshard_dim),
)
saved_tensor_hooks.__enter__()
nonlocal cx
cx = saved_tensor_hooks # type: ignore[name-defined]
def input_reshard_backward_hook(
_: torch.nn.Module, _i: tuple[Any, ...], _o: Any
) -> Any:
nonlocal cx
cx.__exit__() # type: ignore[name-defined, union-attr]
module.register_forward_pre_hook(input_reshard_forward_pre_hook)
module.register_forward_hook(input_reshard_backward_hook)
return module
def _pack_hook_tp(mesh: DeviceMesh, input_reshard_dim: int, x: torch.Tensor) -> Any: # noqa: D401
"""Hook function called after FWD to shard input."""
if isinstance(x, DTensor) and all(p.is_replicate() for p in x._spec.placements):
return x.redistribute(device_mesh=mesh, placements=[Shard(input_reshard_dim)])
elif (
not isinstance(x, DTensor)
and isinstance(x, torch.Tensor)
and x.numel() >= mesh.size()
):
return (
DTensor.from_local(x, device_mesh=mesh)
.redistribute(device_mesh=mesh, placements=[Shard(input_reshard_dim)])
.to_local()
)
else:
return x
def _unpack_hook_tp(mesh: DeviceMesh, input_reshard_dim: int, x: Any) -> torch.Tensor: # noqa: D401
"""Hook function called before activation recomputing in BWD to restore input."""
if (
isinstance(x, DTensor)
and len(x._spec.placements) == 1
and _is_shard_like(x._spec.placements[0])
):
return x.redistribute(device_mesh=mesh, placements=[Replicate()])
elif (
not isinstance(x, DTensor)
and isinstance(x, torch.Tensor)
and x.numel() >= mesh.size()
):
return (
DTensor.from_local(
x, device_mesh=mesh, placements=[Shard(input_reshard_dim)]
)
.redistribute(device_mesh=mesh, placements=[Replicate()])
.to_local()
)
else:
return x
@@ -0,0 +1,514 @@
# mypy: allow-untyped-defs
# Copyright (c) Meta Platforms, Inc. and affiliates
import contextlib
from typing import cast
import torch
import torch._prims_common as utils
import torch.distributed._functional_collectives as funcol
import torch.distributed.distributed_c10d as c10d
from torch import Tensor
from torch.distributed.device_mesh import DeviceMesh
from torch.distributed.tensor import DTensor, Replicate, Shard
from torch.distributed.tensor._dtensor_spec import DTensorSpec, TensorMeta
from torch.distributed.tensor._ops._embedding_ops import _MaskPartial
from torch.distributed.tensor._ops._math_ops import (
_skip_dim,
Reduction,
replicate_reduction_dims,
)
from torch.distributed.tensor._ops.utils import normalize_dim
from torch.distributed.tensor.placement_types import Placement
aten = torch.ops.aten
__all__ = ["loss_parallel"]
@contextlib.contextmanager
def loss_parallel():
"""
A context manager that enables loss parallelism, where efficient parallelized loss computation
can be performed when the input is sharded on the class dimension. Currently only the cross-entropy
loss is supported.
Within this context manager, one can use :func:`~torch.nn.functional.cross_entropy` or
:class:`~torch.nn.CrossEntropyLoss` as usual, with the following assumptions on the input parameters.
The corresponding ``backward()`` call, if any, also needs to happen under this context manager.
Args:
input (:class:`DTensor`):
Input logits. Assumed to be sharded on the class dimension.
target (Union[:class:`torch.Tensor`, :class:`DTensor`]):
Must be ground truth class indices (class probabilities currently not supported).
Assumed to be replicated across the ``DeviceMesh``.
weight (Union[:class:`torch.Tensor`, :class:`DTensor`], optional):
If given, assumed to be replicated across the ``DeviceMesh``.
label_smoothing:
Currently not supported.
Returns:
A replicated :class:`DTensor`.
Example:
A sharded DTensor is manually created here to showcase the usage.
In practice, it is usually the output of a TP module.
>>> # xdoctest: +SKIP("distributed")
>>> from torch.distributed.tensor.parallel import loss_parallel
>>> from torch.distributed.device_mesh import init_device_mesh
>>> ...
>>> device_mesh = init_device_mesh("cuda", (8,))
>>> input = torch.randn(4, 16, device="cuda", requires_grad=True)
>>> dist_input = distribute_tensor(input, device_mesh, placements=[Shard(1)])
>>> target = torch.randint(16, (4,), device="cuda")
>>> with loss_parallel():
>>> loss = F.cross_entropy(dist_input, target, reduction="mean")
>>> loss.backward()
>>> ...
"""
_enable_custom_loss_ops()
yield
_disable_custom_loss_ops()
# Currently only needs to support one dimensional DeviceMesh; in general return
# the mesh_dim with placements[mesh_dim].is_shard(dim)
def _find_all_reduce_mesh_dim(placements: tuple[Placement, ...], dim: int) -> int:
if not len(placements) == 1:
raise ValueError(
"Currently loss_parallel() only supports input on one-dimensional DeviceMesh."
)
if not placements[0].is_shard(dim):
raise ValueError(
f"loss_parallel() should be enabled only when the input tensor is sharded on dimension {dim}."
)
return 0
def _cast_to_dtensor(
tensor, placements: tuple[Placement, ...], mesh: DeviceMesh
) -> DTensor:
if isinstance(tensor, DTensor):
if tensor.placements == placements:
return tensor
else:
raise RuntimeError(f"Expected {placements} but got {tensor.placements}.")
elif isinstance(tensor, torch.Tensor):
return DTensor.from_local(
tensor, device_mesh=mesh, placements=placements, run_check=False
)
else:
raise TypeError(f"Unsupported type {type(tensor)}")
def _propagate_tensor_meta(
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
) -> TensorMeta:
op_info = DTensor._op_dispatcher.unwrap_to_op_info(op_call, args, kwargs)
if op_info.schema is None:
raise AssertionError(
"op_info.schema should not be None after unwrap_to_op_info"
)
tensor_meta = DTensor._op_dispatcher.sharding_propagator._propagate_tensor_meta(
op_info.schema
)
if isinstance(tensor_meta, TensorMeta):
return tensor_meta
elif isinstance(tensor_meta, tuple):
# pyrefly: ignore [bad-return]
return tensor_meta[0]
else:
raise RuntimeError(f"Unexpected tensor meta type: {type(tensor_meta)}.")
# NOTE: The implementation follows torch._decomp.decomposition._log_softmax,
# with all_reduce manually inserted to perform distributed computation.
def _log_softmax(x, dim, half_to_float, mesh, mesh_dim):
if half_to_float:
if x.dtype != torch.half:
raise AssertionError
computation_dtype, result_dtype = utils.elementwise_dtypes(
x, type_promotion_kind=utils.ELEMENTWISE_TYPE_PROMOTION_KIND.DEFAULT
)
x = x.to(dtype=computation_dtype, memory_format=torch.contiguous_format)
if x.numel() == 0:
shifted = x
else:
x_max = torch.amax(x, dim, keepdim=True)
x_max = funcol.all_reduce(
x_max, reduceOp=c10d.ReduceOp.MAX.name, group=(mesh, mesh_dim)
)
shifted = x - x_max
shifted_sumexp = torch.sum(torch.exp(shifted), dim, keepdim=True)
shifted_sumexp = funcol.all_reduce(
shifted_sumexp, reduceOp=c10d.ReduceOp.SUM.name, group=(mesh, mesh_dim)
)
shifted_logsumexp = torch.log(shifted_sumexp)
result = shifted - shifted_logsumexp
if not half_to_float:
result = result.to(result_dtype)
return result
def _log_softmax_handler(
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
) -> object:
x = cast(DTensor, args[0])
dim = cast(int, args[1])
half_to_float = cast(bool, args[2])
spec = x._spec
dim = normalize_dim(dim, x.dim())
mesh_dim = _find_all_reduce_mesh_dim(spec.placements, dim)
output_tensor_meta = _propagate_tensor_meta(op_call, args, kwargs)
res = _log_softmax(x._local_tensor, dim, half_to_float, spec.mesh, mesh_dim)
res_spec = DTensorSpec(
spec.mesh,
spec.placements,
tensor_meta=output_tensor_meta,
)
# pyrefly: ignore [bad-argument-type]
return DTensor(
# pyrefly: ignore [bad-argument-count]
res,
res_spec,
# pyrefly: ignore [unexpected-keyword]
requires_grad=res.requires_grad,
)
# NOTE: As explained below at _nll_loss_and_log_softmax_backward, the
# _log_softmax_backward_handler does not actually do any computation.
def _log_softmax_backward_handler(
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
) -> object:
grad_output = cast(DTensor, args[0])
input_dtype = cast(torch.dtype, args[3])
return grad_output.to(input_dtype)
# NOTE: The implementation follows torch._decomp.decomposition._nll_loss_forward,
# with customized communication inserted to perform distributed computation.
def _nll_loss_forward(
x: Tensor,
target: Tensor,
weight: Tensor | None,
local_weight: Tensor | None,
reduction: int,
ignore_index: int,
input_shape: torch.Size,
channel_dim: int,
mesh: DeviceMesh,
mesh_dim: int,
) -> tuple[Tensor, Tensor]:
n_dims = x.dim()
channel_dim = 1
if n_dims < 2:
channel_dim = 0
def _weight_view(weight: Tensor) -> Tensor:
if n_dims > 1:
shape = [
1,
] * n_dims
shape[channel_dim] = weight.shape[0]
w = weight.view(shape)
else:
w = weight
return w
if weight is not None:
w = _weight_view(weight)
if local_weight is None:
raise AssertionError
local_w = _weight_view(local_weight)
x = x * local_w
safe_target = torch.where(target != ignore_index, target, 0)
safe_target_ = safe_target.unsqueeze(channel_dim)
# The following code block is a distributed version of
# result = -torch.gather(self, channel_dim, safe_target_).squeeze(channel_dim)
partial_placement = _MaskPartial(offset_shape=input_shape, offset_dim=channel_dim)
safe_target_partial_ = partial_placement._partition_value(
safe_target_, mesh, mesh_dim
)
result_partial = torch.gather(x, channel_dim, safe_target_partial_)
# an all_reduce happens here
result_reduced = partial_placement._reduce_value(result_partial, mesh, mesh_dim)
result = -result_reduced.squeeze(channel_dim)
result = torch.where(target != ignore_index, result, 0)
if reduction == Reduction.NONE.value and n_dims > 1:
total_weight = x.new_full((), 0.0)
return result, total_weight
if weight is not None:
new_shape = list(x.shape)
new_shape[channel_dim] = -1
# pyrefly: ignore [unbound-name]
w = w.expand(new_shape)
wsum = torch.gather(w, channel_dim, safe_target_).squeeze(channel_dim)
wsum = torch.where(target != ignore_index, wsum, 0)
total_weight = wsum.sum()
else:
total_weight = (target != ignore_index).sum().to(x)
# NOTE: this is correct only on 1D DeviceMesh; o/w additional
# all-reduce on result and total_weight is needed
if reduction == Reduction.SUM.value:
result = result.sum()
elif reduction == Reduction.MEAN.value:
result = result.sum() / total_weight
return result, total_weight
def _nll_loss_forward_handler(
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
) -> object:
x = cast(DTensor, args[0])
target = args[1]
weight = args[2]
reduction = cast(int, args[3])
ignore_index = cast(int, args[4])
channel_dim = 1 if x.dim() >= 2 else 0
spec = x._spec
mesh_dim = _find_all_reduce_mesh_dim(spec.placements, channel_dim)
# Check user input: if target and weight are not DTensors, convert them to DTensors;
# if they are DTensors, check that they have the desired placements.
target_placements = _skip_dim(
replicate_reduction_dims(spec.placements, [channel_dim]), channel_dim
)
all_replicate_placements = (Replicate(),) * spec.mesh.ndim
target = _cast_to_dtensor(target, target_placements, spec.mesh)
local_weight = None
if weight is not None:
weight = _cast_to_dtensor(weight, all_replicate_placements, spec.mesh)
# For local computation, both (replicated) weight and (sharded) local_weight
# are needed in _nll_loss_forward(). local_weight is generated here using
# DTensor API, without incurring any communication.
sharded_placements = [
Shard(0) if i == mesh_dim else Replicate() for i in range(spec.mesh.ndim)
]
local_weight = weight.redistribute(spec.mesh, sharded_placements)._local_tensor
if local_weight.shape[0] != x._local_tensor.shape[channel_dim]:
raise AssertionError
if reduction == Reduction.NONE.value:
output_placements = target_placements
else:
output_placements = all_replicate_placements
# tensor inputs to _propagate_tensor_meta need to be DTensors
# pyrefly: ignore [bad-assignment]
args = list(args)
# pyrefly: ignore [unsupported-operation]
args[1], args[2] = target, weight
output_tensor_meta = _propagate_tensor_meta(op_call, tuple(args), kwargs)
result, total_weight = _nll_loss_forward(
x._local_tensor,
target._local_tensor,
weight._local_tensor if weight is not None else None,
local_weight,
reduction,
ignore_index,
x.shape,
channel_dim,
spec.mesh,
mesh_dim,
)
out_spec = DTensorSpec(spec.mesh, output_placements, tensor_meta=output_tensor_meta)
return (
# pyrefly: ignore [bad-argument-type]
DTensor(
# pyrefly: ignore [bad-argument-count]
result,
out_spec,
# pyrefly: ignore [unexpected-keyword]
requires_grad=result.requires_grad,
),
total_weight,
)
# NOTE: The backward computation of cross_entropy goes through two steps:
# backward for nll_loss and then backward for log_softmax. In loss parallel,
# the two steps are fused into the following function (called by _nll_loss_backward_handler)
# to avoid communication when target contains class indices not class probabilities.
# Also note that the _log_softmax_backward_handler does not perform computation.
# The implementation resembles _nll_loss_backward and _log_softmax_backward_data
# from torch._decomp.decomposition.
def _nll_loss_and_log_softmax_backward(
grad_output: Tensor,
x: Tensor,
target: Tensor,
weight: Tensor | None,
reduction: int,
ignore_index: int,
total_weight: Tensor,
input_shape: torch.Size,
channel_dim: int,
mesh: DeviceMesh,
mesh_dim: int,
) -> Tensor:
channel_dim = 0 if x.dim() < 2 else 1
if reduction == Reduction.MEAN.value:
grad_output = grad_output / total_weight
target = target.unsqueeze(channel_dim)
safe_target = torch.where(target != ignore_index, target, 0)
grad_input = torch.zeros_like(x)
# The following code block is a distributed version of
# grad_input = torch.scatter(grad_input, channel_dim, safe_target, -1.0)
partial_placement = _MaskPartial(offset_shape=input_shape, offset_dim=channel_dim)
safe_target = safe_target.squeeze(channel_dim).flatten()
masked_safe_target = partial_placement._partition_value(safe_target, mesh, mesh_dim)
# only update grad_input to -1 if not masked
if partial_placement.mask_buffer.data is None:
raise AssertionError
grad_update = partial_placement.mask_buffer.data.to(grad_input.dtype) - 1.0
arange_1d = torch.arange(
masked_safe_target.shape[0], device=masked_safe_target.device
)
# The first two cases with x.dim() <= 2 are for aten.nll_loss_backward.default;
# the last case is for aten.nll_loss2d_backward.default.
if x.dim() == 1:
grad_input[masked_safe_target] = grad_update
elif x.dim() == 2:
grad_input[arange_1d, masked_safe_target] = grad_update
else:
grad_input_t = grad_input.transpose(channel_dim, -1)
intermidate_shape = grad_input_t.shape
grad_input_2d = grad_input_t.reshape(-1, x.shape[channel_dim])
grad_input_2d[arange_1d, masked_safe_target] = grad_update
grad_input = grad_input_2d.view(intermidate_shape).transpose(channel_dim, -1)
if grad_input.dim() > grad_output.dim() > 0:
grad_output = grad_output.unsqueeze(channel_dim)
if weight is not None:
new_shape = [1 for _ in range(x.dim())]
new_shape[channel_dim] = weight.shape[0]
weight = weight.reshape(new_shape)
# In order for fused computation to work, the following line is rewritten.
# grad_output = grad_output * weight
new_shape = list(x.shape)
new_shape[channel_dim] = -1
w = weight.expand(new_shape)
w_target = torch.gather(w, channel_dim, target)
grad_output = grad_output * w_target
grad_output = torch.where(target != ignore_index, grad_output, 0)
# NOTE: Instead of directly returning the grad_input as grad_output for log_softmax,
# here we perform backward computation for log_softmax altogether to avoid the
# otherwise extra all_gather communication.
# return grad_input * grad_output
return (grad_input + torch.exp(x)) * grad_output
def _nll_loss_backward_handler(
op_call: torch._ops.OpOverload,
args: tuple[object, ...],
kwargs: dict[str, object],
) -> object:
grad_output = cast(DTensor, args[0])
x = cast(DTensor, args[1])
target = args[2]
weight = args[3]
reduction = cast(int, args[4])
ignore_index = cast(int, args[5])
total_weight = cast(Tensor, args[6])
channel_dim = 1 if x.dim() >= 2 else 0
spec = x._spec
mesh_dim = _find_all_reduce_mesh_dim(spec.placements, channel_dim)
# if target and weight are not DTensors, convert them to DTensors
target_placements = _skip_dim(
replicate_reduction_dims(spec.placements, [channel_dim]), channel_dim
)
all_replicate_placements = (Replicate(),) * spec.mesh.ndim
target = _cast_to_dtensor(target, target_placements, spec.mesh)
if weight is not None:
weight = _cast_to_dtensor(weight, all_replicate_placements, spec.mesh)
# tensor inputs to _propagate_tensor_meta need to be DTensors
# pyrefly: ignore [bad-assignment]
args = list(args)
# pyrefly: ignore [unsupported-operation]
args[2], args[3] = target, weight
# pyrefly: ignore [unsupported-operation]
args[6] = _cast_to_dtensor(total_weight, all_replicate_placements, spec.mesh)
output_tensor_meta = _propagate_tensor_meta(op_call, tuple(args), kwargs)
result = _nll_loss_and_log_softmax_backward(
grad_output._local_tensor,
x._local_tensor,
target._local_tensor,
weight._local_tensor if weight is not None else None,
reduction,
ignore_index,
total_weight,
x.shape,
channel_dim,
spec.mesh,
mesh_dim,
)
# the output sharding is the same as input sharding: Shard(channel_dim) on mesh_dim
out_spec = DTensorSpec(
spec.mesh,
spec.placements,
tensor_meta=output_tensor_meta,
)
# pyrefly: ignore [bad-argument-type]
return DTensor(
# pyrefly: ignore [bad-argument-count]
result,
out_spec,
# pyrefly: ignore [unexpected-keyword]
requires_grad=result.requires_grad,
)
customized_loss_ops = {
aten._log_softmax.default: _log_softmax_handler,
aten._log_softmax_backward_data.default: _log_softmax_backward_handler,
aten.nll_loss_forward.default: _nll_loss_forward_handler,
aten.nll_loss2d_forward.default: _nll_loss_forward_handler,
aten.nll_loss_backward.default: _nll_loss_backward_handler,
aten.nll_loss2d_backward.default: _nll_loss_backward_handler,
}
def _enable_custom_loss_ops():
DTensor._op_dispatcher._custom_op_handlers.update(customized_loss_ops)
def _disable_custom_loss_ops():
for custom_op in customized_loss_ops:
DTensor._op_dispatcher._custom_op_handlers.pop(custom_op)
@@ -0,0 +1,823 @@
# mypy: allow-untyped-defs
# Copyright (c) Meta Platforms, Inc. and affiliates
from abc import ABC, abstractmethod
from functools import partial
from typing import Any
import torch
import torch.nn as nn
from torch.distributed.tensor import (
DeviceMesh,
distribute_module,
distribute_tensor,
DTensor,
Replicate,
Shard,
)
from torch.distributed.tensor.placement_types import Placement
__all__ = [
"ParallelStyle",
"RowwiseParallel",
"SequenceParallel",
"ColwiseParallel",
"PrepareModuleInput",
"PrepareModuleInputOutput",
"PrepareModuleOutput",
]
class ParallelStyle(ABC):
"""
The parallel style contract defines how the module or submodule should be parallelized.
It only defines the ``apply`` method for ``parallelize_module`` to use, this allows maximum
flexibility for different kind of style implementations.
"""
src_data_rank: int | None = 0
@abstractmethod
def _apply(self, module: nn.Module, device_mesh: DeviceMesh) -> nn.Module: ...
class ColwiseParallel(ParallelStyle):
"""
Partition a compatible nn.Module in a column-wise fashion. Currently supports nn.Linear and nn.Embedding.
Users can compose it together with RowwiseParallel to achieve the sharding of more complicated modules.
(i.e. MLP, Attention)
Keyword Args:
input_layouts (Placement, optional):
The DTensor layout of input tensor for the nn.Module, this is used to annotate the input tensor to
become a DTensor. If not specified, we assume the input tensor to be replicated.
output_layouts (Placement, optional):
The DTensor layout of the output for the nn.Module, this is used to ensure the output of the nn.Module
with the user desired layout. If not specified, the output tensor is sharded on the last dimension.
use_local_output (bool, optional):
Whether to use local :class:`torch.Tensor` instead of :class:`DTensor` for the module output, default: True.
Returns:
A :class:`ParallelStyle` object that represents Colwise sharding of the nn.Module.
Example::
>>> # xdoctest: +SKIP(failing)
>>> from torch.distributed.tensor.parallel import parallelize_module, ColwiseParallel
>>> from torch.distributed.device_mesh import init_device_mesh
>>> ...
>>> m = Model(...) # m is a nn.Module that contains a "w1" nn.Linear submodule
>>> tp_mesh = init_device_mesh("cuda", (8,))
>>>
>>> # By default, the input of the "w1" Linear will be converted to Replicated DTensor
>>> # and the output of "w1" will return :class:`torch.Tensor` that shards on the last dim.
>>>
>>> sharded_mod = parallelize_module(m, tp_mesh, {"w1": ColwiseParallel()})
>>> ...
.. note:: By default ``ColwiseParallel`` output is sharded on the last dimension if the ``output_layouts`` not
specified, if there're operators that require specific tensor shape (i.e. before the paired ``RowwiseParallel``),
keep in mind that if the output is sharded the operator might need to be adjusted to the sharded size.
"""
def __init__(
self,
*,
input_layouts: Placement | None = None,
output_layouts: Placement | None = None,
use_local_output: bool = True,
):
super().__init__()
self.input_layouts = (input_layouts or Replicate(),)
self.output_layouts = (output_layouts or Shard(-1),)
# colwise linear runtime sharding (desired sharding):
# 1. requires replicate input
# 2. shard output on last dim
self.desired_input_layouts = (Replicate(),)
self.use_local_output = use_local_output
@staticmethod
def _prepare_input_fn(
input_layouts, desired_input_layouts, mod, inputs, device_mesh
):
# TODO: figure out dynamo support for instance method and switch this to instance method
# annotate module input placements/sharding with input_layouts
input_tensor = inputs[0]
if not isinstance(input_tensor, DTensor):
input_tensor = DTensor.from_local(
input_tensor,
device_mesh,
input_layouts,
run_check=False,
)
# transform the input layouts to the desired layouts of ColwiseParallel
if input_layouts != desired_input_layouts:
input_tensor = input_tensor.redistribute(
placements=desired_input_layouts, async_op=True
)
return input_tensor
def _partition_linear_fn(self, name, module, device_mesh):
# colwise shard weight/bias to Shard(0), weight be Shard(0)
# means Colwise as Linear is input * weight^T + bias, where
# weight would become Shard(1)
for name, param in module.named_parameters():
dist_param = nn.Parameter(
distribute_tensor(
param, device_mesh, [Shard(0)], src_data_rank=self.src_data_rank
),
requires_grad=param.requires_grad,
)
module.register_parameter(name, dist_param)
def _partition_embedding_fn(self, name, module, device_mesh):
# colwise shard embedding.weight is straight forward as Shard(1)
for name, param in module.named_parameters():
dist_param = nn.Parameter(
distribute_tensor(
param, device_mesh, [Shard(1)], src_data_rank=self.src_data_rank
),
requires_grad=param.requires_grad,
)
module.register_parameter(name, dist_param)
@staticmethod
def _prepare_output_fn(output_layouts, use_local_output, mod, outputs, device_mesh):
# outputs is a shard on last dimension DTensor, i.e. Shard(-1)
if outputs.placements != output_layouts:
outputs = outputs.redistribute(placements=output_layouts, async_op=True)
# back to local tensor
return outputs.to_local() if use_local_output else outputs
def _apply(self, module: nn.Module, device_mesh: DeviceMesh) -> nn.Module:
if isinstance(module, nn.Linear):
partition_fn = self._partition_linear_fn
elif isinstance(module, nn.Embedding):
partition_fn = self._partition_embedding_fn
else:
raise NotImplementedError(
"ColwiseParallel currently only support nn.Linear and nn.Embedding!"
)
return distribute_module(
module,
device_mesh,
partition_fn,
partial(
self._prepare_input_fn,
self.input_layouts,
self.desired_input_layouts,
),
partial(
self._prepare_output_fn, self.output_layouts, self.use_local_output
),
)
def __repr__(self) -> str:
tmpstr = self.__class__.__name__ + "("
tmpstr += f"input_layouts={self.input_layouts}, "
tmpstr += f"output_layouts={self.output_layouts}, "
tmpstr += f"use_local_output={self.use_local_output}"
tmpstr += ")"
return tmpstr
class RowwiseParallel(ParallelStyle):
"""
Partition a compatible nn.Module in a row-wise fashion. Currently supports nn.Linear and nn.Embedding.
Users can compose it with ColwiseParallel to achieve the sharding of more complicated modules.
(i.e. MLP, Attention)
Keyword Args:
input_layouts (Placement, optional):
The DTensor layout of input tensor for the nn.Module, this is used to annotate the input tensor to
become a DTensor. If not specified, we assume the input tensor to be sharded on the last dimension.
output_layouts (Placement, optional):
The DTensor layout of the output for the nn.Module, this is used to ensure the output of the nn.Module
with the user desired layout. If not specified, the output tensor is replicated.
use_local_output (bool, optional):
Whether to use local :class:`torch.Tensor` instead of :class:`DTensor` for the module output, default: True.
Returns:
A :class:`ParallelStyle` object that represents Rowwise sharding of the nn.Module.
Example::
>>> # xdoctest: +SKIP(failing)
>>> from torch.distributed.tensor.parallel import parallelize_module, RowwiseParallel
>>> from torch.distributed.device_mesh import init_device_mesh
>>> ...
>>> m = Model(...) # m is a nn.Module that contains a "w2" nn.Linear submodule
>>> tp_mesh = init_device_mesh("cuda", (8,))
>>>
>>> # By default, the input of the "w2" Linear will be converted to DTensor that shards on the last dim
>>> # and the output of "w2" will return a replicated :class:`torch.Tensor`.
>>>
>>> sharded_mod = parallelize_module(m, tp_mesh, {"w2": RowwiseParallel()}),
>>> ...
"""
def __init__(
self,
*,
input_layouts: Placement | None = None,
output_layouts: Placement | None = None,
use_local_output: bool = True,
):
super().__init__()
self.input_layouts = (input_layouts or Shard(-1),)
self.output_layouts = (output_layouts or Replicate(),)
self.use_local_output = use_local_output
@staticmethod
def _prepare_input_fn(
input_layouts, desired_input_layouts, mod, inputs, device_mesh
):
input_tensor = inputs[0]
if not isinstance(input_tensor, DTensor):
input_tensor = DTensor.from_local(
input_tensor,
device_mesh,
input_layouts,
run_check=False,
)
if input_layouts != desired_input_layouts:
input_tensor = input_tensor.redistribute(
placements=desired_input_layouts, async_op=True
)
return input_tensor
def _partition_linear_fn(self, name, module, device_mesh):
# Rowwise shard weight to Shard(1), bias to Replicate(), weight be Shard(1)
# means Rowwise as nn.Linear is input * weight^T + bias, where
# weight would become Shard(0)
module.register_parameter(
"weight",
nn.Parameter(
distribute_tensor(
module.weight,
device_mesh,
[Shard(1)],
src_data_rank=self.src_data_rank,
),
requires_grad=module.weight.requires_grad,
),
)
if getattr(module, "bias", None) is not None:
# The Linear module has bias
module.register_parameter(
"bias",
nn.Parameter(
distribute_tensor(
module.bias,
device_mesh,
[Replicate()],
src_data_rank=self.src_data_rank,
),
requires_grad=module.bias.requires_grad,
),
)
def _partition_embedding_fn(self, name, module, device_mesh):
# rowwise shard embedding.weight is Shard(0)
for name, param in module.named_parameters():
dist_param = nn.Parameter(
distribute_tensor(
param, device_mesh, [Shard(0)], src_data_rank=self.src_data_rank
),
requires_grad=param.requires_grad,
)
module.register_parameter(name, dist_param)
@staticmethod
def _prepare_output_fn(output_layouts, use_local_output, mod, outputs, device_mesh):
# Rowwise sharding produces partial output, depending on output layouts:
# 1. to replicate -> allreduce
# 2. to shard -> reduce_scatter
if outputs.placements != output_layouts:
outputs = outputs.redistribute(placements=output_layouts, async_op=True)
# back to local tensor if use_local_output is True
return outputs.to_local() if use_local_output else outputs
def _apply(self, module: nn.Module, device_mesh: DeviceMesh) -> nn.Module:
if isinstance(module, nn.Linear):
partition_fn = self._partition_linear_fn
# rowwise linear runtime sharding requires input tensor shard on last dim
self.desired_input_layouts: tuple[Placement, ...] = (Shard(-1),)
elif isinstance(module, nn.Embedding):
partition_fn = self._partition_embedding_fn
# rowwise embedding runtime sharding requires input tensor replicated
self.desired_input_layouts = (Replicate(),)
else:
raise NotImplementedError(
"RowwiseParallel currently only support nn.Linear and nn.Embedding!"
)
return distribute_module(
module,
device_mesh,
partition_fn,
partial(
self._prepare_input_fn,
self.input_layouts,
self.desired_input_layouts,
),
partial(
self._prepare_output_fn, self.output_layouts, self.use_local_output
),
)
def __repr__(self) -> str:
tmpstr = self.__class__.__name__ + "("
tmpstr += f"input_layouts={self.input_layouts}, "
tmpstr += f"output_layouts={self.output_layouts}, "
tmpstr += f"use_local_output={self.use_local_output}"
tmpstr += ")"
return tmpstr
class SequenceParallel(ParallelStyle):
"""
SequenceParallel replicates a compatible ``nn.Module`` parameters and runs the sharded computation with
input sharded on the sequence dimension. This currently supports ``nn.LayerNorm``, ``nn.Dropout``, and the
`RMSNorm python implementation <https://github.com/facebookresearch/llama/blob/main/llama/model.py#L34>`__
This style implements the operation that is described in the paper
`Reducing Activation Recomputation in Large Transformer Models <https://arxiv.org/abs/2205.05198>`__
If the input passed in to this ``nn.Module`` is a :class:`torch.Tensor`, it assumes that the input is already sharded
on the sequence dimension and converts the input to a :class:`DTensor` sharded on the sequence dimension. If the input
passed in to this ``nn.Module`` is already a :class:`DTensor` but is not sharded on the sequence dimension, it would
redistribute the input to be sharded on the sequence dimension.
The output of the ``nn.Module`` will be sharded on the sequence dimension.
Keyword Args:
sequence_dim (int, optional):
The sequence dimension of the input tensor for the ``nn.Module``, this is used to annotate the input tensor to
become a DTensor that is sharded on the sequence dimension, default: 1.
use_local_output (bool, optional):
Whether to use local :class:`torch.Tensor` instead of :class:`DTensor` for the module output, default: False.
Returns:
A :class:`ParallelStyle` object that represents Sequence Parallel of the ``nn.Module``.
Example::
>>> # xdoctest: +SKIP(failing)
>>> from torch.distributed.tensor.parallel import parallelize_module, SequenceParallel
>>> from torch.distributed.device_mesh import init_device_mesh
>>> ...
>>> m = Model(...) # m is a nn.Module that contains a "norm" nn.LayerNorm submodule
>>> tp_mesh = init_device_mesh("cuda", (8,))
>>>
>>> # By default, the input of the "norm" will be converted to DTensor that shards on the sequence dim
>>> # and the output of "norm" will return a sharded on sequence dimension :class:`DTensor`.
>>>
>>> sharded_mod = parallelize_module(m, tp_mesh, {"norm": SequenceParallel()}),
>>> ...
.. note:: SequenceParallel style assumes ones initialization if there are weights in the nn.Module (i.e.
``nn.LayerNorm`` or ``RMSNorm``, and they by default have ones initialization). If you have custom
inits for the weights on those modules, you need to broadcast the weights before/after parallelizing
to ensure that they are replicated.
"""
def __init__(self, *, sequence_dim: int = 1, use_local_output: bool = False):
super().__init__()
self.sequence_sharding = (Shard(sequence_dim),)
self.use_local_output = use_local_output
def _replicate_module_fn(
self, name: str, module: nn.Module, device_mesh: DeviceMesh
):
for p_name, param in module.named_parameters():
# simple replication with fixed ones_ init from LayerNorm/RMSNorm, which allow
# us to simply just use from_local
replicated_param = torch.nn.Parameter(
DTensor.from_local(param, device_mesh, [Replicate()], run_check=False)
)
module.register_parameter(p_name, replicated_param)
@staticmethod
def _prepare_input_fn(sequence_sharding, mod, inputs, device_mesh):
input_tensor = inputs[0]
if isinstance(input_tensor, DTensor):
# if the passed in input DTensor is not sharded on the sequence dim, we need to redistribute it
if input_tensor.placements != sequence_sharding:
input_tensor = input_tensor.redistribute(
placements=sequence_sharding, async_op=True
)
return input_tensor
elif isinstance(input_tensor, torch.Tensor):
# assume the input passed in already sharded on the sequence dim and create the DTensor
return DTensor.from_local(
input_tensor, device_mesh, sequence_sharding, run_check=False
)
else:
raise ValueError(
f"expecting input of {mod} to be a torch.Tensor or DTensor, but got {input_tensor}"
)
@staticmethod
def _prepare_output_fn(use_local_output, mod, outputs, device_mesh):
return outputs.to_local() if use_local_output else outputs
def _apply(self, module: nn.Module, device_mesh: DeviceMesh) -> nn.Module:
return distribute_module(
module,
device_mesh,
self._replicate_module_fn,
partial(self._prepare_input_fn, self.sequence_sharding),
partial(self._prepare_output_fn, self.use_local_output),
)
def __repr__(self) -> str:
tmpstr = self.__class__.__name__ + "("
if len(self.sequence_sharding) == 1:
tmpstr += f"sequence_dim={self.sequence_sharding[0].dim}, "
tmpstr += f"use_local_output={self.use_local_output}"
tmpstr += ")"
return tmpstr
class PrepareModuleInput(ParallelStyle):
"""
Configure the nn.Module's inputs to convert the input tensors of the nn.Module to DTensors at runtime according to
``input_layouts``, and perform layout redistribution according to the ``desired_input_layouts``.
Keyword Args:
input_layouts (Union[Placement, Tuple[Optional[Placement]]]):
The DTensor layouts of input tensors for the nn.Module, this is used to convert the input tensors to
DTensors. If some inputs are not torch.Tensor or no need to convert to DTensors, ``None`` need to be specified
as a placeholder. default: None.
desired_input_layouts (Union[Placement, Tuple[Optional[Placement]]]):
The desired DTensor layout of input tensors for the nn.Module, this is used to ensure the inputs of the nn.Module
have the desired DTensor layouts. This argument needs to have the same length with ``input_layouts``. default: None.
input_kwarg_layouts (Dict[str, Placement]):
The DTensor layouts of input kwargs for the nn.Module, this is used to convert the input kwarg tensors to DTensors.
default: None
desired_input_kwarg_layouts: (Dict[str, Placement]):
The desired DTensor layout of input kwargs for the nn.Module, this is used to ensure the inputs of the nn.Module
have the desired DTensor layouts. default: None.
use_local_output (bool, optional):
Whether to use local :class:`torch.Tensor` instead of :class:`DTensor` for the module inputs, default: False.
Returns:
A :class:`ParallelStyle` object that prepares the sharding layouts of the nn.Module's inputs.
Example::
>>> # xdoctest: +SKIP(failing)
>>> from torch.distributed.tensor.parallel import parallelize_module, PrepareModuleInput
>>> from torch.distributed.device_mesh import init_device_mesh
>>> ...
>>> block = TransformerBlock(...) # block is a nn.Module that contains an "attn" Attention submodule
>>> tp_mesh = init_device_mesh("cuda", (8,))
>>>
>>> # According to the style specified below, the first input of attn will be annotated to Sharded DTensor
>>> # and then redistributed to Replicated DTensor.
>>> parallelize_module(
>>> block, # this can be a submodule or module
>>> tp_mesh,
>>> parallelize_plan={
>>> "attn": PrepareModuleInput(
>>> input_layouts=(Shard(0), None, None, ...),
>>> desired_input_layouts=(Replicate(), None, None, ...)
>>> ),
>>> }
>>> )
"""
def __init__(
self,
*,
input_layouts: Placement | tuple[Placement | None, ...] | None = None,
desired_input_layouts: Placement | tuple[Placement | None, ...] | None = None,
input_kwarg_layouts: dict[str, Placement] | None = None,
desired_input_kwarg_layouts: dict[str, Placement] | None = None,
use_local_output: bool = False,
):
self.input_layouts = (
(input_layouts,) if isinstance(input_layouts, Placement) else input_layouts
)
self.desired_input_layouts = (
(desired_input_layouts,)
if isinstance(desired_input_layouts, Placement)
else desired_input_layouts
)
self.use_local_output = use_local_output
if self.input_layouts is not None:
if self.desired_input_layouts is None:
raise AssertionError("desired module inputs should not be None!")
if len(self.input_layouts) != len(self.desired_input_layouts):
raise AssertionError(
"input_layouts and desired_input_layouts should have same length!"
)
self.with_kwargs = input_kwarg_layouts is not None
self.input_kwarg_layouts = input_kwarg_layouts or {}
self.desired_input_kwarg_layouts = desired_input_kwarg_layouts or {}
if self.with_kwargs:
if len(self.input_kwarg_layouts) != len(self.desired_input_kwarg_layouts):
raise AssertionError(
"input_kwarg_layouts and desired_input_kwarg_layouts should have same length!"
)
def _prepare_input_arg(
self,
input: Any,
mesh: DeviceMesh,
input_layout: Placement | None,
desired_layout: Placement | None,
):
if input_layout is not None:
if isinstance(input, DTensor):
# TODO: re-enable the check once we fix the compile path
# assert inp.placements[0] == input_layout
dt_inp = input
else:
if not isinstance(input, torch.Tensor):
raise AssertionError("expecting input to be a torch.Tensor!")
dt_inp = DTensor.from_local(
input, mesh, (input_layout,), run_check=False
)
if desired_layout is not None and input_layout != desired_layout:
dt_inp = dt_inp.redistribute(placements=(desired_layout,))
return dt_inp.to_local() if self.use_local_output else dt_inp
else:
return input
def _prepare_input_fn(self, inputs, device_mesh):
if self.input_layouts is None:
return inputs
prepared_inputs = []
if not isinstance(inputs, tuple):
inputs = (inputs,)
if len(inputs) != len(self.input_layouts):
raise ValueError("module inputs and input_layouts should have same length!")
if self.desired_input_layouts is None:
raise AssertionError("desired module inputs should not be None!")
for inp, input_layout, desired_layout in zip(
inputs, self.input_layouts, self.desired_input_layouts
):
prepared_inputs.append(
self._prepare_input_arg(inp, device_mesh, input_layout, desired_layout)
)
return tuple(prepared_inputs)
def _prepare_input_kwarg_fn(self, inputs, kwarg_inputs, device_mesh):
prepared_arg_inputs = self._prepare_input_fn(inputs, device_mesh)
prepared_kwarg_inputs = {}
for kwarg_key in kwarg_inputs:
kwarg_val = kwarg_inputs[kwarg_key]
input_layout = self.input_kwarg_layouts.get(kwarg_key)
desired_input_layout = self.desired_input_kwarg_layouts.get(kwarg_key)
prepared_kwarg_inputs[kwarg_key] = self._prepare_input_arg(
kwarg_val, device_mesh, input_layout, desired_input_layout
)
return (prepared_arg_inputs, prepared_kwarg_inputs)
def _apply(self, module: nn.Module, device_mesh: DeviceMesh) -> nn.Module:
if self.with_kwargs:
module.register_forward_pre_hook(
lambda _, inputs, kwargs: self._prepare_input_kwarg_fn(
inputs, kwargs, device_mesh
),
with_kwargs=True,
) # type: ignore[misc]
else:
module.register_forward_pre_hook(
lambda _, inputs: self._prepare_input_fn(inputs, device_mesh)
) # type: ignore[misc, call-arg]
return module
def __repr__(self) -> str:
tmpstr = self.__class__.__name__ + "("
tmpstr += f"input_layouts={self.input_layouts}, "
tmpstr += f"desired_input_layouts={self.desired_input_layouts}, "
tmpstr += f"input_kwarg_layouts={self.input_kwarg_layouts}, "
tmpstr += f"desired_input_kwarg_layouts={self.desired_input_kwarg_layouts}, "
tmpstr += f"use_local_output={self.use_local_output}"
tmpstr += ")"
return tmpstr
class PrepareModuleOutput(ParallelStyle):
"""
Configure the nn.Module's outputs to convert the output tensors of the nn.Module to DTensors at runtime according to
``output_layouts``, and perform layout redistribution according to the ``desired_output_layouts``.
Keyword Args:
output_layouts (Union[Placement, Tuple[Placement]]):
The DTensor layouts of output tensors for the nn.Module, this is used to convert the output tensors to
DTensors if they are :class:`torch.Tensor`. If some outputs are not torch.Tensor or no need to convert to DTensors,
``None`` need to be specified as a placeholder.
desired_output_layouts (Union[Placement, Tuple[Placement]]):
The desired DTensor layouts of output tensors for the nn.Module, this is used to ensure the outputs of the nn.Module
have the desired DTensor layouts.
use_local_output (bool, optional):
Whether to use local :class:`torch.Tensor` instead of :class:`DTensor` for the module outputs, default: True.
Returns:
A ParallelStyle object that prepares the sharding layouts of the nn.Module's outputs.
Example::
>>> # xdoctest: +SKIP(failing)
>>> from torch.distributed.tensor.parallel import parallelize_module, PrepareModuleOutput
>>> from torch.distributed.device_mesh import init_device_mesh
>>> ...
>>> block = TransformerBlock(...) # block is a nn.Module that contains an "attn" Attention submodule
>>> tp_mesh = init_device_mesh("cuda", (8,))
>>>
>>> # According to the style specified below, the output of the TransformerBlock will be converted to Replicated DTensor
>>> # and then redistributed to Sharded DTensor.
>>> parallelize_module(
>>> block, # this can be a submodule or module
>>> tp_mesh,
>>> parallelize_plan = PrepareModuleOutput(
>>> output_layouts=Replicate(),
>>> desired_output_layouts=Shard(0)
>>> )
>>> )
"""
def __init__(
self,
*,
output_layouts: Placement | tuple[Placement | None, ...],
desired_output_layouts: Placement | tuple[Placement, ...],
use_local_output: bool = True,
):
self.output_layouts = (
(output_layouts,)
if isinstance(output_layouts, Placement)
else output_layouts
)
self.desired_output_layouts = (
(desired_output_layouts,)
if isinstance(desired_output_layouts, Placement)
else desired_output_layouts
)
self.use_local_output = use_local_output
if len(self.output_layouts) != len(self.desired_output_layouts):
raise AssertionError(
"output_layouts and desired_output_layouts should have same length!"
)
def _prepare_out_fn(self, outputs, device_mesh):
prepared_outputs = []
if not isinstance(outputs, tuple):
outputs = (outputs,)
if len(outputs) != len(self.output_layouts):
raise ValueError(
"module outputs and output_layouts should have same length!"
)
for out, out_layout, desired_out_layout in zip(
outputs, self.output_layouts, self.desired_output_layouts
):
if out_layout is not None:
if isinstance(out, DTensor):
# TODO: re-enable the check once we fix the compile path
# assert out.placements[0] == out_layout
dt_out = out
else:
dt_out = DTensor.from_local(
out, device_mesh, (out_layout,), run_check=False
)
if out_layout != desired_out_layout:
dt_out = dt_out.redistribute(placements=(desired_out_layout,))
prepared_outputs.append(
dt_out.to_local() if self.use_local_output else dt_out
)
else:
prepared_outputs.append(out)
if len(prepared_outputs) == 1:
return prepared_outputs[0]
else:
return tuple(prepared_outputs)
def _apply(self, module: nn.Module, device_mesh: DeviceMesh) -> nn.Module:
module.register_forward_hook(
lambda _, inputs, outputs: self._prepare_out_fn(outputs, device_mesh)
) # type: ignore[misc, call-arg]
return module
def __repr__(self) -> str:
tmpstr = self.__class__.__name__ + "("
tmpstr += f"output_layouts={self.output_layouts}, "
tmpstr += f"desired_output_layouts={self.desired_output_layouts}, "
tmpstr += f"use_local_output={self.use_local_output}"
tmpstr += ")"
return tmpstr
class PrepareModuleInputOutput(ParallelStyle):
"""
Configure the nn.Module's inputs (and outputs) to convert the input tensors (and output tensors, respectively) of the nn.Module
to DTensors at runtime according to ``input_layouts`` (and output_layouts, respectively), and perform layout redistribution
according to the ``desired_input_layouts`` (and ``desired_output_layouts``, respectively). This is a combination of
:class:`PrepareModuleInput` and :class:`PrepareModuleOutput`.
Keyword Args:
input_layouts (Union[Placement, Tuple[Optional[Placement]]]):
The DTensor layouts of input tensors for the nn.Module, this is used to convert the input tensors to
DTensors. If some inputs are not torch.Tensor or no need to convert to DTensors, ``None`` need to be specified
as a placeholder. default: None.
desired_input_layouts (Union[Placement, Tuple[Optional[Placement]]]):
The desired DTensor layout of input tensors for the nn.Module, this is used to ensure the inputs of the nn.Module
have the desired DTensor layouts. This argument needs to have the same length with ``input_layouts``. default: None.
input_kwarg_layouts (Dict[str, Placement]):
The DTensor layouts of input kwargs for the nn.Module, this is used to convert the input kwarg tensors to DTensors.
default: None
desired_input_kwarg_layouts: (Dict[str, Placement]):
The desired DTensor layout of input kwargs for the nn.Module, this is used to ensure the inputs of the nn.Module
have the desired DTensor layouts. default: None.
use_local_input (bool, optional):
Whether to use local :class:`torch.Tensor` instead of :class:`DTensor` for the module inputs, default: False.
output_layouts (Union[Placement, Tuple[Placement]]):
The DTensor layouts of output tensors for the nn.Module, this is used to convert the output tensors to
DTensors if they are :class:`torch.Tensor`. If some outputs are not torch.Tensor or no need to convert to DTensors,
``None`` need to be specified as a placeholder.
desired_output_layouts (Union[Placement, Tuple[Placement]]):
The desired DTensor layouts of output tensors for the nn.Module, this is used to ensure the outputs of the nn.Module
have the desired DTensor layouts.
use_local_output (bool, optional):
Whether to use local :class:`torch.Tensor` instead of :class:`DTensor` for the module outputs, default: True.
Returns:
A :class:`ParallelStyle` object that prepares the sharding layouts of the nn.Module's inputs and outputs.
Example::
>>> # xdoctest: +SKIP(failing)
>>> from torch.distributed.tensor.parallel import parallelize_module, PrepareModuleInputOutput
>>> from torch.distributed.device_mesh import init_device_mesh
>>> ...
>>> block = TransformerBlock(...) # block is a nn.Module that contains an "attn" Attention submodule
>>> tp_mesh = init_device_mesh("cuda", (8,))
>>>
>>> # According to the style specified below, the first input of attn will be annotated as Sharded DTensor
>>> # and then redistributed to Replicated DTensor, and the output of the TransformerBlock will be annotated
>>> # as Replicated DTensor and then redistributed to Sharded DTensor.
>>> parallelize_module(
>>> block, # this can be a submodule or module
>>> tp_mesh,
>>> parallelize_plan={
>>> "attn": PrepareModuleInputOutput(
>>> input_layouts=(Shard(0), None, None, ...),
>>> desired_input_layouts=(Replicate(), None, None, ...),
>>> output_layouts=Replicate(),
>>> desired_output_layouts=Shard(0),
>>> ),
>>> }
>>> )
"""
def __init__(
self,
*,
input_layouts: Placement | tuple[Placement | None, ...] | None = None,
desired_input_layouts: Placement | tuple[Placement | None, ...] | None = None,
input_kwarg_layouts: dict[str, Placement] | None = None,
desired_input_kwarg_layouts: dict[str, Placement] | None = None,
use_local_input: bool = False,
output_layouts: Placement | tuple[Placement | None, ...],
desired_output_layouts: Placement | tuple[Placement, ...],
use_local_output: bool = True,
):
self.prepare_module_input = PrepareModuleInput(
input_layouts=input_layouts,
desired_input_layouts=desired_input_layouts,
input_kwarg_layouts=input_kwarg_layouts,
desired_input_kwarg_layouts=desired_input_kwarg_layouts,
use_local_output=use_local_input,
)
self.prepare_module_output = PrepareModuleOutput(
output_layouts=output_layouts,
desired_output_layouts=desired_output_layouts,
use_local_output=use_local_output,
)
def _apply(self, module: nn.Module, device_mesh: DeviceMesh) -> nn.Module:
self.prepare_module_input._apply(module, device_mesh)
self.prepare_module_output._apply(module, device_mesh)
return module
def __repr__(self) -> str:
tmpstr = self.__class__.__name__ + "("
tmpstr += f"input_layouts={self.prepare_module_input.input_layouts}, "
tmpstr += (
f"desired_input_layouts={self.prepare_module_input.desired_input_layouts}, "
)
tmpstr += (
f"input_kwarg_layouts={self.prepare_module_input.input_kwarg_layouts}, "
)
tmpstr += f"desired_input_kwarg_layouts={self.prepare_module_input.desired_input_kwarg_layouts}, "
tmpstr += f"use_local_input={self.prepare_module_input.use_local_output}, "
tmpstr += f"output_layouts={self.prepare_module_output.output_layouts}, "
tmpstr += f"desired_output_layouts={self.prepare_module_output.desired_output_layouts}, "
tmpstr += f"use_local_output={self.prepare_module_output.use_local_output}"
tmpstr += ")"
return tmpstr