Perché i compilatori insistono nell'utilizzare un registro salvato dalla chiamata qui?


10

Considera questo codice C:

void foo(void);

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

Quando lo compilo su GCC 9.3 con -O3o -Os, ottengo questo:

bar:
        push    r12
        mov     r12, rdi
        call    foo
        mov     rax, r12
        pop     r12
        ret

L'output di clang è identico tranne che per la scelta rbxanziché r12come registro salvato dalla chiamata.

Tuttavia, voglio / mi aspetto di vedere un assemblaggio che assomigli di più a questo:

bar:
        push    rdi
        call    foo
        pop     rax
        ret

In inglese, ecco cosa vedo accadere:

  • Sposta nello stack il vecchio valore di un registro salvato dalla chiamata
  • Passa xa quel registro salvato dalla chiamata
  • Chiamata foo
  • Passa xdal registro salvato dalla chiamata al registro dei valori di ritorno
  • Pop lo stack per ripristinare il vecchio valore del registro salvato dalla chiamata

Perché preoccuparsi di pasticciare con un registro salvato dalla chiamata? Perché non farlo invece? Sembra più breve, più semplice e probabilmente più veloce:

  • Spingere xin pila
  • Chiamata foo
  • Pop xdallo stack nel registro del valore restituito

La mia assemblea è sbagliata? È in qualche modo meno efficiente del pasticciare con un registro extra? Se la risposta ad entrambi è "no", allora perché GCC o Clang non lo fanno in questo modo?

Link Godbolt .


Modifica: ecco un esempio meno banale, per dimostrarlo accade anche se la variabile viene utilizzata in modo significativo:

long foo(long);

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

Capisco questo:

bar:
        push    rbx
        mov     rbx, rdi
        imul    rdi, rdi
        call    foo
        sub     rax, rbx
        pop     rbx
        ret

Preferirei avere questo:

bar:
        push    rdi
        imul    rdi, rdi
        call    foo
        pop     rdi
        sub     rax, rdi
        ret

Questa volta, è solo un'istruzione off vs due, ma il concetto di base è lo stesso.

Link Godbolt .


4
Interessante ottimizzazione mancata.
fuz,

1
molto probabilmente il presupposto che verrà utilizzato il parametro passato, quindi si desidera salvare un registro volatile e mantenere il parametro passato in un registro non nello stack poiché gli accessi successivi a quel parametro sono più veloci dal registro. passa x a pippo e vedrai questo. quindi è probabilmente solo una parte generica della loro configurazione dello stack frame.
old_timer

garantito vedo che senza pippo non usa lo stack, quindi sì è un'ottimizzazione mancata ma qualcosa che qualcuno dovrebbe aggiungere, analizzare la funzione e se il valore non viene utilizzato e non c'è conflitto con quel registro (generalmente lì è).
old_timer

il backend del braccio lo fa anche su gcc. quindi probabilmente non il backend
old_timer

clang 10 stessa storia (arm backend).
old_timer

Risposte:


5

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 foonon 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 rbxsolo, esiste un compromesso tra la velocità effettiva (conteggio delle istruzioni) rispetto a una latenza aggiuntiva di archivio / ricarica nella xcatena 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 foopush / pop RBX, non c'è molto da guadagnare per la latenza. Fare in modo che il ripristino avvenga poco prima retdi quello immediatamente successivo non è probabilmente rilevante, a meno che non ci sia un reterrore 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 raxsarebbe più efficiente in questo caso, e questa è probabilmente una mancata ottimizzazione per minuscole funzioni non foglia, a seconda di cosa foofa e del bilanciamento tra latenza aggiuntiva di negozio / ricarica xrispetto 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, 8per versare / ricaricare xin uno slot dello stack. (Ma i compilatori non conoscono nemmeno questa ottimizzazione, dell'uso pushdi 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_framestack 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 foosalverà / 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 xtra 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 callse 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 -fltomodo 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 barin linea con il suo chiamante o fooin linea bar. Questo va bene a meno che non ci siano molte barfunzioni simili ed fooè grande, e per qualche ragione non possono essere in linea con i loro chiamanti.


non sono sicuro che abbia senso chiedersi perché alcuni compilatori traducono il codice in quel modo, quando potrebbe essere meglio usarlo .., se non in errore nella traduzione. per esempio possibile chiedere perché clang così strano (non ottimizzato) ha tradotto questo loop, confrontarlo con gcc, icc e persino msvc
RbMm

1
@RbMm: non capisco il tuo punto. Sembra un'ottimizzazione mancante totalmente separata per clang, non correlata all'argomento di questa domanda. Esistono bug di ottimizzazione persi e nella maggior parte dei casi dovrebbero essere corretti. Vai avanti e segnalalo
Peter Cordes il

sì, il mio esempio di codice è assolutamente estraneo alla domanda originale. semplicemente un altro esempio di traduzione strana (per il mio look) (e per un solo compilatore di clang). ma risulta comunque corretto il codice asm. solo non migliore e eveen non nativo confronta gcc / icc / msvc
RbMm
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.