web-dev-qa-db-fra.com

Pourquoi gcc est-il autorisé à se charger spéculativement à partir d'une structure?

Exemple montrant l'optimisation gcc et le code utilisateur pouvant faire défaut

La fonction 'foo' dans l'extrait ci-dessous ne chargera qu'un seul des membres de structure A ou B; c'est du moins l'intention du code non optimisé.

typedef struct {
  int A;
  int B;
} Pair;

int foo(const Pair *P, int c) {
  int x;
  if (c)
    x = P->A;
  else
    x = P->B;
  return c/102 + x;
}

Voici ce que donne gcc -O3:

mov eax, esi
mov edx, -1600085855
test esi, esi
mov ecx, DWORD PTR [rdi+4]   <-- ***load P->B**
cmovne ecx, DWORD PTR [rdi]  <-- ***load P->A***
imul edx
lea eax, [rdx+rsi]
sar esi, 31
sar eax, 6
sub eax, esi
add eax, ecx
ret

Il apparaît donc que gcc est autorisé à charger spéculativement les deux membres de la structure afin d'éliminer les branchements. Mais alors, le code suivant est-il considéré comme un comportement non défini ou l'optimisation gcc ci-dessus est-elle illégale?

#include <stdlib.h>  

int naughty_caller(int c) {
  Pair *P = (Pair*)malloc(sizeof(Pair)-1); // *** Allocation is enough for A but not for B ***
  if (!P) return -1;

  P->A = 0x42; // *** Initializing allocation only where it is guaranteed to be allocated ***

  int res = foo(P, 1); // *** Passing c=1 to foo should ensure only P->A is accessed? ***

  free(P);
  return res;
}

Si la spéculation de charge se produit dans le scénario ci-dessus, il est possible que le chargement de P-> B provoque une exception car le dernier octet de P-> B peut se trouver dans la mémoire non allouée. Cette exception ne se produira pas si l'optimisation est désactivée.

La question

L'optimisation gcc indiquée ci-dessus de la spéculation de charge est-elle légale? Où la spécification dit-elle ou implique-t-elle que c'est ok? Si l'optimisation est légale, comment le code dans 'naughtly_caller' se révèle-t-il être un comportement non défini?

53
zr.

La lecture d'une variable (qui n'a pas été déclarée comme volatile) n'est pas considérée comme un "effet secondaire" comme spécifié par la norme C. Ainsi, le programme est libre de lire un emplacement, puis de rejeter le résultat, en ce qui concerne la norme C.

C'est très courant. Supposons que vous demandiez 1 octet de données à un entier de 4 octets. Le compilateur peut alors lire les 32 bits entiers si c'est plus rapide (lecture alignée), puis supprimer tout sauf l'octet demandé. Votre exemple est similaire à ceci mais le compilateur a décidé de lire la structure entière.

Cela se retrouve formellement dans le comportement de "la machine abstraite", C11 chapitre 5.1.2.3. Étant donné que le compilateur suit les règles qui y sont spécifiées, il est libre de faire ce qu'il veut. Et les seules règles répertoriées concernent les objets volatile et le séquencement des instructions. La lecture d'un autre membre struct dans une structure volatile ne serait pas acceptable.

Quant au cas d'allouer trop peu de mémoire pour l'ensemble de la structure, c'est un comportement indéfini. Parce que la disposition de la mémoire de la structure n'est généralement pas du ressort du programmeur - par exemple, le compilateur est autorisé à ajouter un remplissage à la fin. S'il n'y a pas assez de mémoire allouée, vous pourriez finir par accéder à la mémoire interdite même si votre code ne fonctionne qu'avec le premier membre de la structure.

55
Lundin

Non, si *P est alloué correctement P->B ne sera jamais dans la mémoire non allouée. Ce n'est peut-être pas initialisé, c'est tout.

Le compilateur a le droit de faire ce qu'il fait. La seule chose qui n'est pas autorisée est oops sur l'accès de P->B avec l'excuse qu'il n'est pas initialisé. Mais quoi et comment ils font tout cela est à la discrétion de la mise en œuvre et non pas votre préoccupation.

Si vous transtypez un pointeur sur un bloc renvoyé par malloc vers Pair* qui n'est pas garanti d'être suffisamment large pour contenir un Pair le comportement de votre programme n'est pas défini.

13
Jens Gustedt

Ceci est parfaitement légal car la lecture d'un emplacement mémoire n'est pas considérée comme un comportement observable dans le cas général (volatile changerait cela).

Votre exemple de code est en effet un comportement indéfini, mais je ne trouve aucun passage dans les documents standard qui le déclare explicitement. Mais je pense qu'il suffit de jeter un oeil aux règles pour types efficaces ... de N1570, §6.5 p6:

Si une valeur est stockée dans un objet sans type déclaré via une valeur l dont le type n'est pas un type de caractère, le type de la valeur l devient le type effectif de l'objet pour cet accès et pour les accès ultérieurs qui ne modifient pas le valeur stockée.

Ainsi, votre accès en écriture à *P donne en fait à cet objet le type Pair - par conséquent, il se prolonge simplement dans la mémoire que vous n'avez pas allouée, le résultat est un accès hors limites.

8
user2371524

Une expression suffixe suivie du -> L'opérateur et un identifiant désignent un membre d'une structure ou d'un objet union. La valeur est celle du membre nommé de l'objet vers lequel pointe la première expression

Si vous appelez l'expression P->A est bien défini, alors P doit en fait pointer vers un objet de type struct Pair, et par conséquent P->B est également bien défini.

7
Hurkyl

Un opérateur -> Sur un Pair * Implique qu'il y a un objet Pair entier entièrement alloué. ( @ Hurkyl cite la norme .)

x86 (comme toute architecture normale) n'a pas d'effets secondaires pour accéder à la mémoire allouée normale, donc la sémantique de la mémoire x86 est compatible avec la sémantique de la machine abstraite C pour les nonvolatile mémoire . Les compilateurs peuvent charger de manière spéculative s'ils pensent que ce sera un gain de performances sur la microarchitecture cible pour laquelle ils se concentrent dans une situation donnée.

Notez que sur x86, la protection de la mémoire fonctionne avec une granularité de page. Le compilateur peut dérouler une boucle ou vectoriser avec SIMD d'une manière qui lit à l'extérieur d'un objet, tant que toutes les pages touchées contiennent des octets de l'objet. Est-il sûr de lire après la fin d'un tampon dans la même page sur x86 et x64? . libc strlen() les implémentations écrites à la main dans Assembly font cela, mais AFAIK gcc ne le fait pas, utilisant plutôt des boucles scalaires pour les éléments restants à la fin d'une boucle auto-vectorisée même là où il a déjà aligné les pointeurs avec un (entièrement déroulé) boucle de démarrage. (Peut-être parce que cela rendrait la vérification des limites d'exécution avec valgrind difficile.)


Pour obtenir le comportement que vous attendiez, utilisez un argument const int *.

Un tableau est un objet unique, mais les pointeurs sont différents des tableaux. (Même avec l'incrustation dans un contexte où les deux éléments du tableau sont connus pour être accessibles, je n'ai pas pu faire en sorte que gcc émette du code comme il le fait pour la structure, donc si son code struct est une victoire, c'est une optimisation manquée de ne pas faites-le sur des tableaux quand il est également sûr.).

En C, vous êtes autorisé à passer cette fonction un pointeur vers un seul int, tant que c est différent de zéro. Lors de la compilation pour x86, gcc doit supposer qu'il pourrait pointer vers le dernier int d'une page, avec la page suivante non mappée.

Source + sortie gcc et clang pour ceci et d'autres variations sur l'explorateur du compilateur Godbolt

// exactly equivalent to  const int p[2]
int load_pointer(const int *p, int c) {
  int x;
  if (c)
    x = p[0];
  else
    x = p[1];  // gcc missed optimization: still does an add with c known to be zero
  return c + x;
}

load_pointer:    # gcc7.2 -O3
    test    esi, esi
    jne     .L9
    mov     eax, DWORD PTR [rdi+4]
    add     eax, esi         # missed optimization: esi=0 here so this is a no-op
    ret
.L9:
    mov     eax, DWORD PTR [rdi]
    add     eax, esi
    ret

En C, vous pouvez passer en quelque sorte passer un objet tableau (par référence) à une fonction , garantissant au fonction qu'il est autorisé de toucher toute la mémoire même si la machine abstraite C ne le fait pas. La syntaxe est int p[static 2]

int load_array(const int p[static 2], int c) {
  ... // same body
}

Mais gcc n'en profite pas et émet un code identique vers load_pointer.


Hors sujet: clang compile toutes les versions (struct et tableau) de la même manière, en utilisant un cmov pour calculer sans branchement une adresse de chargement.

    lea     rax, [rdi + 4]
    test    esi, esi
    cmovne  rax, rdi
    add     esi, dword ptr [rax]
    mov     eax, esi            # missed optimization: mov on the critical path
    ret

Ce n'est pas nécessairement bon: il a une latence plus élevée que le code struct de gcc, car l'adresse de chargement dépend de quelques uops ALU supplémentaires. C'est assez bien si les deux adresses ne sont pas sûres à lire et qu'une branche prédit mal.

Nous pouvons obtenir un meilleur code pour la même stratégie à partir de gcc et clang, en utilisant setcc (1 uop avec latence 1c sur tous les CPU sauf certains vraiment anciens), au lieu de cmovcc (2 uops sur Intel avant Skylake). xor- la remise à zéro est toujours moins chère qu'un LEA également.

int load_pointer_v3(const int *p, int c) {
  int offset = (c==0);
  int x = p[offset];
  return c + x;
}

    xor     eax, eax
    test    esi, esi
    sete    al
    add     esi, dword ptr [rdi + 4*rax]
    mov     eax, esi
    ret

gcc et clang placent tous les deux le mov final sur le chemin critique. Et sur la famille Intel Sandybridge, le mode d'adressage indexé ne reste pas micro-fusionné avec le add. Donc, ce serait mieux, comme ce qu'il fait dans la version de branchement:

    xor     eax, eax
    test    esi, esi
    sete    al
    mov     eax, dword ptr [rdi + 4*rax]
    add     eax, esi
    ret

Les modes d'adressage simples comme [rdi] Ou [rdi+4] Ont une latence 1c plus faible que les autres sur les processeurs de la famille Intel SnB, donc cela pourrait en fait être une latence pire sur Skylake (où cmov est bon marché) . test et lea peuvent s'exécuter en parallèle.

Après l'inlining, ce mov final n'existerait probablement pas, et il pourrait simplement add dans esi.

5
Peter Cordes

Ceci est toujours autorisé selon la règle "as-if" si aucun programme conforme ne peut faire la différence. Par exemple, une implémentation pourrait garantir qu'après chaque bloc alloué avec malloc, il y a au moins huit octets accessibles sans effets secondaires. Dans cette situation, le compilateur peut générer du code qui serait un comportement indéfini si vous l'écriviez dans votre code. Il serait donc légal pour le compilateur de lire P [1] chaque fois que P [0] est correctement alloué, même si ce serait un comportement indéfini dans votre propre code.

Mais dans votre cas, si vous n'allouez pas suffisamment de mémoire pour une structure, la lecture du membre any est un comportement non défini. Donc, ici, le compilateur est autorisé à le faire, même si la lecture de P-> B plante.

4
gnasher729