Estaba buscando la forma más rápida de obtener popcount
grandes conjuntos de datos. Encontré un efecto muy extraño : cambiar la variable de bucle de unsigned
a uint64_t
hizo 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 x
megabytes donde x
se lee desde la línea de comandos. Luego, iteramos sobre el búfer y usamos una versión desenrollada del x86 popcount
intrí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_t
versión es solo la mitad del de la unsigned
versió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 26
a 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 static
antes 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 u64
al menos de la versión de 13 GB / s a la versión de 20 GB / s! En la PC de mi colega, la u64
versión se volvió aún más rápida que la u32
versió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
u32
yu64
? - ¿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
static
palabra claveu64
acelerar 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 jb
para 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 static
palabra 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.