assembly x86-64 abi

assembly - ¿Por qué el sistema V/AMD64 ABI exige una alineación de la pila de 16 bytes?



x86-64 (1)

He leído en diferentes lugares que se hace por "razones de rendimiento", pero todavía me pregunto cuáles son los casos particulares en los que el rendimiento mejora con esta alineación de 16 bytes. O, en cualquier caso, cuáles fueron las razones por las que se eligió esto.

editar : Estoy pensando que escribí la pregunta de manera engañosa. No estaba preguntando por qué el procesador hace las cosas más rápido con una memoria alineada de 16 bytes, esto se explica en todas partes en los documentos. Lo que quería saber, en cambio, es cómo la alineación forzada de 16 bytes es mejor que simplemente dejar que los programadores alineen la pila ellos mismos cuando sea necesario. Pregunto esto porque, según mi experiencia con el ensamblaje, la aplicación de la pila tiene dos problemas: solo es útil en menos del 1% por ciento del código que se ejecuta (por lo que en el otro 99% es realmente una sobrecarga); y también es una fuente muy común de errores. Así que me pregunto cómo realmente vale la pena al final. Si bien aún tengo dudas sobre esto, acepto la respuesta de Peter, ya que contiene la respuesta más detallada a mi pregunta original.


Tenga en cuenta que la versión actual del i386 System V ABI utilizada en Linux también requiere una alineación de pila de 16 bytes 1 . Consulte https://sourceforge.net/p/fbc/bugs/659/ para ver un poco de historia.

SSE2 es la línea de base para x86-64 , y hacer que el ABI sea eficiente para tipos como __m128 , y para la auto-vectorización del compilador, fue uno de los objetivos de diseño, creo. El ABI tiene que definir cómo se pasan tales argumentos como argumentos de función, o por referencia.

La alineación de 16 bytes a veces es útil para las variables locales en la pila (especialmente las matrices), y garantizar la alineación de 16 bytes significa que los compiladores pueden obtenerla gratis siempre que sea útil, incluso si la fuente no lo solicita explícitamente.

Si no se conocía la alineación de la pila con respecto a un límite de 16 bytes, cada función que quisiera un local alineado necesitaría un and rsp, -16 e instrucciones adicionales para guardar / restaurar rsp después de un desplazamiento desconocido a rsp ( 0 o -8 ). por ejemplo, usando rbp para un puntero de cuadro.

Sin AVX, los operandos de la fuente de memoria deben estar alineados a 16 bytes. ej. paddd xmm0, [rsp+rdi] falla si el operando de la memoria está desalineado. Entonces, si no se conoce la alineación, tendría que usar movups xmm1, [rsp+rdi] / paddd xmm0, xmm1 , o escribir un prólogo / epílogo de bucle para manejar los elementos desalineados. Para los arreglos locales que el compilador desea auto-vectorizar, simplemente puede elegir alinearlos en 16.

También tenga en cuenta que las primeras CPU x86 (antes de Nehalem / Bulldozer) tenían una instrucción movups que es más lenta que los movaps incluso cuando el puntero resulta estar alineado. (es decir, las cargas / almacenes no alineados en los datos alineados fueron muy lentos, así como también evitaron el plegamiento de cargas en una instrucción ALU). (Consulte las guías de optimización de Agner Fog, la guía de microarquitectura y las tablas de instrucciones para obtener más información sobre todo lo anterior).

Estos factores son la razón por la cual una garantía es más útil que simplemente "generalmente" mantener la pila alineada. Permitir hacer código que realmente falla en una pila desalineada permite más oportunidades de optimización.

Las matrices alineadas también aceleran la memcpy vectorizada memcpy / strcmp / cualquier función que no pueda asumir la alineación, sino que la comprueban y pueden saltar directamente a sus bucles de vector completo.

De una versión reciente del x86-64 System V ABI (r252) :

Una matriz usa la misma alineación que sus elementos, excepto que una variable de matriz local o global de longitud de al menos 16 bytes o una variable de matriz de longitud variable C99 siempre tiene una alineación de al menos 16 bytes. 4

4 El requisito de alineación permite el uso de instrucciones SSE cuando se opera en la matriz. En general, el compilador no puede calcular el tamaño de una matriz de longitud variable (VLA), pero se espera que la mayoría de los VLA requieran al menos 16 bytes, por lo que es lógico exigir que los VLA tengan al menos una alineación de 16 bytes.

Esto es un poco agresivo, y en su mayoría solo ayuda cuando las funciones que se auto-vectorizan pueden integrarse, pero generalmente hay otros locales que el compilador puede rellenar en cualquier espacio para que no desperdicie espacio en la pila. Y no desperdicia instrucciones mientras haya una alineación de pila conocida. (Obviamente, los diseñadores de ABI podrían haber omitido esto si hubieran decidido no requerir la alineación de la pila de 16 bytes).

Derrame / recarga de __m128

Por supuesto, es libre de hacer alignas(16) char buf[1024]; u otros casos donde la fuente solicita una alineación de 16 bytes.

Y también hay __m128 / __m128d / __m128i locales. Es posible que el compilador no pueda mantener todos los vectores locales en los registros (p. Ej., En una llamada de función, o no hay suficientes registros), por lo que necesita poder derramarlos / recargarlos con movaps , o como un operando de fuente de memoria para instrucciones ALU , por razones de eficiencia discutidas anteriormente.

Las cargas / tiendas que en realidad se dividen en un límite de línea de caché (64 bytes) tienen penalizaciones de latencia significativas y también penalizaciones de rendimiento menores en las CPU modernas. La carga necesita datos de 2 líneas de caché separadas, por lo que requiere dos accesos al caché. (Y potencialmente se pierden 2 cachés, pero eso es raro para la memoria de pila).

Creo que los movups ya tenían ese costo movups para los vectores en las CPU más antiguas donde es costoso, pero todavía apesta. Ampliar un límite de página de 4k es mucho peor (en CPU antes de Skylake), con una carga o almacenamiento que toma ~ 100 ciclos si toca bytes en ambos lados de un límite de 4k. (También necesita 2 comprobaciones de TLB). La alineación natural hace que las divisiones en cualquier límite más amplio sean imposibles , por lo que la alineación de 16 bytes fue suficiente para todo lo que puede hacer con SSE2.

max_align_t tiene una alineación de 16 bytes en el sistema x86-64 ABI del sistema V, debido al long double (x87 de 10 bytes / 80 bits). Se define como relleno a 16 bytes por alguna extraña razón, a diferencia del código de 32 bits donde sizeof(long double) == 10 . La carga / almacenamiento x87 de 10 bytes es bastante lenta de todos modos (como 1/3 del rendimiento de carga de double o float en Core2, 1/6 en P4 o 1/8 en K8), pero tal vez las penalizaciones de línea de caché y división de página fueron tan malo en las CPU más antiguas que decidieron definirlo de esa manera. Creo que en las CPU modernas (tal vez incluso Core2) el bucle sobre una matriz de long double no sería más lento con 10 bytes empaquetados, porque el fld m80 sería un cuello de botella más grande que una línea de caché dividida cada ~ 6.4 elementos.

En realidad, el ABI se definió antes de que el silicio estuviera disponible para la evaluación comparativa ( en ~ 2000 ), pero esos números K8 son los mismos que K7 (el modo de 32 bits / 64 bits es irrelevante aquí). Hacer long double 16 bytes hace posible copiar uno solo con movaps , a pesar de que no puede hacer nada con él en los registros XMM. (Excepto manipular el bit de signo con xorps / andps / andps )

Relacionado: esta definición max_align_t significa que malloc siempre devuelve memoria alineada de 16 bytes en código x86-64. Esto le permite evitar usarlo para cargas alineadas con SSE como _mm_load_ps , pero dicho código puede romperse cuando se compila para 32 bits donde alignof(max_align_t) es solo 8. (Use aligned_alloc o lo que sea).

Otros factores ABI incluyen pasar valores __m128 en la pila (después de que xmm0-7 tenga los primeros 8 argumentos flotantes / vectoriales). Tiene sentido exigir una alineación de 16 bytes para los vectores en la memoria, de modo que puedan ser utilizados eficientemente por la persona que llama y almacenados de manera eficiente por la persona que llama. Mantener la alineación de la pila de 16 bytes en todo momento facilita las funciones que necesitan alinear un poco de espacio para pasar argumentos por 16.

Hay tipos como __m128 que las garantías ABI tienen una alineación de 16 bytes . Si define un local y toma su dirección, y pasa ese puntero a alguna otra función, ese local debe estar suficientemente alineado. Por lo tanto, mantener la alineación de la pila de 16 bytes va de la mano con dar algunos tipos de alineación de 16 bytes, lo que obviamente es una buena idea.

En estos días, es bueno que atomic<struct_of_16_bytes> pueda obtener una alineación económica de 16 bytes, por lo que lock cmpxchg16b nunca cruza un límite de línea de caché. Para el caso realmente raro en el que tiene un local atómico con almacenamiento automático, y le pasa punteros a múltiples hilos ...

Nota 1: Linux de 32 bits

No todas las plataformas de 32 bits rompieron la compatibilidad con los binarios existentes y los elementos escritos a mano como lo hizo Linux; algunos como i386 NetBSD todavía solo usan el requisito histórico de alineación de pila de 4 bytes de la versión original del i386 SysV ABI.

La alineación histórica de la pila de 4 bytes también fue insuficiente para un double eficiente de 8 bytes en las CPU modernas. Los fld / fstp no fstp son generalmente eficientes, excepto cuando cruzan un límite de línea de caché (como otras cargas / tiendas), por lo que no es horrible, pero alineado naturalmente es bueno.

Incluso antes de que la alineación de 16 bytes formara parte oficial de la ABI, GCC solía habilitar -mpreferred-stack-boundary=4 (2 ^ 4 = 16 bytes) en 32 bits. Actualmente, esto supone que la alineación de la pila entrante es de 16 bytes (incluso para los casos que fallarán si no es así), así como también preservar esa alineación. No estoy seguro de si las versiones históricas de gcc solían tratar de preservar la alineación de la pila sin depender de ello para la corrección de los objetos SSE code-gen o alignas(16) .

ffmpeg es un ejemplo bien conocido que depende del compilador para darle la alineación de la pila: ¿qué es la "alineación de la pila"? , por ejemplo, en Windows de 32 bits.

El gcc moderno aún emite código en la parte superior de main para alinear la pila en 16 (incluso en Linux donde el ABI garantiza que el núcleo inicia el proceso con una pila alineada), pero no en la parte superior de ninguna otra función. Podrías usar -mincoming-stack-boundary para decirle a gcc qué tan alineado debería asumir que la pila está al generar código.

La antigua gcc4.1 no parecía respetar realmente __attribute__((aligned(16))) o 32 para el almacenamiento automático, es decir, no molesta en alinear la pila extra en este ejemplo en Godbolt , por lo que la antigua gcc tiene una especie de pasado a cuadros cuando se trata de la alineación de la pila. Creo que el cambio de la ABI oficial de Linux a la alineación de 16 bytes ocurrió primero como un cambio de facto, no como un cambio bien planificado. No descubrí nada oficial cuando ocurrió el cambio, pero creo que entre 2005 y 2010, después de que x86-64 se hizo popular y la alineación de la pila de 16 bytes del System V ABI x86-64 resultó útil.

Al principio, fue un cambio en la generación de códigos de GCC para usar más alineamiento que el ABI requerido (es decir, usar un ABI más estricto para el código compilado por gcc), pero luego se escribió en la versión del i386 System V ABI mantenido en https : //github.com/hjl-tools/x86-psABI/wiki/X86-psABI (que es oficial para Linux al menos).

@MichaelPetch y @ThomasJager informan que gcc4.5 puede haber sido la primera versión en tener -mpreferred-stack-boundary=4 para 32 bits y 64 bits. gcc4.1.2 y gcc4.4.7 en Godbolt parecen comportarse de esa manera, por lo que tal vez el cambio fue respaldado, o Matt Godbolt configuró gcc antiguo con una configuración más moderna.