Fast and dynamic encoding of Protocol Buffers in Go

Vincent Bernat

Pro­to­col Buf­fers are a pop­u­lar choice for se­ri­al­iz­ing struc­tured data due to their com­pact size, fast pro­cess­ing speed, lan­guage in­de­pen­dence, and com­pat­i­bil­ity. Other al­ter­na­tives ex­ist: Cap’n Proto, CBOR, and Avro.

Usu­ally, data struc­tures are de­scribed in a proto def­i­ni­tion file (.proto). The protoc com­piler and a lan­guage-spe­cific plugin con­vert it into code:

$ head flow-4.proto
syntax = "proto3";
package decoder;
option go_package = "akvorado/inlet/flow/decoder";

message FlowMessagev4 {

  uint64 TimeReceived = 2;
  uint32 SequenceNum = 3;
  uint64 SamplingRate = 4;
  uint32 FlowDirection = 5;
$ protoc -I=. --plugin=protoc-gen-go --go_out=module=akvorado:. flow-4.proto
$ head inlet/flow/decoder/flow-4.pb.go
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
//      protoc-gen-go v1.28.0
//      protoc        v3.21.12
// source: inlet/flow/data/schemas/flow-4.proto

package decoder

import (
        protoreflect "google.golang.org/protobuf/reflect/protoreflect"

Akvo­rado col­lects net­work flows us­ing IPFIX or sFlow, de­codes them with GoFlow2, en­codes them to Pro­to­col Buf­fers, and sends them to Kafka to be stored in a Click­House data­base. Col­lect­ing a new field, such as source and des­ti­na­tion MAC ad­dresses, re­quires mod­i­fi­ca­tions in mul­ti­ple places, in­clud­ing the proto def­i­ni­tion file and the Click­House mi­gra­tion code. More­over, the cost is paid by all users.1 It would be nice to have an ap­pli­ca­tion-wide schema and let users en­able or dis­able the fields they need.

While the main goal is flex­i­bil­ity, we do not want to sac­ri­fice per­for­mance. On this front, this is quite a suc­cess: when up­grad­ing from 1.6.4 to 1.7.1, the de­cod­ing and en­cod­ing per­for­mance al­most doubled! 🤗

goos: linux
goarch: amd64
pkg: akvorado/inlet/flow
cpu: AMD Ryzen 5 5600X 6-Core Processor
                            │ initial.txt  │              final.txt              │
                            │    sec/op    │   sec/op     vs base                │
Netflow/with_encoding-12      12.963µ ± 2%   7.836µ ± 1%  -39.55% (p=0.000 n=10)
Sflow/with_encoding-12         19.37µ ± 1%   10.15µ ± 2%  -47.63% (p=0.000 n=10)

Faster Protocol Buffers encoding#

I use the fol­low­ing code to bench­mark both the de­cod­ing and en­cod­ing process. Ini­tially, the Decode() method is a thin layer above the GoFlow2 pro­ducer and stores the de­coded data into the in-mem­ory struc­ture gen­er­ated by protoc. Later, some of the data will be en­coded di­rectly dur­ing flow de­cod­ing. This is why we mea­sure both the de­cod­ing and the en­coding.2

func BenchmarkDecodeEncodeSflow(b *testing.B) {
    r := reporter.NewMock(b)
    sdecoder := sflow.New(r)
    data := helpers.ReadPcapPayload(b,
        filepath.Join("decoder", "sflow", "testdata", "data-1140.pcap"))

    for _, withEncoding := range []bool{true, false} {
        title := map[bool]string{
            true:  "with encoding",
            false: "without encoding",
        }[withEncoding]
        var got []*decoder.FlowMessage
        b.Run(title, func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                got = sdecoder.Decode(decoder.RawFlow{
                    Payload: data,
                    Source: net.ParseIP("127.0.0.1"),
                })
                if withEncoding {
                    for _, flow := range got {
                        buf := []byte{}
                        buf = protowire.AppendVarint(buf, uint64(proto.Size(flow)))
                        proto.MarshalOptions{}.MarshalAppend(buf, flow)
                    }
                }
            }
        })
    }
}

The canon­i­cal Go im­ple­men­ta­tion for Pro­to­col Buf­fers, google.​golang.​org/​protobuf is not the most ef­fi­cient one. For a long time, peo­ple were re­ly­ing on gogo­pro­to­buf. How­ever, the project is now dep­re­cated. A good re­place­ment is vt­protobuf.3

goos: linux
goarch: amd64
pkg: akvorado/inlet/flow
cpu: AMD Ryzen 5 5600X 6-Core Processor
                            │ initial.txt │             bench-2.txt             │
                            │   sec/op    │   sec/op     vs base                │
Netflow/with_encoding-12      12.96µ ± 2%   10.28µ ± 2%  -20.67% (p=0.000 n=10)
Netflow/without_encoding-12   8.935µ ± 2%   8.975µ ± 2%        ~ (p=0.143 n=10)
Sflow/with_encoding-12        19.37µ ± 1%   16.67µ ± 2%  -13.93% (p=0.000 n=10)
Sflow/without_encoding-12     14.62µ ± 3%   14.87µ ± 1%   +1.66% (p=0.007 n=10)

Dynamic Protocol Buffers encoding#

We have our base­line. Let’s see how to en­code our Pro­to­col Buf­fers with­out a .proto file. The wire for­mat is sim­ple and re­lies a lot on vari­able-width integers.

Vari­able-width in­te­gers, or var­ints, are an ef­fi­cient way of en­cod­ing un­signed in­te­gers us­ing a vari­able num­ber of bytes, from one to ten, with small val­ues us­ing fewer bytes. They work by split­ting in­te­gers into 7-bit pay­loads and us­ing the 8th bit as a con­tin­u­a­tion in­di­ca­tor, set to 1 for all pay­loads ex­cept the last.

Variable-width integers encoding in Protocol Buffers: conversion of 150 to a varint
Variable-width integers encoding in Protocol Buffers

For our us­age, we only need two types: vari­able-width in­te­gers and byte se­quences. A byte se­quence is en­coded by pre­fix­ing it by its length as a varint. When a mes­sage is en­coded, each key-value pair is turned into a record con­sist­ing of a field num­ber, a wire type, and a pay­load. The field num­ber and the wire type are en­coded as a sin­gle vari­able-width in­te­ger called a tag.

Message encoded with Protocol Buffers: three varints, two sequences of bytes
Message encoded with Protocol Buffers

We use the fol­low­ing low-level func­tions to build the out­put buffer:

Our schema ab­strac­tion con­tains the ap­pro­pri­ate in­for­ma­tion to en­code a mes­sage (ProtobufIndex) and to gen­er­ate a proto def­i­ni­tion file (fields start­ing with Protobuf):

type Column struct {
    Key       ColumnKey
    Name      string
    Disabled  bool

    // […]
    // For protobuf.
    ProtobufIndex    protowire.Number
    ProtobufType     protoreflect.Kind // Uint64Kind, Uint32Kind, …
    ProtobufEnum     map[int]string
    ProtobufEnumName string
    ProtobufRepeated bool
}

We have a few helper meth­ods around the protowire func­tions to di­rectly en­code the fields while de­cod­ing the flows. They skip dis­abled fields or non-re­peated fields al­ready en­coded. Here is an ex­cerpt of the sFlow decoder:

sch.ProtobufAppendVarint(bf, schema.ColumnBytes, uint64(recordData.Base.Length))
sch.ProtobufAppendVarint(bf, schema.ColumnProto, uint64(recordData.Base.Protocol))
sch.ProtobufAppendVarint(bf, schema.ColumnSrcPort, uint64(recordData.Base.SrcPort))
sch.ProtobufAppendVarint(bf, schema.ColumnDstPort, uint64(recordData.Base.DstPort))
sch.ProtobufAppendVarint(bf, schema.ColumnEType, helpers.ETypeIPv4)

For fields that are re­quired later in the pipe­line, like source and des­ti­na­tion ad­dresses, they are stored un­en­coded in a sep­a­rate structure:

type FlowMessage struct {
    TimeReceived uint64
    SamplingRate uint32

    // For exporter classifier
    ExporterAddress netip.Addr

    // For interface classifier
    InIf  uint32
    OutIf uint32

    // For geolocation or BMP
    SrcAddr netip.Addr
    DstAddr netip.Addr
    NextHop netip.Addr

    // Core component may override them
    SrcAS     uint32
    DstAS     uint32
    GotASPath bool

    // protobuf is the protobuf representation for the information not contained above.
    protobuf      []byte
    protobufSet   bitset.BitSet
}

The protobuf slice holds en­coded data. It is ini­tial­ized with a ca­pac­ity of 500 bytes to avoid re­siz­ing dur­ing en­cod­ing. There is also some re­served room at the be­gin­ning to be able to en­code the to­tal size as a vari­able-width in­te­ger. Upon fi­nal­iz­ing en­cod­ing, the re­main­ing fields are added and the mes­sage length is prefixed:

func (schema *Schema) ProtobufMarshal(bf *FlowMessage) []byte {
    schema.ProtobufAppendVarint(bf, ColumnTimeReceived, bf.TimeReceived)
    schema.ProtobufAppendVarint(bf, ColumnSamplingRate, uint64(bf.SamplingRate))
    schema.ProtobufAppendIP(bf, ColumnExporterAddress, bf.ExporterAddress)
    schema.ProtobufAppendVarint(bf, ColumnSrcAS, uint64(bf.SrcAS))
    schema.ProtobufAppendVarint(bf, ColumnDstAS, uint64(bf.DstAS))
    schema.ProtobufAppendIP(bf, ColumnSrcAddr, bf.SrcAddr)
    schema.ProtobufAppendIP(bf, ColumnDstAddr, bf.DstAddr)

    // Add length and move it as a prefix
    end := len(bf.protobuf)
    payloadLen := end - maxSizeVarint
    bf.protobuf = protowire.AppendVarint(bf.protobuf, uint64(payloadLen))
    sizeLen := len(bf.protobuf) - end
    result := bf.protobuf[maxSizeVarint-sizeLen : end]
    copy(result, bf.protobuf[end:end+sizeLen])

    return result
}

Min­i­miz­ing al­lo­ca­tions is crit­i­cal for main­tain­ing en­cod­ing per­for­mance. The bench­mark tests should be run with the -benchmem flag to mon­i­tor al­lo­ca­tion num­bers. Each al­lo­ca­tion in­curs an ad­di­tional cost to the garbage col­lec­tor. The Go pro­filer is a valu­able tool for iden­ti­fy­ing ar­eas of code that can be op­timized:

$ go test -run=__nothing__ -bench=Netflow/with_encoding \
>         -benchmem -cpuprofile profile.out \
>         akvorado/inlet/flow
goos: linux
goarch: amd64
pkg: akvorado/inlet/flow
cpu: AMD Ryzen 5 5600X 6-Core Processor
Netflow/with_encoding-12             143953              7955 ns/op            8256 B/op        134 allocs/op
PASS
ok      akvorado/inlet/flow     1.418s
$ go tool pprof profile.out
File: flow.test
Type: cpu
Time: Feb 4, 2023 at 8:12pm (CET)
Duration: 1.41s, Total samples = 2.08s (147.96%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) web

Af­ter us­ing the in­ter­nal schema in­stead of code gen­er­ated from the proto def­i­ni­tion file, the per­for­mance im­proved. How­ever, this com­par­i­son is not en­tirely fair as less in­for­ma­tion is be­ing de­coded and pre­vi­ously GoFlow2 was de­cod­ing to its own struc­ture, which was then copied to our own version.

goos: linux
goarch: amd64
pkg: akvorado/inlet/flow
cpu: AMD Ryzen 5 5600X 6-Core Processor
                            │ bench-2.txt  │             bench-3.txt             │
                            │    sec/op    │   sec/op     vs base                │
Netflow/with_encoding-12      10.284µ ± 2%   7.758µ ± 3%  -24.56% (p=0.000 n=10)
Netflow/without_encoding-12    8.975µ ± 2%   7.304µ ± 2%  -18.61% (p=0.000 n=10)
Sflow/with_encoding-12         16.67µ ± 2%   14.26µ ± 1%  -14.50% (p=0.000 n=10)
Sflow/without_encoding-12      14.87µ ± 1%   13.56µ ± 2%   -8.80% (p=0.000 n=10)

As for test­ing, we use github.​com/​jhump/​protoreflect: the protoparse pack­age parses the proto def­i­ni­tion file we gen­er­ate and the dynamic pack­age de­codes the mes­sages. Check the ProtobufDecode() method for more details.4

To get the fi­nal fig­ures, I have also op­ti­mized the de­cod­ing in GoFlow2. It was re­ly­ing heav­ily on binary.​Read(). This func­tion may use re­flec­tion in cer­tain cases and each call al­lo­cates a byte ar­ray to read data. Re­plac­ing it with a more ef­fi­cient ver­sion pro­vides the fol­low­ing im­provement:

goos: linux
goarch: amd64
pkg: akvorado/inlet/flow
cpu: AMD Ryzen 5 5600X 6-Core Processor
                            │ bench-3.txt  │             bench-4.txt             │
                            │    sec/op    │   sec/op     vs base                │
Netflow/with_encoding-12       7.758µ ± 3%   7.365µ ± 2%   -5.07% (p=0.000 n=10)
Netflow/without_encoding-12    7.304µ ± 2%   6.931µ ± 3%   -5.11% (p=0.000 n=10)
Sflow/with_encoding-12        14.256µ ± 1%   9.834µ ± 2%  -31.02% (p=0.000 n=10)
Sflow/without_encoding-12     13.559µ ± 2%   9.353µ ± 2%  -31.02% (p=0.000 n=10)

It is now eas­ier to col­lect new data and the in­let com­po­nent is faster! 🚅

Up­date (2025-07)

Buf re­leased hy­perpb, a dy­namic Pro­to­buf parser that is even more per­for­mant than gen­er­ated code. There is no equiv­a­lent for build­ing Pro­to­buf mes­sages yet.

Notice

Some para­graphs were ed­i­to­ri­al­ized by ChatGPT, us­ing “ed­i­to­ri­al­ize and keep it short” as a prompt. The re­sult was proof­read by a hu­man for cor­rect­ness. The main idea is that ChatGPT should be bet­ter at Eng­lish than me.