17°
Portada del artículo: How file compression actually works today (and how I beat zlib by 2.86% with graph theory)
CompressionRustCAlgorithmszlib

How file compression actually works today (and how I beat zlib by 2.86% with graph theory)

I translated zlib from C to Rust with c2rust, proved the translation is bit-exact across more than 15,000 cases, then replaced zlib's heuristic with a shortest path in a graph. Result: 2.86% smaller than zlib -9, verified byte for byte against the real C implementation. Along the way: how lossless compression actually works today, from LZ77 and Huffman to why zopfli and PPMd don't break Shannon's limit either.

Efrain Garay 22 September 2026

Playing summary

Every file compressed today, a .zip, a .png image, an HTTP response with gzip, almost always runs on the same 35-year-old algorithm: DEFLATE, the one zlib implements. I wanted to actually understand how it works underneath, so I did what I know how to do: translate it from C to Rust with c2rust, prove the translation doesn’t change a single bit, then see if I could improve it. It turned out I could. 2.86% smaller than zlib at its highest compression level, verified byte for byte against the original C implementation. Along the way, by measuring and not reading, it became clear where the real limit of lossless compression sits and why that limit can’t be cheated.

In 78 seconds, narrated: DEFLATE's parse as a shortest path in a graph (44 bits from zlib's real output vs 36 bits from the brute-force-verified optimum in the example), the Huffman tree that iterates (-8.5% on a real file), and the real result across 29 files: -2.86% against zlib, verified byte for byte. Muted by default: turn sound on in the controls.Watch it in the reel viewer →

The family of algorithms that compresses almost everything today

Before touching any code, a quick map of what uses what:

  • LZ77 + Huffman (DEFLATE, the engine behind zip/gzip/PNG): looks for recent repeats (“this already appeared 200 bytes back, copy it”) and then encodes what’s left with variable-length codes, shorter for the most frequent symbols. Fast, old, universal.
  • LZMA (the one behind 7-Zip and .xz): the same idea of repeats, but with an arithmetic range coder instead of Huffman, and a much larger history window. Compresses more, runs slower.
  • BWT (the one behind bzip2): reorders each block (100 KB to 900 KB, configurable) so bytes with the same context end up together, turning a scattered repeat into a long run that’s easy to compress.
  • PPM (also an option in 7-Zip): predicts the next byte by looking at the N previous ones, with a probability table that adapts on the fly.
  • Context mixing (cmix, paq8px, the top entries in the serious rankings): instead of one model, runs dozens in parallel and combines them with weights learned bit by bit. This is what leads the Hutter Prize and the Large Text Compression Benchmark today, and it’s expensive (sometimes under 10 KB/s).

All of them, without exception, run into the same limit: no lossless compressor can shrink every possible file of a given size, however clever it is. This can be proven by counting, not guessing: if there are 1,024 possible 10-bit files and the compressor is reversible (it has to recover the original), it has to give each one a distinct output. There’s no way to fit 1,024 inputs into fewer than 1,024 distinct outputs, so on average the output can’t be shorter than the input. That’s the counting (pigeonhole) argument, and it’s the intuitive version of what Shannon formalized for a source with a given probability distribution: there’s a limit to how much you can shrink things on average, even though a particular file, if it has a lot of structure, really can compress far below its size. The only thing a better algorithm can do is get closer to that average, by finding structure a simpler one misses.

The experiment: translate zlib and prove nothing broke

I took zlib 1.3.1’s official source (the same one half the internet uses) and ran it through c2rust, a mechanical C-to-Rust translator. The translation comes out ugly: raw pointers, manual arithmetic, zero Rust type safety. But it compiles and runs.

Before touching a single line, I needed to prove the translation didn’t change behavior. I built a harness that compiles the original C zlib alongside it and compares: compress with the translated version, decompress with the real C, does it give back the exact original file? Compress with the real C, decompress with the translated one, same question? And the strictest check: are the compressed bytes identical, not just “both work”?

I ran this against more than 15,000 generated inputs (random, repetitive, with short patterns, with already-compressed data) and against the standard compression corpora: the 11 files of the Canterbury corpus and 18 from the Calgary corpus (the 14 its official site maintains today, plus paper3–paper6, four files the collection used in earlier evaluations), at several of zlib’s compression levels (of the 10 that exist, 0 to 9). Bit-exact in 100% of the cases tested. In everything I ran, the mechanical translation didn’t change behavior by a single bit.

The automatic translation didn’t come out perfect on the first try. Four real stumbles, among the most instructive:

  • A cleanup script that deleted entire files. To strip the nightly feature attributes c2rust adds (#![feature(extern_types)] and similar), I first used a sed command that deleted from the start of the file to the first match of the pattern. In files that didn’t have that attribute, the pattern never appeared and the deletion range ate the whole file. I replaced it with a Python script that tracks bracket depth across multi-line #![...] attributes.
  • A memcpy with the wrong pointer type. In the translated CRC32, one call passed a *const u8 where the signature expected *const c_void; c2rust doesn’t add that cast automatically. One line of manual cast, documented as the only real intervention in that file.
  • PPMd7 crashing with SIGABRT from memory corruption. The CPpmd7Head struct, hand-translated for lack of a direct Rust equivalent, reserved only 4 KB of trailing padding when the real one needs close to 20 KB for its lookup tables. Bumping the buffer to 64 KB fixed it.
  • Nested C repositories, without noticing. I cloned zlib and xz without deleting their inner .git before adding them to the experiment’s repository, so Git recorded them as broken references (gitlinks) instead of real files. Fixed by deleting the nested .git and re-adding the content.

Where zlib leaves money on the table

With the base translated and tested, I went to look at how zlib decides what to compress. Here’s the interesting part.

DEFLATE bundles two decisions into every byte: write it as-is (a “literal”), or say “this already appeared, copy it from there” (a “match”)? A match is encoded as a backward distance plus a length to copy, always looking at a window of recent history (up to 32 KiB).

The LZ77 sliding windowHistory buffer to the left of the cursor, lookahead to the right. A match points back into history.
input: "el perro corre y el gato corre"
el·perro·corre·y·el·gato·corre
history (up to 32 KiB in DEFLATE)
lookahead
cursor: match: distance 16, length 6
  • match source
  • match target (copy this)
Every position of every real file is checked against its own history this way. The match finder (hash chains over 4-byte prefixes) is what makes this fast enough to run on megabytes.

That decision, made byte by byte across the entire file, is literally a shortest path in a directed graph problem: every file position is a node, every possible match from there is an edge with a cost in bits, and a literal is the default edge to the next node.

zlib never solves this exactly. Its algorithm (deflate_slow, the one used at high levels) is greedy with one symbol of lookahead: it looks at the current match, looks one more step ahead, and decides from that. It never looks further. It’s fast, and for most data it works reasonably well. But it isn’t optimal.

Parsing as a shortest pathSame input, same cost table — greedy stops one symbol early, the shortest path does not.
DEFLATE parsing: greedy vs shortest path Data-flow diagram: the same 9-byte input through the real zlib parser and the exhaustive shortest-path parser, with the real bit cost of each. 01 / Input 02 / Decision 03 / Encoding 04 / Result "abcabcabc" · 9 bytes · 01 / Input · the same file "abcabcabc" 9 bytes the same file real greedy / lazy · zlib 1.2.12, level 9 · 02 / Decision · finds a short match real greedy / lazy zlib 1.2.12, level 9 finds a short match shortest path · dynamic programming · 02 / Decision · finds the optimal match shortest path dynamic programming finds the optimal match 4 literals + match(5,3) · zlib's real output · 03 / Encoding · 44 bits 4 literals + match(5,3) zlib's real output 44 bits 3 literals + match(6,3) · exhaustive optimum · 03 / Encoding · 36 bits 3 literals + match(6,3) exhaustive optimum 36 bits 44 bits · verified with real zlib · 04 / Result · real 44 bits verified with real zlib real -18.2% in this example · verified by brute force · 04 / Result · optimal -18.2% in this example verified by brute force optimal same input no hand-picking same input no hand-picking decides and moves on never looks back decides from the end backward pass real cost 44 bits real cost 36 bits Legend optimal path encoded data default flow
Diagram compiled with Archify (dataflow type, typed JSON, validated layout). The 44 vs 36 bits are not invented: the greedy side is the real output of zlib 1.2.12 on this exact input, decoded by hand against RFC 1951’s fixed-Huffman table; the shortest-path side is the true optimum, found by exhaustive search over every valid match. The real experiment runs the same idea over every byte of real files, with a real match finder and a real dynamic Huffman tree — see the measured 29-file result below.

I replaced that heuristic with dynamic programming: for a fixed cost table (the current Huffman tree), compute the minimum cost to reach the end of the file from every position, working backward, so that by the time I reach a position I already know the real cost of every decision I can make there. It’s exact for that particular cost table, not a global optimum over the whole stream (that would also mean picking the ideal Huffman tree at the same time, a considerably more expensive problem); the next section covers how iterating gets closer to that. It’s the same algorithm that solves shortest paths on a map, applied to compression decisions.

The Huffman tree can also be improved on the fly

It doesn’t stop there. DEFLATE doesn’t just choose what to copy; it also builds a Huffman tree per block, giving shorter codes to the most frequent symbols.

A real Huffman treeBuilt on "abracadabra" (11 chars) — merge the two rarest nodes until one remains.
input: "abracadabra" → a:5 b:2 r:2 c:1 d:1
A real Huffman tree0101010111a:5624c:1d:1b:2r:2
symbolcountcodebits used
a505
b21106
r21116
c11003
d11013
Huffman: 23 bits total. Fixed 3-bit code: 33 bits.
The frequent symbol (a) gets 1 bit; the rare ones get 3. This is the exact mechanism, on a hand-checkable example — the real per-block tree in the experiment is built the same way from each block's actual symbol counts.

The problem is circular: the optimal parse depends on knowing how much each symbol costs, and that depends on the tree. But the tree depends on which symbols the parse chose.

The fix is to iterate: parse with an approximate cost, count which symbols were actually used, build a real tree from those frequencies, recompute the exact costs under that tree, reparse with the improved cost, and repeat until it stops improving.

Iterative reparse, in a loopToggle: a single pass, or the four rounds the experiment actually ran.
parse
→
tally symbols
→
rebuild tree
result: 1 tree fit to the FIXED-Huffman parse — never sees its own output
1
parse
→
tally symbols
→
rebuild tree
→
recost
2
parse
→
tally symbols
→
rebuild tree
→
recost
3
parse
→
tally symbols
→
rebuild tree
→
recost
4
parse
→
tally symbols
→
rebuild tree
→
recost
result: tree fit to what the parse actually chose, 4 times over
kennedy.xls207,029 B→189,404 B (−8.5%)
Measured before block splitting (single-block dynamic Huffman). Every round is verified: the real zlib inflate must decode the exact original, or the round is discarded.

Along the way I found a real bug: my first version of the clamp for Huffman codes that came out too long (over 15 bits, the format’s maximum) left the tree incomplete: the Kraft sum (the inequality any variable-length code table has to satisfy to be decodable) came out below 1 instead of exactly 1. A code with Kraft under 1 isn’t necessarily ambiguous in the abstract, but the DEFLATE format requires a complete one, and zlib rejects the table while building its decode tables (Z_DATA_ERROR), not partway through reading some symbol. Only large, varied files triggered it (768 KB of varied English text reaches 18-bit-deep trees before clamping); the small files in the test corpus never activated it, so it went unnoticed until I ran the full corpus. It’s fixed by checking that the Kraft sum equals exactly 1, not less, shortening the longest code whenever there’s margin to spare.

Splitting the file into blocks, each with its own tree

A single Huffman tree for an entire file is a compromise: if the first pages of a book use different words than the last ones, one tree doesn’t fit either part well. The fix, which Google’s zopfli tool has used for years, is to split the file into several DEFLATE blocks, each with a tree fitted to that section.

I implemented a recursive splitter: it tries candidate cut points, computes the exact cost of treating the range as one block versus two with separate trees, and only splits if the gain pays for the second tree’s cost (a new tree’s header isn’t free).

The result, measured and verified

29 files, real zlib -9 vs. the graph parser + block splittingLeft of 0% the experiment wins; right, it loses. Every point verified byte-exact against the real C zlib.
  • wins 27
  • ties 0
  • loses 2
  1. kennedy.xls-11.50%
  2. sum-5.10%
  3. geo-3.30%
  4. paper3-3.10%
  5. paper1-2.60%
  6. paper2-2.60%
  7. paper6-2.50%
  8. progc-2.50%
  9. asyoulik.txt-2.30%
  10. news-2.20%
  11. paper4-2.20%
  12. cp.html-2.20%
  13. paper5-2.00%
  14. progl-2.00%
  15. grammar.lsp-2.00%
  16. xargs.1-2.00%
  17. book2-1.90%
  18. lcet10.txt-1.90%
  19. progp-1.70%
  20. fields.c-1.70%
  21. book1-1.60%
  22. obj2-1.60%
  23. trans-1.60%
  24. plrabn12.txt-1.60%
  25. alice29.txt-1.30%
  26. bib-1.10%
  27. obj1-1.00%
  28. pic+0.50%
  29. ptt5+0.50%
-12%0%+2%
total across all 29 files (6,062,277 bytes): -2.86%
  • kennedy.xls: -11.50% (5 blocks)
  • sum: -5.10% (3 blocks)
  • geo: -3.30% (2 blocks)
  • paper3: -3.10% (2 blocks)
  • paper1: -2.60% (2 blocks)
  • paper2: -2.60% (2 blocks)
  • paper6: -2.50% (2 blocks)
  • progc: -2.50% (2 blocks)
  • asyoulik.txt: -2.30% (2 blocks)
  • news: -2.20% (3 blocks)
  • paper4: -2.20% (1 block)
  • cp.html: -2.20% (1 block)
  • paper5: -2.00% (1 block)
  • progl: -2.00% (1 block)
  • grammar.lsp: -2.00% (1 block)
  • xargs.1: -2.00% (1 block)
  • book2: -1.90% (5 blocks)
  • lcet10.txt: -1.90% (3 blocks)
  • progp: -1.70% (2 blocks)
  • fields.c: -1.70% (1 block)
  • book1: -1.60% (8 blocks)
  • obj2: -1.60% (5 blocks)
  • trans: -1.60% (2 blocks)
  • plrabn12.txt: -1.60% (6 blocks)
  • alice29.txt: -1.30% (2 blocks)
  • bib: -1.10% (2 blocks)
  • obj1: -1.00% (1 block)
  • pic: +0.50% (4 blocks)
  • ptt5: +0.50% (4 blocks)

Across those 29 files (6,062,277 bytes total), the final result is 2.86% smaller than zlib at its highest compression level. The best case, a binary spreadsheet, improves by 11.5%. The only two that get worse are the same binary image repeated across both corpora, and only by 0.5%.

All 29 files decode exactly with the original C zlib. None of this is worth anything if the compressed file doesn’t turn back into the exact original, byte for byte.

Calibrating expectations: this isn’t a discovery

Here’s the honest part that usually isn’t in “I beat algorithm X” posts. What I did is, in essence, a replica of what Google’s zopfli already does, which typically gets between 3% and 8% against zlib because it has a more thorough match finder and years of fine-tuning. My 2.86% lands at the low end of that range. I didn’t invent anything new; I built, measured and verified a technique that already existed, and the number that came out is consistent with what the literature already knew.

A separate experiment, more interesting for understanding the real limit: I tested whether a version of the same trick works on LZMA, the 7-Zip algorithm. Specifically, what I tried was hand-changing the arithmetic coder’s starting probabilities using statistics from a previous pass, instead of the fixed neutral value it always starts at. It doesn’t work: the file comes out unreadable for any standard decoder, because the format never transmits that state in the compressed file — encoder and decoder rebuild it independently, in parallel, byte by byte, and they have to start out identically or lose sync. That doesn’t mean no adjustment is possible in an adaptive coder: Zstandard, for example, does implement a real two-pass strategy that collects statistics for the next pass, without touching how its probabilities start out. What is scoped to this one case is the specific technique I used for DEFLATE (adjusting the initial state from the outside): that doesn’t transfer to a format that never transmits it.

What each format actually transmitsDEFLATE writes its tree in-band; LZMA's probability state is never written anywhere — encoder and decoder rebuild it independently.
DEFLATE block
HLIT/HDIST/HCLENhow many code-length symbols follow
code-length tablethe tree itself, RLE-encoded (§3.2.7)
Huffman-coded dataliterals + matches, using the tree just declared
LZMA range-coded payload
range-coded data onlyno tree, no probability table (the .lzma/.xz container has its own small header for other metadata — dictionary size, uncompressed size)
probabilities start at a fixed neutral value inside both the encoder and the decoder — never written to the file
Retuning DEFLATE's tree per block is free: the format already pays to transmit it. Retuning LZMA's probabilities breaks any decoder that does not know the same private adjustment.

I also tested LZMA’s most obvious knob (the EXTREME preset, which raises the accepted match length and search depth) against normal preset 9: -0.084% aggregate, with one file even getting 5.6% worse. And in bzip2 I tested raising the rounds of the EM loop that builds its alternate Huffman tables from 4 (the fixed value since 1999) to 50: -0.047% aggregate. Neither has the easy margin DEFLATE had; bzip2 because its multi-table selection has already been doing something like the block splitting DEFLATE was missing, for 25 years, and LZMA because its compact format leaves no room to declare any per-file adjustment.

What this teaches about compressing data, in general

Three ideas worth more than the final number:

  1. Shannon entropy is a floor, not a technical challenge. No algorithm, however clever, compresses below the real information content of the data without losing something. The only thing that can improve is how close to that limit you get, and that depends on how well the model understands the data’s structure.
  2. Fast heuristics (greedy) leave money on the table, usually not much. zlib uses a 30-year-old heuristic because it’s fast and works reasonably well. Solving it exactly (shortest path, dynamic Huffman, block splitting) gives a real but modest improvement: a few percentage points, not a change of category.
  3. The compressed file’s format decides which optimizations are possible. A format that transmits more metadata (like DEFLATE’s Huffman tree) opens the door to tuning it per file. A more compact format (like LZMA’s internal state) closes that door in exchange for less overhead. There’s no option that’s simply better: they’re different trade-offs, and knowing which is which tells you in advance what’s worth trying.

If a genuinely large jump exists, it’s in the context mixing family, which mixes dozens of models with weights learned bit by bit instead of one model with one heuristic. That does change category, at the cost of being orders of magnitude slower. But that’s another story.

When this is worth it, and when it isn’t

Concretely, so this is more than curiosity:

  • Compressing once and decompressing many times (a site’s assets, a release’s packages, static images) is the case where optimal parsing is worth it: the extra cost of compressing is paid once, and the file stays 2-3% smaller forever. Zopfli already does this in production for exactly this case.
  • Compressing on the hot path of a request (real-time gzip of an HTTP response) is the case where it isn’t worth it: the dynamic programming and iterative reparsing are several times slower than zlib’s greedy, and there latency matters more than 3% of size.
  • If the format is already LZMA, the specific adjustment tested here (changing the probability start-up from the outside) doesn’t transfer, for the format reason already covered; but the underlying principle does: asking whether the algorithm in use solves its decision exactly or with a single-pass heuristic usually reveals margin nobody went looking for. Zstandard is a good example that the general principle (a second pass with more information helps) can apply to an adaptive coder, as long as it doesn’t touch the state that never gets transmitted.

Sources

Comments

No comments yet. The first one is yours.

Reviewed before publishing. The email is not stored and never appears anywhere.