Skip to content

Commit bd5ee4c

Browse files
committed
[bugfix]: read H3 geometry from checkpoint JSON so lazy load can drop the DiT before VAE decode
Input prep and unpatchify were holding live VAE/DiT proxies just for two integers, which loaded the video VAE before Qwen and kept the DiT resident through decode. Auto-enable --lazy-module-load on unified memory and on single-GPU FastH3 examples.
1 parent cc9bfd0 commit bd5ee4c

16 files changed

Lines changed: 250 additions & 82 deletions

File tree

docs/getting_started/installation/spark_performance.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,12 @@ is power-cycled. To avoid it:
164164
- **MiniMax H3 / FastH3** still needs sequential loading on one GB10. The Qwen3-VL
165165
conditioner is tens of gigabytes of BF16. If the DiT and VAEs load while that
166166
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 onto the accelerator (`to_cpu` follows `cpu_offload`, which
169-
is off here). See [Offloading](../../inference/offloading.md).
167+
preferred). Pass `--lazy-module-load` (or omit it: FastVideo auto-enables the
168+
flag on unified memory, and `basic_fasth3.py` defaults it on when
169+
`--num-gpus 1`). That loads Qwen, releases it, loads the DiT, releases the DiT,
170+
then loads the VAE. Geometry scalars come from checkpoint `config.json`, not
171+
from live weights. A later `generate()` on the same worker reloads from disk.
172+
See [Offloading](../../inference/offloading.md).
170173

171174
## Gotchas specific to the GB10
172175

docs/inference/offloading.md

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ text_encoder_cpu_offload: bool = True
1212
image_encoder_cpu_offload: bool = True
1313
vae_cpu_offload: bool = True
1414
pin_cpu_memory: bool = True
15-
lazy_module_load: bool = False
15+
lazy_module_load: bool | None = None
1616
```
1717

1818
On unified-memory accelerators such as NVIDIA GB10 and Apple silicon, FastVideo
@@ -23,14 +23,18 @@ memory. CUDA FSDP sharding remains enabled when requested; MPS continues to
2323
disable FSDP. `pin_cpu_memory` is not an offload mode and is left unchanged.
2424

2525
MiniMax H3 CUDA inference uses a second lever that does not copy weights to a
26-
host pool. The pipeline loads the Qwen3-VL text encoder, runs conditioning, then
27-
releases that encoder before it loads the DiT and video/audio VAEs. The MLX FastH3
28-
runtime uses the same phase order. When host offload is off, DiT safetensors are
29-
read onto the accelerator instead of CPU-then-copy. Input-preparation geometry
30-
(spatial ratio, latent channels, audio sample rate) comes from the VAE arch
31-
configs until those weights load. A later `generate()` on the same worker
32-
currently re-enters conditioning after the encoder has been released; start a
33-
new generator for a new prompt until prompt-cache reload exists.
26+
host pool. With `lazy_module_load`, the pipeline loads the Qwen3-VL text encoder,
27+
runs conditioning, and releases that encoder before it loads the DiT. After
28+
denoise it releases the DiT, then loads the video VAE for decode. Input
29+
preparation and unpatchify read geometry from checkpoint `config.json` (VAE
30+
spatial ratio / latent channels, DiT patch size) so those stages do not
31+
materialize weights just to read two integers. The MLX FastH3 runtime uses the
32+
same phase order. When host offload is off, DiT safetensors are read onto the
33+
accelerator instead of CPU-then-copy. A later `generate()` on the same worker
34+
reloads a released component from disk; start a new generator for a new prompt
35+
until prompt-cache reload exists. The flag is auto-enabled on unified-memory
36+
devices. Pass `--no-lazy-module-load` (or `lazy_module_load=False`) to keep every
37+
component resident.
3438

3539
## Behavior Explanation
3640

@@ -109,9 +113,9 @@ By default a pipeline loads every component before the first stage runs, so
109113
peak memory is the sum of all of them even though no two are needed at the same
110114
moment. With `lazy_module_load` enabled, each heavy component loads on first use
111115
and is freed once the last stage that needs it has returned, so peak memory
112-
becomes the largest overlapping set instead of the sum. For a text-to-video
113-
pipeline that is roughly `max(text encoder, DiT + VAE)` rather than
114-
`text encoder + DiT + VAE`.
116+
becomes the largest overlapping set instead of the sum. MiniMax-H3 T2VA is
117+
`max(text encoder, DiT, VAE)` rather than `text encoder + DiT + VAE`, because
118+
the DiT is not held through VAE decode.
115119

116120
#### Performance Impact
117121

@@ -126,8 +130,10 @@ and kernel caches when the component structure and input shapes are unchanged.
126130
Enable this when a model does not fit at load time, which the CPU offload
127131
options above cannot help with because they act after loading. It is
128132
particularly relevant on unified-memory devices, where host and device draw on
129-
the same pool and moving weights to the host frees nothing. Leave it off when
130-
the model already fits.
133+
the same pool and moving weights to the host frees nothing. FastVideo
134+
auto-enables it there (`lazy_module_load=None`). Leave it off when the model
135+
already fits, or pass `--no-lazy-module-load` to keep components resident for
136+
later `generate()` calls.
131137

132138
This option applies to inference only. Training keeps every component resident
133139
and logs a warning if the flag is set.

examples/inference/basic/basic_fasth3.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,13 @@ def build_parser(description: str | None = None) -> argparse.ArgumentParser:
4949
parser.add_argument("--prompt", required=True)
5050
parser.add_argument("--output", default="outputs/fasth3")
5151
parser.add_argument("--lazy-module-load",
52-
action="store_true",
52+
action=argparse.BooleanOptionalAction,
53+
default=None,
5354
help="load each heavy component on first use and free it after the last stage that "
5455
"needs it, so peak memory is the largest overlapping set instead of the sum of every "
55-
"component. Enable when the model does not fit at load time; costs a reload per "
56-
"generation, so leave it off when it does fit")
56+
"component. Default: on when --num-gpus is 1; FastVideo also auto-enables on unified "
57+
"memory. Costs a reload per generation; pass --no-lazy-module-load to keep every "
58+
"component resident")
5759
parser.add_argument("--profile",
5860
choices=("all", "strict"),
5961
default="all",
@@ -259,7 +261,8 @@ def build_generator_config(args: argparse.Namespace) -> GeneratorConfig:
259261
text_encoder=True,
260262
vae=True,
261263
pin_cpu_memory=args.pin_cpu_memory,
262-
lazy_module_load=args.lazy_module_load,
264+
lazy_module_load=(True if args.lazy_module_load is None and args.num_gpus == 1 else
265+
args.lazy_module_load),
263266
),
264267
compile=CompileConfig(
265268
enabled=args.torch_compile,

examples/inference/basic/basic_minimax_h3_t2v.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,13 @@ def parse_args() -> argparse.Namespace:
4949
"First generation pays the inductor JIT (~1-2 min); use --repeats >= 2 and time "
5050
"the last repeat. FASTVIDEO_INFERENCE_TORCH_COMPILE=1 is equivalent")
5151
parser.add_argument("--lazy-module-load",
52-
action="store_true",
52+
action=argparse.BooleanOptionalAction,
53+
default=None,
5354
help="load each heavy component on first use and free it after the last stage that "
5455
"needs it, so peak memory is the largest overlapping set instead of the sum of every "
55-
"component. Enable when the model does not fit at load time; costs a reload per "
56-
"generation, so leave it off when it does fit")
56+
"component. Default: on when --num-gpus is 1; FastVideo also auto-enables on unified "
57+
"memory. Costs a reload per generation; pass --no-lazy-module-load to keep every "
58+
"component resident")
5759
parser.add_argument("--repeats",
5860
type=int,
5961
default=1,
@@ -87,7 +89,8 @@ def main() -> None:
8789
text_encoder=True,
8890
vae=True,
8991
pin_cpu_memory=False,
90-
lazy_module_load=args.lazy_module_load,
92+
lazy_module_load=(True if args.lazy_module_load is None and args.num_gpus == 1 else
93+
args.lazy_module_load),
9194
),
9295
compile=CompileConfig(
9396
enabled=args.torch_compile,

fastvideo/api/schema.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ class OffloadConfig:
3434
# after the last stage that needs it, so peak memory is the largest
3535
# overlapping set rather than the sum. Grouped here because it is the same
3636
# decision the offload knobs answer, which is how much of the model has to
37-
# be resident at once.
38-
lazy_module_load: bool = False
37+
# be resident at once. ``None`` auto-enables on unified-memory devices.
38+
lazy_module_load: bool | None = None
3939

4040

4141
@dataclass

fastvideo/fastvideo_args.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,13 @@ class FastVideoArgs:
162162
# Load each heavy component on first use and free it once the last stage
163163
# that holds it has run, instead of keeping every component resident from
164164
# load time to shutdown. Peak memory becomes the largest overlapping set
165-
# rather than the sum of all components. Off by default: a released
166-
# component is re-read from disk on the next generation, so this trades
167-
# per-request latency for headroom and only pays off when the sum does not
168-
# fit. Inference only; training keeps every component resident.
169-
lazy_module_load: bool = False
165+
# rather than the sum of all components. ``None`` (auto) turns this on for
166+
# unified-memory devices (GB10 / Spark) after the worker binds its device,
167+
# and leaves it off on discrete GPUs. Explicit True / False overrides the
168+
# probe. A released component is re-read from disk on the next generation,
169+
# so this trades per-request latency for headroom. Inference only; training
170+
# keeps every component resident.
171+
lazy_module_load: bool | None = None
170172

171173
# Sequence-parallel MiniMax-H3 VAE (opt-in, default off). With SP > 1 the
172174
# video VAE's temporal chunks (decode) and clips (reference encode) are
@@ -718,10 +720,12 @@ def add_cli_args(parser: FlexibleArgumentParser) -> FlexibleArgumentParser:
718720
)
719721
parser.add_argument(
720722
"--lazy-module-load",
721-
action=StoreBoolean,
723+
action=argparse.BooleanOptionalAction,
724+
default=None,
722725
help="Load each heavy component on first use and free it after the last stage that needs it, "
723-
"so peak memory is the largest overlapping set of components instead of their sum. Enable when a "
724-
"model does not fit at load time. Costs a reload per generation, so leave it off when it does fit.",
726+
"so peak memory is the largest overlapping set of components instead of their sum. "
727+
"Omit for auto (on for unified-memory devices such as GB10; off on discrete GPUs). "
728+
"Pass --no-lazy-module-load to keep every component resident.",
725729
)
726730
parser.add_argument(
727731
"--pin-cpu-memory",
@@ -964,6 +968,20 @@ def _resolve_device_offload_conflicts(self) -> None:
964968
def finalize_device_offload_policy(self, device_id: int = 0) -> bool:
965969
"""Apply device-local memory policy, then resolve incompatible modes."""
966970
has_unified_memory = self.disable_offload_on_unified_memory(device_id)
971+
if self.lazy_module_load is None:
972+
self.lazy_module_load = bool(has_unified_memory) and not self.training_mode
973+
if self.lazy_module_load:
974+
from fastvideo.platforms import current_platform
975+
976+
try:
977+
device_name = current_platform.get_device_name(device_id)
978+
except Exception:
979+
device_name = current_platform.device_name
980+
logger.info(
981+
"Enabling lazy_module_load: %s has unified memory, so encoder, DiT, and VAEs cannot stay "
982+
"resident together. Pass --no-lazy-module-load to keep every component loaded.",
983+
device_name,
984+
)
967985
self._resolve_device_offload_conflicts()
968986
return has_unified_memory
969987

fastvideo/pipelines/basic/minimax_h3/minimax_h3_pipeline.py

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

44
from __future__ import annotations
55

6+
from pathlib import Path
7+
68
from fastvideo.configs.pipelines.minimax_h3 import MiniMaxH3PipelineConfig
79
from fastvideo.fastvideo_args import FastVideoArgs
10+
from fastvideo.logger import init_logger
11+
from fastvideo.models.hf_transformer_utils import get_diffusers_config
812
from fastvideo.pipelines.basic.minimax_h3.stages import (
913
MiniMaxH3AudioDecodingStage,
1014
MiniMaxH3ConditioningStage,
@@ -16,6 +20,26 @@
1620
from fastvideo.pipelines.composed_pipeline_base import ComposedPipelineBase
1721
from fastvideo.pipelines.lora_pipeline import LoRAPipeline
1822

23+
logger = init_logger(__name__)
24+
25+
26+
def _apply_h3_checkpoint_arch_configs(model_path: str, fastvideo_args: FastVideoArgs,
27+
extra_config_module_map: dict[str, str]) -> None:
28+
"""Overlay checkpoint config.json onto pipeline configs without loading weights."""
29+
root = Path(model_path)
30+
vae_dir = root / "vae"
31+
if (vae_dir / "config.json").is_file():
32+
fastvideo_args.pipeline_config.vae_config.update_model_arch(get_diffusers_config(str(vae_dir)))
33+
transformer_dir = root / extra_config_module_map.get("transformer", "transformer")
34+
if (transformer_dir / "config.json").is_file():
35+
fastvideo_args.pipeline_config.dit_config.update_model_arch(get_diffusers_config(str(transformer_dir)))
36+
logger.info(
37+
"MiniMax-H3 geometry from config: patch_size=%s spatial_compression_ratio=%s latent_channels=%s",
38+
tuple(fastvideo_args.pipeline_config.dit_config.patch_size),
39+
int(fastvideo_args.pipeline_config.vae_config.arch_config.spatial_compression_ratio),
40+
int(fastvideo_args.pipeline_config.vae_config.arch_config.latent_channels),
41+
)
42+
1943

2044
class MiniMaxH3BasePipeline(LoRAPipeline, ComposedPipelineBase):
2145
"""Shared loading and target-generation path for MiniMax H3.
@@ -52,17 +76,18 @@ class MiniMaxH3BasePipeline(LoRAPipeline, ComposedPipelineBase):
5276
"scheduler",
5377
"audio_scheduler",
5478
]
55-
# Deferral is safe here: no stage reads a component's attributes while it
56-
# is being constructed, and `initialize_pipeline` only inspects the
57-
# schedulers, which are never deferred.
79+
# Deferral is safe here: geometry scalars come from checkpoint config.json
80+
# (applied in initialize_pipeline without loading weights), no stage
81+
# constructor reads a deferred component, and initialize_pipeline only
82+
# inspects the schedulers, which are never deferred.
5883
_lazy_module_names = ("text_encoder", "transformer", "vae", "audio_vae")
5984

6085
@classmethod
6186
def get_hf_download_component_dirs(cls) -> tuple[str, ...]:
6287
return tuple(sorted(cls._extra_config_module_map.get(name, name) for name in cls._required_config_modules))
6388

6489
def initialize_pipeline(self, fastvideo_args: FastVideoArgs) -> None:
65-
del fastvideo_args
90+
_apply_h3_checkpoint_arch_configs(self.model_path, fastvideo_args, self._extra_config_module_map)
6691
for module_name, modality, expected_shift in (
6792
("scheduler", "video", 12.0),
6893
("audio_scheduler", "audio", 3.0),
@@ -71,17 +96,21 @@ def initialize_pipeline(self, fastvideo_args: FastVideoArgs) -> None:
7196
if shift is None or float(shift) != expected_shift:
7297
raise ValueError(f"MiniMax-H3 {modality} scheduler must expose shift={expected_shift:g}, got {shift}.")
7398

74-
def _add_stages(self, *, ref2va: bool) -> None:
99+
def _add_stages(self, fastvideo_args: FastVideoArgs, *, ref2va: bool) -> None:
75100
transformer = self.get_module("transformer")
76101
vae = self.get_module("vae")
77102
audio_vae = self.get_module("audio_vae")
78103
scheduler = self.get_module("scheduler")
79104
audio_scheduler = self.get_module("audio_scheduler")
105+
# Geometry scalars live on the checkpoint-updated arch config. Holding
106+
# the live VAE/DiT here would materialize them on the first attribute
107+
# read. Encode still needs the live VAE for FL2VA/Ref2VA.
108+
video_geometry = fastvideo_args.pipeline_config.vae_config.arch_config
80109

81110
self.add_stage(
82111
"input_preparation_stage",
83112
MiniMaxH3InputPreparationStage(
84-
vae=vae,
113+
vae=video_geometry,
85114
audio_vae=audio_vae if ref2va else None,
86115
ref2va=ref2va,
87116
),
@@ -98,7 +127,6 @@ def _add_stages(self, *, ref2va: bool) -> None:
98127
self.add_stage(
99128
"latent_preparation_stage",
100129
MiniMaxH3LatentPreparationStage(
101-
transformer=transformer,
102130
vae=vae,
103131
audio_vae=audio_vae,
104132
scheduler=scheduler,
@@ -113,16 +141,15 @@ def _add_stages(self, *, ref2va: bool) -> None:
113141
audio_scheduler=audio_scheduler,
114142
),
115143
)
116-
self.add_stage("video_decoding_stage", MiniMaxH3VideoDecodingStage(vae=vae, transformer=transformer))
144+
self.add_stage("video_decoding_stage", MiniMaxH3VideoDecodingStage(vae=vae))
117145
self.add_stage("audio_decoding_stage", MiniMaxH3AudioDecodingStage(audio_vae=audio_vae))
118146

119147

120148
class MiniMaxH3Pipeline(MiniMaxH3BasePipeline):
121149
"""One-request joint video/stereo-audio pipeline for T2VA and FL2VA."""
122150

123151
def create_pipeline_stages(self, fastvideo_args: FastVideoArgs) -> None:
124-
del fastvideo_args
125-
self._add_stages(ref2va=False)
152+
self._add_stages(fastvideo_args, ref2va=False)
126153

127154

128155
class MiniMaxH3RefPipeline(MiniMaxH3BasePipeline):
@@ -131,8 +158,7 @@ class MiniMaxH3RefPipeline(MiniMaxH3BasePipeline):
131158
_extra_config_module_map = {"transformer": "transformer_ref"}
132159

133160
def create_pipeline_stages(self, fastvideo_args: FastVideoArgs) -> None:
134-
del fastvideo_args
135-
self._add_stages(ref2va=True)
161+
self._add_stages(fastvideo_args, ref2va=True)
136162

137163

138164
class MiniMaxH3ModularPipeline(MiniMaxH3Pipeline):

fastvideo/pipelines/basic/minimax_h3/packing.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from __future__ import annotations
55

66
from dataclasses import dataclass
7-
from typing import TYPE_CHECKING
7+
from typing import TYPE_CHECKING, Any
88

99
import numpy as np
1010
import torch
@@ -38,6 +38,19 @@
3838
MINIMAX_H3_KEYFRAME_NOISE_AUG = 0.999
3939
MINIMAX_H3_KEYFRAME_ENCODE_SEED = 42
4040

41+
42+
def h3_dit_patch_size(fastvideo_args: Any) -> tuple[int, int, int]:
43+
"""Read DiT patch size from pipeline config, not live transformer weights."""
44+
dit_config = getattr(getattr(fastvideo_args, "pipeline_config", None), "dit_config", None)
45+
patch_size = getattr(dit_config, "patch_size", None)
46+
if patch_size is None:
47+
raise ValueError("MiniMax-H3 requires pipeline_config.dit_config.patch_size.")
48+
values = tuple(int(axis) for axis in patch_size)
49+
if len(values) != 3 or min(values) <= 0:
50+
raise ValueError(f"MiniMax-H3 patch_size must be three positive ints, got {patch_size!r}.")
51+
return values
52+
53+
4154
MINIMAX_H3_ROPE_FRAME_RESCALE = 5.0 / 3.0
4255
MINIMAX_H3_ROPE_FRAMES_PER_LATENT = (1, 4, 4, 4, 4)
4356
_ROPE_SPATIAL_SCALE = 32

fastvideo/pipelines/basic/minimax_h3/stages/minimax_h3_decoding.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from fastvideo.profiler import nvtx_range
1717
from fastvideo.pipelines.basic.minimax_h3.packing import (
1818
MiniMaxH3PackedLayout,
19+
h3_dit_patch_size,
1920
unpack_audio_tokens,
2021
unpatchify_video_tokens,
2122
)
@@ -58,10 +59,9 @@ class MiniMaxH3VideoDecodingStage(PipelineStage):
5859

5960
performance_component_metric = "vae_decode_time_s"
6061

61-
def __init__(self, vae: AutoencoderKLMiniMaxH3, transformer: Any) -> None:
62+
def __init__(self, vae: AutoencoderKLMiniMaxH3) -> None:
6263
super().__init__()
6364
self.vae = vae
64-
self.transformer = transformer
6565

6666
def verify_input(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
6767
result = VerificationResult()
@@ -97,7 +97,7 @@ def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> Forward
9797
latent_height,
9898
latent_width,
9999
channels,
100-
self.transformer.patch_size,
100+
h3_dit_patch_size(fastvideo_args),
101101
)
102102
device = get_local_torch_device()
103103
self.vae.to(device)

0 commit comments

Comments
 (0)