Skip to content

Commit 4eee71f

Browse files
committed
[example] LTX-2.3 distilled i2v with timing breakdown
Add `examples/inference/basic/basic_ltx2_3_distilled_i2v.py`: a single-GPU LTX-2.3 distilled image-to-video example that loads the registered `FastVideo/LTX-2.3-Distilled-Diffusers` snapshot, runs the i2v path with the production recipe (8 denoise + 3 refine, CFG=1, 832x1280 portrait, 121 frames @ 24fps), and prints a per-stage timing breakdown averaged over two measured runs. Defaults to eager so it works out of the box on `main`. The docstring documents how to opt into `torch.compile` via `LTX23_ENABLE_COMPILE=1` and names the two upstream sites where fullgraph compile currently fails under recent PyTorch: - `fastvideo/models/encoders/gemma.py` (`_replace_padded_with_learnable_ registers`): boolean-mask indexing produces a data-dependent shape that `dynamic=False` cannot trace. - `fastvideo/models/dits/ltx2.py` nested `_build_attn_keep_mask`: the `bool | torch.Tensor` annotation is evaluated every outer-forward call, producing a `types.UnionType` that dynamo's SourcelessBuilder rejects. Both have small workarounds (see docstring) that can be addressed in follow-up PRs; flipping `LTX23_ENABLE_COMPILE=1` here will then exercise the compile path end-to-end. Fills a gap on `main`: the existing `basic_ltx2*.py` examples are t2v only and target LTX-2.0 distilled. After #1397 merged LTX-2.3, there was no copy-paste example for the LTX-2.3 i2v path. Validated end-to-end on a single GB200: 2 warmup + 2 measured runs, e2e ~8.7s eager per 5s clip; stage sum matches e2e (no hidden overhead). The conditioning image is supplied via `LTX23_I2V_IMAGE` (the script errors out with a usage message if unset) so no binary asset is added to the tree.
1 parent 5706079 commit 4eee71f

1 file changed

Lines changed: 298 additions & 0 deletions

File tree

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""LTX-2.3 distilled image-to-video with per-stage timing breakdown.
3+
4+
This example runs the LTX-2.3 distilled student model on a single GPU and
5+
prints a per-stage timing breakdown so the user can see where wall-time
6+
goes. It is meant as the canonical entry point for trying out the LTX-2.3
7+
i2v path on `hao-ai-lab/FastVideo:main`.
8+
9+
Quick start
10+
-----------
11+
export LTX23_I2V_IMAGE=/path/to/your/portrait_or_product.jpg
12+
# optional overrides:
13+
# export LTX23_I2V_PROMPT="a fashion model walks toward camera..."
14+
# export LTX23_OUTPUT_DIR=outputs_video/ltx2_3_distilled_i2v
15+
python examples/inference/basic/basic_ltx2_3_distilled_i2v.py
16+
17+
What the script does
18+
--------------------
19+
1. Loads FastVideo/LTX-2.3-Distilled-Diffusers (8 denoise + 3 refine steps,
20+
CFG=1, no refine LoRA — the distilled production recipe).
21+
2. Runs 2 warmup calls (untimed) + 2 measured calls. The two-warmup pattern
22+
is documented because it matters once compile is enabled (see below).
23+
3. Prints a per-stage breakdown and an average over the measured runs.
24+
25+
torch.compile (opt-in)
26+
----------------------
27+
Set `LTX23_ENABLE_COMPILE=1` to enable `torch.compile` on the DiT (with
28+
fullgraph + max-autotune-no-cudagraphs). With current `main` HEAD and a
29+
recent PyTorch, this currently fails dynamo at two independent sites — they
30+
both have to be fixed upstream before the compile path completes:
31+
32+
1. `fastvideo/models/encoders/gemma.py` (`_replace_padded_with_learnable_
33+
registers`): boolean-mask indexing produces a data-dependent shape
34+
(`aten.nonzero.default`) that fullgraph + `dynamic=False` can't trace.
35+
Workaround: pack valid tokens without a value-dependent intermediate
36+
shape (e.g. via `gather`/`scatter` against the binary mask, padding to
37+
the original length). Setting `enable_torch_compile_text_encoder=False`
38+
here sidesteps it for the text encoder, but the DiT path also trips on
39+
it transitively when the connector is compiled — best fixed at the
40+
source.
41+
2. `fastvideo/models/dits/ltx2.py` around the nested `_build_attn_keep_
42+
mask`: the nested-function annotation `bool | torch.Tensor` is
43+
evaluated at every outer-forward call, producing a `types.UnionType`
44+
that dynamo's SourcelessBuilder cannot wrap. Workaround: hoist the
45+
annotation to `typing.Union[bool, torch.Tensor]`, add `from __future__
46+
import annotations` at the top of the module, or move the helper to
47+
module scope.
48+
49+
Once those two are addressed, this example's compile path should reach
50+
~4s e2e per clip on GB200 (warm). Until then, it remains eager-by-default.
51+
52+
Hardware notes
53+
--------------
54+
- Single-GPU example; for multi-GPU sequence-parallel see the gradio demo
55+
under `examples/inference/gradio/local/gradio_local_demo_ltx2_3/`.
56+
- On GB200 / Blackwell, launch with `env -u LD_LIBRARY_PATH ...` to avoid
57+
a system-cuBLAS / torch-cuBLAS mismatch that fails every GEMM. The
58+
`_inductor.shape_padding = False` line below also avoids a pad_mm
59+
landmine on the same generation of cards (it is a no-op in eager).
60+
"""
61+
from __future__ import annotations
62+
63+
import os
64+
import time
65+
from collections import OrderedDict
66+
from pathlib import Path
67+
68+
import torch._inductor.config as _inductor
69+
70+
from fastvideo import VideoGenerator
71+
from fastvideo.utils import maybe_download_model
72+
73+
# Env knobs (set BEFORE importing fastvideo where possible — but
74+
# FASTVIDEO_ATTENTION_BACKEND is fine here because the worker reads it
75+
# on generator construction).
76+
os.environ.setdefault("FASTVIDEO_ATTENTION_BACKEND", "FLASH_ATTN")
77+
os.environ.setdefault("FASTVIDEO_STAGE_LOGGING", "1")
78+
79+
# Inductor knobs. The first one (shape_padding=False) is mandatory on
80+
# Blackwell to avoid a cuBLAS INVALID_VALUE crash inside pad_mm during
81+
# the refine path. The rest are autotune-friendliness flags.
82+
_inductor.shape_padding = False
83+
_inductor.conv_1x1_as_mm = True
84+
_inductor.coordinate_descent_tuning = True
85+
_inductor.coordinate_descent_check_all_directions = True
86+
_inductor.epilogue_fusion = False
87+
88+
MODEL_ID = os.path.expandvars(
89+
os.path.expanduser(
90+
os.getenv("LTX23_MODEL_PATH", "FastVideo/LTX-2.3-Distilled-Diffusers")
91+
)
92+
)
93+
OUTPUT_DIR = Path(
94+
os.getenv("LTX23_OUTPUT_DIR", "outputs_video/ltx2_3_distilled_i2v")
95+
)
96+
I2V_IMAGE = os.getenv("LTX23_I2V_IMAGE", "")
97+
DEFAULT_PROMPT = (
98+
"A fashion model takes a slow step forward and shifts her weight, "
99+
"the soft fabric of her clothing swaying and rippling with the "
100+
"motion, her hair shifting gently, soft even studio lighting on a "
101+
"clean light background, elegant slow-motion runway feel."
102+
)
103+
PROMPT = os.getenv("LTX23_I2V_PROMPT", DEFAULT_PROMPT)
104+
ENABLE_COMPILE = os.getenv("LTX23_ENABLE_COMPILE", "0").lower() in (
105+
"1", "true", "yes"
106+
)
107+
108+
# Per-stage timing helpers --------------------------------------------------
109+
110+
def _print_stage_breakdown(result: dict, label: str) -> float | None:
111+
"""Print stage execution times and return the sum, or None if missing."""
112+
logging_info = result.get("logging_info")
113+
stages = getattr(logging_info, "stages", None) if logging_info else None
114+
if not stages:
115+
print(f" [{label}] stage breakdown unavailable")
116+
return None
117+
print(f" [{label}] stage breakdown:")
118+
total = 0.0
119+
for name, metrics in stages.items():
120+
exec_s = float(metrics.get("execution_time", 0.0))
121+
total += exec_s
122+
print(f" - {name}: {exec_s:.3f}s")
123+
print(f" - stage_sum: {total:.3f}s")
124+
return total
125+
126+
127+
def _collect_stage_times(
128+
result: dict,
129+
stage_times: dict[str, list[float]],
130+
stage_order: OrderedDict[str, None],
131+
) -> None:
132+
logging_info = result.get("logging_info")
133+
stages = getattr(logging_info, "stages", None) if logging_info else None
134+
if not stages:
135+
return
136+
for name, metrics in stages.items():
137+
stage_order.setdefault(name, None)
138+
stage_times.setdefault(name, []).append(
139+
float(metrics.get("execution_time", 0.0))
140+
)
141+
142+
143+
def _resolve_refine_upsampler(model_root: str) -> Path:
144+
"""LTX-2.3 distilled snapshots ship a `spatial_upscaler/` subdir."""
145+
for name in ("spatial_upscaler", "spatial_upsampler"):
146+
cand = Path(model_root) / name
147+
if (cand / "config.json").is_file():
148+
return cand
149+
raise FileNotFoundError(
150+
f"No refine upsampler directory under {model_root}. "
151+
f"Expected `{model_root}/spatial_upscaler/config.json`."
152+
)
153+
154+
155+
# Main ---------------------------------------------------------------------
156+
157+
def main() -> None:
158+
if not I2V_IMAGE:
159+
raise SystemExit(
160+
"LTX23_I2V_IMAGE is required for i2v. Example:\n"
161+
" export LTX23_I2V_IMAGE=/path/to/portrait_or_product.jpg\n"
162+
" python examples/inference/basic/basic_ltx2_3_distilled_i2v.py"
163+
)
164+
if not Path(I2V_IMAGE).is_file():
165+
raise SystemExit(f"LTX23_I2V_IMAGE not found: {I2V_IMAGE}")
166+
167+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
168+
model_root = maybe_download_model(MODEL_ID)
169+
refine_upsampler_path = _resolve_refine_upsampler(model_root)
170+
print(f"Model: {model_root}")
171+
print(f"Refine upsampler: {refine_upsampler_path}")
172+
print(f"i2v image: {I2V_IMAGE}")
173+
print(f"Output dir: {OUTPUT_DIR.resolve()}")
174+
print(f"Compile: {'on' if ENABLE_COMPILE else 'off (eager)'}")
175+
176+
torch_compile_kwargs = {
177+
"backend": "inductor",
178+
"fullgraph": True,
179+
"mode": "max-autotune-no-cudagraphs",
180+
"dynamic": False,
181+
}
182+
183+
generator = VideoGenerator.from_pretrained(
184+
model_root,
185+
num_gpus=1,
186+
# LTX-2.3 distilled uses the two-stage refine pipeline; the refine
187+
# LoRA is intentionally empty for the distilled student.
188+
ltx2_refine_enabled=True,
189+
ltx2_refine_upsampler_path=str(refine_upsampler_path),
190+
ltx2_refine_lora_path="",
191+
ltx2_refine_num_inference_steps=3,
192+
ltx2_refine_guidance_scale=1.0,
193+
ltx2_refine_add_noise=True,
194+
enable_torch_compile=ENABLE_COMPILE,
195+
# Text-encoder compile is held off independent of the DiT compile
196+
# flag: the current Gemma connector path
197+
# (`_replace_padded_with_learnable_registers` in
198+
# `fastvideo/models/encoders/gemma.py`) uses boolean-mask indexing
199+
# whose output shape depends on the input data, which torch.compile
200+
# cannot fullgraph-trace under `dynamic=False`. See the module
201+
# docstring for the broader compile-path notes.
202+
enable_torch_compile_text_encoder=False,
203+
torch_compile_kwargs=torch_compile_kwargs,
204+
# Keep everything resident — no CPU offload for serving-style runs.
205+
dit_cpu_offload=False,
206+
text_encoder_cpu_offload=False,
207+
vae_cpu_offload=False,
208+
ltx2_vae_tiling=False,
209+
)
210+
211+
common_kwargs = dict(
212+
prompt=PROMPT,
213+
negative_prompt="", # distilled is CFG-free; no negative needed
214+
guidance_scale=1.0, # CFG=1 for distilled
215+
height=1280, width=832, # portrait runway aspect
216+
num_frames=121, fps=24, # ~5s clip
217+
num_inference_steps=8, # distilled denoise steps
218+
# i2v: anchor the input image at frame 0 with full strength.
219+
# `ltx2_image_crf=0.0` skips an extra JPEG re-encode of an already
220+
# JPEG conditioning image.
221+
ltx2_images=[(I2V_IMAGE, 0, 1.0)],
222+
ltx2_image_crf=0.0,
223+
save_video=True,
224+
)
225+
226+
warmup_runs = 2
227+
measured_runs = 2
228+
warmup_secs: list[float] = []
229+
measured_secs: list[float] = []
230+
stage_times: dict[str, list[float]] = {}
231+
stage_order: OrderedDict[str, None] = OrderedDict()
232+
233+
try:
234+
# Warmup: untimed (but we still wall-clock them so the first compile
235+
# cost is visible to the reader).
236+
for w in range(warmup_runs):
237+
t0 = time.perf_counter()
238+
print(f"\n[warmup {w + 1}/{warmup_runs}] compiling + generating…")
239+
generator.generate_video(
240+
output_path=str(OUTPUT_DIR / f"_warmup_{w + 1}.mp4"),
241+
seed=7,
242+
**common_kwargs,
243+
)
244+
dt = time.perf_counter() - t0
245+
warmup_secs.append(dt)
246+
print(f"[warmup {w + 1}/{warmup_runs}] wall={dt:.1f}s")
247+
248+
# Cleanup warmup artifacts so the user only sees measured outputs.
249+
for w in range(warmup_runs):
250+
(OUTPUT_DIR / f"_warmup_{w + 1}.mp4").unlink(missing_ok=True)
251+
252+
# Measured.
253+
for m in range(measured_runs):
254+
out_path = OUTPUT_DIR / f"output_ltx2_3_distilled_i2v_run_{m + 1}.mp4"
255+
print(f"\n[measured {m + 1}/{measured_runs}] generating: {out_path}")
256+
t0 = time.perf_counter()
257+
result = generator.generate_video(
258+
output_path=str(out_path),
259+
seed=2002 + m,
260+
**common_kwargs,
261+
)
262+
wall = time.perf_counter() - t0
263+
e2e = (
264+
result.get("e2e_latency")
265+
if isinstance(result, dict) else None
266+
) or wall
267+
measured_secs.append(e2e)
268+
print(f"[measured {m + 1}/{measured_runs}] e2e={e2e:.2f}s wall={wall:.2f}s")
269+
if isinstance(result, dict):
270+
_print_stage_breakdown(result, f"measured {m + 1}")
271+
_collect_stage_times(result, stage_times, stage_order)
272+
273+
# Summary.
274+
print("\n=== summary ===")
275+
print(f"warmup wall-times: {[round(x, 1) for x in warmup_secs]}")
276+
if measured_secs:
277+
avg = sum(measured_secs) / len(measured_secs)
278+
print(
279+
f"measured e2e (n={len(measured_secs)}): "
280+
f"{[round(x, 2) for x in measured_secs]} -> avg {avg:.2f}s"
281+
)
282+
if stage_times:
283+
print(f"average stage times over {measured_runs} measured runs:")
284+
avg_total = 0.0
285+
for name in stage_order:
286+
vals = stage_times.get(name) or []
287+
if not vals:
288+
continue
289+
avg_v = sum(vals) / len(vals)
290+
avg_total += avg_v
291+
print(f" - {name}: {avg_v:.3f}s")
292+
print(f" - stage_sum_avg: {avg_total:.3f}s")
293+
finally:
294+
generator.shutdown()
295+
296+
297+
if __name__ == "__main__":
298+
main()

0 commit comments

Comments
 (0)