tutorial software smart qué para online español curso c++ assembly x86 x86-64 abi

c++ - software - ¿Por qué esta función empuja RAX a la pila como la primera operación?



solidity software (3)

El ABI de 64 bits requiere que la pila esté alineada a 16 bytes antes de una instrucción de call .

call empuja una dirección de retorno de 8 bytes en la pila, que rompe la alineación, por lo que el compilador debe hacer algo para alinear la pila nuevamente con un múltiplo de 16 antes de la siguiente call .

(La opción de diseño ABI de requerir alineación antes de una call lugar de después tiene la pequeña ventaja de que si se pasaron argumentos en la pila, esta opción hace que el primer argumento esté alineado con 16B).

Presionar un valor de no importa funciona bien y puede ser más eficiente que sub rsp, 8 en CPU con un motor de pila . (Ver los comentarios).

En el ensamblaje de la fuente de C ++ a continuación. ¿Por qué RAX es empujado a la pila?

RAX, según entiendo, desde la ABI podría contener cualquier cosa de la función de llamada. Pero lo guardamos aquí, y luego lo movemos de nuevo en 8 bytes. Entonces, el RAX en la pila es, creo que solo es relevante para la operación std::__throw_bad_function_call() ...?

El código:-

#include <functional> void f(std::function<void()> a) { a(); }

Salida, desde gcc.godbolt.org , usando Clang 3.7.1 -O3:

f(std::function<void ()>): # @f(std::function<void ()>) push rax cmp qword ptr [rdi + 16], 0 je .LBB0_1 add rsp, 8 jmp qword ptr [rdi + 24] # TAILCALL .LBB0_1: call std::__throw_bad_function_call()

Estoy seguro de que la razón es obvia, pero estoy luchando para resolverlo.

Aquí hay un tailcall sin la envoltura std::function<void()> para comparación:

void g(void(*a)()) { a(); }

El trivial:

g(void (*)()): # @g(void (*)()) jmp rdi # TAILCALL


En otros casos, el Clang generalmente corrige la pila antes de regresar con un pop rcx .

El uso de push tiene un lado positivo para la eficiencia en el tamaño del código ( push es de solo 1 byte en comparación con 4 bytes para sub rsp, 8 ), y también en uops en las CPU de Intel. (No es necesario un uop de sincronización de pila, que obtendría si accede a rsp directamente porque la call que nos llevó a la parte superior de la función actual hace que el motor de pila esté "sucio").

Esta respuesta larga y confusa discute los peores riesgos de rendimiento de usar push rax / pop rcx para alinear la pila, y si o no rax y rcx son buenas opciones de registro. (Perdón por hacer esto tan largo).

(TL: DR: se ve bien, el posible inconveniente suele ser pequeño y el positivo en el caso común hace que valga la pena. Los registros parciales pueden ser un problema en Core2 / Nehalem si al o ax están "sucios", sin embargo. No otra CPU con capacidad para 64 bits tiene grandes problemas (porque no cambian el nombre de registros parciales ni se fusionan de manera eficiente), y el código de 32 bits necesita más de 1 push adicional para alinear la pila en 16 para otra call menos que ya esté guardando / Restaurando algunas reglas de llamadas preservadas para su propio uso.)

Al usar push rax lugar de sub rsp, 8 introduce una dependencia del antiguo valor de rax , por lo que pensaría que podría ralentizar las cosas si el valor de rax es el resultado de una cadena de dependencia de larga latencia (y / o un caché). perder).

por ejemplo, la persona que llama podría haber hecho algo lento con rax que no está relacionado con la función args, como var = table[ x % y ]; var2 = foo(x); var = table[ x % y ]; var2 = foo(x);

# example caller that leaves RAX not-ready for a long time mov rdi, rax ; prepare function arg div rbx ; very high latency mov rax, [table + rdx] ; rax = table[ value % something ], may miss in cache mov [rsp + 24], rax ; spill the result. call foo ; foo uses push rax to align the stack

Afortunadamente, la ejecución fuera de orden hará un buen trabajo aquí.

El push no hace que el valor de rsp dependa de rax . (Es manejado por el motor de pila, o en CPUs muy antiguas, push decodificaciones push a múltiples uops, una de las cuales actualiza rsp independientemente de las uops que almacenan rax . La rax de la dirección de la tienda y las uops de datos de la tienda permiten que push sea ​​una uop de dominio único fusionado, aunque las tiendas siempre toman 2 uops de dominio no fusionado.)

Mientras que nada dependa de la salida push rax / pop rcx , no es un problema para la ejecución fuera de orden. Si push rax tiene que esperar porque rax no está listo, no hará que se llene el ROB (ReOrder Buffer) y eventualmente bloquee la ejecución de instrucciones independientes posteriores. El ROB se llenaría incluso sin el push porque la instrucción es lenta para producir rax , y cualquier instrucción en la persona que llama consume rax antes de que la llamada sea aún más antigua, y tampoco puede retirarse hasta que rax esté listo. La jubilación debe ocurrir en orden en caso de excepciones / interrupciones.

(No creo que una carga de falta de memoria caché pueda retirarse antes de que se complete la carga, dejando solo una entrada de carga de búfer. Pero incluso si pudiera, no tendría sentido producir un resultado en un registro de llamadas entrecortadas sin leer con otra instrucción antes de hacer una call . La instrucción de la persona que llama que consume rax definitivamente no puede ejecutarse / retirarse hasta que nuestro push pueda hacer lo mismo. )

Cuando rax esté listo, push puede ejecutarse y retirarse en un par de ciclos, lo que permite que las instrucciones posteriores (que ya se ejecutaron fuera de orden) también se retiren. La dirección de la tienda uop ya se habrá ejecutado, y asumo que la información de la tienda uop puede completarse en un ciclo o dos después de haber sido enviada al puerto de la tienda. Las tiendas pueden retirarse tan pronto como los datos se escriben en el búfer de la tienda. Comprometerse con L1D ocurre después de la jubilación, cuando se sabe que la tienda no es especulativa.

Así que incluso en el peor de los casos, donde la instrucción que produce rax fue tan lenta que llevó al ROB a llenarse con instrucciones independientes que en su mayoría ya están ejecutadas y listas para retirarse, tener que ejecutar push rax solo causa un par de ciclos adicionales de demora Antes de instrucciones independientes después de que pueda retirarse. (Y algunas de las instrucciones de la persona que llama se retirarán primero, haciendo un poco de espacio en el ROB incluso antes de que nuestro push retire).

Un push rax que tiene que esperar push rax algunos otros recursos de microarquitectura , dejando una entrada menos para encontrar el paralelismo entre otras instrucciones posteriores. (Un add rsp,8 que podría ejecutarse solo consumiría una entrada de ROB, y no mucho más).

Se utilizará una entrada en el programador fuera de orden (también conocido como Reservation Station / RS). La dirección de la tienda uop puede ejecutarse tan pronto como haya un ciclo libre, por lo que solo quedará la información de la tienda uop. La dirección de carga del pop rcx uop está lista, por lo que debe enviarse a un puerto de carga y ejecutarse. (Cuando se ejecuta la carga pop , encuentra que su dirección coincide con el almacén de push incompleto en el búfer de almacenamiento (también conocido como búfer de orden de memoria), por lo que configura el reenvío de almacenamiento que ocurrirá después de que se ejecute el uop de datos de almacén. Esto probablemente consume una entrada de búfer de carga.)

Incluso una CPU antigua como Nehalem tiene una entrada de 36 RS, vs. 54 en Sandybridge , o 97 en Skylake. Mantener 1 entrada ocupada por más tiempo de lo habitual en casos raros no es nada de qué preocuparse. La alternativa de ejecutar dos uops (stack-sync + sub ) es peor.

( fuera de tema )
El ROB es más grande que el RS, 128 (Nehalem), 168 (Sandybridge), 224 (Skylake). (Contiene uops de dominio fusionado desde la emisión hasta la jubilación, mientras que RS tiene uops de dominio no fusionado desde la emisión hasta la ejecución). Con 4 uops por rendimiento máximo de frontend de reloj, eso es más de 50 ciclos de ocultación de demora en Skylake. (Los uarches más antiguos tienen menos probabilidades de mantener 4 uops por reloj durante tanto tiempo ...)

El tamaño de ROB determina la ventana fuera de orden para ocultar una operación lenta e independiente. ( A menos que los límites de tamaño de registro-archivo sean un límite más pequeño ). El tamaño de RS determina la ventana fuera de orden para encontrar el paralelismo entre dos cadenas de dependencia separadas. (Por ejemplo, considere un cuerpo de bucle de 200 uop ​​donde cada iteración es independiente, pero dentro de cada iteración es una larga cadena de dependencia sin mucho paralelismo a nivel de instrucción (por ejemplo, a[i] = complex_function(b[i]) ). El ROB de Skylake puede contener más más de 1 iteración, pero no podemos obtener uops de la siguiente iteración a la RS hasta que estemos a 97 uops del final de la actual. Si la cadena de dep no era mucho más grande que el tamaño de RS, uops de 2 iteraciones podrían estar en vuelo la mayor parte del tiempo.)

Hay casos en los que push rax / pop rcx puede ser más peligroso :

La persona que llama a esta función sabe que rcx está rcx llamada, por lo que no leerá el valor. Pero podría tener una dependencia falsa en rcx después de que rcx , como bsf rcx, rax / jnz o test eax,eax / setz cl . Las CPU de Intel recientes ya no cambian el nombre de los registros parciales de low8, por lo que setcc cl tiene una rcx falsa en rcx . bsf realmente deja su destino sin modificar si la fuente es 0, aunque Intel lo documente como un valor indefinido. AMD documenta el comportamiento de la licencia no modificada.

La dependencia falsa podría crear una cadena de dep. Por otro lado, una dependencia falsa puede hacer eso de todos modos, si nuestra función escribió rcx con instrucciones dependientes de sus entradas.

Sería peor usar push rbx / pop rbx para guardar / restaurar un registro preservado de llamadas que no íbamos a usar. La persona que llama probablemente lo leerá después de que regresemos, y habríamos introducido una latencia de reenvío de la tienda en la cadena de dependencia de la persona que llama para ese registro. (Además, es probable que rbx se escriba justo antes de la call , ya que cualquier cosa que la persona que llama desea rbx a través de la llamada se rbx registros de llamadas preservadas como rbx y rbp ).

En las CPU con paradas de registro parcial (Intel pre-Sandybridge) , la lectura de rax con push podría causar un bloqueo o 2-3 ciclos en Core2 / Nehalem si la persona que llama había hecho algo como setcc al antes de la call . Sandybridge no se detiene al insertar un uop de fusión, y Haswell y más tarde no cambian el nombre de low8 por separado de rax .

Sería bueno push un registro que es menos probable que haya tenido su bajo8 utilizado. Si los compiladores intentaran evitar los prefijos REX por razones de tamaño de código, evitarían dil y sil , por lo que rsi y rsi tendrían menos probabilidades de tener problemas de registro parcial. Pero desafortunadamente gcc y clang no parecen favorecer el uso de dl o cl como registros de 8 bits, usando dil o sil incluso en funciones pequeñas donde nada más está usando rdx o rcx . (Aunque la falta de cambio de nombre de low8 en algunas CPU significa que setcc cl tiene una dependencia falsa en el antiguo rcx , por lo que setcc dil es más seguro si la configuración del indicador dependía de la función arg en rdi ).

pop rcx al final "limpia" rcx de cualquier cosa de registro parcial. Dado que cl se usa para el conteo de turnos, y las funciones a veces escriben solo cl incluso cuando podrían haber escrito ecx . (IIRC he visto clang hacer esto. Gcc favorece más fuertemente los tamaños de operandos de 32 y 64 bits para evitar problemas de registro parcial).

push rdi probablemente sería una buena opción en muchos casos, ya que el resto de la función también lee rdi , por lo que introducir otra instrucción dependiente de ella no sería perjudicial. Sin embargo, impide que la ejecución fuera de orden se salga del camino si rax está listo antes que rax .

Otro posible inconveniente es el uso de ciclos en los puertos de carga / almacenamiento. Pero es poco probable que estén saturados, y la alternativa es uops para los puertos ALU. Con el uop de sincronización de pila adicional en las CPU Intel que obtendría de sub rsp, 8 , serían 2 uU ALU en la parte superior de la función.


La razón por la cual push rax es que hay que alinear la pila de nuevo a un límite de 16 bytes para ajustarse a la ABI de System V de 64 bits en el caso donde se je .LBB0_1 rama je .LBB0_1 . El valor colocado en la pila no es relevante. Otra forma habría sido restar 8 de RSP con sub rsp, 8 . El ABI establece la alineación de esta manera:

El final del área de argumento de entrada se alineará en un límite de 16 bytes (32, si __m256 se pasa en la pila). En otras palabras, el valor (% rsp + 8) siempre es un múltiplo de 16 (32) cuando el control se transfiere al punto de entrada de la función. El puntero de pila,% rsp, siempre apunta al final del último marco de pila asignado.

Antes de la llamada a la función f la pila estaba alineada con 16 bytes según la convención de llamada. Después de transferir el control a través de una LLAMADA a f la dirección de retorno se colocó en la pila desalineando la pila en 8. push rax es una forma simple de restar 8 de RSP y realinearla nuevamente. Si se toma la rama para call std::__throw_bad_function_call() la pila se alineará correctamente para que esa llamada funcione.

En el caso en el que la comparación no coincida, la pila aparecerá como lo hizo en la entrada de la función una vez que se ejecuta la instrucción add rsp, 8 . La dirección de retorno del LLAMADOR a la función f volverá a estar en la parte superior de la pila y la pila quedará desalineada de nuevo en 8. Esto es lo que queremos porque se está realizando una LLAMADA DE jmp qword ptr [rdi + 24] con jmp qword ptr [rdi + 24] para transferir el control a la función a . Esto hará que JMP a la función no lo llame . Cuando la función a hace un RET , regresará directamente a la función que llamó f .

En un nivel de optimización más alto, habría esperado que el compilador fuera lo suficientemente inteligente como para hacer la comparación y dejar que cayera directamente en el JMP . Lo que está en la etiqueta .LBB0_1 podría entonces alinear la pila con un límite de 16 bytes para que la call std::__throw_bad_function_call() funcione correctamente.

Como señaló @CodyGray, si utiliza GCC (no CLANG ) con un nivel de optimización de -O2 o superior, el código producido parece más razonable. La salida de GCC 6.1 de Godbolt es:

f(std::function<void ()>): cmp QWORD PTR [rdi+16], 0 # MEM[(bool (*<T5fc5>) (union _Any_data &, const union _Any_data &, _Manager_operation) *)a_2(D) + 16B], je .L7 #, jmp [QWORD PTR [rdi+24]] # MEM[(const struct function *)a_2(D)]._M_invoker .L7: sub rsp, 8 #, call std::__throw_bad_function_call() #

Este código está más en línea con lo que hubiera esperado. En este caso, parece que el optimizador de GCC puede manejar esta generación de código mejor que CLANG .