# Copyright (c) 2021 PaddlePaddle Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import annotations import copy import logging from typing import TYPE_CHECKING, Any, SupportsIndex import numpy as np import paddle from paddle.distributed import fleet from paddle.distributed.collective import _get_group_map from paddle.distributed.communication.group import is_initialized from paddle.framework import core logger = logging.getLogger(__name__) if TYPE_CHECKING: from collections.abc import Iterable, Sequence from types import TracebackType import numpy.typing as npt from paddle._typing import NestedNumericSequence _NumpyShapeLike = SupportsIndex | Sequence[SupportsIndex] # Use to store the previous and current process mesh _g_previous_process_mesh = None _g_current_process_mesh = None # {shape_process_ids : unique_id} _g_unique_process_mesh_map = {} _g_group_map = {} def get_current_process_mesh(): global _g_current_process_mesh return _g_current_process_mesh def set_current_process_mesh(process_mesh): global _g_previous_process_mesh global _g_current_process_mesh _g_previous_process_mesh = _g_current_process_mesh _g_current_process_mesh = process_mesh def reset_current_process_mesh(): global _g_previous_process_mesh global _g_current_process_mesh _g_current_process_mesh = _g_previous_process_mesh def get_unique_id_for_process_mesh(shape, process_ids): key = f"shape {shape}, process_ids {process_ids}" global _g_unique_process_mesh_map if key in _g_unique_process_mesh_map: unique_id = _g_unique_process_mesh_map[key] else: unique_id = len(_g_unique_process_mesh_map) + 1 _g_unique_process_mesh_map[key] = unique_id return unique_id def retrieve_unique_id_for_process_mesh(shape, process_ids): key = f"shape {shape}, process_ids {process_ids}" global _g_unique_process_mesh_map assert key in _g_unique_process_mesh_map return _g_unique_process_mesh_map[key] def get_unique_process_mesh_map(): global _g_unique_process_mesh_map return _g_unique_process_mesh_map def init_group_by_process_mesh(dim_names): global _g_group_map if dim_names is None: dim_names = [] assert isinstance(dim_names, list), "dim_names must be a list." for dim_name in dim_names: if dim_name in _g_group_map: continue _g_group_map[dim_name] = {} def get_group_map_by_dim_name(dim_name): global _g_group_map if dim_name not in _g_group_map: raise RuntimeError(f'No group found for dim_name {dim_name}') return _g_group_map[dim_name] class ProcessMesh(core.ProcessMesh): """ The `ProcessMesh` object describes the Cartesian topology of the used processes. Args: mesh (list|numpy.array): an n-dimensional array describes the topology of the processes. dim_names (list, optional): the i-th element of this list gives the name of the i-th dimension of the mesh. Examples: .. code-block:: pycon >>> import paddle >>> import paddle.distributed as dist >>> mesh = dist.ProcessMesh([[2, 4, 5], [0, 1, 3]], dim_names=["x", "y"]) >>> assert mesh.shape == [2, 3] >>> assert mesh.process_ids == [2, 4, 5, 0, 1, 3] """ shape: list[int] process_ids: list[int] def __init__( self, mesh: npt.NDArray[Any] | NestedNumericSequence | None = None, dim_names: list[str] | None = None, shape: _NumpyShapeLike | None = None, process_ids: Iterable[Any] | None = None, ) -> None: paddle.base.framework.global_var._in_auto_parallel_ = True # Use shape and process_ids just for compatibility # Users should not use these directly if mesh is None: assert shape is not None assert process_ids is not None mesh = np.array(process_ids).reshape(shape) if not isinstance(mesh, list) and not isinstance(mesh, np.ndarray): raise ValueError( 'The mesh must be an instance of list or np.ndarray.' ) if isinstance(mesh, list): mesh = np.array(mesh) if dim_names is not None and not isinstance(dim_names, list): raise ValueError('The dim_names must be an instance of list.') self._mesh = mesh self._shape = list(self._mesh.shape) self._process_ids = self._mesh.flatten().tolist() assert all(isinstance(p, int) for p in self._process_ids), ( "All elements of the mesh must be integer" ) assert min(self._process_ids) >= 0, ( 'All elements of the mesh must be >= 0.' ) unique_process_ids = set(self._process_ids) assert len(unique_process_ids) == len(self._process_ids), ( 'All elements of the mesh must be unique.' ) if dim_names is not None: assert len(dim_names) == len(self._shape), ( "The length of dims_names must be same as the shape of the mesh." ) self._dim_names = copy.deepcopy(dim_names) else: self._dim_names = ["d" + str(i) for i in range(len(self._shape))] unique_dim_names = set(self._dim_names) assert len(unique_dim_names) == len(self._dim_names), ( f'All dim_names {dim_names} must be unique.' ) # Follow the requirement for using pybind11 core.ProcessMesh.__init__( self, self._shape, self._process_ids, self._dim_names ) # Store all process meshes from .static.dist_context import get_default_distributed_context default_dist_cxt = get_default_distributed_context() default_dist_cxt.add_process_mesh(self) # Add new processes to process group 0 from .static.process_group import get_process_group pg0 = get_process_group(0) pg0.add_ranks(self.process_ids) # Unique Mesh Id self._unique_id = get_unique_id_for_process_mesh( self._shape, self._process_ids ) init_group_by_process_mesh(self._dim_names) @property def mesh(self) -> npt.NDArray[Any]: """ Get the underlying mesh of ProcessMesh. """ return self._mesh @property def dim_names(self) -> list[str]: """ Get the underlying dimension names of ProcessMesh. """ return self._dim_names @property def unique_id(self) -> int: """ Get the unique id of ProcessMesh. NOTE Unique id only take process_ids and shape into account. Different ProcessMesh with same process_ids and shape have same unique id. """ return self._unique_id def __getitem__( self, index: slice | tuple[slice, ...] | str | SupportsIndex ) -> ProcessMesh: if isinstance(index, tuple): new_dim_names = [] for i, item in enumerate(index): if isinstance(item, slice): new_dim_names.append(self._dim_names[i]) new_mesh = self._mesh[index] if new_mesh.shape: return ProcessMesh(new_mesh, new_dim_names) else: # Wrap a scalar into a list but without dim_names return ProcessMesh([new_mesh]) elif isinstance(index, slice): new_mesh = self._mesh[index] new_dim_names = self._dim_names return ProcessMesh(new_mesh, new_dim_names) elif isinstance(index, str): return self.get_submesh_with_dim(index) else: new_mesh = self._mesh[index] new_dim_names = self._dim_names[1:] if new_mesh.shape: return ProcessMesh(new_mesh, new_dim_names) else: return ProcessMesh([new_mesh]) def get_rank_by_dim_and_process_id( self, dim: str | int, process_id: int ) -> int: # do some check if process_id not in self._process_ids: # -1 means invalid rank return -1 if dim is None: # if dim is None, all process's rank is 0 return 0 if isinstance(dim, int): dim_name = self._dim_names[dim] elif isinstance(dim, str): dim_name = dim else: raise ValueError("dim must be a string or an integer.") dim_name_index = self._dim_names.index(dim_name) rank_index = np.where(self._mesh == process_id)[dim_name_index] return int(rank_index.item()) def get_dim_size(self, dim: str | int) -> int: if dim is None: return 1 if isinstance(dim, int): dim_name = self._dim_names[dim] elif isinstance(dim, str): dim_name = dim else: raise ValueError("dim must be a string or an integer.") assert dim_name in self._dim_names return self._shape[self._dim_names.index(dim_name)] def get_mesh_with_dim( self, dim_name: str, index: slice | tuple[slice, ...] | SupportsIndex | None = None, ) -> ProcessMesh: assert dim_name in self._dim_names, ( f'{dim_name} is not a valid dim name.' ) index_axis = self._dim_names.index(dim_name) new_order = [index_axis] + [ i for i in range(len(self._dim_names)) if i != index_axis ] new_dim_names = [dim_name] + [ dim for dim in self._dim_names if dim != dim_name ] new_mesh = self._mesh.transpose(new_order) if index is not None: if len(new_dim_names[1:]) > 0: return ProcessMesh(new_mesh[index], new_dim_names[1:]) # satisfy the single dimension mesh case else: return ProcessMesh([new_mesh[index]], new_dim_names) return ProcessMesh(new_mesh, new_dim_names) def get_submesh_with_dim( self, dim_name: str, ) -> ProcessMesh: """ Slice the current ProcessMesh based on the dim_name given to create a submesh with single dimension remained. Args: dim_name (str): the name of the mesh dimension of the ProcessMesh to create the submesh for. Returns: A :class:`ProcessMesh` object Examples: .. code-block:: pycon >>> import paddle >>> import paddle.distributed as dist >>> dist.init_parallel_env() >>> mesh_2d = dist.ProcessMesh([[0, 1, 2, 3], [4, 5, 6, 7]], dim_names=["dp", "tp"]) >>> dp_mesh = mesh_2d.get_submesh_with_dim("dp") >>> # ProcessMesh:([0, 4]) on rank 0, 4 >>> # ProcessMesh:([1, 5]) on rank 1, 5 >>> # ProcessMesh:([2, 6]) on rank 2, 6 >>> # ProcessMesh:([3, 7]) on rank 3, 7 >>> tp_mesh = mesh_2d.get_submesh_with_dim("tp") >>> # ProcessMesh:([0, 1, 2, 3]) on rank 0, 1, 2, 3 >>> # ProcessMesh:([4, 5, 6, 7]) on rank 4, 5, 6, 7 >>> mesh_3d = dist.ProcessMesh([[[0, 1], [2, 3]], [[4, 5], [6, 7]]], dim_names=["pp", "dp", "tp"]) >>> pp_mesh = mesh_3d.get_submesh_with_dim("pp") >>> # ProcessMesh:([0, 4]) on rank 0, 4 >>> # ProcessMesh:([1, 5]) on rank 1, 5 >>> # ProcessMesh:([2, 6]) on rank 2, 6 >>> # ProcessMesh:([3, 7]) on rank 3, 7 >>> dp_mesh = mesh_3d.get_submesh_with_dim("dp") >>> # ProcessMesh:([0, 2]) on rank 0, 2 >>> # ProcessMesh:([1, 3]) on rank 1, 3 >>> # ProcessMesh:([4, 6]) on rank 4, 6 >>> # ProcessMesh:([5, 7]) on rank 5, 7 >>> tp_mesh = mesh_3d.get_submesh_with_dim("tp") >>> # ProcessMesh:([0, 1]) on rank 0, 1 >>> # ProcessMesh:([2, 3]) on rank 2, 3 >>> # ProcessMesh:([4, 5]) on rank 4, 5 >>> # ProcessMesh:([6, 7]) on rank 6, 7 """ reorder_mesh = self.get_mesh_with_dim(dim_name)._mesh.reshape( self.get_dim_size(dim_name), -1 ) curr_rank = paddle.distributed.get_rank() if curr_rank not in self._process_ids: logger.warning( f"Rank {curr_rank} is not in the process mesh, just return None" ) return None # find curr_rank in reorder_mesh, get the column index col_idx = np.argmax(reorder_mesh == curr_rank) % reorder_mesh.shape[-1] sub_mesh = ProcessMesh(reorder_mesh[:, col_idx], [dim_name]) return sub_mesh def _get_group( self, dim_name: str | None = None, ) -> paddle.distributed.communication.group.Group: """ """ assert is_initialized(), ( "When you want to get a group from the ProcessMesh." " Call paddle.distributed.init_parallel_env first " "to initialize the distributed environment." ) if len(self._dim_names) > 1 and dim_name is None: raise ValueError( "You should specify the dim_name when the ProcessMesh has more than one dimensions." ) reorder_mesh = self.get_mesh_with_dim(dim_name)._mesh.reshape( self.get_dim_size(dim_name), -1 ) curr_rank = paddle.distributed.get_rank() groups = get_group_map_by_dim_name(dim_name) for rank in self._process_ids: col_idx = np.argmax(reorder_mesh == rank) % reorder_mesh.shape[-1] if col_idx in groups: continue pg = paddle.distributed.new_group(reorder_mesh[:, col_idx]) groups[col_idx] = pg cur_col_idx = ( np.argmax(reorder_mesh == curr_rank) % reorder_mesh.shape[-1] ) return groups[cur_col_idx] def get_group( self, dim_name: str | None = None, ) -> paddle.distributed.communication.group.Group: """ Convert single dimension ProcessMesh to the corresponding Group. Args: dim_name (str, optional): it can be the name of the mesh dimension. Default is None. Returns: A :class:`Group` object. """ # check parallel environment whether ready or not assert is_initialized(), ( "When you want to get a group from the ProcessMesh." " Call paddle.distributed.init_parallel_env first " "to initialize the distributed environment." ) if len(self._dim_names) > 1 and dim_name is None: raise ValueError( "You should specify the dim_name when the ProcessMesh has more than one dimensions." ) if len(self._dim_names) == 1: if dim_name is not None and dim_name not in self._dim_names: raise ValueError( f"{dim_name} not in the dimension names {self._dim_names}" ) else: if hasattr(fleet.fleet, "_hcg"): hcg = fleet.get_hybrid_communicate_group() if hcg is not None: parallel_group_map = { "pp": hcg.get_pipe_parallel_group, "dp": hcg.get_data_parallel_group, "mp": hcg.get_model_parallel_group, "sep": hcg.get_sep_parallel_group, "sharding": hcg.get_sharding_parallel_group, } if dim_name not in parallel_group_map: raise ValueError( f"{dim_name} is not a valid dim name." ) return parallel_group_map[dim_name]() group_map = _get_group_map() for group in group_map.values(): if set(group.ranks) == set(self._process_ids): return group return paddle.distributed.new_group(self._process_ids) else: if dim_name not in self._dim_names: raise ValueError( f"{dim_name} not in the dimension names {self._dim_names}" ) sub_mesh = self.get_submesh_with_dim(dim_name) return sub_mesh.get_group(dim_name) def __enter__(self) -> None: set_current_process_mesh(self) default_prog = paddle.static.default_main_program() cur_block = default_prog.current_block() self._old_var_names = list(cur_block.vars.keys()) self._old_op_size = len(cur_block.ops) def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: from .static.dist_op import DistributedOperator from .static.dist_tensor import DistributedTensor default_prog = paddle.static.default_main_program() cur_block = default_prog.current_block() new_var_names = list(cur_block.vars.keys()) new_op_size = len(cur_block.ops) from .static.dist_context import get_default_distributed_context default_dist_ctx = get_default_distributed_context() for name in new_var_names: if name not in self._old_var_names: tensor = cur_block.vars[name] dist_tensor = default_dist_ctx.get_dist_tensor_for_program( tensor ) if dist_tensor is None: dist_tensor = DistributedTensor(cur_block.vars[name]) dist_tensor.dist_attr.process_mesh = self dist_tensor.dist_attr.mark_annotated("process_mesh") default_dist_ctx.add_dist_tensor_for_program(dist_tensor) else: if dist_tensor.dist_attr.process_mesh is None: dist_tensor.dist_attr.process_mesh = self dist_tensor.dist_attr.mark_annotated("process_mesh") for idx in range(self._old_op_size, new_op_size): op = cur_block.ops[idx] dist_op = default_dist_ctx.get_dist_op_for_program(op) if dist_op is None: dist_op = DistributedOperator(op) dist_op.dist_attr.process_mesh = self dist_op.dist_attr.mark_annotated("process_mesh") default_dist_ctx.add_dist_op_for_program(dist_op) else: if dist_op.dist_attr.process_mesh is None: dist_op.dist_attr.process_mesh = self dist_op.dist_attr.mark_annotated("process_mesh") reset_current_process_mesh() def __deepcopy__(self, memo: Any) -> ProcessMesh: if id(self) in memo: return memo[id(self)] new_process_mesh = ProcessMesh(np.array(self.mesh), self.dim_names) memo[id(self)] = new_process_mesh return new_process_mesh def __eq__(self, other: ProcessMesh | core.ProcessMesh) -> bool: if not isinstance(other, (ProcessMesh, core.ProcessMesh)): return False if self.shape != other.shape or self.process_ids != other.process_ids: return False return True def __ne__(self, other: ProcessMesh | core.ProcessMesh) -> None: return not self.__eq__(other) def __str__(self) -> str: str = f"shape {self.shape}, process_ids {self.process_ids}, dim_names {self.dim_names}" return str def __hash__(self) -> int: return super().__hash__() def compute_compatible_process_mesh(process_mesh_list): """Compute the compatible process mesh given a list of process meshes.""" if not process_mesh_list: return None def _compute_compatible_process_mesh_of_two(pm1, pm2): if pm1 is None: return True, pm2 if pm2 is None: return True, pm1 if pm1 == pm2: return True, pm1 if pm1.process_ids == pm2.process_ids: if len(pm1.shape) >= len(pm2.shape): return True, pm1 else: return True, pm2 process_set1 = set(pm1.process_ids) process_set2 = set(pm2.process_ids) if process_set1.issubset(process_set2): return True, pm2 if process_set2.issubset(process_set1): return True, pm1 return False, None compatible_result = None for process_mesh in process_mesh_list: compatible, compatible_result = _compute_compatible_process_mesh_of_two( compatible_result, process_mesh ) if not compatible: return None return copy.deepcopy(compatible_result) def merge_process_meshes(process_meshes): """Merge a list of process meshes.""" merged_process_mesh = None merged_process_ids = set() for process_mesh in process_meshes: if process_mesh is not None: process_ids = set(process_mesh.process_ids) merged_process_ids = merged_process_ids.union(process_ids) if len(merged_process_ids) != 0: merged_process_mesh = ProcessMesh(list(merged_process_ids)) return merged_process_mesh