perf: optimize quantization and sampled rendering - #3
Conversation
Merging this PR will improve performance by 37.25%
Performance Changes
Tip Curious why this is faster? Comment Comparing Footnotes
|
|
@codspeedbot fix this regression |
Fixed in Root cause. The WallTime flame graphs point squarely at The single-pass scan added in "scan color boxes once" widened all six box bounds inside the per-cell loop, so every occupied histogram cell paid six dependent min/max updates. That serial chain ended up costing more than the extra passes it removed. Fix. The scan still runs once, but the bookkeeping moved out of the inner loop:
Correctness. Palette output is unchanged — quantizing a fixed image yields byte-identical palette and indexed output on Measured locally on the
CPU Simulation instrumentation agrees: 2.30 ms on The next CodSpeed run on the macro runners will confirm the regression is cleared. Summary
Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot. |
|
@codspeedbot any other performance improvements you can see? just asking, don't implement anything yet |
Yes — there's still a fair amount on the table. I profiled the current head ( Where the time actually goes now
Notably, the last PR fixed Ranked ideas1. Occupancy bitmap for the median cut — biggest structural win in 2. Batch the histogram scatter in 3. Shrink the inverse-colormap footprint 4. 5. 6. 7. Not worth chasing
One caveat on all of the above: Happy to implement any subset — just say which ones and I'll do them one commit at a time so each shows up separately in the report. Summary
Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot. |
|
@codspeedbot Implement 1, 2, 3, 4, 5, 7. Do them one commit at a time |
Done — one commit per idea, four of the six kept. Ideas 3 and 7 were implemented, measured, and reverted: both are regressions on this code. Details below. KeptMeasured on the
Cumulative, against the previous branch head:
1 — occupancy bitmap. Exactly as sketched: the 128 KB histogram condenses to an 8 KB bitmap. 2 — histogram scatter. The batching part of this one did not work: staging 8 (or 4) indices before the increments made 4 — i32 + scratch reuse. Both halves paid off, and this is the largest single win after the bitmap. 5 — dither row slices. Applied to Measured, not kept3 — u8 colormap + validity bitmap. This is a regression, and the reasoning in my earlier comment was wrong. Today the 7 — monomorphize
The branch is loop-invariant, so it is perfectly predicted and effectively free; what the split actually does is duplicate a very large loop body, and the instruction-cache cost outweighs it. Worth noting for 6 too, if you ever want it: that one is about arithmetic, not branching, so it does not share this problem — but it still needs the CorrectnessPalette bytes and indexed output are byte-identical to The next CodSpeed run on the macro runners will confirm the numbers. Summary
Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot. |
HIST_C2_ELEMS is exactly 32, so the occupancy of one (c0, c1) histogram row fits in a single u32 and the whole 128 KB histogram condenses to an 8 KB bitmap that stays in L1 for the entire median cut. The histogram is immutable while boxes are split, so the bitmap is built once after the prescan and stays valid across all 255 splits. update_box now scans a box with one masked load, a popcount and two ORs per row instead of walking up to 32 histogram cells, and reads the six box bounds off c0/c1/c2 occupancy words at the end rather than widening them as it goes. compute_color walks the set bits of each row, so it never touches an empty cell. Palette output is unchanged. build_palette: 284.7 us -> 217.2 us median.
The per-pixel arithmetic of the prescan is trivial; the cost is the scatter into the histogram. Neighbouring pixels of a photo overwhelmingly land in the same cell, so a single table turns the whole scan into one long chain of store-to-load forwarded increments. Scattering even and odd pixels into two separate tables halves that chain, and folding the two tables back together afterwards is a linear, vectorizable pass. Four lanes was measured too: the larger working set costs more than the shorter chain wins. Batching the index computation ahead of the increments (so the loads overlap) was measured as well and is a regression here - the increments still serialize because the compiler cannot prove the batched indices are distinct, so it only adds the cost of staging the indices. Counts are unchanged: each lane saturates at u16::MAX and so does the fold, so a cell that would have saturated still reads u16::MAX. build_palette: 217.2 us -> 202.1 us median.
A weighted squared distance in this quantizer never exceeds (255*3)^2 * 3, so the whole inverse-colormap computation fits in an i32. Running it in i64 was doubling the traffic on the 128-entry min-reduce for nothing. The winning color is kept as an i32 alongside the distance so the compare-and-select works on two same-width lanes, and is narrowed back to u8 once at the end. fill_inverse_cmap also heap-allocated and zeroed four buffers on every call - colorlist, bestcolor, bestdist and mindist. They are small, fixed-size and dead by the end of the call, so they now live on the stack. Colormap output is unchanged. map_frame_cold_cache: 489.6 us -> 421.2 us median.
Both mapping loops indexed their way through the frame a pixel at a time: quantize_fs_dither called img.get_pixel per pixel and wrote output[row * width + x], and each of the seven per-pixel accesses to the error buffer went through self with its own bounds check. quantize_no_dither did the same on the output side. Both now take a source row and an output row up front, so there is one bounds check per row instead of several per pixel, and the error buffer is moved out of self for the length of the scan - which keeps a plain &mut [i16] available while colormap misses still go through &mut self, and reuses the previous frame's allocation instead of allocating one per call. Hoisting the error limiter table and the palette out of self the same way was measured too and made no difference, so they are left alone. Output is unchanged. map_frame_warm_cache: 148.2 us -> 141.1 us median, map_frame_dithered: 1.346 ms -> 1.323 ms median.
|
@codspeedbot There's a regression, can you look into it? |
Found it, and it is my mistake from the previous round. Fixed in
No regressions left on the PR; Root causeThe regression came from "build the inverse colormap in i32 with stack scratch", and specifically from the part I claimed was a win: putting the winning color in a second That loop compares each of the 128 cells of a box against the running best distance, and after the first candidate color the compare almost never wins — a nearly perfectly predicted, almost never taken branch. Once both arrays were The reason I did not catch it is that this repository is benchmarked in walltime mode on
On the Ice Lake box I measured on, the shuffle-and-extract version is free; on the Neoverse macro runners it cost 460 µs in Fix
CorrectnessPalette, no-dither, warm-cache-repeat and dithered bytes are identical to the previous head for For future rounds I will validate hot-loop changes against the aarch64 codegen and a CI run rather than trusting local x86 walltime — I have noted that for this repository. Summary
Learnings updated. See what CodSpeed remembers about this repository. Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot. |
find_best_colors compares every one of the 128 cells of a box against the running best distance, and after the first candidate color that compare almost never wins. Running the distances in i32 and staging the winning color in a second i32 array next to them let LLVM vectorize the update: the branch becomes an unconditional load, compare, select and store on every cell, for both arrays. That is roughly free on an x86 dev box, which is where it was measured, but it is a large loss on the aarch64 macro runners the benchmarks actually run on - find_best_colors went from 842 us to 1.3 ms there, which is the whole map_frame_cold_cache regression. The distances go back to i64 and the winner is stored straight into the u8 output again, which keeps the loop scalar and branchy on both targets; the comment records why, so the i32 rewrite does not come back. The i32 rewrite of find_nearby_colors is kept - that one is a measured win on the macro runners (140 us -> 60 us) - as is the stack scratch, so bestdist no longer allocates per call. Output is unchanged: palette, no-dither, warm-cache and dithered bytes are identical for toaster frames 11/0/3 over frog/monkey/dolphin. map_frame_cold_cache locally: 386.1 us -> 393.8 us median, i.e. within noise on x86.
No description provided.