- Dans 1BRC, le principal goulot d’étranglement était le parsing extrêmement rapide d’un milliard de valeurs de température CSV, et le code SWAR de merykitty par Quân Anh Mai s’est distingué en convertissant les températures en entiers via des opérations ALU fixes, sans
if
- Ce code utilise une approche SWAR (SIMD Within A Register) qui traite d’un seul coup les 8 octets contenus dans un
long, en manipulant plusieurs caractères en parallèle dans un registre CPU classique
- Le flux de traitement enchaîne la détection du signe moins, la suppression du signe, la détection de la position du point décimal, l’alignement au format
XY.Z, la conversion des chiffres ASCII, la multiplication magique, puis l’application du signe
- Le format d’entrée se limite à quatre variantes :
-XX.X, -X.X, X.X, XX.X, et les octets sont décalés selon la position du point décimal afin d’obtenir la même disposition de bits malgré les longueurs différentes
- Au lieu de branches et de boucles, l’implémentation exploite à fond les propriétés des codes ASCII, du complément à deux, des masques de bits et des propriétés décalage-addition de la multiplication pour obtenir un parsing très performant
Le parsing des températures, goulot d’étranglement de 1BRC
- Dans le One Billion Row Challenge (1BRC), parser des valeurs de température d’un fichier CSV le plus vite possible est devenu le principal goulot d’étranglement
- Rien qu’avec les optimisations précédentes, un code Java parallèle idiomatique était déjà passé de 71 secondes à 1,7 seconde
- Le format des températures est simple, mais parser un milliard de valeurs en moins d’une seconde fait fortement s’accumuler le moindre coût
- Les formats possibles sont
-XX.X, -X.X, X.X, XX.X
- Les premiers participants utilisaient
Double.parseDouble(), puis des parseurs personnalisés sans boucle sont apparus
- Une partie de la solution de @merykitty, signée Quân Anh Mai, traitait le problème avec une seule lecture de fichier et sans
if, au point de devenir un élément quasi standard des meilleures solutions 1BRC
- Le vainqueur, Thomas Wuerthinger, cite d’ailleurs Quân Anh parmi les membres de l’équipe ayant contribué à sa solution
Ce que fait le code de merykitty
- Le code reçoit un
long contenant 8 octets d’entrée CSV et renvoie une valeur de température entière égale à dix fois la température réelle
- L’entrée provient directement du fichier CSV
mmap via une lecture de mémoire native, ce point étant traité séparément
- Le calcul est constitué de 18 opérations ALU dans un ordre fixe
- décalages de bits, AND, NOT, XOR
- additions, soustractions, multiplications
Long.numberOfTrailingZeros()
numberOfTrailingZeros() utilise une instruction CPU spécialisée via un intrinsic du compilateur JDK
- Comme il ne s’agit pas d’instructions SIMD dédiées mais de manipuler plusieurs octets avec des registres et instructions CPU ordinaires, l’approche relève bien du SWAR
- Le code d’exemple a été légèrement adapté pour être plus lisible ; l’original se trouve dans CalculateAverage_merykitty.java
Vue d’ensemble du traitement
- Le code parse la température selon la séquence suivante
- vérifier si le premier caractère est
- afin de détecter une valeur négative
- si un signe est présent, mettre cet octet à 0
- trouver la position du point décimal
.
- déplacer les bits à l’intérieur du
long pour que les chiffres correspondent toujours au modèle XY.Z
- convertir les caractères ASCII en valeurs numériques réelles
- multiplier chaque chiffre par son poids
1x, 10x, 100x puis additionner
- appliquer le signe à la fin
- En apparence, c’est un problème de parsing de haut niveau, mais chaque étape est implémentée uniquement avec des opérations ALU
Étape 1 : détection du signe moins
- La détection du signe commence avec le code suivant
long negatedInput = ~inputData;
long broadcastSign = (negatedInput << 59) >> 63;
- En changeant l’ordre dans l’explication, on peut le voir comme
( ~(inputData << 59) ) >> 63
- En ASCII, le signe moins
- a son bit 4 à 0, alors que ce bit vaut 1 pour les caractères numériques
- En décalant l’entrée de 59 bits vers la gauche, le bit discriminant du premier caractère est déplacé sur le bit de poids fort
- Après inversion avec NOT, puis un décalage arithmétique à droite de 63 bits, le bit de poids fort est propagé à tout le
long
- Le résultat,
broadcastSign, vaut alors tous les bits à 1 si un moins est présent, et tous les bits à 0 sinon
Étape 2 : suppression du caractère de signe
- Comme l’information sur le signe est désormais stockée dans
broadcastSign, on peut retirer le caractère de signe des données d’entrée
long maskToRemoveSign = ~(broadcastSign & 0xFF);
long withSignRemoved = inputData & maskToRemoveSign;
- Si
broadcastSign vaut tout à 1, alors broadcastSign & 0xFF laisse uniquement les 8 bits de poids faible à 1
- En appliquant NOT, on obtient un masque dont seuls les 8 bits de poids faible valent 0
- Le AND avec
inputData supprime donc le - dans l’octet de poids faible
- S’il n’y a pas de moins,
broadcastSign vaut 0, le masque devient tout à 1 et les octets numériques sont conservés
Étape 3 : trouver la position du point décimal
- La position du point décimal est calculée par le code suivant
int dotPos = Long.numberOfTrailingZeros(negatedInput & DOT_DETECTOR);
- Le caractère
. a lui aussi la propriété d’avoir son bit 4 à 0, comme le signe moins
- Pour ne tester que le bit 4 aux positions possibles du point décimal, on utilise le masque
DOT_DETECTOR = 0x10101000
- Dans
negatedInput, qui est l’inversion de l’entrée d’origine, ce bit devient 1 à l’endroit du point décimal
Long.numberOfTrailingZeros() renvoie la position de ce bit à 1
- Dans l’exemple
-10.8, le point décimal se trouve à la position de bit 28, donc dotPos = 28
Étape 4 : alignement sur un modèle fixe
- À partir de la position du point décimal, l’entrée est décalée vers la gauche pour toujours correspondre au même modèle
long alignedToTemplate = withSignRemoved << (28 - dotPos);
- Le modèle visé est le suivant
0 0 0 Z . Y X 0
- Ici,
X représente les dizaines, Y les unités, et Z le premier chiffre après la virgule
- Le
0 ne désigne pas le caractère ASCII "0", mais un octet de valeur 0
- Après suppression du signe, l’entrée peut se présenter dans l’une des quatre dispositions suivantes
0 0 0 Z . Y X 0
0 0 0 0 Z . Y 0
0 0 0 0 Z . Y X
0 0 0 0 0 Z . Y
- Pour
-10.8, on a déjà dotPos = 28, donc le décalage vaut 0
- Pour
-7.7, la position du point est 20 bits, donc on décale de 8 bits, soit un octet, vers la gauche afin de placer 0 à l’emplacement de X
Étape 5 : convertir les chiffres ASCII en valeurs
- Une fois alignés, il ne reste plus qu’à extraire les valeurs numériques des caractères ASCII
long digits = alignedToTemplate & ASCII_TO_DIGIT_MASK;
- Les chiffres ASCII
0 à 9 vont de 0x30 à 0x39 en hexadécimal
- En ne gardant que les 4 bits de poids faible, le code caractère devient la vraie valeur numérique
- On applique un masque avec
F uniquement aux positions des chiffres dans le modèle
0 0 0 Z . Y X 0
000000F000F0F00
- Pour
-10.8, après application du masque, il ne reste que les valeurs correspondant à Z=8, Y=0, X=1
Étape 6 : sommer les poids des chiffres avec une multiplication magique
- La valeur absolue finale doit être calculée comme
100 * X + 10 * Y + Z
- En exploitant le fait qu’une multiplication peut se décomposer en décalages et additions, le calcul des poids de plusieurs chiffres est effectué d’un seul coup avec une unique multiplication
- Si l’on commence par considérer
X + Y + Z, on peut additionner des versions décalées de digits aux positions 0, 16 et 24 bits afin de regrouper les sommes dans certaines plages de bits
- Cette combinaison décalage-addition peut se réécrire comme la multiplication suivante
0x1 + 0x10000 + 0x1000000
- En pratique, comme les poids diffèrent selon les chiffres,
MAGIC_MULTIPLIER est construit ainsi
MAGIC_MULTIPLIER = 0x1 + 10 * 0x10000 + 100 * 0x1000000;
absValue = ((digits * MAGIC_MULTIPLIER) >>> 32) & 0x3FF;
0x3FF est un masque destiné à extraire un résultat de largeur 10 bits
100 * X peut s’étendre jusqu’à 10 bits et chevaucher des bits adjacents, mais les deux bits de droite de Y * 100 sont toujours à 0, ce qui laisse l’espace nécessaire
- merykitty a laissé à cet endroit le commentaire
// That was close :)
Étape 7 : appliquer le signe sans branchement
- À ce stade, on dispose de la valeur absolue
absValue et de l’information de signe dans broadcastSign
broadcastSign se comporte comme 0 pour un nombre positif et comme -1 pour un nombre négatif
- En complément à deux, un nombre négatif s’écrit comme suit
-n = NOT(n) + 1
- XOR peut servir de NOT conditionnel
n XOR -1 donne NOT(n)
n XOR 0 donne n
- Le
+1 optionnel est géré par -broadcastSign
temperature = (absValue ^ broadcastSign) - broadcastSign;
- Au final, sans aucun
if, les valeurs positives restent inchangées et les valeurs négatives sont converties en entier négatif en complément à deux
Bonus : calcul de la position de début de la ligne CSV suivante
- Dans la solution 1BRC complète, il faut aussi calculer à faible coût la position de début de la ligne CSV suivante
- Après le point décimal viennent toujours un chiffre décimal puis un saut de ligne, donc la position du point suffit à retrouver le début de la ligne suivante
- Comme
dotPos est une position en bits, on effectue un décalage de 3 bits vers la droite pour diviser par 8
nextLineStart = (dotPos >>> 3) + 3;
- Le
+3 sert à viser le premier octet après le point, le chiffre décimal et le saut de ligne
Conclusion
- Le code SWAR de merykitty parse les quatre formats de chaîne de température à l’aide d’opérations binaires fixes uniquement
- Les idées centrales sont les propriétés bit à bit du code ASCII, l’alignement basé sur la position du point décimal, l’extraction des chiffres par masque, la somme des poids via une multiplication, et l’application du signe via le complément à deux
- Découpé étape par étape, le fonctionnement devient lisible, mais le fait d’avoir assemblé tout cela en quelques jours pour un challenge en ligne reste particulièrement impressionnant
1 commentaires
Commentaires Hacker News
J’avais remarqué il y a plus de deux ans que byte array view var handle se prêtait plutôt bien à la création de routines SWAR efficaces en Java/Scala
On trouve aussi ici de nombreux exemples d’usage de SWAR, comme le parsing de chaînes Base16/64,
java.time.*ou le parsing direct de valeurs numériques depuis des tableaux d’octets : https://github.com/plokhotnyuk/jsoniter-scala/blob/master/js...Une grande partie de la valeur d’un parseur éprouvé en production réside dans la détection et la récupération d’erreurs efficaces
Et je me demande aussi combien de travail serait nécessaire pour la détecter de façon à renvoyer une valeur d’erreur sentinelle, comme dans le style de code actuel
Pas assez intéressant pour que je le fasse moi-même ;-)
MULest une méthode assez connueVoir l’article de Lemire : https://lemire.me/blog/2023/11/28/parsing-8-bit-integers-qui...
Article : https://arxiv.org/abs/1902.08318
Github : https://github.com/simdjson/simdjson
En plus, le 1BRC officiel précise que les résultats sont évalués depuis un RAM disk afin d’exclure complètement la vitesse d’E/S : https://github.com/gunnarmorling/1brc?tab=readme-ov-file#eva...
“Programs are run from a RAM disk (i.o. the IO overhead for loading the file from disk is not relevant)”
D’après ma compréhension limitée, on charge séquentiellement un gros fichier texte en L1 et chaque valeur est lue une fois. Sur la plupart des processeurs, on peut effectuer deux de ces lectures par cycle. La partie lente serait l’acheminement de la RAM vers le L1, mais les lectures séquentielles sont assez rapides
Ensuite, on traite chaque lecture. À vue de nez, cela semble représenter environ 4 cycles dans une version optimisée. Puis il faut écrire le résultat quelque part, ce qui nécessitera probablement une ou deux lectures aléatoires au préalable. Est-ce cela que vous considérez comme un goulot d’étranglement d’E/S ?
Je ne dis pas qu’il est évident que ce soit limité par le CPU, mais il ne me semble pas évident non plus que ce ne soit pas le cas
Édit : je n’avais pas envisagé que vous vouliez peut-être dire les “E/S disque”. Comme d’autres l’ont dit, ici ce n’est en pratique pas un facteur
Autrement dit, toutes les données se retrouvent en RAM, plus précisément dans le page cache
Si je me souviens bien, la gestion des débordements était délicate. J’aime vraiment beaucoup cet article
Il existe encore des gens qui savent réellement programmer le CPU et comprennent ce qu’ils font
Le vrai mystère, c’est plutôt que la majorité de ceux qui se disent programmeurs semblent manquer de compréhension profonde, et ne semblent même pas savoir à quel point ce manque est grave
Le fait que cela fonctionne bien en pratique se voit dans ce qui semble être la solution C# la plus rapide parmi les 1BRC publiés jusqu’ici : https://hotforknowledge.com/2024/01/13/1brc-in-dotnet-among-...
La question est de savoir si le coût de construction du vecteur initial et d’extraction du résultat n’est pas excessif
En revanche, je doute que HotSpot puisse le faire tout seul, et il y a aussi le fait que la plupart des soumissions 1BRC étaient exécutées avec Graal pour réduire le surcoût de démarrage
Le SSE2 de base n’a pas de multiplication 32 bits ou 64 bits, donc la multiplication 32×32→64 bits pose problème, mais SSE4.1 ajoute justement
pmuldq, qui fait exactement ce qu’il faut. Comme le résultat est sur 64 bits, il faut toutefois faire cette opération deux fois pour traiter un vecteur complet d’entiers 32 bitsDe plus, le champ de température a une longueur variable, donc même avec un stockage en colonnes il est possible qu’il n’y ait pas de gain
En revanche, SSE a été appliqué avec succès à la recherche du délimiteur entre le nom et la température