Considere este código C:
void foo(void);
long bar(long x) {
foo();
return x;
}
Cuando lo compilo en GCC 9.3 con cualquiera -O3
o -Os
, obtengo esto:
bar:
push r12
mov r12, rdi
call foo
mov rax, r12
pop r12
ret
La salida de clang es idéntica, excepto por elegir en rbx
lugar de r12
como el registro guardado por el destinatario.
Sin embargo, quiero / espero ver un ensamblaje que se parezca más a esto:
bar:
push rdi
call foo
pop rax
ret
En inglés, esto es lo que veo que sucede:
- Empuje el valor anterior de un registro guardado por el llamado a la pila
- Pasar
x
a ese registro guardado por el llamado - Llamada
foo
- Pasar
x
del registro guardado por el destinatario al registro de valor de retorno - Haga estallar la pila para restaurar el valor anterior del registro guardado por la persona que llama
¿Por qué molestarse en meterse con un registro guardado? ¿Por qué no hacer esto en su lugar? Parece más corto, más simple y probablemente más rápido:
- Empujar
x
a la pila - Llamada
foo
- Pop
x
de la pila en el registro de valor de retorno
¿Está mal mi montaje? ¿Es de alguna manera menos eficiente que jugar con un registro adicional? Si la respuesta a ambas preguntas es "no", ¿por qué no lo hacen GCC o clang de esta manera?
Editar: Aquí hay un ejemplo menos trivial, para mostrar que sucede incluso si la variable se usa de manera significativa:
long foo(long);
long bar(long x) {
return foo(x * x) - x;
}
Entiendo esto:
bar:
push rbx
mov rbx, rdi
imul rdi, rdi
call foo
sub rax, rbx
pop rbx
ret
Prefiero tener esto:
bar:
push rdi
imul rdi, rdi
call foo
pop rdi
sub rax, rdi
ret
Esta vez, es solo una instrucción en lugar de dos, pero el concepto central es el mismo.
Respuestas:
TL: DR:
foo
no se guarda / restaura RBX.Los compiladores son piezas complejas de maquinaria. No son "inteligentes" como un ser humano, y los algoritmos costosos para encontrar todas las optimizaciones posibles a menudo no valen el costo en tiempo de compilación adicional.
Informé esto como error 69986 de GCC: es posible un código más pequeño con -Os usando push / pop para derramar / recargar en 2016 ; no ha habido actividad ni respuestas de los desarrolladores de GCC. : /
Ligeramente relacionado: el error 70408 de GCC: la reutilización del mismo registro de llamada preservada daría un código más pequeño en algunos casos , los desarrolladores del compilador me dijeron que tomaría una gran cantidad de trabajo para que GCC pueda hacer esa optimización porque requiere elegir el orden de evaluación de dos
foo(int)
llamadas basadas en lo que simplificaría el asm de destino.Si
foo
no se guarda / restaurarbx
, hay una compensación entre el rendimiento (recuento de instrucciones) frente a una latencia adicional de almacenamiento / recarga en lax
cadena de dependencia -> retval.Los compiladores generalmente favorecen la latencia sobre el rendimiento, por ejemplo, usando 2x LEA en lugar de
imul reg, reg, 10
(latencia de 3 ciclos, rendimiento de 1 / reloj), porque la mayoría de los códigos promedian significativamente menos de 4 uops / reloj en tuberías típicas de 4 anchos como Skylake. (Sin embargo, más instrucciones / uops ocupan más espacio en el ROB, reduciendo qué tan adelante puede ver la misma ventana fuera de orden, y la ejecución está realmente llena de puestos que probablemente representan algunos de los menos de 4 uops / promedio de reloj.)Si
foo
empuja / revienta RBX, entonces no hay mucho que ganar para la latencia.ret
Es probable que la restauración se realice justo antes de que en lugar de justo después no sea relevante, a menos que haya unret
error de predicción o falta de I-cache que retrase la obtención de código en la dirección de retorno.La mayoría de las funciones no triviales guardarán / restaurarán RBX, por lo que a menudo no es una buena suposición que dejar una variable en RBX realmente signifique que realmente permaneció en un registro durante la llamada. (Aunque aleatorizar qué funciones de registros conservados de llamadas elegir puede ser una buena idea para mitigar esto a veces).
Entonces sí
push rdi
/pop rax
sería más eficiente en este caso, y esta es probablemente una optimización perdida para pequeñas funciones que no son hojas, dependiendo de lo quefoo
haga y el equilibrio entre la latencia adicional de almacenamiento / recargax
frente a más instrucciones para guardar / restaurar la persona que llamarbx
.Es posible que los metadatos de desenrollado de pila representen los cambios en RSP aquí, como si se hubiera usado
sub rsp, 8
para derramar / recargarx
en una ranura de pila. (Pero los compiladores tampoco conocen esta optimización, de usarpush
para reservar espacio e inicializar una variable. ¿Qué compilador C / C ++ puede usar instrucciones push pop para crear variables locales, en lugar de aumentar el esp una vez? ¿ Y hacerlo por más de una var local llevaría a.eh_frame
metadatos de desenrollado de pila más grandes porque está moviendo el puntero de la pila por separado con cada inserción. Sin embargo, eso no impide que los compiladores usen push / pop para guardar / restaurar registros conservados de llamadas.IDK si valdría la pena enseñar a los compiladores a buscar esta optimización
Tal vez sea una buena idea en torno a una función completa, no a través de una llamada dentro de una función. Y como dije, se basa en la suposición pesimista que
foo
salvará / restaurará RBX de todos modos. (O bien, optimizar el rendimiento si sabe que la latencia desde x hasta el valor de retorno no es importante. Pero los compiladores no lo saben y generalmente optimizan la latencia).Si comienza a hacer esa suposición pesimista en muchos códigos (como alrededor de llamadas de funciones individuales dentro de funciones), comenzará a obtener más casos en los que RBX no se guarda / restaura y podría haber aprovechado.
Tampoco desea que este push / pop adicional de guardar / restaurar en un bucle, solo guarde / restaure RBX fuera del bucle y use registros conservados de llamadas en bucles que realicen llamadas a funciones. Incluso sin bucles, en el caso general, la mayoría de las funciones realizan múltiples llamadas de función. Esta idea de optimización podría aplicarse si realmente no usa
x
entre ninguna de las llamadas, justo antes de la primera y después de la última, de lo contrario tiene un problema de mantener la alineación de la pila de 16 bytes para cada unacall
si está haciendo un pop después de un llamar, antes de otra llamada.Los compiladores no son buenos para las funciones pequeñas en general. Pero tampoco es genial para las CPU. Las llamadas a funciones no en línea tienen un impacto en la optimización en el mejor de los casos, a menos que los compiladores puedan ver las partes internas del destinatario y hacer más suposiciones de lo habitual. Una llamada a una función no en línea es una barrera de memoria implícita: la persona que llama tiene que suponer que una función puede leer o escribir cualquier dato accesible a nivel mundial, por lo que todos estos valores deben estar sincronizados con la máquina abstracta C. (El análisis de escape permite mantener a los locales en registros a través de llamadas si su dirección no ha escapado de la función). Además, el compilador tiene que suponer que todos los registros con bloqueo de llamadas están bloqueados. Esto apesta al punto flotante en x86-64 System V, que no tiene registros XMM conservados para llamadas.
Pequeñas funciones como
bar()
son mejores en línea con sus llamadores. Compile con-flto
para que esto pueda suceder incluso a través de los límites del archivo en la mayoría de los casos. (Los punteros de función y los límites de la biblioteca compartida pueden vencer esto).Creo que una de las razones por las que los compiladores no se han molestado en intentar hacer estas optimizaciones es que requeriría un montón de código diferente en los componentes internos del compilador , diferente del código normal de pila frente al código de asignación de registro que sabe cómo guardar las llamadas preservadas registros y usarlos.
es decir, sería mucho trabajo implementar y mantener mucho código, y si se entusiasma demasiado al hacerlo, podría empeorar el código.
Y también que (con suerte) no es significativo; si es importante, debe estar
bar
en línea con la persona que llama, ofoo
en líneabar
. Esto está bien a menos que haya muchasbar
funciones diferentes yfoo
es grande, y por alguna razón no pueden conectarse con sus llamadores.fuente