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.
fuente
Respuestas:
Culpable: Dependencia de datos falsos (y el compilador ni siquiera lo sabe)
En los procesadores Sandy / Ivy Bridge y Haswell, la instrucción:
parece tener una dependencia falsa en el registro de destino
dest
. Aunque la instrucción solo escribe en ella, la instrucción esperará hasta quedest
esté lista antes de ejecutarse. Esta falsa dependencia está (ahora) documentada por Intel como erratum HSD146 (Haswell) y SKL029 (Skylake)Skylake arregló esto para
lzcnt
ytzcnt
.Cannon Lake (y Ice Lake) arreglaron esto
popcnt
.bsf
/bsr
tiene 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
popcnt
s 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
unsigned
vs.uint64_t
y 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.
popcnt
-add
-popcnt
-popcnt
→ siguiente iteraciónpopcnt
-add
-popcnt
-add
→ siguiente iteraciónpopcnt
-popcnt
→ siguiente iteraciónpopcnt
-popcnt
→ siguiente iteraciónLa 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
count
variable 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)
g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
Diferentes registros: 18.6195 GB / s
Mismo registro: 8.49272 GB / s
Mismo registro con cadena rota: 17.8869 GB / s
Entonces, ¿qué salió mal con el compilador?
Parece que ni GCC ni Visual Studio son conscientes de que
popcnt
tiene una dependencia tan falsa. Sin embargo, estas falsas dependencias no son infrecuentes. Es solo cuestión de si el compilador lo sabe.popcnt
No 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
/bsr
el 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:
Un punto de referencia igualmente interesante se puede encontrar aquí: http://pastebin.com/kbzgL8si
Este punto de referencia varía el número de
popcnt
correos electrónicos que se encuentran en la cadena (falsa) de dependencia.fuente
imul dst, src, imm
no tiene una dependencia de salida, y tampoco lo hace lentolea
. Tampoco lo hacepdep
, 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.Codifiqué un programa C equivalente para experimentar, y puedo confirmar este extraño comportamiento. Además,
gcc
cree que el número entero de 64 bits (que probablemente debería ser desize_t
todos modos ...) es mejor, ya que el usouint_fast32_t
hace 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).
fuente
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.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;
sonar con
uint64_t size=1<<20;
También intenté:
for
declaració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_t
versió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.
fuente
12.9
y16.8
, por lo queunsigned
es más rápido aquí. En mi punto de referencia, lo contrario fue el caso, es decir, 26 porunsigned
, 15 poruint64_t
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:
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
popcnt
cá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.
fuente
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 ++.
código de ensamblaje: r10 = bfrptr, r15 = bfrend, rsi = count, rdi = buffer, r13 = k:
fuente
¿Has intentado pasar
-funroll-loops -fprefetch-loop-arrays
a GCC?Obtengo los siguientes resultados con estas optimizaciones adicionales:
fuente
¿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:
También tiene algún alias extraño, que no estoy seguro de que cumpla con las estrictas reglas de alias.
fuente
void*
ychar*
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.char*
para acceder aT[]
. No puede utilizar a de forma seguraT*
para acceder achar[]
, y su código parece hacer lo último.malloc
una variedad de cosas, ya que malloc regresavoid*
y lo interpretas comoT[]
. Y estoy bastante seguro de esovoid*
ychar*
tenía la misma semántica con respecto al alias estricto. Sin embargo, supongo que esto es bastante tópico aquí :)uint64_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 */
TL; DR: utilice
__builtin
intrínsecos en su lugar; podrían pasar a ayudar.Pude hacer que
gcc
4.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
objdump
salida parece compartir mis puntos de vista. Uso algunos otros trucos (++i
vsi++
) para hacer que el compilador se desenrolle sin ningúnmovl
instrucción (comportamiento extraño, debo decir).Resultados:
Código de evaluación comparativa:
Opciones de compilación:
Versión de GCC:
Versión del kernel de Linux:
Información de la CPU:
fuente
-funroll-loops
código no se cuele en una cadena de dependencia de bucle creada porpopcnt
el 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 unxor edx,edx
para romper la cadena de dependencia.x86intrin.h
las_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.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.
fuente
__builtin_popcountl
con AVX2vpshufb
, 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 /)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í.lzcnt
/tzcnt
, pero no parapopcnt
. 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.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 enpopcnt
latencia por sobrescribir repetidamente EAX, aunque esperaría que fuera de solo escritura. Tienes un Skylake, así que puedes probarlo tú mismo.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é
static
cambia el rendimiento?La línea en cuestión:
uint64_t size = atol(argv[1])<<20;
Respuesta corta
Observaría el ensamblaje generado para acceder
size
y 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
static
o 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.
fuente
static
cambiado la asignación de registros para la función de una manera que afectó la dependencia de salida falsa depopcnt
las 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 unastatic
variable local en un registro, al igual que una variable de almacenamiento automático, pero si no optimizan suponer quemain
solo se ejecuta una vez, entonces afectará code-gen (porque el valor lo establece solo la primera llamada).[RIP + rel32]
y[rsp + 42]
modos de direccionamiento es bastante insignificante para la mayoría de los casos.cmp dword [RIP+rel32], immediate
no 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.