Deux fonctions très similaires impliquant sin () présentent des performances très différentes – pourquoi?

Considérez les deux programmes suivants qui effectuent les mêmes calculs de deux manières différentes:

// v1.c #include  #include  int main(void) { int i, j; int nbr_values = 8192; int n_iter = 100000; float x; for (j = 0; j < nbr_values; j++) { x = 1; for (i = 0; i < n_iter; i++) x = sin(x); } printf("%f\n", x); return 0; } 

et

 // v2.c #include  #include  int main(void) { int i, j; int nbr_values = 8192; int n_iter = 100000; float x[nbr_values]; for (i = 0; i < nbr_values; ++i) { x[i] = 1; } for (i = 0; i < n_iter; i++) { for (j = 0; j < nbr_values; ++j) { x[j] = sin(x[j]); } } printf("%f\n", x[0]); return 0; } 

Lorsque je les comstack en utilisant gcc 4.7.2 avec -O3 -ffast-math et que -O3 -ffast-math sur une boîte Sandy Bridge, le deuxième programme est deux fois plus rapide que le premier.

Pourquoi donc?

Un des suspects est la dépendance des données entre les itérations successives de la boucle i dans v1 . Cependant, je ne vois pas très bien quelle pourrait être l’explication complète.

(Question inspirée par Pourquoi mon exemple python / numpy est-il plus rapide que l’implémentation en C pur? )

MODIFIER:

Voici l’assembly généré pour v1 :

  movl $8192, %ebp pushq %rbx LCFI1: subq $8, %rsp LCFI2: .align 4 L2: movl $100000, %ebx movss LC0(%rip), %xmm0 jmp L5 .align 4 L3: call _sinf L5: subl $1, %ebx jne L3 subl $1, %ebp .p2align 4,,2 jne L2 

et pour v2 :

  movl $100000, %r14d .align 4 L8: xorl %ebx, %ebx .align 4 L9: movss (%r12,%rbx), %xmm0 call _sinf movss %xmm0, (%r12,%rbx) addq $4, %rbx cmpq $32768, %rbx jne L9 subl $1, %r14d jne L8 

Ignorez la structure de la boucle dans son ensemble et ne pensez qu’à la séquence d’appels au sin . v1 fait ce qui suit:

 x <-- sin(x) x <-- sin(x) x <-- sin(x) ... 

c'est-à-dire que chaque calcul de sin( ) ne peut pas commencer tant que le résultat de l'appel précédent n'est pas disponible; il doit attendre l'intégralité du calcul précédent. Cela signifie que pour N appels au sin , le temps total requirejs est 819200000 fois le temps de latence d'une évaluation unique du sin .

En v2 , en revanche, vous procédez comme suit:

 x[0] <-- sin(x[0]) x[1] <-- sin(x[1]) x[2] <-- sin(x[2]) ... 

remarquez que chaque appel au sin ne dépend pas de l'appel précédent. Effectivement, les appels à sin sont tous indépendants et le processeur peut commencer dès que le registre et les ressources ALU nécessaires sont disponibles (sans attendre la fin du calcul précédent). Ainsi, le temps requirejs est fonction du débit de la fonction sin, et non de la latence, de sorte que v2 peut se terminer en beaucoup moins de temps.


Je devrais également noter que DeadMG a raison de dire que les versions v1 et v2 sont formellement équivalentes et que, dans un monde parfait, le compilateur optimiserait les deux en une seule chaîne de 100 000 évaluations sin (ou simplement le résultat au moment de la compilation). Malheureusement, nous vivons dans un monde imparfait.

Dans le premier exemple, il exécute 100 000 boucles de péché, 8192 fois.

Dans le deuxième exemple, il exécute 8192 boucles de péché, 100 000 fois.

À part cela et en stockant le résultat différemment, je ne vois aucune différence.

Cependant, ce qui fait la différence, c’est que l’entrée est modifiée pour chaque boucle dans le second cas. Je suppose donc que ce qui se produit est que la valeur du péché devient beaucoup plus facile à calculer à certains moments de la boucle. Et cela peut faire une grande différence. Le calcul du péché n’est pas tout à fait sortingvial et c’est un calcul en série qui boucle jusqu’à ce que la condition de sortie soit atteinte.