¿Se requiere una extensión de signo o cero al agregar un desplazamiento de 32 bits a un puntero para la ABI x86-64?
Resumen: Estaba mirando el código ensamblador para guiar mis optimizaciones y vi muchas extensiones de signo o cero al agregar int32 a un puntero.
void Test(int *out, int offset)
{
out[offset] = 1;
}
-------------------------------------
movslq %esi, %rsi
movl $1, (%rdi,%rsi,4)
ret
Al principio, pensé que mi compilador tenía problemas para agregar enteros de 32 bits a 64 bits, pero confirmé este comportamiento con Intel ICC 11, ICC 14 y GCC 5.3.
Este hilo confirma mis hallazgos, pero no está claro si la extensión de signo o cero es necesaria. Esta extensión de signo/cero solo sería necesaria si los 32 bits superiores aún no están configurados. ¿Pero no sería la ABI x86-64 lo suficientemente inteligente como para requerir eso?
Soy un poco reacio a cambiar todas las compensaciones de mi puntero a ssize_t porque los derrames de registros aumentarán la huella de caché del código.
Sí, debe asumir que los 32 bits superiores de un registro de argumento o valor de retorno contienen basura. Por otro lado, puedes dejar basura en el nivel 32 alto cuando llamas o regresas. es decir, la carga de ignorar los bits altos recae en el lado receptor, no en el lado que pasa la tarea de limpiar los bits altos.
Debe firmar o extender a cero a 64 bits para usar el valor en una dirección efectiva de 64 bits. En x32 ABI , gcc usa con frecuencia direcciones efectivas de 32 bits en lugar de usar un tamaño de operando de 64 bits para cada instrucción que modifica un entero potencialmente negativo usado como índice de matriz.
El estandar:
La ABI x86-64 SysV solo dice algo sobre qué partes de un registro se ponen a cero _Bool
(también conocido como bool
). Página 20:
Cuando se devuelve o pasa un valor de tipo
_Bool
en un registro o en la pila, el bit 0 contiene el valor de verdad y los bits 1 a 7 serán cero (nota al pie 14: otros bits no se especifican, por lo tanto, el lado del consumidor de esos valores puede confiar si es 0 o 1 cuando se trunca a 8 bits)
Además, lo relacionado con %al
mantener el número de argumentos de registro FP para funciones varargs, no todo %rax
.
Hay un problema abierto de github sobre esta pregunta exacta en la página de github para los documentos ABI x32 y x86-64 .
La ABI no impone ningún requisito o garantía adicional sobre el contenido de las partes altas de los registros enteros o vectoriales que contienen argumentos o valores de retorno, por lo que no hay ninguno. Tengo confirmación de este hecho por correo electrónico de Michael Matz (uno de los mantenedores de ABI): "Generalmente, si la ABI no dice que algo está especificado, no puedes confiar en ello".
También confirmó que, por ejemplo, el uso de clang >= 3.6 de un elemento addps
que podría ralentizar o generar excepciones de FP adicionales con basura en elementos altos es un error (lo que me recuerda que debo informar eso). Agrega que esto fue un problema una vez con la implementación de AMD de una función matemática glibc. El código C normal puede dejar basura en elementos altos de reglas vectoriales al pasar escalares double
o float
argumentos.
Comportamiento real que no está (aún) documentado en la norma:
Los argumentos de función estrecha, incluso _Bool
/ bool
, tienen signo o extensión cero a 32 bits. clang incluso crea código que depende de este comportamiento (aparentemente desde 2007) . ICC17 no lo hace , por lo que ICC y clang no son compatibles con ABI , ni siquiera para C. No llame a funciones compiladas con clang desde el código compilado por ICC para la ABI SysV x86-64, si alguno de los primeros 6 argumentos enteros son más estrechos que los de 32 bits.
Esto no se aplica a los valores de retorno, solo args: gcc y clang asumen que los valores de retorno que reciben solo tienen datos válidos hasta el ancho del tipo. gcc hará que regresen funciones char
que dejen basura en los 24 bits superiores de %eax
, por ejemplo.
Un hilo reciente en el grupo de discusión de ABI fue una propuesta para aclarar las reglas para extender argumentos de 8 y 16 bits a 32 bits, y tal vez modificar la ABI para requerir esto. Los principales compiladores (excepto ICC) ya lo hacen, pero sería un cambio en el contrato entre quienes llaman y quienes llaman.
Aquí hay un ejemplo (compruébelo con otros compiladores o modifique el código en Godbolt Compiler Explorer , donde he incluido muchos ejemplos simples que solo demuestran una pieza del rompecabezas, además de este que demuestra muchas cosas):
extern short fshort(short a);
extern unsigned fuint(unsigned int a);
extern unsigned short array_us[];
unsigned short lookupu(unsigned short a) {
unsigned int a_int = a + 1234;
a_int += fshort(a); // NOTE: not the same calls as the signed lookup
return array_us[a + fuint(a_int)];
}
# clang-3.8 -O3 for x86-64. arg in %rdi. (Actually in %di, zero-extended to %edi by our caller)
lookupu(unsigned short):
pushq %rbx # save a call-preserved reg for out own use. (Also aligns the stack for another call)
movl %edi, %ebx # If we didn't assume our arg was already zero-extended, this would be a movzwl (aka movzx)
movswl %bx, %edi # sign-extend to call a function that takes signed short instead of unsigned short.
callq fshort(short)
cwtl # Don't trust the upper bits of the return value. (This is cdqe, Intel syntax. eax = sign_extend(ax))
leal 1234(%rbx,%rax), %edi # this is the point where we'd get a wrong answer if our arg wasn't zero-extended. gcc doesn't assume this, but clang does.
callq fuint(unsigned int)
addl %ebx, %eax # zero-extends eax to 64bits
movzwl array_us(%rax,%rax), %eax # This zero-extension (instead of just writing ax) is *not* for correctness, just for performance: avoid partial-register slowdowns if the caller reads eax
popq %rbx
retq
Nota: movzwl array_us(,%rax,2)
sería equivalente, pero no más pequeño. Si pudiéramos depender de los bits altos de %rax
ser puestos a cero en fuint()
el valor de retorno, el compilador podría haber usado array_us(%rbx, %rax, 2)
en lugar de usar el add
insn.
Implicaciones de rendimiento
Dejar high32 sin definir es intencional y creo que es una buena decisión de diseño.
Ignorar los 32 altos es gratis cuando se realizan operaciones de 32 bits. Una operación de 32 bits extiende cero su resultado a 64 bits de forma gratuita , por lo que solo necesita un extra mov edx, edi
o algo así si pudiera haber usado el registro directamente en un modo de direccionamiento de 64 bits o en una operación de 64 bits.
Algunas funciones no evitarán que ningún usuario tenga sus argumentos ya extendidos a 64 bits, por lo que es un desperdicio potencial para las personas que llaman tener que hacerlo siempre. Algunas funciones usan sus argumentos de una manera que requiere la extensión opuesta a la firma del argumento, por lo que dejar que el destinatario decida qué hacer funciona bien.
Sin embargo, la extensión cero a 64 bits, independientemente de la firma, sería gratuita para la mayoría de las personas que llaman y podría haber sido una buena opción de diseño ABI. Dado que los registros de arg están bloqueados de todos modos, la persona que llama ya necesita hacer algo adicional si quiere mantener un valor completo de 64 bits en una llamada donde solo pasa los 32 bajos. Por lo tanto, generalmente solo cuesta más cuando se necesita un valor de 64 bits. resultado de algo antes de la llamada y luego pasar una versión truncada a una función. En x86-64 SysV, puede generar su resultado en RDI y usarlo, y luego call foo
solo mirará EDI.
Los tamaños de operandos de 16 y 8 bits a menudo conducen a dependencias falsas (AMD, P4 o Silvermont, y más tarde la familia SnB), o paradas de registro parcial (antes de SnB) o desaceleraciones menores (Sandybridge), por lo que el comportamiento no documentado Tiene algún sentido exigir que los tipos 8 y 16b se extiendan a 32b para el paso de argumentos. Consulte ¿Por qué GCC no utiliza registros parciales? para obtener más detalles sobre esas microarquitecturas.
Probablemente esto no sea un gran problema para el tamaño del código en código real, ya que las funciones pequeñas son/deberían ser static inline
, y las insns de manejo de argumentos son una pequeña parte de funciones más grandes . La optimización entre procedimientos puede eliminar la sobrecarga entre llamadas cuando el compilador puede ver ambas definiciones, incluso sin incluirlas. (No sé qué tan bien les va a los compiladores en esto en la práctica).
No estoy seguro de si cambiar las firmas de funciones uintptr_t
ayudará o perjudicará el rendimiento general con punteros de 64 bits. No me preocuparía por el espacio de pila para los escalares. En la mayoría de las funciones, el compilador inserta/extrae suficientes registros conservados de llamadas (como %rbx
y %rbp
) para mantener sus propias variables activas en los registros. Un poquito de espacio adicional para derrames de 8B en lugar de 4B es insignificante.
En cuanto al tamaño del código, trabajar con valores de 64 bits requiere un prefijo REX en algunos dispositivos que de otro modo no lo habrían necesitado. La extensión cero a 64 bits se realiza de forma gratuita si se requiere alguna operación en un valor de 32 bits antes de que se utilice como índice de matriz. La extensión de signo siempre requiere una instrucción adicional si es necesario. Pero los compiladores pueden firmar y extender y trabajar con él como un valor firmado de 64 bits desde el principio para guardar instrucciones, a costa de necesitar más prefijos REX. (El desbordamiento firmado es UB, no está definido para ajustarse, por lo que los compiladores a menudo pueden evitar rehacer la extensión de signo dentro de un bucle con un int i
que usa arr[i]
).
Las CPU modernas generalmente se preocupan más por el número de unidades que con el tamaño de las unidades, dentro de lo razonable. El código activo a menudo se ejecutará desde la caché de uop en las CPU que lo tienen. Aún así, un código más pequeño puede mejorar la densidad en la caché de uop. Si puede ahorrar el tamaño del código sin utilizar más o más lentos insns, entonces es una victoria, pero normalmente no vale la pena sacrificar nada más a menos que sea un gran tamaño de código.
Como tal vez una instrucción LEA adicional para permitir [reg + disp8]
abordar una docena de instrucciones posteriores, en lugar de disp32
. O xor eax,eax
antes de múltiples mov [rdi+n], 0
instrucciones para reemplazar imm32=0 con una fuente de registro. (Especialmente si eso permite la microfusión donde no sería posible con un RIP-relativo + inmediato, porque lo que realmente importa es el recuento de operaciones de front-end, no el recuento de instrucciones).
Como indica el comentario de EOF, el compilador no puede asumir que los 32 bits superiores de un registro de 64 bits utilizado para pasar un argumento de 32 bits tengan algún valor particular. Eso hace necesaria la extensión del signo o cero.
La única forma de evitar esto sería utilizar un tipo de 64 bits para el argumento, pero esto traslada el requisito de extender el valor al autor de la llamada, lo que puede no ser una mejora. Sin embargo, no me preocuparía demasiado por el tamaño de los derrames de registros, ya que de la forma en que lo está haciendo ahora, probablemente sea más probable que después de la extensión el valor original esté muerto y sea el valor extendido de 64 bits el que se derrame. . Incluso si no está muerto, es posible que el compilador prefiera derramar el valor de 64 bits.
Si está realmente preocupado por su uso de memoria y no necesita el espacio de direcciones de 64 bits más grande, puede consultar la ABI x32 que utiliza los tipos ILP32 pero admite el conjunto completo de instrucciones de 64 bits.