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?
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
pow
même avec des exposants entiers peut donner des résultats incorrects (PaulMcKenzie)pow
est un appel de fonction (alors que x*x
Ne l'est pas) (jtbandes)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.