Encodage rapide et dynamique des Protocol Buffers en Go

Vincent Bernat

Les Protocol Buffers sont un choix populaire pour la sérialisation de données structurées en raison de leur taille compacte, de leur rapidité de traitement, de leur indépendance du langage cible et de leur compatibilité. D’autres alternatives existent, notamment Cap’n Proto, CBOR et Avro.

Les structures de données sont habituellement décrites dans un fichier de définition de protocole (.proto). Le compilateur protoc et un greffon spécifique à un langage les convertissent 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"

Akvorado collecte les flux réseau à l’aide de IPFIX ou sFlow, les décode à l’aide de GoFlow2, les encode en Protocol Buffers et les envoie à Kafka pour les stocker dans une base de données ClickHouse. La collecte d’un nouveau champ, comme les adresses MAC source et destination, nécessite des modifications à plusieurs endroits, y compris le fichier de définition de protocole et le code de migration pour ClickHouse. De plus, le coût est supporté par tous les utilisateurs1. Il serait agréable d’avoir un schéma commun à l’application et de permettre aux utilisateurs d’activer ou de désactiver les champs dont ils ont besoin.

Bien que le principal objectif est la flexibilité, nous ne voulons pas sacrifier les performances. Sur ce front, c’est un véritable succès: lors de la mise à niveau de 1.6.4 à 1.7.1, les performances de décodage et d’encodage 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’utilise le code suivant pour mesurer les performances du processus de décodage et d’encodage. Initialement, la méthode Decode() est une simple façade au-dessus du producteur GoFlow2. Elle stocke les données décodées dans la structure en mémoire générée par protoc. Par la suite, certaines données seront encodées directement pendant le décodage des flux. C’est pourquoi nous mesurons à la fois le décodage et l’encodage2.

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’implémentation Go de référence pour les Protocol Buffers, google.golang.org/protobuf n’est pas la plus efficace. Pendant longtemps, l’alternative la plus courante était gogoprotobuf. Cependant, le projet est maintenant obsolète. vtprotobuf est un bon remplacement3.

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ésormais une bonne référence départ. Voyons comment encoder nos Protocol Buffers sans un fichier .proto. Le format utilisé est relativement simple et repose beaucoup sur les entiers à longueur variable.

Les entiers à longueur variable sont un moyen efficace d’encoder des entiers non signés en utilisant un nombre variable d’octets, de un à dix, les petites valeurs utilisant moins d’octets. Ils fonctionnent en scindant les entiers par groupe de 7 bits et en utilisant le 8ème bit comme signal de continuation : 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 utilisation, nous avons besoin de deux types seulement : les entiers à longueur variable et les séquences d’octets. Une séquence d’octets est codée en la préfixant par sa longueur sous forme d’entier à longueur variable. Lorsqu’un message est codé, chaque couple clé-valeur est transformé en un enregistrement composé d’un numéro de champ, d’un type et de la valeur. Le numéro de champ et le type sont codés en un seul entier de longueur variable appelé « tag ».

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

Nous utilisons les fonctions bas niveau suivantes pour construire le message codé :

Notre abstraction pour le schéma contient les informations appropriées pour coder un message (ProtobufIndex) et pour générer un fichier de définition de protocole (les champs commenç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 autour des fonctions protowire pour encoder directement les champs lors du décodage des flux. Ils sautent les champs désactivés ou ceux déjà encodés mais non répétables. Voici un extrait du décodeur 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écessaires dans la suite du traitement, comme les adresses source et destination, sont stockés non codés dans une structure 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 tableau protobuf contient les données encodées. Il est initialisé avec une capacité de 500 octets pour éviter les redimensionnements pendant l’encodage. Il y a également quelques octets réservés au début pour pouvoir encoder la taille totale en tant qu’entier de longueur variable. Lors de la finalisation de l’encodage, les champs restants sont ajoutés et la longueur du message est insérée dans l’espace 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
}

Minimiser les allocations est essentiel pour maintenir de bonnes performances. Les tests doivent être exécutés avec le drapeau -benchmem pour surveiller le nombre d’allocations : chacune entraîne un coût indirect pour le ramasse-miette. Le profileur Go est un outil précieux pour identifier les zones du code qui peuvent être optimisé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 utilisé le schéma interne au lieu du code généré à partir du fichier de définition, les performances se sont améliorées. Cependant, cette comparaison n’est pas tout à fait équitable car moins d’informations sont décodées et, auparavant, GoFlow2 décodait les flux vers sa propre structure qui était ensuite copié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)

Concernant les tests, nous utilisons github.com/jhump/protoreflect : le paquet protoparse analyse le fichier de définition que nous avons construit dynamiquement et le paquet dynamic décode les messages. Jetez un œil à la méthode ProtobufDecode() method pour plus de détails4.

Pour obtenir les chiffres finaux, j’ai aussi optimisé le décodage dans GoFlow2. Il s’appuyait fortement sur binary.Read(). Cette fonction peut utiliser la réflexion dans certains cas et chaque appel alloue un tableau d’octets pour lire les données. En la remplaçant par une version plus efficace, on obtient encore une amélioration notable des performances :

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 maintenant plus facile de collecter de nouvelles données et le composant recevant les flux est désormais plus rapide ! 🚅

Note

La plupart des paragraphes ont été traduits de l’anglais par ChatGPT en utilisant les instructions suivantes : “From now on, I will paste Markdown code in English and I would like you to translate it to French. Keep the markdown markup and enclose the result into a code block. Thanks.” Le résultat a été légèrement édité si nécessaire. Comparé à DeepL, ChatGPT est capable de conserver le formatage, les anglicismes, mais son français est moins bon et il est nécessaire de lui rappeler régulièrement les instructions.


  1. Bien que les champs vides ne sont pas sérialisés en Protocol Buffers, les colonnes vides dans ClickHouse occupent de la place, même si elles se compressent bien. De plus, les champs inutilisés sont toujours décodés et peuvent encombrer l’interface. ↩︎

  2. Il existe une fonction similaire pour NetFlow. Les protocoles NetFlow et IPFIX sont moins complexes à décoder que sFlow car ils utilisent une structure TLV plus simple. ↩︎

  3. vtprotobuf génère un code mieux optimisé en supprimant un niveau d’indirection. Il produit du code codant chaque champ en octets :

    if m.OutIfSpeed != 0 {
        i = encodeVarint(dAtA, i, uint64(m.OutIfSpeed))
        i--
        dAtA[i] = 0x6
        i--
        dAtA[i] = 0xd8
    }
    
    ↩︎
  4. Il existe également un paquet protoprint pour générer le fichier de définition. Je ne l’ai pas utilisé. ↩︎