From 74ab46312b49ab3fc10394b0ee03afedb06d892e Mon Sep 17 00:00:00 2001 From: Ellis Hewes Date: Fri, 5 Jun 2026 11:28:16 +0100 Subject: [PATCH 1/6] Add docs/output and update README, pyproject Add a new docs/ folder with `docs/output.md` (full `ScanResult` JSON schema) and `docs/README.md` linking the docs. Update README.md to add PyPI/CI badges and a Documentation section pointing to the new output reference. Adjust pyproject.toml sdist build settings: replace `include` with `only-include` and update the listed files to ensure `src/pyframe`, `tests`, `README.md`, and `LICENSE` are packaged. --- README.md | 14 +++++ docs/README.md | 12 ++++ docs/output.md | 147 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 docs/README.md create mode 100644 docs/output.md diff --git a/README.md b/README.md index 1be55ba..8ec516a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,16 @@ NSFW moderation for GIFs, videos, and images using local [HuggingFace](https://h PyFrame uses **temporal segmentation** to avoid moderating every frame: it splits an animation into equal time buckets and extracts the most significant frame from each, capturing diverse scene coverage at a fraction of the cost. It also offers an optional **two-stage cascade** (`--prescreen`): a free local model soft-screens densely, and only the flagged time windows get escalated to the precise (e.g. AWS) backend. See the [pipeline diagram](#pipeline) for a visual of the approach. +
+ +[![PyPI version](https://img.shields.io/pypi/v/pyframe-gif-video-image-moderation)](https://pypi.org/project/pyframe-gif-video-image-moderation/) +[![PyPI Downloads](https://img.shields.io/pepy/dt/pyframe-gif-video-image-moderation?label=PyPI%20Downloads)](https://pepy.tech/project/pyframe-gif-video-image-moderation) +[![Python versions](https://img.shields.io/pypi/pyversions/pyframe-gif-video-image-moderation)](https://pypi.org/project/pyframe-gif-video-image-moderation/) +[![License: MIT](https://img.shields.io/badge/License-MIT-green)](https://github.com/ehewes/pyframe/blob/main/LICENSE) +[![CI](https://img.shields.io/github/actions/workflow/status/ehewes/pyframe/ci.yml?branch=main&label=CI)](https://github.com/ehewes/pyframe/actions/workflows/ci.yml) + +
+ ## Install ```bash @@ -113,6 +123,10 @@ A 150-frame GIF flows through temporal segmentation down to a handful of extract ![PyFrame pipeline: GIF frames to temporal buckets to extracted frames to merged grids to AWS Rekognition](https://raw.githubusercontent.com/ehewes/pyframe/main/media/HCBHD36W0AI3Hz4.jpeg) +## Documentation + +Full reference docs live in [`docs/`](https://github.com/ehewes/pyframe/tree/main/docs). Start with the [output reference](https://github.com/ehewes/pyframe/blob/main/docs/output.md) for the complete JSON / `ScanResult` schema. + ## Notes - The `aws` backend needs credentials: install with `pip install "pyframe-gif-video-image-moderation[aws]"`, then run `aws configure` (or set `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_DEFAULT_REGION`). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..a0a89e9 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,12 @@ +# PyFrame documentation + +Reference documentation for PyFrame. This folder grows over time; add new pages +here and link them from the list below. + +## Contents + +- [Output reference](output.md) - every field in the JSON / `ScanResult`, explained. + +## See also + +- Project [README](../README.md) - install, quickstart, CLI, and the pipeline diagram. diff --git a/docs/output.md b/docs/output.md new file mode 100644 index 0000000..cf05576 --- /dev/null +++ b/docs/output.md @@ -0,0 +1,147 @@ +# Output reference + +Every PyFrame scan returns a `ScanResult`. This page documents its full JSON +shape, field by field. + +## Getting the output + +CLI (machine-readable): + +```bash +pyframe clip.gif --backend local --json +``` + +Python: + +```python +from pyframe import Pipe + +result = Pipe("clip.gif", backend="local").run() + +result.to_json() # JSON string +result.to_dict() # plain dict +result.is_nsfw # bool -> the authoritative pass/fail +result.verdict # Severity -> prints as "clean" / "uncertain" / "nsfw" / "error" +result.max_score # float 0..1 +result.flagged_frames # list of per-frame results where is_nsfw is True +``` + +## Example + +A clean image scanned with the local backend: + +```json +{ + "source": "media/example.jpeg", + "media_kind": "image", + "verdict": "clean", + "is_nsfw": false, + "max_score": 0.0204, + "worst_frame": { + "score": 0.0204, + "is_nsfw": false, + "backend": "local", + "frame_index": 0, + "timestamp": 0.0, + "labels": [ + { "name": "sfw", "confidence": 0.9795508384704590 }, + { "name": "nsfw", "confidence": 0.0204491075128317 } + ] + }, + "frames": [ { "...": "same shape as worst_frame" } ], + "backends_used": ["local"], + "frames_total": 1, + "frames_screened": 0, + "frames_classified": 1, + "cost_usd": 0.0, + "prescreen_used": false, + "escalated": null, + "windows": 0, + "elapsed_s": 0.656 +} +``` + +## Top-level fields + +| Field | Type | Meaning | +|-------|------|---------| +| `source` | string | The input path exactly as passed in. | +| `media_kind` | string | `"image"` or `"animation"` (GIF/video). | +| `verdict` | string | Overall category: `clean`, `uncertain`, `nsfw`, or `error`. See [Verdict values](#verdict-values). | +| `is_nsfw` | bool | The authoritative pass/fail: `true` if any classified frame met the NSFW threshold. Branch on this. | +| `max_score` | float | Highest NSFW score (0..1) across the classified frames, rounded to 4 dp. | +| `worst_frame` | object \| null | The single highest-scoring frame (a [frame object](#frame-object)), or `null` if nothing was classified. | +| `frames` | array | The [frame objects](#frame-object) that were classified. In a short-circuited clean cascade these are the soft-screen frames. | +| `backends_used` | string[] | Which backends produced scores, e.g. `["local"]` or `["local", "aws"]` for a cascade. | +| `frames_total` | int | Total frames decoded from the media (1 for an image). | +| `frames_screened` | int | Frames the cheap soft-screen looked at. `0` in single-pass. | +| `frames_classified` | int | Frames/grids the precise backend classified. In a cascade this equals the number of precise (e.g. AWS) calls made. | +| `cost_usd` | float | Estimated total cost: precise calls plus screen calls, each times that backend's per-image price. `0.0` for local-only. | +| `prescreen_used` | bool | Whether the two-pass cascade was enabled. | +| `escalated` | bool \| null | Cascade only: `true` if it escalated to the precise backend, `false` if it short-circuited clean. `null` for single-pass and images. | +| `windows` | int | Cascade only: how many distinct flagged time-regions the soft-screen found. `0` otherwise. Informational; it does not drive cost. | +| `elapsed_s` | float \| null | Wall-clock seconds for the scan. | + +## Frame object + +Each entry in `frames` (and `worst_frame`) is one classified frame: + +| Field | Type | Meaning | +|-------|------|---------| +| `score` | float | NSFW score 0..1 for this frame, rounded to 4 dp. | +| `is_nsfw` | bool | `true` if `score` met the active threshold (`min_confidence`). | +| `backend` | string | Which backend scored it: `"local"` or `"aws"`. | +| `frame_index` | int | Index of the frame in the decoded sequence. `-1` or a batch index for a merged grid. | +| `timestamp` | float | Seconds into the clip, rounded to 3 dp. `0.0` for images. | +| `labels` | array | Raw [labels](#label-object) the backend returned. | +| `error` | string | Present only if the backend errored on this frame (then `score` is `0` and `is_nsfw` is `false`). | + +## Label object + +| Field | Type | Meaning | +|-------|------|---------| +| `name` | string | Backend label. Local models use their own set (e.g. `sfw`/`nsfw`); AWS uses moderation names (e.g. `Explicit Nudity`). | +| `confidence` | float | The backend's confidence for that label, 0..1 (full precision). | +| `taxonomy` | string | Optional parent category. AWS only (its `ParentName`); absent for local backends. | + +## Verdict values + +`verdict` is derived from `max_score` and the thresholds. `is_nsfw` is the +boolean version; `verdict` adds an "uncertain" band: + +| Value | Condition | +|-------|-----------| +| `nsfw` | `max_score >= min_confidence` (threshold default: 0.5 local, 0.8 aws) | +| `uncertain` | `uncertain_threshold <= max_score < min_confidence` (default `uncertain_threshold` 0.3) | +| `clean` | `max_score < uncertain_threshold` | +| `error` | every classified frame failed to score | + +## Single-pass vs cascade + +The same schema is returned either way; these fields change with the mode: + +| Field | Single-pass (default) | Cascade (`prescreen=True`) | +|-------|-----------------------|----------------------------| +| `prescreen_used` | `false` | `true` | +| `frames_screened` | `0` | number of soft-screen frames | +| `escalated` | `null` | `true` (hit precise backend) or `false` (short-circuited clean) | +| `windows` | `0` | number of flagged regions found | +| `frames_classified` | up to `max_frames` | up to `max_escalations` (merged grids) | +| `backends_used` | the one backend | `["local"]` if clean, `["local", "aws"]` if escalated | +| `cost_usd` | per-frame precise cost | `0` if short-circuited, else up to `max_escalations` precise calls | + +## CLI exit codes + +When run as `pyframe ... ` (without `--json` you still get these), the process +exit code encodes the outcome so it slots into shell gates: + +| Code | Meaning | +|------|---------| +| `0` | clean | +| `1` | NSFW (subject to `--fail-on`) | +| `2` | bad input (unsupported type, decode error, missing file) | +| `3` | backend not installed (missing optional extra) | + +```bash +pyframe upload.gif --backend local || echo "rejected" +``` diff --git a/pyproject.toml b/pyproject.toml index 89bf1a9..d54ff05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ Issues = "https://github.com/ehewes/pyframe/issues" packages = ["src/pyframe"] [tool.hatch.build.targets.sdist] -include = ["src/pyframe", "README.md", "LICENSE", "tests"] +only-include = ["src/pyframe", "tests", "README.md", "LICENSE"] [tool.pytest.ini_options] testpaths = ["tests"] From 9c62ca285704e9c81310fd05e16b198ca8b79438 Mon Sep 17 00:00:00 2001 From: Ellis Hewes Date: Fri, 5 Jun 2026 22:07:04 +0100 Subject: [PATCH 2/6] Add scan_bytes (in-memory GIF scanning) Introduce an in-memory GIF/image scanning path: add iter_frames_from_bytes and scan_bytes so GIFs can be decoded and scanned without touching disk. Expose Scanner.scan_bytes and Pipe.scan_bytes, update package exports and README with usage example, and add a unit test for in-memory decoding. Remove the save_frames option and its related CLI/config/Scanner side-effect (frame-saving was removed). Also add two benchmarking scripts (bench_decode.py, bench_gifs.py) and ignore bench_results.jsonl in .gitignore. --- .gitignore | 3 +++ README.md | 9 ++++++- scripts/bench_decode.py | 55 +++++++++++++++++++++++++++++++++++++++++ src/pyframe/__init__.py | 6 +++-- src/pyframe/cli.py | 2 -- src/pyframe/config.py | 1 - src/pyframe/media.py | 34 +++++++++++++++++++++++++ src/pyframe/pipe.py | 8 ++++-- src/pyframe/scanner.py | 27 ++++++++------------ tests/test_scanner.py | 23 +++++++++++++++++ 10 files changed, 143 insertions(+), 25 deletions(-) create mode 100644 scripts/bench_decode.py diff --git a/.gitignore b/.gitignore index c0c590e..499bf5d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ build/ *.egg-info/ .pytest_cache/ .ruff_cache/ + +# benchmark output +bench_results.jsonl diff --git a/README.md b/README.md index 8ec516a..c5f9327 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,14 @@ Pipe("clip.gif", backend="aws").run() # AWS Rekognition Pipe("clip.gif", backend="aws", prescreen=True).run() # local screens, AWS confirms ``` +Scan raw bytes (e.g. a download) with **no disk touched** at all: + +```python +from pyframe import scan_bytes + +result = scan_bytes(gif_bytes, backend="local") # GIF/image decoded in memory +``` + ### Tuning the two-pass Every knob is a `Pipe` param with a sensible default: @@ -97,7 +105,6 @@ Exit code: `0` clean, `1` NSFW (per `--fail-on`), `2` bad input, `3` backend not | `--max-escalations` | `2` | hard cap on precise (AWS) calls per file | | `--screen-fps` | `2.0` | soft-screen sample rate | | `--use-merged` / `--frames-per-batch` | off / `2` | merge frames into a grid before classifying | -| `--save-frames DIR` | off | write the classified frames to `DIR` | | `--json` / `--fail-on` | off / `nsfw` | output format / exit-code policy | ## How it works diff --git a/scripts/bench_decode.py b/scripts/bench_decode.py new file mode 100644 index 0000000..4de1ca7 --- /dev/null +++ b/scripts/bench_decode.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Micro-benchmark: cv2 file-path decode vs in-memory Pillow decode (the scan_bytes path). + +Shows the per-GIF decode cost of each, so you can see the millisecond delta against +the ~789 ms ViT inference that dominates total time. + + python scripts/bench_decode.py [path/to.gif] # synthesizes one if omitted +""" + +import os +import statistics +import sys +import tempfile +import time + +import numpy as np +from PIL import Image + +from pyframe.media import iter_frames, iter_frames_from_bytes + + +def synth(path, n=60, w=320): + h = int(w * 0.6) + frames = [] + for i in range(n): + a = np.random.randint(0, 255, (h, w, 3), np.uint8) + x = int(i / n * (w - 24)) + a[h // 2 - 8 : h // 2 + 8, x : x + 24] = 255 + frames.append(Image.fromarray(a)) + frames[0].save(path, save_all=True, append_images=frames[1:], duration=66, loop=0) + + +if __name__ == "__main__": + path = sys.argv[1] if len(sys.argv) > 1 else None + if not path: + path = os.path.join(tempfile.mkdtemp(), "decode_bench.gif") + synth(path) + data = open(path, "rb").read() + + N = 25 + cv2_ms, mem_ms = [], [] + for _ in range(N): + t = time.perf_counter() + n1 = len(list(iter_frames(path))) + cv2_ms.append((time.perf_counter() - t) * 1000) + t = time.perf_counter() + n2 = len(list(iter_frames_from_bytes(data))) + mem_ms.append((time.perf_counter() - t) * 1000) + + c, m = statistics.median(cv2_ms), statistics.median(mem_ms) + print(f"GIF: {n1} frames (cv2) / {n2} frames (mem), {len(data) / 1e6:.2f} MB, {N} runs") + print(f" cv2 file-path decode: median {c:6.1f} ms") + print(f" in-memory bytes decode: median {m:6.1f} ms") + delta = m - c + print(f" delta: {delta:+.1f} ms ({delta / c * 100:+.0f}%) -- vs ~789 ms ViT inference, this is noise") diff --git a/src/pyframe/__init__.py b/src/pyframe/__init__.py index a582a42..6befd20 100644 --- a/src/pyframe/__init__.py +++ b/src/pyframe/__init__.py @@ -9,8 +9,8 @@ UnsupportedMediaError, ) from .image_utils import merge_images_to_grid, merge_to_grid -from .media import Frame, MediaKind, iter_frames, media_kind -from .pipe import Pipe, scan +from .media import Frame, MediaKind, iter_frames, iter_frames_from_bytes, media_kind +from .pipe import Pipe, scan, scan_bytes from .results import Label, ScanResult, Severity, Verdict from .scanner import Scanner @@ -27,6 +27,7 @@ __all__ = [ "Pipe", "scan", + "scan_bytes", "Scanner", "Config", "PrescreenConfig", @@ -39,6 +40,7 @@ "Frame", "MediaKind", "iter_frames", + "iter_frames_from_bytes", "media_kind", "merge_to_grid", "merge_images_to_grid", diff --git a/src/pyframe/cli.py b/src/pyframe/cli.py index 57f868f..162a9a3 100644 --- a/src/pyframe/cli.py +++ b/src/pyframe/cli.py @@ -55,7 +55,6 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--escalate-threshold", type=float, default=0.15, help="cascade gate (low = recall-safe)") parser.add_argument("--max-escalations", type=int, default=2, help="max precise (AWS) calls per file") parser.add_argument("--screen-fps", type=float, default=2.0, help="soft-screen sample rate") - parser.add_argument("--save-frames", default=None, metavar="DIR", help="write the classified frames to DIR") parser.add_argument("--json", action="store_true", help="machine-readable output") parser.add_argument("--fail-on", choices=("nsfw", "uncertain", "never"), default="nsfw") return parser @@ -82,7 +81,6 @@ def main() -> int: escalate_threshold=args.escalate_threshold, max_escalations=args.max_escalations, screen_fps=args.screen_fps, - save_frames=args.save_frames, ) except BackendUnavailableError as exc: print(exc, file=sys.stderr) diff --git a/src/pyframe/config.py b/src/pyframe/config.py index 98dde0c..ae61de0 100644 --- a/src/pyframe/config.py +++ b/src/pyframe/config.py @@ -27,5 +27,4 @@ class Config: frames_per_batch: int = 2 screen_backend: object = "local" screen_model: str | None = None - save_frames: str | None = None prescreen: PrescreenConfig = field(default_factory=PrescreenConfig) diff --git a/src/pyframe/media.py b/src/pyframe/media.py index 6cf4336..16d848e 100644 --- a/src/pyframe/media.py +++ b/src/pyframe/media.py @@ -98,3 +98,37 @@ def iter_frames(source: str | os.PathLike) -> Iterator[Frame]: index += 1 finally: cap.release() + + +def iter_frames_from_bytes(data: bytes) -> Iterator[Frame]: + """Decode a GIF / static image from memory (no disk). Pillow only; for video + bytes use the path-based API. Motion + timestamps match iter_frames.""" + import io + + from PIL import Image + + try: + img = Image.open(io.BytesIO(data)) + n_frames = getattr(img, "n_frames", 1) + except Exception as exc: + raise MediaDecodeError( + f"could not decode bytes in memory: {exc} " + "(video bytes are not supported by scan_bytes; use the path-based API)" + ) from exc + + if n_frames <= 1: + rgb = np.asarray(img.convert("RGB")) + yield Frame(index=0, timestamp=0.0, image=cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)) + return + + prev_gray = None + timestamp = 0.0 + for index in range(n_frames): + img.seek(index) + rgb = np.asarray(img.convert("RGB")) + frame = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) + gray = cv2.cvtColor(cv2.resize(frame, (64, 64)), cv2.COLOR_BGR2GRAY) + motion = 0.0 if prev_gray is None else float(np.sum(cv2.absdiff(gray, prev_gray))) + prev_gray = gray + yield Frame(index=index, timestamp=timestamp, image=frame, motion_score=motion) + timestamp += (img.info.get("duration") or 100) / 1000.0 # per-frame GIF duration (ms) diff --git a/src/pyframe/pipe.py b/src/pyframe/pipe.py index 269a993..524fb00 100644 --- a/src/pyframe/pipe.py +++ b/src/pyframe/pipe.py @@ -28,7 +28,6 @@ def __init__( window_pad=4, max_escalations=2, fail_open=True, - save_frames=None, ): self.input_path = input_path self.config = Config( @@ -43,7 +42,6 @@ def __init__( frames_per_batch=frames_per_batch, screen_backend=screen_backend, screen_model=screen_model, - save_frames=save_frames, prescreen=PrescreenConfig( enabled=prescreen, screen_fps=screen_fps, @@ -61,3 +59,9 @@ def run(self) -> ScanResult: def scan(source, **kwargs) -> ScanResult: return Pipe(source, **kwargs).run() + + +def scan_bytes(data, *, label="", **kwargs) -> ScanResult: + """Scan a GIF/image from raw bytes (e.g. a download) without touching disk.""" + config = Pipe(label, **kwargs).config # reuse Pipe's config building; path unused + return Scanner.from_config(config).scan_bytes(data, label=label) diff --git a/src/pyframe/scanner.py b/src/pyframe/scanner.py index 66d8cad..c0f6a96 100644 --- a/src/pyframe/scanner.py +++ b/src/pyframe/scanner.py @@ -1,14 +1,11 @@ from __future__ import annotations -import os import time -import cv2 - from .backends import Backend, load_backend from .config import Config from .image_utils import merge_to_grid -from .media import MediaKind, iter_frames, media_kind +from .media import MediaKind, iter_frames, iter_frames_from_bytes, media_kind from .results import ScanResult, Severity, Verdict from .sampling import ( DenseUniformSampler, @@ -41,10 +38,18 @@ def scan(self, source) -> ScanResult: start = time.perf_counter() kind = media_kind(source) frames = list(iter_frames(source)) + return self._scan_frames(str(source), kind, frames, start) + + def scan_bytes(self, data, *, label: str = "") -> ScanResult: + """Scan a GIF/image decoded from memory, no disk touched.""" + start = time.perf_counter() + frames = list(iter_frames_from_bytes(data)) + kind = MediaKind.ANIMATION if len(frames) > 1 else MediaKind.IMAGE + return self._scan_frames(label, kind, frames, start) + def _scan_frames(self, source, kind, frames, start) -> ScanResult: if kind is MediaKind.IMAGE: verdicts = self.precise.classify_batch(frames, min_confidence=self.min_confidence) - self._save(verdicts, frames, source) return self._aggregate(source, kind, verdicts, [], len(frames), start) if not frames: @@ -67,7 +72,6 @@ def _single_pass(self, source, kind, frames, start) -> ScanResult: verdicts = self._classify_merged(selected) else: verdicts = self.precise.classify_batch(selected, min_confidence=self.min_confidence) - self._save(verdicts, selected, source) return self._aggregate(source, kind, verdicts, [], len(frames), start) def _cascade(self, source, kind, frames, start) -> ScanResult: @@ -100,7 +104,6 @@ def _cascade(self, source, kind, frames, start) -> ScanResult: # Send the top suspicious frames to the precise backend as merged grids. precise = self._classify_merged(selected) - self._save(precise, selected, source) windows = group_flagged_into_windows(flagged, len(frames), pc.group_gap, pc.window_pad) return self._aggregate( @@ -139,16 +142,6 @@ def _classify_merged(self, frames) -> list[Verdict]: ) return verdicts - def _save(self, verdicts, frames, source) -> None: - out_dir = self.config.save_frames - if not out_dir: - return - os.makedirs(out_dir, exist_ok=True) - stem = os.path.splitext(os.path.basename(str(source)))[0] - for rank, frame in enumerate(frames): - name = f"{stem}_{rank:02d}_frame{frame.index:04d}.jpg" - cv2.imwrite(os.path.join(out_dir, name), frame.image) - def _aggregate( self, source, kind, classified, screen_verdicts, frames_total, start, *, escalated=None, windows=0, diff --git a/tests/test_scanner.py b/tests/test_scanner.py index a0ba04e..98a629e 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -81,6 +81,29 @@ def test_cascade_pads_to_full_grid_when_one_frame_flagged(): assert result.is_nsfw +def test_scan_bytes_decodes_in_memory(): + import io + + from PIL import Image + + from pyframe.media import iter_frames_from_bytes + + # distinct fills so PIL's GIF optimizer doesn't collapse identical frames + pil = [Image.fromarray(np.full((16, 16, 3), v, np.uint8)) for v in (10, 60, 250, 120, 30)] + buf = io.BytesIO() + pil[0].save(buf, format="GIF", save_all=True, append_images=pil[1:], duration=80, loop=0) + data = buf.getvalue() + + decoded = list(iter_frames_from_bytes(data)) + assert len(decoded) == 5 # decoded from memory, no disk + assert any(f.motion_score > 0 for f in decoded) + + scanner = _scanner(FakeBackend("aws", 0.001), screen=FakeBackend("local"), enabled=True) + result = scanner.scan_bytes(data, label="x.gif") + assert result.media_kind == "animation" + assert result.frames_total == 5 + + def test_cascade_fail_open_escalates_on_error(): class BrokenScreen(Backend): name = "local" From 3cd149edd0b2c9804318dc7bac5769605705020e Mon Sep 17 00:00:00 2001 From: Ellis Hewes Date: Fri, 5 Jun 2026 22:22:08 +0100 Subject: [PATCH 3/6] Add performance docs, benchmark & plotting scripts Add a new performance guide and tooling: create docs/performance.md and link it from docs/README.md; add media/perf_stages.png and media/perf_latency.png. Introduce scripts/bench_gifs.py (capacity-planning benchmark that runs the real GIF moderation path, synthesizes a corpus if needed, stubs AWS precise calls and emits bench_results.jsonl) and scripts/plot_results.py (generates the two performance charts from the JSONL). This change documents per-stage timing, throughput/latency results, and provides reproducible benchmark + plotting utilities. --- docs/README.md | 1 + docs/performance.md | 81 +++++ media/perf_latency.png | Bin 0 -> 23947 bytes media/perf_stages.png | Bin 0 -> 26383 bytes scripts/bench_gifs.py | 696 ++++++++++++++++++++++++++++++++++++++++ scripts/plot_results.py | 66 ++++ 6 files changed, 844 insertions(+) create mode 100644 docs/performance.md create mode 100644 media/perf_latency.png create mode 100644 media/perf_stages.png create mode 100644 scripts/bench_gifs.py create mode 100644 scripts/plot_results.py diff --git a/docs/README.md b/docs/README.md index a0a89e9..ed75fd9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ here and link them from the list below. ## Contents - [Output reference](output.md) - every field in the JSON / `ScanResult`, explained. +- [Performance](performance.md) - measured throughput, per-stage timing, capacity sizing, and a results log. ## See also diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..416fd92 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,81 @@ +# Performance + +How PyFrame's GIF moderation path (decode -> motion-sample -> local ViT prescreen -> +gate) performs, and how to reproduce it. + +Absolute numbers are **illustrative** (single CPU core, single-threaded) and scale with +hardware and model. The library-relevant takeaway is the *shape*, which is +hardware-independent: the pipeline is **inference-bound**. + +Pinned config for every number below: `prescreen` on, `screen_fps=2.0`, +`escalate_threshold=0.15`, `frames_per_batch=2`, model `AdamCodd/vit-base-nsfw-detector`, +torch CPU single-threaded, AWS stubbed (so this is local throughput only). + +## Per-stage timing (the defining characteristic) + +![PyFrame per-stage timing](https://raw.githubusercontent.com/ehewes/pyframe/main/media/perf_stages.png) + +| Stage | Share | Illustrative | +|-------|-------|--------------| +| decode + sample | ~9% | 14 ms | +| preprocess (to PIL) | ~0.1% | 0.2 ms | +| **inference (ViT)** | **~91%** | 239 ms | +| gate | ~0% | <0.01 ms | + +The ViT forward pass is essentially the entire cost (`f ~ 0.91`). Decode, sampling, and +the gate are negligible, so the only things that move throughput are the **model** +(smaller / quantized) and the **backend** (CPU vs GPU). The proportions hold across +hardware; only the absolute milliseconds change. + +## Throughput & latency (single worker, one core) + +![PyFrame latency percentiles](https://raw.githubusercontent.com/ehewes/pyframe/main/media/perf_latency.png) + +- **~3 GIFs/s per core** (~11k GIFs/hr), single-threaded. +- per-GIF latency: p50 ~250 ms, p95 ~1.1 s. The spread tracks GIF size (more frames = + more ViT calls), not load. + +Per-core throughput is the unit that transfers between machines, not a box total. + +## Memory + +~0.5 GB resident per worker (model weights + buffers). Memory is not the bottleneck for +this path. + +## Decode: file path vs in-memory (`scan_bytes`) + +60-frame 320px GIF, 25 runs: + +| Decoder | Median | +|---------|--------| +| cv2 file path (`iter_frames`) | 39 ms | +| in-memory bytes (`scan_bytes`) | 31 ms | + +In-memory is ~21% faster (skips cv2/ffmpeg per-open overhead) and never touches disk. +Against ~239 ms of inference, the difference is noise. + +## Notes + +- **Inference-bound (`f ~ 0.91`):** the model and the backend are the only real levers; + decode and sampling are already negligible. +- **Concurrency scaling is hardware-dependent.** Many single-threaded workers contend on + memory bandwidth, so per-worker throughput can drop under load. Measure on your own + target rather than multiplying the single-core number blindly. + +## Reproduce + +```bash +pip install psutil matplotlib # bench/plot tools, not runtime deps +python scripts/bench_gifs.py --procs "1" --duration 30 # single-worker profile -> bench_results.jsonl +python scripts/bench_decode.py # decode comparison +python scripts/plot_results.py bench_results.jsonl # regenerate the charts in media/ +``` + +## Results log + +Append a row when you measure on new hardware. + +| Run | Backend / threads | GIFs/s per core | f (inference share) | RSS/worker | +|-----|-------------------|-----------------|---------------------|------------| +| reference | torch CPU, 1 thread | ~3.1 | ~0.91 | ~0.5 GB | +| | | | | | diff --git a/media/perf_latency.png b/media/perf_latency.png new file mode 100644 index 0000000000000000000000000000000000000000..5f81bbd05f6368234412dab18d2b09186fb829f8 GIT binary patch literal 23947 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 dyk&vb=cR)C<|ZWqg;L_&AfPnNPy*83DGkzf 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 AqXQU=`C|4>Q%JO4sYJ(Ul|Cf7+Swjkv2 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 + pyframe.backends.LocalBackend.classify_image (local ViT inference, per frame) + gate (score >= escalate_threshold, fail-open) (escalation decision) + on escalation: pyframe.sampling.SuspicionSampler.select + image_utils.merge_to_grid + -> StubBackend (AWS/Rekognition MOCKED: instant, counted only) +The motion sampler (MotionBucketSampler, max_frames) governs single-pass / escalation +frame budget; the cascade prescreen samples densely at screen_fps, which is the real +behaviour with prescreen.enabled=True. + +AWS is stubbed: the precise backend returns instantly (no network); we still run the +gate and count/log every escalation. We measure LOCAL throughput only. + +Concurrency = a multiprocessing (spawn) process pool, matching the production worker +mechanism (decode is CPU-bound; threads lose to the GIL). Each worker forces +single-threaded inference (OMP/MKL/OpenBLAS/torch/onnx = 1) so N processes don't each +spawn multi-threaded torch and oversubscribe cores. + +Outputs: + 1. Per-process-count scaling curve + sweet spot, knee, bottleneck class. + 2. Inference fraction f, per-stage medians, CPU-vs-GPU verdict (Amdahl bound). + 3. Provider projection table (Hetzner CCX + machine0; native + USD; $/1k; RAM flag). + 4. Peak-load sizing block (baseline pick, machine0 failover, CPU-vs-GPU). + 5. bench_results.jsonl: one record per GIF (seed schema for the eval/RL store). + 6. Environment block (host, versions, pinned config, fx, target-util). + +Run: + python scripts/bench_gifs.py --corpus ./gifs --peak-gifs-per-sec 10 + +Flags: see --help. +""" + +# ruff: noqa: E402 (thread caps are deliberately set before heavy imports below) +# Thread caps MUST be set before numpy/torch/cv2 import (they read these at import). +import os + +for _v in ( + "OMP_NUM_THREADS", + "MKL_NUM_THREADS", + "OPENBLAS_NUM_THREADS", + "NUMEXPR_NUM_THREADS", + "VECLIB_MAXIMUM_THREADS", # macOS Accelerate + "ONNXRUNTIME_INTRA_OP_NUM_THREADS", + "TOKENIZERS_PARALLELISM", +): + os.environ.setdefault(_v, "1" if _v != "TOKENIZERS_PARALLELISM" else "false") +os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error") +os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1") + +import argparse +import json +import math +import multiprocessing as mp +import platform +import statistics +import subprocess +import sys +import tempfile +import threading +import time +from itertools import islice +from pathlib import Path + +import numpy as np +import psutil + +from pyframe.backends import load_backend +from pyframe.backends.base import Backend +from pyframe.image_utils import merge_to_grid +from pyframe.media import iter_frames +from pyframe.sampling import DenseUniformSampler, SuspicionSampler + +# --------------------------------------------------------------------------- # +# Provider price lists (instance, vCPU, RAM_GB, price/hr in native currency) +# --------------------------------------------------------------------------- # +HETZNER_CCX = [ # EUR/hr excl VAT, dedicated vCPU + ("CCX13", 2, 8, 0.0264), + ("CCX23", 4, 16, 0.0513), + ("CCX33", 8, 32, 0.1009), + ("CCX43", 16, 64, 0.2011), + ("CCX53", 32, 128, 0.4014), + ("CCX63", 48, 192, 0.6009), +] +MACHINE0 = [ # USD/hr + ("small", 1, 1, 0.013), + ("medium", 2, 2, 0.034), + ("large", 2, 4, 0.052), + ("xl", 4, 8, 0.104), + ("xxl", 8, 16, 0.208), + ("xxxl", 16, 64, 0.825), + ("4xl", 32, 128, 1.980), +] +MACHINE0_GPU = ("gpu-4000ada-1", 0.836) # USD/hr; vCPU count not published +HOURS_PER_MONTH = 730.0 + +# Worker-process globals (populated by worker_init under spawn). +_SCREEN = None +_STUB = None +_CFG = None + + +class StubBackend(Backend): + """Mocked precise/Rekognition backend: returns instantly, no network.""" + + name = "aws-stub" + cost_per_image = 0.001 + + def _score(self, image): + return 0.0, [], None + + +# --------------------------------------------------------------------------- # +# Corpus +# --------------------------------------------------------------------------- # +def _synth_one(path, n_frames, width): + from PIL import Image + + height = max(8, int(round(width * 0.6))) + grad = np.linspace(0, 255, width, dtype=np.uint8)[None, :, None] + frames = [] + for i in range(n_frames): + arr = np.zeros((height, width, 3), np.uint8) + arr[:, :, 0:1] = grad # horizontal gradient (R channel) + x = int((i / max(1, n_frames - 1)) * (width - 24)) + arr[height // 2 - 8 : height // 2 + 8, x : x + 24, :] = 255 # moving block -> motion + noise = np.random.randint(-12, 12, (height, width, 3), dtype=np.int16) + arr = np.clip(arr.astype(np.int16) + noise, 0, 255).astype(np.uint8) + frames.append(Image.fromarray(arr)) + frames[0].save(path, save_all=True, append_images=frames[1:], duration=66, loop=0, optimize=False) + + +def synthesize_corpus(out_dir, count, rng): + # (weight, frame-range, width-range) + buckets = [ + (0.60, (10, 40), (128, 320)), + (0.30, (40, 90), (320, 480)), + (0.10, (90, 150), (480, 640)), + ] + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + paths = [] + for i in range(count): + r = rng.random() + cum = 0.0 + chosen = buckets[-1] + for b in buckets: + cum += b[0] + if r <= cum: + chosen = b + break + nf = rng.randint(*chosen[1]) + w = rng.randint(*chosen[2]) + p = out_dir / f"synth_{i:04d}_{nf}f_{w}px.gif" + _synth_one(str(p), nf, w) + paths.append(str(p)) + return paths + + +def describe_corpus(paths): + sizes, frame_counts, widths = [], [], [] + for p in paths: + sizes.append(os.path.getsize(p) / 1e6) + try: + from PIL import Image + + with Image.open(p) as im: + widths.append(im.size[0]) + frame_counts.append(getattr(im, "n_frames", 1)) + except Exception: + pass + + def pct(a, q): + return float(np.percentile(a, q)) if a else 0.0 + + print("\n=== CORPUS ===") + print(f" files: {len(paths)} mean file size: {statistics.mean(sizes):.2f} MB" if sizes else " (empty)") + if frame_counts: + print( + f" frames/GIF p50={pct(frame_counts,50):.0f} p90={pct(frame_counts,90):.0f} " + f"min={min(frame_counts)} max={max(frame_counts)}" + ) + if widths: + print( + f" width px p50={pct(widths,50):.0f} p90={pct(widths,90):.0f} " + f"min={min(widths)} max={max(widths)}" + ) + + +# --------------------------------------------------------------------------- # +# Worker +# --------------------------------------------------------------------------- # +def worker_init(cfg): + global _SCREEN, _STUB, _CFG + _CFG = cfg + try: + import torch + + torch.set_num_threads(1) + torch.set_num_interop_threads(1) + except Exception: + pass + try: + import onnxruntime as ort + + _ = ort # intra-op threads pinned via env ONNXRUNTIME_INTRA_OP_NUM_THREADS=1 + except Exception: + pass + _SCREEN = load_backend("local", model=cfg["model"]) + _STUB = StubBackend() + + +def process_one(path): + """Run the real cascade prescreen path on one GIF with per-stage timing.""" + proc = psutil.Process() + esc = _CFG["escalate_threshold"] + t0 = time.perf_counter() + frames = list(iter_frames(path)) + n_total = len(frames) + screen_frames = DenseUniformSampler(_CFG["screen_fps"]).select(frames) + t1 = time.perf_counter() + + pils = [f.to_pil() for f in screen_frames] + t2 = time.perf_counter() + + verdicts = [ + _SCREEN.classify_image(p, min_confidence=esc, index=f.index, timestamp=f.timestamp) + for f, p in zip(screen_frames, pils) + ] + t3 = time.perf_counter() + + scores = {v.frame_index: v.score for v in verdicts} + flagged = [v.frame_index for v in verdicts if v.score >= esc or (v.error and True)] + max_local = max((v.score for v in verdicts), default=0.0) + escalated = bool(flagged) + if escalated: + # Mirror Scanner._cascade escalation: top-suspicious -> merged grids -> precise. + per_batch = max(1, _CFG["frames_per_batch"]) + budget = _CFG["max_escalations"] * per_batch + fset = set(flagged) + flagged_frames = [f for f in frames if f.index in fset] + selected = SuspicionSampler().select(flagged_frames, budget, scores) + for i in range(0, len(selected), per_batch): + grid = merge_to_grid([fr.to_pil() for fr in selected[i : i + per_batch]]) + _STUB.classify_image(grid, min_confidence=0.8) # AWS mocked: instant, counted + t4 = time.perf_counter() + + return { + "gif_id": os.path.basename(path), + "n_frames_total": n_total, + "n_frames_scored": len(screen_frames), + "t_decode_sample": t1 - t0, + "t_preprocess": t2 - t1, + "t_inference": t3 - t2, + "t_gate": t4 - t3, + "latency_ms": (t4 - t0) * 1000.0, + "peak_rss_mb": round(proc.memory_info().rss / 1e6, 1), + "max_local_score": round(float(max_local), 4), + "escalated": escalated, + } + + +# --------------------------------------------------------------------------- # +# Resource monitor (samples worker PIDs during a level) +# --------------------------------------------------------------------------- # +class ResourceMonitor(threading.Thread): + def __init__(self, pids, interval=0.5): + super().__init__(daemon=True) + self.interval = interval + self.procs = [] + for pid in pids: + try: + self.procs.append(psutil.Process(pid)) + except psutil.NoSuchProcess: + pass + self._stop = threading.Event() + self.peak_rss = 0 + self.cpu_samples = [] + + def run(self): + psutil.cpu_percent(None) # prime system-wide + while not self._stop.wait(self.interval): + self.cpu_samples.append(psutil.cpu_percent(None)) + total = 0 + for pr in self.procs: + try: + total += pr.memory_info().rss + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + self.peak_rss = max(self.peak_rss, total) + + def stop(self): + self._stop.set() + self.join(timeout=3) + + @property + def mean_cpu(self): + return statistics.mean(self.cpu_samples) if self.cpu_samples else 0.0 + + +def _path_stream(corpus): + while True: + for p in corpus: + yield p + + +# --------------------------------------------------------------------------- # +# Run one concurrency level +# --------------------------------------------------------------------------- # +def run_level(P, corpus, cfg, duration, min_gifs, warmup): + ctx = mp.get_context("spawn") + pool = ctx.Pool(P, initializer=worker_init, initargs=(cfg,)) + try: + pids = [w.pid for w in getattr(pool, "_pool", [])] + # Warm-up: model load happened in init; exercise JIT/caches and discard. + for _ in pool.imap_unordered(process_one, list(islice(_path_stream(corpus), warmup))): + pass + + mon = ResourceMonitor(pids) + mon.start() + records = [] + t_start = time.perf_counter() + for rec in pool.imap_unordered(process_one, _path_stream(corpus)): + records.append(rec) + elapsed = time.perf_counter() - t_start + if elapsed >= duration and len(records) >= min_gifs: + break + wall = time.perf_counter() - t_start + mon.stop() + finally: + pool.terminate() + pool.join() + + lat = np.array([r["latency_ms"] for r in records], dtype=float) + frames_scored = sum(r["n_frames_scored"] for r in records) + gifs = len(records) + result = { + "P": P, + "wall_s": wall, + "gifs": gifs, + "gifs_per_sec": gifs / wall, + "gifs_per_hr": gifs / wall * 3600.0, + "frames_scored_per_sec": frames_scored / wall, + "lat_p50_ms": float(np.percentile(lat, 50)), + "lat_p95_ms": float(np.percentile(lat, 95)), + "lat_p99_ms": float(np.percentile(lat, 99)), + "mean_cpu_pct": mon.mean_cpu, + "peak_rss_mb": mon.peak_rss / 1e6, + "escalation_rate": float(np.mean([r["escalated"] for r in records])) if records else 0.0, + } + return result, records + + +# --------------------------------------------------------------------------- # +# Analysis +# --------------------------------------------------------------------------- # +def make_sweep(vcpu, max_procs, explicit): + if explicit: + seq = sorted({int(x) for x in explicit.split(",") if x.strip()}) + return [p for p in seq if p >= 1] + cap = max_procs or 2 * vcpu + base = [1, 2, 4, 8, 12, 16, 24, 32, 48, 64] + seq = sorted({p for p in base + [vcpu, 2 * vcpu] if 1 <= p <= cap}) + return seq + + +def analyse(levels, vcpu, ram_gb): + best = max(levels, key=lambda r: r["gifs_per_hr"]) + best_hr = best["gifs_per_hr"] + # knee = smallest P reaching >=90% of peak throughput + knee = min((r for r in levels if r["gifs_per_hr"] >= 0.9 * best_hr), key=lambda r: r["P"]) + # bottleneck classification + peak_rss_gb = best["peak_rss_mb"] / 1024.0 + if peak_rss_gb >= 0.85 * ram_gb: + bottleneck = "RAM-capacity-bound (aggregate RSS approaches host RAM before the knee)" + elif knee["P"] >= 0.9 * vcpu: + bottleneck = f"CPU-bound (knee P={knee['P']} ~ vCPU={vcpu})" + elif best["mean_cpu_pct"] < 85.0: + bottleneck = ( + f"memory-bandwidth-bound (knee P={knee['P']} < vCPU={vcpu}, " + f"CPU only {best['mean_cpu_pct']:.0f}% at sweet spot)" + ) + else: + bottleneck = f"CPU-bound (knee P={knee['P']})" + return best, knee, bottleneck + + +def project(instances, currency, per_vcpu_hr, rss_per_worker_gb, target_util, fx): + rows = [] + for name, vcpu, ram, price in instances: + ram_workers = math.floor(ram / rss_per_worker_gb) if rss_per_worker_gb > 0 else vcpu + eff = max(0, min(vcpu, ram_workers)) + gifs_hr = per_vcpu_hr * eff + price_usd = price * fx if currency == "EUR" else price + native = (f"€{price:.4f}" if currency == "EUR" else f"${price:.4f}") + cost_1k = price_usd / (gifs_hr / 1000.0) if gifs_hr > 0 else float("inf") + sustain = gifs_hr / 3600.0 * target_util + rows.append( + { + "name": name, + "vcpu": vcpu, + "ram": ram, + "eff": eff, + "ram_flag": eff < vcpu, + "gifs_hr": gifs_hr, + "native": native, + "price_usd": price_usd, + "cost_1k": cost_1k, + "sustain": sustain, + } + ) + return rows + + +def smallest_meeting(rows, peak): + ok = [r for r in rows if r["sustain"] >= peak] + return min(ok, key=lambda r: r["price_usd"]) if ok else None + + +# --------------------------------------------------------------------------- # +# Environment +# --------------------------------------------------------------------------- # +def cpu_model(): + try: + if sys.platform == "darwin": + return subprocess.check_output(["sysctl", "-n", "machdep.cpu.brand_string"], text=True).strip() + if sys.platform.startswith("linux"): + for line in Path("/proc/cpuinfo").read_text().splitlines(): + if line.startswith("model name"): + return line.split(":", 1)[1].strip() + except Exception: + pass + return platform.processor() or platform.machine() + + +def versions(): + out = {} + try: + import torch + + out["torch"] = torch.__version__ + except Exception: + out["torch"] = "n/a" + try: + import onnxruntime + + out["onnxruntime"] = onnxruntime.__version__ + except Exception: + out["onnxruntime"] = "not installed" + try: + import transformers + + out["transformers"] = transformers.__version__ + except Exception: + out["transformers"] = "n/a" + return out + + +# --------------------------------------------------------------------------- # +# Pretty printing +# --------------------------------------------------------------------------- # +def hr(title): + print("\n" + "=" * 78) + print(title) + print("=" * 78) + + +def main(): + ap = argparse.ArgumentParser( + description="Capacity-planning benchmark for the always-on GIF moderation path.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + ap.add_argument("--corpus", default=None, help="directory of real .gif files (else synthesize)") + ap.add_argument("--synth-count", type=int, default=120, help="GIFs to synthesize when no --corpus") + ap.add_argument("--out", default="bench_results.jsonl", help="per-GIF JSONL output (seed eval/RL schema)") + ap.add_argument("--duration", type=float, default=120.0, help="steady-state seconds per level") + ap.add_argument("--min-gifs", type=int, default=2000, help="min GIFs per level (overrides short duration)") + ap.add_argument("--warmup", type=int, default=20, help="GIFs to process+discard before timing") + ap.add_argument("--procs", default=None, help="explicit comma list of process counts (overrides sweep)") + ap.add_argument("--max-procs", type=int, default=None, help="cap the process sweep (default 2x vCPU)") + ap.add_argument("--screen-fps", type=float, default=2.0, help="prescreen sample rate (sweepable)") + ap.add_argument("--max-frames", type=int, default=10, help="motion-sample frame budget (sweepable)") + ap.add_argument("--escalate-threshold", type=float, default=0.15, help="gate threshold (recall-safe)") + ap.add_argument("--frames-per-batch", type=int, default=2, help="frames per merged grid on escalation") + ap.add_argument("--max-escalations", type=int, default=2, help="precise (AWS) call cap per GIF") + ap.add_argument("--model", default="AdamCodd/vit-base-nsfw-detector", help="local ViT model id") + ap.add_argument("--peak-gifs-per-sec", type=float, default=10.0, help="peak live load to size for") + ap.add_argument("--target-util", type=float, default=0.70, help="max sustained utilization (latency safety)") + ap.add_argument("--fx", type=float, default=1.08, help="1 EUR -> USD") + args = ap.parse_args() + + vcpu = os.cpu_count() or 1 + ram_gb = psutil.virtual_memory().total / 1e9 + cfg = { + "screen_fps": args.screen_fps, + "max_frames": args.max_frames, + "escalate_threshold": args.escalate_threshold, + "frames_per_batch": args.frames_per_batch, + "max_escalations": args.max_escalations, + "model": args.model, + } + + # --- environment + pinned config --- + hr("ENVIRONMENT & PINNED CONFIG") + ver = versions() + print(f" host: {vcpu} logical vCPU | {ram_gb:.1f} GB RAM | {cpu_model()}") + print(f" OS: {platform.platform()} Python {platform.python_version()}") + print(f" torch={ver['torch']} onnxruntime={ver['onnxruntime']} transformers={ver['transformers']}") + print(" inference backend: torch CPU, single-threaded per worker") + print(" threading: OMP/MKL/OpenBLAS/VECLIB/ONNX=1, torch.set_num_threads(1) per worker") + print( + " pinned: prescreen.enabled=True " + f"screen_fps={cfg['screen_fps']} max_frames={cfg['max_frames']} sampler=motion " + f"escalate_threshold={cfg['escalate_threshold']} frames_per_batch={cfg['frames_per_batch']} " + f"max_escalations={cfg['max_escalations']}" + ) + print(f" AWS/Rekognition precise backend: STUBBED (instant, counted) model={cfg['model']}") + print(f" --fx={args.fx} (EUR->USD) --target-util={args.target_util}") + print( + " pipeline fns/GIF: iter_frames -> DenseUniformSampler.select -> Frame.to_pil" + " -> LocalBackend.classify_image (ViT) -> gate -> [SuspicionSampler.select -> merge_to_grid -> StubBackend]" + ) + + # --- corpus --- + tmp = None + if args.corpus and Path(args.corpus).is_dir(): + corpus = sorted(str(p) for p in Path(args.corpus).glob("*.gif")) + if not corpus: + print(f"\nNo .gif files in {args.corpus}", file=sys.stderr) + return 2 + else: + rng = __import__("random").Random(1234) + tmp = tempfile.mkdtemp(prefix="bench_gifs_") + print(f"\nSynthesizing {args.synth_count} GIFs into {tmp} ...", flush=True) + corpus = synthesize_corpus(tmp, args.synth_count, rng) + describe_corpus(corpus) + + # --- sweep --- + sweep = make_sweep(vcpu, args.max_procs, args.procs) + hr("PER-CORE SCALING CURVE") + print(f" sweep P = {sweep} (steady state: max({args.duration:.0f}s, {args.min_gifs} GIFs)/level)\n") + header = f" {'P':>3} {'GIFs/s':>8} {'GIFs/hr':>10} {'frm/s':>8} {'p50ms':>8} {'p95ms':>8} {'p99ms':>8} {'CPU%':>6} {'RSS_GB':>7} {'esc%':>5}" + print(header) + print(" " + "-" * (len(header) - 2)) + levels = [] + records_by_p = {} + for P in sweep: + res, recs = run_level(P, corpus, cfg, args.duration, args.min_gifs, args.warmup) + levels.append(res) + records_by_p[P] = recs + print( + f" {res['P']:>3} {res['gifs_per_sec']:>8.2f} {res['gifs_per_hr']:>10.0f} " + f"{res['frames_scored_per_sec']:>8.1f} {res['lat_p50_ms']:>8.1f} {res['lat_p95_ms']:>8.1f} " + f"{res['lat_p99_ms']:>8.1f} {res['mean_cpu_pct']:>6.0f} {res['peak_rss_mb']/1024:>7.2f} " + f"{res['escalation_rate']*100:>5.0f}", + flush=True, + ) + + best, knee, bottleneck = analyse(levels, vcpu, ram_gb) + # sweet-spot level's records seed the jsonl + per-stage f (no re-run needed) + sweet_records = records_by_p[best["P"]] + + # per-worker throughput == per-vCPU for single-threaded inference-bound work, so a + # light sub-vCPU sweep still extrapolates; collapses to best/vCPU at full core load. + per_vcpu_hr = best["gifs_per_hr"] / min(best["P"], vcpu) + rss_per_worker_gb = (best["peak_rss_mb"] / 1024.0) / best["P"] + + print(f"\n SWEET SPOT: P={best['P']} {best['gifs_per_hr']:.0f} GIFs/hr " + f"(p95={best['lat_p95_ms']:.0f}ms, CPU={best['mean_cpu_pct']:.0f}%, RSS={best['peak_rss_mb']/1024:.2f}GB)") + print(f" KNEE: P={knee['P']} (>=90% of peak throughput)") + print(f" BOTTLENECK: {bottleneck}") + print(f" GIFS_PER_HOUR_PER_VCPU = {per_vcpu_hr:.0f}") + print(f" PEAK_RSS_PER_WORKER = {rss_per_worker_gb*1024:.0f} MB ({rss_per_worker_gb:.2f} GB)") + print(" NOTE: projection assumes SAME CPU architecture across instance sizes;") + print(" validate by re-running on a second instance size before trusting $ numbers.") + + # --- per-stage + GPU verdict --- + hr("PER-STAGE TIMING + GPU VERDICT") + stages = ["t_decode_sample", "t_preprocess", "t_inference", "t_gate"] + sums = {s: sum(r[s] for r in sweet_records) for s in stages} + meds = {s: statistics.median(r[s] for r in sweet_records) for s in stages} + total = sum(sums.values()) or 1e-9 + f = sums["t_inference"] / total + for s in stages: + print(f" {s:<16} median={meds[s]*1000:>8.2f} ms share={sums[s]/total*100:>5.1f}%") + print(f" inference fraction f = {f:.3f}") + + full_host_hr = per_vcpu_hr * vcpu # projected throughput of a fully-loaded host + gpu_ceiling_hr = full_host_hr / (1 - f) if f < 1 else float("inf") + gpu_cost_1k = MACHINE0_GPU[1] / (gpu_ceiling_hr / 1000.0) if gpu_ceiling_hr > 0 else float("inf") + # best CPU $/1k across both providers (computed below too, but need it here) + cpu_rows = project(HETZNER_CCX, "EUR", per_vcpu_hr, rss_per_worker_gb, args.target_util, args.fx) + project( + MACHINE0, "USD", per_vcpu_hr, rss_per_worker_gb, args.target_util, args.fx + ) + best_cpu = min(cpu_rows, key=lambda r: r["cost_1k"]) + print( + f"\n GPU optimistic ceiling = full_host_throughput / (1-f) = {full_host_hr:.0f}/{1-f:.3f} = {gpu_ceiling_hr:.0f} GIFs/hr" + ) + print(f" GPU {MACHINE0_GPU[0]} @ ${MACHINE0_GPU[1]:.3f}/hr -> ${gpu_cost_1k:.4f}/1k (at the optimistic ceiling)") + print(f" best CPU option {best_cpu['name']} -> ${best_cpu['cost_1k']:.4f}/1k") + if gpu_cost_1k >= best_cpu["cost_1k"]: + gpu_verdict = ( + f"GPU conclusively not worth it (${gpu_cost_1k:.4f}/1k vs ${best_cpu['cost_1k']:.4f}/1k CPU, " + "even with inference time -> 0)" + ) + else: + gpu_verdict = ( + f"GPU *could* win at its optimistic ceiling (${gpu_cost_1k:.4f}/1k < ${best_cpu['cost_1k']:.4f}/1k) " + "-- verify with a real GPU run before trusting this" + ) + print(f" VERDICT: {gpu_verdict}") + print(" (assumes decode/gate stay CPU-bound at host speed and inference -> 0 on GPU; maximally GPU-favourable)") + + # --- provider projection --- + hr("PROVIDER COST PROJECTION (effective_workers = min(vCPU, floor(RAM/RSS_per_worker)))") + het = project(HETZNER_CCX, "EUR", per_vcpu_hr, rss_per_worker_gb, args.target_util, args.fx) + m0 = project(MACHINE0, "USD", per_vcpu_hr, rss_per_worker_gb, args.target_util, args.fx) + + def print_rows(title, rows): + print(f"\n {title}") + h = f" {'instance':<10} {'vCPU':>4} {'RAM':>4} {'eff':>4} {'native/hr':>10} {'USD/hr':>8} {'GIFs/hr':>9} {'$/1k':>8} {'sust GIFs/s':>11} {'RAM?':>5}" + print(h) + print(" " + "-" * (len(h) - 4)) + for r in rows: + print( + f" {r['name']:<10} {r['vcpu']:>4} {r['ram']:>4} {r['eff']:>4} {r['native']:>10} " + f"${r['price_usd']:>7.4f} {r['gifs_hr']:>9.0f} ${r['cost_1k']:>7.4f} {r['sustain']:>11.2f} " + f"{'YES' if r['ram_flag'] else '-':>5}" + ) + + print_rows("Hetzner Cloud CCX (EUR excl VAT; USD via --fx):", het) + print_rows("machine0 (USD):", m0) + print("\n Prices exclude VAT. machine0 also bills suspended-image storage ($0.078/GB/mo):") + print(" irrelevant for an always-on node, relevant only for burst/scale-to-zero.") + + # --- peak-load sizing --- + hr(f"PEAK-LOAD SIZING (peak={args.peak_gifs_per_sec} GIFs/s at {args.target_util:.0%} util)") + het_pick = smallest_meeting(het, args.peak_gifs_per_sec) + m0_pick = smallest_meeting(m0, args.peak_gifs_per_sec) + + def fmt_pick(r, label): + if not r: + return f" {label}: NONE in catalog sustains {args.peak_gifs_per_sec} GIFs/s -- scale horizontally." + return ( + f" {label}: {r['name']} ({r['vcpu']}vCPU/{r['ram']}GB) -> " + f"{r['sustain']:.1f} GIFs/s sustainable, ${r['price_usd']*HOURS_PER_MONTH:,.0f}/mo, ${r['cost_1k']:.4f}/1k" + + (" [RAM-constrained]" if r["ram_flag"] else "") + ) + + print(fmt_pick(het_pick, "Hetzner baseline")) + print(fmt_pick(m0_pick, "machine0 (if you prefer one provider)")) + + # failover: smallest machine0 whose sustained throughput >= chosen Hetzner baseline + failover = None + if het_pick: + cand = [r for r in m0 if r["sustain"] >= het_pick["sustain"]] + failover = min(cand, key=lambda r: r["price_usd"]) if cand else None + print( + fmt_pick(failover, "machine0 FAILOVER (matches Hetzner baseline)") + if failover + else " machine0 FAILOVER: no single machine0 matches the Hetzner baseline -- use 2+ nodes." + ) + + hr("VERDICT") + print(f" 1. BASELINE (Hetzner): {het_pick['name'] if het_pick else 'scale-out'}" + + (f" @ ${het_pick['price_usd']*HOURS_PER_MONTH:,.0f}/mo, ${het_pick['cost_1k']:.4f}/1k" if het_pick else "")) + print(f" 2. FAILOVER (machine0): {failover['name'] if failover else 'multi-node'}" + + (f" @ ${failover['price_usd']*HOURS_PER_MONTH:,.0f}/mo" if failover else "")) + print(f" 3. CPU-vs-GPU: {gpu_verdict}") + + # --- jsonl seed --- + schema_keys = [ + "gif_id", "n_frames_total", "n_frames_scored", "t_decode_sample", "t_preprocess", + "t_inference", "t_gate", "latency_ms", "peak_rss_mb", "max_local_score", "escalated", + ] + with open(args.out, "w") as fh: + for r in sweet_records: + fh.write(json.dumps({k: r[k] for k in schema_keys}) + "\n") + print(f"\nWrote {len(sweet_records)} per-GIF records -> {args.out}") + + if tmp: + print(f"(synthesized corpus left in {tmp}; delete when done)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plot_results.py b/scripts/plot_results.py new file mode 100644 index 0000000..e1f3303 --- /dev/null +++ b/scripts/plot_results.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Generate PyFrame performance charts from a bench_results.jsonl into media/. + + pip install matplotlib + python scripts/plot_results.py [bench_results.jsonl] + +Produces media/perf_stages.png (per-stage median timing) and +media/perf_latency.png (per-GIF latency percentiles). +""" + +import json +import statistics +import sys +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np # noqa: E402 + + +def load(path): + with open(path) as fh: + return [json.loads(line) for line in fh if line.strip()] + + +if __name__ == "__main__": + src = sys.argv[1] if len(sys.argv) > 1 else "bench_results.jsonl" + recs = load(src) + n = len(recs) + out = Path("media") + out.mkdir(exist_ok=True) + + # Chart 1: per-stage median time per GIF (log x, since inference dwarfs the rest) + stages = ["t_decode_sample", "t_preprocess", "t_inference", "t_gate"] + labels = ["decode + sample", "preprocess", "inference (ViT)", "gate"] + vals = [statistics.median(r[s] for r in recs) * 1000 for s in stages] + colors = ["#6c8ebf", "#bdbdbd", "#d6604d", "#bdbdbd"] + fig, ax = plt.subplots(figsize=(7.2, 3.0)) + bars = ax.barh(labels, vals, color=colors) + ax.set_xscale("log") + ax.set_xlabel("median time per GIF (ms, log scale)") + ax.set_title(f"PyFrame per-stage timing (n={n} GIFs, single worker, CPU)") + ax.invert_yaxis() + for b, v in zip(bars, vals): + ax.text(v * 1.05, b.get_y() + b.get_height() / 2, f"{v:.1f} ms", va="center", fontsize=9) + fig.tight_layout() + fig.savefig(out / "perf_stages.png", dpi=130) + plt.close(fig) + + # Chart 2: per-GIF latency percentiles + lat = np.array([r["latency_ms"] for r in recs]) + pcts = [50, 90, 95, 99] + pv = [float(np.percentile(lat, p)) for p in pcts] + fig, ax = plt.subplots(figsize=(7.2, 3.0)) + ax.bar([f"p{p}" for p in pcts], pv, color="#6c8ebf") + ax.set_ylabel("per-GIF latency (ms)") + ax.set_title(f"PyFrame latency percentiles (n={n} GIFs, single worker, CPU)") + for i, v in enumerate(pv): + ax.text(i, v, f"{v:.0f}", ha="center", va="bottom", fontsize=9) + fig.tight_layout() + fig.savefig(out / "perf_latency.png", dpi=130) + plt.close(fig) + + print(f"wrote media/perf_stages.png and media/perf_latency.png from {n} records") From 037d4f390557be19494a80d04bc3b4f1b66f240c Mon Sep 17 00:00:00 2001 From: Ellis Hewes Date: Fri, 5 Jun 2026 22:28:54 +0100 Subject: [PATCH 4/6] Simplify GIF benchmark --- scripts/bench_gifs.py | 243 ++++++------------------------------------ 1 file changed, 35 insertions(+), 208 deletions(-) diff --git a/scripts/bench_gifs.py b/scripts/bench_gifs.py index f4dac7e..291bae7 100644 --- a/scripts/bench_gifs.py +++ b/scripts/bench_gifs.py @@ -1,41 +1,32 @@ #!/usr/bin/env python3 -"""Capacity-planning benchmark for PyFrame's always-on GIF moderation path. +"""Engine benchmark for PyFrame's GIF moderation path. -Decision it serves: pick the always-on instance size for live GIF traffic, rank -compute providers on real $/1k, and settle CPU-vs-GPU. The system is always-on -(every GIF scanned continuously); evals/RL are a passive read of stored results, -not a separate workload, so we only benchmark the LOCAL scan path. - -It drives the REAL pipeline (no reimplementation). Per GIF it calls, in order: +Measures the real pipeline's throughput, latency, per-stage timing, memory, and scaling +under a multiprocessing pool. It drives the REAL pipeline (no reimplementation); per GIF +it calls, in order: pyframe.media.iter_frames (decode) pyframe.sampling.DenseUniformSampler.select (prescreen sampling @ screen_fps) pyframe.media.Frame.to_pil (preprocess: BGR->RGB->PIL) pyframe.backends.LocalBackend.classify_image (local ViT inference, per frame) gate (score >= escalate_threshold, fail-open) (escalation decision) on escalation: pyframe.sampling.SuspicionSampler.select + image_utils.merge_to_grid - -> StubBackend (AWS/Rekognition MOCKED: instant, counted only) -The motion sampler (MotionBucketSampler, max_frames) governs single-pass / escalation -frame budget; the cascade prescreen samples densely at screen_fps, which is the real -behaviour with prescreen.enabled=True. + -> StubBackend (precise/AWS backend MOCKED: instant, counted only) -AWS is stubbed: the precise backend returns instantly (no network); we still run the -gate and count/log every escalation. We measure LOCAL throughput only. +The precise backend is stubbed (instant, no network), so this measures LOCAL throughput. -Concurrency = a multiprocessing (spawn) process pool, matching the production worker -mechanism (decode is CPU-bound; threads lose to the GIL). Each worker forces -single-threaded inference (OMP/MKL/OpenBLAS/torch/onnx = 1) so N processes don't each -spawn multi-threaded torch and oversubscribe cores. +Concurrency = a multiprocessing (spawn) process pool (decode is CPU-bound; threads lose +to the GIL). Each worker forces single-threaded inference (OMP/MKL/OpenBLAS/torch/onnx=1) +so N processes don't each spawn multi-threaded torch and oversubscribe cores. Outputs: 1. Per-process-count scaling curve + sweet spot, knee, bottleneck class. - 2. Inference fraction f, per-stage medians, CPU-vs-GPU verdict (Amdahl bound). - 3. Provider projection table (Hetzner CCX + machine0; native + USD; $/1k; RAM flag). - 4. Peak-load sizing block (baseline pick, machine0 failover, CPU-vs-GPU). - 5. bench_results.jsonl: one record per GIF (seed schema for the eval/RL store). - 6. Environment block (host, versions, pinned config, fx, target-util). + 2. Per-stage median timing + inference fraction f. + 3. bench_results.jsonl: one record per GIF. + 4. Environment block (host, versions, pinned config). Run: - python scripts/bench_gifs.py --corpus ./gifs --peak-gifs-per-sec 10 + python scripts/bench_gifs.py --corpus ./gifs + python scripts/bench_gifs.py --procs "1" # single-worker profile Flags: see --help. """ @@ -59,7 +50,6 @@ import argparse import json -import math import multiprocessing as mp import platform import statistics @@ -80,29 +70,6 @@ from pyframe.media import iter_frames from pyframe.sampling import DenseUniformSampler, SuspicionSampler -# --------------------------------------------------------------------------- # -# Provider price lists (instance, vCPU, RAM_GB, price/hr in native currency) -# --------------------------------------------------------------------------- # -HETZNER_CCX = [ # EUR/hr excl VAT, dedicated vCPU - ("CCX13", 2, 8, 0.0264), - ("CCX23", 4, 16, 0.0513), - ("CCX33", 8, 32, 0.1009), - ("CCX43", 16, 64, 0.2011), - ("CCX53", 32, 128, 0.4014), - ("CCX63", 48, 192, 0.6009), -] -MACHINE0 = [ # USD/hr - ("small", 1, 1, 0.013), - ("medium", 2, 2, 0.034), - ("large", 2, 4, 0.052), - ("xl", 4, 8, 0.104), - ("xxl", 8, 16, 0.208), - ("xxxl", 16, 64, 0.825), - ("4xl", 32, 128, 1.980), -] -MACHINE0_GPU = ("gpu-4000ada-1", 0.836) # USD/hr; vCPU count not published -HOURS_PER_MONTH = 730.0 - # Worker-process globals (populated by worker_init under spawn). _SCREEN = None _STUB = None @@ -110,10 +77,10 @@ class StubBackend(Backend): - """Mocked precise/Rekognition backend: returns instantly, no network.""" + """Mocked precise backend: returns instantly, no network.""" - name = "aws-stub" - cost_per_image = 0.001 + name = "stub" + cost_per_image = 0.0 def _score(self, image): return 0.0, [], None @@ -251,7 +218,7 @@ def process_one(path): selected = SuspicionSampler().select(flagged_frames, budget, scores) for i in range(0, len(selected), per_batch): grid = merge_to_grid([fr.to_pil() for fr in selected[i : i + per_batch]]) - _STUB.classify_image(grid, min_confidence=0.8) # AWS mocked: instant, counted + _STUB.classify_image(grid, min_confidence=0.8) # precise backend mocked: instant, counted t4 = time.perf_counter() return { @@ -378,7 +345,6 @@ def analyse(levels, vcpu, ram_gb): best_hr = best["gifs_per_hr"] # knee = smallest P reaching >=90% of peak throughput knee = min((r for r in levels if r["gifs_per_hr"] >= 0.9 * best_hr), key=lambda r: r["P"]) - # bottleneck classification peak_rss_gb = best["peak_rss_mb"] / 1024.0 if peak_rss_gb >= 0.85 * ram_gb: bottleneck = "RAM-capacity-bound (aggregate RSS approaches host RAM before the knee)" @@ -394,38 +360,6 @@ def analyse(levels, vcpu, ram_gb): return best, knee, bottleneck -def project(instances, currency, per_vcpu_hr, rss_per_worker_gb, target_util, fx): - rows = [] - for name, vcpu, ram, price in instances: - ram_workers = math.floor(ram / rss_per_worker_gb) if rss_per_worker_gb > 0 else vcpu - eff = max(0, min(vcpu, ram_workers)) - gifs_hr = per_vcpu_hr * eff - price_usd = price * fx if currency == "EUR" else price - native = (f"€{price:.4f}" if currency == "EUR" else f"${price:.4f}") - cost_1k = price_usd / (gifs_hr / 1000.0) if gifs_hr > 0 else float("inf") - sustain = gifs_hr / 3600.0 * target_util - rows.append( - { - "name": name, - "vcpu": vcpu, - "ram": ram, - "eff": eff, - "ram_flag": eff < vcpu, - "gifs_hr": gifs_hr, - "native": native, - "price_usd": price_usd, - "cost_1k": cost_1k, - "sustain": sustain, - } - ) - return rows - - -def smallest_meeting(rows, peak): - ok = [r for r in rows if r["sustain"] >= peak] - return min(ok, key=lambda r: r["price_usd"]) if ok else None - - # --------------------------------------------------------------------------- # # Environment # --------------------------------------------------------------------------- # @@ -444,30 +378,14 @@ def cpu_model(): def versions(): out = {} - try: - import torch - - out["torch"] = torch.__version__ - except Exception: - out["torch"] = "n/a" - try: - import onnxruntime - - out["onnxruntime"] = onnxruntime.__version__ - except Exception: - out["onnxruntime"] = "not installed" - try: - import transformers - - out["transformers"] = transformers.__version__ - except Exception: - out["transformers"] = "n/a" + for mod in ("torch", "onnxruntime", "transformers"): + try: + out[mod] = __import__(mod).__version__ + except Exception: + out[mod] = "n/a" return out -# --------------------------------------------------------------------------- # -# Pretty printing -# --------------------------------------------------------------------------- # def hr(title): print("\n" + "=" * 78) print(title) @@ -476,12 +394,12 @@ def hr(title): def main(): ap = argparse.ArgumentParser( - description="Capacity-planning benchmark for the always-on GIF moderation path.", + description="Engine benchmark for the GIF moderation path.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) ap.add_argument("--corpus", default=None, help="directory of real .gif files (else synthesize)") ap.add_argument("--synth-count", type=int, default=120, help="GIFs to synthesize when no --corpus") - ap.add_argument("--out", default="bench_results.jsonl", help="per-GIF JSONL output (seed eval/RL schema)") + ap.add_argument("--out", default="bench_results.jsonl", help="per-GIF JSONL output") ap.add_argument("--duration", type=float, default=120.0, help="steady-state seconds per level") ap.add_argument("--min-gifs", type=int, default=2000, help="min GIFs per level (overrides short duration)") ap.add_argument("--warmup", type=int, default=20, help="GIFs to process+discard before timing") @@ -491,11 +409,8 @@ def main(): ap.add_argument("--max-frames", type=int, default=10, help="motion-sample frame budget (sweepable)") ap.add_argument("--escalate-threshold", type=float, default=0.15, help="gate threshold (recall-safe)") ap.add_argument("--frames-per-batch", type=int, default=2, help="frames per merged grid on escalation") - ap.add_argument("--max-escalations", type=int, default=2, help="precise (AWS) call cap per GIF") + ap.add_argument("--max-escalations", type=int, default=2, help="precise-backend call cap per GIF") ap.add_argument("--model", default="AdamCodd/vit-base-nsfw-detector", help="local ViT model id") - ap.add_argument("--peak-gifs-per-sec", type=float, default=10.0, help="peak live load to size for") - ap.add_argument("--target-util", type=float, default=0.70, help="max sustained utilization (latency safety)") - ap.add_argument("--fx", type=float, default=1.08, help="1 EUR -> USD") args = ap.parse_args() vcpu = os.cpu_count() or 1 @@ -523,8 +438,7 @@ def main(): f"escalate_threshold={cfg['escalate_threshold']} frames_per_batch={cfg['frames_per_batch']} " f"max_escalations={cfg['max_escalations']}" ) - print(f" AWS/Rekognition precise backend: STUBBED (instant, counted) model={cfg['model']}") - print(f" --fx={args.fx} (EUR->USD) --target-util={args.target_util}") + print(f" precise backend: STUBBED (instant, counted) model={cfg['model']}") print( " pipeline fns/GIF: iter_frames -> DenseUniformSampler.select -> Frame.to_pil" " -> LocalBackend.classify_image (ViT) -> gate -> [SuspicionSampler.select -> merge_to_grid -> StubBackend]" @@ -566,25 +480,21 @@ def main(): ) best, knee, bottleneck = analyse(levels, vcpu, ram_gb) - # sweet-spot level's records seed the jsonl + per-stage f (no re-run needed) sweet_records = records_by_p[best["P"]] - # per-worker throughput == per-vCPU for single-threaded inference-bound work, so a - # light sub-vCPU sweep still extrapolates; collapses to best/vCPU at full core load. - per_vcpu_hr = best["gifs_per_hr"] / min(best["P"], vcpu) - rss_per_worker_gb = (best["peak_rss_mb"] / 1024.0) / best["P"] + # per-worker throughput == per-core for single-threaded inference-bound work + per_core_hr = best["gifs_per_hr"] / min(best["P"], vcpu) + rss_per_worker_mb = best["peak_rss_mb"] / best["P"] print(f"\n SWEET SPOT: P={best['P']} {best['gifs_per_hr']:.0f} GIFs/hr " f"(p95={best['lat_p95_ms']:.0f}ms, CPU={best['mean_cpu_pct']:.0f}%, RSS={best['peak_rss_mb']/1024:.2f}GB)") print(f" KNEE: P={knee['P']} (>=90% of peak throughput)") print(f" BOTTLENECK: {bottleneck}") - print(f" GIFS_PER_HOUR_PER_VCPU = {per_vcpu_hr:.0f}") - print(f" PEAK_RSS_PER_WORKER = {rss_per_worker_gb*1024:.0f} MB ({rss_per_worker_gb:.2f} GB)") - print(" NOTE: projection assumes SAME CPU architecture across instance sizes;") - print(" validate by re-running on a second instance size before trusting $ numbers.") + print(f" GIFs/hr per core = {per_core_hr:.0f}") + print(f" RSS per worker = {rss_per_worker_mb:.0f} MB") - # --- per-stage + GPU verdict --- - hr("PER-STAGE TIMING + GPU VERDICT") + # --- per-stage timing --- + hr("PER-STAGE TIMING") stages = ["t_decode_sample", "t_preprocess", "t_inference", "t_gate"] sums = {s: sum(r[s] for r in sweet_records) for s in stages} meds = {s: statistics.median(r[s] for r in sweet_records) for s in stages} @@ -594,90 +504,7 @@ def main(): print(f" {s:<16} median={meds[s]*1000:>8.2f} ms share={sums[s]/total*100:>5.1f}%") print(f" inference fraction f = {f:.3f}") - full_host_hr = per_vcpu_hr * vcpu # projected throughput of a fully-loaded host - gpu_ceiling_hr = full_host_hr / (1 - f) if f < 1 else float("inf") - gpu_cost_1k = MACHINE0_GPU[1] / (gpu_ceiling_hr / 1000.0) if gpu_ceiling_hr > 0 else float("inf") - # best CPU $/1k across both providers (computed below too, but need it here) - cpu_rows = project(HETZNER_CCX, "EUR", per_vcpu_hr, rss_per_worker_gb, args.target_util, args.fx) + project( - MACHINE0, "USD", per_vcpu_hr, rss_per_worker_gb, args.target_util, args.fx - ) - best_cpu = min(cpu_rows, key=lambda r: r["cost_1k"]) - print( - f"\n GPU optimistic ceiling = full_host_throughput / (1-f) = {full_host_hr:.0f}/{1-f:.3f} = {gpu_ceiling_hr:.0f} GIFs/hr" - ) - print(f" GPU {MACHINE0_GPU[0]} @ ${MACHINE0_GPU[1]:.3f}/hr -> ${gpu_cost_1k:.4f}/1k (at the optimistic ceiling)") - print(f" best CPU option {best_cpu['name']} -> ${best_cpu['cost_1k']:.4f}/1k") - if gpu_cost_1k >= best_cpu["cost_1k"]: - gpu_verdict = ( - f"GPU conclusively not worth it (${gpu_cost_1k:.4f}/1k vs ${best_cpu['cost_1k']:.4f}/1k CPU, " - "even with inference time -> 0)" - ) - else: - gpu_verdict = ( - f"GPU *could* win at its optimistic ceiling (${gpu_cost_1k:.4f}/1k < ${best_cpu['cost_1k']:.4f}/1k) " - "-- verify with a real GPU run before trusting this" - ) - print(f" VERDICT: {gpu_verdict}") - print(" (assumes decode/gate stay CPU-bound at host speed and inference -> 0 on GPU; maximally GPU-favourable)") - - # --- provider projection --- - hr("PROVIDER COST PROJECTION (effective_workers = min(vCPU, floor(RAM/RSS_per_worker)))") - het = project(HETZNER_CCX, "EUR", per_vcpu_hr, rss_per_worker_gb, args.target_util, args.fx) - m0 = project(MACHINE0, "USD", per_vcpu_hr, rss_per_worker_gb, args.target_util, args.fx) - - def print_rows(title, rows): - print(f"\n {title}") - h = f" {'instance':<10} {'vCPU':>4} {'RAM':>4} {'eff':>4} {'native/hr':>10} {'USD/hr':>8} {'GIFs/hr':>9} {'$/1k':>8} {'sust GIFs/s':>11} {'RAM?':>5}" - print(h) - print(" " + "-" * (len(h) - 4)) - for r in rows: - print( - f" {r['name']:<10} {r['vcpu']:>4} {r['ram']:>4} {r['eff']:>4} {r['native']:>10} " - f"${r['price_usd']:>7.4f} {r['gifs_hr']:>9.0f} ${r['cost_1k']:>7.4f} {r['sustain']:>11.2f} " - f"{'YES' if r['ram_flag'] else '-':>5}" - ) - - print_rows("Hetzner Cloud CCX (EUR excl VAT; USD via --fx):", het) - print_rows("machine0 (USD):", m0) - print("\n Prices exclude VAT. machine0 also bills suspended-image storage ($0.078/GB/mo):") - print(" irrelevant for an always-on node, relevant only for burst/scale-to-zero.") - - # --- peak-load sizing --- - hr(f"PEAK-LOAD SIZING (peak={args.peak_gifs_per_sec} GIFs/s at {args.target_util:.0%} util)") - het_pick = smallest_meeting(het, args.peak_gifs_per_sec) - m0_pick = smallest_meeting(m0, args.peak_gifs_per_sec) - - def fmt_pick(r, label): - if not r: - return f" {label}: NONE in catalog sustains {args.peak_gifs_per_sec} GIFs/s -- scale horizontally." - return ( - f" {label}: {r['name']} ({r['vcpu']}vCPU/{r['ram']}GB) -> " - f"{r['sustain']:.1f} GIFs/s sustainable, ${r['price_usd']*HOURS_PER_MONTH:,.0f}/mo, ${r['cost_1k']:.4f}/1k" - + (" [RAM-constrained]" if r["ram_flag"] else "") - ) - - print(fmt_pick(het_pick, "Hetzner baseline")) - print(fmt_pick(m0_pick, "machine0 (if you prefer one provider)")) - - # failover: smallest machine0 whose sustained throughput >= chosen Hetzner baseline - failover = None - if het_pick: - cand = [r for r in m0 if r["sustain"] >= het_pick["sustain"]] - failover = min(cand, key=lambda r: r["price_usd"]) if cand else None - print( - fmt_pick(failover, "machine0 FAILOVER (matches Hetzner baseline)") - if failover - else " machine0 FAILOVER: no single machine0 matches the Hetzner baseline -- use 2+ nodes." - ) - - hr("VERDICT") - print(f" 1. BASELINE (Hetzner): {het_pick['name'] if het_pick else 'scale-out'}" - + (f" @ ${het_pick['price_usd']*HOURS_PER_MONTH:,.0f}/mo, ${het_pick['cost_1k']:.4f}/1k" if het_pick else "")) - print(f" 2. FAILOVER (machine0): {failover['name'] if failover else 'multi-node'}" - + (f" @ ${failover['price_usd']*HOURS_PER_MONTH:,.0f}/mo" if failover else "")) - print(f" 3. CPU-vs-GPU: {gpu_verdict}") - - # --- jsonl seed --- + # --- jsonl --- schema_keys = [ "gif_id", "n_frames_total", "n_frames_scored", "t_decode_sample", "t_preprocess", "t_inference", "t_gate", "latency_ms", "peak_rss_mb", "max_local_score", "escalated", From d9c4ef55048edb35a4a55649b5329e117aaf3fc6 Mon Sep 17 00:00:00 2001 From: Ellis Hewes Date: Sat, 6 Jun 2026 13:44:41 +0100 Subject: [PATCH 5/6] Add eden.report docs links; remove bench scripts Add links and badges pointing to eden.report/docs from README and in-repo docs (README.md, docs/README.md, docs/output.md, docs/performance.md) to surface the live/annotated pipeline documentation. Remove legacy benchmarking and plotting utilities (scripts/bench_gifs.py and scripts/plot_results.py). --- README.md | 7 +- docs/README.md | 3 + docs/output.md | 4 + docs/performance.md | 4 + scripts/bench_gifs.py | 523 ---------------------------------------- scripts/plot_results.py | 66 ----- 6 files changed, 17 insertions(+), 590 deletions(-) delete mode 100644 scripts/bench_gifs.py delete mode 100644 scripts/plot_results.py diff --git a/README.md b/README.md index c5f9327..d3fe62d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ PyFrame uses **temporal segmentation** to avoid moderating every frame: it split [![Python versions](https://img.shields.io/pypi/pyversions/pyframe-gif-video-image-moderation)](https://pypi.org/project/pyframe-gif-video-image-moderation/) [![License: MIT](https://img.shields.io/badge/License-MIT-green)](https://github.com/ehewes/pyframe/blob/main/LICENSE) [![CI](https://img.shields.io/github/actions/workflow/status/ehewes/pyframe/ci.yml?branch=main&label=CI)](https://github.com/ehewes/pyframe/actions/workflows/ci.yml) +[![Docs](https://img.shields.io/badge/docs-eden.report/docs-blueviolet)](https://www.eden.report/docs) @@ -130,9 +131,13 @@ A 150-frame GIF flows through temporal segmentation down to a handful of extract ![PyFrame pipeline: GIF frames to temporal buckets to extracted frames to merged grids to AWS Rekognition](https://raw.githubusercontent.com/ehewes/pyframe/main/media/HCBHD36W0AI3Hz4.jpeg) +A short, annotated **live** version of this diagram is at **[eden.report/docs](https://www.eden.report/docs)**. + ## Documentation -Full reference docs live in [`docs/`](https://github.com/ehewes/pyframe/tree/main/docs). Start with the [output reference](https://github.com/ehewes/pyframe/blob/main/docs/output.md) for the complete JSON / `ScanResult` schema. +The documentation home is **[eden.report/docs](https://www.eden.report/docs)**: the fullest guides plus a short annotated live diagram of the pipeline. + +Reference docs also live in [`docs/`](https://github.com/ehewes/pyframe/tree/main/docs); start with the [output reference](https://github.com/ehewes/pyframe/blob/main/docs/output.md) for the complete JSON / `ScanResult` schema. ## Notes diff --git a/docs/README.md b/docs/README.md index ed75fd9..b124e90 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,6 +3,8 @@ Reference documentation for PyFrame. This folder grows over time; add new pages here and link them from the list below. +> **Documentation home:** [eden.report/docs](https://www.eden.report/docs) hosts the fullest version of these docs, including a short annotated live diagram of the pipeline. The pages in this folder are the in-repo reference mirror. + ## Contents - [Output reference](output.md) - every field in the JSON / `ScanResult`, explained. @@ -10,4 +12,5 @@ here and link them from the list below. ## See also +- [eden.report/docs](https://www.eden.report/docs) - the documentation website: expanded guides and an annotated live pipeline diagram. - Project [README](../README.md) - install, quickstart, CLI, and the pipeline diagram. diff --git a/docs/output.md b/docs/output.md index cf05576..7d527a0 100644 --- a/docs/output.md +++ b/docs/output.md @@ -145,3 +145,7 @@ exit code encodes the outcome so it slots into shell gates: ```bash pyframe upload.gif --backend local || echo "rejected" ``` + +--- + +More docs, including a short annotated live diagram of the pipeline, are at **[eden.report/docs](https://www.eden.report/docs)**. diff --git a/docs/performance.md b/docs/performance.md index 416fd92..b9b3186 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -79,3 +79,7 @@ Append a row when you measure on new hardware. |-----|-------------------|-----------------|---------------------|------------| | reference | torch CPU, 1 thread | ~3.1 | ~0.91 | ~0.5 GB | | | | | | | + +--- + +More docs, including a short annotated live diagram of the pipeline, are at **[eden.report/docs](https://www.eden.report/docs)**. diff --git a/scripts/bench_gifs.py b/scripts/bench_gifs.py deleted file mode 100644 index 291bae7..0000000 --- a/scripts/bench_gifs.py +++ /dev/null @@ -1,523 +0,0 @@ -#!/usr/bin/env python3 -"""Engine benchmark for PyFrame's GIF moderation path. - -Measures the real pipeline's throughput, latency, per-stage timing, memory, and scaling -under a multiprocessing pool. It drives the REAL pipeline (no reimplementation); per GIF -it calls, in order: - pyframe.media.iter_frames (decode) - pyframe.sampling.DenseUniformSampler.select (prescreen sampling @ screen_fps) - pyframe.media.Frame.to_pil (preprocess: BGR->RGB->PIL) - pyframe.backends.LocalBackend.classify_image (local ViT inference, per frame) - gate (score >= escalate_threshold, fail-open) (escalation decision) - on escalation: pyframe.sampling.SuspicionSampler.select + image_utils.merge_to_grid - -> StubBackend (precise/AWS backend MOCKED: instant, counted only) - -The precise backend is stubbed (instant, no network), so this measures LOCAL throughput. - -Concurrency = a multiprocessing (spawn) process pool (decode is CPU-bound; threads lose -to the GIL). Each worker forces single-threaded inference (OMP/MKL/OpenBLAS/torch/onnx=1) -so N processes don't each spawn multi-threaded torch and oversubscribe cores. - -Outputs: - 1. Per-process-count scaling curve + sweet spot, knee, bottleneck class. - 2. Per-stage median timing + inference fraction f. - 3. bench_results.jsonl: one record per GIF. - 4. Environment block (host, versions, pinned config). - -Run: - python scripts/bench_gifs.py --corpus ./gifs - python scripts/bench_gifs.py --procs "1" # single-worker profile - -Flags: see --help. -""" - -# ruff: noqa: E402 (thread caps are deliberately set before heavy imports below) -# Thread caps MUST be set before numpy/torch/cv2 import (they read these at import). -import os - -for _v in ( - "OMP_NUM_THREADS", - "MKL_NUM_THREADS", - "OPENBLAS_NUM_THREADS", - "NUMEXPR_NUM_THREADS", - "VECLIB_MAXIMUM_THREADS", # macOS Accelerate - "ONNXRUNTIME_INTRA_OP_NUM_THREADS", - "TOKENIZERS_PARALLELISM", -): - os.environ.setdefault(_v, "1" if _v != "TOKENIZERS_PARALLELISM" else "false") -os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error") -os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1") - -import argparse -import json -import multiprocessing as mp -import platform -import statistics -import subprocess -import sys -import tempfile -import threading -import time -from itertools import islice -from pathlib import Path - -import numpy as np -import psutil - -from pyframe.backends import load_backend -from pyframe.backends.base import Backend -from pyframe.image_utils import merge_to_grid -from pyframe.media import iter_frames -from pyframe.sampling import DenseUniformSampler, SuspicionSampler - -# Worker-process globals (populated by worker_init under spawn). -_SCREEN = None -_STUB = None -_CFG = None - - -class StubBackend(Backend): - """Mocked precise backend: returns instantly, no network.""" - - name = "stub" - cost_per_image = 0.0 - - def _score(self, image): - return 0.0, [], None - - -# --------------------------------------------------------------------------- # -# Corpus -# --------------------------------------------------------------------------- # -def _synth_one(path, n_frames, width): - from PIL import Image - - height = max(8, int(round(width * 0.6))) - grad = np.linspace(0, 255, width, dtype=np.uint8)[None, :, None] - frames = [] - for i in range(n_frames): - arr = np.zeros((height, width, 3), np.uint8) - arr[:, :, 0:1] = grad # horizontal gradient (R channel) - x = int((i / max(1, n_frames - 1)) * (width - 24)) - arr[height // 2 - 8 : height // 2 + 8, x : x + 24, :] = 255 # moving block -> motion - noise = np.random.randint(-12, 12, (height, width, 3), dtype=np.int16) - arr = np.clip(arr.astype(np.int16) + noise, 0, 255).astype(np.uint8) - frames.append(Image.fromarray(arr)) - frames[0].save(path, save_all=True, append_images=frames[1:], duration=66, loop=0, optimize=False) - - -def synthesize_corpus(out_dir, count, rng): - # (weight, frame-range, width-range) - buckets = [ - (0.60, (10, 40), (128, 320)), - (0.30, (40, 90), (320, 480)), - (0.10, (90, 150), (480, 640)), - ] - out_dir = Path(out_dir) - out_dir.mkdir(parents=True, exist_ok=True) - paths = [] - for i in range(count): - r = rng.random() - cum = 0.0 - chosen = buckets[-1] - for b in buckets: - cum += b[0] - if r <= cum: - chosen = b - break - nf = rng.randint(*chosen[1]) - w = rng.randint(*chosen[2]) - p = out_dir / f"synth_{i:04d}_{nf}f_{w}px.gif" - _synth_one(str(p), nf, w) - paths.append(str(p)) - return paths - - -def describe_corpus(paths): - sizes, frame_counts, widths = [], [], [] - for p in paths: - sizes.append(os.path.getsize(p) / 1e6) - try: - from PIL import Image - - with Image.open(p) as im: - widths.append(im.size[0]) - frame_counts.append(getattr(im, "n_frames", 1)) - except Exception: - pass - - def pct(a, q): - return float(np.percentile(a, q)) if a else 0.0 - - print("\n=== CORPUS ===") - print(f" files: {len(paths)} mean file size: {statistics.mean(sizes):.2f} MB" if sizes else " (empty)") - if frame_counts: - print( - f" frames/GIF p50={pct(frame_counts,50):.0f} p90={pct(frame_counts,90):.0f} " - f"min={min(frame_counts)} max={max(frame_counts)}" - ) - if widths: - print( - f" width px p50={pct(widths,50):.0f} p90={pct(widths,90):.0f} " - f"min={min(widths)} max={max(widths)}" - ) - - -# --------------------------------------------------------------------------- # -# Worker -# --------------------------------------------------------------------------- # -def worker_init(cfg): - global _SCREEN, _STUB, _CFG - _CFG = cfg - try: - import torch - - torch.set_num_threads(1) - torch.set_num_interop_threads(1) - except Exception: - pass - try: - import onnxruntime as ort - - _ = ort # intra-op threads pinned via env ONNXRUNTIME_INTRA_OP_NUM_THREADS=1 - except Exception: - pass - _SCREEN = load_backend("local", model=cfg["model"]) - _STUB = StubBackend() - - -def process_one(path): - """Run the real cascade prescreen path on one GIF with per-stage timing.""" - proc = psutil.Process() - esc = _CFG["escalate_threshold"] - t0 = time.perf_counter() - frames = list(iter_frames(path)) - n_total = len(frames) - screen_frames = DenseUniformSampler(_CFG["screen_fps"]).select(frames) - t1 = time.perf_counter() - - pils = [f.to_pil() for f in screen_frames] - t2 = time.perf_counter() - - verdicts = [ - _SCREEN.classify_image(p, min_confidence=esc, index=f.index, timestamp=f.timestamp) - for f, p in zip(screen_frames, pils) - ] - t3 = time.perf_counter() - - scores = {v.frame_index: v.score for v in verdicts} - flagged = [v.frame_index for v in verdicts if v.score >= esc or (v.error and True)] - max_local = max((v.score for v in verdicts), default=0.0) - escalated = bool(flagged) - if escalated: - # Mirror Scanner._cascade escalation: top-suspicious -> merged grids -> precise. - per_batch = max(1, _CFG["frames_per_batch"]) - budget = _CFG["max_escalations"] * per_batch - fset = set(flagged) - flagged_frames = [f for f in frames if f.index in fset] - selected = SuspicionSampler().select(flagged_frames, budget, scores) - for i in range(0, len(selected), per_batch): - grid = merge_to_grid([fr.to_pil() for fr in selected[i : i + per_batch]]) - _STUB.classify_image(grid, min_confidence=0.8) # precise backend mocked: instant, counted - t4 = time.perf_counter() - - return { - "gif_id": os.path.basename(path), - "n_frames_total": n_total, - "n_frames_scored": len(screen_frames), - "t_decode_sample": t1 - t0, - "t_preprocess": t2 - t1, - "t_inference": t3 - t2, - "t_gate": t4 - t3, - "latency_ms": (t4 - t0) * 1000.0, - "peak_rss_mb": round(proc.memory_info().rss / 1e6, 1), - "max_local_score": round(float(max_local), 4), - "escalated": escalated, - } - - -# --------------------------------------------------------------------------- # -# Resource monitor (samples worker PIDs during a level) -# --------------------------------------------------------------------------- # -class ResourceMonitor(threading.Thread): - def __init__(self, pids, interval=0.5): - super().__init__(daemon=True) - self.interval = interval - self.procs = [] - for pid in pids: - try: - self.procs.append(psutil.Process(pid)) - except psutil.NoSuchProcess: - pass - self._stop = threading.Event() - self.peak_rss = 0 - self.cpu_samples = [] - - def run(self): - psutil.cpu_percent(None) # prime system-wide - while not self._stop.wait(self.interval): - self.cpu_samples.append(psutil.cpu_percent(None)) - total = 0 - for pr in self.procs: - try: - total += pr.memory_info().rss - except (psutil.NoSuchProcess, psutil.AccessDenied): - pass - self.peak_rss = max(self.peak_rss, total) - - def stop(self): - self._stop.set() - self.join(timeout=3) - - @property - def mean_cpu(self): - return statistics.mean(self.cpu_samples) if self.cpu_samples else 0.0 - - -def _path_stream(corpus): - while True: - for p in corpus: - yield p - - -# --------------------------------------------------------------------------- # -# Run one concurrency level -# --------------------------------------------------------------------------- # -def run_level(P, corpus, cfg, duration, min_gifs, warmup): - ctx = mp.get_context("spawn") - pool = ctx.Pool(P, initializer=worker_init, initargs=(cfg,)) - try: - pids = [w.pid for w in getattr(pool, "_pool", [])] - # Warm-up: model load happened in init; exercise JIT/caches and discard. - for _ in pool.imap_unordered(process_one, list(islice(_path_stream(corpus), warmup))): - pass - - mon = ResourceMonitor(pids) - mon.start() - records = [] - t_start = time.perf_counter() - for rec in pool.imap_unordered(process_one, _path_stream(corpus)): - records.append(rec) - elapsed = time.perf_counter() - t_start - if elapsed >= duration and len(records) >= min_gifs: - break - wall = time.perf_counter() - t_start - mon.stop() - finally: - pool.terminate() - pool.join() - - lat = np.array([r["latency_ms"] for r in records], dtype=float) - frames_scored = sum(r["n_frames_scored"] for r in records) - gifs = len(records) - result = { - "P": P, - "wall_s": wall, - "gifs": gifs, - "gifs_per_sec": gifs / wall, - "gifs_per_hr": gifs / wall * 3600.0, - "frames_scored_per_sec": frames_scored / wall, - "lat_p50_ms": float(np.percentile(lat, 50)), - "lat_p95_ms": float(np.percentile(lat, 95)), - "lat_p99_ms": float(np.percentile(lat, 99)), - "mean_cpu_pct": mon.mean_cpu, - "peak_rss_mb": mon.peak_rss / 1e6, - "escalation_rate": float(np.mean([r["escalated"] for r in records])) if records else 0.0, - } - return result, records - - -# --------------------------------------------------------------------------- # -# Analysis -# --------------------------------------------------------------------------- # -def make_sweep(vcpu, max_procs, explicit): - if explicit: - seq = sorted({int(x) for x in explicit.split(",") if x.strip()}) - return [p for p in seq if p >= 1] - cap = max_procs or 2 * vcpu - base = [1, 2, 4, 8, 12, 16, 24, 32, 48, 64] - seq = sorted({p for p in base + [vcpu, 2 * vcpu] if 1 <= p <= cap}) - return seq - - -def analyse(levels, vcpu, ram_gb): - best = max(levels, key=lambda r: r["gifs_per_hr"]) - best_hr = best["gifs_per_hr"] - # knee = smallest P reaching >=90% of peak throughput - knee = min((r for r in levels if r["gifs_per_hr"] >= 0.9 * best_hr), key=lambda r: r["P"]) - peak_rss_gb = best["peak_rss_mb"] / 1024.0 - if peak_rss_gb >= 0.85 * ram_gb: - bottleneck = "RAM-capacity-bound (aggregate RSS approaches host RAM before the knee)" - elif knee["P"] >= 0.9 * vcpu: - bottleneck = f"CPU-bound (knee P={knee['P']} ~ vCPU={vcpu})" - elif best["mean_cpu_pct"] < 85.0: - bottleneck = ( - f"memory-bandwidth-bound (knee P={knee['P']} < vCPU={vcpu}, " - f"CPU only {best['mean_cpu_pct']:.0f}% at sweet spot)" - ) - else: - bottleneck = f"CPU-bound (knee P={knee['P']})" - return best, knee, bottleneck - - -# --------------------------------------------------------------------------- # -# Environment -# --------------------------------------------------------------------------- # -def cpu_model(): - try: - if sys.platform == "darwin": - return subprocess.check_output(["sysctl", "-n", "machdep.cpu.brand_string"], text=True).strip() - if sys.platform.startswith("linux"): - for line in Path("/proc/cpuinfo").read_text().splitlines(): - if line.startswith("model name"): - return line.split(":", 1)[1].strip() - except Exception: - pass - return platform.processor() or platform.machine() - - -def versions(): - out = {} - for mod in ("torch", "onnxruntime", "transformers"): - try: - out[mod] = __import__(mod).__version__ - except Exception: - out[mod] = "n/a" - return out - - -def hr(title): - print("\n" + "=" * 78) - print(title) - print("=" * 78) - - -def main(): - ap = argparse.ArgumentParser( - description="Engine benchmark for the GIF moderation path.", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - ap.add_argument("--corpus", default=None, help="directory of real .gif files (else synthesize)") - ap.add_argument("--synth-count", type=int, default=120, help="GIFs to synthesize when no --corpus") - ap.add_argument("--out", default="bench_results.jsonl", help="per-GIF JSONL output") - ap.add_argument("--duration", type=float, default=120.0, help="steady-state seconds per level") - ap.add_argument("--min-gifs", type=int, default=2000, help="min GIFs per level (overrides short duration)") - ap.add_argument("--warmup", type=int, default=20, help="GIFs to process+discard before timing") - ap.add_argument("--procs", default=None, help="explicit comma list of process counts (overrides sweep)") - ap.add_argument("--max-procs", type=int, default=None, help="cap the process sweep (default 2x vCPU)") - ap.add_argument("--screen-fps", type=float, default=2.0, help="prescreen sample rate (sweepable)") - ap.add_argument("--max-frames", type=int, default=10, help="motion-sample frame budget (sweepable)") - ap.add_argument("--escalate-threshold", type=float, default=0.15, help="gate threshold (recall-safe)") - ap.add_argument("--frames-per-batch", type=int, default=2, help="frames per merged grid on escalation") - ap.add_argument("--max-escalations", type=int, default=2, help="precise-backend call cap per GIF") - ap.add_argument("--model", default="AdamCodd/vit-base-nsfw-detector", help="local ViT model id") - args = ap.parse_args() - - vcpu = os.cpu_count() or 1 - ram_gb = psutil.virtual_memory().total / 1e9 - cfg = { - "screen_fps": args.screen_fps, - "max_frames": args.max_frames, - "escalate_threshold": args.escalate_threshold, - "frames_per_batch": args.frames_per_batch, - "max_escalations": args.max_escalations, - "model": args.model, - } - - # --- environment + pinned config --- - hr("ENVIRONMENT & PINNED CONFIG") - ver = versions() - print(f" host: {vcpu} logical vCPU | {ram_gb:.1f} GB RAM | {cpu_model()}") - print(f" OS: {platform.platform()} Python {platform.python_version()}") - print(f" torch={ver['torch']} onnxruntime={ver['onnxruntime']} transformers={ver['transformers']}") - print(" inference backend: torch CPU, single-threaded per worker") - print(" threading: OMP/MKL/OpenBLAS/VECLIB/ONNX=1, torch.set_num_threads(1) per worker") - print( - " pinned: prescreen.enabled=True " - f"screen_fps={cfg['screen_fps']} max_frames={cfg['max_frames']} sampler=motion " - f"escalate_threshold={cfg['escalate_threshold']} frames_per_batch={cfg['frames_per_batch']} " - f"max_escalations={cfg['max_escalations']}" - ) - print(f" precise backend: STUBBED (instant, counted) model={cfg['model']}") - print( - " pipeline fns/GIF: iter_frames -> DenseUniformSampler.select -> Frame.to_pil" - " -> LocalBackend.classify_image (ViT) -> gate -> [SuspicionSampler.select -> merge_to_grid -> StubBackend]" - ) - - # --- corpus --- - tmp = None - if args.corpus and Path(args.corpus).is_dir(): - corpus = sorted(str(p) for p in Path(args.corpus).glob("*.gif")) - if not corpus: - print(f"\nNo .gif files in {args.corpus}", file=sys.stderr) - return 2 - else: - rng = __import__("random").Random(1234) - tmp = tempfile.mkdtemp(prefix="bench_gifs_") - print(f"\nSynthesizing {args.synth_count} GIFs into {tmp} ...", flush=True) - corpus = synthesize_corpus(tmp, args.synth_count, rng) - describe_corpus(corpus) - - # --- sweep --- - sweep = make_sweep(vcpu, args.max_procs, args.procs) - hr("PER-CORE SCALING CURVE") - print(f" sweep P = {sweep} (steady state: max({args.duration:.0f}s, {args.min_gifs} GIFs)/level)\n") - header = f" {'P':>3} {'GIFs/s':>8} {'GIFs/hr':>10} {'frm/s':>8} {'p50ms':>8} {'p95ms':>8} {'p99ms':>8} {'CPU%':>6} {'RSS_GB':>7} {'esc%':>5}" - print(header) - print(" " + "-" * (len(header) - 2)) - levels = [] - records_by_p = {} - for P in sweep: - res, recs = run_level(P, corpus, cfg, args.duration, args.min_gifs, args.warmup) - levels.append(res) - records_by_p[P] = recs - print( - f" {res['P']:>3} {res['gifs_per_sec']:>8.2f} {res['gifs_per_hr']:>10.0f} " - f"{res['frames_scored_per_sec']:>8.1f} {res['lat_p50_ms']:>8.1f} {res['lat_p95_ms']:>8.1f} " - f"{res['lat_p99_ms']:>8.1f} {res['mean_cpu_pct']:>6.0f} {res['peak_rss_mb']/1024:>7.2f} " - f"{res['escalation_rate']*100:>5.0f}", - flush=True, - ) - - best, knee, bottleneck = analyse(levels, vcpu, ram_gb) - sweet_records = records_by_p[best["P"]] - - # per-worker throughput == per-core for single-threaded inference-bound work - per_core_hr = best["gifs_per_hr"] / min(best["P"], vcpu) - rss_per_worker_mb = best["peak_rss_mb"] / best["P"] - - print(f"\n SWEET SPOT: P={best['P']} {best['gifs_per_hr']:.0f} GIFs/hr " - f"(p95={best['lat_p95_ms']:.0f}ms, CPU={best['mean_cpu_pct']:.0f}%, RSS={best['peak_rss_mb']/1024:.2f}GB)") - print(f" KNEE: P={knee['P']} (>=90% of peak throughput)") - print(f" BOTTLENECK: {bottleneck}") - print(f" GIFs/hr per core = {per_core_hr:.0f}") - print(f" RSS per worker = {rss_per_worker_mb:.0f} MB") - - # --- per-stage timing --- - hr("PER-STAGE TIMING") - stages = ["t_decode_sample", "t_preprocess", "t_inference", "t_gate"] - sums = {s: sum(r[s] for r in sweet_records) for s in stages} - meds = {s: statistics.median(r[s] for r in sweet_records) for s in stages} - total = sum(sums.values()) or 1e-9 - f = sums["t_inference"] / total - for s in stages: - print(f" {s:<16} median={meds[s]*1000:>8.2f} ms share={sums[s]/total*100:>5.1f}%") - print(f" inference fraction f = {f:.3f}") - - # --- jsonl --- - schema_keys = [ - "gif_id", "n_frames_total", "n_frames_scored", "t_decode_sample", "t_preprocess", - "t_inference", "t_gate", "latency_ms", "peak_rss_mb", "max_local_score", "escalated", - ] - with open(args.out, "w") as fh: - for r in sweet_records: - fh.write(json.dumps({k: r[k] for k in schema_keys}) + "\n") - print(f"\nWrote {len(sweet_records)} per-GIF records -> {args.out}") - - if tmp: - print(f"(synthesized corpus left in {tmp}; delete when done)") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/plot_results.py b/scripts/plot_results.py deleted file mode 100644 index e1f3303..0000000 --- a/scripts/plot_results.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env python3 -"""Generate PyFrame performance charts from a bench_results.jsonl into media/. - - pip install matplotlib - python scripts/plot_results.py [bench_results.jsonl] - -Produces media/perf_stages.png (per-stage median timing) and -media/perf_latency.png (per-GIF latency percentiles). -""" - -import json -import statistics -import sys -from pathlib import Path - -import matplotlib - -matplotlib.use("Agg") -import matplotlib.pyplot as plt # noqa: E402 -import numpy as np # noqa: E402 - - -def load(path): - with open(path) as fh: - return [json.loads(line) for line in fh if line.strip()] - - -if __name__ == "__main__": - src = sys.argv[1] if len(sys.argv) > 1 else "bench_results.jsonl" - recs = load(src) - n = len(recs) - out = Path("media") - out.mkdir(exist_ok=True) - - # Chart 1: per-stage median time per GIF (log x, since inference dwarfs the rest) - stages = ["t_decode_sample", "t_preprocess", "t_inference", "t_gate"] - labels = ["decode + sample", "preprocess", "inference (ViT)", "gate"] - vals = [statistics.median(r[s] for r in recs) * 1000 for s in stages] - colors = ["#6c8ebf", "#bdbdbd", "#d6604d", "#bdbdbd"] - fig, ax = plt.subplots(figsize=(7.2, 3.0)) - bars = ax.barh(labels, vals, color=colors) - ax.set_xscale("log") - ax.set_xlabel("median time per GIF (ms, log scale)") - ax.set_title(f"PyFrame per-stage timing (n={n} GIFs, single worker, CPU)") - ax.invert_yaxis() - for b, v in zip(bars, vals): - ax.text(v * 1.05, b.get_y() + b.get_height() / 2, f"{v:.1f} ms", va="center", fontsize=9) - fig.tight_layout() - fig.savefig(out / "perf_stages.png", dpi=130) - plt.close(fig) - - # Chart 2: per-GIF latency percentiles - lat = np.array([r["latency_ms"] for r in recs]) - pcts = [50, 90, 95, 99] - pv = [float(np.percentile(lat, p)) for p in pcts] - fig, ax = plt.subplots(figsize=(7.2, 3.0)) - ax.bar([f"p{p}" for p in pcts], pv, color="#6c8ebf") - ax.set_ylabel("per-GIF latency (ms)") - ax.set_title(f"PyFrame latency percentiles (n={n} GIFs, single worker, CPU)") - for i, v in enumerate(pv): - ax.text(i, v, f"{v:.0f}", ha="center", va="bottom", fontsize=9) - fig.tight_layout() - fig.savefig(out / "perf_latency.png", dpi=130) - plt.close(fig) - - print(f"wrote media/perf_stages.png and media/perf_latency.png from {n} records") From dc0ad6b10790ab52df75b6b6514809ca1422b058 Mon Sep 17 00:00:00 2001 From: Ellis Hewes Date: Sat, 6 Jun 2026 13:52:22 +0100 Subject: [PATCH 6/6] package bump --- .gitignore | 3 --- docs/performance.md | 5 +---- pyproject.toml | 2 +- src/pyframe/__init__.py | 4 ++-- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 499bf5d..c0c590e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,3 @@ build/ *.egg-info/ .pytest_cache/ .ruff_cache/ - -# benchmark output -bench_results.jsonl diff --git a/docs/performance.md b/docs/performance.md index b9b3186..83cbcb5 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -65,10 +65,7 @@ Against ~239 ms of inference, the difference is noise. ## Reproduce ```bash -pip install psutil matplotlib # bench/plot tools, not runtime deps -python scripts/bench_gifs.py --procs "1" --duration 30 # single-worker profile -> bench_results.jsonl -python scripts/bench_decode.py # decode comparison -python scripts/plot_results.py bench_results.jsonl # regenerate the charts in media/ +python scripts/bench_decode.py # decode comparison ``` ## Results log diff --git a/pyproject.toml b/pyproject.toml index d54ff05..369c918 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyframe-gif-video-image-moderation" -version = "0.1.0" +version = "0.2.0" description = "Two-stage NSFW moderation for GIFs, videos, and images via local HuggingFace models and/or AWS Rekognition." readme = "README.md" requires-python = ">=3.10" diff --git a/src/pyframe/__init__.py b/src/pyframe/__init__.py index 6befd20..7800fc1 100644 --- a/src/pyframe/__init__.py +++ b/src/pyframe/__init__.py @@ -20,9 +20,9 @@ try: __version__ = version("pyframe-gif-video-image-moderation") except PackageNotFoundError: - __version__ = "0.1.0" + __version__ = "0.2.0" except Exception: - __version__ = "0.1.0" + __version__ = "0.2.0" __all__ = [ "Pipe",