2021-12-31 / Bartłomiej Kurek
Lavka - mini benchmark tool

Spędziłem dzisiaj trochę czasu na pomiarach prędkości serializacji oraz konstrukcji obiektów w Python.
Ostatnio taki pomiar wykonywałem 2 lata temu. Wtedy dataclasses mocno odstawały od zwykłych klas, jednak przez ten okres wiele się zmieniło. Odtwarzając pomiary przy okazji stworzyłem małe narzędzie, które okazało się dosyć pomocne. Program uruchamia pomiary i generuje wykresy używając matplotlib. Tylko tyle i aż tyle.

Lavka

Program jest obecnie jednoplikowy i zawiera jedynie niezbędne minimum. Kod można znaleźć w repozytorium git. Nie posiada jeszcze żadnych testów. Prawdopodobnym kierunkiem rozwinięcia programu będzie uruchamianie i pomiar innych programów/procesów. Output jest tekstowy, ale progam tworzy też wykresy. Output powinien być "w sam raz na tego bloga", zatem ten post to test użyteczności tego minimalnego programu.

Oczywiście istnieją inne, gotowe i sprawdzone rozwiązania do pomiarów, które mają mnóstwo funkcji oraz spełniają wszelkie wymagania i scenariusze, zapewniają obsługę pluginów, itd. Programming is fun. Być może ta minimalistyczna implementacja będzie miała jakąś ciekawą kontynuację.

Przykład

Testowałem zwykłe klasy vs dataclasses vs modele pydantic. Wklejam tutaj jedynie fragment poglądowo, aby wiadomo było jaka była ogólna struktura. Kod tego mini benchmarku jest dostępny w katalogu examples w repozytorium "ławki".

class PlainClass:
    def __init__(self, **kwargs):
        self.my_int = kwargs.pop("my_int", DATA["my_int"])
        self.my_str = kwargs.pop("my_str", DATA["my_str"])
        self.my_list = kwargs.pop("my_list", DATA["my_list"])
        self.my_dict = kwargs.pop("my_dict", DATA["my_dict"])


class PydanticClass(pydantic.BaseModel):
    my_int: int = pydantic.Field(default_factory=lambda: DATA["my_int"])
    my_str: str = pydantic.Field(default_factory=lambda: DATA["my_str"])
    my_list: list = pydantic.Field(default_factory=lambda: DATA["my_list"])
    my_dict: dict = pydantic.Field(default_factory=lambda: DATA["my_dict"])

Kod pisze się bardzo prosto - tworzę obiekt Benchmark(), dodaję grupy pomiarów, a w każdej grupie dodaję konkretne przypadki. Poglądowy fragment:

    from lavka import Benchmark


    types = [PlainClass, DataClass, PydanticClass]
    serializers = [json, pickle, marshal]

    b = Benchmark()
    grp_init = b.add_group("initialization")
    [
        grp_init.add_case(f, kwargs=DATA, identifier=f.__qualname__)
        for f in types
    ]

    # ...

    grp_serializers = b.add_group("serializers")
    [
        grp_serializers.add_case(f.dumps, args=(DATA,), identifier=f.__name__)
        for f in serializers
    ]

    # ...

    parser = b.create_parser()
    args = parser.parse_args()
    b(args)

Benchmark: klasy, serializacja

Poniżej wyniki pomiaru prędkości tworzenia obiektów zwykłych klas, dataclasses oraz pydantic. Wyniki te dzisiaj mnie mocno zaskoczyły. Dataclasses zostały mocno zoptymalizowane (sloty...).
Pomiary zawierają też porównanie prędkości serializacji/deserializacji modułami json/pickle/marshal. W tym przypadku serializery działają na stałych danych.

$ PYTHONPATH=. python examples/basic.py -t 32768 -x 2048
--------------------------------------------------------------------------------
→ initialization
   →               PlainClass    32768 0.06454394
   →                DataClass    32768 0.05464416
   →            PydanticClass    32768 0.23923927
--------------------------------------------------------------------------------
→ init (defaults)
   →               PlainClass    32768 0.06401129
   →                DataClass    32768 0.05251234
   →            PydanticClass    32768 0.24309676
--------------------------------------------------------------------------------
→ serializers
   →                     json    32768 10.67612974
   →                   pickle    32768 1.24846387
   →                  marshal    32768 1.96574905
--------------------------------------------------------------------------------
→ deserializers
   →                     json    32768 10.04917338
   →                   pickle    32768 2.96969549
   →                  marshal    32768 2.79492076
================================================================================
RESULTS
================================================================================
GROUP               | CASE                | N_TIMES | TOTAL TIME  | N/SEC
--------------------------------------------------------------------------------
initialization      | DataClass           | 32768   | 0.05464416  | 599661.51
initialization      | PlainClass          | 32768   | 0.06454394  | 507685.15
initialization      | PydanticClass       | 32768   | 0.23923927  | 136967.48
--------------------------------------------------------------------------------
init (defaults)     | DataClass           | 32768   | 0.05251234  | 624005.71
init (defaults)     | PlainClass          | 32768   | 0.06401129  | 511909.7
init (defaults)     | PydanticClass       | 32768   | 0.24309676  | 134794.06
--------------------------------------------------------------------------------
serializers         | pickle              | 32768   | 1.24846387  | 26246.65
serializers         | marshal             | 32768   | 1.96574905  | 16669.47
serializers         | json                | 32768   | 10.67612974 | 3069.28
--------------------------------------------------------------------------------
deserializers       | marshal             | 32768   | 2.79492076  | 11724.12
deserializers       | pickle              | 32768   | 2.96969549  | 11034.13
deserializers       | json                | 32768   | 10.04917338 | 3260.77
--------------------------------------------------------------------------------
PLOT initialization /tmp/benchmark-plots/initialization.png
PLOT init (defaults) /tmp/benchmark-plots/init-(defaults).png
PLOT serializers /tmp/benchmark-plots/serializers.png
PLOT deserializers /tmp/benchmark-plots/deserializers.png

Output się mieści, wygląda ok... czas sprawdzić pierwsze obrazki.

Wykresy

Peaki widoczne na wykresach to skoki różnicy czasu pomiędzy poszczególnymi przebiegami pomiaru. Program wykonywał każdy przypadek (funkcję) 32k razy, a w trakcie zbierał dane dla 2k równomiernych przedziałów. Wykres obrazuje ogólny trend, wyliczane były różnice prędkości przebiegów pomiędzy kolejnymi odstępami czasu. Wykresy wymagają kilku usprawnień, ale zauważyć można, iż modele pydantic są niestety wolniejsze.

Inicjalizacja

img-border

Inicjalizacja, wartości domyślne

Pydantic nieco odstaje, gdyż implementuje swoją abstrakcję Field, w której dzieje się wiele rzeczy. Jest to bardzo użyteczny moduł, ktory zapewnia wygodną walidację danych. Używany jest m.in. w FastAPI. Przy niewielkiej liczbie obiektów problemu żadnego nie zauważymy, jednak w systemie kolejkowym - zależnie od wymagań - spadek wydajności może mieć większe znaczenie.

img-border

Serializacja

To oryginalny problem mojego pomiaru. Pickle jest najszybszy, ale za to ma dwie kluczowe wady:

  • to format zgodny jedynie z Python
  • deserializacja jest niebezpieczna (tutaj filmik)

JSON jest wygodny i najbardziej przenośny, ale za to najwolniejszy.

img-border

Deserializacja

img-border