# Performance
Measured 2026-09-19 on one Apple Silicon Mac (macOS, release builds, otherwise
idle). Every run preserved the established quality levels (75/85/95), PNG effort
2, full-size SSIMULACRA2 scoring and Alpha checks. No run reduced quality,
skipped verification or lowered the number of candidates.
## Input
A disposable copy of a production iOS project (the original checkout was never
written to): 7,453 files, 115 MB, 2,505 raster images of which 1,941 PNG, 474
HEIC, 82 WebP and 8 JPEG, in 31 asset catalogs plus loose resources. SVGA files
were removed from the copy and `ffprobe` was taken off `PATH`, because v0.5.0
cannot process either; every binary therefore did the same image work. WebP
candidates, which v0.5.0 does not have, were off in every run; they have since
become the default (`--no-webp` restores the measured configuration), so a cold
default run now does additional WebP encoding on loose files.
## Results and ablation
Each row adds one change to the row above it.
| A. v0.5.0, 2 workers | 378.9 s | 1.48 s | 1,433 MiB | — |
| B. main before this work (scorer reuse, in-run duplicate reuse), 2 workers | 346.6 s | 1.42 s | 1,352 MiB | −9% |
| C. new pipeline (largest-first order, duplicate sharing without a serial pre-hash pass, not-smaller candidates skipped before scoring), artifacts still fsynced, 2 workers | 270.1 s | 1.24 s | 1,054 MiB | −29% |
| D. + no fsync for regenerable report artifacts, 2 workers | 244.3 s | 1.23 s | 1,018 MiB | −36% |
| E. + automatic worker count (8) with the pixel budget — **the default** | 152.3 s | 1.35 s | 1,393 MiB | **−60%** |
| F. + persistent cache, cold (writes entries) | 150.1 s | 1.29 s | 1,377 MiB | −60% |
| G. + persistent cache, warm (unchanged project) | **2.4 s** | 1.22 s | 131 MiB | −99% |
The lossless PNG output of the new analyzer is byte-for-byte the same total as
v0.5.0 (18,225,980 bytes across all PNG resources), and it produced 3,248
candidate artifacts against 3,233 for the previous main.
On the 7,078-file Android project (2,003 PNG, 1,555 WebP, `--webp` enabled) the
fsync change alone took whole-project analysis from 86.0 s to 39.0 s, because
that project writes many more small previews.
## Where the time goes
Per-phase timers (`resopt analyze --timings`, summed over workers) for run D:
| Lossy encoding (ImageIO JPEG/HEIC, 3 qualities × 1–2 formats) | 364.9 | 76% |
| Scoring (SSIMULACRA2 + RGB/Alpha error) | 51.8 | 11% |
| Lossless PNG encoding (oxipng) | 28.0 | 6% |
| Decoding (originals and candidates) | 25.5 | 5% |
| Previews | 9.7 | 2% |
| Writing artifacts | 1.9 | <1% (58.1 s before the fsync change) |
| Hashing | 1.3 | <1% |
| Project scan (one pass, Git-aware) | 1.0 | <1% |
| Report generation | 0.04 | <1% |
Two findings shaped the work:
- **fsync was the largest avoidable cost.** Forcing every preview and candidate
to disk cost 58 s here and 420 s (summed) on the Android project. Artifacts
are regenerable, so only the journal, backups and `analysis.json` are synced.
The same applies to cache entries, which are hash-verified when read.
- **HEIC encoding does not scale with workers.** Going from 2 to 8 workers
nearly tripled the summed lossy-encoding time (365 s → 1,052 s) while wall
time fell 38%: the system encoder serializes internally. This is the floor
for cold macOS runs with HEIC enabled; `--qualities 85` cuts it by two thirds
for a first pass.
Time to first result is dominated by the project scan (about 1 s) plus the
first image, and was already good; the live page now shows that first result
instead of waiting for the whole report. Browser rendering stays flat because
rows are streamed in pages of 500 and the list renders 50 rows at a time; the
static `report.html` for the Android project is 9 MB and opens directly.
## Bounded resources
Workers default to the CPU count, capped at 8. Decoded source pixels in flight
are limited to one maximum-size image (`--max-pixels`), so extra workers help
ordinary assets without multiplying worst-case memory. Peak RSS stayed below
v0.5.0's in every configuration.
## Reuse rules
Work is shared only when the source bytes, the resource's policy class
(catalog / loose / Android area, type, nine-patch, `minSdk`, locks), every
option that affects candidates, the tool version and the codec backend all
match. Tests cover content changes, option changes, corrupted and truncated
entries, interrupted writes, foreign entries and traversal attempts
(`src/cache.rs`, `tests/pipeline.rs`).
## Skipping encodes that cannot win (after 0.6.1)
The default run gained WebP, lossy PNG and near-lossless HEIC candidates after
the measurements above, which took the same project copy from 152 s to 206 s
cold. `--timings` now reports lossy encoding per format, and it showed HEIC at
about 80% of all encode time.
Capping concurrent ImageIO encodes was tried and rejected: wall time on a
50-image sample went from 7.3 s to 12–19 s, so the encoder does parallelize at
these image sizes.
What worked is not encoding candidates that cannot be used. Qualities run in
ascending order; when one overshoots the source size by a safe margin, higher
qualities of that format are skipped and the candidate says so.
| Before | 206.2 s | 0 of 16,265 | 7,478 |
| Skip when a lower quality is not smaller | 121.7 s | 5,075 | 7,473 (5 lost) |
| Skip only with a margin (5%; 25% before HEIC quality 100) — **shipped** | **129.4 s** | 4,144 | **7,478 (identical set, same recommended savings)** |
The margins come from the data: in 10,634 encodes size was non-monotonic five
times — once between regular qualities (lower quality 0.7% over the source) and
four times at HEIC quality 100, where the encoder changes mode (lower quality up
to 13% over). Palette sizes for lossy PNG are exempt: on one real icon 128
colours compressed *worse* than 256.
Two runs of an unchanged binary differ in about 10 HEIC files by a few bytes:
Apple's encoder is not perfectly deterministic, which is why the comparison is
on the candidate set and sizes, not on hashes.