feat(minimax-h3): enable 2x RTX 4090/5090 with DLO (#5764)

Signed-off-by: lishunyang12 <lishunyang03@gmail.com>
Signed-off-by: lishunyang12 <lishunyang12@163.com>
Co-authored-by: lishunyang12 <lishunyang03@gmail.com>
This commit is contained in:
SYLARandlishunyang12 authored and GitHub committed 2026-08-06 09:02:29 +08:00
1 parent b4581b293b
commit 1c2a81f6d8
28 files changed
+1658 -162

No files matched your search

@@ -200,4 +200,4 @@ You can add any benchmark running parameters you need here. For all optional par
| num_prompts | int / array | Yes | 10,[10, 20, 30] | Number of requests. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of qps or max_concurrency, e.g., [10,10,10]. If an array is used, its length must match the number of qps or max_concurrency. |
| request_rate | float / array | No | 0.5, [0.5, 1, inf] | Queries per second. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of num_prompts, e.g., [1,1,1]. If an array is used, its length must match the number of num_prompts. |
| max_concurrency | int / array | No | 1, [1, 2, 3] | Maximum concurrent in-flight requests. Same array / expansion rules as `request_rate` (mutually exclusive with QPS mode). |
| baseline | object | No | see below | Optional thresholds. Prefer hardware-nested form: `{"H100": {"throughput_qps": …}, "A3": {…}}` (selected via runtime hardware label). Legacy flat `{metric: threshold}` still works. Per metric: scalar, list (sweep step), or object keyed by concurrency / QPS.
| baseline | object | No | see below | Optional thresholds. Prefer hardware-nested form: `{"H100": {"throughput_qps": …}, "A3": {…}}` (selected via runtime hardware label). Legacy flat `{metric: threshold}` still works. Per metric: scalar, list (sweep step), or object keyed by concurrency / QPS.
+61
View File
@@ -0,0 +1,61 @@
# MiniMax-H3 on RTX 5090
This recipe uses BF16 weights, tiled VAE decode, tensor parallelism where a
second GPU is available, and distributed layerwise offload (DLO). It is a
memory-first serving configuration; lower resident counts reduce HBM use and
increase CPU-to-GPU transfer time.
## Capacity requirements
| Resource | One RTX 5090 | Two RTX 5090s |
| --- | ---: | ---: |
| GPU HBM | 32 GiB | 32 GiB per GPU |
| Checkpoint storage | 135 GiB per partition | 135 GiB per partition |
| Available system RAM | 200 GiB minimum | 200 GiB minimum |
| Recommended system RAM | 384 GiB | 384 GiB |
`FL2VA` and `Ref2VA` are separate 135 GiB checkpoint partitions. Start one
server at a time. DLO keeps rank-local weights in pinned host memory; increasing
`--dlo-resident-layers` improves latency but does **not** reduce host RAM in the
current implementation because resident layers retain pinned CPU master copies.
> **Modular H3:** after #5720 lands, preserve this recipe's one-partition
> behavior with `--task-type fl2va` or `--task-type ref2va`.
## One RTX 5090: 1344x768, 5 seconds
Use 12 resident DiT layers. A 50-step B300 allocation test with this exact
single-rank topology peaked at 26.50 GiB; re-measure peak HBM on the target
card before increasing the resident count.
```bash
CUDA_VISIBLE_DEVICES=0 vllm serve /path/to/MiniMax-H3/FL2VA \
--omni --trust-remote-code --host 0.0.0.0 --port 8000 \
--num-gpus 1 --tensor-parallel-size 1 --text-encoder-tp-size 1 \
--usp 1 --ring 1 --vae-patch-parallel-size 1 \
--vae-parallel-mode tile --vae-use-tiling \
--enable-distributed-layerwise-offload --dlo-no-use-allgather \
--dlo-resident-layers 12 --enforce-eager \
--diffusion-attention-backend CUDNN_ATTN
```
## Two RTX 5090s: 1344x768, 5 seconds
Use TP2 and 20 resident DiT layers. The two-rank B300 capacity run peaked at
27,726 MiB per rank for this shape and 50 steps. This is a memory/correctness
proxy, not a consumer-GPU latency claim.
```bash
CUDA_VISIBLE_DEVICES=0,1 vllm serve /path/to/MiniMax-H3/FL2VA \
--omni --trust-remote-code --host 0.0.0.0 --port 8000 \
--num-gpus 2 --tensor-parallel-size 2 --text-encoder-tp-size 2 \
--usp 1 --ring 1 --vae-patch-parallel-size 2 \
--vae-parallel-mode tile --vae-use-tiling \
--enable-distributed-layerwise-offload --dlo-no-use-allgather \
--dlo-resident-layers 20 --enforce-eager \
--diffusion-attention-backend CUDNN_ATTN
```
For Ref2VA, stop the FL2VA server and restart the same command with
`/path/to/MiniMax-H3/Ref2VA`. Ref2VA reference video count and prompt length
can increase activation memory; begin with one request at a time.
+116 -2
View File
@@ -33,8 +33,10 @@ export MODEL_ROOT=/path/to/MiniMax-H3
hf download MiniMaxAI/MiniMax-H3 --local-dir "${MODEL_ROOT}"
```
Install vLLM-Omni from the checkout containing MiniMax H3 support. On
Blackwell, install the optional FlashAttention-4 dependency:
Install vLLM-Omni from the checkout containing MiniMax H3 support. The
two-GPU RTX 5090/4090 profiles use cuDNN attention and do not need
FlashAttention-4. Install the optional dependency only for the four-GPU
B300/GB200 `FLASH_ATTN` profile:
```bash
uv venv
@@ -50,6 +52,26 @@ reference-video preparation and MP4 output.
One server loads one checkpoint partition. Set `MODEL` to `FL2VA` for T2VA
and FL2VA requests, or to `Ref2VA` for either Ref2VA request.
### Memory and storage requirements
Treat GPU HBM, host RAM, and checkpoint storage as separate requirements. Each
H3 checkpoint partition (`FL2VA` or `Ref2VA`) contains about **134 GiB** of
BF16 safetensors (about **135 GiB** on disk). Keeping both partitions locally
therefore needs roughly **270 GiB** of model storage, although the examples
serve only one partition at a time.
CPU offload and distributed layerwise offload reduce GPU residency; they do
not make the model weights disappear. With `--dlo-no-use-allgather`, each
worker retains its standard-loader rank-local weights in host memory, including
pinned CPU buffers used for H2D streaming. Use at least **200 GiB available
system RAM** before starting the two-GPU recipe; a **384 GiB host is
recommended** to leave room for the OS, CUDA/PyTorch allocations, request
inputs, and filesystem cache. Do not run the FL2VA and Ref2VA servers at the
same time on a host sized for this minimum.
The consumer-GPU profiles below are HBM budgets only. They still require the
host-RAM budget above.
### Single GPU: accuracy and memory first
The single-GPU configuration uses model-level CPU offload.
@@ -77,6 +99,98 @@ Use a GPU with enough memory for the active H3 component and enough system RAM
for the offloaded components. CPU offload reduces GPU memory pressure but adds
PCIe/NVLink transfer latency.
### Two 24/32 GB GPUs: TP2 distributed layerwise offload
For two PCIe consumer GPUs, combine TP2 with distributed layerwise offload
(DLO). The standard loader first creates the rank-local TP shard. DLO keeps
that shard in pinned host memory and streams the 30 tail DiT blocks through a
shared two-buffer window without a DP AllGather. The first 20 DiT blocks are
copied to the GPUs once per denoise stage, reused by every sampling step, and
released before VAE decode so the decoder can reuse their HBM.
```bash
export MODEL="${MODEL_ROOT}/FL2VA"
export PORT=8091
CUDA_VISIBLE_DEVICES=0,1 \
VLLM_WORKER_MULTIPROC_METHOD=spawn \
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=14400 \
vllm serve "${MODEL}" \
--omni \
--host 0.0.0.0 \
--port "${PORT}" \
--trust-remote-code \
--num-gpus 2 \
--tensor-parallel-size 2 \
--usp 1 \
--ring 1 \
--text-encoder-tp-size 2 \
--vae-patch-parallel-size 2 \
--vae-parallel-mode tile \
--vae-use-tiling \
--enable-distributed-layerwise-offload \
--dlo-no-use-allgather \
--dlo-resident-layers 20 \
--enforce-eager \
--diffusion-attention-backend CUDNN_ATTN
```
Use the profile that matches the per-GPU memory capacity:
| Profile | GPUs | Starting shape | Resident DiT blocks | Attention | Execution | Status |
|---|---:|---:|---:|---|---|---|
| `rtx5090` | 2 x 32 GB | 1344x768 | 20 | cuDNN attention | eager | Target-hardware validated |
| `rtx4090` | 2 x 24 GB | 1024x576 | 12 | cuDNN attention | eager | Capacity-proxy starting point |
This topology uses all available parallel capacity: TP2 shards both the DiT
and text encoder, `--dlo-no-use-allgather` streams each rank's local TP shard
without reconstructing full blocks, and VAE patch parallelism splits tiled
decode across both GPUs. cuDNN attention is selected explicitly for the RTX
consumer path; the server stays eager to avoid an unqualified compile path.
The resident count changes placement and transfer frequency only; it does not
quantize or change the BF16/FP32 denoise math. Re-measure peak memory before
increasing it on a different request shape.
### RTX 5090 target-hardware validation
At vLLM-Omni commit `ae6577ea`, one full 50-step T2VA request completed on
2 x RTX 5090 without OOM:
| Shape | Frames | Client E2E | Sampled peak/GPU | Output validation |
|---:|---:|---:|---:|---|
| 1344x768 | 124 at 24 FPS | 8 min 38 s | approximately 22.6 GiB | H.264 video + 32 kHz stereo AAC; full `ffmpeg` decode passed |
This is a single end-to-end validation run, not a warmed multi-run latency
benchmark. The sampled `nvidia-smi` peak is also not a CUDA allocator
high-water mark. The environment used vLLM 0.26.0, vLLM-Omni
`0.26.1.dev14+gae6577ea`, and PyTorch 2.11.0+cu130. The
[run record](https://github.com/lishunyang12/vllm-omni-rankings/blob/dcd06d7e83cb069842535918c0169ee9f3f29ba0/scripts/%E5%BE%AE%E4%BF%A1%E5%9B%BE%E7%89%87_20260805000034_86_237.png)
captures the environment, output contract, elapsed time, and sampled peak.
Before the target run, both profiles were exercised on two B300 ranks as an
allocation and correctness proxy. At 1344x768, 124 frames, and 50 steps, the
20-layer profile peaked at 27,726 MiB per rank. At 1024x576, the 12-layer
profile peaked at 18,888 MiB per rank in a 5-step capacity run. The resident
and fully streamed placements produced identical decoded video-frame and audio
hashes for the same shape, step count, prompt, and seed. The B300 result does
not establish RTX 4090 PCIe latency; treat the 4090 profile as a conservative
starting point until it is measured on that GPU.
To run T2VA, FL2VA, image+audio Ref2VA, and two-video Ref2VA in order, validate
every MP4's H.264/AAC streams, and retain live server and GPU-memory logs:
```bash
RUN_ROOT=/path/to/run-root \
MODEL_ROOT=/path/to/MiniMax-H3 \
GPU_IDS=0,1 \
PROFILE=rtx5090 \
bash examples/offline_inference/minimax_h3/run_h3_2gpu_all_tasks.sh
```
The script selects 20 resident layers for `PROFILE=rtx5090` and 12 for
`PROFILE=rtx4090`; `DLO_RESIDENT_LAYERS=N` overrides either default.
### Four GPUs: measured best-practice throughput
The best validated four-GPU configuration on four NVIDIA B300 GPUs is:
@@ -0,0 +1,64 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from contextlib import nullcontext
import pytest
import torch
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
from vllm_omni.diffusion.attention.backends.cudnn_attn import CuDNNAttentionImpl
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
def test_cudnn_slices_valid_kv_prefix_without_padding_mask(monkeypatch):
observed = {}
def fake_sdpa(query, key, value, **kwargs):
observed.update(query=query, key=key, value=value, kwargs=kwargs)
return query
monkeypatch.setattr(
"vllm_omni.diffusion.attention.backends.cudnn_attn.sdpa_kernel",
lambda _backends: nullcontext(),
)
monkeypatch.setattr(torch.nn.functional, "scaled_dot_product_attention", fake_sdpa)
impl = CuDNNAttentionImpl(
num_heads=2,
head_size=4,
softmax_scale=0.5,
)
query = torch.randn(1, 8, 2, 4)
key = torch.randn_like(query)
value = torch.randn_like(query)
output = impl.forward_cuda(
query,
key,
value,
AttentionMetadata(extra={"valid_kv_length": 5}),
)
assert output.shape == query.shape
assert observed["query"].shape == (1, 2, 8, 4)
assert observed["key"].shape == (1, 2, 5, 4)
assert observed["value"].shape == (1, 2, 5, 4)
assert observed["kwargs"]["attn_mask"] is None
def test_cudnn_rejects_invalid_valid_kv_length():
impl = CuDNNAttentionImpl(
num_heads=2,
head_size=4,
softmax_scale=0.5,
)
query = torch.randn(1, 8, 2, 4)
with pytest.raises(ValueError, match="valid_kv_length"):
impl.forward_cuda(
query,
query,
query,
AttentionMetadata(extra={"valid_kv_length": 9}),
)
@@ -30,6 +30,13 @@ def test_pipeline_import_registry_and_component_discovery():
assert MiniMaxH3Pipeline._vae_modules == ["video_vae", "audio_vae"]
def test_dlo_offload_plan_includes_token_refiner():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
assert MiniMaxH3Pipeline._offload_plan.offload_submodules == {"token_refiner": "blocks"}
assert MiniMaxH3Pipeline._offload_plan.resident_dit_paths == frozenset({"transformer"})
def test_joint_postprocess_is_multiprocessing_picklable():
from vllm_omni.diffusion.models.minimax_h3 import (
get_minimax_h3_post_process_func,
@@ -87,6 +94,87 @@ def test_shifted_sigma_schedule_matches_reference_values():
)
def test_cudnn_packed_attention_uses_python_length_without_padding_mask():
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
MiniMaxH3Attention,
)
class FakeBackend:
supports_prefix_kv_slicing = True
class FakeAttention(torch.nn.Module):
attn_backend = FakeBackend
def __init__(self):
super().__init__()
self.metadata = None
def forward(self, query, key, value, metadata):
self.metadata = metadata
return query
attention = object.__new__(MiniMaxH3Attention)
torch.nn.Module.__init__(attention)
attention.attention = FakeAttention()
# Model-side Ulysses shards rows before Attention gathers them again.
# The Python packed_total must therefore remain global even though q is
# local here.
q = torch.randn(4, 2, 4)
cu_seqlens = torch.tensor([0, 5, 8], dtype=torch.int32)
output = attention._run_packed_attention(
q,
q,
q,
cu_seqlens=cu_seqlens,
max_seqlen=5,
packed_total=8,
)
assert output.shape == q.shape
assert attention.attention.metadata.attn_mask is None
assert attention.attention.metadata.extra["valid_kv_length"] == 5
def test_packed_attention_keeps_padding_mask_for_other_backends():
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
MiniMaxH3Attention,
)
class FakeBackend:
supports_prefix_kv_slicing = False
class FakeAttention(torch.nn.Module):
attn_backend = FakeBackend
def __init__(self):
super().__init__()
self.metadata = None
def forward(self, query, key, value, metadata):
self.metadata = metadata
return query
attention = object.__new__(MiniMaxH3Attention)
torch.nn.Module.__init__(attention)
attention.attention = FakeAttention()
q = torch.randn(8, 2, 4)
attention._run_packed_attention(
q,
q,
q,
cu_seqlens=torch.tensor([0, 5, 8], dtype=torch.int32),
max_seqlen=5,
packed_total=8,
)
assert torch.equal(
attention.attention.metadata.attn_mask,
torch.tensor([[True, True, True, True, True, False, False, False]]),
)
def test_reference_image_resize_contract():
from PIL import Image
@@ -293,6 +381,127 @@ def test_layerwise_offload_releases_text_encoder():
pipeline.text_encoder.offload_to_cpu.assert_called_once_with()
def test_distributed_layerwise_offload_releases_text_encoder():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.od_config = SimpleNamespace(
enable_cpu_offload=False,
enable_layerwise_offload=False,
enable_distributed_layerwise_offload=True,
)
pipeline.text_encoder = Mock()
expected = torch.ones(2, 3)
pipeline.text_encoder.encode_ids.return_value = expected
actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {})
assert actual is expected
pipeline.text_encoder.load_to_device.assert_called_once_with()
pipeline.text_encoder.offload_to_cpu.assert_called_once_with()
def test_distributed_layerwise_offload_stages_vae_component():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.od_config = SimpleNamespace(
enable_layerwise_offload=False,
enable_distributed_layerwise_offload=True,
)
component = Mock()
with pipeline._component_on_device(component):
component.load_to_device.assert_called_once_with()
component.offload_to_cpu.assert_not_called()
component.offload_to_cpu.assert_called_once_with()
def test_distributed_layerwise_resident_blocks_are_stage_scoped():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
controller = Mock()
pipeline._dlo_residency_controller = controller
with pipeline._resident_dit_layers_on_device():
controller.load_resident_layers.assert_called_once_with()
controller.offload_resident_layers.assert_not_called()
controller.offload_resident_layers.assert_called_once_with()
def test_distributed_layerwise_resident_blocks_release_on_failure():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
controller = Mock()
pipeline._dlo_residency_controller = controller
with pytest.raises(RuntimeError, match="denoise failed"):
with pipeline._resident_dit_layers_on_device():
raise RuntimeError("denoise failed")
controller.load_resident_layers.assert_called_once_with()
controller.offload_resident_layers.assert_called_once_with()
def test_encoder_layerwise_offload_keeps_tp_blocks_rank_local(monkeypatch):
from vllm_omni.diffusion.models.minimax_h3.encoder import (
MiniMaxH3Qwen3VLEncoder,
)
class Stack(torch.nn.Module):
def __init__(self, count):
super().__init__()
self.blocks = torch.nn.ModuleList([torch.nn.Linear(2, 2) for _ in range(count)])
class TextStack(torch.nn.Module):
def __init__(self, count):
super().__init__()
self.layers = torch.nn.ModuleList([torch.nn.Linear(2, 2) for _ in range(count)])
hooks = []
def fake_apply(block, next_block, device, stream, pin_memory):
hook = SimpleNamespace(
block=block,
next_block=next_block,
device=device,
stream=stream,
pin_memory=pin_memory,
_prev_hook=None,
offload_layer=Mock(),
)
hooks.append(hook)
return hook
monkeypatch.setattr(
"vllm_omni.diffusion.offloader.layerwise_backend.apply_block_hook",
fake_apply,
)
monkeypatch.setattr(
"vllm_omni.platforms.current_omni_platform.Stream",
Mock(return_value="copy-stream"),
)
encoder = object.__new__(MiniMaxH3Qwen3VLEncoder)
torch.nn.Module.__init__(encoder)
encoder.device_target = torch.device("cpu")
encoder.vision = Stack(2)
encoder.text_model = TextStack(3)
encoder.enable_omni_layerwise_offload(pin_memory=False)
assert len(hooks) == 5
assert all(hook._prev_hook is not None for hook in hooks)
assert all(hook.device == torch.device("cpu") for hook in hooks)
def test_video_vae_keeps_reference_fp32_weights(monkeypatch):
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module
@@ -324,6 +533,82 @@ def test_video_vae_keeps_reference_fp32_weights(monkeypatch):
assert next(video_vae.parameters()).dtype == torch.float32
def test_video_vae_can_load_on_cpu_for_staged_gpu_residency(monkeypatch):
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module
class FakeRemote(torch.nn.Module):
def __init__(self):
super().__init__()
self.model = torch.nn.Linear(1, 1)
monkeypatch.setattr(
vae_module,
"_load_component_config",
lambda _path: {
"latent_channels": 1,
"latents_mean": [0.0],
"latents_std": [1.0],
},
)
monkeypatch.setattr(
vae_module,
"_load_remote_component",
lambda _path, _config: FakeRemote(),
)
video_vae = vae_module.MiniMaxH3VideoVAE(
"unused",
device=torch.device("meta"),
load_device=torch.device("cpu"),
)
assert next(video_vae.parameters()).device.type == "cpu"
assert video_vae._device_target.type == "meta"
def test_video_vae_uses_snapshot_stager_for_accelerator_target(monkeypatch):
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module
class FakeRemote(torch.nn.Module):
def __init__(self):
super().__init__()
self.model = torch.nn.Linear(1, 1)
stager = Mock()
stager_cls = Mock(return_value=stager)
monkeypatch.setattr(
vae_module,
"_load_component_config",
lambda _path: {
"latent_channels": 1,
"latents_mean": [0.0],
"latents_std": [1.0],
},
)
monkeypatch.setattr(
vae_module,
"_load_remote_component",
lambda _path, _config: FakeRemote(),
)
monkeypatch.setattr(vae_module, "PinnedModuleStager", stager_cls)
video_vae = vae_module.MiniMaxH3VideoVAE(
"unused",
device=torch.device("cuda"),
load_device=torch.device("cpu"),
)
video_vae.load_to_device()
video_vae.offload_to_cpu()
stager_cls.assert_called_once_with(
video_vae.remote,
torch.device("cuda"),
pin_memory=True,
)
stager.load.assert_called_once_with()
stager.offload.assert_called_once_with()
def test_video_vae_encode_uses_configured_parallel_tiling():
from vllm_omni.diffusion.models.minimax_h3.vae import (
MiniMaxH3VideoVAE,
@@ -27,7 +27,9 @@ from vllm_omni.diffusion.offloader.block_discovery import (
from vllm_omni.diffusion.offloader.distributed_layerwise_backend import (
DistributedLayerwiseOffloadBackend,
DistributedLayerwiseOffloadHook,
PinnedResidentLayerGroup,
)
from vllm_omni.diffusion.offloader.module_residency import PinnedModuleStager
from vllm_omni.diffusion.offloader.offload_plan import OffloadPlan, get_offload_plan
from vllm_omni.platforms import current_omni_platform
@@ -97,6 +99,7 @@ def patched_offload_runtime(monkeypatch):
monkeypatch.setattr(dist_backend_module.current_omni_platform, "Event", DummyEvent)
monkeypatch.setattr(dist_backend_module.current_omni_platform, "current_stream", lambda: DummyStream())
monkeypatch.setattr(dist_backend_module.current_omni_platform, "stream", dummy_stream)
monkeypatch.setattr(dist_backend_module.current_omni_platform, "synchronize", lambda: None)
class TinyBlock(nn.Module):
@@ -308,6 +311,164 @@ class TestDistributedLayerwiseOffloadHook:
assert shard1.numel() == 2
assert torch.equal(shard1, torch.tensor([102.0, 0.0]))
def test_mmap_layout_transform_is_applied_before_sharding(self, dist_group, patched_offload_runtime):
block = nn.Module()
block.weight = nn.Parameter(torch.zeros(4, dtype=torch.float32))
next_block = nn.Module()
next_block.weight = nn.Parameter(torch.arange(4, dtype=torch.float32))
next_block.weight.mmap_weight_transform = lambda tensor: tensor.flip(0)
next_block.weight.mmap_weight_transform_pending = True
hook = DistributedLayerwiseOffloadHook(
next_block=next_block,
device=torch.device("cpu"),
dp_group=None,
dp_size=2,
rank=0,
pin_memory=False,
)
hook.initialize_hook(block)
assert torch.equal(hook.cpu_shards[torch.float32], torch.tensor([3.0, 2.0]))
def test_regular_loader_weight_does_not_reapply_mmap_transform(self, dist_group, patched_offload_runtime):
block = nn.Module()
block.weight = nn.Parameter(torch.zeros(4, dtype=torch.float32))
next_block = nn.Module()
next_block.weight = nn.Parameter(torch.arange(4, dtype=torch.float32))
next_block.weight.mmap_weight_transform = lambda tensor: tensor.flip(0)
hook = DistributedLayerwiseOffloadHook(
next_block=next_block,
device=torch.device("cpu"),
dp_group=None,
dp_size=2,
rank=0,
pin_memory=False,
)
hook.initialize_hook(block)
assert torch.equal(hook.cpu_shards[torch.float32], torch.tensor([0.0, 1.0]))
class TestPinnedResidentLayerGroup:
def test_load_offload_reuses_pinned_master_weights(self, patched_offload_runtime):
blocks = [nn.Linear(2, 2), nn.Linear(2, 2)]
expected = []
for index, block in enumerate(blocks):
block.weight.data.fill_(index + 1)
block.bias.data.fill_(index + 3)
expected.append((block.weight.detach().clone(), block.bias.detach().clone()))
group = PinnedResidentLayerGroup(
blocks,
device=torch.device("cpu"),
copy_stream=DummyStream(),
pin_memory=False,
)
assert all(block.weight.numel() == 0 for block in blocks)
group.load()
for block, (weight, bias) in zip(blocks, expected):
assert torch.equal(block.weight, weight)
assert torch.equal(block.bias, bias)
# Device-side changes must not overwrite the persistent host master.
blocks[0].weight.data.zero_()
group.offload()
assert all(block.weight.numel() == 0 for block in blocks)
group.load()
for block, (weight, bias) in zip(blocks, expected):
assert torch.equal(block.weight, weight)
assert torch.equal(block.bias, bias)
group.offload()
def test_mmap_loader_attrs_survive_to_empty_parameter_replacement(self):
module = nn.Linear(2, 2, bias=False)
def transform(tensor):
return tensor.flip(0)
module.weight.mmap_weight_transform = transform
backend = object.__new__(DistributedLayerwiseOffloadBackend)
backend._remember_mmap_param_attrs(module)
module.to_empty(device="meta")
replacement = nn.Parameter(torch.arange(4).reshape(2, 2).float())
backend._attach_mmap_param_attrs("weight", replacement, module.weight)
assert replacement.mmap_weight_transform is transform
assert replacement.mmap_weight_transform_pending is True
def test_shared_allgather_output_is_narrowed_to_current_block(
self, dist_group, patched_offload_runtime, monkeypatch
):
block = nn.Module()
block.weight = nn.Parameter(torch.zeros(3, dtype=torch.float32))
next_block = nn.Module()
next_block.weight = nn.Parameter(torch.arange(3, dtype=torch.float32))
hook = DistributedLayerwiseOffloadHook(
next_block=next_block,
device=torch.device("cpu"),
dp_group=object(),
dp_size=2,
rank=0,
pin_memory=False,
)
hook.initialize_hook(block)
# Simulate unified buffers sized for a larger heterogeneous block.
hook.gpu_buffers = [
{torch.float32: torch.empty(12)},
{torch.float32: torch.empty(12)},
]
hook.gpu_shard_buffers = [
{torch.float32: torch.empty(6)},
{torch.float32: torch.empty(6)},
]
output_sizes = []
def fake_allgather(output, local_shard, *, group):
del group
output_sizes.append(output.numel())
output.zero_()
output[: local_shard.numel()].copy_(local_shard)
monkeypatch.setattr(torch.distributed, "all_gather_into_tensor", fake_allgather)
hook.prefetch_layer(slot=0, non_blocking=False)
assert output_sizes == [4]
class TestPinnedModuleStager:
def test_offload_rebinds_immutable_cpu_master(self, patched_offload_runtime):
module = nn.Linear(2, 2)
expected_weight = module.weight.detach().clone()
expected_bias = module.bias.detach().clone()
stager = PinnedModuleStager(
module,
torch.device("cpu"),
pin_memory=False,
copy_stream=DummyStream(),
)
stager.load()
module.weight.data.zero_()
module.bias.data.zero_()
stager.offload()
assert torch.equal(module.weight, expected_weight)
assert torch.equal(module.bias, expected_bias)
stager.load()
assert torch.equal(module.weight, expected_weight)
assert torch.equal(module.bias, expected_bias)
stager.offload()
class _DummyBlock(nn.Module):
def __init__(self):
@@ -592,6 +753,7 @@ class TestOffloadPlan:
plan = OffloadPlan(
block_attrs={"transformer": ("gen_layers",)},
offload_submodules={"context_encoder": "layers"},
resident_dit_paths=frozenset({"transformer"}),
)
class PipelineWithPlan(nn.Module):
@@ -602,12 +764,14 @@ class TestOffloadPlan:
assert result is plan
assert result.block_attrs == {"transformer": ("gen_layers",)}
assert result.offload_submodules == {"context_encoder": "layers"}
assert result.resident_dit_paths == frozenset({"transformer"})
def test_offload_plan_defaults_to_empty(self):
"""OffloadPlan with no arguments should have empty dicts."""
plan = OffloadPlan()
assert plan.block_attrs == {}
assert plan.offload_submodules == {}
assert plan.resident_dit_paths == frozenset()
def test_offload_plan_is_frozen(self):
"""OffloadPlan should be immutable (frozen=True)."""
@@ -615,6 +779,50 @@ class TestOffloadPlan:
with pytest.raises(Exception):
plan.block_attrs = {"x": ("y",)} # type: ignore
def test_all_resident_dit_still_prepares_planned_submodules(self, patched_offload_runtime):
class TokenRefiner(nn.Module):
def __init__(self):
super().__init__()
self.blocks = nn.ModuleList([nn.Linear(2, 2), nn.Linear(2, 2)])
class Transformer(nn.Module):
_layerwise_offload_blocks_attrs = ["blocks"]
def __init__(self):
super().__init__()
self.blocks = nn.ModuleList([nn.Linear(2, 2), nn.Linear(2, 2)])
self.token_refiner = TokenRefiner()
class Pipeline(nn.Module):
_offload_plan = OffloadPlan(
offload_submodules={"token_refiner": "blocks"},
resident_dit_paths=frozenset({"transformer"}),
)
def __init__(self):
super().__init__()
self.transformer = Transformer()
pipeline = Pipeline()
backend = DistributedLayerwiseOffloadBackend(
OffloadConfig(
strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE,
pin_cpu_memory=False,
dlo_use_allgather=False,
dlo_resident_layers=2,
),
torch.device("cpu"),
)
backend.enable(pipeline)
assert len(backend._resident_blocks) == 2
assert len(backend._all_hook_groups) == 1
assert len(backend._all_hook_groups[0]) == 2
assert backend.enabled
backend.disable()
class TestMmapValidation:
"""Tests for mmap loader validation: TP rejection, strict check, validate_loaded_weights."""
@@ -821,7 +1029,7 @@ class TestConfigValidation:
OffloadConfig.from_od_config(FakeODConfig())
def test_hsdp_without_allgather_allowed(self):
"""HSDP + DLO + no-AllGather should be allowed (full weights per rank)."""
"""HSDP + DLO + no-AllGather uses standard-loader rank-local weights."""
from vllm_omni.diffusion.offloader.base import OffloadConfig
class FakePC:
@@ -843,12 +1051,33 @@ class TestConfigValidation:
enable_layerwise_offload = False
enable_distributed_layerwise_offload = True
dlo_use_allgather = False # no AllGather → should be allowed
dlo_resident_layers = 20
pin_cpu_memory = True
parallel_config = FakePC()
model = "/fake/path"
config = OffloadConfig.from_od_config(FakeODConfig())
assert config.dp_size == 1 # forced to 1 when no AllGather
assert config.dlo_resident_layers == 20
def test_resident_layers_with_allgather_rejected(self):
class FakePC:
data_parallel_size = 2
use_hsdp = False
sequence_parallel_size = 1
class FakeODConfig:
enable_cpu_offload = False
enable_layerwise_offload = False
enable_distributed_layerwise_offload = True
dlo_use_allgather = True
dlo_resident_layers = 20
pin_cpu_memory = True
parallel_config = FakePC()
model = "/fake/path"
with pytest.raises(ValueError, match="requires --dlo-no-use-allgather"):
OffloadConfig.from_od_config(FakeODConfig())
def test_num_inference_steps_none_rejected(self):
"""DP multi-concurrency should reject None num_inference_steps."""
@@ -247,6 +247,56 @@ class TestRequestModeDispatch:
results = {ro.request_id: ro.result.error for ro in out.runner_outputs}
assert results == {request_id: f"result_for_{request_id}" for request_id in request_ids}
def test_dlo_dp_routes_multiple_requests_without_pipeline_batching(self):
executor, _, _ = _make_executor(num_gpus=2)
executor.od_config = SimpleNamespace(
step_execution=False,
parallel_config=SimpleNamespace(data_parallel_size=2),
enable_distributed_layerwise_offload=True,
dlo_use_allgather=True,
)
executor.execute_request = Mock(return_value="dlo-dp")
executor.collective_rpc = Mock()
scheduler_output = _make_sched_output("A", "B")
result = executor.execute_batch(scheduler_output)
assert result == "dlo-dp"
executor.execute_request.assert_called_once_with(scheduler_output)
executor.collective_rpc.assert_not_called()
def test_dlo_dp_multi_rank_reply_uses_synchronous_rpc_collection(self):
executor, req_q, res_q = _make_executor(num_gpus=2)
executor.od_config = SimpleNamespace(
step_execution=False,
parallel_config=SimpleNamespace(data_parallel_size=2),
)
executor._sync_result_buffer = res_q
def worker():
request = req_q.get(timeout=2)
assert "rpc_id" not in request
wave_id = request["wave_id"]
for dp_rank in range(2):
res_q.put(
{
"dp_rank": dp_rank,
"output": _tagged_output(str(dp_rank)),
"wave_id": wave_id,
}
)
thread = threading.Thread(target=worker, daemon=True)
thread.start()
results = executor.collective_rpc(
"execute_model",
unique_reply_rank=None,
exec_all_ranks=True,
)
thread.join(timeout=2)
assert [result.error for result in results] == ["0", "1"]
# ───────────────── concurrent collective RPC ─────────────────
@@ -262,6 +262,36 @@ def test_serve_cli_accepts_diffusion_pipeline_profiler_flag():
assert stage_cfg["engine_args"]["enable_diffusion_pipeline_profiler"] is True
def test_serve_cli_forwards_distributed_offload_residency():
"""Ensure the two-GPU DLO placement controls reach the diffusion stage."""
parser = TrackingArgumentParser()
subparsers = parser.add_subparsers(dest="command")
OmniServeCommand().subparser_init(subparsers)
args = parser.parse_args(
[
"serve",
"MiniMaxAI/MiniMax-H3",
"--omni",
"--enable-distributed-layerwise-offload",
"--dlo-no-use-allgather",
"--dlo-resident-layers",
"20",
]
)
explicit_kwargs = args.get_explicit_kwargs_dict()
stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(explicit_kwargs)[0]
engine_args = stage_cfg["engine_args"]
assert args.enable_distributed_layerwise_offload is True
assert args.dlo_use_allgather is False
assert args.dlo_resident_layers == 20
assert engine_args["enable_distributed_layerwise_offload"] is True
assert engine_args["dlo_use_allgather"] is False
assert engine_args["dlo_resident_layers"] == 20
def test_serve_cli_accepts_diffusion_compile_controls():
"""Ensure both compile controls reach the diffusion stage."""
parser = TrackingArgumentParser()
+1
View File
@@ -479,6 +479,7 @@ class _DiffusionConfigProjection:
enable_layerwise_offload: bool = False
enable_distributed_layerwise_offload: bool = False
dlo_use_allgather: bool = True
dlo_resident_layers: int = Field(default=0, ge=0)
pin_cpu_memory: bool = True
diffusion_compile_granularity: Literal["regional", "full"] = "regional"
diffusion_compile_dynamic: bool = Field(default=True, strict=True)
+3
View File
@@ -413,6 +413,9 @@ class StageDeployConfig:
enable_cpu_offload: bool | None = None
enable_layerwise_offload: bool | None = None
enable_distributed_layerwise_offload: bool | None = None
dlo_use_allgather: bool | None = None
dlo_resident_layers: int | None = None
# Diffusion-specific debug and observability knobs.
enable_diffusion_pipeline_profiler: bool | None = None
@@ -15,6 +15,10 @@ class AttentionBackend(ABC):
accept_output_buffer: bool = False
supports_piecewise_spans: bool = False
# The backend can represent a contiguous valid K/V prefix by slicing the
# tensors instead of materializing a padding mask. Models may use this to
# avoid a slower masked-attention plan when tail padding is not semantic.
supports_prefix_kv_slicing: bool = False
@classmethod
def supports_attention_mask(cls) -> bool:
@@ -83,6 +87,8 @@ class AttentionMetadata:
# variable-length query/key sequences for FlashAttention.
# "max_seqlen_q" / "max_seqlen_k": maximum sequence lengths paired with
# the packed cu_seqlens tensors.
# "valid_kv_length": int — contiguous valid K/V prefix length for a
# backend that advertises supports_prefix_kv_slicing.
# Piecewise attention metadata (mixed causal/full masks).
# full_attn_spans: per-sample [start, end) spans in global coordinates using full attention.
@@ -17,6 +17,7 @@ logger = init_logger(__name__)
class CuDNNAttentionBackend(AttentionBackend):
accept_output_buffer: bool = True
supports_prefix_kv_slicing: bool = True
@classmethod
def supports_attention_mask(cls) -> bool:
@@ -61,7 +62,26 @@ class CuDNNAttentionImpl(AttentionImpl):
) -> torch.Tensor:
attention_mask = None
if attn_metadata:
attention_mask = _maybe_reshape_attn_mask(query, key, attn_metadata.attn_mask, mask_mode="broadcast_k")
valid_kv_length = attn_metadata.extra.get("valid_kv_length")
if attn_metadata.attn_mask is None and isinstance(valid_kv_length, int):
if not 0 < valid_kv_length <= key.shape[1]:
raise ValueError(
"valid_kv_length must be within the K/V sequence, "
f"got {valid_kv_length} for length {key.shape[1]}"
)
# A contiguous valid prefix is mathematically equivalent to a
# broadcast key-padding mask. Slicing K/V keeps Q/output in the
# checkpoint's aligned layout while letting cuDNN select its
# much faster mask-free FMHA plan.
key = key[:, :valid_kv_length]
value = value[:, :valid_kv_length]
else:
attention_mask = _maybe_reshape_attn_mask(
query,
key,
attn_metadata.attn_mask,
mask_mode="broadcast_k",
)
enable_gqa = query.shape[2] != key.shape[2]
query, key, value = (x.permute(0, 2, 1, 3) for x in (query, key, value))
+6 -2
View File
@@ -687,9 +687,13 @@ class OmniDiffusionConfig:
# Distributed layer-wise offloading with H2D + AllGather overlap (RFC-1)
enable_distributed_layerwise_offload: bool = False
# If True: shard weights 1/dp_size + AllGather (saves CPU memory, requires
# concurrent requests in DP mode). If False: each rank loads full weights
# via H2D only (N× CPU memory, but no AllGather synchronization needed).
# concurrent requests in DP mode). If False: each rank streams the standard
# loader's rank-local tensors (including TP-local shards) via H2D only.
# This avoids AllGather synchronization, while host memory follows the
# loader's existing rank-local layout instead of adding a second DP shard.
dlo_use_allgather: bool = True
# Leading main-DiT blocks kept resident by distributed layerwise offload.
dlo_resident_layers: int = 0
pin_cpu_memory: bool = True # Use pinned memory for faster transfers when offloading
+1 -1
View File
@@ -237,7 +237,7 @@ class DiffusionEngine:
if getattr(self.od_config, "parallel_config", None) is not None:
dp_size = getattr(self.od_config.parallel_config, "data_parallel_size", 1)
dist_offload = getattr(self.od_config, "enable_distributed_layerwise_offload", False)
if dp_size > 1 and dist_offload:
if dp_size > 1 and dist_offload and getattr(self.od_config, "dlo_use_allgather", True):
self.scheduler.max_num_running_reqs = dp_size
self.dp_concurrent = True
# Set batch admission wait so concurrent requests accumulate
@@ -416,11 +416,8 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
)
extra_args_signatures: set = set()
for nr in new_reqs:
ea = getattr(nr.req, "extra_args", None)
if ea and isinstance(ea, dict):
extra_args_signatures.add(json.dumps(ea, sort_keys=True))
else:
extra_args_signatures.add(None)
ea = getattr(nr.req.sampling_params, "extra_args", None)
extra_args_signatures.add(json.dumps(ea, sort_keys=True, default=repr) if ea is not None else None)
if len(extra_args_signatures) > 1:
raise ValueError(
"DP multi-concurrency requires all concurrent requests to "
@@ -521,6 +518,18 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
if len(scheduler_output.scheduled_new_reqs) <= 1:
return self.execute_request(scheduler_output)
parallel_config = getattr(self.od_config, "parallel_config", None)
dp_size = getattr(parallel_config, "data_parallel_size", 1)
if (
dp_size > 1
and getattr(self.od_config, "enable_distributed_layerwise_offload", False)
and getattr(self.od_config, "dlo_use_allgather", True)
):
# DLO DP uses one independent request per DP replica. It is not a
# fused pipeline request batch, so models such as MiniMax-H3 do not
# need to advertise supports_request_batch=True.
return self.execute_request(scheduler_output)
result = self.collective_rpc(
"execute_model_batch",
args=(scheduler_output, self.od_config),
@@ -599,7 +608,12 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
# so the D2H copy runs on a side stream in the worker background
# thread while the default stream is free for the next forward.
# pump routes the result back to a Future via rpc_id.
if not self.od_config.step_execution and method in ("execute_model", "execute_model_batch"):
multi_rank_reply = unique_reply_rank is None and exec_all_ranks
if (
not self.od_config.step_execution
and method in ("execute_model", "execute_model_batch")
and not multi_rank_reply
):
rpc_id = self._next_rpc_id()
rpc_request["rpc_id"] = rpc_id
fut: concurrent.futures.Future = concurrent.futures.Future()
@@ -1029,16 +1029,84 @@ class MiniMaxH3Qwen3VLEncoder(nn.Module):
def load_to_device(self) -> None:
if not self.is_loaded:
return
if getattr(self, "_omni_layerwise_enabled", False):
for name, child in self.vision.named_children():
if name != "blocks":
child.to(self.device_target)
for name, child in self.text_model.named_children():
if name != "layers":
child.to(self.device_target)
return
self.vision.to(self.device_target)
self.text_model.to(self.device_target)
def offload_to_cpu(self) -> None:
if not self.is_loaded:
return
if getattr(self, "_omni_layerwise_enabled", False):
for hook in self._omni_layerwise_hooks:
hook.offload_layer()
for name, child in self.vision.named_children():
if name != "blocks":
child.to("cpu")
for name, child in self.text_model.named_children():
if name != "layers":
child.to("cpu")
torch.accelerator.empty_cache()
return
self.vision.to("cpu")
self.text_model.to("cpu")
torch.accelerator.empty_cache()
def enable_omni_layerwise_offload(self, *, pin_memory: bool = True) -> None:
"""Stream the TP-local Qwen vision/text blocks for low-HBM serving.
The encoder has its own TP process group, so these blocks must remain
rank-local. Reusing the DiT AllGather group would concatenate
different TP shards and corrupt the encoder weights.
"""
if not self.is_loaded or getattr(self, "_omni_layerwise_enabled", False):
return
from vllm_omni.diffusion.offloader.layerwise_backend import apply_block_hook
from vllm_omni.platforms import current_omni_platform
self._omni_layerwise_hooks = []
self._omni_layerwise_block_groups = []
copy_stream = current_omni_platform.Stream()
for blocks in (self.vision.blocks, self.text_model.layers):
if len(blocks) <= 1:
continue
last_hook = apply_block_hook(
blocks[-1],
blocks[0],
self.device_target,
copy_stream,
pin_memory,
)
hooks = [last_hook]
for index, block in enumerate(blocks[:-1]):
hooks.append(
apply_block_hook(
block,
blocks[index + 1],
self.device_target,
copy_stream,
pin_memory,
)
)
for index, hook in enumerate(hooks):
hook._prev_hook = hooks[index - 1]
self._omni_layerwise_hooks.extend(hooks)
self._omni_layerwise_block_groups.append(blocks)
self._omni_layerwise_enabled = bool(self._omni_layerwise_hooks)
logger.info(
"MiniMax H3 encoder layerwise offload enabled on %d blocks across %d stacks",
sum(len(blocks) for blocks in self._omni_layerwise_block_groups),
len(self._omni_layerwise_block_groups),
)
def _encode(
self,
input_ids: torch.Tensor,
@@ -376,18 +376,30 @@ class MiniMaxH3Attention(nn.Module):
*,
cu_seqlens: torch.Tensor,
max_seqlen: int,
packed_total: int,
) -> torch.Tensor:
"""Run packed attention as a small eager island.
The scalar packed-layout metadata and the CuTe FlashAttention-4 DSL
are intentionally opaque to Dynamo. Keeping this boundary narrow lets
regional compile fuse projections, norms, RoPE, and the surrounding
DiT block without repeatedly graph-breaking inside the FA4 compiler.
The scalar packed-layout metadata and backend-specific attention
kernels are intentionally opaque to Dynamo. Keeping this boundary
narrow lets regional compile fuse projections, norms, RoPE, and the
surrounding DiT block without repeated graph breaks.
"""
used = int(cu_seqlens[1].item())
packed_total = int(cu_seqlens[-1].item())
# max_seqlen is already the first (real) packed document length. Do
# not read the CUDA cu_seqlens scalars here: this function runs once
# per layer and .item() would serialize every attention launch.
if not 0 < max_seqlen <= packed_total:
raise ValueError(
f"max_seqlen must be within the packed sequence, got {max_seqlen} for length {packed_total}"
)
used = min(max_seqlen, packed_total)
attn_mask = None
if used < packed_total:
# Ring attention can dispatch to a different implementation from the
# configured backend, so this no-mask fast path is local-only.
prefix_slice = (
not getattr(self.attention, "use_ring", False) and self.attention.attn_backend.supports_prefix_kv_slicing
)
if used < packed_total and not prefix_slice:
attn_mask = torch.arange(packed_total, device=q.device)[None] < used
metadata = AttentionMetadata(
attn_mask=attn_mask,
@@ -396,6 +408,7 @@ class MiniMaxH3Attention(nn.Module):
"cu_seqlens_k": cu_seqlens,
"max_seqlen_q": max_seqlen,
"max_seqlen_k": max_seqlen,
"valid_kv_length": used,
},
)
return self.attention(
@@ -412,6 +425,7 @@ class MiniMaxH3Attention(nn.Module):
rope_freqs: torch.Tensor | None,
cu_seqlens: torch.Tensor,
max_seqlen: int,
packed_total: int | None = None,
sp_seq_lens: list[int] | None = None,
) -> torch.Tensor:
"""x: [T, hidden] packed thd rows -> [T, hidden].
@@ -448,6 +462,10 @@ class MiniMaxH3Attention(nn.Module):
v,
cu_seqlens=cu_seqlens,
max_seqlen=max_seqlen,
# Before Ulysses, q contains only this rank's row shard. The
# backend receives the global sequence after all-to-all, so carry
# its Python length explicitly instead of inferring it from q.
packed_total=packed_total if packed_total is not None else q.shape[0],
)
out = out.reshape(total, self.num_heads * self.head_dim)
out, _ = self.out_proj(out)
@@ -655,6 +673,7 @@ class MiniMaxH3DiTBlock(nn.Module):
rope_freqs: torch.Tensor,
cu_seqlens: torch.Tensor,
max_seqlen: int,
packed_total: int,
sp_seq_lens: list[int] | None = None,
) -> torch.Tensor:
"""x: [T, H]; t_emb: [M, t_dim]; combined_indices: [T]
@@ -681,6 +700,7 @@ class MiniMaxH3DiTBlock(nn.Module):
rope_freqs=rope_freqs,
cu_seqlens=cu_seqlens,
max_seqlen=max_seqlen,
packed_total=packed_total,
sp_seq_lens=sp_seq_lens,
)
x = _modulate_gate(residual, gate_msa, h, combined_indices, dtype=_BF16_DTYPE)
@@ -1127,6 +1147,7 @@ class MiniMaxH3DiTModel(nn.Module):
rope_freqs=block_rope,
cu_seqlens=cu_seqlens,
max_seqlen=max_seqlen,
packed_total=seq_len,
)
hidden = self.sp_gather(hidden)
@@ -8,6 +8,7 @@ import math
import os
import tempfile
from collections.abc import Iterable, Mapping, Sequence
from contextlib import contextmanager
from pathlib import Path
from typing import Any, ClassVar
@@ -36,6 +37,7 @@ from vllm_omni.diffusion.models.interface import (
SupportsComponentDiscovery,
)
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin
from vllm_omni.diffusion.offloader import OffloadPlan
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import (
DiffusionPipelineProfilerMixin,
)
@@ -454,8 +456,25 @@ class MiniMaxH3Pipeline(
"""CFG-distilled joint video/audio generation for MiniMax H3."""
_dit_modules: ClassVar[list[str]] = ["transformer"]
# TODO(offload): Reconcile this static plan with the modular H3 pipeline
# (#5720). Its combined mode has ``transformer`` and ``transformers_ref``;
# build the resident/staging plan from the selected task partition so a
# consumer-GPU single-task server never stages both DiTs.
_encoder_modules: ClassVar[list[str]] = ["text_encoder"]
_vae_modules: ClassVar[list[str]] = ["video_vae", "audio_vae"]
_offload_plan: ClassVar[OffloadPlan] = OffloadPlan(
offload_submodules={"token_refiner": "blocks"},
resident_dit_paths=frozenset({"transformer"}),
encoder_block_attrs={"text_encoder": ("vision.blocks", "text_model.layers")},
on_demand_component_paths=frozenset({"text_encoder", "video_vae", "audio_vae"}),
)
# H3's regular loader performs checkpoint-layout conversions (grouped QKV
# and fused MLP). Keep it on that path until mmap can run the same loader
# callbacks for every affected parameter.
_supports_mmap_loading: ClassVar[bool] = False
# TODO(offload): Re-enable after the generic rank-local mmap path can run
# this model's grouped-QKV reorder and fused-MLP packing before TP sharding.
# Do not bypass the regular loader until that equivalence is tested.
_PROFILER_TARGETS: ClassVar[list[str]] = [
"_prepare_reference_videos",
"encode_prompt",
@@ -542,13 +561,19 @@ class MiniMaxH3Pipeline(
load_model=rank < text_encoder_tp_size,
encoder_group=self.text_encoder_group,
)
stage_components = bool(
od_config.enable_layerwise_offload or getattr(od_config, "enable_distributed_layerwise_offload", False)
)
component_load_device = torch.device("cpu") if stage_components else self.device
self.video_vae = MiniMaxH3VideoVAE(
os.path.join(model_path, "video_vae"),
device=self.device,
load_device=component_load_device,
)
self.audio_vae = MiniMaxH3AudioVAE(
os.path.join(model_path, "audio_vae"),
device=self.device,
load_device=component_load_device,
)
# Registry-side VAE patch-parallel discovery uses ``pipeline.vae``.
self.vae = self.video_vae
@@ -935,12 +960,16 @@ class MiniMaxH3Pipeline(
input_ids: torch.Tensor,
vision_kwargs: dict[str, torch.Tensor],
) -> torch.Tensor:
if self.od_config.enable_cpu_offload:
if self.od_config.enable_cpu_offload and not getattr(
self.od_config, "enable_distributed_layerwise_offload", False
):
# Invoke nn.Module.__call__ so the generic model-level offloader
# swaps the resident DiT and encoder.
return self.text_encoder(input_ids, **vision_kwargs)
if self.od_config.enable_layerwise_offload:
if self.od_config.enable_layerwise_offload or getattr(
self.od_config, "enable_distributed_layerwise_offload", False
):
# Layerwise DiT offload already provides the low-residency encoder
# phase used by the checkpoint reference.
self.text_encoder.load_to_device()
@@ -955,12 +984,33 @@ class MiniMaxH3Pipeline(
self.text_encoder.load_to_device()
return self.text_encoder.encode_ids(input_ids, **vision_kwargs)
def _uses_manual_component_offload(self) -> bool:
od_config = getattr(self, "od_config", None)
return bool(
getattr(od_config, "enable_layerwise_offload", False)
or getattr(od_config, "enable_distributed_layerwise_offload", False)
)
@contextmanager
def _component_on_device(self, component: nn.Module):
staged = self._uses_manual_component_offload()
if staged:
component.load_to_device()
try:
yield
finally:
if staged:
component.offload_to_cpu()
def _encode_visual_condition(
self,
image: Image.Image,
) -> torch.Tensor:
_, rank, _ = _dit_rank_world()
rows = self.video_vae.encode_image(image) if rank == 0 else None
rows = None
if rank == 0:
with self._component_on_device(self.video_vae):
rows = self.video_vae.encode_image(image)
return _broadcast_tensor(
rows,
dtype=torch.float32,
@@ -996,7 +1046,8 @@ class MiniMaxH3Pipeline(
rows = None
audio_t = 0
if rank == 0:
rows, audio_t = self.audio_vae.encode_waveform(*audio)
with self._component_on_device(self.audio_vae):
rows, audio_t = self.audio_vae.encode_waveform(*audio)
audio_t_tensor = torch.tensor(
[audio_t],
dtype=torch.long,
@@ -1042,6 +1093,15 @@ class MiniMaxH3Pipeline(
prepared_videos: list[dict[str, Any]] | None,
*,
count: int,
) -> tuple[torch.Tensor, list[tuple[int, int, int]]]:
with self._component_on_device(self.video_vae):
return self._encode_video_conditions_resident(prepared_videos, count=count)
def _encode_video_conditions_resident(
self,
prepared_videos: list[dict[str, Any]] | None,
*,
count: int,
) -> tuple[torch.Tensor, list[tuple[int, int, int]]]:
group, rank, world_size = _dit_rank_world()
distributed_encode = self.video_vae.is_distributed_enabled()
@@ -1089,6 +1149,15 @@ class MiniMaxH3Pipeline(
prepared_videos: list[dict[str, Any]] | None,
*,
has_audio: list[bool],
) -> tuple[torch.Tensor | None, list[int]]:
with self._component_on_device(self.audio_vae):
return self._encode_video_audio_conditions_resident(prepared_videos, has_audio=has_audio)
def _encode_video_audio_conditions_resident(
self,
prepared_videos: list[dict[str, Any]] | None,
*,
has_audio: list[bool],
) -> tuple[torch.Tensor | None, list[int]]:
_, rank, _ = _dit_rank_world()
count = sum(has_audio)
@@ -1156,6 +1225,17 @@ class MiniMaxH3Pipeline(
)
return video_rows, audio_rows
@contextmanager
def _resident_dit_layers_on_device(self):
controller = getattr(self, "_dlo_residency_controller", None)
if controller is not None:
controller.load_resident_layers()
try:
yield
finally:
if controller is not None:
controller.offload_resident_layers()
def diffuse(
self,
*,
@@ -1277,21 +1357,22 @@ class MiniMaxH3Pipeline(
num_steps=num_steps,
shift_scale=audio_shift,
)
with self.progress_bar(total=len(video_sigmas) - 1) as progress:
video_rows, audio_rows = minimax_h3_denoise_loop(
model=self.transformer,
positive=branch,
initial_video_rows=initial_video,
initial_audio_rows=initial_audio,
keyframe_cond_rows=visual_anchor,
audio_ref_rows=audio_anchor,
sigmas_video=video_sigmas,
sigmas_audio=audio_sigmas,
device=self.device,
imgvid_cond_noise_aug_for_inference=(MINIMAX_H3_IMGVID_COND_TIMESTEP),
audio_cond_noise_aug_for_inference=(MINIMAX_H3_AUDIO_REF_COND_TIMESTEP),
on_step=lambda step, video, audio: progress.update(),
)
with self._resident_dit_layers_on_device():
with self.progress_bar(total=len(video_sigmas) - 1) as progress:
video_rows, audio_rows = minimax_h3_denoise_loop(
model=self.transformer,
positive=branch,
initial_video_rows=initial_video,
initial_audio_rows=initial_audio,
keyframe_cond_rows=visual_anchor,
audio_ref_rows=audio_anchor,
sigmas_video=video_sigmas,
sigmas_audio=audio_sigmas,
device=self.device,
imgvid_cond_noise_aug_for_inference=(MINIMAX_H3_IMGVID_COND_TIMESTEP),
audio_cond_noise_aug_for_inference=(MINIMAX_H3_AUDIO_REF_COND_TIMESTEP),
on_step=lambda step, video, audio: progress.update(),
)
target_video = video_rows[branch.update_mask_dev]
video_latent = minimax_h3_unpatchify_video_tokens(
@@ -1320,14 +1401,16 @@ class MiniMaxH3Pipeline(
height: int,
width: int,
) -> tuple[torch.Tensor, torch.Tensor]:
with current_omni_platform.create_autocast_context(
device_type=self.device.type,
dtype=torch.float16,
enabled=True,
):
video = self.video_vae.decode_latent(video_latent)
with self._component_on_device(self.video_vae):
with current_omni_platform.create_autocast_context(
device_type=self.device.type,
dtype=torch.float16,
enabled=True,
):
video = self.video_vae.decode_latent(video_latent)
video = video[..., :height, :width].contiguous()
audio = self.audio_vae.decode_latent(audio_latent)
with self._component_on_device(self.audio_vae):
audio = self.audio_vae.decode_latent(audio_latent)
return video, audio
@torch.no_grad()
+49 -2
View File
@@ -19,6 +19,7 @@ from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor impor
DistributedVaeMixin,
)
from vllm_omni.diffusion.distributed.parallel_state import get_dit_group
from vllm_omni.diffusion.offloader.module_residency import PinnedModuleStager
from .packed_tokens import minimax_h3_patchify_video_latent
@@ -102,8 +103,10 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
component_path: str,
*,
device: torch.device,
load_device: torch.device | None = None,
) -> None:
super().__init__()
self._device_target = device
self.config_dict = _load_component_config(component_path)
self.remote = _load_remote_component(
component_path,
@@ -112,13 +115,34 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
# Match the reference loader contract: video VAE weights stay FP32.
# Keyframe encoding is numerically sensitive to first casting the
# checkpoint through FP16; decode still runs under FP16 autocast.
self.remote.eval().to(device=device, dtype=torch.float32)
initial_device = load_device or device
self.remote.eval().to(device=initial_device, dtype=torch.float32)
self._stager = None
if initial_device.type == "cpu" and device.type not in ("cpu", "meta"):
self._stager = PinnedModuleStager(
self.remote,
device,
pin_memory=True,
)
self.model = self.remote.model
self.use_tiling = True
self.use_slicing = False
self.parallel_size = 1
self.device_module = torch.get_device_module()
def load_to_device(self) -> None:
if self._stager is not None:
self._stager.load()
else:
self.remote.to(self._device_target)
def offload_to_cpu(self) -> None:
if self._stager is not None:
self._stager.offload()
else:
self.remote.to("cpu")
torch.accelerator.empty_cache()
def set_parallel_size(
self,
parallel_size: int,
@@ -277,8 +301,10 @@ class MiniMaxH3AudioVAE(nn.Module):
component_path: str,
*,
device: torch.device,
load_device: torch.device | None = None,
) -> None:
super().__init__()
self._device_target = device
self.config_dict = _load_component_config(component_path)
self.remote = _load_remote_component(
component_path,
@@ -286,10 +312,31 @@ class MiniMaxH3AudioVAE(nn.Module):
)
# The checkpoint's audio VAE contract is FP32 for both reference
# encoding and waveform decoding.
self.remote.eval().to(device=device, dtype=torch.float32)
initial_device = load_device or device
self.remote.eval().to(device=initial_device, dtype=torch.float32)
self._stager = None
if initial_device.type == "cpu" and device.type not in ("cpu", "meta"):
self._stager = PinnedModuleStager(
self.remote,
device,
pin_memory=True,
)
self.model = self.remote.model
self.sample_rate = int(self.config_dict["sample_rate"])
def load_to_device(self) -> None:
if self._stager is not None:
self._stager.load()
else:
self.remote.to(self._device_target)
def offload_to_cpu(self) -> None:
if self._stager is not None:
self._stager.offload()
else:
self.remote.to("cpu")
torch.accelerator.empty_cache()
@torch.inference_mode()
def encode_waveform(
self,
@@ -16,6 +16,7 @@ from .distributed_layerwise_backend import (
remove_distributed_block_hook,
)
from .layerwise_backend import LayerWiseOffloadBackend
from .module_residency import PinnedModuleStager
from .offload_plan import OffloadPlan, get_offload_plan, supports_mmap_loading
from .sequential_backend import (
ModelLevelOffloadBackend,
@@ -41,6 +42,7 @@ __all__ = [
"DistributedLayerwiseOffloadBackend",
"DistributedLayerwiseOffloadHook",
"ModelLevelOffloadBackend",
"PinnedModuleStager",
"apply_sequential_offload",
"remove_sequential_offload",
"apply_distributed_block_hook",
+20 -7
View File
@@ -27,7 +27,10 @@ class OffloadConfig:
pin_cpu_memory: bool = True
use_hsdp: bool = False
dp_size: int = 1 # derived from parallel_config, not user-configurable
dlo_use_allgather: bool = True # if True: shard + AllGather; if False: full weights H2D only
# True: add DP sharding + AllGather. False: stream the standard loader's
# rank-local tensors (including TP-local shards) with H2D only.
dlo_use_allgather: bool = True
dlo_resident_layers: int = 0 # leading DiT layers kept on device
model_path: str | None = None # checkpoint path for mmap weight loading
@classmethod
@@ -94,17 +97,26 @@ class OffloadConfig:
else:
strategy = OffloadStrategy.NONE
# When dlo_use_allgather=False, each rank loads full weights (no sharding,
# no AllGather). This avoids AllGather synchronization requirements
# (concurrent requests, dummy run skip) at the cost of N× CPU memory.
# With dlo_use_allgather=False, do not add another DP shard. Each rank
# streams the tensors produced by the standard loader, which may
# already be TP-local shards. This avoids AllGather synchronization
# requirements (concurrent requests, dummy run skip).
dlo_use_allgather = getattr(od_config, "dlo_use_allgather", True)
dlo_resident_layers = int(getattr(od_config, "dlo_resident_layers", 0))
if dlo_resident_layers < 0:
raise ValueError(f"dlo_resident_layers must be >= 0, got {dlo_resident_layers}")
if dlo_resident_layers and dlo_use_allgather:
raise ValueError(
"dlo_resident_layers currently requires --dlo-no-use-allgather so "
"resident blocks use weights prepared by the standard TP-aware loader"
)
# If dlo_use_allgather=False, force dp_size=1 (each rank independent)
if enable_distributed_layerwise_offload and not dlo_use_allgather:
dp_size = 1
logger.info(
"Distributed layerwise offload: dlo_use_allgather=False, "
"each rank loads full weights (no shard, no AllGather)"
"streaming standard-loader rank-local weights (no DP shard or AllGather)"
)
# HSDP already shards parameters into DTensors. Running distributed
@@ -114,8 +126,8 @@ class OffloadConfig:
raise ValueError(
"Distributed layerwise offload with AllGather is incompatible with "
"HSDP: HSDP parameters are already sharded DTensors, and the offloader "
"would double-shard them. Use --dlo-no-use-allgather (full weights per "
"rank) or disable HSDP."
"would double-shard them. Use --dlo-no-use-allgather (standard-loader "
"rank-local weights) or disable HSDP."
)
return cls(
@@ -124,6 +136,7 @@ class OffloadConfig:
use_hsdp=use_hsdp,
dp_size=dp_size,
dlo_use_allgather=dlo_use_allgather,
dlo_resident_layers=dlo_resident_layers,
model_path=getattr(od_config, "model", None),
)
@@ -219,6 +219,15 @@ class DistributedLayerwiseOffloadHook(ModelHook):
weights_with_local = []
for name, t in name2weights.items():
local_t = t.to_local() if hasattr(t, "to_local") else t
mmap_transform = getattr(t, "mmap_weight_transform", None)
if callable(mmap_transform) and getattr(t, "mmap_weight_transform_pending", False):
# Some checkpoints use a layout that is converted by the
# regular weight loader (for example MiniMax-H3 grouped
# QKV). Apply that conversion one block at a time while
# copying the rank-local CPU shard. Keeping the raw
# parameter as an mmap view avoids a private full-model
# copy in every worker.
local_t = mmap_transform(local_t)
weights_with_local.append((name, t, local_t))
total_numel = sum(local.numel() for _, _, local in weights_with_local)
@@ -331,9 +340,9 @@ class DistributedLayerwiseOffloadHook(ModelHook):
with current_omni_platform.stream(self.comm_stream):
for dtype, local_shard in gpu_shards.items():
# Slice the shared (max-sized) output buffer down to this
# block's actual AllGather output size. The buffers are
# sized to the *largest* block across all groups, so for
# any smaller block the full buffer would violate the
# block's actual AllGather output size. The buffers are
# sized to the *largest* block across all groups, so for any
# smaller block the full buffer would violate the
# all_gather_into_tensor contract
# (output.numel() == world_size * input.numel()).
# Repoint offsets are relative to the block's flattened
@@ -485,6 +494,111 @@ def remove_distributed_block_hook(module: nn.Module) -> None:
logger.debug("Removed distributed offload hook from %s", module.__class__.__name__)
class PinnedResidentLayerGroup:
"""Keep selected layers in pinned host memory between requests.
TODO(offload): Extract this alongside PinnedModuleStager after the
distributed shard-and-pin operation becomes a shared storage primitive.
It currently remains here because it depends on DLO's local-shard layout.
Unlike ``module.to(device)``/``module.to("cpu")``, this group retains a
pinned CPU master copy and never copies generated device weights back to
host. Entering the denoise stage performs one asynchronous H2D pass;
leaving it only restores zero-sized placeholders and releases the device
buffers. This lets the following VAE stage reuse the same HBM.
The resident path is intentionally local-shard only. With tensor
parallelism, the regular model loader has already produced each rank's TP
shard, so no DP AllGather is required or desirable here.
"""
def __init__(
self,
blocks: list[nn.Module],
device: torch.device,
copy_stream: Any,
pin_memory: bool,
) -> None:
self.device = device
self.copy_stream = copy_stream
self.loaded = False
self._states: list[dict[str, Any]] = []
self._gpu_buffers: list[dict[torch.dtype, torch.Tensor]] = []
for block in blocks:
params = dict(block.named_parameters())
bufs = dict(block.named_buffers())
targets: dict[str, torch.Tensor] = {**params, **bufs}
cpu_shards, metadata = DistributedLayerwiseOffloadHook._shard_and_pin(
params,
bufs,
dp_size=1,
rank=0,
pin_memory=pin_memory,
)
self._states.append(
{
"targets": targets,
"cpu_shards": cpu_shards,
"metadata": metadata,
}
)
def load(self) -> None:
if self.loaded:
return
# Allocate on the compute stream so the caching allocator can reuse
# blocks released by the encoder stage. Allocating on the copy stream
# would create a separate stream-local pool and inflate peak HBM.
gpu_buffers: list[dict[torch.dtype, torch.Tensor]] = []
for state in self._states:
block_buffers: dict[torch.dtype, torch.Tensor] = {}
for dtype, cpu_shard in state["cpu_shards"].items():
block_buffers[dtype] = torch.empty(
cpu_shard.shape,
dtype=dtype,
device=self.device,
)
gpu_buffers.append(block_buffers)
self.copy_stream.wait_stream(current_omni_platform.current_stream())
ready = current_omni_platform.Event()
with current_omni_platform.stream(self.copy_stream):
for state, block_buffers in zip(self._states, gpu_buffers):
for dtype, cpu_shard in state["cpu_shards"].items():
block_buffers[dtype].copy_(cpu_shard, non_blocking=True)
ready.record(self.copy_stream)
for state, block_buffers in zip(self._states, gpu_buffers):
targets = state["targets"]
for dtype, metas in state["metadata"].items():
gpu_buffer = block_buffers[dtype]
for meta in metas:
set_tensor_storage(
targets[meta["name"]],
gpu_buffer[meta["offset"] : meta["offset"] + meta["numel"]].view(meta["shape"]),
)
current_omni_platform.current_stream().wait_event(ready)
self._gpu_buffers = gpu_buffers
self.loaded = True
def offload(self) -> None:
if not self.loaded:
return
# Denoise kernels consume these buffers on the current compute stream.
# Synchronize once at the stage boundary; no D2H copy is necessary
# because the pinned CPU master weights were never overwritten.
current_omni_platform.synchronize()
for state in self._states:
for target in state["targets"].values():
set_tensor_storage(target, make_offload_placeholder(target))
self._gpu_buffers.clear()
self.loaded = False
# ---------------------------------------------------------------------- #
# Backend #
# ---------------------------------------------------------------------- #
@@ -502,6 +616,8 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend):
overlapped with computation.
"""
_MMAP_PARAM_ATTRS = ("weight_loader", "mmap_weight_transform")
def __init__(self, config: OffloadConfig, device: torch.device):
super().__init__(config, device)
@@ -512,6 +628,41 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend):
self.rank = 0
self._blocks: list[list[nn.Module]] = []
self._all_hook_groups: list[list[DistributedLayerwiseOffloadHook]] = []
self._resident_blocks: list[nn.Module] = []
self._resident_layer_group: PinnedResidentLayerGroup | None = None
def load_resident_layers(self) -> None:
"""Load the model-declared leading blocks for the denoise stage."""
if self._resident_layer_group is not None:
self._resident_layer_group.load()
def offload_resident_layers(self) -> None:
"""Release leading blocks before VAE decode to bound peak HBM."""
if self._resident_layer_group is not None:
self._resident_layer_group.offload()
def _remember_mmap_param_attrs(self, pipeline: nn.Module) -> None:
"""Save loader metadata before ``to_empty`` replaces Parameters."""
self._mmap_param_attrs = {
name: {attr: value for attr in self._MMAP_PARAM_ATTRS if (value := getattr(param, attr, None)) is not None}
for name, param in pipeline.named_parameters()
}
def _attach_mmap_param_attrs(
self,
name: str,
replacement: nn.Parameter,
source: nn.Parameter | None = None,
) -> None:
attrs = getattr(self, "_mmap_param_attrs", {}).get(name, {})
for attr in self._MMAP_PARAM_ATTRS:
value = attrs.get(attr)
if value is None and source is not None:
value = getattr(source, attr, None)
if value is not None:
setattr(replacement, attr, value)
if attr == "mmap_weight_transform":
replacement.mmap_weight_transform_pending = True
def _load_weights_via_mmap(self, pipeline: nn.Module, modules) -> None:
"""Load DiT weights from safetensors via mmap views (no RSS).
@@ -614,6 +765,12 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend):
"instead of TP, or disable distributed layerwise offload."
)
# ``Module.to_empty`` creates new Parameter objects and drops custom
# attributes installed by vLLM weight loaders. Preserve them by full
# pipeline name so mmap replacements can reproduce checkpoint layout
# transforms such as MiniMax-H3 grouped QKV.
self._remember_mmap_param_attrs(pipeline)
#
# Important: when DiT modules are nested (e.g. transformer contains
# transformer.language_model), to_empty("meta") on the parent
@@ -738,7 +895,9 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend):
parent = parent[int(part)]
else:
parent = getattr(parent, part)
parent._parameters[parts[-1]] = torch.nn.Parameter(tensor, requires_grad=param.requires_grad)
replacement = torch.nn.Parameter(tensor, requires_grad=param.requires_grad)
self._attach_mmap_param_attrs(name, replacement, param)
parent._parameters[parts[-1]] = replacement
loaded += 1
loaded_names.add(name)
@@ -793,7 +952,9 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend):
parent = parent[int(part)]
else:
parent = getattr(parent, part)
parent._parameters[bparts[-1]] = torch.nn.Parameter(tensor, requires_grad=bparam.requires_grad)
replacement = torch.nn.Parameter(tensor, requires_grad=bparam.requires_grad)
self._attach_mmap_param_attrs(full_param_name, replacement, bparam)
parent._parameters[bparts[-1]] = replacement
block_loaded += 1
loaded_names.add(full_param_name)
@@ -904,7 +1065,9 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend):
parent = parent[int(part)]
else:
parent = getattr(parent, part)
parent._parameters[parts[-1]] = torch.nn.Parameter(tensor, requires_grad=param.requires_grad)
replacement = torch.nn.Parameter(tensor, requires_grad=param.requires_grad)
self._attach_mmap_param_attrs(full_name, replacement, param)
parent._parameters[parts[-1]] = replacement
loaded += 1
if loaded > 0:
@@ -962,24 +1125,74 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend):
coord.ranks,
)
def _register_on_demand_hook(self, module: nn.Module, label: str) -> None:
"""Move *module* to GPU permanently.
def _register_on_demand_hook(self, module: nn.Module, label: str, *, stage_on_demand: bool = False) -> None:
"""Prepare a pipeline-managed stage component or keep it resident.
Previously, on-demand hooks moved the module to GPU before forward
and back to CPU after forward (with synchronize + empty_cache).
However, _post_forward's global synchronize() and empty_cache()
disrupt the async prefetch pipeline (copy_stream / comm_stream)
used by distributed layerwise offload, causing garbled output.
Moving the module to GPU permanently avoids this issue at the
cost of slightly higher HBM usage (VAE/encoders are small).
Components that expose an explicit stage lifecycle are initially
offloaded and loaded by their pipeline only around encode/decode.
Other models retain the conservative resident behavior because a
generic post-forward hook can disrupt the DiT prefetch streams.
"""
offload_to_cpu = getattr(module, "offload_to_cpu", None)
if stage_on_demand and callable(offload_to_cpu):
offload_to_cpu()
logger.info("Prepared %s (%s) for pipeline-managed staged offload", label, module.__class__.__name__)
return
module.to(self.device)
logger.info("Moved %s (%s) to GPU (resident)", label, module.__class__.__name__)
def _try_layerwise_offload_encoder(self, module: nn.Module, name: str, plan: OffloadPlan | None) -> bool:
"""Stream plan-declared encoder blocks on each rank without AllGather."""
if plan is None or name not in plan.encoder_block_attrs:
return False
if getattr(module, "_omni_layerwise_enabled", False):
return True
from operator import attrgetter
from vllm_omni.diffusion.offloader.layerwise_backend import apply_block_hook
hooks = []
block_groups = []
copy_stream = current_omni_platform.Stream()
for block_path in plan.encoder_block_attrs[name]:
try:
blocks = attrgetter(block_path)(module)
except AttributeError:
logger.warning("Encoder offload path %s.%s was not found", name, block_path)
continue
if not isinstance(blocks, nn.ModuleList) or len(blocks) <= 1:
logger.warning("Encoder offload path %s.%s is not a streamable block list", name, block_path)
continue
group_hooks = [
apply_block_hook(blocks[-1], blocks[0], self.device, copy_stream, self.config.pin_cpu_memory)
]
group_hooks.extend(
apply_block_hook(block, blocks[index + 1], self.device, copy_stream, self.config.pin_cpu_memory)
for index, block in enumerate(blocks[:-1])
)
for index, hook in enumerate(group_hooks):
hook._prev_hook = group_hooks[index - 1]
hooks.extend(group_hooks)
block_groups.append(blocks)
if not hooks:
return False
# The component lifecycle uses these generic attributes to keep only
# non-block encoder state resident during the encode phase.
module._omni_layerwise_hooks = hooks
module._omni_layerwise_block_groups = block_groups
module._omni_layerwise_enabled = True
logger.info(
"Enabled rank-local layerwise offload for encoder %s (%d blocks across %d stacks)",
name,
sum(len(blocks) for blocks in block_groups),
len(block_groups),
)
return True
def _try_layerwise_offload_submodule(self, module: nn.Module, name: str, plan: OffloadPlan | None = None) -> bool:
"""Try to apply layerwise offload to a large submodule's blocks.
Resolution order:
1. OffloadPlan.offload_submodules (declarative, if plan is provided)
2. Heuristic search for common block-list attributes
@@ -1089,6 +1302,78 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend):
self._blocks.append(blocks)
return True
def _prepare_dit_non_block_modules(
self,
dit_module: nn.Module,
dit_name: str,
blocks_attr_names: list[str],
all_dit_modules: set[int],
plan: OffloadPlan | None,
) -> None:
"""Place or hook the DiT parts that are outside its repeated blocks.
This must run even when every repeated block is resident. Otherwise
an all-resident stage skips placement for modules such as H3's token
refiner and enters the forward pass with CPU or meta tensors.
"""
_ON_DEMAND_THRESHOLD = _ON_DEMAND_THRESHOLD_MB
for name, module in dit_module.named_children():
if name in blocks_attr_names:
logger.debug("Skipped blocks module %s", name)
continue
has_meta = any(getattr(param, "is_meta", False) for param in module.parameters())
if has_meta:
self._load_module_weights_from_mmap(module, dit_name, name)
module_mb = (
sum(
param.nelement() * param.element_size() if not getattr(param, "is_meta", False) else 0
for param in module.parameters()
)
/ 1048576
)
explicitly_planned = plan is not None and name in plan.offload_submodules
if explicitly_planned or module_mb > _ON_DEMAND_THRESHOLD:
if id(module) in all_dit_modules:
logger.info("Submodule '%s' is already a DiT module, skipping layerwise offload", name)
elif self._try_layerwise_offload_submodule(module, name, plan):
pass
else:
self._register_on_demand_hook(module, name)
continue
try:
module.to(self.device)
except (NotImplementedError, RuntimeError):
self._load_module_weights_from_mmap(module, dit_name, name)
# Non-persistent buffers such as RoPE frequencies do not
# exist in the checkpoint and must be reconstructed.
has_meta_buffer = any(getattr(buffer, "is_meta", False) for buffer in module.buffers(recurse=True))
if has_meta_buffer:
saved_params = {
param_name: param.data.clone()
for param_name, param in module.named_parameters()
if not getattr(param, "is_meta", False)
}
module.to_empty(device=self.device)
for submodule in module.modules():
if hasattr(submodule, "reset_parameters"):
submodule.reset_parameters()
for param_name, param in module.named_parameters():
if param_name in saved_params:
param.data.copy_(saved_params[param_name])
try:
module.to(self.device)
except Exception:
logger.warning("Module %s still has meta params after mmap load", name)
for param in dit_module._parameters.values():
if param is not None and not getattr(param, "is_meta", False):
param.data = param.data.to(self.device, non_blocking=True)
for buffer in dit_module._buffers.values():
if buffer is not None:
buffer.data = buffer.data.to(self.device, non_blocking=True)
def enable(self, pipeline: nn.Module) -> None:
if self.enabled:
logger.warning("DistributedLayerwiseOffloadBackend already enabled")
@@ -1110,7 +1395,19 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend):
# When present, replaces heuristic block discovery.
plan = get_offload_plan(pipeline)
if self.config.dlo_resident_layers and (plan is None or not plan.resident_dit_paths):
logger.warning(
"dlo_resident_layers=%d was requested, but this model declares no "
"resident_dit_paths; all blocks will be streamed.",
self.config.dlo_resident_layers,
)
# Load weights via mmap for DLO+AllGather.
# TODO(offload): Add a rank-local mmap path for dlo_no_use_allgather.
# It must apply declarative checkpoint-to-runtime adapters before the
# standard TP loader shards each block, and must cover staged encoders
# and VAEs as well as DiT blocks. The current AllGather mmap path is
# deliberately not reused because it changes the weight layout.
# Gate condition MUST match diffusers_loader.py:
# supports_mmap_loading(pipeline) and not _has_online_quant
# When the gate is False, the loader has already loaded weights via
@@ -1137,10 +1434,17 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend):
# This saves ~4.3 GB HBM per card (VAE 1.3 + encoder 1.1 + sound 1.9)
# during the DiT forward pass. They are only needed briefly for
# text-encoding (before DiT) and VAE-decode (after DiT).
for enc in modules.encoders:
self._register_on_demand_hook(enc, "encoder")
for vae in modules.vaes:
self._register_on_demand_hook(vae, "vae")
for enc, enc_name in zip(modules.encoders, modules.encoder_names):
self._try_layerwise_offload_encoder(enc, enc_name, plan)
self._register_on_demand_hook(
enc, "encoder", stage_on_demand=plan is not None and enc_name in plan.on_demand_component_paths
)
for vae, vae_name in zip(modules.vaes, modules.vae_names):
self._register_on_demand_hook(
vae,
"vae",
stage_on_demand=(plan is not None and vae_name in plan.on_demand_component_paths),
)
# Move resident modules to GPU (small modules needed every forward)
for name, module in zip(modules.resident_names, modules.resident_modules):
@@ -1171,93 +1475,41 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend):
dit_module.to(self.device)
continue
resident_count = 0
if plan is not None and dit_name in plan.resident_dit_paths:
resident_count = min(self.config.dlo_resident_layers, len(blocks))
if resident_count:
resident_blocks = blocks[:resident_count]
self._resident_blocks.extend(resident_blocks)
blocks = blocks[resident_count:]
logger.info(
"Keeping %d leading blocks resident on %s; streaming %d tail blocks",
resident_count,
dit_name,
len(blocks),
)
self._prepare_dit_non_block_modules(
dit_module,
dit_name,
blocks_attr_names,
all_dit_modules,
plan,
)
num_blocks = len(blocks)
if num_blocks == 0:
logger.info("All blocks for %s are resident; no streaming hooks required", dit_name)
continue
if num_blocks <= 1:
logger.warning(
"#Target layers (blocks) <= 1. Skipping offloading on %s (%s)",
"#Streaming target layers (blocks) <= 1. Keeping the final block resident on %s (%s)",
dit_name,
dit_module.__class__.__name__,
)
dit_module.to(self.device)
self._resident_blocks.extend(blocks)
continue
# Move non-block modules to GPU (they stay resident).
# Large modules (> 1 GB) use on-demand hooks instead — they are
# only needed briefly (e.g. language_model for text encoding)
# and keeping them resident wastes HBM during the DiT loop.
_ON_DEMAND_THRESHOLD = _ON_DEMAND_THRESHOLD_MB # alias for readability
for name, m in dit_module.named_children():
if name not in blocks_attr_names:
_has_meta = any((p.is_meta if hasattr(p, "is_meta") else False) for p in m.parameters())
if _has_meta:
# Meta params: load from safetensors before moving to GPU
self._load_module_weights_from_mmap(m, dit_name, name)
_mb = (
sum(
(p.nelement() * p.element_size() if not (hasattr(p, "is_meta") and p.is_meta) else 0)
for p in m.parameters()
)
/ 1048576
)
if _mb > _ON_DEMAND_THRESHOLD:
# Skip if this submodule is already a separate DiT
# module (e.g. transformer.language_model when both
# "transformer.language_model" and "transformer" are
# in _dit_modules) — hooks already applied.
if id(m) in all_dit_modules:
logger.info("Submodule '%s' is already a DiT module, skipping layerwise offload", name)
elif self._try_layerwise_offload_submodule(m, name, plan):
pass # layerwise hooks applied
else:
self._register_on_demand_hook(m, name)
else:
try:
m.to(self.device)
except (NotImplementedError, RuntimeError):
# Meta params: load from safetensors via mmap, then move to GPU
self._load_module_weights_from_mmap(m, dit_name, name)
# Materialize any remaining meta buffers (e.g. RoPE
# inv_freq, timestep freqs) on device and
# recompute them from their original formulas.
# These are non-persistent buffers not present in
# the checkpoint — they must be reconstructed, not
# left as torch.empty.
# NOTE: Do NOT call reset_parameters() here — it
# would re-initialize ALL parameters (including
# mmap-loaded weights) with random values.
_has_meta_buf = any(hasattr(b, "is_meta") and b.is_meta for b in m.buffers(recurse=True))
if _has_meta_buf:
# Save mmap-loaded param values before
# reset_parameters (which re-initializes
# ALL params with random values).
saved_params = {
n: p.data.clone()
for n, p in m.named_parameters()
if not (hasattr(p, "is_meta") and p.is_meta)
}
m.to_empty(device=self.device)
for submod in m.modules():
if hasattr(submod, "reset_parameters"):
submod.reset_parameters()
# Restore mmap-loaded weights
for n, p in m.named_parameters():
if n in saved_params:
p.data.copy_(saved_params[n])
try:
m.to(self.device)
except Exception:
logger.warning("Module %s still has meta params after mmap load", name)
else:
logger.debug(f"Skipped blocks module {name}")
# Move top-level params/buffers to GPU
for param in dit_module._parameters.values():
if param is not None and not (hasattr(param, "is_meta") and param.is_meta):
param.data = param.data.to(self.device, non_blocking=True)
for buffer in dit_module._buffers.values():
if buffer is not None:
buffer.data = buffer.data.to(self.device, non_blocking=True)
# Register hooks in a circular sliding window:
# last block prefetches first block, block i prefetches block (i+1)
# All hooks share 2 global device buffers (RFC: "exactly two layers on device")
@@ -1314,7 +1566,17 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend):
self._all_hook_groups.append(block_hooks)
self._blocks.append(blocks)
if self._resident_blocks:
self._resident_layer_group = PinnedResidentLayerGroup(
self._resident_blocks,
self.device,
self.copy_stream,
self.config.pin_cpu_memory,
)
pipeline._dlo_residency_controller = self
if not self._all_hook_groups:
self.enabled = bool(self._resident_blocks)
return
# Unified allocation: 2 shared output buffers + 2 shared shard buffers
@@ -1424,8 +1686,11 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend):
self._on_demand_handles = []
self._on_demand_shard_infos = []
self.offload_resident_layers()
self._blocks.clear()
self._all_hook_groups.clear()
self._resident_blocks.clear()
self._resident_layer_group = None
self.enabled = False
logger.info("Distributed layer-wise offloading disabled")
@@ -19,6 +19,7 @@ class PipelineModules:
encoders: list[nn.Module]
encoder_names: list[str]
vaes: list[nn.Module]
vae_names: list[str] = field(default_factory=list)
resident_modules: list[nn.Module] = field(default_factory=list)
resident_names: list[str] = field(default_factory=list)
@@ -153,7 +154,7 @@ class ModuleDiscovery:
dit_modules, dit_names = ModuleDiscovery._collect_modules(pipeline, dit_attrs, warn_missing=declared)
encoders, encoder_names = ModuleDiscovery._collect_modules(pipeline, enc_attrs, warn_missing=declared)
vaes, _ = ModuleDiscovery._collect_modules(pipeline, vae_attrs, warn_missing=declared)
vaes, vae_names = ModuleDiscovery._collect_modules(pipeline, vae_attrs, warn_missing=declared)
residents, resident_names = ModuleDiscovery._collect_modules(pipeline, res_attrs, warn_missing=declared)
return PipelineModules(
@@ -163,5 +164,6 @@ class ModuleDiscovery:
encoder_names=encoder_names,
vaes=vaes,
resident_modules=residents,
vae_names=vae_names,
resident_names=resident_names,
)
@@ -0,0 +1,89 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""On-demand module staging backed by an immutable pinned CPU snapshot."""
from __future__ import annotations
from itertools import chain
from typing import Any
import torch
from torch import nn
from vllm_omni.platforms import current_omni_platform
from .tensor_utils import set_tensor_storage
class PinnedModuleStager:
"""Stage an immutable module without copying device weights back to CPU.
``nn.Module.to("cpu")`` performs a device-to-host copy for every parameter
after each forward. In inference the weights are immutable, so retain one
pinned CPU master instead. ``load`` materializes fresh device tensors from
that master; ``offload`` only rebinds the Parameters/Buffers to it and
releases the device allocations.
"""
def __init__(
self,
module: nn.Module,
device: torch.device,
*,
pin_memory: bool = True,
copy_stream: Any | None = None,
) -> None:
self.device = device
self.copy_stream = copy_stream or current_omni_platform.Stream()
self.loaded = False
self._entries: list[tuple[torch.Tensor, torch.Tensor]] = []
self._device_tensors: list[torch.Tensor] = []
# parameters()/buffers() remove duplicate objects by default, which
# preserves tied weights and buffer aliases when their storage moves.
for target in chain(module.parameters(), module.buffers()):
master = target.detach()
if master.device.type != "cpu":
master = master.to("cpu")
if pin_memory and not master.is_pinned():
master = master.pin_memory()
set_tensor_storage(target, master)
self._entries.append((target, master))
def load(self) -> None:
if self.loaded:
return
# Allocate on the compute stream so the caching allocator can reuse
# memory released by the preceding encoder/DiT stage.
device_tensors = [torch.empty_like(master, device=self.device) for _, master in self._entries]
compute_stream = current_omni_platform.current_stream()
self.copy_stream.wait_stream(compute_stream)
ready = current_omni_platform.Event()
with current_omni_platform.stream(self.copy_stream):
for device_tensor, (_, master) in zip(device_tensors, self._entries):
device_tensor.copy_(master, non_blocking=master.is_pinned())
ready.record(self.copy_stream)
for device_tensor, (target, _) in zip(device_tensors, self._entries):
set_tensor_storage(target, device_tensor)
compute_stream.wait_event(ready)
self._device_tensors = device_tensors
self.loaded = True
def offload(self) -> None:
if not self.loaded:
return
# The module has completed on the compute stream. Synchronize once at
# the stage boundary, then release HBM by rebinding to the unchanged
# host masters. No device-to-host transfer is necessary.
current_omni_platform.synchronize()
for target, master in self._entries:
set_tensor_storage(target, master)
self._device_tensors.clear()
self.loaded = False
current_omni_platform.empty_cache()
__all__ = ["PinnedModuleStager"]
+14 -1
View File
@@ -35,10 +35,21 @@ class OffloadPlan:
for large non-DiT submodules within a DiT that should be
independently offloaded with their own hooks.
e.g. ``{"context_encoder": "layers"}``
resident_dit_paths: DiT paths whose leading blocks may be kept on the
device when ``dlo_resident_layers`` is nonzero. Keeping this
model-declared avoids applying a consumer-GPU tuning knob to
auxiliary or dual DiTs unintentionally.
encoder_block_attrs: Maps encoder paths to rank-local block-list paths.
These blocks are streamed with ordinary layerwise hooks, never
with the DiT AllGather group.
"""
on_demand_component_paths: frozenset[str] = field(default_factory=frozenset)
block_attrs: dict[str, tuple[str, ...]] = field(default_factory=dict)
offload_submodules: dict[str, str] = field(default_factory=dict)
resident_dit_paths: frozenset[str] = field(default_factory=frozenset)
encoder_block_attrs: dict[str, tuple[str, ...]] = field(default_factory=dict)
def get_offload_plan(pipeline: nn.Module) -> OffloadPlan | None:
@@ -58,4 +69,6 @@ def supports_mmap_loading(pipeline: nn.Module) -> bool:
(to decide whether to call ``_load_weights_via_mmap``), ensuring both
paths use the same gate condition.
"""
return any(callable(getattr(type(m), "_remap_ckpt_key", None)) for m in pipeline.modules())
return bool(getattr(pipeline, "_supports_mmap_loading", True)) and any(
callable(getattr(type(m), "_remap_ckpt_key", None)) for m in pipeline.modules()
)
+1
View File
@@ -495,6 +495,7 @@ class OrchestratorArgs:
enable_layerwise_offload: bool = False
enable_distributed_layerwise_offload: bool = False
dlo_use_allgather: bool = True
dlo_resident_layers: int = 0
boundary_ratio: float | None = None
flow_shift: float | None = None
diffusion_kv_cache_dtype: str | None = None
+1
View File
@@ -1032,6 +1032,7 @@ class AsyncOmniEngine:
"enable_layerwise_offload": kwargs.get("enable_layerwise_offload", False),
"enable_distributed_layerwise_offload": kwargs.get("enable_distributed_layerwise_offload", False),
"dlo_use_allgather": kwargs.get("dlo_use_allgather", True),
"dlo_resident_layers": kwargs.get("dlo_resident_layers", 0),
"enforce_eager": False if kwargs.get("enforce_eager") is None else kwargs.get("enforce_eager"),
"diffusion_compile_granularity": (
"regional"
+14 -4
View File
@@ -646,15 +646,25 @@ class OmniServeCommand(CLISubcommand):
action="store_true",
default=True,
help="Use shard + AllGather for weight reconstruction (default: True). "
"When disabled (--dlo-no-use-allgather), each rank loads full weights "
"via H2D only — no sharding, no AllGather, no concurrent request "
"requirement, but N× CPU memory.",
"When disabled (--dlo-no-use-allgather), each rank streams the "
"standard loader's rank-local tensors via H2D only — no additional "
"DP sharding, no AllGather, and no concurrent-request requirement.",
)
omni_config_group.add_argument(
"--dlo-no-use-allgather",
dest="dlo_use_allgather",
action="store_false",
help="Disable AllGather: each rank loads full weights independently.",
help=(
"Disable AllGather and stream standard-loader rank-local weights "
"independently (including existing TP shards)."
),
)
omni_config_group.add_argument(
"--dlo-resident-layers",
type=int,
default=0,
help="Keep this many leading main-DiT blocks resident on the device "
"while distributed layerwise offload streams the remaining blocks.",
)
# Video model parameters (e.g., Wan2.2) - engine-level
omni_config_group.add_argument(