Benchmarking¶
Sipi uses Google Benchmark for in-process microbenchmarks of the production codec and the IIIF parser front door. The suite exists to enforce one convention:
No benchmark, no hot-path change. Before changing any image decode/encode hot path (
src/formats/*,SipiImageread/write,iiifparser), a microbenchmark must exist — add one if it doesn't — and the change is justified with a before/after comparison in the PR.
The suite was motivated by Ruven Pillay's Evaluating IIIF Server
Performance (IIIF Annual Conference 2026,
docs/benchmarks/01_Ruven_Pillay_Evaluating_IIIF_Server_Performance.pdf),
which benchmarked SIPI as the slowest server tested for TIFF and JPEG. The
microbenchmarks measure intra-SIPI per-stage cost — what can be measured
rigorously and locally; the PDF's cross-server, end-to-end HTTP comparison
is deliberately out of scope (apples-to-oranges across machines and OSes).
The tiers¶
One manual-tagged cc_binary per pipeline stage, named //src:<tier>_benchmark:
| Tier | Source | What it times |
|---|---|---|
parse |
src/iiifparser/parse_benchmark.cpp |
The five IIIF URL component parsers + the full per-request parse. Pure CPU, no fixtures. |
decode |
src/formats/decode_benchmark.cpp |
SipiImage::read() across the input-format matrix (tiled-pyramid TIFF none/zstd/webp, JP2, plain-JPEG and flat-TIFF slow baselines) in two access shapes: full-resolution tile and !256,256 thumbnail. |
process |
src/process_benchmark.cpp |
The operators between decode and encode: scaleFast/scaleMedium/scale, rotate (90° fast path + 45° general), crop, to8bps, convertToIcc, removeChannel. |
encode |
src/formats/encode_benchmark.cpp |
SipiImage::write() for the four formats SIPI emits: JPEG (Q75/Q90), PNG, TIFF, JPEG2000. |
Benchmarks are co-located with the module they measure (ADR-0003 direction:
*_benchmark.cpp beside the source, the Abseil/Bloomberg-BDE/Chromium
convention). The cc_binary targets live in src/BUILD.bazel until
ADR-0003 promotes the respective modules; a **/*_benchmark.cpp glob
exclude on //src:sipi_lib keeps the sources out of the production library
and the coverage build, and tags = ["manual"] keeps the targets out of
bazel test //....
HTJ2K is absent from the decode matrix: Kakadu's HT block coder is
license-gated (FBC_ENABLED, evaluation-only) and the production SIPI
build can neither encode nor decode HT codestreams.
Fixtures¶
- The
parsetier needs none. - The
processtier reuses small checked-in repo fixtures (test/_test_data/images/) with the specific shapes its operators need (alpha channel, 16 bps, CMYK, known dimensions). - The
decode/encodetiers consume@sipi_bench_fixtures— a 321 MB variant matrix generated from one 7216×5412 photographic master bytools/benchmark/generate_fixtures.sh(the checked-in provenance: pinned source, pinned tool versions, exact commands). It is hosted as a release asset ondasch-swiss/dsp-ci-assetsand fetched lazily — the firstjust bench decodedownloads it; production and CI test builds never do.
Running¶
just bench <tier> # tier ∈ parse | decode | process | encode
just bench parse --benchmark_filter=ParseSize --benchmark_min_time=2s
just bench builds the binary with -c opt (matching production codegen —
never fastbuild, never sanitized or instrumented) and execs it directly,
exporting SOURCE_DATE_EPOCH=946684800 + SIPI_WORKSPACE_ROOT=.
(ADR-0002 ICC determinism + fixture resolution), SIPI_BENCH_FIXTURES_DIR
(the @sipi_bench_fixtures runfiles location) and a throwaway
TEST_TMPDIR for encode outputs. All Google Benchmark
flags
pass through.
The before/after workflow¶
just bench decode --benchmark_repetitions=20 \
--benchmark_out=before.json --benchmark_out_format=json
# ... make the hot-path change, rebuild happens automatically ...
just bench decode --benchmark_repetitions=20 \
--benchmark_out=after.json --benchmark_out_format=json
just bench-compare before.json after.json
bench-compare runs the vendored google_benchmark compare.py
(//tools/benchmark:compare, hermetic Python + numpy/scipy via the
sipi_bench_pip hub — nothing needed on the host). It prints per-benchmark
deltas with a Mann-Whitney U-test and an OVERALL_GEOMEAN:
- green/red numbers are the relative change (
+0.05= 5% slower), - the U-test p-value (with
--benchmark_repetitions) tells you whether the two distributions actually differ.
The regression decision rule¶
On Apple Silicon you cannot disable turbo or thermal throttling; rely on statistics instead:
- Run both sides with
--benchmark_repetitions=20on a quiesced machine and demand a baseline CV ≤ ~2% (_cvrows in the repetition output). A CV of 5–10% means the machine is too noisy — re-run cooler or raise--benchmark_min_time. - Trust a delta only if it is green (p < 0.05) AND the median shift exceeds the baseline CV. Treat sub-3% deltas as noise; trust ≥5% green shifts.
- Same machine, same
-c optbinary, for both before and after. Never compare a Mac "before" against a Linux "after".
This is never a CI gate — cross-machine comparisons are meaningless and shared runners are too noisy. The numbers live in PR descriptions, produced on the author's machine.
Adding a benchmark¶
- Put
<name>_benchmark.cppbeside the code it measures (it moves with the module when ADR-0003 promotes it). - Declare a
cc_binaryinsrc/BUILD.bazelnext to the existing ones:tags = ["manual"],testonly = True, deps on//src:sipi_lib+@google_benchmark//:benchmark_main(+//test:test_pathsandenv/dataif it reads fixtures or emits ICC-stamped output). - Keep file I/O and source decoding out of the timed loop (decode once,
deep-copy per iteration outside the timing if the operator mutates), and
defeat the optimizer with
benchmark::DoNotOptimize/benchmark::ClobberMemory. just bench <name>— there is nothing to register anywhere else.