(20240615) msgspec 및 pydantic_v2 추가 && 라이브러리 최신 버전들로 업데이트
복잡한 모델링을 하다보면 nested model 을 사용하는 일이 왕왕 있다. 가령, 이런 것들이다.
class UserRole(enum.Enum):
Normal = 10
Admin = 20
Owner = 30
class User(Model):
fullname: str
email: str
role: UserRole
class Client(Model):
user: User
last_contact: datetime
client = Client(...)
보통 하나의 파이썬 인터프리터 내부에서만 이런 데이터가 사용된다면, class 생성/접근에서 발생하는 오버해드를 줄이기 위해 NamedTuple
을 사용한다. 그러나, 프로세스 간의 통신이나 HTTP request/response, PubSub 등의 외부 프로토콜을 통해 데이터를 주고받고자 한다면 고민할 거리가 많아진다. 그 중에서도 어떤 방법이 가장 빠른가?를 항상 고민하게 되는데, 개발의 세계가 항상 그러하듯 해보지 않고는 모르기 때문에 이번에 겸사겸사 벤치마크해본 것을 공유해보고자 한다.
우선, 파이썬을 벗어나게 되면 파이썬 오브젝트를 상응하는 bytes 로 변환하기 위한 serialization / deserialization (단어가 너무 기니 앞으로는 se/deserialization 이라고 적는다) 이 필수적으로 필요하다. 또한 어떤 serialization 알고리즘을 사용하기에 앞서 모델을 어떻게 정의할지도 생각을 해봐야 한다. 모델을 정의하는 방법은 온갖 라이브러리들이 많은데, 대표적으로 사용되는 것들 중 내가 써본 것은 다음과 같다.
dataclasses
의 dataclass
: 표준 라이브러리라 쓰기에 부담감이 적다는 장점. 그러나 기본 제공되는 기능 중에 nested model 에 대한 deserialization 이 없는 것이 단점. (https://docs.python.org/3/library/dataclasses.html)pydantic
라이브러리의 BaseModel
: 왠만한 기능들은 다 지원해주는게 장점. 특히 데이터 validation 기능이 잘 통합되어 있다. 내부 구조가 복잡하고, 상황에 따라 불필요한 기능으로 인해 오버해드가 발생하는 것은 단점. (https://github.com/pydantic/pydantic)attrs
라이브러리의 @define
: dataclass
보다는 기능이 많고, pydantic
보다는 가벼운 장점. 그러나 막상 dataclass
랑 기능이 거의 비슷하고, pydantic
대비 없는 기능이 많은 것이 단점. (https://github.com/python-attrs/attrs)msgspec
라이브러리의 struct
(https://github.com/jcrist/msgspec)또한 정의된 모델을 bytes 로 바꾸기 위한 se/deserialization 라이브러리는 이런 것들이 있다.
pickle
: 모든 파이썬 오브젝트를 bytes
오브젝트로 nested se/deserialization 지원. (https://docs.python.org/3/library/pickle.html)dacite
: dict
오브젝트를 dataclass
모델로 nested deserialization 지원. (https://github.com/konradhalas/dacite)cattrs
: attrs.define
모델을 dict
오브젝트로 nested se/deserialization 지원. (https://github.com/python-attrs/cattrs)json
: dict
오브젝트를 포함한 다양한 오브젝트를 str
오브젝트로 nested se/deserialization 지원 (https://docs.python.org/3/library/json.html)orjson
: dict
오브젝트를 포함한 다양한 오브젝트를 bytes
오브젝트로 nested se/deserialization 지원 (https://github.com/ijl/orjson)msgpack
: dict
오브젝트를 포함한 다양한 오브젝트를 bytes
오브젝트로 nested se/deserialization 지원 (https://github.com/msgpack/msgpack-python)msgspec
: 자체적으로 msgspec.sturct
를 json 또는 msgpack 포맷의 bytes
오브젝트로 nested se/deserialization 지원 위의 라이브러리들을 바탕으로, 이번 벤치마크에서 테스트해보려는 모델용 라이브러리와 se/deserialization 라이브러리 조합은 다음과 같다.
코드를 단순하게 만들기 위해 Enum.enum
이나 datetime
같은 non-primitive 타입은 일단 제거했다.
import dataclasses
import json
import pickle
from datetime import datetime
import attrs
import cattrs
import dacite
import msgpack
import msgspec
import orjson
import pydantic
# 1. `dataclasses.dataclass` 기반 모델 정의
@dataclasses.dataclass
class DCUser:
fullname: str
email: str
@dataclasses.dataclass
class DCClient:
user: DCUser
last_contact: str
case_1_obj = DCClient(
user=DCUser(fullname="JK", email="jk@elicer.com"),
last_contact=datetime.now().astimezone().isoformat(),
)
# 1-1. `pickle`
case_1_1_se = lambda o: pickle.dumps(o)
case_1_1_de = lambda b: pickle.loads(b)
# 1-(2|3|4). `dacite`
case_1_dacite_se = lambda o, dict_se: dict_se(dataclasses.asdict(o))
case_1_dacite_de = lambda b, dict_de: dacite.from_dict(
data_class=DCClient,
data=dict_de(b),
)
# 1-2. `dacite` + `json`
case_1_2_se = lambda o: case_1_dacite_se(o, lambda od: json.dumps(od).encode())
case_1_2_de = lambda b: case_1_dacite_de(b, json.loads)
# 1-3. `dacite` + `orjson`
case_1_3_se = lambda o: case_1_dacite_se(o, orjson.dumps)
case_1_3_de = lambda b: case_1_dacite_de(b, orjson.loads)
# 1-4. `dacite` + `msgpack`
case_1_4_se = lambda o: case_1_dacite_se(o, msgpack.dumps)
case_1_4_de = lambda b: case_1_dacite_de(b, msgpack.loads)
# 2. `pydantic.BaseModel` 기반 모델 정의
class PDUser(pydantic.BaseModel):
fullname: str
email: str
class PDClient(pydantic.BaseModel):
user: PDUser
last_contact: str
case_2_obj = PDClient(
user=PDUser(fullname="JK", email="jk@elicer.com"),
last_contact=datetime.now().astimezone().isoformat(),
)
# 2-1. `pickle`
case_2_1_se = lambda o: pickle.dumps(o)
case_2_1_de = lambda b: pickle.loads(b)
# 2-2. `json`
case_2_2_se = lambda o: json.dumps(o.dict()).encode()
case_2_2_de = lambda b: PDClient.parse_obj(json.loads(b))
# 2-3. `orjson`
case_2_3_se = lambda o: orjson.dumps(o.dict())
case_2_3_de = lambda b: PDClient.parse_obj(orjson.loads(b))
# 2-4. `msgpack`
case_2_4_se = lambda o: msgpack.dumps(o.dict())
case_2_4_de = lambda b: PDClient.parse_obj(msgpack.loads(b))
# 3. `attrs.define` 기반 모델 정의
@attrs.define(kw_only=True)
class ATUser:
fullname: str
email: str
@attrs.define(kw_only=True)
class ATClient:
user: ATUser
last_contact: str
case_3_obj = ATClient(
user=ATUser(fullname="JK", email="jk@elicer.com"),
last_contact=datetime.now().astimezone().isoformat(),
)
# 3-1. `pickle`
case_3_1_se = lambda o: pickle.dumps(o)
case_3_1_de = lambda b: pickle.loads(b)
# 3-2. `json`
case_3_2_se = lambda o: json.dumps(cattrs.unstructure(o)).encode()
case_3_2_de = lambda b: cattrs.structure(json.loads(b), ATClient)
# 3-3. `orjson`
case_3_3_se = lambda o: orjson.dumps(cattrs.unstructure(o))
case_3_3_de = lambda b: cattrs.structure(orjson.loads(b), ATClient)
# 3-4. `msgpack`
case_3_4_se = lambda o: msgpack.dumps(cattrs.unstructure(o))
case_3_4_de = lambda b: cattrs.structure(msgpack.loads(b), ATClient)
# 4. `msgspec` 기반 모델 정의
class MSUser(msgspec.Struct):
fullname: str
email: str
class MSClient(msgspec.Struct):
user: MSUser
last_contact: str
case_4_obj = MSClient(
user=MSUser(fullname="JK", email="jk@elicer.com"),
last_contact=datetime.now().astimezone().isoformat(),
)
# 4-1. `json`
case_4_1_se = lambda o: msgspec.json.encode(o)
case_4_1_de = lambda b: msgspec.json.decode(b, type=MSClient)
# 4-2. `msgpack`
case_4_2_se = lambda o: msgspec.msgpack.encode(o)
case_4_2_de = lambda b: msgspec.msgpack.decode(b, type=MSClient)
Python 3.10.0 환경에서 각 방식에 대해 serialize 된 bytes 오브젝트의 크기와, se/deserialization 속도를 timeit 를 측정한 결과는 다음과 같다.
import timeit
exps = [
("dataclass + pickle", case_1_obj, case_1_1_se, case_1_1_de),
("dataclass + dacite + json", case_1_obj, case_1_2_se, case_1_2_de),
("dataclass + dacite + orjson", case_1_obj, case_1_3_se, case_1_3_de),
("dataclass + dacite + msgpack", case_1_obj, case_1_4_se, case_1_4_de),
("pydantic + pickle", case_2_obj, case_2_1_se, case_2_1_de),
("pydantic + json", case_2_obj, case_2_2_se, case_2_2_de),
("pydantic + orjson", case_2_obj, case_2_3_se, case_2_3_de),
("pydantic + msgpack", case_2_obj, case_2_4_se, case_2_4_de),
("attrs + pickle", case_3_obj, case_3_1_se, case_3_1_de),
("attrs + cattrs + json", case_3_obj, case_3_2_se, case_3_2_de),
("attrs + cattrs + orjson", case_3_obj, case_3_3_se, case_3_3_de),
("attrs + cattrs + msgpack", case_3_obj, case_3_4_se, case_3_4_de),
("msgspec + json", case_4_obj, case_4_1_se, case_4_1_de),
("msgspec + msgpack", case_4_obj, case_4_2_se, case_4_2_de),
]
print(
"|{:^30s}|{:^15s}|{:^24s}|{:^24s}|".format(
"exp name",
"obj size",
"serialize (μs/op)",
"deserialize (μs/op)",
)
)
print("-"*98)
for exp_name, exp_obj, exp_se, exp_de in exps:
len_obj_bytes = len(exp_se(exp_obj))
serialize_perf = timeit.timeit(
"exp_se(exp_obj)",
globals=globals(),
number=1_000
) / 1_000 * 1_000_000
deserialize_perf = timeit.timeit(
"exp_de(b)",
"b=exp_se(exp_obj)",
globals=globals(),
number=1_000
) / 1_000 * 1_000_000
print(
"|{:^30s}|{:^15s}|{:^24s}|{:^24s}|".format(
exp_name,
str(len_obj_bytes),
"%.3lf" % serialize_perf,
"%.3lf" % deserialize_perf,
)
)
결과는 다음과 같다. (pydantic_v2
는 같은 코드를 별도로 다시 실행한 후 결과를 합쳤다)
| exp name | obj size | serialize (μs/op) | deserialize (μs/op) |
--------------------------------------------------------------------------------------------------
| dataclass + pickle | 162 | 4.007 | 3.145 |
| dataclass + dacite + json | 106 | 9.516 | 15.015 |
| dataclass + dacite + orjson | 100 | 5.803 | 10.354 |
| dataclass + dacite + msgpack | 86 | 6.541 | 10.407 |
| pydantic + pickle | 255 | 6.217 | 4.705 |
| pydantic_v2 + pickle | 279 | 6.555 | 5.351 |
| pydantic + json | 106 | 12.837 | 12.979 |
| pydantic_v2 + json | 106 | 7.443 | 8.549 |
| pydantic + orjson | 100 | 9.499 | 9.215 |
| pydantic_v2 + orjson | 100 | 4.028 | 4.705 |
| pydantic + msgpack | 86 | 9.714 | 9.278 |
| pydantic_v2 + msgpack | 86 | 4.769 | 5.053 |
| attrs + pickle | 162 | 4.664 | 3.984 |
| attrs + cattrs + json | 106 | 3.538 | 4.561 |
| attrs + cattrs + orjson | 100 | 0.783 | 1.791 |
| attrs + cattrs + msgpack | 86 | 1.273 | 1.983 |
| msgspec + json | 100 | 0.317 | 0.530 |
| msgspec + msgpack | 86 | 0.288 | 0.496 |
몇 가지 흥미로운 부분들을 살펴보자면,
msgspec
이 압도적으로 빠르고, 사용 편의성도 좋다.pydantic_v2
는 pydantic
보다 약 2배 정도 빠르다.pickle
은 생각보다 빠르지 않고, serialization 결과물의 크기도 크다. 보안 상의 리스크까지 생각한다면, 이런 상황에서는 굳이 쓸 이유가 없다.msgpack
은 결과물의 크기가 가장 작고, 속도도 빠른 편이다. 다만, 결과물이 human-readable 하지 않다는 단점이 있다. streaming se/deserialization 를 지원하므로, 메모리가 고픈 환경에서 쓰면 좋겠다.orjson
은 json
과 비교했을 때 상당히 빠르다. 역시 Rust 기반 라이브러리답다.pydantic
의 경우 속도가 느리다. 가장 큰 이유는 내장된 validation 기능 때문인 것으로 보인다. .construct()
를 사용해서 validation 을 건너 뛸 수 있다고 하지만, 그러면 nested model 의 deserialization 이 불가능하다.위의 결과를 바탕으로 용도에 따라 어떤 조합을 써야 할지를 정리해본다면 이렇게 볼 수 있을 것 같다.
msgspec
+ json
attrs
+ cattrs
+ msgpack
or orjson
pydantic
+ msgpack
or orjson
msgpack
-> orjson
참고로 사용한 라이브러리들의 버전은 요렇다.
attrs==23.2.0
cattrs==23.2.3
dacite==1.8.1
msgpack==1.0.8
orjson==3.10.5
pydantic==1.10.16 # v1 test
pydantic==2.7.4 # v2 test