sb_lower_boundconserve la même interface questd::lower_boundet, lorsque la branche de comparaison est compilée en déplacement conditionnel (cmov), affiche des résultats jusqu’à 2× plus rapides qu’une recherche binaire classique- Le résultat des comparaisons d’une recherche binaire est souvent sujet aux échecs de prédiction de branche, car la position recherchée n’est pas connue à l’avance ; sur x86, l’option
clang -mllvm -x86-cmov-converter=falseaide à les réduire - Cette implémentation divise
lengthpar deux à chaque boucle et ne met à jour quefirstselon le résultat de la comparaison, ce qui réduit le nombre d’instructions ; dans l’intervalle2^k <= n < 2^(k+1), elle effectue toujoursk+1comparaisons - Dans le benchmark
clang -cmov, les temps d’exécution moyens étaient de 61,30 ns pourstd::lower_bound, 33,24 ns poursb_lower_boundet 32,73 ns pourbb_lower_bound; les moyennes géométriques étaient respectivement de 39,17 ns, 19,81 ns et 21,33 ns, avec un écart important - Dans une recherche sur des chaînes de 8 octets, où la fonction de comparaison est lente,
std::lower_boundétait parfois légèrement devant ; sur de grands tableaux, une variante avec préchargement était en moyenne environ 2,3× plus rapide questd::lower_bound
Structure de base de sb_lower_bound
sb_lower_boundest une fonction C++ de même forme questd::lower_bound- Les entrées sont
first,last,value,comp - La valeur de retour est l’itérateur de la position où la comparaison échoue pour la première fois ; si tous les éléments satisfont la condition, elle renvoie
last
- Les entrées sont
- La boucle principale divise
lengthpar deux et ne déplacefirstvers l’avant que lorsquecomp(first[length], value)est vrai - Ici, « branchless » ne signifie pas que le
ifdisparaît, mais que ceifest compilé en instruction de déplacement conditionnel commecmov, plutôt qu’en saut conditionnel - Avec
clang, l’option-mllvm -x86-cmov-converter=falsepeut permettre de compiler cette forme en déplacement conditionnel
Là où std::lower_bound ralentit
- Une recherche binaire classique compare l’élément du milieu avec
value, puis choisit la partie gauche ou droite - Quand on ne connaît pas la position de l’élément recherché,
if (comp(first[half], value))devient facilement une branche difficile à prédire - Le CPU exécute à l’avance les instructions suivantes grâce à la prédiction de branche, mais si la prédiction est fausse, il doit jeter le travail déjà effectué
- Avec un déplacement conditionnel, on peut choisir une valeur selon le résultat de la comparaison tout en réduisant les sauts conditionnels
clang -cmovpeut aussi transformer certainsif/elsedestd::lower_bounden déplacements conditionnels, ce qui l’a rendu environ 25 % plus rapidegccn’a pas de bonne option pour forcer le déplacement conditionnel dans cette situation, etsb_lower_boundn’émet pas non plus de code branchless actuellement, quel que soit le niveau d’optimisation
Une recherche « optimale » du point de vue du nombre de comparaisons
- Ici, « optimal » désigne une recherche binaire qui minimise le nombre de comparaisons
- Dans une liste de taille
n,std::lower_boundpeut produiren+1résultats possibles :npositions d’éléments plus une position de fin - Si la taille de la liste est
2^k - 1, il y a2^krésultats possibles et, chaque comparaison donnant 1 bit d’information vrai/faux, le nombre optimal de comparaisons estk - Dans les cas « nice » où la longueur vaut
2^k - 1, une recherche optimale est possible avec une boucle très courte - Si la longueur ne convient pas, un accès hors limites peut se produire, comme lorsque
valuevaut 4 dans[0, 1, 2, 3, 4, 5]
Caractéristiques de performance et contraintes de sb_lower_bound
- Lorsque
sb_lower_bounddivise une plage de longueur paire, dans certains cas elle ne saute pas suffisamment d’éléments même si le résultat de la comparaison est vrai - Dans l’intervalle
2^k <= n < 2^(k+1), elle effectue toujoursk+1comparaisons - Dans le même intervalle,
std::lower_boundeffectuekouk+1comparaisons, avec environlog2(n+1)comparaisons en moyenne - Le nombre de comparaisons peut donc être plus élevé, mais le nombre d’instructions dans la boucle est bien plus faible, ce qui réduit le temps d’exécution global
- Si la fonction de comparaison est très lente, l’écart entre
k+1etlog2(n+1)comparaisons peut affecter les performances - Pour forcer le déplacement conditionnel avec
gcc, on peut utiliser ducmoven assembleur inline spécifique à x86, mais l’approche simple augmente le nombre d’instructions et les alternatives nécessitent un assembleur distinct selon les types
La variante plus rapide bb_lower_bound
bb_lower_bounddécoupe d’abord la plage différemment jusqu’à obtenir une longueur de la forme2^k - 1, puis effectue la recherche avec une seconde boucle rapidelength & (length + 1)sert à déterminer si la longueur est de la forme11..1, c’est-à-dire2^k - 1- Pour les longueurs non conformes, elle utilise une valeur MAGIC,
auto step = length / 8 * 6 + 1, afin de se rapprocher rapidement d’une plage « nice » stepdoit généralement être au moins égal àlength / 2pour pouvoir passer souvent à la boucle rapide, mais s’il est trop proche delength, on perd les avantages de la recherche binaire- À cause du
break,bb_lower_boundcomporte des branches - L’utilisation d’une table précalculée avec le
steple plus rapide pour chaque longueur reste une piste qui n’a pas encore été explorée
Une implémentation totalement branchless n’était pas plus rapide
- Sur une machine 64 bits, la boucle de
sb_lower_bounds’exécute au plus 64 fois ; on peut donc créer une version « totalement branchless » qui supprime même le test surlength, avec unswitchet des fall-through intentionnels - Cette approche saute vers la position de code correspondant au nombre de comparaisons nécessaire via
std::bit_width(length) - En pratique, les performances n’étaient pas meilleures
- Les CPU x86 modernes gèrent bien les branches prévisibles comme les conditions de boucle, donc supprimer le test sur
lengthn’apportait pas d’avantage - La boucle classique a aussi été jugée préférable car elle évite les templates, les macros et la duplication-modification de 64 cas
Résultats des benchmarks
- Les résultats en temps d’exécution moyen (ns) avec
clang -cmovsont les suivantsstd::lower_: 61,30branchless_lower_: 43,43asm_lower_: 54,32sb_lower_: 33,24sbm_lower_: 35,54bb_lower_: 32,73
- En moyenne géométrique du temps d’exécution (ns),
sb_lower_était aussi le plus basstd::lower_: 39,17branchless_lower_: 25,14asm_lower_: 31,21sb_lower_: 19,81sbm_lower_: 20,91bb_lower_: 21,33
sbm_lower_boundest une variante qui utilise la formefirst += comp(first[length], value) * (length + rem)au lieu d’unif, afin d’incitergccà générer un déplacement conditionnel- Comme cette optimisation peut disparaître dans une future version de
gcc, elle nécessite des commentaires et des précautions - Les commandes de benchmark utilisaient
g++-10,clang++-10etclang++-10 -mllvm -x86-cmov-converter=false, avec-march=haswell -march=nativeou l’absence de-marchn’ont pas beaucoup influencé le classement, et les tests ont été effectués sur un Intel i7 Kaby Lake
Mesure des échecs de prédiction de branche
- Une exécution
clangclassique mesurée avecperfa enregistré environ 6,94 milliards de branches et environ 1,20 milliard de branch-misses, soit un taux de 17,34 % - L’exécution
clang -cmova enregistré environ 4,07 milliards de branches et environ 35,95 millions de branch-misses, avec un taux réduit à 0,88 % -cmovsupprime environ 2,9 milliards de branches et environ 1,2 milliard d’échecs de branche- Les branches supprimées étaient des branches dont la prédiction échouait avec une probabilité d’environ 41 %
- C’est proche des 50 % attendus pour une branche totalement imprévisible
Les résultats changent avec une fonction de comparaison lente
- Pour observer une situation où la fonction de comparaison est plus lente, une recherche sur des chaînes de 8 octets a été testée
- En temps d’exécution moyen (ns),
std::lower_boundétait légèrement plus rapide quesb_lower_bound, ou similairegcc:std::lower_160,01,sb_lower_165,66clang:std::lower_157,71,sb_lower_162,68,bb_lower_157,22clang -cmov:std::lower_156,06,sb_lower_164,71,bb_lower_157,48
- Dans ce cas,
std::lower_boundest très légèrement, mais systématiquement, plus rapide quesb_lower_bound - Une bibliothèque peut viser les meilleures performances en utilisant
sb_lower_boundlorsqu’elle travaille directement sur des types primitifs, etstd::lower_bounddans les autres cas
Différences visibles dans l’assembleur
- La boucle chaude de
std::lower_boundavecclang -cmovcontient des déplacements conditionnels commecmovaetcmovbe, mais utilise plusieurs instructions pour mettre à jour la longueur et la position - La boucle chaude de
sb_lower_boundcalcule la demi-longueur, le reste et le pointeur à déplacer, puis met à jourfirstaveccmova - L’assembleur de
branchless_lower_boundest très court et propre, mais dans les tests de performance,sb_lower_boundobtient de meilleurs résultats avec un overhead plus faible
Mise à jour : un sb_lower_bound plus court
- Après un commentaire de l’auteur d’orlp.net,
sb_lower_boundpeut être refactorisé pour réduire le nombre d’instructions assembleur de la boucle chaude de 9 à 8 - L’idée clé est que
length - halfest égal àhalf + length % 2 - La forme refactorisée calcule
half = length / 2, puis, si la comparaison est vraie, effectuefirst += length - halfavant de mettre à jourlength = half - Avec
clang -cmov, le temps d’exécution moyen s’améliore légèrement, passant d’environ 33 ns à environ 32 ns
Sur les grands tableaux, le préchargement est efficace
- Le préchargement proposé dans les commentaires consiste à amener à l’avance les données nécessaires dans les caches L1/L2 afin de réduire la latence lors de l’accès réel
- Les exemples de latence sont d’environ 4 cycles pour L1, 12 cycles pour L2, 40 cycles pour L3 et 200 cycles pour la mémoire
gcccommeclangprennent en charge__builtin_prefetch()- Précharger la position
length / 4en gaspille 1 sur 2, et ajouter jusqu’àlength / 8en gaspille 5 sur 6 - Le calcul des positions de préchargement et les appels eux-mêmes ajoutent aussi de l’overhead, et ce coût est important dans une boucle chaude raccourcie
- Plusieurs stratégies de préchargement n’apportaient rien pour les tableaux de moins de 256 Ko
- À partir de 256 Ko,
sbp_lower_bound, qui ajoute du préchargement, a amélioré le temps d’exécution moyen d’environ 32 ns à environ 26 ns dans des tests allant jusqu’à environ 4 millions d’entrées, soit 16 Mo - Dans un test ensuite étendu à environ 128 millions d’entrées, soit 512 Mo, la version avec préchargement était environ 2,3× plus rapide que
std::lower_bounden temps moyen- La comparaison était d’environ 161 ns pour
std::lower_boundcontre environ 71 ns pour la version avec préchargement
- La comparaison était d’environ 161 ns pour
Observations et alternatives sur les grands jeux de données
- Sur de très grandes tailles, le
std::lower_boundbranchless généré parclang -cmovétait plus lent que la version avec branches - Les CPU modernes peuvent suivre les branches prédites et lancer des chargements mémoire ainsi que de l’exécution spéculative, ce qui peut fonctionner de fait comme du préchargement
sbpm_lower_boundest une version desbm_lower_boundavec préchargement, qui utilise une multiplication booléenne pour incitergccà générer du code branchless- Des sauts apparaissent dans le graphe de performance entre 1 million et 10 millions d’éléments, ce qui laisse théoriquement de la place pour une implémentation plus rapide
- Cependant, le code de préchargement devient de plus en plus complexe et multiplie les constantes magiques ; plus cette complexité augmente, moins il paraît probable de pouvoir le contribuer à
gcc/libstdc++oullvm/libc++ - Comme alternative qui s’affranchit des contraintes de
std::lower_bound, il existe l’Eytzinger Binary Search, qui réorganise le tableau d’entrée en forme de tas de médianes binaires pour rendre les recherches plus cache-friendly - Dans le test d’arbre 16-aire sur
intde Sergey Slotin à la CppCon 2022, les résultats étaient 7× à 15× plus rapides questd::lower_bound
Code et conditions d’utilisation
- Si la recherche ou la comparaison est la partie la plus lente du programme et que le processeur a du mal à prédire les résultats des comparaisons, l’option
-mllvm -x86-cmov-converter=falsedeclangsur x86 vaut la peine d’être essayée - Si une recherche binaire plus rapide est nécessaire,
sb_lower_boundpeut être essayé ; avecgcc,sbm_lower_boundest aussi une option - Le code est publié sous licence MIT
- Le code et les benchmarks sont disponibles sur github.com/mh-dm/sb_lower_bound/
1 commentaires
Avis sur Hacker News
Chaque fois que je vois des gens essayer de supprimer les branchements, je me demande s’ils savent que le fait qu’une mauvaise prédiction de branchement bloque un long pipeline n’est pas un élément indispensable de l’architecture CPU.
Les pipelines sont longs parce qu’on effectue beaucoup d’analyses et de transformations juste avant l’exécution, mais comme ce n’est pas un algorithme très dépendant de l’état, la majeure partie pourrait être faite à l’avance.
Les CPU Transmeta Crusoe fonctionnaient de cette manière, et on peut imaginer un monde où l’on n’aurait pas à se soucier des branchements.
En allant plus loin, toute opération est un branchement qui regarde l’état des bits et modifie le résultat, mais ces branchements locaux à l’intérieur de l’ALU ne sont pas des branchements sur le pipeline principal, donc ils ne nuisent pas beaucoup aux performances.
Je me souviens avoir aussi dit à srk, à l’époque, que le choix entre IPC et débit comme métrique influait sur ce que l’on considérait comme bon ou mauvais.
Le camp de l’IPC estime que si l’on augmente l’IPC, le procédé de fabrication permettra de monter la fréquence et tout le monde y gagnera ; le camp du débit adopte une approche plus réaliste, selon laquelle la loi de Moore est morte, faire tourner le silicium plus vite le fait fondre, et c’est donc la conception intelligente de l’ISA qui l’emportera.
Au cours des 20 dernières années, les deux camps ont connu des succès et des déconvenues, et il est intéressant de voir RISC-V revenir aujourd’hui à ce type de questions dans l’architecture CPU.
C’est aussi un bon terrain pour suivre comment des idées superscalaires modernes sont ajoutées grâce à la flexibilité du jeu d’instructions, et à long terme je pense que c’est cette voie qui gagnera.
La traduction de Transmeta n’a pas supprimé le coût des branchements.
Je me souviens que Linus, qui travaillait chez Transmeta, avait dit dans un fil comp.arch quelque chose comme : « le travail du CPU est de provoquer des cache misses le plus vite possible ».
Les cache misses obligatoires existent, et aucun JIT ne peut les éliminer.
Dans le monde réel, même avec les énormes caches actuels, on ne peut pas non plus éviter les capacity misses.
Itanium pensait aussi pouvoir supprimer le coût des branchements par analyse statique ; il suffit de se rappeler comment cela s’est terminé.
J’aimerais que les programmeurs lisent quelques livres d’architecture des ordinateurs avant de conclure avec assurance qu’ils peuvent facilement faire mieux que les processeurs modernes.
À mon avis, ils sous-estiment d’au moins 7 ordres de grandeur l’ampleur de l’effort intellectuel investi dans les processeurs actuels.
L’un d’eux est les données d’entrée traitées.
La recherche binaire en est un cas typique : le compilateur ne sait pas à quelle position le résultat sera trouvé.
Un autre est la microarchitecture, en particulier la hiérarchie de caches et la configuration des unités d’exécution.
Si l’on passe à une ISA dont les instructions ressemblent aux micro-opérations des CPU actuels, il faudrait recompiler pour chaque microarchitecture.
Cela dit, c’est techniquement soluble avec un JIT au niveau de l’OS, comme sur les GPU actuels : les programmes sont distribués sous forme de bytecode (DXBC, SPIR-V, NVPTX), puis le pilote GPU en mode utilisateur les recompile en instructions réelles pour le matériel.
La variable la plus importante est toutefois que d’autres threads CPU exécutent du code inconnu.
Même en supprimant l’hyperthreading pour rendre les cœurs indépendants, il reste des ressources partagées à l’échelle de la puce, comme le cache L3, la mémoire externe, la bande passante d’E/S, ainsi que la puissance et la dissipation thermique.
Si l’on redéfinit tout comme Branch™, alors certains Branch™, y compris des choses qui ne sont pas de vrais branchements, peuvent être précalculés.
Mais la suppression de branchement dont on parle habituellement ne concerne-t-elle pas les cas où le chemin de calcul se sépare réellement, comme dans du code if/else ?
Même dans ce monde, des optimisations utiles seraient possibles, mais elles se limiteraient aux Branch™ qui tentent de calculer simultanément plusieurs résultats futurs.
Chaque fois qu’il existe une opération pouvant être effectuée indépendamment, il existe d’autant plus de possibilités de l’exécuter en parallèle.
Je ne parle pas seulement du décodage, du fetch et de l’exécution.
Avec une ALU et un shifter indépendants, on peut décaler pendant qu’on additionne ; avec un additionneur et un multiplicateur dédiés, il n’y a pas de raison de ne pas tenter les deux en même temps.
Cela conduit à vouloir garder plusieurs instructions en cours simultanément, ce qui signifie qu’il faut pouvoir récupérer et décoder les instructions plus vite qu’on ne les traite.
Cela mène aussi naturellement à vouloir les réordonner pour éviter que N instructions Add n’empêchent de voir un Shift indépendant.
On peut penser que l’architecture actuelle est plus complexe que nécessaire, et ce n’est peut-être pas faux.
Mais comme une énorme quantité d’ingénierie est investie dans les architectures actuelles, si l’on pense pouvoir faire beaucoup plus rapide autrement, il faut creuser sérieusement pour vérifier à quel point cette affirmation est exacte.
À propos du passage « Si seulement il existait un langage bare metal propre et rapide pour écrire tout ça… », l’auteur a ajouté des notes « BUT RUST.. » et « BUT ZIG.. », mais je me demande ce qu’il en serait de Nim
Il semble exister une implémentation native de bibliothèque pour
lowerBound: https://github.com/nim-lang/Nim/blob/version-2-0/lib/pure/al...À strictement parler, ce n’est pas un langage « bare metal », mais comme il compile vers C ou C++, il serait intéressant de voir en quel code cela se compile ici
Et je me demande aussi quel est le problème avec C
TigerBeetle utilise sa propre implémentation sans branchement : https://github.com/tigerbeetle/tigerbeetle/blob/e996abcf7154...
C’est précisément le genre d’usage qui justifie les templates C++
C n’est pas propre
Je ne suis toujours pas sûr que ce soit bien
lower_boundJ’ai peut-être mal lu le code, mais en présence de doublons, il semble renvoyer une correspondance quelconque plutôt que la première
Si la fonction de comparaison cherche un préfixe de chaîne donné pour de l’autocomplétion, plusieurs éléments peuvent correspondre même dans une liste unique, et dans ce cas on veut le premier élément de la liste
Je serais curieux de savoir pourquoi tu penses le contraire
J’aimerais que tous les billets de blog commencent comme celui-ci : « Je sais que vous êtes occupés, alors allons droit au but. Voici l’implémentation de recherche binaire en C++ la plus rapide, générale et simple »
La bibliothèque standard de Zig n’appelle pas C++ pour la recherche binaire
La recherche binaire actuelle se trouve ici : https://github.com/ziglang/zig/blob/b835fd90cef1447904d3b009...
Je ne comprends pas bien
Le problème de la recherche binaire et des branchements n’est pas le branchement lui-même, mais le fait qu’on ne sait pas quel emplacement mémoire du tableau charger ensuite tant que la comparaison n’est pas terminée
Que l’on utilise un branchement ou autre chose n’y change rien : la question est ce que l’on veut au final que le processeur fasse
Il y a une dépendance de données
Avant d’avoir lu l’indice du milieu, on ne sait pas s’il faut chercher dans la moitié supérieure ou inférieure
On peut spéculer et émettre les lectures des deux côtés, ce qui résout la dépendance mais augmente le trafic mémoire
Tout l’enjeu est de savoir si c’est le bon compromis ; supprimer simplement les branchements n’est pas la réponse
C’est abordé vers la fin du billet : https://mhdm.dev/posts/sb_lower_bound/#prefetching
C’est pourquoi une recherche binaire réellement plus rapide utilise une disposition de tableau Eytzinger : https://algorithmica.org/en/eytzinger
Sur mon processeur Cascade Lake,
-mllvm -x86-cmov-converter=falseréduit presque de moitié les performances de la recherche binaireLes chiffres sont en nanosecondes par bsearch sur un tableau
uint32de 100 Moclang 15.0.7 semble bien moins bon que gcc 13.2.1 pour optimiser ce code précis
L’assembleur est consultable ici : https://godbolt.org/z/cbx5Kdjs6
L’assembleur produit par gcc paraît beaucoup plus propre
100 Mo, c’est suffisamment grand pour que la version avec branchements soit légèrement avantagée, mais c’est dû aux caractéristiques de l’exécution spéculative sur x86 plutôt qu’à une réelle supériorité
Quelqu’un sait-il vers quoi le lien « BUT RUST » était censé pointer à l’origine ?
Il n’était pas figé sur une version, donc il semble déjà cassé, et il devait peut-être pointer vers le milieu du commentaire de documentation de
starts_withlet mid = left + size / 2;[1] https://web.archive.org/web/20230602210213/https://doc.rust-...
Il s’agissait de faire un lien vers l’implémentation de la recherche binaire de Rust
Elle a été mise à jour vers https://doc.rust-lang.org/1.71.1/src/core/slice/mod.rs.html#...
Il est intéressant de voir que le résultat ne se maintient pas avec une fonction de comparaison
compplus complexeL’article envisageait un scénario de recherche binaire assez réaliste où la fonction de comparaison est lente, comme pour des ID, des numéros de téléphone, des comptes ou des mots-clés, et indique avoir donc testé la recherche sur des chaînes de 8 octets
Dans ce cas,
std::lower_boundest très légèrement, mais systématiquement, plus rapide quesb_lower_bound, et pour obtenir toujours les meilleures performances, une bibliothèque devrait utilisersb_lower_boundlorsqu’elle manipule directement des types primitifs, etstd::lower_bounddans les autres casJ’aimerais voir l’analyse correspondante
Avec des données et des entrées réellement aléatoires, la prédiction se trompe environ une fois sur deux
L’approche CMOV est bloquée après la fonction de comparaison à cause de la dépendance de données
En moyenne, l’approche avec branchement effectue deux comparaisons à la fois, tandis que CMOV n’en effectue qu’une ; on peut donc s’attendre à un point de bascule lorsque le temps de comparaison devient supérieur à la pénalité d’un échec de prédiction de branchement
Un prototype SIMD que j’avais bricolé auparavant est 3 fois plus rapide que
std::lower_boundjusqu’à ce qu’il soit limité par la bande passante mémoire : https://github.com/matthewkolbe/ThinkingInSimd/tree/main/alg...Je suppose qu’ils sont purement aléatoires, mais si ces chaînes de 8 octets ne sont pas de l’information pure, les prédicteurs de branchement modernes peuvent facilement faire mieux que
cmovL’attribut
unpredictablesemble désormais influencer la passe de conversion en cmovCela date du 1er juin, donc ce sera probablement inclus dans clang 17/18 : https://reviews.llvm.org/D118118