Protobuf mit mehr als 2GB/s parsen: Entwurf eines Hochgeschwindigkeits-Interpreters in C mit Tail Calls (2021)
(blog.reverberate.org)- Mit der Ergänzung von
musttailin Clang lassen sich nun auch in C-artigen Sprachen garantierte Tail Calls nutzen; angewendet auf einen Protobuf-Parser wurde damit eine Leistung von mehr als 2GB/s demonstriert - Der Kern besteht darin, Funktionsaufrufe eher wie
jmpstatt wiecallzu machen, sodass sich die Stack-Nutzung bei aufeinanderfolgenden Aufrufen von O(n) auf O(1) reduziert und wie eine Schleife behandeln lässt - Das Protobuf-Wire-Format interpretiert Tag/Wert-Paare und muss zu Feldern in beliebiger Reihenfolge verzweigen; damit hat die klassische Struktur aus
while+switchein ähnliches Optimierungsproblem wie die Opcode-Dispatch eines Interpreters - Der experimentelle Parser von upb verbindet statt einer einzigen großen Funktion kleine Parserfunktionen per Tail Call und vermeidet auf dem schnellen Pfad Stack-Nutzung, Register-Spills sowie Prolog/Epilog
- Dieser Ansatz hat die Einschränkung, dass eingemischte Non-Tail-Calls die Codequalität stark verschlechtern und
musttaileine nicht standardisierte Erweiterung ist; für den produktiven Einsatz eines schnellen Parsers braucht es daher Aufrufkonventionen und Maßnahmen zur Portabilität
Hochgeschwindigkeits-Protobuf-Parsing durch Clang musttail
- Dem Clang-Main-Branch wurde das Statement-Attribut
[[clang::musttail]]/__attribute__((musttail))hinzugefügt, das in C, C++ und Objective-C garantierte Tail Calls ermöglicht - Tail Calls werden hier nicht als Technik aus der funktionalen Programmierung genutzt, sondern als Optimierungswerkzeug zur Senkung von Verzweigungskosten in Parsern und Interpretern
- Die Anwendung dieser Technik auf das Protobuf-Parsing demonstrierte in
upbpull/310 eine Parsing-Leistung von mehr als 2GB/s- Vorgestellt wurde das als Ergebnis, das mehr als doppelt so schnell wie der bisherige Spitzenwert sei
- Da mehrere Techniken gemeinsam dazu beigetragen haben, wäre die Aussage „allein Tail Calls machten es doppelt so schnell“ nicht korrekt
- Tail Calls waren jedoch einer der entscheidenden Faktoren, die diese Leistungssteigerung ermöglichten
- Spätere Änderungen werden in A Tail Calling Interpreter For Python (And Other Updates) behandelt
Warum Tail Calls wie eine Schleife funktionieren
- Ein Tail Call ist der letzte Funktionsaufruf, den eine Funktion unmittelbar vor ihrer Rückkehr ausführt
- Wenn Tail-Call-Optimierung angewendet wird, erzeugt der Compiler statt eines normalen
calleinejmp-Instruktion- Das Anlegen eines neuen Stack-Frames und das Speichern der Rücksprungadresse entfallen
- Der Aufrufer
f()springt direkt zum Aufgerufeneng() g()kehrt dann direkt zu der Funktion zurück, dief()aufgerufen hatte
- Aufgrund dieser Eigenschaft können Tail Calls eine Schleifenstruktur ersetzen
- Selbst bei
naufeinanderfolgenden Tail Calls sinkt die Stack-Nutzung von O(n) auf O(1) - Der Overhead von
callentfällt, sodass sich Funktionsaufrufe wie gewöhnliche Verzweigungen behandeln lassen
- Selbst bei
- Die Idee ist nicht neu und reicht bis zu Guy Steeles Paper von 1977 und den „Lambda Papers“ aus den Jahren 1975 bis 1980 zurück
- Clang konnte Tail Calls bereits in optimierten Builds wie
-O2optimieren, aber das bisherige Verhalten war eher best effort- In nicht optimierten Builds konnte weiterhin ein echter
callerzeugt werden - Um Tail Calls sicher als Schleifenstruktur nutzen zu können, muss die Optimierung in allen Build-Modi garantiert sein
musttailliefert genau diese Garantie
- In nicht optimierten Builds konnte weiterhin ein echter
Der gleiche Engpass in Interpreter-Schleifen und Protobuf-Parsern
- Mike Pall von LuaJIT schrieb den LuaJIT-2.x-Interpreter nicht in C, sondern in Assembler, und sah darin einen wichtigen Grund für dessen hohe Geschwindigkeit
- C-Compiler stoßen im Hauptloop eines Interpreters vor allem auf zwei Probleme
- Je größer die Funktion und je komplexer der Kontrollfluss, desto schwerer kann der Register-Allokator wichtige Daten in Registern halten
- Wenn schneller und langsamer Pfad in derselben Funktion vermischt sind, verschlechtert der langsame Pfad auch die Codequalität des schnellen Pfads
- Auch das Protobuf-Wire-Format hat eine interpreterähnliche Struktur
- Das Wire-Format besteht aus einer Folge von Tag/Wert-Paaren
- Ein Tag enthält Feldnummer und Wire Type
- Das Tag verhält sich ähnlich wie ein Opcode, der vorgibt, wie die zugehörigen Felddaten zu parsen sind
- Feldnummern können in beliebiger Reihenfolge auftreten, daher muss der Code darauf vorbereitet sein, an nahezu jede Stelle zu dispatchen
- Klassische Protobuf-Parser nutzten meist eine Struktur mit
switchinnerhalb einerwhile-Schleife; über den Großteil der Existenz von Protobuf galt das als State of the Art - In realen Parsing-Abläufen können an fast jedem Schritt Ausnahmen wie falscher Wire Type, beschädigte Daten oder das Erreichen des Pufferendes auftreten
- Der schnelle Pfad sollte möglichst kurz und stabil bleiben
- Schwierige Fälle benötigen größeren und komplexeren Fallback-Code und teils auch Out-of-Line-Funktionsaufrufe
Entwurf des Tail-Call-basierten upb-Parsers
- Der experimentelle Parser von upb verwendet nicht eine einzige große Parsing-Funktion, sondern trennt jede Operation in eine kleine Funktion auf
- Jede Funktion ruft die nächste Operation per Tail Call auf
- Dank der x86-64-Aufrufkonvention werden gemeinsame Parsing-Argumente in Registern übergeben
- Alle Parsing-Funktionen verwenden dieselbe Argumentmenge, um Datenbewegungen zwischen Aufrufen zu minimieren
- Die beispielhafte Parserfunktion für ein Fixed-Width-Feld mit 4 Byte arbeitet wie folgt
- Sie dekodiert Feldinformationen aus
data - Wenn der Wire Type nicht passt, macht sie
MUSTTAIL returnnachfallback() - Sie überspringt das Tag und speichert die Daten in der Nachricht
- Danach liest sie das nächste Tag und tail-callt
dispatch(), das zum passenden Feldparser verzweigt
- Sie dekodiert Feldinformationen aus
- Der von Clang erzeugte Assembler enthält auf dem schnellen Pfad keinen Prolog/Epilog, keine Register-Spills und keine Stack-Nutzung
- Die einzigen Ausstiegspunkte sind
jmpzufallbackoderdispatch - Da sich die Argumente bereits in den richtigen Registern befinden, ist kein zusätzlicher Code zur Parameterübergabe nötig
- Die einzigen Ausstiegspunkte sind
- Konzeptionell behandelt diese Struktur den großen Interpreter-Loop als eine einzige komplexe Funktion; die tatsächliche Implementierung zerlegt ihn jedoch in Funktionen auf Basis von Basic Blocks und übergibt den Kontrollfluss per Tail Call
- Wenn schneller und langsamer Pfad auf getrennte Funktionen aufgeteilt werden, ist es weniger wahrscheinlich, dass Änderungen am Fallback-Code die Codequalität des schnellen Pfads beeinträchtigen
- Bei Bedarf kann
noinlineInlining verhindern - Die Assembler-Sequenz des schnellen Pfads lässt sich damit praktisch festschreiben
- Bei Bedarf kann
Codegenerierungsqualität in einem LuaJIT-Beispiel
- Wendet man dasselbe Muster auf ein LuaJIT-Beispiel an, lässt sich in C Code erzeugen, der nahe an handgeschriebenen Assembler herankommt
- Die Beispiel-Funktion
ADDVNführt folgende Schritte aus- Sie extrahiert Register- und Konstantenindex aus der Instruktion
- Bei fehlgeschlagener Typprüfung springt sie in den Fallback
- Sie addiert eine Konstante zum Registerwert
- Sie liest den nächsten Opcode und tail-callt die passende Funktion aus der Opcode-Tabelle
- Die verbleibenden Verbesserungsmöglichkeiten im erzeugten Assembler sind relativ klein
- Hinter einer bedingten Verzweigung entsteht noch ein separates
jmp - Statt
jmp qword ptr [rsi + 8*rax]wird erst inraxgeladen und dannjmp raxverwendet
- Hinter einer bedingten Verzweigung entsteht noch ein separates
- Solche Punkte werden als kleine, in Clang verbesserbare Fragen der Codegenerierung behandelt
Einschränkungen durch Non-Tail-Calls und Portabilität
- Der wichtigste Vorsichtspunkt bei diesem Ansatz ist, dass Non-Tail-Calls innerhalb einer Funktion die Assemblerqualität stark verschlechtern
- Schon ein einzelner Non-Tail-Call erzwingt die Erzeugung eines Stack-Frames
- Viele Daten können daraufhin auf den Stack gespillt werden
- Um das zu vermeiden, braucht es die Disziplin, andere Funktionsaufrufe entweder zu inlinen oder ausschließlich als Tail Call auszuführen
- Beim Protobuf-Parsing ist die Verarbeitung von Varints ein typischer schwieriger Punkt
- Der häufige und schnelle Fall ist ein 1-Byte-Varint
- Längere Varints sind kein Fehler, kommen aber seltener vor
- Wenn diese Sonderbehandlung inline erfolgt, kann sich die Codequalität des schnellen Pfads verschlechtern
- Erfolgt der Wechsel in eine Fallback-Funktion per Tail Call, lässt sich die ursprüngliche Operation nach der Verarbeitung nicht einfach wiederaufnehmen; der Fallback muss die Operation also vollständig zu Ende führen
- Das führt zu Codeduplizierung und zusätzlicher Komplexität
- Im Update vom 2025-01-27 wurde ergänzt, wie sich dieses Problem über Aufrufkonventionen abmildern lässt
__attribute__((preserve_most))ist eine Aufrufkonvention für Fallback-Funktionen, die die Verantwortung für das Erhalten fast aller Register auf den Callee verlagert und damit die Spill-Kosten in Richtung Fallback verschiebt- Ein Clang-Crash-Bug im Zusammenhang mit diesem Attribut wurde 2023 behoben
__attribute__((preserve_none))ist eine Aufrufkonvention für Tail-Calling-Funktionen, die den Aufwand für Register-Erhalt eliminiert und mehr Register für Argumente nutzt- Von beiden Ansätzen wird
preserve_noneals weniger invasiv und damit als die bessere Wahl bewertet
- Eine weitere Einschränkung ist, dass
musttaileine nicht standardisierte Compiler-Erweiterung ist- Es bleibt zu hoffen, dass sie sich auch in GCC, Visual C++ und darüber hinaus verbreitet und standardisiert wird, kurzfristig ist das jedoch nicht zu erwarten
- Ohne
musttailist pro konzeptioneller Schleifeniteration mindestens ein echtesreturnnötig - In upb ist dieser Fallback bislang noch nicht implementiert; voraussichtlich wird ein Makro benötigt, das je nach Verfügbarkeit von
musttailentweder per Tail Call andispatchweitergibt oder einfach zurückkehrt
Stand der Anwendung in upb und Erweiterungspotenzial
- Der Parser mit mehr als 2GB/s wurde bei
upbeingereicht, einer kleinen in C geschriebenen Protobuf-Bibliothek - Der betreffende Code funktioniert vollständig und besteht alle Protobuf-Conformance-Tests, war zum Zeitpunkt des Schreibens jedoch noch nirgendwo ausgerollt
- In der C++-Version von Protobuf ist dieses Design nicht umgesetzt
- Durch das spätere Update von
upbauf die Nutzung vonmusttailwurde eine große Hürde für die Produktivsetzung des schnellen Parsers beseitigt - Dieselbe Technik könnte auch großen in C geschriebenen Sprachinterpretern wie Python, Ruby, PHP und Lua erhebliche Leistungsvorteile bringen
1 Kommentare
Meinungen auf Hacker News
Im C-Standardvorschlag gibt es eine Syntax für Tail Calls, und zwar
return goto (expression);Was mir daran besser gefällt als am Standard-
[[musttail]], ist, dass garantiert wird, dass die Lebensdauer lokaler Objekte endet. Dadurch wird eine Implementierung auch ohne umfassende Escape Analysis möglich[0] https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3266.htm#5...
return gotoleichter zu implementieren ist. Auch[[musttail]]scheint auf den ersten Blick die Lebensdauer lokaler Objekte zu beendenBeim schnellen Überfliegen steht dort, dass die an einer Tail-Position aufgerufene Funktion denselben Typ wie das Aufrufziel haben muss. Das ist eine Bedingung, damit keine Rückgabewert-Konvertierung nötig ist und der Platz für die Argumentübergabe sowie die Calling Convention erhalten bleiben
Eine Beschwerde, die ich häufig zu dem von mir in Clang implementierten
[[musttail]]gesehen habe, ist, dass diese Einschränkung unnötig streng sei. Manche Architekturen erlauben Tail Calls auch dann, wenn die Typen nicht vollständig übereinstimmen: https://github.com/llvm/llvm-project/issues/54964„Dann wird der Code nicht portabel“ stimmt zwar, aber Tail-Call-Optimierung ist an sich nicht wirklich portabel. Manche Targets unterstützen Tail-Call-Optimierung grundsätzlich nicht, etwa WASM ohne Tail-Call-Erweiterung
Es gibt Änderungen und Ergänzungen, die unbedingt hinein sollten, und sogar Ideen, die klarer gefasst werden müssen, daher freue ich mich darauf. Aber der aggressive Aktualisierungszyklus von C++ scheint am Ende eher einen Buckel auf den nächsten gesetzt zu haben
Besonders problematisch ist es, wenn Features viel früher als erwartet schlecht miteinander interagieren. Ich hoffe, der Standardisierungsprozess stützt sich nicht nur auf Begründungsdokumente, sondern testet Features ausgiebig in großen und vielfältigen Codebasen und wählt sie sehr konservativ aus
Falls man sich für Rust interessiert: Es gibt ein älteres RFC, das ein
become-Keyword hinzufügen wollte, um garantierte Tail-Call-Optimierung bereitzustellenUrsprünglich wurde es verschoben, um sich auf die Ziele der Edition 2018 zu konzentrieren, und diese Entscheidung war richtig, aber kürzlich wurde die Idee wieder geprüft. Sie könnte zurückkommen
[0]: https://github.com/rust-lang/rfcs/pull/1888
[1]: https://github.com/rust-lang/rfcs/pull/3407
Die Art, wie Interpreter in C++ normalerweise solche Geschwindigkeitsgewinne erzielen, ist die Verwendung von computed goto. Dann gibt es auf dem Weg von einem Opcode zum nächsten kein Rauschen durch Calling-Conventions
Der Hauptgrund, warum der computed-goto-Ansatz oder der Tail-Call-Ansatz schneller ist als eine klassische
switch-Schleife, liegt darin, dass sie den Branch Predictor entlasten. Statisch entsteht pro Opcode ein indirekter Branch, statt einer Struktur mit statisch nur einem einzigen indirekten BranchWenn jede Funktion klein ist und die wichtigen Variablen als Argumente erhält, wird die Registerallokation deutlich weniger fragil
Allerdings frage ich mich, ob das bei größer werdenden Interpretern weiterhin gilt
Das verbleibende Problem bei der Verwendung von Tail Calls für Context Switching ist, dass man Funktionen nutzt, die eine Calling Convention verwenden müssen. Leider werden Register verschwendet, um beim Funktionsende den Zustand wiederherzustellen
Eine ausführliche Analyse und eine Alternative mit einem Zwischen-Compiler gibt es im LuaJIT-Remake-Blog: https://sillycross.github.io/2022/11/22/2022-11-22/
Wie bei allem anderen in der Informatik kann sich, wenn sich das Kostenverhältnis der einzelnen Rechenarten ändert, der beste Algorithmus wieder zu dem entwickeln, was vor 15 oder 20 Jahren verwendet wurde. Deshalb wirkt Programmierung oft wie Mode. Wenn man etwas wiederbelebt, heißt das nicht, dass es keinen Grund dafür gibt; problematisch bleibt aber, zu vergessen, warum es beim letzten Mal kein Allheilmittel war
Wenn der Haupt-JIT schneller oder langsamer wird, ändert sich der Nutzen im Verhältnis zu den Ausführungskosten, und auch die Schwellenwerte, die ihn auslösen, werden angepasst. Dadurch ändert sich die Menge an Code, die in anderen Schichten ausgeführt wird, und auch die amortisierten Kosten dieser Schichten können schlechter werden. Es ist wie das Austarieren eines Doppelpendels
Wenn man eine JIT-Schicht schnell und grob genug machen kann, kann man den Interpreter ganz überspringen. Von außen betrachtet scheint die kognitive Last, zwischen einem Interpreter und etwa zwei JITs Buch zu führen, hoch zu sein; einige Sprachen wirken daher so, als hätten sie den Interpreter zurückgestellt und einen JIT verwendet, der eher auf Compile-Zeit als auf Ausgabegeschwindigkeit optimiert ist
Ich erinnere mich nicht, welche Sprache es war, aber soweit ich weiß, hat mindestens ein Team wegen dieses Gleichgewichtsproblems am Ende auch den Zwischen-Compiler entfernt. Es war besser, sich auf zwei Dinge zu konzentrieren, statt drei zu handhaben
Beim Namen komme ich jedes Mal durcheinander; es dürfte
preserve_alloderpreserve_nonesein. Die Frage ist, aus wessen Perspektive das Erhalten gemeint istSoweit ich weiß, wird das Attribut
musttailgerade zu GCC hinzugefügt. Der Patch wird geprüft, und die Semantik ist mit Clang kompatibel.preserve_mostwird. Könnte etwas Ähnliches in GCC landen? Ohne das machen Nicht-Tail-Calls den Interpreter kaputt.Clang scheint Heuristiken zu haben, die für
musttail-Aufrufe die Aufrufsequenz ändern. Auf i686 wird der Aufruf zum Beispiel in einennoplt-Call umgewandelt. Das steht nicht in der Clang-Dokumentation: https://clang.llvm.org/docs/AttributeReference.html#musttailRealistisch machbar ist wohl nur, dass der Compiler eine Diagnosemeldung ausgibt, wenn er keinen Tail-Call erzeugen kann. Für viele Nutzer dürfte das ausreichen. Eine Garantie für Tail-Calls wie in Scheme scheint schwer vorstellbar.
C++-Support wird ebenfalls erwähnt, aber in C++ dürfte es nur sehr wenige Tail-Calls geben.
Zum Beispiel ist
foo() { auto a = SomeClassWithADestructor(); return bar(); }kein Tail-Call, weil nach dem Aufruf vonbar()nochazerstört wird.barausgeführt wird?Ich frage mich, ob der C++-Standard zwingend vorschreibt, dass Destruktoren am Ende des Blocks aufgerufen werden müssen, oder ob sie auch aufgerufen werden dürfen, sobald die Variable nicht mehr verwendet wird.
Vielleicht sind die Beispiele zu einfach, aber für gute Codegenerierung scheint
__attribute__((musttail))nicht unbedingt nötig zu sein.Wenn die Fehlerbehandlungsfunktion ein seltener Pfad ist, dürfte auch die Aufrufgeschwindigkeit nicht sehr wichtig sein.
Eine Struktur wie
if (unlikely(malformed)) return error(); switch (data_type) { case x: return handle_x(); case y: return handle_y(); }scheint ziemlich zuverlässig eine gute Sprungtabelle zu erzeugen.Andernfalls funktioniert diese Struktur nicht und der Stack läuft sofort über. Der Punkt von
[[musttail]]ist, dass Tail-Call-Eliminierung zwingend erforderlich ist. Der Compiler hat keine andere Wahl.Natürlich ist die Formulierung „erzwingt“ möglicherweise nicht ganz korrekt. Es ist nicht festgelegt, dass der Compiler für alle Ausführungspfade einer Funktion eine einzige Stack-Frame-Struktur verwenden muss, und ebenso wenig, dass er für interne Funktionen, deren Adresse nicht genommen wird, oder für Funktionen in anonymen Namespaces die Standard-ABI verwenden muss. Aber alle Compiler, die ich gesehen habe, einschließlich Clang, machen es in der Praxis so. Deshalb braucht man eine Möglichkeit, ihnen zu sagen, dass sie sich keine ABI-Sorgen machen und keine Zeit damit verschwenden sollen, Register über Aufrufe hinweg zu erhalten.
Sprungtabellen werden natürlich gut erzeugt. Wenn man das Ergebnis aber mit etwas wie
perf reportlaufen lässt und der Test-Bytecode keine kurze Schleife darstellt, sieht man eines von zwei Dingen: Entweder gibt es bei jedem Dispatch eine Fehlvorhersage der Verzweigung, oder der Compiler denkt sich „Sieht aus, als wolle hier jemand einen Interpreter schreiben“ und verschiebt den indirekten Sprung ans Ende jedescase. Ich habe so etwas in Clang gesehen. In beiden Fällen ist die Registerallokation des resultierenden Codes mit hoher Wahrscheinlichkeit ziemlich schlecht.Ich frage mich, wie schnell ein Trampolin wäre, also eine Variante, bei der die nächste Funktion als Funktionszeiger zurückgegeben und in einer äußeren Schleife aufgerufen wird. Der Vorteil wäre, dass es portables C ist.
Die Programmiersprache Scheme verlangt, dass kein Tail-Call den Stack wachsen lässt. Deshalb haben Implementierer verschiedene Techniken untersucht, darunter Trampoline.
Ich habe keine konkrete Quelle zum Zitieren, aber Antworten dürfte man in Arbeiten finden, die Scheme nach C kompilieren. Wenn die Zielsprache keine garantierte Tail-Call-Optimierung bietet, wird das erzeugte Programm langsamer sein.
Nebenbei bemerkt ist das auch der Grund, warum insbesondere Implementierer höherer Programmiersprachen unzufrieden damit sind, dass die Tail-Call-Optimierung aus der JavaScript-Spezifikation entfernt wurde. Es gibt auch Lösungen, die sowohl Tail-Call-Optimierung als auch Stack-Prüfungen beibehalten.
https://github.com/schemedoc/bibliography/blob/master/page8....
Wenn man über Funktionszeiger springt, ist das vermutlich nicht ebenso gut vorhersagbar, und man bekommt denselben Vorteil wohl nicht.
Natürlich müsste man das messen, und ich habe es bisher nicht getan.
Ich habe Protobuf-Decoder/-Encoder, einen IML-Parser und Python-Bindings in C geschrieben und habe einiges zur Messung der Parsing-Geschwindigkeit zu sagen.
Wenn diese Bibliothek nur mit Bindings für verwaltete Sprachen angeboten wird, kommt in Sachen Performance ein zusätzlicher Faktor hinzu, der alles andere überlagert. Zu Ruby oder PHP kann ich nichts sagen, aber in Python habe ich dramatische Geschwindigkeitssteigerungen gesehen, wenn man keine Enumeratoren verwendet. Wenn man Protobuf-Enumeratoren in Python-Enumeratoren umwandelt, werden alle Vorteile, die man im C-Code erzielen kann, von der Zeit für das Erzeugen der diversen Python-Objekte zunichtegemacht. Der Unterschied liegt bei mehreren Größenordnungen. Darüber hinaus könnte man alle Hilfsdatenstrukturen in C implementieren und Python nur ein minimales Interface bereitstellen. Wie fair ein solcher Vergleich gegenüber Code ist, der Pythons eingebaute Strukturen nutzt, ist schwer zu beantworten.
Googles Protobuf-Parser für Python kann immer noch „schneller“ als über 2 GB/s sein. Der Grund ist, dass er außer der Top-Level-Message nichts parst. Die interne Struktur der Message wird erst bei Bedarf geparst. Wenn der Code den geparsten Inhalt sofort vollständig liest, ist er wahrscheinlich langsamer als 2 GB/s; die Frage ist aber, wie man diese beiden Ansätze praktisch vergleichen kann. Je nach Art der Anwendung fallen die realen Ergebnisse unterschiedlich aus, daher gibt es keine eindeutige Antwort.
Im allgemeinen Fall lässt sich Protobuf-Parsing wegen redundanter Verarbeitung nicht streamen. In der Praxis stößt Code, der Protobuf-Inhalte parst, auf einen I/O-Flaschenhals, weil er auf das Ende der Message warten muss, bevor er mit dem Parsen beginnen kann. Unabhängig davon kann je nach den typischen Protobuf-Messages einer Anwendung eine Parallelisierung des Parsings möglich sein, womit man einen Single-Thread-Parser wahrscheinlich meist überholen würde. Aber wie bei den vorherigen Beispielen lässt sich das nicht allgemein als überlegene Strategie bezeichnen.
Üblicherweise ist es deutlich effizienter, Parsing und Erzeugung von Domain-Objekten zu kombinieren. Anwendungen müssen diesen Schritt fast immer durchlaufen. Wie ein Parser Zugriff auf diese Funktion ermöglicht, entscheidet in vielen Fällen darüber, welcher Parser gewinnt.
Unterm Strich sind Protobuf – und vielleicht Parser im Allgemeinen – kein gutes Ziel für Geschwindigkeitsmessungen und Vergleiche. Es ist zu low-level und auch das Design ist nicht gut genug, um als Maßstab für Performance-Benchmarks zu dienen.
Es wäre gut, wenn genauer erklärt würde, wie die Regel „das letzte Feld gewinnt“ Streaming-Parsing verhindert.
GCC und Clang haben schon lange die Option
-foptimize-sibling-calls, mit der man Tail Calls auch in Debug-Builds bekommen konnte.Natürlich ist es eine große Verbesserung, wenn diese Funktion standardisiert, garantiert und auf Funktionsebene steuerbar wird.
[1] https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html
[2] https://clang.llvm.org/docs/ClangCommandLineReference.html#t...