¿Por qué la inserción se considera más rápida que una llamada de función?

14 minutos de lectura

avatar de usuario
kodai

Ahora, sé que es porque no existe la sobrecarga de llamar a una función, pero ¿la sobrecarga de llamar a una función es realmente tan pesada (y vale la pena tenerla en línea)?

Por lo que puedo recordar, cuando se llama a una función, digamos f(x,y), x e y se colocan en la pila, y el puntero de la pila salta a un bloque vacío y comienza la ejecución. Sé que esto es un poco simplificado, pero ¿me estoy perdiendo algo? Unos pocos empujones y un salto para llamar a una función, ¿realmente hay tantos gastos generales?

Avísame si me estoy olvidando de algo, ¡gracias!

  • Dependiendo de la implementación particular, no hay garantía absoluta de que exista una pila, por lo que los detalles de lo que sucede exactamente cuando se llama a una función pueden variar un poco entre plataformas. En cuanto a si los gastos generales son significativos… Depende de lo que estés haciendo.

    – wroscrans

    25 de octubre de 2010 a las 15:49

  • No siempre, los jinetes también están ahí. parashift.com/c++-faq-lite/inline-functions.html#faq-9.3

    – Codificador tonto

    25 de octubre de 2010 a las 15:51

  • No hay necesidad de preocuparse por esto. El compilador analiza el código y decide si una función debe estar en línea (ignora cualquier sugerencia que pueda dar). Como el compilador sabe mucho más acerca de cómo se llama la función, puede hacer un análisis informado de costo/beneficio y decidir caso por caso si vale la pena el esfuerzo de incorporar el código. Hay NO Se requiere interacción del usuario (ni debe intentar vencer al compilador).

    – Martín York

    25 de octubre de 2010 a las 15:57

  • @Martin York: No está preguntando si debe alinear una función, está preguntando por qué es importante alinear. Como tal, es una cosa perfectamente válida para tratar de entender.

    – Chris Pitman

    25 de octubre de 2010 a las 16:21

  • @ Michael Foukarakis: Nadie apagó inlining the feature. El compilador ya no usa el inline keyword para determinar el clima para alinear el código. Por lo tanto, el compilador definitivamente lo está ignorando (a menos que lo obligue a hacerlo de otra manera).

    – Martín York

    26 de octubre de 2010 a las 17:28

avatar de usuario
Hormiga

Aparte del hecho de que no hay llamada (y, por lo tanto, no hay gastos asociados, como la preparación de parámetros antes de la llamada y la limpieza después de la llamada), hay otra ventaja significativa de la alineación. Cuando el cuerpo de la función está en línea, su cuerpo se puede reinterpretar en el contexto específico de la persona que llama. Esto podría permitir inmediatamente que el compilador reduzca y optimice aún más el código.

Para un ejemplo simple, esta función

void foo(bool b) {
  if (b) {
    // something
  }
  else {
    // something else
  }
}

requerirá una bifurcación real si se llama como una función no en línea

foo(true);
...
foo(false);

Sin embargo, si las llamadas anteriores están en línea, el compilador podrá eliminar inmediatamente la bifurcación. Esencialmente, en el caso anterior, la inserción permite que el compilador interprete el argumento de la función como una constante de tiempo de compilación (si el parámetro es una constante de tiempo de compilación), algo que generalmente no es posible con las funciones que no están en línea.

Sin embargo, no se limita ni remotamente a eso. En general, las oportunidades de optimización que ofrece la inserción son mucho más amplias. Para otro ejemplo, cuando el cuerpo de la función se inserta en el específico En el contexto de la persona que llama, el compilador en general podrá propagar las relaciones conocidas relacionadas con el alias presentes en el código de llamada al código de la función en línea, lo que permitirá optimizar mejor el código de la función.

Nuevamente, los ejemplos posibles son numerosos, todos ellos derivados del hecho básico de que las llamadas en línea están inmersas en el específico el contexto de la persona que llama, lo que permite varias optimizaciones entre contextos, lo que no sería posible con llamadas no en línea. Con la inserción, básicamente obtiene muchas versiones individuales de su función original, cada versión se adapta y optimiza individualmente para cada contexto específico de la persona que llama. El precio de eso es, obviamente, el peligro potencial de que el código se hinche, pero si se usa correctamente, puede proporcionar beneficios de rendimiento notables.

  • Otra dulce optimización que le brinda la función en línea es la eficiencia de la memoria caché de instrucciones. Es mucho más probable que el código en línea ya esté en la memoria caché, mientras que el código invocado podría causar fácilmente una falla en la memoria caché.

    – Detmar

    25/10/2010 a las 19:10

  • @Detmar: Tal vez. Y tal vez no. Por lo que sé sobre cachés de instrucciones (muy poco, hay que admitirlo), por lo general es necesario medir para saber, y la mayoría de las veces el resultado parece divertido y extraño.

    – sbi

    25 de octubre de 2010 a las 19:24

  • @Detmar, @sbi: De acuerdo en que esto puede ser misterioso. El uso de líneas puede sacar el código activo de la dulce memoria caché de instrucciones L1, mientras que el uso de llamadas a funciones significa que cada función está en la memoria caché de forma independiente, utilizando menos espacio de memoria caché. Esta es la razón por la cual el código compilado en GCC con -Os (reducir tamaño) puede ser más rápido que O2 u O3.

    – Zan Lince

    25/10/2010 a las 20:45


  • ¡¡¡Sí!!! El cuerpo de la función está en línea y … de repente, el compilador puede eliminar la mayor parte del código. Esta es la razón número uno por la cual la inserción (especialmente con la generación de código en tiempo de enlace) es una gran cosa.

    – diente filoso

    26 de octubre de 2010 a las 7:54

  • @meet: quiero decir que, por ejemplo, dentro de la función void foo(int *a, int *b) el compilador no puede hacer suposiciones sobre el alias: a y b puede apuntar al mismo objeto oa diferentes objetos. Cualquiera de las variantes ofrece oportunidades de optimización, pero el compilador no puede aprovechar estas oportunidades. Pero en un nivel superior (en el contexto de la persona que llama) esta información podría estar disponible. Por ejemplo, al alinear int x; foo(&x, &x); llamada, el compilador puede optimizar inmediatamente para a == b condición. Del mismo modo para int x, y; foo(&x, &y); el compilador puede optimizar para a != b.

    – Ant

    20 de junio de 2015 a las 15:51


avatar de usuario
Saludos y hth. – alf

“Algunos empujones y un salto para llamar a una función, ¿realmente hay tantos gastos generales?”

Depende de la función.

Si el cuerpo de la función es solo una instrucción de código de máquina, la sobrecarga de llamada y devolución puede ser de varios cientos %. Digamos, 6 veces, 500% de gastos generales. Entonces, si su programa consta de nada más que un montón de llamadas a esa función, sin intercalar, ha aumentado el tiempo de ejecución en un 500%.

Sin embargo, en la otra dirección, la inserción puede tener un efecto perjudicial, por ejemplo, porque el código que sin la inserción cabría en una página de memoria no lo hace.

Entonces, la respuesta siempre es cuando se trata de optimización, en primer lugar, MEDIR.

  • Además, una función realmente corta puede ser más pequeña que las instrucciones de configuración y desmontaje para una llamada de función, y la inserción en realidad puede hacer que el código sea más pequeño. Medida y perfil.

    –David Thornley

    25 de octubre de 2010 a las 16:57

avatar de usuario
Ponto Gagge

No hay actividad de llamadas ni de pila, lo que sin duda ahorra algunos ciclos de CPU. En las CPU modernas, la localidad del código también es importante: hacer una llamada puede vaciar el canalización de instrucciones y fuerce a la CPU a esperar a que se obtenga la memoria. Esto es muy importante en bucles estrechos, ya que la memoria principal es mucho más lenta que las CPU modernas.

Sin embargo, no se preocupe por incorporarlo si su código solo se llama unas pocas veces en su aplicación. ¡Preocúpate, mucho, si está siendo llamado millones de veces mientras el usuario espera respuestas!

avatar de usuario
sbi

El candidato clásico para inlinear es un descriptor de acceso, como std::vector<T>::size().

Con la inserción habilitada, esto es solo la obtención de una variable de la memoria, probablemente una sola instrucción en cualquier arquitectura. Los “pocos empujones y un salto” (más la devolución) son fácilmente varias veces como mucho.

Agregue a eso el hecho de que, cuanto más código sea visible a la vez para un optimizador, mejor podrá hacer su trabajo. Con muchas líneas, ve mucho código a la vez. Eso significa que podría ser capaz de mantener el valor en un registro de la CPU, y te ahorras por completo el costoso viaje a la memoria. Ahora podríamos considerar una diferencia de varios órdenes de magnitud.

Y luego está metaprogramación de plantillas. A veces, esto da como resultado llamar a muchas funciones pequeñas de forma recursiva, solo para obtener un valor único al final de la recursividad. (Piense en obtener el valor de la primera entrada de un tipo específico en una tupla con docenas de objetos). Con la inserción habilitada, el optimizador puede acceder directamente a ese valor (que, recuerde, podría estar en un registro), colapsando docenas de llamadas a funciones para acceder a un solo valor en un registro de la CPU. Esto puede convertir un terrible cerdo de rendimiento en un programa agradable y rápido.


Ocultar el estado como datos privados en objetos (encapsulación) tiene sus costos. Inlining fue parte de C++ desde el principio con el fin de minimizar estos costos de abstracción. En ese entonces, los compiladores eran significativamente peores en la detección de buenos candidatos para la inserción (y el rechazo de los malos) que en la actualidad, por lo que la inserción manual resultó en ganancias de velocidad considerables.
Hoy en día, los compiladores tienen fama de ser mucho más inteligentes que nosotros en línea. Los compiladores pueden incorporar funciones automáticamente o no incorporar funciones que los usuarios marcaron como inline, aunque podrían. Algunos dicen que la inserción debe dejarse completamente en manos del compilador y que ni siquiera deberíamos molestarnos en marcar las funciones como inline. Sin embargo, todavía tengo que ver un estudio completo que muestre si hacerlo manualmente todavía vale la pena o no. Entonces, por el momento, seguiré haciéndolo yo mismo y dejaré que el compilador lo anule si cree que puede hacerlo mejor.

dejar

int sum(const int &a,const int &b)
{
     return a + b;
}
int a = sum(b,c);

es igual a

int a = b + c

Sin saltos, sin gastos generales

  • Mejor aún: “int a=sum(4,5);” puede convertirse en “int a=9;”. Además, leer y escribir variables a través de referencias suele ser más lento que leerlas y escribirlas directamente; en muchos casos, una función en línea se puede resolver para usar un acceso directo a variables más rápido (tenga en cuenta que en su escenario, si no está en línea, sería mejor pasar las variables por valor en lugar de por referencia, pero si la función lo hizo algo como “a+=b;” la referencia sería necesaria).

    – Super gato

    25 de octubre de 2010 a las 15:43


  • La declaración reading and writing variables through references is generally slower than reading and writing them directly es manera de general para ser verdad. También lo encuentro muy poco probable en la mayoría de las situaciones normales (vea qué fácil es generalizar en exceso).

    – Martín York

    25 de octubre de 2010 a las 16:03


  • ¿Y con qué frecuencia escribimos funciones como sum()? Creo que los accesores son un ejemplo mucho más relevante de lo que hace la inserción.

    – sbi

    25/10/2010 a las 18:45

avatar de usuario
usp

Considere una función simple como:

int SimpleFunc (const int X, const int Y)
{
    return (X + 3 * Y); 
}    

int main(int argc, char* argv[])
{
    int Test = SimpleFunc(11, 12);
    return 0;
}

Esto se convierte en el siguiente código (MSVC++ v6, depuración):

10:   int SimpleFunc (const int X, const int Y)
11:   {
00401020   push        ebp
00401021   mov         ebp,esp
00401023   sub         esp,40h
00401026   push        ebx
00401027   push        esi
00401028   push        edi
00401029   lea         edi,[ebp-40h]
0040102C   mov         ecx,10h
00401031   mov         eax,0CCCCCCCCh
00401036   rep stos    dword ptr [edi]

12:       return (X + 3 * Y);
00401038   mov         eax,dword ptr [ebp+0Ch]
0040103B   imul        eax,eax,3
0040103E   mov         ecx,dword ptr [ebp+8]
00401041   add         eax,ecx

13:   }
00401043   pop         edi
00401044   pop         esi
00401045   pop         ebx
00401046   mov         esp,ebp
00401048   pop         ebp
00401049   ret

Puede ver que solo hay 4 instrucciones para el cuerpo de la función, pero 15 instrucciones solo para la sobrecarga de la función sin incluir otras 3 para llamar a la función en sí. Si todas las instrucciones tomaron el mismo tiempo (no lo hacen), entonces el 80% de este código es una sobrecarga de funciones.

Para una función trivial como esta, existe una buena posibilidad de que el código general de la función tarde tanto en ejecutarse como el cuerpo de la función principal. Cuando tiene funciones triviales que se llaman en un cuerpo de bucle profundo millones o miles de millones de veces, la sobrecarga de la llamada a la función comienza a ser grande.

Como siempre, la clave es crear perfiles/medir para determinar si la integración de una función específica produce ganancias netas de rendimiento o no. Para funciones más “complejas” que no se denominan “a menudo”, la ganancia de la inserción puede ser inmensamente pequeña.

  • Mejor aún: “int a=sum(4,5);” puede convertirse en “int a=9;”. Además, leer y escribir variables a través de referencias suele ser más lento que leerlas y escribirlas directamente; en muchos casos, una función en línea se puede resolver para usar un acceso directo a variables más rápido (tenga en cuenta que en su escenario, si no está en línea, sería mejor pasar las variables por valor en lugar de por referencia, pero si la función lo hizo algo como “a+=b;” la referencia sería necesaria).

    – Super gato

    25 de octubre de 2010 a las 15:43


  • La declaración reading and writing variables through references is generally slower than reading and writing them directly es manera de general para ser verdad. También lo encuentro muy poco probable en la mayoría de las situaciones normales (vea qué fácil es generalizar en exceso).

    – Martín York

    25 de octubre de 2010 a las 16:03


  • ¿Y con qué frecuencia escribimos funciones como sum()? Creo que los accesores son un ejemplo mucho más relevante de lo que hace la inserción.

    – sbi

    25/10/2010 a las 18:45

avatar de usuario
marca rescate

Hay múltiples razones para que la inserción sea más rápida, solo una de las cuales es obvia:

  • Sin instrucciones de salto.
  • una mejor localización, lo que resulta en una mejor utilización de la memoria caché.
  • más oportunidades para que el optimizador del compilador realice optimizaciones, dejando valores en los registros, por ejemplo.

La utilización de la memoria caché también puede funcionar en su contra: si la inserción hace que el código sea más grande, hay más posibilidades de errores de memoria caché. Sin embargo, ese es un caso mucho menos probable.

¿Ha sido útil esta solución?