Skip to content

Commit 8512734

Browse files
committed
[test]: Modal A/B harness for batched-CFG validation
Single-container two-pass A/B harness modeled on Kuan's nccl_stream_ab.py pattern (used to validate hao-ai-lab#1395). Runs the same seed-pinned prompts twice through the same VideoGenerator (once sequential, once batched), computes pairwise SSIM via pytorch_msssim (matches fastvideo/tests/utils.py:compute_video_ssim_torchvision), prints a per-prompt + total wall/SSIM table. Architecture: the harness ferries pass config + records over JSON files and subprocesses the inner script via /opt/venv/bin/python. Modal's main function process runs in its own add_python="3.12" layer where FastVideo isn't importable; the venv subprocess works around that. Same reason ssim_test.py runs pytest as a subprocess. Features: - Mode-tagged output dirs ({eager,compile}) so multiple legs don't clobber each other in the hf-model-weights Volume. - Recovery function reads existing mp4s by mtime and recomputes SSIM for post-run gap-fill if the in-run SSIM step missed. - Prebuilt FA3 wheel install on Hopper (autodetected from gpu kwarg) via mjun0812/flash-attention-prebuild-wheels v0.9.4 — the same release that supplies the FA2 wheel already baked into the fastvideo-dev image. ~30s install vs Kuan's hao-ai-lab#1389 ~90min cold source build. - Modal Secret integration for HF token (no token-on-the-wire). - --enable-compile + --num-prompts flags for flexible legs. Reproducible by reviewers — see PR body for exact commands.
1 parent 66c1fd6 commit 8512734

2 files changed

Lines changed: 759 additions & 0 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""
2+
Inner-script half of the W3b batched-CFG A/B harness.
3+
4+
Runs one denoising pass (sequential or batched) over the harness's
5+
prompt + seed set, records per-prompt walls, and writes a JSON results
6+
blob to the path given on argv.
7+
8+
Invoked by ``batched_cfg_ab.py`` via ``/opt/venv/bin/python`` so that
9+
FastVideo runs inside the image's venv (Modal's main function process
10+
runs in Modal's own Python, where FastVideo is not installed).
11+
"""
12+
import argparse
13+
import json
14+
import os
15+
import sys
16+
import time
17+
18+
19+
def main() -> int:
20+
parser = argparse.ArgumentParser()
21+
parser.add_argument("--mode", choices=("run_pass", "compute_ssim"), default="run_pass",
22+
help="run_pass: execute one A/B pass. compute_ssim: read baseline+patched "
23+
"results JSONs, write a pairwise SSIM JSON.")
24+
parser.add_argument("--config-json", help="run_pass: path to JSON file with run config")
25+
parser.add_argument("--results-json", help="run_pass: path to write JSON results")
26+
parser.add_argument("--baseline-results-json", help="compute_ssim: path to baseline records JSON (mode A)")
27+
parser.add_argument("--patched-results-json", help="compute_ssim: path to patched records JSON (mode A)")
28+
parser.add_argument("--baseline-dir", help="compute_ssim: scan dir for newest mp4 per prompt_NN (mode B)")
29+
parser.add_argument("--patched-dir", help="compute_ssim: scan dir for newest mp4 per prompt_NN (mode B)")
30+
parser.add_argument("--ssim-output-json", help="compute_ssim: path to write SSIM rows JSON")
31+
args = parser.parse_args()
32+
33+
if args.mode == "compute_ssim":
34+
return _compute_ssim_main(args)
35+
36+
if not args.config_json or not args.results_json:
37+
parser.error("--config-json and --results-json are required for mode=run_pass")
38+
with open(args.config_json) as f:
39+
cfg = json.load(f)
40+
41+
model_id: str = cfg["model_id"]
42+
num_gpus: int = cfg["num_gpus"]
43+
use_batched_cfg: bool = cfg["use_batched_cfg"]
44+
enable_compile: bool = cfg.get("enable_compile", False)
45+
height: int = cfg["height"]
46+
width: int = cfg["width"]
47+
num_frames: int = cfg["num_frames"]
48+
num_inference_steps: int = cfg["num_inference_steps"]
49+
output_dir: str = cfg["output_dir"]
50+
prompts: list[str] = cfg["prompts"]
51+
seed_base: int = cfg["seed_base"]
52+
53+
# Log resolved flash-attn version (Hopper -> FA3 expected per Will
54+
# Slack 2026-05-28; L40S -> FA2 baked into the image).
55+
from fastvideo.attention.backends.flash_attn import fa_version
56+
print(f"[inner] resolved flash-attn version: FA{fa_version}", flush=True)
57+
58+
from fastvideo import VideoGenerator
59+
60+
generator = VideoGenerator.from_pretrained(
61+
model_id,
62+
num_gpus=num_gpus,
63+
use_batched_cfg=use_batched_cfg,
64+
enable_torch_compile=enable_compile,
65+
)
66+
if enable_compile:
67+
print("[inner] enable_torch_compile=True — first prompt will include compile warmup", flush=True)
68+
resolved_flag = getattr(generator.fastvideo_args, "use_batched_cfg", None)
69+
if resolved_flag is not use_batched_cfg:
70+
raise RuntimeError(f"use_batched_cfg did not propagate: requested {use_batched_cfg}, "
71+
f"resolved {resolved_flag}")
72+
print(f"[inner] use_batched_cfg resolved to {resolved_flag}", flush=True)
73+
74+
os.makedirs(output_dir, exist_ok=True)
75+
records = []
76+
for i, prompt in enumerate(prompts):
77+
prompt_out = os.path.join(output_dir, f"prompt_{i:02d}")
78+
t0 = time.perf_counter()
79+
generator.generate_video(
80+
prompt,
81+
output_path=prompt_out,
82+
save_video=True,
83+
height=height,
84+
width=width,
85+
num_frames=num_frames,
86+
num_inference_steps=num_inference_steps,
87+
seed=seed_base + i,
88+
)
89+
wall = time.perf_counter() - t0
90+
# FastVideo's generate_video doesn't overwrite — it appends
91+
# _1, _2, ... to avoid collisions. After multiple harness runs
92+
# the same prompt_NN directory accumulates mp4s from each run;
93+
# we want the one we just wrote. Pick by mtime (newest).
94+
mp4s = sorted(
95+
[os.path.join(prompt_out, f) for f in os.listdir(prompt_out) if f.endswith(".mp4")],
96+
key=os.path.getmtime,
97+
)
98+
if not mp4s:
99+
raise RuntimeError(f"no .mp4 produced for prompt {i} at {prompt_out}")
100+
records.append({"i": i, "wall_s": wall, "mp4": mp4s[-1]})
101+
label = "batched" if use_batched_cfg else "sequential"
102+
print(f"[inner] [{label}] prompt {i}: {wall:.3f}s -> {mp4s[0]}", flush=True)
103+
104+
with open(args.results_json, "w") as f:
105+
json.dump(records, f)
106+
print(f"[inner] wrote {len(records)} records to {args.results_json}", flush=True)
107+
return 0
108+
109+
110+
def _compute_ssim_main(args) -> int:
111+
"""Pairwise SSIM between baseline and patched output mp4s.
112+
113+
Two input modes:
114+
A. --baseline-results-json + --patched-results-json: read the
115+
JSON records the per-pass runs wrote; each carries the mp4
116+
path the in-run picker selected.
117+
B. --baseline-dir + --patched-dir: scan each directory's
118+
prompt_NN/ subdirs and pick the NEWEST mp4 per prompt by
119+
mtime. Use this to recover after a run where stale mp4s
120+
from a previous run accumulated in the same dir.
121+
122+
Avoids importing ``fastvideo`` entirely — its top-level package
123+
transitively imports triton (via vmoba), which needs a CUDA driver
124+
to initialise. The recovery function runs on CPU, so we inline the
125+
SSIM logic here using only torch / torchvision / av directly.
126+
"""
127+
if not args.ssim_output_json:
128+
raise SystemExit("--ssim-output-json is required for mode=compute_ssim")
129+
mode_a = args.baseline_results_json and args.patched_results_json
130+
mode_b = args.baseline_dir and args.patched_dir
131+
if not (mode_a or mode_b):
132+
raise SystemExit("Provide either --baseline-results-json + --patched-results-json (mode A) "
133+
"OR --baseline-dir + --patched-dir (mode B).")
134+
135+
# Match fastvideo/tests/utils.py: pytorch_msssim's ssim (single-scale)
136+
# so our numbers are directly comparable to anyone re-running the
137+
# canonical helper. The reference helper defaults to MS-SSIM but the
138+
# SSIM=1.0 gate is the more conservative bar — both should be 1.0
139+
# on identical output, so use ssim (not ms_ssim) here as it's the
140+
# stricter single-scale comparison.
141+
import torch
142+
from pytorch_msssim import ssim as pm_ssim
143+
144+
def _read_video_frames(path: str) -> torch.Tensor:
145+
try:
146+
from torchvision.io import read_video
147+
frames, _, _ = read_video(path, pts_unit="sec", output_format="TCHW")
148+
return frames
149+
except (ImportError, AttributeError):
150+
pass
151+
import av
152+
container = av.open(path)
153+
frames = []
154+
for frame in container.decode(video=0):
155+
arr = frame.to_ndarray(format="rgb24")
156+
frames.append(torch.from_numpy(arr).permute(2, 0, 1))
157+
container.close()
158+
if not frames:
159+
raise RuntimeError(f"No video frames decoded from {path}")
160+
return torch.stack(frames)
161+
162+
def _ssim(video1_path: str, video2_path: str) -> list[float]:
163+
f1 = _read_video_frames(video1_path)
164+
f2 = _read_video_frames(video2_path)
165+
n = min(f1.shape[0], f2.shape[0])
166+
f1 = (f1[:n].float() / 255.0).contiguous()
167+
f2 = (f2[:n].float() / 255.0).contiguous()
168+
vals = []
169+
for i in range(n):
170+
v = pm_ssim(f1[i:i + 1], f2[i:i + 1], data_range=1.0).item()
171+
vals.append(v)
172+
return vals
173+
174+
def _scan_dir_for_newest_mp4s(root: str) -> list[dict]:
175+
"""For each prompt_NN/ subdir, pick the newest .mp4 by mtime."""
176+
rows: list[dict] = []
177+
for entry in sorted(os.listdir(root)):
178+
sub = os.path.join(root, entry)
179+
if not (os.path.isdir(sub) and entry.startswith("prompt_")):
180+
continue
181+
mp4s = sorted(
182+
[os.path.join(sub, f) for f in os.listdir(sub) if f.endswith(".mp4")],
183+
key=os.path.getmtime,
184+
)
185+
if not mp4s:
186+
continue
187+
idx = int(entry.split("_", 1)[1])
188+
rows.append({"i": idx, "wall_s": float("nan"), "mp4": mp4s[-1]})
189+
rows.sort(key=lambda r: r["i"])
190+
return rows
191+
192+
if mode_b:
193+
baseline = _scan_dir_for_newest_mp4s(args.baseline_dir)
194+
patched = _scan_dir_for_newest_mp4s(args.patched_dir)
195+
print(f"[inner] scanned {args.baseline_dir} -> {len(baseline)} prompts", flush=True)
196+
print(f"[inner] scanned {args.patched_dir} -> {len(patched)} prompts", flush=True)
197+
else:
198+
with open(args.baseline_results_json) as f:
199+
baseline = json.load(f)
200+
with open(args.patched_results_json) as f:
201+
patched = json.load(f)
202+
203+
rows = []
204+
for b, p in zip(baseline, patched, strict=True):
205+
assert b["i"] == p["i"]
206+
print(f"[inner] computing SSIM for prompt {b['i']}: {b['mp4']} vs {p['mp4']}", flush=True)
207+
ssim_vals = _ssim(b["mp4"], p["mp4"])
208+
ssim_mean = float(sum(ssim_vals) / len(ssim_vals))
209+
ssim_worst = float(min(ssim_vals))
210+
rows.append({
211+
"i": b["i"],
212+
"baseline_wall_s": b["wall_s"],
213+
"patched_wall_s": p["wall_s"],
214+
"ssim_mean": ssim_mean,
215+
"ssim_worst": ssim_worst,
216+
})
217+
print(f"[inner] prompt {b['i']} SSIM mean={ssim_mean:.6f} worst={ssim_worst:.6f}", flush=True)
218+
with open(args.ssim_output_json, "w") as f:
219+
json.dump(rows, f)
220+
print(f"[inner] wrote {len(rows)} SSIM rows to {args.ssim_output_json}", flush=True)
221+
return 0
222+
223+
224+
if __name__ == "__main__":
225+
sys.exit(main())

0 commit comments

Comments
 (0)