web-dev-qa-db-fra.com

Tableau 1D ou 2D, quoi de plus rapide?

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 

  1. plus rapide, 
  2. plus petit (RAM) 

tableaux 1D dynamiques ou tableaux 2D dynamiques?

Merci :)

53
graywolf

tl; dr: Vous devriez probablement utiliser une approche unidimensionnelle.

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.

1. Qu'est-ce qui est plus rapide?

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.

2. Quoi de plus petit?

Dynamic-1D consomme moins de mémoire que l'approche 2D. Ce dernier nécessite également plus d'allocations.

Remarques

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.

La réponse longue ou la raison pour laquelle le stockage de données dynamique en 2 dimensions (pointeur à pointeur ou vecteur de vecteur) est "mauvais" pour simples /petites matrices.

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.

Le problème

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.

Exemple d'utilisation de la syntaxe pointeur à pointeur

_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;
}
_

Les inconvénients

Lieu de mémoire

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 :

  • Le carré violet est la position de mémoire occupée par p elle-même.
  • Les carrés verts assemblent la région mémoire p pointe sur (4 x _int*_).
  • Les 4 régions de 4 carrés bleus contigus sont celles désignées par chaque _int*_ de la région verte.

Pour le 2d mappé sur 1d cas :

  • Le carré vert est le seul pointeur requis _int *_
  • Les carrés bleus définissent la région mémoire de tous les éléments de la matrice (16 x int).

Real 2d vs mapped 2d memory layout

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.

Allocation/désaffectation fréquente

  • Un maximum de _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.
  • Le même nombre d'opérations de désallocation respectives appropriées doit également être appliqué.

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.

Surcharge de mémoire

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.

Risque de fuite de mémoire

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;
_

Résumé

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.

Alternative

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

Exemple

Pour donner une idée de l'apparence d'une telle classe, voici un exemple simple avec quelques fonctionnalités de base:

  • 2d-size-constructible
  • 2D redimensionnable
  • operator(size_t, size_t) pour l'accès aux éléments majeurs à la deuxième rangée
  • at(size_t, size_t) pour l'accès des éléments majeurs à la deuxième rangée vérifié
  • Répond aux exigences du concept pour conteneur

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ées
  • operator() ne fait aucune vérification "of of range"
  • Pas besoin de gérer les données vous-même
  • Aucun destructeur, constructeur de copie ou opérateur d'affectation requis

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.

Restrictions

Il peut y avoir des cas où une structure bidimensionnelle dynamique "réelle" est favorable. C'est par exemple le cas si

  • la matrice est très volumineuse et clairsemée (si l’une des lignes n’a même pas besoin d’être allouée mais peut être manipulée à l’aide d’un nullptr) ou si
  • les lignes n'ont pas le même nombre de colonnes (c'est-à-dire si vous n'avez pas du tout une matrice mais une autre construction bidimensionnelle).
180
Pixelchemist

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.

16
Konrad Rudolph

Tableaux statiques 1D et 2D

  • 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.)

Tableaux dynamiques 1D et 2D

  • 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.

8

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.

5
M.M

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.

4
Polymorphism

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.

1
cup

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 .

0
Adam Erickson