bzip2 llm rewrite (~1.5-2x speedup) - #811
Conversation
Adds a verbatim test-only copy of the current decoder (LegacyBZip2Decoder) as a differential oracle, BZip2DifferentialTest (generated inputs, all fixtures, read patterns, concatenation, truncation, corruption, randomised blocks), a JMH benchmark over large corpora, a full-file runner and NOTES.md with baseline numbers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JuSWHd6QdvgZQwnrQrV7ng
…fman decoding Replaces BitInputStream/HuffmanDecoder in BZip2CompressorInputStream with a package-private BZip2BitReader (lazy exact reads for headers and trailers so the underlying stream is never over-read, greedy capped refill inside block bodies) and per-group 10-bit lookup tables backed by a canonical bit-by-bit slow path with the same validation and messages. The MTF/RLE2 loop keeps its state in locals. Output path unchanged. 1.27x-1.36x over the previous decoder on the benchmark corpora. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JuSWHd6QdvgZQwnrQrV7ng
…ut path Folds ll8 into tt (data byte in the low 8 bits, successor index in the upper 24) so the inverse BWT traversal needs one dependent load per output byte, and makes read(byte[], int, int) the primary path: the per-byte output state machine runs as one loop with its state in locals, writing directly into the caller's buffer, with the block CRC computed over the written slice. Randomised blocks and block boundaries keep the byte-at-a-time path. A corrupt origPtr is now rejected up front. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JuSWHd6QdvgZQwnrQrV7ng
…y-8, loop hygiene - BZip2InverseBwt unwinds a block by walking eight independent chains of the successor permutation in lockstep (several cache misses in flight instead of one dependent load per byte) and stitching the segments in cycle order; the RLE1 state machine then streams over the materialised block. Output is identical by construction, including corrupt blocks whose permutation has several cycles. - CRC computed over output slices with slicing-by-8 tables (CRCTest). - Manual fills for short RUNA/RUNB runs, branchless run accumulation, provably unreachable bounds checks removed with justification. - 1.7x-1.8x over the previous decoder on the benchmark corpora; the full 3.4 GB corpus decodes at 69.9 MB/s versus 38.8 before and 45.5 for native bzip2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JuSWHd6QdvgZQwnrQrV7ng
…tuning, Java 25 assessment - BZip2BitReader.readBitsInBlock/readUnaryInBlock: the block's table section (bitmap, selectors, code lengths) is read with the greedy in-block refill and a leading-ones unary decoder; the Huffman loop refills once fewer than FAST_BITS + 1 bits remain (6 bytes per refill). - NOTES.md: results of every step, the reverted byte-array MTF stage experiment, and the Java 25 assessment (identical sources built with --release 25 run at the same speed; the parts a newer API could touch are ~14% of the profile, below the bar for a multi-release jar). - Full 3.4 GB corpus now decodes at 73.0 MB/s (38.8 before, 45.5 native). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JuSWHd6QdvgZQwnrQrV7ng
…ed sources BZip2BitReader gains a bulk mode (64 KiB input buffer, 8-byte refills) used when reading ahead cannot be observed: decompressConcatenated=true, or a source with mark/reset, which is then re-synchronised to the first unconsumed byte at chunk boundaries, at the end of the bzip2 stream and after errors, so it ends up exactly where the exact reader leaves it. Other sources keep the exact reader. The MTF loop no longer writes a stale local bit-buffer copy back after an exception from the slow Huffman path. The differential harness now runs every case with both a markable and a non-markable input, demanding the exact source position for markable ones. BufferedInputStream sources: 71 -> 73 MB/s; byte arrays/concatenated: unchanged to -4%. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JuSWHd6QdvgZQwnrQrV7ng
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JuSWHd6QdvgZQwnrQrV7ng
|
Wow, that’s a neat improvement. (I have a feeling the single byte and Boolean Arrays of the old code hurt, and the new one dient even use crazy optimisations. In that regard, the native Tool Performance is shocking unoptimized. Did you disclose your agent, model, harness and skills? |
I agree - it doesn't look too terrifying. Sure - it's complex and uses some assumptions (cache and cpu parallelism friendliness) but it's not revolutionary. It is still a significant speed improvement. What was a bit shocking to me is this - this time on a mac (completely different architecture): Wild. It is much faster than the native tool.
I didn't want this to look like an ad for a particular vendor... you can sort of see it in the intermediate commits - I used claude and fable/ high. I don't have any particular skills set up but I'm sure it collects and reuses some knowledge I've used in the past sesssions... hard to tell what it does know about one and what it doesn't these days. |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JuSWHd6QdvgZQwnrQrV7ng
|
I (the llm) also applied the same (?) concepts to the C version - https://github.com/dweiss/bzip2 It is faster than before (first is patched, recompiled, second is the original): |
|
|
||
| /** | ||
| * Slicing-by-8 tables: {@code SLICE[k][i]} is the CRC contribution of byte {@code i} followed by {@code k} zero bytes; {@code SLICE[0]} is | ||
| * {@link #CRC32_TABLE}. |
There was a problem hiding this comment.
in my experience with lucene, you can get even faster if you can use the builtin CRC in the openjdk, rather than writing your own.
I believe they have two versions, one with the zlib polynomial, the other with the CRC-32C. These are implemented as hotspot intrinsics and are massively optimized, e.g. they will use instructions available such as VPCLMULQDQ and so on?
There was a problem hiding this comment.
Yes, there's more options but this is stuck at Java 8 (!). It'd have to be more complex to discover the builtin and call it but certainly something to try if this is to be taken further.
I haven't used bz2 in a long time (zstandard is so much faster and offers similar compression ratios) but if it can be improved, why not do it. :)
There was a problem hiding this comment.
Or did you mean the java.util.zip.CRC32? This hasn't always been accelerated though, has it? I'd have to look back and see - I'm not familiar with it.
There was a problem hiding this comment.
java.util.zip.CRC32 is the one i mean indeed. It is in java 8. This is the one lucene uses. I spent a ton of time benchmarking those parts. It is insanely fast, just give it enough buffer so that the vectorization can happen (don't give it like just a few bytes at a time). I think 1K or so is enough.
There was a problem hiding this comment.
Or did you mean the java.util.zip.CRC32? This hasn't always been accelerated though, has it?
this depends upon the the JDK version the end user is using (not the project's javac compiler). But just for history, they started hw-accelerating it in java 8: https://bugs.openjdk.org/browse/JDK-7088419. On arm i don't know the timeline but also they improve it, so if you use a newer JDK you'll take advantage automatically.
There was a problem hiding this comment.
Seems like the crc32 flavor in bzip uses different byte ordering so it can't be easily applied here (?). Claude did generate bit/byte reversals to use crc32, of course, but the net gain after that is zero.
There was a problem hiding this comment.
Here are the numbers - literally the same as before. Could be that the llm went sideways somewhere. Still fast though!
time java -cp target/dependency/commons-io-2.22.0.jar:target/commons-compress-1.29.0-SNAPSHOT.jar ./temp/BZip2SumTest.java /tmp/enwiki.bz2
/tmp/enwiki.bz2: 3,364,783,751 bytes, sum = 300,473,491,475 (33.85 s, 99.4 MB/s)
java -cp ./temp/BZip2SumTest.java /tmp/enwiki.bz2 35.49s user 0.18s system 104% cpu 34.125 total
There was a problem hiding this comment.
I've reverted the crc32 patch because it was introducing more complexity for no real benefit. This code isn't to be merged anyway - it's more of an... exploratory experiment. Thanks for the suggestion though!
…C32 intrinsic bzip2's CRC-32 is the non-reflected variant of the polynomial that java.util.zip.CRC32 (a HotSpot intrinsic) computes in reflected form: bzip2(M) == reverse32(crc32(reverse8(M))). CRC now owns a CRC32 and feeds it bit-reversed bytes: bulk slices are reversed eight bytes at a time via cached native-order ByteBuffer views into an 8 KB buffer, single-byte and run updates append to that buffer and are flushed in bulk. The cached view of the caller's array is released at the end of the stream and on close() rather than per block (a field store on the per-block path hit an inlining cliff worth 14% at block size 9). About 1.6x faster than the slicing-by-8 tables for the CRC itself; the full 3.4 GB corpus decodes at 76.5 MB/s versus 39.5 for the previous decoder in the same session. The compressor, which shares the class, is unchanged. The 256-entry table literal is gone. CRCTest checks mixed update sequences against the classic table definition and the check value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JuSWHd6QdvgZQwnrQrV7ng
…l.zip.CRC32 intrinsic" This reverts commit 8827cfa.
|
Hi @dweiss |
Good to know, thank you. Unfortunately this won't work here because bzip2 uses a different byte/bit ordering from standard crc32 (or crc32c) implementations. So it's very specific to this algorithm. Here's the LLM explaining the details:
|
|
Ok. I've taken a look at what the LLM produced and it really isn't that hairy/ crazy - mostly engineering tweaks. All the existing tests (including https://sourceware.org/git/?p=bzip2-tests.git;a=summary) pass with flying colors. I am willing to clean this up from LLM cruft (benchmarks it used to navigate through solutions, excessive comments, etc.) if there is a desire for this to be merged in - let me know. bzip2 is still used these days, even if better alternatives exist, so let's make the decompression faster? |
|
FWIW, this my current priority is fixing the pile of bugs that have recently reported internally. This is nice to have but not a priority ATM. |
…an costs, bulk RLE1 Compressed output is byte-identical (BZip2CompressionDifferentialTest against verbatim copies of the previous encoder and BlockSort). - BlockSort.mainSimpleSort compares the first six bytes of two rotations as one word and then four bytes plus four quadrant values per step as an int and a packed long, deciding by the first differing element in the original interleaved order; NUM_OVERSHOOT_BYTES 20 -> 32 for the 8-byte loads. - sendMTFValues1 packs the code lengths of a symbol in all tables into one long with 10-bit lanes, so the cost of a group is one load and one add per symbol. - write(byte[], int, int) runs the RLE1 state machine with the run in locals. - BZip2CompressionBenchmark (JMH, single shot, 64 MiB corpora). Same-session paired timings (64 MiB, ms): text -9 6522 -> 6116, binary -9 6593 -> 6218, text -1 5696 -> 5134, binary -1 6114 -> 5865. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JuSWHd6QdvgZQwnrQrV7ng
…ropped) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JuSWHd6QdvgZQwnrQrV7ng
New constructor BZip2CompressorInputStream(InputStream, boolean, ExecutorService, int maxConcurrentInFlight). A coordinator scans the compressed stream for the 48-bit block/EOS magics at all 8 bit shifts, slices it into per-block segments, and decodes each segment on the executor as a synthesized single-block stream through the regular decoder, delivering results in order and folding the per-block CRCs into the stream's combined CRC. False-positive magics and corrupt blocks trigger a rescan that ignores the failed delimiter; parse errors found while reading ahead are deferred until the blocks before them have decoded. maxConcurrentInFlight bounds the read-ahead and memory. Full 484 MB corpus: 46.9 s sequential -> 16.6 s with 16 threads (203 MB/s) / 15.7 s with 32 (214 MB/s); the sequential path is unchanged within noise (one null field check per read call). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JuSWHd6QdvgZQwnrQrV7ng
New constructor BZip2CompressorOutputStream(OutputStream, int blockSize, ExecutorService, int maxConcurrentInFlight), output byte-identical to the single-threaded encoder. The caller thread replicates writeRun()'s block-boundary bookkeeping to slice the raw input at exactly the block boundaries the sequential encoder would use; workers compress each slice as a standalone single-block stream with the regular encoder, and the caller stitches the block bits (a new trailingPadBits field exposes the exact stream bit length) into the output in order, folding the block CRCs into the combined CRC. maxConcurrentInFlight provides backpressure and bounds memory. 256 MiB at level 9: 25.9 s sequential -> 3.2 s with 16 threads (8.0x) / 3.0 s with 32 (8.5x), identical output bytes; the sequential path is unchanged within noise (one null field check per write call). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JuSWHd6QdvgZQwnrQrV7ng
Garbage after a block magic used to buffer the whole remaining input while scanning for an end delimiter that never comes. A format-valid compressed block cannot exceed ~2.3 MiB (20 bits per symbol for at most 900,001 MTF/RLE2 symbols plus the table sections), so the scan now gives up after 4 MiB and reports corruption, keeping the rolling input buffer bounded on corrupt streams. Valid streams are unaffected; the cap error defers like any other read-ahead parse error, so the false-positive-delimiter rescan still supersedes it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JuSWHd6QdvgZQwnrQrV7ng
The single-byte buffer for the parallel read() path is one byte; keep it final and drop the lazy null check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JuSWHd6QdvgZQwnrQrV7ng
…ssion test copies of previous implementations.
A block's decompressed output is typically under 1 MiB but RLE1 expansion can grow it to tens of MiB for highly repetitive data; the constructor javadoc claimed 900 KiB. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JuSWHd6QdvgZQwnrQrV7ng
Move the parallel-constructors entry into the ADD section and close an unbalanced parenthesis in the decoder entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JuSWHd6QdvgZQwnrQrV7ng
Absolutely, not a problem at all. I've cleaned up some LLM cruft and added some convenience parallel block decompression/compression stuff on top because it's handy for my use cases. Works beautifully. I agree with you that it's not any priority since bzip2 is not in any mainstream use (again - not sure why wikimedia folks would pick it as their bulk data compressor; seems like a waste of compute power). I'm fine with leaving this on a branch or even closing with |
TL;DR; I am a human but I used LLMs to rewrite bzip2 code in commons-compress and it's 2x as fast. I thought you'll find this interesting - it isn't meant to be merged in as-is but is sure as hell interesting (at least to me!).
So, I've been working with (huge) bz2 files from Java level ([1]) - their decompression takes... a good while (why wikimedia doesn't use zstd for this is another question I'll leave aside). Compression/decompression code is a great self-contained example of hairy stuff and I thought - it'd be interesting to burn some watts and give LLM a task to improve the current implementation.
In short - I gave it a simple prompt of : "Would it be possible to speed up apache-commons bzip2 implementation (especially the decompression) using new Java language features? Consult with implementations in other languages, if needed."
And worked iteratively from there. It was very much unsupervised in what it chose to optimize and how. Eventually though, this is what I see (paths a bit redacted, disk cache-primed):
Which are... crazy numbers but they're consistent with what my eyes see... I don't know if it optimized for my particular CPU/ cache layout or not but I found it interesting that (a) it's correct and works all across the board, (b) it is much, much faster than the code currently in apache-commons.
I wonder what people think and if these results reproduce for others (I haven't tried this yet across any other known compression data sets like the silesia corpus, etc.).
I also include the "description" of changes from the LLM from those interested [2].
[later] After a round of improvements, experiments and tweaks, including parallel stream decompression, it now looks like this -

[1] https://dumps.wikimedia.org/other/cirrus_search_index/20260823/index_name%3Denwiki_content/

[2] LLM summary of changes.