web-dev-qa-db-fra.com

Pourquoi les compilateurs ne fusionnent-ils pas les écritures std :: atomic redondantes?

Je me demande pourquoi aucun compilateur n'est prêt à fusionner des écritures consécutives de la même valeur en une seule variable atomique, par exemple:

#include <atomic>
std::atomic<int> y(0);
void f() {
  auto order = std::memory_order_relaxed;
  y.store(1, order);
  y.store(1, order);
  y.store(1, order);
}

Chaque compilateur que j'ai essayé émettra l'écriture ci-dessus trois fois. Quel observateur légitime, sans race, pourrait voir une différence entre le code ci-dessus et une version optimisée avec une seule écriture (c'est-à-dire que la règle "tel quel" ne s'applique pas)?

Si la variable avait été volatile, il est évident qu'aucune optimisation n'est applicable. Qu'est-ce qui l'empêche dans mon cas?

Voici le code dans compilateur Explorer .

47
PeteC

Les normes C++ 11/C++ 14 telles qu'écrites permettent aux trois magasins d'être pliés/fusionnés en un magasin de la valeur finale. Même dans un cas comme celui-ci:

y (avec une charge atomique ou un CAS) ne verra jamais   y.store(1, order);
  y.store(2, order);
  y.store(3, order); // inlining + constant-folding could produce this in real code
. Un programme qui en dépendait aurait un bogue de course de données, mais uniquement le type de bogue de jardin-variété, pas le type de course de données C++ Undefined Behavior. (C'est UB seulement avec des variables non atomiques). Un programme qui s'attend à parfois voir qu'il n'est pas forcément buggé. (Voir ci-dessous concernant les barres de progression.)

Toute commande possible sur la machine abstraite C++ peut être sélectionnée (au moment de la compilation) en tant que commande qui toujours se produira. C'est la règle as-if en action. Dans ce cas, il s'agit de comme si les trois magasins se sont déroulés dos à dos dans l'ordre global, aucun chargement ou magasin d'autres threads ne se produisant entre y == 2 et y=1.

Cela ne dépend pas de l'architecture ou du matériel cible; tout comme le réordonnancement au moment de la compilation des opérations atomiques relâchées sont autorisées même lorsque vous ciblez x86 fortement ordonné. Le compilateur n'a pas besoin de préserver quoi que ce soit que vous pourriez attendre de penser au matériel pour lequel vous compilez, vous avez donc besoin de barrières. Les barrières peuvent être compilées en instructions zéro asm.


Alors pourquoi les compilateurs ne font-ils pas cette optimisation?

C'est un problème de qualité d'implémentation et peut changer les performances/comportements observés sur du matériel réel.

Le cas le plus évident où c'est un problème est une barre de progression. Si vous plongez les magasins dans une boucle (qui ne contient aucune autre opération atomique) et que vous les pliez en une seule, la barre de progression reste à 0 et passe à 100% à la fin.

Il n'y a pas de moyen C++ 11 y=3 pour les arrêter de le faire dans les cas où vous ne le souhaitez pas. Par conséquent, les compilateurs choisissent simplement de ne jamais fusionner plusieurs opérations atomiques en une seule. (Les fusionner en une seule opération ne modifie pas leur ordre les uns par rapport aux autres.)

Les rédacteurs de compilateurs ont correctement remarqué que les programmeurs s'attendent à ce qu'un magasin atomique arrive réellement à la mémoire chaque fois que la source effectue y.store(). (Voir la plupart des autres réponses à cette question, selon lesquelles les magasins doivent obligatoirement se dérouler séparément en raison de la possibilité pour les lecteurs d'attendre de voir une valeur intermédiaire.) Cela enfreint le principe/de la moindre surprise - /.

Cependant, il y a des cas où cela serait très utile, par exemple en évitant d'utiliser std::atomic ref inutile compte inc/dec dans une boucle.

Évidemment, toute réorganisation ou fusion ne peut enfreindre aucune autre règle de commande. Par exemple, shared_ptr devrait toujours être un obstacle total à la réorganisation de l'exécution et de la compilation, même si elle ne touchait plus la mémoire à num.


Des discussions sont en cours pour étendre l'API num++; num--; afin de donner aux programmeurs le contrôle de ces optimisations. Les compilateurs seront alors en mesure d'optimiser, le cas échéant, ce qui peut se produire même dans un code soigneusement écrit et non intentionnellement inefficace. Quelques exemples de cas d’optimisation utiles sont mentionnés dans les liens suivants de discussion/proposition de groupe de travail:

Voir aussi la discussion à ce sujet sur la réponse de Richard Hodges à num ++ peut-il être atomique pour 'int num'? (voir les commentaires). Voir aussi la dernière section de ma réponse à la même question, où je discute plus en détail que cette optimisation est autorisée. (En résumé, car ces liens de groupe de travail C++ reconnaissent déjà que la norme actuelle, telle qu'écrite, le permet, et que les compilateurs actuels n'optimisent simplement pas à dessein.)


Dans le standard actuel, std::atomic serait un moyen de s’assurer que les magasins qui lui sont associés ne peuvent pas être optimisés. (Comme Herb Sutter le fait remarquer dans une SO réponse , volatile et atomic partagent déjà certaines exigences, mais elles sont différentes). Voir aussi la relation de volatile atomic<int> y AVEC volatile on cppreference.

Les accès aux objets volatile ne peuvent pas être optimisés (car ils pourraient être des registres mappés en mémoire IO, par exemple).

Utiliser std::memory_order résout principalement le problème de la barre de progression, mais c'est un peu moche et peut paraître idiot dans quelques années si/quand C++ décide d'une syntaxe différente pour contrôler l'optimisation afin que les compilateurs puissent commencer à le faire en pratique.Je pense que nous pouvons être certains que les compilateurs ne commenceront pas cette optimisation tant qu’il n’y aura pas moyen de la contrôler. Espérons que ce sera une sorte de opt-in (comme un volatile atomic<T>) qui ne changera pas le comportement du code existant, le code C++ 11/14, une fois compilé au format C++. Mais cela pourrait ressembler à la proposition de wg21/p0062: la balise n’optimise pas les cas avec memory_order_release_coalesce.

wg21/p0062 avertit que même [[brittle_atomic]] ne résout pas tout, et décourage son utilisation à cette fin. Il donne cet exemple:.

__extrait de code__

volatile atomic, un compilateur est autorisé à extraire la y.store() du if(x) {
    foo();
    y.store(0);
} else {
    bar();
    y.store(0);  // release a lock before a long-running loop
    for() {...} // loop contains no atomics or volatiles
}
// A compiler can merge the stores into a y.store(0) here.

volatile arrête la fusion décrite dans la question, mais cela indique que d'autres optimisations sur if/else peuvent également être problématiques pour des performances réelles.

.


Parmi les autres raisons de ne pas optimiser, citons: personne n'a écrit le code compliqué qui permettrait au compilateur d'effectuer ces optimisations en toute sécurité (sans jamais se tromper). Ce n'est pas suffisant, car N4455 indique que LLVM implémente déjà ou pourrait facilement implémenter plusieurs des optimisations mentionnées.

La raison déroutante pour les programmeurs est certainement plausible, cependant. Le code sans verrou est assez dur pour écrire correctement en premier lieu.

Ne soyez pas désinvolte dans votre utilisation des armes atomiques: elles ne sont pas bon marché et n'optimisent pas beaucoup (actuellement, pas du tout). Il n’est pas toujours facile d’éviter les opérations atomiques redondantes avec seq_cst, bien qu’il n’existe pas de version non atomique (bien que une des réponses ici offre un moyen simple de définir un atomic<> pour gcc).

Don't be casual in your use of atomic weapons: they aren't cheap and don't optimize much (currently not at all). It's not always easy easy to avoid redundant atomic operations with std::shared_ptr<T>, though, since there's no non-atomic version of it (although one of the answers here gives an easy way to define a shared_ptr_unsynchronized<T> for gcc).

34
Peter Cordes

Vous faites référence à l'élimination des magasins morts. 

Il n'est pas interdit d'éliminer un magasin mort atomique, mais il est plus difficile de prouver qu'un magasin atomique est éligible en tant que tel.

Les optimisations classiques du compilateur, telles que l'élimination des mémoires mortes, peuvent être effectuées sur des opérations atomiques, même cohérentes séquentiellement.
Les optimiseurs doivent veiller à ne pas le faire sur synchronisation, car un autre thread d'exécution peut observer ou modifier la mémoire, ce qui signifie que les optimisations traditionnelles doivent prendre en compte davantage d'instructions intermédiaires qu'elles ne le feraient habituellement lors de l'optimisation atomique. opérations.
Dans le cas d’une élimination de magasin mort, il ne suffit pas de prouver qu’un magasin atomique post-domine et alias un autre pour éliminer l’autre magasin.

à partir de N4455 Aucun compilateur Sane ne permettrait d’optimiser Atomics

Le problème de la DSE atomique, dans le cas général, est qu’il s’agit de rechercher des points de synchronisation. Je crois comprendre que ce terme désigne des points dans le code où il y a happen-before relation entre une instruction sur un thread A et instruction sur autre fil B. 

Considérons ce code exécuté par un thread A:

y.store(1, std::memory_order_seq_cst);
y.store(2, std::memory_order_seq_cst);
y.store(3, std::memory_order_seq_cst);

Peut-il être optimisé comme y.store(3, std::memory_order_seq_cst)?

Si un thread B attend de voir y = 2 (par exemple avec un CAS), il n’observera jamais cela si le code est optimisé. 

Cependant, à ma connaissance, avoir B en boucle et utiliser CAS avec y = 2 est une course de données car il n'y a pas d'ordre total entre les instructions des deux threads.
Une exécution dans laquelle les instructions de A sont exécutées avant que la boucle de B ne soit observable (c’est-à-dire autorisée) et permet donc au compilateur d’optimiser à y.store(3, std::memory_order_seq_cst).

Si les threads A et B sont synchronisés, d'une manière ou d'une autre, entre les magasins situés dans le thread A, l'optimisation ne serait pas autorisée (un ordre partiel serait induit, pouvant éventuellement conduire à l'observation éventuelle de y = 2 par B). 

Prouver qu'il n'y a pas une telle synchronisation est difficile car cela implique d'envisager une portée plus large et de prendre en compte toutes les bizarreries d'une architecture.

En ce qui concerne ma compréhension, en raison de l’âge relativement petit des opérations atomiques et de la difficulté à raisonner sur l’ordre de la mémoire, la visibilité et la synchronisation, les compilateurs n’effectuent pas toutes les optimisations possibles sur l’atomique jusqu’à ce qu’un cadre plus robuste permette de détecter et de comprendre les conditions sont construites.

Je pense que votre exemple est une simplification du fil de comptage donné ci-dessus, car il ne possède aucun autre fil ni aucun point de synchronisation. Pour ce que je peux voir, je suppose que le compilateur aurait pu optimiser les trois magasins.

41
Margaret Bloom

Pendant que vous modifiez la valeur d'un atome dans un thread, un autre thread peut le vérifier et effectuer une opération en fonction de la valeur de l'atome. L'exemple que vous avez donné est tellement spécifique que les développeurs de compilateurs ne voient pas l'intérêt de l'optimiser. Cependant, si un thread est en train de définir, par exemple, valeurs consécutives pour un atome: 0, 1, 2, etc., l'autre thread peut mettre quelque chose dans les emplacements indiqués par la valeur de l'atome.

9
Serge Rogatch

En bref, parce que la norme (par exemple, les pararaphes autour de et en dessous de 20 dans [intro.multithread]) ne l’autorise pas.

Il existe des garanties «passe-avant» qui doivent être remplies et qui, entre autres, excluent de réorganiser ou de fusionner des écritures (le paragraphe 19 le dit même explicitement à propos de la réorganisation).

Si votre thread écrit trois valeurs en mémoire (disons 1, 2 et 3) l'une après l'autre, un thread différent peut lire la valeur. Si, par exemple, votre thread est interrompu (ou même s’il fonctionne simultanément) et si un autre thread également écrit à cet emplacement, le thread observateur doit voir les opérations exactement dans le même ordre qu’elles se produisent (soit en planifiant). ou une coïncidence, ou quelle que soit la raison). C'est une garantie. 

Comment est-ce possible si vous ne faites que la moitié des écritures (ou même une seule)? Ce n'est pas.

Que se passe-t-il si votre fil écrit 1 -1 -1 mais qu'un autre en écrit sporadiquement 2 ou 3? Que se passe-t-il si un troisième thread observe l'emplacement et attend une valeur particulière qui n'apparaît jamais parce qu'elle est optimisée?

Il est impossible de fournir les garanties données si les magasins (et les charges également) ne sont pas exécutés comme demandé. Tous et dans le même ordre.

5
Damon

NB: J'allais faire un commentaire, mais c'est un peu trop verbeux.

Un fait intéressant est que ce comportement n'est pas, en termes de C++, une course de données.

La note 21 à la p.14 est intéressante: http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n3690.pdf (non souligné dans l'original):

L'exécution d'un programme contient une course de données s'il en contient deux actions conflictuelles dans différents threads, dont au moins dont l’un est pas atomique

Également sur p.11 note 5:

Les opérations atomiques «relâchées» ne sont pas des opérations de synchronisation même cependant, comme les opérations de synchronisation, elles ne peuvent pas contribuer à courses de données.

Ainsi, une action conflictuelle sur un atome n'est jamais une course de données - en termes de standard C++.

Ces opérations sont toutes atomiques (et spécifiquement détendues) mais aucune course de données ici les gens!

Je conviens qu'il n'y a pas de différence fiable/prévisible entre ces deux sur une plate-forme (raisonnable):

include <atomic>
std::atomic<int> y(0);
void f() {
  auto order = std::memory_order_relaxed;
  y.store(1, order);
  y.store(1, order);
  y.store(1, order);
}

et 

include <atomic>
std::atomic<int> y(0);
void f() {
  auto order = std::memory_order_relaxed;
  y.store(1, order);
}

Mais dans le modèle de mémoire C++ fourni, il ne s'agit pas d'une course de données.

Je ne comprends pas bien pourquoi cette définition est fournie, mais elle donne au développeur quelques cartes lui permettant d’engager une communication aléatoire entre des threads dont il sait qu’elles (sur leur plate-forme) fonctionneront statistiquement.

Par exemple, définir une valeur 3 fois, puis la relire montrera un certain degré de conflit pour cet emplacement. De telles approches ne sont pas déterministes, mais de nombreux algorithmes concurrents efficaces ne sont pas déterministes. Par exemple, une try_lock_until() temporisée est toujours une condition de concurrence critique, mais reste une technique utile.

Il semble que la norme C++ vous apporte une certitude sur les «courses de données», tout en autorisant certains jeux amusants avec des conditions de course qui, en dernière analyse, sont différentes.

En bref, la norme semble spécifier que lorsque d'autres threads peuvent voir l'effet «marteler» d'une valeur définie trois fois, les autres threads doivent pouvoir voir cet effet (même s'ils ne le font parfois pas!). C'est le cas où pratiquement toutes les plates-formes modernes que d'autres threads peuvent voir dans certaines circonstances sont frappantes.

5
Persixty

Un cas d'utilisation pratique pour le modèle, si le thread effectue une tâche importante entre les mises à jour qui ne dépendent pas de y et ne la modifie pas, pourrait être: * Le thread 2 lit la valeur de y pour vérifier les progrès accomplis par le thread 1.`

Donc, peut-être que Thread 1 est supposé charger le fichier de configuration à l’étape 1, mettre son contenu analysé dans une structure de données à l’étape 2 et afficher la fenêtre principale à l’étape 3, pendant que Thread 2 attend la fin de l’étape 2 pour pouvoir effectuer une autre tâche en parallèle qui dépend de la structure de données. (Accordé, cet exemple appelle une sémantique d'acquisition/libération, et non un ordre relâché.)

Je suis à peu près sûr qu'une implémentation conforme permet à Thread 1 de ne pas mettre à jour y à aucune étape intermédiaire - bien que je n'aie pas étudié le standard de langue, je serais choqué si elle ne prend pas en charge le matériel sur lequel un autre thread interrogation y ne verra jamais la valeur 2.

Cependant, il s’agit d’un cas hypothétique où il pourrait être optimiste d’optimiser les mises à jour de statut. Peut-être un développeur de compilateur viendra-t-il ici et dira-t-il pourquoi ce compilateur a choisi de ne pas le faire, mais une des raisons possibles est de vous laisser vous tirer une balle dans le pied, ou du moins vous cogner à la pointe des pieds.

2
Davislor

Éloignons-nous un peu plus du cas pathologique de la proximité immédiate des trois magasins. Supposons qu’un travail non trivial est effectué entre les magasins et qu’il n’implique pas du tout y (pour que l’analyse du chemin de données puisse déterminer que les trois magasins sont en fait redondants, du moins dans ce fil) n'introduit aucune barrière de mémoire (de sorte que quelque chose d'autre n'oblige pas les magasins à être visibles par les autres threads). Maintenant, il est fort possible que d'autres threads aient la possibilité d'effectuer un travail entre les magasins, et peut-être que ces autres threads manipulent y et que ce thread a des raisons de devoir le réinitialiser à 1 (le second magasin). Si les deux premiers magasins étaient supprimés, cela modifierait le comportement.

0
Andre Kostur