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

Resuelto Yale Zhang asked hace 8 años • 2 respuestas

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.

Yale Zhang avatar Apr 19 '16 08:04 Yale Zhang
Aceptado

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 _Boolen 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 %almantener 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 addpsque 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 doubleo floatargumentos.


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 charque 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 %raxser 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 addinsn.


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, edio 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 foosolo 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_tayudará 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 %rbxy %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 ique 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,eaxantes de múltiples mov [rdi+n], 0instrucciones 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).

Peter Cordes avatar Apr 21 '2016 05:04 Peter Cordes

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.

Ross Ridge avatar Apr 19 '2016 03:04 Ross Ridge