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

Envisager 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 en quelque sorte tourné avec GCC? (Ma motivation plus large provient d'un problème de custom swap vs std::swap pour les types mobiles, où la suppression des pointeurs nuls peut représenter une pénalité de performance dans le second cas; voir https://stackoverflow.com/a/45689282/580083 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 move assignment operator et un destructor de quelque classe, puis std::swap avec des objets de cette classe donne 3 call instructions avec GCC. Il peut s'agir d'une pénalité de performance considérable, par exemple, lors du tri d'un tableau de tels objets.

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

en outre, j'aime vraiment la possibilité d'appeler delete sans la garde et il a été une surprise pour moi, qu'il pourrait produire différents résultats (de performance).

mise à JOUR

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

temps de fonctionnement de std::sort mesuré avec L'indicateur GCC 7.1 et -O2 sur Xeon E2680v3:

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

  1. sans if garde: 17.6 [s] 40.8 [s] ,
  2. avec if garde: 10.6 [s] 31.5 [s] ,
  3. avec if garde et coutume swap : 10.4 [s] 31.3 [s].

ces résultats étaient absolument cohérents pour de nombreux passages avec une déviation minimale. La différence de performance entre les deux premiers cas est significative et I Je ne dirais pas qu'il s'agit d'un "cas de coin extrêmement rare" comme le code.

45
demandé sur Thunderforge 2017-08-15 12:01:51

6 réponses

selon C++14 [expr.supprimer] / 7:

si la valeur de l'opérande de l'expression delete-n'est pas une valeur de pointeur nul, alors:

  • [...omettre... ]

dans le cas contraire, il n'est pas précisé si la fonction de désallocation sera appelée.

donc les deux compilateurs sont conformes à la norme, parce qu'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 ne fait que compiler le fichier source sans lien. Ainsi, le compilateur à ce stade doit tenir compte de la possibilité que operator delete soit remplacé par un autre fichier source.

comme déjà spéculé dans une autre réponse -- gcc peut être à la recherche d'un comportement cohérent dans le cas d'un remplacement operator delete ; cette mise en œuvre signifierait que quelqu'un peut surcharger cette fonction à des fins de débogage et casser sur toutes les invocations de l'expression delete , même quand il s'est avéré être la suppression d'un pointeur nul.

mise à JOUR: suppression de la spéculation que cela pourrait ne pas être un problème pratique, puisque OP fourni des repères, en montrant qu'il en fait.

29
répondu M.M 2017-08-16 22:16:20

c'est une question de QOI. clang fait bien elide le test:

https://godbolt.org/g/nBSykD

main:                                   # @main
        xor     eax, eax
        ret
7
répondu Richard Hodges 2017-08-15 10:08:43
La norme

stipule 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 l'allocation globale et fonctions de désallocation. Une allocation globale et une désallocation les fonctions sont remplaçables (18.6.1). Un programme C++ doit fournir la plupart des définitions d'une allocation ou d'une désallocation remplaçable fonction. Une telle définition de fonction remplace la version par défaut fournis dans la bibliothèque (17.6.4.6). L'affectation suivante et les fonctions de désallocation (18.6) sont implicitement déclarées dans le champ d'Application global dans chaque unité de traduction d'un programme.

serait probablement la principale raison pour laquelle ces appels de fonction ne sont pas omis de façon arbitraire. S'ils l'étaient, le remplacement de leur mise en œuvre de bibliothèque causerait fonction incohérente de programme compilé.

dans le première alternative (supprimer l'objet), la valeur de l'opérande de supprimer peut être une valeur de pointeur null, un pointeur vers un non-objet array créé par une précédente nouvelle expression, ou un pointeur vers un sous-objet (1.8) représentant une classe de base d'un tel objet (article 10). Si ce n', le comportement est indéfini.

si l'argument donné à une fonction de désallocation dans le standard la bibliothèque est un pointeur qui n'est pas la valeur de pointeur null (4.10), la la fonction de libération est désallouer le stockage référencé par le pointeur, rendant invalides tous les pointeurs se référant à n'importe quelle partie de la libéré de stockage. Indirectement par une valeur de pointeur invalide et passer une valeur de pointeur invalide à une fonction de désallocation comportement non défini. Toute autre utilisation d'une valeur de pointeur invalide a comportement défini par la mise en œuvre.

...

si la valeur de l'opérande de l'expression delete est pas un null valeur du pointeur, puis

  • si l'appel de répartition pour la nouvelle expression de l'objet à supprimer n'a pas été omis et la répartition n'a pas été étendue (5.3.4), l'expression delete-appellera une fonction de deallocation (3.7.4.2). La valeur retournée de l'appel d'attribution de la nouvelle expression doit être passé comme premier argument de la fonction de libération.

  • Dans le cas contraire, si l'allocation a été étendue ou a été fournie par l'extension de l'allocation d'une autre newexpression, et que l'expression delete pour chaque autre valeur de pointeur produite par une nouvelle expression qui a été stockée par la nouvelle expression étendue a été évaluée, l'expression delete doit appeler a fonction de désallocation. La valeur rendue de l'appel d'attribution de la nouvelle-expression doit être passé comme premier argument la fonction de libération.

    • sinon, l'expression delete n'appellera pas une fonction de deallocation

sinon, il n'est pas précisé si la fonction de désallocation sera appelée.

La norme

indique ce qui doit être fait si le pointeur n'est pas nul. Ce qui implique que supprimer dans ce cas est noop, mais à quelle fin, n'est pas spécifié.

7
répondu Swift - Friday Pie 2017-08-15 11:33:19

il est toujours sûr (pour l'exactitude) de laisser votre programme appeler operator delete avec un nullptr.

pour la performance, il est très rare que le fait d'avoir l'asm générée par le compilateur fasse un test supplémentaire et une branche conditionnelle pour passer un appel à operator delete sera une victoire. (Vous pouvez aider gcc à optimiser la suppression de compilations nullptr sans ajouter de vérification d'exécution, cependant; voir ci-dessous).

tout d'abord, plus grande taille de code à l'extérieur d'un le vrai hot-spot augmente la pression sur le cache L1I, et le cache encore plus petit décodé-uop sur les CPU x86 qui en ont un (Famille SNB Intel, AMD Ryzen).

Deuxièmement, les branches extra conditionnelles utilisent les entrées dans les caches de prédiction de branche (BTB = tampon de cible de branche et ainsi de suite). Selon le CPU, même une branche qui n'est jamais prise peut aggraver les prédictions pour d'autres branches si elle les aliène dans le BTB. (Sur d'autres, une telle branche n'obtient jamais une entrée dans le BTB, pour sauver entrées pour les branches où la prédiction statique par défaut de chute-à travers est précise.) Voir . https://xania.org/201602/bpu-part-one .

si nullptr est rare dans un chemin de code donné, alors en moyenne check & branch pour éviter le call finit par votre programme passer plus de temps sur le check que le check sauve.

si le profilage montre que vous avez un point chaud qui comprend un delete , et l'instrumentation / l'enregistrement montre qu'il appelle souvent delete avec un nullptr, alors il vaut la peine d'essayer

if (ptr) delete ptr; au lieu de juste delete ptr;

prédiction de la 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 voisines. (Apparemment, les BPU modernes ne se contentent pas de regarder chaque branche isolément.) Ceci s'ajoute à la sauvegarde de l'inconditionnel call dans la fonction de bibliothèque (plus un autre jmp du talon PLT, de dynamic linking overhead sur Unix/Linux).


si vous vérifiez pour null pour toute autre raison, alors il pourrait être logique de mettre le delete dans la branche non-null de votre code.

vous pouvez éviter les appels delete dans les cas où gcc peut prouver (après lining) qu'un pointeur est nul, mais sans faire d'exécution cocher si non :

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 parce qu'il évalue __builtin_constant_p avant de s'incliner. Mais comme clang évite déjà les appels delete quand il peut prouver qu'un pointeur est nul, vous n'en avez pas besoin.

cela pourrait réellement aider dans std::move cas, et vous pouvez l'utiliser en toute sécurité n'importe où avec (en théorie) aucun inconvénient de performance. J'ai toujours compile if(true) ou if(false) , donc c'est très différent de if(ptr) , qui est susceptible d'aboutir à une branche runtime parce que le compilateur ne peut probablement pas prouver que le pointeur est non-null dans la plupart des cas non plus. (Une dereference pourrait, cependant, parce qu'un deref null serait UB, et des compilateurs modernes optimisés sur la base de l'hypothèse que le code ne contient pas D'UB).

vous pourriez en faire une macro pour éviter de gonfler des constructions non optimisées (et donc cela "marcherait" sans avoir à s'aligner premier.) Vous pouvez utiliser une expression de déclaration GNU C pour éviter une double évaluation de la macro arg ( voir les exemples pour GNU C min() et max() ). Pour le repli des compilateurs sans extensions GNU, vous pouvez écrire ((ptr), false) ou quelque chose pour évaluer l'arg une fois pour les effets secondaires tout en produisant un résultat false .

Demonstration: asm from gcc6.3 - O3 sur le compilateur Godbolt explorer

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 de l'Explorateur de compilateur), mais avec le test retournant 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

intéressant de noter que le 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.


Liées: j'ai trouvé ce blog , en utilisant C11 (pas C++11) _Generic pour essayer de faire quelque chose comme __builtin_constant_p null-pointer vérifie à l'intérieur des initialiseurs statiques.

5
répondu Peter Cordes 2017-08-16 19:22:20

tout d'abord, je suis d'accord avec certains de ceux qui ont déjà répondu que ce n'est pas un bug, et GCC peut faire ce qu'il veut ici. Cela dit, je me demandais si cela signifiait qu'un code RAII commun et simple pourrait être plus lent sur GCC que Clang parce qu'une optimisation simple n'est pas faite.

donc j'ai écrit un petit cas d'essai 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 voir ici , GCC does elide le deuxième appel à delete dans setA1 (pour le mouvement temporaire qui a été créé dans l'appel à getA2 ). Le premier appel est nécessaire pour la rectitude du programme parce que a1 ou a1.ptr peut avoir été précédemment assigné à.

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

2
répondu Arne Vogel 2017-08-15 16:05:38

je pense que le compilateur n'a aucune connaissance de" delete", surtout que" delete null " est un NOOP.

Vous pouvez écrire explicite, de sorte que le compilateur n'a pas besoin d'impliquer des connaissances sur supprimer.

AVERTISSEMENT: je ne recommande pas ce que la mise en œuvre générale. L'exemple suivant devrait montrer, comment vous pourriez "convaincre" un compilateur limité de supprimer du 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 à droite, il y a un moyen de remplacer "supprimer" par une fonction propre. Et dans le cas où une optimisation par le compilateur aurait mal tourné.


@RichardHodges: pourquoi faudrait-il une dé-optimisation quand on donne au compilateur l'indication de supprimer un appel?

supprimer null est en général un NOOP (aucune opération). Cependant, puisqu'il est possible de remplacer ou écraser supprimer il n'y a pas de garantie pour tous les cas.

Donc, c'est au compilateur de savoir et de décider d'utiliser les connaissances de supprimer la valeur null peut toujours supprimé. il y a de bons arguments pour les deux choix

cependant, le compilateur est toujours autorisé à supprimer le code mort, ceci "si (false) {...}" ou "si (nullptr != nullptr) {...} "

donc un compilateur va supprimer le code mort et puis en utilisant la vérification explicite, il ressemble à

int main() {
 int* ptr = nullptr;

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

s'il vous Plaît dites-moi, où est-il de l'optimisation?

j'appelle ma proposition un style défensif de codage, mais pas une dé-optimisation

si quelqu'un peut argumenter, que maintenant le non-nullptr provoquera deux vérifications sur nullptr, je dois répondre

  1. Désolé, ce n'était pas la question originale
  2. si le compilateur sait à propos de delete, surtout que delete null est un noop, que le compilateur pourrait supprimer l'extérieur si soit. Cependant, je ne m'attendrais pas à ce que les compilateurs soient si spécifiques

@Peter Cordes: je suis d'accord pour dire que la surveillance avec un si n'est pas une règle générale d'optimisation. 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 non-sens très court. J'ai montré un moyen de rendre le compilateur de l'éliminer de toute façon.

si une situation se produit comme dans ce programme court, probablement quelque chose d'autre a tort. En général, je voudrais essayer d'éviter Nouveau/supprimer (malloc/gratuit) que les appels sont assez coûteux. Si possible, je préfère utiliser la pile (auto).

quand je jette un coup d'oeil sur le cas réel entre-temps documenté, je dirais que la classe X est mal conçue, causant 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 voudrais demander le sentiment de tri des articles vides/invalides. En fin de compte, je voudrais me débarrasser de "VALIDE", aussi.

2
répondu stefan bachert 2017-08-15 19:21:06