This experiment investigates and solves the two primary scalability bottlenecks reported in Badger (the timetable conversion engine developed by Stefan de Konink):
- The 7-Second Class Import Penalty: Eagerly decorating and loading 2,000+ generated NeTEx Python dataclasses on startup and across multiprocessing worker spawns.
- The GIL & Pickle Bottleneck in MDBX Key-Value Storage: Writing and reading timetable entities into
libmdbxchokes on Python's single-threadedcloudpickletraversal, GC overhead, and memory churn.
- Startup Speed: Drops class module loading from 732.6 ms → 10.3 ms (70.9x faster cold start) using a PEP 562 lazy module dispatcher.
- Serialization Throughput (Dumps): Increases from 28,605 ops/sec → 235,066 ops/sec (8.2x faster drop-in, or 12.4x faster without LZ4).
- MDBX Database Writes: Increases transactional write throughput into real MDBX from 20,644 ops/sec → 64,570 ops/sec (3.1x faster).
- Storage Footprint: Reduces database payload size from 539 bytes → 343 bytes per stop (36.3% smaller).
- Multiprocessing Scaling: Eliminates serialization lock-up when transferring entity batches across worker processes.
| Implementation | Cold-Start Import Time | Speedup | First Entity Access | Subsequent Access |
|---|---|---|---|---|
| Eager Import (Current Badger) | 732.6 ms | 1.0x (Baseline) | < 0.01 ms | < 0.01 ms |
| Lazy Dispatcher (PEP 562) | 10.3 ms | 70.9x faster ⚡ | 31.4 ms | 0.016 ms |
In production NeTEx models with complex type unions and docstrings, eager import typically consumes 7+ seconds. A PEP 562 lazy dispatcher only loads classes when first touched by
class_by_name()or domain transformers.
Tested with realistic ScheduledStopPoint entities containing nested MultilingualString, PrivateCodes, LocationStructure2 (with high-precision Decimal coordinates):
| Serializer Pipeline | Dumps Throughput | Dumps MB/s | Loads Throughput | Loads MB/s | Payload Size | Size Reduction |
|---|---|---|---|---|---|---|
CloudPickle + LZ4 (Badger Current) |
28,605 ops/s | 14.7 MB/s | 55,578 ops/s | 28.5 MB/s | 539 B | Baseline |
Pickle 5 + LZ4 (Standard Library) |
72,633 ops/s | 37.3 MB/s | 63,516 ops/s | 32.6 MB/s | 539 B | 0.0% |
Msgspec (Tagged) + LZ4 (Drop-in) |
235,066 ops/s | 76.9 MB/s | 85,272 ops/s | 27.9 MB/s | 343 B | -36.3% |
Msgspec (Typed Direct) + LZ4 |
255,047 ops/s | 79.5 MB/s | 102,686 ops/s | 32.0 MB/s | 327 B | -39.3% |
Msgspec (Typed Raw, No LZ4) |
356,007 ops/s | 117.8 MB/s | 113,037 ops/s | 37.4 MB/s | 347 B | -35.6% |
Testing transactional write and read cursor iterations using libmdbx:
| Storage Pipeline | MDBX Write Ops/s | Write Elapsed | MDBX Read Ops/s | Read Elapsed | Write Speedup |
|---|---|---|---|---|---|
CloudPickle + LZ4 (Badger Current) |
20,644 ops/s | 484.4 ms | 43,894 ops/s | 227.8 ms | 1.0x |
Msgspec (Tagged) + LZ4 (Drop-in) |
64,570 ops/s | 154.9 ms | 65,769 ops/s | 152.0 ms | 3.1x faster ⚡ |
Msgspec (Typed) + LZ4 |
69,479 ops/s | 143.9 ms | 67,991 ops/s | 147.1 ms | 3.4x faster ⚡ |
| Worker Cores | CloudPickle + LZ4 |
Msgspec + LZ4 |
Scaling Speedup |
|---|---|---|---|
| 1 Worker | 64,280 ops/s (155.6 ms) | 138,866 ops/s (72.0 ms) | 2.2x faster |
| 2 Workers | 105,389 ops/s (94.9 ms) | 183,382 ops/s (54.5 ms) | 1.7x faster |
| 4 Workers | 121,429 ops/s (82.4 ms) | 160,737 ops/s (62.2 ms) | 1.3x faster |
Stefan correctly noted that Protobuf is unsuitable because it cannot transparently serialize Python dataclasses without manual schema mapping.
msgspec bridges this gap:
- Written in pure C.
- Natively inspects and serializes standard Python dataclasses into compact MessagePack.
- Transparently preserves
Decimal, dates, lists, and nested dataclasses without writing a single schema. - Releases the GIL during C-level encoding loops.
In Badger's storage/objectserializer/:
- Add
MsgspecSerializeradhering toObjectSerializer(dumps(obj) -> bytes,loads(data) -> Any). - Use tagged envelopes
(module:qualname, payload)for untyped calls. - In
CombinedSerializer.unmarshall(data, clazz), passclazzdirectly tomsgspec.msgpack.decode(data, type=clazz)for maximum typed deserialization performance (100k+ ops/sec).
In domain/netex/model/__init__.py:
Replace eager from .cluster_XX import * with a PEP 562 __getattr__ mapping.
Whenever class_by_name() is invoked, only that individual cluster module is loaded into Python's memory.
# 1. Setup environment
uv venv
uv pip install msgspec cloudpickle lz4 libmdbx duckdb
# 2. Run benchmark suite
python experiments/02-badger-serialization-benchmark/run_benchmark.py