Encodage rapide et dynamique des Protocol Buffers en Go

Vincent Bernat

Les Pro­to­col Buf­fers sont un choix po­pu­laire pour la sé­ria­li­sa­tion de don­nées struc­tu­rées en rai­son de leur taille com­pacte, de leur ra­pi­dité de trai­te­ment, de leur in­dé­pen­dance du lan­gage cible et de leur com­pa­ti­bi­lité. D’autres al­ter­na­tives existent, no­tam­ment Cap’n Proto, CBOR et Avro.

Les struc­tures de don­nées sont ha­bi­tuel­le­ment dé­crites dans un fi­chier de dé­fi­ni­tion de pro­to­cole (.proto). Le com­pi­la­teur protoc et un gref­fon spé­ci­fique à un lan­gage les conver­tissent en 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"

Ak­vo­rado col­lecte les flux ré­seau à l’aide de IPFIX ou sFlow, les dé­code à l’aide de GoFlow2, les en­code en Pro­to­col Buf­fers et les en­voie à Kafka pour les sto­cker dans une base de don­nées Click­House. La col­lecte d’un nou­veau champ, comme les adresses MAC source et des­ti­na­tion, né­ces­site des mo­di­fi­ca­tions à plu­sieurs en­droits, y com­pris le fi­chier de dé­fi­ni­tion de pro­to­cole et le code de mi­gra­tion pour Click­House. De plus, le coût est sup­porté par tous les uti­li­sa­teurs1. Il se­rait agréable d’avoir un schéma com­mun à l’ap­pli­ca­tion et de per­mettre aux uti­li­sa­teurs d’ac­ti­ver ou de désac­ti­ver les champs dont ils ont besoin.

Bien que le prin­ci­pal ob­jec­tif soit la flexi­bi­lité, nous ne vou­lons pas sa­cri­fier les per­for­mances. Sur ce front, c’est un vé­ri­table suc­cès: lors de la mise à ni­veau de 1.6.4 à 1.7.1, les per­for­mances de dé­co­dage et d’en­co­dage ont presque doublé ! 🤗

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)

Encodage plus rapide des Protocol Buffers#

J’uti­lise le code sui­vant pour me­su­rer les per­for­mances du pro­ces­sus de dé­co­dage et d’en­co­dage. Ini­tia­le­ment, la mé­thode Decode() est une simple fa­çade au-des­sus du pro­duc­teur GoFlow2. Elle stocke les don­nées dé­co­dées dans la struc­ture en mé­moire gé­né­rée par protoc. Par la suite, cer­taines don­nées se­ront en­co­dées di­rec­te­ment pen­dant le dé­co­dage des flux. C’est pour­quoi nous me­su­rons à la fois le dé­co­dage et l’en­codage2.

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)
                    }
                }
            }
        })
    }
}

L’im­plé­men­ta­tion Go de ré­fé­rence pour les Pro­to­col Buf­fers, google.​golang.​org/​protobuf n’est pas la plus ef­fi­cace. Pen­dant long­temps, l’al­ter­na­tive la plus cou­rante était go­go­pro­to­buf. Ce­pen­dant, le pro­jet est main­te­nant ob­so­lète. vt­pro­to­buf est un bon rem­pla­cement3.

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)

Encodage dynamique des Protocol Buffers#

Nous avons dé­sor­mais une bonne ré­fé­rence de dé­part. Voyons com­ment en­co­der nos Pro­to­col Buf­fers sans un fi­chier .proto. Le for­mat uti­lisé est re­la­ti­ve­ment simple et re­pose beau­coup sur les en­tiers à lon­gueur variable.

Les en­tiers à lon­gueur va­riable sont un moyen ef­fi­cace d’en­co­der des en­tiers non si­gnés en uti­li­sant un nombre va­riable d’oc­tets, de un à dix, les pe­tites va­leurs uti­li­sant moins d’oc­tets. Ils fonc­tionnent en scin­dant les en­tiers par groupe de 7 bits et en uti­li­sant le 8ème bit comme si­gnal de conti­nua­tion : il est mis à 1 pour tous les groupes sauf le dernier.

Encodage des entiers à longueur variable dans les Protocol Buffers : conversion de 150
Encodage des entiers à longueur variable

Pour notre uti­li­sa­tion, nous avons be­soin de deux types seule­ment : les en­tiers à lon­gueur va­riable et les sé­quences d’oc­tets. Une sé­quence d’oc­tets est co­dée en la pré­fixant par sa lon­gueur sous forme d’en­tier à lon­gueur va­riable. Lorsqu’un mes­sage est codé, chaque couple clé-va­leur est trans­formé en un en­re­gis­tre­ment com­posé d’un nu­méro de champ, d’un type et de la va­leur. Le nu­méro de champ et le type sont co­dés en un seul en­tier de lon­gueur va­riable ap­pelé « tag ».

Message codé avec les Protocol Buffers : 3 entiers et 2 séquences d'octets
Message codé avec les Protocol Buffers

Nous uti­li­sons les fonc­tions bas ni­veau sui­vantes pour construire le mes­sage codé :

Notre abs­trac­tion pour le schéma contient les in­for­ma­tions ap­pro­priées pour co­der un mes­sage (ProtobufIndex) et pour gé­né­rer un fi­chier de dé­fi­ni­tion de pro­to­cole (les champs com­men­çant par 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
}

Nous avons quelques mé­thodes simples au­tour des fonc­tions protowire pour en­co­der di­rec­te­ment les champs lors du dé­co­dage des flux. Ils sautent les champs désac­ti­vés ou ceux déjà en­co­dés mais non ré­pé­tables. Voici un ex­trait du dé­co­deur sFlow:

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)

Les champs né­ces­saires dans la suite du trai­te­ment, comme les adresses source et des­ti­na­tion, sont sto­ckés non co­dés dans une struc­ture séparée :

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
}

Le ta­bleau protobuf contient les don­nées en­co­dées. Il est ini­tia­lisé avec une ca­pa­cité de 500 oc­tets pour évi­ter les re­di­men­sion­ne­ments pen­dant l’en­co­dage. Il y a éga­le­ment quelques oc­tets ré­ser­vés au dé­but pour pou­voir en­co­der la taille to­tale en tant qu’en­tier de lon­gueur va­riable. Lors de la fi­na­li­sa­tion de l’en­co­dage, les champs res­tants sont ajou­tés et la lon­gueur du mes­sage est in­sé­rée dans l’es­pace libre au début :

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
}

Mi­ni­mi­ser les al­lo­ca­tions est es­sen­tiel pour main­te­nir de bonnes per­for­mances. Les tests doivent être exé­cu­tés avec le dra­peau -benchmem pour sur­veiller le nombre d’al­lo­ca­tions : cha­cune en­traîne un coût in­di­rect pour le ra­masse-miette. Le pro­fi­leur Go est un ou­til pré­cieux pour iden­ti­fier les zones du code qui peuvent être op­timisées :

$ 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

Après avoir uti­lisé le schéma in­terne au lieu du code gé­néré à par­tir du fi­chier de dé­fi­ni­tion, les per­for­mances se sont amé­lio­rées. Ce­pen­dant, cette com­pa­rai­son n’est pas tout à fait équi­table car moins d’in­for­ma­tions sont dé­co­dées et, au­pa­ra­vant, GoFlow2 dé­co­dait les flux vers sa propre struc­ture qui était en­suite co­piée dans notre 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)

Concer­nant les tests, nous uti­li­sons github.​com/​jhump/​protoreflect : le pa­quet protoparse ana­lyse le fi­chier de dé­fi­ni­tion que nous avons construit dy­na­mi­que­ment et le pa­quet dynamic dé­code les mes­sages. Je­tez un œil à la mé­thode ProtobufDecode() me­thod pour plus de détails4.

Pour ob­te­nir les chiffres fi­naux, j’ai aussi op­ti­misé le dé­co­dage dans GoFlow2. Il s’ap­puyait for­te­ment sur binary.​Read(). Cette fonc­tion peut uti­li­ser la ré­flexion dans cer­tains cas et chaque ap­pel al­loue un ta­bleau d’oc­tets pour lire les don­nées. En la rem­pla­çant par une ver­sion plus ef­fi­cace, on ob­tient en­core une amé­lio­ra­tion no­table des per­formances :

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)

Il est main­te­nant plus fa­cile de col­lec­ter de nou­velles don­nées et le com­po­sant re­ce­vant les flux est dé­sor­mais plus rapide ! 🚅

Mise à jour (07.2025)

Buf a pu­blié hy­perpb, un ana­ly­seur Pro­to­buf dy­na­mique qui est en­core plus per­for­mant que le code gé­néré. Il n’y a pas en­core d’équi­valent pour construire des mes­sages Protobuf.

Note

La plu­part des pa­ra­graphes ont été tra­duits de l’an­glais par ChatGPT en uti­li­sant les ins­truc­tions sui­vantes : “From now on, I will paste Mark­down code in Eng­lish and I would like you to trans­late it to French. Keep the mark­down markup and en­close the re­sult into a code block. Thanks.” Le ré­sul­tat a été lé­gè­re­ment édité si né­ces­saire. Com­paré à DeepL, ChatGPT est ca­pable de conser­ver le for­ma­tage, les an­gli­cismes, mais son fran­çais est moins bon et il est né­ces­saire de lui rap­pe­ler ré­gu­liè­re­ment les ins­tructions.