Wróć na górę
Artykuły / Python · Rust

"Przepisałem kernel na Rust i nic nie przyspieszyło” – o pułapkach optymalizacji między językami

Autor: Nina Köhler 3 lipca 2024 ok. 15 min czytania

W społeczności często słyszymy hasło"Rust to bezpieczniejszy Go i szybszy Python”. Dla zespołów dławiących się na wydajności Pythona albo płacących wysokie rachunki za CPU w chmurze, przepisanie krytycznych fragmentów na Rust brzmi jak naturalny krok.

Sam język Rust faktycznie potrafi być ekstremalnie szybki, a przy tym zapewnia silną kontrolę pamięci, system typów z Option/Result i solidne narzędzia (Cargo). Mimo to wiele zespołów po miesiącach pracy odkrywa, że całkowity czas wykonania aplikacji praktycznie się nie zmienił.

Wina rzadko leży po stronie Rusta. Najczęściej problemem jest sposób integracji obu światów i architektura przepływu danych. Przyjrzyjmy się najważniejszym pułapkom.

I. Gdzie ucieka wydajność: koszt przekraczania granicy języków

Python jest dynamicznie typowany, Rust – statycznie. Przekazanie danych między nimi wymaga konwersji typów, a często też serializacji i deserializacji. Każde wywołanie funkcji po drugiej stronie granicy (FFI) ma swój narzut.

Jeśli Twój kod wywołuje miniaturową funkcję w Ruście dziesiątki tysięcy razy z poziomu pętli w Pythonie, to czas spędzony na"przepychaniu” danych i przełączaniu kontekstu bardzo szybko zjada potencjalny zysk.

1. Antywzorzec: drobne wywołania w pętli po stronie Pythona
bad_practice.py
import rust_module

results = []

for raw_data in massive_dataset:
    # Każde wywołanie przechodzi przez granicę Python <-> Rust
    result = rust_module.compute_single_item(raw_data)
    results.append(result)
2. Lepsze podejście: przekaż batch danych
good_practice.py
import rust_module

# Pojedyncze wywołanie, pętla i ciężkie obliczenia są po stronie Rusta
results = rust_module.compute_batch(massive_dataset)

Zasada jest prosta: im rzadziej przekraczasz granicę języków i im więcej pracy wykonujesz"w środku” Rusta, tym większa szansa na realny zysk. W połączeniu z PyO3 i tablicami NumPy (ciągłe obszary pamięci) można zminimalizować koszty kopiowania danych.

II. Debug build vs release: czy Rust w ogóle jest skompilowany z optymalizacjami?

Domyślnie cargo build tworzy wersję debugową – świetną do developmentu, ale z wyłączonymi agresywnymi optymalizacjami. Niezoptymalizowany binarny Rust potrafi działać zaskakująco wolno, czasem porównywalnie lub wolniej niż Python.

bash
# Tryb debug (domyślny) – NIE do benchmarków
cargo build

# Tryb release – z pełnymi optymalizacjami
cargo build --release

Flaga --release włącza m.in. inlining, rozwijanie pętli i usuwanie martwego kodu. Gotowy artefakt znajdziesz wtedy w target/release/. Tylko taką wersję warto łączyć z produkcyjnym Pythonem.

III. Typowe problemy przy ładowaniu modułów Rustowych w Pythonie

Nawet jeśli Rust działa szybko, możesz utknąć na etapie samej integracji – moduł się nie ładuje, pojawia się ModuleNotFoundError lub ImportError. Warto wtedy przejść krótką checklistę:

  • Gdzie leży skompilowany plik? – upewnij się, że .so/.pyd jest w tym samym katalogu co skrypt albo dodany do PYTHONPATH.
  • Zgodność wersji Pythona – nagłówki Pythona (python3-dev) użyte przy kompilacji muszą odpowiadać interpreterowi, który uruchamia Twój kod (ten sam virtualenv).
  • Spójność nazewnictwa – moduł zdefiniowany w PyO3 musi mieć tę samą nazwę, co plik ładowany przez import ....
IV. Ograniczenia po stronie Pythona: GIL, architektura i I/O

Nawet idealnie napisany moduł w Ruście nie pomoże, jeśli to Pythonowa otoczka jest wąskim gardłem.

  • GIL i wątki – jeżeli Twój kod Pythona opiera się na multithreadingu, to GIL może blokować równoległe wywołania do Rusta. W takich przypadkach warto rozważyć multiprocessing i komunikację między procesami, żeby każdy proces mógł niezależnie korzystać z modułu Rustowego.
  • Zły wybór"kawałka do przepisania” – jeśli na podstawie intuicji przepisałeś na Rust jedynie funkcję zajmującą 5% całego czasu wykonania, zysk będzie niezauważalny. Reszta kodu (np. powolne I/O) nadal dominuje.
  • Bottleneck poza CPU – gdy wąskim gardłem jest sieć, baza danych albo dysk (np. swapping przy braku RAM), szybszy kod obliczeniowy niczego nie zmieni.
V. Profilowanie przed optymalizacją

Zgadywanie"gdzie jest wolno” to najgorsza możliwa strategia. Zanim napiszesz pierwszą linijkę w Ruście, zrób profilowanie istniejącego kodu.

W ekosystemie Pythona masz do dyspozycji:

  • cProfile – wbudowany profiler funkcji,
  • line_profiler – pomiar czasu na poziomie pojedynczych linii,
  • py-spy – narzędzie do próbkowania działających procesów Pythona i generowania flame graphów.

Dopiero gdy zobaczysz, które fragmenty kodu rzeczywiście zjadają CPU, ma sens rozważać ich przeniesienie do Rusta – przy zachowaniu rozsądnej architektury FFI.

VI. Trzy kontrolne pytania przed (i po) migracji do Rusta
  • Czy budujesz w trybie --release?
    Sprawdź, czy production build modułu Rustowego powstaje z włączonymi optymalizacjami, a nie jako debug.
  • Jak często przekraczasz granicę Python ↔ Rust?
    Jeśli w gorącej pętli jest wiele małych wywołań, rozważ batchowanie danych i przeniesienie pętli do Rusta.
  • Czy na pewno problemem jest CPU?
    Potwierdź za pomocą narzędzi profilujących, że wąskim gardłem nie jest baza danych, sieć ani subsysytem I/O – w przeciwnym razie"przepisywanie na Rust” nie rozwiąże realnego problemu.
Poprzedni artykuł Korutyny w Pythonie: event loop oraz asyncio.gather i as_completed
Następny artykuł Najczęstsze błędy w Pythonie: środowisko, typy i limity zasobów