¿Por qué GCC no utiliza registros parciales?
assembly x86 (3)
De hecho, gcc muy a menudo usa registros parciales . Si observa el código generado, encontrará muchos casos en los que se utilizan registros parciales.
La respuesta corta para su caso particular es que gcc siempre firma o extiende los argumentos a cero a 32 bits cuando llama a una función C ABI .
El SysV x86 y x86-64 ABI de
facto
adoptado por
gcc
y
clang
requiere que los parámetros más pequeños que 32 bits sean cero o con signo extendido a 32 bits.
Curiosamente, no necesitan extenderse hasta 64 bits.
Entonces, 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 con respecto a
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 facto , porque en realidad no está realmente documentado qué hacer para los tipos de retorno más pequeños, pero puede ver la respuesta de Peter aquí para más detalles. También hice una pregunta relacionada here .
Después de algunas pruebas adicionales, concluí que
icc
realidad rompe este estándar de facto.
gcc
y
clang
parecen adherirse a él, pero
gcc
solo de manera conservadora: cuando
llama a
una función, hace argumentos de extensión cero / signo a 32 bits, pero en sus implementaciones de función no
depende
de la persona que realiza la llamada .
clang
implementa funciones que dependen de que el llamante extienda los parámetros a 32 bits.
De hecho,
clang
e
icc
son mutuamente incompatibles incluso para las funciones simples de C si tienen parámetros más pequeños que
int
.
Desmontando
write(1,"hi",3)
en linux, construido con
gcc -s -nostdlib -nostartfiles -O3
resulta en:
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 compilador, pero dado que cada valor movido a estos registros es constante y conocido en tiempo de compilación, tengo curiosidad por qué gcc no usa
dl
,
dil
y
al
lugar.
Algunos pueden argumentar que esta característica no hará ninguna diferencia en el rendimiento, pero hay una gran diferencia en el tamaño ejecutable entre
mov $1, %rax => b801000000
y
mov $1, %al => b001
cuando estamos hablando de miles de accesos de registro en un programa.
No solo el tamaño pequeño es parte de la elegancia de un software, sino que tiene efecto en el rendimiento.
¿Alguien puede explicar por qué "GCC decidió" que no importa?
En algo como la PC IBM original, 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 busca previamente, pero en la práctica el 8088 pasa la mayor parte del tiempo esperando que las instrucciones se obtengan a un costo de cuatro ciclos por byte). Sin embargo, en los procesadores utilizados en las computadoras de uso general de hoy en día, el tiempo requerido para obtener el código generalmente no es un factor significativo en la velocidad general de ejecución, y el tamaño del código normalmente no es una preocupación particular.
Además, los proveedores de procesadores intentan maximizar el rendimiento de los tipos de código que las personas pueden ejecutar, y las instrucciones de carga de 8 bits no se usan con tanta frecuencia hoy en día como las instrucciones de carga de 32 bits. Los núcleos de procesador a menudo incluyen lógica para ejecutar múltiples instrucciones de 32 bits o 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 una pérdida significativa de rendimiento en los procesadores más nuevos.
Los registros parciales implican una penalización de rendimiento en muchos procesadores x86 porque se renombran en diferentes registros físicos de toda su contraparte cuando se escriben. (Para obtener más información sobre el cambio de nombre del registro que permite la ejecución fuera de orden, consulte estas 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 disponible en un solo registro físico. (Esto sucede en la etapa de emisión / cambio de nombre, ya que la CPU se prepara para enviar el uop al planificador fuera de servicio).
Se llama un puesto de registro parcial . El manual de microarquitectura de Agner Fog lo explica bastante bien:
6.8 Puestos de registro parcial (PPro / PII / PIII y Pentium-M temprano)
El bloqueo parcial del registro es un problema que ocurre cuando escribimos en una parte de un registro de 32 bits y luego leemos en todo el registro o en una parte más grande.
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 que sea independiente deAH
. La unidad de ejecución tiene que esperar hasta que la escritura enAL
haya retirado antes de que sea posible combinar el valor deAL
con el valor del resto deEAX
.
Comportamiento en diferentes CPU :
- Familia Intel P6 temprana: ver arriba: parada de 5-6 relojes hasta que se retiren las escrituras parciales.
- Intel Pentium-M (modelo D) / Core2 / Nehalem: parada durante 2-3 ciclos mientras se inserta una uop de fusión. (consulte estas preguntas y respuestas para obtener un microbenchmark que escribe AX y lee EAX con o sin xor-zeroing primero )
- Intel Sandybridge: inserte una combinación uop para low8 / low16 (AL / AX) sin bloqueo, o para AH / BH / CH / DH mientras se bloquea durante 1 ciclo.
- Intel IvyBridge (tal vez), pero definitivamente Haswell / Skylake: AL / AX no cambian de nombre, pero AH sigue siendo: ¿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. Todos los AMD (y Vía, etc.):
Los registros parciales nunca cambian de nombre. Escribir un registro parcial se fusiona con el registro completo, lo que hace que la escritura dependa del valor anterior del registro completo como entrada.
Sin 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 a nivel de instrucción porque la reutilización de 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 los registros de 32 bits, por lo que debe mantener los 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 todavía era común, por lo que el cambio de nombre del registro parcial era una característica importante para hacer que el código de máquina existente se ejecutara más rápido. (En la práctica, muchos binarios no se vuelven a compilar para las nuevas CPU).
Es por eso que los compiladores en su mayoría evitan
escribir
registros parciales.
Utilizan
movzx
/
movsx
siempre que sea posible para ampliar o reducir los valores estrechos a un registro completo para evitar dependencias falsas de registro parcial (AMD) o paradas (familia Intel P6).
Por lo tanto, el código de máquina más moderno no se beneficia mucho del cambio de nombre de registro parcial, razón por la cual las CPU Intel recientes están simplificando su lógica de cambio de nombre de registro parcial.
Como señala la respuesta de @ BeeOnRope , los compiladores aún leen registros parciales, porque eso no es un problema. (Leer AH / BH / CH / DH puede agregar un ciclo adicional de latencia en Haswell / Skylake, sin embargo, vea el enlace anterior sobre registros parciales en miembros recientes de la familia Sandybridge).
También tenga en cuenta
que la
write
toma argumentos que, para un GCC x86-64 configurado típicamente, necesitan registros completos de 32 bits y 64 bits, por lo que no se puede ensamblar simplemente en
mov dl, 3
.
El tamaño está determinado por el
tipo
de datos, no por el
valor
de los datos.
Finalmente, en ciertos contextos, C tiene
promociones de argumentos por defecto
a 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 desmontaje 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.