Skip to content

Commit 1915d17

Browse files
committed
ci: the benchmarks of a pull request held to those of its base
bench.yml runs the overhead and memory blocks of the base and of the head in one job, and benchmarks/gate.py compares them. Memory is held exactly: tracemalloc's peak of each load within 1 KiB of the base's, so a new copy in Python fails, and the peak resident rise of a batch within 1.1 times libphash's estimate. Time is held relatively, since a shared runner's speed varies: each cell over the C cell of its table, against the base's ratio. A growth past 15 % for a call whose C cell is under 10 us, or 5 % for a whole load or hash, is a warning for now; --fail-timing fails past 25 % or 10 % once the noise of the runners is known. Parallelism and process pools are published, not gated.
1 parent 62b1b66 commit 1915d17

5 files changed

Lines changed: 525 additions & 2 deletions

File tree

‎.github/workflows/bench.yml‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
name: Benchmarks
2+
3+
# A pull request's benchmarks against those of its base, in one job on one runner
4+
# (benchmarks/gate.py): the Python allocations of every load must stay as they are and a
5+
# batch within libphash's memory bound, and the cost of each call over C is compared
6+
# with the base's, as a warning. The results of both sides and the summary are uploaded.
7+
# Parallelism and process pools are not gated: on a shared runner they measure the
8+
# runner. The workflow runs on a pull request that changes what is measured, and by
9+
# hand against a chosen base.
10+
on:
11+
pull_request:
12+
branches: [ main, 'release/**' ]
13+
paths:
14+
- src/libphash/**
15+
- tools/cdef.py
16+
- tools/ffi.py
17+
- tools/native.py
18+
- native/libphash
19+
- benchmarks/**
20+
- setup.py
21+
- pyproject.toml
22+
- uv.lock
23+
- .github/workflows/bench.yml
24+
workflow_dispatch:
25+
inputs:
26+
base:
27+
description: The commit, branch or tag to compare with.
28+
required: true
29+
30+
permissions:
31+
contents: read
32+
33+
concurrency:
34+
group: ${{ github.workflow }}-${{ github.ref }}
35+
cancel-in-progress: true
36+
37+
jobs:
38+
gate:
39+
runs-on: ubuntu-24.04
40+
timeout-minutes: 90
41+
env:
42+
LIBPHASH_REQUIRE_C_REFERENCE: '1'
43+
steps:
44+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
45+
with:
46+
persist-credentials: false
47+
fetch-depth: 0
48+
49+
- uses: ./.github/actions/setup
50+
51+
- name: Check out the base
52+
id: base
53+
env:
54+
BASE: ${{ github.event.pull_request.base.sha || inputs.base }}
55+
run: |
56+
git worktree add "$RUNNER_TEMP/base" "$BASE"
57+
if [ ! -d "$RUNNER_TEMP/base/benchmarks" ]; then
58+
echo "::notice::the base has no benchmarks; nothing to compare with"
59+
echo "skip=true" >> "$GITHUB_OUTPUT"
60+
exit 0
61+
fi
62+
git -C "$RUNNER_TEMP/base" submodule update --init native/libphash
63+
git -C "$RUNNER_TEMP/base/native/libphash" submodule update --init --recursive --depth 1
64+
65+
# Each side builds and measures itself with its own harness; a case only one side
66+
# has is skipped by the gate.
67+
- name: Benchmark the base
68+
if: steps.base.outputs.skip != 'true'
69+
working-directory: ${{ runner.temp }}/base
70+
run: |
71+
uv sync --locked --all-extras
72+
uv run python -m benchmarks --blocks overhead,memory --out "$RUNNER_TEMP/results" --name base
73+
74+
- name: Benchmark the head
75+
if: steps.base.outputs.skip != 'true'
76+
run: |
77+
uv sync --locked --all-extras
78+
uv run python -m benchmarks --blocks overhead,memory --out "$RUNNER_TEMP/results" --name head
79+
80+
- name: Gate
81+
if: steps.base.outputs.skip != 'true'
82+
run: |
83+
status=0
84+
uv run python -m benchmarks.gate "$RUNNER_TEMP/results/base.json" \
85+
"$RUNNER_TEMP/results/head.json" --summary "$RUNNER_TEMP/results/gate.md" || status=$?
86+
cat "$RUNNER_TEMP/results/gate.md" >> "$GITHUB_STEP_SUMMARY"
87+
exit $status
88+
89+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
90+
if: always() && steps.base.outputs.skip != 'true'
91+
with:
92+
name: benchmarks
93+
path: ${{ runner.temp }}/results/
94+
if-no-files-found: ignore
95+
retention-days: 30

‎CLAUDE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ Typing: the package passes mypy and basedpyright in strict mode, and every type
7373
Lint: ruff with a broad rule set and pep257 docstrings (`pyproject.toml`); every exception to a rule is a per-file ignore with a comment or a `# noqa: CODE -- reason`, never a bare `noqa`.
7474
- **`tests/`** — pytest suite (`test_cdef.py` fails when a header function is neither wrapped nor listed as deliberately unwrapped).
7575
`test_c_parity.py` builds `tests/c/reference.c` (through `c_reference.py`) against the libphash install of `tools/native.py` and requires the binding's hashes and error codes to equal C's for every fixture, setting and pixel layout; without the install or CMake it skips, and CI sets `LIBPHASH_REQUIRE_C_REFERENCE=1` so that it fails instead. `test_corpus.py` does the same for libphash's 200-photo site corpus and is collected only when `LIBPHASH_CORPUS` names the list of fetched photos. `test_memory.py` repeats every path (success, errors, GC, batch, callbacks) and bounds the growth of Python objects (tracemalloc) and resident memory (psutil), and holds the peak of a batch to libphash's estimate (4 bytes per pixel and the file, per worker); under a sanitizer it skips, and LeakSanitizer checks the C side.
76-
- **`benchmarks/`** — what the binding costs over libphash and which way of using it is fastest or leanest; never other libraries. `python -m benchmarks [--blocks …] [--quick]` runs five blocks (`overhead`: one call in C, through cffi and through the API; `search`: 1 to N and N to N in Python loops, numpy and C; `memory`: copies of each load, contexts, a batch against its threads, each case in a fresh interpreter; `parallel`: threads, `hash_files`, process pools of each start method, asyncio lag, callbacks, subinterpreters; `startup`: import time and size) and writes JSON and Markdown with the environment on top. Timing follows libphash's site: a warm-up run, then at least 5 runs and 1 s, minimum and median. `benchmarks/c/bench.c` is the C side, built against the install of `tools/native.py` like the parity reference. `benchmarks/results/` holds measured runs, named `<platform>-<machine>-<python tag>`. `tests/test_benchmarks.py` runs every block in the quick mode.
77-
- **`.github/workflows/`** — `ci.yml` (lint, types, zizmor and pip-audit, tests on every platform, their line and branch coverage combined and held at 100 %, sdist, the wheel with cffi at its lower bound, the test suite under ASan/UBSan/LSan and under TSan, a minute of each fuzz target) on every push and PR; `fuzz.yml` (a quarter of an hour per fuzz target) weekly, by hand and when a target or the corpus changes; `wheels.yml` when a build file changes and from `release.yml`: every wheel by cibuildwheel (configuration in `pyproject.toml`) and the sdist, each audited by `tools/audit.py` and twine, the sdist built twice and compared and built offline, each wheel's hash table (`tools/hash_table.py`) compared across all platforms byte for byte, and a build with JPEG decoded by stb_image; `release.yml` on a tag `vX.Y.Z.N`: checks that the tag is signed and names the version, the dated CHANGELOG entry and the submodule's tag, then publishes the artifacts of `wheels.yml` to PyPI (Trusted Publishing, attestations, environment `pypi`; a `rcK` tag to TestPyPI) and to a GitHub Release with the SBOM, checksums and build provenance, and verifies what PyPI serves; `corpus.yml` (the photo corpus, network) weekly, by hand and when the submodule changes; `mutation.yml` (mutmut over `src/libphash/`) weekly, by hand and when the package changes: every mutant is caught or listed in `tests/equivalent_mutants.json` with its reason, and `tools/mutants.py` fails on any other survivor and on a listed mutant that no longer survives. `libphash-release-check.yml` opens a tracking issue weekly when `native/libphash` is behind libphash's newest release tag.
76+
- **`benchmarks/`** — what the binding costs over libphash and which way of using it is fastest or leanest; never other libraries. `python -m benchmarks [--blocks …] [--quick]` runs five blocks (`overhead`: one call in C, through cffi and through the API; `search`: 1 to N and N to N in Python loops, numpy and C; `memory`: copies of each load, contexts, a batch against its threads, each case in a fresh interpreter; `parallel`: threads, `hash_files`, process pools of each start method, asyncio lag, callbacks, subinterpreters; `startup`: import time and size) and writes JSON and Markdown with the environment on top. Timing follows libphash's site: a warm-up run, then at least 5 runs and 1 s, minimum and median. `benchmarks/c/bench.c` is the C side, built against the install of `tools/native.py` like the parity reference. `benchmarks/results/` holds measured runs, named `<platform>-<machine>-<python tag>`. `tests/test_benchmarks.py` runs every block in the quick mode. `benchmarks/gate.py` holds a pull request to its base: the Python allocations of the memory block within 1 KiB and a batch within 1.1 times libphash's bound fail; each timing over its table's C cell, against the base's ratio, warns past 15 % (a C cell under 10 µs) or 5 %, and with `--fail-timing` fails past 25 % or 10 %.
77+
- **`.github/workflows/`** — `ci.yml` (lint, types, zizmor and pip-audit, tests on every platform, their line and branch coverage combined and held at 100 %, sdist, the wheel with cffi at its lower bound, the test suite under ASan/UBSan/LSan and under TSan, a minute of each fuzz target) on every push and PR; `fuzz.yml` (a quarter of an hour per fuzz target) weekly, by hand and when a target or the corpus changes; `wheels.yml` when a build file changes and from `release.yml`: every wheel by cibuildwheel (configuration in `pyproject.toml`) and the sdist, each audited by `tools/audit.py` and twine, the sdist built twice and compared and built offline, each wheel's hash table (`tools/hash_table.py`) compared across all platforms byte for byte, and a build with JPEG decoded by stb_image; `release.yml` on a tag `vX.Y.Z.N`: checks that the tag is signed and names the version, the dated CHANGELOG entry and the submodule's tag, then publishes the artifacts of `wheels.yml` to PyPI (Trusted Publishing, attestations, environment `pypi`; a `rcK` tag to TestPyPI) and to a GitHub Release with the SBOM, checksums and build provenance, and verifies what PyPI serves; `bench.yml` on a pull request that changes what is measured: the overhead and memory blocks of its base and of its head in one job, compared by `benchmarks/gate.py`; `corpus.yml` (the photo corpus, network) weekly, by hand and when the submodule changes; `mutation.yml` (mutmut over `src/libphash/`) weekly, by hand and when the package changes: every mutant is caught or listed in `tests/equivalent_mutants.json` with its reason, and `tools/mutants.py` fails on any other survivor and on a listed mutant that no longer survives. `libphash-release-check.yml` opens a tracking issue weekly when `native/libphash` is behind libphash's newest release tag.
7878

7979
Code, comments, docstrings and tracked documentation are in English.
8080

‎CONTRIBUTING.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ uv run typos
6464
it for every fixture, setting and pixel layout. It needs CMake; without it the tests
6565
skip locally and fail in CI.
6666
- **A change to the stub's inputs regenerates it**: `uv run python tools/stub.py`.
67+
- **No new copy in Python, no slower call.** A pull request that changes the package
68+
or the build runs the benchmarks of its base and of its head on one runner
69+
(`.github/workflows/bench.yml`). The Python allocations of every load must stay as
70+
they are, and a batch within libphash's memory bound; a call whose cost over C grows
71+
is reported as a warning. Locally, against a checkout of the base:
72+
73+
```bash
74+
uv run python -m benchmarks --blocks overhead,memory --name head --out results
75+
uv run python -m benchmarks.gate results/base.json results/head.json
76+
```
6777

6878
Sanitizer builds (AddressSanitizer with UndefinedBehaviorSanitizer, ThreadSanitizer) and
6979
the fuzz targets need Linux with Clang; `tools/sanitizers.py` and `tools/fuzz.py` say

‎benchmarks/gate.py‎

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""The regression gate: a pull request's benchmarks held to those of its base.
2+
3+
``python -m benchmarks.gate BASE.json HEAD.json [--summary FILE] [--fail-timing]``
4+
5+
Both files are written by one job on one machine, from the base of the pull request and
6+
from its head, with ``--blocks overhead,memory``. Two kinds of number are held:
7+
8+
- **Memory, exactly.** Every Python-allocation cell of the memory block (tracemalloc,
9+
the same on every run) equals the base's within :data:`MEMORY_SLACK`: a new copy in
10+
Python fails. The peak resident rise of a batch stays within :data:`BATCH_FACTOR`
11+
times libphash's bound for its threads.
12+
- **Time, against C.** A runner's speed varies from job to job and from minute to
13+
minute, so no absolute time is compared: each cell of an overhead table is divided by
14+
the C cell of its table (its baseline row, or its ``C`` column), and that ratio is
15+
compared with the base's. A ratio that grows by more than :data:`WARN_CHEAP` (a call
16+
whose C cell takes under :data:`CHEAP` seconds) or :data:`WARN_WORK` (a load or a
17+
hash of a whole image) is reported as a warning; with ``--fail-timing``, growth
18+
beyond :data:`FAIL_CHEAP` or :data:`FAIL_WORK` fails. Rows that time C itself, and
19+
tables without a C cell, are published, not gated.
20+
21+
A cell that only one side has (a case added or removed) is skipped. The exit status is
22+
1 when a check fails and 0 otherwise; GitHub annotations go to standard output, and a
23+
Markdown summary to ``--summary``.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import argparse
29+
import json
30+
import sys
31+
from dataclasses import dataclass
32+
from pathlib import Path
33+
from typing import TYPE_CHECKING, Any, cast
34+
35+
if TYPE_CHECKING:
36+
from collections.abc import Iterator, Sequence
37+
38+
#: How far a Python-allocation cell may move, in bytes: allocator rounding, not a copy.
39+
MEMORY_SLACK = 1024
40+
#: The bound on a batch's peak resident rise, as a multiple of libphash's estimate.
41+
BATCH_FACTOR = 1.1
42+
#: A C cell faster than this, in seconds, is a cheap call, where cffi's share is large.
43+
CHEAP = 10e-6
44+
WARN_CHEAP = 0.15
45+
WARN_WORK = 0.05
46+
FAIL_CHEAP = 0.25
47+
FAIL_WORK = 0.10
48+
49+
Table = dict[str, Any]
50+
51+
52+
@dataclass(frozen=True)
53+
class Ratio:
54+
"""One timed cell, against its C cell, at the base and at the head."""
55+
56+
table: str
57+
row: str
58+
column: str
59+
base: float
60+
head: float
61+
cheap: bool
62+
63+
@property
64+
def growth(self) -> float:
65+
"""Return how much the ratio grew, 0.1 for 10 %."""
66+
return self.head / self.base - 1
67+
68+
def exceeds(self, *, fail: bool) -> bool:
69+
"""Say whether the growth passes the warning or the failure threshold."""
70+
if fail:
71+
return self.growth > (FAIL_CHEAP if self.cheap else FAIL_WORK)
72+
return self.growth > (WARN_CHEAP if self.cheap else WARN_WORK)
73+
74+
75+
def tables(result: dict[str, Any], block: str) -> dict[str, Table]:
76+
"""Return the tables of one block of a result, by key."""
77+
for b in result["blocks"]:
78+
if b["key"] == block:
79+
return {t["key"]: t for t in b["tables"]}
80+
return {}
81+
82+
83+
def value(table: Table, row: str, column: str) -> float | None:
84+
"""Return a cell's value, or None where the table has no such cell."""
85+
cell = table["rows"].get(row, {}).get(column)
86+
return None if cell is None else float(cell["value"])
87+
88+
89+
def memory_failures(base: dict[str, Any], head: dict[str, Any]) -> Iterator[str]:
90+
"""Yield a line for each memory cell that breaks its bound."""
91+
before = tables(base, "memory")
92+
for key, table in tables(head, "memory").items():
93+
for column in table["columns"]:
94+
if "Python" not in column:
95+
continue
96+
for row in table["rows"]:
97+
b = value(before.get(key, {"rows": {}}), row, column)
98+
h = value(table, row, column)
99+
if b is not None and h is not None and abs(h - b) > MEMORY_SLACK:
100+
yield (
101+
f"{table['title']} / {row} / {column}: "
102+
f"{h:,.0f} B, the base {b:,.0f} B"
103+
)
104+
if key == "batch_memory":
105+
for column in table["columns"]:
106+
rise = value(table, "peak resident rise", column)
107+
bound = value(table, "libphash's bound", column)
108+
if rise is None or bound is None or rise <= BATCH_FACTOR * bound:
109+
continue
110+
yield (
111+
f"{table['title']} / {column}: peak resident rise {rise:,.0f} B, "
112+
f"over {BATCH_FACTOR} times libphash's bound of {bound:,.0f} B"
113+
)
114+
115+
116+
def ratios(base: dict[str, Any], head: dict[str, Any]) -> Iterator[Ratio]:
117+
"""Yield each timed cell of the overhead block that both sides have."""
118+
before = tables(base, "overhead")
119+
for key, table in tables(head, "overhead").items():
120+
if table["unit"] != "s" or key not in before:
121+
continue
122+
for row in table["rows"]:
123+
if row == "C" or row.startswith("C:"):
124+
continue
125+
for column in table["columns"]:
126+
if column == "C":
127+
continue
128+
b, h = (_ratio(t, row, column) for t in (before[key], table))
129+
if b is None or h is None:
130+
continue
131+
yield Ratio(table["title"], row, column, b[0], h[0], b[1] < CHEAP)
132+
133+
134+
def _ratio(table: Table, row: str, column: str) -> tuple[float, float] | None:
135+
"""Return a cell over its C cell, and the C cell; None without either."""
136+
if table["baseline_row"]:
137+
if row == table["baseline_row"]:
138+
return None
139+
reference = value(table, table["baseline_row"], column)
140+
elif "C" in table["columns"]:
141+
reference = value(table, row, "C")
142+
else:
143+
return None
144+
cell = value(table, row, column)
145+
if cell is None or not reference:
146+
return None
147+
return cell / reference, reference
148+
149+
150+
def main(argv: Sequence[str] | None = None) -> int:
151+
"""Compare the two results; return 1 when a check fails."""
152+
parser = argparse.ArgumentParser(
153+
prog="python -m benchmarks.gate",
154+
description="Hold the benchmarks of a pull request to those of its base.",
155+
)
156+
parser.add_argument("base", type=Path, help="the base's results (JSON)")
157+
parser.add_argument("head", type=Path, help="the head's results (JSON)")
158+
parser.add_argument("--summary", type=Path, help="write a Markdown summary here")
159+
parser.add_argument(
160+
"--fail-timing", action="store_true", help="fail on timing, not only warn"
161+
)
162+
args = parser.parse_args(argv)
163+
base = cast("dict[str, Any]", json.loads(args.base.read_text(encoding="utf-8")))
164+
head = cast("dict[str, Any]", json.loads(args.head.read_text(encoding="utf-8")))
165+
166+
memory = list(memory_failures(base, head))
167+
timed = list(ratios(base, head))
168+
failing = [r for r in timed if args.fail_timing and r.exceeds(fail=True)]
169+
slower = [r for r in timed if r.exceeds(fail=False)]
170+
171+
for line in memory:
172+
print(f"::error title=Memory::{line}") # noqa: T201 -- an annotation
173+
for r in slower:
174+
level = "error" if r in failing else "warning"
175+
print( # noqa: T201 -- an annotation
176+
f"::{level} title=Time::{r.table} / {r.row} / {r.column}: "
177+
f"{r.growth:+.0%} against C"
178+
)
179+
if args.summary:
180+
args.summary.write_text(_summary(memory, timed, slower), encoding="utf-8")
181+
return 1 if memory or failing else 0
182+
183+
184+
def _summary(memory: list[str], timed: list[Ratio], slower: list[Ratio]) -> str:
185+
lines = ["## Benchmarks against the base", "", "### Memory", ""]
186+
lines += [f"- {m}" for m in memory] or ["Every Python allocation as at the base."]
187+
lines += ["", "### Time against C", ""]
188+
if slower:
189+
lines += ["| Table | Row | Column | Growth |", "|---|---|---|---|"]
190+
lines += [
191+
f"| {r.table} | {r.row} | {r.column} | {r.growth:+.1%} |" for r in slower
192+
]
193+
else:
194+
lines.append(f"No ratio of the {len(timed)} grew past its threshold.")
195+
return "\n".join(lines) + "\n"
196+
197+
198+
if __name__ == "__main__":
199+
sys.exit(main())

0 commit comments

Comments
 (0)