9
votes

Comment optimiser un programme C ++ intensive de calcul avec un goulot d'étranglement connu?

Je développe un logiciel scientifique pour mon université. Il est écrit en C ++ sur Windows (VS2008). L'algorithme doit calculer certaines valeurs pour un grand nombre de paires matricielles, c'est-à-dire sur le noyau réside une boucle itération sur les matrices, collectant des données, par exemple: xxx

cette routine est exécutée des millions de personnes. de fois pour différentes matrixa, matrixb paires. Mon problème est que ce programme est extrêmement lent, compilé en mode de sortie avec toutes les optimisations activées. Utilisation de la technique de débogueur «Pause-quand-occupé», j'ai établi que le programme est situé à l'intérieur de cette boucle, pratiquement tous les , même si vous vous attendez, cette routine est entourée d'un ensemble de conditions et de branches de contrôle. Ce qui m'intéresse le plus, c'est que lors de son exécution sur un système à deux processeurs Xeon, le programme utilise l'un des 4 cœurs (pas de surprise, il est uniformisé pour le moment) mais seulement jusqu'à environ 25% de sa limite et avec des oscillations relativement importantes, où je voudrais s'attendre à une charge stable, à 100% jusqu'à ce que le programme se termine.

La version actuelle est en fait une réécriture, créée avec optimisation des performances. J'ai été dévasté quand j'ai découvert qu'il est effectivement plus lent que l'original. La version précédente utilisée des matrices de boost utilisé, que j'ai remplacées par des matrices OpenCV, après les avoir établies pour être supérieures à 10 fois plus rapidement dans la comparaison du temps d'exécution de multiplication de deux matrices 1000x100. J'accède à la matrice par la désérofacturation manuelle d'un pointeur cru à ses données que j'espérais gagnerait des performances. J'ai fait la routine de calcul une macro multiforme #define pour appliquer son inlinage et éviter les appels de fonction et les retours. J'ai amélioré les calculs derrière les calculs afin que la valeur finale soit calculée en une seule passe à travers les matrices (l'ancienne version nécessite deux passes). Je m'attendais à obtenir des gains énormes et pourtant l'inverse est vrai. Je ne suis nulle part près de l'efficacité de mon ancien programme, sans parler de logiciels commerciaux pour l'application particulière.

Je me demandais s'il avait peut-être quelque chose à voir avec les données matricielles étant des caractères de 8 bits, j'ai déjà vu Cet accès aux flotteurs était effectivement plus lent que de doubler dans mon ancien programme, peut-être que les caractères sont encore plus lents puisque le processeur récupère des données dans des morceaux de 32 bits (ce Xeon saisit probablement même 64bits). J'ai également envisagé de transformer les matrices en vecteurs pour éviter une construction en boucle de boucle, ainsi qu'une forme de vectorisation, comme par exemple, calculer les données pour 4 (moins? Plus?) Cellules matricielles consécutives sur une seule itération de boucle. Toutes les autres idées s'il vous plaît?

EDIT: code réel de la nouvelle version basée sur OpenCV: xxx


14 commentaires

Juste la regarder, ne serait-il pas possible d'utiliser quelque chose comme OpenCl ou DirectCompute pour le faire sur une échelle de masse très rapidement?


Je prévois de l'essayer après avoir tout travaillé, mais il existe des difficultés potentielles, l'une des matrices de la paire est créée de manière dynamique dans chaque itération et un algorithme d'optimisation compliqué entoure l'ensemble de l'introduction. D'après ce que je comprends, les routines de calcul ("noyaux") pour GPGPU sont assez limitées en ce qui concerne la taille.


Est-il possible que 25% d'utilisation signifie que 1 de vos 4 cœurs est à 100% d'utilisation?


Juste pour vous assurer que vous êtes sûr que l'utilisation de 25% est d'un noyau, pas d'un processeur?


En regardant les graphiques de performance du chef de la tâche Windows, j'ai 4 zones, 3 d'entre eux surtout oisifs, une oscillation près du milieu de l'axe Y, alors je suppose que je suis à peu près sûr ...


Vous pourriez sauver Vala-Valb ... vous le calculez trois fois. diffsum + = vala-valb; diffsumsq + = (vala-valb) * (vala-valb); Mais cela devrait difficilement importer dans le plus grand schéma des choses ...


"J'ai été dévasté quand j'ai découvert qu'il est effectivement plus lent que l'original." C'est pourquoi la première règle d'optimisation est la suivante: mesure , la seconde est à nouveau / B>, et le troisième est Mesurez une fois de plus . Aviez-vous Profilé votre code d'origine pour voir où il a perdu exactement le temps, peut-être que vous avez déjà eu un programme plus rapide et plus propre . Ne faites pas cette erreur à nouveau. Obtenez un profileur aller et analyser où exactement ces boucles la plupart du temps sont dépensées. Seulement alors tenter d'améliorer ces taches. Et mesurer à nouveau immédiatement , car les optimisations sont des bêtes étranges et pourraient faire l'inattendu.


Je ne comprends pas - j'ai peut-être manqué quelque chose: pourquoi utilisez-vous des doubles pour stocker des sommes lorsque vos données d'entrée semblent être des entiers de taille d'octets (juste dans une matrice) ?? Débordement? Quelle est la taille des données d'entrée?


Le résultat final est une double valeur, j'ai pensé que la conservation des valeurs intermédiaires était une bonne idée. En outre, j'avais peur des débordements. Les données sont généralement comprises entre 31 et 63 matrices carrées de taille 8 bits.


Réponse au nouveau code modifié: Votre compilateur peut ne pas se rendre compte qu'il doit faire la conversion du char S aval et bval à < Code> double une seule fois. Cela pourrait le faire pour chaque ligne qui utilise ces variables, qui serait préjudiciable à la performance. (Essayez de déclarer aval et bval comme double .) Je ne sais pas que les règles relatives aux résultats temporaires de l'arithmétique sur Char S, mais Aval * Bval semble que cela pourrait trop déborder ... qui pourrait gâcher le code qui utilise cette routine, ce qui leur pousse beaucoup plus longtemps à converger ...


Ahem ... Pourriez-vous poster un code ( Non-OpenCl Version , avec goulot d'étranglement que vous souhaitez éliminer), je peux compiler et exécuter? Il suffit de déchirer des choses non pertinentes ...


J'ai essayé le code avec les doubles et les entiers et dans MSVC (optimisation complète, FP précis) La version entier 32 bits était d'environ 30% plus rapide. Toutefois, si des débordements sont problématiques, la version entière de 64 bits est encore plus lente que les doubles sur la plate-forme 32bit.


Est-ce que vous paralellez cela dans plusieurs threads? Je suis hésitant à dire qu'il peut y avoir des conditions de race car vous n'écrivez à aucune mémoire ici, mais c'est définitivement quelque chose à examiner. Le cache-cohérapancy est probablement un problème ici. Quel type de profileur utilisez-vous? Pouvons-nous voir la sortie de profilage?


Aucune parallélisation n'est encore présente, j'essaie de tout faire fonctionner dans un seul fil avant de regarder les possibilités. Je n'utilise pas de profileur depuis que vs2008 Pro manque un, et de ce que j'ai lu pour que beaucoup de gens soient sceptiques à leur sujet, donc je n'ai jamais eu pour essayer d'en utiliser un.


10 Réponses :


3
votes

Votre boucle interne appelle les fonctions! Peu importe la façon dont ils sont triviaux, vous payez une forte pénalité. Vous devriez essayer de linéariser les accès matricielles (en substance les faire 1D) afin que vous puissiez y accéder avec juste le pointeur der cerférencing xxx

et puisque vous êtes des ajouts simples et des soustractions SSE / SSE2. etc. En fonction de vos capacités de processeur cible et de votre arithmétique (entier, point flottant, etc.).

Edit: MMX SSE2 intrinsics sont des fonctions qui mappent une à une avec une avec des instructions de CPU SIMD. Voir Ces pages Microsoft pour commencer et en outre Je suggère de regarder le site Intel pour le Guide des programmeurs IA-32 / Intel64 ou manuels similaires de AMD.

Je recommande également Ce livre sur l'optimisation pour Architectures Intel. Cela expliquera toutes les capacités cachées de votre CPU et de votre compilateur.


5 commentaires

L'inlinisation n'a pas d'importance si elles font l'arithmétique du pointeur dans chaque appel (ou le Heaven s'interdisent la vérification). La linéarisation est l'important indice ici


Comme je l'ai mentionné, dans la nouvelle version, les données matricielles sont accessibles par un pointeur, c'est-à-dire que j'ai char * valapptr = (char *) (matrixada + matrixastep * y); dans la boucle extérieure et vala = valapptr [x]; à l'intérieur. Je ne reçois pas tout à fait le peu de sse, j'ai activé "Activer l'ensemble d'instructions améliorées" et définissez-la sur / arcade: SSE2 dans les options de solution, cela va-t-il faire?


@neuviemeporte: Cela permettra au moins de permettre au compilateur d'utiliser l'unité SSE au lieu de la FPU (voir ma réponse pour plus de détails), mais le compilateur ne sera généralement pas assez intelligent pour vectoriser automatiquement votre code. Si vous pouvez vous vectoriser votre code en utilisant SSE Intrinsics, il y a beaucoup de potentiel d'amélioration de la performance.


C'est ce que je disais sur le pointeur arithmétique. Débarrassez-vous des multiplications! Vous pouvez le faire en allouant la matrice (NXM) comme vecteur (n * m). Si vous devez l'utiliser comme matrice, vous pouvez le jeter comme tel. Mais dans cette boucle, vous l'utilisez comme vecteur. Cela supprimera une boucle. Ensuite, regardez SSE intrinsics pour faire plus dans chaque cycle


Comment puis-je utiliser avec SSE? J'ai fait des programmes d'assemblage, mais surtout sur DOS il y a de nombreuses années, également des microcontrôleurs de l'université, n'ont jamais eu d'expérience avec 32 bits Assembly ou MMX / SSE.



2
votes

Pouvez-vous vérifier le code de l'assembleur que cette boucle génère? Si vous n'obtenez que 25% de l'utilisation du processeur, il se peut que cette boucle soit liée à la mémoire. Il y a environ huit variables locales là-bas et j'imagine que le compilateur ne se mappe pas tous à des registres, de sorte qu'il existe de nombreuses opérations de mémoire dans chaque boucle. Une considération serait d'écrire cette boucle dans l'assembleur.

Pourquoi parcourez-vous la colonne matricielle par colonne? Les matrices seront stockées dans la ligne de mémoire après la rangée. Si vous accédez à une colonne entière dans la boucle interne, vous demandez probablement plus de charges de mémoire à vos différents niveaux de mémoire (caches et ainsi de suite).


5 commentaires

Il est difficile de savoir ce qu'il fait, car "x" tourne à "hauteur" et "y" tourne à "largeur", ce qui semble assez étrange.


AAAAAND ... Même si le code est lié à la mémoire, cela ne provoquera pas l'utilisation de la CPU à 100% car le cache manque de blocage du processus. Le seul scénario dans lequel cela pourrait se produire est s'il y avait deux cœurs "hyperthreading" virtuels partageant le même noyau physique et qu'il y avait un processus maximum de l'autre noyau virtuel. Il semble que l'OP indique que les autres cœurs sont inactifs, cela ne devrait donc pas se passer ici.


@Will: Aaaah ... bonne prise! Dans ce cas, il devrait définitivement vérifier qu'il accède à la mémoire séquentiellement ... une mauvaise localité peut provoquer une augmentation incroyable de performance.


Avec des matrices de boost, il est possible de préciser si vous souhaitez qu'ils soient stockés sous forme de matrices principales de lignes ou de colonnes. S'ils sont stockés sous forme de matrices principales de lignes, qui correspond à la valeur par défaut pour C, l'OP devrait définitivement changer l'ordre des boucles pour les boucles. La boucle dans l'ordre majeur de la colonne sur une matrice stockée dans l'ordre majeur de la ligne peut réduire beaucoup plus d'un facteur de dix.


J'ai eu une erreur dans le code que je n'ai pas remarqué car les matrices sont généralement carrées, en réalité la largeur et la hauteur doivent être inversées et je travaille en ligne à la ligne. Édité pour réfléchir.



1
votes

Si j'étais à vos chaussures, j'essaierais de savoir ce qui provoque exactement la différence de performance entre l'ancien et le nouveau code. Peut-être que les matrices de boost utilisent une sorte de mise en cache ou une évaluation paresseuse / désireuse.


1 commentaires

La base de code a beaucoup divergé entre les versions, les interfaces sont totalement différentes, il n'est pas facile de relier les routines entre les versions.



3
votes

Diverses pensées:

  • Vous dites que vous ne faites que réussir à atteindre une charge de la CPU d'environ 25%. Je peux penser à deux raisons pour cela:
    1. Vous échangez. Quelle est la taille de vos matrices? S'adaptent-ils entièrement en mémoire physique? Regardez la taille de l'utilisation de la mémoire de votre application et la taille du jeu de travail.
    2. Le reste du code de votre application bloque les E / S. Le code qui entoure votre routine de base fait-il des E / S? Il pourrait se bloquer là-bas pour de grandes étendues de temps, mais bien sûr, vous ne voyez pas que l'utilisation de la technique «Pause-quand-inspecter», car chaque fois que le processus débloque à nouveau, il retourne directement dans votre noyau à forte intensité de calcul. routine.
    3. Jetez un coup d'œil au code de montage pour votre routine de base. Est-ce que ça a l'air raisonnable?
    4. Avez-vous réellement besoin de calculer diffsum dans la boucle? Il semble que vous puissiez faire diffsum = suma-bar une fois en dehors de la boucle - mais il peut y avoir des considérations numériques qui vous empêchent de le faire.
    5. Comme Renick a déjà commenté, cela ressemble à une cible privilégiée pour l'optimisation SSE. Encore une fois, vous devez vous assurer que le compilateur génère un code d'assemblage raisonnable (si vous utilisez Intrinsics et n'écrivez pas l'assemblage vous-même).
    6. Si vous ne voulez pas écrire vous-même le code SSE, assurez-vous que le drapeau SSE de votre compilateur est défini. Cela permettra au compilateur d'utiliser l'unité SSE au lieu de la FPU pour les opérations de point flottant scalaire, qui améliorera lui-même les performances, car la FPU basée sur la pile sur le X86 est notoirement mal adaptée à la génération de code du compilateur.

2 commentaires

Les matrices sont généralement de la taille 31x31, mais cela peut changer. Généralement pas plus de 63x63 cependant. Ils sont générés (découpés ou interpolés) à partir de matrices plus grandes, qui sont en réalité des images de 1MPIX 8BPP.


De plus, le reste du code ne fait pas d'E / S, les images sont lues dans la mémoire au début et aucune entrée d'utilisateur n'est nécessaire pendant les calculs.



1
votes

Vous devez également essayer si vous ne pouvez pas multiplier la boucle via une simple configuration comme OpenMP. L'utilisation de 25% de la CPU ressemble à un quad core exécutant un fil de travail unique.


0 commentaires

0
votes

Stockez les matrices avec les mêmes paramètres, en dehors de la boucle? Je pense que je vous sauvegarde certains.


0 commentaires

0
votes

"25% de sa limite, et avec des oscillations relativement importantes, où j'attendrais une charge stable, à 100% jusqu'à ce que le programme se termine."

Vous avez mentionné que cette fonction est entourée de problèmes de conditions et de branches de contrôle, donc je suppose que cela entraîne une pipeline de CPU au lieu d'être utilisée efficacement. Essayez de réécrire votre logiciel afin qu'il ne nécessite pas beaucoup de branchement.

Je recommanderais également d'utiliser une des bibliothèques de mathématiques comme Eigen , < un href = "http://math-atlas.sourceforge.net/" rel = "nOfollow noreferrer"> Atlas ou GSL


1 commentaires

Un pipeline Flush ne provoquera pas le processus de blocage, vous devez donc toujours voir 100% d'utilisation du processeur - cela n'utilisera tout simplement pas ces cycles aussi efficacement que possible.



1
votes

Vous devriez essayer de vous débarrasser de vos boucles et d'essayer de vous vécue à la place des opérations. Utilisation d'une bibliothèque comme Eigen Votre code ressemblerait à ceci:

Eigen::MatrixXd matrixA(height, width);
Eigen::MatrixXd matrixB(height, width);
double sumA = matrixA.sum();
double sumAsq = matrixA.cwise().square().sum();
double sumB = matrixB.sum();
double sumBsq = matrixB.cwise().square().sum();

Eigen::MatrixXd diff = matrixA - matrixB;
double diffSum = diff.sum();
double diffSumSq = diff.cwise().square().sum();

return sumA + sumB / sumAsq + sumBsq * diffSum * diffSumSq;


0 commentaires

1
votes

Si vous utilisez la technique "Pause", il devrait vous dire plus que ce que vous êtes dans cette boucle. Il devrait vous dire où dans la boucle.

Ne devinez jamais quand vous pouvez simplement découvrir. Cela dit, voici mon devinement :-) Vous faites tout le sommation dans les variables de points flottants, mais obtenir les chiffres d'origine en tant que personnages entier, non? Ensuite, vous pouvez vous attendre à ce que la conversion de Int soit doubler pour prendre un certain temps, et si vous verrez que vos pauses se produisent dans ces instructions une bonne partie de l'époque. Donc, fondamentalement, je me demande pourquoi vous ne le faites pas tout entier arithmétique.

Vous dites que l'utilisation ne dépasse jamais 25%. Cela pourrait-il être parce que c'est seulement en utilisant l'un des 4 cœurs?

Vous dites que l'utilisation tombe souvent en dessous de 25%. Cela suggère peut-être que le thread bloque de faire des fichiers d'E / S. Si tel est le cas, vos pauses devraient l'attraper dans la loi et le confirmer. Si tel est le cas, vous pourrez peut-être accélérer les E / S en utilisant des blocs plus importants, ou éventuellement ouvrir / fermer moins souvent. Notez que les améliorations apportées à votre boucle interne diminueront la quantité de temps passée dans cette boucle, mais elle ne rétractera pas le temps passé dans les E / S, le pourcentage de temps en E / S augmentera (provoquer une diminution apparente de l'utilisation). , jusqu'à ce que vous rétrécissez aussi.

L'utilisation n'est en fait pas un nombre très utile. Ce n'est vraiment un indicateur de la CPU / IO Split et ne vous dit pas du tout si vous faites trop de l'un ou l'autre de ceux-ci.

Comme @Renick a dit, débarrassez-vous des calculs d'adresse. Vous devriez pouvoir passer à travers cette boucle au niveau de la langue de montage et le voir ne rien faire de plus que vous ne ferez que si vous mettez votre chapeau «Guru» et écrivit l'Assemblée vous-même.

Dans tous les cas, il pourrait être une grosse victoire.


1 commentaires

Changer le sommation des doubles à l'INT réduit le temps d'exécution moyen de cette fonction de 20 5us à 5 91us. Certains autres changements m'ont amené à 4,83us. Étonnamment, la modification du code en une seule boucle 1D a entraîné une pénalité plutôt qu'une amélioration. Prévoyez de publier une réponse détaillée dès que j'essaye tout ce que vous avez suggéré ici et certaines de mes idées.



0
votes

TLDR: d'abord, optimisez l'algorithme de multiplication matricielle, puis surveillez votre nombre de temporaires, puis optimisez vos allocations internes de matrice.

réponse longue:

Je pense que la chose la plus importante à adresser est la optimisation de la multiplication de vos matrices. La multiplication des matrices pour l'algorithme le plus intuitif est O (n ^ 3) (qui est énorme même pour les petites matrices).

Pour vous donner un exemple, pour une multiplication de matrice 2x2, vous avez 16 opérations de multiplication (" Mo "). Pour la multiplication de matrice 3x3, vous avez 27 mois et pour 4x4, vous avez 64 mois.

Je ne sais pas comment il est implémenté dans votre cas, mais s'il s'agit de l'algorithme intuitif (comme un triple pour boucle), changeant la multiplication de matrice en utilisant Matrices décomposées LU devrait augmenter votre performance radicalement.

C'est parce qu'une fois que vous avez le Matrices décomposées Vous pouvez optimiser l'algorithme de multiplication fortement (sans détection multiplier des lignes et des colonnes pour les éléments zéro).

En outre, envisagez d'utiliser des valeurs en cache au lieu de répéter les opérations pour ajouter à diffsumsq :

code ancien: xxx

nouveau code: xxx

La deuxième variante est trois fois plus vite sur le calcul de la différence (deux pour cycles - x * y Les opérations sont exécutées une seule fois au lieu de trois fois).

Vous pouvez surveiller davantage votre nombre d'objets temporaires: Chaque opération binaire crée une temporaire (ce qui signifie attribuer un autre x * y matrice en mémoire et à copier des valeurs). Par exemple, l'expression: xxx

crée temporaire pour la différence dans la première paranthèse, puis une autre pour la différence dans la seconde, puis une autre pour le produit.

dans mon exemple ci-dessus, vous préférez écrire xxx

au lieu de xxx

comme ça Vous évitez d'allouer le temporaire qui est attribué à diff (dans la deuxième variante).

Une autre chose à regarder serait votre consommation de mémoire: puisque cela se fait exécuter beaucoup, vous pouvez soit pré-allouer de la mémoire, soit piscine les matrices d'occasion et la mémoire de réutilisation au lieu de créer de nouveaux objets.

Je suis sûr qu'il y a d'autres choses à surveiller.

EDIT: Comment multiplier les matrices ? Vous devriez les faire correspondre aux lignes de colonnes x. C'est-à-dire que le nombre de colonnes de Vala devrait être égal au nombre de lignes de Valb (si je me souviens bien de mes multiplications matricielles correctement).

une dernière chose:

J'ai fait la routine de calcul A multiligne #define macro à appliquer son incroyance et pour éviter la fonction appelle et retourne.

Vous n'avez pas besoin de macros pour optimiser le code C ++. Pour éviter les appels de fonction et renvoie utilisez les fonctions inline D. Les macros viennent avec leurs propres problèmes.


2 commentaires

L'OP ne fait pas réellement de multiplication matricielle ... tout le * s sont des opérations scalaires.


C'est vrai, je n'ai pas besoin de multiplication de matrice, il suffit de multiplier pour obtenir des carrés de différences d'éléments matriciels. Je viens de mentionner à l'aide de la multiplication de matrice comme test de comparaison des performances de Boost contre OpenCV.