Código C ++ para probar la conjetura de Collatz más rápido que el ensamblaje escrito a mano.


833

Escribí estas dos soluciones para el Proyecto Euler Q14 , en ensamblaje y en C ++. Son el mismo enfoque de fuerza bruta idéntico para probar la conjetura de Collatz . La solución de ensamblaje se ensambló con

nasm -felf64 p14.asm && gcc p14.o -o p14

El C ++ fue compilado con

g++ p14.cpp -o p14

Montaje, p14.asm

section .data
    fmt db "%d", 10, 0

global main
extern printf

section .text

main:
    mov rcx, 1000000
    xor rdi, rdi        ; max i
    xor rsi, rsi        ; i

l1:
    dec rcx
    xor r10, r10        ; count
    mov rax, rcx

l2:
    test rax, 1
    jpe even

    mov rbx, 3
    mul rbx
    inc rax
    jmp c1

even:
    mov rbx, 2
    xor rdx, rdx
    div rbx

c1:
    inc r10
    cmp rax, 1
    jne l2

    cmp rdi, r10
    cmovl rdi, r10
    cmovl rsi, rcx

    cmp rcx, 2
    jne l1

    mov rdi, fmt
    xor rax, rax
    call printf
    ret

C ++, p14.cpp

#include <iostream>

using namespace std;

int sequence(long n) {
    int count = 1;
    while (n != 1) {
        if (n % 2 == 0)
            n /= 2;
        else
            n = n*3 + 1;

        ++count;
    }

    return count;
}

int main() {
    int max = 0, maxi;
    for (int i = 999999; i > 0; --i) {
        int s = sequence(i);
        if (s > max) {
            max = s;
            maxi = i;
        }
    }

    cout << maxi << endl;
}

Conozco las optimizaciones del compilador para mejorar la velocidad y todo, pero no veo muchas maneras de optimizar aún más mi solución de ensamblaje (hablando programáticamente, no matemáticamente).

El código C ++ tiene un módulo en cada término y una división en cada término par, donde el ensamblaje es solo una división por término par.

Pero el ensamblaje tarda en promedio 1 segundo más que la solución C ++. ¿Por qué es esto? Estoy preguntando por curiosidad principalmente.

Tiempos de ejecución

Mi sistema: Linux de 64 bits en 1.4 GHz Intel Celeron 2955U (microarquitectura Haswell).


232
¿Ha examinado el código de ensamblaje que GCC genera para su programa C ++?
ruakh

69
Compile con -Spara obtener el ensamblado que generó el compilador. El compilador es lo suficientemente inteligente como para darse cuenta de que el módulo hace la división al mismo tiempo.
user3386109

267
Creo que sus opciones son 1. Su técnica de medición es defectuosa, 2. El compilador escribe mejor ensamblaje que usted, o 3. El compilador usa magia.
Galik


18
@jefferson El compilador puede usar una fuerza bruta más rápida. Por ejemplo, tal vez con instrucciones SSE.
user253751

Respuestas:


1896

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 el 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 r64es 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, 1hace 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 r32es de 9 uops, latencia de 22-29c, y uno por rendimiento de 8-11c en Haswell.


Como puede ver al mirar la -O0salida asm de gcc ( explorador del compilador Godbolt ), solo usa instrucciones de turnos . clang se -O0compila 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_13en 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) -O0la 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=bdver3y g++ -O3 -march=skylakehará 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 : ntiene 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 nno 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 longsolo 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 edxlugar 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 cmovclugar 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 nvalores 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 rcxes 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 nvalores 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 nvalores hayan alcanzado 1antes de obtener otro par de nvalores 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 naún no se 1han 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 nvalores 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 1en 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/=2iteraciones 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 nen 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 nnunca 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_ctzllincluso 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,2es 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 ky luego agregarlo a countmá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)


44
Probé el enfoque vectorizado hace un tiempo, no ayudó (porque puedes hacer mucho mejor en el código escalar tzcnty estás bloqueado en la secuencia más larga entre tus elementos vectoriales en el caso vectorizado).
EOF

3
@EOF: No, quiero decir romper el bucle interior cuando cualquier uno de los éxitos elementos del vector 1, en lugar de cuando todos tienen (fácilmente detectable con PCMPEQ / PMOVMSK). Luego usa PINSRQ y otras cosas para jugar con el elemento que terminó (y sus contadores), y volver al ciclo. Eso puede convertirse fácilmente en una pérdida, cuando sales del bucle interno con demasiada frecuencia, pero significa que siempre obtienes 2 o 4 elementos de trabajo útil realizado en cada iteración del bucle interno. Buen punto sobre la memorización, sin embargo.
Peter Cordes

44
@jefferson Lo mejor que logré es godbolt.org/g/1N70Ib . Esperaba poder hacer algo más inteligente, pero parece que no.
Veedrac el

87
Lo que me sorprende de respuestas increíbles como esta es el conocimiento que se muestra con tanto detalle. Nunca sabré un idioma o sistema a ese nivel y no sabría cómo. Bien hecho señor.
camden_kid

8
¡Respuesta legendaria!
Sumit Jain

104

Afirmar que el compilador de C ++ puede producir un código más óptimo que un programador de lenguaje ensamblador competente es un error muy grave. Y especialmente en este caso. El humano siempre puede hacer que el código sea mejor que el compilador, y esta situación particular es una buena ilustración de esta afirmación.

La diferencia de tiempo que está viendo es porque el código de ensamblaje en la pregunta está muy lejos de ser óptimo en los bucles internos.

(El siguiente código es de 32 bits, pero se puede convertir fácilmente a 64 bits)

Por ejemplo, la función de secuencia se puede optimizar a solo 5 instrucciones:

    .seq:
        inc     esi                 ; counter
        lea     edx, [3*eax+1]      ; edx = 3*n+1
        shr     eax, 1              ; eax = n/2
        cmovc   eax, edx            ; if CF eax = edx
        jnz     .seq                ; jmp if n<>1

Todo el código se ve así:

include "%lib%/freshlib.inc"
@BinaryType console, compact
options.DebugMode = 1
include "%lib%/freshlib.asm"

start:
        InitializeAll
        mov ecx, 999999
        xor edi, edi        ; max
        xor ebx, ebx        ; max i

    .main_loop:

        xor     esi, esi
        mov     eax, ecx

    .seq:
        inc     esi                 ; counter
        lea     edx, [3*eax+1]      ; edx = 3*n+1
        shr     eax, 1              ; eax = n/2
        cmovc   eax, edx            ; if CF eax = edx
        jnz     .seq                ; jmp if n<>1

        cmp     edi, esi
        cmovb   edi, esi
        cmovb   ebx, ecx

        dec     ecx
        jnz     .main_loop

        OutputValue "Max sequence: ", edi, 10, -1
        OutputValue "Max index: ", ebx, 10, -1

        FinalizeAll
        stdcall TerminateAll, 0

Para compilar este código, se necesita FreshLib .

En mis pruebas (procesador AMD A4-1200 de 1 GHz), el código anterior es aproximadamente cuatro veces más rápido que el código C ++ de la pregunta (cuando se compila con -O0: 430 ms frente a 1900 ms), y más de dos veces más rápido (430 ms frente a 830 ms) cuando se compila el código C ++ -O3.

La salida de ambos programas es la misma: secuencia máxima = 525 en i = 837799.


66
Huh, eso es inteligente. SHR establece ZF solo si EAX fue 1 (o 0). Me perdí eso al optimizar la -O3salida de gcc , pero sí detecté todas las otras optimizaciones que hiciste en el bucle interno. (¿Pero por qué usas LEA para el incremento de contador en lugar de INC? Está bien golpear banderas en ese punto, y llevar a una desaceleración en cualquier cosa excepto tal vez P4 (dependencia falsa en banderas viejas tanto para INC como para SHR). LEA puede ' No se ejecuta en tantos puertos, y podría conducir a conflictos de recursos que retrasen la ruta crítica con más frecuencia.)
Peter Cordes

44
Oh, en realidad Bulldozer podría tener un cuello de botella en el rendimiento con la salida del compilador. Tiene CMOV de latencia más baja y LEA de 3 componentes que Haswell (que estaba considerando), por lo que la cadena de dep transportada en bucle solo tiene 3 ciclos en su código. Tampoco tiene instrucciones MOV de latencia cero para registros enteros, por lo que las instrucciones MOV desperdiciadas de g ++ en realidad aumentan la latencia de la ruta crítica y son un gran problema para Bulldozer. Entonces, sí, la optimización manual realmente supera al compilador de manera significativa para las CPU que no son lo suficientemente modernas como para leer las instrucciones inútiles.
Peter Cordes

95
" Reclamar mejor el compilador de C ++ es un error muy grave. Y especialmente en este caso. El humano siempre puede mejorar el código y este problema en particular es una buena ilustración de esta afirmación " . Puede revertirlo y sería igual de válido . " Reclamar un ser humano es mejor es muy grave error. Y especialmente en este caso. El ser humano siempre se puede hacer que el código es peor que la de este particular y la pregunta es buen ejemplo de esta afirmación. " Así que no creo que usted tiene un punto aquí , tales generalizaciones están mal.
luk32

55
@ luk32 - Pero el autor de la pregunta no puede ser un argumento en absoluto, porque su conocimiento del lenguaje ensamblador es cercano a cero. Todos los argumentos sobre humano vs compilador, implícitamente suponen humano con al menos algún nivel medio de conocimiento asm. Más: el teorema "El código humano escrito siempre será mejor o igual que el código generado por el compilador" es muy fácil de probar formalmente.
johnfound

30
@ luk32: Un humano experto puede (y generalmente debería) comenzar con la salida del compilador. Entonces, siempre que compares tus intentos de asegurarte de que sean realmente más rápidos (en el hardware de destino para el que estás ajustando), no puedes hacerlo peor que el compilador. Pero sí, tengo que aceptar que es una declaración un poco fuerte. Los compiladores generalmente funcionan mucho mejor que los codificadores asm novatos. Pero generalmente es posible guardar una o dos instrucciones en comparación con los compiladores. (No siempre en la ruta crítica, sin embargo, dependiendo de uarch). Son piezas muy útiles de maquinaria compleja, pero no son "inteligentes".
Peter Cordes

24

Para obtener más rendimiento: un cambio simple es observar que después de n = 3n + 1, n será par, por lo que puede dividir por 2 inmediatamente. Y n no será 1, por lo que no necesita probarlo. Por lo tanto, puede guardar algunas declaraciones if y escribir:

while (n % 2 == 0) n /= 2;
if (n > 1) for (;;) {
    n = (3*n + 1) / 2;
    if (n % 2 == 0) {
        do n /= 2; while (n % 2 == 0);
        if (n == 1) break;
    }
}

Aquí hay una gran victoria: si observa los 8 bits más bajos de n, todos los pasos hasta que los divida entre 2 y ocho veces están completamente determinados por esos ocho bits. Por ejemplo, si los últimos ocho bits son 0x01, eso es en binario, ¿su número es ???? 0000 0001, los siguientes pasos son:

3n+1 -> ???? 0000 0100
/ 2  -> ???? ?000 0010
/ 2  -> ???? ??00 0001
3n+1 -> ???? ??00 0100
/ 2  -> ???? ???0 0010
/ 2  -> ???? ???? 0001
3n+1 -> ???? ???? 0100
/ 2  -> ???? ???? ?010
/ 2  -> ???? ???? ??01
3n+1 -> ???? ???? ??00
/ 2  -> ???? ???? ???0
/ 2  -> ???? ???? ????

Por lo tanto, todos estos pasos se pueden predecir, y 256k + 1 se reemplaza por 81k + 1. Algo similar ocurrirá en todas las combinaciones. Entonces puede hacer un bucle con una gran instrucción de cambio:

k = n / 256;
m = n % 256;

switch (m) {
    case 0: n = 1 * k + 0; break;
    case 1: n = 81 * k + 1; break; 
    case 2: n = 81 * k + 1; break; 
    ...
    case 155: n = 729 * k + 425; break;
    ...
}

Ejecute el ciclo hasta n ≤ 128, porque en ese punto n podría convertirse en 1 con menos de ocho divisiones por 2, y hacer ocho o más pasos a la vez le haría perder el punto donde llega a 1 por primera vez. Luego continúe con el ciclo "normal" o prepare una tabla que le indique cuántos pasos más se necesitan para llegar a 1.

PD. Sospecho firmemente que la sugerencia de Peter Cordes lo haría aún más rápido. No habrá ramas condicionales en absoluto, excepto una, y esa se predecirá correctamente, excepto cuando el ciclo realmente finalice. Entonces el código sería algo como

static const unsigned int multipliers [256] = { ... }
static const unsigned int adders [256] = { ... }

while (n > 128) {
    size_t lastBits = n % 256;
    n = (n >> 8) * multipliers [lastBits] + adders [lastBits];
}

En la práctica, mediría si el procesamiento de los últimos 9, 10, 11, 12 bits de n a la vez sería más rápido. Para cada bit, el número de entradas en la tabla se duplicaría, y excedo una desaceleración cuando las tablas ya no caben en el caché L1.

PPS Si necesita el número de operaciones: en cada iteración hacemos exactamente ocho divisiones por dos, y un número variable de operaciones (3n + 1), por lo que un método obvio para contar las operaciones sería otra matriz. Pero en realidad podemos calcular el número de pasos (en función del número de iteraciones del bucle).

Podríamos redefinir el problema ligeramente: Reemplace n con (3n + 1) / 2 si es impar, y reemplace n con n / 2 si es par. Luego, cada iteración hará exactamente 8 pasos, pero podría considerar esa trampa :-) Así que suponga que hubo operaciones r n <- 3n + 1 y operaciones s n <- n / 2. El resultado será exactamente n '= n * 3 ^ r / 2 ^ s, porque n <- 3n + 1 significa n <- 3n * (1 + 1 / 3n). Tomando el logaritmo encontramos r = (s + log2 (n '/ n)) / log2 (3).

Si hacemos el ciclo hasta n ≤ 1,000,000 y tenemos una tabla calculada previamente cuántas iteraciones se necesitan desde cualquier punto de inicio n ≤ 1,000,000, entonces calcular r como arriba, redondeado al entero más cercano, dará el resultado correcto a menos que s sea realmente grande.


2
O haga tablas de búsqueda de datos para las constantes de multiplicar y agregar, en lugar de un interruptor. La indexación de dos tablas de 256 entradas es más rápida que una tabla de salto, y los compiladores probablemente no estén buscando esa transformación.
Peter Cordes

1
Hmm, pensé por un minuto que esta observación podría probar la conjetura de Collatz, pero no, por supuesto que no. Por cada posible 8 bits al final, hay un número finito de pasos hasta que todos desaparezcan. Pero algunos de esos patrones finales de 8 bits alargarán el resto de la cadena de bits en más de 8, por lo que esto no puede descartar un crecimiento ilimitado o un ciclo repetitivo.
Peter Cordes

Para actualizar count, necesitas una tercera matriz, ¿verdad? adders[]no te dice cuántos cambios a la derecha se hicieron.
Peter Cordes

Para tablas más grandes, valdría la pena usar tipos más estrechos para aumentar la densidad de caché. En la mayoría de las arquitecturas, una carga de extensión cero desde a uint16_tes muy barata. En x86, es tan barato como cero-extendiéndose de 32 bits unsigned inta uint64_t. (MOVZX de la memoria en la CPU Intel sólo se necesita una carga de UOP-puerto, pero las CPU AMD necesitan la ALU también.) Oh por cierto, ¿Por qué utiliza size_tpara lastBits? Es un tipo de 32 bits con -m32e incluso -mx32(modo largo con punteros de 32 bits). Definitivamente es el tipo incorrecto para n. Solo úsalo unsigned.
Peter Cordes

20

En una nota bastante no relacionada: ¡más trucos de rendimiento!

  • [la primera «conjetura» finalmente ha sido desmentida por @ShreevatsaR; remoto]

  • Al atravesar la secuencia, solo podemos obtener 3 casos posibles en el vecindario 2 del elemento actual N(se muestra primero):

    1. [par] [impar]
    2. [impar] [par]
    3. [par] [par]

    Pasar estos 2 elementos significa calcular (N >> 1) + N + 1, ((N << 1) + N + 1) >> 1yN >> 2 , respectivamente.

    Demostremos que para ambos casos (1) y (2) es posible usar la primera fórmula, (N >> 1) + N + 1 .

    El caso (1) es obvio. El caso (2) implica (N & 1) == 1, por lo tanto, si suponemos (sin pérdida de generalidad) que N tiene una longitud de 2 bits y sus bits son bade mayor a menor importancia, entonces a = 1, y lo siguiente se cumple:

    (N << 1) + N + 1:     (N >> 1) + N + 1:
    
            b10                    b1
             b1                     b
           +  1                   + 1
           ----                   ---
           bBb0                   bBb

    dónde B = !b . Desplazar a la derecha el primer resultado nos da exactamente lo que queremos.

    QED: (N & 1) == 1 ⇒ (N >> 1) + N + 1 == ((N << 1) + N + 1) >> 1 .

    Como se demostró, podemos atravesar los elementos de secuencia 2 a la vez, usando una sola operación ternaria. Otra reducción de tiempo 2 veces.

El algoritmo resultante se ve así:

uint64_t sequence(uint64_t size, uint64_t *path) {
    uint64_t n, i, c, maxi = 0, maxc = 0;

    for (n = i = (size - 1) | 1; i > 2; n = i -= 2) {
        c = 2;
        while ((n = ((n & 3)? (n >> 1) + n + 1 : (n >> 2))) > 2)
            c += 2;
        if (n == 2)
            c++;
        if (c > maxc) {
            maxi = i;
            maxc = c;
        }
    }
    *path = maxc;
    return maxi;
}

int main() {
    uint64_t maxi, maxc;

    maxi = sequence(1000000, &maxc);
    printf("%llu, %llu\n", maxi, maxc);
    return 0;
}

Aquí comparamos n > 2porque el proceso puede detenerse en 2 en lugar de 1 si la longitud total de la secuencia es impar.

[EDITAR:]

¡Vamos a traducir esto en asamblea!

MOV RCX, 1000000;



DEC RCX;
AND RCX, -2;
XOR RAX, RAX;
MOV RBX, RAX;

@main:
  XOR RSI, RSI;
  LEA RDI, [RCX + 1];

  @loop:
    ADD RSI, 2;
    LEA RDX, [RDI + RDI*2 + 2];
    SHR RDX, 1;
    SHRD RDI, RDI, 2;    ror rdi,2   would do the same thing
    CMOVL RDI, RDX;      Note that SHRD leaves OF = undefined with count>1, and this doesn't work on all CPUs.
    CMOVS RDI, RDX;
    CMP RDI, 2;
  JA @loop;

  LEA RDX, [RSI + 1];
  CMOVE RSI, RDX;

  CMP RAX, RSI;
  CMOVB RAX, RSI;
  CMOVB RBX, RCX;

  SUB RCX, 2;
JA @main;



MOV RDI, RCX;
ADD RCX, 10;
PUSH RDI;
PUSH RCX;

@itoa:
  XOR RDX, RDX;
  DIV RCX;
  ADD RDX, '0';
  PUSH RDX;
  TEST RAX, RAX;
JNE @itoa;

  PUSH RCX;
  LEA RAX, [RBX + 1];
  TEST RBX, RBX;
  MOV RBX, RDI;
JNE @itoa;

POP RCX;
INC RDI;
MOV RDX, RDI;

@outp:
  MOV RSI, RSP;
  MOV RAX, RDI;
  SYSCALL;
  POP RAX;
  TEST RAX, RAX;
JNE @outp;

LEA RAX, [RDI + 59];
DEC RDI;
SYSCALL;

Use estos comandos para compilar:

nasm -f elf64 file.asm
ld -o file file.o

Vea la C y una versión mejorada / corregida de errores del asm por Peter Cordes en Godbolt . (Nota del editor: ¡Perdón por poner mis cosas en tu respuesta, pero mi respuesta alcanzó el límite de 30k caracteres de los enlaces de Godbolt + texto!)


2
No hay una integral Qtal que 12 = 3Q + 1. Tu primer punto no es correcto, creo.
Veedrac

1
@Veedrac: He estado jugando con esto: se puede implementar con una mejor asm que la implementación en esta respuesta, usando ROR / TEST y solo un CMOV. Este código asm tiene bucles infinitos en mi CPU, ya que aparentemente depende de OF, que no está definido después de SHRD o ROR con conteo> 1. También se esfuerza mucho para evitar mov reg, imm32, aparentemente para guardar bytes, pero luego usa el Versión de registro de 64 bits en todas partes, incluso para xor rax, rax, por lo que tiene muchos prefijos REX innecesarios. Obviamente, solo necesitamos REX en los registros que se mantienen nen el bucle interno para evitar el desbordamiento.
Peter Cordes el

1
Resultados de temporización (de un Core2Duo E6600: Merom 2.4GHz. Complex-LEA = 1c latencia, CMOV = 2c) . La mejor implementación de bucle interno asm de un solo paso (de Johnfound): 111 ms por ejecución de este bucle @main. Salida del compilador de mi versión desenmascarada de este C (con algunos tmp vars): clang3.8 -O3 -march=core2: 96ms. gcc5.2: 108ms. Desde mi versión mejorada del bucle interno asm de clang: 92ms (debería ver una mejora mucho mayor en la familia SnB, donde LEA compleja es 3c no 1c). Desde mi versión mejorada + de trabajo de este bucle asm (usando ROR + TEST, no SHRD): 87ms. Medido con 5 repeticiones antes de imprimir
Peter Cordes

2
Aquí están los primeros 66 establecedores de registros (A006877 en OEIS); He marcado los pares en negrita: 2, 3, 6, 7, 9, 18, 25, 27, 54, 73, 97, 129, 171, 231, 313, 327, 649, 703, 871, 1161, 2223, 2463, 2919, 3711, 6171, 10971, 13255, 17647, 23529, 26623, 34239, 35655, 52527, 77031, 106239, 142587, 156159, 216367, 230631, 410011, 511935, 626331, 837799, 1117065, 15013 1723519, 2298025, 3064033, 3542887, 3732423, 5649499, 6649279, 8400511, 11200681, 14934241, 15733191, 31466382, 36791535, 63728127, 127456254, 169941673, 226588897, 268549803, 2 de 537099606, 670617279, 1341234558
ShreevatsaR

1
@hidefromkgb ¡Genial! Y ahora también aprecio mejor tu otro punto: 4k + 2 → 2k + 1 → 6k + 4 = (4k + 2) + (2k + 1) + 1, y 2k + 1 → 6k + 4 → 3k + 2 = ( 2k + 1) + (k) + 1. ¡Buena observación!
ShreevatsaR

6

Los programas C ++ se traducen a programas de ensamblaje durante la generación de código de máquina a partir del código fuente. Sería prácticamente incorrecto decir que el ensamblaje es más lento que C ++. Además, el código binario generado difiere de un compilador a otro. Por lo tanto, un compilador inteligente de C ++ puede producir código binario más óptimo y eficiente que el código de un ensamblador tonto.

Sin embargo, creo que su metodología de perfil tiene ciertos defectos. Las siguientes son pautas generales para la creación de perfiles:

  1. Asegúrese de que su sistema esté en su estado normal / inactivo. Detenga todos los procesos en ejecución (aplicaciones) que inició o que usan CPU de manera intensiva (o sondeo en la red).
  2. Su tamaño de datos debe ser mayor en tamaño.
  3. Su prueba debe ejecutarse durante algo más de 5-10 segundos.
  4. No confíe en una sola muestra. Realice su prueba N veces. Recopile resultados y calcule la media o mediana del resultado.

Sí, no he hecho ningún perfil formal, pero los he ejecutado ambas veces y soy capaz de distinguir 2 segundos de 3 segundos. De todos modos, gracias por responder. Ya recogí una gran cantidad de información aquí
jeffer son

99
Probablemente no sea solo un error de medición, el código ASM escrito a mano está utilizando una instrucción DIV de 64 bits en lugar de un desplazamiento a la derecha. Mira mi respuesta. Pero sí, medir correctamente también es importante.
Peter Cordes

77
Las viñetas tienen un formato más apropiado que un bloque de código. Deje de poner su texto en un bloque de código, porque no es código y no se beneficia de una fuente monoespaciada.
Peter Cordes

16
Realmente no veo cómo esto responde la pregunta. Esta no es una pregunta vaga acerca de si el código de ensamblaje o el código de C ++ podrían ser más rápidos, es una pregunta muy específica sobre el código real , que ha sido útil en la pregunta misma. Su respuesta ni siquiera menciona ninguno de ese código, ni hace ningún tipo de comparación. Claro, sus consejos sobre cómo comparar son básicamente correctos, pero no lo suficiente como para dar una respuesta real.
Cody Gray

6

Para el problema de Collatz, puede obtener un impulso significativo en el rendimiento al almacenar en caché las "colas". Esta es una compensación tiempo / memoria. Ver: memorización ( https://en.wikipedia.org/wiki/Memoization ). También puede buscar soluciones de programación dinámica para otras compensaciones de tiempo / memoria.

Ejemplo de implementación de Python:

import sys

inner_loop = 0

def collatz_sequence(N, cache):
    global inner_loop

    l = [ ]
    stop = False
    n = N

    tails = [ ]

    while not stop:
        inner_loop += 1
        tmp = n
        l.append(n)
        if n <= 1:
            stop = True  
        elif n in cache:
            stop = True
        elif n % 2:
            n = 3*n + 1
        else:
            n = n // 2
        tails.append((tmp, len(l)))

    for key, offset in tails:
        if not key in cache:
            cache[key] = l[offset:]

    return l

def gen_sequence(l, cache):
    for elem in l:
        yield elem
        if elem in cache:
            yield from gen_sequence(cache[elem], cache)
            raise StopIteration

if __name__ == "__main__":
    le_cache = {}

    for n in range(1, 4711, 5):
        l = collatz_sequence(n, le_cache)
        print("{}: {}".format(n, len(list(gen_sequence(l, le_cache)))))

    print("inner_loop = {}".format(inner_loop))

1
La respuesta de gnasher muestra que puede hacer mucho más que simplemente almacenar en caché las colas: los bits altos no afectan lo que sucede a continuación, y agregar / mul solo propaga el transporte hacia la izquierda, por lo que los bits altos no afectan lo que sucede con los bits bajos. es decir, puede usar búsquedas LUT para ir a 8 (o cualquier número) de bits a la vez, con constantes de multiplicar y agregar para aplicar al resto de los bits. memorizar las colas es ciertamente útil en muchos problemas como este, y para este problema cuando aún no ha pensado en el mejor enfoque o no ha demostrado que sea correcto.
Peter Cordes

2
Si entiendo correctamente la idea anterior de Gnasher, creo que la memorización de la cola es una optimización ortogonal. Así que posiblemente podrías hacer ambas cosas. Sería interesante investigar cuánto podría ganar al agregar la memorización al algoritmo de gnasher.
Emanuel Landeholm el

2
Tal vez podamos hacer que la memorización sea más barata almacenando solo la parte densa de los resultados. Establezca un límite superior en N, y por encima de eso, ni siquiera verifique la memoria. Debajo de eso, use hash (N) -> N como la función hash, por lo que key = position en la matriz, y no necesita ser almacenado. Una entrada de 0medios aún no presente. Podemos optimizar aún más almacenando solo N impar en la tabla, por lo que la función hash es n>>1, descartando el 1. Escriba el código de paso para que siempre termine con un n>>tzcnt(n)o algo para asegurarse de que sea impar.
Peter Cordes el

1
Eso se basa en mi idea (no probada) de que los valores de N muy grandes en el medio de una secuencia tienen menos probabilidades de ser comunes a varias secuencias, por lo que no nos perdemos demasiado de no memorizarlas. Además, un N de tamaño razonable será parte de muchas secuencias largas, incluso las que comienzan con un N muy grande (Esto puede ser una ilusión; si está mal, entonces solo el almacenamiento en caché de un rango denso de N consecutivo puede perderse frente a un hash tabla que puede almacenar claves arbitrarias.) ¿Ha realizado algún tipo de prueba de índice de aciertos para ver si las N iniciales cercanas tienden a tener alguna similitud en sus valores de secuencia?
Peter Cordes

2
Puede almacenar resultados precalculados para todos n <N, para algunos N. grandes, por lo que no necesita la sobrecarga de una tabla hash. Los datos en esa tabla se usarán eventualmente para cada valor inicial. Si solo desea confirmar que la secuencia de Collatz siempre termina en (1, 4, 2, 1, 4, 2, ...): Esto puede probarse que es equivalente a demostrar que para n> 1, la secuencia eventualmente ser menor que el original n. Y para eso, el almacenamiento en caché de colas no ayudará.
gnasher729

5

De comentarios:

Pero, este código nunca se detiene (debido al desbordamiento de enteros). Yves Daoust

Para muchos números que se no desborde.

Si lo hará desbordar - para una de esas semillas iniciales de mala suerte, el número es muy probable que se sobrevuelen converger hacia 1 sin otra desbordamiento.

Aún así, esto plantea una pregunta interesante, ¿hay algún número de semilla cíclica de desbordamiento?

Cualquier serie convergente final simple comienza con una potencia de dos valores (¿lo suficientemente obvio?).

2 ^ 64 se desbordará a cero, que es un bucle infinito indefinido según el algoritmo (termina solo con 1), pero la solución más óptima en respuesta terminará debido a shr rax producción de ZF = 1.

¿Podemos producir 2 ^ 64? Si el número inicial es 0x5555555555555555, es un número impar, el siguiente número es entonces 3n + 1, que es 0xFFFFFFFFFFFFFFFF + 1= 0. Teóricamente en un estado de algoritmo indefinido, pero la respuesta optimizada de johnfound se recuperará al salir de ZF = 1. El cmp rax,1de Peter Cordes terminará en bucle infinito (QED variante 1, "cheapo" a través de un 0número indefinido ).

¿Qué tal un número más complejo, que creará un ciclo sin 0? Francamente, no estoy seguro, mi teoría matemática es demasiado confusa para tener una idea seria, cómo tratarla de manera seria. Pero intuitivamente diría que la serie convergerá a 1 para cada número: 0 <número, ya que la fórmula 3n + 1 convertirá lentamente cada factor primo no 2 del número original (o intermedio) en una potencia de 2, tarde o temprano. . Por lo tanto, no debemos preocuparnos por el bucle infinito para las series originales, solo el desbordamiento puede obstaculizarnos.

Así que solo puse algunos números en la hoja y eché un vistazo a los números truncados de 8 bits.

Hay tres valores que desbordan a 0: 227, 170y 85( 85yendo directamente a 0, otros dos progresando hacia 85).

Pero no tiene valor crear semillas de desbordamiento cíclico.

Curiosamente, hice una comprobación, que es el primer número que sufre un truncamiento de 8 bits, ¡y ya 27está afectado! Alcanza el valor 9232en series no truncadas adecuadas (el primer valor truncado está 322en el 12º paso), y el valor máximo alcanzado para cualquiera de los números de entrada 2-255 de forma no truncada es 13120(para 255sí mismo), número máximo de pasos converger 1es aproximadamente 128(+ -2, no estoy seguro si "1" es para contar, etc ...).

Curiosamente (para mí) el número 9232es máximo para muchos otros números fuente, ¿qué tiene de especial? : -O 9232= 0x2410... hmmm ... ni idea.

Desafortunadamente, no puedo comprender a fondo esta serie, por qué converge y cuáles son las implicaciones de truncarlos a k bits, pero con la cmp number,1condición de terminación es ciertamente posible colocar el algoritmo en un bucle infinito con un valor de entrada particular que termina como 0después truncamiento

Pero el valor que se 27desborda para el caso de 8 bits es una especie de alerta, parece que si cuenta el número de pasos para alcanzar el valor 1, obtendrá un resultado incorrecto para la mayoría de los números del conjunto total de enteros de k bits. Para los enteros de 8 bits, los 146 números de 256 han afectado las series por truncamiento (algunos de ellos aún pueden alcanzar el número correcto de pasos por accidente, tal vez, soy demasiado vago para verificar).


"el número desbordado muy probablemente convergerá hacia 1 sin otro desbordamiento": el código nunca se detiene. (Esa es una conjetura ya que no puedo esperar hasta el final de los tiempos para estar seguro ...)
Yves Daoust

@YvesDaoust, ¿pero sí? ... por ejemplo, la 27serie con truncamiento 8b se ve así: 82 41124 62 31 94 47142 71 214107 66 (truncada) 33100 50 25 76 38 19 58 29 88 44 22 11 34 17 52 26 13 40 20 10 5 16 8 4 2 1 (el resto funciona sin truncamiento). No te entiendo, lo siento. Nunca se detendría si el valor truncado fuera igual a algunos de los alcanzados previamente en las series actualmente en curso, y no puedo encontrar dicho valor frente al truncamiento de k bits (pero tampoco puedo entender la teoría matemática detrás, por qué esto soporta el truncamiento de 8/16/32/64 bits, intuitivamente creo que funciona).
Ped7g

1
Debería haber verificado antes la descripción original del problema: "Aunque todavía no se ha probado (problema de Collatz), se cree que todos los números iniciales terminan en 1." ... no está mal, no es de extrañar que no puedo conseguir comprensión de que con mi conocimiento limitado nebuloso matemáticas ...: D Y de mis experimentos hoja le puedo asegurar que converge para cada 2- 255número, ya sea sin truncamiento (a 1), o con truncamiento de 8 bits (ya sea esperado 1o 0por tres números).
Ped7g

Hem, cuando digo que nunca se detiene, quiero decir ... que no se detiene. El código dado se ejecuta para siempre si lo prefiere.
Yves Daoust el

1
Upvoted para el análisis de lo que sucede en el desbordamiento. El bucle basado en CMP podría usar cmp rax,1 / jna(es decir do{}while(n>1)) para terminar también en cero. Pensé en hacer una versión instrumentada del bucle que registre el máximo nvisto, para dar una idea de cuán cerca llegamos al desbordamiento.
Peter Cordes el

5

No publicó el código generado por el compilador, por lo que hay algunas conjeturas aquí, pero incluso sin haberlo visto, se puede decir que esto:

test rax, 1
jpe even

... tiene un 50% de posibilidades de predecir mal la rama, y ​​eso será costoso.

Es casi seguro que el compilador realiza ambos cálculos (lo que cuesta mucho más, ya que el div / mod tiene una latencia bastante larga, por lo que la suma múltiple es "gratuita") y sigue con un CMOV. Lo cual, por supuesto, tiene un cero por ciento de posibilidades de ser mal pronosticado.


1
Hay algún patrón en la ramificación; Por ejemplo, un número impar siempre va seguido de un número par. Pero a veces 3n + 1 deja múltiples bits cero al final, y ahí es cuando esto va a predecir mal. Comencé a escribir sobre la división en mi respuesta, y no abordé esta otra gran bandera roja en el código del OP. (Tenga en cuenta también que usar una condición de paridad es realmente extraño, en comparación con solo JZ o CMOVZ. También es peor para la CPU, porque las CPU Intel pueden fusionar macro TEST / JZ, pero no TEST / JPE. Agner Fog dice que AMD puede fusionar cualquier TEST / CMP con cualquier JCC, por lo que en ese caso es peor para los lectores humanos)
Peter Cordes

5

Incluso sin mirar el ensamblaje, la razón más obvia es que /= 2probablemente está optimizado como>>=1 y muchos procesadores tienen una operación de cambio muy rápida. Pero incluso si un procesador no tiene una operación de desplazamiento, la división de enteros es más rápida que la división de coma flotante.

Editar: su kilometraje puede variar en la declaración anterior "la división de enteros es más rápida que la división de punto flotante". Los comentarios a continuación revelan que los procesadores modernos han priorizado la optimización de la división fp sobre la división entera. Así que si alguien estuviera mirando por la razón más probable para el aumento de velocidad, que la pregunta de este hilo pregunta acerca de optimización, entonces el compilador /=2como >>=1sería el mejor lugar para buscar primero.


En una nota no relacionada , si nes impar, la expresión n*3+1siempre será par. Entonces no hay necesidad de verificar. Puedes cambiar esa rama a

{
   n = (n*3+1) >> 1;
   count += 2;
}

Entonces toda la declaración sería

if (n & 1)
{
    n = (n*3 + 1) >> 1;
    count += 2;
}
else
{
    n >>= 1;
    ++count;
}

44
La división de enteros no es realmente más rápida que la división de FP en las CPU modernas x86. Creo que esto se debe a que Intel / AMD gasta más transistores en sus divisores FP, porque es una operación más importante. (La división entera por constantes puede optimizarse para multiplicarse por un inverso modular). Verifique las tablas de información de Agner Fog y compare DIVSD (flotante de doble precisión) con DIV r32(entero sin signo de 32 bits) o DIV r64( entero sin signo de 64 bits mucho más lento). Especialmente para el rendimiento, la división FP es mucho más rápida (uop único en lugar de microcodificado y parcialmente canalizado), pero la latencia también es mejor.
Peter Cordes

1
por ejemplo, en la CPU Haswell del OP: DIVSD es 1 uop, latencia de 10-20 ciclos, uno por rendimiento de 8-14c. div r64es de 36 uops, latencia de 32-96c y una por rendimiento de 21-74c. Skylake tiene un rendimiento de división FP aún más rápido (canalizado a uno por 4c con latencia no mucho mejor), pero no mucho más rápido. Las cosas son similares en la familia AMD Bulldozer: DIVSD es 1M-op, latencia 9-27c, una por rendimiento de 4.5-11c. div r64es 16M-ops, latencia 16-75c, uno por rendimiento 16-75c.
Peter Cordes el

1
¿No es básicamente la división FP lo mismo que los exponentes de resta de enteros, mantisa de división de enteros, detectar denormales? Y esos 3 pasos se pueden hacer en paralelo.
MSalters

2
@MSalters: sí, eso suena bien, pero con un paso de normalización al final de los bits de cambio entre exponente y mantis. doubletiene una mantisa de 53 bits, pero sigue siendo significativamente más lenta que div r32en Haswell. Entonces, definitivamente es solo una cuestión de cuánto hardware Intel / AMD arroja al problema, porque no usan los mismos transistores para los divisores enteros y fp. El número entero es escalar (no hay división entre entero y SIMD), y el vector uno maneja 128b vectores (no 256b como otras ALU de vectores). Lo importante es que el número entero div es muchos uops, gran impacto en el código circundante.
Peter Cordes

Err, no cambiar bits entre mantisa y exponente, sino normalizar la mantisa con un cambio, y agregar la cantidad de cambio al exponente.
Peter Cordes

4

Como respuesta genérica, no específicamente dirigida a esta tarea: en muchos casos, puede acelerar significativamente cualquier programa haciendo mejoras a un alto nivel. Al igual que calcular datos una vez en lugar de varias veces, evitar el trabajo innecesario por completo, usar cachés de la mejor manera, etc. Estas cosas son mucho más fáciles de hacer en un lenguaje de alto nivel.

Al escribir código de ensamblador, es posible mejorar lo que hace un compilador de optimización, pero es un trabajo duro. Y una vez que está hecho, su código es mucho más difícil de modificar, por lo que es mucho más difícil agregar mejoras algorítmicas. A veces, el procesador tiene una funcionalidad que no puede usar desde un lenguaje de alto nivel, el ensamblado en línea a menudo es útil en estos casos y aún le permite usar un lenguaje de alto nivel.

En los problemas de Euler, la mayoría de las veces tiene éxito construyendo algo, descubriendo por qué es lento, construyendo algo mejor, descubriendo por qué es lento, y así sucesivamente. Eso es muy, muy difícil de usar ensamblador. Un algoritmo mejor a la mitad de la velocidad posible generalmente vencerá a un algoritmo peor a toda velocidad, y obtener la velocidad máxima en ensamblador no es trivial.


2
Totalmente de acuerdo con esto. gcc -O3hizo un código que estaba dentro del 20% del óptimo en Haswell, para ese algoritmo exacto. (Obtener esas aceleraciones fue el enfoque principal de mi respuesta solo porque eso es lo que hizo la pregunta y tiene una respuesta interesante, no porque sea el enfoque correcto). Se obtuvieron aceleraciones mucho mayores a partir de transformaciones que el compilador sería extremadamente improbable que buscara , como diferir los cambios a la derecha o hacer 2 pasos a la vez. Se pueden obtener aceleraciones mucho más grandes que eso desde las tablas de búsqueda / memorización. Todavía pruebas exhaustivas, pero no pura fuerza bruta.
Peter Cordes

2
Aún así, tener una implementación simple que es obviamente correcta es extremadamente útil para probar otras implementaciones. Lo que probablemente haría es mirar la salida de asm para ver si gcc lo hizo sin ramificaciones como esperaba (principalmente por curiosidad), y luego pasar a mejoras algorítmicas.
Peter Cordes

-2

La respuesta simple:

  • hacer un MOV RBX, 3 y MUL RBX es costoso; solo AGREGUE RBX, RBX dos veces

  • ADD 1 es probablemente más rápido que INC aquí

  • MOV 2 y DIV es muy costoso; solo cambia a la derecha

  • El código de 64 bits suele ser notablemente más lento que el código de 32 bits y los problemas de alineación son más complicados; con pequeños programas como este, debe empaquetarlos para que esté haciendo un cálculo paralelo para tener alguna posibilidad de ser más rápido que el código de 32 bits

Si genera la lista de ensamblaje para su programa C ++, puede ver cómo difiere de su ensamblaje.


44
1): agregar 3 veces sería tonto en comparación con LEA. También mul rbxen la CPU Haswell del OP hay 2 uops con latencia de 3c (y 1 por rendimiento de reloj). imul rcx, rbx, 3es solo 1 uop, con la misma latencia 3c. Dos instrucciones ADD serían 2 uops con latencia 2c.
Peter Cordes

55
2) ADD 1 es probablemente más rápido que INC aquí . No, el OP no está usando un Pentium4 . Su punto 3) es la única parte correcta de esta respuesta.
Peter Cordes

55
4) suena como una tontería total. El código de 64 bits puede ser más lento con estructuras de datos con muchos punteros, porque los punteros más grandes significan una mayor huella de caché. Pero este código solo funciona en registros, y los problemas de alineación de código son los mismos en el modo de 32 y 64 bits. (También lo son los problemas de alineación de datos, no tengo idea de qué está hablando, ya que la alineación es un problema mayor para x86-64). De todos modos, el código ni siquiera toca la memoria dentro del bucle.
Peter Cordes

El comentarista no tiene idea de qué está hablando. Hacer un MOV + MUL en una CPU de 64 bits será aproximadamente tres veces más lento que agregar un registro a sí mismo dos veces. Sus otros comentarios son igualmente incorrectos.
Tyler Durden el

66
Bueno, MOV + MUL es definitivamente tonto, pero MOV + ADD + ADD sigue siendo una tontería (en realidad, hacer ADD RBX, RBXdos veces se multiplicaría por 4, no por 3). De lejos, la mejor manera es lea rax, [rbx + rbx*2]. O, a costa de convertirlo en una LEA de 3 componentes, haga el +1 también con lea rax, [rbx + rbx*2 + 1] (latencia 3c en HSW en lugar de 1, como expliqué en mi respuesta). Mi punto era que la multiplicación de 64 bits no es muy costosa en CPU Intel recientes, porque tienen unidades de multiplicación de enteros increíblemente rápidas (incluso en comparación con AMD, donde lo mismo MUL r64es latencia 6c, con uno por rendimiento de 4c: ni siquiera totalmente canalizado.
Peter Cordes
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.