Pourquoi les compilateurs insistent-ils ici pour utiliser un registre enregistré par l'appelé?

10

Considérez ce code C:

void foo(void);

long bar(long x) {
    foo();
    return x;
}

Quand je le compile sur GCC 9.3 avec -O3ou -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 rbxau lieu de r12comme 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 xdu 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 xvers la pile
  • Appel foo
  • Pop xde 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?

Lien Godbolt .


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.

Lien Godbolt .

Joseph Sible-Reinstate Monica
la source
4
Optimisation manquée intéressante.
fuz
1
très probablement l'hypothèse que le paramètre passé sera utilisé de sorte que vous souhaitiez enregistrer un registre volatile et conserver le paramètre passé dans un registre ne figurant pas sur la pile, car les accès ultérieurs à ce paramètre sont plus rapides à partir du registre. passez x à foo et vous verrez cela. il s'agit donc probablement d'une partie générique de la configuration de leur cadre de pile.
old_timer
d'accord, je vois que sans foo il n'utilise pas la pile, donc oui c'est une optimisation manquée mais quelque chose que quelqu'un devrait ajouter, analyser la fonction et si la valeur n'est pas utilisée et qu'il n'y a pas de conflit avec ce registre (généralement là est).
old_timer
le backend de bras le fait aussi sur gcc. donc probablement pas le backend
old_timer
clang 10 même histoire (bras arrière).
old_timer

Réponses:

5

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 foone 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 xchaî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 foopush / pop RBX, il n'y a pas grand-chose à gagner pour la latence. Le fait que la restauration se produise juste avant le retau lieu de juste après n'est probablement pas pertinent, à moins qu'il y ait une reterreur 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 raxserait 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 foopasse et de l'équilibre entre la latence de stockage / rechargement supplémentaire par xrapport à 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, 8pour se répandre / recharger xdans 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_framemé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 foopermettra 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 xentre 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 callsi 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 -fltoafin 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 baravec son interlocuteur, ou en ligne fooavec bar. C'est très bien à moins qu'il y ait beaucoup de barfonctions de type différent et foosoit grand, et pour une raison quelconque, ils ne peuvent pas s'aligner sur leurs appelants.

Peter Cordes
la source
Je ne sais pas si vous avez du sens à demander pourquoi certains compilateurs traduisent le code de cette façon, quand peut-être mieux l'utiliser .., sinon une erreur de traduction. par exemple, demander pourquoi clang si étrange (non optimisé) a traduit cette boucle, comparer à gcc, icc et même msvc
RbMm
1
@RbMm: Je ne comprends pas votre point. Cela ressemble à une optimisation manquée totalement distincte pour clang, sans rapport avec le sujet de cette question. Il existe des bogues d'optimisations manqués et, dans la plupart des cas, ils devraient être corrigés. Allez-y et signalez-le sur bugs.llvm.org
Peter Cordes
oui, mon exemple de code absolu sans rapport avec la question d'origine. simplement un autre exemple de traduction étrange (à mon avis) (et pour un seul compilateur simple clang). mais le code asm est de toute façon correct. seulement pas le meilleur et même pas natif comparer gcc / icc / msvc
RbMm