Skip to content

perf: compression optimizations (match extension, huffman decode, xz/lzma2 ratio) - #112

Merged
MagicalTux merged 9 commits into
masterfrom
perf/compression-optimizations
Jul 5, 2026
Merged

perf: compression optimizations (match extension, huffman decode, xz/lzma2 ratio)#112
MagicalTux merged 9 commits into
masterfrom
perf/compression-optimizations

Conversation

@MagicalTux

Copy link
Copy Markdown
Member

A sweep of profiled, benchmarked optimizations. Every change keeps output byte-identical / spec-correct; verified by the full test suite (1706 tests) plus native xz -d cross-decode for the LZMA2/xz work.

Throughput / ratio wins

Word-at-a-time forward match extension (91eb503) — replaced byte-at-a-time match-extension loops with an 8-byte LE u64 XOR + trailing_zeros scan (the technique deflate/zstd/brotli already used) in lz4, snappy, lzo, xpress, lzs, adc. Encode on 1 MiB Lorem: lz4 1773 → 13950 MB/s (7.9×), lzo 3.2×, xpress 3.2×, snappy 1.7×, lzs/adc ~1.3×. Output byte-identical.

huffman_codec decode (9cfd96a) — replaced the per-symbol 8-byte window rebuild with a maintained 64-bit accumulator, and packed the two parallel decode tables into one Vec<u16>. Decode +51% (source) to +107% (lorem).

LZMA2/xz compressible ratio (4b28e97, 1483d65) — two fixes closing the gap to native xz:

  1. Carry the LZMA probability model across chunks (continuation control 0x80) instead of resetting every 64 KiB.
  2. Pack up to 1 MiB per chunk (vs 64 KiB), amortising per-chunk framing + range-coder-flush overhead. The parsers now stop at a compressed-size cap at a clean packet/window boundary.

Result — 16 MiB Lorem xz: 21,872 → 2,976 bytes (~1.03× native xz, was ~7.5×). Incompressible size and encode speed unchanged. Tradeoff: streaming buffer floor rises from ~64 KiB to ~1 MiB (memory stays O(dict_size + 1 MiB)).

Tooling / docs

  • examples/micro.rs — a subprocess-free single-codec micro-benchmark used for the before/after numbers.
  • BENCH.md regenerated on the current host; tests/xz.rs strengthened with many-chunk native cross-decode cases (and its pipe_through helper fixed to stream stdin on a thread).

🤖 Generated with Claude Code

MagicalTux and others added 9 commits July 4, 2026 02:10
Replace the byte-at-a-time forward match-extension loops in six LZ
encoders with an 8-byte little-endian u64 XOR + trailing_zeros scan
(match_forward), the same technique the deflate/zstd/brotli extenders
already use. Emitted match lengths — and therefore the compressed
bitstreams — are byte-for-byte identical; only the scan speed changes.

Encode throughput on 1 MiB inputs (min MB/s, this host):

  codec    lorem before->after     source before->after
  lz4      1773 -> 13950  (7.9x)    418 -> 828  (2.0x)
  xpress   3125 ->  9998  (3.2x)      —          538
  lzo      2453 ->  7944  (3.2x)    470 -> 636  (1.35x)
  snappy   1758 ->  2989  (1.7x)    406 -> 605  (1.5x)
  lzs       902 ->  1182  (1.31x)     —          216
  adc       635 ->   878  (1.38x)     —          305

lznt1 is intentionally left byte-at-a-time: its matches self-overlap
(prev_pos + (len % dist)), which a word-wide compare cannot model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The standalone Huffman codec's decode loop rebuilt an 8-byte big-endian
bit window from memory on every symbol (BitReader::peek's 8-iteration
byte loop) and read two parallel tables (sym_tbl, len_tbl) per lookup.

Replace both:
- Maintain a left-aligned 64-bit accumulator refilled 8 bytes at a time,
  so the per-symbol cost is a shift + a single table load instead of a
  from-scratch window assembly. The tail still reads as zero-padded,
  matching the old peek exactly, and the truncation / corrupt-table
  checks are preserved.
- Pack the two tables into one Vec<u16> ((len << 8) | sym) so a lookup
  touches a single cache line.

Decoded output is unchanged. Decode throughput on 1 MiB: source
180 -> 271 MB/s (+51%), lorem 181 -> 375 MB/s (+107%). BitReader is now
unused and removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- examples/micro.rs: a subprocess-free single-codec micro-benchmark
  (min/median MB/s over N runs with a warmup) for clean before/after
  measurement of one algorithm + input, complementing the full
  reference-comparison harness in examples/bench.rs.
- BENCH.md: regenerated on this host (Intel i9-14900K). Reflects the
  word-at-a-time match-extension speedups (lz4 Lorem encode ~1.8 -> 14.9
  GB/s, etc.) and the current bounded-memory lzma/xz encode tradeoff.
  Analysis prose rewritten to match the new numbers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ressible)

The bounded-memory streaming LZMA2 encoder reset the whole probability
model, state, and rep distances at the start of every 64 KiB chunk
(control 0xC0), so the adaptive model never warmed past one chunk. On
highly compressible input this bloated output badly: 16 MiB of Lorem
encoded to ~21.8 KB vs native xz's ~2.9 KB (~7.5x larger).

Continue the model across chunks instead, matching native xz:
- Continuation compressed chunks now frame as control 0x80 (no state /
  props reset, no props byte); the first chunk stays 0xE0 and a chunk
  following an uncompressed-fallback chunk stays 0xC0.
- The encoder core resets only the per-chunk range coder between
  continued chunks (new `reset_range_coder`), keeping probs / state /
  reps / output_pos. `Lzma2Chunk` carries a `reset_state` flag; the
  stream encoder tracks `need_state_reset` and forces a reset after any
  uncompressed chunk (whose discarded compressed attempt mutated the
  model the decoder never sees).

The decoder already handled all four reset modes, so this is
encoder-only. Verified byte-identical round-trips through our decoder
AND decode by native `xz -d` on many-chunk compressible and
incompressible inputs (strengthened tests/xz.rs; also fixed its
pipe_through helper to stream stdin on a thread so large payloads don't
deadlock).

Result (16 MiB Lorem): xz 21872 -> 5552 bytes, lzma2 21815 -> 5497
bytes (~3.9x smaller; ~7.5x -> ~1.9x vs native xz). Incompressible and
realistic mixed data are unchanged in size.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
xz Lorem output 1708 -> 752 B, Zeros 1368 -> 440 B after the LZMA2
continuation-chunk model carry; note the effect and the closed gap to
native xz in the analysis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ratio)

The streaming encoder emitted a fresh LZMA2 chunk every 64 KiB, so the
per-chunk framing header + 5-byte range-coder flush (~10 bytes/chunk)
was paid 256 times over a 16 MiB stream — ~2.5 KB of pure overhead that
dominated the remaining ratio gap vs native xz after the model-carry fix.

Pack up to CHUNK_UNCOMP_MAX (1 MiB) of uncompressed data into each
compressed chunk. Because a chunk's compressed body must fit the 16-bit
comp_size field, encode_greedy/encode_optimal now take a comp_cap and
return the position they actually reached, cutting the chunk at a clean
packet/window boundary once the body reaches CHUNK_COMP_CAP (58 000 —
leaves margin for the last committed window + flush under 65 536). So a
chunk ends at whichever comes first: 1 MiB uncompressed, the compressed
cap, or end of input. Uncompressed-fallback chunks still fit the 16-bit
uncomp field (a fallback implies uncomp_len <= body <= the cap).

The compressed-chunk uncompressed-size field is 21 bits (2 MiB), so the
framing already supported this; only the per-chunk size cap and the
framing debug-asserts changed. Encoder-only; decoder unchanged.

Result (16 MiB Lorem): xz 5552 -> 2976 B, lzma2 5497 -> 2921 B — now
~1.03x native xz (was ~1.9x). 1 MiB Lorem: xz 752 -> 588, Zeros
440 -> 280. Incompressible unchanged. Encode speed unchanged. Verified
round-trip + native `xz -d` (tests/xz.rs many-chunk cases now span 1 MiB
chunks) + debug_assert invariants under medium-entropy stress.

Tradeoff: the streaming buffer floor rises from ~64 KiB to ~1 MiB
(memory stays O(dict_size + 1 MiB), bounded and input-length-independent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
xz Lorem output 752 -> 588 B, Zeros 440 -> 280 B; 16 MiB Lorem now
~3.0 KB (~1.03x native xz).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- rustfmt examples/micro.rs
- huffman_codec: drop the orphaned BitReader doc line left after removing
  the reader; drop a redundant `u16` cast
- snappy: reorder match_forward above compress_block so compress_block's
  doc comment attaches to it again (fixes doc_lazy_continuation)

No functional change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`examples/micro.rs` uses `compcol::factory`, so it must not be built when
`factory` is off. The narrow-feature-subset clippy job builds
`--all-targets` with `--no-default-features --features lz4`, which tried to
compile the example without `factory` and failed. Declare
`required-features = ["factory"]` for it, mirroring the `bench` example.
@MagicalTux
MagicalTux merged commit ac16b85 into master Jul 5, 2026
42 checks passed
@MagicalTux
MagicalTux deleted the perf/compression-optimizations branch July 5, 2026 03:21
@MagicalTux MagicalTux mentioned this pull request Jul 5, 2026
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