Co tak naprawdę spowalnia kompilację w C i C++
Preprocesor, nagłówki i szablony jako główny koszt
Kompilacja w C i C++ jest droga głównie z powodu ogromnej ilości kodu, którą kompilator musi przeanalizować. Źródłowy plik .cpp to często tylko mała część faktycznego wejścia. Resztę stanowią nagłówki i wygenerowany przez preprocesor kod.
Typowy 300‑linijkowy plik .cpp po przejściu przez preprocesor może mieć dziesiątki lub setki tysięcy linii, jeśli włącza bogate biblioteki standardowe, biblioteki szablonowe (np. STL, Eigen, Boost) i ciężkie frameworki. Kompilator musi:
- załączyć każdą dyrektywę
#includei rekurencyjne include w nagłówkach, - rozwinąć makra (czasem bardzo skomplikowane),
- parsować wszystkie deklaracje i definicje klas, funkcji, szablonów,
- instancjonować szablony (często wielokrotnie dla różnych typów).
Im więcej wspólnych nagłówków jest powtarzanych w wielu plikach .cpp, tym więcej pracy wykonuje kompilator przy każdym buildzie. Ten koszt jest w większości powtarzalny i właśnie tu uderzają narzędzia takie jak ccache i unity builds.
Różnica między „wolno się buduje” a „wolno linkuje”
W językach C i C++ często miesza się pojęcia: „kompilacja jest wolna” i „linkowanie jest wolne”. Profilowanie czasu builda zwykle pokazuje, że:
- kompilacja dominuje w projektach o dużej liczbie plików .cpp i ciężkich nagłówkach,
- linkowanie dominuje, gdy powstaje jeden ogromny binarny moduł (monolityczny serwis, duża biblioteka statyczna) lub gdy włączone jest LTO (Link-Time Optimization).
Prosty test rozróżniający: jeśli paski postępu kompilacji (np. w Ninja) długo „jadą” na poszczególnych plikach .cpp, a końcowy etap linkowania trwa relatywnie krótko, problemem jest kompilacja. Jeśli odwrotnie – kompilacja szybko przechodzi, ale na końcu linkowanie wisi przez długie sekundy czy minuty – winny jest linker lub LTO. To ważne, bo ccache i unity builds wpływają niemal wyłącznie na etap kompilacji, nie na linkowanie.
Wpływ architektury projektu na czas kompilacji
Czas builda zależy nie tylko od narzędzi, ale także od samej architektury kodu:
- Monolit – jeden duży executable, dużo współdzielonych nagłówków, często mocno powiązane moduły. Zmiana w jednym kluczowym nagłówku może wymusić przebudowę połowy projektu.
- Wiele bibliotek współdzielonych – rozdzielenie kodu na moduły pomaga ograniczyć zasięg zmian. Jeśli interfejs biblioteki jest stabilny, zmiany w jej implementacji nie wpływają na konsumentów na etapie kompilacji, tylko na linkowanie.
- Mikroserwisy – mniejsze binaria, mniejsze grafy zależności. Zwykle szybsze eksperymentalne buildy, choć nadrabiają to potencjalnie większą liczbą projektów.
Unity builds często „monolityzują” kompilację w ramach jednego modułu – zwiększają rozmiar pojedynczych translation units, ale zmniejszają ich liczbę. Ccache z kolei zyskuje najbardziej, gdy projekt ma wiele plików kompilowanych wielokrotnie z tymi samymi nagłówkami i flagami.
Krótki przegląd prostych narzędzi pomiarowych
Bez minimum pomiarów łatwo gonić złudzenia. Do rozdzielenia problemów i identyfikacji wąskiego gardła warto używać prostych narzędzi:
/usr/bin/time -v ninjalubtime make -j– daje ogólny czas ścienny i CPU.ninja -d stats– statystyki liczby zadań, czasu spędzonego na kompilacji, linkowaniu, I/O.- logi builda kompilatora (
-ftime-reportdla GCC, odpowiednie flagi Clanga/MSVC) – pokazują, które etapy kompilacji są najdroższe. - logi systemu CI – czasy poszczególnych etapów pipeline’u.
Na tej podstawie można zorientować się, czy inwestować w ccache, unity builds, czy może raczej w refaktoryzację nagłówków lub rezygnację z agresywnego LTO.
Uproszczenia, które mylą przy diagnozie wolnych buildów
Kilka stereotypów często utrudnia trzeźwą diagnozę:
- „Winny jest kompilator” – w wielu przypadkach kompilator robi po prostu ciężką, konieczną pracę na bardzo rozbudowanym kodzie. Zmiana wersji GCC/Clanga może dać kilka–kilkanaście procent poprawy, ale nie rozwiąże problemu strukturalnego.
- „Unity build wszystko załatwi” – unity builds mogą uczynić czysty build kilka razy szybszym, ale czasem drastycznie pogarszają incremental buildy, utrudniają debugowanie i maskują problemy z separacją modułów.
- „Ccache automatcznie przyspieszy każdy projekt” – przy częstych zmianach w szeroko używanych nagłówkach cache hit rate będzie niski i korzyść może być marginalna.
Dlatego kluczowa jest kombinacja: pomiar, zrozumienie architektury kodu i dopiero na tej podstawie dobór technik – a nie odwrotnie.
Pomiar przed optymalizacją – jak rzetelnie zmierzyć czas builda
Podstawowe metryki: clean build, incremental, cold i warm cache
Na potrzeby przyspieszania kompilacji w C/C++ warto zdefiniować kilka precyzyjnych metryk, bo „kompilacja jest wolna” bez doprecyzowania niewiele znaczy.
- Clean build – build po pełnym wyczyszczeniu katalogów z artefaktami (np.
rm -rf build/). Wykonywany stosunkowo rzadko lokalnie, ale typowy w CI. - Incremental build – build po typowej małej zmianie (np. zmiana jednego pliku .cpp lub jednego nagłówka w określonym module). To zwykle najważniejsza metryka dla deweloperów.
- Cold cache – build przy pustym cache’owaniu na poziomie systemu (page cache), ccache, itp. Trudny do idealnego odtworzenia, ale zbliżony do pierwszego builda na świeżej maszynie.
- Warm cache – powtórzenie tego samego builda lub podobnego tuż po poprzednim, gdy dysk, ccache i inne warstwy mają już dane w pamięci.
Profilowanie czasu builda bez wyraźnego rozróżnienia tych scenariuszy prowadzi do błędnych wniosków. Ccache np. jest szczególnie istotny przy incremental buildach i warm cache scenariuszach, zwłaszcza w CI.
Proste metody pomiaru czasu builda
Do podstawowego profilowania wystarczy kilka prostych poleceń:
- Linux / macOS:
/usr/bin/time -v ninja -j8(czy make). Oprócz czasu ściennego podaje czas CPU, zużycie pamięci, page faults. - Windows (PowerShell):
Measure-Command { msbuild.exe /m YourSolution.sln }– prosty pomiar czasu całego builda. - CI: rejestrowanie czasu każdego joba/stage’a. Jeśli pipeline ma osobne kroki „configure”, „build”, „test”, widać od razu, czy problem leży w kompilacji, czy np. w testach.
Ważne, by zapisywać wyniki w stabilny sposób (np. jako artefakty CI, w plikach logów, bazie danych), aby obserwować trend w czasie i ocenić wpływ zmian konfiguracji ccache/unity builds.
Narzędzia specyficzne: Ninja, CMake, MSBuild
Niektóre narzędzia buildowe mają wbudowane wsparcie dla bardziej szczegółowych statystyk:
- Ninja:
ninja -d statswypisuje liczbę zadań kompilacji, czas średni, maksymalny, procent czasu spędzonego w komendach vs idle. Może też generować profil (ninja -t profile,-t compdb). - CMake: flagi
--trace,--trace-expandpomagają diagnozować czas konfiguracji. Opcja-DCMAKE_EXPORT_COMPILE_COMMANDS=ONgeneruje bazę komend kompilacji, przydatną do dalszej analizy i profilowania. - MSBuild: parametry
/clp:PerformanceSummary,/v:diagdostarczają szczegółowe informacje o czasie poszczególnych targetów i tasków.
Połączenie tych danych z narzędziami systemowymi (np. perf, VTune, Windows Performance Analyzer) pozwala dokładnie ustalić, gdzie uciekają sekundy i minuty – czy w kompilacji, preprocesingu, czy może w generatorze kodu lub w testach.
Powtarzalny eksperyment: jak go przygotować
Przed każdym poważniejszym tuningiem kompilacji warto przygotować kilka powtarzalnych scenariuszy testowych:
- Ustalić liczbę równoległych jobów (
-jN,/m:N) – zbyt zmienna liczba wątków zafałszuje wyniki. - Wyłączyć inne duże procesy (np. masowe testy, intensywny IO na tej samej maszynie).
- W CI – używać tego samego typu runnera/maszyny, tej samej konfiguracji.
- Przed „clean build” faktycznie usuwać katalogi z artefaktami (w tym
ccache, jeśli mierzymy cold cache). - Jeśli w czasie builda uruchamiane są testy lub skrypty generujące kod, mierzyć je osobno lub tymczasowo wyłączyć, by skupić się na kompilacji.
Bez takiego podejścia „przyspieszenie o 30%” może w praktyce wynikać np. z innego obciążenia dysku lub z tego, że druga próba miała już nagrzany cache dysku.
Typowe pułapki przy pomiarach czasu kompilacji
Kilka częstych źródeł błędów w interpretacji wyników:
- Cache dysku – pierwsze uruchomienie builda po restarcie systemu będzie zwykle wolniejsze. Kolejne, identyczne buildy mogą mieć radykalnie krótszy czas odczytu plików.
- Zmieniające się zależności – automatycznie generowane nagłówki, pliki wersji, timestampy włączane do kompilacji. Powodują one przebudowy, których deweloper nie zawsze jest świadomy.
- Testy i kroki post-build – jeśli w systemie builda testy są częścią głównego targetu, czas kompilacji jest mieszany z czasem testów. To utrudnia ocenę wpływu ccache czy unity builds.
- Różnice w konfiguracji (Debug/Release, różne flagi optymalizacji) – czas kompilacji przy -O0 może wyglądać bardzo dobrze, ale w realnej konfiguracji produkcyjnej -O2/-O3 sytuacja jest inna.
Profilowanie czasu kompilacji ma sens tylko wtedy, gdy porównuje się te same scenariusze pod tymi samymi warunkami, a nie przypadkowe buildy z różnych dni i gałęzi.

Podstawy konfiguracji systemu buildów pod wydajność
Wybór generatora: Ninja, Make, Visual Studio i inni
Narzędzie sterujące buildem ma istotny wpływ na wydajność, zwłaszcza przy dużej liczbie plików i zależności. Kilka ogólnych obserwacji (są wyjątki, ale reguły są dość stabilne):
- Ninja – projekt stworzony z myślą o maksymalnej szybkości. Minimalny narzut na analizę plików, świetne równoległe wykonywanie, precyzyjne śledzenie zależności. Dobrze współpracuje z CMake.
- Make – klasyk, ale przy dużych projektach potrafi mieć istotny narzut na analizę reguł, zwłaszcza przy złożonych Makefile’ach generowanych dynamicznie.
- Visual Studio (MSBuild) – wygodne dla deweloperów na Windows, ale generowany przez CMake projekt VS często ma mniejszą efektywność równoległości niż Ninja na tej samej platformie.
Jeśli głównym celem jest przyspieszanie kompilacji C++ w codziennej pracy, a nie ściśle powiązanie z IDE, warto rozważyć:
- na Linux/macOS – przejście na Ninja + CMake,
- na Windows – również Ninja jako backend (IDE wciąż może się podłączać do generowanych compile_commands).
Różnica nie zawsze będzie dramatyczna, ale często w granicach kilkunastu–kilkudziesięciu procent na korzyść Ninja przy dużych projektach.
Równoległe kompilacje i ograniczenia sprzętowe
Kompilacja C/C++ jest z natury zadaniem łatwo równoległym – każdy plik .cpp jest osobnym zadaniem. Sterowanie liczbą równoległych jobów:
make -jN,ninja -jN– N określa maksymalną liczbę zadań jednocześnie,msbuild /m:N– równoległe buildy w MSBuild.
Teoretycznie ustawienie N na liczbę rdzeni (lub rdzeni × 1.5) maksymalizuje wykorzystanie CPU. W praktyce kompilacja bywa:
- CPU-bound – wtedy zwiększanie N powyżej liczby rdzeni tylko powoduje kontekst‑switching, a nie przyspiesza builda,
- IO-bound – przy wolnym dysku (HDD, przeciążony NFS) wysokie N może wręcz spowalniać build, bo zadania czekają na dostęp do plików.
Flagi kompilatora a czas builda
Agresywne optymalizacje i „bezpieczne z punktu widzenia wydajności run-time” ustawienia potrafią zabić czas kompilacji. Zanim zacznie się majstrować przy ccache czy unity builds, zwykle da się coś ugrać czystą konfiguracją flag.
- Poziom optymalizacji –
-O0kompiluje zazwyczaj najszybciej,-O2i-O3potrafią wielokrotnie wydłużyć czas kompilacji, zwłaszcza w dużych funkcjach szablonowych. - Debug a Release – częsta praktyka: Release z pełnym
-O2/-O3i dużą ilością sanitizerów lub LTO w CI. Lokalnie deweloper używa podobnej konfiguracji, a potem narzeka na minuty przy drobnej zmianie. Najczęściej da się to rozdzielić. - Sanitizery (ASan, UBSan, TSan) – świetne narzędzia, ale kosztują sporo czasu kompilacji i linkowania. Dobrze, jeśli istnieje odrębna, lżejsza konfiguracja do codziennej pracy.
- LTO / Link-time optimization – zyski w runtime, ale kompilacja i linkowanie zwłaszcza w trybie full LTO mogą wydłużyć build kilkukrotnie. Użyteczne głównie w CI i wydaniach, raczej nie jako domyślny tryb „dev”.
Praktyczny kompromis to zwykle:
- lokalna konfiguracja debugowa z
-O0lub-Og, - osobna konfiguracja Release/RelWithDebInfo z pełnymi optymalizacjami, uruchamiana rzadziej lub głównie w CI.
Inny typowy błąd: włączanie wszystkich możliwych ostrzeżeń i analizerów statycznych na każdą kompilację. Ostrzeżenia typu -Wall -Wextra są rozsądne na co dzień, ale pełne zestawy analizy statycznej (np. /analyze w MSVC, niektóre dodatki Clanga) lepiej wydzielić do osobnego joba w CI.
Prekompilowane nagłówki (PCH) – kiedy pomagają, kiedy przeszkadzają
Precompiled headers potrafią uratować sytuację w projektach z ciężkimi nagłówkami (np. STL, Qt, duże zbiory bibliotek firmowych), ale nie są magicznym przyspieszaczem w każdym układzie.
Typowy schemat:
- tworzony jest jeden (lub kilka) PCH obejmujących najczęściej używane nagłówki,
- wszystkie pliki źródłowe w projekcie dołączają ten sam „super-nagłówek” (np.
pch.h,stdafx.h), - kompilator rozwija i przetwarza go raz, zapisując wynik do pliku PCH, a potem ponownie używa tych danych przy innych jednostkach kompilacji.
Korzyści pojawiają się głównie tam, gdzie:
- ten sam zestaw dużych nagłówków jest włączany do ogromnej liczby plików
.cpp, - nagłówki są relatywnie stabilne (rzadko zmieniane).
Problemy i pułapki:
- zmiana w jednym z nagłówków PCH wymusza przebudowę wszystkich jednostek zależnych – clean build potrafi być wtedy cięższy niż bez PCH,
- źle dobrany zestaw nagłówków (zbyt dużo, w tym często zmieniające się) może dać efekt gorszy niż brak PCH,
- wieloplatformowe projekty często muszą utrzymywać różne PCH na różne toolchainy, co zwiększa złożoność konfiguracji.
Rozsądne podejście:
- zebrać tylko „naprawdę ciężkie i stabilne” nagłówki (STL, Boost, QtCore, wewnętrzne stabilne API) do PCH,
- unikać umieszczania w PCH nagłówków, które zmieniają się kilka razy dziennie (np. generowane pliki wersji, configi z definicjami makr),
- mierzyć efekt PCH osobno dla clean builda i incremental buildów – czasem przyspieszenie incremental jest duże, ale clean wydłuża się o tyle, że w CI bilans wychodzi na zero.
Optymalizacja zależności nagłówkowych
Największe zyski przychodzą często nie z „dopalenia” kompilatora, ale z ograniczenia tego, co on w ogóle musi przetworzyć. Nadmiarowe włączanie nagłówków (szczególnie wielkich, szablonowych) odczuwa się od razu na czasach preprocesingu.
Kilka praktycznych zasad:
- Minimalne nagłówki publiczne – publiczny interfejs modułu nie musi widzieć pełnej definicji wszystkich typów. W wielu przypadkach wystarczy deklaracja wstępna (
class Foo;) i trzymanie pełnej definicji w.cpp. - Wewnętrzne „umbrella headers” z umiarem – nagłówki typu
all_in_one.h, które wciągają cały moduł, bywają wygodne, ale dramatycznie zwiększają zależności i powodują, że zmiana w jednym podnagłówku przebudowuje pół projektu. - Rozbijanie gigantycznych nagłówków – jeżeli pojedynczy
.hma kilkaset kilobajtów i jest używany w wielu modułach, dobrym kandydatem jest rozdzielenie deklaracji na mniejsze części, podłączane tylko tam, gdzie są faktycznie potrzebne. - Unikanie include’ów w nagłówkach z „wygody” – jeśli w nagłówku potrzebny jest tylko wskaźnik lub referencja do typu, nie ma powodu include’ować pełnej definicji.
Dobrym narzędziem diagnostycznym są raporty zależności generowane przez kompilator (np. -M/-MM w GCC/Clang) lub przez build system. Łatwo wtedy zauważyć, że zmiana w jednym prostym nagłówku generuje przebudowę setek plików .cpp.
Caching na poziomie linkera i narzędziach towarzyszących
Czas kompilacji to jedno, ale w dużych projektach wąskim gardłem potrafi być linkowanie lub inne kroki post-build. Zanim zacznie się inwestować w unity builds, czasem wystarczy usprawnić ten etap.
- Incremental linking – w MSVC tryb incremental linku (
/INCREMENTAL) przyspiesza typowe rebuildy, choć może generować wolniejszy, większy binarny artefakt. Na etapie developmentu jest to często akceptowalne. - Caching linkera – w niektórych toolchainach (np. lld w połączeniu z niektórymi build systemami) da się ograniczyć liczbę „dotknięć” linkera przy małych zmianach, np. poprzez dzielenie biblioteki na mniejsze komponenty.
- Generatory kodu i kroki pośrednie – jeśli generowanie plików
.cpplub.hjest kosztowne, trzeba zadbać, żeby uruchamiało się tylko przy realnej zmianie wejścia. Skrypty generujące „na wszelki wypadek” psują cały incremental build.
ccache – jak działa i czego (realistycznie) można oczekiwać
Mechanizm działania ccache w skrócie technicznym
Ccache działa jak pamięć podręczna na poziomie wyjścia kompilatora. Dla danej komendy kompilacji (compiler args + wersja kompilatora + wejściowe pliki) wyliczany jest klucz (hash). Jeżeli istnieje wcześniejszy wynik o tym samym kluczu, ccache zwraca zbuforowany plik obiektu zamiast uruchamiać kompilator.
Najważniejsze elementy klucza:
- pełna ścieżka do kompilatora i jego wersja,
- argumenty przekazane do kompilatora (w tym flagi optymalizacji, warningi, definicje makr),
- zawartość przetwarzanych plików źródłowych i nagłówków,
- często również zmienne środowiskowe wpływające na kompilację.
Jeśli którykolwiek z tych elementów się zmienia, klucz jest inny, a wpis z cache nie może zostać użyty. To naturalne, ale prowadzi do zaskoczeń, gdy np. ścieżka do projektu zawiera timestamp w katalogu lub wersja kompilatora jest często aktualizowana w CI.
Kiedy ccache daje największy efekt
Ccache nie przyspiesza pierwszego pełnego builda na danej maszynie – wtedy cache jest pusty. Korzyści pojawiają się w powtarzalnych scenariuszach:
- Incremental buildy lokalne – częste przełączanie się między branchami, rebase, cofanie zmian. Duża część kodu pozostaje niezmieniona, więc ccache zwraca gotowe pliki
.o. - CI z reużywalnym storage – jeśli runner CI utrzymuje cache pomiędzy jobami/commitami, przy stabilnej konfiguracji builda ccache potrafi drastycznie skrócić czas clean buildów, bo „czysty” build z punktu widzenia artefaktów nie jest pusty z punktu widzenia cache.
- Różne konfiguracje testowe przy tej samej wersji kompilatora – jeśli znaczna część flag jest wspólna, a różnią się tylko drobne rzeczy (np. definicje części makr, włączanie pojedynczych modułów), hit rate może być nadal sensowny.
Typowe scenariusze, gdzie oczekiwania bywają nadmierne:
- częste zmiany całego zestawu flag kompilatora (ciągłe przełączanie między zupełnie różnymi profilami),
- systemy CI z efemerycznymi maszynami bez współdzielonego cache – każdy job zaczyna od zera,
- projekty, gdzie generowane nagłówki zmieniają się przy każdym buildzie (np. wplatanie timestampów do interfejsów).
Ograniczenia i edge-case’y ccache
Kilka szczegółów, które często wywołują „nagły spadek” hit rate, a nie zawsze są oczywiste:
- Ścieżki do plików – jeśli projekt jest budowany w różnych katalogach (np. różne ścieżki robocze w CI, developerzy w różnych katalogach domowych), domyślna konfiguracja może traktować takie buildy jako odrębne. Ccache ma opcje modyfikujące zachowanie
CCACHE_BASEDIR, ale ich użycie niesie konsekwencje dla deterministyczności. - Wbudowane makra kompilatora –
__DATE__,__TIME__,__FILE__mogą powodować, że z punktu widzenia cache każdy build to coś nowego. Trzeba ich unikać w kodzie włączanym do produkcji lub świadomie ograniczyć do bardzo wąskich miejsc. - Różne wersje kompilatora – aktualizacja toolchainu (nawet minor) zwykle oznacza dla ccache osobny namespace w cache. Stary cache formalnie jest bezużyteczny dla nowego kompilatora. To rozsądne z punktu widzenia bezpieczeństwa, ale w CI potrafi „skasować” oszczędności po każdej zmianie obrazu bazowego.
- Kompilacja skrośna (cross-compiling) – jeśli w projekcie używanych jest kilka różnych kompilatorów (host/target), ccache musi zostać poprawnie skonfigurowany dla każdego z nich, inaczej część buildów będzie zawsze „miss”.
Jak realistycznie oceniać wyniki ccache
Sama liczba „cache hit rate: 90%” niewiele znaczy, jeśli nie widać, co się kryje pod spodem. Przy ocenie efektu warto patrzeć na:
- stosunek czasu spędzonego w kompilatorze vs w ccache (czas wall + CPU),
- hit rate osobno dla clean i incremental buildów,
- stopień „rozjechania się” konfiguracji – czy każdy branch/gałąź ma inne flagi i inne ścieżki.
Dobrym nawykiem jest włączenie szczegółowych statystyk (np. ccache -s) przed i po serii buildów oraz zapisywanie ich w artefaktach CI. Widać wtedy, czy zmiana konfiguracji faktycznie poprawiła sytuację, czy tylko przesunęła obciążenie w inne miejsce.

Konfiguracja i tuning ccache w praktyce (Linux, macOS, Windows)
Podstawowa konfiguracja ccache
Minimalna, ale sensowna konfiguracja obejmuje:
- ustawienie lokalizacji cache (np.
CCACHE_DIR), - ograniczenie rozmiaru cache (
CCACHE_MAXSIZE), - zdecydowanie, czy cache jest współdzielony (np. między użytkownikami/runnerami CI), czy lokalny dla jednego dewelopera.
Przykładowa konfiguracja w pliku ~/.ccache/ccache.conf:
max_size = 20G
compression = true
compression_level = 5
Rozmiar cache musi być dobrany do wielkości projektu i ilości miejsca na dysku. Zbyt mały cache będzie się często czyścił, co obniża hit rate. Zbyt duży, przy wolnym dysku, może powodować wyraźne opóźnienia przy zarządzaniu wpisami (pruning).
Integracja z CMake i Ninja
W świecie CMake typowy sposób integracji to ustawienie zmiennej kompilatora na wrapper ccache:
cmake -DCMAKE_C_COMPILER=ccache -DCMAKE_CXX_COMPILER=ccache ..Lub bardziej jawnie, z wykorzystaniem właściwości:
set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE ccache)Zaletą drugiego podejścia jest mniejsza ingerencja w konfigurację projektu – kompilator pozostaje „prawdziwym” binarium, a ccache jest tylko wrapperem na poziomie reguł. W niektórych środowiskach (szczególnie przy cross-compilingu) daje to mniej zaskoczeń.
Ważne, by:
- unikać podmiany kompilatora w połowie cyklu konfiguracji (CMake zapisuje wiele informacji o toolchainie, które stają się nieaktualne),
- pozostawić generowanie
compile_commands.jsonw stanie spójnym – narzędzia analityczne powinny widzieć prawdziwy kompilator, a nie wrapper.
Specyficzne ustawienia dla CI i buildów współdzielonych
Przy konfiguracji ccache w CI główną decyzją jest to, czy cache ma być:
- współdzielony między jobami/agentami (np. wspólne NFS/S3),
- lokalny dla pojedynczej maszyny/runnera.
Pierwsza opcja daje potencjalnie większe zyski, ale natychmiast wyciąga na wierzch problemy z prędkością i spójnością storage. Druga bywa „nudna”, ale przewidywalna – cache rośnie i stabilizuje się na danej maszynie, nie trzeba się martwić o wyścigi przy równoczesnym zapisie.
Przy współdzielonym cache kilka ustawień i praktyk staje się krytycznych:
- trzymanie stabilnego obrazu toolchainu (ten sam kompilator i te same ścieżki) dla większości jobów; częste przebudowy kontenerów kasują korzyści,
- rozważne użycie
CCACHE_BASEDIR, aby znormalizować ścieżki buildów w różnych katalogach roboczych runnerów, - dobór backendu przyjaznego wielu klientom – klasyczny NFS potrafi zamulić przy dużej liczbie jednoczesnych odczytów/zapisów małych plików.
Zdarza się, że lokalny cache na każdym runnerze z osobna, zsynchronizowany okazjonalnie (np. rsync raz dziennie) daje bardziej stabilne czasy niż pełne współdzielenie per-job. Nie ma uniwersalnej recepty – bez kilku tygodni pomiarów na danym CI trudno zgadnąć, które ograniczenie będzie dominujące: CPU kompilatora, dysk, czy sieć.
Windows i ccache – praktyczne obejścia
Na Windows ccache formalnie działa, ale największe problemy wynikają z kombinacji:
- różnych ABI i frontendów (MSVC, Clang-CL, MinGW),
- innej semantyki ścieżek i wielkości liter,
- narzędzi uruchamianych poprzez
.bat/.cmd, a nie bezpośrednie binaria.
W praktyce przy MSVC wygodniejsze bywa użycie sccache (który jest bardziej „native” dla Windows), ale jeśli projekt korzysta z GCC/Clang pod MSYS/MinGW, klasyczny ccache nadal ma sens. Kilka rzeczy wymaga wtedy świadomej konfiguracji:
- uruchamianie przez bezpośrednie binaria (np.
clang++.exe), a nie aliasy skryptowe, - pilnowanie spójności ścieżek (CMake + Ninja pod MSYS potrafią mieszać
C:i ścieżki w stylu/c/), - przechowywanie katalogu cache na stałym, szybkim dysku, a nie w domyślnym katalogu profilu użytkownika synchronizowanym przez korporacyjny OneDrive.
Jeśli narzędzia są głównie msvc-owe, a ccache i tak musi być emulowany przez warstwę POSIX, zyski bywają wątpliwe. W takiej konfiguracji rozsądniej najpierw zoptymalizować sam system buildów i I/O (np. Ninja + PCH), a dopiero potem oceniać zasadność cache’a.
Monitoring i utrzymanie cache w długim cyklu życia projektu
Po początkowej konfiguracji ccache stopniowo „gnije”, jeśli nikt nie pilnuje podstawowych parametrów. Typowe symptomy:
- narastający czas dostępu do cache przy dużej liczbie plików,
- nagle spadający hit rate po dodaniu nowej konfiguracji toolchainu,
- zajęcie całego dostępnego miejsca na dysku przy zbyt wysokim limicie rozmiaru cache.
Prosty mechanizm kontrolny to:
- okresowe (np. raz dziennie w CI) zapisywanie wyniku
ccache -sjako artefaktu, - skrypt, który sprawdza, czy:
- hit rate dla clean buildów nie spadł poniżej określonego progu,
- pruning nie „zjada” zbyt dużej części wpisów między kolejnymi buildami.
Jeśli pruning jest częsty i agresywny, prawdopodobnie rozmiar cache jest za mały w stosunku do powierzchni projektu albo do liczby równoległych konfiguracji (debug/release, różne architektury). Wtedy trzeba świadomie ograniczyć liczbę wspieranych konfiguracji w CI albo rozdzielić cache na osobne katalogi per-konfiguracja.
Unity builds (Jumbo builds) – idea, korzyści i koszty uboczne
Czym są unity builds w praktyce
Unity build polega na wygenerowaniu plików źródłowych, które #include’ują wiele „zwykłych” .cpp w jeden większy moduł kompilacji. Dla kompilatora wygląda to jak jeden, spory translation unit, dzięki czemu:
- nagłówki są parsowane tylko raz per unity-plik,
- makra, szablony i inline’y są rozwijane na większym fragmencie kodu naraz,
- czas startu kompilatora (setup toolchainu, parsowanie standardowych nagłówków) jest amortyzowany.
Typowy schemat:
// unity_core_1.cpp
#include "src/foo.cpp"
#include "src/bar.cpp"
#include "src/baz.cpp"
Tradycyjne foo.cpp, bar.cpp, baz.cpp nadal istnieją, ale w konfiguracji unity nie są kompilowane bezpośrednio. Kompilator widzi tylko unity_core_1.cpp, gdzie zawartość pozostałych plików jest „wciągnięta” przez preprocesor.
Dlaczego unity builds przyspieszają kompilację
Większość czasu kompilatora w typowym projekcie C++ to nie generowanie kodu maszynowego, tylko:
- parsowanie nagłówków (często setki tysięcy linii),
- instancjonowanie szablonów i rozwiązywanie typów,
- praca preprocesora na powtarzalnych zestawach
#include.
Unity builds minimalizują powtarzanie tej pracy, bo:
- ten sam zestaw nagłówków projektowych i bibliotecznych jest parsowany raz w jednym dużym TU, zamiast osobno w wyizolowanych
.cpp, - instancje szablonów widoczne w obrębie jednego unity-plitu mogą być lepiej współdzielone,
- preprocesor ma krótszą ścieżkę I/O – mniej otwieranych plików, mniej statów, mniej cache misses w systemie plików.
Przy dużych projektach różnica jest wyraźna: zamiast dziesiątek tysięcy małych translation units powstaje kilkaset większych. To lepiej pasuje do architektury współczesnych kompilatorów, o ile nie przesadzi się z rozmiarem pojedynczego unity-pliku.
Typowe problemy wyskakujące po włączeniu unity
Wprowadzenie unity błyskawicznie obnaża miejsca, w których projekt łamał niepisane zasady izolacji między plikami. Najczęstsze kategorie problemów:
- Globalne symbole – funkcje/zmienne zewnętrzne bez
statici bezinline, zdefiniowane w wielu.cpp; kompilator, widząc je w jednym unity-TU, zgłasza multiple definition już na etapie kompilacji (a nie dopiero linkowania). - Makra i redefinicje – różne pliki
.cppzakładają, że mają własne makro-konfiguracje; w unity jedno#definemoże „zanieczyścić” kolejny.cppdołączony później. - Ukryte zależności na kolejność – jeśli coś „działało” tylko dlatego, że dany nagłówek był transitively wciągany w konkretnej kolejności, unity potrafi odwrócić kolejność
#includei ujawnić błędy. - Czas kompilacji pojedynczego TU – pojedynczy unity-plik może stać się tak ciężki, że jego kompilacja przekracza timeout w CI lub „zjada” pamięć na słabszych maszynach.
Większość z tych błędów oznacza, że kod i tak był kruchy, tylko tradycyjny layout .cpp to maskował. Unity działa tu jak wymuszone „sanity check” spójności.
Relacja unity builds z PCH, ccache i kompilacją równoległą
Unity nie zastępuje PCH ani ccache; te mechanizmy się uzupełniają, ale nie zawsze liniowo. Kilka charakterystycznych interakcji:
- Unity + PCH – jeśli PCH jest już dobrze skonfigurowane (standardowe nagłówki i stabilne nagłówki projekowe), unity może przynieść mniejszy przyrost niż „od zera”. Zysk pochodzi wtedy głównie z redukcji parsowania ciężkich nagłówków lokalnych i szablonów bibliotecznych, które nie trafiły do PCH.
- Unity + ccache – unity zmienia kształt workloadu. Pojedyncza zmiana w jednym
.cppmoże unieważnić całą grupę plików w jednym unity-TU, co zmniejsza skuteczność incremental buildów i ccache. Z drugiej strony mniej TUs oznacza mniej entries w cache. - Unity + równoległość – zamiast tysięcy małych jobów kompilatora, pojawia się kilkaset większych. Jeśli ilość rdzeni jest duża, ale liczba unity-plików niewspółmiernie mała, część hardware’u pozostanie niewykorzystana. Trzeba świadomie dobrać liczbę unity-plików do typowej liczby równoległych zadań (np.
n_jobs ≈ 2 × liczba rdzeni).
Zdarzają się projekty, gdzie agresywne PCH daje podobny efekt co unity, ale bez kosztów ubocznych. W innych unity jest jedynym realnym sposobem zejścia z czasu clean builda z godzin do kilkunastu minut. Nie ma uniwersalnego „leku”, trzeba testować konfiguracje per-projekt.
Gdzie unity builds mają największy sens
Największe przyspieszenia pojawiają się zazwyczaj tam, gdzie:
- projekt ma tysiące
.cppo podobnym zestawie#include, - nagłówki są ciężkie (szablony, meta-programowanie, duże biblioteki),
- clean build jest wykonywany często (np. wiele gałęzi w CI, testy z różnymi konfiguracjami),
- linkowanie nie jest jedynym dominującym wąskim gardłem (w przeciwnym razie optymalizuje się nie to, co trzeba).
W małych i średnich projektach (kilkaset .cpp) z prostą strukturą nagłówków i dobrą konfiguracją PCH zysk bywa mizerny w stosunku do nakładu na utrzymanie unity. Jeśli clean build trwa 2–3 minuty, unity może skrócić go o 30–40 sekund, ale przy każdym bardziej skomplikowanym błędzie kompilacji/debugowaniu overhead szybkiej kompilacji bywa „zjadany” przez trudniejszą diagnozę.
Projektowanie i wdrażanie unity builds bez rozbijania projektu
Strategia stopniowego wdrażania unity
Najbardziej ryzykowny wariant to masowe przełączenie całego projektu na unity w jednym commicie. Lepiej podejść do tematu iteracyjnie:
- Włączyć unity tylko dla jednego, odizolowanego modułu (np. podkatalogu biblioteki wewnętrznej).
- Skonfigurować osobny target builda (np.
myproj_unity), obok klasycznego – tak, aby można było porównywać czasy i diagnozować regresje niezależnie. - Przerobić typowe „antypatterny unity” (globalne symbole, brudzące makra, ukryte zależności) w tym module.
- Rozszerzać unity na kolejne komponenty, gdy wcześniejsze są już stabilne.
Taki tryb pozwala też zespołowi stopniowo przyzwyczaić się do nowych kategorii błędów, zamiast mieć setki komunikatów z całego projektu naraz.
Automatyczne generowanie plików unity
Ręczne utrzymywanie listy #include "foo.cpp" nie skaluje. W większości projektów unity-pliki generuje się automatycznie podczas konfiguracji lub builda. Proste podejście:
- skrypt (np. w Pythonie) skanuje katalog z
.cpp, - dzieli listę plików na grupy o zbliżonej wielkości (liczbie plików lub szacowanej „wadze”),
- generuje
unity_X.cppz odpowiednimi#include, - aktualizuje konfigurację build-systemu (CMake/Ninja) do kompilowania unity-pliku zamiast pojedynczych
.cpp.
Przykładowo, w CMake można wygenerować listę unity-plików w trakcie konfiguracji:
set(ALL_SOURCES
src/foo.cpp
src/bar.cpp
src/baz.cpp
# ...
)
include(generate_unity) # własny moduł CMake
generate_unity_sources(ALL_SOURCES UNITY_SOURCES 20) # 20 plików na unity
add_library(core STATIC ${UNITY_SOURCES})
Gdzie generate_unity_sources() to funkcja, która generuje pliki unity_*.cpp w katalogu builda. Dzięki temu źródła pozostają czyste, a layout unity jest całkowicie po stronie konfiguracji.
Funkcje wyłączone z unity (blacklist/whitelist)
Nie wszystkie pliki .cpp nadają się do wrzucenia do jednego translation unit. Typowe kandydaty na „wyłączone z unity”:
- pliki, które:
- definiują globalne operatory new/delete,
- zawierają „magiczne” makra konfigurujące zachowanie systemowych nagłówków (np.
WIN32_LEAN_AND_MEAN,_GNU_SOURCE), - wykorzystują
#includegenerujące różne instancje tego samego pliku nagłówkowego pod innymi definicjami makr.
Najczęściej zadawane pytania (FAQ)
Co tak naprawdę najbardziej spowalnia kompilację w C i C++?
Najczęściej głównym kosztem nie jest sam kod w pliku .cpp, tylko to, co przychodzi z nagłówków po przejściu przez preprocesor. Jeden niewielki plik źródłowy może po rozwinięciu mieć dziesiątki tysięcy linii, jeśli włącza rozbudowane biblioteki standardowe, szablony (STL, Eigen, Boost) czy frameworki.
Kompilator musi wtedy rekurencyjnie przetworzyć wszystkie #include, rozwinąć makra, sparsować definicje klas i funkcji oraz zinstancjonować szablony dla różnych typów. Jeśli te same nagłówki są powtarzane w dziesiątkach plików .cpp, praca jest powielana przy każdym buildzie. To jest miejsce, gdzie można najwięcej ugrać, optymalizując strukturę nagłówków i używając narzędzi takich jak ccache czy unity builds.
Jak sprawdzić, czy problemem jest powolna kompilacja czy linkowanie?
Najprostszy test to obserwacja kroków builda: jeśli paski postępu kompilatora długo „mielą” poszczególne pliki .cpp, a linkowanie na końcu trwa krótko, problemem jest kompilacja. Jeśli kompilacja przechodzi sprawnie, ale ostatni etap linkowania „wisi” przez długie sekundy lub minuty, wąskim gardłem jest linker lub LTO (Link-Time Optimization).
Praktycznie można to potwierdzić narzędziami: /usr/bin/time -v ninja lub time make -j pokaże ogólny czas, a ninja -d stats rozbije go na kompilację i inne zadania. W CI zwykle widać to po czasie ostatniego kroku „link” lub „link+LTO” w logach pipeline’u.
Czy ccache zawsze przyspiesza kompilację C/C++?
Nie. Ccache daje duży zysk wtedy, gdy:
- pliki są kompilowane wielokrotnie z tymi samymi flagami,
- nagłówki są w miarę stabilne (mało zmian w szeroko używanych plikach .h),
- buildy są powtarzalne (np. w CI lub w częstych incremental buildach).
Jeśli wspólne nagłówki zmieniają się często, cache hit rate spada i korzyść bywa marginalna.
Typowy scenariusz: w projekcie monolitycznym zmiana jednego core’owego nagłówka generuje przebudowę połowy kodu. Wtedy ccache niewiele pomoże, bo większość artefaktów stanie się nieaktualna. W takiej sytuacji większy efekt daje porządkowanie zależności i rozbicie modułów niż samo dorzucenie cache’a.
Czym są unity builds i kiedy faktycznie przyspieszają build?
Unity build polega na łączeniu wielu plików .cpp w jeden większy translation unit (np. przez generowany plik, który #includeuje inne .cpp). Kompilator analizuje wtedy wspólne nagłówki tylko raz dla całej grupy plików, zamiast osobno dla każdego. Przy dużych projektach potrafi to wielokrotnie przyspieszyć clean build.
Efekt nie jest jednak gwarantowany. Unity builds:
- mogą mocno przyspieszyć pełne buildy,
- często pogarszają incremental buildy (mała zmiana wymusza przebudowę całego „zunifikowanego” zestawu),
- utrudniają debugowanie i wykrywanie problemów z zależnościami (ukryte include’y, niejednoznaczne symbole).
Sprawdzają się głównie tam, gdzie dominują duże clean buildy (np. w CI na świeżych maszynach), a mniej w codziennym, lokalnym cyklu „zmiana → szybki incremental build”.
Jak rzetelnie zmierzyć czas kompilacji w C/C++?
Najpierw trzeba rozdzielić kilka scenariuszy: clean build, incremental build po typowej małej zmianie, build przy pustym (cold) i „rozgrzanym” (warm) cache’u. Bez tego łatwo wyciągnąć błędne wnioski, np. chwalić się czasem pierwszego builda, który deweloper wykonuje raz na tydzień, ignorując codzienny incremental.
W praktyce wystarczy kilka prostych narzędzi:
/usr/bin/time -v ninja -jNlubtime make -jN– ogólny czas ścienny, CPU, pamięć,ninja -d stats– liczba zadań i czas spędzony na kompilacji vs idle,- logi kompilatora (np.
-ftime-reportw GCC) – które etapy kompilacji są najdroższe.
Wyniki warto zapisywać w stałym miejscu (artefakty CI, repo z logami), żeby porównywać konfiguracje przed i po zmianach w ccache, unity builds czy architekturze projektu.
Jak architektura projektu wpływa na czas builda C/C++?
Struktura kodu często ma większy wpływ niż wersja kompilatora. W dużym monolicie, gdzie wiele modułów współdzieli te same nagłówki, jedna zmiana w kluczowym pliku .h potrafi wywołać lawinę przebudowań. W modelu „wiele bibliotek współdzielonych” zmiany w implementacji biblioteki zwykle nie wymuszają rekompilacji kodu klienckiego, tylko ponowne linkowanie.
Mikroserwisy czy mniejsze moduły często budują się szybciej, bo graf zależności jest skromniejszy. Z drugiej strony, liczba projektów rośnie i pojawia się inny koszt – utrzymania. Unity builds dodatkowo „monolityzują” kompilację w ramach modułu: drastycznie zmniejszają liczbę translation units, ale powiększają każdy z nich. W efekcie poprawiają clean build, za to nierzadko pogarszają czas po małych zmianach.
Czy zmiana kompilatora (np. GCC na Clang) znacząco przyspieszy build?
Zazwyczaj zysk z samej zmiany kompilatora to pojedyncze–kilkanaście procent, rzadko rewolucja. Kompilator zwykle wykonuje ciężką, ale konieczną pracę na bardzo rozbudowanym kodzie. Jeśli kod jest pełen ciężkich nagłówków i szablonów powtarzanych w setkach plików .cpp, to żaden kompilator nie „wyczaruje” nagle 10× krótszego czasu.
W praktyce większe efekty daje:
- ograniczenie zależności w nagłówkach (pimpl, forward-declarations, dzielenie interfejs/implementacja),
- sensowna modularizacja (podział na biblioteki, redukcja „globalnych” nagłówków),
- rozsądne użycie ccache i ewentualnie unity builds po wcześniejszym profilowaniu.
Zmiana kompilatora ma sens, ale raczej jako element większej strategii, a nie jedyne „magic bullet”.






