- La gem
jsonpar 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 versojpour 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 commeOj.mimic_JSONetOj.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’optionscript_safeignoré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.jsonde 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/jsonsuffisamment 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.2etojn’é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.9msavecjson 2.7.2et1.6msavecoj - La génération du même document prenait
0.8msavecjson 2.7.2et0.4msavecoj
- Le parsing d’un document JSON de 467 KiB contenant 100 tweets prenait
- 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_JSONest souvent utilisé pour monkey patcher la gemjson, etOj.optimize_railspour monkey patcherActiveSupport::JSONJSON.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
ojne connaît pas l’optionscript_safeet l’ignore, une gem sûre lorsqu’elle est utilisée seule peut créer une possibilité d’attaque XSS dans une application qui appelleOj.mimic_JSON Oj.optimize_railspeut 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_railsetOj.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èregrpcen 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
ojcontenait 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é
Ojde son monolith, ce qui a permis d’observer de subtiles différences entreOj.mimic_JSONet le vraijson
- Dans les environnements à grande échelle,
Trouver les goulots d’étranglement par benchmarks et profiling
- L’objectif était que
ruby/jsonse comporte de façon proche deoj, à la fois dans les usages réels et les microbenchmarks, afin de réduire l’attrait d’utiliserOj.mimic_JSONpour 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.dumpavec le payloadtwitter.json,9%du temps était passé dansisLegalUTF8de JSON lui-même, et1.9%dansrb_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 scanENC_CODERANGE_UNKNOWN: pas encore scannéeENC_CODERANGE_VALID: encodage valideENC_CODERANGE_7BIT: encodage valide et uniquement des caractères ASCIIENC_CODERANGE_INVALID: encodage invalide
- L’ancien
convert_UTF8_to_JSON_ASCIIappelait d’abordrb_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
coderangedéjà calculé- En Ruby, la structure revient à lever
JSON::GeneratorErrorsi la chaîne n’est passtring.ascii_only?et questring.encoding != Encoding::UTF_8ou!string.valid_encoding? #ascii_only?comme#valid_encoding?utilisent lecoderangemis en cache, donc la chaîne n’est scannée qu’une fois au maximum
- En Ruby, la structure revient à lever
- Contrairement aux 9 % attendus, l’amélioration réelle était d’environ 3%
- Une grande partie du temps passé dans
isLegalUTF8s’est déplacée versconvert_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.jsonest passé de1077.3 i/sà1113.3 i/s, soit1.03xplus rapide
- Une grande partie du temps passé dans
Tester d’abord les conditions moins coûteuses et plus probables
fbuffer_inc_capareprésentait5.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->capavalait0, ce qui faisait aussi doublon en partie avec la vérificationrequired > fb->capa - Après modification, le cas le plus courant, « la capacité du buffer est suffisante », est testé en premier, avec
RB_LIKELYetRB_UNLIKELYpour 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.jsonde1068.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.generatepeut recevoir des options commearray_nl,object_nl,indentetspacepour 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
- Par exemple :
- 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 *viarb_enc_get(obj), puis le comparait à US-ASCII ou UTF-8 rb_enc_getest 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
jsonsait 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 avecRB_ENCODING_GET- Ce changement a fait passer le benchmark de génération
twitter.jsonde1159.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.jsonde1258.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
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.
Même s’il s’agit d’extensions C, elle aide à comprendre certains concepts.
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 ?
https://github.com/ruby/json/releases/tag/v2.7.3
https://github.com/ruby/json/releases/tag/v2.8.0
Excellent article et beau travail. Y a-t-il encore une raison d’utiliser Oj à l’avenir ?
Oj expose une très grande API que la gem
jsonde 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_pureait é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.
En bref,
ruby/jsonest 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.
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 ?
La gem
jsonest 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.
« À 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. »