TL: DR:
- Les composants internes du compilateur ne sont probablement pas configurés pour rechercher facilement cette optimisation, et il n'est probablement utile que pour les petites fonctions, pas dans les grandes fonctions entre les appels.
- La création de grandes fonctions est une meilleure solution la plupart du temps
- Il peut y avoir un compromis latence / débit s'il
foo
ne parvient pas à enregistrer / restaurer RBX.
Les compilateurs sont des machines complexes. Ils ne sont pas "intelligents" comme un humain, et les algorithmes coûteux pour trouver toutes les optimisations possibles ne valent souvent pas le coût en temps de compilation supplémentaire.
J'ai signalé cela en tant que bug GCC 69986 - un code plus petit possible avec -Os en utilisant push / pop pour renverser / recharger en 2016 ; il n'y a eu aucune activité ou réponse des développeurs GCC. : /
Légèrement lié: bogue GCC 70408 - la réutilisation du même registre préservé par les appels donnerait du code plus petit dans certains cas - les développeurs du compilateur m'ont dit qu'il faudrait énormément de travail pour que GCC puisse faire cette optimisation car il nécessite de choisir l'ordre d'évaluation de deux foo(int)
appels basés sur ce qui rendrait l'asm cible plus simple.
Si foo
ne se sauvegarde pas / ne se restaure pas rbx
, il y a un compromis entre le débit (nombre d'instructions) et une latence de stockage / rechargement supplémentaire sur la x
chaîne de dépendance -> retval.
Les compilateurs favorisent généralement la latence sur le débit, par exemple en utilisant 2x LEA au lieu de imul reg, reg, 10
(latence à 3 cycles, 1 / débit d'horloge), car la plupart des codes affichent une moyenne nettement inférieure à 4 uops / horloge sur des pipelines à 4 larges comme Skylake. (Plus d'instructions / uops prennent plus de place dans le ROB, ce qui réduit la distance à venir que la même fenêtre hors service peut voir, et l'exécution est en fait explosive avec des décrochages représentant probablement certains des moins de 4 uops / moyenne d'horloge.)
Si foo
push / pop RBX, il n'y a pas grand-chose à gagner pour la latence. Le fait que la restauration se produise juste avant le ret
au lieu de juste après n'est probablement pas pertinent, à moins qu'il y ait une ret
erreur de prévision ou une erreur I-cache qui retarde la récupération du code à l'adresse de retour.
La plupart des fonctions non triviales enregistreront / restaureront RBX, donc ce n'est souvent pas une bonne hypothèse que de laisser une variable dans RBX signifie qu'elle est vraiment restée dans un registre pendant l'appel. (Bien que la randomisation des fonctions de registres préservées par appel choisisse peut être une bonne idée pour atténuer cela parfois.)
Donc oui push rdi
/ pop rax
serait plus efficace dans ce cas, et il s'agit probablement d'une optimisation manquée pour de minuscules fonctions non-feuilles, en fonction de ce qui se foo
passe et de l'équilibre entre la latence de stockage / rechargement supplémentaire par x
rapport à davantage d'instructions pour enregistrer / restaurer l'appelant rbx
.
Il est possible que les métadonnées de déroulement de pile représentent les modifications apportées à RSP ici, tout comme si elles avaient été utilisées sub rsp, 8
pour se répandre / recharger x
dans un emplacement de pile. (Mais les compilateurs ne connaissent pas non plus cette optimisation, qui consiste push
à réserver de l'espace et à initialiser une variable. Quel compilateur C / C ++ peut utiliser des instructions push pop pour créer des variables locales, au lieu d'augmenter simplement esp une fois?. Et faire cela pendant plus de une variable locale entraînerait des .eh_frame
métadonnées de déroulement de pile plus importantes, car vous déplacez le pointeur de pile séparément à chaque push. Cela n'empêche pas les compilateurs d'utiliser push / pop pour enregistrer / restaurer les regs préservés par les appels.)
IDK s'il vaut la peine d'enseigner aux compilateurs à rechercher cette optimisation
C'est peut-être une bonne idée pour une fonction entière, pas pour un appel à l'intérieur d'une fonction. Et comme je l'ai dit, il est basé sur l'hypothèse pessimiste qui foo
permettra de sauvegarder / restaurer RBX de toute façon. (Ou optimisation du débit si vous savez que la latence de x à la valeur de retour n'est pas importante. Mais les compilateurs ne le savent pas et optimisent généralement la latence).
Si vous commencez à faire cette hypothèse pessimiste dans beaucoup de code (comme autour d'appels de fonction unique à l'intérieur de fonctions), vous commencerez à obtenir plus de cas où RBX n'est pas enregistré / restauré et vous auriez pu en profiter.
Vous ne voulez pas non plus que cette sauvegarde / restauration push / pop supplémentaire dans une boucle, enregistrez / restaurez RBX en dehors de la boucle et utilisez des registres préservés dans les boucles qui effectuent des appels de fonction. Même sans boucles, dans la plupart des cas, la plupart des fonctions effectuent des appels de fonction multiples. Cette idée d'optimisation pourrait s'appliquer si vous n'utilisez vraiment pas x
entre l'un des appels, juste avant le premier et après le dernier, sinon vous avez un problème de maintien de l'alignement de pile de 16 octets pour chacun call
si vous effectuez un pop après un appel, avant un autre appel.
Les compilateurs ne sont pas parfaits pour les petites fonctions en général. Mais ce n'est pas non plus idéal pour les processeurs. Les appels de fonction non en ligne ont un impact sur l'optimisation dans le meilleur des cas, sauf si les compilateurs peuvent voir les éléments internes de l'appelé et faire plus d'hypothèses que d'habitude. Un appel de fonction non en ligne est une barrière de mémoire implicite: un appelant doit supposer qu'une fonction peut lire ou écrire des données accessibles à l'échelle mondiale, de sorte que toutes ces variables doivent être synchronisées avec la machine abstraite C. (L'analyse d'échappement permet de conserver les sections locales dans les registres des appels si leur adresse n'a pas échappé à la fonction.) De plus, le compilateur doit supposer que les registres clobés sont tous clobés. Cela craint pour la virgule flottante dans le système V x86-64, qui n'a pas de registres XMM préservés des appels.
De minuscules fonctions comme bar()
sont mieux placées dans leurs appelants. Compilez avec -flto
afin que cela puisse se produire même au-delà des limites des fichiers dans la plupart des cas. (Les pointeurs de fonction et les limites de bibliothèque partagée peuvent vaincre cela.)
Je pense que l'une des raisons pour lesquelles les compilateurs n'ont pas pris la peine d'essayer de faire ces optimisations est que cela nécessiterait tout un tas de code différent dans les internes du compilateur , différent de la pile normale par rapport au code d'allocation de registre qui sait comment sauvegarder les appels préservés registres et les utiliser.
c'est-à-dire que ce serait beaucoup de travail à implémenter, et beaucoup de code à maintenir, et s'il devient trop enthousiaste à le faire, cela pourrait aggraver le code.
Et aussi que ce n'est (espérons-le) pas significatif; si cela est important, vous devriez être en ligne bar
avec son interlocuteur, ou en ligne foo
avec bar
. C'est très bien à moins qu'il y ait beaucoup de bar
fonctions de type différent et foo
soit grand, et pour une raison quelconque, ils ne peuvent pas s'aligner sur leurs appelants.