Optymalny kod w Pythonie — od profilowania przez refaktoring po pamięciową 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.

# Uruchomienie cProfile z linii komend:
python -m cProfile -o output.prof moj_skrypt.py

# Analiza wynirow w Pythonie:
import pstats
stats = pstats.Stats('output.prof')
stats.sort_stats('cumulative').print_stats(10)

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

Kiedy cProfile, a kiedy line_profiler?

cProfile użyj jako pierwszy krok — identyfikuje 3-5 najwolniejszych funkcji. Następnie line_profiler analizuje te funkcje linia po linii, pokazując dokładnie, gdzie leży wąskie gardlo.

# 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

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

Workflow jest prosty: cProfile daje ogolny obraz, line_profiler pokazuje szczegoly wewnątrz wolnych funkcji, a potem optymalizujesz tylko to, co naprawde boli.

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

Lista 10 milionow rekordow zużywa setki megabajtow; generator produkujący te same dane zużywa kilobajty. Różnica polega na tym, że generatory nie budują całej struktury w pamięci — produkują wartości na żądanie.

CechaListaGenerator
Zużycie pamięciO(n) — całość w pamięciO(1) — jeden element na raz
DostępLosowy (indeksowanie)Sekwencyjny (iteracja)
Wielokrotna iteracjaTak, bez limituNie, generator się wyczerpuje
len()DostępneNiedostępne
# ZLA wersja — buduje cala liste w pamieci:
def process_with_list(n):
    result = []
    for i in range(n):
        result.append(i * 2)
    return result

# DOBRA wersja — generator zużywający staą pamięć:
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 gory do dou, i pamięć ma znaczenie.

# 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, filterfalse

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

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

Modu itertools to zestaw wysokowydajnych, pamięciooszczednych blokow do budowy iteratorow. Wszystkie funkcje zwracają iteratory, więc można je bezpiecznie komponować nawet z nieskończonymi wejściami.

Typowe puapki wydajnościowe

Konkatenacja stringow w ptli

Operator += na stringach w ptli tworzy nowy obiekt stringa przy każdej iteracji — koszt O(n2). Zamiast tego użyj "".join(), ktory alokuje pamięć raz.

# ŹLE — O(n2) alokacji:
def concat_bad(items):
    result = ""
    for item in items:
        result += str(item)
    return result

# DOBRZE — O(n) alokacji:
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, ale list comprehension jest czytelniejsze i często szybsze. Używaj map/filter tylko z istniejącymi funkcjami, nie z lambdami.

# 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łędow typow. PEP 8 to oficjalny guide 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 commit, w CI
RuffSzybki linter (zastępuje flake8, isort)W pre-commit hooks, CI
mypySprawdzanie typow 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.

Jeżeli funkcja jest wywoywana 10 razy i zajmuje 0.1ms, nie ma sensu spędzać 3 godzin na jej przyspieszenie do 0.05ms. Jeżeli inna funkcja jest wywoywana milion razy i zajmuje 50ms, to tam leży realny zysk.

Alternatywy dla cProfile i line_profiler

Do profilowania w produkcji użyj py-spy — to profiler probkujący z niskim narzutem, ktory działa na działających procesach bez restartu. memory_profiler śledzi zużycie pamięci linia-po-linii, podobnie jak line_profiler śledzi czas.

# 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

Podsumowanie — workflow optymalizacji

  • Krok 1: Uruchom cProfile — znajdź 3-5 najwolniejszych funkcji.
  • Krok 2: Użyj line_profiler na tych funkcjach — znajdź wolne linie.
  • Krok 3: Zastanow się nad strukturą — czy lista może być generatorem?
  • Krok 4: Sprawdź zużycie pamięci z memory_profiler.
  • Krok 5: Uruchom ponownie profiling — potwierdź poprawę.

Optymalny kod w Pythonie to kod, ktory najpierw został zmierzony, a dopiero potem dotknięty. Narzędzia są — cProfile, line_profiler, memory_profiler, py-spy — używaj ich w tej kolejności, a przestaniesz zgadywać, a zaczniesz optymalizować tam, gdzie to naprawdę ma znaczenie.

Gdzie iść dalej

Jeżeli chcesz zrozumieć, co dzieje się pod maską Pythona, zacznij od artykuu o malloc internals — pokaże, dlaczego free() nie oddaje pamięci systemowi i jak to wpływa na profilowanie pamięci. Do debugowania wąskich garde przyda się strace — gdy profiling pokaże syscall jako bottleneck, strace pokaże dokładnie, co się dzieje. A jeżeli interesuje Cię optymalizacja I/O, epoll vs io_uring pokaże, kiedy event loop przestaje wystarczać.

Zostaw komentarz

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

Przewijanie do góry