web-dev-qa-db-fra.com

Pourquoi GCC n'optimise-t-il pas la suppression des pointeurs nuls en C ++?

Considérez un programme simple:

int main() {
  int* ptr = nullptr;
  delete ptr;
}

Avec GCC (7.2), il y a une instruction call concernant operator delete Dans le programme résultant. Avec les compilateurs Clang et Intel, il n'y a pas de telles instructions, la suppression du pointeur nul est complètement optimisée (-O2 Dans tous les cas). Vous pouvez tester ici: https://godbolt.org/g/JmdoJi .

Je me demande si une telle optimisation peut être activée d'une manière ou d'une autre avec GCC? (Ma motivation plus large découle d'un problème de swap personnalisé contre std::swap Pour les types mobiles, où la suppression de pointeurs nuls peut représenter une baisse des performances dans le deuxième cas; voir https://stackoverflow.com/a/45689282/58008 pour plus de détails.)

[~ # ~] mise à jour [~ # ~]

Pour clarifier ma motivation pour la question: si j'utilise juste delete ptr; Sans if (ptr) garde dans un déplacer l'opérateur d'affectation et un destructeur d'une classe, puis std::swap avec les objets de cette classe donne 3 instructions call avec GCC. Cela peut être une pénalité de performance considérable, par exemple lors du tri d'un tableau de ces objets.

De plus, je peux écrire if (ptr) delete ptr; partout, mais je me demande si cela ne peut pas également être une pénalité de performance, car l'expression delete doit également vérifier ptr. Mais, ici, je suppose que les compilateurs ne généreront qu'une seule vérification.

De plus, j'aime vraiment la possibilité d'appeler delete sans le gardien et ce fut une surprise pour moi, que cela puisse donner des résultats (de performance) différents.

[~ # ~] mise à jour [~ # ~]

Je viens de faire un benchmark simple, à savoir le tri d'objets, qui invoque delete dans leur opérateur d'affectation de mouvement et destructeur. La source est ici: https://godbolt.org/g/7zGUvo

Les temps de fonctionnement de std::sort Mesurés avec GCC 7.1 et l'indicateur -O2 Sur Xeon E2680v3:

Il y a un bug dans le code lié, il compare les pointeurs, pas les valeurs pointées. Les résultats corrigés sont les suivants:

  1. sans if garde: 17,6 [s]  40,8 [s] ,
  2. avec if guard: 10,6 [s]  31,5 [s] ,
  3. avec if garde et personnalisé swap10,4 [s] 31,3 [s].

Ces résultats étaient absolument cohérents sur de nombreuses séries avec un écart minimal. La différence de performances entre les deux premiers cas est importante et je ne dirais pas qu'il s'agit d'un "cas d'angle extrêmement rare" comme du code.

45
Daniel Langr

Selon C++ 14 [expr.delete]/7:

Si la valeur de l'opérande de l'expression de suppression n'est pas une valeur de pointeur nulle, alors:

  • [ ...omis... ]

Sinon, il n'est pas spécifié si la fonction de désallocation sera appelée.

Les deux compilateurs respectent donc la norme, car il n'est pas précisé si operator delete est appelé pour la suppression d'un pointeur nul.

Notez que le compilateur en ligne godbolt compile simplement le fichier source sans lier. Le compilateur à ce stade doit donc prévoir la possibilité que operator delete sera remplacé par un autre fichier source.

Comme déjà spéculé dans une autre réponse - gcc peut rechercher un comportement cohérent dans le cas d'un remplacement operator delete; cette implémentation signifierait que quelqu'un peut surcharger cette fonction à des fins de débogage et interrompre toutes les invocations de l'expression delete, même si elle supprimait un pointeur nul.

MISE À JOUR: Suppression de la spéculation selon laquelle cela pourrait ne pas être un problème pratique, car OP a fourni des repères montrant que c'est effectivement le cas.

29
M.M

C'est un problème de QOI. clang élide en effet le test:

https://godbolt.org/g/nBSykD

main:                                   # @main
        xor     eax, eax
        ret
7
Richard Hodges

La norme indique en fait quand les fonctions d'allocation et de désallocation doivent être appelées et où elles ne le sont pas. Cette clause (@ n4296)

La bibliothèque fournit des définitions par défaut pour les fonctions globales d'allocation et de désallocation. Certaines fonctions globales d'allocation et de désallocation sont remplaçables (18.6.1). Un programme C++ doit fournir au plus une définition d'une fonction d'allocation ou de désallocation remplaçable. Une telle définition de fonction remplace la version par défaut fournie dans la bibliothèque (17.6.4.6). Les fonctions d'allocation et de désallocation suivantes (18.6) sont implicitement déclarées dans la portée globale dans chaque unité de traduction d'un programme.

serait probablement la raison principale pour laquelle ces appels de fonction ne sont pas omis de façon arbitraire. S'ils l'étaient, le remplacement de leur implémentation de bibliothèque entraînerait une fonction incohérente du programme compilé.

Dans la première alternative (objet de suppression), la valeur de l'opérande de suppression peut être une valeur de pointeur nulle, un pointeur vers un objet non tableau créé par une nouvelle expression précédente, ou un pointeur vers un sous-objet (1.8) représentant un classe de base d'un tel objet (article 10). Sinon, le comportement n'est pas défini.

Si l'argument donné à une fonction de désallocation dans la bibliothèque standard est un pointeur qui n'est pas la valeur de pointeur nulle (4.10), la fonction de désallocation doit désallouer le stockage référencé par le pointeur, rendant invalides tous les pointeurs faisant référence à n'importe quelle partie du stockage désalloué . L'indirection via une valeur de pointeur non valide et le passage d'une valeur de pointeur non valide à une fonction de désallocation ont un comportement indéfini. Toute autre utilisation d'une valeur de pointeur non valide a un comportement défini par l'implémentation.

...

Si la valeur de l'opérande de la suppression-expression n'est pas une valeur de pointeur nulle, alors

  • Si l'appel d'allocation de la nouvelle expression pour l'objet à supprimer n'a pas été omis et que l'allocation n'a pas été étendue (5.3.4), l'expression de suppression doit appeler une fonction de désallocation (3.7.4.2). La valeur renvoyée par l'appel d'allocation de la nouvelle expression doit être transmise comme premier argument à la fonction de désallocation.

  • Sinon, si l'allocation a été étendue ou a été fournie en étendant l'allocation d'une autre nouvelle expression, et que la suppression de l'expression pour chaque autre valeur de pointeur produite par une nouvelle expression dont le stockage était fourni par la nouvelle expression étendue a été évaluée, la suppression -expression appellera une fonction de désallocation. La valeur renvoyée par l'appel d'allocation de la nouvelle expression étendue doit être transmise comme premier argument à la fonction de désallocation.

    • Sinon, la suppression-expression n'appellera pas une fonction de désallocation

Sinon, il n'est pas spécifié si la fonction de désallocation sera appelée.

La norme indique ce qui doit être fait si le pointeur n'est PAS nul. Impliquer que la suppression dans ce cas est noop, mais à quelle fin, n'est pas spécifié.

7
Swift - Friday Pie

Il est toujours sûr (pour être correct) de laisser votre programme appeler operator delete Avec un nullptr.

Pour les performances, il est très rare que l'asm généré par le compilateur fasse en fait un test supplémentaire et une branche conditionnelle pour ignorer un appel à operator delete Sera une victoire. (Cependant, vous pouvez aider gcc à optimiser la suppression de la compilation nullptr à l'extérieur sans ajouter de vérification d'exécution; voir ci-dessous).

Tout d'abord, une taille de code plus grande en dehors d'un véritable point chaud augmente la pression sur le cache L1I et le cache décodé uop encore plus petit sur les processeurs x86 qui en ont un (famille Intel SnB, AMD Ryzen).

Deuxièmement, les branches conditionnelles supplémentaires utilisent des entrées dans les caches de prédiction de branche (BTB = tampon cible de branche, etc.). Selon le processeur, même une branche qui n'a jamais été prise peut aggraver les prédictions pour d'autres branches si elle les alias dans le BTB. (Sur d'autres, une telle branche n'obtient jamais d'entrée dans le BTB, pour enregistrer les entrées pour les branches où la prédiction statique par défaut de la transition est exacte.) Voir https://xania.org/201602/bpu- première partie .

Si nullptr est rare dans un chemin de code donné, alors en moyenne la vérification et la branche pour éviter le call finissent par que votre programme passe plus de temps sur le chèque que le chèque n'enregistre.

Si le profilage montre que vous avez un point chaud qui inclut un delete et que l'instrumentation/journalisation montre qu'il appelle souvent en fait delete avec un nullptr, alors cela vaut la peine d'essayer
if (ptr) delete ptr; au lieu de simplement delete ptr;

La prédiction de branche pourrait avoir plus de chance dans ce site d'appel que pour la branche à l'intérieur de operator delete, Surtout s'il y a une corrélation avec d'autres branches proches. (Apparemment, les BPU modernes ne se contentent pas de regarder chaque branche isolément.) Ceci est en plus d'enregistrer le call inconditionnel dans la fonction de bibliothèque (plus un autre jmp du stub PLT, du dynamique liaison de surcharge sur Unix/Linux).


Si vous recherchez null pour toute autre raison, il peut être judicieux de placer le delete dans la branche non nulle de votre code.

Vous pouvez éviter les appels delete dans les cas où gcc peut prouver (après l'inlining) qu'un pointeur est nul, mais sans faire de vérification d'exécution sinon :

static inline bool 
is_compiletime_null(const void *ptr) {
#ifdef   __GNUC__
    // __builtin_constant_p(ptr) is false even for nullptr,
    // but the checking the result of booleanizing works.
    return __builtin_constant_p(!ptr) && !ptr;
#else
    return false;
#endif
}

Il retournera toujours false avec clang car il évalue __builtin_constant_p avant l'inline. Mais comme clang ignore déjà les appels delete lorsqu'il peut prouver qu'un pointeur est nul, vous n'en avez pas besoin.

Cela peut réellement aider dans les cas std::move, Et vous pouvez l'utiliser en toute sécurité n'importe où sans (en théorie) aucun inconvénient de performance. Je compile toujours en if(true) ou if(false), c'est donc très différent de if(ptr), ce qui est susceptible d'entraîner une branche d'exécution car le compilateur ne peut probablement pas prouver le pointeur n'est pas non plus nul dans la plupart des cas. (Un déréférencement pourrait, cependant, car un déréf nul serait UB, et les compilateurs modernes optimisés en supposant que le code ne contient aucun UB).

Vous pouvez en faire une macro pour éviter de gonfler les versions non optimisées (et ainsi cela "fonctionnera" sans avoir à s'aligner en premier). Vous pouvez utiliser une expression-instruction GNU C pour éviter la double évaluation de la macro arg ( voir les exemples pour GNU C min() et max() ). Pour la solution de rechange pour les compilateurs sans GNU, vous pouvez écrire ((ptr), false) ou quelque chose pour évaluer l'argument une fois pour effets secondaires tout en produisant un résultat false.

Démonstration: asm de gcc6.3 -O3 sur l'explorateur du compilateur Godbolt

void foo(int *ptr) {
    if (!is_compiletime_null(ptr))
        delete ptr;
}

    # compiles to a tailcall of operator delete
    jmp     operator delete(void*)


void bar() {
    foo(nullptr);
}

    # optimizes out the delete
    rep ret

Il se compile correctement avec MSVC (également sur le lien Explorateur du compilateur), mais avec le test renvoyant toujours false, bar() est:

    # MSVC doesn't support GNU C extensions, and doesn't skip nullptr deletes itself
    mov      edx, 4
    xor      ecx, ecx
    jmp      ??3@YAXPEAX_K@Z      ; operator delete

Il est intéressant de noter que operator delete De MSVC prend la taille de l'objet comme une fonction arg (mov edx, 4), Mais le code gcc/Linux/libstdc ++ passe juste le pointeur.


Connexes: j'ai trouvé cet article de blog , en utilisant C11 (pas C++ 11) _Generic Pour essayer de faire de manière portative quelque chose comme __builtin_constant_p Vérifications de pointeur nul à l'intérieur des initialiseurs statiques .

5
Peter Cordes

Tout d'abord, je suis juste d'accord avec certains répondeurs précédents en ce que ce n'est pas un bug, et GCC peut faire ce qu'il veut ici. Cela dit, je me demandais si cela signifie qu'un code RAII commun et simple peut être plus lent sur GCC que Clang car une optimisation simple n'est pas effectuée.

J'ai donc écrit un petit cas de test pour RAII:

struct A
{
    explicit A() : ptr(nullptr) {}
    A(A &&from)
        : ptr(from.ptr)
    {
        from.ptr = nullptr;
    }

    A &operator =(A &&from)
    {
        if ( &from != this )
        {
            delete ptr;
            ptr = from.ptr;
            from.ptr = nullptr;
        }
        return *this;
    }

    int *ptr;
};

A a1;

A getA2();

void setA1()
{
    a1 = getA2();
}

Comme vous pouvez le voir ici , GCC élide le deuxième appel à delete dans setA1 (pour le temporaire déplacé qui a été créé dans l'appel à getA2). Le premier appel est nécessaire pour l'exactitude du programme car a1 Ou a1.ptr Ont peut-être été précédemment affectés à.

Évidemment, je préférerais plus de "rime et raison" - pourquoi l'optimisation est-elle parfois mais pas toujours - mais je ne suis pas prêt à saupoudrer de redondance if ( ptr != nullptr ) vérifie tout mon code RAII pour l'instant.

2
Arne Vogel

Je pense que le compilateur n'a aucune connaissance de "supprimer", en particulier que "supprimer null" est un NOOP.

Vous pouvez l'écrire explicitement, donc le compilateur n'a pas besoin d'impliquer la connaissance de la suppression.

AVERTISSEMENT: je ne recommande pas cela comme implémentation générale. L'exemple suivant devrait montrer comment vous pouvez "convaincre" un compilateur limité de supprimer le code de toute façon dans ce programme très spécial et limité

int main() {
 int* ptr = nullptr;

 if (ptr != nullptr) {
    delete ptr;
 }
}

Là où je me souviens bien, il existe un moyen de remplacer "supprimer" par une fonction propre. Et dans le cas où une optimisation par le compilateur se serait mal passée.


@RichardHodges: Pourquoi devrait-il s'agir d'une désoptimisation lorsque l'on donne au compilateur l'indice de supprimer un appel?

delete null est en général un NOOP (aucune opération). Cependant, comme il est possible de remplacer ou d'écraser la suppression, il n'y a aucune garantie pour tous les cas.

Il appartient donc au compilateur de savoir et de décider d'utiliser ou non les connaissances qui suppriment null. il y a de bons arguments pour les deux choix

Cependant, le compilateur est toujours autorisé à supprimer le code mort, ce "if (false) {...}" ou "if (nullptr! = Nullptr) {...}"

Donc, un compilateur supprimera le code mort, puis lors de l'utilisation de la vérification explicite, il ressemble à

int main() {
 int* ptr = nullptr;

 // dead code    if (ptr != nullptr) {
 //        delete ptr;
 //     }
}

Dites-moi s'il y a une désoptimisation?

J'appelle ma proposition un style de codage défensif, mais pas une désoptimisation

Si quelqu'un peut faire valoir que maintenant le non-nullptr provoquera une vérification à deux reprises sur nullptr, je dois répondre

  1. Désolé, ce n'était pas la question d'origine
  2. si le compilateur connaît la suppression, en particulier que la suppression null est un noop, alors le compilateur pourrait supprimer le cas échéant. Cependant, je ne m'attendrais pas à ce que les compilateurs soient si spécifiques

@Peter Cordes: Je suis d'accord pour garder avec un if n'est pas une règle d'optimisation générale. Cependant, l'optimisation générale n'était PAS la question de l'ouvreur. La question était de savoir pourquoi certains compilateurs n'éliminent pas la suppression dans un programme très court et sans sens. J'ai montré un moyen de faire en sorte que le compilateur l'élimine de toute façon.

Si une situation se produit comme dans ce programme court, quelque chose d'autre ne va probablement pas. En général, j'essaierais d'éviter le nouveau/supprimer (malloc/free) car les appels sont assez chers. Si possible, je préfère utiliser la pile (auto).

Quand je jette un œil au cas réel documenté entre-temps, je dirais que la classe X est mal conçue, ce qui entraîne de mauvaises performances et trop de mémoire. ( https://godbolt.org/g/7zGUvo )

Au lieu de

class X {
  int* i_;
  public:
  ...

dans la conception

class X {
  int i;
  bool valid;
  public:
  ...

ou plus tôt, je demanderais au sens de trier les éléments vides/invalides. En fin de compte, je voudrais aussi me débarrasser de "valide".

2
stefan bachert