Si cree que una instrucción DIV de 64 bits es una buena forma de dividir entre dos, entonces no es de extrañar que la salida asm del compilador supere su código escrito a mano, incluso con -O0
(compilación rápida, sin optimización adicional y almacenamiento / recarga en la memoria después de / antes de cada instrucción C para que un depurador pueda modificar variables).
Consulte la guía de optimización de ensamblaje de Agner Fog para aprender a escribir un asm eficiente. También tiene tablas de instrucciones y una guía de microarquitectura para obtener detalles específicos para CPU específicas. Ver también elx86 etiqueta wiki para obtener más enlaces de rendimiento.
Vea también esta pregunta más general sobre cómo vencer al compilador con asm escrito a mano: ¿Es el lenguaje ensamblador en línea más lento que el código nativo de C ++? . TL: DR: sí, si lo haces mal (como esta pregunta).
Por lo general, está bien dejando que el compilador haga lo suyo, especialmente si intenta escribir C ++ que pueda compilar de manera eficiente . ¿Ver también es el ensamblaje más rápido que los lenguajes compilados? . Uno de los enlaces de respuestas a estas diapositivas ordenadas muestra cómo varios compiladores de C optimizan algunas funciones realmente simples con trucos geniales. Charla CppCon2017 de Matt Godbolt “ ¿Qué ha hecho mi compilador por mí últimamente? Desatornillar la tapa del compilador ”es similar.
even:
mov rbx, 2
xor rdx, rdx
div rbx
En Intel Haswell, div r64
es de 36 uops, con una latencia de 32-96 ciclos y un rendimiento de uno por cada 21-74 ciclos. (Además de los 2 uops para configurar RBX y cero RDX, pero la ejecución fuera de orden puede ejecutarlos antes). Las instrucciones de conteo alto de UOP como DIV están microcodificadas, lo que también puede causar cuellos de botella en el front-end. En este caso, la latencia es el factor más relevante porque es parte de una cadena de dependencia transportada en bucle.
shr rax, 1
hace la misma división sin signo: es 1 uop, con latencia 1c , y puede ejecutar 2 por ciclo de reloj.
En comparación, la división de 32 bits es más rápida, pero aún horrible frente a los cambios. idiv r32
es de 9 uops, latencia de 22-29c, y uno por rendimiento de 8-11c en Haswell.
Como puede ver al mirar la -O0
salida asm de gcc ( explorador del compilador Godbolt ), solo usa instrucciones de turnos . clang se -O0
compila ingenuamente como pensabas, incluso usando IDIV de 64 bits dos veces. (Al optimizar, los compiladores usan ambas salidas de IDIV cuando la fuente hace una división y módulo con los mismos operandos, si es que usan IDIV)
GCC no tiene un modo totalmente ingenuo; siempre se transforma a través de GIMPLE, lo que significa que algunas "optimizaciones" no se pueden deshabilitar . Esto incluye el reconocimiento de la división por constante y el uso de cambios (potencia de 2) o un inverso multiplicativo de punto fijo (no potencia de 2) para evitar IDIV (ver div_by_13
en el enlace godbolt anterior).
gcc -Os
(optimizar para tamaño) hace uso IDIV para la división no-poder-de-2, por desgracia, incluso en los casos en que el código inverso multiplicativo es sólo ligeramente más grande pero mucho más rápido.
Ayudando al compilador
(resumen para este caso: uso uint64_t n
)
En primer lugar, solo es interesante observar la salida optimizada del compilador. ( -O3
) -O0
la velocidad básicamente no tiene sentido.
Mire su salida asm (en Godbolt, o vea ¿Cómo eliminar el "ruido" de la salida del conjunto GCC / clang? ). Cuando el compilador no crea un código óptimo en primer lugar: escribir su fuente C / C ++ de una manera que guíe al compilador a hacer un mejor código suele ser el mejor enfoque . Tienes que saber asm y saber qué es eficiente, pero aplicas este conocimiento indirectamente. Los compiladores también son una buena fuente de ideas: a veces el sonido metálico hará algo genial, y puedes hacer que gcc haga lo mismo: mira esta respuesta y lo que hice con el bucle no desenrollado en el código de @ Veedrac a continuación).
Este enfoque es portátil, y en 20 años algún compilador futuro puede compilarlo para lo que sea eficiente en el hardware futuro (x86 o no), tal vez usando una nueva extensión ISA o auto-vectorización. El asm x86-64 escrito a mano de hace 15 años generalmente no se sintonizaría de manera óptima para Skylake. por ejemplo, la macro fusión de comparación y ramificación no existía en ese entonces Lo que es óptimo ahora para un asm hecho a mano para una microarquitectura podría no ser óptimo para otras CPU actuales y futuras. Los comentarios sobre la respuesta de @johnfound discuten las principales diferencias entre AMD Bulldozer e Intel Haswell, que tienen un gran efecto en este código. Pero en teoría, g++ -O3 -march=bdver3
y g++ -O3 -march=skylake
hará lo correcto. (O. -march=native
) O -mtune=...
simplemente para sintonizar, sin usar instrucciones que otras CPU podrían no admitir.
Mi opinión es que guiar el compilador para que sea bueno para una CPU actual que le interesa no debería ser un problema para futuros compiladores. Es de esperar que sean mejores que los compiladores actuales para encontrar formas de transformar el código, y pueden encontrar una manera que funcione para futuras CPU. De todos modos, el futuro x86 probablemente no será terrible en nada que sea bueno en el x86 actual, y el compilador futuro evitará cualquier escollo específico de asm mientras implementa algo como el movimiento de datos de su fuente C, si no ve algo mejor.
El asm escrito a mano es un recuadro negro para el optimizador, por lo que la propagación constante no funciona cuando la inserción hace que una entrada sea una constante en tiempo de compilación. Otras optimizaciones también se ven afectadas. Lea https://gcc.gnu.org/wiki/DontUseInlineAsm antes de usar asm. (Y evite el asm en línea de estilo MSVC: las entradas / salidas tienen que pasar por la memoria que agrega sobrecarga ).
En este caso : n
tiene un tipo con signo y gcc usa la secuencia SAR / SHR / ADD que proporciona el redondeo correcto. (IDIV y arithmetic-shift "round" de manera diferente para las entradas negativas, consulte la entrada manual de la referencia del conjunto SAR insn ). (IDK si gcc intentó y no pudo demostrar que n
no puede ser negativo, o qué. El desbordamiento firmado es un comportamiento indefinido, por lo que debería haber sido capaz).
Deberías haberlo usado uint64_t n
, por lo que solo puede SHR. Y, por lo tanto, es portátil para sistemas donde long
solo es de 32 bits (por ejemplo, Windows x86-64).
Por cierto, la salida asm optimizada de gcc se ve bastante bien (usando unsigned long n
) : el bucle interno en el que se alinea main()
hace esto:
# from gcc5.4 -O3 plus my comments
# edx= count=1
# rax= uint64_t n
.L9: # do{
lea rcx, [rax+1+rax*2] # rcx = 3*n + 1
mov rdi, rax
shr rdi # rdi = n>>1;
test al, 1 # set flags based on n%2 (aka n&1)
mov rax, rcx
cmove rax, rdi # n= (n%2) ? 3*n+1 : n/2;
add edx, 1 # ++count;
cmp rax, 1
jne .L9 #}while(n!=1)
cmp/branch to update max and maxi, and then do the next n
El bucle interno no tiene ramificaciones, y la ruta crítica de la cadena de dependencia transportada por el bucle es:
- LEA de 3 componentes (3 ciclos)
- cmov (2 ciclos en Haswell, 1c en Broadwell o posterior).
Total: 5 ciclos por iteración, cuello de botella de latencia . La ejecución fuera de orden se encarga de todo lo demás en paralelo con esto (en teoría: no he probado con contadores de rendimiento para ver si realmente funciona a 5c / iter).
La entrada FLAGS de cmov
(producida por TEST) es más rápida de producir que la entrada RAX (de LEA-> MOV), por lo que no está en la ruta crítica.
Del mismo modo, el MOV-> SHR que produce la entrada RDI de CMOV está fuera del camino crítico, porque también es más rápido que el LEA. MOV en IvyBridge y más tarde tiene latencia cero (manejado en el momento de cambio de nombre de registro). (Todavía se necesita una subida y una ranura en la tubería, por lo que no es gratis, solo latencia cero). El MOV adicional en la cadena LEA dep es parte del cuello de botella en otras CPU.
El cmp / jne tampoco es parte de la ruta crítica: no se lleva en bucle, porque las dependencias de control se manejan con predicción de rama + ejecución especulativa, a diferencia de las dependencias de datos en la ruta crítica.
Venciendo al compilador
GCC hizo un buen trabajo aquí. Podría guardar un byte de código usando en inc edx
lugar deadd edx, 1
, porque a nadie le importa P4 y sus dependencias falsas para las instrucciones de modificación de bandera parcial.
También podría guardar todas las instrucciones MOV, y la PRUEBA: SHR establece CF = el bit desplazado, por lo que podemos usar en cmovc
lugar de test
/ cmovz
.
### Hand-optimized version of what gcc does
.L9: #do{
lea rcx, [rax+1+rax*2] # rcx = 3*n + 1
shr rax, 1 # n>>=1; CF = n&1 = n%2
cmovc rax, rcx # n= (n&1) ? 3*n+1 : n/2;
inc edx # ++count;
cmp rax, 1
jne .L9 #}while(n!=1)
Vea la respuesta de @ johnfound para otro truco inteligente: elimine el CMP ramificándose en el resultado de la bandera de SHR y utilizándolo para CMOV: cero solo si n era 1 (o 0) para comenzar. (Dato curioso : ¡ SHR con conteo! = 1 en Nehalem o anterior causa un bloqueo si lees los resultados de la bandera . Así es como lo hicieron single-uop. Sin embargo, la codificación especial shift-por-1 está bien).
Evitar MOV no ayuda con la latencia en absoluto en Haswell ( ¿Puede el MOV de x86 ser realmente "gratis"? ¿Por qué no puedo reproducir esto en absoluto? ). Ayuda significativamente en CPU como Intel pre-IvB y AMD Bulldozer-family, donde MOV no tiene latencia cero. Las instrucciones MOV desperdiciadas del compilador afectan la ruta crítica. El complejo LEA y CMOV de BD tienen latencia más baja (2c y 1c respectivamente), por lo que es una fracción mayor de la latencia. Además, los cuellos de botella de rendimiento se convierten en un problema, ya que solo tiene dos tuberías ALU enteras. Vea la respuesta de @ johnfound , donde tiene resultados de sincronización de una CPU AMD.
Incluso en Haswell, esta versión puede ayudar un poco al evitar algunos retrasos ocasionales en los que un uop no crítico roba un puerto de ejecución de uno en la ruta crítica, retrasando la ejecución en 1 ciclo. (Esto se llama un conflicto de recursos). También guarda un registro, lo que puede ayudar al hacer múltiples n
valores en paralelo en un bucle intercalado (ver más abajo).
La latencia de LEA depende del modo de direccionamiento , en las CPU de la familia Intel SnB. 3c para 3 componentes ( [base+idx+const]
que toma dos adiciones separadas), pero solo 1c con 2 o menos componentes (una adición). Algunas CPU (como Core2) hacen incluso una LEA de 3 componentes en un solo ciclo, pero la familia SnB no lo hace. Peor aún, la familia Intel SnB estandariza las latencias para que no haya 2c uops , de lo contrario, la LEA de 3 componentes sería solo 2c como Bulldozer. (LEA de 3 componentes también es más lento en AMD, pero no tanto).
Entonces lea rcx, [rax + rax*2]
/ inc rcx
es solo 2c latencia, más rápido que lea rcx, [rax + rax*2 + 1]
, en CPUs Intel SnB-family como Haswell. Punto de equilibrio en BD, y peor en Core2. Cuesta una uop adicional, que normalmente no vale la pena para ahorrar 1c de latencia, pero la latencia es el principal cuello de botella aquí y Haswell tiene una tubería lo suficientemente amplia como para manejar el rendimiento adicional de la uop.
Ni gcc, icc, ni clang (en godbolt) usaron la salida CF de SHR, siempre usando un AND o TEST . Compiladores tontos. : P Son grandes piezas de maquinaria compleja, pero un humano inteligente a menudo puede vencerlos en problemas a pequeña escala. (¡Por supuesto, dado miles o millones de veces más de tiempo para pensarlo! Los compiladores no usan algoritmos exhaustivos para buscar todas las formas posibles de hacer las cosas, porque eso tomaría demasiado tiempo al optimizar una gran cantidad de código en línea, que es lo que lo hacen mejor. Tampoco modelan la tubería en la microarquitectura objetivo, al menos no con el mismo detalle que IACA u otras herramientas de análisis estático; solo usan algunas heurísticas).
El desenrollado de bucle simple no ayudará ; este bucle cuellos de botella en la latencia de una cadena de dependencia transportada en bucle, no en la sobrecarga / rendimiento del bucle. Esto significa que funcionaría bien con hyperthreading (o cualquier otro tipo de SMT), ya que la CPU tiene mucho tiempo para intercalar instrucciones de dos hilos. Esto significaría paralelizar el ciclo main
, pero está bien porque cada subproceso puede simplemente verificar un rango de n
valores y producir un par de enteros como resultado.
El intercalado a mano dentro de un solo hilo también podría ser viable . Tal vez calcule la secuencia para un par de números en paralelo, ya que cada uno solo toma un par de registros, y todos pueden actualizar el mismo max
/ maxi
. Esto crea más paralelismo a nivel de instrucción .
El truco consiste en decidir si esperar hasta que todos los n
valores hayan alcanzado 1
antes de obtener otro par de n
valores iniciales , o si romper y obtener un nuevo punto de inicio para solo uno que haya alcanzado la condición final, sin tocar los registros para la otra secuencia. Probablemente sea mejor mantener cada cadena trabajando en datos útiles, de lo contrario, tendría que incrementar condicionalmente su contador.
Tal vez incluso podría hacer esto con cosas comparadas con SSE para incrementar condicionalmente el contador de elementos vectoriales que n
aún no se 1
han alcanzado . Y luego, para ocultar la latencia aún más larga de una implementación de incremento condicional SIMD, necesitaría mantener más vectores de n
valores en el aire. Tal vez solo valga con 256b vector (4x uint64_t
).
Creo que la mejor estrategia para detectar un 1
"pegajoso" es enmascarar el vector de todos los que agregas para incrementar el contador. Entonces, después de haber visto un 1
en un elemento, el vector de incremento tendrá un cero, y + = 0 es un no-op.
Idea no probada para vectorización manual
# starting with YMM0 = [ n_d, n_c, n_b, n_a ] (64-bit elements)
# ymm4 = _mm256_set1_epi64x(1): increment vector
# ymm5 = all-zeros: count vector
.inner_loop:
vpaddq ymm1, ymm0, xmm0
vpaddq ymm1, ymm1, xmm0
vpaddq ymm1, ymm1, set1_epi64(1) # ymm1= 3*n + 1. Maybe could do this more efficiently?
vprllq ymm3, ymm0, 63 # shift bit 1 to the sign bit
vpsrlq ymm0, ymm0, 1 # n /= 2
# FP blend between integer insns may cost extra bypass latency, but integer blends don't have 1 bit controlling a whole qword.
vpblendvpd ymm0, ymm0, ymm1, ymm3 # variable blend controlled by the sign bit of each 64-bit element. I might have the source operands backwards, I always have to look this up.
# ymm0 = updated n in each element.
vpcmpeqq ymm1, ymm0, set1_epi64(1)
vpandn ymm4, ymm1, ymm4 # zero out elements of ymm4 where the compare was true
vpaddq ymm5, ymm5, ymm4 # count++ in elements where n has never been == 1
vptest ymm4, ymm4
jnz .inner_loop
# Fall through when all the n values have reached 1 at some point, and our increment vector is all-zero
vextracti128 ymm0, ymm5, 1
vpmaxq .... crap this doesn't exist
# Actually just delay doing a horizontal max until the very very end. But you need some way to record max and maxi.
Puede y debe implementar esto con intrínsecos en lugar de asm escritos a mano.
Mejora algorítmica / de implementación:
Además de implementar la misma lógica con un sistema asm más eficiente, busque formas de simplificar la lógica o evitar el trabajo redundante. por ejemplo, memorizar para detectar terminaciones comunes a secuencias. O incluso mejor, mire 8 bits finales a la vez (respuesta de gnasher)
@EOF señala que tzcnt
(o bsf
) podría usarse para hacer múltiples n/=2
iteraciones en un solo paso. Eso es probablemente mejor que la vectorización SIMD; ninguna instrucción SSE o AVX puede hacer eso. Sin embargo, todavía es compatible con hacer múltiples escalares n
en paralelo en diferentes registros enteros.
Entonces el bucle podría verse así:
goto loop_entry; // C++ structured like the asm, for illustration only
do {
n = n*3 + 1;
loop_entry:
shift = _tzcnt_u64(n);
n >>= shift;
count += shift;
} while(n != 1);
Esto puede hacer muchas menos iteraciones, pero los cambios de conteo variable son lentos en las CPU de la familia Intel SnB sin BMI2. 3 uops, 2c latencia. (Tienen una dependencia de entrada en las FLAGS porque count = 0 significa que las banderas no están modificadas. Manejan esto como una dependencia de datos, y toman múltiples uops porque un uop solo puede tener 2 entradas (de todos modos pre-HSW / BDW)). Este es el tipo al que se refieren las personas que se quejan del diseño loco-CISC de x86. Hace que las CPU x86 sean más lentas de lo que serían si el ISA fuera diseñado desde cero hoy, incluso de una manera similar. (es decir, esto es parte del "impuesto x86" que cuesta velocidad / potencia). SHRX / SHLX / SARX (BMI2) son una gran victoria (1 uop / 1c de latencia).
También coloca tzcnt (3c en Haswell y posterior) en la ruta crítica, por lo que alarga significativamente la latencia total de la cadena de dependencia transportada en bucle. Sin embargo, elimina cualquier necesidad de un CMOV o de preparar una tenencia de registro n>>1
. La respuesta de @ Veedrac supera todo esto al diferir el tzcnt / shift para múltiples iteraciones, lo cual es altamente efectivo (ver más abajo).
Podemos usar BSF o TZCNT de manera intercambiable, porque n
nunca puede ser cero en ese punto. El código de máquina de TZCNT decodifica como BSF en CPU que no admiten BMI1. (Los prefijos sin sentido se ignoran, por lo que REP BSF se ejecuta como BSF).
TZCNT funciona mucho mejor que BSF en las CPU AMD que lo admiten, por lo que puede ser una buena idea usarlo REP BSF
, incluso si no le importa configurar ZF si la entrada es cero en lugar de la salida. Algunos compiladores hacen esto cuando lo usas __builtin_ctzll
incluso con -mno-bmi
.
Realizan lo mismo en las CPU de Intel, así que solo guarde el byte si eso es todo lo que importa. TZCNT en Intel (pre-Skylake) todavía tiene una dependencia falsa en el operando de salida supuestamente de solo escritura, al igual que BSF, para soportar el comportamiento indocumentado de que BSF con input = 0 deja su destino sin modificar. Por lo tanto, debe solucionarlo a menos que optimice solo para Skylake, por lo que no hay nada que ganar con el byte REP adicional. (Intel a menudo va más allá de lo que requiere el manual x86 ISA, para evitar romper el código ampliamente utilizado que depende de algo que no debería, o que se rechaza retroactivamente. Por ejemplo, Windows 9x asume que no hay captación previa especulativa de entradas TLB , lo cual era seguro cuando se escribió el código, antes de que Intel actualizara las reglas de administración de TLB ).
De todos modos, LZCNT / TZCNT en Haswell tienen la misma información falsa que POPCNT: vea estas preguntas y respuestas . Es por eso que en la salida asm de gcc para el código de @ Veedrac, lo ve rompiendo la cadena dep con xor-zeroing en el registro que está a punto de usar como destino de TZCNT cuando no usa dst = src. Dado que TZCNT / LZCNT / POPCNT nunca dejan su destino indefinido o sin modificar, esta falsa dependencia de la salida en las CPU de Intel es un error / limitación de rendimiento. Presumiblemente, vale la pena que algunos transistores / potencia se comporten como otros uops que van a la misma unidad de ejecución. La única ventaja es la interacción con otra limitación de uarch: pueden microfundir un operando de memoria con un modo de direccionamiento indexado en Haswell, pero en Skylake, donde Intel eliminó la falsa dep para LZCNT / TZCNT, "deslaminaron" los modos de direccionamiento indexado, mientras que POPCNT aún puede micro-fusionar cualquier modo adicional.
Mejoras a ideas / código de otras respuestas:
La respuesta de @ hidefromkgb tiene una buena observación de que está garantizado que podrá hacer un cambio correcto después de 3n + 1. Puede calcular esto de manera aún más eficiente que simplemente omitir las comprobaciones entre los pasos. Sin embargo, la implementación de asm en esa respuesta está rota (depende de OF, que no está definida después de SHRD con un conteo> 1), y lenta: ROR rdi,2
es más rápida que SHRD rdi,rdi,2
, y el uso de dos instrucciones CMOV en la ruta crítica es más lento que una PRUEBA adicional eso puede correr en paralelo.
Puse C ordenada / mejorada (que guía al compilador para producir mejores asm), y probé + trabajando asm más rápido (en los comentarios debajo de la C) en Godbolt: vea el enlace en la respuesta de @ hidefromkgb . (Esta respuesta alcanzó el límite de 30k char de las URL de Godbolt grandes, pero los enlaces cortos pueden pudrirse y eran demasiado largos para goo.gl de todos modos).
También mejoró la impresión de salida para convertirla en una cadena y hacer una en write()
lugar de escribir una char a la vez. Esto minimiza el impacto en el cronometraje de todo el programa con perf stat ./collatz
(para registrar contadores de rendimiento), y quité la ofuscación de algunos de los elementos no críticos.
@ Código de Veedrac
Obtuve una aceleración menor al cambiar a la derecha todo lo que sabemos que se necesita hacer y verificar para continuar el ciclo. Desde 7.5s para limit = 1e8 hasta 7.275s, en Core2Duo (Merom), con un factor de desenrollado de 16.
código + comentarios en Godbolt . No uses esta versión con clang; hace algo tonto con el bucle diferido. El uso de un contador tmp k
y luego agregarlo a count
más tarde cambia lo que hace el sonido metálico, pero eso perjudica ligeramente a gcc.
Vea la discusión en los comentarios: el código de Veedrac es excelente en CPU con BMI1 (es decir, no Celeron / Pentium)