| """Profile /predict with cProfile (étape 4 — bottleneck identification). |
| |
| Runs N in-process /predict calls via FastAPI's TestClient and dumps a |
| ``.prof`` file consumable by snakeviz. By default DATABASE_URL is unset |
| so the profile shows ONLY what remains on the critical path after the |
| BackgroundTask migration (handler = assembly + inference + response). |
| |
| Usage: |
| uv run python scripts/profile_predict.py |
| uv run python scripts/profile_predict.py --n 200 --warmup 5 |
| uv run snakeviz profiling/profile_predict.prof # interactive view |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import cProfile |
| import logging |
| import os |
| import pstats |
| import random |
| import sys |
| from pathlib import Path |
|
|
| REPO_ROOT = Path(__file__).resolve().parents[1] |
| if str(REPO_ROOT) not in sys.path: |
| sys.path.insert(0, str(REPO_ROOT)) |
|
|
| logger = logging.getLogger("scripts.profile_predict") |
| logging.basicConfig(level=logging.INFO, format="%(message)s") |
|
|
|
|
| def main() -> int: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("--n", type=int, default=100, |
| help="number of /predict calls to profile (default: 100)") |
| parser.add_argument("--warmup", type=int, default=3, |
| help="warm-up calls executed BEFORE profiling starts (default: 3)") |
| parser.add_argument("--output", type=Path, |
| default=REPO_ROOT / "profiling" / "profile_predict.prof", |
| help="output .prof path (default: ./profiling/profile_predict.prof)") |
| parser.add_argument("--seed", type=int, default=42, |
| help="payload sampling seed (default: 42)") |
| parser.add_argument("--top", type=int, default=25, |
| help="number of rows to print from the cumulative-time table") |
| args = parser.parse_args() |
|
|
| |
| |
| |
| os.environ.pop("DATABASE_URL", None) |
|
|
| |
| |
| |
| from scripts.seed_traffic import APP_TRAIN_PATH, build_payloads |
| rng = random.Random(args.seed) |
| payloads = build_payloads( |
| APP_TRAIN_PATH, n_known=args.n + args.warmup, n_unknown=0, rng=rng |
| ) |
|
|
| |
| from fastapi.testclient import TestClient |
| from api.main import app |
|
|
| with TestClient(app) as client: |
| |
| |
| for payload in payloads[: args.warmup]: |
| resp = client.post("/predict", json=payload) |
| if resp.status_code != 200: |
| raise SystemExit( |
| f"Warm-up failed: status={resp.status_code} body={resp.text}" |
| ) |
| logger.info("Warm-up: %d calls OK", args.warmup) |
|
|
| |
| profiler = cProfile.Profile() |
| profiler.enable() |
| for payload in payloads[args.warmup:]: |
| resp = client.post("/predict", json=payload) |
| if resp.status_code != 200: |
| logger.warning( |
| "Unexpected status %s during profiling: %s", |
| resp.status_code, resp.text[:200], |
| ) |
| profiler.disable() |
|
|
| args.output.parent.mkdir(parents=True, exist_ok=True) |
| profiler.dump_stats(str(args.output)) |
| logger.info("Profile saved to %s", args.output) |
|
|
| |
| logger.info("\nTop %d functions by cumulative time:\n", args.top) |
| stats = pstats.Stats(profiler).sort_stats("cumulative") |
| stats.print_stats(args.top) |
|
|
| logger.info("\nVisualise interactively:\n uv run snakeviz %s\n", args.output) |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|