Perché GCC non utilizza registri parziali?

Disassemblando write(1,"hi",3) su linux, gcc -s -nostdlib -nostartfiles -O3 con gcc -s -nostdlib -nostartfiles -O3 traduce in:

 ba03000000 mov edx, 3 ; thanks for the correction jester! bf01000000 mov edi, 1 31c0 xor eax, eax e9d8ffffff jmp loc.imp.write 

Non sono nello sviluppo del compilatore ma poiché ogni valore trasferito in questi registri è costante e noto in fase di compilazione, sono curioso di sapere perché gcc non usa dl , dil e al invece. Alcuni potrebbero obiettare che questa funzione non farà alcuna differenza nelle prestazioni, ma c’è una grande differenza nella dimensione dell’eseguibile tra mov $1, %rax => b801000000 e mov $1, %al => b001 quando parliamo di migliaia di accessi ai registri in un programma. Non solo di piccole dimensioni se parte dell’eleganza di un software, ha un effetto sulle prestazioni.

Qualcuno può spiegare perché “GCC ha deciso” che non importa?

I registri parziali comportano una penalizzazione delle prestazioni su molti processori x86 poiché sono stati rinominati in registri fisici diversi dalla loro controparte quando scritti. (Per ulteriori informazioni sulla rinomina del registro che consente l’esecuzione fuori ordine, vedere questo Q & A ).

Ma quando un’istruzione legge l’intero registro, la CPU deve rilevare il fatto che non ha il valore di registro architettonico corretto disponibile in un singolo registro fisico. (Questo accade nella fase di emissione / ridenominazione, in quanto la CPU si prepara a inviare l’uop nello scheduler out-of-order.)

Si chiama stallo di registro parziale . Il manuale di microarchitettura di Agner Fog lo spiega abbastanza bene:

6.8 Stand di registrazione parziale (PPro / PII / PIII e primi Pentium-M)

Lo stallo del registro parziale è un problema che si verifica quando scriviamo su una parte di un registro a 32 bit e successivamente leggiamo dall’intero registro o da una parte più grande di esso.
Esempio:

 ; Example 6.10a. Partial register stall mov al, byte ptr [mem8] mov ebx, eax ; Partial register stall 

Questo dà un ritardo di 5 – 6 orologi . Il motivo è che un registro temporaneo è stato assegnato a AL per renderlo indipendente da AH . L’unità di esecuzione deve attendere che la scrittura su AL andata in pensione prima che sia ansible combinare il valore di AL con il valore del resto di EAX .

Comportamento in diverse CPU :

  • Intel precoce famiglia P6: vedi sopra: stallo per 5-6 orologi fino a quando le scritture parziali si ritirano.
  • Intel Pentium-M (modello D) / Core2 / Nehalem: si blocca per 2-3 cicli durante l’inserimento di un uop di unione. (vedi questo Q & A per un microbenchmark che scrive AX e legge EAX con o senza xor-zeroing prima )
  • Intel Sandybridge: inserire un valore di unione per low8 / low16 (AL / AX) senza stallo o per AH / BH / CH / DH durante lo stallo per 1 ciclo.
  • Intel IvyBridge (forse), ma decisamente Haswell / Skylake: AL / AX non vengono rinominati, ma AH è ancora: come funzionano esattamente i registri parziali su Haswell / Skylake? La scrittura di AL sembra avere una falsa dipendenza da RAX e AH è incoerente .
  • Tutte le altre CPU x86 : Intel Pentium4, Atom / Silvermont / Landing di Knight. Tutto AMD (e Via, ecc.):

    I registri parziali non vengono mai rinominati. La scrittura di un registro parziale si fonde con il registro completo, facendo in modo che la scrittura dipenda dal vecchio valore del registro completo come input.

Senza la ridenominazione del registro parziale, la dipendenza di input per la scrittura è una dipendenza falsa se non si legge mai il registro completo. Ciò limita il parallelismo a livello di istruzioni perché il riutilizzo di un registro a 8 o 16 bit per qualcos’altro non è in realtà indipendente dal punto di vista della CPU (il codice a 16 bit può accedere ai registri a 32 bit, quindi deve mantenere i valori corretti nella parte superiore metà). E inoltre, rende AL e AH non indipendenti. Quando Intel ha progettato la famiglia P6 (PPro rilasciato nel 1993), il codice a 16 bit era ancora comune, quindi la ridenominazione parziale del registro era una funzione importante per far funzionare più velocemente il codice macchina esistente. (In pratica, molti binari non vengono ricompilati per le nuove CPU.)

Ecco perché i compilatori per lo più evitano di scrivere registri parziali. Usano movzx / movsx quando ansible per movsx o firmare estendere i valori stretti a un registro completo per evitare false dipendenze di registri parziali (AMD) o bancarelle (famiglia Intel P6). Quindi la maggior parte dei codici macchina moderni non beneficiano molto della ridenominazione a registri parziali, motivo per cui le recenti CPU Intel stanno semplificando la loro logica di ridenominazione del registro parziale.

Come sottolinea la risposta di @BeeOnRope , i compilatori leggono ancora i registri parziali, perché non è un problema. (Leggere AH / BH / CH / DH può aggiungere un ulteriore ciclo di latenza su Haswell / Skylake, tuttavia, si veda il precedente collegamento sui registri parziali sui membri recenti della famiglia Sandybridge.)


Si noti inoltre che la write accetta argomenti che, per un GCC x86-64 tipicamente configurato, richiedono interi registri a 32 bit e 64 bit, quindi non possono essere semplicemente assemblati in mov dl, 3 . La dimensione è determinata dal tipo di dati, non dal valore dei dati.

Infine, in certi contesti, C ha delle promozioni di argomento predefinite di cui essere a conoscenza, sebbene non sia questo il caso .
In realtà, come ha sottolineato RossRidge , la chiamata è stata probabilmente effettuata senza un prototipo visibile.


Il tuo sassembly è fuorviante, come ha sottolineato @Jester.
Ad esempio mov rdx, 3 è in realtà mov edx, 3 , anche se entrambi hanno lo stesso effetto, cioè mettere 3 nell’intero rdx .
Questo è vero perché un valore immediato di 3 non richiede l’estensione del segno e un MOV r32, imm32 cancella implicitamente i 32 bit superiori del registro.

Infatti, gcc usa molto spesso registri parziali . Se si guarda codice generato, troverete molti casi in cui vengono utilizzati registri parziali.

La risposta breve per il caso specifico , è perché gcc firma sempre o zero estende gli argomenti a 32 bit quando chiama una funzione C ABI .

Di fatto, SysV x86 e x86-64 ABI adottati da gcc e clang richiedono che i parametri inferiori a 32 bit siano zero o sign-extended a 32-bit. È interessante notare che non è necessario estenderli fino a 64 bit.

Quindi, per una funzione come la seguente su una piattaforma SysV ABI a 64 bit:

 void foo(short s) { ... } 

… l’argomento s è passato in rdi e i bit di s saranno i seguenti (ma vedi la mia avvertenza qui sotto riguardo a icc ):

  bits 0-31: SSSSSSSS SSSSSSSS SPPPPPPP PPPPPPPP bits 32-63: XXXXXXXX XXXXXXXX XXXXXXXX XXXXXXXX where: P: the bottom 15 bits of the value of `s` S: the sign bit of `s` (extended into bits 16-31) X: arbitrary garbage 

Il codice per foo può dipendere dai bit S e P , ma non dai bit X , che possono essere qualsiasi cosa.

Allo stesso modo, per foo_unsigned(unsigned short u) , avresti 0 nei bit 16-31, ma sarebbe altrimenti identico.

Nota che ho detto defacto – perché in realtà non è veramente documentato cosa fare per i tipi di ritorno più piccoli, ma puoi vedere la risposta di Peter qui per i dettagli. Ho anche fatto una domanda correlata qui .

Dopo ulteriori test, ho concluso che icc realtà rompe questo standard de facto. gcc e clang sembrano aderire ad esso, ma gcc solo in modo conservativo: quando si chiama una funzione, fa zero / sign-estendere gli argomenti a 32-bit, ma nella sua funzione le implementazioni in non dipendono dal chiamante che lo fa . clang implementa funzioni che dipendono dal chiamante estendendo i parametri a 32-bit. Quindi infatti clang e icc sono reciprocamente incompatibili anche per le normali funzioni C se hanno parametri più piccoli di int .

Si noti che l’uso di -O3 richiede esplicitamente al compilatore di favorire in modo aggressivo le prestazioni rispetto alle dimensioni del codice. Usa la dimensione di -Os se non sei pronto a sacrificare circa il 20% delle dimensioni.

Su qualcosa come il PC IBM originale, se si sapeva che AH conteneva 0 ed era necessario caricare AX con un valore come 0x34, usando “MOV AL, 34h” in genere sarebbero necessari 8 cicli anziché il 12 richiesto per “MOV AX, 0034h “- un miglioramento della velocità abbastanza grande (entrambe le istruzioni possono essere eseguite in 2 cicli se pre-recuperate, ma in pratica l’8088 passa la maggior parte del tempo in attesa di essere recuperate le istruzioni al costo di quattro cicli per byte). Tuttavia, sui processori utilizzati nei computer general-purpose di oggi, il tempo richiesto per il recupero del codice non è in genere un fattore significativo della velocità complessiva di esecuzione e le dimensioni del codice normalmente non rappresentano un problema particolare.

Inoltre, i produttori di processori cercano di massimizzare le prestazioni dei tipi di codice che è probabile che le persone eseguano e le istruzioni di caricamento a 8 bit non saranno probabilmente utilizzate quasi altrettanto spesso come le istruzioni di caricamento a 32 bit. I core dei processori spesso includono la logica per eseguire simultaneamente più istruzioni a 32 o 64 bit, ma potrebbe non includere la logica per eseguire un’operazione a 8 bit contemporaneamente a qualsiasi altra cosa. Di conseguenza, mentre utilizzava le operazioni a 8 bit sull’8088 quando ansible era un’ottimizzazione utile su 8088, può effettivamente essere un notevole drenaggio delle prestazioni sui nuovi processori.