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.
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 ».
Nous utilisons les fonctions bas niveau suivantes pour construire le message codé :
protowire.AppendTag()
code un « tag »,protowire.AppendVarint()
code un entier,protowire.AppendBytes()
ajoute des octets sans modification.
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.
-
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. ↩︎
-
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. ↩︎
-
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 }
-
Il existe également un paquet
protoprint
pour générer le fichier de définition. Je ne l’ai pas utilisé. ↩︎