Ce qui est plus rapide: allocation de pile ou allocation de tas

503

Cette question peut sembler assez élémentaire, mais c'est un débat que j'ai eu avec un autre développeur avec lequel je travaille.

Je prenais soin d'empiler les choses là où je pouvais, au lieu de les allouer en tas. Il me parlait et veillait sur mon épaule et a commenté que ce n'était pas nécessaire car ils sont les mêmes en termes de performances.

J'avais toujours l'impression que la croissance de la pile était un temps constant, et les performances de l'allocation de tas dépendaient de la complexité actuelle du tas pour l'allocation (trouver un trou de la bonne taille) et la désallocation (réduire les trous pour réduire la fragmentation, comme de nombreuses implémentations de bibliothèques standard prennent du temps pour le faire lors des suppressions si je ne me trompe pas).

Cela me semble être quelque chose qui serait probablement très dépendant du compilateur. Pour ce projet en particulier, j'utilise un compilateur Metrowerks pour l' architecture PPC . Un aperçu de cette combinaison serait très utile, mais en général, pour GCC et MSVC ++, quel est le cas? L'allocation de segment de mémoire n'est-elle pas aussi performante que l'allocation de pile? N'y a-t-il pas de différence? Ou les différences sont-elles si minimes que cela devient une micro-optimisation inutile.

Adam
la source
11
Je sais que c'est assez ancien, mais ce serait bien de voir des extraits de code C / C ++ illustrant les différents types d'allocation.
Joseph Weissman
42
Votre vacher est terriblement ignorant, mais plus important encore, il est dangereux car il fait des déclarations faisant autorité sur des choses dont il est terriblement ignorant. Accise ces personnes de votre équipe le plus rapidement possible.
Jim Balter
5
Notez que le tas est généralement beaucoup plus grand que la pile. Si vous disposez de grandes quantités de données, vous devez vraiment les mettre sur le tas, ou bien changer la taille de la pile à partir du système d'exploitation.
Paul Draper
1
Toutes les optimisations sont, sauf si vous avez des repères ou des arguments de complexité prouvant le contraire, par défaut des micro-optimisations inutiles.
Björn Lindqvist
2
Je me demande si votre collègue a principalement une expérience Java ou C #. Dans ces langues, presque tout est alloué sous le capot, ce qui pourrait conduire à de telles hypothèses.
Cort Ammon

Réponses:

493

L'allocation de pile est beaucoup plus rapide car elle ne fait que déplacer le pointeur de pile. En utilisant des pools de mémoire, vous pouvez obtenir des performances comparables grâce à l'allocation de tas, mais cela s'accompagne d'une légère complexité supplémentaire et de ses propres maux de tête.

En outre, la pile par rapport au tas n'est pas seulement une considération de performance; il vous en dit également beaucoup sur la durée de vie attendue des objets.

Torbjörn Gyllebring
la source
211
Et plus important encore, la pile est toujours chaude, la mémoire que vous obtenez est beaucoup plus susceptible d'être dans le cache que n'importe quelle mémoire allouée loin
Benoît
47
Sur certaines architectures (pour la plupart intégrées, à ma connaissance), la pile peut être stockée dans une mémoire vive (par exemple SRAM). Cela peut faire une énorme différence!
leander
38
Parce que la pile est en fait une pile. Vous ne pouvez pas libérer un morceau de mémoire utilisé par la pile à moins qu'il ne soit au-dessus. Il n'y a pas de gestion, vous poussez ou faites éclater des choses dessus. D'un autre côté, la mémoire de tas est gérée: elle demande au noyau des morceaux de mémoire, peut-être les divise, les fusionne, les réutilise et les libère. La pile est vraiment destinée à des allocations rapides et courtes.
Benoît
24
@Pacerier Parce que la pile est beaucoup plus petite que le tas. Si vous souhaitez allouer de grands tableaux, vous feriez mieux de les allouer sur le tas. Si vous essayez d'allouer un grand tableau sur la pile, cela vous donnerait un débordement de pile. Essayez par exemple en C ++ ceci: int t [100000000]; Essayez par exemple t [10000000] = 10; puis cout << t [10000000]; Cela devrait vous donner un débordement de pile ou ne fonctionnera tout simplement pas et ne vous montrera rien. Mais si vous allouez le tableau sur le tas: int * t = new int [100000000]; et effectuez les mêmes opérations après, cela fonctionnera parce que le tas a la taille nécessaire pour un si grand tableau.
Lilian A. Moraru
7
@Pacerier La raison la plus évidente est que les objets de la pile sont hors de portée à la sortie du bloc dans lequel ils sont alloués.
Jim Balter
166

La pile est beaucoup plus rapide. Il n'utilise littéralement qu'une seule instruction sur la plupart des architectures, dans la plupart des cas, par exemple sur x86:

sub esp, 0x10

(Cela déplace le pointeur de pile vers le bas de 0x10 octets et "alloue" donc ces octets à une variable.)

Bien sûr, la taille de la pile est très, très finie, car vous découvrirez rapidement si vous abusez de l'allocation de pile ou essayez de faire une récursivité :-)

De plus, il y a peu de raisons d'optimiser les performances du code qui n'en a pas besoin de manière vérifiable, comme le démontre le profilage. "L'optimisation prématurée" cause souvent plus de problèmes que cela ne vaut.

Ma règle d'or: si je sais que je vais avoir besoin de certaines données au moment de la compilation , et que leur taille est inférieure à quelques centaines d'octets, je les attribue en pile. Sinon, je l'alloue en tas.

Dan Lenski
la source
20
Une instruction, qui est généralement partagée par TOUS les objets de la pile.
MSalters
9
A bien fait valoir le point, en particulier le point sur le besoin vérifiable. Je suis toujours étonné de voir à quel point les préoccupations des gens concernant la performance sont déplacées.
Mike Dunlavey
6
La «désallocation» est également très simple et se fait avec une seule leaveinstruction.
doc
15
Gardez à l'esprit le coût "caché" ici, en particulier pour la première fois que vous étendez la pile. Cela pourrait entraîner une erreur de page, un changement de contexte vers le noyau qui doit effectuer un certain travail pour allouer la mémoire (ou la charger à partir du swap, dans le pire des cas).
nos
2
Dans certains cas, vous pouvez même l'allouer avec 0 instructions. Si certaines informations sont connues sur le nombre d'octets à allouer, le compilateur peut les allouer à l'avance en même temps qu'il alloue d'autres variables de pile. Dans ces cas, vous ne payez rien du tout!
Cort Ammon du
119

Honnêtement, il est trivial d'écrire un programme pour comparer les performances:

#include <ctime>
#include <iostream>

namespace {
    class empty { }; // even empty classes take up 1 byte of space, minimum
}

int main()
{
    std::clock_t start = std::clock();
    for (int i = 0; i < 100000; ++i)
        empty e;
    std::clock_t duration = std::clock() - start;
    std::cout << "stack allocation took " << duration << " clock ticks\n";
    start = std::clock();
    for (int i = 0; i < 100000; ++i) {
        empty* e = new empty;
        delete e;
    };
    duration = std::clock() - start;
    std::cout << "heap allocation took " << duration << " clock ticks\n";
}

On dit qu'une consistance stupide est le hobgoblin des petits esprits . Apparemment, les compilateurs d'optimisation sont les hobgobelins de l'esprit de nombreux programmeurs. Cette discussion était au bas de la réponse, mais les gens ne peuvent apparemment pas être dérangés de lire aussi loin, donc je la déplace ici pour éviter d'avoir des questions auxquelles j'ai déjà répondu.

Un compilateur d'optimisation peut remarquer que ce code ne fait rien et peut tout optimiser. C'est le travail de l'optimiseur de faire des choses comme ça, et combattre l'optimiseur est une course de dupes.

Je recommanderais de compiler ce code avec l'optimisation désactivée car il n'y a pas de bon moyen de tromper tous les optimiseurs actuellement utilisés ou qui seront utilisés à l'avenir.

Quiconque allume l'optimiseur puis se plaint de le combattre doit être ridiculisé par le public.

Si je me souciais de la précision en nanosecondes, je ne l'utiliserais pas std::clock(). Si je voulais publier les résultats en tant que thèse de doctorat, je ferais beaucoup plus à ce sujet et je comparerais probablement GCC, Tendra / Ten15, LLVM, Watcom, Borland, Visual C ++, Digital Mars, ICC et d'autres compilateurs. En l'état, l'allocation de segment de mémoire prend des centaines de fois plus longtemps que l'allocation de pile, et je ne vois rien d'utile pour approfondir la question.

L'optimiseur a pour mission de se débarrasser du code que je teste. Je ne vois aucune raison de dire à l'optimiseur de s'exécuter, puis d'essayer de tromper l'optimiseur pour ne pas réellement l'optimiser. Mais si je voyais de la valeur à le faire, je ferais une ou plusieurs des actions suivantes:

  1. Ajoutez un membre de données à empty, et accédez à ce membre de données dans la boucle; mais si je ne lis que depuis le membre de données, l'optimiseur peut effectuer un pliage constant et supprimer la boucle; si je n'écris que sur le membre de données, l'optimiseur peut ignorer tout sauf la toute dernière itération de la boucle. De plus, la question n'était pas «allocation de pile et accès aux données vs allocation de tas et accès aux données».

  2. Déclarez e volatile, mais volatileest souvent compilé de manière incorrecte (PDF).

  3. Prenez l'adresse de l' eintérieur de la boucle (et affectez-la peut-être à une variable déclarée externet définie dans un autre fichier). Mais même dans ce cas, le compilateur peut remarquer que - sur la pile au moins - esera toujours alloué à la même adresse mémoire, puis effectuera un pliage constant comme dans (1) ci-dessus. J'obtiens toutes les itérations de la boucle, mais l'objet n'est jamais réellement alloué.

Au-delà de l'évidence, ce test est défectueux en ce qu'il mesure à la fois l'allocation et la désallocation, et la question initiale ne posait pas de question sur la désallocation. Bien sûr, les variables allouées sur la pile sont automatiquement désallouées à la fin de leur portée, donc ne pas appeler deleteserait (1) fausser les nombres (la désallocation de pile est incluse dans les chiffres sur l'allocation de pile, il est donc juste de mesurer la désallocation de tas) et ( 2) provoquer une fuite de mémoire assez mauvaise, sauf si nous gardons une référence au nouveau pointeur et appelons deleteaprès avoir obtenu notre mesure du temps.

Sur ma machine, en utilisant g ++ 3.4.4 sous Windows, j'obtiens "0 ticks d'horloge" pour l'allocation de pile et de tas pour rien de moins de 100000 allocations, et même alors j'obtiens "0 ticks d'horloge" pour l'allocation de pile et "15 ticks d'horloge" "pour l'allocation de segments. Lorsque je mesure 10 000 000 d'allocations, l'allocation de pile prend 31 ticks d'horloge et l'allocation de tas prend 1562 ticks d'horloge.


Oui, un compilateur d'optimisation peut éviter la création des objets vides. Si je comprends bien, cela peut même éluder toute la première boucle. Lorsque j'ai augmenté les itérations à 10 000 000, l'allocation de pile a pris 31 ticks d'horloge et l'allocation de tas a pris 1562 ticks d'horloge. Je pense qu'il est prudent de dire que sans dire à g ++ d'optimiser l'exécutable, g ++ n'a pas élidé les constructeurs.


Depuis que j'ai écrit ceci, la préférence sur Stack Overflow a été de publier les performances des builds optimisés. En général, je pense que c'est correct. Cependant, je pense toujours qu'il est stupide de demander au compilateur d'optimiser le code alors que vous ne voulez en fait pas que ce code soit optimisé. Cela me semble très similaire à payer un supplément pour le service de voiturier, mais refusant de remettre les clés. Dans ce cas particulier, je ne veux pas que l'optimiseur fonctionne.

Utiliser une version légèrement modifiée du benchmark (pour traiter le point valide que le programme d'origine n'a pas alloué quelque chose sur la pile à chaque fois dans la boucle) et compiler sans optimisations mais en établissant un lien vers les bibliothèques de publication (pour traiter le point valide que nous ne donnons pas ne veux pas inclure de ralentissement causé par un lien vers des bibliothèques de débogage):

#include <cstdio>
#include <chrono>

namespace {
    void on_stack()
    {
        int i;
    }

    void on_heap()
    {
        int* i = new int;
        delete i;
    }
}

int main()
{
    auto begin = std::chrono::system_clock::now();
    for (int i = 0; i < 1000000000; ++i)
        on_stack();
    auto end = std::chrono::system_clock::now();

    std::printf("on_stack took %f seconds\n", std::chrono::duration<double>(end - begin).count());

    begin = std::chrono::system_clock::now();
    for (int i = 0; i < 1000000000; ++i)
        on_heap();
    end = std::chrono::system_clock::now();

    std::printf("on_heap took %f seconds\n", std::chrono::duration<double>(end - begin).count());
    return 0;
}

affiche:

on_stack took 2.070003 seconds
on_heap took 57.980081 seconds

sur mon système lors de la compilation avec la ligne de commande cl foo.cc /Od /MT /EHsc.

Vous n'êtes peut-être pas d'accord avec mon approche pour obtenir une version non optimisée. C'est très bien: n'hésitez pas à modifier le benchmark autant que vous le souhaitez. Lorsque j'active l'optimisation, j'obtiens:

on_stack took 0.000000 seconds
on_heap took 51.608723 seconds

Pas parce que l'allocation de pile est en fait instantanée mais parce que tout compilateur à moitié décent peut remarquer que on_stackcela ne fait rien d'utile et peut être optimisé. GCC sur mon ordinateur portable Linux remarque également que on_heapcela ne fait rien d'utile et l'optimise également:

on_stack took 0.000003 seconds
on_heap took 0.000002 seconds
Max Lybbert
la source
2
En outre, vous devez ajouter une boucle de "calibrage" au tout début de votre fonction principale, quelque chose pour vous donner une idée du temps que vous obtenez par cycle de boucle, et ajuster les autres boucles afin de vous assurer que votre exemple fonctionne pour un certain temps, au lieu de la constante fixe que vous utilisez.
Joe Pineda
2
Je suis également heureux d'augmenter le nombre de fois que chaque boucle d'option s'exécute (en plus de demander à g ++ de ne pas optimiser?) A donné des résultats significatifs. Alors maintenant, nous avons des faits difficiles à dire que la pile est plus rapide. Merci pour vos efforts!
Joe Pineda
7
C'est le travail de l'optimiseur de se débarrasser de ce code. Y a-t-il une bonne raison d'activer l'optimiseur, puis de l'empêcher de réellement s'optimiser? J'ai édité la réponse pour rendre les choses encore plus claires: si vous aimez combattre l'optimiseur, préparez-vous à savoir comment les rédacteurs de compilateurs sont intelligents.
Max Lybbert
3
Je suis très en retard, mais il convient également de mentionner ici que l'allocation de tas demande de la mémoire via le noyau, de sorte que les performances dépendent également fortement de l'efficacité du noyau. L'utilisation de ce code avec Linux (Linux 3.10.7-gentoo # 2 SMP Wed 4 Sep 18:58:21 MDT 2013 x86_64), la modification du minuteur HR et l'utilisation de 100 millions d'itérations dans chaque boucle permettent d'obtenir ces performances: stack allocation took 0.15354 seconds, heap allocation took 0.834044 secondsavec -O0set, making L'allocation de tas Linux n'est plus lente que sur un facteur d'environ 5,5 sur ma machine particulière.
Taywee
4
Sur les fenêtres sans optimisations (build de débogage), il utilisera le tas de débogage qui est beaucoup plus lent que le tas sans débogage. Je ne pense pas que ce soit une mauvaise idée de "tromper" l'optimiseur du tout. Les auteurs de compilateurs sont intelligents, mais les compilateurs ne sont pas des IA.
paulm
30

Une chose intéressante que j'ai apprise sur l'allocation de pile contre le tas sur le processeur Xbox 360 Xenon, qui peut également s'appliquer à d'autres systèmes multicœurs, est que l'allocation sur le tas provoque la saisie d'une section critique pour arrêter tous les autres cœurs afin que l'allocation ne soit pas effectuée. 't conflit. Ainsi, dans une boucle serrée, l'allocation de pile était la voie à suivre pour les tableaux de taille fixe car elle empêchait les décrochages.

Cela peut être une autre accélération à considérer si vous codez pour multicœur / multiproc, dans la mesure où votre allocation de pile ne sera visible que par le cœur exécutant votre fonction de portée, et cela n'affectera pas les autres cœurs / processeurs.

Codeur furieux
la source
4
Cela est vrai de la plupart des machines multicœurs, pas seulement du Xenon. Même Cell doit le faire car vous exécutez peut-être deux threads matériels sur ce noyau PPU.
Crashworks
15
C'est un effet de l'implémentation (particulièrement médiocre) de l'allocateur de tas. De meilleurs allocateurs de segments n'ont pas besoin d'acquérir un verrou sur chaque allocation.
Chris Dodd
19

Vous pouvez écrire un allocateur de tas spécial pour des tailles spécifiques d'objets qui est très performant. Cependant, l' allocateur de tas général n'est pas particulièrement performant.

Je suis également d'accord avec Torbjörn Gyllebring sur la durée de vie attendue des objets. Bon point!

Chris Jester-Young
la source
1
Cela est parfois appelé allocation de dalle.
Benoit
8

Je ne pense pas que l'allocation de pile et l'allocation de tas soient généralement interchangeables. J'espère également que les performances des deux sont suffisantes pour une utilisation générale.

Je recommande fortement pour les petits articles, celui qui convient le mieux à la portée de l'allocation. Pour les gros articles, le tas est probablement nécessaire.

Sur les systèmes d'exploitation 32 bits qui ont plusieurs threads, la pile est souvent plutôt limitée (bien que généralement à au moins quelques Mo), car l'espace d'adressage doit être découpé et tôt ou tard, une pile de threads en rencontrera une autre. Sur les systèmes à un seul thread (Linux glibc single threaded de toute façon), la limitation est beaucoup moins importante car la pile peut simplement grandir et croître.

Sur les systèmes d'exploitation 64 bits, l'espace d'adressage est suffisant pour que les piles de threads soient assez volumineuses.

MarkR
la source
6

Habituellement, l'allocation de pile consiste simplement à soustraire du registre de pointeur de pile. C'est beaucoup plus rapide que de chercher un tas.

Parfois, l'allocation de pile nécessite l'ajout d'une ou de plusieurs pages de mémoire virtuelle. L'ajout d'une nouvelle page de mémoire mise à zéro ne nécessite pas la lecture d'une page à partir du disque, donc généralement cela va être beaucoup plus rapide que la recherche d'un tas (surtout si une partie du tas a été paginée également). Dans une situation rare, et vous pourriez construire un tel exemple, il se trouve que suffisamment d'espace est disponible dans une partie du tas qui est déjà en RAM, mais l'allocation d'une nouvelle page pour la pile doit attendre qu'une autre page soit écrite sur le disque. Dans cette situation rare, le tas est plus rapide.

Programmeur Windows
la source
Je ne pense pas que le tas soit "fouillé" à moins qu'il ne soit paginé. La mémoire SSD utilise un multiplexeur et peut accéder directement à la mémoire, d'où la mémoire à accès aléatoire.
Joe Phillips
4
Voici un exemple. Le programme appelant demande d'allouer 37 octets. La fonction de bibliothèque recherche un bloc d'au moins 40 octets. Le premier bloc de la liste gratuite contient 16 octets. Le deuxième bloc de la liste gratuite contient 12 octets. Le troisième bloc a 44 octets. La bibliothèque arrête de chercher à ce stade.
Programmeur Windows
6

Mis à part l'avantage des performances de l'ordre de grandeur par rapport à l'allocation de tas, l'allocation de pile est préférable pour les applications serveur de longue durée. Même les tas les mieux gérés finissent par être tellement fragmentés que les performances des applications se dégradent.

Geai
la source
4

Une pile a une capacité limitée, contrairement à un tas. La pile typique d'un processus ou d'un thread est d'environ 8 Ko. Vous ne pouvez pas modifier la taille une fois qu'elle est allouée.

Une variable de pile suit les règles de portée, contrairement à une variable de tas. Si votre pointeur d'instruction dépasse une fonction, toutes les nouvelles variables associées à la fonction disparaissent.

Plus important encore, vous ne pouvez pas prévoir à l'avance la chaîne globale des appels de fonction. Ainsi, une simple allocation de 200 octets de votre part peut entraîner un débordement de pile. Ceci est particulièrement important si vous écrivez une bibliothèque, pas une application.

yogman
la source
1
La quantité d'espace d'adressage virtuel alloué à une pile en mode utilisateur sur un système d'exploitation moderne est susceptible d'être d'au moins 64 Ko ou plus par défaut (1 Mo sous Windows). Parlez-vous des tailles de pile du noyau?
bk1e
1
Sur ma machine, la taille de pile par défaut pour un processus est de 8 Mo, pas de Ko. Quel âge a votre ordinateur?
Greg Rogers
3

Je pense que la durée de vie est cruciale, et si la chose allouée doit être construite de manière complexe. Par exemple, dans la modélisation basée sur les transactions, vous devez généralement remplir et transmettre une structure de transaction avec un tas de champs aux fonctions d'opération. Regardez la norme OSCI SystemC TLM-2.0 pour un exemple.

Leur allocation sur la pile à proximité de l'appel à l'opération a tendance à entraîner d'énormes frais généraux, car la construction est coûteuse. Le bon moyen consiste à allouer sur le tas et à réutiliser les objets de transaction soit en regroupant ou une politique simple comme "ce module n'a besoin que d'un objet de transaction jamais".

Ceci est plusieurs fois plus rapide que l'allocation de l'objet à chaque appel d'opération.

La raison en est simplement que l'objet a une construction coûteuse et une durée de vie utile assez longue.

Je dirais: essayez les deux et voyez ce qui fonctionne le mieux dans votre cas, car cela peut vraiment dépendre du comportement de votre code.

jakobengblom2
la source
3

Le plus gros problème de l'allocation de tas par rapport à l'allocation de pile est probablement que l'allocation de tas dans le cas général est une opération illimitée, et donc vous ne pouvez pas l'utiliser lorsque le timing est un problème.

Pour d'autres applications où le timing n'est pas un problème, cela peut ne pas avoir autant d'importance, mais si vous allouez beaucoup, cela affectera la vitesse d'exécution. Essayez toujours d'utiliser la pile pour la mémoire à courte durée de vie et souvent allouée (par exemple dans les boucles), et aussi longtemps que possible - faites une allocation de tas au démarrage de l'application.

larsivi
la source
3

Ce n'est pas l'allocation de pile jsut qui est plus rapide. Vous gagnez également beaucoup sur l'utilisation des variables de pile. Ils ont une meilleure localité de référence. Et enfin, la désallocation est aussi beaucoup moins chère.

MSalters
la source
3

L'allocation de pile est un couple d'instructions alors que l'allocateur de tas rtos le plus rapide que je connaisse (TLSF) utilise en moyenne de l'ordre de 150 instructions. De plus, les allocations de pile ne nécessitent pas de verrou, car elles utilisent le stockage local des threads, ce qui est un autre gain de performances énorme. Ainsi, les allocations de pile peuvent être plus rapides de 2 à 3 ordres de grandeur selon le niveau de multithread de votre environnement.

En général, l'allocation de tas est votre dernier recours si vous vous souciez des performances. Une option intermédiaire viable peut être un allocateur de pool fixe qui n'est également que quelques instructions et a très peu de surcharge par allocation, ce qui est idéal pour les petits objets de taille fixe. En revanche, il ne fonctionne qu'avec des objets de taille fixe, n'est pas intrinsèquement sûr pour les threads et présente des problèmes de fragmentation des blocs.

Andrei Pokrovsky
la source
3

Problèmes spécifiques au langage C ++

Tout d'abord, il n'y a pas d'allocation dite "pile" ou "tas" mandatée par C ++ . Si vous parlez d'objets automatiques dans des étendues de bloc, ils ne sont même pas "alloués". (BTW, la durée de stockage automatique en C n'est certainement PAS la même chose que "allouée"; cette dernière est "dynamique" dans le langage C ++.) La mémoire allouée dynamiquement se trouve sur le magasin gratuit , pas nécessairement sur "le tas", bien que le cette dernière est souvent l' implémentation (par défaut) .

Bien que selon les règles sémantiques de la machine abstraite , les objets automatiques occupent toujours la mémoire, une implémentation C ++ conforme est autorisée à ignorer ce fait lorsqu'elle peut prouver que cela n'a pas d'importance (lorsqu'elle ne modifie pas le comportement observable du programme). Cette autorisation est accordée par la règle as-if dans ISO C ++, qui est également la clause générale permettant les optimisations habituelles (et il existe également presque la même règle dans ISO C). Outre la règle de simulation, ISO C ++ doit également règles d'élision de copiepermettre l'omission de créations spécifiques d'objets. Les appels constructeur et destructeur impliqués sont ainsi omis. En conséquence, les objets automatiques (le cas échéant) dans ces constructeurs et destructeurs sont également éliminés, par rapport à la sémantique abstraite naïve impliquée par le code source.

D'un autre côté, l'allocation gratuite de magasins est définitivement «allocation» par conception. Selon les règles ISO C ++, une telle allocation peut être obtenue par un appel d'une fonction d'allocation . Cependant, depuis ISO C ++ 14, il existe une nouvelle règle (non-as-if) pour permettre la fusion des ::operator newappels de fonction d'allocation globale (c'est-à-dire ) dans des cas spécifiques. Ainsi, certaines parties des opérations d'allocation dynamique peuvent également être sans opération comme dans le cas des objets automatiques.

Les fonctions d'allocation allouent des ressources de mémoire. Les objets peuvent être davantage alloués en fonction de l'allocation à l'aide d'allocateurs. Pour les objets automatiques, ils sont présentés directement - bien que la mémoire sous-jacente soit accessible et utilisée pour fournir de la mémoire à d'autres objets (par placement new), mais cela n'a pas beaucoup de sens en tant que magasin gratuit, car il n'y a aucun moyen de déplacer le ailleurs.

Toutes les autres préoccupations sont hors de portée de C ++. Néanmoins, ils peuvent être encore importants.

A propos des implémentations de C ++

C ++ n'expose pas les enregistrements d'activation réifiés ou certaines sortes de continuations de première classe (par exemple par le célèbre call/cc), il n'y a aucun moyen de manipuler directement les trames d'enregistrement d'activation - où l'implémentation doit placer les objets automatiques. Une fois qu'il n'y a pas d'interopérations (non portables) avec l'implémentation sous-jacente (code non portable "natif", tel que le code d'assemblage en ligne), une omission de l'allocation sous-jacente des trames peut être assez banale. Par exemple, lorsque la fonction appelée est en ligne, les trames peuvent être efficacement fusionnées dans d'autres, il n'y a donc aucun moyen de montrer ce qu'est "l'allocation".

Cependant, une fois les interopérations respectées, les choses deviennent complexes. Une implémentation typique de C ++ exposera la capacité d'interopérabilité sur ISA (architecture de jeu d'instructions) avec certaines conventions d'appel comme frontière binaire partagée avec le code natif (machine de niveau ISA). Cela serait explicitement coûteux, notamment lors de la maintenance du pointeur de pile , qui est souvent directement détenu par un registre de niveau ISA (avec probablement des instructions machine spécifiques auxquelles accéder). Le pointeur de pile indique la limite de la trame supérieure de l'appel de fonction (actuellement actif). Lorsqu'un appel de fonction est entré, une nouvelle trame est nécessaire et le pointeur de pile est ajouté ou soustrait (selon la convention d'ISA) par une valeur non inférieure à la taille de trame requise. La trame est alors dite allouéelorsque le pointeur de pile après les opérations. Les paramètres des fonctions peuvent également être transmis à la trame de pile, selon la convention d'appel utilisée pour l'appel. Le cadre peut contenir la mémoire d'objets automatiques (incluant probablement les paramètres) spécifiés par le code source C ++. Dans le sens de telles implémentations, ces objets sont "alloués". Lorsque le contrôle quitte l'appel de fonction, la trame n'est plus nécessaire, elle est généralement libérée en restaurant le pointeur de pile à l'état avant l'appel (enregistré précédemment selon la convention d'appel). Cela peut être considéré comme une «désallocation». Ces opérations font de l'enregistrement d'activation une structure de données LIFO efficace, il est donc souvent appelé " la pile (d'appel) ".

Étant donné que la plupart des implémentations C ++ (en particulier celles ciblant le code natif de niveau ISA et utilisant le langage d'assemblage comme sortie immédiate) utilisent des stratégies similaires comme celle-ci, un tel schéma d'allocation déroutant est populaire. De telles allocations (ainsi que des désallocations) passent des cycles machine, et cela peut être coûteux lorsque les appels (non optimisés) se produisent fréquemment, même si les microarchitectures CPU modernes peuvent avoir des optimisations complexes implémentées par le matériel pour le modèle de code commun (comme l'utilisation d'un moteur de pile dans la mise en œuvre PUSH/ POPinstructions).

Mais de toute façon, en général, il est vrai que le coût de l'allocation de trames de pile est nettement inférieur à un appel à une fonction d'allocation exploitant le magasin gratuit (à moins qu'il ne soit totalement optimisé) , qui lui-même peut avoir des centaines (sinon des millions de :-) opérations pour maintenir le pointeur de pile et d'autres états. Les fonctions d'allocation sont généralement basées sur l'API fournie par l'environnement hébergé (par exemple le runtime fourni par le système d'exploitation). Différentes du but de la conservation d'objets automatiques pour les appels de fonctions, ces allocations sont générales, donc elles n'auront pas de structure de trame comme une pile. Traditionnellement, ils allouent de l'espace à partir du stockage de pool appelé tas (ou plusieurs tas). Différent de la "pile", le concept de "tas" n'indique pas ici la structure de données utilisée;il est dérivé des premières implémentations de langage il y a des décennies . (BTW, la pile d'appels est généralement allouée avec une taille fixe ou spécifiée par l'utilisateur à partir du tas par l'environnement au démarrage du programme ou du thread.) La nature des cas d'utilisation rend les allocations et les désallocations à partir d'un tas beaucoup plus compliquées (que push ou pop de stack frames), et difficilement optimisables directement par le matériel.

Effets sur l'accès à la mémoire

L'allocation de pile habituelle place toujours le nouveau cadre en haut, donc il a une assez bonne localité. Ceci est convivial pour le cache. OTOH, la mémoire allouée au hasard dans le magasin gratuit n'a pas une telle propriété. Depuis ISO C ++ 17, il existe des modèles de ressources de pool fournis par <memory>. Le but direct d'une telle interface est de permettre aux résultats d'allocations consécutives d'être rapprochées en mémoire. Cela reconnaît le fait que cette stratégie est généralement bonne pour les performances avec les implémentations contemporaines, par exemple en étant conviviale pour la mise en cache dans les architectures modernes. Il s'agit cependant de la performance de l' accès plutôt que de l' allocation .

Accès simultané

L'attente d'un accès simultané à la mémoire peut avoir des effets différents entre la pile et les tas. Une pile d'appels appartient généralement exclusivement à un thread d'exécution dans une implémentation C ++. OTOH, les tas sont souvent partagés entre les threads d'un processus. Pour de tels tas, les fonctions d'allocation et de désallocation doivent protéger la structure des données administratives internes partagées de la course aux données. Par conséquent, les allocations de tas et les désallocations peuvent entraîner des frais supplémentaires en raison des opérations de synchronisation interne.

Efficacité spatiale

En raison de la nature des cas d'utilisation et des structures de données internes, les tas peuvent souffrir de la fragmentation de la mémoire interne , contrairement à la pile. Cela n'a pas d'impact direct sur les performances de l'allocation de mémoire, mais dans un système avec mémoire virtuelle , une faible efficacité de l'espace peut dégénérer les performances globales de l'accès à la mémoire. C'est particulièrement affreux lorsque le disque dur est utilisé comme échange de mémoire physique. Il peut provoquer une latence assez longue - parfois des milliards de cycles.

Limitations des allocations de pile

Bien que les allocations de pile soient souvent plus performantes que les allocations de tas en réalité, cela ne signifie certainement pas que les allocations de pile peuvent toujours remplacer les allocations de tas.

Tout d'abord, il n'y a aucun moyen d'allouer de l'espace sur la pile avec une taille spécifiée au moment de l'exécution de manière portable avec ISO C ++. Il existe des extensions fournies par des implémentations telles allocaque le VLA (tableau de longueur variable) de G ++, mais il y a des raisons de les éviter. (IIRC, la source Linux supprime récemment l'utilisation de VLA.) (Notez également que ISO C99 a mandaté VLA, mais ISO C11 rend le support facultatif.)

Deuxièmement, il n'existe aucun moyen fiable et portable de détecter l'épuisement de l'espace de pile. Ceci est souvent appelé débordement de pile (hmm, l'étymologie de ce site) , mais probablement plus précisément, dépassement de pile . En réalité, cela provoque souvent un accès à la mémoire invalide, et l'état du programme est alors corrompu (... ou pire, une faille de sécurité). En fait, ISO C ++ n'a pas de concept de «pile» et rend le comportement indéfini lorsque la ressource est épuisée . Soyez prudent quant à la place qui doit être laissée aux objets automatiques.

Si l'espace de la pile est épuisé, il y a trop d'objets alloués dans la pile, ce qui peut être dû à trop d'appels de fonctions actifs ou à une mauvaise utilisation des objets automatiques. De tels cas peuvent suggérer l'existence de bogues, par exemple un appel de fonction récursif sans conditions de sortie correctes.

Néanmoins, des appels récursifs profonds sont parfois souhaités. Dans les implémentations de langages nécessitant la prise en charge des appels actifs non liés (où la profondeur des appels n'est limitée que par la mémoire totale), il est impossible d'utiliser la pile d'appels natifs (contemporaine) directement comme enregistrement d'activation de la langue cible comme les implémentations C ++ typiques. Pour contourner le problème, d'autres méthodes de construction des enregistrements d'activation sont nécessaires. Par exemple, SML / NJ alloue explicitement des trames sur le tas et utilise des piles de cactus . L'allocation compliquée de telles trames d'enregistrement d'activation n'est généralement pas aussi rapide que les trames de pile d'appels. Cependant, si de tels langages sont implémentés davantage avec la garantie d' une récursivité de queue appropriée, l'allocation directe de pile dans le langage objet (c'est-à-dire que "l'objet" dans le langage n'est pas stocké en tant que références, mais les valeurs primitives natives qui peuvent être mappées un à un sur des objets C ++ non partagés) est encore plus compliquée avec plus pénalité de performance en général. Lorsque vous utilisez C ++ pour implémenter de tels langages, il est difficile d'estimer les impacts sur les performances.

FrankHB
la source
Comme stl, de moins en moins sont prêts à différencier ces concepts. De nombreux mecs sur cppcon2018 utilisent également heapfréquemment.
@ 陳 力 "Le tas" peut être sans ambiguïté en gardant à l'esprit certaines implémentations spécifiques, il peut donc parfois être OK. Il est cependant redondant "en général".
FrankHB
Qu'est-ce que l'interopérabilité?
陳 力
@ 陳 力 Je voulais dire toutes sortes d'interopérations de code "natives" impliquées dans la source C ++, par exemple, tout code d'assemblage en ligne. Cela repose sur des hypothèses (d'ABI) non couvertes par C ++. L'interopérabilité COM (basée sur certaines ABI spécifiques à Windows) est plus ou moins similaire, bien qu'elle soit généralement neutre par rapport à C ++.
FrankHB
2

Il y a un point général à faire sur ces optimisations.

L'optimisation que vous obtenez est proportionnelle à la durée pendant laquelle le compteur de programme est réellement dans ce code.

Si vous échantillonnez le compteur de programme, vous découvrirez où il passe son temps, et c'est généralement dans une petite partie du code, et souvent dans les routines de bibliothèque sur lesquelles vous n'avez aucun contrôle.

Ce n'est que si vous trouvez qu'il passe beaucoup de temps dans l'allocation de tas de vos objets qu'il sera sensiblement plus rapide de les allouer en pile.

Mike Dunlavey
la source
2

L'allocation de pile sera presque toujours aussi rapide ou plus rapide que l'allocation de tas, bien qu'il soit certainement possible pour un allocateur de tas d'utiliser simplement une technique d'allocation basée sur la pile.

Cependant, il existe des problèmes plus importants en ce qui concerne les performances globales de l'allocation basée sur la pile par rapport au tas (ou en termes légèrement meilleurs, l'allocation locale par rapport à l'allocation externe). Habituellement, l'allocation en tas (externe) est lente car elle traite de nombreux types d'allocations et de modèles d'allocation. Réduire la portée de l'allocateur que vous utilisez (le rendre local à l'algorithme / code) aura tendance à augmenter les performances sans aucun changement majeur. Ajouter une meilleure structure à vos modèles d'allocation, par exemple, forcer un ordre LIFO sur les paires d'allocation et de désallocation peut également améliorer les performances de votre allocateur en utilisant l'allocateur de manière plus simple et plus structurée. Ou, vous pouvez utiliser ou écrire un allocateur réglé pour votre modèle d'allocation particulier; la plupart des programmes allouent fréquemment quelques tailles discrètes, ainsi un tas qui est basé sur un tampon d'aspect de quelques tailles fixes (de préférence connues) fonctionnera extrêmement bien. Windows utilise son tas à faible fragmentation pour cette même raison.

D'un autre côté, l'allocation basée sur la pile sur une plage de mémoire 32 bits est également lourde de risques si vous avez trop de threads. Les piles ont besoin d'une plage de mémoire contiguë, donc plus vous avez de threads, plus vous aurez besoin d'espace d'adressage virtuel pour qu'elles s'exécutent sans débordement de pile. Ce ne sera pas un problème (pour l'instant) avec 64 bits, mais cela peut certainement faire des ravages dans les programmes de longue durée avec beaucoup de threads. Manquer d'espace d'adressage virtuel en raison de la fragmentation est toujours difficile à gérer.

MSN
la source
Je ne suis pas d'accord avec votre première phrase.
brian beuning
2

Comme d'autres l'ont dit, l'allocation de pile est généralement beaucoup plus rapide.

Cependant, si vos objets coûtent cher à copier, l'allocation sur la pile peut entraîner une baisse considérable des performances plus tard lorsque vous utilisez les objets si vous ne faites pas attention.

Par exemple, si vous allouez quelque chose sur la pile, puis le placez dans un conteneur, il aurait été préférable d'allouer sur le tas et de stocker le pointeur dans le conteneur (par exemple avec un std :: shared_ptr <>). La même chose est vraie si vous passez ou renvoyez des objets par valeur et d'autres scénarios similaires.

Le fait est que, bien que l'allocation de pile soit généralement meilleure que l'allocation de tas dans de nombreux cas, parfois si vous vous efforcez d'allouer la pile lorsqu'elle ne correspond pas le mieux au modèle de calcul, cela peut causer plus de problèmes qu'elle n'en résout.

wjl
la source
2
class Foo {
public:
    Foo(int a) {

    }
}
int func() {
    int a1, a2;
    std::cin >> a1;
    std::cin >> a2;

    Foo f1(a1);
    __asm push a1;
    __asm lea ecx, [this];
    __asm call Foo::Foo(int);

    Foo* f2 = new Foo(a2);
    __asm push sizeof(Foo);
    __asm call operator new;//there's a lot instruction here(depends on system)
    __asm push a2;
    __asm call Foo::Foo(int);

    delete f2;
}

Ce serait comme ça en asm. Lorsque vous êtes dedans func, le f1pointeur et f2a été alloué sur la pile (stockage automatisé). Et par ailleurs, Foo f1(a1)n'a pas d' effets sur instruction pointeur de pile ( esp), il a été alloué, si funcbesoins obtenir le membre f1, l' instruction de c'est quelque chose comme ceci: lea ecx [ebp+f1], call Foo::SomeFunc(). Une autre chose que la pile alloue peut faire penser à quelqu'un que la mémoire est quelque chose comme FIFO, c'est FIFOjuste arrivé quand vous allez dans une fonction, si vous êtes dans la fonction et allouez quelque chose comme int i = 0, il n'y a pas eu de poussée.

bitnick
la source
1

Il a été mentionné précédemment que l'allocation de pile déplace simplement le pointeur de pile, c'est-à-dire une instruction unique sur la plupart des architectures. Comparez cela à ce qui se passe généralement dans le cas de l'allocation de segments.

Le système d'exploitation maintient des portions de mémoire libre sous forme de liste liée avec les données de charge utile constituées du pointeur vers l'adresse de départ de la portion libre et la taille de la portion libre. Pour allouer X octets de mémoire, la liste de liens est parcourue et chaque note est visitée en séquence, en vérifiant si sa taille est au moins X. Lorsqu'une portion de taille P> = X est trouvée, P est divisé en deux parties avec tailles X et PX. La liste liée est mise à jour et le pointeur vers la première partie est renvoyé.

Comme vous pouvez le voir, l'allocation de segments de mémoire dépend de plusieurs facteurs tels que la quantité de mémoire que vous demandez, la fragmentation de la mémoire, etc.

Nikhil
la source
1

En général, l'allocation de pile est plus rapide que l'allocation de tas comme mentionné par presque toutes les réponses ci-dessus. Un push ou pop de pile est O (1), tandis que l'allocation ou la libération d'un tas peut nécessiter une promenade d'allocations précédentes. Cependant, vous ne devez généralement pas allouer des boucles serrées et gourmandes en performances, donc le choix se résume généralement à d'autres facteurs.

Il pourrait être bon de faire cette distinction: vous pouvez utiliser un "allocateur de pile" sur le tas. À strictement parler, je considère l'allocation de pile comme la méthode réelle d'allocation plutôt que l'emplacement de l'allocation. Si vous allouez beaucoup de choses sur la pile réelle du programme, cela peut être mauvais pour diverses raisons. D'un autre côté, utiliser une méthode de pile pour allouer sur le tas lorsque cela est possible est le meilleur choix que vous pouvez faire pour une méthode d'allocation.

Puisque vous avez mentionné Metrowerks et PPC, je suppose que vous parlez de Wii. Dans ce cas, la mémoire est précieuse, et l'utilisation d'une méthode d'allocation de pile dans la mesure du possible garantit que vous ne gaspillez pas de mémoire sur des fragments. Bien sûr, cela nécessite beaucoup plus de soin que les méthodes d'allocation de tas "normales". Il est sage d'évaluer les compromis pour chaque situation.

Dan Olson
la source
1

Remarquez que les considérations ne concernent généralement pas la vitesse et les performances lors du choix de l'allocation de pile par rapport à l'allocation de segment de mémoire. La pile agit comme une pile, ce qui signifie qu'elle est bien adaptée pour pousser des blocs et les faire éclater à nouveau, dernier entré, premier sorti. L'exécution des procédures est également semblable à une pile, la dernière procédure entrée doit d'abord être fermée. Dans la plupart des langages de programmation, toutes les variables nécessaires dans une procédure ne seront visibles que pendant l'exécution de la procédure, elles sont donc poussées lors de l'entrée dans une procédure et sautées de la pile lors de la sortie ou du retour.

Maintenant, pour un exemple où la pile ne peut pas être utilisée:

Proc P
{
  pointer x;
  Proc S
  {
    pointer y;
    y = allocate_some_data();
    x = y;
  }
}

Si vous allouez de la mémoire dans la procédure S et la placez sur la pile, puis quittez S, les données allouées seront extraites de la pile. Mais la variable x dans P pointait également vers ces données, donc x pointe maintenant vers un endroit sous le pointeur de pile (supposons que la pile croît vers le bas) avec un contenu inconnu. Le contenu peut toujours être là si le pointeur de pile est simplement déplacé vers le haut sans effacer les données en dessous, mais si vous commencez à allouer de nouvelles données sur la pile, le pointeur x peut en fait pointer vers ces nouvelles données à la place.

Kent Munthe Caspersen
la source
0

Ne faites jamais d'hypothèse prématurée car tout autre code d'application et utilisation peut avoir un impact sur votre fonction. Donc, regarder la fonction est que l'isolement est inutile.

Si vous êtes sérieux avec l'application alors VTune ou utilisez un outil de profilage similaire et regardez les hotspots.

Ketan

Ketan
la source
-1

J'aimerais dire que le code généré par GCC (je me souviens aussi de VS) n'a pas de surcharge pour faire l'allocation de pile .

Dites pour la fonction suivante:

  int f(int i)
  {
      if (i > 0)
      {   
          int array[1000];
      }   
  }

Voici le code généré:

  __Z1fi:
  Leh_func_begin1:
      pushq   %rbp
  Ltmp0:
      movq    %rsp, %rbp
  Ltmp1:
      subq    $**3880**, %rsp <--- here we have the array allocated, even the if doesn't excited.
  Ltmp2:
      movl    %edi, -4(%rbp)
      movl    -8(%rbp), %eax
      addq    $3880, %rsp
      popq    %rbp
      ret 
  Leh_func_end1:

Donc, quelle que soit la quantité de variable locale que vous avez (même à l'intérieur de if ou switch), seul le 3880 changera pour une autre valeur. Sauf si vous n'aviez pas de variable locale, cette instruction doit simplement être exécutée. Donc, allouer une variable locale n'a pas de surcharge.

ZijingWu
la source