파이썬 Nested Model Serialization 벤치마크 (dataclasses, pydantic, attrs, msgspec)

Jungkook Park·2022년 8월 23일
1
post-thumbnail

(20240615) msgspec 및 pydantic_v2 추가 && 라이브러리 최신 버전들로 업데이트

Intro

복잡한 모델링을 하다보면 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 등의 외부 프로토콜을 통해 데이터를 주고받고자 한다면 고민할 거리가 많아진다. 그 중에서도 어떤 방법이 가장 빠른가?를 항상 고민하게 되는데, 개발의 세계가 항상 그러하듯 해보지 않고는 모르기 때문에 이번에 겸사겸사 벤치마크해본 것을 공유해보고자 한다.

Libraries

우선, 파이썬을 벗어나게 되면 파이썬 오브젝트를 상응하는 bytes 로 변환하기 위한 serialization / deserialization (단어가 너무 기니 앞으로는 se/deserialization 이라고 적는다) 이 필수적으로 필요하다. 또한 어떤 serialization 알고리즘을 사용하기에 앞서 모델을 어떻게 정의할지도 생각을 해봐야 한다. 모델을 정의하는 방법은 온갖 라이브러리들이 많은데, 대표적으로 사용되는 것들 중 내가 써본 것은 다음과 같다.

  • 파이썬 표준 라이브러리인 dataclassesdataclass : 표준 라이브러리라 쓰기에 부담감이 적다는 장점. 그러나 기본 제공되는 기능 중에 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 라이브러리는 이런 것들이 있다.

Dump & Load Examples

위의 라이브러리들을 바탕으로, 이번 벤치마크에서 테스트해보려는 모델용 라이브러리와 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)

Benchmark Code

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,
        )
    )

Benchmark Result

결과는 다음과 같다. (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          |

몇 가지 흥미로운 부분들을 살펴보자면,

  • (20240615) msgspec 이 압도적으로 빠르고, 사용 편의성도 좋다.
  • (20240615) pydantic_v2pydantic 보다 약 2배 정도 빠르다.
  • pickle 은 생각보다 빠르지 않고, serialization 결과물의 크기도 크다. 보안 상의 리스크까지 생각한다면, 이런 상황에서는 굳이 쓸 이유가 없다.
  • msgpack 은 결과물의 크기가 가장 작고, 속도도 빠른 편이다. 다만, 결과물이 human-readable 하지 않다는 단점이 있다. streaming se/deserialization 를 지원하므로, 메모리가 고픈 환경에서 쓰면 좋겠다.
  • orjsonjson 과 비교했을 때 상당히 빠르다. 역시 Rust 기반 라이브러리답다.
  • pydantic 의 경우 속도가 느리다. 가장 큰 이유는 내장된 validation 기능 때문인 것으로 보인다. .construct() 를 사용해서 validation 을 건너 뛸 수 있다고 하지만, 그러면 nested model 의 deserialization 이 불가능하다.

위의 결과를 바탕으로 용도에 따라 어떤 조합을 써야 할지를 정리해본다면 이렇게 볼 수 있을 것 같다.

  • (20240615) 대부분의 경우의 정답 : msgspec + json
  • 신뢰할 수 있는 데이터를 주고받을 때 : attrs + cattrs + msgpack or orjson
  • 사용자로부터 입력받아 검증이 필요한 데이터를 주고받을 때 : pydantic + msgpack or orjson
  • serialization 결과물을 쉽게 디버깅하고 싶을 때 : 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
profile
취미는 개발, 위스키, 전자기기 @elice.io

0개의 댓글