web-dev-qa-db-fra.com

Combien de temps y a-t-il pour appeler une fonction en C ++?

De nombreuses publications parlent de l'utilisation de fonctions en ligne pour "éviter la surcharge d'un appel de fonction". Cependant, je n'ai pas vu de données quantifiables. Quel est le surcoût réel d'un appel de fonction, c'est-à-dire quel type d'augmentation de performances réalisons-nous en intégrant des fonctions?

58
Obediah Stane

Sur la plupart des architectures, le coût consiste à enregistrer tous (ou certains, ou aucun) des registres dans la pile, à pousser les arguments de fonction dans la pile (ou à les mettre dans des registres), à incrémenter le pointeur de la pile et à sauter au début de la nouveau code. Ensuite, lorsque la fonction est terminée, vous devez restaurer les registres de la pile. Cette page Web a une description de ce qui est impliqué dans les différentes conventions d'appel.

La plupart des compilateurs C++ sont maintenant assez intelligents pour intégrer des fonctions pour vous. Le mot clé en ligne n'est qu'un indice pour le compilateur. Certains feront même l'inline dans les unités de traduction où ils jugent que c'est utile.

44
Eclipse

Il y a la réponse technique et pratique. La réponse pratique est que cela n'aura jamais d'importance, et dans le cas très rare, cela ne se fera que par le biais de tests profilés réels.

La réponse technique, à laquelle votre littérature se réfère, n'est généralement pas pertinente en raison des optimisations du compilateur. Mais si vous êtes toujours intéressé, est bien décrit par Josh .

En ce qui concerne un "pourcentage", vous devez savoir à quel point la fonction elle-même coûte cher. En dehors du coût de la fonction appelée, il n'y a pas de pourcentage car vous vous comparez à une opération à coût nul. Pour le code en ligne, il n'y a aucun coût, le processeur passe simplement à l'instruction suivante. L'inconvénient de l'inling est une taille de code plus grande qui manifeste ses coûts d'une manière différente de celle des coûts de construction/démontage de la pile.

11
nedruod

J'ai fait un benchmark simple contre une simple fonction d'incrémentation:

inc.c:

typedef unsigned long ulong;
ulong inc(ulong x){
    return x+1;
}

main.c

#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;
}

L'exécuter avec un milliard d'itérations sur mon processeur Intel (R) Core (TM) i5 M 430 @ 2,27 GHz m'a donné:

  • 1,4 secondes pour la version inline
  • 4,4 secondes pour la version régulièrement liée

(Il semble fluctuer jusqu'à 0,2, mais je suis trop paresseux pour calculer les écarts-types appropriés et je ne m'en soucie pas)

Cela suggère que la surcharge des appels de fonction sur cet ordinateur est d'environ nanosecondes

Le plus rapide que j'ai mesuré quelque chose était d'environ 0,3ns, ce qui suggérerait qu'un appel de fonction coûte environ 9 opérations primitives, pour le dire très simplement.

Cette surcharge augmente d'environ un autre 2ns par appel (temps total d'appel d'environ 6ns) pour les fonctions appelées via un PLT (fonctions dans une bibliothèque partagée).

9
PSkocik

Votre question est l'une des questions, qui n'a pas de réponse, on pourrait appeler la "vérité absolue". Les frais généraux d'un appel de fonction normale dépendent de trois facteurs:

  1. Le CPU. La surcharge de x86, PPC et ARM CPU varie beaucoup et même si vous restez avec une seule architecture, la surcharge varie également un peu entre un Intel Pentium 4, Intel Core 2 Duo et un processeur Intel Core i7. La surcharge peut même varier sensiblement entre un processeur Intel et un processeur AMD, même si les deux fonctionnent à la même vitesse d'horloge, car des facteurs tels que la taille du cache, les algorithmes de mise en cache, les modèles d'accès à la mémoire et la mise en œuvre matérielle réelle de l'opcode call lui-même peut avoir une énorme influence sur les frais généraux.

  2. L'ABI (Application Binary Interface). Même avec le même CPU, il existe souvent différentes ABI qui spécifient comment les appels de fonction passent les paramètres (via les registres, via la pile ou via une combinaison des deux) et où et comment l'initialisation et le nettoyage des trames de pile ont lieu. Tout cela a une influence sur les frais généraux. Différents systèmes d'exploitation peuvent utiliser des ABI différents pour le même CPU; par exemple. Linux, Windows et Solaris peuvent tous les trois utiliser un ABI différent pour le même CPU.

  3. Le compilateur. Suivre strictement l'ABI n'est important que si des fonctions sont appelées entre des unités de code indépendantes, par ex. si une application appelle une fonction d'une bibliothèque système ou qu'une bibliothèque utilisateur appelle une fonction d'une autre bibliothèque utilisateur. Tant que les fonctions sont "privées", non visibles en dehors d'une certaine bibliothèque ou binaire, le compilateur peut "tricher". Il peut ne pas suivre strictement l'ABI mais utiliser des raccourcis qui conduisent à des appels de fonction plus rapides. Par exemple. il peut passer des paramètres dans le registre au lieu d'utiliser la pile ou il peut ignorer la configuration et le nettoyage du cadre de pile complètement si ce n'est pas vraiment nécessaire.

Si vous souhaitez connaître les frais généraux pour une combinaison spécifique des trois facteurs ci-dessus, par ex. pour Intel Core i5 sous Linux utilisant GCC, votre seul moyen d'obtenir ces informations est de comparer la différence entre deux implémentations, l'une utilisant des appels de fonction et l'autre où vous copiez le code directement dans l'appelant; de cette façon, vous forcez l'inline à coup sûr, car l'instruction inline n'est qu'un indice et ne conduit pas toujours à l'inline.

Cependant, la vraie question ici est: les frais généraux exacts ont-ils vraiment de l'importance? Une chose est sûre: un appel de fonction a toujours une surcharge. Il peut être petit, il peut être gros, mais il est certain qu'il existe. Et quelle que soit sa taille si une fonction est appelée assez souvent dans une section critique pour les performances, la surcharge aura une certaine importance. L'inline rend rarement votre code plus lent, sauf si vous en faites trop; cela rendra le code plus gros. Les compilateurs d'aujourd'hui sont assez bons pour décider eux-mêmes quand ils doivent être intégrés et quand ils ne le sont pas, donc vous n'avez presque jamais à vous inquiéter.

Personnellement, j'ignore complètement l'inline pendant le développement, jusqu'à ce que je dispose d'un produit plus ou moins utilisable que je peux profiler et seulement si le profilage me le dit, qu'une certaine fonction est appelée très souvent et également dans une section critique de l'application, alors je vais envisager de "forcer" cette fonction.

Jusqu'à présent, ma réponse est très générique, elle s'applique autant à C qu'à C++ et Objective-C. En guise de mot de clôture, permettez-moi de dire quelque chose à propos du C++ en particulier: les méthodes qui sont virtuelles sont des appels de fonction indirecte doubles, cela signifie qu'elles ont une surcharge d'appel de fonction plus élevée que les appels de fonction normaux et elles ne peuvent pas non plus être intégrées. Les méthodes non virtuelles peuvent être intégrées par le compilateur ou non, mais même si elles ne le sont pas, elles sont toujours significativement plus rapides que les virtuelles, vous ne devez donc pas rendre les méthodes virtuelles, sauf si vous prévoyez vraiment de les remplacer ou de les faire remplacer.

8
Mecki

Le montant de la surcharge dépendra du compilateur, du CPU, etc. Le pourcentage de la surcharge dépendra du code que vous insérez. La seule façon de savoir est de prendre votre code et de le profiler dans les deux sens - c'est pourquoi il n'y a pas de réponse définitive.

8
Mark Ransom

Pour les très petites fonctions, l'inlining est logique, car le (petit) coût de l'appel de fonction est significatif par rapport au (très petit) coût du corps de la fonction. Pour la plupart des fonctions sur quelques lignes, ce n'est pas une grosse victoire.

5
Don Neufeld

Il convient de souligner qu'une fonction intégrée augmente la taille de la fonction appelante et que tout ce qui augmente la taille d'une fonction peut avoir un effet négatif sur la mise en cache. Si vous êtes à la limite, "juste une menthe mince de plus" est du code en ligne qui pourrait avoir un effet considérablement négatif sur les performances.


Si vous lisez de la documentation qui met en garde contre "le coût d'un appel de fonction", je dirais qu'il s'agit peut-être de matériel plus ancien qui ne reflète pas les processeurs modernes. À moins que vous ne soyez dans le monde intégré, l'ère dans laquelle C est un "langage d'assemblage portable" est essentiellement passée. Une grande partie de l'ingéniosité des concepteurs de puces au cours de la dernière décennie (disons) est allée dans toutes sortes de complexités de bas niveau qui peuvent différer radicalement de la façon dont les choses fonctionnaient "à l'époque".

4
Larry OBrien

Les processeurs modernes sont très rapides (évidemment!). Presque toutes les opérations impliquées dans les appels et le passage d'arguments sont des instructions à pleine vitesse (les appels indirects peuvent être légèrement plus chers, principalement la première fois dans une boucle).

La surcharge des appels de fonction est si petite que seules les boucles qui appellent les fonctions peuvent rendre la surcharge des appels pertinente.

Par conséquent, lorsque nous parlons (et mesurons) la surcharge des appels de fonction aujourd'hui, nous parlons généralement de la surcharge de ne pas être capable de sortir des sous-expressions courantes des boucles. Si une fonction doit faire un tas de travaux (identiques) chaque fois qu'elle est appelée, le compilateur pourrait la "hisser" hors de la boucle et le faire une fois si elle était en ligne. Lorsqu'il n'est pas intégré, le code va probablement continuer et répéter le travail, vous l'avez dit!

Les fonctions intégrées semblent incroyablement plus rapides non pas à cause de la surcharge des appels et des arguments, mais à cause des sous-expressions courantes qui peuvent être extraites de la fonction.

Exemple:

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 optimiseur peut voir à travers cette folie et faire:

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();
}

Il semble que la surcharge des appels soit incroyablement réduite car elle a vraiment bloqué un gros morceau de la fonction hors de la boucle (l'appel CalculatePi_1000_digits). Le compilateur devrait être en mesure de prouver que CalculatePi_1000_digits renvoie toujours le même résultat, mais de bons optimiseurs peuvent le faire.

2
doug65536

Il existe un excellent concept appelé "observation des registres", qui permet de transmettre (jusqu'à 6?) Des valeurs via des registres (sur le processeur) au lieu de la pile (mémoire). De plus, selon la fonction et les variables utilisées, le compilateur peut simplement décider que le code de gestion de trame n'est pas requis !!

De plus, même le compilateur C++ peut faire une 'optimisation de récursivité de queue', c'est-à-dire si A() appelle B (), et après avoir appelé B (), A revient simplement, le compilateur réutilisera le cadre de la pile !!

Bien sûr, tout cela peut être fait, seulement si le programme s'en tient à la sémantique de la norme (voir l'aliasing du pointeur et son effet sur les optimisations)

2
vrdhn

Il y a quelques problèmes ici.

  • Si vous avez un compilateur suffisamment intelligent, il effectuera une incrustation automatique pour vous même si vous n'avez pas spécifié inline. D'un autre côté, il y a beaucoup de choses qui ne peuvent pas être intégrées.

  • Si la fonction est virtuelle, vous paierez bien sûr le prix qu'elle ne peut pas être intégrée car la cible est déterminée au moment de l'exécution. Inversement, en Java, vous pourriez payer ce prix sauf si vous indiquez que la méthode est définitive.

  • Selon la façon dont votre code est organisé en mémoire, il se peut que vous payiez un coût en cas de manque de cache et même de page manquante car le code se trouve ailleurs. Cela peut finir par avoir un impact énorme sur certaines applications.

1
Uri

Il n'y a pas beaucoup de frais généraux, en particulier avec les petites fonctions (en ligne) ou même les classes.

L'exemple suivant comporte trois tests différents qui sont chacun exécutés plusieurs fois et chronométrés. Les résultats sont toujours égaux à l'ordre de quelques millièmes d'une unité de temps.

#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 sortie pour l'exécution de 10 000 000 d'itérations (de chaque type: simple, six appels de fonction, trois appels d'objet) était avec cette charge utile de travail semi-alambiquée:

sum += std::sqrt(a*a + b*b + sum) + std::sin(sum) + std::cos(sum)

comme suit:

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

En utilisant une charge utile de travail simple de

sum += a + b

Donne les mêmes résultats à l'exception de quelques ordres de grandeur plus rapides pour chaque cas.

1
teeks99

Chaque nouvelle fonction nécessite la création d'une nouvelle pile locale. Mais la surcharge de ceci ne serait perceptible que si vous appelez une fonction à chaque itération d'une boucle sur un très grand nombre d'itérations.

0
Ash

Pour la plupart des fonctions, il n'y a pas de surcharge supplémentaire pour les appeler en C++ vs C (sauf si vous comptez que le pointeur "this" comme argument inutile pour chaque fonction .. Vous devez passer l'état à une fonction en quelque sorte) ...

Pour les fonctions virtuelles, il s'agit d'un niveau d'indirection supplémentaire (équivalent à l'appel d'une fonction via un pointeur en C) ... Mais vraiment, sur le matériel d'aujourd'hui, c'est trivial.

0
dicroce

Selon la façon dont vous structurez votre code, la division en unités telles que les modules et les bibliothèques peut avoir une grande importance dans certains cas.

  1. L'utilisation de la fonction de bibliothèque dynamique avec liaison externe imposera la plupart du temps un traitement de trame de pile complète.
    C'est pourquoi l'utilisation de qsort de la bibliothèque stdc est un ordre de grandeur (10 fois) plus lente que l'utilisation du code stl lorsque l'opération de comparaison est aussi simple qu'une comparaison entière.
  2. Le passage des pointeurs de fonction entre les modules sera également affecté.
  3. La même pénalité affectera très probablement l'utilisation des fonctions virtuelles de C++ ainsi que d'autres fonctions, dont le code est défini dans des modules séparés.

  4. La bonne nouvelle est que l'optimisation complète du programme pourrait résoudre le problème des dépendances entre les bibliothèques statiques et les modules.

0
user377178

Je n'ai pas de chiffres non plus, mais je suis content que vous demandiez. Trop souvent, je vois des gens essayer d'optimiser leur code en commençant par de vagues idées de surcharge, mais sans vraiment savoir.

0
Andy Lester

Comme d'autres l'ont dit, vous n'avez vraiment pas à vous soucier trop des frais généraux, sauf si vous recherchez des performances ultimes ou quelque chose de similaire. Lorsque vous créez une fonction, le compilateur doit écrire du code dans:

  • Enregistrer les paramètres de fonction dans la pile
  • Enregistrez l'adresse de retour dans la pile
  • Aller à l'adresse de départ de la fonction
  • Allouer de l'espace pour les variables locales de la fonction (pile)
  • Exécutez le corps de la fonction
  • Enregistrer la valeur de retour (pile)
  • Espace libre pour les variables locales aka garbage collection
  • Revenez à l'adresse de retour enregistrée
  • Libérez la sauvegarde pour les paramètres etc ...

Cependant, vous devez tenir compte de la réduction de la lisibilité de votre code, ainsi que de la manière dont cela affectera vos stratégies de test, vos plans de maintenance et l'impact de la taille globale de votre fichier src.

0
Jesse