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 right
seguito da un and
e poi store
a nel target
buffer. 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 load
dalla memoria prima di ogni shift ( mov rdx,QWORD PTR [rdi]
). Sembra che il target
puntatore (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 target
in 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-19ubuntu1
con -O3
ottimizzazione. Ho anche provato clang++ 3.4-1ubuntu3
con risultati simili: Clang è persino in grado di vettorializzare il metodo con il target
puntatore locale . Tuttavia, l'utilizzo del this->target
puntatore 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 this
debba 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.
this->
è solo zucchero sintattico. Il problema è legato alla natura delle variabili (locale vs membro) e alle cose che il compilatore deduce da questo fatto.