How MarshalVT and UnmarshalVT Methods Work Without Reflection in protobuf-go-lite
The protobuf-go-lite generator produces static, compile-time determined serialization routines that encode and decode protobuf messages using direct field access and low-level binary operations, completely eliminating Go's reflection machinery.
The aperturerobotics/protobuf-go-lite repository provides a lightweight alternative to the standard Go protobuf runtime. Unlike generic serialization libraries that rely on reflect.TypeOf or reflect.Value, the generated MarshalVT and UnmarshalVT methods use static code generation to achieve high-performance, reflection-free protobuf encoding.
Static Code Generation Strategy
The protobuf-go-lite tool generates explicit marshalling and unmarshalling routines for every protobuf message. These routines are completely static—they know the exact memory layout of each message at compile time and therefore never invoke Go’s reflection package.
The generated code follows the same architectural pattern used by the official google.golang.org/protobuf generator, but it is trimmed down to the essentials needed by the “lite” runtime. This approach allows the compiler to inline hot paths and optimize the serialization logic as if it were hand-written.
Step-by-Step MarshalVT Implementation
Size Calculation with SizeVT()
Before allocating memory, MarshalVT() determines the exact serialized size by calling SizeVT(). This generated method walks through every field in the message and accumulates the wire-encoded size of each element, including varints, fixed-size fields, and length-delimited payloads.
In types/known/wrapperspb/wrappers.pb.go, the SizeVT() method for DoubleValue calculates the size of the Value field plus any unknown fields:
func (m *DoubleValue) SizeVT() (n int) {
if m == nil {
return 0
}
var l int
_ = l
if m.Value != 0 {
n += 9 // 1 byte tag + 8 bytes fixed64
}
l = len(m.unknownFields)
n += l
return n
}
Buffer Allocation and Reverse Encoding
MarshalVT() allocates a byte slice of the exact length returned by SizeVT() and delegates to MarshalToSizedBufferVT. This method implements reverse-order encoding: it starts at the end of the buffer and writes fields backwards toward the beginning.
This technique eliminates the need for temporary buffers or extra copies. Because the encoder writes from the end, it can immediately return len(dAtA) - i as the number of bytes written, where i is the final position index.
In wrappers.pb.go, the MarshalToSizedBufferVT method for DoubleValue demonstrates this pattern:
func (m *DoubleValue) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
i := len(dAtA)
_ = i
var l int
_ = l
if len(m.unknownFields) > 0 {
i -= len(m.unknownFields)
copy(dAtA[i:], m.unknownFields)
}
if m.Value != 0 {
i -= 8
binary.LittleEndian.PutUint64(dAtA[i:], uint64(math.Float64bits(m.Value)))
i--
dAtA[i] = 0x9 // tag (field 1, wire type 1)
}
return len(dAtA) - i, nil
}
Low-Level Encoding Primitives
The generated code relies on a small set of optimized helper functions from the lite runtime rather than reflection:
- Fixed-size fields (
float64,float32,uint64, etc.) usebinary.LittleEndian.PutUint64orPutUint32for direct byte conversion. - Varint fields use
EncodeVarintfromprotobuf-go-lite.goto write variable-length integers. - Length-delimited fields encode the length as a varint followed by the raw payload bytes.
- Unknown fields are appended unchanged to preserve forward compatibility.
The EncodeVarint implementation in protobuf-go-lite.go handles the variable-length encoding without any type introspection:
func EncodeVarint(x uint64) []byte {
var buf [10]byte
n := binary.PutUvarint(buf[:], x)
return buf[:n]
}
UnmarshalVT Implementation Details
Forward Parsing Without Reflection
UnmarshalVT walks the input byte slice sequentially, decoding wire-format tags and values. For each field number encountered, the generated code contains a direct switch case that writes the decoded value into the corresponding struct field using simple assignment.
In wrappers.pb.go, the UnmarshalVT method for DoubleValue parses the wire format directly:
func (m *DoubleValue) UnmarshalVT(dAtA []byte) error {
l := len(dAtA)
iNdEx := 0
for iNdEx < l {
preIndex := iNdEx
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
fieldNum := int32(wire >> 3)
wireType := int(wire & 0x7)
if wireType == 4 {
return fmt.Errorf("proto: DoubleValue: wiretype end group for non-group")
}
if fieldNum <= 0 {
return fmt.Errorf("proto: DoubleValue: illegal tag %d (wire type %d)", fieldNum, wireType)
}
switch fieldNum {
case 1:
if wireType != 1 {
return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType)
}
if iNdEx+8 > l {
return io.ErrUnexpectedEOF
}
v := uint64(binary.LittleEndian.Uint64(dAtA[iNdEx:]))
m.Value = math.Float64frombits(v)
iNdEx += 8
default:
iNdEx = preIndex
skippy, err := skip(dAtA[iNdEx:])
if err != nil {
return err
}
if (skippy < 0) || (iNdEx+skippy) < 0 {
return ErrInvalidLength
}
if (iNdEx + skippy) > l {
return io.ErrUnexpectedEOF
}
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
iNdEx += skippy
}
}
if iNdEx > l {
return io.ErrUnexpectedEOF
}
return nil
}
Handling Unknown Fields
When UnmarshalVT encounters a field number not recognized in the switch statement, it captures the raw bytes into the unknownFields slice. This preserves forward compatibility without requiring reflection to determine the field type.
Unsafe Variant for Performance
For scenarios where the caller guarantees a well-formed payload, UnmarshalVTUnsafe provides a variant that skips bounds checking and nil checks. This method uses the same parsing logic but removes safety overhead for maximum throughput.
Complete Working Example
The following example demonstrates round-trip serialization using the generated MarshalVT and UnmarshalVT methods without any reflection:
package main
import (
"fmt"
"log"
// import a generated message type
"github.com/aperturerobotics/protobuf-go-lite/types/known/wrapperspb"
)
func main() {
// Build a message
m := &wrapperspb.Int64Value{Value: 42}
// ---------- Marshal ----------
// Serialize without reflection
b, err := m.MarshalVT()
if err != nil {
log.Fatalf("marshal error: %v", err)
}
fmt.Printf("binary: %x\n", b)
// ---------- Unmarshal ----------
// Decode back into a fresh struct
var decoded wrapperspb.Int64Value
if err := decoded.UnmarshalVT(b); err != nil {
log.Fatalf("unmarshal error: %v", err)
}
fmt.Printf("decoded: %+v\n", decoded)
}
Running this program outputs the varint-encoded protobuf payload (08 2a) and confirms the original value round-trips correctly, all without invoking Go's reflection machinery.
Key Source Files and Architecture
The reflection-free serialization layer is implemented across these critical files:
-
protobuf-go-lite.go— Core runtime helpers includingEncodeVarint,ConsumeVarint, andDecodeVarintfunctions that handle wire-format encoding without type introspection. -
types/known/wrapperspb/wrappers.pb.go— Example generated code demonstratingMarshalVT,MarshalToSizedBufferVT,UnmarshalVT,UnmarshalVTUnsafe, andSizeVTimplementations for wrapper types. -
generator/generator.go— The code generator that emits theMarshalVTandUnmarshalVTmethods into.pb.gofiles during the protobuf compilation phase. -
features/marshal/marshal.go— Registers the marshal feature with the generator and integrates the generatedMarshalVTmethods into the public API surface.
Together, these components form a fully static serialization layer that mimics the full protobuf runtime while minimizing binary size and eliminating reflection overhead.
Summary
- Static Generation:
protobuf-go-litegenerates explicit marshalling code for each message at compile time, eliminating runtime reflection. - Reverse Encoding:
MarshalToSizedBufferVTwrites fields backwards from the end of a pre-allocated buffer, avoiding temporary copies. - Direct Field Access:
UnmarshalVTuses switch statements on field numbers to write directly into struct fields without type introspection. - Zero Reflection: No usage of
reflect.TypeOf,reflect.Value, orprotoimpl.Xhelpers occurs in the generated code or lite runtime.
Frequently Asked Questions
Why does protobuf-go-lite avoid reflection in MarshalVT and UnmarshalVT?
Reflection in Go requires runtime type inspection through reflect.Value and reflect.Type, which adds significant CPU overhead and prevents compiler inlining optimizations. By generating static code that knows the exact field layout at compile time, protobuf-go-lite achieves near hand-written performance while maintaining a minimal runtime footprint.
How does reverse-order encoding improve performance in MarshalVT?
MarshalToSizedBufferVT writes fields starting from the end of the pre-allocated byte slice and moving backwards toward the beginning. This technique eliminates the need for intermediate buffers or memory copies when handling length-delimited fields, and allows the function to return the number of bytes written by simply calculating len(dAtA) - i where i is the final position index.
What is the difference between UnmarshalVT and UnmarshalVTUnsafe?
UnmarshalVT includes bounds checking, nil pointer checks, and validation of wire types to ensure safe parsing of arbitrary input. UnmarshalVTUnsafe provides the same parsing logic but removes these safety checks, offering maximum throughput when the caller can guarantee that the input buffer is well-formed and properly bounded.
Can I use MarshalVT and UnmarshalVT with standard protobuf messages?
These methods are specifically generated by the protobuf-go-lite compiler for messages defined in that ecosystem. While the wire format is compatible with standard Protocol Buffers, the generated MarshalVT and UnmarshalVT methods themselves are only available on message types generated by the protobuf-go-lite tool, not on messages generated by the standard protoc-gen-go.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →