Conseils d'optimisation de bas niveau C ++ [fermé]

79

En supposant que vous ayez déjà le meilleur algorithme, quelles solutions de bas niveau pouvez-vous proposer pour extraire les dernières gouttes de cadence douce en code C ++?

Il va sans dire que ces conseils ne s'appliquent qu'à la section de code critique que vous avez déjà mise en surbrillance dans votre profileur. Toutefois, ils doivent constituer des améliorations non structurelles de bas niveau. J'ai semé un exemple.

réduction
la source
1
Ce qui fait de cette question une question de développement de jeux et non une question de programmation générale comme celle-ci: stackoverflow.com/search?q=c%2B%2B+optimization
Danny Varod le
@ Danny - Cela pourrait probablement être une question de programmation générale. C'est aussi certainement une question liée à la programmation de jeux. Je pense que c'est une question viable sur les deux sites.
Smashery
@Smashery La seule différence entre les deux réside dans le fait que la programmation de jeux peut nécessiter des optimisations spécifiques au niveau du moteur graphique ou des optimisations de codeur de shader. La partie C ++ est identique.
Danny Varod
@Danny - Certes, certaines questions seront "plus" pertinentes sur l'un ou l'autre site; mais je ne voudrais pas refuser les questions pertinentes simplement parce qu'elles pourraient aussi être posées sur un autre site.
Smashery

Réponses:

76

Optimisez votre mise en page de données! (Ceci s'applique à plus de langages que juste C ++)

Vous pouvez aller assez en profondeur en adaptant cela spécifiquement à vos données, à votre processeur, à la gestion multi-cœur, etc. Mais le concept de base est le suivant:

Lorsque vous traitez des éléments en boucle étroite, vous souhaitez que les données de chaque itération soient aussi petites que possible et aussi proches que possible en mémoire. Cela signifie que l’idéal est un tableau ou un vecteur d’objets (pas de pointeurs) contenant uniquement les données nécessaires au calcul.

Ainsi, lorsque la CPU récupère les données pour la première itération de votre boucle, les différentes itérations suivantes de données sont chargées dans le cache.

Vraiment le processeur est rapide et le compilateur est bon. Vous ne pouvez pas vraiment faire grand chose en utilisant des instructions moins nombreuses et plus rapides. La cohérence du cache en est la base (c'est un article aléatoire que j'ai cherché sur Google - il contient un bon exemple pour obtenir la cohérence du cache pour un algorithme qui ne traite pas simplement les données de manière linéaire).

Andrew Russell
la source
Cela vaut la peine d'essayer l'exemple C dans la page de cohérence du cache liée. Lorsque j'ai découvert la première fois cela, j'ai été choqué de constater à quel point cela faisait une différence.
Neel
9
Voir également l'excellent exposé sur les pièges de la programmation orientée objet (R & D de Sony) ( research.scee.net/files/presentations/gcapaustralia09/… ) - et les articles grincheux mais fascinants de Mike Acton ( cellperformance.beyond3d.com/articles/ index.html ). Le blog Games from Within de Noel Llopis aborde également ce sujet fréquemment ( gamesfromwithin.com ). Je ne peux pas recommander assez les glissades des pièges ...
leander
2
Je voudrais juste vous avertir de "rendre les données pour chaque itération aussi petites que possible et aussi proches que possible en mémoire" . L'accès à des données non alignées peut ralentir les choses; dans ce cas, le rembourrage donnera de meilleures performances. L' ordre des données est également important, car des données bien ordonnées peuvent réduire le remplissage. Scott Mayers peut expliquer cela mieux que moi :)
Jonathan Connell Le
+1 à la présentation Sony. Je l'avais déjà lu auparavant et cela donne un sens à la manière d'optimiser les données au niveau de la plate-forme, en envisageant de fractionner les données et de les aligner correctement.
ChrisC
84

Un conseil très, très bas, mais qui peut être utile:

La plupart des compilateurs prennent en charge certaines formes d'indices conditionnels explicites. GCC a une fonction appelée __builtin_expect qui vous permet d'informer le compilateur de la valeur probable d'un résultat. GCC peut utiliser ces données pour optimiser les conditions conditionnelles afin qu'elles s'exécutent aussi rapidement que possible dans le cas prévu, avec une exécution légèrement plus lente dans le cas imprévu.

if(__builtin_expect(entity->extremely_unlikely_flag, 0)) {
  // code that is rarely run
}

J'ai constaté une accélération de 10 à 20% avec une utilisation appropriée de cette fonctionnalité.

ZorbaTHut
la source
1
Je voterais deux fois si je pouvais.
mardi
10
+1, le noyau Linux l'utilise abondamment pour les microoptimisations dans le code du planificateur, ce qui fait une différence significative dans certains chemins de code.
Greyfade
2
Malheureusement, il ne semble pas y avoir de bon équivalent dans Visual Studio. stackoverflow.com/questions/1440570/…
mmyers
1
Donc, à quelle fréquence la valeur attendue devrait-elle être la bonne pour gagner en performance? 49/50 fois? Ou 999999/1000000 fois?
Douglas
36

La première chose que vous devez comprendre est le matériel que vous utilisez. Comment gère-t-il les branches? Qu'en est-il de la mise en cache? At-il un jeu d'instructions SIMD? Combien de processeurs peut-il utiliser? Doit-il partager le temps processeur avec autre chose?

Vous pouvez résoudre le même problème de manières très différentes - même votre choix d'algorithme doit dépendre du matériel. Dans certains cas, O (N) peut fonctionner plus lentement que O (NlogN) (en fonction de la mise en œuvre).

En tant que vue d’ensemble de l’optimisation, la première chose à faire est d’examiner exactement quels problèmes et quelles données vous essayez de résoudre. Optimisez ensuite pour cela. Si vous voulez des performances extrêmes, oubliez les solutions génériques - vous pouvez personnaliser tout ce qui ne correspond pas à votre cas le plus utilisé.

Puis profil. Profil, profil, profil. Examinez l'utilisation de la mémoire, examinez les pénalités de branchement, examinez la surcharge des appels de fonction, examinez l'utilisation du pipeline. Déterminez ce qui ralentit votre code. Il s’agit probablement de l’accès aux données (j’ai écrit un article intitulé "The Latency Elephant" sur les frais généraux liés à l’accès aux données - google it. Je ne peux pas poster 2 liens ici car je n’ai pas assez de "réputation"), examinez de près cela et optimisez ensuite la disposition de vos données (de superbes baies plates et homogènes sont géniales ) et votre accès aux données (pré-extraction si possible).

Une fois que vous avez minimisé les frais généraux du sous-système de mémoire, essayez de déterminer si les instructions constituent maintenant le goulot d'étranglement (espérons-le), puis examinez les implémentations SIMD de votre algorithme - Les implémentations Structure-of-Arrays (SoA) peuvent être très data et cache d'instruction efficace. Si SIMD ne correspond pas à votre problème, des codages intrinsèques et de niveau assembleur peuvent être nécessaires.

Si vous avez encore besoin de plus de vitesse, passez en parallèle. Si vous avez l'avantage de fonctionner sur une PS3, alors les SPU sont vos amis. Utilisez-les, aimez-les. Si vous avez déjà écrit une solution SIMD, vous obtiendrez un avantage considérable en passant à SPU.

Et ensuite, profilez un peu plus. Testez dans des scénarios de jeu - ce code est-il toujours le goulot d'étranglement? Pouvez-vous changer la façon dont ce code est utilisé à un niveau supérieur pour minimiser son utilisation (en fait, cela devrait être votre première étape)? Pouvez-vous différer les calculs sur plusieurs images?

Quelle que soit la plate-forme sur laquelle vous vous trouvez, renseignez-vous le plus possible sur le matériel et les profileurs disponibles. Ne présumez pas que vous connaissez le goulot d'étranglement - trouvez-le avec votre profileur. Et assurez-vous d'avoir une heuristique pour déterminer si vous avez réellement accéléré votre jeu.

Et puis profilez à nouveau.

Tony Albrecht
la source
31

Première étape: réfléchissez bien à vos données par rapport à vos algorithmes. O (log n) n'est pas toujours plus rapide que O (n). Exemple simple: il est souvent préférable de remplacer une table de hachage avec seulement quelques clés par une recherche linéaire.

Deuxième étape: regardez l'assemblage généré. C ++ apporte beaucoup de code implicite à la table. Parfois, il se faufile sur vous sans que vous le sachiez.

Mais en supposant que c’est vraiment le moment de pédaler du métal: Profile. Sérieusement. Appliquer au hasard des "astuces de performance" est aussi susceptible de faire mal que d’aider.

Ensuite, tout dépend de vos goulots d'étranglement.

cache de données manquantes => optimisez la disposition de vos données. Voici un bon point de départ: http://gamesfromwithin.com/data-oriented-design

code cache misses => Examinez les appels de fonctions virtuelles, la profondeur excessive de la pile d’appel, etc. Une cause fréquente de mauvaises performances est la croyance erronée que les classes de base doivent être virtuelles.

Autres puits de performance C ++ courants:

  • Allocation / désallocation excessive. Si les performances sont critiques, n'appelez pas le moteur d'exécution. Déjà.
  • Copier la construction. Évitez partout où vous le pouvez. Si cela peut être une référence const, faites-en une.

Toutes les réponses ci-dessus sont immédiatement évidentes lorsque vous regardez l’ensemble, voyez donc ci-dessus;)

Rachel Blum
la source
19

Supprimer les branches inutiles

Sur certaines plates-formes et avec certains compilateurs, les branches peuvent jeter tout votre pipeline. Même des blocs if si insignifiants peuvent coûter cher.

L'architecture PowerPC (PS3 / X360) offre l'instruction de sélection à virgule flottante, fsel. Ceci peut être utilisé à la place d'une branche si les blocs sont de simples affectations:

float result = 0;
if (foo > bar) { result = 2.0f; }
else { result = 1.0f; }

Devient:

float result = fsel(foo-bar, 2.0f, 1.0f);

Lorsque le premier paramètre est supérieur ou égal à 0, le deuxième paramètre est renvoyé, sinon le troisième.

Le prix à payer pour perdre la branche est que le bloc if {} et le bloc else {} seront exécutés. Par conséquent, si l'opération est coûteuse ou supprime un pointeur NULL, cette optimisation n'est pas appropriée.

Parfois, votre compilateur a déjà effectué ce travail, alors vérifiez d'abord votre assemblage.

Voici plus d'informations sur les branches et le fsel:

http://assemblyrequired.crashworks.org/tag/intrinsics/

tenpn
la source
float result = (foo> bar)? 2.f: 1.f
knight666 Le
3
@ knight666: Cela produira toujours une branche n'importe où qu'un "si" aurais pu faire. Je le dis comme ça parce que sur ARM, au moins, de petites séquences comme celle-là peuvent être implémentées avec des instructions conditionnelles qui ne nécessitent pas de branchement.
chrisbtoo
1
@ knight666 si vous avez de la chance, le compilateur peut transformer cela en fsel, mais ce n'est pas certain. FWIW, j’écrirais normalement cet extrait avec un opérateur tertiaire, puis l’optimiserais ultérieurement si le profileur était d’accord.
mardi
Sur IA32, vous avez plutôt CMOVcc.
Skizz
Voir aussi blueraja.com/blog/285/… (notez que dans ce cas, si le compilateur est bon, il devrait pouvoir l'optimiser lui-même, donc ce n'est pas quelque chose qui vous préoccupe normalement)
BlueRaja - Danny Pflughoeft
16

Évitez à tout prix les accès à la mémoire, en particulier les plus aléatoires.

C'est la chose la plus importante à optimiser pour les processeurs modernes. Vous pouvez faire un tas de calculs arithmétiques et même beaucoup de branches mal prédites pendant que vous attendez des données de la RAM.

Vous pouvez également lire cette règle dans l’inverse: faites autant de calculs que possible entre les accès mémoire.

Axel Gneiting
la source
13

Utilisez les éléments intrinsèques du compilateur.

Assurez-vous que le compilateur génère l'assemblage le plus efficace pour certaines opérations en utilisant intrinsics - des constructions ressemblant à des appels de fonction que le compilateur transforme en assemblage optimisé:

Voici une référence pour Visual Studio , et en voici une pour GCC

AShelly
la source
11

Supprimer les appels de fonction virtuels inutiles

L'envoi d'une fonction virtuelle peut être très lent. Cet article donne une bonne explication de pourquoi. Si possible, évitez-les pour les fonctions appelées plusieurs fois par image.

Vous pouvez le faire de plusieurs manières. Parfois, vous pouvez simplement réécrire les classes pour ne pas avoir besoin d'héritage - peut-être qu'il se trouve que MachineGun est la seule sous-classe de Weapon et que vous pouvez les fusionner.

Vous pouvez utiliser des modèles pour remplacer le polymorphisme au moment de la compilation par un polymorphisme au moment de la compilation. Cela ne fonctionne que si vous connaissez le sous-type de vos objets au moment de l'exécution et peut être une réécriture majeure.

tenpn
la source
9

Mon principe de base est le suivant: ne faites rien qui ne soit pas nécessaire .

Si vous avez constaté qu'une fonction particulière est un goulot d'étranglement, vous pouvez l'optimiser - ou vous pouvez essayer de l'empêcher d'appeler cette fonction.

Cela ne signifie pas nécessairement que vous utilisez un mauvais algorithme. Cela signifie peut-être que vous exécutez des calculs pour chaque image pouvant être mise en cache pendant un court instant (ou entièrement précalculée), par exemple.

J'essaie toujours cette approche avant toute tentative d'optimisation à très bas niveau.

mmyers
la source
2
Cette question suppose que vous avez déjà effectué toutes les tâches structurelles possibles.
mardi
2
Cela fait. Mais souvent, vous supposez que vous avez, et vous ne l'avez pas. Alors vraiment, chaque fois qu'une fonction coûteuse doit être optimisée, demandez-vous si vous devez appeler cette fonction.
Rachel Blum
2
... mais parfois, il peut être plus rapide de faire le calcul même si vous allez jeter le résultat par la suite, plutôt que de créer une branche.
tenpn
9

Utilisez SIMD (par SSE), si vous ne le faites pas déjà. Gamasutra a un bel article à ce sujet . Vous pouvez télécharger le code source à partir de la bibliothèque présentée à la fin de l'article.

Peter Mortensen
la source
6

Réduisez au minimum les chaînes de dépendance afin de mieux utiliser la ligne de commande du processeur.

Dans des cas simples, le compilateur peut le faire pour vous si vous activez le déroulement de la boucle. Cependant, il ne le fait souvent pas, en particulier lorsque des flottants sont impliqués, car la réorganisation des expressions modifie le résultat.

Exemple:

float *data = ...;
int length = ...;

// Slow version
float total = 0.0f;
int i;
for (i=0; i < length; i++)
{
  total += data[i]
}

// Fast version
float total1, total2, total3, total4;
for (i=0; i < length-3; i += 4)
{
  total1 += data[i];
  total2 += data[i+1];
  total3 += data[i+2];
  total4 += data[i+3];
}
for (; i < length; i++)
{
  total += data[i]
}
total += (total1 + total2) + (total3 + total4);
Adam
la source
4

Ne négligez pas votre compilateur - si vous utilisez gcc sur Intel, vous pouvez facilement obtenir un gain de performances en passant au compilateur Intel C / C ++, par exemple. Si vous ciblez une plate-forme ARM, consultez le compilateur commercial d’ARM. Si vous êtes sur l'iPhone, Apple vient d'autoriser l'utilisation de Clang à partir du SDK iOS 4.0.

L’un des problèmes que vous rencontrerez probablement avec l’optimisation, en particulier sur le x86, est que beaucoup de choses intuitives finissent par s’opposer à vous sur les implémentations de processeurs modernes. Malheureusement pour la plupart d'entre nous, la capacité d'optimiser le compilateur a disparu depuis longtemps. Le compilateur peut planifier des instructions dans le flux en fonction de sa propre connaissance interne du processeur. En outre, le processeur peut également reprogrammer des instructions en fonction de ses propres besoins. Même si vous pensez à une méthode optimale pour organiser une méthode, il est probable que le compilateur ou le processeur l'ait déjà conçue seule et qu'elle a déjà effectué cette optimisation.

Mon meilleur conseil serait d'ignorer les optimisations de bas niveau et de se concentrer sur les optimisations de niveau supérieur. Le compilateur et la CPU ne peuvent pas changer votre algorithme d'un algorithme O (n ^ 2) à un algorithme O (1), quelle que soit leur qualité. Cela va vous obliger à regarder exactement ce que vous essayez de faire et à trouver un meilleur moyen de le faire. Laissez le compilateur et la CPU se soucier du niveau bas et concentrez-vous sur les niveaux moyen à élevé.

Dennis Munsie
la source
Je vois ce que vous dites, mais il arrive un moment où vous avez atteint O (logN) et que vous n'allez pas vous en tirer plus que des changements structurels, où les optimisations de bas niveau peuvent entrer en jeu et vous gagner. cette demi-milliseconde supplémentaire.
mardi
1
Voir ma réponse re: O (log n). En outre, si vous recherchez une demi milliseconde, vous devrez peut-être examiner le niveau supérieur. C'est 3% de votre temps de cadre!
Rachel Blum
4

Le mot-clé restrict est potentiellement pratique, notamment dans les cas où vous devez manipuler des objets avec des pointeurs. Cela permet au compilateur de supposer que l’objet pointé ne sera pas modifié d’une autre manière, ce qui lui permettra d’optimiser de manière plus agressive, par exemple en conservant des parties de l’objet dans des registres ou en réorganisant les lectures et les écritures.

Une bonne chose à propos du mot clé est qu'il s'agit d'un conseil que vous pouvez appliquer une fois pour voir les avantages qui en découlent sans modifier votre algorithme. Le mauvais côté est que si vous l'utilisez au mauvais endroit, vous pourriez voir des données corrompues. Mais en général, il est assez facile de repérer où il est légitime de l'utiliser - c'est l'un des rares exemples dans lesquels on peut raisonnablement s'attendre à ce que le programmeur en sache plus que ce que le compilateur peut supposer en toute sécurité, ce qui explique pourquoi le mot clé a été introduit.

Techniquement, la «restriction» n'existe pas en C ++ standard, mais des équivalents spécifiques à une plate-forme sont disponibles pour la plupart des compilateurs C ++.

Voir aussi: http://cellperformance.beyond3d.com/articles/2006/05/demystifying-the-restrict-keyword.html

Kylotan
la source
2

Const tout!

Plus vous donnez d'informations au compilateur sur les données, meilleures sont les optimisations (du moins d'après mon expérience).

void foo(Bar * x) {...;}

devient;

void foo(const Bar * const x) {...;}

Le compilateur sait maintenant que le pointeur x ne va pas changer et que les données qu'il pointe ne changeront pas non plus.

L’autre avantage supplémentaire est que vous pouvez réduire le nombre de bogues accidentels, en vous arrêtant (vous-même ou d’autres) en modifiant des choses qu’ils ne devraient pas.

sheredom
la source
Et votre copain de code vous aimera!
tenpn
4
constn'améliore pas les optimisations du compilateur. Certes, le compilateur peut générer un meilleur code s'il sait qu'une variable ne changera pas, mais constne fournit pas une garantie suffisamment forte.
deft_code
3
Nan. 'restrict' est beaucoup plus utile que 'const'. Voir gamedev.stackexchange.com/questions/853/…
Justicule
+1 ppl en disant que l'aide ne peut pas être changée
NoSenseEtAl
2

Le plus souvent, le meilleur moyen d'obtenir des performances est de changer votre algorithme. Moins la mise en œuvre est générale, plus vous pouvez vous rapprocher du métal.

En supposant que cela ait été fait ...

Si le code est vraiment essentiel, essayez d'éviter les lectures en mémoire, évitez de calculer des éléments qui peuvent être précalculés (bien qu'aucune table de recherche ne viole la règle 1). Sachez ce que fait votre algorithme et écrivez-le de manière à ce que le compilateur le sache également. Vérifiez l'assemblage pour vous en assurer.

Évitez les oublis de cache. Traitement par lots autant que vous le pouvez. Évitez les fonctions virtuelles et autres indirections.

En fin de compte, tout mesurer. Les règles changent tout le temps. Ce qui accélérait le code il y a 3 ans le ralentit maintenant. Un bon exemple est «utilisez des fonctions mathématiques doubles au lieu de versions flottantes». Je ne l'aurais pas compris si je ne l'avais pas lu.

J'ai oublié - les constructeurs par défaut n'ont pas besoin d'intialiser vos variables, ou si vous insistez, créez au moins aussi des constructeurs qui ne le font pas. Soyez conscient des choses qui ne figurent pas dans les profils. Lorsque vous perdez un cycle inutile par ligne de code, rien n’apparaîtra dans votre profileur, mais vous perdrez beaucoup de cycles dans l’ensemble. Encore une fois, sachez ce que fait votre code. Rendez votre fonction principale maigre au lieu d'être infaillible. Des versions à toute épreuve peuvent être appelées si nécessaire, mais ne sont pas toujours nécessaires. La polyvalence a un prix - la performance en est une.

Édité pour expliquer pourquoi aucune initialisation par défaut: Beaucoup de code dit: Vector3 bla; bla = DoQuelque chose ();

L'initialisation dans le constructeur est une perte de temps. De plus, dans ce cas, le temps perdu est peu important (effacement du vecteur probablement), mais si vos programmeurs le font habituellement, cela s’ajoute. En outre, beaucoup de fonctions créent un temporaire (pensez opérateurs surchargés), qui est initialisé à zéro et attribué immédiatement après. Les cycles perdus masqués qui sont trop petits pour voir une pointe dans votre profileur, mais les cycles de fond perdu dans votre base de code. En outre, certaines personnes font beaucoup plus dans les constructeurs (ce qui est évidemment un non-non). J'ai vu des gains de plusieurs millisecondes avec une variable non utilisée dans laquelle le constructeur était un peu lourd. Dès que le constructeur provoque des effets secondaires, le compilateur ne pourra pas l'optimiser. Par conséquent, à moins que vous n'utilisiez jamais le code ci-dessus, je préfère un constructeur non-initialisant ou, comme je l'ai dit,

Vector3 bla (noInit); bla = doQuelque chose ();

Kaj
la source
/ N'initialisez pas vos membres dans des constructeurs? Comment ça aide?
mardi
Voir le post édité. Ne correspondait pas à la zone de commentaire.
Kaj
const Vector3 = doSomething()? Ensuite, l’optimisation de la valeur de retour peut commencer et probablement éliminer une ou deux tâches.
tenpn
1

Réduire l'évaluation de l'expression booléenne

Celui-ci est vraiment désespéré, car c'est une modification très subtile mais dangereuse de votre code. Toutefois, si votre condition est évaluée un nombre de fois excessif, vous pouvez réduire les frais généraux liés à l'évaluation booléenne en utilisant plutôt des opérateurs au niveau du bit. Alors:

if ((foo && bar) || blah) { ... } 

Devient:

if ((foo & bar) | blah) { ... }

Utiliser l'arithmétique entière à la place. Si vos foos et barres sont des constantes ou évaluées avant if (), cela pourrait être plus rapide que la version booléenne normale.

En prime, la version arithmétique a moins de branches que la version booléenne classique. Ce qui est une autre façon d' optimiser .

Le gros inconvénient est que vous perdez une évaluation paresseuse - le bloc entier est évalué, vous ne pouvez donc pas le faire foo != NULL & foo->dereference(). Pour cette raison, on peut soutenir que cela est difficile à maintenir et que le compromis peut être trop important.

réduction
la source
1
C'est un compromis assez flagrant pour la performance, principalement parce que ce n'est pas immédiatement évident que c'était prévu.
Bob Somers
Je suis presque complètement d'accord avec toi. J'ai dit que c'était désespéré!
mardi
3
Cela ne réduirait-il pas également les courts-circuits et rendrait la prédiction de branche moins fiable?
Egon
1
Si foo est 2 et bar est 1, le code ne se comporte pas de la même manière. Cela, et non pas une évaluation précoce, est le principal inconvénient, à mon avis.
1
En réalité, les booléens en C ++ sont garantis égaux à 0 ou 1, aussi longtemps que vous ne le faites qu'avec des booléens, vous êtes en sécurité. En savoir plus: altdevblogaday.org/2011/04/18/understanding-your-bool-type
10
1

Gardez un œil sur votre utilisation de la pile

Tout ce que vous ajoutez à la pile est une poussée et une construction supplémentaires quand une fonction est appelée. Lorsqu'une grande quantité d'espace de pile est requise, il peut parfois être avantageux d'allouer de la mémoire de travail à l'avance. Si la plate-forme sur laquelle vous travaillez est dotée d'une mémoire vive rapide à utiliser, tant mieux!

Neilogd
la source