Peephole optimizer CPython skanuje bajtcode w poszukiwaniu nieosiągalnych bloków - sekwencji instrukcji następujących po bezwarunkowym skoku (JUMP_ABSOLUTE, JUMP_FORWARD, RETURN_VALUE, RAISE_VARARGS), które nie mają żadnych punktów wejścia z innych gałęzi. Po zidentyfikowaniu usuwa te martwe instrukcje, aby zmniejszyć nacisk na pamięć podręczną i poprawić gęstość instrukcji.
Ponieważ tabele obsługi wyjątków Pythona, konstrukcje pętli oraz skoki warunkowe przechowują lokalizacje docelowe jako absolutne przesunięcia bajtów w sekwencji co_code obiektu kodu, optymalizator musi skonstruować mapę relocacyjną, która śledzi, ile bajtów zostało usuniętych przed każdą pozostałą instrukcją. Następnie przegląda wszystkie instrukcje skoku i zakresy obsługi wyjątków, dostosowując ich docelowe przesunięcia poprzez odjęcie skumulowanej liczby usuniętych bajtów w docelowej pozycji. Zapewnia to, że bloki SETUP_FINALLY, pętle FOR_ITER oraz skoki zdefiniowane przez użytkownika trafiają na poprawny opcode, nawet po skompaktowaniu poprzedzającego bajtcode.
Zespół ds. przesyłu danych zauważył, że skrypt uruchamiający ich narzędzie ETL zawierał rozbudowane bloki logowania debugowania chronione flagami if DEBUG:, gdzie DEBUG był stałą na poziomie modułu ustawioną na False. Pomimo tego, że warunek był statycznie fałszywy, skompilowany bajtcode nadal zawierał logikę logowania po kompilacji, zwiększając rozmiar pliku .pyc o 40% i nieznacznie pogarszając lokalność pamięci podręcznej instrukcji na serwerach produkcyjnych.
Rozważali trzy różne podejścia.
Po pierwsze, rozważali użycie preprocesora C lub szablonowania Jinja2, aby usunąć kod debugowania przed wdrożeniem. To podejście zagwarantowałoby zerowy bajtcode debugowania w produkcji, ale wprowadziłoby skomplikowane zależności w kroku build i narażałoby na subtelne różnice między kodami rozwoju i produkcji, co utrudniałoby debugowanie problemów produkcyjnych, gdzie kod źródłowy nie odpowiadał już uruchamianemu bajtcode.
Po drugie, ocenili przekształcenie wszystkich bloków debugowania w osobne funkcje w podmodule, mając nadzieję, że niezaładowane funkcje nie zostaną załadowane. Jednak system importu Pythona kompiluje całe moduły na raz, a niezaładowane funkcje pozostają jako obiekty kodu w słowniku modułu; peephole optimizer nie wykonuje eliminacji martwego kodu międzyproceduralnego, więc rozmiar bajtcode pozostał niezmieniony.
Po trzecie, zbadali potok kompilacji CPython i odkryli, że peephole optimizer automatycznie usuwa kod po konstrukcjach if False:, ponieważ kompilator generuje bezwarunkowy skok wokół bloku, a krok peephole usuwa nieosiągalny ogon. Weryfikując za pomocą modułu dis, że RETURN_VALUE lub JUMP_FORWARD nie były następnie śledzone przez żaden martwy kod, potwierdzili, że optymalizacja była aktywna. Postanowili polegać na tym wbudowanym mechanizmie, zapewniając, że DEBUG było dosłownym False, co zmniejszyło rozmiar skompilowanego bajtcode o 35% bez dodatkowego narzędzia.
Dlaczego peephole optimizer odmawia usunięcia nieosiągalnego kodu, gdy poprzedni cel skoku jest adresowany przez instrukcję skoku obliczaną?
Obliczone skoki określają swoje przeznaczenie w czasie wykonywania na podstawie wartości na stosie, takich jak w instrukcjach MATCH lub wzorcach dynamicznego przesyłania. Ponieważ optymalizator nie może statycznie wiedzieć, które przesunięcia mogą być ukierunkowane, musi ostrożnie założyć, że każda instrukcja mogła by być punktem wejścia. Dlatego usuwa tylko kod, który jest wykazany jako nieosiągalny za pomocą analizy statycznej bezwarunkowych skoków i grafów przepływu kontroli, zachowując każdy blok, który może być celem dynamicznego przesyłania, aby zapobiec niezdefiniowanemu zachowaniu.
Jak optymalizator radzi sobie z tabelami obsługi wyjątków (co_exceptiontable) przy usuwaniu instrukcji NOP używanych jako zamienniki skoków?
Gdy kompilator generuje skoki do lokalizacji do przodu, które nie są jeszcze znane, często emituje instrukcje NOP (brak operacji) jako zamienniki lub wypełnienie, a następnie koryguje cele skoków później. Podczas optymalizacji peephole te NOP są usuwane, aby zaoszczędzić miejsce. Optymalizator utrzymuje dwukierunkowe mapowanie między oryginalnymi a końcowymi przesunięciami. Podczas przetwarzania tabeli wyjątku - która przechowuje przesunięcia start, end i handler dla bloków try/except - stosuje skumulowany delta usuniętych bajtów do każdego wpisu. Jeśli NOP znajduje się w zakresie wyjątku, jego usunięcie przesuwa przesunięcie end w lewo, zapewniając, że chroniony zakres bajtcode pozostaje dokładny, a wyjątki są łapane w odpowiednich granicach.
Co uniemożliwia peephole optimizerowi reorganizację niezależnych instrukcji w celu poprawy wydajności potoku, jak to widać w kompilatorach C?
Bajtcode Pythona jest ściśle powiązany z semantyką stosu oceny i tabelami numerów wierszy używanymi do generowania tracebacków. Reorganizacja instrukcji - na przykład przesunięcie LOAD_CONST przed LOAD_NAME - mogłaby zmienić stan stosu, gdy występuje wyjątek, zmieniając zgłaszany numer wiersza w tracebackach lub łamiąc invariancy głębokości stosu wymagane przez pętlę interpretacyjną. Dodatkowo, ponieważ Python pozwala na introspekcję obiektów ramkowych i f_lasti (wskaźnik instrukcji), dowolna reorganizacja mogłaby złamać debugery i profilery, które polegają na deterministycznym mapowaniu przesunięcia do źródła. Dlatego optymalizator jest ograniczony do usuwania nieosiągalnego kodu i przekierowywania skoków, nie zmieniając względnego porządku wykonalnych instrukcji.