web-dev-qa-db-fra.com

Pourquoi UNE opération arithmétique de base dans le corps de la boucle for est-elle exécutée PLUS LENTEMENT QUE DEUX opérations arithmétiques?

Alors que j'expérimentais la mesure du temps d'exécution des opérations arithmétiques, je suis tombé sur un comportement très étrange. Un bloc de code contenant une boucle for avec une opération arithmétique dans le corps de la boucle était toujours exécuté plus lentement qu'un bloc de code identique, mais avec deux opérations arithmétiques dans le corps de la boucle for. Voici le code que j'ai fini par tester:

#include <iostream>
#include <chrono>

#define NUM_ITERATIONS 100000000

int main()
{
    // Block 1: one operation in loop body
    {
        int64_t x = 0, y = 0;
        auto start = std::chrono::high_resolution_clock::now();

        for (long i = 0; i < NUM_ITERATIONS; i++) {x+=31;}

        auto end = std::chrono::high_resolution_clock::now();
        std::chrono::duration<double> diff = end-start;
        std::cout << diff.count() << " seconds. x,y = " << x << "," << y << std::endl;
    }

    // Block 2: two operations in loop body
    {
        int64_t x = 0, y = 0;
        auto start = std::chrono::high_resolution_clock::now();

        for (long i = 0; i < NUM_ITERATIONS; i++) {x+=17; y-=37;}

        auto end = std::chrono::high_resolution_clock::now();
        std::chrono::duration<double> diff = end-start;
        std::cout << diff.count() << " seconds. x,y = " << x << "," << y << std::endl;
    }

    return 0;
}

J'ai testé cela avec différents niveaux d'optimisation du code (-O0, -O1, -O2, -O3), avec différents compilateurs en ligne (par exemple onlinegdb.com ), sur ma machine de travail, sur mon PC et ordinateur portable hame, sur RaspberryPi et sur l'ordinateur de mon collègue. J'ai réorganisé ces deux blocs de code, les ai répétés, changé les constantes, changé les opérations (+, -, <<, =, etc.), ont modifié les types d'entiers. Mais j'ai toujours eu un résultat similaire: le bloc avec une ligne en boucle est [~ # ~] plus lent [~ # ~] que le bloc avec deux lignes:

1,05681 secondes. x, y = 3100000000,0
0,90414 secondes. x, y = 1700000000, -3700000000

J'ai vérifié la sortie de l'Assemblée sur https://godbolt.org/ mais tout ressemblait à ce à quoi je m'attendais: le deuxième bloc avait juste une opération de plus dans la sortie de l'Assemblée.

Trois opérations se comportent toujours comme prévu: elles sont plus lentes que une et plus rapides que quatre . Alors pourquoi deux opérations produisent une telle anomalie?

Modifier:

Permettez-moi de répéter: j'ai un tel comportement sur toutes mes machines Windows et Unix avec un code non optimisé. J'ai regardé l'assemblage que j'exécute (Visual Studio, Windows) et j'y vois les instructions que je veux tester. Quoi qu'il en soit, si la boucle est optimisée, il n'y a rien que je demande dans le code qui reste. J'ai ajouté que les optimisations notent dans la question pour éviter les réponses "ne pas mesurer le code non optimisé" car les optimisations ne sont pas ce que je demande. La question est en fait pourquoi mes ordinateurs exécutent deux opérations plus rapidement qu'une, d'abord dans le code où ces opérations ne sont pas optimisées. La différence de temps d'exécution est de 5-25% sur mes tests (assez notable).

15
Oliort

@PeterCordes a prouvé que cette réponse était fausse dans de nombreuses hypothèses, mais cela pourrait encore être utile comme une tentative de recherche aveugle du problème.

J'ai mis en place quelques points de repère rapides, pensant que cela pourrait être lié à l'alignement de la mémoire de code, une pensée vraiment folle.

Mais il semble que @Adrian McCarthy a bien compris la mise à l'échelle dynamique des fréquences.

Quoi qu'il en soit, les repères indiquent que l'insertion de certains NOP pourrait aider à résoudre le problème, avec 15 NOP après le x + = 31 dans le bloc 1, ce qui conduit à presque les mêmes performances que le bloc 2. Vraiment époustouflant comment 15 NOP dans un corps de boucle d'instruction unique augmentent les performances.

http://quick-bench.com/Q_7HY838oK5LEPFt-tfie0wy4uA

J'ai aussi essayé les compilateurs -OFast pensants qui pourraient être assez intelligents pour jeter un peu de mémoire de code en insérant de tels NOP, mais cela ne semble pas être le cas. http://quick-bench.com/so2CnM_kZj2QEWJmNO2mtDP9ZX

Edit : Grâce à @PeterCordes, il était clair que les optimisations ne fonctionnaient jamais comme prévu dans les benchmarks ci-dessus (car la variable globale nécessitait d'ajouter des instructions pour accéder à la mémoire) , nouveau benchmark http://quick-bench.com/HmmwsLmotRiW9xkNWDjlOxOTShE montre clairement que les performances des blocs 1 et 2 sont égales pour les variables de pile. Mais les NOP pourraient toujours aider avec une application monothread avec une boucle d'accès à la variable globale, que vous ne devriez probablement pas utiliser dans ce cas et assigner simplement une variable globale à une variable locale après la boucle.

Edit 2 : En fait, les optimisations n'ont jamais fonctionné en raison de macros de benchmark rapide rendant l'accès aux variables volatile, empêchant des optimisations importantes. Il n'est logique de charger la variable qu'une seule fois car nous ne la modifions que dans la boucle, ce sont donc les optimisations volatiles ou désactivées qui constituent le goulot d'étranglement. Donc, cette réponse est fondamentalement fausse, mais au moins, elle montre comment les NOP pourraient accélérer l'exécution de code non optimisé, si cela a un sens dans le monde réel (il existe de meilleures façons comme les compteurs de compartimentage).

2
Sasha Knorre

Les processeurs sont si complexes de nos jours qu'on ne peut que deviner.

L'assembly émis par votre compilateur n'est pas ce qui est réellement exécuté. Le microcode/firmware/quel que soit votre CPU l'interprétera et le transformera en instructions pour son moteur d'exécution, un peu comme les langages JIT tels que C # ou Java do.

Une chose à considérer ici est que pour chaque boucle, il n'y a pas 1 ou 2 instructions, mais n + 2, car vous incrémentez et comparez également i à votre nombre d'itérations. Dans la grande majorité des cas, cela n'a pas d'importance, mais ici c'est le cas, car le corps de la boucle est si simple.

Voyons l'Assemblée:

Certains définissent:

#define NUM_ITERATIONS 1000000000ll
#define X_INC 17
#define Y_INC -31

C/C++:

for (long i = 0; i < NUM_ITERATIONS; i++) { x+=X_INC; }

ASM:

    mov     QWORD PTR [rbp-32], 0
.L13:
    cmp     QWORD PTR [rbp-32], 999999999
    jg      .L12
    add     QWORD PTR [rbp-24], 17
    add     QWORD PTR [rbp-32], 1
    jmp     .L13
.L12:

C/C++:

for (long i = 0; i < NUM_ITERATIONS; i++) {x+=X_INC; y+=Y_INC;}

ASM:

    mov     QWORD PTR [rbp-80], 0
.L21:
    cmp     QWORD PTR [rbp-80], 999999999
    jg      .L20
    add     QWORD PTR [rbp-64], 17
    sub     QWORD PTR [rbp-72], 31
    add     QWORD PTR [rbp-80], 1
    jmp     .L21
.L20:

Les deux assemblées se ressemblent donc assez. Mais réfléchissons à deux fois: les processeurs modernes ont des ALU qui fonctionnent sur des valeurs plus larges que leur taille de registre. Il y a donc une chance que dans le premier cas, l'opération sur x et i se fasse sur la même unité de calcul. Mais alors vous devez relire i, car vous mettez une condition sur le résultat de cette opération. Et lire, c'est attendre.

Ainsi, dans le premier cas, pour itérer sur x, le processeur devra peut-être être synchronisé avec l'itération sur i.

Dans le second cas, peut-être que x et y sont traités sur une unité différente de celle traitant de i. Donc, en fait, votre corps de boucle est parallèle à la condition qui le conduit. Et il y a votre processeur et votre calcul jusqu'à ce que quelqu'un lui dise de s'arrêter. Peu importe si cela va trop loin, revenir en arrière de quelques boucles est toujours bien comparé au temps que cela vient de gagner.

Donc, pour comparer ce que nous voulons comparer (une opération vs deux opérations), nous devrions essayer de sortir i du chemin.

Une solution est de s'en débarrasser complètement en utilisant une boucle while: C/C++:

while (x < (X_INC * NUM_ITERATIONS)) { x+=X_INC; }

ASM:

.L15:
    movabs  rax, 16999999999
    cmp     QWORD PTR [rbp-40], rax
    jg      .L14
    add     QWORD PTR [rbp-40], 17
    jmp     .L15
.L14:

Une autre consiste à utiliser le mot-clé C "register" antequated: C/C++:

register long i;
for (i = 0; i < NUM_ITERATIONS; i++) { x+=X_INC; }

ASM:

    mov     ebx, 0
.L17:
    cmp     rbx, 999999999
    jg      .L16
    add     QWORD PTR [rbp-48], 17
    add     rbx, 1
    jmp     .L17
.L16:

Voici mes résultats:

x1 pendant: 10,2985 secondes. x, y = 17000000000,0
x1 pendant: 8 00049 secondes. x, y = 17000000000,0
x1 enregistrement-pour: 7,31426 secondes. x, y = 17000000000,0
x2 pendant: 9,30073 secondes. x, y = 17000000000, -31000000000
x2 pendant: 8,88801 secondes. x, y = 17000000000, -31000000000
x2 enregistrement-pour: 8,70302 secondes. x, y = 17000000000, -31000000000

Le code est ici: https://onlinegdb.com/S1lAANEhI

1
Jérôme Gardou