Droga do weryfikacji typów 4 milionów linii kodu Python. Część 2

Dziś publikujemy drugą część tłumaczenia materiału o tym, jak w Dropboxie zorganizowano kontrolę typów kilku milionów linii kodu Python.

Droga do weryfikacji typów 4 milionów linii kodu Python. Część 2

Przeczytaj pierwszą część

Oficjalne wsparcie dla typów (PEP 484)

Przeprowadziliśmy nasze pierwsze poważne eksperymenty z mypy w Dropboxie podczas Hack Week 2014. Hack Week to wydarzenie organizowane przez Dropbox przez tydzień, w tym czasie pracownicy mogą zajmować się czymkolwiek! Niektóre z najsłynniejszych projektów technologicznych Dropboxa zaczęły się właśnie podczas takich wydarzeń. W wyniku tego eksperymentu doszliśmy do wniosku, że mypy wygląda obiecująco, chociaż projekt ten nadal nie był gotowy do szerokiego użytku.

Wtedy w powietrzu unosiła się idea standaryzacji systemów wydawania wskazówek dotyczących typów Python. Jak już mówiłem, od wersji Python 3.0 można było korzystać z adnotacji typów w funkcjach, ale były to jedynie dowolne wyrażenia, bez określonej składni i semantyki. W trakcie wykonywania programu te adnotacje w dużej mierze były po prostu ignorowane. Po Hack Week zaczęliśmy pracować nad standaryzacją semantyki. Prace te doprowadziły do powstania PEP 484 (ten dokument był współtworzony przez Guido van Rossuma, Łukasza Langa i mnie).

Nasze motywy można było rozpatrywać z dwóch stron. Po pierwsze, mieliśmy nadzieję, że całe ekosystem Python mogłoby przyjąć wspólne podejście do korzystania z sugestii dotyczących typów (type hints — termin używany w Pythonie jako odpowiednik „adnotacji typów”). Biorąc pod uwagę możliwe ryzyko, byłoby to lepsze niż korzystanie z wielu wzajemnie niekompatybilnych podejść. Po drugie, chcieliśmy otwarcie omówić mechanizmy adnotowania typów z wieloma przedstawicielami społeczności Python. Częściowo to pragnienie wynikało z tego, że nie chcieliśmy wyglądać jak „odstępcy” od podstawowych idei języka w oczach szerszych rzeszy programistów Python. Jest to język o dynamicznej typizacji, znany z „typizacji kaczki”. Na początku w społeczności mogło pojawić się kilka podejrzliwych reakcji na pomysł statycznej typizacji. Ale takie nastawienie ostatecznie osłabło — po tym, jak stało się jasne, że statyczna typizacja nie ma być obowiązkowa (i po tym, jak ludzie zrozumieli, że jest naprawdę użyteczna).

Ostatecznie przyjęta składnia podpowiedzi typów była bardzo podobna do tej, którą wówczas obsługiwał mypy. Dokument PEP 484 został wydany wraz z Pythonem 3.5 w 2015 roku. Python przestał być językiem, który obsługiwał wyłącznie dynamiczne typowanie. Lubię myśleć o tym wydarzeniu jako o znaczącej cezurze w historii Pythona.

Początek migracji

Pod koniec 2015 roku w Dropbox stworzyliśmy zespół złożony z trzech osób, aby pracować nad mypy. W jego skład weszli Guido van Rossum, Greg Price i David Fisher. Od tego momentu sytuacja zaczęła szybko się rozwijać. Pierwszą przeszkodą na drodze wzrostu mypy była wydajność. Jak już wspomniałem powyżej, na wczesnym etapie rozwoju projektu zastanawiałem się nad przetłumaczeniem implementacji mypy na język C, ale ten pomysł został na razie skreślony z listy. Utknęliśmy przy tym, że do uruchomienia systemu używany był interpreter CPython, który nie charakteryzował się wystarczającą szybkością dla narzędzi takich jak mypy. (Projekt PyPy, alternatywna implementacja Pythona z kompilatorem JIT, również nam nie pomógł.)

Na szczęście przyszły z pomocą pewne algorytmiczne usprawnienia. Pierwszym potężnym "przyspieszaczem" była implementacja inkrementalnej kontroli. Pomysł tego usprawnienia był prosty: jeśli wszystkie zależności modułu od ostatniego uruchomienia mypy się nie zmieniły, możemy skorzystać podczas pracy z zależnościami z danych, które zostały zbuforowane podczas poprzedniej sesji. Musieliśmy tylko przeprowadzić kontrolę typów w zmienionych plikach oraz tych plikach, które były od nich zależne. Mypy poszło nawet nieco dalej: jeśli zewnętrzny interfejs modułu się nie zmienił — mypy uznawało, że inne moduły importujące ten moduł nie muszą być ponownie sprawdzane.

Inkrementalne sprawdzanie bardzo pomogło nam podczas annotacji dużych zbiorów istniejącego kodu. Chodzi o to, że ten proces zazwyczaj obejmuje wiele iteracyjnych uruchomień mypy, ponieważ adnotacje są stopniowo dodawane do kodu i stopniowo poprawiane. Pierwsze uruchomienie mypy wciąż było bardzo wolne, ponieważ podczas jego wykonania należało sprawdzić wiele zależności. W celu poprawy sytuacji wdrożyliśmy mechanizm zdalnego buforowania. Jeśli mypy stwierdzi, że lokalny bufor prawdopodobnie jest nieaktualny, pobiera aktualny zrzut bufora dla całej bazy kodu z centralnego repozytorium. Następnie wykonuje, korzystając z tej kopii, inkrementalne sprawdzanie. To jeszcze o jeden znaczący krok przyspieszyło nas na drodze do zwiększenia wydajności mypy.

To był okres szybkiej i naturalnej implementacji systemu sprawdzania typów w Dropbox. Pod koniec 2016 roku mieliśmy już około 420000 linii kodu Python z adnotacjami typów. Wiele osób entuzjastycznie podchodziło do sprawdzania typów. W Dropbox mypy było wykorzystywane przez coraz większe zespoły programistyczne.

Wszystko wtedy wyglądało dobrze, ale mieliśmy jeszcze wiele do zrobienia. Zaczęliśmy przeprowadzać okresowe wewnętrzne ankiety wśród użytkowników, aby zidentyfikować problemy projektu i zrozumieć, jakie pytania należy rozwiązać w pierwszej kolejności (ta praktyka jest stosowana w firmie nawet do dziś). Jak się okazało, najważniejsze były dwa zadania. Po pierwsze, potrzebne było większe pokrycie kodu typami, po drugie, mypy miało działać szybciej. Było zupełnie jasne, że nasza praca nad przyspieszeniem mypy i jego wdrożeniem w projektach firmy była wciąż daleka od zakończenia. W pełni zdając sobie sprawę z wagi tych dwóch zadań, zabrały się za ich rozwiązanie.

Więcej wydajności!

Sprawdzanie inkrementalne przyspieszyło mypy, ale ten narzędzie wciąż nie było wystarczająco szybkie. Wiele sprawdzeń inkrementalnych trwało około minuty. Przyczyną tego były importy cykliczne. To prawdopodobnie nie zdziwi nikogo, kto pracował z dużymi bazami kodu napisanymi w Pythonie. Mieliśmy zestawy z setkami modułów, z których każdy pośrednio importował wszystkie pozostałe. Jeśli jakikolwiek plik w cyklu importów był zmieniony, mypy musiał przetworzyć wszystkie pliki wchodzące w ten cykl, a często także jakiekolwiek moduły, które importowały moduły z tego cyklu. Jednym z takich cykli było głośno krytykowane "kłębek zależności", które przysporzyło wielu kłopotów w Dropboxie. Kiedyś ta struktura zawierała w sobie kilka setek modułów, które były importowane, bezpośrednio lub pośrednio, przez wiele testów, a także wykorzystywane w kodzie produkcyjnym.

Rozważaliśmy możliwość "rozwikłania" cyklicznych zależności, ale nie mieliśmy zasobów, aby to zrobić. Było tam za dużo kodu, z którym nie byliśmy zaznajomieni. W końcu poszliśmy w alternatywnym kierunku. Postanowiliśmy sprawić, aby mypy działał szybko nawet w obecności "kłębków zależności". Osiągnęliśmy ten cel dzięki demonowi mypy. Demon to proces serwera, który realizuje dwie interesujące możliwości. Po pierwsze - przechowuje w pamięci informacje o całej bazie kodu. Oznacza to, że przy każdym uruchomieniu mypy nie trzeba załadować pamięci podręcznej dotyczącej tysięcy importowanych zależności. Po drugie - szczegółowo analizuje zależności między funkcjami i innymi bytami na poziomie małych struktur. Na przykład, jeśli funkcja foo wywołuje funkcję bar, to istnieje zależność foo od bar. Kiedy plik się zmienia - demon najpierw, w izolacji, przetwarza tylko zmieniony plik. Następnie przygląda się zmianom w tym pliku, widocznym na zewnątrz, takim jak zmienione sygnatury funkcji. Demon wykorzystuje szczegółowe informacje o importach tylko do ponownego sprawdzenia tych funkcji, które naprawdę używają zmienionej funkcji. Zazwyczaj przy takim podejściu trzeba sprawdzać tylko niewielką liczbę funkcji.

Wdrożenie tego wszystkiego było trudnym zadaniem, ponieważ początkowa implementacja mypy była mocno skierowana na przetwarzanie jednego pliku naraz. Musieliśmy zmierzyć się z wieloma sytuacjami granicznymi, których wystąpienie wymagało ponownych weryfikacji, gdy w kodzie coś się zmieniało. Na przykład, dzieje się tak, gdy klasie przypisywana jest nowa klasa bazowa. Po tym, jak zrealizowaliśmy to, co chcieliśmy, zdołaliśmy skrócić czas wykonywania większości inkrementalnych kontroli do kilku sekund. Uważaliśmy to za duży sukces.

Jeszcze więcej wydajności!

Razem z zdalnym buforowaniem, o którym mówiłem wcześniej, demon mypy prawie całkowicie rozwiązał problemy, które pojawiały się, gdy programista często uruchamia kontrolę typów, wprowadzając zmiany w niewielkiej liczbie plików. Niemniej jednak wydajność systemu w najmniej sprzyjających warunkach użytkowania była wciąż daleka od optymalnej. Czyste uruchomienie mypy mogło zająć ponad 15 minut. A to było znacznie więcej, niż by nas satysfakcjonowało. Sytuacja co tydzień stawała się coraz gorsza, ponieważ programiści nadal pisali nowy kod i dodawali adnotacje do istniejącego kodu. Nasi użytkownicy wciąż pragnęli większej wydajności, a my chętnie staraliśmy się im w tym pomóc.

Postanowiliśmy wrócić do jednego z wcześniejszych pomysłów dotyczących mypy. A mianowicie — do przekształcania kodu Pythona w kod C. Eksperymenty z Cythonem (systemem, który umożliwia tłumaczenie kodu napisanego w Pythonie na kod C) nie przyniosły widocznego przyspieszenia, więc postanowiliśmy wskrzesić pomysł stworzenia własnego kompilatora. Ponieważ baza kodu mypy (napisana w Pythonie) już zawierała wszystkie niezbędne adnotacje typów, wydawało się to wartą próbą wykorzystania tych adnotacji do zwiększenia wydajności systemu. Szybko stworzyłem prototyp w celu przetestowania tego pomysłu. Wykazał on na różnych mikro-benchmarkach ponad 10-krotne zwiększenie wydajności. Naszym pomysłem było kompilowanie modułów Pythona do modułów C za pomocą Cythona oraz przekształcanie adnotacji typów w kontrole typów wykonywane w czasie działania programu (zwykle adnotacje typów są ignorowane podczas wykonywania programów i używane tylko przez systemy do sprawdzania typów). W rzeczywistości planowaliśmy przetłumaczyć implementację mypy z Pythona na język, który byłby stworzony statycznie typowany i wyglądał (a w większości przypadków działał) dokładnie tak, jak Python. (Ten rodzaj międzyjęzykowej migracji stał się czymś w rodzaju tradycji projektu mypy. Pierwsza implementacja mypy została napisana w Alore, potem był to hybrydowy składnia Javy i Pythona).

Ukierunkowanie na API rozszerzeń CPython było kluczem do zachowania możliwości zarządzania projektem. Nie musieliśmy implementować maszyny wirtualnej ani jakichkolwiek bibliotek, z których korzystał mypy. Ponadto wciąż mielibyśmy dostęp do całego ekosystemu Pythona, wszystkie narzędzia (takie jak pytest) byłyby dostępne. Oznaczało to, że moglibyśmy kontynuować korzystanie z interpretowanego kodu Pythona w trakcie rozwoju, co pozwoliłoby nam na dalszą pracę, korzystając z bardzo szybkiego schematu wprowadzania poprawek do kodu i jego testowania, zamiast czekać na kompilację kodu. Wyglądało to tak, jakbyśmy świetnie sobie radzili, można powiedzieć, że siedzimy na dwóch krzesłach, i nam się to podobało.

Kompilator, który nazwaliśmy mypyc (ponieważ wykorzystuje mypy do analizy typów jako frontend), okazał się bardzo udanym projektem. Ogólnie rzecz biorąc, osiągnęliśmy około czterokrotnego przyspieszenia w przypadku częstych uruchomień mypy bez użycia pamięci podręcznej. Rozwój rdzenia projektu mypyc zajął małemu zespołowi, w skład którego wchodzili Michael Sullivan, Ivan Levkivskiy, Hugh Han i ja, około 4 miesięcy kalendarzowych. Ten zakres pracy był znacznie mniej rozległy niż ten, który byłby potrzebny do przepisywania mypy, na przykład w C++ lub Go. A dodatkowo wprowadziliśmy do projektu znacznie mniej zmian, niż byłoby konieczne w przypadku przepisywania go w innym języku. Mieliśmy także nadzieję, że uda nam się doprowadzić mypyc do takiego poziomu, aby mogli z niego korzystać inni programiści z Dropbox, w celu kompilacji i przyspieszania swojego kodu.

Aby osiągnąć taki poziom wydajności, musieliśmy zastosować kilka interesujących rozwiązań inżynieryjnych. Kompilator może przyspieszyć wykonanie wielu operacji dzięki zastosowaniu szybkich, niskopoziomowych konstrukcji C. Na przykład, wywołanie skompilowanej funkcji jest przekształcane w wywołanie funkcji C. Takie wywołanie jest znacznie szybsze od wywołania funkcji interpretowanej. Niektóre operacje, takie jak wyszukiwanie w słownikach, nadal polegały na używaniu zwykłych wywołań C-API z CPython, które po skompilowaniu były tylko nieco szybsze. Udało nam się pozbyć dodatkowego obciążenia systemu spowodowanego interpretacją, ale w tym przypadku przyniosło to tylko niewielkie korzyści w zakresie wydajności.

Aby zidentyfikować najczęściej występujące operacje 'wolne', wykonaliśmy profilowanie kodu. Wyposażeni w uzyskane dane, próbowaliśmy либо dostosować mypyc, aby generował szybszy kod C dla takich operacji, либо przepisać odpowiedni kod Python, używając szybszych operacji (a czasami po prostu nie mieliśmy wystarczająco prostego rozwiązania dla danego problemu). Przepisywanie kodu Python często okazywało się łatwiejszym rozwiązaniem problemu niż wdrożenie automatycznego wykonania tej samej transformacji w kompilatorze. W perspektywie długoterminowej chcieliśmy zautomatyzować wiele z tych transformacji, ale w tamtym momencie koncentrowaliśmy się na tym, aby, wkładając minimalny wysiłek, przyspieszyć mypy. I dążąc do tego celu, zredukowaliśmy kilka rogów.

Ciąg dalszy nastąpi…

Drodzy Czytelnicy! Jakie wrażenia wywarł na Tobie projekt mypy w momencie, gdy dowiedziałeś się o jego istnieniu?

Droga do weryfikacji typów 4 milionów linii kodu Python. Część 2
Droga do weryfikacji typów 4 milionów linii kodu Python. Część 2

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster