Jak unikać typowych antywzorców wydajności w Pythonie przy przetwarzaniu dużych danych

0
60
1/5 - (2 votes)

Nawigacja:

Kontekst: dlaczego Python „dusi się” na dużych danych

Co naprawdę znaczy „duże dane” w kontekście Pythona

„Duże dane” brzmią efektownie, ale w praktyce zwykle chodzi o coś bardzo przyziemnego: ilość danych zaczyna przekraczać to, co swobodnie mieści się w pamięci RAM i co Python jest w stanie obrobić bez zadyszki. Dla jednego projektu „duże” to kilkadziesiąt milionów rekordów, dla innego już kilkaset tysięcy w nieoptymalnym formacie powoduje problemy.

Przy przetwarzaniu dużych danych w Pythonie liczą się trzy podstawowe zasoby:

  • RAM – ile możesz jednocześnie wczytać i przetworzyć w pamięci.
  • I/O – jak szybko potrafisz czytać i zapisywać z/do dysku lub sieci.
  • Czas CPU – ile instrukcji musisz wykonać na każdy rekord.

Antywzorce wydajności w Pythonie prawie zawsze uderzają w któryś z tych zasobów. Najczęściej zaczyna się od subtelnych oznak: skrypty działają „trochę za długo”, Jupyter Notebook zaczyna lagować, a potem dochodzą twarde symptomy – pełna pamięć, swapowanie, proces zabity przez system.

Koszt dynamicznej natury Pythona

Python jest dynamiczny: każdy obiekt ma metadane, typ, referencję, zarządzanie pamięcią przez garbage collector. To świetne dla produktywności, ale kosztowne przy milionach elementów. Jeden prosty integer w Pythonie to nie tylko liczba – to pełnoprawny obiekt z narzutem pamięci.

Najczęstsze konsekwencje tego faktu przy dużych danych:

  • Przeciążone listy – przechowujesz miliony małych obiektów typu `int`, `str` lub małych krotek; narzut pamięci na strukturę listy i każdy element staje się istotny.
  • Rozproszona pamięć – obiekty przydzielane i zwalniane w różnych miejscach, co zwiększa koszty działania garbage collectora.
  • Dodatkowe poziomy pośrednie – zamiast prostych tablic bajtów są referencje do obiektów, które same trzymają swoje dane.

Dlatego przy przetwarzaniu dużych danych Python z czystymi strukturami wbudowanymi przegrywa z narzędziami wykorzystującymi zwarte tablice (NumPy), kolumnowe formaty (Parquet) czy niskopoziomowe biblioteki C. Antywzorce pojawiają się wtedy, gdy korzysta się z Pythona tak, jakby dane miały zawsze rozmiar „komfortowy”.

Kiedy Python wystarcza, a kiedy sam język staje się wąskim gardłem

Dla większości zastosowań biznesowych i analitycznych Python jest aż nadto wystarczający, o ile nie blokuje się go niepotrzebnymi kopiami, pętlami i nieefektywnymi strukturami danych. Często problem nie leży w samym Pythonie, ale w sposobie korzystania z niego.

Python nadal jest rozsądnym wyborem, gdy:

  • większość ciężkiej pracy robią biblioteki w C (NumPy, Pandas, scikit-learn),
  • dane mieszczą się w RAM przy rozsądnej optymalizacji,
  • procesy są wsadowe i może trwać to minuty, a nie milisekundy.

Natomiast język staje się wąskim gardłem, gdy:

  • większość logiki to zagnieżdżone pętle po Pythonowych listach lub słownikach,
  • próbujesz przetwarzać kilkadziesiąt gigabajtów „na raz” na maszynie z kilkoma gigabajtami RAM,
  • operujesz na seriach zdarzeń w czasie rzeczywistym, a opóźnienia mają znaczenie biznesowe.

Jeśli trzeba zareagować w sekundach, a nie minutach, często bardziej opłaca się wprowadzić strumieniowanie, częściową obróbkę i lepsze struktury danych niż całkowicie zmieniać technologię. Rzadko na starcie trzeba sięgać po Spark czy Rust – ogromny zysk daje usunięcie kilku typowych antywzorców.

Typowe objawy, że coś jest nie tak

Zanim przejdzie się do optymalizacji, dobrze jest nauczyć się rozpoznawać wczesne sygnały, że kod w Pythonie nie radzi sobie z dużymi danymi:

  • Proces „zjada” całą pamięć RAM, a system zaczyna korzystać intensywnie ze swapu (dysk mieli, wentylator działa pełną parą, wszystko spowalnia).
  • „Wieczne” pętle – kod, który działał sekundy na małych próbnych danych, nagle trwa dziesiątki minut przy pełnym zbiorze.
  • Zawieszony notebook – Jupyter przestaje reagować podczas pojedynczej komórki z wczytywaniem lub przetwarzaniem.
  • Częste „MemoryError” albo zabijanie procesu przez system (szczególnie na serwerach współdzielonych lub w Dockerze).

Gdy takie symptomy się pojawiają, instynktowna reakcja „kupmy więcej RAM” rzadko jest najtańszym rozwiązaniem. Zwykle kilka korekt w stylu programowania Pythona daje większy efekt niż koszt sprzętu.

Kiedy optymalizować kod, a kiedy zmienić podejście

Nie każdy problem wydajnościowy da się rozwiązać prostymi poprawkami. Przydają się praktyczne kryteria:

  • Jeśli kod jest napisany „naiwnie” (pętle po wierszach DataFrame, wiele `.copy()`, zbędne konwersje) – zawsze najpierw optymalizacja antywzorców.
  • Jeśli dane są większe niż dostępna pamięć o rząd wielkości – zmień podejście na przetwarzanie strumieniowe lub batchowe, zamiast ładować wszystko na raz.
  • Jeśli po usunięciu typowych antywzorców nadal brakuje mocy – rozważ:
    • wydzielenie gorących fragmentów do Cython / Numba,
    • użycie narzędzi rozproszonych (Spark, Dask) tylko tam, gdzie to potrzebne.

Tanią, szybką poprawkę daje wyeliminowanie oczywistych antywzorców w Pythonie. Zmiana technologii to dopiero kolejny poziom, gdy proste środki zostały już wykorzystane.

Osoba czyta książkę Python for Unix and Linux System Administration
Źródło: Pexels | Autor: Christina Morillo

Skuteczne diagnozowanie: jak namierzyć wąskie gardła zanim zacznie się „tuning na oko”

Zasada: najpierw profilowanie, potem optymalizacja

Zgadywanie, co spowalnia kod, jest kuszące, ale zwykle prowadzi do tracenia czasu na optymalizowanie niewłaściwych miejsc. Przy przetwarzaniu dużych danych antywzorce wydajności są często ukryte w dwóch–trzech funkcjach, które pochłaniają 80–90% czasu lub pamięci. Bez profilera trudno je namierzyć.

Zasada jest prosta:

  • Najpierw mierz – użyj narzędzi do pomiaru czasu i pamięci.
  • Potem modyfikuj – w jednym miejscu, na podstawie wyników.
  • Porównaj – sprawdź realny zysk czasowy/pamięciowy po zmianie.

Ten minimalny workflow zapewnia najlepszy stosunek efektu do wysiłku. Zamiast przerabiać cały projekt, poprawia się konkretny fragment, który faktycznie spowalnia całość.

Podstawowe narzędzia profilowania w Pythonie

Kilka prostych narzędzi wystarczy, żeby uzyskać użyteczny obraz tego, co dzieje się w kodzie:

  • `timeit` – dobre do porównania dwóch krótkich fragmentów kodu lub funkcji w izolacji; np. różnica między list comprehension a pętlą `for`.
  • `cProfile` – profilowanie całego skryptu lub większej funkcji; pokazuje, ile czasu spędza się w poszczególnych funkcjach.
  • `line_profiler` – rozszerzenie, które potrafi zmierzyć czas wykonywania dla każdej linii w wybranych funkcjach; przydatne przy „grubych” funkcjach przetwarzających dane.
  • `memory_profiler` – śledzi wykorzystanie pamięci na poziomie funkcji/wierszy; idealne, gdy skrypt „puchnie” w RAM.

Na start zwykle wystarcza `cProfile` + `memory_profiler`. Z ich pomocą można szybko zobaczyć, czy wąskim gardłem są pętle, I/O czy kopiowanie danych.

Minimalny workflow profilowania wydajności

Praktyczny, tani w utrzymaniu schemat może wyglądać tak:

  1. Utwórz reprezentatywny scenariusz testowy – niekoniecznie na pełnych danych, ale na próbce na tyle dużej, aby były widoczne realne problemy (np. 5–10% produkcyjnego wolumenu).
  2. Owiń główną funkcję przetwarzania w `cProfile`:
    import cProfile
    import pstats
    
    def main():
        # kod przetwarzający dane
        ...
    
    if __name__ == "__main__":
        with cProfile.Profile() as pr:
            main()
        stats = pstats.Stats(pr)
        stats.sort_stats(pstats.SortKey.CUMULATIVE).print_stats(30)
    
  3. Sprawdź TOP 20–30 funkcji w raporcie – wypatruj tych, które zużywają najwięcej czasu łącznego (cumulative time).
  4. Dla 1–2 „podejrzanych” funkcji uruchom `line_profiler` i/lub `memory_profiler`, żeby zobaczyć, które linie są najcięższe.
  5. Wprowadź jedną konkretną zmianę (np. wektoryzacja, usunięcie kopiowania) i zmierz ponownie na tym samym scenariuszu.

Ten schemat jest szybki i pozwala uniknąć bezproduktywnego „tuningowania na oko”. Szczególnie przy przetwarzaniu dużych danych, gdzie każdy zły krok może mnożyć koszty czasowe razy milion rekordów.

Jak czytać raporty profilera i wyciągać wnioski

Najważniejsze elementy raportów:

  • Czas łączny (cumulative) – ile czasu łącznie spędzono w danej funkcji, wraz z funkcjami, które ona wywołuje. Jeśli jedna funkcja ma 70% czasu łącznego, to właśnie ona jest priorytetem.
  • Czas własny (percall / tottime) – czas spędzony tylko w tej funkcji, bez funkcji podrzędnych. Wysoki czas własny na krótkiej funkcji często wskazuje na intensywne operacje np. na listach.
  • Liczba wywołań (ncalls) – funkcja może być szybka, ale wywoływana milion razy, co w sumie daje duży koszt.

Typowe wnioski:

  • Widzisz funkcję wywoływaną kilkaset tysięcy razy w pętli – być może da się ją zbatchować lub przenieść logikę do wektoryzacji.
  • Widzisz linię z `.copy()` lub `list(…)` rosnącą w `memory_profiler` – to kandydat do przetworzenia w miejscu lub do leniwego iteratora.
  • Widzisz wiele czasu w metodach I/O (czytanie plików, zapytania do bazy) – zamiast optymalizować CPU, trzeba ograniczyć liczbę wywołań I/O.

Przykład: prosta funkcja przetwarzania po profilowaniu

Załóżmy, że jest funkcja, która filtruje i transformuje rekordy:

def process(records):
    result = []
    for r in records:
        if is_valid(r):
            result.append(transform(r))
    return result

Profilowanie pokazuje, że:

  • `is_valid` i `transform` są lekkie,
  • za to pętla „mieli” miliony rekordów w Pythonie.

Prosty krok:

  • jeśli `records` to tablica NumPy – przenieść filtr i transformację do wektoryzacji,
  • jeśli to DataFrame – skorzystać z filtrów i wektoryzowanych operacji zamiast `for`.

Często taki ruch skraca czas z minut do sekund, bez zmiany całej architektury systemu.

Antywzorce w pętlach i iteracjach: drobne błędy, duże straty czasu

Pętle w pętlach nad listami vs operacje zbiorcze

Najczęstszy antywzorzec w Pythonie przy dużych danych to zagnieżdżone pętle `for` operujące na zwykłych listach lub słownikach. Działa, jest czytelne, ale przy milionach elementów każdy nadmiarowy krok mnoży się w astronomiczne czasy.

Typowy przykład:

for row in data:
    for item in row:
        total += item

Jeśli `data` to lista list liczb, takie liczenie sumy jest dużo wolniejsze niż zrobienie tego „hurtowo” w NumPy:

import numpy as np
arr = np.array(data)
total = arr.sum()

Różnica staje się kolosalna przy setkach tysięcy wierszy. Wszystko dlatego, że pętle w Pythonie działają w interpretrze, a NumPy korzysta z implementacji wewnętrznej w C, zoptymalizowanej i działającej na zwartym bloku pamięci.

Nadmierne wywoływanie metod wewnątrz pętli

Kolejny antywzorzec to wywoływanie ciężkich operacji w każdej iteracji pętli:

  • zapytania do bazy danych dla każdego rekordu,
  • zapisy na dysk przy każdej iteracji,
  • ciągłe wywołania `append` na liście, które powiększa się dynamicznie.

Przykład:

results = []
for row in big_dataset:
    res = expensive_call(row)
    results.append(res)

Ta konstrukcja jest poprawna, ale gdy `expensive_call` robi I/O lub ciężkie obliczenia, dużo efektywniejsze jest:

  • zbatchowanie danych – przekazanie większego kawałka na raz, jeśli API na to pozwala,
  • Minimalizacja pracy w pętli: przenoszenie obliczeń „na zewnątrz”

    Kod w środku pętli powinien być maksymalnie prosty. Wszystko, co da się policzyć raz przed pętlą albo zbiorczo po niej, nie powinno się wykonywać przy każdym obrocie.

    Typowy błąd:

    discounts = {"A": 0.9, "B": 0.8}
    
    for row in rows:
        # za każdym razem nowe wyrażenie, nowy słownik pośredni itd.
        price = float(row["price"])
        category = row["category"].strip().upper()
        factor = discounts.get(category, 1.0)
        total += price * factor
    

    Prostszy wariant:

  • przygotować dane wejściowe wcześniej (np. oczyścić kategorie, zrzutować typy),
  • część zmian przenieść do wektoryzacji lub osobnej fazy pipeline’u.
# etap 1: normalizacja kategorii i cen (np. przy wczytywaniu)
for row in rows:
    row["price"] = float(row["price"])
    row["category"] = row["category"].strip().upper()

# etap 2: szybka pętla z minimalną logiką
discounts = {"A": 0.9, "B": 0.8}
for row in rows:
    total += row["price"] * discounts.get(row["category"], 1.0)

Zysk nie wynika z „magii”, tylko z tego, że interpreter wykonuje mniej instrukcji w gorącej ścieżce, a koszt porządkowania danych nie mnoży się przez liczbę rekordów.

Unikanie niepotrzebnej alokacji w każdej iteracji

Kolejny typowy antywzorzec to tworzenie nowych obiektów w każdej iteracji, choć nie ma takiej potrzeby:

for row in rows:
    tmp_list = []
    for x in row["values"]:
        tmp_list.append(process(x))
    row["processed"] = tmp_list

Jeśli struktura ma stały rozmiar, da się z góry zaalokować kontener i uzupełniać go „w miejscu”:

values = row["values"]
n = len(values)
processed = [None] * n

for i in range(n):
    processed[i] = process(values[i])

row["processed"] = processed

Przy małych danych różnica jest kosmetyczna, ale przy milionach rekordów ciągłe tworzenie nowych list czy słowników generuje dodatkową pracę dla alokatora i GC. Tańsza jest modyfikacja przygotowanej struktury niż jej wielokrotne konstruowanie od zera.

Prealokacja struktur wynikowych zamiast bezmyślnego `append`

`list.append` jest szybki, ale przy naprawdę dużych kolekcjach zaczyna być wąskim gardłem, szczególnie gdy:

  • opracowujesz strukturę, której rozmiar znasz z góry,
  • robisz to w wielu wątkach/procesach (synchronizacja, kolejki).

Jeśli liczba elementów jest znana:

n = len(rows)
results = [None] * n

for i, row in enumerate(rows):
    results[i] = transform(row)

Przy przetwarzaniu batchem:

batch_size = 10_000
buffer = []

for i, row in enumerate(stream_rows()):
    buffer.append(transform(row))
    if len(buffer) == batch_size:
        write_batch(buffer)
        buffer.clear()

# na koniec resztka
if buffer:
    write_batch(buffer)

Dzięki batchom zmniejsza się liczba wywołań I/O, a rozmiar bufora trzyma się w ryzach. Implementacja jest tania i nie wymaga żadnych dodatkowych bibliotek.

Unikanie powtarzanych look‑upów i dynamicznej introspekcji

Wewnątrz ciasnych pętli każdy dodatkowy lookup w słowniku czy atrybut obiektu kosztuje. Często da się te koszty zredukować przez proste „wyciągnięcie” referencji poza pętlę:

# antywzorzec
for row in rows:
    total += row["value"]
    if row["flag"]:
        flagged.append(row["id"])

# prościej
get_value = operator.itemgetter("value")
get_flag = operator.itemgetter("flag")
get_id = operator.itemgetter("id")

for row in rows:
    total += get_value(row)
    if get_flag(row):
        flagged.append(get_id(row))

Jeszcze prościej, gdy da się przejść na struktury „płaskie” (np. NumPy, strukturę rekordów) i unikać słowników w gorącej pętli. Zmiana jest niewielka w kodzie, a efekt przy milionach elementów bywa wyraźny.

Nieprzemyślane generatory i komprehensje

List comprehensions i generatory są zwykle szybsze niż ręczne pętle, ale mogą stać się antywzorcem, jeśli:

  • tworzysz pośrednie listy, których nie potrzebujesz,
  • gnieżdżisz komprehensje z wewnętrznymi wywołaniami ciężkich funkcji.

Przykład:

# antywzorzec: dwie listy pośrednie
result = [expensive(x) for x in [parse(r) for r in records]]

Bez pośredniej listy:

result = [expensive(parse(r)) for r in records]

Albo, jeśli da się przetwarzać leniwie:

from itertools import starmap

parsed = (parse(r) for r in records)
result = [expensive(x) for x in parsed]

Zamiast gonić za „ładnym onelinerem”, lepiej mieć prostą, profilowalną strukturę, która nie tworzy niepotrzebnych kontenerów pośrednich.

Ręce piszące kod na laptopie obok książki o Pythonie
Źródło: Pexels | Autor: Christina Morillo

Pamięć i kopiowanie danych: cichy zabójca wydajności

Rozumienie kosztu `.copy()` i niejawnych kopii

Kopiowanie dużych struktur danych jest często droższe niż same obliczenia. Problem w tym, że te kopie tworzą się czasem „po cichu” – przy rzutowaniach typów, łączeniu list, operacjach na DataFrame.

Przykład z listami:

# antywzorzec: tworzenie nowej listy w każdej operacji
for _ in range(10):
    data = data + new_values       # O(n) kopia

# znacznie taniej
for _ in range(10):
    data.extend(new_values)        # modyfikacja w miejscu

Podobnie z krotkami:

# każda konkatenacja to nowa krotka
t = ()
for x in big_iter:
    t = t + (x,)

Ten kod jest ekstremalnie wolny. Wystarczy zwykła lista plus ewentualna konwersja na końcu:

tmp = []
for x in big_iter:
    tmp.append(x)
t = tuple(tmp)

Przy danych rzędu milionów elementów to różnica między sekundami a minutami.

Kiedy pracować „w miejscu”, a kiedy nie

Modyfikacja danych w miejscu oszczędza pamięć i czas kopiowania, ale niesie koszt: utratę „bezpiecznego” oryginału. W praktyce najtańszy kompromis to:

  • zrobić jedną, jawną kopię bazową na początku etapu przetwarzania,
  • potem wykonywać większość transformacji w miejscu.

Przykład z tablicą NumPy:

# jedna kopia wejścia
arr = np.array(raw_data, dtype=np.float32, copy=True)

# dalsze operacje w miejscu
arr -= arr.mean()
arr /= arr.std()
arr[arr < 0] = 0

Zamiana na operacje in‑place (`-=`, `/=`, modyfikacje wycinków) eliminuje potrzebę alokowania kolejnych dużych tablic pośrednich. Z punktu widzenia budżetu RAM różnica bywa kilkukrotna.

Formaty danych i alignowanie typów

Przetwarzanie „surowych” danych tekstowych bez wcześniejszej normalizacji typów to prosty przepis na marnowanie CPU i RAM. Każde `float(x)` w pętli, każdy `datetime.strptime` dla miliona rekordów to osobny rachunek.

Taniej jest:

  • ustawić odpowiednie typy przy wczytywaniu (np. `dtype` w NumPy, `dtype`/`parse_dates` w pandas),
  • korzystać z typów lżejszych (`float32` zamiast `float64`, `int32` zamiast `int64`) tam, gdzie precyzja na to pozwala.
import numpy as np

# antywzorzec
arr = np.array(raw_data)   # domyślnie float64, często zbyt ciężkie

# lżejsza alternatywa
arr = np.array(raw_data, dtype=np.float32)

Przy macierzach wielowymiarowych redukcja typu potrafi uciąć zużycie pamięci o połowę, co często eliminuje konieczność dokładania kolejnych gigabajtów RAM.

Fragmentacja pamięci i duże obiekty

Pythonowe obiekty (listy, słowniki, obiekty klas) są „spuchnięte” w porównaniu z prostymi tablicami C. Każdy element listy to wskaźnik, każdy wpis w słowniku ma dodatkowe narzuty. Przy dużych kolekcjach:

  • rozrasta się narzut zarządzania pamięcią,
  • GC ma więcej pracy,
  • powstaje fragmentacja pamięci, która utrudnia alokację dużych bloków.

Tani sposób ograniczenia problemu to zamiana struktur „słownikowych” na zwarte:

  • tablice NumPy,
  • `array` z modułu standardowego dla prostych typów,
  • proste struktury rekordów (np. `namedtuple`, `dataclass` z `__slots__`).
from dataclasses import dataclass

# zamiast słownika
# row = {"x": 1.0, "y": 2.0}

@dataclass(slots=True)
class Row:
    x: float
    y: float

row = Row(1.0, 2.0)

`__slots__` ogranicza narzut pamięciowy per obiekt i przyspiesza dostęp do atrybutów. Nie jest to magiczny dopalacz, ale przy milionach rekordów daje wymierne oszczędności bez zmiany całego stosu technologicznego.

Unikanie zbędnego „materializowania” całych kolekcji

Często pojawia się nawyk: „wrzucę wszystko do listy, potem przetworzę”. Przy dużych danych ta strategia szybko wyczerpuje RAM, szczególnie gdy dane i tak są przetwarzane sekwencyjnie.

Antywzorzec:

rows = list(stream_rows_from_disk())  # <= wszystko w pamięci

for row in rows:
    process(row)

Dużo rozsądniej:

for row in stream_rows_from_disk():
    process(row)

Jeżeli procesowanie wymaga czasem „zajrzenia” w dalsze elementy, lepiej korzystać z bufora o kontrolowanym rozmiarze (np. `deque` o ograniczonej długości) niż z całej listy.

Zbliżenie ekranu monitora z kodem Pythona w edytorze programistycznym
Źródło: Pexels | Autor: Mathews Jumba

Antywzorce w Pandas i NumPy: jak nie zabijać wektoryzacji

Iterowanie po wierszach DataFrame

Najdroższym klasykiem jest ręczne iterowanie po wierszach DataFrame:

# antywzorzec
for idx, row in df.iterrows():
    df.loc[idx, "value"] = transform(row["value"])

Taka konstrukcja łączy wszystkie problemy:

  • miliony wywołań Pythona,
  • ciągłe aktualizacje struktury indeksu,
  • brak wykorzystania wewnętrznych optymalizacji pandas.

Prosty zamiennik:

df["value"] = df["value"].apply(transform)      # lepiej
# a jeśli transform jest prosta i czysto numeryczna:
df["value"] = df["value"] * 1.23 + 5            # najlepiej, pełna wektoryzacja

Jeżeli logika jest złożona, często da się ją rozbić na kilka prostszych, wektoryzowanych kroków zamiast jednego dużego, iteracyjnego potwora.

Nadmierne użycie `.apply(axis=1)`

`DataFrame.apply(axis=1)` kusi prostotą, ale pod spodem działa jak pętla po wierszach. Każdy wiersz jest „składany” do Series, co przy dużej liczbie rekordów staje się zabójcze.

# antywzorzec
def score(row):
    return row["a"] * 2 + row["b"] ** 2 - row["c"]

df["score"] = df.apply(score, axis=1)

Dużo wydajniej:

df["score"] = df["a"] * 2 + df["b"] ** 2 - df["c"]

Jeśli logika jest bardziej warunkowa, dobrze sprawdzają się:

  • `np.where` dla prostych warunków,
  • mapowanie słowników (`Series.map`),
  • łączenie przez `merge` zamiast ręcznego wyszukiwania w pętli.

Tworzenie DataFrame w pętli zamiast jednej konstrukcji

Kolejny popularny antywzorzec to budowanie DataFrame „po jednym wierszu”:

df = pd.DataFrame(columns=["a", "b", "c"])

for row in raw_rows:
    df.loc[len(df)] = row

To drogie, bo przy każdym dodaniu wiersza pandas potencjalnie powiększa wewnętrzne struktury. Taniej jest:

rows = []

for row in raw_rows:
    rows.append(row)

df = pd.DataFrame(rows, columns=["a", "b", "c"])

Albo – gdy dane są w słownikach:

records = [parse(line) for line in input_stream]
df = pd.DataFrame.from_records(records)

Wariant „najpierw lista, potem DataFrame” jest nie tylko szybszy, ale też mniej pamięciożerny, bo struktury pandas powstają raz, a nie są przebudowywane w kółko.

Nieświadome kopiowanie kolumn i fragmentów DataFrame

Pandas potrafi tworzyć kopie danych w wielu miejscach – czasem jawnie, czasem przy okazji. Klasyczny zły wzorzec:

Łańcuchy kopii i `SettingWithCopyWarning`

Najwięcej RAM-u ucieka przy nieświadomym kopiowaniu fragmentów DataFrame. Podstawowy przykład to tzw. chained indexing:

# antywzorzec: potencjalna kopia, brak gwarancji modyfikacji oryginału
subset = df[df["country"] == "PL"]["value"]
subset += 1

Często pojawia się wtedy `SettingWithCopyWarning`, ale nawet bez ostrzeżenia koszt kopiowania już został zapłacony. Bezpieczniej i taniej:

# jedno, jawne filtrowanie + loc
mask = df["country"] == "PL"
df.loc[mask, "value"] = df.loc[mask, "value"] + 1

Ta wersja:

  • nie tworzy niepotrzebnego, oderwanego fragmentu pamięci,
  • pracuje bezpośrednio na oryginalnym bloku danych.

Nieoptymalne typy kolumn w pandas

Domyślne typy pandas są bezpieczne, ale ciężkie. Kilka prostych korekt potrafi zbić rozmiar DataFrame o wielokrotność:

  • zamiana `object` z powtarzalnymi stringami na `category`,
  • redukcja szerokich typów liczbowych (`int64` → `int32`, `float64` → `float32`),
  • parsowanie dat przy wczytywaniu, zamiast w pętlach po DataFrame.
# antywzorzec: domyślne typy
df = pd.read_csv("data.csv")

# wariant oszczędnościowy
df = pd.read_csv(
    "data.csv",
    dtype={
        "country": "category",
        "user_id": "int32",
    },
    parse_dates=["created_at"],
)

Dla już wczytanych danych:

df["country"] = df["country"].astype("category")
df["amount"] = df["amount"].astype("float32")

Przy codziennych procesach ETL taka drobna zmiana czasem decyduje o tym, czy pipeline zmieści się w jednej maszynie, czy trzeba skalować klaster.

Używanie Pythonowych funkcji w wektoryzowanych pipeline’ach

Częsty błąd to wrzucanie ciężkich funkcji Pythona w miejsca, gdzie pandas/NumPy mogą użyć szybkiego C. Przykład:

import math

# antywzorzec
df["log_v"] = df["v"].apply(math.log)

# znacznie lepiej: wersja z NumPy
df["log_v"] = np.log(df["v"])

Różnica wynika z tego, że:

  • `apply` wywołuje Pythonową funkcję dla każdego elementu,
  • `np.log` działa blokowo na tablicy, omijając interpretator.

Analogia dotyczy praktycznie każdej operacji: trygonometrii, zaokrąglania, podstawowych warunków (np. `np.where` zamiast pętli z `if`).

Niepotrzebne przełączanie między pandas a Pythonem

Na dużych DataFrame obowiązuje prosta zasada: jeżeli dane są już w kolumnach, lepiej trzymać je tam jak najdłużej. Antywzorzec:

# antywzorzec: wyciąganie serii do list, obrabianie w Pythonie, wpychanie z powrotem
values = df["value"].tolist()
processed = [expensive(v) for v in values]
df["value_proc"] = processed

Czasem da się przenieść logikę do NumPy albo użyć prostszego wektoryzowania:

# uproszczony przykład – kombinacja kilku operacji numerycznych
df["value_proc"] = np.clip(df["value"] * scale + bias, 0, 1)

Jeśli funkcja jest nie-wektoryzowalna, opłaca się przynajmniej ograniczyć liczbę przejść tam–z powrotem (np. przetwarzać kilka kolumn naraz, a nie jedną po drugiej w osobnych pętlach Pythona).

Nieefektywne operacje join/merge

Łączenie dużych DataFrame może być tanie lub boleśnie drogie. Typowe potknięcia:

  • łączenie po kolumnach typu `object` zamiast po liczbach / kategorii,
  • brak indeksu lub zły indeks,
  • kilka kolejnych `merge` tam, gdzie wystarczy jedno rozsądnie zaplanowane połączenie.
# antywzorzec: łączenie po stringach
big["country"] = big["country"].astype("category")
countries["country"] = countries["country"].astype("category")

# merge po typie category zwykle jest lżejszy
merged = big.merge(countries, on="country", how="left")

Przy seryjnych łączeniach opłaca się czasem najpierw skleić wszystkie słowniki referencyjne w jeden mniejszy DataFrame i dopiero jego użyć w jednym `merge`, zamiast wykonywać kilka przejść po wielomilionowym zbiorze danych.

Nieświadome materializowanie wyników pośrednich w NumPy

W NumPy łatwo zalać pamięć przez łączenie operacji w „ładne” wyrażenie:

# antywzorzec: kilka dużych tablic pośrednich
z = (x * 2 + y ** 2 - np.sqrt(w)) / np.log1p(v)

Dla bardzo dużych tablic ta linijka może wygenerować kilka tymczasowych macierzy o tym samym rozmiarze. Taniej jest rozbić obliczenia i część kroków wykonać w miejscu:

z = x.astype(np.float32, copy=True)  # jawna jedna kopia wejścia
z *= 2
z += y ** 2          # tu powstaje tymczasowa tablica, ale tylko jedna
z -= np.sqrt(w)
z /= np.log1p(v)

Struktura jest mniej „elegancka”, ale oszczędza RAM i redukuje szczytowe zapotrzebowanie na pamięć, co przy dużych macierzach bywa granicą między ukończonym zadaniem a OOM.

Broadcasting bez kontroli kształtów

Broadcasting to mocna funkcja NumPy, ale nieostrożne użycie potrafi wyprodukować monstrualną tablicę. Klasyczny przykład:

# wektory długości N i M
a = np.random.rand(N)
b = np.random.rand(M)

# antywzorzec: pełna macierz N x M
dist = (a[:, None] - b[None, :]) ** 2

Przy dużym N i M pamięć eksploduje. Ta konstrukcja ma sens tylko wtedy, gdy rzeczywiście potrzebna jest pełna macierz. Jeśli celem jest np. minimalna odległość:

# wariant strumieniowy / blokowy (szkic)
best = np.full(N, np.inf, dtype=np.float32)

block = 10_000
for i in range(0, M, block):
    b_block = b[i : i + block]
    dist_block = (a[:, None] - b_block[None, :]) ** 2
    best = np.minimum(best, dist_block.min(axis=1))

Zamiast jednego olbrzymiego obiektu, powstają mniejsze bloki mieszczące się w pamięci podręcznej i RAM.

Leniwe przetwarzanie i strumienie: ucieczka od „wszystko na raz do RAM”

Generatory zamiast list w pipeline’ach

Najprostsza dźwignia oszczędności to zamiana list na generatory tam, gdzie dane przetwarzane są sekwencyjnie. Kontrast:

# antywzorzec – trzy pełne listy pośrednie
rows = [parse(line) for line in open(path)]
filtered = [r for r in rows if is_valid(r)]
scores = [score(r) for r in filtered]

Streamingowy wariant:

def iter_rows(path):
    with open(path) as f:
        for line in f:
            yield parse(line)

rows = (r for r in iter_rows(path) if is_valid(r))
scores = (score(r) for r in rows)

# materializacja dopiero na końcu, jeśli potrzeba
top = heapq.nlargest(1000, scores)

W ten sposób w RAM trzymana jest tylko niewielka część danych, a nie cały zbiór. Z punktu widzenia budżetu infrastruktury to często różnica między jednym tanim serwerem a drogim klastrem.

Strumieniowe przetwarzanie plików i sieci

Gdy źródłem danych jest plik lub socket, odruch „wczytaj wszystko” jest zabójczy. Dużo rozsądniej:

# antywzorzec
data = socket.recvall()   # pseudo-kod
records = json.loads(data)

# zamiast tego – strumieniowy parser (np. ijson, ujson w trybie iteracyjnym)
import ijson

def iter_json_records(stream):
    for obj in ijson.items(stream, "item"):
        yield obj

Podobnie przy CSV:

import csv

with open("big.csv", newline="") as f:
    reader = csv.DictReader(f)
    for row in reader:
        process(row)  # każda linia od razu przetwarzana

Brak wielkiej struktury pośredniej w pamięci upraszcza zarządzanie zasobami i zmniejsza ryzyko awaryjności całego procesu.

Buforowane okna zamiast pełnych kolekcji

Czasami potrzebny jest kontekst: kilka poprzednich elementów albo krótkie okno czasowe. Zamiast trzymać wszystko w liście, wystarczy mały bufor:

from collections import deque

def moving_avg(iterable, window=100):
    buf = deque(maxlen=window)
    for x in iterable:
        buf.append(x)
        yield sum(buf) / len(buf)

To rozwiązanie rośnie do stałego rozmiaru i nie wycieka w nieskończoność, w przeciwieństwie do prostego:

# antywzorzec
history = []
for x in iterable:
    history.append(x)
    avg = sum(history[-100:]) / min(100, len(history))

Przy długich strumieniach `history` rozrasta się do milionów elementów, mimo że do obliczeń potrzebnych jest raptem sto ostatnich wartości.

Łączenie strumieni z przetwarzaniem wsadowym

Pełne strumieniowanie bywa niepraktyczne – np. niektóre algorytmy wymagają danych w blokach. Zamiast dwóch skrajności (wszystko naraz vs pojedyncze rekordy) opłaca się korzystać z małych wsadów:

def batched(iterable, batch_size):
    batch = []
    for item in iterable:
        batch.append(item)
        if len(batch) == batch_size:
            yield batch
            batch = []
    if batch:
        yield batch

for batch in batched(stream_rows_from_disk(), batch_size=10_000):
    process_batch(batch)

Oszczędność jest dwutorowa: liczba wywołań funkcji `process_batch` spada, a jednocześnie kontrolowany jest rozmiar danych trzymanych równocześnie w pamięci.

Leniwe ładowanie i chunkowanie w pandas

Pandas ma wbudowany mechanizm ładowania plików „po kawałku”. Antywzorzec:

# antywzorzec – całość w RAM
df = pd.read_csv("big.csv")
result = heavy_transform(df)

Przy dużych CSV:

result_parts = []

for chunk in pd.read_csv("big.csv", chunksize=100_000):
    part = heavy_transform(chunk)
    result_parts.append(part)

result = pd.concat(result_parts, ignore_index=True)

Jeśli wynik końcowy nie musi być pojedynczym DataFrame (np. zapis do bazy lub do kilku plików), da się nawet pominąć `concat` i od razu wypisywać efekty:

for chunk in pd.read_csv("big.csv", chunksize=100_000):
    part = heavy_transform(chunk)
    part.to_parquet(get_next_output_path())

Zamiast jednego ogromnego zrzutu do pamięci powstaje kilka mniejszych, łatwiejszych do opanowania kroków.

Pipeline’y z `itertools` i prostymi funkcjami

Standardowa biblioteka ma sporo narzędzi, które pozwalają budować tanie, leniwe pipeline’y bez dodatkowych bibliotek. Przykładowy łańcuch:

from itertools import islice, filterfalse

def is_comment(line):
    return line.lstrip().startswith("#")

with open("log.txt") as f:
    lines = (line.rstrip("n") for line in f)
    no_comments = filterfalse(is_comment, lines)
    # ograniczenie do pierwszych 100k linii po filtrze
    head = islice(no_comments, 100_000)
    for line in head:
        process(line)

Cały pipeline jest leniwy, nie ma list pośrednich i niepotrzebnych kopii. Dodatkowo, zmiana jednego etapu (np. dokładanie nowego filtra) jest tania zarówno w kodzie, jak i w kosztach wykonania.

Unikanie blokujących operacji I/O w przetwarzaniu CPU-bound

Strumieniowanie bywa kojarzone tylko z I/O, ale przy dużych danych ważne jest również to, jak I/O miesza się z CPU. Antywzorzec:

for row in stream_rows_from_disk():
    row = decode(row)
    row = transform(row)   # ciężka operacja CPU
    save(row)              # blokujące I/O

Na jednej maszynie można zdobyć sporo czasu, rozdzielając I/O i CPU na osobne etapy z kolejką między nimi (nawet prostą, opartą na listach lub `queue.Queue`). Dysk pracuje w tle, CPU nie czeka na zapis/odczyt, a pamięć trzyma tylko niewielki bufor rekordów „w tranzycie” zamiast całego datasetu.

Najczęściej zadawane pytania (FAQ)

Co w praktyce oznacza „duże dane” w Pythonie i od jakiego rozmiaru zaczynają się problemy?

W kontekście Pythona „duże dane” to nie modne hasło, tylko taki wolumen, który przestaje wygodnie mieścić się w RAM i obnaża koszty dynamicznych obiektów. Dla jednego projektu problem zaczyna się przy kilkudziesięciu milionach rekordów, dla innego już kilkaset tysięcy w nieoptymalnym formacie potrafi zabić wydajność.

Typowy sygnał, że przekraczasz zdrowy próg: skrypt, który działał sekundy na próbce, przy pełnych danych idzie kilkanaście–kilkadziesiąt minut, RAM dobija do 100%, system zaczyna swapować, a Jupyter przestaje reagować. W praktyce „duże” zaczyna się tam, gdzie pojedynczy DataFrame lub lista obiektów wchodzi w dziesiątki procent dostępnej pamięci.

Dlaczego Python jest wolny przy przetwarzaniu dużych danych w porównaniu z NumPy czy narzędziami w C?

Każdy Pythonowy obiekt to nie tylko wartość, ale zestaw metadanych, wskaźników i mechanizmów do zarządzania pamięcią. Z jednym integerem różnicy nie widać, ale przy milionach elementów narzut pamięci i czasu na obsługę tych struktur robi się ogromny.

Biblioteki takie jak NumPy czy narzędzia oparte o C/kolumnowe formaty (np. Parquet) trzymają dane w zwartych tablicach bajtów. Dzięki temu CPU czyta je sekwencyjnie, cache działa lepiej, a operacje wektorowe wykonują się w zoptymalizowanym kodzie C zamiast w milionach iteracji pętli Pythona. Efektem jest często różnica rzędu dziesiątek razy bez zmiany sprzętu.

Jakie są najczęstsze antywzorce wydajności przy pracy z dużymi danymi w Pythonie?

Najczęściej spotykane antywzorce to:

  • iterowanie po wierszach DataFrame (np. `iterrows()`) zamiast wektoryzacji lub operacji grupowych,
  • wczytywanie całego zbioru danych do pamięci „na raz”, mimo że da się go łatwo zbatować lub streamować,
  • przetrzymywanie wielkich list/`dict` z milionami małych obiektów zamiast użycia zwartych struktur (NumPy, `array`, typy kolumnowe),
  • nadmiarowe kopiowanie danych (`.copy()`, niepotrzebne konwersje typów, tworzenie pośrednich list zamiast generatorów).

Każdy z tych wzorców osobno może wydawać się „wystarczająco szybki” na małej próbce. Po pomnożeniu przez miliony rekordów i ograniczoną ilość RAM-u zamienia się w realny koszt sprzętowy i czasowy.

Skąd mam wiedzieć, czy problem leży w Pythonie, czy w moim kodzie?

Dobrym testem jest odpowiedź na kilka pytań. Jeśli większość ciężkiej pracy wykonuje NumPy/Pandas/scikit-learn, dane mieszczą się w RAM (bez „dusznego” swapowania), a proces może potrwać minuty – zazwyczaj sam Python nie jest głównym problemem. Wtedy zwykle wystarczy ograniczenie zbędnych kopii danych i prostych pętli.

Jeśli widzisz dużo zagnieżdżonych pętli po listach lub słownikach Pythona, próbujesz jednorazowo przeżuć kilkadziesiąt gigabajtów na maszynie z kilkoma GB RAM albo opóźnienia muszą być liczone w milisekundach – wtedy to podejście (i język na pierwszej linii) staje się wąskim gardłem. W takiej sytuacji często taniej jest zmienić sposób obróbki (strumieniowanie, batchowanie, wektoryzacja) niż od razu wymieniać całą technologię.

Jak szybko sprawdzić, gdzie mój kod Pythona najbardziej traci czas i pamięć?

Najprostszy, „budżetowy” workflow to mały scenariusz testowy (np. 5–10% produkcyjnych danych), a potem uruchomienie go z profilowaniem. Do czasu wykonania wystarczy `cProfile`, do pamięci – `memory_profiler`. Z ich pomocą łatwo namierzyć kilka funkcji, które zabierają większość czasu i RAM-u.

Praktycznie wygląda to tak: owijasz główną funkcję w `cProfile`, sortujesz wynik po czasie łącznym i oglądasz TOP 20–30 wpisów. Dla 1–2 najcięższych funkcji odpalasz `line_profiler`/`memory_profiler` i optymalizujesz konkretną linię czy fragment, zamiast losowo przepisywać pół projektu.

Kiedy opłaca się optymalizować kod, a kiedy lepiej zmienić podejście (np. na strumieniowanie lub narzędzia rozproszone)?

Jeśli kod jest napisany „po prostu żeby działało” – pętle po DataFrame, wiele `.copy()`, trzymanie wszystkiego w RAM – najpierw zawsze warto wyczyścić oczywiste antywzorce. To zwykle tania operacja: zmiana kilku fragmentów, bez dużej ingerencji w architekturę.

Gdy dane przekraczają dostępny RAM o rząd wielkości, pojedynczy proces notorycznie „zabija” maszynę albo po optymalizacji dalej brakuje mocy, wtedy sensowniejsze jest inne podejście: przetwarzanie strumieniowe/batchowe, wydzielenie gorących fragmentów do Cython/Numba lub dobranie narzędzia rozproszonego (Dask, Spark) tylko do najbardziej wymagających etapów. To większy koszt wdrożenia, ale często jedyny sposób na stabilność przy naprawdę dużej skali.

Jakie proste kroki dają największy efekt przy pracy z dużymi danymi w Pythonie na zwykłym serwerze?

Najwięcej „złotówek za minutę oszczędności” zwykle przynoszą trzy ruchy: przejście z pętli Pythona na operacje wektorowe (NumPy/Pandas), ograniczenie liczby pełnych kopii danych oraz przetwarzanie w partiach zamiast ładowania wszystkiego naraz. W praktyce często wystarczy zmiana kilku funkcji operujących na DataFrame, żeby czas obliczeń spadł kilkukrotnie bez dokładania RAM-u.

Dobrym, tanim trikiem jest też testowanie na reprezentatywnej próbce i dopiero po zidentyfikowaniu najcięższych miejsc uruchamianie całości. Oszczędza to godziny „tuningowania na oko” i przyspiesza iterację nad kodem, co przy ograniczonym budżecie czasu deweloperskiego ma znaczenie nie mniejsze niż szybkość samego skryptu.

Co warto zapamiętać

  • „Duże dane” w Pythonie zaczynają się tam, gdzie zestaw nie mieści się swobodnie w RAM albo Python nie wyrabia czasowo; dla jednego projektu to miliony rekordów, dla innego już kilkaset tysięcy w kiepskim formacie.
  • Główne limity przy dużych danych to RAM, I/O i czas CPU, a typowe antywzorce (nadmiar obiektów, kopiowanie, pętle po Pythonowych strukturach) zawsze mocno uderzają w któryś z tych zasobów.
  • Dynamiczna natura Pythona ma wysoki narzut pamięciowy na każdy obiekt; przechowywanie milionów prostych wartości w listach lub słownikach szybko prowadzi do „rozdmuchania” RAM i kosztownego działania garbage collectora.
  • Python sprawdza się dobrze, gdy ciężar obliczeń biorą na siebie biblioteki w C (NumPy, Pandas), dane mieszczą się w pamięci, a proces może trwać minuty; staje się wąskim gardłem przy zagnieżdżonych pętlach na słownikach/listach i próbie obróbki dziesiątek gigabajtów „na raz”.
  • Wczesne objawy kłopotów to m.in. pełny RAM ze swapowaniem, pętle wydłużające się z sekund do dziesiątek minut, zawieszający się Jupyter oraz MemoryError albo ubijanie procesu przez system.
  • Zanim sięgniesz po drogie rozwiązania (więcej RAM, Spark, przepisywanie w Rust), najpierw usuń oczywiste antywzorce: pętle po wierszach DataFrame, zbędne kopie danych, niepotrzebne konwersje i ładowanie wszystkiego naraz.
  • Bibliografia i źródła

  • High Performance Python: Practical Performant Programming for Humans. O'Reilly Media (2020) – Optymalizacja wydajności Pythona, pamięć, profilowanie, NumPy
  • Python Cookbook, 3rd Edition. O'Reilly Media (2013) – Wzorce i dobre praktyki wydajnego kodu w Pythonie 3
  • Python 3.12 Documentation – Profiling and Optimization. Python Software Foundation – Oficjalna dokumentacja modułów timeit, cProfile i narzędzi profilujących