Concevoir des algorithmes SIMD depuis zéro
(mcyoung.xyz)- Le codec base64 vb64 créé avec
std::simdde Rust devient un code SIMD rapide et portable non pas en vectorisant tel quel une boucle procédurale, mais en repensant la disposition des données et le flux d’opérations comme un circuit - L’optimisation clé consiste à réduire les stalls dus aux branchements et aux accès mémoire, en construisant une structure branchless qui exécute les mêmes opérations indépendamment de l’entrée grâce à des comparaisons, masques,
selectetshuffle - Pour le décodage base64, on construit un perfect hash qui convertit les caractères ASCII en sextets à l’aide de
byte >> 4et d’une correction pour/, puis on trouve l’offset avec une table de lookup dans le vecteur SIMD et un shuffle - Lors du compactage de quatre sextets de 6 bits en trois octets, on élargit les lanes en
u16avant de faire le shift, puis on sépare les octets low/high et on combine les fragments d’octets de lanes adjacentes avecrotate_lanes_leftet OR - Dans les benchmarks, après la combinaison
-Zbuild-std,-Ctarget-cpu=native,N = 32et l’optimisation du chargement du remainder, les performances atteignent environ 2x celles de l’implémentation base64 de référence sur crates.io sur presque toute la plage
Contexte physique qui rend le SIMD nécessaire
- L’amélioration des performances informatiques est liée directement non seulement à l’informatique théorique, mais aussi aux contraintes physiques
- La loi de Moore semble toujours tenir en 2023, mais au cours des 15 dernières années, l’effet du Dennard scaling s’est effondré, si bien qu’une plus forte densité de transistors entraîne une hausse de la densité de consommation électrique
- Après qu’il est devenu difficile d’augmenter continuellement la fréquence d’horloge, la principale voie d’amélioration des performances s’est déplacée au début des années 2000 vers l’utilisation d’un plus grand nombre de cœurs
- Le multithreading exige une coopération entre les cœurs, ce qui introduit des coûts de synchronisation, et les flux de contrôle comme les sauts, les appels virtuels et la synchronisation provoquent des stalls
- Il existe deux causes principales de stall
- Branchements : flux de contrôle comme
if, boucles, appels de fonction, retours de fonction ouswitchen C - Opérations mémoire : load/store, en particulier les accès peu favorables au cache
- Branchements : flux de contrôle comme
Code procédural et parallélisme au niveau des instructions
- Les cœurs CPU modernes n’exécutent pas le code ligne par ligne, mais émettent simultanément les opérations qui ne dépendent pas les unes des autres
- Des opérations indépendantes comme
a = x + yetb = x ^ ypeuvent utiliser en même temps les circuits d’addition et de xor - Cette approche correspond au parallélisme au niveau des instructions, et les dépendances qui l’entravent sont appelées data hazards
- Plus le CPU parvient à saturer ses functional units, plus il traite d’opérations par unité de temps
- Les branchements provoquent des stalls parce qu’il faut attendre le calcul de la condition avant d’aller chercher l’instruction suivante, et les opérations mémoire parce que les données doivent physiquement parvenir jusqu’au CPU
- Comme les GPU traitent les images sous forme de pixels vectoriels et effectuent beaucoup d’opérations à forte localité, ils sont plus proches de machines SIMD conçues pour les traitements par lots et un flux de contrôle limité
- SIMD signifie single instruction, multiple data : une seule instruction exécute des opérations en parallèle sur plusieurs lanes de données
Raisonner à l’échelle des lanes
- SIMD et vector sont souvent employés dans le même sens, et l’unité de base d’une instruction SIMD est un vector, c’est-à-dire un tableau de nombres de taille fixe
- Chaque composant d’un vector est appelé une lane
- Les vecteurs SIMD doivent tenir dans des registres, ils sont donc généralement petits
- Dans l’environnement d’exemple, la largeur vectorielle maximale est de 256 bits
- Cela correspond à 32 octets de
u8x32ou à 4 doubles def64x8
- Même un petit vecteur peut améliorer la latence s’il réduit d’un facteur 4 la charge nécessaire pour saturer le pipeline
Divide and conquer vu à travers popcnt
- L’opération vectorielle la plus simple est le bitwise and/or/xor
- Même un entier ordinaire peut être vu, du point de vue des opérations bitwise, comme un vecteur de lanes de 1 bit
i32équivaut dans cette perspective ài1x32
popcntest l’opération qui compte le nombre de bits à 1 dans un entier, et si l’on considèrei32commei1x32, c’est une opération de réduction- Une implémentation naïve qui extrait les 32 bits dans un tableau pour les additionner peut produire un mauvais code
- Une meilleure méthode consiste à additionner d’abord les paires de bits adjacents, puis les paires de paires, en augmentant progressivement la largeur des lanes
- séparation des bits pairs/impairs avec les masques
0x55555555et0xaaaaaaaa - alignement des lanes par shift, puis addition
- répétition ensuite à l’échelle de 2 bits, 4 bits, 8 bits et 16 bits
- séparation des bits pairs/impairs avec les masques
- Cette implémentation n’est pas optimisée en instruction
popcnt, mais elle produit un code compact et rapide sur les systèmes qui ne disposent pas de cette instruction - Elle s’applique aussi à
u64en ajoutant simplement une étape de réduction supplémentaire, sans nécessiter une additionu64globale - Cette approche de divide and conquer est un motif central de la programmation SIMD
Principaux outils des jeux d’instructions SIMD
- Les vrais vecteurs SIMD offrent une sémantique plus complexe que les scalaires, et les fonctions destinées à remplacer les flux de contrôle lents sont particulièrement importantes
- Les instructions disponibles dépendent fortement de l’architecture
- De nombreux cœurs hautes performances x86 implémentent AVX2
- AVX2 fournit des vecteurs
ymmde 256 bits - Le registre lui-même n’a pas de nombre de lanes ; c’est l’instruction qui détermine comment les interpréter
- Par exemple,
vpaddbinterprèteymmcommei8x32
- Les opérations généralement disponibles sont les suivantes
- opérations bitwise : la largeur de lane est implicitement toujours de 1 bit
- arithmétique lane par lane : addition, soustraction, multiplication, division, shift entier, min/max, etc.
- comparaison lane par lane : produit un vecteur de masque du type
m[i] = a[i] < b[i] - select : choisit lane par lane une valeur entre deux vecteurs à l’aide d’un masque
- shuffle/swizzle : considère un vecteur comme une table de lookup et réorganise les lanes avec un vecteur d’index
- Les valeurs true/false d’un vecteur de masque utilisent généralement des motifs de bits all-ones ou all-zeros
- La comparaison et
selectsont des outils essentiels pour permettre au code SIMD de rester branchless - Un code branchless effectue les mêmes opérations quelle que soit l’entrée, puis élimine les résultats inutiles grâce à des propriétés comme
x * 0 = 0oua ^ b ^ a = b
Aligner les données avec shuffle
shuffleest un outil essentiel en SIMD pour mettre les données à la « bonne position »- Broadcast ou splat crée un vecteur dont toutes les lanes contiennent le même scalaire, ce qui peut s’exprimer par un shuffle d’index
[0, 0, ...] - Interleave ou zip/pack alterne les lanes de deux vecteurs
aetbc = [a[0], b[0], a[1], b[1], ...]- peut être implémenté avec
shuffle2
- Deinterleave ou unzip/unpack est l’opération inverse de l’interleave
- Rotate fait tourner les lanes sous la forme
b[i] = a[(i + j) % n], ce qui est aussi un shuffle - En programmation SIMD, il est fréquent de réinterpréter et de réagencer des blocs de données plus grands que des entiers en petits blocs de tailles variées
intrinsics, target feature, portable SIMD
- Les opérations disponibles en SIMD varient selon l’architecture et les extensions du jeu d’instructions
- x86 peut proposer des opérations absentes sur ARM, et même chez un même fournisseur il existe des extensions réservées aux puces serveur haut de gamme, comme Intel AVX-512
- Les toolchains généralisent ces extensions sous la forme de target features
- Sur Linux,
lscpuaffiche les features reconnues par le CPU - LLVM sélectionne les instructions différemment selon les features activées
- Il faut
+avx2pour que LLVM génère du code utilisantymm
- Sur Linux,
-march=nativeou-Ctarget-cpu=nativepeuvent produire un bon code pour la machine de build, mais la portabilité vers d’autres processeurs peut en pâtir- La détection des features à l’exécution consiste à vérifier ce que le CPU prend en charge afin de décider quelle version d’une fonction appeler ; on l’utilise dans du code distribué sur des appareils variés, comme les bibliothèques de chiffrement
- Le code SIMD en C++ utilise généralement des intrinsics comme
_mm256_cvtps_epu32- Elles représentent des opérations de bas niveau propres à un jeu d’instructions donné
- Elles ne se traduisent pas forcément par une seule instruction
- Le compilateur peut fusionner, éliminer les doublons et optimiser le choix des instructions
- Si l’on finit par réécrire un code similaire pour plusieurs jeux d’instructions, l’avantage de maintenance par rapport à l’assembly peut devenir limité
- Les bibliothèques de portable SIMD adoptent une approche où une partie de la sélection d’instructions est gérée au niveau de la bibliothèque, le reste étant laissé au compilateur
- L’implémentation de
vb64est une expérience destinée à vérifier si le portable SIMD de Rust génère un code compétitif
transformer le décodage base64 en SIMD
- base64 est une méthode d’encodage de données binaires arbitraires en ASCII
- La séquence d’octets d’entrée est vue comme un vecteur de bits, puis divisée en morceaux de 6 bits appelés sextets
- Les valeurs des sextets sont mappées vers les caractères suivants
0..25→'A'..'Z'26..51→'a'..'z'52..61→'0'..'9'62→+63→/
- Il existe plusieurs variantes de base64, mais l’essentiel de la complexité est commun
- Il faut garder deux points en tête
- base64 est un format où les bits à l’intérieur d’un octet sont en big endian
- La longueur de l’entrée peut ne pas être divisible par 4 ; en principe on utilise un padding
=pour compléter jusqu’à un multiple de 4, mais on peut aussi traiter des messages dont le padding est incorrect
- La longueur décodée se calcule comme
input / 4 * 3, à laquelle on ajoute une longueur résiduelle seloninput % 4
refactorisation de base vers du branchless
- Un décodeur base64 simple contient plusieurs branches
- une boucle qui parcourt l’entrée par chunks
- une boucle sur les octets à l’intérieur de chaque chunk
- un
matchpar caractère ASCII - un
return Erren cas d’erreur - un
matchdansdecoded_len Vec::extend_from_sliceet la possibilité d’un appel à l’allocator
- La consigne d’optimisation consiste à supprimer toutes les branches
- Le
matchdedecoded_lenmappe les valeurs0, 1, 2, 3deinput % 4vers0, 1, 1, 2 - Le remplacer par
mod4 - mod4 / 2donne une version branchless - LLVM peut certes replier le
matchd’origine en table de saut, mais dans cette zone des accès mémoire inutiles dégradent les performances
isoler la boucle la plus chaude
- La force du SIMD est de traiter beaucoup de données à la fois afin de dérouler fortement la boucle et de se rapprocher d’un fonctionnement sans branchement
- L’objectif de la hot loop est de lire jusqu’à 4 octets, de produire jusqu’à 3 octets décodés, et d’indiquer aussi s’il y a une erreur de syntaxe
- Trois faits peuvent être exploités
- La longueur de sortie peut être calculée avec un
decoded_len()branchless - Une base64 invalide peut être considérée comme un chemin très rare, et si l’on a besoin de la position d’erreur on peut refaire un scan après coup
- En base64,
Avaut 0, donc compléter un chunk tronqué avec desAne change pas la valeur
- La longueur de sortie peut être calculée avec un
decode_hot()est isolée sous la forme d’une fonction qui traite quatre octets d’entrée et renvoie le résultat décodé avec un booléen de succès- Renvoyer séparément un booléen plutôt qu’utiliser
Option<[u8; 3]>facilite ensuite la suppression de la brancheif !ok - Dans la version SIMD, l’entrée est un
Simd<u8, 4>, et la sortie reste aussi unSimd<u8, 4>pour correspondre à un nombre de lanes puissance de deux- La sortie réellement nécessaire est de 3 octets
- La dernière lane n’est pas utilisée
comment convertir l’ASCII en sextets
- L’essentiel du
matchqui convertit un caractère ASCII en sextet peut s’exprimer sous la formebyte - C'A'..'Z'→byte - 'A''a'..'z'→byte - 'a' + 26'0'..'9'→byte - '0' + 52'+'→byte - '+' + 62'/'→byte - '/' + 63
- Il suffit de construire un vecteur d’offsets par lane puis d’effectuer
ascii - offsets - La première approche est compare-and-select
- on crée des masques pour
A-Z,a-z,0-9,+,/ - une lane pour laquelle aucun masque n’est sélectionné est considérée comme invalide
- on splat l’offset correspondant à chaque masque puis on combine le tout avec des OR
- on crée des masques pour
- Cette approche est élégante et peut produire un code compétitif, mais elle nécessite 8 comparaisons au total et le nombre de valeurs vivantes peut créer de la pression sur les registres
table de hachage SIMD et perfect hash
- Les plages d’octets de
A-Z,a-z,0-9sont respectivement0x41..0x5b,0x61..0x7b,0x30..0x3a, et leurs high nibbles diffèrent +et/valent0x2bet0x2f, doncbyte >> 4suffit à les distinguer dans la plupart des cas- Dans le cas de
/, soustraire 1 donne un perfect hash sur les plages visées - Le mapping de
(byte >> 4) - (byte == '/')est le suivantA-Z→ 4 ou 5a-z→ 6 ou 70-9→ 3+→ 2/→ 1
- Cette valeur est petite, donc on peut placer une table de lookup d’offsets dans un vecteur SIMD et faire le lookup avec un shuffle
- Cette idée de perfect hash a été proposée par un utilisateur anonyme dans une GitHub issue
Simd::swizzle_dyn()impose que le tableau d’index et la longueur de la table de lookup aient la même taille- Avec l’approche perfect hash, la validation n’est plus obtenue comme effet de bord pendant le calcul des sextets ; on utilise donc un exact bloom filter pour vérifier la validité des octets, comme dans la même GitHub issue
- Un exemple d’implémentation est disponible dans le fichier simd.rs de vb64
empaqueter quatre sextets en trois octets
- L’étape qui consiste à fusionner quatre sextets de 6 bits en trois octets est plus délicate
- En fixant un sextet d’entrée particulier à all-ones puis en observant où ses bits se déplacent dans la sortie, on peut suivre la logique d’agencement
- Un simple shuffle au niveau des octets ne suffit pas
- la cible du déplacement est un fragment d’octet
- de simples décalages ne suffisent pas non plus
- les bits décalés au-delà de la limite doivent se propager vers la lane voisine
- La solution consiste à élargir les lanes
- On caste
sextetsen vecteur deu16, puis on applique des décalages lane par laneinput[0]est décalé de 2 bitsinput[1]est décalé de 4 bitsinput[2]est décalé de 6 bitsinput[3]est ajusté avec un décalage de 8 bits
- On sépare ensuite le résultat des décalages en vecteurs d’octets bas et d’octets hauts
- Avec
hi.rotate_lanes_left::<1>(), on aligne la partie en octets hauts sur la lane voisine, puis on combine aveclo | hi_rotated - Cette méthode exploite fortement les primitives matérielles, ce qui donne un code compact et efficace
Extension du nombre de lanes et suppression des garbage lanes
Simd<u8, 4>est plus petit que le registre vectoriel x86 minimal de 128 bits, doncdecode_hot()a été rendu générique sur le nombre de lanesN- La contrainte
LaneCount<N>: SupportedLaneCountgarantit un petit nombre de lanes en puissance de deux - La table de lookup et la table de décalage construisent des vecteurs à motif répété avec le helper
tiled() - Avec
N = 4, il suffisait d’ignorer la valeur garbage de la dernière lane, mais quandNaugmente, des garbage values se mélangent à chaque quatrième lane - Un shuffle est utilisé pour les supprimer
- La relation voulue est
shuffled[i] = output[i + i / 3] - On saute chaque quatrième index pour supprimer la garbage lane
- La partie qui déborde correspond au quart supérieur du vecteur de sortie final, donc elle est ignorée
- La relation voulue est
- De cette façon,
decode_hot::<32>()peut décoder en parallèle 32 octets base64
Optimisation de la boucle externe
decode()est lui aussi rendu générique sur le nombre interne de lanesN- Les coûts restants sont les suivants
- la branche de comparaison de longueur dans
for chunks in ... - le
memcpyà longueur variable de[T]::copy_from_slice - la branche
okà chaque itération de boucle - l’appel potentiel à l’allocateur dans
Vec::extend_from_sliceet un autrememcpy
- la branche de comparaison de longueur dans
- Comme la longueur de sortie est connue, l’espace est préalloué avec
out.reserve(final_len + N / 4) - Un espace de slop est ajouté en plus afin d’effectuer un store SIMD complet au lieu d’un memcpy à longueur variable
- Chaque itération écrit le vecteur SIMD complet, et l’écriture suivante se déplace de
3/4 * Npour écraser les précédents garbage bytes - Les derniers garbage bytes ne sont pas inclus dans le
Vec::set_len()final, ils sont donc traités comme supprimés - Même si
if !okprovoque un retour anticipé,set_len()n’a pas validé l’écriture, doncoutreste inchangé
Reporter la gestion des erreurs hors de la hot loop
- Au lieu de retourner à chaque itération avec
if !ok, l’erreur est accumulée avecerror |= !ok - L’état d’erreur n’est vérifié qu’une seule fois, juste avant le
set_len()final - En partant du principe que la plupart des blobs base64 sont valides, le chemin d’erreur est déplacé hors de la hot loop
- Même en cas d’erreur de syntaxe, les opérations SIMD suivantes ne se comportent pas arbitrairement de travers, donc les garbage writes ne sont pas validées et disparaissent
- Un appel ultérieur comme
Vec::push()peut écraser cette même zone du buffer
Unroll and jam et gestion du remainder
- Unroll and jam est appliqué pour réduire le
memcpyà longueur variable decopy_from_slice - La boucle est divisée en deux parties
- hot vectorized loop : traite toujours une entrée de longueur
N - cold remainder part : traite au plus une fois une entrée
i < N
- hot vectorized loop : traite toujours une entrée de longueur
Iterator::chunks_exact()de Rust est utilisé pour implémenter un unroll-and-jam écrit à la main- Dans la hot loop,
Simd::from_slice()est appelé pour effectuer un unique chargement de taille vectorielle - Les bounds checks prennent alors une forme que le compilateur élimine plus facilement
Benchmarks et optimisation du chargement manuel
- Les benchmarks décodent des messages de longueur 0 jusqu’à environ 200 ou 500 octets, et les comparent à l’implémentation base64 de référence sur crates.io
- Les options de compilation utilisées sont
-Zbuild-stdet-Ctarget-cpu=native - Après tuning,
N = 32s’est révélé optimal, en utilisant un registre YMM par itération de la hot loop - Au départ, le baseline était battu, mais une variation de performances en forme de heartbeat, fortement corrélée à
data.len() % 32, est apparue - Après inspection de l’assembly, il a été estimé que
copy_from_sliceétait inline/unrolled en boucle de chargement octet par octet Simd::gather_or()a aussi été essayé, mais produisait un assembly plus mauvais et n’a pas été retenu- À la place, une fonction de chargement manuel a été écrite pour les données de longueur variable
- la hot part effectue dans une boucle des chargements
u128, le plus grand chargement scalaire possible - LLVM abaisse les chunks de 16 octets en chargements XMM
- le remainder utilise des chargements qui se chevauchent en
u64,u32etu8
- la hot part effectue dans une boucle des chargements
- Pour lire 15 octets, on lit un
u64depuispet unu64depuisp + 7, ce qui fait chevaucher 1 octet, puis on combine le tout avec un OR - Pour 4 à 7 octets, des chargements
u32qui se chevauchent sont utilisés - Pour 1 à 3 octets, on lit depuis
p,p + len/2etp + len - 1, ce qui peut recharger certains octets, mais réduit le nombre de branches - Après application du nouveau code de chargement, la variance est devenue très faible et les performances ont atteint presque 2x le baseline sur quasiment toute la plage
Encodage et base64 web-safe
- Pour la fonction d’encodage, il suffit d’implémenter
encode_hot()en inversant les opérations dedecode_hot() - Le perfect hash utilisé pour le décodage ne convient pas à l’encodage, donc un nouveau hash est nécessaire
- Le code de chargement/stockage autour de l’encoder diffère aussi légèrement de celui du decoder
vb64implémente également une routine d’encodage efficace- Le base64 web-safe est une variante qui remplace
+et/par-et_ - La construction d’un perfect hash pour le base64 web-safe est plus délicate ; par exemple, une approche comme
(byte >> 4) - (byte == '_' ? '_' : 0)peut être nécessaire vb64ne prend pas encore en charge le base64 web-safe
Conclusion
vb64n’est pas présenté comme une bibliothèque destinée à résoudre un goulot d’étranglement majeur, et l’auteur précise ne pas savoir où le décodage base64 constitue réellement un bottleneck- Le code branchless est souvent excessif, mais il aide à comprendre ce que le compilateur peut et ne peut pas faire
std::simdde Rust est globalement bon et génère un excellent code- Il existe encore quelques rough edges qui mériteraient d’être corrigés pour simplifier davantage le code SIMD, mais le résultat actuel est jugé satisfaisant
- Le SIMD et l’optimisation des performances sont des sujets complexes qui exigent beaucoup d’astuces et de connaissances matérielles, dont une grande partie n’est pas documentée
1 commentaires
Commentaires sur Hacker News
C’était intéressant de voir portable SIMD utilisé en pratique, et en reproduisant le benchmark sur un système Zen 3, j’ai obtenu le même gain de performance
Sur un MacBook Pro M1, le gain commençait à 1,4x pour une longueur d’entrée de 110 octets puis montait progressivement jusqu’à 2x ; c’est moins que sur x86_64, mais l’objectif semble atteint
Cela dit, en regardant le code, cela a confirmé mon expérience selon laquelle Rust a une ergonomie assez mauvaise pour le SIMD et le travail avec les pointeurs, et plus largement pour l’ingénierie de la performance
Cela dit, le portable SIMD de Rust n’est pas encore très convaincant par rapport à C++, et pour descendre au niveau des zones d’octets brutes, des pointeurs et de la manipulation de buffers, il faut se familiariser avec
Pin,MaybeUninit, etc.portable_simdetallocator_apisont instables depuis des années, la barrière à l’entrée reste élevée et l’ensemble est plus maladroit, ce qui est pour l’essentiel intentionnelEn revanche, rien n’empêche de créer soi-même des abstractions plus agréables à utiliser dans son programme, ou d’employer des crates tierces
Les intrinsics SSE en C++ sont bien pires, avec leurs underscores hideux et des noms difficiles à mémoriser
J’ai déjà implémenté quelque chose du mieux possible en C++ classique, puis vu quelqu’un arriver avec une version SIMD plus de 10 fois plus rapide, et c’est parfois vraiment stupéfiant
En contrepartie, ce code est moins portable
J’aimerais que l’auto-vectorisation des compilateurs soit meilleure, et qu’il existe aussi une forme de support au niveau du langage, comme des annotations permettant localement une certaine réorganisation des opérations
En dehors d’un contexte très local, le compilateur ne peut pas réorganiser les données à votre place, ce qui rend l’auto-vectorisation vraiment difficile
Par exemple, dans
for(double v: vec) sum+=v, l’addition en virgule flottante n’est pas associative ; additionner les valeurs dans l’ordre n’est donc pas équivalent à une approche SIMD où l’on additionne par groupes espacés de 8 puis combine les restesDu point de vue du compilateur, cela peut sembler être une optimisation évidente, mais à moins d’indiquer explicitement qu’on peut assouplir certaines garanties, il privilégiera la préservation de la sémantique sérielle plutôt que l’optimisation
Cela devient donc vite compliqué et, comme le disait janwas, il vaut mieux utiliser des bibliothèques sur les chemins critiques, en particulier Google Highway ou quelque chose comme Intel ISPC
Il cherche à être efficace de la manière la plus portable possible, tout en facilitant la programmation ciblée quand c’est nécessaire
Les compilateurs FORTRAN sont clairement meilleurs en auto-vectorisation, parce que l’aliasing n’y est pas permis
C++ est pénalisé par le fait qu’il suit le modèle mémoire du C
CUDA est du C++ conçu pour les GPU, qui sont les machines SIMD ultimes d’aujourd’hui, et ROCm est en pratique assez proche d’un CUDA pour AMD
Personnellement, j’aimais bien le C++AMP de Microsoft, que je trouvais le plus simple pour débuter
Dommage qu’il n’ait finalement pas trouvé sa place
Et avec une bibliothèque d’encapsulation SIMD, on peut en réalité obtenir quelque chose d’assez portable
Petite remarque au passage : le compilateur n’a pas réussi à optimiser cette implémentation de
popcounten une seule instruction, mais c’est possible avec d’autres implémentationsC’est certes assez délicat : https://godbolt.org/z/T69KxWWW8
Il a été dit que
_mm256_cvtps_epu32représente une opération bas niveau d’un jeu d’instructions donné, et décrit comme le cast float-vers-int d’AVX2, mais cette instruction fait en fait partie d’AVX-512AVX2 n’a pas de cast float-vers-int non signé ; en AVX1, le résultat entier est signed et l’instruction est
_mm256_cvtps_epi32Je me demande ce que cela donnerait comparé à fastbase64[0]
L’article est excellent et je suis ravi de voir ce type de contenu en ligne, mais j’ai plus de mal à partager l’optimisme de l’auteur au sujet des bibliothèques portable SIMD
[0]: https://github.com/lemire/fastbase64
À mon avis, ISPC est tout simplement meilleur que d’ajouter du SIMD à C++ ou à Rust
Il prend aussi en charge le dispatch dynamique, une fonctionnalité pénible à implémenter soi-même
On peut ainsi réinjecter des appels inline vers du C++, utiliser templates et classes dans le code SIMD, et même inliner ensemble plusieurs zones de code SIMD
Je suis d’accord sur le fait que le dispatch dynamique est difficile à implémenter, mais Highway s’en charge
Excellent article, avec cette impression persistante de « je ne serai jamais aussi intelligent »
Un peu comme la plupart des gens ne sont ni ingénieurs logiciel ni physiciens
Avec quelques mois d’étude concentrée, vous pourriez probablement atteindre un niveau comparable
Au fond, c’est surtout une question d’intérêt et de nécessité
Moi aussi je passe de l’optimisation des performances à une ingénierie plus proche du système ou du bare metal dans des projets personnels, et j’aimerais en avoir davantage besoin dans mon travail
Mais la plupart des emplois du secteur n’exigent pas cela
Pas en Python idiomatique, mais en résolvant tout avec numpy
C’est amusant, on peut y apprendre cette forme d’ingéniosité, et beaucoup d’éléments de l’article paraissent tout à fait naturels quand on aborde les problèmes avec l’état d’esprit de ces langages
Avec le temps, on commence à voir les problèmes sous cette forme
Article intéressant
Dès le premier exemple, l’auteur dit que l’implémentation non vectorisée de
popcntproduit un code « franchement ridiculement mauvais », mais en mode release avec le CPU cible natif, cette fonction semble en réalité être assez bien vectoriséehttps://godbolt.org/z/WE1Eq65jY
pub fn popcnt(mut x: u32) -> u32 { x.count_ones() }Il est compilé en
popcnt eax, edi; retSur de grands vecteurs de bits, une implémentation AVX2 peut être plus rapide que
POPCNTVoir « Faster Population Counts Using AVX2 Instructions » : https://academic.oup.com/comjnl/article/61/1/111/3852071
32 bits n’est pas une taille suffisante, et le code généré par Rust est effectivement ridiculement mauvais
popcntJ’ai récemment écrit du code où il fallait compter le nombre de bits dans un masque résultat d’opérations vectorielles, et là cela est bien transformé en
popcnthttps://godbolt.org/z/zT9Whcnco
À cause de passages du genre « ça ressemble à une question piège… ce n’est pas simplement un add ? », on a généralement envie de cibler la représentation vectorielle intermédiaire et de laisser le compilateur décider des détails
Par exemple, les puces Haswell disposaient de plusieurs unités d’exécution en virgule flottante par cœur, et le CPU pouvait exécuter simultanément plus d’une opération en virgule flottante pipelineée, mais parmi elles une seule instruction
addétait possibleS’il y avait beaucoup d’additions ne dépendant pas du résultat précédent, au point d’éviter la latence, on pouvait aussi envoyer des instructions de fused multiply-add avec un terme de multiplication égal à 1 afin de doubler le débit des additions
Cette instruction pouvait être exécutée en parallèle d’une addition vectorielle classique en virgule flottante