web-dev-qa-db-fra.com

Eigen: effet du style de codage sur les performances

D'après ce que j'ai lu sur Eigen ( ici ), il semble que operator=() joue le rôle de "barrière" pour l'évaluation paresseuse - par exemple. Eigen cesse de renvoyer les modèles d'expression et effectue le calcul (optimisé) en stockant le résultat dans la partie gauche du =.

Cela semblerait vouloir dire que son "style de codage" a un impact sur les performances - c'est-à-dire que l'utilisation de variables nommées pour stocker le résultat de calculs intermédiaires pourrait avoir un effet négatif sur les performances en faisant évaluer certaines parties du calcul "trop ​​tôt". .

Pour essayer de vérifier mon intuition, j'ai écrit un exemple et j'ai été surpris des résultats ( code complet ici ):

using ArrayXf  = Eigen::Array <float, Eigen::Dynamic, Eigen::Dynamic>;
using ArrayXcf = Eigen::Array <std::complex<float>, Eigen::Dynamic, Eigen::Dynamic>;

float test1( const MatrixXcf & mat )
{
    ArrayXcf arr  = mat.array();
    ArrayXcf conj = arr.conjugate();
    ArrayXcf magc = arr * conj;
    ArrayXf  mag  = magc.real();
    return mag.sum();
}

float test2( const MatrixXcf & mat )
{
    return ( mat.array() * mat.array().conjugate() ).real().sum();
}

float test3( const MatrixXcf & mat )
{
    ArrayXcf magc   = ( mat.array() * mat.array().conjugate() );

    ArrayXf mag     = magc.real();
    return mag.sum();
}

Ce qui précède donne 3 façons différentes de calculer la somme des grandeurs selon les coefficients dans une matrice à valeurs complexes.

  1. test1 prend en quelque sorte chaque partie du calcul "une étape à la fois".
  2. test2 fait le calcul complet en une seule expression.
  3. test3 adopte une approche "hybride" - avec un certain nombre de variables intermédiaires.

Je m'attendais en quelque sorte à ce que, puisque test2 regroupe l'intégralité du calcul dans une seule expression, Eigen serait en mesure de tirer parti de cela et d'optimiser globalement l'intégralité du calcul en fournissant les meilleures performances.

Cependant, les résultats ont été surprenants (les chiffres indiqués sont en microsecondes totales sur 1 000 exécutions de chaque test):

test1_us: 154994
test2_us: 365231
test3_us: 36613

(Ceci a été compilé avec g ++ -O3 - voir le résumé pour plus de détails.)

La version que je pensais être la plus rapide (test2) était en réalité la plus lente. De plus, la version que je pensais être la plus lente (test1) était en fait au milieu.

Donc, mes questions sont:

  1. Pourquoi test3 fonctionne-t-il tellement mieux que les alternatives?
  2. Existe-t-il une technique (à part plonger dans le code d'assemblage) pour avoir une idée de la façon dont Eigen implémente réellement vos calculs?
  3. Existe-t-il un ensemble de directives à suivre pour trouver un bon compromis entre performance et lisibilité (utilisation de variables intermédiaires) dans votre code Eigen?

Dans des calculs plus complexes, tout faire en une seule expression peut nuire à la lisibilité. Je suis donc intéressé à trouver le bon moyen d'écrire du code à la fois lisible et performant.

36
jeremytrimble

Cela ressemble à un problème de GCC. Le compilateur Intel donne le résultat attendu.

$ g++ -I ~/program/include/eigen3 -std=c++11 -O3 a.cpp -o a && ./a
test1_us: 200087
test2_us: 320033
test3_us: 44539

$ icpc -I ~/program/include/eigen3 -std=c++11 -O3 a.cpp -o a && ./a
test1_us: 214537
test2_us: 23022
test3_us: 42099

Comparé à la version icpc, gcc semble avoir du mal à optimiser votre test2.

Pour un résultat plus précis, vous pouvez désactiver les assertions de débogage par -DNDEBUG comme indiqué ici .

MODIFIER

Pour la question 1

@ggael donne une excellente réponse que gcc ne parvient pas à vectoriser la boucle de somme. Mon expérience a également révélé que test2 est aussi rapide que la boucle naïve écrite à la main, à la fois avec gcc et icc, ce qui suggère que la vectorisation en est la raison et qu'aucune allocation de mémoire temporaire n'est détectée dans test2 par la méthode décrite ci-dessous, ce qui suggère que Eigen évaluer l'expression correctement.

Pour la question 2

Éviter la mémoire intermédiaire est l’objectif principal pour lequel Eigen utilise des modèles d’expression. Eigen fournit donc une macro EIGEN_RUNTIME_NO_MALLOC et une fonction simple pour vous permettre de vérifier si une mémoire intermédiaire est allouée lors du calcul de l'expression. Vous pouvez trouver un exemple de code ici . Veuillez noter que cela ne peut fonctionner qu'en mode débogage.

EIGEN_RUNTIME_NO_MALLOC - si défini, un nouveau commutateur est introduit qui peut être activé ou désactivé en appelant set_is_malloc_allowed (bool). Si Malloc n'est pas autorisé et Eigen tente d'allouer de la mémoire de manière dynamique de toute façon, un échec d’assertion en résulte. Non défini par défaut.

Pour la question 3

Il existe un moyen d'utiliser des variables intermédiaires et d'obtenir en même temps l'amélioration des performances introduite par les modèles d'évaluation/d'expression paresseux. 

Le moyen consiste à utiliser des variables intermédiaires avec le type de données correct. Au lieu d'utiliser Eigen::Matrix/Array, qui charge l'expression à évaluer, vous devez utiliser le type d'expression Eigen::MatrixBase/ArrayBase/DenseBase pour que l'expression soit uniquement mise en mémoire tampon, mais pas évaluée. Cela signifie que vous devez stocker l'expression en tant qu'intermédiaire, plutôt que le résultat de l'expression, à condition que cet intermédiaire ne soit utilisé qu'une seule fois dans le code suivant. 

La détermination des paramètres de modèle dans le type d'expression Eigen::MatrixBase/... pouvant s'avérer pénible, vous pouvez utiliser auto à la place. Vous pourriez trouver des indications sur le moment où vous devriez/ne pas utiliser les types auto/expression dans cette page . Une autre page vous indique également comment passer les expressions sous forme de paramètres de fonction sans les évaluer. 

Selon l'expérience instructive sur .abs2() dans la réponse de @ggael, je pense qu'une autre directive est d'éviter de réinventer la roue.

14
kangshiyin

En effet, en raison de l'étape .real(), Eigen ne vectorise pas explicitement test2. Il appellera donc l'opérateur standard complex :: operator *, qui, malheureusement, n'est jamais aligné avec gcc. Les autres versions, en revanche, utilisent la propre mise en œuvre de complexes vectorisée de produits d'Eigen.

En revanche, ICC utilise inline complex :: operator *, faisant ainsi du test2 le plus rapide pour ICC. Vous pouvez également réécrire test2 en tant que:

return mat.array().abs2().sum();

pour obtenir des performances encore meilleures sur tous les compilateurs:

gcc:
test1_us: 66016
test2_us: 26654
test3_us: 34814

icpc:
test1_us: 87225
test2_us: 8274
test3_us: 44598

clang:
test1_us: 87543
test2_us: 26891
test3_us: 44617

Le très bon score de l'ICC dans ce cas est dû à son moteur intelligent de vectorisation automatique.

Un autre moyen de contourner l'échec en ligne de gcc sans modifier test2 consiste à définir votre propre operator* pour complex<float>. Par exemple, ajoutez ce qui suit en haut de votre fichier:

namespace std {
  complex<float> operator*(const complex<float> &a, const complex<float> &b) {
    return complex<float>(real(a)*real(b) - imag(a)*imag(b), imag(a)*real(b) + real(a)*imag(b));
  }
}

et puis je reçois:

gcc:
test1_us: 69352
test2_us: 28171
test3_us: 36501

icpc:
test1_us: 93810
test2_us: 11350
test3_us: 51007

clang:
test1_us: 83138
test2_us: 26206
test3_us: 45224

Bien sûr, cette astuce n’est pas toujours recommandée car, contrairement à la version glib, elle peut entraîner des problèmes de débordement ou d’annulation numérique, mais c’est ce que l’icpc et les autres versions vectorisées calculent quand même.

14
ggael

Une chose que j’ai faite auparavant est d’utiliser beaucoup le mot clé auto. En gardant à l'esprit que la plupart des expressions Eigen renvoient des types de données d'expression spéciaux (par exemple, CwiseBinaryOp), une affectation dans Matrix peut forcer l'évaluation de l'expression (ce que vous voyez actuellement). Utiliser auto permet au compilateur de déduire le type de retour sous la forme d’un type d’expression, ce qui évitera une évaluation aussi longue que possible:

float test1( const MatrixXcf & mat )
{
    auto arr  = mat.array();
    auto conj = arr.conjugate();
    auto magc = arr * conj;
    auto mag  = magc.real();
    return mag.sum();
}

Cela devrait essentiellement être plus proche de votre deuxième cas de test. Dans certains cas, les performances ont été améliorées tout en maintenant la lisibilité (vous voulez que pas épelez les types de gabarit d'expression). Bien sûr, votre kilométrage peut varier, alors comparez attentivement :)

5
mindriot

Je veux juste que vous remarquiez que vous avez créé le profilage de manière non optimale. Le problème pourrait donc bien être votre méthode de profilage.

Comme il y a beaucoup de choses à garder en compte, comme la localité du cache, vous devriez faire le profilage de cette façon:

int warmUpCycles = 100;
int profileCycles = 1000;

// TEST 1
for(int i=0; i<warmUpCycles ; i++)
      doTest1();

auto tick = std::chrono::steady_clock::now();
for(int i=0; i<profileCycles ; i++)
      doTest1();  
auto tock = std::chrono::steady_clock::now();
test1_us = (std::chrono::duration_cast<std::chrono::microseconds>(tock-tick)).count(); 

// TEST 2


// TEST 3

Une fois que vous avez fait le test de la manière appropriée, vous pouvez alors tirer des conclusions.

Je soupçonne fortement que, étant donné que vous profilez une opération à la fois, vous finissez par utiliser la version mise en cache du troisième test, car les opérations risquent d'être réordonnées par le compilateur.

Vous devriez également essayer différents compilateurs pour voir si le problème est le déroulement des modèles (l'optimisation des modèles a une limite de profondeur: il est probable que vous puissiez y appuyer avec une seule grande expression).

De plus, si Eigen prend en charge le déplacement de la sémantique, il n’ya aucune raison pour qu’une version soit plus rapide car il n’est pas toujours garanti que les expressions puissent être optimisées.

S'il vous plaît essayez de me le faire savoir, c'est intéressant. Assurez-vous également d'avoir activé les optimisations avec des indicateurs tels que -O3, le profilage sans optimisation n'a pas de sens.

Afin d’empêcher le compilateur d’optimiser tout le contenu, utilisez l’entrée initiale d’un fichier ou cin, puis ré-alimentez l’entrée au sein des fonctions.

0
GameDeveloper