Recentemente, deparei-me com uma estranha desoptimização (ou melhor, perdi uma oportunidade de otimização).
Considere esta função para descompactar com eficiência matrizes de números inteiros de 3 bits a números inteiros de 8 bits. Descompacta 16 ints em cada iteração de loop:
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;
}
}
Aqui está o assembly gerado para partes do código:
...
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
...
Parece bastante eficaz. Simplesmente um shift right
seguido de um and
e depois um store
para o target
buffer. Mas agora, veja o que acontece quando altero a função para um método em uma struct:
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;
}
}
Eu pensei que o assembly gerado deveria ser o mesmo, mas não é. Aqui está uma 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!
...
Como você vê, introduzimos uma redundância adicional load
da memória antes de cada turno ( mov rdx,QWORD PTR [rdi]
). Parece que o target
ponteiro (que agora é um membro em vez de uma variável local) deve ser sempre recarregado antes de armazená-lo. Isso diminui consideravelmente o código (cerca de 15% nas minhas medidas).
Primeiro, pensei que talvez o modelo de memória C ++ imponha que um ponteiro de membro não possa ser armazenado em um registro, mas precise ser recarregado, mas isso parecia uma escolha incômoda, pois impossibilitaria muitas otimizações viáveis. Então, fiquei muito surpreso que o compilador não tenha armazenado target
em um registro aqui.
Tentei armazenar em cache o ponteiro de membro em uma variável local:
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;
}
}
Esse código também gera o montador "bom" sem lojas adicionais. Então, meu palpite é: O compilador não pode elevar a carga de um ponteiro membro de uma estrutura, portanto, esse "ponteiro quente" deve sempre ser armazenado em uma variável local.
- Então, por que o compilador não consegue otimizar essas cargas?
- É o modelo de memória C ++ que proíbe isso? Ou é simplesmente uma falha do meu compilador?
- Meu palpite está correto ou qual é o motivo exato pelo qual a otimização não pode ser executada?
O compilador em uso foi g++ 4.8.2-19ubuntu1
com -O3
otimização. Eu também tentei clang++ 3.4-1ubuntu3
com resultados semelhantes: Clang é capaz de vetorizar o método com o target
ponteiro local . No entanto, o uso do this->target
ponteiro produz o mesmo resultado: Uma carga extra do ponteiro antes de cada loja.
Eu verifiquei o assembler de alguns métodos semelhantes e o resultado é o mesmo: parece que um membro this
sempre precisa ser recarregado antes de uma loja, mesmo que essa carga possa ser içada simplesmente fora do loop. Terei que reescrever muito código para livrar-se desses armazenamentos adicionais, principalmente armazenando o ponteiro em cache em uma variável local declarada acima do código quente. Mas eu sempre pensei que mexer com detalhes como o cache de um ponteiro em uma variável local certamente se qualificaria para a otimização prematura nos dias de hoje em que os compiladores se tornaram tão inteligentes. Mas parece que estou errado aqui . O armazenamento em cache de um ponteiro de membro em um loop ativo parece ser uma técnica de otimização manual necessária.
this->
é apenas açúcar sintático. O problema está relacionado à natureza das variáveis (local versus membro) e às coisas que o compilador deduz desse fato.