¿Por qué GCC no usa registros parciales?

Desmontando write(1,"hi",3) en Linux, construido con gcc -s -nostdlib -nostartfiles -O3 resultado:

 ba03000000 mov edx, 3 ; thanks for the correction jester! bf01000000 mov edi, 1 31c0 xor eax, eax e9d8ffffff jmp loc.imp.write 

No estoy en el desarrollo del comstackdor, pero dado que cada valor movido en estos registros es constante y conocido en tiempo de comstackción, tengo curiosidad por qué gcc no usa dl , dil y al cambio. Algunos pueden argumentar que esta característica no hará ninguna diferencia en el rendimiento, pero hay una gran diferencia en el tamaño del ejecutable entre mov $1, %rax => b801000000 y mov $1, %al => b001 cuando hablamos de miles de accesos de registro en un progtwig. No solo el tamaño pequeño, si forma parte de la elegancia de un software, tiene un efecto en el rendimiento.

¿Alguien puede explicar por qué “GCC decidió” que no importa?

Los registros parciales implican una penalización de rendimiento en muchos procesadores x86 porque se les cambia el nombre a registros físicos diferentes de su contraparte completa cuando se escriben. (Para obtener más información sobre el cambio de nombre de registro que permite la ejecución fuera de orden, consulte esta sección de Preguntas y respuestas ).

Pero cuando una instrucción lee todo el registro, la CPU tiene que detectar el hecho de que no tiene el valor de registro arquitectónico correcto disponible en un solo registro físico. (Esto sucede en la etapa de problema / cambio de nombre, ya que la CPU se prepara para enviar el uop al progtwigdor fuera de servicio).

Se llama un puesto de registro parcial . El manual de microarchitecture de Agner Fog lo explica bastante bien:

6.8 puestos de registro parcial (PPro / PII / PIII y primeros Pentium-M)

El locking parcial es un problema que ocurre cuando escribimos en una parte de un registro de 32 bits y luego leemos todo el registro o una parte más grande de él.
Ejemplo:

 ; Example 6.10a. Partial register stall mov al, byte ptr [mem8] mov ebx, eax ; Partial register stall 

Esto da un retraso de 5 a 6 relojes . La razón es que se ha asignado un registro temporal a AL para hacerlo independiente de AH . La unidad de ejecución debe esperar hasta que la escritura en AL haya retirado antes de que sea posible combinar el valor de AL con el valor del rest de EAX .

Comportamiento en diferentes CPU :

  • Familia Intel P6 temprana: vea más arriba: deje de 5-6 relojes hasta que la escritura parcial se retire.
  • Intel Pentium-M (modelo D) / Core2 / Nehalem: locking durante 2-3 ciclos mientras se inserta un uop de fusión. (vea estas preguntas y respuestas para un microbenchmark que escribe AX y lee EAX con o sin xor-zeroing primero )
  • Intel Sandybridge: inserte un uop de fusión para low8 / low16 (AL / AX) sin estancamiento, o para AH / BH / CH / DH mientras se estanca durante 1 ciclo.
  • Intel IvyBridge (tal vez), pero definitivamente Haswell / Skylake: AL / AX no se renombra, pero AH aún lo es: ¿Cómo funcionan exactamente los registros parciales en Haswell / Skylake? Escribir AL parece tener una dependencia falsa en RAX, y AH es inconsistente .
  • Todas las demás CPU x86 : Intel Pentium4, Atom / Silvermont / Knight’s Landing. Todo AMD (y Vía, etc.):

    Los registros parciales nunca se renombran. Escribir un registro parcial se fusiona en el registro completo, haciendo que la escritura dependa del valor anterior del registro completo como una entrada.

Sin el cambio de nombre de registro parcial, la dependencia de entrada para la escritura es una dependencia falsa si nunca lee el registro completo. Esto limita el paralelismo de nivel de instrucción porque reutilizar un registro de 8 o 16 bits para otra cosa no es realmente independiente del punto de vista de la CPU (el código de 16 bits puede acceder a registros de 32 bits, por lo que debe mantener valores correctos en la parte superior mitades). Y también, hace que AL y AH no sean independientes. Cuando Intel diseñó la familia P6 (PPro lanzado en 1993), el código de 16 bits aún era común, por lo que el cambio de nombre de registro parcial era una característica importante para hacer que el código de máquina existente fuera más rápido. (En la práctica, muchos binarios no se vuelven a comstackr para nuevas CPU).

Es por eso que los comstackdores en su mayoría evitan escribir registros parciales. Usan movzx / movsx siempre que sea posible para movsx a cero o extender los valores estrechos a un registro completo para evitar el registro parcial de dependencias falsas (AMD) o puestos (familia Intel P6). Por lo tanto, la mayoría de los códigos de máquina modernos no se benefician demasiado del cambio de nombre de registro parcial, por lo que las CPU Intel recientes simplifican su lógica de cambio de nombre de registro parcial.

Como señala la respuesta de @ BeeOnRope , los comstackdores aún leen registros parciales, porque eso no es un problema. (Leer AH / BH / CH / DH puede agregar un ciclo extra de latencia en Haswell / Skylake, sin embargo, consulte el enlace anterior sobre registros parciales en miembros recientes de la familia Sandybridge).


También tenga en cuenta que write toma argumentos que, para un GCC x86-64 típicamente configurado, necesitan registros enteros de 32 y 64 bits, por lo que no podrían simplemente ensamblarse en mov dl, 3 . El tamaño está determinado por el tipo de datos, no el valor de los datos.

Finalmente, en ciertos contextos, C tiene promociones de argumento predeterminadas para tener en cuenta, aunque este no es el caso .
En realidad, como señaló RossRidge , la llamada probablemente se hizo sin un prototipo visible.


Su desassembly es engañoso, como señaló @Jester.
Por ejemplo, mov rdx, 3 es en realidad mov edx, 3 , aunque ambos tienen el mismo efecto, es decir, poner 3 en todo el rdx .
Esto es cierto porque un valor inmediato de 3 no requiere extensión de signo y un MOV r32, imm32 borra implícitamente los 32 bits superiores del registro.

De hecho, gcc muy a menudo usa registros parciales . Si mira el código generado, encontrará muchos casos donde se usan registros parciales.

La respuesta corta para su caso particular se debe a que gcc siempre firma o amplía cero argumentos a 32 bits cuando llama a una función C ABI .

Los SysV x86 y x86-64 de facto adoptados por gcc y clang requieren que los parámetros menores a 32 bits sean cero o estén extendidos a 32 bits. Curiosamente, no necesitan extenderse hasta 64 bits.

Por lo tanto, para una función como la siguiente en una plataforma SysV ABI de plataforma de 64 bits:

 void foo(short s) { ... } 

… el argumento s se pasa en rdi y los bits de s serán los siguientes (pero vea mi advertencia a continuación sobre el icc ):

  bits 0-31: SSSSSSSS SSSSSSSS SPPPPPPP PPPPPPPP bits 32-63: XXXXXXXX XXXXXXXX XXXXXXXX XXXXXXXX where: P: the bottom 15 bits of the value of `s` S: the sign bit of `s` (extended into bits 16-31) X: arbitrary garbage 

El código para foo puede depender de los bits S y P , pero no de los bits X , que pueden ser cualquier cosa.

Del mismo modo, para foo_unsigned(unsigned short u) , tendría 0 en los bits 16-31, pero de lo contrario sería idéntico.

Tenga en cuenta que dije de hecho, porque en realidad no está realmente documentado qué hacer para los tipos de devolución más pequeños, pero puede ver la respuesta de Peter aquí para más detalles. También hice una pregunta relacionada aquí .

Después de algunas pruebas adicionales, llegué a la conclusión de que el icc realmente rompe este estándar de facto. gcc y clang parecen adherirse a él, pero gcc solo de forma conservadora: cuando se llama a una función, hace cero / extiende argumentos a 32 bits, pero en sus implementaciones de función no depende de que la persona que llama lo esté haciendo . clang implementa funciones que dependen de que la persona que llama amplíe los parámetros a 32 bits. De hecho, clang e icc son mutuamente incompatibles incluso para funciones C simples si tienen algún parámetro más pequeño que int .

Tenga en cuenta que el uso de -O3 explícitamente le pide al comstackdor que favorezca agresivamente el rendimiento sobre el tamaño del código. Use -Os tamaño si no está listo para sacrificar alrededor del 20% del tamaño.

En algo parecido al PC original de IBM, si se sabía que AH contenía 0 y era necesario cargar AX con un valor como 0x34, usar “MOV AL, 34h” generalmente tomaría 8 ciclos en lugar de los 12 requeridos para “MOV AX”. 0034h “- una mejora de velocidad bastante grande (cualquiera de las instrucciones podría ejecutarse en 2 ciclos si se realiza una búsqueda previa, pero en la práctica el 8088 pasa la mayor parte del tiempo esperando que se obtengan las instrucciones a un costo de cuatro ciclos por byte). Sin embargo, en los procesadores utilizados en las computadoras de uso general actuales, el tiempo requerido para obtener el código generalmente no es un factor significativo en la velocidad de ejecución general, y el tamaño del código normalmente no es una preocupación particular.

Además, los proveedores de procesadores tratan de maximizar el rendimiento de los tipos de código que es probable que ejecuten las personas, y las instrucciones de carga de 8 bits probablemente no se usen casi tan a menudo hoy en día como las instrucciones de carga de 32 bits. Los núcleos del procesador a menudo incluyen lógica para ejecutar múltiples instrucciones de 32 bits o de 64 bits simultáneamente, pero pueden no incluir lógica para ejecutar una operación de 8 bits simultáneamente con cualquier otra cosa. En consecuencia, si bien utilizar operaciones de 8 bits en el 8088, cuando fue posible, fue una optimización útil en el 8088, en realidad puede ser un drenaje de rendimiento significativo en los procesadores más nuevos.