Skip to content

Instantly share code, notes, and snippets.

@nmoinvaz
Last active September 5, 2026 23:14
Show Gist options
  • Select an option

  • Save nmoinvaz/85eed41307e8d80ffcc552d44c55f764 to your computer and use it in GitHub Desktop.

Select an option

Save nmoinvaz/85eed41307e8d80ffcc552d44c55f764 to your computer and use it in GitHub Desktop.
zlib-ng: level 10 two-step lazy matching, -0.310% on Silesia for +15.9% time
diff --git a/deflate.c b/deflate.c
index 1d699d55..c0d8aaf3 100644
--- a/deflate.c
+++ b/deflate.c
@@ -103,7 +103,7 @@ typedef struct config_s {
compress_func func;
} config;
-static const config configuration_table[10] = {
+static const config configuration_table[11] = {
/* good lazy nice chain */
/* 0 */ {0, 0, 0, 0, deflate_stored}, /* store only */
@@ -129,7 +129,8 @@ static const config configuration_table[10] = {
/* 7 */ {8, 32, 128, 256, deflate_slow},
/* 8 */ {32, 128, 258, 1024, deflate_slow},
-/* 9 */ {32, 258, 258, 4096, deflate_slow}}; /* max compression */
+/* 9 */ {32, 258, 258, 4096, deflate_slow}, /* max compression */
+/* 10 */{32, 258, 258, 4096, deflate_slow}}; /* max compression, two-step lazy */
/* Note: the deflate() code requires max_lazy >= STD_MIN_MATCH and max_chain >= 4
* For deflate_fast() (levels <= 3) good is ignored and lazy has a different
@@ -288,7 +289,7 @@ int32_t ZNG_CONDEXPORT PREFIX(deflateInit2)(PREFIX3(stream) *strm, int32_t level
#endif
}
if (memLevel < 1 || memLevel > MAX_MEM_LEVEL || method != Z_DEFLATED || windowBits < MIN_WBITS ||
- windowBits > MAX_WBITS || level < 0 || level > 9 || strategy < 0 || strategy > Z_FIXED ||
+ windowBits > MAX_WBITS || level < 0 || level > 10 || strategy < 0 || strategy > Z_FIXED ||
(windowBits == 8 && wrap != 1)) {
return Z_STREAM_ERROR;
}
@@ -623,7 +624,7 @@ int32_t Z_EXPORT PREFIX(deflateParams)(PREFIX3(stream) *strm, int32_t level, int
if (level == Z_DEFAULT_COMPRESSION)
level = 6;
- if (level < 0 || level > 9 || strategy < 0 || strategy > Z_FIXED)
+ if (level < 0 || level > 10 || strategy < 0 || strategy > Z_FIXED)
return Z_STREAM_ERROR;
DEFLATE_PARAMS_HOOK(strm, level, strategy, &hook_flush); /* hook for IBM Z DFLTCC */
func = configuration_table[s->level].func;
@@ -831,7 +832,7 @@ static int deflateHeaders(deflate_state *s, PREFIX3(stream) *strm) {
if (s->gzhead == NULL) {
put_uint32(s, 0);
put_byte(s, 0);
- put_byte(s, s->level == 9 ? 2 :
+ put_byte(s, s->level >= 9 ? 2 :
(s->strategy >= Z_HUFFMAN_ONLY || s->level < 2 ? 4 : 0));
put_byte(s, OS_CODE);
s->status = BUSY_STATE;
@@ -850,7 +851,7 @@ static int deflateHeaders(deflate_state *s, PREFIX3(stream) *strm) {
(s->gzhead->comment == NULL ? 0 : 16)
);
put_uint32(s, s->gzhead->time);
- put_byte(s, s->level == 9 ? 2 : (s->strategy >= Z_HUFFMAN_ONLY || s->level < 2 ? 4 : 0));
+ put_byte(s, s->level >= 9 ? 2 : (s->strategy >= Z_HUFFMAN_ONLY || s->level < 2 ? 4 : 0));
put_byte(s, s->gzhead->os & 0xff);
if (s->gzhead->extra != NULL)
put_short(s, (uint16_t)s->gzhead->extra_len);
diff --git a/deflate_slow.c b/deflate_slow.c
index b46e010e..e81e944f 100644
--- a/deflate_slow.c
+++ b/deflate_slow.c
@@ -10,6 +10,20 @@
#include "functable.h"
#include "insert_string_p.h"
+/* Level 10 keeps looking one position further than the lazy match. Deferring costs an
+ * extra literal, so the deferred match has to gain more than the pending one is worth. */
+#define LAZY2_LEVEL 10
+#define LAZY2_MIN_GAIN 6
+
+/* ===========================================================================
+ * Estimated bit gain from taking a match of new_len at new_dist over one of cur_len at
+ * cur_dist. A length byte is worth about four bits, and a distance costs its magnitude
+ * in extra bits, so a shorter distance offsets a smaller length.
+ */
+static inline int lazy_gain(uint32_t new_len, uint32_t cur_len, uint32_t new_dist, uint32_t cur_dist) {
+ return 4 * (int)(new_len - cur_len) + (int)zng_clz32(new_dist) - (int)zng_clz32(cur_dist);
+}
+
/* ===========================================================================
* Same as deflate_medium, but achieves better compression. We use a lazy
* evaluation for matches: a match is finally adopted only if there is
@@ -21,6 +35,7 @@ Z_INTERNAL block_state deflate_slow(deflate_state *s, int flush) {
unsigned char *window = s->window;
int bflush; /* set if current block must be flushed */
int level = s->level;
+ int lazy2 = level >= LAZY2_LEVEL;
if (level >= 9) {
longest_match = FUNCTABLE_FPTR(longest_match_roll);
@@ -85,6 +100,67 @@ Z_INTERNAL block_state deflate_slow(deflate_state *s, int flush) {
unsigned int max_insert = s->strstart + s->lookahead - STD_MIN_MATCH;
/* Do not insert strings in hash table beyond this. */
+ /* Before adopting the pending match, look one position past the one that just
+ * lost. Peek at the chain head without inserting, so backing out costs nothing.
+ */
+ if (lazy2 && s->lookahead > MIN_LOOKAHEAD && s->prev_length < s->max_lazy_match) {
+ uint32_t next_pos = s->strstart + 1;
+ uint32_t hash_head2 = s->head[update_hash_roll(s->ins_h, window[next_pos + STD_MIN_MATCH - 1])];
+
+ int64_t dist2 = (int64_t)next_pos - hash_head2;
+ if (dist2 <= MAX_DIST(s) && dist2 > 0 && hash_head2 != 0) {
+ uint32_t cur_len = s->prev_length;
+ uint32_t cur_dist = s->strstart - 1 - s->prev_match;
+ uint32_t saved_match_start = s->match_start;
+ uint32_t match_len2;
+
+ /* longest_match() scans from strstart, floors at prev_length and clamps
+ * to lookahead, so probe with both already stepped forward. */
+ s->strstart = next_pos;
+ s->lookahead--;
+ match_len2 = longest_match(s, hash_head2);
+ s->strstart = next_pos - 1;
+ s->lookahead++;
+
+ if (match_len2 > cur_len && !(match_len2 <= 5 && s->strategy == Z_FILTERED) &&
+ lazy_gain(match_len2, cur_len, next_pos - s->match_start, cur_dist) > LAZY2_MIN_GAIN) {
+ /* Two literals buy the better match. Emit the first one exactly as the
+ * single-literal step does, so a full symbol buffer is flushed with the
+ * output still drainable. */
+ bflush = zng_tr_tally_lit(s, window[s->strstart-1]);
+ if (UNLIKELY(bflush))
+ FLUSH_BLOCK_ONLY(s, window, 0);
+ s->prev_length = match_len;
+ s->strstart++;
+ s->lookahead--;
+ if (UNLIKELY(bflush)) {
+ /* Emitting more now would write symbols over pending output that
+ * has not been handed to the caller, so drop back to lazy matching.
+ */
+ s->match_start = saved_match_start;
+ if (UNLIKELY(s->strm->avail_out == 0))
+ return need_more;
+ continue;
+ }
+
+ bflush = zng_tr_tally_lit(s, window[s->strstart-1]);
+ if (UNLIKELY(bflush))
+ FLUSH_BLOCK_ONLY(s, window, 0);
+ s->prev_length = match_len2;
+ s->strstart++;
+ s->lookahead--;
+
+ /* Keep the rolling hash contiguous over the position we skipped. */
+ insert_roll(s, window, next_pos);
+
+ if (UNLIKELY(s->strm->avail_out == 0))
+ return need_more;
+ continue;
+ }
+ s->match_start = saved_match_start;
+ }
+ }
+
Assert((s->strstart-1) <= UINT16_MAX, "strstart-1 should fit in uint16_t");
check_match(s, s->strstart - 1, s->prev_match, s->prev_length);

zlib-ng: level 10 two-step lazy matching

A prototype compression level above 9 that spends more CPU on match selection to squeeze the output a little further. Branch base is upstream/develop at cb34e282.

What it does

Level 10 reuses deflate_slow with a lazy2 flag. When the lazy step is about to commit the pending match, it looks one position further before deciding. Accepting that later match costs two literals instead of one, so it has to gain more than the pending match is worth.

The acceptance rule is cost-aware rather than a plain length comparison:

static inline int lazy_gain(uint32_t new_len, uint32_t cur_len, uint32_t new_dist, uint32_t cur_dist) {
    return 4 * (int)(new_len - cur_len) + (int)zng_clz32(new_dist) - (int)zng_clz32(cur_dist);
}

A length byte is worth about four bits and a distance costs its magnitude in extra bits, so a shorter distance can offset a shorter match. That term is where most of the gain comes from. A plain new_len > cur_len test (threshold 0) only reaches -0.268% on Silesia against -0.310% for the cost rule at threshold 6.

The probe peeks at the hash chain head instead of inserting, so backing out costs nothing. The insert happens only on the accepted path, which keeps the rolling hash contiguous over the position that gets skipped.

Results

Silesia, deflate level 9 vs level 10, best of 3 runs.

file L9 bytes L10 bytes ratio time
nci 2998374 2923936 -2.483% +20.6%
xml 661887 655913 -0.903% +19.8%
webster 12114318 12035739 -0.649% +21.0%
samba 5406334 5381811 -0.454% +20.7%
reymont 1826403 1818186 -0.450% +22.1%
dickens 3859108 3843387 -0.407% +22.9%
osdb 3667508 3662304 -0.142% +8.7%
mr 3656167 3651900 -0.117% +22.2%
mozilla 19033237 19036400 +0.017% +10.1%
ooffice 3078277 3078811 +0.017% +8.7%
sao 5318086 5319425 +0.025% +5.7%
x-ray 5957207 5959780 +0.043% +12.4%
total 67576906 67367592 -0.310% +15.9%

Text and structured data pay off. The four regressions are all binary or numeric data where the extra literals rarely buy anything back.

Gain threshold sweep

Total Silesia delta and the worst single-file regression per threshold.

threshold total worst regression
0 -0.268% x-ray +0.189%
2 -0.293% x-ray +0.130%
4 -0.317% x-ray +0.123%
6 -0.310% x-ray +0.043%
8 -0.291% x-ray +0.010%
12 -0.228% mr +0.007%
16 -0.183% mozilla -0.017%
24 -0.141% ooffice +0.001%

Threshold 4 gives the best total, 8 removes the regressions almost entirely, 6 is the balance point and is what the patch ships.

Searching a shallower chain for the second probe was also tried, shifting max_chain_length right by 1 to 3. Ratio was unchanged and time only moved from +16.0% to +14.8%, so the cost is the probe call itself and not the chain depth. That matches level 9 already being past the point where a deeper chain pays.

Update: ported into the PR stack at level 9, with a probe gate

The probe was later merged directly into level 9 on the working stack (develop + open PRs + block splitter + TOO_FAR gate + match pricing), where it compounds with the other match-selection work: silesia ratio 3.1531 to 3.1664 (+0.42%) at +16% time.

The silesia-only validation above hid a failure mode. On match-dense data with degenerate chains the second search doubles the worst case: the literals synthetic data type went 4.2ms to 41ms (10x) for zero ratio change, and dna +42% for +0.29%. The fix is a per-block self-tuning gate: count probes and accepted probes, and stop probing the block once acceptance proves rare.

(s->lazy2_probes < 256 || s->lazy2_hits * 16 >= s->lazy2_probes)

With the gate, literals and dna return to full speed keeping their small ratio gains, and silesia keeps +0.35% ratio at +11% time. Any future use of this patch should include the gate, and validation should include literal-heavy and degenerate-chain inputs, not just silesia.

Validation

  • 70/70 ctest including gtest_zlib.
  • 2000 fuzz iterations over generated data with random input and output chunk sizes.
  • Chunked round-trips over all 12 Silesia files at chunk sizes 1, 7, 113, 4096, 65536.
  • Level 9 output byte-identical to pristine upstream/develop, so the change is inert below level 10.

One bug was worth recording. The first version corrupted streams under chunked I/O only. sym_buf lives inside pending_buf, and the two-literal path tallied its second symbol after a FLUSH_BLOCK_ONLY that could not drain, so the symbol write landed on compressed output that had not been handed to the caller yet. Every encoder-side invariant still held, which is what made it slow to find: symbols replayed correctly against the input, frequencies matched the symbol buffer, distances were in range, and blocks tiled the stream exactly. The patch now emits the first literal exactly as the single-literal step does and falls back to plain lazy matching if the symbol buffer fills between the pair.

Open question

deflateInit2 and deflateParams now accept level 10, which diverges from zlib in ZLIB_COMPAT builds where zlib returns Z_STREAM_ERROR. Gating it behind !ZLIB_COMPAT is the conservative option.

Machine

Apple M5, 10 cores, 32 GB, macOS 26.6.2, Apple clang 21.0.0. Release build, -D BUILD_SHARED_LIBS=OFF -D ZLIB_COMPAT=ON -D WITH_MAINTAINER_WARNINGS=ON.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment