Skip to content

deflate: decode, then encode, faster than libdeflate on the hosted runners #13

Open

Description

@c4milo

The owner's goal, 2026-09-27: stdx's DEFLATE decoder faster than libdeflate on GitHub's hosted runners, then each encoder level. Decision 14 expected 1 MiB decoding to match libdeflate at best; this issue aims past that.

Where it stands. Bench run 36267746852 at 7aa0098, reports bench/results/2026-09-26-deflate-encoder-search-*.md, 38 corpus files:

  • gzip decoding of zlib level 6 streams: stdx runs at a median of 0.74 of libdeflate's speed on an AMD EPYC 9V45 and 0.70 on the Neoverse N2, and is faster on 7 of 38 files on each. The wins are the four 1 KiB bodies and grammar.lsp on both runners, with kennedy.xls and xargs.1 (1.001) on the EPYC and xargs.1 and json-16k on the N2. The largest losses are asyoulik.txt (0.55) and alice29.txt (0.57) on the EPYC, and nci and ooffice (0.64) on the N2.
  • gzip encoding speed over libdeflate's: level 1 at 0.53 on x86-64 and 0.49 on aarch64, level 6 at 0.45 and 0.47, level 9 at 0.79 and 0.73.
  • Ratio over libdeflate's: 0.904 at level 1, 1.000 at level 6, 0.989 at level 9.

Target.

  1. Decoding: a median above 1.00 of libdeflate's speed on ubuntu-24.04 and on ubuntu-24.04-arm, over all 39 corpus files, shuffled/dickens-1m among them.
  2. Then encoding: each of levels 1, 6 and 9 above 1.00 of libdeflate's speed at the same level, at a ratio no worse than libdeflate's.

Every file where stdx still loses is listed, on each runner.

Rules that bind the work.

  • libdeflate is a baseline only. Nobody reads its source, or any other implementation's (non-negotiable 5, decision 9). The work comes from RFC 1951 and from measurements.
  • Numbers come from the runners (decisions 10 and 20): bench.yml with benchmark=deflate for the ratios, and benchmark=profile for cycles, instructions and branch misses per octet. The N2's counters judge a decoder change; the M1 Pro only filters. The x86-64 runner draws an AMD or an Intel CPU at random, so only ratios within one job compare.
  • Fast paths follow decision 16. @setRuntimeSafety(false) needs an A/B and the owner's ruling. Assembly for DEFLATE needs a decision entry, as decision 23 is for Zstandard, and the owner's ruling before any is written.
  • A change that does not beat the 5% noise floor leaves with its code. Each performance commit carries the comparison with libdeflate and zlib-ng, and each new check its mutations.
  • No regression on the owner's M1 Pro: each performance commit is measured on the Mac against main first.

The check. bench-deflate on both runners in one run: the median of stdx over libdeflate above 1.00 over the 39 files for decoding, then per encoder level for speed with the ratio held, the losing files listed. differential-deflate and differential-encode pass, the fuzzer runs, and every new check reports its mutations. Each result goes into design §8, under steps 7 and 9.

Activity

  1. c4milo commented on Sep 28, 2026

    @c4milo
    OwnerAuthor

    Target 1, decoding: met. Landed on main as 7c29419 to 11b5b8a. Medians of stdx's gzip decode speed over libdeflate's, 39 files, zlib level 6:

    Runner CPU stdx / libdeflate Faster on
    ubuntu-24.04-arm Neoverse-N2 1.101 26 of 39
    ubuntu-24.04 AMD EPYC 9V74 1.028 23
    ubuntu-24.04 Intel Xeon 6973P-C, three draws 1.021, 1.010, 1.008 21, 21, 20
    ubuntu-24.04 Intel Xeon Platinum 8573C, two draws 1.005, 1.014 21, 21

    Runs 36478169575 (N2 and EPYC) and 36478163045 (Intel: a temporary workflow that kept only the Intel CPUs its jobs drew). Against zlib-ng: 1.40, 1.20 and 1.30 to 1.33.

    Where stdx still loses, below 1.00 of libdeflate:

    • N2: mozilla 0.90, nci 0.90, ooffice 0.91, sao 0.92, osdb 0.94, ptt5 0.94, samba 0.95, dickens-1m 0.96, xml 0.99, html-1m 0.99; json-1m, webster and css-1m round to 1.00.
    • EPYC 9V74: nci 0.89, html-1m 0.90, xml, osdb and json-1m 0.93, mozilla 0.94, samba 0.95, html-16k 0.95, sao, js-1m and ooffice 0.96, js-16k 0.97, cp.html and webster 0.98, css-16k and world192.txt 0.99.
    • Intel, 18 files each: the 1 MiB HTTP bodies at 0.82 to 0.93, ptt5 0.84 to 0.86, nci 0.84 to 0.89, xml, osdb, mozilla, samba, ooffice and sao at 0.91 to 0.98, html-16k, js-16k, css-16k and cp.html at 0.94 to 0.98, and world192.txt, webster or dickens-1m.

    What moved it, with each step's runs: design §8 step 7, the entry "Issue 13, checked on 2026-09-28". The common loop runs in assembly under decision 29, numbered after main's 27 and 28. CI passed on all three runners: run 36489727777.

    Next: the files above, then encoding. The median target is met, but stdx still loses 13 to 18 files per CPU, so the decoder work goes on with them first. The lead is the branch between combined and plain table entries in blocks that mix both: on a Xeon 8370C, those files ran 4 to 9% faster with S11 off, while text ran slower.

    For target 2, at the same commit stdx encodes at 0.54 of libdeflate's speed at level 1 on the EPYC and 0.49 on the N2, 0.50 and 0.47 at level 6, and 0.83 and 0.72 at level 9. Its ratio is 0.909 of libdeflate's at level 1, 1.000 at level 6 and 0.990 at level 9 (run 36478169575).

  2. c4milo commented on Sep 29, 2026

    @c4milo
    OwnerAuthor

    Second round, 2026-09-29, for the files that still lose. The median target held; the owner asked for every file and ruled the constraints in decision 34 (smaller gains may stay, a 12-bit table measured, x86-64 also benchmarked at v3), and then kept the clean-room rule: libdeflate stays a black box.

    Method. Raw DEFLATE streams whose symbol statistics vary one at a time (literal code lengths, run lengths, distances, lengths, plain share, near sources, random distances, mixes), decoded by libdeflate and by stdx in one program on each runner CPU drawn. Runs 36504398125, 36505458044, 36506506249, 36507428104, 36508216806.

    Found.

    • Literals alone: stdx at 0.93 to 0.97 of libdeflate on every CPU. The refill after a run's last literal sat on the lookup chain. Not a branch, not the table load's addressing mode (scaled and byte-offset loads take the same 4 cycles everywhere).
    • Distances 17 to 31: the copy's second chunk loads octets the first chunk's store has not committed: +18 cycles a match on the N2, +10 on x86-64. libdeflate is fast at 1, 2, 3, 4, 8, 16, 24, 32 and slow elsewhere.
    • A mispredicted length or literal-after-match branch costs 10 to 17 cycles, the latter alike for both decoders. An alignment-only x86-64 change moves Intel files by ±2 to 3%.
    • No cache-footprint effect up to 8 KiB distances.

    Landed (f4c60e3): the aarch64 lookup before the refill: N2, six jobs, sao, osdb, samba and mozilla +1 to 3%, no file down; and the x86-64-v3 report in bench-deflate (stdx built for v3 runs 1.1% faster at the median on an EPYC 7763, the small bodies 4 to 8%).

    Measured and not kept. The x86-64 early lookup (Intel text −2 to 6%, ooffice −3.6% on a 9V74); four literals per refill (loses to it everywhere); a rolling copy and a loads-first copy for distances 17 to 31 (2.5× on the streams, 0.985 and 0.991 on the N2 corpus: those distances are 3 to 9% of a file's matches, and a branch taken that rarely is mispredicted nearly every time it is taken); plain tables for blocks under a combined-share threshold on x86-64 (at 7/8, 4/5 and 5/6: nci +3 to 10% and ptt5 +2.5 to 4% on Intel, but css-1m −5% on an EPYC 7763 and, at 5/6, osdb −2.9% and a median of 0.995 on the Intel 8573C); the per-block 12-bit table (css-1m and html-1m +3 to 7%, text −2 to 4% on AMD, nci −3% on the N2).

    Standing at f4c60e3, from the A/B jobs' baselines: N2 1.090 to 1.094 of libdeflate, faster on 28 or 29 of 39, behind on ooffice 0.90, nci 0.90, mozilla 0.91, ptt5 0.94, osdb 0.95, sao 0.96, samba 0.96, dickens-1m 0.97; EPYC 7763 1.015 to 1.026 (24 or 25); EPYC 9V45 1.032 (21; ptt5 0.86, nci 0.88, css-1m 0.89, html-1m 0.90); Intel Xeon 8573C 0.984 and 0.997 (18 or 19; ptt5 0.84, css-1m 0.87, html-1m 0.87, nci 0.89). On the N2 the remaining gap on the literal-heavy files is the branch misses of S11's combined-or-plain entry and of the literal runs; on Intel it is the plain share of the HTTP bodies. Details in design §8 step 7.

  3. c4milo commented on Sep 29, 2026

    @c4milo
    OwnerAuthor

    The encoder, 2026-09-29: fast loops and decision 36

    Seven commits on main, 6ed7806 to 5e36c10: design §8 step 9's entry of 2026-09-29 and decision 36 hold the record.

    Speed with the same output (run 36556604513, main and 8eae6af in one job; stdx's gzip encoder over libdeflate's, the median of 39 files, and stdx over its own before):

    Level N2 before N2 after EPYC 7763 before EPYC 7763 after
    1 0.49 0.84 (1.68 times, no file slower) 0.52 0.85 (1.71)
    6 0.47 0.59 (1.26) 0.51 0.60 (1.18)
    9 0.73 0.89 (1.25) 0.84 0.96 (1.07; json-16k 0.87, kennedy.xls 0.95)

    The symbol writer stores 8 octets at once and runs on local state; the chain walk hoists its checks; the greedy and lazy levels decide the positions with the whole lookahead ahead in loops that keep their state in locals.

    Size, from a comparison of libdeflate's output symbols with stdx's (decision 36, run 36559451595; output over libdeflate's, the 39 files' median):

    Level Before After Speed over libdeflate after, N2 / EPYC
    1 1.101 1.055 0.71 / 0.77
    6 1.000 0.999 (json-1m 1.077 to 0.994) 0.58 / 0.57
    9 1.011 1.010 0.84 / 0.88

    Level 1 makes the positions a match of 8 octets or fewer covers heads too; the lazy step compares its two matches by length less a distance penalty. Their cost on the runners: level 1 at 0.91 to 0.93 of its speed (text 0.80 to 0.88, E.coli 0.68 to 0.73); level 6 json-1m at 0.75; level 9 json-1m at 0.63 to 0.66.

    What stands between level 6 and libdeflate: the chain walk, about 10 cycles a candidate (two dependent loads) at 2 to 5 candidates an octet. Refused at equal memory: bucketed heads, an 8-octet second table, a shorter lazy length, the penalty inside the walk. A 64-candidate budget would trade 0.3 to 0.6% of size for 12 to 27% of time; not ruled. The 1 KiB files stay the slowest against libdeflate at every level (0.34 to 0.45 on the N2).

  4. c4milo commented on Sep 29, 2026

    @c4milo
    OwnerAuthor

    Level 6's budget, 2026-09-29: 64 candidates with a cut of 16, measured at the owner's request and adopted (8847786; decision 36 records it). Run 36568474837, main and the budget in one job: level 6's speed over libdeflate's from 0.58 to 0.66 on the N2 and from 0.57 to 0.64 on the EPYC 7763, stdx at 1.12 and 1.15 times its own speed at the median with no file slower (E.coli 1.7, reymont 1.3, json-1m 1.3). Output over libdeflate's stays at the median (0.999 to 1.000); per file a median 0.15% larger, 16 of 39 files over 0.2%, the worst E.coli 1.6%, reymont 1.2%, xml 1.1%. Levels 1 and 9 unchanged. Reports: bench/results/2026-09-29-deflate-encoder-budget-*.md.

  5. c4milo commented on Sep 29, 2026

    @c4milo
    OwnerAuthor

    Level 6's chain walk, 2026-09-29: after a taken match the lazy step's two searches now walk their chains in one loop, so their dependent loads overlap (7f262fa, best_pair; design §8 step 9's entry). Output is identical on every file. Run 36594809955, main and the change in one job: level 6's speed over libdeflate's from 0.66 to 0.69 on the N2 and from 0.62 to 0.67 on an EPYC 9V74, stdx at 1.07 and 1.06 times its own speed at the median, the text files 1.12 to 1.16, E.coli 1.12 and 1.35, the four 1 KiB files 0.98 to 0.99. Compression unchanged: 35.8%, 29.4% and 29.2% of the input at levels 1, 6 and 9 (medians), 1.055, 1.000 and 1.010 of libdeflate's output.

    Measured and not kept: the pair at level 9 (kennedy.xls 0.87 on both runners, run 36586101645), and a prefetch of the head the step inserts next (a wash, run 36586241515). Level 6 stands at 0.67 to 0.69 of libdeflate's speed at equal size; what remains is the walk's own latency, two dependent loads a candidate, which only a structure with more state would shorten.

  6. c4milo commented on Oct 17, 2026

    @c4milo
    OwnerAuthor

    The encoder, 2026-10-17: where each level stands against libdeflate. This comment covers the encoder only. Main is 2038eea, and its encoder code is the code of f0ff50d.

    The numbers come from runs 38298500583 and 38298503481. Each job ran main (1e300a2) first and then f0ff50d. The tables read the second program of run 38298503481, which drew a Neoverse N2 and an AMD EPYC 7763. The N2's other job gives the same counts. The other x86-64 job drew an EPYC 9V45, whose second program ran faster for libdeflate too, so it is not used here. Sizes are gzip members.

    Level N2: median of stdx over libdeflate N2: rows slower EPYC 7763: median EPYC 7763: rows slower Output over libdeflate's, geometric mean Rows larger
    1 1.059 13 of 39 1.055 15 1.0590 38
    6 1.052 14 0.990 20 0.9979 11
    9 1.742 4 1.781 2 1.0080 33

    Rows slower than libdeflate, as stdx's speed over libdeflate's:

    • Level 1, N2: dickens and reymont 0.91; plrabn12.txt, bible.txt, world192.txt, alice29.txt, webster, E.coli and lcet10.txt 0.92; mr and asyoulik.txt 0.93; js-1m and osdb 0.99.
    • Level 1, EPYC 7763: dickens and plrabn12.txt 0.84; E.coli 0.85; asyoulik.txt and alice29.txt 0.87; reymont, lcet10.txt, world192.txt and bible.txt 0.89; webster and mr 0.90; sum 0.93; kennedy.xls 0.94; js-1m and osdb 0.96.
    • Level 6, N2: x-ray 0.86; mr 0.88; ooffice 0.91; shuffled dickens, plrabn12.txt and asyoulik.txt 0.96; mozilla, world192.txt and dickens 0.97; sum, osdb, webster and reymont 0.98; alice29.txt 0.99.
    • Level 6, EPYC 7763: x-ray 0.85; mr 0.86; world192.txt 0.89; ooffice 0.90; asyoulik.txt, webster, sum, plrabn12.txt and dickens 0.91; mozilla, alice29.txt and reymont 0.92; lcet10.txt and osdb 0.93; shuffled dickens 0.94; bible.txt 0.95; cp.html and js-16kx64 0.97; js-1m 0.98; nci 0.99.
    • Level 9, N2: shuffled dickens 0.88; x-ray 0.92; osdb and xargs.1 0.97.
    • Level 9, EPYC 7763: x-ray 0.92; shuffled dickens 0.97.

    Rows larger than libdeflate's:

    • Level 1: 38 of 39 rows. The largest are nci 13.5%, world192.txt 13.1%, osdb 11.6%, css-1m 11.4%, xml 11.3%, js-1m 10.8% and html-1m 10.6%.
    • Level 6: sum 3.5%, x-ray 1.4%, mozilla 0.7%, ooffice 0.6%, kennedy.xls 0.5%, shuffled dickens 0.4%, sao, osdb and E.coli 0.2%, nci 0.1%, and asyoulik.txt by less than 0.1%.
    • Level 9: 33 rows. The largest are kennedy.xls 10.4%, css-1m 2.8%, sum 2.1%, x-ray 1.5%, lcet10.txt 1.3%, webster 1.2% and bible.txt 1.1%. The other 26 are larger by 1.0% or less.

    What landed after the last comment. Design §8 step 9 holds each change with its runs.

    • Decision 42, 2026-10-01: a lazy level prices its matches after a block of cheap literals.
    • Decision 44, 2026-10-04: a lazy level ends a block where a new block with its own codes costs less. A block may cross a slide of the window.
    • Decision 46, 2026-10-11: level 9 searches the chain of its match's tail.
    • Decision 48, 2026-10-11: level 6 searches by its match's tail, with 24 links a search.
    • Decision 49, 2026-10-12: the encoder's runtime safety checks are the caller's choice. The bar reads the checked encoder, which a caller gets by default.
    • Decision 50, 2026-10-12: levels 6 and 9 take matches of 3 octets after a block of dear literals. At level 6, x-ray comes out 2.9% smaller and ooffice 2.1% smaller.
    • fad7ed7 and f0ff50d, 2026-10-12: two cuts to the steps of decision 50. They write the same octets. On the N2 they make the rows that take those steps 1% to 4.5% faster.

    Next. The owner chose level 6 on x86-64 as the next gap on 2026-10-17.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions