Reemplazar un contador de bucle de 32 bits con 64 bits introduce desviaciones de rendimiento locas con _mm_popcnt_u64 en las CPU Intel

1424

Estaba buscando la forma más rápida de obtener popcountgrandes conjuntos de datos. Encontré un efecto muy extraño : cambiar la variable de bucle de unsigneda uint64_thizo que el rendimiento se redujera en un 50% en mi PC.

El punto de referencia

#include <iostream>
#include <chrono>
#include <x86intrin.h>

int main(int argc, char* argv[]) {

    using namespace std;
    if (argc != 2) {
       cerr << "usage: array_size in MB" << endl;
       return -1;
    }

    uint64_t size = atol(argv[1])<<20;
    uint64_t* buffer = new uint64_t[size/8];
    char* charbuffer = reinterpret_cast<char*>(buffer);
    for (unsigned i=0; i<size; ++i)
        charbuffer[i] = rand()%256;

    uint64_t count,duration;
    chrono::time_point<chrono::system_clock> startP,endP;
    {
        startP = chrono::system_clock::now();
        count = 0;
        for( unsigned k = 0; k < 10000; k++){
            // Tight unrolled loop with unsigned
            for (unsigned i=0; i<size/8; i+=4) {
                count += _mm_popcnt_u64(buffer[i]);
                count += _mm_popcnt_u64(buffer[i+1]);
                count += _mm_popcnt_u64(buffer[i+2]);
                count += _mm_popcnt_u64(buffer[i+3]);
            }
        }
        endP = chrono::system_clock::now();
        duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
        cout << "unsigned\t" << count << '\t' << (duration/1.0E9) << " sec \t"
             << (10000.0*size)/(duration) << " GB/s" << endl;
    }
    {
        startP = chrono::system_clock::now();
        count=0;
        for( unsigned k = 0; k < 10000; k++){
            // Tight unrolled loop with uint64_t
            for (uint64_t i=0;i<size/8;i+=4) {
                count += _mm_popcnt_u64(buffer[i]);
                count += _mm_popcnt_u64(buffer[i+1]);
                count += _mm_popcnt_u64(buffer[i+2]);
                count += _mm_popcnt_u64(buffer[i+3]);
            }
        }
        endP = chrono::system_clock::now();
        duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
        cout << "uint64_t\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
             << (10000.0*size)/(duration) << " GB/s" << endl;
    }

    free(charbuffer);
}

Como puede ver, creamos un búfer de datos aleatorios, con un tamaño de xmegabytes donde xse lee desde la línea de comandos. Luego, iteramos sobre el búfer y usamos una versión desenrollada del x86 popcountintrínseco para realizar el popcount. Para obtener un resultado más preciso, hacemos el conteo pop 10,000 veces. Medimos los tiempos para el popcount. En mayúsculas, la variable de bucle interno es unsigned, en minúsculas, la variable de bucle interno es uint64_t. Pensé que esto no debería hacer ninguna diferencia, pero lo contrario es el caso.

Los resultados (absolutamente locos)

Lo compilo así (versión g ++: Ubuntu 4.8.2-19ubuntu1):

g++ -O3 -march=native -std=c++11 test.cpp -o test

Estos son los resultados en mi CPU Haswell Core i7-4770K a 3.50 GHz en ejecución test 1(por lo tanto, 1 MB de datos aleatorios):

  • sin firmar 41959360000 0.401554 seg. 26.113 GB / s
  • uint64_t 41959360000 0.759822 sec 13.8003 GB / s

Como puede ver, ¡el rendimiento de la uint64_tversión es solo la mitad del de la unsignedversión! El problema parece ser que se genera un ensamblaje diferente, pero ¿por qué? Primero, pensé en un error del compilador, así que lo intenté clang++(Ubuntu Clang versión 3.4-1ubuntu3):

clang++ -O3 -march=native -std=c++11 teest.cpp -o test

Resultado: test 1

  • sin firmar 41959360000 0.398293 seg 26.3267 GB / s
  • uint64_t 41959360000 0.680954 seg 15.3986 GB / s

Entonces, es casi el mismo resultado y sigue siendo extraño. Pero ahora se pone súper extraño. Reemplazo el tamaño del búfer que se leyó desde la entrada con una constante 1, así que cambio:

uint64_t size = atol(argv[1]) << 20;

a

uint64_t size = 1 << 20;

Por lo tanto, el compilador ahora conoce el tamaño del búfer en tiempo de compilación. ¡Quizás pueda agregar algunas optimizaciones! Aquí están los números para g++:

  • sin firmar 41959360000 0.509156 seg 20.5944 GB / s
  • uint64_t 41959360000 0.508673 seg 20.6139 GB / s

Ahora, ambas versiones son igualmente rápidas. Sin embargo, ¡se unsigned volvió aún más lento ! Se redujo de 26a 20 GB/s, reemplazando así una no constante por un valor constante que conduce a una desoptimización . En serio, no tengo idea de lo que está pasando aquí. Pero ahora clang++con la nueva versión:

  • sin firmar 41959360000 0.677009 seg 15.4884 GB / s
  • uint64_t 41959360000 0.676909 seg 15.4906 GB / s

¿Esperar lo? Ahora, ambas versiones cayeron a la lenta cantidad de 15 GB / s. Por lo tanto, reemplazar un valor no constante por un valor constante incluso conduce a un código lento en ambos casos para Clang.

Le pedí a un colega con una CPU Ivy Bridge que compilara mi punto de referencia. Obtuvo resultados similares, por lo que no parece ser Haswell. Debido a que dos compiladores producen resultados extraños aquí, tampoco parece ser un error del compilador. No tenemos una CPU AMD aquí, por lo que solo pudimos probar con Intel.

¡Más locura, por favor!

Tome el primer ejemplo (el que tiene atol(argv[1])) y ponga un staticantes de la variable, es decir:

static uint64_t size=atol(argv[1])<<20;

Aquí están mis resultados en g ++:

  • sin firmar 41959360000 0.396728 seg 26.4306 GB / s
  • uint64_t 41959360000 0.509484 seg 20.5811 GB / s

Yay, otra alternativa más . Todavía tenemos los rápidos 26 GB / s u32, ¡pero logramos pasar u64al menos de la versión de 13 GB / s a ​​la versión de 20 GB / s! En la PC de mi colega, la u64versión se volvió aún más rápida que la u32versión, produciendo el resultado más rápido de todos. Lamentablemente, esto solo funciona g++, clang++no parece importarle static.

Mi pregunta

¿Puedes explicar estos resultados? Especialmente:

  • ¿Cómo puede haber tanta diferencia entre u32y u64?
  • ¿Cómo puede reemplazar un no constante por un tamaño de búfer constante desencadenar un código menos óptimo ?
  • ¿Cómo puede la inserción de la staticpalabra clave u64acelerar el ciclo? ¡Incluso más rápido que el código original en la computadora de mi colega!

Sé que la optimización es un territorio complicado, sin embargo, nunca pensé que cambios tan pequeños pueden conducir a una diferencia del 100% en el tiempo de ejecución y que factores pequeños como un tamaño de búfer constante pueden mezclar nuevamente los resultados por completo. Por supuesto, siempre quiero tener la versión que sea capaz de contar 26 GB / s. La única forma confiable que se me ocurre es copiar y pegar el ensamblaje para este caso y usar el ensamblaje en línea. Esta es la única forma en que puedo deshacerme de los compiladores que parecen volverse locos con los pequeños cambios. ¿Qué piensas? ¿Hay alguna otra manera de obtener el código de manera confiable con el mayor rendimiento?

El desmontaje

Aquí está el desmontaje de los distintos resultados:

Versión de 26 GB / s de g ++ / u32 / non-const bufsize :

0x400af8:
lea 0x1(%rdx),%eax
popcnt (%rbx,%rax,8),%r9
lea 0x2(%rdx),%edi
popcnt (%rbx,%rcx,8),%rax
lea 0x3(%rdx),%esi
add %r9,%rax
popcnt (%rbx,%rdi,8),%rcx
add $0x4,%edx
add %rcx,%rax
popcnt (%rbx,%rsi,8),%rcx
add %rcx,%rax
mov %edx,%ecx
add %rax,%r14
cmp %rbp,%rcx
jb 0x400af8

Versión de 13 GB / s de g ++ / u64 / non-const bufsize :

0x400c00:
popcnt 0x8(%rbx,%rdx,8),%rcx
popcnt (%rbx,%rdx,8),%rax
add %rcx,%rax
popcnt 0x10(%rbx,%rdx,8),%rcx
add %rcx,%rax
popcnt 0x18(%rbx,%rdx,8),%rcx
add $0x4,%rdx
add %rcx,%rax
add %rax,%r12
cmp %rbp,%rdx
jb 0x400c00

Versión de 15 GB / s de clang ++ / u64 / non-const bufsize :

0x400e50:
popcnt (%r15,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r15,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r15,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r15,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp %rbp,%rcx
jb 0x400e50

Versión de 20 GB / s de g ++ / u32 y u64 / const bufsize :

0x400a68:
popcnt (%rbx,%rdx,1),%rax
popcnt 0x8(%rbx,%rdx,1),%rcx
add %rax,%rcx
popcnt 0x10(%rbx,%rdx,1),%rax
add %rax,%rcx
popcnt 0x18(%rbx,%rdx,1),%rsi
add $0x20,%rdx
add %rsi,%rcx
add %rcx,%rbp
cmp $0x100000,%rdx
jne 0x400a68

Versión de 15 GB / s de clang ++ / u32 y u64 / const bufsize :

0x400dd0:
popcnt (%r14,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r14,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r14,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r14,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp $0x20000,%rcx
jb 0x400dd0

Curiosamente, la versión más rápida (26 GB / s) también es la más larga. Parece ser la única solución que utiliza lea. Algunas versiones usan jbpara saltar, otras usan jne. Pero aparte de eso, todas las versiones parecen ser comparables. No veo de dónde podría originarse una brecha de rendimiento del 100%, pero no soy muy hábil para descifrar el ensamblaje. La versión más lenta (13 GB / s) parece incluso muy corta y buena. ¿Alguien puede explicar esto?

Lecciones aprendidas

No importa cuál sea la respuesta a esta pregunta; He aprendido que, en los bucles realmente activos, cada detalle puede importar, incluso los detalles que no parecen tener ninguna asociación con el código activo . Nunca pensé en qué tipo usar para una variable de bucle, pero como puede ver, un cambio tan pequeño puede hacer una diferencia del 100% . ¡Incluso el tipo de almacenamiento de un búfer puede marcar una gran diferencia, como vimos con la inserción de la staticpalabra clave frente a la variable de tamaño! En el futuro, siempre probaré varias alternativas en varios compiladores al escribir bucles realmente ajustados y dinámicos que son cruciales para el rendimiento del sistema.

Lo interesante también es que la diferencia de rendimiento sigue siendo muy alta, aunque ya he desenrollado el ciclo cuatro veces. Entonces, incluso si se desenrolla, aún puede ser golpeado por grandes desviaciones de rendimiento. Bastante interesante.

gexicida
fuente
8
¡MUCHOS COMENTARIOS! Puede verlos en el chat e incluso dejar los suyos allí si lo desea, ¡pero no agregue más aquí!
Shog9
3
También vea GCC Issue 62011, False Data Dependency en la instrucción popcnt . Alguien más lo proporcionó, pero parece haberse perdido durante las limpiezas.
jww
No puedo decirlo, pero ¿es uno de los desensambles para la versión con la estática? Si no, ¿puedes editar la publicación y agregarla?
Kelly S. French

Respuestas:

1552

Culpable: Dependencia de datos falsos (y el compilador ni siquiera lo sabe)

En los procesadores Sandy / Ivy Bridge y Haswell, la instrucción:

popcnt  src, dest

parece tener una dependencia falsa en el registro de destino dest. Aunque la instrucción solo escribe en ella, la instrucción esperará hasta que destesté lista antes de ejecutarse. Esta falsa dependencia está (ahora) documentada por Intel como erratum HSD146 (Haswell) y SKL029 (Skylake)

Skylake arregló esto para lzcntytzcnt .
Cannon Lake (y Ice Lake) arreglaron esto popcnt.
bsf/ bsrtiene una verdadera dependencia de salida: salida sin modificar para input = 0. (Pero no hay forma de aprovechar eso con intrínsecos , solo AMD lo documenta y los compiladores no lo exponen).

(Sí, todas estas instrucciones se ejecutan en la misma unidad de ejecución ).


Esta dependencia no solo retiene los 4 popcnts de una iteración de bucle único. Puede llevar a través de iteraciones de bucle, lo que hace que sea imposible para el procesador paralelizar diferentes iteraciones de bucle.

Los ajustes unsignedvs. uint64_ty otros no afectan directamente el problema. Pero influyen en el asignador de registros que asigna los registros a las variables.

En su caso, las velocidades son un resultado directo de lo que está atascado en la cadena de dependencias (falsa) dependiendo de lo que el asignador de registros haya decidido hacer.

  • 13 GB / s tiene una cadena: popcnt- add- popcnt- popcnt→ siguiente iteración
  • 15 GB / s tiene una cadena: popcnt- add- popcnt- add→ siguiente iteración
  • 20 GB / s tiene una cadena: popcnt- popcnt→ siguiente iteración
  • 26 GB / s tiene una cadena: popcnt- popcnt→ siguiente iteración

La diferencia entre 20 GB / sy 26 GB / s parece ser un artefacto menor del direccionamiento indirecto. De cualquier manera, el procesador comienza a alcanzar otros cuellos de botella una vez que alcanza esta velocidad.


Para probar esto, utilicé el ensamblaje en línea para omitir el compilador y obtener exactamente el ensamblaje que quiero. También dividí la countvariable para romper todas las demás dependencias que podrían interferir con los puntos de referencia.

Aquí están los resultados:

Sandy Bridge Xeon @ 3.5 GHz: (el código de prueba completo se puede encontrar en la parte inferior)

  • CCG 4.6.3: g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
  • Ubuntu 12

Diferentes registros: 18.6195 GB / s

.L4:
    movq    (%rbx,%rax,8), %r8
    movq    8(%rbx,%rax,8), %r9
    movq    16(%rbx,%rax,8), %r10
    movq    24(%rbx,%rax,8), %r11
    addq    $4, %rax

    popcnt %r8, %r8
    add    %r8, %rdx
    popcnt %r9, %r9
    add    %r9, %rcx
    popcnt %r10, %r10
    add    %r10, %rdi
    popcnt %r11, %r11
    add    %r11, %rsi

    cmpq    $131072, %rax
    jne .L4

Mismo registro: 8.49272 GB / s

.L9:
    movq    (%rbx,%rdx,8), %r9
    movq    8(%rbx,%rdx,8), %r10
    movq    16(%rbx,%rdx,8), %r11
    movq    24(%rbx,%rdx,8), %rbp
    addq    $4, %rdx

    # This time reuse "rax" for all the popcnts.
    popcnt %r9, %rax
    add    %rax, %rcx
    popcnt %r10, %rax
    add    %rax, %rsi
    popcnt %r11, %rax
    add    %rax, %r8
    popcnt %rbp, %rax
    add    %rax, %rdi

    cmpq    $131072, %rdx
    jne .L9

Mismo registro con cadena rota: 17.8869 GB / s

.L14:
    movq    (%rbx,%rdx,8), %r9
    movq    8(%rbx,%rdx,8), %r10
    movq    16(%rbx,%rdx,8), %r11
    movq    24(%rbx,%rdx,8), %rbp
    addq    $4, %rdx

    # Reuse "rax" for all the popcnts.
    xor    %rax, %rax    # Break the cross-iteration dependency by zeroing "rax".
    popcnt %r9, %rax
    add    %rax, %rcx
    popcnt %r10, %rax
    add    %rax, %rsi
    popcnt %r11, %rax
    add    %rax, %r8
    popcnt %rbp, %rax
    add    %rax, %rdi

    cmpq    $131072, %rdx
    jne .L14

Entonces, ¿qué salió mal con el compilador?

Parece que ni GCC ni Visual Studio son conscientes de que popcnttiene una dependencia tan falsa. Sin embargo, estas falsas dependencias no son infrecuentes. Es solo cuestión de si el compilador lo sabe.

popcntNo es exactamente la instrucción más utilizada. Entonces, no es realmente una sorpresa que un compilador importante pueda perderse algo como esto. Tampoco parece haber documentación en ninguna parte que mencione este problema. Si Intel no lo revela, nadie lo sabrá hasta que alguien lo encuentre por casualidad.

( Actualización: a partir de la versión 4.9.2 , GCC es consciente de esta falsa dependencia y genera código para compensarla cuando se habilitan las optimizaciones. Los principales compiladores de otros proveedores, incluidos Clang, MSVC e incluso el propio ICC de Intel aún no son conscientes de este error de microarquitectura y no emitirá código que lo compense).

¿Por qué la CPU tiene una dependencia tan falsa?

Podemos especular: se ejecuta en la misma unidad de ejecución como bsf/ bsrel que hacer tener una dependencia de salida. ( ¿Cómo se implementa POPCNT en el hardware? ). Para esas instrucciones, Intel documenta el resultado entero para input = 0 como "indefinido" (con ZF = 1), pero el hardware de Intel en realidad ofrece una garantía más fuerte para evitar romper el software antiguo: salida sin modificar. AMD documenta este comportamiento.

Presumiblemente, de alguna manera fue inconveniente hacer que algunos uops para esta unidad de ejecución dependieran de la salida, pero otros no.

Los procesadores AMD no parecen tener esta falsa dependencia.


El código de prueba completo está debajo para referencia:

#include <iostream>
#include <chrono>
#include <x86intrin.h>

int main(int argc, char* argv[]) {

   using namespace std;
   uint64_t size=1<<20;

   uint64_t* buffer = new uint64_t[size/8];
   char* charbuffer=reinterpret_cast<char*>(buffer);
   for (unsigned i=0;i<size;++i) charbuffer[i]=rand()%256;

   uint64_t count,duration;
   chrono::time_point<chrono::system_clock> startP,endP;
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "popcnt %4, %4  \n\t"
                "add %4, %0     \n\t"
                "popcnt %5, %5  \n\t"
                "add %5, %1     \n\t"
                "popcnt %6, %6  \n\t"
                "add %6, %2     \n\t"
                "popcnt %7, %7  \n\t"
                "add %7, %3     \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "No Chain\t" << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "popcnt %4, %%rax   \n\t"
                "add %%rax, %0      \n\t"
                "popcnt %5, %%rax   \n\t"
                "add %%rax, %1      \n\t"
                "popcnt %6, %%rax   \n\t"
                "add %%rax, %2      \n\t"
                "popcnt %7, %%rax   \n\t"
                "add %%rax, %3      \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
                : "rax"
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "Chain 4   \t"  << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "xor %%rax, %%rax   \n\t"   // <--- Break the chain.
                "popcnt %4, %%rax   \n\t"
                "add %%rax, %0      \n\t"
                "popcnt %5, %%rax   \n\t"
                "add %%rax, %1      \n\t"
                "popcnt %6, %%rax   \n\t"
                "add %%rax, %2      \n\t"
                "popcnt %7, %%rax   \n\t"
                "add %%rax, %3      \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
                : "rax"
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "Broken Chain\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }

   free(charbuffer);
}

Un punto de referencia igualmente interesante se puede encontrar aquí: http://pastebin.com/kbzgL8si
Este punto de referencia varía el número de popcntcorreos electrónicos que se encuentran en la cadena (falsa) de dependencia.

False Chain 0:  41959360000 0.57748 sec     18.1578 GB/s
False Chain 1:  41959360000 0.585398 sec    17.9122 GB/s
False Chain 2:  41959360000 0.645483 sec    16.2448 GB/s
False Chain 3:  41959360000 0.929718 sec    11.2784 GB/s
False Chain 4:  41959360000 1.23572 sec     8.48557 GB/s
Místico
fuente
3
¡Hola amigos! Muchos comentarios pasados ​​aquí; antes de dejar uno nuevo, revise el archivo .
Shog9
1
@ JustinL.it parece que este problema en particular se solucionó en Clang a partir de 7.0
Dan M.
@PeterCordes No creo que sea tanto la unidad de ejecución como el planificador. Es el planificador que rastrea las dependencias. Y para hacer eso, las instrucciones se agrupan en una serie de "clases de instrucciones", cada una de las cuales son tratadas de manera idéntica por el planificador. Por lo tanto, todas las instrucciones de 3 ciclos "slow-int" se incluyeron en la misma "clase" con el propósito de programar la instrucción.
Mysticial
@Mysticial: ¿Todavía lo crees ahora? Eso es plausible, pero imul dst, src, imm no tiene una dependencia de salida, y tampoco lo hace lento lea. Tampoco lo hace pdep, pero eso es VEX codificado con 2 operandos de entrada. Estuvo de acuerdo que no es la unidad de ejecución en sí la que causa la falsa dep; eso depende de la etapa RAT y de emisión / cambio de nombre, ya que cambia el nombre de los operandos del registro arquitectónico en registros físicos. Presumiblemente necesita una tabla de código uop -> patrón de dependencia y opciones de puerto, y agrupar todos los uops para la misma unidad de ejecución juntos simplifica esa tabla. Eso es lo que quise decir con más detalle.
Peter Cordes el
Avíseme si desea que edite eso en su respuesta, o si desea volver a decir algo como lo que dijo originalmente sobre el programador. El hecho de que SKL eliminó la falsa dep para lzcnt / tzcnt pero no popcnt debería decirnos algo, pero IDK qué. Otra posible señal de que está relacionado con el cambio de nombre / RAT es que SKL lamina un modo de direccionamiento indexado como fuente de memoria para lzcnt / tzcnt pero no popcnt. Obviamente, la unidad de cambio de nombre tiene que crear uops que el back-end puede representar, sin embargo.
Peter Cordes
50

Codifiqué un programa C equivalente para experimentar, y puedo confirmar este extraño comportamiento. Además, gcccree que el número entero de 64 bits (que probablemente debería ser de size_ttodos modos ...) es mejor, ya que el uso uint_fast32_thace que gcc use un uint de 64 bits.

Hice un poco de trabajo con el ensamblaje:
simplemente tome la versión de 32 bits, reemplace todas las instrucciones / registros de 32 bits con la versión de 64 bits en el bucle popcount interno del programa. Observación: ¡el código es tan rápido como la versión de 32 bits!

Obviamente, esto es un truco, ya que el tamaño de la variable no es realmente de 64 bits, ya que otras partes del programa todavía usan la versión de 32 bits, pero mientras el bucle popcount interno domine el rendimiento, este es un buen comienzo .

Luego copié el código del bucle interno de la versión de 32 bits del programa, lo pirateé para que sea de 64 bits, manipulé los registros para convertirlo en un reemplazo del bucle interno de la versión de 64 bits. Este código también se ejecuta tan rápido como la versión de 32 bits.

Mi conclusión es que esta es una mala programación de instrucciones por parte del compilador, no una ventaja de velocidad / latencia real de las instrucciones de 32 bits.

(Advertencia: pirateé el montaje, podría haber roto algo sin darme cuenta. No lo creo).

EOF
fuente
1
"Además, gcc cree que el entero de 64 bits [...] es mejor, ya que el uso de uint_fast32_t hace que gcc use un uint de 64 bits". Desafortunadamente, y para mi pesar, no hay magia ni una profunda introspección de código detrás de estos tipos. Todavía tengo que verlos proporcionados de otra manera que no sea como typedefs individuales para cada lugar posible y cada programa en toda la plataforma. Es probable que se haya pensado bastante en la elección exacta de los tipos, pero la definición para cada uno de ellos no puede ajustarse a cada aplicación que haya existido. Algunas lecturas adicionales: stackoverflow.com/q/4116297 .
Keno
2
@Keno Eso es porque sizeof(uint_fast32_t)tiene que definirse. Si permite que no sea así, puede hacer ese truco, pero eso solo se puede lograr con una extensión del compilador.
wizzwizz4
25

Esta no es una respuesta, pero es difícil de leer si pongo resultados en el comentario.

Obtengo estos resultados con una Mac Pro ( Westmere 6-Cores Xeon 3.33 GHz). Lo compilé con clang -O3 -msse4 -lstdc++ a.cpp -o a(-O2 obtiene el mismo resultado).

sonar con uint64_t size=atol(argv[1])<<20;

unsigned    41950110000 0.811198 sec    12.9263 GB/s
uint64_t    41950110000 0.622884 sec    16.8342 GB/s

sonar con uint64_t size=1<<20;

unsigned    41950110000 0.623406 sec    16.8201 GB/s
uint64_t    41950110000 0.623685 sec    16.8126 GB/s

También intenté:

  1. Invierta el orden de prueba, el resultado es el mismo, por lo que descarta el factor de caché.
  2. Tener la fordeclaración a la inversa: for (uint64_t i=size/8;i>0;i-=4). Esto da el mismo resultado y demuestra que la compilación es lo suficientemente inteligente como para no dividir el tamaño entre 8 en cada iteración (como se esperaba).

Aquí está mi suposición salvaje:

El factor de velocidad viene en tres partes:

  • caché de código: la uint64_tversión tiene un tamaño de código mayor, pero esto no tiene ningún efecto en mi CPU Xeon. Esto hace que la versión de 64 bits sea más lenta.

  • Instrucciones utilizadas Tenga en cuenta no solo el recuento de bucles, sino que se accede al búfer con un índice de 32 bits y 64 bits en las dos versiones. El acceso a un puntero con un desplazamiento de 64 bits solicita un registro y direccionamiento de 64 bits dedicados, mientras que puede utilizarlo inmediatamente para un desplazamiento de 32 bits. Esto puede hacer que la versión de 32 bits sea más rápida.

  • Las instrucciones solo se emiten en la compilación de 64 bits (es decir, la captación previa). Esto hace que 64 bits sea más rápido.

Los tres factores juntos coinciden con los resultados aparentemente conflictivos observados.

Interrupción no enmascarable
fuente
44
Interesante, ¿puedes agregar la versión del compilador y las banderas del compilador? Lo mejor es que en su máquina, los resultados se cambian, es decir, usar u64 es más rápido . Hasta ahora, nunca he pensado sobre qué tipo tiene mi variable de bucle, pero parece que tengo que pensarlo dos veces la próxima vez :).
Gexicida
2
@gexicide: No llamaría un salto de 16.8201 a 16.8126 por lo que es "más rápido".
user541686
2
@Mehrdad: El salto que quiero decir es el que está entre 12.9y 16.8, por lo que unsignedes más rápido aquí. En mi punto de referencia, lo contrario fue el caso, es decir, 26 por unsigned, 15 poruint64_t
gexicida
@gexicide ¿Ha notado la diferencia en el direccionamiento del buffer [i]?
Interrupción no enmascarable el
@ Calvin: No, ¿qué quieres decir?
Gexicida
10

No puedo dar una respuesta autorizada, pero proporcionar una visión general de una causa probable. Esta referencia muestra claramente que para las instrucciones en el cuerpo de su ciclo hay una relación de 3: 1 entre latencia y rendimiento. También muestra los efectos del despacho múltiple. Dado que hay (dar o recibir) tres unidades enteras en los procesadores x86 modernos, generalmente es posible enviar tres instrucciones por ciclo.

Entonces, entre el rendimiento máximo de la tubería y el despacho múltiple y la falla de estos mecanismos, tenemos un factor de rendimiento de seis. Es bien sabido que la complejidad del conjunto de instrucciones x86 hace que sea bastante fácil que ocurra una ruptura peculiar. El documento anterior tiene un gran ejemplo:

El rendimiento del Pentium 4 para cambios a la derecha de 64 bits es realmente pobre. El desplazamiento a la izquierda de 64 bits, así como todos los cambios de 32 bits, tienen un rendimiento aceptable. Parece que la ruta de datos desde los 32 bits superiores a los 32 bits inferiores de la ALU no está bien diseñada.

Personalmente, me encontré con un caso extraño en el que un bucle activo se ejecutó considerablemente más lento en un núcleo específico de un chip de cuatro núcleos (AMD, si mal no recuerdo). De hecho, obtuvimos un mejor rendimiento en un cálculo de reducción de mapas al desactivar ese núcleo.

Aquí supongo que las unidades enteras están en desacuerdo: que los popcntcálculos del contador de bucles y direcciones apenas pueden ejecutarse a toda velocidad con el contador de 32 bits de ancho, pero el contador de 64 bits provoca contenciones y paradas de canalización. Dado que solo hay alrededor de 12 ciclos en total, potencialmente 4 ciclos con despacho múltiple, por ejecución de cuerpo de bucle, una sola pérdida podría afectar razonablemente el tiempo de ejecución en un factor de 2.

El cambio inducido por el uso de una variable estática, que supongo que solo causa un reordenamiento menor de las instrucciones, es otra pista de que el código de 32 bits está en algún punto de inflexión para la contención.

Sé que este no es un análisis riguroso, pero es una explicación plausible.

Gene
fuente
2
Desafortunadamente, desde (¿Core 2?) Prácticamente no hay diferencias de rendimiento entre las operaciones de enteros de 32 bits y 64 bits, excepto para multiplicar / dividir, que no están presentes en este código.
Mysticial
@Gene: Tenga en cuenta que todas las versiones almacenan el tamaño en un registro y nunca lo leen de la pila en el bucle. Por lo tanto, el cálculo de la dirección no puede estar en la mezcla, al menos no dentro del bucle.
Gexicida
@Gene: ¡Una explicación interesante de hecho! Pero no explica los puntos principales de WTF: que 64 bits es más lento que 32 bits debido a las paradas de la tubería es una cosa. Pero si este es el caso, no debe ser la versión de 64 bits de forma fiable más lenta que la de 32 bits? En cambio, tres compiladores diferentes emiten código lento incluso para la versión de 32 bits cuando se utiliza el tamaño del búfer de tiempo de compilación constante; cambiar el tamaño del búfer a estático nuevamente cambia las cosas por completo. Incluso hubo un caso en la máquina de mis colegas (y en la respuesta de Calvin) donde la versión de 64 bits es considerablemente más rápida. Parece ser absolutamente impredecible ..
gexicida 01 de
@ Mysticial Ese es mi punto. No hay una diferencia máxima de rendimiento cuando no hay contención para IU, tiempo de bus, etc. La referencia lo muestra claramente. La contención hace que todo sea diferente. Aquí hay un ejemplo de la literatura de Intel Core: "Una nueva tecnología incluida en el diseño es Macro-Ops Fusion, que combina dos instrucciones x86 en una sola micro-operación. Por ejemplo, una secuencia de código común como una comparación seguida de un salto condicional se convertiría en una sola microoperación. Desafortunadamente, esta tecnología no funciona en modo de 64 bits ". Entonces tenemos una relación 2: 1 en velocidad de ejecución.
Gene
@gexicide Veo lo que estás diciendo, pero estás infiriendo más de lo que quise decir. Estoy diciendo que el código que se ejecuta más rápido es mantener la canalización y las colas de despacho llenas. Esta condición es frágil. Cambios menores como agregar 32 bits al flujo total de datos y reordenar las instrucciones son suficientes para romperlo. En resumen, la afirmación de OP de que tocar el violín y probar es el único camino a seguir es correcta.
Gene
10

Intenté esto con Visual Studio 2013 Express , usando un puntero en lugar de un índice, lo que aceleró un poco el proceso. Sospecho que esto se debe a que el direccionamiento es desplazamiento + registro, en lugar de desplazamiento + registro + (registro << 3). Código C ++.

   uint64_t* bfrend = buffer+(size/8);
   uint64_t* bfrptr;

// ...

   {
      startP = chrono::system_clock::now();
      count = 0;
      for (unsigned k = 0; k < 10000; k++){
         // Tight unrolled loop with uint64_t
         for (bfrptr = buffer; bfrptr < bfrend;){
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
         }
      }
      endP = chrono::system_clock::now();
      duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "uint64_t\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
           << (10000.0*size)/(duration) << " GB/s" << endl;
   }

código de ensamblaje: r10 = bfrptr, r15 = bfrend, rsi = count, rdi = buffer, r13 = k:

$LL5@main:
        mov     r10, rdi
        cmp     rdi, r15
        jae     SHORT $LN4@main
        npad    4
$LL2@main:
        mov     rax, QWORD PTR [r10+24]
        mov     rcx, QWORD PTR [r10+16]
        mov     r8, QWORD PTR [r10+8]
        mov     r9, QWORD PTR [r10]
        popcnt  rdx, rax
        popcnt  rax, rcx
        add     rdx, rax
        popcnt  rax, r8
        add     r10, 32
        add     rdx, rax
        popcnt  rax, r9
        add     rsi, rax
        add     rsi, rdx
        cmp     r10, r15
        jb      SHORT $LL2@main
$LN4@main:
        dec     r13
        jne     SHORT $LL5@main
rcgldr
fuente
9

¿Has intentado pasar -funroll-loops -fprefetch-loop-arraysa GCC?

Obtengo los siguientes resultados con estas optimizaciones adicionales:

[1829] /tmp/so_25078285 $ cat /proc/cpuinfo |grep CPU|head -n1
model name      : Intel(R) Core(TM) i3-3225 CPU @ 3.30GHz
[1829] /tmp/so_25078285 $ g++ --version|head -n1
g++ (Ubuntu/Linaro 4.7.3-1ubuntu1) 4.7.3

[1829] /tmp/so_25078285 $ g++ -O3 -march=native -std=c++11 test.cpp -o test_o3
[1829] /tmp/so_25078285 $ g++ -O3 -march=native -funroll-loops -fprefetch-loop-arrays -std=c++11     test.cpp -o test_o3_unroll_loops__and__prefetch_loop_arrays

[1829] /tmp/so_25078285 $ ./test_o3 1
unsigned        41959360000     0.595 sec       17.6231 GB/s
uint64_t        41959360000     0.898626 sec    11.6687 GB/s

[1829] /tmp/so_25078285 $ ./test_o3_unroll_loops__and__prefetch_loop_arrays 1
unsigned        41959360000     0.618222 sec    16.9612 GB/s
uint64_t        41959360000     0.407304 sec    25.7443 GB/s
Dangelov
fuente
3
Pero aún así, sus resultados son totalmente extraños (primero sin firmar más rápido, luego uint64_t más rápido) ya que desenrollar no soluciona el problema principal de la falsa dependencia.
Gexicida
7

¿Has intentado mover el paso de reducción fuera del ciclo? En este momento tiene una dependencia de datos que realmente no es necesaria.

Tratar:

  uint64_t subset_counts[4] = {};
  for( unsigned k = 0; k < 10000; k++){
     // Tight unrolled loop with unsigned
     unsigned i=0;
     while (i < size/8) {
        subset_counts[0] += _mm_popcnt_u64(buffer[i]);
        subset_counts[1] += _mm_popcnt_u64(buffer[i+1]);
        subset_counts[2] += _mm_popcnt_u64(buffer[i+2]);
        subset_counts[3] += _mm_popcnt_u64(buffer[i+3]);
        i += 4;
     }
  }
  count = subset_counts[0] + subset_counts[1] + subset_counts[2] + subset_counts[3];

También tiene algún alias extraño, que no estoy seguro de que cumpla con las estrictas reglas de alias.

Ben Voigt
fuente
2
Eso fue lo primero que hice después de leer la pregunta. Romper la cadena de dependencia. Al final resultó que la diferencia de rendimiento no cambia (al menos en mi computadora - Intel Haswell con GCC 4.7.3).
Nils Pipenbrinck
1
@BenVoigt: es conforme al alias estricto. void*y char*son los dos tipos que pueden tener alias, ya que en esencia se consideran "punteros en un trozo de memoria". Su idea con respecto a la eliminación de la dependencia de datos es buena para la optimización, pero no responde la pregunta. Y, como dice @NilsPipenbrinck, no parece cambiar nada.
Gexicida
@gexicide: la estricta regla de alias no es simétrica. Puedes usar char*para acceder a T[]. No puede utilizar a de forma segura T*para acceder a char[], y su código parece hacer lo último.
Ben Voigt
@BenVoigt: Entonces nunca podrías guardar mallocuna variedad de cosas, ya que malloc regresa void*y lo interpretas como T[]. Y estoy bastante seguro de eso void*y char*tenía la misma semántica con respecto al alias estricto. Sin embargo, supongo que esto es bastante tópico aquí :)
gexicida
1
Personalmente, creo que la forma correcta esuint64_t* buffer = new uint64_t[size/8]; /* type is clearly uint64_t[] */ char* charbuffer=reinterpret_cast<char*>(buffer); /* aliasing a uint64_t[] with char* is safe */
Ben Voigt
6

TL; DR: utilice __builtinintrínsecos en su lugar; podrían pasar a ayudar.

Pude hacer que gcc4.8.4 (e incluso 4.7.3 en gcc.godbolt.org) generen un código óptimo para esto usando__builtin_popcountll que usa la misma instrucción de ensamblaje, pero tiene suerte y código que no tiene un inesperado dependencia larga transportada en bucle debido al error de dependencia falso.

No estoy 100% seguro de mi código de evaluación comparativa, pero la objdumpsalida parece compartir mis puntos de vista. Uso algunos otros trucos ( ++ivs i++) para hacer que el compilador se desenrolle sin ningúnmovl instrucción (comportamiento extraño, debo decir).

Resultados:

Count: 20318230000  Elapsed: 0.411156 seconds   Speed: 25.503118 GB/s

Código de evaluación comparativa:

#include <stdint.h>
#include <stddef.h>
#include <time.h>
#include <stdio.h>
#include <stdlib.h>

uint64_t builtin_popcnt(const uint64_t* buf, size_t len){
  uint64_t cnt = 0;
  for(size_t i = 0; i < len; ++i){
    cnt += __builtin_popcountll(buf[i]);
  }
  return cnt;
}

int main(int argc, char** argv){
  if(argc != 2){
    printf("Usage: %s <buffer size in MB>\n", argv[0]);
    return -1;
  }
  uint64_t size = atol(argv[1]) << 20;
  uint64_t* buffer = (uint64_t*)malloc((size/8)*sizeof(*buffer));

  // Spoil copy-on-write memory allocation on *nix
  for (size_t i = 0; i < (size / 8); i++) {
    buffer[i] = random();
  }
  uint64_t count = 0;
  clock_t tic = clock();
  for(size_t i = 0; i < 10000; ++i){
    count += builtin_popcnt(buffer, size/8);
  }
  clock_t toc = clock();
  printf("Count: %lu\tElapsed: %f seconds\tSpeed: %f GB/s\n", count, (double)(toc - tic) / CLOCKS_PER_SEC, ((10000.0*size)/(((double)(toc - tic)*1e+9) / CLOCKS_PER_SEC)));
  return 0;
}

Opciones de compilación:

gcc --std=gnu99 -mpopcnt -O3 -funroll-loops -march=native bench.c -o bench

Versión de GCC:

gcc (Ubuntu 4.8.4-2ubuntu1~14.04.1) 4.8.4

Versión del kernel de Linux:

3.19.0-58-generic

Información de la CPU:

processor   : 0
vendor_id   : GenuineIntel
cpu family  : 6
model       : 70
model name  : Intel(R) Core(TM) i7-4870HQ CPU @ 2.50 GHz
stepping    : 1
microcode   : 0xf
cpu MHz     : 2494.226
cache size  : 6144 KB
physical id : 0
siblings    : 1
core id     : 0
cpu cores   : 1
apicid      : 0
initial apicid  : 0
fpu     : yes
fpu_exception   : yes
cpuid level : 13
wp      : yes
flags       : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx rdtscp lm constant_tsc nopl xtopology nonstop_tsc eagerfpu pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm arat pln pts dtherm fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 invpcid xsaveopt
bugs        :
bogomips    : 4988.45
clflush size    : 64
cache_alignment : 64
address sizes   : 36 bits physical, 48 bits virtual
power management:
assp1r1n3
fuente
3
Es una buena suerte que el -funroll-loopscódigo no se cuele en una cadena de dependencia de bucle creada por popcntel falso dep. Usar un viejo compilador que no conozca la falsa dependencia es un riesgo. Sin -funroll-loops, el bucle de gcc 4.8.5 tendrá un cuello de botella en la latencia popcnt en lugar del rendimiento, porque cuenta enrdx . El mismo código, compilado por gcc 4.9.3 agrega un xor edx,edxpara romper la cadena de dependencia.
Peter Cordes
3
Con compiladores antiguos, su código aún sería vulnerable a la misma variación de rendimiento que experimentó el OP: los cambios aparentemente triviales podrían hacer que gcc sea algo lento porque no tenía idea de que causaría un problema. Encontrar algo que funciona en un caso en un compilador antiguo no es la cuestión.
Peter Cordes
2
Para el registro, x86intrin.hlas _mm_popcnt_*funciones de GCC son envoltorios forzados en línea alrededor del__builtin_popcount* ; la alineación debe hacer que una sea exactamente equivalente a la otra. Dudo mucho que veas alguna diferencia que pueda ser causada por cambiar entre ellos.
ShadowRanger
-2

En primer lugar, trate de estimar el rendimiento máximo: examine https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf , en particular, el Apéndice C.

En su caso, la tabla C-10 muestra que la instrucción POPCNT tiene latencia = 3 relojes y rendimiento = 1 reloj. El rendimiento muestra su velocidad máxima en relojes (multiplique por la frecuencia del núcleo y 8 bytes en el caso de popcnt64 para obtener el mejor número de ancho de banda posible).

Ahora examine lo que hizo el compilador y resuma el rendimiento de todas las demás instrucciones del bucle. Esto dará la mejor estimación posible para el código generado.

Por último, observe las dependencias de datos entre las instrucciones en el bucle, ya que forzarán una demora de latencia grande en lugar de un rendimiento, por lo tanto, divida las instrucciones de iteración única en las cadenas de flujo de datos y calcule la latencia a través de ellas y luego obtenga ingenuamente el máximo de ellas. dará una estimación aproximada teniendo en cuenta las dependencias del flujo de datos.

Sin embargo, en su caso, solo escribir código de la manera correcta eliminaría todas estas complejidades. En lugar de acumular a la misma variable de recuento, simplemente acumule a diferentes (como count0, count1, ... count8) y sume al final. O incluso cree una serie de recuentos [8] y acumule en sus elementos; tal vez, se vectorizará incluso y obtendrá un rendimiento mucho mejor.

PS y nunca ejecute un punto de referencia por un segundo, primero caliente el núcleo y luego ejecute el bucle durante al menos 10 segundos o mejor 100 segundos. de lo contrario, probará el firmware de administración de energía y la implementación de DVFS en hardware :)

PPS Escuché un sinfín de debates sobre cuánto tiempo debería correr realmente el benchmark. La mayoría de las personas más inteligentes incluso se preguntan por qué 10 segundos, no 11 o 12. Debo admitir que esto es divertido en teoría. En la práctica, simplemente va y ejecuta el punto de referencia cien veces seguidas y registra las desviaciones. Eso es gracioso. La mayoría de las personas cambian de fuente y ejecutan un banco de pruebas exactamente UNA VEZ para capturar un nuevo récord de rendimiento. Haz las cosas bien bien.

¿Todavía no estás convencido? Simplemente use la versión C anterior de benchmark de assp1r1n3 ( https://stackoverflow.com/a/37026212/9706746 ) e intente 100 en lugar de 10000 en el bucle de reintento.

Mi 7960X muestra, con RETRY = 100:

Conteo: 203182300 Transcurrido: 0.008385 segundos Velocidad: 12.505379 GB / s

Conteo: 203182300 Transcurrido: 0.011063 segundos Velocidad: 9.478225 GB / s

Conteo: 203182300 Transcurrido: 0.011188 segundos Velocidad: 9.372327 GB / s

Conteo: 203182300 Transcurrido: 0.010393 segundos Velocidad: 10.089252 GB / s

Conteo: 203182300 Transcurrido: 0.009076 segundos Velocidad: 11.553283 GB / s

con RETRY = 10000:

Recuento: 20318230000 Transcurrido: 0,661791 segundos Velocidad: 15,844519 GB / s

Conteo: 20318230000 Transcurrido: 0.665422 segundos Velocidad: 15.758060 GB / s

Conteo: 20318230000 Transcurrido: 0.660983 segundos Velocidad: 15.863888 GB / s

Conteo: 20318230000 Transcurrido: 0.665337 segundos Velocidad: 15.760073 GB / s

Recuento: 20318230000 Transcurrido: 0,662138 segundos Velocidad: 15,836215 GB / s

PPPS Finalmente, en "respuesta aceptada" y otro misterio ;-)

Usemos la respuesta de assp1r1n3: tiene un núcleo de 2.5Ghz. POPCNT tiene 1 reloj a través de la entrada, su código está usando popcnt de 64 bits. Entonces las matemáticas son 2.5Ghz * 1 reloj * 8 bytes = 20 GB / s para su configuración. Está viendo 25 Gb / s, tal vez debido al impulso de turbo a alrededor de 3Ghz.

Por lo tanto, vaya a ark.intel.com y busque i7-4870HQ: https://ark.intel.com/products/83504/Intel-Core-i7-4870HQ-Processor-6M-Cache-up-to-3-70 -GHz-? Q = i7-4870HQ

Ese núcleo podría correr hasta 3.7Ghz y la tasa máxima real es de 29.6 GB / s para su hardware. Entonces, ¿dónde están otros 4GB / s? Tal vez, se gasta en lógica de bucle y otro código circundante dentro de cada iteración.

¿ Dónde está esta falsa dependencia? el hardware funciona a una velocidad casi máxima. Tal vez mis matemáticas son malas, a veces sucede :)

PPPPPS Todavía la gente sugiere que la errata de HW es la culpable, por lo que sigo la sugerencia y creé un ejemplo en línea, ver a continuación.

En mi 7960X, la primera versión (con salida única a cnt0) se ejecuta a 11 MB / s, la segunda versión (con salida a cnt0, cnt1, cnt2 y cnt3) se ejecuta a 33 MB / s. Y se podría decir: ¡voila! es dependencia de salida.

OK, tal vez, el punto que hice es que no tiene sentido escribir código como este y no es un problema de dependencia de salida, sino una generación de código tonto. No estamos probando hardware, estamos escribiendo código para liberar el máximo rendimiento. Podría esperar que HW OOO cambie el nombre y oculte esas "dependencias de salida", pero, simplemente, haga las cosas bien y nunca enfrentará ningún misterio.

uint64_t builtin_popcnt1a(const uint64_t* buf, size_t len) 
{
    uint64_t cnt0, cnt1, cnt2, cnt3;
    cnt0 = cnt1 = cnt2 = cnt3 = 0;
    uint64_t val = buf[0];
    #if 0
        __asm__ __volatile__ (
            "1:\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "subq $4, %0\n\t"
            "jnz 1b\n\t"
        : "+q" (len), "=q" (cnt0)
        : "q" (val)
        :
        );
    #else
        __asm__ __volatile__ (
            "1:\n\t"
            "popcnt %5, %1\n\t"
            "popcnt %5, %2\n\t"
            "popcnt %5, %3\n\t"
            "popcnt %5, %4\n\t"
            "subq $4, %0\n\t"
            "jnz 1b\n\t"
        : "+q" (len), "=q" (cnt0), "=q" (cnt1), "=q" (cnt2), "=q" (cnt3)
        : "q" (val)
        :
        );
    #endif
    return cnt0;
}
Kovalex
fuente
Si está cronometrando en ciclos de reloj principales (en lugar de segundos), 1 segundo es tiempo suficiente para un pequeño bucle vinculado a la CPU. Incluso 100 ms está bien para encontrar diferencias importantes o verificar los contadores de rendimiento para los conteos de uop. Especialmente en un Skylake, donde la gestión del estado P del hardware le permite aumentar la velocidad máxima del reloj en microsegundos después de que comienza la carga.
Peter Cordes
clang puede auto-vectorizarse __builtin_popcountlcon AVX2 vpshufb, y no necesita múltiples acumuladores en la fuente C para hacerlo. No estoy seguro de eso _mm_popcnt_u64; eso solo podría auto-vectorizarse con AVX512-VPOPCNT. (Consulte Recuento de 1 bits (recuento de población) en datos grandes con AVX-512 o AVX-2 /)
Peter Cordes
Pero de todos modos, mirar el manual de optimización de Intel no ayudará: como muestra la respuesta aceptada, el problema es una dependencia de salida inesperada popcnt. Esto está documentado en las erratas de Intel para algunas de sus microarquitecturas recientes, pero creo que no estaba en ese momento. El análisis de la cadena de distribución fallará si hay dependencias falsas inesperadas, por lo que esta respuesta es un buen consejo genérico, pero no es aplicable aquí.
Peter Cordes
1
¿Me estás tomando el pelo? No tengo que "creer" en cosas que puedo medir experimentalmente con contadores de rendimiento en un ciclo asm escrito a mano. Son solo hechos. Lo he probado y Skylake arregló la falsa dependencia para lzcnt/ tzcnt, pero no para popcnt. Ver SKL029 fe de erratas de Intel en intel.com/content/dam/www/public/us/en/documents/... . Además, gcc.gnu.org/bugzilla/show_bug.cgi?id=62011 está "resuelto fijo", no "inválido". No hay base para su afirmación de que no hay dependencia de salida en el HW.
Peter Cordes
1
Si realiza un bucle simple como popcnt eax, edx/ dec ecx / jnz, esperaría que se ejecute a 1 por reloj, con un cuello de botella en el rendimiento de popcnt y el rendimiento de la rama tomada. Pero en realidad solo funciona a 1 por cada 3 relojes con un cuello de botella en popcntlatencia por sobrescribir repetidamente EAX, aunque esperaría que fuera de solo escritura. Tienes un Skylake, así que puedes probarlo tú mismo.
Peter Cordes
-3

Ok, quiero proporcionar una pequeña respuesta a una de las subpreguntas que el OP hizo y que no parecen abordarse en las preguntas existentes. Advertencia, no he hecho ninguna prueba o generación de código, o desmontaje, solo quería compartir un pensamiento para que otros puedan exponerlo.

¿Por qué staticcambia el rendimiento?

La línea en cuestión: uint64_t size = atol(argv[1])<<20;

Respuesta corta

Observaría el ensamblaje generado para acceder sizey vería si hay pasos adicionales de indirección de puntero involucrados para la versión no estática.

Respuesta larga

Dado que solo hay una copia de la variable, ya sea que se haya declarado statico no, y el tamaño no cambie, teorizo ​​que la diferencia es la ubicación de la memoria utilizada para respaldar la variable junto con el lugar donde se usa más en el código abajo.

Ok, para comenzar con lo obvio, recuerde que todas las variables locales (junto con los parámetros) de una función tienen espacio en la pila para usar como almacenamiento. Ahora, obviamente, el marco de pila para main () nunca se limpia y solo se genera una vez. Ok, ¿qué hay de hacerlo static? Bueno, en ese caso, el compilador sabe que debe reservar espacio en el espacio de datos global del proceso para que la ubicación no pueda borrarse mediante la eliminación de un marco de pila. Pero aún así, solo tenemos una ubicación, ¿cuál es la diferencia? Sospecho que tiene que ver con cómo se hace referencia a las ubicaciones de memoria en la pila.

Cuando el compilador genera la tabla de símbolos, solo hace una entrada para una etiqueta junto con los atributos relevantes, como el tamaño, etc. Sabe que debe reservar el espacio apropiado en la memoria, pero en realidad no elige esa ubicación hasta un poco más tarde en proceso después de hacer un análisis de vida y posiblemente registrar la asignación. ¿Cómo sabe entonces el enlazador qué dirección proporcionar al código de máquina para el código de ensamblaje final? O conoce la ubicación final o sabe cómo llegar a la ubicación. Con una pila, es bastante simple referirse a una ubicación basada en dos elementos, el puntero al marco de la pila y luego un desplazamiento en el marco. Esto se debe básicamente a que el vinculador no puede conocer la ubicación del stackframe antes del tiempo de ejecución.

Kelly S. French
fuente
2
Me parece mucho más probable que el uso haya staticcambiado la asignación de registros para la función de una manera que afectó la dependencia de salida falsa de popcntlas CPU Intel en las que el OP estaba probando, con un compilador que no sabía cómo evitarlas. (Debido a que aún no se ha descubierto este bache de rendimiento en las CPU de Intel). Un compilador puede mantener una staticvariable local en un registro, al igual que una variable de almacenamiento automático, pero si no optimizan suponer que mainsolo se ejecuta una vez, entonces afectará code-gen (porque el valor lo establece solo la primera llamada).
Peter Cordes
1
De todos modos, la diferencia de rendimiento entre [RIP + rel32]y [rsp + 42]modos de direccionamiento es bastante insignificante para la mayoría de los casos. cmp dword [RIP+rel32], immediateno puede fusionarse en una sola carga + cmp uop, pero no creo que eso sea un factor. Como dije, los bucles internos probablemente permanezcan en un registro de todos modos, pero ajustar el C ++ puede significar diferentes opciones de compilación.
Peter Cordes el