# Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. # # This source code is licensed under the BSD license found in the # LICENSE file in the root directory of this source tree. from collections import OrderedDict from dataclasses import dataclass, field import itertools import threading from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple, Union import warnings import torch from torch import Tensor, nn from fairscale.nn.model_parallel import get_pipeline_parallel_group from . import microbatch from .async_pipeline import AsyncPipeline from .async_schedule import Invocation, Location, ModuleWrapper from .batchnorm import DeferredBatchNorm from .skip.layout import SkipLayout from .skip.skippable import Skippable from .types import LazyModule if TYPE_CHECKING: Module = nn.Module[TensorOrTensors] NamedModules = OrderedDict[str, Module] else: Module = nn.Module NamedModules = OrderedDict Tensors = Tuple[Tensor, ...] TensorOrTensors = Union[Tensor, Tensors] @dataclass class PartitionInfo: location: Location modules: "OrderedDict[str, nn.Module]" invocations: List[Invocation] = field(default_factory=list) def __len__(self) -> int: return len(self.modules) def verify_module(module: Union[nn.Sequential, List[LazyModule]]) -> None: if len(set(map(id, module))) != len(module): raise ValueError("module with duplicate children is not supported") def check_balance(module: Union[nn.Sequential, List[LazyModule]], balance: List[int]) -> None: if len(module) != sum(balance): raise ValueError( f"module and sum of balance have different length (module: {len(module)}, sum of balance: {sum(balance)})" ) if any(x <= 0 for x in balance): raise ValueError(f"all balance numbers must be positive integer (balance: {balance})") MOVING_DENIED = TypeError("denied to move parameters and buffers, because Pipe should manage device placement") class AsyncPipe(Module): """Wraps an arbitrary :class:`nn.Sequential ` module to train on Pipe_. If the module requires lots of memory, Pipe will be very efficient. Pipe combines pipeline parallelism with checkpointing to reduce peak memory required to train while minimizing device under-utilization. You should determine the balance when defining a :class:`AsyncPipe` module, as balancing will not be done automatically. The module will be partitioned into multiple devices according to the given balance. You may rely on heuristics to find your own optimal configuration. Args: module (torch.nn.Sequential): sequential module to be parallelized balance (ints): list of number of layers in each partition Keyword Args: group (ProcessGroup): the process group that all pipeline stages are a member of. Defaults to `get_pipeline_parallel_group()` worker_map (Dict[int, str]): a map from worker name (the first argument to `torch.distributed.rpc.init_rpc`) to global rank (i.e. `torch.distributed.get_rank()`) needed in order for pipeline stages to communicate with each other input_device (device): the device on which tensors should be located before being passed to the first module in a given pipeline stage chunks (int): number of micro-batches (default: ``1``) checkpoint (str): when to enable checkpointing, one of ``'always'``, ``'except_last'``, or ``'never'`` (default: ``'except_last'``) deferred_batch_norm (bool): whether to use deferred BatchNorm moving statistics (default: :data:`False`, see :class:`DeferredBatchNorm` for more details) Raises: TypeError: the module is not a :class:`nn.Sequential `. ValueError: invalid arguments, or wrong balance IndexError: the number of devices is fewer than the number of partitions. """ #: The number of layers in each partition. balance: List[int] = [] # ^^ # The default value [] required for Sphinx's autoattribute. #: The devices mapped to each partition. #: #: ``devices[-1]`` refers to the device of the last partition, which means #: it is the output device. Probably, you need to use it to transfer the #: target to calculate the loss without a device mismatch #: :exc:`RuntimeError`. For example:: #: #: out_device = pipe.devices[-1] #: #: for input, target in loader: #: target = target.to(out_device, non_blocking=True) #: output = pipe(input) #: loss = F.cross_entropy(output, target) #: #: The number of micro-batches. chunks: int = 1 #: The checkpoint mode to determine when to enable checkpointing. It is one #: of ``'always'``, ``'except_last'``, or ``'never'``. checkpoint: str = "except_last" def __init__( self, module: Union[nn.Sequential, List[LazyModule]], balance: Iterable[int], *, group: Optional[torch.distributed.ProcessGroup] = None, worker_map: Optional[Dict[int, str]] = None, input_device: Union[None, int, str, torch.device] = None, chunks: int = chunks, checkpoint: str = checkpoint, deferred_batch_norm: bool = False, ) -> None: super().__init__() if chunks <= 0: raise ValueError("number of chunks must be positive integer") if checkpoint not in ["always", "except_last", "never"]: raise ValueError("checkpoint is not one of 'always', 'except_last', or 'never'") self.balance = list(balance) verify_module(module) check_balance(module, self.balance) self.chunks = chunks self.checkpoint = checkpoint self.pipeline: Optional[AsyncPipeline] self.lock = threading.Lock() self.worker_map = worker_map self.input_device = input_device self.group: torch.distributed.ProcessGroup if group is None: self.group = get_pipeline_parallel_group() else: self.group = group if self.group.size() < len(self.balance): raise IndexError( f"too few ranks to hold given partitions (ranks: {self.group.size()}, partitions:" f" {len(self.balance)})" ) self._skip_layout = SkipLayout(len(module), {}) # FIXME(tom) rank = self.group.rank() self.final_stage = rank == len(self.balance) - 1 if rank >= len(self.balance): warnings.warn("More ranks than partitions, some ranks unused") self.partitions: List[ModuleWrapper] = [] self.pipeline = None # TODO(msb) remove this hack self.partition = None else: self.partitions = self.instantiate_partition(module, self.balance, self.group) if deferred_batch_norm: for part in self.partitions: part.module = DeferredBatchNorm.convert_deferred_batch_norm(part.module, chunks) for name, part in enumerate(self.partitions): self.add_module(str(name), part.module) self.create_pipeline() # TODO(msb) remove this hack self.partition = self.partitions[0].module del module def create_pipeline(self) -> None: # The micro-batch index where the checkpointing stops. checkpoint_stop = {"always": self.chunks, "except_last": self.chunks - 1, "never": 0}[self.checkpoint] self.pipeline = AsyncPipeline( self.partitions, self._skip_layout, checkpoint_stop, group=self.group, worker_map=self.worker_map, input_device=self.input_device, final_stage=self.final_stage, ) def instantiate_partition( self, module: Union[nn.Sequential, List[LazyModule]], balance: List[int], group: torch.distributed.ProcessGroup, ) -> List[ModuleWrapper]: layers: NamedModules = OrderedDict() def maybe_realize(layer: Any) -> nn.Module: if isinstance(layer, nn.Module): return layer elif callable(layer): return layer() else: raise TypeError(f"layer must be nn.Module or callable, is {type(layer)}") def iterate_module(module: Union[nn.Sequential, list]) -> Iterable[Tuple[Any, nn.Module]]: if isinstance(module, nn.Sequential): yield from module.named_children() else: yield from ((str(k), v) for k, v in enumerate(module)) module_ids = list(map(id, module)) index_of_first_use = [module_ids.index(x) for x in module_ids] locations: List[Location] = [] module_iter = enumerate(iterate_module(module)) partitions: List[List[PartitionInfo]] = [] for bi, b in enumerate(balance): modules_for_rank: List[PartitionInfo] = [] current_module: OrderedDict[str, nn.Module] = OrderedDict() def current_location() -> Location: return Location(bi, len(modules_for_rank)) def append_module(mod: "OrderedDict[str, nn.Module]") -> None: modules_for_rank.append(PartitionInfo(current_location(), mod)) while sum(map(len, modules_for_rank)) + len(current_module) < b: module_index, (name, layer) = next(module_iter) if index_of_first_use[module_index] != module_index: # Subsequent reuse of a module locations.append(locations[index_of_first_use[module_index]]) continue is_reused = index_of_first_use.count(index_of_first_use[module_index]) > 1 if is_reused and len(current_module) > 0: append_module(current_module) current_module = OrderedDict() current_module[str(name)] = layer locations.append(current_location()) if is_reused: append_module(current_module) current_module = OrderedDict() if len(current_module) > 0: append_module(current_module) partitions.append(modules_for_rank) filtered_locations: List[Optional[Location]] = [loc for loc, _ in itertools.groupby(locations)] filtered_locations.append(None) for i in range(len(filtered_locations) - 1): loc = filtered_locations[i] assert loc if i == 0: inv = Invocation(i, loc, None, filtered_locations[i + 1]) else: inv = Invocation(i, loc, filtered_locations[i - 1], filtered_locations[i + 1]) partitions[loc.stage][loc.index].invocations.append(inv) invocations = enumerate(iterate_module(module)) partition = partitions[group.rank()] result: List[ModuleWrapper] = [] for partition_info in partition: wrapper = ModuleWrapper( nn.Sequential(OrderedDict((k, maybe_realize(m)) for k, m in partition_info.modules.items())), partition_info.location, partition_info.invocations, ) if not isinstance(module, nn.Sequential): for layer in wrapper.module: if isinstance(layer, Skippable): raise ValueError("Can't use Skippable layers with multi-process pipe and lazy construction") result.append(wrapper) return result def __len__(self) -> int: """Counts the length of the underlying sequential module.""" return sum(len(p) for p in self.partitions) def __getitem__(self, index: int) -> nn.Module: """Gets a layer in the underlying sequential module.""" partitions: List[Any] partitions = self.partitions if index < 0: partitions = partitions[::-1] for partition in partitions: try: if isinstance(partition, ModuleWrapper): return partition.module[index] else: return partition[index] except IndexError: pass shift = len(partition) if index < 0: index += shift else: index -= shift raise IndexError def __iter__(self) -> Iterable[nn.Module]: """Iterates over children of the underlying sequential module.""" for partition in self.partitions: yield from partition.module def forward(self, input: TensorOrTensors, *, event=None) -> TensorOrTensors: # type: ignore """:class:`AsyncPipe` is a fairly transparent module wrapper. It doesn't modify the input and output signature of the underlying module. But there's type restriction. Input and output have to be a :class:`~torch.Tensor` or a tuple of tensors. This restriction is applied at partition boundaries too. Args: input (torch.Tensor or tensors): input mini-batch Returns: tensor or tensors: output mini-batch Raises: TypeError: input is not a tensor or tensors. """ microbatch.check(input) if not self.pipeline: # No pipeline is not illegal, more ranks than partitions return input # Divide a mini-batch into micro-batches. batches = microbatch.scatter(input, self.chunks) # Run pipeline parallelism. with self.lock: self.pipeline.run(self.training, batches, event) if self.final_stage: output = microbatch.gather(batches) else: # Don't merge micro-batches to avoid unnecessary edges in autograd # graph # FIXME(tom) should figure out a proper type here output = batches # type: ignore return output def back_helper(self, output: List[microbatch.Batch]) -> None: if self.final_stage: raise ValueError("back_helper should only be called on non-final stages") if self.pipeline: self.pipeline.back_helper(output)