web-dev-qa-db-fra.com

Pourquoi la puissance (int, int) est-elle si lente?

J'ai travaillé sur quelques exercices du projet Euler pour améliorer ma connaissance du C++.

J'ai écrit la fonction suivante:

int a = 0,b = 0,c = 0;

for (a = 1; a <= SUMTOTAL; a++)
{
    for (b = a+1; b <= SUMTOTAL-a; b++)
    {
        c = SUMTOTAL-(a+b);

        if (c == sqrt(pow(a,2)+pow(b,2)) && b < c)
        {
            std::cout << "a: " << a << " b: " << b << " c: "<< c << std::endl;
            std::cout << a * b * c << std::endl;
        }
    }
}

Cela se calcule en 17 millisecondes.

Cependant, si je change de ligne

if (c == sqrt(pow(a,2)+pow(b,2)) && b < c)

à

if (c == sqrt((a*a)+(b*b)) && b < c)

le calcul a lieu en 2 millisecondes. Y a-t-il des détails d'implémentation évidents de pow(int, int) qui me manquent, ce qui rend le calcul de la première expression tellement plus lent?

48
Fang

pow() fonctionne avec de vrais nombres à virgule flottante et utilise sous le capot la formule

pow(x,y) = e^(y log(x))

pour calculer x^y. Les int sont convertis en double avant d'appeler pow. (log est le logarithme naturel, basé sur l'e)

x^2 En utilisant pow() est donc plus lent que x*x.

Modifier en fonction des commentaires pertinents

  • L'utilisation de pow même avec des exposants entiers peut donner des résultats incorrects (PaulMcKenzie)
  • En plus d'utiliser une fonction mathématique de type double, pow est un appel de fonction (alors que x*x Ne l'est pas) (jtbandes)
  • De nombreux compilateurs modernes optimiseront en fait la puissance avec des arguments entiers constants, mais il ne faut pas s'y fier.
69
Ring Ø

Vous avez choisi l'un des moyens les plus lents de vérifier

c*c == a*a + b*b   // assuming c is non-negative

Cela compile en trois multiplications entières (dont une peut être hissée hors de la boucle). Même sans pow(), vous convertissez toujours en double et prenez une racine carrée, ce qui est terrible pour le débit. (Et aussi la latence, mais la prédiction de branche + l'exécution spéculative sur les processeurs modernes signifie que la latence n'est pas un facteur ici).

L'instruction SQRTSD d'Intel Haswell a un débit d'un par 8-14 cycles ( source: tableaux d'instructions d'Agner Fog ), donc même si votre version sqrt() conserve la FP sqrt execution unit satured, it's still about 4 fois slowly than what I got gcc to emit (below).


Vous pouvez également optimiser la condition de boucle pour sortir de la boucle lorsque la partie b < c De la condition devient fausse, de sorte que le compilateur ne doit effectuer qu'une seule version de cette vérification.

void foo_optimized()
{ 
  for (int a = 1; a <= SUMTOTAL; a++) {
    for (int b = a+1; b < SUMTOTAL-a-b; b++) {
        // int c = SUMTOTAL-(a+b);   // gcc won't always transform signed-integer math, so this prevents hoisting (SUMTOTAL-a) :(
        int c = (SUMTOTAL-a) - b;
        // if (b >= c) break;  // just changed the loop condition instead

        // the compiler can hoist a*a out of the loop for us
        if (/* b < c && */ c*c == a*a + b*b) {
            // Just print a newline.  std::endl also flushes, which bloats the asm
            std::cout << "a: " << a << " b: " << b << " c: "<< c << '\n';
            std::cout << a * b * c << '\n';
        }
    }
  }
}

Cela compile (avec gcc6.2 -O3 -mtune=haswell) Pour coder avec cette boucle interne. Voir le code complet sur l'explorateur du compilateur Godbolt .

# a*a is hoisted out of the loop.  It's in r15d
.L6:
    add     ebp, 1    # b++
    sub     ebx, 1    # c--
    add     r12d, r14d        # ivtmp.36, ivtmp.43  # not sure what this is or why it's in the loop, would have to look again at the asm outside
    cmp     ebp, ebx  # b, _39
    jg      .L13    ## This is the loop-exit branch, not-taken until the end
                    ## .L13 is the rest of the outer loop.
                    ##  It sets up for the next entry to this inner loop.
.L8:
    mov     eax, ebp        # multiply a copy of the counters
    mov     edx, ebx
    imul    eax, ebp        # b*b
    imul    edx, ebx        # c*c
    add     eax, r15d       # a*a + b*b
    cmp     edx, eax  # tmp137, tmp139
    jne     .L6
 ## Fall-through into the cout print code when we find a match
 ## extremely rare, so should predict near-perfectly

Sur Intel Haswell, toutes ces instructions sont à 1 unité chacune. (Et les macro-paires cmp/jcc fusionnent en uops de comparaison et de branche.) Donc, c'est 10 uops de domaine fusionné, qui peuvent émettre à une itération par 2,5 cycles .

Haswell exécute imul r32, r32 Avec un débit d'une itération par horloge, donc les deux multiplications à l'intérieur de la boucle interne ne saturent pas le port 1 à deux multiplications par 2,5c. Cela laisse de la place pour absorber les conflits de ressources inévitables avec ADD et SUB volant le port 1.

Nous ne sommes même pas proches des autres goulots d'étranglement des ports d'exécution, donc le goulot d'étranglement frontal est le seul problème, et cela devrait s'exécuter à une itération par 2,5 cycles sur Intel Haswell et versions ultérieures.

Le déroulement de la boucle pourrait aider ici à réduire le nombre d'uops par contrôle. par exemple. utilisez lea ecx, [rbx+1] pour calculer b + 1 pour la prochaine itération, afin que nous puissions imul ebx, ebx sans utiliser de MOV pour le rendre non destructif.


Une réduction de force est également possible : Étant donné b*b, Nous pourrions essayer de calculer (b-1) * (b-1) Sans IMUL. (b-1) * (b-1) = b*b - 2*b + 1, Alors peut-être pouvons-nous faire un lea ecx, [rbx*2 - 1] Et ensuite le soustraire de b*b. (Il n'y a pas de modes d'adressage qui soustraient au lieu d'ajouter. Hmm, nous pourrions peut-être garder -b Dans un registre et compter jusqu'à zéro, donc nous pourrions utiliser lea ecx, [rcx + rbx*2 - 1] Pour mettre à jour b*b Dans ECX, étant donné -b Dans EBX).

À moins que vous ne bloquiez réellement le débit IMUL, cela pourrait finir par prendre plus d'ups et ne pas être une victoire. Il pourrait être amusant de voir à quel point un compilateur ferait bien avec cette réduction de force dans la source C++.


Vous pourriez probablement aussi vectoriser ceci avec SSE ou AVX , en vérifiant 4 ou 8 valeurs b consécutives _ Étant donné que les hits sont vraiment rares, il vous suffit de vérifier si l'un des 8 a eu un hit, puis de déterminer lequel, dans le cas rare où il y a eu un match.

Voir aussi le wiki de la balise x86 pour plus d'informations sur l'optimisation.

38
Peter Cordes