El uso de este puntero causa una extraña desoptimización en hot loop
Recientemente me encontré con una extraña desoptimización (o más bien una oportunidad de optimización perdida).
Considere esta función para desempaquetar eficientemente matrices 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
...
Parece bastante eficiente. Simplemente un shift right
seguido de un and
, y luego un store
al búfer target
. Pero ahora, mira lo que pasa cuando cambio el 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 él:
...
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, hemos introducido un load
redundante adicional de la memoria antes de cada cambio (mov rdx,QWORD PTR [rdi]
). Parece que el puntero target
(que ahora es un miembro en lugar de una variable local) tiene que recargarse siempre antes de almacenarse en él. Esto ralentiza el código considerablemente (alrededor del 15% en mi mediciones).
Primero pensé que tal vez el modelo de memoria C++ obliga a que un puntero de miembro no se almacene en un registro, sino que debe recargarse, pero esto parecía una elección incómoda, ya que haría que muchas optimizaciones viables fueran imposibles. Así que me sorprendió mucho que el compilador no almacenara target
en un registro aquí.
Traté de cachear el puntero 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. Así que mi conjetura es: Al compilador no se le permite levantar la carga de un puntero miembro de una estructura, por lo que un "puntero caliente" siempre debe almacenarse en una variable local.
- Entonces, ¿por qué el compilador no puede optimizar estas cargas?
- ¿Es el modelo de memoria C++ el que prohíbe esto? O es simplemente un defecto de mi compilador?
- ¿Es mi conjetura correcta o cuál es la razón exacta por la que la optimización no puede ser ¿realizado?
El compilador en uso era g++ 4.8.2-19ubuntu1
con -O3
optimización. También probé clang++ 3.4-1ubuntu3
con resultados similares: Clang es incluso capaz de vectorizar el método con el puntero local target
. Sin embargo, usando el puntero this->target
se obtiene el mismo resultado: Una carga adicional del puntero antes de cada almacén.
He comprobado el ensamblador de algunos métodos similares y el resultado es el mismo: Parece que un miembro de this
siempre tiene que ser recargado antes de una tienda, incluso si tal carga podría simplemente ser izada fuera del bucle. Tendré que reescribir mucho código para deshacerme de estas tiendas adicionales, principalmente almacenando el puntero en caché en una variable local que se declara por encima del código caliente. Pero siempre pensé que juguetear con detalles como almacenar en caché un puntero en una variable local seguramente calificaría para una optimización prematura en estos días donde los compiladores se han vuelto tan inteligentes. Pero parece que estoy equivocado aquí . Cachear un puntero de miembro en un hot loop parece ser un técnica de optimización manual necesaria.
3 answers
El aliasing de puntero parece ser el problema, irónicamente entre this
y this->target
. El compilador está teniendo en cuenta la posibilidad bastante obscena que inicializaste:
this->target = &this
En ese caso, escribir a this->target[0]
alteraría el contenido de this
(y por lo tanto, este->target).
El problema de aliasing de memoria no se limita a lo anterior. En principio, cualquier uso de this->target[XX]
dado un valor (in)apropiado de XX
podría apuntar a this
.
Estoy mejor versado en C, donde esto se puede remediar declarando variables de puntero con la palabra clave _ _ restrict__.
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2014-10-10 16:35:36
Las reglas estrictas de alias permiten a char*
alias a cualquier otro puntero. Así que this->target
puede alias con this
, y en su método de código, la primera parte del código,
target[0] = t & 0x7;
target[1] = (t >> 3) & 0x7;
target[2] = (t >> 6) & 0x7;
Es de hecho
this->target[0] = t & 0x7;
this->target[1] = (t >> 3) & 0x7;
this->target[2] = (t >> 6) & 0x7;
Ya que this
puede modificarse cuando modifique el contenido de this->target
.
Una vez que this->target
se almacena en caché en una variable local, el alias ya no es posible con la variable local.
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2014-10-10 16:36:27
El problema aquí es alias estrictos que dice que se nos permite alias a través de un char* y por lo que impide 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 LO que vemos este problema que es que los usuarios intentan alias a través de tipos de puntero incompatibles .
Parecería razonable para implementar uint8_t como a unsigned char y si nos mira cstdint en Coliru incluye stdint.h which typedefs uint8_t as follows:
typedef unsigned char uint8_t;
Si utiliza otro tipo que no sea char, entonces el compilador debería ser capaz de optimizar.
Esto se trata en el borrador de la sección estándar de C++ 3.10
Lvalues y rvalues que dice:
Si un programa intenta acceder al valor almacenado de un objeto a través de un glvalue distinto de uno de los tipos siguientes el comportamiento es undefined
, E incluye la siguiente viñeta:
- un tipo char o char sin signo.
Nota, he publicado un comentario sobre posibles solucionesen una pregunta que pregunta Cuando es uint8_t ≠ unsigned char? y la recomendación fue:
La solución trivial, sin embargo, es usar la palabra clave restrict, o copiar el puntero a una variable local cuya dirección nunca se toma que el compilador no necesidad de preocuparse acerca de si el uint8_t los objetos pueden alias.
Dado que C++ no soporta la palabra clave restrict tienes que confiar en la extensión del compilador, por ejemplo gcc usa __restrict__ así que esto no es totalmente portable, pero la otra sugerencia debería serlo.
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-05-23 12:01:28