assembly x86-64 compiler-optimization abi sign-extension

assembly - ¿Se requiere un signo o una extensión cero al agregar un desplazamiento de 32 bits a un puntero para el x86-64 ABI?



compiler-optimization sign-extension (2)

Como el comentario de EOF indica que el compilador no puede suponer que los 32 bits superiores de un registro de 64 bits utilizados para pasar un argumento de 32 bits tienen un valor particular. Eso hace que el signo o la extensión cero sean necesarios.

La única forma de evitar esto sería usar un tipo de 64 bits para el argumento, pero esto mueve el requisito de extender el valor a la persona que llama, lo que puede no ser una mejora. Sin embargo, no me preocuparía demasiado el tamaño de los derrames de registros, ya que 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 derramará . Incluso si no está muerto, el compilador aún puede preferir derramar el valor de 64 bits.

Si está realmente preocupado por su huella de memoria y no necesita el espacio de direcciones de 64 bits más grande, puede mirar el x32 ABI que usa los tipos ILP32 pero admite el conjunto completo de instrucciones de 64 bits.

Resumen: Estaba mirando el código de ensamblaje para guiar mis optimizaciones y vi muchos signos o extensiones 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 el desafío de agregar números enteros de 32 bits a 64 bits, pero he confirmado este comportamiento con Intel ICC 11, ICC 14 y GCC 5.3.

Este thread confirma mis hallazgos, pero no está claro si el signo o la extensión cero son necesarios. 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 el x86-64 ABI lo suficientemente inteligente como para requerir eso?

Soy un poco reacio a cambiar todos mis desplazamientos de puntero a ssize_t porque los derrames de registro aumentarán la huella de caché del código.


Sí, debe suponer que los 32 bits altos de un registro de arg o valor de retorno contienen basura. Por otro lado, se le permite dejar basura en los 32 altos cuando llama o regresa. es decir, la carga está en el lado receptor para ignorar los bits altos, no en el lado que pasa para limpiar los bits altos.

Debe firmar o extender cero a 64 bits para usar el valor en una dirección efectiva de 64 bits. En el x32 ABI , gcc con frecuencia usa 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 utilizado como índice de matriz.

El estandar:

El x86-64 SysV ABI solo dice algo sobre qué partes de un registro se ponen a cero para _Bool (también conocido como bool ). Page 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 se dejan sin especificar, por lo tanto, el lado del consumidor de esos valores puede confíe en que sea 0 o 1 cuando se trunca a 8 bits)

Además, las cosas sobre %al mantener el número de argumentos de registro FP para funciones varargs, no todo el %rax .

Hay un problema de github abierto sobre esta pregunta exacta en la página de github para los documentos x32 y x86-64 ABI .

El ABI no establece ningún otro requisito o garantía sobre el contenido de las partes altas de 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 el ABI no dice que algo se especifica, no puede confiar en ello".

También confirmó que, por ejemplo, el uso de clang> = 3.6 de addps que podrían ralentizar o aumentar las 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 una implementación AMD de una función matemática glibc. El código C normal puede dejar basura en los elementos altos de los registros vectoriales al pasar los argumentos escalares double o float .

Comportamiento real que no está (todavía) documentado en el estándar:

Los argumentos de función estrecha, incluso _Bool / bool , son signos o cero-extendidos 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 , incluso para C. No llame a las funciones compiladas de clang desde el código compilado de ICC para el x86-64 SysV ABI, si alguno de los primeros 6 argumentos enteros son más estrechos que 32 bits.

Esto no se aplica a los valores de retorno, solo a los argumentos: gcc y clang asumen que los valores de retorno que reciben solo tienen datos válidos hasta el ancho del tipo. gcc realizará funciones que devuelven caracteres que dejan basura en los 24 bits altos de %eax , por ejemplo.

Un hilo reciente sobre 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 las personas que llaman y los que 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 esto que demuestra mucho):

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 que los bits altos de %rax se %rax cero en el valor de retorno de fuint() , el compilador podría haber usado array_us(%rbx, %rax, 2) lugar de usar add insn.

Implicaciones de rendimiento

Dejar el high32 indefinido 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 cero extiende su resultado a 64 bits de forma gratuita , por lo que solo necesita un mov edx, edi o algo adicional si hubiera podido usar el registro directamente en un modo de direccionamiento de 64 bits o una operación de 64 bits.

Algunas funciones no salvarán a ningún miembro de tener sus argumentos ya extendidos a 64 bits, por lo que es una pérdida 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 la persona que llama 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. Debido a que las reglas de arg se bloquean 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 necesita un 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 que solo verá EDI.

Los tamaños de operando de 16 y 8 bits a menudo conducen a falsas dependencias (AMD, P4 o Silvermont, y más tarde a la familia SnB), o paradas de registro parcial (pre SnB) o ralentizaciones menores (Sandybridge), por lo que el comportamiento indocumentado el hecho de requerir que los tipos 8 y 16b se extiendan a 32b para pasar argumentos tiene algún sentido. Consulte ¿Por qué GCC no utiliza registros parciales? para más detalles sobre esas microarquitecturas.

Esto probablemente no sea un gran problema para el tamaño del código en el código real, ya que las funciones pequeñas son / deberían ser static inline , y los insns de manejo de argumentos son una pequeña parte de las funciones más grandes . La optimización entre procedimientos puede eliminar la sobrecarga entre llamadas cuando el compilador puede ver ambas definiciones, incluso sin incluir en línea. (IDK qué tan bien los compiladores lo hacen en la práctica).

No estoy seguro de si cambiar las firmas de funciones para usar uintptr_t ayudará o perjudicará el rendimiento general con punteros de 64 bits. No me preocuparía por el espacio de pila para escalares. En la mayoría de las funciones, el compilador empuja / saca suficientes registros de llamadas preservadas (como %rbx y %rbp ) para mantener sus propias variables %rbx en los registros. Un pequeño espacio extra 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 insns que de otro modo no hubieran necesitado uno. La extensión cero a 64 bits ocurre de forma gratuita si se requiere alguna operación en un valor de 32 bits antes de que se use como un índice de matriz. La extensión de señal siempre toma una instrucción adicional si es necesaria. Pero los compiladores pueden extender-firmar 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 envolver, 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 recuento interno que por el tamaño interno, dentro de lo razonable. El código activo a menudo se ejecutará desde la caché uop en las CPU que los tienen. Aún así, un código más pequeño puede mejorar la densidad en el caché uop. Si puede guardar el tamaño del código sin usar insns más o más lentos, entonces es una victoria, pero generalmente 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 el direccionamiento [reg + disp8] para una docena de instrucciones posteriores, en lugar de disp32 . O xor eax,eax antes de múltiples mov [rdi+n], 0 instrucciones para reemplazar el imm32 = 0 con una fuente de registro. (Especialmente si eso permite la micro fusión donde no sería posible con un RIP relativo + inmediato, porque lo que realmente importa es el recuento de UOP de front-end, no el recuento de instrucciones).