Skip to content
11 changes: 11 additions & 0 deletions src/diffusers/hooks/context_parallel.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,17 @@ def _all_gather_anything(tensor: torch.Tensor, dim: int, group: dist.device_mesh
rank_dim = shape[dim]
gather_dims = gather_size_by_comm(rank_dim, group)

max_dim = max(gather_dims)
if any(d != max_dim for d in gather_dims) and tensor.device.type in ("neuron", "tpu"):
# Neuron and TPU all-gathers require every rank to send the same size, so pad each chunk to the longest one,
# gather, and trim each chunk back to its real length.
pad_shape = list(shape)
pad_shape[dim] = max_dim - rank_dim
padded = torch.cat([tensor, tensor.new_zeros(pad_shape)], dim=dim)
gathered_tensors = [torch.empty_like(padded) for _ in range(world_size)]
dist.all_gather(gathered_tensors, padded, group=group)
return torch.cat([t.narrow(dim, 0, d) for t, d in zip(gathered_tensors, gather_dims)], dim=dim)

gather_shapes = _fill_gather_shapes(tuple(shape), tuple(gather_dims), dim, world_size)

gathered_tensors = [torch.empty(shape, device=tensor.device, dtype=tensor.dtype) for shape in gather_shapes]
Expand Down
43 changes: 38 additions & 5 deletions src/diffusers/models/_modeling_parallel.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,12 @@ class ParallelConfig:
"""
Configuration for applying different parallelisms.

Both may be set at once. The two are then applied over one device mesh with a dimension each — `("tp", "ring",
"ulysses")`, built by `enable_parallelism` — so their collectives stay in separate process groups. This is what
lets a model too large for one device (TP shards the weights) also run a sequence too long for one device's
attention scratchpad (CP splits the sequence): TP alone cannot divide the sequence, and CP alone cannot divide the
weights.

Args:
context_parallel_config (`ContextParallelConfig`, *optional*):
Configuration for context parallelism.
Expand All @@ -216,12 +222,17 @@ class ParallelConfig:
_mesh: torch.distributed.device_mesh.DeviceMesh = None

def __post_init__(self):
if self.context_parallel_config is not None and self.tensor_parallel_config is not None:
if self.context_parallel_config is None and self.tensor_parallel_config is None:
raise ValueError(
"Combining context parallelism and tensor parallelism in a single `ParallelConfig` is not supported. "
"Please specify only one of `context_parallel_config` or `tensor_parallel_config`."
"A `ParallelConfig` must specify at least one of `context_parallel_config` or "
"`tensor_parallel_config`."
)

@property
def _is_combined(self) -> bool:
"""Whether both context and tensor parallelism are requested, i.e. they must share one mesh."""
return self.context_parallel_config is not None and self.tensor_parallel_config is not None

def setup(
self,
rank: int,
Expand All @@ -234,10 +245,32 @@ def setup(
self._world_size = world_size
self._device = device
self._mesh = mesh

# Context and tensor parallelism compose because they cut the model along different axes: CP splits the
# sequence (and, under Ulysses, trades sequence for heads inside attention) while TP shards the Linear
# weights. Composing them means giving each its own mesh *dimension*, so that every collective one issues
# stays inside its own process group: TP's all-reduce must not reach a rank holding a different sequence
# chunk, and CP's sequence all-gather must not reach a rank holding a different weight shard.
cp_mesh = tp_mesh = mesh
if self._is_combined:
dim_names = mesh.mesh_dim_names if mesh is not None else None
missing = [d for d in ("ring", "ulysses", "tp") if dim_names is None or d not in dim_names]
if missing:
raise ValueError(
f"Combining context and tensor parallelism requires a device mesh with 'ring', 'ulysses' and "
f"'tp' dimensions, but {missing} {'is' if len(missing) == 1 else 'are'} missing (got "
f"{dim_names}). `enable_parallelism` builds this mesh from `ring_degree`/`ulysses_degree`/"
f"`tp_degree`; if you build it yourself, set `mesh=` on one of the two configs."
)
# `ContextParallelConfig.setup` slices ("ring", "ulysses") off whatever mesh it is handed, so it takes
# the full mesh. TP instead needs its own 1-D submesh: `parallelize_module` shards a weight over every
# rank of the mesh it is given, so handing it the 3-D mesh would shard over the CP ranks as well.
tp_mesh = mesh["tp"]

if self.context_parallel_config is not None:
self.context_parallel_config.setup(rank, world_size, device, mesh)
self.context_parallel_config.setup(rank, world_size, device, cp_mesh)
if self.tensor_parallel_config is not None:
self.tensor_parallel_config.setup(rank, world_size, device, mesh)
self.tensor_parallel_config.setup(rank, world_size, device, tp_mesh)


@dataclass(frozen=True)
Expand Down
91 changes: 77 additions & 14 deletions src/diffusers/models/modeling_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1546,6 +1546,9 @@ def from_pretrained(cls, pretrained_model_name_or_path: str | os.PathLike | None
# was recorded by `_resolve_parallel_config` before loading.
from ..hooks.tensor_parallel import apply_tensor_parallel

# A config that also asks for context parallelism gets its hooks here, in the same order as
# `enable_parallelism`, which is no longer callable now that the weights are sharded.
model._apply_context_parallel(parallel_config)
apply_tensor_parallel(model, tp_config, cls._tp_plan, weights_already_sharded=True)
else:
model.enable_parallelism(config=parallel_config)
Expand Down Expand Up @@ -1720,15 +1723,33 @@ def _resolve_parallel_config(
device = torch.device(device_type, rank % device_module.device_count())

mesh = None
if config.context_parallel_config is not None:
cp_config = config.context_parallel_config
cp_config, tp_config = config.context_parallel_config, config.tensor_parallel_config
if cp_config is not None and tp_config is not None:
# A single mesh spanning both parallelisms, with the context parallel dimensions varying fastest: a
# CP group is then a contiguous run of ranks and a TP group takes one rank out of each run.
#
# That order is a default, not a universal optimum. Some accelerator runtimes only accept contiguous
# replica groups for all-to-all -- which Ulysses needs -- while tolerating strided groups for
# all-reduce, which is all TP needs; since only one axis of a 2-D mesh can be contiguous, Ulysses
# gets it. On multi-node CUDA the opposite order is usually preferable, because TP is the most
# bandwidth-hungry collective and wants to stay within one NVLink domain. Pass `mesh=` on either
# config to choose the layout yourself.
mesh = (
cp_config.mesh
or tp_config.mesh
or torch.distributed.device_mesh.init_device_mesh(
device_type=device_type,
mesh_shape=(tp_config.tp_degree, *cp_config.mesh_shape),
mesh_dim_names=("tp", *cp_config.mesh_dim_names),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sayakpaul what's your take on the default order of the mesh? If Neuron runtime requests Ulysses mesh to be contiguous maybe we should set default value according to the device?

)
)
elif cp_config is not None:
mesh = cp_config.mesh or torch.distributed.device_mesh.init_device_mesh(
device_type=device_type,
mesh_shape=cp_config.mesh_shape,
mesh_dim_names=cp_config.mesh_dim_names,
)
elif config.tensor_parallel_config is not None:
tp_config = config.tensor_parallel_config
elif tp_config is not None:
mesh = tp_config.mesh or torch.distributed.device_mesh.init_device_mesh(
device_type=device_type,
mesh_shape=(tp_config.tp_degree,),
Expand All @@ -1737,6 +1758,32 @@ def _resolve_parallel_config(

# `config.setup()` records the mesh resolved above onto the config; see `ParallelConfig.setup`.
config.setup(rank, world_size, device, mesh=mesh)

# Validate the combination up front — after `setup`, which resolves `_tp_degree` from the mesh, but before
# anything is recorded on the model or applied to it. CP hooks are applied before TP, so a check left to the
# TP branch would raise on a model already carrying CP hooks: half-parallelised, and not usable.
if cp_config is not None and tp_config is not None:
tp_degree = tp_config._tp_degree
requested = cp_config.ring_degree * cp_config.ulysses_degree * tp_degree
if requested > world_size:
raise ValueError(
f"Combining context and tensor parallelism needs `ring_degree` ({cp_config.ring_degree}) * "
f"`ulysses_degree` ({cp_config.ulysses_degree}) * `tp_degree` ({tp_degree}) = {requested} "
f"devices, which exceeds the world size ({world_size})."
)
num_heads = getattr(self.config, "num_attention_heads", None)
divisor = tp_degree * cp_config.ulysses_degree
if num_heads is not None and not cp_config.ulysses_anything and num_heads % divisor != 0:
# Ulysses trades sequence for heads inside attention (`SeqAllToAllDim` scatters over dim 2), and it
# only ever sees the heads TP left on this rank, so the count has to survive both splits.
raise ValueError(
f"Combining tensor parallelism (`tp_degree`={tp_degree}) with Ulysses context parallelism "
f"(`ulysses_degree`={cp_config.ulysses_degree}) requires the number of attention heads "
f"({num_heads}) to be divisible by their product ({divisor}): TP shards the heads first, and "
f"Ulysses splits what is left on each rank. Pass `ulysses_anything=True` to pad the head "
f"dimension instead, or pick degrees whose product divides {num_heads}."
)

self._parallel_config = config
return config

Expand All @@ -1750,11 +1797,6 @@ def enable_parallelism(
"`enable_parallelism` is an experimental feature. The API may change in the future and breaking changes may be introduced at any time without warning."
)

from ..hooks.context_parallel import apply_context_parallel
from .attention import AttentionModuleMixin
from .attention_dispatch import AttentionBackendName, _AttentionBackendRegistry
from .attention_processor import Attention, MochiAttention

if self._parallel_config is not None:
raise RuntimeError(
f"Parallelism is already applied to this {self.__class__.__name__}. `enable_parallelism` cannot be "
Expand All @@ -1781,6 +1823,32 @@ def enable_parallelism(

config = self._resolve_parallel_config(config)

self._apply_context_parallel(config, cp_plan)

if config.tensor_parallel_config is not None:
from ..hooks.tensor_parallel import apply_tensor_parallel

apply_tensor_parallel(self, config.tensor_parallel_config, self._tp_plan)

def _apply_context_parallel(
self,
config: ParallelConfig,
cp_plan: dict[str, ContextParallelModelPlan] | None = None,
):
"""Install the context-parallel hooks `config` asks for, if any, and hand it to the attention processors.

Split out of `enable_parallelism` for the same reason as `_resolve_parallel_config`: `from_pretrained(...,
parallel_config=...)` shards the weights while reading them, so it cannot call `enable_parallelism`
afterwards, yet it still has to apply these hooks when the config asks for context parallelism.
"""
from ..hooks.context_parallel import apply_context_parallel
from .attention import AttentionModuleMixin
from .attention_dispatch import AttentionBackendName, _AttentionBackendRegistry
from .attention_processor import Attention, MochiAttention

if config.context_parallel_config is None:
return

attention_classes = (Attention, MochiAttention, AttentionModuleMixin)

if config.context_parallel_config is not None:
Expand Down Expand Up @@ -1830,11 +1898,6 @@ def enable_parallelism(
cp_plan = cp_plan if cp_plan is not None else self._cp_plan
apply_context_parallel(self, config.context_parallel_config, cp_plan)

if config.tensor_parallel_config is not None:
from ..hooks.tensor_parallel import apply_tensor_parallel

apply_tensor_parallel(self, config.tensor_parallel_config, self._tp_plan)

@classmethod
def _load_pretrained_model(
cls,
Expand Down
Loading
Loading