Quel est le coût d'utilisation d'un pointeur vers une fonction membre par rapport à un commutateur?

StackOverflow https://stackoverflow.com/questions/113150

  •  02-07-2019
  •  | 
  •  

Question

J'ai la situation suivante:


class A
{
public:
    A(int whichFoo);
    int foo1();
    int foo2();
    int foo3();
    int callFoo(); // cals one of the foo's depending on the value of whichFoo
};

Dans mon implémentation actuelle, j'enregistre la valeur de whichFoo dans un membre de données du constructeur et utilise un commutateur dans callFoo () pour décider lequel des foo appeler. Alternativement, je peux utiliser un commutateur dans le constructeur pour enregistrer un pointeur sur le fooN () droit à appeler dans callFoo () .

Ma question est de savoir quelle est la solution la plus efficace si un objet de classe A n'est construit qu'une seule fois, alors que callFoo () est appelé un très grand nombre de fois. Ainsi, dans le premier cas, nous avons plusieurs exécutions d'une instruction switch, alors que dans le second, il n'y a qu'un commutateur, et plusieurs appels d'une fonction membre en utilisant le pointeur sur elle. Je sais que l'appel d'une fonction membre à l'aide d'un pointeur est plus lent que le simple appel direct. Quelqu'un sait-il si ces frais généraux sont supérieurs ou inférieurs au coût d'un commutateur ?

Clarification: je réalise que vous ne savez jamais vraiment quelle approche donne les meilleures performances tant que vous n’avez pas essayé et chronométré. Cependant, dans ce cas, l'approche 1 est déjà implémentée et je voulais savoir si l'approche 2 pouvait être plus efficace, du moins en principe. Il semble que cela puisse être le cas et il est maintenant logique que je me donne la peine de l’appliquer et de l’essayer.

Oh, et j'aime aussi mieux aborder 2 pour des raisons esthétiques. Je suppose que je cherche une justification pour l’appliquer. :)

Était-ce utile?

La solution

A quel point êtes-vous certain que l'appel d'une fonction membre via un pointeur est plus lent que le simple appel direct? Pouvez-vous mesurer la différence?

En général, vous ne devez pas compter sur votre intuition pour évaluer vos performances. Asseyez-vous avec votre compilateur et une fonction de minutage et mesurez les différents choix. Vous pourriez être surpris!

Plus d'infos: Il existe un excellent article pointeurs de fonctions membres et des délégués C ++ les plus rapides possibles qui décrit très en détail la mise en œuvre des pointeurs de fonction de membre.

Autres conseils

Vous pouvez écrire ceci:

class Foo {
public:
  Foo() {
    calls[0] = &Foo::call0;
    calls[1] = &Foo::call1;
    calls[2] = &Foo::call2;
    calls[3] = &Foo::call3;
  }
  void call(int number, int arg) {
    assert(number < 4);
    (this->*(calls[number]))(arg);
  }
  void call0(int arg) {
    cout<<"call0("<<arg<<")\n";
  }
  void call1(int arg) {
    cout<<"call1("<<arg<<")\n";
  }
  void call2(int arg) {
    cout<<"call2("<<arg<<")\n";
  }
  void call3(int arg) {
    cout<<"call3("<<arg<<")\n";
  }
private:
  FooCall calls[4];
};

Le calcul du pointeur de la fonction réelle est linéaire et rapide:

  (this->*(calls[number]))(arg);
004142E7  mov         esi,esp 
004142E9  mov         eax,dword ptr [arg] 
004142EC  push        eax  
004142ED  mov         edx,dword ptr [number] 
004142F0  mov         eax,dword ptr [this] 
004142F3  mov         ecx,dword ptr [this] 
004142F6  mov         edx,dword ptr [eax+edx*4] 
004142F9  call        edx 

Notez que vous n'avez même pas besoin de corriger le numéro de fonction réel dans le constructeur.

J'ai comparé ce code à l'asm généré par un commutateur . La version switch n'augmente pas les performances.

Pour répondre à la question posée: au niveau le plus fin du grain, le pointeur sur la fonction membre fonctionnera mieux.

Pour répondre à la question non posée: que signifie "mieux" veux dire ici? Dans la plupart des cas, je m'attendrais à ce que la différence soit négligeable. Cependant, selon ce que la classe fait, la différence peut être significative. Effectuer des tests de performance avant de s’inquiéter de la différence est évidemment la bonne première étape.

Si vous continuez à utiliser un commutateur, ce qui est parfaitement correct, vous devriez probablement placer la logique dans une méthode d'assistance et appeler si à partir du constructeur. Sinon, il s'agit d'un cas classique du modèle de stratégie . Vous pouvez créer une interface (ou classe abstraite) nommée IFoo qui possède une méthode portant la signature de Foo. Le constructeur prendrait une instance d'IFoo (constructeur Injection de dépendance qui implémentait la méthode foo Vous aurez un IFoo privé défini avec ce constructeur, et chaque fois que vous voudrez appeler Foo, vous appelez la version de votre IFoo.

Remarque: je ne travaille pas avec C ++ depuis l'université. Mon jargon est peut-être mal choisi, mais les idées générales sont valables pour la plupart des langages OO.

Si votre exemple est du code réel, alors je pense que vous devriez revoir la conception de votre classe. Transmettre une valeur au constructeur et l'utiliser pour modifier le comportement revient à créer une sous-classe. Pensez à la refactorisation pour la rendre plus explicite. En conséquence, votre code finira par utiliser un pointeur de fonction (toutes les méthodes virtuelles sont, en réalité, des pointeurs de fonction dans les tables de saut).

Si, toutefois, votre code n'était qu'un exemple simplifié pour vous demander si, en général, les tables de saut sont plus rapides que les instructions switch, mon intuition dirait que les tables de saut sont plus rapides, mais que vous dépendez de l'étape d'optimisation du compilateur. Mais si les performances vous préoccupent vraiment, ne vous fiez jamais à l’intuition. Créez un programme de test et testez-le, ou regardez l’assembleur généré.

Une chose est certaine, une instruction switch ne sera jamais plus lente qu'une table de saut. La raison en est que l'optimiseur d'un compilateur peut être optimisé, il faut aussi transformer une série de tests conditionnels (c'est-à-dire un commutateur) en une table de saut. Donc, si vous voulez vraiment en être certain, sortez le compilateur du processus de décision et utilisez une table de saut.

On dirait que vous devriez faire de callFoo une fonction virtuelle pure et créer des sous-classes de A .

À moins que vous n'ayez vraiment besoin de la vitesse, avons effectué un profilage et une instrumentation étendus, et déterminé que les appels à callFoo constituent réellement le goulot d'étranglement. Avez-vous?

Les pointeurs de fonction sont presque toujours meilleurs que les chained-if. Ils rendent le code plus propre et sont presque toujours plus rapides (sauf peut-être dans le cas où ce n’est qu’un choix entre deux fonctions et qui est toujours correctement prédit).

Je devrais penser que le pointeur serait plus rapide.

Instructions de pré-extraction des processeurs modernes; les branches mal prédites vident le cache, ce qui signifie qu'il se bloque pendant le remplissage du cache. Un pointeur ne fait pas ça.

Bien sûr, vous devriez mesurer les deux.

Optimiser uniquement si nécessaire

Premièrement: la plupart du temps, cela vous est égal, la différence sera minime. Assurez-vous que l'optimisation de cet appel a vraiment un sens en premier. Ne procédez à l'optimisation que si vos mesures montrent que le temps système consacré à la surcharge est très important (Cf. Comment optimiser une application pour la rendre plus rapide? ) Si l'optimisation n'est pas significative, préférez le code plus lisible.

Le coût des appels indirects dépend de la plate-forme cible

Une fois que vous avez déterminé qu'il est utile d'appliquer l'optimisation de bas niveau, il est alors temps de comprendre votre plate-forme cible. Le coût que vous pouvez éviter ici est la pénalité de mauvaise prédiction de la succursale. Sur les processeurs modernes x86 / x64, ces erreurs de prédiction sont susceptibles d’être très minimes (elles peuvent prévoir les appels indirects la plupart du temps), mais lorsque vous ciblez PowerPC ou d’autres plates-formes RISC, les appels / sauts indirects ne sont souvent pas prédits et vous évitent eux peuvent entraîner un gain de performance significatif. Voir aussi Le coût d'un appel virtuel dépend de la plate-forme .

Le compilateur peut également implémenter un commutateur utilisant la table de saut

Un seul piège: le commutateur peut parfois aussi être implémenté en tant qu’appel indirect (à l’aide d’une table), en particulier lors de la commutation entre plusieurs valeurs possibles. Un tel commutateur présente la même erreur de prédiction qu'une fonction virtuelle. Pour rendre cette optimisation fiable, on préférerait probablement utiliser if au lieu de switch dans le cas le plus courant.

Utilisez les minuteries pour voir celle qui est la plus rapide. Bien qu'à moins que ce code ne soit répétitif, il est peu probable que vous remarquiez une différence.

Assurez-vous que si vous exécutez du code à partir du constructeur, si la construction échoue, vous ne perdrez pas de mémoire.

Cette technique est fortement utilisée avec Symbian OS: http://www.titu.jyu.fi/modpa/Patterns/ pattern-TwoPhaseConstruction.html

Si vous appelez callFoo () une seule fois, le pointeur de la fonction sera probablement moins rapide que probablement . Si vous l’appelez plus de fois que le plus probablement , le pointeur de la fonction sera plus rapide d’un montant insignifiant (car il n’a pas besoin de continuer à passer par le commutateur).

Dans les deux cas, examinez le code assemblé pour savoir s'il fait ce que vous pensez qu'il fait.

Un avantage souvent négligé lors du basculement (même sur le tri et l'indexation) est que vous savez qu'une valeur particulière est utilisée dans la grande majorité des cas. Il est facile de commander le commutateur de sorte que les plus courants soient vérifiés en premier.

ps. Pour renforcer la réponse de greg, si vous tenez à la vitesse, mesurez. Regarder l'assembleur n'aide pas lorsque les processeurs ont des branches de prélecture / prédictive et des blocages de pipeline, etc.

Licencié sous: CC-BY-SA avec attribution
Non affilié à StackOverflow
scroll top