Skip to content

* Deflate: encoder ~1.4-2x faster at equal or better ratio, decoder faster than zlib-ng - #454

Merged
Hawkynt merged 6 commits into
mainfrom
perf/deflate-fast-paths
Oct 9, 2026
Merged

Hawkynt merged 6 commits into
mainfrom
perf/deflate-fast-paths

Conversation

@Hawkynt

@Hawkynt Hawkynt commented Oct 9, 2026

Copy link
Copy Markdown
Owner

Closes the Deflate speed gap left by #452 as far as clean, safe-ish managed C# allows without giving up ratio.

What changed

  • Benchmarks (Compression.Benchmarks): five 8 MB corpora (code, prose, binary, already-compressed, repetitive), a BenchmarkDotNet suite against System.IO.Compression (zlib-ng) at levels 1/6/9, and dotnet run -c Release -- deflate-table for a quick table. The platform codec is used only there, as the yardstick.
  • Encoder: four-byte hash chains with a three-byte side table, vectorised run linking, register-resident hot loops, branch-free table-driven symbol emission, AggressiveOptimization on the hot paths.
  • Decoder: folded table entries (base + extra count + code length in one entry), an unchecked fast loop (3 literals per refill, 16-byte match copies, next lookup before the copy) and a careful edge loop.

Measured (frozen corpora, Release x64, cycles/byte, before → after, zlib-ng in brackets)

corpus L1 compress L6 compress L9 compress inflate (L6 stream)
code 8.9 → 8.6 (5.2) 32.3 → 22.3 (19.0) 71.4 → 45.8 (40.4) 2.6 → 1.6 (2.1)
prose 4.5 → 3.3 (2.4) 14.2 → 10.6 (8.6) 23.8 → 16.7 (18.5) 1.0 → 0.71 (0.78)
binary 17.7 → 16.9 (9.0) 44.7 → 31.9 (27.8) 107.9 → 51.0 (64.1) 4.2 → 3.3 (3.9)
repetitive 0.46 → 0.40 (0.31) 3.09 → 0.65 (0.55) 4.86 → 2.03 (4.20) 0.19 → 0.14 (0.11)

Ratio is equal or better at every level/corpus (largest change: code L9 +0.01%). Already-compressed input compresses ~10% slower (side-table probe). Level 1 stays ~1.7x zlib-ng's cycles because zlib-ng's deflate_quick writes fixed-Huffman output 20–25% larger; level 6 stays ~1.15–1.25x because zlib-ng uses its cheaper deflate_medium (worse ratio) while ours runs classic lazy matching with zlib's limits.

Pinned digests PinnedFast/PinnedDefault updated deliberately; PinnedBest unchanged.

Verification

  • Core tier locally: 33569 passed, 0 failed.
  • New DeflateFastPathTests: levels 1–9 × 5 shapes × 25 boundary sizes through ours and zlib-ng; zlib-ng output at levels 1/2/6/9 read by ours; sub-table codes; input/output chunking; write-split determinism; 2400 bit-flipped streams and every truncation rejected cleanly.
  • External interop (gzip/zlib) green locally; 7z cases skip without 7z; the only local failures are GNU tar treating C: as a remote host (environment).

…kDotNet and a quick table

The Deflate speed work needs a yardstick that is not anecdotal. DeflateCorpus
builds five 8 MB inputs from files every checkout has: this repository's C#
sources (code), the .NET reference pack's XML docs (prose), the runtime's
managed DLLs (binary), the code corpus deflated once (already compressed), and
runs of one value between log lines (repetitive). DEFLATE_CORPUS_DIR freezes
them, so a before/after comparison across two checkouts sees the same bytes.

DeflateBenchmarks runs the building block against System.IO.Compression
(zlib-ng) at zlib levels 1, 6 and 9, compress and decompress, both decoders
inflating the same stream into the same 64 KB buffer. The platform codec
appears only in this non-product project, as the yardstick.
`dotnet run -c Release -- deflate-table` prints ratio and MB/s for the
edit-measure loop without a full BenchmarkDotNet run.
…r-resident loops

Compression ran at about 55% of zlib-ng's speed at levels 6 and 9. A profile
put 58% of level 6 in the chain walk at ~4 cycles a probe, so the number of
probes, not their cost, was the lever: three-byte chains hold every string
sharing three bytes, most of which cannot beat a match of four.

- Chains are keyed on four bytes (15-bit multiplicative hash). Matches of
  exactly three come from a side table holding the latest position of each
  three-byte hash, taken within 4096 bytes (lazy levels, zlib's TOO_FAR) or
  1024 (greedy levels). Ratio is equal or better at every level and corpus.
- Runs of one byte are linked into their chain a vector at a time
  (IndexOfAnyExcept + sequential Vector128 stores) instead of a dependent
  head read/write per byte; same table contents.
- The hot loops keep scan state, table and symbol-buffer references in locals
  (ref structs), the match search is out of line, and all of them are
  AggressiveOptimization: tier-0 code with OSR otherwise ran the first
  megabytes of every short-lived process.
- Symbols are one literal/length index plus distance; emission folds codes,
  extra bits and bases into per-block tables and is branch-free (a literal
  takes distance slot 0, whose entry writes nothing), spilling 8 bytes per
  symbol with one room check.
- The one-shot Compress presizes its MemoryStream to half the input.

Frozen 8 MB corpora, Release x64, same process, cycles/byte ours vs zlib-ng:
  code    L1 8.9->8.6 (5.2)  L6 32.3->22.3 (19.0)  L9 71.4->45.8 (40.4)
  prose   L1 4.5->3.3 (2.4)  L6 14.2->10.6 (8.6)   L9 23.8->16.7 (18.5)
  binary  L1 17.7->16.9 (9.0) L6 44.7->31.9 (27.8) L9 107.9->51.0 (64.1)
  repetit L6 3.09->0.65 (0.55)  L9 4.86->2.03 (4.20)
  already compressed: ~10% slower (27-28 -> 30-33) from the side-table probe.
Ratio (bytes, before -> after): code L1 2350857->2285939, L6 1868456->1866945,
L9 1855786->1855997 (+0.01%); prose L6 657626->655782; binary L6
3864543->3859883, L9 3856356->3854456; repetitive unchanged.
Level 1 stays ~1.7x zlib-ng's cycles: its deflate_quick writes fixed-Huffman
blocks 20-25% larger; matching that would cost the ratio this keeps.

Digests: PinnedFast and PinnedDefault in DeflateEngineTests change on purpose
(new parse); PinnedBest is unchanged. Output still does not depend on write
splits (now tested at levels 1/3/4/6/9 on runs and three-byte data).

Sourcing: rung 3, RFC 1951 for the format and section 4's hash-chain/lazy
scheme; zlib's per-level limits stay (rung 1, constants). Four-byte chains
with a three-byte side table is the arrangement of zlib-ng (zlib licence) and
libdeflate (MIT), rung 1 for the idea only; no code was converted.
…faster than zlib-ng

Inflate ran at 0.7-0.8x zlib-ng. Every symbol paid for truncation and
bounds checks and for two more lookups (base and extra-bit tables) after the
Huffman one.

- Table entries carry value << 16, flags, code length and code+extra bit
  count, so consuming a symbol is one shift by the entry and a length or
  distance needs no further lookup; reserved symbols and holes decode to an
  invalid entry. Root tables 11 bits (literal/length), 8 (distance).
- A fast loop runs wherever 16 bytes of read-ahead and a match plus 32 bytes
  of output are free: one branch-free refill per step leaves >= 56 bits, so up
  to three literals or one length+distance go without checks; the next entry
  is looked up before the match copy; copies go 16 bytes (distance >= 16) or
  8 bytes at a time, memset for distance 1.
- A careful loop with every check handles the edges and hands back as soon as
  the fast loop can resume. Malformed input is rejected by both alike.
- Both loops are AggressiveOptimization: the fast loop is entered once per
  ~96 KB, too rarely for tiering, and stayed tier-0 for whole runs.
- Dynamic tables reuse their arrays across blocks.

Cycles/byte, same streams, before -> after (zlib-ng): code 2.6 -> 1.6 (2.1),
prose 1.0 -> 0.71 (0.75), binary 4.2 -> 3.2 (3.9), repetitive 0.19 -> 0.14
(0.11); 1.2-1.4 GB/s on code. Stored data stays at memcpy speed.

Tests: every level x five data shapes x 25 boundary sizes through both
decoders, zlib-ng output at levels 1/2/6/9 read back, 15-bit codes forcing
sub-tables, every input/output chunking, 2400 bit-flipped streams and every
truncation rejected with InvalidData/EndOfStream, never a crash.

Sourcing: rung 3, RFC 1951. The entry layout (base and extra count folded in,
the low byte driving the shift) follows libdeflate's decode-table design
(MIT, rung 1, idea only; no code converted).
@Hawkynt
Hawkynt force-pushed the perf/deflate-fast-paths branch from 997ce60 to 06c7b6a Compare October 9, 2026 12:35
@Hawkynt
Hawkynt merged commit 2507301 into main Oct 9, 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