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.

Author: Peter Mortensen, 2014-10-10

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__.

 93
Author: Peter Boncz,
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.

 28
Author: Jarod42,
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.

 23
Author: Shafik Yaghmour,
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