Mateusz Mazurek – programista z pasją

Python, architektura, ciekawostki ze świata IT

Inżynieria oprogramowania Programowanie

Just-in-time compiler (JIT) w Pythonie

Cześć!

W Pythonie 3.13 dodano JITa!

JIT, czyli just-in-time compiler to optymalizacja na która Python naprawdę długo czekał. Na nasze szczęście doczekał się i w tym artykule trochę się nim pobawimy. Żeby jednak móc przejść dalej, zastanówmy się najpierw w jaki sposób Python uruchamia nasze programy.

W jaki sposób Python uruchamia nasze programy?

Przyjęło się uznawać, że Python jest językiem interpretowanym. Co nie do końca jest prawdą. Ba, w zdaniu „Python jest językiem interpretowanym” są dwa spore skróty myślowe, spróbujmy więc rozprawić się z nimi. Python jest językiem programowania, to nie ulega wątpliwości… Prawda? Czym jest w takim razie język programowania? Posłużmy się definicją z wikipedii:

Podobnie jak języki naturalne, język programowania składa się ze zbiorów reguł syntaktycznych oraz semantyki, które opisują, jak należy budować poprawne wyrażenia oraz jak komputer ma je rozumieć. Wiele języków programowania posiada pisemną specyfikację swojej składni oraz semantyki, lecz inne zdefiniowane są jedynie przez oficjalne implementacje.

Skoro język programowania to zbiór zasad, to jak możemy mówić, że zbiór zasad jest interpretowany albo kompilowany? Tu właśnie mamy pierwszy skrót myślowy. Pisząc „Python jest językiem interpretowanym” nie mamy na myśli Pythona jako języka a jego konkretną implementację czyli CPythona. CPython to implementacja zbioru reguł, które składają się na język Python, w języku C. CPython to oczywiście nie jedyna implementacja Pythona, mamy np. implementację w Javie czy w .NET, ale CPython jest najpopularniejszą, wzorcową implementacją.

Czyli pierwszy skrót myślowy za nami, poprawiona wersja omawianego przeze mnie zdania powinna brzmieć „CPython jest interpretowaną implementacją Pythona„. Przejdźmy do drugiego skrótu myślowego. CPython nie jest implementacją interpretowaną. A przynajmniej nie jest to czysta interpretacja. Gdzie w takim razie mamy kompilację? Uruchommy taki kawałek kodu:

1
2
3
4
5
6
7
import dis

def test(var: str) -> str:
    return f"test {var}"


dis.dis(test)

Jego wynik to:

  4           RESUME                   0

5 LOAD_CONST 1 ('test ')
LOAD_FAST 0 (var)
FORMAT_SIMPLE
BUILD_STRING 2
RETURN_VALUE

To co widzimy to bytecode, czyli język pośredni CPythona, który powstaje w wyniku… Kompilacji. Zastanawiałeś się kiedyś, czym jest folder __pycache__ tworzący się w folderze z plikami Pythona? To po prostu folder, gdzie Python zapisuje bytecode już skompilowanych modułów, żeby nie kompilować tego samego kolejny raz. Czyli taki cache. Stąd nazwa __pycache__. Folder jest tworzony w momencie pierwszego importu.

Bytecode nie jest jeszcze kodem który będzie uruchamiany na procesorze komputera a dopiero będzie interpretowany przez maszynę wirtualną Pythona (PVM) i właśnie w tym momencie mamy interpretację. Więc pełna prawda o naturze CPythona jest taka, że jest on językiem hybrydowym.

No i jeszcze żeby postawić kropkę, odpowiedzmy na pytanie: po co te himalajskie kombinacje? Ano po to, żeby móc optymalizować. Już na etapie kompilacji bytecode’u zachodzą optymalizacje, które znacznie przyśpieszają nasze programy.

Czy te skróty myślowe to problem? Nie sądzę, bo każdy kto potrzebuje wiedzieć więcej i tak wie, więc nie ma sensu komplikować prostoty przekazu.

Co się zmieniło w ostatnich wersjach Pythona?

Czyli jeszcze nie o JITcie, który wszedł w Pythonie 3.13 a o wersji nieco starszej, czyli 3.11. Ta wersja jest o tyle istotna, że wprowadziła coś takiego co zostało nazwane „Specializing Adaptive Interpreter„.

Z poprzedniego akapitu wiemy, że bytecode jest interpretowany przez PVM. Zmiana, która nastąpiła w wersji 3.11 sprawia, że interpreter potrafi dostosować bytecode do najczęściej występujących przypadków w kodzie. W praktyce oznacza, to, że „nauczono” interpreter by ten optymalizował najczęściej uruchamiane fragmenty kodu, korzystając z kontekstu jego wykorzystania. Brzmi zawile ale sama idea jest dość prosta.

Załóżmy, że mamy taki kod:

1
2
3
4
5
6
import dis

def add(a, b):
    return a + b

dis.dis(add, adaptive=True)

Po uruchomieniu go w Pythonie co najmniej 3.11, dostaniemy:

  3           0 RESUME_QUICK             0

4 2 LOAD_FAST__LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+) <----
10 RETURN_VALUE

Opcode BINARY_OP nie jest operatorem specjalizowanym, tzn że musi dbać o obsługę wszystkich typów.

Co się stanie, jeśli zmienimy nasz kod w taki sposób:

1
2
3
4
5
6
7
8
9
import dis

def add(a, b):
    return a + b

for _ in range(20):
    add(2, 3)

dis.dis(add, adaptive=True)

W tym przypadku mamy tę samą funkcję, ale cały program został wzbogacony o kontekst. To co dopisaliśmy to użycie w pętli funkcji z argumentami typu int, co sprawi, że bytecode będzie wyglądał tak:

  3           0 RESUME_QUICK             0

4 2 LOAD_FAST__LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP_ADD_INT 0 (+) <----
10 RETURN_VALUE

Czyli już na poziomie budowanie bytecode’u jest robiona specjalizacja w oparciu o kontekst. Mówiąc prościej, Python zauważył, że dodawanie jest robione tylko z intami, więc nie ma sensu używać ogólnych metod, które będą musiały określić typ a można zrobić to już teraz.

I tę optymalizację nazywany Tier 1.

Już prawie JIT

Często wykonywane kawałki kodu są kompilowane do „intermediate representation (IR)”. Takie fragmenty są interpretowane przez tę samą maszynę co reszta kodu, ale ich format jest lepiej przystosowane do późniejszego wykonywania, co sprawia, że są wykonywane szybciej. Czemu w takim razie nie robić tego dla całego kodu? Bo ten proces nie jest darmowy, czas na przekształcenie do IR nie jest pomijalny, więc nie warto tego robić dla kodu, który jest wykonywany rzadko, ale warto to robić dla najczęściej używanych fragmentów.

Ten etap nazywamy Tier 2.

No i w końcu JIT

Popatrzmy na chwilę na język C. Posiada on kompilator typu AOT (Ahead of Time), który po prostu kompiluje wszystko naraz i tak skompilowane binarki są dystrybuowane do użytkowników. Problem z tym podejściem jest taki, że skoro kompilacja następuje na konkretnym systemie i na konkretnym procesorze, to ona będzie działać tylko i wyłącznie na takim środowisku (lub oczywiście innym, kompatybilnym), a więc jeśli skompilujesz coś na Linuxie to nie uruchomisz tego na Windowsie. A co jeśli nie ma binarek pod Twoje środowisko? No to musisz to kompilować ręcznie a ten kto musiał kompilować coś bardziej zaawansowanego, ten się w cyrku nie śmieje.

Wracając do Pythona, Interpretacja bytecode’u jest dość intuicyjna: bierzesz konkretny opcode (czyli np ten nasz BINARY_OP_ADD_INT) i go wykonujesz. I tu znów, mimo wcześniejszych optymalizacji, następuję interpretacja tych samych bytecode’ów. Zasadne więc jest pytanie: czy naprawdę tak trzeba? No nie trzeba, i tu cały na biało, wkracza JIT. On po prostu kompiluje najczęściej wykonywane fragmenty kodu do już docelowego kodu maszynowego i je wykonuje za każdym razem, zamiast tak jak wcześniej, interpretować je linijka po linijce.

JIT jest połączeniem tego co dotychczas było w Pythonie i podejścia stosowanego w języku C. Skompilowane JITem fragmenty kodu Pythona są przechowywane w pamięci programu, więc problem różnych środowisk nie istnieje a sam kod, nadal jest w pełni przenośny i nie wymusza mozolnej rekompilacji.

To co tu opisuję oczywiście wydaje się być mega proste, ale w istocie nie jest proste ani trochę. Tutaj jest PR ze zmianami wprowadzającymi JITa. Zawierał on 459 commitów. Nie zazdroszczę osobom, które robiły review. Nie mniej jednak jest to ogromny krok w dobrą stronę.

Pobawmy się w końcu JITem

JIT domyślnie jest wyłączony w Pythonie 3.13 a żeby go włączyć, trzeba skompilować (hyhy) Pythona z odpowiednią opcją konfiguracyjną, najłatwiej zrobić to poprzez liba pyenv, poleceniem:

PYTHON_CONFIGURE_OPTS="--enable-experimental-jit=yes-off" pyenv install 3.13

Flaga „enable-experimental-jit” może przyjmować kilka wartości, zainteresowanie na pewno sobie to wygooglują, ja wybrałem „yes-off”, która pozwala na skompilowanie Pythona z JITem, ale włączenie go jest sterowane zmienną środowiskową PYTHON_JIT.

No dobra, uruchommy coś. Np taki kawałek kodu:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import time


def process_data(n):
    data = [i for i in range(n)]  # Tworzymy dużą listę liczb
    result = []
    for num in data:
        if num % 2 == 0:
            result.append(str(num))  # Konwersja do stringa, dodawanie do listy
    return result

N = 10**6  # Duża liczba elementów

start = time.perf_counter()
output = process_data(N)
end = time.perf_counter()


print(f"Przetworzono {len(output)} elementów w czasie: {end - start:.4f} sekundy")

Najpierw efekt bez JITa:

Przetworzono 500000 elementów w czasie: 0.1633 sekundy

I teraz z JITem, poprzedzając „python nazwa_pliku.py” jeszcze zmienną środowiskową PYTHON_JIT=1, co daje efekt:

Przetworzono 500000 elementów w czasie: 0.1430 sekundy

Co daje zysk szybkości około 13,5%.

Czy tylko CPython ma JITa?

Ano nie. Najpopularniejsza implementacja Pythona, która od dawna ma JITa to Pypy. Co ciekawe jest to implementacja Pythona w… Pythonie! A dokładniej w RPythonie (Restricted Python), który nakłada szereg ograniczeń na kod, jednocześnie będąc kompatybilnym z samym Pythonem. Każdy program napisany w RPython jest zarazem poprawnym programem w zwykłym Pythonie. Ale wracając do Pypy, możemy sobie go zainstalować za pomocą pyenv:

pyenv install pypy3.10-7.3.17

A uruchomienie programu z poprzedniego akapitu daje wynik:

Przetworzono 500000 elementów w czasie: 0.0666 sekundy

Nie da się ukryć, że jest kapkę szybszy. Ale miałbym spore wątpliwości co do użycia go produkcyjnie. Tak czy siak, ciekawe, prawda?

Kończąc

Oczywiście mój test „szybkości” w żaden sposób nie jest miarodajny a sam autor deklaruje średni zysk pomiędzy 2% a 9%. Czy to dużo? Nie bardzo. Natomiast aktualna implementacja jest implementacją eksperymentalną, więc na bank będzie rozbudowywana, a poza tym, wprowadzenie JITa umożliwia rozpoczęcie prac nad innymi optymalizacjami, więc mówiąc krótko: nie zostało powiedziane jeszcze ostatnie słowo.

Wzrostu szybkości możemy oczekiwać wszędzie tam, gdzie jest dużo często używanego kodu. Czyli w sumie wszędzie. Pomyśl o wszystkich warstwach abstrakcji przez które przechodzi Twój kod. A potem pomyśl o wszystkich warstwach abstrakcji które są używane przez zależności Twojego projektu. A potem przez zależności tych zależności… I już pewnie zauważyłeś jaki potencjał w tym drzemie. Świetlana przyszłość przed nami.

Dzięki za wizytę,
Mateusz Mazurek

A może wolisz nowości na mail?

ZOSTAW ODPOWIEDŹ

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

This site uses Akismet to reduce spam. Learn how your comment data is processed.