web-dev-qa-db-fra.com

Le compilateur est-il autorisé à optimiser les allocations de mémoire en tas?

Considérez le code simple suivant qui utilise new (je sais qu'il n'y a pas de delete[], mais cela ne concerne pas cette question):

int main()
{
    int* mem = new int[100];

    return 0;
}

Le compilateur est-il autorisé à optimiser l'appel new?

Dans mes recherches, g ++ (5.2.0) et Visual Studio 2015 n'optimisent pas l'appel new, contrairement à clang (3.0+) . Tous les tests ont été effectués avec des optimisations complètes activées (-O3 pour g ++ et clang, mode Release pour Visual Studio).

Est-ce que new ne fait pas d'appel système sous le capot, ce qui rend impossible (et illégal) pour un compilateur d'optimiser cela?

[~ # ~] modifier [~ # ~] : J'ai maintenant exclu le comportement non défini du programme:

#include <new>  

int main()
{
    int* mem = new (std::nothrow) int[100];
    return 0;
}

clang 3.0 n'optimise plus cela plus, mais les versions ultérieures le font .

EDIT2 :

#include <new>  

int main()
{
    int* mem = new (std::nothrow) int[1000];

    if (mem != 0)
      return 1;

    return 0;
}

clang renvoie toujours 1 .

67
Banex

L'histoire semble être que clang suit les règles énoncées dans N3664: Clarifying Memory Allocation qui permet au compilateur d'optimiser autour des allocations de mémoire mais comme Nick Lewycky souligne :

Shafik a souligné que cela semble violer la causalité, mais N3664 a commencé sa vie en tant que N3433, et je suis presque sûr que nous avons écrit l'optimisation en premier et que nous avons ensuite écrit le document par la suite.

Clang a donc implémenté l'optimisation qui est devenue plus tard une proposition implémentée dans le cadre de C++ 14.

La question de base est de savoir s'il s'agit d'une optimisation valide avant N3664, C'est une question difficile. Il faudrait aller à la comme si la règle couverte dans le projet de norme C++ section 1.9 Exécution du programme qui dit ( c'est moi qui souligne):

Les descriptions sémantiques de la présente Norme internationale définissent une machine abstraite non déterministe paramétrée. La présente Norme internationale n'impose aucune exigence sur la structure des implémentations conformes. En particulier, ils n'ont pas besoin de copier ou d'émuler la structure de la machine abstraite. Au contraire, des implémentations conformes sont nécessaires pour émuler (uniquement) le comportement observable de la machine abstraite comme expliqué ci-dessous.5

où note 5 dit:

Cette disposition est parfois appelée la règle "comme si" , car une mise en œuvre est libre de ne pas tenir compte de toute exigence de la présente Norme internationale tant que le résultat est comme si l'exigence avait été respectée, dans la mesure où cela peut être déterminé à partir du comportement observable du programme. Par exemple, une implémentation réelle n'a pas besoin d'évaluer une partie d'une expression si elle peut en déduire que sa valeur n'est pas utilisée et qu'aucun effet secondaire affectant le comportement observable du programme n'est produit.

Étant donné que new pourrait lever une exception qui aurait un comportement observable car elle modifierait la valeur de retour du programme, cela semblerait plaider contre son autorisation par la règle comme si =.

Bien que l'on puisse faire valoir que c'est le détail de l'implémentation quand lever une exception et donc clang pourrait décider même dans ce scénario qu'il ne provoquerait pas d'exception et donc élider l'appel new ne violerait pas le comme -si règle.

Il semble également valide selon la règle comme si pour optimiser également l'appel vers la version non lancée.

Mais nous pourrions avoir un opérateur global de remplacement nouveau dans une unité de traduction différente, ce qui pourrait affecter le comportement observable, de sorte que le compilateur devrait avoir un moyen de prouver que ce n'était pas le cas, sinon il ne serait pas en mesure d'effectuer cette optimisation sans violer la règle comme si. Les versions précédentes de clang ont en effet optimisé dans ce cas comme cet exemple de godbolt montre qui a été fourni via Casey ici , en prenant ce code:

#include <cstddef>

extern void* operator new(std::size_t n);

template<typename T>
T* create() { return new T(); }

int main() {
    auto result = 0;
    for (auto i = 0; i < 1000000; ++i) {
        result += (create<int>() != nullptr);
    }

    return result;
}

et l'optimiser à cela:

main:                                   # @main
    movl    $1000000, %eax          # imm = 0xF4240
    ret

Cela semble en effet beaucoup trop agressif, mais les versions ultérieures ne semblent pas le faire.

51
Shafik Yaghmour

Ceci est autorisé par N3664 .

Une implémentation est autorisée à omettre un appel à une fonction d'allocation globale remplaçable (18.6.1.1, 18.6.1.2). Dans ce cas, le stockage est plutôt fourni par l'implémentation ou fourni en étendant l'allocation d'une autre nouvelle expression.

Cette proposition fait partie de la norme C++ 14, donc en C++ 14 le compilateur est autorisé à optimiser un new expression (même si elle pourrait jeter).

Si vous jetez un oeil à Clang implementation status , il indique clairement qu'ils implémentent N3664.

Si vous observez ce comportement lors de la compilation en C++ 11 ou C++ 03, vous devez remplir un bogue.

Notez qu'avant C++ 14, les allocations de mémoire dynamique font partie de l'état observable du programme (bien que je ne puisse pas trouver de référence pour le moment ), une implémentation conforme n'était donc pas autorisée à appliquer la règle comme si dans ce cas.

18
sbabbi

Gardez à l'esprit que la norme C++ indique ce qu'un programme correct doit faire, pas comment il doit le faire. Il ne peut pas le dire du tout plus tard, car de nouvelles architectures peuvent apparaître et surviennent après que la norme a été écrite et que la norme doit leur être utile.

new ne doit pas être un appel système sous le capot. Il existe des ordinateurs utilisables sans système d'exploitation et sans concept d'appel système.

Par conséquent, tant que le comportement final ne change pas, le compilateur peut optimiser tout et n'importe quoi. Y compris que new

Il y a une mise en garde.
Un opérateur global de remplacement nouveau aurait pu être défini dans une autre unité de traduction
Dans ce cas, les effets secondaires du nouveau peuvent être tels qu'ils ne peuvent pas être optimisés. Mais si le compilateur peut garantir que le nouvel opérateur n'a pas d'effets secondaires, comme ce serait le cas si le code publié est le code entier, alors l'optimisation est valide.
Ce nouveau peut lancer std :: bad_alloc n'est pas une exigence. Dans ce cas, lorsque new est optimisé, le compilateur peut garantir qu'aucune exception ne sera levée et qu'aucun effet secondaire ne se produira.

10

Il est parfaitement permis (mais pas obligatoire) pour un compilateur d'optimiser les allocations dans votre exemple d'origine, et plus encore dans l'exemple EDIT1 par §1.9 de la norme, qui est généralement référencé comme règle comme si:

Des implémentations conformes sont requises pour émuler (uniquement) le comportement observable de la machine abstraite comme expliqué ci-dessous:
[3 pages de conditions]

Une représentation plus lisible par l'homme est disponible sur cppreference.com .

Les points pertinents sont:

  • Vous n'avez pas de volatiles, donc 1) et 2) ne s'appliquent pas.
  • Vous ne générez/n'écrivez aucune donnée ni n'invitez l'utilisateur, donc 3) et 4) ne s'appliquent pas. Mais même si vous l'avez fait, ils seraient clairement satisfaits dans EDIT1 (sans doute aussi dans l'exemple d'origine, bien que d'un point de vue purement théorique, ce soit illégal puisque le flux et la sortie du programme - théoriquement - diffère, mais voir deux paragraphes ci-dessous).

Une exception, même non détectée, est un comportement bien défini (pas indéfini!). Cependant, à strictement parler, dans le cas où new lance (ne va pas se produire, voir également le paragraphe suivant), le comportement observable serait différent, à la fois par le code de sortie du programme et par toute sortie qui pourrait suivre plus tard dans le programme.

Maintenant, dans le cas particulier d'une petite allocation singulière, vous pouvez donner au compilateur le "bénéfice du doute" qu'il peut garantir que l'allocation n'échouera pas.
Même sur un système soumis à une pression de mémoire très élevée, il n'est même pas possible de démarrer un processus lorsque vous avez moins que la granularité d'allocation minimale disponible, et le segment de mémoire aura été configuré avant d'appeler main, aussi. Donc, si cette allocation échouait, le programme ne démarrerait jamais ou aurait déjà rencontré une fin ingrate avant même que main soit appelé.
Dans la mesure où, en supposant que le compilateur le sache, même si l'allocation pourrait en théorie lancer, il est légal même d'optimiser l'exemple d'origine, puisque le compilateur peut pratiquement garantit que cela ne se produira pas.

<légèrement indécis>
D'autre part, il n'est pas autorisé (et comme vous pouvez le constater, un bogue du compilateur) pour optimiser l'allocation dans votre Exemple EDIT2. La valeur est consommée pour produire un effet observable de l'extérieur (le code retour).
Notez que si vous remplacez new (std::nothrow) int[1000] par new (std::nothrow) int[1024*1024*1024*1024ll] (c'est une allocation de 4 To!), Qui est - sur les ordinateurs actuels - garanti d'échouer, il optimise toujours l'appel. En d'autres termes, il renvoie 1 bien que vous ayez écrit du code qui doit sortir 0.

@Yakk a soulevé un bon argument contre cela: tant que la mémoire n'est jamais touchée, un pointeur peut être retourné, et non réel RAM est nécessaire. Dans la mesure où il serait même légitime d'optimiser l'allocation dans EDIT 2. Je ne sais pas qui a raison et qui a tort ici.

Faire une allocation de 4 To est à peu près garanti d'échouer sur une machine qui n'a pas au moins quelque chose comme une quantité de gigaoctets à deux chiffres de RAM simplement parce que le système d'exploitation doit créer des tables de pages. Maintenant bien sûr, la norme C++ ne se soucie pas des tables de pages ou de ce que le système d'exploitation fait pour fournir de la mémoire, c'est vrai.

Mais d'un autre côté, l'hypothèse "cela fonctionnera si la mémoire n'est pas touchée" dépend sur exactement un tel détail et sur quelque chose que l'OS fournit. L'hypothèse que si RAM qui n'est pas touché, il n'est en fait pas nécessaire est seulement vraie parce que l'OS fournit de la mémoire virtuelle. Et cela implique que l'OS doit créer des tables de pages (je peux prétendre que je ne suis pas au courant, mais cela ne change pas le fait que j'y compte de toute façon).

Par conséquent, je pense qu'il n'est pas correct à 100% de supposer d'abord l'un puis de dire "mais nous ne nous soucions pas de l'autre".

Donc, oui, le compilateur peut suppose qu'une allocation de 4 To est en général parfaitement possible tant que la mémoire n'est pas touchée, et peut suppose qu'elle est généralement possible de réussir. Il pourrait même supposer qu'il est susceptible de réussir (même s'il ne l'est pas). Mais je pense qu'en tout cas, vous n'êtes jamais autorisé à supposer que quelque chose doit fonctionner quand il y a une possibilité d'échec. Et non seulement il y a une possibilité d'échec, dans cet exemple, l'échec est même la possibilité plus probable.
</ légèrement indécis>

7
Damon

Le pire qui puisse arriver dans votre extrait est que new jette std::bad_alloc, qui n'est pas géré. Ce qui se passe alors est défini par l'implémentation.

Le meilleur cas étant un no-op et le pire des cas n'étant pas défini, le compilateur est autorisé à les factoriser en non-existence. Maintenant, si vous essayez de saisir l'exception possible:

int main() try {
    int* mem = new int[100];
    return 0;
} catch(...) {
  return 1;
}

... puis l'appel à operator new est conservé .

2
Quentin