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.Respuestas:
El alias de puntero parece ser el problema, irónicamente entre
this
ythis->target
. El compilador está teniendo en cuenta la posibilidad bastante obscena que inicializó:this->target = &this
En ese caso, escribir en
this->target[0]
alteraría el contenido dethis
(y por lo tantothis->target
).El problema de alias de memoria no está restringido a lo anterior. En principio, cualquier uso de
this->target[XX]
un valor dado (in) apropiado deXX
podría apuntarthis
.Estoy mejor versado en C, donde esto puede remediarse declarando variables de puntero con la
__restrict__
palabra clave.fuente
target
deuint8_t
auint16_t
(para que se apliquen las estrictas reglas de alias) lo cambió. Conuint16_t
, la carga siempre se optimiza.this
no es lo que quieres decir (no es una variable); te refieres a cambiar el contenido de*this
.Las reglas de alias estrictas permiten
char*
alias cualquier otro puntero. Entoncesthis->target
, alias conthis
, y en su método de código, la primera parte del código,es de hecho
como
this
se puede modificar cuando modifica elthis->target
contenido.Una vez que
this->target
se almacena en caché en una variable local, el alias ya no es posible con la variable local.fuente
char*
ovoid*
en su estructura, asegúrese de guardarlo en una variable local antes de escribir en él?char*
, no es necesario como miembro.El problema aquí es el alias estricto que dice que se nos permite alias a través de un char * y eso evita la optimización del compilador en su caso. No se nos permite alias a través de un puntero de un tipo diferente que sería un comportamiento indefinido, normalmente en SO vemos este problema en el que los usuarios intentan alias a través de tipos de puntero incompatibles .
Parece razonable implementar uint8_t como un carácter sin signo y si miramos cstdint en Coliru , incluye stdint.h que typedefs uint8_t de la siguiente manera:
si usó otro tipo que no sea char, el compilador debería poder optimizar.
Esto está cubierto en el borrador de la sección estándar de C ++
3.10
Lvalues and rvalues que dice:e incluye la siguiente viñeta:
Tenga en cuenta que publiqué un comentario sobre posibles soluciones alternativas en una pregunta que pregunta cuándo es uint8_t ≠ unsigned char? y la recomendación fue:
Dado que C ++ no admite la palabra clave restrictiva , debe confiar en la extensión del compilador, por ejemplo, gcc usa __restrict__, por lo que no es totalmente portátil, pero la otra sugerencia debería serlo.
fuente