El uso de este puntero provoca una desoptimización extraña en el bucle dinámico

122

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 rightseguido de un and, y luego un storeal targetbú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 loadde memoria antes de cada turno ( mov rdx,QWORD PTR [rdi]). Parece que el targetpuntero (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 targeten 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-19ubuntu1con -O3optimización. También probé clang++ 3.4-1ubuntu3con resultados similares: Clang incluso puede vectorizar el método con el targetpuntero local . Sin embargo, el uso del this->targetpuntero 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 thissiempre 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.

gexicida
fuente
55
No estoy seguro de por qué esto recibió un voto negativo: es una pregunta interesante. FWIW He visto problemas de optimización similares con las variables miembro sin puntero donde la solución ha sido similar, es decir, almacenar en caché la variable miembro en una variable local durante la vida útil del método. ¿Supongo que tiene algo que ver con las reglas de alias?
Paul R
1
Parece que el compilador no se optimiza porque no puede garantizar que no se acceda al miembro a través de algún código "externo". Por lo tanto, si el miembro se puede modificar afuera, se debe volver a cargar cada vez que se accede. Parece ser considerado como una especie de volátil ...
Jean-Baptiste Yunès
No no usar 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.
Jean-Baptiste Yunès
¿Tiene algo que ver con los alias de puntero?
Yves Daoust
3
Como una cuestión más semántica, la "optimización prematura" se aplica solo a la optimización que es prematura, es decir, antes de que la elaboración de perfiles descubra que es un problema. En este caso, analizó y descompiló diligentemente y encontró la fuente de un problema y formuló y perfiló una solución. Absolutamente no es "prematuro" aplicar esa solución.
raptortech97

Respuestas:

107

El alias de puntero parece ser el problema, irónicamente entre thisy this->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 de this(y por lo tanto this->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 de XXpodría apuntar this.

Estoy mejor versado en C, donde esto puede remediarse declarando variables de puntero con la __restrict__palabra clave.

Peter Boncz
fuente
18
¡Puedo confirmar esto! El cambio targetde uint8_ta uint16_t(para que se apliquen las estrictas reglas de alias) lo cambió. Con uint16_t, la carga siempre se optimiza.
Gexicida
3
Cambiar el contenido de thisno es lo que quieres decir (no es una variable); te refieres a cambiar el contenido de *this.
Marc van Leeuwen
¿@gexicide mind elaborando cuán estricto es el alias y soluciona el problema?
HCSF
33

Las reglas de alias estrictas permiten char*alias cualquier otro puntero. Entonces this->target, 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;

como thisse puede modificar cuando modifica el this->targetcontenido.

Una vez que this->targetse almacena en caché en una variable local, el alias ya no es posible con la variable local.

Jarod42
fuente
1
Entonces, ¿podemos decir como regla general: siempre que tenga un char*o void*en su estructura, asegúrese de guardarlo en una variable local antes de escribir en él?
Gexicida
55
De hecho, es cuando usas un char*, no es necesario como miembro.
Jarod42
24

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:

typedef unsigned char       uint8_t;

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:

Si un programa intenta acceder al valor almacenado de un objeto a través de un valor gl diferente de uno de los siguientes tipos, el comportamiento es indefinido

e incluye la siguiente viñeta:

  • un tipo de carácter char o unsigned.

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:

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 para que el compilador no tenga que preocuparse de si los objetos uint8_t pueden alias.

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.

Shafik Yaghmour
fuente
Este es un ejemplo de un lugar donde el Estándar es peor para los optimizadores de lo que sería una regla que permitiría que un compilador suponga que entre dos accesos a un objeto de tipo T, o dicho acceso y el inicio o el final de un bucle / función en donde ocurre, todos los accesos al almacenamiento usarán el mismo objeto a menos que una operación de intervención use ese objeto (o un puntero / referencia a él) para derivar un puntero o referencia a algún otro objeto . Dicha regla eliminaría la necesidad de la "excepción de tipo de carácter" que puede matar el rendimiento del código que funciona con secuencias de bytes.
supercat