todas - ¿Cuánta sobrecarga hay para llamar a una función en C++?
tipos de funciones en lenguaje c (16)
Mucha literatura habla sobre el uso de funciones en línea para "evitar la sobrecarga de una llamada de función". Sin embargo, no he visto datos cuantificables. ¿Cuál es la sobrecarga real de una llamada a una función, es decir, qué tipo de aumento del rendimiento conseguimos al incorporar funciones?
Cada nueva función requiere la creación de una nueva pila local. Pero la sobrecarga de esto solo se notará si llama a una función en cada iteración de un ciclo en una gran cantidad de iteraciones.
Como han dicho otros, realmente no tienes que preocuparte demasiado por los gastos generales, a menos que estés buscando el máximo rendimiento o algo parecido. Cuando realiza una función, el compilador debe escribir el código en:
- Guarde los parámetros de la función en la pila
- Guarde la dirección de retorno en la pila
- Salta a la dirección de inicio de la función
- Asignar espacio para las variables locales de la función (pila)
- Ejecute el cuerpo de la función
- Guarde el valor de retorno (pila)
- Espacio libre para las variables locales, también conocido como recolección de basura
- Regrese a la dirección de devolución guardada
- Libere para arriba para los parámetros etc ...
Sin embargo, debe explicar la reducción de la legibilidad de su código, así como la forma en que afectará sus estrategias de prueba, planes de mantenimiento y el impacto global de tamaño de su archivo src.
Dependiendo de cómo estructure su código, división en unidades tales como módulos y bibliotecas, podría ser importante en algunos casos.
- El uso de la función de biblioteca dinámica con enlace externo la mayoría de las veces impone el procesamiento completo de cuadros de pila.
Es por eso que usar qsort de la biblioteca stdc es un orden de magnitud (10 veces) más lento que el uso del código stl cuando la operación de comparación es tan simple como la comparación de enteros. - Pasar punteros a funciones entre módulos también se verá afectado.
La misma penalización probablemente afectará el uso de las funciones virtuales de C ++, así como otras funciones, cuyo código se define en módulos separados.
La buena noticia es que la optimización de todo el programa podría resolver el problema de las dependencias entre bibliotecas estáticas y módulos.
En la mayoría de las arquitecturas, el costo consiste en guardar todos (o algunos, o ninguno) de los registros en la pila, empujando los argumentos de la función a la pila (o ponerlos en registros), incrementando el puntero de la pila y saltando al comienzo de la pila nuevo código. Luego, cuando la función está lista, debe restaurar los registros de la pila. Esta página web tiene una descripción de lo que está involucrado en las diversas convenciones de llamadas.
La mayoría de los compiladores de C ++ son lo suficientemente inteligentes ahora para las funciones en línea para usted. La palabra clave en línea es solo una pista para el compilador. Algunos incluso realizarán inline en las unidades de traducción donde decidan que es útil.
Existe la respuesta técnica y práctica. La respuesta práctica es que nunca importará, y en el muy raro caso, la única forma en que lo sabrá es a través de pruebas con perfiles reales.
La respuesta técnica, a la que se refiere su literatura, generalmente no es relevante debido a las optimizaciones del compilador. Pero si todavía está interesado, Josh describe bien.
En cuanto a un "porcentaje", debería saber qué tan costosa era la función en sí misma. Fuera del costo de la función llamada no hay porcentaje porque está comparando con una operación de costo cero. Para el código en línea no hay costo, el procesador simplemente pasa a la siguiente instrucción. La desventaja de la entrada es un tamaño de código más grande que manifiesta sus costos de una manera diferente a los costos de construcción / eliminación de la pila.
Existe un gran concepto llamado ''registro sombreado'', que permite pasar (hasta 6?), Valores a través de registros (en la CPU) en lugar de apilar (memoria). Además, dependiendo de la función y las variables utilizadas en el interior, el compilador puede decidir que el código de administración de cuadros no es necesario.
Además, incluso el compilador de C ++ puede hacer una "optimización de recursividad de cola", es decir, si A () llama a B (), y después de llamar a B (), A simplemente regresa, el compilador reutilizará el marco de pila.
Por supuesto, todo esto se puede hacer, solo si el programa se apega a la semántica del estándar (vea el alias del puntero y su efecto sobre las optimizaciones)
Hay algunos problemas aquí.
Si tiene un compilador lo suficientemente inteligente, le servirá para encasillar automáticamente, incluso si no especificó en línea. Por otro lado, hay muchas cosas que no se pueden incluir.
Si la función es virtual, entonces, por supuesto, pagará el precio que no puede incluirse porque el objetivo se determina en tiempo de ejecución. Por el contrario, en Java, puede estar pagando este precio a menos que indique que el método es final.
Dependiendo de cómo se organice su código en la memoria, es posible que esté pagando un costo por fallas en la memoria caché e incluso omisiones en la página, ya que el código se encuentra en otro lugar. Eso puede terminar teniendo un gran impacto en algunas aplicaciones.
Hice un punto de referencia simple contra una simple función de incremento:
inc.c:
typedef unsigned long ulong;
ulong inc(ulong x){
return x+1;
}
C Principal
#include <stdio.h>
#include <stdlib.h>
typedef unsigned long ulong;
#ifdef EXTERN
ulong inc(ulong);
#else
static inline ulong inc(ulong x){
return x+1;
}
#endif
int main(int argc, char** argv){
if (argc < 1+1)
return 1;
ulong i, sum = 0, cnt;
cnt = atoi(argv[1]);
for(i=0;i<cnt;i++){
sum+=inc(i);
}
printf("%lu/n", sum);
return 0;
}
Ejecutarlo con mil millones de iteraciones en mi CPU Intel (R) Core (TM) i5 M 430 @ 2.27GHz me dio:
- 1.4 segundos para la versión de alineación
- 4.4 segundos para la versión regularmente enlazada
(Parece fluctuar hasta 0.2 pero soy demasiado perezoso para calcular las desviaciones estándar adecuadas ni me preocupo por ellas)
Esto sugiere que la sobrecarga de llamadas a funciones en esta computadora es de aproximadamente 3 nanosegundos
Lo más rápido que medí algo fue aproximadamente 0.3ns, por lo que sugeriría que una llamada a función cuesta alrededor de 9 operaciones primitivas , para decirlo de manera muy simplista.
Esta sobrecarga aumenta aproximadamente otros 2ns por llamada (tiempo total de llamada de aproximadamente 6ns ) para funciones llamadas a través de un PLT (funciones en una biblioteca compartida).
Las CPU modernas son muy rápidas (¡obviamente!). Casi todas las operaciones involucradas con el paso de llamadas y argumentos son instrucciones de velocidad completa (las llamadas indirectas pueden ser un poco más caras, la mayoría de las veces a través de un bucle).
La sobrecarga de llamada de función es tan pequeña, solo los bucles que las funciones de llamada pueden hacer que la sobrecarga de llamadas sea relevante.
Por lo tanto, cuando hablamos de (y midamos) la sobrecarga de llamadas de funciones hoy en día, en realidad estamos hablando de la sobrecarga de no poder levantar subexpresiones comunes de los bucles. Si una función tiene que hacer un montón de trabajo (idéntico) cada vez que se llama, el compilador podría "levantarlo" del ciclo y hacerlo una vez si estuviera en línea. Cuando no esté en línea, el código probablemente continuará y se repetirá el trabajo, ¡se lo dijiste!
Las funciones en línea parecen imposiblemente más rápidas no debido a sobrecarga de llamadas y argumentos, sino debido a subexpresiones comunes que pueden ser eliminadas de la función.
Ejemplo:
Foo::result_type MakeMeFaster()
{
Foo t = 0;
for (auto i = 0; i < 1000; ++i)
t += CheckOverhead(SomethingUnpredictible());
return t.result();
}
Foo CheckOverhead(int i)
{
auto n = CalculatePi_1000_digits();
return i * n;
}
Un optimizador puede ver a través de esta tontería y hacer:
Foo::result_type MakeMeFaster()
{
Foo t;
auto _hidden_optimizer_tmp = CalculatePi_1000_digits();
for (auto i = 0; i < 1000; ++i)
t += SomethingUnpredictible() * _hidden_optimizer_tmp;
return t.result();
}
Parece que la sobrecarga de llamadas se reduce de forma increíble porque realmente ha liberado una gran parte de la función del ciclo (la llamada CalculatePi_1000_digits). El compilador necesitaría poder demostrar que CalculatePi_1000_digits siempre devuelve el mismo resultado, pero los buenos optimizadores pueden hacerlo.
No hay demasiados gastos generales, especialmente con funciones pequeñas (en línea) o incluso clases.
El siguiente ejemplo tiene tres pruebas diferentes que se ejecutan muchas, muchas veces y cronometradas. Los resultados son siempre iguales al orden de un par de milésimas de una unidad de tiempo.
#include <boost/timer/timer.hpp>
#include <iostream>
#include <cmath>
double sum;
double a = 42, b = 53;
//#define ITERATIONS 1000000 // 1 million - for testing
//#define ITERATIONS 10000000000 // 10 billion ~ 10s per run
//#define WORK_UNIT sum += a + b
/* output
8.609619s wall, 8.611255s user + 0.000000s system = 8.611255s CPU(100.0%)
8.604478s wall, 8.611255s user + 0.000000s system = 8.611255s CPU(100.1%)
8.610679s wall, 8.595655s user + 0.000000s system = 8.595655s CPU(99.8%)
9.5e+011 9.5e+011 9.5e+011
*/
#define ITERATIONS 100000000 // 100 million ~ 10s per run
#define WORK_UNIT sum += std::sqrt(a*a + b*b + sum) + std::sin(sum) + std::cos(sum)
/* output
8.485689s wall, 8.486454s user + 0.000000s system = 8.486454s CPU (100.0%)
8.494153s wall, 8.486454s user + 0.000000s system = 8.486454s CPU (99.9%)
8.467291s wall, 8.470854s user + 0.000000s system = 8.470854s CPU (100.0%)
2.50001e+015 2.50001e+015 2.50001e+015
*/
// ------------------------------
double simple()
{
sum = 0;
boost::timer::auto_cpu_timer t;
for (unsigned long long i = 0; i < ITERATIONS; i++)
{
WORK_UNIT;
}
return sum;
}
// ------------------------------
void call6()
{
WORK_UNIT;
}
void call5(){ call6(); }
void call4(){ call5(); }
void call3(){ call4(); }
void call2(){ call3(); }
void call1(){ call2(); }
double calls()
{
sum = 0;
boost::timer::auto_cpu_timer t;
for (unsigned long long i = 0; i < ITERATIONS; i++)
{
call1();
}
return sum;
}
// ------------------------------
class Obj3{
public:
void runIt(){
WORK_UNIT;
}
};
class Obj2{
public:
Obj2(){it = new Obj3();}
~Obj2(){delete it;}
void runIt(){it->runIt();}
Obj3* it;
};
class Obj1{
public:
void runIt(){it.runIt();}
Obj2 it;
};
double objects()
{
sum = 0;
Obj1 obj;
boost::timer::auto_cpu_timer t;
for (unsigned long long i = 0; i < ITERATIONS; i++)
{
obj.runIt();
}
return sum;
}
// ------------------------------
int main(int argc, char** argv)
{
double ssum = 0;
double csum = 0;
double osum = 0;
ssum = simple();
csum = calls();
osum = objects();
std::cout << ssum << " " << csum << " " << osum << std::endl;
}
La salida para ejecutar 10,000,000 iteraciones (de cada tipo: simple, seis llamadas a funciones, tres llamadas a objetos) fue con esta carga de trabajo semi-intrincada:
sum += std::sqrt(a*a + b*b + sum) + std::sin(sum) + std::cos(sum)
como sigue:
8.485689s wall, 8.486454s user + 0.000000s system = 8.486454s CPU (100.0%)
8.494153s wall, 8.486454s user + 0.000000s system = 8.486454s CPU (99.9%)
8.467291s wall, 8.470854s user + 0.000000s system = 8.470854s CPU (100.0%)
2.50001e+015 2.50001e+015 2.50001e+015
Usando una carga de trabajo simple de
sum += a + b
Da los mismos resultados, excepto un par de órdenes de magnitud más rápido para cada caso.
No tengo ningún número, tampoco, pero me alegra que estés preguntando. Con demasiada frecuencia veo que las personas intentan optimizar su código comenzando con vagas ideas de sobrecarga, pero sin saber realmente.
Para funciones muy pequeñas, la alineación tiene sentido, porque el costo (pequeño) de la llamada a la función es significativo en relación con el costo (muy pequeño) del cuerpo de la función. Para la mayoría de las funciones en pocas líneas, no es una gran victoria.
Para la mayoría de las funciones, no hay una sobrecarga adicional para llamarlas en C ++ contra C (a menos que cuente que el puntero "this" es un argumento innecesario para cada función ... Tiene que pasar el estado a una función de alguna manera).
Para las funciones virtuales, su es un nivel adicional de direccionamiento indirecto (equivalente a llamar una función a través de un puntero en C) ... Pero realmente, en el hardware de hoy en día esto es trivial.
Su pregunta es una de las preguntas, que no tiene respuesta, podría llamarse la "verdad absoluta". La sobrecarga de una llamada de función normal depende de tres factores:
La CPU La sobrecarga de las CPU x86, PPC y ARM varía mucho e, incluso si solo se queda con una arquitectura, la sobrecarga también varía bastante entre un Intel Pentium 4, Intel Core 2 Duo y un Intel Core i7. La sobrecarga incluso puede variar notablemente entre una CPU Intel y AMD, incluso si ambos funcionan a la misma velocidad, ya que factores como el tamaño de la memoria caché, los algoritmos de almacenamiento en caché, los patrones de acceso a la memoria y la implementación real del mismo influencia en la sobrecarga.
El ABI (Interfaz Binaria de Aplicación). Incluso con la misma CPU, a menudo existen diferentes ABI que especifican cómo las llamadas de función pasan los parámetros (a través de registros, a través de la pila o mediante una combinación de ambos) y dónde y cómo se lleva a cabo la inicialización y limpieza del marco de pila. Todo esto tiene una influencia en la sobrecarga. Los diferentes sistemas operativos pueden usar diferentes ABI para la misma CPU; por ejemplo, Linux, Windows y Solaris, los tres pueden usar un ABI diferente para la misma CPU.
El compilador Seguir estrictamente el ABI solo es importante si se llaman funciones entre unidades de código independientes, por ejemplo, si una aplicación llama a una función de una biblioteca de sistema o si una biblioteca de usuario llama a una función de otra biblioteca de usuario. Siempre que las funciones sean "privadas", no visibles fuera de una determinada biblioteca o binario, el compilador puede "hacer trampa". Es posible que no siga estrictamente el ABI, sino que utilice accesos directos que conducen a llamadas de función más rápidas. Por ejemplo, puede pasar los parámetros en el registro en lugar de usar la pila, o puede omitir la configuración del cuadro de pila y la limpieza por completo si no es realmente necesario.
Si desea conocer la sobrecarga para una combinación específica de los tres factores anteriores, por ejemplo, para Intel Core i5 en Linux utilizando GCC, su única forma de obtener esta información es comparar la diferencia entre dos implementaciones, una que usa llamadas de función y otra en la que copiar el código directamente en la persona que llama; de esta manera, forzarás la creación de líneas, ya que la declaración en línea es solo una pista y no siempre conduce a la creación de líneas.
Sin embargo, la verdadera pregunta aquí es: ¿la sobrecarga exacta realmente importa? Una cosa es segura: una llamada a una función siempre tiene una sobrecarga. Puede ser pequeño, puede ser grande, pero es seguro que existe. Y no importa cuán pequeño sea si una función se llama con la suficiente frecuencia en una sección de desempeño crítico, la sobrecarga será importante hasta cierto punto. Inventar raramente ralentiza tu código, a menos que te excedas terriblemente; sin embargo, hará que el código sea más grande. Los compiladores de hoy en día son bastante buenos para decidirse a sí mismos cuándo alinearse y cuándo no, por lo que casi nunca tiene que preocuparse por ello.
Personalmente ignoro completamente durante el desarrollo, hasta que tenga un producto más o menos utilizable que pueda perfilar y solo si los perfiles me dicen que una determinada función se llama muy a menudo y también dentro de una sección de rendimiento crítico de la aplicación, entonces lo haré Considere "forzar el alineamiento" de esta función.
Hasta ahora, mi respuesta es muy genérica, se aplica a C tanto como se aplica a C ++ y Objective-C. Como palabra final, permítanme decir algo acerca de C ++ en particular: los métodos que son virtuales son llamadas a funciones indirectas dobles, lo que significa que tienen una sobrecarga de llamadas de función más alta que las llamadas a funciones normales y tampoco se pueden insertar. El compilador puede o no incluir métodos no virtuales, pero incluso si no están en línea, son aún más importantes que los virtuales, por lo que no debe hacer que los métodos sean virtuales, a menos que realmente pretenda anularlos o anularlos.
Vale la pena señalar que una función en línea aumenta el tamaño de la función de llamada y cualquier cosa que aumente el tamaño de una función puede tener un efecto negativo en el almacenamiento en caché. Si se encuentra justo en un límite, "solo una menta delgada más delgada" del código en línea podría tener un efecto dramáticamente negativo en el rendimiento.
Si está leyendo literatura que advierte sobre "el costo de una llamada a función", sugeriría que puede tratarse de un material más antiguo que no refleja los procesadores modernos. A menos que esté en el mundo incrustado, la era en la que C es un "lenguaje ensamblador portátil" prácticamente ha pasado. Una gran parte del ingenio de los diseñadores de chips en la última década (por ejemplo) ha entrado en todo tipo de complejidades de bajo nivel que pueden diferir radicalmente de la forma en que las cosas funcionaron "en el pasado".
La cantidad de sobrecarga dependerá del compilador, la CPU, etc. El porcentaje de sobrecarga dependerá del código que está en línea. La única forma de saber es tomar su código y perfilarlo de ambas maneras: por eso no hay una respuesta definitiva.