Performance & correctness review
Tested on a dedicated VPS, pinned to a single core via taskset -c 0.
- OS: Ubuntu 24.04.3 LTS, kernel 6.8.0-107-generic
- CPU: AMD EPYC (with IBPB), 1 vCPU
- RAM: 1.9 GiB
- Python: 3.12.3
- Method: A/B/A (main -> PR -> main), 3x100 rounds per benchmark, trimmed mean (drop top/bottom 10%),
taskset -c 0
Benchmark results
| Benchmark | main_1 (ms) | PR (ms) | main_2 (ms) | PR vs avg(main) | stdev (m1/pr/m2) | verdict |
|---|---|---|---|---|---|---|
| point_encode | 0.0788 | 0.0810 | 0.0841 | -0.5% | 2.4/2.5/15.8% | noise |
| point_decode | 0.0837 | 0.0888 | 0.0871 | +4.0% | 2.6/1.4/4.4% | noise (main drifts) |
| generic_struct_decode | 0.0587 | 0.0603 | 0.0586 | +2.7% | 1.2/1.5/1.4% | borderline, see below |
| decoder_creation_simple | 0.0903 | 0.0925 | 0.0914 | +1.9% | 1.4/1.9/2.3% | noise |
| decoder_creation_generic | 0.1681 | 0.1696 | 0.1691 | +0.6% | 1.1/3.7/1.6% | noise |
generic_struct_decode shows +2.7%, but this benchmark uses a pre-created Decoder(Box[int]) where Box is Struct, Generic[T] (a typing._GenericAlias, not a types.GenericAlias). The convert_types_generic_alias path is not hit here, so this is VPS noise despite the low stdev.
Conclusion: no measurable performance regression.
Correctness verification
6/6 tests pass on the PR branch:
| Test | Status |
|---|---|
dataclass inheriting collections.abc.Mapping[str, T] (original #957) |
PASS |
Struct + ABCMeta + collections.abc.Mapping[str, T] |
PASS |
typing.Mapping[str, T] variant |
PASS |
| PEP 695 type var syntax (Python 3.12) | PASS |
| Refcount / cache across decoder instances | PASS |
| Cache persistence across 10 decode calls | PASS |
Memory leak test
10,000 iterations of Decoder(Foo[int]) where Foo inherits collections.abc.Mapping (exercising convert_types_generic_alias on every call):
- RSS growth: 60 KB (within noise)
- tracemalloc: 3.4 KB growth (from test scaffolding)
No memory leak detected.
Benchmark scriptimport timeit import statistics import json import typing import msgspec from msgspec import Struct T = typing.TypeVar("T") class Point(Struct): x: int y: int z: float class Box(Struct, typing.Generic[T]): value: T enc = msgspec.json.Encoder() dec_point = msgspec.json.Decoder(Point) dec_box_int = msgspec.json.Decoder(Box[int]) point_msg = enc.encode(Point(1, 2, 3.14)) box_msg = enc.encode(Box(value=42)) def bench_point_encode(): p = Point(1, 2, 3.14) for _ in range(1000): enc.encode(p) def bench_point_decode(): for _ in range(1000): dec_point.decode(point_msg) def bench_generic_struct_decode(): for _ in range(1000): dec_box_int.decode(box_msg) def bench_decoder_creation_simple(): for _ in range(200): msgspec.json.Decoder(Point) def bench_decoder_creation_generic(): for _ in range(200): msgspec.json.Decoder(Box[int]) benchmarks = [ ("point_encode", bench_point_encode), ("point_decode", bench_point_decode), ("generic_struct_decode", bench_generic_struct_decode), ("decoder_creation_simple", bench_decoder_creation_simple), ("decoder_creation_generic", bench_decoder_creation_generic), ] # Warmup for name, func in benchmarks: timeit.repeat(func, number=1, repeat=10) # 3 full runs of 100 rounds each all_results = {} for run in range(3): for name, func in benchmarks: times = timeit.repeat(func, number=1, repeat=100) times_sorted = sorted(times) trimmed = times_sorted[10:-10] # drop top/bottom 10% if name not in all_results: all_results[name] = [] all_results[name].extend(trimmed) results = {} for name, times in all_results.items(): results[name] = { "mean": statistics.mean(times), "stdev": statistics.stdev(times), "min": min(times), "p50": statistics.median(times), } print(json.dumps(results, indent=2))
import sys import typing import collections.abc import dataclasses import gc import msgspec from msgspec import Struct results = [] def test(name, func): try: func() results.append((name, "PASS", "")) except Exception as e: results.append((name, "FAIL", str(e))) def test_dataclass_mapping(): @dataclasses.dataclass class Bar(typing.Generic[typing.T], collections.abc.Mapping[str, typing.T]): data: dict[str, typing.T] def __getitem__(self, x): return self.data[x] def __len__(self): return len(self.data) def __iter__(self): return iter(self.data) x = Bar(data={"x": 3}) encoded = msgspec.msgpack.encode(x) decoded = msgspec.msgpack.decode(encoded, type=Bar[int]) assert decoded == x test("dataclass_mapping_generic", test_dataclass_mapping) def test_struct_mapping(): import abc class CombinedMeta(msgspec.structs.StructMeta, abc.ABCMeta): pass T = typing.TypeVar("T") class Foo(collections.abc.Mapping[str, T], Struct, typing.Generic[T], metaclass=CombinedMeta): data: dict[str, T] def __getitem__(self, x): return self.data[x] def __len__(self): return len(self.data) def __iter__(self): return iter(self.data) encoded = msgspec.msgpack.encode(Foo(data={"x": 1})) decoded = msgspec.msgpack.decode(encoded, type=Foo[int]) assert decoded.data == {"x": 1} try: msgspec.msgpack.decode( msgspec.msgpack.encode(Foo(data={"x": "foo"})), type=Foo[int] ) assert False, "Should have raised ValidationError" except msgspec.ValidationError: pass test("struct_mapping_generic", test_struct_mapping) def test_typing_mapping(): T = typing.TypeVar("T") @dataclasses.dataclass class Bar(typing.Generic[T], typing.Mapping[str, T]): data: dict[str, T] def __getitem__(self, x): return self.data[x] def __len__(self): return len(self.data) def __iter__(self): return iter(self.data) x = Bar(data={"x": 3}) encoded = msgspec.msgpack.encode(x) decoded = msgspec.msgpack.decode(encoded, type=Bar[int]) assert decoded == x test("typing_mapping_generic", test_typing_mapping) if sys.version_info >= (3, 12): def test_typevar_syntax(): code = ''' from msgspec import Struct class Ex[T](Struct): x: T y: list[T] ''' ns = {} exec(code, ns) Ex = ns["Ex"] msg = msgspec.json.encode(Ex(1, [1, 2])) res = msgspec.json.decode(msg, type=Ex[int]) assert res.x == 1 and res.y == [1, 2] test("pep695_typevar_syntax", test_typevar_syntax) def test_refcount_cache(): T = typing.TypeVar("T") @dataclasses.dataclass class Foo(typing.Generic[T], collections.abc.Mapping[str, T]): data: dict[str, T] def __getitem__(self, x): return self.data[x] def __len__(self): return len(self.data) def __iter__(self): return iter(self.data) typ = Foo[int] dec1 = msgspec.json.Decoder(typ) dec2 = msgspec.json.Decoder(typ) msg = msgspec.json.encode(Foo(data={"a": 1})) r1 = dec1.decode(msg) r2 = dec2.decode(msg) assert r1 == r2 del dec1, dec2 gc.collect() test("refcount_cache", test_refcount_cache) def test_cache_persistence(): T = typing.TypeVar("T") @dataclasses.dataclass class Foo(typing.Generic[T], collections.abc.Mapping[str, T]): data: dict[str, T] def __getitem__(self, x): return self.data[x] def __len__(self): return len(self.data) def __iter__(self): return iter(self.data) typ = Foo[int] msg = msgspec.msgpack.encode(Foo(data={"a": 1})) for i in range(10): dec = msgspec.msgpack.Decoder(typ) result = dec.decode(msg) assert result.data == {"a": 1} test("cache_persistence", test_cache_persistence) for name, status, detail in results: marker = "OK" if status == "PASS" else "FAIL" print(f"[{marker}] {name}" + (f" - {detail}" if detail else "")) print(f"\n{len(results)} tests, {sum(1 for _, s, _ in results if s == 'FAIL')} failed")
import gc import sys import typing import collections.abc import dataclasses import tracemalloc import msgspec from msgspec import Struct T = typing.TypeVar("T") @dataclasses.dataclass class Foo(typing.Generic[T], collections.abc.Mapping[str, T]): data: dict[str, T] def __getitem__(self, x): return self.data[x] def __len__(self): return len(self.data) def __iter__(self): return iter(self.data) tracemalloc.start() gc.collect() with open("/proc/self/status") as f: rss_before = int([l for l in f if l.startswith("VmRSS:")][0].split()[1]) snap1 = tracemalloc.take_snapshot() for i in range(10000): dec = msgspec.json.Decoder(Foo[int]) msg = msgspec.json.encode(Foo(data={"a": 1})) dec.decode(msg) del dec gc.collect() with open("/proc/self/status") as f: rss_after = int([l for l in f if l.startswith("VmRSS:")][0].split()[1]) snap2 = tracemalloc.take_snapshot() print(f"RSS diff: {rss_after - rss_before} KB") for stat in snap2.compare_to(snap1, "lineno")[:5]: print(f" {stat}")