web-dev-qa-db-fra.com

En général, vaut-il la peine d'utiliser des fonctions virtuelles pour éviter les branchements?

Il semble y avoir des équivalents approximatifs d'instructions à assimiler au coût d'une branche manquant. Les fonctions virtuelles ont un compromis similaire:

  • instruction vs manque de cache de données
  • barrière d'optimisation

Si vous regardez quelque chose comme:

if (x==1) {
   p->do1();
}
else if (x==2) {
   p->do2();
}
else if (x==3) {
   p->do3();
}
...

Vous pouvez avoir un tableau de fonctions membres, ou si de nombreuses fonctions dépendent de la même catégorisation, ou si une catégorisation plus complexe existe, utilisez des fonctions virtuelles:

p->do()

Mais, en général, combien coûtent les fonctions virtuelles par rapport aux branchements Il est difficile de tester sur suffisamment de plates-formes pour généraliser, donc je me demandais si quelqu'un avait une règle empirique approximative (adorable si c'était aussi simple que 4 ifs est le point d'arrêt)

En général, les fonctions virtuelles sont plus claires et je pencherais pour elles. Mais, j'ai plusieurs sections très critiques où je peux changer le code des fonctions virtuelles en branches. Je préférerais avoir des réflexions à ce sujet avant d'entreprendre cela. (ce n'est pas un changement trivial ou facile à tester sur plusieurs plates-formes)

21
Glenn Teitelbaum

Je voulais sauter ici parmi ces réponses déjà excellentes et admettre que j'ai adopté l'approche laide de travailler en arrière vers l'anti-modèle de changement de code polymorphe en switches ou if/else branches avec gains mesurés. Mais je ne l'ai pas fait en gros, uniquement pour les chemins les plus critiques. Il n'a pas besoin d'être aussi noir et blanc.

En tant que clause de non-responsabilité, je travaille dans des domaines tels que le lancer de rayons où l'exactitude n'est pas si difficile à atteindre (et est souvent floue et approximative de toute façon) tandis que la vitesse est souvent l'une des qualités les plus compétitives. Une réduction des temps de rendu est souvent l'une des demandes les plus courantes des utilisateurs, nous nous grattant constamment la tête et trouvant comment y parvenir pour les chemins mesurés les plus critiques.

Refactorisation polymorphe des conditionnelles

Tout d'abord, il vaut la peine de comprendre pourquoi le polymorphisme peut être préférable du point de vue de la maintenabilité que la ramification conditionnelle (switch ou un tas de if/else déclarations). Le principal avantage ici est l'extensibilité .

Avec le code polymorphe, nous pouvons introduire un nouveau sous-type dans notre base de code, en ajouter des instances à une certaine structure de données polymorphes et faire en sorte que tout le code polymorphe existant fonctionne toujours de manière automagique sans autres modifications. Si vous avez un tas de code dispersé dans une grande base de code qui ressemble à la forme de, "Si ce type est 'foo', faites-le" , vous pourrait se retrouver avec un horrible fardeau de mettre à jour 50 sections disparates de code afin d'introduire un nouveau type de chose, et finir par en manquer quelques-unes.

Les avantages de maintenabilité du polymorphisme diminuent naturellement ici si vous n'avez que quelques ou même une section de votre base de code qui doit effectuer de telles vérifications de type.

Barrière d'optimisation

Je suggérerais de ne pas considérer cela du point de vue de la ramification et du pipelining autant, et de le regarder davantage du point de vue de la conception du compilateur des barrières d'optimisation. Il existe des moyens d'améliorer la prédiction de branche qui s'appliquent aux deux cas, comme le tri des données en fonction du sous-type (s'il s'inscrit dans une séquence).

Ce qui diffère davantage entre ces deux stratégies, c'est la quantité d'informations que l'optimiseur possède à l'avance. Un appel de fonction connu fournit beaucoup plus d'informations, un appel de fonction indirect qui appelle une fonction inconnue au moment de la compilation conduit à une barrière d'optimisation.

Lorsque la fonction appelée est connue, les compilateurs peuvent effacer la structure et la réduire en fragments, en alignant les appels, en éliminant le surcoût potentiel, en effectuant un meilleur travail lors de l'allocation des instructions/registres, peut-être même en réorganisant les boucles et d'autres formes de branches, en générant des -LUT miniatures codées le cas échéant (quelque chose que GCC 5.3 m'a récemment surpris avec une instruction switch en utilisant une LUT codée en dur pour les résultats plutôt qu'une table de saut).

Certains de ces avantages sont perdus lorsque nous commençons à introduire des inconnues au moment de la compilation dans le mélange, comme dans le cas d'un appel de fonction indirect, et c'est là que la ramification conditionnelle peut très probablement offrir un Edge.

Optimisation de la mémoire

Prenons un exemple de jeu vidéo qui consiste à traiter une séquence de créatures à plusieurs reprises dans une boucle étroite. Dans un tel cas, nous pourrions avoir un conteneur polymorphe comme celui-ci:

vector<Creature*> creatures;

Remarque: pour plus de simplicité, j'ai évité unique_ptr ici.

... où Creature est un type de base polymorphe. Dans ce cas, l'une des difficultés des conteneurs polymorphes est qu'ils souhaitent souvent allouer de la mémoire pour chaque sous-type séparément/individuellement (ex: en utilisant le lancement par défaut operator new pour chaque créature individuelle).

Cela fera souvent la première priorisation de l'optimisation (si nous en avons besoin) basée sur la mémoire plutôt que sur la ramification. Une stratégie consiste ici à utiliser un allocateur fixe pour chaque sous-type, en encourageant une représentation contiguë en allouant en gros morceaux et en regroupant la mémoire pour chaque sous-type alloué. Avec une telle stratégie, cela peut certainement aider à trier ce creatures conteneur par sous-type (ainsi que l'adresse), car cela améliore non seulement éventuellement la prédiction de branche mais également la localité de référence (permettant à plusieurs créatures de le même sous-type accessible depuis une seule ligne de cache avant l'expulsion).

Dévirtualisation partielle des structures de données et des boucles

Disons que vous avez parcouru tous ces mouvements et que vous souhaitez toujours plus de vitesse. Il convient de noter que chaque étape que nous entreprenons ici dégrade la maintenabilité, et nous serons déjà à un stade de broyage des métaux avec des rendements décroissants. Il doit donc y avoir une demande de performances assez importante si nous pénétrons sur ce territoire, où nous sommes prêts à sacrifier encore plus la maintenabilité pour des gains de performances de plus en plus petits.

Pourtant, la prochaine étape à essayer (et toujours avec une volonté d'annuler nos modifications si cela n'aide pas du tout) pourrait être une dévirtualisation manuelle.

Astuce de contrôle de version: à moins que vous ne soyez beaucoup plus averti en optimisation que moi, il peut être utile de créer une nouvelle branche à ce stade avec la volonté de la jeter si notre optimisation les efforts manquent, ce qui peut très bien se produire. Pour moi, tout est un essai et une erreur après ce genre de points, même avec un profileur à la main.

Néanmoins, nous n'avons pas à appliquer cet état d'esprit en gros. Poursuivant notre exemple, disons que ce jeu vidéo est composé de loin de créatures humaines. Dans un tel cas, nous ne pouvons dévirtualiser que des créatures humaines en les hissant et en créant une structure de données distincte juste pour elles.

vector<Human> humans;               // common case
vector<Creature*> other_creatures;  // additional rare-case creatures

Cela implique que toutes les zones de notre base de code qui ont besoin de traiter des créatures ont besoin d'une boucle spéciale pour les créatures humaines. Pourtant, cela élimine les frais généraux de répartition dynamique (ou peut-être, de manière plus appropriée, la barrière d'optimisation) pour les humains qui sont, de loin, le type de créature le plus courant. Si ces zones sont nombreuses et que nous pouvons nous le permettre, nous pouvons le faire:

vector<Human> humans;               // common case
vector<Creature*> other_creatures;  // additional rare-case creatures
vector<Creature*> creatures;        // contains humans and other creatures

... si nous pouvons nous le permettre, les chemins les moins critiques peuvent rester tels quels et traiter simplement tous les types de créatures de manière abstraite. Les chemins critiques peuvent traiter humans en une seule boucle et other_creatures dans une seconde boucle.

Nous pouvons étendre cette stratégie si nécessaire et potentiellement comprimer certains gains de cette façon, mais il convient de noter à quel point nous dégradons la maintenabilité dans le processus. L'utilisation de modèles de fonction ici peut aider à générer le code pour les humains et les créatures sans dupliquer la logique manuellement.

Dévirtualisation partielle des classes

Quelque chose que j'ai fait il y a des années et qui était vraiment dégoûtant, et je ne suis même plus sûr que cela soit bénéfique (c'était à l'ère C++ 03), c'était la dévirtualisation partielle d'une classe. Dans ce cas, nous stockions déjà un ID de classe avec chaque instance à d'autres fins (accessible via un accesseur dans la classe de base qui n'était pas virtuelle). Là, nous avons fait quelque chose d'analogue à cela (ma mémoire est un peu floue):

switch (obj->type())
{
   case id_common_type:
       static_cast<CommonType*>(obj)->non_virtual_do_something();
       break;
   ...
   default:
       obj->virtual_do_something();
       break;
}

... où virtual_do_something a été implémenté pour appeler des versions non virtuelles dans une sous-classe. C'est grossier, je sais, de faire un downcast statique explicite pour dévirtualiser un appel de fonction. Je ne sais pas à quel point c'est bénéfique maintenant car je n'ai pas essayé ce genre de chose depuis des années. Avec une exposition à la conception orientée données, j'ai trouvé que la stratégie ci-dessus de diviser les structures de données et les boucles de manière chaude/froide était beaucoup plus utile, ouvrant plus de portes pour des stratégies d'optimisation (et beaucoup moins laides).

Dévirtualisation en gros

Je dois admettre que je ne suis jamais allé aussi loin en appliquant un état d'esprit d'optimisation, donc je n'ai aucune idée des avantages. J'ai évité les fonctions indirectes dans la prospective dans les cas où je savais qu'il n'y aurait qu'un seul ensemble central de conditions (ex: traitement d'événements avec un seul événement central de traitement des événements), mais je n'ai jamais commencé avec un état d'esprit polymorphe et optimisé à fond jusqu'ici.

Théoriquement, les avantages immédiats pourraient être une manière potentiellement plus petite d'identifier un type qu'un pointeur virtuel (ex: un seul octet si vous pouvez vous engager à l'idée qu'il existe 256 types uniques ou moins) en plus d'effacer complètement ces barrières d'optimisation .

Il peut également être utile dans certains cas d'écrire du code plus facile à gérer (par rapport aux exemples de dévirtualisation manuelle optimisés ci-dessus) si vous utilisez simplement une seule instruction switch centrale sans avoir à diviser vos structures de données et vos boucles en fonction du sous-type , ou s'il y a une dépendance à l'ordre dans ces cas où les choses doivent être traitées dans un ordre précis (même si cela nous oblige à nous ramifier partout). Ce serait pour les cas où vous n'avez pas trop d'endroits qui doivent faire le switch.

Je ne recommanderais généralement pas cela même avec un état d'esprit très critique, sauf si cela est raisonnablement facile à maintenir. "Facile à entretenir" aurait tendance à dépendre de deux facteurs dominants:

  • N'ayant pas un réel besoin d'extensibilité (ex: savoir avec certitude que vous avez exactement 8 types de choses à traiter, et jamais plus).
  • Ne pas avoir beaucoup d'endroits dans votre code qui doivent vérifier ces types (ex: un endroit central).

... pourtant je recommande le scénario ci-dessus dans la plupart des cas et itère vers des solutions plus efficaces par dévirtualisation partielle selon les besoins. Cela vous donne beaucoup plus de marge de manœuvre pour équilibrer les besoins d'extensibilité et de maintenabilité avec les performances.

Fonctions virtuelles et pointeurs de fonctions

Pour couronner le tout, j'ai remarqué ici qu'il y avait une discussion sur les fonctions virtuelles par rapport aux pointeurs de fonction. Il est vrai que les fonctions virtuelles nécessitent un peu de travail supplémentaire pour être appelées, mais cela ne signifie pas qu'elles sont plus lentes. Contre-intuitivement, cela peut même les rendre plus rapides.

C'est contre-intuitif ici parce que nous sommes habitués à mesurer le coût en termes d'instructions sans prêter attention à la dynamique de la hiérarchie de la mémoire qui a tendance à avoir un impact beaucoup plus important.

Si nous comparons un class avec 20 fonctions virtuelles par rapport à un struct qui stocke 20 pointeurs de fonction, et les deux sont instanciés plusieurs fois, la surcharge de mémoire de chaque instance de class dans ce cas, 8 octets pour le pointeur virtuel sur les machines 64 bits, tandis que la surcharge de mémoire du struct est de 160 octets.

Le coût pratique, il peut y avoir beaucoup plus de ratés de cache obligatoires et non obligatoires avec le tableau des pointeurs de fonction par rapport à la classe utilisant des fonctions virtuelles (et éventuellement des défauts de page à une échelle d'entrée suffisamment grande). Ce coût a tendance à éclipser le travail légèrement supplémentaire d'indexation d'une table virtuelle.

J'ai également traité des bases de code C héritées (plus anciennes que moi) où le fait de transformer de tels structs remplis de pointeurs de fonction, et instanciés de nombreuses fois, a en fait amélioré considérablement les performances (plus de 100% d'améliorations) en les transformant en classes avec des fonctions virtuelles, et tout simplement en raison de la réduction massive de l'utilisation de la mémoire, de la convivialité accrue du cache, etc.

D'un autre côté, lorsque les comparaisons deviennent plus sur des pommes avec des pommes, j'ai également trouvé la mentalité opposée de la conversion d'un état d'esprit de fonction virtuelle C++ en état d'esprit de pointeur de fonction de style C pour être utile dans ces types de scénarios:

class Functionoid
{
public:
    virtual ~Functionoid() {}
    virtual void operator()() = 0;
};

... où la classe stockait une seule fonction redoutable (ou deux si nous comptons le destructeur virtuel). Dans ces cas, cela peut certainement aider dans les chemins critiques à transformer cela en ceci:

void (*func_ptr)(void* instance_data);

... idéalement derrière une interface sécurisée pour cacher les lancers dangereux vers/depuis void*.

Dans les cas où nous sommes tentés d'utiliser une classe avec une seule fonction virtuelle, cela peut rapidement aider à utiliser des pointeurs de fonction à la place. Une grande raison n'est même pas nécessairement le coût réduit de l'appel d'un pointeur de fonction. C'est parce que nous ne sommes plus confrontés à la tentation d'allouer chaque fonctionoïde séparée sur les régions dispersées du tas si nous les agrégons en une structure persistante. Ce type d'approche peut permettre d'éviter plus facilement les surcharges associées à la segmentation et à la mémoire si les données d'instance sont homogènes, par exemple, et seul le comportement varie.

Il y a donc certainement des cas où l'utilisation de pointeurs de fonction peut aider, mais souvent je l'ai trouvé dans l'autre sens si nous comparons un tas de tables de pointeurs de fonction à une seule table virtuelle qui ne nécessite qu'un seul pointeur pour être stocké par instance de classe . Cette table sera souvent placée dans une ou plusieurs lignes de cache L1 ainsi que dans des boucles serrées.

Conclusion

Donc de toute façon, c'est mon petit tour sur ce sujet. Je recommande de s'aventurer dans ces domaines avec prudence. Faites confiance aux mesures, pas à l'instinct, et étant donné la façon dont ces optimisations dégradent souvent la maintenabilité, n'allez que dans la mesure de vos moyens (et une voie sage serait d'errer du côté de la maintenabilité).

22
user204677

Observations:

  • Dans de nombreux cas, les fonctions virtuelles sont plus rapides car la recherche de table est une opération O(1) tandis que l'échelle else if() est une opération O(n). Cependant, cela n'est vrai que si la distribution des cas est plate.

  • Pour une seule if() ... else, le conditionnel est plus rapide car vous enregistrez la surcharge d'appel de fonction.

  • Ainsi, lorsque vous avez une distribution uniforme des cas, un seuil de rentabilité doit exister. La seule question est de savoir où il se trouve.

  • Si vous utilisez un switch() au lieu de else if() ladder ou des appels de fonction virtuelle, votre compilateur peut produire un code encore meilleur: il peut créer une branche vers un emplacement qui est recherché depuis une table, mais qui n'est pas un appel de fonction. Autrement dit, vous disposez de toutes les propriétés de l'appel de fonction virtuelle sans tous les frais généraux d'appel de fonction.

  • Si l'une est beaucoup plus fréquente que les autres, démarrer une if() ... else avec ce cas vous donnera les meilleures performances: vous exécuterez une seule branche conditionnelle qui est correctement prédite dans la plupart des cas.

  • Votre compilateur n'a aucune connaissance de la distribution attendue des cas et assumera une distribution uniforme.

Étant donné que votre compilateur a probablement de bonnes heuristiques en place quant au moment de coder une switch() comme une échelle else if() ou comme une recherche de table. J'aurais tendance à faire confiance à son jugement à moins que vous ne sachiez que la distribution des cas est biaisée.

Donc, mon conseil est le suivant:

  • Si l'un des cas éclipse le reste en termes de fréquence, utilisez une échelle triée de else if().

  • Sinon, utilisez une instruction switch(), sauf si l'une des autres méthodes rend votre code beaucoup plus lisible. Assurez-vous que vous n'achetez pas un gain de performances négligeable avec une lisibilité considérablement réduite.

  • Si vous avez utilisé une switch() et que vous n'êtes toujours pas satisfait des performances, faites la comparaison, mais soyez prêt à découvrir que la switch() était déjà la possibilité la plus rapide.

En général, vaut-il la peine d'utiliser des fonctions virtuelles pour éviter les branchements?

En général, oui. Les avantages pour la maintenance sont importants (tests en séparation, séparation des préoccupations, modularité et extensibilité améliorées).

Mais, en général, combien coûtent les fonctions virtuelles par rapport aux branchements Il est difficile de tester sur suffisamment de plates-formes pour généraliser, donc je me demandais si quelqu'un avait une règle approximative (charmant si c'était aussi simple que 4 ifs est le point d'arrêt)

Sauf si vous avez profilé votre code et que vous savez que la répartition entre les branches (l'évaluation des conditions) prend plus de temps que les calculs effectués (le code dans les branches), optimisez les calculs effectués .

C'est-à-dire que la bonne réponse à "combien coûtent les fonctions virtuelles par rapport aux branchements" est de mesurer et de découvrir.

Règle générale: à moins d'avoir la situation ci-dessus (discrimination de branche plus chère que les calculs de branche), optimisez cette partie du code pour l'effort de maintenance (utilisez des fonctions virtuelles).

Vous dites que vous souhaitez que cette section s'exécute le plus rapidement possible; À quelle vitesse est-ce? Quelle est votre exigence concrète?

En général, les fonctions virtuelles sont plus claires et je pencherais pour elles. Mais, j'ai plusieurs sections très critiques où je peux changer le code des fonctions virtuelles en branches. Je préférerais avoir des réflexions à ce sujet avant de commencer. (ce n'est pas un changement anodin ou facile à tester sur plusieurs plates-formes)

Utilisez alors des fonctions virtuelles. Cela vous permettra même d'optimiser par plate-forme si nécessaire, tout en gardant le code client propre.

7
utnapistim

Les autres réponses fournissent déjà de bons arguments théoriques. Je voudrais ajouter les résultats d'une expérience que j'ai effectuée récemment pour estimer si ce serait une bonne idée d'implémenter une machine virtuelle (VM) en utilisant un grand switch sur l'op-code ou plutôt d'interpréter le op-code comme index dans un tableau de pointeurs de fonction. Bien que ce ne soit pas exactement la même chose qu'un appel de fonction virtual, je pense qu'il est raisonnablement proche.

J'ai écrit un script Python pour générer de façon aléatoire du code C++ 14 pour un VM avec une taille de jeu d'instructions choisie de manière aléatoire (bien que pas uniformément, en échantillonnant la plage basse plus densément) entre 1 et 10000. Le VM généré avait toujours 128 registres et pas de RAM. Les instructions n'ont pas de sens et ont toutes le format suivant.

inline void
op0004(machine_state& state) noexcept
{
  const auto c = Word_t {0xcf2802e8d0baca1dUL};
  const auto r1 = state.registers[58];
  const auto r2 = state.registers[69];
  const auto r3 = ((r1 + c) | r2);
  state.registers[6] = r3;
}

Le script génère également des routines de répartition à l'aide d'une instruction switch

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  switch (opcode)
  {
  case 0x0000: op0000(state); return 0;
  case 0x0001: op0001(state); return 0;
  // ...
  case 0x247a: op247a(state); return 0;
  case 0x247b: op247b(state); return 0;
  default:
    return -1;  // invalid opcode
  }
}

… Et un tableau de pointeurs de fonction.

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  typedef void (* func_type)(machine_state&);
  static const func_type table[VM_NUM_INSTRUCTIONS] = {
    op0000,
    op0001,
    // ...
    op247a,
    op247b,
  };
  if (opcode >= VM_NUM_INSTRUCTIONS)
    return -1;  // invalid opcode
  table[opcode](state);
  return 0;
}

La routine d'envoi générée a été choisie au hasard pour chaque machine virtuelle générée.

Pour le benchmarking, le flux de codes d'opération a été généré par un moteur aléatoire Mersenne twister (std::random_device) Semé au hasard (std::mt19937_64).

Le code de chaque VM a été compilé avec GCC 5.2.0 à l'aide des commutateurs -DNDEBUG, -O3 Et -std=c++14. Tout d'abord, il a été compilé à l'aide de l'option -fprofile-generate Et des données de profil collectées pour simuler 1000 instructions aléatoires. Le code a ensuite été recompilé avec l'option -fprofile-use Permettant des optimisations basées sur les données de profil collectées.

Le VM a ensuite été exercé (dans le même processus) quatre fois pendant 50000000 cycles et le temps pour chaque analyse a été mesuré. La première exécution a été rejetée pour éliminer les effets de cache froid. Le PRNG n'a pas été réensemencé entre les exécutions afin de ne pas exécuter la même séquence d'instructions.

En utilisant cette configuration, 1000 points de données pour chaque routine de répartition ont été collectés. Les données ont été collectées sur un processeur quadricœur AMD A8-6600K APU avec 2048 Ko de cache exécutant 64 bits GNU/Linux sans bureau graphique ni autres programmes en cours d'exécution. Vous trouverez ci-dessous un graphique du temps CPU moyen (avec écart-type) par instruction pour chaque machine virtuelle.

enter image description here

À partir de ces données, je pourrais gagner en assurance que l'utilisation d'une table de fonction est une bonne idée, sauf peut-être pour un très petit nombre de codes opérationnels. Je n'ai pas d'explication pour les valeurs aberrantes de la version switch entre 500 et 1000 instructions.

Tout le code source de l'indice de référence ainsi que les données expérimentales complètes et un graphique à haute résolution peuvent être trouvés sur mon site Web .

5
5gon12eder

En plus de la bonne réponse de cmaster, que j'ai surévaluée, gardez à l'esprit que les pointeurs de fonction sont généralement strictement plus rapides que les fonctions virtuelles. La répartition des fonctions virtuelles implique généralement d'abord le suivi d'un pointeur de l'objet vers la table virtuelle, l'indexation appropriée, puis le déréférencement d'un pointeur de fonction. La dernière étape est donc la même, mais il y a des étapes supplémentaires au départ. De plus, les fonctions virtuelles prennent toujours "ceci" comme argument, les pointeurs de fonction sont plus flexibles.

Une autre chose à garder à l'esprit: si votre chemin critique implique une boucle, il peut être utile de trier la boucle par destination de répartition. Évidemment, c'est nlogn, alors que parcourir la boucle n'est que n, mais si vous allez parcourir plusieurs fois cela peut valoir la peine. En triant par destination d'expédition, vous vous assurez que le même code est exécuté à plusieurs reprises, le gardant au chaud dans icache, minimisant les erreurs de cache.

Une troisième stratégie à garder à l'esprit: si vous décidez de vous éloigner des fonctions virtuelles/pointeurs de fonction vers des stratégies if/switch, vous pouvez également être bien servi en passant d'objets polymorphes à quelque chose comme boost :: variant (qui fournit également le commutateur sous forme d'abstraction du visiteur). Les objets polymorphes doivent être stockés par le pointeur de base, donc vos données sont partout dans le cache. Cela pourrait facilement avoir une plus grande influence sur votre chemin critique que le coût de la recherche virtuelle. Considérant que la variante est stockée en ligne en tant qu'union discriminée; il a une taille égale au plus grand type de données (plus une petite constante). Si vos objets ne diffèrent pas trop en taille, c'est un excellent moyen de les manipuler.

En fait, je ne serais pas surpris si l'amélioration de la cohérence de la mémoire cache de vos données aurait un impact plus important que votre question d'origine, alors j'irais certainement plus loin.

3
Nir Friedman

Puis-je simplement expliquer pourquoi je pense que c'est un problème XY ? (Vous n'êtes pas seul à leur demander.)

Je suppose que votre véritable objectif est de gagner du temps dans l'ensemble, pas seulement de comprendre un point sur les ratés de cache et les fonctions virtuelles.

Voici un exemple de réglage des performances réelles , dans un logiciel réel.

Dans les vrais logiciels, les choses se font qui, quelle que soit l'expérience du programmeur, pourraient être mieux faites. On ne sait pas ce qu'ils sont jusqu'à ce que le programme soit écrit et que le réglage des performances puisse être fait. Il existe presque toujours plus d'une façon d'accélérer le programme. Après tout, pour dire qu'un programme est optimal, vous dites que dans le panthéon des programmes possibles pour résoudre votre problème, aucun d'entre eux ne prend moins de temps. Vraiment?

Dans l'exemple que j'ai lié, cela prenait à l'origine 2700 microsecondes par "travail". Une série de six problèmes ont été corrigés, allant dans le sens antihoraire autour de la pizza. La première accélération a supprimé 33% du temps. Le second a supprimé 11%. Mais remarquez, le second n'était pas de 11% au moment où il a été trouvé, il était de 16%, parce que le premier problème avait disparu . De même, le troisième problème a été amplifié de 7,4% à 13% (presque le double) car les deux premiers problèmes avaient disparu.

À la fin, ce processus d'agrandissement a permis d'éliminer toutes les microsecondes sauf 3,7. Cela représente 0,14% du temps d'origine, soit une accélération de 730x.

enter image description here

La suppression des problèmes initialement importants donne une accélération modérée, mais ils ouvrent la voie à la suppression des problèmes ultérieurs. Ces problèmes ultérieurs auraient pu initialement être des parties insignifiantes du total, mais après l'élimination des premiers problèmes, ces petits problèmes deviennent importants et peuvent produire de grandes accélérations. (Il est important de comprendre que, pour obtenir ce résultat, aucun ne peut être manqué, et ce post montre à quel point ils peuvent facilement être.)

enter image description here

Le programme final était-il optimal? Probablement pas. Aucun des accélérations n'avait quoi que ce soit à voir avec les ratés du cache. Est-ce que les erreurs de cache importent maintenant? Peut être.

EDIT: Je reçois des downvotes de la part de personnes se focalisant sur les "sections hautement critiques" de la question du PO. Vous ne savez pas que quelque chose est "hautement critique" tant que vous ne savez pas quelle fraction de temps cela représente. Si le coût moyen de ces méthodes appelées est de 10 cycles ou plus, au fil du temps, la méthode de leur envoyer n'est probablement pas "critique", par rapport à ce qu'elles font réellement. Je vois cela encore et encore, où les gens considèrent "avoir besoin de chaque nanoseconde" comme une raison d'être un centime et une folie.

2
Mike Dunlavey