web-dev-qa-db-fra.com

Efficacité: tableaux vs pointeurs

L'accès à la mémoire via des pointeurs serait plus efficace que l'accès à la mémoire via un tableau. J'apprends C et ce qui précède est indiqué dans K & R. Plus précisément, ils disent

Toute opération pouvant être réalisée par un indice de tableau peut également être effectuée avec des pointeurs. La version du pointeur sera en général plus rapide

J'ai désassemblé le code suivant à l'aide de Visual C++ (le mien est un processeur 686. J'ai désactivé toutes les optimisations.)

int a[10], *p = a, temp;

void foo()
{
    temp = a[0];
    temp = *p;
}

À ma grande surprise, je constate que l'accès à la mémoire via un pointeur prend 3 instructions pour les deux prises par accès à la mémoire via un tableau. Ci-dessous le code correspondant.

; 5    : temp = a[0];

    mov eax, DWORD PTR _a
    mov DWORD PTR _temp, eax

; 6    : temp = *p;

    mov eax, DWORD PTR _p
    mov ecx, DWORD PTR [eax]
    mov DWORD PTR _temp, ecx

S'il vous plaît aidez-moi à comprendre. Qu'est-ce que j'oublie ici??


Comme le soulignaient de nombreuses réponses et commentaires, j'avais utilisé une constante de temps de compilation comme index de tableau, facilitant ainsi l'accès à un tableau. Ci-dessous se trouve le code d'assemblage avec une variable comme index. J'ai maintenant le même nombre d'instructions d'accès via des pointeurs et des tableaux. Mes questions plus larges sont toujours valables. L'accès à la mémoire via un pointeur ne se prête pas comme étant plus efficace.

; 7    :        temp = a[i];

    mov eax, DWORD PTR _i
    mov ecx, DWORD PTR _a[eax*4]
    mov DWORD PTR _temp, ecx

; 8    : 
; 9    :    
; 10   :        temp = *p;

    mov eax, DWORD PTR _p
    mov ecx, DWORD PTR [eax]
    mov DWORD PTR _temp, ecx
55
Abhijith Madhav

L'accès à la mémoire via des pointeurs serait plus efficace que l'accès à la mémoire via un tableau.

C'était peut-être vrai dans le passé, quand les compilateurs étaient des bêtes relativement stupides. Il vous suffit de regarder une partie du code produit par gcc dans les modes d'optimisation élevés pour savoir qu'il n'est plus vrai. Une partie de ce code est très difficile à comprendre mais, une fois que vous l’avez fait, son éclat est évident.

Un compilateur correct générera le même code pour les accès par pointeur et par tableau et vous ne devriez probablement pas vous inquiéter de ce niveau de performance. Les personnes qui écrivent des compilateurs en savent beaucoup plus sur leurs architectures cibles que nous, simples mortels. Concentrez-vous davantage sur le niveau macro lors de l'optimisation de votre code (sélection d'algorithmes, etc.) et faites confiance à vos outilleurs pour faire leur travail.


En fait, je suis surpris que le compilateur n'ait pas optimisé l'ensemble

temp = a[0];

ligne hors existence puisque temp est écrasée dans la ligne suivante avec une valeur différente et a n'est en aucun cas marqué volatile.

Je me souviens d'un mythe urbain d'il y a bien longtemps concernant une référence pour le dernier compilateur VAX Fortran (montrant mon âge ici) qui surpassait ses concurrents de plusieurs ordres de grandeur.

Le compilateur s'est rendu compte que le résultat du calcul de l'indice de référence n'était utilisé nulle part, il a donc optimisé la totalité de la boucle de calcul. D'où l'amélioration substantielle de la vitesse d'exécution.


Mise à jour: La raison pour laquelle le code optimisé est plus efficace dans votre cas particulier est due à la façon dont vous trouvez l'emplacement. a sera à un emplacement fixe choisi au moment du lien/du chargement et sa référence sera corrigée en même temps. Donc, a[0] ou bien a[any constant] sera à un emplacement fixe.

Et p lui-même sera également à un emplacement fixe pour la même raison. Mais*p (le contenu de p) est variable et nécessite donc une recherche supplémentaire pour trouver le bon emplacement de mémoire.

Vous constaterez probablement qu'avoir encore une autre variable x définie sur 0 (et non const) et utiliser a[x] introduirait également des calculs supplémentaires.


Dans l'un de vos commentaires, vous déclarez:

En suivant ce que vous avez suggéré, vous avez également généré 3 instructions pour l’accès à la mémoire via des tableaux (index d’extraction, valeur d’extraction de l’élément de tableau, stockage dans temp). Mais je suis toujours incapable de voir l'efficacité. :

Ma réponse à cela est que vous avez très probablement --- (ne sera pas voir une efficacité dans l'utilisation des pointeurs. Les compilateurs modernes sont plus que capables de comprendre que les opérations de tableau et les opérations de pointeur peuvent être transformées en le même code machine sous-jacent.

En fait, sans optimisation activée, le code de pointeur peut être moins efficace. Considérez les traductions suivantes:

int *pa, i, a[10];

for (i = 0; i < 10; i++)
    a[i] = 100;
/*
    movl    $0, -16(%ebp)              ; this is i, init to 0
L2:
    cmpl    $9, -16(%ebp)              ; from 0 to 9
    jg      L3
    movl    -16(%ebp), %eax            ; load i into register
    movl    $100, -72(%ebp,%eax,4)     ; store 100 based on array/i
    leal    -16(%ebp), %eax            ; get address of i
    incl    (%eax)                     ; increment
    jmp     L2                         ; and loop
L3:
*/

for (pa = a; pa < a + 10; pa++)
    *pa = 100;
/*
    leal    -72(%ebp), %eax
    movl    %eax, -12(%ebp)            ; this is pa, init to &a[0]
L5:
    leal    -72(%ebp), %eax
    addl    $40, %eax
    cmpl    -12(%ebp), %eax            ; is pa at &(a[10])
    jbe     L6                         ; yes, stop
    movl    -12(%ebp), %eax            ; get pa
    movl    $100, (%eax)               ; store 100
    leal    -12(%ebp), %eax            ; get pa
    addl    $4, (%eax)                 ; add 4 (sizeof int)
    jmp     L5                         ; loop around
L6:
*/

À partir de cet exemple, vous pouvez réellement voir que l’exemple de pointeur est plus long et inutilement. Il charge pa dans %eax plusieurs fois sans que cela change et alterne bien %eax entre pa et &(a[10]). L'optimisation par défaut ici est fondamentalement nulle.

Lorsque vous passez au niveau d'optimisation 2, le code obtenu est le suivant:

    xorl    %eax, %eax
L5:
    movl    $100, %edx
    movl    %edx, -56(%ebp,%eax,4)
    incl    %eax
    cmpl    $9, %eax
    jle     L5

pour la version du tableau, et:

    leal    -56(%ebp), %eax
    leal    -16(%ebp), %edx
    jmp     L14
L16:
    movl    $100, (%eax)
    addl    $4, %eax
L14:
    cmpl    %eax, %edx
    ja      L16

pour la version du pointeur.

Je ne vais pas faire d'analyse sur les cycles d'horloge ici (car c'est trop de travail et je suis fondamentalement paresseux), mais je vais souligner une chose. Il n'y a pas de grande différence de code pour les deux versions en termes d'instructions d'assembleur et, étant donné les vitesses d'exécution actuelles des processeurs modernes, vous ne remarquerez de différence que si vous le faites milliards opérations. J'ai toujours tendance à préférer écrire du code pour des raisons de lisibilité et ne me soucier de la performance que si cela devient un problème.

En passant, vous faites référence à cette déclaration:

5.3 Pointeurs et tableaux: La version du pointeur sera en général plus rapide mais, du moins pour les non-initiés, un peu plus difficile à saisir immédiatement.

remonte aux premières versions de K & R, y compris mon ancienne version de 1978 où les fonctions sont encore écrites:

getint(pn)
int *pn;
{
    ...
}

Les compilateurs ont parcouru un long chemin depuis cette époque.

70
paxdiablo

Si vous programmez des plates-formes intégrées, vous apprendrez rapidement que la méthode du pointeur est beaucoup plus rapide que l’utilisation d’un index.

struct bar a[10], *p;

void foo()
{
    int i;

    // slow loop
    for (i = 0; i < 10; ++i)
        printf( a[i].value);

    // faster loop
    for (p = a; p < &a[10]; ++p)
        printf( p->value);
}

La boucle lente doit calculer un + (i * sizeof (barre de structure)) à chaque fois, alors que la seconde doit simplement ajouter sizeof (barre de structure) à p à chaque fois. L'opération de multiplication utilise plus de cycles d'horloge que l'ajout de nombreux processeurs.

Vous commencez vraiment à voir des améliorations si vous référencez un [i] plusieurs fois dans la boucle. Certains compilateurs ne mettent pas cette adresse en cache, elle peut donc être recalculée plusieurs fois dans la boucle.

Essayez de mettre à jour votre exemple pour utiliser une structure et référencer plusieurs éléments.

11
tomlogic

Dans le premier cas, le compilateur connaît directement l'adresse du tableau (qui est également l'adresse du premier élément) et y accède. Dans le second cas, il connaît l'adresse du pointeur et lit la valeur du pointeur qui pointe vers cet emplacement mémoire. C'est en fait une indirection supplémentaire, donc c'est probablement plus lent ici.

8
Alexander Gessler

La vitesse est surtout gagnée dans les boucles. Lorsque vous utilisez un tableau, vous utilisez un compteur que vous incrémentez. Pour calculer la position, le système multiplie ce compteur par la taille de l'élément de tableau, puis ajoute l'adresse du premier élément pour obtenir l'adresse . Avec les pointeurs, il suffit de passer à l'élément suivant. augmentez le pointeur actuel avec la taille de l'élément pour obtenir le suivant, en supposant que tous les éléments sont côte à côte en mémoire.

L'arithmétique de pointeur nécessite donc un peu moins de calculs lors de la réalisation de boucles. De plus, avoir des pointeurs sur le bon élément est plus rapide que d'utiliser un index dans un tableau.

Le développement moderne se débarrasse lentement de nombreuses opérations de pointeur, cependant. Les processeurs deviennent de plus en plus rapides et les tableaux sont plus faciles à gérer que les pointeurs. De plus, les tableaux ont tendance à réduire le nombre de bogues dans le code. Le tableau permettra les vérifications d'index en s'assurant que vous n'accédez pas aux données en dehors du tableau.

7
Wim ten Brink

Comme l'a dit paxdiablo, tout nouveau compilateur les rendra très similaires.

Encore plus, j'ai vu des situations où le tableau était plus rapide que les pointeurs. C'était sur un processeur DSP qui utilise des opérations vectorielles. 

Dans ce cas, l’utilisation de tableaux était semblable à l’utilisation de restrict pointeurs. Parce qu'en utilisant deux tableaux, le compilateur sait (implicitement) qu'ils ne pointent pas au même emplacement ..__ Mais si vous traitez avec un pointeur 2, le compilateur peut penser qu'ils pointent au même endroit et ignoreront la ligne de conduite.

par exemple:

int a[10],b[10],c[10];
int *pa=a, *pb=b, *pc=c;
int i;

// fill a and b.
fill_arrays(a,b);

// set c[i] = a[i]+b[i];
for (i = 0; i<10; i++)
{
   c[i] = a[i] + b[i];
}

// set *pc++ = *pa++ + *pb++;
for (i = 0; i<10; i++)
{
   *pc++ = *pa++ + *pb++;
}

Dans le cas 1, le compilateur fera facilement la doublure en ajoutant a et b et en stockant de la valeur dans c.

Dans le cas 2, le compilateur ne fera pas de pipe-line, car il pourrait écraser a ou b lors de l'enregistrement en C. 

7
Yousf

Les pointeurs expriment naturellement des variables d'induction simples, tandis que les indices nécessitent intrinsèquement des optimisations plus sophistiquées du compilateur


Dans de nombreux cas, le simple fait d'utiliser une expression en indice nécessite l'ajout d'une couche supplémentaire au problème. Une boucle qui incrémente un indice i peut être considérée comme une machine à états, et l'expression a [i] requiert techniquement, chaque fois qu'elle est utilisée, que i soit multiplié par taille de chaque élément et ajouté à l'adresse de base.

Afin de transformer ce modèle d'accès pour utiliser des pointeurs, le compilateur doit analyser la boucle entière et déterminer que, par exemple, chaque élément est en cours d'accès. Ensuite, le compilateur peut remplacer les multiples instances de multiplication de l'indice par la taille de l'élément par un simple incrément de la valeur de la boucle précédente. Ce processus combine des optimisations appelées élimination de la sous-expression commune et réduction de la force d'induction.

Lors de l’écriture avec des pointeurs, le processus d’optimisation n’est pas nécessaire dans son ensemble, car le programmeur ne fait que commencer par parcourir le tableau.

Parfois, le compilateur peut effectuer l'optimisation et parfois non. Il est plus courant de disposer d'un compilateur sophistiqué ces dernières années. Le code à base de pointeur est donc pas toujours plus rapide.

Comme les tableaux doivent généralement être contigus, la création de structures composites allouées de manière incrémentielle constitue un autre avantage pour les pointeurs.

7
DigitalRoss

C'est une très vieille question à laquelle il a été répondu, en tant que tel, je n'ai pas besoin de répondre! Cependant, je n'ai pas remarqué de réponse simple, alors je vous en donne une.

REPONSE: Un accès indirect (pointeur/tableau) "pourrait" ajouter une instruction supplémentaire pour charger l'adresse (de base), mais tous les accès suivants (éléments dans le cas d'un tableau/membres dans le cas d'un pointeur vers la structure) ne devraient être qu'une instruction car il s’agit simplement de l’ajout d’un décalage à l’adresse (de base) déjà chargée. En un sens, cela va être aussi bon que l’accès direct. En tant que tel, dans la majorité des cas, l’accès par tableau/pointeur est équivalent et les accès aux éléments valent aussi bien que l’accès direct à une variable.

Ex. si j'ai un tableau (ou un pointeur) avec 10 éléments ou une structure de 10 membres (accessible via un pointeur vers la structure) et que j'accède à un élément/membre, l'instruction supplémentaire possible n'est requise qu'une seule fois au début. Tous les accès élément/membre ne doivent contenir qu'une instruction après cela.

3
RcnRcf

Vous obtenez de bonnes réponses à votre question ici, mais puisque vous étudiez, il est utile de souligner que les gains d'efficacité à ce niveau sont rarement perceptibles.

Lorsque vous optimisez un programme pour obtenir des performances maximales, vous devez au moins accorder autant d’attention à la recherche et à la résolution de problèmes plus importants dans la structure du programme. Une fois ces problèmes résolus, les optimisations de bas niveau peuvent faire une différence supplémentaire.

Voici un exemple de la façon dont cela peut être fait.

2
Mike Dunlavey

Les pointeurs étaient plus rapides que les tableaux. À l'époque où le langage C était conçu, les pointeurs étaient un peu plus rapides. Mais de nos jours, les optimiseurs peuvent généralement mieux optimiser les tableaux que les pointeurs, car les tableaux sont plus restreints. 

Les jeux d'instructions des processeurs modernes ont également été conçus pour optimiser l'accès aux modules RAID. 

L'essentiel est que les tableaux sont souvent plus rapides de nos jours, surtout lorsqu'ils sont utilisés dans des boucles avec des variables d'index. 

Bien sûr, vous voudriez toujours utiliser des pointeurs pour des choses telles que les listes chaînées, mais l'optimisation traditionnelle consistant à déplacer un pointeur dans un tableau plutôt qu'à utiliser une variable d'index risque maintenant d'être une désoptimisation.

2
John Knoeller

Puisque 0 est défini comme une constante, un [0] est également une constante et le compilateur sait où il se trouve au moment de la compilation. Dans le cas "normal", le compilateur devrait calculer l'adresse de l'élément à partir d'une base + offset (l'offset étant mis à l'échelle en fonction de la taille de l'élément).

OTOH, p est une variable et l'indirection requiert un déplacement supplémentaire.

D'une manière générale, l'index de tableau est traité en interne comme une arithmétique de pointeur de toute façon, donc je ne suis pas sûr de voir ce que K & R essayait de faire valoir.

1
filofel

"La version du pointeur sera en général plus rapide" signifie que dans la plupart des cas, il est plus facile pour le compilateur de générer du code plus efficace avec un pointeur (qui doit simplement être déréférencé) plutôt qu'avec un tableau et un indice décaler l'adresse depuis le début du tableau). Avec les processeurs modernes et les compilateurs d'optimisation, toutefois, l'accès aux tableaux n'est généralement pas plus lent que l'accès par pointeur.

Dans votre cas, vous devez activer l'optimisation pour obtenir le même résultat.

1
Vlad

je suis un peu surpris par le ptr plus rapide que par tableau, où la preuve que ce n'est pas le cas est donnée initialement par le code asm de Abhijith.

mov eax, dord ptr _a; // charge directement la valeur depuis l'adresse _a

contre

mov eax, dword ptr _p; // adresse de chargement/valeur de p dans eax

et

mov ecx, dword ptr [eax]; // utilise une adresse chargée pour accéder à la valeur et la mettre dans ecx

Un tableau représente une adresse fixe pour que le cpu puisse y accéder directement, mais pas avec le ptr, il doit être déréférencé pour que le cpu puisse accéder à la valeur!

Le second lot de code n’est pas comparable, car l’offset de tableau doit être calculé. Pour ce faire, vous aurez également besoin d’au moins 1/2 instructions supplémentaires!

Tout ce qu'un compilateur peut déduire pendant la compilation (adresses fixes, décalages, etc.) est la clé du code performant . Comparer le code itératif et l'affecter à vars:

Tableau:

; 2791: tmp = buf_ai [l];

mov eax, DWORD PTR _l$[ebp]
mov ecx, DWORD PTR _buf_ai$[ebp+eax*4]
mov DWORD PTR _tmp$[ebp], ecx

contre

PTR

; 2796: tmp2 = * p;

mov eax, DWORD PTR _p$[ebp]
mov ecx, DWORD PTR [eax]
mov DWORD PTR _tmp2$[ebp], ecx

plus

; 2801: ++ p;

mov eax, DWORD PTR _p$[ebp]
add eax, 4
mov DWORD PTR _p$[ebp], eax

C’est tout simplement pour l’adresse de chargement ptr d’abord que pour l’utiliser comparé à Array pour utiliser l’adresse et obtenir de la valeur simultanément!

meilleures salutations

0
SwDev42