Optymalizacja kodu w Pythonie — profilowanie, pamięć i wydajność

Zanim zaczniesz optymalizować: zmierz, nie zgaduj

Optymalizacja kodu w Pythonie bez profilowania to strzelanie w ciemno — intuicja często wskazuje złe miejsca. Zanim dotkniesz refaktoringu, uruchom cProfile i zobacz, które funkcje naprawdę dominują czas wykonania.

# Polecenia w terminalu:
python -m cProfile -o output.prof moj_skrypt.py
python -c "import pstats; pstats.Stats('output.prof').sort_stats('cumulative').print_stats(10)"

cProfile to wbudowany profiler funkcji w Pythonie, który mierzy statystyki wywołań — czas wykonania każdej funkcji i liczbę wywołań. Daje ogólny przegląd, ale nie pokaże, które linie wewnątrz funkcji są wolne.

Kiedy cProfile, a kiedy line_profiler?

cProfile pokaże czas spędzony w funkcjach i ich wywołaniach. Zacznij od tych, które mają największy udział w czasie całego programu. Gdy potrzebujesz wskazać kosztowne linie wewnątrz wybranej funkcji, użyj line_profiler.

# Instalacja line_profiler:
# pip install line_profiler

# Dekorowanie funkcji do profilowania linia-po-linii:
@profile
def process_data(items):
    result = []
    for item in items:
        if item % 2 == 0:
            result.append(item * 2)
    return result

if __name__ == "__main__":
    process_data(range(100_000))

# Uruchomienie z kernprof:
# kernprof -l -v moj_skrypt.py

Workflow jest prosty: cProfile daje ogólny obraz, line_profiler pokazuje szczegóły wewnątrz wolnych funkcji, a potem optymalizujesz tylko to, co naprawdę boli.

To zastosowanie tej samej zasady co debugowanie oparte na hipotezach: najpierw ustal, gdzie znika czas, potem zmieniaj kod.

Generatory i itertools — pamięciowa wydajność w praktyce

Lista przechowuje wszystkie wyniki naraz. Generator może produkować je po jednym, więc w prostym przetwarzaniu strumieniowym nie potrzebujesz pamięci na całą kolekcję. Oszczędność zależy jednak od tego, co generator przechowuje wewnątrz i czy odbiorca nie zbiera wyników z powrotem do listy.

CechaListaGenerator
Zużycie pamięciO(n) — całość w pamięciZależne od stanu generatora; O(1) w pokazanym przykładzie
DostępLosowy (indeksowanie)Sekwencyjny (iteracja)
Wielokrotna iteracjaTak, bez limituNie, generator się wyczerpuje
len()DostępneNiedostępne
# Lista — przechowuje wszystkie wyniki:
def process_with_list(n):
    result = []
    for i in range(n):
        result.append(i * 2)
    return result

# Generator — oblicza kolejne wyniki na żądanie:
def process_with_generator(n):
    for i in range(n):
        yield i * 2

# Użycie:
for value in process_with_generator(10_000_000):
    process(value)  # Przetwarzanie strumieniowe

Wyrażenia generatorowe to składnia list comprehensions z nawiasami okrągymi zamiast kwadratowych. Są leniwe — nie budują listy w pamięci. Używaj ich, gdy przetwarzasz dane raz, od góry do dołu, i pamięć ma znaczenie.

W poniższych fragmentach process(), data i iteratory wejściowe oznaczają elementy Twojego programu. Samo utworzenie generatora nie wykonuje jego pracy — pomiar musi obejmować również pobieranie wyników.

# List comprehension — buduje listę:
squares_list = [x**2 for x in range(1000000)]

# Generator expression — zużywa O(1) pamięci:
squares_gen = (x**2 for x in range(1000000))

# itertools — wszystkie funkcje zwracają iteratory, nigdy listy:
from itertools import islice, chain, count

# Nieskończony iterator, bezpieczny bo islice odcina:
for x in islice(count(), 100):
    process(x)

# Łączenie iteratorów bez budowania listy:
all_data = chain(iterator1, iterator2, iterator3)

itertools dostarcza narzędzia do składania iteratorów. Nie wszystkie mają stałe zużycie pamięci ani obsługują nieskończone wejście: product() najpierw zbiera pule danych, a tee() buforuje elementy, gdy odbiorcy przesuwają się w różnym tempie. islice(count(), 100) jest skończone, bo jawnie ograniczasz liczbę pobrań.

Typowe pułapki wydajnościowe

Konkatenacja stringów w pętli

Wielokrotne łączenie niemutowalnych stringów może wielokrotnie kopiować rosnący wynik i prowadzić do kosztu kwadratowego. CPython potrafi optymalizować niektóre przypadki +=, więc nie traktuj tej złożoności jako gwarancji każdego uruchomienia. Gdy składasz wiele fragmentów, "".join() jasno wyraża zamiar; koszt zmierz na swoich danych.

# Powtarzana konkatenacja — może kopiować rosnący wynik:
def concat_bad(items):
    result = ""
    for item in items:
        result += str(item)
    return result

# Łączenie przygotowanych fragmentów:
def concat_good(items):
    return "".join(str(item) for item in items)

List comprehension vs map/filter

W Pythonie 3 map() i filter() zwracają iteratory. Dla prostego warunku i przekształcenia list comprehension bywa łatwiejsze do przeczytania niż zagnieżdżone lambdy. Wybierz czytelny zapis, a różnicę wydajności sprawdź pomiarem.

# List comprehension — czytelne i szybkie:
even_doubled = [x*2 for x in data if x % 2 == 0]

# Unikać — lambda w map/filter jest mniej czytelne:
even_doubled = list(map(lambda x: x*2, filter(lambda x: x % 2 == 0, data)))

# Dopuszczalne — gdy funkcja już istnieje:
even_doubled = list(map(double, filter(is_even, data)))

Narzędzia poza profilingiem: jakość kodu

Optymalny kod to nie tylko szybki kod — to też kod czytelny, utrzymany i wolny od błędów typów. PEP 8 to oficjalny przewodnik po stylu Pythona. Black automatycznie formatuje kod bez dyskusji. Ruff to szybki linter zastępujący wolniejsze narzędzia. mypy statycznie sprawdza typy.

NarzędzieZadanieKiedy używać
BlackAuto-formatowanie koduPrzed każdym commitem, w CI
RuffSzybki linter (zastępuje flake8, isort)W pre-commit hooks, CI
mypySprawdzanie typów statycznieGdy kod używa type hints
cProfileProfilowanie funkcjiPierwszy krok optymalizacji
line_profilerProfilowanie linia-po-liniiDo mikrooptymalizacji
# Type hints + mypy:
from typing import List, Iterator

def process_items(items: List[int]) -> Iterator[int]:
    for item in items:
        if item % 2 == 0:
            yield item * 2

# Uruchomienie mypy:
# mypy moj_modul.py

Kiedy (i kiedy nie) optymalizować

Zasada jest prosta: najpierw spraw, żeby kod działał, potem spraw, żeby był czytelny, a dopiero potem spraw, żeby był szybki — ale tylko tam, gdzie profiling pokaże problem. Optymalizacja bez danych to strata czasu.

Patrz na łączny koszt, liczbę wywołań i cel pomiaru. Funkcja zajmująca większość czasu całego zadania zwykle daje większe pole do poprawy niż rzadko używany fragment. Dla usługi liczą się również opóźnienia pojedynczych żądań — sama średnia może ukrywać problem.

Alternatywy dla cProfile i line_profiler

py-spy to profiler próbkujący, który potrafi obserwować działający proces Pythona. Potrzebuje odpowiednich uprawnień, a jego narzut też warto ocenić. Do pamięci przyda się wbudowany tracemalloc; memory_profiler umożliwia pomiary linia po linii, lecz jego repozytorium informuje o braku aktywnego utrzymania.

Pomiar linia po linii przez memory_profiler wymaga oznaczenia wybranych funkcji dekoratorem @profile i faktycznego wywołania ich podczas uruchomienia. Pomiar profilera służy lokalizacji kosztu; porównanie czasu przed zmianą i po niej wykonaj też bez profilera.

# py-spy — profilowanie procesu w produkcji:
py-spy top --pid 12345

# memory_profiler — pamięć linia-po-linii:
# pip install memory_profiler
# Uruchomienie: python -m memory_profiler moj_skrypt.py

Jak sprawdzić, czy zmiana pomogła

Znajdź funkcje dominujące w pomiarze, w razie potrzeby sprawdź kosztowne linie i oceń dobór struktury danych. Jeśli problem dotyczy pamięci, sprawdź alokacje osobno, np. przez tracemalloc.

Po zmianie powtórz pomiar na tych samych danych i sprawdź wynik programu. Nie musisz używać wszystkich profilerów po kolei — dobierz narzędzie do pytania, na które chcesz odpowiedzieć.

Gdzie iść dalej

Jeśli po zwolnieniu obiektów RSS nie spada, zajrzyj do mechaniki malloc i free() — pamiętaj przy tym, że Python ma też własne mechanizmy zarządzania pamięcią. Gdy podejrzewasz oczekiwanie na system, strace pomoże obejrzeć wywołania systemowe. Sam pomiar cProfile nie daje jednak pełnego śladu syscalli.

Weryfikacja techniczna: profilery Pythona, itertools, memory_profiler — status projektu.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry