- Une optimisation en assembleur AArch64 qui visait à supprimer un branchement dans une boucle a au contraire rendu le code 4 fois plus lent ; la cause était un usage asymétrique du couple appel-retour
bl/ret, qui a perturbé le prédicteur de branchement
- Le code d’origine effectue deux branchements par itération,
bl foo et b loop, mais la version modifiée tente de réduire le nombre de branchements avec un unique bl loop pour fixer x30, puis plusieurs ret revenant tous à la même adresse
- Sur un M1 Max, la somme d’un tableau de 1024
float prenait 969 ns avec un code bl/ret normal, mais la structure un seul bl et plusieurs ret ralentissait à 3,85 µs ; en remplaçant ret par br x30, on revenait à 913 ns
- Les compteurs de performance d’Instruments ont montré que, lors de la somme d’un milliard d’éléments, les échecs de prédiction des branches de retour dans la structure asymétrique
bl/ret atteignaient environ 93 % ; FETCH_RESTART, MAP_DISPATCH_BUBBLE et MAP_REWIND augmentaient aussi fortement
- Des implémentations plus rapides passent ensuite par l’inlining de
foo, Rust avec iter().sum(), puis le SIMD NEON et le déroulage manuel de boucle ; la version SIMD finale descend à 94 ns, mais l’ordre des additions en virgule flottante change, donc le résultat peut différer
Effet contre-productif dans une boucle AArch64
- La fonction d’exemple parcourt un tableau de
float, transmet chaque valeur à foo, et foo met à jour l’accumulateur g
- Une traduction AArch64 simple suit le flux suivant
- Vérification en début de boucle de
n == 0
- Lecture d’une valeur avec
ldr s1, [x0], #4
- Appel de la sous-routine avec
bl foo
- Le
ret de foo revient à l’instruction qui suit bl
- Retour au début de boucle avec
b loop
foo a une forme proche d’une naked function, utilisant la même pile et les mêmes registres que la fonction parente ; elle lit s1 et accumule dans s0
Rôle de bl et ret
bl est l’instruction branch and link : elle saute vers un label donné tout en stockant l’adresse de l’instruction suivante dans le registre de lien lr, ou x30
ret saute vers l’adresse contenue dans le registre de lien
- Dans la structure d’origine,
bl foo et ret sont appariés, et ret revient toujours à l’instruction qui suit ce bl
L’optimisation ratée pour « supprimer un branchement »
- La structure modifiée cherche à enlever un branchement dans la boucle sans modifier
foo
- Au début de la fonction,
bl loop place l’adresse de début de loop dans x30
- Après vérification de la condition de fin de boucle, l’exécution tombe dans le code de
foo sans branchement séparé
- Le
ret de foo revient à loop, contenu dans x30
- Dans cette structure,
x30 ne change pas dans le corps de boucle ; les ret successifs reviennent donc toujours à la même adresse
- Le
foo simple ressemble à ceci, avec une seule addition de float
foo:
fadd s0, s0, s1
ret
- Dans ce cas, la fonction complète calcule la somme du tableau de
float en entrée
Résultats du benchmark et problème de prédiction de branchement
- Un benchmark avec
criterion a été exécuté sur un CPU M1 Max avec un tableau de 1024 éléments
bl/ret normal : 969 ns
- un seul
bl, plusieurs ret : 3,85 µs
- Le code qui supprime un branchement est environ 4 fois plus lent que le code d’origine qui en utilise deux
- Cliff et Dan ont estimé que le prédicteur de branchement était perturbé parce que les paires
bl/ret ne correspondaient plus
- Selon la documentation ARM,
RET permet au processeur de reconnaître un retour de fonction, ce qui améliore la précision de la prédiction de branchement
BR LR peut faire fonctionnellement la même chose
- Mais
RET reste une instruction distincte que le processeur peut identifier comme un retour de fonction
- Si la prédiction est correcte, les bonnes instructions sont injectées dans le pipeline et on évite d’attendre leur chargement depuis la mémoire
Pile d’adresses de retour et expérience avec br x30
- Le prédicteur de branchement maintient probablement en interne une pile d’adresses de retour de fonctions
- À l’exécution de
bl, l’adresse de retour est empilée
- En voyant
ret, il suppose un retour vers l’adresse du bl le plus récent
- Il lance alors le préfetch et l’exécution spéculative à partir de cette adresse, puis dépile l’entrée
- Ce mécanisme fonctionne bien quand les paires
bl/ret sont correctement appariées
- Si plusieurs
ret utilisent en boucle la même adresse, la prédiction échoue et peut provoquer préfetch inutile, mauvaise exécution spéculative, blocage ou vidage du pipeline
- Comme l’a proposé Dan, remplacer
ret par br x30 fait disparaître la dégradation des performances
bl/ret normal : 969 ns
- un seul
bl, plusieurs ret : 3,85 µs
- un seul
bl, plusieurs br x30 : 913 ns
- La version
br x30 n’effectue qu’un seul branchement par itération, ce qui la rend légèrement plus rapide que le code d’origine
Compteurs de performance Instruments
- Les compteurs de performance des deux premiers programmes ont été examinés avec Instruments
- La mesure a été réalisée pendant la somme d’un tableau d’un milliard d’éléments
- Dans le cas du
bl/ret asymétrique, les échecs de prédiction des branches de retour atteignaient environ 93 %
| Compteur |
bl/ret normal |
un seul bl, plusieurs ret |
BRANCH_RET_INDIR_MISPRED_NONSPECIFIC |
92 |
928,644,975 |
FETCH_RESTART |
61,121 |
987,765,276 |
MAP_DISPATCH_BUBBLE |
1,155,632 |
7,350,085,139 |
MAP_REWIND |
6,412,734 |
2,789,499,545 |
- Apple ne documente pas complètement ces compteurs
- Les autres compteurs sont supposés refléter les effets en aval d’une mauvaise prédiction de branchement
FETCH_RESTART : probablement lié à un mauvais préfetch
MAP_DISPATCH_BUBBLE : probablement lié à un pipeline stall
MAP_REWIND : probablement lié à une exécution spéculative erronée qu’il faut rembobiner
Comment aller plus vite
- L’exemple est un code pédagogique, et si
foo est une sous-routine, c’est davantage pour l’explication que pour obtenir « le code le plus rapide possible »
- Si le contenu de
foo est connu à la compilation et reste dans la portée maximale de saut, on peut supprimer totalement bl et ret par inlining
- On passe de 969 ns à 911 ns, soit environ 6 % de gain
- En Rust, un simple
f.iter().sum() descend à 833 ns
pub fn sum_slice(f: &[f32]) -> f32 {
f.iter().sum()
}
SIMD et déroulage manuel de boucle
- L’implémentation SIMD manuelle en AArch64 se compose de trois boucles
loop : ajoute individuellement les valeurs dans s0 jusqu’à ce que le nombre restant soit un multiple de 4
simd : ajoute 4 valeurs à la fois dans le registre vectoriel v1, jusqu’à ce que le nombre restant soit un multiple de 8
simd2 : déroule simd par 2 pour traiter 8 valeurs par itération et accumuler dans v1 et v2
- À la fin de la fonction, les valeurs de
v1 et v2 sont accumulées dans s0 puis renvoyées
- Le type punning consiste à traiter
x0, de type float*, comme un double*, afin de lire 128 bits, soit 4 float, dans d3 et d4
mov v3.d[1], v4.d[0] déplace les 64 bits de d4 dans la moitié haute de v3
- Dans
fadd v1.4s, v1.4s, v3.4s, le suffixe .4s permet de les traiter comme quatre float
- Cette implémentation SIMD s’exécute en 94 ns, soit environ 8,8 fois plus vite que la meilleure version précédente en Rust à 833 ns
Résumé global des performances et point d’attention
| Implémentation |
Temps |
bl/ret normal |
969 ns |
un seul bl, plusieurs ret |
3,85 µs |
un seul bl, plusieurs br x30 |
913 ns |
boucle classique avec b |
911 ns |
| réécriture en Rust |
833 ns |
| SIMD + déroulage manuel de boucle |
94 ns |
- Le code SIMD modifie l’ordre des additions en virgule flottante
- Comme l’addition en virgule flottante n’est pas associative, la version SIMD peut ne pas produire exactement le même résultat que le code linéaire
- C’est peut-être aussi pour cette raison que le compilateur n’a pas généré d’instructions SIMD pour cette somme
- Tout le code est disponible sur GitHub
- Il suffit d’exécuter
cargo bench sur une machine ARM64 pour reproduire le benchmark
1 commentaires
Commentaires Hacker News
Le dernier code optimisé termine la somme d’un tableau de 1 024 nombres flottants 32 bits en 94 ns.
Pendant ces 94 ns, notre vieil ami le 6502 à 1 MHz en serait à peine au stade où il commence à se demander s’il doit signaler à la puce mémoire de récupérer le premier octet de la première instruction du programme.
Cela dit, ce code repose entièrement sur l’hypothèse qu’il s’exécute dans le cache. Sinon, même le puissant M1 Max cité dans l’article serait resté bloqué à attendre le premier accès mémoire. La DRAM est lente.
Raymond Chen a traité du même sujet il y a près de 20 ans : https://devblogs.microsoft.com/oldnewthing/20041216-00/?p=36...
À part une compréhension de base des propriétés semi-conductrices du silicium et du dopage, il n’y a presque rien d’intuitif là-dedans. Si vous n’avez pas vu le schéma du die, le câblage et les chemins, il y a très peu de raisons de s’attendre à ce que A soit plus rapide que B, sauf si les ingénieurs et les datasheets le disent explicitement. C’est encore plus vrai, à mon avis, avec ARM.
Ce que le présent article ajoute, c’est une simple correction consistant à remplacer
retpar une autre instructionbr. Ainsi, la paire redevient « symétrique », et l’on peut obtenir un code un peu plus rapide sans perturber le prédicteur de branchement.Bien sûr, tout est possible, et une boucle ordinaire qui additionne un tableau demande effectivement à l’ordinateur d’accumuler les éléments un par un.
Mais, par exemple, il est difficile de dire que créer quatre accumulateurs en parallèle avec SIMD, puis les additionner à la fin, est plus incorrect que d’additionner les éléments un par un.
Il faut considérer que les additions en virgule flottante ont, par nature, une marge d’erreur, et qu’une réponse située dans cette marge doit être valide. Si l’on sait quelque chose de particulier sur les nombres flottants en entrée, le langage devrait fournir un moyen d’exprimer explicitement cette intention. Comme la boucle la plus simple est le comportement par défaut, il me semble normal que, par défaut, elle offre les meilleures performances.
La méthode naïve consistant à les ajouter un par un dans une boucle est évidente, mais il existe des méthodes plus sophistiquées qui offrent de meilleures bornes sur l’erreur cumulée totale, et la sommation de Kahan en est un exemple bien connu : https://en.wikipedia.org/wiki/Kahan_summation_algorithm
Avec des données en streaming, on n’a parfois pas d’autre choix que d’ajouter les valeurs une par une ; mais si l’on peut utiliser N tampons de taille fixe, des questions apparaissent : quel sous-ensemble transformer en somme partielle lorsqu’un nouveau nombre arrive, comment l’ajouter à la somme cumulée, et ce choix apporte-t-il une amélioration démontrable de l’erreur ?
Par exemple, calculer
[1e50, -1e50, 1e3, 1e3]sous la forme(((1e50 + -1e50) + 1e3) + 1e3)donne2e3, tandis que le calculer sous la forme((1e50 + 1e3) + (-1e50 + 1e3))donne0.C’est similaire lorsqu’on ajoute beaucoup de petites valeurs à une seule grande valeur :
(((1e3 + 1e3) + 1e3) ... + 1e50)et(((1e50 + 1e3) + 1e3) ... + 1e3)sont assez différents.Si les résultats arithmétiques ne sont pas stables d’un build à l’autre, cela devient un cauchemar absolu. On ne devrait pas obtenir un résultat différent en recompilant puis en exécutant le logiciel avec les mêmes entrées.
J’ai aussi connu, il y a longtemps, un cas spécifique à Intel : le FPU utilisait en interne des registres 80 bits, mais stockait 64 bits en mémoire ; donc si le moment où les registres étaient remplis ou vidés changeait, le moment de l’arrondi changeait aussi, et le résultat avec lui. On pouvait définir un drapeau FPU global au démarrage du programme pour forcer l’arrondi à chaque opération.
Il existe toujours une bonne réponse, et je pense que le compilateur ne devrait pas effectuer, du moins par défaut, une transformation incorrecte. Cela dit, tout moyen permettant au programmeur d’exprimer plus clairement son intention est toujours bienvenu.
Appliquer SIMD aux boucles en virgule flottante aurait pu devenir le comportement par défaut, mais cela casserait beaucoup de code existant et modifierait souvent les sorties de façon non déterministe ; c’est donc devenu une fonctionnalité que le programmeur doit choisir explicitement.
De plus, beaucoup de programmeurs peuvent ignorer ce fait, si bien que si
float Sum(float[] values)commence à renvoyer une valeur différente, ils n’auront peut-être aucun moyen de savoir que la vectorisation en est la cause. C’est pourquoi, par exemple, la bibliothèque standard .NET utilise SIMD pourintegers.Sum(), mais pas pourfloats.Sum().Rien qu’en lisant la ligne « après avoir vérifié la fin de la boucle, on tombe directement dans la fonction
foosans branchement », je me suis dit : « ah, voilà le problème ».Je pensais que ce serait une discussion approfondie sur des heuristiques sophistiquées de prédiction de branchement, mais au final c’était une violation d’une heuristique de base.
Il ne faut pas croire qu’on peut obtenir un énorme gain de performance en utilisant des instructions
call/retqui ne correspondent pas. Le fait que le prédicteur de branchement maintienne une pile fantôme des adresses de retour existe depuis des décennies.Ce genre de risque doit donc être documenté, mais les concepteurs devraient pouvoir partir du principe que les personnes qui écrivent directement de l’assembleur ont lu la documentation.
D’un autre côté, Sophie Wilson a écrit l’implémentation originale de BBC BASIC pour ARM, mais à l’époque il n’y avait pas de prédicteur de branchement. Même si les règles diffèrent parce que c’était du 32 bits, je me demande comment AArch64 ralentit le code lorsque les hypothèses architecturales changent.
C’est une référence à un classique de SNL, « Do not taunt happy fun ball » : https://www.youtube.com/watch?v=GmqeZl8OI2M
Il ne faut pas oublier que l’article date de 2023. Il est déjà un peu dépassé aujourd’hui, et depuis Rust 1.78, le compilateur utilise un déroulage de boucle plus agressif et un peu de SIMD : https://godbolt.org/z/zhbobW7rr
L’article original disait « en regardant l’assembleur, on voit qu’il déroule la boucle » et renvoyait vers https://godbolt.org/z/Kv77abW6c, mais il utilisait le « Rust Nightly », qui évolue en permanence. Aujourd’hui, le déroulage de boucle est plus important.
Le déroulage de boucle a commencé avec Rust 1.59 : https://godbolt.org/z/5PTnWrWf7
D’après le code GitHub, il utilisait Rust
1.67.0-nightly, version du 27/11/2022.J’ai relancé le benchmark moi-même sur le même matériel avec le déroulage de boucle agressif du dernier nightly, Rust 1.81, mais il n’y avait pas de différence : la vitesse était la même qu’il y a 1,5 an.
C’est un article de 2023. Discussion de l’époque : https://news.ycombinator.com/item?id=34520498
Qu’un article soit republié environ un an plus tard ne pose pas de problème, et le lien vers l’ancien fil est destiné aux lecteurs qui veulent en savoir plus.
N’étant pas très familier avec l’assembleur ARM/ARM64, j’ai été troublé par la façon dont
x0est incrémenté.const float f = *data++;devientldr s1, [x0], #4, et cette instruction semble lire la valeur tout en incrémentantx0de 4.On peut apparemment aussi utiliser une valeur négative, ce qui permettrait de parcourir en sens inverse. C’est plutôt élégant. Sur x86_64, il ne semble pas y avoir d’instruction unique qui fasse à la fois la lecture et l’incrémentation.
lodsetstosfont respectivement une lecture/écriture avec incrémentation surrsiourdi. Il y a aussimovs, qui copie entre deux adresses mémoire tout en incrémentant.On l’utilise généralement avec
rep, qui répète l’opérationrcxfois. Par exemple, unmemsetde 10 octets peut s’écrire sous la formemov rcx, 10,mov rdi, dest,mov rax, 0,rep stosb.Avec les suffixes
w,d,q, on avance respectivement de 2, 4 et 8 octets.L’article était bon, mais j’ai regretté qu’il alterne constamment entre les unités µs et ns dans les tableaux, ce qui rend les comparaisons difficiles au survol.
Je suis surpris qu’une méthode moins maligne n’ait pas été essayée avant d’optimiser le code.
En réécrivant l’assembleur, une seule branche est nécessaire en bas de la boucle, et pour
X1aussi, on peut utiliser une seule opération ALU au lieu de faire séparément une soustraction pour la comparaison et un décrément.En allant plus loin, on pourrait simplement inliner
fooet supprimer aussi l’instructionRET, sans recourir au tour de passe-passe deBL/RETnon appariés. Je ne l’ai pas benchmarké moi-même, donc je ne sais pas à quel point ce serait plus rapide en pratique.cbnzdevrait êtrecbz. CBZ branche vers l’étiquette si le registre vaut 0, tandis que CBNZ branche s’il ne vaut pas 0.