Improving build of open source projects Part #2 - fixing the zip task
One CPU core, zipping alone, eating a third of the build. Time to fix that — and make sure the fix doesn't backfire on the other ten projects.
Part 1 ended with a promise: pick one of the three real bottlenecks profiling found, and go after it. SonarQube’s real critical path — computed from actual dependency edges and timestamps, not guessed — spent its final 42 seconds in exactly two tasks: shadowJar, then zip. A third of the whole critical path, on this machine, building a single archive, single-threaded.
What Gradle’s Zip task actually gets wrong
Two separate things, not one. It’s single-threaded — one core does all the compression work while eleven sit idle. And it always fully DEFLATEs every entry, with no regard for whether that entry is already about as small as it’s going to get. SonarQube’s installer archive is mostly jars, and a jar is itself a ZIP file, holding class files that were already DEFLATEd once when the jar was built. Compressing that content a second time, inside the outer archive, spends CPU for very little size benefit — in the worst case, none at all.
That observation is the whole premise behind parallel-zip, a drop-in replacement for Gradle’s Zip task. It attacks both problems directly, not just the first one:
- Compression runs across every core, not one. Entries compress in parallel, then get written back in a fixed order, so parallelism never changes the output bytes — the archive stays byte-for-byte reproducible regardless of thread count.
- Small-entry DEFLATE runs through a bundled native libdeflate build instead of the JDK’s own
Deflater, on the platforms it supports — its match-finder is simply faster thanjava.util.zip’s at the same compression level. Each worker thread holds one native compressor handle for the life of the whole archive (allocCompressor/freeCompressoronce, not per entry) and batches a whole run of small entries into a single JNI crossing, instead of re-allocating libdeflate’s internal hash tables and paying a JNI round-trip for every individual file. - Entries are read lazily, on the same worker thread that compresses them, instead of eagerly on Gradle’s single-threaded copy walk before compression even starts — on archives with thousands of small files, this alone was the bigger of the two performance changes.
- And the incompressibility sniff below, so an entry that won’t shrink skips the expensive pass entirely instead of paying for it and discarding the result.
None of this touches Gradle’s public Zip/AbstractArchiveTask contract — the whole CopySpec DSL (from, into, include, exclude, rename, …) works unchanged. Swapping the task type is the only change a build script needs.
The obvious-looking shortcut here — skip compression for anything with a .jar/.zip/.png extension, since containers like that “can’t compress further” — was tried, and it was wrong: every one of 11 real-world test corpora got measurably bigger, from +3.4% to +12.1%.
Real jars aren’t reliably already at their smallest possible size. Different compression tools — and even different versions of the same tool — don’t always produce identical output for identical input, and some reproducible-build tooling deliberately leaves class files uncompressed inside the jar specifically to avoid depending on exactly which tool built it. Either way, there’s often real room left to shrink. On SonarQube’s own jars, a second compression pass shrank them to 92.3% of their original size — a real 7.7% saving, and exactly what a blanket “skip anything jar-shaped” rule would have thrown away.
The fix that actually holds up: don’t guess from a file extension, measure the content. Probe the first 64 KiB of every entry over 256 KiB; if compressing the sample saves less than 2%, store the rest of that entry as-is instead of paying for a full DEFLATE pass that won’t pay off. No assumptions about what a “jar” is or does — just a cheap, conservative, content-only test, run on every large entry, every time.
SonarQube: before and after
Same 927.9 MiB, 610-file archive Part 1 flagged, same machine, only the archiver swapped. The safe default — DEFLATE, with the sniff — takes it from 17.67 seconds to 4.13 (4.27×). If you’re willing to trade some archive size for speed, skipping compression outright (store = true) takes it to 0.45 seconds (39.45×). This is a separate benchmark run, not a rerun of Part 1’s exact profiling pass — it archives a full extracted SonarQube distribution directly rather than replaying the specific :sonar-application:zip Gradle task — but it’s the same shape of problem: a jar-and-JRE-heavy installer, built single-threaded, re-compressing content that mostly doesn’t need it.
Is SonarQube special, or does this hold up everywhere?
Every one of the 11 real open-source projects benchmarked gets a real speedup from the safe default, no configuration and no per-project tuning: 1.65× on Hadoop (dominated by a handful of individual files over 500 MiB, where multithreading a few dozen entries barely matters) up to 9.67× on Kafka. This is why DEFLATE-with-the-sniff is the plugin’s default rather than an opt-in — the win doesn’t depend on knowing anything about the archive’s contents ahead of time.
STORE is a different kind of decision.
Skipping compression entirely beats even a fast compressor, everywhere: 17.46× on Groovy at the low end, up to 40.34× on Kafka. But that speed comes from skipping real work, and that work was buying something — a smaller archive. How much smaller depends entirely on the content.
This is the chart that actually answers “how much of this content is already zipped.” Kafka (+3.5%) and SonarQube (+6.5%) barely move when compression is skipped entirely — their archives are almost all jars and binaries already close to their compression floor, so there’s very little left for DEFLATE to buy. Groovy sits at the opposite extreme: +271.4%. Its SDK distribution is full of source, documentation, and text — exactly the content DEFLATE was built for — and storing it uncompressed would nearly quadruple the archive for no reason. Gradle (+107.3%) and ZooKeeper (+107.0%) tell the same story at a smaller scale.
That’s the actual rule, and it’s a judgment call, not a default: STORE only the archives you already know are jar- or binary-heavy. For SonarQube — the exact project Part 1’s critical path flagged — that trade is an easy yes: 6.5% bigger for a 39× win on the single slowest task in the real build. For Groovy, it very much is not.
What this doesn’t fix
Part 1’s other finding about SonarQube stands untouched: roughly half its build’s wall-clock time isn’t explained by any dependency chain at all, because the project never turns on cross-project parallelism. A faster zip task shortens the real critical path — it doesn’t touch the other 50% of the build that’s just sitting there unparallelized. That’s a different fix, for a different post.