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,176 @@
# mypy: allow-untyped-defs
import logging
import sys
import traceback
import typing
from datetime import timedelta
import torch
RankType = int | torch.SymInt
log = logging.getLogger(__name__)
def is_available() -> bool:
"""
Return ``True`` if the distributed package is available.
Otherwise,
``torch.distributed`` does not expose any other APIs. Currently,
``torch.distributed`` is available on Linux, MacOS and Windows. Set
``USE_DISTRIBUTED=1`` to enable it when building PyTorch from source.
Currently, the default value is ``USE_DISTRIBUTED=1`` for Linux and Windows,
``USE_DISTRIBUTED=0`` for MacOS.
"""
return hasattr(torch._C, "_c10d_init")
if is_available() and not torch._C._c10d_init():
raise RuntimeError("Failed to initialize torch.distributed")
# Custom Runtime Errors thrown from the distributed package
DistError = torch._C._DistError
DistBackendError = torch._C._DistBackendError
DistNetworkError = torch._C._DistNetworkError
DistStoreError = torch._C._DistStoreError
QueueEmptyError = torch._C._DistQueueEmptyError
if is_available():
from torch._C._distributed_c10d import (
_broadcast_coalesced,
_compute_bucket_assignment_by_size,
_ControlCollectives,
_DEFAULT_FIRST_BUCKET_BYTES,
_make_nccl_premul_sum,
_register_builtin_comm_hook,
_register_comm_hook,
_StoreCollectives,
_test_python_store,
_verify_params_across_processes,
Backend as _Backend,
BuiltinCommHookType,
DebugLevel,
FileStore,
get_debug_level,
GradBucket,
Logger,
PrefixStore,
ProcessGroup as ProcessGroup,
Reducer,
set_debug_level,
set_debug_level_from_env,
Store,
TCPStore,
Work as _Work,
)
def _make_distributed_pdb():
"""
Supports using PDB from inside a multiprocessing child process.
Usage:
_make_distributed_pdb().set_trace()
"""
# Lazy import pdb only if we set breakpoints.
import pdb
class _DistributedPdb(pdb.Pdb):
def interaction(self, *args, **kwargs):
_stdin = sys.stdin
try:
with open("/dev/stdin") as sys.stdin:
pdb.Pdb.interaction(self, *args, **kwargs)
finally:
sys.stdin = _stdin
return _DistributedPdb()
_breakpoint_cache: dict[int, typing.Any] = {}
def breakpoint(rank: int = 0, skip: int = 0, timeout_s=3600):
"""
Set a breakpoint, but only on a single rank. All other ranks will wait for you to be
done with the breakpoint before continuing.
Args:
rank (int): Which rank to break on. Default: ``0``
skip (int): Skip the first ``skip`` calls to this breakpoint. Default: ``0``.
"""
if skip > 0:
key = hash(str(traceback.format_exc()))
counter = _breakpoint_cache.get(key, 0) + 1
_breakpoint_cache[key] = counter
if counter <= skip:
log.warning("Skip the breakpoint, counter=%d", counter)
return
# avoid having the default timeout (if short) interrupt your debug session
if timeout_s is not None:
for group in torch.distributed.distributed_c10d._pg_map:
torch.distributed.distributed_c10d._set_pg_timeout(
timedelta(seconds=timeout_s), group
)
if get_rank() == rank:
pdb = _make_distributed_pdb()
pdb.message(
"\n!!! ATTENTION !!!\n\n"
f"Type 'up' to get to the frame that called dist.breakpoint(rank={rank})\n"
)
pdb.set_trace()
# If Meta/Python keys are in the TLS, we want to make sure that we ignore them
# and hit the (default) CPU/CUDA implementation of barrier.
meta_in_tls = torch._C._meta_in_tls_dispatch_include()
guard = torch._C._DisableTorchDispatch() # type: ignore[attr-defined]
torch._C._set_meta_in_tls_dispatch_include(False)
try:
barrier()
finally:
torch._C._set_meta_in_tls_dispatch_include(meta_in_tls)
del guard
if sys.platform != "win32":
from torch._C._distributed_c10d import HashStore
from .device_mesh import DeviceMesh, init_device_mesh
# Variables prefixed with underscore are not auto imported
# See the comment in `distributed_c10d.py` above `_backend` on why we expose
# this.
from .distributed_c10d import * # noqa: F403
from .distributed_c10d import (
_all_gather_base,
_coalescing_manager,
_CoalescingManager,
_create_process_group_wrapper,
_get_process_group_name,
_rank_not_in_group,
_reduce_scatter_base,
_time_estimator,
get_node_local_rank,
)
from .remote_device import _remote_device
from .rendezvous import (
_create_store_from_options,
register_rendezvous_handler,
rendezvous,
)
set_debug_level_from_env()
else:
# This stub is sufficient to get
# python test/test_public_bindings.py -k test_correct_module_names
# working even when USE_DISTRIBUTED=0. Feel free to add more
# stubs as necessary.
# We cannot define stubs directly because they confuse pyre
class _Stub:
pass
sys.modules["torch.distributed"].GroupName = _Stub # type: ignore[attr-defined]
sys.modules["torch.distributed"].ProcessGroup = _Stub # type: ignore[attr-defined]
@@ -0,0 +1,37 @@
# Copyright (c) Meta Platforms, Inc. and affiliates
from typing_extensions import Protocol, runtime_checkable
import torch
@runtime_checkable
class _Checkpointable(Protocol): # noqa: PYI046
"""
Interface for checkpointable objects.
Implemented as a protocol, implicit subtyping is supported so subclasses do not need to inherit this explicitly.
This is to allow arbitrary objects/tensor subclasses to hook into DCP seamlessly through implementing the interface.
"""
def __create_write_items__(self, fqn: str, object: object) -> list[object]:
"""
Return a list of WriteItems based on object's contents.
"""
raise NotImplementedError(
"_Checkpointable._create_write_items is not implemented"
)
def __create_chunk_list__(self) -> list[object]:
"""
Return a list of `ChunkStorageMetadata` based on object's contents.
"""
raise NotImplementedError(
"_Checkpointable._create_chunk_list is not implemented"
)
def __get_tensor_shard__(self, index: int) -> torch.Tensor:
"""
Return a 'torch.Tensor' shard based on 'MetadataIndex'.
"""
raise NotImplementedError(
"_Checkpointable._get_tensor_shard is not implemented"
)
@@ -0,0 +1,3 @@
from .checkpoint_activation import checkpoint
from .contract import _get_registry, contract
from .replicate import replicate
@@ -0,0 +1,135 @@
# mypy: allow-untyped-defs
from collections.abc import Generator
from contextlib import AbstractContextManager, contextmanager, nullcontext
from typing import Any
import torch
import torch.nn as nn
from torch.utils.checkpoint import (
_checkpoint_without_reentrant_generator,
_DEFAULT_DETERMINISM_MODE,
)
from .contract import _State, contract
@contextmanager
def _no_hook(module: nn.Module, user_ctx: AbstractContextManager | None = None):
r"""
Disable hooks installed by checkpoint to avoid unintentional recursion
during backward recomputation.
"""
with user_ctx if user_ctx else nullcontext():
orig_enable_hook = checkpoint.state(module).enable_hook
checkpoint.state(module).enable_hook = False
try:
yield
finally:
checkpoint.state(module).enable_hook = orig_enable_hook
class _CheckpointState(_State):
enable_hook: bool = False
_ac_generator: Generator[None, None, None] | None
@contract(_CheckpointState)
def checkpoint(module: nn.Module, **kwargs) -> nn.Module:
r"""
This is a composable activation checkpointing API. Unlike functional
activation checkpointing APIs, this one does not require changing model
source code. Unlike ``nn.Module`` wrapper activation checkpointing APIs,
this one does not modify model structure or fully-qualified names either.
Under the hood, it registers activation checkpointing logic as pre- and
post-forward hooks. Hence, this API can be easily applied to any model or
sub-modules in the model.
Args:
module (nn.Module): the target model or sub-module to apply activation
checkpointing.
Example::
>>> # xdoctest: +SKIP
>>> import torch.nn as nn
>>>
>>> class MyModel(nn.Module):
>>> def __init__(self) -> None:
>>> super().__init__()
>>> self.l1 = nn.Linear(10, 10)
>>> self.l2 = nn.Linear(10, 10)
>>>
>>> def forward(self, x):
>>> return self.l2(self.l1(x))
>>>
>>> model = MyModel()
>>> checkpoint(model.l1) # apply activation checkpointing only to l1
>>> model(torch.zeros(2, 10)).sum().backward()
"""
torch._C._log_api_usage_once("torch.distributed.checkpoint")
use_reentrant = kwargs.pop("use_reentrant", False)
if use_reentrant:
raise NotImplementedError(
"use_reentrant=True is not supported in composable checkpoint. "
"Please use torch.utils.checkpoint.checkpoint instead."
)
preserve_rng_state = kwargs.pop("preserve_rng_state", True)
user_context_fns = kwargs.pop("context_fn", None)
determinism_check = kwargs.pop("determinism_check", _DEFAULT_DETERMINISM_MODE)
debug = kwargs.pop("debug", False)
early_stop = kwargs.pop("early_stop", True)
if kwargs:
raise ValueError(
"Unexpected keyword arguments: " + ",".join(arg for arg in kwargs)
)
def forward_pre_hook(
module: nn.Module, args: tuple[Any, ...], kwargs: dict[str, Any]
) -> None:
if checkpoint.state(module).enable_hook:
def context_fns():
if user_context_fns is not None:
ctx1, ctx2 = user_context_fns()
return ctx1, _no_hook(module, ctx2)
else:
return nullcontext(), _no_hook(module)
gen = _checkpoint_without_reentrant_generator(
module,
preserve_rng_state,
context_fns,
determinism_check,
debug,
early_stop,
*args,
**kwargs,
)
checkpoint.state(module)._ac_generator = gen
next(gen)
def forward_hook(module: nn.Module, inputs: tuple[Any, ...], output: Any) -> Any:
if checkpoint.state(module).enable_hook:
try:
gen = checkpoint.state(module)._ac_generator
if gen is None:
raise AssertionError
next(gen)
except StopIteration:
pass
else:
raise RuntimeError(
"Expected non-reentrant activation checkpoint generator to be exhausted, but it was not!"
)
# Ensure that we no longer hold on to the generator. always_call=True helps ensure we
# clear this even in the case of exception in fwd pass.
checkpoint.state(module)._ac_generator = None
checkpoint.state(module).enable_hook = True
module.register_forward_pre_hook(forward_pre_hook, with_kwargs=True)
module.register_forward_hook(forward_hook, prepend=True, always_call=True)
return module
@@ -0,0 +1,250 @@
# mypy: allow-untyped-defs
import uuid
from collections import OrderedDict
from collections.abc import Callable
from functools import wraps
from typing import Concatenate, Generic, Protocol
from typing_extensions import ParamSpec, TypeVar
import torch
import torch.nn as nn
from torch.distributed._composable_state import _State
from torch.distributed.utils import _get_root_modules
_T = TypeVar("_T", covariant=True)
_P = ParamSpec("_P")
def generate_state_key(string="__composable_api_state_key"):
return f"{string}_{str(uuid.uuid4())}"
STATE_KEY = generate_state_key()
REGISTRY_KEY = generate_state_key()
# TODO: we can add additional info to RegistryItem to share across APIs. E.g.,
# we can add args and kwargs here, and then we can detect whether fully_shard
# is combined with reentrant activation checkpointing and error out with a clear
# message.
class RegistryItem:
pass
_TState = TypeVar("_TState", bound="_State", covariant=True)
_M = TypeVar("_M", nn.Module, list[nn.Module])
class _ContractFn(Protocol, Generic[_P, _T, _TState]):
def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _T: ...
def state(self, module: nn.Module) -> _TState: ...
def contract(
state_cls: type[_TState] = _State, # type: ignore[assignment]
) -> Callable[
[Callable[Concatenate[_M, _P], _M]],
_ContractFn[Concatenate[_M, _P], _M, _TState],
]:
r"""
Decorate a function as a composable distributed API, where the first
argument of the function must be an :class:`nn.Module` instance or sequence
of :class:`nn.Module` instances.
The decorator verifies that the decorated function does not modify
fully-qualified names (FQNs) for parameters, buffers, or modules. The
decorated function can return different module instances than the input
modules; the FQN invariant will be enforced following the input order.
When a function ``func`` is decorated by ``@contract()``, a
``.state(module: nn.Module)`` method will be installed to the decorated
function. Then you can retrieve and modify the state on a module by calling
``func.state(module)``.
Example::
>>> # xdoctest: +SKIP
>>> import torch.nn as nn
>>>
>>> class MyModel(nn.Module):
>>> def __init__(self) -> None:
>>> super().__init__()
>>> self.l1 = nn.Linear(10, 10)
>>> self.l2 = nn.Linear(10, 10)
>>>
>>> def forward(self, x):
>>> return self.l2(self.l1(x))
>>>
>>> @contract()
>>> def my_feature(module: nn.Module) -> nn.Module:
>>> my_feature.state(module).some_state = "any value"
>>> return module
>>>
>>> model = MyModel()
>>> my_feature(model.l1)
>>> assert my_feature.state(model.l1).some_state == "any value"
>>> my_feature(model.l2)
>>> model(torch.randn(2, 10)).sum().backward()
"""
# wraps will make functions decorated with contract() pickleable - needed for integration with torch.package
@wraps(state_cls) # type: ignore[arg-type]
def inner(
func: Callable[Concatenate[_M, _P], _M],
) -> _ContractFn[Concatenate[_M, _P], _M, _TState]:
@wraps(func)
def wrapper(
module: _M,
*args: _P.args,
**kwargs: _P.kwargs,
) -> _M:
inp_module = module
modules: list[nn.Module]
if isinstance(module, nn.Module):
modules = [module]
else:
# If the user passes a sequence of modules, then we assume that
# we only need to insert the state object on the root modules
# (i.e. those without a parent) among the passed-in modules.
# pyrefly: ignore [no-matching-overload]
modules = _get_root_modules(list(module))
state = state_cls() # shared across all modules
registry_item = RegistryItem() # shared across all modules
# `func` is allowed to return different module instances than the
# input modules as long as FQNs are preserved following the input
# module order
all_orig_named_params: list[dict[str, nn.Parameter]] = []
all_orig_named_buffers: list[dict[str, torch.Tensor]] = []
all_orig_named_modules: list[dict[str, nn.Module]] = []
for module in modules:
default_all_state: dict[Callable, _State] = OrderedDict()
default_registry: dict[str, RegistryItem] = OrderedDict()
all_state: dict[Callable, _State] = module.__dict__.setdefault( # type: ignore[call-overload]
STATE_KEY, default_all_state
)
if not isinstance(all_state, dict):
raise AssertionError(
f"Distributed composable API states corrupted: {all_state}"
)
registry: dict[str, RegistryItem] = module.__dict__.setdefault( # type: ignore[call-overload]
REGISTRY_KEY, default_registry
)
if not isinstance(registry, dict):
raise AssertionError(
f"Distributed composable API registry corrupted: {registry}"
)
if func in all_state or func.__name__ in registry:
raise AssertionError(
"Each distinct composable distributed API can only be applied to a "
f"module once. {func.__name__} has already been applied to the "
f"following module:\n{module}"
)
all_state.setdefault(func, state)
registry.setdefault(func.__name__, registry_item)
all_orig_named_params.append(OrderedDict(module.named_parameters()))
all_orig_named_buffers.append(OrderedDict(module.named_buffers()))
all_orig_named_modules.append(OrderedDict(module.named_modules()))
updated = func(inp_module, *args, **kwargs)
if updated is None:
updated = inp_module # type: ignore[assignment]
updated_modules: list[nn.Module]
if isinstance(updated, nn.Module):
updated_modules = [updated]
else:
updated_modules = _get_root_modules(list(inp_module)) # type: ignore[arg-type, call-overload]
all_new_named_params: list[dict[str, nn.Parameter]] = []
all_new_named_buffers: list[dict[str, torch.Tensor]] = []
all_new_named_modules: list[dict[str, nn.Module]] = []
for module in updated_modules:
all_new_named_params.append(OrderedDict(module.named_parameters()))
all_new_named_buffers.append(OrderedDict(module.named_buffers()))
all_new_named_modules.append(OrderedDict(module.named_modules()))
num_orig_modules = len(all_orig_named_modules)
num_new_modules = len(all_new_named_modules)
if num_orig_modules != num_new_modules:
raise AssertionError(
f"{func.__name__} should return the same number of modules as input modules"
f"Inputs: {num_orig_modules} modules\n"
f"Outputs: {num_new_modules} modules"
)
def check_fqn(orig_fqns: list[str], new_fqns: list[str], check_key: str):
if orig_fqns == new_fqns:
return
orig_fqn_set, new_fqn_set = set(orig_fqns), set(new_fqns)
orig_only = orig_fqn_set - new_fqn_set
new_only = new_fqn_set - orig_fqn_set
if len(orig_only) or len(new_only):
raise RuntimeError(
f"{check_key}"
"Composable distributed API implementations cannot modify FQNs.\n"
f"FQNs only in original: {orig_only}\n"
f"FQNs only in new: {new_only}"
)
else:
raise RuntimeError(
f"{check_key}"
"Composable distributed API implementations cannot modify "
"the order of FQNs.\n"
f"Original FQNs: {orig_only}\n"
f"New FQNs: {new_only}"
)
for orig_named_params, new_named_params in zip(
all_orig_named_params, all_new_named_params
):
check_fqn(
list(orig_named_params.keys()),
list(new_named_params.keys()),
"Checking parameters: ",
)
for orig_named_buffers, new_named_buffers in zip(
all_orig_named_buffers, all_new_named_buffers
):
check_fqn(
list(orig_named_buffers.keys()),
list(new_named_buffers.keys()),
"Checking buffers: ",
)
for orig_named_modules, new_named_modules in zip(
all_orig_named_modules, all_new_named_modules
):
check_fqn(
list(orig_named_modules.keys()),
list(new_named_modules.keys()),
"Checking modules: ",
)
# TODO: verify that installed distributed paradigms are compatible with
# each other.
return updated
def get_state(module: nn.Module) -> _State:
return module.__dict__.setdefault( # type: ignore[call-overload]
STATE_KEY,
{}, # TODO(@yhcharles): this is a temporary fix, need a better way
).get(func) # type: ignore[call-overload]
wrapper.state = get_state # type: ignore[attr-defined]
return wrapper # type: ignore[return-value]
return inner # type: ignore[return-value]
def _get_registry(module: nn.Module) -> dict[str, RegistryItem] | None:
r"""
Get an ``OrderedDict`` of composable APIs that have been applied to the
``module``, indexed by the API name. If no API has been applied, then this
returns ``None``.
"""
return getattr(module, REGISTRY_KEY, None)
@@ -0,0 +1,3 @@
from torch.distributed.fsdp import CPUOffloadPolicy, MixedPrecisionPolicy, OffloadPolicy
from .fully_shard import FSDPModule, fully_shard, register_fsdp_forward_method
@@ -0,0 +1,8 @@
# TODO: For backward compatibility, we are importing the public objects
# originally from this file.
from torch.distributed.fsdp import ( # noqa: F401
FSDPModule,
fully_shard,
register_fsdp_forward_method,
UnshardHandle,
)
@@ -0,0 +1,257 @@
# mypy: allow-untyped-defs
import weakref
from collections.abc import Iterable
from typing import Any, NoReturn
import torch
import torch.nn as nn
from torch.distributed._composable_state import _State
from torch.nn.parallel import DistributedDataParallel
from .contract import _get_registry, contract
_ROOT_MODULE_PREFIX = ""
class _ReplicateState(_State):
_ddp_weakref: weakref.ref
def __init__(self) -> None:
super().__init__()
self.module: nn.Module = nn.ParameterList()
self.has_initialized: bool = False
self._param_list: nn.ParameterList = nn.ParameterList()
# TODO(@fegin): this variable is originally create for testing, we
# should remove this if possible.
self._orig_module = self.module
self._param_names: list[str] = []
self._no_sync: bool = False
self._init_args: tuple[Any, ...] | None = None
self._init_kwargs: dict[str, Any] = {}
self._comm_hook_args: list[Any] = []
def _collect_params(
self,
module: nn.Module,
ignored_modules: set[nn.Module],
ignored_params: set[nn.Parameter],
prefix: str = _ROOT_MODULE_PREFIX,
) -> None:
# skip if managed by fully_sharded API
if _is_fully_sharded(module):
return
# if a module is ignored, all descendants of the module are ignored.
if module in ignored_modules:
return
recurse_prefix = (
f"{prefix}." if prefix != _ROOT_MODULE_PREFIX else _ROOT_MODULE_PREFIX
)
for n, p in module.named_parameters(recurse=False):
if p not in ignored_params:
self._param_list.append(p)
self._param_names.append(f"{recurse_prefix}{n}")
for name, child_module in module.named_children():
self._collect_params(
child_module,
ignored_modules,
ignored_params,
prefix=f"{recurse_prefix}{name}",
)
def lazy_init(self) -> None:
@torch._disable_dynamo(recursive=True)
def _lazy_init():
if self._init_args is None:
raise AssertionError
self.init(*self._init_args, **self._init_kwargs)
self.register_comm_hook()
self._init_args = ()
self._init_kwargs = {}
_lazy_init()
def init(
self,
module: nn.Module,
ignored_modules: set[nn.Module],
**kwargs,
) -> None:
if self.has_initialized:
return
self.has_initialized = True
self.module = module
ignored_params = {p for m in ignored_modules for p in m.parameters()}
for submodule in module.modules():
if _is_fully_sharded(submodule):
ignored_params.update(submodule.parameters())
from torch.distributed.tensor.parallel.ddp import _localize_dtensor
_localize_dtensor(module, ignored_params=ignored_params)
self._collect_params(module, ignored_modules, ignored_params)
if "device_id" in kwargs:
# replicate() supports a small usability enhancement where
# user can pass in device_id as a Union[int, torch.device] even for
# CPU devices so users don't have to change code for CPU/GPU runs.
# We derive the right device_ids to feed into DDP to support this.
if kwargs["device_id"] is not None:
device_id = kwargs["device_id"]
# Convert to device_ids that DDP expects.
if isinstance(device_id, torch.device) and device_id.type == "cpu":
# CPU modules receive device_ids None
kwargs["device_ids"] = None
else:
# GPU modules expect device_ids=[cuda_device]
kwargs["device_ids"] = [device_id]
else:
kwargs["device_ids"] = None
kwargs.pop("device_id")
self._ddp = DistributedDataParallel(self._param_list, **kwargs)
# Weakref to the DDP instance is currently only used for testing.
replicate.state(self.module)._ddp_weakref = weakref.ref(self._ddp)
def register_comm_hook(self) -> None:
for comm_args, comm_kwargs in self._comm_hook_args:
self._ddp.register_comm_hook(*comm_args, **comm_kwargs)
self._comm_hook_args.clear()
def record_init_args(self, *args, **kwargs) -> None:
self._init_args = args
self._init_kwargs = kwargs
def forward_pre_hook(
self, module: nn.Module, args: tuple[Any, ...], kwargs: dict[str, Any]
) -> Any:
if self._init_args or self._init_kwargs:
self.lazy_init()
self._ddp.require_backward_grad_sync = not self._no_sync
DistributedDataParallel._active_ddp_module = self._ddp
return self._ddp._pre_forward(*args, **kwargs)
def forward_post_hook(
self,
module: nn.Module,
input: tuple[torch.Tensor],
output: torch.Tensor,
) -> torch.Tensor:
DistributedDataParallel._active_ddp_module = None
return self._ddp._post_forward(output)
def unimplemented_deepcopy(*args: Any, **kwargs: Any) -> NoReturn:
raise AssertionError(
"DDP does not support deepcopy. Please use state dict for serialization."
)
# Follow the same pattern as FSDP/fully_shard
class DDP:
def __new__(cls, *args, **kwargs):
"""
Override ``__new__`` to remove the DDP class and directly construct
the original class for cases like indexing into a container module.
"""
# Use index 2 since 0 is the dynamically constructed `DDP<...>` class
# and index 1 is the `DDP` class itself
orig_cls = cls.__mro__[2]
return orig_cls.__new__(orig_cls, *args, **kwargs)
def set_requires_gradient_sync(self, requires_gradient_sync: bool) -> None:
"""
Sets if the module should sync gradients. This can be used to implement
gradient accumulation without communication.
Args:
requires_gradient_sync (bool): Whether to reduce gradients for the
module's parameters.
"""
replicate.state(self)._no_sync = not requires_gradient_sync # type: ignore[arg-type]
def register_comm_hook(self, *args, **kwargs) -> None:
replicate.state(self)._comm_hook_args.append((args, kwargs)) # type: ignore[arg-type]
@contract(state_cls=_ReplicateState)
def replicate(
module: nn.Module,
ignored_modules: Iterable[torch.nn.Module] | None = None,
**kwargs,
) -> nn.Module:
r"""Replicates a module
Args:
module (torch.nn.Module): module to replicate
Example::
>>> # xdoctest: +REQUIRES(module:torch._C._distributed_c10d)
>>> module = nn.Linear(3, 3)
>>> replicate(module)
"""
torch._C._log_api_usage_once("torch.distributed.replicate")
# TODO(fegin): using kwargs is not a good idea if we would like to make
# replicate a formal API to replace DDP.
if "device_id" in kwargs:
if not isinstance(kwargs["device_id"], (int, torch.device)):
raise RuntimeError(
"Expected device_id to be int or torch.device, "
f"but got {type(kwargs['device_id'])}"
)
if _is_fully_sharded(module):
raise RuntimeError(
"Cannot apply `replicate()` on a Module already managed by `fully_shard`"
)
if ignored_modules is None:
ignored_modules = {}
else:
ignored_modules = set(ignored_modules)
state = replicate.state(module)
module.register_forward_pre_hook(state.forward_pre_hook, with_kwargs=True)
device_mesh = kwargs.get("device_mesh")
if device_mesh is not None:
root_mesh = device_mesh._get_root_mesh()
# if a root mesh is not the same as device_mesh,
# meaning the device_mesh is sliced out from the root mesh.
if root_mesh != device_mesh:
# TODO: This is a temporary work around to enable DDP + TP.
# We should do the logic in DDP so that the 2D implementation is
# sound and the state_dict works out of the box.
#
# This won't conflict with what is done in DDP class as the module
# replicate is going to pass is NOT the original module.
from torch.distributed.tensor.parallel.ddp import (
_localize_dtensor,
_reconstruct_dtensor,
)
module.register_forward_pre_hook(_reconstruct_dtensor)
module.register_forward_hook(_localize_dtensor)
module.register_forward_hook(state.forward_post_hook) # type: ignore[arg-type]
state.record_init_args(module, ignored_modules, **kwargs)
# Place DDP leftmost for highest priority in the method resolution order
cls = module.__class__
dct = {"__deepcopy__": unimplemented_deepcopy}
new_cls = type(f"DDP{cls.__name__}", (DDP, cls), dct)
module.__class__ = new_cls
return module
def _is_fully_sharded(module: nn.Module) -> bool:
r"""Check if module is marked with fully_shard."""
registry = _get_registry(module)
if registry is None:
return False
return "fully_shard" in registry
@@ -0,0 +1,204 @@
# mypy: allow-untyped-defs
from __future__ import annotations
import logging
from typing import overload, TYPE_CHECKING
import torch
import torch.nn as nn
from torch.distributed._composable_state import _get_module_state
from torch.distributed.fsdp._fully_shard._fsdp_api import (
MixedPrecisionPolicy,
OffloadPolicy,
)
from torch.distributed.fsdp._fully_shard._fsdp_common import DDPMeshInfo
from torch.distributed.fsdp._fully_shard._fsdp_init import (
_apply_to_module,
_get_device_from_mesh,
_get_mesh_info,
_get_modules_and_states,
_init_default_mesh,
_init_param_group,
_validate_mesh as _validate_mesh_common,
_validate_module as _validate_module_common,
)
from torch.distributed.fsdp._fully_shard._fsdp_state import FSDPState, FSDPStateContext
from torch.distributed.fsdp._fully_shard._fully_shard import (
_unimplemented_deepcopy,
FSDPModule,
)
from .contract import _get_registry, contract
if TYPE_CHECKING:
from torch.distributed.fsdp._fully_shard._fsdp_api import DataParallelMeshDims
from torch.distributed.tensor import DeviceMesh
cls_to_replicate_cls: dict[type, type] = {}
logger = logging.getLogger("torch.distributed._composable.replicate_with_fsdp")
class _ReplicateStateContext(FSDPStateContext["_ReplicateState"]):
"""
State shared across Replicate states.
This is a typed subclass of FSDPStateContext parameterized with _ReplicateState,
providing correct type annotations (e.g., all_states: list[_ReplicateState]).
It also allows call sites to differentiate between Replicate and FSDP contexts
via isinstance checks if needed.
"""
def _get_module_replicate_state(module: nn.Module) -> _ReplicateState | None:
state = _get_module_state(module)
if isinstance(state, _ReplicateState):
return state
return None
class _ReplicateState(FSDPState):
_state_name: str = "Replicate"
def __init__(self) -> None:
super().__init__()
self._state_ctx = _ReplicateStateContext()
def _get_state_for_module(self, module: nn.Module) -> FSDPState | None:
return _get_module_replicate_state(module)
def init(
self,
modules: tuple[nn.Module, ...],
device: torch.device,
mp_policy: MixedPrecisionPolicy,
auto_reshard_after_forward: bool = False,
) -> None:
super().init(modules, device, mp_policy, auto_reshard_after_forward)
@overload
# pyrefly: ignore [inconsistent-overload]
def replicate(
module: nn.Module,
*,
mesh: DeviceMesh | None = ...,
mp_policy: MixedPrecisionPolicy = ...,
offload_policy: OffloadPolicy = ...,
ignored_params: set[nn.Parameter] | None = ...,
dp_mesh_dims: DataParallelMeshDims | None = ...,
) -> ReplicateModule: ...
@overload
# pyrefly: ignore [inconsistent-overload]
def replicate(
module: list[nn.Module],
*,
mesh: DeviceMesh | None = ...,
mp_policy: MixedPrecisionPolicy = ...,
offload_policy: OffloadPolicy = ...,
ignored_params: set[nn.Parameter] | None = ...,
dp_mesh_dims: DataParallelMeshDims | None = ...,
) -> list[ReplicateModule]: ...
@contract(state_cls=_ReplicateState) # type: ignore[misc]
def replicate(
module: nn.Module,
*,
mesh: DeviceMesh | None = None,
mp_policy: MixedPrecisionPolicy = MixedPrecisionPolicy(),
offload_policy: OffloadPolicy = OffloadPolicy(),
ignored_params: set[nn.Parameter] | None = None,
dp_mesh_dims: DataParallelMeshDims | None = None,
):
r"""Replicates a module
Args:
module (torch.nn.Module): module to replicate
Example::
>>> # xdoctest: +REQUIRES(module:torch._C._distributed_c10d)
>>> module = nn.Linear(3, 3)
>>> replicate(module)
"""
torch._C._log_api_usage_once("torch.distributed._composable.replicate_with_fsdp")
_validate_module(module)
mesh = mesh or _init_default_mesh(mesh_dim_names=("replicate",))
if dp_mesh_dims is not None:
_validate_mesh_common(mesh, dp_mesh_dims)
mesh_info = _get_mesh_info(mesh, dp_mesh_dims)
if not isinstance(mesh_info, DDPMeshInfo):
raise ValueError(
"replicate() with dp_mesh_dims requires replicate-only "
"dims (no shard dims). Use fully_shard() for sharding."
)
else:
_validate_mesh(mesh)
mesh_info = DDPMeshInfo(mesh, replicate_mesh_dim=0)
device = _get_device_from_mesh(mesh)
# managed_modules (3rd return) and buffers (5th return) are unused:
# - managed_modules: FSDP uses this to set Dynamo-specific attributes
# (_is_fsdp_managed_module, _fsdp_use_orig_params), which replicate doesn't need
# - buffers: already moved to device by _get_modules_and_states; replicate
# doesn't need to track them separately
arg_module, modules, _, params, _ = _get_modules_and_states(
module,
device,
ignored_params,
is_composable_fn=is_composable_with_replicate,
get_state_fn=_get_module_replicate_state,
)
state = replicate.state(modules[0]) # type: ignore[attr-defined]
state.init(modules, device, mp_policy)
_init_param_group(
state,
params,
modules,
mesh_info,
None, # post_forward_mesh_info
device,
None, # shard_placement_fn
mp_policy,
offload_policy,
)
# Place Replicate leftmost for highest priority in the method resolution order
_apply_to_module(
modules,
cls_to_replicate_cls,
ReplicateModule,
"Replicate",
_unimplemented_deepcopy,
)
return arg_module
class ReplicateModule(FSDPModule):
# Index in MRO where the original class is found.
# For Replicate: [Replicate<Orig>, ReplicateModule, FSDPModule, Orig, ...] -> index 3
_orig_cls_mro_index: int = 3
def is_composable_with_replicate(module: nn.Module) -> bool:
registry = _get_registry(module)
if registry is None:
return True
return "fully_shard" not in registry
def _validate_module(module: nn.Module) -> None:
if not is_composable_with_replicate(module):
raise RuntimeError(
"Cannot apply `replicate()` on a Module already managed by `fully_shard`"
)
_validate_module_common(module, "replicate")
def _validate_mesh(mesh: DeviceMesh) -> None:
if mesh.ndim != 1:
raise ValueError(f"replicate expects a 1D DeviceMesh but got {mesh}")
@@ -0,0 +1,45 @@
import weakref
from typing import cast
import torch.nn as nn
class _State:
pass
_module_state_mapping: weakref.WeakKeyDictionary[
nn.Module, weakref.ReferenceType[_State]
] = weakref.WeakKeyDictionary()
def _insert_module_state(module: nn.Module, state: _State) -> None:
global _module_state_mapping
if module in _module_state_mapping:
raise AssertionError(f"Inserting {module} more than once.")
_module_state_mapping[module] = weakref.ref(state)
def _get_module_state(module: nn.Module) -> _State | None:
"""
Return the ``_State`` in ``model``.
Given a ``module``, this API finds out if the module is also a ``_State``
instance or if the module is managed by a composable API. If the module
is also a ``_State``, ``module`` will be casted to ``_State` and returned.
If it is managed by a composable API, the corresponding ``_State`` will
be returned.
"""
global _module_state_mapping
if isinstance(module, _State):
return cast(_State, module)
else:
# https://github.com/pytorch/pytorch/issues/107054
if module in _module_state_mapping:
state_ref = _module_state_mapping[module]
state = state_ref()
if state is None:
raise AssertionError("State has already been garbage collected")
return state
else:
return None
@@ -0,0 +1,183 @@
"""
This is an experimental new API for PyTorch Distributed. This is actively in development and subject to change or deletion entirely.
This is intended as a proving ground for more flexible and object oriented distributed APIs.
"""
from collections.abc import Generator
from contextlib import contextmanager
from datetime import timedelta
from typing import Protocol
import torch
from torch._C._distributed_c10d import (
_current_process_group,
_set_process_group,
ProcessGroup,
ReduceOp,
Store,
)
from torch.distributed.rendezvous import rendezvous
_BACKENDS: dict[str, "ProcessGroupFactory"] = {}
__all__ = [
"ProcessGroup",
"ReduceOp",
"ProcessGroupFactory",
"register_backend",
"new_group",
"current_process_group",
"process_group",
]
class ProcessGroupFactory(Protocol):
"""Protocol for process group factories."""
def __call__(
self,
store: Store,
rank: int,
world_size: int,
timeout: timedelta,
device: torch.device,
**kwargs: object,
) -> ProcessGroup: ...
def register_backend(name: str, func: ProcessGroupFactory) -> None:
"""
Register a new process group backend.
Args:
name: The name of the backend.
func: The function to create the process group.
"""
if name in _BACKENDS:
raise ValueError(f"Backend {name} already registered")
_BACKENDS[name] = func
def _gloo_factory(
store: Store,
rank: int,
world_size: int,
timeout: timedelta,
device: torch.device,
**kwargs: object,
) -> ProcessGroup:
from torch.distributed import ProcessGroupGloo
if len(kwargs) != 0:
raise AssertionError("Gloo backend received unexpected kwargs")
backend_class = ProcessGroupGloo(store, rank, world_size, timeout)
backend_class._set_sequence_number_for_group()
pg = ProcessGroup(store, rank, world_size)
pg._set_default_backend(ProcessGroup.BackendType.GLOO)
# register devices
pg._register_backend(device, ProcessGroup.BackendType.GLOO, backend_class)
pg._register_backend(
torch.device("cpu"), ProcessGroup.BackendType.GLOO, backend_class
)
if torch.cuda.is_available():
pg._register_backend(
torch.device("cuda"), ProcessGroup.BackendType.GLOO, backend_class
)
return pg
def _nccl_factory(
store: Store,
rank: int,
world_size: int,
timeout: timedelta,
device: torch.device,
**kwargs: object,
) -> ProcessGroup:
from torch.distributed import ProcessGroupNCCL
opts = ProcessGroupNCCL.Options()
opts._timeout = timeout
for k, v in kwargs.items():
if not hasattr(opts, k):
raise KeyError(f"Unknown option {k}")
setattr(opts, k, v)
backend_class = ProcessGroupNCCL(store, rank, world_size, opts)
backend_class._set_sequence_number_for_group()
backend_class.eager_connect_single_device(device)
pg = ProcessGroup(store, rank, world_size)
pg._set_default_backend(ProcessGroup.BackendType.NCCL)
pg._register_backend(device, ProcessGroup.BackendType.NCCL, backend_class)
return pg
register_backend("gloo", _gloo_factory)
register_backend("nccl", _nccl_factory)
def new_group(
backend: str,
timeout: timedelta,
device: str | torch.device,
**kwargs: object,
) -> ProcessGroup:
"""
Create a new process group with the given backend and options. This group is
independent and will not be globally registered and thus not usable via the
standard torch.distributed.* APIs.
Args:
backend: The backend to use for the process group.
timeout: The timeout for collective operations.
device: The device to use for the process group.
**kwargs: All remaining arguments are passed to the backend constructor.
See the backend specific documentation for details.
Returns:
A new process group.
"""
if backend not in _BACKENDS:
raise ValueError(f"Backend {backend} not registered")
device = torch.device(device)
store, rank, world_size = next(iter(rendezvous("env://")))
store.set_timeout(timeout)
return _BACKENDS[backend](store, rank, world_size, timeout, device, **kwargs)
def current_process_group() -> ProcessGroup:
"""
Get the current process group. Thread local method.
Returns:
The current process group.
"""
return _current_process_group()
@contextmanager
def process_group(pg: ProcessGroup) -> Generator[None, None, None]:
"""
Context manager for process groups. Thread local method.
Args:
pg: The process group to use.
"""
prev_pg = current_process_group()
_set_process_group(pg)
try:
yield
finally:
_set_process_group(prev_pg)
@@ -0,0 +1,137 @@
# mypy: allow-untyped-defs
import torch
import torch.distributed.distributed_c10d as c10d
"""
This file contains the op impls for the legacy (c10d_functional) functional collectives.
These impls simply call into the native (_c10d_functional) functional collectives.
"""
def _broadcast(input, src, tag, ranks, group_size):
group_name = c10d._resolve_group_name_by_ranks_and_tag(ranks, tag)
return torch.ops._c10d_functional.broadcast(
input,
src,
group_name,
)
def _all_reduce(input, reduce_op, tag, ranks, group_size):
group_name = c10d._resolve_group_name_by_ranks_and_tag(ranks, tag)
return torch.ops._c10d_functional.all_reduce(
input,
reduce_op,
group_name,
)
def _all_reduce_coalesced(inputs, reduce_op, tag, ranks, group_size):
group_name = c10d._resolve_group_name_by_ranks_and_tag(ranks, tag)
return torch.ops._c10d_functional.all_reduce_coalesced(
inputs,
reduce_op,
group_name,
)
def _all_gather_into_tensor(input, tag, ranks, group_size):
group_name = c10d._resolve_group_name_by_ranks_and_tag(ranks, tag)
return torch.ops._c10d_functional.all_gather_into_tensor(
input,
group_size,
group_name,
)
def _all_gather_into_tensor_coalesced(input, tag, ranks, group_size):
group_name = c10d._resolve_group_name_by_ranks_and_tag(ranks, tag)
return torch.ops._c10d_functional.all_gather_into_tensor_coalesced(
input,
group_size,
group_name,
)
def _reduce_scatter_tensor(
input: torch.Tensor,
reduce_op: str,
tag: str,
ranks: list[int],
group_size: int,
):
group_name = c10d._resolve_group_name_by_ranks_and_tag(ranks, tag)
return torch.ops._c10d_functional.reduce_scatter_tensor(
input,
reduce_op,
group_size,
group_name,
)
def _reduce_scatter_tensor_coalesced(
inputs: list[torch.Tensor],
reduce_op: str,
tag: str,
ranks: list[int],
group_size: int,
):
group_name = c10d._resolve_group_name_by_ranks_and_tag(ranks, tag)
return torch.ops._c10d_functional.reduce_scatter_tensor_coalesced(
inputs,
reduce_op,
group_size,
group_name,
)
def _all_to_all_single(
input: torch.Tensor,
output_split_sizes: list[int] | None,
input_split_sizes: list[int] | None,
tag: str,
ranks: list[int],
group_size: int,
):
if output_split_sizes is None or input_split_sizes is None:
if not (output_split_sizes is None and input_split_sizes is None):
raise AssertionError(
"output_split_sizes and input_split_sizes must either be "
"specified together or both set to None"
)
output_split_sizes = [input.shape[0] // group_size] * group_size
input_split_sizes = output_split_sizes
group_name = c10d._resolve_group_name_by_ranks_and_tag(ranks, tag)
return torch.ops._c10d_functional.all_to_all_single(
input,
output_split_sizes,
input_split_sizes,
group_name,
)
def _wait_tensor(tensor: torch.Tensor) -> torch.Tensor:
return torch.ops._c10d_functional.wait_tensor(tensor)
def _isend(tensor: torch.Tensor, dst: int, tag: str, group_name):
return torch.ops._c10d_functional.isend(tensor, dst, tag, group_name)
def _irecv(tensor: torch.Tensor, src: int, tag: str, group_name):
return torch.ops._c10d_functional.irecv(tensor, src, tag, group_name)
def _batch_p2p_ops(
op_list: list[str],
peer_list: list[int],
tag_list: list[int],
tensors: list[torch.Tensor],
group_name: str,
):
return torch.ops._c10d_functional.batch_p2p_ops(
op_list, peer_list, tag_list, tensors, group_name
)
@@ -0,0 +1,320 @@
"""
Definition of CuTe inspired Layouts for DeviceMesh internal bookkeeping and functions to manipulate them
"""
import math
from collections.abc import Iterator
from dataclasses import dataclass
from itertools import product
import torch
from torch.distributed._pycute import (
as_tuple,
coalesce,
complement,
composition,
flatten,
IntTuple,
is_int,
is_tuple,
Layout,
match_structure,
)
@dataclass(frozen=True, init=True)
class _MeshLayout(Layout):
"""
Utility class for representing an integer layout by borrowing ideas from CuTe Layout Algebra.
See https://docs.nvidia.com/cutlass/media/docs/cpp/cute/02_layout_algebra.html for more details.
Each layout is represented as a list of sizes and strides. We use it as a way for mechanical bookkeeping
of the integers such as ranks in a SPMD mesh, and the transformation on top of it.
Lots of methods of layout like coalesce, composition, complement, etc. are borrowed from pycute.
https://github.com/NVIDIA/cutlass/blob/6dd13d42784ee5bfa232d2441e6b9a021c5c6290/python/pycute/layout.py#L137,L257
Note this is a CuTe-inspired layout, because CuTe uses co-lexicographic way in linearization while PyTorch
is using lexicographic. So even though the CuTe documentation can still be referenced, the implementation will be
different from that of PyCute's.
"""
# pyrefly: ignore [bad-override]
shape: IntTuple
# pyrefly: ignore [bad-override]
stride: IntTuple
def __post_init__(self) -> None:
if not is_tuple(self.shape) and not is_int(self.shape):
raise TypeError(f"shape must be a tuple or int, got {type(self.shape)}")
if not is_tuple(self.stride) and not is_int(self.stride):
raise TypeError(f"stride must be a tuple or int, got {type(self.stride)}")
if not match_structure(self.shape, self.stride):
raise ValueError(
f"sizes {self.shape} and strides {self.stride} don't match"
)
@property
def sizes(self) -> IntTuple:
return self.shape
@property
def strides(self) -> IntTuple:
return self.stride
@property
def sizes_and_strides(self) -> Iterator[tuple[int, int]]:
return zip(flatten(self.shape), flatten(self.stride))
@property
def top_level_sizes(self) -> tuple[int, ...]:
return tuple(self[i].numel() for i in range(len(self)))
def numel(self) -> int:
return math.prod(flatten(self.shape))
# # operator [] (get-i like tuples)
def __getitem__(self, i: int) -> "_MeshLayout":
if i < -len(self) or i >= len(self):
raise IndexError(
f"Dim {i} is out of range for layout with {len(self)} dimensions. "
f"Expected dim to be in range [{-len(self)}, {len(self) - 1}]."
)
layout = super().__getitem__(i)
return _MeshLayout(layout.shape, layout.stride)
def nest(self) -> "_MeshLayout":
return _MeshLayout((self.shape,), (self.stride,))
def coalesce(self) -> "_MeshLayout":
"""
A layout is represented by (sizes):(strides), e.g. (3,2):(4,2).
Two consecutive dimensions can be "merged" into one if their
strides are contiguous/multiplicative (i.e., the inner stride * inner size
equals the next stride), we perform this kind of merge inside coalesce.
Example 1 (simple): (3,2):(2,1)
- inner dimension: has stride=1, size=2
- outer dimension: stride = inner_stride * inner_size = 2
→ coalesced = (6:1) # acts like a flat 1D array of length 6
Example 2 (non-coalescible): (3,2):(4,1)
- inner dimension: stride=1, size=2 → 2*1 = 2
- outer dimension: stride=4, mismatch (≠ 2)
→ cannot merge; result stays (3,2):(4,1)
"""
layout = coalesce(self)
# The original PuCute coalesce() will use stride=0 for size=1 for all dimension.
# We don't want to do that in device mesh, we will reset them to be 1 to be same as PyTorch.
if is_int(layout.stride) and layout.stride == 0:
return _MeshLayout(layout.shape, 1)
elif is_tuple(layout.stride) and any(s == 0 for s in layout.stride):
non_zero_strides = tuple(s if s != 0 else 1 for s in layout.stride)
return _MeshLayout(layout.shape, non_zero_strides)
else:
return _MeshLayout(layout.shape, layout.stride)
def composition(self, layout: "_MeshLayout") -> "_MeshLayout":
"""
By-dimension composition allows one layout to "select from" or "filter through" another layout.
Think of it as function composition: (self ∘ layout)(input) = self(layout(input))
between two layouts. This function is a wrapper of pycute's composition.
Mental model about how to understand the composition logic:
- The LEFT layout (self) defines the "output space" - what indices are possible
- The RIGHT layout (layout parameter) acts as a "selector" - which specific indices to pick
- The composition only generates indices that the left layout could originally produce,
but the right layout determines which indices to be picked.
- The stride of the composition layout will not be smaller than the stride of the right layout,
because when picking the indices the composition will at least follow the the right layout's stride
to move forward.
Example:
self = (6,2):(2,1) # sizes=(6,2), strides=(2,1)
layout = (3:2) # sizes=(3,), stride=(2,)
self o layout = (3:2)
Returns:
Layout being composed.
"""
result = composition(self, layout)
return _MeshLayout(result.shape, result.stride)
def complement(self, world_size: int) -> "_MeshLayout":
"""
Compute the "complement layout" relative to a given world_size.
A complement layout fills in the "missing" factor so that: self repeat a layout of complement(self, world_size)
will get a complete world_size. We use ⊗ to denote the repeat operation.
Example:
self = (4:1) # size=4, stride=1
world_size = 8
Then:
complete needed factor = 8 / 4 = 2
complement(self, 8) = (2:1)
Together they form:
(4:1) ⊗ (2:1) = (4,2):(2,1)
which has world_size = 4 * 2 = 8, as required.
In distributed terms, complement() is often used to derive the "other"
rank grouping when splitting processes into 2D meshes.
For a visualized explanation, see https://x.com/ezyang/status/1962364978393981433/
"""
layout = complement(self, world_size)
return _MeshLayout(layout.shape, layout.stride)
def splice(self, start: int, end: int, layout: "_MeshLayout") -> "_MeshLayout":
sizes = list(as_tuple(self.sizes))
strides = list(as_tuple(self.strides))
sizes[start:end] = list(as_tuple(layout.sizes))
strides[start:end] = list(as_tuple(layout.strides))
return _MeshLayout(tuple(sizes), tuple(strides))
def all_ranks_from_zero(self) -> list[int]:
"""
This function computes the all ranks specified by the layout staring from zero.
How it works:
1. we enumerates every possible coordinate (like a nested for-loop).
If sizes = (2, 3), we get the following coordinates:
(0,0), (0,1), (0,2), (1,0), (1,1), (1,2)
2. For each coordinate, we compute a linear rank index as:
all_ranks_from_zero = sum(coord[i] * strides[i] for i in range(ndim))
Example A:
sizes = (2, 3) # 2 rows, 3 cols
strides = (3, 1) # row-major layout
coords = (0,0) -> 0*3 + 0*1 = 0
(0,1) -> 0*3 + 1*1 = 1
(0,2) -> 0*3 + 2*1 = 2
(1,0) -> 1*3 + 0*1 = 3
(1,1) -> 1*3 + 1*1 = 4
(1,2) -> 1*3 + 2*1 = 5
result = [0, 1, 2, 3, 4, 5]
Example B:
sizes = (2, 3)
strides = (1, 2) # non-standard / strided layout
coords = (0,0) -> 0*1 + 0*2 = 0
(0,1) -> 0*1 + 1*2 = 2
(0,2) -> 0*1 + 2*2 = 4
(1,0) -> 1*1 + 0*2 = 1
(1,1) -> 1*1 + 1*2 = 3
(1,2) -> 1*1 + 2*2 = 5
result = [0, 2, 4, 1, 3, 5]
"""
return [
sum(c * s for c, s in zip(coord, flatten(self.strides)))
for coord in product(*(range(s) for s in flatten(self.sizes)))
]
def global_ranks(self, world_size: int) -> list[list[int]]:
"""
Build global ranks specified by the layout via two-level ranks composition.
The nested list forms the Cartesian product of all ranks for one layout and offset
regarding filling up the world_size with the layout.
The final global ranks are the addition of these two. The result is a
list of lists: one sublist per layout. This rank list will be used to build
the communicator underlying the layout and the given `world_size`.
Example:
world_size = 16
self.size = 4
self.stride = 1
ranks = [0, 1, 2, 3]
offsets = [0, 4, 8, 12]
result = [
[0+0, 0+1, 0+2, 0+3], # → [0, 1, 2, 3]
[4+0, 4+1, 4+2, 4+3], # → [4, 5, 6, 7]
[8+0, 8+1, 8+2, 8+3], # → [8, 9, 10,11]
[12+0, 12+1, 12+2, 12+3], # → [12,13,14,15]
]
"""
return [
[offset + rank for rank in self.all_ranks_from_zero()]
for offset in self.complement(world_size).all_ranks_from_zero()
]
def check_non_overlap(self) -> bool:
"""
Check if the layout has any overlap between the ranks it generates. If there is overlap,
we return False, otherwise True.
The layout is supposed to be injective i.e, aside from indice 0, indices from each
dim of the layout must be non-overlapping.
Example 1 - Valid (no overlap):
Layout: sizes=(2,3), strides=(6,1)
- Dim 1: stride=1, span=3*1=3, covers indices [0,1,2]
- Dim 0: stride=6, span=2*6=12, covers indices [0,6]
→ No overlap since 6 > 3
Example 2 - Invalid (overlap):
Layout: sizes=(2,3), strides=(2,1)
- Dim 1: stride=1, span=3*1=3, covers indices [0,1,2]
- Dim 0: stride=2, span=2*2=4, covers indices [0,2]
→ Overlap! stride=2 < span=3, so indices [0,2] are duplicated
Example 3 - Invalid (overlap):
Layout: sizes=(4,2), strides=(1,1)
- Dim 1: stride=1, span=4, covers indices [0,1,2,3]
- Dim 0: stride=1, span=2, covers indices [0,1]
→ Overlap! stride is same for two dims, so indices [0,2] are duplicated
Returns:
bool: True if no overlap, False if overlap detected
"""
ranks = self.all_ranks_from_zero()
return len(ranks) == len(set(ranks))
def remap_to_tensor(self, rank_map: torch.Tensor) -> torch.Tensor:
"""
Leverage layout as an index for mesh tensor that re-maps the indexes after layout
transformation to actual device ranks.
With this method, the cute layout serves as the backend of indices bookkeeping for the
mesh tensor when it comes to flatten, unflatten and slicing operations. The actual mesh
tensor still represents the actual device assignment and ranks. We need this function
to specify device allocation and create backend for a mesh. Although any transform of mesh tensors
can be treated as a view or subset of mesh tensor, we do need to use the actual view or
sub-tensor for DeviceMesh and its backend creation.
The shape of the `rank_map` must be 1D and contiguous.
Examples:
Case 1 - Consecutive ranks, full world:
original_mesh_tensor = [[0,1],[2,3]] # 2x2 mesh, ranks 0-3
world_size = 4
layout = Layout(2:2)
Return: [[0,2],[1,3]]
Case 2 - Non-consecutive ranks:
original_mesh_tensor = [[10,20],[30,40]] # custom rank assignment
world_size = 4
layout = Layout(2:2)
Return: [[[10,30],[20,40]]]
Args:
rank_map: The concrete mesh tensor with actual device ranks
Returns:
torch.Tensor: A tensor representing the actual device allocation from rank_map
"""
if rank_map.ndim != 1:
raise AssertionError
if not rank_map.is_contiguous():
raise AssertionError
if rank_map.numel() < self.cosize():
raise AssertionError
complement_layout = self.complement(rank_map.numel())
return rank_map.as_strided(
flatten(complement_layout.sizes) + flatten(self.sizes),
flatten(complement_layout.strides) + flatten(self.strides),
).reshape(-1, *self.top_level_sizes)
@@ -0,0 +1,55 @@
import random
import torch
from torch._C._distributed_c10d import FakeWork
used_ids: set[int] = set()
def generate_unique_id() -> int:
while True:
new_id = random.randint(1, 10**9)
if new_id not in used_ids:
used_ids.add(new_id)
return new_id
# Function to create and return FakeWork object
def create_fakework(args, return_first_arg=True): # type: ignore[no-untyped-def]
work = FakeWork()
work.seq_id = generate_unique_id()
fakework_script_obj = work.boxed()
return (args[0], fakework_script_obj) if return_first_arg else fakework_script_obj
# Dictionary mapping collective operations to their meta functions
# All 20 ops from torch.csrc.distributed.c10d.Ops.cpp are included
# _DEPRECATED_META_FUNCTIONS = {
# "allreduce_coalesced_": lambda *args: create_fakework(args, return_first_arg=False),
# "allgather_coalesced_": lambda *args: create_fakework(args, return_first_arg=False),
# "allgather_into_tensor_coalesced_": lambda *args: create_fakework(args, return_first_arg=False),
# "reduce_scatter_tensor_coalesced_": lambda *args: create_fakework(args, return_first_arg=False),
# }
_META_FUNCTIONS = {
"broadcast_": lambda *args: create_fakework(args),
"allreduce_": lambda *args: create_fakework(args),
"allgather_": lambda *args: create_fakework(args),
"_allgather_base_": lambda *args: create_fakework(args),
"reduce_scatter_": lambda *args: create_fakework(args),
"_reduce_scatter_base_": lambda *args: create_fakework(args),
"reduce_": lambda *args: create_fakework(args, return_first_arg=False),
"gather_": lambda *args: create_fakework(args, return_first_arg=False),
"scatter_": lambda *args: create_fakework(args),
"alltoall_": lambda *args: create_fakework(args),
"alltoall_base_": lambda *args: create_fakework(args, return_first_arg=False),
"barrier": lambda *args: create_fakework(args, return_first_arg=False),
"monitored_barrier_": lambda *args: None,
"send": lambda *args: create_fakework(args, return_first_arg=False),
"recv_": lambda *args: create_fakework(args, return_first_arg=False),
"recv_any_source_": lambda *args: create_fakework(args, return_first_arg=False),
}
lib_impl = torch.library.Library("c10d", "IMPL") # noqa: TOR901
for op, meta_func in _META_FUNCTIONS.items():
lib_impl.impl(op, meta_func, "Meta")
@@ -0,0 +1 @@
# Copyright (c) Meta Platforms, Inc. and affiliates
@@ -0,0 +1,105 @@
# Copyright (c) Meta Platforms, Inc. and affiliates
import torch
from torch import SymInt
from ..device_mesh import DeviceMesh
# Register custom operator
torch.library.define(
"device_mesh::_runtime_compute_coordinate_on_dim",
"(Tensor full_mesh, int index) -> SymInt",
tags=torch.Tag.pt2_compliant_tag,
)
@torch.library.register_fake("device_mesh::_runtime_compute_coordinate_on_dim")
def _runtime_compute_coordinate_on_dim_fake(
full_mesh: torch.Tensor, index: int
) -> SymInt:
from torch.fx.experimental.symbolic_shapes import _constrain_range_for_size
ctx = torch._custom_op.impl.get_ctx()
shape_env = ctx._shape_env
# Bypass allow_dynamic_output_shape_ops check by directly creating the symint.
# This is intentional - the coordinate is always valid and bounded.
sz = shape_env.create_unbacked_symint()
# Apply size constraints - coordinate is bounded by mesh size on the given dimension.
# The full_mesh tensor has an extra batch dimension at the front, so the actual
# mesh dimensions start at index 1. mesh.size(index) = full_mesh.size(index + 1)
mesh_size = full_mesh.size(index + 1)
_constrain_range_for_size(
sz, min=0, max=mesh_size - 1 if isinstance(mesh_size, int) else None
)
try:
# Check if we're currently tracing in dynamo (as opposed to AOT or export).
in_dynamo = torch._dynamo.symbolic_convert.InstructionTranslator.current_tx()
except AttributeError:
in_dynamo = False
if in_dynamo:
# During dynamo tracing, distributed ops are treated as atomic - so the
# rank SymInt may be computed but not traced into the graph (e.g., it
# affects tensor values but not shapes). Mark it as ignorable here;
# when we decompose these ops later (after dynamo), we'll create fresh
# SymInts that do get traced.
shape_env.ignorable_fresh_unbacked_symbols.append(sz.node._expr)
return sz
@torch.library.impl(
"device_mesh::_runtime_compute_coordinate_on_dim", "CompositeExplicitAutograd"
)
def _runtime_compute_coordinate_on_dim_impl(full_mesh: torch.Tensor, index: int) -> int:
rank = torch.distributed.get_rank()
mesh = DeviceMesh._get_mesh_tensor_from_full_mesh(full_mesh)
mesh_coords = DeviceMesh._compute_coordinates_from_mesh(mesh, rank)
if mesh_coords is None:
raise AssertionError
return mesh_coords[index]
def _get_flattened_submesh_impl(mesh: DeviceMesh, mesh_dims: list[int]) -> DeviceMesh:
from torch.distributed.tensor._redistribute import (
_get_flattened_mesh_by_layout_impl,
)
result = _get_flattened_mesh_by_layout_impl(mesh, tuple(mesh_dims))
if result is None:
raise ValueError(f"No flattened mesh found for mesh_dims={mesh_dims} on {mesh}")
return result
@torch.library.custom_op("device_mesh::_get_flattened_submesh", mutates_args=())
def _get_flattened_submesh(mesh: DeviceMesh, mesh_dims: list[int]) -> DeviceMesh:
return _get_flattened_submesh_impl(mesh, mesh_dims)
@_get_flattened_submesh.register_fake
def _get_flattened_submesh_fake(mesh: DeviceMesh, mesh_dims: list[int]) -> DeviceMesh:
return _get_flattened_submesh_impl(mesh, mesh_dims)
def _get_submesh_impl(mesh: DeviceMesh, mesh_dims: list[int]) -> DeviceMesh:
all_dim_names = mesh._mesh_dim_names
if all_dim_names is None:
raise ValueError(f"Cannot slice mesh without dim names: {mesh}")
dim_names = tuple(all_dim_names[i] for i in mesh_dims)
if len(dim_names) == 1:
return mesh[dim_names[0]]
return mesh[dim_names]
@torch.library.custom_op("device_mesh::_get_submesh", mutates_args=())
def _get_submesh(mesh: DeviceMesh, mesh_dims: list[int]) -> DeviceMesh:
return _get_submesh_impl(mesh, mesh_dims)
@_get_submesh.register_fake
def _get_submesh_fake(mesh: DeviceMesh, mesh_dims: list[int]) -> DeviceMesh:
return _get_submesh_impl(mesh, mesh_dims)
@@ -0,0 +1,74 @@
#################################################################################################
#
# Copyright (c) 2023 - 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
#################################################################################################
from .int_tuple import (
as_tuple,
crd2crd,
crd2idx,
elem_scale,
flatten,
has_none,
idx2crd,
inner_product,
IntTuple,
is_int,
is_tuple,
match_structure,
product,
shape_div,
signum,
slice_,
suffix_product,
tuple_max,
)
from .layout import (
coalesce,
complement,
composition,
cosize,
filter,
is_layout,
Layout,
LayoutBase,
left_inverse,
logical_divide,
logical_product,
make_layout,
right_inverse,
size,
slice_and_offset,
tiled_divide,
tiled_product,
zipped_divide,
zipped_product,
)
from .typing import Integer
@@ -0,0 +1,285 @@
#################################################################################################
#
# Copyright (c) 2023 - 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
#################################################################################################
"""
Functions for manipulating IntTuples
"""
from functools import reduce
from itertools import chain
from typing import TypeAlias
from typing_extensions import TypeIs
from .typing import Integer
# Type aliases for better readability
IntTuple: TypeAlias = int | tuple["IntTuple", ...]
def is_int(x: object) -> TypeIs[int]:
return isinstance(x, Integer)
def is_tuple(x: object) -> TypeIs[tuple]:
return isinstance(x, tuple)
def as_tuple(x: IntTuple) -> tuple[IntTuple, ...]:
if is_int(x):
return (x,)
return x
def match_structure(a: IntTuple, b: IntTuple) -> bool:
if is_int(a) and is_int(b):
return True
if is_tuple(a) and is_tuple(b):
return len(a) == len(b) and all(match_structure(x, y) for x, y in zip(a, b))
return False
def flatten(t: IntTuple) -> tuple[int, ...]:
if is_tuple(t):
if len(t) == 0:
return ()
else:
return tuple(i for a in t for i in flatten(a))
else:
return (t,)
def signum(a: int) -> int:
return bool(a > 0) - bool(a < 0)
def product(a: IntTuple) -> int:
if is_tuple(a):
return reduce(lambda val, elem: val * product(elem), a, 1)
else:
return a
def inner_product(a: IntTuple, b: IntTuple) -> int:
if is_tuple(a) and is_tuple(b): # tuple tuple
if len(a) != len(b):
raise AssertionError
return sum(inner_product(x, y) for x, y in zip(a, b))
else: # "int" "int"
if is_tuple(a) or is_tuple(b):
raise AssertionError
return a * b
def tuple_max(a: IntTuple) -> int:
if is_tuple(a):
return max(tuple_max(x) for x in a)
else:
return a
def elem_scale(a: IntTuple, b: IntTuple) -> IntTuple:
if is_tuple(a):
if is_tuple(b): # tuple tuple
if len(a) != len(b):
raise AssertionError
return tuple(elem_scale(x, y) for x, y in zip(a, b))
else: # tuple "int"
raise AssertionError("Invalid combination: tuple with int")
else:
if is_tuple(b): # "int" tuple
return elem_scale(a, product(b))
else: # "int" "int"
return a * b
# Inclusive prefix ceil div with output congruent to input a
def shape_div(a: IntTuple, b: IntTuple) -> IntTuple:
if is_tuple(a):
if is_tuple(b): # tuple tuple
if len(a) != len(b):
raise AssertionError
return tuple(shape_div(x, y) for x, y in zip(a, b))
else: # tuple "int"
# r = [shape_div(a[0],b)] + [shape_div(a[i],b := shape_div(b, product(a[i-1]))) for i in range(1,len(a))]
r = []
for v in a:
r.append(shape_div(v, b))
b = shape_div(b, product(v))
return tuple(r)
else:
if is_tuple(b): # "int" tuple
return shape_div(a, product(b))
else: # "int" "int"
if not (a % b == 0 or b % a == 0):
raise AssertionError
return (a + b - 1) // b
# Exclusive suffix product with output congruent to input a (lexicographic)
def suffix_product(a: IntTuple, init: IntTuple = 1) -> IntTuple:
# TODO: With all these length asserts, may want to create a zip_strict wrapper.
if is_tuple(a):
if is_tuple(init): # tuple tuple
if len(a) != len(init):
raise AssertionError
return tuple(suffix_product(x, i) for x, i in zip(a, init))
else: # tuple "int"
# Process from right to left for lexicographic ordering
# r = [prefix_product(a[len(a)-1],init)] +
# [prefix_product(a[i],init := init * product(a[i+1])) for i in range(len(a)-1,0)].reverse()
r = []
# Calculate products from right to left, appending to list
for i in range(len(a) - 1, -1, -1):
r.append(suffix_product(a[i], init))
init = init * product(a[i])
# Reverse to get correct lexicographic order
r.reverse()
return tuple(r)
else:
if is_tuple(init): # "int" tuple
raise AssertionError("Invalid combination: int with tuple init")
else: # "int" "int"
return init
def idx2crd(idx: IntTuple, shape: IntTuple, stride: IntTuple | None = None) -> IntTuple:
if stride is None:
stride = suffix_product(shape)
if is_tuple(idx):
if is_tuple(shape) and is_tuple(stride): # tuple tuple tuple
if not (len(idx) == len(shape) and len(stride) == len(shape)):
raise AssertionError
return tuple(idx2crd(i, s, d) for i, s, d in zip(idx, shape, stride))
else: # tuple "int" "int"
raise AssertionError("Invalid combination: tuple with int stride")
else:
if is_tuple(shape) and is_tuple(stride): # "int" tuple tuple
if len(shape) != len(stride):
raise AssertionError
return tuple(idx2crd(idx, s, d) for s, d in zip(shape, stride))
else: # "int" "int" "int"
if is_tuple(shape) or is_tuple(stride):
raise AssertionError
return (idx // stride) % shape # all are ints after type checks
def crd2idx(
crd: IntTuple | None, shape: IntTuple, stride: IntTuple | None = None
) -> int:
if stride is None:
stride = suffix_product(shape)
if is_tuple(crd):
if is_tuple(shape) and is_tuple(stride): # tuple tuple tuple
if not (len(crd) == len(shape) and len(stride) == len(shape)):
raise AssertionError
return sum(crd2idx(c, s, d) for c, s, d in zip(crd, shape, stride))
else: # tuple "int" "int"
raise AssertionError(f"Invalid combination: crd={crd}, shape={shape}")
else:
if crd is None:
crd = 0
if is_tuple(shape) and is_tuple(stride): # "int" tuple tuple
if len(shape) != len(stride):
raise AssertionError
result = 0
# Process from right to left for lexicographic ordering
for i in range(len(shape) - 1, 0, -1):
result += crd2idx(crd % product(shape[i]), shape[i], stride[i])
crd = crd // product(shape[i])
if len(shape) > 0:
result += crd2idx(crd, shape[0], stride[0])
return result
else: # "int" "int" "int"
if is_tuple(shape) or is_tuple(stride):
raise AssertionError
return crd * stride # all are ints after type checks
# Transform crd into the dst_shape's iteration space
def crd2crd(
crd: IntTuple, dst_shape: IntTuple, src_shape: IntTuple | None = None
) -> IntTuple:
if is_tuple(crd):
if is_tuple(dst_shape): # tuple tuple
if len(crd) != len(dst_shape):
raise AssertionError
return tuple(crd2crd(x, y) for x, y in zip(crd, dst_shape))
else: # tuple "int"
# Ambiguous unless we have src_shape
if src_shape is None:
raise AssertionError
return crd2idx(crd, src_shape)
else:
if is_tuple(dst_shape): # "int" tuple
return idx2crd(crd, dst_shape)
else: # "int" "int"
if crd >= dst_shape:
raise AssertionError
return crd
# Filter trg according to crd: keep only elements of trg that are paired with None
def slice_(crd: tuple | int | None, trg: tuple | int) -> tuple | int:
if is_tuple(crd):
if is_tuple(trg): # tuple tuple
if len(crd) != len(trg):
raise AssertionError
# match C++ behavior of `filter_tuple` using `tuple_cat(...)`
return tuple(
chain(
*filter( # type: ignore[arg-type] # filter returns Iterator which is compatible
lambda x: x != (),
[slice_(c, s) for c, s in zip(crd, trg)],
)
)
)
else:
raise AssertionError("Invalid combination: tuple crd with int trg")
elif crd is None:
# match C++ behavior `return cute::tuple<B>{b};`
return (trg,)
else:
return ()
# Determine if None appears at any of an int_tuples' terminals
def has_none(a: tuple | int | None) -> bool:
if is_tuple(a):
return any(has_none(v) for v in a)
else:
return a is None
@@ -0,0 +1,484 @@
#################################################################################################
#
# Copyright (c) 2023 - 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
#################################################################################################
"""
Definition of CuTe Layouts and functions to manipulate them which works with the order
of lexicographic instead of co-lexicographic as implemented in the original layout.py
"""
from itertools import chain
from typing import TypeAlias
from typing_extensions import Self, TypeIs
from .int_tuple import (
crd2idx,
flatten,
has_none,
IntTuple,
is_int,
is_tuple,
product,
slice_,
suffix_product,
)
# Type aliases
CoordinateType: TypeAlias = (
int | IntTuple | tuple[object, ...] | None
) # Input for slice_ and crd2idx functions
class LayoutBase:
pass
def is_layout(x: object) -> TypeIs["Layout"]:
return isinstance(x, LayoutBase)
class Layout(LayoutBase):
def __init__(self, _shape: IntTuple, _stride: IntTuple | None = None) -> None:
self.shape = _shape
if _stride is None:
self.stride = suffix_product(self.shape)
else:
self.stride = _stride
# operator ==
def __eq__(self, other: object) -> bool:
if not isinstance(other, Layout):
return False
return self.shape == other.shape and self.stride == other.stride
# operator len(L) (len [rank] like tuples)
def __len__(self) -> int:
if is_tuple(self.shape):
return len(self.shape)
else:
return 1
# operator () (map coord to idx)
def __call__(self, *args: CoordinateType) -> Self | int:
"""
Map a logical coordinate to a linear index (Coord has no Underscore slice operators)
OR
Slice the layout and return the sublayout (Coord has an Underscore slice op)
Follow the same behavior of `Layout::operator(Coord const&)` in cute C++
"""
if has_none(args):
if len(args) == 1:
return Layout(slice_(args[0], self.shape), slice_(args[0], self.stride))
else:
return Layout(slice_(args, self.shape), slice_(args, self.stride))
else:
if len(args) == 1:
return crd2idx(args[0], self.shape, self.stride) # type: ignore[arg-type]
else:
return crd2idx(args, self.shape, self.stride) # type: ignore[arg-type]
# operator [] (get-i like tuples)
def __getitem__(self, i: int) -> Self:
if is_tuple(self.shape):
return Layout(self.shape[i], self.stride[i]) # type: ignore[index]
else:
if i != 0:
raise AssertionError
return Layout(self.shape, self.stride)
# size(layout) Size of the domain
def size(self) -> int:
return product(self.shape)
# cosize(layout) Size of the codomain
def cosize(self) -> int:
return self(self.size() - 1) + 1 # type: ignore[operator]
# print and str
def __str__(self) -> str:
return f"{self.shape}:{self.stride}"
# error msgs and representation
def __repr__(self) -> str:
return f"Layout({self.shape},{self.stride})"
# Type aliases
LayoutOrIntTuple: TypeAlias = Layout | IntTuple
LayoutProfile: TypeAlias = tuple[object, ...] | Layout | None
LayoutInput: TypeAlias = Layout | IntTuple | tuple[object, ...] | None
# Make Layout from a list of layouts (each layout it's own mode in the result)
def make_layout(*layouts: Layout | tuple[Layout, ...]) -> Layout:
if len(layouts) == 1 and not is_layout(layouts[0]):
layouts = layouts[0]
shape, stride = zip(*((a.shape, a.stride) for a in layouts)) # type: ignore[union-attr]
return Layout(shape, stride)
# Size of the domain
def size(layout: LayoutOrIntTuple) -> int:
if is_layout(layout):
return layout.size()
return product(layout)
# Size of the codomain
def cosize(layout: Layout) -> int:
return layout.cosize()
# Layout coalesce -- flatten and combine as many modes as possible while preserving the int-to-int function
def coalesce(layout: Layout, profile: LayoutProfile = None) -> Layout:
if is_tuple(profile):
if len(layout) < len(profile):
raise AssertionError
return make_layout(
# pyrefly: ignore [bad-argument-type]
chain(
(coalesce(layout[i], profile[i]) for i in range(len(profile))), # type: ignore[arg-type]
(layout[i] for i in range(len(profile), len(layout))),
)
)
result_shape = [1]
result_stride = [0]
# Since we now follow lexicographic order, we need to process from right to left.
# And to make implementation more efficient, we append to the end of list and reverse it in the end.
for shape, stride in zip(
reversed(flatten(layout.shape)), reversed(flatten(layout.stride))
):
# skip their shape-1s
if shape == 1:
continue
# replace our shape-1 with anything
elif result_shape[-1] == 1:
result_shape[-1] = shape
result_stride[-1] = stride
# merge modes if the shape*stride match
elif result_shape[-1] * result_stride[-1] == stride:
result_shape[-1] = result_shape[-1] * shape
# append a new mode
else:
result_shape.append(shape)
result_stride.append(stride)
if len(result_shape) == 1:
return Layout(result_shape[0], result_stride[0])
else:
result_shape.reverse()
result_stride.reverse()
return Layout(tuple(result_shape), tuple(result_stride))
# Layout filter -- replace all stride-0 modes with size-1 and then coalesce to remove them
def filter(layout: Layout, profile: LayoutProfile = None) -> Layout:
if is_tuple(profile):
if len(layout) < len(profile):
raise AssertionError
return make_layout(
# pyrefly: ignore [bad-argument-type]
chain(
(filter(layout[i], profile[i]) for i in range(len(profile))), # type: ignore[arg-type]
(layout[i] for i in range(len(profile), len(layout))),
)
)
result_shape = []
result_stride = []
for shape, stride in zip(flatten(layout.shape), flatten(layout.stride)):
# skip their shape-1s and stride-0s
if not (shape == 1 or stride == 0):
result_shape.append(shape)
result_stride.append(stride)
if len(result_shape) == 0:
return Layout(1, 0)
else:
return coalesce(Layout(tuple(result_shape), tuple(result_stride)))
# Layout composition
# Use tuples-of-layouts to perform this operation by-mode and None as no-op
def composition(layoutA: Layout, layoutB: LayoutInput) -> Layout:
if layoutB is None:
return layoutA
elif is_int(layoutB):
return composition(layoutA, Layout(layoutB))
elif is_tuple(layoutB):
if len(layoutA) < len(layoutB):
raise AssertionError
return make_layout(
# pyrefly: ignore [bad-argument-type]
chain(
(composition(layoutA[i], layoutB[i]) for i in range(len(layoutB))), # type: ignore[arg-type]
(layoutA[i] for i in range(len(layoutB), len(layoutA))),
)
)
elif is_tuple(layoutB.shape):
return make_layout(composition(layoutA, layoutB_i) for layoutB_i in layoutB) # type: ignore[arg-type, attr-defined]
if layoutB.stride == 0:
return Layout(layoutB.shape, 0)
else:
result_shape = []
result_stride = []
rest_shape = layoutB.shape
rest_stride = layoutB.stride
flat_A = coalesce(layoutA)
# when left layout is multi-dimensional sublayout, aka, self = (a,b,...,c):(x,y,...,z), layout = s:d,
# for integral s and d means that we want:
# (1) “remove” the first d elements from left, starting from rightmost. (This will increase the stride.)
# (2) “keep” the first s of those strided elements. (This does not affect the stride.)
# For example, if self = (6,2):(2,1), layout = (3:2)
# Step 1: remove the first 2 elements from self with stride increase, i.e., (6,2):(2,1) -> (6,1):(2,2)
# Step 2: keep the first 3 of those strided elements, i.e., (6,1):(2,2) -> (3,1):(2,2)
# Because we are going lexicographically, we go through left layout from right to left.
for curr_shape, curr_stride in zip(
reversed(flatten(flat_A.shape)[1:]), reversed(flatten(flat_A.stride)[1:])
):
if not (curr_shape % rest_stride == 0 or rest_stride % curr_shape == 0): # type: ignore[operator]
raise AssertionError
new_shape = min(max(1, curr_shape // rest_stride), rest_shape) # type: ignore[operator]
if new_shape != 1:
result_shape.append(new_shape) # Append to end, will reverse later
result_stride.append(rest_stride * curr_stride)
rest_shape = rest_shape // new_shape # type: ignore[operator]
rest_stride = -(
-rest_stride // curr_shape # type: ignore[operator]
) # Python exclusive impl: "//" is always floor div so == ceil_div(abs(rest_stride), curr_shape) * signum(rest_stride)
# When left has single-size sublayout or reach the last sublayout, aka, left = a:b, layout = s:d,
# the result is rather trivial: left o layout = a:b o s:d = s:(b*d).
# For example, if self = (6:2), layout = (3:2), the result is (3:(2*2)) = (3:4).
if rest_shape != 1 or len(result_shape) == 0:
result_shape.append(rest_shape) # Append to end, will reverse later
result_stride.append(rest_stride * flatten(flat_A.stride)[0])
# Reverse the lists because we build lists in reverse order (append to end), this way it is more efficient.
result_shape.reverse()
result_stride.reverse()
if len(result_shape) == 1:
return Layout(result_shape[0], result_stride[0]) # type: ignore[arg-type]
else:
return Layout(tuple(result_shape), tuple(result_stride)) # type: ignore[arg-type]
# Layout complement
def complement(layout: LayoutOrIntTuple, max_idx: int = 1) -> Layout:
if is_int(layout):
return complement(Layout(layout))
result_shape = []
result_stride = []
current_idx = 1
sorted_DS = sorted(zip(flatten(layout.stride), flatten(layout.shape))) # type: ignore[union-attr]
for stride, shape in sorted_DS:
if stride == 0 or shape == 1:
continue
in_bound = current_idx <= shape * stride
# To support symbolic value which can't be evaluated now
if (type(in_bound) is bool) and not in_bound:
raise AssertionError
result_shape.append(stride // current_idx)
result_stride.append(current_idx)
current_idx = shape * stride
result_shape.append((max_idx + current_idx - 1) // current_idx) # ceil_div
result_stride.append(current_idx)
# This is different from original pycute implementation, because we want to follow the lexicographic order here
# where the right-most dimension is the innermost dimension (smallest stride).
result_shape.reverse()
result_stride.reverse()
return coalesce(Layout(tuple(result_shape), tuple(result_stride)))
# Layout right inverse
def right_inverse(layout: LayoutOrIntTuple | None) -> Layout | None:
if layout is None:
return None
elif is_int(layout):
return Layout(layout)
result_shape = []
result_stride = []
current_idx = 1
flat_shape = flatten(layout.shape) # type: ignore[union-attr]
flat_stride = flatten(layout.stride) # type: ignore[union-attr]
sorted_DSA = sorted(zip(flat_stride, flat_shape, suffix_product(flat_shape))) # type: ignore[arg-type]
for stride, shape, rstride in sorted_DSA:
if shape == 1:
continue
if current_idx != stride:
break
result_shape.append(shape)
result_stride.append(rstride)
current_idx = shape * stride
result_shape.reverse()
result_stride.reverse()
return coalesce(Layout(tuple(result_shape), tuple(result_stride)))
# Layout left inverse
def left_inverse(layout: LayoutOrIntTuple | None) -> Layout | None:
if layout is None:
return None
elif is_int(layout):
return Layout(layout)
return right_inverse(make_layout(complement(layout), layout)) # type: ignore[arg-type]
# Split a layout by the composition of B and the "rest"
# Use tuples-of-layouts to perform this operation by-mode and None as no-op
def logical_divide(layoutA: Layout, layoutB: LayoutInput) -> Layout:
if layoutB is None:
return layoutA
elif is_int(layoutB):
return logical_divide(layoutA, Layout(layoutB))
elif is_tuple(layoutB):
if len(layoutA) < len(layoutB):
raise AssertionError
return make_layout(
# pyrefly: ignore [bad-argument-type]
chain(
(
logical_divide(layoutA[i], layoutB[i]) # type: ignore[arg-type]
for i in range(len(layoutB))
),
(layoutA[i] for i in range(len(layoutB), len(layoutA))),
)
)
return composition(
layoutA,
make_layout(layoutB, complement(layoutB, size(layoutA))),
)
# Reproduce a layoutA over a layoutB
# Use tuples-of-layouts to perform this operation by-mode and None as no-op
def logical_product(layoutA: Layout, layoutB: LayoutInput) -> Layout:
if layoutB is None:
return layoutA
elif is_int(layoutB):
return logical_divide(layoutA, Layout(layoutB))
elif is_tuple(layoutB):
if len(layoutA) < len(layoutB):
raise AssertionError
return make_layout(
# pyrefly: ignore [bad-argument-type]
chain(
(
logical_product(layoutA[i], layoutB[i]) # type: ignore[arg-type]
for i in range(len(layoutB))
),
(layoutA[i] for i in range(len(layoutB), len(layoutA))),
)
)
return make_layout(
layoutA,
composition(complement(layoutA, size(layoutA) * cosize(layoutB)), layoutB),
)
# Gather the modes from a hierarchical logical_divide or logical_product
def hier_unzip(
splitter: object,
layoutA: Layout,
layoutB: LayoutInput,
) -> Layout:
if layoutB is None:
return make_layout(Layout(1, 0), layoutA)
elif is_tuple(layoutB):
if len(layoutA) < len(layoutB):
raise AssertionError
# A layout with shape ((A,a),(B,b),(C,c))
split = make_layout(
hier_unzip(splitter, layoutA[i], layoutB[i]) # type: ignore[arg-type]
for i in range(len(layoutB))
)
# Gather to shape ((A,B,C,...),(a,b,c,...,y,z))
return make_layout(
make_layout(split[i][0] for i in range(len(layoutB))), # type: ignore[arg-type]
make_layout(
chain( # type: ignore[arg-type]
(split[i][1] for i in range(len(layoutB))),
(layoutA[i] for i in range(len(layoutB), len(layoutA))),
)
),
)
# splitter must return a rank-2 layout
return splitter(layoutA, layoutB) # type: ignore[operator]
# Apply logical divide hierarchically and gather the split modes into two modes
def zipped_divide(layoutA: Layout, layoutB: LayoutInput) -> Layout:
return hier_unzip(logical_divide, layoutA, layoutB)
# Perform logical divide hierarchically and gather tiles (B-layouts) into a new mode
def tiled_divide(layoutA: Layout, layoutB: LayoutInput) -> Layout:
result = zipped_divide(layoutA, layoutB)
return make_layout([result[0]] + [result[1][i] for i in range(len(result[1]))]) # type: ignore[arg-type]
# Apply logical product hierarchically and gather the split modes into two modes
def zipped_product(layoutA: Layout, layoutB: LayoutInput) -> Layout:
return hier_unzip(logical_product, layoutA, layoutB)
# Perform logical product hierarchically and gather tiles (B-layouts) into a new mode
def tiled_product(layoutA: Layout, layoutB: LayoutInput) -> Layout:
result = zipped_product(layoutA, layoutB)
return make_layout([result[0]] + [result[1][i] for i in range(len(result[1]))]) # type: ignore[arg-type]
def slice_and_offset(crd: tuple[object, ...], layout: Layout) -> tuple[Layout, int]:
return (
Layout(slice_(crd, layout.shape), slice_(crd, layout.stride)),
crd2idx(crd, layout.shape, layout.stride), # type: ignore[arg-type]
)
@@ -0,0 +1,42 @@
#################################################################################################
#
# Copyright (c) 2023 - 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
#################################################################################################
from abc import ABC
class Integer(ABC): # noqa: B024 # Uses __subclasshook__ instead of abstract methods
@classmethod
def __subclasshook__(cls, c: type) -> bool:
if c in [bool, float]:
return False
return issubclass(c, int)
@@ -0,0 +1,158 @@
import pickle
from dataclasses import dataclass
from io import BufferedIOBase
from typing import Any
import torch
import torch._weights_only_unpickler as _weights_only_unpickler
from torch.serialization import _load, _save, DEFAULT_PROTOCOL, MAP_LOCATION
__all__: list[str] = []
@dataclass
class _Entry:
key: str
is_storage: bool
length: int
_weights_only_unpickler._add_safe_globals([_Entry])
class _PseudoZipFile:
def __init__(self) -> None:
self.records: dict[str, tuple[object, int]] = {}
def write_record(self, key: str, data: object, length: int) -> None:
self.records[key] = (data, length)
def write_to(self, f: BufferedIOBase) -> None:
entries = []
for key, (data, length) in self.records.items():
entries.append(
_Entry(
key=key,
is_storage=isinstance(data, torch.UntypedStorage),
length=length,
)
)
pickle.dump(entries, f, protocol=DEFAULT_PROTOCOL)
for data, _ in self.records.values():
if isinstance(data, bytes):
f.write(data)
elif isinstance(data, str):
f.write(data.encode("utf-8"))
elif isinstance(data, torch.UntypedStorage):
data._write_file(f, False, False, 1)
else:
raise TypeError(f"unknown type: {type(data)}")
def read_from(self, f: BufferedIOBase) -> None:
entries = _weights_only_unpickler.load(f)
for entry in entries:
data = f.read(entry.length)
if entry.is_storage:
if entry.length == 0:
storage = torch.UntypedStorage(0)
else:
storage = torch.frombuffer(
data,
dtype=torch.uint8,
).untyped_storage()
self.records[entry.key] = (
storage,
entry.length,
)
else:
self.records[entry.key] = (data, entry.length)
def has_record(self, key: str) -> bool:
return key in self.records
def get_record(self, key: str) -> object:
return self.records[key][0]
def get_storage_from_record(
self, key: str, _length: int, _type: int
) -> torch.Tensor:
return torch.tensor(self.records[key][0], dtype=torch.uint8)
def serialization_id(self) -> str:
return "torchft"
def _streaming_save(
obj: object,
f: BufferedIOBase,
pickle_module: Any = pickle,
pickle_protocol: int = DEFAULT_PROTOCOL,
) -> None:
"""
Save the object to a file-like object in a streaming fashion compatible with
network sockets.
This behaves similarly to :func:`torch.save` with a few notable differences:
* A non-seekable file like object can be used when loading.
* No forwards/backwards compatibility is provided for the serialization
format. This is only intended to be used with a single version of PyTorch
with transient storage (i.e. sockets or temp files).
* mmap is not supported
See :func:`torch.save` for more details on specific arguments.
"""
zip_file = _PseudoZipFile()
_save(
obj,
zip_file=zip_file,
pickle_module=pickle_module,
pickle_protocol=pickle_protocol,
_disable_byteorder_record=False,
)
zip_file.write_to(f)
def _streaming_load(
f: BufferedIOBase,
map_location: MAP_LOCATION = None,
pickle_module: Any = None,
*,
weights_only: bool = True,
**pickle_load_args: Any,
) -> object:
"""
Load the object from a file-like object in a streaming fashion compatible with
network sockets.
See :func:`_streaming_save` for more details about the streaming behavior.
See :func:`torch.load` for more details on specific arguments.
"""
if weights_only:
if pickle_module is not None:
raise RuntimeError(
"Can not safely load weights when explicit pickle_module is specified"
)
pickle_module = _weights_only_unpickler
else:
if pickle_module is None:
pickle_module = pickle
if "encoding" not in pickle_load_args:
pickle_load_args["encoding"] = "utf-8"
zip_file = _PseudoZipFile()
zip_file.read_from(f)
return _load(
zip_file=zip_file,
map_location=map_location,
pickle_module=pickle_module,
**pickle_load_args,
)
@@ -0,0 +1 @@
from .api import _shard_tensor, load_with_process_group, shard_module, shard_parameter
@@ -0,0 +1,32 @@
from collections.abc import Sequence
import torch
from torch.distributed._shard.metadata import ShardMetadata
DEPRECATE_MSG = "Please use DTensor instead and we are deprecating ShardedTensor."
def narrow_tensor_by_index(
tensor: torch.Tensor,
offsets: Sequence[int],
sizes: Sequence[int],
) -> torch.Tensor:
"""
Narrow the tensor according to ``offsets`` and ``sizes``.
"""
narrowed_tensor = tensor
for idx, (offset, size) in enumerate(zip(offsets, sizes)):
if size < tensor.size(idx):
# Reshape to get shard for this rank and we don't want autograd
# recording here for the narrow op and 'local_shard' should be a
# leaf variable in the autograd graph.
narrowed_tensor = narrowed_tensor.narrow(idx, offset, size)
return narrowed_tensor
def narrow_tensor(tensor: torch.Tensor, metadata: ShardMetadata) -> torch.Tensor:
"""
Narrow the tensor according to the metadata
"""
return narrow_tensor_by_index(tensor, metadata.shard_offsets, metadata.shard_sizes)
@@ -0,0 +1,305 @@
# mypy: allow-untyped-defs
from contextlib import contextmanager
import torch
import torch.distributed as dist
import torch.nn as nn
from torch.distributed import distributed_c10d
from torch.distributed._shard.sharded_tensor import ShardedTensor
from .sharder import Sharder
from .sharding_plan import ShardingPlan
from .sharding_spec import ChunkShardingSpec, ShardingSpec
def _shard_tensor(
tensor: torch.Tensor, sharding_spec: ShardingSpec, src_rank=0, process_group=None
) -> ShardedTensor:
"""
Given a :class:`torch.Tensor`, it shards that tensor according to the provided
``sharding_spec``. ``src_rank`` denotes the source rank which would be
used as the ground truth of the data which would be scattered as shards
across the rest of the ranks.
Args:
tensor (:class:`torch.Tensor`): Tensor needs to be sharded.
sharding_spec (:class:`torch.distributed._shard.sharding_spec.ShardingSpec`): The specification
describing how to shard the Tensor.
Keyword args:
src_rank (int, optional): The source rank which is used as the ground truth of
the data for the parameter that would be sharded and scattered
across the rest of the ranks.
Default: 0.
process_group (ProcessGroup, optional): The process group to work on. If None,
the default process group will be used.
Returns:
A :class:`ShardedTensor` sharded from the given tensor.
.. warning::
Only :class:`torch.distributed._shard.sharding_spec.ChunkShardingSpec` is
currently supported as the ``sharding_spec``.
"""
if not tensor.is_contiguous():
raise ValueError("input tensor is not a contiguous Tensor")
pg = (
process_group
if process_group is not None
else distributed_c10d._get_default_group()
)
world_size = dist.get_world_size(pg)
current_rank = dist.get_rank(pg)
# Validate src_rank and sharding_spec are same across all ranks.
gathered_list = [None] * world_size
dist.all_gather_object(gathered_list, (src_rank, sharding_spec), group=pg)
for idx, entry in enumerate(gathered_list):
if src_rank != entry[0]: # type: ignore[index]
raise ValueError(
f"src_rank={src_rank} on rank: {current_rank} does not " # type: ignore[index]
f"match with src_rank={entry[0]} on rank: {idx}" # type: ignore[index]
)
if sharding_spec != entry[1]: # type: ignore[index]
raise ValueError(
f"sharding_spec={sharding_spec} on rank: {current_rank} does not " # type: ignore[index]
f"match with sharding_spec={entry[1]} on rank: {idx}" # type: ignore[index]
)
st = sharding_spec.shard(tensor, src_rank=src_rank, process_group=pg)
return st
def shard_parameter(
module: torch.nn.Module,
param_name: str,
sharding_spec: ShardingSpec,
src_rank=0,
process_group=None,
):
"""
Given a :class:`torch.nn.Module`, a ``param_name`` for a parameter in that
module, it shards that parameter according to the provided
``sharding_spec``. ``src_rank`` denotes the source rank which would be
used as the ground truth of the data which would be scattered as shards
across the rest of the ranks.
This method replaces ``module.param_name`` with a
:class:`torch.distributed._sharded_tensor.ShardedTensor`
Args:
module (:class:`torch.nn.Module`): Module whose parameter needs to be sharded.
param_name (str): Name of the parameter of ``module`` that needs to be sharded.
sharding_spec (:class:`torch.distributed._shard.sharding_spec.ShardingSpec`): The specification
describing how to shard the Tensor.
Keyword args:
src_rank (int, optional): The source rank which is used as the ground truth of
the data for the parameter that would be sharded and scattered
across the rest of the ranks.
Default: 0.
process_group (ProcessGroup, optional): The process group to work on. If None,
the default process group will be used.
.. warning::
Only :class:`torch.distributed._shard.sharding_spec.ChunkShardingSpec` is
currently supported as the ``sharding_spec``.
"""
# Perform some validation first.
if not hasattr(module, param_name):
raise AttributeError(f"{module._get_name()} has no attribute `{param_name}`")
tensor = getattr(module, param_name)
if not isinstance(tensor, torch.Tensor):
raise ValueError(
f"Expected {type(module).__name__}.{param_name} to be a Tensor, but found {type(tensor).__name__}"
)
if not tensor.is_contiguous():
raise ValueError(f"param: {param_name} is not a contiguous Tensor")
st = _shard_tensor(tensor, sharding_spec, src_rank, process_group)
# Replace param with ShardedTensor.
module.register_parameter(param_name, nn.Parameter(st))
# Tracks the current process group in the load context manager.
_CURRENT_PROCESS_GROUP: dist.ProcessGroup | None = None
@contextmanager
def load_with_process_group(process_group):
"""
Context manager to set the process group with which to load a ShardedTensor.
"""
global _CURRENT_PROCESS_GROUP
if _CURRENT_PROCESS_GROUP is not None:
raise RuntimeError(
'ProcessGroup already set by previous "load_with_process_group" '
"context manager"
)
_CURRENT_PROCESS_GROUP = process_group
try:
yield process_group
finally:
_CURRENT_PROCESS_GROUP = None
def _get_current_process_group():
"""
Retrieves the current process group set by ``load_with_process_group``.
If not set, it just returns the default group.
"""
global _CURRENT_PROCESS_GROUP
if _CURRENT_PROCESS_GROUP is None:
return distributed_c10d._get_default_group()
else:
return _CURRENT_PROCESS_GROUP
def _reshard_output(
module: torch.nn.Module, resharding_spec: ShardingSpec
) -> torch.nn.Module:
"""
Hook a module with output resharding in the forward pass according
to the given ``resharding_spec``.
Args:
module (:class:`torch.nn.Module`): Module whose output needs to be resharded.
resharding_spec (:class:`torch.distributed._shard.sharding_spec.ShardingSpec`):
The specification describing how the output of the module will be resharded.
Returns:
A :class:`torch.nn.Module` object with reshard API hooked.
"""
def hook_func(_module, _input, output):
if isinstance(output, ShardedTensor):
return output.reshard(resharding_spec)
return output
module.register_forward_hook(hook_func)
return module
def _collect_local_shard(module: torch.nn.Module) -> torch.nn.Module:
"""
Hook a module with local shards collection in the forward pass.
This API is typically used to convert a sharded representation back to data parallel
representation. In particular, it returns the local tensor for this Shard. If the
size along the sharding dimension for the local tensor is 1, this dimension is removed
from the final result. For example a [4, 16] ShardedTensor across 4 ranks is typically
a local Tensor of size [16] across each rank and not [1, 16] across each rank.
Args:
module (:class:`torch.nn.Module`): Module whose output is ShardedTensor and the
local tensor value needs to be returned.
Returns:
A :class:`torch.nn.Module` object with collection API hooked.
"""
def hook_func(_module, _input, output):
if isinstance(output, ShardedTensor):
local_tensor = output.local_tensor()
# Squeeze the # of dimensions manually, only applicable to ChunkShardingSpec
sharding_spec = output._sharding_spec
if (
isinstance(sharding_spec, ChunkShardingSpec)
and local_tensor.size(sharding_spec.dim) == 1 # type: ignore[attr-defined, arg-type]
):
local_tensor = local_tensor.squeeze(
output._sharding_spec.dim # type: ignore[attr-defined]
)
return local_tensor
module.register_forward_hook(hook_func)
return module
def shard_module(module: nn.Module, plan: ShardingPlan, src_rank=0, process_group=None):
"""
Shards a given module according to the provided sharding `plan`. This method
first shards all the parameters according to the given sharding `plan`. Then if
`output_plan` and `return_local_tensor` are specified in the sharding `plan`, it
will tag the output of modules according `output_plan`, convert the module's
output back to data parallel according to `return_local_tensor`.
Needs to be called on all ranks in an SPMD fashion.
Args:
module (:class:`torch.nn.Module`): The module to apply sharding to
plan (:class:`torch.distributed._shard.sharding_plan.ShardingPlan`):
The ShardingPlan which specified param name to ShardingSpec to apply to
each parameter.
Keyword args:
src_rank (int, optional): The source rank which is used as the ground truth of
the data for the module that would be sharded and scattered across the rest
of the ranks.
Default: 0.
process_group (ProcessGroup, optional): The process group to work on. If None,
the default process group will be used.
"""
# record Sharder paths for sanity check on the plan to ensure items in the plan
# does not conflict with the submodule tree that the Sharder is working with
sharder_paths = []
for name, spec in plan.plan.items():
if isinstance(spec, Sharder):
sharder_paths.append(name)
# shard the parameter according to the ShardingPlan
for name, spec in plan.plan.items():
if isinstance(spec, ShardingSpec):
# if found a sharding spec, try to shard the parameter
module_path, _, param_name = name.rpartition(".")
for sharder_path in sharder_paths:
if module_path.startswith(sharder_path):
raise RuntimeError(
f"ShardingPlan is in-valid, trying to shard a parameter: {name},"
f" but there's already a Sharder entry for module {sharder_path},"
f" parameter sharding should not conflict with the submodule tree"
f" that a Sharder is working with!"
)
mod = module.get_submodule(module_path)
shard_parameter(
mod, param_name, spec, src_rank=src_rank, process_group=process_group
)
elif isinstance(spec, Sharder):
parent_mod_path, _, _mod_name = name.rpartition(".")
if name == "":
raise KeyError("Module path must not be empty for custom sharder!")
mod = module.get_submodule(name)
parent_mod = module.get_submodule(parent_mod_path)
sharded_mod = spec.shard(mod)
# swap this submodule with the sharded module
parent_mod.mod_name = sharded_mod
else:
raise TypeError(
f"Only `ShardingSpec` and `Sharder` are supported to shard '{name}'"
)
# reshard output if there's an entry in `reshard_output` for this module
if plan.output_plan is not None:
for module_path, output_spec in plan.output_plan.items():
if isinstance(output_spec, ShardingSpec):
mod = module.get_submodule(module_path)
_reshard_output(mod, output_spec)
else:
raise TypeError(
f"Only `ShardingSpec` is supported as output_plan for '{module_path}'"
)
# convert the output back to data parallel for the modules appears in
# `return_local_tensor` of the plan, we will call `_collect_local_shard`
# to collect the local tensor for output of modules
if plan.return_local_tensor is not None:
for module_path in plan.return_local_tensor:
mod = module.get_submodule(module_path)
_collect_local_shard(mod)
@@ -0,0 +1,19 @@
# Keep old package for BC purposes, this file should be removed once
# everything moves to the `torch.distributed.checkpoint` package.
import sys
import warnings
import torch
from torch.distributed.checkpoint import * # noqa: F403
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"`torch.distributed._shard.checkpoint` will be deprecated, "
"use `torch.distributed.checkpoint` instead",
DeprecationWarning,
stacklevel=2,
)
sys.modules["torch.distributed._shard.checkpoint"] = torch.distributed.checkpoint
@@ -0,0 +1,64 @@
# mypy: allow-untyped-defs
import torch
from torch.utils import _pytree as pytree
def _basic_validation(op, args=(), kwargs=None):
"""
Common validation across all ops go in here.
"""
from torch.distributed._shard.sharded_tensor import ShardedTensor
if len(args) == 0 and (kwargs is None or len(kwargs) == 0):
raise ValueError(f" No input for '{op.__name__}'!")
# Validate types
has_distributed_tensor = False
def is_distributed_tensor(e):
nonlocal has_distributed_tensor
if isinstance(e, ShardedTensor):
has_distributed_tensor = True
pytree.tree_map_(is_distributed_tensor, args)
pytree.tree_map_(is_distributed_tensor, kwargs)
if not has_distributed_tensor:
raise TypeError(
f"torch function '{op.__name__}', with args: {args} and "
f"kwargs: {kwargs} are called without any distributed tensor!"
)
# Validate all distributed tensors use the same PG.
cur_pg: torch.distributed.ProcessGroup | None = None
def validate_pg(e):
nonlocal cur_pg
if isinstance(e, ShardedTensor):
if cur_pg is not None and e._process_group is not cur_pg:
raise RuntimeError(
"All distributed tensors should use the "
"same ProcessGroup if used together in an op."
)
cur_pg = e._process_group
pytree.tree_map_(validate_pg, args)
pytree.tree_map_(validate_pg, kwargs)
def _register_default_op(op, decorator):
@decorator(op)
def tensor_default_op(types, args=(), kwargs=None, pg=None):
"""
Handles ``__torch_function__`` dispatch for the default tensor ops that
behave the same as ``torch.Tensor`` such as ``torch.Tensor.shape`` or
``torch.Tensor.dtype``. We simply lower to the real op call with
DisableTorchFunctionSubclass context like ``torch.Tensor.__torch_function__``
to avoid recursions.
"""
if kwargs is None:
kwargs = {}
with torch._C.DisableTorchFunctionSubclass():
return op(*args, **kwargs)
@@ -0,0 +1,63 @@
# mypy: allow-untyped-defs
from dataclasses import dataclass
from functools import reduce
from torch.distributed.remote_device import _remote_device
@dataclass
class ShardMetadata:
"""
Represents a shard of the overall Tensor including its
offsets, lengths and device placement.
Args:
shard_offsets(List[int]): Offsets in the original tensor indicating
the start offsets for this shard. Should have the same rank as
the original tensor.
shard_sizes(List[int]): Integers indicating the size of each
dimension for this shard. Should have the same rank as the
original tensor.
placement(:class:`torch.distributed._remote_device`):
Specifies the placement of this shard.
"""
__slots__ = ["shard_offsets", "shard_sizes", "placement"]
shard_offsets: list[int]
shard_sizes: list[int]
placement: _remote_device | None
def __init__(
self,
shard_offsets: list[int],
shard_sizes: list[int],
placement: str | _remote_device | None = None,
):
self.shard_offsets = shard_offsets
self.shard_sizes = shard_sizes
if isinstance(placement, str):
self.placement = _remote_device(placement)
else:
self.placement = placement
if len(self.shard_offsets) != len(self.shard_sizes):
raise ValueError(
f"shard_offsets and shard_sizes should have "
f"the same number of elements, found {len(self.shard_offsets)} "
f"and {self.shard_sizes} respectively"
)
for i in range(len(self.shard_offsets)):
if self.shard_offsets[i] < 0:
raise ValueError("shard_offsets should be >=0")
if self.shard_sizes[i] < 0:
raise ValueError("shard_sizes should be >= 0")
def __hash__(self):
def _hash_reduce(a, b):
return (a << 8) + hash(b)
res = reduce(_hash_reduce, self.shard_offsets, 37)
res = reduce(_hash_reduce, self.shard_sizes, res)
res = _hash_reduce(res, self.placement)
return res
@@ -0,0 +1,41 @@
# mypy: allow-untyped-defs
import functools
from inspect import signature
from .common_op_utils import _basic_validation
"""
Common utilities to register ops on ShardedTensor
and PartialTensor.
"""
def _register_op(op, func, op_table):
"""
Performs basic validation and registers the provided op in the given
op_table.
"""
if len(signature(func).parameters) != 4:
raise TypeError(
f"Custom sharded op function expects signature: "
f"(types, args, kwargs, process_group), but received "
f"signature: {signature(func)}"
)
op_table[op] = func
def _decorator_func(wrapped_func, op, op_table):
"""
Decorator function to register the given ``op`` in the provided
``op_table``
"""
@functools.wraps(wrapped_func)
def wrapper(types, args, kwargs, process_group):
_basic_validation(op, args, kwargs)
return wrapped_func(types, args, kwargs, process_group)
_register_op(op, wrapper, op_table)
return wrapper
@@ -0,0 +1,53 @@
from collections.abc import Iterator
from typing import Union
import torch.nn as nn
from torch.distributed._shard.sharded_tensor import ShardedTensor
from .api import ShardedOptimizer
def named_params_with_sharded_tensor(
module: nn.Module,
prefix: str = "",
recurse: bool = True,
) -> Iterator[tuple[str, nn.Parameter | ShardedTensor]]:
r"""Returns an iterator over module parameters (together with the
ShardedTensor parameters), yielding both the name of the parameter
as well as the parameter itself. This is typically passed to a
:class:torch.distributed._shard.sharded_optim.ShardedOptimizer
Args:
prefix (str): prefix to prepend to all parameter names.
recurse (bool): if True, then yields parameters of this module
and all submodules. Otherwise, yields only parameters that
are direct members of this module.
Yields:
(str, Union[Tensor, ShardedTensor]): Tuple containing
the name and parameter (or ShardedTensor parameter)
Example::
>>> # xdoctest: +SKIP
>>> model = torch.nn.Linear(*linear_size)
>>> shard_parameter(model, "weight", spec)
>>> for name, param in named_params_with_sharded_tensor(model):
>>> if name in ['weight']:
>>> print(param.size())
"""
modules = module.named_modules(prefix=prefix) if recurse else [(prefix, module)]
memo = set()
for mod_prefix, mod in modules:
# find all sharded tensor params
for name, val in vars(mod).items():
if isinstance(val, ShardedTensor) and val not in memo:
memo.add(val)
name = mod_prefix + ("." if mod_prefix else "") + name
yield name, val
# find all nn.Parameters
for name, val in module.named_parameters():
yield name, val
@@ -0,0 +1,102 @@
# mypy: allow-untyped-defs
from collections.abc import Mapping
from typing import Any
import torch.optim as optim
from torch import Tensor
from torch.distributed._shard.sharded_tensor import ShardedTensor
class ShardedOptimizer(optim.Optimizer):
def __init__(
self,
named_params: Mapping[str, Tensor | ShardedTensor],
optimizer_class,
*optimizer_args,
**optimizer_kwargs,
):
"""
ShardedOptimizer collects all tensors and local shard tensors of
ShardedTensor, then use these tensors as ``params`` for optimizers
Args:
named_params (Dict[str, Union[Tensor, ShardedTensor]]) : a Dict
of parameters, where key is the parameter key, value is either
Tensor or ShardedTensor parameter.
optimizer_class (torch.optim.Optimizer): the Optimizer to use
locally, i.e. torch.optim.SGD, torch.optim.Adagrad, etc.
*optimizer_args: the arguments to initialize the optimizer.
**optimizer_kwargs: the key-word arguments to initialize the optimizer.
"""
tensors: list[Tensor] = []
for value in named_params.values():
if isinstance(value, ShardedTensor):
tensors.extend(
local_shard.tensor for local_shard in value.local_shards()
)
else:
tensors.append(value)
self.named_params = named_params
self._optim = optimizer_class(tensors, *optimizer_args, **optimizer_kwargs)
self.param_groups = self._optim.param_groups
self.state = self._optim.state
def zero_grad(self, set_to_none: bool = True): # type: ignore[override]
r"""Resets the gradients of all optimized :class:`torch.Tensor` s.
Args:
set_to_none (bool): instead of setting to zero, set the grads to None.
This will in general have lower memory footprint, and can modestly improve performance.
However, it changes certain behaviors. For example:
1. When the user tries to access a gradient and perform manual ops on it,
a None attribute or a Tensor full of 0s will behave differently.
2. If the user requests ``zero_grad(set_to_none=True)`` followed by a backward pass, ``.grad``\ s
are guaranteed to be None for params that did not receive a gradient.
3. ``torch.optim`` optimizers have a different behavior if the gradient is 0 or None
(in one case it does the step with a gradient of 0 and in the other it skips
the step altogether).
"""
self._optim.zero_grad(set_to_none)
def step(self, closure=None):
r"""Performs a single optimization step (parameter update).
Args:
closure (Callable): A closure that reevaluates the model and
returns the loss. Optional for most optimizers.
.. note::
Unless otherwise specified, this function should not modify the
``.grad`` field of the parameters.
"""
self._optim.step(closure)
def state_dict(self) -> dict[str, Any]:
"""
Returned state and param_groups will contain parameter keys
instead of parameter indices like torch.optim.Optimizer.
This allows for advanced functionality like optimizer re-sharding to be implemented.
"""
# TODO: implement state_dict
raise NotImplementedError("ShardedOptimizer state_dict not implemented yet!")
def load_state_dict(self, state_dict: Mapping[str, Any]):
r"""Loads the ShardedOptimizer state.
Args:
state_dict (dict): ShardedOptimizer state. Should be an object returned
from a call to :meth:`state_dict`.
"""
# TODO: implement load_state_dict
raise NotImplementedError(
"ShardedOptimizer load_state_dict not implemented yet!"
)
def add_param_group(self, param_group: Any):
r"""Add a new param group"""
# TODO: implement add_param_group
raise NotImplementedError(
"ShardedOptimizer add_param_group not implemented yet!"
)
@@ -0,0 +1,490 @@
# mypy: allow-untyped-defs
import functools
from typing import TYPE_CHECKING
import torch
from torch.distributed._shard.op_registry_utils import _decorator_func
from .api import (
_CUSTOM_SHARDED_OPS,
_SHARDED_OPS,
Shard,
ShardedTensor,
ShardedTensorBase,
ShardedTensorMetadata,
TensorProperties,
)
from .metadata import ShardMetadata # noqa: F401
if TYPE_CHECKING:
from torch.distributed._shard.sharding_spec import ShardingSpec
else:
ShardingSpec = "ShardingSpec"
def empty(
sharding_spec: ShardingSpec,
*size,
dtype=None,
layout=torch.strided,
requires_grad=False,
pin_memory=False,
memory_format=torch.contiguous_format,
process_group=None,
init_rrefs=False,
) -> ShardedTensor:
"""
Returns a :class:`ShardedTensor` filled with uninitialized data.
Needs to be called on all ranks in an SPMD fashion.
Args:
sharding_spec (:class:`torch.distributed._shard.sharding_spec.ShardingSpec`): The specification
describing how to shard the Tensor.
size (int...): a sequence of integers defining the shape of the output
tensor. Can be a variable number of arguments or a collection like a list or tuple.
Keyword args:
dtype (:class:`torch.dtype`, optional): the desired data type of returned tensor.
Default: if ``None``, uses a global default (see :func:`torch.set_default_dtype`).
layout (:class:`torch.layout`, optional): the desired layout of returned Tensor.
Default: ``torch.strided``.
requires_grad (bool, optional): If autograd should record operations on the
returned tensor. Default: ``False``.
pin_memory (bool, optional): If set, returned tensor would be allocated in
the pinned memory. Works only for CPU tensors. Default: ``False``.
memory_format (:class:`torch.memory_format`, optional): the desired memory format of
returned Tensor. Default: ``torch.contiguous_format``.
process_group (ProcessGroup, optional): The process group to work on. If None,
the default process group will be used.
init_rrefs (bool, optional): Whether or not to initialize
:class:`torch.distributed.rpc.RRef`s pointing to remote shards.
Need to initialize the RPC Framework if specified as ``True``.
Default: ``False``.
Returns:
A :class:`ShardedTensor` object on each rank
"""
return ShardedTensor(
sharding_spec,
*size,
dtype=dtype,
layout=layout,
requires_grad=requires_grad,
pin_memory=pin_memory,
memory_format=memory_format,
process_group=process_group,
init_rrefs=init_rrefs,
)
def ones(
sharding_spec: ShardingSpec,
*size,
dtype=None,
layout=torch.strided,
requires_grad=False,
pin_memory=False,
memory_format=torch.contiguous_format,
process_group=None,
init_rrefs=False,
) -> ShardedTensor:
"""
Returns a :class:`ShardedTensor` with the scalar value 1.
Needs to be called on all ranks in an SPMD fashion.
Args:
sharding_spec (:class:`torch.distributed._shard.sharding_spec.ShardingSpec`): The specification
describing how to shard the Tensor.
size (int...): a sequence of integers defining the shape of the output
tensor. Can be a variable number of arguments or a collection like a list or tuple.
Keyword args:
dtype (:class:`torch.dtype`, optional): the desired data type of returned tensor.
Default: if ``None``, uses a global default (see :func:`torch.set_default_dtype`).
layout (:class:`torch.layout`, optional): the desired layout of returned Tensor.
Default: ``torch.strided``.
requires_grad (bool, optional): If autograd should record operations on the
returned tensor. Default: ``False``.
pin_memory (bool, optional): If set, returned tensor would be allocated in
the pinned memory. Works only for CPU tensors. Default: ``False``.
process_group (ProcessGroup, optional): The process group to work on. If None,
the default process group will be used.
init_rrefs (bool, optional): Whether or not to initialize
:class:`torch.distributed.rpc.RRef`s pointing to remote shards.
Need to initialize the RPC Framework if specified as ``True``.
Default: ``False``.
Returns:
A :class:`ShardedTensor` object on each rank
"""
return full(
sharding_spec,
size,
fill_value=1,
dtype=dtype,
layout=layout,
requires_grad=requires_grad,
pin_memory=pin_memory,
memory_format=memory_format,
process_group=process_group,
init_rrefs=init_rrefs,
)
def zeros(
sharding_spec: ShardingSpec,
*size,
dtype=None,
layout=torch.strided,
requires_grad=False,
pin_memory=False,
memory_format=torch.contiguous_format,
process_group=None,
init_rrefs=False,
) -> ShardedTensor:
"""
Returns a :class:`ShardedTensor` filled with the scalar value 0.
Needs to be called on all ranks in an SPMD fashion.
Args:
sharding_spec (:class:`torch.distributed._shard.sharding_spec.ShardingSpec`): The specification
describing how to shard the Tensor.
size (int...): a sequence of integers defining the shape of the output
tensor. Can be a variable number of arguments or a collection like a list or tuple.
Keyword args:
dtype (:class:`torch.dtype`, optional): the desired data type of returned tensor.
Default: if ``None``, uses a global default (see :func:`torch.set_default_dtype`).
layout (:class:`torch.layout`, optional): the desired layout of returned Tensor.
Default: ``torch.strided``.
requires_grad (bool, optional): If autograd should record operations on the
returned tensor. Default: ``False``.
pin_memory (bool, optional): If set, returned tensor would be allocated in
the pinned memory. Works only for CPU tensors. Default: ``False``.
process_group (ProcessGroup, optional): The process group to work on. If None,
the default process group will be used.
init_rrefs (bool, optional): Whether or not to initialize
:class:`torch.distributed.rpc.RRef`s pointing to remote shards.
Need to initialize the RPC Framework if specified as ``True``.
Default: ``False``.
Returns:
A :class:`ShardedTensor` object on each rank
"""
return full(
sharding_spec,
size,
fill_value=0,
dtype=dtype,
layout=layout,
requires_grad=requires_grad,
pin_memory=pin_memory,
memory_format=memory_format,
process_group=process_group,
init_rrefs=init_rrefs,
)
def full(
sharding_spec: ShardingSpec,
size,
fill_value,
*,
dtype=None,
layout=torch.strided,
requires_grad=False,
pin_memory=False,
memory_format=torch.contiguous_format,
process_group=None,
init_rrefs=False,
) -> ShardedTensor:
"""
Creates a :class:`ShardedTensor` filled with fill_value. The tensor's dtype
is inferred from fill_value. If dtype is specified, it will override the
inferred type from fill_value. Needs to be called on all ranks in an SPMD fashion.
Args:
sharding_spec (:class:`torch.distributed._sharding_spec.ShardingSpec`): The specification
describing how to shard the Tensor.
size (int...): a list, tuple, or `torch.Size` of integers defining the shape of the
output tensor.
fill_value (Scalar) - the value to fill the output tensor with.
Keyword args:
dtype (:class:`torch.dtype`, optional): the desired data type of returned tensor.
Default: if ``None``, uses a global default (see :func:`torch.set_default_dtype`).
layout (:class:`torch.layout`, optional): the desired layout of returned Tensor.
Default: ``torch.strided``.
requires_grad (bool, optional): If autograd should record operations on the
returned tensor. Default: ``False``.
pin_memory (bool, optional): If set, returned tensor would be allocated in
the pinned memory. Works only for CPU tensors. Default: ``False``.
process_group (ProcessGroup, optional): The process group to work on. If None,
the default process group will be used.
init_rrefs (bool, optional): Whether or not to initialize
:class:`torch.distributed.rpc.RRef`s pointing to remote shards.
Need to initialize the RPC Framework if specified as ``True``.
Default: ``False``.
Returns:
A :class:`ShardedTensor` object on each rank
"""
sharded_tensor = ShardedTensor(
sharding_spec,
*size,
dtype=dtype,
layout=layout,
requires_grad=requires_grad,
pin_memory=pin_memory,
memory_format=memory_format,
process_group=process_group,
init_rrefs=init_rrefs,
)
torch.nn.init.constant_(sharded_tensor, fill_value) # type: ignore[arg-type]
return sharded_tensor
def rand(
sharding_spec: ShardingSpec,
*size,
dtype=None,
layout=torch.strided,
requires_grad=False,
pin_memory=False,
memory_format=torch.contiguous_format,
process_group=None,
init_rrefs=False,
) -> ShardedTensor:
"""
Creates a :class:`ShardedTensor` filled with random numbers from a uniform distribution
on the interval :math:`[0, 1)`. The shape of the tensor is defined by the
variable argument `size`. Needs to be called on all ranks in an SPMD fashion.
Args:
sharding_spec (:class:`torch.distributed._shard.sharding_spec.ShardingSpec`): The specification
describing how to shard the Tensor.
size (int...): a list, tuple, or `torch.Size` of integers defining the shape of the
output tensor.
Keyword args:
dtype (:class:`torch.dtype`, optional): the desired data type of returned tensor.
Default: if ``None``, uses a global default (see :func:`torch.set_default_dtype`).
layout (:class:`torch.layout`, optional): the desired layout of returned Tensor.
Default: ``torch.strided``.
requires_grad (bool, optional): If autograd should record operations on the
returned tensor. Default: ``False``.
pin_memory (bool, optional): If set, returned tensor would be allocated in
the pinned memory. Works only for CPU tensors. Default: ``False``.
process_group (ProcessGroup, optional): The process group to work on. If None,
the default process group will be used.
init_rrefs (bool, optional): Whether or not to initialize
:class:`torch.distributed.rpc.RRef`s pointing to remote shards.
Need to initialize the RPC Framework if specified as ``True``.
Default: ``False``.
Returns:
A :class:`ShardedTensor` object on each rank
"""
sharded_tensor = ShardedTensor(
sharding_spec,
*size,
dtype=dtype,
layout=layout,
requires_grad=requires_grad,
pin_memory=pin_memory,
memory_format=memory_format,
process_group=process_group,
init_rrefs=init_rrefs,
)
torch.nn.init.uniform_(sharded_tensor, 0, 1) # type: ignore[arg-type]
return sharded_tensor
def randn(
sharding_spec: ShardingSpec,
*size,
dtype=None,
layout=torch.strided,
requires_grad=False,
pin_memory=False,
memory_format=torch.contiguous_format,
process_group=None,
init_rrefs=False,
) -> ShardedTensor:
"""
Creates a :class:`ShardedTensor` filled with random numbers from a uniform distribution
with mean `0` and variance `1` (also called standard normal distribution). The shape
of the tensor is defined by the variable argument `size`. Needs to be called on all ranks
in an SPMD fashion.
Args:
sharding_spec (:class:`torch.distributed._shard.sharding_spec.ShardingSpec`): The specification
describing how to shard the Tensor.
size (int...): a list, tuple, or `torch.Size` of integers defining the shape of the
output tensor.
Keyword args:
dtype (:class:`torch.dtype`, optional): the desired data type of returned tensor.
Default: if ``None``, uses a global default (see :func:`torch.set_default_dtype`).
layout (:class:`torch.layout`, optional): the desired layout of returned Tensor.
Default: ``torch.strided``.
requires_grad (bool, optional): If autograd should record operations on the
returned tensor. Default: ``False``.
pin_memory (bool, optional): If set, returned tensor would be allocated in
the pinned memory. Works only for CPU tensors. Default: ``False``.
process_group (ProcessGroup, optional): The process group to work on. If None,
the default process group will be used.
init_rrefs (bool, optional): Whether or not to initialize
:class:`torch.distributed.rpc.RRef`s pointing to remote shards.
Need to initialize the RPC Framework if specified as ``True``.
Default: ``False``.
Returns:
A :class:`ShardedTensor` object on each rank
"""
sharded_tensor = ShardedTensor(
sharding_spec,
*size,
dtype=dtype,
layout=layout,
requires_grad=requires_grad,
pin_memory=pin_memory,
memory_format=memory_format,
process_group=process_group,
init_rrefs=init_rrefs,
)
torch.nn.init.normal_(sharded_tensor, 0, 1) # type: ignore[arg-type]
return sharded_tensor
def init_from_local_shards(
local_shards: list[Shard], *global_size, process_group=None, init_rrefs=False
) -> ShardedTensor:
"""
Creates an :class:`ShardedTensor` from local shards and the global metadata.
Needs to be called on all ranks in an SPMD fashion.
Args:
local_shards (List[:class `torch.distributed._shard.sharded_tensor.Shard`]): A list
of shards that represent the local shards on this rank.
global_size (int...): a list, tuple, or `torch.Size` of integers defining the
shape of the overall sharded tensor.
Keyword args:
process_group (ProcessGroup, optional): The process group to work on. If None,
the default process group will be used.
init_rrefs (bool, optional): Whether or not to initialize
:class:`torch.distributed.rpc.RRef`s pointing to remote shards.
Need to initialize the RPC Framework if specified as ``True``.
Default: ``False``.
Returns:
A :class:`ShardedTensor` object handle on this rank
Examples:
Suppose we want construct a sharded tensor on two ranks, global size = (10, 5),
each shard have a (5, 5) local tensor, we can do it like below:
on rank 0:
>>> # xdoctest: +SKIP("not distributed")
>>> local_shard_metadata = ShardMetadata(
>>> shard_offsets=[0, 0],
>>> shard_lengths=[5, 5],
>>> placement="rank:0/cuda:0"
>>> )
>>> local_shards = [Shard(torch.randn(5, 5), local_shard_metadata)]
>>> sharded_tensor = init_from_local_shards(local_shards, [10, 5])
on rank 1:
>>> # xdoctest: +SKIP("not distributed")
>>> local_shard_metadata = ShardMetadata(
>>> shard_offsets=[5, 0],
>>> shard_lengths=[5, 5],
>>> placement="rank:1/cuda:1"
>>> )
>>> local_shards = [Shard(torch.randn(5, 5), local_shard_metadata)]
>>> sharded_tensor = init_from_local_shards(local_shards, [10, 5])
"""
return ShardedTensor._init_from_local_shards(
local_shards, *global_size, process_group=process_group, init_rrefs=init_rrefs
)
def state_dict_hook(module, destination, prefix, local_metadata):
"""
Hook to add ShardedTensor to Module's ``state_dict``. Needs to be
registered to the Module using
:meth:`torch.nn.Module._register_state_dict_hook`.
"""
for submodule_name, submodule in module.named_modules():
for attr_name, attr in submodule.__dict__.items():
if isinstance(attr, ShardedTensor):
mod_prefix = prefix + submodule_name
key = mod_prefix + ("." if mod_prefix else "") + attr_name
destination[key] = attr
def pre_load_state_dict_hook(
module,
state_dict,
prefix,
local_metadata,
strict,
missing_keys,
unexpected_keys,
error_msgs,
):
"""
Pre-load state dict hook to add ShardedTensor to the module.
"""
for submodule_name, submodule in module.named_modules():
for attr_name in submodule.__dict__:
mod_prefix = prefix + submodule_name
key = mod_prefix + ("." if mod_prefix else "") + attr_name
if key in state_dict:
if isinstance(state_dict[key], ShardedTensor):
setattr(submodule, attr_name, state_dict[key])
def custom_sharded_op_impl(func):
"""
Provides a way for users to write their own custom sharded operator. This
can be used to override existing ShardedTensor operators or write a new
one not supported by ShardedTensor. If the operator in question is covered
by ``__torch_function__`` dispatch and has a ShardedTensor as any of its
parameters, the function provided will be invoked for that operator.
Example::
>>> # xdoctest: +SKIP
>>> @custom_sharded_op_impl(torch.nn.functional.linear)
>>> def my_custom_sharded_linear(types, args, kwargs, process_group):
>>> ...
>>> # xdoctest: +SKIP("Undefined variables")
>>> input = torch.rand(10, 32)
>>> weight = sharded_tensor.rand(32, 16)
>>> bias = torch.rand(16)
>>> # This will call 'my_custom_sharded_linear'
>>> torch.nn.functional.linear(input, weight, bias)
The types, args and kwargs parameters are the same parameters that are
passed to ``__torch_function__`` dispatch API
(https://pytorch.org/docs/stable/notes/extending.html#extending-torch).
There is an additional ``process_group`` parameter which is the
process_group used for the ShardedTensor and can be used by
implementations for communications within a sharded implementation.
Args:
func(Callable): Torch function for which we want to provide a sharded
implementation (ex: torch.nn.functional.linear)
"""
return functools.partial(_decorator_func, op=func, op_table=_CUSTOM_SHARDED_OPS)
def _sharded_op_impl(func):
"""
Decorator to register a default sharded op.
"""
return functools.partial(_decorator_func, op=func, op_table=_SHARDED_OPS)
# Import all builtin sharded ops
from ._ops import * # noqa: F403
@@ -0,0 +1,13 @@
import torch.distributed._shard.sharded_tensor._ops.misc_ops
import torch.distributed._shard.sharded_tensor._ops.tensor_ops
# Import all ChunkShardingSpec ops
from torch.distributed._shard.sharding_spec.chunk_sharding_spec_ops.embedding import (
sharded_embedding,
)
from torch.distributed._shard.sharding_spec.chunk_sharding_spec_ops.embedding_bag import (
sharded_embedding_bag,
)
from .binary_cmp import allclose, equal
from .init import constant_, kaiming_uniform_, normal_, uniform_
@@ -0,0 +1,115 @@
# mypy: allow-untyped-defs
import functools
from torch.distributed._shard.common_op_utils import _basic_validation
from torch.distributed._shard.sharded_tensor import (
_sharded_op_impl,
Shard,
ShardedTensor,
)
def _sharded_op_common(op, early_stop_func, extra_check):
"""
Inject sharded tensor op registration with common logics executed before
different behaviors are done on either local shards or a local tensor.
Example::
>>> # xdoctest: +SKIP("Undefined variables")
>>> op = torch.transpose
>>> @_sharded_op_impl(op)
>>> @_sharded_op_common(op, early_stop_func, extra_check)
>>> def sharded_tensor_op(types, args, kwargs, process_group):
>>> ...
>>>
>>> st = sharded_tensor.rand(32, 16)
>>> st.transpose(1, 2)
>>> # This will call '_sharded_op_common'
Args:
op: The op to be registered and applied to all shards of the st.
early_stop_func (Callable, optional): the func for early stop.
Default: if ``None``, no early stop.
extra_check (Callable, optional): the func for extra condition check.
Default: if ``None``, no extra check.
Return:
func (Callable): Torch function for which we want to provide a sharded
implementation (ex: torch.transpose)
"""
def decorator_sharded_func(wrapped_func):
@functools.wraps(wrapped_func)
def wrapper(types, args=(), kwargs=None, pg=None):
_basic_validation(op, args, kwargs)
# pyrefly: ignore [bad-index]
st = args[0]
if kwargs is None:
kwargs = {}
if extra_check:
extra_check(*args, **kwargs)
if early_stop_func:
early_stop = early_stop_func(*args, **kwargs)
if early_stop:
return st
return wrapped_func(types, args, kwargs, pg)
return wrapper
return decorator_sharded_func
def _register_sharded_op_on_local_shards(
op, early_stop_func=None, extra_check=None, customized_func=None
):
"""
Handles ``__torch_function__`` dispatch for ops which are performed on
each shard of the sharded tensor such as elementwise op like
``torch.nn.functional.gelu`` or ``torch.nn.functional.relu``.
For more complicated ops, a customized func can be used to generate
the new shards and sharded tensor size.
This function expects that the original ShardingSpec for the ShardedTensor
is preserved irrespective of whether or not a customized function is used.
Args:
op: The op to be registered and applied to all shards of the st.
early_stop_func (Callable, optional): the func for early stop.
Default: if ``None``, no early stop.
extra_check (Callable, optional): the func for extra condition check.
Default: if ``None``, no extra check.
customized_func (Callable, optional): the func for customized logic
to generate new shards and sharded tensor size.
Default: if ``None``, we simply lower to the real op call with
all local shards of the st.
Return:
func (Callable): registered implementation for sharded op for
``__torch_function__`` dispatch.
"""
@_sharded_op_impl(op)
@_sharded_op_common(op, early_stop_func, extra_check)
def sharded_tensor_op_on_local_shards(types, args=(), kwargs=None, pg=None):
# pyrefly: ignore [bad-index]
st = args[0]
st_metadata = st.metadata()
local_shards = st.local_shards()
local_shards_new = []
if customized_func:
local_shards_new, st_metadata = customized_func(args, kwargs, pg)
else:
for local_shard in local_shards:
args = (local_shard.tensor, *args[1:])
local_shards_new.append(
Shard(op(*args, **kwargs), local_shard.metadata)
)
return ShardedTensor._init_from_local_shards_and_global_metadata(
local_shards_new,
st_metadata,
process_group=pg,
init_rrefs=st._init_rrefs,
sharding_spec=st.sharding_spec(),
)
@@ -0,0 +1,78 @@
# mypy: allow-untyped-defs
import torch
import torch.distributed as dist
import torch.distributed.distributed_c10d as distributed_c10d
from torch.distributed._shard.sharded_tensor import _sharded_op_impl, ShardedTensor
def _communicate_result(result, pg):
# Gather results from all ranks.
if result:
result_tensor = torch.ones(1, device=torch.device(torch.cuda.current_device()))
else:
result_tensor = torch.zeros(1, device=torch.device(torch.cuda.current_device()))
dist.all_reduce(result_tensor, group=pg)
expected_result = torch.ones(
1, device=torch.device(torch.cuda.current_device())
) * dist.get_world_size(pg)
return torch.equal(result_tensor, expected_result)
def binary_cmp(cmp_fun, types, args, kwargs=None, process_group=None):
if len(args) != 2:
raise ValueError(f"Expected two arguments for torch.{cmp_fun.__name__}")
st1 = args[0]
st2 = args[1]
if not (isinstance(st1, ShardedTensor) and isinstance(st2, ShardedTensor)):
raise TypeError(
f"Both arguments to torch.{cmp_fun.__name__} need to be of type ShardedTensor"
)
# Verify same PG
if st1._process_group != st2._process_group:
return False
if distributed_c10d._rank_not_in_group(
st1._process_group
) or distributed_c10d._rank_not_in_group(st2._process_group):
return distributed_c10d._rank_not_in_group(
st1._process_group
) == distributed_c10d._rank_not_in_group(st2._process_group)
# Verify metadata
if st1.metadata() != st2.metadata():
return _communicate_result(False, st1._process_group)
# Verify number of local shards
st1_local_shards = st1.local_shards()
st2_local_shards = st2.local_shards()
if len(st1_local_shards) != len(st2_local_shards):
return _communicate_result(False, st1._process_group)
# kwargs must be dict-like
if kwargs is None:
kwargs = {}
# Verify each local shard
for idx in range(len(st1_local_shards)):
if st1_local_shards[idx].metadata != st2_local_shards[idx].metadata:
return _communicate_result(False, st1._process_group)
if not cmp_fun(
st1_local_shards[idx].tensor, st2_local_shards[idx].tensor, **kwargs
):
return _communicate_result(False, st1._process_group)
return _communicate_result(True, st1._process_group)
@_sharded_op_impl(torch.equal)
def equal(types, args, kwargs, process_group):
return binary_cmp(torch.equal, types, args, kwargs, process_group)
@_sharded_op_impl(torch.allclose)
def allclose(types, args, kwargs, process_group):
return binary_cmp(torch.allclose, types, args, kwargs, process_group)
@@ -0,0 +1,164 @@
# mypy: allow-untyped-defs
import torch
import torch.distributed._shard.sharded_tensor as sharded_tensor
from torch.distributed._shard.sharded_tensor import _sharded_op_impl
def validate_param(param, param_name):
if param is None:
raise ValueError(f"param: {param_name} shouldn't be None!")
@_sharded_op_impl(torch.nn.init.uniform_)
def uniform_(types, args=(), kwargs=None, pg=None):
r"""
Fills the Tensor in tensor.local_shards with values drawn from the uniform
distribution :math:`\mathcal{U}(a, b)`.
Args:
tensor: tensor sharded across devices
a: the lower bound of the uniform distribution
b: the upper bound of the uniform distribution
"""
validate_param(kwargs, "kwargs")
# pyrefly: ignore [unsupported-operation]
sharded_tensor = kwargs["tensor"]
validate_param(sharded_tensor, "tensor")
# pyrefly: ignore [unsupported-operation]
a = kwargs["a"]
validate_param(a, "a")
# pyrefly: ignore [unsupported-operation]
b = kwargs["b"]
validate_param(b, "b")
for shard in sharded_tensor.local_shards():
torch.nn.init.uniform_(shard.tensor, a=a, b=b)
return sharded_tensor
@_sharded_op_impl(torch.nn.init.normal_)
def normal_(types, args=(), kwargs=None, pg=None):
r"""
Fills the Tensors in tensor.local_shards with values drawn from the normal
distribution :math:`\mathcal{N}(\text{mean}, \text{std}^2)`.
Args:
tensor: tensor sharded across devices
mean: the mean of the normal distribution
std: the standard deviation of the normal distribution
"""
validate_param(kwargs, "kwargs")
# pyrefly: ignore [unsupported-operation]
sharded_tensor = kwargs["tensor"]
validate_param(sharded_tensor, "tensor")
# pyrefly: ignore [unsupported-operation]
mean = kwargs["mean"]
validate_param(mean, "mean")
# pyrefly: ignore [unsupported-operation]
std = kwargs["std"]
validate_param(std, "std")
for shard in sharded_tensor.local_shards():
torch.nn.init.normal_(shard.tensor, mean=mean, std=std)
return sharded_tensor
@_sharded_op_impl(torch.nn.init.kaiming_uniform_)
def kaiming_uniform_(types, args=(), kwargs=None, pg=None):
r"""
Fills the Tensors in tensor.local_shards with values according to the method
described in `Delving deep into rectifiers: Surpassing human-level
performance on ImageNet classification` - He, K. et al. (2015), using a
uniform distribution. The resulting tensor will have values sampled from
:math:`\mathcal{U}(-\text{bound}, \text{bound})` where
.. math::
\text{bound} = \text{gain} \times \sqrt{\frac{3}{\text{fan\_mode}}}
Also known as He initialization.
Args:
tensor: tensor sharded across devices
a: the negative slope of the rectifier used after this layer (only
used with ``'leaky_relu'``)
mode: either ``'fan_in'`` (default) or ``'fan_out'``. Choosing ``'fan_in'``
preserves the magnitude of the variance of the weights in the
forward pass. Choosing ``'fan_out'`` preserves the magnitudes in the
backwards pass.
nonlinearity: the non-linear function (`nn.functional` name),
recommended to use only with ``'relu'`` or ``'leaky_relu'`` (default).
"""
validate_param(kwargs, "kwargs")
# pyrefly: ignore [unsupported-operation]
sharded_tensor = kwargs["tensor"]
validate_param(sharded_tensor, "tensor")
# pyrefly: ignore [unsupported-operation]
a = kwargs["a"]
validate_param(a, "a")
# pyrefly: ignore [unsupported-operation]
mode = kwargs["mode"]
validate_param(mode, "mode")
# pyrefly: ignore [unsupported-operation]
nonlinearity = kwargs["nonlinearity"]
validate_param(nonlinearity, "nonlinearity")
for shard in sharded_tensor.local_shards():
torch.nn.init.kaiming_uniform_(
shard.tensor, a=a, mode=mode, nonlinearity=nonlinearity
)
return sharded_tensor
@_sharded_op_impl(torch.nn.init.constant_)
def constant_(types, args=(), kwargs=None, pg=None):
r"""
Fills the input ShardedTensor with the value \text{val}val.
Args:
tensor: tensor sharded across devices
val: the value to fill the tensor with
"""
validate_param(kwargs, "kwargs")
# pyrefly: ignore [unsupported-operation]
sharded_tensor = kwargs["tensor"]
validate_param(sharded_tensor, "tensor")
# pyrefly: ignore [unsupported-operation]
val = kwargs["val"]
validate_param(val, "val")
for shard in sharded_tensor.local_shards():
torch.nn.init.constant_(shard.tensor, val=val)
return sharded_tensor
tensor_like_creation_op_map = {
torch.full_like: sharded_tensor.full,
torch.empty_like: sharded_tensor.empty,
torch.zeros_like: sharded_tensor.zeros,
torch.ones_like: sharded_tensor.ones,
torch.rand_like: sharded_tensor.rand,
torch.randn_like: sharded_tensor.randn,
}
# tensor ops that behave the same as the default tensor
def register_tensor_creation_op(op):
@_sharded_op_impl(op)
def tensor_creation_op(types, args=(), kwargs=None, pg=None):
"""
Handles ``__torch_function__`` dispatch for tensor creation ops that
takes a ShardedTensor as argument, such as ``torch.zeros_like`` or
``torch.full_like``.
"""
creation_op = tensor_like_creation_op_map.get(op)
if creation_op is None:
raise RuntimeError(f"Tensor creation {op} not supported!")
if kwargs is None:
kwargs = {}
# pyrefly: ignore [bad-index]
st = args[0]
new_st = creation_op(st.sharding_spec(), st.size(), *args[1:], **kwargs) # type: ignore[operator]
return new_st
register_tensor_creation_op(torch.full_like)
register_tensor_creation_op(torch.empty_like)
register_tensor_creation_op(torch.zeros_like)
register_tensor_creation_op(torch.ones_like)
register_tensor_creation_op(torch.rand_like)
register_tensor_creation_op(torch.randn_like)
@@ -0,0 +1,12 @@
# mypy: allow-untyped-defs
import torch
from torch.distributed._shard.sharded_tensor import _sharded_op_impl
# This is used by `_apply()` within module.py to set new
# parameters after apply a certain method, we should follow
# the future behavior of overwriting the existing tensor
# instead of doing in-place change using `.data = `.
@_sharded_op_impl(torch._has_compatible_shallow_copy_type)
def tensor_has_compatible_shallow_copy_type(types, args=(), kwargs=None, pg=None):
return False
@@ -0,0 +1,222 @@
# mypy: allow-untyped-defs
import copy
import torch
from torch.distributed._shard.common_op_utils import _register_default_op
from torch.distributed._shard.sharded_tensor import (
_sharded_op_impl,
Shard,
ShardedTensor,
)
from ._common import _register_sharded_op_on_local_shards
# Tensor properties access
_register_default_op(torch.Tensor.shape.__get__, _sharded_op_impl) # type: ignore[attr-defined]
_register_default_op(torch.Tensor.dtype.__get__, _sharded_op_impl) # type: ignore[attr-defined]
_register_default_op(torch.Tensor.layout.__get__, _sharded_op_impl) # type: ignore[attr-defined]
_register_default_op(torch.Tensor.size, _sharded_op_impl)
_register_default_op(torch.Tensor.dim, _sharded_op_impl)
_register_default_op(torch.Tensor.ndim.__get__, _sharded_op_impl) # type: ignore[attr-defined]
_register_default_op(torch.Tensor.is_contiguous, _sharded_op_impl)
_register_default_op(torch.Tensor.contiguous, _sharded_op_impl)
_register_default_op(torch.Tensor.is_floating_point, _sharded_op_impl)
# __reduce_ex__ to dispatch to get_state/set_state
_register_default_op(torch.Tensor.__reduce_ex__, _sharded_op_impl)
# autograd related properties
_register_default_op(torch.Tensor.requires_grad.__get__, _sharded_op_impl) # type: ignore[attr-defined]
# TODO: set grad with a ShardedTensor that consists of all local grads
_register_default_op(torch.Tensor.grad.__get__, _sharded_op_impl) # type: ignore[union-attr]
_register_default_op(torch.Tensor.grad_fn.__get__, _sharded_op_impl) # type: ignore[union-attr]
_register_default_op(torch.Tensor.is_leaf.__get__, _sharded_op_impl) # type: ignore[attr-defined]
# device property is ambiguous as from a global prospective,
# ShardedTensor.device consists of multiple devices (might even across hosts)
# We choose to return the current device of the local tensor to represent
# the device property on each rank
@_sharded_op_impl(torch.Tensor.device.__get__)
def tensor_device(types, args=(), kwargs=None, pg=None):
# pyrefly: ignore [bad-index]
self_st = args[0]
# Validate types
if not isinstance(self_st, ShardedTensor):
raise TypeError("input needs to be a ShardedTensor")
dev: torch.device
if self_st._local_shards:
dev = self_st._local_shards[0].tensor.device
elif pg and pg._get_backend_name() == "gloo":
dev = torch.device("cpu")
else:
dev = torch.device(torch.cuda.current_device())
return dev
@_sharded_op_impl(torch.Tensor.is_meta.__get__) # type: ignore[attr-defined]
def st_is_meta(types, args=(), kwargs=None, pg=None):
# pyrefly: ignore [bad-index]
return args[0].local_tensor().is_meta
def sharded_type_as_check(*args, **kwargs):
"""
Perform extra checks for the sharded_type_as op such as the input needs to
be either a Tensor or ShardedTensor.
Args: same as ``torch.Tensor.type_as``.
Return: None
"""
if len(args) < 2:
raise ValueError("Needs to give a tensor to cast type as!")
if not isinstance(args[1], torch.Tensor) and not isinstance(args[1], ShardedTensor):
raise ValueError("Needs to give a Tensor or ShardedTensor to cast type as!")
def same_dtype(*args, **kwargs):
"""
When the dtype is the same, return the original ShardedTensor.
Args: same as ``torch.Tensor.type_as``.
Return (bool): Whether to return early or not.
"""
return args[0].dtype == args[1].dtype
def sharded_type_as(args, kwargs, pg):
"""
Handles ``__torch_function__`` dispatch for the ``torch.Tensor.type_as`` op.
Args: same as ``torch.Tensor.type_as``.
Return:
new_local_shards (List[Shard]): Local shards for the new sharded tensor.
st_meta (ShardedTensorMetadata): Metadata of the new sharded tensor.
"""
st = args[0]
tensor = args[1]
if isinstance(tensor, ShardedTensor):
tensor = tensor.local_tensor()
new_local_shards = [
Shard(shard.tensor.type_as(tensor), shard.metadata)
for shard in st.local_shards()
]
st_meta = copy.deepcopy(st._metadata)
st_meta.tensor_properties.dtype = tensor.dtype
return new_local_shards, st_meta
_register_sharded_op_on_local_shards(
torch.Tensor.type_as,
early_stop_func=same_dtype,
extra_check=sharded_type_as_check,
customized_func=sharded_type_as,
)
def sharded_deepcopy(args, kwargs, pg):
# NOTE: we directly implement deepcopy magic method
# instead of using the default tensor.__deepcopy__
# and implement clone(). This is because the default
# tensor deepcopy copies every attribute, but the
# process_group in ShardedTensor cannot be deep copied.
self_st = args[0]
new_local_shards = copy.deepcopy(self_st.local_shards())
new_metadata = copy.deepcopy(self_st.metadata())
return new_local_shards, new_metadata
_register_sharded_op_on_local_shards(
torch.Tensor.__deepcopy__,
customized_func=sharded_deepcopy,
)
@_sharded_op_impl(torch.Tensor.copy_)
def sharded_inplace_copy(types, args, kwargs, pg):
# NOTE: inplace op don't need to rewrap
kwargs = {} if kwargs is None else kwargs
self_st = args[0]
new_st = args[1]
nonblocking = kwargs.get("non_blocking", False)
for local_shard, new_shard in zip(self_st.local_shards(), new_st.local_shards()):
if local_shard.metadata != new_shard.metadata:
raise RuntimeError(
"inplace copy can only happen between two ShardedTensor with same metadata!"
)
for local_shard, new_shard in zip(self_st.local_shards(), new_st.local_shards()):
local_shard.tensor.copy_(new_shard.tensor, nonblocking)
return self_st
def sharded_clone(args, kwargs, pg):
self_st = args[0]
desire_memory_format = kwargs.get("memory_format", None)
if desire_memory_format and desire_memory_format != torch.preserve_format:
raise RuntimeError("Only support torch.preserve_format for ShardedTensor!")
cloned_local_shards = [
Shard(
local_shard.tensor.clone(memory_format=desire_memory_format),
metadata=copy.deepcopy(local_shard.metadata),
)
for local_shard in self_st.local_shards()
]
new_metadata = copy.deepcopy(self_st.metadata())
return cloned_local_shards, new_metadata
_register_sharded_op_on_local_shards(
torch.Tensor.clone,
customized_func=sharded_clone,
)
def sharded_detach(args, kwargs, pg):
self_st = args[0]
detached_local_shards = [
Shard(
local_shard.tensor.detach(),
metadata=copy.deepcopy(local_shard.metadata),
)
for local_shard in self_st.local_shards()
]
new_metadata = copy.deepcopy(self_st.metadata())
new_metadata.tensor_properties.requires_grad = False
return detached_local_shards, new_metadata
_register_sharded_op_on_local_shards(
torch.Tensor.detach,
customized_func=sharded_detach,
)
@_sharded_op_impl(torch.Tensor.requires_grad_)
def tensor_requires_grad_set(types, args=(), kwargs=None, pg=None):
# pyrefly: ignore [bad-index]
self_st = args[0]
# Validate types
if not isinstance(self_st, ShardedTensor):
raise TypeError("input needs to be a ShardedTensor")
if kwargs is None:
kwargs = {}
requires_grad = args[1] if len(args) > 1 else kwargs.get("requires_grad", True)
if requires_grad == self_st.requires_grad:
return self_st
for local_shard in self_st.local_shards():
local_shard.tensor.requires_grad_(requires_grad)
# update the wrapper class property
with torch._C.DisableTorchFunctionSubclass():
self_st.requires_grad_(requires_grad)
# update the metadata in the meanwhile
self_st._metadata.tensor_properties.requires_grad = requires_grad
return self_st
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
# Copyright (c) Facebook, Inc. and its 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.
import logging
from torch.distributed._shard.sharded_tensor.logging_handlers import _log_handlers
__all__: list[str] = []
def _get_or_create_logger() -> logging.Logger:
logging_handler, log_handler_name = _get_logging_handler()
logger = logging.getLogger(f"sharding-spec-{log_handler_name}")
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter(
"%(asctime)s %(filename)s:%(lineno)s %(levelname)s p:%(processName)s t:%(threadName)s: %(message)s"
)
logging_handler.setFormatter(formatter)
logger.propagate = False
logger.addHandler(logging_handler)
return logger
def _get_logging_handler(
destination: str = "default",
) -> tuple[logging.Handler, str]:
log_handler = _log_handlers[destination]
log_handler_name = type(log_handler).__name__
return (log_handler, log_handler_name)
@@ -0,0 +1,16 @@
#!/usr/bin/env python3
# Copyright (c) Facebook, Inc. and its 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.
import logging
__all__: list[str] = []
_log_handlers: dict[str, logging.Handler] = {
"default": logging.NullHandler(),
}
@@ -0,0 +1,94 @@
# mypy: allow-untyped-defs
from dataclasses import dataclass, field
from enum import Enum
import torch
from torch.distributed._shard.metadata import ShardMetadata
class MEM_FORMAT_ENCODING(Enum):
TORCH_CONTIGUOUS_FORMAT = 0
TORCH_CHANNELS_LAST = 1
TORCH_PRESERVE_FORMAT = 2
@dataclass
class TensorProperties:
"""Properties used to create :class:`Tensor`"""
# Regular tensor fields
dtype: torch.dtype = field(default=torch.get_default_dtype())
layout: torch.layout = field(default=torch.strided)
requires_grad: bool = False
memory_format: torch.memory_format = field(default=torch.contiguous_format)
pin_memory: bool = False
def __getstate__(self):
# Since torch.memory_format cannot be pickled!
memory_format = self.memory_format
if memory_format == torch.contiguous_format:
mem_format_encoding = MEM_FORMAT_ENCODING.TORCH_CONTIGUOUS_FORMAT
elif memory_format == torch.channels_last:
mem_format_encoding = MEM_FORMAT_ENCODING.TORCH_CHANNELS_LAST
elif memory_format == torch.preserve_format:
mem_format_encoding = MEM_FORMAT_ENCODING.TORCH_PRESERVE_FORMAT
else:
raise RuntimeError(f"Invalid torch.memory_format: {memory_format}")
return (
self.dtype,
self.layout,
self.requires_grad,
mem_format_encoding,
self.pin_memory,
)
def __setstate__(
self,
state,
):
(
self.dtype,
self.layout,
self.requires_grad,
mem_format_encoding,
self.pin_memory,
) = state
if mem_format_encoding == MEM_FORMAT_ENCODING.TORCH_CONTIGUOUS_FORMAT:
memory_format = torch.contiguous_format
elif mem_format_encoding == MEM_FORMAT_ENCODING.TORCH_CHANNELS_LAST:
memory_format = torch.channels_last
elif mem_format_encoding == MEM_FORMAT_ENCODING.TORCH_PRESERVE_FORMAT:
memory_format = torch.preserve_format
else:
raise RuntimeError(
f"Invalid torch.memory_format encoding: {mem_format_encoding}"
)
self.memory_format = memory_format
@staticmethod
def create_from_tensor(tensor: torch.Tensor) -> "TensorProperties":
return TensorProperties(
dtype=tensor.dtype,
layout=tensor.layout,
requires_grad=tensor.requires_grad,
memory_format=torch.contiguous_format,
pin_memory=tensor.is_pinned(),
)
@dataclass
class ShardedTensorMetadata:
"""
Represents metadata for :class:`ShardedTensor`
"""
# Metadata about each shard of the Tensor
shards_metadata: list[ShardMetadata] = field(default_factory=list)
# Size of each dim of the overall Tensor.
size: torch.Size = field(default=torch.Size([]))
tensor_properties: TensorProperties = field(default_factory=TensorProperties)
@@ -0,0 +1,243 @@
# mypy: allow-untyped-defs
import copy
import torch
import torch.distributed as dist
import torch.distributed._shard.sharding_spec as shard_spec
from torch._C._distributed_c10d import ProcessGroup
from torch.distributed._shard.metadata import ShardMetadata
from torch.distributed._shard.sharding_spec._internals import (
get_chunked_dim_size,
get_split_size,
)
from torch.distributed.nn.functional import all_to_all, all_to_all_single
from .shard import Shard
def get_idx_from_placements(placements, current_rank) -> int:
"""
Return the position of the current rank in the given placements.
Args:
placements(List[Union[_remote_device, str]]):
Specifies the placement of each shard of the Tensor. The size of
the list represents the number of shards to be created. This could
be a list of
:class:`torch.distributed._remote_device`'s. This list
could also contain a string which represents remote
device as accepted by
:class:`torch.distributed._remote_device`
current_rank (int): number of current device.
Returns:
A int which contains the position of current device in the placement list.
"""
for idx, placement in enumerate(placements): # type: ignore[attr-defined]
if current_rank == placement.rank(): # type: ignore[union-attr]
return idx
raise RuntimeError("current_rank not in the placement.")
def build_reshard_metadata(
st_size: torch.Size,
sharding_spec: shard_spec.ShardingSpec,
world_size: int,
) -> tuple[list[ShardMetadata], list[int]]:
"""
Based the given sharding spec, we calculate the offset and local shard size.
We then build a ShardMetadata on top of the calculation result.
Args:
st_size (torch.Size): The size of the sharded tensor.
sharding_spec (:class:`torch.distributed._shard.sharding_spec.ShardingSpec`): The
specification describing how the tensor is sharded.
world_size (int): number of ranks.
Returns:
A Tuple of the followings:
A List[`ShardMetadata`] which contains the metadata for the shard, including
offsets, lengths and device placement.
A List[int] which contains the ranks in the order of placement.
"""
shard_dim = int(sharding_spec.dim) # type: ignore[attr-defined]
shards_metadata = [None] * world_size
ranks = []
offsets = [0] * len(st_size)
split_size = get_split_size(st_size[shard_dim], world_size)
for idx, placement in enumerate(sharding_spec.placements): # type: ignore[attr-defined]
ranks.append(placement.rank())
sharded_dim_size = get_chunked_dim_size(st_size[shard_dim], split_size, idx)
local_tensor_size = list(st_size)
local_tensor_size[shard_dim] = sharded_dim_size
shards_metadata[placement.rank()] = ShardMetadata( # type: ignore[call-overload]
shard_offsets=copy.deepcopy(offsets),
shard_sizes=local_tensor_size,
placement=placement,
)
offsets[shard_dim] += sharded_dim_size
return shards_metadata, ranks # type: ignore[return-value]
def reshuffle_local_shard(
local_shard: torch.Tensor,
st_size: torch.Size,
sharding_spec: shard_spec.ShardingSpec,
resharding_spec: shard_spec.ShardingSpec,
pg: ProcessGroup,
) -> tuple[list[Shard], list[ShardMetadata]]:
"""
Reshuffle the local shard directly when the reshard dim is same as the original
sharding dim. Logically we do this in two step:
1. To collect all shards based on original sharding spec.
2. Reshard the tensor based on the given resharding spec.
In reality, we consolidate the two steps into one by sending the local tensor to
the new shard directly based on the resharding spec.
Args:
local_shard (Tensor): Local tensor stored in the current rank.
st_size (torch.Size): The size of the sharded tensor.
sharding_spec (:class:`torch.distributed._shard.sharding_spec.ShardingSpec`): The
specification describing how the tensor is sharded originally.
resharding_spec (:class:`torch.distributed._shard.sharding_spec.ShardingSpec`): The
specification describing how the tensor will be resharded.
pg (ProcessGroup): The process group to aggregate on.
Returns:
A Tuple of the followings:
A List[`Shard`] which contains the local tensor and its metadata.
A List[`ShardMetadata`] which contains the metadata for the shard, including
offsets, lengths and device placement.
"""
current_rank = dist.get_rank(pg)
world_size = dist.get_world_size(pg)
# Build shards_metadata first.
shards_metadata, ranks = build_reshard_metadata(
st_size, resharding_spec, world_size
)
# Get input split size for all2all.
reshard_dim = int(resharding_spec.dim) # type: ignore[attr-defined]
split_size = get_split_size(st_size[reshard_dim], world_size)
input_split_sizes = [0] * world_size
idx = get_idx_from_placements(sharding_spec.placements, current_rank) # type: ignore[attr-defined]
new_rank = resharding_spec.placements[idx].rank() # type: ignore[union-attr, attr-defined]
input_split_sizes[new_rank] = local_shard.size(reshard_dim)
# Get output split size for all2all.
output_split_sizes = [0] * world_size
new_idx = ranks.index(current_rank)
sharded_dim_size = get_chunked_dim_size(st_size[reshard_dim], split_size, new_idx)
output_split_sizes[new_rank] = sharded_dim_size
# Get gathered_input for all2all.
local_shard = local_shard.transpose(0, reshard_dim).contiguous()
gathered_input_size = list(local_shard.size())
gathered_input_size[0] = sharded_dim_size
gathered_input = torch.empty(
gathered_input_size, device=local_shard.device, dtype=local_shard.dtype
)
# all2all.
local_shard = all_to_all_single(
gathered_input,
local_shard,
input_split_sizes=input_split_sizes,
output_split_sizes=output_split_sizes,
group=pg,
)
local_tensor = local_shard.transpose(0, reshard_dim).contiguous()
local_shards = [Shard(local_tensor, shards_metadata[current_rank])]
return local_shards, shards_metadata
def reshard_local_shard(
local_tensor: torch.Tensor,
st_size: torch.Size,
sharding_spec: shard_spec.ShardingSpec,
resharding_spec: shard_spec.ShardingSpec,
pg: ProcessGroup,
) -> tuple[list[Shard], list[ShardMetadata]]:
"""
Reshard a sharded tensor given the ``resharding_spec``. When the reshard dim is
different from the original sharding dim, we need to do two steps logically:
1. To collect all shards based on original sharding spec.
2. Reshard the tensor based on the given resharding spec.
In reality, we consolidate the two steps into one by sending each rank the new
shard based on the resharding spec.
Args:
local_tensor (Tensor): Local tensor stored in the current rank.
st_size (torch.Size): The size of the sharded tensor.
sharding_spec (:class:`torch.distributed._shard.sharding_spec.ShardingSpec`): The
specification describing how the tensor is sharded originally.
resharding_spec (:class:`torch.distributed._shard.sharding_spec.ShardingSpec`): The
specification describing how the tensor will be resharded.
pg (ProcessGroup): The process group to aggregate on.
Returns:
A Tuple of the followings:
A List[`Shard`] which contains the local tensor and its metadata.
A List[`ShardMetadata`] which contains the metadata for the shard, including
offsets, lengths and device placement.
"""
current_rank = dist.get_rank(pg)
world_size = dist.get_world_size(pg)
current_sharding_dim = int(sharding_spec.dim) # type: ignore[attr-defined]
reshard_dim = int(resharding_spec.dim) # type: ignore[attr-defined]
# Build shards_metadata first.
shards_metadata, ranks = build_reshard_metadata(
st_size, resharding_spec, world_size
)
# Compute expected size
input_split_sizes = [
metadata.shard_sizes[reshard_dim] for metadata in shards_metadata
]
rearrange_input = any(ranks[i] > ranks[i + 1] for i in range(len(ranks) - 1))
if rearrange_input:
# Need to re-arrange reshard_dim of local_tensor before all2all.
indices: list[int] = []
for metadata in shards_metadata:
offset_start_idx = metadata.shard_offsets[reshard_dim]
split_size = metadata.shard_sizes[reshard_dim]
indices += range(offset_start_idx, offset_start_idx + split_size)
local_tensor = local_tensor.index_select(
reshard_dim, torch.tensor(indices, device=local_tensor.device)
)
# Because reshard_dim != original shard_dim. We need to compute the
# size of tensor from each rank.
output_tensor_list = [torch.tensor(1)] * world_size
split_size = get_split_size(st_size[current_sharding_dim], world_size)
rearrange_output_list = False
indices = []
for idx, placement in enumerate(sharding_spec.placements): # type: ignore[attr-defined]
sharded_dim_size = get_chunked_dim_size(
st_size[current_sharding_dim], split_size, idx
)
output_tensor_size = list(st_size)
output_tensor_size[current_sharding_dim] = sharded_dim_size
output_tensor_size[reshard_dim] = input_split_sizes[current_rank]
output_tensor_list[placement.rank()] = torch.empty( # type: ignore[union-attr, index]
output_tensor_size, device=local_tensor.device, dtype=local_tensor.dtype
)
indices.append(placement.rank()) # type: ignore[union-attr, index, arg-type]
if idx != placement.rank(): # type: ignore[union-attr]
rearrange_output_list = True
# Perform autograd enabled all2all.
input_tensor_tuple = torch.split(local_tensor, input_split_sizes, dim=reshard_dim)
input_tensor_list = [tensor.contiguous() for tensor in input_tensor_tuple]
output_tensor_list = all_to_all(
output_tensor_list,
input_tensor_list,
group=pg,
)
if rearrange_output_list:
# Need to re-arrange original shard_dim of output_tensor_list.
output_tensor_list = [output_tensor_list[idx] for idx in indices] # type: ignore[call-overload]
local_tensor = torch.cat(output_tensor_list, dim=current_sharding_dim)
local_shards = [Shard(local_tensor, shards_metadata[current_rank])]
return local_shards, shards_metadata
@@ -0,0 +1,61 @@
from dataclasses import dataclass
import torch
from torch.distributed._shard.metadata import ShardMetadata
from torch.distributed.remote_device import _remote_device
@dataclass
class Shard:
"""
Container which holds the data for a shard as a Tensor and also
the associated metadata for that shard.
Args:
tensor(torch.Tensor): Local tensor for the shard.
metadata(:class `torch.distributed._shard.sharded_tensor.ShardMetadata`):
The metadata for the shard, including offsets, lengths and device placement.
"""
__slots__ = ["tensor", "metadata"]
tensor: torch.Tensor
metadata: ShardMetadata
def __post_init__(self) -> None:
# verification between local tensor and metadata
if list(self.tensor.size()) != self.metadata.shard_sizes:
raise ValueError(
"Shard tensor size does not match with metadata.shard_lengths! "
f"Found shard tensor size: {list(self.tensor.size())}, "
f"metadata.shard_lengths: {self.metadata.shard_sizes}, "
)
placement_device = self.metadata.placement
if (
placement_device is not None
and placement_device.device() != self.tensor.device
):
raise ValueError(
f"Local shard tensor device does not match with local Shard's placement! "
f"Found local shard tensor device: {self.tensor.device}, "
f"local shard metadata placement device: {placement_device.device()}"
)
@classmethod
def from_tensor_and_offsets(
cls, tensor: torch.Tensor, shard_offsets: list[int], rank: int
) -> "Shard":
"""
Creates a Shard of a ShardedTensor from a local torch.Tensor, shard_offsets and rank.
Args:
tensor(torch.Tensor): Local tensor for the shard.
shard_offsets(List[int]): List of integers specify the offset
of the shard on each dimension.
rank(int): Specify the rank for the shard.
"""
shard_sizes = list(tensor.size())
placement = _remote_device(f"rank:{rank}/{str(tensor.device)}")
shard_meta = ShardMetadata(
shard_offsets=shard_offsets, shard_sizes=shard_sizes, placement=placement
)
return Shard(tensor, shard_meta)
@@ -0,0 +1,327 @@
# mypy: allow-untyped-defs
import collections.abc
import copy
import itertools
from collections.abc import Sequence
from typing import TYPE_CHECKING
import torch
from torch.distributed import distributed_c10d as c10d, rpc
from torch.distributed._shard.sharding_spec._internals import (
check_tensor,
validate_non_overlapping_shards_metadata,
)
from .metadata import ShardedTensorMetadata, TensorProperties
from .shard import Shard
if TYPE_CHECKING:
from torch.distributed._shard.metadata import ShardMetadata
def _parse_and_validate_remote_device(pg, remote_device):
if remote_device is None:
raise ValueError("remote device is None")
worker_name = remote_device.worker_name()
rank = remote_device.rank()
device = remote_device.device()
# Validate rank, skip validation if rank is not part of process group.
if rank is not None and not c10d._rank_not_in_group(pg):
pg_global_ranks = c10d.get_process_group_ranks(pg)
if rank not in pg_global_ranks:
raise ValueError(
f"Global rank {rank} does not exist in input process group: {pg_global_ranks}"
)
if worker_name is not None:
if not rpc._is_current_rpc_agent_set():
raise RuntimeError(
f"RPC framework needs to be initialized for using worker names: {worker_name}"
)
workers = rpc._get_current_rpc_agent().get_worker_infos()
for worker in workers:
if worker.name == worker_name:
return worker.id, device
raise ValueError(f"Invalid worker name: {worker_name}")
return rank, device
def _validate_output_tensor_for_gather(
my_rank: int,
dst_rank: int,
size: torch.Size,
dst_tensor: torch.Tensor | None,
) -> None:
if dst_rank == my_rank:
if dst_tensor is None:
raise ValueError(
f"Argument ``dst_tensor`` must be specified on destination rank {dst_rank}"
)
if tuple(size) != (dst_tensor.size()):
raise ValueError(
f"Argument ``dst_tensor`` have size {tuple(dst_tensor.size())},"
f"but should be {tuple(size)}"
)
elif dst_tensor:
raise ValueError(
"Argument ``dst_tensor`` must NOT be specified on non-destination ranks."
)
def _flatten_tensor_size(size) -> torch.Size:
"""
Checks if tensor size is valid, then flatten/return a torch.Size object.
"""
if len(size) == 1 and isinstance(size[0], collections.abc.Sequence):
dims = list(*size)
else:
dims = list(size)
for dim in dims:
if not isinstance(dim, int):
raise TypeError(f"size has to be a sequence of ints, found: {dims}")
return torch.Size(dims)
def _raise_if_mismatch(expected, actual, prop_name, ranks, is_local=True):
if is_local:
if not isinstance(ranks, int):
raise AssertionError
if expected != actual:
raise ValueError(
f"Local shards' tensor {prop_name} property need to be the same on rank:{ranks}! "
f"Found one local shard tensor {prop_name}={expected}, "
f"the other local shard tensor {prop_name}={actual}."
)
else:
# compare failure check across ranks, ranks list should have two rank
if len(ranks) != 2:
raise AssertionError
if expected != actual:
raise ValueError(
f"ShardedTensor {prop_name} property does not match from different ranks! "
f"Found {prop_name}={expected} on rank:{ranks[0]}, "
f"and {prop_name}={actual} on rank:{ranks[1]}."
)
def build_metadata_from_local_shards(
local_shards: list[Shard],
global_size: torch.Size,
current_rank: int,
pg: c10d.ProcessGroup,
) -> ShardedTensorMetadata:
if len(local_shards) <= 0:
raise AssertionError("must have local shards!")
local_shard_metadatas: list[ShardMetadata] = []
first_shard_dtype = local_shards[0].tensor.dtype
first_shard_layout = local_shards[0].tensor.layout
first_shard_requires_grad = local_shards[0].tensor.requires_grad
first_shard_is_pinned = local_shards[0].tensor.is_pinned()
# 1). Validate local tensors and associated metadatas
for local_shard in local_shards:
local_shard_tensor = local_shard.tensor
local_shard_meta = local_shard.metadata
local_shard_metadatas.append(local_shard_meta)
rank, local_device = _parse_and_validate_remote_device(
pg, local_shard_meta.placement
)
if (
local_shard_tensor.layout != torch.strided
or local_shard_tensor.layout != first_shard_layout
):
raise ValueError(
f"Only torch.strided layout is currently supported, but found "
f"{local_shard_tensor.layout} on rank:{current_rank}!"
)
if not local_shard_tensor.is_contiguous():
raise ValueError(
"Only torch.contiguous_format memory_format is currently supported!"
)
if rank != current_rank:
raise ValueError(
f"Local shard metadata's rank does not match with the rank in its process group! "
f"Found current rank in the process group: {current_rank}, "
f"local ShardMetadata placement's rank: {rank}"
)
if local_shard_tensor.device != local_device:
raise ValueError(
f"Local shard tensor device does not match with local Shard's placement! "
f"Found local shard tensor device: {local_shard_tensor.device}, "
f"local shard metadata placement device: {local_device}"
)
_raise_if_mismatch(
local_shard_meta.shard_sizes,
list(local_shard_tensor.size()),
"size",
current_rank,
)
_raise_if_mismatch(
local_shard_tensor.is_pinned(),
first_shard_is_pinned,
"pin_memory",
current_rank,
)
_raise_if_mismatch(
local_shard_tensor.dtype, first_shard_dtype, "dtype", current_rank
)
_raise_if_mismatch(
local_shard_tensor.requires_grad,
first_shard_requires_grad,
"requires_grad",
current_rank,
)
# 2). Build a "local" ShardedTensorMetadata with all local shards on this rank, then
# do all_gather to collect local_sharded_tensor_metadata from all ranks
local_tensor_properties = TensorProperties(
dtype=first_shard_dtype,
layout=first_shard_layout,
requires_grad=first_shard_requires_grad,
memory_format=torch.contiguous_format,
pin_memory=first_shard_is_pinned,
)
local_sharded_tensor_metadata = ShardedTensorMetadata(
shards_metadata=local_shard_metadatas,
size=global_size,
tensor_properties=local_tensor_properties,
)
return local_sharded_tensor_metadata
def build_global_metadata(
gathered_metadatas: Sequence[ShardedTensorMetadata | None],
recalc_metadata: bool = False,
):
global_sharded_tensor_metadata = None
global_metadata_rank = 0
# pyrefly: ignore [bad-assignment]
for rank, rank_metadata in enumerate(gathered_metadatas):
if rank_metadata is None:
continue
if global_sharded_tensor_metadata is None:
global_sharded_tensor_metadata = copy.deepcopy(rank_metadata)
global_metadata_rank = rank
else:
_raise_if_mismatch(
global_sharded_tensor_metadata.size,
rank_metadata.size,
"global_size",
[global_metadata_rank, rank],
is_local=False,
)
# don't need to check layout and memory format as we already checked in local shards validation stage
_raise_if_mismatch(
global_sharded_tensor_metadata.tensor_properties.dtype,
rank_metadata.tensor_properties.dtype,
"dtype",
[global_metadata_rank, rank],
is_local=False,
)
_raise_if_mismatch(
global_sharded_tensor_metadata.tensor_properties.requires_grad,
rank_metadata.tensor_properties.requires_grad,
"requires_grad",
[global_metadata_rank, rank],
is_local=False,
)
_raise_if_mismatch(
global_sharded_tensor_metadata.tensor_properties.pin_memory,
rank_metadata.tensor_properties.pin_memory,
"pin_memory",
[global_metadata_rank, rank],
is_local=False,
)
# pass all validations, extend shards metadata
global_sharded_tensor_metadata.shards_metadata.extend(
rank_metadata.shards_metadata
)
if global_sharded_tensor_metadata is not None:
if recalc_metadata:
recalc_global_sharded_tensor_metadata(
global_sharded_tensor_metadata,
0, # sharded on 0th dim
)
# check if shards_metadata have overlap shards
validate_non_overlapping_shards_metadata(
global_sharded_tensor_metadata.shards_metadata
)
# check if the shards_metadata is compatible with global size of the sharded tensor.
check_tensor(
global_sharded_tensor_metadata.shards_metadata,
global_sharded_tensor_metadata.size,
)
else:
raise ValueError("ShardedTensor have no local shards on all ranks!")
return global_sharded_tensor_metadata
def recalc_global_sharded_tensor_metadata(
global_sharded_tensor_metadata: ShardedTensorMetadata, sharded_dim: int
) -> None:
# recalculate global ShardedTensorMetadata
# reorder here in case shard metadata is not sorted on sharded_dim
placement_idx_pairs = []
for i, shard_metadata in enumerate(global_sharded_tensor_metadata.shards_metadata):
if shard_metadata.placement:
placement_idx_pairs.append((shard_metadata.placement.rank(), i))
else:
raise AssertionError(
"currently only support rw, it should always have valid rank info"
)
sorted_idx = sorted(placement_idx_pairs)
shard_sizes = [
global_sharded_tensor_metadata.shards_metadata[idx].shard_sizes[sharded_dim]
for _, idx in sorted_idx
]
cum_sum = [0] + list(itertools.accumulate(shard_sizes))
for shard_id, shard_metadata in enumerate(
global_sharded_tensor_metadata.shards_metadata
):
# update shard offset for each shard on the sharded dimension
shard_metadata.shard_offsets[sharded_dim] = cum_sum[shard_id]
for other_dim in range(
len(global_sharded_tensor_metadata.shards_metadata[0].shard_sizes)
):
if other_dim != sharded_dim:
# shard offset for each shard on the unsharded dimension
shard_metadata.shard_offsets[other_dim] = 0
# update global size for ShardedTensorMetadata
global_size_list = []
for other_dim in range(
len(global_sharded_tensor_metadata.shards_metadata[0].shard_sizes)
):
if other_dim != sharded_dim:
global_size_list.append(
global_sharded_tensor_metadata.shards_metadata[0].shard_sizes[other_dim]
)
else:
global_size_list.append(cum_sum[-1])
global_sharded_tensor_metadata.size = torch.Size(global_size_list)
@@ -0,0 +1,29 @@
import abc
import torch.nn as nn
class Sharder(abc.ABC):
"""
This is an interface which allows user to create more advanced
sharding strategies that are not easily be composed by the
`ShardingSpec`.
:class:`torch.distributed._shard.sharding_plan.ShardingPlan` could
take an object of the `Sharder` and call `shard` to shard the module,
then replace the original module with sharded module returned.
"""
@abc.abstractmethod
def shard(self, module: nn.Module) -> nn.Module:
"""
Shard a module base on the implementation of this method, and
return the sharded version of the module.
Args:
module (:class:`torch.nn.Module`):
The module to apply sharding to.
Returns:
A :class:`torch.nn.Module` object that represents a module
that's already been sharded.
"""
@@ -0,0 +1 @@
from .api import ShardingPlan, ShardingPlanner
@@ -0,0 +1,86 @@
import abc
from dataclasses import dataclass
import torch.nn as nn
from torch.distributed._shard.sharder import Sharder
from torch.distributed._shard.sharding_spec import ShardingSpec
@dataclass
class ShardingPlan:
"""
Representation of a sharding plan, describes how to shard a module
across hosts. `plan` is used to shard module parameters according to the spec provided,
`output_plan` and `return_local_tensor` are optional, they are used to specify the output
layout of a module with a spec, and when to convert back to data parallel fashion.
Args:
plan (Dict[str, Union[:class:`torch.distributed._shard.sharding_spec.ShardingSpec`,
:class:`torch.distributed._shard.sharder.Sharder`]):
a dict describes how to shard a module, there're currently two ways to shard a module:
1. directly shard a module parameter by a `ShardingSpec`, keyed by the name of
a parameter to a `ShardingSpec`.
2. shard a submodule by applying a `Sharder` on it, keyed by the name of a module
to a `Sharder` object.
output_plan (Dict[str, :class:`torch.distributed._shard.sharding_spec.ShardingSpec`), optional):
a dict specifies the layout of a module's output which produces a ShardedTensor,
keyed by the name of module to ShardingSpec("" in key means the root module).
Default: `None`
return_local_tensor (List[str], optional): a list of string, each element enables
a module's sharded output to be returned as a Tensor from its local shards to
ensure further processing in a data parallel fashion. ("" in list means the
root module).
Default: None
Example:
Suppose we want to shard a module with two linear layers and then run it with DDP, we also
want to convert the output of the second linear layer back to DDP, we can do it as follows:
>>> # xdoctest: +REQUIRES(module:torch._C._distributed_c10d)
>>> class MyModule(nn.Module):
>>> def __init__(self) -> None:
>>> super().__init__()
>>> self.fc1 = nn.Linear()
>>> self.gelu = nn.GELU()
>>> self.fc2 = nn.Linear()
>>> self.relu = nn.Linear()
>>>
>>> def forward(self, input):
>>> return self.relu(self.fc2(self.gelu(self.fc1(input))))
>>> # xdoctest: +SKIP("Undefined spec1, spec2)
>>> sharding_plan = ShardingPlan(
>>> plan={
>>> "fc1.weight": spec1,
>>> "fc2.weight": spec2
>>> },
>>> output_plan={
>>> "fc2": output_spec
>>> },
>>> return_local_tensor=["fc2"]
>>> )
"""
plan: dict[str, ShardingSpec | Sharder]
output_plan: dict[str, ShardingSpec] | None = None
return_local_tensor: list[str] | None = None
class ShardingPlanner(abc.ABC):
"""
Default ShardingPlanner interface, can be extended and
implement advanced sharding strategies.
"""
@abc.abstractmethod
def build_plan(self, module: nn.Module) -> ShardingPlan:
"""
Given a nn.Module, define how to shard the module across
ranks, return a ShardingPlan
Args:
module (:class:`torch.nn.Module`):
The module to apply sharding to.
Returns:
A :class:`torch.distributed._shard.sharding_plan.ShardingPlan` object that
represents how to shard the module.
"""
@@ -0,0 +1,10 @@
from torch.distributed._shard.metadata import ShardMetadata
from .api import (
_infer_sharding_spec_from_shards_metadata,
DevicePlacementSpec,
EnumerableShardingSpec,
PlacementSpec,
ShardingSpec,
)
from .chunk_sharding_spec import ChunkShardingSpec as ChunkShardingSpec
@@ -0,0 +1,244 @@
# mypy: allow-untyped-defs
import math
import sys
from bisect import bisect_right, insort
from torch.distributed._shard.metadata import ShardMetadata
def _check_shard_metadata_pair_overlap(shard1: ShardMetadata, shard2: ShardMetadata):
"""
Checks if two shards overlap.
"""
# For each dim of each shard, check if one shard resides on the other
# end of second shard with respect to that dim. As an example for a 2D
# shard, we would check if one shard is above or on the left of the
# other shard.
ndims = len(shard1.shard_offsets)
for i in range(ndims):
if shard1.shard_offsets[i] >= shard2.shard_offsets[i] + shard2.shard_sizes[i]:
return False
if shard2.shard_offsets[i] >= shard1.shard_offsets[i] + shard1.shard_sizes[i]:
return False
return True
def _find_nd_overlapping_shards(
shards: list[ShardMetadata], sharded_dims: list[int]
) -> tuple[int, int] | None:
"""Find overlapping shards using sweep-line algorithm."""
if len(shards) <= 1:
return None
dims = len(sharded_dims)
if dims == 0:
return None
sweep_dim_idx = 0
if dims > 1:
max_size = 0
for i, dim in enumerate(sharded_dims):
dim_size = shards[0].shard_offsets[dim] + shards[0].shard_sizes[dim]
if dim_size > max_size:
max_size = dim_size
sweep_dim_idx = i
sweep_dim = sharded_dims[sweep_dim_idx]
sorted_indices = sorted(
range(len(shards)),
key=lambda idx: (
shards[idx].shard_offsets[sweep_dim],
*(shards[idx].shard_offsets[d] for d in sharded_dims if d != sweep_dim),
),
)
active: list[tuple[int, int]] = []
for idx in sorted_indices:
current = shards[idx]
start = current.shard_offsets[sweep_dim]
end = start + current.shard_sizes[sweep_dim]
cutoff = bisect_right(active, (start, sys.maxsize))
if cutoff:
del active[:cutoff]
for _, other_idx in active:
other = shards[other_idx]
if _check_shard_metadata_pair_overlap(current, other):
return (other_idx, idx)
insort(active, (end, idx))
return None
def _find_1d_overlapping_shards(
shards: list[ShardMetadata], dim: int
) -> tuple[int, int] | None:
# (begin, end, index_in_shards). Begin and end are inclusive.
intervals = [
(s.shard_offsets[dim], s.shard_offsets[dim] + s.shard_sizes[dim] - 1, i)
for i, s in enumerate(shards)
]
intervals.sort()
for i in range(len(shards) - 1):
if intervals[i][1] >= intervals[i + 1][0]:
return (intervals[i][2], intervals[i + 1][2])
return None
def validate_non_overlapping_shards_metadata(shards: list[ShardMetadata]):
"""
Ensures none of the shards overlap with each other.
Args:
shards(List[ShardMetadata]): List of :class:`ShardMetadata` objects representing
each shard.
Raises:
``ValueError`` if there's overlap in any two shards.
"""
if not shards or len(shards) == 1:
return
sharded_dims: list[int] = []
for dim in range(len(shards[0].shard_offsets)):
for i in range(1, len(shards)):
if (
shards[i].shard_offsets[dim] != shards[0].shard_offsets[dim]
or shards[i].shard_sizes[dim] != shards[0].shard_sizes[dim]
):
sharded_dims.append(dim)
break
pair: tuple[int, int] | None = None
if len(sharded_dims) == 0:
# if shard is all zeros, we should consider as pass
all_zeros: bool = all(
# strictly limited all offsets to be 0 to pass
# could loose it later on
shard.shard_offsets == [0] * len(shards[0].shard_offsets)
and math.prod(shard.shard_sizes) == 0 # one dimension is 0
for shard in shards
)
if all_zeros:
return
# All shards are the same, all dims are not partitioned. Choose any 2.
pair = (0, 1)
elif len(sharded_dims) == 1:
# Shards are partitioned over only one dimension. Overlap can be found
# using a O(nlogn) overlapping interval algorithm.
pair = _find_1d_overlapping_shards(shards, sharded_dims[0])
else:
# Shards are partitioned over more than one dimension.
# Use sweep-line algorithm for O(n log n) complexity.
pair = _find_nd_overlapping_shards(shards, sharded_dims)
if pair:
raise ValueError(f"Shards {shards[pair[0]]} and {shards[pair[1]]} overlap")
def check_tensor(shards_metadata, tensor_dims) -> None:
"""
Checks if the shards_metadata is compatible with the provided tensor dims.
Args:
shards_metadata(List[ShardMetadata]): List of :class:`ShardMetadata`
objects representing each shard of the tensor.
tensor_dims(Sequence of int): Dimensions of tensor to verify
Raises:
``ValueError`` if not compatible.
"""
# If the tensor's volume matches the total volume of all shards and
# all shard boundaries are within tensor dims, we have a compatible
# sharding spec for this tensor. Note that we have already verified
# we don't have overlapping shards.
tensor_rank = len(tensor_dims)
shards_rank = len(shards_metadata[0].shard_offsets)
if tensor_rank != shards_rank:
raise ValueError(
f"Rank of tensor is {tensor_rank}, but shards rank is {shards_rank}"
)
total_shard_volume = 0
for shard in shards_metadata:
shard_volume = 1
for i, shard_length in enumerate(shard.shard_sizes):
shard_volume *= shard_length
if shard.shard_offsets[i] + shard.shard_sizes[i] > tensor_dims[i]:
raise ValueError(
f"Shard offset {shard.shard_offsets[i]} and length "
f"{shard.shard_sizes[i]} exceeds tensor dim: {tensor_dims[i]} for shard {shard}"
)
total_shard_volume += shard_volume
tensor_volume = 1
for size in tensor_dims:
tensor_volume *= size
if total_shard_volume != tensor_volume:
# TODO: Can we improve this error message to point out the gaps?
raise ValueError(
f"Total volume of shards: {total_shard_volume} "
f"does not match tensor volume: {tensor_volume}, in other words "
f"all the individual shards do not cover the entire tensor"
)
def get_split_size(dim_size, chunks):
"""
Computes the split size inline with ``torch.chunk``
Args:
dim_size(int): Size of the dimension being chunked.
chunks(int): Number of chunks to create for ``dim_size``.
Returns:
An int indicating the split size to use.
"""
return (dim_size + chunks - 1) // chunks
def get_chunked_dim_size(dim_size, split_size, idx):
"""
Computes the dim size of the chunk for provided ``idx`` given ``dim_size``
and ``split_size``.
Args:
dim_size(int): Size of the dimension being chunked.
split_size(int): The chunk size for each chunk of ``dim_size``.
idx(int): The index of chunk whose dim size is being requested.
Returns:
An int indicating the dim size of the chunk.
"""
return max(min(dim_size, split_size * (idx + 1)) - split_size * idx, 0)
def get_chunk_sharding_params(sharding_dim_size, world_size, spec, rank):
"""
Generate the start pos and offset length for the current rank for
chunk sharding.
Args:
sharding_dim_size(int): The dimension length which we shard on.
world_size(int): number of ranks.
spec (:class:`torch.distributed._shard.sharding_spec.ChunkShardingSpec`):
sharding spec.
rank(int): # of cuda process.
Returns:
start_pos(int): start position of sharded tensor on the given rank.
chunk_size(int): chunk size of sharded tensor on the given rank.
"""
split_size = get_split_size(sharding_dim_size, world_size)
current_offsets = 0
start_pos = current_offsets
for idx, placement in enumerate(spec.placements):
chunk_size = get_chunked_dim_size(sharding_dim_size, split_size, idx)
if rank == placement.rank():
start_pos = current_offsets
break
current_offsets += chunk_size
return start_pos, chunk_size # type: ignore[possibly-undefined]
@@ -0,0 +1,264 @@
# mypy: allow-untyped-defs
import functools
import operator
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
import torch
import torch.distributed._shard.sharded_tensor.metadata as sharded_tensor_meta
from torch.distributed._shard.metadata import ShardMetadata
from torch.distributed._shard.op_registry_utils import _decorator_func
from ._internals import (
check_tensor,
get_chunked_dim_size,
get_split_size,
validate_non_overlapping_shards_metadata,
)
if TYPE_CHECKING:
# Only include ShardedTensor when do type checking, exclude it
# from run-time to resolve circular dependency.
from torch.distributed._shard.sharded_tensor import ShardedTensor
class PlacementSpec(ABC): # noqa: B024
"""
Base class representing the placement of an entity. Subclasses of this
class can be used to specify customized placements which might not be
covered by existing APIs.
"""
@dataclass
class DevicePlacementSpec(PlacementSpec):
"""
Associates placement of an entity with a single device.
Args:
device(:class:`torch.distributed._remote_device`): The device to place the entity on.
"""
device: torch.distributed._remote_device
def __post_init__(self):
if not isinstance(self.device, torch.distributed._remote_device):
self.device = torch.distributed._remote_device(self.device)
class ShardingSpec(ABC):
"""
Base class representing sharding specifications.
"""
@abstractmethod
def build_metadata(
self,
tensor_sizes: torch.Size,
tensor_properties: sharded_tensor_meta.TensorProperties,
) -> sharded_tensor_meta.ShardedTensorMetadata:
"""
Given a global tensor size, define how to shard a tensor like this shape
across ranks, return ShardedTensorMetadata
Args:
tensor_sizes (:class:`torch.Size`):
The tensor shape to shard on, a `torch.Size` object that represents the
tensor shape to be sharded according to the ShardingSpec.
tensor_properties(:class:`torch.distributed._shard.sharded_tensor.TensorProperties):
Tensor properties used to create a ShardedTensor.
Returns:
A :class:`ShardedTensorMetadata` object that encodes the information about
the layout of the ShardedTensor and its properties.
"""
@abstractmethod
def shard(
self, tensor: torch.Tensor, src_rank: int = 0, process_group=None
) -> "ShardedTensor":
"""
Given a global tensor on src_rank, shard this tensor
across ranks within the process group, return a ShardedTensor.
Args:
tensor (:class:`torch.Tensor`): Tensor needs to be sharded.
Keyword args:
src_rank (int, optional): The source rank which is used as the ground truth of
the data for the parameter that would be sharded and scattered
across the rest of the ranks.
Default: 0.
process_group (ProcessGroup, optional): The process group to work on. If None,
the default process group will be used.
Returns:
A :class:`ShardedTensor` sharded from the given tensor.
"""
# Ops customized for a particular ShardingSpec.
_CUSTOM_SHARDING_SPEC_OPS: dict[str, dict[Callable, Callable]] = {}
def _has_custom_op(sharding_spec, op):
"""
Returns whether or not the ShardingSpec has a custom op implementation.
"""
class_name = type(sharding_spec).__qualname__
return (
class_name in _CUSTOM_SHARDING_SPEC_OPS
and op in _CUSTOM_SHARDING_SPEC_OPS[class_name]
)
def _dispatch_custom_op(
sharding_spec, op: Callable, types, args, kwargs, process_group
):
"""
Calls the custom op for this ShardingSpec if it exists.
"""
class_name = type(sharding_spec).__qualname__
if not _has_custom_op(sharding_spec, op):
raise RuntimeError(f"Custom op: {op} not registered for {class_name}")
func = _CUSTOM_SHARDING_SPEC_OPS[class_name][op]
return func(types, args, kwargs, process_group)
def custom_sharding_spec_op(sharding_spec_class, func):
"""
Decorator to allow custom registration of ops.
Args:
sharding_spec_class(type): The ShardingSpec for which we need to add this custom op.
func(Callable): The op to override (ex: torch.bmm)
"""
class_name = sharding_spec_class.__qualname__
if class_name not in _CUSTOM_SHARDING_SPEC_OPS:
_CUSTOM_SHARDING_SPEC_OPS[class_name] = {}
return functools.partial(
_decorator_func, op=func, op_table=_CUSTOM_SHARDING_SPEC_OPS[class_name]
)
@dataclass
class EnumerableShardingSpec(ShardingSpec):
"""
This is a type of PlacementSpec that allows users to specify a generic
sharding scheme by enumerating exactly how each shard is laid out.
Args:
shards(List[ShardMetadata]): List of :class:`ShardMetadata` objects representing
each shard. Note that none of the shards should overlap.
"""
shards: list[ShardMetadata]
def __post_init__(self):
if len(self.shards) == 0:
raise ValueError(f"Empty shard list provided: {self.shards}")
# Validate each shard has same rank.
rank = -1
for shard in self.shards:
if rank != -1 and rank != len(shard.shard_offsets):
raise ValueError(
f"Found inconsistent ranks for shards: {rank} and {len(shard.shard_offsets)}"
)
rank = len(shard.shard_offsets)
validate_non_overlapping_shards_metadata(self.shards)
def build_metadata(
self,
tensor_sizes: torch.Size,
tensor_properties: sharded_tensor_meta.TensorProperties,
) -> sharded_tensor_meta.ShardedTensorMetadata:
# check if shards form a valid tensor
check_tensor(self.shards, tensor_sizes)
return sharded_tensor_meta.ShardedTensorMetadata(
self.shards, tensor_sizes, tensor_properties
)
def shard(
self, tensor: torch.Tensor, src_rank: int = 0, process_group=None
) -> "ShardedTensor":
# TODO: figure out a generic and efficient way to scatter the shards for EnumerableShardingSpec
raise NotImplementedError("EnumerableShardingSpec.shard not implemented yet!")
def _infer_sharding_spec_from_shards_metadata(shards_metadata):
"""
Infer the sharding spec from the metadata of each shard of a ShardedTensor.
If the tensor is sharded only on one dimension, we can then verify whether it's
a ChunkShardingSpec or not. The way to verify it is to first get the total length
and perform a chunk sharding with the given placements to see if we can have the
same chunk size as the given shards_metadata. If not, we assume it's enum sharded.
Args:
shards_metadata (List[ShardMetadata]): List of Metadata of local shards.
Returns:
A :class:`torch.distributed._shard.sharding_spec.ShardingSpec` object of sharding
spec for one sharded tensor.
"""
placements = []
chunk_sharding_dim = None
chunk_offset_list = []
shard_size_list = []
shard_offset_list = []
# collect local shard metadatas from the global sharded_tensor_metadata
for shard_metadata in shards_metadata: # type: ignore[attr-defined]
placements.append(shard_metadata.placement)
local_offsets = shard_metadata.shard_offsets
chunk_offset_list.append(sum(local_offsets))
shard_size_list.append(shard_metadata.shard_sizes)
shard_offset_list.append(shard_metadata.shard_offsets)
shard_dims = [idx for idx, e in enumerate(local_offsets) if e != 0]
# If the offset is [0, 0, ..., 0] (all zeros),
# we cannot decide whether how the tensor is sharded.
if len(shard_dims) == 0:
continue
# If the offset is [0, N, .,0, M, 0, .., 0],
# we are sure it's sharded by more than one dimension.
if len(shard_dims) != 1:
chunk_sharding_dim = None
break
# If the offset is [0, 0, .,0, M, 0, .., 0], aka, it's sharded by just
# one dimension, we need to make sure all ranks share the same dimension.
if not chunk_sharding_dim:
chunk_sharding_dim = shard_dims[0]
elif chunk_sharding_dim != shard_dims[0]:
chunk_sharding_dim = None
break
if chunk_sharding_dim is not None:
# Ensure we infer the correct placement order from offsets
placements = [
x
for _, x in sorted(
zip(chunk_offset_list, placements), key=operator.itemgetter(0)
)
]
from .chunk_sharding_spec import ChunkShardingSpec
chunk_spec = ChunkShardingSpec(
dim=chunk_sharding_dim,
placements=placements,
)
shard_sizes = sorted([x[chunk_sharding_dim] for x in shard_size_list])
shard_total_length = sum(shard_sizes)
shard_offsets = sorted([x[chunk_sharding_dim] for x in shard_offset_list])
chunks = len(placements)
split_size = get_split_size(shard_total_length, chunks)
chunk_shard_sizes = sorted(
[
get_chunked_dim_size(shard_total_length, split_size, idx)
for idx in range(chunks)
]
)
# Should match ChunkShardingSpec offsets calculation
chunk_shard_offsets = [split_size * idx for idx in range(chunks)]
if shard_sizes == chunk_shard_sizes and shard_offsets == chunk_shard_offsets:
return chunk_spec
return EnumerableShardingSpec(shards_metadata)
@@ -0,0 +1,232 @@
# mypy: allow-untyped-defs
from dataclasses import dataclass
from typing import cast, TYPE_CHECKING
import torch
import torch.distributed as dist
import torch.distributed._shard.sharded_tensor.metadata as sharded_tensor_meta
import torch.distributed.distributed_c10d as distributed_c10d
from torch.distributed._shard._utils import narrow_tensor
from torch.distributed._shard.metadata import ShardMetadata
from torch.distributed._shard.sharded_tensor.shard import Shard
from torch.distributed._shard.sharded_tensor.utils import (
_parse_and_validate_remote_device,
)
from ._internals import get_chunked_dim_size, get_split_size
from .api import ShardingSpec
if TYPE_CHECKING:
# Only include ShardedTensor when do type checking, exclude it
# from run-time to resolve circular dependency.
from torch.distributed._shard.sharded_tensor import ShardedTensor
@dataclass
class ChunkShardingSpec(ShardingSpec):
"""
This is a type of PlacementSpec that defines the placement as being sharded
across multiple devices. In particular, it represents sharding a Tensor
along a single dimension into equal chunks (similar to :meth:`torch.chunk`).
The semantics of how a tensor is partitioned is inline with
:meth:`torch.chunk`, where ``dim`` in torch.chunk corresponds to the
specified ``dim`` and ``chunks`` in torch.chunk is the number of elements
in the placement specified.
Args:
dim (int or str):
The dimension to shard on, could be an integer representing the
dimension or a string in case of named tensors where dimensions are
named. Note that named tensor support is not added yet.
placement(List[Union[_remote_device, str]]):
Specifies the placement of each shard of the Tensor. The size of
the list represents the number of shards to be created. This could
be a list of
:class:`torch.distributed._remote_device`'s. This list
could also contain a string which represents remote
device as accepted by
:class:`torch.distributed._remote_device`
"""
ShardingDim = int | str
dim: ShardingDim
placements: list[torch.distributed._remote_device | str]
def __post_init__(self):
self._verify_dim(self.dim)
for i, remote_device in enumerate(self.placements):
if not isinstance(remote_device, torch.distributed._remote_device):
self.placements[i] = torch.distributed._remote_device(remote_device)
@staticmethod
def _verify_dim(dim):
# Validate the sharding spec.
# TODO: support named dimension
if isinstance(dim, str):
raise NotImplementedError(
"ChunkShardingSpec does not support named dimension yet!"
)
if not isinstance(dim, int):
raise ValueError(f"Sharding dim needs to be an integer, found: {dim}")
def build_metadata(
self,
tensor_sizes: torch.Size,
tensor_properties: sharded_tensor_meta.TensorProperties,
) -> sharded_tensor_meta.ShardedTensorMetadata:
tensor_num_dim = len(tensor_sizes)
self._verify_dim(self.dim)
if self.dim >= tensor_num_dim or self.dim < -tensor_num_dim: # type: ignore[operator]
raise ValueError(f"Invalid sharding dim: {self.dim}")
shards_metadata = []
sharding_dim_size = tensor_sizes[self.dim] # type: ignore[index]
chunks = len(self.placements)
split_size = get_split_size(sharding_dim_size, chunks)
for idx, placement in enumerate(self.placements):
# generate ShardMetadata for each placement device
chunked_dim_size = get_chunked_dim_size(sharding_dim_size, split_size, idx)
shard_size = list(tensor_sizes)
current_offsets = [0] * tensor_num_dim
current_offsets[self.dim] = split_size * idx # type: ignore[index]
shard_size[self.dim] = chunked_dim_size # type: ignore[index]
shard_metadata = ShardMetadata(
shard_offsets=current_offsets,
shard_sizes=shard_size,
placement=placement,
)
shards_metadata.append(shard_metadata)
return sharded_tensor_meta.ShardedTensorMetadata(
shards_metadata, tensor_sizes, tensor_properties
)
def shard(
self, tensor: torch.Tensor, src_rank: int = 0, process_group=None
) -> "ShardedTensor":
"""
Args:
src_rank: group rank relative to ``process_group``
N.B. If ``process_group`` is None, ``src_rank`` is a global rank.
"""
# relative imports to avoid circular dependency
from torch.distributed._shard.sharded_tensor import ShardedTensor
tensor_properties = sharded_tensor_meta.TensorProperties(
dtype=tensor.dtype,
layout=tensor.layout,
requires_grad=tensor.requires_grad,
memory_format=torch.contiguous_format,
pin_memory=tensor.is_pinned(),
)
current_rank = dist.get_rank(process_group)
current_global_rank = dist.get_rank()
tensor_meta = self.build_metadata(tensor.size(), tensor_properties)
local_shards = []
local_tensor = None
local_metadata = None
tensors_to_scatter = cast(
list[torch.Tensor | None],
[None] * dist.get_world_size(process_group),
)
sharding_dim_size = tensor.size()[self.dim] # type: ignore[index]
chunks = len(self.placements)
split_size = get_split_size(sharding_dim_size, chunks)
scatter_shape = list(tensor.size())
scatter_shape[self.dim] = split_size # type: ignore[index]
for shard_meta in tensor_meta.shards_metadata:
remote_global_rank, device = _parse_and_validate_remote_device(
process_group, shard_meta.placement
)
if current_rank == src_rank:
# Reshape to get shard for this rank and we don't want autograd
# recording here for the narrow op and 'local_shard' should be a
# leaf variable in the autograd graph.
narrowed_tensor = narrow_tensor(tensor, shard_meta)
if shard_meta.shard_sizes[self.dim] < split_size: # type: ignore[index]
# for the last shard that might be smaller to other shards
# resize the narrowed tensor to the same size and use it for
# the scatter collective as dist.scatter requires same size
# inputs on every rank
tensor_to_scatter = (
narrowed_tensor.detach().clone().resize_(scatter_shape)
)
else:
tensor_to_scatter = narrowed_tensor.detach().clone(
memory_format=torch.contiguous_format
)
tensors_to_scatter[
# pyrefly: ignore [bad-argument-type]
dist.get_group_rank(process_group, remote_global_rank)
] = tensor_to_scatter
if current_global_rank == remote_global_rank:
local_tensor = torch.empty(
scatter_shape,
dtype=tensor.dtype,
layout=tensor.layout,
device=device,
)
local_metadata = shard_meta
# each rank should have local_tensor and local_metadata initialized if we build
# the metadata list in a correct way.
if local_tensor is None:
raise AssertionError
if local_metadata is None:
raise AssertionError
# Scatter the shards to all ranks in the pg
# scatter takes the global rank as ``src``
src_for_scatter = src_rank
if (
process_group is not None
and process_group is not distributed_c10d._get_default_group()
):
src_for_scatter = distributed_c10d.get_global_rank(
process_group, src_for_scatter
)
tensors_to_scatter_: list[torch.Tensor] | None = None
if current_rank == src_rank:
tensors_to_scatter_ = []
for t in tensors_to_scatter:
if not isinstance(t, torch.Tensor):
raise AssertionError
tensors_to_scatter_.append(t)
dist.scatter(
local_tensor,
scatter_list=tensors_to_scatter_,
src=src_for_scatter,
group=process_group,
)
if list(local_tensor.size()) != local_metadata.shard_sizes:
# detach again after receiving to ensure local shards remain a leaf node
local_tensor = local_tensor.resize_(local_metadata.shard_sizes).detach()
# Sync requires_grad to local_shard.
local_tensor.requires_grad = tensor.requires_grad
local_shards.append(Shard(tensor=local_tensor, metadata=local_metadata))
st = ShardedTensor._init_from_local_shards_and_global_metadata(
local_shards, tensor_meta, process_group=process_group
)
# Manually set sharding_spec
st._sharding_spec = self
return st
@@ -0,0 +1,350 @@
# mypy: allow-untyped-defs
import torch
import torch.distributed as dist
from torch.distributed._shard.sharded_tensor import ShardedTensor
from torch.distributed._shard.sharded_tensor._ops._common import _sharded_op_common
from torch.distributed._shard.sharding_spec import ChunkShardingSpec
from torch.distributed._shard.sharding_spec._internals import (
get_chunk_sharding_params,
get_chunked_dim_size,
get_split_size,
)
from torch.distributed._shard.sharding_spec.api import custom_sharding_spec_op
from torch.distributed.nn.functional import (
_all_gather_base,
all_reduce,
all_to_all_single,
)
def _chunk_sharding_spec_check(spec, op):
"""
For the given op implementation check if the sharding spec is ChunkShardingSpec.
"""
if not isinstance(spec, ChunkShardingSpec):
raise NotImplementedError(
f"Only ChunkShardingSpec supported for '{op.__name__}'."
)
def _register_sharded_op_on_local_tensor(
op, early_stop_func=None, extra_check=None, customized_func=None
):
"""
Handles ``__torch_function__`` dispatch for ops which are performed on
the single local tensor of the sharded tensor such as op like
``torch.nn.functional.softmax`` or ``torch.Tensor.view``.
For more complicated ops, a customized func can be used to generate
the new local tensor, sharding spec and sharded tensor size.
Args:
op: The op to be registered and applied to all shards of the st.
early_stop_func (Callable, optional): the func for early stop.
Default: if ``None``, no early stop.
extra_check (Callable, optional): the func for extra condition check.
Default: if ``None``, no extra check.
customized_func (Callable, optional): the func for customized logic
to generate the new local tensor, sharding spec and sharded tensor size.
Default: if ``None``, we simply lower to the real op call with
the single local tensor of the st.
Return:
func (Callable): registered implementation for sharded op for
``__torch_function__`` dispatch.
"""
@custom_sharding_spec_op(ChunkShardingSpec, op)
@_sharded_op_common(op, early_stop_func, extra_check)
def sharded_tensor_op_on_local_tensor(types, args=(), kwargs=None, pg=None):
# pyrefly: ignore [bad-index]
st = args[0]
sharding_spec = st.sharding_spec()
if len(st.local_shards()) != 1:
raise TypeError(
f"torch function '{op.__name__}', with args: {args} and "
f"kwargs: {kwargs} only supported for single local tensor!"
)
st_size = st.size()
if customized_func:
local_tensor, sharding_spec, st_size = customized_func(args, kwargs, pg)
else:
args = (st.local_tensor(), *args[1:])
local_tensor = op(*args, **kwargs)
return ShardedTensor._init_from_local_tensor(
local_tensor.contiguous(),
sharding_spec,
st_size, # type: ignore[arg-type]
process_group=pg,
init_rrefs=st._init_rrefs,
)
def _handle_col_wise_sharding_base(
op_func,
col_dim,
input,
world_size,
weight,
local_shard,
pg,
gathered_inputs,
mode=None,
gathered_per_sample_weights=None,
gathered_offsets=None,
padding_idx=None,
):
"""
For col-wise sharding of weight, lots of logic are common.
So we extract the common logic and put in this function:
Step 1. To get input from each rank and
Step 2. To perform the op on the concatenated tensor.
Step 3. To distribute results to each rank with col rearrangement.
Step 4. To concatenate all results from all ranks.
Args:
op_func: operator which is applied to the input tensor.
col_dim: dim of result tensor after the operation.
input: tensor to be applied op on.
world_size: number of ranks.
weight: sharded weight tensor.
local_shard: col-wise sharded weight tensor.
pg: process group.
gathered_inputs: list of inputs from all ranks. If specified, we
don't need to communicate with each rank any more.
mode: aggregation mode of EmbeddingBag.
gathered_per_sample_weights: per_sample_weights across all ranks.
gathered_offsets: offsets across all ranks.
padding_idx: If specified, the entries at padding_idx do
not contribute to the gradient; therefore, the embedding
vector at padding_idx is not updated during training,
i.e. it remains as a fixed "pad".
Note that the embedding vector at padding_idx is
excluded from the reduction.
Return: final result of input being applied with the op.
"""
# run the operator's function for all the inputs.
results = []
for i, inp in enumerate(gathered_inputs):
if op_func is torch.nn.functional.embedding_bag:
result = op_func(
inp,
local_shard,
offsets=gathered_offsets[i] if gathered_offsets is not None else None,
# pyrefly: ignore [bad-argument-type]
mode=mode,
per_sample_weights=gathered_per_sample_weights[i]
if gathered_per_sample_weights is not None
else None,
padding_idx=padding_idx,
)
elif op_func is torch.nn.functional.embedding:
result = op_func(
inp,
local_shard,
padding_idx=padding_idx,
)
else:
result = op_func(inp, local_shard)
results.append(torch.transpose(result, 0, col_dim))
# Distribute results to each rank with col rearrangement.
output = _result_distribute_with_col_rearrange(
results, input, world_size, weight, pg
)
# transpose the output and return result.
return torch.transpose(output, 0, col_dim)
def _result_distribute_with_col_rearrange(results, input, world_size, weight, pg):
"""
For col-wise sharding of weight, we need to distribute
results to each rank. We do them in this function.
Note that, if the index in the Sharding Spec is not equal to
the rank number, we need to do the rearrangement based on the
order given by the Sharding Spec (placement).
Args:
results: results from ops applied to inputs from all ranks.
We need to distribute them back to their original ranks.
input: tensor to be applied op to.
world_size: number of ranks.
weight: sharded weight tensor.
pg: process group.
Return: column rearranged result.
"""
# Process results and outputs for all2all.
sharding_dim = weight._sharding_spec.dim
sharding_dim_size = weight.size(sharding_dim)
dims = list(results[0].size())
dims[0] = sharding_dim_size
combined_results = torch.cat(results)
output = torch.empty(
*dims, device=combined_results.device, dtype=combined_results.dtype
)
# Compute output splits
split_size = get_split_size(sharding_dim_size, world_size)
output_split_sizes = [0] * world_size
for idx, placement in enumerate(weight._sharding_spec.placements):
output_split_sizes[placement.rank()] = get_chunked_dim_size(
sharding_dim_size, split_size, idx
)
# distribute the outputs using all2all.
output = all_to_all_single(
output, combined_results, output_split_sizes=output_split_sizes, group=pg
)
# Check if we need to rearrange columns appropriately for output.
rearrange_columns = any(
idx != placement.rank()
for idx, placement in enumerate(weight._sharding_spec.placements)
)
if not rearrange_columns:
return output
indices = []
for placement in weight._sharding_spec.placements:
dim_size = output_split_sizes[placement.rank()]
start = sum(
split_size if i < placement.rank() else 0
for i, split_size in enumerate(output_split_sizes)
)
indices += list(range(start, start + dim_size))
return output.index_select(0, torch.tensor(indices, device=output.device))
def _handle_max_norm_col_wise(
max_norm,
norm_type,
local_shard,
input,
world_size,
gathered_inputs,
pg,
):
"""
For col-wise sharding of weight, we need to aggregate the
norm across all ranks before we can perform the proper re-norm.
Note that, the max_norm logic is only applied to the embedding
indices that are looked up and not the whole shard.
Args:
max_norm: If given, each embedding vector with norm larger
than max_norm is renormalized to have norm max_norm.
Note: this will modify weight in-place.
norm_type: The p in the p-norm to compute for the max_norm option.
local_shard: col-wise shared local weight used for lookup.
input: tensor to be applied op to.
world_size: number of ranks.
gathered_inputs: list of inputs from all ranks.
pg: process group.
Return:
local_shard_norm_renormed: local_shard re-normed to max_norm if the norm is larger
than it.
"""
norm_type = norm_type if norm_type is not None else 2.0
unique_inp = torch.unique(torch.cat(gathered_inputs))
local_shard_sum = torch.sum(
torch.pow(torch.abs(local_shard), norm_type), dim=1, dtype=local_shard.dtype
)
# For col-wise sharding, we need to first aggregate the powered sum
# from each rank first and then calculate the norm.
local_shard_sum = all_reduce(local_shard_sum, group=pg)
local_shard_norm = torch.pow(local_shard_sum, 1.0 / norm_type)
max_norm_tensor = torch.full(
(local_shard.size(0),),
float("inf"),
dtype=local_shard.dtype,
device=input.device,
)
max_norm_tensor[unique_inp] = max_norm
local_shard_t = local_shard.t().contiguous()
normalized_tensor = torch.where(
local_shard_norm > max_norm_tensor, max_norm_tensor, local_shard_norm
)
# Make sure divisor is not zero.
local_shard_norm[local_shard_norm == 0.0] = 1.0
local_shard_norm_renormed = (
torch.div(torch.mul(local_shard_t, normalized_tensor), local_shard_norm)
.t()
.contiguous()
)
return local_shard_norm_renormed
def _all_gather_base_input(input, pg):
"""
Use _all_gather_base to get a concatenated input from each rank.
Args:
input: tensor to be applied op on.
pg: process group.
Returns:
gathered_inputs: input gathered from each rank and concat by dim 0.
"""
# allgather the inputs first.
gather_inp_size = list(input.size())
gather_inp_size[0] = input.size(0) * dist.get_world_size(pg)
gather_inp = torch.empty(gather_inp_size, device=input.device, dtype=input.dtype)
return _all_gather_base(gather_inp, input, group=pg)
def _handle_row_wise_mask(gather_inp, padding_idx, weight, world_size, rank):
"""
Mask the input for embedding look-up for IDs which are not stored
on the current rank. This function also adjust the ``padding_idx``
so that it is only used on the rank where the corresponding row is
stored.
Note that, with ``max_norm`` flag on, only weights of rows being
looked up will be re-normed. So we need an extra row for masked ID
so that it does not affect the final result and ``max_norm``.
Args:
gather_inp: tensor to be applied op on gathered from all ranks.
padding_idx: If specified, the entries at padding_idx do
not contribute to the gradient; therefore, the embedding
vector at padding_idx is not updated during training,
i.e. it remains as a fixed "pad".
Note that the embedding vector at padding_idx is
excluded from the reduction.
weight: weight tensor of Embedding look-up table.
world_size: number of ranks.
rank: # of cuda process.
Returns:
lookup_input: Tensor of masked input.
padding_idx: adjusted padding_idx.
padding_row: The extra row we used during lookup so that
looking up does not affect ``max_norm``.
"""
(start_pos, chunk_size) = get_chunk_sharding_params(
weight.size(0), world_size, weight._sharding_spec, rank
)
mask = (gather_inp < start_pos) | (gather_inp >= start_pos + chunk_size)
lookup_input = gather_inp.clone() - start_pos
lookup_input[mask] = chunk_size
if (
padding_idx is not None
and padding_idx >= start_pos
and padding_idx < (start_pos + chunk_size)
):
padding_idx = padding_idx - start_pos
else:
padding_idx = None
# When max_norm is set, it will only re-norm the row being looked up.
padding_row = torch.zeros(
1, weight.size(1), device=gather_inp.device, dtype=weight.dtype
)
return lookup_input, padding_idx, padding_row
@@ -0,0 +1,294 @@
# mypy: allow-untyped-defs
import torch
import torch.distributed as dist
from torch.distributed._shard.sharded_tensor import ShardedTensor
from torch.distributed._shard.sharding_spec import ChunkShardingSpec
from torch.distributed._shard.sharding_spec.api import custom_sharding_spec_op
from torch.distributed.nn.functional import all_gather, reduce_scatter
from ._common import (
_all_gather_base_input,
_handle_col_wise_sharding_base,
_handle_max_norm_col_wise,
_handle_row_wise_mask,
)
@custom_sharding_spec_op(ChunkShardingSpec, torch.nn.functional.embedding)
def sharded_embedding(types, args, kwargs, pg):
"""
Handles ``__torch_function__`` dispatch for ``torch.nn.functional.embedding``.
This method computes a sharded embedding lookup and has the following limitations:
1. Supports only sharding of ``weight``.
2. Supports only ``ChunkShardingSpec``.
3. Supports only a single local shard per rank.
4. Supports all specs except for scale_grad_by_freq, sparse, etc.
Based on the dimension that the weight is sharded on, there are two
algorithms:
ROWWISE SHARDING
================
For row-wise sharding the weight is sharded on dimension 0.
The overall algorithm can be best explained with an example. Let's assume
the dims for input are (4 x 6) and W are (10 x 17) and W is sharded across
4 GPUs creating 3 shard of (3 x 17) and 1 shard of (1 x 17).
The algorithm is as follows:
1. First the input is all gathered to all ranks, since this is SPMD and
input is actually sharded across all ranks. The inputs then become a
4 (4 x 6) tensor on each rank. For example if the given input is
tensor([[6, 5, 2, 9, 6, 3],
[3, 1, 2, 4, 7, 6],
[4, 0, 4, 9, 8, 9],
[8, 6, 6, 4, 6, 1]])
on rank 0.
Then on every rank, we will have this tensor.
If input itself is already replicated, no all-gather will be done.
2. Next, we mask the ID which are not stored on that rank.
For example on rank 0, we store ID [0, 1, 2]. We only keep the ID
inside the set of numbers. The rest of them will be masked to an extra row.
The masked matrix will be used for embedding look up and is like:
tensor([[4, 4, 2, 4, 4, 4],
[4, 1, 2, 4, 4, 4],
[4, 0, 4, 4, 4, 4],
[4, 4, 4, 4, 4, 1]])
The reason of having an extra row (aka, number 4 in the example) is
because when max_norm is specified only weight which has looked will
be re-normed so mask IDs whose embeddings are not stored in current
rank will to an extra row will ensure max_norm still works as expected.
3. If max_norm is specified, the extra row guarantees that the mask ID will
not affect the behavior of weigh re-norm.
COLWISE SHARDING
================
For col-wise sharding the weight is sharded on dimension 1.
The overall algorithm can be best explained with an example. Let's assume
the dims for input are (4 x 6) and W are (16 x 17) and W is sharded across
4 GPUs creating 3 shards of (16 x 5) and 1 shard of (16 x 2).
The algorithm is as follows:
1. First the input is broadcasted to all ranks, since this is SPMD we
actually do an all_gather for all the inputs resulting in 4 (4 x 6)
inputs on each rank.
2. Next we perform local embedding lookup operation by apply each
input (4 x 6) with the local shard (16 x 5) ((16 x 2) for the last).
This results in 4 (5 x 6 x 4) ((2 x 6 x 4) for the last) matrices
on each rank. We transpose dim 0 and dim 2.
3. Next, we concat these 4 matrices and perform an all2all to share the
appropriate (5 x 6 x 4) or (2 x 6 x 4) matrices to each rank.
4. Now, each rank receives a (17 x 6 x 4) matrix which is basically the
size of the result we need.
5. If placements are not in order any appropriate rearrangement of columns
are done for the (17 x 6 x 4) matrix and finally we transpose the
dim 0 and dim 2 again.
6. If max_norm is specified, we manually sum up the norm and renorm. Because
the renorm must be in place, we need to override the local_shard to mimic
this behavior.
"""
# Validate input params
_validate_embedding_param(args, kwargs)
input = args[0]
weight = args[1]
max_norm = kwargs.get("max_norm")
norm_type = kwargs.get("norm_type")
padding_idx = kwargs.get("padding_idx")
local_shard = weight.local_tensor().contiguous()
sharding_dim = weight._sharding_spec.dim
world_size = dist.get_world_size(pg)
rank = dist.get_rank(pg)
if sharding_dim == 1:
output, local_shard = _handle_col_wise_sharding(
input, world_size, weight, local_shard, max_norm, norm_type, padding_idx, pg
)
weight.local_shards()[0].tensor = local_shard
return output
elif sharding_dim == 0:
return _handle_row_wise_sharding(
input,
world_size,
weight,
local_shard,
max_norm,
norm_type,
padding_idx,
rank,
pg,
)
else:
raise RuntimeError(
f"nn.Embedding weight sharded on dim {sharding_dim} not supported!"
)
def _validate_embedding_param(args, kwargs):
"""
Validate input params of sharded embedding op.
Args:
input: list of ID used for lookup.
weight: sharded weight tensor.
kwargs: same as normal Embedding.
Return: None.
"""
input = args[0]
weight = args[1]
max_norm = kwargs.get("max_norm")
scale_grad_by_freq = kwargs.get("scale_grad_by_freq")
sparse = kwargs.get("sparse")
# Validate types
if not isinstance(input, torch.Tensor):
raise TypeError("input need to be torch.Tensor")
if not isinstance(weight, ShardedTensor):
raise TypeError("weight needs to be ShardedTensor")
weight_size = weight.size()
if len(weight_size) != 2:
raise ValueError("Weight needs to have exactly 2 dims")
if int(torch.min(input).item()) < 0:
raise ValueError(
"Index out of range in Input %d %d",
int(torch.min(input).item()),
weight_size[1],
)
if int(torch.max(input).item()) >= weight_size[0]:
raise ValueError(
"Index out of range in Input %d %d",
int(torch.max(input).item()),
weight_size[1],
)
if scale_grad_by_freq:
raise RuntimeError(
'nn.Embedding weight sharded with flag on "scale_grad_by_freq" not supported!'
)
if sparse:
raise RuntimeError(
'nn.Embedding weight sharded with flag on "sparse" not supported!'
)
if max_norm and max_norm <= 0.0:
raise ValueError('"max_norm" must be larger than zero!')
if not isinstance(weight._sharding_spec, ChunkShardingSpec):
raise ValueError("Only ChunkShardingSpec supported for ShardedTensor ops!")
if len(weight.local_shards()) != 1:
raise ValueError("Only one local shard supported!")
def _handle_col_wise_sharding(
input, world_size, weight, local_shard, max_norm, norm_type, padding_idx, pg
):
"""
Entry-point function to handle the logic of col-wise sharding of weight
for embedding. (Detailed explanations of the logic can be found in
the comment for sharded_embedding.)
Args:
input: list of ID used for lookup and aggregation.
world_size: number of ranks.
weight: sharded weight tensor.
local_shard: col-wise shared local weight used for lookup.
max_norm: If given, each embedding vector with norm larger
than max_norm is renormalized to have norm max_norm.
Note: this will modify weight in-place.
norm_type: The p in the p-norm to compute for the max_norm option.
padding_idx: If specified, the entries at padding_idx do
not contribute to the gradient; therefore, the embedding
vector at padding_idx is not updated during training,
i.e. it remains as a fixed "pad".
pg: process group.
Returns: final result of lookup.
"""
# allgather the inputs first for non Replicated Tensor.
gathered_inputs = all_gather(input, group=pg)
if max_norm is not None:
# max_norm changes the weight in-place
local_shard = _handle_max_norm_col_wise(
max_norm, norm_type, local_shard, input, world_size, gathered_inputs, pg
)
output = _handle_col_wise_sharding_base(
torch.nn.functional.embedding,
len(input.size()),
input,
world_size,
weight,
local_shard,
pg,
gathered_inputs,
padding_idx=padding_idx,
)
return (output, local_shard)
def _handle_row_wise_sharding(
input, world_size, weight, local_shard, max_norm, norm_type, padding_idx, rank, pg
):
"""
Entry-point function to handle the logic of row-wise sharding of weight
for embedding. (Detailed explanations of the logic can be found in
the comment for sharded_embedding.)
Args:
input: list of ID used for lookup and aggregation.
world_size: number of ranks.
weight: sharded weight tensor.
local_shard: row-wise shared local weight used for lookup.
max_norm: If given, each embedding vector with norm larger
than max_norm is renormalized to have norm max_norm.
Note: this will modify weight in-place.
norm_type: The p in the p-norm to compute for the max_norm option.
padding_idx: If specified, the entries at padding_idx do
not contribute to the gradient; therefore, the embedding
vector at padding_idx is not updated during training,
i.e. it remains as a fixed "pad".
rank: # of cuda process.
pg: process group.
Returns: final result of lookup.
"""
# allgather the inputs first for non Replicated Tensor.
gather_inp = _all_gather_base_input(input, pg)
# Mask the input according to sharding spec.
lookup_input, padding_idx, padding_row = _handle_row_wise_mask(
gather_inp, padding_idx, weight, world_size, rank
)
# When input is a large tensor, the value of weight is changed.
# This is a walk-around for now. GH issue: #81717
if max_norm is not None:
torch.nn.functional.embedding(
torch.unique(lookup_input)[:-1],
local_shard,
padding_idx=padding_idx,
max_norm=max_norm,
norm_type=norm_type,
)
max_norm = None
local_input_embeddings = torch.nn.functional.embedding(
lookup_input,
torch.cat([local_shard, padding_row]),
padding_idx=padding_idx,
max_norm=max_norm,
norm_type=norm_type,
)
# TODO: Make the result a PartialTensor.
local_shards = local_input_embeddings.chunk(pg.size())
return reduce_scatter(
torch.empty_like(local_shards[0]),
list(local_shards),
group=pg,
)
@@ -0,0 +1,477 @@
# mypy: allow-untyped-defs
from typing import cast
import torch
import torch.distributed as dist
from torch._C._distributed_c10d import ReduceOp
from torch.distributed._shard.sharded_tensor import ShardedTensor
from torch.distributed._shard.sharding_spec import ChunkShardingSpec
from torch.distributed._shard.sharding_spec.api import custom_sharding_spec_op
from torch.distributed.nn.functional import all_gather, reduce_scatter
from ._common import (
_all_gather_base_input,
_handle_col_wise_sharding_base,
_handle_max_norm_col_wise,
_handle_row_wise_mask,
)
@custom_sharding_spec_op(ChunkShardingSpec, torch.nn.functional.embedding_bag)
def sharded_embedding_bag(types, args, kwargs, pg):
"""
Handles ``__torch_function__`` dispatch for ``torch.nn.functional.embedding_bag``.
This method computes a sharded embedding bag aggregation and has the following limitations:
1. Supports only sharding of ``weight``.
2. Supports only ``ChunkShardingSpec``.
3. Supports only a single local shard per rank.
4. Supports all specs except for scale_grad_by_freq, sparse, etc.
Based on the dimension that the weight is sharded on, there are two
algorithms:
ROWWISE SHARDING
================
For row-wise sharding the weight is sharded on dimension 0.
The overall algorithm can be best explained with an example. Let's assume
the dims for input are (4 x 6) and W are (16 x 17) and W is sharded across
4 GPUs creating 4 shard of (4 x 17).
The algorithm is as follows:
1. First the input is all gathered to all ranks, since this is SPMD and
input is actually sharded across all ranks. The inputs then become a
4 (4 x 6) tensor on each rank. For example if the given input is
tensor([[6, 5, 2, 9, 6, 3],
[3, 1, 2, 4, 7, 6],
[4, 0, 4, 9, 8, 9],
[8, 6, 6, 4, 6, 1]])
on rank 0.
Then on every rank, we will have this tensor.
If input itself is already replicated, no all-gather will be done.
2. Next, we mask the ID which are not stored on that rank.
For example on rank 0, we store ID [0, 1, 2]. We only keep the ID
inside the set of numbers. The rest of them will be masked to an extra row.
The masked matrix will be used for embedding look up and is like:
tensor([[4, 4, 2, 4, 4, 4],
[4, 1, 2, 4, 4, 4],
[4, 0, 4, 4, 4, 4],
[4, 4, 4, 4, 4, 1]])
3. If ``max_norm`` is specified, the extra row guarantees that the mask ID will
not affect the behavior of weigh re-norm.
4. The example above only happens in one rank and each rank does a very similar thing.
For "Mean" mode we need to divide by either column size (2D) or the interval length
defined by the offset (excluding the row specified in ``padding_idx``).
We also need to mask the unexisting row to neg Inf so that negative value does not
gets wiped out in the "Max" mode.
COLWISE SHARDING
================
For col-wise sharding the weight is sharded on dimension 1.
The overall algorithm can be best explained with an example. Let's assume
the dims for input are (4 x 6) and W are (16 x 17) and W is sharded across
4 GPUs creating 3 shards of (16 x 5) and 1 shard of (16 x 2).
The algorithm is as follows:
1. First the input is broadcasted to all ranks, since this is SPMD we
actually do an all_gather for all the inputs resulting in 4 (4 x 6)
inputs on each rank.
2. Next we perform local embedding bag operation under the given mode by
apply each input (4 x 6) with the local shard (16 x 5) ((16 x 2) for the last).
This results in 4 (5 x 4) ((2 x 4) for the last) matrices on each rank.
We transpose the aggregation result.
3. Next, we concatenate these 4 matrices and perform an all2all to share the
appropriate (5 x 4) or (2 x 4) matrices to each rank.
4. Now, each rank receives a (17 x 4) matrix which is basically the
size of the result we need.
5. If placements are not in order any appropriate rearrangement of columns
are done for the (17 x 4) matrix and finally we transpose the output again.
6. If max_norm is specified, we manually sum up the norm and renorm. Because
the renorm must be in place, we need to override the local_shard to mimic
this behavior.
"""
# Validate input params
_validate_embedding_bag_param(args, kwargs)
input = args[0]
weight = args[1]
offsets = kwargs.get("offsets")
per_sample_weights = kwargs.get("per_sample_weights")
mode = kwargs.get("mode")
max_norm = kwargs.get("max_norm")
norm_type = kwargs.get("norm_type")
include_last_offset = kwargs.get("include_last_offset")
padding_idx = kwargs.get("padding_idx")
local_shard = weight.local_tensor().contiguous()
sharding_dim = weight._sharding_spec.dim
world_size = dist.get_world_size(pg)
rank = dist.get_rank(pg)
if include_last_offset:
offsets = offsets[:-1]
if sharding_dim == 1:
output, local_shard = _handle_col_wise_sharding(
input,
world_size,
weight,
local_shard,
offsets,
per_sample_weights,
mode,
max_norm,
norm_type,
padding_idx,
pg,
)
weight.local_shards()[0].tensor = local_shard
return output
elif sharding_dim == 0:
return _handle_row_wise_sharding(
input,
world_size,
weight,
local_shard,
offsets,
per_sample_weights,
mode,
max_norm,
norm_type,
padding_idx,
rank,
pg,
)
else:
raise RuntimeError(
f"nn.EmbeddingBag weight sharded on dim {sharding_dim} not supported!"
)
def _validate_embedding_bag_param(args, kwargs):
"""
Validate input params of sharded embeddingBag op.
Args:
input: list of ID used for lookup and aggregation.
weight: sharded weight tensor.
kwargs: same as normal EmbeddingBag.
Return: None.
"""
input = args[0]
weight = args[1]
offsets = kwargs.get("offsets")
per_sample_weights = kwargs.get("per_sample_weights")
mode = kwargs.get("mode")
max_norm = kwargs.get("max_norm")
scale_grad_by_freq = kwargs.get("scale_grad_by_freq")
sparse = kwargs.get("sparse")
include_last_offset = kwargs.get("include_last_offset")
# Validate types
if not isinstance(input, torch.Tensor):
raise TypeError("input need to be torch.Tensor")
if offsets is not None and not isinstance(offsets, torch.Tensor):
raise TypeError("offsets need to be torch.Tensor")
if per_sample_weights is not None and not isinstance(
per_sample_weights, torch.Tensor
):
raise TypeError("per_sample_weights need to be torch.Tensor")
if not isinstance(weight, ShardedTensor):
raise TypeError("weight needs to be ShardedTensor")
if len(input.size()) > 2:
raise ValueError("Input more than 2 dims not supported")
weight_size = weight.size()
if len(weight_size) != 2:
raise ValueError("Weight needs to have exactly 2 dims")
if int(torch.min(input).item()) < 0:
raise ValueError(
"Index out of range in Input %d %d",
int(torch.min(input).item()),
weight_size[1],
)
if int(torch.max(input).item()) >= weight_size[0]:
raise ValueError(
"Index out of range in Input %d %d",
int(torch.max(input).item()),
weight_size[1],
)
if offsets is not None and len(input.size()) != 1:
raise ValueError("Input dimension needs to be exactly 1 dim")
if len(input.size()) == 1 and offsets is None:
raise ValueError("offsets is required for 1D input")
if per_sample_weights is not None and per_sample_weights.size() != input.size():
raise ValueError(
f"per_sample_weights size {per_sample_weights.size()} not equal to input size {input.size()}"
)
if mode is None:
mode = "mean"
if mode not in ["sum", "mean", "max"]:
raise ValueError(f"mode '{mode}' is not supported")
if scale_grad_by_freq:
raise RuntimeError(
'nn.Embedding weight sharded with flag on "scale_grad_by_freq" not supported!'
)
if sparse:
raise RuntimeError(
'nn.Embedding weight sharded with flag on "sparse" not supported!'
)
if include_last_offset and offsets is None:
raise ValueError('offsets is required for flag "include_last_offset"!')
if include_last_offset and cast(list[int], offsets)[-1] != input.size(0):
raise ValueError(
'offsets need to have the input size in the end when the flag "include_last_offset" is on!'
)
if max_norm and max_norm <= 0.0:
raise ValueError('"max_norm" must be larger than zero!')
if not isinstance(weight._sharding_spec, ChunkShardingSpec):
raise ValueError("Only ChunkShardingSpec supported for ShardedTensor ops!")
if len(weight.local_shards()) != 1:
raise ValueError("Only one local shard supported!")
def _handle_col_wise_sharding(
input,
world_size,
weight,
local_shard,
offsets,
per_sample_weights,
mode,
max_norm,
norm_type,
padding_idx,
pg,
):
"""
Entry-point function to handle the logic of col-wise sharding of weight
for embeddingBag. (Detailed explanations of the logic can be found in
the comment for sharded_embedding_bag.)
Args:
input: list of ID used for lookup and aggregation.
world_size: number of ranks.
weight: sharded weight tensor.
local_shard: col-wise shared local weight used for lookup.
offsets: list of start positions of each bag for 1D input.
per_sample_weights: weights for weighted sum mode.
mode: aggregation method of each bag.
max_norm: If given, each embedding vector with norm larger
than max_norm is renormalized to have norm max_norm.
Note: this will modify weight in-place.
norm_type: The p in the p-norm to compute for the max_norm option.
padding_idx: If specified, the entries at padding_idx do
not contribute to the gradient; therefore, the embedding
vector at padding_idx is not updated during training,
i.e. it remains as a fixed "pad".
Note that the embedding vector at padding_idx is
excluded from the reduction.
pg: process group.
Return:
output: final result of lookup and aggregation.
local_shard: col-wise shared local weight used for lookup.
If max_norm, this will be the renormed weight.
"""
# allgather the special input of embedding bag first.
(
gathered_inputs,
gathered_per_sample_weights,
gathered_offsets,
) = _all_gather_embedding_bag_input(input, per_sample_weights, offsets, pg)
if max_norm is not None:
# max_norm changes the weight in-place
local_shard = _handle_max_norm_col_wise(
max_norm, norm_type, local_shard, input, world_size, gathered_inputs, pg
)
output = _handle_col_wise_sharding_base(
torch.nn.functional.embedding_bag,
1,
input,
world_size,
weight,
local_shard,
pg,
gathered_inputs,
mode=mode,
gathered_per_sample_weights=gathered_per_sample_weights,
gathered_offsets=gathered_offsets,
padding_idx=padding_idx,
)
return (output, local_shard)
def _handle_row_wise_sharding(
input,
world_size,
weight,
local_shard,
offsets,
per_sample_weights,
mode,
max_norm,
norm_type,
padding_idx,
rank,
pg,
):
"""
Entry-point function to handle the logic of row-wise sharding of weight
for embeddingBag. (Detailed explanations of the logic can be found in
the comment for sharded_embedding_bag.)
Args:
input: list of ID used for lookup and aggregation.
world_size: number of ranks.
weight: sharded weight tensor.
local_shard: row-wise shared local weight used for lookup.
offsets: list of start positions of each bag for 1D input.
per_sample_weights: weights for weighted sum mode.
mode: aggregation method of each bag.
max_norm: If given, each embedding vector with norm larger
than max_norm is renormalized to have norm max_norm.
Note: this will modify weight in-place.
norm_type: The p in the p-norm to compute for the max_norm option.
padding_idx: If specified, the entries at padding_idx do
not contribute to the gradient; therefore, the embedding
vector at padding_idx is not updated during training,
i.e. it remains as a fixed "pad".
Note that the embedding vector at padding_idx is
excluded from the reduction.
rank: # of cuda process.
pg: process group.
Returns:
gathered_output: final result of lookup and aggregation.
"""
if input.dim() > 1 and per_sample_weights is None:
# allgather the inputs first for non Replicated Tensor.
gather_inp = _all_gather_base_input(input, pg)
else:
(
gathered_inputs,
gathered_per_sample_weights,
gathered_offsets,
) = _all_gather_embedding_bag_input(input, per_sample_weights, offsets, pg)
cat_dim = 0 if input.dim() != 1 else -1
gather_inp = torch.cat(gathered_inputs, dim=cat_dim)
if per_sample_weights is not None:
per_sample_weights = torch.cat(gathered_per_sample_weights, dim=cat_dim)
offset_add = 0 if input.dim() > 1 else input.size(0)
if offsets is not None:
offsets_list = torch.cat(
[gathered_offsets[i] + (offset_add * i) for i in range(pg.size())],
dim=cat_dim,
)
# Mask the input according to sharding spec.
lookup_input, padding_local, padding_row = _handle_row_wise_mask(
gather_inp, padding_idx, weight, world_size, rank
)
if mode == "max":
padding_row[:] = -float("Inf")
# When input is a large tensor, the value of weight is changed.
# This is a walk-around for now. GH issue: #81717.
if max_norm is not None:
torch.nn.functional.embedding_bag(
torch.unique(lookup_input)[:-1],
local_shard,
offsets=torch.tensor([0], device=local_shard.device, dtype=torch.long),
mode=mode,
per_sample_weights=None,
max_norm=max_norm,
norm_type=norm_type,
padding_idx=padding_local,
)
max_norm = None
result = torch.nn.functional.embedding_bag(
lookup_input,
torch.cat([local_shard, padding_row]),
offsets=offsets_list if offsets is not None else offsets, # type: ignore[possibly-undefined]
mode=mode if mode != "mean" else "sum",
per_sample_weights=per_sample_weights,
max_norm=max_norm,
norm_type=norm_type,
padding_idx=padding_local,
)
op = ReduceOp.SUM if mode != "max" else ReduceOp.MAX
# TODO: Make the result a PartialTensor and move the logic below there.
local_shards = result.chunk(pg.size())
result = reduce_scatter(
torch.empty_like(local_shards[0]),
list(local_shards),
op=op,
group=pg,
)
# For Mean, we cannot do the division until very end because the sum of means
# not equal to the mean of sum. (Divisor is different)
if mode == "mean":
if input.dim() > 1:
padding_idx = padding_idx if padding_idx is not None else -1
split_sizes = torch.sum(
torch.ne(input, padding_idx), dim=-1, dtype=local_shard.dtype
)
else:
split_sizes = torch.cat(
(
offsets[1 : offsets.size(0)] - offsets[0:-1],
(input.size(0) - offsets[-1]).unsqueeze(0),
),
dim=-1,
)
return torch.div(result, split_sizes.unsqueeze(1))
# Return the appropriate local result.
return result
def _all_gather_embedding_bag_input(input, per_sample_weights, offsets, pg):
"""
In case we need to gather input and all other parameters of embeddingBag
ops, we need to stack all input together to perform ``all_gather``
collective communication just once.
Note that since offsets does not share the same size as input and
is always smaller than input, we resize it during the communication.
Args:
input: tensor to be applied op on.
per_sample_weights: weights for weighted sum mode.
offsets: when input is 1D. offsets determines the starting
index position of each bag (sequence) in input.
pg: process group.
Returns:
gathered_inputs: list of input tensor gathered from each rank.
gathered_per_sample_weights: list of per_sample_weights from each rank.
gathered_offsets: list of offsets from each rank.
"""
input_to_gather = [input]
if per_sample_weights is not None:
input_to_gather.append(per_sample_weights)
if offsets is not None:
input_to_gather.append(offsets.clone().resize_(input.size()))
gathered_inputs = all_gather(torch.stack(input_to_gather), group=pg)
gathered_per_sample_weights = None
if per_sample_weights is not None:
gathered_per_sample_weights = [t[1] for t in gathered_inputs]
gathered_offsets = None
if offsets is not None:
idx = 2 if per_sample_weights is not None else 1
gathered_offsets = [
t[idx].resize_(offsets.size()).to(offsets.dtype) for t in gathered_inputs
]
gathered_inputs = [t[0].to(input.dtype) for t in gathered_inputs]
return gathered_inputs, gathered_per_sample_weights, gathered_offsets
@@ -0,0 +1,21 @@
# Keep old package for BC purposes, this file should be removed once
# everything moves to the `torch.distributed._shard` package.
import sys
import warnings
import torch
from torch.distributed._shard.sharded_tensor import * # noqa: F403
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"`torch.distributed._sharded_tensor` will be deprecated, "
"use `torch.distributed._shard.sharded_tensor` instead",
DeprecationWarning,
stacklevel=2,
)
sys.modules["torch.distributed._sharded_tensor"] = (
torch.distributed._shard.sharded_tensor
)
@@ -0,0 +1,22 @@
# Keep old package for BC purposes, this file should be removed once
# everything moves to the `torch.distributed._shard` package.
import sys
import warnings
import torch
from torch.distributed._shard.sharding_spec import * # noqa: F403
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"`torch.distributed._sharding_spec` will be deprecated, "
"use `torch.distributed._shard.sharding_spec` instead",
DeprecationWarning,
stacklevel=2,
)
import torch.distributed._shard.sharding_spec as _sharding_spec
sys.modules["torch.distributed._sharding_spec"] = _sharding_spec
@@ -0,0 +1,829 @@
# mypy: allow-untyped-defs
import copy
import io
import math
import weakref
from collections.abc import Callable, Mapping, MutableMapping
from typing import Any, cast, NamedTuple, TYPE_CHECKING
import torch
import torch.cuda._pin_memory_utils as pin_memory_utils
import torch.distributed as dist
import torch.nn.functional as F
from torch.distributed._functional_collectives import AsyncCollectiveTensor
if dist.is_available() or TYPE_CHECKING:
from torch.distributed import distributed_c10d
from torch.distributed._shard.sharded_tensor import ShardedTensor
from torch.distributed.tensor import distribute_tensor, DTensor, Replicate
from torch.distributed.tensor._utils import compute_local_shape_and_global_offset
def _identity_func(
obj: torch.Tensor,
pg: dist.ProcessGroup | None,
device: torch.device | None,
companion_obj: Any,
) -> torch.Tensor:
return obj
def _all_gather_sharded_tensor(
sharded_tensor: "ShardedTensor",
pg: dist.ProcessGroup | None = None,
device: torch.device | None = None,
) -> torch.Tensor:
if pg is None:
pg = distributed_c10d._get_default_group()
world_size = dist.get_world_size(pg)
shards = sharded_tensor.local_shards()
dim_0_size = sharded_tensor.size()[0] # type: ignore[index]
tensor_numel = sharded_tensor.size().numel() # type: ignore[union-attr]
chunk_size = math.ceil(dim_0_size / world_size) * tensor_numel // dim_0_size
pg_device = (
distributed_c10d._get_pg_default_device(pg) if device is None else device
)
if shards:
local_tensor = shards[0].tensor.flatten()
if local_tensor.device.type != pg_device.type:
local_tensor = local_tensor.to(pg_device)
num_padding = chunk_size - local_tensor.numel()
if num_padding > 0:
local_tensor = F.pad(local_tensor, [0, num_padding])
else:
local_tensor = torch.zeros(
chunk_size, dtype=sharded_tensor.dtype, device=pg_device
)
tensor = torch.empty(
chunk_size * world_size,
dtype=local_tensor.dtype,
device=pg_device,
)
dist.all_gather_into_tensor(tensor, local_tensor, group=pg)
tensor = tensor.narrow(0, 0, tensor_numel).reshape(sharded_tensor.size())
return tensor
class CompanionMismatch(Exception):
pass
def _iterate_state_dict(
iter_object: Any,
sharded_tensor_func: Callable,
dtensor_func: Callable,
tensor_func: Callable,
*,
pg: dist.ProcessGroup | None = None,
device: torch.device | None = None,
cpu_offload: bool = False,
companion_obj: Any = None,
ranks_only: tuple[int, ...] = (),
type_check: bool = True,
non_blocking: bool = True,
) -> dict[str, Any]:
"""Iterate through the state dict, applying the given functions to each tensor type.
Args:
iter_object (Any): the target state_dict.
sharded_tensor_func (Callable): the function to apply to ShardedTensor
dtensor_func (Callable): the function to apply to DTensor
tensor_func (Callable): the function to apply to Tensor
pg (Optional[dist.ProcessGroup]): process group passed to tensor functions
device (Optional[torch.device]): device passed to tensor functions
cpu_offload (bool): whether to offload the tensors to CPU memory. This option is ignored
if a companion_obj is supplied.
companion_obj (Any): A companion object to the state dict. If this object
is supplied, we attempt to copy the tensor to the companion object.
ranks_only (Tuple[int, ...]): if this tuple is empty, all ranks will
have the same state_dicts. Otherwise only ranks that in ``ranks_only``
have the same state_dicts. Other ranks will get empty state_dicts.
type_check (bool): check if the instance data type is a supported type
that can be saved by DCP. The current supported data types are
torch.Tensor, DTensor, int, float, str, list, dict, None.
non_blocking (bool): whether to use non-blocking copy when copying to the companion object.
"""
# TODO: should we use pytree?
cpu_device = torch.device("cpu")
if isinstance(iter_object, ShardedTensor):
ret = sharded_tensor_func(iter_object, pg, device, companion_obj)
elif isinstance(iter_object, DTensor):
ret = dtensor_func(iter_object, pg, device, companion_obj)
elif isinstance(iter_object, torch.Tensor):
ret = tensor_func(iter_object, pg, device, companion_obj)
elif (
isinstance(iter_object, (int, float, str, bytes, io.BytesIO))
or iter_object is None
):
ret = iter_object
elif isinstance(iter_object, dict):
if companion_obj is not None and (
not isinstance(companion_obj, dict)
or set(companion_obj.keys()) != set(iter_object.keys())
):
msg = (
""
if isinstance(companion_obj, dict)
else f"{set(companion_obj.keys())=} {set(iter_object.keys())=}"
)
raise CompanionMismatch(msg)
ret = {
key: _iterate_state_dict(
value,
sharded_tensor_func,
dtensor_func,
tensor_func,
pg=pg,
device=device,
cpu_offload=cpu_offload,
companion_obj=companion_obj[key] if companion_obj is not None else None,
ranks_only=ranks_only,
type_check=type_check,
non_blocking=non_blocking,
)
for key, value in iter_object.items()
}
elif isinstance(iter_object, (list, tuple)):
if companion_obj is not None and (
not isinstance(companion_obj, (list, tuple))
or len(companion_obj) != len(iter_object)
):
raise CompanionMismatch
ret = [
_iterate_state_dict(
v,
sharded_tensor_func,
dtensor_func,
tensor_func,
pg=pg,
device=device,
cpu_offload=cpu_offload,
companion_obj=companion_obj[idx] if companion_obj is not None else None,
ranks_only=ranks_only,
type_check=type_check,
non_blocking=non_blocking,
)
for idx, v in enumerate(iter_object)
]
if isinstance(iter_object, tuple):
ret = tuple(ret)
elif not type_check:
ret = copy.deepcopy(iter_object)
else:
raise ValueError(f"Unexpected value type {type(iter_object)}")
if not ranks_only or dist.get_rank(pg) in ranks_only:
if isinstance(ret, torch.Tensor):
if cpu_offload and companion_obj is None:
ret = ret.to(cpu_device)
if companion_obj is not None:
if isinstance(companion_obj, DTensor):
if not isinstance(ret, DTensor):
raise AssertionError(
"ret must be a DTensor when companion_obj is a DTensor"
)
companion_obj._local_tensor.copy_(
ret._local_tensor, non_blocking=non_blocking
)
elif isinstance(companion_obj, ShardedTensor):
if not isinstance(ret, ShardedTensor):
raise AssertionError(
"ret must be a ShardedTensor when companion_obj is a ShardedTensor"
)
for idx, shard in enumerate(companion_obj.local_shards()):
shard.tensor.copy_(
ret.local_shards()[idx].tensor, non_blocking=non_blocking
)
else:
companion_obj.copy_(ret, non_blocking=non_blocking)
ret = companion_obj
else:
ret = {} if isinstance(ret, dict) else None
# pyrefly: ignore [bad-return]
return ret
def _gather_state_dict(
state_dict: dict[str, Any],
*,
pg: dist.ProcessGroup | None = None,
device: torch.device | None = None,
cpu_offload: bool = False,
ranks_only: tuple[int, ...] = (),
type_check: bool = True,
) -> dict[str, Any]:
"""
Given a state_dict, this API gathers all the ShardedTensors or DTensors in
the state_dict.
Args:
state_dict (Dict[str, Any]): the target sharded state_dict.
pg (Optional[dist.ProcessGroup]): the process group that is used to
gather ShardedTensor. Note that gathering a DTensor will use
the DeviceMesh. So this argument will be ignored when gathering a
DTensor.
device: (Optional[torch.device]): the device that is used to
perform allgather for ShardedTensor. Note that gathering a DTensor
will use the DeviceMesh. So this argument will be ignored when
gathering a DTensor.
cpu_offload (bool): whether to offload the tensors to CPU memory. The
default value is False.
ranks_only: (Tuple[int, ...]): if this tuple is empty, all ranks will
have the same state_dicts. Otherwise only ranks that in ``ranks_only``
have the same state_dicts. Other ranks will get empty state_dicts.
type_check: (bool): check if the instance data type is a supported type
that can be saved by DCP. The current supported data types are
torch.Tensor, DTensor, int, float, str, list, dict, None.
Returns:
The gathered state dictionary.
"""
def sharded_tensor_func(value, pg, device, companion_obj):
# ShardedTensor does not seem to record the original device type.
# So if the tensor is moved to CPU, we won't know the original type.
# As a result, we have to rely on the user to tell us the correct one.
cpu_device = torch.device("cpu")
output_tensor = _all_gather_sharded_tensor(value, pg, device)
local_shard_device = (
value.local_shards()[0].tensor.device
if value.local_shards()
else cpu_device
)
if output_tensor.device != local_shard_device:
value = output_tensor.to(local_shard_device)
else:
value = output_tensor
return value
def dtensor_func(value, pg, device, companion_obj):
if value.device != value.device_mesh.device_type:
value = value.to(value.device_mesh.device_type)
# FSDP all_gather: [Shard(0)] -> [Replicate()]
# HSDP all_gather: [Replicate(), Shard(0)] -> [Replicate(), Replicate()]
# 2D FSDP + TP all_gather:
# - [Shard(0), Shard(n)] -> [Replicate(), Replicate()]
# - [Shard(0), Replicate()] -> [Replicate(), Replicate()]
placements = [Replicate() for _ in value.placements]
value = value.redistribute(
device_mesh=value.device_mesh,
placements=placements,
)
# Call `wait()` to force the tensor to be synchronous with respect
# to the main stream.
# See the discussion in https://github.com/pytorch/pytorch/pull/117799.
value = value.to_local()
if isinstance(value, AsyncCollectiveTensor):
value = value.wait()
return value
return _iterate_state_dict(
state_dict,
sharded_tensor_func,
dtensor_func,
_identity_func,
pg=pg,
device=device,
cpu_offload=cpu_offload,
ranks_only=ranks_only,
type_check=type_check,
)
def _offload_state_dict_to_cpu(
state_dict: dict[str, Any],
*,
ranks_only: tuple[int, ...] = (),
type_check: bool = True,
) -> dict[str, Any]:
"""
Given a state_dict, this API offload all the tensors to CPU memory.
Args:
state_dict (Dict[str, Any]): the target state_dict.
pg (Optional[dist.ProcessGroup]): the process group that is used to
gather ShardedTensor. Note that gathering a DTensor will use
the DeviceMesh. So this argument will be ignored when gathering a
DTensor.
ranks_only: (Tuple[int, ...]): if this tuple is empty, all ranks will
have the same state_dicts. Otherwise only ranks that in ``ranks_only``
have the same state_dicts. Other ranks will get empty state_dicts.
type_check: (bool): check if the instance data type is a supported type
that can be saved by DCP. The current supported data types are
torch.Tensor, DTensor, int, float, str, list, dict, None.
Returns:
The gathered state dictionary.
"""
ret = _iterate_state_dict(
state_dict,
_identity_func,
_identity_func,
_identity_func,
pg=None,
device=None,
cpu_offload=True,
ranks_only=ranks_only,
type_check=type_check,
)
return ret
@torch.no_grad()
def _copy_state_dict(
state_dict: dict[str, Any],
copy_state_dict: dict[str, Any],
non_blocking: bool = False,
type_check: bool = True,
) -> dict[str, Any]:
"""
Copies all tensors in a given state dict into a different state_dict with the
same structure. Additionally, a copied state dict with the same value references
is returned. Editing the keys on this state dict will not affect the
passed in copy_state_dict (but the value references are the same).
.. warning::
It is expected by this function that state_dict and copy_state_dict share
the same structure and data types.
.. warning::
The current supported data types are
torch.Tensor, DTensor, int, float, str, list, dict, None.
Args:
state_dict (Dict[str, Any]): the target state_dict.
copy_state_dict (Dict[str, Any]):
The state dict we are copying into. This state_dict must have exactly
the same structure as the source `state_dict`.
non_blocking: (bool): Whether copy ops should be performed asynchronously
type_check (bool): check if the instance data type is a supported type
that can be saved by DCP. The current supported data types are
torch.Tensor, DTensor, int, float, str, list, dict, None.
Returns:
State Dict copy
"""
return _iterate_state_dict(
state_dict,
_identity_func,
_identity_func,
_identity_func,
pg=None,
device=None,
cpu_offload=False,
ranks_only=(),
companion_obj=copy_state_dict,
type_check=type_check,
non_blocking=non_blocking,
)
@torch.no_grad()
def _create_cpu_state_dict(
state_dict: dict[str, Any], pin_memory: bool = False, share_memory: bool = False
) -> dict[str, Any]:
"""
Given a state_dict, create another state_dict with the same structure and elements.
However, all tensors in the returned state_dict are new tensors on CPU. These
tensors can be placed on pin_memory or share_memory based on the provided arguments.
.. warning::
Setting both `pin_memory` and `share_memory` to True significantly increases the
latency of this method because of the nuances which require us to register memory
as pinned directly as opposed to relying on the pin_memory cache allocator. This
option should only be used for long lived tensors which are required to be shared.
This is not the case as long as at least one of `pin_memory` or `share_memory` is
set to False.
"""
def tensor_func(
obj: torch.Tensor,
pg: dist.ProcessGroup | None,
device: torch.device | None,
_: Any,
) -> torch.Tensor:
if len(obj.size()) == 0:
return torch.tensor(0, dtype=obj.dtype)
# sometimes, a tensor might have non-zero size and 0 numel. In this case, pinning memory will fail
# so we take a best guess at how to replicate the tensor below to maintain symmetry in the returned
# state dict.
if obj.numel() == 0 or obj.data_ptr() == 0:
t = torch.zeros_like(obj, device="cpu")
if share_memory:
t = t.share_memory_()
return t
if share_memory:
t = torch.empty(*tuple(obj.size()), dtype=obj.dtype)
t = t.share_memory_()
if pin_memory:
pin_memory_utils.pin_memory(t.data_ptr(), t.numel() * t.element_size())
weakref.finalize(t, pin_memory_utils.unpin_memory, t.data_ptr())
return t
elif pin_memory:
return torch.empty(*tuple(obj.size()), dtype=obj.dtype).pin_memory()
else:
return torch.empty(*tuple(obj.size()), dtype=obj.dtype)
def dtensor_func(
obj: DTensor,
pg: dist.ProcessGroup | None,
device: torch.device | None,
_: Any,
) -> DTensor:
if len(obj.size()) == 0:
return obj
if obj.device != torch.device("cpu"):
ret = cast(DTensor, obj.to(device="cpu"))
else:
ret = copy.deepcopy(obj)
ret._local_tensor = tensor_func(ret._local_tensor, pg, device, None)
return ret
def sharded_tensor_func(
obj: ShardedTensor,
pg: dist.ProcessGroup | None,
device: torch.device | None,
_: Any,
) -> ShardedTensor:
if not obj.local_shards():
return obj
if obj.device != torch.device("cpu"):
ret = obj.to(device="cpu")
else:
ret = copy.deepcopy(obj)
for shards in ret.local_shards():
shards.tensor = tensor_func(shards.tensor, pg, device, None)
return ret
ret = _iterate_state_dict(
state_dict,
sharded_tensor_func,
dtensor_func,
tensor_func,
pg=None,
device=None,
cpu_offload=False,
ranks_only=(),
type_check=False,
)
return ret
def _check_state_dict_similarity(
state_dict: dict[str, Any],
compared_state_dict: dict[str, Any],
) -> bool:
"""
Given two state_dicts, check if the structures are the same. And
if a [key, tensor] pair exist in one state_dict there must be
the a corresponding pait, [key, other_tensor], in the other state_dict,
where tensor and other_tensor have the same size and dtype.
Return the check result.
"""
def tensor_func(
obj: torch.Tensor,
pg: dist.ProcessGroup | None,
device: torch.device | None,
companion_obj: Any,
) -> torch.Tensor:
if companion_obj.dtype != obj.dtype or companion_obj.size() != obj.size():
raise CompanionMismatch
return obj
try:
_iterate_state_dict(
state_dict,
_identity_func,
_identity_func,
tensor_func,
pg=None,
device=None,
cpu_offload=False,
ranks_only=(),
companion_obj=compared_state_dict,
type_check=False,
)
except CompanionMismatch:
return False
return True
class _TensorInfo(NamedTuple):
size: torch.Size
dtype: torch.dtype
def _broadcast_tensors(
full_state_dict: dict[str, Any],
local_state_dict: dict[str, Any],
keys: list[str],
device: torch.device,
pg: dist.ProcessGroup | None = None,
) -> None:
if pg is None:
pg = dist.distributed_c10d._get_default_group()
pg_device = (
device
if device.type in {pg_device.type for pg_device in pg._device_types}
else pg._device_types[0]
)
tensors: list[torch.Tensor] = []
for key in keys:
if dist.get_rank() == 0:
full_state = full_state_dict[key]
if not isinstance(full_state, torch.Tensor):
raise AssertionError("full_state must be a torch.Tensor")
full_tensor = full_state.detach().to(pg_device)
else:
tensor_info = full_state_dict[key]
full_tensor = torch.empty(
size=tensor_info.size,
device=pg_device,
dtype=tensor_info.dtype,
)
tensors.append(full_tensor)
if (local_state := local_state_dict.get(key)) is None:
continue
local_state_dict[key] = (
(local_state, full_tensor)
if isinstance(local_state, DTensor)
else full_tensor
)
if len(tensors) > 1:
dist._broadcast_coalesced(pg, tensors, 500, 0)
else:
dist.broadcast(tensors[0], src=0, group=pg)
if pg_device != device:
for key, full_tensor in zip(keys, tensors):
if (local_state := local_state_dict.get(key)) is not None:
local_state_dict[key] = (
(local_state[0], full_tensor.to(device))
if (
isinstance(local_state, tuple)
and isinstance(local_state[0], DTensor)
)
else full_tensor.to(device)
)
_distribute_tensors(local_state_dict, keys, device, pg)
def _distribute_tensors(
local_state_dict: dict[str, Any],
keys: list[str],
device: torch.device,
pg: dist.ProcessGroup | None = None,
) -> None:
if pg is None:
pg = dist.distributed_c10d._get_default_group()
for key in keys:
_local_state = local_state_dict.get(key)
if _local_state is None or torch.is_tensor(_local_state):
continue
local_state = _local_state[0]
full_tensor = _local_state[1]
shape, offset = compute_local_shape_and_global_offset(
full_tensor.shape, local_state.device_mesh, local_state.placements
)
slices = [
slice(cur_offset, cur_offset + cur_shape)
for cur_shape, cur_offset in zip(shape, offset)
]
if local_state.is_meta:
# Use .clone() here rather than view to clone and return only the sliced portion, minimizing memory access and cost.
local_tensor = full_tensor[tuple(slices)].detach().clone()
# TODO: currently, we cannot handle strided sharding if the dp dimension is not even. For example,
# one of the case that is not yet supported is when placements = (Shard(0), _StridedShard(0, sf=2)).
ret = DTensor.from_local(
local_tensor,
local_state.device_mesh,
local_state.placements,
shape=local_state.shape,
stride=local_state.stride(),
)
else:
ret = local_state
# Copy full_tensor[slices] into local_state.to_local() to reduce memory footprint.
ret.to_local().copy_(full_tensor[tuple(slices)])
local_state_dict[key] = ret
def _broadcast_state_dict(
full_state_dict: dict[str, Any],
local_state_dict: dict[str, Any],
device: torch.device,
pg: dist.ProcessGroup | None = None,
strict: bool = False,
cpu_offload: bool = False,
) -> None:
# Broadcast from rank0's `full_state_dict` to all ranks' `local_state_dict`.
# If strict is True, any keys in `local_state_dict` but not in `full_state_dict`
# will be removed from `local_state_dict`.
ret = {}
if dist.get_rank() == 0:
for key, value in full_state_dict.items():
if not torch.is_tensor(value):
ret[key] = value
elif value.dim() == 0:
ret[key] = value.cpu()
else:
ret[key] = _TensorInfo(value.size(), value.dtype)
broadcast_list = [ret]
dist.broadcast_object_list(broadcast_list, src=0, group=pg)
ret = broadcast_list[0]
# Gather values
keys = []
local_state_dict_keys = set(local_state_dict.keys())
global_keys = set()
for key, value in ret.items():
global_keys.add(key)
if not isinstance(value, _TensorInfo):
if key in local_state_dict:
local_state_dict[key] = value
continue
if dist.get_rank() == 0:
ret[key] = full_state_dict[key]
keys.append(key)
# Broadcast every tensor to avoid OOM for now.
if len(keys) >= 1:
_broadcast_tensors(ret, local_state_dict, keys, device, pg)
if cpu_offload:
for key in keys:
local_state_dict[key] = local_state_dict[key].cpu()
keys.clear()
if strict:
if missing_keys := (local_state_dict_keys - global_keys):
for key in missing_keys:
local_state_dict.pop(key)
if keys:
_broadcast_tensors(ret, local_state_dict, keys, device, pg)
if cpu_offload:
for key in keys:
local_state_dict[key] = local_state_dict[key].cpu()
def _distribute_state_dict(
full_state_dict: dict[str, Any],
local_state_dict: dict[str, Any],
device: torch.device,
pg: dist.ProcessGroup | None = None,
) -> None:
# Full_state_dict = True, broadcast_from_rank0 = False here. Each rank has
# full_state_dict. Skip the broadcast in ``_broadcast_state_dict`` and
# distribute tensors in each rank
for key, value in full_state_dict.items():
if key not in full_state_dict:
continue
if not torch.is_tensor(value):
local_state_dict[key] = value
elif value.dim() == 0:
local_state_dict[key] = value.cpu()
else:
if not isinstance(value, torch.Tensor):
raise AssertionError("value must be a torch.Tensor")
local_state = local_state_dict.get(key)
if local_state is None:
continue
elif isinstance(local_state, DTensor):
local_state_dict[key] = distribute_tensor(
value.detach().to(device),
local_state.device_mesh,
local_state.placements,
)
else:
local_state_dict[key] = value.detach().to(device)
# These APIs are from torch.distributed.checkpoint.
# TODO: We should consolidate the code here as some not all modules can depend on
# DCP.
PATH_ITEM = str | int
OBJ_PATH = tuple[PATH_ITEM, ...]
FLATTEN_MAPPING = dict[str, OBJ_PATH]
STATE_DICT_TYPE = dict[str, Any]
CONTAINER_TYPE = MutableMapping[PATH_ITEM, Any]
def _traverse_state_dict(
state_dict: STATE_DICT_TYPE,
visitor: Callable[[OBJ_PATH, Any], None],
) -> None:
"""
Invoke ``visitor`` for each value recursively in ``state_dict``.
Mapping, list, and tuple will be flattened and other value types are treated
as the terminal values and will invoke ``visitor``.
"""
def _traverse_obj(path: OBJ_PATH, value: Any) -> None:
if isinstance(value, Mapping):
for k, v in value.items():
_traverse_obj(path + (str(k),), v)
elif isinstance(value, (list, tuple)):
for i, v in enumerate(value):
_traverse_obj(path + (i,), v)
else:
visitor(path, value)
for key, value in state_dict.items():
_traverse_obj((str(key),), value)
def _flatten_state_dict(
state_dict: STATE_DICT_TYPE,
) -> tuple[STATE_DICT_TYPE, FLATTEN_MAPPING]:
"""
Flatten ``state_dict`` made of nested dicts and lists into a top level dictionary.
Use ``unflatten_state_dict`` to revert this process.
Returns:
A tuple with the flatten state_dict and a mapping from original to new state_dict.
N.B. The new keys are derived from the object paths, joined by dot.
For example: ``{ 'a': {'b':...}}`` results in the key `a.b`.
"""
flattened: STATE_DICT_TYPE = {}
mappings: FLATTEN_MAPPING = {}
def flat_copy(path: OBJ_PATH, value: Any) -> None:
new_fqn = ".".join(map(str, path))
if new_fqn in flattened:
raise ValueError(f"duplicated flatten key {new_fqn}")
flattened[new_fqn] = value
mappings[new_fqn] = path
_traverse_state_dict(state_dict, flat_copy)
return flattened, mappings
def _set_element(root_dict: STATE_DICT_TYPE, path: OBJ_PATH, value: Any) -> None:
"""Set ``value`` in ``root_dict`` along the ``path`` object path."""
cur_container = cast(CONTAINER_TYPE, root_dict)
def extend_list(lst: list[Any], idx: int) -> None:
while len(lst) <= idx:
lst.append(None)
for i in range(1, len(path)):
prev_key = path[i - 1]
key = path[i]
def_val: CONTAINER_TYPE | list[Any] = {} if type(key) is str else []
if isinstance(cur_container, Mapping):
cur_container = cast(
CONTAINER_TYPE, cur_container.setdefault(prev_key, def_val)
)
else:
# pyrefly: ignore [bad-argument-type]
extend_list(cur_container, prev_key)
if cur_container[prev_key] is None:
cur_container[prev_key] = def_val
cur_container = cur_container[prev_key]
key = path[-1]
if type(key) is int:
extend_list(cast(list[Any], cur_container), key)
cur_container[key] = value
def _unflatten_state_dict(
state_dict: STATE_DICT_TYPE, mapping: FLATTEN_MAPPING
) -> STATE_DICT_TYPE:
"""Restore the original nested state_dict according to ``mapping`` and the flattened ``state_dict``."""
nested: STATE_DICT_TYPE = {}
for key, value in state_dict.items():
_set_element(nested, mapping[key], value)
return nested
@@ -0,0 +1,45 @@
"""
NOTICE: DTensor has moved to torch.distributed.tensor
This file is a shim to redirect to the new location, and
we keep the old import path starts with `_tensor` for
backward compatibility. We will remove this folder once
we resolve all the BC issues.
"""
import sys
from importlib import import_module
submodules = [
# TODO: _shards_wrapper/_utils here mainly for checkpoint BC, remove them
"_shards_wrapper",
"_utils",
"experimental",
"device_mesh",
]
# Redirect imports
for submodule in submodules:
full_module_name = f"torch.distributed.tensor.{submodule}"
sys.modules[f"torch.distributed._tensor.{submodule}"] = import_module(
full_module_name
)
from torch.distributed.tensor import ( # noqa: F401
DeviceMesh,
distribute_module,
distribute_tensor,
DTensor,
empty,
full,
init_device_mesh,
ones,
Partial,
Placement,
rand,
randn,
Replicate,
Shard,
zeros,
)
@@ -0,0 +1,9 @@
"""
NOTE: torch.distributed._tensor has been moved to torch.distributed.tensor.
The imports here are purely for backward compatibility. We will remove these
imports in a few releases
TODO: throw warnings when this module imported
"""
from torch.distributed.tensor._api import * # noqa: F401, F403
@@ -0,0 +1,10 @@
"""
NOTE: torch.distributed._tensor has been moved to torch.distributed.tensor.
The imports here are purely for backward compatibility. We will remove these
imports in a few releases
TODO: throw warnings when this module imported
"""
from torch.distributed.tensor._dtensor_spec import * # noqa: F401, F403
from torch.distributed.tensor.placement_types import * # noqa: F401, F403
@@ -0,0 +1,12 @@
from .fsdp2_mem_tracker import FSDPMemTracker
from .mem_tracker import MemTracker
from .memory_tracker import MemoryTracker
from .mod_tracker import ModTracker
from .runtime_estimator import RuntimeEstimator
from .sac_estimator import (
MSPS,
SACEstimator,
SACGreedyOrderMeta,
SACStats,
SACTradeOffStats,
)
@@ -0,0 +1,43 @@
import warnings
import torch
from torch._opaque_base import OpaqueBase
from torch.utils._python_dispatch import is_traceable_wrapper_subclass
def get_untyped_storages(t: torch.Tensor) -> set[torch.UntypedStorage]:
"""
Recursively extracts untyped storages from a tensor or its subclasses.
Args:
t (torch.Tensor): The tensor to extract storages from.
Returns:
Set[torch.UntypedStorage]: A set of untyped storages.
"""
unflattened_tensors = [t]
flattened_tensor_storages = set()
while len(unflattened_tensors) > 0:
obj = unflattened_tensors.pop()
if is_traceable_wrapper_subclass(obj):
attrs, _ = obj.__tensor_flatten__()
for attr in attrs:
match getattr(obj, attr):
case torch.Tensor() as v:
unflattened_tensors.append(v)
case OpaqueBase():
pass
case unexpected:
raise AssertionError(
f"expected Tensor or OpaqueBase, got {type(unexpected)}"
)
else:
if not hasattr(obj, "untyped_storage"):
warnings.warn(
f"Expected a tensor or a traceable wrapper-subclass of tensor, but got {type(obj)}",
category=UserWarning,
stacklevel=2,
)
else:
flattened_tensor_storages.add(obj.untyped_storage())
return flattened_tensor_storages
@@ -0,0 +1,270 @@
from typing import Any
import torch
from torch._C._distributed_c10d import (
_resolve_process_group,
FakeWork,
ProcessGroup,
Work,
)
from torch.utils._pytree import tree_map_only
c10d = torch.ops.c10d
_c10d_functional = torch.ops._c10d_functional
_c10d_functional_autograd = torch.ops._c10d_functional_autograd
_dtensor = torch.ops._dtensor
# List of collective operation functions including functional collectives
# Note: The following collectives might be deprecated soon hence not adding them
# depcreated_non_functional_collectives = [
# c10d.allreduce_coalesced_.default,
# c10d.reduce_scatter_tensor_coalesced_.default,
# c10d.allgather_into_tensor_coalesced_.default,
# c10d.allgather_coalesced_.default,
# ]
non_functional_collectives: set[torch._ops.OpOverload] = {
c10d.broadcast_.default,
c10d.allreduce_.default,
c10d.reduce_.default,
c10d.send.default,
c10d.recv_.default,
c10d.recv_any_source_.default,
c10d.allgather_.default,
c10d.reduce_scatter_.default,
c10d._reduce_scatter_base_.default,
c10d._allgather_base_.default,
c10d.gather_.default,
c10d.scatter_.default,
c10d.alltoall_.default,
c10d.alltoall_base_.default,
c10d.barrier.default,
c10d.monitored_barrier_.default,
}
functional_collectives: set[torch._ops.OpOverload] = {
_c10d_functional.broadcast.default,
_c10d_functional.all_reduce.default,
_c10d_functional.all_gather_into_tensor.default,
_c10d_functional.reduce_scatter_tensor.default,
_c10d_functional.reduce_scatter_tensor_out.default,
_c10d_functional.all_to_all_single.default,
_c10d_functional_autograd.all_to_all_single.default,
_c10d_functional.wait_tensor.default,
_c10d_functional.all_reduce_.default,
_c10d_functional.all_reduce_coalesced.default,
_c10d_functional.all_reduce_coalesced_.default,
_c10d_functional.all_gather_into_tensor_out.default,
_c10d_functional.all_gather_into_tensor_coalesced.default,
_c10d_functional_autograd.all_gather_into_tensor.default,
_c10d_functional.reduce_scatter_tensor_coalesced.default,
_c10d_functional_autograd.reduce_scatter_tensor.default,
_c10d_functional.broadcast_.default,
_c10d_functional.isend.default,
_c10d_functional.irecv.default,
_c10d_functional.batch_p2p_ops.default,
_dtensor.shard_dim_alltoall.default,
}
sync_ops: set[torch._ops.OpOverload] = {
c10d.barrier.default,
c10d.monitored_barrier_.default,
_c10d_functional.wait_tensor.default,
}
collective_ops = set.union(functional_collectives, non_functional_collectives)
class CollectiveOp:
# Static sets for performance optimization
PG_ARG_1 = {
c10d.broadcast_.default,
c10d.allreduce_.default,
c10d.reduce_.default,
c10d.send.default,
c10d.recv_.default,
c10d.recv_any_source_.default,
c10d.barrier.default,
# c10d.allreduce_coalesced_.default
}
PG_ARG_2 = {
c10d.allgather_.default,
c10d._allgather_base_.default,
c10d.reduce_scatter_.default,
c10d._reduce_scatter_base_.default,
c10d.gather_.default,
c10d.scatter_.default,
c10d.alltoall_.default,
c10d.alltoall_base_.default,
# c10d.allgather_coalesced_.default,
# c10d.allgather_into_tensor_coalesced_.default
# c10d.reduce_scatter_tensor_coalesced_.default
}
PG_ARG_3 = {
_c10d_functional.broadcast.default,
_c10d_functional.broadcast_.default,
_c10d_functional.all_reduce.default,
_c10d_functional.all_reduce_.default,
_c10d_functional.all_reduce_coalesced.default,
_c10d_functional.all_reduce_coalesced_.default,
_c10d_functional.all_gather_into_tensor.default,
_c10d_functional.all_gather_into_tensor_out.default,
_c10d_functional_autograd.all_gather_into_tensor.default,
_c10d_functional.all_gather_into_tensor_coalesced.default,
}
PG_ARG_4 = {
_c10d_functional.reduce_scatter_tensor.default,
_c10d_functional.reduce_scatter_tensor_coalesced.default,
_c10d_functional_autograd.reduce_scatter_tensor.default,
_c10d_functional.all_to_all_single.default,
_c10d_functional_autograd.all_to_all_single.default,
_c10d_functional.isend.default,
_c10d_functional.irecv.default,
_dtensor.shard_dim_alltoall.default,
}
PG_ARG_5 = {
_c10d_functional.batch_p2p_ops.default,
}
WK_ARG_1 = {
c10d.broadcast_.default,
c10d.allreduce_.default,
c10d.allgather_.default,
c10d.reduce_scatter_.default,
c10d._reduce_scatter_base_.default,
c10d._allgather_base_.default,
c10d.scatter_.default,
c10d.alltoall_.default,
_c10d_functional.isend.default,
_c10d_functional.irecv.default,
}
WK = {
c10d.send.default,
c10d.recv_.default,
c10d.recv_any_source_.default,
c10d.reduce_.default,
c10d.gather_.default,
c10d.alltoall_base_.default,
c10d.barrier.default,
}
COMM_TENSOR_ARG_0 = {
c10d.allreduce_.default,
c10d.send.default,
c10d.recv_.default,
c10d.recv_any_source_.default,
c10d.allgather_.default,
c10d.gather_.default,
c10d.reduce_.default,
c10d.broadcast_.default,
_c10d_functional.all_reduce_coalesced.default,
_c10d_functional.all_reduce_coalesced_.default,
# c10d.allreduce_coalesced_.default
# c10d.allgather_coalesced_.default
# c10d.allgather_into_tensor_coalesced_.default,
}
COMM_TENSOR_ARG_1 = {
c10d.reduce_scatter_.default,
c10d.scatter_.default,
# c10d.reduce_scatter_tensor_coalesced_.default,
}
COMM_TENSOR_ARG_RES = {
_c10d_functional.all_gather_into_tensor.default,
_c10d_functional_autograd.all_gather_into_tensor.default,
}
COMM_TENSOR_SINGLE_UNTYPED_STORAGE = {
c10d._allgather_base_.default,
_c10d_functional.broadcast.default,
_c10d_functional.broadcast_.default,
_c10d_functional.all_reduce.default,
_c10d_functional.all_reduce_.default,
_c10d_functional.reduce_scatter_tensor.default,
_c10d_functional_autograd.reduce_scatter_tensor.default,
}
COMM_TENSOR_ARG_0_AND_RES = {
_c10d_functional.all_to_all_single.default,
_c10d_functional_autograd.all_to_all_single.default,
_dtensor.shard_dim_alltoall.default,
}
COMM_TENSOR_RES_SUM = {
_c10d_functional.all_gather_into_tensor_coalesced.default,
_c10d_functional.reduce_scatter_tensor_coalesced.default,
}
@staticmethod
def sum_tensors(arg: Any) -> int:
"""Calculate total memory consumed by the tensors in the argument."""
total_memory = 0
def sum_bytes(t: torch.Tensor) -> None:
nonlocal total_memory
total_memory += t.untyped_storage().nbytes()
tree_map_only(torch.Tensor, sum_bytes, arg)
return total_memory
@staticmethod
def get_process_group(func, args) -> ProcessGroup: # type: ignore[no-untyped-def]
"""Retrieve the process group for collective operations, except `wait_tensor`."""
if func in CollectiveOp.PG_ARG_1:
return ProcessGroup.unbox(args[1])
if func in CollectiveOp.PG_ARG_2:
return ProcessGroup.unbox(args[2])
if func in CollectiveOp.PG_ARG_3:
return _resolve_process_group(args[2])
if func in CollectiveOp.PG_ARG_4:
return _resolve_process_group(args[3])
if func in CollectiveOp.PG_ARG_5:
return _resolve_process_group(args[4])
raise TypeError(f"Func {func} not found in {collective_ops}")
@staticmethod
def get_comm_tensor_size(func, res, args, kwargs) -> int: # type: ignore[no-untyped-def]
"""Compute the communication tensor size, except for `wait_tensor`, `barrier`, and `monitored_barrier`."""
if func in CollectiveOp.COMM_TENSOR_ARG_0:
return CollectiveOp.sum_tensors(args[0])
if func in CollectiveOp.COMM_TENSOR_ARG_1:
return CollectiveOp.sum_tensors(args[1])
if func in CollectiveOp.COMM_TENSOR_ARG_RES:
return res.untyped_storage().nbytes()
if func in CollectiveOp.COMM_TENSOR_SINGLE_UNTYPED_STORAGE:
return args[0].untyped_storage().nbytes()
if func is c10d._reduce_scatter_base_.default:
return args[1].untyped_storage().nbytes()
if func is c10d.alltoall_.default:
# TODO(@sanketpurandare) - Confirm size computation
return max(
CollectiveOp.sum_tensors(args[0]), CollectiveOp.sum_tensors(args[1])
)
if func is c10d.alltoall_base_.default:
# TODO(@sanketpurandare) - Confirm size computation
return max(
args[0].untyped_storage().nbytes(), args[1].untyped_storage().nbytes()
)
if func == _c10d_functional.all_gather_into_tensor_out.default:
return args[-1].untyped_storage().nbytes()
if func in CollectiveOp.COMM_TENSOR_RES_SUM:
return CollectiveOp.sum_tensors(res)
if func in CollectiveOp.COMM_TENSOR_ARG_0_AND_RES:
# TODO(@sanketpurandare) - Confirm size computation
return args[0].untyped_storage().nbytes() + res.untyped_storage().nbytes()
if func is _c10d_functional.batch_p2p_ops.default:
return CollectiveOp.sum_tensors(args[3])
raise TypeError(f"Unknown function: {func} in {collective_ops}")
@staticmethod
def get_work(func, res) -> Work: # type: ignore[no-untyped-def]
if func in CollectiveOp.WK:
return FakeWork.unbox(res)
elif func in CollectiveOp.WK_ARG_1:
return FakeWork.unbox(res[1])
raise TypeError(f"Func {func} not found in {collective_ops}")
@@ -0,0 +1,579 @@
from collections.abc import Callable
from copy import deepcopy
from enum import auto, Enum
from functools import partial, wraps
from typing import Any, NamedTuple, TYPE_CHECKING, TypeVar
from typing_extensions import ParamSpec, TypeVarTuple, Unpack
import torch
import torch.distributed._tools.fake_collectives
from torch import nn, optim
from torch._guards import active_fake_mode
from torch.distributed._tools.mem_tracker import _RefType, _State, MemTracker
from torch.distributed.fsdp import FSDPModule
from torch.distributed.fsdp._fully_shard._fsdp_param_group import FSDPParamGroup
from torch.distributed.tensor import DTensor
from torch.utils._python_dispatch import TorchDispatchMode
from torch.utils._pytree import tree_map_only
from torch.utils.weak import WeakIdKeyDictionary, weakref
if TYPE_CHECKING:
from torch.utils.hooks import RemovableHandle
_TOTAL_KEY = "Total"
__all__ = ["FSDPMemTracker"]
_P = ParamSpec("_P")
_R = TypeVar("_R")
_Ts = TypeVarTuple("_Ts")
c10d = torch.ops.c10d
class _FSDPRefType(_RefType):
"""
Enumerates categories of memory usage in FSDP modules, including parameters, gradients, activations,
and optimizer states.
Attributes:
SHARDED_PARAM (str): Memory usage of sharded parameters.
UNSHARDED_PARAM (str): Memory usage of unsharded parameters.
SHARDED_GRAD (str): Memory usage of sharded gradients corresponding to the sharded parameters.
UNSHARDED_GRAD (str): Memory usage of unsharded gradients corresponding to the unsharded parameters.
ACT (str): Memory usage of activations and tensors from forward and AC recomputation.
TEMP (str): Memory usage of temporary tensors during the backward pass including gradients of activations.
ALL_GATHER (str): Memory usage of all_gather output tensor.
REDUCE_SCATTER (str): Memory usage of reduce_scatter input tensor.
OPT (str): Memory usage of tensors storing optimizer states.
INP (str): Memory usage of input tensors.
"""
SHARDED_PARAM = "Sharded Param"
UNSHARDED_PARAM = "Unsharded Param"
BUFFER = "Buffer"
SHARDED_GRAD = "Sharded Grad"
UNSHARDED_GRAD = "Unsharded Grad"
ACT = "Activation"
TEMP = "Temp"
ALL_GATHER = "All Gather"
REDUCE_SCATTER = "Reduce Scatter"
OPT = "OptState"
INP = "Inputs"
class _SavedFSDPMethods(NamedTuple):
pre_backward: Callable
post_backward: Callable
class _FSDPModState(_State):
"""
Enumerates the states of FSDP modules during the forward and backward passes.
"""
BEF_PRE_FW = "Before Pre-Forward"
AFT_PRE_FW = "After Pre-Forward"
BEF_POST_FW = "Before Post-Forward"
AFT_POST_FW = "After Post-Forward"
BEF_PRE_BW = "Before Pre-Backward"
AFT_PRE_BW = "After Pre-Backward"
BEF_POST_BW = "Before Post-Backward"
AFT_POST_BW = "After Post-Backward"
PRE_FW_AC = "Pre-Forward AC"
POST_FW_AC = "Post-Forward AC"
PEAK_FW = "Peak Forward"
PEAK_BW = "Peak Backward"
class _FSDPModMemStats:
"""
A class to store the memory statistics of an FSDP module.
Args:
mod_fqn (str): The fully qualified name of the FSDP module.
Attributes:
snapshots (Dict[_FSDPModState, Dict[torch.device, Dict[str, int]]]): A dictionary of memory snapshots
of the module at different states as defined by ``_FSDPModState``. Each key is a device, and
each value is another dictionary with keys as memory reference types defined by ``_FSDPRefType`` and
values as the memory consumed in bytes.
"""
def __init__(self, mod_fqn: str) -> None:
self.mod_fqn = mod_fqn
self.local_peak: dict[torch.device, int] = {}
self.snapshots: dict[
_FSDPModState, list[dict[torch.device, dict[str, int]]]
] = {}
class _FSDPState(Enum):
PRE_FW = auto()
FW = auto()
POST_FW = auto()
PRE_BW = auto()
BW = auto()
POST_BW = auto()
class FSDPMemTracker(MemTracker):
"""
A ``TorchDispatchMode`` based context manager that extends ``torch.distributed._tools.mem_tracker.MemTracker`` to track
and categorize the peak memory and module-wise memory usage of FSDP modules.
It tracks the peak memory usage across all the devices of all the FSDP modules in the module tree and categorizes
the tensor memory usage as defined by ``_FSDPRefType``. Further, it captures memory `snapshots` at different stages of
the module execution defined by ``_FSDPModState``.
Attributes:
memory_tracking: A weakref key dictionary to store the memory statistics of each module. Each key is a reference
to a module, and each value is a ``_FSDPModMemStats`` object that stores the memory statistics of the module.
Args:
mod (torch.nn.Module): The root FSDP module to be tracked.
optm (torch.optim.Optimizer, optional): The optimizer to be tracked.
Note: Please refer to ``torch.distributed._tools.mem_tracker.MemTracker`` to learn about the limitations.
Example usage
.. code-block:: python
module = ...
optimizer = ...
inp = ...
fmt = FSDPMemTracker(module, optimizer)
fmt.track_inputs((inp,))
with fmt:
optimizer.zero_grad()
loss = module(inp)
print("After Forward:")
fmt.display_snapshot("current")
loss.backward()
optimizer.step()
fmt.display_snapshot("peak")
fmt.display_modulewise_snapshots(depth=3, units="MB")
"""
def __init__(
self,
mod: torch.nn.Module,
optm: torch.optim.Optimizer | None = None,
) -> None:
super().__init__()
if not isinstance(mod, FSDPModule):
raise AssertionError("FSDPMemTracker only supports FSDP modules")
self._root_mod = mod
self._optm = optm
self._fsdp_mod_to_saved_methods: WeakIdKeyDictionary = WeakIdKeyDictionary()
self._fsdp_state: _FSDPState = _FSDPState.PRE_FW
self._ref_class: type[_RefType] = _FSDPRefType
def _instrument_fsdp_sharded_params_grads(
self, fsdp_param_group: FSDPParamGroup
) -> None:
# Track sharded params and grads after initialization
for fsdp_param in fsdp_param_group.fsdp_params:
self._update_and_maybe_create_winfos(
fsdp_param.sharded_param,
_FSDPRefType.SHARDED_PARAM,
)
sharded_grad = fsdp_param.sharded_param.grad
if sharded_grad is not None:
self._update_and_maybe_create_winfos(
sharded_grad,
_FSDPRefType.SHARDED_GRAD,
)
def _fsdp_state_pre_forward(
self,
fsdp_mod: FSDPModule,
orig_fsdp_state_pre_fw: Callable[_P, tuple[tuple[Unpack[_Ts]], dict[str, Any]]],
) -> Callable[_P, tuple[tuple[Unpack[_Ts]], dict[str, Any]]]:
# We capture memory snapshots before and after ``FSDPState._pre_forward`` to attribute the `unsharded` params
# and `all_gather` buffers. There are three cases:
# Case 1: If the module is not in the ``memory_tracking`` dictionary, create a new ``_FSDPModMemStats``
# instance for the module and add it to the ``memory_tracking`` dictionary.
# Case 2: If the module is already in the ``memory_tracking`` dictionary and we are in backward, this means
# we are in the AC region. We check if this is the top most module in the AC region. If it is,
# we store a weak reference and set the flag ``_in_ac`` to True.
# Case 3: If the module is already in the ``memory_tracking`` dictionary and we are in forward, this means
# this module is called for the second time. If it is a root module, that means we are in the next
# iteration and we error out. If it is not a root module, that means it's a submodule that is being
# used multiple times in the same iteration, which we allow and track.
# For Case 1 and 3, we also initialize the ``local_peak`` and ``PEAK_FW`` snapshot for the module.
# For Case 2 we only capture 1 snapshot after ``FSDPState._pre_forward`` runs because it is a no-op.
@wraps(orig_fsdp_state_pre_fw)
def inner(
*args: _P.args, **kwargs: _P.kwargs
) -> tuple[tuple[Unpack[_Ts]], dict[str, Any]]:
self._fsdp_state = _FSDPState.PRE_FW
mod_fqn = self._mod_tracker.get_known_fqn(fsdp_mod)
if mod_fqn is None:
raise AssertionError
if fsdp_mod not in self.memory_tracking:
mod_stat = _FSDPModMemStats(mod_fqn)
self.memory_tracking[fsdp_mod] = mod_stat
snapshot = self.get_tracker_snapshot()
mod_stat.local_peak = {
dev: dev_snap[_TOTAL_KEY] for dev, dev_snap in snapshot.items()
}
mod_stat.snapshots.setdefault(_FSDPModState.PEAK_FW, []).append(
snapshot
)
mod_stat.snapshots.setdefault(_FSDPModState.BEF_PRE_FW, []).append(
deepcopy(snapshot)
)
elif not self._mod_tracker.is_bw:
parents = self._mod_tracker.parents - {mod_fqn}
if len(parents) == 1 and "Global" in parents:
raise NotImplementedError(
"FSDPMemTracker does not support memory tracking for multiple iterative calls."
" Either use ``reset_mod_stats`` to clear module memory stats for the previous iteration"
" or file a github issue if you need this feature."
)
# pyrefly: ignore [bad-assignment]
args, kwargs = orig_fsdp_state_pre_fw(*args, **kwargs)
fsdp_state = fsdp_mod._get_fsdp_state()
if fsdp_param_group := fsdp_state._fsdp_param_group:
for fsdp_param in fsdp_param_group.fsdp_params:
self._update_and_maybe_create_winfos(
fsdp_param.unsharded_param,
_FSDPRefType.UNSHARDED_PARAM,
)
mod_stat = self.memory_tracking[fsdp_mod]
if self._mod_tracker.is_bw:
state = _FSDPModState.PRE_FW_AC
if self._ac_mod is None:
self._ac_mod = weakref.ref(fsdp_mod)
self._in_ac = True
else:
state = _FSDPModState.AFT_PRE_FW
mod_stat.snapshots.setdefault(state, []).append(self.get_tracker_snapshot())
self._fsdp_state = _FSDPState.FW
# pyrefly: ignore [bad-return]
return args, kwargs
return inner
def _fsdp_state_post_forward(
self,
fsdp_mod: FSDPModule,
orig_fsdp_state_post_fw: Callable[_P, _R],
) -> Callable[_P, _R]:
# We capture memory snapshots before and after ``FSDPState._post_forward`` to capture the resharded state
# if ``reshard_after_forward`` is not ``False``. There are two cases:
# Case 1: This is called in backward, which means we are in the AC region. If this is the top most module
# in the AC region, we set the flag ``_in_ac`` to False.
# Case 2: This is called in forward.
@wraps(orig_fsdp_state_post_fw)
def inner(*args: _P.args, **kwargs: _P.kwargs) -> _R:
mod_stat = self.memory_tracking[fsdp_mod]
if self._mod_tracker.is_bw:
state = _FSDPModState.POST_FW_AC
if self._ac_mod is not None and self._ac_mod() is fsdp_mod:
self._ac_mod = None
self._in_ac = False
else:
state = _FSDPModState.BEF_POST_FW
mod_stat.snapshots.setdefault(state, []).append(self.get_tracker_snapshot())
self._fsdp_state = _FSDPState.POST_FW
output = orig_fsdp_state_post_fw(*args, **kwargs)
if not self._mod_tracker.is_bw:
mod_stat.snapshots.setdefault(_FSDPModState.AFT_POST_FW, []).append(
self.get_tracker_snapshot()
)
return output
return inner
def _fsdp_param_group_pre_backward(
self,
fsdp_mod: FSDPModule,
orig_fsdp_param_group_pre_backward: Callable[_P, Any],
) -> Callable[_P, None]:
# We capture memory snapshots before and after ``FSDPParamGroup.pre_backward`` to capture the pre-fetching
# and unsharding of params. We also initialize ``local_peak`` and ``PEAK_BW`` snapshot for the module.
@wraps(orig_fsdp_param_group_pre_backward)
def inner(*args: _P.args, **kwargs: _P.kwargs) -> None:
self._fsdp_state = _FSDPState.PRE_BW
mod_stat = self.memory_tracking[fsdp_mod]
snapshot = self.get_tracker_snapshot()
mod_stat.local_peak = {
dev: dev_snap[_TOTAL_KEY] for dev, dev_snap in snapshot.items()
}
mod_stat.snapshots.setdefault(_FSDPModState.PEAK_BW, []).append(snapshot)
mod_stat.snapshots.setdefault(_FSDPModState.BEF_PRE_BW, []).append(
deepcopy(snapshot)
)
orig_fsdp_param_group_pre_backward(*args, **kwargs)
mod_stat.snapshots.setdefault(_FSDPModState.AFT_PRE_BW, []).append(
self.get_tracker_snapshot()
)
self._fsdp_state = _FSDPState.BW
return inner
def _fsdp_param_group_post_backward(
self,
fsdp_mod: FSDPModule,
orig_fsdp_param_group_post_backward: Callable[_P, Any],
) -> Callable[_P, None]:
# We capture the memory snapshots before and after ``FSDPParamGroup.post_backward`` to track and attribute
# the `unsharded` grads before the post backward and then `sharded` grads and `reduce_scatter` buffers
# after the post backward.
@wraps(orig_fsdp_param_group_post_backward)
def inner(*args: _P.args, **kwargs: _P.kwargs) -> None:
fsdp_state = fsdp_mod._get_fsdp_state()
if fsdp_param_group := fsdp_state._fsdp_param_group:
for fsdp_param in fsdp_param_group.fsdp_params:
unsharded_grad = fsdp_param._unsharded_param.grad
if unsharded_grad is not None:
self._update_and_maybe_create_winfos(
unsharded_grad,
_FSDPRefType.UNSHARDED_GRAD,
update_existing=True,
)
mod_stat = self.memory_tracking[fsdp_mod]
mod_stat.snapshots.setdefault(_FSDPModState.BEF_POST_BW, []).append(
self.get_tracker_snapshot()
)
self._fsdp_state = _FSDPState.POST_BW
orig_fsdp_param_group_post_backward(*args, **kwargs)
if fsdp_param_group := fsdp_state._fsdp_param_group:
for fsdp_param in fsdp_param_group.fsdp_params:
sharded_grad = fsdp_param.sharded_param.grad
if sharded_grad is not None:
self._update_and_maybe_create_winfos(
sharded_grad,
_FSDPRefType.SHARDED_GRAD,
)
mod_stat.snapshots.setdefault(_FSDPModState.AFT_POST_BW, []).append(
self.get_tracker_snapshot()
)
return inner
def _instrument_fsdp_module(self) -> None:
# We uninstall the existing `FSDPState._pre_forward` and `FSDPState._post_forward` hooks and install
# our own hooks that wrap them. We choose this over monkey-patching `FSDPParamGroup.pre_forward` and
# `FSDPParamGroup.post_forward` because during AC these won't be called.
# TODO(@sanketpurandare): This will need to be modified after this PR (https://github.com/pytorch/pytorch/pull/127786)
# lands. For backward we monkey-patch the `FSDPParamGroup.pre_backward` and `FSDPParamGroup.post_backward`.
# get the unique _MultiHandlers/RemoveHandlers and store in dictionary
# the _MultiHandlers object will only need to be grabbed once.
unique_handlers: dict[RemovableHandle, bool] = {}
for module in self._root_mod.modules():
if isinstance(module, FSDPModule):
fsdp_state = module._get_fsdp_state()
if fsdp_param_group := fsdp_state._fsdp_param_group:
if not unique_handlers.get(fsdp_state._pre_forward_hook_handle):
unique_handlers[fsdp_state._pre_forward_hook_handle] = True
if not unique_handlers.get(fsdp_state._post_forward_hook_handle):
unique_handlers[fsdp_state._post_forward_hook_handle] = True
# call remove on the handles once
for f_hook_handle in unique_handlers:
f_hook_handle.remove()
for module in self._root_mod.modules():
if isinstance(module, FSDPModule):
fsdp_state = module._get_fsdp_state()
if fsdp_param_group := fsdp_state._fsdp_param_group:
self._instrument_fsdp_sharded_params_grads(fsdp_param_group)
fsdp_state._pre_forward_hook_handle = (
module.register_forward_pre_hook(
self._fsdp_state_pre_forward(
module, fsdp_state._pre_forward
),
prepend=True,
with_kwargs=True,
)
)
fsdp_state._post_forward_hook_handle = module.register_forward_hook(
self._fsdp_state_post_forward(module, fsdp_state._post_forward),
prepend=False,
always_call=True,
)
self._fsdp_mod_to_saved_methods[module] = _SavedFSDPMethods(
fsdp_param_group.pre_backward,
fsdp_param_group.post_backward,
)
fsdp_param_group.pre_backward = self._fsdp_param_group_pre_backward( # type: ignore[assignment]
module, fsdp_param_group.pre_backward
)
fsdp_param_group.post_backward = ( # type: ignore[assignment]
self._fsdp_param_group_post_backward(
module, fsdp_param_group.post_backward
)
)
for buffer in self._root_mod.buffers():
self._update_and_maybe_create_winfos(
buffer,
_FSDPRefType.BUFFER,
)
def _instrument_optimizer(self) -> None:
# Register a hook on the optimizer step to track the optimizer states.
# The pre-hook is to set the flag ``_in_opt`` to True. The post-hook unsets the flag,
# and also tracks any optimizer states that are created during the optimizer step.
if self._optm is not None:
self._track_optimizer_states(_FSDPRefType.OPT, self._optm)
def _opt_step_pre_hook(
optimizer: optim.Optimizer, args: Any, kwargs: Any
) -> None:
self._in_opt = True
def _opt_step_post_hook(
optimizer: optim.Optimizer, args: Any, kwargs: Any
) -> None:
self._track_optimizer_states(_FSDPRefType.OPT, optimizer)
self._in_opt = False
self._optimizer_hook_handles = (
self._optm.register_step_pre_hook(_opt_step_pre_hook),
self._optm.register_step_post_hook(_opt_step_post_hook),
)
def _register_module_and_optimizer_hooks(self) -> None:
self._instrument_fsdp_module()
self._instrument_optimizer()
def _deregister_module_and_optimizer_hooks(self) -> None:
for (
fsdp_mod,
saved_methods,
) in self._fsdp_mod_to_saved_methods.items():
fsdp_state = fsdp_mod._get_fsdp_state()
fsdp_state._pre_forward_hook_handle.remove()
fsdp_state._post_forward_hook_handle.remove()
fsdp_state._pre_forward_hook_handle = fsdp_mod.register_forward_pre_hook(
fsdp_state._pre_forward, prepend=True, with_kwargs=True
)
fsdp_state._post_forward_hook_handle = fsdp_mod.register_forward_hook(
fsdp_state._post_forward, prepend=False
)
if fsdp_param_group := fsdp_state._fsdp_param_group:
fsdp_param_group.pre_backward = saved_methods.pre_backward
fsdp_param_group.post_backward = saved_methods.post_backward
self._fsdp_mod_to_saved_methods.clear()
if self._optimizer_hook_handles is not None:
for handle in self._optimizer_hook_handles:
handle.remove()
self._optimizer_hook_handles = None
def track_inputs(self, inputs: tuple[Any, ...]) -> None:
"""
This is used to track the input tensors to the model and annotate them as ``Inputs``.
Args:
inputs (Tuple[Any]): A tuple containing the input data. This can include tensors
as well as other data types. Only tensors will be tracked.
"""
def _track_inputs(t: torch.Tensor) -> None:
self._update_and_maybe_create_winfos(
t,
_FSDPRefType.INP,
)
tree_map_only(torch.Tensor, _track_inputs, inputs)
def track_external(
self, *external: nn.Module | optim.Optimizer | torch.Tensor
) -> None:
"""This is no-op for ``FSDPMemTracker``"""
def __enter__(self) -> "FSDPMemTracker":
if self._depth == 0:
self._register_module_and_optimizer_hooks()
self._track_resize()
self._peak_mem_snap = self.get_tracker_snapshot()
self._peak_mem = {
dev: dev_snap[_TOTAL_KEY]
for dev, dev_snap in self._peak_mem_snap.items()
}
self._mod_tracker.__enter__()
TorchDispatchMode.__enter__(self)
self._depth += 1
return self
def __exit__(self, *args: Any) -> None:
self._depth -= 1
if self._depth == 0:
self._deregister_module_and_optimizer_hooks()
self._restore_resize()
self._mod_tracker.__exit__(*args)
TorchDispatchMode.__exit__(self, *args)
def __torch_dispatch__(self, func, types, args=..., kwargs=None): # type: ignore[no-untyped-def]
# 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 local tensor ops, before `MemTracker` sees them.
if any(t == DTensor for t in types):
return NotImplemented
if (
func is torch.ops._c10d_functional.wait_tensor.default
and active_fake_mode()
):
# N.B: This is a hacky way to override the Meta IMPL of wait_tensor. The original impl returns
# a new tensor which does not happen in eager mode, when a wait_tensor is called.
# pyrefly: ignore [unsupported-operation]
res = args[0]
else:
res = func(*args, **kwargs or {})
# If we are tracking an optimizer state, we use the optimizer reference type.
# If we are in backward region and not in AC region, we use the backward reference type.
# Else we use the forward reference type.
if self._in_opt:
reftype = _FSDPRefType.OPT
elif self._mod_tracker.is_bw and not self._in_ac:
reftype = _FSDPRefType.TEMP
else:
reftype = _FSDPRefType.ACT
if func is c10d._allgather_base_.default and self._fsdp_state in [
_FSDPState.PRE_FW,
_FSDPState.PRE_BW,
]:
# pyrefly: ignore [unsupported-operation]
output_tensor = args[0]
self._update_and_maybe_create_winfos(
output_tensor,
_FSDPRefType.ALL_GATHER,
update_existing=True,
)
if (
func is c10d._reduce_scatter_base_.default
and self._fsdp_state == _FSDPState.POST_BW
):
# pyrefly: ignore [unsupported-operation]
input_tensor = args[1]
self._update_and_maybe_create_winfos(
input_tensor,
_FSDPRefType.REDUCE_SCATTER,
update_existing=True,
)
tree_map_only(torch.Tensor, partial(self._track, reftype), res)
peak_state = (
_FSDPModState.PEAK_BW if self._mod_tracker.is_bw else _FSDPModState.PEAK_FW
)
self._update_peak_stats(peak_state)
return res
@@ -0,0 +1,293 @@
import copy
from collections import OrderedDict
from typing import cast, TypedDict
import numpy as np
import torch
from torch.distributed._tools.mem_tracker import (
_MemRefType,
_ModMemStats,
_ModState,
MemTracker,
)
from torch.distributed._tools.runtime_estimator import RuntimeEstimator
from torch.distributed._tools.sac_estimator import SACEstimator, SACTradeOffStats
class ModOrder(TypedDict):
fw_pre_order: list[str]
bw_pre_order: list[str]
fw_post_order: list[str]
bw_post_order: list[str]
class ModRuntime(TypedDict):
fw: float
bw: float
class ModStats(TypedDict):
fqn: str
# per-module params
param_per_module: int
# per-module grads
grad_per_module: int
# total accumulated gradients up to and including this module
grad_total: int
# per module fw activation size (excluding input and output)
act_fw_per_module: int
# per module bw activation size during peak_bw
act_bw_per_module: int
# per module activation grad size during peak_bw
act_grad_per_module: int
# total activation size up to but excluding the current module
# includes input of the current module (i.e., output of previous module)
act_total: int
# Inputs to the module
input_per_module: int
# Outputs of the module
output_per_module: int
# Total fw run-time of the module
fw_runtime_per_module: float
# Total bw run-time of the module
bw_runtime_per_module: float
# Is this module a leaf module
is_leaf: bool
# Total ac run-time of the module
sac_runtime: float
# Total ac_memory for the module
sac_memory: int
# Number of piecewise-linear functions used for approximating ac tradeoff curve
n_segments: int
# Slopes of the of piecewise-linear functions
slopes: list[float]
# Intercepts of the of piecewise-linear functions
intercepts: list[float]
# X breakpoints of the of piecewise-linear functions
breakpoints: list[float]
# Original trade-off curves
tradeoff_curve: OrderedDict[float, float]
class ModuleInfo(TypedDict):
mod_order: ModOrder
mod_stats: list[ModStats]
def aggregate_stats(
model: torch.nn.Module,
mem_tracker: MemTracker,
runtime_estimator: RuntimeEstimator,
sac_estimator: SACEstimator,
dev: torch.device,
) -> ModuleInfo:
"""
Collect modulewise stats for a given model, including memory, runtime, and AC tradeoff stats.
Args:
model: nn.Module object
runtime_estimator: RuntimeEstimator object with runtime stats
mem_tracker: MemTracker object with memory stats
sac_estimator: SACEstimator object with AC tradeoff stats
dev: device the model was run on (used to extract memory stats from MemTracker)
Returns:
ModuleInfo: A dictionary with module order and module stats.
"""
# Memory stats
mod_mem_stats: dict[torch.nn.Module, _ModMemStats] = dict(
copy.deepcopy(mem_tracker.memory_tracking)
)
# Runtime stats
mod_runtime_stats: dict[str, ModRuntime] = {
fqn: {"fw": v["fw"], "bw": v["bw"]}
for fqn, v in runtime_estimator.mod_runtimes.items()
}
# Module order
mod_order: ModOrder = {
"fw_pre_order": list(runtime_estimator.mod_fw_pre_order),
"bw_pre_order": list(runtime_estimator.mod_bw_pre_order),
"fw_post_order": list(runtime_estimator.mod_fw_post_order),
"bw_post_order": list(runtime_estimator.mod_bw_post_order),
}
# Selective Activation Checkpointing stats
sac_estimator.pwlf_sac_tradeoff_curve()
mod_sac_tradeoff_stats: dict[str, SACTradeOffStats] = copy.deepcopy(
sac_estimator.sac_mod_tradeoff_stats
)
module_info: ModuleInfo = {
"mod_order": mod_order,
"mod_stats": [],
}
for mod in model.modules():
if mod_mem_stat := mod_mem_stats.get(mod):
if tradeoff_stats := mod_sac_tradeoff_stats.get(mod_mem_stat.mod_fqn):
sac_runtime = tradeoff_stats.sac_runtime
sac_memory = tradeoff_stats.sac_memory
n_segments = tradeoff_stats.n_segments
slopes = tradeoff_stats.slopes
intercepts = tradeoff_stats.intercepts
breakpoints = tradeoff_stats.fit_breaks
tradeoff_curve = tradeoff_stats.tradeoff_curve
is_leaf = False
else:
sac_runtime = sac_memory = n_segments = 0
slopes = intercepts = breakpoints = []
tradeoff_curve: OrderedDict[float, float] = OrderedDict() # type: ignore[no-redef]
is_leaf = True
mod_stat: ModStats = {
"fqn": mod_mem_stat.mod_fqn,
"param_per_module": mod_mem_stat.parameter_mem,
"grad_per_module": mod_mem_stat.parameter_mem,
"grad_total": mod_mem_stat.snapshots[_ModState.PRE_BW][-1][dev][
_MemRefType.GRAD
],
"act_fw_per_module": max(
0,
mod_mem_stat.snapshots[_ModState.POST_FW][-1][dev][_MemRefType.ACT]
- mod_mem_stat.snapshots[_ModState.PRE_FW][-1][dev][_MemRefType.ACT]
- mod_mem_stat.output_mem,
),
"act_bw_per_module": max(
0,
mod_mem_stat.snapshots[_ModState.PEAK_BW][-1][dev][_MemRefType.ACT],
),
"act_grad_per_module": (
mod_mem_stat.snapshots[_ModState.PEAK_BW][-1][dev][_MemRefType.TEMP]
- mod_mem_stat.snapshots[_ModState.PRE_BW][-1][dev][
_MemRefType.TEMP
]
),
"act_total": mod_mem_stat.snapshots[_ModState.POST_FW][-1][dev][
_MemRefType.ACT
],
"input_per_module": mod_mem_stat.input_mem,
"output_per_module": mod_mem_stat.output_mem,
"fw_runtime_per_module": mod_runtime_stats[mod_mem_stat.mod_fqn]["fw"],
"bw_runtime_per_module": mod_runtime_stats[mod_mem_stat.mod_fqn]["bw"],
"is_leaf": is_leaf,
"sac_runtime": sac_runtime,
"sac_memory": sac_memory,
"n_segments": n_segments,
"slopes": slopes,
"intercepts": intercepts,
"breakpoints": breakpoints,
"tradeoff_curve": tradeoff_curve,
}
module_info["mod_stats"].append(mod_stat)
return module_info
class Node(ModStats):
index: int # index according to forward pre-order
pos_fw_post_order: int # index according to forward post-order
class Graph:
def __init__(self, n: int) -> None:
self.nodes: list[Node] = []
self.name2node: dict[str, Node] = {}
self.ad_matrix = np.zeros((n, n))
self.fw_post_order: list[str] = []
def add_node(self, node: Node) -> None:
self.nodes.append(node)
self.name2node[node["fqn"]] = node
def parse_module_info(module_info: ModuleInfo) -> Graph:
"""
Parse module info and create a graph (tree) of modules. The graph will be
used by MILP solver to find optimal SAC and/or FSDP configurations.
"""
mod_stats = module_info["mod_stats"]
fw_pre_order = module_info["mod_order"]["fw_pre_order"]
# assertion and number of nodes
if len(mod_stats) != len(fw_pre_order):
raise AssertionError
n_nodes = len(mod_stats)
# create graph
g = Graph(n_nodes)
g.fw_post_order = module_info["mod_order"]["fw_post_order"]
# sort the modules by pre-order and add them to the graph
module_info["mod_stats"] = sorted(
mod_stats, key=lambda x: fw_pre_order.index(x["fqn"])
)
for i, one_mod_stats in enumerate(mod_stats):
node: Node = cast(Node, one_mod_stats)
node["index"] = i
node["pos_fw_post_order"] = g.fw_post_order.index(node["fqn"])
g.add_node(node)
# set up ancestor-descendant matrix
for i in range(n_nodes):
for j in range(i, n_nodes):
if is_self_or_submodule(g.nodes[j]["fqn"], g.nodes[i]["fqn"]):
g.ad_matrix[i][j] = 1
else:
break
return g
def is_self_or_submodule(name_descendant: str, name_ancestor: str) -> bool:
"""
check if name_descendant is a submodule of name_ancestor, or if they are the same
"""
return name_descendant == name_ancestor or name_ancestor + "." in name_descendant
def is_submodule(name_descendant: str, name_ancestor: str) -> bool:
"""
if name_descendant is a submodule of name_ancestor, but not the same
"""
return name_ancestor + "." in name_descendant
def display_bytes(b: int, unit: str = "MiB") -> str:
"""
return a string that represent the number of bytes in a desired unit
"""
if unit == "KiB":
return f"{b / 2**10:.2f} KiB"
if unit == "MiB":
return f"{b / 2**20:.2f} MiB"
if unit == "GiB":
return f"{b / 2**30:.2f} GiB"
return f"{b:.2f} bytes"
def get_peak_memory_runtime_baseline(graph: Graph) -> tuple[int, float]:
"""
Get the baseline peak memory and runtime.
Baseline here means there is no FSDP or AC.
Memory includes the parameters, gradients, activations, and activation gradients.
Memory does not include e.g., optimizer states, embedding tables, etc.
Returns:
int: peak memory in bytes
float: compute time in ms
"""
P_1 = graph.nodes[0]["param_per_module"]
num_nodes = len(graph.nodes)
peak_mem = 0
for i in range(num_nodes):
TG_i = graph.nodes[i]["grad_total"]
AG_i = graph.nodes[i]["act_grad_per_module"]
TA_i = graph.nodes[i]["act_total"]
peak_mem = max(peak_mem, P_1 + TG_i + AG_i + TA_i)
compute_time = (
graph.nodes[0]["fw_runtime_per_module"]
+ graph.nodes[0]["bw_runtime_per_module"]
)
return (peak_mem, compute_time)
@@ -0,0 +1,940 @@
import math
import os
import re
import warnings
from collections.abc import Callable
from copy import deepcopy
from enum import auto, Enum
from functools import partial, wraps
from typing import Any, TYPE_CHECKING
from typing_extensions import Self
import torch
import torch.distributed._tools.fake_collectives
from torch import nn, optim
from torch._guards import active_fake_mode
from torch.distributed._tools.common_utils import get_untyped_storages
from torch.distributed._tools.mod_tracker import ModTracker
from torch.distributed.tensor import DTensor
from torch.optim.optimizer import (
register_optimizer_step_post_hook,
register_optimizer_step_pre_hook,
)
from torch.utils._python_dispatch import TorchDispatchMode
from torch.utils._pytree import tree_flatten, tree_map_only
from torch.utils.weak import WeakIdKeyDictionary, weakref
if TYPE_CHECKING:
from torch.utils.hooks import RemovableHandle
# This value is hard-coded here:
# https://github.com/pytorch/pytorch/blob/5fba5d83f0703ff8077ab65448a998e9ad6598fd/c10/cuda/CUDACachingAllocator.cpp#L117
_PYTORCH_MIN_ALLOCATE = (
2**9 if int(os.environ.get("PYTORCH_NO_CUDA_MEMORY_CACHING", 0)) == 0 else 1
)
_TOTAL_KEY = "Total"
__all__ = ["MemTracker"]
class _RefType(str, Enum):
"""Base Class for defining memory reference types, categorizing tensors based on their usage within a model."""
class _State(str, Enum):
"""Base Class for defining module state to capture snapshots ."""
class _MemRefType(_RefType):
"""
An enum to define memory reference types, categorizing tensors based on their usage within a model.
- PARAM: Tensors registered as nn.Parameter within modules.
- BUFFER: Tensors registered as nn.Buffer within modules.
- GRAD: Gradients associated with parameters.
- ACT: Tensors produced during the forward pass and recomputation in activation checkpointing.
- TMP: Temporary memory used during the backward pass, including gradients of activations.
- OPT: Tensors holding optimizer states.
- OTH: Tensors registered via `track_external` that do not fit the above categories.
"""
PARAM = "Parameter"
BUFFER = "Buffer"
GRAD = "Gradient"
ACT = "Activation"
TEMP = "Temp"
OPT = "Optstate"
OTH = "Other"
class _ModState(_State):
"""
An enum to define the state of a module.
- PRE_FW: The module is about to run the forward pass.
- POST_FW: The module has finished running the forward pass.
- PEAK_FW: The module has reached the peak memory usage during the forward pass.
- PRE_BW: The module is about to run the backward pass.
- PRE_FW_AC: The module is about to run the forward pass with activation checkpointing.
- POST_FW_AC: The module has finished running the forward pass with activation checkpointing.
- POST_BW: The module has finished running the backward pass.
- PEAK_BW: The module has reached the peak memory usage during the backward pass.
"""
PRE_FW = "Pre-Forward"
POST_FW = "Post-Forward"
PEAK_FW = "Peak-Forward"
PRE_BW = "Pre-Backward"
PRE_FW_AC = "Pre-Forward-AC"
POST_FW_AC = "Post-Forward-AC"
POST_BW = "Post-Backward"
PEAK_BW = "Peak-Backward"
class _ModMemStats:
"""
A class to store the memory statistics of a module.
Args:
mod_fqn (str): The fully qualified name of the module.
Attributes:
mod_fqn (str): The fully qualified name of the module.
parameter_mem (int): The memory usage of the parameters of the module.
buffer_mem (int): The memory usage of the buffers of the module.
input_mem (int): The memory usage of the inputs to the module.
output_mem (int): The memory usage of the outputs from the module.
snapshots (Dict[_ModState, Dict[torch.device, Dict[str, int]]]): A dictionary of memory snapshots
of the module at different states defined by ``_ModState``.
Note:
The memory snapshot is stored as a dictionary - Dict[torch.device, Dict[str, int]], where each key is a device,
and each value is another dictionary with keys as memory reference types defined by `_MemRefType` and
values as the memory consumed in bytes.
"""
def __init__(self, mod_fqn: str):
self.mod_fqn = mod_fqn
self.parameter_mem: int
self.buffer_mem: int
self.input_mem: int
self.output_mem: int
self.local_peak: dict[torch.device, int] = {}
self.snapshots: dict[_ModState, list[dict[torch.device, dict[str, int]]]] = {}
class _WeakRefInfo:
"""
Manages memory statistics and device attributes for tensor storages.
"""
def __init__(
self, size: int, element_size: int, device: torch.device, reftype: _RefType
) -> None:
"""
Initializes the ``_WeakRefInfo`` object with tensor storage properties.
Args:
size (int): The number of elements in the tensor storage.
element_size (int): The size of each element in the tensor storage.
device (torch.device): The device on which the tensor is allocated.
reftype (_RefType): The reference type of the tensor.
"""
self.size = size
self.element_size = element_size
self.reftype = reftype
self.device = device
self.mem_consumed = self._calculate_mem_consumed()
def _calculate_mem_consumed(self) -> int:
"""
Calculates the memory consumed by the tensor storage, considering device-specific allocation rules.
Returns:
int: The memory consumed in bytes.
"""
mem = self.size * self.element_size
if self.device.type == "cuda":
return math.ceil((mem) / _PYTORCH_MIN_ALLOCATE) * _PYTORCH_MIN_ALLOCATE
return mem
def update_mem_consumed(self, st: torch.UntypedStorage) -> int:
"""
Updates and returns the memory consumed if the storage size has changed.
Args:
st (torch.UntypedStorage): The tensor storage to check for size updates.
Returns:
int: The updated memory consumed in bytes.
"""
if st.size() != self.size:
self.size = st.size()
self.mem_consumed = self._calculate_mem_consumed()
return self.mem_consumed
@classmethod
def create_winfo(
cls,
st: torch.UntypedStorage,
device: torch.device,
reftype: _RefType,
callback: Callable[[Self, weakref.ref], Any] | None = None,
) -> tuple[Self, weakref.ref]:
"""
Creates a new ``_WeakRefInfo`` instance and a weak reference to a ``torch.UntypedStorage`` object,
optionally attaching a callback to the weak reference.
Args:
st (torch.UntypedStorage): The storage object for which to create the weak reference info.
device (torch.device): The device associated with the storage object.
reftype (_RefType): The type of reference, used to categorize the storage.
callback (Optional[Callable[[Self, weakref.ref]]]): A callback function that is called when
the storage object is about to be finalized (garbage collected). The callback function
should accept two arguments: the ``_WeakRefInfo`` instance and the weak reference to the storage.
Returns:
Tuple[Self, weakref.ref]: A tuple containing the newly created ``_WeakRefInfo`` instance and the
weak reference to the storage object. The weak reference may have an attached callback if provided.
"""
winfo = cls(st.size(), st.element_size(), device, reftype)
w_st = weakref.ref(st, partial(callback, winfo) if callback else None)
return winfo, w_st
def _get_mem_divisor(units: str) -> int:
unit_dict = {"B": 1, "KiB": 2**10, "MiB": 2**20, "GiB": 2**30}
if units in unit_dict:
return unit_dict[units]
else:
raise ValueError(
f"Unsupported unit: {units}. Supported units are: {', '.join(unit_dict.keys())}"
)
def _rounding_fn(value: int, divisor: int, precision: int) -> float | int:
return value if divisor == 1 else round(value / divisor, precision)
def _print_snapshot(snapshot: dict[torch.device, dict[str, int]], units: str) -> None:
if len(snapshot) == 0:
print("No memory tracked.")
return
divisor = _get_mem_divisor(units)
for dev, dev_snap in snapshot.items():
if _rounding_fn(dev_snap[_TOTAL_KEY], divisor, 2) <= 0:
continue
print(
f"Device: {dev}",
*(
f"\t{k.value}: {_rounding_fn(v, divisor, 2)} {units}"
if isinstance(k, _RefType)
else f"\t{k}: {_rounding_fn(v, divisor, 2)} {units}"
for k, v in dev_snap.items()
),
sep="\n",
)
def _print_snapshot_tabular(
snapshot: dict[torch.device, dict[str, int]], units: str
) -> None:
if len(snapshot) == 0:
print("No memory tracked.")
return
try:
from tabulate import tabulate
except ImportError as err:
raise ImportError(
"Please install tabulate to use the tabulate option."
) from err
divisor = _get_mem_divisor(units)
table_data = []
key_list = list(next(iter(snapshot.values())).keys())
headers = ["Device"] + [
f"{key.value}" if isinstance(key, _RefType) else f"{key}" for key in key_list
]
for dev, dev_snap in snapshot.items():
if _rounding_fn(dev_snap[_TOTAL_KEY], divisor, 2) <= 0:
continue
row = [str(dev)]
row.extend(f"{_rounding_fn(v, divisor, 2)} {units}" for v in dev_snap.values())
table_data.append(row)
print(tabulate(table_data, headers=headers, tablefmt="rst"))
def _print_state_snapshots(
snapshots: dict[_State, list[dict[torch.device, dict[str, int]]]], units: str
) -> None:
for state, snapshot_list in snapshots.items():
print(f"{state.value}")
for i, snapshot in enumerate(snapshot_list):
print(f"# {i + 1}:")
_print_snapshot(snapshot, units)
print()
def _print_state_snapshots_tabular(
snapshots: dict[_State, list[dict[torch.device, dict[str, int]]]], units: str
) -> None:
try:
from tabulate import tabulate
except ImportError as err:
raise ImportError(
"Please install tabulate to use the tabulate option."
) from err
table_data = []
last_state_call = None
divisor = _get_mem_divisor(units)
for state, snapshot_list in snapshots.items():
for i, snapshot in enumerate(snapshot_list):
state_call = f"{state.value} # {i + 1}"
for dev, dev_snap in snapshot.items():
if _rounding_fn(dev_snap[_TOTAL_KEY], divisor, 2) <= 0:
continue
row = {
"State & Call": (
state_call if state_call != last_state_call else ""
),
"Device": str(dev),
}
last_state_call = state_call
for k, v in dev_snap.items():
row[f"{k.value}" if isinstance(k, _RefType) else f"{k}"] = (
f"{_rounding_fn(v, divisor, 2)} {units}"
)
table_data.append(row)
print(tabulate(table_data, headers="keys", tablefmt="rst"))
class _UpdateType(Enum):
# These are used for tracking updates to the continuouly maintained memory snapshot.
# ADD - When a new tensor storage is tracked
# DEL - When a tensor storage is about to be finalized (garbage collected).
# REF - When a tensor reference is updated, for instance, the gradients are marked as
# generic backward reference types until the grad_hook categorizes them as gradients.
# SIZE - When a tensor's storage is resized.
ADD = auto()
DEL = auto()
REF = auto()
SIZE = auto()
class MemTracker(TorchDispatchMode):
"""
A TorchDispatchMode to track, categorize and attribute the tensor memory created or accessed within its context.
It categorizes the tracked tensors as parameters, buffers, activations, gradients, temporary memory and optimizer states
as defined by ``_MemRefType`` within its context. It captures memory `snapshots` for the modules, called within its context,
at various states defined by ``_ModState``.
Attributes:
memory_tracking: A weakref key dictionary to store the memory statistics of each module. Each key
is a reference to a module, and each value is a ``_ModMemStats`` object that stores the memory
statistics of the module.
Note:
The MemTracker should be used as a context manager. The modules, optimizers, and any other tensors created within
the context of MemTracker will be tracked by default. Any tensors or stateful objects such as modules, optimizers etc.
that need to be tracked but are created outside the MemTracker should be registered using the `track_external` method.
The `track_external` method should be called before the MemTracker is used. Any tensors created outside the ``MemTracker``
and not supplied to the `track_external` method will not be tracked by the ``MemTracker``.
Example usage:
.. code-block:: python
module = ...
optimizer = ...
inp = ...
mem_tracker = MemTracker()
mem_tracker.track_external(module, optimizer, inp)
with mem_tracker as mt:
loss = module(inp)
print("After Forward:")
mt.display_snapshot("current")
loss.backward()
optimizer.step()
optimizer.zero_grad()
mt.display_snapshot("peak")
mt.display_modulewise_snapshots(depth=3, units="MiB")
Known Limitations:
- The ``MemTracker`` does not track memory for tensors that bypass the ``TorchDispatchMode`` ex. under ``no_dispatch``.
- Resizing tensor storages directly by using non-Tensor methods other than using ``torch.Untyped_Storage.resize_``
is not tracked. File a Github issue if you have use-cases for this.
- If the tensors are not traceable or wrappable subclasses of ``torch.Tensor``, then the tracker does not know how to
track their storages. File a Github issue if you have use-cases for this.
- During AC in the backward pass there might be misattribution between activation and temp memory, but the peak memory
will be tracked accurately. This will be fixed in the next update by hooking intricately with ``torch.uitls.checkpoint``.
"""
def __init__(self) -> None:
self.memory_tracking = WeakIdKeyDictionary()
self._curr_mem_snap: dict[torch.device, dict[str, int]] = {}
self._peak_mem: dict[torch.device, int] = {}
self._peak_mem_snap: dict[torch.device, dict[str, int]] = {}
self._param_to_grad_hook_handles = WeakIdKeyDictionary()
self._optimizer_hook_handles: tuple[RemovableHandle, RemovableHandle] | None = (
None
)
# Dictionary to store the ``_WeakRefInfo`` instances corresponding to each tensor's storage.
self._WINFO = WeakIdKeyDictionary()
self._mod_tracker = ModTracker()
# This is a general memory tracker which can be used with any ``_RefType`` subclass
self._ref_class: type[_RefType] = _MemRefType
# Flags to track if we are in the AC region or optimizer step region
self._in_opt: bool = False
self._in_ac: bool = False
# Weak references to the topmost AC module currently active
self._ac_mod: weakref.ref | None = None
self._orig_resize = torch.UntypedStorage.resize_
self._depth = 0
def _update_snap(
self,
u_type: _UpdateType,
winfo: _WeakRefInfo,
old_mem_consumed: int | None = None,
old_reftype: _RefType | None = None,
) -> None:
# Initialize a flag to track if the total memory might drop to zero after updates.
maybe_zero = False
# Ensure the device entry exists in the current memory snapshot, initializing if necessary.
# pyrefly: ignore [no-matching-overload]
dev_snap = self._curr_mem_snap.setdefault(
winfo.device, dict.fromkeys(self._ref_class, 0)
)
dev_snap.setdefault(_TOTAL_KEY, 0)
# Handle different types of updates based on the update type (`u_type`).
if u_type == _UpdateType.ADD:
# Increase the memory consumed for the specific reference type and update the total.
dev_snap[winfo.reftype] += winfo.mem_consumed
dev_snap[_TOTAL_KEY] += winfo.mem_consumed
elif u_type == _UpdateType.DEL:
# Decrease the memory consumed for the specific reference type and reduce the total.
dev_snap[winfo.reftype] -= winfo.mem_consumed
dev_snap[_TOTAL_KEY] -= winfo.mem_consumed
maybe_zero = True
elif u_type == _UpdateType.REF:
if old_reftype is None:
raise AssertionError
# Adjust memory consumption between two reference types within the same device.
dev_snap[old_reftype] -= winfo.mem_consumed
dev_snap[winfo.reftype] += winfo.mem_consumed
elif u_type == _UpdateType.SIZE:
if old_mem_consumed is None:
raise AssertionError
# Adjust the memory consumed for a reference type due to a change in size.
change = winfo.mem_consumed - old_mem_consumed
dev_snap[winfo.reftype] += change
dev_snap[_TOTAL_KEY] += change
maybe_zero = True
else:
raise ValueError(f"Invalid update type: {u_type}")
# Check if the total memory for the device has dropped to zero.
if maybe_zero:
if self._curr_mem_snap[winfo.device][_TOTAL_KEY] == 0:
# Remove the device entry from the memory snapshot if the total memory is zero.
del self._curr_mem_snap[winfo.device]
def _update_and_maybe_create_winfos(
self,
t: torch.Tensor,
reftype: _RefType,
update_existing: bool = False,
) -> set[_WeakRefInfo]:
sts = get_untyped_storages(t)
winfos = set()
for st in sts:
# Attempt to retrieve existing ``_WeakRefInfo`` and its weak reference from the tracking dictionary.
winfo, _ = self._WINFO.get(st, (None, None))
if winfo is not None:
# If ``_WeakRefInfo`` exists, check if the reference type needs to be updated.
old_reftype = winfo.reftype
if old_reftype != reftype:
# Update the reference type and apply changes via ``_update_snap``.
winfo.reftype = reftype
self._update_snap(_UpdateType.REF, winfo, old_reftype=old_reftype)
winfos.add(winfo)
elif update_existing:
# If no existing ``_WeakRefInfo`` is found and update_existing is True, raise an error.
raise KeyError("No existing winfo found")
else:
# If no existing _WeakRefInfo is found and update_existing is False, create a new ``_WeakRefInfo``.
winfo, w_st = _WeakRefInfo.create_winfo(
st, t.device, reftype, self._delete_callback
)
# Store the new ``_WeakRefInfo`` and its weak reference in the tracking dictionary.
self._WINFO[st] = (winfo, w_st)
# Update the snapshot for the newly added ``_WeakRefInfo``.
if winfo.mem_consumed > 0:
self._update_snap(_UpdateType.ADD, winfo)
winfos.add(winfo)
return winfos
def _delete_callback(self, winfo: _WeakRefInfo, w_st: weakref.ref) -> None:
# Callback to be called when the storage object corresponding to the ``_WeakRefInfo``
# instance is about to be finalized.
if winfo.mem_consumed > 0:
self._update_snap(_UpdateType.DEL, winfo)
def _track_resize(self) -> None:
# Need to monkey-patch this because ``torch.UntypedStorage.resize_`` is not captured
# by ``TorchDispatchMode``.
@wraps(self._orig_resize)
def resize_(st: torch.UntypedStorage, size: int) -> None:
self._orig_resize(st, size)
winfo, _ = self._WINFO.get(st, (None, None))
if winfo is not None and winfo.size != st.size():
old_mem_consumed = winfo.mem_consumed
winfo.update_mem_consumed(st)
self._update_snap(
_UpdateType.SIZE, winfo, old_mem_consumed=old_mem_consumed
)
torch.UntypedStorage.resize_ = resize_ # type: ignore[method-assign, assignment]
def _restore_resize(self) -> None:
torch.UntypedStorage.resize_ = self._orig_resize # type: ignore[method-assign]
def _update_peak_stats(self, peak_state: _State) -> None:
# We first capture the current memory snapshot of the current tracker state then,
# We step through each of the modules we have tracked so far in ``memory_tracking``
# and check if it is currently active by querying ``_mod_tracker.parents``
# If it is active, we update the per device peak memory usage for the module
# corresponding to the ``_State`` which can be ``PEAK_FW`` or ``PEAK_BW``.
curr_snap = self._curr_mem_snap
for mod_stats in self.memory_tracking.values():
if mod_stats.mod_fqn in self._mod_tracker.parents:
if peak_state in mod_stats.snapshots:
for dev, dev_snap in curr_snap.items():
if mod_stats.local_peak.get(dev, 0) < dev_snap[_TOTAL_KEY]:
mod_stats.local_peak[dev] = dev_snap[_TOTAL_KEY]
mod_stats.snapshots[peak_state][-1][dev] = deepcopy(
dev_snap
)
for dev, dev_snap in curr_snap.items():
if self._peak_mem.get(dev, 0) < dev_snap[_TOTAL_KEY]:
self._peak_mem[dev] = dev_snap[_TOTAL_KEY]
self._peak_mem_snap[dev] = deepcopy(dev_snap)
def _track(self, reftype: _RefType, t: torch.Tensor) -> None:
# Get the storages of the tensor and check if we have already tracked them.
# If yes, then check if the storage size has changed and update the current snapshot.
# Else create a new ``_WeakRefInfo`` instance and add it to the dictionary.
sts = get_untyped_storages(t)
for st in sts:
winfo, _ = self._WINFO.get(st, (None, None))
if winfo is not None:
if winfo.size != st.size():
old_mem_consumed = winfo.mem_consumed
winfo.update_mem_consumed(st)
self._update_snap(
_UpdateType.SIZE, winfo, old_mem_consumed=old_mem_consumed
)
return
else:
winfo, w_st = _WeakRefInfo.create_winfo(
st, t.device, reftype, self._delete_callback
)
self._WINFO[st] = (winfo, w_st)
# Update the current snapshot for the newly added ``_WeakRefInfo``.
if winfo.mem_consumed > 0:
self._update_snap(_UpdateType.ADD, winfo)
def get_tracker_snapshot(
self, type: str = "current"
) -> dict[torch.device, dict[str, int]]:
"""
Capture a snapshot of the memory usage breakdown per device, based on the specified type.
Args:
type (str): The type of snapshot to capture. Can be "current" for the current memory usage or "peak" for the
peak memory usage. Defaults to "current".
Returns:
Dict[torch.device, Dict[str, int]]: A dictionary where each key is a torch.device, and each value is another
dictionary. This inner dictionary has keys representing memory reference
types as defined in ``_MemRefType`` and values representing the amount of
memory consumed in bytes.
Raises:
ValueError: If an invalid type is specified.
"""
if type == "current":
return deepcopy(self._curr_mem_snap)
elif type == "peak":
return deepcopy(self._peak_mem_snap)
else:
raise ValueError(f"Invalid type {type}")
def _track_module_params_and_buffers(
self, module: nn.Module, install_grad_hooks: bool = True
) -> tuple[int, int]:
# Track the parameters and buffers of the module if not already tracked.
# If the parameters have gradients, track the gradients as well.
# If install_grad_hooks is True, install a gradient hook on the parameters
# to track the gradients, if it has not already been installed.
# Return the total memory consumed by the parameters and buffers.
def _grad_hook(grad: torch.Tensor) -> None:
self._update_and_maybe_create_winfos(
grad,
_MemRefType.GRAD,
)
param_memory = 0
for param in module.parameters():
winfos = self._update_and_maybe_create_winfos(
param,
_MemRefType.PARAM,
)
param_memory += sum(winfo.mem_consumed for winfo in winfos)
if param.grad is not None:
self._update_and_maybe_create_winfos(
param.grad,
_MemRefType.GRAD,
)
if (
self._param_to_grad_hook_handles.get(param, None) is None
and install_grad_hooks
):
grad_hook_handle = param.register_hook(_grad_hook)
post_acc_grad_hook_handle = param.register_post_accumulate_grad_hook(
lambda p: (_grad_hook(p.grad))
)
self._param_to_grad_hook_handles[param] = (
grad_hook_handle,
post_acc_grad_hook_handle,
)
buffer_memory = 0
for buffer in module.buffers():
winfos = self._update_and_maybe_create_winfos(
buffer,
_MemRefType.BUFFER,
)
buffer_memory += sum(winfo.mem_consumed for winfo in winfos)
return (param_memory, buffer_memory)
def _track_inputs_or_outputs(self, args: Any) -> int:
# Calculate the memory consumed by the inputs or outputs of the module.
input_or_output_memory = 0
def add_inps_or_outs(t: torch.Tensor) -> None:
nonlocal input_or_output_memory
sts = get_untyped_storages(t)
for st in sts:
winfo, _ = self._WINFO.get(st, (None, None))
if winfo is not None:
input_or_output_memory += winfo.mem_consumed
tree_map_only(torch.Tensor, add_inps_or_outs, args)
return input_or_output_memory
def _pre_fw_hook(self, module: nn.Module, inputs: Any) -> None:
# This is installed as a pre-fwd user hook with ``ModTracker.`` Based on the following cases we
# set the state and capture the memory snapshot for the module.
# Case 1: If the module is not in the ``memory_tracking`` dictionary, we track the parameters, buffers,
# input and output memory of the module. Create a new ``_ModMemStats`` instance for the module
# and add it to the ``memory_tracking`` dictionary.
# Case 2: If the module is already in the ``memory_tracking`` dictionary and we are in backward, this means
# we are in the AC region. We check if this is the top most module in the AC region. If it is,
# we store a weak reference and set the flag ``_in_ac`` to True.
# Case 3: If the module is already in the ``memory_tracking`` dictionary and we are in forward, this means
# this module is called for the second time. If it is a root module, that means we are in the next
# iteration and we error out. If it is not a root module, that means it's a submodule that is being
# used multiple times in the same iteration, which we allow and track.
# For Case 1 and 3, we also initialize the ``local_peak`` and ``PEAK_FW`` snapshot for the module.
mod_name = self._mod_tracker.get_known_fqn(module)
if mod_name is None:
raise AssertionError
if module not in self.memory_tracking:
mod_stats = _ModMemStats(mod_name)
param_mem, buffer_mem = self._track_module_params_and_buffers(
module, install_grad_hooks=True
)
input_mem = self._track_inputs_or_outputs(inputs)
mod_stats.parameter_mem = param_mem
mod_stats.buffer_mem = buffer_mem
mod_stats.input_mem = input_mem
self.memory_tracking[module] = mod_stats
state = _ModState.PRE_FW
elif self._mod_tracker.is_bw:
mod_stats = self.memory_tracking[module]
state = _ModState.PRE_FW_AC
if self._ac_mod is None:
self._ac_mod = weakref.ref(module)
self._in_ac = True
else:
parents = set(self._mod_tracker.parents) - {mod_name}
if len(parents) == 1 and "Global" in parents:
raise NotImplementedError(
"MemTracker does not support memory tracking for multiple iterative calls."
" Either use ``reset_mod_stats`` to clear module memory stats for the previous iteration"
" or file a github issue if you need this feature."
)
mod_stats = self.memory_tracking[module]
state = _ModState.PRE_FW
input_mem = self._track_inputs_or_outputs(inputs)
mod_stats.mod_fqn = mod_name
mod_stats.input_mem = input_mem
mem_snapshot = self.get_tracker_snapshot()
if state == _ModState.PRE_FW:
mod_stats.local_peak = {
dev: dev_snap[_TOTAL_KEY] for dev, dev_snap in mem_snapshot.items()
}
mod_stats.snapshots.setdefault(_ModState.PEAK_FW, []).append(mem_snapshot)
mod_stats.snapshots.setdefault(state, []).append(deepcopy(mem_snapshot))
def _post_fw_hook(self, module: nn.Module, inputs: Any, outputs: Any) -> None:
# This is installed as a post-fwd user hook with ``ModTracker``. Based on the following cases we
# set the state and capture the memory snapshot for the module.
# Case 1: This is called in backward, which means we are in the AC region. If this is the top most module
# in the AC region, we set the flag ``_in_ac`` to False.
# Case 2: This is called in forward so we calculate the output memory
# of the module and update its mod_stats.
mod_stats = self.memory_tracking[module]
if self._mod_tracker.is_bw:
state = _ModState.POST_FW_AC
if self._ac_mod is not None and self._ac_mod() is module:
self._ac_mod = None
self._in_ac = False
else:
state = _ModState.POST_FW
output_mem = self._track_inputs_or_outputs(outputs)
mod_stats.output_mem = output_mem
mod_stats.snapshots.setdefault(state, []).append(self.get_tracker_snapshot())
def _pre_bw_hook(self, module: nn.Module, args: Any) -> None:
# This is installed as a pre-bwd user hook with ``ModTracker``. We set the state and capture the
# snapshot for the module. We also initialize the ``local_peak`` and ``PEAK_BW`` snapshot for it.
# If the module is None, we skip the hook.
# This can happen since this installed inside a multi-grad hook on the module's output tensors
# and the module itself may not be alive during backward.
if module is None:
warnings.warn("Module is None. Skipping PRE_BW hook.", stacklevel=2)
return
mod_stats = self.memory_tracking[module]
mem_snapshot = self.get_tracker_snapshot()
mod_stats.local_peak = {
dev: dev_snap[_TOTAL_KEY] for dev, dev_snap in mem_snapshot.items()
}
mod_stats.snapshots.setdefault(_ModState.PEAK_BW, []).append(mem_snapshot)
mod_stats.snapshots.setdefault(_ModState.PRE_BW, []).append(
deepcopy(mem_snapshot)
)
def _post_bw_hook(self, module: nn.Module, args: Any) -> None:
# This is installed as a post-bwd user hook with ``ModTracker``. We set the state and capture the
# snapshot for the module if it is not None.
# This can happen since this installed inside a multi-grad hook on the module's input tensors
# and the module itself may not be alive during backward.
if module is None:
warnings.warn("Module is None. Skipping POST_BW hook.", stacklevel=2)
return
mod_stats = self.memory_tracking[module]
mod_stats.snapshots.setdefault(_ModState.POST_BW, []).append(
self.get_tracker_snapshot()
)
def _track_optimizer_states(
self, reftype: _RefType, optimizer: optim.Optimizer
) -> None:
for states in optimizer.state.values():
for val in states.values():
if isinstance(val, torch.Tensor):
self._update_and_maybe_create_winfos(
val,
reftype,
)
def _register_global_optimizer_hook(self) -> None:
# Register a hook on the optimizer step to track the optimizer states.
# The pre-hook is to set the flag ``_in_opt`` to True. The post-hook unsets the flag,
# and also tracks any optimizer states that are created during the optimizer step.
def _opt_step_pre_hook(
optimizer: optim.Optimizer, args: Any, kwargs: Any
) -> None:
self._in_opt = True
def _opt_step_post_hook(
optimizer: optim.Optimizer, args: Any, kwargs: Any
) -> None:
self._track_optimizer_states(_MemRefType.OPT, optimizer)
self._in_opt = False
self._optimizer_hook_handles = (
register_optimizer_step_pre_hook(_opt_step_pre_hook),
register_optimizer_step_post_hook(_opt_step_post_hook),
)
def _deregister_param_and_optimizer_hooks(self) -> None:
for (
grad_hook_handle,
post_acc_grad_hook_handle,
) in self._param_to_grad_hook_handles.values():
grad_hook_handle.remove()
post_acc_grad_hook_handle.remove()
self._param_to_grad_hook_handles.clear()
if self._optimizer_hook_handles is not None:
for handle in self._optimizer_hook_handles:
handle.remove()
self._optimizer_hook_handles = None
def track_external(
self, *external: nn.Module | optim.Optimizer | torch.Tensor
) -> None:
"""
Track tensors and stateful objects like modules, optimizers etc. that are created outside the MemTracker.
This method should be called before the ``MemTracker`` is used. Any tensors that are not module parameters, buffers,
gradients activations, or optimizer states will be categorized as ``Other``. If you want them categorized with a
custom name, please file a GitHub issue. Any tensors created outside the MemTracker and not supplied to this
method will not be be tracked by ``MemTracker``.
Args:
*external (Union[nn.Module, optim.Optimizer, torch.Tensor]): The external modules, optimizers, and
tensors to be tracked.
"""
flat_external, _ = tree_flatten(external)
for obj in flat_external:
if isinstance(obj, torch.Tensor):
self._update_and_maybe_create_winfos(
obj,
_MemRefType.OTH,
)
elif isinstance(obj, torch.nn.Module):
self._track_module_params_and_buffers(obj, install_grad_hooks=False)
elif isinstance(obj, optim.Optimizer):
self._track_optimizer_states(_MemRefType.OPT, obj)
elif obj is None:
continue
else:
raise TypeError(
f"Object of type {type(obj)} is not supported for tracking. "
f"Only stateful objects like modules, optimizers, and tensors are supported."
)
def display_snapshot(
self, type: str = "current", units: str = "B", tabulate: bool = False
) -> None:
"""
Display the memory usage breakdown snapshot of the tracker based on the specified type and units.
Keyword args:
type (str): The type of snapshot to display. Can be "current" for the current memory usage or "peak" for the
peak memory usage. Defaults to "current".
units (str): The units to use for displaying memory usage. Defaults to "B". Supports ["B", "KiB", "MiB", "GiB"].
tabulate (bool): Whether to display the snapshot in a tabular format. Defaults to False.
"""
snapshot = self.get_tracker_snapshot(type)
if tabulate:
_print_snapshot_tabular(snapshot, units)
else:
_print_snapshot(snapshot, units)
def display_modulewise_snapshots(
self, depth: int = 2, units: str = "B", tabulate: bool = False
) -> None:
"""
Print per device memory breakdown snapshot for each module called within MemTracker.
Snapshots are displayed for the states defined by ``_ModState``.
The module hierarchy is displayed up to the specified depth.
Keyword Args:
depth (int, optional): The depth of the module hierarchy to display. Defaults to 2.
units (str, optional): The units to use for memory tracking. Defaults to "B". Supports ["B", "KiB", "MiB", "GiB"].
tabulate (bool, optional): Whether to display the snapshot in a tabular format. Defaults to False.
"""
def natural_sort_key(s: str) -> list[int | str]:
return [
int(text) if text.isdigit() else text.lower()
for text in re.split("([0-9]+)", s)
]
for mod_stats in sorted(
self.memory_tracking.values(),
key=lambda m_stats: natural_sort_key(m_stats.mod_fqn),
):
mod_fqn = mod_stats.mod_fqn
mod_depth = mod_fqn.count(".") + 1
if mod_depth > depth:
continue
print(f"Module: {mod_fqn}")
if tabulate:
_print_state_snapshots_tabular(mod_stats.snapshots, units)
else:
_print_state_snapshots(mod_stats.snapshots, units)
def reset_mod_stats(self) -> None:
"""
Reset all the module memory stats. Clears ``memory_tracking`` dictionary.
"""
self.memory_tracking.clear()
def __enter__(self) -> "MemTracker":
if self._depth == 0:
self._register_global_optimizer_hook()
self._mod_tracker.register_user_hooks(
self._pre_fw_hook,
self._post_fw_hook,
self._pre_bw_hook,
self._post_bw_hook,
)
self._track_resize()
self._peak_mem_snap = self.get_tracker_snapshot()
self._peak_mem = {
dev: dev_snap[_TOTAL_KEY]
for dev, dev_snap in self._peak_mem_snap.items()
}
self._mod_tracker.__enter__()
super().__enter__()
self._depth += 1
return self
# pyrefly: ignore [bad-override]
def __exit__(self, *args: Any) -> None:
self._depth -= 1
if self._depth == 0:
self._deregister_param_and_optimizer_hooks()
self._mod_tracker.clear_user_hooks()
self._restore_resize()
self._mod_tracker.__exit__(*args)
super().__exit__(*args)
def __torch_dispatch__(self, func, types, args=(), kwargs=None): # type: ignore[no-untyped-def]
# 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 local tensor ops, before `MemTracker` sees them.
if any(t == DTensor for t in types):
return NotImplemented
if (
func is torch.ops._c10d_functional.wait_tensor.default
and active_fake_mode()
):
# N.B: This is a hacky way to override the Meta IMPL of wait_tensor. The original impl returns
# a new tensor which does not happen in eager mode, when a wait_tensor is called.
# pyrefly: ignore [bad-index]
res = args[0]
else:
res = func(*args, **kwargs or {})
# If we are tracking an optimizer state, we use the optimizer reference type.
# If we are in backward region and not in AC region, we use the backward reference type.
# Else we use the forward reference type.
if self._in_opt:
reftype = _MemRefType.OPT
elif self._mod_tracker.is_bw and not self._in_ac:
reftype = _MemRefType.TEMP
else:
reftype = _MemRefType.ACT
tree_map_only(torch.Tensor, partial(self._track, reftype), res)
peak_state = _ModState.PEAK_BW if self._mod_tracker.is_bw else _ModState.PEAK_FW
self._update_peak_stats(peak_state)
return res
@@ -0,0 +1,302 @@
# mypy: allow-untyped-defs
import operator
import pickle
from collections import defaultdict
from collections.abc import Callable, Sequence
from itertools import chain
from typing import Any, no_type_check, TYPE_CHECKING
import torch
import torch.nn as nn
from torch.utils._python_dispatch import TorchDispatchMode
if TYPE_CHECKING:
from torch.utils.hooks import RemovableHandle
BYTES_PER_MB = 1024 * 1024.0
class MemoryProfileDispatchMode(TorchDispatchMode):
"""Run in ``TorchDispatchMode`` to get memory stats at operator level."""
def __init__(self, memory_tracker) -> None:
self.memory_tracker = memory_tracker
def __torch_dispatch__(self, func, types, args=..., kwargs=None):
rs = func(*args, **kwargs)
if func is torch.ops.aten.detach.default:
return rs
func_name: str = (
self.memory_tracker._cur_module_name
+ "."
+ func.__name__
+ "_"
+ str(self.memory_tracker._operator_names[func.__name__])
)
self.memory_tracker._operator_names[func.__name__] = (
self.memory_tracker._operator_names[func.__name__] + 1
)
self.memory_tracker._record_memory_stats(func_name)
return rs
class MemoryTracker:
"""
Collect and plot the memory stats at operator level.
Includes ``memories_allocated``, ``memories_active`` and ``memories_reserved``.
It also prints a summary for the top 20 operators that generate the most memories.
Example usage:
>>> # xdoctest: +SKIP(failing)
>>> net.cuda()
>>> input = input.cuda()
>>> mem_tracker = MemoryTracker()
>>> mem_tracker.start_monitor(net)
>>> net.zero_grad(True)
>>> loss = net(input)
>>> if isinstance(loss, dict):
>>> loss = loss['out']
>>> loss.sum().backward()
>>> net.zero_grad(set_to_none=True)
>>> mem_tracker.stop()
>>> mem_tracker.summary()
>>> mem_tracker.show_traces()
"""
def __init__(self) -> None:
torch._C._log_api_usage_once("torch.distributed.memory_tracker")
self._hooks: list[RemovableHandle] = []
self._operator_names: dict[str, int] = defaultdict(int)
self.memories_allocated: dict[int, dict[str, float]] = defaultdict()
self.memories_active: dict[int, dict[str, float]] = defaultdict()
self.memories_reserved: dict[int, dict[str, float]] = defaultdict()
self._markers: dict[str, int] = defaultdict(int)
self._cur_module_name: str = ""
self._op_index: int = 0
self._num_alloc_retries: int = 0
self._device_module = torch.get_device_module()
@no_type_check
def start_monitor(self, root_module: nn.Module) -> None:
"""
Register module hooks and entering ``MemoryProfileDispatchMode``.
This enables operator level memory stats can be tracked during module runtime.
"""
self._clear_state()
root_module.__setattr__("_memory_tracker_is_root", True)
for name, m in root_module.named_modules():
if m is not root_module:
m.__setattr__("_memory_tracker_is_root", False)
# fused_proxy_group does not support hooks
if ".fused_proxy_grouped_embedding_bag" in name:
continue
# hook ordering with other hooks added by users is not managed, so
# the memory stats tracked here may not completely accurate.
h1 = m.register_forward_pre_hook(self._create_pre_forward_hook(name))
h2 = m.register_forward_hook(self._create_post_forward_hook(name))
# it does not work well with jagged tensor somehow, the root cause is not
# clear and remove it for now as it does not really capture important info.
# h3 = m.register_backward_hook(self._create_backward_hook(name))
self._hooks.extend([h1, h2])
self._device_module.empty_cache()
if getattr(self, "profile_mode", None) is not None:
raise AssertionError
self.profile_mode = MemoryProfileDispatchMode(self)
self.profile_mode.__enter__()
@no_type_check
def stop(self) -> None:
"""
Remove module hooks and exit ``MemoryProfileDispatchMode`` to stop tracking memory stats at operator level.
Get some aggregated stats when the memory_tracker() is enabled, like ``num_alloc_retries``.
"""
self._num_alloc_retries = self._device_module.memory_stats().get(
"num_alloc_retries", 0
)
for h in self._hooks:
h.remove()
self._hooks.clear()
if getattr(self, "profile_mode", None) is None:
raise AssertionError
self.profile_mode.__exit__(None, None, None)
self.profile_mode = None
@no_type_check
def summary(self, top: int = 20) -> None:
"""
Print out the top operators that generate the most memories.
The number of the top operators can be configured.
"""
op_diff: dict[str, float] = defaultdict(float)
op_name, previous_allocated_memory = self.memories_allocated[0]
for i in range(1, self._op_index):
op_name, current_allocated_memory = self.memories_allocated[i]
op_diff[op_name] = current_allocated_memory - previous_allocated_memory
previous_allocated_memory = current_allocated_memory
print("------------------------------------------------")
print(f"The number of alloc retries are: {self._num_alloc_retries}")
print(f"Top {top} ops that generates memory are:")
for k, v in sorted(op_diff.items(), key=operator.itemgetter(1), reverse=True)[
:top
]:
print(f"{k}: {v}MB")
print("------------------------------------------------")
@no_type_check
def show_traces(self, path: str = "") -> None:
import matplotlib.pyplot as plt
def _plot_figure(x, y_values, labels):
min_val = min(chain.from_iterable(y_values)) * 0.999
max_val = max(chain.from_iterable(y_values)) * 1.001
plt.figure()
for y, label in zip(y_values, labels):
plt.plot(x, y, label=label)
plt.xlabel("# Operator Calls")
plt.ylabel("Memory (MB)")
plt.legend()
for marker_name, marker in self._markers.items():
if marker_name == "fw_bw_boundary":
plt.plot(
[marker, marker],
[min_val, max_val],
"r",
lw=2,
label=marker_name,
)
else:
plt.plot(
[marker, marker],
[min_val, max_val],
"k-",
lw=2,
label=marker_name,
)
if path != "":
self.load(path)
y_1 = [gb for (name, gb) in self.memories_allocated.values()]
y_2 = [gb for (name, gb) in self.memories_active.values()]
y_3 = [gb for (name, gb) in self.memories_reserved.values()]
x = list(range(len(y_1)))
# Split figures when there is big difference between
# "reserved_memory" and "allocated_memory" or "active_memory".
_plot_figure(
x,
[list(y_1), list(y_2), list(y_3)],
["allocated_memory", "active_memory", "reserved_memory"],
)
_plot_figure(x, [list(y_1)], ["allocated_memory"])
_plot_figure(x, [list(y_2)], ["active_memory"])
_plot_figure(x, [list(y_3)], ["reserved_memory"])
def save_stats(self, path: str) -> None:
"""Save the stats using pickle during runtime if users want to plot the traces in other places like notebook."""
stats = {
"memories_allocated": self.memories_allocated,
"memories_active": self.memories_active,
"memories_reserved": self.memories_reserved,
"markers": self._markers,
"num_alloc_retries": self._num_alloc_retries,
}
with open(path, "wb") as f:
pickle.dump(stats, f, pickle.HIGHEST_PROTOCOL)
def load(self, path: str) -> None:
"""Load the pickled memory stats to plot the traces or print the summary."""
with open(path, "rb") as f:
stats = pickle.load(f)
self.memories_allocated = stats["memories_allocated"]
self.memories_active = stats["memories_active"]
self.memories_reserved = stats["memories_reserved"]
self._markers = stats["markers"]
self._num_alloc_retries = stats["num_alloc_retries"]
def _create_pre_forward_hook(self, name: str) -> Callable:
"""Prefix operator name with current module and 'forward', and insert 'fw_start' marker at forward pass start."""
def _pre_forward_hook(module: nn.Module, inputs: Any) -> None:
self._cur_module_name = f"{name}.forward"
if (
hasattr(module, "_memory_tracker_is_root")
and module._memory_tracker_is_root
):
self._add_marker("fw_start")
return _pre_forward_hook
def _create_post_forward_hook(self, name: str) -> Callable:
"""Insert the marker 'fw_bw_boundary' at the boundary of forward and backward pass."""
def _post_forward_hook(
module: nn.Module,
inputs: Sequence[torch.Tensor],
outputs: Sequence[torch.Tensor],
) -> None:
if (
hasattr(module, "_memory_tracker_is_root")
and module._memory_tracker_is_root
):
self._add_marker("fw_bw_boundary")
return _post_forward_hook
def _create_backward_hook(self, name: str) -> Callable:
"""Insert the current module name with backward prefix for the operator name."""
def _backward_hook(
module: nn.Module, grad_input: torch.Tensor, grad_output: torch.Tensor
) -> None:
self._cur_module_name = f"{name}.backward"
return _backward_hook
@no_type_check
def _record_memory_stats(self, fn_name: str) -> None:
"""
Record current memory allocated, current memory active and current memory reserved.
The memory stats dict is indexed with ``self._op_index``.
"""
memory_allocated: float = self._device_module.memory_allocated() / BYTES_PER_MB
memory_reserved: float = self._device_module.memory_reserved() / BYTES_PER_MB
memory_active: float = (
self._device_module.memory_stats().get("active_bytes.all.current", 0)
/ BYTES_PER_MB
)
self.memories_allocated[self._op_index] = (fn_name, memory_allocated)
self.memories_reserved[self._op_index] = (fn_name, memory_reserved)
self.memories_active[self._op_index] = (fn_name, memory_active)
self._op_index += 1
def _add_marker(self, marker_name: str) -> None:
"""Set the marker's x-axis value."""
marker_val = len(self.memories_allocated.values())
self._markers[marker_name] = marker_val
def _clear_state(self) -> None:
"""Clear states when start_monitor() is called."""
self._operator_names.clear()
self.memories_allocated.clear()
self.memories_active.clear()
self.memories_reserved.clear()
self._markers.clear()
self._cur_module_name = ""
self._op_index = 0
self._num_alloc_retries = 0
@@ -0,0 +1,259 @@
# mypy: allow-untyped-defs
import warnings
import weakref
from collections.abc import Callable
import torch
from torch.autograd.graph import register_multi_grad_hook
from torch.nn.modules.module import (
register_module_forward_hook,
register_module_forward_pre_hook,
)
from torch.utils._pytree import tree_flatten
__all__ = ["ModTracker"]
class ModTracker:
"""
``ModTracker`` is a context manager that tracks the nn.Module hierarchy during execution
so that other system can query which Module is currently being executed (or its backward is being
executed).
You can access the ``parents`` attribute on this context manager to get the set of all the
Modules currently being executed via their fqn (fully qualified name, also used as the key within
the state_dict).
You can access the ``is_bw`` attribute to know if you are currently running in backward or not.
Note that ``parents`` is never empty and always contains the "Global" key. The ``is_bw`` flag
will remain ``True`` after the forward until another Module is executed. If you need it to be
more accurate, please submit an issue requesting this. Adding a map from fqn to the module instance
is possible but not done yet, please submit an issue requesting this if you need it.
Example usage
.. code-block:: python
mod = torch.nn.Linear(2, 2)
with ModTracker() as tracker:
# Access anything during the forward pass
def my_linear(m1, m2, bias):
print(f"Current modules: {tracker.parents}")
return torch.mm(m1, m2.t()) + bias
torch.nn.functional.linear = my_linear
mod(torch.rand(2, 2))
"""
parents: set[str]
"""
A Set containing the fqn for each module currently running their forward
"""
def __init__(self):
self.parents = {"Global"}
self._active_module_cnt = {}
self._known_modules: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
self._seen_modules: weakref.WeakSet = weakref.WeakSet()
self._has_callback = False
self._post_bw_callbacks_to_enqueue: list[Callable] = []
self._user_pre_fw_hook = None
self._user_post_fw_hook = None
self._user_pre_bw_hook = None
self._user_post_bw_hook = None
def _maybe_set_engine_callback(self):
# This assumes no concurrent calls to backward
if self._has_callback:
return
for post_bw_callback in reversed(self._post_bw_callbacks_to_enqueue):
torch.autograd.Variable._execution_engine.queue_callback(post_bw_callback)
self._post_bw_callbacks_to_enqueue.clear()
def callback():
self.parents = {"Global"}
self._has_callback = False
torch.autograd.Variable._execution_engine.queue_callback(callback)
self._has_callback = True
@property
def is_bw(self):
"""
A boolean marking if this is currently running during the backward pass or not
"""
return torch._C._current_graph_task_id() != -1
def get_known_fqn(self, mod):
"""
Return the fqn for the given module if it is known to the ``ModTracker``, otherwise ``None``.
"""
return self._known_modules.get(mod, None)
def register_user_hooks(
self,
pre_fw_hook: Callable | None = None,
post_fw_hook: Callable | None = None,
pre_bw_hook: Callable | None = None,
post_bw_hook: Callable | None = None,
):
"""
Registers user-specified hooks to be called before/after the forward/backward pass for each
module tracked by the ``ModTracker``. One or more can be ``None``.
Args:
pre_fw_hook (Callable, optional): A hook to be called before the forward pass for the
module. It should have the following signature:
pre_fw_hook (module, input) -> None
post_fw_hook (Callable, optional): A hook to be called after the forward pass for the
module. It should have the following signature:
post_fw_hook (module, input, output) -> None
pre_bw_hook (Callable, optional): A multi-grad hook to be called on all the outputs of
the module that require gradients. It should have the following signature:
pre_bw_hook (module, grad_output) -> None
post_bw_hook (Callable, optional): A multi-grad hook to be called on all the inputs of
the module that require gradients. It should have the following signature:
post_bw_hook (module, grad_input) -> None
Raises:
AssertionError: If a new hook is provided when one is already registered.
Note:
If the module is not alive during the backward pass, the pre_bw_hook and post_bw_hook will
will receive None as the module argument.
The module fqn will be present in the ``parents`` attribute when each of the hooks is called.
Hooks are intended to be used as markers only not to modify the inputs/outputs.
"""
def set_hook(hook, user_hook, hook_name):
if hook is not None and user_hook is not None:
raise AssertionError(
f"Only one {hook_name} can be registered at a time"
f" Clear the existing hook by calling ``clear_user_hooks`` before registering a new one"
)
return hook
self._user_pre_fw_hook = set_hook(
pre_fw_hook, self._user_pre_fw_hook, "pre_fw_hook"
)
self._user_post_fw_hook = set_hook(
post_fw_hook, self._user_post_fw_hook, "post_fw_hook"
)
self._user_pre_bw_hook = set_hook(
pre_bw_hook, self._user_pre_bw_hook, "pre_bw_hook"
)
self._user_post_bw_hook = set_hook(
post_bw_hook, self._user_post_bw_hook, "post_bw_hook"
)
def clear_user_hooks(self):
"""
Clears the user specified hooks registered with ``register_user_hooks``
"""
self._user_pre_fw_hook = None
self._user_post_fw_hook = None
self._user_pre_bw_hook = None
self._user_post_bw_hook = None
def _get_mod_name(self, mod):
if mod not in self._known_modules:
self._known_modules[mod] = type(mod).__name__
mod_name = self._known_modules[mod]
if mod not in self._seen_modules:
for name, submod in mod.named_children():
self._known_modules[submod] = f"{mod_name}.{name}"
self._get_mod_name(submod)
self._seen_modules.add(mod)
return mod_name
def _get_append_fn(self, w_mod, name, is_bw):
def fn(*args):
if is_bw:
self._maybe_set_engine_callback()
if name in self.parents and not self.is_bw:
def custom_formatwarning(msg, category, filename, lineno, line=None):
return f"{filename}:{lineno}: {category.__name__}: {msg} \n"
# pyrefly: ignore [bad-assignment]
warnings.formatwarning = custom_formatwarning
warnings.warn(
"The module hierarchy tracking maybe be messed up."
" Please file a bug to PyTorch, if it is the case.",
stacklevel=2,
)
if name not in self.parents:
self._active_module_cnt[name] = 1
self.parents.add(name)
else:
self._active_module_cnt[name] += 1
if self._user_pre_bw_hook is not None and is_bw:
self._user_pre_bw_hook(w_mod(), args)
return fn
def _get_pop_fn(self, w_mod, name, is_bw):
def fn(*args):
if self._user_post_bw_hook is not None and is_bw:
self._user_post_bw_hook(w_mod(), args)
if name in self.parents:
self._active_module_cnt[name] -= 1
if self._active_module_cnt[name] == 0:
self.parents.remove(name)
elif not self.is_bw:
# Due to some input/output not requiring gradients, we cannot enforce
# proper nesting in backward
raise RuntimeError(
"The Module hierarchy tracking is wrong. Report a bug to PyTorch"
)
return fn
def _fw_pre_hook(self, mod, input):
if torch._dynamo.eval_frame._is_in_optimized_module():
return
name = self._get_mod_name(mod)
w_mod = weakref.ref(mod)
self._get_append_fn(w_mod, name, False)()
if self._user_pre_fw_hook is not None:
self._user_pre_fw_hook(mod, input)
args, _ = tree_flatten(input)
tensors = [a for a in args if isinstance(a, torch.Tensor) and a.requires_grad]
if not self.is_bw:
if tensors:
register_multi_grad_hook(tensors, self._get_pop_fn(w_mod, name, True))
else:
self._post_bw_callbacks_to_enqueue.append(
self._get_pop_fn(w_mod, name, True)
)
def _fw_post_hook(self, mod, input, output):
if torch._dynamo.eval_frame._is_in_optimized_module():
return
name = self._get_mod_name(mod)
w_mod = weakref.ref(mod)
if self._user_post_fw_hook is not None:
self._user_post_fw_hook(mod, input, output)
self._get_pop_fn(w_mod, name, False)()
args, _ = tree_flatten(output)
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, self._get_append_fn(w_mod, name, True), mode="any"
)
def __enter__(self):
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, always_call=True
)
return self
def __exit__(self, *args):
self._fw_pre_handle.remove()
self._fw_post_handle.remove()
@@ -0,0 +1,401 @@
# Owner(s): ["module: unknown"]
from collections import defaultdict
from typing import Any, TYPE_CHECKING
from typing_extensions import Self
import torch
import torch.utils._pytree as pytree
from torch._guards import active_fake_mode
from torch._subclasses.fake_tensor import FakeTensorMode
from torch.distributed._tools.mod_tracker import ModTracker
from torch.utils._mode_utils import no_dispatch
from torch.utils._python_dispatch import TorchDispatchMode
from torch.utils._runtime_estimation import (
_FLOAT_TYPES,
_IGNORE_OPS,
_VIEW_OPS,
get_compute_time,
get_transfer_time,
)
if TYPE_CHECKING:
from collections.abc import Callable
__all__ = ["RuntimeEstimator"]
class RuntimeEstimator(TorchDispatchMode):
"""
Estimates the GPU runtime in milliseconds using various estimation methods under the ``FakeTensorMode``.
This class provides a ``TorchDispatchMode`` based context manager that can be used to estimate the eager
runtime of PyTorch functions. It supports two estimation modes, benchmarking (`operator-level-benchmark`) and
roofline cost modeling (`operator-level-cost-model`).
For modules executed under this context manager, it aggregates the forward and backward operation runtimes
and also records their execution orders.
Attributes:
mod_runtimes (Dict[str, Dict[str, float]]): A dictionary of module runtimes. The key to the outer dictionary
is the fully qualified name (FQN) of the module. For each module the forward and backward runtimes of the
operations are aggregated in the inner dictionary keyed by 'fw' and 'bw'.
mod_fw_pre_order (List[str]): List of module FQNs in pre-forward execution order.
mod_bw_pre_order (List[str]): List of module FQNs in pre-backward execution order.
mod_fw_post_order (List[str]): List of module FQNs in post-forward execution order.
mod_bw_post_order (List[str]): List of module FQNs in post-backward execution order.
total_runtime (float): The total estimated runtime in milliseconds.
Note:
1) The benchmarking estimate mode will execute kernels on GPU and assumes that every operation can run in
isolation without causing an OOM error. It is also designed to be used only under ``FakeTensorMode``.
2) Currently wrapper tensor sub-classes such as ``DTensor`` won't produce correct estimates. We plan to support
them in future PRs.
3) We only estimate the compute time, if your code has communication, it will not be considered. Again, we will
support this in future PRs.
Example usage:
.. code-block:: python
runtime_estimator = RuntimeEstimator()
with FakeTensorMode():
module = ...
optimizer = ...
inp = ...
with runtime_estimator(estimate_mode_type="operator-level-cost-model"):
loss = module(inp)
loss.backward()
optimizer.step()
optimizer.zero_grad()
runtime_estimator.display_modulewise_stats()
"""
_no_fallback_kernel: set[torch._ops._OpNamespace] = set()
fake_mode: FakeTensorMode
def __init__(self) -> None:
super().__init__()
self._estimate: Callable
self._estimate_mode_type: str
self._mod_tracker = ModTracker()
self.mod_runtimes: dict[str, dict[str, float]] = defaultdict(
lambda: defaultdict(lambda: 0.0)
)
self.mod_fw_pre_order: list[str] = []
self.mod_bw_pre_order: list[str] = []
self.mod_fw_post_order: list[str] = []
self.mod_bw_post_order: list[str] = []
self.total_runtime: float = 0.0
# Adapted from: https://github.com/pytorch/pytorch/blob/9b902b3ee3bd608a19543362b66bf06c373dd374/torch/_subclasses/fake_tensor.py#L1969 # noqa: PGH004,B950
# NB: returns fake tensors
@classmethod
def _maybe_run_and_benchmark_fallback_kernel( # type: ignore[no-untyped-def]
cls,
func,
args,
kwargs,
orig_not_implemented_exception,
):
"""
Runs and benchmarks a fallback kernel for a given function.
Args:
func (Callable): The function to benchmark.
args (Tuple): The arguments to pass to the function.
kwargs (Dict[str, Any]): The keyword arguments to pass to the function.
orig_not_implemented_exception (Exception): The original exception to raise if the fallback kernel
is not implemented.
Returns:
Tuple[Any, float]: A tuple containing the result of the function and
the mean operation time in milliseconds.
"""
# these should all be supported, just to be safe
# avoid fallback for operators which inplace modify metadata
# because the input fake tensors would be umodified
if torch.Tag.inplace_view in func.tags: # type: ignore[attr-defined]
raise orig_not_implemented_exception
inp_impls = {}
flat_args, args_spec = pytree.tree_flatten((args, kwargs))
# Don't use in_kernel_invocation_manager(fake_mode) as we want to do
# REAL compute (not with meta device)
with no_dispatch():
def to_real_tensor(e): # type: ignore[no-untyped-def]
if cls.fake_mode.is_our_fake(e):
if e.dtype in _FLOAT_TYPES:
out = torch.rand_like(e, device=e.fake_device)
else:
out = torch.ones_like(e, device=e.fake_device)
if e.is_sparse:
out._coalesced_(e.is_coalesced())
inp_impls[id(out)] = e
return out
return e
flat_args = [to_real_tensor(a) for a in flat_args]
args, kwargs = pytree.tree_unflatten(flat_args, args_spec)
r = func(*args, **kwargs)
warmup_iters, actual_iters = 2, 3
for _ in range(warmup_iters):
func(*args, **kwargs)
start_event = torch.cuda.Event(enable_timing=True)
end_event = torch.cuda.Event(enable_timing=True)
start_event.record(torch.cuda.current_stream())
for _ in range(actual_iters):
func(*args, **kwargs)
end_event.record(torch.cuda.current_stream())
torch.cuda.synchronize()
cuda_time = start_event.elapsed_time(end_event)
mean_op_time = cuda_time / actual_iters
storages = set()
for e in flat_args:
if isinstance(e, torch.Tensor):
if not e.is_sparse:
storages.add(e._typed_storage()._cdata)
# TODO: also check metadata change on inputs
# proper aliasing/metadata relationship between outputs and inputs will
# not be set up, bc of conversion to device, unless we can reuse an
# input impl
def map_out(e): # type: ignore[no-untyped-def]
if id(e) not in inp_impls and (
isinstance(e, torch.Tensor)
and not e.is_sparse
and e._typed_storage()._cdata in storages
):
raise orig_not_implemented_exception
if isinstance(e, torch.Tensor):
if id(e) in inp_impls:
return inp_impls[id(e)]
else:
return cls.fake_mode.fake_tensor_converter.from_real_tensor(
cls.fake_mode, e
)
else:
return e
return (pytree.tree_map(map_out, r), mean_op_time)
@classmethod
def _benchmark_estimate(cls, func, args, kwargs) -> tuple[Any, float]: # type: ignore[no-untyped-def]
"""
Estimates the runtime of a function using benchmarking.
Args:
func: The function to estimate.
args: The arguments to pass to the function.
kwargs: The keyword arguments to pass to the function.
res: The result of the function.
Returns:
Tuple[Any, float]: A tuple containing the result of the function and
the mean operation time in milliseconds.
"""
if not isinstance(cls.fake_mode, FakeTensorMode):
raise AssertionError(
"Initialize/Assign FakeTensorMode before using this function"
)
mean_op_time = 0.0
if func._overloadpacket not in _VIEW_OPS:
try:
res, mean_op_time = cls._maybe_run_and_benchmark_fallback_kernel(
func,
args,
kwargs,
NotImplementedError,
)
return (res, mean_op_time)
except NotImplementedError:
cls._no_fallback_kernel.add(func._overloadpacket)
res = func(*args, **kwargs or {})
return (res, mean_op_time)
# Adapted from: https://github.com/pytorch/pytorch/blob/9b902b3ee3bd608a19543362b66bf06c373dd374/torch/_inductor/scheduler.py#L589 # noqa: PGH004,B950
@classmethod
def _roofline_estimate(cls, func, args, kwargs) -> tuple[Any, float]: # type: ignore[no-untyped-def]
"""
Estimates the runtime of a function using a roofline cost model.
Args:
func: The function to estimate.
args: The arguments to pass to the function.
kwargs: The keyword arguments to pass to the function.
out: The output of the function.
Returns:
Tuple[Any, float]: A tuple containing the result of the function and
the mean operation time in milliseconds.
"""
if not torch.cuda.is_available():
raise AssertionError(
"Roofline estimation needs to access CUDA capabilities to make estimations"
)
# Roofline Cost Model Explanation
# The roofline cost model estimates the execution time of an operator based on
# the device's empirical maximum FLOPs/sec (pi) and device DRAM bandwidth (beta).
# Variables:
# - pi: Maximum empirical FLOPs/sec of the device
# - beta: Maximum empirical device DRAM bandwidth (bytes/sec) of the device
# - I: Arithmetic intensity of the operator (FLOPs/bytes)
# - op_flops: FLOPs required by the operator
# - op_bytes: Bytes transferred to and from DRAM for the operator
# Calculation Steps:
# 1. Calculate arithmetic intensity: I = op_flops / op_bytes
# 2. Calculate estimated FLOPs/sec: est_flops_sec = min(pi, beta * I)
# 3. Calculate estimated operator time: estimated_op_time = op_flops / est_flops_sec
# This simplifies to: estimated_op_time = max(op_flops / pi, op_flops / (beta * I))
# Further simplifying: estimated_op_time = max(op_flops / pi, op_bytes / beta)
# Simplified Formulas:
# - compute_time = op_flops / pi
# - transfer_time = op_bytes / beta
# - estimated_op_time = max(compute_time, transfer_time)
kwargs = kwargs if kwargs else {}
out = func(*args, **kwargs)
op_time = 0.0
func_packet = func._overloadpacket
if func_packet not in _IGNORE_OPS:
flat_args_kwargs, args_spec = pytree.tree_flatten((args, kwargs))
flat_outs, out_spec = pytree.tree_flatten(out)
transfer_time = get_transfer_time(flat_args_kwargs, flat_outs)
out_dtypes = {
t.dtype
for t in flat_outs
if isinstance(t, torch.Tensor) and t.dtype in _FLOAT_TYPES
}
args, kwargs = pytree.tree_unflatten(flat_args_kwargs, args_spec)
out = pytree.tree_unflatten(flat_outs, out_spec)
compute_time = get_compute_time(func_packet, args, kwargs, out, out_dtypes)
# We get the estimated time as the max of the transfer time and
# compute time. We divide by 1e6 to get the time in ms
op_time = max(transfer_time, compute_time) / 1e6
return (out, op_time)
def display_modulewise_stats(self, depth: int = 2) -> None:
"""
Displays module-wise statistics collected by ``RuntimeEstimator``.
Prints the pre-forward and pre-backward execution orders.
Displays the module-wise forward and backward runtimes in milliseconds.
Args:
depth (int): The maximum depth of module hierarchy to display (default to 2).
"""
print("Pre-Forward Execution Order: ")
for mod_fqn in self.mod_fw_pre_order:
mod_depth = mod_fqn.count(".") + 1
if mod_depth > depth:
continue
print(mod_fqn)
print("Pre-Backward Execution Order: ")
for mod_fqn in self.mod_bw_pre_order:
mod_depth = mod_fqn.count(".") + 1
if mod_depth > depth:
continue
print(mod_fqn)
for mod_fqn, runtimes in self.mod_runtimes.items():
mod_depth = mod_fqn.count(".") + 1
if mod_depth > depth:
continue
print(
f"{mod_fqn} fw: {runtimes.get('fw', 0.0):.3f}ms bw: {runtimes.get('bw', 0.0):.3f}ms"
)
def __torch_dispatch__(self, func, types, args=..., kwargs=None): # type: ignore[no-untyped-def]
# TODO: @sanketpurandare: Flatten tensors by desugaring the tensor subclasses
# TODO: @sanketpurandare: Add logic for incorporating communication time
res, op_time = self._estimate(func, args, kwargs)
for par in self._mod_tracker.parents:
if self._mod_tracker.is_bw:
self.mod_runtimes[par]["bw"] += op_time
else:
self.mod_runtimes[par]["fw"] += op_time
self.total_runtime += op_time
return res
def __call__(self, estimate_mode_type: str) -> Self:
"""
Sets the estimate mode type.
Currently supported modes:
- "operator-level-benchmark": Estimates runtime using operator benchmarking.
- "operator-level-cost-model": Estimates runtime using roofline cost model.
Args:
estimate_mode_type (str): The type of estimate mode to use.
Returns:
RuntimeEstimator: The runtime estimator instance.
Raises:
NotImplementedError: If the estimate mode type is not supported.
"""
if estimate_mode_type == "operator-level-benchmark":
self._estimate = RuntimeEstimator._benchmark_estimate
elif estimate_mode_type == "operator-level-cost-model":
self._estimate = RuntimeEstimator._roofline_estimate
else:
raise NotImplementedError(
f"estimate_mode_type {estimate_mode_type} not supported"
)
self._estimate_mode_type = estimate_mode_type
return self
def __enter__(self) -> Self:
fake_mode = active_fake_mode()
if not isinstance(fake_mode, FakeTensorMode):
raise AssertionError(
"No FakeTensorMode found, designed to used under FakeTensorMode"
)
RuntimeEstimator.fake_mode = fake_mode
self.total_runtime = 0.0
self.mod_runtimes = defaultdict(lambda: defaultdict(lambda: 0.0))
self.mod_fw_pre_order.clear()
self.mod_bw_pre_order.clear()
self.mod_fw_post_order.clear()
self.mod_bw_post_order.clear()
self._mod_tracker.register_user_hooks(
pre_fw_hook=lambda mod, inp: self.mod_fw_pre_order.append(
self._mod_tracker.get_known_fqn(mod)
),
pre_bw_hook=lambda mod, g_out: self.mod_bw_pre_order.append(
self._mod_tracker.get_known_fqn(mod)
),
post_fw_hook=lambda mod, inp, out: self.mod_fw_post_order.append(
self._mod_tracker.get_known_fqn(mod)
),
post_bw_hook=lambda mod, g_inp: self.mod_bw_post_order.append(
self._mod_tracker.get_known_fqn(mod)
),
)
self._mod_tracker.__enter__()
super().__enter__()
return self
# pyrefly: ignore [bad-override]
def __exit__(self, *args: Any) -> None:
print(
f"Estimated ({self._estimate_mode_type})"
f"total_time: {self.total_runtime:.3f} ms"
)
if len(self._no_fallback_kernel) > 0:
print("no_fallback_kernel: ", list(self._no_fallback_kernel))
super().__exit__(*args)
self._mod_tracker.clear_user_hooks()
self._mod_tracker.__exit__()
@@ -0,0 +1,965 @@
import math
import os
import sys
from collections import OrderedDict
from dataclasses import astuple, dataclass
from typing import Any, NamedTuple
from typing_extensions import Self
import torch
from torch import nan, nn, UntypedStorage
from torch._guards import active_fake_mode
from torch._subclasses.fake_tensor import FakeTensorMode
from torch.distributed._tools.common_utils import get_untyped_storages
from torch.distributed._tools.mod_tracker import ModTracker
from torch.distributed._tools.runtime_estimator import RuntimeEstimator
from torch.testing._internal.composite_compliance import (
is_inplace,
is_inplace_view_fn,
is_view_fn,
)
from torch.utils._python_dispatch import TorchDispatchMode
from torch.utils._pytree import tree_flatten
from torch.utils.checkpoint import SAC_IGNORED_OPS
__all__ = ["SACEstimator", "SACStats", "MSPS", "SACTradeOffStats", "SACGreedyOrderMeta"]
aten = torch.ops.aten
_ADDITIONAL_IGNORED_OPS = {
aten.lift_fresh.default, # type: ignore[attr-defined]
torch.ops.profiler._record_function_exit._RecordFunction, # type: ignore[attr-defined]
aten.clone.default, # type: ignore[attr-defined] # seems needed for torch.compile
}
OPS_TO_ALWAYS_SKIP = SAC_IGNORED_OPS | _ADDITIONAL_IGNORED_OPS
# This value is hard-coded here:
# https://github.com/pytorch/pytorch/blob/5fba5d83f0703ff8077ab65448a998e9ad6598fd/c10/cuda/CUDACachingAllocator.cpp#L117
_PYTORCH_MIN_ALLOCATE = (
2**9 if int(os.environ.get("PYTORCH_NO_CUDA_MEMORY_CACHING", 0)) == 0 else 1
)
def _display_stats_tabular(headers: list[str], table_data: list[list[Any]]) -> None:
try:
from tabulate import tabulate
except ImportError as err:
raise ImportError("Please install tabulate.") from err
# Use tabulate to print the table
print(tabulate(table_data, headers=headers, tablefmt="rst"))
# Based on:
# https://github.com/facebookresearch/xformers/blob/main/xformers/checkpoint.py#L71
@dataclass
class _SACMetadata:
"""
Stores metadata for a single operator for SAC.
Attributes:
func (Any): The operator function.
time_taken (float): The time taken by the operator.
memory_used (float): The memory used by the operator.
curr_idx (int): The current operator index.
output_ids (Tuple[int, ...]): The storage IDs of the operator's outputs.
inplace_info (Tuple[int, ...]): Tuple of self and parent operator for in-place operator.
is_view_like (bool): Whether the operator is view-like.
is_rand_op (bool): Whether the operator is a random operator.
"""
func: Any
time_taken: float
memory_used: float
curr_idx: int
output_ids: tuple[int, ...]
inplace_info: tuple[int, ...]
is_view_like: bool
is_rand_op: bool
@dataclass
class _SACModMetadata:
"""
Stores metadata for a module for SAC.
Attributes:
start_idx (int): The starting index of the module's operators.
force_store_random (bool): Whether to force store random operators in the module.
sac_metadata (List[_SACMetadata]): List of metadata for each operator in the module.
"""
start_idx: int
force_store_random: bool
sac_metadata: list[_SACMetadata]
@dataclass
class SACStats:
"""
A class for storing Activation Checkpointing statistics corresponding to a module.
Attributes:
func_names (List[str]): List of operator names.
runtimes (List[float]): List of operator runtimes in millliseconds.
memory (List[int]): List of operator memory usage in bytes.
view_like_ops (List[int]): Indices of view-like operators.
rand_ops (List[int]): Indices of random operators.
saved_autograd_ops (List[int]): Indices of operator results saved by autograd engine.
inplace_ops (List[Tuple[int, int]]): Tuple of indices of op and its first parent for Inplace operators.
force_store_random (bool): Whether to force store random operator results.
"""
func_names: list[str]
runtimes: list[float]
memory: list[int]
view_like_ops: list[int]
rand_ops: list[int]
saved_autograd_ops: list[int]
inplace_ops: list[tuple[int, int]]
force_store_random: bool
class MSPS(NamedTuple):
"""
Represents Memory and Runtime Statistics for an operator/operator group.
Attributes:
func_names (set[str]): Set of operator/operator group names.
op_idx (int): Operator index (group head index in case of operator groups).
memory (int): Memory usage in bytes.
runtime (float): Runtime in milliseconds.
msps (float): Memory per second calculated as memory/runtime.
"""
func_names: set[str]
op_idx: int
memory: int
runtime: float
msps: float
@dataclass
class SACTradeOffStats:
"""
Stores statistics for activation-checkpointing trade-off.
Attributes:
n_segments (int): Number of piecewise linear segments fitted to the trade-off curve.
slopes (List[float]): Slopes of the pieces of linear segments fitted to the trade-off curve.
intercepts (List[float]): Intercepts of the of the pieces of linear segments fitted to the trade-off curve.
fit_breaks (List[float]): Breakpoints of the of the pieces of linear segments fitted to the trade-off curve.
tradeoff_curve (OrderedDict[float, float]): Trade-off curve data of memory discarded vs recomputation time.
sac_memory (int): Total memory of operations available for activation checkpointing in bytes.
sac_runtime (float): Total runtime of operations available for activation checkpointing in milliseconds.
"""
n_segments: int
slopes: list[float]
intercepts: list[float]
fit_breaks: list[float]
tradeoff_curve: OrderedDict[float, float]
sac_memory: int
sac_runtime: float
@dataclass
class SACGreedyOrderMeta:
"""
Stores metadata for Greedy-order SAC.
Attributes:
recomputed_ops (set[int]): Set of operator indices to be recomputed.
stored_ops (set[int]): Set of operator indices to be stored.
inplace_op_groups (dict[int, set[int]]): Dictionary of inplace operator groups from group-head to operators.
random_ops_group (dict[int, set[int]]): Dictionary of random op group head to random ops.
msps_meta (list[MSPS]): List of Memory and Runtime Statistics for operators.
"""
recomputed_ops: set[int]
stored_ops: set[int]
inplace_op_groups: dict[int, set[int]]
random_ops_group: dict[int, set[int]]
msps_meta: list[MSPS]
class SACEstimator(TorchDispatchMode):
"""
Estimates the memory and recomputation time trade-offs for applying Selective Activation Checkpointing (SAC).
This class provides a ``TorchDispatchMode`` based context manager that can be used to estimate the memory and
runtime trade-offs of functions or ``torch.nn.Module``s for Selective Activation Checkpointing (SAC). It provides
detailed statistics and metadata information for operators of each module and provides a greedy order for selecting
the operators to be recomputed/checkpointed. It also constructs the per-module trade-off graph of discarded memory
vs recomputation time for the obtained greedy order. Using ``RuntimeEstimator`` under the hood, it supports two
estimation modes, `operator-level-benchmark` and (`operator-level-cost-model` (roofline model).
Attributes:
sac_mod_stats (Dict[str, SACStats]): Dictionary from module FQN (fully qualified name) to ``SACStats``.
sac_mod_tradeoff_stats (Dict[str, SACTradeOffStats]): Dictionary from module FQN to ``SACTradeOffStats``.
sac_mod_greedy_order_meta (Dict[str, SACGreedyOrderMeta]): Dictionary from module FQN to ``SACGreedyOrderMeta``.
Note:
1) This class is designed to be used under ``FakeTensorMode``.
2) Currently, it only supports estimation of compute time and memory usage, and does not consider communication.
Example usage:
.. code-block:: python
sac_estimator = SACEstimator()
with FakeTensorMode():
module = ...
inp = ...
with sac_estimator("operator-level-cost-model"):
output = module(inp)
sac_estimator.display_modulewise_sac_stats(depth=4, print_tabular=True)
"""
def __init__(self) -> None:
self.sac_mod_stats: dict[str, SACStats] = {}
self.sac_mod_tradeoff_stats: dict[str, SACTradeOffStats] = {}
self.sac_mod_greedy_order_meta: dict[str, SACGreedyOrderMeta] = {}
self._mod_tracker = ModTracker()
self._sac_metadata: list[_SACMetadata] = []
self._sac_mod_metadata: dict[str, _SACModMetadata] = {}
self._leaf_modules: set[str] = set()
self._saved_tensor_hook_ctx = torch.autograd.graph.saved_tensors_hooks(
self._pack_hook, lambda x: x
)
self._saved_tensor_ids: set[int] = set()
self._estimate_runtime = RuntimeEstimator._roofline_estimate
def _pack_hook(self, x: torch.Tensor) -> torch.Tensor:
# Hook function to track underlying storage IDs of tensors
# Updates the _saved_tensor_ids set with the IDs of the tensor's storages
# Used in conjunction with torch.autograd.graph.saved_tensors_hooks
untyped_storages = get_untyped_storages(x)
storage_ids = (hash(st) for st in untyped_storages)
self._saved_tensor_ids.update(storage_ids)
return x
def _pre_fw_hook(self, mod: nn.Module, inputs: Any) -> None:
# Pre-forward hook function to prepare module metadata
# Tracks module FQN, force store random flag, and ``SACModMetadata``
# Initializes metadata for non-leaf modules, marks leaf modules
mod_fqn = self._mod_tracker.get_known_fqn(mod)
if mod_fqn is None:
raise AssertionError
num_children = sum(1 for _ in mod.children())
if num_children > 0:
force_store_random = self._get_force_store_random(inputs)
self._sac_mod_metadata[mod_fqn] = _SACModMetadata(
start_idx=len(self._sac_metadata),
force_store_random=force_store_random,
sac_metadata=[],
)
else:
self._leaf_modules.add(mod_fqn)
def _post_fw_hook(self, mod: nn.Module, inputs: Any, outputs: Any) -> None:
# 1. Retrieves the module's FQN and checks if it's a leaf module
# 2. If not a leaf module, computes:
# - ``SACStats`` using the module's metadata and force store random flag
# - ``SACGreedyOrderMeta`` using the computed SAC statistics
mod_fqn = self._mod_tracker.get_known_fqn(mod)
if mod_fqn is None:
raise AssertionError
if mod_fqn in self._leaf_modules:
return
else:
self.sac_mod_stats[mod_fqn] = self._get_sac_stats(
data=self._sac_mod_metadata[mod_fqn].sac_metadata,
force_store_random=self._sac_mod_metadata[mod_fqn].force_store_random,
)
self.sac_mod_greedy_order_meta[mod_fqn] = self._get_greedy_order_meta(
self.sac_mod_stats[mod_fqn]
)
def _get_force_store_random(self, inputs: Any) -> bool:
flat_inputs, _ = tree_flatten(inputs)
return all(not isinstance(x, torch.Tensor) for x in flat_inputs)
def _get_sac_stats(
self, data: list[_SACMetadata], force_store_random: bool
) -> SACStats:
# 1. Ignore the operations that should be skipped by SAC such as aten.detach.default because autograd
# inserts those during backward and it breaks the fwd-bwd alignment
filtered_data = [x for x in data if x.func not in OPS_TO_ALWAYS_SKIP]
(
ops,
runtimes_,
memory_,
new_ids,
output_ids,
inplace_ops_,
view_like_ops_,
rand_ops_,
) = zip(*[astuple(x) for x in filtered_data], strict=True)
# 2. Extract the metadata information
runtimes = list(runtimes_)
memory = list(memory_)
func_names = [op._overloadpacket.__name__ for op in ops]
view_like_ops = [i for i, x in enumerate(view_like_ops_) if x]
rand_ops = [i for i, x in enumerate(rand_ops_) if x]
saved_autograd_ops = [
i
for i, out_ids in enumerate(output_ids)
if set(out_ids).issubset(self._saved_tensor_ids)
]
# 3. Remap the inplace indices as we have removed OPS_TO_ALWAYS_SKIP
# FIXME @sanketpurandare: Fix this by changing the parent of the inplace-op
# to itself if the original parent is in OPS_TO_ALWAYS_SKIP.
try:
inplace_ops = [tuple(map(new_ids.index, x)) for x in inplace_ops_ if x]
except ValueError as err:
raise ValueError(
f"The remapping of inplace ops failed since one of the inplace op parents"
f" must have been present in {OPS_TO_ALWAYS_SKIP}"
) from err
# 4. The last operation is always stored as the output of the checkpoint
# block, so we can avoid recomputing it. We set the memory to zero
# instead of adding a new constraint because we want both the 0 and 1
# endpoints for memory_budget to be valid
# FIXME @sanketpurandare: this heuristic for finding the last non-view non-inplace op
# might not always be correct, which would yield suboptimal policies
last_op = len(ops) - 1
skip_ops_ = set(view_like_ops) | set({x[0] for x in inplace_ops})
reversed_skip_ops = sorted(skip_ops_, reverse=True)
for op in reversed_skip_ops:
if op == last_op:
last_op -= 1
memory[last_op] = 0
# 5. Create a single ``SACStats`` object for the entire block of ``_SACMetadata``.
return SACStats(
func_names=func_names,
runtimes=runtimes,
memory=memory,
view_like_ops=view_like_ops,
rand_ops=rand_ops,
saved_autograd_ops=saved_autograd_ops,
inplace_ops=inplace_ops, # type: ignore[arg-type]
force_store_random=force_store_random,
)
def _get_inplace_metadata(
self, func: Any, out_storages: set[UntypedStorage]
) -> tuple[int, tuple[int, ...], dict[str, tuple[int, ...]]]:
# 1. Get the current index of the metadata obtained so far
curr_idx = len(self._sac_metadata)
# 2. Get the set of active modules that are not leaf
active_mod_fqns: set[str] = {
par for par in self._mod_tracker.parents if par not in self._leaf_modules
}
# 3. Output ids are the identifies of the storage objects corresponding to the tensors
output_ids = tuple(hash(st) for st in out_storages)
# 4. If the function is not inplace, return
if not is_inplace(func):
return curr_idx, output_ids, dict.fromkeys(active_mod_fqns, ())
op_idx = curr_idx
# 5. Initialize the parent op ids of the inplace op for each of the active modules
mod_op_parent_idxs: dict[str, int] = dict.fromkeys(active_mod_fqns, -1)
for i, d in enumerate(self._sac_metadata):
# 6. Find the first occurrence of a tensor corresponding to each module that
# shares the same storage as the current tensor
past_output_ids = d.output_ids
if set(output_ids).issubset(set(past_output_ids)):
for mod_fqn, op_parent_idx in mod_op_parent_idxs.items():
if op_parent_idx == -1:
if acm_stats := self._sac_mod_metadata.get(mod_fqn, None):
if i >= acm_stats.start_idx:
mod_op_parent_idxs[mod_fqn] = i
else:
if mod_fqn != "Global":
raise AssertionError
mod_op_parent_idxs[mod_fqn] = i
# 7. If no parent tensor is found, then it's probably an inplace op on the arguments
# so one can just store the current-op idx as parent idx
for mod_fqn, op_parent_idx in mod_op_parent_idxs.items():
if op_parent_idx < 0:
mod_op_parent_idxs[mod_fqn] = op_idx
mod_inplace_info = {
mod_fqn: (op_idx, mod_op_parent_idxs[mod_fqn])
for mod_fqn in active_mod_fqns
}
return curr_idx, output_ids, mod_inplace_info # type: ignore[return-value]
def __torch_dispatch__( # type: ignore[no-untyped-def]
self, func, types, args=..., kwargs=None
):
# 1. Get the runtime estimate
out, op_time = self._estimate_runtime(func, args, kwargs)
flat_outs, _ = tree_flatten(out)
out_storages_cuda: set[UntypedStorage] = set()
out_storages_cpu: set[UntypedStorage] = set()
cuda_devices: set[torch.device] = set()
for o in flat_outs:
if isinstance(o, torch.Tensor):
if o.device.type == "cuda":
out_storages_cuda.update(get_untyped_storages(o))
cuda_devices.add(o.device)
else:
out_storages_cpu.update(get_untyped_storages(o))
# Check if there's more than 1 CUDA device
if len(cuda_devices) > 1:
raise AssertionError(
f"{func.__name__}'s output has more than 1 CUDA devices {cuda_devices}"
)
# 2. Get the memory consumed by output
nbytes_cuda = sum(
math.ceil(st.nbytes() / _PYTORCH_MIN_ALLOCATE) * _PYTORCH_MIN_ALLOCATE
for st in out_storages_cuda
)
nbytes_cpu = sum(st.nbytes() for st in out_storages_cpu)
nbytes = nbytes_cuda + nbytes_cpu
# 3. Get the current operator index, output storage identifiers and inplace metadata
out_storages = out_storages_cuda | out_storages_cpu
curr_idx, output_ids, mod_inplace_info = self._get_inplace_metadata(
func, out_storages
)
# 4. Determine if the function is in-place, random-op or a view-like
is_view_like = is_view_fn(func) or is_inplace_view_fn(func)
is_rand_op = torch.Tag.nondeterministic_seeded in func.tags
if is_view_like:
nbytes = 0
# sdpa has non-deterministic seed, but might be deterministic
# if no dropout is applied
if func.overloadpacket.__name__ == "_scaled_dot_product_flash_attention":
# pyrefly: ignore [missing-attribute]
is_rand_op = kwargs.get("dropout_p", 0) != 0
# 5. Create metadata information per active non-leaf module
for mod_fqn in self._mod_tracker.parents:
if mod_fqn in self._leaf_modules:
continue
acm = _SACMetadata(
func=func,
time_taken=op_time,
memory_used=nbytes,
curr_idx=curr_idx,
output_ids=output_ids,
inplace_info=mod_inplace_info[mod_fqn],
is_view_like=is_view_like,
is_rand_op=is_rand_op,
)
if acm_stats := self._sac_mod_metadata.get(mod_fqn, None):
acm_stats.sac_metadata.append(acm)
else:
if mod_fqn != "Global":
raise AssertionError(f"Module {mod_fqn} not found in AC Mod Stats")
self._sac_metadata.append(acm)
return out
def _get_greedy_order_meta(self, sac_stats: SACStats) -> SACGreedyOrderMeta:
# An inplace-op group is a set of inplace-ops that operate on the same underlying tensor storage.
# 1. inplace_op_groups: A dictionary from the top-most parent of inplace-ops to the inplace-ops in the group
# The top-most op can itself be an inplace-op or can be a non-inplace op.
# 2. inplace_op_to_group_head: A dictionary that maps all the inplace-ops to their respective group heads.
inplace_op_groups: dict[int, set[int]] = {}
inplace_op_to_group_head: dict[int, int] = dict(sac_stats.inplace_ops)
# Initialize inplace_op_groups using inplace_op_to_group_head
for op_idx, group_head_idx in inplace_op_to_group_head.items():
op_group = inplace_op_groups.setdefault(group_head_idx, {group_head_idx})
op_group.add(op_idx)
# Like inplace ops, all of the random ops in the function/module should all be either recomputed or saved
# as a group. This is because, they affect the ranom seed generator. If force_store_random is set True,
# all of the random ops will be stored by default. For easy of manageability, we store the top-most random op
# as the leader of the random_ops_group.
random_ops_group: dict[int, set[int]] = {}
random_group_head_idx = min(sac_stats.rand_ops, default=-1)
has_rand_ops = bool(sac_stats.rand_ops)
if has_rand_ops:
random_ops_group[random_group_head_idx] = set(sac_stats.rand_ops)
# 1. Random ops are stored if force_store_random is set
# 2. View-like ops are recomputed by default
# 3. For inplace_op_groups:
# a) If the head of this group is an inplace op, then we have to store the entire group.
# b) If any op in the group is random and force_store_random is set, then entire group will be stored.
# c) If none of ops in the group are random and the head of the group is not an in-place op, then
# this group can be considered for recomputation in its entirety
stored_ops: set[int] = set()
recomputed_ops: set[int] = set()
# Case 1:
if has_rand_ops and sac_stats.force_store_random:
stored_ops.add(random_group_head_idx)
# Case 2:
recomputed_ops.update(set(sac_stats.view_like_ops))
for group_head_idx, op_group in inplace_op_groups.items():
# Case 3a:
if group_head_idx in inplace_op_to_group_head:
stored_ops.add(group_head_idx)
# Case 3b:
if (
sac_stats.force_store_random & len(op_group & set(sac_stats.rand_ops))
> 0
):
stored_ops.add(group_head_idx)
# The potential recompute candidates are populated as:
recompute_candidates: set[int] = set()
# 1) The random group head if it is not stored
if has_rand_ops and random_group_head_idx not in stored_ops:
recompute_candidates.add(random_group_head_idx)
# 2) The in-place op group heads that are not stored
recompute_candidates.update(set(inplace_op_groups.keys()) - stored_ops)
# 3) The non-inplace and non-random ops that are neither stored nor recomputed by default
recompute_candidates.update(
set(range(len(sac_stats.memory)))
- recomputed_ops
- stored_ops
- set(inplace_op_to_group_head.keys())
- set(sac_stats.rand_ops)
)
# We define msps for a recomp candidate as the ratio of memory/runtime aka memory savings per second
msps_meta: list[MSPS] = []
for cand_idx in recompute_candidates:
op_indices = {cand_idx}
if cand_idx in inplace_op_groups:
op_indices.update(inplace_op_groups[cand_idx])
if has_rand_ops and cand_idx == random_group_head_idx:
op_indices.update(sac_stats.rand_ops)
mem = sum(sac_stats.memory[op_idx] for op_idx in op_indices)
runtime = sum(sac_stats.runtimes[op_idx] for op_idx in op_indices)
func_names = {sac_stats.func_names[op_idx] for op_idx in op_indices}
msps = (mem / runtime) if runtime > 0 else sys.float_info.max
msps_meta.append(MSPS(func_names, cand_idx, mem, runtime, msps))
# We choose candidates to be recomputed based on increasing msps
msps_meta.sort(key=lambda x: x.msps, reverse=True)
return SACGreedyOrderMeta(
recomputed_ops, stored_ops, inplace_op_groups, random_ops_group, msps_meta
)
def _get_sac_tradeoff_pwlf_stats(
self,
sac_stats: SACStats,
greedy_order_meta: SACGreedyOrderMeta,
n_segments: int = 2,
save_tradeoff_graph: bool = False,
filename: str = "ac_tradeoff",
) -> SACTradeOffStats:
try:
import numpy as np # type: ignore[import-not-found]
import pwlf # type: ignore[import-untyped, import-not-found]
except ImportError as err:
raise ImportError("Please install pwlf and numpy package.") from err
stored_ops, recomputed_ops, inplace_op_groups, random_ops_group, msps_meta = (
greedy_order_meta.stored_ops,
greedy_order_meta.recomputed_ops,
greedy_order_meta.inplace_op_groups,
greedy_order_meta.random_ops_group,
greedy_order_meta.msps_meta,
)
# 1. Initialize the discarded memory and recomputation runtime to sum of already chosen recomputed_ops
recomp_indices: set[int] = set()
for r_idx in recomputed_ops:
recomp_indices.add(r_idx)
if r_idx in inplace_op_groups:
recomp_indices.update(inplace_op_groups[r_idx])
if r_idx in random_ops_group:
recomp_indices.update(random_ops_group[r_idx])
discarded_mem = sum(sac_stats.memory[op_idx] for op_idx in recomp_indices)
recomp_runtime = sum(sac_stats.runtimes[op_idx] for op_idx in recomp_indices)
# 2. Initialize the max recomputation time and total recomputation memory
sac_runtime = sum(sac_stats.runtimes)
sac_memory = sum(sac_stats.memory)
# 3. Tradeoff curve stores the KV pair of the discarded memory to total memory and,
# recomputation time to total runtime incurred.
delta = 1e-2
tradeoff_curve = OrderedDict()
# 4. Initialize the trade-off curve with the stats of of already chosen recomputed_ops
tradeoff_curve[(discarded_mem / sac_memory) + delta] = (
recomp_runtime / sac_runtime
)
# 5. Update the trade-off curve with memory and runtime stats of SAC candidates in the
# greedy order of their ``MSPS``.
for cand in msps_meta:
discarded_mem += cand.memory
recomp_runtime += cand.runtime
tradeoff_curve[(discarded_mem / sac_memory) + delta] = (
recomp_runtime / sac_runtime
)
# 6. Finally, we add the memory and recomputation time of the always stored ops.
stored_indices: set[int] = set()
for s_idx in stored_ops:
stored_indices.add(s_idx)
if s_idx in inplace_op_groups:
stored_indices.update(inplace_op_groups[s_idx])
if s_idx in random_ops_group:
stored_indices.update(random_ops_group[s_idx])
discarded_mem += sum(sac_stats.memory[op_idx] for op_idx in stored_indices)
recomp_runtime += sum(sac_stats.runtimes[op_idx] for op_idx in stored_indices)
tradeoff_curve[(discarded_mem / sac_memory) + delta] = (
recomp_runtime / sac_runtime
)
x_ = list(tradeoff_curve.keys())
y_ = list(tradeoff_curve.values())
# 7. We shift the y values to left and x values to right to upperbound the trade-off function
# TODO: Write a better explanation why this needs to be done
x = x_[: len(x_) - 1]
y = y_[1:]
tradeoff_pwlf = pwlf.PiecewiseLinFit(x, y)
# 8. Fit a piecewise linear function with the specified number of segments to the trade-off curve.
n_segments = max(min(len(x) - 2, n_segments), 1)
tradeoff_pwlf.fit(n_segments=n_segments)
# save prediction graph
def save_prediction_graph(
pwlf_: pwlf.PiecewiseLinFit, x: list[float], y: list[float], filename: str
) -> None:
try:
import matplotlib.pyplot as plt # type: ignore[import-not-found]
import numpy as np # type: ignore[import-not-found]
except ImportError as err:
raise ImportError(
"Install matplotlib and numpy using pip: pip install matplotlib numpy"
) from err
# predict for the determined points
xHat = np.linspace(min(x), max(x), num=10000)
yHat = pwlf_.predict(xHat)
# plot the results
plt.figure()
plt.plot(x, y, "o", label="Shifted")
plt.plot(xHat, yHat, "-", label="Predicted")
plt.plot(x_, y_, "x", label="Original")
plt.ylabel("Recomp time / Total recomp time")
plt.xlabel("Memory discarded / Total memory")
plt.legend()
plt.title(f"{filename}")
plt.suptitle(
f"Total Memory = {sac_memory} B Total Runtime = {sac_runtime:.4f} ms",
fontsize=10,
)
folder_name = "tradeoff_graphs"
if not os.path.exists(folder_name):
os.makedirs(folder_name)
# Save the plots in the folder
plt.savefig(os.path.join(folder_name, f"{filename}.png"))
if save_tradeoff_graph:
save_prediction_graph(tradeoff_pwlf, x, y, filename)
# 9. Obtain the slopes, intercepts and breakpoints of the fitted piecewise linear functions
slopes = tradeoff_pwlf.calc_slopes().tolist()
if not (
isinstance(tradeoff_pwlf.intercepts, np.ndarray)
and isinstance(tradeoff_pwlf.fit_breaks, np.ndarray)
):
raise AssertionError
intercepts = tradeoff_pwlf.intercepts.tolist()
fit_breaks = tradeoff_pwlf.fit_breaks.tolist()
return SACTradeOffStats(
n_segments=n_segments,
slopes=slopes,
intercepts=intercepts, # type: ignore[arg-type]
fit_breaks=fit_breaks, # type: ignore[arg-type]
tradeoff_curve=tradeoff_curve,
sac_memory=sac_memory,
sac_runtime=sac_runtime,
)
def display_sac_stats(
self, sac_stats: SACStats, print_tabular: bool = False
) -> None:
"""
Displays the SAC statistics.
Args:
sac_stats (SACStats): The SAC statistics to display.
print_tabular (bool, optional): Whether to print the statistics in a tabular format. Defaults to False.
Prints:
1. Total Memory: The total memory usage in bytes.
2. Total Runtime: The total runtime in milliseconds.
3. Store Random: A flag indicating whether to force store random operator results.
Followed by a table with the following columns:
1. Op Idx: The operator index.
2. Op Name: The operator name.
3. Runtimes (ms): The operator runtime in milliseconds.
4. Memory (B): The operator memory usage in bytes.
5. View-like: A flag indicating whether the operator is view-like.
6. Random: A flag indicating whether the operator is random.
7. Saved Autograd: A flag indicating whether the operator's result is saved by autograd engine.
8. In-place: The index of the operator's first parent, or None if not in-place.
If print_tabular is True, the table is printed in a tabular format.
Otherwise, the table is printed in a plain text format.
"""
print(
f"Total Memory: {sum(sac_stats.memory)} B Total Runtime: {sum(sac_stats.runtimes)} ms"
f" Store Random: {sac_stats.force_store_random}"
)
table_data = []
op_parent = dict(sac_stats.inplace_ops)
for i, fn_name in enumerate(sac_stats.func_names):
row = [
str(i),
fn_name,
f"{sac_stats.runtimes[i]:.4f}",
str(sac_stats.memory[i]),
str(i in sac_stats.view_like_ops),
str(i in sac_stats.rand_ops),
str(i in sac_stats.saved_autograd_ops),
str(op_parent.get(i)),
]
table_data.append(row)
# Define headers
headers = [
"Op Idx",
"Op Name",
"Runtimes(ms)",
"Memory (B)",
"View-like",
"Random",
"Saved Autograd",
"In-place",
]
if print_tabular:
_display_stats_tabular(headers, table_data)
else:
max_widths = [0 for _ in range(len(headers))]
table_data.insert(0, headers)
for row in table_data:
for i, elem in enumerate(row):
max_widths[i] = max(max_widths[i], len(elem))
for row in table_data:
print(
"\t".join(
[f"{elem:<{max_widths[i]}}" for i, elem in enumerate(row)]
)
)
def display_sac_tradeoff_stats(
self,
greedy_order_meta: SACGreedyOrderMeta,
sac_stats: SACStats,
print_tabular: bool = False,
) -> None:
"""
Displays the SAC trade-off statistics.
Args:
greedy_order_meta (SACGreedyOrderMeta): The SAC greedy order metadata.
sac_stats (SACStats): The SAC statistics.
print_tabular (bool, optional): Whether to print the statistics in a tabular format. Defaults to False.
Prints:
A table with the following columns:
1. Op Id(s): The operator index(es).
2. Op Name(s): The operator name(s).
3. Discarded Mem (%): The percentage of discarded memory.
4. Discarded Mem (B): The discarded memory in bytes.
5. Recomp time (%): The percentage of recomputed time.
6. Recomp time (ms): The recomputed time in milliseconds.
7. MSPS: The memory per second.
8. Always Stored: A flag indicating whether the operator is always stored.
9. Always Recomputed: A flag indicating whether the operator is always recomputed.
If print_tabular is True, the table is printed in a tabular format.
Otherwise, the table is printed in a plain text format.
"""
table_data = []
total_memory, total_runtime = sum(sac_stats.memory), sum(sac_stats.runtimes)
discarded_mem: int = 0
recomp_runtime: float = 0.0
def append_row(
op_indices: set[int],
func_names: set[str],
msps: float | None = None,
stored: bool | None = False,
recomputed: bool | None = False,
) -> None:
row = [
str(op_indices),
str(func_names),
f"{discarded_mem / total_memory:.4f}",
str(discarded_mem),
f"{recomp_runtime / total_runtime:.4f}",
str(recomp_runtime),
f"{msps:.2e}" if msps is not None else str(nan),
str(stored),
str(recomputed),
]
table_data.append(row)
stored_ops, recomputed_ops, inplace_op_groups, random_ops_group, msps_meta = (
greedy_order_meta.stored_ops,
greedy_order_meta.recomputed_ops,
greedy_order_meta.inplace_op_groups,
greedy_order_meta.random_ops_group,
greedy_order_meta.msps_meta,
)
for op_idx in recomputed_ops:
op_indices: set[int] = {op_idx}
if op_idx in inplace_op_groups:
op_indices.update(inplace_op_groups[op_idx])
if op_idx in random_ops_group:
op_indices.update(random_ops_group[op_idx])
discarded_mem += sum(sac_stats.memory[i] for i in op_indices)
recomp_runtime += sum(sac_stats.runtimes[i] for i in op_indices)
func_names = {sac_stats.func_names[i] for i in op_indices}
append_row(op_indices, func_names, recomputed=True)
for cand in msps_meta:
discarded_mem += cand.memory
recomp_runtime += cand.runtime
op_indices = {cand.op_idx}
if cand.op_idx in inplace_op_groups:
op_indices.update(inplace_op_groups[cand.op_idx])
if cand.op_idx in random_ops_group:
op_indices.update(random_ops_group[cand.op_idx])
append_row(op_indices, cand.func_names, msps=cand.msps)
for op_idx in stored_ops:
op_indices = {op_idx}
if op_idx in inplace_op_groups:
op_indices.update(inplace_op_groups[op_idx])
if op_idx in random_ops_group:
op_indices.update(random_ops_group[op_idx])
discarded_mem += sum(sac_stats.memory[i] for i in op_indices)
recomp_runtime += sum(sac_stats.runtimes[i] for i in op_indices)
func_names = {sac_stats.func_names[i] for i in op_indices}
append_row(op_indices, func_names, stored=True)
headers = [
"Op Id(s)",
"Op Name(s)",
"Discarded Mem (%)",
"Discarded Mem (B)",
"Recomp time (%)",
"Recomp time (ms)",
"MSPS",
"Always Stored",
"Always Recomputed",
]
if print_tabular:
_display_stats_tabular(headers, table_data)
else:
max_widths = [0 for _ in range(len(headers))]
table_data.insert(0, headers)
for row in table_data:
for i, elem in enumerate(row):
max_widths[i] = max(max_widths[i], len(elem))
for row in table_data:
print(
"\t".join(
[f"{elem:<{max_widths[i]}}" for i, elem in enumerate(row)]
)
)
def pwlf_sac_tradeoff_curve(
self,
n_segments: int = 2,
save_tradeoff_graphs: bool = False,
) -> None:
"""
Fits a piecewise linear function with the specified sumber of segments to the SAC trade-off curve of
discarded memory vs recomputation time.
Args:
n_segments (int, optional): The number of segments to be used for fitting the piecewise linear function to
the trade-off curve. Defaults to 2.
save_tradeoff_graphs (bool, optional): Whether to save the trade-off graphs to file. Defaults to False.
If save_tradeoff_graphs is True, the trade-off graphs are saved to file using the module FQN as the filename.
"""
for mod_fqn, sac_stats in self.sac_mod_stats.items():
self.sac_mod_tradeoff_stats[mod_fqn] = self._get_sac_tradeoff_pwlf_stats(
sac_stats=sac_stats,
greedy_order_meta=self.sac_mod_greedy_order_meta[mod_fqn],
n_segments=n_segments,
save_tradeoff_graph=save_tradeoff_graphs,
filename=mod_fqn,
)
def display_modulewise_sac_stats(
self, depth: int = 2, print_tabular: bool = False
) -> None:
"""
Displays the SAC and trade-off statistics for each module.
Args:
depth (int, optional): The maximum depth of modules to display. Defaults to 2.
print_tabular (bool, optional): Whether to print the statistics in a tabular format. Defaults to False.
Prints:
For each module with depth less than or equal to the specified depth:
1. The SAC statistics for the module (using display_sac_stats).
2. The SAC trade-off statistics for the module (using display_sac_tradeoff_stats).
If print_tabular is True, the statistics are printed in a tabular format.
Otherwise, the statistics are printed in a plain text format.
"""
for mod_fqn, sac_stats in self.sac_mod_stats.items():
mod_depth = mod_fqn.count(".") + 1
if mod_depth > depth:
continue
print(f"Module: {mod_fqn}")
self.display_sac_stats(sac_stats, print_tabular)
print(f"AC Trade-off for Module: {mod_fqn} MSPS = Memory/Runtime")
self.display_sac_tradeoff_stats(
self.sac_mod_greedy_order_meta[mod_fqn], sac_stats, print_tabular
)
def __call__(self, estimate_mode_type: str) -> Self:
"""
Sets the estimate mode type.
Currently supported modes:
- "operator-level-benchmark": Estimates runtime using operator benchmarking.
- "operator-level-cost-model": Estimates runtime using roofline cost model.
Args:
estimate_mode_type (str): The type of estimate mode to use.
Returns:
SACEstimator: The SAC estimator instance.
Raises:
NotImplementedError: If the estimate mode type is not supported.
"""
if estimate_mode_type == "operator-level-benchmark":
self._estimate_runtime = RuntimeEstimator._benchmark_estimate
elif estimate_mode_type == "operator-level-cost-model":
self._estimate_runtime = RuntimeEstimator._roofline_estimate
else:
raise NotImplementedError(
f"estimate_mode_type {estimate_mode_type} not supported"
)
return self
def __enter__(self) -> Self: # type: ignore[no-untyped-def]
fake_mode = active_fake_mode()
if not isinstance(fake_mode, FakeTensorMode):
raise AssertionError("SAC Estimator should be called in FakeTensorMode")
RuntimeEstimator.fake_mode = fake_mode
self._mod_tracker.register_user_hooks(
pre_fw_hook=self._pre_fw_hook,
post_fw_hook=self._post_fw_hook,
)
self._mod_tracker.__enter__()
self._saved_tensor_hook_ctx.__enter__()
return super().__enter__()
def __exit__(self, *args: Any) -> None: # type: ignore[no-untyped-def]
self._saved_tensor_hook_ctx.__exit__()
self._mod_tracker.__exit__(*args)
super().__exit__(*args)
@@ -0,0 +1,294 @@
import logging
import math
from enum import IntEnum
from torch.distributed._tools.ilp_utils import Graph, is_submodule
from torch.distributed._tools.sac_estimator import SACStats
try:
from pulp import ( # type: ignore[import-untyped,import-not-found]
lpDot,
LpInteger,
LpMaximize,
LpMinimize,
LpProblem,
LpStatus,
lpSum,
LpVariable,
PULP_CBC_CMD,
value,
)
except ImportError as err:
raise ImportError(
"Please install pulp package. See: https://github.com/coin-or/pulp."
) from err
# Create a logger object
logger = logging.getLogger(__name__)
# Set the logging level to INFO
logger.setLevel(logging.INFO)
def sac_milp(
graph: Graph,
memory_budget: float,
world_size: int = 1,
ac_units: list[str] | None = None,
fsdp_units: list[str] | None = None,
) -> tuple[dict[str, float], float, int]:
"""
MILP to decide which modules to AC and how much memory to discard.
The objective is to minimize recomputation time.
The constraint is to ensure peak memory is under budget.
Args:
graph: graph representation of the model as a module submodule tree
where each node is a submodule with memory & runtime stats
memory_budget: memory budget in GiB
world_size: number of GPUs. In the case of FSDP, world_size will be
used to compute the amount of parameter and gradient memory on each rank
ac_units: a list of user-specified AC units.
fsdp_units: a list of FSDP units. AC units cannot be supermodules of FSDP units.
Returns:
Dict[str, float]: the optimal SAC solution, mapping from module fqn to
the percentage of activation memory to **discard**
float: the recomputation time of the optimal SAC solution
int: upper bound on the peak memory of the optimal SAC solution.
note that value of -1 means that the ILP solver failed to find a solution.
"""
num_nodes = len(graph.nodes)
M = 10**2 # note: numerical issue may occur if M is too big
MEM_MULTIPLIER = 2**30
# Create a MILP problem
prob = LpProblem("SAC", LpMinimize)
# Create decision variables
# y_i: indicator for if module i is AC'ed
y = LpVariable.matrix("y", list(range(num_nodes)), 0, 1, LpInteger)
# r_i: percentage of discarded activation memory
r = LpVariable.matrix("r", list(range(num_nodes)), 0, 1)
# d_i: discarded activation memory for module i
d = LpVariable.matrix("d", list(range(num_nodes)), 0)
# a_i: total activation memory at module i
a = LpVariable.matrix("a", list(range(num_nodes)), 0)
# m_i: memory at module i, combining parameters, gradients, and activations
m = LpVariable.matrix("m", list(range(num_nodes)), 0)
# rcp_i: percentage of recomputation time
rcp = LpVariable.matrix("rcp", list(range(num_nodes)), 0)
# rct_i: recomputation time for module i (in ms)
rct = LpVariable.matrix("rct", list(range(num_nodes)), 0)
# max_m: peak memory
max_m = LpVariable("max_m", 0)
# Add constraints
# [Constraint] User specified AC units
if ac_units:
ac_units_set = set(ac_units)
for i in range(num_nodes):
if graph.nodes[i]["fqn"] not in ac_units_set:
prob += y[i] == 0
# [Constraint] AC units cannot be supmodules of user specified FSDP units
if fsdp_units:
for i in range(num_nodes):
if any(
is_submodule(fsdp_unit, graph.nodes[i]["fqn"])
for fsdp_unit in fsdp_units
):
prob += y[i] == 0
# [Constraint] No nested AC units
for i in range(num_nodes):
for j in range(i + 1, num_nodes):
if graph.ad_matrix[i][j] == 1:
prob += y[i] + y[j] <= 1
# [Constraint] Do not AC leaf modules
for i in range(num_nodes):
if graph.nodes[i]["is_leaf"]:
prob += y[i] == 0
# [Constraint] Express amount of discarded activation memory
for i in range(num_nodes):
# There are two measures for activation memory: ACM and IA
# 1. IA is the activation memory saved when not using AC
# 2. ACM is the total activation memory, including those
# that are not typically saved when not using AC
# Note: ACM >= IA
if (not graph.nodes[i]["is_leaf"]) and graph.nodes[i][
"sac_memory"
] < graph.nodes[i]["act_fw_per_module"]:
logger.warning("For module {%s}: ", graph.nodes[i]["fqn"])
logger.warning(
"activation memory from memory tracker is {%d},",
graph.nodes[i]["act_fw_per_module"],
)
logger.warning(
"activation memory from SAC estimator is {%d}.",
graph.nodes[i]["sac_memory"],
)
logger.warning("Something is wrong. Please check!")
logger.warning("Overriding the latter with the former.")
graph.nodes[i]["sac_memory"] = graph.nodes[i]["act_fw_per_module"]
ACM_i = graph.nodes[i]["sac_memory"] / MEM_MULTIPLIER
IA_i = graph.nodes[i]["act_fw_per_module"] / MEM_MULTIPLIER
prob += d[i] == ACM_i * r[i] - (ACM_i - IA_i) * y[i]
# [Constraint] Ensure correctness of r_i
# There are two parts to its correctness
# 1. r_i > 0 only if y_i == 1 (discard only if it is an AC unit)
# 2. r_i needs to be large enough to cover the difference between
# ACM and IA. Otherwise, we are not saving any memory
for i in range(num_nodes):
prob += y[i] >= r[i]
if graph.nodes[i]["is_leaf"]:
continue
ACM_i = graph.nodes[i]["sac_memory"] / MEM_MULTIPLIER
IA_i = graph.nodes[i]["act_fw_per_module"] / MEM_MULTIPLIER
prob += r[i] >= (ACM_i - IA_i) / ACM_i * y[i]
# [Constraint] Express total activation memory in the backward pass
for i in range(num_nodes):
AG_i = graph.nodes[i]["act_grad_per_module"] / MEM_MULTIPLIER
TA_i = graph.nodes[i]["act_total"] / MEM_MULTIPLIER
# related to discarded amount of memory
pos = graph.nodes[i]["pos_fw_post_order"]
coeff = [0] * num_nodes
for p in range(pos):
j = graph.name2node[graph.fw_post_order[p]]["index"]
coeff[j] = 1
prob += a[i] == TA_i + AG_i - lpDot(coeff, d)
# [Constraint] Express the total amount of memory at each module
# Note that unsharded parameters and gradients are not included here
P_1 = graph.nodes[0]["param_per_module"] / MEM_MULTIPLIER
for i in range(num_nodes):
TG_i = graph.nodes[i]["grad_total"] / MEM_MULTIPLIER
prob += m[i] == a[i] + (P_1 + TG_i) / world_size
# [Constraint] Express peak memory
for i in range(num_nodes):
prob += max_m >= m[i]
# [Constraint] Express percentage of recomputation time
for i in range(num_nodes):
for s in range(graph.nodes[i]["n_segments"]):
slope = graph.nodes[i]["slopes"][s]
intercept = graph.nodes[i]["intercepts"][s]
prob += rcp[i] >= slope * r[i] + intercept
# [Constraint] Express recomputation time
# rct_i = (rcp_i * ACT_i) if y_i == 1 else 0
for i in range(num_nodes):
ACT_i = graph.nodes[i]["sac_runtime"]
prob += rct[i] <= M * y[i]
prob += rct[i] <= ACT_i * rcp[i]
prob += rct[i] >= ACT_i * rcp[i] - M * (1 - y[i])
# [Constraint] Peak memory should be below budget
prob += max_m <= memory_budget
# Set Objeictive
prob += lpSum(rct)
# Solve
solver = PULP_CBC_CMD(gapRel=0.05, timeLimit=180, msg=0)
status = prob.solve(solver)
# If solver fails, print status and return empty solution
if status != 1:
logger.error("Solver failed to find a solution: %s", LpStatus[status])
return {}, 0, -1
# Gather and return solution if optimal solution is found
ac_decisions = {}
for i in range(num_nodes):
if round(y[i].varValue) == 1:
ac_decisions[graph.nodes[i]["fqn"]] = round(r[i].varValue, 4)
recomputation_time = round(value(prob.objective), 2)
peak_mem = round(max_m.varValue * MEM_MULTIPLIER)
return ac_decisions, recomputation_time, peak_mem
class SACDecision(IntEnum):
RECOMPUTE = 0
SAVE = 1
def get_optimal_checkpointing_policy_per_module(
sac_stats: SACStats, memory_budget: float
) -> list[int]:
"""
This is adapted from --
https://github.com/facebookresearch/xformers/blob/c6c0ac31f1b08542a0bc27278c6ed10f825f6963/xformers/checkpoint.py#L375
Given the SACStats of a module, including list of operators, their memory, runtimes, and metadata,
decide via MILP an optimal set of operators to checkpoint under a given ``memory_budget``.
Args:
sac_stats: the SACStats object of the module
memory_budget: a float between zero and one
Returns:
List[int]: the decision whether each operator should be saved (1) or recomptued (0).
"""
if not (0 <= memory_budget <= 1):
raise ValueError(
f"`memory_budget` must be a float between 0 and 1. Got {memory_budget}."
)
num_ops = len(sac_stats.func_names)
# Create a MILP problem
prob = LpProblem("SAC-per-module", LpMaximize)
# Create decision variables
# x[i] = 1 means the i-th operator should be saved, otherwise it should be recomputed
x = LpVariable.matrix("x", list(range(num_ops)), 0, 1, LpInteger)
# Add constraints
# [Constraint] random ops should be saved if ``force_store_random`` is True
# otherwise, random ops should either be all recomputed or all saved
if sac_stats.force_store_random:
for i in sac_stats.rand_ops:
prob += x[i] == SACDecision.SAVE.value
else:
for i1, i2 in zip(sac_stats.rand_ops[:-1], sac_stats.rand_ops[1:]):
prob += x[i1] == x[i2]
# [Constraint] view-like ops should always be recomputed
for i in sac_stats.view_like_ops:
prob += x[i] == SACDecision.RECOMPUTE.value
# [Constraint] inplace ops should always be done in conjunction with its parent op
for op, op_parent in sac_stats.inplace_ops:
if op != op_parent:
prob += x[op] == x[op_parent]
else:
prob += x[op] == SACDecision.SAVE.value
# [Constraint] saved memory should be under the ``memory_budget``
max_memory = math.ceil(memory_budget * sum(sac_stats.memory))
prob += lpDot(x, sac_stats.memory) <= max_memory
# [Objective] minimize recomputation time, note the ILP is a maximization problem
# because x[i] == 1 means the op is saved (not recomputed), and thus recomputation
# time is sum(sac_stats.runtimes) - lpDot(x, sac_stats.runtimes)
prob += lpDot(x, sac_stats.runtimes)
# Solve
solver = PULP_CBC_CMD(gapRel=0.05, timeLimit=10, msg=0)
status = prob.solve(solver)
# If solver fails, print status and return empty solution
if status != 1:
logger.error("Solver failed to find a solution: %s", LpStatus[status])
return []
# Gather and return solution if optimal solution is found
return [round(x[i].varValue) for i in range(num_ops)]
@@ -0,0 +1 @@
from .join import Join, Joinable, JoinHook
@@ -0,0 +1,313 @@
# mypy: allow-untyped-defs
from abc import ABC, abstractmethod
from collections.abc import Callable, Iterator
from enum import auto, Enum
from functools import partial
from typing import Any
import torch
import torch.nn as nn
from torch.autograd.graph import save_on_cpu
from torch.distributed.utils import _pack_kwargs, _replace_by_prefix, _unpack_kwargs
from torch.utils._typing_utils import copy_method_params
from torch.utils.checkpoint import checkpoint as torch_utils_checkpoint
_CHECKPOINT_WRAPPED_MODULE = "_checkpoint_wrapped_module"
_CHECKPOINT_PREFIX = _CHECKPOINT_WRAPPED_MODULE + "."
class CheckpointImpl(Enum):
REENTRANT = auto()
NO_REENTRANT = auto()
class ActivationWrapper(torch.nn.Module, ABC):
"""
Base class for Activation Checkpoint and Activation Offload.
Not meant to be instantiated directly.
"""
def __init__(self, mod):
super().__init__()
self._checkpoint_wrapped_module = mod
# state_dict post hook to remove prefix to allow loading into a
# non-checkpoint wrapped module.
self._register_state_dict_hook(self._post_state_dict_hook)
# load_state_dict pre-hook to allow loading back into
# checkpoint-wrapped module.
self.register_load_state_dict_pre_hook(self._pre_load_state_dict_hook)
@abstractmethod
def forward(self, *args, **kwargs):
raise ValueError("Subclasses should implement forward().")
def __getattr__(self, name: str) -> Any:
"""Forward missing attributes to wrapped module."""
try:
return super().__getattr__(name) # defer to nn.Module's logic
except AttributeError:
return getattr(self._checkpoint_wrapped_module, name)
def __getitem__(self, key: int) -> Any:
"""Forward indexing calls in case the module is a nn.Sequential."""
return self._checkpoint_wrapped_module.__getitem__(key) # type: ignore[operator]
@copy_method_params(torch.nn.Module.named_parameters)
def named_parameters(
self,
*args,
**kwargs,
) -> Iterator[tuple[str, torch.nn.Parameter]]:
"""
Override :meth:`named_parameters()` to intercept parameter names.
remove all occurrences of ``_CHECKPOINT_PREFIX``.
"""
for param_name, param in super().named_parameters(*args, **kwargs):
yield param_name.replace(_CHECKPOINT_PREFIX, ""), param
@staticmethod
def _post_state_dict_hook(
module: nn.Module,
state_dict: dict[str, Any],
prefix: str,
*args: Any,
) -> dict[str, Any]:
"""
_post_state_dict_hook() is called after the state_dict() of this FSDP module is executed.
For ``checkpoint_wrapper``, it will strip checkpoint-wrapped module prefix,
so that this module can be loaded into non-checkpointed modules.
It would still be able to be loaded into checkpoint-wrapped modules as this class,
adds the prefix back before loading the state_dict.
"""
_replace_by_prefix(state_dict, f"{prefix}{_CHECKPOINT_PREFIX}", prefix)
return state_dict
@staticmethod
def _pre_load_state_dict_hook(
module: nn.Module,
state_dict: dict[str, Any],
prefix: str,
*args: Any,
) -> None:
"""
``_pre_state_dict_hook` is called before ``self._load_from_state_dict()`` is called.
For ``checkpoint_wrapper``, it will add back the module
prefix so that non-checkpointed modules can be loaded into
checkpoint_wrapper modules properly.
"""
_replace_by_prefix(state_dict, prefix, prefix + f"{_CHECKPOINT_PREFIX}")
class OffloadWrapper(ActivationWrapper):
def forward(self, *args, **kwargs):
with save_on_cpu(pin_memory=True):
return self._checkpoint_wrapped_module(*args, **kwargs)
class CheckpointWrapper(ActivationWrapper):
"""
An ``nn.Module`` that wraps another ``nn.Module`` with checkpointing.
Note that this module is not meant to be used directly but instead,
it is to be used through the ``checkpoint_wrapper`` function.
"""
def __init__(
self,
mod: torch.nn.Module,
checkpoint_impl: CheckpointImpl = CheckpointImpl.NO_REENTRANT,
checkpoint_fn=None,
**checkpoint_fn_kwargs,
):
super().__init__(mod)
self.checkpoint_impl = checkpoint_impl
if checkpoint_fn is None:
# use torch.utils.checkpoint
self.checkpoint_fn = partial(
torch_utils_checkpoint,
use_reentrant=(self.checkpoint_impl == CheckpointImpl.REENTRANT),
**checkpoint_fn_kwargs,
)
else:
# Construct user-specified checkpoint function.
self.checkpoint_fn = partial(
checkpoint_fn,
**checkpoint_fn_kwargs,
)
def forward(self, *args, **kwargs):
# Support keyword arguments for reentrant checkpoint. Note that this
# only works if user has specified self.checkpoint_impl and is not
# using their own custom checkpoint_fn.
if self.checkpoint_impl == CheckpointImpl.REENTRANT and kwargs != {}:
# Pack the args and kwargs
flat_args, kwarg_keys = _pack_kwargs(*args, **kwargs)
# Function that only takes (packed) args, but can unpack them
# into the original args and kwargs for the checkpointed
# function, and runs that function.
def my_function(*inputs):
# unpack back into args and kwargs
unpacked_args, unpacked_kwargs = _unpack_kwargs(inputs, kwarg_keys)
# run original module
return self._checkpoint_wrapped_module(
*unpacked_args, **unpacked_kwargs
)
# Pass the function that only takes packed args into reentrant
# checkpoint API.
return self.checkpoint_fn( # type: ignore[misc]
my_function,
*flat_args,
)
else:
return self.checkpoint_fn( # type: ignore[misc]
self._checkpoint_wrapped_module, *args, **kwargs
)
def offload_wrapper(module: torch.nn.Module) -> torch.nn.Module:
"""
Wrap a module for activation offloading to CPU.
Offloads intermediate activations to the CPU for modules wrapped with this function.
Wrappers with activation offload can be composed with ones that do recomputation-based
checkpoint to trade off increased compute versus increased CPU
memory usage and additional H2D transfers.
Usage::
offloaded_module = offload_wrapper(module)
outputs = checkpointed_module(inputs)
Args:
module (nn.Module):
The module to be wrapped
Returns:
(nn.Module):
Wrapped module
"""
return OffloadWrapper(module)
def checkpoint_wrapper(
module: torch.nn.Module,
checkpoint_impl: CheckpointImpl = CheckpointImpl.NO_REENTRANT,
checkpoint_fn=None,
**checkpoint_fn_kwargs,
) -> torch.nn.Module:
"""
Wrap a module for activation checkpointing.
If the module is wrapped with this function, all subsequent calls to the module will,
automatically perform checkpointing without the user having to explicitly call ``checkpoint`` function.
Usage::
checkpointed_module = checkpoint_wrapper(module)
outputs = checkpointed_module(inputs)
Args:
module (nn.Module):
The module to be wrapped
checkpoint_impl (Optional[CheckpointImpl]):
The checkpointing implementation to use. Note that this will only
be passed into the ``torch.utils.checkpoint.checkpoint``
implementation, and is ignored if a custom ``checkpoint_fn`` is
specified. Note that for implementations using reentrant checkpoint
from ``torch.utils.checkpoint``, keyword arguments will only be
supported if ``checkpoint_impl`` is passed as ``CheckpointImpl.REENTRANT`.
checkpoint_fn (Optional[Callable]):
Functional checkpoint implementation to use. If this is specified,
it will be used over the default ``torch.utils.checkpoint.checkpoint``
implementation and the `checkpoint_impl` argument will be ignored.
**checkpoint_fn_kwargs: (Dict[str, Any]): Keyword arguments to pass into `checkpoint_fn`.
Returns:
(nn.Module):
Wrapped module
"""
return CheckpointWrapper(
module,
checkpoint_impl,
checkpoint_fn,
**checkpoint_fn_kwargs,
)
def apply_activation_checkpointing(
model,
checkpoint_wrapper_fn=checkpoint_wrapper,
check_fn=lambda _: True,
auto_wrap_policy: Callable[[nn.Module, bool, int], bool] | None = None,
):
"""
Apply :func:`checkpoint_wrapper` to modules within `model` based on a user-defined configuration.
For each module within `model`, the `check_fn` is used to decide
whether `module` should be wrapped with :func:`checkpoint_wrapper` or not.
Note::
This function modifies `model` in place and replaces appropriate layers with
their checkpoint-wrapped modules.
Note::
This function will not wrap the overall root module. If this is needed, please directly use
:func:`checkpoint_wrapper` or :func:`offload_wrapper`.
Usage::
model = nn.Sequential(
nn.Linear(10, 10), nn.Linear(10, 10), nn.Linear(10, 10)
)
check_fn = lambda l: isinstance(l, nn.Linear)
# checkpoint activations
apply_activation_checkpointing(model, checkpoint_wrapper_fn=checkpoint_wrapper, check_fn=check_fn)
# Or offload activations to CPU
apply_activation_checkpointing(model, checkpoint_wrapper_fn=offload_wrapper, check_fn=check_fn)
Args:
model (nn.Module):
The model whose submodules should be wrapped with activation checkpointing.
checkpoint_wrapper_fn (Optional[Callable[nn.Module]])
A ``Callable`` which will wrap modules
check_fn (Optional[Callable[nn.Module, nn.Module]])
A lambda function which will be passed each child submodule of ``model`` and returns
``True`` or ``False`` depending on whether the submodule should be wrapped.
auto_wrap_policy (Optional[Callable[[nn.Module, bool, int], bool]]): A policy to wrap model's
submodules with AC. Note that if this is specified, it takes precedence over ``check_fn``.
Returns: None (`model` is modified inplace)
"""
# TODO: Importing inside function to avoid circular import issue between FSDP and
# checkpoint_wrapper. This can be resolved once wrap() APIs are decoupled from FSDP code.
from torch.distributed.fsdp._wrap_utils import _construct_wrap_fn, _post_order_apply
from torch.distributed.fsdp.wrap import (
_Policy,
_recursive_wrap,
lambda_auto_wrap_policy,
)
policy = (
auto_wrap_policy
if auto_wrap_policy is not None
else partial(lambda_auto_wrap_policy, lambda_fn=check_fn)
)
if not callable(policy):
if not isinstance(policy, _Policy):
raise ValueError(
f"Expected {policy} to be callable or be a pre-defined wrap policy"
)
target_module_to_kwargs = policy._run_policy(
model, ignored_modules=set(), root_kwargs={}
)
wrap_fn = _construct_wrap_fn(
model, target_module_to_kwargs, checkpoint_wrapper_fn
)
_post_order_apply(model, wrap_fn)
return
_recursive_wrap(
module=model,
auto_wrap_policy=policy, # type: ignore[arg-type]
wrapper_cls=checkpoint_wrapper_fn,
ignored_modules=set(),
ignored_params=set(),
only_wrap_children=True,
)
@@ -0,0 +1,7 @@
from . import default_hooks as default
LOW_PRECISION_HOOKS = [
default.fp16_compress_hook,
default.bf16_compress_hook,
]
@@ -0,0 +1,191 @@
# mypy: allow-untyped-defs
import functools
import torch
import torch.distributed as dist
class DefaultState:
r"""
Stores state needed to perform the default communication algorithm within a communication hook.
Args:
process_group (ProcessGroup): The process group to be used.
"""
__slots__ = [
"process_group",
"world_size",
"gradient_predivide_factor",
"gradient_postdivide_factor",
]
def __init__(self, process_group: dist.ProcessGroup):
if process_group is None:
raise ValueError(f"Expected to pass in an explicit ProcessGroup to {self}.")
self.process_group = process_group
self.world_size = dist.get_world_size(process_group)
# Setting two factors `self.gradient_predivide_factor`
# and `self.gradient_postdivide_factor` to avoid underflow and overflow
self.gradient_predivide_factor = self._get_gradient_predivide_factor(
self.world_size
)
self.gradient_postdivide_factor = (
self.world_size / self.gradient_predivide_factor
)
@staticmethod
def _get_gradient_predivide_factor(world_size: int) -> float:
factor: int = 1
while world_size % factor == 0 and world_size / factor > factor:
factor *= 2
return float(factor)
class LowPrecisionState(DefaultState):
r"""
Stores state needed to perform gradient communication in a lower precision within a communication hook.
Communication hook will cast gradients back to the original
parameter precision specified by ``parameter_type`` (default: torch.float32).
Builds on top of the :class:`DefaultState`.
Args:
parameter_type (torch.dtype): The precision of model's parameters.
Required for a hook to cast gradients back to a parameter's precision.
"""
__slots__ = [
"parameter_type",
]
def __init__(
self,
process_group,
parameter_type=torch.float32,
):
super().__init__(process_group)
self.parameter_type = parameter_type
def _decompress(state: LowPrecisionState, grad: torch.Tensor):
"""
Casts gradients back to full parameter precision so that further computation happens in full precision.
"""
orig_grad_data = grad.data
grad.data = grad.data.to(state.parameter_type)
device_type = ""
try:
if grad.device.type == "privateuse1":
device_type = torch._C._get_privateuse1_backend_name()
else:
device_type = grad.device.type
backend = getattr(torch, device_type)
except AttributeError as e:
raise AttributeError(
f"Device {grad.device} does not have a \
corresponding backend registered as 'torch.device_type'."
) from e
# Don't let this memory get reused until after the transfer.
orig_grad_data.record_stream(backend.current_stream()) # type: ignore[arg-type]
def allreduce_hook(state: DefaultState, grad: torch.Tensor):
r"""
Implement the FSDP communication hook for ``all_reduce`` algorithm and a necessary pre- and post-division of gradients.
Args:
state (DefaultState): State information, configures pre- and post-division factors.
grad (torch.Tensor): A gradient for the local batch that needs to be communicated across ranks.
"""
# Average grad by pre-division factor. Together pre- and post-division factors
# lead to an overall averaging by world_size, required for consistency with PyTorch DDP.
# This is a two-step process to avoid potential underflow and overflow.
if state.gradient_predivide_factor > 1:
grad.div_(state.gradient_predivide_factor)
dist.all_reduce(grad, group=state.process_group)
# Average grad by post-division factor.
if state.gradient_postdivide_factor > 1:
grad.div_(state.gradient_postdivide_factor)
def reduce_scatter_hook(state: DefaultState, grad: torch.Tensor, output: torch.Tensor):
r"""
Implement the FSDP communication hook for ``reduce_scatter`` algorithm.
For sharded FSDP strategies and a necessary pre- and post-division of gradients.
Args:
state (DefaultState): State information, configures pre- and post-division factors.
grad (torch.Tensor): An unsharded gradient for the local batch that needs to be
communicated across ranks.
output (torch.Tensor): Stores a single shard of the gradient after ``reduce_scatter``.
"""
# Average grad by pre-division factor.
if state.gradient_predivide_factor > 1:
grad.div_(state.gradient_predivide_factor)
dist.reduce_scatter_tensor(output, grad, group=state.process_group)
# Average grad's shard by post-division factor.
if state.gradient_postdivide_factor > 1:
output.div_(state.gradient_postdivide_factor)
def _low_precision_hook(
prec: torch.dtype,
state: LowPrecisionState,
grad: torch.Tensor,
output: torch.Tensor | None,
):
if grad.dtype != prec:
grad.data = grad.data.to(prec)
if output is not None:
if output.dtype != prec:
output.data = output.data.to(prec)
reduce_scatter_hook(state, grad, output)
_decompress(state, output)
else:
allreduce_hook(state, grad)
_decompress(state, grad)
def fp16_compress_hook(
state: LowPrecisionState, grad: torch.Tensor, output: torch.Tensor | None = None
):
r"""
Implement FSDP communication hook for a simple gradient compression approach.
Casts ``grad`` to half-precision floating-point format (``torch.float16``).
It also averages gradients by ``world_size`` in two steps: first it pre-divides gradients by a
``state.gradient_predivide_factor``, and after a communication step (``all_reduce`` or ``reduce_scatter``)
gradients are averaged by a ``state.gradient_postdivide_factor``.
Once post-division is done, compressed gradients are casted back to parameters' precision.
Args:
state (LowPrecisionState): State information, configures pre- and post-division factors, parameters' precision.
grad (torch.Tensor): A gradient for the local batch that needs to be communicated across ranks in a lower precision.
output (torch.Tensor): Stores a single shard of the gradient after ``reduce_scatter``.
"""
fp16_hook = functools.partial(_low_precision_hook, torch.float16)
return fp16_hook(state, grad, output)
def bf16_compress_hook(
state: LowPrecisionState, grad: torch.Tensor, output: torch.Tensor | None = None
):
r"""
Implement FSDP communication hook for a simple gradient compression approach .
Casts ``grad`` to half-precision floating-point format.
It also averages gradients by ``world_size`` in two steps: first it pre-divides gradients by a
``state.gradient_predivide_factor``, and after a communication step (``all_reduce`` or ``reduce_scatter``)
gradients are averaged by a ``state.gradient_postdivide_factor``.
Once post-division is done, compressed gradients are casted back to parameters' precision.
Args:
state (LowPrecisionState): State information, configures pre- and post-division factors, parameters' precision.
grad (torch.Tensor): A gradient for the local batch that needs to be communicated across ranks in a lower precision.
output (torch.Tensor): Stores a single shard of the gradient after ``reduce_scatter``.
"""
bf16_hook = functools.partial(_low_precision_hook, torch.bfloat16)
return bf16_hook(state, grad, output)
@@ -0,0 +1 @@
from .optimizer_overlap import _as_overlapped_optim
@@ -0,0 +1,96 @@
# mypy: allow-untyped-defs
import inspect
from abc import ABC, abstractmethod
from torch.distributed.algorithms.ddp_comm_hooks.default_hooks import allreduce_hook
from torch.distributed.algorithms.ddp_comm_hooks.optimizer_overlap_hooks import (
_hook_then_optimizer,
_OptimizerHookState,
)
from torch.distributed.fsdp import FullyShardedDataParallel
from torch.distributed.optim import as_functional_optim
from torch.nn.parallel import DistributedDataParallel
from torch.optim import Optimizer
# Contains the mappings between the regular and overlapped optimizer types.
_registered_overlapped_optims: dict[type, type] = {}
def register_overlapped(optim_cls):
def decorator(target_overlapped_optim_cls):
if target_overlapped_optim_cls in _registered_overlapped_optims:
raise ValueError(
f"{target_overlapped_optim_cls} already registered with optim_cls "
f"{_registered_overlapped_optims[optim_cls]} {optim_cls}, trying to"
f"re-register it for {optim_cls} is not supported."
)
_registered_overlapped_optims[optim_cls] = target_overlapped_optim_cls
return target_overlapped_optim_cls
return decorator
class OverlappedOptimizer(ABC):
def __init__(self, optim_cls: type) -> None:
"""
Initialize the OverlappedOptimizer.
Overlappedoptimizer is a base class that child classes can implement to
specify how different optimizers will register themselves with DDP.
"""
self.optim_cls = optim_cls
@abstractmethod
def register_ddp(self, ddp: DistributedDataParallel) -> None:
"""Registers the overlapped optimizer with DDP."""
raise NotImplementedError(
f"{self.__class__.__name__} does not support overlapped DDP."
)
@abstractmethod
def register_fsdp(self, fsdp: FullyShardedDataParallel) -> None:
"""Registers the overlapped optimizer with FSDP."""
raise NotImplementedError(
f"{self.__class__.__name__} does not support overlapped FSDP."
)
@register_overlapped(Optimizer)
class _OverlappedStandardOptimizer(OverlappedOptimizer):
"""Overlaps a regular ``Optimizer``."""
def __init__(self, optim_cls: type, params, *optim_args, **optim_kwargs) -> None:
super().__init__(optim_cls)
f_optim = as_functional_optim(self.optim_cls, *optim_args, **optim_kwargs)
self._opt_hook_state = _OptimizerHookState(f_optim, params)
def register_ddp(self, ddp_inst: DistributedDataParallel):
# NOTE: using a custom communication hook and fused optimizer is not
# yet supported.
ddp_inst.register_comm_hook( # type: ignore[operator]
None, # wrapped hook state
_hook_then_optimizer(allreduce_hook, self._opt_hook_state),
)
# TODO: register_fsdp once FSDP supports communication hook.
def register_fsdp(self, fsdp: FullyShardedDataParallel) -> None:
"""Register the overlapped optimizer with FSDP."""
raise NotImplementedError(
f"{self.__class__.__name__} does not support overlapped FSDP."
)
def _as_overlapped_optim(optim_cls: type, params, *args, **kwargs):
"""Return a new ``OverlappedOptimizer`` instance that supports ``optim_cls``."""
for clz in inspect.getmro(optim_cls):
try:
return _registered_overlapped_optims[clz](
optim_cls, params, *args, **kwargs
)
except KeyError:
pass
# Fallback to standard overlapped optimizer, which will raise errors if user
# is attempting to use an unsupported optimizer.
return _OverlappedStandardOptimizer(optim_cls, params, *args, **kwargs)
@@ -0,0 +1,151 @@
# mypy: allow-untyped-defs
import functools
from enum import Enum
import torch
import torch.distributed as dist
TORCH_HALF_MIN = torch.finfo(torch.float16).min
TORCH_HALF_MAX = torch.finfo(torch.float16).max
class DQuantType(Enum):
"""
Different quantization methods for auto_quantize API are identified here.
auto_quantize API currently supports fp16 and bfp16 methods.
"""
FP16 = ("fp16",)
BFP16 = "bfp16"
def __str__(self) -> str:
return self.value
def _fp32_to_fp16_with_clamp(tensor: torch.Tensor) -> torch.Tensor:
return torch.clamp(tensor, TORCH_HALF_MIN, TORCH_HALF_MAX).half()
def _quantize_tensor(tensor, qtype):
if not isinstance(tensor, torch.Tensor):
raise RuntimeError(
f"_quantize_tensor expecting torch.Tensor as input but found {type(tensor)}"
)
if qtype == DQuantType.FP16:
return _fp32_to_fp16_with_clamp(tensor)
elif qtype == DQuantType.BFP16:
return torch.ops.quantization._FloatToBfloat16Quantized(tensor)
else:
raise RuntimeError(f"Quantization type {qtype} is not supported")
def _quantize_tensor_list(tensor_list, qtype):
if not isinstance(tensor_list, list) or not all(
isinstance(p, torch.Tensor) for p in tensor_list
):
raise RuntimeError(
f"_quantize_tensor_list expecting list of torch.Tensor as input but found {type(tensor_list)}"
)
quantized_tensor_list = [_quantize_tensor(t, qtype) for t in tensor_list]
return quantized_tensor_list
def _dequantize_tensor(tensor, qtype, quant_loss=None):
if not isinstance(tensor, torch.Tensor):
raise RuntimeError(
f"_dequantize_tensor expecting torch.Tensor as input but found {type(tensor)}"
)
if qtype == DQuantType.FP16:
if tensor.dtype != torch.float16:
raise RuntimeError(
f"tensor dtype is {tensor.dtype} while expected to be FP16."
)
elif tensor.dtype == torch.float16 and quant_loss is None:
return tensor.float()
else:
# pyrefly: ignore [unsupported-operation]
return tensor.float() / quant_loss
elif qtype == DQuantType.BFP16:
if tensor.dtype != torch.float16:
raise RuntimeError(
f"tensor dtype is {tensor.dtype} while expected to be FP16."
)
else:
return torch.ops.quantization._Bfloat16QuantizedToFloat(tensor)
else:
raise RuntimeError(f"Quantization type {qtype} is not supported")
def _dequantize_tensor_list(tensor_list, qtype, quant_loss=None):
if not isinstance(tensor_list, list) or not all(
isinstance(p, torch.Tensor) for p in tensor_list
):
raise RuntimeError(
f"_dequantize_tensor_list expecting list of torch.Tensor as input but found {type(tensor_list)}"
)
dequantized_tensor_list = [_dequantize_tensor(t, qtype) for t in tensor_list]
return dequantized_tensor_list
def auto_quantize(func, qtype, quant_loss=None):
"""
Quantize the input tensors, choose the precision types, and pass other necessary arguments and then dequantizes the output.
Currently it only supports:
. FP16 and BFP16 quantization method supported for gloo and nccl backends
. all_gather, all_to_all collective ops
Note: BFP16 only supports 2D tensors.
Args:
func (Callable): A function representing collective operations.
qtype (QuantType): Quantization method
quant_loss (float, optional): This can be used to improve accuracy in the dequantization.
Returns:
(Callable): the same collective as func but enables automatic quantization/dequantization.
"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
group = kwargs.get("group")
async_op = kwargs.get("async_op", False)
if async_op is True:
raise RuntimeError("The async_op=True mode is not supported yet.")
if func is dist.all_gather:
tensors = args[0]
input_tensors = _quantize_tensor(args[1], qtype)
out_tensors = _quantize_tensor_list(tensors, qtype)
dist.all_gather(out_tensors, input_tensors, group=group, async_op=async_op)
for i, t in enumerate(
_dequantize_tensor_list(out_tensors, qtype, quant_loss=quant_loss)
):
tensors[i] = t
elif func is dist.all_to_all:
tensors = args[0]
input_tensors = _quantize_tensor_list(args[1], qtype)
out_tensors = _quantize_tensor_list(tensors, qtype)
dist.all_to_all(out_tensors, input_tensors, group=group, async_op=async_op)
for i, t in enumerate(
_dequantize_tensor_list(out_tensors, qtype, quant_loss=quant_loss)
):
tensors[i] = t
elif func is dist.all_to_all_single:
tensors = args[0]
out_splits = kwargs.get("out_splits")
in_splits = kwargs.get("in_splits")
# Quantizing the input/output tensor
input_tensors = _quantize_tensor(args[1], qtype)
out_tensors = _quantize_tensor(tensors, qtype)
dist.all_to_all_single(
out_tensors, input_tensors, out_splits, in_splits, group=group
)
for i, t in enumerate(
_dequantize_tensor(out_tensors, qtype, quant_loss=quant_loss)
):
tensors[i] = t
else:
raise RuntimeError(f"The collective op {func} is not supported yet")
return wrapper
@@ -0,0 +1,141 @@
# mypy: allow-untyped-defs
import sys
from enum import Enum
from functools import partial
# To suppress FutureWarning from partial since 3.13
if sys.version_info >= (3, 11):
# member was introduced in Python 3.11
from enum import member
def _enum_member(x):
return member(x)
else:
def _enum_member(x):
return x
import torch.distributed as dist
from . import (
debugging_hooks as debugging,
default_hooks as default,
optimizer_overlap_hooks as optimizer_overlap,
powerSGD_hook as powerSGD,
quantization_hooks as quantization,
)
__all__ = ["DDPCommHookType", "register_ddp_comm_hook"]
def _ddp_comm_hook_wrapper(comm_hook, model, state):
model.register_comm_hook(state, comm_hook)
def _powerSGD_comm_hook_wrapper(
comm_hook,
model,
state,
matrix_approximation_rank,
start_powerSGD_iter=1_000,
):
"""
Wrap PowerSGD communication hook.
To be consistent with the wrappers of other DDP comm hooks, the input state only needs to be a process group,
which will be wrapped up with other state info.
"""
powerSGD_state = powerSGD.PowerSGDState(
process_group=state,
matrix_approximation_rank=matrix_approximation_rank,
start_powerSGD_iter=start_powerSGD_iter,
)
model.register_comm_hook(powerSGD_state, comm_hook)
class DDPCommHookType(Enum):
"""
Enumerate ``ddp_comm_hooks`` and ``ddp_comm_hook_wrapper`` communucation hook types.
DDPCommHookType enumerates the hooks of ``torch.distributed.algorithms.ddp_comm_hooks``
as names and ``ddp_comm_hook_wrapper`` partials with hook specified. As an example,
you can register allreduce hook by
``DDPCommHookType.ALLREDUCE.value(model=model, state=process_group)``.
"""
ALLREDUCE = _enum_member(
partial(_ddp_comm_hook_wrapper, comm_hook=default.allreduce_hook)
)
FP16_COMPRESS = _enum_member(
partial(_ddp_comm_hook_wrapper, comm_hook=default.fp16_compress_hook)
)
BF16_COMPRESS = _enum_member(
partial(_ddp_comm_hook_wrapper, comm_hook=default.bf16_compress_hook)
)
QUANTIZE_PER_TENSOR = _enum_member(
partial(
_ddp_comm_hook_wrapper, comm_hook=quantization.quantization_pertensor_hook
)
)
QUANTIZE_PER_CHANNEL = _enum_member(
partial(
_ddp_comm_hook_wrapper, comm_hook=quantization.quantization_perchannel_hook
)
)
POWER_SGD = _enum_member(
partial(
_powerSGD_comm_hook_wrapper,
comm_hook=powerSGD.powerSGD_hook,
matrix_approximation_rank=1,
)
)
# Rank-2 PowerSGD can give a higher accuracy than the default rank-1 version,
# but it runs slower and consumes more memory.
POWER_SGD_RANK2 = _enum_member(
partial(
_powerSGD_comm_hook_wrapper,
comm_hook=powerSGD.powerSGD_hook,
matrix_approximation_rank=2,
)
)
# Batching can lead to a faster training at the cost of accuracy.
BATCHED_POWER_SGD = _enum_member(
partial(
_powerSGD_comm_hook_wrapper,
comm_hook=powerSGD.batched_powerSGD_hook,
matrix_approximation_rank=1,
)
)
BATCHED_POWER_SGD_RANK2 = _enum_member(
partial(
_powerSGD_comm_hook_wrapper,
comm_hook=powerSGD.batched_powerSGD_hook,
matrix_approximation_rank=2,
)
)
NOOP = _enum_member(
partial(
_ddp_comm_hook_wrapper,
comm_hook=debugging.noop_hook,
)
)
def register_ddp_comm_hook(comm_hook_type: DDPCommHookType, model, state=None):
"""
Register ``ddp_comm_hooks`` to DDP model.
Registers the hooks of ``torch.distributed.algorithms.ddp_comm_hooks``
to the DDP model. User can specify the type of hook as an enum
``DDPCommHookType`` type using ``comm_hook_type`` input. State input will
be passed to the model.
Uses Python comm hook implementations.
Example::
>>> # xdoctest: +SKIP
>>> register_ddp_comm_hook(DDPCommHookType.FP16_COMPRESS, model, state)
"""
comm_hook_type.value(model=model, state=state)
@@ -0,0 +1,464 @@
# mypy: allow-untyped-defs
import weakref
from collections.abc import Callable
from typing import Any
import torch
import torch.distributed as dist
from torch.distributed.optim import ZeroRedundancyOptimizer
from torch.distributed.optim.zero_redundancy_optimizer import _OverlapStatus
from torch.nn.parallel.distributed import DistributedDataParallel
__all__ = ["hook_with_zero_step", "hook_with_zero_step_interleaved"]
# Functional optimizers require passing a list of gradients to their `step()`
# method, and ZeRO requires a functional optimizer to overlap with DDP
# Passing a `None` instead of an actual gradient indicates to the optimizer
# to not update the corresponding parameter
_NO_PARAM_UPDATE: None = None
def _perform_local_step(
bucket: dist.GradBucket,
zero: ZeroRedundancyOptimizer,
rank: int,
):
r"""
Perform a local optimizer step using the gradients provided by ``bucket``.
Arguments:
bucket (dist.GradBucket): the bucket providing the gradients.
zero (ZeroRedundancyOptimizer): the :class:`ZeroRedundancyOptimizer`
instance to perform the :meth:`_local_step`.
rank (int): the calling process's rank.
.. warning::
This function assumes that appropriate synchronization has taken place
so that the bucket's gradients can be used.
"""
overlap_info = zero._overlap_info
bucket_index = bucket.index()
if len(zero.optim.param_groups) != 1:
raise AssertionError(
"Overlapping DDP with ZeRO only supports a single parameter group"
)
# Construct the `gradients` input for the local optimizer step, which
# expects `None` in a list position to indicate that the corresponding
# parameter should not be updated
num_local_optim_params = len(zero.optim.param_groups[0]["params"])
gradients: list[torch.Tensor | None] = [
_NO_PARAM_UPDATE for _ in range(num_local_optim_params)
]
if bucket_index not in overlap_info.offsets:
raise AssertionError(
f"Bucket index {bucket_index} was not assigned to rank {rank}"
)
gradients_offset = overlap_info.offsets[bucket_index]
bucket_assignment = zero._bucket_assignments_per_rank[rank][bucket_index]
bucket_offset = bucket_assignment.offset
length = len(bucket_assignment.parameters)
bucket_gradients = bucket.gradients()[bucket_offset : bucket_offset + length]
for i, grad in enumerate(bucket_gradients):
gradients[gradients_offset + i] = grad
zero._local_step(gradients)
def _broadcast_bucket(
bucket_index: int,
zero: ZeroRedundancyOptimizer,
):
r"""
Broadcasts a bucket's parameters.
Arguments:
bucket_index (int): the index of the bucket corresponding to the
parameters to broadcast.
zero (ZeroRedundancyOptimizer): the calling process's
:class:`ZeroRedundancyOptimizer` instance.
"""
overlap_info = zero._overlap_info
if len(overlap_info.assigned_ranks_per_bucket) <= bucket_index:
raise AssertionError("`assigned_ranks_per_bucket` is not fully constructed")
# Sort to ensure the same ordering across ranks
assigned_ranks = sorted(overlap_info.assigned_ranks_per_bucket[bucket_index])
if len(assigned_ranks) <= 0:
raise AssertionError(
f"Bucket {bucket_index} should be assigned to at least one rank"
)
for assigned_rank in assigned_ranks:
bucket_assignments = zero._bucket_assignments_per_rank[assigned_rank]
if bucket_index in bucket_assignments:
send_tensor = bucket_assignments[bucket_index].tensor
if send_tensor is None:
raise AssertionError
overlap_info.broadcast_handles.append(
dist.broadcast(
send_tensor,
src=dist.get_global_rank(zero.process_group, assigned_rank),
group=zero.process_group,
async_op=True,
)
)
def _save_ddp_bucket_info(
bucket: dist.GradBucket,
zero: ZeroRedundancyOptimizer,
):
r"""
Save :class:`DistributedDataParallel` gradient bucket information for :class:`ZeroRedundancyOptimizer` instance ``zero``.
In particular, this function is meant to be called upon seeing each
gradient bucket to use when overlapping, meaning it does not save or compute any global
information.
Arguments:
bucket (dist.GradBucket): the current gradient bucket.
zero (ZeroRedundancyOptimizer): the calling process's
:class:`ZeroRedundancyOptimizer` instance.
"""
overlap_info = zero._overlap_info
bucket_params = bucket.parameters()
if len(bucket_params) <= 0:
raise AssertionError("Empty bucket")
# Save the parameters in the bucket
overlap_info.params_per_bucket.append(bucket_params)
if overlap_info.shard_buckets:
# Additionally save the bucket size for the assignment heuristic to use
bucket_size = 0
for param in bucket_params:
bucket_size += param.numel()
if overlap_info.total_size is None:
raise AssertionError
overlap_info.total_size += bucket_size
def _hook_with_zero_step_setup(
ddp_ref: weakref.ReferenceType,
zero: ZeroRedundancyOptimizer,
bucket: dist.GradBucket,
):
r"""
Encapsulate the setup logic for :func:`hook_with_zero_step` and :func:`hook_with_zero_step_interleaved`.
This means the logic to run in the
hook before the backward pass and optimizer step can actually be
overlapped. This is factored out since it is common to both
:func:`hook_with_zero_step` and :func:`hook_with_zero_step_interleaved`.
Arguments:
ddp_ref (weakref.ReferenceType): weak reference to the process's
:class:`DistributedDataParallel` instance.
zero (ZeroRedundancyOptimizer): the calling process's
:class:`ZeroRedundancyOptimizer` instance.
bucket (dist.GradBucket): the current gradient bucket.
"""
# Proceed as normal until the DDP buckets have been rebuilt
if not ddp_ref()._has_rebuilt_buckets: # type: ignore[union-attr]
if zero._overlap_info.status != _OverlapStatus.UNINITIALIZED:
raise AssertionError
return
bucket_index = bucket.index()
overlap_info = zero._overlap_info
if overlap_info.status == _OverlapStatus.UNINITIALIZED:
overlap_info.status = _OverlapStatus.DDP_HAS_REBUILT_BUCKETS
if overlap_info.status == _OverlapStatus.DDP_HAS_REBUILT_BUCKETS:
if bucket_index == 0 and len(overlap_info.params_per_bucket) > 0:
# This corresponds to the first bucket of the backward pass
# immediately after all information has been saved, so we
# can perform the delayed ZeRO initialization
zero._init_zero_for_overlap()
else:
# Once DDP buckets have been rebuilt but ZeRO has not been
# properly initialized yet, save the information needed
_save_ddp_bucket_info(bucket, zero)
def hook_with_zero_step(
hook: Callable[[Any, dist.GradBucket], torch.futures.Future],
ddp: DistributedDataParallel,
zero: ZeroRedundancyOptimizer,
shard_buckets: bool = False,
) -> Callable[[Any, dist.GradBucket], torch.futures.Future[torch.Tensor]]:
r"""
Modify ``hook`` to overlap :class:`ZeroRedundancyOptimizer` optimizer step with :class:`DistributedDataParallel` backward pass.
This approach overlaps the optimizer computation and communication with the
backward communication. In particular, the backward computation proceeds
contiguously, and the optimizer computation follows, overlapping with
outstanding backward communication (i.e. all-reduces) and possibly other
optimizer communication (i.e. broadcasts).
The optimizer step computation begins after the last gradient bucket computation has finished.
This approach may be preferred over :meth:`hook_with_zero_step_interleaved`
if communication is relatively slow compared to computation.
Arguments:
hook (Callable[[Any, dist.GradBucket], torch.futures.Future]): the hook
to modify.
ddp (DistributedDataParallel): the :class:`DistributedDataParallel`
instance to use.
zero (ZeroRedundancyOptimizer): the :class:`ZeroRedundancyOptimizer`
instance to use.
shard_buckets (bool): if ``True``, then the assignment of each
:class:`DistributedDataParallel` bucket is partitioned across
possibly multiple :class:`ZeroRedundancyOptimizer` instances (i.e.
across possibly multiple ranks) to approximate uniformity; if
``False``, then each bucket is wholly assigned to a single
:class:`ZeroRedundancyOptimizer` instance (i.e. to a single rank).
Returns:
The modified hook.
Raises:
ValueError: if ``zero`` was constructed with ``overlap_with_ddp=False``.
RuntimeError: if using any backend other than NCCL/HCCL since currently
Gloo may hang.
.. warning::
Given the way that overlapping :class:`DistributedDataParallel` with
:class:`ZeroRedundancyOptimizer` is currently implemented, the first
two or three training iterations do not perform parameter updates in
the optimizer step, depending on if ``static_graph=False`` or
``static_graph=True``, respectively. This is because it needs
information about the gradient bucketing strategy used by
:class:`DistributedDataParallel`, which is not finalized until the
second forward pass if ``static_graph=False`` or until the third
forward pass if ``static_graph=True``.
"""
if not zero._overlap_with_ddp:
raise ValueError(
"ZeroRedundancyOptimizer must be constructed with "
"`overlap_with_ddp=True` to use this hook properly"
)
ddp_ref = weakref.ref(ddp)
# NOTE: Gloo may hang with this overlapping approach; see https://github.com/pytorch/pytorch/issues/62300
pg = dist.get_backend(ddp_ref().process_group) # type: ignore[union-attr]
if pg == dist.Backend.GLOO:
raise RuntimeError(
"Gloo backend using Overlapping DDP with ZeRO may meet hangs"
)
if shard_buckets:
zero._overlap_info.shard_buckets = True
zero._overlap_info.total_size = 0
def hook_with_zero_fn(
state: Any,
bucket: dist.GradBucket,
) -> torch.futures.Future[torch.Tensor]:
r"""
Return :class:`Future` that runs the optimizer step if this corresponds to the last gradient bucket.
Perform equivalent of :class:`ZeroRedundancyOptimizer` :meth:`step` if ``bucket`` is last gradient bucket.
The function gives a gradient bucket tensor and
performs additional computation on the iteration that
the :class:`DistributedDataParallel` buckets are rebuilt to collect
information used to implement the modified hook.
Arguments:
state (Any): any state for the hook.
bucket (dist.GradBucket): the :class:`DistributedDataParallel`
gradient bucket.
"""
fut = hook(state, bucket)
_hook_with_zero_step_setup(ddp_ref, zero, bucket)
if zero._overlap_info.status != _OverlapStatus.INITIALIZED:
return fut
overlap_info = zero._overlap_info
bucket_index = bucket.index()
rank = zero.global_rank
if overlap_info.status != _OverlapStatus.INITIALIZED:
raise AssertionError
if len(overlap_info.assigned_ranks_per_bucket) <= bucket_index:
raise AssertionError("`assigned_ranks_per_bucket` is not fully constructed")
assigned_to_bucket = (
rank in overlap_info.assigned_ranks_per_bucket[bucket_index]
)
# Save the bucket reference and all-reduce future for the final bucket
if assigned_to_bucket:
overlap_info.bucket_index_to_bucket[bucket_index] = bucket
overlap_info.bucket_index_to_future[bucket_index] = fut
# Check that buckets are indexed incrementally starting from 0 in the
# order of their autograd hooks firing
if len(overlap_info.bucket_indices_seen) > 0:
if overlap_info.bucket_indices_seen[-1] != bucket_index - 1:
raise AssertionError("Bucket indices are not in incremental order")
else:
if bucket_index != 0:
raise AssertionError("Bucket indices do not start from 0")
overlap_info.bucket_indices_seen.append(bucket_index)
# Directly return the future without any optimizer computation if this
# is not the last bucket
num_buckets = len(overlap_info.params_per_bucket)
is_last_bucket = bucket_index == num_buckets - 1
if not is_last_bucket:
return fut
# Perform partial optimizer step on all buckets after the final
# bucket has been computed
# NOTE: This should not be chained as a callback to the last bucket's
# all-reduce future since that would add synchronization that delays
# all optimizer computation to wait for that last all-reduce
for bucket_index in range(num_buckets):
assigned_ranks = overlap_info.assigned_ranks_per_bucket[bucket_index]
if rank in assigned_ranks:
# Wait on the bucket's all-reduce future to ensure correct
# gradients
if bucket_index not in overlap_info.bucket_index_to_future:
raise AssertionError(
f"All-reduce future for bucket {bucket_index} not saved "
f"on rank {rank}"
)
allreduce_future = overlap_info.bucket_index_to_future[bucket_index]
allreduce_future.wait()
# Perform the partial optimizer step
curr_bucket = overlap_info.bucket_index_to_bucket[bucket_index]
_perform_local_step(curr_bucket, zero, rank)
_broadcast_bucket(bucket_index, zero)
# Ensure that all parameter updates are finished before the
# next forward pass
overlap_info.wait_for_broadcasts()
overlap_info.clear_per_iter_info()
return fut
return hook_with_zero_fn
def hook_with_zero_step_interleaved(
hook: Callable[[Any, dist.GradBucket], torch.futures.Future],
ddp: DistributedDataParallel,
zero: ZeroRedundancyOptimizer,
shard_buckets: bool = False,
) -> Callable[[Any, dist.GradBucket], torch.futures.Future[torch.Tensor]]:
r"""
Modify ``hook`` to overlap :class:`ZeroRedundancyOptimizer` optimizer step with :class:`DistributedDataParallel` backward pass
This approach overlaps the optimizer computation and communication with the
backward computation and communication. In particular, once a bucket's
gradients have been computed, the optimizer computation using those
gradients is launched (though the actual computation must wait for the
bucket's all-reduce to complete). This yields an interleaving of all-
reduces and broadcasts in the communication stream.
This approach may be preferred over :meth:`hook_with_zero_step` if
communication is relatively fast compared to computation.
Arguments:
hook (Any * dist.GradBucket -> torch.futures.Future): the hook to
modify.
ddp (DistributedDataParallel): the :class:`DistributedDataParallel`
instance to use.
zero (ZeroRedundancyOptimizer): the :class:`ZeroRedundancyOptimizer`
instance to use.
shard_buckets (bool): if ``True``, then the assignment of each
:class:`DistributedDataParallel` bucket is partitioned across
possibly multiple :class:`ZeroRedundancyOptimizer` instances (i.e.
across possibly multiple ranks) to approximate uniformity; if
``False``, then each bucket is wholly assigned to a single
:class:`ZeroRedundancyOptimizer` instance (i.e. to a single rank).
Returns:
The modified hook.
Raises:
ValueError: if ``zero`` was constructed with ``overlap_with_ddp=False``.
RuntimeError: if using any backend other than NCCL since currently
Gloo may hang.
.. warning::
Given the way that overlapping :class:`DistributedDataParallel` with
:class:`ZeroRedundancyOptimizer` is currently implemented, the first
two or three training iterations do not perform parameter updates in
the optimizer step, depending on if ``static_graph=False`` or
``static_graph=True``, respectively. This is because it needs
information about the gradient bucketing strategy used by
:class:`DistributedDataParallel`, which is not finalized until the
second forward pass if ``static_graph=False`` or until the third
forward pass if ``static_graph=True``.
"""
if not zero._overlap_with_ddp:
raise ValueError(
"ZeroRedundancyOptimizer must be constructed with "
"`overlap_with_ddp=True` to use this hook properly"
)
ddp_ref = weakref.ref(ddp)
# NOTE: Gloo may hang with this overlapping approach; see https://github.com/pytorch/pytorch/issues/62300
pg = dist.get_backend(ddp_ref().process_group) # type: ignore[union-attr]
if pg == dist.Backend.GLOO:
raise RuntimeError(
"Gloo backend using Overlapping DDP with ZeRO may meet hangs"
)
if shard_buckets:
zero._overlap_info.shard_buckets = True
zero._overlap_info.total_size = 0
def hook_with_zero_interleaved_fn(
state,
bucket: dist.GradBucket,
) -> torch.futures.Future[torch.Tensor]:
r"""
Return :class:`Future` that gives gradient bucket tensor and performs partial :class:`ZeroRedundancyOptimizer` :meth:`step`.
This function uses the gradients in gradient in given bucket to perform a partial
:class:`ZeroRedundancyOptimizer` :meth:`step`
Arguments:
state: any state for the hook.
bucket (dist.GradBucket): the :class:`DistributedDataParallel`
gradient bucket.
"""
fut = hook(state, bucket)
_hook_with_zero_step_setup(ddp_ref, zero, bucket)
if zero._overlap_info.status != _OverlapStatus.INITIALIZED:
return fut
def zero_step(fut: torch.futures.Future) -> torch.Tensor:
r"""
Perform partial :class:`ZeroRedundancyOptimizer` :meth:`step` using gradients in the :class:`DistributedDataParallel`.
Returns:
A :class:`torch.Tensor` representing the contents of the
gradient bucket.
"""
overlap_info = zero._overlap_info
bucket_index = bucket.index()
rank = zero.global_rank
assigned_ranks = overlap_info.assigned_ranks_per_bucket[bucket_index]
overlap_info.bucket_indices_seen.append(bucket_index)
if rank in assigned_ranks:
_perform_local_step(bucket, zero, rank)
_broadcast_bucket(bucket_index, zero)
num_buckets = len(overlap_info.params_per_bucket)
if len(overlap_info.bucket_indices_seen) == num_buckets:
# Ensure that all parameter updates are finished before the
# next forward pass
overlap_info.wait_for_broadcasts()
overlap_info.clear_per_iter_info()
return bucket.buffer()
return fut.then(zero_step)
return hook_with_zero_interleaved_fn
@@ -0,0 +1,29 @@
from typing import Any
import torch
from torch.distributed import GradBucket
__all__ = ["noop_hook"]
def noop_hook(_: Any, bucket: GradBucket) -> torch.futures.Future[torch.Tensor]:
"""
Return a future that wraps the input, so it is a no-op that does not incur any communication overheads.
This hook should **only** be used for headroom analysis of allreduce optimization,
instead of the normal gradient synchronization.
For example, if only less than 10% speedup of training time can be observed after this hook is registered,
it usually implies that allreduce is not a performance bottleneck for this case.
Such instrumentation can be particularly useful
if GPU traces cannot be easily retrieved or the trace analysis is complicated
some factors such as the overlap between allreduce and computation or the desynchronization across ranks.
Example::
>>> # xdoctest: +SKIP
>>> ddp_model.register_comm_hook(None, noop_hook)
"""
fut: torch.futures.Future[torch.Tensor] = torch.futures.Future()
fut.set_result(bucket.buffer())
return fut
@@ -0,0 +1,211 @@
# mypy: allow-untyped-defs
from collections.abc import Callable
from typing import Any, cast
import torch
import torch.distributed as dist
__all__ = [
"allreduce_hook",
"fp16_compress_hook",
"bf16_compress_hook",
"fp16_compress_wrapper",
"bf16_compress_wrapper",
]
def _allreduce_fut(
process_group: dist.ProcessGroup, tensor: torch.Tensor
) -> torch.futures.Future[torch.Tensor]:
"""Average the input gradient tensor by allreduce and returns a future."""
group_to_use = process_group if process_group is not None else dist.group.WORLD
# Apply the division first to avoid overflow, especially for FP16.
# pyrefly: ignore [missing-attribute]
tensor.div_(group_to_use.size())
return (
dist.all_reduce(tensor, group=group_to_use, async_op=True)
.get_future()
.then(lambda fut: fut.value()[0])
)
def allreduce_hook(
process_group: dist.ProcessGroup, bucket: dist.GradBucket
) -> torch.futures.Future[torch.Tensor]:
"""
Call ``allreduce`` using ``GradBucket`` tensors.
Once gradient tensors are aggregated across all workers, its ``then``
callback takes the mean and returns the result.
If user registers this DDP communication hook,
DDP results is expected to be same as the case where no hook was registered.
Hence, this won't change behavior of DDP and user can use this as a reference
or modify this hook to log useful information or any other purposes while
unaffecting DDP behavior.
Example::
>>> # xdoctest: +SKIP
>>> ddp_model.register_comm_hook(process_group, allreduce_hook)
"""
return _allreduce_fut(process_group, bucket.buffer())
def _compress_hook(
dtype: torch.dtype,
process_group: dist.ProcessGroup,
bucket: dist.GradBucket,
) -> torch.futures.Future[torch.Tensor]:
group_to_use = process_group if process_group is not None else dist.group.WORLD
# pyrefly: ignore [missing-attribute]
world_size = group_to_use.size()
buffer = (
cast(tuple[torch.Tensor, ...], bucket)[0]
if isinstance(bucket, tuple)
else bucket.buffer()
)
compressed_tensor = buffer.to(dtype).div_(world_size)
def decompress(fut):
decompressed_tensor = buffer
# Decompress in place to reduce the peak memory.
# See: https://github.com/pytorch/pytorch/issues/45968
value = fut if isinstance(fut, torch.Tensor) else fut.value()[0]
decompressed_tensor.copy_(value)
return decompressed_tensor
if torch.compiler.is_compiling():
grad = dist._functional_collectives.all_reduce(
compressed_tensor,
"sum",
# pyrefly: ignore [bad-argument-type]
group_to_use,
)
return decompress(grad)
else:
fut = dist.all_reduce(
compressed_tensor, group=group_to_use, async_op=True
).get_future()
return fut.then(decompress)
def fp16_compress_hook(
process_group: dist.ProcessGroup,
bucket: dist.GradBucket,
) -> torch.futures.Future[torch.Tensor]:
"""
Compress by casting ``GradBucket`` to ``torch.float16`` divided by process group size.
This DDP communication hook implements a simple gradient compression
approach that casts ``GradBucket`` tensor to half-precision floating-point format (``torch.float16``)
and then divides it by the process group size.
It allreduces those ``float16`` gradient tensors. Once compressed gradient
tensors are allreduced, the chained callback ``decompress`` casts it back to the input data type (such as ``float32``).
Example::
>>> # xdoctest: +SKIP
>>> ddp_model.register_comm_hook(process_group, fp16_compress_hook)
"""
return _compress_hook(torch.float16, process_group, bucket)
def bf16_compress_hook(
process_group: dist.ProcessGroup,
bucket: dist.GradBucket,
) -> torch.futures.Future[torch.Tensor]:
"""
Warning: This API is experimental, and it requires NCCL version later than 2.9.6.
This DDP communication hook implements a simple gradient compression
approach that casts ``GradBucket`` tensor to half-precision
`Brain floating point format <https://en.wikipedia.org/wiki/Bfloat16_floating-point_format>`_ (``torch.bfloat16``)
and then divides it by the process group size.
It allreduces those ``bfloat16`` gradient tensors. Once compressed gradient
tensors are allreduced, the chained callback ``decompress`` casts it back to the input data type (such as ``float32``).
Example::
>>> # xdoctest: +SKIP
>>> ddp_model.register_comm_hook(process_group, bf16_compress_hook)
"""
return _compress_hook(torch.bfloat16, process_group, bucket)
def fp16_compress_wrapper(
hook: Callable[[Any, dist.GradBucket], torch.futures.Future[torch.Tensor]],
) -> Callable[[Any, dist.GradBucket], torch.futures.Future[torch.Tensor]]:
"""
Cast input tensor to ``torch.float16``, cast result of hook back to input dtype.
This wrapper casts the input gradient tensor of a given DDP communication hook to half-precision
floating point format (``torch.float16``), and casts the resulting tensor of the given hook back to
the input data type, such as ``float32``.
Therefore, ``fp16_compress_hook`` is equivalent to ``fp16_compress_wrapper(allreduce_hook)``.
Example::
>>> # xdoctest: +SKIP
>>> state = PowerSGDState(process_group=process_group, matrix_approximation_rank=1, start_powerSGD_iter=10)
>>> ddp_model.register_comm_hook(state, fp16_compress_wrapper(powerSGD_hook))
"""
def fp16_compress_wrapper_hook(
hook_state, bucket: dist.GradBucket
) -> torch.futures.Future[torch.Tensor]:
# Cast bucket tensor to FP16.
bucket.set_buffer(bucket.buffer().to(torch.float16))
fut = hook(hook_state, bucket)
def decompress(fut):
decompressed_tensor = bucket.buffer()
# Decompress in place to reduce the peak memory.
# See: https://github.com/pytorch/pytorch/issues/45968
decompressed_tensor.copy_(fut.value())
return decompressed_tensor
# Decompress after hook has run.
return fut.then(decompress)
return fp16_compress_wrapper_hook
def bf16_compress_wrapper(
hook: Callable[[Any, dist.GradBucket], torch.futures.Future[torch.Tensor]],
) -> Callable[[Any, dist.GradBucket], torch.futures.Future[torch.Tensor]]:
"""
Warning: This API is experimental, and it requires NCCL version later than 2.9.6.
This wrapper casts the input gradient tensor of a given DDP communication hook to half-precision
`Brain floating point format <https://en.wikipedia.org/wiki/Bfloat16_floating-point_format>`_ (``torch.bfloat16``),
and casts the resulting tensor of the given hook back to the input data type, such as ``float32``.
Therefore, ``bf16_compress_hook`` is equivalent to ``bf16_compress_wrapper(allreduce_hook)``.
Example::
>>> # xdoctest: +SKIP
>>> state = PowerSGDState(process_group=process_group, matrix_approximation_rank=1, start_powerSGD_iter=10)
>>> ddp_model.register_comm_hook(state, bf16_compress_wrapper(powerSGD_hook))
"""
def bf16_compress_wrapper_hook(
hook_state, bucket: dist.GradBucket
) -> torch.futures.Future[torch.Tensor]:
# Cast bucket tensor to BF16.
bucket.set_buffer(bucket.buffer().to(torch.bfloat16))
fut = hook(hook_state, bucket)
def decompress(fut):
decompressed_tensor = bucket.buffer()
# Decompress in place to reduce the peak memory.
# See: https://github.com/pytorch/pytorch/issues/45968
decompressed_tensor.copy_(fut.value())
return decompressed_tensor
# Decompress after hook has run.
return fut.then(decompress)
return bf16_compress_wrapper_hook
@@ -0,0 +1,86 @@
from dataclasses import dataclass
from typing import Any, no_type_check
import torch
import torch.distributed as dist
from torch.autograd import Variable
from torch.distributed.utils import _free_storage
@dataclass
class _AllreduceUpcastHookState:
"""
State to manage DDP mixed precision in backward / gradient communication.
This contains a weakref to the DDP module for access to reducer and process
group, and a stream to run parameter and gradient upcasts.
"""
ddp_weakref: Any
upcast_stream: torch.Stream
wait_for_stream_enqueued: bool = False
@no_type_check
def _reducer_allreduce_and_upcast_hook(
hook_state: _AllreduceUpcastHookState, bucket: dist.GradBucket
) -> torch.futures.Future[torch.Tensor]:
"""
Perform allreduce in precision ``reduce_dtype``, upcast to prepare for optimizer.
Performs allreduce in the reduced precision given by DDP's mixed precision
reduce_dtype, and upcasts parameters and gradients to fp32 in preparation
to run the optimizer.
"""
ddp_weakref = hook_state.ddp_weakref
reducer, process_group = ddp_weakref().reducer, ddp_weakref().process_group
# Cast bucket if different than param_dtype.
if (
ddp_weakref().mixed_precision.param_dtype
!= ddp_weakref().mixed_precision.reduce_dtype
):
# Cast bucket tensor to reduce_dtype
bucket.set_buffer(
bucket.buffer().to(ddp_weakref().mixed_precision.reduce_dtype)
)
fut = reducer._run_allreduce_hook(bucket)
ret_fut = torch.futures.Future()
stream = hook_state.upcast_stream
with stream:
fut.wait()
bucket.buffer().div_(process_group.size())
ret_fut.set_result(bucket.buffer())
# Upcast parameters and gradients so optimizer step can run in fp32.
for p in bucket.parameters():
p.data = p._fp_param
# free storage for mp param as it will be allocated again in next
# forward pass.
_free_storage(p._mp_param)
p.grad.data = p.grad.to(p.data.dtype)
# enqueue a callback to wait for this stream at end of backward
def wait_for_stream_cb():
torch.accelerator.current_stream().wait_stream(stream)
# Remove post-backward hooks since they are re-installed in next
# iteration, similar to FSDP.
# Parameters that don't require grad still needed to be casted since
# they may participate in computation. However, they would not be recast
# by hook above as they don't have a grad hook installed, so cast them
# back here.
for _, p in ddp_weakref().module.named_parameters():
if hasattr(p, "_ddp_mp_hook_state"):
p._ddp_mp_hook_state[1].remove()
delattr(p, "_ddp_mp_hook_state")
if not p.requires_grad and not hasattr(p, "_ddp_ignored"):
p.data = p._fp_param
# reset for next backward pass
hook_state.wait_for_stream_enqueued = False
if not hook_state.wait_for_stream_enqueued:
Variable._execution_engine.queue_callback(wait_for_stream_cb)
# mark that the callback is enqueued
hook_state.wait_for_stream_enqueued = True
return ret_fut
@@ -0,0 +1,163 @@
# mypy: allow-untyped-defs
from collections.abc import Callable
from dataclasses import dataclass
from functools import partial
from typing import Any, no_type_check
import torch
import torch.distributed as dist
from torch.autograd import Variable
__all__: list[str] = []
_FUNCTIONAL_OPTIM_STEP_METHOD_NAME = "step_param"
class _OptimizerHookState:
"""
Holds state for running optimizer in-line after DDP communication hook.
Currently contains only optimizer class which must have a method `step_param`.
"""
__slots__ = ["functional_optimizer", "params_to_optimize"]
def __init__(self, functional_optim, params=None):
self.functional_optimizer = functional_optim
self._check_valid_functional_optim()
self._set_params_to_optimize(params)
def _set_params_to_optimize(self, params):
if params is not None:
self.params_to_optimize = set(params)
def _check_valid_functional_optim(self):
if not hasattr(self.functional_optimizer, _FUNCTIONAL_OPTIM_STEP_METHOD_NAME):
raise ValueError(
f"Class {type(self.functional_optimizer)} must implement method "
f"{_FUNCTIONAL_OPTIM_STEP_METHOD_NAME}."
)
@dataclass
class _OptimInBackwardHookState:
optim_stream: torch.Stream
wait_for_optim_stream_enqueued: bool
@no_type_check
def _apply_optim_in_backward_hook(
gradient_is_bucket_view: bool,
) -> Callable[[Any, dist.GradBucket], torch.futures.Future[torch.Tensor]]:
r"""
Register hook to apply the optimizer in backward.
If torch.distributed.optim._apply_optimizer_in_backward is used to overlap
optimizer with backward pass, DDP will run the below hook to run optimizer
step for parameters after gradient communication has taken place.
"""
optim_in_bwd_state = _OptimInBackwardHookState(
optim_stream=torch.Stream(),
wait_for_optim_stream_enqueued=False,
)
def apply_optim_in_backward_hook(
hook_state: Any,
bucket: dist.GradBucket,
optim_stream_state,
) -> torch.futures.Future[torch.Tensor]:
# Run original hook
ddp_weakref = hook_state
ddp_inst = ddp_weakref()
reducer, process_group = ddp_inst.reducer, ddp_inst.process_group
fut = reducer._run_allreduce_hook(bucket)
optimizer_stream = optim_stream_state.optim_stream
with optimizer_stream:
fut.wait()
# Apply gradient division since C++ side only allreduces and does
# not average. TODO: (rohan-varma) the div factor may be different
# when running with join hook
bucket.buffer().div_(process_group.size())
model_params = bucket.parameters()
grads = bucket.gradients()
# TODO (rohan-varma): upcast as needed for DDP mixed precision,
# once optimizer in backward + DDP mixed precision is supported.
for p, g in zip(model_params, grads):
if hasattr(p, "_in_backward_optimizers"):
# Note: need to set grad to the bucket's grad, because
# running allreduce results in the bucket's grad being
# reduced, but not grad field.
if not gradient_is_bucket_view:
p.grad = g
for optim in p._in_backward_optimizers:
optim.step()
# Need to return a Future[Tensor] to obey comm hook API contract.
ret_fut = torch.futures.Future()
ret_fut.set_result(bucket.buffer())
# enqueue a callback to wait for this optimizer stream at the end of
# backward and set all DDP managed grads to None.
def wait_for_optim_stream_callback():
torch.accelerator.current_stream().wait_stream(
optim_stream_state.optim_stream
)
# Set DDP managed grads to None
for param in ddp_inst._get_data_parallel_params(ddp_inst.module):
if hasattr(param, "_in_backward_optimizers"):
param.grad = None
# reset for the next backwards pass
optim_stream_state.wait_for_optim_stream_enqueued = False
if not optim_stream_state.wait_for_optim_stream_enqueued:
Variable._execution_engine.queue_callback(wait_for_optim_stream_callback)
# mark that the callback is enqueued
optim_stream_state.wait_for_optim_stream_enqueued = True
return ret_fut
comm_hook = partial(
apply_optim_in_backward_hook, optim_stream_state=optim_in_bwd_state
)
# These are needed for DDP's logging of comm hooks
comm_hook.__name__ = apply_optim_in_backward_hook.__name__
comm_hook.__qualname__ = apply_optim_in_backward_hook.__qualname__
return comm_hook
def _hook_then_optimizer(
hook: Callable[[Any, dist.GradBucket], torch.futures.Future[torch.Tensor]],
optimizer_state: _OptimizerHookState,
) -> Callable[[Any, dist.GradBucket], torch.futures.Future[torch.Tensor]]:
r"""Run optimizer in a functional fashion after DDP communication hook."""
has_set_params = (
hasattr(optimizer_state, "params_to_optimize")
and optimizer_state.params_to_optimize is not None
)
def hook_then_optimizer_wrapper(
hook_state, bucket: dist.GradBucket
) -> torch.futures.Future[torch.Tensor]:
# Run original hook
fut = hook(hook_state, bucket)
def optimizer_step(fut):
gradient_tensors = bucket.gradients()
model_params = bucket.parameters()
for grad_tensor, model_param in zip(gradient_tensors, model_params):
if (
not has_set_params
or model_param in optimizer_state.params_to_optimize
):
optimizer_state.functional_optimizer.step_param(
model_param,
grad_tensor,
)
return bucket.buffer()
return fut.then(optimizer_step)
return hook_then_optimizer_wrapper
@@ -0,0 +1,124 @@
# mypy: allow-untyped-defs
import logging
import torch
import torch.distributed as dist
from . import default_hooks as default
logger = logging.getLogger(__name__)
class PostLocalSGDState:
r"""
Store state for all-reducing gradients globally until given step, then locally after.
Stores the state for all-reducing gradients globally using ``process_group`` until step ``start_localSGD_iter``,
and all-reducing gradients locally using ``subgroup`` afterwards.
If ``process_group`` is ``None``, the global process group will be used.
If ``subgroup`` is ``None``, the intra-node process group on each machine will be used.
Additionally, ``post_local_gradient_allreduce`` may be worth tuning,
because both true and false may give a faster convergence.
"""
__slots__ = [
"process_group",
"subgroup",
"start_localSGD_iter",
"post_local_gradient_allreduce",
"iter",
]
def __init__(
self,
process_group,
subgroup,
start_localSGD_iter,
post_local_gradient_allreduce=True,
):
"""Initialize state object with given parameters and log when localSGD start."""
logger.info(
"Local SGD will be started after %s iterations", start_localSGD_iter
)
# The group used for all-reducing gradients globally.
self.process_group = process_group
# The group used for all-reducing gradients locally.
self.subgroup = subgroup
self.start_localSGD_iter = start_localSGD_iter
# Allreduce gradients locally since iteration `start_localSGD_iter`.
# This may help with the convergence efficiency at the cost of relatively cheap intra-subgroup communication.
self.post_local_gradient_allreduce = post_local_gradient_allreduce
# Iteration/step in the training loop.
self.iter = 0
def maybe_increase_iter(self, bucket):
"""Track iterations and trigger log message at start of local SGD."""
# Since bucket 0 is the last bucket to allreduce in an iteration.
# Only increase `iter` when bucket 0 is processed.
if bucket.is_last():
self.iter += 1
if self.iter == self.start_localSGD_iter:
logger.info("Start to apply local SGD after %s iterations.", self.iter)
def post_localSGD_hook(
state: PostLocalSGDState, bucket: dist.GradBucket
) -> torch.futures.Future[torch.Tensor]:
"""
Run post-localSGD algorithm.
This DDP communication hook is used for running post-localSGD algorithm,
by combining with a model averaging component (e.g.,
:class:`~torch.distributed.algorithms.model_averaging.averagers.PeriodicModelAverager`)
that runs after the optimizer step.
Args:
state (PostLocalSGDState): State information to run post-localSGD.
Users mainly need to tune ``start_localSGD_iter`` to determine when to start local SGD.
bucket (dist.GradBucket): Bucket that stores a 1D flattened gradient tensor that batches multiple per-variable tensors.
Note that since DDP comm hook only supports single process single device mode,
only exactly one tensor is stored in this bucket.
Returns:
Future handler of the communication, which updates the gradients in place.
Example::
>>> # xdoctest: +SKIP
>>> state = PostLocalSGDState(process_group=process_group, subgroup=subgroup,
start_localSGD_iter=10)
>>> ddp_model.register_comm_hook(state, post_localSGD_hook)
>>> # Also need to establish a model averaging module and run model averaging after ``optimizer.step()``.
>>> # Please refer to the examples in ``torch.distributed.algorithms.model_averaging.averagers`` module.
"""
global_group_to_use = (
state.process_group if state.process_group is not None else dist.group.WORLD
)
# The input tensor is a flattened 1D tensor.
input_tensor = bucket.buffer()
# Run allreduce using `global_group_to_use` in the first `start_localSGD_iter` iterations.
if state.iter < state.start_localSGD_iter:
state.maybe_increase_iter(bucket)
return default._allreduce_fut(global_group_to_use, input_tensor) # type: ignore[arg-type]
# If `post_local_gradient_allreduce` is not set,
# then no gradient synchronization after the first `start_localSGD_iter` iterations.
if not state.post_local_gradient_allreduce:
fut: torch.futures.Future[torch.Tensor] = torch.futures.Future()
fut.set_result(input_tensor)
return fut
# Run allreduce using `subgroup` after the first `start_localSGD_iter` iterations.
# Note that by default, a separate subgroup for each node is created which
# causes an intra-node allreduce to be done at each training step.
# From this moment, model averaging should run after the optimizer step,
# to globally allreduce all the parameters.
if state.subgroup is None:
state.subgroup, _ = dist.new_subgroups()
return default._allreduce_fut(state.subgroup, input_tensor)
@@ -0,0 +1,865 @@
# mypy: allow-untyped-defs
import logging
import math
from collections import defaultdict
import torch
import torch.distributed as dist
from torch.distributed import distributed_c10d
from torch.utils._typing_utils import not_none
from . import default_hooks as default
__all__ = ["PowerSGDState", "powerSGD_hook", "batched_powerSGD_hook"]
logger = logging.getLogger(__name__)
def _orthogonalize(matrices, epsilon=0):
"""
Decide between Gram-Schmidt or QR factorization to orthogonalize a batch of matrices.
QR factorization doesn't work with half-precision, but it is usually faster with a rank > 2.
"""
if not (len(matrices.shape) == 3 and matrices.shape[2] <= matrices.shape[1]):
raise AssertionError
num_matrices = matrices.shape[0]
rank = matrices.shape[2]
dtype = matrices.dtype
if rank <= 2 or dtype in [torch.float16, torch.bfloat16]:
_orthogonalize_gram_schmidt(matrices, epsilon=epsilon)
else:
torch.linalg.qr(
matrices,
out=(
matrices,
torch.empty(
num_matrices, rank, rank, device=matrices.device, dtype=dtype
),
),
)
def _orthogonalize_gram_schmidt(matrices, epsilon=0):
"""
Apply Gram-Schmidt procedure to orthogonalize a batch of matrices.
If epsilon is 0, this is equivalent to `torch.qr(matrices, out=(matrices, _))`,
"""
num_cols = matrices.shape[2]
for i in range(num_cols):
# Normalize the i'th column.
col = matrices[:, :, i : i + 1]
# If no epsilon is added here, division by zero may be caused by vanishing gradients.
# This epsilon is not needed if the input batch of matrices covers the gradients of at least one entire layer
# in the neural network.
if epsilon == 0:
# Note that col ** 2 can underflow/overflow if we use FP16.
# May need to consider multiplying a scaling factor and dividing it later, or using bfloat16 instead.
try:
col /= torch.norm(col, dim=1, keepdim=True)
except ZeroDivisionError:
logger.error(
"The matrices to be orthogonalized has at least a column of all 0s. Please set a small value such as 1e-8 "
"as `orthogonalization_epsilon` in PowerSGD state."
)
# Recover the values from NaNs to 0s.
col.fill_(0.0)
else:
col /= torch.norm(col, dim=1, keepdim=True) + epsilon
# Project it on the rest and remove it.
if i + 1 < num_cols:
rest = matrices[:, :, i + 1 :]
rest -= torch.sum(col * rest, dim=1, keepdim=True) * col
def _should_compress(
num_rows, num_cols, matrix_approximation_rank, min_compression_rate
):
"""
Recommend if tensor given is worth compressing.
Returns a recommendation as to whether the 2D tensor described by the arguments is worth compressing,
including statistics describing the expected savings from compression. We consider a tensor worth
compressing when ``min_compression_rate`` < uncompressed size / compressed size, where
uncompressed size = ``num_rows`` * ``num_cols``,
and compressed size = (``num_rows`` + ``num_cols``) * ``matrix_approximation_rank``.
The result of this function is a tuple of the form (compression_recommendation, uncompressed_el_count, compressed_el_count), where:
compression_recommendation is true if the tensor is worth compressing, and false otherwise (see above);
uncompressed_el_count is the uncompressed element count, i.e. ``num_rows`` * ``num_cols``; and,
compress_el_count is the element count after compression, i.e. (``num_rows`` + ``num_cols``) * ``matrix_approximation_rank``.
""" # noqa: B950
uncompressed_size = num_rows * num_cols
compressed_size = (num_rows + num_cols) * matrix_approximation_rank
return (
compressed_size * min_compression_rate < uncompressed_size,
uncompressed_size,
compressed_size,
)
def _report_compression_stats(bucket, state):
"""Report compression stats at frequency of ``compression_stats_logging_frequency`` specified in PowerSGD state."""
if bucket.is_last() and state.iter >= state.next_stats_report:
stats = state.compression_stats()
logger.info(
"Compression stats: iter %s, total before compression %s, total after compression %s, "
"rate %s",
state.iter,
stats[1],
stats[2],
stats[0],
)
state.next_stats_report = state.iter + state.compression_stats_logging_frequency
class PowerSGDState:
r"""
Store both the algorithm's hyperparameters and internal state for all gradients during training.
Particularly, ``matrix_approximation_rank`` and ``start_powerSGD_iter`` are the main hyperparameters that should be tuned by the user.
For performance, we suggest to keep binary hyperparameters ``use_error_feedback`` and ``warm_start`` on.
1. ``matrix_approximation_rank`` controls the size of compressed low-rank tensors, which determines the compression rate. The lower the rank, the stronger the compression.
1.1. If ``matrix_approximation_rank`` is too low, the full model quality will need more training steps to reach or will never reach and yield loss in accuracy.
1.2. The increase of ``matrix_approximation_rank`` can substantially increase the computation costs of the compression, and the accuracy may not be further improved beyond a certain ``matrix_approximation_rank`` threshold.
To tune ``matrix_approximation_rank``, we suggest to start from 1 and increase by factors of 2 (like an exponential grid search, 1, 2, 4, ...), until a satisfactory accuracy is reached. Typically only a small value 1-4 is used. For some NLP tasks (as shown in Appendix D of the original paper), this value has been increased to 32.
2. ``start_powerSGD_iter`` defers PowerSGD compression until step ``start_powerSGD_iter``, and vanilla allreduce runs prior to step ``start_powerSGD_iter``. This hybrid scheme of **vanilla allreduce + PowerSGD** can effectively improve the accuracy, even a relatively small ``matrix_approximation_rank`` is used. This is because that, the beginning of training phase is usually very sensitive to inaccurate gradients, and compressing gradients too early may make the training quickly take a suboptimal trajectory, which can result in an irrecoverable impact on the accuracy.
To tune ``start_powerSGD_iter``, we suggest to start with 10% of total training steps, and increase it until a satisfactory accuracy is reached. If there is a warm-up stage in the training, ``start_powerSGD_iter`` typically should be no less than the number of warm-up steps.
3. ``min_compression_rate`` is the minimum compression rate required when a layer is compressed. Due to the computation overheads incurred by the compression, a tensor is worth compressing only if there can be sufficient saving in bandwidth, where ``(num_rows + num_cols) * matrix_approximation_rank * min_compression_rate < num_rows * num_cols``. If the specified compression rate threshold cannot be satisfied, the tensor will be directly allreduced without compression.
Compression statistics are logged every ``compression_stats_logging_frequency`` iterations once PowerSGD compression starts.
4. ``orthogonalization_epsilon`` can be a very small value (e.g., 1e-8) added to every normalized matrix column in orthogonalization step, to prevent div-by-zero error if any column has all 0s. If this can already be prevented (e.g., by batch normalization), an epsilon of 0 is recommended for accuracy.
5. ``batch_tensors_with_same_shape`` controls whether to compress and decompress tensors with same shape in a batched operation to achieve higher parallelism. Note that you should also increase the bucket size (i.e., ``bucket_cap_mb`` arg in DDP constructor) to make more same-shaped tensors appear in the same bucket, however this may reduce the overlap between computation and communication, and increase the memory footprint due to stacking the tensors of the same shape. Set to ``True`` if the compression / decompression computation is a bottleneck.
.. warning ::
If error feedback or warm-up is enabled, the minimum value of ``start_powerSGD_iter`` allowed in DDP is 2.
This is because there is another internal optimization that rebuilds buckets at iteration 1 in DDP,
and this can conflict with any tensor memorized before the rebuild process.
""" # noqa: B950
__slots__ = [
"process_group",
# The fields below are the hyperparameters that often need to be tuned by the user.
"matrix_approximation_rank",
"start_powerSGD_iter",
# The fields below are the hyperparameters that seldom need be tuned by the user.
"min_compression_rate",
"orthogonalization_epsilon",
# The fields below are the binary hyperparameters recommended to be turned on for performance and accuracy.
"use_error_feedback",
"warm_start",
"batch_tensors_with_same_shape",
# The fields below are internal state.
"rng",
"error_dict",
"p_memory_dict",
"q_memory_dict",
"iter",
# The fields below are for recording compression stats.
"total_numel_before_compression",
"total_numel_after_compression",
"compression_stats_logging_frequency",
"next_stats_report",
]
def __init__(
self,
process_group,
matrix_approximation_rank=1,
start_powerSGD_iter=1_000,
min_compression_rate=2,
use_error_feedback=True,
warm_start=True,
orthogonalization_epsilon=0,
random_seed=0,
compression_stats_logging_frequency=10_000,
batch_tensors_with_same_shape: bool = False,
):
logger.info(
"PowerSGD config: matrix_approximation_rank = %s; start_powerSGD_iter = %s; "
"min_compression_rate = %s; orthogonalization_epsilon = %s; use_error_feedback = %s; warm_start = %s; "
"random_seed = %s; compression_stats_logging_frequency = %s; batch_tensors_with_same_shape = %s",
matrix_approximation_rank,
start_powerSGD_iter,
min_compression_rate,
orthogonalization_epsilon,
use_error_feedback,
warm_start,
random_seed,
compression_stats_logging_frequency,
batch_tensors_with_same_shape,
)
self.process_group = process_group
self.matrix_approximation_rank = matrix_approximation_rank
# Deferring PowerSGD compression util step 'start_powerSGD_iter' can have two advantages:
# 1) It turns out that PowerSGD may lead to a non-trivial accuracy loss,
# even if the matrix approximation rank is increased to a large value.
# To mitigate the accuracy loss, a simple yet effective way is mixing vanilla allreduce
# (or a more conservative compression such as FP16 compression) with PowerSGD.
# 2) There is an internal optimization of rebuilding buckets process in DDP,
# in order to save the memory space.
# This step takes place after the first iteration.
# However, this means that the shape of input bucketized tensors is subject to change,
# which will complicate the implementations of error feedback and warm-up.
# Running vanilla allreduce in the first few iterations can avoid this complexity.
if (use_error_feedback or warm_start) and start_powerSGD_iter <= 1:
raise ValueError(
"Expect `start_powerSGD_iter` > 1 if `use_error_feedback` or `warm_start` is enabled, "
"because PowerSGD can only be applied after the first two iterations in DDP."
)
self.start_powerSGD_iter = start_powerSGD_iter
self.min_compression_rate = min_compression_rate
# Error feedback is usually crucial for both for convergence and generalization,
# because PowerSGD is a biased compressor,
# i.e., compressing and decompressing a random gradient does not yield the original in expectation.
# This mechanism requires a temporary copy of the input gradients,
# so it increases the peak memory consumption by the size of the gradient tensor.
# However, if the target matrices are known to be exactly low-ranked (instead of just low stable rank),
# sometimes it is possible to converge to the optima without error feedback.
# See: http://proceedings.mlr.press/v54/yurtsever17a/yurtsever17a.pdf
self.use_error_feedback = use_error_feedback
# Warm-start reuses P(s) and Q(s) from the previous iteration.
# This can improve the approximation quality and hence improve the accuracy.
# Additionally, by avoiding the initialization of these low-rank tensors at every step,
# this can also accelerate training.
# However, this is at the cost of extra memory.
self.warm_start = warm_start
# Can use a very small value to prevent div-by-zero error caused by orthogonalization of vanishing gradients.
self.orthogonalization_epsilon = orthogonalization_epsilon
# The purpose of this RNG is to generate different random seeds for initializing Q across iterations,
# but in the same order for all the DDP replicas.
# Different random seeds across iterations indicate different 'projections' of the gradients at different SGD steps.
# If the same random projection is used,
# there will be differences between the gradients that are never synchronized.
import numpy as np
self.rng = np.random.RandomState(random_seed)
# Since there is only a single state instance for all the input buckets,
# need to maintain a dictionary that maps each bucket index to the local error.
self.error_dict: dict[int, torch.Tensor] = {}
self.p_memory_dict: dict[int, torch.Tensor] = {}
self.q_memory_dict: dict[int, torch.Tensor] = {}
# Iteration/step in the training loop.
self.iter = 0
# Compression stats accumulators
self.total_numel_before_compression = 0
self.total_numel_after_compression = 0
# We'll report compression stats every 'compression_stats_logging_frequency' iterations
# Note that we always report compression stats at least once.
self.compression_stats_logging_frequency = max(
1, compression_stats_logging_frequency
)
self.next_stats_report = 0
# Batching tensors with same shape can increase parallelism in compression / decompression computation.
# This requires a larger bucket size to make more same-shaped tensor to appear in one bucket, however
# this may reduce the overlap between computation and communication, and increase the memory footprint
# due to stacking tensors.
# Turn on if compression / decompression computation is a bottleneck.
self.batch_tensors_with_same_shape = batch_tensors_with_same_shape
def __getstate__(self):
r"""
Return a ``Dict[str, Any]`` which will be pickled and saved.
``process_group`` is not serializable and excluded from
a returned state.
"""
logger.warning(
"NOTE: Process group is not serializable and excluded from a saved state."
)
return {
slot: getattr(self, slot)
for slot in self.__slots__
if slot != "process_group"
}
def __setstate__(self, state):
r"""
Take a provided ``state`` and set to this ``PowerSGDState`` instance.
``process_group`` is set to default.
"""
self.process_group = distributed_c10d._get_default_group()
logger.warning(
"NOTE: Process group will be set to a default group (i.e. the world size).\
If a different group is desired, please set `self.process_group` after PowerSGD state is loaded."
)
for slot, value in state.items():
setattr(self, slot, value)
def maybe_increase_iter(self, bucket):
"""Track iterations and trigger log message at start of local SGD."""
# Since bucket 0 is the last bucket to allreduce in an iteration.
# Only increase `iter` when bucket 0 is processed.
if bucket.is_last():
self.iter += 1
if self.iter == self.start_powerSGD_iter:
logger.info("Start to apply PowerSGD after %s iterations.", self.iter)
def compression_stats(self):
r"""
Return latest compression statistics as tuple.
Returns tuple of form (compress_rate, numel_before_compression, numel_after_compression) where:
compress_rate is the effective compression rate i.e. (number of elements before compression) / (number of elements after compression);
numel_before_compression is the total number of elements before compression was applied; and,
numel_after_compression is the total number of elements after compression was applied.
""" # noqa: B950
compress_rate = (
self.total_numel_before_compression / self.total_numel_after_compression
if self.total_numel_after_compression > 0
else 0
)
return (
compress_rate,
self.total_numel_before_compression,
self.total_numel_after_compression,
)
def powerSGD_hook(
state: PowerSGDState, bucket: dist.GradBucket
) -> torch.futures.Future[torch.Tensor]:
r"""
Implement PowerSGD algorithm.
This DDP communication hook implements PowerSGD gradient compression
algorithm described in the `paper <https://arxiv.org/abs/1905.13727>`_.
Once gradient tensors are aggregated across all workers, this hook applies
compression as follows:
1. Views the input flattened 1D gradient tensor as a list of per-parameter tensors, and divides all the tensors into two groups:
1.1 The tensors that should be compressed before allreduce, because the compression can give enough saving in bandwidth.
1.2 Rest of the tensors will be directly allreduced without compression, including all the vector tensors (for biases).
2. Handles uncompressed tensors:
2.1. Allocate contiguous memory for those uncompressed tensors, and allreduces all the uncompressed tensors as a batch, without compression;
2.2. Copies the individual uncompressed tensors from the contiguous memory back to the input tensor.
3. Handles the tensors that should be compressed by PowerSGD compression:
3.1. For each tensor M, creates two low-rank tensors P and Q for decomposing M,
such that M = PQ^T, where Q is initialized from a standard normal distribution and orthogonalized;
3.2. Computes each P in Ps, which is equal to MQ;
3.3. Allreduces Ps as a batch;
3.4. Orthogonalizes each P in Ps;
3.5. Computes each Q in Qs, which is approximately equal to M^TP;
3.6. Allreduces Qs as a batch;
3.7. Computes each M among all the compressed tensors, which is approximately equal to PQ^T.
Note that this communication hook enforces vanilla allreduce for the first ``state.start_powerSGD_iter`` iterations.
This not only gives the user more control over the tradeoff between speedup and accuracy,
but also helps abstract away some complexity of the internal optimization of DDP for future communication hook developers.
Args:
state (PowerSGDState): State information to configure the compression rate and support error feedback, warm start, etc.
To tune the compression configs, mainly need to tune ``matrix_approximation_rank``, ``start_powerSGD_iter``
and ``min_compression_rate``.
bucket (dist.GradBucket): Bucket that stores a 1D flattened gradient tensor that batches multiple per-variable tensors.
Note that since DDP comm hook only supports single process single device mode,
only exactly one tensor is stored in this bucket.
Returns:
Future handler of the communication, which updates the gradients in place.
Example::
>>> # xdoctest: +SKIP
>>> state = PowerSGDState(process_group=process_group, matrix_approximation_rank=1,
start_powerSGD_iter=10, min_compression_rate=0.5)
>>> ddp_model.register_comm_hook(state, powerSGD_hook)
""" # noqa: B950
process_group = state.process_group
group_to_use = (
process_group if process_group is not None else not_none(dist.group.WORLD)
)
world_size = group_to_use.size()
# The input tensor is a flattened 1D tensor.
input_tensor = bucket.buffer()
# Run vanilla allreduce in the first `start_powerSGD_iter` iterations.
if state.iter < state.start_powerSGD_iter:
state.maybe_increase_iter(bucket)
return default._allreduce_fut(group_to_use, input_tensor)
# Apply PowerSGD after `start_powerSGD_iter` iterations.
device = input_tensor.device
dtype = input_tensor.dtype
# Incorporate the error from the previous state into the gradients.
bucket_index = bucket.index()
input_tensor_cp = None
total_length = input_tensor.shape[0]
if state.use_error_feedback:
if bucket_index in state.error_dict:
input_tensor.add_(state.error_dict[bucket_index])
else:
logger.info(
"A zero tensor of length %s that represents local error is created.",
total_length,
)
state.error_dict[bucket_index] = torch.zeros(
total_length, device=device, dtype=dtype
)
# Keep a copy of the input tensor,
# so that we can compute the local error caused by compression later,
# by comparing this copy and the input tensor updated after decompression.
input_tensor_cp = input_tensor.detach().clone()
# Unflatten the input tensor into per-parameter tensors, for layer-wise compression.
tensors = bucket.gradients()
# Step I: Divide all the tensors into two groups,
# one will be compressed before allreduce and the other will be directly allreduced without compression.
tensors_to_compress, uncompressed_tensors = [], []
total_Ps_size = 0
total_Qs_size = 0
for tensor in tensors:
matrix = tensor.view(tensor.shape[0], -1)
n, m = matrix.shape
matrix_approximation_rank = min(n, m, state.matrix_approximation_rank)
compress_test = _should_compress(
n, m, matrix_approximation_rank, state.min_compression_rate
)
state.total_numel_before_compression += compress_test[1]
if compress_test[0]:
tensors_to_compress.append(matrix)
total_Ps_size += n * matrix_approximation_rank
total_Qs_size += m * matrix_approximation_rank
state.total_numel_after_compression += compress_test[2]
else:
uncompressed_tensors.append(tensor)
state.total_numel_after_compression += compress_test[1]
_report_compression_stats(bucket, state)
# Step II: Handle uncompressed tensors.
# Allocate contiguous memory for these tensors to allreduce efficiently.
uncompressed_tensors_memory = (
torch.cat([tensor.view(-1) for tensor in uncompressed_tensors])
if uncompressed_tensors
else torch.tensor([], device=device, dtype=dtype)
)
# Step III: Handle the tensors that should be compressed.
# Allocate contiguous memory for Ps and Qs to allreduce efficiently.
# If warm-start is enabled, reuse Ps and Qs from the previous iteration if possible.
# The memory spaces of Ps and Qs need to be allocated in the first iteration when PowerSGD is applied.
need_randomize_qs = False
if not state.warm_start or bucket_index not in state.p_memory_dict:
need_randomize_qs = True
# If warm-start is disabled, low-rank tensors will be initialized at every step.
# Only log this if warm-start to avoid spamming.
if state.warm_start:
logger.info(
"Allocating contiguous memory of length %s for Ps, and of length %s for Qs, respectively.",
total_Ps_size,
total_Qs_size,
)
state.p_memory_dict[bucket_index] = torch.empty(
total_Ps_size, device=device, dtype=dtype
)
state.q_memory_dict[bucket_index] = torch.empty(
total_Qs_size, device=device, dtype=dtype
)
# Batch tensors to compress by shape.
shape_to_tensors = defaultdict(list)
for tensor in tensors_to_compress:
shape_to_tensors[tensor.shape].append(tensor)
# This function decides whether to batch tensors with same shape or not according to the argument,
# so the following process could share the same code.
def maybe_batched_tensors_to_compress():
for tensors in shape_to_tensors.values():
if state.batch_tensors_with_same_shape:
batch_size = len(tensors)
if batch_size == 1:
# Use the original tensor to avoid copy.
yield tensors[0].unsqueeze(0)
else:
yield torch.stack(tensors)
else:
for tensor in tensors:
yield tensor.unsqueeze(0)
# Create Ps and Qs that point to the allocated memory.
tensors_to_compress = []
ps = []
qs = []
p_idx = 0
q_idx = 0
for tensor in maybe_batched_tensors_to_compress():
batch_size, n, m = tensor.shape
matrix_approximation_rank = min(n, m, state.matrix_approximation_rank)
tensors_to_compress.append(tensor)
ps.append(
state.p_memory_dict[bucket_index][
p_idx : p_idx + batch_size * n * matrix_approximation_rank
].view(batch_size, n, matrix_approximation_rank)
)
qs.append(
state.q_memory_dict[bucket_index][
q_idx : q_idx + batch_size * m * matrix_approximation_rank
].view(batch_size, m, matrix_approximation_rank)
)
p_idx += batch_size * n * matrix_approximation_rank
q_idx += batch_size * m * matrix_approximation_rank
# If warm-start is enabled, reuse Qs from the previous iteration if possible and skip filling random values.
# The exception is the first iteration when PowerSGD is applied.
if not need_randomize_qs:
for q in qs:
_orthogonalize(q, state.orthogonalization_epsilon)
else:
with torch.random.fork_rng(devices=[]):
# Fork this RNG to avoid changing the seed globally and affecting the random sampling anywhere else in the training.
# The seed makes sure that the initial random values are the same across all the DDP replicas.
# This seed should differ at every step.
# Since it is very slow to fork RNG state across all the CUDA devices,
# only fork on CPU and then move the generated tensor to the CUDA device (by overwriting q).
torch.manual_seed(state.rng.randint(1_000_000_000))
for q in qs:
q.copy_(
torch.randn(
*q.shape,
device="cpu",
dtype=dtype,
)
)
_orthogonalize(q, state.orthogonalization_epsilon)
# Compute Ps.
for tensor, q, p in zip(tensors_to_compress, qs, ps):
torch.bmm(tensor, q, out=p)
# This allreduce is only applied to uncompressed tensors,
# so it should have been kicked off before the above computation on the compressed tensors to hide more communication costs.
# However, this somehow requires a separate future chain at this time.
allreduce_contiguous_uncompressed_tensors_fut = dist.all_reduce(
uncompressed_tensors_memory, group=group_to_use, async_op=True
).get_future()
def unpack_uncompressed_tensors_and_allreduce_ps(fut):
uncompressed_tensors_memory = fut.value()[0].div_(world_size)
idx = 0
for tensor in uncompressed_tensors:
tensor.copy_(
uncompressed_tensors_memory[idx : idx + tensor.numel()].view_as(tensor)
)
idx += tensor.numel()
# Since these Ps will be orthogonalized later, no need to divide them by world size.
return (
dist.all_reduce(
state.p_memory_dict[bucket_index], group=group_to_use, async_op=True
)
.get_future()
.wait()[0]
)
def compute_qs(fut):
state.p_memory_dict[bucket_index] = fut.value()
for p in ps:
_orthogonalize(p, state.orthogonalization_epsilon)
# Compute Qs.
for tensor, p, q in zip(tensors_to_compress, ps, qs):
torch.bmm(tensor.transpose(1, 2), p, out=q)
# TODO: The above procedure does two matmul+allreduce steps per iteration --
# one left multiplication and one right multiplication.
# For warm-start, can take one such step at a time, and alternate between them.
# Allreduce Qs.
return (
dist.all_reduce(
state.q_memory_dict[bucket_index], group=group_to_use, async_op=True
)
.get_future()
.wait()[0]
)
def decompress(fut):
state.q_memory_dict[bucket_index] = fut.value().div_(world_size)
for p, q, tensor in zip(ps, qs, tensors_to_compress):
torch.bmm(p, q.transpose(1, 2), out=tensor)
# Copy batched tensors back to original buffer.
if state.batch_tensors_with_same_shape:
for tensor in tensors_to_compress:
if tensor.shape[0] == 1:
# Skip tensor with batch_size == 1 since itself is the original tensor.
continue
original_tensors = shape_to_tensors[tensor.shape[1:]]
for i, original_tensor in enumerate(original_tensors):
original_tensor.copy_(tensor[i])
if torch.cuda.is_available():
torch.cuda.synchronize(device)
if state.use_error_feedback:
# Memorize the local errors.
if input_tensor_cp is None:
raise AssertionError
state.error_dict[bucket_index] = input_tensor_cp - input_tensor
if not state.warm_start:
state.p_memory_dict.clear()
state.q_memory_dict.clear()
state.maybe_increase_iter(bucket)
return input_tensor
return (
allreduce_contiguous_uncompressed_tensors_fut.then(
unpack_uncompressed_tensors_and_allreduce_ps
)
.then(compute_qs)
.then(decompress)
)
def batched_powerSGD_hook(
state: PowerSGDState, bucket: dist.GradBucket
) -> torch.futures.Future[torch.Tensor]:
r"""
Implement simplified PowerSGD algorithm.
This DDP communication hook implements a simplified PowerSGD gradient compression
algorithm described in the `paper <https://arxiv.org/abs/1905.13727>`_.
This variant does not compress the gradients layer by layer,
but instead compresses the flattened input tensor that batches all the gradients.
Therefore, it is **faster** than :meth:`powerSGD_hook`,
but usually results in a **much lower accuracy**, unless ``matrix_approximation_rank`` is 1.
.. warning ::
Increasing ``matrix_approximation_rank`` here may not necessarily increase the accuracy,
because batching per-parameter tensors without column/row alignment can destroy low-rank structure.
Therefore, the user should always consider :meth:`powerSGD_hook` first,
and only consider this variant when a satisfactory accuracy can be achieved when ``matrix_approximation_rank`` is 1.
Once gradient tensors are aggregated across all workers, this hook applies
compression as follows:
1. Views the input flattened 1D gradient tensor as a square-shaped tensor M with 0 paddings;
2. Creates two low-rank tensors P and Q for decomposing M, such that M = PQ^T, where Q is initialized from a standard normal distribution and orthogonalized;
3. Computes P, which is equal to MQ;
4. Allreduces P;
5. Orthogonalizes P;
6. Computes Q, which is approximately equal to M^TP;
7. Allreduces Q;
8. Computes M, which is approximately equal to PQ^T.
9. Truncates the input tensor to the original length.
Note that this communication hook enforces vanilla allreduce for the first ``state.start_powerSGD_iter`` iterations.
This not only gives the user more control over the tradeoff between speedup and accuracy,
but also helps abstract away some complexity of the internal optimization of DDP for future communication hook developers.
Args:
state (PowerSGDState): State information to configure the compression rate and support error feedback, warm start, etc.
To tune the compression configs, mainly need to tune ``matrix_approximation_rank`` and ``start_powerSGD_iter``.
bucket (dist.GradBucket): Bucket that stores a 1D flattened gradient tensor that batches multiple per-variable tensors.
Note that since DDP comm hook only supports single process single device mode,
only exactly one tensor is stored in this bucket.
Returns:
Future handler of the communication, which updates the gradients in place.
Example::
>>> # xdoctest: +SKIP
>>> state = PowerSGDState(process_group=process_group, matrix_approximation_rank=1)
>>> ddp_model.register_comm_hook(state, batched_powerSGD_hook)
""" # noqa: B950
process_group = state.process_group
group_to_use = (
process_group if process_group is not None else not_none(dist.group.WORLD)
)
world_size = group_to_use.size()
# The input tensor is a flattened 1D tensor.
input_tensor = bucket.buffer()
# Run vanilla allreduce in the first `start_powerSGD_iter` iterations.
if state.iter < state.start_powerSGD_iter:
state.maybe_increase_iter(bucket)
return default._allreduce_fut(group_to_use, input_tensor)
# Apply PowerSGD after `start_powerSGD_iter` iterations.
device = input_tensor.device
total_length = input_tensor.shape[0]
state.total_numel_before_compression += total_length
# View the input tensor as a 2D square-shape tensor, and pad 0s if necessary.
square_side_length = math.ceil(math.sqrt(total_length))
state.total_numel_after_compression += (
square_side_length * state.matrix_approximation_rank * 2
)
padded_total_length = square_side_length**2
input_tensor.resize_(padded_total_length)
input_tensor[total_length:padded_total_length].fill_(0)
_report_compression_stats(bucket, state)
# Incorporate the error from the previous state into the gradients.
bucket_index = bucket.index()
input_tensor_cp = None
if state.use_error_feedback:
if bucket_index in state.error_dict:
input_tensor.add_(state.error_dict[bucket_index])
else:
logger.info(
"A zero tensor of length %s that represents local error is created.",
padded_total_length,
)
state.error_dict[bucket_index] = torch.zeros(
padded_total_length, device=device, dtype=input_tensor.dtype
)
# Keep a copy of the input tensor,
# so that we can compute the local error caused by compression later,
# by comparing this copy and the input tensor updated after decompression.
input_tensor_cp = input_tensor.detach().clone()
matrix = input_tensor.view(square_side_length, square_side_length)
# Reuse P and Q from the previous iteration if possible.
# The memory spaces of P and Q need to be allocated in the first iteration when PowerSGD is applied.
if not state.warm_start or bucket_index not in state.p_memory_dict:
# If warm-start is disabled, low-rank tensors will be initialized at every step.
# Only log this if warm-start to avoid spamming.
if state.warm_start:
logger.info(
"Initializing low-rank tensors P and Q, each of which has a shape of %s x %s.",
square_side_length,
state.matrix_approximation_rank,
)
def create_low_rank_tensor(fill_random_values, rng):
"""Return a low-rank 2D tensor of square_side_length * matrix_approximation_rank."""
if fill_random_values:
with torch.random.fork_rng(devices=[]):
# Fork this RNG to avoid changing the seed globally and affecting the random sampling
# anywhere else in the training.
# The seed makes sure that the initial random values are the same across all the DDP replicas.
# This seed should differ at every step.
# Since it is very slow to fork RNG state across all the CUDA devices,
# only fork on CPU and then move the generated tensor to the CUDA device.
torch.manual_seed(rng.randint(1_000_000_000))
return torch.randn(
square_side_length,
state.matrix_approximation_rank,
device="cpu",
dtype=input_tensor.dtype,
).to(device)
else:
return torch.empty(
square_side_length,
state.matrix_approximation_rank,
device=device,
dtype=input_tensor.dtype,
)
state.p_memory_dict[bucket_index] = create_low_rank_tensor(
fill_random_values=False, rng=state.rng
)
state.q_memory_dict[bucket_index] = create_low_rank_tensor(
fill_random_values=True, rng=state.rng
)
_orthogonalize(state.q_memory_dict[bucket_index])
torch.matmul(
matrix, state.q_memory_dict[bucket_index], out=state.p_memory_dict[bucket_index]
)
allreduce_p_fut = dist.all_reduce(
state.p_memory_dict[bucket_index], group=group_to_use, async_op=True
).get_future()
def compute_q(fut):
state.p_memory_dict[bucket_index] = fut.value()[0]
_orthogonalize(state.p_memory_dict[bucket_index])
torch.matmul(
matrix.t(),
state.p_memory_dict[bucket_index],
out=state.q_memory_dict[bucket_index],
)
# TODO: The above procedure does two matmul+allreduce steps per iteration --
# one left multiplication and one right multiplication.
# For warm-start, can take one such step at a time, and alternate between them.
return (
dist.all_reduce(
state.q_memory_dict[bucket_index], group=group_to_use, async_op=True
)
.get_future()
.wait()[0]
)
def decompress(fut):
state.q_memory_dict[bucket_index] = fut.value().div_(world_size)
torch.matmul(
state.p_memory_dict[bucket_index],
state.q_memory_dict[bucket_index].t(),
out=matrix,
)
if state.use_error_feedback:
# Memorize the local errors.
if input_tensor_cp is None:
raise AssertionError
state.error_dict[bucket_index] = input_tensor_cp - input_tensor
# Removing this seemingly unnecessary sync somehow may cause failures.
# See: https://github.com/pytorch/pytorch/pull/54838
if torch.cuda.is_available():
torch.cuda.synchronize(device)
if not state.warm_start:
state.p_memory_dict.clear()
state.q_memory_dict.clear()
ret = input_tensor.resize_(total_length)
state.maybe_increase_iter(bucket)
return ret
return allreduce_p_fut.then(compute_q).then(decompress)
@@ -0,0 +1,220 @@
# mypy: allow-untyped-defs
import torch
import torch.distributed as dist
from torch import nn
def _quantize_per_tensor_backend(x, scale, zero_point):
y = torch.round(x / scale) + zero_point
y = torch.clamp(y, 0, 255).to(torch.uint8)
return y
def _dequantize_per_tensor_backend(y, scale, zero_point):
x = scale * (y.to(torch.float32) - zero_point)
return x
def _quantize_per_channel_backend(x, scale, zero_point):
y = torch.zeros(x.size(), device=x.device)
for i in range(x.size()[0]):
y[i, :] = torch.round(x[i, :] / scale[i]) + zero_point[i]
y = torch.clamp(y, 0, 255).to(torch.uint8)
return y
def _dequantize_per_channel_backend(y, scale, zero_point):
y = y.to(torch.float32).to(y.device)
x = torch.zeros_like(y, device=y.device)
for i in range(x.size()[0]):
x[i, :] = scale[i] * (y[i, :] - zero_point[i])
return x
def _get_allgather_out_list(all_gather_in_list, world_size):
out_list = [
torch.zeros_like(
all_gather_in_list,
device=all_gather_in_list.device,
dtype=all_gather_in_list.dtype,
)
for _ in range(world_size)
]
return out_list
def quantization_pertensor_hook(
process_group: dist.ProcessGroup, bucket: dist.GradBucket
) -> torch.futures.Future[torch.Tensor]:
"""
Apply ``torch.quantize_per_tensor`` logic to DDP using ``allgather`` protocol.
Workers first allgather the scale and zero point of their own
``GradBucket`` prior to the quantization. After all workers have that information,
the first ``then`` callback called ``quantize_and_allgather`` quantizes worker's
own gradient tensor, and uses ``allgather`` to communicate these across all workers.
The final ``then`` callback called ``dequantize_and_aggregate``, dequantizes and
aggregates each quantized gradient tensor locally and returns the mean.
.. warning ::
This is experimental, and uses ``allgather`` protocol which is considerably slower than
``allreduce`` protocol. It works only with flattened grads.
Example::
>>> # xdoctest: +SKIP
>>> ddp_model.register_comm_hook(process_group, quantization_pertensor_hook)
"""
group_to_use = process_group if process_group is not None else dist.group.WORLD
rank = process_group.rank() if process_group is not None else dist.get_rank()
# pyrefly: ignore [missing-attribute]
world_size = group_to_use.size()
tensor = bucket.buffer()
myObserver = torch.ao.quantization.MinMaxObserver().to(tensor.device)
myObserver(tensor)
s, z = myObserver.calculate_qparams()
s_and_z = torch.FloatTensor([s, z]).to(tensor.device)
all_ranks_s_and_z = _get_allgather_out_list(s_and_z, world_size)
# First, allgather scale and zeros.
fut = dist.all_gather(
all_ranks_s_and_z, s_and_z, group=group_to_use, async_op=True
).get_future()
def quantize_and_allgather(fut):
# Store scale and zeros across all workers.
all_ranks_s_and_z = fut.wait()[0]
# All workers quantize their own ``GradBucket`` tensors.
quantized_tensor = _quantize_per_tensor_backend(
tensor, all_ranks_s_and_z[rank][0], all_ranks_s_and_z[rank][1]
)
# Allgather quantized tensors.
fut = dist.all_gather(
_get_allgather_out_list(quantized_tensor, world_size),
quantized_tensor,
group=group_to_use,
async_op=True,
).get_future()
return fut.wait()
def dequantize_and_aggregate(fut):
all_ranks_quantized_tensor = fut.wait()[0]
aggregated_dequantized_tensor = torch.zeros_like(
all_ranks_quantized_tensor[0], device=tensor.device, dtype=torch.float32
)
# Using previously allgathered scales and zeros, dequantize gradient tensors
# locally and then aggregate them.
for r, quantized_tensor in enumerate(all_ranks_quantized_tensor):
aggregated_dequantized_tensor += _dequantize_per_tensor_backend(
quantized_tensor, all_ranks_s_and_z[r][0], all_ranks_s_and_z[r][1]
)
return aggregated_dequantized_tensor / world_size
return fut.then(quantize_and_allgather).then(dequantize_and_aggregate)
def quantization_perchannel_hook(
process_group: dist.ProcessGroup, bucket: dist.GradBucket, bucket_size=512
) -> torch.futures.Future[torch.Tensor]:
"""
Apply``torch.quantize_per_channel`` logic to DDP using ``allgather`` protocol.
Compared to per-tensor, the main motivation of per-channel is
for considerably large tensors such as a tensor that contains 6 million
elements quantizing per a bucket size of 512 (or 128) elements may significantly
increase the resolution.
It first splits ``GradBucket`` tensor into multiple chunks (channels) of ``bucket_size``
elements. Then, workers allgather the scales and zero points of their own
``GradBucket`` prior to the quantization. After all workers have that information,
the first ``then`` callback called ``quantize_and_allgather`` quantizes worker's
own gradient tensor, and uses ``allgather`` to communicate these across all workers.
The final ``then`` callback called ``dequantize_and_aggregate``, dequantizes, flattens, and
aggregates each quantized gradient tensor locally and returns the mean.
.. warning ::
This is experimental, and uses ``allgather`` protocol which is considerably slower than
``allreduce`` protocol. It works only with flattened grads.
Example::
>>> # xdoctest: +SKIP
>>> ddp_model.register_comm_hook(process_group, quantization_perchannel_hook)
"""
group_to_use = process_group if process_group is not None else dist.group.WORLD
rank = process_group.rank() if process_group is not None else dist.get_rank()
# pyrefly: ignore [missing-attribute]
world_size = group_to_use.size()
tensor = bucket.buffer()
tensor_in_channels = (
nn.functional.pad(
input=tensor,
pad=(0, bucket_size - len(tensor) % bucket_size),
mode="constant",
value=0,
)
.view(-1, bucket_size)
.to(tensor.device)
)
myPerChannelObserver = torch.ao.quantization.PerChannelMinMaxObserver().to(
tensor.device
)
myPerChannelObserver(tensor_in_channels)
s_ch, z_ch = myPerChannelObserver.calculate_qparams()
s_and_z = torch.stack((s_ch, z_ch)).to(tensor.device)
all_ranks_s_and_z = _get_allgather_out_list(s_and_z, world_size)
# First, allgather scale and zeros.
fut = dist.all_gather(
all_ranks_s_and_z, s_and_z, group=group_to_use, async_op=True
).get_future()
def quantize_and_allgather(fut):
# Store scale and zeros across all workers.
all_ranks_s_and_z = fut.wait()[0]
# All workers quantize their corresponding ``GradBucket`` tensors.
quantized_tensor = _quantize_per_channel_backend(
tensor_in_channels,
all_ranks_s_and_z[rank, 0, :],
all_ranks_s_and_z[rank, 1, :],
)
# Allgather quantized tensors.
fut = dist.all_gather(
_get_allgather_out_list(quantized_tensor, world_size),
quantized_tensor,
group=group_to_use,
async_op=True,
).get_future()
return fut.wait()
def dequantize_and_aggregate(fut):
all_ranks_quantized_tensor = fut.wait()[0]
aggregated_dequantized_tensor = torch.zeros_like(
all_ranks_quantized_tensor[0], device=tensor.device, dtype=torch.float32
)
# Using previously allgathered scales and zeros, dequantize gradient tensors
# locally and then aggregate them.
for r, quantized_tensor in enumerate(all_ranks_quantized_tensor):
aggregated_dequantized_tensor += _dequantize_per_channel_backend(
quantized_tensor, all_ranks_s_and_z[r][0], all_ranks_s_and_z[r][1]
)
return (
torch.flatten(aggregated_dequantized_tensor).to(tensor.device)[
: tensor.size()[0]
]
/ world_size
)
return fut.then(quantize_and_allgather).then(dequantize_and_aggregate)
@@ -0,0 +1,352 @@
# mypy: allow-untyped-defs
import warnings
from abc import ABC, abstractmethod
from types import TracebackType
from typing import Any, NamedTuple
import torch
import torch.distributed as dist
__all__ = ["JoinHook", "Joinable", "Join"]
class JoinHook:
r"""
This defines a join hook, which provides two entry points in the join context manager.
Entry points : a main hook, which is called repeatedly while there exists a non-joined
process, and a post-hook, which is called once all processes have joined.
To implement a join hook for the generic join context manager, define a
class that inherits from :class:`JoinHook` and override ``main_hook()`` and
``post_hook()`` as appropriate.
"""
def main_hook(self) -> None:
r"""Call this hook while there exists a non-joined process to shadow collective communications in a training iteration.
Training iteration i.e., in one forward pass, backward pass, and optimizer step.
"""
def post_hook(self, is_last_joiner: bool) -> None:
r"""
Call hook after all processes have joined.
It is passed an additional ``bool`` argument ``is_last_joiner``, which indicates if the rank is one of the last to join.
Arguments:
is_last_joiner (bool): ``True`` if the rank is one of the last to
join; ``False`` otherwise.
"""
class Joinable(ABC):
r"""
This defines an abstract base class for joinable classes.
A joinable class
(inheriting from :class:`Joinable`) should implement :meth:`join_hook`,
which returns a :class:`JoinHook` instance, in addition to
:meth:`join_device` and :meth:`join_process_group` that return device and
process group information, respectively.
"""
@abstractmethod
def __init__(self) -> None:
super().__init__()
self._join_config = _JoinConfig.construct_disabled_join_config()
@abstractmethod
def join_hook(self, **kwargs) -> JoinHook:
r"""
Return a :class:`JoinHook` instance for the given :class:`Joinable`.
Arguments:
kwargs (dict): a :class:`dict` containing any keyword arguments
to modify the behavior of the join hook at run time; all
:class:`Joinable` instances sharing the same join context
manager are forwarded the same value for ``kwargs``.
"""
...
@property
@abstractmethod
def join_device(self) -> torch.device:
r"""Return the device from which to perform collective communications needed by the join context manager."""
...
@property
@abstractmethod
def join_process_group(self) -> Any:
r"""Returns the process group for the collective communications needed by the join context manager itself."""
...
class _JoinConfig(NamedTuple):
r"""This includes all fields needed from a :class:`Joinable` instance for the join context manager side."""
enable: bool
throw_on_early_termination: bool
is_first_joinable: bool
@staticmethod
def construct_disabled_join_config():
r"""Return a :class:`_JoinConfig` instance indicating that join-related logic should be disabled.
e.g. if the caller is not in a join context manager.
"""
return _JoinConfig(
enable=False, throw_on_early_termination=False, is_first_joinable=False
)
class Join:
r"""
This class defines the generic join context manager, which allows custom hooks to be called after a process joins.
These hooks should shadow the
collective communications of non-joined processes to prevent hanging and
erroring and to ensure algorithmic correctness. Refer to :class:`JoinHook`
for details about the hook definition.
.. warning::
The context manager requires each participating :class:`Joinable` to
call the method :meth:`notify_join_context()` before its own per-
iteration collective communications to ensure correctness.
.. warning::
The context manager requires that all ``process_group`` attributes in
the :class:`JoinHook` objects are the same. If there are multiple
:class:`JoinHook` objects, then the ``device`` of the first is used.
The process group and device information is used for checking for non-
joined processes and for notifying processes to throw an exception if
``throw_on_early_termination`` is enabled, both of which using an all-
reduce.
Arguments:
joinables (List[Joinable]): a list of the participating
:class:`Joinable` s; their hooks are iterated over in the given
order.
enable (bool): a flag enabling uneven input detection; setting to
``False`` disables the context manager's functionality and should
only be set when the user knows the inputs will not be uneven
(default: ``True``).
throw_on_early_termination (bool): a flag controlling whether to throw an
exception upon detecting uneven inputs (default: ``False``).
Example::
>>> import os
>>> import torch
>>> import torch.distributed as dist
>>> import torch.multiprocessing as mp
>>> # xdoctest: +SKIP
>>> import torch.nn.parallel.DistributedDataParallel as DDP
>>> import torch.distributed.optim.ZeroRedundancyOptimizer as ZeRO
>>> from torch.distributed.algorithms.join import Join
>>>
>>> # On each spawned worker
>>> def worker(rank):
>>> dist.init_process_group("nccl", rank=rank, world_size=2)
>>> model = DDP(torch.nn.Linear(1, 1).to(rank), device_ids=[rank])
>>> optim = ZeRO(model.parameters(), torch.optim.Adam, lr=0.01)
>>> # Rank 1 gets one more input than rank 0
>>> inputs = [torch.tensor([1.]).to(rank) for _ in range(10 + rank)]
>>> with Join([model, optim]):
>>> for input in inputs:
>>> loss = model(input).sum()
>>> loss.backward()
>>> optim.step()
>>> # All ranks reach here without hanging/erroring
"""
def __init__(
self,
joinables: list[Joinable],
enable: bool = True,
throw_on_early_termination: bool = False,
**kwargs,
):
if len(joinables) == 0:
raise ValueError("The join context manager requires at least one joinable")
self._joinables = joinables
self._join_hooks = [
joinable.join_hook(**kwargs) for joinable in self._joinables
]
self._enable = enable
self._throw_on_early_termination = throw_on_early_termination
self._set_joinable_configs()
self._extract_dist_info()
def _set_joinable_configs(self) -> None:
r"""Set the :class:`_JoinConfig` of each participating :class:`Joinable`."""
if len(self._joinables) <= 0:
raise AssertionError
is_first_joinable = True
for joinable in self._joinables:
joinable._join_config = _JoinConfig(
enable=self._enable,
throw_on_early_termination=self._throw_on_early_termination,
is_first_joinable=is_first_joinable,
)
is_first_joinable = False
def _extract_dist_info(self) -> None:
r"""
Extract the process group and device information from the joinables.
If there are multiple joinables, then the context manager uses the
first specified device.
Preconditions:
``self._joinables`` is not ``None`` and is non-empty.
Raises:
ValueError
If there are multiple conflicting ``process_group`` attributes
among the ``Joinable`` objects.
"""
process_group = None
device = None
# pyrefly: ignore [bad-assignment]
for joinable in self._joinables:
if process_group is None:
process_group = joinable.join_process_group
elif process_group != joinable.join_process_group:
raise ValueError(
"Using join context manager with multiple process groups"
)
if device is None:
device = joinable.join_device
self._process_group = process_group
self._rank = dist.get_rank(self._process_group)
self._device = device
def __enter__(self): ...
def __exit__(
self,
type: type[BaseException] | None,
value: BaseException | None,
traceback: TracebackType | None,
):
r"""
Repeatedly runs the main hooks until all processes join; then, runs the post-hooks.
Raises:
RuntimeError
If ``throw_on_early_termination=True``.
"""
if not self._enable or type:
return # propagate the exception directly if one was raised
all_procs_joined = False
is_last_joiner = True
i = 0
WARN_THRESHOLD = 1000
warnings.simplefilter("once")
while not all_procs_joined:
if i > WARN_THRESHOLD:
warnings.warn(
"Detected uneven input skew of greater than "
f"{WARN_THRESHOLD}. This means that rank "
f"{self._rank} has at least {WARN_THRESHOLD} "
f"fewer inputs than other currently-active ranks. "
"This level of skew could lead to performance "
"degradation during training.",
stacklevel=2,
)
# Shadow the all-reduce in non-joined processes
num_nonjoined_procs = self._get_num_nonjoined_procs()
if num_nonjoined_procs == 0:
all_procs_joined = True
else:
if self._throw_on_early_termination:
self._notify_procs_to_terminate()
# Run main hooks
for join_hook in self._join_hooks:
join_hook.main_hook()
is_last_joiner = False
i += 1
# Run post-hooks
for join_hook in self._join_hooks:
join_hook.post_hook(is_last_joiner)
def _get_num_nonjoined_procs(self):
r"""Return the number of non-joined processes by shadowing an all-reduce in the non-joined processes."""
num_nonjoined_procs = torch.zeros(1, device=self._device)
dist.all_reduce(num_nonjoined_procs, group=self._process_group)
return num_nonjoined_procs.item()
def _notify_procs_to_terminate(self):
r"""Schedule an all-reduce to notify non-joined processes to terminate.
Also raise a ``RuntimeError`` indicating that the current process has exhausted its inputs.
"""
ones = torch.ones(1, device=self._device)
dist.all_reduce(ones, group=self._process_group)
raise RuntimeError(f"Rank {self._rank} exhausted all inputs.")
@staticmethod
def notify_join_context(joinable: Joinable):
r"""
Notifies the join context manager that the calling process has not yet joined.
Then, if ``throw_on_early_termination=True``, checks if uneven inputs have been detected
(i.e. if one process has already joined) and throws an exception if so.
This method should be called from a :class:`Joinable` object before
its per-iteration collective communications. For example, this should
be called at the beginning of the forward pass in
:class:`DistributedDataParallel`.
Only the first :class:`Joinable` object passed into the context
manager performs the collective communications in this method, and
for the others, this method is vacuous.
Arguments:
joinable (Joinable): the :class:`Joinable` object calling this
method.
Returns:
An async work handle for the all-reduce meant to notify the context
manager that the process has not yet joined if ``joinable`` is the
first one passed into the context manager; ``None`` otherwise.
"""
if not hasattr(joinable, "_join_config"):
raise AssertionError(
f"Check that the {type(joinable)} constructor calls the "
"``Joinable`` constructor"
)
join_config = joinable._join_config
# First joinable is responsible for the collective communications
if not join_config.is_first_joinable or not join_config.enable:
return None
device = joinable.join_device
process_group = joinable.join_process_group
# Schedule an all-reduce to indicate that the caller has not yet joined
ones = torch.ones(1, device=device)
work = dist.all_reduce(ones, group=process_group, async_op=True)
if join_config.throw_on_early_termination:
# Check if uneven inputs have been detected
zeros = torch.zeros(1, device=device)
dist.all_reduce(zeros, group=process_group)
should_throw = zeros.item()
if should_throw:
raise RuntimeError(
"Detected at least one rank that exhausted inputs. "
"Throwing across all ranks."
)
return work
@@ -0,0 +1,128 @@
# mypy: allow-untyped-defs
import warnings
from abc import ABC, abstractmethod
from collections.abc import Iterable
import torch
import torch.distributed as dist
import torch.distributed.algorithms.model_averaging.utils as utils
from torch.utils._typing_utils import not_none as _not_none
__all__ = ["ModelAverager", "PeriodicModelAverager"]
class ModelAverager(ABC):
r"""Base class for all model averagers.
Args:
process_group: The process group to be used for all-reduce.
If ``None``, the default process group, which
is created by :func:`torch.distributed.init_process_group`,
will be used. (default: ``None``)
"""
def __init__(self, process_group: dist.ProcessGroup | None = None):
self.process_group = (
process_group if process_group is not None else _not_none(dist.group.WORLD)
)
self.step = 0
@abstractmethod
def average_parameters(self, params):
raise NotImplementedError
class PeriodicModelAverager(ModelAverager):
r"""
Averages parameters periodically after the warm-up stage.
This can be used for running `post-local SGD <https://arxiv.org/abs/1808.07217>`_,
by running :class:`~torch.nn.DistributedDataParallel` (DDP)
using the subgroups created by :meth:`~torch.distributed.new_subgroups`.
Args:
period (int): The number of steps per model averaging.
Usually the period should be greater than ``1`` to reduce the communication cost.
Otherwise, only DDP needs to be used.
warmup_steps (int): The number of warm-up steps. During this stage,
model averaging is skipped.
process_group: The process group to be used for all-reduce.
If ``None``, the default process group, which
is created by :func:`torch.distributed.init_process_group`,
will be used. (default: ``None``)
Example::
>>> # xdoctest: +SKIP("undefined variables")
>>> import torch
>>> import torch.distributed as dist
>>> import torch.distributed.algorithms.ddp_comm_hooks.post_localSGD_hook as post_localSGD
>>> import torch.distributed.algorithms.model_averaging.averagers as averagers
>>> import torch.nn as nn
>>>
>>> dist.init_process_group("nccl", rank=rank, world_size=16)
>>> torch.cuda.set_device(rank)
>>> module = nn.Linear(1, 1, bias=False).cuda()
>>> model = nn.parallel.DistributedDataParallel(
>>> module, device_ids=[rank], output_device=rank
>>> )
>>> # Register a post-localSGD communication hook.
>>> state = PostLocalSGDState(process_group=None, subgroup=None, start_localSGD_iter=100)
>>> model.register_comm_hook(state, post_localSGD_hook)
>>>
>>> # In the first 100 steps, run global gradient averaging like normal DDP at every step.
>>> # After 100 steps, run model averaging every 4 steps.
>>> # Note that ``warmup_steps`` must be the same as ``start_localSGD_iter`` used in ``PostLocalSGDState``.
>>> averager = averagers.PeriodicModelAverager(period=4, warmup_steps=100)
>>> for step in range(0, 200):
>>> optimizer.zero_grad()
>>> loss = loss_fn(output, labels)
>>> loss.backward()
>>> optimizer.step()
>>> # Will average model parameters globally every 4 steps. Thus,
>>> # inter-node communication only occurs every 4 iterations after
>>> # the initial ``warmup_steps`` period.
>>> averager.average_parameters(model.parameters())
"""
def __init__(
self, period, warmup_steps=0, process_group: dist.ProcessGroup | None = None
):
super().__init__(process_group)
if warmup_steps < 0:
raise ValueError("Arg ``warmup_steps`` must be a non-negative number.")
self.warmup_steps = warmup_steps
if period < 1:
raise ValueError("Arg ``period`` must be a positive value.")
elif period == 1:
warnings.warn(
"When period is 1, no need to use model averaging because the communication cost "
"of all-reducing parameters will be no less than the cost of all-reducing gradients "
"by DistributedDataParallel in the backward pass. Therefore, only "
"DistributedDataParallel should be used for this case.",
stacklevel=2,
)
self.period = period
def average_parameters(
self,
params: Iterable[torch.nn.Parameter] | Iterable[dict[str, torch.nn.Parameter]],
):
"""
Averages parameters or parameter groups of an optimizer if ``step`` is no less than ``warmup_steps``.
Can be divided by ``period``, where ``step`` is increased by 1
at each iteration in the training loop.
Args:
params: The parameters of a model or parameter groups of an optimizer.
"""
if (
self.step >= self.warmup_steps
and (self.step - self.warmup_steps) % self.period == 0
):
utils.average_parameters_or_parameter_groups(
params, _not_none(self.process_group)
)
self.step += 1
@@ -0,0 +1,179 @@
# mypy: allow-untyped-defs
# Copyright 2022 Cruise LLC
import logging
import warnings
from collections import OrderedDict
from collections.abc import Iterable
import torch
import torch.distributed as dist
import torch.distributed.algorithms.model_averaging.averagers as averagers
import torch.distributed.algorithms.model_averaging.utils as utils
logger = logging.getLogger(__name__)
class HierarchicalModelAverager(averagers.ModelAverager):
r"""
Runs hierarchical model averaging (`hierarchical SGD <https://arxiv.org/pdf/2010.12998.pdf>`_).
Process groups of different sizes are organized in a hierarchy, and they average parameters
by using different periods concurrently after the warm-up stage.
This is an extension of :class:`~torch.distributed.algorithms.model_averaging.averagers.PeriodicModelAverager`
that supports `post-local SGD <https://arxiv.org/abs/1808.07217>`_, which essentially only supports
a two-level hierarchy: the intra-machine level and the global level, where the intra-machine
level is usually embedded in :meth:`~torch.distributed.algorithms.ddp_comm_hooks.post_localSGD_hook`.
Similarly, the process groups within this class do not have such an intra-machine process
subgroup, which should be embedded by the post-local SGD communication hook instead.
Args:
period_group_size_dict: An ordered dict mapping keys of model averaging period to
process group size, used for initializing process groups of
different sizes in a hierarchy to average parameters concurrently.
Particularly, at each iteration, there will be at most a single
process group that runs averaging -- the period of such group should
have the largest period which the current step can be divided by.
For example, if the dict has three keys: 2, 4, and 8,
then this means totally three process groups will be created to
average parameters every 2, 4, and 8 iterations, respectively.
At the 4th iteration, only the second process group will run
averaging, because the first process group should be a
subset of the second process group, and no need to execute the first
process group redundantly.
On the other hand, the third process group can only be triggered
every 8 iterations, so it will not be triggered at the 4th iteration.
warmup_steps (int): The number of warm-up steps. During this stage, model averaging is skipped.
process_group (ProcessGroup, optional): The overall process group containing all the processes that runs model averaging.
If ``None``, the default process group, which is created
by :func:`torch.distributed.init_process_group`, will be used.
(default: ``None``)
Example::
>>> # xdoctest: +SKIP('undefined rank')
>>> from collections import OrderedDict
>>> import torch
>>> import torch.distributed as dist
>>> from torch.distributed.algorithms.ddp_comm_hooks.post_localSGD_hook import (
>>> PostLocalSGDState,
>>> post_localSGD_hook,
>>> )
>>> import torch.distributed.algorithms.model_averaging.hierarchical_model_averager as hierarchicalSGD
>>> import torch.nn as nn
>>>
>>> dist.init_process_group("nccl", rank=rank, world_size=16)
>>> torch.cuda.set_device(rank)
>>> module = nn.Linear(1, 1, bias=False).to(rank)
>>> model = nn.parallel.DistributedDataParallel(
>>> module, device_ids=[rank], output_device=rank
>>> )
>>> # Register a post-localSGD communication hook.
>>> # Assume that each machine has 4 GPUs, then each intra-machine subgroup has a size of 4.
>>> subgroup, _ = dist.new_subgroups()
>>> state = PostLocalSGDState(process_group=None, subgroup=subgroup, start_localSGD_iter=100)
>>> model.register_comm_hook(state, post_localSGD_hook)
>>>
>>> # Average parameters among each group of 8 processes every 4 iterations, and among all
>>> # the 16 processes every 16 iterations.
>>> averager = hierarchicalSGD.HierarchicalModelAverager(
>>> period_group_size_dict=OrderedDict([(4, 8), (16, 16)]), warmup_steps=100)
>>> # Note that ``warmup_steps`` must be the same as ``start_localSGD_iter`` used in ``PostLocalSGDState``.
>>> # In the first 100 steps, run global gradient averaging like normal DDP at every step.
>>> # After 100 steps, run model averaging at two levels.
>>> for step in range(0, 200):
>>> optimizer.zero_grad()
>>> loss = loss_fn(output, labels)
>>> loss.backward()
>>> optimizer.step()
>>> # Average parameters after ``optimizer.step()``.
>>> # Thus, the inter-node communication only occurs periodically after ``warmup_steps``.
>>> averager.average_parameters(model.parameters())
.. warning ::
The last group size in the dict must be the size of the provided ``process_group``,
which indicates model averaging at the highest level of the hierarchy.
If ``process_group`` is not provided, then the last group size should be equal to the world size.
.. warning ::
`HierarchicalModelAverager` is experimental and subject to change.
"""
def __init__(self, period_group_size_dict=None, warmup_steps=0, process_group=None):
super().__init__(process_group)
if not period_group_size_dict:
raise ValueError("Arg ``period_group_size_dict`` must not be empty.")
self._periods = list(period_group_size_dict.keys())
if self._periods[0] <= 0:
raise ValueError(
"The minimum period in arg ``period_group_size_dict`` must be a positive value."
)
elif self._periods[-1] == 1:
warnings.warn(
"When the maximum period in arg ``period_group_size_dict`` is 1, "
"no need to use model averaging because the communication cost "
"of all-reducing parameters will be no less than the cost of all-reducing gradients "
"by DistributedDataParallel in the backward pass. Therefore, only "
"DistributedDataParallel should be used for this case.",
stacklevel=2,
)
overall_group_size = dist.get_world_size(group=self.process_group)
if list(period_group_size_dict.values())[-1] != overall_group_size:
raise ValueError(
f"The last value in arg ``period_process_group_dict`` {list(period_group_size_dict.values())[-1]} "
f"must be equal to the size of arg ``process_group`` {overall_group_size}."
)
self.period_process_group_dict = OrderedDict()
logger.info("Model averaging hierarchy:")
for period, group_size in period_group_size_dict.items():
logger.info(
"\tEach group that has %s processes average parameters every %s iterations, "
"if no higher-level averaging.",
group_size,
period,
)
if group_size != overall_group_size:
self.period_process_group_dict[period], _ = dist.new_subgroups(
group_size=group_size, group=self.process_group
)
else:
self.period_process_group_dict[period] = self.process_group
if warmup_steps < 0:
raise ValueError("Arg ``warmup_steps`` must be a non-negative number.")
self.warmup_steps = warmup_steps
def _find_process_group(self):
"""
Return a process group as the value of an ``period_process_group_dict`` entry.
If ``step`` can be divided by multiple periods in the keys of ``period_process_group_dict``,
then the returned process group is the one corresponding to the largest period,
since this process group will be used for averaging parameters at this ``step``.
Returns ``None`` if not found.
"""
for period in reversed(self._periods):
if self.step % period == 0:
return self.period_process_group_dict[period]
return None
def average_parameters(
self,
params: Iterable[torch.nn.Parameter] | Iterable[dict[str, torch.nn.Parameter]],
):
"""
Averages parameters or parameter groups of an optimizer.
Averaging only occurs if ``step`` is no less than ``warmup_steps``
and it can be divided by a period in the keys of ``period_process_group_dict``,
where ``step`` is increased by 1 at each iteration in the training loop.
If ``step`` can be divided by multiple periods in the keys of ``period_process_group_dict``,
only the largest period is used, and the corresponding process group is used for averaging parameters.
Args:
params: The parameters of a model or parameter groups of an optimizer.
"""
if self.step >= self.warmup_steps:
group = self._find_process_group()
if group is not None:
utils.average_parameters_or_parameter_groups(params, group)
self.step += 1
@@ -0,0 +1,86 @@
# mypy: allow-untyped-defs
import itertools
from collections.abc import Iterable, Iterator
import torch
import torch.distributed as dist
# The two imports below are not always available depending on the
# USE_DISTRIBUTED compile flag. Make sure they raise import error
# if we're trying to use them.
from torch.distributed import group, ProcessGroup
__all__ = [
"average_parameters",
"get_params_to_average",
"average_parameters_or_parameter_groups",
]
def average_parameters(
params: Iterator[torch.nn.Parameter], process_group: ProcessGroup
):
"""
Averages all the given parameters.
For allreduce efficiency, all the parameters are flattened into a contiguous buffer.
Thus, it requires extra memory of the same size as the given parameters.
"""
group_to_use = process_group if process_group is not None else group.WORLD
# Do not update any parameter if not in the process group.
if dist._rank_not_in_group(group_to_use):
return
params_it1, params_it2 = itertools.tee(params)
# If the input parameters have different data types,
# packing these parameters will trigger an implicit type up-casting.
# The original parameter data types will be restored during the subsequent unpacking.
flat_params = torch.cat([p.data.reshape(-1) for p in params_it1])
flat_params /= dist.get_world_size(group_to_use)
# Make sure the allreduce will not conflict with any other ongoing process group.
if torch.accelerator.is_available():
torch.accelerator.synchronize()
dist.all_reduce(flat_params, group=group_to_use)
offset = 0
for p in params_it2:
p.data = flat_params[offset : offset + p.numel()].view_as(p).type_as(p)
offset += p.numel()
def get_params_to_average(
params: Iterable[torch.nn.Parameter] | Iterable[dict[str, torch.nn.Parameter]],
):
"""
Return a list of parameters that need to average.
This filters out the parameters that do not contain any gradients.
Args:
params: The parameters of a model or parameter groups of an optimizer.
"""
filtered_params = []
for param in params:
if isinstance(param, torch.nn.Parameter):
# model.parameters() input
param_data = param
if param_data.grad is not None:
filtered_params.append(param_data)
elif isinstance(param, dict):
# optimizer.param_groups input
for param_data in param["params"]:
if param_data.grad is not None:
filtered_params.append(param_data)
else:
raise NotImplementedError(
f"Parameter input of type {type(param)} is not supported"
)
return filtered_params
def average_parameters_or_parameter_groups(
params: Iterable[torch.nn.Parameter] | Iterable[dict[str, torch.nn.Parameter]],
process_group: ProcessGroup,
):
"""Averages parameters of a model or parameter groups of an optimizer."""
average_parameters(iter(get_params_to_average(params)), process_group)

Some files were not shown because too many files have changed in this diff Show More