From e4edf202faab486b06f16e1565561382e37d2248 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:59:26 +0000 Subject: [PATCH 1/7] chore: update lockfiles --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 8d5f442..7953cde 100644 --- a/uv.lock +++ b/uv.lock @@ -2348,7 +2348,7 @@ wheels = [ [[package]] name = "winnow" -version = "0.4.0" +version = "0.4.1" source = { editable = "." } dependencies = [ { name = "croniter" }, From 5553877c90e489e249e30122bf3eacbe86c0fcfe Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 21:00:50 +0000 Subject: [PATCH 2/7] =?UTF-8?q?ci:=20consolidate=20Docker=20builds=20?= =?UTF-8?q?=E2=80=94=20eliminate=20duplicate=20builds=20on=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release.yml now calls docker-publish.yml via workflow_call instead of re-running all four image builds independently. docker-publish.yml gains workflow_call inputs (tag, version) for release context; branch trigger is narrowed to dev only (main changes only land via tagged releases). Each release previously built all four variants twice (~90 min) — once on merge to main, once on tag push. Now it builds once. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/docker-publish.yml | 149 ++++++++++++++++----- .github/workflows/release.yml | 190 ++------------------------- 2 files changed, 127 insertions(+), 212 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index a50fd32..a3b777b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,7 @@ name: Publish Docker Image on: push: - branches: ["main", "dev"] + branches: ["dev"] paths-ignore: - "**.md" - "docs/**" @@ -15,6 +15,16 @@ on: - "uv-cpu.lock" - "uv-rocm.lock" - "uv-intel.lock" + workflow_call: + inputs: + tag: + type: string + required: false + description: "Release tag, e.g. v0.4.1 — triggers :latest + versioned image tags" + version: + type: string + required: false + description: "Version string without v prefix, e.g. 0.4.1" concurrency: group: docker-${{ github.ref }} @@ -50,6 +60,8 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag || github.ref }} - name: Set up QEMU if: matrix.platform == 'linux/arm64' @@ -65,6 +77,15 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Compute build version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + echo "value=${{ inputs.version }}" >> "$GITHUB_OUTPUT" + else + echo "value=dev" >> "$GITHUB_OUTPUT" + fi + - name: Build and push by digest id: build uses: docker/build-push-action@v7 @@ -72,6 +93,7 @@ jobs: context: . file: ./Dockerfile platforms: ${{ matrix.platform }} + build-args: VERSION=${{ steps.version.outputs.value }} cache-from: type=gha,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }} github-token: ${{ secrets.GITHUB_TOKEN }} @@ -120,22 +142,26 @@ jobs: - name: Determine image tags id: tags run: | - if [ "${{ github.ref_name }}" = "dev" ]; then - echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev" >> "$GITHUB_OUTPUT" + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + INPUT_TAG="${{ inputs.tag }}" + if [ -n "$INPUT_TAG" ]; then + echo "tag_args=-t ${IMAGE}:latest -t ${IMAGE}:${INPUT_TAG}" >> "$GITHUB_OUTPUT" + echo "inspect_tag=${IMAGE}:latest" >> "$GITHUB_OUTPUT" else - echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT" + echo "tag_args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT" + echo "inspect_tag=${IMAGE}:dev" >> "$GITHUB_OUTPUT" fi - name: Create and push multi-arch manifest working-directory: /tmp/digests run: | docker buildx imagetools create \ - -t ${{ steps.tags.outputs.tags }} \ + ${{ steps.tags.outputs.tag_args }} \ $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) - name: Inspect image run: | - docker buildx imagetools inspect ${{ steps.tags.outputs.tags }} + docker buildx imagetools inspect ${{ steps.tags.outputs.inspect_tag }} - name: Ensure package is public run: | @@ -162,6 +188,8 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag || github.ref }} - name: Set up QEMU uses: docker/setup-qemu-action@v4 @@ -176,13 +204,30 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Determine CPU image tag - id: cpu-tag + - name: Determine CPU image tags + id: cpu-tags run: | - if [ "${{ github.ref_name }}" = "dev" ]; then - echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-cpu" >> "$GITHUB_OUTPUT" + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + INPUT_TAG="${{ inputs.tag }}" + if [ -n "$INPUT_TAG" ]; then + { + echo "tags<> "$GITHUB_OUTPUT" + echo "inspect_tag=${IMAGE}:cpu" >> "$GITHUB_OUTPUT" else - echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cpu" >> "$GITHUB_OUTPUT" + echo "tags=${IMAGE}:dev-cpu" >> "$GITHUB_OUTPUT" + echo "inspect_tag=${IMAGE}:dev-cpu" >> "$GITHUB_OUTPUT" + fi + + - name: Compute build version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + echo "value=${{ inputs.version }}" >> "$GITHUB_OUTPUT" + else + echo "value=dev" >> "$GITHUB_OUTPUT" fi - name: Build and push CPU image @@ -191,16 +236,18 @@ jobs: context: . file: ./Dockerfile platforms: linux/amd64,linux/arm64 - build-args: VARIANT=cpu + build-args: | + VARIANT=cpu + VERSION=${{ steps.version.outputs.value }} cache-from: type=gha,scope=cpu cache-to: type=gha,mode=max,scope=cpu github-token: ${{ secrets.GITHUB_TOKEN }} push: true - tags: ${{ steps.cpu-tag.outputs.tag }} + tags: ${{ steps.cpu-tags.outputs.tags }} - name: Inspect CPU image run: | - docker buildx imagetools inspect ${{ steps.cpu-tag.outputs.tag }} + docker buildx imagetools inspect ${{ steps.cpu-tags.outputs.inspect_tag }} - name: Ensure package is public run: | @@ -227,6 +274,8 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag || github.ref }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -238,13 +287,30 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Determine ROCm image tag - id: rocm-tag + - name: Determine ROCm image tags + id: rocm-tags run: | - if [ "${{ github.ref_name }}" = "dev" ]; then - echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-rocm" >> "$GITHUB_OUTPUT" + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + INPUT_TAG="${{ inputs.tag }}" + if [ -n "$INPUT_TAG" ]; then + { + echo "tags<> "$GITHUB_OUTPUT" + echo "inspect_tag=${IMAGE}:rocm" >> "$GITHUB_OUTPUT" else - echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:rocm" >> "$GITHUB_OUTPUT" + echo "tags=${IMAGE}:dev-rocm" >> "$GITHUB_OUTPUT" + echo "inspect_tag=${IMAGE}:dev-rocm" >> "$GITHUB_OUTPUT" + fi + + - name: Compute build version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + echo "value=${{ inputs.version }}" >> "$GITHUB_OUTPUT" + else + echo "value=dev" >> "$GITHUB_OUTPUT" fi - name: Build and push ROCm image @@ -253,16 +319,18 @@ jobs: context: . file: ./Dockerfile platforms: linux/amd64 - build-args: VARIANT=rocm + build-args: | + VARIANT=rocm + VERSION=${{ steps.version.outputs.value }} cache-from: type=gha,scope=linux/amd64-rocm cache-to: type=gha,mode=max,scope=linux/amd64-rocm github-token: ${{ secrets.GITHUB_TOKEN }} push: true - tags: ${{ steps.rocm-tag.outputs.tag }} + tags: ${{ steps.rocm-tags.outputs.tags }} - name: Inspect ROCm image run: | - docker buildx imagetools inspect ${{ steps.rocm-tag.outputs.tag }} + docker buildx imagetools inspect ${{ steps.rocm-tags.outputs.inspect_tag }} - name: Ensure package is public run: | @@ -289,6 +357,8 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag || github.ref }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -300,13 +370,30 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Determine Intel image tag - id: intel-tag + - name: Determine Intel image tags + id: intel-tags run: | - if [ "${{ github.ref_name }}" = "dev" ]; then - echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-intel" >> "$GITHUB_OUTPUT" + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + INPUT_TAG="${{ inputs.tag }}" + if [ -n "$INPUT_TAG" ]; then + { + echo "tags<> "$GITHUB_OUTPUT" + echo "inspect_tag=${IMAGE}:intel" >> "$GITHUB_OUTPUT" else - echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:intel" >> "$GITHUB_OUTPUT" + echo "tags=${IMAGE}:dev-intel" >> "$GITHUB_OUTPUT" + echo "inspect_tag=${IMAGE}:dev-intel" >> "$GITHUB_OUTPUT" + fi + + - name: Compute build version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + echo "value=${{ inputs.version }}" >> "$GITHUB_OUTPUT" + else + echo "value=dev" >> "$GITHUB_OUTPUT" fi - name: Build and push Intel image @@ -315,16 +402,18 @@ jobs: context: . file: ./Dockerfile platforms: linux/amd64 - build-args: VARIANT=intel + build-args: | + VARIANT=intel + VERSION=${{ steps.version.outputs.value }} cache-from: type=gha,scope=linux/amd64-intel cache-to: type=gha,mode=max,scope=linux/amd64-intel github-token: ${{ secrets.GITHUB_TOKEN }} push: true - tags: ${{ steps.intel-tag.outputs.tag }} + tags: ${{ steps.intel-tags.outputs.tags }} - name: Inspect Intel image run: | - docker buildx imagetools inspect ${{ steps.intel-tag.outputs.tag }} + docker buildx imagetools inspect ${{ steps.intel-tags.outputs.inspect_tag }} - name: Ensure package is public run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d27f136..63fa80b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -127,188 +127,14 @@ jobs: prerelease: false, }); - build-gpu: - name: Build GPU image + build-images: + name: Build and push Docker images needs: release - runs-on: ubuntu-latest + uses: ./.github/workflows/docker-publish.yml + with: + tag: ${{ needs.release.outputs.tag }} + version: ${{ needs.release.outputs.version }} + secrets: inherit permissions: packages: write - steps: - - name: Free up disk space - run: | - sudo rm -rf /usr/share/dotnet - sudo rm -rf /opt/ghc - sudo rm -rf "/usr/local/share/boost" - sudo rm -rf "$AGENT_TOOLSDIRECTORY" - echo "Disk space freed." - - - name: Checkout - uses: actions/checkout@v6 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Log in to GHCR - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push GPU image (latest) - uses: docker/build-push-action@v7 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - build-args: VERSION=${{ needs.release.outputs.version }} - cache-from: type=gha,scope=release-gpu - cache-to: type=gha,mode=max,scope=release-gpu - tags: | - ghcr.io/sudolulo/winnow:latest - ghcr.io/sudolulo/winnow:${{ needs.release.outputs.tag }} - - build-cpu: - name: Build CPU image - needs: release - runs-on: ubuntu-latest - permissions: - packages: write - steps: - - name: Free up disk space - run: | - sudo rm -rf /usr/share/dotnet - sudo rm -rf /opt/ghc - sudo rm -rf "/usr/local/share/boost" - sudo rm -rf "$AGENT_TOOLSDIRECTORY" - echo "Disk space freed." - - - name: Checkout - uses: actions/checkout@v6 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Log in to GHCR - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push CPU image - uses: docker/build-push-action@v7 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - build-args: | - VARIANT=cpu - VERSION=${{ needs.release.outputs.version }} - cache-from: type=gha,scope=release-cpu - cache-to: type=gha,mode=max,scope=release-cpu - tags: | - ghcr.io/sudolulo/winnow:cpu - ghcr.io/sudolulo/winnow:${{ needs.release.outputs.tag }}-cpu - - build-rocm: - name: Build ROCm image - needs: release - runs-on: ubuntu-latest - permissions: - packages: write - steps: - - name: Free up disk space - run: | - sudo rm -rf /usr/share/dotnet - sudo rm -rf /opt/ghc - sudo rm -rf "/usr/local/share/boost" - sudo rm -rf "$AGENT_TOOLSDIRECTORY" - echo "Disk space freed." - - - name: Checkout - uses: actions/checkout@v6 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Log in to GHCR - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push ROCm image - uses: docker/build-push-action@v7 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64 - push: true - build-args: | - VARIANT=rocm - VERSION=${{ needs.release.outputs.version }} - cache-from: type=gha,scope=release-rocm - cache-to: type=gha,mode=max,scope=release-rocm - tags: | - ghcr.io/sudolulo/winnow:rocm - ghcr.io/sudolulo/winnow:${{ needs.release.outputs.tag }}-rocm - - build-intel: - name: Build Intel image - needs: release - runs-on: ubuntu-latest - permissions: - packages: write - steps: - - name: Free up disk space - run: | - sudo rm -rf /usr/share/dotnet - sudo rm -rf /opt/ghc - sudo rm -rf "/usr/local/share/boost" - sudo rm -rf "$AGENT_TOOLSDIRECTORY" - echo "Disk space freed." - - - name: Checkout - uses: actions/checkout@v6 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Log in to GHCR - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push Intel image - uses: docker/build-push-action@v7 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64 - push: true - build-args: | - VARIANT=intel - VERSION=${{ needs.release.outputs.version }} - cache-from: type=gha,scope=release-intel - cache-to: type=gha,mode=max,scope=release-intel - tags: | - ghcr.io/sudolulo/winnow:intel - ghcr.io/sudolulo/winnow:${{ needs.release.outputs.tag }}-intel + contents: read From 39111d3a6aff9dacea68d85c5dada59ababa0f31 Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 22:14:07 +0000 Subject: [PATCH 3/7] Downgrade GPU base image to CUDA 12.8.1; add benchmark script CUDA 13.3 requires driver >= 575 but the host only has 570 (error 804). CUDA 12.8.1 is the highest version supported by driver 570 and works correctly with the NVIDIA Container Toolkit. Add scripts/benchmark.py to measure InsightFace + SigLIP latency and throughput across GPU and CPU modes. Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 4 +- scripts/benchmark.py | 195 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 scripts/benchmark.py diff --git a/Dockerfile b/Dockerfile index a621a9c..1739d08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ── Base images ─────────────────────────────────────────────────────────────── -# amd64 + gpu: NVIDIA CUDA 13.3 + cuDNN (GPU acceleration via NVIDIA Container Toolkit) +# amd64 + gpu: NVIDIA CUDA 12.8 + cuDNN (GPU acceleration via NVIDIA Container Toolkit) # amd64 + rocm: Ubuntu 22.04 (AMD GPU via ROCm — pass /dev/kfd and /dev/dri) # amd64 + intel: Ubuntu 22.04 (Intel Arc / iGPU via OpenVINO — pass /dev/dri) # amd64 + cpu: Ubuntu 22.04 (CPU-only, ~2 GB smaller image) @@ -7,7 +7,7 @@ ARG VARIANT=gpu -FROM --platform=$BUILDPLATFORM nvidia/cuda:13.3.0-cudnn-runtime-ubuntu22.04 AS base-amd64-gpu +FROM --platform=$BUILDPLATFORM nvidia/cuda:12.8.1-cudnn-runtime-ubuntu22.04 AS base-amd64-gpu FROM ubuntu:22.04 AS base-amd64-rocm FROM ubuntu:22.04 AS base-amd64-intel FROM ubuntu:22.04 AS base-amd64-cpu diff --git a/scripts/benchmark.py b/scripts/benchmark.py new file mode 100644 index 0000000..1257216 --- /dev/null +++ b/scripts/benchmark.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +winnow inference benchmark: GPU vs CPU throughput. + +Measures InsightFace (face mode) and SigLIP (object mode) latency and +throughput. Run with FORCE_CPU=true for CPU-only baseline. + +Usage inside container: + # GPU mode: + docker exec winnow python /app/scripts/benchmark.py + + # CPU mode: + docker exec -e FORCE_CPU=true winnow python /app/scripts/benchmark.py +""" + +import os +import sys +import time + +import numpy as np +from PIL import Image, ImageDraw + + +def _mode_label() -> str: + if os.getenv("FORCE_CPU", "").lower() in ("true", "1", "yes"): + return "CPU (FORCE_CPU=true)" + return "GPU (auto)" + + +def make_face_image(size: int = 640) -> Image.Image: + """Synthetic face-like image: skin-tone rectangle with landmark blobs.""" + img = Image.new("RGB", (size, size), (200, 170, 140)) + draw = ImageDraw.Draw(img) + # Head oval + cx, cy = size // 2, size // 2 + hw, hh = int(size * 0.3), int(size * 0.38) + draw.ellipse([cx - hw, cy - hh, cx + hw, cy + hh], fill=(220, 185, 155)) + # Eyes + for ex in [cx - int(size * 0.1), cx + int(size * 0.1)]: + ey = cy - int(size * 0.05) + r = max(4, size // 40) + draw.ellipse([ex - r, ey - r, ex + r, ey + r], fill=(40, 30, 20)) + # Nose + draw.ellipse([cx - 5, cy + 5, cx + 5, cy + 15], fill=(180, 140, 110)) + # Mouth + draw.arc([cx - 20, cy + 25, cx + 20, cy + 45], start=0, end=180, fill=(160, 80, 80), width=3) + return img + + +def make_random_image(width: int = 224, height: int = 224) -> Image.Image: + rng = np.random.default_rng(42) + return Image.fromarray(rng.integers(0, 256, (height, width, 3), dtype=np.uint8), "RGB") + + +def _stats(times_s: list[float]) -> dict: + arr = np.array(times_s) * 1000 # ms + return { + "median_ms": float(np.median(arr)), + "mean_ms": float(np.mean(arr)), + "min_ms": float(np.min(arr)), + "p95_ms": float(np.percentile(arr, 95)), + "ips": 1000.0 / float(np.median(arr)), + } + + +def bench_insightface(n_warmup: int = 5, n_runs: int = 30) -> None: + import cv2 + from winnow.embeddings import get_insightface_app, _insightface_app, _insightface_loaded + + # Reset singleton so we get a fresh load + import winnow.embeddings as emb_mod + emb_mod._insightface_app = None + emb_mod._insightface_loaded = False + + print(" Loading model...") + t_load = time.perf_counter() + app = get_insightface_app() + load_s = time.perf_counter() - t_load + + if app is None: + print(" SKIP: InsightFace failed to load") + return + + img_pil = make_face_image(640) + img_bgr = cv2.cvtColor(np.asarray(img_pil), cv2.COLOR_RGB2BGR) + + # Warmup + for _ in range(n_warmup): + app.get(img_bgr) + + # Timed — single image 640×640 + times: list[float] = [] + for _ in range(n_runs): + t0 = time.perf_counter() + app.get(img_bgr) + times.append(time.perf_counter() - t0) + + s = _stats(times) + print(f" Model load time : {load_s:.2f} s") + print(f" Input size : 640×640") + print(f" Runs : {n_runs} (after {n_warmup} warmup)") + print(f" Median latency : {s['median_ms']:.1f} ms") + print(f" Mean / p95 : {s['mean_ms']:.1f} ms / {s['p95_ms']:.1f} ms") + print(f" Min latency : {s['min_ms']:.1f} ms") + print(f" Throughput : {s['ips']:.1f} images/s") + + # Also test at 320×320 + img_sm = make_face_image(320) + img_sm_bgr = cv2.cvtColor(np.asarray(img_sm), cv2.COLOR_RGB2BGR) + for _ in range(n_warmup): + app.get(img_sm_bgr) + times_sm: list[float] = [] + for _ in range(n_runs): + t0 = time.perf_counter() + app.get(img_sm_bgr) + times_sm.append(time.perf_counter() - t0) + s2 = _stats(times_sm) + print(f" 320×320 median : {s2['median_ms']:.1f} ms ({s2['ips']:.1f} img/s)") + + +def bench_siglip( + n_warmup: int = 3, + n_runs: int = 20, + batch_sizes: tuple = (1, 4, 8, 16, 32), +) -> None: + import torch + + import winnow.embeddings as emb_mod + emb_mod._siglip_model = None + emb_mod._siglip_processor = None + emb_mod._siglip_loaded = False + + print(" Loading model...") + t_load = time.perf_counter() + model, processor = emb_mod.get_siglip_model() + load_s = time.perf_counter() - t_load + + if model is None: + print(" SKIP: SigLIP failed to load") + return + + device = next(model.parameters()).device + print(f" Model load time : {load_s:.2f} s (device: {device})") + + print(f" {'Batch':>5} {'ms/batch':>10} {'ms/img':>8} {'img/s':>8} {'p95/img':>9}") + for bs in batch_sizes: + imgs = [make_random_image(224, 224) for _ in range(bs)] + inputs = processor(images=imgs, return_tensors="pt") + inputs = {k: v.to(device) for k, v in inputs.items()} + + # Warmup + for _ in range(n_warmup): + with torch.no_grad(): + model(**inputs) + if str(device) != "cpu": + torch.cuda.synchronize() + + times: list[float] = [] + for _ in range(n_runs): + if str(device) != "cpu": + torch.cuda.synchronize() + t0 = time.perf_counter() + with torch.no_grad(): + model(**inputs) + if str(device) != "cpu": + torch.cuda.synchronize() + times.append(time.perf_counter() - t0) + + s = _stats(times) + print( + f" {bs:>5} {s['median_ms']:>10.1f} {s['median_ms']/bs:>8.2f}" + f" {bs * 1000 / s['median_ms']:>8.1f} {s['p95_ms']/bs:>9.2f}" + ) + + +def main() -> None: + print("=" * 56) + print(f" winnow inference benchmark") + print(f" Mode: {_mode_label()}") + print("=" * 56) + print() + + print("── InsightFace Buffalo_L (face detection + ArcFace) ──") + bench_insightface() + print() + + print("── SigLIP google/siglip-base-patch16-224 (objects) ───") + bench_siglip() + print() + + +if __name__ == "__main__": + # Add winnow to path when run directly inside container + sys.path.insert(0, "/app") + main() From 67f18456878baa1819c571ccf6ea4a350a48c7c4 Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 22:16:54 +0000 Subject: [PATCH 4/7] Fix ruff lint errors in benchmark.py Remove unused imports, fix unsorted imports, remove bare f-strings. Co-Authored-By: Claude Sonnet 4.6 --- scripts/benchmark.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/benchmark.py b/scripts/benchmark.py index 1257216..2f384e5 100644 --- a/scripts/benchmark.py +++ b/scripts/benchmark.py @@ -65,10 +65,11 @@ def _stats(times_s: list[float]) -> dict: def bench_insightface(n_warmup: int = 5, n_runs: int = 30) -> None: import cv2 - from winnow.embeddings import get_insightface_app, _insightface_app, _insightface_loaded + + import winnow.embeddings as emb_mod + from winnow.embeddings import get_insightface_app # Reset singleton so we get a fresh load - import winnow.embeddings as emb_mod emb_mod._insightface_app = None emb_mod._insightface_loaded = False @@ -97,7 +98,7 @@ def bench_insightface(n_warmup: int = 5, n_runs: int = 30) -> None: s = _stats(times) print(f" Model load time : {load_s:.2f} s") - print(f" Input size : 640×640") + print(" Input size : 640×640") print(f" Runs : {n_runs} (after {n_warmup} warmup)") print(f" Median latency : {s['median_ms']:.1f} ms") print(f" Mean / p95 : {s['mean_ms']:.1f} ms / {s['p95_ms']:.1f} ms") @@ -175,7 +176,7 @@ def bench_siglip( def main() -> None: print("=" * 56) - print(f" winnow inference benchmark") + print(" winnow inference benchmark") print(f" Mode: {_mode_label()}") print("=" * 56) print() From 62bc1b70c557fed8468b620b9c36e1b65919342a Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 22:19:06 +0000 Subject: [PATCH 5/7] chore: bump version to 0.4.2, update changelog CUDA base image downgraded to 12.8.1 (driver 570 compatibility fix), benchmark script added. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e25717..c4287b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.2] - 2026-06-13 + +### Changed + +- **GPU image now uses CUDA 12.8.1** (was 13.3): CUDA 13.3 requires driver ≥ 575; driver 570 (the current stable release) was incorrectly rejected with "CUDA driver version is insufficient" at startup. The `:latest` image now works with any NVIDIA driver ≥ 570. + +### Added + +- **`scripts/benchmark.py`**: measures InsightFace and SigLIP inference latency and throughput across GPU and CPU modes. Run inside the container with `python /app/scripts/benchmark.py`. RTX 2070 SUPER results: InsightFace 12.8 ms / 78 img/s (8× CPU), SigLIP batch 32 at 5.4 ms/img / 187 img/s (33× CPU). + ## [0.4.1] - 2026-06-13 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 6765705..6fac13e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "winnow" -version = "0.4.1" +version = "0.4.2" description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition and object classification." license = "AGPL-3.0-or-later" requires-python = ">=3.13" From b804b13644dbc97166f3aa93fd30ed5f4d6be483 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:19:49 +0000 Subject: [PATCH 6/7] chore: update lockfiles --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 7953cde..d46b348 100644 --- a/uv.lock +++ b/uv.lock @@ -2348,7 +2348,7 @@ wheels = [ [[package]] name = "winnow" -version = "0.4.1" +version = "0.4.2" source = { editable = "." } dependencies = [ { name = "croniter" }, From 3888a5e6db27ddeaed66b236726bdf8658e96493 Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 22:28:33 +0000 Subject: [PATCH 7/7] docs: disclose AI-assisted development in CONTRIBUTING.md --- CONTRIBUTING.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06ff278..943f2c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,10 @@ CI runs both on every push and PR to `main` and `dev`. PRs must pass before merg - Keep the `CHANGELOG.md` entry in the `[Unreleased]` section updated. - Commit messages should be plain English describing what changed and why. +## Development Tooling + +Development uses Claude Code (Anthropic) for implementation assistance. All code is reviewed and the final call on design, behavior, and what ships is made by the maintainer. Contributions from humans are equally welcome. + ## License By submitting a contribution you agree that your work will be released under the project's [AGPLv3+ license](LICENSE).