¿Por qué la introducción de instrucciones MOV inútiles aceleraría un ciclo cerrado en el ensamblaje x86_64?

222

Antecedentes:

Al optimizar un código Pascal con lenguaje ensamblador incorporado, noté una MOVinstrucción innecesaria y la eliminé.

Para mi sorpresa, eliminar las instrucciones innecesarias hizo que mi programa se ralentizara .

Descubrí que agregar MOVinstrucciones arbitrarias e inútiles aumentaba aún más el rendimiento .

El efecto es errático y los cambios se basan en el orden de ejecución: las mismas instrucciones basura transpuestas hacia arriba o hacia abajo por una sola línea producen una desaceleración .

Entiendo que la CPU realiza todo tipo de optimizaciones y racionalizaciones, pero esto parece más magia negra.

Los datos:

Una versión de mi código compila condicionalmente tres operaciones basura en medio de un ciclo que ejecuta 2**20==1048576tiempos. (El programa circundante solo calcula los hash SHA-256 ).

Los resultados en mi máquina bastante antigua (Intel (R) Core (TM) 2 CPU 6400 @ 2.13 GHz):

avg time (ms) with -dJUNKOPS: 1822.84 ms
avg time (ms) without:        1836.44 ms

Los programas se ejecutaron 25 veces en un bucle, con el orden de ejecución cambiando al azar cada vez.

Extracto:

{$asmmode intel}
procedure example_junkop_in_sha256;
  var s1, t2 : uint32;
  begin
    // Here are parts of the SHA-256 algorithm, in Pascal:
    // s0 {r10d} := ror(a, 2) xor ror(a, 13) xor ror(a, 22)
    // s1 {r11d} := ror(e, 6) xor ror(e, 11) xor ror(e, 25)
    // Here is how I translated them (side by side to show symmetry):
  asm
    MOV r8d, a                 ; MOV r9d, e
    ROR r8d, 2                 ; ROR r9d, 6
    MOV r10d, r8d              ; MOV r11d, r9d
    ROR r8d, 11    {13 total}  ; ROR r9d, 5     {11 total}
    XOR r10d, r8d              ; XOR r11d, r9d
    ROR r8d, 9     {22 total}  ; ROR r9d, 14    {25 total}
    XOR r10d, r8d              ; XOR r11d, r9d

    // Here is the extraneous operation that I removed, causing a speedup
    // s1 is the uint32 variable declared at the start of the Pascal code.
    //
    // I had cleaned up the code, so I no longer needed this variable, and 
    // could just leave the value sitting in the r11d register until I needed
    // it again later.
    //
    // Since copying to RAM seemed like a waste, I removed the instruction, 
    // only to discover that the code ran slower without it.
    {$IFDEF JUNKOPS}
    MOV s1,  r11d
    {$ENDIF}

    // The next part of the code just moves on to another part of SHA-256,
    // maj { r12d } := (a and b) xor (a and c) xor (b and c)
    mov r8d,  a
    mov r9d,  b
    mov r13d, r9d // Set aside a copy of b
    and r9d,  r8d

    mov r12d, c
    and r8d, r12d  { a and c }
    xor r9d, r8d

    and r12d, r13d { c and b }
    xor r12d, r9d

    // Copying the calculated value to the same s1 variable is another speedup.
    // As far as I can tell, it doesn't actually matter what register is copied,
    // but moving this line up or down makes a huge difference.
    {$IFDEF JUNKOPS}
    MOV s1,  r9d // after mov r12d, c
    {$ENDIF}

    // And here is where the two calculated values above are actually used:
    // T2 {r12d} := S0 {r10d} + Maj {r12d};
    ADD r12d, r10d
    MOV T2, r12d

  end
end;

Inténtalo tú mismo:

El código está en línea en GitHub si quieres probarlo tú mismo.

Mis preguntas:

  • ¿Por qué copiar inútilmente el contenido de un registro a la RAM aumentaría el rendimiento?
  • ¿Por qué la misma instrucción inútil proporcionaría una aceleración en algunas líneas y una desaceleración en otras?
  • ¿Es este comportamiento algo que un compilador podría explotar de manera predecible?
tormenta tangente
fuente
77
Hay todo tipo de instrucciones "inútiles" que pueden servir para romper las cadenas de dependencia, marcar los registros físicos como retirados, etc. La explotación de estas operaciones requiere cierto conocimiento de la microarquitectura . Su pregunta debe proporcionar una breve secuencia de instrucciones como un ejemplo mínimo, en lugar de dirigir a las personas a Github.
Brett Hale
1
@BrettHale buen punto, gracias. Agregué un extracto de código con algunos comentarios. ¿Copiar el valor de un registro para ram marcará el registro como retirado, incluso si el valor en él se usa más tarde?
tangentstorm
99
¿Puedes poner la desviación estándar en esos promedios? No hay ninguna indicación real en esta publicación de que haya una diferencia real.
starwed
2
¿Puede intentar cronometrar las instrucciones utilizando la instrucción rdtscp y verificar los ciclos de reloj para ambas versiones?
jakobbotsch
2
¿Puede ser también debido a la alineación de la memoria? Yo no hice los cálculos yo mismo (perezoso: P) pero agregar algunas instrucciones
falsas

Respuestas:

144

La causa más probable de la mejora de la velocidad es que:

  • insertar un MOV desplaza las instrucciones posteriores a diferentes direcciones de memoria
  • una de esas instrucciones movidas era una rama condicional importante
  • esa rama se estaba prediciendo incorrectamente debido a alias en la tabla de predicción de rama
  • mover la rama eliminó el alias y permitió que la rama se prediga correctamente

Su Core2 no mantiene un registro de historial separado para cada salto condicional. En cambio, mantiene un historial compartido de todos los saltos condicionales. Una desventaja de la predicción de rama global es que el historial se diluye con información irrelevante si los diferentes saltos condicionales no están correlacionados.

Este pequeño tutorial de predicción de rama muestra cómo funcionan los búferes de predicción de rama. El búfer de caché está indexado por la parte inferior de la dirección de la instrucción de bifurcación. Esto funciona bien a menos que dos ramas importantes no correlacionadas compartan los mismos bits inferiores. En ese caso, terminas con alias que causa muchas ramas mal predichas (que detiene la canalización de instrucciones y ralentiza tu programa).

Si desea comprender cómo las predicciones erróneas de las ramas afectan el rendimiento, eche un vistazo a esta excelente respuesta: https://stackoverflow.com/a/11227902/1001643

Los compiladores generalmente no tienen suficiente información para saber qué ramas tendrán un alias y si esos alias serán significativos. Sin embargo, esa información se puede determinar en tiempo de ejecución con herramientas como Cachegrind y VTune .

Raymond Hettinger
fuente
2
Hmm Esto suena prometedor. Las únicas ramas condicionales en esta implementación de sha256 son las comprobaciones para el final de los bucles FOR. En ese momento, había etiquetado esta revisión como una rareza en git y seguí optimizando. Uno de mis próximos pasos fue reescribir el bucle FOR pascal en el ensamblaje, momento en el que estas instrucciones adicionales ya no tuvieron un efecto positivo. Quizás el código generado por free pascal fue más difícil de predecir por el procesador que el simple contador con el que lo reemplacé.
tangentstorm
1
@tangentstorm Eso suena como un buen resumen. La tabla de predicción de rama no es muy grande, por lo que una entrada de la tabla puede referirse a más de una rama. Esto puede hacer que algunas predicciones sean inútiles. El problema se soluciona fácilmente si una de las ramas en conflicto se mueve a otra parte de la tabla. Casi cualquier pequeño cambio puede hacer que esto suceda :-)
Raymond Hettinger
1
Creo que esta es la explicación más razonable del comportamiento específico que observé, así que voy a marcar esto como la respuesta. Gracias. :)
tangentstorm
3
Hay una discusión absolutamente excelente sobre un problema similar con el que se encontró uno de los contribuyentes a Bochs, es posible que desee agregar esto a su respuesta: emulators.com/docs/nx25_nostradamus.htm
leander
3
La alineación interna es importante para mucho más que solo objetivos de rama. Los cuellos de botella de decodificación son un gran problema para Core2 y Nehalem: a menudo le resulta difícil mantener ocupadas sus unidades de ejecución. La introducción de Sandybridge de la caché de uop aumentó el rendimiento de la interfaz en gran medida. La alineación de los objetivos de las ramas se realiza debido a este problema, pero afecta a todo el código.
Peter Cordes
80

Es posible que desee leer http://research.google.com/pubs/pub37077.html

TL; DR: la inserción aleatoria de instrucciones nop en los programas puede aumentar fácilmente el rendimiento en un 5% o más, y no, los compiladores no pueden explotar esto fácilmente. Por lo general, es una combinación de predicción de ramificación y comportamiento de caché, pero también puede ser, por ejemplo, una parada de la estación de reserva (incluso en el caso de que no haya cadenas de dependencia que estén rotas o que haya sobre-suscripciones de recursos evidentes).

Jonas Maebe
fuente
1
Interesante. Pero, ¿es el procesador (o FPC) lo suficientemente inteligente como para ver que escribir en RAM es un NOP en este caso?
tangentstorm
8
El ensamblador no está optimizado.
Marco van de Voort
55
Los compiladores podrían explotarlo haciendo optimizaciones increíblemente costosas, como construir y perfilar repetidamente y luego variar la salida del compilador con un recocido simulado o un algoritmo genético. He leído sobre algunos trabajos en esa área. Pero estamos hablando de un mínimo de 5-10 minutos de 100% de CPU para compilar, y las optimizaciones resultantes probablemente serían un modelo de núcleo de CPU e incluso una revisión de núcleo o microcódigo específica.
AdamIerymenko
No lo llamaría NOP aleatorio, explican por qué los NOP pueden tener un efecto positivo en el rendimiento (tl; dr: stackoverflow.com/a/5901856/357198 ) y la inserción aleatoria de NOP resultó en una degradación del rendimiento. ¡Lo interesante del documento es que la eliminación del NOP 'estratégico' por parte de GCC no tuvo ningún efecto en el rendimiento general!
PuercoPop
15

Creo que en las CPU modernas, las instrucciones de ensamblaje, aunque son la última capa visible para un programador para proporcionar instrucciones de ejecución a una CPU, en realidad son varias capas de la ejecución real de la CPU.

Las CPU modernas son híbridos RISC / CISC que traducen las instrucciones CISC x86 en instrucciones internas que tienen un comportamiento más RISC. Además, hay analizadores de ejecución fuera de orden, predictores de rama, "fusión de micro-operaciones" de Intel que intentan agrupar las instrucciones en lotes más grandes de trabajo simultáneo (algo así como el VLIW / Itanium titanic). Incluso hay límites de caché que podrían hacer que el código se ejecute más rápido para Dios sabe por qué si es más grande (tal vez el controlador de caché lo ranura de manera más inteligente o lo mantiene por más tiempo).

CISC siempre ha tenido una capa de traducción de ensamblado a microcódigo, pero el punto es que con las CPU modernas las cosas son mucho mucho más complicadas. Con todo el espacio extra del transistor en las plantas modernas de fabricación de semiconductores, las CPU probablemente pueden aplicar varios enfoques de optimización en paralelo y luego seleccionar el que al final proporciona la mejor aceleración. Las instrucciones adicionales pueden estar sesgando la CPU para usar una ruta de optimización que sea mejor que otras.

El efecto de las instrucciones adicionales probablemente depende del modelo / generación / fabricante de la CPU, y no es probable que sea predecible. Optimizar el lenguaje ensamblador de esta manera requeriría la ejecución en muchas generaciones de arquitectura de CPU, tal vez utilizando rutas de ejecución específicas de la CPU, y solo sería deseable para secciones de código realmente muy importantes, aunque si está haciendo ensamblaje, probablemente ya lo sepa.

cowarldlydragon
fuente
66
Tu respuesta es un poco confusa. En muchos lugares parece que estás adivinando, aunque la mayoría de lo que dices es correcto.
alcuadrado
2
Quizás debería aclararlo. Lo que encuentro confuso es la falta de certeza
alcuadrado
3
adivinar eso tiene sentido y con una buena argumentación es completamente válido.
jturolla
77
Nadie puede saber con certeza por qué el OP está observando este comportamiento extraño, a menos que fuera un ingeniero de Intel que tuviera acceso a un equipo de diagnóstico especial. Entonces todo lo que otros pueden hacer es adivinar. Eso no es culpa de @ cowarldlydragon.
Alex D
2
Voto negativo; nada de lo que dices explica el comportamiento que OP está viendo. Tu respuesta es inútil.
fuz
0

Preparando el caché

Mover operaciones a la memoria puede preparar el caché y acelerar las operaciones de movimiento posteriores. Una CPU generalmente tiene dos unidades de carga y una unidad de almacenamiento. Una unidad de carga puede leer desde la memoria en un registro (una lectura por ciclo), una unidad de almacenamiento almacena desde el registro en la memoria. También hay otras unidades que realizan operaciones entre registros. Todas las unidades funcionan en paralelo. Entonces, en cada ciclo, podemos hacer varias operaciones a la vez, pero no más de dos cargas, una tienda y varias operaciones de registro. Por lo general, son hasta 4 operaciones simples con registros simples, hasta 3 operaciones simples con registros XMM / YMM y 1-2 operaciones complejas con cualquier tipo de registro. Su código tiene muchas operaciones con registros, por lo que una operación de almacenamiento de memoria ficticia es gratuita (ya que hay más de 4 operaciones de registro de todos modos), pero prepara la memoria caché para la operación de almacenamiento posterior. Para saber cómo funcionan los almacenes de memoria, consulte elManual de referencia de optimización de arquitecturas Intel 64 e IA-32 .

Romper las falsas dependencias

Aunque esto no se refiere exactamente a su caso, pero a veces el uso de operaciones mov de 32 bits bajo el procesador de 64 bits (como en su caso) se usa para borrar los bits más altos (32-63) y romper las cadenas de dependencia.

Es bien sabido que bajo x86-64, el uso de operandos de 32 bits borra los bits más altos del registro de 64 bits. Lea la sección correspondiente - 3.4.1.1 - del Manual del desarrollador de software de arquitecturas Intel® 64 e IA-32 Volumen 1 :

Los operandos de 32 bits generan un resultado de 32 bits, cero extendido a un resultado de 64 bits en el registro de destino general

Entonces, las instrucciones mov, que pueden parecer inútiles a primera vista, borran los bits más altos de los registros apropiados. ¿Qué nos da? Rompe las cadenas de dependencia y permite que las instrucciones se ejecuten en paralelo, en orden aleatorio, mediante el algoritmo Fuera de servicio implementado internamente por las CPU desde Pentium Pro en 1995.

Una cita del Manual de referencia de optimización de arquitecturas Intel® 64 e IA-32 , Sección 3.5.1.8:

Las secuencias de código que modifican el registro parcial pueden experimentar cierto retraso en su cadena de dependencia, pero pueden evitarse utilizando modismos de ruptura de dependencia. En los procesadores basados ​​en la microarquitectura Intel Core, varias instrucciones pueden ayudar a eliminar la dependencia de ejecución cuando el software usa estas instrucciones para borrar el contenido del registro a cero. Divida las dependencias en porciones de registros entre instrucciones operando en registros de 32 bits en lugar de registros parciales. Para movimientos, esto se puede lograr con movimientos de 32 bits o usando MOVZX.

Regla 37 de codificación del ensamblador / compilador (impacto M, generalidad MH) : interrumpa las dependencias de las partes de los registros entre instrucciones al operar en registros de 32 bits en lugar de registros parciales. Para movimientos, esto se puede lograr con movimientos de 32 bits o usando MOVZX.

El MOVZX y el MOV con operandos de 32 bits para x64 son equivalentes: todos rompen las cadenas de dependencia.

Es por eso que su código se ejecuta más rápido. Si no hay dependencias, la CPU puede renombrar internamente los registros, aunque a primera vista parezca que la segunda instrucción modifica un registro utilizado por la primera instrucción, y las dos no pueden ejecutarse en paralelo. Pero debido al cambio de nombre de registro pueden.

El cambio de nombre de registro es una técnica utilizada internamente por una CPU que elimina las dependencias de datos falsos que surgen de la reutilización de registros mediante instrucciones sucesivas que no tienen ninguna dependencia de datos real entre ellos.

Creo que ahora ves que es demasiado obvio.

Maxim Masiutin
fuente
Todo esto es cierto, pero no tiene nada que ver con el código presentado en la pregunta.
Cody Gray
@CodyGray: gracias por sus comentarios. Edité la respuesta y agregué un capítulo sobre el caso: ese movimiento a la memoria rodeado de operaciones de registro prepara el caché y es gratis ya que la unidad de tienda está inactiva de todos modos. Por lo tanto, la operación de almacenamiento posterior será más rápida.
Maxim Masiutin
1
no hay MOVZX para operandos de 32 bits, porque todas las instrucciones con destino de 32 bits
phuclv