transformPacked: interpolate a whole pixel at a time, and skip the bound check inside the frame - #178
Merged
Conversation
…und check inside the frame 8da5ae2 fixed a heap overflow in interpolateN() by replacing the unchecked PIXN() with the range checked PIXELN() for all four bilinear samples. That is in the innermost loop of the packed (RGB24/BGR24/RGBA) transform, so it is worth knowing what it costs -- and what else is spent there. Measured with the new bench/bench_transform.c at 1080p (best of five, Ryzen 9 9900X, GCC 15.2 -O3, ms/frame): before PIXN only this commit packed RGB24 fixedpoint 17.93 17.28 11.72 packed RGBA fixedpoint 23.05 21.88 13.84 packed RGB24 float 38.86 33.27 - planar YUV420 (control) 9.08 9.08 9.08 The bound check was not the expensive part -- ~4% here. What cost 35% was next to it: transformPacked() called interpolateN() once per channel, and every one of those calls recomputed the source indices and all four interpolation weights for the same pixel, three times over for RGB24 and four for RGBA. So both are addressed: * interpolateN() takes the unchecked accessor again when ix_f+1 < width and iy_f+1 < height. The outer test already establishes that ix_f/iy_f are in range, so only the "ceil" neighbours can leave the frame; away from the last row and column no sample can be out of bounds. On the border the checked accessor still supplies def. One predictable branch per call replaces four bound checks, and the overflow that 8da5ae2 fixed stays fixed: a variant with the check simply removed diverges on 18 of the 60 verification cases below, i.e. it reads past the buffer exactly as before. * New interpolateNall() does all N channels of one destination pixel: indices and weights once, then four loads and a blend per channel. transformPacked() calls it once per pixel instead of N times. The border case delegates to interpolateN() unchanged. * The blend itself moved into blendBiLinN(), shared by both, so the fixed point arithmetic exists once and the two cannot drift apart. transformfloat.c gets the same interior fast path. It is a test-only reference (not part of the library sources) so it keeps the per-channel structure. Output is bit-identical, not just close: bench_transform verify hashes every byte of the result frame over 3 packed formats x crop on/off x 5 transforms chosen to hit interior pixels, the last row/column, and source coordinates far outside the frame -- 60/60 unchanged. ./tests --all passes 38/38, and the benchmark is clean under ASan and UBSan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
8da5ae2 fixed a heap overflow in
interpolateN()by replacing the uncheckedPIXN()with the range checkedPIXELN()for all four bilinear samples. That sits in the innermost loop of the packed (RGB24/BGR24/RGBA) transform, so the question was what the check costs — and whether we can get back toPIXN.We can, but it turned out not to be where the time went.
Measurements
New
bench/bench_transform.c, 1080p, best of five, Ryzen 9 9900X, GCC 15.2-O3, ms/frame:PIXNonlyThe bound check was worth ~4%. What cost 35% was next to it:
transformPacked()calledinterpolateN()once per channel, and each call recomputed the source indices and all four interpolation weights for the same pixel — 3x over for RGB24, 4x for RGBA.What changed
interpolateN()uses the unchecked accessor again whenix_f+1 < width && iy_f+1 < height. The outer test already establishesix_f/iy_fare in range, so only the "ceil" neighbours can leave the frame; on the last row/column the checked accessor still suppliesdef. One predictable branch replaces four bound checks.interpolateNall()does all N channels of one destination pixel — indices and weights once, then four loads and a blend per channel.transformPacked()calls it once per pixel. The border case delegates tointerpolateN()unchanged.blendBiLinN(), shared by both, so the fixed point arithmetic exists once.transformfloat.cgets the same interior fast path. It is a test-only reference (not in the library sources), so it keeps the per-channel structure.The check is still load-bearing
A variant with the check simply deleted diverges on 18 of the 60 verification cases below — it reads past the buffer exactly as before 8da5ae2. Only interior pixels can skip it.
Verification
bench_transform verifyhashes every byte of the result frame over 3 packed formats x crop on/off x 5 transforms chosen to hit interior pixels, the last row/column, and source coordinates far outside the frame: 60/60 bit-identical to master../tests --all— 38/38.🤖 Generated with Claude Code