web-dev-qa-db-fra.com

Pourquoi les compilateurs C ++ ne font-ils pas mieux le pliage constant?

J'étudie des moyens d'accélérer une grande partie du code C++, qui comporte des dérivés automatiques pour le calcul des jacobiens. Ceci implique de faire un peu de travail dans les résidus réels, mais la majorité du travail (basé sur le temps d'exécution profilé) est dans le calcul des jacobiens.

Cela m'a surpris, car la plupart des jacobiens sont propagés en avant à partir de 0 et de 1, de sorte que la quantité de travail devrait être de 2 à 4 fois la fonction, et non de 10 à 12. Afin de modéliser à quoi ressemble une grande partie du travail jacobien, j'ai créé un exemple super minimal avec juste un produit scalaire (au lieu de sin, cos, sqrt et plus qui se trouveraient dans une situation réelle) que le compilateur devrait pouvoir pour optimiser une valeur de retour unique:

#include <Eigen/Core>
#include <Eigen/Geometry>

using Array12d = Eigen::Matrix<double,12,1>;

double testReturnFirstDot(const Array12d& b)
{
    Array12d a;
    a.array() = 0.;
    a(0) = 1.;
    return a.dot(b);
}

Quel devrait être le même que

double testReturnFirst(const Array12d& b)
{
    return b(0);
}

J'ai été déçu de constater que, si le calcul rapide ne soit pas activé, ni GCC 8.2, Clang 6 ni MSVC 19 n'ont été en mesure d'optimiser le produit-produit naïf avec une matrice pleine de 0. Même avec fast-math ( https://godbolt.org/z/GvPXFy ), les optimisations sont très médiocres dans GCC et Clang (impliquent toujours des multiplications et des ajouts), et MSVC ne fait aucune optimisation. du tout.

Je n'ai pas de formation en compilation, mais y a-t-il une raison à cela? Je suis à peu près sûr que dans une grande partie des calculs scientifiques, être capable de faire une meilleure propagation/repliement constant ferait apparaître plus d'optimisations, même si le pli constant lui-même n'entraînait pas d'accélération.

Bien que je sois intéressé par les explications sur la raison pour laquelle cela n’a pas été fait du côté du compilateur, je suis également intéressé par ce que je peux faire concrètement pour rendre mon propre code plus rapide lorsque je suis confronté à ce type de modèle.

59
jkflying

En effet, Eigen vectorise explicitement votre code en tant que 3 vmulpd, 2 vaddpd et 1 réduction horizontale dans les 4 registres de composants restants (ceci suppose AVX, avec SSE seulement vous obtiendrez 6 mulpd et 5 addpd ). Avec -ffast-math _ GCC et clang sont autorisés à supprimer les 2 derniers vmulpd et vaddpd (et c’est ce qu’ils font), mais ils ne peuvent pas vraiment remplacer les vmulpd et la réduction horizontale restants qui ont été explicitement générés par Eigen.

Et si on désactivait la vectorisation explicite d’Eigen en définissant EIGEN_DONT_VECTORIZE? Vous obtenez alors ce que vous attendiez ( https://godbolt.org/z/UQsoeH ), mais d'autres morceaux de code risquent de devenir beaucoup plus lents.

Si vous souhaitez désactiver localement la vectorisation explicite et que vous n'avez pas peur de manipuler l'interne d'Eigen, vous pouvez introduire une option DontVectorize dans Matrix et désactiver la vectorisation en spécialisant traits<> pour ce Matrix tapez:

static const int DontVectorize = 0x80000000;

namespace Eigen {
namespace internal {

template<typename _Scalar, int _Rows, int _Cols, int _MaxRows, int _MaxCols>
struct traits<Matrix<_Scalar, _Rows, _Cols, DontVectorize, _MaxRows, _MaxCols> >
: traits<Matrix<_Scalar, _Rows, _Cols> >
{
  typedef traits<Matrix<_Scalar, _Rows, _Cols> > Base;
  enum {
    EvaluatorFlags = Base::EvaluatorFlags & ~PacketAccessBit
  };
};

}
}

using ArrayS12d = Eigen::Matrix<double,12,1,DontVectorize>;

Exemple complet à cet endroit: https://godbolt.org/z/bOEyzv

72
ggael

J'ai été déçu de constater que, si le calcul rapide ne soit pas activé, ni GCC 8.2, Clang 6 ni MSVC 19 n'ont été en mesure d'optimiser le produit-produit naïf avec une matrice pleine de 0.

Ils n'ont malheureusement pas d'autre choix. Puisque les floats IEEE ont signé des zéros, ajouter 0.0 n'est pas une opération d'identité:

-0.0 + 0.0 = 0.0 // Not -0.0!

De même, multiplier par zéro ne donne pas toujours zéro:

0.0 * Infinity = NaN // Not 0.0!

Ainsi, les compilateurs ne peuvent tout simplement pas effectuer ces plis constants dans le produit scalaire tout en conservant la conformité flottante IEEE. Pour autant qu'ils sachent, votre entrée peut contenir des zéros et/ou des infinis signés.

Vous devrez utiliser -ffast-math obtenir ces plis, mais cela peut avoir des conséquences indésirables. Vous pouvez obtenir un contrôle plus détaillé avec des indicateurs spécifiques (à partir de http://gcc.gnu.org/wiki/FloatingPointMath ). Selon l'explication ci-dessus, l'ajout des deux indicateurs suivants devrait permettre le repliement constant:
-ffinite-math-only, -fno-signed-zeros

En effet, vous obtenez la même assemblée qu'avec -ffast-math _ de cette manière: https://godbolt.org/z/vGULLA . Vous n'abandonnez que les zéros signés (probablement sans importance), NaNs et les infinis. Vraisemblablement, si vous deviez toujours les produire dans votre code, vous auriez un comportement indéfini, alors pesez vos options.


Pourquoi votre exemple n’est pas mieux optimisé même avec -ffast-math: C'est sur Eigen. Vraisemblablement, ils ont une vectorisation sur leurs opérations matricielles, qui sont beaucoup plus difficiles à comprendre pour les compilateurs. Une boucle simple est correctement optimisée avec ces options: https://godbolt.org/z/OppEhY

38
Max Langhof

Une façon de forcer un compilateur à optimiser les multiplications par 0 et par 1 consiste à dérouler manuellement la boucle. Pour plus de simplicité, utilisons

#include <array>
#include <cstddef>
constexpr std::size_t n = 12;
using Array = std::array<double, n>;

Ensuite, nous pouvons implémenter une simple fonction dot en utilisant des expressions de pli (ou une récursion si elles ne sont pas disponibles):

<utility>
template<std::size_t... is>
double dot(const Array& x, const Array& y, std::index_sequence<is...>)
{
    return ((x[is] * y[is]) + ...);
}

double dot(const Array& x, const Array& y)
{
    return dot(x, y, std::make_index_sequence<n>{});
}

Regardons maintenant votre fonction

double test(const Array& b)
{
    const Array a{1};    // = {1, 0, ...}
    return dot(a, b);
}

Avec -ffast-math Gcc 8.2 produit :

test(std::array<double, 12ul> const&):
  movsd xmm0, QWORD PTR [rdi]
  ret

clang 6.0.0 va dans le même sens:

test(std::array<double, 12ul> const&): # @test(std::array<double, 12ul> const&)
  movsd xmm0, qword ptr [rdi] # xmm0 = mem[0],zero
  ret

Par exemple, pour

double test(const Array& b)
{
    const Array a{1, 1};    // = {1, 1, 0...}
    return dot(a, b);
}

on a

test(std::array<double, 12ul> const&):
  movsd xmm0, QWORD PTR [rdi]
  addsd xmm0, QWORD PTR [rdi+8]
  ret

Addition. Clang déroule une boucle for (std::size_t i = 0; i < n; ++i) ... sans toutes ces astuces sur les expressions de plis, gcc ne l’a pas et a besoin d’aide.

12
Evg