/ Inżynier wydajności
← Wszystkie wpisy
lip 6, 2026 · 7 min czytania · Wydajność buildów

Przyspieszanie buildów projektów open source, część 2 – naprawianie zadania zip

Jeden rdzeń CPU, zipujący samotnie, zjadający jedną trzecią builda. Czas to naprawić — i upewnić się, że poprawka nie odbije się czkawką na pozostałych dziesięciu projektach.

Smutny procesor widzący plik ZIP

Część 1 kończyła się obietnicą: wybrać jedno z trzech prawdziwych wąskich gardeł znalezionych przez profilowanie i się nim zająć. Prawdziwa ścieżka krytyczna SonarQube — policzona z rzeczywistych krawędzi zależności i znaczników czasu, nie zgadnięta — spędziła swoje ostatnie 42 sekundy dokładnie w dwóch zadaniach: shadowJar, a potem zip. Jedna trzecia całej ścieżki krytycznej, na tej maszynie, budowanie jednego archiwum, jednowątkowo.

Co zadanie Zip w Gradle’u naprawdę robi źle

Dwie osobne rzeczy, nie jedną. Jest jednowątkowe — jeden rdzeń wykonuje całą pracę kompresji, podczas gdy jedenaście pozostałych stoi bezczynnie. I zawsze w pełni kompresuje DEFLATE’em każdy wpis, bez względu na to, czy ten wpis jest już mniej więcej tak mały, jak to możliwe. Archiwum instalatora SonarQube to głównie jary, a jar sam w sobie jest plikiem ZIP, zawierającym pliki class, które już raz zostały skompresowane DEFLATE’em, gdy jar powstawał. Kompresowanie tej zawartości po raz drugi, wewnątrz zewnętrznego archiwum, zużywa CPU przy bardzo małej korzyści rozmiarowej — w najgorszym razie żadnej.

Ta obserwacja to cała przesłanka stojąca za parallel-zip, zamiennikiem typu drop-in dla zadania Zip w Gradle’u. Atakuje oba problemy wprost, nie tylko ten pierwszy:

  • Kompresja działa na wszystkich rdzeniach, nie na jednym. Wpisy są kompresowane równolegle, a potem zapisywane z powrotem w ustalonej kolejności, więc równoległość nigdy nie zmienia bajtów wynikowych — archiwum pozostaje bit-w-bit reprodukowalne niezależnie od liczby wątków.
  • DEFLATE dla małych wpisów działa przez dołączoną, natywną kompilację libdeflate zamiast własnego Deflater z JDK, na platformach, które to wspierają — jego mechanizm wyszukiwania dopasowań jest po prostu szybszy niż w java.util.zip przy tym samym poziomie kompresji. Każdy wątek roboczy trzyma jeden natywny uchwyt kompresora przez cały czas życia archiwum (allocCompressor/freeCompressor raz, nie przy każdym wpisie) i pakuje całą serię małych wpisów w jedno przejście przez JNI, zamiast realokować wewnętrzne tablice haszujące libdeflate i płacić za przejście JNI przy każdym pojedynczym pliku.
  • Wpisy są czytane leniwie, na tym samym wątku roboczym, który je kompresuje, zamiast zachłannie, na jednowątkowym przejściu kopiującym Gradle’a, jeszcze zanim kompresja w ogóle się zacznie — na archiwach z tysiącami małych plików to samo w sobie było większą z tych dwóch zmian wydajnościowych.
  • No i opisany niżej sniff niekompresowalności, dzięki któremu wpis, który się nie skurczy, w ogóle pomija kosztowne przejście, zamiast za nie zapłacić i wyrzucić wynik.

Nic z tego nie dotyka publicznego kontraktu Zip/AbstractArchiveTask w Gradle’u — cały DSL CopySpec (from, into, include, exclude, rename, …) działa bez zmian. Zamiana typu zadania to jedyna zmiana, jakiej potrzebuje skrypt builda.

Pozornie oczywisty skrót — pomijaj kompresję dla wszystkiego z rozszerzeniem .jar/.zip/.png, bo takie kontenery „nie da się bardziej skompresować” — został wypróbowany i był błędny: każde z 11 prawdziwych korpusów testowych zrobiło się mierzalnie większe, od +3.4% do +12.1%.

Prawdziwe jary nie są niezawodnie już na swoim najmniejszym możliwym rozmiarze. Różne narzędzia kompresujące — a nawet różne wersje tego samego narzędzia — nie zawsze produkują identyczny wynik dla identycznego wejścia, a niektóre narzędzia do reprodukowalnych buildów celowo zostawiają pliki class w jarze nieskompresowane, żeby nie zależeć od tego, które dokładnie narzędzie zbudowało jar. Tak czy inaczej, często zostaje realne miejsce do skurczenia. Na jarach SonarQube drugie przejście kompresji skurczyło je do 92.3% oryginalnego rozmiaru — prawdziwa oszczędność 7.7%, i dokładnie to, co odrzuciłaby ogólna reguła „pomijaj wszystko, co wygląda jak jar”.

Poprawka, która faktycznie się trzyma: nie zgaduj z rozszerzenia pliku, zmierz zawartość. Sprawdź pierwsze 64 KiB każdego wpisu powyżej 256 KiB; jeśli skompresowanie próbki oszczędza mniej niż 2%, zapisz resztę tego wpisu bez zmian, zamiast płacić za pełne przejście DEFLATE, które się nie zwróci. Żadnych założeń o tym, czym jest lub co robi „jar” — tylko tani, ostrożny test oparty wyłącznie na zawartości, uruchamiany na każdym dużym wpisie, za każdym razem.

SonarQube: przed i po

Czas archiwizacji instalatora SonarQube: bazowy Gradle Zip 17.67 s, parallel-zip DEFLATE 4.13 s (4.27×), parallel-zip STORE 0.45 s (39.45×)

To samo archiwum 927.9 MiB, 610 plików, na które wskazała Część 1, ta sama maszyna, zmienił się tylko archiwizator. Bezpieczny domyślny tryb — DEFLATE z sniffem — zabiera to z 17.67 sekundy do 4.13 (4.27×). Jeśli jesteś skłonny wymienić trochę rozmiaru archiwum na szybkość, całkowite pominięcie kompresji (store = true) zabiera to do 0.45 sekundy (39.45×). To osobny przebieg benchmarku, nie powtórka dokładnego przebiegu profilowania z Części 1 — archiwizuje bezpośrednio pełną, wypakowaną dystrybucję SonarQube, zamiast odtwarzać konkretne zadanie Gradle :sonar-application:zip — ale to ten sam kształt problemu: instalator ciężki od jarów i JRE, budowany jednowątkowo, rekompresujący zawartość, która w większości tego nie potrzebuje.

Czy SonarQube jest wyjątkowy, czy to trzyma się wszędzie?

Przyspieszenie trybu DEFLATE względem Gradle Zip w 11 projektach open source: Kafka 9.7×, ZooKeeper 9.2×, Cassandra 4.5×, Solr 4.5×, Gradle 4.4×, SonarQube 4.3×, Groovy 3.8×, HBase 3.8×, Flink 3.2×, Spark 3.1×, Hadoop 1.6×

Każdy z 11 prawdziwych projektów open source objętych benchmarkiem zyskuje realne przyspieszenie z bezpiecznego trybu domyślnego, bez konfiguracji i bez strojenia pod konkretny projekt: 1.65× na Hadoopie (zdominowanym przez garstkę pojedynczych plików powyżej 500 MiB, gdzie wielowątkowość kilkudziesięciu wpisów ledwo ma znaczenie) aż po 9.67× na Kafce. Dlatego DEFLATE ze sniffem jest domyślnym trybem pluginu, a nie opcją do włączenia — wygrana nie zależy od wcześniejszej wiedzy o zawartości archiwum.

STORE to inny rodzaj decyzji.

Przyspieszenie trybu STORE względem Gradle Zip w 11 projektach open source: Kafka 40.3×, Flink 40.3×, SonarQube 39.5×, Cassandra 39.2×, HBase 37.3×, Spark 37.2×, Solr 34.3×, Hadoop 29.8×, ZooKeeper 25.0×, Gradle 21.2×, Groovy 17.5×

Całkowite pominięcie kompresji bije nawet szybki kompresor, wszędzie: 17.46× na Groovy na dolnym końcu, aż po 40.34× na Kafce. Ale ta szybkość bierze się z pominięcia prawdziwej pracy, a ta praca coś kupowała — mniejsze archiwum. To, o ile mniejsze, zależy całkowicie od zawartości.

Koszt rozmiaru archiwum w trybie STORE w 11 projektach open source: Kafka 3.5%, SonarQube 6.5%, Flink 8.5%, Spark 9.8%, Solr 12.1%, Cassandra 18.9%, HBase 22.1%, Hadoop 77.2%, ZooKeeper 107.0%, Gradle 107.3%, Groovy 271.4%

To jest wykres, który naprawdę odpowiada na pytanie „ile z tej zawartości jest już zzipowane”. Kafka (+3.5%) i SonarQube (+6.5%) ledwo drgają, gdy kompresja jest całkowicie pomijana — ich archiwa to niemal same jary i binaria, już blisko swojego minimum kompresji, więc DEFLATE’owi zostaje bardzo mało do ugrania. Groovy siedzi na przeciwnym biegunie: +271.4%. Jej dystrybucja SDK jest pełna kodu źródłowego, dokumentacji i tekstu — dokładnie tego, do czego DEFLATE został zbudowany — a przechowywanie tego bez kompresji niemal poczwórnie zwiększyłoby archiwum bez żadnego powodu. Gradle (+107.3%) i ZooKeeper (+107.0%) opowiadają tę samą historię, tylko w mniejszej skali.

To jest faktyczna zasada, i jest to decyzja wymagająca oceny sytuacji, a nie ustawienie domyślne: STORE stosuj tylko dla archiwów, o których już wiesz, że są ciężkie od jarów lub plików binarnych. Dla SonarQube — dokładnie tego projektu, na który wskazała ścieżka krytyczna z Części 1 — ten kompromis to łatwe „tak”: 6.5% większy rozmiar za 39× wygraną na pojedynczym najwolniejszym zadaniu w prawdziwym buildzie. Dla Groovy zdecydowanie nie.

Czego to nie naprawia

Drugie odkrycie z Części 1 dotyczące SonarQube pozostaje nietknięte: mniej więcej połowa czasu rzeczywistego jej builda nie jest w ogóle wyjaśniona żadnym łańcuchem zależności, bo projekt nigdy nie włącza równoległości między modułami. Szybsze zadanie zip skraca prawdziwą ścieżkę krytyczną — nie dotyka pozostałych 50% builda, które po prostu siedzi tam bez zrównoleglenia. To inna poprawka, na inny wpis.

ŁJ
Łukasz Jarocki
Inżynier wydajności