Si vous pensez qu'une instruction DIV 64 bits est un bon moyen de diviser par deux, alors pas étonnant que la sortie asm du compilateur bat votre code manuscrit, même avec -O0
(compiler rapidement, pas d'optimisation supplémentaire, et stocker / recharger en mémoire après / avant chaque instruction C afin qu'un débogueur puisse modifier les variables).
Consultez le guide d' optimisation d'assemblage d'Agner Fog pour savoir comment écrire un asm efficace. Il a également des tables d'instructions et un guide microarch pour des détails spécifiques pour des CPU spécifiques. Voir aussi lex86 balise wiki pour plus de liens perf.
Voir aussi cette question plus générale sur la façon de battre le compilateur avec un asm écrit à la main: le langage d'assemblage en ligne est-il plus lent que le code C ++ natif? . TL: DR: oui si vous vous trompez (comme cette question).
Habituellement, vous pouvez laisser le compilateur faire son travail, surtout si vous essayez d'écrire C ++ qui peut compiler efficacement . Voir aussi l' assemblage est plus rapide que les langages compilés? . L'une des réponses renvoie à ces diapositives soignées montrant comment divers compilateurs C optimisent certaines fonctions vraiment simples avec des astuces intéressantes. CppCon2017 de Matt Godbolt: « Qu'est-ce que mon compilateur a fait pour moi récemment? Déboulonner le couvercle du compilateur »est dans la même veine.
even:
mov rbx, 2
xor rdx, rdx
div rbx
Sur Intel Haswell, div r64
c'est 36 uops, avec une latence de 32-96 cycles , et un débit d'un par 21-74 cycles. (Plus les 2 uops pour configurer RBX et zéro RDX, mais une exécution dans le désordre peut être exécutée tôt). Les instructions à nombre d'uop élevé comme DIV sont microcodées, ce qui peut également provoquer des goulots d'étranglement frontaux. Dans ce cas, la latence est le facteur le plus pertinent car elle fait partie d'une chaîne de dépendance portée par la boucle.
shr rax, 1
fait la même division non signée: c'est 1 uop, avec 1c de latence , et peut en exécuter 2 par cycle d'horloge.
À titre de comparaison, la division 32 bits est plus rapide, mais toujours horrible par rapport aux décalages. idiv r32
est de 9 uops, 22-29c de latence et un par 8-11c de débit sur Haswell.
Comme vous pouvez le voir en regardant la -O0
sortie asm de gcc ( explorateur du compilateur Godbolt ), il n'utilise que des instructions de décalage . clang -O0
compile naïvement comme vous le pensiez, même en utilisant deux fois l'IDIV 64 bits. (Lors de l'optimisation, les compilateurs utilisent les deux sorties d'IDIV lorsque la source fait une division et un module avec les mêmes opérandes, s'ils utilisent IDIV du tout)
GCC n'a pas de mode totalement naïf; il se transforme toujours via GIMPLE, ce qui signifie que certaines "optimisations" ne peuvent pas être désactivées . Cela comprend la reconnaissance de la division par constante et l'utilisation de décalages (puissance de 2) ou d' un inverse multiplicatif à virgule fixe (non puissance de 2) pour éviter l'IDIV (voir div_by_13
dans le lien Godbolt ci-dessus).
gcc -Os
(pour optimiser la taille) ne utilisation IDIV pour la division non-power-of-2, malheureusement , même dans les cas où le code inverse multiplicatif est légèrement plus grande , mais beaucoup plus rapide.
Aider le compilateur
(résumé pour ce cas: utilisation uint64_t n
)
Tout d'abord, il est seulement intéressant de regarder la sortie optimisée du compilateur. ( -O3
). -O0
la vitesse n'a pratiquement aucun sens.
Regardez votre sortie asm (sur Godbolt, ou consultez Comment supprimer le "bruit" de la sortie de l'assemblage GCC / clang? ). Lorsque le compilateur ne crée pas de code optimal en premier lieu: l' écriture de votre source C / C ++ d'une manière qui guide le compilateur dans la création d'un meilleur code est généralement la meilleure approche . Vous devez connaître asm et savoir ce qui est efficace, mais vous appliquez ces connaissances indirectement. Les compilateurs sont également une bonne source d'idées: parfois clang fera quelque chose de cool, et vous pouvez aider gcc à faire la même chose: voir cette réponse et ce que j'ai fait avec la boucle non déroulée dans le code de @ Veedrac ci-dessous.)
Cette approche est portable, et dans 20 ans, un futur compilateur pourra le compiler en tout ce qui sera efficace sur le futur matériel (x86 ou non), peut-être en utilisant une nouvelle extension ISA ou une vectorisation automatique. Asm x86-64 manuscrite d'il y a 15 ans ne serait généralement pas optimisé pour Skylake. Par exemple, la macro-fusion de comparaison et de branche n'existait pas à l'époque. Ce qui est optimal maintenant pour un asm fabriqué à la main pour une microarchitecture peut ne pas être optimal pour d'autres CPU actuels et futurs. Les commentaires sur la réponse de @ johnfound discutent des différences majeures entre AMD Bulldozer et Intel Haswell, qui ont un grand effet sur ce code. Mais en théorie, g++ -O3 -march=bdver3
et g++ -O3 -march=skylake
fera la bonne chose. (Ou -march=native
.) Ou -mtune=...
simplement régler, sans utiliser d'instructions que d'autres processeurs pourraient ne pas prendre en charge.
Mon sentiment est que guider le compilateur vers asm qui est bon pour un processeur actuel dont vous vous souciez ne devrait pas être un problème pour les futurs compilateurs. Nous espérons qu'ils sont meilleurs que les compilateurs actuels pour trouver des moyens de transformer le code, et peuvent trouver un moyen qui fonctionne pour les futurs processeurs. Quoi qu'il en soit, le futur x86 ne sera probablement pas terrible pour tout ce qui est bon sur le x86 actuel, et le futur compilateur évitera tous les pièges spécifiques à asm tout en implémentant quelque chose comme le mouvement de données depuis votre source C, s'il ne voit pas mieux.
L'asm manuscrit est une boîte noire pour l'optimiseur, donc la propagation constante ne fonctionne pas lorsque l'inlining fait d'une entrée une constante au moment de la compilation. D'autres optimisations sont également affectées. Lisez https://gcc.gnu.org/wiki/DontUseInlineAsm avant d'utiliser asm. (Et évitez l'asm en ligne de style MSVC: les entrées / sorties doivent passer par la mémoire, ce qui ajoute de la surcharge .)
Dans ce cas : votre n
a un type signé et gcc utilise la séquence SAR / SHR / ADD qui donne l'arrondi correct. (IDIV et décalage arithmétique "arrondi" différemment pour les entrées négatives, voir l' entrée manuelle de la référence SAR insn set ). (IDK si gcc a essayé et échoué à prouver que n
cela ne peut pas être négatif, ou quoi. Le dépassement de signature est un comportement indéfini, donc il aurait dû pouvoir.)
Vous auriez dû utiliser uint64_t n
, donc il peut simplement SHR. Et donc il est portable pour les systèmes où il long
n'y a que 32 bits (par exemple Windows x86-64).
BTW, la sortie asm optimisée de gcc semble assez bonne (en utilisant unsigned long n
) : la boucle interne dans laquelle elle est insérée main()
fait ceci:
# from gcc5.4 -O3 plus my comments
# edx= count=1
# rax= uint64_t n
.L9: # do{
lea rcx, [rax+1+rax*2] # rcx = 3*n + 1
mov rdi, rax
shr rdi # rdi = n>>1;
test al, 1 # set flags based on n%2 (aka n&1)
mov rax, rcx
cmove rax, rdi # n= (n%2) ? 3*n+1 : n/2;
add edx, 1 # ++count;
cmp rax, 1
jne .L9 #}while(n!=1)
cmp/branch to update max and maxi, and then do the next n
La boucle interne est sans branche, et le chemin critique de la chaîne de dépendance portée par la boucle est:
- LEA à 3 composants (3 cycles)
- cmov (2 cycles sur Haswell, 1c sur Broadwell ou plus tard).
Total: 5 cycles par itération, goulot d'étranglement de latence . L'exécution dans le désordre s'occupe de tout le reste en parallèle avec cela (en théorie: je n'ai pas testé avec des compteurs de performances pour voir s'il fonctionne vraiment à 5c / iter).
L'entrée FLAGS de cmov
(produite par TEST) est plus rapide à produire que l'entrée RAX (de LEA-> MOV), elle n'est donc pas sur le chemin critique.
De même, le MOV-> SHR qui produit l'entrée RDI du CMOV est hors du chemin critique, car il est également plus rapide que le LEA. MOV sur IvyBridge et plus tard n'a aucune latence (géré au moment du changement de nom de registre). (Il faut toujours un uop et un slot dans le pipeline, donc ce n'est pas gratuit, juste une latence nulle). Le MOV supplémentaire dans la chaîne de dépose LEA fait partie du goulot d'étranglement des autres processeurs.
Le cmp / jne ne fait pas non plus partie du chemin critique: il n'est pas transporté en boucle, car les dépendances de contrôle sont gérées avec la prédiction de branche + l'exécution spéculative, contrairement aux dépendances de données sur le chemin critique.
Battre le compilateur
GCC a fait du très bon travail ici. Il pourrait enregistrer un octet de code en utilisant à la inc edx
place deadd edx, 1
, car personne ne se soucie de P4 et de ses fausses dépendances pour les instructions de modification de drapeau partiel.
Il pourrait également enregistrer toutes les instructions MOV, et le TEST: SHR définit CF = le bit décalé, afin que nous puissions utiliser à la cmovc
place de test
/ cmovz
.
### Hand-optimized version of what gcc does
.L9: #do{
lea rcx, [rax+1+rax*2] # rcx = 3*n + 1
shr rax, 1 # n>>=1; CF = n&1 = n%2
cmovc rax, rcx # n= (n&1) ? 3*n+1 : n/2;
inc edx # ++count;
cmp rax, 1
jne .L9 #}while(n!=1)
Voir la réponse de @ johnfound pour une autre astuce: supprimez le CMP en vous ramifiant sur le résultat du drapeau de SHR ainsi qu'en l'utilisant pour CMOV: zéro uniquement si n était 1 (ou 0) pour commencer. (Fait amusant: SHR avec count! = 1 sur Nehalem ou une version antérieure provoque un blocage si vous lisez les résultats de l'indicateur .
Éviter le MOV n'aide pas du tout avec la latence sur Haswell ( le MOV de x86 peut-il vraiment être "gratuit"? Pourquoi ne puis-je pas le reproduire du tout? ). Cela aide considérablement sur les processeurs comme Intel pré-IvB et AMD Bulldozer-family, où MOV n'est pas à latence nulle. Les instructions MOV perdues du compilateur affectent le chemin critique. Le LEA complexe et le CMOV de BD ont tous deux une latence plus faible (2c et 1c respectivement), c'est donc une plus grande fraction de la latence. De plus, les goulots d'étranglement du débit deviennent un problème, car il ne possède que deux canaux ALU entiers. Voir la réponse de @ johnfound , où il a les résultats de synchronisation d'un processeur AMD.
Même sur Haswell, cette version peut aider un peu en évitant certains retards occasionnels où une uop non critique vole un port d'exécution à l'un sur le chemin critique, retardant l'exécution d'un cycle. (Cela s'appelle un conflit de ressources). Il enregistre également un registre, ce qui peut être utile lorsque vous effectuez plusieurs n
valeurs en parallèle dans une boucle entrelacée (voir ci-dessous).
La latence de LEA dépend du mode d'adressage , sur les processeurs de la famille Intel SnB. 3c pour 3 composants ( [base+idx+const]
, ce qui prend deux ajouts séparés), mais seulement 1c avec 2 composants ou moins (un ajout). Certains processeurs (comme Core2) font même un LEA à 3 composants en un seul cycle, mais pas la famille SnB. Pire encore, la famille Intel SnB standardise les latences afin qu'il n'y ait pas d'uops 2c , sinon le LEA à 3 composants ne serait que 2c comme Bulldozer. (Le LEA à 3 composants est également plus lent sur AMD, mais pas autant).
Donc lea rcx, [rax + rax*2]
/ inc rcx
est que la latence 2c, plus rapide que lea rcx, [rax + rax*2 + 1]
sur les processeurs Intel SNB-famille comme Haswell. Le seuil de rentabilité sur BD, et pire sur Core2. Cela coûte un uop supplémentaire, ce qui ne vaut généralement pas la peine d'économiser la latence 1c, mais la latence est le principal goulot d'étranglement ici et Haswell dispose d'un pipeline suffisamment large pour gérer le débit d'uop supplémentaire.
Ni gcc, icc, ni clang (sur godbolt) n'utilisaient la sortie CF de SHR, utilisant toujours un AND ou TEST . Compilateurs stupides. : P Ce sont d'excellentes pièces de machines complexes, mais un humain intelligent peut souvent les battre sur des problèmes à petite échelle. (Étant donné des milliers à des millions de fois plus de temps pour y penser, bien sûr! Les compilateurs n'utilisent pas d'algorithmes exhaustifs pour rechercher toutes les façons possibles de faire les choses, car cela prendrait trop de temps lors de l'optimisation de beaucoup de code en ligne, ce qui est ce que Ils ne modélisent pas non plus le pipeline dans la microarchitecture cible, du moins pas dans les mêmes détails que IACA ou d'autres outils d'analyse statique; ils utilisent simplement quelques heuristiques.)
Le simple déroulement de la boucle n'aidera pas ; ce goulot d'étranglement de boucle sur la latence d'une chaîne de dépendance portée par la boucle, pas sur la surcharge / le débit de la boucle. Cela signifie qu'il ferait bien avec l'hyperthreading (ou tout autre type de SMT), car le processeur a beaucoup de temps pour entrelacer les instructions de deux threads. Cela signifierait paralléliser la boucle main
, mais c'est très bien car chaque thread peut simplement vérifier une plage de n
valeurs et produire une paire d'entiers en conséquence.
L'entrelacement à la main dans un seul thread peut également être viable . Peut-être calculer la séquence pour une paire de nombres en parallèle, car chacun ne prend que quelques registres, et ils peuvent tous mettre à jour le même max
/ maxi
. Cela crée plus de parallélisme au niveau de l'instruction .
L'astuce consiste à décider d'attendre jusqu'à ce que toutes les n
valeurs soient atteintes 1
avant d'obtenir une autre paire de n
valeurs de départ , ou de sortir et d'obtenir un nouveau point de départ pour une seule qui a atteint la condition de fin, sans toucher aux registres de l'autre séquence. Il est probablement préférable de garder chaque chaîne travaillant sur des données utiles, sinon vous devrez incrémenter conditionnellement son compteur.
Vous pourriez peut-être même le faire avec des éléments de comparaison compressés SSE pour incrémenter conditionnellement le compteur des éléments vectoriels qui n
n'avaient pas 1
encore atteint . Et puis, pour masquer la latence encore plus longue d'une implémentation d'incrément conditionnelle SIMD, vous devez garder plus de vecteurs de n
valeurs en l'air. Peut-être ne vaut-il qu'avec le vecteur 256b (4x uint64_t
).
Je pense que la meilleure stratégie pour faire la détection d'un 1
"collant" est de masquer le vecteur de tout-ce que vous ajoutez pour incrémenter le compteur. Donc, après avoir vu un 1
dans un élément, le vecteur d'incrémentation aura un zéro, et + = 0 est un no-op.
Idée non testée pour la vectorisation manuelle
# starting with YMM0 = [ n_d, n_c, n_b, n_a ] (64-bit elements)
# ymm4 = _mm256_set1_epi64x(1): increment vector
# ymm5 = all-zeros: count vector
.inner_loop:
vpaddq ymm1, ymm0, xmm0
vpaddq ymm1, ymm1, xmm0
vpaddq ymm1, ymm1, set1_epi64(1) # ymm1= 3*n + 1. Maybe could do this more efficiently?
vprllq ymm3, ymm0, 63 # shift bit 1 to the sign bit
vpsrlq ymm0, ymm0, 1 # n /= 2
# FP blend between integer insns may cost extra bypass latency, but integer blends don't have 1 bit controlling a whole qword.
vpblendvpd ymm0, ymm0, ymm1, ymm3 # variable blend controlled by the sign bit of each 64-bit element. I might have the source operands backwards, I always have to look this up.
# ymm0 = updated n in each element.
vpcmpeqq ymm1, ymm0, set1_epi64(1)
vpandn ymm4, ymm1, ymm4 # zero out elements of ymm4 where the compare was true
vpaddq ymm5, ymm5, ymm4 # count++ in elements where n has never been == 1
vptest ymm4, ymm4
jnz .inner_loop
# Fall through when all the n values have reached 1 at some point, and our increment vector is all-zero
vextracti128 ymm0, ymm5, 1
vpmaxq .... crap this doesn't exist
# Actually just delay doing a horizontal max until the very very end. But you need some way to record max and maxi.
Vous pouvez et devez implémenter cela avec des éléments intrinsèques au lieu d'un asm manuscrit.
Amélioration algorithmique / implémentation:
Outre l'implémentation de la même logique avec un asm plus efficace, recherchez des moyens de simplifier la logique ou d'éviter les travaux redondants. par exemple mémoriser pour détecter les terminaisons communes aux séquences. Ou encore mieux, regardez 8 bits de fin à la fois (réponse de gnasher)
@EOF souligne que tzcnt
(ou bsf
) pourrait être utilisé pour effectuer plusieurs n/=2
itérations en une seule étape. C'est probablement mieux que la vectorisation SIMD; aucune instruction SSE ou AVX ne peut le faire. n
Cependant, il est toujours compatible avec plusieurs scalaires en parallèle dans différents registres entiers.
La boucle pourrait donc ressembler à ceci:
goto loop_entry; // C++ structured like the asm, for illustration only
do {
n = n*3 + 1;
loop_entry:
shift = _tzcnt_u64(n);
n >>= shift;
count += shift;
} while(n != 1);
Cela peut faire beaucoup moins d'itérations, mais les décalages de nombre variable sont lents sur les processeurs de la famille Intel SnB sans BMI2. 3 uops, latence 2c. (Ils ont une dépendance d'entrée sur les FLAGS car count = 0 signifie que les drapeaux ne sont pas modifiés. Ils gèrent cela comme une dépendance de données et prennent plusieurs uops car un uop ne peut avoir que 2 entrées (pré-HSW / BDW de toute façon)). C'est le genre auquel les gens se plaignant du design fou-CISC de x86 font référence. Cela rend les processeurs x86 plus lents qu'ils ne le seraient si l'ISA était conçu à partir de zéro aujourd'hui, même d'une manière presque similaire. (c'est-à-dire que cela fait partie de la "taxe x86" qui coûte vitesse / puissance.) SHRX / SHLX / SARX (BMI2) sont une grosse victoire (1 uop / 1c de latence).
Il place également tzcnt (3c sur Haswell et versions ultérieures) sur le chemin critique, de sorte qu'il allonge considérablement la latence totale de la chaîne de dépendance portée par la boucle. Cela supprime toutefois le besoin d'un CMOV ou de la préparation d'un registre n>>1
. La réponse de @ Veedrac surmonte tout cela en différant le tzcnt / shift pour plusieurs itérations, ce qui est très efficace (voir ci-dessous).
Nous pouvons utiliser en toute sécurité BSF ou TZCNT de manière interchangeable, car n
il ne peut jamais être nul à ce stade. Le code machine de TZCNT se décode en BSF sur les processeurs qui ne prennent pas en charge BMI1. (Les préfixes sans signification sont ignorés, donc REP BSF s'exécute en tant que BSF).
TZCNT fonctionne beaucoup mieux que BSF sur les processeurs AMD qui le prennent en charge, il peut donc être une bonne idée à utiliser REP BSF
, même si vous ne vous souciez pas de définir ZF si l'entrée est zéro plutôt que la sortie. Certains compilateurs le font lorsque vous utilisez __builtin_ctzll
même avec -mno-bmi
.
Ils fonctionnent de la même manière sur les processeurs Intel, alors enregistrez simplement l'octet si c'est tout ce qui compte. TZCNT sur Intel (pré-Skylake) a toujours une fausse dépendance sur l'opérande de sortie soi-disant en écriture, tout comme BSF, pour prendre en charge le comportement non documenté selon lequel BSF avec entrée = 0 laisse sa destination non modifiée. Vous devez donc contourner cela à moins d'optimiser uniquement pour Skylake, donc il n'y a rien à gagner de l'octet REP supplémentaire. (Intel va souvent au-delà de ce qu'exige le manuel x86 ISA, pour éviter de casser du code largement utilisé qui dépend de quelque chose qu'il ne devrait pas, ou qui est rétroactivement interdit. Par exemple, Windows 9x ne suppose aucune prélecture spéculative des entrées TLB , ce qui était sûr lors de l'écriture du code, avant qu'Intel ne mette à jour les règles de gestion TLB .)
Quoi qu'il en soit, LZCNT / TZCNT sur Haswell ont le même faux dépôt que POPCNT: voir ce Q&R . C'est pourquoi dans la sortie asm de gcc pour le code de @ Veedrac, vous le voyez briser la chaîne dep avec la mise à zéro xor sur le registre qu'il est sur le point d'utiliser comme destination de TZCNT quand il n'utilise pas dst = src. Étant donné que TZCNT / LZCNT / POPCNT ne laissent jamais leur destination indéfinie ou non modifiée, cette fausse dépendance à la sortie sur les processeurs Intel est un bug / limitation de performances. Vraisemblablement, cela vaut certains transistors / puissance pour qu'ils se comportent comme les autres uops qui vont à la même unité d'exécution. Le seul avantage positif est l'interaction avec une autre limitation d'uarch: ils peuvent micro-fusionner un opérande de mémoire avec un mode d'adressage indexé sur Haswell, mais sur Skylake où Intel a supprimé le faux dépôt pour LZCNT / TZCNT, ils "décollent" les modes d'adressage indexés tandis que POPCNT peut toujours micro-fusionner n'importe quel mode addr.
Améliorations des idées / du code à partir d'autres réponses:
La réponse de @ hidefromkgb a une belle observation que vous êtes assuré de pouvoir effectuer un décalage à droite après un 3n + 1. Vous pouvez calculer cela de manière encore plus efficace que de laisser de côté les contrôles entre les étapes. L'implémentation asm dans cette réponse est cassée, cependant (cela dépend de OF, qui n'est pas défini après SHRD avec un nombre> 1), et lente: ROR rdi,2
est plus rapide que SHRD rdi,rdi,2
, et l'utilisation de deux instructions CMOV sur le chemin critique est plus lente qu'un TEST supplémentaire qui peut fonctionner en parallèle.
J'ai mis du C rangé / amélioré (qui guide le compilateur pour produire un meilleur asm), et testé + travaillant plus rapidement asm (dans les commentaires ci-dessous le C) sur Godbolt: voir le lien dans la réponse de @ hidefromkgb . (Cette réponse a atteint la limite de 30k caractères des grandes URL Godbolt, mais les liens courts peuvent pourrir et étaient trop longs pour goo.gl de toute façon.)
Amélioration de l'impression de sortie pour convertir en chaîne et en créer une write()
au lieu d'écrire un caractère à la fois. Cela minimise l'impact sur la synchronisation de l'ensemble du programme avec perf stat ./collatz
(pour enregistrer les compteurs de performance), et j'ai désobscurci une partie de l'asm non critique.
@ Le code de Veedrac
J'ai obtenu une accélération mineure en déplaçant à droite autant que nous le savons , et en vérifiant pour continuer la boucle. De 7,5 s pour limite = 1e8 à 7,275 s, sur Core2Duo (Merom), avec un facteur de déroulement de 16.
code + commentaires sur Godbolt . N'utilisez pas cette version avec clang; il fait quelque chose de stupide avec la boucle de report. Utiliser un compteur tmp k
et l'ajouter ensuite pour count
changer plus tard ce que fait clang, mais cela blesse légèrement gcc.
Voir la discussion dans les commentaires: Le code de Veedrac est excellent sur les CPU avec BMI1 (c'est-à-dire pas Celeron / Pentium)