Add opt-in unmarshal_slab feature: slab-allocated UnmarshalVTSlab with counting prescan - #169
Add opt-in unmarshal_slab feature: slab-allocated UnmarshalVTSlab with counting prescan#169merlimat wants to merge 9 commits into
Conversation
Slab[T] hands out zeroed elements carved from chunked backing arrays so N element allocations collapse into O(log N) chunk allocations; elements are handed out once and never reused, so pointers stay valid and a retained pointer pins its whole chunk. Reserve pre-sizes an empty slab from the counts gathered by CountFields, a single linear pass over the top-level records of a serialized message. Growth after an undershot reservation restarts small instead of doubling the reserved chunk.
Mirrors the mempool opt-in: a per-message extension plus slab= / slab-exclude= plugin flags, surfaced through GeneratedFile.ShouldSlab.
For every message that opts in with (vtproto.slab), generate a public UnmarshalVTSlab entry point: payloads under SlabUnmarshalThreshold fall back to UnmarshalVT; larger ones run a counting pre-pass over the top-level wire format, reserve exactly-sized slabs for the repeated message fields (singular sub-message chains inherit the parent count as an upper bound), and decode through arena-threaded unmarshalVTSlab helpers generated for the same-file message graph. The decode loops are the stock ones with only the allocation sites redirected: repeated and singular message fields, map values and oneof payloads come from per-type slabs, and scalar presence pointers are copied into per-type slabs instead of escaping to the heap. Unknown-field retention, merge semantics and required-field tracking are unchanged, and UnmarshalVT itself is untouched. Combining (vtproto.slab) with (vtproto.mempool) on one message is rejected.
Messages that opted into slab unmarshalling get arena-backed decoding through the bundled codecs without any call-site changes.
Covers repeated and singular message fields, map values, oneofs, recursion, packed scalars and every presence-pointer scalar kind. Tests assert equivalence with UnmarshalVT (fresh and merge decodes), unknown-field retention, no aliasing of the input buffer, no panics on truncated or corrupted input, and reduced allocations; the benchmark compares stock and slab decoding across payload sizes.
Deeply nested shapes exposed two weaknesses. First, reservation bounds now multiply the originating count by how many instances of each type one parent instance can reach through chains of plain singular message fields (memoized per file, cycles cut), and the root's own singular chains reserve constants — so document-style payloads get exact-sized chunks through several levels instead of only one. Second, a payload can exceed the byte threshold while carrying only a couple of repeated elements, where per-type chunk minimums cost more than the amortization saves; when the pre-pass counts fewer than SlabUnmarshalMinCount elements, UnmarshalVTSlab now falls back to UnmarshalVT.
A four-to-five-level document shape (order, party, addresses, geo, lines, products, dimensions, discounts, shipments, notes) with maps, oneofs and optional scalars at every level, in its own .proto file so it also exercises two per-file arenas coexisting in one package. BenchmarkUnmarshalNested compares UnmarshalVT and UnmarshalVTSlab across payload sizes; tests assert equivalence and reduced allocations.
The reservation rows are accumulated by ranging over the singularReach maps, so ordering them by first touch made the generated output vary from one generator run to the next — caught by CI's regenerate-and-diff step. Emit them in arena field registration order (declaration order) instead; the coefficients themselves were always order-independent sums.
mattlord
left a comment
There was a problem hiding this comment.
Hi @merlimat !
I see a few issues that I think we should address before merging.
-
I think that we should bound length-delimited records before converting to
intinprotohelpers/slab.go:91-105.CountFieldsconverts the untrusteduint64length tointand only checks the resulting index afterward. A counted field followed byMaxUint64-10makes the index wrap back to zero on 64-bit systems, so a slab-enabled RPC payload can loop forever in the pre-pass. I reproduced this with a two-second test timeout. I think we should reject lengths larger than the remaining buffer before conversion and add this exact overflow case throughUnmarshalVTSlab. -
I think that we should cap reservations derived from possible singular descendants in
features/unmarshal/slab.go:283-345. The multiplicity walk eagerly reserves every possible singular descendant even when none appears on the wire. With a legal five-level schema containing ten singular child fields per level, it generatedReserve(100000 * counts[0]); a valid 268-byte payload containing four empty roots allocated roughly 24.5 MB before decoding any children. Since the codecs select this path automatically, that seems like a significant remote memory-amplification/OOM risk. I think we should cap the total reservation using a payload-derived budget, or leave speculative descendants to normal slab growth, and add a sparse branching-schema regression test. -
I think that we should make
unmarshal_slabdepend onunmarshalinfeatures/unmarshal/slab.go:305-321. The generator acceptsfeatures=unmarshal_slab, but that configuration emitsUnmarshalVTSlabwithout emittingUnmarshalVT, even though both bypass paths call it. The resulting generated package does not compile. I think we should automatically includeunmarshal, or reject this feature combination with a clear generator error, and cover the standalone feature selection in a generation test.
Thanks, @merlimat ! ❤️
Motivation
For RPC servers, unmarshalling is where vtprotobuf still spends most of its allocations: every repeated message element, every nested singular message, and every explicit-presence scalar (
optional int64,optional string, …) is a separate heap object, so a batch of N puts costs O(N·fields) allocations that all become GC work the moment the request is done.poolhelps for the top-level message but does not compose down the tree (children still allocate, and pooling has its own lifecycle burden), andunmarshal_unsafeonly addressesstring/bytescopies.The observation behind this PR: for the dominant server pattern — decode a request, handle it, drop it — all those little objects have exactly the same lifetime. They can be carved out of a handful of chunks instead of allocated one by one, without changing the message types, the decode semantics, or the call sites.
This was first validated as a hand-written prototype against real Oxia schemas; two lessons from that prototype shaped the design:
What is generated
Messages opt in per message. For each opted-in root, the generator emits a public entry point; the messages reachable from it (within the same
.protofile) get unexported arena-threaded decoders, and the file gets one unexported arena type:The decode loops are the stock
unmarshalloops with only the allocation sites redirected:append(m.Puts, &PutRequest{})becomesappend(m.Puts, a.f_PutRequest.Next()), and the element decode threads the arena;m.Shard = &v) become value copies into per-type scalar slabs (m.Shard = a.f_int64.NextValue(v)).protohelpers.Slab[T]is a trivial generic bump allocator over[]Tchunks (reserve-exact, or 8→1024 doubling growth when a reservation is absent or undershoots).Design
Opt-in, mirroring
mempool. A new message optionoption (vtproto.slab) = true;(extension 64103) plusslab=/slab-exclude=plugin flags, surfaced throughGeneratedFile.ShouldSlab. The feature itself isunmarshal_slab, registered the same wayunmarshal_unsafeis — a mode of theunmarshalfeature, so the emission code is shared rather than forked.A separate entry point, not a changed
UnmarshalVT.UnmarshalVToutput is byte-for-byte unchanged; callers that retain messages keep a safe default. Adoption is still zero-effort for the common path because the bundled gRPC and DRPC codecs preferUnmarshalVTSlabwhen the message implements it — the schema owner's opt-in is what flips the switch.Per-file arena, same-file closure. The arena struct and the threaded decoders are generated into the same output file, so the output is self-contained under per-file generation (protoc and buf both). Fields whose types live in other files/packages, well-known types, and groups keep the stock path; unknown-field retention works because the generated code lives in the message's own package.
Counting pre-pass and reservations. One linear scan over the top-level records counts every repeated-message and message-valued-map field of the root in a single pass (
CountFields). Reservations are multiplicity-aware: for each counted type, a memoized walk computes how many instances of every type one parent instance can allocate through chains of plain singular message fields, so bounds likeReserve(2 + 3*counts[0])fall out; the root's own singular chains reserve constants. Oneof variants are excluded from bounds (at most one is set; reserving all would systematically over-allocate) and fall back to growth. Reservations are hints only — undershoot falls back to chunked growth, malformed input just stops the scan early — so there is no correctness coupling between the pre-pass and the decoder.Two bypasses, both from the prototype's scars. Payloads under
SlabUnmarshalThreshold(256 B) skip everything. Payloads whose counted elements sum belowSlabUnmarshalMinCount(4) fall back after the scan: a payload can be large yet carry only a couple of elements spread across many types, where per-type chunk minimums cost more than amortization saves (the nested benchmark below was +21% before this bypass, exact stock parity after).Unchanged semantics. Merge-on-non-fresh, unknown-field retention, required-field tracking, extension ranges,
string/bytescopying (no aliasing of the input buffer) are all identical toUnmarshalVT— the emission body is shared, only allocation sites differ. Combining(vtproto.slab)with(vtproto.mempool)on one message is rejected at generation time, since returning chunk-interior pointers to a pool is a footgun.The trade-off (why this is strictly opt-in)
Slab elements are carved out of shared chunks. A decoded message behaves exactly like an independently allocated one, except that retaining any pointer into the message graph (a sub-message, a repeated element, an
optionalfield pointer) keeps that pointer's whole chunk alive. This is the right trade for messages whose lifetime ends with the RPC that carried them, and the wrong one for messages you dissect and retain pieces of — hence per-message opt-in by the schema owner, and prominent documentation in the README and on the generated method.Performance
testproto/slabbenchmarks, Apple M1 Max,UnmarshalVTvsUnmarshalVTSlabon the same types.Flat batch (
SlabBatch: repeated items + map + presence-heavy fields):Nested document (
NestedOrder: 4–5 message levels, maps, oneofs, optionals at every level):Real-world validation — Oxia's production schemas regenerated with this branch and run through Oxia's serde benchmark harness (count=5 medians):
Geomean across the engaged Oxia scenarios: -13% decode time, -46% allocs/op. The generated code matches the hand-written prototype's numbers exactly, and the B/op overhead (unused chunk tails) stays within a few percent on realistic shapes.
Testing
testproto/slabcovers repeated/singular/map/oneof/recursive message fields and every presence-pointer scalar kind, across two proto files (two arenas in one package). Tests assert: equivalence withUnmarshalVTon fresh and merged decodes, unknown-field retention through decode + re-encode, no aliasing of the input buffer, no panics on truncation/corruption at every byte offset, and strictly reduced allocations.protohelpersunit tests cover slab growth/reservation/undershoot andCountFields(top-level-only counting, group bail-out, truncation, huge phantom lengths).UnmarshalVTemission is untouched (so conformance coverage is unaffected).Limitations / future work
bytes/stringcontents keep their stock allocation.unmarshal_slab_unsafecombination yet; slab uses the safe string/bytes copies.