|
| 1 | +# Copyright (c) Meta Platforms, Inc. and affiliates. All Rights Reserved |
| 2 | + |
| 3 | +""" |
| 4 | +SAM3 encoder torch.compile A/B benchmark — production-faithful. |
| 5 | +
|
| 6 | +Builds ``Sam3VideoPredictorMultiGPU`` with the EXACT constructor arguments that |
| 7 | +scene_service's ``Sam3DenseTrackingModel.load_model`` uses (see |
| 8 | +``fbcode/genai/media_editing/project/sam_stateful/inferencer/dense_tracking.py``), |
| 9 | +plus the ``sam3_v4.pt`` production checkpoint, with ``compile`` toggled on/off. |
| 10 | +It then measures steady-state propagation throughput (FPS), per-frame latency, |
| 11 | +and peak GPU memory on a synthetic moving-circles video. |
| 12 | +
|
| 13 | +This isolates the win from ``torch.compile`` (dominated by the ViT image encoder, |
| 14 | +which is the ~1.3s hotspot) on the same model + GPU class the IPNext tenant serves. |
| 15 | +
|
| 16 | +Usage: |
| 17 | + buck run @fbcode//mode/opt \ |
| 18 | + fbcode//deeplearning/projects/sam3_release:bench_compile -- \ |
| 19 | + --checkpoint /tmp/sam3_v4.pt --num_objects 5 --n_frames 50 --compile |
| 20 | + # eager baseline: |
| 21 | + buck run @fbcode//mode/opt \ |
| 22 | + fbcode//deeplearning/projects/sam3_release:bench_compile -- \ |
| 23 | + --checkpoint /tmp/sam3_v4.pt --num_objects 5 --n_frames 50 --no-compile |
| 24 | +""" |
| 25 | + |
| 26 | +import argparse |
| 27 | +import getpass |
| 28 | +import os |
| 29 | +import time |
| 30 | + |
| 31 | +import torch |
| 32 | + |
| 33 | +# Reuse the author's synthetic-video + timing helpers (same package, via :sam3). |
| 34 | +from scripts.measure_speed import main_loop, max_memory_allocated, synthesize_video_data |
| 35 | + |
| 36 | + |
| 37 | +def build_prod_predictor(checkpoint_path: str, do_compile: bool): |
| 38 | + """Construct the predictor identically to Sam3DenseTrackingModel.load_model.""" |
| 39 | + from sam3.model.sam3_video_predictor import Sam3VideoPredictorMultiGPU |
| 40 | + |
| 41 | + return Sam3VideoPredictorMultiGPU( |
| 42 | + checkpoint_path=checkpoint_path, |
| 43 | + bpe_path=None, |
| 44 | + has_presence_token=True, |
| 45 | + geo_encoder_use_img_cross_attn=True, |
| 46 | + strict_state_dict_loading=False, |
| 47 | + apply_temporal_disambiguation=True, |
| 48 | + async_loading_frames=False, |
| 49 | + video_loader_type="cv2", |
| 50 | + compile=do_compile, |
| 51 | + gpus_to_use=[0], |
| 52 | + ) |
| 53 | + |
| 54 | + |
| 55 | +def time_scene_frame( |
| 56 | + model_wrapper, |
| 57 | + image_path: str, |
| 58 | + labels: list[str], |
| 59 | + n_iters: int = 12, |
| 60 | + warmup_iters: int = 5, |
| 61 | +) -> tuple[float, float]: |
| 62 | + """Replicate scene_service's per-frame SAM3 sequence (Sam3Client.getMasksStream). |
| 63 | +
|
| 64 | + For each ingested frame, subscribe_scene_objects drives exactly: |
| 65 | + start_session(single image) -> add_prompt(label) x K -> close/reset |
| 66 | + i.e. a FRESH session per frame (image re-encoded every frame, no cross-frame |
| 67 | + reuse) with the K subscription labels sharing that one image encode. This is |
| 68 | + the real production unit -- NOT video propagate_in_video. Returns (median, min) |
| 69 | + ms per frame. |
| 70 | +
|
| 71 | + Runs warmup_iters discarded iterations first so the single-image detection path |
| 72 | + is compiled/settled (the propagate warm-up does NOT exercise this path), then |
| 73 | + times n_iters.""" |
| 74 | + |
| 75 | + def _one_frame() -> float: |
| 76 | + torch.cuda.synchronize() |
| 77 | + t0 = time.perf_counter() |
| 78 | + resp = model_wrapper.handle_request( |
| 79 | + {"type": "start_session", "resource_path": image_path} |
| 80 | + ) |
| 81 | + sid = resp["session_id"] |
| 82 | + for label in labels: |
| 83 | + model_wrapper.handle_request( |
| 84 | + { |
| 85 | + "type": "add_prompt", |
| 86 | + "session_id": sid, |
| 87 | + "frame_index": 0, |
| 88 | + "text": label, |
| 89 | + } |
| 90 | + ) |
| 91 | + torch.cuda.synchronize() |
| 92 | + dt = (time.perf_counter() - t0) * 1000.0 |
| 93 | + model_wrapper.handle_request({"type": "reset_session", "session_id": sid}) |
| 94 | + return dt |
| 95 | + |
| 96 | + for _ in range(warmup_iters): |
| 97 | + _one_frame() |
| 98 | + times_ms = [_one_frame() for _ in range(n_iters)] |
| 99 | + times_ms.sort() |
| 100 | + return times_ms[len(times_ms) // 2], times_ms[0] # median, min |
| 101 | + |
| 102 | + |
| 103 | +def run( |
| 104 | + checkpoint_path: str, |
| 105 | + num_objects: int, |
| 106 | + n_frames: int, |
| 107 | + radius: int, |
| 108 | + speed: int, |
| 109 | + width: int, |
| 110 | + height: int, |
| 111 | + video_dir: str, |
| 112 | + do_compile: bool, |
| 113 | + full_warmup: bool = False, |
| 114 | + native_warmup: bool = False, |
| 115 | +) -> float: |
| 116 | + torch.autocast(device_type="cuda", dtype=torch.bfloat16).__enter__() |
| 117 | + |
| 118 | + synthesize_video_data( |
| 119 | + num_objects=num_objects, |
| 120 | + out_dir=video_dir, |
| 121 | + radius=radius, |
| 122 | + speed=speed, |
| 123 | + width=width, |
| 124 | + height=height, |
| 125 | + n_frames=n_frames, |
| 126 | + ) |
| 127 | + |
| 128 | + mode = "COMPILED" if do_compile else "EAGER" |
| 129 | + print(f"\n=== Building {mode} predictor from {checkpoint_path} ===") |
| 130 | + model_wrapper = build_prod_predictor(checkpoint_path, do_compile) |
| 131 | + |
| 132 | + # --native-warmup mirrors the production Sam3DenseTrackingModel.warmup path: |
| 133 | + # install the compile wrappers directly via _compile_model() (NOT via a video |
| 134 | + # propagate) and skip the propagate rounds entirely, so the only thing that |
| 135 | + # compiles the served graphs is the single-image add_prompt warm-up inside |
| 136 | + # time_scene_frame. Run under TORCH_LOGS=recompiles to prove no recompile |
| 137 | + # fires on the TIMED single-image iters (all recompiles absorbed in warmup). |
| 138 | + best_fps = 0.0 |
| 139 | + if do_compile and native_warmup: |
| 140 | + print("Native warmup: _compile_model() direct (no propagate)...") |
| 141 | + model_wrapper.model._compile_model() |
| 142 | + else: |
| 143 | + response = model_wrapper.handle_request( |
| 144 | + {"type": "start_session", "resource_path": video_dir} |
| 145 | + ) |
| 146 | + session_id = response["session_id"] |
| 147 | + |
| 148 | + if do_compile and full_warmup: |
| 149 | + try: |
| 150 | + print("Warming up torch.compile (varying object counts)...") |
| 151 | + model_wrapper.model.warm_up_compilation() |
| 152 | + except Exception as e: |
| 153 | + print(f"warm_up_compilation() failed ({e!r}); relying on lazy compile.") |
| 154 | + |
| 155 | + print("Warm-up rounds...") |
| 156 | + fps = 0.0 |
| 157 | + for _ in range(3): |
| 158 | + fps = max(main_loop(model_wrapper, session_id, "circle"), fps) |
| 159 | + |
| 160 | + print("Timing rounds...") |
| 161 | + for i in range(10): |
| 162 | + torch.cuda.empty_cache() |
| 163 | + torch.cuda.reset_peak_memory_stats() |
| 164 | + f = main_loop(model_wrapper, session_id, "circle") |
| 165 | + best_fps = max(best_fps, f) |
| 166 | + print(f" round {i + 1}: {f:.2f} FPS") |
| 167 | + max_memory_allocated() |
| 168 | + |
| 169 | + # |
| 170 | + # scene_service per-frame SAM3 cost: start_session(single image) + K add_prompt. |
| 171 | + image_path = os.path.join(video_dir, "000.jpg") |
| 172 | + scene_ms = {} |
| 173 | + for k in (1, 3): |
| 174 | + labels = ["circle", "square", "triangle"][:k] |
| 175 | + med, mn = time_scene_frame(model_wrapper, image_path, labels) |
| 176 | + scene_ms[k] = med |
| 177 | + print(f" scene per-frame K={k} labels: {med:.2f} ms median ({mn:.2f} ms min)") |
| 178 | + |
| 179 | + per_frame_ms = 1000.0 / best_fps if best_fps > 0 else float("nan") |
| 180 | + print( |
| 181 | + f"\n=== RESULT {mode}: " |
| 182 | + f"scene/frame K=1 {scene_ms[1]:.2f} ms | K=3 {scene_ms[3]:.2f} ms | " |
| 183 | + f"propagate {best_fps:.2f} FPS ({per_frame_ms:.2f} ms/frame) | " |
| 184 | + f"num_objects={num_objects} {width}x{height} ===" |
| 185 | + ) |
| 186 | + return best_fps |
| 187 | + |
| 188 | + |
| 189 | +def main() -> None: |
| 190 | + username = getpass.getuser() |
| 191 | + os.environ["TORCHINDUCTOR_CACHE_DIR"] = f"/tmp/torchinductor_cache_{username}" |
| 192 | + os.environ["USE_PERFLIB"] = "1" |
| 193 | + |
| 194 | + parser = argparse.ArgumentParser( |
| 195 | + description="SAM3 production-model torch.compile A/B benchmark" |
| 196 | + ) |
| 197 | + parser.add_argument("--checkpoint", type=str, default="/tmp/sam3_v4.pt") |
| 198 | + parser.add_argument( |
| 199 | + "--video_dir", type=str, default="/tmp/sam3_bench_compile/synth_video" |
| 200 | + ) |
| 201 | + parser.add_argument("--num_objects", type=int, default=5) |
| 202 | + parser.add_argument("--n_frames", type=int, default=50) |
| 203 | + parser.add_argument("--radius", type=int, default=50) |
| 204 | + parser.add_argument("--speed", type=int, default=20) |
| 205 | + parser.add_argument("--width", type=int, default=1024) |
| 206 | + parser.add_argument("--height", type=int, default=1024) |
| 207 | + parser.add_argument( |
| 208 | + "--compile", |
| 209 | + action=argparse.BooleanOptionalAction, |
| 210 | + default=True, |
| 211 | + help="torch.compile the model; use --no-compile for the eager baseline", |
| 212 | + ) |
| 213 | + parser.add_argument( |
| 214 | + "--full-warmup", |
| 215 | + action="store_true", |
| 216 | + help="run prod warm_up_compilation (object-count sweep) instead of lazy compile", |
| 217 | + ) |
| 218 | + parser.add_argument( |
| 219 | + "--native-warmup", |
| 220 | + action="store_true", |
| 221 | + help="mirror prod Sam3DenseTrackingModel.warmup: _compile_model() direct + " |
| 222 | + "single-image warm only (no propagate); validates 0 serve-time recompiles", |
| 223 | + ) |
| 224 | + |
| 225 | + args = parser.parse_args() |
| 226 | + |
| 227 | + run( |
| 228 | + checkpoint_path=args.checkpoint, |
| 229 | + num_objects=args.num_objects, |
| 230 | + n_frames=args.n_frames, |
| 231 | + radius=args.radius, |
| 232 | + speed=args.speed, |
| 233 | + width=args.width, |
| 234 | + height=args.height, |
| 235 | + video_dir=args.video_dir, |
| 236 | + do_compile=args.compile, |
| 237 | + full_warmup=args.full_warmup, |
| 238 | + native_warmup=args.native_warmup, |
| 239 | + ) |
| 240 | + |
| 241 | + |
| 242 | +if __name__ == "__main__": |
| 243 | + main() |
0 commit comments