Skip to content

Commit 9a7a796

Browse files
committed
[perf]: Release H3 text encoder before DiT/VAE
GB10 unified memory disables host offload, so MiniMax H3 CUDA used to load Qwen3-VL together with the DiT and VAEs and get killed by earlyoom. Encode first, drop the encoder, then load denoise weights. Input-prep geometry comes from the VAE arch configs until those modules exist. A later generate() on the same worker still needs a new process; prompt-cache reload is not in this change.
1 parent 620bc36 commit 9a7a796

4 files changed

Lines changed: 291 additions & 8 deletions

File tree

docs/getting_started/installation/spark_performance.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,11 @@ is power-cycled. To avoid it:
161161
on: "CPU" offload uses the same unified RAM. Multi-GPU FSDP sharding remains
162162
available because it partitions weights without parking them in a separate
163163
host pool.
164+
- **MiniMax H3 / FastH3** still needs sequential loading on one GB10. The Qwen3-VL
165+
conditioner is tens of gigabytes of BF16. If the DiT and VAEs load while that
166+
encoder is still resident, the process is a typical `earlyoom` kill (Python is
167+
preferred). The CUDA pipeline now encodes first, releases the encoder, then
168+
loads DiT and VAEs. See [Offloading](../../inference/offloading.md).
164169

165170
## Gotchas specific to the GB10
166171

@@ -177,6 +182,10 @@ A few things that surprise people on this box (beyond the memory notes above):
177182
- **Cosmos-2.5** uses a Qwen2.5-VL text encoder; make sure you're on a FastVideo
178183
build recent enough to include its `transformers`-compatibility handling before
179184
running it.
185+
- **MiniMax H3 worker init can look healthy and still die on the first generate**
186+
if you are on a build that loads encoder, VAE, and DiT together. Confirm the log
187+
contains `Released MiniMax-H3 text encoder after conditioning` before
188+
`Loading MiniMax-H3 denoise modules`.
180189

181190
## Reproduce these numbers
182191

docs/inference/offloading.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ pool there, so offload adds transfers and duplicate residency instead of freeing
2121
memory. CUDA FSDP sharding remains enabled when requested; MPS continues to
2222
disable FSDP. `pin_cpu_memory` is not an offload mode and is left unchanged.
2323

24+
MiniMax H3 CUDA inference uses a second lever that does not copy weights to a
25+
host pool. The pipeline loads the Qwen3-VL text encoder, runs conditioning, then
26+
releases that encoder before it loads the DiT and video/audio VAEs. The MLX FastH3
27+
runtime uses the same phase order. Input-preparation geometry (spatial ratio,
28+
latent channels, audio sample rate) comes from the VAE arch configs until those
29+
weights load. A later `generate()` on the same worker currently re-enters
30+
conditioning after the encoder has been released; start a new generator for a
31+
new prompt until prompt-cache reload exists.
32+
2433
## Behavior Explanation
2534

2635
!!! note

fastvideo/pipelines/basic/minimax_h3/minimax_h3_pipeline.py

Lines changed: 143 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,17 @@
33

44
from __future__ import annotations
55

6+
import gc
7+
from dataclasses import dataclass
8+
from typing import Any
9+
10+
import torch
11+
12+
from fastvideo.configs.models.vaes.minimax_h3_audio import MiniMaxH3AudioVAEArchConfig
13+
from fastvideo.configs.models.vaes.minimax_h3_video import MiniMaxH3VideoVAEArchConfig
614
from fastvideo.configs.pipelines.minimax_h3 import MiniMaxH3PipelineConfig
715
from fastvideo.fastvideo_args import FastVideoArgs
16+
from fastvideo.logger import init_logger
817
from fastvideo.pipelines.basic.minimax_h3.stages import (
918
MiniMaxH3AudioDecodingStage,
1019
MiniMaxH3ConditioningStage,
@@ -15,6 +24,37 @@
1524
)
1625
from fastvideo.pipelines.composed_pipeline_base import ComposedPipelineBase
1726
from fastvideo.pipelines.lora_pipeline import LoRAPipeline
27+
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
28+
29+
logger = init_logger(__name__)
30+
31+
# Same split as the MLX runtime: condition, release the ~66 GB Qwen3-VL stack,
32+
# then load DiT + VAEs. Keeping them resident together OOMs unified-memory
33+
# boxes (GB10 / Spark) even though host offload is correctly disabled there.
34+
_DENOISE_MODULE_NAMES = ("vae", "audio_vae", "transformer")
35+
36+
37+
@dataclass(frozen=True)
38+
class _H3VideoGeometry:
39+
spatial_compression_ratio: int
40+
latent_channels: int
41+
42+
43+
@dataclass(frozen=True)
44+
class _H3AudioGeometry:
45+
sampling_rate: int
46+
47+
48+
def _default_video_geometry() -> _H3VideoGeometry:
49+
arch = MiniMaxH3VideoVAEArchConfig()
50+
return _H3VideoGeometry(
51+
spatial_compression_ratio=int(arch.spatial_compression_ratio),
52+
latent_channels=int(arch.latent_channels),
53+
)
54+
55+
56+
def _default_audio_geometry() -> _H3AudioGeometry:
57+
return _H3AudioGeometry(sampling_rate=int(MiniMaxH3AudioVAEArchConfig().sampling_rate))
1858

1959

2060
class MiniMaxH3BasePipeline(LoRAPipeline, ComposedPipelineBase):
@@ -53,6 +93,11 @@ class MiniMaxH3BasePipeline(LoRAPipeline, ComposedPipelineBase):
5393
"audio_scheduler",
5494
]
5595

96+
def __init__(self, *args: Any, **kwargs: Any) -> None:
97+
self._ref2va = False
98+
self._denoise_stages_ready = False
99+
super().__init__(*args, **kwargs)
100+
56101
@classmethod
57102
def get_hf_download_component_dirs(cls) -> tuple[str, ...]:
58103
return tuple(sorted(cls._extra_config_module_map.get(name, name) for name in cls._required_config_modules))
@@ -67,18 +112,70 @@ def initialize_pipeline(self, fastvideo_args: FastVideoArgs) -> None:
67112
if shift is None or float(shift) != expected_shift:
68113
raise ValueError(f"MiniMax-H3 {modality} scheduler must expose shift={expected_shift:g}, got {shift}.")
69114

70-
def _add_stages(self, *, ref2va: bool) -> None:
71-
transformer = self.get_module("transformer")
72-
vae = self.get_module("vae")
73-
audio_vae = self.get_module("audio_vae")
74-
scheduler = self.get_module("scheduler")
75-
audio_scheduler = self.get_module("audio_scheduler")
115+
def _defer_denoise_modules(self, fastvideo_args: FastVideoArgs) -> bool:
116+
return bool(fastvideo_args.inference_mode) and not bool(getattr(fastvideo_args, "training_mode", False))
117+
118+
def _denoise_modules_loaded(self) -> bool:
119+
return all(self.get_module(name) is not None for name in _DENOISE_MODULE_NAMES)
76120

121+
def load_modules(self,
122+
fastvideo_args: FastVideoArgs,
123+
loaded_modules: dict[str, torch.nn.Module] | None = None) -> dict[str, Any]:
124+
"""Load the Qwen3-VL conditioner first; defer DiT and VAEs until after encode."""
125+
if not self._defer_denoise_modules(fastvideo_args):
126+
return super().load_modules(fastvideo_args, loaded_modules)
127+
if loaded_modules is not None and all(name in loaded_modules for name in _DENOISE_MODULE_NAMES):
128+
return super().load_modules(fastvideo_args, loaded_modules)
129+
130+
saved = list(self.required_config_modules)
131+
self._required_config_modules = [name for name in saved if name not in _DENOISE_MODULE_NAMES]
132+
try:
133+
logger.info("Loading MiniMax-H3 condition modules first: %s", self._required_config_modules)
134+
return super().load_modules(fastvideo_args, loaded_modules)
135+
finally:
136+
self._required_config_modules = saved
137+
138+
def _load_denoise_modules(self, fastvideo_args: FastVideoArgs) -> None:
139+
if self._denoise_modules_loaded():
140+
return
141+
saved = list(self.required_config_modules)
142+
self._required_config_modules = [name for name in saved if name != "text_encoder"]
143+
try:
144+
logger.info("Loading MiniMax-H3 denoise modules after releasing the text encoder: %s",
145+
[name for name in self._required_config_modules if name in _DENOISE_MODULE_NAMES])
146+
loaded = super().load_modules(fastvideo_args, loaded_modules=self.modules)
147+
for name, module in loaded.items():
148+
self.add_module(name, module)
149+
finally:
150+
self._required_config_modules = saved
151+
152+
def _release_text_encoder(self) -> None:
153+
stage = self._stage_name_mapping.get("conditioning_stage")
154+
if stage is not None:
155+
stage.conditioner = None
156+
encoder = self.modules.pop("text_encoder", None)
157+
if encoder is None:
158+
return
159+
logger.info("Released MiniMax-H3 text encoder after conditioning")
160+
del encoder
161+
gc.collect()
162+
if torch.cuda.is_available():
163+
torch.cuda.empty_cache()
164+
165+
def _input_vae(self) -> Any:
166+
return self.get_module("vae") or _default_video_geometry()
167+
168+
def _input_audio_vae(self, *, ref2va: bool) -> Any | None:
169+
if not ref2va:
170+
return None
171+
return self.get_module("audio_vae") or _default_audio_geometry()
172+
173+
def _add_condition_stages(self, *, ref2va: bool) -> None:
77174
self.add_stage(
78175
"input_preparation_stage",
79176
MiniMaxH3InputPreparationStage(
80-
vae=vae,
81-
audio_vae=audio_vae if ref2va else None,
177+
vae=self._input_vae(),
178+
audio_vae=self._input_audio_vae(ref2va=ref2va),
82179
ref2va=ref2va,
83180
),
84181
)
@@ -91,6 +188,15 @@ def _add_stages(self, *, ref2va: bool) -> None:
91188
ref2va=ref2va,
92189
),
93190
)
191+
192+
def _add_denoise_stages(self, *, ref2va: bool) -> None:
193+
transformer = self.get_module("transformer")
194+
vae = self.get_module("vae")
195+
audio_vae = self.get_module("audio_vae")
196+
scheduler = self.get_module("scheduler")
197+
audio_scheduler = self.get_module("audio_scheduler")
198+
if transformer is None or vae is None or audio_vae is None:
199+
raise RuntimeError("MiniMax-H3 denoise stages require transformer, vae, and audio_vae to be loaded.")
94200
self.add_stage(
95201
"latent_preparation_stage",
96202
MiniMaxH3LatentPreparationStage(
@@ -111,6 +217,35 @@ def _add_stages(self, *, ref2va: bool) -> None:
111217
)
112218
self.add_stage("video_decoding_stage", MiniMaxH3VideoDecodingStage(vae=vae, transformer=transformer))
113219
self.add_stage("audio_decoding_stage", MiniMaxH3AudioDecodingStage(audio_vae=audio_vae))
220+
self._denoise_stages_ready = True
221+
222+
def _add_stages(self, *, ref2va: bool) -> None:
223+
self._ref2va = ref2va
224+
self._add_condition_stages(ref2va=ref2va)
225+
if self._denoise_modules_loaded():
226+
self._add_denoise_stages(ref2va=ref2va)
227+
228+
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
229+
if not self.post_init_called:
230+
self.post_init()
231+
232+
if self._denoise_stages_ready:
233+
return super().forward(batch, fastvideo_args)
234+
235+
logger.info("Running MiniMax-H3 condition stages before loading DiT/VAE weights")
236+
for stage in self.stages:
237+
batch = stage(batch, fastvideo_args)
238+
self._release_text_encoder()
239+
self._load_denoise_modules(fastvideo_args)
240+
self._add_denoise_stages(ref2va=self._ref2va)
241+
for name in (
242+
"latent_preparation_stage",
243+
"denoising_stage",
244+
"video_decoding_stage",
245+
"audio_decoding_stage",
246+
):
247+
batch = self._stage_name_mapping[name](batch, fastvideo_args)
248+
return batch
114249

115250

116251
class MiniMaxH3Pipeline(MiniMaxH3BasePipeline):
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""CPU contracts for MiniMax-H3 Mac-style sequential module loading."""
3+
from __future__ import annotations
4+
5+
from contextlib import nullcontext
6+
from types import SimpleNamespace
7+
8+
import torch
9+
10+
import fastvideo.pipelines.composed_pipeline_base as composed_pipeline_base
11+
from fastvideo.fastvideo_args import FastVideoArgs
12+
from fastvideo.pipelines.basic.minimax_h3.minimax_h3_pipeline import (
13+
MiniMaxH3Pipeline,
14+
_DENOISE_MODULE_NAMES,
15+
)
16+
from fastvideo.pipelines.composed_pipeline_base import ComposedPipelineBase
17+
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
18+
19+
20+
class _Profiler:
21+
22+
def region(self, name):
23+
del name
24+
return nullcontext()
25+
26+
27+
def _stub_module(name: str) -> SimpleNamespace:
28+
if name in {"scheduler"}:
29+
return SimpleNamespace(shift=12.0, name=name)
30+
if name in {"audio_scheduler"}:
31+
return SimpleNamespace(shift=3.0, name=name)
32+
if name == "transformer":
33+
# LoRAPipeline reads exclude_lora_layers off the DiT arch config.
34+
return SimpleNamespace(
35+
name=name,
36+
config=SimpleNamespace(arch_config=SimpleNamespace(exclude_lora_layers=[])),
37+
)
38+
return SimpleNamespace(name=name)
39+
40+
41+
def _patch_pipeline_construction(monkeypatch, events: list) -> None:
42+
monkeypatch.setattr(
43+
composed_pipeline_base,
44+
"maybe_init_distributed_environment_and_model_parallel",
45+
lambda *args, **kwargs: events.append(("distributed", None)),
46+
)
47+
monkeypatch.setattr(composed_pipeline_base, "get_local_torch_device", lambda: torch.device("cpu"))
48+
monkeypatch.setattr(composed_pipeline_base, "get_world_group", lambda: SimpleNamespace(local_rank=0))
49+
monkeypatch.setattr(composed_pipeline_base, "get_or_create_profiler", lambda trace_dir: _Profiler())
50+
monkeypatch.setattr(composed_pipeline_base, "warmup_sequence_parallel_communication", lambda: None)
51+
monkeypatch.setattr("fastvideo.platforms.current_platform.has_unified_memory", lambda device_id: False)
52+
monkeypatch.setattr("fastvideo.platforms.current_platform.is_mps", lambda: False)
53+
54+
55+
def test_inference_defers_dit_and_vae_until_after_conditioning(monkeypatch) -> None:
56+
events: list = []
57+
_patch_pipeline_construction(monkeypatch, events)
58+
loads: list[list[str]] = []
59+
60+
def fake_load(self, fastvideo_args, loaded_modules=None):
61+
del fastvideo_args
62+
requested = list(self.required_config_modules)
63+
loads.append(requested)
64+
modules = dict(loaded_modules or {})
65+
for name in requested:
66+
modules.setdefault(name, _stub_module(name))
67+
return modules
68+
69+
monkeypatch.setattr(ComposedPipelineBase, "load_modules", fake_load)
70+
71+
args = FastVideoArgs(model_path="unused/for-this-test", enable_stage_verification=False)
72+
pipeline = MiniMaxH3Pipeline("unused/for-this-test", args)
73+
pipeline.post_init()
74+
75+
assert loads, "condition modules should load during construction"
76+
assert "text_encoder" in loads[0]
77+
assert all(name not in loads[0] for name in _DENOISE_MODULE_NAMES)
78+
assert pipeline.get_module("text_encoder") is not None
79+
assert pipeline.get_module("transformer") is None
80+
assert list(pipeline._stage_name_mapping) == ["input_preparation_stage", "conditioning_stage"]
81+
82+
condition_stage = pipeline._stage_name_mapping["conditioning_stage"]
83+
passthrough = lambda batch, _args: batch
84+
monkeypatch.setattr(pipeline._stage_name_mapping["input_preparation_stage"], "forward", passthrough)
85+
monkeypatch.setattr(condition_stage, "forward", passthrough)
86+
87+
original_add_denoise = pipeline._add_denoise_stages
88+
89+
def fake_add_denoise(*, ref2va: bool) -> None:
90+
original_add_denoise(ref2va=ref2va)
91+
for name in (
92+
"latent_preparation_stage",
93+
"denoising_stage",
94+
"video_decoding_stage",
95+
"audio_decoding_stage",
96+
):
97+
monkeypatch.setattr(pipeline._stage_name_mapping[name], "forward", passthrough)
98+
99+
monkeypatch.setattr(pipeline, "_add_denoise_stages", fake_add_denoise)
100+
101+
batch = ForwardBatch(data_type="video", prompt="alpine dancer")
102+
out = pipeline.forward(batch, args)
103+
104+
assert out is batch
105+
assert len(loads) == 2
106+
assert "transformer" in loads[1]
107+
assert "vae" in loads[1]
108+
assert "text_encoder" not in loads[1]
109+
assert pipeline.get_module("text_encoder") is None
110+
assert condition_stage.conditioner is None
111+
assert pipeline.get_module("transformer") is not None
112+
assert pipeline._denoise_stages_ready is True
113+
114+
115+
def test_injected_denoise_weights_skip_the_deferred_split(monkeypatch) -> None:
116+
events: list = []
117+
_patch_pipeline_construction(monkeypatch, events)
118+
loads: list[list[str]] = []
119+
120+
def fake_load(self, fastvideo_args, loaded_modules=None):
121+
del fastvideo_args
122+
loads.append(list(self.required_config_modules))
123+
return dict(loaded_modules or {})
124+
125+
monkeypatch.setattr(ComposedPipelineBase, "load_modules", fake_load)
126+
injected = {name: _stub_module(name) for name in MiniMaxH3Pipeline._required_config_modules}
127+
args = FastVideoArgs(model_path="unused/for-this-test")
128+
MiniMaxH3Pipeline("unused/for-this-test", args, loaded_modules=injected)
129+
130+
assert loads == [list(MiniMaxH3Pipeline._required_config_modules)]

0 commit comments

Comments
 (0)