Le traitement des chaînes dans serde_json accéléré de 20 %
(purplesyringa.moe)- serde_json, utilisé comme le parseur JSON de référence dans l’écosystème Rust, a gagné 10 %, 23 % et 32 % de performances dans des benchmarks riches en chaînes grâce à de petites optimisations internes
- Le calcul de la position des erreurs ne parcourt plus directement le début de la chaîne : il utilise désormais memchr pour trouver le nombre de lignes et le dernier retour à la ligne, réduisant l’écart d’un chemin d’erreur qui était plus de deux fois plus lent que le chemin de succès
- La boucle centrale de parsing des chaînes n’identifie plus séparément
\,"et les caractères de contrôle : elle les vérifie en une fois avec SWAR, ce qui offre une faible latence sur les chaînes courtes sans véritable SIMD - Le décodage des escapes
\uréduit les coûts de branchement et de shift grâce à une LUT, des entiers signés et une table de décalage préalable ; le parsing du JSON de War and Peace en russe passe de 284 MB/s à 344 MB/s - L’encodage UTF-8, devenu ensuite le goulot d’étranglement, est traité par une génération UTF-8 manuelle évitant les coûts d’initialisation et de copie de
char::encode_utf8etVec::extend_from_slice, portant le même benchmark à 374 MB/s
Pourquoi l’optimisation de serde_json produit un effet important
serdeest le principal framework de sérialisation et de désérialisation de Rust, etserde_jsonest largement utilisé comme combinaison officielle pour traiter JSON- Au moment de la publication,
serde_jsoncomptait 26 916 dépendances inverses, contre 66 poursimd-json - Avec un tel niveau d’utilisation, même de petites améliorations internes de
serde_jsonpeuvent avoir un effet cumulé dans tout l’écosystème Rust - Dans un contexte où beaucoup d’utilisateurs ne changent pas facilement de parseur JSON, des optimisations à faible risque dans la bibliothèque existante sont pratiques
Calcul de la position des erreurs : d’une boucle simple à memchr
- Dans le benchmark du chemin d’erreur,
serde_jsonétait plus de deux fois plus lent que le chemin de succès sur les mêmes données- Sur les jeux de données
canada,citm_catalogettwitter, le chemin d’erreur était 48 % à 77 % plus lent que le chemin de succès
- Sur les jeux de données
- Le goulot d’étranglement était la fonction
position_of_index(), qui convertit un index en line/column pour formater le message d’erreur- L’ancienne implémentation parcourait
self.slice[..i]octet par octet et mettait à jour line et column à chaque\n
- L’ancienne implémentation parcourait
- En divisant le calcul en deux étapes, on peut appliquer
memchr- Compter le nombre de
\ndansself.slice[..i]pour obtenir le numéro de ligne en base 0 - Trouver la position du dernier
\net la soustraire deipour obtenir le numéro de colonne
- Compter le nombre de
- memchr fournit une implémentation optimisée pour rechercher un seul caractère et compter ses occurrences, en utilisant SIMD en interne
- Après la fusion de la PR #1160, les performances du chemin d’erreur se sont nettement améliorées
- Sur
canadaen DOM, le chemin d’erreur est passé de 122 MB/s à 216 MB/s - Sur
citm_catalogen struct, il est passé de 195 MB/s à 736 MB/s - Le chemin d’erreur restait plus lent que le chemin de succès, mais l’écart s’est réduit
- Sur
Recherche des escapes dans les chaînes : trouver trois conditions en une seule fois
- L’ancienne boucle centrale du parsing des chaînes parcourait les octets non échappés à l’aide d’une table
ESCAPE- Dans une chaîne JSON, les éléments à traiter comme escapes sont
\,"et les caractères de contrôle ASCII inférieurs ou égaux à0x1F - Selon la spécification JSON, les codes de contrôle ne sont pas autorisés dans les chaînes
- Dans une chaîne JSON, les éléments à traiter comme escapes sont
- La première tentative utilisait
memchr2pour trouver d’abord\ou", puis vérifiait séparément les caractères de contrôle- Cela revenait à parcourir la chaîne deux fois, une fois rapidement et une fois lentement, ce qui était plus lent que l’existant
- Vectoriser seulement une moitié simple et laisser le reste en scalaire ne débouchait pas sur une amélioration globale
- La deuxième tentative combinait
memchr2avec du SIMD manuel pour vérifier l’absence de caractères de contrôle- Sur les chaînes courtes, le coût de l’appel de fonction avec sélection à l’exécution devenait significatif
- Sur les chaînes longues, le problème de lire la mémoire deux fois subsistait
- L’implémentation finale a été organisée pour trouver
\,"et les caractères de contrôle en un seul passage
Implémenter un traitement proche du SIMD avec SWAR
- Pour éviter d’ajouter du code SIMD propre à chaque plateforme, la technique SWAR (SIMD Within A Register) a été utilisée
- Au lieu de SIMD 128 bits, un mot de 64 bits est traité comme 8 octets
- Des opérations bit à bit vérifient simultanément si chaque octet satisfait la condition
- Le test des caractères de contrôle transforme la condition
c >= 0 && c < 0x20en opérations bit à bit- Sur des blocs de 8 octets, cela se vérifie avec un masque de la forme
!c & (c - 0x2020202020202020) & 0x8080808080808080 - L’emprunt d’une soustraction 64 bits peut se propager à l’octet supérieur, mais cela ne pose pas de problème quand l’objectif est de trouver le caractère de contrôle le plus bas
- Sur des blocs de 8 octets, cela se vérifie avec un masque de la forme
\et"sont vérifiés de façon similaire et combinés en une seule expression- L’expression complète se compose de 9 opérations bit à bit
- En SIMD x86, 7 opérations seraient nécessaires, donc le débit est inférieur, mais la latence compte davantage pour les chaînes courtes
- Dans json-benchmark, ce code SWAR s’est révélé plus efficace que du vrai code SIMD
- Sur les chaînes très courtes, SWAR peut être plus lent que le code scalaire
- Une régression apparaît autour des chaînes d’environ 5 caractères
- Pour protéger des motifs fréquents comme la chaîne vide
""et les escapes consécutifs\r\n,\uD801\uDC37, le premier caractère est vérifié avant d’entrer dans la boucle SWAR
- Les résultats de l’optimisation de la recherche dans les chaînes varient selon les jeux de données
twitterstruct passe de 638 MB/s à 785 MB/s, soit +23 %twitterDOM passe de 305 MB/s à 335 MB/s, soit +10 %citm_catalogstruct passe de 865 MB/s à 905 MB/s, soit +5 %- Les chaînes vides restent jusqu’à 2 % plus lentes dans certains microbenchmarks
Optimisation du décodage des escapes Unicode
serde_jsontraite à la fois les chaînes Unicode brutes et les escapes\u- Par exemple :
"🥺"et"\ud83e\udd7a"
- Par exemple :
- L’ancien décodage hexadécimal utilisait une LUT de 256 entrées pour associer chaque caractère à une valeur de 0 à 15
- Il gérait
'0'..='9','A'..='F'et'a'..='f' - Les caractères invalides étaient marqués par une valeur sentinelle
- Il gérait
- Un escape
\ulit 4 chiffres hexadécimaux ; l’ancienne méthode effectuait donc à chaque itération shift, addition, comparaison et branchement conditionnel - La nouvelle implémentation ne renvoie pas d’erreur à chaque itération : elle traite les 4 caractères, puis vérifie leur validité
- L’implémentation finale utilise une LUT
[i8; 256]où les chiffres invalides valent-1, et calcule avec un entier 32 bits- En cas de succès, le résultat n’est pas négatif ; en cas d’échec, il l’est
- Sur x86, le chargement mémoire et l’extension de signe peuvent être combinés en
movsx
- Deux tables sont utilisées pour réduire la latence des shifts
HEX0: la valeur originaleHEX1: une table où la valeur est déjà décalée de 4 bits vers la gauche
- Le tout est structuré en une implémentation
decode_four_hex_digits()avec un unroll explicite de la boucle - Grâce à cette optimisation, les performances de parsing de War and Peace en russe sous forme JSON-encoded passent de 284 MB/s à 344 MB/s, soit +21 %
Goulot d’étranglement de l’encodage UTF-8 et génération manuelle
- Après le décodage des escapes Unicode, le goulot d’étranglement s’est déplacé vers l’encodage UTF-8
- UTF-8 encode les codepoints sur 1 à 4 octets selon leur longueur
- 1 octet :
0xxxxxxx - 2 octets :
110xxxxx 10xxxxxx - 3 octets :
1110xxxx 10xxxxxx 10xxxxxx - 4 octets :
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
- 1 octet :
char::encode_utf8, dans la bibliothèque standard Rust, écrit dans un buffer&mut [u8], qui doit donc déjà contenir des valeursu8valides- Il ne peut pas écrire directement dans un buffer non initialisé
- Comme UTF-8 est un encodage de longueur variable, LLVM a du mal à supprimer la zeroization inutile
serde_jsonutilisait une approche du typescratch.extend_from_slice(c.encode_utf8(&mut [0u8; 4]).as_bytes())- En théorie, l’utilisation d’un buffer local
[0u8; 4]pourrait aider l’analyse d’alias - En pratique, à cause de la copie de longueur variable de 1 à 4 octets, LLVM génère un appel à
memcpy
- En théorie, l’utilisation d’un buffer local
- Pour éviter les appels à
memsetetmemcpy, UTF-8 est généré directement- L’algorithme lui-même est simple, mais nécessite
unsafe - Avec quelques petits changements supplémentaires, le benchmark War and Peace s’améliore encore de 344 MB/s à 374 MB/s, soit +9 %
- L’algorithme lui-même est simple, mais nécessite
Résultat final
- Dans les benchmarks JSON riches en chaînes, les performances de
serde_jsonprogressent respectivement de 10 %, 23 % et 32 % - Comme beaucoup de données JSON contiennent de nombreuses chaînes, cette optimisation peut avoir un impact sur une large part du code Rust utilisant
serde_json - Les améliorations ont été obtenues en éliminant successivement différents goulots d’étranglement : calcul de la position des erreurs, recherche d’escapes dans les chaînes, décodage des escapes Unicode et encodage UTF-8
1 commentaires
Avis sur Hacker News
Les astuces UTF-8 me mettent assez mal à l’aise, tant j’ai vu d’attaques jouant sur la confusion des parseurs
J’utilise plutôt serde pour son exactitude que pour sa vitesse, donc j’espère qu’ils ont fuzzé ça jusqu’au bout avec plein de chaînes UTF-8 invalides
Ça ne veut pas dire qu’il n’y a pas de bugs, mais l’état interne du parseur devrait rester limité, et des tests exhaustifs semblent possibles
C’est sympa de voir serde évoluer aussi vite
Je regardais justement simdutf8 et j’ai découvert que la PR pour le parsing UTF-8 avec prise en charge SIMD traîne depuis presque 5 ans
https://github.com/rust-lang/rust/issues/68455
J’ai pris plaisir à lire ce blog, il dégage une forte impression de jart
Le passage « il va falloir réinventer la roue, mais quand on y pense c’est plutôt chouette » : je ne sais pas si c’est sincère ou ironique
En le lisant, j’ai commencé à rire en me moquant de l’auteur, mais le reste de la page semble assez chargé en autodérision
« Enseigner comment penser est aussi important qu’enseigner à coder, mais cela se fait rarement »
J’y sens l’arrogance de quelqu’un qui considère que les autres ne pensent pas
Elle ne suppose pas que les autres ne pensent pas ; c’est plutôt le constat que la plupart des articles de blog et des guides montrent seulement le résultat final, sans forcément montrer les étapes pour y arriver
Enseigner comment penser signifie qu’il faut enquêter et qu’on ne peut pas sauter cette étape ; et le fait d’avoir enquêté ne vous décharge pas non plus de la responsabilité de tirer vos propres conclusions
Il est facile de sauter l’une ou l’autre de ces deux étapes, mais c’est une mauvaise méthode
serde_json occupe 3 Go de dépendances si l’on fait une build debug et une build release
Utilisez serde dans quelques projets actifs et vous n’aurez plus d’espace disque
Je ne vois pas pourquoi parser du JSON nécessite 3 Go de dépendances ; je suis pour la réutilisation de code, mais les dépendances côté JSON de serde ont l’air assez brouillonnes
Qu’un exploit apparaisse dans l’une de ces dépendances et la moitié de l’écosystème Rust devient vulnérable
Rust devrait intégrer JSON en natif
indexmap = { version = "2.2.3", optional = true }itoa = "1.0"memchr = { version = "2", default-features = false }ryu = "1.0"serde = { version = "1.0.194", default-features = false }Quand tu parles de 3 Go de dépendances, il est très probable que ce ne soit pas ce que tu as réellement mesuré
Je ne peux pas en être sûr, puisque tu l’énonces comme un fait sans preuve, mais je suppose que tu utilises beaucoup
#[derive(Serialize, Deserialize)], ce qui génère une grosse quantité de code, puis que tu mesures la taille totale du répertoiretargetUne build simple donne, comme l’ont montré d’autres commentaires, quelque chose de l’ordre de quelques dizaines de Mo
Ce ne serait qu’une manière d’introduire de la bloat, et la bibliothèque standard n’a pas d’avantage particulier par rapport aux autres crates, hormis ses garanties de stabilité
Au final, cela ne ferait que lier le cycle de publication de la bibliothèque à celui du compilateur
En pratique,
rustc-serializeétait autrefois presque intégré, et Rust a déjà essayé cette voieEt serde_json, dans sa configuration par défaut, n’est pas gros
Ni serde_json ni serde ne sont gros, et tous deux maintiennent une MSRV très basse, que les autres crates prennent rarement bien en charge, ce qui les empêche de toute façon d’avoir beaucoup de dépendances
L’arbre de dépendances d’un projet Rust d’une certaine taille devient vite assez effrayant, auditer toutes ces dépendances est irréaliste, et le niveau de confiance dans beaucoup d’entre elles est faible
Après avoir travaillé avec Rust pendant quelques années, je ne pense pas y revenir avant que l’écosystème soit beaucoup plus mature
Cette maturité viendra avec une adoption massive par les grandes entreprises ; sinon, je ne l’utiliserais que pour des projets no-std, no-deps, destinés à remplacer du C pur
Cela dit, une fois Zig stabilisé, il pourrait aussi déloger Rust pour cet usage
C’est tellement grotesquement volumineux que je suppose que c’est tout simplement un bug
Quoi que vous compiliez, le dossier
targetaccumule des gigaoctets, mais ce n’est pas représentatif de l’artefact final une fois les informations de debug supprimées, ou au moins avec des réglages moins détailléstargetfait littéralement toujours au moins quelques Go