Skip to content

Speed up lossless decoding - #11

Merged
SeriousBug merged 7 commits into
mainfrom
decode-speed
Aug 7, 2026
Merged

Speed up lossless decoding#11
SeriousBug merged 7 commits into
mainfrom
decode-speed

Conversation

@SeriousBug

Copy link
Copy Markdown
Owner

Speeds up VP8L (lossless) decoding, adds a decode pass to the benchmark suite,
and recaptures the decode numbers on both machines.

Where the time was going

The VP8L decoder read Huffman codes one bit at a time and resolved each prefix
through a map[uint16]uint16 per code length. Map lookups alone were ~47% of
decode time.

What changed

  • Table-driven Huffman. libwebp's two-level canonical lookup table: an 8-bit
    root table, with second-level tables only for codes longer than 8 bits. A
    symbol is one peek and one indexed load.
  • Branch-free bit reader. The stream is held in a zero-padded buffer, so
    peeking 8 bytes needs no end-of-stream check and inlines into the symbol
    reader. Overrun is caught once per pixel instead of once per symbol.
  • In-place inverse transforms. Subtract-green, cross-color and predictor all
    read pixels only after their predictors are reconstructed, so they run over the
    decoded buffer. Peak decode memory dropped roughly by half.
  • Span-based predictor. The mode is fixed across a transform tile, so it is
    chosen once per span and each mode is its own loop, with left and top-left
    neighbours carried in registers.
  • Pixel loop. Tracks x/y instead of dividing per pixel, looks up the Huffman
    group only on tile crossings, and keeps metadata and color cache in locals.

Three things measured slower on the M4 and were reverted: SWAR sum-of-absolute-
differences for the Select predictor, representing single-symbol trees as a
filled root table, and root tables of 7, 9 or 10 bits.

Results

Lossless decode, ours against the field, across the seven test images:

arm64 amd64
vs x/image 0.78-1.15x 0.76-0.87x
vs libwebp 1.50-2.18x 1.93-2.93x
vs wasm 0.39-0.81x 0.37-0.49x
vs webp-rust 0.30-0.40x 0.27-0.69x

On the geometric mean we are ahead of x/image in every mode on both machines,
by 1% to 18%. Before this branch, lossless decode ran 1.4-3.9x slower than
x/image and level with the Rust original.

Lossy decode is untouched: 0.86-0.99x x/image, 2.4-5.8x libwebp.

Benchmark harness

  • benchmark/run-decode.sh and the decode path in webpbench/rustbench, which
    add golang.org/x/image/webp as a fifth engine. Every engine decodes the same
    libwebp-encoded file and ends at packed RGBA, so the YCbCr-returning engines
    pay for that conversion inside the measurement.
  • A decode-time figure in benchmark/chart, on the same bar layout as the
    existing figures.
  • Both machines now build webp-rust from the v0.2.0 tag. The amd64 host had
    been carrying an untagged 0.2.1 tree whose lossless decoder differs, which is
    why benchmark/rustbench/Cargo.lock moves.

Times ours, libwebp, wasm, webp-rust and x/image decoding the same
libwebp-encoded files, in a lossless and a lossy mode. Conversion to
packed RGBA is inside the measurement, since x/image and gen2brain hand
back YCbCr planes and libwebp does not.

Captured on both machines: we edge out x/image on lossy and trail it by
1.4-3.9x on lossless.
Reads the decode tables out of results.md like the other figures do, on
the same bar layout. The decode table has the peak-RSS table's column
count, so the parser now takes the caption above a table as what tells
them apart.
The lossless decoder read Huffman codes one bit at a time and resolved each
prefix through a map keyed by the bits seen so far, which put map lookups at
half of decode time. It now builds libwebp's two-level canonical lookup table
and reads a symbol with one peek and one indexed load, with a second level only
for codes longer than eight bits.

Around that:

- The bit reader keeps the stream in a buffer padded with zeros, so peeking
  eight bytes needs no end-of-stream branch and inlines into the symbol reader.
  Running past the end is caught once per pixel instead of once per symbol.
- The inverse transforms run over the decoded buffer in place, halving the
  memory a decode holds.
- The predictor transform picks its mode once per transform tile and runs a
  loop per mode, carrying the left and top-left neighbours in registers.
- The pixel loop tracks x and y instead of dividing, and looks up the Huffman
  group only when the pixel crosses a tile.

On an M4 Pro, decoding libwebp's lossless output of the test photos: 2.7-3.1x
faster on the 5.5 MP images, and 0.78-1.08x golang.org/x/image/webp's time
where it was 1.4-3.0x before.

Adds a decode benchmark and match-against-x/image test to the benchmark module,
and a corrupt-input test covering the bit reader's new bounds contract.
The loop reloaded the Huffman metadata and the colour cache through pointers on
every pixel. Reading them into locals once turns a cache insert into a multiply,
a shift and a store.
The VP8L rewrite landed after the last decode capture, so both tables still
showed the old numbers. Lossless decode now runs level with x/image and ahead
of the Rust original.

Both machines now build webp-rust from the v0.2.0 tag; colossus had been
carrying an untagged 0.2.1 tree whose lossless decoder differs.
@SeriousBug
SeriousBug merged commit 6e35fbe into main Aug 7, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant