J'ai besoin de représenter un champ 2D (axes x, y) et je suis confronté à un problème: dois-je utiliser un tableau 1D ou un tableau 2D?
J'imagine que recalculer les indices pour les tableaux 1D (y + x * n) pourrait être plus lent que d'utiliser un tableau 2D (x, y), mais je pourrais imaginer que 1D pourrait se trouver dans le cache du processeur.
J'ai fait quelques recherches sur Google, mais je n'ai trouvé que des pages concernant les tableaux statiques (et affirmant que 1D et 2D sont fondamentalement les mêmes). Mais mes tableaux doivent être dynamiques.
Donc quoi
tableaux 1D dynamiques ou tableaux 2D dynamiques?
Merci :)
Remarque: Vous ne pouvez pas entrer dans les détails qui affectent les performances lorsque vous comparez des modèles de stockage dynamiques 1d ou 2d dynamiques sans remplissage des livres, car les performances du code dépendent d'un très grand nombre de paramètres. Profil si possible.
Pour les matrices denses, l’approche 1D sera probablement plus rapide car elle offre une meilleure localisation mémoire et moins de temps système d’allocation et de désaffectation.
Dynamic-1D consomme moins de mémoire que l'approche 2D. Ce dernier nécessite également plus d'allocations.
J'ai énoncé une réponse assez longue avec plusieurs raisons, mais je voudrais d'abord faire quelques remarques sur vos hypothèses.
Je peux imaginer que le recalcul des indices pour les tableaux 1D (y + x * n) pourrait être plus lent que d'utiliser un tableau 2D (x, y)
Comparons ces deux fonctions:
_int get_2d (int **p, int r, int c) { return p[r][c]; }
int get_1d (int *p, int r, int c) { return p[c + C*r]; }
_
L'assembly (non intégré) généré par Visual Studio 2015 RC pour ces fonctions (avec les optimisations activées) est:
_?get_1d@@YAHPAHII@Z PROC
Push ebp
mov ebp, esp
mov eax, DWORD PTR _c$[ebp]
lea eax, DWORD PTR [eax+edx*4]
mov eax, DWORD PTR [ecx+eax*4]
pop ebp
ret 0
?get_2d@@YAHPAPAHII@Z PROC
Push ebp
mov ebp, esp
mov ecx, DWORD PTR [ecx+edx*4]
mov eax, DWORD PTR _c$[ebp]
mov eax, DWORD PTR [ecx+eax*4]
pop ebp
ret 0
_
La différence est mov
(2d) par rapport à lea
(1d). Le premier a une latence de 3 cycles et un débit maximum de 2 par cycle tandis que le dernier a une latence de 2 cycles et un débit maximum de 3 par cycle. (Selon Tableaux d'instructions - Agner Fog Les différences étant mineures, je pense qu'il ne devrait pas y avoir de grande différence de performance résultant du recalcul d'indice. Je m'attends à ce qu'il soit très peu probable d'identifier cette différence elle-même le goulot d'étranglement dans n'importe quel programme.
Cela nous amène au point suivant (et plus intéressant):
... mais je pourrais imaginer que 1D pourrait être dans le cache du processeur ...
Vrai, mais 2d pourrait aussi être dans le cache du processeur. Voir The Downsides: Memory locality pour savoir pourquoi 1d est encore meilleur.
Remarque: Il s'agit des tableaux dynamiques/schémas d'allocation [malloc/new/vector etc.]. Un tableau 2D statique est un bloc de mémoire contigu et n'est donc pas sujet aux inconvénients que je vais présenter ici.
Pour pouvoir comprendre pourquoi un tableau dynamique de tableaux dynamiques ou un vecteur de vecteurs n’est très probablement pas le modèle de stockage de données de choix, vous devez comprendre la structure de la mémoire de ces structures.
_int main (void)
{
// allocate memory for 4x4 integers; quick & dirty
int ** p = new int*[4];
for (size_t i=0; i<4; ++i) p[i] = new int[4];
// do some stuff here, using p[x][y]
// deallocate memory
for (size_t i=0; i<4; ++i) delete[] p[i];
delete[] p;
}
_
Pour cette "matrice", vous affectez un bloc de quatre pointeurs et quatre blocs de quatre entiers. Toutes les affectations ne sont pas liées et peuvent donc entraîner une position de mémoire arbitraire.
L'image suivante vous donnera une idée de l'apparence de la mémoire.
Pour le 2 e cas réel :
p
elle-même.p
pointe sur (4 x _int*
_).int*
_ de la région verte.Pour le 2d mappé sur 1d cas :
int *
_int
).Cela signifie que (lorsque vous utilisez la disposition de gauche), vous constaterez probablement des performances inférieures à celles d'un modèle de stockage contigu (comme indiqué à droite), en raison par exemple de la mise en cache.
Supposons qu'une ligne de cache représente "la quantité de données transférées dans le cache à la fois" et imaginons un programme accédant à l'ensemble de la matrice, un élément après l'autre.
Si vous avez une matrice 4 fois 4 correctement alignée de valeurs 32 bits correctement alignée, un processeur avec une ligne de cache de 64 octets (valeur typique) est capable de "traiter en une seule fois" les données (4 * 4 * 4 = 64 octets). Si vous commencez le traitement et que les données ne sont pas déjà dans le cache, vous serez confronté à un manque de cache et les données seront extraites de la mémoire principale. Cette charge peut extraire la matrice entière en une fois puisqu'elle s'insère dans une ligne de cache, si et seulement si elle est stockée de manière contiguë (et correctement alignée). Il n’y aura probablement plus d’erreur lors du traitement de ces données.
Dans le cas d'un système dynamique "réel en deux dimensions" avec des emplacements non liés de chaque ligne/colonne, le processeur doit charger chaque emplacement de mémoire séparément. Bien que seuls 64 octets soient nécessaires, le chargement de 4 lignes de mémoire cache pour 4 positions de mémoire non liées entraînerait, dans le pire des cas, le transfert de 256 octets et une perte de bande passante de 75% du débit. Si vous traitez les données à l'aide du schéma 2d, vous rencontrerez à nouveau un cache manquant sur le premier élément (s'il n'est pas déjà mis en cache). Mais maintenant, seule la première ligne/colonne sera dans le cache après le premier chargement à partir de la mémoire principale car toutes les autres lignes sont situées ailleurs dans la mémoire et non adjacentes à la première. Dès que vous atteignez une nouvelle ligne/colonne, il y aura à nouveau un manque de cache et le prochain chargement à partir de la mémoire principale est effectué.
Bref récit: le modèle 2d a plus de chances d'absence de mémoire cache, le schéma 1d offrant un meilleur potentiel de performance en raison de la localisation des données.
N + 1
_ (4 + 1 = 5) allocations (en utilisant new, malloc, allocator :: allocate ou autre) sont nécessaires pour créer la matrice NxM (4 × 4) souhaitée.Par conséquent, il est plus coûteux de créer/copier de telles matrices par opposition à un schéma d'allocation unique.
Cela devient encore pire avec un nombre croissant de lignes.
Je vais supposer une taille de 32 bits pour les int et 32 bits pour les pointeurs. (Remarque: dépendance du système.)
Rappelons-nous: nous voulons stocker une matrice 4 × 4 int, ce qui signifie 64 octets.
Pour une matrice NxM, stockée avec le schéma présenté de pointeur à pointeur, nous consommons
N*M*sizeof(int)
[les données bleues réelles] +N*sizeof(int*)
[les pointeurs verts] +sizeof(int**)
[la variable violette p] octets.Cela donne _4*4*4 + 4*4 + 4 = 84
_ octets dans le cas du présent exemple et cela s’aggrave encore lorsqu’on utilise _std::vector<std::vector<int>>
_. Il faudra N * M * sizeof(int)
+ N * sizeof(vector<int>)
+ sizeof(vector<vector<int>>)
octets, c'est-à-dire _4*4*4 + 4*16 + 16 = 144
_ octets au total, au maximum 64 octets pour 4 x 4 int.
De plus, en fonction de l'allocateur utilisé, chaque allocation peut avoir (et aura très probablement) 16 octets supplémentaires de mémoire vive. (Certains "Infobytes" qui stockent le nombre d'octets alloués aux fins d'une désallocation appropriée.)
Cela signifie que le pire des cas est:
N*(16+M*sizeof(int)) + 16+N*sizeof(int*) + sizeof(int**)
= 4*(16+4*4) + 16+4*4 + 4 = 164 bytes ! _Overhead: 156%_
La part des frais généraux diminuera à mesure que la taille de la matrice augmente mais restera toujours présente.
Le groupe d'allocations nécessite une gestion des exceptions appropriée afin d'éviter les fuites de mémoire si l'une des allocations échoue! Vous devrez garder une trace des blocs de mémoire alloués et ne pas les oublier lors de la désallocation de la mémoire.
Si new
pas assez de mémoire et que la ligne suivante ne peut pas être allouée (surtout si la matrice est très grande), un _std::bad_alloc
_ est lancé par new
.
Exemple:
Dans l'exemple nouveau/supprimer mentionné ci-dessus, nous ferons face à davantage de code si nous voulons éviter les fuites en cas d'exceptions _bad_alloc
_.
_ // allocate memory for 4x4 integers; quick & dirty
size_t const N = 4;
// we don't need try for this allocation
// if it fails there is no leak
int ** p = new int*[N];
size_t allocs(0U);
try
{ // try block doing further allocations
for (size_t i=0; i<N; ++i)
{
p[i] = new int[4]; // allocate
++allocs; // advance counter if no exception occured
}
}
catch (std::bad_alloc & be)
{ // if an exception occurs we need to free out memory
for (size_t i=0; i<allocs; ++i) delete[] p[i]; // free all alloced p[i]s
delete[] p; // free p
throw; // rethrow bad_alloc
}
/*
do some stuff here, using p[x][y]
*/
// deallocate memory accoding to the number of allocations
for (size_t i=0; i<allocs; ++i) delete[] p[i];
delete[] p;
_
Il existe des cas où les configurations de mémoire "réelles 2d" s’adaptent et ont un sens (c’est-à-dire si le nombre de colonnes par ligne n’est pas constant), mais dans les cas de stockage de données 2D les plus simples et les plus courantes, elles alourdissent la complexité de votre code et réduisent les performances. et l'efficacité de la mémoire de votre programme.
Vous devez utiliser un bloc de mémoire contigu et mapper vos lignes sur ce bloc.
La "méthode C++" consiste probablement à écrire une classe qui gère votre mémoire tout en prenant en compte des éléments importants tels que
Pour donner une idée de l'apparence d'une telle classe, voici un exemple simple avec quelques fonctionnalités de base:
operator(size_t, size_t)
pour l'accès aux éléments majeurs à la deuxième rangéeat(size_t, size_t)
pour l'accès des éléments majeurs à la deuxième rangée vérifiéLa source:
_#include <vector>
#include <algorithm>
#include <iterator>
#include <utility>
namespace matrices
{
template<class T>
class simple
{
public:
// misc types
using data_type = std::vector<T>;
using value_type = typename std::vector<T>::value_type;
using size_type = typename std::vector<T>::size_type;
// ref
using reference = typename std::vector<T>::reference;
using const_reference = typename std::vector<T>::const_reference;
// iter
using iterator = typename std::vector<T>::iterator;
using const_iterator = typename std::vector<T>::const_iterator;
// reverse iter
using reverse_iterator = typename std::vector<T>::reverse_iterator;
using const_reverse_iterator = typename std::vector<T>::const_reverse_iterator;
// empty construction
simple() = default;
// default-insert rows*cols values
simple(size_type rows, size_type cols)
: m_rows(rows), m_cols(cols), m_data(rows*cols)
{}
// copy initialized matrix rows*cols
simple(size_type rows, size_type cols, const_reference val)
: m_rows(rows), m_cols(cols), m_data(rows*cols, val)
{}
// 1d-iterators
iterator begin() { return m_data.begin(); }
iterator end() { return m_data.end(); }
const_iterator begin() const { return m_data.begin(); }
const_iterator end() const { return m_data.end(); }
const_iterator cbegin() const { return m_data.cbegin(); }
const_iterator cend() const { return m_data.cend(); }
reverse_iterator rbegin() { return m_data.rbegin(); }
reverse_iterator rend() { return m_data.rend(); }
const_reverse_iterator rbegin() const { return m_data.rbegin(); }
const_reverse_iterator rend() const { return m_data.rend(); }
const_reverse_iterator crbegin() const { return m_data.crbegin(); }
const_reverse_iterator crend() const { return m_data.crend(); }
// element access (row major indexation)
reference operator() (size_type const row,
size_type const column)
{
return m_data[m_cols*row + column];
}
const_reference operator() (size_type const row,
size_type const column) const
{
return m_data[m_cols*row + column];
}
reference at() (size_type const row, size_type const column)
{
return m_data.at(m_cols*row + column);
}
const_reference at() (size_type const row, size_type const column) const
{
return m_data.at(m_cols*row + column);
}
// resizing
void resize(size_type new_rows, size_type new_cols)
{
// new matrix new_rows times new_cols
simple tmp(new_rows, new_cols);
// select smaller row and col size
auto mc = std::min(m_cols, new_cols);
auto mr = std::min(m_rows, new_rows);
for (size_type i(0U); i < mr; ++i)
{
// iterators to begin of rows
auto row = begin() + i*m_cols;
auto tmp_row = tmp.begin() + i*new_cols;
// move mc elements to tmp
std::move(row, row + mc, tmp_row);
}
// move assignment to this
*this = std::move(tmp);
}
// size and capacity
size_type size() const { return m_data.size(); }
size_type max_size() const { return m_data.max_size(); }
bool empty() const { return m_data.empty(); }
// dimensionality
size_type rows() const { return m_rows; }
size_type cols() const { return m_cols; }
// data swapping
void swap(simple &rhs)
{
using std::swap;
m_data.swap(rhs.m_data);
swap(m_rows, rhs.m_rows);
swap(m_cols, rhs.m_cols);
}
private:
// content
size_type m_rows{ 0u };
size_type m_cols{ 0u };
data_type m_data{};
};
template<class T>
void swap(simple<T> & lhs, simple<T> & rhs)
{
lhs.swap(rhs);
}
template<class T>
bool operator== (simple<T> const &a, simple<T> const &b)
{
if (a.rows() != b.rows() || a.cols() != b.cols())
{
return false;
}
return std::equal(a.begin(), a.end(), b.begin(), b.end());
}
template<class T>
bool operator!= (simple<T> const &a, simple<T> const &b)
{
return !(a == b);
}
}
_
Notez plusieurs choses ici:
T
doit répondre aux exigences des fonctions membres _std::vector
_ utiliséesoperator()
ne fait aucune vérification "of of range"Vous n'avez donc pas à vous soucier de la gestion correcte de la mémoire pour chaque application, mais seulement une fois pour la classe que vous écrivez.
Il peut y avoir des cas où une structure bidimensionnelle dynamique "réelle" est favorable. C'est par exemple le cas si
Sauf si vous parlez de tableaux statiques, 1D est plus rapide .
Voici la disposition de la mémoire d’un tableau 1D (std::vector<T>
):
+---+---+---+---+---+---+---+---+---+
| | | | | | | | | |
+---+---+---+---+---+---+---+---+---+
Et voici la même chose pour un tableau 2D dynamique (std::vector<std::vector<T>>
):
+---+---+---+
| * | * | * |
+-|-+-|-+-|-+
| | V
| | +---+---+---+
| | | | | |
| | +---+---+---+
| V
| +---+---+---+
| | | | |
| +---+---+---+
V
+---+---+---+
| | | |
+---+---+---+
Clairement, le cas 2D perd la localité du cache et utilise plus de mémoire. Il introduit également une indirection supplémentaire (et donc un pointeur supplémentaire à suivre), mais le premier tableau est surchargé par le calcul des indices, de sorte que ceux-ci soient plus ou moins égaux.
Taille: Les deux nécessiteront la même quantité de mémoire.
Vitesse: Vous pouvez supposer qu’il n’y aura pas de différence de vitesse car la mémoire de ces deux tableaux doit être contiguë (le tableau 2D entier. des morceaux répartis dans la mémoire). (Cela pourrait toutefois être un compilateur Dépendant.)
Taille: Le tableau 2D nécessitera un peu plus de mémoire que le tableau 1D en raison des pointeurs nécessaires dans le tableau 2D pour pointer sur l'ensemble des tableaux 1D alloués. (Ce petit fragment n’est que très petit lorsque nous parlons de très grands tableaux. Pour les petits tableaux, le tout petit morceau pourrait être assez gros, relativement parlant.)
Vitesse: La matrice 1D peut être plus rapide que la matrice 2D car la mémoire de la matrice 2D ne serait pas contigue, de sorte que les erreurs de mémoire cache deviendraient un problème.
Utilisez ce qui fonctionne et semble le plus logique. Si vous rencontrez des problèmes de vitesse, effectuez une refactorisation.
Les réponses existantes ne comparent toutes que les tableaux à une dimension et les tableaux de pointeurs.
En C (mais pas C++), il existe une troisième option; vous pouvez avoir un tableau 2-D contigu qui est alloué dynamiquement et a des dimensions d'exécution:
int (*p)[num_columns] = malloc(num_rows * sizeof *p);
et ceci est accédé comme p[row_index][col_index]
.
Je m'attendrais à ce que les performances de ce tableau soient très similaires à celles du tableau 1-D, mais cela vous donne une syntaxe plus agréable pour accéder aux cellules.
En C++, vous pouvez obtenir un résultat similaire en définissant une classe qui gère un tableau 1D en interne, mais que vous pouvez exposer via une syntaxe d'accès à un tableau 2-D à l'aide d'opérateurs surchargés. Encore une fois, je m'attendrais à ce que les performances soient identiques ou identiques à celles du tableau simple 1-D.
Une autre différence entre les tableaux 1D et 2D apparaît dans l’allocation de mémoire. Nous ne pouvons pas être sûrs que les membres du tableau 2D soient séquentiels.
Cela dépend vraiment de la façon dont votre tableau 2D est implémenté.
int a[200], b[10][20], *c[10], *d[10];
for (ii = 0; ii < 10; ++ii)
{
c[ii] = &b[ii][0];
d[ii] = (int*) malloc(20 * sizeof(int)); // The cast for C++ only.
}
Il y a 3 implémentations ici: b, c et d Il n’y aura pas beaucoup de différence en accédant à b [x] [y] ou a [x * 20 + y] puisqu’on fait le calcul et est le compilateur le faire pour vous. c [x] [y] et d [x] [y] sont plus lents car la machine doit trouver l'adresse indiquée par c [x], puis accéder au yième élément à partir de là. Ce n'est pas un calcul simple. Sur certaines machines (par exemple, AS400 qui a des pointeurs de 36 octets), l'accès au pointeur est extrêmement lent. Tout dépend de l'architecture utilisée. Sur les architectures de type x86, a et b ont la même vitesse, c et d sont plus lents que b.
J'aime la réponse approfondie fournie par Pixelchemist . Une version plus simple de cette solution peut être la suivante. Tout d'abord, déclarez les dimensions:
constexpr int M = 16; // rows
constexpr int N = 16; // columns
constexpr int P = 16; // planes
Créez ensuite un alias et les méthodes get et set:
template<typename T>
using Vector = std::vector<T>;
template<typename T>
inline T& set_elem(vector<T>& m_, size_t i_, size_t j_, size_t k_)
{
// check indexes here...
return m_[i_*N*P + j_*P + k_];
}
template<typename T>
inline const T& get_elem(const vector<T>& m_, size_t i_, size_t j_, size_t k_)
{
// check indexes here...
return m_[i_*N*P + j_*P + k_];
}
Enfin, un vecteur peut être créé et indexé comme suit:
Vector array3d(M*N*P, 0); // create 3-d array containing M*N*P zero ints
set_elem(array3d, 0, 0, 1) = 5; // array3d[0][0][1] = 5
auto n = get_elem(array3d, 0, 0, 1); // n = 5
La définition de la taille du vecteur à l'initialisation fournit des performances optimales . Cette solution est modifiée à partir de this answer . Les fonctions peuvent être surchargées pour prendre en charge diverses dimensions avec un seul vecteur. L'inconvénient de cette solution est que les paramètres M, N, P sont implicitement transmis aux fonctions get et set. Cela peut être résolu en implémentant la solution dans une classe, comme le fait Pixelchemist .