Deserialization formats
Which format is cheapest to decode? This rig answers it fairly: the same logical data is encoded as both Avro and JSON and decoded CPU-bound (no broker, no server, no network in the loop), reported per record so every format — and every JSON framing — is a directly comparable bar. As the framework gains formats this chart grows with them; today it is Avro and JSON.
Two workloads are measured: a nested batch of 50 richer readings (a bool, a
float, and a small nested string array each) and a single flat 15-field
record. Avro's typed arm uses the single-pass fast backend
(serde_avro_fast) — Avro's recommended typed decoder and the fair peer to
JSON's serde_json. See the Avro-fast pipeline study
for Avro's backend A/B in depth.
Batch — per-record decode cost
Every bar below decodes the identical 50 readings; only the physical layout differs — Avro's nested datum, JSON as one nested document, as a top-level array, or as NDJSON (one reading per line). Throughput is normalized per reading.
Data table
| Variant | Value | 95% CI | n |
|---|---|---|---|
| Avro (fast · nested datum) | 133.09331491243816 ns | 131.84725110418094 – 134.33937872069538 | 9 |
| JSON (nested document) | 216.59970619021453 ns | 215.56086335802487 – 217.6385490224042 | 9 |
| JSON (top-level array) | 216.72896814347578 ns | 215.85182778789837 – 217.6061084990532 | 9 |
| JSON (NDJSON) | 238.98702896610382 ns | 238.11268804114553 – 239.8613698910621 | 9 |
Avro's compact binary datum decodes fastest — roughly 1.8× cheaper per record than JSON typed, since JSON pays to parse text, unescape strings, and re-parse numbers. Among JSON's framings the difference is small: a nested document and a top-level array sit within a few percent of each other (the array marginally faster), while NDJSON trails by ~10–15% because it re-frames per line (a line split plus a fresh parse per record) instead of amortizing one parse across the whole batch. NDJSON buys that back in per-line error isolation and true streaming — the design trade-off the JSON format page covers.
Typed vs dynamic
Decoding into your own structs (typed) versus a dynamic tree
(serde_json::Value / apache_avro::Value) is the other lever. The dynamic path
allocates a node per field and is roughly 2× costlier for both formats — use
it only when the schema is unknown at compile time.
Data table
| Variant | Value | 95% CI | n |
|---|---|---|---|
| Avro (fast · nested datum) | 243.59414190481962 ns | 242.31952179635962 – 244.86876201327962 | 9 |
| JSON (top-level array) | 381.7846096056321 ns | 378.9943848069693 – 384.5748344042949 | 9 |
| JSON (nested document) | 385.87737547912917 ns | 384.07295732675937 – 387.681793631499 | 9 |
| JSON (NDJSON) | 411.80541803807387 ns | 410.4437497902683 – 413.1670862858794 | 9 |
Single record — a flat 15-field row
The batch shape stresses nested-array and small-object decoding; this one is a single flat record decoded per payload, so it isolates per-field scalar cost.
Data table
| Variant | Value | 95% CI | n |
|---|---|---|---|
| Avro (fast) | 186.1334070460607 ns | 183.53495005485254 – 188.73186403726888 | 9 |
| JSON (serde_json) | 346.2500764399581 ns | 344.73634639616785 – 347.76380648374834 | 9 |
SIMD backend — serde_json vs simd-json
Everything above decodes with serde_json, the default backend. The byte-slice
→ value step sits behind an internal seam, so the opt-in simd Cargo feature
(json-simd on the etl facade) swaps in
simd-json — a SIMD-accelerated parser
— with no API change. Same 50 readings, same typed decode; only the parser
differs.
Data table
| Group | Series | Value | 95% CI | n |
|---|---|---|---|---|
| single doc | serde_json (default) | 216.59970619021453 ns | 215.56086335802487 – 217.6385490224042 | 9 |
| single doc | simd-json (opt-in) | 153.30910499738616 ns | 152.57057944436073 – 154.04763055041158 | 9 |
| array | serde_json (default) | 216.72896814347578 ns | 215.85182778789837 – 217.6061084990532 | 9 |
| array | simd-json (opt-in) | 154.04921266107297 ns | 153.2298160349762 – 154.86860928716973 | 9 |
| NDJSON | serde_json (default) | 238.98702896610382 ns | 238.11268804114553 – 239.8613698910621 | 9 |
| NDJSON | simd-json (opt-in) | 205.95719103952965 ns | 205.19393179361532 – 206.72045028544397 | 9 |
simd-json parses a mutable buffer in place (it unescapes strings into the
buffer). The framework hands the decoder a borrowed, immutable payload — for a
Kafka message it is librdkafka-owned memory, and at-least-once replay needs the
original bytes intact — so the payload is copied into a reused thread-local
buffer first, and the parser's own scratch buffers are reused across calls too.
Both are per-message allocations a production integration avoids; the
memcpy_baseline bar isolates what remains — the copy itself, about 1 ns per
reading, under 1% of the decode.
On the single-document and top-level-array framings — one parse over the
whole payload, and the JSON connector's Kafka-message default — simd-json decodes
roughly 1.4× faster than serde_json. On NDJSON (50 independent line
parses) the gap narrows to ~1.15× as per-parse overhead dominates, and on the
flat single-record order shape it is ~1.25×. Every arm is faster; the win is
largest exactly where one large document is parsed.
These numbers are from an Apple M5 Max (aarch64, NEON) — the machine every page
here uses. simd-json's speedup is architecture-sensitive; an x86-64 host with
AVX2 was not measured. serde_json remains the default backend, and simd is a
per-deployment opt-in worth benchmarking on your own hardware and payloads.
simd-json is a different parser, so decode is not semantically identical to
serde_json on every input. It rejects integer literals outside the
i64/u64 range that serde_json accepts as f64 (such a document is dropped
as malformed, or fails the payload under on_error: fail), normalizes -0 to
0, and does not honor the serde_json-specific arbitrary-precision,
raw-value, or float-roundtrip features. The duplicate-key guard is unaffected —
it always runs on serde_json. Ordinary-range numbers decode identically;
validate against your own payloads before switching.
Reproduce
The rig is benchmarks/src/bin/deser_formats.rs; the shared shapes and both
formats' encoders are in benchmarks/src/deser_sample.rs. One invocation
measures one arm (mean of REPS reps, Student-t 95% CI). Sweep the matrix:
RESULTS=benchmarks/results/deser-formats.jsonl
for R in typed value; do
for F in avro json; do
SHAPE=order FORMAT=$F RECORD=$R REPS=9 RESULTS=$RESULTS \
cargo run -p benchmarks --release --bin deser_formats
done
SHAPE=batch FORMAT=avro RECORD=$R REPS=9 RESULTS=$RESULTS \
cargo run -p benchmarks --release --bin deser_formats
for FR in single array ndjson; do
SHAPE=batch FORMAT=json FRAMING=$FR RECORD=$R REPS=9 RESULTS=$RESULTS \
cargo run -p benchmarks --release --bin deser_formats
done
done
# simd-json backend — rebuild with the feature; JSON typed arms.
SHAPE=order FORMAT=json RECORD=typed REPS=9 RESULTS=$RESULTS \
cargo run -p benchmarks --release --bin deser_formats --features json-simd
for FR in single array ndjson; do
SHAPE=batch FORMAT=json FRAMING=$FR RECORD=typed REPS=9 RESULTS=$RESULTS \
cargo run -p benchmarks --release --bin deser_formats --features json-simd
done
# memcpy-only baseline — the copy simd-json's mutable-buffer parse pays.
SHAPE=order FORMAT=json RECORD=typed COPY_ONLY=1 REPS=9 RESULTS=$RESULTS \
cargo run -p benchmarks --release --bin deser_formats
for FR in single array ndjson; do
SHAPE=batch FORMAT=json FRAMING=$FR RECORD=typed COPY_ONLY=1 REPS=9 RESULTS=$RESULTS \
cargo run -p benchmarks --release --bin deser_formats
done
Per-format micro-benchmarks (criterion) live in each format crate —
crates/etl-json/benches/decode.rs and crates/etl-avro/benches/decode.rs. See
Methodology for the harness and the Report schema the charts
read.