web-dev-qa-db-ja.com

1Dまたは2Dアレイ、何が速いですか?

2Dフィールド(軸x、y)を表す必要があり、1D配列または2D配列を使用する必要があるという問題に直面しています。

1D配列(y + x * n)のインデックスの再計算は、2D配列(x、y)を使用するよりも遅くなることが想像できますが、1DがCPUキャッシュにあることを想像できます。

私はいくつかのグーグルを行いましたが、静的配列に関するページのみを見つけました(1Dと2Dは基本的に同じであると述べています)。しかし、私の配列は動的でなければなりません。

だから、何ですか

  1. もっと早く、
  2. 小さい(RAM)

動的1D配列または動的2D配列?

ありがとう:)

60
graywolf

tl; dr:1次元アプローチを使用する必要があります。

注:コードのパフォーマンスは非常に多くのパラメーターに依存するため、ブックを埋めずにダイナミック1dまたはダイナミック2dストレージパターンを比較する場合、パフォーマンスに影響する詳細を掘り下げることはできません。可能であればプロファイル。

1.速いのは何ですか?

密な行列の場合、1Dアプローチはメモリの局所性が向上し、割り当てと割り当て解除のオーバーヘッドが小さくなるため、より高速になる可能性があります。

2.小​​さいものは何ですか?

Dynamic-1Dは、2Dアプローチよりも少ないメモリを消費します。後者もより多くの割り当てを必要とします。

備考

いくつかの理由でかなり長い答えを下にレイアウトしましたが、最初にあなたの仮定についていくつか述べたいと思います。

1D配列(y + x * n)のインデックスの再計算は、2D配列(x、y)を使用するよりも遅くなることが想像できます

これら2つの関数を比較してみましょう。

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

これらの機能のためにVisual Studio 2015 RCによって生成された(非インライン)アセンブリ(最適化がオンになっている)は次のとおりです。

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

違いは、mov(2d)とlea(1d)です。前者のレイテンシは3サイクルであり、最大スループットはサイクルあたり2です。後者のレイテンシは2サイクルであり、最大スループットはサイクルあたり3です。 ( 命令テーブル-Agner Fog によると、違いは小さいので、インデックスの再計算によって大きなパフォーマンスの違いが生じることはないと思います。この違い自体がプログラムのボトルネックになります。

これにより、次の(より興味深い)ポイントに到達します。

...しかし、1DがCPUキャッシュにある可能性があることをイメージできました...

確かに、2dもCPUキャッシュにある可能性があります。 1-dの方が優れている理由については、The Downsides:Memory localityを参照してください。

長い答え、または動的な2次元データストレージ(ポインターからポインターまたはベクトルのベクトル)がsimple /小さな行列に対して「悪い」理由.

注:これは、動的配列/割り当てスキーム[malloc/new/vectorなど]に関するものです。静的な2D配列は連続したメモリブロックであるため、ここで説明する欠点はありません。

問題

動的配列の動的配列またはベクトルのベクトルが、選択したデータストレージパターンではない可能性が高い理由を理解するには、そのような構造のメモリレイアウトを理解する必要があります。

ポインターからポインターへの構文を使用した例

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

欠点

メモリの局所性

この「マトリックス」には、4つのポインターの1つのブロックと4つの整数の4つのブロックを割り当てます。 すべての割り当ては無関係であるであるため、任意のメモリ位置になる可能性があります。

次の図は、メモリがどのように見えるかを示しています。

実際の2Dケースの場合:

  • 紫の正方形は、p自体が占めるメモリ位置です。
  • 緑色の四角形は、pが指すメモリ領域を組み立てます(4 x _int*_)。
  • 4つの連続した青い正方形の4つの領域は、緑の領域の各_int*_が指す領域です。

1dケースにマッピングされた2dの場合:

  • 緑の四角が唯一の必須ポインター_int *_
  • 青い四角は、すべての行列要素(16 x int)のメモリ領域をまとめています。

Real 2d vs mapped 2d memory layout

これは、(左側のレイアウトを使用する場合)おそらくキャッシュなどにより、連続したストレージパターン(右側に表示)よりもパフォーマンスが低下することを意味します。

キャッシュラインが「一度にキャッシュに転送されるデータ量」であり、マトリックス全体に次々にアクセスするプログラムを想像してみましょう。

32ビット値の4×4マトリックスが適切に配置されている場合、64バイトのキャッシュライン(標準値)を備えたプロセッサーは、データを「ワンショット」で処理できます(4 * 4 * 4 = 64バイト)。処理を開始し、データがまだキャッシュにない場合、キャッシュミスが発生し、データはメインメモリから取得されます。このロードは、連続して格納されている(および適切に配置されている)場合にのみ、キャッシュラインに収まるため、マトリックス全体を一度にフェッチできます。そのデータの処理中にミスが発生することはおそらくないでしょう。

各行/列の位置が無関係な動的な「実際の2次元」システムの場合、プロセッサはすべてのメモリ位置を別々にロードする必要があります。必要なのは64バイトだけですが、4つの無関係なメモリ位置に4つのキャッシュラインをロードすると、最悪の場合、実際に256バイトを転送し、75%のスループット帯域幅を浪費します。 2d-schemeを使用してデータを処理すると、最初の要素でキャッシュミスが発生します(まだキャッシュされていない場合)。しかし、他のすべての行はメモリ内のどこかにあり、最初の行に隣接していないため、メインメモリから最初にロードした後は、最初の行/列のみがキャッシュに格納されます。新しい行/列に到達するとすぐに、再びキャッシュミスが発生し、メインメモリからの次のロードが実行されます。

簡単に言えば、2dパターンはキャッシュミスの可能性が高く、1dスキームではデータの局所性によりパフォーマンスが向上する可能性があります。

頻繁な割り当て/割り当て解除

  • 必要なNxM(4×4)マトリックスを作成するには、_N + 1_(4 + 1 = 5)の割り当て(new、malloc、allocator :: allocateなどを使用)が必要です。
  • 同じ数の適切なそれぞれの割り当て解除操作も適用する必要があります。

したがって、単一の割り当てスキームとは対照的に、このようなマトリックスを作成/コピーする方が費用がかかります。

これは行の数が増えるにつれてさらに悪化しています。

メモリ消費のオーバーヘッド

intには32ビット、ポインタには32ビットのサイズを想定します。 (注:システムの依存関係。)

覚えておきましょう:64バイトを意味する4x4 intマトリックスを保存したいのです。

提示されたポインターツーポインター方式で保存されたNxMマトリックスの場合、

  • N*M*sizeof(int) [実際の青いデータ] +
  • N*sizeof(int*) [緑色のポインター] +
  • sizeof(int**) [Violet変数p]バイト。

これにより、この例の場合は_4*4*4 + 4*4 + 4 = 84_バイトになり、_std::vector<std::vector<int>>_を使用するとさらに悪化します。 N * M * sizeof(int) + N * sizeof(vector<int>) + sizeof(vector<vector<int>>)バイト、つまり合計で_4*4*4 + 4*16 + 16 = 144_バイト、4 x 4 intの場合は64バイトが必要です。

さらに、使用されるアロケーターに応じて、各単一の割り当てには16バイトのメモリオーバーヘッドが発生する可能性があります(ほとんどの場合、そうなります)。 (適切な割り当て解除のために割り当てられたバイト数を格納する「Infobytes」もあります。)

つまり、最悪の場合は次のとおりです。

N*(16+M*sizeof(int)) + 16+N*sizeof(int*) + sizeof(int**)
= 4*(16+4*4) + 16+4*4 + 4 = 164 bytes ! _Overhead: 156%_

マトリックスのサイズが大きくなるにつれてオーバーヘッドの割合は減少しますが、それでも存在します。

メモリリークのリスク

大量の割り当てでは、割り当ての1つが失敗した場合のメモリリークを回避するために、適切な例外処理が必要です。割り当てられたメモリブロックを追跡する必要があり、メモリの割り当てを解除するときにそれらを忘れてはなりません。

newがメモリ不足で次の行を割り当てることができない場合(特にマトリックスが非常に大きい場合)、newによって_std::bad_alloc_がスローされます。

例:

上記のnew/deleteの例では、_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;
_

概要

「実際の2D」メモリレイアウトが適切で理にかなっている場合があります(つまり、行ごとの列数が一定でない場合)が、最も単純で一般的な2Dデータストレージの場合、コードの複雑さが増大し、パフォーマンスが低下しますプログラムのメモリ効率。

代替案

連続したメモリブロックを使用し、行をそのブロックにマップする必要があります。

それを行う「C++の方法」は、おそらく次のような重要なことを考慮しながら、メモリを管理するクラスを書くことです。

そのようなクラスがどのように見えるかのアイデアを提供するために、いくつかの基本的な機能を備えた簡単な例を示します。

  • 2Dサイズで構成可能
  • 2Dサイズ変更可能
  • operator(size_t, size_t) 2d-row主要要素アクセス用
  • at(size_t, size_t)チェック済みの2行の主要要素アクセス
  • Containerのコンセプト要件を満たします

ソース:

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

}
_

ここにいくつかのことに注意してください:

  • Tは、使用される_std::vector_メンバー関数の要件を満たす必要があります
  • operator()は「範囲外」のチェックを行いません
  • 自分でデータを管理する必要はありません
  • デストラクタ、コピーコンストラクタ、または代入演算子は不要

そのため、アプリケーションごとに適切なメモリ処理を気にする必要はなく、作成するクラスに対して1回だけ行う必要があります。

制限事項

動的な「実際の」2次元構造が好ましい場合があります。これは、たとえば次の場合です

  • 行列が非常に大きくてまばらである(行のいずれかを割り当てる必要さえないが、nullptrを使用して処理できる場合)または
  • 行の列数は同じではありません(つまり、別の2次元構造を除いてマトリックスがまったくない場合)。
180
Pixelchemist

nlessあなたは静的配列について話している1Dの方が速い

1D配列のメモリレイアウトは次のとおりです(std::vector<T>):

+---+---+---+---+---+---+---+---+---+
|   |   |   |   |   |   |   |   |   |
+---+---+---+---+---+---+---+---+---+

動的2D配列の場合も同じです(std::vector<std::vector<T>>):

+---+---+---+
| * | * | * |
+-|-+-|-+-|-+
  |   |   V
  |   | +---+---+---+
  |   | |   |   |   |
  |   | +---+---+---+
  |   V
  | +---+---+---+
  | |   |   |   |
  | +---+---+---+
  V
+---+---+---+
|   |   |   |
+---+---+---+

明らかに、2Dケースはキャッシュの局所性を失い、より多くのメモリを使用します。また、追加のインダイレクション(およびそれに続く追加のポインター)が導入されますが、最初の配列にはインデックスを計算するオーバーヘッドがあり、これらが多かれ少なかれ均等になります。

19
Konrad Rudolph

1Dおよび2D静的配列

  • サイズ:両方とも同じ量のメモリが必要です。

  • 速度:これらの配列の両方のメモリは連続している必要があるため、速度の違いはないと想定できます(2D配列全体が、全体に広がるチャンクの束ではなく、メモリ内の1つのチャンクとして表示される必要がありますメモリ)。 (ただし、これはコンパイラに依存する可能性があります。)

1Dおよび2D動的配列

  • サイズ: 2D配列は、割り当てられた1D配列のセットを指すために2D配列で必要なポインターがあるため、1D配列よりもわずかに多くのメモリを必要とします。 (この小さなビットは、本当に大きな配列について話している場合にのみ小さくなります。小さな配列の場合、小さなビットは比較的話すのにかなり大きくなります。)

  • Speed: 1D配列は、2D配列のメモリが連続していないため、2D配列よりも高速である可能性があり、キャッシュミスが問題になります。


動作し、最も論理的に見えるものを使用し、速度の問題に直面した場合は、リファクタリングします。

9

既存の回答はすべて、1次元配列とポインターの配列を比較するだけです。

C(ただしC++ではない)には3番目のオプションがあります。動的に割り当てられ、実行時の次元を持つ連続した2次元配列を使用できます。

int (*p)[num_columns] = malloc(num_rows * sizeof *p);

これはp[row_index][col_index]のようにアクセスされます。

これは1次元配列の場合と非常によく似たパフォーマンスを期待しますが、セルにアクセスするためのより良い構文を提供します。

C++では、1次元配列を内部的に維持するクラスを定義することで同様のことを実現できますが、オーバーロードされた演算子を使用して、2次元配列アクセス構文を介して公開できます。繰り返しになりますが、プレーン1-Dアレイと同等または同等のパフォーマンスが期待されます。

5
M.M

1D配列と2D配列のもう1つの違いは、メモリの割り当てにあります。 2D配列のメンバーがシーケンシャルであることを確認することはできません。

4
Polymorphism

それは、実際に2D配列がどのように実装されるかに依存します。

以下のコードを検討してください。

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

ここには3つの実装があります:b、c、d

b[x][y]またはa[x*20 + y]へのアクセスに大きな違いはありません。1つは計算を行い、もう1つは計算を行うためです。 c[x][y]d[x][y]は、マシンがc[x]が指すアドレスを見つけて、そこからy番目の要素にアクセスする必要があるため、低速です。単純な計算ではありません。一部のマシン(36ビット(ビットではない)ポインターを持つAS400など)では、ポインターアクセスが非常に遅くなります。それはすべて使用中のアーキテクチャに依存します。 x86タイプのアーキテクチャでは、aとbは同じ速度であり、cとdはbよりも低速です。

1
cup

Pixelchemist が提供する徹底的な回答が大好きです。このソリューションのより単純なバージョンは次のようになります。まず、ディメンションを宣言します。

constexpr int M = 16; // rows
constexpr int N = 16; // columns
constexpr int P = 16; // planes

次に、エイリアスを作成し、メソッドを取得および設定します。

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

最後に、次のようにベクトルを作成してインデックスを作成できます。

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

初期化時にベクトルサイズを定義すると、 最適なパフォーマンス が提供されます。このソリューションは this answer から変更されています。関数は、単一のベクトルでさまざまな次元をサポートするためにオーバーロードされる場合があります。このソリューションの欠点は、M、N、Pパラメーターが暗黙的にgetおよびset関数に渡されることです。これは、 Pixelchemist によって行われるように、クラス内にソリューションを実装することで解決できます。

0
Adam Erickson