In one sentence. Every transaction gets packed and unpacked at least four times in its short life, so ComputeFlux swapped the general-purpose packing code for code generated specifically for each message type — cutting the memory allocations per operation from several down to exactly one.
Picture it like this. Packing a suitcase. The general-purpose approach measures each item, finds a box for it, then finds a bigger box to hold those boxes. The generated approach already knows exactly what's going in and reaches for one correctly sized case. Identical contents; very different amount of labor.
Why it matters. This is the least glamorous article in the series and arguably the most honest one — it declines to quote a speedup number because the codebase has no maintained benchmark to back it up. It's also a lesson that generalizes far beyond blockchains: on a hot path, the bookkeeping around the work routinely costs more than the work.
Short on time? The comparison with alternatives is the section with the transferable lesson.
Article 12 was about semantics — translating meaning across OpenAI's, Anthropic's, and Gemini's incompatible request formats. This article deliberately changes altitude: from protocol translation down to wire-format bytes, from what a request means to how many nanoseconds and heap allocations it costs to move a Tx struct on and off the network.
That's a jump from the edge of the Gateway to the guts of consensus, but it's one system. Every request that survives protocol conversion and the retry logic in Article 9 eventually becomes a transaction that must be serialized, gossiped, and stored. That path is where ComputeFlux's serialization choices live.
Blockchain consensus engines live and die by serialization performance. Every transaction entering the mempool gets deserialized for validation, re-serialized for gossip, deserialized for execution, and serialized again for storage. ComputeFlux's Tx struct — caller identity in Caller and CallerType, a signature, and an encoded Call payload that may hold contract calls, relay requests, or governance operations — passes through protobuf serialization at least four times in its short life.
Reflection-based marshaling, the kind gogofast-style implementations and stock proto.Marshal both do, allocates a non-trivial amount of heap per call. At meaningful throughput, the garbage collector burns measurable CPU reclaiming those short-lived allocations. That's the cost profile vtproto's code-generation approach exists to remove.
The Allocation Problem
To see why standard protobuf serialization allocates so heavily, consider what proto.Marshal(tx) does internally. It calls proto.Size(tx) to compute the serialized size by walking the message tree counting bytes — this allocates nothing but costs CPU. It calls make([]byte, size) to allocate the output buffer — the first heap allocation. It calls the internal marshal function, which for nested messages (like SysCall containing a ContractCall containing [][]byte args) allocates temporary buffers for each nesting level — a deeply nested message can trigger 5–10 additional allocations per serialization. And for []byte fields (signatures, caller addresses, WASM bytecode), standard protobuf copies the data into the output buffer rather than referencing the original — another allocation if the caller needs the original data preserved.
gogofast improved on stock protobuf with code generation that avoided some reflection overhead, but it retained the core allocation pattern: every marshal allocated a new buffer.
Why vtproto Specifically
Several zero-allocation protobuf solutions exist — vtprotobuf (PlanetScale), gogoprotobuf with buffer pooling, manual encoding/protobuf with pre-allocated buffers. The choice of vtproto came down to three factors.
Code generation, not reflection: vtproto generates dedicated MarshalVT() and UnmarshalVT() methods per message type — hand-unrolled serialization loops that know the exact field layout at compile time. No reflect, no interface boxing, no dynamic dispatch. For the Tx message, MarshalVT is roughly 80 lines of generated code that writes each field's tag, wire type, and value directly into a byte slice, with no function calls beyond encoding/binary for varint encoding.
Pre-sized allocation: MarshalVT calls SizeVT() first to compute the exact output size, then makes a single make([]byte, size) call — one allocation per marshal versus 5–10 for nested reflection-based marshaling, and exactly sized, with no overallocation and no subsequent trimming copy.
A single, exactly-sized allocation, with no pooling layer on top: ComputeFlux's protoio helper package (pkg/model/protoio) calls SizeVT() to compute the exact output size, does one make([]byte, size), and hands the slice to MarshalToVT. No sync.Pool wraps this in the current code — every protoio.Marshal and WriteMessage call allocates its own buffer. So the win over reflection-based marshaling comes entirely from collapsing several allocations into one correctly sized allocation, not from reusing buffers across calls.
Underneath MarshalToVT, the generated code for each message type still uses the standard vtprotobuf pattern of a MarshalToSizedBufferVT variant that writes from the end of the buffer backwards, which is what lets the one-shot, exactly-sized allocation work without a separate size-then-copy pass. That low-level detail is real; what's not present in this codebase is a buffer pool sitting on top of it.
Performance Quantification
A representative workload for reasoning about this migration is serializing a Tx struct with a ContractCall payload containing a handful of arguments (a couple of []byte fields, a uint64, a short string) — a typical smart contract invocation.
Allocations per Tx.Marshal: reflection-based marshaling costs several allocations per call — an output buffer plus per-nesting-level temporaries. vtproto, as wired up in ComputeFlux's protoio package, costs exactly one: the exactly-sized output buffer from SizeVT(). No pooling layer reduces it further in steady state.
CPU time per marshal: eliminating reflection overhead and collapsing multiple allocations down to one both contribute to lower CPU time per marshal, though ComputeFlux doesn't have a maintained gogofast-vs-vtproto benchmark in the current codebase to cite precise before/after numbers. The improvement should be expected to be more pronounced for larger, more deeply-nested messages, where reflection overhead and the number of avoided intermediate allocations both scale up; simple, shallow messages see proportionally less benefit.
GC pressure: because every marshal still performs one allocation (not zero), vtproto doesn't eliminate GC pressure from serialization entirely — it reduces the number of allocations per marshal from several down to one. That's still a meaningful reduction in the amount of short-lived garbage the collector has to scan, and should be expected to show up as fewer, shorter GC pauses relative to reflection-based marshaling, particularly at higher transaction throughput.
Benchmarking Methodology and Pitfalls
Measuring serialization performance honestly means controlling for four confounds.
- Warm-up. Go compiles ahead of time, so there's no JIT warm-up phase to exclude. Early loop iterations can still skew from cache effects and allocator or GC startup behavior, so a harness should let
testing.Brun enough iterations to amortize that away. - Allocation measurement.
testing.B.ReportAllocs()reports the average allocations per operation. Since theprotoiopath allocates exactly once per marshal with no pooling, that average should converge on one allocation per call in this codebase — not zero. - Message size sensitivity. The gain from dropping reflection isn't uniform. Very small messages are dominated by fixed function-call overhead; very large ones bottleneck on memory bandwidth no matter how you marshal them. The middle range, where ComputeFlux's typical transaction payloads sit, is where eliminating allocations pays best.
- Cross-validation. Whatever the benchmark, confirm that vtproto and reflection-based marshaling emit identical wire bytes for the same message. That validates correctness and rules out the compiler quietly optimizing the serialization away.
Comparison with Alternative Approaches
- gogoprotobuf with a manual buffer pool. Wrap the existing gogofast marshaler in a
sync.Poolfor output buffers. That removes the output-buffer allocation and leaves the nested-message temporaries reflection produces completely untouched. A partial improvement, well short of vtproto's single exactly-sized allocation. - Cap'n Proto. A different format entirely, zero-copy by design, where the wire format is the in-memory format. It would eliminate serialization overhead outright. It would also require migrating every message definition, every client, and all stored data — a cost judged unacceptable against the benefit, especially with vtproto already near-zero allocation on the existing schema.
- Hand-written serialization. Performance comparable to vtproto, maintenance burden enormous. Every field added or removed means updating hand-written code, with real risk of off-by-one field numbers and incorrect varint encoding. Code generation delivers the same speed with automated correctness.
The key insight from this evaluation: the bottleneck was never protobuf itself, it was the Go runtime's allocation and garbage-collection overhead. vtproto solves precisely that, by generating allocation-free code, without requiring a format migration or hand-written serializers.
What vtproto Cannot Do
vtproto is not a universal protobuf replacement. Three limits are worth knowing.
- No dynamic messages. It needs generated code, so a message type unknown at compile time — a generic registry dispatching by type URL, say — is out of reach. ComputeFlux's fixed types for transactions, contract calls, and governance operations play directly to its strengths.
- No JSON or text format. vtproto handles the binary wire format only. Debugging and logging still go through
protojsonor manual JSON marshaling, and neither path is performance-critical. - Maintenance overhead. Every
.protochange means regenerating the vtproto code, and ifprotoc-gen-go-vtprotodrifts out of sync with the protobuf library version, subtle bugs follow. CI guards against this by validating that generated code still matches the source protos.
Why Generated Code Instead of Reflection
Why generate code at all when Go's reflect package can serialize any struct? The answer is performance determinism. Reflection-based serialization has variable cost depending on struct layout, field count, and nesting depth — two messages with the same logical content but different in-memory representations (pointer vs. value fields) can serialize at different speeds. In a consensus system, this variability is undesirable: even if all validators produce identical outputs, different serialization speeds could contribute to timing-based consensus instability. Generated code eliminates the variability — every call to MarshalVT executes the same sequence of binary.PutUvarint and copy calls regardless of how the message was constructed, so performance is deterministic across all nodes running the same binary.
Integration Points
The vtproto migration touched every path where protobuf messages cross a boundary. Transaction signing: BytesForSig falls back to MarshalVT to produce the canonical byte representation that gets signed (contract calls use a dedicated byte-encoding instead) — any change to that serialization format would invalidate all existing signatures, a hard fork. P2P gossip: transactions broadcast to peers use MarshalVT for wire format, and all nodes must agree on it, making this another hard-fork boundary. Storage: blocks stored in PebbleDB (see Article 2, Contract Storage & Sealed PebbleDB) contain protobuf-serialized transactions — changing the format would make existing blocks unreadable. RPC: the JSON-RPC API exposes transactions to external clients using JSON, but the internal representation uses vtproto, and conversion between the two has to stay consistent.
The migration itself was sequenced to avoid a hard fork: vtproto produces byte-identical output to standard protobuf for valid messages — the only difference is how the bytes are produced (generated code vs. reflection), not the bytes themselves. This compatibility property allowed a gradual rollout where nodes running old and new code could coexist, validating that vtproto output matched gogofast output byte-for-byte before the old code path was removed.
Future Directions
The vtproto migration opened the door to further optimization: zero-copy deserialization. Currently, UnmarshalVT copies data from the input buffer into the message struct's fields. A true zero-copy implementation would reference the input buffer directly for []byte fields like Signature and Caller, avoiding the copy — but this is unsafe in Go, because the input buffer might be reused or garbage-collected while the message still references it. ComputeFlux hasn't adopted this yet, for exactly that safety reason, but the vtproto foundation makes it possible.
Key Takeaways
- A single transaction is packed and unpacked at least four times — once to validate it, once to gossip it, once to execute it, once to store it. Costs that look trivial per operation stop looking trivial when multiplied by four and then by throughput.
- The bottleneck was never the format. It was memory allocation and the garbage collection that follows. Generated code collapses several allocations per operation into one exactly-sized allocation.
- Note what this article refuses to claim. There is no buffer pool in this codebase and no maintained before-and-after benchmark, so no speedup figure is quoted. That restraint is worth imitating when reading performance claims anywhere else.
- In consensus, predictable beats fast. Generated code executes the same instruction sequence every time regardless of how the message was constructed. Reflection-based code varies with struct layout, and variance across validators is its own kind of problem.
- The migration avoided a hard fork because the output bytes are identical. Only the code producing them changed. That single property is what allowed old and new nodes to run side by side during the rollout instead of requiring a flag day.
The vtproto migration illustrates a principle that generalizes well beyond blockchain: when a hot path allocates, the allocation is often more expensive than the computation it supports. Replacing reflection with generated code attacks exactly that cost — a direct consequence of taking seriously that make([]byte) is not free, and that paying for it several times over on every transaction is a tax the system shouldn't carry.
Next — Article 14: Streaming SSE Protocol: back up to the edge, and to the hardest version of the translation problem from Article 12 — doing it word by word, as the answer is still being written.