Est-ce une bonne idée d'utiliser le vecteur <vecteur <double >> pour former une classe de matrice pour du code de calcul scientifique hautes performances?

37

Est-ce une bonne idée vector<vector<double>>(en utilisant std) de former une classe de matrice pour du code de calcul scientifique haute performance?

Si la réponse est non. Pourquoi? Merci

cfdgeek
la source
2
-1 Bien sûr que c'est une mauvaise idée. Vous ne pourrez pas utiliser blas, lapack ou toute autre bibliothèque de matrices existante avec un tel format de stockage. De plus, vous introduisez des inefficacités par données non locales et indirectionnelles
Thomas Klimpel
9
@Thomas Est-ce que cela justifie vraiment un vote négatif?
Akid
33
Ne pas voter. C'est une question légitime même si c'est une idée erronée.
Wolfgang Bangerth
3
std :: vector n'est pas un vecteur distribué, vous ne pourrez donc pas faire beaucoup de calculs parallèles avec ce dernier (sauf pour les machines à mémoire partagée), utilisez plutôt Petsc ou Trilinos. De plus, on utilise généralement des matrices éparses et vous stockez des matrices entièrement denses. Pour jouer avec des matrices creuses, vous pouvez utiliser un std :: vector <std :: map>, mais là encore, cela ne fonctionnerait pas très bien, voir l'article de WolfgangBangerth ci-dessous.
Gnzlbg
3
essayez d'utiliser std :: vector <std :: vector <double >> avec MPI et vous voudrez vous prendre en photo
pyCthon

Réponses:

43

C'est une mauvaise idée, car vector doit allouer autant d'objets dans l'espace qu'il y a de lignes dans votre matrice. L'allocation coûte cher, mais c'est surtout une mauvaise idée car les données de votre matrice se trouvent maintenant dans un certain nombre de tableaux dispersés dans la mémoire, plutôt que dans un seul endroit où le cache du processeur peut facilement y accéder.

C'est aussi un format de stockage inutile: std :: vector stocke deux pointeurs, l'un au début du tableau et l'autre à la fin, car la longueur du tableau est flexible. D'autre part, pour que cette matrice soit correcte, les longueurs de toutes les lignes doivent être identiques et il suffirait donc de stocker le nombre de colonnes une seule fois, plutôt que de laisser chaque ligne stocker sa longueur indépendamment.

Wolfgang Bangerth
la source
C'est en fait pire que ce que vous dites, car std::vectorstocke en réalité trois pointeurs: le début, la fin et la fin de la région de stockage allouée (nous permettant d'appeler, par exemple .capacity()). Cette capacité peut être différente de la taille rend la situation bien pire!
user14717
18

Outre les raisons évoquées par Wolfgang, si vous utilisez a vector<vector<double> >, vous devrez le déréférencer deux fois à chaque fois que vous souhaitez récupérer un élément, ce qui est plus onéreux en calcul qu'une seule opération de déréférencement. Une approche typique consiste à allouer un seul tableau (a vector<double>ou a double *) à la place. J'ai également vu des personnes ajouter du sucre syntaxique aux classes de la matrice en encerclant ce tableau unique d'opérations d'indexation plus intuitives, afin de réduire le "surcoût mental" nécessaire pour appeler les indices appropriés.

Geoff Oxberry
la source
5

Est-ce vraiment une si mauvaise chose?

@Wolfgang: En fonction de la taille de la matrice dense, deux pointeurs supplémentaires par ligne peuvent être négligeables. En ce qui concerne les données dispersées, on pourrait penser à utiliser un allocateur personnalisé garantissant que les vecteurs sont en mémoire contiguë. Tant que la mémoire n'est pas recyclée, même l'allocateur standard utilisera une mémoire contiguë avec un écart de taille de deux pointeurs.

@ Geoff: Si vous effectuez un accès aléatoire et utilisez un seul tableau, vous devez toujours calculer l'index. Peut-être pas plus vite.

Alors laissez-nous faire un petit test:

vectormatrix.cc:

#include<vector>
#include<iostream>
#include<random>
#include <functional>
#include <sys/time.h>

int main()
{
  int N=1000;
  struct timeval start, end;

  std::cout<< "Checking differenz between last entry of previous row and first entry of this row"<<std::endl;
  std::vector<std::vector<double> > matrix(N, std::vector<double>(N, 0.0));
  for(std::size_t i=1; i<N;i++)
    std::cout<< "index "<<i<<": "<<&(matrix[i][0])-&(matrix[i-1][N-1])<<std::endl;
  std::cout<<&(matrix[0][N-1])<<" "<<&(matrix[1][0])<<std::endl;
  gettimeofday(&start, NULL);
  int k=0;

  for(int j=0; j<100; j++)
    for(std::size_t i=0; i<N;i++)
      for(std::size_t j=0; j<N;j++, k++)
        matrix[i][j]=matrix[i][j]*matrix[i][j];
  gettimeofday(&end, NULL);
  double seconds  = end.tv_sec  - start.tv_sec;
  double useconds = end.tv_usec - start.tv_usec;

  double mtime = ((seconds) * 1000 + useconds/1000.0) + 0.5;

  std::cout<<"calc took: "<<mtime<<" k="<<k<<std::endl;

  std::normal_distribution<double> normal_dist(0, 100);
  std::mt19937 engine; // Mersenne twister MT19937
  auto generator = std::bind(normal_dist, engine);
  for(std::size_t i=1; i<N;i++)
    for(std::size_t j=1; j<N;j++)
      matrix[i][j]=generator();
}

Et maintenant, en utilisant un tableau:

arraymatrix.cc

    #include<vector>
#include<iostream>
#include<random>
#include <functional>
#include <sys/time.h>

int main()
{
  int N=1000;
  struct timeval start, end;

  std::cout<< "Checking difference between last entry of previous row and first entry of this row"<<std::endl;
  double* matrix=new double[N*N];
  for(std::size_t i=1; i<N;i++)
    std::cout<< "index "<<i<<": "<<(matrix+(i*N))-(matrix+(i*N-1))<<std::endl;
  std::cout<<(matrix+N-1)<<" "<<(matrix+N)<<std::endl;

  int NN=N*N;
  int k=0;

  gettimeofday(&start, NULL);
  for(int j=0; j<100; j++)
    for(double* entry =matrix, *endEntry=entry+NN;
        entry!=endEntry;++entry, k++)
      *entry=(*entry)*(*entry);
  gettimeofday(&end, NULL);
  double seconds  = end.tv_sec  - start.tv_sec;
  double useconds = end.tv_usec - start.tv_usec;

  double mtime = ((seconds) * 1000 + useconds/1000.0) + 0.5;

  std::cout<<"calc took: "<<mtime<<" k="<<k<<std::endl;

  std::normal_distribution<double> normal_dist(0, 100);
  std::mt19937 engine; // Mersenne twister MT19937
  auto generator = std::bind(normal_dist, engine);
  for(std::size_t i=1; i<N*N;i++)
      matrix[i]=generator();
}

Sur mon système, il y a maintenant un gagnant clair (Compiler gcc 4.7 avec -O3)

empreintes matricielles:

index 997: 3
index 998: 3
index 999: 3
0xc7fc68 0xc7fc80
calc took: 185.507 k=100000000

real    0m0.257s
user    0m0.244s
sys     0m0.008s

Nous voyons également que tant que l’allocateur standard ne recycle pas la mémoire libérée, les données sont contiguës. (Bien sûr, après quelques désallocations, il n'y a aucune garantie pour cela.)

temps arraymatrix imprime:

index 997: 1
index 998: 1
index 999: 1
0x7ff41f208f48 0x7ff41f208f50
calc took: 187.349 k=100000000

real    0m0.257s
user    0m0.248s
sys     0m0.004s
Markus Blatt
la source
Vous écrivez "Sur mon système, il y a maintenant un gagnant clair" - ne voulez-vous pas dire un gagnant clair?
Akid
9
-1 Comprendre les performances du code hpc peut être non trivial. Dans votre cas, la taille de la matrice dépasse simplement la taille du cache, de sorte que vous ne faites que mesurer la bande passante mémoire de votre système. Si je change N en 200 et augmente le nombre d'itérations à 1000, j'obtiens "calcul pris: 65" par rapport à "calcul pris: 36". Si je remplace encore a = a * a par un + = a1 * a2 pour le rendre plus réaliste, je reçois "calcul pris: 176" contre "calculé: 84". Il semble donc que vous pouvez perdre un facteur deux en performances en utilisant un vecteur de vecteurs au lieu d'une matrice. La vraie vie sera plus compliquée, mais c'est toujours une mauvaise idée.
Thomas Klimpel le
oui, mais essayez d'utiliser std :: vecteur avec MPI, C gagne haut la main
pyCthon
4

Je ne le recommande pas, mais pas à cause de problèmes de performances. Ce sera un peu moins performant qu'une matrice traditionnelle, qui est généralement allouée sous la forme d'une grande quantité de données contiguës indexées à l'aide d'un seul déréférencement de pointeur et d'une arithmétique entière. La raison de la baisse des performances est principalement due aux différences de cache, mais une fois que la taille de votre matrice est suffisamment grande, cet effet est amorti. Si vous utilisez un allocateur spécial pour les vecteurs internes afin qu'ils soient alignés sur les limites du cache, cela atténue encore le problème de la mise en cache. .

Ce n'est pas en soi une raison suffisante pour ne pas le faire, à mon avis. La raison pour moi est que cela crée beaucoup de maux de tête de codage. Voici une liste des maux de tête que cela causera à long terme

Utilisation des librairies HPC

Si vous souhaitez utiliser la plupart des bibliothèques HPC, vous devrez effectuer une itération sur votre vecteur et placer toutes leurs données dans un tampon contigu, car la plupart des bibliothèques HPC attendent ce format explicite. BLAS et LAPACK me viennent à l’esprit, mais la bibliothèque MPC omniprésente MPI serait beaucoup plus difficile à utiliser.

Plus de potentiel d'erreur de codage

std::vectorne sait rien de ses entrées. Si vous remplissez std::vectorplus de std::vectors, alors votre travail consiste entièrement à vous assurer qu'ils ont tous la même taille, car rappelez-vous que nous voulons une matrice et que les matrices n'ont pas un nombre variable de lignes (ou de colonnes). Ainsi, vous devrez appeler tous les constructeurs appropriés pour chaque entrée de votre vecteur externe, et toute autre personne utilisant votre code doit résister à la tentation de l'utiliser std::vector<T>::push_back()sur l'un des vecteurs internes, ce qui provoquerait la rupture de tout le code suivant. Bien sûr, vous pouvez interdire cela si vous écrivez correctement votre classe, mais il est beaucoup plus facile de l'appliquer simplement avec une grande allocation contiguë.

Culture et attentes HPC

Les programmeurs HPC attendent simplement des données de bas niveau. Si vous leur donnez une matrice, on s’attend à ce que s’ils saisissent le pointeur sur le premier élément de la matrice et le pointeur sur le dernier élément de la matrice, tous les pointeurs entre ces deux sont valides et pointent sur des éléments de la même matrice. Ceci est similaire à mon premier point, mais différent parce que cela peut ne pas être tellement lié aux bibliothèques mais plutôt aux membres de l'équipe ou à toute personne avec laquelle vous partagez votre code.

Plus facile de raisonner sur les performances des données de niveau inférieur

Passer au niveau de représentation le plus bas de la structure de données souhaitée vous facilite la vie à long terme pour HPC. L'utilisation d'outils tels que perfet vtunevous donnera des mesures de compteur de performances de très bas niveau que vous essayerez de combiner avec les résultats de profilage traditionnels afin d'améliorer les performances de votre code. Si votre structure de données utilise beaucoup de conteneurs sophistiqués, il sera difficile de comprendre que les erreurs de cache proviennent d'un problème lié au conteneur ou d'une inefficacité de l'algorithme lui-même. Des conteneurs de code plus compliqués sont nécessaires, mais pour l'algèbre matricielle, ils ne le sont pas vraiment - vous pouvez vous en contenter 1 std::vectorde stocker les données plutôt que de n std::vectors, alors continuez.

Reid.Atcheson
la source
1

J'écris aussi un repère. Pour une matrice de petite taille (<100 * 100), les performances sont similaires pour le vecteur <vecteur <double >> et le vecteur enveloppé 1D. Pour une matrice de grande taille (~ 1000 * 1000), le vecteur 1D enveloppé est préférable. La matrice Eigen se comporte moins bien. Je suis surpris que l'Eigen soit le pire.

#include <iostream>
#include <iomanip>
#include <fstream>
#include <sstream>
#include <algorithm>
#include <map>
#include <vector>
#include <string>
#include <cmath>
#include <numeric>
#include "time.h"
#include <chrono>
#include <cstdlib>
#include <Eigen/Dense>

using namespace std;
using namespace std::chrono;    // namespace for recording running time
using namespace Eigen;

int main()
{
    const int row = 1000;
    const int col = row;
    const int N = 1e8;

    // 2D vector
    auto start = high_resolution_clock::now();
    vector<vector<double>> vec_2D(row,vector<double>(col,0.));
    for (int i = 0; i < N; i++)
    {
        for (int i=0; i<row; i++)
        {
            for (int j=0; j<col; j++)
            {
                vec_2D[i][j] *= vec_2D[i][j];
            }
        }
    }
    auto stop = high_resolution_clock::now();
    auto duration = duration_cast<microseconds>(stop - start);
    cout << "2D vector: " << duration.count()/1e6 << " s" << endl;

    // 2D array
    start = high_resolution_clock::now();
    double array_2D[row][col];
    for (int i = 0; i < N; i++)
    {
        for (int i=0; i<row; i++)
        {
            for (int j=0; j<col; j++)
            {
                array_2D[i][j] *= array_2D[i][j];
            }
        }
    }
    stop = high_resolution_clock::now();
    duration = duration_cast<microseconds>(stop - start);
    cout << "2D array: " << duration.count() / 1e6 << " s" << endl;

    // wrapped 1D vector
    start = high_resolution_clock::now();
    vector<double> vec_1D(row*col, 0.);
    for (int i = 0; i < N; i++)
    {
        for (int i=0; i<row; i++)
        {
            for (int j=0; j<col; j++)
            {
                vec_1D[i*col+j] *= vec_1D[i*col+j];
            }
        }
    }
    stop = high_resolution_clock::now();
    duration = duration_cast<microseconds>(stop - start);
    cout << "1D vector: " << duration.count() / 1e6 << " s" << endl;

    // eigen 2D matrix
    start = high_resolution_clock::now();
    MatrixXd mat(row, col);
    for (int i = 0; i < N; i++)
    {
        for (int j=0; j<col; j++)
        {
            for (int i=0; i<row; i++)
            {
                mat(i,j) *= mat(i,j);
            }
        }
    }
    stop = high_resolution_clock::now();
    duration = duration_cast<microseconds>(stop - start);
    cout << "2D eigen matrix: " << duration.count() / 1e6 << " s" << endl;
}
Michael
la source
0

Comme d'autres l'ont fait remarquer, n'essayez pas de faire des calculs avec cela ni de faire quelque chose de performant.

Cela dit, j'ai utilisé cette structure temporairement lorsque le code a besoin d'assembler un tableau à deux dimensions dont les dimensions seront déterminées lors de l'exécution et après le début du stockage des données. Par exemple, collecter des sorties de vecteurs à partir d'un processus coûteux dans lequel il n'est pas simple de calculer exactement le nombre de vecteurs à stocker au démarrage.

Vous pouvez simplement concaténer toutes vos entrées vectorielles dans un tampon au fur et à mesure de leur arrivée, mais le code sera plus durable et plus lisible si vous utilisez a vector<vector<T>>.

Channing Moore
la source