Une variable membre inutilisée occupe-t-elle de la mémoire?

91

L'initialisation d'une variable membre et ne pas la référencer / l'utiliser occupe-t-elle davantage de RAM pendant l'exécution, ou le compilateur ignore-t-il simplement cette variable?

struct Foo {
    int var1;
    int var2;

    Foo() { var1 = 5; std::cout << var1; }
};

Dans l'exemple ci-dessus, le membre 'var1' obtient une valeur qui est ensuite affichée dans la console. 'Var2', cependant, n'est pas du tout utilisé. Par conséquent, l'écrire dans la mémoire pendant l'exécution serait un gaspillage de ressources. Le compilateur prend-il en compte ces types de situations et ignore simplement les variables inutilisées, ou l'objet Foo a-t-il toujours la même taille, que ses membres soient ou non utilisés?

Chriss555888
la source
25
Cela dépend du compilateur, de l'architecture, du système d'exploitation et de l'optimisation utilisée.
Owl
16
Il existe une tonne métrique de code de pilote de bas niveau qui ajoute spécifiquement des membres de structure à ne rien faire pour que le remplissage corresponde aux tailles de trame de données matérielles et comme un hack pour obtenir l'alignement de mémoire souhaité. Si un compilateur commençait à les optimiser, il y aurait beaucoup de bris.
Andy Brown
2
@Et ils ne font pas vraiment rien, car l'adresse des membres de données suivants est évaluée. Cela signifie que l'existence de ces membres de remplissage a un comportement observable sur le programme. Ici, var2non.
YSC
4
Je serais surpris si le compilateur pouvait l'optimiser étant donné que toute unité de compilation adressant une telle structure pourrait être liée à une autre unité de compilation utilisant la même structure et le compilateur ne peut pas savoir si l'unité de compilation séparée s'adresse au membre ou non.
Galik
2
@geza sizeof(Foo)ne peut pas diminuer par définition - si vous imprimez, sizeof(Foo)il doit céder8 (sur les plates-formes courantes). Les compilateurs peuvent optimiser l'espace utilisé par var2(que ce soit à travers newou sur la pile ou dans les appels de fonction ...) dans n'importe quel contexte qu'ils trouvent raisonnable, même sans LTO ou l'optimisation de l'ensemble du programme. Là où ce n'est pas possible, ils ne le feront pas, comme pour n'importe quelle autre optimisation. Je crois que la modification de la réponse acceptée la rend beaucoup moins susceptible d'être induite en erreur.
Max Langhof

Réponses:

106

La règle d'or "as-if" 1 du C ++ déclare que, si le comportement observable d'un programme ne dépend pas d'une existence de membre de données inutilisé, le compilateur est autorisé à l'optimiser .

Une variable membre inutilisée occupe-t-elle de la mémoire?

Non (s'il est "vraiment" inutilisé).


Maintenant vient deux questions à l'esprit:

  1. Quand le comportement observable ne dépendrait-il pas de l'existence d'un membre?
  2. Ce genre de situation se produit-il dans des programmes réels?

Commençons par un exemple.

Exemple

#include <iostream>

struct Foo1
{ int var1 = 5;           Foo1() { std::cout << var1; } };

struct Foo2
{ int var1 = 5; int var2; Foo2() { std::cout << var1; } };

void f1() { (void) Foo1{}; }
void f2() { (void) Foo2{}; }

Si nous demandons à gcc de compiler cette unité de traduction , il renvoie:

f1():
        mov     esi, 5
        mov     edi, OFFSET FLAT:_ZSt4cout
        jmp     std::basic_ostream<char, std::char_traits<char> >::operator<<(int)
f2():
        jmp     f1()

f2est le même que f1, et aucune mémoire n'est jamais utilisée pour contenir un réel Foo2::var2. ( Clang fait quelque chose de similaire ).

Discussion

Certains peuvent dire que c'est différent pour deux raisons:

  1. c'est un exemple trop trivial,
  2. la structure est entièrement optimisée, ça ne compte pas.

Eh bien, un bon programme est un assemblage intelligent et complexe de choses simples plutôt qu'une simple juxtaposition de choses complexes. Dans la vraie vie, vous écrivez des tonnes de fonctions simples en utilisant des structures simples que le compilateur optimise. Par exemple:

bool insert(std::set<int>& set, int value)
{
    return set.insert(value).second;
}

Ceci est un exemple authentique d'un membre de données (ici std::pair<std::set<int>::iterator, bool>::first) non utilisé. Devine quoi? Il est optimisé à distance ( exemple plus simple avec un ensemble factice si cet assemblage vous fait pleurer).

Ce serait le moment idéal pour lire l'excellente réponse de Max Langhof (votez-la pour moi s'il vous plaît). Cela explique pourquoi, en fin de compte, le concept de structure n'a pas de sens au niveau de l'assemblage que le compilateur produit.

"Mais, si je fais X, le fait que le membre inutilisé soit optimisé est un problème!"

Il y a eu un certain nombre de commentaires faisant valoir que cette réponse doit être fausse parce qu'une opération (comme assert(sizeof(Foo2) == 2*sizeof(int))) casserait quelque chose.

Si X fait partie du comportement observable du programme 2 , le compilateur n'est pas autorisé à optimiser les choses. Il y a beaucoup d'opérations sur un objet contenant un membre de données "inutilisé" qui aurait un effet observable sur le programme. Si une telle opération est effectuée ou si le compilateur ne peut pas prouver qu'aucune n'est effectuée, ce membre de données "inutilisé" fait partie du comportement observable du programme et ne peut pas être optimisé .

Les opérations qui affectent le comportement observable incluent, mais ne sont pas limitées à:

  • prenant la taille d'un type d'objet (sizeof(Foo) ),
  • en prenant l'adresse d'un membre de données déclarée après celle "non utilisée",
  • copier l'objet avec une fonction comme memcpy,
  • manipuler la représentation de l'objet (comme avec memcmp ),
  • qualifier un objet de volatil ,
  • etc .

1)

[intro.abstract]/1

Les descriptions sémantiques de ce document définissent une machine abstraite paramétrée non déterministe. Ce document 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, les implémentations conformes sont nécessaires pour émuler (uniquement) le comportement observable de la machine abstraite comme expliqué ci-dessous.

2) Comme une affirmation réussie ou échouée.

YSC
la source
Les commentaires suggérant des améliorations à la réponse ont été archivés dans le chat .
Cody Gray
1
Même le assert(sizeof(…)…)ne contraint pas réellement le compilateur - il doit fournir un sizeofqui permet au code d'utiliser des choses comme memcpyle travail, mais cela ne signifie pas que le compilateur est en quelque sorte obligé d'utiliser autant d'octets à moins qu'ils ne soient exposés à un memcpytel qu'il puisse ne réécrivez pas pour produire la valeur correcte de toute façon.
Davis Herring le
@ Davis Absolument.
YSC
63

Il est important de réaliser que le code produit par le compilateur n'a aucune connaissance réelle de vos structures de données (car une telle chose n'existe pas au niveau de l'assembly), et l'optimiseur non plus. Le compilateur produit uniquement du code pour chaque fonction , pas des structures de données .

Ok, il écrit également des sections de données constantes et autres.

Sur cette base, nous pouvons déjà dire que l'optimiseur ne «supprimera» ni «éliminera» les membres, car il ne génère pas de structures de données. Il produit du code , qui peut ou non utiliser les membres, et parmi ses objectifs est d'économiser de la mémoire ou des cycles en éliminant les utilisations inutiles (c'est-à-dire les écritures / lectures) des membres.


L'essentiel est que «si le compilateur peut prouver dans le cadre d'une fonction (y compris les fonctions qui y étaient incorporées) que le membre inutilisé ne fait aucune différence quant au fonctionnement de la fonction (et à ce qu'elle renvoie), alors il y a de bonnes chances que la présence du membre n'entraîne aucune surcharge ".

Lorsque vous rendez les interactions d'une fonction avec le monde extérieur plus compliquées / peu claires pour le compilateur (prenez / retournez des structures de données plus complexes, par exemple std::vector<Foo> , cachez la définition d'une fonction dans une unité de compilation différente, interdisez / désinciterez l'inlining, etc.) , il devient de plus en plus probable que le compilateur ne puisse pas prouver que le membre inutilisé n'a aucun effet.

Il n'y a pas de règles strictes ici car tout dépend des optimisations effectuées par le compilateur, mais tant que vous faites des choses triviales (comme le montre la réponse de YSC), il est très probable qu'aucune surcharge ne sera présente, tout en faisant des choses compliquées (par exemple, retourner a std::vector<Foo>d'une fonction trop grande pour l'inlining) entraînera probablement la surcharge.


Pour illustrer ce point, considérons cet exemple :

struct Foo {
    int var1 = 3;
    int var2 = 4;
    int var3 = 5;
};

int test()
{
    Foo foo;
    std::array<char, sizeof(Foo)> arr;
    std::memcpy(&arr, &foo, sizeof(Foo));
    return arr[0] + arr[4];
}

Nous faisons des choses non triviales ici (prendre des adresses, inspecter et ajouter des octets à partir de la représentation d'octets ) et pourtant l'optimiseur peut comprendre que le résultat est toujours le même sur cette plate-forme:

test(): # @test()
  mov eax, 7
  ret

Non seulement les membres de Foon'occupaient aucune mémoire, mais Fooils n'ont même pas vu le jour! S'il y a d'autres utilisations qui ne peuvent pas être optimisées, par exemple, cela sizeof(Foo)peut avoir de l'importance - mais uniquement pour ce segment de code! Si toutes les utilisations pouvaient être optimisées de cette manière, l'existence de par exemple var3n'influencait pas le code généré. Mais même s'il est utilisé ailleurs, test()resterait optimisé!

En bref: chaque utilisation de Fooest optimisée indépendamment. Certains peuvent utiliser plus de mémoire en raison d'un membre inutile, d'autres non. Consultez le manuel de votre compilateur pour plus de détails.

Max Langhof
la source
6
Mic drop "Consultez le manuel de votre compilateur pour plus de détails." : D
YSC
22

Le compilateur n'optimisera une variable membre inutilisée (en particulier une variable publique) que s'il peut prouver que la suppression de la variable n'a pas d'effets secondaires et qu'aucune partie du programme ne dépend de sa taille Foo.

Je ne pense pas qu'un compilateur actuel effectue de telles optimisations à moins que la structure ne soit vraiment utilisée du tout. Certains compilateurs peuvent au moins avertir des variables privées inutilisées, mais généralement pas des variables publiques.

Alan Birtles
la source
1
Et pourtant, c'est le cas: godbolt.org/z/UJKguS + aucun compilateur n'avertirait pour un membre de données inutilisé.
YSC
@YSC clang ++ avertit des membres de données et des variables inutilisés.
Maxim Egorushkin
3
@YSC Je pense que c'est une situation légèrement différente, elle optimise complètement la structure et imprime juste 5 directement
Alan Birtles
4
@AlanBirtles Je ne vois pas en quoi c'est différent. Le compilateur a tout optimisé à partir de l'objet qui n'a aucun effet sur le comportement observable du programme. Donc votre première phrase "le compilateur est très peu susceptible d'optimiser awau une variable membre inutilisée" est fausse.
YSC
2
@YSC en code réel où la structure est réellement utilisée plutôt que simplement construite pour ses effets secondaires, il est probablement plus improbable qu'elle soit optimisée
Alan Birtles
7

En général, vous devez supposer que vous obtenez ce que vous avez demandé, par exemple, les variables membres "inutilisées" sont là.

Puisque dans votre exemple les deux membres le sont public, le compilateur ne peut pas savoir si du code (en particulier d'autres unités de traduction = d'autres fichiers * .cpp, qui sont compilés séparément puis liés) accède au membre "inutilisé".

La réponse de YSC donne un exemple très simple, où le type de classe n'est utilisé que comme variable de durée de stockage automatique et où aucun pointeur vers cette variable n'est pris. Là, le compilateur peut incorporer tout le code et peut ensuite éliminer tout le code mort.

Si vous avez des interfaces entre des fonctions définies dans différentes unités de traduction, généralement le compilateur ne sait rien. Les interfaces suivent généralement des ABI prédéfinis (comme ça ) de sorte que différents fichiers objets peuvent être liés ensemble sans aucun problème. En général, les ABI ne font pas de différence si un membre est utilisé ou non. Ainsi, dans de tels cas, le deuxième membre doit être physiquement dans la mémoire (à moins d'être éliminé plus tard par l'éditeur de liens).

Et tant que vous êtes dans les limites de la langue, vous ne pouvez pas observer qu'une élimination se produit. Si vous appelez sizeof(Foo), vous obtiendrez 2*sizeof(int). Si vous créez un tableau de Foos, la distance entre les débuts de deux objets consécutifs de Fooest toujours sizeof(Foo)octets.

Votre type est un type de mise en page standard , ce qui signifie que vous pouvez également accéder aux membres en fonction des décalages calculés lors de la compilation (cf. la offsetofmacro). De plus, vous pouvez inspecter la représentation octet par octet de l'objet en copiant sur un tableau d' charutilisation std::memcpy. Dans tous ces cas, le deuxième membre peut être observé.

Handy999
la source
Les commentaires ne sont pas destinés à une discussion approfondie; cette conversation a été déplacée vers le chat .
Cody Gray
2
+1: seule une optimisation agressive de l'ensemble du programme pourrait éventuellement ajuster la disposition des données (y compris les tailles et les décalages au moment de la compilation) pour les cas où un objet struct local n'est pas entièrement optimisé,. gcc -fwhole-program -O3 *.cpourrait en théorie le faire, mais en pratique ne le fera probablement pas. (par exemple, au cas où le programme émettrait des hypothèses sur la valeur exacte sizeof()de cette cible, et parce que c'est une optimisation vraiment compliquée que les programmeurs devraient faire à la main s'ils le veulent.)
Peter Cordes
6

Les exemples fournis par d'autres réponses à cette question qui élisent var2sont basés sur une seule technique d'optimisation: propagation constante, puis élision de l'ensemble de la structure (pas l'élision de juste var2). C'est le cas simple, et l'optimisation des compilateurs l'implémente.

Pour les codes C / C ++ non gérés, la réponse est que le compilateur ne sera généralement pas élidé var2. Autant que je sache, il n'y a pas de support pour une telle transformation de structure C / C ++ dans les informations de débogage, et si la structure est accessible en tant que variable dans un débogueur, elle var2ne peut pas être éludée. Autant que je sache, aucun compilateur C / C ++ actuel ne peut spécialiser les fonctions en fonction de l'élision de var2, donc si la structure est passée ou retournée à une fonction non intégrée, elle var2ne peut pas être élidée.

Pour les langages gérés tels que C # / Java avec un compilateur JIT, le compilateur peut être en mesure d'éliminer en toute sécurité var2 car il peut suivre avec précision s'il est utilisé et s'il s'échappe vers du code non managé. La taille physique de la structure dans les langages managés peut être différente de sa taille signalée au programmeur.

Les compilateurs C / C ++ de l'année 2019 ne peuvent pas éluder var2la structure à moins que la variable struct entière ne soit élidée. Pour les cas intéressants d'élision de var2la structure, la réponse est: Non.

Certains futurs compilateurs C / C ++ pourront échapper var2à la structure, et l'écosystème construit autour des compilateurs devra s'adapter aux informations d'élision de processus générées par les compilateurs.

atomesymbole
la source
1
Votre paragraphe sur les informations de débogage se résume à "nous ne pouvons pas l'optimiser si cela rendrait le débogage plus difficile", ce qui est tout simplement faux. Ou je me trompe. Pourriez-vous clarifier?
Max Langhof
Si le compilateur émet des informations de débogage sur la structure, il ne peut pas élider var2. Les options sont: (1) Ne pas émettre les informations de débogage si elles ne correspondent pas à la représentation physique de la structure, (2) Soutenir l'élision des membres de la structure dans les informations de débogage et émettre les informations de débogage
Atomymbol
Peut-être plus général est de se référer au remplacement scalaire des agrégats (puis élision des magasins morts, etc. ).
Davis Herring le
4

Cela dépend de votre compilateur et de son niveau d'optimisation.

Dans gcc, si vous spécifiez -O, il activera les indicateurs d'optimisation suivants :

-fauto-inc-dec 
-fbranch-count-reg 
-fcombine-stack-adjustments 
-fcompare-elim 
-fcprop-registers 
-fdce
-fdefer-pop
...

-fdcesignifie Dead Code Elimination .

Vous pouvez utiliser __attribute__((used))pour empêcher gcc d'éliminer une variable inutilisée avec un stockage statique:

Cet attribut, attaché à une variable avec stockage statique, signifie que la variable doit être émise même s'il apparaît que la variable n'est pas référencée.

Lorsqu'il est appliqué à un membre de données statiques d'un modèle de classe C ++, l'attribut signifie également que le membre est instancié si la classe elle-même est instanciée.

wonter
la source
C'est pour les membres de données statiques , pas pour les membres inutilisés par instance (qui ne sont pas optimisés à moins que l'objet entier ne le fasse). Mais oui, je suppose que cela compte. BTW, éliminer les variables statiques inutilisées n'est pas l' élimination du code mort , à moins que GCC ne plie le terme.
Peter Cordes