Recientemente me encontré con una desoptimización extraña (o más bien perdí la oportunidad de optimización).
Considere esta función para desempaquetar de manera eficiente conjuntos de enteros de 3 bits a enteros de 8 bits. Descomprime 16 ints en cada iteración de bucle:
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;
}
}
Aquí está el ensamblado generado para partes del 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
...
Se ve bastante eficiente. Simplemente un shift right
seguido de un and
, y luego un store
al target
búfer. Pero ahora, mira lo que sucede cuando cambio la función a un método en una estructura:
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;
}
}
Pensé que el ensamblado generado debería ser el mismo, pero no lo es. Aquí hay una parte de esto:
...
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 puede ver, introdujimos un redundante adicional load
de memoria antes de cada turno ( mov rdx,QWORD PTR [rdi]
). Parece que el target
puntero (que ahora es un miembro en lugar de una variable local) debe recargarse siempre antes de almacenarlo. Esto ralentiza el código considerablemente (alrededor del 15% en mis mediciones).
Primero, pensé que tal vez el modelo de memoria de C ++ exige que un puntero miembro no se almacene en un registro, sino que deba volverse a cargar, pero esto parecía una elección incómoda, ya que haría imposible muchas optimizaciones viables. Así que me sorprendió mucho que el compilador no se almacenara target
en un registro aquí.
Intenté almacenar en caché el puntero del miembro en una variable 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;
}
}
Este código también produce el ensamblador "bueno" sin tiendas adicionales. Entonces, supongo: el compilador no puede elevar la carga de un puntero miembro de una estructura, por lo que dicho "puntero activo" siempre debe almacenarse en una variable local.
- Entonces, ¿por qué el compilador no puede optimizar estas cargas?
- ¿Es el modelo de memoria C ++ lo que prohíbe esto? ¿O es simplemente una deficiencia de mi compilador?
- ¿Es mi suposición correcta o cuál es la razón exacta por la que no se puede realizar la optimización?
El compilador en uso fue g++ 4.8.2-19ubuntu1
con -O3
optimización. También probé clang++ 3.4-1ubuntu3
con resultados similares: Clang incluso puede vectorizar el método con el target
puntero local . Sin embargo, el uso del this->target
puntero arroja el mismo resultado: una carga adicional del puntero antes de cada tienda.
Verifiqué el ensamblador de algunos métodos similares y el resultado es el mismo: parece que un miembro de this
siempre tiene que recargarse antes de una tienda, incluso si tal carga simplemente se puede levantar fuera del bucle. Tendré que volver a escribir una gran cantidad de código para deshacerme de estos almacenes adicionales, principalmente almacenando el puntero en una variable local que se declara sobre el código activo. Pero siempre pensé que jugar con detalles como el almacenamiento en caché de un puntero en una variable local seguramente calificaría para la optimización prematura en estos días en que los compiladores se han vuelto tan inteligentes. Pero parece que estoy equivocado aquí . El almacenamiento en caché de un puntero de miembro en un bucle activo parece ser una técnica de optimización manual necesaria.
this->
es solo azúcar sintáctico. El problema está relacionado con la naturaleza de las variables (local versus miembro) y las cosas que el compilador deduce de este hecho.