L'uso di questo puntatore provoca una strana deottimizzazione nell'hot loop


122

Recentemente mi sono imbattuto in una strana deottimizzazione (o meglio un'opportunità di ottimizzazione persa).

Considera questa funzione per un efficiente decompressione di array da interi a 3 bit a interi a 8 bit. Decomprime 16 int in ogni iterazione del ciclo:

void unpack3bit(uint8_t* target, char* source, int size) {
   while(size > 0){
      uint64_t t = *reinterpret_cast<uint64_t*>(source);
      target[0] = t & 0x7;
      target[1] = (t >> 3) & 0x7;
      target[2] = (t >> 6) & 0x7;
      target[3] = (t >> 9) & 0x7;
      target[4] = (t >> 12) & 0x7;
      target[5] = (t >> 15) & 0x7;
      target[6] = (t >> 18) & 0x7;
      target[7] = (t >> 21) & 0x7;
      target[8] = (t >> 24) & 0x7;
      target[9] = (t >> 27) & 0x7;
      target[10] = (t >> 30) & 0x7;
      target[11] = (t >> 33) & 0x7;
      target[12] = (t >> 36) & 0x7;
      target[13] = (t >> 39) & 0x7;
      target[14] = (t >> 42) & 0x7;
      target[15] = (t >> 45) & 0x7;
      source+=6;
      size-=6;
      target+=16;
   }
}

Ecco l'assembly generato per parti del codice:

 ...
 367:   48 89 c1                mov    rcx,rax
 36a:   48 c1 e9 09             shr    rcx,0x9
 36e:   83 e1 07                and    ecx,0x7
 371:   48 89 4f 18             mov    QWORD PTR [rdi+0x18],rcx
 375:   48 89 c1                mov    rcx,rax
 378:   48 c1 e9 0c             shr    rcx,0xc
 37c:   83 e1 07                and    ecx,0x7
 37f:   48 89 4f 20             mov    QWORD PTR [rdi+0x20],rcx
 383:   48 89 c1                mov    rcx,rax
 386:   48 c1 e9 0f             shr    rcx,0xf
 38a:   83 e1 07                and    ecx,0x7
 38d:   48 89 4f 28             mov    QWORD PTR [rdi+0x28],rcx
 391:   48 89 c1                mov    rcx,rax
 394:   48 c1 e9 12             shr    rcx,0x12
 398:   83 e1 07                and    ecx,0x7
 39b:   48 89 4f 30             mov    QWORD PTR [rdi+0x30],rcx
 ...

Sembra abbastanza efficiente. Semplicemente un shift rightseguito da un ande poi storea nel targetbuffer. Ma ora, guarda cosa succede quando cambio la funzione in un metodo in una struttura:

struct T{
   uint8_t* target;
   char* source;
   void unpack3bit( int size);
};

void T::unpack3bit(int size) {
        while(size > 0){
           uint64_t t = *reinterpret_cast<uint64_t*>(source);
           target[0] = t & 0x7;
           target[1] = (t >> 3) & 0x7;
           target[2] = (t >> 6) & 0x7;
           target[3] = (t >> 9) & 0x7;
           target[4] = (t >> 12) & 0x7;
           target[5] = (t >> 15) & 0x7;
           target[6] = (t >> 18) & 0x7;
           target[7] = (t >> 21) & 0x7;
           target[8] = (t >> 24) & 0x7;
           target[9] = (t >> 27) & 0x7;
           target[10] = (t >> 30) & 0x7;
           target[11] = (t >> 33) & 0x7;
           target[12] = (t >> 36) & 0x7;
           target[13] = (t >> 39) & 0x7;
           target[14] = (t >> 42) & 0x7;
           target[15] = (t >> 45) & 0x7;
           source+=6;
           size-=6;
           target+=16;
        }
}

Ho pensato che l'assembly generato dovrebbe essere lo stesso, ma non lo è. Eccone una parte:

...
 2b3:   48 c1 e9 15             shr    rcx,0x15
 2b7:   83 e1 07                and    ecx,0x7
 2ba:   88 4a 07                mov    BYTE PTR [rdx+0x7],cl
 2bd:   48 89 c1                mov    rcx,rax
 2c0:   48 8b 17                mov    rdx,QWORD PTR [rdi] // Load, BAD!
 2c3:   48 c1 e9 18             shr    rcx,0x18
 2c7:   83 e1 07                and    ecx,0x7
 2ca:   88 4a 08                mov    BYTE PTR [rdx+0x8],cl
 2cd:   48 89 c1                mov    rcx,rax
 2d0:   48 8b 17                mov    rdx,QWORD PTR [rdi] // Load, BAD!
 2d3:   48 c1 e9 1b             shr    rcx,0x1b
 2d7:   83 e1 07                and    ecx,0x7
 2da:   88 4a 09                mov    BYTE PTR [rdx+0x9],cl
 2dd:   48 89 c1                mov    rcx,rax
 2e0:   48 8b 17                mov    rdx,QWORD PTR [rdi] // Load, BAD!
 2e3:   48 c1 e9 1e             shr    rcx,0x1e
 2e7:   83 e1 07                and    ecx,0x7
 2ea:   88 4a 0a                mov    BYTE PTR [rdx+0xa],cl
 2ed:   48 89 c1                mov    rcx,rax
 2f0:   48 8b 17                mov    rdx,QWORD PTR [rdi] // Load, BAD!
 ...

Come puoi vedere, abbiamo introdotto un ulteriore ridondante loaddalla memoria prima di ogni shift ( mov rdx,QWORD PTR [rdi]). Sembra che il targetpuntatore (che ora è un membro invece di una variabile locale) debba essere sempre ricaricato prima di essere memorizzato in esso. Questo rallenta notevolmente il codice (circa il 15% nelle mie misurazioni).

Per prima cosa ho pensato che forse il modello di memoria C ++ impone che un puntatore a un membro potrebbe non essere memorizzato in un registro ma deve essere ricaricato, ma questa mi è sembrata una scelta imbarazzante, poiché renderebbe impossibili molte ottimizzazioni praticabili. Quindi sono rimasto molto sorpreso dal fatto che il compilatore non sia stato memorizzato targetin un registro qui.

Ho provato a memorizzare nella cache il puntatore del membro in una variabile locale:

void T::unpack3bit(int size) {
    while(size > 0){
       uint64_t t = *reinterpret_cast<uint64_t*>(source);
       uint8_t* target = this->target; // << ptr cached in local variable
       target[0] = t & 0x7;
       target[1] = (t >> 3) & 0x7;
       target[2] = (t >> 6) & 0x7;
       target[3] = (t >> 9) & 0x7;
       target[4] = (t >> 12) & 0x7;
       target[5] = (t >> 15) & 0x7;
       target[6] = (t >> 18) & 0x7;
       target[7] = (t >> 21) & 0x7;
       target[8] = (t >> 24) & 0x7;
       target[9] = (t >> 27) & 0x7;
       target[10] = (t >> 30) & 0x7;
       target[11] = (t >> 33) & 0x7;
       target[12] = (t >> 36) & 0x7;
       target[13] = (t >> 39) & 0x7;
       target[14] = (t >> 42) & 0x7;
       target[15] = (t >> 45) & 0x7;
       source+=6;
       size-=6;
       this->target+=16;
    }
}

Questo codice fornisce anche l'assemblatore "buono" senza archivi aggiuntivi. Quindi la mia ipotesi è: al compilatore non è consentito sollevare il carico di un puntatore membro di una struttura, quindi un tale "puntatore caldo" dovrebbe essere sempre memorizzato in una variabile locale.

  • Allora, perché il compilatore non è in grado di ottimizzare questi carichi?
  • È il modello di memoria C ++ che lo vieta? O è semplicemente un difetto del mio compilatore?
  • La mia ipotesi è corretta o qual è il motivo esatto per cui l'ottimizzazione non può essere eseguita?

Il compilatore in uso era g++ 4.8.2-19ubuntu1con -O3ottimizzazione. Ho anche provato clang++ 3.4-1ubuntu3con risultati simili: Clang è persino in grado di vettorializzare il metodo con il targetpuntatore locale . Tuttavia, l'utilizzo del this->targetpuntatore produce lo stesso risultato: un carico aggiuntivo del puntatore prima di ogni archivio.

Ho controllato l'assemblatore di alcuni metodi simili e il risultato è lo stesso: sembra che un membro di thisdebba essere sempre ricaricato prima di un negozio, anche se un tale carico potrebbe semplicemente essere sollevato fuori dal loop. Dovrò riscrivere molto codice per sbarazzarmi di questi archivi aggiuntivi, principalmente memorizzando personalmente il puntatore nella cache in una variabile locale dichiarata sopra l'hot code. Ma ho sempre pensato che giocherellare con dettagli come la memorizzazione nella cache di un puntatore in una variabile locale si sarebbe sicuramente qualificato per un'ottimizzazione prematura in questi giorni in cui i compilatori sono diventati così intelligenti. Ma sembra che mi sbaglio qui . La memorizzazione nella cache di un puntatore a un membro in un hot loop sembra essere una tecnica di ottimizzazione manuale necessaria.


5
Non sono sicuro del motivo per cui questo ha ottenuto un voto negativo - è una domanda interessante. FWIW Ho visto problemi di ottimizzazione simili con variabili membro non puntatore in cui la soluzione è stata simile, ovvero memorizzare nella cache la variabile membro in una variabile locale per la durata del metodo. Immagino che abbia qualcosa a che fare con le regole di aliasing?
Paul R

1
Sembra che il compilatore non ottimizzi perché non può garantire che il membro non sia accessibile tramite un codice "esterno". Quindi, se il membro può essere modificato all'esterno, dovrebbe essere ricaricato ogni volta che si accede. Sembra essere considerato come una specie di volatile ...
Jean-Baptiste Yunès

No, non usare this->è solo zucchero sintattico. Il problema è legato alla natura delle variabili (locale vs membro) e alle cose che il compilatore deduce da questo fatto.
Jean-Baptiste Yunès

Ha qualcosa a che fare con gli alias dei puntatori?
Yves Daoust

3
In termini più semantici, l '"ottimizzazione prematura" si applica solo all'ottimizzazione prematura, ovvero prima che la profilazione abbia riscontrato che si tratta di un problema. In questo caso, hai diligentemente profilato e decompilato e trovato la fonte di un problema e hai formulato e profilato una soluzione. Non è assolutamente "prematuro" applicare quella soluzione.
raptortech97

Risposte:


107

L'aliasing del puntatore sembra essere il problema, ironicamente tra thise this->target. Il compilatore tiene conto della possibilità piuttosto oscena che hai inizializzato:

this->target = &this

In tal caso, scrivere in this->target[0]altererebbe il contenuto di this(e quindi, this->target).

Il problema di aliasing della memoria non è limitato a quanto sopra. In linea di principio, qualsiasi uso di this->target[XX]dato un valore (in) appropriato di XXpotrebbe indicare this.

Sono più esperto in C, dove questo può essere risolto dichiarando variabili puntatore con la __restrict__parola chiave.


18
Lo posso confermare! Il passaggio targetda uint8_ta uint16_t(in modo che le rigide regole di aliasing entrino in vigore) lo ha cambiato. Con uint16_t, il carico è sempre ottimizzato.
gexicide


3
Cambiare il contenuto di thisnon è ciò che intendi (non è una variabile); intendi cambiare il contenuto di *this.
Marc van Leeuwen

@gexicide mind elabora il modo in cui il rigoroso alias interviene e risolve il problema?
HCSF

33

Rigide regole di aliasing consentono char*di creare alias per qualsiasi altro puntatore. Quindi this->targetpuò alias con thise nel tuo metodo di codice, la prima parte del codice,

target[0] = t & 0x7;
target[1] = (t >> 3) & 0x7;
target[2] = (t >> 6) & 0x7;

è in effetti

this->target[0] = t & 0x7;
this->target[1] = (t >> 3) & 0x7;
this->target[2] = (t >> 6) & 0x7;

che thispossono essere modificati quando si modifica il this->targetcontenuto.

Una volta this->targetmemorizzato nella cache in una variabile locale, l'alias non è più possibile con la variabile locale.


1
Quindi, possiamo dire come regola generale: ogni volta che hai un char*o void*nella tua struttura, assicurati di metterlo in cache in una variabile locale prima di scriverci?
gexicide

5
In effetti è quando usi un char*, non necessario come membro.
Jarod42

24

Il problema qui è uno stretto aliasing che dice che ci è permesso creare alias tramite un carattere * e quindi nel tuo caso impedisce l'ottimizzazione del compilatore. Non siamo autorizzati ad alias tramite un puntatore di un tipo diverso che sarebbe un comportamento indefinito, normalmente su SO vediamo questo problema che è che gli utenti tentano di alias attraverso tipi di puntatore incompatibili .

Sembrerebbe ragionevole implementare uint8_t come un carattere senza segno e se guardiamo a cstdint su Coliru include stdint.h che typedefs uint8_t come segue:

typedef unsigned char       uint8_t;

se hai usato un altro tipo non char, il compilatore dovrebbe essere in grado di ottimizzare.

Questo è trattato nella bozza della sezione standard C ++ 3.10 Lvalues ​​e rvalues che dice:

Se un programma tenta di accedere al valore memorizzato di un oggetto tramite un valore collante diverso da uno dei seguenti tipi il comportamentoèindefinito

e include il seguente punto:

  • un tipo di carattere char o unsigned char.

Nota, ho pubblicato un commento su possibili soluzioni in una domanda che chiede When is uint8_t ≠ unsigned char? e la raccomandazione era:

La soluzione più banale, tuttavia, è usare la parola chiave limits, o copiare il puntatore a una variabile locale il cui indirizzo non viene mai preso in modo che il compilatore non debba preoccuparsi se gli oggetti uint8_t possono creare un alias.

Dal momento che C ++ non supporta il limitare parola chiave che può contare solo su di estensione del compilatore, ad esempio usi GCC __restrict__ quindi questo non è completamente portatile, ma l'altro suggerimento dovrebbe essere.


Questo è un esempio di un luogo in cui lo standard è peggiore per gli ottimizzatori di quanto sarebbe una regola consentirebbe a un compilatore di presumere che tra due accessi a un oggetto di tipo T, o tale accesso e l'inizio o la fine di un ciclo / funzione in cui si verifica, tutti gli accessi alla memoria utilizzeranno lo stesso oggetto a meno che un'operazione intermedia non utilizzi quell'oggetto (o un puntatore / riferimento ad esso) per derivare un puntatore o un riferimento a qualche altro oggetto . Una regola del genere eliminerebbe la necessità dell '"eccezione del tipo di carattere" che può interrompere le prestazioni del codice che funziona con sequenze di byte.
supercat
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.