Skip to content

Commit 512295b

Browse files
[perf]: make deferred loading opt-in per pipeline and clean up on failure
Review of the first version found that the flag was unsafe as a global default. Releasing a component and loading it again is only correct when nothing outside the loader has changed it, and two habits in this tree break that without raising: * mutating a component after load. LongCatPipeline.initialize_pipeline turns on block-sparse attention and writes parameters into every transformer block. That runs once, so a re-materialized component silently comes back with the feature off and the second generation quietly degrades. * reading a component's attributes while stages are built. The shared DenoisingStage.__init__ derives the attention backend from transformer.hidden_size, which materializes the DiT during post_init and defeats the deferral it was meant to gain. _lazy_module_names is now empty in the base class, so an unchecked pipeline gets no deferral and says so. MiniMax-H3 opts in to the four components this PR measured, and nothing else changes behaviour. Also from review: Releasing on the failure path. A stage's hook frees only what that stage is the last user of, and it ran only after a successful forward. Both the stage and the whole run now release on the way out, so the retry a memory constrained caller attempts does not start from a worse position than the request that just failed. A failing release cannot replace the exception being propagated. Walking into nested stages. Cosmos25AutoDenoisingStage keeps the transformer inside child stages, so a one-level scan called it unreferenced and never freed it. The scan now recurses through stages and containers with cycle protection. Keeping the proxy when torch.compile is skipped. The FSDP check ran after the proxy had already been replaced by the real module, so an FSDP-wrapped component lost both the compile and its release hook. Not attaching the activation trace to a deferred component, since the hook manager pins every module it wraps. test_parser.py asserted on a whole serialized config dict, so adding a field to OffloadConfig broke it. Grepping the field name could not find that; only running the suite could. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent aa30a98 commit 512295b

6 files changed

Lines changed: 279 additions & 79 deletions

File tree

docs/inference/offloading.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,15 @@ the model already fits.
113113
This option applies to inference only. Training keeps every component resident
114114
and logs a warning if the flag is set.
115115

116+
Deferral is opt-in per pipeline. Releasing a component and loading it again is
117+
only safe when nothing outside the loader has changed it, and two common habits
118+
break that without raising: mutating a component after load, as LongCat does
119+
when it enables block-sparse attention, and reading a component's attributes
120+
while stages are built, as the shared denoising stage does to pick an attention
121+
backend. A pipeline therefore lists the components it has checked in
122+
`_lazy_module_names`, which is empty in the base class. MiniMax-H3 opts in. On
123+
a pipeline that has not, the flag logs a warning and changes nothing.
124+
116125
## General Recommendations
117126

118127
### Single GPU Inference

fastvideo/pipelines/basic/minimax_h3/minimax_h3_pipeline.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ class MiniMaxH3BasePipeline(ComposedPipelineBase):
3030
"scheduler",
3131
"audio_scheduler",
3232
]
33+
# Deferral is safe here: no stage reads a component's attributes while it
34+
# is being constructed, and `initialize_pipeline` only inspects the
35+
# schedulers, which are never deferred.
36+
_lazy_module_names = ("text_encoder", "transformer", "vae", "audio_vae")
3337

3438
@classmethod
3539
def get_hf_download_component_dirs(cls) -> tuple[str, ...]:

fastvideo/pipelines/composed_pipeline_base.py

Lines changed: 96 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import argparse
99
import os
1010
from abc import ABC, abstractmethod
11+
from collections.abc import Iterator
1112
from typing import Any, cast
1213

1314
import torch
@@ -29,6 +30,40 @@
2930
logger = init_logger(__name__)
3031

3132

33+
def _iter_held_objects(stage: PipelineStage) -> Iterator[Any]:
34+
"""Yield everything a stage holds, walking into nested stages.
35+
36+
A stage can compose others rather than hold a component directly:
37+
``Cosmos25AutoDenoisingStage`` keeps the transformer inside its ``_t2w``
38+
and ``_v2w`` children. A scan that stopped at the outer stage would call
39+
that transformer unreferenced and never free it, so the flag would quietly
40+
deliver less than it promises on those pipelines.
41+
"""
42+
stack: list[Any] = [stage]
43+
visited: set[int] = set()
44+
while stack:
45+
obj = stack.pop()
46+
# is_lazy_module first: isinstance() on a proxy forwards __class__ and
47+
# would load every deferred component just to work out where to free it.
48+
if is_lazy_module(obj):
49+
yield obj
50+
continue
51+
if isinstance(obj, PipelineStage | list | tuple | dict):
52+
if id(obj) in visited:
53+
continue
54+
visited.add(id(obj))
55+
if isinstance(obj, PipelineStage):
56+
for key, value in vars(obj).items():
57+
if key == "_lazy_modules_to_release":
58+
# Installed by this schedule, not a real use.
59+
continue
60+
stack.append(value)
61+
elif isinstance(obj, list | tuple):
62+
stack.extend(obj)
63+
elif isinstance(obj, dict):
64+
stack.extend(obj.values())
65+
66+
3267
class ComposedPipelineBase(ABC):
3368
"""
3469
Base class for pipelines composed of multiple stages.
@@ -52,23 +87,23 @@ class ComposedPipelineBase(ABC):
5287
# list, so a stage added afterwards can rebuild it instead of running
5388
# against a plan that predates it.
5489
_lazy_release_hooks_installed: bool = False
55-
# Components eligible for deferred loading under ``lazy_module_load``.
56-
# These are the weight-bearing ones; tokenizers, processors, and
57-
# schedulers are cheap and stay resident so the pipeline can inspect them
58-
# at construction time. Names follow the diffusers manifest convention, so
59-
# the default list covers most pipelines; override per pipeline if a
60-
# component is named differently or must never be released.
61-
_lazy_module_names: tuple[str, ...] = (
62-
"transformer",
63-
"transformer_2",
64-
"transformer_ref",
65-
"transformer_refine",
66-
"text_encoder",
67-
"text_encoder_2",
68-
"image_encoder",
69-
"vae",
70-
"audio_vae",
71-
)
90+
# Components this pipeline allows ``lazy_module_load`` to defer and free.
91+
# Empty by default: deferral is opt-in per pipeline, because releasing a
92+
# component and loading it again is only safe when nothing outside the
93+
# loader has changed it. Two habits break that and neither raises:
94+
#
95+
# * mutating a component after load. ``LongCatPipeline.initialize_pipeline``
96+
# turns on block-sparse attention and writes parameters into every
97+
# transformer block. That runs once, so a re-materialized component
98+
# silently comes back with the feature off.
99+
# * reading a component's attributes while building stages. The shared
100+
# ``DenoisingStage.__init__`` derives the attention backend from
101+
# ``transformer.hidden_size``, which materializes the DiT before the
102+
# first request and defeats the deferral it was meant to gain.
103+
#
104+
# A pipeline opts in by listing the components it has checked. Names match
105+
# the diffusers manifest.
106+
_lazy_module_names: tuple[str, ...] = ()
72107

73108
@classmethod
74109
def get_hf_download_component_dirs(cls) -> tuple[str, ...] | None:
@@ -163,14 +198,12 @@ def _maybe_compile_pipeline_module(
163198
if module_name not in self.modules:
164199
return
165200

166-
module = self.modules[module_name]
167-
if is_lazy_module(module):
168-
# torch.compile replaces the entry in self.modules, which would
169-
# drop the proxy and with it the ability to release. Load now and
170-
# keep this component resident for the run.
171-
logger.info("torch.compile requested for %s; loading it eagerly instead of deferring", module_name)
172-
module = module.materialize()
173-
self.modules[module_name] = module
201+
entry = self.modules[module_name]
202+
# Materialize into a local. The dict keeps the proxy until we know a
203+
# compiled callable is actually going to replace it, so a component
204+
# that turns out to be FSDP-wrapped is neither compiled nor stripped of
205+
# its release hook.
206+
module = entry.materialize() if is_lazy_module(entry) else entry
174207
if fsdp_module_cls is not None and isinstance(module, fsdp_module_cls):
175208
logger.info(
176209
"%s is already FSDP-wrapped; skipping torch.compile in pipeline",
@@ -195,6 +228,9 @@ def _maybe_compile_pipeline_module(
195228

196229
# Backward-compatible fallback: compile full module if no condition matched.
197230
logger.info("Enabling torch.compile for %s with kwargs=%s", module_name, compile_kwargs)
231+
if is_lazy_module(entry):
232+
logger.info("Whole-module torch.compile replaces the deferred %s, so it stays resident for the run",
233+
module_name)
198234
self.modules[module_name] = torch.compile(module, **compile_kwargs)
199235

200236
def post_init(self) -> None:
@@ -287,7 +323,15 @@ def post_init(self) -> None:
287323
)
288324
logger.info("Torch Compile enabled for audio VAE")
289325

290-
self._trace_mgr = attach_activation_trace(self.modules.get("transformer"))
326+
trace_target = self.modules.get("transformer")
327+
if is_lazy_module(trace_target):
328+
# The hook manager keeps a strong reference to every module it
329+
# wraps, so attaching here would materialize the DiT before the
330+
# first request and pin that instance past any release.
331+
logger.warning("Activation trace is not attached to a deferred transformer; "
332+
"turn off lazy_module_load to trace it")
333+
trace_target = None
334+
self._trace_mgr = attach_activation_trace(trace_target)
291335

292336
if not self.fastvideo_args.training_mode:
293337
logger.info("Creating pipeline stages...")
@@ -561,25 +605,10 @@ def _build_lazy_release_schedule(self) -> dict[int, list[str]]:
561605

562606
last_use: dict[str, int] = {}
563607
for index, stage in enumerate(self._stages):
564-
for key, value in vars(stage).items():
565-
if key == "_lazy_modules_to_release":
566-
# Installed by this schedule, not a real use.
567-
continue
568-
# is_lazy_module first: isinstance() on a proxy forwards
569-
# __class__ and would load every deferred module just to work
570-
# out where to release it.
571-
if is_lazy_module(value):
572-
candidates: tuple[Any, ...] = (value, )
573-
elif isinstance(value, list | tuple):
574-
candidates = tuple(value)
575-
elif isinstance(value, dict):
576-
candidates = tuple(value.values())
577-
else:
578-
continue
579-
for candidate in candidates:
580-
name = lazy_names_by_id.get(id(candidate))
581-
if name is not None:
582-
last_use[name] = index
608+
for held in _iter_held_objects(stage):
609+
name = lazy_names_by_id.get(id(held))
610+
if name is not None:
611+
last_use[name] = index
583612

584613
schedule: dict[int, list[str]] = {}
585614
for name, index in sorted(last_use.items()):
@@ -612,6 +641,17 @@ def _install_lazy_release_hooks(self) -> None:
612641
getattr(self._stages[index], "_pipeline_stage_name", "?"), names)
613642
self._lazy_release_hooks_installed = True
614643

644+
def _release_all_lazy_modules(self) -> None:
645+
"""Free every deferred component that is currently materialized."""
646+
for module_name, module in self.modules.items():
647+
if not is_lazy_module(module):
648+
continue
649+
try:
650+
module.release()
651+
except Exception:
652+
# Never let cleanup replace the exception being propagated.
653+
logger.exception("Failed to release deferred module %s", module_name)
654+
615655
def add_stage(self, stage_name: str, stage: PipelineStage):
616656
assert self.modules is not None, "No modules are registered"
617657
# Preserve the pipeline-unique stage key for structured metrics.
@@ -653,8 +693,17 @@ def forward(
653693
# Execute each stage
654694
logger.info("Running pipeline stages: %s", self._stage_name_mapping.keys())
655695
# logger.info("Batch: %s", batch)
656-
for stage in self.stages:
657-
batch = stage(batch, fastvideo_args)
696+
try:
697+
for stage in self.stages:
698+
batch = stage(batch, fastvideo_args)
699+
except BaseException:
700+
# A stage's own hook frees only what that stage was the last user
701+
# of. When the run aborts earlier, everything already materialized
702+
# stays for the life of the generator, and the retry a
703+
# memory-constrained caller is most likely to attempt starts from a
704+
# worse position than the request that just failed.
705+
self._release_all_lazy_modules()
706+
raise
658707

659708
# Return the output
660709
return batch

fastvideo/pipelines/stages/base.py

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,34 @@ def __call__(
151151
raise
152152

153153
# Execute the actual stage logic
154+
try:
155+
result = self._execute(batch, fastvideo_args, stage_key, stage_class_name, stage_name)
156+
except BaseException:
157+
self._release_deferred_modules(stage_name)
158+
raise
159+
160+
if enable_verification:
161+
# Post-execution output verification
162+
try:
163+
output_result = self.verify_output(result, fastvideo_args)
164+
self._run_verification(output_result, stage_name, "output")
165+
except Exception as e:
166+
logger.error("Output verification failed for %s: %s", stage_name, str(e))
167+
self._release_deferred_modules(stage_name)
168+
raise
169+
170+
self._release_deferred_modules(stage_name)
171+
return result
172+
173+
def _execute(
174+
self,
175+
batch: ForwardBatch,
176+
fastvideo_args: FastVideoArgs,
177+
stage_key: str,
178+
stage_class_name: str,
179+
stage_name: str,
180+
) -> ForwardBatch:
181+
"""Run forward, with the optional timing and logging wrapper."""
154182
if envs.FASTVIDEO_STAGE_LOGGING:
155183
logger.info("[%s] Starting execution", stage_name)
156184
torch.cuda.synchronize()
@@ -176,19 +204,23 @@ def __call__(
176204
# Direct execution (current behavior)
177205
result = self.forward(batch, fastvideo_args)
178206

179-
if enable_verification:
180-
# Post-execution output verification
181-
try:
182-
output_result = self.verify_output(result, fastvideo_args)
183-
self._run_verification(output_result, stage_name, "output")
184-
except Exception as e:
185-
logger.error("Output verification failed for %s: %s", stage_name, str(e))
186-
raise
207+
return result
187208

188-
for lazy_module in self._lazy_modules_to_release:
189-
lazy_module.release()
209+
def _release_deferred_modules(self, stage_name: str) -> None:
210+
"""Free the deferred components this stage is the last user of.
190211
191-
return result
212+
Called on the way out whether or not the stage succeeded. A stage that
213+
raises after materializing a multi-gigabyte component would otherwise
214+
keep it for the life of the generator, and the retry that a
215+
memory-constrained caller is most likely to attempt would start from a
216+
worse position than the request that just failed.
217+
"""
218+
for lazy_module in self._lazy_modules_to_release:
219+
try:
220+
lazy_module.release()
221+
except Exception:
222+
# Never let cleanup replace the exception being propagated.
223+
logger.exception("Failed to release deferred module after %s", stage_name)
192224

193225
@abstractmethod
194226
def forward(

fastvideo/tests/api/test_parser.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ def test_load_run_config_supports_yaml_roundtrip(tmp_path) -> None:
114114
"image_encoder": True,
115115
"vae": True,
116116
"pin_cpu_memory": True,
117+
"lazy_module_load": False,
117118
},
118119
"compile": {
119120
"enabled": False,

0 commit comments

Comments
 (0)