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
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
Réponses:
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.
std::vector
stocke 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!
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.
Non, utilisez l’une des bibliothèques d’algèbre linéaire disponibles gratuitement. Vous trouverez une discussion sur les différentes bibliothèques ici: Recommandations pour une bibliothèque matricielle rapide et utilisable?
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
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
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.
std::vector
ne sait rien de ses entrées. Si vous remplissez std::vector
plus de std::vector
s, 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ë.
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.
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 perf
et vtune
vous 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::vector
de stocker les données plutôt que de n
std::vector
s, alors continuez.
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;
}
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>>
.