Einen performanten JSON-Parser bauen
(dave.cheney.net)- Dave Cheneys Vortrag auf der GopherCon Singapore 2023 behandelt den Entwurfsprozess für einen Streaming-JSON-Parser in Go, der eine API ähnlich
encoding/jsonbeibehält, dabei aber den Durchsatz erhöht und Allokationen reduziert - JSON hat keine Längenangabe, daher muss die Eingabe bis zum Ende gelesen werden; die untere Performance-Grenze liegt mindestens bei read(N)+parse(N), weshalb das Reduzieren erneuter Besuche von Bytes und Tokens, von Kopien, Allokationen und Funktionsaufrufen im Hot Path die zentrale Einschränkung ist
encoding/json.Decoder.Tokengibt Tokens alsinterface{}zurück und ist damit bequem, aber konkrete Werte escapen auf den Heap, erzeugen Allokationen proportional zur Token-Anzahl und verursachen selbst bei einem einzelnen"hello"-Token 3 allocs/oppkg/jsonreduziert die Kosten im Hot Path durchNextToken, das Sub-Slices des Eingabe-[]bytezurückgibt, ein Sliding Window inbyteReader, manuelles Inlining, direkte Aufrufe von Zustandsmethoden und das Entfernen von Bounds Checks- Am Ende tokenisiert
pkg/json.Scannerbei bereitgestelltem Buffer ohne Allokationen;Decoder.Tokenist 2- bis 3-mal schneller alsencoding/json.Decoder.Token, und das allokationsärmereDecoder.NextTokenist 8- bis 10-mal schneller
Ziele und grundlegende Einschränkungen
- Ziel ist es, als Fallstudie für Go-Paketdesign einen performanten JSON-Parser zu bauen
- Es gibt drei Designziele
- Unterstützung für Streaming-Verarbeitung, ohne die gesamte Eingabe in den Speicher zu laden
- Angemessene Kompatibilität mit der High-Level-API
json.Decoderausencoding/json, bei höherem Durchsatz und weniger Allokationen - Zusätzlich zur
encoding/json-API effizientere APIs ohne Allokationen oder mit begrenzten Allokationen anbieten
- Wenn die gesamte Eingabe zunächst im Speicher gepuffert wird, entstehen Verfügbarkeitsrisiken, wenn die Eingabegröße unbekannt oder unendlich ist; außerdem steigt die Wartezeit vor der Verarbeitung
- Streaming-Lesen verarbeitet Daten, sobald sie eintreffen, und kann Lesen und Verarbeitung überlappen
Zeitkomplexität beim Parsen von JSON
- JSON hat keinen Längenmarker; um zu wissen, wie viel gelesen werden muss, muss die gesamte Eingabe gelesen werden
- Um das 1.000. Element eines JSON-Arrays zu parsen, müssen auch die 999 Elemente davor gelesen und verarbeitet werden; die Eingabeverarbeitung lässt sich also nicht überspringen
- Die untere Performance-Grenze ist proportional zur Eingabegröße; da nicht nur gelesen wird, sondern auch eine JSON-Zustandsmaschine durchlaufen werden muss, um Anfang und Ende von Tokens zu finden, beträgt sie mindestens
read(N)+parse(N) - Maßstäbe zur Reduzierung zusätzlicher Kosten sind:
- Wenn N Bytes gelesen wurden, sollte jedes Byte möglichst nur einmal verarbeitet werden
- Dasselbe Token sollte ebenfalls nur einmal verarbeitet werden
- Die Zahl der Funktionsaufrufe im Hot Path von
ScanneroderDecodersollte aufO(tokens)stattO(bytes)begrenzt werden - Kopien sollten reduziert werden, um die Anzahl erneuter Besuche derselben Bytes zu senken
- Allokationen sollten reduziert werden, um Heap-Allokationen, Zugriffe auf gemeinsame Datenstrukturen, Locks, Cache-Contention und GC-Kosten zu senken
Tokenisierung und API-Design
- Ein JSON-Decoder besteht grob aus zwei Stufen
- Einem Scanner oder Tokenizer, der einen Byte-Stream in einen Stream von JSON-Tokens umwandelt
- Einem Unmarshaller, der den JSON-Token-Stream auf Go-Objekte anwendet
encoding/json.Decoder.Tokengibt Tokens alsinterface{}zurück- Strings werden als
string, Zahlen alsfloat64, Booleans alsbool,nullalsnilund Trennzeichen alsjson.Delimdargestellt - Dieser Ansatz ist bequem, weil er Token-Wert und -Typ gemeinsam darstellt
- Strings werden als
- Bequemlichkeit hat ihren Preis
- Brad Fitzpatrick nennt die Token-API eine garbage factory
- Durch das Design der
Decoder.Token-API escapen die konkreten Werte, die jedem Token zugeordnet werden, auf den Heap - Die Anzahl der Allokationen ist an die Anzahl der Tokens in der Eingabe gebunden
- In einem Benchmark mit einem einzelnen
"hello"-Token zeigtencoding/json355 ns/op, 19,7 MB/s, 37,0 B/op und 3,00 allocs/op - API-Design bestimmt Allokationen, und Allokationen können sich direkt auf die Performance auswirken
[]byte-Tokens und implizite Typinformationen
- Bei JSON-Tokens lässt sich der Typ bereits am ersten Zeichen erkennen
{,}: Anfang und Ende eines Objekts[,]: Anfang und Ende eines Arrayst: truef: falsen: null": String-,0~9: Zahl
- Die API
Decoder.NextTokenvonpkg/jsonwandelt Eingabe-[]bytenicht in Go-Werte um, sondern gibt die Bytes, die das Token darstellen, direkt als Sub-Slice aus der Eingabe zurück - Das erste Byte des zurückgegebenen
[]bytegibt den Token-Typ an - Diese API hat Einschränkungen
- Die Ausgabe ist keine Kopie, sondern ein Sub-Slice der Eingabe und hat daher eine begrenzte Lebensdauer
- Das ähnelt der API von
bufio.Scanner - Um Token-Typen oder konkrete String- und Zahlenwerte bequemer zu handhaben, braucht es eine Abstraktion auf höherer Ebene
Effizientes Lesen: byteReader
- Die traditionelle Methode
io.Reader.Readkopiert Daten vom Reader in einen Buffer; auch diese Kopie verursacht Kosten io.Reader.Readüberlässt das Buffer-Management dem Aufrufer- Wenn byteweise gelesen wird, kann Speicher nötig sein, um bereits gelesene Bytes zu behalten oder zurückzugehen
- Wenn zunächst in einen großen Buffer gelesen und dann Anfang und Ende von Tokens gesucht werden, sind viel Verwaltungsaufwand, Kopien und Buffer-Erweiterungen nötig, sobald das Token-Ende nicht im Buffer liegt
- Als Alternative wird
byteReaderverwendet, inspiriert von Steven Schveighoffers iopipe und Ideen von Phil Pearl byteReaderstellt überio.Readerein Sliding Window bereit und ähneltbufio.Reader, hat aber eine effizientere APIwindow()gibt das aktuelle Fenster ungelesener Daten zurückrelease(n)verwirft die ersten n Bytes des Fenstersextend()liest weitere Daten aus dem darunterliegenden Reader und erweitert das Fenster
- Der Benchmark zur Suche nach Whitespace ist eine Baseline, die jedes Zeichen besucht und nur prüft, ob es Whitespace ist; bei mehreren Eingaben erreicht sie etwa 2,04~2,07 GB/s
- Beispielcode für den Whitespace-Zähler steht unter github.com/davecheney/whitespace
Scanner-Optimierung
Scanner.Nextüberspringt Whitespace dazwischen, bestimmt anhand des ersten Zeichens im Fenster das Token und liest bis zum Token-Ende- Die anfängliche Performance von
Scanner.Nextliegt im Vergleich zur Whitespace-Baseline bei etwa 1/4 bis 2/5- Beispiel:
Scanner/canada510 MB/s,citm_catalog677 MB/s,sample837 MB/s
- Beispiel:
- Die erste Optimierung besteht darin, Aktualisierungen des Felds
s.offsetdurch die lokale Variableoffsetzu ersetzens.offsetist beim Eintritt in die Funktion und beim Verlassen 0, daher sind interne Änderungen von außen nicht sichtbar- Durch die lokale Variable kann der Compiler temporäre Speicher-Schreibvorgänge vermeiden
citm_catalogsinkt von 2,52 ms auf 1,80 ms, eine Reduktion um 28,46 %,samplevon 828 µs auf 528 µs, eine Reduktion um 36,24 %
- Der Grund für die unterschiedliche Wirkung je nach Eingabe ist die unterschiedliche Anzahl von Whitespace-Zeichen
canadahat nur 33 Whitespace-Zeichencitmhat 1.227.563 Whitespace-Zeichen
- Die zweite Optimierung ist das manuelle Inlining von
Scanner.tokeninScanner.Next- Der Go-Compiler kann
Scanner.token,parseString,parseNumber,Scanner.Nextund andere wegenfor-Schleifen und Funktionskomplexität nicht automatisch inlinen Scanner.NextundScanner.tokenwerden pro Eingabe-Token aufgerufen, wodurch zwei Funktionsaufrufe pro Token entstehen
- Der Go-Compiler kann
- Nach manuellem Inlining verbessert sich der Durchsatz um 9~24 %
canadasteigt von 512 MB/s auf 642 MB/s, ein Plus von 24,50 %citm_catalogsteigt von 960 MB/s auf 1105 MB/s, ein Plus von 15,16 %samplesteigt von 1,33 GB/s auf 1,46 GB/s, ein Plus von 9,11 %
- Die Wirkung der Optimierungen lässt sich in zwei Punkten zusammenfassen
- Aktualisierungen von
s.offsetwerden von einmal pro Byte auf einmal pro Token reduziert - Das Vermeiden von Funktionsaufrufen im Hot Path kann die Performance verbessern
- Aktualisierungen von
Validierung und Decoder.NextToken
- Der Scanner allein kann Tokens aufteilen, aber für vollständige JSON-Verarbeitung ist Zustandsvalidierung erforderlich
- JSON ist eine Zustandsmaschine; je nach aktuellem Token ist eingeschränkt, welches Token als Nächstes kommen darf
- Nachdem beispielsweise
{,"username"gelesen wurden, ist nur:gültig
- Nachdem beispielsweise
Decoder.NextTokenlegt Zustandslogik überScanner.Next, um zu prüfen, ob die Token-Sequenz gültig ist- Die Zustände umfassen unter anderem Wert, Objekt-Key-String, Objekt-Doppelpunkt, Objektwert, Objekt-Komma, Array-Wert, Array-Komma und Endzustand
- Schon mit der anfänglichen Validierungsimplementierung zeigt
pkg/json8- bis 10-mal schnellere Ergebnisse alsencoding/jsoncanada:pkg/json399 MB/s,encoding/json34,6 MB/scitm_catalog:pkg/json713 MB/s,encoding/json87,1 MB/ssample:pkg/json1,23 GB/s,encoding/json216 MB/s
Optimierung der Zustandsübergänge
- Im Zentrum von
Decoder.NextTokensteht eineswitch-Anweisung - Ein gewöhnliches
switchkann wie eine Reihe vonif-Anweisungen implementiert werden; eine lange Verzweigungskette teilt den Instruktionsstrom auf und belastet den Branch Predictor der CPU - Man könnte eine Tabelle verwenden, um aus dem Zustandswert die Zustandsmethode zu finden, aber die Beispielimplementierung kompiliert wegen der Initialisierungsschleife nicht
- Stattdessen wird Gos method expression genutzt, um in
d.statedirekt eine Methode statt eines Zustands-Enums zu speichernDecoder.NextTokenruft die aktuelle Zustandsmethode direkt auf, etwa mitreturn d.state(d, tok)
- Dieser Computed-goto-Ansatz bringt für sich genommen keine große Performance-Verbesserung
- Einige Eingaben ändern sich fast gar nicht, und bei
twitter,code,examplewird es geringfügig langsamer samplewird um 1,15 % schneller
- Einige Eingaben ändern sich fast gar nicht, und bei
- Diese Änderung ermöglicht die nächste Optimierung: Outlining
Outlining und Entfernen von Bounds Checks
- Nach dem Outlining führt
Decoder.NextTokennur nochreturn d.state(d)aus, und jede Zustandsmethode ruft direktd.scanner.Next()auf - Da
toknicht mehr als Argument an die Zustandsmethode übergeben wird, werden im Call Stack 3 words eingespart - Weil die Prüfung
len(tok) < 1undswitch tok[0]in derselben Funktion liegen, wird das Entfernen von Bounds Checks möglich- Zuvor lag die
len(tok)-Prüfung inDecoder.NextToken, während die Zustandsmethode als Method Expression aufgerufen und nicht inlined wurde - Daher war für
tok[0]in der Zustandsmethode ein Bounds Check nötig - Wenn die Längenprüfung in derselben Funktion erfolgt, kann der Compiler beweisen, dass
tokmindestens Länge 1 hat
- Zuvor lag die
- Auch
Decoder.NextTokenselbst wird einfacher und damit inline-fähig- Der Aufrufer sieht statt
dec.NextToken()praktisch einen direkten Aufruf der aktuellen Zustandsmethode - Die Kosten des Funktionsaufrufs entfallen
- Der Aufrufer sieht statt
Finale Benchmark-Ergebnisse
- Die unterste Ebene
pkg/json.Scannerführt bei einem Buffer von einigen KB Streaming-Tokenisierung ohne Allokationen auscanada: 638,78 MB/s, 0 B/op, 0 allocs/opcitm_catalog: 1110,51 MB/s, 0 B/op, 0 allocs/opsample: 1471,01 MB/s, 0 B/op, 0 allocs/op
pkg/json.Decoder.Tokenist 2- bis 3-mal schneller alsencoding/json.Decoder.Tokencanada: 101,98 MB/s vs. 33,19 MB/scitm_catalog: 333,23 MB/s vs. 82,71 MB/ssample: 788,59 MB/s vs. 209,12 MB/s
pkg/json.Decoder.NextTokenhat deutlich weniger Allokationen und ist 8- bis 10-mal schnellercanada: 466,52 MB/s, 136 B/op, 3 allocs/op vs. 34,42 MB/s, 17.740.399 B/op, 889.106 allocs/opcitm_catalog: 798,58 MB/s, 136 B/op, 3 allocs/op vs. 86,08 MB/s, 5.661.597 B/op, 324.692 allocs/opsample: 1346,85 MB/s, 1144 B/op, 9 allocs/op vs. 217,44 MB/s, 723.781 B/op, 26.095 allocs/op
- Auf der höchsten API-Ebene kann
pkg/jsonGo-Objekte auf dieselbe Weise wieencoding/jsonunmarshalncanada: 82,08 MB/s vs. 58,70 MB/scitm_catalog: 215,66 MB/s vs. 104,00 MB/ssample: 615,99 MB/s vs. 128,04 MB/s
- Der Vortragslink ist dave.cheney.net/paste/gophercon-sg-2023.html, der Code liegt unter github.com/pkg/json
Erkenntnisse aus dem Design
-
Allokationen wirken sich auf Performance aus
- Auch wenn der GC schnell allokiert und effizient einsammelt, ist es immer schneller, gar nicht zu allokieren
- API-Design kann Allokationen vermeiden
- Der Großteil der Geschwindigkeitssteigerung dieses Pakets kommt aus reduzierten Allokationen
- Zeit, die nicht im Heap-Allokationspfad und in GC-Zyklen verbracht wird, steht fürs Scannen zur Verfügung
- Die API
encoding/json.Decodererfordert Allokationen, weil sie primitive Werte alsinterface{}zurückgibt - Werte escapen auf den Heap und werden damit faktisch zu Zeigern auf Werte
- Bei der Datenverarbeitung können Allokationen der größte Performance-Kostenfaktor eines Algorithmus sein
- Das sorgfältige Reduzieren von Kosten pro Byte und pro Token ist der zweitgrößte Performance-Hebel
- Wichtig ist die Richtung, Funktionsaufrufe pro Byte in Funktionsaufrufe pro Token zu verwandeln
- Ausgangspunkt war die Annahme, dass
encoding/jsonwegen seiner API langsamer sein kann; wenn man eine andere API akzeptiert, sind auf manchen Unmarshal-Pfaden 2- bis 3-fache und bei der Tokenisierung 8- bis 10-fache Performance möglich
1 Kommentare
Hacker-News-Kommentare
Sieht ziemlich gut aus. Im Laufe meiner Karriere habe ich schon viel zu viele JSON-Parser gebaut, aber es ist wirklich schön, eine Referenz zu haben, die Schritt für Schritt zeigt, wie man einen vernünftigen und schnellen JSON-Parser entwirft.
Allerdings braucht JSON nicht zwingend einen expliziten Tokenizer. Man kann das Token-Konzept weglassen und Parsing und Tokenisierung vollständig zusammenlegen. So wird es normalerweise gemacht, und das Ganze wird einfacher.
Bei Sprachen wie ECMAScript ist das deutlich schwieriger, weil es Fälle gibt, die zunächst wie eine Teilmenge der Syntax von Klammerausdrücken aussehen, etwa Arrow Functions, und erst davon abhängen, ob ein
=>folgt; dadurch kann ein beliebig langer Lookahead nötig werden.Ein gut nachvollziehbarer Artikel, und er beschreibt sehr klar den Ablauf, wie man es machen würde, wenn man es selbst implementiert.
Wenn es in der Produktionsumgebung um reine Performance geht, lohnt sich auch ein Blick auf Daniel Lemires https://github.com/simdjson/simdjson. Es gibt auch einen Go-Port von MinIO: https://github.com/minio/simdjson-go.
Was ich beim Bau schneller JSON-Parser gelernt habe, ist zwar stark von den jeweiligen Spracheigenschaften geprägt, lässt sich aber allgemein so zusammenfassen:
Bei der Tokenisierung sollte man Heap-Allokationen vermeiden. Ein Tokenizer sollte besser ein auf dem Stack allokiertes Struct zurückgeben oder eine Funktion sein, die ein
int64-Token zurückliefert, in dem Startposition, Länge, Typ-Offset usw. des Tokens gepackt sind.Auch beim Parsing sollte man Heap-Allokationen vermeiden und für Clients, die mit Slices auf den Buffer arbeiten wollen, eine Schnittstelle wie
getString(key String)unterstützen.Beim Deserialisieren in Objekte, deren Felder zur Compile-Zeit bekannt sind, erzeugt man normalerweise ein
switchauf Basis der Schlüssellänge, bevor man String-Werte vergleicht.In Daten-Pipelines, die viel JSON verarbeiten, konnte allein die Wahl der JSON-Bibliothek einen Performance-Unterschied um den Faktor 3 bis 10 ausmachen, und die großen Parser wollen im Allgemeinen Objekte allokieren.
Wenn die zu serialisierenden und zu deserialisierenden Klassen zur Compile-Zeit bekannt sind, macht Java Jackson das ziemlich gut, aber mit sorgfältigem Coding und Profiling kann man noch ungefähr Faktor 2 herausholen.
Wenn man dagegen beliebiges JSON verarbeitet, versuchen Mainstream-Parser viele Allokationen vorzunehmen; ein selbst geschriebener, stärker eingreifender Parser kann das vermeiden, und bei Tausenden bis Millionen Objekten pro Sekunde ist der Performance-Gewinn sehr groß.
Mit einem ähnlichen Ansatz habe ich einen GraphQL-Tokenizer und -Parser gebaut; auch der kommt ohne Speicherallokationen aus und ist ziemlich schnell. Wer sich den Code ansehen möchte: https://github.com/wundergraph/graphql-go-tools
Ich habe dazu auch einen Vortrag gehalten, leider wurde er nicht aufgezeichnet. Ich bin fast verrückt geworden, als ich versucht habe, in Go möglichst viel herauszuholen :D
Oder ich frage mich, ob es sich auch auf andere Weise auswirkt.
In n2[1] brauchte ich einen schnellen Tokenizer und hatte dasselbe Problem mit dem Müllgenerator. Im Grunde war es die Mischung aus einer festen Menge von Konstanten-Tokens wie
json.Delimund Strings, die Allokationen verursachen.Eine Lösung, die ich ziemlich gut finde, ist, den Tokenizer generisch über irgendein
Tzu machen und eine Funktion entgegenzunehmen, die Byte-Slices inTumwandelt, sodass man statt StringsTverwendet.So kann der Aufrufer eine effizientere Darstellung bereitstellen, zum Beispiel eine mit weniger Allokationen, und gleichzeitig kann man in Unit-Tests bequem die Identitätsfunktion verwenden, um den Tokenizer zu testen.
In gewisser Weise ähnelt das dem Verschmelzen von Tokenizer und Parser zur Build-Zeit, aber dank Generics kann der Tokenizer die Schichtentrennung beibehalten, ohne die Darstellung des Parsers zu kennen.
[1] https://github.com/evmar/n2
Mit besserem API-Design kann man gegenüber der Standardbibliothek Verbesserungen erzielen, aber es ist praktisch schwierig, einen vollständigen Streaming-Parser zu bauen und dabei nicht eine Struktur halb zu befüllen und dann mitten drin auszusteigen, bevor ein Fehler entdeckt wird. Die Standardbibliothek scheint auch das als explizite Design-Einschränkung zu haben.
Vielleicht habe ich etwas übersehen, aber der Autor sagt wiederholt, er habe einen „Streaming“-Parser gebaut, erklärt aber nicht, was das tatsächlich bedeutet.
Insbesondere gibt es keine Erklärung dazu, wie wiederholte Schlüssel in der „Hash-Tabelle“ behandelt wurden. Ich frage mich, ob bei einem wiederholten Schlüssel der Sink-Code zweimal aufgerufen wird oder ob gewartet wird, bis die gesamte „Hash-Tabelle“ gelesen ist, bevor der Sink-Code aufgerufen wird.
Meiner Meinung nach ist JSON hierarchisch, hat keine vorab bekannte Länge und besitzt vor allem wiederholte Schlüssel, wodurch es grundsätzlich schlecht für Streaming geeignet ist.
Man kann bestimmte Teilmengen von JSON Streaming-freundlicher machen, aber wenn man das tut, gibt es keinen besonderen Grund, JSON zu reparieren. Wenn die Lösung darin besteht, JSON zu ändern, ist aus meiner Sicht ein ganz anderes Format besser.
Schön, Phil Pearl erwähnt zu sehen.
https://github.com/bytedance/sonic ist ebenfalls einen Blick wert.
Es ist überraschend, dass es keine Möglichkeit gibt, bei einer Funktion, die zu groß ist und deshalb nicht inline eingebettet wird, zu sagen: „Diese Funktion wirklich inlinen“.
Einfaches Zählen und Suchen von Whitespace-Zeichen ließe sich mit SIMD-Vektorisierung wohl deutlich beschleunigen, aber ich verstehe, dass das außerhalb des Umfangs des Autors liegt.
Die Aussage „Es ist unrealistisch zu erwarten, dass man die gesamte Eingabe im Speicher halten kann“ ist für die meisten Anwendungen falsch.
Man könnte sagen, dass Go nicht das richtige Werkzeug dafür ist, aber mit Optimierungen dieser Art sehe ich keinen Grund, warum es nicht gehen sollte.
Ich habe schon JSON-Daten im Gigabyte-Bereich hineingeschoben, daher bin ich für Streaming-Parser dankbar. Außerdem ist Streaming-Unterstützung auch ein Signal dafür, dass der Autor mehrere Anwendungsfälle kennt und besseres Engineering betreibt.
Speicher ist nur in der Theorie billig und nahezu kostenlos, in der Realität nicht.
mmap()dann nicht auch eine mögliche Option? Echte Streaming-Anforderungen, zum Beispiel wenn man frühe Daten aus einer einzelnen JSON-Datei wie einen Strom von Transaktionen oder Jobs früh verarbeiten muss, sind ein separater Fall.