- Das standardmäßige Ruby-
json-Gem wurde verbessert, indem Engpässe per Profiling beseitigt wurden. So sinkt der praktische Druck in der Praxis, allein aus Geschwindigkeitsgründen zuojzu wechseln. - Das Ziel ist nicht,
ojum jeden Preis zu schlagen, sondern auch ohne Monkey-Patching wieOj.mimic_JSONundOj.optimize_railseine ausreichend schnelle und vorhersehbare JSON-Verarbeitung zu bieten. ojwar in einigen Benchmarks schneller, brachte aber durch ignoriertescript_safe-Optionen, Unterschiede bei der Rails-Serialisierung und Ruby-Abstürze Belastungen bei Betriebsstabilität und API-Kompatibilität mit sich.- Zu den wichtigsten Optimierungen gehören das Entfernen doppelter UTF-8-Prüfungen, das Vorziehen häufiger Bedingungen, das Senken der Kosten für Generator-Konfigurationen, das Vermeiden von Encoding-Pointer-Tracking und Escape-Prüfungen auf Basis von Lookup-Tabellen.
- Im Erzeugungs-Benchmark mit
twitter.jsonund 467 KiB brachten die einzelnen Änderungen Verbesserungen von 3 %, 8 %, 15 % und 30 %; die Erzeugung kleiner Hashes wurde allein durch geringere Konfigurationskosten 1,51-mal schneller.
Hintergrund: Warum das json-Gem schneller gemacht wurde
- Nachdem der Autor vor Kurzem Maintainer des
json-Gems geworden war, konzentrierte er sich neben der Behebung alter Bugs auch auf Performance-Verbesserungen. Dadurch wurde es in den meisten Benchmarks zum schnellsten JSON-Parser und -Generator für Ruby. - Die meisten Performance-Patches beruhen weniger auf Geheimtricks als auf Profiling, um Engpässe zu finden und einfache Verschwendung zu beseitigen.
- Der zentrale Antrieb war,
ruby/jsonschnell genug zu machen, damit Nutzer nicht allein wegen der Geschwindigkeit zu einem alternativen Gem greifen müssen.
Die Belastung durch den Einsatz von oj als Ersatz
- Der Unterschied zwischen
json 2.7.2undojwar in einigen realitätsnäheren Benchmarks nicht groß.- Das Parsen eines 467-KiB-JSON-Dokuments mit 100 Tweets dauerte bei
json 2.7.21.9ms, beioj1.6ms. - Das Erzeugen desselben Dokuments dauerte bei
json 2.7.20.8ms, beioj0.4ms.
- Das Parsen eines 467-KiB-JSON-Dokuments mit 100 Tweets dauerte bei
- In vielen Anwendungsfällen liegt der langsamere Teil nicht in der JSON-Serialisierung selbst, sondern in der darüberliegenden Schicht, die Active-Record-Modelle in Ruby-Hashs und -Arrays umwandelt.
ojwurde in vielen Projekten verwendet, darunter auch in der Shopify-Codebasis; seine Beliebtheit dürfte vor allem an der Geschwindigkeit gelegen haben.-
API-Abweichungen durch Monkey-Patching
Oj.mimic_JSONwird oft verwendet, um dasjson-Gem zu monkeypatchen,Oj.optimize_rails, umActiveSupport::JSONzu monkeypatchen.JSON.dump(data, script_safe: true)kann</script>zu<\/script>escapen, damit JSON sicher innerhalb eines<script>-Tags eingebettet werden kann.- Da
ojdie Optionscript_safenicht kennt und ignoriert, kann ein für sich genommen sicheres Gem in einer Anwendung, dieOj.mimic_JSONaufruft, eine mögliche XSS-Angriffsfläche schaffen. - Auch
Oj.optimize_railskann subtile Unterschiede bei der Objektserialisierung erzeugen. - Bei
ActiveSupport::JSON::Encoding.time_precision = 0kannActiveSupport::JSON.encode(t)einen String mit nur Sekundenauflösung erzeugen. - Nach
Oj.optimize_railsundOj.mimic_JSONgibt es Beispiele, in denen ein String mit Millisekunden ausgegeben wird. - Dieser Fall ist ein Corner Case aufgrund der Lade-Reihenfolge, aber in der Vergangenheit änderte sich noch mehr Verhalten.
-
Stabilitätsprobleme im Betrieb
- In großen Umgebungen war
ojeine der auffälligeren Ursachen für Ruby-Abstürze und machte nachgrpcdie meisten Probleme. - Das Schreiben nativer Gems erfordert Verständnis der Ruby-VM und insbesondere der GC; andernfalls drohen Abstürze oder Speicherbeschädigungen.
- In der
oj-Codebasis gab es Hacks, die Vertrauen erschwerten; zeitweise wurde die GC in manchen Situationen deaktiviert, um Bugs zu umgehen. - Beim erneuten Aktivieren der GC kann ein Major-GC-Zyklus ausgelöst werden.
- Solcher Code kann in Mikrobenchmarks vorteilhaft sein, die tatsächliche Performance in Produktion aber verschlechtern.
- Aufgrund dieser Erfahrungen entfernte Shopify
Ojaus seinem Monolithen und stellte dabei subtile Unterschiede zwischenOj.mimic_JSONund dem echtenjsonfest.
- In großen Umgebungen war
Engpässe per Benchmarking und Profiling finden
- Ziel war, dass sich
ruby/jsonsowohl in realen Anwendungen als auch in Mikrobenchmarks ähnlich wieojverhält, sodass der Anreiz sinkt, aus GeschwindigkeitsgründenOj.mimic_JSONzu verwenden. - Der erste Schritt war der Aufbau einer Benchmark-Suite.
- Sie enthält sowohl Mikrobenchmarks als auch realistischere Benchmarks.
- Grundlage war die Benchmark-Suite aus John Hawthorns rapidjson-ruby-Gem, ergänzt um weitere Fälle.
- Als C-Profiler kam samply zum Einsatz.
- Ein Vorteil ist, dass er Firefox-Profiler-kompatible Reports ausgibt und sich damit leicht teilen lässt.
Doppelte UTF-8-Prüfungen entfernen
- Beim Profiling von
JSON.dumpmit der Payloadtwitter.jsonentfielen9%aufisLegalUTF8im JSON-Code selbst und1.9%aufrb_enc_str_asciionly_p. - Ruby-Strings haben eine interne Eigenschaft namens
coderange, die den Encoding-Status bzw. ob ein String nur ASCII enthält nach einem Scan cached.ENC_CODERANGE_UNKNOWN: noch nicht gescanntENC_CODERANGE_VALID: Encoding ist gültigENC_CODERANGE_7BIT: Encoding ist gültig und enthält nur ASCII-ZeichenENC_CODERANGE_INVALID: Encoding ist ungültig
- Das bisherige
convert_UTF8_to_JSON_ASCIIrief zunächstrb_enc_str_asciionly_pauf und scannte den String später noch einmal manuell, um die UTF-8-Gültigkeit zu prüfen — also doppelte Arbeit. - Nach der Änderung wird die UTF-8-Gültigkeit über den bereits berechneten
coderangebestimmt.- In Ruby ausgedrückt bedeutet das: Wenn
string.ascii_only?nicht gilt, wirdJSON::GeneratorErrorausgelöst, fallsstring.encoding != Encoding::UTF_8oder!string.valid_encoding?gilt. - Sowohl
#ascii_only?als auch#valid_encoding?nutzen den gecachtencoderange, sodass der String höchstens einmal gescannt wird.
- In Ruby ausgedrückt bedeutet das: Wenn
- Trotz der erwarteten 9 % lag die reale Verbesserung nur bei rund 3 %.
- Ein erheblicher Teil der zuvor in
isLegalUTF8verbrachten Zeit wanderte zuconvert_UTF8_to_JSON. - Der Grund ist nicht sicher, aber ein großer Teil der 9 % könnte auf den Kosten beruhen, String-Bytes aus dem RAM in den CPU-Cache zu holen.
- Der Erzeugungs-Benchmark für
twitter.jsonstieg von1077.3 i/sauf1113.3 i/s, also auf das1.03x-Fache.
- Ein erheblicher Teil der zuvor in
Zuerst billigere und wahrscheinlichere Bedingungen prüfen
fbuffer_inc_capamachte5.7%der Gesamtlaufzeit aus, und die meiste Zeit floss in die Prüfung, ob der Puffer bereits allokiert war.- Diese Funktion wird bei jedem Schreibzugriff in den Puffer aufgerufen, aber nach dem ersten Aufruf ist der Puffer immer bereits allokiert.
- Die bisherige Struktur prüfte zuerst eine Bedingung, die fast nie zutraf, und verschwendete so Arbeit; wenn der Puffer noch nicht allokiert war, war
fb->capaaußerdem0, sodass sich ein Teil mit der Prüfungrequired > fb->capaüberschnitt. - Nach der Änderung wird zuerst der häufigste Fall geprüft, nämlich dass die Pufferkapazität ausreicht, und mit
RB_LIKELYundRB_UNLIKELYwerden Hinweise für die CPU-Branch-Prediction gegeben. - Die Funktion wurde als
inlinemarkiert, wodurch der Aufruf günstiger wurde; im Regelfall beschränkt sich die nötige Arbeit nun im Wesentlichen auf Subtraktion und Vergleich. - Diese Änderung erhöhte den Erzeugungs-Benchmark für
twitter.jsonvon1068.6 i/sauf1224.7 i/sund machte ihn damit 1,15-mal schneller. - Dasselbe Prinzip lässt sich auch auf Ruby-Code anwenden: zuerst die billigste und wahrscheinlichste Bedingung prüfen.
Die Konfigurationskosten des JSON-Generators senken
- Auch der Ruby-Comitter Yusuke Endoh aka Mame beteiligte sich an der Optimierung von
ruby/json; es gab bereits einen älteren PR mit mehreren Optimierungen. - Viele der Änderungen konzentrierten sich darauf, die Konfigurationskosten vor der JSON-Erzeugung zu senken.
- Argument-Parsing
- Allokation von Generator und zugehörigen Strukturen
- Vorbereitungsschritte vor dem eigentlichen Generieren
- Bei
ruby/jsonwaren diese Konfigurationskosten höher als bei alternativen Implementierungen, wodurch es in Mikrobenchmarks schlechter aussah. JSON.generateakzeptiert für Pretty-JSON Optionen wiearray_nl,object_nl,indentundspace.- Bisher wurden Trennzeichenpuffer anhand der übergebenen Strings vorab berechnet.
- Zum Beispiel:
",#{opts[:array_nl]}",",#{opts[:object_nl]}",":#{opts[:space]}" - Die Idee war, einen längeren Block in einem Stück anzuhängen, aber die tatsächlich eingesparte Arbeit war gering.
- In den meisten Fällen werden diese Optionen gar nicht genutzt, sodass die Vorab-Berechnung stärker ins Gewicht fiel.
- Zum Beispiel:
- Mame machte diese Optimierung im Wesentlichen rückgängig und senkte damit die Konfigurationskosten deutlich.
- In großen Benchmarks ist der Unterschied nicht groß.
- Im Benchmark für die Erzeugung eines kleinen 65-Byte-Hashs stieg die Leistung von
2,112,189.3 i/sauf3,199,311.0 i/sund wurde damit 1,51-mal schneller.
Pointer-Tracking vermeiden und Encoding-Indizes vergleichen
- Eine weitere Optimierung von Mame bestand darin, einen Aufruf von
rb_enc_getzu entfernen. - JSON musste häufig prüfen, ob ein String UTF-8-kompatibel ist; bisher wurde dazu per
rb_enc_get(obj)einrb_encoding *geholt und dann mit US-ASCII oder UTF-8 verglichen. rb_enc_getist eine defensive High-Level-API und führt verschiedene Typprüfungen aus.- Sie kann mit String, Symbol, Regexp, File, Data und anderen Objekten umgehen.
- Das bringt viele Verzweigungen mit sich, die teuer werden können, wenn die CPU-Branch-Prediction danebenliegt.
- Ein Ruby-String hält konzeptionell eine Encoding-Referenz, speichert aber intern statt eines 64-Bit-Pointers einen kleineren 7-Bit-Encoding-Index in seiner eigenen Bitmap.
- Um an den vollständigen Encoding-Objekt-Pointer zu kommen, muss über ein internes globales Array der VM das tatsächliche Encoding nachgeschlagen werden; in Low-Level-Code entspricht das Pointer-Tracking.
- Wenn die Daten bereits im CPU-Cache liegen, geht das schnell; müssen sie aus dem RAM geholt werden, muss die CPU warten.
jsonweiß aber bereits, dass das Ziel ein String ist, und braucht nur die Information, ob es ASCII oder UTF-8 ist; deshalb kann es mitRB_ENCODING_GETden Encoding-Index direkt vergleichen.- Diese Änderung erhöhte den Erzeugungs-Benchmark für
twitter.jsonvon1159.6 i/sauf1253.3 i/sund machte ihn damit 1,08-mal schneller.
String-Escaping mit Lookup-Tabellen beschleunigen
- Das Dumpen von JSON-Strings ist teuer, weil für jedes Zeichen geprüft werden muss, ob es direkt kopiert werden kann oder escaped werden muss.
- Ein naiver Ansatz prüft für jedes Zeichen mehrere Bedingungen.
- ob es ein ASCII-Steuerzeichen ist
- ob es
\n,\r,\t,\foder\bist - ob es
"oder\ist
- Beim Ansatz mit Lookup-Tabellen wird diese Entscheidung vorab in einem statischen Array berechnet; statt mehrerer Vergleiche pro Zeichen wird an einem dynamischen Offset ein Boolean gelesen.
- Das verbraucht etwas mehr statischen Speicher, macht die Schleife aber deutlich schneller.
- Unter der Annahme, dass die meisten Strings keine Zeichen enthalten, die escaped werden müssen, fügte Mame zunächst eine günstige Prüfung hinzu, ob der Fast Path greift; wenn ja, wird der gesamte String in einem Rutsch in den Puffer kopiert.
- Mames Patch ist als C-Code komplexer, nutzt aber dasselbe Muster.
- Allein diese Änderung hob den Erzeugungs-Benchmark für
twitter.jsonvon1258.1 i/sauf1630.2 i/sund machte ihn 1,30-mal schneller.
Weitere Optimierungen folgen
- Es bleiben noch weitere Optimierungen zu besprechen, daher wird ein Folgebeitrag angekündigt.
- Inzwischen wurde auch Teil zwei veröffentlicht.
1 Kommentare
Meinungen auf Hacker News
Ich mag byroots Arbeit wirklich sehr. Nicht nur die Art der Beiträge, sondern auch das Ausmaß seiner Produktivität ist immer wieder erstaunlich.
Ich habe ein paar Mal versucht, in die Arbeit am Ruby-Core einzusteigen, aber nichts gefunden, das zu meinen Fähigkeiten passte und zu dem ich sinnvoll positiv beitragen konnte; wenn nach ein paar Wochen nichts herauskommt, verschwindet die Motivation. Es ist wirklich schwer, sich den Kontext zu erarbeiten, wie er ihn im Artikel teilt.
Wenn die Leute aus der Ruby-C-Ecke häufiger schreiben würden, gäbe es wohl mehr Menschen mit den nötigen Fähigkeiten, um Ruby weiter zu verbessern. Auch die Tipps zu C-Profilern waren gut, und ich denke, man könnte damit anfangen, sich ein Ruby-Gem mit C-Code zu schnappen und wieder an Optimierungen herumzuschrauben.
Es geht zwar um C-Erweiterungen, hilft aber, einige Konzepte zu verstehen.
Teil 2 ist ebenfalls online: https://byroot.github.io/ruby/json/2024/12/18/optimizing-rub...
Eine erwähnenswerte Sache ist jbuilder, die Standardnutzung in Rails. jbuilder selbst ist zwar nicht die JSON-Serialisierung, aber wenn es darum geht, was JSON-Rendering in Ruby/Rails langsam macht, stünde es ganz oben auf meiner Liste.
Wenn man mit jbuilder viele Partials rendert, wird es wirklich langsam.
Die Artikel zu diesem Thema sind leicht nachzuvollziehen und machen Lust, meinen eigenen Ruby-Code zu benchmarken und zu optimieren. Sowohl der Artikel als auch die Arbeit sind gut.
Vielleicht habe ich es übersehen, aber steht irgendwo, wie lange die neue Version mit allen Optimierungen braucht, um einen Twitter-JSON-Dump zu parsen/encoden?
https://github.com/ruby/json/releases/tag/v2.7.3
https://github.com/ruby/json/releases/tag/v2.8.0
Großartiger Artikel und gute Arbeit. Gibt es künftig noch einen Grund, Oj zu verwenden?
Oj hat eine sehr große API, die das Standard-
json-Gem nicht nachbilden soll. Zum Beispiel „SAJ“ (Parsing im SAX-Stil), verschiedene Escape-Varianten usw.Mein Ziel ist nur, Oj für ungefähr 95 % der Anwendungsfälle überflüssig zu machen; für etliche Zwecke wird Oj also weiterhin nützlich sein.
Nach diesem Artikel frage ich mich, wie viel schneller es inzwischen als diese nicht mehr gepflegte Implementierung ist:
https://netflixtechblog.com/fast-json-api-serialization-with...
Ich fand diese reine Ruby-Implementierung ziemlich sauber, habe sie aber nie in echter Produktion eingesetzt. Sie liegt schon lange brach.
Insgesamt interessiert mich auch der Stand reiner Ruby-Implementierungen. Es sieht so aus, als sei
json_pureentfernt worden; falls ja, wäre das schade. Weiß jemand Genaueres? Der interessanteste Teil des Artikels sind für mich eher die Ruby-Optimierungen als die C-Optimierungen.Interessante Lektüre. Bei Optimierungen, die nicht Ruby-spezifisch sind, etwa eine Lookup-Tabelle für Escape-Zeichen, frage ich mich allerdings, warum man nicht bestehende Bibliotheken wie simdjson nutzt, die so etwas bereits tun.
Kurz gesagt:
ruby/jsonwird mit Ruby ausgeliefert und muss daher zu Rubys Einschränkungen kompatibel sein, was derzeit reines C99 und kein C++ bedeutet. Die Apache-2-Lizenz von simdjson könnte ebenfalls ein Problem sein, aber da bin ich mir nicht sicher.Insgesamt würde ich gern hervorragende C++-Bibliotheken wie dragonbox verwenden, kann es aber nicht.
Außerdem bot simdjson, als ich zuletzt nachgesehen habe, nur einen Parser. Das ruby/json-Gem übernimmt sowohl Parsing als auch Encoding, es würde also nur bei der Hälfte des Problemraums helfen.
In modernen Projekten passiert so etwas viel zu selten. Wenn solche Arbeit regelmäßiger gemacht worden wäre, frage ich mich, ob Bibliotheken wie simdjson oder oj überhaupt nötig gewesen wären. Dieser Problemraum ist gar nicht so schwierig.
Verwendet Ruby JSON Intrinsics? Kann es das?
Und wie spielt das mit den verschiedenen JITs zusammen?
Das
json-Gem ist in C implementiert und ist damit aus Sicht von YJIT, also dem JIT der Referenzimplementierung, eine Blackbox.Der TruffleRuby-JIT konnte früher C-Erweiterungen über Sulong interpretieren und über die Sprachgrenze hinweg JIT-kompilieren, aber meines Wissens wurde dieser Ansatz wegen verschiedener Kompatibilitätsprobleme vor Kurzem aufgegeben.
Außerdem ist der JSON-Parser in TruffleRuby in C implementiert, der Encoder aber in reinem Ruby: https://github.com/ruby/json/blob/e1f6456499d497f33f69ae4c1a...
Wenn ich mich richtig erinnere, sind Branch-Prediction-Hints auf modernen CPUs nutzlos.
„Ab der Redwood-Cove-Mikroarchitektur gilt: Wenn der Prädiktor keine gespeicherten Informationen zu einem Branch hat und dieser Branch einen Intel-SSE2-branch-taken-Hint, also das Instruktionspräfix 3EH, besitzt, dreht der Decoder beim Decodieren des Branches die Branch Prediction von not-taken auf taken. Anschließend wird die Front-End-Pipeline geflusht und die Pipeline dazu gebracht, den taken-Pfad zu holen.
...
Dieser Hint wird nur verwendet, wenn der Prädiktor keine gespeicherten Informationen zu diesem Branch hat. Um Code-Bloat und eine verringerte Instruction-Fetch-Bandbreite zu vermeiden, sollten Hints nicht bei Branches in Hot Code hinzugefügt werden, etwa in Schleifen mit vielen Iterationen, da der Prädiktor wahrscheinlich bereits Informationen zu diesem Branch gespeichert hat. Idealerweise sollten Hints nur bei selten ausgeführten, aber überwiegend taken Branches hinzugefügt werden; solche Branches zu identifizieren kann jedoch schwierig sein. Compilern wird empfohlen, Hints im Rahmen profilgesteuerter Optimierung hinzuzufügen, wenn ein Ausführungspfad nicht als Fall-through angeordnet werden kann. Die Redwood-Cove-Mikroarchitektur führt neue Performance-Monitoring-Events ein, um die Platzierung von Hints zu unterstützen.“