parallel-zip 1.4.0: 26× szybciej — i dokładnie ile to kosztuje
Nowy tryb domyślny pomija ponowną kompresję wszystkiego, co rozpoznaje jako już skompresowane, zamiast próbkować i zgadywać. To duża wygrana w szybkości — i realny koszt rozmiaru, którego nie zamierzam przed wami ukrywać.
parallel-zip 1.4.0 jest już dostępny. Krótka wersja: jest sporo szybszy niż 1.3.0, z powodu, który warto porządnie wyjaśnić — a ten powód ma realny koszt, o którym wolę powiedzieć wprost, zamiast chować go w changelogu.
Próbkowanie kontra czytanie etykiety
1.3.0 miał już obronę przed marnowaniem CPU na zawartość, która się nie skompresuje: sprawdź pierwsze 64 KiB dużego wpisu, i jeśli DEFLATE ledwo je skurczy, zapisz resztę bez zmian zamiast płacić za pełne przejście, które się nie zwróci. To statystyczny zakład — dobry, ale wciąż zakład. Próbkuje tylko początek wpisu, co bywa niereprezentatywne (META-INF/MANIFEST.MF jara siedzi tuż na początku i kompresuje się świetnie, nawet gdy pliki class za nim już nie), i włącza się dopiero dla wpisów wystarczająco dużych, żeby próbka miała sens.
1.4.0 dokłada coś bardziej bezpośredniego: sprawdza sygnaturę pliku wpisu — te same magiczne bajty, których używa twój system operacyjny, żeby zgadnąć typ pliku — względem listy formatów, które są już skompresowane. ZIP i wszystko na nim zbudowane (jar/war/ear/apk/whl/docx/xlsx…), gzip, bzip2, xz, 7-zip, zstd, rar, lz4, png, jpeg, gif, webp, mp4/mov, webm/mkv, ogg, woff/woff2. Trafienie od razu zapisuje wpis przez STORE, bez żadnej próby DEFLATE — taniej niż statystyczna próbka, i nie obchodzi go, jak duży jest wpis ani jak wygląda jego wysemplowany fragment.
To ten sam pomysł co verbatim copy w shaded-jar: przestań otwierać zaklejone pudło, żeby sprawdzić, czy warto je pakować od nowa. Tym razem pudło nie ma nawet central directory jako skrótu, na którym można się oprzeć — tylko swoje własne, pierwsze bajty, które okazują się wystarczające.
Liczby
Powtórzyłem cały zestaw benchmarków: te same 9 prawdziwych projektów open source i ich realnych zadań Zip z wydania 1.3.0, tym razem porównując faktyczny opublikowany jar 1.3.0 z nowym buildem 1.4.0, plus świeże przejście przez wszystkie 11 projektów z korpusu stałego (pełne szczegóły w dokumencie z benchmarkami pluginu).
Średnia geometryczna przyspieszenia względem standardowego Gradle Zip w tych dziewięciu zadaniach: ~26.5×, w górę z ~5.4× na 1.3.0. JBake i Micronaut Starter CLI są na szczycie wykresu z tego samego powodu — ich dystrybucje są gęsto upakowane małymi, już skompresowanymi jarami, dokładnie tym, na co celuje ta funkcja. SonarQube siedzi na dole (5.89×) nie dlatego, że funkcja tam nie pomaga, ale dlatego, że jego archiwum instalatora było już blisko tego pułapu na 1.3.0 — po prostu zostało mniej miejsca do odzyskania.
Część, której nie zamierzam ukrywać
Pominięcie przejścia DEFLATE dla wpisu „prawdopodobnie już skompresowanego“ oznacza pominięcie go nawet wtedy, gdy DEFLATE mógłby jeszcze uszczknąć procent czy dwa więcej, niż plugin teraz zostawia na stole. To nie błąd — to dokładnie ten kompromis, który jest zawierany — ale jest realny, i widać go w rozmiarze archiwum:
6–17% większe niż standardowy Zip, w każdym pojedynczym projekcie z zestawu. 1.3.0, dla porównania, trzymał się w granicach ~0.3% od standardu — praktycznie za darmo. To zauważalnie inna oferta niż „szybciej za nic“, i wolę pokazać wam uczciwy wykres niż zestaw samych highlightów.
Furtka
Patrząc na ten drugi wykres, „zawsze włączone domyślnie“ przestało wydawać się słuszną decyzją dla każdego archiwum. Więc 1.4.0 dostaje właściwość zadania, żeby to wyłączyć:
tasks.register('dist', com.ljarocki.parallelzip.ParallelZip) {
skipAlreadyCompressed = false // domyślnie: true
}
false wraca do zawsze próbowanego DEFLATE — bliżej rozmiaru z 1.3.0, kosztem większości powyższego przyspieszenia. Statystyczny sniff z 1.3.0 zostaje aktywny niezależnie od tego ustawienia; ta flaga rządzi tylko nowym sprawdzaniem sygnatury. Domyślnie zostaje true, bo dla większości prawdziwych archiwów szybkość jest tego warta — ale „większość“ to nie „wszystkie“, i teraz nie musisz akceptować kompromisu, o który nie prosiłeś.
(Przy okazji doprowadzania tej flagi znalazłem i naprawiłem realną lukę: ścieżka kodu, która kompresuje kilka małych plików jednym natywnym wywołaniem — optymalizacja wsadowa dla archiwów z mnóstwem drobnych wpisów — w ogóle nie sprawdzała sygnatury, robiła to tylko ścieżka dla pojedynczych wpisów. Małe, już skompresowane pliki, które trafiły razem do jednej partii, całkowicie pomijały tę optymalizację. Teraz jest naprawione, a nowa flaga rządzi tą ścieżką tak samo konsekwentnie.)
Dwie mniejsze poprawki
Nie wszystko w tym wydaniu dotyczy kompromisu kompresji:
- Naprawiony błąd blokady pliku, tylko na Windowsie. Duże wpisy mogą być kompresowane przez widok pliku źródłowego zmapowany w pamięci (mmap), dla szybkości. Na Windowsie niezwolnione mapowanie trzyma swój plik źródłowy zablokowany przed usunięciem albo nadpisaniem, dopóki garbage collector się nim nie zajmie — a to nie jest ograniczone w czasie, i czasem blokada wciąż trzymała, gdy kolejne zadanie w tym samym demonie Gradle próbowało dotknąć tego samego pliku. Mapowanie jest teraz jawnie zwalniane zaraz po powrocie z natywnego wywołania, zamiast czekać na GC.
- Zabezpieczenie przed
OutOfMemoryErrorna małych stertach demona Gradle. Plugin już wcześniej ograniczał, ile bajtów buforowanej pracy kompresji trzyma naraz w pamięci, skalując to do ułamka sterty demona — ale ten budżet nie brał pod uwagę, jak kilka dużych buforów w locie potrafi pofragmentować małą stertę, zanim nominalny budżet w ogóle zostanie osiągnięty. Rozmiar bufora na ścieżce mmap teraz też skaluje się w dół razem z rozmiarem sterty, a jednorazowe ostrzeżenie pojawia się, gdy sterta demona wygląda na zbyt małą do budowanego archiwum.
Jak to zdobyć
plugins {
id 'com.ljarocki.parallel-zip' version '1.4.0'
}
Pełne notatki wydania są na GitHubie; kompletne zestawienie benchmarków — wszystkie 9 projektów in-build, wszystkie 11 projektów z korpusu stałego, cztery kodeki — jest w docs/BENCHMARKS.md.