Initial import: grid-bot — grid trading bot for BTC-USDT on Cifra Markets
This commit is contained in:
@@ -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
|
||||
+135
@@ -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)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from torch.distributed.fsdp import CPUOffloadPolicy, MixedPrecisionPolicy, OffloadPolicy
|
||||
|
||||
from .fully_shard import FSDPModule, fully_shard, register_fsdp_forward_method
|
||||
+8
@@ -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
|
||||
+204
@@ -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)
|
||||
+1934
File diff suppressed because it is too large
Load Diff
+137
@@ -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
|
||||
)
|
||||
+2130
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
+19
@@ -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
|
||||
+41
@@ -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
|
||||
+53
@@ -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
|
||||
+102
@@ -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!"
|
||||
)
|
||||
+490
@@ -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
|
||||
+13
@@ -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_
|
||||
+115
@@ -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(),
|
||||
)
|
||||
+78
@@ -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)
|
||||
+164
@@ -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)
|
||||
+12
@@ -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
|
||||
+222
@@ -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
|
||||
+1373
File diff suppressed because it is too large
Load Diff
+35
@@ -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)
|
||||
+16
@@ -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(),
|
||||
}
|
||||
+94
@@ -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)
|
||||
+243
@@ -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
|
||||
+61
@@ -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)
|
||||
+327
@@ -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.
|
||||
"""
|
||||
+1
@@ -0,0 +1 @@
|
||||
from .api import ShardingPlan, ShardingPlanner
|
||||
+86
@@ -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.
|
||||
"""
|
||||
+10
@@ -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
|
||||
+244
@@ -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]
|
||||
+264
@@ -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)
|
||||
+232
@@ -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
|
||||
+350
@@ -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
|
||||
+294
@@ -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,
|
||||
)
|
||||
+477
@@ -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
|
||||
+21
@@ -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
|
||||
+2302
File diff suppressed because it is too large
Load Diff
+1220
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
+270
@@ -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}")
|
||||
+579
@@ -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()
|
||||
+401
@@ -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
|
||||
+313
@@ -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,
|
||||
)
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
from . import default_hooks as default
|
||||
|
||||
|
||||
LOW_PRECISION_HOOKS = [
|
||||
default.fp16_compress_hook,
|
||||
default.bf16_compress_hook,
|
||||
]
|
||||
+191
@@ -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)
|
||||
+1
@@ -0,0 +1 @@
|
||||
from .optimizer_overlap import _as_overlapped_optim
|
||||
+96
@@ -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)
|
||||
+151
@@ -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
|
||||
+141
@@ -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)
|
||||
+464
@@ -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
|
||||
+29
@@ -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
|
||||
+211
@@ -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
|
||||
+86
@@ -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
|
||||
+163
@@ -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
|
||||
+124
@@ -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)
|
||||
+865
@@ -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)
|
||||
+220
@@ -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
|
||||
+128
@@ -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
|
||||
+179
@@ -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
|
||||
+86
@@ -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
Reference in New Issue
Block a user