|
| 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