web-dev-qa-db-fra.com

Code C ++ pour tester la conjecture de Collatz plus rapidement qu'un assemblage écrit à la main - pourquoi?

J'ai écrit ces deux solutions pour Project Euler Q14 , en Assembly et en C++. Ce sont la même approche de force brute identique pour tester le conjecture de Collatz . La solution d'assemblage a été assemblée avec

nasm -felf64 p14.asm && gcc p14.o -o p14

Le C++ a été compilé avec

g++ p14.cpp -o p14

Assemblage, p14.asm

section .data
    fmt db "%d", 10, 0

global main
extern printf

section .text

main:
    mov rcx, 1000000
    xor rdi, rdi        ; max i
    xor rsi, rsi        ; i

l1:
    dec rcx
    xor r10, r10        ; count
    mov rax, rcx

l2:
    test rax, 1
    jpe even

    mov rbx, 3
    mul rbx
    inc rax
    jmp c1

even:
    mov rbx, 2
    xor rdx, rdx
    div rbx

c1:
    inc r10
    cmp rax, 1
    jne l2

    cmp rdi, r10
    cmovl rdi, r10
    cmovl rsi, rcx

    cmp rcx, 2
    jne l1

    mov rdi, fmt
    xor rax, rax
    call printf
    ret

C++, p14.cpp

#include <iostream>

using namespace std;

int sequence(long n) {
    int count = 1;
    while (n != 1) {
        if (n % 2 == 0)
            n /= 2;
        else
            n = n*3 + 1;

        ++count;
    }

    return count;
}

int main() {
    int max = 0, maxi;
    for (int i = 999999; i > 0; --i) {
        int s = sequence(i);
        if (s > max) {
            max = s;
            maxi = i;
        }
    }

    cout << maxi << endl;
}

Je connais les optimisations du compilateur pour améliorer la vitesse et tout, mais je ne vois pas beaucoup de façons d’optimiser davantage ma solution d’Assembly (parler de manière programmatique et non mathématique).

Le code C++ a un module pour chaque terme et une division pour chaque terme pair, où Assembly ne représente qu'une division par terme pair.

Mais l’Assemblée prend en moyenne une seconde de plus que la solution C++. Pourquoi est-ce? Je demande par curiosité principalement.

Temps d'exécution

Mon système: Linux 64 bits sur Intel Celeron 2955U à 1,4 GHz (microarchitecture Haswell).

793
jeffer son

Si vous pensez qu'une instruction DIV 64 bits est un bon moyen de diviser par deux, il n’est pas étonnant que la sortie asm du compilateur batte votre code manuscrit, même avec _-O0_ (compilation rapide, sans optimisation supplémentaire, et stocker/recharger en mémoire après/avant chaque instruction C afin qu'un débogueur puisse modifier des variables).

Reportez-vous à Guide d’assemblage optimisé d’Agner Fog pour apprendre à écrire un asm efficace. Il dispose également de tables d'instructions et d'un guide microarch pour des détails spécifiques sur des processeurs spécifiques. Voir aussi le wiki x86 pour plus de liens parfaits.

Voir aussi cette question plus générale sur le fait de battre le compilateur avec asm écrit à la main: Le langage d'assemblage en-ligne est-il plus lent que le code C++ natif? . TL: DR: oui si vous le faites mal (comme cette question).

Généralement, vous pouvez laisser le compilateur agir, en particulier si vous essayez d’écrire en C++ pour pouvoir compiler efficacement . Voir aussi Assembly est-il plus rapide que les langages compilés? . Une des réponses renvoie à ces diapositives bien ordonnées montrant comment divers compilateurs C optimisent des fonctions très simples avec des astuces géniales.


_even:
    mov rbx, 2
    xor rdx, rdx
    div rbx
_

Sur Intel Haswell, div r64 est égal à 36 uops, avec une latence de 32 à 96 cycles et un débit d'un par 21 à 74 cycles. (En plus des 2 uops pour configurer RBX et zéro RDX, mais une exécution dans le désordre peut s'exécuter plus tôt). Les instructions à comptage élevé telles que DIV sont microcodées, ce qui peut également causer des goulots d'étranglement frontaux. Dans ce cas, le temps de latence est le facteur le plus important, car il fait partie d'une chaîne de dépendance acheminée par la boucle.

_shr rax, 1_ fait la même division non signée: 1 uop, avec une latence de 1c , et peut en exécuter 2 par cycle d'horloge.

À titre de comparaison, la division 32 bits est plus rapide, mais reste horrible par rapport aux changements. _idiv r32_ correspond à 9 uops, une latence de 22 à 29c et un débit par 8 à 11c sur Haswell.


Comme vous pouvez le constater en regardant le résultat de asm _-O0_ de gcc ( Explorateur du compilateur Godbolt ), il utilise uniquement 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 de IDIV lorsque la source effectue une division et un module avec les mêmes opérandes, s'ils utilisent IDIV du tout.)

GCC n'a pas un mode totalement naïf; il se transforme toujours via GIMPLE, ce qui signifie que certaines "optimisations" ne peuvent pas être désactivées . Cela inclut la reconnaissance de division par constante et l’utilisation de décalages (puissance de 2) ou n inverse multiplicatif à virgule fixe (non puissance de 2) pour éviter IDIV (voir _div_by_13_ dans le godbolt ci-dessus lien).

_gcc -Os_ (optimiser pour la taille) utilise IDIV pour la division non power-of-2, malheureusement même dans les cas où le code inverse multiplicatif est à peine plus grand mais beaucoup plus rapide.


Aider le compilateur

(résumé pour ce cas: utilisez _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 pas de sens.

Regardez votre sortie asm (sur Godbolt, ou voyez Comment supprimer le "bruit" de la sortie GCC/clang Assembly? ). Lorsque le compilateur ne crée pas le code optimal en premier lieu: Écrire votre source C/C++ de manière à guider le compilateur pour améliorer le code est généralement la meilleure approche . Vous devez connaître asm et savoir ce qui est efficace, mais vous appliquez cette connaissance indirectement. Les compilateurs sont aussi une bonne source d’idées: parfois, clang fera quelque chose de sympa, et vous pouvez tenir gcc en même temps: voir cette réponse et ce que j’ai fait avec la boucle non déroulée Le code de @ Veedrac ci-dessous.)

Cette approche est portable et, dans 20 ans, certains futurs compilateurs pourront la compiler en fonction de ce qui sera efficace sur les futurs matériels (x86 ou non), en utilisant éventuellement la nouvelle extension ISA ou la vectorisation automatique. Les x86-64 asm manuscrites d'il y a 15 ans ne sont généralement pas optimisées 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 l'être pour d'autres CPU actuels et futurs. Commentaires sur la réponse de @ johnfound discuter des différences majeures entre AMD Bulldozer et Intel Haswell, qui ont un impact important sur ce code. Mais en théorie, _g++ -O3 -march=bdver3_ et _g++ -O3 -march=skylake_ feront la bonne chose. (Ou _-march=native_.) Ou _-mtune=..._ pour régler, sans utiliser les instructions que d'autres processeurs pourraient ne pas prendre en charge.

Mon sentiment est que guider le compilateur pour qu'il soit bon pour un processeur actuel qui vous tient à cœur 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 les pièges spécifiques à asm tout en implémentant quelque chose comme le mouvement de données de votre source C, s'il ne voit pas mieux.

Asm écrit à la main est une boîte noire pour l'optimiseur. Par conséquent, la propagation constante ne fonctionne pas lorsque l'inline fait en sorte qu'une entrée soit une constante de compilation. D'autres optimisations sont également affectées. Lisez https://gcc.gnu.org/wiki/DontUseInlineAsm avant d'utiliser asm. (Et évitez les ASM en ligne de style MSVC: les entrées/sorties doivent passer par la mémoire ce qui ajoute une surcharge .)

Dans ce cas : votre n a un type signé, et gcc utilise la séquence SAR/SHR/ADD qui donne le bon arrondi. (IDIV et décalage arithmétique "arrondi" différemment pour les entrées négatives, voir SAR saisie manuelle de référence ). (IDK si gcc a essayé et échoué à prouver que n ne peut pas être négatif, ou quoi. Signed-overflow est un comportement non défini, il aurait donc dû pouvoir.)

Vous auriez dû utiliser _uint64_t n_ pour pouvoir simplement SHR. Et donc, il est portable pour les systèmes où long n’est que 32 bits (Windows x86-64, par exemple).


BTW, gcc optimisé la sortie asm est plutôt jolie (avec _unsigned long n_) : la partie interne le boucle inline dans 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 n'a pas de branche et le chemin critique de la chaîne de dépendance véhiculée par la boucle est le suivant:

  • 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 hors service s’occupe de tout le reste en parallèle (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 (à partir de LEA-> MOV), elle n'est donc pas sur le chemin critique.

De même, le MOV-> SHR qui produit l'entrée RDI de CMOV est en dehors du chemin critique, car il est également plus rapide que le LEA. MOV sur IvyBridge et versions ultérieures a une latence nulle (gérée au moment de la modification du registre). (Il faut tout de même un uop et un créneau dans le pipeline, donc ce n’est pas gratuit, mais une latence nulle). Le MOV supplémentaire dans la chaîne LEA Dep 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 acheminé en boucle, car les dépendances de contrôle sont gérées avec une prédiction de branche + une exécution spéculative, contrairement aux dépendances de données sur le chemin critique.


Battre le compilateur

GCC a fait un très bon travail ici. Il pourrait économiser un octet de code en utilisant inc edx_ AU LIEU DE _add edx, 1 , car personne ne se soucie de P4 et de ses fausses dépendances pour les instructions de modification partielle du drapeau.

Cela pourrait aussi sauvegarder toutes les instructions MOV, et le TEST: SHR initialise CF = le bit décalé, afin que nous puissions utiliser cmovc au lieu 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 intelligente: supprimez le CMP en créant une branche sur le résultat du drapeau de SHR et en l'utilisant pour CMOV: zéro uniquement si n était égal à 1 (ou 0). (Fait amusant: SHR avec compte! = 1 sur Nehalem ou avant provoque un décrochage si vous lisez les résultats du drapea . C'est comme cela qu'ils l'ont fait en mode simple. Le codage spécial en décalage par 1 convient parfaitement , bien que.)

Eviter MOV n'aide pas du tout la latence sur Haswell ( Le MOV de x86 peut-il vraiment être "gratuit"? Pourquoi ne puis-je pas reproduire cela du tout? ). Cela aide de manière significative sur les processeurs comme Intel pré-IvB et la famille de bulldozers AMD, où MOV n’est pas à latence nulle. Les instructions MOV gâchées par le compilateur affectent le chemin critique. Les complexes LEA et CMOV de BD ont tous deux une latence inférieure (2c et 1c respectivement), ce qui en fait une fraction plus importante de la latence. En outre, les goulots d'étranglement de débit deviennent un problème, car il ne comporte que deux canaux ALU entiers. Voir la réponse de @ johnfound , où il dispose des résultats de synchronisation d'un processeur AMD.

Même sur Haswell, cette version peut aider un peu en évitant certains retards occasionnels dans lesquels un utilisateur non critique vole un port d’exécution à un port situé sur le chemin critique, retardant ainsi l’exécution de 1 cycle. (Cela s'appelle un conflit de ressources). Il enregistre également un registre, ce qui peut être utile lors de la mise en parallèle de plusieurs valeurs n dans une boucle entrelacée (voir ci-dessous).

La latence de LEA dépend du mode d'adressage , des processeurs de la famille Intel SnB. 3c pour 3 composants (_[base+idx+const]_, 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 la famille SnB ne le fait pas. Pire encore, la famille Intel SnB normalise les latences de sorte qu'il n'y ait pas d'uops 2c , sinon le LEA à 3 composants serait seulement 2c comme Bulldozer. (La LEA à 3 composants ralentit également la DMLA, mais pas autant).

Donc _lea rcx, [rax + rax*2]_/_inc rcx_ n’est qu’une latence de 2c, plus rapide que _lea rcx, [rax + rax*2 + 1]_, sur les processeurs de la famille Intel SnB comme Haswell. Rentabilité sur BD et pire sur Core2. Cela coûte un uop supplémentaire, ce qui n'est normalement pas la peine d'économiser du temps de latence, mais le temps d'attente est le principal goulot d'étranglement et Haswell dispose d'un pipeline suffisamment large pour gérer le débit supplémentaire.

Ni gcc, icc, ni clang (sur godbolt) n'utilisaient la sortie CF de SHR, toujours en utilisant un AND ou un TEST . Compilateurs stupides. : P Ce sont d'excellentes pièces de machinerie complexe, mais un humain intelligent peut souvent les vaincre pour des problèmes mineurs. (Bien entendu, des milliers, des millions de fois plus de temps à y penser! Les compilateurs n'utilisent pas des algorithmes exhaustifs pour rechercher toutes les méthodes possibles, car cela prendrait trop de temps pour optimiser beaucoup de code en ligne. Ils ne modélisent pas non plus le pipeline dans la microarchitecture cible, du moins pas avec les mêmes détails que IACA ou d’autres outils d’analyse statique; ils utilisent simplement des méthodes heuristiques.)


Le déroulage d'une boucle simple ne vous aidera pas ; ces goulots d'étranglement de boucle sur la latence d'une chaîne de dépendance véhiculée par la boucle, et non sur le temps système de traitement/le débit de la boucle. Cela signifie que cela fonctionnerait bien avec l'hyperthreading (ou tout autre type de SMT), car le processeur dispose de beaucoup de temps pour entrelacer les instructions de deux threads. Cela impliquerait de paralléliser la boucle dans main, mais c'est bien comme cela, chaque thread peut simplement vérifier une plage de valeurs n et produire ainsi une paire d'entiers.

Un entrelacement manuel dans un seul thread pourrait également être viable . Peut-être calculer la séquence pour une paire de nombres en parallèle, puisque chacun ne prend que deux registres et qu'ils peuvent tous mettre à jour le même max/maxi. Cela crée plus de parallélisme au niveau instruction .

L’astuce consiste à décider s’il faut attendre que toutes les valeurs n aient atteint _1_ avant d’obtenir une autre paire de valeurs de départ n, ou s’il faut sortir et 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 laisser chaque chaîne travailler sur des données utiles, sinon vous devrez incrémenter son compteur de manière conditionnelle.


Vous pourriez peut-être même faire cela avec SSE objets de comparaison-emballés pour incrémenter conditionnellement le compteur d'éléments vectoriels où n n'avait pas encore atteint _1_. Ensuite, pour masquer la latence encore plus longue d'une implémentation à incrément conditionnel SIMD, vous devez conserver davantage de vecteurs de valeurs n. Peut-être ne vaut-il que le vecteur 256b (4x _uint64_t_).

Je pense que la meilleure stratégie pour détecter un _1_ "collant" est de masquer le vecteur de tous les uns que vous ajoutez pour incrémenter le compteur. Ainsi, après avoir vu un _1_ dans un élément, le vecteur d'incrémentation aura un zéro et + = 0 est un non-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

    # There may be a better way to do this blend, avoiding the bypass delay for an FP blend between integer insns, not sure.  Probably worth it
    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’asm écrit à la main.


Amélioration algorithmique/implémentation:

En plus d’implémenter la même logique avec un asm plus efficace, cherchez des moyens de la simplifier ou d’éviter les tâches redondantes. par exemple. mémoize pour détecter les fins communes des séquences. Ou même mieux, regardez 8 bits de fuite à la fois (la réponse de gnasher)

@EOF indique que tzcnt (ou bsf) pourrait être utilisé pour effectuer plusieurs itérations _n/=2_ en une étape. C'est probablement mieux que la vectorisation SIMD, car aucune instruction SSE ni AVX ne peut le faire. Il est toujours compatible avec la possibilité de faire plusieurs _ scalaires ns en parallèle dans des registres entiers différents.

Donc, la boucle pourrait 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 à compte 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 la traitent 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 font référence les personnes qui se plaignent de la conception folle-CISC de x86. Cela rend les processeurs x86 plus lents qu'ils ne le seraient si le ISA était conçu de toutes pièces aujourd'hui, même de manière presque similaire. (c’est-à-dire que cela fait partie de la "taxe x86" qui coûte de la vitesse/puissance.) SHRX/SHLX/SARX (BMI2) sont une grande victoire (latence 1 uop/1c).

Il place également tzcnt (3c sur Haswell et plus tard) sur le chemin critique, ce qui allonge considérablement la latence totale de la chaîne de dépendance acheminée par la boucle. Cela supprime toutefois la nécessité d'un CMOV ou de la préparation d'un registre contenant _n>>1_. @ La réponse de Veedrac résout 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 ne peut jamais être nul à ce stade. Le code machine de TZCNT se décode en tant que BSF sur les CPU ne prenant pas en charge BMI1. (Les préfixes sans signification sont ignorés, REP BSF est donc exécuté en tant que BSF).

TZCNT fonctionne beaucoup mieux que BSF sur les processeurs AMD qui le supportent. Il peut donc être judicieux d’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 font cela lorsque vous utilisez ___builtin_ctzll_ même avec _-mno-bmi_.

Ils fonctionnent de la même manière sur les processeurs Intel. Enregistrez donc simplement l’octet si c’est tout ce qui compte. TZCNT sur Intel (avant Skylake) a toujours une fausse dépendance sur l'opérande de sortie supposé en écriture seule, tout comme BSF, pour prendre en charge le comportement non documenté selon lequel BSF avec input = 0 laisse sa destination non modifiée. Vous devez donc contourner ce problème, à moins d’optimiser uniquement pour Skylake. Il n’ya donc rien à gagner de l’octet REP supplémentaire. (Intel va souvent au-delà de ce que le manuel x86 ISA requiert, 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 suppose pas de prélecture spéculative d'entrées TLB , ce qui était sûr au moment 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 & A . C'est pourquoi, dans la sortie asm de gcc pour le code de @ Veedrac, vous voyez ceci briser la chaîne dep avec xor-zeroing sur le registre, il est sur le point de l'utiliser comme destination de TZCNT, lorsqu'il n'utilise pas dst = src . Etant donné que TZCNT/LZCNT/POPCNT ne laisse jamais leur destination indéfinie ou modifiée, cette fausse dépendance de la sortie sur les processeurs Intel est purement un bug de performance. Vraisemblablement, certains transistors/puissances valent la peine de les faire se comporter comme d’autres uops qui vont à la même unité d’exécution. Le seul logiciel visible par le logiciel réside dans l'interaction avec une autre limitation microarchitecturale: 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é la fausse dépendance pour LZCNT/TZCNT "désagrègent" les modes d'adressage indexé, tandis que POPCNT peut toujours micro-fusionner n'importe quel mode addr.


Améliorations apportées aux idées/codes à partir d'autres réponses:

La réponse de @ hidefromkgb indique que vous êtes assuré de pouvoir effectuer un décalage à droite après un 3n + 1. Vous pouvez calculer cela encore plus efficacement que de simplement laisser de côté les vérifications entre les étapes. L'implémentation asm de cette réponse est cependant brisée (elle dépend de OF, qui n'est pas défini après SHRD avec un nombre> 1) et de slow: _ROR rdi,2_ est plus rapide que _SHRD rdi,rdi,2_ et utilise deux instructions CMOV. sur le chemin critique est plus lent qu'un TEST supplémentaire pouvant s'exécuter en parallèle.

Je mets C bien rangé/amélioré (qui guide le compilateur pour produire un meilleur asm), et je teste + travaille plus vite (dans les commentaires sous le C) jusqu'à Godbolt: voir le lien dans réponse de @ hidefromkgb . (Cette réponse a atteint la limite de 30 000 caractères des grandes URL Godbolt, mais les liens courts peuvent pourrir et étaient trop longs pour goo.gl de toute façon.)

Également amélioré l'impression de sortie pour convertir en chaîne et créer un write() au lieu d'écrire un caractère à la fois. Ceci minimise l'impact sur le chronométrage de l'ensemble du programme avec _perf stat ./collatz_ (pour enregistrer les compteurs de performance), et j'ai dés-obfusqué certains des asm non critiques.


@ Le code de Veedrac

J'ai eu une très petite accélération de virer à droite autant que nous savons que nous avons besoin de faire, et en vérifiant de continuer la boucle. De 7,5s pour limit = 1e8 à 7.275s, sur Core2Duo (Merom), avec un facteur de déroulement de 16.

code + commentaires sur Godbolt . N'utilisez pas cette version avec clang; cela fait quelque chose d'idiot avec la boucle différée. Utiliser un compteur tmp k puis l'ajouter à count change ce que fait Clang, mais cela légèrement blesse gcc.

Voir la discussion dans les commentaires: le code de Veedrac est excellent sur les processeurs avec BMI1 (c'est-à-dire pas Celeron/Pentium)

1834
Peter Cordes

Affirmer que le compilateur C++ peut produire un code plus optimal qu'un programmeur compétent en langage Assembly est une très grave erreur. Et surtout dans ce cas. L'homme peut toujours améliorer le code autant que le compilateur, et cette situation particulière illustre bien cette affirmation.

La différence de temps que vous voyez est due au fait que le code d'assemblage de la question est très loin d'être optimal dans les boucles internes.

(Le code ci-dessous est 32 bits, mais peut être facilement converti en 64 bits)

Par exemple, la fonction de séquence ne peut être optimisée que pour 5 instructions:

    .seq:
        inc     esi                 ; counter
        lea     edx, [3*eax+1]      ; edx = 3*n+1
        shr     eax, 1              ; eax = n/2
        cmovc   eax, edx            ; if CF eax = edx
        jnz     .seq                ; jmp if n<>1

Le code entier ressemble à:

include "%lib%/freshlib.inc"
@BinaryType console, compact
options.DebugMode = 1
include "%lib%/freshlib.asm"

start:
        InitializeAll
        mov ecx, 999999
        xor edi, edi        ; max
        xor ebx, ebx        ; max i

    .main_loop:

        xor     esi, esi
        mov     eax, ecx

    .seq:
        inc     esi                 ; counter
        lea     edx, [3*eax+1]      ; edx = 3*n+1
        shr     eax, 1              ; eax = n/2
        cmovc   eax, edx            ; if CF eax = edx
        jnz     .seq                ; jmp if n<>1

        cmp     edi, esi
        cmovb   edi, esi
        cmovb   ebx, ecx

        dec     ecx
        jnz     .main_loop

        OutputValue "Max sequence: ", edi, 10, -1
        OutputValue "Max index: ", ebx, 10, -1

        FinalizeAll
        stdcall TerminateAll, 0

Afin de compiler ce code, FreshLib est nécessaire.

Dans mes tests (processeur AMD A4-1200 à 1 GHz), le code ci-dessus est environ quatre fois plus rapide que le code C++ de la question (lorsqu’il est compilé avec -O0: 430 ms contre 1900 ms), et plus de deux fois plus rapide (430 ms contre 830 ms) lorsque le code C++ est compilé avec -O3.

La sortie des deux programmes est la même: séquence max = 525 sur i = 837799.

97
johnfound

Pour plus de performance: Un simple changement consiste à observer qu'après n = 3n + 1, n sera pair, vous pouvez donc diviser par 2 immédiatement. Et n ne sera pas 1, vous n'avez donc pas besoin de le tester. Ainsi, vous pouvez enregistrer quelques instructions if et écrire:

while (n % 2 == 0) n /= 2;
if (n > 1) for (;;) {
    n = (3*n + 1) / 2;
    if (n % 2 == 0) {
        do n /= 2; while (n % 2 == 0);
        if (n == 1) break;
    }
}

Voici un grand gagnant: Si vous regardez les 8 bits les plus bas de n, toutes les étapes jusqu’à ce que vous divisiez par 8 fois sont complètement déterminées par ces huit bits. Par exemple, si les huit derniers bits sont 0x01, c’est en binaire que votre numéro est ???? 0000 0001 puis les prochaines étapes sont les suivantes:

3n+1 -> ???? 0000 0100
/ 2  -> ???? ?000 0010
/ 2  -> ???? ??00 0001
3n+1 -> ???? ??00 0100
/ 2  -> ???? ???0 0010
/ 2  -> ???? ???? 0001
3n+1 -> ???? ???? 0100
/ 2  -> ???? ???? ?010
/ 2  -> ???? ???? ??01
3n+1 -> ???? ???? ??00
/ 2  -> ???? ???? ???0
/ 2  -> ???? ???? ????

Donc, toutes ces étapes peuvent être prédites, et 256k + 1 est remplacé par 81k + 1. Quelque chose de similaire se produira pour toutes les combinaisons. Vous pouvez donc créer une boucle avec une grosse instruction switch:

k = n / 256;
m = n % 256;

switch (m) {
    case 0: n = 1 * k + 0; break;
    case 1: n = 81 * k + 1; break; 
    case 2: n = 81 * k + 1; break; 
    ...
    case 155: n = 729 * k + 425; break;
    ...
}

Exécutez la boucle jusqu’à n ≤ 128, car à ce stade, n peut devenir 1 avec moins de huit divisions par 2, et effectuer huit étapes ou plus à la fois vous ferait perdre le point où vous atteignez 1 pour la première fois. Continuez ensuite la boucle "normale" - ou préparez un tableau qui vous indique combien d’étapes supplémentaires sont nécessaires pour atteindre 1.

PS Je soupçonne fortement la suggestion de Peter Cordes d'accélérer les choses. Il n'y aura aucune branche conditionnelle du tout sauf une, et celle-ci sera prédite correctement sauf lorsque la boucle se termine réellement. Donc, le code serait quelque chose comme

static const unsigned int multipliers [256] = { ... }
static const unsigned int adders [256] = { ... }

while (n > 128) {
    size_t lastBits = n % 256;
    n = (n >> 8) * multipliers [lastBits] + adders [lastBits];
}

En pratique, vous évalueriez si le traitement des 9, 10, 11, 12 derniers bits de n à la fois serait plus rapide. Pour chaque bit, le nombre d'entrées dans la table doublerait et j'entraînerais un ralentissement lorsque les tables ne rentrent plus dans le cache L1.

PPS. Si vous avez besoin du nombre d'opérations: Dans chaque itération, nous faisons exactement huit divisions sur deux et un nombre variable d'opérations (3n + 1). Une méthode évidente pour compter les opérations serait donc un autre tableau. Mais nous pouvons réellement calculer le nombre d'étapes (en fonction du nombre d'itérations de la boucle).

Nous pourrions redéfinir légèrement le problème: remplacez n par (3n + 1)/2 si impair, et remplacez par n/2 si pair. Ensuite, chaque itération fera exactement 8 étapes, mais vous pourriez envisager de tricher :-) Supposons donc qu'il y avait r opérations n <- 3n + 1 et s opérations n <- n/2. Le résultat sera exactement exactement n '= n * 3 ^ r/2 ^ s, car n <- 3n + 1 signifie n <- 3n * (1 + 1/3n). En prenant le logarithme, nous trouvons r = (s + log2 (n '/ n))/log2 (3).

Si nous faisons la boucle jusqu'à n ≤ 1 000 000 et si nous avons une table précalculée, combien d'itérations sont nécessaires à partir de tout point de départ n ≤ 1 000 000, puis le calcul de r comme ci-dessus, arrondi à l'entier le plus proche, donnera le bon résultat, à moins que s ne soit vraiment grand.

21
gnasher729

Sur une note plutôt indépendante: plus de piratages de performance!

  • [la première "conjecture" a finalement été démystifiée par @ShreevatsaR; enlevé]

  • En parcourant la séquence, on ne peut avoir que 3 cas possibles dans le voisinage voisin de l'élément en cours N (affiché en premier):

    1. [même bizarre]
    2. [impair] [pair]
    3. [même] [même]

    Dépasser ces 2 éléments signifie calculer (N >> 1) + N + 1, ((N << 1) + N + 1) >> 1 et N >> 2, respectivement.

    Montrons que dans les deux cas (1) et (2), il est possible d'utiliser la première formule, (N >> 1) + N + 1.

    Le cas (1) est évident. Le cas (2) implique (N & 1) == 1, donc si nous supposons (sans perte de généralité) que N a une longueur de 2 bits et que ses bits sont ba du plus significatif au moins significatif, alors a = 1, et les prises suivantes:

    (N << 1) + N + 1:     (N >> 1) + N + 1:
    
            b10                    b1
             b1                     b
           +  1                   + 1
           ----                   ---
           bBb0                   bBb
    

    B = !b. En décalant à droite le premier résultat, nous obtenons exactement ce que nous voulons.

    Q.E.D .: (N & 1) == 1 ⇒ (N >> 1) + N + 1 == ((N << 1) + N + 1) >> 1.

    Comme prouvé, nous pouvons parcourir la séquence 2 éléments à la fois, en utilisant une seule opération ternaire. Une autre réduction du temps 2 ×.

L'algorithme résultant ressemble à ceci:

uint64_t sequence(uint64_t size, uint64_t *path) {
    uint64_t n, i, c, maxi = 0, maxc = 0;

    for (n = i = (size - 1) | 1; i > 2; n = i -= 2) {
        c = 2;
        while ((n = ((n & 3)? (n >> 1) + n + 1 : (n >> 2))) > 2)
            c += 2;
        if (n == 2)
            c++;
        if (c > maxc) {
            maxi = i;
            maxc = c;
        }
    }
    *path = maxc;
    return maxi;
}

int main() {
    uint64_t maxi, maxc;

    maxi = sequence(1000000, &maxc);
    printf("%llu, %llu\n", maxi, maxc);
    return 0;
}

Ici, nous comparons n > 2 parce que le processus peut s’arrêter à 2 au lieu de 1 si la longueur totale de la séquence est impair.

[MODIFIER:]

Traduisons cela en Assemblée!

MOV RCX, 1000000;



DEC RCX;
AND RCX, -2;
XOR RAX, RAX;
MOV RBX, RAX;

@main:
  XOR RSI, RSI;
  LEA RDI, [RCX + 1];

  @loop:
    ADD RSI, 2;
    LEA RDX, [RDI + RDI*2 + 2];
    SHR RDX, 1;
    SHRD RDI, RDI, 2;    ror rdi,2   would do the same thing
    CMOVL RDI, RDX;      Note that SHRD leaves OF = undefined with count>1, and this doesn't work on all CPUs.
    CMOVS RDI, RDX;
    CMP RDI, 2;
  JA @loop;

  LEA RDX, [RSI + 1];
  CMOVE RSI, RDX;

  CMP RAX, RSI;
  CMOVB RAX, RSI;
  CMOVB RBX, RCX;

  SUB RCX, 2;
JA @main;



MOV RDI, RCX;
ADD RCX, 10;
Push RDI;
Push RCX;

@itoa:
  XOR RDX, RDX;
  DIV RCX;
  ADD RDX, '0';
  Push RDX;
  TEST RAX, RAX;
JNE @itoa;

  Push RCX;
  LEA RAX, [RBX + 1];
  TEST RBX, RBX;
  MOV RBX, RDI;
JNE @itoa;

POP RCX;
INC RDI;
MOV RDX, RDI;

@outp:
  MOV RSI, RSP;
  MOV RAX, RDI;
  SYSCALL;
  POP RAX;
  TEST RAX, RAX;
JNE @outp;

LEA RAX, [RDI + 59];
DEC RDI;
SYSCALL;

Utilisez ces commandes pour compiler:

nasm -f elf64 file.asm
ld -o file file.o

Voir le C et une version améliorée/corrigée de l'ASM par Peter Cordes sur Godbolt . (note de l'éditeur: désolé d'avoir mis mes informations dans votre réponse, mais ma réponse a atteint la limite de 30 000 caractères des liens Godbolt + texte!)

18
hidefromkgb

Les programmes C++ sont traduits en programmes Assembly lors de la génération du code machine à partir du code source. Il serait presque faux de dire que Assembly est plus lent que C++. De plus, le code binaire généré diffère d'un compilateur à l'autre. Donc, un compilateur C++ intelligent peut produire un code binaire plus optimal et efficace que le code d’un assembleur muet.

Cependant, je pense que votre méthodologie de profilage présente certains défauts. Voici des instructions générales pour le profilage:

  1. Assurez-vous que votre système est dans son état normal/inactif. Arrêtez tous les processus en cours (applications) que vous avez démarrés ou qui utilisent intensément le processeur (ou interrogez le réseau).
  2. Votre taille de données doit être plus grande.
  3. Votre test doit durer plus de 5 à 10 secondes.
  4. Ne comptez pas sur un seul échantillon. Effectuez votre test N fois. Recueillir les résultats et calculer la moyenne ou la médiane du résultat.

Pour le problème Collatz, vous pouvez augmenter considérablement les performances en mettant en cache les "queues". C'est un compromis temps/mémoire. Voir: memoization ( https://en.wikipedia.org/wiki/Memoization ). Vous pouvez également rechercher des solutions de programmation dynamiques pour d’autres compromis temps/mémoire.

Exemple python implémentation:

import sys

inner_loop = 0

def collatz_sequence(N, cache):
    global inner_loop

    l = [ ]
    stop = False
    n = N

    tails = [ ]

    while not stop:
        inner_loop += 1
        tmp = n
        l.append(n)
        if n <= 1:
            stop = True  
        Elif n in cache:
            stop = True
        Elif n % 2:
            n = 3*n + 1
        else:
            n = n // 2
        tails.append((tmp, len(l)))

    for key, offset in tails:
        if not key in cache:
            cache[key] = l[offset:]

    return l

def gen_sequence(l, cache):
    for elem in l:
        yield elem
        if elem in cache:
            yield from gen_sequence(cache[elem], cache)
            raise StopIteration

if __== "__main__":
    le_cache = {}

    for n in range(1, 4711, 5):
        l = collatz_sequence(n, le_cache)
        print("{}: {}".format(n, len(list(gen_sequence(l, le_cache)))))

    print("inner_loop = {}".format(inner_loop))
5

Même sans regarder Assembly, la raison la plus évidente est que /= 2 est probablement optimisé en tant que >>=1 et de nombreux processeurs fonctionnent très rapidement. Mais même si un processeur ne subit pas d'opération de décalage, la division entière est plus rapide que la division à virgule flottante.

Edit: votre kilométrage peut varier dans l’instruction "la division entière est plus rapide que la division en virgule flottante" ci-dessus. Les commentaires ci-dessous révèlent que les processeurs modernes ont privilégié l'optimisation de la division fp par rapport à la division entière. Donc, si quelqu'un cherchait la raison la plus probable de l'accélération évoquée par la question de ce fil, le compilateur optimisant /=2 comme >>=1 serait la meilleure 1ère place à regarder.


Sur une note sans rapport , si n est impair, l'expression n*3+1 sera toujours paire. Donc, il n'y a pas besoin de vérifier. Vous pouvez changer cette branche en

{
   n = (n*3+1) >> 1;
   count += 2;
}

Donc toute la déclaration serait alors

if (n & 1)
{
    n = (n*3 + 1) >> 1;
    count += 2;
}
else
{
    n >>= 1;
    ++count;
}
4

De commentaires:

Mais, ce code ne s'arrête jamais (à cause d'un débordement entier)!?! Yves Daoust

Pour beaucoup de nombres, il ne sera pas débordé .

Si va déborder - pour l’une de ces graines initiales malchanceuses, le nombre débordé convergera très probablement vers 1 sans autre débordement.

Cela pose néanmoins une question intéressante: existe-t-il un certain nombre de semences cycliques en cas de débordement?

Toute série finale convergente simple commence par une puissance de deux (assez évidente?).

2 ^ 64 débordera à zéro, ce qui correspond à une boucle infinie non définie selon l'algorithme (ne termine que par 1), mais la solution la plus optimale en réponse prendra fin en raison de shr rax produisant ZF = 1.

Peut-on produire 2 ^ 64? Si le numéro de départ est 0x5555555555555555, c'est un nombre impair, le nombre suivant est alors 3n + 1, ce qui correspond à 0xFFFFFFFFFFFFFFFF + 1 = 0. Théoriquement dans un état non défini de l'algorithme, mais la réponse optimisée de johnfound sera récupérée en sortant sur ZF = 1. Le cmp rax,1 de Peter Cordes se terminera par une boucle infinie (version QED 1, "cheapo" via 0 nombre indéfini).

Que diriez-vous d'un nombre plus complexe, qui créera un cycle sans 0? Franchement, je ne suis pas sûr, ma théorie mathématique est trop floue pour avoir une idée sérieuse, comment la gérer sérieusement. Mais intuitivement, je dirais que la série convergera vers 1 pour chaque nombre: 0 <nombre, car la formule 3n + 1 transformera lentement chaque facteur non-2 du nombre initial (ou intermédiaire) en une puissance de 2, tôt ou tard . Donc, nous n'avons pas à nous soucier de la boucle infinie pour les séries originales, seul le débordement peut nous gêner.

Donc, je viens de mettre quelques chiffres en feuille et j’ai jeté un coup d’œil sur les nombres tronqués en 8 bits.

Trois valeurs débordent dans 0: 227, 170 et 85 (85 allant directement à 0 et 85. SOME_CODE] _).

Mais il n'y a pas de valeur à créer une graine de débordement cyclique.

Curieusement, j'ai fait un contrôle, qui est le premier numéro à avoir subi une troncature de 8 bits, et déjà 27 est affecté! Il atteint la valeur 9232 dans une série non tronquée appropriée (la première valeur tronquée est 322 à la 12ème étape), et la valeur maximale atteinte pour n'importe lequel des nombres d'entrée 2-255 de manière non tronquée est 13120 (pour le 255 lui-même), le nombre maximal d'étapes pour converger vers 1 correspond à peu près à 128 (+ -2, ne sachant pas si "1" doit compter, etc...).

Chose intéressante (pour moi), le nombre 9232 est maximal pour de nombreux autres numéros source. Qu'est-ce qui le rend si spécial? : -O 9232 = 0x2410 ... hmmm .. aucune idée.

Malheureusement, je ne parviens pas à comprendre en profondeur cette série, pourquoi converge-t-elle et quelles sont les implications de leur troncature en k bits, mais avec cmp number,1 condition finale, il est certainement possible de placer l'algorithme en boucle infinie avec une valeur d'entrée particulière se terminant par 0 après la troncature.

Mais la valeur 27 débordant pour le cas 8 bits est en quelque sorte une alerte, cela ressemble à si vous comptez le nombre d'étapes pour atteindre la valeur 1, vous obtiendrez un résultat erroné pour la majorité des nombres du total k -bits de nombres entiers. Pour les nombres entiers de 8 bits, les 146 nombres sur 256 ont affecté les séries par troncation (certains d'entre eux peuvent toujours atteindre le nombre correct d'étapes par accident. Peut-être que je suis trop paresseux pour le vérifier).

4
Ped7g

Vous n'avez pas posté le code généré par le compilateur, il y a donc quelques incertitudes, mais même sans l'avoir vu, on peut dire que:

test rax, 1
jpe even

... a 50% de chances de mal prédire la succursale, et cela coûtera cher.

Le compilateur effectue presque certainement les deux calculs (ce qui coûte considérablement plus cher puisque le div/mod est une latence assez longue, le multiply-add est donc "gratuit") et est suivi d'un CMOV. Qui, bien sûr, a zéro pour cent de chances d’être mal prédite.

4
Damon

En guise de réponse générique, qui ne vise pas spécifiquement cette tâche: Dans de nombreux cas, vous pouvez accélérer de manière significative tout programme en apportant des améliorations de haut niveau. Par exemple, calculez les données une fois au lieu de plusieurs fois, en évitant complètement le travail inutile, en utilisant au mieux les caches, etc. Ces choses sont beaucoup plus faciles à faire dans une langue de haut niveau.

En écrivant du code assembleur, il est possible d'améliorer ce que fait un compilateur d'optimisation, mais c'est un travail difficile. Et une fois que c'est fait, votre code est beaucoup plus difficile à modifier, il est donc beaucoup plus difficile d'ajouter des améliorations algorithmiques. Parfois, le processeur dispose de fonctionnalités que vous ne pouvez pas utiliser à partir d'un langage de haut niveau. L'assemblage en ligne est souvent utile dans ces cas et vous permet tout de même d'utiliser un langage de haut niveau.

Dans les problèmes d'Euler, vous réussissez la plupart du temps en construisant quelque chose, en trouvant pourquoi c'est lent, en construisant quelque chose de mieux, en trouvant pourquoi il est lent, et ainsi de suite. C'est très, très difficile d'utiliser un assembleur. Un meilleur algorithme à la moitié de la vitesse possible vaincra généralement un algorithme moins performant à la vitesse maximale, et obtenir la vitesse maximale dans l'assembleur n'est pas anodin.

3
gnasher729