The numbers below are a point-in-time snapshot. For the continuously-updated results (re-run on every push to
main) see the live dashboard: https://alex60217101990.github.io/qdf/dev/bench/ — readBENCHMARKS.mdfor how to read the trend graphs and tell a real change from shared-runner noise.
Measured on Darwin amd64 / Intel i7-9750H @ 2.6 GHz. Go 1.26.0.
go test -bench=. -benchmem -benchtime=2s in bench/.
Operating modes compared:
- qdf_fast — default build. Reflect-based with specialized fast
paths for common slice (
[]string,[]int*,[]uint*,[]float32/64,[]bool) and map types. - qdf_qpack — Fast mode + QPack codecs (bit-packed bool, raw-LE,
Frame-of-Reference, Delta+FOR, RLE, dictionary, Patched FOR for
numeric/bool slices; Gorilla/Chimp128 XOR and ALP decimal for float64
under
OptCompression). Auto-selects the smallest predicted form per slice. - qdf_dense — qdf_qpack + inline state-table interning for repeating strings (logs, columnar telemetry). Enum-like string columns in a struct array are dictionary-coded (distinct table + bit-packed index per row) when that beats per-value interning.
- qdf_codegen — code-generated
MarshalQDF/UnmarshalQDFfromcmd/qdfgen(no runtime reflection). - qdf_fast + qdf_reflect2 (opt-in build tag) — swap
reflect.MakeSlice/MakeMapWithSizeformodern-go/reflect2unsafe equivalents. - qdf_fast + qdf_simd (opt-in build tag) — AVX2 asm bit-unpack at the byte-aligned widths (8 / 16 / 32 bits per slot); ~50 GB/s, runtime CPUID gate, scalar fallback otherwise.
vs. encoding/json (stdlib) and github.com/vmihailenco/msgpack/v5.
Round-trip verified by TestSizes and the TestFastPath_* suite. Race
coverage by TestPool_ConcurrentEncoders + the full -race test sweep.
- Encode: qdf beats msgpack and json across the board. Wins are proportional to payload structure: 6× on map-heavy, 12-16× on numeric vectors, 4-6× on log batches.
- Decode: qdf beats both on every payload. 2-7× over msgpack, 4-9× over json.
- QPack (numeric/bool slices): 5× smaller wire than json, 21× faster encode, 80× faster decode on a mixed numeric payload. Delta +FOR reaches 512× compression on monotonic timestamp vectors.
- Large realistic payload (~150 MiB): qdf_dense encodes 7.5× faster than json, decodes 8.1× faster, with a working-set delta of just 9.7 MiB for a 43.7 MiB output (json's encode allocator delta is 199 MiB for the same payload).
- Generic
MarshalT[T]: -1 alloc and 25-40 % faster than theany-boxing entry points on small/medium payloads. Same wire. MarshalDirect[T Marshaler]: 1.55× faster thanMarshalon generated types, 1 alloc per call, 3× less peak memory. Fast-mode only (Dense falls back to the reflect path so the intern table is resolved correctly).tagStateRepeat(Dense, Markov-0 predictor): ~50 % size cut on payloads with repeating service / region / level fields, on top of the existing Dense intern table.tagStatePair(Dense, Markov-1 predictor,0xEA) +tagMapShape(struct shape interning,0xEC): another -31 % on the TelemetryBatch fixture (73 104 → 50 129 bytes, 5.0× vs JSON) by eliding per-record struct headers and exploiting conditional transitions between intern IDs.qdf_simd(AVX2): 22-53× faster bit-unpack at byte-aligned widths (~50 GB/s, memory-bound). CPUID-gated; runtime falls back cleanly on older amd64.- Realistic / unique-data: pool wins survive. UniqueLog is 1.4-1.6× faster than json/msgpack encode and 3-4× faster on decode.
- Concurrent: parallel decode is 1.4-1.7× faster than json/msgpack.
- Size: Dense mode = 34% of json on log batches (was 43% before shape interning), 20% of json on the TelemetryBatch realistic-corpus fixture. No external compression layer.
- Codegen halves the per-call overhead vs reflect path on small
fixed-schema structs (
Sampledecode: 695 ns → vs json 5899 ns = 8.5× faster). - All build-tag combinations (default,
qdf_reflect2,qdf_simd, both) build, test under -race, and fuzz-pass cleanly.
Six representative workloads, each encoded with the Options
combination the docs/CHOOSING.md recipe recommends,
vs encoding/json and vmihailenco/msgpack/v5 on the same fixture.
Numbers from bench/profiles_test.go, median of two
-benchtime=300ms runs on Intel i7-9750H, Go 1.26.0.
| Scenario | Recipe | json | msgpack | qdf | vs json | vs msgpack |
|---|---|---|---|---|---|---|
| hot_path | OptSpeed |
97 | 63 | 70 | 0.72× | 1.11× |
| telemetry_1k | OptBalanced |
142 881 | 111 637 | 40 563 | 0.28× | 0.36× |
| metric_1024 | OptQPack |
37 258 | 19 512 | 8 391 | 0.22× | 0.43× |
| embed_768 | OptQPack |
8 385 | 3 864 | 3 103 | 0.37× | 0.80× |
| config | OptBalanced |
250 | 197 | 225 | 0.90× | 1.14× |
| archive_5k | OptCompression |
714 795 | 558 510 | 127 570 | 0.18× | 0.23× |
| Scenario | json | msgpack | qdf | qdf vs json |
|---|---|---|---|---|
| hot_path | 813 | 520 | 369 | 2.2× |
| telemetry_1k | 439 714 | 590 475 | 353 000 | 1.2× |
| metric_1024 | 189 446 | 129 394 | 6 713 | 28× |
| embed_768 | 80 429 | 36 240 | 903 | 89× |
| config | 2 667 | 2 099 | 1 657 | 1.6× |
| archive_5k | 2 416 341 | 3 482 714 | 4 441 188 | 0.5× |
| Scenario | json | msgpack | qdf | qdf vs json |
|---|---|---|---|---|
| hot_path | 1 954 | 766 | 368 | 5.3× |
| telemetry_1k | 2 893 504 | 1 133 928 | 457 781 | 6.3× |
| metric_1024 | 664 262 | 181 433 | 5 329 | 125× |
| embed_768 | 213 466 | 57 077 | 904 | 236× |
| config | 7 022 | 3 331 | 2 155 | 3.3× |
| archive_5k | 15 797 454 | 6 091 719 | 4 068 275 | 3.9× |
Reading the numbers:
- Decode is faster than json + msgpack across every scenario. The
per-decoder key-intern cache, pooled
*Decoder, and self-describing wire avoid the schemaless-tag walk JSON pays per call. - Encode is faster across every scenario including Dense ones.
OptBalancedon the telemetry fixture now encodes 1.2× faster than json (was 0.47× before the lazy-alloc + reset-skip work). Only the archive atOptCompressionstill trades CPU for bytes (0.5× json) — expected for a format doing Gorilla XOR + an entropy pass over 5 000 rows. (Since this snapshot the entropy pass switched from rANS to tANS/FSE — 3.2× faster encode / 2× faster decode than the rANS stage measured here, at within ±2 % of its ratio.) OptQPackon numeric payloads is the dramatic case — 28–89× faster encode, 125–236× faster decode, 4× smaller wire than json. If your hot path moves floats around, the bit is essentially free and the wins are large.- hot_path msgpack edges qdf on size (63 B vs 70 B) because the
5-byte qdf header (
'QDF' + version + flags) plus per-field tags are slightly looser than msgpack's fixmap on a 5-field struct. Speed and decode latency still favour qdf.
Reproduce: go test -C bench -bench=BenchmarkProfile_ -benchmem -benchtime=300ms
| Payload | json | msgpack | qdf_fast | qdf_dense | vs msgpack | vs json |
|---|---|---|---|---|---|---|
| Tiny | 192 | 286 | 227 | 398 | 1.26× | 0.85× ⚠ |
| Flat (20 fields) | 1182 | 1262 | 480 | 948 | 2.63× | 2.46× |
| Nested (4 deep) | 446 | 793 | 331 | 786 | 2.40× | 1.35× |
| Deep16 | 1157 | 3060 | 477 | 814 | 6.42× | 2.43× |
| Wide ×1000 | 991k | 991k | 213k | 289k | 4.66× | 4.66× |
| LogBatch ×1000 | 998k | 624k | 171k | 562k | 3.65× | 5.84× |
Throughput (encode, MB/s):
| Payload | json | msgpack | qdf_fast |
|---|---|---|---|
| Wide1k | 211 | 144 | 604 |
| LogBatch1k | 252 | 306 | 1 086 |
| Float64Vec512 | 100 | 170 | 1 700+ |
These benchmarks construct a different LogEntry per loop iteration so
the pool's buffer-reuse heuristic and the type-descriptor cache have to
handle real variability rather than re-encoding the same byte sequence.
| Bench | json | msgpack | qdf_fast | vs msgpack | vs json |
|---|---|---|---|---|---|
| UniqueLog (serial) | 2419 | 2080 | 1510 | 1.38× | 1.60× |
| MixedTypes (rotating shape) | 1223 | 1121 | 692 | 1.62× | 1.77× |
| RandomSize (Wide len ∈{1,10,100,1000}) | 313k | 283k | 76k | 3.72× | 4.12× |
| UniqueLog (RunParallel) | 667 | 682 | 487 | 1.40× | 1.37× |
The pool wins are not an artifact of synthetic loops — qdf still beats both encoders on speed AND on allocations under unique-data conditions. Alloc count differential is dominated by payload-generation helpers (randomHex allocates 2 per call); the encoder itself is steady at ~3 allocs/op for fast path.
| Payload | json | msgpack | qdf_fast | vs msgpack | vs json |
|---|---|---|---|---|---|
| Tiny | 729 | 383 | 170 | 2.25× | 4.29× |
| Flat | 4411 | 1862 | 1122 | 1.66× | 3.93× |
| Nested | 2483 | 1206 | 425 | 2.84× | 5.84× |
| Deep16 | 7363 | 4170 | 1972 | 2.11× | 3.73× |
| Wide ×1000 | 4.14M | 1.95M | 1.06M | 1.84× | 3.89× |
| LogBatch ×1000 | 3.42M | 1.32M | 469k | 2.81× | 7.30× |
peekTag ran on every tag and called readHeader just to test the
already-read flag; readHeader was too complex to inline, so it stayed a real
call. Outlining the parse into readHeaderSlow (//go:noinline) and inlining
the flag check into peekTag drops that call from the common path. benchstat
(i7-9750H, count=10, reflect decode): Flat −6.8%, Nested −10.0%,
LogBatch1k −7.6%, geomean −6.8% / +7.3% throughput; allocs and wire
unchanged. Per-tag overhead matters most on small/per-message decodes; the
big columnar Wide ×1000 fixture is dominated by codec work and moves within
noise.
| Bench | json | msgpack | qdf_fast | vs msgpack | vs json |
|---|---|---|---|---|---|
| UniqueLog (serial) | 3569 | 1296 | 509 | 2.55× | 7.01× |
| UniqueLog (RunParallel, contention) | 750 | 330 | 199 | 1.66× | 3.77× |
Opt-in per-epoch Arena: copied string bodies are bump-packed into
one dense block instead of one heap allocation each. Measured off → on over an
epoch loop (Reset per message), reflect decode path unless noted. The win
scales with string density and never regresses; numeric-heavy payloads see a
small gain and []byte fields stay copy-only.
| Corpus | ns/op off | ns/op on | allocs off | allocs on | |
|---|---|---|---|---|---|
| Telemetry log batch × 1000 | 258 k | 167 k | −35 % | 3 502 | 3 |
| AD / directory export × 200 (11 str) | 450 k | 335 k | −26 % | 4 856 | 605 |
Event batch × 500 ([]byte + string) |
116 k | 101 k | −13 % | 1 003 | 504 |
| IoT sensor batch (numeric + map tags) | 102 k | 98 k | −4 % | 291 | 164 |
| LogEntry single (codegen, serial) | 682 | 608 | −11 % | 8 | 2 |
| LogEntry single (codegen, RunParallel) | 248 | 205 | −17 % | 8 | 2 |
Correctness is verified by reflect.DeepEqual(arenaDecoded, plainDecoded) over
all four realistic corpora (maps, nested slices, []byte, codegen nested
structs). Lifetime contract and when-to-use heuristic in ARENA.md.
The type-specific fastpath (no reflect.MapRange / SetMapIndex on the hot path) is the difference between losing and winning here.
| Op | json | msgpack | qdf_fast | qdf_dense | vs msgpack |
|---|---|---|---|---|---|
| Encode | 8446 | 5282 | 1391 | 2019 | 3.80× |
| Decode | 18508 | 7972 | 3108 | - | 2.57× |
Allocs: encode 84 / 46 / 3 / 9. Decode 124 / 112 / 71. qdf wins both ways without an opt-in build tag.
| Bench | json | msgpack | qdf_fast | qdf_simd | vs msgpack |
|---|---|---|---|---|---|
| Encode []float32×512 | 30 054 | 18 270 | 1821 | 1508 | 12.1× |
| Encode []float64×512 | 39 819 | 23 856 | 2450 | 1735 | 13.8× |
| Decode []float32×512 | 72 928 | 28 521 | 3960 | 3928 | 7.20× |
The wire format matches the native little-endian layout on amd64 and arm64, so the bulk float path collapses to a tight LE-store loop.
Payload: 256 booleans, 512 monotonic uint64 (timestamps), 512 int64,
256 float64 — the shape a metrics or columnar batch produces.
| Format | Bytes | Encode ns/op | Decode ns/op |
|---|---|---|---|
| json | 10 739 | 48 000 | 200 000 |
| msgpack | 11 808 | 64 000 | 80 000 |
| qdf_fast | 6 694 | 6 500 | 14 000 |
| qdf_qpack | 2 132 | 2 300 | 2 600 |
| qdf_dense | 2 134 | 2 500 | 2 500 |
QPack auto-selects, per slice, by predicted wire size: bit-packed
bool, raw-LE bulk, Frame-of-Reference + bit-pack, Delta + zigzag +
FOR, run-length, a low-cardinality dictionary codec, and Patched FOR
for integer slices. Float slices add XOR and decimal codecs under
OptCompression — Gorilla/Chimp128 XOR for smooth series (float64
encodes both and keeps the smaller; float32 stays Gorilla) and ALP
decimal for quantized/decimal grids — picked against raw-LE only when
strictly smaller, since their bit-level work costs CPU that pays off
only when size matters more.
On a 2-decimal metric_quant_1024 fixture ALP brings OptCompression
to 2 592 B versus 8 238 B at OptBalanced (3.2× smaller).
On a 1024-element monotonic Unix-second timestamp vector the Delta+FOR codec collapses the wire from 8 201 bytes (raw) to 16 bytes — a 512× reduction.
On a latency_spikes_1024 fixture (sub-millisecond latencies with a
~1 % tail of large spikes) Patched FOR packs the common case at 9 bits
and patches the outliers from an exception list, landing at 1 215 B
versus 2 566 B for plain FOR — ~53 % smaller. Plain FOR loses here
because the rare spikes force every slot to ~20 bits. The picker uses a
conservative cost upper bound, so PFOR is chosen only when strictly
smaller — clean columns keep FOR with a byte-identical wire.
Bit-unpack throughput at 1024 elements per call, median of 5:
| Bits | Scalar (orig) | Pure-Go 128-bit window | AVX2 (qdf_simd) | vs scalar |
|---|---|---|---|---|
| 8 | 3 450 ns/op | 3 450 ns/op | 152 ns/op | 22× |
| 16 | 4 790 ns/op | 3 530 ns/op | 145 ns/op | 33× |
| 32 | 8 070 ns/op | 4 330 ns/op | 152 ns/op | 53× |
| 56 | 13 340 ns/op | 5 412 ns/op | (no asm path) | 2.47× |
The AVX2 path (VPMOVZX{B,W,D}Q + VMOVDQU) hits ~50 GB/s on the
byte-aligned widths — effectively memory-bandwidth bound. Non-byte-
aligned widths (1..7, 9..15, 17..31, 33..56) stay on the pure-Go
sliding-window decoder, which still beats the original byte-at-a-time
loop by 1.85× to 2.5×.
MarshalT[T] / UnmarshalT[T] skip the interface{} conversion and
the reflect copy that Marshal(v any) needs for value-typed inputs.
Same wire output, fewer allocations per call.
| Op (mixed struct, 5 u64 + 3 bool) | Marshal(any) |
MarshalT[T] |
Speedup |
|---|---|---|---|
| encode (n=0 empty slice) | 285 ns/op | 170 ns/op | 1.67× |
| encode (n=4 small slice) | 287 ns/op | 175 ns/op | 1.65× |
| encode (n=64) | 460 ns/op | 350 ns/op | 1.30× |
| allocs | 3 | 2 | -1 |
| heap bytes | 192 B | 112 B | -80 B |
MarshalDirect[T Marshaler] / UnmarshalDirect[T Unmarshaler] go one
step further: with the receiver method known at compile time (qdfgen
output or hand-written), they skip the descriptor cache lookup and the
runtime interface assertion that encodeMarshaler does inside the
reflect path. Same wire bytes; Fast-mode only.
| Encode (Sample fixture, 11 fields) | ns/op | B/op | allocs |
|---|---|---|---|
| json | 1800 | 576 | 8 |
qdf_reflect (Marshal) |
580 | 480 | 3 |
qdf_codegen ((*T).MarshalQDF) |
530 | 504 | 6 |
qdf_direct (MarshalDirect) |
364 | 160 | 1 |
Encode is 1.55× faster than the reflect path, with one third of the
allocations and 3× less peak memory. Decode runs at parity with the
reflect path when the UnmarshalQDF method is well-written
(qdfgen uses Decoder.InternKey for keys and matches the pooled
key-intern cache the reflect path relies on). Ad-hoc receivers that
build a fresh Decoder per call regress decode noticeably — the
docstring spells this out.
Dense mode now collapses a state-ref whose ID equals the immediately
preceding emission to a single byte (the 0xE8 tag). The encoder side
also invalidates the chain on any inline-string emission so a later
tagStateRepeat cannot resurrect a stale ID across an uninterned
value. Wire savings on synthetic single-token vs alternating-token
batches:
| n elements | alternating (no predictor hit) | all-same (predictor every time) | delta |
|---|---|---|---|
| 16 | 81 B | 49 B | -40 % |
| 256 | 563 B | 291 B | -48 % |
| 1024 | 2 099 B | 1 059 B | -50 % |
Real workloads where the predictor pays off:
- Log batches: same
service,region,level,envacross most events. - Columnar rows where a few "tag" columns rarely change.
- Repeated nested keys produced by the reflect / codegen field-name emit path.
Forward-compat note: a reader that does not implement 0xE8 fails
with ErrBadTag on first contact rather than silently mis-decode. The encoder only emits the tag in Dense mode, so Fast
buffers stay byte-identical to previous versions.
Dense additionally encodes a state-ref's LRU rank instead of its raw
intern ID when the rank's varuint is strictly shorter (0xE9 tag).
Catches "hot subset defined late in intern order" patterns that the
Markov-0 predictor on its own misses.
Synthetic stress: 256 unique strings, every intern ID > 200 so the raw varuint is 2 bytes, followed by 4 000 references rotating through a hot subset of 8 items.
OptBalanced (Markov-0 + MTF on) 10 824 bytes
OptDense + OptPairPred (Markov-0 only) ~15 000 bytes (estimated, raw refs)
OptDense alone ~18 000 bytes
The encoder picks tagStateMTF only when its rank varuint is strictly
shorter than the raw id varuint, so the wire never grows over plain
tagStateRef. The decoder mirrors the LRU chain so all three forms
(repeat / ref / mtf) co-exist on the same wire.
Dense keeps a per-prev ring of the last four successor IDs and emits
0xEA + 1-byte rank when the next ID is in that ring AND the raw
state-ref would need a multi-byte varuint. Catches conditional
patterns Markov-0 misses — country → city, service → region,
level → host — where the transition is predictable but the values
themselves do not repeat back-to-back.
Selection rule (encoder, emitStateRef):
bestTag = tagStateRef ; bestLen = uvarintLen(id)
if pair-hit && 1 < bestLen → tagStatePair, payload = rank
if mtf-hit && rankLen < bestLen → tagStateMTF, payload = rank
The "strictly shorter" rule means the predictor never wins on small state tables (ids ≤ 127) — the raw state-ref already uses a single byte. It engages on streams with intern tables ≥ ~130 entries, which is exactly where the byte cost of raw IDs would otherwise inflate.
Dense routes every struct emission through tagMapShape instead of
the generic tagMap8/16/32 path:
first emit: 0xEC, 0, varuint(N), [N x key], [N x value]
later emits: 0xEC, varuint(shapeID), [N x value]
shapeID is assigned per encoder lifetime and addressed by *typeDesc
on the encoder side, so different struct types never collide on the
same id and types are looked up in O(N) over a tiny binding slice
(typical: 1 – 4 entries per stream).
Per-record saving on an array of identical-shape structs: roughly
N × 2 bytes for the elided state-refs covering the key names, plus
the tagMap8 header. For the 1 000-event TelemetryBatch fixture the
TelemetryBatch wire dropped 73 104 → 50 129 bytes (-31 %) purely
from the shape codec layered on top of Markov-0 / MTF / Markov-1.
Forward-compat: a reader that does not implement 0xEC fails with
ErrBadTag on first contact. Fast mode is unaffected — it
never emits the tag.
Built-in realistic_corpus_test.go builders produce three shapes
that mirror real telemetry workloads. Numbers below are encoded
sizes (TestSizes_RealisticCorpus) plus encode latency
(BenchmarkCorpus_TelemetryBatch1000, Intel i7-9750H, 3 runs).
| bytes | vs json | encode ns/op | |
|---|---|---|---|
| json | 252 497 | 1.00× | — |
| qdf_fast | 186 674 | 0.74× | 272 k |
| qdf_qpack | 186 674 | 0.74× | 261 k |
| qdf_dense | 50 129 | 0.20× | 1.0 M |
Dense pays ~4× on CPU for a 5.0× size reduction vs JSON and 3.7× vs qdf_fast — string-intern + Markov-0 + MTF + Markov-1 pair
- shape interning collapse the repeating service / region / level / host fields and the per-record struct headers. QPack does not help much here because the per-event numeric fields are scalar (TS, Span, Trace, Duration) rather than slice-shaped.
| bytes | vs json | |
|---|---|---|
| json | 30 043 | 1.00× |
| qdf_fast | 14 442 | 0.48× |
| qdf_qpack | 8 307 | 0.28× |
| qdf_dense | 8 315 | 0.28× |
Here QPack pulls its weight: the []int64 timestamp column is
monotonic and Delta+FOR compresses it to near-zero bytes per
element; the []float64 value column uses raw-LE bulk. Dense and
QPack converge because the string overhead is tiny.
Driven by bench/largepayload_test.go. The builder emits a struct
of N records, every record carrying every qdf-supported field type:
scalar ints/floats/bools, low-cardinality hot strings (service /
region / level / host), unique-per-record UUIDs, nested map +
string slice, []int32 path, []byte blob, []float64 vector.
Numbers below come from TestSizes_LargePayload (sizes, N = 200 000)
and TestMem_LargePayload (encode/decode latency + working-set
delta, N = 100 000) on Intel i7-9750H, Go 1.26.0. Both helpers skip
under -short. Reproduce:
go test -C bench -run TestSizes_LargePayload -count=1 -timeout=10m
go test -C bench -run TestMem_LargePayload -count=1 -timeout=10m| Format | bytes | MiB | vs json |
|---|---|---|---|
| json | 149 006 973 | 142.10 | 1.00× |
| msgpack | 97 508 774 | 92.99 | 0.65× |
| qdf_fast | 96 462 436 | 91.99 | 0.65× |
| qdf_qpack | 94 008 854 | 89.65 | 0.63× |
| qdf_dense | 92 820 231 | 88.52 | 0.62× |
Dense's compression ceiling here is set by the 200 000 unique UUIDs which cannot dedupe (each ~36 bytes literal). The win shows in encode/decode latency and memory below.
| Format | bytes (MiB) | encode (ms) | decode (ms) | encode heap delta (MiB) |
|---|---|---|---|---|
| json | 71.08 | 1 070 | 1 744 | 199.10 |
| msgpack | 46.51 | 296 | 597 | 64.01 |
| qdf_fast | 46.01 | 142 | 300 | 94.14 |
| qdf_qpack | 44.84 | 147 | 231 | 92.95 |
| qdf_dense | 43.70 | 169 | 216 | 9.73 |
Speedups vs json: qdf_fast encode 7.5×, qdf_dense decode 8.1×. Speedups vs msgpack: qdf_fast encode 2.1×, qdf_dense decode 2.8×.
The most surprising line is qdf_dense's encode heap-delta of
9.7 MiB for a 43.7 MiB output. Marshal(v, OptBalanced) reuses a pooled
encoder buffer plus the intern table; the produced wire is slices. Clone-d for the caller, but the pool buffer survives and shrinks
per-call working-set proportionally. json builds a fresh buffer per
call and the allocator delta is 4.5× the output size. msgpack falls
between the two.
Decode heap deltas are omitted from the table: forced runtime.GC()
inside the timing window reclaims the encoded buffer in the same
sample so the delta reads negative for several formats and the
number stops being useful. The latency column captures the real
decode work.
11-field struct with nested struct, slice, map, pointer, fixed array,
[]byte, time.Time.
| Op | json | qdf_fast (reflect) | qdf_codegen | gen vs reflect | gen vs json |
|---|---|---|---|---|---|
| Encode | 1714 | 702 | 646 | 1.09× | 2.65× |
| Decode | 5899 | 792 | 695 | 1.14× | 8.49× |
The codegen gap over the tuned reflect path is modest because the reflect
path already uses pool, cached descriptors, and unsafe.Pointer + offset
field access. Codegen wins more on decode where reflect must alloc
map/slice values through reflect.MakeMap.
| Payload | json | msgpack | qdf_fast | qdf_dense | dense vs json |
|---|---|---|---|---|---|
| Tiny | 24 | 16 | 22 | 25 | 1.04× |
| Flat | 210 | 134 | 132 | 138 | 0.66× |
| Nested | 103 | 76 | 86 | 96 | 0.93× |
| Deep16 | 239 | 139 | 166 | 63 | 0.26× |
| Wide ×1000 | 212 901 | 135 626 | 128 632 | 66 702 | 0.31× |
| LogBatch ×1000 | 251 902 | 185 639 | 185 649 | 85 440 | 0.34× |
A few rows are slightly larger under Dense than under Fast on
tiny / non-repeating payloads (Tiny, Flat, Nested). That is the
expected 1-3 byte cost of the shape declaration prelude on a one-shot
struct emit — Markov / shape predictors need at least one repeat to
amortise. Wins start at Deep16 and grow with payload size.
| Payload | json | msgpack | qdf_fast | dense | qdf vs json | qdf vs msgpack |
|---|---|---|---|---|---|---|
| Tiny | 248 | 77 | 29 | 29 | 0.12× | 0.38× |
| Flat | 448 | 272 | 224 | 224 | 0.50× | 0.82× |
| Nested | 664 | 160 | 112 | 112 | 0.17× | 0.70× |
| Deep16 | 1200 | 312 | 264 | 264 | 0.22× | 0.85× |
| Wide ×1000 | 638 353 | 409 221 | 220 591 | 220 587 | 0.35× | 0.54× |
| LogBatch ×1000 | 442 536 | 407 698 | 251 838 | 251 860 | 0.57× | 0.62× |
| MapHeavy unique | 4912 | 3089 | 2359 | - | 0.48× | 0.76× |
| Float32Vec512 | 4384 | 4282 | 2113 | - | 0.48× | 0.49× |
| MapStringAny (repeated keys) | 790 | - | 345 | - | 0.44× | - |
| Payload | json | msgpack | qdf_fast (no intern) | qdf_fast (with intern wins) |
|---|---|---|---|---|
| Nested | 15 | 6 | 5 | - |
| Wide ×1000 | 5020 | 5007 | 5003 | - |
| LogBatch ×1000 | 7019 | 7007 | 7003 | - |
| MapHeavy 40-entry | 124 | 112 | 71 → 32 | −39 allocs from intern |
| MapHeavy repeated keys | 71 | 46 | - | 26 |
| MapStringAny repeated | 37 | - | - | 3 (12× fewer) |
| Float32Vec512 | 16 | 8 | 3 | - |
The intern win is biggest when:
- The same key set appears across many maps (typical for analytics / tracing telemetry).
- The Decoder is recycled via
sync.Pool(Unmarshal already does this).
Decode is allocation/GC-bound: an alloc_objects profile of a string-heavy
batch attributes ~99.85 % of all decode allocations to ReadString copying
string bodies out of the buffer. (Encode is already lean — 3 allocs/op, pooled
output buffer; the asymmetry is that decode allocs scale with the number of
values while encode allocs do not.)
WithNoCopy() aliases the input buffer instead of copying. On the 1000-row
LogBatch (i7-9750H, OptSpeed, count=10):
| decode | ns/op | B/op | allocs/op |
|---|---|---|---|
| default (copy) | ~635 000 | 251 829 | 7002 |
WithNoCopy() |
~371 000 | 155 813 | 3 |
≈ 1.7× faster, −38 % B/op, 7002 → 3 allocs. On the reflect-path Wide ×1000
fixture: 5003 → 4 allocs. It works on codegen types too — the generated
UnmarshalQDFOpts threads the flag through nested decodes.
Future lever (probe, not yet built): a safe decode-arena would copy bodies
into one owned slab (~1 alloc, lifetime-safe for the default path). Under
concurrent + GC-pressured load (GOGC=20, -cpu 8) the copy path runs at
~327 µs/op vs ~210 µs/op for the alloc-free path — a ~1.55× headroom an arena
could recover safely. Tracked as a separate follow-up.
- 256-slot direct-mapped hash table, fixed size, lives in the Decoder.
- On cache hit: zero-allocation string return.
- On miss: one
string(b)copy, stored. Collisions overwrite (no chain). - Survives across
qdf.Unmarshalcalls because the decoder is pooled. - Same shape as
go-json-experiment/json's intern.go.
BenchmarkStream_LogBatch1k_Dense encode through bytes.Buffer sink:
| Sender | total bytes written | over 1000 entries |
|---|---|---|
| json (one Marshal) | 251 902 | 251 B/entry |
| msgpack | 185 639 | 186 B/entry |
| qdf_fast (Marshal) | 185 649 | 186 B/entry |
| qdf_dense (Stream) | 107 416 | 107 B/entry |
Stream Dense halves the bytes on the wire vs single-shot Fast because
the intern table is shared across the entire stream — every repeated
level, service, host, region, msg value collapses to a
1-2-byte state reference after first sight.
The decoder validates length-prefixed payloads against the remaining
buffer (Decoder.CheckLength) before any make. A malicious wire
encoding claiming a 2-billion-element map can NOT cause an OOM — it
returns ErrShortBuffer instead. Verified by the fuzz suite (3 M+
iterations across FuzzDecoder_NeverPanics and FuzzRoundTrip_StringSlice,
plus reproducers stored in testdata/fuzz/).
What the test suite verifies (all under -race):
- Primitives round-trip at every wire-format boundary (int8/16/32/64, uint8/16/32/64, float32/64, fixstr boundary, str8/16/32 thresholds).
- Boundary integers: every power-of-two boundary from 0 to MaxInt64 and the corresponding negatives down to MinInt64.
- Strings: 0, 1, 31 (fixstr edge), 32, 255 (str8 edge), 256, 65535 (str16 edge), 65536, 1 MiB. Unicode + invalid-UTF-8 round-trip.
- Fast-paths: every specialized slice/map type vs the generic path.
- Truncated input: every prefix from 0 to N-1 decodes without panic.
- Bad magic / bad version: errors, not panics.
- Cross-mode interop: Fast-encoded bytes decode through the auto-detecting Decoder; same for Dense.
- Streaming: single-type and mixed-type (
Saleinterleaved withSig) Dense stream preserves intern table across messages. - Concurrency: 32 goroutines × 500 iterations × marshal+unmarshal
with
-race, plus dedicatedTestRace_AppendMarshal. No races, no cross-call state bleed. - Fuzz: 400 k+ iterations across
FuzzDecoder_NeverPanicsandFuzzRoundTrip_StringSlice. Never panics, round-trips agree. - Codegen: round-trip through generated
MarshalQDF/UnmarshalQDFplus interop withqdf.Unmarshalon the same bytes.
# Default build
go test -race -count=1 ./...
# Cross-format bench (Marshal at OptSpeed / OptQPack / OptBalanced vs json/msgpack)
cd bench && go test -bench='BenchmarkQPack_' -benchmem -benchtime=2s
# Whole-suite bench
cd bench && go test -bench=. -benchmem -benchtime=2s -timeout=10m
# QPack codec micro-benchmarks (root module)
go test -bench='BenchmarkQPack' -benchmem -benchtime=2s
# AVX2 bit-unpack (asm under qdf_simd; CPUID-gated at run time)
go test -tags qdf_simd -bench='BenchmarkBitUnpackFast' -benchmem -benchtime=2s
# Build-tag race sweeps
go test -tags qdf_reflect2 -race ./...
go test -tags qdf_simd -race ./...
go test -tags "qdf_simd qdf_reflect2" -race ./...
# Codegen
cd internal/codegen_test
go test -run TestGenerate .
go test -bench=. -benchmem -benchtime=2s
# Fuzz
go test -run=^$ -fuzz=FuzzDecoder_NeverPanics -fuzztime=30s
go test -run=^$ -fuzz=FuzzRoundTrip_StringSlice -fuzztime=30s
go test -run=^$ -fuzz=FuzzQPackBool -fuzztime=30s
go test -run=^$ -fuzz=FuzzQPackRawUint64 -fuzztime=30s