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

This commit is contained in:
Kolp
2026-09-24 13:22:23 +07:00
commit 642cc11a9f
18968 changed files with 5683248 additions and 0 deletions
@@ -0,0 +1,72 @@
from ._flat_param import FlatParameter as FlatParameter
from ._fully_shard import (
CPUOffloadPolicy,
DataParallelMeshDims,
FSDPModule,
fully_shard,
MixedPrecisionPolicy,
OffloadPolicy,
register_fsdp_forward_method,
share_comm_ctx,
UnshardHandle,
)
from .fully_sharded_data_parallel import (
BackwardPrefetch,
CPUOffload,
FullOptimStateDictConfig,
FullStateDictConfig,
FullyShardedDataParallel,
LocalOptimStateDictConfig,
LocalStateDictConfig,
MixedPrecision,
OptimStateDictConfig,
OptimStateKeyType,
ShardedOptimStateDictConfig,
ShardedStateDictConfig,
ShardingStrategy,
StateDictConfig,
StateDictSettings,
StateDictType,
)
__all__ = [
# FSDP1
"BackwardPrefetch",
"CPUOffload",
"FullOptimStateDictConfig",
"FullStateDictConfig",
"FullyShardedDataParallel",
"LocalOptimStateDictConfig",
"LocalStateDictConfig",
"MixedPrecision",
"OptimStateDictConfig",
"OptimStateKeyType",
"ShardedOptimStateDictConfig",
"ShardedStateDictConfig",
"ShardingStrategy",
"StateDictConfig",
"StateDictSettings",
"StateDictType",
# FSDP2
"CPUOffloadPolicy",
"DataParallelMeshDims",
"FSDPModule",
"fully_shard",
"MixedPrecisionPolicy",
"OffloadPolicy",
"register_fsdp_forward_method",
"UnshardHandle",
"share_comm_ctx",
]
# Set namespace for exposed private names
CPUOffloadPolicy.__module__ = "torch.distributed.fsdp"
DataParallelMeshDims.__module__ = "torch.distributed.fsdp"
FSDPModule.__module__ = "torch.distributed.fsdp"
fully_shard.__module__ = "torch.distributed.fsdp"
MixedPrecisionPolicy.__module__ = "torch.distributed.fsdp"
OffloadPolicy.__module__ = "torch.distributed.fsdp"
register_fsdp_forward_method.__module__ = "torch.distributed.fsdp"
UnshardHandle.__module__ = "torch.distributed.fsdp"
share_comm_ctx.__module__ = "torch.distributed.fsdp"
@@ -0,0 +1,717 @@
# mypy: allow-untyped-defs
"""
This file includes private common utilities for FSDP.
"""
import dataclasses
import logging
import traceback
import warnings
import weakref
from collections.abc import Callable, Generator, Iterable, Iterator
from enum import auto, Enum
from functools import partial
from itertools import chain
from typing import Any, cast, no_type_check, Optional, TYPE_CHECKING
import torch
import torch.distributed as dist
import torch.distributed.fsdp._flat_param as flat_param_file
import torch.nn as nn
from torch.distributed._composable_state import _get_module_state, _State
from torch.distributed.algorithms._checkpoint.checkpoint_wrapper import (
_CHECKPOINT_PREFIX,
)
from torch.distributed.utils import _apply_to_tensors
from torch.utils._mode_utils import no_dispatch
from .api import (
FullOptimStateDictConfig,
FullStateDictConfig,
OptimStateDictConfig,
ShardingStrategy,
StateDictConfig,
StateDictType,
)
if TYPE_CHECKING:
from torch.distributed.device_mesh import DeviceMesh
from torch.distributed.fsdp._fsdp_extensions import FSDPExtensions
from ._flat_param import FlatParamHandle
_MAX_TRAVERSE_DEPTH = 128
def _is_namedtuple(obj: Any) -> bool:
# Mirrors torch.nn.parallel.scatter_gather._is_namedtuple
fields = getattr(type(obj), "_fields", None)
return (
isinstance(obj, tuple)
and hasattr(obj, "_asdict")
and isinstance(fields, tuple)
and all(isinstance(f, str) for f in fields)
)
def collect_grad_tensors(output: Any) -> tuple[torch.Tensor, ...]:
"""
Recursively collect tensors that require gradients from a nested structure.
Traverses dict, list, tuple, NamedTuple, and dataclass containers.
Sets and other iterables are *not* traversed (consistent with
``tree_flatten``). Uses the same traversal order as
:func:`replace_grad_tensors`.
"""
tensors_list: list[torch.Tensor] = []
_collect_grad_tensors(output, tensors_list)
return tuple(tensors_list)
def _collect_grad_tensors(
output: Any, out: list[torch.Tensor], _depth: int = 0
) -> None:
"""Collect grad-requiring tensors in the same order as _replace_grad_tensors."""
if _depth >= _MAX_TRAVERSE_DEPTH:
raise RuntimeError(
f"collect_grad_tensors exceeded max depth ({_MAX_TRAVERSE_DEPTH}), "
"likely due to a circular reference in the output structure"
)
# Branch order must mirror _replace_grad_tensors exactly.
# Only dict, list, tuple, NamedTuple, and dataclass are traversed;
# set and other iterables are intentionally skipped (matching tree_flatten).
if torch.is_tensor(output) and output.requires_grad:
out.append(output)
elif _is_namedtuple(output):
# NamedTuple before dataclass to match _replace_grad_tensors ordering.
for item in output:
_collect_grad_tensors(item, out, _depth + 1)
elif dataclasses.is_dataclass(output) and not isinstance(output, type):
for field in dataclasses.fields(output):
_collect_grad_tensors(getattr(output, field.name), out, _depth + 1)
elif isinstance(output, dict):
for v in output.values():
_collect_grad_tensors(v, out, _depth + 1)
elif isinstance(output, (list, tuple)):
for item in output:
_collect_grad_tensors(item, out, _depth + 1)
def replace_grad_tensors(output: Any, tensor_iter: Iterator[torch.Tensor]) -> Any:
"""
Replace grad-requiring tensors in a nested structure using replacements
from tensor_iter.
Tensors are consumed from tensor_iter in the same traversal order as
:func:`collect_grad_tensors`. Traverses dict, list, tuple, NamedTuple,
and dataclass containers; sets and other iterables are *not* traversed
(consistent with ``tree_flatten``).
Note: dataclass reconstruction uses ``dataclasses.replace()``, which calls
``__init__``. Dataclasses with custom ``__init__`` validation,
``__post_init__`` side effects, or non-standard dict subclass constructors
may not be compatible. In practice, FSDP module outputs are expected to be
shallowly nested, so recursion depth is not a concern.
"""
result = _replace_grad_tensors(output, tensor_iter)
sentinel = object()
leftover = next(tensor_iter, sentinel)
if leftover is not sentinel:
# Count remaining without holding references to all of them
n = 1 + sum(1 for _ in tensor_iter)
raise RuntimeError(
f"{n} replacement tensors were not consumed while processing "
f"{type(output).__qualname__}"
)
return result
def _replace_grad_tensors(
output: Any, tensor_iter: Iterator[torch.Tensor], _depth: int = 0
) -> Any:
# Branch order must mirror _collect_grad_tensors exactly.
if _depth >= _MAX_TRAVERSE_DEPTH:
raise RuntimeError(
f"replace_grad_tensors exceeded max depth ({_MAX_TRAVERSE_DEPTH}), "
"likely due to a circular reference in the output structure"
)
if torch.is_tensor(output) and output.requires_grad:
return next(tensor_iter)
elif _is_namedtuple(output):
# NamedTuple before dataclass: a NamedTuple that is also a dataclass
# should be reconstructed via positional args, not dataclasses.replace.
new_items = []
any_changed = False
for item in output:
new_item = _replace_grad_tensors(item, tensor_iter, _depth + 1)
new_items.append(new_item)
if new_item is not item:
any_changed = True
if any_changed:
return type(output)(*new_items)
return output
elif dataclasses.is_dataclass(output) and not isinstance(output, type):
changes = {}
for field in dataclasses.fields(output):
old_val = getattr(output, field.name)
new_val = _replace_grad_tensors(old_val, tensor_iter, _depth + 1)
if new_val is not old_val:
changes[field.name] = new_val
if changes:
try:
return dataclasses.replace(output, **changes)
except TypeError as e:
raise TypeError(
f"Failed to reconstruct dataclass {type(output).__qualname__} "
f"via dataclasses.replace(). Dataclasses used as FSDP module "
f"inputs/outputs must support dataclasses.replace(): {e}"
) from None
return output
elif isinstance(output, dict):
new_dict = {}
any_changed = False
for k, v in output.items():
new_v = _replace_grad_tensors(v, tensor_iter, _depth + 1)
new_dict[k] = new_v
if new_v is not v:
any_changed = True
if any_changed:
return new_dict if type(output) is dict else type(output)(new_dict)
return output
elif isinstance(output, (list, tuple)):
new_items = []
any_changed = False
for item in output:
new_item = _replace_grad_tensors(item, tensor_iter, _depth + 1)
new_items.append(new_item)
if new_item is not item:
any_changed = True
if any_changed:
typ = type(output)
try:
return typ(new_items)
except TypeError:
# Fall back to base type for subclasses with custom __init__
return list(new_items) if isinstance(output, list) else tuple(new_items)
return output
else:
return output
FSDP_WRAPPED_MODULE = "_fsdp_wrapped_module"
FSDP_PREFIX = FSDP_WRAPPED_MODULE + "."
FSDP_FLATTENED = "_fsdp_flattened"
# Save a global mapping from module to its input tensor dtype to be populated
# during the forward pre-hook and consumed in the forward post-hook when
# overriding a module's mixed precision
# NOTE: We currently take the last input tensor's dtype in the case of multiple
# floating-point input tensors, which may be incorrect. However, since there is
# not a 1:1 correspondence between input and output tensors, we must use *some*
# heuristic like this to predict the desired output dtype.
_MODULE_TO_INP_DTYPE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
class _FSDPDeviceHandle:
"""
This is a simple abstraction for FSDP computing devices,
which enables custom backends that implement CUDA-like
semantics to be integrated with FSDP.
"""
def __init__(self, device: torch.device, backend: Any = None):
if backend is None:
try:
self.__backend = getattr(torch, device.type)
self.__device = device
except AttributeError as exc:
raise AttributeError(
f"Device '{device}' does not have a corresponding backend registered as 'torch.{device.type}'."
) from exc
else:
self.__backend = backend
@classmethod
def from_device(cls, device: torch.device) -> "_FSDPDeviceHandle":
"""
Return a device handle corresponding to the device, and through this handle,
operations with the same semantics as CUDA can be performed on the device.
Just return torch.cuda if the device is cuda to make attribute-access faster.
Custom backend must first register a module with the same name with {device.type} on torch.
"""
if device.type == "cuda":
return cast(_FSDPDeviceHandle, torch.cuda)
elif device.type == "mtia":
return cast(_FSDPDeviceHandle, torch.mtia)
return cls(device)
def __getattr__(self, name: str, /) -> Any:
try:
return getattr(self.__backend, name)
except AttributeError as exc:
raise AttributeError(
f"Custom backend '{self.__device.type}' not implement 'torch.{self.__device.type}.{name}'"
) from exc
class _UninitializedDeviceHandle(_FSDPDeviceHandle):
def __init__(self) -> None:
pass
def __getattribute__(self, name: str, /) -> Any:
raise RuntimeError("Trying to use an uninitialized device handle.")
class _FSDPState(_State):
def __init__(self) -> None:
# TODO: Move all the attributes to this class to enable typing for
# FSDP/fully_shard.
self._ignored_modules: set[nn.Module] = set()
self._ignored_params: set[nn.Parameter] = set()
# Buffer names are cleaned (without wrapper prefixes)
self._ignored_buffer_names: set[str] = set()
self.process_group: dist.ProcessGroup | None = None
self.rank: int = -1
self.world_size: int = -1
self._device_mesh: DeviceMesh | None = None
self.sharding_strategy = ShardingStrategy.FULL_SHARD
self._use_orig_params: bool = False
self.training_state = TrainingState.IDLE
self._unshard_params_ctx: dict[nn.Module, Generator] = {}
self._state_dict_type: StateDictType = StateDictType.FULL_STATE_DICT
self._state_dict_config: StateDictConfig = FullStateDictConfig()
self._optim_state_dict_config: OptimStateDictConfig = FullOptimStateDictConfig()
self._is_root: bool | None = None
self._handle: flat_param_file.FlatParamHandle | None = None
self._fully_sharded_module_to_handle: dict[
nn.Module, flat_param_file.FlatParamHandle | None
] = {}
self.compute_device: torch.device | None = None
self._gradient_predivide_factor: int = 0
self._gradient_postdivide_factor: int = 0
self._comm_hook: Callable | None = None
self._comm_hook_state: Any | None = None
self._unshard_event: torch.Event | None = None
# Abstract device handle for fsdp compute device. For now,
# the compute device must implement cuda semantics used by fsdp
self._device_handle: _FSDPDeviceHandle = _UninitializedDeviceHandle()
# All following attributes should only be used for root states:
# Save these static lists to avoid the repeated tree traversals
self._all_fsdp_states: list[_FSDPState] = []
self._all_handles: list[flat_param_file.FlatParamHandle] = []
self._fsdp_extension: FSDPExtensions | None = None
def _get_module_fsdp_state(module: nn.Module) -> _FSDPState | None:
state = _get_module_state(module)
if state is None or not isinstance(state, _FSDPState):
return None
return state
def _get_module_fsdp_state_if_fully_sharded_module(
module: nn.Module,
) -> _FSDPState | None:
state = _get_module_fsdp_state(module)
if state is None:
return None
if state == module: # FullyShardedDataParallel module case.
return state
if module in state._fully_sharded_module_to_handle: # fully_shard case.
return state
return None
class TrainingState(Enum):
"""
An enum that indicates the state of a ``FullyShardedDataParallel` instance.
"""
IDLE = auto()
FORWARD_BACKWARD = auto()
SUMMON_FULL_PARAMS = auto()
class HandleTrainingState(Enum):
"""
An enum that indicates the state of a ``FlatParamHandle`.
"""
IDLE = auto()
FORWARD = auto()
BACKWARD_PRE = auto()
BACKWARD_POST = auto()
SUMMON_FULL_PARAMS = auto()
def _is_composable(state: _FSDPState):
# TODO: This is a temporary hack for differentiate between code paths.
return not isinstance(state, nn.Module)
@no_type_check
def _module_handle(state: _FSDPState, module: nn.Module) -> Optional["FlatParamHandle"]:
"""
Returns the ``FlatParamHandle`` s corresponding to ``module``. This is
the handle that contains some parameter in ``module``.
"""
if _is_composable(state):
# A valid FSDP state may have no managed parameters and hence no
# handles, meaning no entry in `_fully_sharded_module_to_handles`
if state._handle is None:
return None
if module not in state._fully_sharded_module_to_handle:
raise AssertionError(
f"Expects a fully sharded module but got {module} on rank {state.rank}"
)
return state._fully_sharded_module_to_handle[module]
else:
# NOTE: This assumes `module` is a `FullyShardedDataParallel` instance.
return module._handle
@no_type_check
def _has_fsdp_params(state: _FSDPState, module: nn.Module) -> bool:
"""Returns if ``module`` has parameters managed by FSDP."""
return _module_handle(state, module) is not None
def _get_sharding_strategy(handle):
"""
Returns the sharding strategy of the handle.
"""
return handle._sharding_strategy if handle else None
def clean_tensor_name(tensor_name: str) -> str:
"""
Cleans the parameter or buffer name by removing any module wrapper
prefixes.
"""
tensor_name = tensor_name.replace(FSDP_PREFIX, "")
# TODO: Explicitly replacing the checkpoint wrapper prefix is not ideal as
# it couples `CheckpointWrapper` and FSDP and also does not scale for more
# module wrappers.
tensor_name = tensor_name.replace(_CHECKPOINT_PREFIX, "")
return tensor_name
def _set_fsdp_flattened(tensor: torch.Tensor) -> None:
"""
Sets an attribute on ``tensor`` to mark it as flattened by FSDP. This is to
avoid re-flattening it during nested construction.
"""
setattr(tensor, FSDP_FLATTENED, True)
def _is_fsdp_flattened(tensor: torch.Tensor) -> bool:
"""Returns if ``tensor`` has been marked as flattened by FSDP."""
return getattr(tensor, FSDP_FLATTENED, False)
def _named_parameters_with_duplicates(
module: nn.Module, **kwargs: Any
) -> list[tuple[str, nn.Parameter]]:
"""
This API is required as some modules overwrite `named_parameters()` but do not support
`remove_duplicate`.
"""
if "remove_duplicate" in kwargs:
raise AssertionError(
"_named_parameters_with_duplicates cannot be used with `remove_duplicate` argument."
)
kwargs["remove_duplicate"] = False
try:
ret = list(module.named_parameters(**kwargs))
except AssertionError:
kwargs.pop("remove_duplicate")
ret = list(module.named_parameters(**kwargs))
return ret
def _get_param_to_fqns(
model: torch.nn.Module,
dedup_shared_params: bool = True,
) -> dict[nn.Parameter, list[str]]:
"""
Constructs a mapping from parameter to a list of its \"canonical\" FQNs. Here,
we use canonical to mean the fully-qualified name assigned to the parameter
based on its position in the original nn.Module hierarchy before any wrapper
or parallelism has been applied to it. This is in contrast to FQNs that may be
generated after parallelisms or wrappers have been applied to the model.
Each normal parameter maps to a singleton list containing its FQN, while each
``FlatParameter`` maps to a list of its original parameter FQNs, which may
have length greater than one. All FQNs are prefixed starting from ``model``.
In the case where FSDP was applied with ``use_orig_params=True``, there should be no
``FlatParameter`` s registered to the model's modules and this mapping will only
contain mappings from ``nn.Parameter`` s to singleton FQN lists.
It is only in the case where FSDP was applied with ``use_orig_params=False`` where
a ``FlatParameter`` will be registered in place of the original parameters and there
will be mappings from each ``FlatParameter`` to lists of FQNs corresponding to the
original parameters.
Args:
model (torch.nn.Module): Root module (which may or may not be a
:class:`FullyShardedDataParallel` instance).
dedup_shared_params (bool): For shared parameters, if ``True``, only
includes the FQNs corresponding to the first encounter of the
shared parameter in the module traversal; if ``False``, then
includes the FQNs across all encounters. (Default: ``True``)
"""
def module_fn(module, prefix, tree_level, param_to_fqns):
for param_name, param in _named_parameters_with_duplicates(
module, recurse=False
):
local_fqns = (
param._fqns
if isinstance(param, flat_param_file.FlatParameter)
else [param_name]
) # prefixed from `module`
global_fqns = [
clean_tensor_name(prefix + name) for name in local_fqns
] # prefixed from the top level `model` (i.e. including `prefix`)
is_shared_param = param in param_to_fqns
if not is_shared_param:
param_to_fqns[param] = global_fqns
else:
if isinstance(param, flat_param_file.FlatParameter):
# DMP overwrites `named_parameters` and skip (advance to
# the next child module) the wrapped_module (e.g.,
# _dmp_wrapped_module and _fsdp_wrapped_module). When a user
# calls `named_child` to traverse the module recursively and
# calls `named_parameters` with `recurse=False`, parameters
# will be traversed more than once.
# This hack is specified designed for DMP + FSDP. We
# overwrite the flat_parameters traversal result to only obtain
# the last one, which happens to be the correct one.
#
# TODO: Remove this hack once DMP + FSDP is not supported.
warnings.warn(
"FlatParameter is being traversed more than once. "
"This case should only happen when using "
"DistributedModelParallel with FullyShardedDataParallel.",
stacklevel=2,
)
param_to_fqns[param] = global_fqns
elif not dedup_shared_params:
param_to_fqns[param].extend(global_fqns)
def return_fn(param_to_fqns):
return param_to_fqns
param_to_unflat_param_names: dict[torch.nn.Parameter, list[str]] = {}
return _apply_to_modules(
model,
module_fn,
return_fn,
[key for key, _ in _named_parameters_with_duplicates(model)],
param_to_unflat_param_names,
)
@no_type_check
def _log_post_backward_hook(
state: _FSDPState, handle: "FlatParamHandle", logger: logging.Logger
) -> None:
# Under TORCH_DISTRIBUTED_DEBUG=INFO, log the module names this hook fires for.
# Below logging of module names this post-bwd hook fires for can help debug certain
# cases where hooks don't fire, such as under certain activation checkpoint configs.
if state._use_orig_params and handle._debug_level == dist.DebugLevel.INFO:
param_fqns = _get_handle_fqns_from_root(state, handle)
logger.warning("FSDP firing post-backward hooks for parameters %s", param_fqns)
@no_type_check
def _get_handle_fqns_from_root(
state: _FSDPState, handle: "FlatParamHandle"
) -> list[str] | None:
if handle is None:
return None
param_to_fqn = state._exec_order_data.param_to_fqn
handle_params = handle.flat_param._params # only populated for use_orig_params
param_fqns = [*chain.from_iterable(param_to_fqn[p] for p in handle_params)]
return param_fqns
def _apply_to_modules(
root_module: torch.nn.Module,
module_fn: Callable,
return_fn: Callable,
filter_fqns: list[str] | None = None,
*args,
**kwargs,
):
"""
Performs a pre-order traversal of the modules in the hierarchy rooted at
``root_module``, applying ``module_fn`` at each module and finally
returning a value using ``return_fn``. The traversal constructs the full
module prefix name (e.g. "module.submodule." just like in model state dict)
and makes that available to ``module_fn``.
``filter_fqns`` is used because some module may have its own prefix similar
to ``FullyShardedDataParallel`` and the ``named_parameters()`` is overwritten
to remove the prefix.
"""
# Precompute the set of all prefixes from filter_fqns so that the
# "any FQN starts with new_prefix?" check is O(1) instead of O(N).
filter_prefixes: set[str] | None = None
if filter_fqns is not None:
filter_prefixes = set()
for fqn in filter_fqns:
i = fqn.find(".")
while i != -1:
filter_prefixes.add(fqn[: i + 1])
i = fqn.find(".", i + 1)
def f(module: torch.nn.Module, prefix: str, tree_level: int, *args, **kwargs):
# Call the module function before recursing over children (pre-order)
module_fn(module, prefix, tree_level, *args, **kwargs)
for submodule_name, submodule in module.named_children():
if submodule is None:
continue
new_prefix = prefix + submodule_name + "."
new_tree_level = tree_level + 1
if filter_prefixes is not None:
if new_prefix not in filter_prefixes:
# DMP's named_parameter() will mess up the traversal with
# ``named_children`` + `named_parameter(recurse=False)``.
# This hack is a must to make the traversal work.
# TODO: Remove this hack once DMP + FSDP is not supported.
# It turns out that recursive wrapping may trigger this as
# well.
if (
submodule_name == "_fsdp_wrapped_module"
or submodule_name == "_dmp_wrapped_module"
):
new_prefix = prefix
elif submodule_name == "module":
new_prefix = prefix
f(submodule, new_prefix, new_tree_level, *args, **kwargs)
f(root_module, "", 0, *args, **kwargs)
return return_fn(*args, **kwargs)
@no_type_check
def _assert_in_training_states(
state: _FSDPState,
training_states: list[TrainingState],
) -> None:
"""Asserts that FSDP is in the states ``_training_states``."""
# Raise a `ValueError` instead of using `assert` to ensure that these
# logical assertions run even if `assert`s are disabled
if state.training_state not in training_states:
msg = (
f"expected to be in states {training_states} but current state is "
f"{state.training_state}"
)
# Print the error on rank 0 in case this is called in the backward pass
if state.rank == 0:
if isinstance(state, nn.Module):
print(f"Asserting FSDP instance is: {state}")
print(f"ERROR: {msg}")
traceback.print_stack()
raise ValueError(msg)
def _get_root_modules(modules: set[nn.Module]) -> set[nn.Module]:
"""
Returns:
Set[nn.Module]: The subset of ``modules`` that are root modules (i.e.
parent-less) with respect to the modules in the set itself. In other
words, these are the modules in ``modules`` that are not the child of
any other module in ``modules``.
"""
root_modules: set[nn.Module] = set()
module_to_submodules = {module: set(module.modules()) for module in modules}
for candidate_module in modules:
is_root_module = True
for module, submodules in module_to_submodules.items():
is_child_module = (
candidate_module is not module and candidate_module in submodules
)
if is_child_module:
is_root_module = False
break
if is_root_module:
root_modules.add(candidate_module)
return root_modules
def _override_module_mixed_precision(
root: torch.nn.Module,
module_classes_to_override: Iterable[type[nn.Module]],
wrap_override_dict: dict[str, Any] = {"mixed_precision": None}, # noqa: B006
) -> set[type[nn.Module]]:
module_classes_to_override = tuple(set(module_classes_to_override))
# Return a set of the actually overridden module classes
overridden_module_classes: set[type[nn.Module]] = set()
for mod in root.modules():
if isinstance(mod, module_classes_to_override):
overridden_module_classes.add(type(mod))
mod._wrap_overrides = wrap_override_dict # type: ignore[assignment]
# TODO: We need to run this mixed precision ignored module in fp32,
# but ensure subsequent modules, that may possibly be running with
# mixed precision, still receive the appropriate precision inputs
# without user having to adjust mixed precision config too much.
# As a result, we attach pre and post forward hooks to up / down
# cast. We should revisit this design.
def cast_fn(
dtype: torch.dtype, module: nn.Module, x: torch.Tensor
) -> torch.Tensor:
if not torch.is_floating_point(x) or x.dtype == dtype:
return x
_MODULE_TO_INP_DTYPE[module] = x.dtype
return x.to(dtype)
def forward_pre_hook(module, args):
return _apply_to_tensors(partial(cast_fn, torch.float32, module), args)
def forward_post_hook(module, args, output):
# NOTE: If the forward did not have any floating-point tensors,
# then the dtype will not be set for this module, and we do not
# upcast the dtype.
if module in _MODULE_TO_INP_DTYPE:
old_dtype = _MODULE_TO_INP_DTYPE[module]
return _apply_to_tensors(
partial(cast_fn, old_dtype, module), output
)
# We intentionally append both of these hooks so that they run after
# all other hooks.
mod.register_forward_pre_hook(forward_pre_hook, prepend=False)
mod.register_forward_hook(forward_post_hook, prepend=False)
return overridden_module_classes
def _no_dispatch_record_stream(tensor: torch.Tensor, stream: torch.Stream) -> None:
# FIXME record_stream doesn't work with non-cuda/mtia/xpu tensors
if tensor.device.type not in [
"cuda",
"mtia",
"xpu",
torch._C._get_privateuse1_backend_name(),
]:
return
if torch.distributed._functional_collectives.is_torchdynamo_compiling():
return
# from @ezyang:
# The no_dispatch was added in https://github.com/pytorch/pytorch/pull/88014 cc @fegin
# Looking over the PR, it looks like this is because we don't actually support Stream arguments
# in torch dispatch, so it just chokes.
# If Dynamo is able to answer "are there any torch dispatch modes" active (it should answer False),
# a better version of this would just be to check if there are any modes before disabling dispatch.
# TODO(voz): Extend a dynamo util to answer the above, unify the codepaths here.
tensor.record_stream(stream)
else:
with no_dispatch():
tensor.record_stream(stream)
@@ -0,0 +1,159 @@
# mypy: allow-untyped-defs
import logging
import time
from collections import defaultdict
from collections.abc import Iterator
from contextlib import contextmanager
from enum import Enum
import torch
import torch.distributed as dist
import torch.distributed.fsdp._flat_param as flat_param_file
from torch.distributed.fsdp._common_utils import (
_apply_to_modules,
_get_module_fsdp_state,
clean_tensor_name,
)
logger = logging.getLogger(__name__)
class SimpleProfiler:
class Type(str, Enum):
ALL = "all"
ALLGATHER = "all_gather"
ALLGATHER_OBJ = "all_gather_object"
RESHARDING = "resharding"
H2D = "H2D"
D2H = "D2H"
results: dict[str, float] = defaultdict(float)
profiling: set[str] = set()
@classmethod
def reset(cls) -> None:
cls.results.clear()
cls.profiling.clear()
@classmethod
@contextmanager
def profile(cls, profile_type: str) -> Iterator[None]:
if profile_type in cls.profiling:
raise AssertionError(
f"{profile_type} is already being profiled. "
"SimpleProfiler does not support profiling multiple instances at "
"the same time. "
)
cls.profiling.add(profile_type)
begin = time.monotonic()
try:
yield
finally:
end = time.monotonic()
cls.results[profile_type] += end - begin
cls.profiling.remove(profile_type)
@classmethod
def dump_and_reset(cls, msg: str) -> None:
# This cannot be combined with DETAIL distributed log
# as the profiling will be very incorrect.
if dist.get_rank() == 0 and dist.get_debug_level() == dist.DebugLevel.INFO:
logger.info("%s %s", msg, cls.results)
cls.reset()
def _get_sharded_module_tree_with_module_name_to_fqns(
model: torch.nn.Module,
) -> tuple[str, dict[str, list[str]]]:
"""
It is used for composable fully_shard() code path, it returns
1. sharded module tree info: each line represents a submodule name that contains the
submodule's FQN and its submodule class name, if the submodule is sharded by `fully_shard`,
the submodule name will add a postfix with ' FULLY SHARDED'. Each increased tree
level adds 4 spaces before the printed name. A printed sharded module tree info for a toy model
is like this:
[CompositeModel] FULLY SHARDED
l1[Linear]
u1[UnitModule] FULLY SHARDED
u1.l1[Linear]
u1.seq[Sequential]
u1.seq.0[ReLU]
u1.seq.1[Linear]
u1.seq.2[ReLU]
u1.l2[Linear]
u2[UnitModule] FULLY SHARDED
u2.l1[Linear]
u2.seq[Sequential]
u2.seq.0[ReLU]
u2.seq.1[Linear]
u2.seq.2[ReLU]
u2.l2[Linear]
l2[Linear]
2. a dict mapping from the concated module FQN and class name to a list of its managed
original parameters' FQNs. An example of the dict for the above toy sharded model is like this:
{'[CompositeModel]': ['l1.weight', 'l1.bias', 'l2.weight', 'l2.bias'],
'u1[UnitModule]': ['u1.l1.weight', 'u1.l1.bias', 'u1.seq.1.weight', 'u1.seq.1.bias', 'u1.l2.weight', 'u1.l2.bias'],
'u2[UnitModule]': ['u2.l1.weight', 'u2.l1.bias', 'u2.seq.1.weight', 'u2.seq.1.bias', 'u2.l2.weight', 'u2.l2.bias']
}
All FQNs are prefixed starting from ``model``.
Args:
model (torch.nn.Module): Root module (which may or may not be passed to
composable `fully_shard()`).
"""
def module_fn(
module, prefix, tree_level, sharded_tree_info, sharded_module_name_to_fqns
):
num_spaces = tree_level * 4
trimed_prefix = (
prefix[:-1] if (len(prefix) > 0 and prefix[-1] == ".") else prefix
)
prefixed_module_name = trimed_prefix + "[" + module.__class__.__name__ + "]"
printed_prefixed_module_name = " " * num_spaces + prefixed_module_name
state = _get_module_fsdp_state(module)
if state is None:
sharded_tree_info[0] += printed_prefixed_module_name + "\n"
return
handle = state._fully_sharded_module_to_handle.get(module, None)
if handle:
sharded_tree_info[0] += (
printed_prefixed_module_name + " FULLY SHARDED" + "\n"
)
else:
sharded_tree_info[0] += printed_prefixed_module_name + "\n"
if handle:
param = handle.flat_param
if not isinstance(param, flat_param_file.FlatParameter):
raise AssertionError(f"Expected FlatParameter, got {type(param)}")
global_fqns = [
clean_tensor_name(prefix + name) for name in param._fqns
] # prefixed from the top level `model` (i.e. including `prefix`)
if prefixed_module_name in sharded_module_name_to_fqns:
sharded_module_name_to_fqns[prefixed_module_name].extend(global_fqns)
else:
sharded_module_name_to_fqns[prefixed_module_name] = global_fqns
def return_fn(sharded_tree_info, sharded_module_name_to_fqns):
return sharded_tree_info[0], sharded_module_name_to_fqns
# Use List to mutate its value in place while running the recursive functions
sharded_tree_info: list[str] = [
"",
]
sharded_module_name_to_fqns: dict[str, list[str]] = {}
return _apply_to_modules(
model,
module_fn,
return_fn,
[key for key, _ in model.named_parameters()],
sharded_tree_info,
sharded_module_name_to_fqns,
)
@@ -0,0 +1,43 @@
import torch.nn as nn
def _annotate_modules_for_dynamo(
module: nn.Module,
ignored_modules: set[nn.Module],
use_orig_params: bool,
) -> None:
"""
Annotates the submodules in ``module`` 's tree, except those in
``ignored_modules``, indicating that the submodules are FSDP-managed and
saving the ``use_orig_params`` setting passed to the FSDP constructor.
"""
for submodule in module.modules():
if submodule not in ignored_modules:
"""[note: Dynamo treats FSDP wrapped modules as UnspecializedNNModule]
Dynamo doesn't get to see this instance (FullyShardedDataParallel) during tracing, since
it skips tracing all the torch.distributed.fsdp code.
- Why? Running the FSDP code eagerly avoids lots of issues trying to trace complex hooks, and also
gets us graph-breaks on FSDP module boundaries which we want anyway for comm ops.
- However, we _also_ want dynamo to treat the wrapped module inside FSDP 'unspecially' (*),
and we need a way to indicate to dynamo which modules are wrapped by FSDP.
(*) UnspecializedNNModules in dynamo are traced-through without any assumptions, and with thorough
guards. NNModules otherwise are 'specialized', meaning there is less overhead due to assuming
their code is well-behaved.
One particular issue with specialized NNModules for FSDP is that the
views created for orig_params are captured into the compiled graph on the first iteration, and while
they are always going to point to the correct flatparameter and give correct results, their order
of creation influences the order of backward execution, preventing overlap of comm and computation
during backward. We need to _use_ the new parameter views created on each forward iteration, in
order for backward to interleave hooks with compute per layer. UnspecializedNNModule lets us achieve
this by capturing the module code more 'functionally' and passing parameters in as inputs each time.
"""
submodule._is_fsdp_managed_module = True # type: ignore[assignment]
# Dynamo only supports FSDP with use_orig_params=True.
# This is hacky, but I could not think of another way to add an assertion to dynamo
# for this, since Dynamo skips all the FSDP code frames and thus can't inspect the
# FSDP module directly
submodule._fsdp_use_orig_params = use_orig_params # type: ignore[assignment]
@@ -0,0 +1,365 @@
# mypy: allow-untyped-defs
import itertools
import warnings
from enum import auto, Enum
import torch
import torch.distributed as dist
import torch.distributed.fsdp._traversal_utils as traversal_utils
import torch.nn as nn
from torch.distributed.fsdp._common_utils import _FSDPState, _get_param_to_fqns
from torch.distributed.fsdp._flat_param import FlatParamHandle
class _ExecOrderWarnStatus(Enum):
"""Used internally for execution order validation."""
NONE = auto() # no deviation yet
WARNING = auto() # deviated this iteration; currently issuing warnings
WARNED = auto() # deviated in a previous iteration
class _ExecOrderData:
"""
This contains the data structures to track the execution order. We track
the pre-forward order on the *first* iteration for forward prefetching
(which thus assumes static graph) and the post-forward order on *every*
iteration for backward prefetching (which thus does not assume static
graph but may be provide an incorrect order).
"""
def __init__(
self,
debug_level: dist.DebugLevel,
backward_prefetch_limit: int,
forward_prefetch_limit: int,
) -> None:
# Tracks the (static) pre-forward order for execution order validation
# and forward prefetching
self.handles_pre_forward_order: list[FlatParamHandle] = []
# Tracks the post-forward order for pre-backward prefetching
self.handles_post_forward_order: list[FlatParamHandle | None] = []
self._iter = 0
# Gives the max number of backward/forward prefetched all-gathers by a
# single module
self._backward_prefetch_limit = backward_prefetch_limit
self._forward_prefetch_limit = forward_prefetch_limit
# Data structures for execution order validation
self._checking_order: bool = debug_level == dist.DebugLevel.DETAIL
self.process_group: dist.ProcessGroup | None = None
self.world_size: int | None = None
self.all_handles: list[FlatParamHandle] = []
# Names are prefixed from the root module
self.param_to_fqn: dict[nn.Parameter, list[str]] = {}
# Current index in the pre-forward execution order
self.current_order_index = 0
self.warn_status = _ExecOrderWarnStatus.NONE
def init(
self,
state: _FSDPState,
root_module: nn.Module,
process_group: dist.ProcessGroup,
) -> None:
"""
Initializes the data structures needed for checking the forward order.
This should be called after a root FSDP instance has been set during
lazy initialization.
"""
self.process_group = process_group
self.rank = process_group.rank()
self.world_size = process_group.size()
# Fix an order over the handles, which should be the same across ranks
for handle in traversal_utils._get_fsdp_handles(root_module):
index = len(self.all_handles)
self.all_handles.append(handle)
handle._handle_index = index
self.param_to_fqn = _get_param_to_fqns(root_module)
# TODO (awgu): We can broadcast the metadata of rank 0's `all_handles`
# to check that all ranks have the same handles in the same order.
# https://github.com/pytorch/pytorch/issues/79620
@property
def is_first_iter(self) -> bool:
return self._iter == 0
def get_handle_to_backward_prefetch(
self,
current_handle: FlatParamHandle,
) -> FlatParamHandle | None:
"""
Returns a :class:`list` of the handles keys of the handles to backward
prefetch given the current handles key. If there are no valid handles
keys to prefetch, then this returns an empty :class:`list`.
"""
current_index = current_handle._post_forward_index
if current_index is None:
return None
target_index = current_index - 1
target_handle: FlatParamHandle | None = None
for _ in range(self._backward_prefetch_limit):
if target_index < 0:
break
target_handle = self.handles_post_forward_order[target_index]
target_index -= 1
return target_handle
def get_handle_to_forward_prefetch(
self,
current_handle: FlatParamHandle,
) -> FlatParamHandle | None:
"""
Returns a :class:`list` of the handles keys of the handles to forward
prefetch given the current handles key. If there are no valid handles
keys to prefetch, then this returns an empty :class:`list`.
"""
current_index = current_handle._pre_forward_order_index
if current_index is None:
return None
target_index = current_index + 1
target_handle: FlatParamHandle | None = None
for _ in range(self._forward_prefetch_limit):
if target_index >= len(self.handles_pre_forward_order):
break
target_handle = self.handles_pre_forward_order[target_index]
target_index += 1
return target_handle
def record_post_forward(self, handle: FlatParamHandle | None) -> None:
"""
Records ``handles`` in the post-forward order, where ``handles`` should
be a group of handles used in the same module's forward. If ``handles``
is empty, then it is omitted.
Unlike :meth:`record_pre_forward`, this records the order *every*
iteration with the expectation that the recorded order is reset in
:meth:`next_iter`.
"""
if not handle:
return
# Only record the first usage of a handles key
if handle._post_forward_index:
self.handles_post_forward_order.append(handle)
return
index = len(self.handles_post_forward_order)
handle._post_forward_index = index
self.handles_post_forward_order.append(handle)
def record_pre_forward(
self, handle: FlatParamHandle | None, is_training: bool
) -> None:
"""
Records ``handles`` in the pre-forward order, where ``handles`` should
be a group of handles used in the same module's forward. If ``handles``
is empty, then it is omitted.
On the first iteration, this checks the execution order across ranks.
See :meth:`_check_order` for details.
"""
if not handle:
return
self._check_order(handle, is_training)
# Fix the order after the first iteration and only record the first
# usage of a handles key
if not self.is_first_iter or handle._pre_forward_order_index is not None:
return
index = len(self.handles_pre_forward_order)
handle._pre_forward_order_index = index
self.handles_pre_forward_order.append(handle)
def _check_order(self, handle: FlatParamHandle, is_training: bool) -> None:
"""
Checks the forward execution order as long as ``is_training`` is
``True`` since checking in eval mode is not supported. This only checks
if the distributed debug level is DETAIL.
- On the first iteration, this uses all-gathers to check that all ranks
are all-gathering the same handles and hence ``FlatParameter`` s,
raising an error if not.
- On subsequent iterations, this checks that each rank is locally
consistent with its own forward order from the first iteration, issuing
a warning if not. This issues a warning on the first deviating
iteration and stops warning thereafter.
"""
# Do not check order in eval mode since the post-backward callback does
# not run so it cannot be used to mark the end of an iteration
if not is_training or not self._checking_order:
return
if self.is_first_iter:
msg_prefix = "Forward order differs across ranks:"
optional_local_indices: tuple[int | None, ...] = self._get_handle_indices(
handle
)
device = handle.device # guaranteed to be non-CPU
num_valid_indices = sum(
(index is not None) for index in optional_local_indices
)
tensor_kwargs: dict[str, torch.dtype | torch.device] = {
"dtype": torch.int32,
"device": device,
}
world_num_valid_indices = torch.zeros(self.world_size, **tensor_kwargs) # type: ignore[arg-type, call-overload]
local_num_valid_indices = torch.tensor([num_valid_indices], **tensor_kwargs) # type: ignore[arg-type, call-overload]
dist.all_gather_into_tensor(
world_num_valid_indices,
local_num_valid_indices,
group=self.process_group,
)
# Copy entire tensor from D2H once to avoid per element D2H copies
world_num_valid_indices = world_num_valid_indices.cpu()
# Check that all ranks plan to all-gather the same number of
# parameters
# TODO (awgu): Since every module has at most one handle in the
# current implementation, this should never raise the error.
if self.world_size is None:
raise AssertionError("Expected world_size to not be None")
if not torch.distributed._functional_collectives.is_torchdynamo_compiling():
# TODO(voz): Don't graph break on this - dynamo hates the n1 != n2
# tensor comparison control flow.
# https://github.com/pytorch/pytorch/issues/107055
for (r1, n1), (r2, n2) in itertools.combinations(
(
(rank, world_num_valid_indices[rank])
for rank in range(self.world_size)
),
2,
):
if n1 != n2:
raise RuntimeError(
f"{msg_prefix} rank {r1} is all-gathering {n1} parameters "
f"while rank {r2} is all-gathering {n2} parameters"
)
world_indices = torch.zeros( # type: ignore[call-overload]
self.world_size * num_valid_indices, **tensor_kwargs
)
local_indices = torch.tensor(optional_local_indices, **tensor_kwargs) # type: ignore[arg-type]
dist.all_gather_into_tensor(
world_indices, local_indices, group=self.process_group
)
# Copy entire tensor from D2H once to avoid per element D2H copies
world_indices = world_indices.cpu()
# Check that all ranks plan to all-gather the same index parameters
if not torch.distributed._functional_collectives.is_torchdynamo_compiling():
# TODO(voz): Don't graph break on this - dynamo hates the i1 != i2
# tensor comparison control flow.
# https://github.com/pytorch/pytorch/issues/107055
for (r1, i1), (r2, i2) in itertools.combinations(
(
(
rank,
world_indices[
rank * num_valid_indices : (rank + 1)
* num_valid_indices
],
)
for rank in range(self.world_size)
),
2,
):
if i1 != i2:
r1_param_names = self._get_names_from_handle_indices(i1)
r2_param_names = self._get_names_from_handle_indices(i2)
raise RuntimeError(
f"{msg_prefix} rank {r1} is all-gathering parameters "
f"for {r1_param_names} while rank {r2} is all-gathering "
f"parameters for {r2_param_names}"
)
else:
# Only issue warnings on the first deviating iteration and stop
# checking thereafter to avoid flooding the console
if self.warn_status == _ExecOrderWarnStatus.WARNED:
return
msg_prefix = None # non-`None` means we should warn
if self.current_order_index >= len(self.handles_pre_forward_order):
# This iteration sees extra all-gather(s) compared to the first
msg_prefix = (
"Expected to not all-gather any more parameters in the "
"forward but trying to all-gather parameters for "
)
else:
expected_handle = self.handles_pre_forward_order[
self.current_order_index
]
if expected_handle != handle:
expected_param_names = self._get_names_from_handles(expected_handle)
msg_prefix = (
f"Expected to all-gather for {expected_param_names} "
"but trying to all-gather parameters for "
)
if msg_prefix is not None:
param_names = self._get_names_from_handles(handle)
msg_suffix = (
f"{param_names}"
if param_names
else "a newly-added parameter since construction time"
)
warnings.warn(
"Forward order differs from that of the first iteration "
f"on rank {self.rank}. Collectives are unchecked and may "
f"give incorrect results or hang.\n{msg_prefix}{msg_suffix}",
stacklevel=2,
)
self.warn_status = _ExecOrderWarnStatus.WARNING
self.current_order_index += 1
def _get_handle_indices(
self,
handle: FlatParamHandle,
) -> tuple[int | None, ...]:
"""
Returns the handle indices (i.e. indices into ``self.all_handles``)
corresponding to the handles in ``handle``. An entry in the
returned tuple is ``None`` if the handle is invalid.
"""
indices: list[int | None] = []
if handle:
indices.append(handle._handle_index)
return tuple(indices)
def _get_names_from_handle_indices(
self,
handle_indices: tuple[int, ...],
) -> list[list[str]]:
"""
Returns a list of FQNs for each handle in ``handle_indices``. If a
handle index is invalid, then its FQNs are omitted from the returned
list.
"""
fqns: list[list[str]] = []
for index in handle_indices:
if index is None or index < 0 or index >= len(self.all_handles):
continue
handle = self.all_handles[index]
flat_param = handle.flat_param
fqns.append(self.param_to_fqn[flat_param])
return fqns
def _get_names_from_handles(
self,
handle: FlatParamHandle,
) -> list[list[str]]:
"""
Returns a list of FQNs for each handle in ``handles_key``. If a handle
is invalid, then its FQNs are omitted from the returned list.
"""
fqns: list[list[str]] = []
if handle:
flat_param = handle.flat_param
if flat_param in self.param_to_fqn:
fqns.append(self.param_to_fqn[flat_param])
return fqns
def next_iter(self):
"""
Advances the internal data structures per iteration. This should be
called in the post-backward callback since that marks the true end of
an iteration.
"""
self._iter += 1
self.handles_post_forward_order.clear()
if self._checking_order:
self.current_order_index = 0
if self.warn_status == _ExecOrderWarnStatus.WARNING:
self.warn_status = _ExecOrderWarnStatus.WARNED
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
from abc import ABC, abstractmethod
from typing import Any
import torch
import torch.distributed as dist
from torch.distributed._shard.sharded_tensor.api import ShardedTensor
from torch.distributed._shard.sharded_tensor.shard import Shard
from torch.distributed.fsdp._shard_utils import (
_all_gather_dtensor,
_create_chunk_dtensor,
_create_chunk_sharded_tensor,
)
from torch.distributed.tensor import DeviceMesh, DTensor
class FSDPExtensions(ABC):
"""
This enables some customizable hooks to enable composability with tensor
parallelism. To activate these hooks, use :func:`_set_fsdp_extensions` to
set a custom :class:`FSDPExtensions` that implements the hooks.
"""
@abstractmethod
def pre_flatten_transform(
self,
tensor: torch.Tensor,
) -> tuple[torch.Tensor, Any | None]:
"""E.g. converting ``DistributedTensor`` to local tensor."""
...
@abstractmethod
def post_unflatten_transform(
self,
tensor: torch.Tensor,
param_extension: Any,
) -> torch.Tensor:
"""E.g. converting local tensor to ``DistributedTensor``."""
...
@abstractmethod
def chunk_tensor(
self,
tensor: torch.Tensor,
rank: int,
world_size: int,
num_devices_per_node: int,
pg: dist.ProcessGroup,
device: torch.device | None = None,
) -> torch.Tensor:
"""Shards a tensor to chunks and returns the local chunk."""
...
@abstractmethod
def chunk_dtensor(
self,
tensor: torch.Tensor,
rank: int,
device_mesh: DeviceMesh,
) -> torch.Tensor:
"""Shards a tensor/DTensor to DTensor and returns the local DTensor."""
...
@abstractmethod
def pre_load_state_dict_transform(
self,
tensor: torch.Tensor,
) -> tuple[torch.Tensor, list[Shard]]:
"""
This is to be called before loading a *sharded* model state dict and
should return the tensor and list of shards from which to load data.
"""
...
@abstractmethod
def all_gather_dtensor(
self,
tensor: DTensor,
parent_mesh: DeviceMesh | None,
) -> torch.Tensor:
"""
This is to be called before loading a *sharded* DTensor state dict.
This gathers tensor in FSDP dimension and returns local tensor of
TP DTensor.
"""
...
_extensions: FSDPExtensions | None = None
def _set_fsdp_extensions(flattener: FSDPExtensions) -> None:
global _extensions
_extensions = flattener
def _ext_pre_flatten_transform(
tensor: torch.Tensor,
fsdp_extension: FSDPExtensions | None = None,
) -> tuple[torch.Tensor, Any | None]:
if fsdp_extension is not None:
new_tensor, param_extension = fsdp_extension.pre_flatten_transform(tensor)
if param_extension is not None:
return new_tensor, param_extension
return tensor, None
def _ext_post_unflatten_transform(
tensor: torch.Tensor,
param_extension: Any,
fsdp_extension: FSDPExtensions | None = None,
) -> torch.Tensor:
if fsdp_extension is not None and param_extension is not None:
return fsdp_extension.post_unflatten_transform(tensor, param_extension)
return tensor
def _ext_chunk_tensor(
tensor: torch.Tensor,
rank: int,
world_size: int,
num_devices_per_node: int,
pg: dist.ProcessGroup,
fsdp_extension: FSDPExtensions | None = None,
) -> torch.Tensor:
chunk_tensor_fn = (
fsdp_extension.chunk_tensor
if fsdp_extension is not None
else _create_chunk_sharded_tensor
)
return chunk_tensor_fn(
tensor,
rank,
world_size,
num_devices_per_node,
pg,
)
def _ext_chunk_dtensor(
tensor: torch.Tensor,
rank: int,
device_mesh: DeviceMesh,
fsdp_extension: FSDPExtensions | None = None,
) -> torch.Tensor:
chunk_dtensor_fn = (
fsdp_extension.chunk_dtensor
if fsdp_extension is not None
else _create_chunk_dtensor
)
return chunk_dtensor_fn(
tensor,
rank,
device_mesh,
)
def _ext_pre_load_state_dict_transform(
tensor: torch.Tensor,
fsdp_extension: FSDPExtensions | None = None,
) -> tuple[torch.Tensor, list[Shard]]:
if fsdp_extension is not None:
return fsdp_extension.pre_load_state_dict_transform(tensor)
if type(tensor) is not ShardedTensor:
raise AssertionError(f"Expected ShardedTensor, got {type(tensor)}")
shards = tensor.local_shards()
return (tensor, shards)
def _ext_all_gather_dtensor(
tensor: DTensor,
parent_mesh: DeviceMesh | None,
fsdp_extension: FSDPExtensions | None = None,
) -> torch.Tensor:
all_gather_dtensor_fn = (
fsdp_extension.all_gather_dtensor
if fsdp_extension is not None
else _all_gather_dtensor
)
return all_gather_dtensor_fn(tensor, parent_mesh)
@@ -0,0 +1,26 @@
from ._fsdp_api import (
CPUOffloadPolicy,
DataParallelMeshDims,
MixedPrecisionPolicy,
OffloadPolicy,
)
from ._fully_shard import (
FSDPModule,
fully_shard,
register_fsdp_forward_method,
share_comm_ctx,
UnshardHandle,
)
__all__ = [
"CPUOffloadPolicy",
"DataParallelMeshDims",
"FSDPModule",
"fully_shard",
"MixedPrecisionPolicy",
"OffloadPolicy",
"register_fsdp_forward_method",
"UnshardHandle",
"share_comm_ctx",
]
@@ -0,0 +1,197 @@
# mypy: allow-untyped-defs
from abc import ABC, abstractmethod
from collections.abc import Sequence
from dataclasses import dataclass
import torch
import torch.distributed as dist
_ReduceOp = dist.ReduceOp | dist.ReduceOp.RedOpType
@dataclass(frozen=True)
class MixedPrecisionPolicy:
"""
This configures FSDP's mixed precision. Unlike autocast, this applies mixed
precision at the module level, not op level, which means low-precision
activations are saved for backward and high-to-low-precision casts are
incurred only at module boundaries.
FSDP works well with module-level mixed precision since it keeps the
high-precision sharded parameters in memory anyway. In other words, FSDP
does not require any extra memory to keep a high-precision copy of the
parameters for the optimizer step.
Attributes:
param_dtype (Optional[torch.dtype]): This specifies the dtype for
the unsharded parameter and hence the dtype for forward/backward
computation and the parameter all-gather. If this is ``None``, then
the unsharded parameter uses the original dtype. The optimizer step
uses the sharded parameter in the original dtype. (Default:
``None``)
reduce_dtype (Optional[torch.dtype]): This specifies the dtype for
gradient reduction (i.e. reduce-scatter or all-reduce). If this is
``None`` but ``param_dtype`` is not ``None``, then the reduction
uses the compute dtype. This can be used to run gradient reduction
in full precision while using low precision for compute. If also
gradient reduction is disabled via :meth:`set_requires_gradient_sync`,
then FSDP will accumulate gradients using ``reduce_dtype``.
(Default: ``None``)
output_dtype (Optional[torch.dtype]): This specifies the dtype for
casting floating-point forward outputs. This can be used to
help implement cases where different modules have different mixed
precision policies. (Default: ``None``)
cast_forward_inputs (bool): This specifies whether FSDP should cast the
forward's floating-point input tensors to ``param_dtype`` or not.
"""
param_dtype: torch.dtype | None = None
reduce_dtype: torch.dtype | None = None
output_dtype: torch.dtype | None = None
cast_forward_inputs: bool = True
class Comm(ABC):
"""
Interface for communication primitives.
A primitive primarily needs to handle 3 tasks, namely:
1. How to allocate memory for communication
Depending on the goal, an implementation can choose to:
a. associate each call to a temporary buffer
(best for flexibility and simplicity)
b. reuse an persistent buffer for efficiency reasons
2. Where to allocate memory
(e.g. NCCL mem pool or regular cuda caching allocator)
3. What to do/call upon the comm is called
(see `AllGather` interface as an example)
"""
@abstractmethod
def allocate(
self,
size: Sequence[int | torch.SymInt],
*,
dtype: torch.dtype,
device: torch.device,
) -> torch.Tensor:
"""
This handles the "how to allocate memory" part.
A default implementation could be simply:
.. code-block:: python
with self.mem_pool:
torch.empty(...)
Args:
size (Sequence[Union[int, torch.SymInt]]): size of the tensor buffer
dtype (torch.dtype): dtype of the tensor buffer
device (torch.device): which device to allocate the tensor onto
"""
...
class AllGather(Comm):
"""
Interface for all_gather comm primitive
"""
@abstractmethod
def __call__(
self,
output_tensor: torch.Tensor,
input_tensor: torch.Tensor,
group: dist.ProcessGroup,
async_op: bool = False,
) -> dist.Work | None: ...
class ReduceScatter(Comm):
"""
Interface for reduce_scatter comm primitive
"""
@abstractmethod
def __call__(
self,
output_tensor: torch.Tensor,
input_tensor: torch.Tensor,
group: dist.ProcessGroup,
op: _ReduceOp,
async_op: bool = False,
) -> dist.Work | None: ...
@dataclass
class DataParallelMeshDims:
"""
Specifies which dimensions of a full SPMD :class:`DeviceMesh` correspond to
data parallelism when using :func:`fully_shard` whose parameters are already
DTensors on that mesh.
Attributes:
shard (Optional[Union[str, tuple[str, ...]]]): Mesh dimension name(s)
that FSDP shards parameters on. If a tuple of names, those dims
are flattened into a single shard dimension. At least one of
``shard`` and ``replicate`` must be set.
replicate (Optional[Union[str, tuple[str, ...]]]): Mesh dimension
name(s) for HSDP or DDP replication. If a tuple of names, those
dims are flattened into a single replicate dimension.
"""
shard: str | tuple[str, ...] | None = None
replicate: str | tuple[str, ...] | None = None
def __post_init__(self):
if self.shard is None and self.replicate is None:
raise ValueError(
"At least one of shard or replicate must be set in DataParallelMeshDims"
)
@property
def shard_names(self) -> tuple[str, ...]:
if self.shard is None:
return ()
if isinstance(self.shard, str):
return (self.shard,)
return tuple(self.shard)
@property
def replicate_names(self) -> tuple[str, ...]:
if self.replicate is None:
return ()
if isinstance(self.replicate, str):
return (self.replicate,)
return tuple(self.replicate)
@dataclass
class OffloadPolicy:
"""
This base class represents the policy of no offloading and is only used as
the default value for the ``offload_policy`` arg.
"""
@dataclass
class CPUOffloadPolicy(OffloadPolicy):
"""
This offload policy offloads parameters, gradients, and optimizer states to
CPU. Sharded parameters are copied host-to-device before all-gather. The
all-gathered parameters are freed according to ``reshard_after_forward``.
Sharded gradients are copied device-to-host in backward, and the optimizer
step runs on CPU with CPU optimizer states.
Attributes:
pin_memory (bool): Whether to pin sharded parameter and gradient
memory. Pinning memory allows both more efficient H2D/D2H copies
and for the copies to overlap with compute. However, the pinned
memory cannot be used by other processes. Set this to ``False`` if
you have insufficient CPU memory. (Default: ``True``)
"""
pin_memory: bool = True
@@ -0,0 +1,846 @@
import math
from collections.abc import Callable, Sequence
from itertools import chain
from typing import Any, cast, Literal, NamedTuple
import torch
import torch.distributed as dist
import torch.distributed._symmetric_memory as symm_mem
from torch.distributed.device_mesh import _get_device_handle
from torch.distributed.distributed_c10d import ReduceOp
from torch.distributed.fsdp._fully_shard._fsdp_api import AllGather, ReduceScatter
from torch.distributed.tensor import DTensor
from ._fsdp_api import _ReduceOp
from ._fsdp_common import (
_get_dim0_padded_size,
_raise_assert_with_print,
_to_dtype_if_needed,
)
from ._fsdp_param import FSDPParam, ShardedState
class AllGatherResult(NamedTuple):
all_gather_output: torch.Tensor
all_gather_event: torch.Event | None
all_gather_work: dist.distributed_c10d.Work | None
# For each parameter, the all-gather input dtype for each input
param_all_gather_input_dtypes: list[list[torch.dtype]]
# For each parameter, the all-gather input numel for each input
param_all_gather_input_numels: list[list[int]]
# 1D flattened version of `param_all_gather_input_numels` saved to avoid
# CPU overhead from recomputing
all_gather_input_split_sizes: list[int]
lib = torch.library.Library("fsdp", "FRAGMENT") # noqa: TOR901
lib.define(
"""
all_gather_copy_in(
Tensor[] all_gather_inputs,
Tensor all_gather_output,
SymInt[] inp_split_sizes,
SymInt all_gather_input_numel,
SymInt rank
) -> (Tensor, Tensor)
"""
)
class DefaultAllocMixin:
def allocate(
self,
size: Sequence[int | torch.SymInt],
*,
dtype: torch.dtype,
device: torch.device,
) -> torch.Tensor:
return torch.empty(*size, dtype=dtype, device=device)
class ProcessGroupAllocMixin:
def __init__(self, group: dist.ProcessGroup, *args: Any, **kwargs: Any):
self._group = group
super().__init__(*args, **kwargs)
def allocate(
self,
size: Sequence[int | torch.SymInt],
*,
dtype: torch.dtype,
device: torch.device,
) -> torch.Tensor:
backend = self._group._get_backend(device)
if backend.supports_tensor_alloc(device):
size_1d = math.prod(int(s) for s in size)
return backend.allocate_tensor(size_1d, dtype=dtype, device=device)
return torch.empty(*size, dtype=dtype, device=device)
class SymmMemAllocMixin:
def __init__(
self,
group: dist.ProcessGroup,
backend: Literal["NCCL"] = "NCCL",
*args: Any,
**kwargs: Any,
):
self._group = group
symm_mem.set_backend(backend)
# Force initialization of communicator; otherwise, the rendezvous may
# see empty communicator.
# TODO: Remove this, maybe by warning user to perform eager dist init.
# For now, it is okay since it isjust a one-time cost at init.
dist.barrier(group=group)
def allocate(
self,
size: Sequence[int | torch.SymInt],
*,
dtype: torch.dtype,
device: torch.device,
) -> torch.Tensor:
# Leverage MemPool to reuse the symmetric buffer, avoiding allocation
# and rendezvous overhead
mempool = symm_mem.get_mem_pool(device)
with torch.cuda.use_mem_pool(mempool):
return torch.empty(size, dtype=dtype, device=device)
class DefaultAllGather(DefaultAllocMixin, AllGather):
def __call__(
self,
output_tensor: torch.Tensor,
input_tensor: torch.Tensor,
group: dist.ProcessGroup,
async_op: bool = False,
) -> dist.Work | None:
return dist.all_gather_into_tensor(
output_tensor,
input_tensor,
group=group,
async_op=async_op,
)
class ProcessGroupAllocAllGather(ProcessGroupAllocMixin, AllGather):
def __init__(self, group: dist.ProcessGroup) -> None:
super().__init__(group)
def __call__(
self,
output_tensor: torch.Tensor,
input_tensor: torch.Tensor,
group: dist.ProcessGroup,
async_op: bool = False,
) -> dist.Work | None:
return dist.all_gather_into_tensor(
output_tensor,
input_tensor,
group=group,
async_op=async_op,
)
class SymmMemAllGather(SymmMemAllocMixin, AllGather):
def __init__(
self,
group: dist.ProcessGroup,
backend: Literal["NCCL"] = "NCCL",
) -> None:
super().__init__(group, backend)
def __call__(
self,
output_tensor: torch.Tensor,
input_tensor: torch.Tensor,
group: dist.ProcessGroup,
async_op: bool = False,
) -> dist.Work | None:
# We are doing inplace all-gather, so we need to rendezvous the output tensor only
symm_mem.rendezvous(output_tensor, group=group.group_name)
# Calling regular all-gather would already cause libraries like NCCL to
# use its optimized all-gather implementation for symmetric memory:
# - Copy Engine All-Gather (when zero-CTA policy is enabled)
# - Symmetric Kernel All-Gather (when zero-CTA policy is not enabled)
return dist.all_gather_into_tensor(
output_tensor,
input_tensor,
group=group,
async_op=async_op,
)
class DefaultReduceScatter(DefaultAllocMixin, ReduceScatter):
def __call__(
self,
output_tensor: torch.Tensor,
input_tensor: torch.Tensor,
group: dist.ProcessGroup,
op: _ReduceOp,
async_op: bool = False,
) -> dist.Work:
return dist.reduce_scatter_tensor(
output=output_tensor,
input=input_tensor,
group=group,
op=op,
async_op=async_op,
)
class ProcessGroupAllocReduceScatter(ProcessGroupAllocMixin, ReduceScatter):
def __init__(self, group: dist.ProcessGroup) -> None:
super().__init__(group)
def __call__(
self,
output_tensor: torch.Tensor,
input_tensor: torch.Tensor,
group: dist.ProcessGroup,
op: _ReduceOp,
async_op: bool = False,
) -> dist.Work:
return dist.reduce_scatter_tensor(
output=output_tensor,
input=input_tensor,
group=group,
op=op,
async_op=async_op,
)
class SymmMemReduceScatter(SymmMemAllocMixin, ReduceScatter):
def __init__(
self,
group: dist.ProcessGroup,
backend: Literal["NCCL"] = "NCCL",
) -> None:
super().__init__(group, backend)
def __call__(
self,
output_tensor: torch.Tensor,
input_tensor: torch.Tensor,
group: dist.ProcessGroup,
op: _ReduceOp,
async_op: bool = False,
) -> dist.Work | None:
symm_mem.rendezvous(input_tensor, group=group.group_name)
symm_mem.rendezvous(output_tensor, group=group.group_name)
# Calling regular reduce-scatter would already cause libraries like NCCL to
# use its optimized reduce-scatter implementation for symmetric memory
return dist.reduce_scatter_tensor(
output=output_tensor,
input=input_tensor,
group=group,
op=op,
async_op=async_op,
)
@torch.library.impl(lib, "all_gather_copy_in", "Meta")
def all_gather_copy_in_meta(
all_gather_inputs: list[torch.Tensor],
all_gather_output: torch.Tensor,
inp_split_sizes: list[int],
all_gather_input_numel: int,
rank: int,
) -> tuple[torch.Tensor, torch.Tensor]:
all_gather_input = all_gather_output.narrow(
0, all_gather_input_numel * rank, all_gather_input_numel
)
return all_gather_input, all_gather_output
@torch.library.impl(lib, "all_gather_copy_in", "CUDA")
@torch.library.impl(lib, "all_gather_copy_in", "XPU")
@torch.library.impl(lib, "all_gather_copy_in", "HPU")
@torch.library.impl(lib, "all_gather_copy_in", "CPU")
@torch.library.impl(lib, "all_gather_copy_in", "MTIA")
@torch.library.impl(lib, "all_gather_copy_in", "PrivateUse1")
def all_gather_copy_in_cuda(
all_gather_inputs: list[torch.Tensor],
all_gather_output: torch.Tensor,
inp_split_sizes: list[int],
all_gather_input_numel: int,
rank: int,
) -> tuple[torch.Tensor, torch.Tensor]:
all_gather_input = all_gather_output.narrow(
0, all_gather_input_numel * rank, all_gather_input_numel
)
foreach_copy_dsts = torch.split(all_gather_input, inp_split_sizes)
with torch.no_grad():
torch._foreach_copy_(foreach_copy_dsts, all_gather_inputs)
return all_gather_input, all_gather_output
lib.define(
"split_with_sizes_copy(Tensor all_gather_output, SymInt[] all_gather_input_split_sizes, int dim=0, *, Tensor(a!)[] out) -> ()"
)
@torch.library.impl(lib, "split_with_sizes_copy", "Meta")
@torch.library.impl(lib, "split_with_sizes_copy", "CUDA")
@torch.library.impl(lib, "split_with_sizes_copy", "XPU")
@torch.library.impl(lib, "split_with_sizes_copy", "HPU")
@torch.library.impl(lib, "split_with_sizes_copy", "CPU")
@torch.library.impl(lib, "split_with_sizes_copy", "MTIA")
@torch.library.impl(lib, "split_with_sizes_copy", "PrivateUse1")
def split_with_sizes_copy(
all_gather_output: torch.Tensor,
all_gather_input_split_sizes: list[int],
dim: int = 0,
*,
out: list[torch.Tensor],
) -> None:
torch.split_with_sizes_copy(
all_gather_output, all_gather_input_split_sizes, dim=dim, out=out
)
lib.define(
"chunk_cat(Tensor[] tensors, int dim, int num_chunks, *, Tensor(a!) out) -> ()"
)
@torch.library.impl(lib, "chunk_cat", "Meta")
@torch.library.impl(lib, "chunk_cat", "CUDA")
@torch.library.impl(lib, "chunk_cat", "XPU")
@torch.library.impl(lib, "chunk_cat", "HPU")
@torch.library.impl(lib, "chunk_cat", "CPU")
@torch.library.impl(lib, "chunk_cat", "MTIA")
@torch.library.impl(lib, "chunk_cat", "PrivateUse1")
def chunk_cat(
tensors: list[torch.Tensor],
dim: int,
num_chunks: int,
out: torch.Tensor,
) -> None:
torch._chunk_cat(tensors, dim, num_chunks, out=out)
@torch.no_grad()
def foreach_all_gather(
fsdp_params: list[FSDPParam],
group: dist.ProcessGroup,
async_op: bool,
all_gather_copy_in_stream: torch.Stream,
all_gather_stream: torch.Stream,
device: torch.device,
all_gather_comm: AllGather,
) -> AllGatherResult | None:
world_size, rank = group.size(), group.rank()
device_handle = _get_device_handle(device.type)
with device_handle.stream(all_gather_copy_in_stream):
param_all_gather_inputs = _get_param_all_gather_inputs(fsdp_params)
(
param_all_gather_input_dtypes,
param_all_gather_input_numels,
dtype,
) = _get_all_gather_input_metadatas(param_all_gather_inputs)
if dtype == torch.uint8:
all_gather_inputs = [
t.view(torch.uint8) for ts in param_all_gather_inputs for t in ts
]
else:
all_gather_inputs = [*chain.from_iterable(param_all_gather_inputs)]
inp_split_sizes = [t.numel() for t in all_gather_inputs]
all_gather_input_numel = sum(inp_split_sizes)
all_gather_output = all_gather_comm.allocate(
(all_gather_input_numel * world_size,), dtype=dtype, device=device
)
all_gather_input, all_gather_output = torch.ops.fsdp.all_gather_copy_in(
all_gather_inputs,
all_gather_output,
inp_split_sizes,
all_gather_input_numel,
rank,
)
del param_all_gather_inputs
all_gather_stream.wait_stream(all_gather_copy_in_stream)
with device_handle.stream(all_gather_stream):
all_gather_work = all_gather_comm(
output_tensor=all_gather_output,
input_tensor=all_gather_input,
group=group,
async_op=async_op,
)
all_gather_event = all_gather_stream.record_event()
return AllGatherResult(
all_gather_output,
all_gather_event,
all_gather_work,
param_all_gather_input_dtypes,
param_all_gather_input_numels,
inp_split_sizes,
)
@torch.no_grad()
def _get_param_all_gather_inputs(
fsdp_params: list[FSDPParam],
) -> list[list[torch.Tensor]]:
# Intentionally try to run a fast-path that bypasses abstractions for the
# common FSDP case of bf16/fp32 mixed precision in order to use foreach
# copy for lower CPU overhead and more efficient copying in eager
def use_foreach_copy(fsdp_param: FSDPParam) -> bool:
return (
fsdp_param.param_dtype is not None
and not fsdp_param.offload_to_cpu
and not hasattr(fsdp_param._sharded_local_tensor, "fsdp_pre_all_gather")
)
param_all_gather_inputs: list[list[torch.Tensor]] = [[] for _ in fsdp_params]
foreach_copy_indices: list[int] = []
foreach_copy_inputs: list[torch.Tensor] = []
foreach_copy_input_numels: list[int] = []
# 1st pass: for foreach-copy parameters, get inputs and metadata for the
# foreach copy, and for the others, actually get their all-gather inputs
for i, fsdp_param in enumerate(fsdp_params):
if use_foreach_copy(fsdp_param):
foreach_copy_indices.append(i)
all_gather_input = (
fsdp_param._sharded_param_data
if fsdp_param.sharded_state == ShardedState.SHARDED
else cast(torch.Tensor, fsdp_param._sharded_post_forward_param_data)
)
foreach_copy_inputs.append(all_gather_input)
foreach_copy_input_numels.append(all_gather_input.numel())
else:
param_all_gather_inputs[i] = fsdp_param.all_gather_inputs
# 2nd pass: use foreach copy to compute the remaining all-gather inputs
if foreach_copy_inputs:
fsdp_param_0 = fsdp_params[foreach_copy_indices[0]]
param_dtype, device = fsdp_param_0.param_dtype, fsdp_param_0.device
flat_foreach_copy_input = torch.empty(
(sum(foreach_copy_input_numels),), device=device, dtype=param_dtype
)
splits = torch.split(flat_foreach_copy_input, foreach_copy_input_numels)
torch._foreach_copy_(splits, foreach_copy_inputs)
for i, split in zip(foreach_copy_indices, splits):
param_all_gather_inputs[i] = [split]
return param_all_gather_inputs
@torch.no_grad()
def foreach_all_gather_copy_out(
all_gather_result: AllGatherResult,
fsdp_params: list[FSDPParam],
group: dist.ProcessGroup,
) -> None:
(
all_gather_output,
all_gather_event,
all_gather_work,
param_all_gather_input_dtypes,
param_all_gather_input_numels,
all_gather_input_split_sizes,
) = all_gather_result
_dtype, device = all_gather_output.dtype, all_gather_output.device
device_handle = _get_device_handle(device.type)
if all_gather_event is not None: # sync op
device_handle.current_stream().wait_event(all_gather_event)
if isinstance(all_gather_work, dist.distributed_c10d.Work): # async op
all_gather_work.wait()
world_size, device = group.size(), all_gather_output.device
split_with_sizes_out: list[torch.Tensor] = []
shard_i_copy_infos: list[tuple[FSDPParam, list[torch.Tensor]]] = []
for all_gather_input_numels, all_gather_input_dtypes, fsdp_param in zip(
param_all_gather_input_numels, param_all_gather_input_dtypes, fsdp_params
):
# NOTE: Under compile, make sure we always recreate all_gather_outputs
# per AllGather. See [Note: Invariants for torch.compile Traceable FSDP2].
fsdp_param.init_all_gather_outputs(
all_gather_input_numels,
all_gather_input_dtypes,
world_size,
device,
)
fsdp_param.alloc_all_gather_outputs()
param_all_gather_outputs = fsdp_param.all_gather_outputs
if fsdp_param.fsdp_placement.dim != 0:
# Copy to a temporary and then chunk-cat into the final all-gather
# output tensors
param_all_gather_outputs = [
torch.empty_like(t) for t in param_all_gather_outputs
]
shard_i_copy_infos.append((fsdp_param, param_all_gather_outputs))
split_with_sizes_out.extend(param_all_gather_outputs)
all_gather_output = all_gather_output.view(world_size, -1)
if all_gather_output.dtype == torch.uint8:
out = [t.view(world_size, -1).view(torch.uint8) for t in split_with_sizes_out]
else:
out = [t.view(world_size, -1) for t in split_with_sizes_out]
# only avoid VC bump if we are not in inference mode
non_inference_outs = [o for o in out if not o.is_inference()]
if len(non_inference_outs) > 0:
with torch.autograd._unsafe_preserve_version_counter(tuple(non_inference_outs)):
torch.ops.fsdp.split_with_sizes_copy(
all_gather_output, all_gather_input_split_sizes, dim=1, out=out
)
else:
torch.ops.fsdp.split_with_sizes_copy(
all_gather_output, all_gather_input_split_sizes, dim=1, out=out
)
for fsdp_param, param_all_gather_outputs in shard_i_copy_infos:
# Chunk-cat from the temporary to the final all-gather output tensors
shard_dim = fsdp_param.fsdp_placement.dim
with torch.autograd._unsafe_preserve_version_counter(
tuple(fsdp_param.all_gather_outputs)
):
for param_all_gather_output, target_all_gather_output in zip(
param_all_gather_outputs, fsdp_param.all_gather_outputs
):
padded_sharded_size = (
fsdp_param.padded_sharded_param_size
if fsdp_param.sharded_state == ShardedState.SHARDED
else cast(
torch.Tensor, fsdp_param._sharded_post_forward_param_data
).size()
)
pre_param_size = list(padded_sharded_size)
pre_param_size[0] *= world_size
chunks = torch.chunk(
param_all_gather_output.view(pre_param_size), world_size, dim=0
)
post_param_size = list(padded_sharded_size)
post_param_size[shard_dim] *= world_size
cat_out = target_all_gather_output.view(post_param_size)
torch.cat(chunks, dim=shard_dim, out=cat_out)
@torch.no_grad()
def foreach_reduce(
fsdp_params: list[FSDPParam],
unsharded_grads: list[torch.Tensor],
reduce_scatter_group: dist.ProcessGroup,
reduce_scatter_stream: torch.Stream,
reduce_scatter_comm: ReduceScatter,
orig_dtype: torch.dtype | None,
reduce_dtype: torch.dtype | None,
device: torch.device,
gradient_divide_factor: float | None,
all_reduce_group: dist.ProcessGroup | None, # not `None` iff HSDP
all_reduce_stream: torch.Stream,
all_reduce_grads: bool,
partial_reduce_output: torch.Tensor | None, # only used for HSDP
all_reduce_hook: Callable[[torch.Tensor], None] | None,
force_sum_reduction_for_comms: bool = False,
) -> tuple[
torch.Tensor,
torch.Event,
torch.Event,
torch.Tensor | None,
torch.Event | None,
torch.Tensor | None,
]:
"""
``unsharded_grads`` owns the references to the gradients computed by
autograd, so clearing the list frees the gradients.
"""
grad_dtypes = {grad.dtype for grad in unsharded_grads}
if len(grad_dtypes) != 1:
# Check this at runtime since it could be a real runtime error if e.g.
# fp8 weights do not produce the correct higher precision gradients
_raise_assert_with_print(
f"FSDP reduce-scatter expects uniform gradient dtype but got {grad_dtypes}"
)
grad_dtype = unsharded_grads[0].dtype
reduce_dtype = reduce_dtype or grad_dtype
(predivide_factor, postdivide_factor, reduce_scatter_op, all_reduce_op) = (
_get_gradient_divide_factors(
reduce_scatter_group,
all_reduce_group,
reduce_dtype,
device.type,
gradient_divide_factor,
force_sum_reduction_for_comms,
)
)
if reduce_scatter_group is None:
world_size = 1
else:
world_size = reduce_scatter_group.size()
device_handle = _get_device_handle(device.type)
current_stream = device_handle.current_stream()
if world_size > 1:
for i, (fsdp_param, unsharded_grad) in enumerate(
zip(fsdp_params, unsharded_grads)
):
if (shard_dim := fsdp_param.fsdp_placement.dim) == 0:
continue
if unsharded_grad.size(shard_dim) % world_size != 0:
raise AssertionError(
f"Shard({shard_dim}) requires even sharding: {unsharded_grad.size()=} {world_size=}"
)
chunks = torch.chunk(unsharded_grad, world_size, dim=shard_dim)
unsharded_grads[i] = torch.cat(chunks, dim=0)
padded_unsharded_sizes = tuple(
_get_dim0_padded_size(grad.size(), world_size) for grad in unsharded_grads
)
reduce_scatter_input_numel = sum(s.numel() for s in padded_unsharded_sizes)
reduce_scatter_output_numel = reduce_scatter_input_numel // world_size
reduce_scatter_input = reduce_scatter_comm.allocate(
(reduce_scatter_input_numel,),
dtype=reduce_dtype,
device=device,
)
foreach_reduce_scatter_copy_in(unsharded_grads, reduce_scatter_input, world_size)
# Only after the copy-in finishes can we free the gradients
unsharded_grads.clear()
reduce_scatter_stream.wait_stream(current_stream)
all_reduce_input = None
all_reduce_event = None
with device_handle.stream(reduce_scatter_stream):
reduce_output = reduce_scatter_comm.allocate(
(reduce_scatter_output_numel,),
dtype=reduce_dtype,
device=device,
)
_div_if_needed(reduce_scatter_input, predivide_factor)
if world_size > 1:
reduce_scatter_comm(
output_tensor=reduce_output,
input_tensor=reduce_scatter_input,
group=reduce_scatter_group,
op=reduce_scatter_op,
)
else:
# For single GPU, just copy the input to output (no actual reduce-scatter needed), and
# account for a possible gradient_divide_factor.
if gradient_divide_factor is not None:
reduce_output.copy_(reduce_scatter_input / gradient_divide_factor)
else:
reduce_output.copy_(reduce_scatter_input)
reduce_scatter_event = reduce_scatter_stream.record_event()
post_reduce_stream = reduce_scatter_stream
if all_reduce_group is not None: # HSDP or DDP/replicate
# Accumulations must run in the reduce-scatter stream
if not all_reduce_grads:
if partial_reduce_output is not None:
partial_reduce_output += reduce_output
else:
partial_reduce_output = reduce_output
return (
reduce_scatter_input,
reduce_scatter_event,
post_reduce_stream.record_event(),
all_reduce_input,
all_reduce_event,
partial_reduce_output,
)
if partial_reduce_output is not None:
reduce_output += partial_reduce_output
post_reduce_stream = all_reduce_stream
if world_size >= 1:
all_reduce_stream.wait_stream(reduce_scatter_stream)
else:
all_reduce_stream.wait_stream(current_stream)
with device_handle.stream(all_reduce_stream):
dist.all_reduce(
reduce_output,
group=all_reduce_group,
op=all_reduce_op,
)
# Keep refs to the reduce-dtype AR buffer + completion
# event so FSDPParamGroup._all_reduce_state can hold them
# across layers. This keeps the buffer off the caching
# allocator's free list; otherwise the next layer's
# reduce-scatter can reuse the same physical block while
# this layer's AR is still in flight, causing cross-layer
# gradient aliasing under slow AR. See PR #140044,
# regression test PR #180900.
all_reduce_input = reduce_output
all_reduce_event = all_reduce_stream.record_event()
# -- END: ops in reduce_scatter stream
if all_reduce_hook is not None:
# Execute user-specified all reduce hook.
# If native HSDP is used, this is executed after the HSDP all reduce.
# If 1-d FSDP is used, this is executed post reduce-scatter.
post_reduce_stream = all_reduce_stream
all_reduce_stream.wait_stream(reduce_scatter_stream)
with device_handle.stream(all_reduce_stream):
all_reduce_hook(reduce_output)
# -- END: ops post reduce_scatter
with device_handle.stream(post_reduce_stream):
_div_if_needed(reduce_output, postdivide_factor)
reduce_output = _to_dtype_if_needed(reduce_output, orig_dtype)
# View out and accumulate sharded gradients
flat_grad_offset = 0 # [0, reduce_scatter_output_numel - 1]
for padded_unsharded_size, fsdp_param in zip(
padded_unsharded_sizes, fsdp_params
):
# Assume even sharding for Shard(i), i > 0; otherwise would require
# copy-out for contiguous strides
new_sharded_grad = torch.as_strided(
reduce_output,
size=fsdp_param.sharded_size,
stride=fsdp_param.contiguous_sharded_stride,
storage_offset=flat_grad_offset,
)
to_accumulate_grad = fsdp_param.sharded_param.grad is not None
if fsdp_param.offload_to_cpu:
# Only overlap the D2H copy (copying to pinned memory) if not
# accumulating gradients since the CPU add kernel depends on
# the copy result and we cannot run the add as a callback
non_blocking = fsdp_param.pin_memory and not to_accumulate_grad
# Since the GPU sharded gradient is allocated in the RS stream,
# we can free it here by not keeping a ref without waiting for
# the D2H copy since future RS-stream ops run after the copy
new_sharded_grad = new_sharded_grad.to(
torch.device("cpu"), non_blocking=non_blocking
)
if non_blocking:
# Record an event on which to block the CPU thread to
# ensure that the D2H copy finishes before the optimizer
fsdp_param.grad_offload_event = post_reduce_stream.record_event()
if to_accumulate_grad:
if not isinstance(fsdp_param.sharded_param.grad, DTensor):
raise AssertionError(
f"Expected fsdp_param.sharded_param.grad to be DTensor, got {type(fsdp_param.sharded_param.grad)}"
)
fsdp_param.sharded_param.grad._local_tensor += new_sharded_grad
else:
new_sharded_dtensor_grad = fsdp_param.to_sharded_dtensor(
new_sharded_grad
)
fsdp_param.sharded_param.grad = new_sharded_dtensor_grad
for hook in (
getattr(fsdp_param.sharded_param, "_post_accumulate_grad_hooks", {})
or {}
).values():
hook(fsdp_param.sharded_param)
padded_sharded_numel = padded_unsharded_size.numel() // world_size
flat_grad_offset += padded_sharded_numel
post_reduce_event = post_reduce_stream.record_event()
# The RS output is allocated in the RS stream and used in the default
# stream (for optimizer). To ensure its memory is not reused for later
# RSs, we do not need extra synchronization since the sharded parameters
# hold refs through the end of backward.
return (
reduce_scatter_input,
reduce_scatter_event,
post_reduce_event,
all_reduce_input,
all_reduce_event,
None,
)
def foreach_reduce_scatter_copy_in(
unsharded_grads: list[torch.Tensor],
reduce_scatter_input: torch.Tensor,
world_size: int,
) -> None:
reduce_scatter_input = reduce_scatter_input.view(world_size, -1)
torch.ops.fsdp.chunk_cat(
unsharded_grads, dim=0, num_chunks=world_size, out=reduce_scatter_input
)
def _get_all_gather_input_metadatas(
param_all_gather_inputs: list[list[torch.Tensor]],
) -> tuple[list[list[torch.dtype]], list[list[int]], torch.dtype]:
param_all_gather_input_dtypes: list[list[torch.dtype]] = []
param_all_gather_input_numels: list[list[int]] = []
all_gather_dtype = param_all_gather_inputs[0][0].dtype
for all_gather_inputs in param_all_gather_inputs:
input_dtypes: list[torch.dtype] = []
input_numels: list[int] = []
for all_gather_input in all_gather_inputs:
if all_gather_input.dtype != all_gather_dtype:
all_gather_dtype = torch.uint8
input_dtypes.append(all_gather_input.dtype)
input_numels.append(all_gather_input.numel())
param_all_gather_input_dtypes.append(input_dtypes)
param_all_gather_input_numels.append(input_numels)
return (
param_all_gather_input_dtypes,
param_all_gather_input_numels,
all_gather_dtype,
)
def _get_gradient_divide_factors(
reduce_scatter_group: dist.ProcessGroup | None,
all_reduce_group: dist.ProcessGroup | None,
reduce_dtype: torch.dtype,
device_type: str = "",
factor: float | None = None,
force_sum_reduction_for_comms: bool = False,
) -> tuple[
float | None,
float | None,
dist.ReduceOp | dist.ReduceOp.RedOpType,
dist.ReduceOp | dist.ReduceOp.RedOpType,
]:
# MTIA appears to only support SUM reduction, hence we force it implicitly
if device_type == "mtia":
force_sum_reduction_for_comms = True
# For fp32/bf16, we do not need to worry about overflow/underflow, so we
# use NCCL's built-in division to avoid separate div kernels
overflow_risk = reduce_dtype not in (torch.float32, torch.bfloat16)
if reduce_scatter_group is not None:
data_parallel_size = reduce_scatter_group.size()
else:
data_parallel_size = 1
if all_reduce_group is not None:
data_parallel_size *= all_reduce_group.size()
if not overflow_risk and not force_sum_reduction_for_comms:
if factor is None:
# Warning: NCCL ReduceOp.AVG may produce incorrect results with
# world size 1.
if data_parallel_size == 1:
return None, None, ReduceOp.SUM, ReduceOp.SUM
return None, None, ReduceOp.AVG, ReduceOp.AVG
if reduce_scatter_group is not None and factor == reduce_scatter_group.size():
reduce_scatter_op = ReduceOp.AVG
else:
reduce_scatter_op = torch.distributed._make_nccl_premul_sum(1 / factor)
return None, None, reduce_scatter_op, ReduceOp.SUM
if factor is None:
factor = float(data_parallel_size)
pre_factor: float | None
if overflow_risk:
# Since fp16 has smaller dynamic range than fp32/bf16, we want to avoid
# overflow/underflow. For N data parallel workers, each worker computes
# g_i, and they collectively reduce (g_1 + ... + g_N) / N. To avoid
# overflow/underflow, we divide by ~sqrt(N) before/after the reduction.
pre_factor = 1
while factor % pre_factor == 0 and factor / pre_factor > pre_factor:
pre_factor *= 2
post_factor = factor / pre_factor
else:
# Prefer post-multiplying as it operates on less data and is thus faster
pre_factor, post_factor = None, factor
return pre_factor, post_factor, ReduceOp.SUM, ReduceOp.SUM
def _div_if_needed(tensor: torch.Tensor, div_factor: float | None) -> None:
if div_factor is not None and div_factor != 1:
tensor.div_(div_factor)
@@ -0,0 +1,207 @@
# mypy: allow-untyped-defs
import functools
import math
import traceback
from dataclasses import dataclass, field
from enum import auto, Enum
from typing import Any
import torch
import torch.distributed as dist
import torch.nn as nn
from torch.distributed._composable.contract import _get_registry
from torch.distributed.tensor import DeviceMesh, DTensor, Shard
from torch.distributed.tensor._dtensor_spec import DTensorSpec
from ._fsdp_api import DataParallelMeshDims
def _dynamo_disable(func):
"""Disable dynamo tracing for FSDP hooks."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
return torch._dynamo.disable(
func, recursive=True, reason="skipping FSDP hooks"
)(*args, **kwargs)
return wrapper
@dataclass
class DataParallelMeshInfo:
mesh: DeviceMesh
shard_mesh_dim: int | None = None
replicate_mesh_dim: int | None = None
dp_mesh_dims: DataParallelMeshDims | None = None
# The full SPMD mesh (excluding PP dims) that params are distributed on.
# Must include all non-PP SPMD dims (e.g. DP + TP); passing a submesh
# that omits dims like TP will lead to incorrect behavior.
spmd_mesh: DeviceMesh | None = field(default=None, repr=False)
is_spmd_mesh: bool = field(default=False, init=False, repr=False)
def __post_init__(self):
if self.shard_mesh_dim is None and self.replicate_mesh_dim is None:
raise AssertionError(
"At least one of shard_mesh_dim and replicate_mesh_dim must not be None"
)
self.is_spmd_mesh = self.dp_mesh_dims is not None
@dataclass
class FSDPMeshInfo(DataParallelMeshInfo):
def __post_init__(self):
super().__post_init__()
if self.shard_mesh_dim is None:
raise AssertionError("Expects non-None shard_mesh_dim")
self.shard_mesh_size: int = self.mesh.size(self.shard_mesh_dim)
self.shard_process_group = self.mesh.get_group(self.shard_mesh_dim)
self.shard_mesh_rank: int = self.shard_process_group.rank()
@dataclass
class DDPMeshInfo(DataParallelMeshInfo):
def __post_init__(self):
super().__post_init__()
if self.replicate_mesh_dim is None:
raise AssertionError("Expects non-None replicate_mesh_dim")
self.replicate_mesh_size: int = self.mesh.size(self.replicate_mesh_dim)
self.replicate_process_group = self.mesh.get_group(self.replicate_mesh_dim)
self.replicate_mesh_rank: int = self.replicate_process_group.rank()
@dataclass
class HSDPMeshInfo(FSDPMeshInfo, DDPMeshInfo):
def __post_init__(self): # pylint:disable=useless-parent-delegation
# Calls `FSDPMeshInfo` -> `DDPMeshInfo` -> `DataParallelMeshInfo`
super().__post_init__()
class TrainingState(Enum):
"""Describes the training state of one FSDP state / parameter group."""
# Transition to forward starting pre-forward until post-forward
FORWARD = auto()
# Transition to pre-backward when unsharding in backward
PRE_BACKWARD = auto()
# Transition to post-backward when resharding and reducing gradients
POST_BACKWARD = auto()
# Idle before/after forward or before pre-backward/after post-backward
IDLE = auto()
def _raise_assert_with_print(*args: Any, **kwargs: Any):
print(f"[Rank {dist.get_rank()}] ", end="")
print(*args, **kwargs)
traceback.print_stack()
raise AssertionError(*args, **kwargs)
def _is_composable_with_fsdp(module: nn.Module) -> bool:
registry = _get_registry(module)
if registry is None:
return True
# Registry keys by function name
return "replicate" not in registry
def _get_dim0_padded_size(tensor_size: torch.Size, dim0_factor: int) -> torch.Size:
padded_dim0 = math.ceil(tensor_size[0] / dim0_factor) * dim0_factor
return torch.Size([padded_dim0]) + tensor_size[1:]
def _chunk_with_empty(
tensor: torch.Tensor, num_chunks: int, dim: int
) -> list[torch.Tensor]:
chunks = list(torch.chunk(tensor, num_chunks, dim=dim))
while len(chunks) < num_chunks:
chunks.append(chunks[0].new_empty(0))
return chunks
def _get_dim_chunked_size(
chunk: torch.Tensor, unchunked_size: torch.Size, dim: int
) -> torch.Size:
if chunk.numel() > 0:
return chunk.size()
# For 0 numel, we need to preserve nonzero-sized dims for DTensor APIs
# pyrefly: ignore [bad-return]
return unchunked_size[:dim] + torch.Size([0]) + unchunked_size[dim + 1 :]
def _from_local_no_grad(
local_tensor: torch.Tensor,
sharding_spec: DTensorSpec,
) -> DTensor:
"""
This method is similar to ``DTensor.from_local()`` except that in eager mode
it avoids some CPU overhead by avoiding default args and not being differentiable.
"""
# pyrefly: ignore [bad-argument-type]
return DTensor(
# Use the local tensor directly instead of constructing a new tensor
# variable, e.g. with `view_as()`, since this is not differentiable
# pyrefly: ignore [bad-argument-count]
local_tensor,
sharding_spec,
# pyrefly: ignore [unexpected-keyword]
requires_grad=local_tensor.requires_grad,
)
def _to_dtype_if_needed(
tensor: torch.Tensor, dtype: torch.dtype | None
) -> torch.Tensor:
if dtype is not None and tensor.dtype != dtype:
return tensor.to(dtype)
return tensor
def _cast_fp_tensor(dtype: torch.dtype, x: torch.Tensor) -> torch.Tensor:
if (
not isinstance(x, torch.Tensor)
or not torch.is_floating_point(x)
or x.dtype == dtype
):
return x
return x.to(dtype)
def is_bw() -> bool:
return torch._C._current_graph_task_id() != -1
@dataclass
class ShardPlacementResult:
placement: Shard | None
mesh_info: FSDPMeshInfo
ShardPlacementFnResult = Shard | ShardPlacementResult | None
def resolve_shard_placement(
result: ShardPlacementFnResult,
default_mesh_info: FSDPMeshInfo,
) -> ShardPlacementResult:
"""Resolve the shard_placement_fn result to a ShardPlacementResult.
Handles different input types and applies defaults:
- None: Use default sharding (Shard(0)) on default mesh
- Shard: Use specified shard dimension on default mesh
- ShardPlacementResult: Use as-is
Args:
result: The return value from shard_placement_fn, or None if no fn provided.
default_mesh_info: The default FSDPMeshInfo to use if not specified.
Returns:
A ShardPlacementResult with placement and mesh_info.
"""
if result is None:
return ShardPlacementResult(placement=None, mesh_info=default_mesh_info)
if isinstance(result, Shard):
return ShardPlacementResult(placement=result, mesh_info=default_mesh_info)
if isinstance(result, ShardPlacementResult):
return result
raise ValueError(f"Invalid shard_placement_fn result: {result}")
@@ -0,0 +1,569 @@
import itertools
import logging
from typing import TYPE_CHECKING
import torch
import torch.distributed as dist
import torch.nn as nn
from torch._logging import warning_once
from torch.distributed.device_mesh import _get_device_handle
from torch.distributed.tensor import DeviceMesh, DTensor, init_device_mesh
from torch.utils._python_dispatch import is_traceable_wrapper_subclass
from ._fsdp_common import (
_is_composable_with_fsdp,
DataParallelMeshInfo,
DDPMeshInfo,
FSDPMeshInfo,
HSDPMeshInfo,
)
from ._fsdp_state import _get_module_fsdp_state
if TYPE_CHECKING:
from collections.abc import Callable
from typing import Any
from ._fsdp_api import DataParallelMeshDims, MixedPrecisionPolicy, OffloadPolicy
from ._fsdp_common import ShardPlacementFnResult
from ._fsdp_state import FSDPState
logger = logging.getLogger("torch.distributed.fsdp.fully_shard")
def _validate_module(module: nn.Module, func_name: str) -> None:
"""
Validate that the module can be used with fully_shard or replicate.
Raises ValueError if the module is a container that doesn't implement forward.
"""
if (
isinstance(module, (nn.ModuleList, nn.ModuleDict))
and module.__class__.forward is nn.Module.forward
):
raise ValueError(
f"{func_name} does not support containers that do not implement forward: {module}"
)
def _validate_mesh(
mesh: "DeviceMesh",
dp_mesh_dims: "DataParallelMeshDims | None" = None,
) -> None:
"""
Validate that the mesh can be used with fully_shard.
When ``dp_mesh_dims`` is provided, validates that the named dims
exist in the mesh and at least one of shard/replicate is set.
Otherwise raises ValueError if the mesh is not 1D or 2D.
"""
if dp_mesh_dims is not None:
if dp_mesh_dims.shard is None and dp_mesh_dims.replicate is None:
raise ValueError(
"At least one of shard or replicate must be set in dp_mesh_dims"
)
if mesh.mesh_dim_names is None:
raise ValueError(
"mesh must have mesh_dim_names when dp_mesh_dims is provided"
)
names_to_check: list[str] = list(dp_mesh_dims.shard_names)
names_to_check.extend(dp_mesh_dims.replicate_names)
for name in names_to_check:
if name not in mesh.mesh_dim_names:
raise ValueError(
f"Mesh dim name '{name}' not found in mesh.mesh_dim_names "
f"{mesh.mesh_dim_names}"
)
return
if mesh.ndim not in (1, 2):
raise ValueError(f"fully_shard expects a 1D or 2D DeviceMesh but got {mesh}")
if mesh.ndim == 2 and mesh.mesh_dim_names is None:
raise AssertionError(
"Please init the 2D mesh for HSDP with mesh_dim_names specified"
)
def _get_mesh_info(
mesh: "DeviceMesh",
dp_mesh_dims: "DataParallelMeshDims | None" = None,
) -> "DataParallelMeshInfo":
"""
Get the appropriate mesh info for the given mesh.
When ``dp_mesh_dims`` is provided, extracts the DP submesh from the
full SPMD mesh and returns FSDPMeshInfo, HSDPMeshInfo, or DDPMeshInfo
with ``dp_mesh_dims`` set and ``is_spmd_mesh`` as True.
Returns FSDPMeshInfo for 1D mesh, HSDPMeshInfo for 2D mesh.
"""
if dp_mesh_dims is not None:
return _get_mesh_info_from_named_dims(mesh, dp_mesh_dims)
if mesh.ndim == 1:
return FSDPMeshInfo(mesh, shard_mesh_dim=0)
else:
return HSDPMeshInfo(mesh, shard_mesh_dim=1, replicate_mesh_dim=0)
def _get_mesh_info_from_named_dims(
mesh: "DeviceMesh",
dp_mesh_dims: "DataParallelMeshDims",
) -> "DataParallelMeshInfo":
shard_names = dp_mesh_dims.shard_names
replicate_names = dp_mesh_dims.replicate_names
def _get_submesh(names: tuple[str, ...]) -> "DeviceMesh":
if len(names) == 1:
return mesh[names[0]]
# Flatten multi-dim submesh into a single dim so FSDP's internal
# logic (which expects one shard and/or one replicate dim) works
# unchanged. This creates a new 1D DeviceMesh and ProcessGroup.
return mesh[names]._flatten("_".join(names))
if len(shard_names) == 0: # DDP
dp_mesh = _get_submesh(replicate_names)
return DDPMeshInfo(
dp_mesh,
replicate_mesh_dim=0,
dp_mesh_dims=dp_mesh_dims,
spmd_mesh=mesh,
)
if len(replicate_names) == 0: # FSDP
dp_mesh = _get_submesh(shard_names)
return FSDPMeshInfo(
dp_mesh,
shard_mesh_dim=0,
dp_mesh_dims=dp_mesh_dims,
spmd_mesh=mesh,
)
# HSDP
shard_mesh = _get_submesh(shard_names)
replicate_mesh = _get_submesh(replicate_names)
dp_mesh = DeviceMesh._concatenate([replicate_mesh, shard_mesh])
return HSDPMeshInfo(
dp_mesh,
shard_mesh_dim=1,
replicate_mesh_dim=0,
dp_mesh_dims=dp_mesh_dims,
spmd_mesh=mesh,
)
def _get_post_forward_mesh_info(
reshard_after_forward: bool | int, mesh_info: FSDPMeshInfo
) -> FSDPMeshInfo | None:
shard_mesh_size = mesh_info.shard_mesh_size
if not isinstance(reshard_after_forward, (bool, int)):
raise ValueError(
"reshard_after_forward should be a bool or an int representing the "
f"group size to reshard to, not {reshard_after_forward}"
)
# NOTE: `isinstance(False, int)` returns `True`.
if not isinstance(reshard_after_forward, bool) and isinstance(
reshard_after_forward, int
):
if (
reshard_after_forward < 1
or reshard_after_forward > shard_mesh_size
or shard_mesh_size % reshard_after_forward != 0
):
raise ValueError(
"If passing reshard_after_forward as an int, it should be a "
f"factor of {shard_mesh_size}, not {reshard_after_forward}"
)
elif reshard_after_forward == 1:
msg = (
"reshard_after_forward=1 (int) means resharding parameters to world size 1, "
"instead of reshard_after_forward=True (bool)"
)
warning_once(logger, msg, stacklevel=2)
reshard_after_forward = False
elif reshard_after_forward == shard_mesh_size:
reshard_after_forward = True
post_forward_mesh_info = None
if reshard_after_forward is True:
post_forward_mesh_info = mesh_info
elif reshard_after_forward is not False: # int case
# For HSDP, we can flatten the two replicate dims into the 0th dim
post_forward_mesh_tensor = mesh_info.mesh.mesh.view(-1, reshard_after_forward)
post_forward_mesh = DeviceMesh(
mesh_info.mesh.device_type, post_forward_mesh_tensor
)
post_forward_mesh_info = HSDPMeshInfo(
post_forward_mesh, shard_mesh_dim=1, replicate_mesh_dim=0
)
return post_forward_mesh_info
def _init_default_mesh(
mesh_dim_names: tuple[str, ...] | None = None,
) -> DeviceMesh:
"""Default to global CUDA mesh if possible else global CPU mesh."""
if not dist.distributed_c10d.is_initialized():
dist.distributed_c10d.init_process_group()
default_pg = dist.distributed_c10d._get_default_group()
device = torch._C._get_accelerator()
mesh = init_device_mesh(
device.type,
mesh_shape=(default_pg.size(),),
mesh_dim_names=mesh_dim_names,
)
return mesh
def _init_default_fully_shard_mesh() -> DeviceMesh:
"""Default to global CUDA mesh if possible else global CPU mesh."""
return _init_default_mesh()
def _get_device_from_mesh(mesh: DeviceMesh) -> torch.device:
if mesh.device_type == "cpu":
return torch.device("cpu")
device_handle = _get_device_handle(mesh.device_type)
return torch.device(mesh.device_type, device_handle.current_device())
def _ignore_module(
module: nn.Module,
ignored_params: set[nn.Parameter],
ignore_decision: dict[nn.Module, bool],
) -> bool:
"""
Decide if it is safe to ignore a module for applying fully_shard.
"""
if module in ignore_decision:
return ignore_decision[module]
if len(list(module.buffers(recurse=False))) > 0:
# Cannot ignore a module with any buffer
ignore_decision[module] = False
return False
for _, param in module.named_parameters(recurse=False):
if param not in ignored_params:
# at least one param is not ignored. So this module shouldn't be.
ignore_decision[module] = False
return False
# Need to consider descendants of module
for child in list(module.children()):
ignore_child = _ignore_module(child, ignored_params, ignore_decision)
if not ignore_child:
# Cannot ignore module if one of its children is not ignored
ignore_decision[module] = False
return False
# Safe to ignore module
ignore_decision[module] = True
return True
def _adjust_managed_modules(
modules: list[nn.Module], ignored_params: set[nn.Parameter]
) -> list[nn.Module]:
"""
Adjust the given list of managed modules by removing those with all parameters ignored.
"""
ignore_decision: dict[nn.Module, bool] = {}
new_modules = []
for module in modules:
ignored = _ignore_module(module, ignored_params, ignore_decision)
if not ignored:
new_modules.append(module)
return new_modules
def _get_managed_modules(
root_modules: tuple[nn.Module, ...],
ignored_params: set[nn.Parameter] | None = None,
is_composable_fn: "Callable[[nn.Module], bool] | None" = None,
get_state_fn: "Callable[[nn.Module], Any] | None" = None,
) -> list[nn.Module]:
"""
Get the list of managed modules for FSDP/replicate.
Args:
root_modules: The root modules to start the search from.
ignored_params: Parameters to ignore.
is_composable_fn: Callable to check if a module is composable.
Defaults to ``_is_composable_with_fsdp``.
get_state_fn: Callable to get the state of a module.
Defaults to ``_get_module_fsdp_state``.
"""
if is_composable_fn is None:
is_composable_fn = _is_composable_with_fsdp
if get_state_fn is None:
get_state_fn = _get_module_fsdp_state
modules: list[nn.Module] = []
root_modules_set = set(root_modules)
# Track visisted modules to avoid visiting shared modules multiple times
visited_modules: set[nn.Module] = set()
def dfs(module: nn.Module) -> None:
"""
Runs a DFS to collect managed modules, not recursing into modules with
a non-composable API or ``fully_shard`` already applied.
"""
if not is_composable_fn(module):
return
elif module not in root_modules_set and get_state_fn(module) is not None:
return # nested `fully_shard` module
visited_modules.add(module)
for submodule in module.children():
if submodule not in visited_modules:
dfs(submodule)
modules.append(module)
for root_module in root_modules:
dfs(root_module)
if ignored_params is None:
return modules
adjusted_modules = _adjust_managed_modules(modules, ignored_params)
return adjusted_modules
def _verify_managed_param(name: str, param: nn.Parameter) -> None:
"""
Verify if the parameter is accepted by fully_shard. The only restriction now
is that the parameter cannot be a scalar tensor (param.numel == 0) since we
need at least one dim to shard.
"""
if len(param.shape) == 0:
raise ValueError(
"fully_shard doesn't support scalar parameters. "
f"Change {name} to a 1D tensor with numel equal to 1."
)
def _get_managed_states(
modules: list[nn.Module], ignored_params: set[nn.Parameter] | None = None
) -> tuple[list[nn.Parameter], list[torch.Tensor]]:
params: list[nn.Parameter] = []
buffers: list[torch.Tensor] = []
# Track visited parameters/buffers to avoid visiting shared parameters and
# buffers multiple times
visited_params: set[nn.Parameter] = set()
visited_buffers: set[torch.Tensor] = set()
if ignored_params is None:
ignored_params = set()
for module in modules:
for name, param in module.named_parameters(recurse=False):
if param in ignored_params:
# do not include an ignored parameters
continue
if param not in visited_params:
_verify_managed_param(name, param)
params.append(param)
visited_params.add(param)
for buffer in module.buffers(recurse=False):
if buffer not in visited_buffers:
buffers.append(buffer)
visited_buffers.add(buffer)
return params, buffers
def _move_states_to_device(
params: list[nn.Parameter],
buffers: list[torch.Tensor],
device: torch.device,
) -> None:
"""
We have FSDP move states to device for simpler and faster initialization
since FSDP almost always uses CUDA for training. We move parameters/buffers
rather than modules since modules to support ignoring parameters/buffers in
the future.
"""
# Follow the logic in `nn.Module._apply`
# pyrefly: ignore [bad-argument-type]
for tensor in itertools.chain(params, buffers):
if tensor.device == device or tensor.device.type == "meta":
# Keep meta-device tensors on meta device for deferred init
continue
if isinstance(tensor, DTensor):
if (dtensor_mesh_type := tensor.device_mesh.device_type) != device.type:
raise ValueError(
"Requires DTensor to have mesh of the same type as the FSDP mesh "
f"but got {dtensor_mesh_type} for DTensor and {device.type} for FSDP"
)
raise AssertionError(
f"Expects DTensor to be moved to {dtensor_mesh_type} but got {tensor.device}"
)
tensor_ = tensor
if is_traceable_wrapper_subclass(tensor_):
with torch.no_grad(): # avoid autograd increasing C++ refcount by 1
tensor_on_device = nn.Parameter(tensor.to(device))
torch.utils.swap_tensors(tensor, tensor_on_device)
else:
tensor.data = tensor.to(device)
def _apply_to_module(
modules: tuple[nn.Module, ...],
cls_to_wrapper_cls: dict[type, type],
wrapper_module_cls: type,
wrapper_cls_prefix: str,
unimplemented_deepcopy: "Callable",
) -> None:
"""
Modify module classes to include the wrapper class in their MRO.
Args:
modules: The modules to apply the wrapper to.
cls_to_wrapper_cls: Cache dict mapping original class to wrapper class.
wrapper_module_cls: The wrapper module class (e.g., FSDPModule, ReplicateModule).
wrapper_cls_prefix: Prefix for the dynamically created class name (e.g., "FSDP", "Replicate").
unimplemented_deepcopy: The deepcopy function to use for the wrapper class.
"""
for module in modules:
cls = module.__class__
new_cls = cls_to_wrapper_cls.get(cls)
if not new_cls:
dct = {"__deepcopy__": unimplemented_deepcopy}
new_cls = type(
f"{wrapper_cls_prefix}{cls.__name__}", (wrapper_module_cls, cls), dct
)
cls_to_wrapper_cls[cls] = new_cls
module.__class__ = new_cls
def _init_param_group(
state: "FSDPState",
params: list[nn.Parameter],
modules: tuple[nn.Module, ...],
mesh_info: DataParallelMeshInfo,
post_forward_mesh_info: FSDPMeshInfo | None,
device: torch.device,
shard_placement_fn: "Callable[[nn.Parameter], ShardPlacementFnResult] | None",
mp_policy: "MixedPrecisionPolicy",
offload_policy: "OffloadPolicy",
reshard_after_forward: bool | int = True,
) -> None:
"""
Initialize FSDP param groups for the given state.
Params are grouped by their process group (derived from ``mesh_info`` via
``shard_placement_fn``). Each group becomes a separate ``FSDPParamGroup``.
When ``shard_placement_fn`` is ``None`` or returns the same mesh for all
params, this creates a single group.
"""
# Import here to avoid circular imports
from ._fsdp_common import FSDPMeshInfo, resolve_shard_placement
from ._fsdp_param_group import FSDPParamGroup
if not params:
return
if shard_placement_fn is None:
# No shard_placement_fn means all params use the same mesh_info,
# so no grouping is needed. This also handles DDPMeshInfo from
# replicate_with_fsdp, which doesn't have shard_process_group.
state._fsdp_param_groups.append(
FSDPParamGroup(
params,
modules,
mesh_info,
post_forward_mesh_info,
device,
shard_placement_fn,
mp_policy,
offload_policy,
)
)
return
# Group params by their process group to support per-param mesh,
# e.g., expert params using ep_mesh vs regular params using dp_mesh.
# For HSDP, also key by replicate_process_group to avoid grouping
# FSDPMeshInfo params with HSDPMeshInfo params that share the same
# shard_process_group but require different gradient reduction behavior.
if not isinstance(mesh_info, FSDPMeshInfo):
raise ValueError(
"Per-param mesh via shard_placement_fn is not supported with "
f"{type(mesh_info).__name__}; it requires FSDPMeshInfo (or subclass)"
)
pg_to_group: dict[
tuple[dist.ProcessGroup, dist.ProcessGroup | None],
tuple[FSDPMeshInfo, list[nn.Parameter]],
] = {}
for param in params:
param_mesh_info = resolve_shard_placement(
shard_placement_fn(param),
mesh_info,
).mesh_info
shard_pg = param_mesh_info.shard_process_group
replicate_pg: dist.ProcessGroup | None = None
if isinstance(param_mesh_info, HSDPMeshInfo):
replicate_pg = param_mesh_info.replicate_process_group
key = (shard_pg, replicate_pg)
if key not in pg_to_group:
pg_to_group[key] = (param_mesh_info, [param])
else:
existing_mesh_info = pg_to_group[key][0]
if existing_mesh_info is not param_mesh_info:
raise ValueError(
f"Params sharing the same process group must use the same "
f"FSDPMeshInfo object, but got different objects: "
f"{existing_mesh_info} vs {param_mesh_info}"
)
pg_to_group[key][1].append(param)
# Create a FSDPParamGroup per process group
for group_mesh_info, group_params in pg_to_group.values():
if group_mesh_info is not mesh_info:
group_post_forward = _get_post_forward_mesh_info(
reshard_after_forward, group_mesh_info
)
else:
group_post_forward = post_forward_mesh_info
state._fsdp_param_groups.append(
FSDPParamGroup(
group_params,
modules,
group_mesh_info,
group_post_forward,
device,
shard_placement_fn,
mp_policy,
offload_policy,
)
)
def _get_modules_and_states(
module: nn.Module,
device: torch.device,
ignored_params: set[nn.Parameter] | None,
is_composable_fn: "Callable[[nn.Module], bool] | None" = None,
get_state_fn: "Callable[[nn.Module], Any] | None" = None,
) -> tuple[
nn.Module,
tuple[nn.Module, ...],
list[nn.Module],
list[nn.Parameter],
list[torch.Tensor],
]:
"""
Get modules tuple, managed modules, params, and buffers for FSDP/replicate initialization.
Returns:
Tuple of (arg_module, modules, managed_modules, params, buffers)
"""
from torch.distributed.utils import _get_root_modules
arg_module = module
modules = (
(module,) if isinstance(module, nn.Module) else tuple(_get_root_modules(module))
)
managed_modules = _get_managed_modules(
modules, ignored_params, is_composable_fn, get_state_fn
)
params, buffers = _get_managed_states(managed_modules, ignored_params)
_move_states_to_device(params, buffers, device)
return arg_module, modules, managed_modules, params, buffers
@@ -0,0 +1,923 @@
# mypy: allow-untyped-defs
from __future__ import annotations
import contextlib
import logging
from typing import Any, cast, Literal, NamedTuple, TYPE_CHECKING
import torch
import torch.distributed as dist
import torch.nn as nn
from torch.distributed.device_mesh import _get_device_handle
from torch.distributed.fsdp._common_utils import (
_named_parameters_with_duplicates,
collect_grad_tensors,
replace_grad_tensors,
)
from torch.profiler import record_function
from torch.utils.hooks import RemovableHandle
from ._fsdp_api import CPUOffloadPolicy, MixedPrecisionPolicy, OffloadPolicy
from ._fsdp_collectives import (
AllGather,
AllGatherResult,
DefaultAllGather,
DefaultReduceScatter,
foreach_all_gather,
foreach_all_gather_copy_out,
foreach_reduce,
ProcessGroupAllocAllGather,
ProcessGroupAllocReduceScatter,
ReduceScatter,
SymmMemAllGather,
SymmMemReduceScatter,
)
from ._fsdp_common import (
_dynamo_disable,
DataParallelMeshInfo,
DDPMeshInfo,
FSDPMeshInfo,
HSDPMeshInfo,
is_bw,
ShardPlacementFnResult,
TrainingState,
)
from ._fsdp_param import alloc_storage, FSDPParam, ParamModuleInfo, ShardedState
if TYPE_CHECKING:
from collections.abc import Callable
logger = logging.getLogger("torch.distributed.fsdp.fully_shard")
_ModuleToHandleDict = dict[nn.Module, RemovableHandle] # for state dict
"""
[Note: Overlapping all-gather copy-in and all-gather]
For implicit forward prefetching, we want to overlap the next copy-in with the
current all-gather. We do so using a separate copy-in stream. However, since
we have the all-gather input as a view into the output, we must make sure to
copy into different memory from the current all-gather's output. Thus, we keep
a reference to the current all-gather's output and have the next FSDP parameter
group free it after its copy-in. Finally, we have the last FSDP state flush the
reference to avoid holding onto memory after forward.
"""
class FSDPCommContext:
"""This has the communication state shared across FSDP states/parameter groups."""
def lazy_init(self, device: torch.device):
self.device_handle = _get_device_handle(device.type)
# Setting the all-gather/reduce-scatter streams to be higher priority
# can help avoid some issues where their copies in/out are delayed and
# block computation (this is different from high-pri NCCL streams)
high_priority = -1
# All-gather state and copy-in stream allow overlapping the next
# copy-in with the current all-gather in forward; copy-in overlaps with
# reduce-scatter in backward without the separate copy-in stream
self.all_gather_copy_in_stream = self.device_handle.Stream(
priority=high_priority
)
# All-gather stream allows overlapping next all-gather with current
# forward compute
self.all_gather_stream = self.device_handle.Stream(priority=high_priority)
# Reduce-scatter stream gives separate execution "thread" for post-
# backward logic like pre/post-gradient division and reduce-scatter
self.reduce_scatter_stream = self.device_handle.Stream(priority=high_priority)
# Run the HSDP all-reduces concurrently with all-gather/reduce-scatter
# since collectives use different network resources and can overlap
# in the typical intra-node sharding / inter-node replication case
self.all_reduce_stream = self.device_handle.Stream()
# All-gather/reduce-scatter states keep references to collective
# tensors produced in one stream and used in another and accompanying
# CUDA events for synchronization
self.all_gather_state: AllGatherState | None = None
self.reduce_scatter_states: list[ReduceScatterState] = []
# Post-forward order for explicit backward prefetching
self.post_forward_order: list[FSDPParamGroup] = [] # will cause ref cycles
def get_all_gather_streams(
self, async_op: bool, training_state: TrainingState
) -> tuple[torch.Stream, torch.Stream]:
if not async_op and training_state in (
TrainingState.FORWARD,
TrainingState.PRE_BACKWARD,
):
# Use separate streams for implicit prefetching
return self.all_gather_copy_in_stream, self.all_gather_stream
current_stream = self.device_handle.current_stream()
return current_stream, current_stream
# See [Note: Overlapping all-gather copy-in and all-gather]
class AllGatherState(NamedTuple):
all_gather_result: AllGatherResult
event: torch.Event | None # all-gather copy-out
class ReduceScatterState(NamedTuple):
reduce_scatter_input: torch.Tensor
event: torch.Event | None # reduce-scatter event
class AllReduceState(NamedTuple):
all_reduce_input: torch.Tensor
event: torch.Event | None # all-reduce event
class FSDPParamGroup:
"""This class represents a parameter group to communicate together."""
_orig_dtype: torch.dtype | None
_reduce_dtype: torch.dtype | None
def __init__(
self,
params: list[nn.Parameter],
modules: tuple[nn.Module, ...],
mesh_info: DataParallelMeshInfo,
post_forward_mesh_info: FSDPMeshInfo | None,
device: torch.device,
shard_placement_fn: Callable[[nn.Parameter], ShardPlacementFnResult] | None,
mp_policy: MixedPrecisionPolicy,
offload_policy: OffloadPolicy,
):
self.modules = modules # permit ref cycle because 1:1 lifetime
param_module_infos = _get_param_module_infos(params, modules)
self.fsdp_params = [
FSDPParam(
param,
module_info,
mesh_info,
post_forward_mesh_info,
device,
shard_placement_fn,
mp_policy,
offload_policy,
)
for param, module_info in zip(params, param_module_infos)
]
self.mesh_info = mesh_info
self.post_forward_mesh_info = post_forward_mesh_info
self.device = device
self.device_handle = _get_device_handle(device.type)
self.mp_policy = mp_policy
self.offload_policy = offload_policy
self._training_state = TrainingState.IDLE
# Group's sharded state always matches its parameters' sharded states
self._sharded_state = ShardedState.SHARDED
self._module_fqn: str | None = None # prefixed from root module
# Only consider resetting sharded parameters once in lazy init since it
# can incur nontrivial overhead to reset them
self._reset_sharded_params: bool = False
# - Hook state
self._module_to_pre_save_state_dict_hook_handle: _ModuleToHandleDict = {}
self._module_to_pre_load_state_dict_hook_handle: _ModuleToHandleDict = {}
self._all_reduce_hook: Callable[[torch.Tensor], None] | None = None
self._all_gather_comm: AllGather = DefaultAllGather()
self._all_gather_output = torch.empty(0, device=self.device)
self._reduce_scatter_comm: ReduceScatter = DefaultReduceScatter()
# Optional stream to run the user-defined all-reduce hook in
# Saved here and not in the comm. context because we allow the user to
# specify it, possibly at construction time before lazy init
self._all_reduce_hook_stream: torch.cuda.Stream | None = None
# - Communication and communication/computation overlap
self.comm_ctx = FSDPCommContext()
self._param_group_index: int = 0
self._num_param_groups: int = 1
# Group's indices in the shared post-forward order
self._post_forward_indices: list[int] = []
# Whether to reduce gradients at all (whether for FSDP or HSDP)
self.reduce_grads: bool = True
# Whether to all-reduce gradients for HSDP; only used if
# `self.reduce_grads` is true, in which case setting this to false
# means reduce-scatter but no all-reduce
self.all_reduce_grads: bool = True
# Whether to reshard parameters after backward (only useful for
# gradient accumulation)
self.reshard_after_backward: bool = True
# Optional custom factor for the gradient reduction op (e.g. to divide
# by a factor other than the world size)
self.gradient_divide_factor: float | None = None
# Whether reduce-scatter and all-reduce should be issued using only
# summations, potentially with separate pre-/post-scaling.
self.force_sum_reduction_for_comms: bool = False
# `async_op` arg used for pre-forward/pre-backward unshard; can be
# overridden to only do explicit prefetching and avoid inter-stream
# fragmentation from using separate unshard streams
self.unshard_async_op: bool = False
# Whether to unshard in backward: can be overridden by the user if the
# parameters in this group are not needed for backward (e.g. embedding)
self.unshard_in_backward: bool = True
# - CUDA events for stream synchronization
# Holds the all-gather output buffer, sync objects, and metadata
self._all_gather_result: AllGatherResult | None = None
# Holds the reduce-scatter/all-reduce view-out CUDA event that marks the end of
# the group's post-backward (e.g. reduce-scatter, all-reduce and div), which
# should be waited on at the end of backward
self._post_reduce_event: torch.Event | None = None
# Holds the reshard-after-forward CUDA event when resharding to a
# different world size, which should be waited on in the next unshard
self._reshard_after_forward_event: torch.Event | None = None
# Only for HSDP, if accumulating gradients without all-reduce, save the
# partial reduce output (only reduce-scattered but not all-reduced)
self._partial_reduce_output: torch.Tensor | None = None
# Holds the all-reduce input and all-reduce event to keep it alive
# until the end of backward (critical when doing bf16 reduction with
# fp32 parameters since the all-reduce input is allocated in the RS
# stream and will have no refs to it after being upcast to fp32)
self._all_reduce_state: AllReduceState | None = None
# Initialization #
def _init_mp_dtypes(self) -> None:
for fsdp_param in self.fsdp_params:
fsdp_param.init_dtype_attrs(self.mp_policy)
trainable_params: list[FSDPParam] = [
p for p in self.fsdp_params if p.sharded_param.requires_grad
]
orig_dtypes = {p.orig_dtype for p in trainable_params}
reduce_dtypes = {p.reduce_dtype for p in trainable_params}
if len(trainable_params) > 0 and len(orig_dtypes) != 1:
# Models may have no grad params
raise AssertionError(
f"FSDP expects uniform original parameter dtype but got {orig_dtypes}"
)
self._orig_dtype = next(iter(orig_dtypes)) if trainable_params else None
if len(trainable_params) > 0 and len(reduce_dtypes) != 1:
# This can be relaxed if we issue one reduce-scatter per reduce
# dtype (but we would need a way for users to specify multiple
# reduce dtypes)
raise AssertionError(
f"FSDP expects uniform reduce dtype but got {reduce_dtypes}"
)
self._reduce_dtype = next(iter(reduce_dtypes)) if trainable_params else None
def lazy_init(self):
# Lazy init should be idempotent
# Users may change or register parameters after construction time.
# For example, DoRA (https://arxiv.org/abs/2402.09353) initializes linear magnitudes based on
# other parameters (e.g. loaded from the state dict).
if not hasattr(self.comm_ctx, "device_handle"):
self.comm_ctx.device_handle = _get_device_handle(self.device.type)
if self.is_sharded and not self._reset_sharded_params:
for fsdp_param in self.fsdp_params:
fsdp_param.reset_sharded_param()
fsdp_param._init_extensions() # allow monkey patch after init
self._reset_sharded_params = True
self._validate_no_meta_params()
self._validate_cpu_offload_params()
# Initialize mixed precision attributes lazily in case the user changes
# the parameter dtypes after construction time but before forward
self._init_mp_dtypes()
self._register_state_dict_hooks()
def set_symm_mem(self, backend: Literal["NCCL"] = "NCCL") -> None:
if not isinstance(self._all_gather_comm, (DefaultAllGather | SymmMemAllGather)):
raise AssertionError(
"cannot call set_symm_mem() "
f"when all gather comm is custom: {self._all_gather_comm.__class__.__name__}"
)
self._all_gather_comm = SymmMemAllGather(
self._all_gather_process_group, backend
)
if not isinstance(
self._reduce_scatter_comm, (DefaultReduceScatter | SymmMemReduceScatter)
):
raise AssertionError(
"cannot call set_symm_mem() "
f"when reduce scatter comm is custom: {self._reduce_scatter_comm.__class__.__name__}"
)
if self.force_sum_reduction_for_comms:
# As of NCCL 2.29.3, NCCL symmetric reduce-scatter only supports SUM reduction
self._reduce_scatter_comm = SymmMemReduceScatter(
self._reduce_scatter_process_group, backend
)
def set_allocate_memory_from_process_group(self, enable: bool) -> None:
"""
Whether to (try to) use the ProcessGroup's allocate_tensor method for
the staging buffers for collective comms.
"""
if not isinstance(
self._all_gather_comm, (DefaultAllGather | ProcessGroupAllocAllGather)
):
raise AssertionError(
"cannot call set_allocate_memory_from_process_group() "
f"when all gather comm is custom: {self._all_gather_comm.__class__.__name__}"
)
self._all_gather_comm = (
ProcessGroupAllocAllGather(self._all_gather_process_group)
if enable
else DefaultAllGather()
)
if not isinstance(
self._reduce_scatter_comm,
(DefaultReduceScatter | ProcessGroupAllocReduceScatter),
):
raise AssertionError(
"cannot call set_allocate_memory_from_process_group() "
f"when reduce scatter comm is custom: {self._reduce_scatter_comm.__class__.__name__}"
)
self._reduce_scatter_comm = (
ProcessGroupAllocReduceScatter(self._reduce_scatter_process_group)
if enable
else DefaultReduceScatter()
)
# Runtime #
def unshard(self, async_op: bool = False):
if self._all_gather_result is not None: # already called, pending wait
return
if self.is_unsharded:
return # no-op
if (
not self.unshard_in_backward
and self._training_state == TrainingState.PRE_BACKWARD
):
return
if self._reshard_after_forward_event is not None:
# Resharded parameter data is allocated in the default stream and
# used in the all-gather streams
self._wait_all_gather_streams_on_event(self._reshard_after_forward_event)
self._reshard_after_forward_event = None
if isinstance(self.mesh_info, FSDPMeshInfo):
world_size = self._all_gather_process_group.size()
else:
world_size = 1
if world_size == 1:
# can't skip due to early return in wait_for_unshard if
# no self._all_gather_result
self._all_gather_result = AllGatherResult(
all_gather_output=self._all_gather_output,
all_gather_event=self.device_handle.Event().record(),
all_gather_work=None,
param_all_gather_input_dtypes=[],
param_all_gather_input_numels=[],
all_gather_input_split_sizes=[],
)
return
with record_function(self._with_fqn("FSDP::all_gather")):
self._all_gather_result = foreach_all_gather(
self.fsdp_params,
self._all_gather_process_group,
async_op,
*self.comm_ctx.get_all_gather_streams(async_op, self._training_state),
self.device,
self._all_gather_comm,
)
def wait_for_unshard(self):
"""
1. In forward with implicit prefetching, to overlap the current copy-out
with the next all-gather, we save a reference to the current all-gather
result to free after the next copy-out.
2. Otherwise (explicit prefetching or in backward), we free the
all-gather result immediately after the current copy-out since we can
already overlap the current copy-out with the previous reduce-scatter.
"""
if not self._all_gather_result:
return # no preceding unshard
async_op = self._all_gather_result.all_gather_work is not None
if self._training_state == TrainingState.FORWARD: # implicit prefetch
if prev_all_gather_state := self.comm_ctx.all_gather_state:
self._wait_all_gather_streams_on_event(prev_all_gather_state.event)
self.comm_ctx.all_gather_state = None # free the all-gather result
if isinstance(self.mesh_info, FSDPMeshInfo):
world_size = self._all_gather_process_group.size()
else:
world_size = 1
if world_size == 1:
# directly initialize unsharded parameters from sharded parameters
for fsdp_param in self.fsdp_params:
# Use all_gather_inputs which already handles conversion to param_dtype
# This is consistent with the world_size > 1 path
all_gather_input = fsdp_param.all_gather_inputs[0]
# Make sure the all_gather_outputs has proper storage size before using it
# First ensure we have at least one tensor in all_gather_outputs
fsdp_param.init_all_gather_outputs(
[all_gather_input.numel()],
[all_gather_input.dtype],
world_size,
self.device,
force_recreate=False,
)
tensor = fsdp_param.all_gather_outputs[0]
alloc_storage(tensor)
# find alternative way to check if tensor.is_inference
with torch.autograd._unsafe_preserve_version_counter(tensor):
tensor.copy_(all_gather_input)
else:
with record_function(self._with_fqn("FSDP::all_gather_copy_out")):
foreach_all_gather_copy_out(
self._all_gather_result,
self.fsdp_params,
self._all_gather_process_group,
)
for fsdp_param in self.fsdp_params:
fsdp_param.init_unsharded_param()
self._to_unsharded()
all_gather_copy_out_event = self.device_handle.Event()
all_gather_copy_out_event.record()
if (
not async_op
and self._training_state == TrainingState.FORWARD
and world_size > 1
):
# Defer free to allow for overlap of this copy-out with next
# all-gather collective
self.comm_ctx.all_gather_state = AllGatherState(
self._all_gather_result, all_gather_copy_out_event
)
else:
self._wait_all_gather_streams_on_event(all_gather_copy_out_event)
self._all_gather_result = None # free unless saved in `all_gather_state`
def _wait_all_gather_streams_on_event(self, event: torch.Event | None):
# Calling `unshard` before lazy init means streams are not initialized
if hasattr(self.comm_ctx, "all_gather_copy_in_stream") and event is not None:
self.comm_ctx.all_gather_copy_in_stream.wait_event(event)
if hasattr(self.comm_ctx, "all_gather_stream") and event is not None:
self.comm_ctx.all_gather_stream.wait_event(event)
def reshard(self):
if self._training_state == TrainingState.FORWARD:
if not self._reshard_after_forward:
return
if self._use_post_forward_mesh:
self._to_sharded_post_forward()
self._reshard_after_forward_event = self.device_handle.Event()
if self._reshard_after_forward_event is not None:
self._reshard_after_forward_event.record()
return
self._to_sharded()
def pre_forward(
self, module: nn.Module, args: tuple[Any, ...], kwargs: dict[str, Any]
) -> tuple[tuple[Any, ...], dict[str, Any]]:
logger.debug("%s", self._with_fqn("FSDP::pre_forward"))
with record_function(self._with_fqn("FSDP::pre_forward")):
self._training_state = TrainingState.FORWARD
self.unshard(self.unshard_async_op)
self.wait_for_unshard()
args, kwargs = self._register_post_backward_hook(args, kwargs)
return args, kwargs
def post_forward(self, module: nn.Module, input: Any, output: Any):
logger.debug("%s", self._with_fqn("FSDP::post_forward"))
with record_function(self._with_fqn("FSDP::post_forward")):
# for AC(fully_shard(model)), AC runs fsdp's _pre_forward
# it shouldn't change post_forward_order
if not is_bw():
self.reshard()
self._record_post_forward()
self._training_state = TrainingState.IDLE
return output
def _record_post_forward(self) -> None:
# Since a group has one pre-backward unshard for each forward call
# before the backward, we record each usage (with multiplicity)
post_forward_index = len(self.comm_ctx.post_forward_order)
self.comm_ctx.post_forward_order.append(self)
self._post_forward_indices.append(post_forward_index)
@_dynamo_disable
def pre_backward(self, default_prefetch: bool, *unused: Any):
if self._training_state == TrainingState.PRE_BACKWARD:
return
logger.debug("%s", self._with_fqn("FSDP::pre_backward"))
with record_function(self._with_fqn("FSDP::pre_backward")):
self._training_state = TrainingState.PRE_BACKWARD
self.unshard(self.unshard_async_op) # no-op if prefetched
self.wait_for_unshard()
if default_prefetch:
self._backward_prefetch()
@_dynamo_disable
def post_backward(self, *unused: Any):
# This method should be idempotent and safe to call even when this
# FSDP parameter group was not used in backward (should be a no-op)
logger.debug("%s", self._with_fqn("FSDP::post_backward"))
self._training_state = TrainingState.POST_BACKWARD
with record_function(self._with_fqn("FSDP::post_backward_accumulate")):
for fsdp_param in self.fsdp_params:
fsdp_param.accumulate_unsharded_grad_if_needed()
with record_function(self._with_fqn("FSDP::post_backward_reshard")):
if not self.reduce_grads:
if self.reshard_after_backward:
self.reshard()
for fsdp_param in self.fsdp_params:
fsdp_param.to_accumulated_grad_if_needed()
return
# Save the autograd-computed gradients before resharding to only
# access the unsharded parameters when their data is present
fsdp_params_with_grad: list[FSDPParam] = []
unsharded_grads: list[torch.Tensor] = []
for fsdp_param in self.fsdp_params:
if not hasattr(fsdp_param, "_unsharded_param"):
continue
# May have an accumulated gradient of the reduce dtype if the
# previous backward did not reduce-scatter
if fsdp_param.unsharded_accumulated_grad is not None:
fsdp_params_with_grad.append(fsdp_param)
unsharded_grads.append(fsdp_param.unsharded_accumulated_grad_data)
fsdp_param.unsharded_accumulated_grad = None
elif fsdp_param.unsharded_param.grad is not None:
fsdp_params_with_grad.append(fsdp_param)
unsharded_grads.append(fsdp_param.unsharded_grad_data)
fsdp_param.unsharded_param.grad = None
if self.reshard_after_backward:
self.reshard()
# Wait on prior module's RS states (assumes backward fires groups
# N-1 first; if not, overlap degrades but correctness is preserved).
if (
self._param_group_index == self._num_param_groups - 1
and self.comm_ctx.reduce_scatter_states
):
with record_function(f"FSDP::post_backward_rs_wait ({self._module_fqn})"):
for rs_state in self.comm_ctx.reduce_scatter_states:
if rs_state.event is not None:
self.device_handle.current_stream().wait_event(rs_state.event)
self.comm_ctx.reduce_scatter_states.clear()
if len(fsdp_params_with_grad) == 0:
return
with record_function(self._with_fqn("FSDP::post_backward_reduce")):
all_reduce_pg = (
self._all_reduce_process_group
if isinstance(self.mesh_info, DDPMeshInfo)
else None
)
all_reduce_stream: torch.cuda.Stream
if all_reduce_pg is None and self._all_reduce_hook_stream is not None:
# this means the native HSDP is not enabled,
# but user may want to have a custom HSDP setup
if self._all_reduce_hook is None:
raise AssertionError(
"all reduce hook stream is specified but hook itself is missing."
)
all_reduce_stream = self._all_reduce_hook_stream
else:
all_reduce_stream = self.comm_ctx.all_reduce_stream
self._wait_for_post_backward()
(
reduce_scatter_input,
reduce_scatter_event,
self._post_reduce_event,
all_reduce_input,
all_reduce_event,
self._partial_reduce_output,
) = foreach_reduce(
fsdp_params_with_grad,
unsharded_grads,
(
# pyrefly: ignore [bad-argument-type]
self._reduce_scatter_process_group
if isinstance(self.mesh_info, FSDPMeshInfo)
else None # pyre-fixme[6]
),
self.comm_ctx.reduce_scatter_stream,
self._reduce_scatter_comm,
self._orig_dtype,
self._reduce_dtype,
self.device,
self.gradient_divide_factor,
(
self._all_reduce_process_group
if isinstance(self.mesh_info, DDPMeshInfo)
else None
),
all_reduce_stream,
self.all_reduce_grads,
self._partial_reduce_output,
self._all_reduce_hook,
self.force_sum_reduction_for_comms,
)
self.comm_ctx.reduce_scatter_states.append(
ReduceScatterState(reduce_scatter_input, reduce_scatter_event)
)
if all_reduce_input is not None:
if self.device.type != "cpu":
if all_reduce_event is None:
raise AssertionError(
"Expected all_reduce_event to be set for non-CPU device"
)
self._all_reduce_state = AllReduceState(
all_reduce_input, all_reduce_event
)
def finalize_backward(self):
self._wait_for_post_backward()
for fsdp_param in self.fsdp_params:
if fsdp_param.grad_offload_event is not None:
fsdp_param.grad_offload_event.synchronize()
fsdp_param.grad_offload_event = None
if self._all_gather_result is not None:
# If there was a mistargeted unshard without a corresponding wait,
# then we wait here and clear the unshard
if (event := self._all_gather_result.all_gather_event) is not None:
torch.accelerator.current_stream().wait_event(event)
work = self._all_gather_result.all_gather_work
if isinstance(work, dist.distributed_c10d.Work):
work.wait()
self._all_gather_result = None
self._post_forward_indices.clear()
def _wait_for_post_backward(self):
if self._post_reduce_event is not None:
self.device_handle.current_stream().wait_event(self._post_reduce_event)
self._post_reduce_event = None
if (
self._all_reduce_state is not None
and self._all_reduce_state.event is not None
):
self.device_handle.current_stream().wait_event(self._all_reduce_state.event)
self._all_reduce_state = None
def _backward_prefetch(self) -> None:
if self._training_state == TrainingState.PRE_BACKWARD:
if not self._post_forward_indices:
# Can be cleared if running multiple `backward`s
return
curr_index = self._post_forward_indices.pop()
if self._num_param_groups > 1:
# Backward fires groups in reverse forward order:
# N-1, N-2, ..., 1, 0. Index 1 is always the
# penultimate group regardless of N. Prefetching here
# lets the next module's AG overlap with group 0's RS
# without holding unsharded params too long (as would
# happen if we prefetched from N-1).
if self._param_group_index != 1:
return
# E.g. fully_shard(block, shard_placement_fn=...) creates two
# param groups per block (dense + moe), giving
# post_forward_order = [block0, block0.moe, block1, block1.moe].
# block1.moe walks back past block1 to prefetch block0.moe then block0.
curr_modules = self.modules
target_modules: tuple[nn.Module, ...] | None = None
for step in range(1, curr_index + 1):
target = self.comm_ctx.post_forward_order[curr_index - step]
if target.modules is curr_modules:
continue
if target_modules is None:
target_modules = target.modules
elif target.modules is not target_modules:
break
# Prefetch all groups of the target module in
# reverse forward order (highest index first),
# matching the explicit path in _pre_backward.
self._prefetch_unshard(target, "backward")
elif curr_index > 0:
target = self.comm_ctx.post_forward_order[curr_index - 1]
self._prefetch_unshard(target, "backward")
@staticmethod
def _prefetch_unshard(
target_fsdp_param_group: FSDPParamGroup, pass_type: str
) -> None:
if pass_type == "backward":
training_state = TrainingState.PRE_BACKWARD
elif pass_type == "forward":
training_state = TrainingState.FORWARD
else:
raise ValueError(f"Unknown pass type: {pass_type}")
target_fqn = target_fsdp_param_group._module_fqn
with (
record_function(f"FSDP::{pass_type}_prefetch for {target_fqn}"),
target_fsdp_param_group.use_training_state(training_state),
):
async_op = target_fsdp_param_group.unshard_async_op
target_fsdp_param_group.unshard(async_op)
# Utilities #
def _to_sharded(self):
if not self.is_sharded:
for fsdp_param in self.fsdp_params:
fsdp_param.to_sharded()
self._sharded_state = ShardedState.SHARDED
def _to_sharded_post_forward(self):
if not self.is_sharded_post_forward:
for fsdp_param in self.fsdp_params:
fsdp_param.to_sharded_post_forward()
self._sharded_state = ShardedState.SHARDED_POST_FORWARD
def _to_unsharded(self):
if not self.is_unsharded:
for fsdp_param in self.fsdp_params:
fsdp_param.to_unsharded()
self._sharded_state = ShardedState.UNSHARDED
@property
def is_sharded(self) -> bool:
return self._sharded_state == ShardedState.SHARDED
@property
def is_sharded_post_forward(self) -> bool:
return self._sharded_state == ShardedState.SHARDED_POST_FORWARD
@property
def is_unsharded(self) -> bool:
return self._sharded_state == ShardedState.UNSHARDED
@contextlib.contextmanager
def use_training_state(self, training_state: TrainingState):
old_training_state = self._training_state
self._training_state = training_state
try:
yield
finally:
self._training_state = old_training_state
# Hook Registration #
def _register_post_backward_hook(
self, args: tuple[Any, ...], kwargs: dict[str, Any]
) -> tuple[tuple[Any, ...], dict[str, Any]]:
if not torch.is_grad_enabled():
return args, kwargs
# Collect all tensors that require gradients (including from dataclasses)
inp_tensors = collect_grad_tensors((args, kwargs))
if not inp_tensors:
return args, kwargs
# Apply RegisterPostBackwardFunction to all tensors at once
out_tensors = RegisterPostBackwardFunction.apply(self, *inp_tensors)
# Replace tensors in the structure (iterator order matches collect order)
new_args, new_kwargs = replace_grad_tensors((args, kwargs), iter(out_tensors))
return new_args, new_kwargs
def _register_state_dict_hooks(self) -> None:
num_pre_save_hooks = len(self._module_to_pre_save_state_dict_hook_handle)
num_pre_load_hooks = len(self._module_to_pre_load_state_dict_hook_handle)
if num_pre_save_hooks != num_pre_load_hooks:
raise AssertionError(
f"Pre-save: {num_pre_save_hooks} pre-load: {num_pre_load_hooks}"
)
if num_pre_save_hooks > 0:
return # already registered
modules_with_fsdp_params: set[nn.Module] = {
fsdp_param._module_info.module for fsdp_param in self.fsdp_params
}
def to_sharded_hook(*args: Any, **kwargs: Any) -> None:
self._to_sharded()
for module in modules_with_fsdp_params:
self._module_to_pre_save_state_dict_hook_handle[module] = (
module.register_state_dict_pre_hook(to_sharded_hook)
)
self._module_to_pre_load_state_dict_hook_handle[module] = (
module._register_load_state_dict_pre_hook(to_sharded_hook)
)
# Properties #
@property
def _reshard_after_forward(self) -> bool:
return self.post_forward_mesh_info is not None
@property
def _use_post_forward_mesh(self) -> bool:
return (
self._reshard_after_forward
and self.mesh_info != self.post_forward_mesh_info
)
@property
def _is_hsdp(self) -> bool:
return isinstance(self.mesh_info, HSDPMeshInfo)
@property
def _all_gather_process_group(self) -> dist.ProcessGroup:
mesh_info = (
cast(FSDPMeshInfo, self.post_forward_mesh_info)
if self.is_sharded_post_forward
else self.mesh_info
)
if not isinstance(mesh_info, FSDPMeshInfo):
raise AssertionError(
f"Expected mesh_info to be FSDPMeshInfo, got {type(mesh_info)}"
)
return mesh_info.shard_process_group
@property
def _reduce_scatter_process_group(self) -> dist.ProcessGroup:
if not isinstance(self.mesh_info, FSDPMeshInfo):
raise AssertionError(
f"Expected mesh_info to be FSDPMeshInfo, got {type(self.mesh_info)}"
)
return self.mesh_info.shard_process_group
@property
def _all_reduce_process_group(self) -> dist.ProcessGroup:
if not isinstance(self.mesh_info, DDPMeshInfo):
raise AssertionError(
f"Expected mesh_info to be DDPMeshInfo or HSDPMeshInfo, got {type(self.mesh_info)}"
)
return self.mesh_info.replicate_process_group
def _with_fqn(self, label: str) -> str:
if self._module_fqn:
label = f"{label} ({self._module_fqn})"
if self._num_param_groups > 1 and isinstance(self.mesh_info, FSDPMeshInfo):
label = f"{label} [pg={self.mesh_info.shard_mesh_size}]"
return label
def __repr__(self):
return f"FSDPParamGroup(fqn={self._module_fqn})"
def _validate_no_meta_params(self):
param_names_on_meta = [
fsdp_param._param_fqn
for fsdp_param in self.fsdp_params
if fsdp_param.sharded_param.device.type == "meta"
]
if param_names_on_meta:
raise RuntimeError(
"FSDP parameters should be materialized from meta device before training, "
f"but the following were still on meta device: {param_names_on_meta}\n"
"For example, call module.to_empty(device) to materialize to device and "
"call module.reset_parameters() on each module to initialize values."
)
def _validate_cpu_offload_params(self):
if not isinstance(self.offload_policy, CPUOffloadPolicy):
return
fsdp_params_not_on_cpu = [
fsdp_param
for fsdp_param in self.fsdp_params
if fsdp_param.sharded_param.device.type != "cpu"
]
if fsdp_params_not_on_cpu:
raise RuntimeError(
"FSDP parameters should be materialized on CPU when enabling CPU offloading. "
'For example, load a CPU state dict or call module.to_empty(device="cpu"). '
"Found following parameters on non-CPU device: "
f"{[(fsdp_param._param_fqn, fsdp_param.sharded_param.device) for fsdp_param in fsdp_params_not_on_cpu]}\n"
)
def _get_param_module_infos(
params: list[nn.Parameter], modules: tuple[nn.Module, ...]
) -> list[ParamModuleInfo]:
"""
Shared parameter: lin1.weight = lin2.weight
Shared module: mlp.lin1 = mlp.lin2
We do not remove duplicates when traversing both modules and parameters to
find shared modules' parameters and shared parameters within a module.
"""
params_set = set(params)
param_to_module_info: dict[nn.Parameter, ParamModuleInfo] = {}
for module in modules:
for _, submodule in module.named_modules(remove_duplicate=False):
for param_name, param in _named_parameters_with_duplicates(
submodule, recurse=False
):
if param in params_set:
if param not in param_to_module_info:
param_to_module_info[param] = ParamModuleInfo(
submodule, param_name
)
else:
param_to_module_info[param].shared_modules.append(submodule)
param_to_module_info[param].shared_param_names.append(
param_name
)
if len(param_to_module_info) != len(params):
raise AssertionError(f"Some parameters are not in the module tree of {modules}")
return [param_to_module_info[param] for param in params]
class RegisterPostBackwardFunction(torch.autograd.Function):
@staticmethod
# pyrefly: ignore [bad-override]
def forward(ctx, param_group: FSDPParamGroup, *inputs: torch.Tensor):
# All tensors in `inputs` should require gradient
ctx.param_group = param_group
return inputs
@staticmethod
def backward(ctx, *grads: torch.Tensor):
ctx.param_group.post_backward()
return (None,) + grads
@@ -0,0 +1,459 @@
# mypy: allow-untyped-decorators
# mypy: allow-untyped-defs
import functools
import logging
from collections.abc import Callable, Sequence
from typing import Any, Generic, TYPE_CHECKING, TypeVar
import torch
import torch.nn as nn
from torch._logging import warning_once
from torch.autograd import Variable
from torch.autograd.graph import _MultiHandle
from torch.distributed._composable_state import (
_get_module_state,
_insert_module_state,
_State,
)
from torch.distributed.device_mesh import _get_device_handle
from torch.distributed.fsdp._common_utils import collect_grad_tensors
from torch.distributed.utils import _apply_to_tensors, _to_kwargs
from ._fsdp_api import MixedPrecisionPolicy
from ._fsdp_common import _cast_fp_tensor, _dynamo_disable, TrainingState
from ._fsdp_param_group import FSDPCommContext, FSDPParamGroup
if TYPE_CHECKING:
from ._fsdp_param import FSDPParam
logger = logging.getLogger("torch.distributed.fsdp.fully_shard")
_StateType = TypeVar("_StateType", bound="FSDPState")
class FSDPStateContext(Generic[_StateType]):
"""This has state shared across FSDP states."""
def __init__(self) -> None:
# All FSDP states in the root state's module tree
self.all_states: list[_StateType] = []
# Iteration's forward root runs the once-per-forward logic; this root
# may not be the overall root set by lazy initialization in cases where
# only a submodule runs forward (e.g. encoder-only for eval)
self.iter_forward_root: _StateType | None = None
# Final callback should only be queued once per backward
self.post_backward_final_callback_queued: bool = False
# Whether to finalize backward in this backward's final callback
self.is_last_backward: bool = True
# Optional user-provided event recorded after optimizer for the
# all-gather streams to wait on in the root pre-forward
self.post_optim_event: torch.Event | None = None
class FSDPState(_State):
# Name used in error messages; subclasses can override
_state_name: str = "FSDP"
def __init__(self) -> None:
super().__init__()
# Support multiple param groups for per-param mesh support.
# Each group has params with the same mesh_info.
self._fsdp_param_groups: list[FSDPParamGroup] = []
self._is_root: bool | None = None # root set during lazy init
self._state_ctx = FSDPStateContext()
self._comm_ctx = FSDPCommContext()
self._training_state: TrainingState = TrainingState.IDLE
self._states_to_forward_prefetch: list[FSDPState] = []
self._states_to_backward_prefetch: list[FSDPState] = []
self._modules_to_run_forward: set[nn.Module] = set()
# ``False`` when user set reshard_after_forward
# through ``fully_shard`` or ``set_reshard_after_forward``
self._auto_reshard_after_forward: bool | None = True
def _get_state_for_module(self, module: nn.Module) -> "FSDPState | None":
"""Get the state for a module. Subclasses can override to use different state getters."""
return _get_module_fsdp_state(module)
@property
def _fsdp_param_group(self) -> FSDPParamGroup | None:
"""
Returns the param group for backward compatibility.
This property is only valid when there is at most one param group.
For per-param mesh support with multiple param groups, use
``_fsdp_param_groups`` instead.
"""
if len(self._fsdp_param_groups) > 1:
group_fqns = [g._module_fqn for g in self._fsdp_param_groups]
raise AssertionError(
f"Expected at most 1 param group for backward compatibility, "
f"but got {len(self._fsdp_param_groups)} (fqns: {group_fqns}). "
f"Use `_fsdp_param_groups` (plural) to access all param groups "
f"when using per-param mesh via shard_placement_fn returning "
f"ShardPlacementResult."
)
if self._fsdp_param_groups:
return self._fsdp_param_groups[0]
return None
# Define a separate init since `__init__` is called in the contract
def init(
self,
modules: tuple[nn.Module, ...],
device: torch.device,
mp_policy: MixedPrecisionPolicy,
auto_reshard_after_forward: bool,
) -> None:
for module in modules:
_insert_module_state(module, self)
self._modules = modules
self._device = device
self._device_handle = _get_device_handle(device.type)
self._mp_policy = mp_policy
self._auto_reshard_after_forward = auto_reshard_after_forward
if len(modules) == 1:
self._pre_forward_hook_handle = modules[0].register_forward_pre_hook(
self._pre_forward, prepend=True, with_kwargs=True
)
self._post_forward_hook_handle = modules[0].register_forward_hook(
self._post_forward, prepend=False
)
else:
hook_handle = _register_group_forward_hooks(
modules,
self._pre_forward,
self._post_forward,
self._modules_to_run_forward,
)
self._pre_forward_hook_handle = hook_handle
self._post_forward_hook_handle = hook_handle
def _root_pre_forward(
self, module: nn.Module, args: tuple[Any, ...], kwargs: dict[str, Any]
) -> tuple[tuple[Any, ...], dict[str, Any]]:
self._lazy_init()
if self._state_ctx.iter_forward_root is not None:
return args, kwargs
logger.debug("FSDP::root_pre_forward")
self._state_ctx.iter_forward_root = self
with torch.profiler.record_function("FSDP::root_pre_forward"):
# Wait for optimizer before implicitly prefetched all-gathers
if (event := self._state_ctx.post_optim_event) is not None:
self._comm_ctx.all_gather_copy_in_stream.wait_event(event)
self._comm_ctx.all_gather_stream.wait_event(event)
self._state_ctx.post_optim_event = None
else:
current_stream = self._device_handle.current_stream()
self._comm_ctx.all_gather_copy_in_stream.wait_stream(current_stream)
self._comm_ctx.all_gather_stream.wait_stream(current_stream)
if self._device.type in [
"cuda",
"hpu",
"xpu",
"mtia",
torch._C._get_privateuse1_backend_name(),
]:
with torch.profiler.record_function("FSDP::inputs_to_device"):
args_tuple, kwargs_tuple = _to_kwargs(
args, kwargs, self._device, False
) # same as DDP
args, kwargs = args_tuple[0], kwargs_tuple[0]
return args, kwargs
def _lazy_init(self) -> None:
"""
Lazy initialization represents when all modules' parallelisms have
finalized (e.g. FSDP has been applied to all desired modules). This
means that we can determine which state is the root, and we do so by
the 1st state to run forward.
"""
if self._is_root is not None:
return # no-op: already initialized
self._is_root = True
if len(self._modules) > 1:
raise RuntimeError(
f"{self._state_name} requires a single root module but got {self._modules}"
)
root_module = self._modules[0]
visited_states: set[FSDPState] = set()
for module_name, module in root_module.named_modules():
if (state := self._get_state_for_module(module)) is None:
continue
if module is not root_module:
if state not in visited_states and state._is_root is not None:
raise RuntimeError(
f"{self._state_name} state has already been lazily initialized for "
f"{module_name}\n{self._state_name} requires running forward through "
"the root module first"
)
state._is_root = False
# A single state can map to multiple modules (e.g.
# fully_shard([mod_a, mod_b, mod_c])), so dedup here.
if state not in visited_states:
self._state_ctx.all_states.append(state)
visited_states.add(state)
# For the root, do not reshard after forward since for training,
# the parameters would be freed and all-gathered immediately
if self._auto_reshard_after_forward:
for fsdp_param_group in self._fsdp_param_groups:
fsdp_param_group.post_forward_mesh_info = None
self._init_fqns()
self._init_shared_state()
self._validate_no_duplicate_params()
# Run parameter group lazy inits after initializing FQNs for improved
# error messages
for state in self._state_ctx.all_states:
for fsdp_param_group in state._fsdp_param_groups:
fsdp_param_group.lazy_init()
def _validate_no_duplicate_params(self) -> None:
seen: set[int] = set()
for state in self._state_ctx.all_states:
for fsdp_param_group in state._fsdp_param_groups:
for fsdp_param in fsdp_param_group.fsdp_params:
if fsdp_param._orig_param_uid in seen:
raise ValueError(
f"Parameter '{fsdp_param._param_fqn}' is shared with a "
f"parameter already managed by another FSDP group. "
f"For shared/tied parameters, use "
f"fully_shard([module_a, module_b]) to place them in "
f"the same FSDP group."
)
seen.add(fsdp_param._orig_param_uid)
def _init_shared_state(self) -> None:
self._comm_ctx.lazy_init(self._device)
for state in self._state_ctx.all_states:
state._state_ctx = self._state_ctx
state._comm_ctx = self._comm_ctx
num_groups = len(state._fsdp_param_groups)
for i, fsdp_param_group in enumerate(state._fsdp_param_groups):
fsdp_param_group.comm_ctx = self._comm_ctx
fsdp_param_group._param_group_index = i
fsdp_param_group._num_param_groups = num_groups
def _init_fqns(self) -> None:
"""Sets module and parameter FQN attributes for debugging."""
if not self._is_root:
raise AssertionError("Expected _is_root to be True")
root_module = self._modules[0]
param_to_fsdp_param: dict[nn.Parameter, FSDPParam] = {}
# Build a mapping from module to all its FSDPParamGroups (not just one)
module_to_fsdp_param_groups: dict[nn.Module, list[FSDPParamGroup]] = {}
for state in self._state_ctx.all_states:
for fsdp_param_group in state._fsdp_param_groups:
for fsdp_param in fsdp_param_group.fsdp_params:
param_to_fsdp_param[fsdp_param.sharded_param] = fsdp_param
for module in fsdp_param_group.modules:
if module not in module_to_fsdp_param_groups:
module_to_fsdp_param_groups[module] = []
module_to_fsdp_param_groups[module].append(fsdp_param_group)
for param_name, param in root_module.named_parameters():
if param in param_to_fsdp_param:
param_to_fsdp_param[param]._param_fqn = param_name
for module_name, module in root_module.named_modules():
if module in module_to_fsdp_param_groups:
# Set FQN for all param groups associated with this module
for fsdp_param_group in module_to_fsdp_param_groups[module]:
module_fqn = fsdp_param_group._module_fqn
if module_fqn is None:
fsdp_param_group._module_fqn = module_name
else:
if not isinstance(module_fqn, str):
raise AssertionError(
f"Expected module_fqn to be str, got {type(module_fqn)}: {module_fqn}"
)
module_fqn += f", {module_name}"
fsdp_param_group._module_fqn = module_fqn
@_dynamo_disable
def _pre_forward(
self, module: nn.Module, args: tuple[Any, ...], kwargs: dict[str, Any]
) -> tuple[tuple[Any, ...], dict[str, Any]]:
# When composing with module-hook-based activation checkpointing, the
# pre-backward hook is responsible for the unshard
if self._training_state == TrainingState.PRE_BACKWARD:
# With nested FSDP and multiple forward passes before backward,
# the params might have been resharded by a previous post_backward.
# We need to ensure params are unsharded for AC recomputation.
for fsdp_param_group in self._fsdp_param_groups:
if not fsdp_param_group.is_unsharded:
fsdp_param_group.unshard()
fsdp_param_group.wait_for_unshard()
return args, kwargs
self._training_state = TrainingState.FORWARD
args, kwargs = self._root_pre_forward(module, args, kwargs)
if self._mp_policy.cast_forward_inputs and self._mp_policy.param_dtype:
with torch.profiler.record_function("FSDP::cast_forward_inputs"):
cast_fn = functools.partial(
_cast_fp_tensor, self._mp_policy.param_dtype
)
args, kwargs = (
_apply_to_tensors(cast_fn, args),
_apply_to_tensors(cast_fn, kwargs),
)
for fsdp_param_group in self._fsdp_param_groups:
args, kwargs = fsdp_param_group.pre_forward(module, args, kwargs)
for fsdp_state in self._states_to_forward_prefetch:
# Forward order (not reversed) to match forward execution order;
# contrast with reversed() in _pre_backward for backward order.
for target_param_group in fsdp_state._fsdp_param_groups:
FSDPParamGroup._prefetch_unshard(target_param_group, "forward")
return args, kwargs
@_dynamo_disable
def _post_forward(self, module: nn.Module, input: Any, output: Any) -> Any:
# When composing with module-hook-based activation checkpointing, the
# post-backward hook is responsible for the reshard
if self._training_state == TrainingState.PRE_BACKWARD:
return output
for fsdp_param_group in self._fsdp_param_groups:
output = fsdp_param_group.post_forward(module, input, output)
output = self._register_pre_backward_hook(output)
self._training_state = TrainingState.IDLE
if self._state_ctx.iter_forward_root is self:
if all_gather_state := self._comm_ctx.all_gather_state:
# Free the last all-gather result if needed; refer to
# [Note: Overlapping all-gather copy-in and all-gather]
self._comm_ctx.all_gather_copy_in_stream.wait_event(
all_gather_state.event
)
self._comm_ctx.all_gather_stream.wait_event(all_gather_state.event)
self._comm_ctx.all_gather_state = None # free the all-gather result
self._state_ctx.iter_forward_root = None
if self._mp_policy.output_dtype is not None:
with torch.profiler.record_function("FSDP::cast_forward_outputs"):
output = _apply_to_tensors(
functools.partial(_cast_fp_tensor, self._mp_policy.output_dtype),
output,
)
return output
@_dynamo_disable
def _pre_backward(self, grad: torch.Tensor) -> torch.Tensor:
self._training_state = TrainingState.PRE_BACKWARD
self._register_root_post_backward_final_callback()
default_prefetch = len(self._states_to_backward_prefetch) == 0
for fsdp_param_group in self._fsdp_param_groups:
fsdp_param_group.pre_backward(default_prefetch)
for fsdp_state in self._states_to_backward_prefetch:
# Reverse so higher-indexed groups are prefetched first,
# matching backward execution order (reverse of forward).
for target_param_group in reversed(fsdp_state._fsdp_param_groups):
FSDPParamGroup._prefetch_unshard(target_param_group, "backward")
return grad
@_dynamo_disable
def _root_post_backward_final_callback(self) -> None:
logger.debug("FSDP::root_post_backward")
with torch.profiler.record_function("FSDP::root_post_backward_callback"):
for state in self._state_ctx.all_states:
# Reverse so that the last param group (which gates the
# reduce-scatter wait/clear) fires first, matching the
# autograd backward order and preserving RS overlap for
# per-param-mesh modules whose inputs lack gradients.
for fsdp_param_group in reversed(state._fsdp_param_groups):
if fsdp_param_group._training_state != TrainingState.POST_BACKWARD:
# Run post-backward in case forward inputs did not require
# gradient so the autograd backward did not run
fsdp_param_group.post_backward()
fsdp_param_group._training_state = TrainingState.IDLE
state._training_state = TrainingState.IDLE
if self._state_ctx.is_last_backward:
state._finalize_backward()
if self._state_ctx.is_last_backward:
self._comm_ctx.post_forward_order.clear()
# Catch the last module's RS states that no subsequent
# module's group N-1 wait will clear.
for rs_state in self._comm_ctx.reduce_scatter_states:
if rs_state.event is not None:
self._device_handle.current_stream().wait_event(rs_state.event)
self._comm_ctx.reduce_scatter_states.clear()
self._state_ctx.post_backward_final_callback_queued = False
def _finalize_backward(self) -> None:
if self._modules_to_run_forward:
msg = (
f"{len(self._modules_to_run_forward)} of the {len(self._modules)} "
f"modules passed to fully_shard did not run forward before backward, "
"which is error-prone since FSDP post-forward/pre-backward logic "
"will not run for these modules. We recommend passing only modules "
"that run forward together. Modules that did not run forward: "
f"{list(self._modules_to_run_forward)}"
)
warning_once(logger, msg, stacklevel=2)
# Clear since we want the next forward to run
self._modules_to_run_forward.clear()
for fsdp_param_group in self._fsdp_param_groups:
fsdp_param_group.finalize_backward()
def _register_pre_backward_hook(self, output: Any) -> Any:
if not torch.is_grad_enabled():
return output
# output is the forward return value — pass directly without wrapping
# (unlike _register_post_backward_hook which wraps (args, kwargs))
tensors = collect_grad_tensors(output)
for t in tensors:
t.register_hook(self._pre_backward)
return output
def _register_root_post_backward_final_callback(self):
if self._state_ctx.post_backward_final_callback_queued:
return
self._state_ctx.post_backward_final_callback_queued = True
Variable._execution_engine.queue_callback(
self._root_post_backward_final_callback
)
def _get_module_fsdp_state(module: nn.Module) -> FSDPState | None:
state = _get_module_state(module)
if isinstance(state, FSDPState):
return state
return None
def _register_group_forward_hooks(
modules: Sequence[nn.Module],
pre_hook: Callable,
post_hook: Callable,
modules_to_run: set[nn.Module],
):
"""
Registers group forward pre and post-hooks. The pre-hook runs upon the
first module pre-forward, and the post-hook runs upon the last. If at least
one module does not run forward, then the post-hook does not run.
"""
modules_set = set(modules)
@_dynamo_disable
@functools.wraps(pre_hook)
def wrapped_pre_hook(*args: Any, **kwargs: Any):
if len(modules_to_run) == 0: # first to run
modules_to_run.update(modules_set)
return pre_hook(*args, **kwargs)
@_dynamo_disable
def get_wrapped_post_hook(module: nn.Module):
@functools.wraps(post_hook)
def wrapped_post_hook(*args: Any, **kwargs: Any):
modules_to_run.discard(module)
if len(modules_to_run) == 0:
return post_hook(*args, **kwargs)
return wrapped_post_hook
pre_handles = [
module.register_forward_pre_hook(
wrapped_pre_hook, prepend=True, with_kwargs=True
)
for module in modules
]
post_handles = [
module.register_forward_hook(
get_wrapped_post_hook(module), prepend=False, always_call=True
)
for module in modules
]
return _MultiHandle(tuple(pre_handles + post_handles))
@@ -0,0 +1,828 @@
# mypy: allow-untyped-decorators
# mypy: allow-untyped-defs
from __future__ import annotations
import functools
from contextlib import contextmanager
from typing import Any, cast, Literal, NoReturn, overload, TYPE_CHECKING
from typing_extensions import deprecated
import torch
import torch.nn as nn
from torch.distributed._composable import contract
from ._fsdp_api import (
AllGather,
DataParallelMeshDims,
MixedPrecisionPolicy,
OffloadPolicy,
ReduceScatter,
)
from ._fsdp_common import FSDPMeshInfo, ShardPlacementFnResult
from ._fsdp_init import (
_apply_to_module,
_get_device_from_mesh,
_get_mesh_info,
_get_modules_and_states,
_get_post_forward_mesh_info,
_init_default_mesh,
_init_param_group,
_validate_mesh,
_validate_module,
)
from ._fsdp_state import _get_module_fsdp_state, FSDPState
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Iterator
from torch.distributed.tensor import DeviceMesh
from ._fsdp_param_group import FSDPParamGroup
__all__ = [
"fully_shard",
"FSDPModule",
"UnshardHandle",
"register_fsdp_forward_method",
"get_cls_to_fsdp_cls",
"disable_fsdp_module_new_init",
"share_comm_ctx",
]
cls_to_fsdp_cls: dict[type, type] = {}
def get_cls_to_fsdp_cls() -> dict[type, type]:
return cls_to_fsdp_cls
@overload
# pyrefly: ignore [inconsistent-overload]
def fully_shard(
module: nn.Module,
*,
mesh: DeviceMesh | None = ...,
reshard_after_forward: bool | int | None = ...,
shard_placement_fn: Callable[[nn.Parameter], ShardPlacementFnResult] | None = ...,
mp_policy: MixedPrecisionPolicy = ...,
offload_policy: OffloadPolicy = ...,
ignored_params: set[nn.Parameter] | None = ...,
dp_mesh_dims: DataParallelMeshDims | None = ...,
) -> FSDPModule: ...
@overload
# pyrefly: ignore [inconsistent-overload]
def fully_shard(
module: list[nn.Module],
*,
mesh: DeviceMesh | None = ...,
reshard_after_forward: bool | int | None = ...,
shard_placement_fn: Callable[[nn.Parameter], ShardPlacementFnResult] | None = ...,
mp_policy: MixedPrecisionPolicy = ...,
offload_policy: OffloadPolicy = ...,
ignored_params: set[nn.Parameter] | None = ...,
dp_mesh_dims: DataParallelMeshDims | None = ...,
) -> list[FSDPModule]: ...
# The decorator adds a state object to `module` that can be accessed via
# `fully_shard.state(module)`. The state object and module are 1:1.
# [1] Python runtime decorator does not play well with static type checking
# so suppressing some type checks to support type overloads
# such that caller can still get correct return types based on input type
@contract(state_cls=FSDPState) # type: ignore[misc] # see [1]
def fully_shard(
module,
*,
mesh: DeviceMesh | None = None,
reshard_after_forward: bool | int | None = None,
shard_placement_fn: Callable[[nn.Parameter], ShardPlacementFnResult] | None = None,
mp_policy: MixedPrecisionPolicy = MixedPrecisionPolicy(),
offload_policy: OffloadPolicy = OffloadPolicy(),
ignored_params: set[nn.Parameter] | None = None,
dp_mesh_dims: DataParallelMeshDims | None = None,
):
"""
Apply fully sharded data parallelism (FSDP) to ``module``, where FSDP
shards module parameters, gradients, and optimizer states across data
parallel workers to save memory at the cost of communication.
At initialization, FSDP shards the module's parameters across the data
parallel workers given by ``mesh``. Before forward, FSDP all-gathers the
sharded parameters across the data-parallel workers to get the unsharded
parameters for forward computation. If ``reshard_after_forward`` is
``True``, then FSDP frees the unsharded parameters after forward and
re-all-gathers them in backward before gradient computation. After gradient
computation, FSDP frees the unsharded parameters and reduce-scatters the
unsharded gradients across data-parallel workers.
This implementation represents the sharded parameters as :class:`DTensor` s
sharded on dim-0, while the unsharded parameters will be like the original
parameters on ``module`` (e.g. :class:`torch.Tensor` if originally
:class:`torch.Tensor`). A module
`forward pre-hook <https://pytorch.org/docs/main/generated/torch.nn.Module.html#torch.nn.Module.register_forward_pre_hook>`_
on ``module`` all-gathers the parameters, and a module
`forward hook <https://pytorch.org/docs/main/generated/torch.nn.Module.html#torch.nn.Module.register_forward_hook>`_
on ``module`` frees them (if needed). Similar backward hooks all-gather
parameters and later free parameters and reduce-scatter gradients.
Since grouping multiple tensors together for one collective is critical for
communication efficiency, this implementation makes this grouping first
class. Calling :meth:`fully_shard` on ``module`` constructs one group that
includes the parameters in ``module.parameters()`` except those already
assigned to a group from an earlier call on a submodule. This means that
:meth:`fully_shard` should be called bottom-up on your model. Each group's
parameters are all-gathered in one collective, and its gradients are
reduce-scattered in one collective. Partitioning the model into multiple
groups ("layer by layer") allows for peak memory savings and communication/computation
overlap. Users generally should *not* call :meth:`fully_shard` only on the
topmost root module.
Args:
module (Union[nn.Module, List[nn.Module]): The module or modules to
shard with FSDP and group together for communication.
mesh (Optional[DeviceMesh]): This data parallel mesh defines the
sharding and device. If 1D, then parameters are fully sharded
across the 1D mesh (FSDP) with ``(Shard(0),)`` placement. If 2D,
then parameters are sharded across the 1st dim and replicated
across the 0th dim (HSDP) with ``(Replicate(), Shard(0))``
placement. The mesh's device type gives the device type used for
communication; if a CUDA or CUDA-like device type, then we use the
current device.
reshard_after_forward (Optional[Union[bool, int]]): This controls the parameter
behavior after forward and can trade off memory and communication:
- If ``True``, then this reshards parameters after forward and
re-all-gathers in backward.
- If ``False``, then this keeps the unsharded parameters in memory
after forward and avoids the all-gather in backward. For best performance,
we usually set ``False`` for the root module, because the root module
is typically required immediately when the backward pass begins.
- If ``None``, it is set to ``True`` for non-root modules and ``False``
for root modules.
- If an ``int``, then this represents the world size to reshard to
after forward. It should be a non-trivial divisor of the ``mesh``
shard dim size (i.e. excluding 1 and the dim size itself). A
choice may be the intra-node size (e.g. ``torch.cuda.device_count()``).
This allows the all-gather in backward to be over a smaller world
size at the cost of higher memory usage than setting to ``True``.
- After forward, the parameters registered to the module depend on
to this: The registered parameters are the sharded parameters if
``True``; unsharded parameters if ``False``; and the parameters
resharded to the smaller mesh otherwise. To modify the parameters
between forward and backward, the registered parameters must be
the sharded parameters. For ``False`` or an ``int``, this can be
done by manually resharding via :meth:`reshard`.
shard_placement_fn (Optional[Callable[[nn.Parameter], Optional[Shard | ShardPlacementResult]]]):
This callable can be used to override the sharding placement and/or
mesh for a parameter. It can return:
- ``None``: Use default sharding (Shard(0)) on the mesh passed to
``fully_shard``.
- :class:`Shard`: Shard the parameter on the specified dimension
using the mesh passed to ``fully_shard``.
- :class:`ShardPlacementResult`: Specify both the shard placement
and a custom :class:`FSDPMeshInfo`. This allows different
parameters to be sharded across different process groups, enabling
use cases like Mixture of Experts where expert params use a
different mesh than regular params.
If sharding on a nonzero dim, we currently require even sharding,
i.e. the tensor dim size on that dim must be divisible by the FSDP
shard mesh size.
mp_policy (MixedPrecisionPolicy): This controls the mixed precision
policy, which offers parameter/reduction mixed precision for this
module. See :class:`MixedPrecisionPolicy` for details.
offload_policy (OffloadPolicy): This controls the offloading policy,
which offers parameter/gradient/optimizer state offloading. See
:class:`OffloadPolicy` and its subclasses for details.
ignored_params: Optional(Set[nn.Parameter]): The set of parameters to be
ignored by FSDP. They will not be sharded, nor moved to the device
during init, nor have their gradients reduced in backward.
dp_mesh_dims (Optional[DataParallelMeshDims]): When provided,
``mesh`` is treated as the full SPMD mesh, and parameters should be
DTensors on this mesh with ``Replicate()`` on all DP dimensions.
The ``shard`` field names which dim(s) FSDP shards on (multiple
dims are flattened). The ``replicate`` field names the HSDP
replication dim(s) (multiple dims are flattened).
Returns:
FSDPModule: The module with FSDP applied (in-place).
"""
torch._C._log_api_usage_once("torch.distributed.fsdp.fully_shard")
_validate_module(module, "fully_shard")
mesh = mesh or _init_default_mesh()
_validate_mesh(mesh, dp_mesh_dims)
mesh_info = _get_mesh_info(mesh, dp_mesh_dims)
device = _get_device_from_mesh(mesh)
auto_reshard_after_forward = reshard_after_forward is None
# If the user does not provide ``reshard_after_forward``, we set it to True.
# During lazy_init, we identify which module is the root and override its value to False
if isinstance(mesh_info, FSDPMeshInfo):
if (
mesh_info.is_spmd_mesh
and not isinstance(reshard_after_forward, bool)
and isinstance(reshard_after_forward, int)
):
raise NotImplementedError(
"reshard_after_forward as int is not yet supported with "
"SPMD mesh (dp_mesh_dims)"
)
post_forward_mesh_info = _get_post_forward_mesh_info(
reshard_after_forward if not auto_reshard_after_forward else True, # type: ignore[arg-type]
mesh_info,
)
else:
# DDPMeshInfo: no sharding, so no post-forward resharding needed
post_forward_mesh_info = None
arg_module, modules, managed_modules, params, buffers = _get_modules_and_states(
module, device, ignored_params
)
state = fully_shard.state(modules[0]) # type: ignore[attr-defined]
state.init(modules, device, mp_policy, auto_reshard_after_forward)
_init_param_group(
state,
params,
modules,
mesh_info,
post_forward_mesh_info,
device,
shard_placement_fn,
mp_policy,
offload_policy,
reshard_after_forward=reshard_after_forward
if not auto_reshard_after_forward
else True,
)
# For Dynamo
for managed_module in managed_modules:
managed_module._is_fsdp_managed_module = True # type: ignore[assignment]
managed_module._fsdp_use_orig_params = True # type: ignore[assignment]
# Place FSDP leftmost for highest priority in the method resolution order
_apply_to_module(
modules, cls_to_fsdp_cls, FSDPModule, "FSDP", _unimplemented_deepcopy
)
return arg_module
def _unimplemented_deepcopy(*args: Any, **kwargs: Any) -> NoReturn:
raise AssertionError(
"FSDP does not support deepcopy. Please use state dict for serialization."
)
_enable_fsdp_module_new_init: bool = True
@contextmanager
def disable_fsdp_module_new_init() -> Iterator[None]:
global _enable_fsdp_module_new_init
prev, _enable_fsdp_module_new_init = _enable_fsdp_module_new_init, False
try:
yield
finally:
_enable_fsdp_module_new_init = prev
class FSDPModule:
# Index in MRO where the original class is found.
# For FSDP: [FSDP<Orig>, FSDPModule, Orig, ...] -> index 2
# Subclasses like ReplicateModule override this.
_orig_cls_mro_index: int = 2
def __new__(cls, *args, **kwargs):
"""
Override ``__new__`` to remove the FSDP class and directly construct
the original class for cases like indexing into a container module.
"""
orig_cls = cls.__mro__[cls._orig_cls_mro_index]
self = orig_cls.__new__(orig_cls, *args, **kwargs)
if _enable_fsdp_module_new_init:
self.__init__(*args, **kwargs)
return self
def reshard(self) -> None:
"""
Reshards the module's parameters, freeing the unsharded parameters if
they are allocated and registering the sharded parameters to the
module. This method is *not* recursive.
"""
state = self._get_fsdp_state()
for fsdp_param_group in state._fsdp_param_groups:
fsdp_param_group.reshard()
def unshard(self, async_op: bool = False) -> UnshardHandle | None:
"""
Unshards the module's parameters by allocating memory and all-gathering
the parameters. This method is *not* recursive. The unshard follows the
:class:`MixedPrecisionPolicy`, so it will all-gather following
``param_dtype`` if set.
Args:
async_op (bool): If ``True``, then returns a :class:`UnshardHandle`
that has a :meth:`wait` method to wait on the unshard op. If
``False``, then returns ``None`` and waits on the handle inside
this function.
.. note:: If ``async_op=True``, then FSDP will wait on the pending
unshard in the module's pre-forward for the user. The user only
needs to call :meth:`wait` explicitly if the wait should happen
before pre-forward.
"""
state = self._get_fsdp_state()
for fsdp_param_group in state._fsdp_param_groups:
fsdp_param_group.lazy_init()
fsdp_param_group.unshard(async_op=async_op)
handle = _UnshardHandleImpl(
list(state._fsdp_param_groups) if state._fsdp_param_groups else None
)
if async_op:
return handle
handle.wait()
return None
def set_is_last_backward(self, is_last_backward: bool) -> None:
"""
Sets whether the next backward is the last one. On the last backward,
FSDP waits on pending gradient reduction and clears internal data
data structures for backward prefetching. This can be useful for
microbatching.
"""
state = self._get_fsdp_state()
state._state_ctx.is_last_backward = is_last_backward
def set_requires_gradient_sync(
self, requires_gradient_sync: bool, *, recurse: bool = True
) -> None:
"""
Sets if the module should sync gradients. This can be used to implement
gradient accumulation *without communication*. For HSDP, this controls
both reduce-scatter and all-reduce together. This is the equivalence of
`no_sync` in FSDP1.
Args:
requires_gradient_sync (bool): Whether to reduce gradients for the
module's parameters.
recurse (bool): Whether to set for all FSDP submodules or just the
passed-in module.
"""
self_module = cast(nn.Module, self)
modules = list(self_module.modules()) if recurse else [self_module]
for module in modules:
if isinstance(module, FSDPModule):
state = module._get_fsdp_state()
for fsdp_param_group in state._fsdp_param_groups:
fsdp_param_group.reduce_grads = requires_gradient_sync
fsdp_param_group.all_reduce_grads = requires_gradient_sync
def set_requires_all_reduce(
self, requires_all_reduce: bool, *, recurse: bool = True
) -> None:
"""
Sets if the module should all-reduce gradients. This can be used to
implement gradient accumulation with only reduce-scatter but not
all-reduce for HSDP.
"""
self_module = cast(nn.Module, self)
modules = list(self_module.modules()) if recurse else [self_module]
for module in modules:
if isinstance(module, FSDPModule):
state = module._get_fsdp_state()
for fsdp_param_group in state._fsdp_param_groups:
fsdp_param_group.all_reduce_grads = requires_all_reduce
def set_reshard_after_forward(
self, reshard_after_forward: bool, recurse: bool = True
) -> None:
"""
Sets if the module should reshard parameters after forward. This can be
used to change the ``reshard_after_forward`` FSDP arg at runtime. For
example, this can be used to set the FSDP root module's value to
``True`` (since it is otherwise specially set to ``False``), or it can
set an FSDP module's value to ``False`` for running evals and set back
to ``True`` for training.
Args:
reshard_after_forward (bool): Whether to reshard parameters after
forward.
recurse (bool): Whether to set for all FSDP submodules or just the
passed-in module.
"""
if not isinstance(reshard_after_forward, bool):
raise ValueError(
f"reshard_after_forward should be a bool, got {type(reshard_after_forward)}"
)
self_module = cast(nn.Module, self)
modules = list(self_module.modules()) if recurse else [self_module]
for module in modules:
if isinstance(module, FSDPModule):
state = module._get_fsdp_state()
state._auto_reshard_after_forward = False
for fsdp_param_group in state._fsdp_param_groups:
if not isinstance(fsdp_param_group.mesh_info, FSDPMeshInfo):
raise AssertionError
fsdp_param_group.post_forward_mesh_info = (
_get_post_forward_mesh_info(
reshard_after_forward,
fsdp_param_group.mesh_info,
)
)
def set_reshard_after_backward(
self, reshard_after_backward: bool, *, recurse: bool = True
) -> None:
"""
Sets if the module should reshard parameters after backward. This can
be used during gradient accumulation to trade off higher memory for
reduced communication since the unsharded parameters do not need to be
re-all-gathered before the next forward.
Args:
reshard_after_backward (bool): Whether to reshard parameters after
backward.
recurse (bool): Whether to set for all FSDP submodules or just the
passed-in module.
"""
self_module = cast(nn.Module, self)
modules = list(self_module.modules()) if recurse else [self_module]
for module in modules:
if isinstance(module, FSDPModule):
state = module._get_fsdp_state()
for fsdp_param_group in state._fsdp_param_groups:
fsdp_param_group.reshard_after_backward = reshard_after_backward
def set_modules_to_forward_prefetch(self, modules: list[FSDPModule]) -> None:
"""
Sets the FSDP modules for which this FSDP module should explicitly
prefetch all-gathers in forward. The prefetching runs after this
module's all-gather copy-out.
Passing a singleton list containing the next FSDP module gives the same
all-gather overlap behavior as the default overlap behavior, except the
prefetched all-gather is issued earlier from the CPU. Passing a list
with at least length two is required for more aggressive overlap and
will use more reserved memory.
Args:
modules (List[FSDPModule]): FSDP modules to prefetch.
"""
_assert_all_fsdp_modules(modules)
self._get_fsdp_state()._states_to_forward_prefetch = [
module._get_fsdp_state() for module in modules
]
def set_modules_to_backward_prefetch(self, modules: list[FSDPModule]) -> None:
"""
Sets the FSDP modules for which this FSDP module should explicitly
prefetch all-gathers in backward. This overrides the default backward
pretching implementation that prefetches the next FSDP module based on
the reverse post-forward order.
Passing a singleton list containing the previous FSDP module gives the
same all-gather overlap behavior as the default overlap behavior.
Passing a list with at least length two is required for more aggressive
overlap and will use more reserved memory.
Args:
modules (List[FSDPModule]): FSDP modules to prefetch.
"""
_assert_all_fsdp_modules(modules)
self._get_fsdp_state()._states_to_backward_prefetch = [
module._get_fsdp_state() for module in modules
]
def set_custom_all_gather(self, comm: AllGather) -> None:
"""
Overrides the default ``all_gather`` communication behavior,
to have better control over the communication and memory usage.
See `Comm` and `ReduceScatter` for details.
Args:
comm (AllGather): Custom all-gather communication.
"""
state = self._get_fsdp_state()
if len(state._fsdp_param_groups) > 1:
raise ValueError(
"set_custom_all_gather is not supported with multiple param "
"groups (from per-param mesh via shard_placement_fn). "
"The custom comm would be ambiguous across groups with different meshes."
)
for fsdp_param_group in state._fsdp_param_groups:
fsdp_param_group._all_gather_comm = comm
def set_custom_reduce_scatter(self, comm: ReduceScatter) -> None:
"""
Overrides the default ``reduce_scatter`` communication behavior,
to have better control over the communication and memory usage.
See `Comm` and `ReduceScatter` for details.
Args:
comm (ReduceScatter): Custom reduce_scatter communication.
"""
state = self._get_fsdp_state()
if len(state._fsdp_param_groups) > 1:
raise ValueError(
"set_custom_reduce_scatter is not supported with multiple param "
"groups (from per-param mesh via shard_placement_fn). "
"The custom comm would be ambiguous across groups with different meshes."
)
for fsdp_param_group in state._fsdp_param_groups:
fsdp_param_group._reduce_scatter_comm = comm
def set_all_reduce_hook(
self,
hook: Callable[[torch.Tensor], None],
*,
stream: torch.cuda.Stream | None = None,
):
"""
Args:
hook (Callable[[torch.Tensor], None]): User-defined all-reduce hook
with expected signature ``hook(reduce_output: torch.Tensor) -> None``
where ``reduce_output`` is the reduce-scatter output if only
using FSDP or the all-reduce output if using native HSDP.
stream (Optional[torch.cuda.Stream]): Stream to run the all-reduce
hook in. This should only be set if not using native HSDP. If
using native HSDP, the hook will run in the internally defined
all-reduce stream used by the native HSDP all-reduce.
"""
state = self._get_fsdp_state()
if len(state._fsdp_param_groups) > 1:
raise ValueError(
"set_all_reduce_hook is not supported with multiple param "
"groups (from per-param mesh via shard_placement_fn). "
"The hook would be ambiguous across groups with different meshes."
)
for fsdp_param_group in state._fsdp_param_groups:
fsdp_param_group._all_reduce_hook = hook
if stream is not None:
if fsdp_param_group._is_hsdp:
raise ValueError("stream cannot be set when using native HSDP")
fsdp_param_group._all_reduce_hook_stream = stream
def set_post_optim_event(self, event: torch.Event) -> None:
"""
Sets a post-optimizer-step event for the root FSDP module to wait the
all-gather streams on.
By default, the root FSDP module waits the all-gather streams on the
current stream to ensure that the optimizer step has finished before
all-gathering. However, this may introduce false dependencies if
there is unrelated computation after the optimizer step. This API
allows the user to provide their own event to wait on. After the root
waits on the event, the event is discarded, so this API should be
called with a new event each iteration.
Args:
event (torch.Event): Event recorded after the optimizer step
to wait all-gather streams on.
"""
self._get_fsdp_state()._state_ctx.post_optim_event = event
@deprecated("Use `set_gradient_divide_factor` instead")
def set_reduce_scatter_divide_factor(self, factor: float) -> None:
"""Use :py:meth:`set_gradient_divide_factor` instead"""
self.set_gradient_divide_factor(factor)
def set_gradient_divide_factor(self, factor: float) -> None:
"""
Sets a custom divide factor for the gradient reduction. This might use
a custom reduce op using NCCL's PreMulSum, which allows multiplying by
the factor before reduction.
Args:
factor (float): Custom divide factor.
"""
state = self._get_fsdp_state()
for fsdp_param_group in state._fsdp_param_groups:
fsdp_param_group.gradient_divide_factor = factor
def set_force_sum_reduction_for_comms(self, enable: bool) -> None:
"""
Sets whether to require the low-level collective communication
primitives to exclusively use "sum"-type reductions, even if it comes
at the cost of separate additional pre- or post-scaling operations.
This is needed for example because NCCL currently supports zero-copy
transfers only for this kind of collectives.
NB: for MTIA devices, this is always implicitly enabled.
NB: if `set_all_reduce_hook` is used under FSDP setup, the caller needs
to ensure the custom all-reduce across FSDP units follow this strategy
as well, as FSDP can no longer automatically handle that.
Args:
enable (bool): Whether to only ever use ReduceOp.SUM for comms.
"""
state = self._get_fsdp_state()
for fsdp_param_group in state._fsdp_param_groups:
fsdp_param_group.force_sum_reduction_for_comms = enable
def set_unshard_in_backward(self, unshard_in_backward: bool) -> None:
"""
Sets whether the FSDP module's parameters need to be unsharded in
backward. This can be used in expert cases when the user knows that all
parameters in this FSDP module's parameter group are not needed for
backward computation (e.g. embedding).
"""
state = self._get_fsdp_state()
for fsdp_param_group in state._fsdp_param_groups:
fsdp_param_group.unshard_in_backward = unshard_in_backward
def set_allocate_memory_from_process_group_for_comm(self, enable: bool) -> None:
"""
Sets whether the temporary staging buffers used to send and receive data
over collective communications should be allocated using the custom
optimized allocator provided by the ProcessGroup itself (if any). This
might allow the ProcessGroup to be more efficient. For example, when
using NCCL, this enables it to leverage zero-copy transfers over SHARP
(for NVLink and/or InfiniBand).
This cannot be used together with :meth:`set_custom_all_gather` or
:meth:`set_custom_reduce_scatter` as those APIs allow for
finer-grained control over each communication, and this method cannot
determine their staging buffer allocation strategy.
Args:
enable (bool): Whether to turn on ProcessGroup allocation.
"""
state = self._get_fsdp_state()
for fsdp_param_group in state._fsdp_param_groups:
fsdp_param_group.set_allocate_memory_from_process_group(enable)
def set_symm_mem_for_comm(self, backend: Literal["NCCL"] = "NCCL") -> None:
"""
Sets the symmetric memory (``symm_mem``) backend for allocating the
staging buffers used in all-gather collectives. This allows NCCL to use
optimized all-gather implementations via symmetric memory. Such
optimization may depend on the topology of the system. For single node,
Copy Engine All-Gather may be used. For multi-node, Symmetric Kernel
All-Gather may be used.
To enable Copy Engine All-Gather, you need to set the NCCL process group
with the zero-CTA policy.
```python
opts = dist.ProcessGroupNCCL.Options()
opts.config.cta_policy = dist.ProcessGroupNCCL.NCCL_CTA_POLICY_ZERO
dist.init_process_group(backend="nccl", pg_options=opts, device_id=device)
```
Alternatively, you can set the environment variable `NCCL_CTA_POLICY` to 2.
```bash
export NCCL_CTA_POLICY=2
```
For more details, see [Copy Engine
Collectives](https://docs.pytorch.org/docs/2.11/symmetric_memory.html#copy-engine-collectives).
This cannot be used together with :meth:`set_custom_all_gather` or
:meth:`set_custom_reduce_scatter`.
Args:
backend (str): The symmetric memory backend to use. Defaults to
``"NCCL"``. Currently, only ``"NCCL"`` is supported.
"""
state = self._get_fsdp_state()
for fsdp_param_group in state._fsdp_param_groups:
fsdp_param_group.set_symm_mem(backend)
def _set_unshard_async_op(self, async_op: bool):
"""
Sets whether to use ``async_op=True`` or ``False`` for the pre-forward
and pre-backward unshard op. This defaults to ``False`` but can be set
to ``True`` with this method.
Setting this to ``True`` allows the all-gather allocations to happen in
the default stream, avoiding inter-stream memory fragmentation.
However, you must use explicit prefetching (e.g. via :meth:`unshard`)
in forward to still get overlap, and the pre-all-gather ops like dtype
casting and copy-in will not overlap with compute.
"""
self_module = cast(nn.Module, self)
for module in self_module.modules():
if isinstance(module, FSDPModule):
state = module._get_fsdp_state()
for fsdp_param_group in state._fsdp_param_groups:
fsdp_param_group.unshard_async_op = async_op
def _get_fsdp_state(self) -> FSDPState:
if (state := _get_module_fsdp_state(cast(nn.Module, self))) is None:
raise AssertionError(f"No FSDP state found on {self}")
return state
def _apply(self, *args: Any, **kwargs: Any) -> Any:
# Reshard to ensure that sharded parameters are registered
self.reshard()
ret = super()._apply(*args, **kwargs) # type: ignore[misc]
state = self._get_fsdp_state()
if not state._fsdp_param_groups:
return ret
# TODO: Remove this padding logic once DTensor pads the local tensor:
# https://github.com/pytorch/pytorch/issues/113045
with torch.no_grad():
for fsdp_param_group in state._fsdp_param_groups:
for fsdp_param in fsdp_param_group.fsdp_params:
fsdp_param.reset_sharded_param()
return ret
class UnshardHandle:
"""
A handle to wait on a :meth:`FSDPModule.unshard` op.
"""
def wait(self) -> None:
"""
Waits on the unshard op. This ensures that the current stream can use
the unsharded parameters, which are now registered to the module.
"""
return
class _UnshardHandleImpl(UnshardHandle):
def __init__(self, fsdp_param_groups: list[FSDPParamGroup] | None):
self._fsdp_param_groups = fsdp_param_groups
def wait(self):
if self._fsdp_param_groups is not None:
for fsdp_param_group in self._fsdp_param_groups:
fsdp_param_group.wait_for_unshard()
# Avoid keeping a reference
self._fsdp_param_groups = None
def register_fsdp_forward_method(module: nn.Module, method_name: str) -> None:
"""
Registers a method on ``module`` to be considered a forward method for
FSDP.
FSDP all-gathers parameters pre-forward and optionally frees parameters
post-forward (depending on ``reshard_after_forward``). FSDP only knows to
do this for :meth:`nn.Module.forward` by default. This function patches a
user-specified method to run the pre/post-forward hooks before/after the
method, respectively. If ``module`` is not an :class:`FSDPModule`, then
this is a no-op.
Args:
module (nn.Module): Module to register the forward method on.
method_name (str): Name of the forward method.
"""
if not isinstance(module, FSDPModule):
# Make no-op to allow including both when using/not using FSDP
return
if not hasattr(module, method_name):
raise ValueError(f"{type(module)} does not have a method {method_name}")
orig_method = getattr(module, method_name)
@functools.wraps(orig_method)
def wrapped_method(self, *args, **kwargs):
fsdp_state = self._get_fsdp_state()
args, kwargs = fsdp_state._pre_forward(self, args, kwargs)
out = orig_method(*args, **kwargs)
return fsdp_state._post_forward(self, args, out)
# Use `__get__` to make `wrapped_method` an instance method
setattr(
module,
method_name,
wrapped_method.__get__(module, type(module)), # type:ignore[attr-defined]
)
def share_comm_ctx(modules: list[FSDPModule]) -> None:
"""
Share cuda streams for multiple FSDPModules
Example usage:
from torch.distributed.fsdp import share_comm_ctx
share_comm_ctx([fsdp_model_1, fsdp_model_2, ...])
For Pipeline Parallelism (PP), each model chunk is a FSDP root. We want
to share cuda streams for all-gather, reduce-scatter, and all-reduce.
This avoids allocating inter-stream memory framgmentation
Args:
modules (List[FSDPModule]): modules to share cuda streams
"""
if len(modules) == 0:
return
for module in modules:
if not isinstance(module, FSDPModule):
raise ValueError(f"Expects list of FSDPModules but got {module}")
fsdp_states = [module._get_fsdp_state() for module in modules]
comm_ctx = fsdp_states[0]._comm_ctx
for fsdp_state in fsdp_states[1:]:
fsdp_state._comm_ctx = comm_ctx
for fsdp_param_group in fsdp_state._fsdp_param_groups:
fsdp_param_group.comm_ctx = comm_ctx
def _assert_all_fsdp_modules(modules: Iterable[Any]) -> None:
for module in modules:
if not isinstance(module, FSDPModule):
raise ValueError(f"Expects FSDPModule but got {type(module)}: {module}")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
import collections
import torch
class _FreeEventQueue:
"""
This tracks all pending frees corresponding to inflight all-gathers. The
queueing pattern is iterative enqueues with a single dequeue per iteration
once the limit ``_max_num_inflight_all_gathers`` is reached.
"""
def __init__(self) -> None:
self._queue: collections.deque[torch.Event] = collections.deque()
self._max_num_inflight_all_gathers = 2 # empirically chosen
def enqueue(self, free_event: torch.Event) -> None:
"""Enqueues a free event."""
self._queue.append(free_event)
def dequeue_if_needed(self) -> torch.Event | None:
"""Dequeues a single event if the limit is reached."""
if len(self._queue) >= self._max_num_inflight_all_gathers:
return self._dequeue()
return None
def _dequeue(self) -> torch.Event | None:
"""Dequeues a free event if possible."""
if self._queue:
event = self._queue.popleft()
return event
return None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,139 @@
# mypy: allow-untyped-defs
import copy
import itertools
import math
import torch
import torch.distributed as dist
from torch._utils import _get_device_module
from torch.distributed import distributed_c10d
from torch.distributed._shard.sharded_tensor import (
Shard,
ShardedTensor,
ShardedTensorMetadata,
TensorProperties,
)
from torch.distributed._shard.sharding_spec import ShardMetadata
from torch.distributed.tensor import DeviceMesh, DTensor, Replicate, Shard as DShard
def _get_remote_device_str(rank, device_type, num_devices_per_node):
if device_type.lower() == "cpu":
return f"rank:{rank}/{device_type}"
elif device_type.lower() == "hpu":
return f"rank:{rank}/{device_type}:{_get_device_module(device_type).current_device()}"
else:
return f"rank:{rank}/{device_type}:{rank % num_devices_per_node}"
def _create_chunk_sharded_tensor(
tensor: torch.Tensor,
rank: int,
world_size: int,
num_devices_per_node: int,
pg: dist.ProcessGroup,
device: torch.device | None = None,
) -> ShardedTensor:
"""
Shard a tensor to chunks along the first dimension. The local rank will gets its
corresponding chunk as the local shard to create a ShardedTensor.
"""
chunks = tensor.chunk(world_size, dim=0)
if len(chunks) > rank:
local_shard = chunks[rank].clone()
offsets = [0 for _ in tensor.size()]
offsets[0] = math.ceil(tensor.size()[0] / world_size) * rank
local_shards = [Shard.from_tensor_and_offsets(local_shard, offsets, rank)]
else:
local_shards = []
# Create a ShardedTensor without invoking communication.
chunk_sizes = [list(chunk.size()) for chunk in chunks]
dim0_offsets = [0] + list(
itertools.accumulate([chunk_size[0] for chunk_size in chunk_sizes])
)[:-1]
offsets = [0] * (len(chunk_sizes[0]) - 1)
chunk_offsets = [[d0] + offsets for d0 in dim0_offsets]
device_type = (
distributed_c10d._get_pg_default_device(pg).type
if device is None
else device.type
)
placements = [
_get_remote_device_str(
dist.get_global_rank(pg, r),
device_type,
num_devices_per_node,
)
for r in range(len(chunk_sizes))
]
if len(chunk_sizes) != len(chunk_offsets) or len(chunk_sizes) != len(placements):
raise AssertionError(
f"Expected chunk_sizes, chunk_offsets, and placements to have the same length, "
f"got {len(chunk_sizes)}, {len(chunk_offsets)}, {len(placements)}"
)
shard_metadata = [
ShardMetadata(offset, size, placement)
for offset, size, placement in zip(chunk_offsets, chunk_sizes, placements)
]
sharded_tensor_metadata = ShardedTensorMetadata(
shards_metadata=shard_metadata,
size=tensor.size(),
tensor_properties=TensorProperties(
dtype=tensor.dtype,
layout=tensor.layout,
requires_grad=False,
memory_format=torch.contiguous_format,
pin_memory=tensor.is_pinned(),
),
)
return ShardedTensor._init_from_local_shards_and_global_metadata(
local_shards, sharded_tensor_metadata=sharded_tensor_metadata, process_group=pg
)
def _create_chunk_dtensor(
tensor: torch.Tensor,
rank: int,
device_mesh: DeviceMesh,
) -> DTensor:
"""
Shard a tensor to chunks along the first dimension. The local rank will gets its
corresponding chunk as the local tensor to create a DTensor.
"""
# We need to explicitly call .detach() to return a new tensor detached from the current graph.
tensor = tensor.detach().clone()
# FSDP placements: [Shard(0)]
# HSDP placements: [Replicate(), Shard(0)]
replicate_placements = [Replicate() for _ in range(device_mesh.ndim)]
shard_placements = [Replicate() for _ in range(device_mesh.ndim)]
shard_placements[-1] = DShard(0) # type: ignore[call-overload]
return DTensor.from_local(
tensor, device_mesh, replicate_placements, run_check=False
).redistribute(
placements=shard_placements,
)
def _all_gather_dtensor(
tensor: DTensor,
root_mesh: DeviceMesh | None,
) -> torch.Tensor:
"""
All gather a DTensor in its sharded dimension and return the local tensor.
"""
if root_mesh != tensor.device_mesh:
raise AssertionError("The device mesh of a tensor should be a root mesh.")
placements = list(copy.deepcopy(tensor.placements))
# FSDP placements: [Shard(0)] -> [Replicate()]
# HSDP placements: [Replicate(), Shard(0)] -> [Replicate(), Replicate()]
placements[-1] = Replicate()
tensor = tensor.redistribute(
device_mesh=tensor.device_mesh,
placements=placements,
)
return tensor.to_local()
@@ -0,0 +1,932 @@
# mypy: allow-untyped-defs
import contextlib
import logging
import math
import warnings
from collections.abc import Callable, Generator, Iterator
from typing import Any, cast, no_type_check
import torch
import torch.distributed as dist
import torch.distributed.algorithms._checkpoint.checkpoint_wrapper as checkpoint_wrapper
import torch.nn as nn
import torch.nn.functional as F
from torch.distributed._shard.sharded_tensor import (
init_from_local_shards,
Shard,
ShardedTensor,
)
from torch.distributed.fsdp._common_utils import (
_FSDPState,
_get_module_fsdp_state_if_fully_sharded_module,
_has_fsdp_params,
_is_composable,
_module_handle,
clean_tensor_name,
FSDP_PREFIX,
FSDP_WRAPPED_MODULE,
)
from torch.distributed.fsdp._debug_utils import SimpleProfiler
from torch.distributed.fsdp._runtime_utils import (
_cast_buffers_to_dtype_and_device,
_get_orig_buffer_dtypes,
_lazy_init,
_reset_flat_param_grad_info_if_needed,
)
from torch.distributed.fsdp.api import (
FullStateDictConfig,
ShardingStrategy,
StateDictType,
)
from torch.distributed.tensor import DTensor
from torch.distributed.utils import _replace_by_prefix
from ._fsdp_extensions import (
_ext_all_gather_dtensor,
_ext_chunk_dtensor,
_ext_chunk_tensor,
_ext_post_unflatten_transform,
_ext_pre_load_state_dict_transform,
)
from ._unshard_param_utils import _unshard_fsdp_state_params, FLAT_PARAM
logger = logging.getLogger(__name__)
def _should_unshard_params(fsdp_state: _FSDPState) -> bool:
return not (
fsdp_state.sharding_strategy == ShardingStrategy.NO_SHARD
and (_is_composable(fsdp_state) or fsdp_state._use_orig_params)
)
def _convert_to_wrapped_module_name(module_name: str) -> str:
module_name = module_name.replace(f"{FSDP_PREFIX}", "")
module_name = module_name.replace(f"{FSDP_WRAPPED_MODULE}", "")
if module_name:
module_name = f"{module_name}."
# `CheckpointWrapper` adds a prefix that has to be removed as well.
module_name = module_name.replace(checkpoint_wrapper._CHECKPOINT_PREFIX, "")
return module_name
def _param_name_infos(
module: nn.Module, fsdp_state: _FSDPState
) -> Iterator[tuple[str, str, str]]:
if not _has_fsdp_params(fsdp_state, module):
return
for param_name, module_name in _module_handle(
fsdp_state, module
).param_module_names():
module_name = _convert_to_wrapped_module_name(module_name)
fqn = f"{module_name}{param_name}"
yield fqn, param_name, module_name
def _shared_param_name_infos(
module: nn.Module, fsdp_state
) -> Iterator[tuple[str, str, str]]:
for param_name, module_name in _module_handle(
fsdp_state, module
).shared_param_module_names():
module_name = _convert_to_wrapped_module_name(module_name)
fqn = f"{module_name}{param_name}"
yield fqn, param_name, module_name
@no_type_check
def _enter_unshard_params_ctx(
module: nn.Module,
fsdp_state: _FSDPState,
writeback: bool = False,
rank0_only: bool = False,
offload_to_cpu: bool = False,
with_grads: bool = False,
) -> None:
"""
state_dict hooks cannot use the pure context call as the checkpoint flow
requires to enter the context in the pre-hook but leave the context in the
post-hook. This API enters the context of ``_unshard_fsdp_state_params``.
"""
if module in fsdp_state._unshard_params_ctx:
raise AssertionError(
"Entering the ``_unshard_fsdp_state_params`` context but _unshard_params_ctx[module] "
"is not None."
)
fsdp_state._unshard_params_ctx[module] = _unshard_fsdp_state_params(
module,
fsdp_state,
writeback=writeback,
rank0_only=rank0_only,
offload_to_cpu=offload_to_cpu,
with_grads=with_grads,
)
fsdp_state._unshard_params_ctx[module].__enter__()
@no_type_check
def _exit_unshard_params_ctx(module: nn.Module, fsdp_state: _FSDPState) -> None:
"""A helper function to exit ``_unshard_fsdp_state_params`` context."""
fsdp_state._unshard_params_ctx[module].__exit__(None, None, None)
fsdp_state._unshard_params_ctx.pop(module)
def _common_pre_state_dict_hook(
module: nn.Module,
fsdp_state: _FSDPState,
) -> None:
"""Performs the pre-state_dict tasks shared by all state_dict types."""
if fsdp_state._device_handle.is_available():
fsdp_state._device_handle.synchronize()
# TODO: need to check if this is always correct for composable FSDP.
_lazy_init(fsdp_state, module)
if fsdp_state._is_root:
_reset_flat_param_grad_info_if_needed(fsdp_state._all_handles)
def _common_unshard_pre_state_dict_hook(
module: nn.Module,
fsdp_state: _FSDPState,
offload_to_cpu: bool,
rank0_only: bool,
) -> None:
"""
Performs the pre-state_dict tasks shared by all state_dict types that require
``_unshard_fsdp_state_params()``. FULL_STATE_DICT and SHARDED_STATE_DICT use this hook.
"""
# For composable `fully_shard`, it does not need to unshard parameters for `NO_SHARD` cases.
if not _should_unshard_params(fsdp_state):
return
_enter_unshard_params_ctx(
module,
fsdp_state,
writeback=False,
offload_to_cpu=offload_to_cpu,
rank0_only=rank0_only,
)
@no_type_check
def _common_unshard_post_state_dict_hook(
module: nn.Module,
fsdp_state: _FSDPState,
state_dict: dict[str, Any],
prefix: str,
param_hook: Callable,
) -> dict[str, Any]:
"""
The post-state_dict flow that shared by all state_dict types that require
``_unshard_fsdp_state_params()``. FULL_STATE_DICT and SHARDED_STATE_DICT use this
hook.
"""
_replace_by_prefix(state_dict, prefix + f"{FSDP_PREFIX}", prefix)
# Return early for trivial cases
if not state_dict or not _has_fsdp_params(fsdp_state, module):
if _should_unshard_params(fsdp_state):
_exit_unshard_params_ctx(module, fsdp_state)
return state_dict
# If a rank does not have unsharded parameters(when `rank0_only=True`
# and `rank != 0`), then the rank only needed to participate in the
# all-gather and does not need to save the # state dict. We simply check
# rank0_only to ensure this issue.
rank0_only = (
fsdp_state._state_dict_type == StateDictType.FULL_STATE_DICT
and cast(FullStateDictConfig, fsdp_state._state_dict_config).rank0_only
)
# no_fsdp_return means the state_dict returned by this rank should contain
# only non-FSDP controlled parameters and buffers.
no_fsdp_return = rank0_only and fsdp_state.rank != 0
if no_fsdp_return and not fsdp_state._use_orig_params:
for clean_key in fsdp_state._buffer_names:
# This is a hack to support activation checkpoint.
clean_key = clean_key.replace(
f"{checkpoint_wrapper._CHECKPOINT_PREFIX}.", ""
)
state_dict.pop(f"{prefix}{clean_key}", None)
# Non-zero ranks have flat_param key when rank0_only=True, because rank0_only=True is
# passed in to unshard context, but nonzero ranks reshard early, causing this flat_param
# to appear in state_dict.
state_dict.pop(f"{prefix}{FLAT_PARAM}")
_exit_unshard_params_ctx(module, fsdp_state)
return state_dict
# Loop only the parameters saved in this instance's wrapped module to
# avoid processing buffers.
for fqn, param_name, module_name in _param_name_infos(module, fsdp_state):
fqn = f"{prefix}{fqn}"
if no_fsdp_return:
state_dict.pop(fqn)
continue
if fqn not in state_dict:
raise AssertionError(
f"FSDP assumes {fqn} is in the state_dict but the state_dict only "
f"has {state_dict.keys()}. "
f"prefix={prefix}, module_name={module_name}, "
f"param_name={param_name} rank={fsdp_state.rank}."
)
param_hook(state_dict, prefix, fqn)
if _should_unshard_params(fsdp_state):
_exit_unshard_params_ctx(module, fsdp_state)
cpu_device = torch.device("cpu")
buffer_clean_fqns = []
buffers = []
for clean_key in fsdp_state._buffer_names:
# This is a hack to support activation checkpoint.
clean_key = clean_tensor_name(clean_key)
fqn = f"{prefix}{clean_key}"
if fqn not in state_dict:
# A buffer can be registered as non-persistent.
continue
if no_fsdp_return:
state_dict.pop(fqn)
else:
buffer = state_dict[fqn]
if (
fsdp_state._state_dict_config.offload_to_cpu
and buffer.device != cpu_device
):
state_dict[fqn] = buffer.to(cpu_device)
# skip upcasting for ignored buffers
if clean_key not in fsdp_state._ignored_buffer_names:
buffer_clean_fqns.append(clean_key)
buffers.append(state_dict[fqn])
if buffers:
mixed_precision_enabled_for_buffers = (
fsdp_state._mixed_precision_enabled_for_buffers()
if not _is_composable(fsdp_state)
else (fsdp_state.mixed_precision.buffer_dtype is not None)
)
if mixed_precision_enabled_for_buffers:
buffer_dtypes = _get_orig_buffer_dtypes(fsdp_state, buffer_clean_fqns)
_cast_buffers_to_dtype_and_device(
buffers, buffer_dtypes, fsdp_state.compute_device
)
for buffer, clean_fqn in zip(buffers, buffer_clean_fqns):
fqn = f"{prefix}{clean_fqn}"
logger.info("FSDP is casting the dtype of %s to %s", fqn, buffer.dtype)
state_dict[fqn] = buffer.clone()
return state_dict
@no_type_check
def _full_pre_state_dict_hook(
fsdp_state: _FSDPState,
module: nn.Module,
*args,
**kwargs,
) -> None:
"""
Hook that runs before model.state_dict() is called. pre-state_dict hook is
not actually supported by ``nn.Module``. As a result, this API is called
from ``_full_post_state_dict_hook()`` to simulate the case. Once pre-state_dict
is supported in ``nn.Module``, this hook will be registered as a hook in
``nn.Module``.
"""
if getattr(fsdp_state, "_device_mesh", False):
fsdp_state._device_mesh._get_root_mesh()
_common_pre_state_dict_hook(module, fsdp_state)
_common_unshard_pre_state_dict_hook(
module,
fsdp_state,
offload_to_cpu=fsdp_state._state_dict_config.offload_to_cpu,
rank0_only=cast(FullStateDictConfig, fsdp_state._state_dict_config).rank0_only,
)
@no_type_check
def _full_post_state_dict_hook(
module: nn.Module,
fsdp_state: _FSDPState,
state_dict: dict[str, Any],
prefix: str,
) -> dict[str, Any]:
"""
Hook that runs after model.state_dict() is called before returning result to
user. For FSDP, we may have to clone the tensors in state_dict as params go
back to sharded version after _unshard_fsdp_state_params ends, and also remove
the ``FSDP_WRAPPED_MODULE`` prefix.
"""
def param_hook(
state_dict: dict[str, Any],
prefix: str,
fqn: str,
) -> None:
clean_key = fqn
clean_prefix = clean_tensor_name(prefix)
# Strip prefix out of key if needed as buffer names and param names
# do not have prefix considered as they are not computed in `state_dict`
# call.
clean_key = clean_key.removeprefix(clean_prefix)
# Clone parameters before exiting the `_unshard_fsdp_state_params()` context.
if not getattr(state_dict[fqn], "_has_been_cloned", False):
try:
state_dict[fqn] = state_dict[fqn].detach().clone()
state_dict[fqn]._has_been_cloned = True # type: ignore[attr-defined]
except BaseException as e: # noqa: B036
warnings.warn(
f"Failed to clone() tensor with name {fqn} on rank {fsdp_state.rank}. "
"This may mean that this state_dict entry could point to invalid "
"memory regions after returning from state_dict() call if this "
"parameter is managed by FSDP. Please check clone "
f"implementation of {fqn}. Error: {str(e)}",
stacklevel=2,
)
return _common_unshard_post_state_dict_hook(
module, fsdp_state, state_dict, prefix, param_hook
)
def _full_pre_load_state_dict_hook(
module: nn.Module,
fsdp_state: _FSDPState,
state_dict: dict[str, Any],
prefix: str,
) -> None:
_lazy_init(fsdp_state, module)
if _should_unshard_params(fsdp_state):
with SimpleProfiler.profile("_enter_unshard_params_ctx"):
_enter_unshard_params_ctx(module, fsdp_state, writeback=True)
# Add FSDP_PREFIX only for wrapper-based FSDP.
if not _is_composable(fsdp_state):
_replace_by_prefix(state_dict, prefix, prefix + f"{FSDP_PREFIX}")
def _full_post_load_state_dict_hook(
module: nn.Module, fsdp_state: _FSDPState, *args, **kwargs
) -> None:
if _should_unshard_params(fsdp_state):
with SimpleProfiler.profile("_exit_unshard_params_ctx"):
_exit_unshard_params_ctx(module, fsdp_state)
def _local_pre_state_dict_hook(
fsdp_state: _FSDPState,
module: nn.Module,
*args,
**kwargs,
) -> None:
"""
Hook that runs before model.state_dict() is called. Right now, pre-state_dict
hook is not supported by the PyTorch core. So this API is called from
`_local_post_state_dict_hook()` to simulate the case.
"""
if (
_has_fsdp_params(fsdp_state, module)
and not _module_handle(fsdp_state, module).uses_sharded_strategy
):
raise RuntimeError(
"``local_state_dict`` can only be used when parameters are flatten "
"and sharded."
)
_common_pre_state_dict_hook(module, fsdp_state)
@no_type_check
def _local_post_state_dict_hook(
module: nn.Module,
fsdp_state: _FSDPState,
state_dict: dict[str, Any],
prefix: str,
) -> dict[str, Any]:
"""
This hook create a ShardedTensor from the local flat_param and replace
the state_dict[f"{prefix}{FLAT_PARAM}] with the ShardedTensor. No copy
will happen. The underlying storage is the same.
"""
_replace_by_prefix(state_dict, f"{prefix}{FSDP_PREFIX}", prefix)
if not _has_fsdp_params(fsdp_state, module):
return state_dict
# state_dict[f"{prefix}{FLAT_PARAM}"] exists and has the same tensor
# value as the flat_param but it is a pure Tensor because
# nn.Module.state_dict() will detach the parameter. Therefore, we need
# to get flat_param to get the metadata.
if not _module_handle(fsdp_state, module):
raise AssertionError("Should have returned early")
flat_param = _module_handle(fsdp_state, module).flat_param
# Constructs a ShardedTensor from the flat_param "without" padding.
# Removing the padding allows users to change the number of ranks
# when loading the local_state_dict.
full_numel = flat_param._unpadded_unsharded_size.numel() # type: ignore[attr-defined]
shard_offset = flat_param.numel() * fsdp_state.rank
valid_data_size = flat_param.numel() - flat_param._shard_numel_padded
if valid_data_size > 0:
# If FlatParameter is returned, FlatParameter._local_shard cause a
# pickling issue (can be torch.save but not torch.load). Since there
# is no benefit for state_dict to return the actual FlatParameter class,
# a view (which is a tensor) of the FlatParameter will be returned.
flat_param = flat_param[:valid_data_size].view(valid_data_size)
local_shards = [
Shard.from_tensor_and_offsets(flat_param, [shard_offset], fsdp_state.rank)
]
else:
local_shards = []
sharded_tensor = init_from_local_shards(
local_shards, full_numel, process_group=fsdp_state.process_group
) # type: ignore[assignment]
# TODO: Add DTensor state_dict support for LOCAL_STATE_DICT.
if fsdp_state._state_dict_config.offload_to_cpu:
sharded_tensor = sharded_tensor.cpu()
state_dict[f"{prefix}{FLAT_PARAM}"] = sharded_tensor
return state_dict
def _local_post_load_state_dict_hook(
module: nn.Module, fsdp_state: _FSDPState, *args, **kwargs
) -> None:
pass
def _local_pre_load_state_dict_hook(
module: nn.Module,
fsdp_state: _FSDPState,
state_dict: dict[str, Any],
prefix: str,
) -> None:
"""
This hook finds the local flat_param for this FSDP module from the
state_dict. The flat_param should be a ShardedTensor. This hook converts
the ShardedTensor to a tensor. No copy happen unless padding is required.
"""
_lazy_init(fsdp_state, module)
_replace_by_prefix(state_dict, prefix, f"{prefix}{FSDP_PREFIX}")
fqn = f"{prefix}{FSDP_PREFIX}{FLAT_PARAM}"
if fqn not in state_dict:
if _has_fsdp_params(fsdp_state, module):
raise AssertionError(
"No `FlatParameter` in `state_dict` for this FSDP instance "
"but it has parameters"
)
return
load_tensor = state_dict[fqn]
if not isinstance(load_tensor, ShardedTensor):
raise AssertionError("Tensors in local_state_dict should be ShardedTensor.")
# Convert the ShardedTensor to a Tensor.
flat_param = _module_handle(fsdp_state, module).flat_param
if flat_param is None:
raise AssertionError("Expected flat_param to be set")
valid_data_size = flat_param.numel() - flat_param._shard_numel_padded
shards = load_tensor.local_shards()
if valid_data_size > 0:
if not len(shards):
raise AssertionError(
"load_local_state_dict assume one shard per ShardedTensor."
)
load_tensor = shards[0].tensor
# Get the metadata of the flat_param to decide whether to pad the loaded
# tensor.
if flat_param._shard_numel_padded > 0:
if load_tensor.numel() >= flat_param.numel():
raise AssertionError(
f"Local shard size = {flat_param.numel()} and the tensor in "
f"the state_dict is {load_tensor.numel()}."
)
load_tensor = F.pad(load_tensor, [0, flat_param._shard_numel_padded])
else:
load_tensor = flat_param
# TODO: Add DTensor state_dict support for LOCAL_STATE_DICT.
state_dict[fqn] = load_tensor
def _sharded_pre_state_dict_hook(
fsdp_state: _FSDPState,
module: nn.Module,
*args,
**kwargs,
) -> None:
"""
Hook that runs before model.state_dict() is called. Check
``_full_pre_load_state_dict_hook`` for the detail.
"""
if (
_has_fsdp_params(fsdp_state, module)
and not _module_handle(fsdp_state, module).uses_sharded_strategy
):
raise RuntimeError(
"``sharded_state_dict`` can only be used when parameters are flatten "
"and sharded."
)
_common_pre_state_dict_hook(module, fsdp_state)
# Setting offload_to_cpu here does not work even if offload_to_cpu is True.
# We have to create ShardedTensor first then move it to CPU.
_common_unshard_pre_state_dict_hook(
module,
fsdp_state,
offload_to_cpu=False,
rank0_only=False,
)
@no_type_check
def _sharded_post_state_dict_hook(
module: nn.Module,
fsdp_state: _FSDPState,
state_dict: dict[str, Any],
prefix: str,
) -> dict[str, Any]:
"""
The hook replaces the unflattened, unsharded parameter in the state_dict
with a unflattened, sharded parameter (a ShardedTensor).
"""
def param_hook(state_dict: dict[str, Any], prefix: str, fqn: str):
param = state_dict[fqn]
if not fsdp_state._state_dict_config._use_dtensor:
sharded_tensor = _ext_chunk_tensor(
tensor=param,
rank=fsdp_state.rank,
world_size=fsdp_state.world_size,
num_devices_per_node=fsdp_state._device_handle.device_count(),
pg=fsdp_state.process_group,
fsdp_extension=fsdp_state._fsdp_extension,
)
else:
sharded_tensor = _ext_chunk_dtensor(
tensor=param,
rank=fsdp_state.rank,
device_mesh=fsdp_state._device_mesh,
fsdp_extension=fsdp_state._fsdp_extension,
)
if fsdp_state._state_dict_config.offload_to_cpu:
sharded_tensor = sharded_tensor.cpu()
state_dict[fqn] = sharded_tensor
return _common_unshard_post_state_dict_hook(
module, fsdp_state, state_dict, prefix, param_hook
)
@no_type_check
def _sharded_post_load_state_dict_hook(
module: nn.Module, fsdp_state: _FSDPState, *args, **kwargs
) -> None:
if _has_fsdp_params(fsdp_state, module):
with SimpleProfiler.profile("_exit_unshard_params_ctx"):
_exit_unshard_params_ctx(module, fsdp_state)
@no_type_check
def _sharded_pre_load_state_dict_hook(
module: nn.Module,
fsdp_state: _FSDPState,
state_dict: dict[str, Any],
prefix: str,
) -> None:
"""
The hook combines the unflattened, sharded parameters (ShardedTensor) to
a new FlatParameter and shards the new FlatParameter to the local chunk.
"""
_lazy_init(fsdp_state, module)
if not _is_composable(fsdp_state):
_replace_by_prefix(state_dict, prefix, prefix + f"{FSDP_PREFIX}")
if not _has_fsdp_params(fsdp_state, module):
return
handle = _module_handle(fsdp_state, module)
if not handle.uses_sharded_strategy:
raise RuntimeError(
"load_sharded_state_dict can only be called when parameters "
"are flattened and sharded."
)
fqn_to_param_ext = dict(
zip(handle.flat_param._fqns, handle.flat_param._param_extensions)
)
for fqn, _, _ in _param_name_infos(module, fsdp_state):
if not _is_composable(fsdp_state):
fqn_from_global_root = f"{prefix}{FSDP_PREFIX}{fqn}"
else:
fqn_from_global_root = f"{prefix}{fqn}"
try:
param = state_dict.pop(fqn_from_global_root)
except KeyError:
logger.warning(
f"Did not find param with FQN {fqn_from_global_root}, skipping it. " # noqa: G004
"The weight will not be filled if you expect it to be."
)
continue # TODO: Improve unittesting for state_dict finetuning
# cases: https://github.com/pytorch/pytorch/issues/109134
if not fsdp_state._state_dict_config._use_dtensor:
# All-gather the param (ShardedTensor)
param, shards = _ext_pre_load_state_dict_transform(
param, fsdp_state._fsdp_extension
)
if len(shards) >= 2:
raise AssertionError(
"Expects 0 or 1 shard per rank "
f"but got {len(shards)} shards on rank {fsdp_state.rank}."
)
param_numel = param.size().numel()
dim_0_size = param.size()[0]
chunk_size = (
math.ceil(dim_0_size / fsdp_state.world_size)
* param_numel
// dim_0_size
)
if len(shards) == 1:
local_tensor = shards[0].tensor.flatten()
with SimpleProfiler.profile(SimpleProfiler.Type.H2D):
local_tensor = local_tensor.to(fsdp_state.compute_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=param.dtype, device=fsdp_state.compute_device
)
tensor = torch.empty(
chunk_size * fsdp_state.world_size,
dtype=local_tensor.dtype,
device=fsdp_state.compute_device,
)
with SimpleProfiler.profile(SimpleProfiler.Type.ALLGATHER):
dist.all_gather_into_tensor(
tensor, local_tensor, group=fsdp_state.process_group
)
tensor = tensor.narrow(0, 0, param_numel).reshape(param.size())
state_dict[fqn_from_global_root] = tensor
else:
if param.device != fsdp_state._device_mesh.device_type:
param = param.to(fsdp_state._device_mesh.device_type)
root_mesh = fsdp_state._device_mesh._get_root_mesh()
local_tensor = _ext_all_gather_dtensor(
param, root_mesh, fsdp_state._fsdp_extension
)
if fqn_to_param_ext.get(fqn) is not None:
ext = fqn_to_param_ext[fqn]
local_tensor = _ext_post_unflatten_transform(
local_tensor, ext, fsdp_state._fsdp_extension
)
state_dict[fqn_from_global_root] = local_tensor
with SimpleProfiler.profile("_enter_unshard_params_ctx"):
_enter_unshard_params_ctx(module, fsdp_state, writeback=True)
@contextlib.contextmanager
def _replace_with_full_state_dict_type(fsdp_state: _FSDPState) -> Generator:
old_state_dict_config = fsdp_state._state_dict_config
old_state_dict_type = fsdp_state._state_dict_type
fsdp_state._state_dict_config = FullStateDictConfig()
fsdp_state._state_dict_type = StateDictType.FULL_STATE_DICT
yield
fsdp_state._state_dict_config = old_state_dict_config
fsdp_state._state_dict_type = old_state_dict_type
@no_type_check
@torch.no_grad()
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. ``fsdp_state._state_dict_type`` is used to decide
what postprocessing will be done.
"""
fsdp_state = _get_module_fsdp_state_if_fully_sharded_module(module)
if fsdp_state.sharding_strategy == ShardingStrategy.NO_SHARD:
context = _replace_with_full_state_dict_type(fsdp_state)
warnings.warn(
"When using ``NO_SHARD`` for ``ShardingStrategy``, full_state_dict will "
"be returned.",
stacklevel=2,
)
else:
context = contextlib.nullcontext()
with context:
_post_state_dict_hook_fn = {
StateDictType.FULL_STATE_DICT: _full_post_state_dict_hook,
StateDictType.LOCAL_STATE_DICT: _local_post_state_dict_hook,
StateDictType.SHARDED_STATE_DICT: _sharded_post_state_dict_hook,
}
processed_state_dict = _post_state_dict_hook_fn[fsdp_state._state_dict_type](
module, fsdp_state, state_dict, prefix
)
if fsdp_state._is_root:
logger.info("FSDP finished processing state_dict(), prefix=%s", prefix)
for key, tensor in sorted(processed_state_dict.items()):
if key.startswith(prefix) and isinstance(tensor, torch.Tensor):
local_shape = tensor.shape
device = None
if isinstance(tensor, ShardedTensor):
local_shape = None
shards = tensor.local_shards()
if shards:
local_shape = shards[0].tensor.shape
device = shards[0].tensor.device
elif isinstance(tensor, DTensor):
local_shape = tensor.to_local().shape
device = tensor.device
else:
device = tensor.device
logger.info(
"FQN=%s: type=%s, shape=%s, local_shape=%s, dtype=%s, device=%s",
key,
type(tensor),
tensor.shape,
local_shape,
tensor.dtype,
device,
)
return processed_state_dict
@no_type_check
@torch.no_grad()
def _pre_state_dict_hook(
module: nn.Module,
*args,
**kwargs,
) -> None:
"""
This is called before the core state dict saving logic of ``module``.
``fsdp_state._state_dict_type`` is used to decide what postprocessing will
be done.
"""
fsdp_state = _get_module_fsdp_state_if_fully_sharded_module(module)
if fsdp_state.sharding_strategy == ShardingStrategy.NO_SHARD:
context = _replace_with_full_state_dict_type(fsdp_state)
warnings.warn(
"When using ``NO_SHARD`` for ``ShardingStrategy``, full_state_dict will "
"be returned.",
stacklevel=2,
)
else:
_set_use_dtensor(fsdp_state)
context = contextlib.nullcontext()
with context:
_pre_state_dict_hook_fn = {
StateDictType.FULL_STATE_DICT: _full_pre_state_dict_hook,
StateDictType.LOCAL_STATE_DICT: _local_pre_state_dict_hook,
StateDictType.SHARDED_STATE_DICT: _sharded_pre_state_dict_hook,
}
_pre_state_dict_hook_fn[fsdp_state._state_dict_type](
fsdp_state,
module,
*args,
**kwargs,
)
@no_type_check
def _set_use_dtensor(fsdp_state: _FSDPState) -> None:
# If device_mesh is passed in when initializing FSDP, we automatically turn the
# _use_dtensor flag to be true for ShardedStateDictConfig().
if getattr(fsdp_state, "_device_mesh", None):
state_dict_type = fsdp_state._state_dict_type
if state_dict_type == StateDictType.LOCAL_STATE_DICT:
raise RuntimeError(
"Found state_dict_type LOCAL_STATE_DICT. "
"DeviceMesh is not compatible with LOCAL_STATE_DICT. "
"Please set state_dict_type to SHARDED_STATE_DICT to get DTensor state_dict."
)
else:
fsdp_state._state_dict_config._use_dtensor = True
@no_type_check
@torch.no_grad()
def _pre_load_state_dict_hook(
module: nn.Module,
state_dict: dict[str, Any],
prefix: str,
*args: Any,
) -> None:
"""
This is called before ``module._load_from_state_dict()``.
``fsdp_state._state_dict_type`` is used to decide what preprocessing will
be done.
"""
fsdp_state = _get_module_fsdp_state_if_fully_sharded_module(module)
if fsdp_state.sharding_strategy == ShardingStrategy.NO_SHARD:
context = _replace_with_full_state_dict_type(fsdp_state)
warnings.warn(
"When using ``NO_SHARD`` for ``ShardingStrategy``, full_state_dict will"
"be returned.",
stacklevel=2,
)
else:
_set_use_dtensor(fsdp_state)
context = contextlib.nullcontext()
_lazy_init(fsdp_state, module)
if fsdp_state._is_root:
SimpleProfiler.reset()
with context:
_pre_load_state_dict_hook_fn = {
StateDictType.FULL_STATE_DICT: _full_pre_load_state_dict_hook,
StateDictType.LOCAL_STATE_DICT: _local_pre_load_state_dict_hook,
StateDictType.SHARDED_STATE_DICT: _sharded_pre_load_state_dict_hook,
}
# Code that is common for all state_dict impls
if fsdp_state._device_handle.is_available():
fsdp_state._device_handle.synchronize()
# Dispatch into state_dict specific implementation of pre-hook.
_pre_load_state_dict_hook_fn[fsdp_state._state_dict_type](
module, fsdp_state, state_dict, prefix
)
@no_type_check
@torch.no_grad()
def _post_load_state_dict_hook(
module: nn.Module,
incompatible_keys: tuple[list[str], list[str]],
*args: Any,
) -> None:
fsdp_state = _get_module_fsdp_state_if_fully_sharded_module(module)
if fsdp_state.sharding_strategy == ShardingStrategy.NO_SHARD:
context = _replace_with_full_state_dict_type(fsdp_state)
warnings.warn(
"When using ``NO_SHARD`` for ``ShardingStrategy``, full_state_dict will"
"be returned.",
stacklevel=2,
)
else:
context = contextlib.nullcontext()
with context:
_post_load_state_dict_hook_fn = {
StateDictType.FULL_STATE_DICT: _full_post_load_state_dict_hook,
StateDictType.LOCAL_STATE_DICT: _local_post_load_state_dict_hook,
StateDictType.SHARDED_STATE_DICT: _sharded_post_load_state_dict_hook,
}
# Code that is common for all state_dict impls
# Dispatch into state_dict type specific implementation of post-hook for
# loading state_dict.
_post_load_state_dict_hook_fn[fsdp_state._state_dict_type](module, fsdp_state)
# When reporting incompatible keys, trim FSDP prefixes.
missing_keys = incompatible_keys[0]
unexpected_keys = incompatible_keys[1]
for i in range(len(missing_keys)):
missing_keys[i] = clean_tensor_name(missing_keys[i])
for i in range(len(unexpected_keys)):
unexpected_keys[i] = clean_tensor_name(unexpected_keys[i])
if fsdp_state._is_root:
SimpleProfiler.dump_and_reset("FSDP model load_state_dict profiling: ")
def _register_all_state_dict_hooks(state: _FSDPState):
"""
Registers pre-save, post-save, pre-load, and post-load state dict hooks.
"""
for hook_registration_fn_str, hook, hook_registration_fn_kwargs in (
("register_state_dict_pre_hook", _pre_state_dict_hook, {}),
("_register_state_dict_hook", _post_state_dict_hook, {}),
(
"_register_load_state_dict_pre_hook",
_pre_load_state_dict_hook,
{"with_module": True},
),
("register_load_state_dict_post_hook", _post_load_state_dict_hook, {}),
):
_register_state_dict_hooks_base(
state, hook_registration_fn_str, hook, hook_registration_fn_kwargs
)
@no_type_check
def _register_state_dict_hooks_base(
state: _FSDPState,
hook_registration_fn_name: str,
hook: Callable,
hook_registration_fn_kwargs: dict[str, Any],
) -> None:
"""Registers ``hook`` using ``hook_registration_fn``."""
if not _is_composable(state):
getattr(state, hook_registration_fn_name)(hook, **hook_registration_fn_kwargs)
else:
handle = state._handle
if handle:
getattr(handle._fully_sharded_module, hook_registration_fn_name)(
hook, **hook_registration_fn_kwargs
)
@@ -0,0 +1,240 @@
# mypy: allow-untyped-defs
import functools
from collections.abc import Callable
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any, NamedTuple
import torch
import torch.nn as nn
@dataclass
class TracingConfig:
"""
This represents a symbolic tracing configuration.
Args:
tracer (torch.fx.Tracer): An instance of :class:`torch.fx.Tracer` to
use for symbolic tracing. The default value is the native
:class:`torch.fx.Tracer` constructed with default arguments.
However, the user may want to pass a different value such as the
``HFTracer`` for models in the HuggingFace Transformers_ library.
.. _Transformers: https://huggingface.co/docs/transformers/index
concrete_args (Optional[Dict[str, Any]]): Concrete arguments that
should not be treated as ``torch.fx.Proxy`` when tracing the
module ``forward()``. Passing ``concrete_args`` allows partially
specializing the forward, e.g. to remove control flow or data
structures. This ``concrete_args`` here is the same argument used
in :meth:`~torch.fx.Tracer.trace`.
"""
tracer: torch.fx.Tracer = field(default_factory=torch.fx.Tracer)
concrete_args: dict[str, Any] | None = None
class _ParamUsageInfo(NamedTuple):
"""
This is used for ``_ExecutionInfo.module_to_param_usage_infos`` to record
execution information. The ``dict`` maps modules to a list of these
``_ParamUsageInfo`` instances, where each instance represents a group of
parameters used together.
Specifically, for each module key in the ``dict``, each instance of this
class represents either:
(1) the module and some sublist of its ``named_parameters()`` used
together in execution (see ``_patched_create_proxy()``), or
(2) a submodule and all of ``submodule.named_parameters()`` (see
``_patched_call_module()``).
Type (1) corresponds to directly using parameters in ops without calling
``forward()``, and type (2) corresponds to calling ``forward()``. The
mapped-to lists in the ``dict`` follow the execution order.
"""
module: nn.Module
named_params: list[tuple[str, nn.Parameter]]
class _ExecutionInfo:
"""
This represents the execution order information from the forward pass.
Attributes:
curr_module (nn.Module): Current module being traced.
module_forward_order (List[nn.Module]): The modules in (pre-)forward
order, i.e. the order in which their ``forward()`` methods are
called. Each call to a module's ``forward()`` corresponds to one
element in the list.
module_to_param_usage_infos (Dict[nn.Module, List[_ParamUsageInfo]]):
Maps a module to a list of module execution infos. See
:class:`_ParamUsageInfo` for details.
param_forward_order (List[nn.Parameter]): The parameters in forward
execution order, where only a parameter's first participation is
included.
visited_params (Set[nn.Parameter]): The parameters visited so far
during the trace. This is only used during tracing for fast
membership check. Invariant: The parameters in
``param_forward_order`` are exactly those in ``visited_params``.
"""
def __init__(self, root_module: nn.Module) -> None:
self.curr_module: nn.Module = root_module
self.module_forward_order: list[nn.Module] = [root_module]
self.module_to_param_usage_infos: dict[nn.Module, list[_ParamUsageInfo]] = {
root_module: []
}
self.param_forward_order: list[nn.Parameter] = []
self.visited_params: set[nn.Parameter] = set()
class _ExecOrderTracer:
def __init__(self) -> None:
self.exec_info: _ExecutionInfo | None = None
@contextmanager
def patch_tracer(self, tracer: torch.fx.Tracer, root_module: nn.Module):
self.exec_info = _ExecutionInfo(root_module)
orig_call_module = tracer.call_module
orig_create_proxy = tracer.create_proxy
tracer.call_module = functools.partial( # type: ignore[method-assign]
self._patched_call_module, orig_call_module, self.exec_info
)
fqn_to_param = dict(root_module.named_parameters())
tracer.create_proxy = functools.partial( # type: ignore[method-assign]
self._patched_create_proxy,
orig_create_proxy,
self.exec_info,
fqn_to_param,
)
try:
yield
finally:
tracer.call_module = orig_call_module # type: ignore[method-assign]
tracer.create_proxy = orig_create_proxy # type: ignore[method-assign]
def _patched_call_module(
self,
call_module: Callable,
exec_info: _ExecutionInfo,
# Below are the expected arguments to `call_module()`
module: nn.Module,
forward: Callable,
args: tuple[Any, ...],
kwargs: dict[str, Any],
) -> Any:
"""
Overrides ``call_module`` to save execution information to
``exec_info``. Note that ``call_module`` is called during symbolic
tracing for each non-root module.
Args:
call_module (Callable): Original ``call_module`` to override.
exec_info (_ExecutionInfo): Used to record execution information.
module (nn.Module): Module corresponding to this ``call_module``.
forward (Callable): ``forward()`` method of ``module`` to be called
for this ``call_module``.
args (Tuple[Any, ...]): Positional arguments for ``forward``.
kwargs (Dict[str, Any]): Keyword arguments for ``forward``.
Returns:
Same return value as ``call_module``.
"""
exec_info.module_forward_order.append(module)
named_params = list(module.named_parameters())
curr_module = exec_info.curr_module
if named_params:
if curr_module not in exec_info.module_to_param_usage_infos:
raise AssertionError(
"The current module should have already been processed by a patched `call_module`"
)
exec_info.module_to_param_usage_infos[exec_info.curr_module].append(
_ParamUsageInfo(module, named_params)
)
prev_curr_module = curr_module
exec_info.curr_module = module
exec_info.module_to_param_usage_infos[module] = []
output = call_module(module, forward, args, kwargs)
exec_info.curr_module = prev_curr_module
return output
def _patched_create_proxy(
self,
create_proxy: Callable,
exec_info: _ExecutionInfo,
fqn_to_param: dict[str, nn.Parameter],
# Below are the expected arguments to `create_proxy()`
kind: str,
target: torch.fx.node.Target,
args: tuple[Any, ...],
kwargs: dict[str, Any],
name: str | None = None,
type_expr: Any | None = None,
proxy_factory_fn: Callable[[torch.fx.Node], torch.fx.Proxy] | None = None,
) -> torch.fx.Proxy:
"""
Overrides ``create_proxy`` to save execution information to
``exec_info``. Note that ``create_proxy`` is called during symbolic
tracing for each leaf function/method/module.
Args:
create_proxy (Callable): Original ``create_proxy`` to override.
exec_info (_ExecutionInfo): Used to record execution information.
fqn_to_param (Dict[str, nn.Parameter]): ``dict`` version of the
root module's ``named_parameters()`` with FQN as key and
parameter as value.
kind (str): Kind of the target method ('call_function',
'call_method', 'get_attr', 'call_module', 'placeholder', or
'output'). See :class:`torch.fx.Graph` for details. This is
passed to ``create_proxy``.
target (torch.fx.node.Target): Contains the string name of the
function/method/module. This is passed to ``create_proxy``.
args (Tuple[Any, ...]): Positional arguments for the function/
method/module. This is passed to ``create_proxy``.
kwargs (Dict[str, Any]): Keyword arguments for the function/method/
module. This is passed to ``create_proxy``
name (Optional[str]): An optional string name for the ``Node``
created in ``create_proxy``. This is passed to
``create_proxy``.
type_expr (Optional[Any]): An optional type annotation representing
the Python type that the output of the node has. This is passed
to ``create_proxy``.
proxy_factory_fn (Callable[[torch.fx.Node], torch.fx.Proxy]):
An alternative proxy constructor used in ``create_proxy``. This
is passed to ``create_proxy``.
Returns:
torch.fx.Proxy: Created ``Node`` wrapped in a ``Proxy`` object.
"""
proxy = create_proxy(
kind, target, args, kwargs, name, type_expr, proxy_factory_fn
)
curr_module = exec_info.curr_module
if kind in ("call_function", "call_method"):
if args is not None:
named_params: list[tuple[str, nn.Parameter]] = []
for arg in args:
if (
isinstance(arg, torch.fx.Proxy)
and arg.node.target in fqn_to_param
):
param = fqn_to_param[arg.node.target] # type: ignore[index]
named_params.append((arg.node.target, param)) # type: ignore[arg-type]
if param not in exec_info.visited_params:
exec_info.visited_params.add(param)
exec_info.param_forward_order.append(param)
if named_params:
exec_info.module_to_param_usage_infos[curr_module].append(
_ParamUsageInfo(curr_module, named_params)
)
elif kind == "call_module":
named_params = list(curr_module.named_parameters())
if named_params:
exec_info.module_to_param_usage_infos[curr_module].append(
_ParamUsageInfo(curr_module, named_params)
)
for _, param in named_params:
if param not in exec_info.visited_params:
exec_info.visited_params.add(param)
exec_info.param_forward_order.append(param)
return proxy
@@ -0,0 +1,112 @@
"""
NOTE: This file must be imported like
``import torch.distributed.fsdp._traversal_utils`` and not like
``from torch.distributed.fsdp._traversal_utils import ...`` to avoid circular
imports. For brevity, we may import the file as ``traversal_utils``.
"""
import collections
import torch.nn as nn
from torch.distributed._composable.contract import _get_registry
from torch.distributed.fsdp._common_utils import _FSDPState, _get_module_fsdp_state
"""
[Note: FSDP State Traversal]
For the wrapper code path, ``_FSDPState`` is the ``FullyShardedDataParallel``
module wrapping a fully sharded module, and for the non-wrapper code path,
``_FSDPState`` is an object that gets embedded on a fully sharded module.
See [Note: Fully Sharded Module] for the definition.
There are three common traversal idioms: Given a root module,
- ``_get_fsdp_states()`` returns all ``_FSDPState`` s in the tree.
- ``get_fsdp_root_states()`` returns all local root ``_FSDPState`` s in the
tree (i.e. those with ``_is_root == True``).
- ``_get_fsdp_handles()``returns all ``FlatParamHandle`` s in the tree.
All of these methods must take in the root module (i.e. an ``nn.Module``) and
not a general ``_FSDPState`` because ``_FSDPState`` does not support a graph
traversal, whereas ``nn.Module`` has ``nn.Module.modules()`` for traversal.
"""
def _composable(module: nn.Module) -> bool:
"""
Returns if ``module`` can compose with ``fully_shard``.
"""
# TODO: Add any other composable APIs that are mutually exclusive.
registry = _get_registry(module)
if registry is None:
return True
return "replicate" not in registry
# TODO (awgu): We may be able to remove this function if we retired the
# `use_orig_params=False` code path since so far we only need the module for
# `FlatParameter` registration, which is not needed for `use_orig_params=True`.
def _get_fsdp_states_with_modules(
module: nn.Module,
) -> tuple[list[_FSDPState], list[nn.Module]]:
"""
Returns a tuple containing:
1. A list of the ``_FSDPState`` instances in the module tree rooted at
``module`` without any duplicates and following the ``module.modules()``
traversal order (which is assumed to be depth-first).
2. A corresponding list of the modules owning the states in the first list.
For the wrapper code path, both returned lists are the same, each
containing all ``FullyShardedDataParallel`` instances. For the composable
code path, this returns a list of all composable state instances and a list
of the corresponding fully sharded modules. See [Note: Fully Sharded
Module].
NOTE: The traversal does not proceed into any module annotated by an
incompatible API (e.g. ``replicate``).
"""
fsdp_states: list[_FSDPState] = []
fsdp_modules: list[nn.Module] = []
# Track the visited FSDP states since multiple modules may share the same
# one and we want to return a de-duplicated list
visited_fsdp_states: set[_FSDPState] = set()
# Track the visited modules in case of shared modules, which implies the
# module graph is no longer a tree
visited_modules: set[nn.Module] = set()
# Perform depth-first search from `module` to ensure that we do not
# traverse into an incompatible API's subtree (use DFS instead of BFS to
# match `.modules()` order)
deque: collections.deque[nn.Module] = collections.deque([module])
while deque:
submodule = deque.popleft()
visited_modules.add(submodule)
if not _composable(submodule):
continue
for child_module in reversed(list(submodule.children())):
if child_module not in visited_modules:
deque.appendleft(child_module)
optional_state = _get_module_fsdp_state(submodule)
if optional_state is not None and optional_state not in visited_fsdp_states:
visited_fsdp_states.add(optional_state)
fsdp_states.append(optional_state)
fsdp_modules.append(submodule)
return fsdp_states, fsdp_modules
def _get_fsdp_states(module: nn.Module) -> list[_FSDPState]:
"""See :func:`_get_fsdp_states_with_modules`."""
fsdp_states, _ = _get_fsdp_states_with_modules(module)
return fsdp_states
def _get_fsdp_handles(module: nn.Module) -> list:
"""
Returns all ``FlatParamHandle`` s in the module tree rooted at ``module``
following the rules in :func:`_get_fsdp_state`.
"""
handles = [
fsdp_state._handle
for fsdp_state in _get_fsdp_states(module)
if fsdp_state._handle is not None
]
return handles
@@ -0,0 +1,340 @@
# mypy: allow-untyped-defs
import contextlib
import warnings
from collections.abc import Generator
from typing import cast
import torch
import torch.distributed.fsdp._traversal_utils as traversal_utils
import torch.nn as nn
from torch.distributed.fsdp._common_utils import (
_FSDPState,
_get_module_fsdp_state,
_has_fsdp_params,
_module_handle,
HandleTrainingState,
TrainingState,
)
from torch.distributed.fsdp._runtime_utils import (
_lazy_init,
_reset_flat_param_grad_info_if_needed,
_reshard,
_reshard_grads,
_unshard,
_unshard_grads,
)
from torch.distributed.utils import _p_assert
from ._flat_param import FlatParamHandle
FLAT_PARAM = "_flat_param"
@torch.no_grad()
def _writeback_to_local_shard(
handle: FlatParamHandle,
writeback_grad: bool,
):
"""
For the handle, writes back the this rank's shard of the unsharded
flattened parameter to the sharded flattened parameter. If
``writeback_grad=True``, then writes back to the sharded gradient as
well.
Precondition: The handle's ``FlatParameter`` 's data points to the
padded unsharded flattened parameter.
"""
def _get_shard(flat_param_or_grad: torch.Tensor) -> torch.Tensor:
if handle.uses_sharded_strategy:
# For sharded strategies, get the *unpadded* shard instead of
# the *padded* shard to persist user changes to the padding
# (though FSDP does not explicitly support this)
shard, _ = FlatParamHandle._get_unpadded_shard(
flat_param_or_grad,
handle.rank,
handle.world_size,
)
return shard
# For `NO_SHARD`, the `flat_param` or its gradient may be modified,
# so we write it back directly
return flat_param_or_grad
param_shard = _get_shard(handle.flat_param)
handle.flat_param._local_shard[: param_shard.numel()].copy_(param_shard) # type: ignore[attr-defined]
if writeback_grad:
existing_grad = handle.sharded_grad
if existing_grad is not None:
if handle.flat_param.grad is None:
raise AssertionError("Expected handle.flat_param.grad to not be None")
grad_shard = _get_shard(handle.flat_param.grad)
existing_grad[: grad_shard.numel()].copy_(grad_shard)
def _deregister_flat_param(state: _FSDPState, module: nn.Module) -> None:
"""
De-registers the flattened parameter from the wrapped module, hiding it
from ``nn.Module`` methods.
We do not use ``del`` because we want ``FLAT_PARAM`` to always be an
attribute but dynamically change whether it is visible to ``nn.Module``
methods.
"""
if _has_fsdp_params(state, module):
# TODO: figure out the case for the composable APIs.
cast(nn.Module, module.module)._parameters.pop(FLAT_PARAM, None)
def _register_flat_param(state: _FSDPState, module: nn.Module) -> None:
"""
Registers the flattened parameter to the wrapped module, making it
visible to ``nn.Module`` methods.
We do not use :meth:`nn.Module.register_parameter` because we want
``FLAT_PARAM`` to always be an attribute but dynamically change whether
it is visible to ``nn.Module`` methods.
"""
handle = _module_handle(state, module)
if _has_fsdp_params(state, module):
# TODO: figure out the case for the composable APIs.
cast(nn.Module, module.module)._parameters[FLAT_PARAM] = handle.flat_param
@contextlib.contextmanager
def _unflatten_as_params(state: _FSDPState, module: nn.Module) -> Generator:
"""
Assumes that the flattened parameter is unsharded. When in the context,
de-registers the flattened parameter and unflattens the original
parameters as ``nn.Parameter`` views into the flattened parameter.
After the context, re-registers the flattened parameter and restores
the original parameters as ``Tensor`` views into the flattened
parameter.
"""
handle = _module_handle(state, module)
if not handle:
yield
else:
_deregister_flat_param(state, module)
try:
with handle.unflatten_as_params():
yield
finally:
if not handle._use_orig_params:
_register_flat_param(state, module)
def _validate_unshard_params_args(
state: _FSDPState,
writeback: bool,
rank0_only: bool,
offload_to_cpu: bool,
with_grads: bool,
) -> None:
if with_grads and (offload_to_cpu or not state._use_orig_params):
raise NotImplementedError(
f"with_grads={with_grads}, "
f"use_orig_params={state._use_orig_params}, "
f"offload_to_cpu={offload_to_cpu} "
f"is not supported yet"
)
if offload_to_cpu and state._handle and (not state._handle.uses_sharded_strategy):
raise NotImplementedError(
"offload_to_cpu=True and NO_SHARD is not supported yet"
)
if writeback and rank0_only:
# TODO: Rank 0 can broadcast the `FlatParameter` to allow all ranks to
# persist the changes.
raise NotImplementedError(
"writeback=True and rank0_only=True is not supported yet"
)
if offload_to_cpu and not rank0_only:
warnings.warn(
"offload_to_cpu=True and rank0_only=False may result in the"
"unsharded parameters being redundantly copied to CPU memory for "
"GPUs sharing the same CPU memory, which risks CPU OOM. We "
"recommend using offload_to_cpu=True with rank0_only=True.",
stacklevel=2,
)
@contextlib.contextmanager
def _unshard_fsdp_state_params(
module: nn.Module,
state: _FSDPState,
writeback: bool,
rank0_only: bool,
offload_to_cpu: bool,
with_grads: bool,
):
"""
This unshards the parameters for a single FSDP state ``state`` that
corresponds to ``module``.
"""
_validate_unshard_params_args(
state, writeback, rank0_only, offload_to_cpu, with_grads
)
state._device_handle.synchronize()
# If handles are shared by other module(s), the handle may be already unsharded.
maybe_handle = _module_handle(state, module)
handle = None
if (
maybe_handle
and maybe_handle._training_state != HandleTrainingState.SUMMON_FULL_PARAMS
):
handle = maybe_handle
if not handle:
yield
return
if handle._training_state != HandleTrainingState.IDLE:
raise AssertionError(
f"Expects the handle training to be IDLE but got {handle._training_state}"
)
handle._training_state = HandleTrainingState.SUMMON_FULL_PARAMS
_reset_flat_param_grad_info_if_needed(handle)
free_unsharded_flat_param = handle.needs_unshard()
# No need to call `wait_stream()` since we unshard in the computation
# stream directly
computation_stream = state._device_handle.current_stream()
_unshard(state, handle, computation_stream, computation_stream)
if with_grads:
_unshard_grads(handle)
if rank0_only and state.rank != 0:
# Free the unsharded flattened parameter early
_reshard(state, handle, free_unsharded_flat_param)
if with_grads:
_reshard_grads(handle)
try:
yield
finally:
handle._training_state = HandleTrainingState.IDLE
else:
# Unflatten the unsharded flattened parameters
with contextlib.ExitStack() as stack:
# Invariant: rank == 0 or !rank0_only
if offload_to_cpu and handle.uses_sharded_strategy:
stack.enter_context(handle.to_cpu())
# NOTE: Since PyTorch enforces that a parameter and its
# gradients need to match metadata (e.g. device), we must
# move gradients to CPU *after* we move parameters.
# NOTE: This assumes 1 `FlatParameter`
if not state._use_orig_params:
stack.enter_context(_unflatten_as_params(state, module))
try:
yield
finally:
stack.close()
if writeback:
_writeback_to_local_shard(handle, with_grads)
_reshard(state, handle, free_unsharded_flat_param)
if with_grads:
_reshard_grads(handle)
handle._training_state = HandleTrainingState.IDLE
@contextlib.contextmanager
def _unshard_params_for_summon(
module: nn.Module,
state: _FSDPState,
writeback: bool,
rank0_only: bool,
offload_to_cpu: bool,
with_grads: bool,
):
_validate_unshard_params_args(
state, writeback, rank0_only, offload_to_cpu, with_grads
)
_lazy_init(state, module)
if state.training_state == TrainingState.FORWARD_BACKWARD:
raise AssertionError(
"Cannot manually unshard parameters during forward/backward"
)
elif state.training_state == TrainingState.SUMMON_FULL_PARAMS:
raise AssertionError(
"Cannot manually unshard parameters when already unsharding parameters"
)
with _unshard_fsdp_state_params(
module=module,
state=state,
writeback=writeback,
rank0_only=rank0_only,
offload_to_cpu=offload_to_cpu,
with_grads=with_grads,
):
try:
state.training_state = TrainingState.SUMMON_FULL_PARAMS
yield
finally:
state.training_state = TrainingState.IDLE
@contextlib.contextmanager
def _unshard_params(
module: nn.Module,
recurse: bool,
writeback: bool,
rank0_only: bool,
offload_to_cpu: bool,
with_grads: bool,
):
"""
This unshards FSDP-managed parameters for all modules with FSDP applied in
the module tree rooted at ``module``.
"""
if not recurse:
optional_state = _get_module_fsdp_state(module)
if optional_state is None:
with contextlib.nullcontext():
yield
return
states_and_modules = ([optional_state], [module])
else:
states_and_modules = traversal_utils._get_fsdp_states_with_modules(module)
with contextlib.ExitStack() as stack:
for state, module in zip(*states_and_modules):
stack.enter_context(
_unshard_params_for_summon(
module=module,
state=state,
writeback=writeback,
rank0_only=rank0_only,
offload_to_cpu=offload_to_cpu,
with_grads=with_grads,
)
)
yield
def _deregister_orig_params(state: _FSDPState, module: nn.Module) -> None:
"""
Deregisters the original parameters; registers the ``FlatParameter``.
"""
handle = _module_handle(state, module)
if not handle:
return
_p_assert(
handle._use_orig_params,
f"Inconsistent `_use_orig_params` -- FSDP: {state._use_orig_params} "
f"handle: {handle._use_orig_params}",
)
handle._deregister_orig_params()
_register_flat_param(state, module)
def _register_orig_params(state: _FSDPState, module: nn.Module) -> None:
"""
Deregisters the ``FlatParameter``; registers the original parameters.
"""
handle = _module_handle(state, module)
if not handle:
return
_deregister_flat_param(state, module)
if handle.is_sharded(handle.flat_param):
handle._use_sharded_views()
handle._use_sharded_grad_views()
else:
handle._use_unsharded_views(as_params=True)
@@ -0,0 +1,264 @@
# mypy: allow-untyped-defs
import collections
import functools
import inspect
import warnings
from collections.abc import Callable
from functools import partial
from typing import Any
import torch.nn as nn
from torch.distributed.fsdp._common_utils import (
_get_module_fsdp_state,
_override_module_mixed_precision,
)
from torch.distributed.fsdp.wrap import (
_construct_wrap_fn,
_or_policy,
_Policy,
_post_order_apply,
_recursive_wrap,
_run_mixed_precision_override_policy,
_wrap_module_cls_individually,
)
def _auto_wrap(
root_module: nn.Module,
policy: Callable | _Policy,
ignored_modules: set[nn.Module],
ignored_params: set[nn.Parameter],
root_kwargs: dict[str, Any],
fsdp_fn: Callable, # e.g. `FullyShardedDataParallel` or `fully_shard`
):
"""
Auto wraps modules in ``root_module`` 's tree according to ``policy``
following a post-order traversal.
Precondition: ``root_kwargs`` should contain all arguments except
``module``. This function accepts the kwargs dict directly since it gets
forwarded into the post-order traversal function.
"""
mixed_precision = root_kwargs["mixed_precision"]
is_wrapper = inspect.isclass(fsdp_fn)
# TODO: We may relax this no-nested-wrapping constraint to support manual
# wrapping followed by auto wrapping.
_check_nested_wrapping(root_module)
if isinstance(policy, _Policy):
root_kwargs["auto_wrap_policy" if is_wrapper else "policy"] = None
target_module_to_kwargs = policy._run_policy(
root_module, ignored_modules, root_kwargs
)
if mixed_precision is not None:
target_module_to_kwargs = _run_mixed_precision_override_policy(
root_module,
mixed_precision._module_classes_to_ignore,
ignored_modules,
root_kwargs,
target_module_to_kwargs,
)
overridden_module_classes = _override_module_mixed_precision(
root_module, mixed_precision._module_classes_to_ignore
)
_warn_on_overridden_mixed_precision(overridden_module_classes)
use_orig_params = root_kwargs.get("use_orig_params", False)
_validate_frozen_params(
root_module,
set(target_module_to_kwargs.keys()),
ignored_params,
use_orig_params,
)
wrap_fn = _construct_wrap_fn(root_module, target_module_to_kwargs, fsdp_fn)
_post_order_apply(root_module, wrap_fn)
return
recursive_wrap_kwargs = {
"module": root_module,
"auto_wrap_policy": policy,
"wrapper_cls": fsdp_fn,
"ignored_modules": ignored_modules,
"ignored_params": ignored_params,
"only_wrap_children": True,
}
if mixed_precision is not None:
# Wrap modules of the ignored types separately and register forward
# hooks to cast to fp32 and back to the original dtype, respectively
overridden_module_classes = _override_module_mixed_precision(
root_module, mixed_precision._module_classes_to_ignore
)
policy = functools.partial(
_or_policy,
policies=[
policy,
partial(
_wrap_module_cls_individually,
module_classes=mixed_precision._module_classes_to_ignore,
),
],
)
recursive_wrap_kwargs["auto_wrap_policy"] = policy
_warn_on_overridden_mixed_precision(overridden_module_classes)
_recursive_wrap(**recursive_wrap_kwargs, **root_kwargs) # type: ignore[arg-type]
def _check_nested_wrapping(root_module: nn.Module):
for module_name, module in root_module.named_modules():
if _get_module_fsdp_state(module) is not None:
raise ValueError(
"FSDP auto wrapping requires modules to not already have "
f"FSDP applied but found {module_name} in\n{root_module}"
)
def _warn_on_overridden_mixed_precision(
overridden_module_classes: set[type[nn.Module]],
):
if len(overridden_module_classes) == 0:
return
warnings.warn(
"Both mixed precision and an auto_wrap_policy were specified to FSDP, "
f"where the wrapped module has submodules of type:\n{overridden_module_classes}\n"
"These modules will be wrapped as separate FSDP instacnes with mixed "
"precision disabled.",
stacklevel=2,
)
def _validate_frozen_params(
root_module: nn.Module,
modules_to_wrap: set[nn.Module],
ignored_params: set[nn.Parameter],
use_orig_params: bool,
):
"""
This checks that, given ``modules_to_wrap``, each module would manage
parameters that are uniformly frozen or non-frozen. This uniformity
requirement is strict for ``use_orig_params=False`` (hard error) and highly
recommended for ``use_orig_params=True`` (user warning).
"""
post_order_named_modules = _get_post_order_named_modules(root_module)
visited_modules: set[nn.Module] = set()
for module_name, module in post_order_named_modules:
if module in modules_to_wrap:
param_to_fqn = _get_managed_param_to_fqn(
module, ignored_params, visited_modules, module_name
)
frozen_param_fqns: list[str] = []
frozen_param_numel = 0
nonfrozen_param_fqns: list[str] = []
nonfrozen_param_numel = 0
for param, fqn in param_to_fqn.items():
if param.requires_grad:
nonfrozen_param_fqns.append(fqn)
nonfrozen_param_numel += param.numel()
else:
frozen_param_fqns.append(fqn)
frozen_param_numel += param.numel()
if len(frozen_param_fqns) > 0 and len(nonfrozen_param_fqns) > 0:
msg = f"{module_name} has both parameters with requires_grad=True and False."
if use_orig_params:
total_param_numel = frozen_param_numel + nonfrozen_param_numel
msg += (
" We do not recommend wrapping such modules since "
"the gradient memory usage will be higher than expected "
f"({total_param_numel} numel instead of {nonfrozen_param_numel} numel "
"before sharding via reduce-scatter). "
)
else:
msg += " FSDP does not support wrapping such modules when use_orig_params=False. "
msg += "If possible, wrap the frozen parameters with FSDP separately.\n"
msg += (
f"The following parameters have requires_grad=True:\n{nonfrozen_param_fqns}\n"
f"The following parameters have requires_grad=False:\n{frozen_param_fqns}"
)
if use_orig_params:
warnings.warn(msg, stacklevel=2)
else:
raise ValueError(msg)
def _get_post_order_named_modules(
root_module: nn.Module,
) -> list[tuple[str, nn.Module]]:
"""
This returns the named modules following a post-order traversal, which is a
valid reverse topological sort. We achieve this using the reverse of a
stack-based DFS order instead of reversing ``root_module.named_modules()``
since the former gives the modules in registration order at each level in
the module tree (as opposed to the reverse), which allows us to error/warn
on the first registered module that violates the condition.
For example, consider the following module structure:
M(
S1(),
S2(
SS1(),
SS2(),
),
S3(),
)
The reverse DFS order is [S1, SS1, SS2, S2, S3, M], while the reverse
``named_modules()`` order is [S3, SS2, SS1, S2, S1, M].
"""
visited_modules = {root_module}
stack = [("", root_module)]
# Append and reverse at the end for linear-time algorithm
reverse_post_order_named_modules: list[tuple[str, nn.Module]] = []
while stack:
module_name, module = stack.pop()
reverse_post_order_named_modules.append((module_name, module))
for child_module_name, child_module in module.named_children():
if child_module is None: # only for overrides of `named_children()`
continue
if child_module not in visited_modules:
visited_modules.add(child_module)
if module_name != "":
child_module_name = module_name + "." + child_module_name
stack.append((child_module_name, child_module))
post_order_named_modules = list(reversed(reverse_post_order_named_modules))
return post_order_named_modules
def _get_managed_param_to_fqn(
module_to_wrap: nn.Module,
ignored_params: set[nn.Parameter],
visited_modules: set[nn.Module],
root_prefix: str,
) -> dict[nn.Parameter, str]:
"""
This returns a dict that maps managed parameter to its FQN for the given
``module_to_wrap``. The dict's keys are exactly the parameters that would
be managed by the module, where this is achieved by calling this function
on the modules to wrap in reverse topological order, destructively updating
``visited_modules``, and not traversing into those modules. The FQNs are
prefixed from the root (via ``root_prefix``) to be more informative.
NOTE: This function is meant to be called pre-wrapping and iteratively in
reverse topological order to cover the full module tree. This differs from
the ``_get_param_to_fqn()`` function meant to be called post-wrapping and
on the full module tree in one shot. Given those differences, we do not try
to unify the two.
"""
param_to_fqn: dict[nn.Parameter, str] = {}
# Run BFS (or any tree traversal works)
queue = collections.deque([(module_to_wrap, root_prefix)])
visited_modules.add(module_to_wrap)
while queue:
module, prefix = queue.popleft()
for param_name, param in module.named_parameters(recurse=False):
if param not in ignored_params:
fqn = param_name if prefix == "" else prefix + "." + param_name
param_to_fqn[param] = fqn
for child_module_name, child_module in module.named_children():
if child_module is None: # only for overrides of `named_children()`
continue
if child_module not in visited_modules:
visited_modules.add(child_module)
child_prefix = (
child_module_name
if prefix == ""
else prefix + "." + child_module_name
)
queue.append((child_module, child_prefix))
return param_to_fqn
@@ -0,0 +1,416 @@
"""
This file includes public APIs for FSDP such as the classes used for the
constructor arguments.
"""
from collections.abc import Sequence
from dataclasses import dataclass
from enum import auto, Enum
import torch
from torch.nn.modules.batchnorm import _BatchNorm
__all__ = [
"ShardingStrategy",
"BackwardPrefetch",
"MixedPrecision",
"CPUOffload",
"StateDictType",
"StateDictConfig",
"FullStateDictConfig",
"LocalStateDictConfig",
"ShardedStateDictConfig",
"OptimStateDictConfig",
"FullOptimStateDictConfig",
"LocalOptimStateDictConfig",
"ShardedOptimStateDictConfig",
"StateDictSettings",
]
class ShardingStrategy(Enum):
"""
This specifies the sharding strategy to be used for distributed training by
:class:`FullyShardedDataParallel`.
- ``FULL_SHARD``: Parameters, gradients, and optimizer states are sharded.
For the parameters, this strategy unshards (via all-gather) before the
forward, reshards after the forward, unshards before the backward
computation, and reshards after the backward computation. For gradients,
it synchronizes and shards them (via reduce-scatter) after the backward
computation. The sharded optimizer states are updated locally per rank.
- ``SHARD_GRAD_OP``: Gradients and optimizer states are sharded during
computation, and additionally, parameters are sharded outside
computation. For the parameters, this strategy unshards before the
forward, does not reshard them after the forward, and only reshards them
after the backward computation. The sharded optimizer states are updated
locally per rank. Inside ``no_sync()``, the parameters are not resharded
after the backward computation.
- ``NO_SHARD``: Parameters, gradients, and optimizer states are not sharded
but instead replicated across ranks similar to PyTorch's
:class:`DistributedDataParallel` API. For gradients, this strategy
synchronizes them (via all-reduce) after the backward computation. The
unsharded optimizer states are updated locally per rank.
- ``HYBRID_SHARD``: Apply ``FULL_SHARD`` within a node, and replicate parameters across
nodes. This results in reduced communication volume as expensive all-gathers and
reduce-scatters are only done within a node, which can be more performant for medium
-sized models.
- ``_HYBRID_SHARD_ZERO2``: Apply ``SHARD_GRAD_OP`` within a node, and replicate parameters across
nodes. This is like ``HYBRID_SHARD``, except this may provide even higher throughput
since the unsharded parameters are not freed after the forward pass, saving the
all-gathers in the pre-backward.
"""
FULL_SHARD = auto()
SHARD_GRAD_OP = auto()
NO_SHARD = auto()
HYBRID_SHARD = auto()
_HYBRID_SHARD_ZERO2 = auto()
class BackwardPrefetch(Enum):
"""
This configures explicit backward prefetching, which improves throughput by
enabling communication and computation overlap in the backward pass at the
cost of slightly increased memory usage.
- ``BACKWARD_PRE``: This enables the most overlap but increases memory
usage the most. This prefetches the next set of parameters *before* the
current set of parameters' gradient computation. This overlaps the *next
all-gather* and the *current gradient computation*, and at the peak, it
holds the current set of parameters, next set of parameters, and current
set of gradients in memory.
- ``BACKWARD_POST``: This enables less overlap but requires less memory
usage. This prefetches the next set of parameters *after* the current
set of parameters' gradient computation. This overlaps the *current
reduce-scatter* and the *next gradient computation*, and it frees the
current set of parameters before allocating memory for the next set of
parameters, only holding the next set of parameters and current set of
gradients in memory at the peak.
- FSDP's ``backward_prefetch`` argument accepts ``None``, which disables
the backward prefetching altogether. This has no overlap and does not
increase memory usage. In general, we do not recommend this setting since
it may degrade throughput significantly.
For more technical context: For a single process group using NCCL backend,
any collectives, even if issued from different streams, contend for the
same per-device NCCL stream, which implies that the relative order in which
the collectives are issued matters for overlapping. The two backward
prefetching values correspond to different issue orders.
"""
# NOTE: For both modes, the ordering that defines "current" and "next" is
# not always exact in the current implementation. A mistargeted prefetch
# simply means that the parameter memory is allocated earlier than needed,
# possibly increasing peak memory usage, but does not affect correctness.
BACKWARD_PRE = auto()
BACKWARD_POST = auto()
@dataclass
class MixedPrecision:
"""
This configures FSDP-native mixed precision training.
Attributes:
param_dtype (Optional[torch.dtype]): This specifies the dtype for model
parameters during forward and backward and thus the dtype for
forward and backward computation. Outside forward and backward, the
*sharded* parameters are kept in full precision (e.g. for the
optimizer step), and for model checkpointing, the parameters are
always saved in full precision. (Default: ``None``)
reduce_dtype (Optional[torch.dtype]): This specifies the dtype for
gradient reduction (i.e. reduce-scatter or all-reduce). If this is
``None`` but ``param_dtype`` is not ``None``, then this takes on
the ``param_dtype`` value, still running gradient reduction in low
precision. This is permitted to differ from ``param_dtype``, e.g.
to force gradient reduction to run in full precision. (Default:
``None``)
buffer_dtype (Optional[torch.dtype]): This specifies the dtype for
buffers. FSDP does not shard buffers. Rather, FSDP casts them to
``buffer_dtype`` in the first forward pass and keeps them in that
dtype thereafter. For model checkpointing, the buffers are saved
in full precision except for ``LOCAL_STATE_DICT``. (Default:
``None``)
keep_low_precision_grads (bool): If ``False``, then FSDP upcasts
gradients to full precision after the backward pass in preparation
for the optimizer step. If ``True``, then FSDP keeps the gradients
in the dtype used for gradient reduction, which can save memory if
using a custom optimizer that supports running in low precision.
(Default: ``False``)
cast_forward_inputs (bool): If ``True``, then this FSDP module casts
its forward args and kwargs to ``param_dtype``. This is to ensure
that parameter and input dtypes match for forward computation, as
required by many ops. This may need to be set to ``True`` when only
applying mixed precision to some but not all FSDP modules, in which
case a mixed-precision FSDP submodule needs to recast its inputs.
(Default: ``False``)
cast_root_forward_inputs (bool): If ``True``, then the root FSDP module
casts its forward args and kwargs to ``param_dtype``, overriding
the value of ``cast_forward_inputs``. For non-root FSDP modules,
this does not do anything. (Default: ``True``)
_module_classes_to_ignore: (Sequence[Type[nn.Module]]): This specifies
module classes to ignore for mixed precision when using an
``auto_wrap_policy``: Modules of these classes will have FSDP
applied to them separately with mixed precision disabled (meaning
that the final FSDP construction would deviate from the specified
policy). If ``auto_wrap_policy`` is not specified, then this does
not do anything. This API is experimental and subject to change.
(Default: ``(_BatchNorm,)``)
.. note:: This API is experimental and subject to change.
.. note:: Only floating point tensors are cast to their specified dtypes.
.. note:: In ``summon_full_params``, parameters are forced to full
precision, but buffers are not.
.. note:: Layer norm and batch norm accumulate in ``float32`` even when
their inputs are in a low precision like ``float16`` or ``bfloat16``.
Disabling FSDP's mixed precision for those norm modules only means that
the affine parameters are kept in ``float32``. However, this incurs
separate all-gathers and reduce-scatters for those norm modules, which
may be inefficient, so if the workload permits, the user should prefer
to still apply mixed precision to those modules.
.. note:: By default, if the user passes a model with any ``_BatchNorm``
modules and specifies an ``auto_wrap_policy``, then the batch norm
modules will have FSDP applied to them separately with mixed precision
disabled. See the ``_module_classes_to_ignore`` argument.
.. note:: ``MixedPrecision`` has ``cast_root_forward_inputs=True`` and
``cast_forward_inputs=False`` by default. For the root FSDP instance,
its ``cast_root_forward_inputs`` takes precedence over its
``cast_forward_inputs``. For non-root FSDP instances, their
``cast_root_forward_inputs`` values are ignored. The default setting is
sufficient for the typical case where each FSDP instance has the same
``MixedPrecision`` configuration and only needs to cast inputs to the
``param_dtype`` at the beginning of the model's forward pass.
.. note:: For nested FSDP instances with different ``MixedPrecision``
configurations, we recommend setting individual ``cast_forward_inputs``
values to configure casting inputs or not before each instance's
forward. In such a case, since the casts happen before each FSDP
instance's forward, a parent FSDP instance should have its non-FSDP
submodules run before its FSDP submodules to avoid the activation dtype
being changed due to a different ``MixedPrecision`` configuration.
Example::
>>> # xdoctest: +SKIP("undefined variables")
>>> model = nn.Sequential(nn.Linear(3, 3), nn.Linear(3, 3))
>>> model[1] = FSDP(
>>> model[1],
>>> mixed_precision=MixedPrecision(param_dtype=torch.float16, cast_forward_inputs=True),
>>> )
>>> model = FSDP(
>>> model,
>>> mixed_precision=MixedPrecision(param_dtype=torch.bfloat16, cast_forward_inputs=True),
>>> )
The above shows a working example. On the other hand, if ``model[1]``
were replaced with ``model[0]``, meaning that the submodule using
different ``MixedPrecision`` ran its forward first, then ``model[1]``
would incorrectly see ``float16`` activations instead of ``bfloat16``
ones.
"""
param_dtype: torch.dtype | None = None
reduce_dtype: torch.dtype | None = None
buffer_dtype: torch.dtype | None = None
keep_low_precision_grads: bool = False
cast_forward_inputs: bool = False
cast_root_forward_inputs: bool = True
_module_classes_to_ignore: Sequence[type[torch.nn.Module]] = (_BatchNorm,)
@dataclass
class CPUOffload:
"""
This configures CPU offloading.
Attributes:
offload_params (bool): This specifies whether to offload parameters to
CPU when not involved in computation. If ``True``, then this
offloads gradients to CPU as well, meaning that the optimizer step
runs on CPU.
"""
offload_params: bool = False
class StateDictType(Enum):
"""
This enum indicates that which type of ``state_dict`` the FSDP module is
currently processing (returning or loading).
The default value is FULL_STATE_DICT to comply the PyTorch convention.
.. note::
FSDP currently supports three types of ``state_dict``:
1. ``state_dict/load_state_dict`: this pair of APIs return and load
the non-sharded, unflattened parameters. The semantics is the
same as using DDP.
2. ``_local_state_dict/_load_local_state_dict``: this pair of APIs return
and load local sharded, flattened parameters. The values returned
by ``_local_state_dict`` can be directly used by FSDP and is only
meaningful to FSDP (because parameters are flattened). Note that
these APIs are meant for use via the :func:`state_dict_type`
context manager as follows:
>>> # xdoctest: +SKIP("undefined variables")
>>> with fsdp.state_dict_type(StateDictType.LOCAL_STATE_DICT):
... state = fsdp.state_dict() # loads local state dict
3. ``_sharded_state_dict/_load_sharded_state_dict``: this pair of APIs
return and load sharded, unflattened parameters. The ``state_dict``
return by ``sharded_state_dict`` can be used by all other parallel
schemes (resharding may be required).
"""
FULL_STATE_DICT = auto()
LOCAL_STATE_DICT = auto()
SHARDED_STATE_DICT = auto()
@dataclass
class StateDictConfig:
"""
``StateDictConfig`` is the base class for all ``state_dict`` configuration
classes. Users should instantiate a child class (e.g.
``FullStateDictConfig``) in order to configure settings for the
corresponding ``state_dict`` type supported by FSDP.
Attributes:
offload_to_cpu (bool): If ``True``, then FSDP offloads the state dict
values to CPU, and if ``False``, then FSDP keeps them on GPU.
(Default: ``False``)
"""
offload_to_cpu: bool = False
@dataclass
class FullStateDictConfig(StateDictConfig):
"""
``FullStateDictConfig`` is a config class meant to be used with
``StateDictType.FULL_STATE_DICT``. We recommend enabling both
``offload_to_cpu=True`` and ``rank0_only=True`` when saving full state
dicts to save GPU memory and CPU memory, respectively. This config class
is meant to be used via the :func:`state_dict_type` context manager as
follows:
>>> # xdoctest: +SKIP("undefined variables")
>>> from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
>>> fsdp = FSDP(model, auto_wrap_policy=...)
>>> cfg = FullStateDictConfig(offload_to_cpu=True, rank0_only=True)
>>> with FSDP.state_dict_type(fsdp, StateDictType.FULL_STATE_DICT, cfg):
>>> state = fsdp.state_dict()
>>> # `state` will be empty on non rank 0 and contain CPU tensors on rank 0.
>>> # To reload checkpoint for inference, finetuning, transfer learning, etc:
>>> model = model_fn() # Initialize model in preparation for wrapping with FSDP
>>> if dist.get_rank() == 0:
>>> # Load checkpoint only on rank 0 to avoid memory redundancy
>>> state_dict = torch.load("my_checkpoint.pt")
>>> model.load_state_dict(state_dict)
>>> # All ranks initialize FSDP module as usual. `sync_module_states` argument
>>> # communicates loaded checkpoint states from rank 0 to rest of the world.
>>> fsdp = FSDP(
... model,
... device_id=torch.cuda.current_device(),
... auto_wrap_policy=...,
... sync_module_states=True,
... )
>>> # After this point, all ranks have FSDP model with loaded checkpoint.
Attributes:
rank0_only (bool): If ``True``, then only rank 0 saves the full state
dict, and nonzero ranks save an empty dict. If ``False``, then all
ranks save the full state dict. (Default: ``False``)
"""
rank0_only: bool = False
@dataclass
class LocalStateDictConfig(StateDictConfig):
pass
@dataclass
class ShardedStateDictConfig(StateDictConfig):
"""
``ShardedStateDictConfig`` is a config class meant to be used with
``StateDictType.SHARDED_STATE_DICT``.
Attributes:
_use_dtensor (bool): If ``True``, then FSDP saves the state dict values
as ``DTensor``, and if ``False``, then FSDP saves them as
``ShardedTensor``. (Default: ``False``)
.. warning:: ``_use_dtensor`` is a private field of :class:`ShardedStateDictConfig`
and it is used by FSDP to determine the type of state dict values. Users should not
manually modify ``_use_dtensor``.
"""
_use_dtensor: bool = False
@dataclass
class OptimStateDictConfig:
"""
``OptimStateDictConfig`` is the base class for all ``optim_state_dict``
configuration classes. Users should instantiate a child class (e.g.
``FullOptimStateDictConfig``) in order to configure settings for the
corresponding ``optim_state_dict`` type supported by FSDP.
Attributes:
offload_to_cpu (bool): If ``True``, then FSDP offloads the state dict's
tensor values to CPU, and if ``False``, then FSDP keeps them on the
original device (which is GPU unless parameter CPU offloading is
enabled). (Default: ``True``)
"""
offload_to_cpu: bool = True
@dataclass
class FullOptimStateDictConfig(OptimStateDictConfig):
"""
Attributes:
rank0_only (bool): If ``True``, then only rank 0 saves the full state
dict, and nonzero ranks save an empty dict. If ``False``, then all
ranks save the full state dict. (Default: ``False``)
"""
rank0_only: bool = False
@dataclass
class LocalOptimStateDictConfig(OptimStateDictConfig):
offload_to_cpu: bool = False
@dataclass
class ShardedOptimStateDictConfig(OptimStateDictConfig):
"""
``ShardedOptimStateDictConfig`` is a config class meant to be used with
``StateDictType.SHARDED_STATE_DICT``.
Attributes:
_use_dtensor (bool): If ``True``, then FSDP saves the state dict values
as ``DTensor``, and if ``False``, then FSDP saves them as
``ShardedTensor``. (Default: ``False``)
.. warning:: ``_use_dtensor`` is a private field of :class:`ShardedOptimStateDictConfig`
and it is used by FSDP to determine the type of state dict values. Users should not
manually modify ``_use_dtensor``.
"""
_use_dtensor: bool = False
@dataclass
class StateDictSettings:
state_dict_type: StateDictType
state_dict_config: StateDictConfig
optim_state_dict_config: OptimStateDictConfig
@@ -0,0 +1,377 @@
# mypy: allow-untyped-defs
import logging
from collections import abc, defaultdict
from collections.abc import Iterable
from typing import Any, overload
import torch
import torch.distributed as dist
from torch.amp.grad_scaler import _MultiDeviceReplicator, GradScaler, OptState
from torch.distributed.distributed_c10d import ProcessGroup
logger = logging.getLogger(__name__)
def _refresh_per_optimizer_state() -> dict[str, Any]:
return {"stage": OptState.READY, "found_inf_per_device": {}}
def _is_supported_device(tensor: torch.Tensor) -> bool:
return tensor.is_cuda or tensor.device.type in (
"xla",
"cpu",
"hpu",
"mtia",
"xpu",
torch._C._get_privateuse1_backend_name(),
)
class _GeneralMultiDeviceReplicator(_MultiDeviceReplicator):
"""
Lazily serves tensor to request device. This class extends
_MultiDeviceReplicator to allow support for "cpu" as a device.
"""
def __init__(self, master_tensor: torch.Tensor) -> None:
if not _is_supported_device(master_tensor):
raise AssertionError(
f"Expected supported device, got {master_tensor.device}"
)
self.master = master_tensor
self._per_device_tensors: dict[torch.device, torch.Tensor] = {}
class ShardedGradScaler(GradScaler):
"""
ShardedGradScaler helps perform gradient scaling in a shard aware manner. It extends
functionality from GradScaler:
* Supports Pytorch DDP and FSDP implementations
* Support CPU offloaded tensors (as used in fully sharded data parallel[FSDP])
* Supports the custom Mixed Precision loss dtype (fp16, bf16) that FSDP returns
* Sync inf/nan for scaled gradient tensors on any torch.device (where tensors are placed) across
nodes
Example::
# Creates a ShardedGradScaler once at the beginning of training.
scaler = ShardedGradScaler()
for epoch in epochs:
for input, target in data:
optimizer.zero_grad()
output = model(input)
loss = loss_fn(output, target)
# Scales loss. Calls backward() on scaled loss to create scaled gradients.
scaler.scale(loss).backward()
# scaler.step() first unscales gradients of the optimizer's params.
# If gradients don't contain infs/NaNs, optimizer.step() is then called,
# otherwise, optimizer.step() is skipped.
scaler.step(optimizer)
# Updates the scale for next iteration.
scaler.update()
See :class:`GradScaler` for explanation of scaling/unscaling and more use cases.
Args:
init_scale (float, optional, default=2.**16): Initial scale factor.
growth_factor (float, optional, default=2.0): Factor by which the scale is multiplied during
:meth:`update` if no inf/NaN gradients occur for ``growth_interval`` consecutive iterations.
backoff_factor (float, optional, default=0.5): Factor by which the scale is multiplied during
:meth:`update` if inf/NaN gradients occur in an iteration.
growth_interval (int, optional, default=2000): Number of consecutive iterations without inf/NaN gradients
that must occur for the scale to be multiplied by ``growth_factor``.
enabled (bool, optional): If ``False``, disables gradient scaling. :meth:`step` simply
invokes the underlying ``optimizer.step()``, and other methods become no-ops.
Default: ``True``
process_group (ProcessGroup, optional, default=torch.distributed.group.WORLD):
process group for sharding
"""
def __init__(
self,
device: str = "cuda",
init_scale: float = 2.0**16,
backoff_factor: float = 0.5,
growth_factor: float = 2.0,
growth_interval: int = 2000,
enabled: bool = True,
process_group: ProcessGroup | None = dist.group.WORLD,
) -> None:
super().__init__(
device,
init_scale=init_scale,
backoff_factor=backoff_factor,
growth_factor=growth_factor,
growth_interval=growth_interval,
enabled=enabled,
)
if self._enabled:
self.process_group = process_group
self._per_optimizer_states = defaultdict(_refresh_per_optimizer_state)
@overload
def scale(self, outputs: torch.Tensor) -> torch.Tensor: ...
@overload
def scale(self, outputs: list[torch.Tensor]) -> list[torch.Tensor]: ...
@overload
def scale(self, outputs: tuple[torch.Tensor, ...]) -> tuple[torch.Tensor, ...]: ...
@overload
def scale(self, outputs: Iterable[torch.Tensor]) -> Iterable[torch.Tensor]: ...
def scale(
self, outputs: torch.Tensor | Iterable[torch.Tensor]
) -> torch.Tensor | Iterable[torch.Tensor]:
if not self._enabled:
return outputs
if isinstance(outputs, torch.Tensor):
if not _is_supported_device(outputs):
raise AssertionError(f"Expected supported device, got {outputs.device}")
if self._scale is None:
self._lazy_init_scale_growth_tracker(outputs.device)
if self._scale is None:
raise AssertionError("Expected _scale to be initialized, got None")
scaled_output = outputs * self._scale.to(
device=outputs.device, non_blocking=True
)
# Here we ensure the return dtype is the same as the outputs dtype.
# For the FSDP + Mixed Precision use case, the loss output is in the Mixed Precision
# format (fp16, bf16) and so the scaled loss should be of the same dtype.
return scaled_output.type(outputs.dtype)
stash: list[_GeneralMultiDeviceReplicator] = []
def apply_scale(val: torch.Tensor | Iterable[torch.Tensor]):
if isinstance(val, torch.Tensor):
if not _is_supported_device(val):
raise AssertionError(f"Expected supported device, got {val.device}")
if len(stash) == 0:
if self._scale is None:
self._lazy_init_scale_growth_tracker(val.device)
if self._scale is None:
raise AssertionError(
"Expected _scale to be initialized, got None"
)
stash.append(_GeneralMultiDeviceReplicator(self._scale))
scaled_val = val * stash[0].get(val.device)
# Here we ensure the return dtype is the same as the outputs dtype.
# For the FSDP + Mixed Precision use case, the loss output is in the Mixed Precision
# format (fp16, bf16) and so the scaled loss should be of the same dtype.
return scaled_val.type(val.dtype)
if isinstance(val, abc.Iterable):
iterator = map(apply_scale, val)
if isinstance(val, (list, tuple)):
return type(val)(iterator)
return iterator
raise ValueError("outputs must be a Tensor or an iterable of Tensors")
return apply_scale(outputs)
def _unscale_grads_(
self,
optimizer: torch.optim.Optimizer,
inv_scale: torch.Tensor,
found_inf: torch.Tensor,
allow_fp16: bool = True,
) -> dict[torch.device, torch.Tensor]:
per_device_inv_scale = _GeneralMultiDeviceReplicator(inv_scale)
per_device_found_inf = _GeneralMultiDeviceReplicator(found_inf)
# To set up _amp_foreach_non_finite_check_and_unscale_, split grads by device and dtype.
# There could be thousands of grads, so we'd like to iterate through them just once.
# However, we don't know their devices or dtypes in advance.
# https://stackoverflow.com/questions/5029934/defaultdict-of-defaultdict
# Google says mypy struggles with defaultdicts type annotations.
per_device_and_dtype_grads = defaultdict(lambda: defaultdict(list)) # type: ignore[var-annotated]
with torch.no_grad():
for group in optimizer.param_groups:
for param in group["params"]:
if param.grad is None:
continue
if (not allow_fp16) and param.grad.dtype == torch.float16:
raise ValueError("Attempting to unscale FP16 gradients.")
if param.grad.is_sparse:
# is_coalesced() == False means the sparse grad has values with duplicate indices.
# coalesce() deduplicates indices and adds all values that have the same index.
# For scaled fp16 values, there's a good chance coalescing will cause overflow,
# so we should check the coalesced _values().
if param.grad.dtype is torch.float16:
# coalesce is not supported in torch.float16
param_grad_fp32 = param.grad.type(torch.float32).coalesce()
param.grad = param_grad_fp32.type(torch.float16)
to_unscale = param.grad._values()
else:
to_unscale = param.grad
per_device_and_dtype_grads[to_unscale.device][
to_unscale.dtype
].append(to_unscale)
for device, per_dtype_grads in per_device_and_dtype_grads.items():
for grads in per_dtype_grads.values():
torch._amp_foreach_non_finite_check_and_unscale_(
grads,
per_device_found_inf.get(device),
per_device_inv_scale.get(device),
)
# There exist contexts (e.g. w/ `use_orig_params=True`) wherein some
# ranks may have no (non-zero sized) parameter shards, necessitating the
# initialization of `per_device_found_inf._per_device_tensors` here
if not per_device_found_inf._per_device_tensors:
if self._scale is None:
raise AssertionError("Expected _scale to be initialized, got None")
per_device_found_inf.get(self._scale.device)
return per_device_found_inf._per_device_tensors
def unscale_(self, optimizer: torch.optim.Optimizer) -> None:
if not self._enabled:
return
self._check_scale_growth_tracker("unscale_")
optimizer_state = self._per_optimizer_states[id(optimizer)]
if optimizer_state["stage"] is OptState.UNSCALED:
raise RuntimeError(
"unscale_() has already been called on this optimizer since the last update()."
)
elif optimizer_state["stage"] is OptState.STEPPED:
raise RuntimeError("unscale_() is being called after step().")
# FP32 division can be imprecise for certain compile options, so we carry out the reciprocal in FP64.
if self._scale is None:
raise AssertionError("Expected _scale to be initialized, got None")
inv_scale = self._scale.double().reciprocal().float()
found_inf = torch.full(
(1,), 0.0, dtype=torch.float32, device=self._scale.device
)
optimizer_state["found_inf_per_device"] = self._unscale_grads_(
optimizer, inv_scale, found_inf, True
)
optimizer_state["stage"] = OptState.UNSCALED
# Synchronize the detected inf across the ranks
optimizer_state = self._per_optimizer_states[id(optimizer)]
works = []
found_inf_on_cpus = []
found_inf_on_devices = []
for found_inf in optimizer_state["found_inf_per_device"].values():
if self._device != "cpu" and found_inf.device.type == "cpu":
found_inf_on_cpus.append(found_inf)
found_inf_on_device = found_inf.to(self._device)
found_inf_on_devices.append(found_inf_on_device)
works.append(
dist.all_reduce(
found_inf_on_device, async_op=True, group=self.process_group
)
)
else:
works.append(
dist.all_reduce(found_inf, async_op=True, group=self.process_group)
)
for work in works:
work.wait()
if found_inf_on_cpus:
torch._foreach_copy_(found_inf_on_cpus, found_inf_on_devices)
def _amp_update_scale_cpu_(self, found_inf: torch.Tensor) -> None:
"""
If found_inf is 1.0 (True), then scale is multiplied by backoff_factor and growth_tracker is set to zero.
Otherwise, scale is multiplied by the growth factor when the growth interval is reached.
"""
if self._scale is None or self._growth_tracker is None:
raise AssertionError(
"Expected _scale and _growth_tracker to be initialized, got None"
)
if found_inf.item() >= 1.0:
self._scale *= self._backoff_factor
self._growth_tracker.fill_(0)
else:
successful = self._growth_tracker + 1
if successful == self._growth_interval:
self._scale *= self._growth_factor
self._growth_tracker.fill_(0)
else:
self._growth_tracker = successful
def update(self, new_scale: float | torch.Tensor | None = None) -> None:
"""
Updates the scale factor.
If any optimizer steps were skipped the scale is multiplied by ``backoff_factor``
to reduce it. If ``growth_interval`` unskipped iterations occurred consecutively,
the scale is multiplied by ``growth_factor`` to increase it.
Passing ``new_scale`` sets the new scale value manually. (``new_scale`` is not
used directly, it's used to fill GradScaler's internal scale tensor. So if
``new_scale`` was a tensor, later in-place changes to that tensor will not further
affect the scale GradScaler uses internally.)
Args:
new_scale (float or :class:`torch.Tensor`, optional, default=None): New scale factor.
.. warning::
:meth:`update` should only be called at the end of the iteration, after ``scaler.step(optimizer)`` has
been invoked for all optimizers used this iteration.
"""
if not self._enabled:
return
_scale, _growth_tracker = self._check_scale_growth_tracker("update") # type: ignore[var-annotated]
if new_scale is not None:
# Accept a new user-defined scale.
if isinstance(new_scale, float):
self._scale.fill_(new_scale) # type: ignore[union-attr]
else:
reason = (
"new_scale should be a float or a 1-element torch.cuda.FloatTensor or "
"torch.FloatTensor with requires_grad=False."
)
if new_scale.device.type != self._device:
raise AssertionError(reason)
if new_scale.numel() != 1:
raise AssertionError(reason)
if new_scale.requires_grad is not False:
raise AssertionError(reason)
self._scale.copy_(new_scale) # type: ignore[union-attr]
else:
# Consume shared inf/nan data collected from optimizers to update the scale.
# If all found_inf tensors are on the same device as self._scale, this operation is asynchronous.
found_infs = [
found_inf.to(device=_scale.device, non_blocking=True)
for state in self._per_optimizer_states.values()
for found_inf in state["found_inf_per_device"].values()
]
if len(found_infs) == 0:
raise AssertionError("No inf checks were recorded prior to update.")
found_inf_combined = found_infs[0]
if len(found_infs) > 1:
for i in range(1, len(found_infs)):
found_inf_combined += found_infs[i]
if _scale.device.type == "cpu":
self._amp_update_scale_cpu_(found_inf_combined)
else:
torch._amp_update_scale_(
self._scale, # type: ignore[arg-type]
self._growth_tracker, # type: ignore[arg-type]
found_inf_combined,
self._growth_factor, # type: ignore[arg-type]
self._backoff_factor, # type: ignore[arg-type]
self._growth_interval, # type: ignore[arg-type]
)
# To prepare for next iteration, clear the data collected from optimizers this iteration.
self._per_optimizer_states = defaultdict(_refresh_per_optimizer_state)
@@ -0,0 +1,608 @@
# mypy: allow-untyped-defs
# Copyright (c) Facebook, Inc. and its affiliates.
#
# This source code is licensed under the BSD license found in the
# LICENSE file in the root directory of this source tree.
import contextlib
import copy
from abc import ABC, abstractmethod
from collections.abc import Callable, Generator, Iterable, Sequence
from typing import Any, cast
import torch.nn as nn
__all__ = [
"always_wrap_policy",
"lambda_auto_wrap_policy",
"transformer_auto_wrap_policy",
"size_based_auto_wrap_policy",
"enable_wrap",
"wrap",
"CustomPolicy",
"ModuleWrapPolicy",
]
# NOTE: We intentionally keep this function simple and isolate the complexity
# to `fn` to enable using this function generically. We may move this to a
# non-FSDP-specific folder and/or make it public in the future.
def _post_order_apply(
root_module: nn.Module,
fn: Callable[[nn.Module], nn.Module | None],
):
"""
This applies ``fn`` to every module in the module tree of ``root_module``
following a post-order traversal. If ``fn`` returns an :class:`nn.Module`,
then this replaces the original module with the newly returned one in the
tree. Otherwise, ``fn`` should return ``None``, in which case the module is
not changed.
"""
# Track visited modules to avoid visiting shared modules multiple times
visited_modules: set[nn.Module] = {root_module}
def _post_order_apply_inner(
module: nn.Module,
module_name: str,
parent_module: nn.Module | None,
):
for child_module_name, child_module in module.named_children():
if child_module not in visited_modules:
visited_modules.add(child_module)
_post_order_apply_inner(child_module, child_module_name, module)
optional_module = fn(module)
if optional_module is not None:
if not isinstance(parent_module, nn.Module):
raise AssertionError(
"Non-root modules should have their parent module set but got "
f"{parent_module} for {module}"
)
if not module_name:
raise AssertionError(
"Non-root modules should have their module name set but got "
f"an empty module name for {module}"
)
if not isinstance(optional_module, nn.Module):
raise AssertionError(
f"fn should return None or an nn.Module but got {optional_module}"
)
setattr(parent_module, module_name, optional_module)
_post_order_apply_inner(root_module, "", None)
def _construct_wrap_fn(
root_module: nn.Module,
target_module_to_kwargs: dict[nn.Module, dict[str, Any]],
fsdp_fn: Callable,
) -> Callable[[nn.Module], nn.Module | None]:
"""
This constructs the "wrap" function to pass to :func:`_post_order_apply`
based on ``target_module_to_kwargs``, which should be constructed from the
wrapping policy.
"""
def fn(module: nn.Module) -> nn.Module | None:
# Explicitly avoid wrapping the root module since for FSDP, it is
# handled by the caller
if module in target_module_to_kwargs and module is not root_module:
kwargs = target_module_to_kwargs[module]
return fsdp_fn(module, **kwargs)
return None
return fn
def _run_mixed_precision_override_policy(
root_module: nn.Module,
module_classes: Iterable[type[nn.Module]],
ignored_modules: set[nn.Module],
root_kwargs: dict[str, Any],
target_module_to_kwargs: dict[nn.Module, dict[str, Any]],
):
module_classes_tuple = tuple(set(module_classes))
for module in root_module.modules():
if module in ignored_modules:
continue
elif isinstance(module, module_classes_tuple):
# This policy overrides any existing policy
if module not in target_module_to_kwargs:
# Only inherit from the root kwargs if not already specified
target_module_to_kwargs[module] = root_kwargs
target_module_to_kwargs[module]["mixed_precision"] = None
return target_module_to_kwargs
def always_wrap_policy(*args, **kwargs) -> bool:
"""
A simple recursive wrap policy that always returns ``True``. This means
that every submodule is wrapped by the wrapper class in
:func:`_recursive_wrap`.
"""
return True
class _Policy(ABC):
"""
This defines an abstract base class that represents a policy for applying
a module-level API.
"""
@abstractmethod
def _run_policy(
self,
root_module: nn.Module,
ignored_modules: set[nn.Module],
root_kwargs: dict[str, Any],
) -> dict[nn.Module, dict[str, Any]]:
"""
This should return a dict ``target_module_to_kwargs`` that maps from
each target module to wrap to its kwargs.
"""
...
def _module_wrap_policy(
module: nn.Module,
recurse: bool,
nonwrapped_numel: int,
module_classes: set[type[nn.Module]],
) -> bool:
"""
This auto wrap policy wraps every module that is an instance of any type in
``module_classes`` as its own FSDP instance. The root module given by
``module`` is always wrapped as an FSDP instance regardless. Since the
wrapping proceeds bottom up, each FSDP instance manages the parameters in
its subtree excluding any already managed by a child FSDP instance.
Args:
module (nn.Module): Current module being considered.
recurse (bool): If ``False``, then this function must decide whether
``module`` should be wrapped as an FSDP instance or not. If
``True``, then the function is still recursing down the module
tree as a part of the DFS.
nonwrapped_numel (int): Parameter numel not yet wrapped.
module_classes (Set[Type[nn.Module]]): Set of module classes that are
wrapped as FSDP instances.
Returns:
``True`` if ``recurse=True``, and whether ``module`` should be wrapped
if ``recurse=False``.
"""
if recurse:
return True # always recurse
return isinstance(module, tuple(module_classes))
class ModuleWrapPolicy(_Policy):
"""
This policy applies to every module of the specified module classes,
passing in the kwargs given to the root.
"""
def __init__(self, module_classes: Iterable[type[nn.Module]]):
module_classes_set = set(module_classes)
self._module_classes = module_classes_set
self._module_classes_str = str(module_classes_set)
def _run_policy(
self,
root_module: nn.Module,
ignored_modules: set[nn.Module],
root_kwargs: dict[str, Any],
) -> dict[nn.Module, dict[str, Any]]:
module_classes = tuple(self._module_classes)
target_module_to_kwargs: dict[nn.Module, dict[str, Any]] = {}
for module in root_module.modules():
if module in ignored_modules:
continue
elif isinstance(module, module_classes):
# Shallow copy to avoid coupling changes across modules
target_module_to_kwargs[module] = copy.copy(root_kwargs)
return target_module_to_kwargs
def __call__(self, module, recurse, *args, **kwargs):
# nonwrapped_numel is not used.
return _module_wrap_policy(
module, recurse, nonwrapped_numel=-1, module_classes=self._module_classes
)
def __repr__(self) -> str:
return super().__repr__() + f"({self._module_classes_str})"
class CustomPolicy(_Policy):
"""
This policy takes in a lambda function that maps a given ``nn.Module`` to
either ``False``, ``True``, or a kwarg dictionary.
- If the function returns ``False`` or an empty dictionary, then the module
does not have the API applied.
- If the function returns ``True``, then the module has the API applied
with the root's kwargs.
- If the function returns a non-empty dictionary, then the module has the
API applied, and the dictionary overrides the root's kwargs.
Example::
>>> # xdoctest: +SKIP("undefined variables")
>>> model = init_transformer_model(...)
>>> def lambda_fn(module: nn.Module):
>>> if module is model.lm_head:
>>> return {"sharding_strategy": ShardingStrategy.SHARD_GRAD_OP}
>>> elif isinstance(module, TransformerBlock):
>>> return True
>>> return False
>>> policy = CustomPolicy(lambda_fn)
>>> fsdp_model = FSDP(model, auto_wrap_policy=policy)
"""
def __init__(self, lambda_fn: Callable[[nn.Module], bool | dict[str, Any]]):
self._lambda_fn = lambda_fn
def _run_policy(
self,
root_module: nn.Module,
ignored_modules: set[nn.Module],
root_kwargs: dict[str, Any],
) -> dict[nn.Module, dict[str, Any]]:
target_module_to_kwargs: dict[nn.Module, dict[str, Any]] = {}
for module in root_module.modules():
if module in ignored_modules:
continue
res = self._lambda_fn(module)
if not isinstance(res, (dict, bool)):
raise ValueError(
"The lambda_fn passed to CustomPolicy should return "
f"False/True or a kwarg dict, but it returned {res}"
)
if not res:
continue
kwargs = copy.copy(root_kwargs)
if isinstance(res, dict):
# Override the root kwargs with the ones specified by the
# lambda function
kwargs.update(res)
target_module_to_kwargs[module] = kwargs
return target_module_to_kwargs
def lambda_auto_wrap_policy(
module: nn.Module, recurse: bool, nonwrapped_numel: int, lambda_fn: Callable
) -> bool:
"""
A convenient auto wrap policy to wrap submodules based on an arbitrary user
function. If `lambda_fn(submodule) == True``, the submodule will be wrapped as
a `wrapper_cls` unit.
Return if a module should be wrapped during auto wrapping.
The first three parameters are required by :func:`_recursive_wrap`.
Args:
module (nn.Module): Current module being considered.
recurse (bool): If ``False``, then this function must decide whether
``module`` should be wrapped as an FSDP instance or not. If
``True``, then the function is still recursing down the module
tree as a part of the DFS.
nonwrapped_numel (int): Parameter numel not yet wrapped.
lambda_fn (Callable[[nn.Module], bool]): If this returns ``True``, then
this module will be wrapped.
"""
if recurse:
return True # always recurse
return lambda_fn(module)
def transformer_auto_wrap_policy(
module: nn.Module,
recurse: bool,
nonwrapped_numel: int,
transformer_layer_cls: set[type[nn.Module]],
) -> bool:
"""
See :func:`_module_wrap_policy`, where ``transformer_layer_cls`` is the
same as ``module_classes``. Note that shared parameters must be wrapped in
the same FSDP instance, so this auto wrap policy can help wrap shared
embeddings into the same FSDP instance for transformer models.
"""
return _module_wrap_policy(module, recurse, nonwrapped_numel, transformer_layer_cls)
def _wrap_module_cls_individually(
module: nn.Module, module_classes: Sequence[type], recurse: bool, *args, **kwargs
):
if recurse:
# always recurse
return True
else:
# if not recursing, decide whether we should wrap based on whether the type of module
# is in `module_classes`.
return isinstance(module, tuple(module_classes))
def _or_policy(
module: nn.Module,
recurse: bool,
nonwrapped_numel: int,
policies,
) -> bool:
"""
A policy that wraps ``module`` if any policy in the passed in iterable of
``policies`` returns ``True``.
"""
return any(
policy(module=module, recurse=recurse, nonwrapped_numel=nonwrapped_numel)
for policy in policies
)
def size_based_auto_wrap_policy(
module: nn.Module,
recurse: bool,
nonwrapped_numel: int,
# Additional custom arguments
min_num_params: int = int(1e8),
force_leaf_modules: set[type[nn.Module]] | None = None,
exclude_wrap_modules: set[type[nn.Module]] | None = None,
) -> bool:
"""
A size-based auto wrap policy.
Args:
module (nn.Module): Current module being considered.
recurse (bool): If ``False``, then this function must decide whether
``module`` should be wrapped as an FSDP instance or not. If
``True``, then the function is still recursing down the module
tree as a part of the DFS.
nonwrapped_numel (int): Parameter numel not yet wrapped.
min_num_params (int): Customizable policy input that controls the size
threshold over which a module is ready to be wrapped. This is in
units of numel.
force_leaf_modules (Optional[set[type[nn.Module]]]): Set of module types to keep
as leaves, i.e. their children will never be wrapped.
exclude_wrap_modules (Optional[set[type[nn.Module]]]): Set of module types to be
excluded in wrapping.
Returns:
Whether ``module`` should be wrapped.
"""
force_leaf_modules = (
size_based_auto_wrap_policy.FORCE_LEAF_MODULES # type: ignore[attr-defined]
if force_leaf_modules is None
else force_leaf_modules
)
exclude_wrap_modules = (
size_based_auto_wrap_policy.EXCLUDE_WRAP_MODULES # type: ignore[attr-defined]
if exclude_wrap_modules is None
else exclude_wrap_modules
)
# Keep the argument `min_num_params` for BC for now, but it represents the
# minimum non-wrapped *numel* before triggering a wrapping
min_nonwrapped_numel = min_num_params
is_large = nonwrapped_numel >= min_nonwrapped_numel
if recurse:
# We should recurse if the module is big enough but not in force_leaf_modules list.
return is_large and not isinstance(module, tuple(force_leaf_modules))
else:
# If we are not recursing, determine if we should wrap.
return is_large and not isinstance(module, tuple(exclude_wrap_modules))
# Set those defaults to the size_based_auto_wrap_policy function. Make them easy to be imported.
size_based_auto_wrap_policy.EXCLUDE_WRAP_MODULES = {nn.ModuleList, nn.ModuleDict} # type: ignore[attr-defined]
size_based_auto_wrap_policy.FORCE_LEAF_MODULES = {nn.MultiheadAttention} # type: ignore[attr-defined]
@contextlib.contextmanager
def enable_wrap(
*, wrapper_cls: Any, **wrapper_kwargs: Any
) -> Generator[None, None, None]:
"""
Context manager to wrap modules using a wrapper.
Useful for when you'd like to apply the same configuration arguments to all
child modules that you wrap. A particularly important use case is wrapping
large layers so that they get sharded (in-place) during initialization, to
avoid running out of system memory. Large layers can indicate that they
should be sharded via the ``wrap`` annotation and this context manager can
provide the exact configuration for these nested instances.
Usage::
with enable_wrap(wrapper_cls, **params):
# Wraps layer in FSDP by default if within context
self.l1 = wrap(torch.nn.Linear(5, 5))
Args:
wrapper_cls:
Class that `wrap` annotation will `wrap` modules with, such as
`FullyShardedDataParallel`.
**wrapper_kwargs:
Configuration settings that will be passed to all ``wrap``
instances inside the context
"""
kwargs = {
"wrapper_cls": wrapper_cls,
**wrapper_kwargs,
}
with _ConfigAutoWrap(**kwargs):
yield
def wrap(module: nn.Module, **wrap_overrides: Any) -> nn.Module:
"""
Annotate that a module should be wrapped. Annotated modules will only be
wrapped if inside of an :func:`enable_wrap` context manager. This allows
a module to be initialized both with and without a wrapper without code
change.
The class that this function wraps the passed in ``nn.Module`` with is the
passed in ``wrapper_cls`` argument into ``enable_wrap``. Both
``enable_wrap`` and ``wrap`` can take in kwargs specifying how to construct
the ``wrapper_cls`` instance. In the case of duplicate kwargs in
``enable_wrap`` and ``wrap``, the argument passed into ``wrap`` will be
respected.
Usage::
with enable_wrap(wrapper_cls=FSDP, **fsdp_config):
# Wraps layer in FSDP by default if within context
self.l1 = wrap(torch.nn.Linear(5, 5))
Args:
module (nn.Module): module to wrap (if in :func:`enable_wrap` context)
**wrap_overrides: configuration overrides that will take priority over
the values provided by the :func:`enable_wrap` context
"""
if _ConfigAutoWrap.in_autowrap_context:
if _ConfigAutoWrap.wrapper_cls is None:
raise AssertionError("Expected _ConfigAutoWrap.wrapper_cls to be set")
wrap_overrides = {**_ConfigAutoWrap.kwargs, **wrap_overrides}
return _wrap(
module,
_ConfigAutoWrap.wrapper_cls,
**wrap_overrides,
)
return module
def _wrap(module: nn.Module, wrapper_cls: Callable, **kwargs) -> nn.Module:
if wrapper_cls is None:
raise AssertionError("Expected wrapper_cls to be set")
if hasattr(module, "_wrap_overrides"):
# If module has a _wrap_overrides attribute, we force overriding the
# FSDP config with these attributes for this module. Currently this
# is only used to disable mixed precision for BatchNorm when
# auto_wrapping.
overrides = {**kwargs, **module._wrap_overrides} # type: ignore[arg-type, dict-item]
return wrapper_cls(module, **overrides)
return wrapper_cls(module, **kwargs)
def _recursive_wrap(
module: nn.Module,
auto_wrap_policy: Callable,
wrapper_cls: Callable,
ignored_modules: set[nn.Module],
ignored_params: set[nn.Parameter],
only_wrap_children: bool = False,
**kwargs: Any,
) -> tuple[nn.Module, int]:
"""
Wraps submodules of ``module`` for which ``auto_wrap_policy`` returns
``True`` with ``wrapper_cls``.
Args:
module (nn.Module): Module to recursively wrap.
auto_wrap_policy (Callable): A callable representing a policy that
determines which modules to recursively wrap with ``wrapper_cls``.
ignored_modules (set[torch.nn.Module]): Modules to ignore when
wrapping.
ignored_params (set[torch.nn.Parameter]): Parameters to ignore when
wrapping; these should be the parameters contained in the modules
in ``ignored_modules``.
Returns:
(nn.Module, int):
``module`` after wrapping and the numel recursively wrapped.
"""
if auto_wrap_policy is None:
raise AssertionError("Must specify auto_wrap_policy.")
if wrapper_cls is None:
raise AssertionError("Must specify wrapper_cls")
# Make sure no child is already wrapped.
for _, child in module.named_modules():
if child in ignored_modules:
continue
try:
if isinstance(child, cast(type, wrapper_cls)):
raise AssertionError(
f"Child module {child} is already wrapped by {wrapper_cls}"
)
except TypeError:
# wrapper_cls is a function as opposed to a class type, just bypass above check.
pass
# We count all params, assuming none of them are already wrapped.
nonwrapped_numel = sum(
p.numel() for p in module.parameters() if p not in ignored_params
)
if auto_wrap_policy is None:
raise AssertionError("Expected auto_wrap_policy to be set")
if auto_wrap_policy(module=module, recurse=True, nonwrapped_numel=nonwrapped_numel):
total_wrapped_numel = 0
# Iterate through the children, recursively wrap if necessary
for name, child in module.named_children():
if child in ignored_modules:
continue
wrapped_child, num_wrapped_params = _recursive_wrap(
module=child,
auto_wrap_policy=auto_wrap_policy,
wrapper_cls=wrapper_cls,
ignored_modules=ignored_modules,
ignored_params=ignored_params,
**kwargs,
)
setattr(module, name, wrapped_child)
# Keep track of how many parameters have been wrapped
total_wrapped_numel += num_wrapped_params
# decide if we need to wrap the current module,
# since the left over parameters exceed the number of params to wrap
remainder = nonwrapped_numel - total_wrapped_numel
if not only_wrap_children and auto_wrap_policy(
module=module, recurse=False, nonwrapped_numel=remainder
):
# Leaf node or final wrapping of the remainder both happen here.
return _wrap(module, wrapper_cls, **kwargs), nonwrapped_numel
else:
return module, total_wrapped_numel
return module, 0
class _ConfigAutoWrap:
"""
Helper class to wrap modules based on default config args via a context manager.
See :func:`enable_wrap` for more information.
"""
in_autowrap_context: bool = False # Context flag
wrapper_cls: Callable | None = None # The wrapper class
kwargs: dict[str, Any] = {} # Wrapper's args
def __init__(self, **kwargs: dict[str, Any]):
self.kwargs = kwargs
@staticmethod
def enable_autowrap_context(kwargs: Any) -> None:
if _ConfigAutoWrap.in_autowrap_context:
raise NotImplementedError(
"You are already within an autowrap context and we currently do not supported nested autowrap."
)
_ConfigAutoWrap.in_autowrap_context = True
# Get and save the wrapper cls for the context.
if "wrapper_cls" not in kwargs:
raise AssertionError(
"Expected to pass in wrapper_cls arg into _ConfigAutoWrap."
)
_ConfigAutoWrap.wrapper_cls = cast(Callable, kwargs["wrapper_cls"])
del kwargs["wrapper_cls"]
# Save the rest.
_ConfigAutoWrap.kwargs = kwargs
@staticmethod
def disable_autowrap_context() -> None:
_ConfigAutoWrap.in_autowrap_context = False
_ConfigAutoWrap.wrapper_cls = None
_ConfigAutoWrap.kwargs = {}
def __enter__(self) -> None:
self.enable_autowrap_context(self.kwargs)
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
self.disable_autowrap_context()