¿Por qué esta función empuja RAX a la stack como la primera operación?

En el ensamblaje de la fuente de C ++ a continuación. ¿Por qué RAX se envía a la stack?

RAX, tal como lo entiendo de ABI, podría contener algo de la función de llamada. Pero lo guardamos aquí, y luego movemos la stack de vuelta por 8 bytes. Entonces, ¿el RAX en la stack es, creo que solo relevante para la operación std::__throw_bad_function_call() …?

El código:-

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

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

 f(std::function): # @f(std::function) 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 para comparar:

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

El trivial:

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

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

call empuja una dirección de retorno de 8 bytes en la stack, lo que rompe la alineación, por lo que el comstackdor necesita hacer algo para alinear la stack nuevamente a un múltiplo de 16 antes de la próxima call .

(La elección del diseño ABI de requerir alineación antes de una call lugar de después tiene la menor ventaja de que si se pasaban argumentos en la stack, esta opción hace que la primera arg 16B esté alineada).

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

La razón por la que push rax está ahí es para alinear la stack de nuevo a un límite de 16 bytes para cumplir con el System V ABI de 64 bits en el caso en que se je .LBB0_1 twig je .LBB0_1 . El valor colocado en la stack 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 del argumento de entrada se alineará en un límite de bytes de 16 (32, si __m256 se pasa en la stack). 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 stack,% rsp, siempre apunta al final del último marco de stack asignado.

Antes de la llamada a la función f la stack estaba alineada en 16 bytes según la convención de llamadas. Después de que el control se transfirió a través de una LLAMADA a f la dirección de retorno se colocó en la stack y la alineación incorrecta de la stack se hizo por 8. push rax es una manera simple de restar 8 de RSP y realinearlo de nuevo. Si se toma la twig para call std::__throw_bad_function_call() la stack se alineará correctamente para que funcione esa llamada.

En el caso donde la comparación no se cumple, la stack aparecerá tal como lo hizo en la entrada de la función una vez que se ejecute la instrucción add rsp, 8 . La dirección de retorno del CALLER para la función f ahora estará nuevamente en la parte superior de la stack y la stack estará desalineada por 8 nuevamente. Esto es lo que queremos porque se realiza una LLAMADA TAIL 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 comstackdor fuera lo suficientemente inteligente como para hacer la comparación, y dejarla pasar directamente al JMP . Lo que está en la etiqueta .LBB0_1 podría alinear la stack a un límite de 16 bytes para que la call std::__throw_bad_function_call() funcione correctamente.


Como señaló @CodyGray, si usa 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): cmp QWORD PTR [rdi+16], 0 # MEM[(bool (*) (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 .

En otros casos, clang normalmente arregla la stack antes de volver con pop rcx .

Usar push tiene una ventaja para la eficiencia en el tamaño del código ( push es de solo 1 byte contra 4 bytes para sub rsp, 8 ), y también en uops en las CPU de Intel. (No es necesario un uop stack-sync, que obtendría si accede directamente a rsp porque la call que nos llevó a la parte superior de la función actual “ensucia” al motor de la stack).

Esta larga y compleja respuesta discute los peores riesgos de rendimiento del uso de push rax / pop rcx para alinear la stack, y si rax y rcx son buenas opciones de registro. (Perdón por hacer esto tan largo.)

(TL: DR: se ve bien, la posible desventaja es generalmente pequeña y el lado positivo en el caso común hace que valga la pena. Los puestos de registro parcial podrían ser un problema en Core2 / Nehalem si al o ax son “sucios”, sin embargo. otra CPU de 64 bits tiene grandes problemas (porque no cambian el nombre de registros parciales, o se combinan de manera eficiente), y el código de 32 bits necesita más de 1 push adicional para alinear la stack por 16 para otra call menos que ya esté guardando / restaurar algunas reglas preservadas de llamada para su propio uso).


Usar push rax lugar de sub rsp, 8 introduce una dependencia en el valor anterior de rax , por lo que podría pensar que podría desacelerar si el valor de rax es el resultado de una cadena de dependencia de latencia larga (y / o un caché) perder).

por ejemplo, la persona que llama pudo haber hecho algo lento con rax que no está relacionado con las funciones 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 servicio hará un buen trabajo aquí.

La push no hace que el valor de rsp dependa de rax . (O bien es manejado por el motor de stack, o en CPUs muy antiguas que push decodificaciones a múltiples uops, una de las cuales actualiza rsp independientemente de los uops que almacenan rax . La rax de la dirección de la tienda y los datos almacenados deja que push sea ​​un uop de dominio único, a pesar de que las tiendas siempre toman 2 uops de dominio no fusionado).

Mientras nada dependa de la salida push rax / pop rcx , no es un problema para la ejecución fuera de servicio. Si push rax tiene que esperar porque rax no está listo, no causará que el ROB (ReOrder Buffer) se llene y eventualmente bloqueará la ejecución de instrucciones independientes posteriores. El ROB se llenaría incluso sin el push porque las instrucciones que tardan en producir rax , y cualquier instrucción en la persona que llama consume rax antes de la llamada son aún más antiguas, y no pueden retirarse hasta que rax esté listo. La jubilación tiene que suceder en orden en caso de excepciones / interrupciones.

(No creo que una carga de caché se pueda retirar antes de que la carga se complete, dejando solo una entrada en el búfer de carga. Pero incluso si pudiera, no tendría sentido producir un resultado en un registro cancelado sin leer con otra instrucción antes de hacer una call . Las instrucciones del que llama que consumen rax definitivamente no pueden ejecutarse / retirarse hasta que nuestro push pueda hacer lo mismo ) .

Cuando rax está listo, push puede ejecutar y retirarse en un par de ciclos, lo que permite que las instrucciones posteriores (que ya se han ejecutado fuera de servicio) también se retiren. El uop de dirección de tienda ya se habrá ejecutado, y supongo que el uop de datos de tienda puede completarse en uno o dos ciclos después de ser enviado al puerto de la tienda. Las tiendas pueden retirarse tan pronto como los datos se escriben en el búfer de la tienda. El compromiso 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 era tan lenta que llevó al ROB a completar 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 retraso antes de instrucciones independientes después de que pueda retirarse. (Y algunas de las instrucciones de la persona que llama se retirarán primero, dejando un poco de espacio en el ROB, incluso antes de que nuestro push retire).


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

Utilizará una entrada en el progtwigdor fuera de servicio (también conocido como Reservation Station / RS). El uop de dirección de tienda se puede ejecutar tan pronto como haya un ciclo gratuito, por lo que solo quedará el uop de datos de tienda. La dirección de carga de 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 la tienda de push incompleta en el búfer de la tienda (también conocido como búfer de orden de memoria), por lo que configura el reenvío de tienda que ocurrirá después de que se ejecute uop de datos de la tienda. una entrada de buffer de carga)

Incluso una antigua CPU como Nehalem tiene una RS de 36 entradas, contra 54 en Sandybridge , o 97 en Skylake. Mantener una 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 del tema )
El ROB es más grande que el RS, 128 (Nehalem), 168 (Sandybridge), 224 (Skylake). (Contiene uops de dominio fusionado desde el momento de la jubilación, frente al RS que tiene uops de dominio no fusionado desde el momento en que se ejecuta hasta que se ejecuta). Con 4 uops por reloj, la velocidad máxima de procesamiento frontend es de más de 50 ciclos de retraso oculto en Skylake. (Los uarches más viejos tienen menos probabilidades de mantener 4 uops por reloj durante tanto tiempo …)

El tamaño ROB determina la ventana fuera de servicio para ocultar una operación lenta e independiente. ( A menos que los límites de tamaño de archivo de registro sean un límite menor ). 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 sea independiente, pero dentro de cada iteración se trata de una larga cadena de dependencias sin mucho paralelismo de nivel de instrucción (por ejemplo, a[i] = complex_function(b[i]) ). ROB de Skylake puede contener más de 1 iteración, pero no podemos obtener uops de la siguiente iteración en el RS hasta que estemos dentro de 97 uops del final de la actual. Si la cadena de depósito no era mucho más grande que el tamaño RS, uops de 2 las iteraciones pueden estar en vuelo la mayor parte del tiempo).


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

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

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

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


En las CPU con puestos de registro parcial (Intel pre-Sandybridge) , leer rax con push podría causar un locking o 2-3 ciclos en Core2 / Nehalem si el interlocutor había hecho algo así 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 cambia el nombre de los registros low8 por separado del rax .

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

pop rcx al final “limpia” rcx de cualquier material de registro parcial. Dado que cl se usa para recuentos de turno, y las funciones a veces escriben solo cl incluso cuando podrían haber escrito ecx lugar. (IIRC He visto hacer clang para hacer esto. GCC favorece más los tamaños de operandos de 32 y 64 bits para evitar problemas de registro parcial).


push rdi probablemente sea una buena opción en muchos casos, ya que el rest de la función también lee rdi , por lo que introducir otra instrucción que dependa de ella no dolería. Sin embargo, evita que la ejecución fuera de servicio se salga del camino si rax está listo antes de rax .


Otro inconveniente potencial 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 stack adicional en las CPU de Intel que obtendría de sub rsp, 8 , eso sería uU de 2 ALU en la parte superior de la función.