Considérez ce code C:
void foo(void);
long bar(long x) {
foo();
return x;
}
Quand je le compile sur GCC 9.3 avec -O3
ou -Os
, j'obtiens ceci:
bar:
push r12
mov r12, rdi
call foo
mov rax, r12
pop r12
ret
La sortie de clang est identique, sauf pour choisir rbx
au lieu de r12
comme registre enregistré par l'appelé.
Cependant, je veux / m'attends à voir un assemblage qui ressemble plus à ceci:
bar:
push rdi
call foo
pop rax
ret
En anglais, voici ce que je vois se produire:
- Poussez l'ancienne valeur d'un registre sauvegardé dans la pile
- Accédez
x
à ce registre enregistré par l'appelé - Appel
foo
- Passer
x
du registre enregistré à l'appelé au registre des valeurs de retour - Faites éclater la pile pour restaurer l'ancienne valeur du registre enregistré par l'appelé
Pourquoi prendre la peine de jouer avec un registre sauvegardé? Pourquoi ne pas faire ça à la place? Cela semble plus court, plus simple et probablement plus rapide:
- Poussez
x
vers la pile - Appel
foo
- Pop
x
de la pile dans le registre de valeur de retour
Mon montage est-il faux? Est-ce en quelque sorte moins efficace que de jouer avec un registre supplémentaire? Si la réponse à ces deux questions est «non», alors pourquoi GCC ou Clang ne le font-ils pas de cette façon?
Edit: Voici un exemple moins trivial, pour montrer que cela se produit même si la variable est utilisée de manière significative:
long foo(long);
long bar(long x) {
return foo(x * x) - x;
}
J'ai compris:
bar:
push rbx
mov rbx, rdi
imul rdi, rdi
call foo
sub rax, rbx
pop rbx
ret
Je préfère avoir ceci:
bar:
push rdi
imul rdi, rdi
call foo
pop rdi
sub rax, rdi
ret
Cette fois, ce n'est qu'une instruction au lieu de deux, mais le concept de base est le même.
Réponses:
TL: DR:
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 pasrbx
, il y a un compromis entre le débit (nombre d'instructions) et une latence de stockage / rechargement supplémentaire sur lax
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 leret
au lieu de juste après n'est probablement pas pertinent, à moins qu'il y ait uneret
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 sefoo
passe et de l'équilibre entre la latence de stockage / rechargement supplémentaire parx
rapport à davantage d'instructions pour enregistrer / restaurer l'appelantrbx
.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 / rechargerx
dans un emplacement de pile. (Mais les compilateurs ne connaissent pas non plus cette optimisation, qui consistepush
à 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 chacuncall
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 lignefoo
avecbar
. C'est très bien à moins qu'il y ait beaucoup debar
fonctions de type différent etfoo
soit grand, et pour une raison quelconque, ils ne peuvent pas s'aligner sur leurs appelants.la source