Najszybszy sposób na skompresowanie pliku to go nie kompresować
Budowanie fat JAR-a spędza większość czasu procesora na dekompresowaniu plików i kompresowaniu ich z powrotem do... dokładnie tych samych bajtów. Oto sztuczka z formatu ZIP, dzięki której shaded-jar całkowicie pomija tę pracę — wyjaśniona na przykładzie przeprowadzki.
Budowanie fat JAR-a brzmi jak coś banalnego: spakuj pliki .class, dołóż zależności, gotowe. W praktyce jednak często siedzi na ścieżce krytycznej — programista albo agent AI czeka, żeby dowiedzieć się, czy poprawka faktycznie działa. Spędziłem więc kilka dni, sprawdzając, jak to jest naprawdę zbudowane, głównie po to, żeby odpowiedzieć na jedno pytanie: czy popularne narzędzia są już blisko maksymalnej szybkości?
Narzędziem z wyboru do tego zadania jest plugin Gradle com.gradleup.shadow (dawniej com.github.johnrengelman.shadow). Jest open source i szeroko używany, więc spodziewałem się, że będzie już szybki. Nie jest — i powód, dlaczego, okazał się najciekawszą częścią.
Czym właściwie jest fat albo shaded JAR
JAR to po prostu plik ZIP zawierający pliki .class oraz MANIFEST.MF, który opisuje projekt. Nic skomplikowanego. Fat JAR to twoje skompilowane klasy plus wszystkie JAR-y zależności, połączone w jeden duży JAR. Jeśli projekt nie ma żadnych zależności, fat JAR to po prostu… zwykły JAR.
Po co więc jeszcze shaded JAR? Bo drzewa zależności potrafią się zderzyć. Powiedzmy, że biblioteka A zależy od B-1.0.0, a biblioteka C od B-1.2.0. Spakuj obie do fat JAR-a, a dostaniesz dwie kopie tych samych klas pod tymi samymi nazwami pakietów — classloader nie będzie wiedział, której użyć.
Shading rozwiązuje ten problem, zmieniając nazwy i lokalizacje pakietów tak, żeby te dwie kopie nigdy się nie zderzyły. Może też usuwać nieużywany kod i stosować inne transformacje. Bez żadnych transformacji shaded JAR to znowu po prostu fat JAR.
Co właściwie zajmuje tyle czasu
Wyobraź sobie, że przeprowadzasz się do nowego mieszkania. Wszystko, co masz, jest już spakowane w zaklejone, opisane pudła. Przyjeżdżają przeprowadzkowcy i oto ich metoda pracy: otwierają każde pudło, wyjmują wszystko, oglądają, wkładają z powrotem, zaklejają nowe, identyczne pudło i dopiero to niosą do ciężarówki.
Wyrzuciłbyś ich z pracy na miejscu. A dokładnie to robi większość narzędzi do budowania fat JAR-ów z twoimi zależnościami, przy każdym pojedynczym buildzie.
Ten wpis jest o jednym mechanizmie wewnątrz shaded-jar — nazywam go verbatim copy (kopia dosłowna) — i o tym, dlaczego “po prostu zanieś zaklejone pudło” jest zarówno możliwe, jak i zaskakująco rzadko spotykane.
Sześćdziesiąt sekund o ZIP-ie
Dwie właściwości formatu ZIP sprawiają, że wszystko poniżej działa:
- Każdy plik w środku jest kompresowany niezależnie. W przeciwieństwie do
tar.gz, które kompresuje całe archiwum jako jeden strumień, ZIP kompresuje każdy wpis osobno (zwykle algorytmem DEFLATE). Każdy wpis to swoje własne, zaklejone pudło. - Na końcu jest spis treści — central directory. Dla każdego wpisu przechowuje nazwę, miejsce, w którym zaczynają się jego bajty, rozmiar skompresowany i nieskompresowany oraz sumę kontrolną CRC-32. Kompletny spis wszystkich pudeł, bez otwierania żadnego z nich.
Co faktycznie robi build fat JAR-a
Wejściem do builda fat JAR-a jest garść świeżo skompilowanych plików .class oraz megabajty zależności, które są już skompresowane — każda biblioteka pobrana z Maven Central to ZIP pełen wpisów już przepuszczonych przez DEFLATE.
Standardowe podejście (zadanie Jar w Gradle, plugin Shadow) łączy je mniej więcej tak: dla każdego wpisu w każdej zależności — dekompresuj go do surowych bajtów, a potem skompresuj te same bajty ponownie do wynikowego JAR-a.
Rozpakuj pudło, spakuj identyczne pudło na nowo.
I to ponowne pakowanie jest tą drogą połową. Kompresja DEFLATE działa w tempie rzędu dziesiątek MB/s na rdzeń; dekompresja jest kilkukrotnie szybsza. Więc większość czasu procesora, który spala twój build, idzie na odtwarzanie skompresowanych bajtów, które — bajt w bajt — już leżały w źródłowym JAR-ze.
Sztuczka
Skoro każdy wpis to własne, zaklejone pudło, a central directory mówi nam dokładnie, gdzie każde pudło się zaczyna i jak jest duże, możemy zamiast tego zrobić coś innego:
Skopiować skompresowane bajty, nietknięte, prosto ze źródłowego JAR-a do wynikowego JAR-a.
Żadnej dekompresji, żadnej ponownej kompresji — samo przenoszenie bajtów, co twój komputer robi w tempie gigabajtów na sekundę zamiast dziesiątek megabajtów. I robi się jeszcze lepiej:
- Suma kontrolna jest za darmo. Zwykle writer ZIP-a musi policzyć CRC-32 zawartości każdego wpisu. Ale central directory źródłowego JAR-a już ją przechowuje, a zawartość się nie zmieniła — więc kopiujemy też sumę kontrolną.
- Zmiana nazwy jest za darmo. Nazwa wpisu żyje w metadanych ZIP-a, nie wewnątrz skompresowanych bajtów. Więc nawet gdy shading zmienia ścieżkę zasobu, zaklejone pudło jedzie bez zmian — zmienia się tylko etykieta.
Szacunek na kartce
Celowo uproszczone liczby, żeby zbudować intuicję. Weźmy realistyczny średniej wielkości serwis: ~20 000 wpisów, ~30 MiB skompresowanych zależności, może ~80 MiB nieskompresowanych.
- Kompresja wszystkiego od nowa: ~80 MiB przez DEFLATE przy ~40 MB/s ≈ 2 sekundy czystego czasu procesora, zanim build zrobi cokolwiek innego.
- Verbatim copy: ~30 MiB operacji typu
memcpy≈ dziesiątki milisekund.
Dwa rzędy wielkości na dominującym koszcie tego zadania — nie dzięki szybszej kompresji, ale dzięki zauważeniu, że ta praca w ogóle nie musi się odbywać. (Realne wyniki są siłą rzeczy bardziej chaotyczne; zmierzone liczby są w benchmarkach.)
Drobny druk
Gdyby ta sztuczka zawsze działała, wszyscy już by jej używali. Verbatim copy jest bezpieczne tylko wtedy, gdy zawartość wpisu na pewno się nie zmienia, więc shaded-jar sprawdza kwalifikowalność dla każdego wpisu osobno:
- Zwykły fat JAR (bez reguł relokacji): praktycznie wszystko się kwalifikuje. To najlepszy przypadek.
- Shaded JAR: każdy plik
.classwciąż musi przejść przez przepisanie bajtkodu — nawet klasy, których własny pakiet nie jest zmieniany — bo ich bajtkod może odwoływać się do klasy, która jest zmieniana. Pominięcie tego sprawdzenia kończy się JAR-em, który w runtime rzucaClassNotFoundException. Pliki niebędące klasami (zasoby, konfiguracje) wciąż się kwalifikują: relokacja zmienia tylko ich ścieżkę, nigdy zawartość. - Pliki, które są łączone, a nie kopiowane:
META-INF/services/*,spring.factoriesze Springa i podobne, cache pluginów log4j2. Kilka zależności wnosi swoją własną kopię, a wynik musi zawierać ich połączoną treść — więc zawartość z definicji się zmienia. - Wszystko nietypowe wraca na ścieżkę zapasową. Wpis zapisany bez kompresji w źródle, zbyt duże archiwum (Zip64), którego tani indeks nie potrafi sparsować, uszkodzony nagłówek — taki wpis (albo cały JAR) po cichu trafia na wolną ścieżkę dekompresji i ponownej kompresji. Verbatim copy to oportunistyczny skrót, nigdy hazard z poprawnością.
Wniosek
Zależności w twoim fat JAR-ze zostały skompresowane raz, przez autora biblioteki, być może lata temu. Nie ma powodu, żeby powtarzać tę pracę na twoim CI dzisiaj o 14:00 — format ZIP od zawsze niósł ze sobą wszystko, co potrzebne, żeby to pominąć: niezależne wpisy, znane offsety, zapisane sumy kontrolne.
Verbatim copy to jeden z kilku mechanizmów, dzięki którym shaded-jar jest szybki (największy z nich — pakowanie wpisów równolegle na puli workerów Gradle’a — zasługuje na osobny wpis). Ale to mój ulubiony rodzaj optymalizacji: najszybszy sposób na wykonanie kosztownej pracy to udowodnienie, że nie trzeba jej wykonywać.