Extraños usos de movzx por Clang y GCC

8 minutos de lectura

avatar de usuario
Hintro

Yo sé eso movzx se puede usar para romper la dependencia, pero me topé con algunos movzx usos de Clang y GCC que realmente no puedo ver para qué sirven. Aquí hay un ejemplo simple que probé en el explorador del compilador Godbolt:

#include <stdint.h>

int add2bytes(uint8_t* a, uint8_t* b) {
    return uint8_t(*a + *b);
}

con CCG 12 -O3:

add2bytes(unsigned char*, unsigned char*):
        movzx   eax, BYTE PTR [rsi]
        add     al, BYTE PTR [rdi]
        movzx   eax, al
        ret

Si entiendo bien, la primera movzx aquí rompe la dependencia de la anterior eax valor, pero ¿cuál es el segundo movzx ¿haciendo? No creo que haya ninguna dependencia que pueda romper, y tampoco debería afectar el resultado.

con sonido metálico 14 -O3es aún más raro:

add2bytes(unsigned char*, unsigned char*):                       # @add2bytes(unsigned char*, unsigned char*)
        mov     al, byte ptr [rsi]
        add     al, byte ptr [rdi]
        movzx   eax, al
        ret

Usa mov dónde movzx parece más razonable, y entonces cero se extiende al a eaxpero no sería mucho mejor hacer movzx ¿al principio?

Tengo 2 ejemplos más aquí: https://godbolt.org/z/z45xr4hq1
GCC genera tanto sensato como extraño. movzxy el uso de Clang de mov r8 m y movzx simplemente no tiene sentido para mí. También intenté agregar -march=skylake para asegurarse de que esta no sea una función para arquitecturas realmente antiguas, pero el ensamblaje generado se ve más o menos igual.

La publicación más cercana que he encontrado es https://stackoverflow.com/a/64915219/14730360 donde mostraron similar movzx usos que parecen inútiles y/o fuera de lugar.

¿Los compiladores realmente usan movzx mal aquí, o me estoy perdiendo algo?

Editar: he abierto informes de errores para Clang y GCC:

https://github.com/llvm/llvm-project/issues/56498

https://gcc.gnu.org/bugzilla/show_bug.cgi?id=106277

Soluciones temporales mediante ensamblaje en línea:
https://godbolt.org/z/7qob8G3j7

#define addb(a, b) asm (\
    "addb %1, %b0"\
    : "+r"(a) : "mi"(b))

int add2bytes(uint8_t* a, uint8_t* b) {
    int ret = *a;
    addb(ret, *b);
    return ret;
}

Ahora Clang -O3 produce:

add2bytes(unsigned char*, unsigned char*):                       # @add2bytes(unsigned char*, unsigned char*)
        movzx   eax, byte ptr [rdi]
        add     al, byte ptr [rsi]
        ret

  • Quizás relacionado: stackoverflow.com/questions/43491737/…

    – Jakob Stark

    12 de julio a las 8:37

  • ¿A qué llamas “romper la dependencia”?

    – Yves Daoust

    12 de julio a las 8:38

  • @YvesDaoust stackoverflow.com/a/43910889/14730360 tiene un ejemplo de cómo movzx rompe la dependencia y ayuda a la ejecución de OoO. Sin embargo, no creo que movzx haga eso aquí.

    – Hintro

    12 de julio a las 8:43

  • @YvesDaoust: Las CPU x86 modernas tienen muchos más registros de los que tienen nombres. Es por eso que los registros físicos están constantemente siendo renombrado. Este es un proceso complicado. Una preocupación importante es que debería ser invisible para el código. Esto es importante cuando se usan nombres de registros parciales como al. Que debería preservar los bits superiores, deteniendo un cambio de nombre. Pero movzx hace una extensión cero, por lo que permite un cambio de nombre.

    – MSalters

    12 de julio a las 8:45

  • Actualicé mi respuesta con una sección sobre su enlace Godbolt a[a[i] | a[j]] mostrando clang esperando que la persona que llama ya se haya extendido i y j a 32 bits.

    – Peter Cordes

    13 de julio a las 4:42

  • ¡Gracias por una respuesta tan detallada! Estoy escribiendo informes para Clang y GCC, y vincularé esta publicación.

    – Hintro

    12 de julio a las 20:02

  • @PeterCordes, ¿qué quieres decir con “mov al, [rdi] seguiría siendo una carga de microfusible + ALU uop, por lo que tiene una latencia de carga adicional”. ¿La ALU microfusionada tiene una latencia más alta que la carga explícita + ALU?

    – Noé

    13 de julio a las 2:06

  • @Noah: No, pero movzx eax, byte ptr [rdi] no tiene ALU uop en absoluto, EAX está listo directamente desde el puerto de carga, sin ALU uop como parte de la cadena de distribución.

    – Peter Cordes

    13 de julio a las 3:00


  • @Hintro: para mantener los tiempos de compilación rápidos, en su mayoría se limitan a los algoritmos O (n ^ 2) en el peor de los casos, sin considerar todas las secuencias posibles para encontrar una que produzca los resultados deseados con un costo mínimo. Eso es lo que un “superoptimizador” lo hace, y es efectivo para secuencias de quizás 4 instrucciones o menos. Pero solo si tiene un modelo de costo apropiado, en este caso uno que tenga en cuenta los efectos de registro parcial.

    – Peter Cordes

    13 de julio a las 7:37

  • @PeterCordes Creo que tienes razón sobre cómo clang generó esa secuencia, github.com/llvm/llvm-project/issues/…

    – Hintro

    13 de julio a las 7:50

  • ¿Pensé que el estancamiento ya no estaba ocurriendo desde el Core 2? según 2 guías de Agner. Los únicos que aún podrían ser un problema son los 8 registros altos. De acuerdo con stackoverflow.com/q/45660139/14730360 movzx eax, al no se elimina y, en cierto sentido, es una penalización en sí misma, entonces, ¿es realmente una buena idea usarlo aquí?

    – Hintro

    12 de julio a las 9:11

  • @Hintro: Solo Sandybridge y más tarde realmente hicieron que la fusión de registros parciales fuera barata. Core2 y Nehalem insertan una operación de fusión, pero solo después de detenerse durante aproximadamente 3 ciclos IIRC. SnB inserta una uop de fusión con solo el costo inicial que esperaría. (A menos que sea una uop de fusión AH, entonces tiene que emitirse por sí misma). Y HSW ni siquiera cambia el nombre de low8 por separado del registro completo, por lo que el código de clang (como de costumbre) vive peligrosamente con una falsa dependencia del valor anterior de RAX, solo para ahorrar 1 byte de tamaño de código en la carga.

    – Peter Cordes

    12 de julio a las 9:35


  • A,h, por supuesto, a clang no le importan los valores superiores de 24 bits anteriores alpero una CPU que no realiza cambios de nombre parciales tiene que rastrear esos 24 bits hasta que se ponen a cero, dos instrucciones más abajo.

    – MSalters

    12 de julio a las 9:42

  • @Hintro: Tienes razón, la final movzx eax, al es una elección tonta por parte de ambos compiladores. Dado que necesitamos usar un tamaño de operando de 8 bits para devolver (int)(uint8_t)sumen las CPU modernas tendría más sentido movzx eax, byte [rdi] / add al, [rsi] (Código de GCC sin el movzx final), con -mtune=haswell o k8 .. znver3o si -mtune=generic no le importa mucho la familia Intel P6. Si desea terminar con movzx para Nehalem y versiones anteriores, bl sería una mala elección porque RBX conserva la llamada. Pero sí, movzx edx, byte [rdi] / add dl, [rsi] / movzx eax, dl sería bueno.

    – Peter Cordes

    12 de julio a las 9:43


  • @Hintro: es posible que desee informar un error de optimización perdido, especialmente para clang. Su generación de código ya es un gran dedo medio hacia la familia P6 la mayor parte del tiempo con el uso de registro parcial, por lo que probablemente estarían interesados ​​​​en intentar generar la versión de 2 instrucciones. github.com/llvm/llvm-proyecto/problemas y gcc.gnu.org/bugzilla/enter_bug.cgi?product=gcc (use la palabra clave miss-optimization para los errores de GCC. Siéntase libre de vincular esta publicación de desbordamiento de pila y/o citar cualquiera de mis comentarios si lo desea).

    – Peter Cordes

    12 de julio a las 9:58

  • eso no explica la versión compilada de GCC en la pregunta. Ahí eax ya fue borrado en la primera instrucción.

    – Jakob Stark

    12 de julio a las 8:45

  • @JakobStark: olvidas el posible acarreo.

    – Yves Daoust

    12 de julio a las 8:46

  • No entiendo esta respuesta. La convención de retorno es que los 32 bits en EAX deben ser correctos. No le importa un ápice cómo esos bits terminaron siendo correctos. El primero movzx en el código de GCC puso a cero los 24 bits superiores, y no hay necesidad de “volver a ponerlos a cero”.

    – MSalters

    12 de julio a las 8:55

  • los 8 bits agregar La instrucción suma dos valores de 8 bits y almacena el resultado en el byte más bajo del registro de destino. El posible desbordamiento y acarreo se señala mediante la bandera de acarreo, no el noveno bit en el registro.

    – Jakob Stark

    12 de julio a las 8:55

  • Esta respuesta ya no es incorrecta, pero no es realmente útil. Recomendaría la eliminación, ya que creo que las otras respuestas ahora cubren suficientemente el hecho de que el resultado tiene que ser una adición truncada cero extendida a 32 bits. (En la versión clang, no es cierto que el movzx tenga que ser allá, podría estar en la carga inicial. O podríamos empezar con xor eax,eax para lograr la extensión cero de una manera diferente; eso sería aún mejor para la familia P6, permitiéndonos terminar con un byte add sin una penalización de registro parcial para el lector.)

    – Peter Cordes

    13 de julio a las 7:42

¿Ha sido útil esta solución?

Esta web utiliza cookies propias y de terceros para su correcto funcionamiento y para fines analíticos y para mostrarte publicidad relacionada con sus preferencias en base a un perfil elaborado a partir de tus hábitos de navegación. Al hacer clic en el botón Aceptar, acepta el uso de estas tecnologías y el procesamiento de tus datos para estos propósitos. Configurar y más información
Privacidad