Je regarde le discours de Chandler Carruth dans CppCon 2019:
Il n'y a pas d'abstractions à coût nul
dans ce document, il donne l'exemple de la façon dont il a été surpris par la quantité de frais généraux que vous encourez en utilisant un std::unique_ptr<int>
sur un int*
; ce segment commence au point de temps 17:25.
Vous pouvez consulter les résultats de la compilation de son exemple de paire d'extraits de code (godbolt.org) - pour constater qu'en effet, il semble que le compilateur ne souhaite pas transmettre la valeur unique_ptr - qui, en fait, en fin de compte, est juste une adresse - à l'intérieur d'un registre, uniquement en mémoire pure.
L'un des points soulevés par M. Carruth vers 27h00 est que l'ABI C ++ nécessite que des paramètres de sous-valeur (certains mais pas tous; peut-être - des types non primitifs? Des types non trivialement constructibles?) Soient passés en mémoire plutôt que dans un registre.
Mes questions:
- Est-ce réellement une exigence ABI sur certaines plateformes? (lequel?) Ou peut-être que c'est juste une pessimisation dans certains scénarios?
- Pourquoi l'ABI est-il comme ça? Autrement dit, si les champs d'une structure / classe s'inscrivent dans des registres, ou même dans un seul registre - pourquoi ne pourrions-nous pas le transmettre dans ce registre?
- Le comité des normes C ++ a-t-il discuté de ce point ces dernières années, ou jamais?
PS - Pour ne pas laisser cette question sans code:
Pointeur simple:
void bar(int* ptr) noexcept;
void baz(int* ptr) noexcept;
void foo(int* ptr) noexcept {
if (*ptr > 42) {
bar(ptr);
*ptr = 42;
}
baz(ptr);
}
Pointeur unique:
using std::unique_ptr;
void bar(int* ptr) noexcept;
void baz(unique_ptr<int> ptr) noexcept;
void foo(unique_ptr<int> ptr) noexcept {
if (*ptr > 42) {
bar(ptr.get());
*ptr = 42;
}
baz(std::move(ptr));
}
la source
this
pointeur qui pointe vers un emplacement valide.unique_ptr
a ceux-là. Renverser le registre à cet effet annulerait en quelque sorte toute l'optimisation "passer dans un registre".Réponses:
Un exemple est System V Application Binary Interface Architecture AMD64 Supplément processeur . Cette ABI est destinée aux CPU 64 bits compatibles x86 (architecture Linux x86_64). Il est suivi sur Solaris, Linux, FreeBSD, macOS, sous-système Windows pour Linux:
Notez que seuls 2 registres à usage général peuvent être utilisés pour passer 1 objet avec un constructeur de copie trivial et un destructeur trivial, c'est-à-dire que seules les valeurs des objets
sizeof
ne dépassant pas 16 peuvent être passées dans les registres. Voir Conventions d'appel par Agner Fog pour un traitement détaillé des conventions d'appel, en particulier §7.1 Passage et retour d'objets. Il existe des conventions d'appel distinctes pour le passage des types SIMD dans les registres.Il existe différentes ABI pour d'autres architectures de CPU.
Il s'agit d'un détail d'implémentation, mais lorsqu'une exception est gérée, lors du déroulement de la pile, les objets dont la durée de stockage automatique est détruite doivent être adressables par rapport au cadre de la pile de fonctions car les registres ont été encombrés à ce moment-là. Le code de déroulement de pile a besoin des adresses des objets pour invoquer leurs destructeurs mais les objets dans les registres n'ont pas d'adresse.
Pédantiquement, les destructeurs opèrent sur des objets :
et un objet ne peut pas exister en C ++ si aucun stockage adressable ne lui est alloué car l'identité de l'objet est son adresse .
Lorsqu'une adresse d'un objet avec un constructeur de copie trivial conservé dans des registres est nécessaire, le compilateur peut simplement stocker l'objet en mémoire et obtenir l'adresse. Si le constructeur de copie n'est pas trivial, en revanche, le compilateur ne peut pas simplement le stocker en mémoire, il doit plutôt appeler le constructeur de copie qui prend une référence et nécessite donc l'adresse de l'objet dans les registres. La convention d'appel ne peut probablement pas dépendre du fait que le constructeur de copie a été inséré dans l'appelé ou non.
Une autre façon de penser à cela est que pour les types trivialement copiables, le compilateur transfère la valeur d'un objet dans des registres, à partir desquels un objet peut être récupéré par des mémoires de mémoire ordinaire si nécessaire. Par exemple:
sur x86_64 avec System V ABI se compile en:
Dans son discours incitant à la réflexion, Chandler Carruth mentionne qu'un changement d'ABI cassant peut être nécessaire (entre autres) pour mettre en œuvre le mouvement destructeur qui pourrait améliorer les choses. IMO, le changement ABI pourrait être sans rupture si les fonctions utilisant le nouvel ABI optent explicitement pour avoir une nouvelle liaison différente, par exemple les déclarer en
extern "C++20" {}
bloc (éventuellement, dans un nouvel espace de noms en ligne pour la migration des API existantes). Pour que seul le code compilé avec les nouvelles déclarations de fonction avec la nouvelle liaison puisse utiliser le nouvel ABI.Notez que l'ABI ne s'applique pas lorsque la fonction appelée a été insérée. Comme pour la génération de code au moment de la liaison, le compilateur peut incorporer des fonctions définies dans d'autres unités de traduction ou utiliser des conventions d'appel personnalisées.
la source
Avec les ABI courants, le destructeur non trivial -> ne peut pas passer dans les registres
(Une illustration d'un point dans la réponse de @ MaximEgorushkin utilisant l'exemple de @ harold dans un commentaire; corrigé selon le commentaire de @ Yakk.)
Si vous compilez:
vous obtenez:
c'est-à-dire que l'
Foo
objet est passé àtest
dans un registre (edi
) et également retourné dans un registre (eax
).Lorsque le destructeur n'est pas trivial (comme le
std::unique_ptr
exemple des OP) - Les ABI communs nécessitent un placement sur la pile. Cela est vrai même si le destructeur n'utilise pas du tout l'adresse de l'objet.Ainsi, même dans le cas extrême d'un destructeur ne rien faire, si vous compilez:
vous obtenez:
avec chargement et stockage inutiles.
la source
std::unique_ptr
dans un registre non conforme.register
mot-clé était destiné à rendre trivial pour la machine physique de stocker quelque chose dans un registre en bloquant les choses qui rendent pratiquement plus difficile de "ne pas avoir d'adresse" dans la machine physique.Si quelque chose est visible à la frontière de l'unité de complication, qu'il soit défini implicitement ou explicitement, il fait partie de l'ABI.
Le problème fondamental est que les registres sont enregistrés et restaurés tout le temps lorsque vous montez et descendez dans la pile des appels. Il n'est donc pas pratique d'avoir une référence ou un pointeur vers eux.
L'intégration et les optimisations qui en découlent sont agréables quand cela se produit, mais un concepteur ABI ne peut pas compter sur cela. Ils doivent concevoir l'ABI en supposant le pire des cas. Je ne pense pas que les programmeurs seraient très satisfaits d'un compilateur où l'ABI a changé en fonction du niveau d'optimisation.
Un type trivialement copiable peut être passé dans les registres car l'opération de copie logique peut être divisée en deux parties. Les paramètres sont copiés dans les registres utilisés pour transmettre les paramètres par l'appelant, puis copiés dans la variable locale par l'appelé. Le fait que la variable locale ait ou non un emplacement mémoire n'est donc que la préoccupation de l'appelé.
Un type où un constructeur de copie ou de déplacement doit être utilisé d'autre part ne peut pas avoir son opération de copie divisée de cette manière, il doit donc être passé en mémoire.
Je ne sais pas si les organismes de normalisation ont envisagé cela.
La solution évidente pour moi serait d'ajouter des mouvements destructeurs appropriés (plutôt que la maison à mi-chemin actuelle d'un "état valide mais non spécifié") à la jauge, puis d'introduire un moyen de signaler un type comme permettant des "mouvements destructeurs triviaux "même s'il ne permet pas de copies triviales.
mais une telle solution DEVRAIT nécessiter la rupture de l'ABI du code existant à implémenter pour les types existants, ce qui peut apporter un peu de résistance (bien que les ruptures ABI à la suite de nouvelles versions standard C ++ ne soient pas sans précédent, par exemple les changements std :: string en C ++ 11 a entraîné une rupture ABI ..
la source
unique_ptr
etshared_ptr
sémantique:shared_ptr<T>
vous permet de fournir au ctor 1) un ptr x à l'objet dérivé U à supprimer avec le type statique U w / l'expressiondelete x;
(vous n'avez donc pas besoin d'un dtor virtuel ici) 2) ou même une fonction de nettoyage personnalisée. Cela signifie que l'état d'exécution est utilisé à l'intérieur dushared_ptr
bloc de contrôle pour coder ces informations. OTOHunique_ptr
n'a pas une telle fonctionnalité et n'encode pas le comportement de suppression dans l'état; la seule façon de personnaliser le nettoyage est de créer une autre instanciation de modèle (un autre type de classe).Nous devons d'abord revenir à ce que signifie passer par valeur et par référence.
Pour les langages comme Java et SML, le passage par valeur est simple (et il n'y a pas de passage par référence), tout comme la copie d'une valeur de variable est, comme toutes les variables ne sont que des scalaires et ont une sémantique de copie intégrée: ce sont celles qui comptent comme arithmétique tapez en C ++, ou "références" (pointeurs avec un nom et une syntaxe différents).
En C, nous avons des types scalaires et définis par l'utilisateur:
En C ++, les types définis par l'utilisateur peuvent avoir une sémantique de copie définie par l'utilisateur, qui permet une programmation véritablement "orientée objet" avec des objets possédant la propriété de leurs ressources et des opérations de "copie profonde". Dans ce cas, une opération de copie est vraiment un appel à une fonction qui peut presque effectuer des opérations arbitraires.
Pour les structures C compilées en C ++, la "copie" est toujours définie comme appelant l'opération de copie définie par l'utilisateur (constructeur ou opérateur d'affectation), qui est implicitement générée par le compilateur. Cela signifie que la sémantique d'un programme de sous-ensemble commun C / C ++ est différente en C et C ++: en C, un type d'agrégat entier est copié, en C ++ une fonction de copie générée implicitement est appelée pour copier chaque membre; le résultat final étant que dans les deux cas, chaque membre est copié.
(Il y a une exception, je pense, quand une structure à l'intérieur d'une union est copiée.)
Donc, pour un type de classe, la seule façon (en dehors des copies d'union) de créer une nouvelle instance est via un constructeur (même pour ceux avec des constructeurs générés par un compilateur trivial).
Vous ne pouvez pas prendre l'adresse d'une valeur r via un opérateur unaire,
&
mais cela ne signifie pas qu'il n'y a pas d'objet rvalue; et un objet, par définition, a une adresse ; et cette adresse est même représentée par une construction syntaxique: un objet de type classe ne peut être créé que par un constructeur, et il a unthis
pointeur; mais pour les types triviaux, il n'y a pas de constructeur écrit par l'utilisateur donc il n'y a pas de place pour mettrethis
tant que la copie n'est pas construite et nommée.Pour le type scalaire, la valeur d'un objet est la valeur r de l'objet, la valeur mathématique pure stockée dans l'objet.
Pour un type de classe, la seule notion d'une valeur de l'objet est une autre copie de l'objet, qui ne peut être créée que par un constructeur de copie, une fonction réelle (bien que pour les types triviaux cette fonction soit si spécialement triviale, ceux-ci peuvent parfois être créé sans appeler le constructeur). Cela signifie que la valeur de l'objet est le résultat d'un changement d'état global du programme par une exécution . Il n'y accède pas mathématiquement.
Donc, passer par valeur n'est vraiment pas une chose: c'est passer par l'appel du constructeur de copie , ce qui est moins joli. On s'attend à ce que le constructeur de copie effectue une opération de "copie" sensible selon la sémantique appropriée du type d'objet, en respectant ses invariants internes (qui sont des propriétés utilisateur abstraites, pas des propriétés C ++ intrinsèques).
Passer par la valeur d'un objet de classe signifie:
Notez que le problème n'a rien à voir avec le fait que la copie elle-même soit un objet avec une adresse: tous les paramètres de fonction sont des objets et ont une adresse (au niveau sémantique du langage).
La question est de savoir si:
Dans le cas d'un type de classe trivial, vous pouvez toujours définir le membre du membre copie de l'original, vous pouvez donc définir la valeur r pure de l'original en raison de la trivialité des opérations de copie (constructeur de copie et affectation). Ce n'est pas le cas avec des fonctions utilisateur spéciales arbitraires: une valeur de l'original doit être une copie construite.
Les objets de classe doivent être construits par l'appelant; un constructeur a formellement un
this
pointeur mais le formalisme n'est pas pertinent ici: tous les objets ont formellement une adresse mais seuls ceux qui obtiennent leur adresse utilisée de manière non purement locale (contrairement à*&i = 1;
ce qui est une utilisation purement locale de l'adresse) doivent avoir une définition bien définie adresse.Un objet doit absolument être passé par adresse s'il doit sembler avoir une adresse dans ces deux fonctions compilées séparément:
Ici, même s'il
something(address)
s'agit d'une fonction ou d'une macro pure ou autre (commeprintf("%p",arg)
) qui ne peut pas stocker l'adresse ou communiquer avec une autre entité, nous avons l'obligation de passer par adresse car l'adresse doit être bien définie pour un objet uniqueint
qui a un unique identité.Nous ne savons pas si une fonction externe sera "pure" en termes d'adresses qui lui seront transmises.
Ici, le potentiel d'une utilisation réelle de l'adresse dans un constructeur ou un destructeur non trivial du côté de l'appelant est probablement la raison de prendre la route simpliste et sécurisée et de donner à l'objet une identité dans l'appelant et de transmettre son adresse, comme il le fait sûr que toute utilisation non triviale de son adresse dans le constructeur, après la construction et dans le destructeur est cohérente :
this
doit sembler être la même sur l'existence de l'objet.Un constructeur ou destructeur non trivial comme toute autre fonction peut utiliser le
this
pointeur d'une manière qui requiert une cohérence sur sa valeur même si certains objets avec des éléments non triviaux ne le peuvent pas:Notez que dans ce cas, malgré l'utilisation explicite d'un pointeur (syntaxe explicite
this->
), l'identité de l'objet n'est pas pertinente: le compilateur pourrait bien utiliser la copie au niveau du bit de l'objet pour le déplacer et pour faire une "élision de copie". Ceci est basé sur le niveau de "pureté" de l'utilisation dethis
dans les fonctions membres spéciales (l'adresse n'échappe pas).Mais la pureté n'est pas un attribut disponible au niveau de la déclaration standard (il existe des extensions de compilateur qui ajoutent une description de la pureté sur la déclaration de fonction non en ligne), vous ne pouvez donc pas définir un ABI basé sur la pureté du code qui peut ne pas être disponible (le code peut ou peuvent ne pas être en ligne et disponibles pour analyse).
La pureté est mesurée comme «certainement pure» ou «impure ou inconnue». Le terrain d'entente, ou limite supérieure de la sémantique (en fait maximum), ou LCM (Least Common Multiple) est "inconnu". Ainsi, l'ABI s'installe sur inconnu.
Sommaire:
Travaux futurs possibles:
L'annotation de pureté est-elle suffisamment utile pour être généralisée et standardisée?
la source
void foo(unique_ptr<int> ptr)
prend l'objet de classe par valeur . Cet objet a un membre pointeur, mais nous parlons de l'objet de classe lui-même transmis par référence. (Parce qu'il n'est pas trivialement copiable, son constructeur / destructeur a donc besoin d'un cohérentthis
.) C'est le vrai argument et non connecté au premier exemple de passage par référence explicitement ; dans ce cas, le pointeur est passé dans un registre.int
: j'ai écrit un exemple de "smart fileno" qui illustre que la "propriété" n'a rien à voir avec "porter un ptr".unique_ptr<T*>
, il s'agit de la même taille et de la même disposition queT*
dans un registre. Les objets de classe copiables de manière triviale peuvent être passés par valeur dans les registres du système V x86-64, comme la plupart des conventions d'appel. Cela fait une copie de l'unique_ptr
objet, contrairement à votreint
exemple où l'appelé&i
est l'adresse de l'appelanti
car vous avez passé par référence au niveau C ++ , pas seulement comme détail d'implémentation asm.unique_ptr
objet; il utilisestd::move
donc il est sûr de le copier car cela n'entraînera pas 2 copies du mêmeunique_ptr
. Mais pour un type trivialement copiable, oui, il copie tout l'objet agrégé. S'il s'agit d'un seul membre, les bonnes conventions d'appel le traitent de la même manière qu'un scalaire de ce type.struct{}
est une structure C ++. Vous devriez peut-être dire «structures simples» ou «contrairement à C». Parce que oui, il y a une différence. Si vous utilisezatomic_int
comme membre struct, C le copiera de manière non atomique, erreur C ++ sur le constructeur de copie supprimé. J'oublie ce que fait C ++ sur les structures avec lesvolatile
membres. C vous permettrastruct tmp = volatile_struct;
de copier le tout (utile pour un SeqLock); C ++ ne le fera pas.