Skip to content

perf(photos): avifenc --speed 2 for new tiles, and a deliberately mixed library - #653

Merged
oddharsh merged 1 commit into
mainfrom
feat/avifenc-speed-2
Aug 28, 2026
Merged

oddharsh merged 1 commit into
mainfrom
feat/avifenc-speed-2

Conversation

@oddharsh

Copy link
Copy Markdown
Owner

What was wrong

Every avifenc call site in the photo pipeline passed --speed 4. That was
inherited rather than chosen, and it was leaving both bytes and quality on the
table at a cost of a fifth of a second per photo.

The measurement

Taken against true pipeline intermediates (sips to TIFF, then zenc square at
600/400/200), 6 stems covering both yuv paths, shipping flags verbatim and
varying only --speed:

speed 4 speed 2 speed 0
600 tier, 6 stems 153,138 B 150,948 B (-1.43%) 151,081 B
all three tiers 262,158 B 257,836 B (-1.65%) 257,606 B
ssimulacra2 mean (600) 79.439 79.601 (+0.162) 79.840
serial, per photo 0.515 s 1.078 s (2.09x) 2.942 s (5.71x)
parallel, per photo 0.132 s 0.346 s

Incremental add of 5 photos: 0.7 s to 1.7 s. Full library (165): 21.8 s to 57 s.

So speed 2 is smaller AND better for about +0.21 s per photo.

speed 0 is rejected on its own numbers. It beats speed 2 by 0.09 points of
ssimulacra2 for 3.6x the time, and it produced LARGER files than speed 2 on 2 of
the 6 photos.

My own spot-check

One stem end to end, on this machine, brew avifenc 1.4.2 over aom 3.14.1 (the
binary build.sh records as byte-identical to the vendored build at these
settings). XT500018.HIF, orientation 8, yuv420, sips to TIFF then
zenc square --orient 8 --filter box at 600/400/200:

tier speed 4 speed 2 delta
600 34,530 B 33,988 B -542 (-1.57%)
400 18,035 B 17,480 B -555 (-3.08%)
200 6,206 B 6,089 B -117 (-1.89%)
all three 58,771 B 57,557 B -1,214 (-2.07%)

ssimulacra2 on the 600 tier against its own source PNG (avifdec to PNG first,
since ssimulacra2 will not decode AVIF): 73.857 at speed 4, 73.902 at speed
2.
Wall clock on that tier: 0.248 s to 0.580 s (2.34x), which brackets the
2.09x serial figure above.

Same direction on both axes, so the handed-over numbers reproduce.

The fix

Five pipeline call sites move to --speed 2:

  • tools/photos/add-photos.sh:297 (avif_encode, which all three tiers go through)
  • tools/photos/reencode-thumbnails.sh:253,261,272
  • tools/photos/add-car-photo.sh:99

add-car-photo.sh inherits the measurement rather than carrying its own: same
encoder at the same -q 63 -d 10 on a 480px tier, one image per invocation, and
public/cars is not content-addressed so it re-mints no URL. The comment says
so.

The library stays MIXED, and that is the point

Existing tiles are speed 4. New ones are speed 2. Re-encoding to collect the
difference buys 118.6 KiB spread over 495 immutable-1y AVIF files, about 245
bytes per tile, of which a homepage visit fetches 12.

It would cost 495 re-minted /i/ URLs, 495 rewritten rows in
public/images/fingerprints.json, the a/s/x keys of all 165 stems in
hashes.json, a hand-edit to src/pages/garage/tooltips.html (12 literal /i/
refs across 3 stems: XT508687 a=5f22fe09, XT508940 a=0707c701, XT508756
a=5b99dd40), and a p-dict roll for that page. That is the trade that has broken
this build twice.

Two speeds cost nothing operationally, and config/tools.json's own $comment
already makes the argument: "Nothing recorded which encoder made the 632 files
in public/i, and #394 re-encoded 316 of them on 2026-08-14, so claiming these
versions produced them would be inventing provenance." A /i/ URL names exact
bytes per file, and nothing downstream reads encoder settings. The remaining
118.6 KiB gets collected whenever something forces a full re-encode anyway: a
LIBAVIF_TAG bump, a geometry change, or another reencode-thumbnails.sh run.
Written down at the call site in add-photos.sh, again at
reencode-thumbnails.sh (the script that collects it), and in CLAUDE.md.

The study generators are left alone, deliberately

gen-encoding-grids.sh passes --speed 6 and gen-encoding-samples.sh passes
--speed 4. Both stay, with a comment each recording the divergence.

Aligning them means regenerating public/garage/enc, which this PR is
explicitly a no-op for. gen-encoding-samples.sh PRINTS the byte counts that
get transcribed onto /garage/encoding, so changing the effort without re-running
it makes the script disagree with the numbers on the page. On a page whose job
is teaching encoder axes, a script that lies about its own output is worse than
three efforts. Picking one effort there is an editorial decision about what the
page teaches, and it belongs in the change that regenerates the assets.

What records the encoder settings

file names the speed? action
CLAUDE.md pipeline diagram no (added it) now -q 63 -d 10 --speed 2, plus a prose block with the measurement and the mixed-library argument
CLAUDE.md libavif bullet yes, --speed 4 kept verbatim as the measurement's own settings, plus a note that the pair is UNMEASURED at speed 2
tools/photos/libavif/build.sh:23 yes, --speed 4 same treatment
docs/MAINTENANCE.md:1186 "at the pipeline's settings" now names --speed 4 and says it has not been re-measured
config/tools.json no untouched. It records VERSIONS, not flags, and its recorded must not move because no binary changed
docs/PHOTO-PIPELINE.md no untouched
design/PORTING.md yes, --speed 4 untouched on purpose. design/README.md files it under "History (a record of decisions, NOT a spec to implement)" and says its numbers are stale; editing it would falsify a record

The byte-identity claim (vendored avifenc == brew avifenc) was measured at
--speed 4 on 2026-08-26. It is now flagged as unmeasured at speed 2 in all
three places rather than quietly re-pointed at settings nobody tested. Both are
still libavif 1.4.2 over aom 3.14.1 so identity is likely, and likely is not
evidence. It costs nothing until somebody re-encodes, because a new photo mints
a new URL under either binary.

Gates

gate result
bun run lint exit 0, no findings
bun run typecheck exit 0, "check-tool-types: matches the baseline (507 owed)", ts-coverage 291 files across 10 programs
bun test 474 pass, 0 fail, 67 files
bun run build exit 0
bun run photos:check exit 0: "165 photos, 660 hashed tiers, index in bijection, complete EXIF + histogram + alt-text metadata"
bash -n on all 6 edited scripts all parse

The no-op property, verified rather than asserted

git status after all five gates shows 8 files, every one under tools/ or
docs. Nothing under public/, src/, or config/.

The stronger check, because "tools/ does not ship" is a claim: I hashed the
built tree, stashed the change, rebuilt from the clean tree, and hashed again.

with the change:    37aeddb8ab629b16ef733d1c4a411c0ea2abbb238e41970102277462d81532e5
without the change: 37aeddb8ab629b16ef733d1c4a411c0ea2abbb238e41970102277462d81532e5

Identical over every file in .build/public. So no served byte moves, no /a/
or /i/ URL is re-minted, and no dictionary roll is owed.

…ed library

--speed 2 wins on both axes against the 4 the pipeline has been passing.
Measured over 6 stems covering both yuv paths through the real ingest geometry
(sips to TIFF, then zenc square at 600/400/200), shipping flags verbatim and
varying only --speed: the 600 tier goes 153,138 to 150,948 bytes (-1.43%), all
three tiers 262,158 to 257,836 (-1.65%), and mean ssimulacra2 on the 600 tier
rises 79.439 to 79.601. The bill is +0.21 s per photo serial, so an incremental
add of 5 goes 0.7 s to 1.7 s.

speed 0 is rejected on its own numbers: +0.09 ssimulacra2 over speed 2 for 3.6x
the time, and larger files than speed 2 on 2 of the 6 stems.

Five pipeline call sites move (add-photos.sh, reencode-thumbnails.sh x3,
add-car-photo.sh). The two /garage/encoding study generators keep their own
efforts (6 and 4) and now say why: their byte counts are what that page prints,
so aligning them means regenerating public/garage/enc, which is a separate
change with a real diff.

The library stays MIXED on purpose. Collecting the difference on the committed
tiles is worth 118.6 KiB over 495 immutable-1y AVIF files, about 245 bytes each,
of which a homepage visit fetches 12. It would cost 495 re-minted /i/ URLs, 495
fingerprint rows, the a/s/x keys of all 165 stems in hashes.json, a hand-edit to
src/pages/garage/tooltips.html, and a p-dict roll for that page. Two speeds cost
nothing operationally, because a /i/ URL names exact bytes per file and nothing
downstream reads encoder settings.

Nothing under public/ moves. Verified by building with and without the change:
both trees hash to 37aeddb8ab629b16ef733d1c4a411c0ea2abbb238e41970102277462d81532e5.
@github-actions github-actions Bot added area: docs Runbooks and repository prose area: photos The photo pipeline and everything it emits type: perf Changes what something costs labels Aug 28, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
aadhar-sh 07c5b8e Aug 28 2026, 03:25 PM

@github-actions

Copy link
Copy Markdown
Contributor

Wire-size diff vs merge base

3955d580 → 52b88050

Brotli q11, the quality the edge serves. Every number here is deterministic:
an unchanged file produces no row. Advisory — this check fails on nothing.

Client assets

No change. 25 files, 86.35 KiB Brotli total.

Pages

No change. 54 files, 571.62 KiB Brotli total.

Worker bundle

No change. 239.10 KiB gzip.

Dictionary deltas (pd/)

No change. 110 deltas, 498.41 KiB total.

@oddharsh
oddharsh merged commit 47d5d3b into main Aug 28, 2026
8 checks passed
@oddharsh
oddharsh deleted the feat/avifenc-speed-2 branch August 28, 2026 15:27
oddharsh added a commit that referenced this pull request Aug 28, 2026
…s that had gone quiet (#660)

#653 moved five pipeline call sites from --speed 4 to --speed 2 and shipped it
forward-only, leaving the 495 committed AVIF tiles at speed 4. Its own body named
the next forced re-encode as where the rest gets collected. This is that run.

WHAT IT BOUGHT, and it beat the 6-stem projection. 138,609 bytes over 495 files,
which is 135.4 KiB against the 118.6 KiB projected: -1.816% on the 600 tier,
-1.968% on 400, -2.007% on 200, -1.882% across all three. Quality rose with it,
mean ssimulacra2 +0.327 over 24 tier comparisons on 8 stems, better on 20 of 24
and smaller on 23 of 24. Both candidates are scored against the same lossless
reference the encoder was handed, so neither gets one built from its own encode.

THE JPG TIER DID NOT MOVE, on any of the 165. That is what said no histogram
re-bake was owed, and it was checked three ways rather than assumed: a per-tier
hash diff taken before hash-thumbnails.sh ran, that script's own j-hash tripwire
staying silent, and derive:check reporting images/histograms fresh. git agrees,
with 0 .jpg among the 990 changed files under public/i.

TWO CONTROLS RAN FIRST, because a re-encode is interpretable only if the
toolchain is the one that baked what it replaces. Rebuilding three stems at the
OLD --speed 4 reproduced their committed bytes exactly, grayscale yuv400
included. And build.sh asks for a vendored-versus-brew re-measurement before any
full re-encode, so that ran too: byte-identical on all 6 pairs across both speeds
and both yuv paths, despite brew reporting libyuv unavailable. So every byte that
moved is attributable to --speed and to nothing else.

TWO SCANNERS HAD STOPPED MATCHING, and this run is what found them.

check-photo-pipeline.ts walked public/garage and public/lwe for hardcoded /i/
refs. Neither has held an HTML file since the src/pages split on 2026-08-18, so
it read 0 refs and reported a pass while the 12 real ones sat in
src/pages/garage/tooltips.html. That is gotcha 40 from the scanner's side, and a
grep for the old path finds nothing because the roots are a list of directories.
Roots are src/pages, src/content and src/client now, with a floor of 12 so a
collapse fails instead of passing. Control: against the stale refs it names the
file and the dead hash, which is what it did here before the refs were fixed.

derive:check's census walks the filesystem rather than git, so the vendored
libavif tree build.sh clones reported 7 of its own scripts as undeclared
generators. Anyone following build.sh got a red derive:check on a clean tree. The
shell tier fires hardest and by construction, since it matches any mention of
src/ and every C project has one. Skipped by path now, because the basenames are
src and build, which are the two least safe names to put in SKIP.

WHAT ELSE MOVED. fingerprints.json regenerated by hash-thumbnails.sh, 660
digests. hashes.json carries new a/s/x for all 165 stems and unchanged j.
tooltips.html re-points 12 literal refs across 3 stems. config/derivations.json
and its lock re-record images/fingerprints and images/hashes; the other four
pinned derivations stayed fresh on their own and are untouched.

NO DICTIONARY ROLL HERE. dict:roll reads production, so it can only run once this
deploys, and the nightly dictionary-roll workflow opens that PR itself. a-dict is
untouched because /i/ files are not shell assets. tooltips.html owes a p-dict
snapshot on the next roll and is the only page whose bytes moved.

Nothing reached R2. A thumbnail re-encode leaves the originals alone.

config/tools.json's recorded versions are a real record for the pixel tiers now
rather than the observed baseline its own comment admitted to, and it gained the
flag half of that provenance. Measured the same day: --speed decides output, and
--jobs decides it at 1 against any value above, while 2, 4 and 8 are identical at
this tile size. The pipeline pins 4 and this run held it there, so one variable
moved. Neither flag is added as a field, on the reasoning min_version already
cost that file: a declaration nothing reads is the one nobody keeps true.

photos:check, derive:check, tools:check, build, lint, typecheck and 520 tests
green. The build reports 3234 internal refs across 54 documents all resolving,
which is the backstop under the hardcoded-ref fix.

Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: docs Runbooks and repository prose area: photos The photo pipeline and everything it emits type: perf Changes what something costs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant