top of page

Biblioteki uruchamiają Rust wewnątrz Pythona (z PyO3), ale o szybkości decyduje granica między nimi

15 wrz
13 minut(y) czytania

PyO3 znalazło się w centrum nowej dyskusji wśród deweloperów po tym, jak przykład parsera pokazał, dlaczego sama natywna szybkość nie gwarantuje szybszej biblioteki Pythona.

Demonstracja z 13 września wyjaśnia, jak biblioteki uruchamiają Rust wewnątrz Pythona za pomocą PyO3, wykorzystując ręcznie zbudowany parser JSON jako przykład testowy. Rust najpierw analizuje dane wejściowe. Następnie PyO3 przekształca powstałe drzewo w słowniki, listy, ciągi znaków, liczby, wartości logiczne i wyjątki Pythona.

To właśnie drugi etap tworzy napięcie. Szybki algorytm w Ruście może zakończyć pracę, zanim warstwa integracyjna wykona swoje zadanie. W przypadku dużych wyników przekształcanie natywnych wartości w obiekty Pythona może zająć więcej czasu niż operacja, którą deweloperzy chcieli przyspieszyć.

Nie jest to nowa funkcja Pythona ani nowe wydanie PyO3. CPython od dekad obsługuje natywne rozszerzenia, w tym moduły napisane w C, C++ i Fortranie. Obecne zainteresowanie odzwierciedla to, jak Rust uczynił tę starą architekturę atrakcyjną dla kolejnego pokolenia autorów bibliotek.

Pydantic, Polars, cryptography i inne projekty już umieściły Rust za znanymi interfejsami Pythona. Ich sukces skłania opiekunów projektów do ponownego rozważenia wolnych ścieżek wewnętrznych, ale rodzi też trudniejsze pytania dotyczące pakowania i obsługi platform.

Najważniejsza rywalizacja nie przebiega więc między Rustem a Pythonem. Chodzi o natywne obliczenia kontra narzut na granicy między nimi. To ona określa, które przeniesienia przynoszą wymierne korzyści, a które jedynie przenoszą złożoność do skompilowanego pakietu.

Biblioteki uruchamiają Rust wewnątrz Pythona z PyO3 poprzez zwykły import

PyO3 przekształca skompilowany Rust w natywne rozszerzenie, które CPython może importować, nie wymagając od twórców aplikacji porzucenia składni Pythona.

Bob Belderbos zademonstrował tę ścieżkę za pomocą parsera JSON napisanego w Ruście i udostępnionego przez funkcję Pythona. Jego opis parsera sprowadza proces do czterech etapów.

Deweloper pisze zwykły moduł Rust, dodaje atrybuty PyO3, buduje pakiet przy użyciu maturin i importuje wynikowe rozszerzenie z Pythona. Maturin to narzędzie do budowania i pakowania modułów Pythona opartych na Ruście.

Skompilowany artefakt to natywny kod maszynowy umieszczony we współdzielonej bibliotece. W zależności od systemu operacyjnego plik ten zwykle kończy się rozszerzeniem .so, .dylib lub .dll. Python ładuje go za pomocą tego samego ogólnego mechanizmu rozszerzeń, z którego korzystają starsze moduły natywne.

To sformułowanie ma znaczenie. Python nie interpretuje kodu źródłowego Rust w czasie wykonywania. Kompilator Rust tworzy kod maszynowy, a CPython wywołuje eksportowane funkcje przez swój natywny interfejs binarny aplikacji.

PyO3 zapewnia warstwę wiązań. Jego makra generują dużą część kodu łączącego potrzebnego do wywołań funkcji, zarządzania referencjami, pobierania argumentów, wartości zwracanych i obsługi wyjątków.

W demonstracji #[pyfunction] oznacza funkcję Rust, którą może wywołać Python. Makro #[pymodule] definiuje moduł rozszerzenia inicjalizowany przez Python podczas importu.

Publiczne doświadczenie pozostaje zwykłym Pythonem. Wywołujący importuje moduł i przekazuje ciąg znaków do funkcji parsującej. Nic w tej interakcji nie wymaga od niego rozumienia własności Rust, traits, czasów życia ani Cargo.

Implementacja podąża inną drogą. Dane wejściowe przechodzą z ciągu Pythona do referencji do ciągu Rust. Rust wykonuje parsowanie i konstruuje enum reprezentujący drzewo JSON.

Enum to typ Rust, który może przechowywać jeden z kilku zdefiniowanych wariantów. W tym przypadku warianty reprezentują wartości null, wartości logiczne, liczby, ciągi znaków, tablice i obiekty.

Powstałe drzewo początkowo w całości należy do Rust. Python nie może bezpośrednio użyć tej struktury, ponieważ jego interpreter oczekuje obiektów zarządzanych przez systemy pamięci i typów Pythona.

Oficjalny przewodnik PyO3 opisuje oba kierunki obsługiwane przez projekt. Deweloperzy mogą tworzyć moduły Pythona w Ruście albo osadzać interpreter Pythona wewnątrz aplikacji Rust.

Pierwszy kierunek napędza tę konkretną historię. Pozwala opiekunom zachować interfejs skierowany do Pythona, jednocześnie przenosząc wybrane zadania do skompilowanego kodu.

Ten model jest już powszechny w ekosystemie Pythona. NumPy ustanowił szerszy wzorzec, prezentując wygodne operacje Pythona wspierane przez natywne obliczenia. PyO3 zmienia język i narzędzia używane do budowy rozszerzenia, a nie fundamentalną architekturę.

Najnowsza dyskusja ma znaczenie, ponieważ ujawnia ukrytą granicę. Interesującym wydarzeniem nie jest to, że Python nagle nauczył się wykonywać kod natywny. Chodzi o to, że większa liczba opiekunów może teraz budować takie rozszerzenia bez ręcznego pisania każdej warstwy kodu łączącego CPython.

Niższa bariera implementacyjna rozszerza zbiór funkcji, które warto rozważyć do przeniesienia do kodu natywnego. Nie eliminuje jednak potrzeby mierzenia pełnego wywołania, w tym wszystkiego, co trafia do Rust i z niego wychodzi.

Opiekunowie bibliotek Pythona odczuwają presję, by przenosić gorące ścieżki do Rust

Sukces pakietów opartych na Ruście przekształcił natywne rozszerzenia z techniki dla specjalistów w wiarygodną strategię utrzymania głównych projektów Pythona.

Pydantic stanowi najczytelniejszy punkt odniesienia. Jego druga główna wersja przeniosła walidację do pydantic-core, osobnego pakietu zaimplementowanego w Ruście.

Wczesny projekt Pydantic V2 informował, że przepisany rdzeń zapewniał znaczące zyski wydajności względem pierwszej wersji. Liczby te pochodziły z przedpremierowych benchmarków samego Pydantic, więc wynik nadal zależy od konkretnego obciążenia.

Sygnał architektoniczny jest ważniejszy niż jeden benchmark. Kod Pythona definiuje modele i generuje schematy. Rdzeń Rust wykonuje walidację i serializację na ścieżce wrażliwej na wydajność.

Polars stosuje szerszą wersję tego samego wzorca. Jego rdzeń jest napisany w Ruście i udostępniany przez interfejsy dla kilku języków, w tym Pythona.

Architektura Polars utrzymuje planowanie zapytań, operacje kolumnowe i wykonanie równoległe blisko natywnych struktur danych. Użytkownicy Pythona nadal piszą wyrażenia i otrzymują znane wyniki wysokiego poziomu.

Te projekty wywierają presję na opiekunów parserów, walidatorów, tokenizerów, narzędzi kompresji, klientów baz danych i silników danych. Użytkownicy wiedzą już, że pakiet Pythona może zachować przystępny interfejs, jednocześnie zastępując wybrane elementy wewnętrzne.

Presja nie dotyczy wyłącznie rankingów benchmarków. Kod natywny może skrócić czas procesora, zwiększyć przepustowość i umożliwić bardziej przewidywalne wykorzystanie pamięci w dobrze zdefiniowanych operacjach.

Rust ma dla opiekunów jeszcze jedną zaletę. Jego systemy własności i typów wykrywają podczas kompilacji całe kategorie błędów pamięci, choć kod unsafe i defekty zależności nadal pozostają możliwe.

PyO3 zapewnia również konwersje między wieloma popularnymi typami Pythona i Rust. Tłumaczy błędy Rust na wyjątki Pythona i uczestniczy w zarządzaniu referencjami Pythona.

To narzędziowanie może uczynić rozszerzenie łatwiejszym w utrzymaniu niż ręcznie napisany interfejs C. Nie czyni natywnej integracji automatyczną, lecz ogranicza ilość potrzebnej niestandardowej infrastruktury.

W rezultacie opiekunowie podejmują inną decyzję typu „budować czy kupić”. Wcześniej wolna funkcja Pythona mogła pozostać w Pythonie, ponieważ natywne przepisanie wymagało rzadkiej wiedzy o C.

Teraz zespół mający doświadczenie w Ruście może udostępnić mniejszy natywny rdzeń przez PyO3. Maturin może następnie zbudować rozszerzenie i umieścić je w pakiecie Pythona.

Ta ścieżka działa najlepiej, gdy wąska funkcja pobiera kompaktowe dane wejściowe, wykonuje znaczącą ilość obliczeń i zwraca kompaktowy wynik. Kompresja, haszowanie, podsumowania parsowania, walidacja i jądra numeryczne często pasują do tego modelu.

Działa mniej przewidywalnie, gdy funkcja wielokrotnie przekracza granicę między językami. Wiele drobnych wywołań może tracić czas na sprawdzanie argumentów, dyspozycję, alokację i konwersję.

Duży wynik tworzy powiązany problem. Algorytm może działać wydajnie w Ruście, ale wywołujący nadal oczekuje zwykłych obiektów Pythona.

To oczekiwanie sprawia, że reprezentacja danych staje się czynnikiem rozstrzygającym. Biblioteka przechowująca bufory kolumnowe w natywnej pamięci ma inny profil kosztów niż parser zwracający tysiące zagnieżdżonych słowników.

Opiekunowie są więc popychani ku decyzjom architektonicznym, a nie jedynie przepisaniu kodu na inny język. Muszą zdecydować, która strona jest właścicielem danych i kiedy powinny powstawać obiekty Pythona.

Ta presja będzie się utrzymywać, ponieważ użytkownicy porównują zachowanie od początku do końca. Interesuje ich czas między wywołaniem funkcji a otrzymaniem użytecznego wyniku, a nie odizolowana szybkość jej wewnętrznej pętli.

Powrót danych może zniwelować zysk z natywnej szybkości

Kluczowym mechanizmem jest materializacja obiektów: konwersja dużego wyniku Rust na obiekty Pythona może zdominować całe ukończone działanie.

Parser Belderbosa najpierw tworzy drzewo Rust zawierające każdą wartość w dokumencie JSON. Ten etap może korzystać ze skompilowanego wykonania Rust i jawnego modelu pamięci.

Kolejny etap ponownie przechodzi przez całe drzewo. Każdy obiekt Rust staje się słownikiem Pythona, każda tablica listą, a każdy liść wartością Pythona.

Proces ten nazywa się materializacją. Tworzy konkretne obiekty Pythona, które wywołujący oczekuje sprawdzać, modyfikować, serializować lub przekazywać dalej.

Trait IntoPyObject PyO3 koordynuje tę konwersję. Trait definiuje zachowanie, które mogą implementować typy, pozwalając każdemu wariantowi JSON opisać odpowiadającą mu reprezentację Pythona.

Konwersja jest rekurencyjna. Obiekt wymaga nowego słownika, a następnie wpisów dla każdego klucza i wartości. Tablica wymaga listy zawierającej przekonwertowane elementy potomne.

Każdy obiekt Pythona trafia również do systemu zarządzania pamięcią CPython. Alokacja, metadane typu i zliczanie referencji generują koszty, których nie było w drzewie Rust.

Tradycyjne kompilacje CPython wprowadzają dodatkowe ograniczenie. Kod dotykający obiektów Pythona na ogół potrzebuje dostępu do interpretera powiązanego z global interpreter lock, czyli GIL.

GIL pozwala tylko jednemu wątkowi obsługiwać tradycyjny interpreter Pythona w danym momencie. Praca Rust odłączona od obiektów Pythona może działać bez utrzymywania tej blokady.

PyO3 dokumentuje mechanizm Python::detach, służący do zwalniania dostępu do interpretera podczas wykonywania przez Rust niezależnych obliczeń. Jego przewodnik po wykonaniu równoległym wyjaśnia, jak inne wątki Pythona mogą wówczas kontynuować pracę.

Technika ta pomaga tylko wtedy, gdy operacja Rust pozostaje z dala od obiektów Pythona. Odbudowanie słownika lub listy Pythona ponownie sprowadza wykonanie do granicy interpretera.

Przykład parsera szacuje, że dokument zawierający 100 000 wartości wymaga utworzenia zbliżonej liczby obiektów Pythona. Jest to zależność poglądowa, a nie uniwersalny benchmark wydajności.

Kształt ma równie duże znaczenie jak rozmiar. Płaski bufor numeryczny może czasem przekroczyć granicę przez współdzieloną reprezentację. Głęboko zagnieżdżony dokument wymaga większej liczby alokacji obiektów i przechodzenia po wskaźnikach.

Częstotliwość wywołań tworzy kolejny wymiar. Jedno natywne wywołanie przetwarzające duży, ciągły bufor może zamortyzować koszt przygotowania. Tysiące wywołań przetwarzających pojedyncze wartości często nie mogą tego zrobić.

Obsługa błędów również przekracza tę granicę. Błąd parsowania Rust musi stać się wyjątkiem Pythona z oczekiwaną klasą, komunikatem i informacją o lokalizacji.

PyO3 może mapować typowany błąd Rust na PyErr. Nieprawidłowy ciąg może więc pojawić się dla wywołującego w Pythonie jako ValueError, podczas gdy niedostępny plik może stać się FileNotFoundError.

Tłumaczenie to ma wartość, ponieważ chroni publiczne API. Użytkownicy nie powinni potrzebować osobnego modelu błędów tylko dlatego, że opiekunowie projektu zmienili język implementacji.

Jednak zachowanie semantyki Pythona wymaga pracy. Natywne przepisanie musi odtworzyć przypadki brzegowe, typy wyjątków, zachowanie iteracji, zasady własności, a czasem także interakcje z podklasami.

Prawdziwym celem optymalizacji jest zatem kompletny interfejs. Szybsze parsowanie nie pomaga wystarczająco, jeśli konwersja reprezentacji pozostaje bez zmian i dominuje w całkowitym czasie wykonania.

Biblioteki mają kilka odpowiedzi architektonicznych. Mogą zwracać mniejsze podsumowania, udostępniać iteratory, przetwarzać wywołania zwrotne partiami albo utrzymywać przy życiu nieprzezroczysty obiekt oparty na Rust.

Widok leniwy jest szczególnie istotny dla dużych drzew. Zamiast od razu tworzyć każdy obiekt Pythona, rozszerzenie może materializować wyłącznie wartości, o które poprosi wywołujący.

Taka konstrukcja ogranicza niepotrzebną konwersję, gdy aplikacja odczytuje niewielką część wyniku. Przenosi też złożoność do obszarów czasu życia obiektów, buforowania, mutacji i projektowania API.

Biblioteki kolumnowe mogą uniknąć części kosztów, zachowując dane w ciągłych natywnych buforach. Python otrzymuje lekki obiekt wskazujący bazowy magazyn danych, zamiast duplikować każdy element.

Strategia ta wyjaśnia, dlaczego Polars reprezentuje silniejszą natywną architekturę niż mechaniczne przepisanie funkcji po funkcji. Jego interfejs Pythona steruje silnikiem Rust, który utrzymuje razem znaczną część pracy i danych.

Parser zwracający zwykłe słowniki napotyka mniej korzystną granicę. Jego format wyjściowy wymaga od rozszerzenia utworzenia dokładnie tego grafu obiektów, którego oczekuje kod Pythona.

Wniosek nie brzmi, że konwersja PyO3 jest wyjątkowo nieefektywna. Każdy interfejs funkcji obcych musi uzgodnić reprezentacje, czasy życia, błędy i własność po obu swoich stronach.

PyO3 ułatwia wyrażanie tych zobowiązań. Nie może jednak znieść ich kosztu.

Rust i Python są partnerami, ale pakowanie jest bezlitosne

Szybkie rozszerzenie tworzy obowiązek dystrybucyjny, ponieważ skompilowane wheele muszą odpowiadać systemom operacyjnym, procesorom, interpreterom i interfejsom binarnym.

Pakiety czysto pythonowe mają ogromną przewagę pod względem przenośności. Jeden ogólny wheel często może działać w różnych systemach operacyjnych i architekturach procesorów.

Skompilowane rozszerzenia generują specyficzny dla platformy kod maszynowy. Opiekunowie pakietu muszą dostarczać zgodne artefakty albo prosić użytkowników o lokalną kompilację projektu.

Wheel to format binarnej dystrybucji Pythona. Zawiera instalowalne pliki pakietu i przenosi znaczniki zgodności dla interpretera, binarnego interfejsu aplikacji i platformy.

Przewodnik po pakowaniu Pythona opisuje domyślną macierz. Opiekunowie często potrzebują kompilacji obejmujących wersje Pythona, systemy operacyjne i architektury.

Ciągła integracja może zautomatyzować znaczną część tej pracy. Maturin i powiązane narzędzia mogą budować oraz publikować wheele, a projekty takie jak cibuildwheel koordynują kompilacje w docelowych środowiskach.

Automatyzacja nie eliminuje testowania. Wheel może zainstalować się pomyślnie, a mimo to zawieść z powodu nieobsługiwanej funkcji procesora, brakującej biblioteki systemowej lub niezgodnego oczekiwania środowiska uruchomieniowego.

Linux tworzy szczególną złożoność, ponieważ dystrybucje dostarczają różne wersje komponentów systemowych. Specyfikacje Manylinux zapewniają projektom ustandaryzowane środowiska kompilacji dla szeroko zgodnych wheelów.

Windows i macOS wymagają własnych artefaktów. Podział sprzętu Apple na Intel i Arm dodał kolejny wymiar, choć binaria uniwersalne mogą czasem łączyć architektury.

Stabilne ABI może ograniczyć wymiar wersji interpretera. ABI to niskopoziomowy kontrakt regulujący sposób, w jaki skompilowany kod wywołuje binarne środowisko uruchomieniowe.

Oryginalne stabilne ABI abi3 CPythona pozwala kwalifikującym się rozszerzeniom obsługiwać wiele wersji Python 3 za pomocą jednego wheela na platformę i architekturę. Rozszerzenie musi ograniczyć się do Limited API.

To ograniczenie wymienia część dostępu do API i potencjalnych optymalizacji na szerszą zgodność. PyO3 obsługuje wybór odpowiedniej minimalnej wersji Pythona podczas budowania rozszerzenia abi3.

Problem pakowania ponownie ewoluuje wraz z Pythonem CPython bez GIL. Kompilacje bez wątkowej blokady GIL usuwają tradycyjną konfigurację GIL, zmieniając założenia przyjmowane przez natywne rozszerzenia.

Obecna dokumentacja PyO3 rozróżnia tradycyjne wheele abi3 od nowszej ścieżki stabilnego ABI dla kompilacji bez GIL. Opiekunowie muszą zweryfikować, które konfiguracje interpretera rzeczywiście obsługują ich artefakty.

Pytania platformowe szybko pojawiły się w publicznej dyskusji wokół artykułu o parserze. Programiści pytali, czy zależności oparte na Rust nadal działają wszędzie tam, gdzie można uruchomić Pythona.

Krótka odpowiedź brzmi: nie. Skompilowana zależność działa tam, gdzie jej opiekunowie publikują zgodny wheel albo gdzie użytkownicy mogą go zbudować przy użyciu obsługiwanego łańcucha narzędzi.

Ograniczenie to dotyczy także natywnych rozszerzeń napisanych w innych językach. Rust zmienia dostępne cele kompilatora i zależności budowania, ale nie rozwiązuje podstawowego problemu przenośności.

Pakiet cryptography ilustruje doświadczenie użytkownika. Jego dokumentacja mówi, że większość użytkowników otrzymuje gotowy wheel i nie potrzebuje zainstalowanego Rust.

Użytkownicy spoza zestawu opublikowanych wheelów mogą potrzebować kompilatora Rust i innych natywnych zależności. Takie rozwiązanie awaryjne może zaskoczyć osoby, które oczekiwały, że pip install pozostanie niezależne od języka.

Python hostowany w przeglądarce stanowi kolejny przypadek brzegowy. Pyodide uruchamia Pythona przez WebAssembly, więc konwencjonalne wheele desktopowe i serwerowe nie działają tam automatycznie.

Ekosystem poczynił postępy w kompilacjach WebAssembly dla pakietów zawierających Rust. Pydantic-core oferuje obecnie odpowiednie artefakty, zgodnie z linkami przytoczonymi w dyskusji na Hacker News.

Mimo to obsługa WebAssembly wymaga świadomego pakowania i testowania. Zachowanie wejścia i wyjścia może również napotkać ograniczenia przeglądarki dotyczące wątków, plików, gniazd i asynchronicznych środowisk uruchomieniowych.

Systemy mobilne, środowiska wbudowane, nietypowe procesory i starsze dystrybucje korporacyjne tworzą podobną presję. Czysto pythonowy wariant awaryjny może chronić zasięg, lecz utrzymywanie dwóch implementacji zwiększa nakład pracy.

Zespoły bibliotek muszą więc uwzględniać koszt przenośności przy każdym natywnym przepisaniu. Ścieżka kodu może być szybsza, podczas gdy projekt staje się trudniejszy do dystrybucji wśród całej grupy odbiorców.

W przypadku popularnych pakietów taka wymiana może być warta zaakceptowania. Duża baza współtwórców i dojrzały proces wydań mogą obsługiwać rozbudowaną macierz wheelów.

Mniejsze projekty stają przed innym równaniem. Natywne kompilacje mogą przekształcić niewielką bibliotekę w zobowiązanie do inżynierii wydań w kilku środowiskach.

PyO3 i maturin znacząco redukują to zobowiązanie. Nie usuwają go, a użytkownicy odczuwają każdy nieutworzony cel jako niepowodzenie instalacji.

Sceptyczne podejście zaczyna się od pomiaru od początku do końca

„Napisane w Rust” jest faktem implementacyjnym, a nie wynikiem wydajnościowym; opiekunowie potrzebują testów porównawczych uwzględniających konwersję i wykorzystanie wyniku.

Rust często zwiększa szybkość kodu intensywnie wykorzystującego CPU w porównaniu z równoważną pętlą Pythona. To porównanie samo w sobie niewiele mówi o ukończonym zadaniu aplikacji.

Wywołanie rozszerzenia obejmuje konwersję argumentów, walidację, natywne przekazanie sterowania, obliczenia, konwersję wyniku, alokację i obsługę błędów. Wywołujący może następnie ponownie przekształcić wynik.

Użyteczny test porównawczy mierzy całą tę drogę. Zaczyna się od reprezentacji używanej przez aplikację i kończy na danych, które może wykorzystać kolejny etap.

Mikrobenchmarki nadal mają swoją rolę. Wskazują, który etap pochłania czas, i ujawniają, czy algorytm poprawił się po zmianie kodu.

Stają się mylące, gdy zespoły prezentują wyłącznie najszybszy etap wewnętrzny. Wartość przepustowości parsera może ukrywać późniejszy koszt budowania dużego grafu obiektów Pythona.

Benchmarki potrzebują także reprezentatywnych danych wejściowych. Małe dokumenty mogą zawyżać znaczenie stałego narzutu wywołania, natomiast jednolite dane syntetyczne mogą pomijać wzorce alokacji występujące w środowisku produkcyjnym.

Ciepłe pamięci podręczne, kompilacje wydaniowe, flagi kompilatora i funkcje CPU mogą zmieniać wyniki. Porównania powinny jasno uwidaczniać te warunki i testować to samo publiczne zachowanie.

Poprawność zasługuje na równą wagę. Parsery i walidatory napotykają nieprawidłowe kodowania, ekstremalne zagnieżdżenia, zduplikowane klucze, przypadki brzegowe liczbowe i nieoczekiwane zużycie zasobów.

Port, który zmienia wyjątki lub akceptuje inne dane wejściowe, może być szybszy, ponieważ wykonuje inną pracę. Testy zgodności muszą towarzyszyć testom wydajności.

Zużycie pamięci może odwrócić pozorną przewagę. Przechowywanie jednocześnie kompletnego drzewa Rust i świeżo zmaterializowanego drzewa Pythona może tymczasowo wymagać dwóch reprezentacji.

Projekty strumieniowe lub leniwe mogą ograniczyć tę duplikację. Mogą również zmieniać moment wystąpienia błędów i czas, przez jaki natywne bufory pozostają zaalokowane.

Twierdzenia dotyczące współbieżności wymagają podobnej ostrożności. Kod Rust może używać wielu wątków, a PyO3 może zwalniać dostęp do interpretera podczas odpowiedniej pracy natywnej.

Rozszerzenie nie może bezpiecznie manipulować zwykłymi obiektami Pythona z dowolnych natywnych wątków. Musi wracać do reguł interpretera PyO3 za każdym razem, gdy z nimi współdziała.

Python bez GIL zmienia część tego obrazu, lecz nie usuwa synchronizacji z danych aplikacji. Bezpieczeństwo wątkowe nadal pozostaje wyraźną odpowiedzialnością biblioteki.

Bezpieczeństwo także nie poddaje się prostym hasłom. Bezpieczny podzbiór Rust zapobiega kilku kategoriom niewłaściwego użycia pamięci, lecz natywne rozszerzenia mogą zawierać bloki unsafe i podatne zależności.

Defekt w skompilowanym kodzie może spowodować awarię procesu interpretera zamiast zgłosić zwykły wyjątek Pythona. Konsekwencja ta dotyczy wszystkich języków natywnych rozszerzeń.

Najmocniejszy argument za PyO3 jest więc konkretny. Umożliwia opiekunom połączenie interfejsu Pythona z implementacjami Rust tam, gdzie obliczenia i układ danych uzasadniają tę granicę.

Najsłabszy argument to kosmetyczne przepisanie. Zastąpienie zwięzłego kodu Pythona natywną infrastrukturą daje niewiele, gdy funkcja wykonuje ograniczoną pracę lub spędza większość czasu na wejściu/wyjściu.

Projekt powinien najpierw profilować istniejącą aplikację. Powinien zidentyfikować stabilną gorącą ścieżkę, zdefiniować wymagania dotyczące zgodności oraz zmierzyć rozmiar i kształt wymienianych wartości.

Zespół może następnie stworzyć prototyp jednej granicy. Jeśli dominuje konwersja, kolejna decyzja dotyczy reprezentacji danych, a nie instrukcji parsera czy optymalizacji kompilatora.

Ten sceptyczny test nie jest argumentem przeciw Pythonowi opartemu na Rust. Chroni to podejście przed staniem się domyślną odpowiedzią na każdą skargę dotyczącą wydajności.

Udane natywne rozszerzenie ukrywa swoją implementację przed zwykłymi użytkownikami. Instalacja działa, wyjątki pozostają rozpoznawalne, podpowiedzi typów są nadal użyteczne, a wydajność poprawia się w rzeczywistym obciążeniu.

Użytkownicy nie powinni musieć świętować wyboru języka. Powinni po prostu zauważyć, że ich istniejąca operacja Pythona kończy się szybciej.

Trzy sygnały pokażą, dokąd PyO3 zmierza dalej

Kolejna faza zależy od API świadomych granic, szerszego pokrycia wheelami oraz rozszerzeń działających zarówno w tradycyjnym Pythonie, jak i Pythonie bez GIL.

Pierwszym sygnałem jest zmiana w publicznych benchmarkach. Więcej projektów powinno raportować pomiary od początku do końca, uwzględniające tworzenie obiektów, a nie tylko przepustowość natywnych jąder obliczeniowych.

Taka zmiana wzmocniłaby argument za PyO3, ponieważ połączyłaby wybór języka z obserwowalnymi wynikami aplikacji. Ujawniłaby także porty, których zyski znikają podczas konwersji.

Szukaj benchmarków porównujących wiele projektów zwracanych wyników. Widok oparty na kodzie natywnym, iterator, wynik wsadowy i w pełni zmaterializowany słownik mogą prowadzić do bardzo różnych rezultatów.

Profile pamięci powinny pojawiać się obok wyników czasowych. Mogą pokazać, czy rozszerzenie przez chwilę przechowuje zduplikowane struktury Rust i Pythona.

Drugim sygnałem jest szersze pokrycie wheelami. Udane projekty będą publikować niezawodne artefakty dla głównych systemów operacyjnych, rodzin procesorów i obsługiwanych wersji Pythona.

WebAssembly zasługuje na szczególną uwagę. Lepsze narzędzia i większa liczba hostowanych na PyPI pakietów WebAssembly wheels ograniczyłyby zastrzeżenia dotyczące przenośności zgłaszane przez użytkowników Pythona działającego w przeglądarce.

Urządzenia mobilne i mniej popularne architektury Linuxa nadal stanowią użyteczne testy. Każdy obsługiwany cel rozszerza praktyczne znaczenie zwykłej zależności Pythona.

Projekty powinny również dokumentować proces budowania ze źródeł. Brak pakietu wheel jest mniej dotkliwy, gdy komunikaty o błędach, wymagania dotyczące kompilatora i instrukcje odtwarzalnego budowania są jasne.

Jeśli pakiety natywne wielokrotnie rezygnują z obsługi celów wspieranych przez wersje czysto pythonowe, obawy dotyczące przenośności rosną. Jeśli automatyzacja procesu budowania eliminuje te luki, słabną.

Trzecim sygnałem jest stabilne wsparcie dla CPythona z wolnymi wątkami. Rozszerzenia muszą dostosować swoje założenia dotyczące blokowania interpretera, współdzielonego stanu i dostępu do obiektów.

Rozwijające się API PyO3 oraz wsparcie dla stabilnego ABI ukształtują tę transformację. Opiekunowie projektów potrzebują czegoś więcej niż udanej kompilacji; potrzebują testów współbieżności przy realistycznych obciążeniach.

Dojrzałe rozwiązanie pozwoliłoby jednemu projektowi obsługiwać tradycyjne interpretery i interpretery z wolnymi wątkami bez mnożenia złożoności wydań ponad rozsądne granice.

Porażka wyglądałaby inaczej. Fragmentaryczne zestawy pakietów wheels, niejasne znaczniki ABI lub ukryte wąskie gardła serializacji sprawiłyby, że pakietom opartym na Rust trudniej byłoby zaufać jako domyślnym zależnościom.

Szerszy kierunek pozostaje przekonujący. Python zapewnia dużą bazę użytkowników, czytelną orkiestrację i produktywną warstwę aplikacyjną. Rust zapewnia skompilowane jądra, jawne struktury danych i bezpieczniejsze narzędzia natywne.

Partnerstwo odnosi jednak sukces tylko wtedy, gdy granica między nimi staje się częścią projektu. Biblioteki uruchamiają Rust wewnątrz Pythona za pomocą PyO3, lecz użytkownicy korzystają z obiektów Pythona i artefaktów pakietów Pythona.

Programiści rozważający migrację powinni zacząć od jednego konkretnego pytania: co musi przekroczyć tę granicę po zwróceniu wyniku przez szybką funkcję Rust?

Przeanalizuj profil tej ścieżki zwracania danych przed podjęciem decyzji o przepisaniu kodu. Przetestuj pakiet wheel na każdym deklarowanym celu. Następnie porównaj pełną operację z implementacją w Pythonie, od której użytkownicy już zależą.

Jeśli wersja natywna nadal wygrywa, PyO3 zasłużyło na swoje miejsce. Jeśli konwersja pochłania zysk, zmień interfejs, zanim zmienisz więcej kodu.

 
 

Zacznij bezpłatnie

Asystent AI działający przede wszystkim lokalnie, z funkcją zarządzania wiedzą osobistą

Aby zapewnić lepsze działanie AI,

remio obsługuje obecnie wyłącznie Windows 10+ (x64) i M-Chip Macs.

Twój partner AI w pracy
Zrób więcej z remio

Planuj. Twórz. Dostarczaj.
Wszystko w jednym miejscu.

bottom of page