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.
| Cecha | Lista | Generator |
|---|---|---|
| Zużycie pamięci | O(n) — całość w pamięci | O(1) — jeden element na raz |
| Dostęp | Losowy (indeksowanie) | Sekwencyjny (iteracja) |
| Wielokrotna iteracja | Tak, bez limitu | Nie, generator się wyczerpuje |
| len() | Dostępne | Niedostę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ędzie | Zadanie | Kiedy używać |
|---|---|---|
| Black | Auto-formatowanie kodu | Przed każdym commit, w CI |
| Ruff | Szybki linter (zastępuje flake8, isort) | W pre-commit hooks, CI |
| mypy | Sprawdzanie typow statycznie | Gdy kod używa type hints |
| cProfile | Profilowanie funkcji | Pierwszy krok optymalizacji |
| line_profiler | Profilowanie linia-po-linii | Do 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_profilerna 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ć.


