web-dev-qa-db-fra.com

Comment BLAS obtient-il des performances aussi extrêmes?

Par curiosité, j'ai décidé de comparer ma propre fonction de multiplication matricielle à l'implémentation BLAS ... Je suis pour le moins surpris du résultat:

Implémentation personnalisée, 10 essais de multiplication matricielle 1000x1000:

Took: 15.76542 seconds.

Implémentation BLAS, 10 essais de multiplication matricielle 1000x1000:

Took: 1.32432 seconds.

Il s'agit de nombres à virgule flottante simple précision.

Ma mise en œuvre:

template<class ValT>
void mmult(const ValT* A, int ADim1, int ADim2, const ValT* B, int BDim1, int BDim2, ValT* C)
{
    if ( ADim2!=BDim1 )
        throw std::runtime_error("Error sizes off");

    memset((void*)C,0,sizeof(ValT)*ADim1*BDim2);
    int cc2,cc1,cr1;
    for ( cc2=0 ; cc2<BDim2 ; ++cc2 )
        for ( cc1=0 ; cc1<ADim2 ; ++cc1 )
            for ( cr1=0 ; cr1<ADim1 ; ++cr1 )
                C[cc2*ADim2+cr1] += A[cc1*ADim1+cr1]*B[cc2*BDim1+cc1];
}

J'ai deux questions:

  1. Étant donné qu'une multiplication matrice-matrice dit: nxm * mxn nécessite n * n * m multiplications, donc dans le cas ci-dessus 1000 ^ 3 ou 1e9 opérations. Comment est-il possible sur mon processeur 2.6Ghz pour BLAS de faire 10 * 1e9 opérations en 1,32 seconde? Même si les multiplications étaient une seule opération et qu'il n'y avait rien d'autre à faire, cela devrait prendre environ 4 secondes.
  2. Pourquoi ma mise en œuvre est-elle tellement plus lente?
92
DeusAduro

Un bon point de départ est le grand livre The Science of Programming Matrix Computations de Robert A. van de Geijn et Enrique S. Quintana-Ortí. Ils fournissent une version de téléchargement gratuite.

BLAS est divisé en trois niveaux:

  • Le niveau 1 définit un ensemble de fonctions d'algèbre linéaire qui fonctionnent uniquement sur les vecteurs. Ces fonctions bénéficient de la vectorisation (par exemple en utilisant SSE).

  • Les fonctions de niveau 2 sont des opérations matricielles-vectorielles, par ex. un produit matrice-vecteur. Ces fonctions pourraient être implémentées en termes de fonctions de niveau 1. Cependant, vous pouvez augmenter les performances de ces fonctions si vous pouvez fournir une implémentation dédiée qui utilise une architecture multiprocesseur avec mémoire partagée.

  • Les fonctions de niveau 3 sont des opérations comme le produit matrice-matrice. Encore une fois, vous pouvez les implémenter en termes de fonctions de niveau 2. Mais les fonctions de niveau 3 effectuent des opérations O (N ^ 3) sur les données O (N ^ 2). Donc, si votre plate-forme a une hiérarchie de cache, vous pouvez améliorer les performances si vous fournissez une implémentation dédiée qui est optimisée pour le cache/compatible avec le cache . Ceci est bien décrit dans le livre. Le principal coup de pouce des fonctions Level3 vient de l'optimisation du cache. Ce boost dépasse considérablement le deuxième boost du parallélisme et d'autres optimisations matérielles.

Soit dit en passant, la plupart (voire la totalité) des implémentations BLAS hautes performances ne sont PAS implémentées dans Fortran. ATLAS est implémenté en C. GotoBLAS/OpenBLAS est implémenté en C et ses parties critiques en termes de performances dans Assembler. Seule l'implémentation de référence de BLAS est implémentée dans Fortran. Cependant, toutes ces implémentations BLAS fournissent une interface Fortran telle qu'elle peut être liée à LAPACK (LAPACK tire toutes ses performances de BLAS).

Les compilateurs optimisés jouent un rôle mineur à cet égard (et pour GotoBLAS/OpenBLAS, le compilateur n'a pas d'importance du tout).

IMHO aucune implémentation BLAS utilise des algorithmes comme l'algorithme Coppersmith – Winograd ou l'algorithme Strassen. Je ne suis pas exactement sûr de la raison, mais voici ma supposition:

  • Peut-être qu'il n'est pas possible de fournir une implémentation optimisée du cache de ces algorithmes (c'est-à-dire que vous perdriez plus que vous ne gagneriez)
  • Ces algorithmes ne sont pas stables numériquement. Comme BLAS est le noyau de calcul de LAPACK, c'est un no-go.

Modifier/mettre à jour:

Le nouveau papier révolutionnaire pour ce sujet est le papiers BLIS . Ils sont exceptionnellement bien écrits. Pour ma conférence "Notions de base logicielles pour le calcul haute performance", j'ai implémenté le produit matrice-matrice suivant leur article. En fait, j'ai implémenté plusieurs variantes du produit matrice-matrice. Les variantes les plus simples sont entièrement écrites en C simple et contiennent moins de 450 lignes de code. Toutes les autres variantes optimisent simplement les boucles

    for (l=0; l<MR*NR; ++l) {
        AB[l] = 0;
    }
    for (l=0; l<kc; ++l) {
        for (j=0; j<NR; ++j) {
            for (i=0; i<MR; ++i) {
                AB[i+j*MR] += A[i]*B[j];
            }
        }
        A += MR;
        B += NR;
    }

Les performances globales du produit matrice-matrice uniquement dépendent de ces boucles. Environ 99,9% du temps est passé ici. Dans les autres variantes, j'ai utilisé le code intrinsèque et l'assembleur pour améliorer les performances. Vous pouvez voir le tutoriel en passant par toutes les variantes ici:

lmBLAS: Tutoriel sur GEMM (Matrix-Matrix Product)

Avec les papiers BLIS, il devient assez facile de comprendre comment des bibliothèques comme Intel MKL peuvent obtenir de telles performances. Et pourquoi cela n'a pas d'importance si vous utilisez le stockage principal de ligne ou de colonne!

Les repères finaux sont ici (nous avons appelé notre projet ulmBLAS):

Repères pour ulmBLAS, BLIS, MKL, openBLAS et Eigen

Une autre édition/mise à jour:

J'ai également écrit un tutoriel sur la façon dont BLAS est utilisé pour les problèmes d'algèbre linéaire numérique comme la résolution d'un système d'équations linéaires:

Haute performance LU Factorisation

(Cette LU factorisation est par exemple utilisée par Matlab pour résoudre un système d'équations linéaires.)

J'espère trouver le temps pour étendre le didacticiel pour décrire et démontrer comment réaliser une implémentation parallèle hautement évolutive de la factorisation LU comme dans PLASMA .

Ok, c'est parti: Codage d'un cache optimisé en parallèle LU factorisation

P.S .: J'ai également fait quelques expériences sur l'amélioration des performances de uBLAS. En fait, il est assez simple d'améliorer (ouais, jouer sur les mots :)) les performances de uBLAS:

Expériences sur uBLAS .

Voici un projet similaire avec BLAZE :

Expériences sur BLAZE .

124
Michael Lehn

Donc tout d'abord BLAS n'est qu'une interface d'environ 50 fonctions. Il existe de nombreuses implémentations concurrentes de l'interface.

Tout d'abord, je mentionnerai des choses qui sont largement indépendantes:

  • Fortran vs C, ne fait aucune différence
  • Les algorithmes matriciels avancés tels que Strassen, les implémentations ne les utilisent pas car ils n'aident pas en pratique

La plupart des implémentations décomposent chaque opération en opérations matricielles ou vectorielles de petite dimension de manière plus ou moins évidente. Par exemple, une grande multiplication matricielle de 1 000 x 1 000 peut être divisée en une séquence de multiplications matricielles de 50 x 50.

Ces opérations de petite dimension de taille fixe (appelées noyaux) sont codées en dur dans du code d'assemblage spécifique au processeur en utilisant plusieurs fonctionnalités du processeur de leur cible:

  • Instructions de style SIMD
  • Parallélisme au niveau de l'instruction
  • Sensibilisation au cache

De plus, ces noyaux peuvent être exécutés en parallèle les uns par rapport aux autres à l'aide de plusieurs threads (cœurs de processeur), dans le modèle de conception de réduction de carte typique.

Jetez un œil à ATLAS qui est l'implémentation BLAS open source la plus utilisée. Il a de nombreux noyaux concurrents différents, et pendant le processus de construction de la bibliothèque ATLAS, il exécute une compétition entre eux (certains sont même paramétrés, donc le même noyau peut avoir des paramètres différents). Il essaie différentes configurations, puis sélectionne le meilleur pour le système cible particulier.

(Astuce: c'est pourquoi si vous utilisez ATLAS, vous feriez mieux de construire et de régler la bibliothèque à la main pour votre machine particulière, puis d'utiliser une bibliothèque pré-construite.)

23
Andrew Tomazos

Premièrement, il existe des algorithmes de multiplication matricielle plus efficaces que celui que vous utilisez.

Deuxièmement, votre processeur peut exécuter plus d'une instruction à la fois.

Votre CPU exécute 3-4 instructions par cycle, et si les unités SIMD sont utilisées, chaque instruction traite 4 flottants ou 2 doubles. (bien sûr, ce chiffre n'est pas exact non plus, car le processeur ne peut généralement traiter qu'une seule instruction SIMD par cycle)

Troisièmement, votre code est loin d'être optimal:

  • Vous utilisez des pointeurs bruts, ce qui signifie que le compilateur doit supposer qu'ils peuvent alias. Il existe des mots clés ou des indicateurs spécifiques au compilateur que vous pouvez spécifier pour indiquer au compilateur qu'ils n'ont pas d'alias. Alternativement, vous devez utiliser d'autres types que des pointeurs bruts, qui s'occupent du problème.
  • Vous détruisez le cache en effectuant une traversée naïve de chaque ligne/colonne des matrices d'entrée. Vous pouvez utiliser le blocage pour effectuer autant de travail que possible sur un bloc plus petit de la matrice, qui tient dans le cache du processeur, avant de passer au bloc suivant.
  • Pour les tâches purement numériques, Fortran est à peu près imbattable, et C++ prend beaucoup de cajoleries pour atteindre une vitesse similaire. Cela peut être fait, et il y a quelques bibliothèques qui le démontrent (généralement en utilisant des modèles d'expression), mais ce n'est pas trivial, et cela ne juste se produit pas.
14
jalf

Je ne connais pas spécifiquement l'implémentation de BLAS mais il existe des alogorithmes plus efficaces pour la multiplication matricielle qui ont une complexité meilleure que O(n3). Un exemple bien connu est Strassen Algorithm =

11
softveda

La plupart des arguments de la deuxième question - assembleur, division en blocs, etc. (mais pas moins que N ^ 3 algorithmes, ils sont vraiment surdéveloppés) - jouent un rôle. Mais la faible vitesse de votre algorithme est essentiellement due à la taille de la matrice et à l'agencement malheureux des trois boucles imbriquées. Vos matrices sont si grandes qu'elles ne tiennent pas immédiatement dans la mémoire cache. Vous pouvez réorganiser les boucles de manière à ce que le plus possible se fasse sur une ligne dans le cache, de cette manière, réduisant considérablement les rafraîchissements du cache (la division BTW en petits blocs a un effet analogique, mieux si les boucles sur les blocs sont disposées de la même manière). Une implémentation de modèle pour les matrices carrées suit. Sur mon ordinateur, sa consommation de temps était d'environ 1:10 par rapport à l'implémentation standard (comme la vôtre). En d'autres termes: ne programmez jamais une multiplication matricielle le long du schéma de la "colonne des temps de ligne" que nous avons appris à l'école. Après avoir réorganisé les boucles, d'autres améliorations sont obtenues en déroulant les boucles, le code assembleur, etc.

    void vector(int m, double ** a, double ** b, double ** c) {
      int i, j, k;
      for (i=0; i<m; i++) {
        double * ci = c[i];
        for (k=0; k<m; k++) ci[k] = 0.;
        for (j=0; j<m; j++) {
          double aij = a[i][j];
          double * bj = b[j];
          for (k=0; k<m; k++)  ci[k] += aij*bj[k];
        }
      }
    }

Encore une remarque: cette implémentation est encore meilleure sur mon ordinateur que de tout remplacer par la routine BLAS cblas_dgemm (essayez-la sur votre ordinateur!). Mais beaucoup plus rapidement (1: 4) appelle directement dgemm_ de la bibliothèque Fortran. Je pense que cette routine n'est en fait pas Fortran mais du code assembleur (je ne sais pas ce qu'il y a dans la bibliothèque, je n'ai pas les sources). Je ne comprends pas du tout pourquoi cblas_dgemm n'est pas aussi rapide, car à ma connaissance, il s'agit simplement d'un wrapper pour dgemm_.

4
Wolfgang Jansen

Il s'agit d'une accélération réaliste. Pour un exemple de ce qui peut être fait avec l'assembleur SIMD sur du code C++, voir un exemple fonctions de matrice iPhone - celles-ci étaient 8 fois plus rapides que la version C, et ne sont même pas "optimisées" pour l'assemblage - il y a pas encore de revêtement de tuyau et il y a des opérations de pile inutiles.

De plus, votre code n'est pas " restriction correcte " - comment le compilateur sait-il que lorsqu'il modifie C, il ne modifie pas A et B?

3
Justicle

En ce qui concerne le code d'origine en MM multiplier, la référence de mémoire pour la plupart des opérations est la principale cause de mauvaises performances. La mémoire fonctionne 100 à 1000 fois plus lentement que le cache.

La plupart de l'accélération provient de l'utilisation de techniques d'optimisation de boucle pour cette fonction de triple boucle en multiplication MM. Deux techniques principales d'optimisation de boucle sont utilisées; déroulement et blocage. En ce qui concerne le déroulement, nous déroulons les deux boucles les plus externes et les bloquons pour la réutilisation des données dans le cache. Le déroulement de la boucle externe permet d'optimiser temporairement l'accès aux données en réduisant le nombre de références de mémoire aux mêmes données à différents moments pendant toute l'opération. Le blocage de l'index de boucle à un numéro spécifique permet de conserver les données dans le cache. Vous pouvez choisir d'optimiser le cache L2 ou le cache L3.

https://en.wikipedia.org/wiki/Loop_nest_optimization

2
Pari Rajaram