Perché questa funzione spinge RAX in pila come prima operazione?

Nell’assemblaggio della sorgente C ++ qui sotto. Perché RAX è spinto in pila?

RAX, come ho capito dall’ABI, potrebbe contenere qualsiasi cosa, dalla funzione di chiamata. Ma lo salviamo qui e poi spostiamo lo stack indietro di 8 byte. Quindi il RAX in pila è, penso che sia rilevante solo per l’operazione std::__throw_bad_function_call() …?

Il codice:-

 #include  void f(std::function a) { a(); } 

Output, da gcc.godbolt.org , usando Clang 3.7.1 -O3:

 f(std::function): # @f(std::function) push rax cmp qword ptr [rdi + 16], 0 je .LBB0_1 add rsp, 8 jmp qword ptr [rdi + 24] # TAILCALL .LBB0_1: call std::__throw_bad_function_call() 

Sono sicuro che la ragione è ovvia, ma ho difficoltà a capirlo.

Ecco una tailcall senza il wrapper std::function per il confronto:

 void g(void(*a)()) { a(); } 

Il banale:

 g(void (*)()): # @g(void (*)()) jmp rdi # TAILCALL 

L’ ABI a 64 bit richiede che lo stack sia allineato a 16 byte prima di un’istruzione di call .

call spinge un indirizzo di ritorno a 8 byte nello stack, che interrompe l’allineamento, quindi il compilatore deve fare qualcosa per allineare nuovamente lo stack a un multiplo di 16 prima della prossima call .

(La scelta progettuale ABI di richiedere l’allineamento prima di una call anziché dopo ha il vantaggio minore che se qualche argomento è passato nello stack, questa scelta rende il primo arg 16B-allineato).

Spingere un valore di non-cura funziona bene e può essere più efficiente di sub rsp, 8 su CPU con un motore stack . (Vedi i commenti).

Il motivo che push rax è che è necessario allineare lo stack a un limite di 16 byte per conformarsi all’ABI del sistema V a 64 bit nel caso in cui je .LBB0_1 ramo je .LBB0_1 . Il valore inserito nello stack non è rilevante. Un altro modo sarebbe stato sottrarre 8 da RSP con sub rsp, 8 . L’ABI dichiara l’allineamento in questo modo:

La fine dell’area degli argomenti di input deve essere allineata su un limite di byte 16 (32, se __m256 è passato sullo stack). In altre parole, il valore (% rsp + 8) è sempre un multiplo di 16 (32) quando il controllo viene trasferito al punto di ingresso della funzione. Il puntatore dello stack,% rsp, punta sempre alla fine dell’ultimo frame dello stack allocato.

Prima della chiamata alla funzione f lo stack era allineato a 16 byte secondo la convenzione di chiamata. Dopo che il controllo è stato trasferito tramite una CHIAMATA a f l’indirizzo di ritorno è stato posto sullo stack disallineare lo stack di 8. push rax è un modo semplice per sottrarre 8 da RSP e riallinearlo di nuovo. Se il ramo è utilizzato per call std::__throw_bad_function_call() lo stack sarà allineato correttamente affinché quella chiamata funzioni.

Nel caso in cui il confronto cada, la pila apparirà esattamente come è avvenuta all’ingresso della funzione una volta add rsp, 8 l’ add rsp, 8 istruzioni. L’indirizzo di ritorno del CALLER alla funzione f tornerà in cima allo stack e lo stack sarà nuovamente disallineato di 8. Questo è ciò che vogliamo perché una TAIL CALL è stata creata con jmp qword ptr [rdi + 24] per trasferire il controllo alla funzione a . Questo farà in modo che JMP non lo chiami . Quando la funzione a fa un RET , ritornerà direttamente alla funzione che ha chiamato f .

A un livello di ottimizzazione più elevato mi sarei aspettato che il compilatore fosse abbastanza intelligente per fare il confronto e lasciarlo cadere direttamente su JMP . Ciò che è all’etichetta .LBB0_1 potrebbe quindi allineare lo stack a un limite di 16 byte in modo che call std::__throw_bad_function_call() correttamente.


Come sottolineato da @CodyGray, se si utilizza GCC (non CLANG ) con un livello di ottimizzazione di -O2 o superiore, il codice prodotto sembra più ragionevole. L’ output GCC 6.1 di Godbolt è:

 f(std::function): cmp QWORD PTR [rdi+16], 0 # MEM[(bool (*) (union _Any_data &, const union _Any_data &, _Manager_operation) *)a_2(D) + 16B], je .L7 #, jmp [QWORD PTR [rdi+24]] # MEM[(const struct function *)a_2(D)]._M_invoker .L7: sub rsp, 8 #, call std::__throw_bad_function_call() # 

Questo codice è più in linea con quello che mi sarei aspettato. In questo caso sembrerebbe che l’ottimizzatore di GCC possa gestire questa generazione di codice meglio di CLANG .

In altri casi, clang in genere risolve lo stack prima di tornare con un pop rcx .

L’utilizzo di push ha un vantaggio per l’efficienza in termini di dimensioni del codice ( push è di solo 1 byte rispetto a 4 byte per sub rsp, 8 ) e anche in uops su CPU Intel. (Non c’è bisogno di uno stack-sync-uop, che si otterrebbe se si accede a rsp direttamente perché la call che ci ha portato all’inizio della funzione corrente rende il motore dello stack “sporco”).

Questa lunga e sconclusionante risposta analizza i peggiori rischi di performance dell’utilizzo di push rax / pop rcx per allineare lo stack e se rax e rcx sono buone scelte di registro. (Ci scusiamo per averlo fatto così a lungo.)

(TL: DR: sembra buono, l’eventuale svantaggio è di solito piccolo e il rialzo nel caso comune fa sì che ne valga la pena.I stalli di registro parziale potrebbero essere un problema su Core2 / Nehalem se al o ax sono “sporchi”, comunque. altre CPU con 64 bit hanno grossi problemi (perché non rinominano regs parziali, o si uniscono efficientemente), e il codice a 32 bit ha bisogno di più di 1 push aggiuntivo per allineare lo stack di 16 per un’altra call meno che non stia già salvando / ripristinare alcuni reg di conservazione delle chiamate per il proprio uso).


Usando push rax invece di sub rsp, 8 introduce una dipendenza dal vecchio valore di rax , quindi si potrebbe pensare che potrebbe rallentare se il valore di rax è il risultato di una catena di dipendenze a lunga latenza (e / o una cache Perdere).

ad esempio il chiamante potrebbe aver fatto qualcosa di lento con rax che non è correlato alla funzione args, come var = table[ x % y ]; var2 = foo(x); var = table[ x % y ]; var2 = foo(x);

 # example caller that leaves RAX not-ready for a long time mov rdi, rax ; prepare function arg div rbx ; very high latency mov rax, [table + rdx] ; rax = table[ value % something ], may miss in cache mov [rsp + 24], rax ; spill the result. call foo ; foo uses push rax to align the stack 

Fortunatamente l’esecuzione fuori sequenza farà un buon lavoro qui.

La push non rende il valore di rsp dipendente da rax . (È gestito dal motore dello stack, o su CPU molto datate push decodifiche a più uops, uno dei quali aggiorna rsp indipendentemente dagli uops che memorizzano il rax . Micro-fusione degli indirizzi del negozio e dei dati del negozio lasciano che sia push un singolo dominio con fusibile, anche se gli archivi utilizzano sempre due indirizzi di dominio non utilizzati.)

Finché nulla dipende dall’output push rax / pop rcx , non è un problema per l’esecuzione fuori ordine. Se push rax deve attendere perché rax non è pronto, non farà in modo che il ROB (ReOrder Buffer) si riempia e alla fine blocchi l’esecuzione di istruzioni indipendenti successive. Il ROB si riempirebbe anche senza la push perché le istruzioni che sono lente a produrre rax , e qualunque istruzione nel chiamante consuma rax prima che la chiamata sia ancora più vecchia, e non può andare in pensione fino a quando rax è pronto. Il pensionamento deve avvenire in ordine in caso di eccezioni / interruzioni.

(Non penso che un carico di cache-miss possa andare in pensione prima che il caricamento si concluda, lasciando solo una voce load-buffer. Ma anche se potesse, non avrebbe senso produrre un risultato in un registro call-clobbered senza leggere con un’altra istruzione prima di effettuare una call . Le istruzioni del chiamante che consumano rax sicuramente non possono essere eseguite / ritirate finché la nostra push non può fare lo stesso. )

Quando rax diventa pronto, push può essere eseguito e ritirato in un paio di cicli, consentendo anche alle istruzioni successive (che erano già state eseguite fuori servizio) di andare in pensione. L’indirizzo del negozio uop sarà già stato eseguito e suppongo che i dati del negozio possano essere completati in un ciclo o due dopo essere stati inviati alla porta del negozio. Gli archivi possono ritirarsi non appena i dati vengono scritti nel buffer del negozio. Il commit su L1D avviene dopo il pensionamento, quando il negozio è noto per essere non speculativo.

Quindi, anche nel peggiore dei casi, dove l’istruzione che produce rax era così lenta che ha portato il ROB a riempirsi di istruzioni indipendenti che sono per lo più già eseguite e pronte a ritirarsi, dover eseguire push rax causa solo un paio di cicli aggiuntivi di ritardo prima di istruzioni indipendenti dopo che può andare in pensione. (E alcune delle istruzioni del chiamante andranno in pensione prima, facendo un po ‘di spazio nel ROB anche prima che il nostro push ritiri.)


Un push rax che deve attendere legherà alcune altre risorse microarchitetturali , lasciando una voce in meno per trovare il parallelismo tra le altre istruzioni successive. (Un’aggiunta di add rsp,8 che potrebbe essere eseguita add rsp,8 solo una voce ROB, e non molto altro.)

Utilizzerà una voce nell’Utilità di pianificazione out-of-order (ovvero Reservation Station / RS). L’indirizzo del negozio uop può essere eseguito non appena c’è un ciclo libero, quindi saranno lasciati solo i dati del negozio. L’indirizzo di caricamento di pop rcx uop è pronto, quindi deve essere inviato a una porta di caricamento ed eseguito. (Quando viene eseguito il caricamento pop , trova che il suo indirizzo corrisponde all’archivio push incompleto nel buffer dello store (ovvero il buffer dell’ordine di memoria), quindi imposta l’inoltro dello store che avverrà dopo l’esecuzione dei dati negozio-uop. una voce del buffer di caricamento.)

Anche una vecchia CPU come Nehalem ha 36 RS in entrata, contro 54 in Sandybridge , o 97 in Skylake. Mantenere una voce occupata più a lungo del solito in rari casi non è nulla di cui preoccuparsi. L’alternativa di eseguire due UOP (stack-sync + sub ) è peggiore.

( fuori tema )
Il ROB è più grande della RS, 128 (Nehalem), 168 (Sandybridge), 224 (Skylake). (Contiene i domini del dominio fusi dall’emissione al pensionamento, contro la RS che detiene i domini del dominio non utilizzati dal problema all’esecuzione). A 4 uop per clock il throughput massimo del frontend, sono oltre 50 i cicli di delay-hiding su Skylake. (Gli uarchi più vecchi hanno meno probabilità di sostenere 4 uop per orologio per tutto il tempo …)

La dimensione ROB determina la finestra fuori ordine per hide un’operazione lenta e indipendente. ( A meno che i limiti delle dimensioni del file di registro non siano un limite inferiore ). La dimensione RS determina la finestra fuori ordine per trovare il parallelismo tra due catene di dipendenze separate. (ad esempio, considera un corpo di loop da 200 uop ​​in cui ogni iterazione è indipendente, ma all’interno di ogni iterazione è una lunga catena di dipendenze senza molto parallelismo a livello di istruzione (ad esempio a[i] = complex_function(b[i]) ). di 1 iterazione, ma non siamo in grado di ottenere gli effetti dalla prossima iterazione nella RS finché non siamo a meno di 97 punti dalla fine di quella corrente.Se la catena di dep non fosse molto più grande della dimensione RS, si passa da 2 le iterazioni potrebbero essere in volo per la maggior parte del tempo).


Ci sono casi in cui push rax / pop rcx può essere più pericoloso :

Il chiamante di questa funzione sa che rcx è call-clobbered, quindi non leggerà il valore. Ma potrebbe essere una falsa dipendenza da rcx dopo il nostro ritorno, come bsf rcx, rax / jnz o test eax,eax / setz cl . Le recenti CPU Intel non rinominano più i registri parziali low8, quindi setcc cl ha un falso dep su rcx . bsf realtà lascia la sua destinazione non modificata se la sorgente è 0, anche se Intel la documenta come un valore non definito. I documenti AMD lasciano un comportamento non modificato.

La falsa dipendenza potrebbe creare una catena di deploy loop-loaded. D’altra parte, una falsa dipendenza può farlo comunque, se la nostra funzione ha scritto rcx con istruzioni dipendenti dai suoi input.

Sarebbe peggio usare push rbx / pop rbx per salvare / ripristinare un registro preservato dalla chiamata che non avremmo usato. Probabilmente il chiamante l’ avrebbe letto dopo il nostro ritorno e avremmo introdotto una latenza di inoltro dello store nella catena di dipendenze del chiamante per quel registro. (Inoltre, è probabilmente più probabile che rbx venga scritto subito prima della call , dal momento che tutto ciò che il chiamante desiderava mantenere attraverso la chiamata verrebbe spostato in registri di preservazione della chiamata come rbx e rbp .)


Nelle CPU con stacks a registro parziale (Intel pre-Sandybridge) , la lettura di rax con push potrebbe causare uno stallo o 2-3 cicli su Core2 / Nehalem se il chiamante aveva fatto qualcosa come setcc al prima della call . Sandybridge non si blocca durante l’inserimento di un uop di fusione e Haswell e in seguito non rinominano i registri low8 separatamente da rax .

Sarebbe bello push un registro che era meno probabile che avesse il suo low8 usato. Se i compilatori cercassero di evitare i prefissi REX per ragioni di dimensione del codice, eviterebbero il dil e il sil , quindi rdi e rsi avrebbero meno probabilità di avere problemi di registro parziale. Ma sfortunatamente gcc e clang non sembrano favorire l’uso di dl o cl come registri di scratch da 8 bit, usando dil e sil anche in minuscole funzioni dove nient’altro sta usando rdx o rcx . (Anche se la mancanza della ridenominazione low8 in alcune CPU significa che setcc cl ha una falsa dipendenza dal vecchio rcx , quindi setcc dil è più sicuro se il flag-setting dipende dalla funzione arg in rdi .)

pop rcx alla fine “pulisce” rcx di qualsiasi roba di registro parziale. Dato che cl è usato per i conteggi di shift, e le funzioni a volte scrivono solo cl anche quando potrebbero invece aver scritto ecx . (IIRC ho visto clang fare questo. Gcc più fortemente favorisce le dimensioni degli operandi a 32-bit e 64-bit per evitare problemi di registro parziale.)


push rdi sarebbe probabilmente una buona scelta in molti casi, dal momento che il resto della funzione legge anche rdi , quindi l’introduzione di un’altra istruzione dipendente da questo non danneggerebbe. rax , l’esecuzione fuori rax che il rax venga rax fuori strada se rax è pronto prima di rdi , comunque.


Un altro svantaggio potenziale è l’utilizzo di cicli sulle porte di carico / archivio. Ma è improbabile che siano saturati e l’alternativa è uops per le porte ALU. Con l’extra stack sync su CPU Intel che sub rsp, 8 da sub rsp, 8 , sarebbero due UFO ALU nella parte superiore della funzione.