Ścieżka do sprawdzania typów 4 milionów linii kodu Pythona. Część 2

Dziś publikujemy drugą część tłumaczenia materiału o tym jak Dropbox zorganizował kontrolę typów dla kilku milionów linii kodu Pythona.

Ścieżka do sprawdzania typów 4 milionów linii kodu Pythona. Część 2

Przeczytaj część pierwszą

Oficjalna obsługa typów (PEP 484)

Nasze pierwsze poważne eksperymenty z mypy przeprowadziliśmy w Dropbox podczas Hack Week 2014. Hack Week to tygodniowe wydarzenie organizowane przez Dropbox. W tym czasie pracownicy mogą pracować nad czym chcą! Niektóre z najsłynniejszych projektów technologicznych Dropbox rozpoczęły się podczas takich wydarzeń. W wyniku tego eksperymentu doszliśmy do wniosku, że mypy wygląda obiecująco, chociaż projekt nie jest jeszcze gotowy do powszechnego użycia.

W tamtym czasie w powietrzu wisiał pomysł standaryzacji systemów podpowiedzi typu Python. Jak mówiłem, od wersji Python 3.0 możliwe było używanie adnotacji typów dla funkcji, ale były to tylko dowolne wyrażenia, bez zdefiniowanej składni i semantyki. Podczas wykonywania programu te adnotacje były w większości po prostu ignorowane. Po Tygodniu Hackowania rozpoczęliśmy pracę nad standaryzacją semantyki. Ta praca doprowadziła do powstania PEP 484 (Guido van Rossum, Łukasz Langa i ja współpracowaliśmy przy tym dokumencie).

Na nasze motywy można spojrzeć z dwóch stron. Po pierwsze, mieliśmy nadzieję, że cały ekosystem Pythona może przyjąć wspólne podejście do używania wskazówek dotyczących typów (termin używany w Pythonie jako odpowiednik „adnotacji typów”). Biorąc pod uwagę możliwe ryzyko, byłoby to lepsze niż stosowanie wielu wzajemnie niezgodnych podejść. Po drugie, chcieliśmy otwarcie omówić mechanizmy adnotacji typów z wieloma członkami społeczności Pythona. Pragnienie to było częściowo podyktowane faktem, że nie chcieliśmy wyglądać na „odstępców” od podstawowych idei języka w oczach szerokich mas programistów Pythona. Jest to język z dynamicznym pisaniem, znany jako „pisanie na klawiaturze”. W społeczności na samym początku nie mogło nie pojawić się nieco podejrzliwe podejście do idei statycznego pisania. Jednak to nastawienie ostatecznie osłabło, gdy stało się jasne, że pisanie statyczne nie będzie obowiązkowe (i kiedy ludzie zdali sobie sprawę, że jest to naprawdę przydatne).

Ostatecznie przyjęta składnia podpowiedzi typu była bardzo podobna do składni obsługiwanej wówczas przez mypy. PEP 484 został wydany z Pythonem 3.5 w 2015 roku. Python nie był już językiem dynamicznie typowanym. Lubię myśleć o tym wydarzeniu jako o kamieniu milowym w historii Pythona.

Rozpoczęcie migracji

Pod koniec 2015 roku Dropbox stworzył trzyosobowy zespół do pracy nad mypy. Byli wśród nich Guido van Rossum, Greg Price i David Fisher. Od tego momentu sytuacja zaczęła się rozwijać niezwykle szybko. Pierwszą przeszkodą w rozwoju mypy była wydajność. Jak wspomniałem powyżej, na początku projektu myślałem o przetłumaczeniu implementacji mypy na C, ale na razie ten pomysł został skreślony z listy. Utknęliśmy przy uruchamianiu systemu przy użyciu interpretera CPython, który nie jest wystarczająco szybki dla narzędzi takich jak mypy. (Projekt PyPy, alternatywna implementacja Pythona z kompilatorem JIT, również nam nie pomógł.)

Na szczęście z pomocą przyszły nam tutaj pewne ulepszenia algorytmiczne. Pierwszym potężnym „akceleratorem” było wdrożenie sprawdzania przyrostowego. Pomysł na to ulepszenie był prosty: jeśli wszystkie zależności modułu nie uległy zmianie od poprzedniego uruchomienia mypy, to możemy wykorzystać dane zbuforowane podczas poprzedniego uruchomienia podczas pracy z zależnościami. Musieliśmy jedynie sprawdzić typ zmodyfikowanych plików i plików od nich zależnych. Mypy poszedł nawet trochę dalej: jeśli zewnętrzny interfejs modułu się nie zmienił, mypy zakładał, że inne moduły, które zaimportowały ten moduł, nie muszą być ponownie sprawdzane.

Sprawdzanie przyrostowe bardzo nam pomogło podczas opisywania dużych ilości istniejącego kodu. Rzecz w tym, że proces ten zwykle obejmuje wiele iteracyjnych uruchomień mypy, w miarę jak adnotacje są stopniowo dodawane do kodu i stopniowo ulepszane. Pierwsze uruchomienie mypy było nadal bardzo powolne, ponieważ zawierało wiele zależności do sprawdzenia. Następnie, aby poprawić sytuację, wdrożyliśmy mechanizm zdalnego buforowania. Jeśli mypy wykryje, że lokalna pamięć podręczna prawdopodobnie jest nieaktualna, pobiera bieżącą migawkę pamięci podręcznej dla całej bazy kodu z centralnego repozytorium. Następnie przeprowadza kontrolę przyrostową, korzystając z tej migawki. To zrobiło nam kolejny duży krok w kierunku zwiększenia wydajności mypy.

Był to okres szybkiego i naturalnego przyjęcia sprawdzania typów w Dropbox. Do końca 2016 roku mieliśmy już około 420000 XNUMX linii kodu Pythona z adnotacjami typów. Wielu użytkowników było entuzjastycznie nastawionych do sprawdzania typów. Coraz więcej zespołów programistycznych korzystało z mypy Dropbox.

Wszystko wyglądało wtedy dobrze, ale wciąż mieliśmy wiele do zrobienia. Zaczęliśmy przeprowadzać okresowe wewnętrzne badania użytkowników, aby zidentyfikować obszary problemowe projektu i zrozumieć, jakie kwestie wymagają rozwiązania w pierwszej kolejności (ta praktyka jest stosowana w firmie do dziś). Najważniejsze, jak się okazało, były dwa zadania. Po pierwsze, potrzebowaliśmy większego pokrycia typów kodu, po drugie, potrzebowaliśmy mypy, aby działać szybciej. Było całkowicie jasne, że nasza praca nad przyspieszeniem mypy i wdrożeniem jej do projektów firmowych nie została jeszcze ukończona. My, mając pełną świadomość wagi tych dwóch zadań, przystąpiliśmy do ich rozwiązania.

Większa produktywność!

Przyrostowe kontrole sprawiły, że mypy stało się szybsze, ale narzędzie nadal nie było wystarczająco szybkie. Wiele kolejnych kontroli trwało około minuty. Powodem był cykliczny import. Prawdopodobnie nie zaskoczy to nikogo, kto pracował z dużymi bazami kodu napisanymi w Pythonie. Mieliśmy zestawy setek modułów, z których każdy pośrednio importował wszystkie pozostałe. Jeśli jakikolwiek plik w pętli importu został zmieniony, mypy musiał przetworzyć wszystkie pliki w tej pętli, a często także wszystkie moduły, które importowały moduły z tej pętli. Jednym z takich cykli była niesławna „plątanina zależności”, która spowodowała wiele problemów w Dropbox. Gdy struktura ta zawierała kilkaset modułów, importowano bezpośrednio lub pośrednio wiele testów, wykorzystywano ją także w kodzie produkcyjnym.

Rozważaliśmy możliwość „rozwikłania” zależności cyklicznych, ale nie mieliśmy na to zasobów. Było zbyt dużo kodu, którego nie znaliśmy. W rezultacie opracowaliśmy alternatywne podejście. Postanowiliśmy sprawić, że mypy będzie działać szybko, nawet w obecności „splątań zależności”. Osiągnęliśmy ten cel za pomocą demona mypy. Demon to proces serwera, który implementuje dwie interesujące funkcje. Po pierwsze, przechowuje w pamięci informacje o całej bazie kodu. Oznacza to, że za każdym razem, gdy uruchamiasz mypy, nie musisz ładować danych z pamięci podręcznej związanych z tysiącami zaimportowanych zależności. Po drugie, dokładnie, na poziomie małych jednostek strukturalnych, analizuje zależności pomiędzy funkcjami i innymi bytami. Na przykład, jeśli funkcja foo wywołuje funkcję bar, to istnieje zależność foo od bar. Kiedy plik ulega zmianie, demon najpierw w izolacji przetwarza tylko zmieniony plik. Następnie sprawdza widoczne zewnętrznie zmiany w tym pliku, takie jak zmienione podpisy funkcji. Demon wykorzystuje szczegółowe informacje o importach jedynie w celu ponownego sprawdzenia tych funkcji, które faktycznie korzystają ze zmodyfikowanej funkcji. Zwykle przy takim podejściu trzeba sprawdzić bardzo niewiele funkcji.

Zaimplementowanie tego wszystkiego nie było łatwe, ponieważ oryginalna implementacja mypy była mocno skupiona na przetwarzaniu jednego pliku na raz. Mieliśmy do czynienia z wieloma sytuacjami granicznymi, których zaistnienie wymagało wielokrotnych kontroli w przypadku, gdy coś uległo zmianie w kodzie. Dzieje się tak na przykład, gdy klasie zostaje przypisana nowa klasa bazowa. Kiedy już zrobiliśmy to, co chcieliśmy, byliśmy w stanie skrócić czas wykonywania większości kontroli przyrostowych do zaledwie kilku sekund. Wydawało nam się to wielkim zwycięstwem.

Jeszcze większa produktywność!

Razem ze zdalnym buforowaniem, o którym pisałem powyżej, demon mypy prawie całkowicie rozwiązał problemy, które pojawiają się, gdy programista często przeprowadza sprawdzanie typu, wprowadzając zmiany w niewielkiej liczbie plików. Jednak wydajność systemu w najmniej korzystnym przypadku użycia była nadal daleka od optymalnej. Czyste uruchomienie mypy może zająć ponad 15 minut. A to było o wiele więcej, niż bylibyśmy zadowoleni. Z każdym tygodniem sytuacja się pogarszała, ponieważ programiści nadal pisali nowy kod i dodawali adnotacje do istniejącego kodu. Nasi użytkownicy wciąż byli głodni większej wydajności, ale z radością spotkaliśmy się z nimi w połowie drogi.

Postanowiliśmy wrócić do jednego z wcześniejszych pomysłów dotyczących mypy. Mianowicie, aby przekonwertować kod Pythona na kod C. Eksperymenty z Cythonem (systemem pozwalającym na tłumaczenie kodu napisanego w Pythonie na kod C) nie dały nam widocznego przyspieszenia, dlatego postanowiliśmy wskrzesić pomysł napisania własnego kompilatora. Ponieważ baza kodu mypy (napisana w Pythonie) zawierała już wszystkie niezbędne adnotacje typów, pomyśleliśmy, że warto spróbować użyć tych adnotacji w celu przyspieszenia systemu. Szybko stworzyłem prototyp, aby przetestować ten pomysł. Wykazał ponad 10-krotny wzrost wydajności w różnych mikro-benchmarkach. Nasz pomysł polegał na skompilowaniu modułów Pythona do modułów C przy użyciu Cythona i przekształceniu adnotacji typu w sprawdzanie typu w czasie wykonywania (zwykle adnotacje typu są ignorowane w czasie wykonywania i używane tylko przez systemy sprawdzania typu). Właściwie planowaliśmy przetłumaczyć implementację mypy z Pythona na język, który został zaprojektowany do pisania statycznego i który wyglądałby (i w większości działał) dokładnie jak Python. (Ten rodzaj migracji międzyjęzykowej stał się już tradycją projektu mypy. Oryginalna implementacja mypy została napisana w Alore, potem powstała syntaktyczna hybryda Javy i Pythona).

Kluczem do utrzymania możliwości zarządzania projektami było skupienie się na interfejsie API rozszerzenia CPython. Nie musieliśmy wdrażać maszyny wirtualnej ani żadnych bibliotek potrzebnych mypy. Poza tym nadal mielibyśmy dostęp do całego ekosystemu Pythona i wszystkich narzędzi (takich jak pytest). Oznaczało to, że mogliśmy w dalszym ciągu używać zinterpretowanego kodu Pythona podczas programowania, co pozwoliło nam kontynuować pracę z bardzo szybkim wzorcem wprowadzania zmian w kodzie i testowania go, zamiast czekać na kompilację kodu. Wyglądało na to, że świetnie sobie radziliśmy, siedząc na dwóch krzesłach, że tak powiem, i bardzo nam się to podobało.

Kompilator, który nazwaliśmy mypyc (ponieważ wykorzystuje mypy jako interfejs do analizy typów), okazał się bardzo udanym projektem. Ogólnie rzecz biorąc, osiągnęliśmy około 4-krotne przyspieszenie w przypadku częstych uruchomień mypy bez buforowania. Opracowanie rdzenia projektu mypyc zajęło małemu zespołowi składającemu się z Michaela Sullivana, Ivana Levkivsky'ego, Hugh Hahna i mnie około 4 miesięcy kalendarzowych. Ta ilość pracy była znacznie mniejsza niż to, co byłoby potrzebne do przepisania mypy na przykład w C++ lub Go. I musieliśmy wprowadzić znacznie mniej zmian w projekcie, niż musielibyśmy dokonać przepisując go w innym języku. Mieliśmy także nadzieję, że uda nam się doprowadzić mypyc do takiego poziomu, aby inni programiści Dropbox mogli go używać do kompilowania i przyspieszania swojego kodu.

Aby osiągnąć ten poziom wydajności, musieliśmy zastosować kilka ciekawych rozwiązań inżynieryjnych. W ten sposób kompilator może przyspieszyć wiele operacji, używając szybkich, niskiego poziomu konstrukcji C. Na przykład skompilowane wywołanie funkcji jest tłumaczone na wywołanie funkcji C. A takie wywołanie jest znacznie szybsze niż wywołanie interpretowanej funkcji. Niektóre operacje, takie jak wyszukiwanie w słowniku, nadal wymagały użycia zwykłych wywołań C-API z CPythona, które po kompilacji były tylko nieznacznie szybsze. Udało nam się wyeliminować dodatkowe obciążenie systemu spowodowane interpretacją, ale w tym przypadku dało to jedynie niewielki wzrost wydajności.

Aby zidentyfikować najczęstsze „powolne” operacje, przeprowadziliśmy profilowanie kodu. Uzbrojeni w te dane, próbowaliśmy albo ulepszyć mypyc, aby generował szybszy kod C dla takich operacji, albo przepisać odpowiedni kod Pythona, używając szybszych operacji (a czasami po prostu nie mieliśmy wystarczająco prostego rozwiązania tego lub innego problemu). . Przepisanie kodu Pythona było często łatwiejszym rozwiązaniem problemu niż automatyczne wykonanie tej samej transformacji przez kompilator. W dłuższej perspektywie chcieliśmy zautomatyzować wiele z tych transformacji, ale w tamtym czasie skupialiśmy się na przyspieszeniu mypy przy minimalnym wysiłku. Idąc w stronę tego celu, poszliśmy na skróty.

To be continued ...

Drodzy Czytelnicy! Jakie były Twoje wrażenia na temat projektu mypy, gdy dowiedziałeś się o jego istnieniu?

Ścieżka do sprawdzania typów 4 milionów linii kodu Pythona. Część 2
Ścieżka do sprawdzania typów 4 milionów linii kodu Pythona. Część 2

Źródło: www.habr.com

Dodaj komentarz