Making LZ4’s Multithreaded File Compression Scale

lz4 v1.10.0 development tree (upstream commit 0774d05)

Benchmarks: official lz4 -b bench (silesia / enwik8 / calgary) · file-mode wall clock on an 8× silesia 1.7 GB corpus · head-to-head vs pigz, pzstd and zstd -T

File-mode compressed output byte-identical to stock lz4 at every tested level, verified by cross-decoding with the pristine upstream binary

Optimized end-to-end by KISS Sorcar in about 7½ hours of agent wall-clock time, on a budget under $600

What this is about

LZ4 is the codec systems reach for when compression must never become the bottleneck: it sits inside the Linux kernel (zram, zswap, squashfs), ZFS and btrfs, game engines, and databases. Version 1.10.0 of the command-line tool added multithreaded compression (-T#). This post describes making that multithreading actually scale on a many-core machine — up to 2.57× faster than the stock CLI at equal thread counts — plus smaller single-thread gains from a corrected profile-guided build, while keeping the compressed file output byte-for-byte identical to upstream.

The problem

LZ4’s compression core has been micro-optimized by its authors for over a decade; the single-thread hot loop is memory-bandwidth- and branch-limited, and build-level tricks recover only 1–2% there. The opportunity is one level up. Profiling the v1.10.0 CLI at -T16 on a 16-core machine showed only about 5 of 16 cores busy: throughput plateaued near 1.5–2 GB/s no matter how many threads were requested.

The cause is structural. The file-compression pipeline reads the input through a single self-resubmitting job in the same thread pool as the compression workers: allocate a fresh 4 MB buffer, fread the next chunk, run the frame checksum serially, then re-queue behind the compression jobs. Sixteen workers wait on one reader. On top of that, the project’s object-file cache turned out to silently defeat profile-guided optimization in a way that is easy to miss (details below).

LZ4’s reputation rests on the stability of its format, so the binding constraint was: the bytes coming out must not change. Every optimization had to keep file-mode output identical to the stock binary, keep every other mode identical to the pre-change binary, survive adversarial and sanitizer testing, and leave the serial code path untouched for pipes, dependent-block mode, the legacy format, and platforms without pread.

Summary

Result: at equal thread counts, multithreaded file compression is 1.88–2.57× faster than stock lz4 at level -1 (3,810 vs 1,519 MB/s at 32 threads), 1.21–1.27× faster at level -3, and 7–16% faster at levels -6 and -9; single-threaded high-compression levels gained 7% from a retrained PGO build. The compressed file output is byte-identical to stock at every tested level (-1/-3/-6/-9), and the patched CLI now wins the parallel file-compression race against pzstd and zstd -T at 16–32 threads, a race it previously lost.

All of the work (profiling, the parallel-read redesign, the benchmark harness, adversarial testing, and reviews) was carried out by KISS Sorcar in about 7½ hours on a budget under $600, using claude-fable-5 for development and adversarial breaking, gpt-5.6-sol for independent read-only review (it found four real defects, all fixed), and kimi-k3 for independent falsification of review findings.

Final measured results

Protocol: pristine stock lz4 v1.10.0 (upstream commit 0774d05, plain make, its own built-in -T# multithreading) versus the patched build, both compressing the same 1.7 GB corpus (8× silesia.tar in tmpfs) to /dev/null: lz4 -<level> -T<threads> -c silesia8.tar. Runs interleaved stock/patched, best of 3, on an idle 2× Xeon 2.80 GHz box (16 physical cores, 32 hyperthreads). 64 threads means 2× oversubscription.

LevelThreads Stock (MB/s)Patched (MB/s) Advantage Stock scaling vs its own T1 Patched scaling
-1 (fast) 1426.6419.3 0.98× (noise)1.0×1.0×
81,614.93,028.0 1.88×3.8×7.2×
161,583.33,670.3 2.32×3.7×8.8×
321,519.43,810.5 2.51×3.6×9.1×
641,473.23,785.0 2.57×3.5×9.0×
-3 (HC) 181.387.2 1.07×1.0×1.0×
161,057.81,276.9 1.21×13.0×14.6×
321,293.41,638.3 1.27×15.9×18.8×
641,213.81,477.1 1.22×14.9×16.9×
-6 (HC) 149.553.0 1.07×1.0×1.0×
16730.9798.0 1.09×14.8×15.1×
32929.61,068.5 1.15×18.8×20.2×
64883.61,021.5 1.16×17.9×19.3×
-9 (HC) 131.934.0 1.07×1.0×1.0×
16474.2506.3 1.07×14.9×14.9×
32626.9684.0 1.09×19.7×20.1×
64609.1678.3 1.11×19.1×19.9×
0 1000 2000 3000 4000 MB/s 1 thread 427 / 419 8 threads 3,028 1,615 16 threads 3,670 1,583 32 threads 3,810 1,519 64 threads 3,785 1,473 stock lz4 -T# patched
Level -1 file compression throughput on the 1.7 GB corpus. Stock plateaus near 1.5 GB/s regardless of thread count; the patched CLI reaches 3.8 GB/s. Best of 3 interleaved runs.

Reading the table: the advantage is level-dependent for a physical reason. At -1 the stock serial read→checksum→dispatch loop starves the workers (stock stalls at 3.5–3.8× scaling), so removing it is worth 1.88–2.57×. At the high-compression levels the compression itself dominates (30–80 MB/s per core) and both binaries scale to ~19–20× on 16 cores plus hyperthreads; the remaining 7–27% comes from HC-aware PGO, LTO and native codegen plus slightly better read/compute overlap. Oversubscribing to 64 threads collapses neither binary. Single-threaded level -1 is unchanged (0.98× is within noise, confirmed with 5 extra interleaved runs).

Against the other parallel compressors

Fast compression is only interesting if it holds up against the tools people would actually reach for. Same machine, same 1.7 GB tmpfs corpus, output to /dev/null, every output decompressed and md5-verified against the original. Before the parallel-read fix, the lz4 CLI lost this race above 8 threads; now:

Tool (fastest level) 16 threads (MB/s) 32 threads (MB/s)
lz4 -1 -T# (patched) 3,594–3,675 3,594–3,675
pzstd -1 -p# 2,695–2,741 2,788–2,888
zstd -1 -T# 2,488–2,608 2,651–2,940
pigz (gzip) 1,351 1,314

Ranges are interleaved best-of-3 spreads from the same session. The trade-off is unchanged and worth stating plainly: lz4’s compression ratio on this corpus is 2.10 versus ~2.8–2.9 for the zstd/gzip family — lz4 buys speed with ratio, by design. pzstd also retains a parallel decompression path that the lz4 CLI does not have; lz4’s single-threaded file decode runs at ~1.7 GB/s.

What was changed

All changes preserve the stock code path where the redesign does not apply. Nothing about the frame format, block splitting, or checksumming changed: file-mode output bytes are identical to stock, and every mode is byte-identical to the pre-change binary.

1. Parallel chunk reads for multithreaded file compression (largest gain)

2. Opt-in multithreaded benchmark mode (-T# / --mt)

3. A profile-guided build — and the two build defects it exposed

The remaining ceiling, measured

Why stop at ~3.8 GB/s? The LZ4 frame format’s content checksum is a single sequential XXH32 over the whole input, and under pipeline load it runs at about 3.7 GB/s on this machine. Compressing with --no-frame-crc reaches 6.4–8.1 GB/s — which proves nothing else in the pipeline limits scaling, and also why that number is not the headline: dropping the checksum (or emitting multiple frames) changes the output bytes, and was rejected as cheating. 4 GB/s with the default checksum is physically unreachable on this hardware.

How the work was verified

Reproducing the results

The patch series is 15 commits on top of upstream 0774d05, touching programs/lz4io.c, programs/bench.c, the CLI option parsing, and the new build-optimized.sh; every measurement round is recorded in OPTIMIZATION-RESULTS.md in the tree. The series has not yet been published as a public fork, so the protocol below is what an independent run looks like once it is:

# pristine baseline
git clone https://github.com/lz4/lz4 lz4-stock
git -C lz4-stock checkout 0774d05 && make -C lz4-stock -j

# patched build, from a sibling checkout of the patch series
# (PGO trains levels 1, 3 and 9; the script aborts on any missing-profile warning)
( cd lz4-patched && ./build-optimized.sh )

# 1.7 GB corpus in RAM (silesia.tar is the standard Silesia corpus as one tar)
rm -f /dev/shm/silesia8.tar
for i in $(seq 8); do cat silesia.tar >> /dev/shm/silesia8.tar; done

# interleaved level x thread sweep
for L in 1 3 6 9; do for T in 8 16 32 64; do
  /usr/bin/time -f "stock   -$L -T$T: %e s" ./lz4-stock/lz4   -$L -T$T -c /dev/shm/silesia8.tar > /dev/null
  /usr/bin/time -f "patched -$L -T$T: %e s" ./lz4-patched/lz4 -$L -T$T -c /dev/shm/silesia8.tar > /dev/null
done; done

# byte-identity: compressed streams from both binaries must hash the same
./lz4-stock/lz4   -9 -T16 -c /dev/shm/silesia8.tar | md5sum
./lz4-patched/lz4 -9 -T16 -c /dev/shm/silesia8.tar | md5sum

# cross-decode: patched output, decoded by the stock binary, must equal the input
md5sum /dev/shm/silesia8.tar
./lz4-patched/lz4 -9 -T16 -c /dev/shm/silesia8.tar | ./lz4-stock/lz4 -d | md5sum

Absolute throughput depends on core count, memory bandwidth and the sequential-XXH32 ceiling of the machine; the shape of the result — stock plateauing at 3.5–3.8× scaling at level -1 while the patched CLI reaches ~9×, and near-parity scaling at high-compression levels with a single-digit codegen win — is what the patch series reproduces. All measurements so far are from one Linux machine (2× Xeon, 16 physical cores); platforms and modes where the pread parallel-read path does not apply (Windows, pipes, dependent blocks, the legacy format) keep the stock serial path.