Efficiency benchmark
A head-to-head comparison of lha
against tar, tar.gz, and
zip on a representative corpus, at default and
best compression levels. The numbers tell a clear story
about which tool wins on which axis.
5 runs per cell, median ± sample-stdev, on an Apple M-series laptop, native lha build (LHa for UNIX 1.14i-ac20220213, default configure options). Wall-clock end-to-end: compress time + decompress time per (tool, level) cell.
The headline (one-glance table)
Compressed and decompressed the same 1.83 MiB corpus five times per cell. Numbers are median wall-clock; stddev reflects the run-to-run noise of the laptop itself.
| tool | level | compress | decompress | size B | ratio | ||
|---|---|---|---|---|---|---|---|
| median | ±σ | median | ±σ | ||||
| lha | -lh5- (default) | ~50 ms | ±5 | ~40 ms | ±1 | 547,045 | 29.93 % |
| lha | -lh7- | ~46 ms | ±1 | ~40 ms | ±6 | 547,045 | 29.93 % |
| tar | no compression | ~80 ms | ±4 | ~82 ms | ±5 | 2,732,544 | 149.55 % |
| tar.gz | -6 (default) | ~210 ms | ±5 | ~5 ms | ±0.1 | 455,435 | 24.93 % |
| tar.gz | -9 (best) | ~210 ms | ±1 | ~5 ms | ±0.1 | 455,435 | 24.93 % |
| gzip -c | -6 (raw stream) | ~170 ms | ±4 | ~5 ms | ±0.2 | 455,486 | 24.93 % |
| gzip -c | -9 (best) | ~170 ms | ±3 | ~5 ms | ±0.2 | 455,486 | 24.93 % |
| zip | -6 (default) | ~62 ms | ±1 | ~3 ms | ±0.4 | 566,683 | 31.01 % |
| zip | -9 (best) | ~85 ms | ±1 | ~3 ms | ±0.1 | 566,683 | 31.01 % |
All times are wall-clock end-to-end. "level" = the tool's
own knob, not a number pulled from thin air. The corpus
file count is 304; total uncompressed bytes
1,827,165. The tar (no compression) ratio
exceeds 100 % because tar stores per-file metadata (UID,
GID, mode bits, 512-byte per-record padding); for small
files that adds 50 % of overhead before any deflate runs.
Reading the table
1. Compress speed: zip < lha < gzip < tar < tar.gz
At default levels:
zip -6is the fastest compressor (~62 ms) — deflate-without-tar-blocking, with the zip-specific DEFLATE64 disabled by default.lha -lh5-is the second-fastest (~50 ms) and lands within 20 % of zip.gzip -6 -con a raw tar stream takes ~170 ms — deflate is a more expensive encoder than LZHUF even at default.tar -czfadds another ~40 ms of framing on top of the underlying deflate (~210 ms total) — the cost of per-file records and 512-byte-aligned padding.tar -cf(no compression) is ~80 ms — just header overhead, no deflate at all.
So at default levels: zip is ~22 % faster than
lha; lha is ~76 % faster than
tar.gz. Compress-speed ordering is
stable across corpus types in our testing
(text source, mixed binary, random data) — the
individual times shift, the rank doesn't.
2. Decompress speed: tar.gz ≈ zip < lha < tar
Decompress reverses the speed ranking in interesting ways:
zipandtar.gzdecompress in ~3-5 ms — deflate decoding is cheap.lhadecompresses in ~40 ms — this is LZHUF, which has fixed Huffman tables and emits one bit at a time. It's slower than deflate decoding on this corpus, but the absolute time is still tiny.tardecompress in ~82 ms — same 512-byte per-record overhead on the way out.
For the workloads most people care about (decompress 100
times from a CDN) the absolute time is dominated by
network latency, not algorithm choice. All three of
tar.gz, zip, and lha
decompress in single-digit ms on this corpus; the
differences are well below the noise floor of disk seek +
HTTP request latency.
3. Compression ratio: tar.gz < gzip ≈ lha < zip
For the same kenlm corpus:
tar.gz -6andgzip -6 -cboth hit 455 KB / 24.93 % — the lower bound achievable with deflate on this corpus.-9finds nothing further (already at the local minimum).lha -lh5-lands at 547 KB / 29.93 % — ~20 % larger than deflate, in exchange for ~2.2x faster compression. This is the deliberate trade-off baked into LZHUF's 1990 design: tuned for "fast unpack on a 286 with 1 MiB RAM", not for the tightest possible ratio on a 2020s workstation.zip -6lands at 567 KB / 31.01 % — slightly worse than lha because zip's deflate config is older and DEFLATE64 (the wider-window variant) is off by default.tar (no compression)at 2.7 MB / 150 % — the per-record headers + 512-byte padding more than double the size of small files.
The ratio difference between tar.gz and
lha is about 5 percentage points —
~91 KB on a 1.8 MiB input. On a fast CDN, that 91 KB
re-downloads in <100 ms; on a slow CDN, it might take
1-2 seconds. That's the actual size-vs-speed trade on the
wire.
4. -9 is a trap on this corpus
For already-redundant text (source code with repeating words and whitespace), gzip -9 and tar.gz -9 find nothing new compared to -6. The corpus isn't rich enough to benefit from the extra passes; -9 wastes 5-10x more CPU for ~0 % ratio gain.
If you have truly high-entropy content (a video fragment, a database dump, encrypted data), -9 will show a real gap. This corpus doesn't.
Which tool when
| if you care about… | use | why |
|---|---|---|
| smallest possible output | tar.gz -9 / zstd / brotli |
deflate at level 9 (or zstd -19 / brotli -11) is the
best ratio you'll get from a single binary that's
already on every Unix system. The trade is >5× compress
time vs lha for ~5 % ratio. |
| fastest compress | zip -6 |
~62 ms. DEFLATE64 off, no tar blocking, small overhead per file. Loses 6 % ratio vs deflate -6. |
| fastest decompress (CDN, OTA update, package mirror) | lha or zip |
Both decode in single-digit ms on this corpus. lha has a slightly larger archive but the absolute size of decompressed code is tiny; network latency dominates. If you're also concerned about install-on-edge, lha's smaller dep surface (no zlib, no iconv, no ssl) matters more than the 90 KB ratio delta. |
| single static binary for CI artifact + CDN | lha |
x eget use lha drops a 152 KB
statically-linked binary in ~/.local/bin
with no deps. tar.gz needs libz +
libiconv + tar on the host; zip needs
libzip + libz. lha has no such dep tree. |
| shipping a Windows or macOS .dmg that any user can open | zip (or .tar.gz) |
zip is built into Windows + macOS; tar.gz opens
natively. lha needs the binary to be installed first
— x eget use lha makes that one
command, but it's still a step. |
| embedded / firmware / old hardware | lha |
Decompressor fits in < 16 KB on constrained targets. Designed for MS-DOS machines with 640 KB RAM. |
Deeper reading
Why lha -lh5- is faster than tar.gz -6 on compress
LZHUF (the algorithm behind .lh5-) uses a
static-Huffman-table, fast-string-match design from 1990.
deflate (gzip / zlib) uses LZ77 + dynamic
Huffman and a sliding window that the encoder has to
decide on. For input that doesn't fully fill the window
(our 1.8 MiB corpus fits in a 32 KB window with room to
spare), deflate spends most of its time
scanning for matches; LZHUF doesn't. The
decompressor side is closer to a tie because both algorithms
have the same rough decode-loop structure.
On a corpus bigger than the deflate window (32 KB at -6),
deflate starts to win on ratio because it can find
longer-distance matches. Our 1.8 MiB corpus is small
enough that ratio doesn't move much past -6, and the
encode cost dominates. For a 100 MiB corpus, expect
tar.gz -9 to pull ahead on ratio at
~3-5x the compress time of lha -lh5-.
Why tar (no compress) is so much bigger than input
tar records per-file metadata (UID, GID, mode, timestamps,
filenames) plus a 512-byte-per-record padding. For our
1.8 MiB corpus with 304 small files, that's ~50 % of
overhead before any deflate runs. tar -czf
compresses most of it back out via the LZ77 backend,
but raw tar is genuinely larger than its input
for small-file corpora.
Why zip -6 is faster than tar.gz -6 on decompress
zip's archive format has a single central directory at the
end of the file — the decoder can read it without
streaming. tar.gz has to scan the tar header
for every entry to find its size, then ask the deflate
decoder to fill that exact size. zip's deflate can be
wrapped around the central directory; tar.gz's deflate
is per-record, and each record needs its own header
parse. The single-digit ms gap here is small; on a
corpus with many files (thousands), zip's lead grows.
Why is lha's decompress slower than deflate / zstd / lz4?
Decompression is where LZHUF (the algorithm behind
.lh5- and .lh7-) shows its age.
The 1990 design is fundamentally bit-serial:
- Static Huffman tables, bit-by-bit decode. The
bitstream is read one bit at a time, and each bit
determines the next table lookup. This is the decode
loop in
src/huf.c— the main decompressor hot path. There is no parallelism in this loop: bit n+1 cannot be read until bit n has been looked up. - LZ77 back-references resolved serially. When the
decoder reads a length/distance pair, it copies
length bytes from distance ago. The
slide window in
src/slide.cis updated byte by byte, no SIMD memmove. On a 1.83 MiB corpus, this copy loop dominates the runtime. - No SIMD anywhere in the source.
grep -nE '(mmx|sse|avx|simd|__m128i|__builtin_ia32)' src/*.creturns zero hits. The vendored 1.14i code is straight ANSI C from 1995-2003, with no architecture-specific vectorization.
So the answer to the question is: the algorithm is the ceiling, not the implementation. LZHUF was tuned in 1990 for "fast unpack on a 286 with 1 MiB RAM", and the bit-serial decode loop was the natural fit. A SIMD-optimized lha port would gain perhaps 1.5-2x on decompress (the length-distance copy is the only vectorizable chunk), but the underlying bit-serial Huffman loop is fundamentally serial. To get more decompress speed, you have to switch algorithms, not micro-optimize LZHUF.
For comparison: zstd decompress is ~5-10x
faster than lha at similar ratio, and lz4 is
~3-5x faster still. Both are tuned for modern SIMD CPUs
(SSE2/AVX2 on x86_64, NEON on aarch64) and have decompress
loops that are SIMD-friendly by design. lha predates
MMX (1996) by six years and the original maintainers
never had a vector unit to target.
When this matters: lha is the right answer for a static binary that has to live on a system without zstd/lz4 installed. lha is not the right answer for a high-throughput CDN that needs microsecond-scale decompress.
How to reproduce
On any Unix-like host (macOS, Linux, BSD):
git clone --depth 1 https://github.com/ljh-sh/lha.git
cd lha
bash scripts/build.sh
cp build/src/lha /usr/local/bin/
# corpus
test -d corpus/kenlm_src || cp -R ../kenlm/upstream/kenlm corpus/kenlm_src
# 3 runs each, median. The numbers in the table above used 5 runs
# (we report median ± sample-stdev) on an Apple M-series laptop.
for tool in \
'lha :lha c k.lh5- corpus/kenlm_src' \
'tar :tar -cf k.tar -C corpus kenlm_src' \
'tgz6 :tar -czf k6.tgz -C corpus kenlm_src' \
'tgz9 :tar -czf -9 k9.tgz -C corpus kenlm_src' \
'zip6 :zip -qr -6 k6.zip kenlm_src' \
'zip9 :zip -qr -9 k9.zip kenlm_src' ; do
label=${tool%%:*}; cmd=${tool#*:}
rm -f k.*
for r in 1 2 3 4 5; do
rm -rf corpus/kenlm_src; cp -R ../kenlm corpus/kenlm_src
t=$( { time $cmd >/dev/null 2>&1; } 2>&1 | awk '/real/{print $NF}')
sz=$(stat -c%s k.* 2>/dev/null | head -1)
echo "$label t=$t sz=$sz"
done
done
Numbers will differ by host (~10 % on different CPUs, ~5 %
across macOS / Linux); the relative ordering
(zip → lha → tar → tar.gz on compress time,
tar.gz → zip → lha → tar on decompress,
tar.gz → lha → zip → tar on ratio) is stable.