Skip to content

Commit 56f284c

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 tag in output dirs ({eager,compile}[-{dit_precision}]) 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, --dit-precision, --num-prompts flags for flexible legs (eager/compile, bf16/fp16/fp32, short diagnostic runs vs full 5-prompt validation). Reproducible by reviewers — see PR body for exact commands.
1 parent 24468b6 commit 56f284c

2 files changed

Lines changed: 774 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)