1 Punkte von GN⁺ 2024-12-19 | 1 Kommentare | Auf WhatsApp teilen
  • 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 zu oj zu wechseln.
  • Das Ziel ist nicht, oj um jeden Preis zu schlagen, sondern auch ohne Monkey-Patching wie Oj.mimic_JSON und Oj.optimize_rails eine ausreichend schnelle und vorhersehbare JSON-Verarbeitung zu bieten.
  • oj war in einigen Benchmarks schneller, brachte aber durch ignorierte script_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.json und 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/json schnell 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.2 und oj war in einigen realitätsnäheren Benchmarks nicht groß.
    • Das Parsen eines 467-KiB-JSON-Dokuments mit 100 Tweets dauerte bei json 2.7.2 1.9ms, bei oj 1.6ms.
    • Das Erzeugen desselben Dokuments dauerte bei json 2.7.2 0.8ms, bei oj 0.4ms.
  • 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.
  • oj wurde 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_JSON wird oft verwendet, um das json-Gem zu monkeypatchen, Oj.optimize_rails, um ActiveSupport::JSON zu monkeypatchen.
    • JSON.dump(data, script_safe: true) kann </script> zu <\/script> escapen, damit JSON sicher innerhalb eines <script>-Tags eingebettet werden kann.
    • Da oj die Option script_safe nicht kennt und ignoriert, kann ein für sich genommen sicheres Gem in einer Anwendung, die Oj.mimic_JSON aufruft, eine mögliche XSS-Angriffsfläche schaffen.
    • Auch Oj.optimize_rails kann subtile Unterschiede bei der Objektserialisierung erzeugen.
    • Bei ActiveSupport::JSON::Encoding.time_precision = 0 kann ActiveSupport::JSON.encode(t) einen String mit nur Sekundenauflösung erzeugen.
    • Nach Oj.optimize_rails und Oj.mimic_JSON gibt 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 oj eine der auffälligeren Ursachen für Ruby-Abstürze und machte nach grpc die 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 Oj aus seinem Monolithen und stellte dabei subtile Unterschiede zwischen Oj.mimic_JSON und dem echten json fest.

Engpässe per Benchmarking und Profiling finden

  • Ziel war, dass sich ruby/json sowohl in realen Anwendungen als auch in Mikrobenchmarks ähnlich wie oj verhält, sodass der Anreiz sinkt, aus Geschwindigkeitsgründen Oj.mimic_JSON zu 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.dump mit der Payload twitter.json entfielen 9% auf isLegalUTF8 im JSON-Code selbst und 1.9% auf rb_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 gescannt
    • ENC_CODERANGE_VALID: Encoding ist gültig
    • ENC_CODERANGE_7BIT: Encoding ist gültig und enthält nur ASCII-Zeichen
    • ENC_CODERANGE_INVALID: Encoding ist ungültig
  • Das bisherige convert_UTF8_to_JSON_ASCII rief zunächst rb_enc_str_asciionly_p auf 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 coderange bestimmt.
    • In Ruby ausgedrückt bedeutet das: Wenn string.ascii_only? nicht gilt, wird JSON::GeneratorError ausgelöst, falls string.encoding != Encoding::UTF_8 oder !string.valid_encoding? gilt.
    • Sowohl #ascii_only? als auch #valid_encoding? nutzen den gecachten coderange, sodass der String höchstens einmal gescannt wird.
  • Trotz der erwarteten 9 % lag die reale Verbesserung nur bei rund 3 %.
    • Ein erheblicher Teil der zuvor in isLegalUTF8 verbrachten Zeit wanderte zu convert_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.json stieg von 1077.3 i/s auf 1113.3 i/s, also auf das 1.03x-Fache.

Zuerst billigere und wahrscheinlichere Bedingungen prüfen

  • fbuffer_inc_capa machte 5.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->capa außerdem 0, sodass sich ein Teil mit der Prüfung required > fb->capa überschnitt.
  • Nach der Änderung wird zuerst der häufigste Fall geprüft, nämlich dass die Pufferkapazität ausreicht, und mit RB_LIKELY und RB_UNLIKELY werden Hinweise für die CPU-Branch-Prediction gegeben.
  • Die Funktion wurde als inline markiert, 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.json von 1068.6 i/s auf 1224.7 i/s und 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/json waren diese Konfigurationskosten höher als bei alternativen Implementierungen, wodurch es in Mikrobenchmarks schlechter aussah.
  • JSON.generate akzeptiert für Pretty-JSON Optionen wie array_nl, object_nl, indent und space.
  • 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.
  • 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/s auf 3,199,311.0 i/s und 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_get zu entfernen.
  • JSON musste häufig prüfen, ob ein String UTF-8-kompatibel ist; bisher wurde dazu per rb_enc_get(obj) ein rb_encoding * geholt und dann mit US-ASCII oder UTF-8 verglichen.
  • rb_enc_get ist 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.
  • json weiß aber bereits, dass das Ziel ein String ist, und braucht nur die Information, ob es ASCII oder UTF-8 ist; deshalb kann es mit RB_ENCODING_GET den Encoding-Index direkt vergleichen.
  • Diese Änderung erhöhte den Erzeugungs-Benchmark für twitter.json von 1159.6 i/s auf 1253.3 i/s und 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, \f oder \b ist
    • 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.json von 1258.1 i/s auf 1630.2 i/s und 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

 
GN⁺ 2024-12-19
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 gibt auch eine hervorragende Reihe von Peter Zhu: https://blog.peterzhu.ca/ruby-c-ext/
      Es geht zwar um C-Erweiterungen, hilft aber, einige Konzepte zu verstehen.
    • „Enorme Produktivität“ stimmt, aber er ist auch wirklich ein enorm kluger Mensch. Ich habe bei Shopify im selben Büro mit ihm gearbeitet, und er wirkt wie jemand auf einem unerreichbaren Niveau.
  • 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?

  • Großartiger Artikel und gute Arbeit. Gibt es künftig noch einen Grund, Oj zu verwenden?

    • Ich bin der Autor.
      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_pure entfernt 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.

    • Das wurde gewissermaßen hier beantwortet: https://news.ycombinator.com/item?id=42450085
      Kurz gesagt: ruby/json wird 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.
    • Das Gute an diesem Artikel ist, dass es echte Engineering-Arbeit an einer bestehenden Codebasis ist. Es wird nicht versucht, für ein bisschen mehr Geschwindigkeit alles auszutauschen oder die Bibliothek zu wechseln, sondern man gräbt sich in den tatsächlichen Code ein und verbessert nicht nur die Geschwindigkeit, sondern auch die Effizienz wirklich.
      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?

    • Ich bin mir nicht ganz sicher, was mit Intrinsics genau gemeint ist.
      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.

    • Auf modernen CPUs waren sie nutzlos, aber auf einigen CPUs sind sie wieder in gewissem Maß nützlich geworden. https://www.phoronix.com/news/GCC-Clang-Intel-x86-Branch-Hin...
      „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.“