Profilowanie aplikacji krok po kroku w Linuxie

0
33
2/5 - (1 vote)

Nawigacja:

Dlaczego profilowanie, a nie „strzelanie na ślepo”

Różnica między przeczuciem a zmierzonym problemem

Program „zjada CPU”, użytkownicy zgłaszają „lagi”, a logi pełne są ostrzeżeń. Naturalny odruch: szukać „magicznej” opcji w konfiguracji albo przepisywać od razu „podejrzany” fragment kodu. To klasyczny przykład strzelania na ślepo. Brakuje twardych danych, skąd bierze się opóźnienie ani gdzie faktycznie ginie czas procesora.

Profilowanie aplikacji w Linuxie zamienia domysły na pomiar. Zamiast „wydaje mi się, że bottleneck jest w bazie”, otrzymujesz konkret: 63% czasu CPU zużywa funkcja parse_json(), a realny I/O jest minimalny. Praca zmienia się wtedy z gaszenia pożarów na audyt procesu wykonania: krok po kroku, z jasnymi wskaźnikami, gdzie dotknąć kodu lub konfiguracji.

Jeśli Twoje działania optymalizacyjne nie są poprzedzone pomiarem, równie dobrze możesz wprowadzać zmiany losowo. Czasem „trafią”, ale najczęściej ulepszysz element, który ma marginalny udział w całkowitym czasie wykonania, marnując godziny i ryzykując regresje.

Profilowanie jako powtarzalny proces, nie jednorazowa akcja

Porządne profilowanie nie polega na jednorazowym uruchomieniu perf czy valgrind. To proces, który ma własny cykl życia. Minimum wygląda tak:

  • Hipoteza – np. „przy większej liczbie użytkowników CPU idzie w 100%, bo algorytm wyszukiwania jest złożoności O(n²)”.
  • Pomiar – profil wydajności przy kontrolowanym obciążeniu, wyciągnięcie konkretnych liczb: gdzie i ile czasu spędza proces, jaki jest profil CPU / I/O / pamięci.
  • Zmiana – poprawka w kodzie lub konfiguracji, najlepiej jedna istotna na raz, żeby dało się przypisać efekt do przyczyny.
  • Weryfikacja – ponowne profilowanie w tych samych warunkach, porównanie wyników, sprawdzenie, czy problem został usunięty, czy tylko przesunięty w inne miejsce.

Przerwanie tego cyklu w środku to sygnał ostrzegawczy. Przykład: widać wysokie użycie CPU, wprowadzasz kilka zmian „na wszelki wypadek”, ale nie mierzysz efektu. Po tygodniu nikt nie jest w stanie powiedzieć, co faktycznie pomogło, a co tylko po cichu obniżyło stabilność.

Jeżeli profilowanie traktujesz jako powtarzalną pętlę hipoteza → pomiar → zmiana → weryfikacja, z czasem zyskujesz nie tylko szybszy kod, ale i wiedzę, jak Twoje aplikacje starzeją się wydajnościowo wraz ze zmianami biznesowymi i rosnącym ruchem.

Kiedy profilować – typowe sygnały ostrzegawcze

Profilowanie również ma swój koszt, więc nie jest narzędziem do każdej drobnej niedogodności. Dobrą praktyką jest reagowanie, gdy pojawiają się powtarzalne sygnały:

  • Systematyczny wzrost czasu odpowiedzi przy niezmienionym lub tylko nieznacznie rosnącym ruchu.
  • Skoki obciążenia CPU – load average stale powyżej liczby rdzeni, przy braku dużego I/O.
  • Throttling CPU lub „duszenie” kontenerów przez limity – szczególnie w środowiskach Kubernetes/Docker.
  • Skoki zużycia pamięci i częste OOM-killer, mimo że logika biznesowa nie wskazuje na aż tak duże potrzeby.
  • Stałe blokady – procesy wiszą długo w stanie D, kolejki dyskowe są przepełnione.

Jeżeli któryś z tych symptomów powtarza się pod obciążeniem, to punkt kontrolny: czas na profil CPU/pamięci lub I/O zamiast dalszego dokręcania parametrów JVM, PHP-FPM czy bazy „na ślepo”.

Skutki braku profilowania – co zwykle idzie nie tak

Ignorowanie metod profilowania ma kilka typowych konsekwencji:

  • Przedwczesna optymalizacja – spędzanie tygodni na ręcznym „tuningu” funkcji, które odpowiadają za ułamki procenta czasu wykonania.
  • Rozpraszanie wysiłku – zespół pracuje równolegle nad różnymi elementami, bo każdy „czuje”, że coś innego jest winne.
  • Przekupowanie sprzętem – dorzucanie kolejnych vCPU i RAM, gdy kod nadal skaluje się słabo i problem wraca po kilku miesiącach.
  • Brak regresji wydajnościowej w pipeline – zmiany kodu wchodzą bez kontroli kosztu wydajności; spadki widoczne dopiero na produkcji.

Jeżeli nie masz twardych danych o tym, gdzie aplikacja spędza czas i jakie zasoby konsumuje, każda „optymalizacja” jest domysłem, a nie zaplanowaną poprawą. Profilowanie jest tu minimalnym standardem jakości, nie luksusem.

Przygotowanie środowiska do profilowania w Linuxie

Uprawnienia i konfiguracja jądra

Profilowanie w Linuxie bazuje na możliwościach jądra: licznikach sprzętowych, tracepoints, dostępie do /proc. Zbyt restrykcyjna konfiguracja lub brak uprawnień potrafi skutecznie zablokować sensowne pomiary.

Kluczowe elementy do sprawdzenia:

  • perf_event_paranoid – parametr jądra określający, jak bardzo ograniczone jest korzystanie z perf:
    • 0 lub 1 – profilowanie na poziomie jądra i użytkownika jest zwykle dostępne;
    • 2 i więcej – znacznie ogranicza możliwości, szczególnie dla użytkowników bez uprawnień roota.
  • Dostęp do /proc – niektóre polityki bezpieczeństwa (np. w kontenerach) usuwają lub przycinają /proc, co utrudnia narzędziom zbieranie danych.
  • Kernel headers – niezbędne do budowania niektórych profilerek (np. narzędzi eBPF) lub instalacji perf z dystrybucji.

Jeżeli perf odmawia działania lub pokazuje komunikaty o braku dostępu do liczników, pierwszy punkt kontrolny to zawartość /proc/sys/kernel/perf_event_paranoid oraz polityka bezpieczeństwa (AppArmor, SELinux, profile kontenerów).

Podstawowe narzędzia do profilowania w systemie

Na sensowny audyt wydajności w Linuxie składa się kilka warstw narzędzi. Minimum, które powinno znaleźć się w systemie testowym lub narzędziowym, to:

  • time – prosta analiza czasu wykonania procesu (real, user, sys).
  • top / htop – szybki pogląd na użycie CPU, pamięci i stany procesów.
  • vmstat, iostat, free, sar – analiza kondycji systemu: CPU, pamięć, I/O, swap.
  • perf – główny profiler CPU i zdarzeń sprzętowych w nowoczesnych jądrach.
  • gprof – profilowanie na podstawie kompilacji z -pg (bardziej klasyczne podejście).
  • valgrind (w szczególności massif) – profilowanie pamięci i wykrywanie wycieków.
  • strace, ltrace – analiza wywołań systemowych i wywołań bibliotecznych.

Jeżeli brakuje któregoś z tych elementów lub ich użycie jest zabronione na produkcji, trzeba to uwzględnić w planie profilowania i przygotować osobne środowisko z odpowiednim zestawem narzędzi.

Kompilacja aplikacji z symbolami debug

Większość nowoczesnych profilerów potrafi coś powiedzieć nawet bez symboli debug, ale analiza bez przyjaznych nazw funkcji i linii kodu jest żmudna. Minimalnym standardem jest budowanie wersji profilowanej z:

  • -g – generowanie symboli debug;
  • -O2 – optymalizacje na rozsądnym poziomie, aby profil odzwierciedlał rzeczywiste zachowanie aplikacji.

Flaga -O0 ułatwia debugowanie, ale drastycznie zmienia profil wykonywania (brak inline, inny rozkład kodu, inne użycie rejestrów). Profil wydajnościowy na -O0 często prowadzi do fałszywych wniosków – wąskie gardła pojawiają się w miejscach, które znikają lub zmieniają się przy optymalnej kompilacji.

Rozsądne podejście:

  • wersja do profilowania wydajności: -O2 -g,
  • wersja do debugowania logiki: -O0 -g.

Jeśli profil CPU wskazuje na kod generowany przez kompilator (np. „[unknown]” lub „__libc_start_main”), a nie na Twoje funkcje, to wyraźny sygnał, że brakuje symboli lub binaria zostały zstripowane.

Środowisko testowe vs produkcyjne

Profilowanie „na żywo” na produkcji ma sens, ale tylko przy zachowaniu rygorystycznych kryteriów bezpieczeństwa i stabilności. Z kolei profilowanie wyłącznie na lokalnej maszynie developera zazwyczaj nie odzwierciedla realnego obciążenia i wzorców użycia.

Kilka kluczowych punktów kontrolnych:

  • Profilowanie na produkcji:
    • stosować głównie narzędzia o niskim narzucie (perf w trybie sampling, eBPF, lekkie tracepoints);
    • ograniczać czas i zakres profilowania (konkretny PID, wąski okres szczytowego obciążenia);
    • monitorować overhead – jeśli perf zaczyna sam generować problem, kończyć sesję.
  • Profilowanie na kopii:
    • konieczny realistyczny scenariusz obciążenia (generator ruchu, dane zbliżone do produkcyjnych);
    • konfiguracja systemu możliwie zbieżna z produkcją (CPU, parametry jądra, limity ulimit, wersje bibliotek);
    • można używać narzędzi o wyższym narzucie (valgrind, pełne trace’y systemowe).

Jeśli brak jest osobnego środowiska zbliżonego do produkcji, każda interpretacja wyników jest obarczona dużym ryzykiem. Optymalizujesz wtedy pod warunki lokalne (inne cache, inną topologię NUMA, inne limity), które niewiele mają wspólnego z realnym ruchem.

Metodyka profilowania – proces krok po kroku

Definiowanie celu profilowania

Pierwsze pytanie przed odpaleniem narzędzi brzmi: co dokładnie chcesz poprawić. Brak precyzyjnego celu rozmywa analizę i prowadzi do lawiny niepowiązanych pomiarów.

Najczęstsze typy celów:

  • Czas odpowiedzi – np. „średni czas obsługi requestu HTTP nie może przekraczać 200 ms”.
  • Zużycie CPU – np. „przy X zapytań na sekundę CPU nie powinno przekraczać 70% na dłużej niż Y minut”.
  • Limit pamięci – np. „proces ma działać stabilnie w granicy 512 MB RAM”.
  • Przepustowość – np. „aplikacja powinna obsłużyć minimum N requestów/s przy danym SLA czasów odpowiedzi”.

Jeżeli cel jest opisany ogólnikowo („ma być szybciej”), każdy wykres można zinterpretować tak, by pasował do oczekiwań. Precyzyjny cel profilowania jest elementem minimalnym; bez niego profilowanie staje się chaotycznym „klikanie w narzędzia”.

Wybór scenariusza obciążenia

Aby wyniki profilowania były powtarzalne, scenariusz ruchu musi być możliwie stabilny i opisany. Nie chodzi o idealny benchmark, tylko o konfigurację, którą można uruchomić ponownie po wprowadzeniu zmian.

Przygotowując scenariusz:

  • Określ typowe dane wejściowe – np. realne payloady JSON, rzeczywiste zapytania SQL z logów, a nie sztuczne „Hello world”.
  • Zadbaj o powtarzalność – ten sam zestaw danych i podobny rozkład czasowy między requestami.
  • Ustal minimalną liczbę powtórzeń testu – pojedynczy przebieg może być zaburzony zimnym cachem, jitterem sieci, GC itd.

Jeżeli scenariusz obciążenia zmienia się przy każdym teście, wyniki profilu będą trudne do porównania i łatwo wyciągnąć błędny wniosek, że zmiana kodu „pomogła”, podczas gdy realnie spadło obciążenie lub zmienił się zestaw danych.

Makroprofil vs mikroprofil – dwa poziomy spojrzenia

Profilowanie można prowadzić na dwóch głównych poziomach:

  • Makroprofil – patrzysz na cały proces / usługę: ile czasu spędza w kernelu, ile w funkcjach użytkownika, jaki jest profil CPU dla całej aplikacji.
  • Mikroprofil – skupiasz się na konkretnych funkcjach, modułach, a nawet liniach kodu.

Rozsądna kolejność:

  1. Zacząć od makroprofilu – np. perf record na całym procesie, obserwacja globalnych metryk (top, vmstat, iostat).
  2. Iteracyjne zawężanie obszaru problemu

    Po makroprofilu przychodzi etap zawężania. Celem nie jest „zmierzenie wszystkiego”, tylko wyłapanie tego jednego, dominującego wąskiego gardła, które realnie ogranicza cel biznesowy (czas odpowiedzi, przepustowość, koszty CPU). Zbyt szczegółowa analiza zbyt wcześnie rozprasza i prowadzi do optymalizacji elementów drugorzędnych.

    Praktyczne podejście do zawężania:

  1. Sprawdź, gdzie ginie czas na osi makro: CPU, I/O, blokady, oczekiwanie na zewnętrzne usługi (baza, cache sieciowy, kolejki).
  2. Wybierz jeden aspekt dominujący (np. zużycie CPU lub opóźnienia dyskowe) i skup się na nim w bieżącej iteracji.
  3. Na tym obszarze zejdź poziom niżej: z procesu na wątek, z modułu na konkretny fragment kodu.

Jeśli po pierwszych pomiarach nie potrafisz jednoznacznie wskazać dominującego typu kosztu (CPU vs I/O), to sygnał ostrzegawczy, że scenariusz obciążenia jest zbyt mało reprezentatywny lub pomiary są zbyt krótkie. W takiej sytuacji minimum to wydłużenie testu i dołożenie kilku globalnych metryk systemowych (vmstat, iostat, netstat).

Weryfikacja hipotez zamiast chaotycznych zmian

Profilowanie kończy się poprawnie tylko wtedy, gdy każda zmiana ma z tyłu jasną hipotezę. „Przeróbmy to tak, bo wydaje się wolne” nie jest hipotezą – to hazard. Hipoteza powinna wiązać konkretną obserwację z oczekiwanym efektem liczbowym.

Schemat minimalny:

  • Obserwacja: 60% próbek CPU wypada w funkcji X, w szczególności przy alokacji pamięci i kopiowaniu buforów.
  • Hipoteza: ograniczenie liczby alokacji na request o połowę obniży zużycie CPU o co najmniej 20% i skróci P95 czasu odpowiedzi.
  • Eksperyment: implementacja prostego cache obiektu lub pooling buforów; powtórzenie identycznego scenariusza obciążenia.
  • Weryfikacja: porównanie metryk przed/po; jeśli zysk jest marginalny, hipoteza jest odrzucona, niezależnie od tego, jak „ładnie” wygląda kod.

Jeśli każda zmiana w kodzie jest „na czuja”, a potem szuka się usprawiedliwienia w losowych wykresach, to nie jest profilowanie, tylko tuning według przeczucia. Punkt kontrolny: do każdej większej zmiany powinien istnieć krótki opis typu „jeśli to zrobimy, oczekujemy X% poprawy w metryce Y”.

Laptop z kodem i wykresami wydajności, okulary leżą na klawiaturze
Źródło: Pexels | Autor: Daniil Komov

Pierwszy rzut oka na system – gdzie naprawdę jest problem

Ocena ogólnej kondycji systemu

Zanim zaczniesz wchodzić w szczegóły kodu, trzeba jednoznacznie rozstrzygnąć, czy problem faktycznie leży w aplikacji, czy raczej w otoczeniu (dysk, sieć, baza danych, limity systemowe). Krótki, lecz uporządkowany przegląd kilku narzędzi systemowych potrafi oszczędzić godziny mikroskopowego profilowania.

Minimalny „panel kontrolny” dla analizy systemu:

  • top / htop – użycie CPU per proces / per wątek, load average, stany TASK (R, D, S), zużycie pamięci.
  • vmstat – przełączanie kontekstu, kolejki procesów, page faulty, użycie swapu.
  • iostat – obciążenie urządzeń blokowych, czasy oczekiwania (await), przepustowość I/O.
  • sar (jeśli dostępny) – historyczne użycie CPU, I/O, sieci.

Jeżeli wykresy pokazują stale niskie użycie CPU, a jednocześnie wysokie czasy odpowiedzi, sygnał ostrzegawczy wskazuje na zewnętrzne zależności (I/O, sieć, baza) lub blokady/wait-y w aplikacji. Odwrotna sytuacja – CPU przyklejone do 90–100% i rosnący czas odpowiedzi – najczęściej oznacza, że aplikacja jest CPU-bound i ma sens zejść głębiej z perf.

Identyfikacja typu przeciążenia: CPU-bound vs I/O-bound

Rozróżnienie, czy usługa jest głównie ograniczona przez CPU, czy przez I/O, jest kluczowym punktem kontrolnym przed uruchomieniem cięższych profilerów. Kilka prostych obserwacji pomaga podjąć właściwą decyzję:

  • CPU-bound:
    • wysokie wykorzystanie CPU (user/sys), niskie „idle”;
    • mało procesów w stanie D (uninterruptible sleep);
    • iostat pokazuje umiarkowane I/O, dyski nie są zatkane.
  • I/O-bound:
    • CPU ma wyraźnie dostępny zapas (idle rośnie), a mimo to czas odpowiedzi jest długi;
    • w top sporo procesów/wątków jest w stanie D;
    • iostat/ostat/netstat pokazują ogony w kolejkach, wysokie czasy oczekiwania na I/O lub saturację łącza sieciowego.

Jeśli po kilku minutach obserwacji nadal nie potrafisz zdecydować, do której kategorii należy problem, rozsądne minimum to krótkie użycie perf top (sekcja CPU) oraz strace -f -p PID na jednym z reprezentatywnych wątków – szybko pokaże, czy proces większość czasu spędza na instrukcjach czy na wywołaniach systemowych.

Szybka diagnoza z użyciem top/htop

top i htop są często bagatelizowane jako „zabawki”, tymczasem przy odpowiedniej konfiguracji dają solidny pierwszy obraz problemu. Kilka kroków konfiguracyjnych robi różnicę:

  • Włącz widok wątków (w htop klawisz H), aby zobaczyć, który konkretnie wątek zjada CPU lub czeka.
  • Dodaj kolumny STATE, WAKEUPS, ewentualnie STACK (jeśli wspierane) – dają one szybką informację, czy wątek głównie pracuje, czy śpi.
  • Obserwuj load average w korelacji z liczbą CPU – load znacząco wyższy od liczby rdzeni zazwyczaj oznacza kolejki procesów.

Jeśli w htop widzisz jeden wątek stale przy 100% CPU, a pozostałe niemal bezczynne, to silny sygnał ostrzegawczy: prawdopodobnie główny wątek jest wąskim gardłem (błąd w architekturze wielowątkowej, blokady globalne, single-threaded event loop). Jeśli natomiast wiele wątków równomiernie używa CPU, profilowanie wymaga podejścia per-grupa funkcji lub per-komponent, a nie „szukania jednego winowajcy”.

Analiza I/O i pamięci: vmstat, iostat, free

Nawet przy aplikacji CPU-heavy pierwsze przejście przez vmstat i iostat jest niezbędne. Pozwala wychwycić klasyczne pułapki: swap, stałe page faulty, przeciążone dyski lub nadmierne logowanie na dysk.

Kilka punktów kontrolnych:

  • vmstat:
    • kolumny r (run queue) i b (blocked) – wysokie wartości przy niskim idle są sygnałem problemu z wydolnością CPU lub I/O;
    • kolumny związane z si/so (swap in/out) – każdorazowy, niezerowy ruch swapu przy wysokim obciążeniu to sygnał ostrzegawczy;
    • częste cs (context switches) mogą wskazywać na przesadnie drobną wielowątkowość lub gorącą sekcję krytyczną.
  • iostat:
    • wysokie await i svctm na dyskach to jasna wskazówka, że aplikacja czeka na I/O;
    • wysoki %util blisko 100% przy obciążeniu aplikacji sugeruje wąskie gardło w warstwie storage;
    • duże wahania przepustowości w zależności od obciążenia często łączą się z brakiem kolejkowania I/O po stronie aplikacji.
  • free:
    • niskie „available” i wysoki cache/buffers są normalne, ale połączone ze swap-in/out już nie;
    • jeśli przy stabilnym ruchu pamięć RSS procesu rośnie skokowo, dobrym następnym krokiem nie jest perf, tylko valgrind massif lub profiler pamięci (np. heaptrack).

Jeżeli po przejściu przez to trio widać rosnący swap oraz wysokie czasy I/O, profilowanie CPU powinno poczekać – najpierw trzeba rozwiązać problem pamięciowy lub storage. W przeciwnym razie każda interpretacja profilu CPU będzie zafałszowana przez „ucieczkę” czasu w warstwę I/O.

Profilowanie CPU w Linuxie – perf krok po kroku

Podstawowy przepływ: record → report

Standardowa ścieżka pracy z perf przy profilowaniu CPU składa się z dwóch komend: perf record (zbieranie próbek) oraz perf report (analiza). Każdy eksperyment powinien być tak zorganizowany, aby można było powtórzyć go z identycznymi parametrami i warunkami.

Minimalny przykład dla procesu uruchamianego lokalnie:

perf record -F 99 -g -- ./twoja_aplikacja --parametry-testowe
perf report

Kluczowe elementy:

  • -F 99 – częstotliwość próbkowania (ok. 99 próbek na sekundę); to rozsądny kompromis między dokładnością a narzutem.
  • -g – zbieranie pełnych stosów wywołań, nie tylko funkcji „wierzchniej”.
  • – separator między argumentami perf a komendą; bez niego parametry mogą zostać błędnie zinterpretowane.

Jeżeli aplikacja już działa, można profilować istniejący proces:

perf record -F 99 -g -p <PID> -- sleep 60
perf report

Zwróć uwagę na sleep 60 – perf będzie profilował przez 60 sekund, po czym zakończy zbieranie. Jeśli trzeba uchwycić chwilowy pik (np. okresowy „spike” CPU), warto mieć wcześniej przygotowaną komendę i odpalić ją ręcznie w momencie problemu.

Jeśli pierwszy perf report pokazuje głównie symbole z jądra lub „unknown”, to sygnał ostrzegawczy: brakuje symboli debug dla aplikacji lub bibliotek, albo sampling wpadł w miejsca, które nie odpowiadają Twojemu kodowi. Minimum w takim przypadku to upewnienie się, że binaria nie są zstripowane i zostały skompilowane z -g.

Wybór zdarzeń: nie tylko cycles

Domyślnym zdarzeniem w perf record są zwykle cykle CPU (cpu-cycles). To dobra baza, ale nie zawsze wystarczająca, zwłaszcza gdy problemem są cache missy, branch misprediction albo TLB. Dobrą praktyką jest świadome decydowanie, jakie zdarzenia mierzyć w konkretnej iteracji.

Przykładowe konfiguracje:

  • Profil CPU ogólnie:
    perf record -F 99 -g -p <PID>
  • Cache missy L1/LLC:
    perf record -e cache-misses,cache-references -g -p <PID>
  • Branch misprediction:
    perf record -e branch-misses,branches -g -p <PID>
  • Stall-e frontendu/backendu (jeśli wspierane przez CPU):
    perf record -e stall_frontend,stall_backend -g -p <PID>

Jeśli profil CPU pokazuje wysokie zużycie, ale brak jest jednego dominującego „hot spotu”, zmierz osobno cache-misses i branch-misses. Wysoki udział tych zdarzeń to sugestia, że problem leży w układzie danych (np. słaby locality) lub nadmiernym rozgałęzieniu logiki, a nie w pojedynczej kosztownej funkcji.

Analiza wyników w perf report

perf report to interaktywny interfejs do analizy profilu. Kluczowe jest zrozumienie, co oznacza procent w kolumnie „Overhead” i jak interpretować stosy wywołań.

Punkty kontrolne przy analizie:

  • Domyślnie Overhead oznacza udział danej funkcji (lub symbolu) w łącznej liczbie próbek. 30% overheadu w funkcji X przy dobrze zebranym profilu to jasny sygnał, że co najmniej tam trzeba zajrzeć.
  • Włącz widok call graph (często domyślnie aktywny przy -g) i przełączaj się między „caller” a „callee” – pozwala to zrozumieć, z jakich ścieżek wywołań pochodzi obciążenie.
  • Filtruj po konkretnych bibliotekach lub modułach, jeśli aplikacja jest duża – umożliwia to skupienie się na kodzie własnym zamiast na całym systemie.

Precyzyjne zawężanie zakresu profilowania

Profilując duże systemy łatwo zgubić się w hałasie. Zanim włączysz intensywny sampling na całej maszynie, lepiej zbudować wąski, kontrolowany wycinek. Celem jest odseparowanie kodu własnego od „szumu tła” – bibliotek, runtime’u, jądra, innych procesów.

Zanim zdecydujesz o zakresie, przejdź przez krótką checklistę:

  • czy problem jest per-proces (jeden demon), per-usługa (kilka procesów jednej aplikacji), czy systemowy (konflikt wielu usług);
  • czy masz możliwość odtworzenia problemu w środowisku izolowanym (np. osobny host, kontener, dedykowane VM);
  • czy masz już PID-y procesów krytycznych i wiesz, które wątki są gorące (z htop/ps);
  • czy kod jest podzielony na kilka binariów/serwisów, które można profilować oddzielnie.

Jeżeli choć na jedno z pytań odpowiedź brzmi „nie”, minimum to ograniczenie profilowania do konkretnego PID lub cgroup zamiast całego systemu:

perf record -F 99 -g -p <PID> -- sleep 30

Jeżeli system działa w kontenerach, dokładne przypisanie obciążenia do usług jest trudniejsze. Dobrym punktem kontrolnym jest użycie cgroup:

# przykład dla cgroup v2
perf record -F 99 -g --cgroup my_service.slice -- sleep 30

Jeśli pierwsze profile są zbyt „rozmyte” (brak dominujących funkcji, profile zbyt ogólne), zawęź eksperyment do jednego endpointu, jednego joba batchowego lub jednej ścieżki biznesowej. Dopiero gdy profil na małym, kontrolowanym scenariuszu jest czysty, można przejść do ruchu produkcyjnego.

Typowe pułapki przy użyciu perf

Niewielkie różnice w parametrach perf potrafią wywrócić interpretację do góry nogami. Kilka pułapek powtarza się w audytach niemal zawsze.

  • Brak debug symbols:
    • jeśli w perf report dominują wpisy typu [unknown], [kernel] lub sygnatury bez nazw funkcji, to sygnał ostrzegawczy: binaria są zstripowane albo nie zainstalowano pakietów -dbg/-debuginfo;
    • minimum to rekompilacja z -g -fno-omit-frame-pointer i upewnienie się, że debug symbols nie zostały usunięte w pipeline CI.
  • Frame pointery wyłączone:
    • agresywna optymalizacja (np. -fomit-frame-pointer) utrudnia odtwarzanie stosu wywołań;
    • jeśli call graph jest „poszarpany” albo brakuje kluczowych ramek, jednym z pierwszych punktów kontrolnych jest konfiguracja kompilatora.
  • Profilowanie w środowisku JIT (JVM, .NET, Go):
    • domyślnie perf nie widzi symboli wygenerowanych przez JIT, w wynikach pojawiają się tajemnicze adresy;
    • dla JVM konieczne jest np. włączenie perf-map-agent lub użycie perf w połączeniu z narzędziami językowymi; bez tego profil jest nieprzydatny diagnostycznie.
  • Sampling bias:
    • zbyt niska częstotliwość (-F) powoduje, że rzadkie, ale kosztowne fragmenty kodu znikają w szumie;
    • z kolei zbyt wysoka częstotliwość obciąża system i potrafi zmienić charakterystykę obciążenia – zwłaszcza w aplikacjach o krótkotrwałych wątkach.

Jeśli profil wygląda „dziwnie” – brak nazw funkcji, call graph nie ma sensu, większość próbek ląduje w [kernel] – zanim ruszysz optymalizować kod, przerwij i wróć do konfiguracji kompilacji oraz debug symbols. Inaczej każdy wniosek jest obarczony błędem metodologicznym.

Łączenie perf z innymi źródłami danych

Sam profil CPU mówi, gdzie CPU spędza czas, ale nie zawsze wyjaśnia dlaczego. Najmocniejszy efekt daje korelacja z metrykami zewnętrznymi i logami aplikacyjnymi. Audyt, który ignoruje te źródła, ma ograniczoną wartość.

Kilka sposobów, jak podejść do korelacji:

  • Znaczniki czasu:
    • zadbaj o spójność czasu (NTP, brak dużych skoków zegara) – tylko wtedy porównanie profili z metrykami (Prometheus, Graphite, InfluxDB) ma sens;
    • uruchamiaj perf dokładnie w oknie, w którym w dashboardach widać degradację.
  • Identyfikacja scenariusza:
    • przed startem profilowania wymuś jeden konkretny scenariusz – np. ruch tylko na jednym endpointcie, wyraźnie oznaczony w logach;
    • następnie, w perf report, filtruj pod kątem wątków lub procesów obsługujących ten ruch (np. po nazwie wątku w JVM, PID procesu worker).
  • Logi z poziomu aplikacji:
    • połącz ID żądania (trace id, correlation id) z informacją o wątku; pozwala to później powiązać gorące stosy z konkretnymi ścieżkami biznesowymi;
    • jeśli w logach widać timeouty tylko dla wybranych żądań, profiluj proces/worker obsługujący ten typ ruchu, zamiast całego poola.

Jeżeli profil CPU nie pokrywa się z obserwacjami z metryk (np. wysokie CPU, ale brak spadku throughputu albo odwrotnie), to sygnał ostrzegawczy, że patrzysz na niewłaściwy fragment systemu lub zły horyzont czasowy. W takiej sytuacji lepiej ponownie skalibrować okno pomiarowe niż „na siłę” interpretować rozbieżne dane.

Przypadki szczególne: aplikacje wielowątkowe i blokady

W aplikacjach intensywnie wielowątkowych główny problem rzadko leży w pojedynczej funkcji CPU-bound. Częściej jest to blokada, sekcja krytyczna, kolejka lub scheduler w aplikacji. perf jest w stanie je pokazać, ale wymaga to zmiany nastawy analizy.

Kroki diagnostyczne:

  • Identyfikacja wątków gorących i uśpionych:
    • z htop lub ps -L -p <PID> -o pid,tid,psr,pcpu,state,comm wylistuj wszystkie wątki, sprawdź, które stale mają wysokie %CPU, a które blokują się w stanie D lub S;
    • w kolejnej iteracji perf record zawęź profilowanie do wybranych TID-ów (wątków): -t <TID1,TID2,...>.
  • Analiza blokad w call graph:
    • szukaj wzorców typu pthread_mutex_lock, futex, funkcji z std::mutex, czy internals frameworka (np. locki ORM, connection pool);
    • jeśli większość czasu spędza się w futexach lub prymitywach synchronizacji, problem nie jest stricte CPU-bound, a raczej w projektowaniu współbieżności.
  • Lock contention w jądrze:
    • jeżeli call graph pokazuje gorące miejsca w schedule(), __futex_wait(), funkcjach lockujących w jądrze, sprawdź, czy nie ma zbyt dużej liczby wątków „walczących” o jeden zasób;
    • w takim scenariuszu prostą próbą jest ograniczenie liczby wątków workerów i porównanie profili przed/po – poprawa zwykle oznacza, że system jest over-threaded.

Jeśli profil pokazuje jednoznaczną dominację funkcji synchronizacyjnych, nie ma sensu dalej szukać drobnych optymalizacji w pętlach CPU. Priorytetem staje się przeprojektowanie sekcji krytycznych lub zmiana modelu współbieżności (np. z wielu blokujących wątków na async/event loop).

Flamegraphy i wizualizacja wąskich gardeł

Od profilu tekstowego do flamegraphu

Interaktywny perf report jest użyteczny, ale ma ograniczenia przy dużych stosach i skomplikowanych zależnościach. Flamegraphy pozwalają szybciej zobaczyć hierarchię wywołań oraz rozkład czasu na całym stosie, a nie tylko w jednej ramce.

Podstawowy przepływ generowania flamegraphu dla perf wygląda następująco:

# 1. Zbierz próbki z pełnym stosem
perf record -F 99 -g -p <PID> -- sleep 60

# 2. Wyeksportuj stosy do formatu "perf script"
perf script > out.perf

# 3. Przekształć do folded stacks i wygeneruj SVG (w katalogu z narzędziami FlameGraph)
./stackcollapse-perf.pl out.perf > out.folded
./flamegraph.pl out.folded > flamegraph.svg

Punkty kontrolne:

  • upewnij się, że w perf record używasz -g; bez tego stosy będą płaskie i flamegraph straci sens;
  • sprawdź, czy masz aktualną wersję skryptów FlameGraph (repozytorium Brendana Gregga); stare wersje mogą mieć problemy z nowszymi formatami perf;
  • jeśli plik SVG jest ogromny i trudny do przeglądania, zmniejsz okno pomiarowe lub zawęź profil do jednego procesu.

Jeżeli generowany flamegraph jest prawie cały w kolorach reprezentujących jądro ([kernel]), najpierw usuń ten szum, używając filtra po nazwie binarium lub symbolach. Flamegraphy nabierają sensu dopiero po oddzieleniu kodu własnego od reszty ekosystemu.

Jak czytać flamegraph – kryteria interpretacji

Flamegraph wymaga innego sposobu patrzenia niż klasyczne tabelki z perf report. Szerokość prostokąta odpowiada udziałowi w próbkach, pionowa wysokość – głębokości stosu, a kolory są najczęściej losowe (nie kodują czasu).

Przy interpretacji warto przejść przez kilka stałych punktów kontrolnych:

  • Dominujące „płaskowyże”:
    • szukaj szerokich bloków u góry – to miejsca, gdzie faktycznie mierzony jest czas (leaf functions);
    • jeśli jedna funkcja na szczycie stosu zajmuje dużą szerokość, to oczywisty kandydat do optymalizacji.
  • Powtarzające się ścieżki:
    • korzenie (dolne warstwy) pokazują, skąd startują ścieżki; powtarzające się kombinacje sugerują powielane wzorce (np. ten sam middleware, ten sam ORM);
    • gdy różne wierzchołki (różne endpointy) zbiegają się w jeden wspólny „pas” biblioteki, to właśnie ten komponent jest realnym wąskim gardłem.
  • Rozproszony profil bez wyraźnych hotspotów:
    • jeżeli nie ma ani jednego wyraźnie szerokiego prostokąta, a stos jest „rozsmarowany”, system może być bottleneckiem na pamięci lub I/O, a nie na CPU;
    • to też sygnał, że optymalizacje lokalne (jedna funkcja) dadzą marginalny efekt i trzeba spojrzeć na algorytm lub architekturę.
  • Funkcje infrastrukturalne:
    • zidentyfikuj bloki reprezentujące logging, serializację, deserializację, alokację pamięci; ich nadmierna szerokość to typowy sygnał ostrzegawczy;
    • czas poświęcony na malloc/new, GC, rejestrowanie metryk czy parsing JSON bywa porównywalny z właściwą logiką biznesową.

Jeśli flamegraph pokazuje, że duża część czasu przepala się na operacjach ubocznych (logowanie, formatowanie, konwersje), działania optymalizacyjne należy uporządkować: najpierw ograniczenie/odchudzenie tych warstw, dopiero potem mikrooptymalizacje rdzenia algorytmu.

Filtry i segmentacja flamegraphów

Na dużych systemach flamegraph bez filtrów przypomina rozmazaną mapę cieplną – teoretycznie bogaty w informacje, praktycznie nieczytelny. Kluczem jest segmentacja: generowanie kilku węższych flamegraphów, a nie jednego „wszystkomającego”.

Przy generowaniu flamegraphów rozważ następujące filtry:

  • Po procesie lub binarium:
    • używaj -p <PID> lub --pid w perf, a nie globalnego perf record na całym systemie;
    • alternatywnie, przy perf script, filtruj po nazwie binarium: grep 'twoja_aplikacja' out.perf > app.perf.

    Najczęściej zadawane pytania (FAQ)

    Po czym poznać, że czas zacząć profilowanie aplikacji w Linuxie?

    Najczęstsze sygnały ostrzegawcze to rosnące czasy odpowiedzi przy zbliżonym ruchu, nagłe skoki użycia CPU oraz sytuacje, w których load average długotrwale przekracza liczbę rdzeni, mimo że nie ma intensywnego I/O. Dodatkowo alarmujące są częste wywołania OOM-killera, „duszenie” kontenerów przez limity CPU/memory oraz procesy wiszące w stanie D z przepełnionymi kolejkami dyskowymi.

    Jeśli te objawy powtarzają się pod obciążeniem, to punkt kontrolny: zamiast kręcić „gałkami” w JVM, PHP-FPM czy bazie, uruchom profilowanie CPU/pamięci lub I/O i zbierz twarde dane. Jeżeli problem znika po dokładnym restarcie całego środowiska, ale wraca po kilku dniach, to dodatkowy sygnał, że bez profilera tylko przesuwasz objaw w czasie.

    Czym różni się profilowanie od „strzelania na ślepo” w optymalizacji?

    Strzelanie na ślepo polega na wprowadzaniu zmian na podstawie przeczucia: „wydaje mi się, że winna jest baza”, „to pewnie GC”. Profilowanie opiera się na pomiarze – pokazuje dokładnie, ile czasu CPU spędza w konkretnych funkcjach, jak wygląda profil I/O i czy system faktycznie czeka na dysk, czy spala cykle w parsowaniu JSON-a. Różnica jest taka, jak między naprawą auta „na słuch” a diagnostyką komputerową.

    Jeśli Twoje działania optymalizacyjne nie są poprzedzone pomiarem, zakładasz pełne ryzyko: możesz poprawić element marginalny, wprowadzić regresję i nadal nie trafić w prawdziwe wąskie gardło. Jeżeli po każdej zmianie nie wracasz do pomiaru w tych samych warunkach, to sygnał ostrzegawczy, że proces nie jest kontrolowany.

    Jaki jest poprawny cykl profilowania i optymalizacji aplikacji?

    Minimum to powtarzalna pętla: hipoteza → pomiar → zmiana → weryfikacja. Najpierw formułujesz hipotezę, np. „przy rosnącej liczbie użytkowników CPU rośnie do 100%, bo algorytm wyszukiwania ma złożoność O(n²)”. Potem zbierasz profil pod kontrolowanym obciążeniem, analizujesz, gdzie faktycznie ginie czas CPU / I/O / pamięci. Na tej podstawie wprowadzasz jedną, wyraźną zmianę w kodzie lub konfiguracji.

    Na końcu wracasz do pomiaru w możliwie takich samych warunkach i porównujesz wyniki. Jeśli robisz wiele zmian „na wszelki wypadek” i nie mierzysz skutku każdej z nich, tracisz możliwość przypisania efektu do przyczyny. Jeżeli po tygodniu nikt nie potrafi powiedzieć, co faktycznie pomogło, to jasny sygnał, że cykl profilowania został przerwany w połowie.

    Jakie narzędzia do profilowania aplikacji w Linuxie są absolutnym minimum?

    Do sensownego audytu wydajności przydaje się kilka warstw narzędzi. Absolutne minimum to: time (czas wykonania procesu), top/htop (szybki podgląd CPU, pamięci, stanów procesów), vmstat, iostat, free, sar (kondycja CPU, RAM, I/O, swap). Na poziomie szczegółowym niezbędne są: perf do profilowania CPU i zdarzeń sprzętowych, gprof dla binariów zbudowanych z -pg, valgrind (np. massif) do pamięci oraz strace/ltrace do analizy wywołań systemowych i bibliotecznych.

    Jeśli w środowisku produkcyjnym część tych narzędzi jest zablokowana przez polityki bezpieczeństwa, to punkt kontrolny: trzeba zapewnić osobne środowisko testowe z pełnym zestawem profilerów. Jeżeli dostęp do /proc lub liczników sprzętowych jest przycięty, nie uzyskasz wiarygodnych profili, niezależnie od umiejętności analizy.

    Jakie ustawienia jądra i uprawnienia są potrzebne do użycia perf w Linuxie?

    Kluczowym parametrem jest /proc/sys/kernel/perf_event_paranoid, który określa, jak ograniczone jest profilowanie zdarzeń. Typowe wartości: 0 lub 1 pozwalają na profilowanie na poziomie jądra i użytkownika dla zwykłych użytkowników, natomiast 2 i więcej znacząco ogranicza możliwości i często wymaga uprawnień roota. Równolegle trzeba mieć działający i nieokaleczony /proc oraz zainstalowane nagłówki jądra, szczególnie jeśli korzystasz z narzędzi eBPF.

    Jeżeli perf odmawia działania, zgłasza brak dostępu do liczników lub widzisz wyłącznie szczątkowe dane, punkt kontrolny jest jasny: sprawdź wartość perf_event_paranoid, politykę SELinux/AppArmor oraz ograniczenia nałożone przez warstwę kontenerową. Bez tego każde „profilowanie” będzie jedynie przybliżeniem, a wnioski mogą być błędne.

    Dlaczego do profilowania trzeba kompilować aplikację z symbolami debug?

    Bez symboli debug profiler widzi głównie adresy i nazwy wewnętrznych funkcji bibliotecznych, a nie Twoje funkcje biznesowe. Analiza staje się wtedy żmudna: zamiast „parse_json() zużywa 63% CPU” widzisz np. „[unknown]” lub fragmenty __libc_start_main. Minimalnym standardem dla wersji profilowanej jest kompilacja z -g (symbole debug) oraz -O2 (optymalizacje zbliżone do produkcyjnych).

    Budowanie pod profilowanie z -O0 mocno zniekształca obraz – kompilator nie robi inline, inaczej rozkłada kod i rejestry, więc wąskie gardła pojawiają się tam, gdzie w realnej, zoptymalizowanej wersji ich nie będzie. Jeśli profil CPU uparcie wskazuje anonimowe ramki lub kod startowy biblioteki C, to sygnał ostrzegawczy, że binaria są zstripowane lub pozbawione symboli debug.

    Czy lepiej profilować na produkcji, czy w osobnym środowisku testowym?

    Profilowanie na produkcji ma sens, gdy potrzebujesz realnego wzorca obciążenia i zachowań użytkowników, ale wymaga narzędzi o niskim narzucie i rygorystycznego podejścia do bezpieczeństwa. W wielu organizacjach pełny perf czy valgrind są tam zakazane. Z kolei środowisko testowe pozwala na agresywne profilowanie (np. z wysoką częstotliwością próbkowania, valgrind, szczegółowe trace’y), lecz musi możliwie wiernie odwzorowywać produkcję: wersje kernela, limity CPU/memory, konfigurację kontenerów i typowy ruch.

    Najważniejsze punkty

    • Profilowanie zastępuje „strzelanie na ślepo” twardymi danymi: zamiast zgadywać, czy wąskim gardłem jest baza, konfiguracja czy kod, mierzysz dokładnie, gdzie znika czas CPU i jakie zasoby są realnie zużywane. Jeśli nie masz profilu, każda optymalizacja jest domysłem, nie decyzją opartą na faktach.
    • Profilowanie to powtarzalna pętla hipoteza → pomiar → zmiana → weryfikacja, a nie jednorazowe odpalenie perf czy valgrinda. Jeżeli wprowadzasz kilka zmian „na wszelki wypadek” i nie mierzysz efektu w tych samych warunkach, tracisz możliwość przypisania przyczyny do skutku i ryzykujesz ukryte regresje.
    • Moment rozpoczęcia profilowania wyznaczają konkretne sygnały ostrzegawcze: stały wzrost czasu odpowiedzi przy podobnym ruchu, load average wyższy niż liczba rdzeni, throttling CPU w kontenerach, skoki pamięci z OOM-killerem czy procesy wiszące w stanie D. Jeśli którykolwiek z tych symptomów powtarza się pod obciążeniem, punktem kontrolnym jest profil CPU/pamięci/I/O, a nie kolejne „dokręcanie” parametrów środowiska.
    • Brak profilowania prowadzi do typowych patologii: przedwczesnej optymalizacji elementów o marginalnym wpływie, rozpraszania zespołu na wiele „przeczuć”, gaszenia problemów przez dokładanie sprzętu oraz braku kontroli regresji wydajności w pipeline. Jeśli nie mierzysz, gdzie aplikacja spędza czas, będziesz przepłacać zarówno w kodzie, jak i w infrastrukturze.