Repository navigation
[feat] Tensor parallelism for MiniMax-H3 - #14609
JingyaHuang wants to merge 28 commits into
Conversation
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
|
Tried this branch ( Setup. Numbers. 30 model evaluations in 354.1s. The first evaluation is 71s (kernel compile/warmup) and steady state is 9.30 s/step. For scale: our hand-written NeuronCore plugin for H3 does the same canvas at 2.90 s/step on 32 cores with CP=4, i.e. 92.8 core-seconds/step, against 74.4 core-seconds/step here. So the native path is not costing per-core throughput at all — it comes out ahead, which is expected since CP=4 has scaling loss that TP=8 doesn't. Its only limit is that 8 of the 64 cores are usable (see the last section). Bug: non-persistent buffers stay on CPU in the safetensors TP path
The old Suggested fix — hoist your existing loop into a helper so both sharded-load paths share it. This also folds the three copies of the Neuron device resolution into one: # src/diffusers/hooks/tensor_parallel.py (next to resolve_tp_shard_specs)
def tp_device(tp_config: TensorParallelConfig) -> torch.device:
"""The device this rank's shards belong on.
`TensorParallelConfig._device` is derived from the default accelerator, which is not meaningful on
Neuron: the core a rank owns is only known after `init_process_group`.
"""
if tp_config._mesh.device_type == "neuron":
return torch.neuron.current_device()
return tp_config._device
def move_non_persistent_buffers(model: torch.nn.Module, tp_config: TensorParallelConfig) -> None:
"""Place the buffers that no loading path can have placed.
Non-persistent buffers are absent from both the state dict and the checkpoint, so neither the
per-rank safetensors reader nor `dcp.load` ever sees them, and `init_empty_weights` leaves them
as real CPU tensors.
"""
from torch.distributed.tensor import DTensor
device = tp_device(tp_config)
for name, buffer in model.named_buffers():
if buffer.device != device and not isinstance(buffer, DTensor):
module_path, _, buffer_name = name.rpartition(".")
module = model.get_submodule(module_path) if module_path else model
module._buffers[buffer_name] = buffer.to(device)--- a/src/diffusers/models/modeling_utils.py
+++ b/src/diffusers/models/modeling_utils.py
@@ -1653,8 +1653,9 @@ class ModelMixin(torch.nn.Module, PushToHubMixin):
if tp_shard_specs is not None:
# The weights are already sharded, so this only registers the forward hooks. `_parallel_config`
# was recorded by `_resolve_parallel_config` before loading.
- from ..hooks.tensor_parallel import apply_tensor_parallel
+ from ..hooks.tensor_parallel import apply_tensor_parallel, move_non_persistent_buffers
+ move_non_persistent_buffers(model, tp_config)
apply_tensor_parallel(model, tp_config, cls._tp_plan, weights_already_sharded=True)--- a/src/diffusers/models/model_loading_utils.py
+++ b/src/diffusers/models/model_loading_utils.py
@@ -411,15 +411,10 @@ def _load_shard_file_tp(
- from ..hooks.tensor_parallel import _local_shard
+ from ..hooks.tensor_parallel import _local_shard, tp_device
tp_mesh = tp_config._mesh
- # `TensorParallelConfig._device` is derived from the default accelerator, which is not meaningful on
- # Neuron; resolve it the way the Neuron pre-shard backend does.
- if tp_mesh.device_type == "neuron":
- device = torch.neuron.current_device()
- else:
- device = tp_config._device
+ device = tp_device(tp_config)and in I ran this patch on the box: the explicit buffer sweep I had been carrying in my own script no longer finds anything to move, and 768p completes unchanged. Happy to open it as a PR against your branch if that's easier. Where the ceiling is, and a possible next step. FYI for anyone else on Neuron eager (not a diffusers issue). |
|
@JingyaHuang two things stacked on this branch, both verified on a
The second one matters specifically for your use case: at 33B parameters the The PR includes a Neuron test following your This is independent of the non-persistent-buffer fix I left above, which is on the same loading path. |
|
Tested the current head ( With Env: torch 2.14.0, torch-neuronx 2.14.3.0.2008+2dda91f8d. Once this lands I'll close #14725 in favour of this PR. |
7e4a7ef to
e4ae9de
Compare
Shards `MiniMaxH3Transformer3DModel` across devices, following the plan already established for Flux1/Flux2/Qwen-Image. Validated on Trainium at TP=2 and TP=8. - `_tp_plan` with twelve entries: the same six shapes for the 50 denoiser blocks and for the two token-refiner blocks, which are the same attention + SwiGLU FFN minus AdaLN and rotary. Q/K/V and the attention output are unfused, so they are plain colwise/rowwise; the SwiGLU input `ff.net.0.proj` is one Linear producing `[value; gate]` in equal halves and takes PackedColwiseParallel([1, 1]). - The attention processor reshaped by the config head count, `unflatten(-1, (attn.heads, -1))`, which mis-splits under sharding: each rank holds `inner_dim / tp_degree` columns, so this yields `head_dim / tp_degree` per head instead of `heads / tp_degree` heads. Reshape by the fixed `attn.head_dim` instead and let `-1` absorb the head count, as Flux does. Numerically identical unsharded, since `inner_dim == heads * head_dim`. - Norms, QK-norms (head_dim-shaped, applied after the head split), AdaLN modulation and the patch/text embedders and output heads stay replicated. `attn.to_qkv` is deliberately not in the plan: it exists only after `fuse_projections()`, and the plan is resolved by attribute lookup. No RoPE change was needed — unlike Qwen-Image, H3's rotary is already real sin/cos and already broadcasts over the head axis. Tests mirror the Flux2/Qwen-Image layout: the CUDA/XPU `TensorParallelTesterMixin` class, a `make_neuron_tp_spec()` factory, and a Neuron launcher that shells out to the model-agnostic `_neuron_tp_worker.py`. `get_dummy_inputs` and `get_packed_layout` take an optional `device` so the Neuron spec can ask for CPU tensors, since its worker shards on CPU and moves to device after.
|
Re-tested Fixed, thanks: default TP+CP mesh ( One regression: with Neuron's all-gather needs equal sizes per rank. Restoring the padding branch removed in |
|
Thanks for contributing and helping out @whn09 ! I think the PR is in good shape for review @sayakpaul @DN6 , tested on both AWS Neuron and Google TPU v6. |
| elif isinstance(style, ReplicatedInputRowwiseParallel): | ||
| # `input_layouts=Replicate()` makes `prepare_input` narrow the replicated activation down to this rank's | ||
| # columns rather than trusting it to already be `Shard(-1)`; the default would read a full-width tensor | ||
| # as if it were one rank's shard. | ||
| resolved[path] = RowwiseParallel(input_layouts=Replicate()) |
There was a problem hiding this comment.
Can I get some explanation on when this case arrives?
There was a problem hiding this comment.
this just shard the weight like "rowwise" but declares the input as replicated, it's applied to adaln_proj.linear of H3. Without it, adaln_proj would stay copied on every chip, and H3 wouldn't fit at tp=4 a 32 GB tpu chip.
| # Pre-shard on CPU instead of letting `parallelize_module` distribute the weights. On Neuron, consecutive | ||
| # `reduce_scatter`s of large weights (>= 5120x5120) in one `parallelize_module` call can fail in the runtime. On | ||
| # TPU, it would materialize every full weight on each chip first, which runs out of HBM on large models. |
There was a problem hiding this comment.
Should _pre_shard_and_parallelize then not be shape and device aware? Like for example, if we don't have such big shapes then doing it on CPU might be watseful, no?
There was a problem hiding this comment.
actually here _pre_shard_and_parallelize should be applied to all device including cuda instead of just neuron/ tpu. Just removed the if condition.
And for the concern of slicing on CPU might be wasteful when shape is small. indeed small shape slicing on device could be faster than on CPU, but it also mean copy all weights on each rank that takes time then throw away (n_rank -1)/n_rank that takes time and could fail if shape is too big for memory-limited device. So I feel that pre-shard on CPU should be set as default, since it's memory-safe and should not make huge difference on speed when shape is small. Besides _pre_shard_and_parallelize doesn't move anything to the CPU, _local_shard slices the weight wherever it already is, if user insist slice on device and free parts for other rank afterward, they can always send to device by .to("cuda").
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Keep this PR to tensor parallelism for MiniMax-H3. Combining TP and CP in one ParallelConfig, with its tests, now lives in huggingface#14725. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`config._device` now resolves to the local Neuron core on every rank, so the `torch.neuron.current_device()` special case is no longer needed when pre-sharding, loading sharded checkpoints and moving buffers.
…input" style The marker class carried no state, so make it a plain string style next to "colwise" and "rowwise". The name matches the transformers style of the same name, which is the same RowwiseParallel(input_layouts=Replicate()). Add it to the TP styles table in the distributed inference docs, with the AdaLN modulation projection as the example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the comment on how torch narrows the input with when the style is needed, plus two examples: H3's AdaLN modulation projection and an output projection after a gathered QK-norm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks, left some comments. Would be nice to simplify the comments to explain the critical changes.
Update: Ran the tests on 2 L4 GPUs, TP=2, PyTorch 2.10.0+cu129 using HF Jobs and they pass: https://huggingface.co/jobs/sayakpaul/6ae033333fb22a235dd25b98
| |---|---|---|---| | ||
| | `"colwise"` | output features (`weight` dim 0) | computes a slice of the output | `to_q`, `to_k`, `to_v`, FFN in-projection | | ||
| | `"rowwise"` | input features (`weight` dim 1) | computes a partial sum | `to_out.0`, FFN out-projection | | ||
| | `"rowwise_split_input"` | input features (`weight` dim 1) | slices its replicated input, then computes a partial sum | a standalone projection that reads a replicated activation and must output the full width, e.g. an AdaLN modulation projection off the timestep embedding | |
There was a problem hiding this comment.
it's just to say all output features. With rowwise_split_input, each rank multiplies its slice of the input features by its weight shard, producing a partial sum for every output feature. AllReduce adds these partial sums, giving every rank the complete output. Let me make it less confusing.
| return [grouped[key] for key in order] | ||
|
|
||
|
|
||
| def _shard_packed_param(param, dim: int, blocks: "list[int]", device_mesh, src_data_rank) -> torch.nn.Parameter: |
There was a problem hiding this comment.
Where are these utilities going?
There was a problem hiding this comment.
In this PR, the refactor moves weights sharding before hook registration with _pre_shard_and_parallelize (mostly for Neuron/TPU’s memory constraints, I removed the if condition in previous commit so it applies on cuda as well now) instead of using torch.distributed.tensor.parallel.parallelize_module, so we have
_shard_packed_param-> covered by_sync_tp_model_state,_local_shard, and_pre_shard_and_parallelize_styles->_hooks_only_styles(no weights partition tho since it's already done)
| return resolved | ||
|
|
||
|
|
||
| def _pre_shard_and_parallelize( |
There was a problem hiding this comment.
Where is this coming from? Could I get some explanation?
There was a problem hiding this comment.
Hmm, I see what we're doing, but this is also a behavioral change, right? It assumes all ranks already have identical weights. Is that something we can always assume?
There was a problem hiding this comment.
True the assumption won't be always valid for in-memory model with these edge cases:
- Random initialization differs: each rank calls Model.from_config(config) with a different RNG state.
- Ranks modify weights differently: for example, only rank 0 loads an updated state_dict, or reinitializes a layer.
- Ranks load different checkpoints or revisions.
- Buffers differ: random non-persistent buffers aren’t restored from a checkpoint and may differ even when weights match.
As we have two paths for the sharding shard-on-read(recommended) and in-memory which is slower and consume more memory as we added in the TP doc, and the changes here is a refactor to ensure that the memory of tpu/neuron won't explode when user goes with the in-memory path while doing TP for H3 (shard-on-read path is always fine tho)
For the edge cases, I can add a _sync_tp_model_state helper to broadcast model state from the first TP rank... Or if we don't want the refactor, I can just restore _styles / _shard_packed_param where previously used, only keep the required rowwise_split_input support in this file.
It's really If we want to support every possibility...
| world_size = torch.distributed.get_world_size() | ||
| device_type = torch._C._get_accelerator().type | ||
| device = torch.device(device_type, rank % torch.accelerator.device_count()) | ||
| device = torch.device(device_type, rank % max(torch.accelerator.device_count(), 1)) |
There was a problem hiding this comment.
not necessary at all, removed
| # The row plan is built on CPU and the caller moves the finished pair to the denoiser's device: `unique` | ||
| # has a data-dependent output shape, which an accelerator would rather not trace. The layout step hands the | ||
| # index tensors over already on that device, so bring them back for the scatter below — indexing a CPU | ||
| # tensor with an accelerator one does not work. |
There was a problem hiding this comment.
Can we simplify this? I don't fully understand why this is even needed.
What does this PR do?
Shards
MiniMaxH3Transformer3DModelacross a TP mesh._tp_planfor the transformer blocks and the token refiner, and reshapes attention byhead_dimso the processor works on a rank's local heads.adaln_projwith a newReplicatedInputRowwiseParallelstyle. It is ~40% of the weights, and left replicated it puts the per-rank memory above a 24 GiB core at any TP degree.from_pretrainedpath.Combining TP with context parallelism is split out to #14725.
Validation
On Neuron (trn2), TP=8 eager generates 768×1344, 124 frames: peak 15.31 GiB per core. This needs the packed sequence to be a multiple of 512 so the NKI flash attention kernel is used.
Before submitting
self-reviewskill on the diff?documentation guidelines, and
here are tips on formatting docstrings.
Who can review?
Anyone in the community is free to review the PR once the tests have passed. Feel free to tag
members/contributors who may be interested in your PR.