Skip to content

lib/deflate_compress: speed up level 1 on large inputs with few matches - #476

Closed
ptweezy wants to merge 1 commit into
ebiggers:masterfrom
ptweezy:level1-few-matches
Closed

ptweezy wants to merge 1 commit into
ebiggers:masterfrom
ptweezy:level1-few-matches

Conversation

@ptweezy

@ptweezy ptweezy commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Opening this as a draft to ask whether it's worth having. It makes level 1 much faster on large inputs that barely compress, such as gzipped or random data, and leaves all other inputs on the existing code path. It adds about 420 lines.

At level 1, ht_matchfinder_longest_match() checks whether each hash table entry is in the window before comparing bytes. On random data, level 1 mispredicts about 0.7 branches per byte, nearly all of them in these checks. This adds a check that compares the bytes first. A copy of the level-1 loop, deflate_compress_fastest_adaptive(), switches to it after any 32 KiB interval with fewer matches than literals. Only level-1 inputs of at least 16 MiB, in which a small probe finds few matches, go there. deflate_compress_fastest() is unchanged, and so is the output.

Speed change at level 1 (for the runners, the range of the means per runner type and compiler):

Input Zen 5, gcc / clang GitHub runners
Silesia files, each gzipped (65 MiB) +160% / +220% +68% to +199%
Random data (50 MiB) +175% / +250% +73% to +224%
16 MiB synthetic inputs with few matches +15% to +51% +8% to +49%
Silesia files, enwik8 within ±0.34% within ±0.4% on Linux and Windows ARM

Costs:

  • No gain below 16 MiB. With a 4 MiB minimum, 4 MiB chunks of osdb, sao and x-ray were 27% to 85% faster on Zen 5, but inputs the probe rejected were up to 0.72% slower.
  • On inputs of at least 16 MiB that it rejects, the probe costs nothing measurable on text, where it stops after 1 to 2 KiB. On synthetic inputs whose matches mostly come from 4 to 32 KiB back, it cost up to 0.33% (Zen 5).
  • With clang and MSVC, the new functions move deflate_flush_block(). That changed the speed of the unchanged code by -0.7% to +2.1%, depending on compiler and CPU.

Measurement details and testing are in the commit message.

@ptweezy
ptweezy marked this pull request as draft November 8, 2026 01:38
At level 1, ht_matchfinder_longest_match() checks whether each of the
two entries in the hash bucket is in the window before comparing the
bytes at it.  On inputs with few matches, these checks are hard to
predict: on random data, level 1 mispredicts about 0.7 branches per
byte, nearly twice as many as on dickens.

Add ht_matchfinder_longest_match_branchless(), which compares the bytes
first and checks the window only if they match.
ht_matchfinder_node_ptr() computes an entry's pointer without
branching; for an entry out of the window, it points into
[in_base, in_next], so it can be read as far as in_next can.  On random
data, this leaves under 0.005 mispredicted branches per byte.

Always checking this way would make level 1 up to about 6% slower on
text, and any change to the loop in deflate_compress_fastest() can slow
it down through register allocation or code placement.  So that
function is left as it is, and libdeflate_deflate_compress() uses the
new deflate_compress_fastest_adaptive() only for level-1 inputs of at
least 16 MiB in which deflate_fastest_few_matches() finds few matches.

The probe parses four 4 KiB windows greedily and requires more than 8
literals per match in each.  Then, to see matches up to 32 KiB back, it
parses the first window again after inserting the 28 KiB before it, and
requires more than 2.  The text files tested fail in the first window,
after 1 to 2 KiB.  deflate_compress_fastest_adaptive() has a copy of
the loop for each way of checking; after each interval of up to 32 KiB,
it uses the new way for the next one if that one had fewer matches than
literals.  The output is unchanged.

Speed change at level 1 on a Ryzen 7 9800X3D (Zen 5), with -O2
-falign-functions=4096, from user-mode cycles (median of 11 interleaved
rounds of the best of 3 runs, averaged over two passes):

	                                gcc 13          clang 18
	Silesia, whole files            -0.04%          +0.00%
	enwik8                          -0.01%          +0.09%
	Silesia, each file gzipped      +160%           +220%
	random data                     +175%           +250%
	16 MiB synthetic, few matches   +15% to +42%    +17% to +51%
	16 MiB synthetic, rejected      -0.21% to 0%    -0.33% to +0.03%

No Silesia file was more than 0.34% slower, and an A/A control moved
single inputs by up to 0.24%.  The synthetic inputs are random bytes
over 13 to 16 symbols, random 4- or 8-byte tokens, and random bytes
with 85% to 95% of them in short copies.  The six that the probe
rejects have most of their matches 4 to 32 KiB back, so they reach its
second stage: the probe costs the most on them, and less as inputs
grow.

Tested: identical output at levels 0, 1, 2, 6, 9 and 12 (gcc, and clang
with ASan and UBSan); with the minimum at 4 MiB, also at level 1 under
qemu (i386, s390x, armhf, aarch64), MSan and valgrind; with it at
256 KiB, the deflate_compress fuzzer (734,441 runs) and a fuzzer
comparing the output with the old code's (215,966 runs); and
scripts/run_tests.sh with gcc and clang.
@ptweezy
ptweezy force-pushed the level1-few-matches branch from 1b8cb3d to 0c51e0c Compare November 8, 2026 01:40
@ptweezy ptweezy closed this Nov 8, 2026
@ptweezy
ptweezy deleted the level1-few-matches branch November 9, 2026 05:01
@ptweezy
ptweezy restored the level1-few-matches branch November 12, 2026 01:53
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