hyperpb-go

repository·main·Indexed 20 days ago

https://github.com/bufbuild/hyperpb-go

A high-performance, read-only dynamic Protobuf message library for Go designed as a replacement for dynamicpb. It utilizes a table-driven parsing (TDP) VM to provide fast parsing and reflection-based access for workloads like generic services or transcoding proxies. Key features include support for Profile-Guided Optimization (PGO), arena-based memory management via hyperpb.Shared to reduce GC overhead, and compatibility with protojson and protovalidate. It supports amd64 and arm64 architectures.

Tokens
8.9K
Snippets
27
Records
40
Agent score
70%

What's inside hyperpb-go

  1. What is hyperpb?

    main

    hyperpb is a highly optimized dynamic message library for Protobuf designed for read-only workloads. It serves as a high-performance drop-in replacement for dynamicpb (the canonical solution in protobuf-go).

    Key characteristics:

    • Performance: Uses an efficient VM based on table-driven parsing (TDP), beating dynamicpb by up to 10x and often outperforming generated code by 2-3x.
    • Read-Only: It is optimized for reading and reflection. Mutation is not supported; any operation attempting to mutate an already-parsed message will panic.
    • Reflection-Based: Currently, it only supports manipulating messages through the protoreflect API.
  2. How tag matching and fallback work

    main

    The parser uses an optimized equality check for tag matching. Each field parser contains the expected tag (a fusion of the field number and wire type).

    1. Matching: If the tag matches, the parser calls the corresponding parser thunk, which consumes the field value and may advance the field table pointer.
    2. Non-matching (Retry): If the tag does not match, the parser advances the field table pointer and retries a limited number of times.
    3. Fallback: If retries fail, the parser enters a fallback mode where it performs a full varint decode of the tag, searches the number table, and potentially skips unknown fields.
  3. Reuse memory with hyperpb.Shared

    main

    To reduce allocation latency and bypass the Go garbage collector, use hyperpb.Shared. A hyperpb.Shared instance acts as a pool of resources shared by messages resulting from the same parse.

    Workflow:

    1. Create or retain a hyperpb.Shared instance.
    2. Use shared.NewMessage(msgType) to obtain a message.
    3. Call shared.Free() to reclaim resources for reuse.

    CRITICAL: A message (msg) must not outlive the call to shared.Free(). Failure to follow this will result in memory errors that Go cannot protect you from.

    type requestContext struct {
        shared *hyperpb.Shared
        types map[string]*hyperpb.MessageType
        // Additional context fields...
    }
    
    func (c *requestContext) Handle(req Request) {
        msgType := c.types[req.Type]
        msg := c.shared.NewMessage(msgType)
        defer c.shared.Free()
    
        c.process(msg, req, ...)
    }
  4. How the hyperpb compiler and interpreter work together

    main

    The hyperpb architecture is split into two primary stages to maximize performance:

    1. The Compiler: A runtime component that consumes descriptors to generate Types. These Types act as highly optimized programs for parsing specific Protobuf messages. The compiler determines memory layout (including headers, bitfields for optional/bool fields, and field storage) and selects Archetypes (specialized parsing strategies) for each field.
    2. The Interpreter: A highly optimized VM that executes the programs generated by the compiler. It uses a threaded interpreter model where state is passed by-value to minimize stack spilling, and it manages its own recursion stack rather than using Go's native recursion.
  5. Understand parser thunks and specialization

    main

    To maximize performance and optimize branch prediction (BHT), hyperpb-go uses highly specialized parser thunks.

    Instead of using generic functions with internal branching (e.g., one function handling both optional and singular fields), the library provides distinct thunks for each specific case. This amortizes the cost of branching into the thunk call itself.

    Key behaviors:

    • Specialization: Distinct thunks exist for different presence disciplines (optional vs. singular) and different wire types (varint, zigzag, fixed integers).
    • Archetype Reuse: To maintain instruction cache friendliness, thunks are reused across types that share the same underlying wire format. For example, int32, uint32, and enum fields use the same thunk because they are all parsed as 32-bit varints. Similarly, fixed32, sfixed32, and float32 share a thunk.
    • Pointer Advancement: Some thunks predict the next record's field to optimize pointer movement. For non-packed repeated fields, a thunk might predict the next record is the same field. For packed repeated fields, the thunk typically advances the parser to the next field immediately.
  6. How partial tag decoding is implemented

    main

    To avoid the overhead of full varint decoding, the parser uses Partial Tag Decode. Since parser thunks know the expected wire format, the parser can compare raw bytes without performing full shifts or variable-length masking.

    The process:

    1. Byte Loading: The parser loads up to 8 bytes. It uses bitwise operations to count sign bits (treating the uint64 as a SIMD-like vector) to detect overlong tags (up to 10 bytes).
    2. Masking: It uses the varint byte length to mask off bytes after the highest relevant order one. For 8, 9, or 10-byte varints, no masking is applied.
    3. Clearing: Sign bits are cleared. For a match to succeed, all bytes beyond the non-zero bytes for the tag must be zero.
    4. Caching: This partially decoded value is cached once per record to be compared against tdp.Tag values in the subsequent phase.
  7. How tdp.Type represents a compiled message

    main

    A *tdp.Type is a pointer to a location within a tdp.Library (a large byte buffer). It contains the compiled instructions for parsing and accessing a message. Key components include:

    • Field Layout: Fields are laid out in field index order following the tdp.Type to facilitate efficient reflection.
    • TypeParser: Contains tdp.FieldParsers, which include offsets and parsing information, as well as a hand-written hashmap mapping field numbers to field indices for fast resynchronization.
    • No reflect.Type: The compiled type does not use reflect.Type for dynamic messages. Instead, it relies on raw loads and stores to avoid the overhead of Go's reflection and write barriers.
  8. Understand the memory management and arena model

    main

    To achieve high performance and avoid Garbage Collector (GC) overhead, hyperpb uses an arena-based memory model:

    • Arena Allocation: All memory is allocated on arenas (via the arena package). Fields are often zero-copy references to the original parse input.
    • GC Side-stepping: The implementation leverages specific Go memory semantics to avoid write barriers. By storing pointers in heap memory that is not of 'GC shape' (using uintptr or similar techniques) and ensuring those pointers are transitively reachable by the GC via the arena, the library avoids the performance penalty of atomic global updates during pointer stores.
    • Zero-copy: Where possible, fields are references to the original input buffer to minimize allocations.
  9. Optimize parsing with Profile-Guided Optimization (PGO)

    main

    You can use Profile-Guided Optimization (PGO) to optimize the parser based on the actual structure of your messages (e.g., predicting repeated field sizes).

    Offline PGO:

    1. Compile a message type using hyperpb.CompileMessageDescriptor.
    2. Create a profile using msgType.NewProfile().
    3. Parse a corpus of messages using hyperpb.WithRecordProfile(profile, samplingRate) to record data.
    4. Recompile the type using msgType.Recompile(profile).

    Online PGO: You can sample data during live request flows by using hyperpb.WithRecordProfile with a low sampling rate (e.g., 0.01 for 1%) and asynchronously calling Recompile on a background goroutine to update the MessageType used by your application.

    func compilePGO(
        md protoreflect.MessageDescriptor,
        corpus [][]byte,
    ) (*hyperpb.MessageType, error) {
        // Compile the type without any profiling information.
        msgType := hyperpb.CompileMessageDescriptor(md)
    
        // Construct a new profile recorder.
        profile := msgType.NewProfile()
    
        // Parse all of the specimens in the corpus, making sure to record a profile for all of them.
        s := new(hyperpb.Shared)
        for _, specimen := range corpus {
            if err := s.NewMessage(msgType).Unmarshal(
                specimen,
                hyperpb.WithRecordProfile(profile, 1.0),
            ); err != nil {
                return nil, err
            }
            s.Free()
        }
    
        // Recompile with the profile.
        return msgType.Recompile(profile), nil
    }
  10. How to use hyperpb with dynamic types from a registry

    main

    If you are working with types downloaded at runtime (e.g., from a schema registry), use hyperpb.CompileFileDescriptorSet to create a parser. This function takes a *descriptorpb.FileDescriptorSet and a protoreflect.FullName to identify the target message type. Like the compiled-in approach, you must cache the resulting msgType for performance.

    func processDynamicMessage(
        schema *descriptorpb.FileDescriptorSet,
        messageName protoreflect.FullName,
        data []byte,
    ) error {
        // Compile the dynamic type (Remember to cache this!)
        msgType, err := hyperpb.CompileFileDescriptorSet(schema, messageName)
        if err != nil {
            return err
        }
    
        msg := hyperpb.NewMessage(msgType)
        if err := proto.Unmarshal(data, msg); err != nil {
            return err
        }
    
        // Iterate over populated fields using the Range iterator
        for field, value := range msg.Range {
            // Do something with each populated field
        }
        return nil
    }
  11. How to use hyperpb with compiled-in types

    main

    To use hyperpb with types already present in your binary, you must first compile a parser for that specific message descriptor using hyperpb.CompileMessageDescriptor. You should cache the resulting msgType to avoid redundant compilation overhead. Once compiled, you can allocate a message with hyperpb.NewMessage and parse data using standard proto.Unmarshal.

    package main
    
    import (
        "buf.build/go/hyperpb"
        "google.golang.org/protobuf/proto"
        weatherv1 "buf.build/gen/go/bufbuild/hyperpb-examples/protocolbuffers/go/example/weather/v1"
    )
    
    func main() {
        // 1. Compile the type (Cache this result!)
        msgType := hyperpb.CompileMessageDescriptor(
            (*weatherv1.WeatherReport)(nil).ProtoReflect().Descriptor(),
        )
    
        // 2. Allocate a fresh message
        msg := hyperpb.NewMessage(msgType)
    
        // 3. Parse using standard proto.Unmarshal
        if err := proto.Unmarshal(weatherDataBytes, msg); err != nil {
            panic(err)
        }
    
        // 4. Access fields via reflection
        fields := msgType.Descriptor().Fields()
        val := msg.Get(fields.ByName("region"))
        fmt.Println(val)
    }
  12. Run benchmarks and profiling for hyperpb

    main

    To verify performance and ensure changes do not degrade speed, use the provided Makefile commands. The package includes benchmarks comparing it against protobuf-go and dynamicpb.

    • make bench: Runs all benchmarks.
    • make profile: Runs a CPU profile and opens a local pprof instance.
    • make asm: Dumps the assembly of the benchmarks for manual inspection.
    make bench
    make profile
    make asm