TL: DR:
- Gli interni del compilatore probabilmente non sono impostati per cercare facilmente questa ottimizzazione, ed è probabilmente utile solo per piccole funzioni, non all'interno di grandi funzioni tra le chiamate.
- L'allineamento per creare funzioni di grandi dimensioni è una soluzione migliore per la maggior parte del tempo
- Può verificarsi un compromesso di latenza e velocità effettiva se
foo
non accade per salvare / ripristinare RBX.
I compilatori sono macchinari complessi. Non sono "intelligenti" come un essere umano, e costosi algoritmi per trovare ogni possibile ottimizzazione spesso non valgono il costo in tempi di compilazione extra.
Ho segnalato questo come bug GCC 69986 - codice più piccolo possibile con -Os usando push / pop per versare / ricaricare nel 2016 ; non ci sono state attività o risposte dagli sviluppatori GCC. : /
Leggermente correlato: bug GCC 70408 - riutilizzare lo stesso registro conservato per le chiamate in alcuni casi darebbe un codice più piccolo - gli sviluppatori del compilatore mi hanno detto che ci sarebbe voluto un sacco di lavoro affinché GCC potesse fare quell'ottimizzazione perché richiede un ordine di valutazione di due foo(int)
chiamate basate su ciò che renderebbe il bersaglio più semplice.
Se foo
non si salva / ripristina da rbx
solo, esiste un compromesso tra la velocità effettiva (conteggio delle istruzioni) rispetto a una latenza aggiuntiva di archivio / ricarica nella x
catena di dipendenze -> retval.
I compilatori di solito favoriscono la latenza rispetto alla velocità effettiva, ad esempio utilizzando 2x LEA anziché imul reg, reg, 10
(latenza a 3 cicli, velocità effettiva 1 / clock), poiché la maggior parte del codice ha una media significativamente inferiore a 4 uops / clock su condotte tipiche a 4 larghezze come Skylake. (Altre istruzioni / uops occupano più spazio nel ROB, riducendo quanto più avanti può vedere la stessa finestra fuori servizio, e l'esecuzione è in realtà piena di bancarelle che probabilmente rappresentano alcuni dei meno di 4 uops / orologio medio).
Se foo
push / pop RBX, non c'è molto da guadagnare per la latenza. Fare in modo che il ripristino avvenga poco prima ret
di quello immediatamente successivo non è probabilmente rilevante, a meno che non ci sia un ret
errore o una I-cache mancante che ritarda il recupero del codice all'indirizzo di ritorno.
La maggior parte delle funzioni non banali salverà / ripristinerà RBX, quindi spesso non è una buona ipotesi che lasciare una variabile in RBX significhi effettivamente che è rimasto davvero in un registro attraverso la chiamata. (Anche se la scelta casuale delle funzioni dei registri conservati dalla chiamata può essere una buona idea per mitigare questo a volte.)
Quindi sì push rdi
/ pop rax
sarebbe più efficiente in questo caso, e questa è probabilmente una mancata ottimizzazione per minuscole funzioni non foglia, a seconda di cosa foo
fa e del bilanciamento tra latenza aggiuntiva di negozio / ricarica x
rispetto a più istruzioni per salvare / ripristinare il chiamante rbx
.
È possibile che i metadati dello svolgersi della pila rappresentino qui le modifiche a RSP, proprio come se fosse stato usato sub rsp, 8
per versare / ricaricare x
in uno slot dello stack. (Ma i compilatori non conoscono nemmeno questa ottimizzazione, dell'uso push
di riservare spazio e inizializzare una variabile. Quale compilatore C / C ++ può usare le istruzioni push pop per creare variabili locali, invece di aumentare esp una volta?. E farlo per più di una var locale porterebbe a .eh_frame
stack più grandi di svolgersi dei metadati perché si sposta il puntatore dello stack separatamente ad ogni push. Ciò non impedisce ai compilatori di usare push / pop per salvare / ripristinare reg conservati alle chiamate.)
IDK se varrebbe la pena insegnare ai compilatori a cercare questa ottimizzazione
È forse una buona idea attorno a un'intera funzione, non attraverso una chiamata all'interno di una funzione. E come ho detto, si basa sul presupposto pessimistico che foo
salverà / ripristinerà RBX comunque. (O l'ottimizzazione per la velocità effettiva se sai che la latenza da x al valore restituito non è importante. Ma i compilatori non lo sanno e di solito ottimizzano per la latenza).
Se inizi a fare questa ipotesi pessimistica in un sacco di codice (come intorno alle chiamate a funzione singola all'interno di funzioni), inizierai a ricevere più casi in cui RBX non viene salvato / ripristinato e potresti averne tratto vantaggio.
Inoltre, non si desidera questo ulteriore salvataggio / ripristino push / pop in un loop, è sufficiente salvare / ripristinare RBX all'esterno del loop e utilizzare i registri conservati nelle chiamate nei loop che effettuano chiamate di funzione. Anche senza loop, in generale la maggior parte delle funzioni effettua più chiamate di funzione. Questa idea di ottimizzazione potrebbe applicarsi se in realtà non si utilizza x
tra nessuna delle chiamate, appena prima della prima e dopo l'ultima, altrimenti si ha un problema a mantenere l'allineamento dello stack di 16 byte per ciascuna call
se si esegue un pop dopo una chiama, prima di un'altra chiamata.
I compilatori non sono bravi nelle minuscole funzioni in generale. Ma non è eccezionale nemmeno per le CPU. Le chiamate di funzione non in linea hanno un impatto sull'ottimizzazione nel migliore dei casi, a meno che i compilatori non possano vedere gli interni della chiamata e fare più ipotesi del solito. Una chiamata di funzione non in linea è una barriera di memoria implicita: un chiamante deve presumere che una funzione possa leggere o scrivere qualsiasi dato accessibile a livello globale, quindi tutti questi var devono essere sincronizzati con la macchina astratta C. (L'analisi di escape consente di mantenere i locali nei registri tra le chiamate se il loro indirizzo non è sfuggito alla funzione.) Inoltre, il compilatore deve presumere che i registri con blocco delle chiamate siano tutti bloccati. Questo fa schifo per il virgola mobile in x86-64 System V, che non ha registri XMM conservati nella chiamata.
Le funzioni minuscole come bar()
stanno meglio in linea con i loro chiamanti. Compilare in -flto
modo che ciò possa accadere anche attraverso i limiti dei file nella maggior parte dei casi. (I puntatori a funzione e i limiti delle librerie condivise possono annullare questo.)
Penso che uno dei motivi per cui i compilatori non si siano presi la briga di provare a fare queste ottimizzazioni sia che richiederebbe un sacco di codice diverso negli interni del compilatore , diverso dallo stack normale rispetto al codice di allocazione del registro che sa come salvare le chiamate conservate registra e li usa.
cioè sarebbe un sacco di lavoro da implementare e molto codice da mantenere, e se diventa troppo entusiasta di farlo potrebbe peggiorare il codice.
E anche che (si spera) non è significativo; se è importante, dovresti essere bar
in linea con il suo chiamante o foo
in linea bar
. Questo va bene a meno che non ci siano molte bar
funzioni simili ed foo
è grande, e per qualche ragione non possono essere in linea con i loro chiamanti.