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
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.
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.