Skip to content

bzip2 llm rewrite (~1.5-2x speedup) - #811

Open
dweiss wants to merge 22 commits into
apache:masterfrom
dweiss:bzip2-llm-rewrite
Open

bzip2 llm rewrite (~1.5-2x speedup)#811
dweiss wants to merge 22 commits into
apache:masterfrom
dweiss:bzip2-llm-rewrite

Conversation

@dweiss

@dweiss dweiss commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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):

$ time java -cp commons-io-2.22.0.jar:commons-compress-1.29.0-SNAPSHOT.jar BZip2SumTest.java enwiki_general-20260823-00000.json.bz2
enwiki_general-20260823-00000.json.bz2: 3,364,783,751 bytes, sum = 300,473,491,475 (47.00 s, 71.6 MB/s)

real    0m47.469s
user    0m49.976s
sys     0m0.236s

$ time java -cp commons-io-2.22.0.jar:commons-compress-1.28.0.jar BZip2SumTest.java enwiki_general-20260823-00000.json.bz2
enwiki_general-20260823-00000.json.bz2: 3,364,783,751 bytes, sum = 300,473,491,475 (91.01 s, 37.0 MB/s)

real    1m31.506s
user    1m32.751s
sys     0m0.211s

$ time bunzip2 -c enwiki_general-20260823-00000.json.bz2   | wc -c
3364783751

real    1m10.402s
user    1m9.625s
sys     0m3.266s

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 -
image

[1] https://dumps.wikimedia.org/other/cirrus_search_index/20260823/index_name%3Denwiki_content/
[2] LLM summary of changes.
image

dweiss and others added 9 commits August 27, 2026 22:11
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
@ecki

ecki commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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?

@dweiss

dweiss commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

(I have a feeling the single byte and Boolean Arrays of the old code hurt, and the new one dient even use crazy optimisations.

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):

$ curl https://dumps.wikimedia.org/other/cirrus_search_index/20260823/index_name%3Denwiki_general/enwiki_general-20260823-00000.json.bz2 > /tmp/enwiki.bz2
$ mvn package dependency:copy-dependencies

$ 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 (34.71 s, 96.9 MB/s)
java -cp  ./temp/BZip2SumTest.java /tmp/enwiki.bz2  36.12s user 0.21s system 103% cpu 34.989 total

$ time bunzip2 -c /tmp/enwiki.bz2 | wc -c
 3364783751
bunzip2 -c /tmp/enwiki.bz2  44.51s user 0.43s system 99% cpu 44.946 total

Wild. It is much faster than the native tool.

Did you disclose your agent, model, harness and skills?

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.

@dweiss

dweiss commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

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):

$ time ./bzip2 -d -c enwiki_general-20260823-00000.json.bz2 | wc -c
3364783751

real    0m43.440s
user    0m42.915s
sys     0m3.073s

$ time bzip2 -d -c enwiki_general-20260823-00000.json.bz2 | wc -c
3364783751

real    1m8.521s
user    1m7.824s
sys     0m3.212s


/**
* 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}.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

dweiss and others added 2 commits August 28, 2026 20:39
…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
@garydgregory

Copy link
Copy Markdown
Member

Hi @dweiss
Commons Compress already depends on Commons Codec where you'll find org.apache.commons.codec.digest.PureJavaCrc32 and org.apache.commons.codec.digest.PureJavaCrc32C. How about reusing that and dropping the Compress version?

@dweiss

dweiss commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Commons Compress already depends on Commons Codec where you'll find org.apache.commons.codec.digest.PureJavaCrc32 and org.apache.commons.codec.digest.PureJavaCrc32C.

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:

  • PureJavaCrc32C: wrong polynomial (Castagnoli). It can't produce bzip2's CRC under any transformation. Unusable.
  • PureJavaCrc32: right polynomial, wrong bit orientation (reflected/LSB-first; bzip2 is MSB-first). Usable only via reverse32(crc(reverse8(M))), which costs a
    bit-reversal pass per byte — and since it's plain Java slicing-by-8 with no intrinsic behind it, you'd pay that pass on top of essentially the same table
    computation the tree already does. Net effect ≈ 1.6× slower CRC than the current class, for the sake of deleting ~60 lines.

@dweiss

dweiss commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

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?

@garydgregory

Copy link
Copy Markdown
Member

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.

dweiss and others added 9 commits August 30, 2026 10:52
…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
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
@dweiss

dweiss commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

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.

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 won't commit status. It has been fun to explore the possibilities.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants