Repository navigation
[feat] Tensor parallelism for MiniMax-H3 - #14609
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. Pages changed in this PR: |
|
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.
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
| 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...
| # 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.
There was a problem hiding this comment.
I simplified the docstring, it's just that video_indices and audio_indices were on device
but
happens on cpu.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…into add-h3-tp-support
There was a problem hiding this comment.
Thanks! Just one question regarding putting the indices on CPU unconditionally.
I am also running a snippet with real values to see if the videos look as expected. Will keep this comment updated once done.
Update: Was able to successfully run and create a realistic video: https://huggingface.co/jobs/sayakpaul/6aec4c52c78863f9c6333a02
tp2.mp4
Script: https://gist.github.com/sayakpaul/b52339e37b4513822de8a26ccdaa8039
| # The layout step puts the indices on the execution device, but this plan is built on CPU. | ||
| video_indices = video_indices.cpu() | ||
| audio_indices = audio_indices.cpu() |
There was a problem hiding this comment.
it's just that video_indices and audio_indices were on device
but
happens on cpu, and it failed without moving indices back to cpu.
There was a problem hiding this comment.
But why does it particularly show up for TP? Does regular inference fail without it?
There was a problem hiding this comment.
I'm almost sure that's bc I changed the definition of pipeline's device in my tp PR, but let me confirm.
There was a problem hiding this comment.
ok @sayakpaul , on cuda actually it works w/o. the change since PyTorch itself copies a foreign-device index onto the indexed tensor's device:
https://github.com/pytorch/pytorch/blob/735a2ec9b17c91c93d6714f88115f97394b836c9/aten/src/ATen/native/IndexingUtils.h#L60-L61
so there was a hidden GPU→CPU copy, and it's not the case for Neuron which check the device consistency first:
https://github.com/aws-neuron/torch-neuronx/blob/d9d8423807874b11c8d59242b09378e68bc9a504/torch_neuronx/csrc/core/dispatch/PreprocessArgs.cpp#L31-L42
There was a problem hiding this comment.
So here the move of indices to cpu is equivalent to what had happened under the hood in PyTorch.
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.