Skip to content

perf(deflate,checksum): table-driven inflate with a fast loop; faster checksums - #167

Merged
MagicalTux merged 5 commits into
masterfrom
deflate-decoder-perf
Oct 31, 2026
Merged

MagicalTux merged 5 commits into
masterfrom
deflate-decoder-perf

Conversation

@MagicalTux

Copy link
Copy Markdown
Member

Decoder speed item of #156. The deflate decoder is now 3.5–4.6× faster: 0.8–1.0× zlib on text, source and binaries, and up to 1.34× zlib on repetitive data (before: 0.55–0.75×).

input format level before MB/s after MB/s zlib MB/s
dict/words deflate 1 / 6 / 9 197 / 223 / 224 923 / 898 / 902 1007 / 878 / 885
Rust sources deflate 1 / 6 / 9 249 / 313 / 328 1235 / 1534 / 1559 1536 / 1685 / 1691
zsh + dylibs deflate 1 / 6 / 9 226 / 242 / 248 862 / 975 / 1003 931 / 1029 / 1037
words / rust / bin zlib 6 218 / 291 / 232 866 / 1392 / 908 857 / 1574 / 985
words / rust / bin gzip 6 211 / 276 / 221 793 / 1214 / 817 848 / 1627 / 1027

Measured on Apple M5 Max against system zlib 1.2.12; full table in BENCH.md. gzip trails on repetitive data because the system zlib uses the ARM CRC instructions, and calling them needs unsafe, which the crate forbids.

Changes

  • Tables: an 11-bit literal/length and 8-bit distance root table with subtables (src/deflate/inftable.rs). Each entry holds the value, the code length and the extra bits.
  • Fast loop: runs while at least 16 input and 277 output bytes remain. It uses a 64-bit bit buffer refilled 8 bytes at a time, looks up the next symbol before copying a match, and copies in chunks, with pattern expansion for short distances.
  • Output: written straight into the caller's buffer instead of passing through the window twice. The window is updated once per call, and stored blocks are bulk-copied.
  • Checksums: a vectorisable 32-lane Adler-32 (about 20–27 GB/s) and a four-way interleaved slice-by-8 CRC-32 (about 7 GB/s). These replace the versions from perf(deflate,checksum): faster deflate encoder and checksums #166 because they're faster. There's a committed test against reference implementations.

Behavior

No API changes. Outputs, errors, consumed counts, block stops and unused bits are unchanged. This was checked by a differential test against the previous decoder, which was not committed:

  • 60,000 random seeds in release and 1,500 in debug with overflow checks
  • inputs from every level and strategy, plus corrupted, truncated and random streams, small windows and dictionaries
  • chunk sizes from 1 byte in/1 byte out up to the whole buffer, mixing normal and block-at-a-time decoding

Also, 60,000 corrupted zlib and gzip streams decoded with no panics. Rebased on #166, and every test suite passes.

🤖 Generated with Claude Code

MagicalTux and others added 5 commits October 31, 2026 15:54
Adler-32 now sums 32 lanes per block (the inner loop auto-vectorises) and
folds the lane totals back into a and b, reducing once per 128 KiB:
~4.0 -> ~20-27 GB/s on an Apple M5 Max.

CRC-32 runs four independent slice-by-8 chains over consecutive 2 KiB
stretches and joins them by multiplying with x^(8*2048) mod P (a
compile-time constant), so the table loads overlap: ~2.6 -> ~7 GB/s.
Inputs under 8 KiB take the plain slice-by-8 loop as before.

Both are checked against bit/byte-at-a-time reference implementations
over many lengths and split points, including a block of 0xFF bytes
that drives the Adler lane sums to their bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… into the output

The decoder was ~0.25x zlib: a bit-walking canonical decoder behind a
9-bit LUT, one state-machine step per field, every byte written twice
(output and ring window), the bit buffer refilled a byte at a time.

- New Huffman tables (src/deflate/inftable.rs): 11-bit literal/length and
  8-bit distance root tables with subtables for longer codes. Each entry
  packs the literal byte or the length/distance base, the code length and
  the extra-bit count, with code+extra bits in the low byte so a whole
  length or distance field is dropped with one shift. Incomplete codes,
  reserved symbols (286/287, distance 30/31) and empty codes give the same
  errors as before. The fixed-code tables are built once per run of fixed
  blocks; the tables live in the decoder instead of a per-block box.
- A fast loop runs while >= 16 input bytes and >= 277 output bytes remain:
  64-bit bit buffer refilled 8 bytes at a time, up to two literals per
  refill, length and distance decoded in one go, the next entry looked up
  before the match copy so the table load overlaps it, and match copies in
  8/16-byte chunks (with pattern expansion for distances under 8). The
  symbol-by-symbol state machine still handles the edges, so streaming
  with any buffer sizes, Z_BLOCK stops, exact consumed counts and errors
  are unchanged.
- Matches within the current call's output are copied from the output
  itself; the window only holds earlier calls' output and is brought up to
  date once per call (one copy of at most 32 KiB). Stored blocks are bulk
  copied.

Checked against the previous decoder with a differential test (not
committed) over thousands of random streams from every encoder level and
strategy, corrupted, truncated and random inputs, small windows and
preset dictionaries, under 1-byte to whole-buffer input/output chunking
and decode_block: identical results, consumed/written counts, statuses,
errors, unused_bits and block-boundary state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mark subtable pointers with their own bit so a lookup tests one bit, and
compare the fast loop's positions against precomputed limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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