¿Puede el MOV de x86 ser realmente “libre”? ¿Por qué no puedo reproducir esto en absoluto?

Sigo viendo que las personas afirman que la instrucción MOV puede ser gratuita en x86, debido al cambio de nombre de registro.

Por mi vida, no puedo verificar esto en un solo caso de prueba. Cada caso de prueba que bash lo desinflama.

Por ejemplo, aquí está el código que estoy comstackndo con Visual C ++:

#include  #include  #include  int main(void) { unsigned int k, l, j; clock_t tstart = clock(); for (k = 0, j = 0, l = 0; j < UINT_MAX; ++j) { ++k; k = j; // <-- comment out this line to remove the MOV instruction l += j; } fprintf(stderr, "%d ms\n", (int)((clock() - tstart) * 1000 / CLOCKS_PER_SEC)); fflush(stderr); return (int)(k + j + l); } 

Esto produce el siguiente código de ensamblaje para el ciclo (no dude en producirlo como quiera, obviamente no necesita Visual C ++):

 LOOP: add edi,esi mov ebx,esi inc esi cmp esi,FFFFFFFFh jc LOOP 

Ahora ejecuto este progtwig varias veces, y observo una diferencia bastante constante del 2% cuando se elimina la instrucción MOV:

 Without MOV With MOV 1303 ms 1358 ms 1324 ms 1363 ms 1310 ms 1345 ms 1304 ms 1343 ms 1309 ms 1334 ms 1312 ms 1336 ms 1320 ms 1311 ms 1302 ms 1350 ms 1319 ms 1339 ms 1324 ms 1338 ms 

Entonces, ¿qué da? ¿Por qué el MOV no es “gratuito”? ¿Este lazo es demasiado complicado para x86?
¿Hay algún ejemplo que pueda demostrar que MOV es libre como dicen las personas?
Si es así, ¿qué es? Y si no, ¿por qué todos siguen diciendo que MOV es gratis?

El rendimiento del ciclo en la pregunta no depende de la latencia de MOV, o (en Haswell) del beneficio de no usar una unidad de ejecución.

El ciclo sigue siendo solo 4 uops para que el front-end emita en el núcleo fuera de servicio. ( mov todavía tiene que ser rastreado por el núcleo fuera de orden, incluso si no necesita una unidad de ejecución, pero macro fusibles cmp/jc en un solo uop).

Las CPU Intel desde Core2 han tenido un ancho de problema de 4 uops por reloj, por lo que el mov no impide que se ejecute en (cerca de) un iter por reloj en Haswell. También funcionaría a una por reloj en Ivybridge (con eliminación de mov), pero no en Sandybridge (sin eliminación de mov). En SnB, sería aproximadamente un iter por 1.333c ciclos, embotellado en el rendimiento de la ALU porque el mov siempre necesitaría uno . (SnB / IvB tiene solo tres puertos ALU, mientras que Haswell tiene cuatro).

Tenga en cuenta que un manejo especial en la etapa de cambio de nombre ha sido algo para x87 FXCHG (swap st0 con st0 ) durante mucho más tiempo que MOV. Agner Fog enumera FXCHG como 0 latencia en PPro / PII / PIII (núcleo P6 de primera generación).


El ciclo en la pregunta tiene dos cadenas de dependencia entrelazadas (el add edi,esi depende de EDI y del contador de bucles ESI), lo que lo hace más sensible a la progtwigción imperfecta. No es inusual una desaceleración del 2% frente a la predicción teórica debido a instrucciones aparentemente no relacionadas, y pequeñas variaciones en el orden de las instrucciones pueden marcar esta diferencia. Para ejecutar exactamente 1c por iter, cada ciclo necesita ejecutar un INC y un ADD. Dado que todos los INC y ADD son dependientes de la iteración anterior, la ejecución fuera de orden no puede ponerse al día ejecutando dos en un solo ciclo. Peor aún, el ADD depende del INC en el ciclo anterior, que es lo que quise decir con “enclavamiento”, por lo que perder un ciclo en la cadena de depósito INC también detiene la cadena de depósito ADD.

Además, las ramificaciones pronosticadas solo pueden ejecutarse en port6, por lo que cualquier ciclo en el que port6 ​​no ejecute un cmp / jc es un ciclo de rendimiento perdido . Esto sucede cada vez que INC o ADD roban un ciclo en port6 ​​en lugar de ejecutarse en los puertos 0, 1 o 5. IDK si este es el culpable, o si perder ciclos en las cadenas de dep. INC / ADD es el problema, o tal vez algunos de ambos.

Agregar el MOV adicional no agrega ninguna presión de puerto de ejecución, suponiendo que se elimine al 100%, pero evita que el front-end se ejecute antes del núcleo de ejecución . (Solo 3 de los 4 uops en el bucle necesitan una unidad de ejecución, y su CPU Haswell puede ejecutar INC y ADD en cualquiera de sus 4 puertos ALU: 0, 1, 5 y 6. Entonces los cuellos de botella son:

  • el rendimiento máximo del front-end de 4 uops por reloj. (El bucle sin MOV es solo 3 uops, por lo que el front-end puede avanzar).
  • take-branch throughput de uno por reloj.
  • la cadena de dependencia que implica esi (latencia INC de 1 por reloj)
  • la cadena de dependencia que implica edi (ADD latencia de 1 por reloj, y también depende del INC de la iteración anterior)

Sin el MOV, el front-end puede emitir los tres uops del bucle a 4 por reloj hasta que el núcleo fuera de servicio esté lleno. (AFAICT, “desenrolla” pequeños bucles en el búfer-bucle (Loop Stream Detector: LSD), por lo que un bucle con ABC uops puede emitirse en un patrón ABCA BCAB CABC … El contador de lsd.cycles_4_uops para lsd.cycles_4_uops confirma mayormente problemas en grupos de 4 cuando emite uops).

Las CPU Intel asignan uops a los puertos a medida que emiten en el núcleo fuera de servicio . La decisión se basa en contadores que rastrean cuántos uops para cada puerto ya están en el progtwigdor (también conocido como Reservation Station, RS). Cuando hay muchos uops en el RS que esperan ejecutarse, esto funciona bien y por lo general se debe evitar progtwigr INC o ADD en port6. Y supongo que también evita progtwigr el INC y el ADD de modo que se pierda el tiempo de cualquiera de esas cadenas de depósito. Pero si el RS está vacío o casi vacío, los contadores no detendrán un ADD o INC para robar un ciclo en port6.

Pensé que tenía algo aquí, pero cualquier progtwigción subóptima debería permitir que la interfaz se ponga al día y mantener el back-end completo. No creo que debamos esperar que el front-end cause suficientes burbujas en la tubería para explicar una caída del 2% por debajo del rendimiento máximo, ya que el pequeño bucle debe ejecutarse desde el búfer de bucle a un rendimiento de 4 por reloj muy consistente. Tal vez hay algo más pasando.


Un ejemplo real del beneficio de la eliminación de mov .

Utilicé lea para construir un bucle que solo tiene un mov por reloj, creando una demostración perfecta donde la eliminación de MOV tiene éxito al 100%, o al 0% del tiempo con el mov same,same para demostrar el cuello de botella de latencia que produce.

Como el macro / fusionado dec/jnz es parte de la cadena de dependencia que involucra el contador de dec/jnz , la progtwigción imperfecta no puede retrasarlo. Esto es diferente del caso en que cmp/jc “se desvía” de la cadena de dependencia de ruta crítica en cada iteración.

 _start: mov ecx, 2000000000 ; each iteration decrements by 2, so this is 1G iters align 16 ; really align 32 makes more sense in case the uop-cache comes into play, but alignment is actually irrelevant for loops that fit in the loop buffer. .loop: mov eax, ecx lea ecx, [rax-1] ; we vary these two instructions dec ecx ; dec/jnz macro-fuses into one uop in the decoders, on Intel jnz .loop .end: xor edi,edi ; edi=0 mov eax,231 ; __NR_exit_group from /usr/include/asm/unistd_64.h syscall ; sys_exit_group(0) 

En la familia Intel SnB, LEA con uno o dos componentes en el modo de direccionamiento funciona con 1 latencia (consulte http://agner.org/optimize/ y otros enlaces en la wiki de la etiqueta x86 ).

Construí y ejecuté esto como un binario estático en Linux, por lo que los contadores de rendimiento del espacio de usuario para todo el proceso están midiendo solo el ciclo con una sobrecarga de inicio / apagado insignificante. (la perf stat es realmente fácil en comparación con poner consultas perf-counter en el progtwig mismo)

 $ yasm -felf64 -Worphan-labels -gdwarf2 mov-elimination.asm && ld -o mov-elimination mov-elimination.o && objdump -Mintel -drwC mov-elimination && taskset -c 1 ocperf.py stat -etask-clock,context-switches,page-faults,cycles,instructions,branches,uops_issued.any,uops_executed.thread -r2 ./mov-elimination Disassembly of section .text: 00000000004000b0 <_start>: 4000b0: b9 00 94 35 77 mov ecx,0x77359400 4000b5: 66 66 2e 0f 1f 84 00 00 00 00 00 data16 nop WORD PTR cs:[rax+rax*1+0x0] 00000000004000c0 <_start .loop>: 4000c0: 89 c8 mov eax,ecx 4000c2: 8d 48 ff lea ecx,[rax-0x1] 4000c5: ff c9 dec ecx 4000c7: 75 f7 jne 4000c0 <_start .loop> 00000000004000c9 <_start .end>: 4000c9: 31 ff xor edi,edi 4000cb: b8 e7 00 00 00 mov eax,0xe7 4000d0: 0f 05 syscall perf stat -etask-clock,context-switches,page-faults,cycles,instructions,branches,cpu/event=0xe,umask=0x1,name=uops_issued_any/,cpu/event=0xb1,umask=0x1,name=uops_executed_thread/ -r2 ./mov-elimination Performance counter stats for './mov-elimination' (2 runs): 513.242841 task-clock:u (msec) # 1.000 CPUs utilized ( +- 0.05% ) 0 context-switches:u # 0.000 K/sec 1 page-faults:u # 0.002 K/sec 2,000,111,934 cycles:u # 3.897 GHz ( +- 0.00% ) 4,000,000,161 instructions:u # 2.00 insn per cycle ( +- 0.00% ) 1,000,000,157 branches:u # 1948.396 M/sec ( +- 0.00% ) 3,000,058,589 uops_issued_any:u # 5845.300 M/sec ( +- 0.00% ) 2,000,037,900 uops_executed_thread:u # 3896.865 M/sec ( +- 0.00% ) 0.513402352 seconds time elapsed ( +- 0.05% ) 

Como se esperaba, el ciclo funciona 1G veces ( branches ~ = 1 mil millones). Los 111k ciclos “extras” más allá de 2G son gastos generales que también están presentes en las otras pruebas, incluida la que no tiene mov . No se trata de una falla ocasional de la eliminación de mov, sino que se escala con el recuento de iteraciones, por lo que no es solo una sobrecarga de inicio. Probablemente se deba a las interrupciones del temporizador, ya que IIRC Linux perf no se ocupa de los perf-counters mientras maneja las interrupciones, y simplemente les permite seguir contando. ( perf virtualiza los contadores de rendimiento de hardware para que pueda obtener recuentos por proceso incluso cuando un subproceso migra a través de CPU). Además, las interrupciones de temporizador en el núcleo lógico de hermanos que comparten el mismo núcleo físico perturbarán un poco las cosas.

El cuello de botella es la cadena de dependencia transportada por bucle que implica el contador de bucles. Los ciclos 2G para 1G iters son 2 relojes por iteración, o 1 reloj por decremento. El confirma que la longitud de la cadena de depósito es de 2 ciclos. Esto solo es posible si mov tiene latencia cero . (Sé que no prueba que no haya otro cuello de botella. Realmente solo demuestra que la latencia es como máximo de 2 ciclos, si no crees mi afirmación de que la latencia es el único cuello de botella. Hay un resource_stalls.any contador de rendimiento, pero no tiene muchas opciones para descomponer qué recurso microarquitectónico se agotó).

El ciclo tiene 3 uops de dominio fusionado: mov , lea y macro-fusionada dec/jnz . El conteo de uops_issued.any 3G confirma que: Cuenta en el dominio fusionado, que es todo el proceso desde los decodificadores hasta el retiro, excepto el planificador (RS) y las unidades de ejecución. (los pares de instrucciones macro-fusionados permanecen como un solo uop en todas partes. Solo para la microfusión de tiendas o carga de ALU + que 1 uop de dominio fusionado en el ROB rastrea el progreso de dos uops de dominio no fusionado).

2G uops_executed.thread ( uops_executed.thread -domain) nos dice que todos los mov uops fueron eliminados (es decir, manejados por la etapa de emisión / cambio de nombre, y colocados en el ROB en un estado ya ejecutado). Todavía ocupan el ancho de banda de emisión / retirada, y el espacio en la memoria caché uop, y el tamaño del código. Toman espacio en el ROB, lo que limita el tamaño de la ventana fuera de servicio. Una instrucción mov nunca es gratis. Existen muchos posibles cuellos de botella microarquitectónicos además de los puertos de latencia y ejecución, siendo el más importante la tasa de emisión de 4 elementos del front-end.

En las CPUs de Intel, la latencia cero es a menudo más importante que no necesitar una unidad de ejecución, especialmente en Haswell y más adelante, donde hay 4 puertos ALU. (Pero solo 3 de ellos pueden manejar vectores uops, por lo que movimientos vectoriales no eliminados serían un cuello de botella más fácil, especialmente en código sin muchas cargas o tiendas que tengan ancho de banda de entrada (4 uops de dominio fundido por reloj) lejos de uu de ALU Además, progtwigr uops para las unidades de ejecución no es perfecto (más como primero, listo para más edad), por lo que los uops que no están en la ruta crítica pueden robar ciclos de la ruta crítica.)

Si ponemos un nop o un xor edx,edx en el ciclo, esos también emitirían pero no se ejecutarían en las CPU de la familia Intel SnB.

La eliminación de movimiento de latencia cero puede ser útil para extender cero de 32 a 64 bits y para 8 a 64. ( movzx eax, bl se elimina, movzx eax, bx no ).


Sin eliminación de mov

 mov ecx, ecx # Intel can't eliminate mov same,same lea ecx, [rcx-1] dec ecx jnz .loop 3,000,320,972 cycles:u # 3.898 GHz ( +- 0.00% ) 4,000,000,238 instructions:u # 1.33 insn per cycle ( +- 0.00% ) 1,000,000,234 branches:u # 1299.225 M/sec ( +- 0.00% ) 3,000,084,446 uops_issued_any:u # 3897.783 M/sec ( +- 0.00% ) 3,000,058,661 uops_executed_thread:u # 3897.750 M/sec ( +- 0.00% ) 

Esto requiere ciclos 3G para iteraciones 1G, porque la longitud de la cadena de dependencia ahora es de 3 ciclos.

El recuento uop de dominio fusionado no cambió, aún 3G.

Lo que sí cambió es que ahora el recuento uop de dominio no fusionado es el mismo que el dominio fusionado. Todos los uops necesitaban una unidad de ejecución; ninguna de las instrucciones de mov se eliminó, por lo que todas agregaron latencia 1c a la cadena de depósito transportada por bucle.

(Cuando hay uops microfundidos, como add eax, [rsi] , el recuento uops_executed puede ser mayor que uops_issued . Pero no tenemos eso).


Sin el mov en absoluto:

 lea ecx, [rcx-1] dec ecx jnz .loop 2,000,131,323 cycles:u # 3.896 GHz ( +- 0.00% ) 3,000,000,161 instructions:u # 1.50 insn per cycle 1,000,000,157 branches:u # 1947.876 M/sec 2,000,055,428 uops_issued_any:u # 3895.859 M/sec ( +- 0.00% ) 2,000,039,061 uops_executed_thread:u # 3895.828 M/sec ( +- 0.00% ) 

Ahora volvemos a latencia de 2 ciclos para la cadena de depósito transportada por bucle.

Nada es eliminado.


Probé en un i7-6700k Skylake de 3.9GHz. Obtengo resultados idénticos en un Haswell i5-4210U (hasta dentro de 40k de conteos 1G) para todos los eventos de perf. Eso es aproximadamente el mismo margen de error que volver a ejecutar en el mismo sistema.

Tenga en cuenta que si ejecuté el perf como raíz y los cycles contados en lugar de cycles:u (espacio de usuario solamente), mide la frecuencia de la CPU exactamente como 3.900 GHz. (IDK por qué Linux solo obedece a la configuración de bios para el máximo turbo justo después del reinicio, pero luego baja a 3.9GHz si lo dejo inactivo por un par de minutos. Asus Z170 Pro Gaming mobo, Arch Linux con kernel 4.10.11-1-ARCH . Vimos lo mismo con Ubuntu. Escribir balance_performance para cada uno de /sys/devices/system/cpu/cpufreq/policy[0-9]*/energy_performance_preference de /etc/rc.local corrige, pero escribir balance_power hace retroceder a 3.9 GHz nuevamente más tarde).


Deberías obtener los mismos resultados en AMD Ryzen, ya que puede eliminar el mov entero. AMD Bulldozer-family solo puede eliminar copias de registro xmm. (Según Agner Fog, las copias de registro de ymm son una mitad baja eliminada y una opción de ALU para la mitad alta).

Por ejemplo, AMD Bulldozer e Intel Ivybridge pueden soportar un rendimiento de 1 por reloj para

  movaps xmm0, xmm1 movaps xmm2, xmm3 movaps xmm4, xmm5 dec jnz .loop 

Pero Intel Sandybridge no puede eliminar movimientos, por lo que sería un cuello de botella en 4 ALU uops para 3 puertos de ejecución. Si fue pxor xmm0,xmm0 lugar de movaps, SnB también podría sostener una iteración por reloj. (Pero la familia Bulldozer no pudo, porque xor-zeroing todavía necesita una unidad de ejecución en AMD, aunque es independiente del valor anterior del registro. Y la familia Bulldozer solo tiene 0.5c de rendimiento para PXOR).


Limitaciones de eliminación de mov

Dos instrucciones de MOV dependientes en una fila exponen una diferencia entre Haswell y Skylake.

 .loop: mov eax, ecx mov ecx, eax sub ecx, 2 jnz .loop 

Haswell: menor variabilidad de ejecución-ejecución (1.746 a 1.749 c / iter), pero esto es típico:

  1,749,102,925 cycles:u # 2.690 GHz 4,000,000,212 instructions:u # 2.29 insn per cycle 1,000,000,208 branches:u # 1538.062 M/sec 3,000,079,561 uops_issued_any:u # 4614.308 M/sec 1,746,698,502 uops_executed_core:u # 2686.531 M/sec 745,676,067 lsd_cycles_4_uops:u # 1146.896 M/sec 

No todas las instrucciones de MOV se eliminan: aproximadamente 0,75 de las 2 por iteración utilizan un puerto de ejecución. Cada MOV que se ejecuta en lugar de eliminarse agrega 1c de latencia a la cadena de depósito transportada por bucle, por lo que no es una coincidencia que uops_executed y los cycles sean muy similares. Todos los usuarios son parte de una sola cadena de dependencia, por lo que no hay paralelismo posible. cycles es siempre aproximadamente 5M más alto que uops_executed independientemente de la uops_executed de ejecución a ejecución, por lo que supongo que solo se utilizan 5M ciclos en otro lugar.

Skylake: más estable que los resultados de HSW, y más eliminación de mov: solo 0.6666 MOV de cada 2 necesitaron una unidad de ejecución.

  1,666,716,605 cycles:u # 3.897 GHz 4,000,000,136 instructions:u # 2.40 insn per cycle 1,000,000,132 branches:u # 2338.050 M/sec 3,000,059,008 uops_issued_any:u # 7014.288 M/sec 1,666,548,206 uops_executed_thread:u # 3896.473 M/sec 666,683,358 lsd_cycles_4_uops:u # 1558.739 M/sec 

En Haswell, lsd.cycles_4_uops representó a todos los uops. (0.745 * 4 ~ = 3). Entonces, en casi todos los ciclos donde se emite uops, se emite un grupo completo de 4 (del loop-buffer. Probablemente debería haber mirado un contador diferente que no le importa de dónde vienen, como uops_issued.stall_cycles para contar ciclos donde no se emitió uops).

Pero en SKL, 0.66666 * 4 = 2.66664 es menor que 3, por lo que en algunos ciclos el front-end emitió menos de 4 uops. (Por lo general, se detiene hasta que haya espacio en el núcleo fuera de servicio para emitir un grupo completo de 4, en lugar de emitir grupos no completos).

Es extraño, IDK, cuál es la limitación microarquitectónica exacta. Como el ciclo es de solo 3 uops, cada grupo de problemas de 4 uops es más que una iteración completa. Por lo tanto, un grupo de problemas puede contener hasta 3 MOV dependientes. ¿Tal vez Skylake está diseñado para romper eso a veces, para permitir más eliminación de mov?

actualización : en realidad esto es normal para los bucles 3-uop en Skylake. uops_issued.stall_cycles muestra que HSW y SKL emiten un simple bucle de 3 uop sin eliminación de mov del mismo modo que emiten este. Entonces, una mejor eliminación de mov es un efecto secundario de dividir grupos de problemas por alguna otra razón. (No es un cuello de botella porque las sucursales tomadas no pueden ejecutar más rápido que 1 por reloj, independientemente de qué tan rápido emitan). Todavía no sé por qué SKL es diferente, pero no creo que sea algo de lo que preocuparse.


En un caso menos extremo, SKL y HSW son los mismos, y ambos no lograron eliminar 0.3333 de cada 2 instrucciones de MOV:

 .loop: mov eax, ecx dec eax mov ecx, eax sub ecx, 1 jnz .loop 

  2,333,434,710 cycles:u # 3.897 GHz 5,000,000,185 instructions:u # 2.14 insn per cycle 1,000,000,181 branches:u # 1669.905 M/sec 4,000,061,152 uops_issued_any:u # 6679.720 M/sec 2,333,374,781 uops_executed_thread:u # 3896.513 M/sec 1,000,000,942 lsd_cycles_4_uops:u # 1669.906 M/sec 

Todos los uops emiten en grupos de 4. Cualquier grupo contiguo de 4 uops contendrá exactamente dos Ups de MOV que son candidatos para la eliminación. Como claramente tiene éxito en eliminar ambos en algunos ciclos, IDK explica por qué no siempre puede hacer eso.


El manual de optimización de Intel dice que sobrescribir el resultado de eliminación de mov lo antes posible libera los recursos microarquitectónicos para que pueda tener éxito con mayor frecuencia, al menos para movzx . Vea el Ejemplo 3-25. Secuencia de reordenación para mejorar la efectividad de las instrucciones MOV de latencia cero .

¿Entonces tal vez se rastrea internamente con una tabla de conteos de refinanciados de tamaño limitado? Algo tiene que evitar que la entrada del archivo de registro físico se libere cuando ya no se necesita como el valor del registro de architecture original, si todavía se necesita como el valor del destino de mov. Es clave liberar las entradas de PRF tan pronto como sea posible, ya que el tamaño de PRF puede limitar la ventana fuera de servicio a un tamaño menor que ROB.

Probé los ejemplos en Haswell y Skylake, y descubrí que la eliminación de mov de hecho funcionaba significativamente la mayor parte del tiempo al hacer eso, pero que en realidad era un poco más lento en ciclos totales, en lugar de más rápido. El ejemplo fue diseñado para mostrar el beneficio en IvyBridge, que probablemente atasca en sus 3 puertos ALU, pero HSW / SKL solo obstaculiza los conflictos de recursos en las cadenas de dep y no parece molestarse por la necesidad de un puerto ALU para más de instrucciones movzx .

Consulte también ¿Por qué es XCHG reg, reg 3 instrucciones de microoperación en las architectures Intel modernas? para más investigación + conjeturas sobre cómo funciona la eliminación de mov, y si podría funcionar para xchg eax, ecx . (En la práctica, xchg reg,reg es 3 ALU uops en Intel, pero 2 eliminaron uops en Ryzen. Es interesante adivinar si Intel podría haberlo implementado de manera más eficiente.)


Por cierto, como una solución para una errata en Haswell, Linux no proporciona uops_executed.thread cuando hyperthreading está habilitado, solo uops_executed.core . El otro núcleo estaba definitivamente inactivo todo el tiempo, ni siquiera las interrupciones del temporizador, porque lo desconecté con echo 0 > /sys/devices/system/cpu/cpu3/online . Lamentablemente, esto no se puede hacer antes de que perf decida que HT esté habilitado, y mi laptop Dell no tiene una opción de BIOS para deshabilitar HT. Por lo tanto, no puedo utilizar todos los 8 contadores PMU de hardware a la vez en ese sistema, solo 4.: /

Aquí hay dos pequeñas pruebas que creo que demuestran de manera concluyente evidencia de eliminación de mov:

 __loop1: add edx, 1 add edx, 1 add ecx, 1 jnc __loop1 

versus

 __loop2: mov eax, edx add eax, 1 mov edx, eax add edx, 1 add ecx, 1 jnc __loop2 

Si mov agregó un ciclo a una cadena de dependencia, se esperaría que la segunda versión tome alrededor de 4 ciclos por iteración. En mi Haswell, ambos toman alrededor de 2 ciclos por iteración, lo que no puede suceder sin eliminación de mov.