1 points par GN⁺ 2024-12-19 | 1 commentaires | Partager sur WhatsApp
  • La gem json par défaut de Ruby a été améliorée en supprimant des goulots d’étranglement par profiling, afin de réduire la pression pratique qui pousse à migrer vers oj pour des raisons de vitesse
  • L’objectif n’est pas de battre oj à tout prix, mais de fournir un traitement JSON suffisamment rapide et prévisible sans monkey patching comme Oj.mimic_JSON et Oj.optimize_rails
  • oj était plus rapide dans certains benchmarks, mais a créé une charge en matière de stabilité en production et de compatibilité d’API, avec notamment l’option script_safe ignorée, des différences de sérialisation Rails et des crashs Ruby
  • Les principales optimisations ont consisté à supprimer des vérifications UTF-8 redondantes, tester d’abord les conditions courantes, réduire le coût de configuration du générateur, éviter le suivi de pointeurs d’encodage et utiliser une vérification d’échappement basée sur une table de lookup
  • Sur le benchmark de génération twitter.json de 467 KiB, les changements ont apporté respectivement 3 %, 8 %, 15 % et 30 % d’amélioration, et la génération d’un petit Hash est devenue 1,51 fois plus rapide grâce à la seule réduction du coût de configuration

Pourquoi la gem json a été rendue plus rapide

  • Après être récemment devenu maintainer de la gem json, l’auteur a corrigé d’anciens bugs tout en se concentrant sur les améliorations de performance, ce qui en a fait, dans la plupart des benchmarks, le parseur et générateur JSON le plus rapide pour Ruby
  • La plupart des patchs de performance relèvent moins d’astuces particulières que d’un travail de profiling pour identifier les goulots d’étranglement et réduire les gaspillages simples
  • La motivation principale était de rendre ruby/json suffisamment rapide pour que les utilisateurs n’aient plus à choisir une gem de remplacement pour des raisons de vitesse

La charge créée par l’usage de oj comme remplacement

  • L’écart entre json 2.7.2 et oj n’était pas très grand dans certains benchmarks proches de tailles réelles
    • Le parsing d’un document JSON de 467 KiB contenant 100 tweets prenait 1.9ms avec json 2.7.2 et 1.6ms avec oj
    • La génération du même document prenait 0.8ms avec json 2.7.2 et 0.4ms avec oj
  • Dans beaucoup de cas d’usage, la partie lente n’est pas la sérialisation JSON elle-même, mais la couche supérieure qui transforme des modèles Active Record en Hash et Array Ruby
  • oj était utilisé dans de nombreux projets, y compris la codebase de Shopify, et sa popularité était probablement due à sa vitesse
  • Incohérences d’API dues au monkey patching

    • Oj.mimic_JSON est souvent utilisé pour monkey patcher la gem json, et Oj.optimize_rails pour monkey patcher ActiveSupport::JSON
    • JSON.dump(data, script_safe: true) peut échapper </script> en <\/script> afin d’insérer du JSON en toute sécurité dans une balise <script>
    • Comme oj ne connaît pas l’option script_safe et l’ignore, une gem sûre lorsqu’elle est utilisée seule peut créer une possibilité d’attaque XSS dans une application qui appelle Oj.mimic_JSON
    • Oj.optimize_rails peut aussi introduire de subtiles différences dans la sérialisation des objets
    • Lorsque ActiveSupport::JSON::Encoding.time_precision = 0, ActiveSupport::JSON.encode(t) peut produire une chaîne à la précision de la seconde
    • Après Oj.optimize_rails et Oj.mimic_JSON, il existe des exemples où la chaîne produite inclut des millisecondes
    • Ce cas est un corner case lié à l’ordre de chargement, mais par le passé, bien davantage de comportements changeaient
  • Problèmes de stabilité en environnement de production

    • Dans les environnements à grande échelle, oj était l’une des causes notables de crashs Ruby, juste derrière grpc en nombre de problèmes
    • Écrire une gem native nécessite de comprendre la VM Ruby et en particulier le GC ; sinon, des crashs ou corruptions mémoire peuvent survenir
    • La codebase de oj contenait des hacks qui rendaient la confiance difficile, et a même désactivé le GC dans certaines situations pour contourner un bug
    • Réactiver le GC peut déclencher un cycle de major GC
    • Ce type de code peut avantager les microbenchmarks tout en dégradant les performances réelles en production
    • À cause de cette expérience, Shopify a retiré Oj de son monolith, ce qui a permis d’observer de subtiles différences entre Oj.mimic_JSON et le vrai json

Trouver les goulots d’étranglement par benchmarks et profiling

  • L’objectif était que ruby/json se comporte de façon proche de oj, à la fois dans les usages réels et les microbenchmarks, afin de réduire l’attrait d’utiliser Oj.mimic_JSON pour des raisons de vitesse
  • La première étape a consisté à constituer une suite de benchmarks
    • Elle inclut à la fois des microbenchmarks et des benchmarks plus réalistes
    • Elle s’appuie sur la suite de benchmarks de la gem rapidjson-ruby de John Hawthorn, avec quelques ajouts
  • Pour le profiler C, samply a été utilisé
    • Son avantage est de produire des rapports compatibles avec Firefox Profiler, faciles à partager

Supprimer les vérifications UTF-8 redondantes

  • Lors du profiling de JSON.dump avec le payload twitter.json, 9% du temps était passé dans isLegalUTF8 de JSON lui-même, et 1.9% dans rb_enc_str_asciionly_p
  • Les String Ruby possèdent un attribut interne appelé coderange, qui met en cache l’état de l’encodage de la chaîne ou le fait qu’elle soit ASCII-only après un premier scan
    • ENC_CODERANGE_UNKNOWN : pas encore scannée
    • ENC_CODERANGE_VALID : encodage valide
    • ENC_CODERANGE_7BIT : encodage valide et uniquement des caractères ASCII
    • ENC_CODERANGE_INVALID : encodage invalide
  • L’ancien convert_UTF8_to_JSON_ASCII appelait d’abord rb_enc_str_asciionly_p, puis rescannait manuellement la chaîne pour vérifier la validité UTF-8, ce qui constituait un travail redondant
  • Après modification, la validité UTF-8 est déterminée en comparant le coderange déjà calculé
    • En Ruby, la structure revient à lever JSON::GeneratorError si la chaîne n’est pas string.ascii_only? et que string.encoding != Encoding::UTF_8 ou !string.valid_encoding?
    • #ascii_only? comme #valid_encoding? utilisent le coderange mis en cache, donc la chaîne n’est scannée qu’une fois au maximum
  • Contrairement aux 9 % attendus, l’amélioration réelle était d’environ 3%
    • Une grande partie du temps passé dans isLegalUTF8 s’est déplacée vers convert_UTF8_to_JSON
    • La raison n’est pas certaine, mais une grande part de ces 9 % correspondait peut-être au coût de chargement des octets de la chaîne depuis la RAM vers le cache CPU
    • Le benchmark de génération twitter.json est passé de 1077.3 i/s à 1113.3 i/s, soit 1.03x plus rapide

Tester d’abord les conditions moins coûteuses et plus probables

  • fbuffer_inc_capa représentait 5.7% du runtime total, et la majeure partie du temps servait à vérifier si le buffer était déjà alloué
  • Cette fonction est appelée à chaque écriture dans le buffer, mais après le premier appel, le buffer est toujours déjà alloué
  • L’ancienne structure testait d’abord une condition presque jamais vraie, ce qui entraînait beaucoup de gaspillage ; si le buffer n’était pas encore alloué, fb->capa valait 0, ce qui faisait aussi doublon en partie avec la vérification required > fb->capa
  • Après modification, le cas le plus courant, « la capacité du buffer est suffisante », est testé en premier, avec RB_LIKELY et RB_UNLIKELY pour donner des indications à la branch prediction du CPU
  • La fonction a été marquée inline, réduisant le coût d’appel, et dans la plupart des cas le travail nécessaire se limite à une soustraction et une comparaison
  • Ce changement a fait passer le benchmark de génération twitter.json de 1068.6 i/s à 1224.7 i/s, soit 1,15 fois plus rapide
  • Le même principe peut s’appliquer au code Ruby : tester d’abord la condition la moins coûteuse et la plus probable

Réduire le coût de configuration du générateur JSON

  • Le committer Ruby Yusuke Endoh, alias Mame, a aussi participé à l’optimisation de ruby/json, avec une ancienne PR regroupant plusieurs optimisations
  • Beaucoup de changements visaient à réduire le coût de configuration nécessaire avant la génération JSON
    • Parsing des arguments
    • Allocation du générateur et des structures associées
    • Travail de préparation avant le début de la génération
  • Dans ruby/json, ce coût de configuration était plus élevé que dans les implémentations alternatives, ce qui le désavantageait dans les microbenchmarks
  • JSON.generate peut recevoir des options comme array_nl, object_nl, indent et space pour générer du pretty JSON
  • Auparavant, des buffers de séparateurs étaient précalculés à partir des chaînes fournies
    • Par exemple : ",#{opts[:array_nl]}", ",#{opts[:object_nl]}", ":#{opts[:space]}"
    • L’idée était d’append un seul long fragment, mais le travail réellement économisé était faible
    • Dans la plupart des cas, ces options n’étaient pas utilisées, donc le coût du précalcul pesait davantage
  • Mame a essentiellement annulé cette optimisation, réduisant fortement le coût de configuration
    • Sur les gros benchmarks, la différence n’est pas majeure
    • Le benchmark de génération d’un petit Hash de 65 bytes est passé de 2,112,189.3 i/s à 3,199,311.0 i/s, soit 1,51 fois plus rapide

Éviter le suivi de pointeurs et comparer l’index d’encodage

  • Une autre optimisation de Mame a consisté à supprimer un appel à rb_enc_get
  • JSON doit souvent vérifier si une chaîne est compatible UTF-8, et l’ancien code obtenait un rb_encoding * via rb_enc_get(obj), puis le comparait à US-ASCII ou UTF-8
  • rb_enc_get est une API défensive de haut niveau qui effectue plusieurs vérifications de type
    • Elle est conçue pour traiter divers objets comme String, Symbol, Regexp, File, Data, etc.
    • Elle contient de nombreux branchements, et le coût peut augmenter si la branch prediction du CPU se trompe
  • Conceptuellement, une String Ruby possède une référence d’encodage, mais en pratique, au lieu d’un pointeur 64-bit, elle stocke un index d’encodage sur 7 bits plus petit dans le bitmap interne de chaque String
  • Pour obtenir le pointeur complet vers l’objet d’encodage, il faut retrouver l’encodage réel via un tableau global interne à la VM, ce qui correspond à du suivi de pointeurs dans du code bas niveau
    • C’est rapide si c’est déjà dans le cache CPU, mais si les données doivent être chargées depuis la RAM, le CPU doit attendre
  • json sait déjà que la cible est une String, et l’information nécessaire se limite à savoir s’il s’agit d’ASCII ou d’UTF-8 ; il peut donc comparer directement l’index d’encodage avec RB_ENCODING_GET
  • Ce changement a fait passer le benchmark de génération twitter.json de 1159.6 i/s à 1253.3 i/s, soit 1,08 fois plus rapide

Accélérer l’échappement des chaînes avec une table de lookup

  • Le dump d’une chaîne JSON coûte cher, car il faut vérifier pour chaque caractère s’il peut être copié tel quel ou s’il doit être échappé
  • Une approche naïve teste plusieurs conditions pour chaque caractère
    • Vérifier s’il s’agit d’un caractère de contrôle ASCII
    • Vérifier s’il s’agit de \n, \r, \t, \f, \b
    • Vérifier s’il s’agit de " ou \
  • L’approche par table de lookup précalcule cette décision dans un tableau statique, et lit un booléen à un offset dynamique pour chaque caractère, au lieu d’effectuer plusieurs comparaisons
  • En échange d’un peu plus de mémoire statique, la boucle devient beaucoup plus rapide
  • En partant de l’hypothèse que la plupart des chaînes ne contiennent pas de caractères à échapper, Mame a ajouté une précondition qui vérifie à faible coût s’il s’agit du fast path, puis copie toute la chaîne dans le buffer en une seule fois si c’est le cas
  • Le patch de Mame est plus complexe car il est en C, mais utilise le même schéma
  • À lui seul, ce changement a fait passer le benchmark de génération twitter.json de 1258.1 i/s à 1630.2 i/s, soit 1,30 fois plus rapide

Optimisations à venir

  • D’autres optimisations restant à couvrir, l’article annonce une suite
  • La partie deux a ensuite été publiée

1 commentaires

 
GN⁺ 2024-12-19
Commentaires Hacker News
  • J’aime vraiment le travail de byroot. Ce n’est pas seulement le type de contributions, mais aussi l’ampleur de sa productivité, qui est toujours impressionnante.
    J’ai essayé à plusieurs reprises de me mettre au travail sur le cœur de Ruby, mais je n’ai pas trouvé de tâche à ma portée qui me permette de contribuer positivement, et après quelques semaines sans résultat, la motivation disparaît. C’est vraiment difficile d’acquérir le contexte comme celui partagé dans l’article.
    Si les gens qui travaillent sur la partie C de Ruby écrivaient plus souvent, je pense qu’il y aurait davantage de personnes ayant les compétences nécessaires pour améliorer Ruby. Les conseils sur les profileurs C étaient bons aussi, et je me dis qu’on pourrait commencer par prendre une gem Ruby contenant du code C et retravailler son optimisation.

    • Il y a aussi l’excellente série de Peter Zhu : https://blog.peterzhu.ca/ruby-c-ext/
      Même s’il s’agit d’extensions C, elle aide à comprendre certains concepts.
    • « Productivité incroyable », certes, mais c’est aussi quelqu’un de vraiment incroyablement intelligent. J’ai travaillé dans le même bureau que lui chez Shopify, et il est à un niveau qui paraît inatteignable.
  • La deuxième partie est également en ligne : https://byroot.github.io/ruby/json/2024/12/18/optimizing-rub...

  • Un point qui mérite d’être mentionné est jbuilder, utilisé par défaut dans Rails. jbuilder en lui-même n’est pas la partie sérialisation JSON, mais si l’on parle de ce qui ralentit le rendu JSON dans Ruby/Rails, il serait tout en haut de ma liste.
    Rendre beaucoup de partials avec jbuilder devient vraiment lent.

  • Les articles sur ce sujet sont faciles à suivre et donnent envie de benchmarker et optimiser aussi mon propre code Ruby. L’article comme le travail sont excellents.

  • Je l’ai peut-être manqué, mais y a-t-il un endroit où l’on voit combien de temps la nouvelle version avec toutes les optimisations met à parser/encoder le dump JSON de Twitter ?

  • Excellent article et beau travail. Y a-t-il encore une raison d’utiliser Oj à l’avenir ?

    • Je suis l’auteur.
      Oj expose une très grande API que la gem json de base n’a pas l’intention d’imiter. Par exemple « SAJ » (parsing de style SAX), plusieurs modes d’échappement, etc.
      Mon objectif est simplement de rendre Oj inutile pour environ 95 % des cas d’usage ; il restera donc utile dans de nombreuses situations.
  • Après cet article, je me demande à quel point c’est devenu plus rapide que cette implémentation désormais non maintenue.
    https://netflixtechblog.com/fast-json-api-serialization-with...
    Je trouvais cette implémentation en Ruby pur assez propre, mais je ne l’ai jamais utilisée en production réelle. Elle est laissée à l’abandon depuis longtemps.
    Plus généralement, je suis aussi curieux de l’état des implémentations en Ruby pur. Il semble que json_pure ait été supprimé, ce qui serait dommage. Quelqu’un connaît-il les détails ? La partie la plus intéressante de l’article, pour moi, concerne davantage les optimisations Ruby que les optimisations C.

  • Lecture intéressante. Cela dit, pour les optimisations qui ne sont pas propres à Ruby, par exemple une table de correspondance pour les caractères d’échappement, je me demande pourquoi ne pas s’appuyer sur des bibliothèques existantes comme simdjson qui font déjà ce genre de choses.

    • Une partie de la réponse a été donnée ici : https://news.ycombinator.com/item?id=42450085
      En bref, ruby/json est distribué avec Ruby et doit donc rester compatible avec les contraintes de Ruby, ce qui signifie aujourd’hui du C99 pur et pas de C++. La licence Apache 2 de simdjson pourrait aussi poser problème, mais je n’en suis pas certain.
      Globalement, j’aimerais utiliser d’excellentes bibliothèques C++ comme dragonbox, mais ce n’est pas possible.
      Et la dernière fois que j’ai vérifié, simdjson ne fournissait qu’un parseur. La gem ruby/json fait à la fois parsing et encodage, donc cela n’aiderait que pour la moitié du problème.
    • Ce qui est bien dans cet article, c’est qu’il s’agit d’un vrai travail d’ingénierie sur une base de code existante. Il ne cherche pas à tout remplacer ni à changer de bibliothèque pour gagner un peu en vitesse ; il plonge dans le code réel pour l’améliorer vraiment, en vitesse comme en efficacité.
      On ne fait pas assez ce genre de travail dans les projets modernes. Si cela avait été fait plus régulièrement, je me demande si des bibliothèques comme simdjson ou oj auraient été nécessaires au départ. Ce domaine de problème n’est pas si difficile que cela.
  • Ruby JSON utilise-t-il des intrinsics ? Peut-il en utiliser ?
    Et comment cela interagit-il avec les différents JIT ?

    • Je ne suis pas sûr de ce que tu entends exactement par intrinsics.
      La gem json est implémentée en C, donc pour YJIT, c’est-à-dire le JIT de l’implémentation de référence, c’est une boîte noire.
      Le JIT de TruffleRuby pouvait auparavant interpréter les extensions C via sulong et faire du JIT au-delà des frontières de langage, mais à ma connaissance cette approche a récemment été abandonnée en raison de divers problèmes de compatibilité.
      Par ailleurs, dans TruffleRuby, le parseur JSON est implémenté en C, mais l’encodeur est en Ruby pur : https://github.com/ruby/json/blob/e1f6456499d497f33f69ae4c1a...
  • Si je me souviens bien, les indices de prédiction de branchement ne servent à rien sur les CPU modernes.

    • Ils étaient inutiles sur les CPU modernes, mais sont redevenus quelque peu utiles sur certains CPU. https://www.phoronix.com/news/GCC-Clang-Intel-x86-Branch-Hin...
      « À partir de la microarchitecture Redwood Cove, si le prédicteur ne dispose d’aucune information stockée pour un branchement et que ce branchement comporte un indice Intel SSE2 branch taken, c’est-à-dire le préfixe d’instruction 3EH, le codec inverse la prédiction de branchement de not-taken à taken lors du décodage du branchement. Il flush ensuite le front-end du pipeline et oriente le pipeline pour récupérer le chemin taken.
      ...
      Cet indice n’est utilisé que lorsque le prédicteur ne dispose d’aucune information stockée pour ce branchement. Pour éviter le gonflement du code et la réduction de la bande passante de fetch des instructions, il ne faut pas ajouter d’indices aux branchements du code chaud, comme ceux dans des boucles exécutées de nombreuses fois, car le prédicteur a probablement déjà stocké des informations sur ces branchements. Idéalement, il ne faudrait ajouter des indices qu’aux branchements rarement exécutés mais majoritairement taken, même s’il peut être difficile de les identifier. Lorsque le compilateur ne peut pas placer l’un des chemins d’exécution en fall-through, il est recommandé d’ajouter l’indice dans le cadre d’une optimisation guidée par profil. La microarchitecture Redwood Cove introduit de nouveaux événements de monitoring des performances pour guider le placement des indices. »