Performance results¶
The append-only record of comparative benchmark runs — what was
measured, on which commit, in which environment. The numbers behind the
scorecard in CANTALOUPE_PARITY.md live
here; the harness contract is in
specs/bench-harness.md. Per-run JSON/Markdown
reports under bench/results/ are gitignored ephemera — this doc is the
durable, curated record.
When you run the bench, add an entry (see Recording a run at the bottom). Don't rewrite past entries — they're a historical record of what that commit did on that hardware.
Environment (shared across the runs below unless noted)¶
| Host | AWS c7i.2xlarge — 8 vCPU (Intel Sapphire Rapids), 16 GiB, us-west-2 |
| OS | Ubuntu 24.04 |
| Per-container limits | --cpus 8 --memory 8g, identical for both servers |
| iiiris | built from source per commit (distroless image; libvips static, Go 1.25). R1-R6 ran libvips 8.16; R7 onward 8.18.2 — see R7 for why the bump matters. |
| Cantaloupe | 5.0.7 on Java 17 (Temurin); JP2 via Grok |
| Corpus master | Van Gogh Irises, 9021×7122, Getty Open Content (sha256 e8db6a68…) |
— large.tif |
vips tiffsave --tile --pyramid --compression jpeg --Q 90 → pyramidal tiled TIFF, 128 px internal tiles, ~104 MB |
— master.jp2 |
OpenJPEG -n 6 -t 1024,1024 -p RPCL -r 20 |
| Harness | tools/bench-compare, --smoke --format jpeg,jp2,large |
| Metric | caches-off saturation req/s (offered rate calibrated to ~100 % success); also cold (first-request) latency and peak RSS |
Cantaloupe is a fixed reference (its numbers don't move with iiiris
commits), so later runs are often iiiris-only (--servers iiiris) and
compared against the Cantaloupe column recorded on 2026-06-07.
Results timeline — caches-off saturation req/s (iiiris)¶
Higher is better. Cantaloupe column is the 2026-06-07 measurement.
| Cell | Cantaloupe | R1 baseline | R2 mmap | R3 +info-dims | R4 +concurrency | R5 +baseline-JPEG |
|---|---|---|---|---|---|---|
| info jpeg | 82 | 71 | 512 | 512 | 512 | 512 |
| info jp2 | 512 | 174 | 512 | 512 | 512 | 512 |
| info large | 512 | 18 | 512 | 512 | 512 | 512 |
| full-scaled jpeg | 2 | 9 | 10 | 10 | 10 | 15 |
| full-scaled jp2 | 17 | 48 | 63 | 63 | 63 | 82 |
| full-scaled large | 23 | 17 | 238 | 238 | 238 | 176† |
| tiled jpeg | 10 | 8 | 9 | 9 | 9 | 10 |
| tiled jp2 | 41 | 35 | 40 | 39 | 53 | 85 |
| tiled large | 81 | 15 | 62 | 61 | 78 → 95 | 175 |
R4/R5 are max_concurrent: 16 (R1–R3 are 4). R5 also flips JPEG output from
progressive to baseline, which lifts every JPEG-output render cell (the
encode is ~3× cheaper). †full-scaled large dips because mc=16
over-subscribes that very fast op (cold 15 ms) — it's a tile-large tuning
value; the cell still wins 176 vs 23. R3's win is in cost not throughput
(info was already capped): see its note.
Runs¶
R1 — baseline (info-probe OOM fix)¶
- Commit
7c663c6· Date 2026-06-07 · Configmax_concurrent: 4, both servers - What it isolates the OOM fix alone — the clean reference. The full caches-off matrix ran with 0 OOM / 0 restarts (prior runs crashed 3×).
- Notable RSS info large 991 MiB, tiled large 924 MiB (whole-source buffering).
- Finding confirms the real losses are genuine, not crash artifacts:
info large18,tiled large15,info jp2174.
R2 — mmap source I/O¶
- Commit
0c62679· Date 2026-06-07 · Configmax_concurrent: 4, both servers - What it isolates mmap'ing filesystem sources (resident memory now independent of source size).
- Finding three ❌ cells flip to the 512 cap (
info large/jpeg/jp2);full-scaled large17 → 238;tiled large15 → 62. RSS 10–150 MiB vs Cantaloupe 0.3–2.2 GiB everywhere.tiled large62 vs 81 — iiiris wins cold-latency (60 vs 65 ms), throughput still capped bymax_concurrent: 4.
R3 — header-only info dimensions + heap O(1) LRU¶
- Commit
ace9886· Date 2026-06-07 · Configmax_concurrent: 4, iiiris-only - What it isolates reading info.json dimensions from the header (no libvips); heap cache O(1) LRU.
- Finding info throughput was already capped, so the win is cost:
info jp2CPU 13 → 3 %, RSS 150 → 10 MiB, cold 2.7 → 1.3 ms (OpenJPEG header parse gone). info is now ~3 % CPU / 10 MiB / sub-ms for every format. Heap O(1) LRU not visible at the 512 cap / small smoke cache.
R4 — concurrency sized to the cores¶
- Commit
c248b34(run) + amax_concurrent: 16probe · Date 2026-06-07 · iiiris-only - What it isolates
max_concurrenton the I/O-bound (EBS)tiled largedecode. Product default is nowNumCPU; bench config tested at 8 then 16. - Finding the EBS corpus makes tile reads I/O-bound, so cores idle during reads until concurrency exceeds the core count:
max_concurrent |
tiled large req/s | CPU |
|---|---|---|
| 4 | 61 | ~2 cores |
| 8 | 78 | ~7 cores (≈ Cantaloupe 81 — tie) |
| 16 | 95 | ~7.5 cores (beats 81) |
Bench configs set to 16. iiiris is less CPU-efficient per tile than Cantaloupe (~13 vs ~22 req/s/core) but out-throughputs it by using more of the same 8 cores, at ~12× less memory. Scorecard: 0 losing cells.
R5 — baseline JPEG (matched-output)¶
- Commit
4ed49d5· Date 2026-06-07 · Configmax_concurrent: 16, iiiris-only - What it isolates JPEG output flipped progressive → baseline (the govips default was progressive — multiple entropy-coding passes, ~3× slower encode). Both servers now emit baseline q90, so this is a true matched-output comparison.
- Finding the encoder was the per-tile CPU gap (parity item 2):
tiled large95 → 175 req/s; per-core 13 → 25 (beats Cantaloupe's 22 — iiiris now more CPU-efficient per tile); cold 60 → 26 ms (vs 65); RSS 88–156 MiB vs 1717.- every JPEG-output render cell rose:
full-scaled jpeg10 → 15 (cold 553 → 363 ms),full-scaled jp263 → 82,tiled jp253 → 85. full-scaled large238 → 176 — not a regression in capability; mc=16 over-subscribes that very fast op (it's tuned fortiled large). Still 7.6× Cantaloupe's 23.- 0 OOM. iiiris vs Cantaloupe on
tiled large: 2.2× throughput, more CPU-efficient per tile, 2.5× faster cold, ~15× less memory.
R6 — pre-1.0 parity re-validation ⚠️ INVALID for jp2/large full-scaled¶
Retained, not deleted — past runs are never edited, and a wrong run that is explained is more useful than one that vanished. But do not cite its
full-scaledjp2 or large figures.R6 measured iiiris returning undersized images. A bug in the shrink-on-load path (fixed in
!110) made pyramidal TIFF and JP2 sources answer derived-size requests with a small pyramid level:full/!1024,1024on the 9021×7122 master returned 70×55 forlarge.tifand 282×223 formaster.jp2, HTTP 200, valid JPEG. iiiris was shipping ~5 KB where Cantaloupe shipped ~590 KB, so its "wins" in those cells measured it doing far less work.Invalid:
full-scaledjp2 (4.0×) and large (8.0×), both cache postures. Still valid: everytiledcell (explicitregion/w,h— the bug never touched that path), everyinfocell, and everything onplain.jpg.Superseded by R7. The irony worth recording: the
on full-scaled jpeglatency that looked anomalous in R6 was the only correct cell in that group — jp2 and large looked faster because they were serving thumbnails.
R6 (original text) — pre-1.0 parity re-validation¶
- Commit
5c25bd7· Date 2026-08-23 · Hostc7i.2xlarge, 8 CPUs / 8g per server · Config both servers, caches off + on, formats jpeg/jp2/large - Why the 1.0 checklist holds Cantaloupe parity as a standing release gate, not a one-time check. The previous evidence was R1–R5 (2026-06-07), which predates downscale sharpening, supersampling, and the colour-policy work in the render path. This run re-measures both servers in one pass, so the verdict doesn't depend on comparing against Cantaloupe numbers recorded eleven weeks and several builds earlier.
- Verdict — gate holds: 6 wins, 12 ties, 0 losses on saturation throughput.
| caches | scenario | format | iiiris | cantaloupe | ratio |
|---|---|---|---|---|---|
| off | full-scaled | jpeg | 15 | 2 | 7.8× |
| off | full-scaled | jp2 | 64 | 16 | 4.0× |
| off | full-scaled | large | 128 | 16 | 8.0× |
| off | info | jpeg | 1024 | 64 | 16.0× |
| off | tiled | jp2 | 64 | 32 | 2.0× |
| off | tiled | large | 128 | 64 | 2.0× |
- Read the ties carefully. 11 of the 12 are both servers pinned at the
ramp's 1024 req/s ceiling — the harness runs out of headroom, so the tie
means "both exceed what this ramp measures", not "equal". Only
tiled/jpeg/caches-off(8 req/s each) is a tie the benchmark actually resolved. With caches on, every cell caps for both servers. - Latency breaks the capped ties, and mostly for iiiris — 10 of 12 on p50,
by up to 5× (
on info *: 0.32 ms vs 0.90 ms). Two go the other way and are worth a look: off info jp2— 1.30 ms vs 0.94 ms.on full-scaled jpeg— 4.63 ms vs 2.00 ms. Both cap on throughput, but this is a cached full-scaled render, so it should be a cache-hit serve rather than a render. 2.3× on that path is the one number in this run worth chasing. Not a gate failure; throughput tied.- Memory, unchanged in character. iiiris 35–199 MiB against Cantaloupe 0.76–2.4 GiB across the matrix — the same order-of-magnitude gap R2 recorded.
- Not measured: htj2k. The corpus manifest records that its compression
isn't yet tuned for size parity with jp2, so the cell wouldn't be fair; the
bench host also lacks
grk_compress. - Corpus note: the master was re-pinned on 2026-08-23 (Getty re-encoded it
upstream — same artwork, same 9021×7122, new bytes; see
bench/corpus/manifest.json). Comparisons with R1–R5 are therefore in-kind rather than byte-identical. The within-run comparison this verdict rests on is unaffected: both servers read the same file.
R7 — parity on correct output (pyramid fix + libvips 8.18.2)¶
- Commit
5d8b9e8· Date 2026-08-24 · Hostc7i.2xlarge, 8 CPUs / 8g per server · Config both servers, caches off + on, formats jpeg/jp2/large · libvips 8.18.2 - Why R6's pyramid cells were measuring undersized output (see above). This re-measures on correct renders, both servers in one pass.
- Verdict — gate holds: 5 wins, 13 ties, 0 losses.
| caches | scenario | format | iiiris | cantaloupe | ratio |
|---|---|---|---|---|---|
| off | full-scaled | jpeg | 14 | 2 | 7.4× |
| off | full-scaled | large | 57 | 16 | 3.6× |
| off | info | jpeg | 1024 | 64 | 16.0× |
| off | tiled | jp2 | 64 | 32 | 2.0× |
| off | tiled | large | 118 | 64 | 1.9× |
| off | full-scaled | jp2 | 16 | 16 | 1.0× (tie) |
full-scaled largeis 3.6×, not R6's 8.0×. The honest number is lower, because R6's was measuring a 5 KB thumbnail.- The bug fix alone was not enough. An intermediate run on the same commit
with libvips 8.16 (the version both release images shipped) scored
4 wins / 12 ties / 2 LOSSES:
full-scaledjp2 at 0.06× and large at 0.20×. Correct output, but the fix's fallback gives up shrink-on-load, so those cells full-decode a 64 MP master per request. Bumping the distroless image to libvips 8.18.2 (!111) restores the fast path and turns both around. That intermediate run is the before for the bump and is not recorded separately; its numbers are quoted here. - Unaffected cells are stable across both runs —
full-scaled jpeg7.48 → 7.35,info jpeg16.02 → 16.04,tiled jp22.00 → 2.00 — which is what makes the two moved cells attributable to libvips rather than run-to-run noise. - Ties are still mostly ceiling. 12 of 13 are both servers pinned at the
1024 req/s ramp ceiling; only
tiled/jpeg/caches-off(8 req/s each) andfull-scaled/jp2/caches-off(16 each) are ties the ramp actually resolved. - Not measured: htj2k (compression not tuned for size parity with jp2; host
lacks
grk_compress).
Recording a run¶
After a comparative run, append a new ### Rn — <label> section with:
- Commit (
git rev-parse --short HEADof the build under test) and date. - Config / environment deltas from the table above (instance type,
max_concurrent, caches, which servers, any corpus change). - Findings — the cells that moved and why, in 2–4 lines.
- Add a column (or update the relevant cell) in the Results timeline if the run changes a headline number.
Keep the raw bench/results/*.json only if a number is surprising and worth
re-deriving; otherwise the curated entry here is the record. Never edit a
past run's numbers — add a new run instead.