Last active
March 4, 2025 03:25
-
-
Save jcrist/80b84817e9c53a63222bd905aa607b43 to your computer and use it in GitHub Desktop.
Benchmark of msgspec, orjson, pydantic, ... taken from Python discord
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# This is a modified version of `orig_benchmark.py`, using different data to | |
# highlight performance differences. | |
import json | |
import random | |
import string | |
import timeit | |
from statistics import mean, stdev | |
import orjson | |
import simdjson | |
import msgspec | |
import pydantic | |
def describe_json(buf: bytes) -> None: | |
"""Describe the type of values found in a JSON message""" | |
json_types = [ | |
("objects", dict), | |
("arrays", list), | |
("strs", str), | |
("ints", int), | |
("floats", float), | |
("bools", bool), | |
("nulls", type(None)), | |
] | |
counts = dict.fromkeys([v for _, v in json_types], 0) | |
def inner(obj): | |
typ = type(obj) | |
counts[typ] += 1 | |
if typ is list: | |
for i in obj: | |
inner(i) | |
elif typ is dict: | |
for k, v in obj.items(): | |
inner(k) | |
inner(v) | |
inner(msgspec.json.decode(buf)) | |
total = sum(counts.values()) | |
print("JSON Types:") | |
results = [(k, counts[v]) for k, v in json_types if counts[v]] | |
results.sort(key=lambda row: row[1], reverse=True) | |
for kind, count in results: | |
print(f"- {kind}: {count} ({count/total:.2f})") | |
random.seed(42) | |
def randstr(): | |
return "".join(random.choices(string.printable, k=10)) | |
class ItemStruct(msgspec.Struct): | |
name: str | |
value: int | |
class UserStruct(msgspec.Struct): | |
username: str | |
exp: float | |
level: float | |
items: list[ItemStruct] | |
class ItemPydantic(pydantic.BaseModel): | |
name: str | |
value: int | |
class UserPydantic(pydantic.BaseModel): | |
username: str | |
exp: float | |
level: float | |
items: list[ItemPydantic] | |
N = 10000 | |
msg = msgspec.json.encode( | |
[ | |
UserStruct( | |
randstr(), | |
random.random(), | |
random.uniform(0, 100), | |
[ | |
ItemStruct(randstr(), random.randint(0, 100)) | |
for _ in range(random.randrange(10, 20)) | |
], | |
) | |
for _ in range(N) | |
] | |
) | |
BENCHMARKS = [ | |
("stdlib json", json.loads), | |
("orjson", orjson.loads), | |
("simdjson", simdjson.loads), | |
("msgspec-dict", msgspec.json.decode), | |
("msgspec-struct", msgspec.json.Decoder(list[UserStruct]).decode), | |
("pydantic-v2", pydantic.TypeAdapter(list[UserPydantic]).validate_json), | |
] | |
describe_json(msg) | |
print("") | |
print("Benchmarks:") | |
results = {} | |
for name, fun in BENCHMARKS: | |
results[name] = times = timeit.repeat( | |
"loads(msg)", | |
repeat=20, | |
number=10, | |
globals={"loads": fun, "msg": msg}, | |
) | |
print(f"- {name}: {mean(times):.2f} ± {stdev(times):.2f}") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# This is a cleaned up version of the original benchmark. Semantically it's the | |
# same, just a bit easier to read. The performance numbers also match those | |
# seen on discord. | |
import json | |
import random | |
import string | |
import timeit | |
from statistics import mean, stdev | |
import orjson | |
import simdjson | |
import msgspec | |
import pydantic | |
def describe_json(buf: bytes) -> None: | |
"""Describe the type of values found in a JSON message""" | |
json_types = [ | |
("objects", dict), | |
("arrays", list), | |
("strs", str), | |
("ints", int), | |
("floats", float), | |
("bools", bool), | |
("nulls", type(None)), | |
] | |
counts = dict.fromkeys([v for _, v in json_types], 0) | |
def inner(obj): | |
typ = type(obj) | |
counts[typ] += 1 | |
if typ is list: | |
for i in obj: | |
inner(i) | |
elif typ is dict: | |
for k, v in obj.items(): | |
inner(k) | |
inner(v) | |
inner(msgspec.json.decode(buf)) | |
total = sum(counts.values()) | |
print("JSON Types:") | |
results = [(k, counts[v]) for k, v in json_types if counts[v]] | |
results.sort(key=lambda row: row[1], reverse=True) | |
for kind, count in results: | |
print(f"- {kind}: {count} ({count/total:.2f})") | |
random.seed(42) | |
def randstr(): | |
return "".join(random.choices(string.printable, k=10)) | |
class UserStruct(msgspec.Struct): | |
username: str | |
exp: float | |
level: float | |
last_values: list[float] | |
class UserPydantic(pydantic.BaseModel): | |
username: str | |
exp: float | |
level: float | |
last_values: list[float] | |
N = 10000 | |
msg = msgspec.json.encode( | |
[ | |
UserStruct( | |
randstr(), | |
random.random(), | |
random.uniform(0, 100), | |
[random.uniform(0, 100) for _ in range(random.randrange(10, 20))], | |
) | |
for _ in range(N) | |
] | |
) | |
BENCHMARKS = [ | |
("stdlib json", json.loads), | |
("orjson", orjson.loads), | |
("simdjson", simdjson.loads), | |
("msgspec-dict", msgspec.json.decode), | |
("msgspec-struct", msgspec.json.Decoder(list[UserStruct]).decode), | |
("pydantic-v2", pydantic.TypeAdapter(list[UserPydantic]).validate_json), | |
] | |
describe_json(msg) | |
print("") | |
print("Benchmarks:") | |
results = {} | |
for name, fun in BENCHMARKS: | |
results[name] = times = timeit.repeat( | |
"loads(msg)", | |
repeat=20, | |
number=10, | |
globals={"loads": fun, "msg": msg}, | |
) | |
print(f"- {name}: {mean(times):.2f} ± {stdev(times):.2f}") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
On the python discord someone posted a benchmark comparing
msgspec
,orjson
,pydantic
,simdjson
, ... This original benchmark showsmsgspec
decoding and validating JSON to be ~the same performance (or a bit slower) asorjson
decoding it alone.While nice (msgspec is doing more for the user in the same amount of time), this didn't match performance numbers seen on the msgspec website here. I was curious about the differences - what about the message structure here was leading to different results?
After asking for the benchmark (which was output from an
.ipynb
), I spent some time cleaning it up but changing none of the semantic details. This isorig_benchmark.py
. I then played around a bit with inspecting the message structure, and arbitrarily changing it to see how that affected the results. A similar message with a different structure shown inbenchmark.py
.Results
Running these on my machine:
orig_benchmark.py
benchmark.py
Analysis
The two benchmarks above show different results for the same parsers. The first one shows msgspec performing ~the same as orjson, the second one shows it performing ~2x faster than orjson. What's going on?
There are two main differences in the message structures here:
1. Fewer floats.
JSON is composed of 7 core types (
object
,array
,str
,int
,float
,bool
,null
). When benchmarking individual types for the core parsing routines,msgspec
'sfloat
parser is known to be a bit slower (~15% slower) than orjson's, while the other core type parsing routines are approximately equivalent (we're slightly faster at ints for some reason).The message used in the original benchmark is 70% floats, which in my experience is much higher than average for JSON data. Most JSON apis I've interacted with are composed of mostly object, int, and str types, so I haven't spent a ton of time optimizing float performance. If float parsing performance is a user priority we could spend more time on this to close the gap.
The second benchmark adjusts the ratios a bit to skew more towards strings/ints. This makes a small difference, but isn't the main reason for the different results.
2. More objects
This is the biggest difference. Decoding into a
msgspec.Struct
type is significantly cheaper than decoding into adict
. Messages that have a higher ratio ofobject
types will hit this hot path more frequently, resulting in a larger relative speedup. In this case we've replaced the list of ints with a list of small objects.Conversely, pydantic does much worse here, since allocating a pydantic
BaseModel
is relatively more expensive than allocating adict
.Closing thoughts
Benchmarks are hard. As we've seen here, the structure of your messages matters a ton to determine how well a certain JSON library will handle it. When benchmarking it's important to ensure the structure of the messages you're using matches those in your real world use case.