38 votes

Efficacité sur une plate-forme 64 bits : indexation des pointeurs et des tableaux 32 bits

Dans l'une de ses présentations, Andrei Alexandrescu suggère que, sur une plate-forme 64 bits, l'utilisation d'une indexation de tableau 32 bits est plus rapide que l'utilisation d'un pointeur brut :

Page 16 : http://www.slideshare.net/andreialexandrescu1/three-optimization-tips-for-c-15708507

Sur son compte Facebook, il est plus précis et dit : "Préférer l'indexation des tableaux aux pointeurs (cette affirmation semble s'inverser tous les dix ans)".

J'ai essayé de nombreuses choses pour trouver une différence, mais je n'ai pas réussi à construire un programme qui montre cette différence. Connaissant Andrei, je ne serais pas surpris que la différence ne dépasse pas quelques pour cent, mais je serais heureux que quelqu'un trouve un tel exemple.

Voici un test que j'ai effectué. Je choisis n = 5000, assez grand pour obtenir un timing décent et assez petit pour que tout rentre dans le cache L1. Je boucle plusieurs fois pour que la fréquence du CPU augmente.

#include <iostream>
#include <chrono>

int main(int argc, const char* argv[]) {
  const int n{5000};
  int* p{new int[n]};

  // Warm up the cache
  for (int i{0}; i < n; i++) {
    p[i] += 1;
  }

  for (int j{0}; j < 5; j++) {
    {
      auto start_pointer = std::chrono::high_resolution_clock::now();
      for (int* q{p}; q != p + n; ++q) {
        ++(*q);
      }
      auto end_pointer = std::chrono::high_resolution_clock::now();
      auto time_pointer = std::chrono::duration_cast<std::chrono::nanoseconds>(
                              end_pointer - start_pointer)
                              .count();
      std::cout << " Pointer: " << time_pointer << std::endl;
    }

    {
      auto start_pointer = std::chrono::high_resolution_clock::now();
      for (int* q{p}; q != p + n; ++q) {
        ++(*q);
      }
      auto end_pointer = std::chrono::high_resolution_clock::now();
      auto time_pointer = std::chrono::duration_cast<std::chrono::nanoseconds>(
                              end_pointer - start_pointer)
                              .count();
      std::cout << " Pointer: " << time_pointer << std::endl;
    }

    {
      auto start_index_32 = std::chrono::high_resolution_clock::now();
      for (int i{0}; i < n; i++) {
        p[i] += 1;
      }
      auto end_index_32 = std::chrono::high_resolution_clock::now();
      auto time_index_32 = std::chrono::duration_cast<std::chrono::nanoseconds>(
                               end_index_32 - start_index_32)
                               .count();
      std::cout << "Index 32: " << time_index_32 << std::endl;
    }

    {
      auto start_index_32 = std::chrono::high_resolution_clock::now();
      for (int i{0}; i < n; i++) {
        p[i] += 1;
      }
      auto end_index_32 = std::chrono::high_resolution_clock::now();
      auto time_index_32 = std::chrono::duration_cast<std::chrono::nanoseconds>(
                               end_index_32 - start_index_32)
                               .count();
      std::cout << "Index 32: " << time_index_32 << std::endl;
    }

    {
      const std::size_t n_64{n};
      auto start_index_64 = std::chrono::high_resolution_clock::now();
      for (std::size_t i{0}; i < n_64; i++) {
        p[i] += 1;
      }
      auto end_index_64 = std::chrono::high_resolution_clock::now();
      auto time_index_64 = std::chrono::duration_cast<std::chrono::nanoseconds>(
                               end_index_64 - start_index_64)
                               .count();
      std::cout << "Index 64: " << time_index_64 << std::endl;
    }

    {
      const std::size_t n_64{n};
      auto start_index_64 = std::chrono::high_resolution_clock::now();
      for (std::size_t i{0}; i < n_64; i++) {
        p[i] += 1;
      }
      auto end_index_64 = std::chrono::high_resolution_clock::now();
      auto time_index_64 = std::chrono::duration_cast<std::chrono::nanoseconds>(
                               end_index_64 - start_index_64)
                               .count();
      std::cout << "Index 64: " << time_index_64 << std::endl;
    }
    std::cout << std::endl;
  }

  delete[] p;

  return 0;
}

Voici le type de résultat que j'obtiens sur ma machine (core i7). Tout ce que j'obtiens est du "bruit".

 Pointer: 883
 Pointer: 485
Index 32: 436
Index 32: 380
Index 64: 372
Index 64: 429

 Pointer: 330
 Pointer: 316
Index 32: 336
Index 32: 321
Index 64: 337
Index 64: 318

 Pointer: 311
 Pointer: 314
Index 32: 318
Index 32: 319
Index 64: 316
Index 64: 301

 Pointer: 306
 Pointer: 325
Index 32: 323
Index 32: 313
Index 64: 318
Index 64: 305

 Pointer: 311
 Pointer: 319
Index 32: 313
Index 32: 324
Index 64: 315
Index 64: 303

40voto

rici Points 45980

Le problème avec des conseils de bas niveau comme celui-ci (même venant d'Andrei Alexandrescu) est qu'ils ignorent le fait que les compilateurs optimisent.

Les compilateurs modernes optimisent de manière si agressive (et, en général, avec succès) qu'essayer de les remettre en question devient un jeu de dupes. Dans l'ensemble, écrire un code clair et lisible vous aidera, ainsi que vos collègues et vos compilateurs, à analyser le code. Et je crois honnêtement que c'est le meilleur conseil général que l'on puisse donner.

L'une des optimisations bien connues utilisées par les compilateurs modernes est la conversion entre les boucles basées sur les index et les pointeurs. Dans le cas particulier de votre benchmark, avec la plupart des paramètres d'optimisation, gcc compilera à la fois la boucle basée sur les pointeurs et la boucle basée sur les index 32 bits avec la même sortie assembleur.

Dans ce qui suit, j'ai remplacé le truc du chrono par ++sentrysentry est un volatile afin de réduire la taille du code. La sortie de l'assemblage correspond à :

for (int* q{p}; q != p + n; ++q) ++(*q);
++sentry;
for (int i{0}; i < n; i++) p[i] += 1;

Compiler avec -O2 ce qui donne le résultat suivant : ( %rdi et %ebp étaient toujours initialisés à partir de la boucle qui remplissait p )

        movq    %rdi, %rdx
        cmpq    %rcx, %rdi
        je      .L10
.L16:
        addl    $1, (%rdx)
        addq    $4, %rdx
        cmpq    %rcx, %rdx
        jne     .L16
.L10:
        movl    sentry(%rip), %eax
        movq    %rdi, %rdx
        addl    $1, %eax
        movl    %eax, sentry(%rip)
        testl   %ebp, %ebp
        jle     .L8
.L14:
        addl    $1, (%rdx)
        addq    $4, %rdx
        cmpq    %rdx, %rsi
        jne     .L14
.L8:

Vous pouvez voir qu'il n'y a aucune différence entre les boucles à .L16 et .L14 .

Des paramètres d'optimisation différents produisent des résultats différents, bien sûr. Avec -O3 les boucles sont vectorisées à l'aide d'instructions SIMD et du dispositif de Duff, mais sont à nouveau presque identiques. clang effectue cette optimisation au niveau de -O2

Rien de tout cela ne nie le point soulevé, à savoir que le compilateur peut avoir besoin de travailler davantage pour prouver qu'un pointeur qui est écrit ne peut pas modifier une mémoire arbitraire.

Mais dans ce cas, comme dans de nombreux cas, l'index de la boucle est une variable locale et la boucle est suffisamment simple pour que le compilateur puisse l'analyser complètement, permettant ainsi la réduction de force, le déroulage et la vectorisation ; que la variable de contrôle soit un pointeur ou un index n'est alors pas pertinent.


Un exemple plus intéressant (éventuellement) est une boucle sur deux tableaux dont les éléments de base ont des tailles différentes. Étant donné les deux fonctions suivantes :

void d2f_ptr(float* out, const double* in, int n) {
  for (auto lim = out + n; out < lim;) *out++ = *in++;
}
void d2f_idx(float out[], const double in[], int n) {
  for (int i = 0; i < n; ++i) out[i] = in[i];
}

gcc (v5.3.0, -O2) produit des boucles différentes et la boucle basée sur l'index est plus courte d'une instruction :

d2f_ptr(float*, double const*, int):    d2f_idx(float*, double const*, int):
        movslq  %edx, %rdx                      xorl    %eax, %eax
        leaq    (%rdi,%rdx,4), %rax             testl   %edx, %edx
        cmpq    %rax, %rdi                      jle     .L16
        jnb     .L11
.L15:                                   .L20:
        addq    $4, %rdi                        pxor    %xmm0, %xmm0
        addq    $8, %rsi                        cvtsd2ss (%rsi,%rax,8), %xmm0
        pxor    %xmm0, %xmm0                    movss   %xmm0, (%rdi,%rax,4)
        cvtsd2ss -8(%rsi), %xmm0                addq    $1, %rax
        movss   %xmm0, -4(%rdi)
        cmpq    %rdi, %rax                      cmpl    %eax, %edx
        ja      .L15                            jg      .L20
.L11:                                   .L16:
        ret                                     ret

Mais changez le double et float à des objets dont la taille ne permet plus d'utiliser le mode d'adressage indexé de la puce Intel, et le compilateur convertit à nouveau le code basé sur l'index en une variante basée sur les pointeurs.

Ici, le code est essentiellement le même que précédemment, mais le double a été porté à 48 octets :

struct Big { double val; char padding[40]; };
struct Small {
  float val;
  Small& operator=(const Big& other) {
    val = other.val;
    return *this;
  }
};

d2f_ptr(Small*, Big const*, int):       d2f_idx(Small*, Big const*, int):
        movslq  %edx, %rdx                      testl   %edx, %edx
        leaq    (%rdi,%rdx,4), %rax             jle     .L26
        cmpq    %rax, %rdi                      leal    -1(%rdx), %eax
        jnb     .L21                            leaq    4(%rdi,%rax,4), %rax
.L25:                                   .L29:
        addq    $48, %rsi                       pxor    %xmm0, %xmm0
        addq    $4, %rdi                        addq    $4, %rdi
        pxor    %xmm0, %xmm0                    cvtsd2ss (%rsi), %xmm0
        cvtsd2ss -48(%rsi), %xmm0               addq    $48, %rsi
        movss   %xmm0, -4(%rdi)                 movss   %xmm0, -4(%rdi)
        cmpq    %rdi, %rax                      cmpq    %rax, %rdi
        ja      .L25                            jne     .L29
.L21:                                   .L26:
        ret                                     ret

Il est peut-être utile d'ajouter que pour les compilateurs, il n'est pas nécessairement plus difficile d'analyser quel objet une écriture de pointeur particulière va modifier. [ Modifié : Il y avait une citation d'Alexandrescu ici, mais elle n'était pas aussi pertinente que je le pensais, alors je l'ai supprimée, laissant cette section être principalement un homme de paille].

En fait, si un pointeur n'est affecté directement qu'une seule fois, et que toutes les autres modifications se font par des opérations d'incrémentation et de décrémentation (y compris l'opération += et -= ), alors le compilateur a tout à fait le droit de supposer que le pointeur pointe toujours vers le même objet. Si une modification additive du pointeur devait déborder sur un autre objet, il s'agirait d'un comportement indéfini et le compilateur peut écarter cette possibilité. Il est assez facile de suivre les opérations d'assignation et d'inc/dec dans un diagramme de flux, donc dans les cas où le pointeur aurait pu être remplacé par une expression d'index, il est tout à fait possible pour un compilateur de le découvrir et de savoir ainsi que d'autres objets ne sont pas mutés aléatoirement par des écritures à travers le pointeur.

11voto

KingsIndian Points 26855

Son raisonnement (Andrei Alexandrescu) semble être basé sur le fait que l'utilisation d'un registre pour une variable pointeur est généralement plus difficile pour le compilateur puisqu'un pointeur peut pointer vers des données globales. Mais je ne vois rien de spécifique à l'indexation des tableaux 32 bits (à ma lecture, la diapositive n'était pas tout à fait claire s'il faisait réellement référence aux tableaux 32 bits ou à l'indexation des tableaux des systèmes 32 bits).

De la bouche du cheval : (oui, c'est un lien vers son compte Facebook :)

Minimiser l'écriture des tableaux

Pour être plus rapide, le code devrait réduire le nombre d'écritures de tableaux, et plus généralement, les écritures par les pointeurs.

Sur les machines modernes dotées de grands fichiers de registre et d'une de registre, vous pouvez supposer que la plupart des variables individuelles nommées (nombres, pointeurs) finissent par se retrouver dans les registres. Opérer avec registres est rapide et joue sur les points forts de la configuration matérielle. Même lorsque les dépendances de données - un ennemi majeur de l'instruction - niveau d'instructions - entrent en jeu, les CPU disposent d'un matériel spécifique dédié à la à gérer les différents modèles de dépendance. Opérer avec des registres (c'est-à-dire des variables nommées), c'est parier sur la maison. Faites-le.

En revanche, les opérations sur les tableaux (et les accès indirects en général) sont moins naturelles dans toute la hiérarchie compilateur-processeur-cache. naturelles dans toute la hiérarchie compilateur-processeur-cache. A l'exception de quelques modèles évidents, les accès aux tableaux ne sont pas enregistrés. De plus, chaque fois que des pointeurs sont impliqués, le compilateur doit supposer que les pointeurs pourraient pointer vers des données globales, ce qui signifie que tout appel de fonction peut modifier données pointées arbitrairement. Et parmi les opérations sur les tableaux, les écritures de tableaux sont sont les pires du lot. Étant donné que tout le trafic avec la mémoire est fait à granularité de la ligne de cache, l'écriture d'un mot en mémoire est essentiellement une lecture de la ligne de cache suivie d'une lecture de la ligne de cache. lecture de ligne de cache suivie d'une écriture de ligne de cache. Donc, étant donné que dans une que, dans une large mesure, les lectures de tableau sont inévitables, ce conseil se résume à se résume à "éviter les écritures de tableau autant que possible".

Il semble aussi suggérer, c'est général plutôt qu'une suggestion toujours plus rapide d'utiliser l'indexation des tableaux (du même article) :

Quelques bonnes choses, mais moins connues, à faire pour un code rapide :

Préférez les liens statiques et le code dépendant de la position (par opposition au PIC, code indépendant de la position).
Préférez le code 64 bits et les données 32 bits.
Préférer l'indexation des tableaux aux pointeurs (ce principe semble s'inverser tous les dix ans). années).
Préférer des schémas d'accès à la mémoire réguliers. Minimiser le flux de contrôle.
Évitez les dépendances de données.

7voto

InsideLoop Points 1346

J'ai écrit un courriel à Andrei Alexandrescu et il a eu la gentillesse de me répondre. Voici son explication :

"Pour que l'accélération soit visible, vous devez exploiter la capacité de l'UAL à exécuter soit deux opérations 32 bits, soit une opération 64 bits en un cycle. Tous les benchmarks ne montrent pas un gain de vitesse."

J'ai compris que les instructions SIMD traitent plus de données par cycle avec des données 32 bits qu'avec des données 64 bits. Je n'ai pas encore trouvé un benchmark (qui ne contient pas de tableau d'entiers) où cela fait une différence. Mais je suppose que cela va être difficile. Andrei travaillait pour Facebook où le moindre pourcentage valait la peine d'être obtenu.

3voto

ShadowRanger Points 44

Pas exactement une réponse, mais trop complexe pour un commentaire :

Votre test est un test vraiment limité de l'arithmétique des pointeurs par rapport à l'indexation des tableaux ; dans les cas simples, avec l'optimisation à fond chaque Un compilateur digne de ce nom produira le même assemblage pour les deux. Lorsque la variable index n'est pas utilisée, le compilateur est parfaitement libre de passer à l'arithmétique des pointeurs dans l'assemblage, et il est également capable de repasser de l'arithmétique des pointeurs à l'accès aux tableaux s'il le souhaite.

Le meilleur exemple que je puisse trouver date d'il y a quelques années (et n'est probablement pas cohérent d'un compilateur à l'autre, d'une architecture à l'autre, etc.) Je jouais avec (à des fins d'apprentissage) deux versions de code qui se résumaient à une opération de copie de tableau :

for (unsigned i = 0; i < copycnt; ++i) {
    x[i] = y[i];
}

vs.

while (copycnt--) {
    *x++ = *y++;
}

Je suppose qu'il y avait un facteur de complication (ou que les optimisations du compilateur ont changé, puisque la dernière fois que j'ai testé quelque chose comme ça à haute optimisation, cela a compilé dans le même assemblage), mais même si le compilateur pouvait trivialement convertir le premier cas en le second (et théoriquement sauver un registre, éviter les instructions de chargement et de stockage décalées en faveur d'instructions de chargement et de stockage directes, utiliser un fichier de type testl pour 0 au lieu d'un cmpl de deux valeurs, pour le faible coût de l'ajout d'une instruction de décrémentation), le compilateur a plutôt choisi de compiler un code qui serait approximé par :

const ptrdiff_t diff = y - x;
decltype(*x) *const end = x + copycnt;
while (x < end) {
    *x = *(x + diff);
    ++x;
}

Cette version est probablement supérieure à l'une ou l'autre des versions "normales" du code si l'on compte le nombre de registres nécessaires dans la boucle, le nombre d'instructions dans la boucle (en supposant que le chargement à un offset de registre fixe est une instruction combinée comme c'est le cas sur les machines x86, au lieu de add puis chargement direct), etc., et le compilateur l'a certainement pensé, puisqu'il a choisi cette version plutôt qu'une arithmétique de pointeur simple (que n'importe quel compilateur pourrait faire ici).

Mais lorsque j'ai compilé le code arithmétique direct des pointeurs, le compilateur n'a pas pu comprendre la relation (probablement en raison d'un facteur de complication qui n'est pas présent dans cette version simplifiée ; je ne sais ni l'un ni l'autre x , y ou copycnt a été utilisé à nouveau, donc il ne s'agissait pas de préserver les valeurs originales), et compilé plus ou moins exactement ce que je lui avais donné, deux pointeurs, incrémentés indépendamment.

Ma théorie est que l'utilisation d'un index a donné au compilateur un contexte pour ce que je faisais ; il ne s'agissait pas de deux pointeurs sans rapport, mais de deux "tableaux" auxquels on accédait via un index commun, et il savait comment améliorer le code compilé pour ce modèle. L'arithmétique des pointeurs était "fais ce que je dis" sans donner de contexte pour "ce que j'essaie de faire" et le compilateur ne pouvait pas le comprendre, donc il ne l'a pas optimisé.

Quoi qu'il en soit, ce n'est évidemment qu'une anecdote, mais je pense que l'exemple est représentatif des possibilités plus complexes ; l'indexation des tableaux donne au compilateur plus d'informations sur la "logique supérieure" de ce que vous faites, alors que l'arithmétique des pointeurs dit ce qu'il faut faire mais pas pourquoi il faut le faire, donc le compilateur a plus de mal à optimiser, ce qui peut expliquer la recommandation. J'espère que cela vous aidera.

-1voto

g24l Points 1355

Ce type d'optimisation n'existe qu'au niveau du métal et vous devez les ignorer. Je me concentrerais plus sur d'autres choses qui introduisent réellement bruit à vos tests.

[ENJEUX]

  1. L'affectation des pointeurs se fait avec ++(*q) alors que pour les types intégraux, il est plus rapide de faire (*q)++ mais en tout état de cause, vous devriez être cohérent avec les tests int32/int64 et faire *q+=1 .
  2. les tests de pointeur calculent à chaque fois dans votre boucle le pointeur de fin, vous devriez le faire une seule fois int * ep{p+n} et vérifier par rapport à cela.
  3. Dans les tests sur les nombres entiers, vous ne devez pas utiliser < mais != pour l'évaluation de la résiliation.
  4. Si vous ne faites que cinq essais, vous n'obtiendrez pas suffisamment de schémas.
  5. Vous devez définir l'affinité du processeur
  6. Vous devriez faire plus de boucles et ne tenir compte que de la dernière.
  7. Vous devriez avoir un système où le compilateur a été compilé au métal
  8. Vous ne devez pas "réchauffer le cache de la manière dont vous le faites".
  9. Vous devriez randomiser vos entrées.

J'ai modifié votre code, et vous pouvez le récupérer à l'adresse suivante ici .

Vous devriez compiler avec :

g++ -O3 -march=native --std=c++11 -o intvsptr

et lancer avec

taskset 0x00000001 ./intvsptr

et alors vous devriez obtenir des résultats cohérents.

Pointeur : 4396 Pointeur : 4397 Index 32 : 4395 Index 32 : 4394 Index 64 : 4394 Index 64 : 4395

Pointeur : 4395 Pointeur : 4397 Index 32 : 4397 Index 32 : 4395 Index 64 : 4393 Index 64 : 4396

Pointeur : 4395 Pointeur : 4397 Index 32 : 4396 Index 32 : 4394 Index 64 : 4394 Index 64 : 4396

Pointeur : 4396 Pointeur : 4397 Index 32 : 4397 Index 32 : 4395 Index 64 : 4394 Index 64 : 4395

Pointeur : 4395 Pointeur : 7698 Index 32 : 4471 Index 32 : 4422 Index 64 : 4425 Index 64 : 4407

Pointeur : 4399 Pointeur : 4416 Index 32 : 4394 Index 32 : 4393 Index 64 : 4399 Index 64 : 4412

La précision de ces tests devrait être de l'ordre du dernier chiffre, mais normalement il faut faire une analyse statistique approfondie.

J'ai collé les derniers passages d'une exécution, mais après en avoir fait plusieurs, je pense qu'il est possible de dire, pour autant que ce microbenchmark le permette, que l'arithmétique des pointeurs est plus rapide ou dans le pire des cas juste un peu plus lent .

En tout cas, vous pouvez ignorer ce type de conseils qui pouvait avoir de l'importance il y a des années mais pas avec les compilateurs actuels. .

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X