FAQ
Performance, format, future formats, and the lha-rs question. A consolidated record of the analysis behind the static-binary distribution.
Why is lha's decompress so much slower than deflate / zstd / lz4?
Short answer: the algorithm is fundamentally bit-serial and there's zero SIMD in the source. ~3-7× speedup is available with non-SIMD algorithmic changes; matching zstd -1 is a multi-year project.
The two hot paths in the source
Decoding LZHUF means:
- Read N bits → walk a binary tree (one branch per bit) →
output one symbol. (
src/huf.c:decode_c_dyn,src/huf.c:decode_p_dyn) - If the symbol is a back-reference, copy L bytes from D
bytes ago in the slide window. (
src/slide.c's hand-rolledforloop, one byte per iteration.)
Why this is fundamentally slow
- Static Huffman means the bit-stream is decoded one bit at a time. The decoder can't "look ahead" more than 1-2 bits without doing the table walk anyway.
- No SIMD anywhere:
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 vectorization. - Back-reference copy is byte-by-byte in
src/slide.c. Amemcpy(dst, src, len)call would already be 2-4× faster on modern glibc (which has SSE2/AVX2 memcpy internally).
What you can do without changing the on-disk format
| Optimization | Where | Decoded speed gain |
|---|---|---|
| Pre-computed symbol lookup table (zlib-style inflate) | replaces bit-by-bit tree walk in src/huf.c |
3-5× on the bit-loop part |
memcpy(dst, src, len) for LZ77 back-ref copy |
replaces per-byte loop in src/slide.c |
2-4× on the copy part |
| Branchless 16-bit table lookup | same code path as #1, more aggressive | 5-7× |
| Two-stage pipeline (read buffer + decode chunks) | rewire src/dhuf.c and src/slide.c |
1.5-2× |
| Cache hot back-reference distances (top-3 MRU) | new in src/slide.c |
1.2-1.5× |
Combined: 3-7× decode speedup, taking lha -lh5- from
~45 MB/s to ~150-300 MB/s on the kenlm corpus — competitive
with deflate level 1-3 (~300-500 MB/s on the
same corpus), but not "best in class."
Hard ceiling: what tANS buys (and doesn't)
To match zstd -1 (~2 GB/s) you'd need a fundamentally different entropy coder (tANS/FSE) and a faster LZ77. That means changing the on-disk format — see "Where should new formats live?" below. lha -lh5- on today's C decoder is the limit; no algorithmic trick within LZHUF gets above ~300 MB/s decode.
Source citation
The bench numbers come from a 5-run, kenlm upstream source (1.83 MiB / 304 files) bench, Apple M-series, native lha build. Full table on the perf page. The 5-axis source audit on the security page confirms the bit-loop and per-byte copy are the only two decode-side hot paths.
Could tANS make lha competitive with modern codecs?
Short answer: yes, with tANS, lha lands in the same
league as deflate / brotli. Not in
the same league as zstd -1+ (multi-year optimization
gap) and not in the same league as lz4 /
snappy (no entropy coder at all — different
architecture).
Where tANS lands against each competitor
| Opponent | Decode speed | Why tANS helps / doesn't |
|---|---|---|
| zstd -1 | ~2 GB/s | Yann Collet has been tuning FSE/tANS for 8+ years. A "first version" tANS implementation in lha-rs would reach ~1-1.5 GB/s, ~30-50 % behind zstd -1. Multi-year iteration needed to catch up. |
| zstd -3+ | ~1 GB/s | Even further behind; optimal parser is the hard part. |
| deflate -1 to -3 | 500-800 MB/s | Same league. tANS gets to ~500-800 MB/s, deflate does too. Within a small constant factor. |
| brotli -1 | 300-500 MB/s | Slightly ahead on a fresh tANS implementation (brotli is static-Huffman; tANS is faster at the same ratio). |
| lz4 / snappy / LZO | 3-5 GB/s | Never competitive. These have no entropy coder — they store LZ77 matches in 4-byte token format and decode to ~1 byte/cycle. LHA-with-tANS has entropy-coder overhead lz4 doesn't, so it can't match that speed class by architecture. |
The fundamental limit
A decoder is bottlenecked by memory bandwidth + branch mispredicts + cache misses. tANS minimizes branch mispredicts (one branch per symbol). lz4 minimizes cache misses (literal copy in 16-byte chunks). Modern CPUs hit ~1 byte/cycle for the simplest decoders; tANS is around 1-2; lz4 is around 1. The architectures trade these two bottlenecks; one can't dominate the other from outside.
What lha's actual competitive position is
Adding tANS gives lha "competitive with deflate / brotli" status, not "beating zstd -1+" status, and definitely not "beating lz4" status. The real value of lha in 2026 is not speed — it's 35-year format stability + single-file static binary with no deps. Speed is the price of that. So the question becomes: do we add tANS as a new format (.lh8-) to give speed-conscious users an option, while keeping .lh5- as the stable read-only compatibility target? See "Where should new formats live?".
Is zstd -1 "the best" zstd level?
Short answer: yes, zstd -1 is the fastest zstd level
(lowest compression ratio, highest speed). But it's not
"best" in the sense of "best default" — that would be
zstd -3.
zstd's level table
zstd uses zstd -N for levels 1-22, or
zstd --fast=N for "faster than default."
| Level | Role | Ratio vs default -3 | Encode speed vs default -3 |
|---|---|---|---|
| -1 | fastest | ~5-10 % worse | ~2-3× faster |
| -3 | default — speed / ratio sweet spot | 1.0× | 1.0× |
| -6 | balanced | ~5-8 % better | slightly slower |
| -10 | prefer compression | ~10-15 % better | 3-5× slower |
| -15 | high compression | ~20-25 % better | 8-10× slower |
| -22 | max | ~30-50 % better | 30-50× slower |
Why -3 is the actual default
The speed/ratio curve is not linear:
- -1 to -3: very flat on the time axis, real gain on ratio. Going to -3 is a no-brainer.
- -3 to -10: roughly linear trade.
- -10 to -22: very flat on the ratio axis, big time cost. The ratio gains diminish.
Hence zstd's author (Yann Collet) chose -3 as the default: it's the shoulder of the curve. -1 is too aggressive on ratio for most use cases; -10 wastes time without much gain.
But zstd -1 isn't the fastest decoder overall
zstd -1 is the fastest zstd level, but:
| Compressor | Decode speed | Has entropy coding? |
|---|---|---|
| lz4 | ~3-4 GB/s | no |
| snappy | ~3-5 GB/s | no |
| zstd -1 | ~1.5-2 GB/s | yes (FSE / tANS) |
| zstd -3 | ~1 GB/s | yes |
| brotli -1 | ~300-500 MB/s | yes (static Huffman) |
| deflate -1 | ~500-800 MB/s | yes (dynamic Huffman) |
| lha -lh5- (current) | ~45 MB/s | yes (static Huffman) |
lz4 / snappy are 1.5-2× faster than zstd -1 because they have no entropy coder at all. So "fastest decoder" is a two-track race, and lz4 wins the no-entropy track; zstd -1 wins the entropy-track.
What are lha's unique features vs bzip2 / zip / 7z?
Short answer: lha's "features" are mostly not features — they're trade-offs that bought a 35-year stable format. But lha has some genuine uniqueness: per-file independent compression, stdio-stream friendly, and streaming-buildable catalog.
What lha does not have
| Feature | bzip2 | zip | 7z | lha |
|---|---|---|---|---|
| Block-random access (seek to 100 KB, decode 1 KB) | ✅ 100-900 KB blocks | ⚠️ per-file only | ✅ blocks | ❌ (stream) |
| Global index file | ❌ | ✅ central dir at end | ❌ | ❌ (but streaming-buildable) |
| Sparse files / hardlinks / ACL / xattr | ❌ | partial | ✅ | ❌ |
| Strong checksum (SHA-256) | ❌ (CRC32) | ❌ | ✅ | ❌ (CRC32) |
| Compression dictionary / pretrain | ✅ | ❌ | ✅ | ❌ |
lha is a stream-oriented (not block) archiver — same shape as zip / gzip / tar, not bzip2 / 7z. So bzip2's "seek to byte 100 MB and decode 1 KB" is structurally impossible with lha -lh5-.
What lha does have that's unusual
- 35-year format stability. Files written in 1990 decode today. zip has 36+ years, tar has 45+, bzip2 28, 7z 25. lha isn't the longest but it's at the top tier.
- Streaming-buildable catalog. LHA headers are fixed-size (~35 B per file). You can read an lha archive in one pass, building an in-memory index of "file N at offset O, size S, mtime M" without decompressing any file. zip can't do this (central directory is at the end).
- Per-file independent compression. Each entry's compression method is in its own header. File 1 might be -lh0- (stored), file 2 -lh5-, file 3 -lh0- again. No "all files share one dictionary" constraint.
- stdio native.
lha c dir/ > archive.lzhandlha xq archive.lzh < /dev/ttywork directly. zip / 7z / tar need temp files for stdout. - Tiny decompression code. LZHUF decoder is ~1,500 lines of C — much smaller than zstd's ~50,000 or 7z's ~30,000. Easier to embed in embedded systems, easier to audit (we just did 5-axis on it).
- No entropy table in the stream. Static Huffman means the table is in the decoder, not the data. A 50-byte file in lha has only the 35-byte header + 50-byte body, no per-block overhead. deflate has a ~20-byte tree per block.
- Japanese filename encodings (Shift-JIS, EUC-JP) baked in for legacy BBS / 2ch archives.
What this means strategically
lha's value proposition in 2026 is format stability + zero
deps + tiny size + 35-year portability, not "rich feature
set." To get bzip2-style features (random access, index,
strong checksum) the right path is new formats
.lh5b- (block), .lh8- (tANS), and
.lhi- (index file) — not a Rust rewrite of
lha. See next question.
Could a Rust rewrite help lha? (the lha-rs question)
Short answer: no. lha-rs is not worth it as a 1:1 Rust replacement of the C version. The honest answer: most of the standard reasons for a Rust rewrite don't apply to lha specifically.
Each standard "rewrite in Rust" reason, applied to lha
| "Reason" | Reality for lha |
|---|---|
| Memory safety (Rust eliminates strcpy/sprintf risks) | We just did a 5-axis audit. 8 strcpy sites are all bounds-checked; 0 sprintf / gets / scanf. The vulnerable-class doesn't exist in this codebase. Re-implementing the whole thing to remove a class of bugs that aren't present is poor cost / benefit. |
| cargo-fuzz / cargo test infrastructure | libFuzzer attaches to any C / C++ project, not just Rust. oss-fuzz provides free hosting for C projects. Adding libFuzzer to lha is 1-2 weeks of work, not a separate repo. |
| Modern error handling (Result<T, E>) | C can return a tagged-union error. The real work is the error vocabulary, not the language. 1 week of refactoring lha's error path, not 1 year. |
| Modern CLI (clap / argh) | lha's CLI is stable since 1990. Refactoring it is a breaking change, not an improvement. The CLI is not lha's pain point. |
| Pluggable compression crates (flate2, zstd-rs, lz4_flex) | Those are for new projects. They don't need to be in lha-rs to be useful — anyone writing a new Rust tool uses them already. |
| Algorithm optimization carrier | Wrong layer. The pre-computed-table + memcpy optimizations are language-agnostic. Write them in C — they fit in the existing CI matrix, no new project. |
| New format experiments (tANS, etc.) | This is a new-format problem, not a lha-rs problem.
New formats live in lha/ as src/lh5b/,
src/lh8/. Not a separate repo. |
When a Rust rewrite is actually right
One of these conditions must hold:
- The C maintainer is retiring and the only next-generation maintainers are Rust developers — it's an exit strategy.
- A new format is designed Rust-first (uses async,
Sendplumbing,rayonparallelism, std SIMD for portable vectorization). - A specific user requirement mandates it (e.g. "must run in
WebAssembly in a browser extension" — Rust's
wasm32-unknown-unknownis the only sane path).
None of these applies to lha today.
What the right move is
- Stay in lha (C) — maintain and audit. 5-axis security audit done; libFuzzer integration 1-2 weeks away; oss-fuzz entry 1-2 hours. Done.
- Add new formats in C as subdirectories:
src/lh5b/(block format with random read),src/lh8/(tANS),src/lhi/(index file). C implementations get the existing CI matrix; no new repo, no new release cadence. - Write a format spec at
docs/lzh-format.mdso any future implementation (Rust, Python, Go) has a single source of truth. - If new formats prove useful, then a
separate
ljh-sh/lha-spec(or similar) for multiple reference implementations in different languages. Not a "rewrite" repo — a multi-impl test-bed.
lha-rs is not on this list. It would be a 6-12-month project to rewrite a 13,400-line C program whose safety profile is already good, in a language whose value proposition doesn't help. Better: keep the C, do the small additional work in C, and reserve Rust for the day a new format genuinely needs it.
Where should new formats (.lh5b- / .lh8- / .lhi-) live?
Short answer: in the existing ljh-sh/lha
repository, not in a new one. New formats are additions
to the lha family, not replacements for it.
Proposed new formats
| Suffix | Backend | Compatibility with .lh5- | What it adds |
|---|---|---|---|
.lh5- (current) |
LZHUF + per-byte copy | — (canonical) | 35-year stable format; tiny binary; zero deps |
.lh5b- |
Same as .lh5-, but with 256 KB blocks | Old .lh5- readers can still extract; new readers gain block random-read | Random access within a file; partial extraction (close to bzip2 behavior) |
.lh8- |
tANS (FSE) + faster LZ77 | New format, not readable by old .lh5- decoder | Decode speed competitive with deflate -1; better ratio than .lh5- on most corpora |
.lhi- |
Index file (separate from archive) | Works with any .lh5- (or .lh8-) archive | Streaming-buildable catalog; lets
lhacat archive.lzh list every file
without reading any body |
Where each lives
src/lh5b/— block format. C. Subdirectory ofljh-sh/lha. 4-6 weeks.src/lh8/— tANS backend. C or Rust (tANS is a fresh format, so this is the place where if Rust is the right choice, this is the right place). 12-18 months. Likely still in C for consistency with the rest of lha.src/lhi/— index format. C. 1-2 weeks.docs/lzh-format.md— format spec that the new formats all reference. 2-3 weeks.
Single binary that handles all of them
The killer feature of doing it in the existing repo: a single
ljh-sh/lha binary that reads all of
these formats:
$ lha l archive.lh5- # canonical
$ lha l archive.lh5b # block random-read
$ lha l archive.lh8- # tANS, faster decode
$ lha l --index archive.lhi # streamed catalog
$ lha x archive.lh8- # write a new tANS archive
Single static binary, ~250 KB on disk, zero deps. The same
x i lha / x eget use lha install
path. 35-year stable read path + modern tANS write path +
block random read. This is lha's competitive
position in 2026: a single binary that does what
no other archiver can do, and weighs 250 KB.