Przyspieszanie buildów projektów open source, część 1 – profilowanie
Co tak naprawdę uruchamia CI kontrybutora, zanim jeszcze ruszą testy? Wyciągnąłem dokładne komendy z GitHub Actions i sprofilowałem je na dwa różne sposoby, na trzech projektach open source.
Co naprawdę spowalnia czekanie na CI? Nie wyidealizowany ./gradlew build, który sam sobie wymyśliłem — tylko dokładna komenda, za którą czeka prawdziwy pull request kontrybutora, zanim uruchomi się choćby jeden test.
Więc poszedłem to sprawdzić. Dla SonarQube, Kafki i Groovy wziąłem prawdziwy przebieg GitHub Actions dla każdego z nich, użyłem gh api, żeby znaleźć dokładny job i plik workflow, który za nim stoi, i wyciągnąłem dosłowne wywołanie Gradle’a, które ich własne CI uruchamia jako warunek wstępny przed testami. Potem sprofilowałem tę komendę na dwa różne sposoby. Trzy bardzo różne bazy kodu, trzy bardzo różne powody, dla których ich buildy są wolne — a drugie podejście, patrzące na zupełnie inny sygnał, opowiedziało zupełnie inną historię.
Metoda, w skrócie
Znalezienie prawdziwych komend. gh api repos/<org>/<repo>/actions/jobs/<id> daje listę kroków dla konkretnego przebiegu CI; zestawienie tego z YAML-em workflow (gh api repos/<org>/<repo>/contents/.github/workflows/...) daje dosłowną komendę shellową stojącą za każdym krokiem. To nie jest zgadywanie — to jest to, co faktycznie uruchomiło się na tym PR-ze:
- Kafka (
build.yml, zadanie „Compile and Check”):./gradlew --build-cache --info --scan check releaseTarGz -x test - Groovy (
groovy-build-test.yml, zadanie „lts (17, windows-latest)”):./gradlew test -Pgroovy.grape.bridge-cache=true -Ptarget.java.home=... - SonarQube (współdzielona akcja
SonarSource/ci-github-actions/build-gradle,build.sh):./gradlew --no-daemon --stacktrace --console plain build [-x test] sonar ... artifactoryPublish ...
Do każdej z nich doszły trzy stałe, własne dodatki, niezależnie od tego, co robi CI danego projektu: najpierw clean; --no-build-cache (sensem profilowania jest czysty wynik, a własny cache CI to dokładnie ten rodzaj rzeczy, który raz już dał tu błędny wynik — więcej o tym niżej); i pominięcie uruchamiania testów, bo testy to temat na osobny, późniejszy wpis. --continue okazał się konieczny we wszystkich trzech przypadkach — to starsze, przypięte tagi wydań, a formattery, JDK-i i sumy kontrolne zdążyły się rozjechać od czasu ich wycięcia, więc każdy z nich teraz oblewa jakieś zadanie sprawdzające z powodów niemających nic wspólnego z szybkością builda. Bez --continue Gradle zatrzymuje się na pierwszym błędzie i większość grafu w ogóle się nie wykonuje.
Jedna korekta w trakcie śledztwa: prawdziwa komenda CI Groovy to tylko ./gradlew test ... — nic więcej nie jest obok niej zamawiane. Dopisanie do tego -x test usuwa cały graf, bo po wykluczeniu samego test nic innego nie prosi już o jego zależności — Gradle nigdy nie buduje tego, o co nikt nie prosi. Pierwsza próba skończyła się jako 8-zadaniowy, dwusekundowy no-op. Uczciwym odpowiednikiem „zbuduj to, czego potrzebują testy, ale ich nie uruchamiaj” jest zażądanie wprost testClasses — i to właśnie stoi za każdym wykresem dla Groovy poniżej.
Finalne komendy, uruchamiane po jednym projekcie na raz (wszystkie trzy naraz na jednej 12-rdzeniowej maszynie spowodowały realną rywalizację o zasoby za pierwszym razem — jeden pomiar wyszedł zawyżony 10×):
- SonarQube:
./gradlew clean && ./gradlew --no-daemon --stacktrace --console plain build -x test --no-build-cache --continue - Kafka:
./gradlew clean && ./gradlew check releaseTarGz -x test --no-build-cache --continue - Groovy:
./gradlew clean && ./gradlew testClasses -Pgroovy.grape.bridge-cache=true --no-build-cache --continue
Dwa różne profile z tego samego przebiegu. Flaga --profile w Gradle’u daje czasy trwania per zadanie, tutaj pogrupowane według typu zadania (compileJava, checkstyleMain, zip, …), zsumowane po wszystkich modułach i zwinięte w osiem koszyków: Kompilacja, Kompilacja testów, Sprawdzanie stylu, Analiza statyczna, Dokumentacja, Generowanie kodu, Pakowanie, Inne. To odpowiada na pytanie „gdzie idzie wysiłek” — ale w buildzie wielomodułowym wysiłek i czas rzeczywisty (wall-clock) to dwa różne pytania, bo niezależne moduły budują się równolegle. Na drugie pytanie — co faktycznie musi wykonać się po kolei — odpowiada mały skrypt inicjalizacyjny Gradle’a (TaskExecutionGraph.getDependencies() dla prawdziwych krawędzi zależności, beforeTask/afterTask dla prawdziwych znaczników czasu), który zbiera wszystko, co potrzebne do policzenia prawdziwej ścieżki krytycznej: klasyczna metoda ścieżki krytycznej, najdłuższy ważony czasem łańcuch w grafie zależności, bez żadnej zewnętrznej usługi build-scan.
SonarQube: kompilacja wciąż na czele, ale pakowanie jest już prawdziwe
./gradlew clean && ./gradlew --no-daemon --stacktrace --console plain build -x test --no-build-cache --continue
Uruchamiając prawdziwą komendę CI w całości — bez skrótów, bez pomijanych modułów — 58.9% sprofilowanego czasu SonarQube to wciąż zwykła kompilacja Javy. Ale Pakowanie to teraz prawdziwe 21.3%, a nie niemal zero, jak wtedy, gdy wcześniejsze podejście sztucznie pomijało moduł budujący instalator. Ten koszyk to naprawdę składająca się dystrybucja SonarQube: shadow jar modułu sonar-application, jego finalny zip i JRE-ki, które paczkuje dla sześciu platform. „Inne” (11.6%) to głównie skan listy komponentów oprogramowania, czyli SBOM (cyclonedxDirectBom), plus te pobierania JRE — to ruch sieciowy, nie CPU, ale i tak prawdziwy czas rzeczywisty.
Kafka: wąskim gardłem wciąż jest analiza statyczna, nie kod
./gradlew clean && ./gradlew check releaseTarGz -x test --no-build-cache --continue
Prawie się nie zmieniło względem wcześniejszego podejścia, i z tego samego powodu: sam SpotBugs (52.5%) wciąż przebija kompilację (13.1%) 4-krotnie. Sprawdzanie stylu (18.4%, Checkstyle plus formatter Spotless dla Scali — prawdziwa komenda CI uruchamia oba) wypycha narzędzia weryfikacyjne ponad 70% całości. releaseTarGz, zadanie, za którym goniło całe to śledztwo, samo w sobie okazuje się zaokrągleniem w dół (2.7%) — jego koszt to niemal w całości praca kompilacji i sprawdzeń, która musi się wydarzyć wcześniej, a nie sam etap archiwizacji. Pojawia się też nowy, drobny kawałek, Dokumentacja (1.5%): releaseTarGz ciągnie za sobą dziesiątki drobnych zadań gen*Docs (dokumentacja protokołu, konfiguracji, metryk), których nigdy nie było widać w profilu ograniczonym do samego check.
Groovy: bez dokumentacji to po prostu kompilacja
./gradlew clean && ./gradlew testClasses -Pgroovy.grape.bridge-cache=true --no-build-cache --continue
To jest wykres, którego znaczenie faktycznie zmienia poprawka metodologii. testClasses nigdy nie dotyka Javadoc, Groovydoc, Asciidoctora ani Checkstyle’a — te nigdy tak naprawdę nie były częścią „przygotowania do testów”, tylko artefaktem profilowania całego zadania build zamiast tego, co CI uruchamia przed testami. Wyrzuć je, a prawdziwy build Groovy przed testami to po prostu kompilacja, kropka: 51.5% kompilowania kodu głównego, 40.0% kompilowania kodu testowego, 8.3% pakowania pośrednich jarów, których każdy moduł potrzebuje, zanim skompiluje się kolejny. Żadnego tajemniczego wąskiego gardła — po prostu dużo kodu Groovy do skompilowania, dwa razy (raz jako główny, raz jako testowy).
Co naprawdę spowalnia pętlę zwrotną
Powodem, dla którego to całe ćwiczenie w ogóle istnieje, jest deweloper siedzący przed pull requestem i czekający, aż CI zaświeci się na zielono — a coraz częściej też agent AI robiący dokładnie to samo w pętli, gdzie wolny cykl zwrotny kosztuje jeszcze więcej niż u człowieka. To, jak faktycznie odczuwa się to czekanie, to nie „gdzie idzie wysiłek” — to „co decyduje o czasie rzeczywistym”, a to są dwa różne pytania. Czas zagregowany odpowiada na pierwsze. Odpowiedź na drugie wymaga prawdziwych krawędzi zależności i prawdziwych znaczników czasu, a nie samych czasów trwania zsumowanych po typie.
SonarQube: prawdziwa ścieżka krytyczna kończy się na prawdziwym zadaniu archiwizacji
./gradlew clean && ./gradlew --no-daemon --stacktrace --console plain build -x test --no-build-cache --continue --init-script capture-graph.init.gradle -PdepGraphOut=...
Prawdziwa ścieżka krytyczna to 2 minuty 16 sekund; build trwa 4 minuty 41. 52% czasu rzeczywistego wciąż nie leży na żadnym łańcuchu zależności — to samo odkrycie o niewykorzystanym potencjale równoległości co wcześniej (org.gradle.parallel nie jest ustawione), a tym razem trzyma się to uczciwie, nie jako artefakt błędu z cache’em. Nowe jest to, jak kończy się łańcuch: 27 prawdziwych kroków kompilacji moduł-po-module, ten sam kształt co poprzednio, ale teraz zakończonych przez :sonar-application:shadowJar (12.8 s), a potem :sonar-application:zip (29.6 s) — prawdziwe archiwum instalatora, zbudowane jednowątkowo, naprawdę. Blisko jedna trzecia tej ścieżki krytycznej to jedno zadanie archiwizacji na samym końcu.
Kafka: już działa blisko swojego minimum
./gradlew clean && ./gradlew check releaseTarGz -x test --no-build-cache --continue --init-script capture-graph.init.gradle -PdepGraphOut=...
Ścieżka krytyczna Kafki (4 min 27.8 s) jest niemal identyczna z jej czasem rzeczywistym (4 min 35 s) — org.gradle.parallel=true już wyciąga niemal wszystko, na co pozwala ten graf zależności, tak jak poprzednio. :core:compileScala (102.8 s) to wciąż pojedyncze największe ogniwo, nie SpotBugs; jedyna instancja SpotBugs, która faktycznie blokuje metę, :jmh-benchmarks:spotbugsMain (36.7 s), jest ogonem łańcucha, a nie jego powodem. Wykres zagregowany mówi, że wąskim gardłem jest analiza statyczna; prawdziwy łańcuch mówi, że to pojedyncza kompilacja Scali, bo to jedyna instancja, przy której nie ma nic do uruchomienia równolegle.
Groovy: nie zostało już żadnego luzu
./gradlew clean && ./gradlew testClasses -Pgroovy.grape.bridge-cache=true --no-build-cache --continue --init-script capture-graph.init.gradle -PdepGraphOut=...
To się kompletnie odwraca względem poprzedniej wersji tego wykresu, i to z dobrego powodu: tamta wersja profilowała pełne build, a jej ścieżka krytyczna była jednym, niezależnym od niczego zadaniem renderowania PDF-a, które akurat było ogromne. testClasses w ogóle nie dotyka tego zadania, więc prawdziwa ścieżka krytyczna przed testami wygląda zupełnie inaczej — to prawdziwy, 29-krokowy łańcuch przez moduł za modułem (groovy-xml → groovy-templates → groovy-docgenerator → groovy-groovydoc → groovy-ant → własny compileTestGroovy projektu głównego), z których każdy czeka na poprzedni, bo tak naprawdę od siebie zależą. I w przeciwieństwie do SonarQube, prawie nie ma już luzu: 2 min 31.6 s ścieżki krytycznej wobec builda trwającego 2 min 34 s, różnica 1.6%. Cokolwiek jest wolne w przygotowaniu Groovy do testów, to nie niewykorzystana równoległość — ograniczeniem jest sam graf zależności, a skrócenie go oznacza przebudowanie tego, który moduł zależy od którego, a nie przełączenie jednej flagi.
Gdzie wszystkie trzy buildy spędzają swój czas, razem
Każdy wykres powyżej dotyczy jednego projektu na raz. Zsumuj surowe liczby z --profile dla wszystkich trzech — 235.9 s SonarQube, 2226.6 s Kafki, 570.5 s Groovy, 50 minut 33 sekundy łącznego czasu maszyny — pogrupowane według dosłownego typu zadania Gradle’a zamiast projektu, i odpowiedź pada na inne pytanie: nie „co jest wolne w projekcie X”, tylko gdzie mała flota prawdziwych buildów Gradle’a faktycznie zużywa moc obliczeniową.
Wyliczone z trzech sprofilowanych przebiegów powyżej, zsumowane po surowym typie zadania (nie jest to jedna komenda)
Kompilacja sama w sobie nie jest odpowiedzią. Pojedynczy typ zadania analizy statycznej, SpotBugs, to 38.5% łącznego czasu wszystkich trzech buildów — więcej niż jakikolwiek inny pojedynczy typ zadania, a występuje tylko w jednym z trzech projektów. Dodaj do siebie wszystkie zadania związane z kompilacją — compileJava, compileGroovy, compileScala, compileTestJava, compileTestGroovy, compileTestScala i ich mniejszych kuzynów — a suma wychodzi na około 40%, na tyle blisko samego SpotBugsa, by nazwać to remisem. Checkstyle (checkstyleMain + checkstyleTest, też tylko w Kafce) to kolejne 12.8%.
A praca archiwizacji, do której cały ten cykl wpisów wciąż wraca — zip, jar, shadowJar, copyDependantLibs, osgiClasses, releaseTarGz razem — to razem około 127 sekund. 4.2% całości, siedzące spokojnie wewnątrz tego 14.4-procentowego kawałka „Inne”. Na tej próbce, cokolwiek jest kosztowne w buildzie CI na Gradle’u, to kompilowanie i analizowanie kodu — nie pakowanie go do zipa.
Trzy komendy, trzy prawdziwe odpowiedzi
Ta sama metoda zastosowana tym razem uczciwie — dokładna komenda, którą uruchamia CI każdego projektu, a nie jej namiastka. SonarQube wciąż jest zdominowany przez kompilację w ujęciu zagregowanym, ale ma prawdziwe, nieskażone wnioski o pakowaniu i niewykorzystanej równoległości, gdy uruchomi się pełną komendę. Kafka się nie zmieniła: analiza statyczna dominuje w sumach, pojedynczy plik Scali dominuje w prawdziwym łańcuchu, oba prawdziwe, tak jak wcześniej. Groovy zmieniła się najbardziej, bo zadanie właściwego pytania (czego naprawdę potrzebują testy) zmieniło, jakie zadania w ogóle wchodziły w zakres — wykres zagregowany zmienił się z „pipeline’u dokumentacji” na „kompiluj dwa razy”, a ścieżka krytyczna zmieniła się z „jednego ogromnego, niezależnego zadania” na „ciasny łańcuch bez żadnego luzu”.
To wciąż jest sens profilowania przed optymalizacją: nie ma uniwersalnej poprawki na „CI jest wolne”, i nie ma jednej komendy, do której da się zgadnąć, żeby odpowiedziała za ciebie — trzeba pójść i znaleźć tę, która faktycznie się uruchomiła. Część 2 wybierze jeden z tych trzech przypadków i zajmie się prawdziwym wąskim gardłem.