web-dev-qa-db-ja.com

静的配列と動的配列のC / C ++パフォーマンス

アプリケーションにパフォーマンスが不可欠な場合、スタックとヒープのどちらで配列を宣言するかを検討する必要がありますか?この疑問が浮かんだ理由を簡単に説明します。

C/C++の配列はオブジェクトではなく、ポインターに減衰するため、コンパイラーは提供されたインデックスを使用して、要素にアクセスするためのポインター演算を実行します。私の理解では、このプロシージャは、最初の次元を通過するときに、静的に宣言された配列から動的に宣言された配列にdiffersです。

スタック上の配列を次のように宣言するとします。

  int array[2][3] = { 0, 1, 2, 3, 4, 5 }
  //In memory        { row1 } { row2 }

この配列は、メモリの連続したブロックに格納されるため、メモリに行メジャー形式で格納されます。つまり、配列内の要素にアクセスしようとすると、コンパイラは正しい場所を確認するために、加算と乗算を実行する必要があります。

だから私が次のことをするなら

  int x = array[1][2]; // x = 5

コンパイラは、次の場合にこの式を使用します。

i =行インデックスj =列インデックスn =単一行のサイズ(ここではn = 2)
array =最初の要素へのポインタ

  *(array + (i*n) + j)
  *(array + (1*2) + 2)  

つまり、この配列をループして各要素にアクセスすると、インデックスによるアクセスごとに追加の乗算ステップが実行されます。

現在、ヒープ上で宣言された配列では、パラダイムが異なり、多段階のソリューションが必要です。注:ここでC++ new演算子を使用することもできますが、データの表現方法に違いはないと思います。

  int ** array;
  int rowSize = 2;
  // Create a 2 by 3 2d array on the heap
  array = malloc(2 * sizeof(int*));
  for (int i = 0; i < 2; i++) {
      array[i] = malloc(3 * sizeof(int));
  }

  // Populating the array
  int number = 0;
  for (int i = 0; i < 2; i++) {
      for (int j = 0l j < 3; j++) {
          array[i][j] = number++;
      }
  }

これで配列は動的になるため、その表現は1次元配列の1次元配列になります。アスキー絵を描いてみます...

              int *        int int int
int ** array-> [0]          0   1   2
               [1]          3   4   5

これは、乗算がもはや関与していないことを意味しますか?私が次のことをするなら

int x = array[1][1];

次に、array [1]で間接参照/ポインタ演算を実行して2番目の行へのポインタにアクセスし、これをもう一度実行して2番目の要素にアクセスします。私はこれを言うのは正しいですか?

いくつかのコンテキストがあるので、質問に戻ります。フレームのレンダリングに約0.016秒かかるゲームのように、鮮明なパフォーマンスを必要とするアプリケーションのコードを記述している場合、スタックとヒープで配列を使用することを2度考えるべきですか?これで、mallocまたは新しい演算子の使用には1回限りのコストがかかることに気付きましたが、データセットが大きくなる特定の時点(Big O分析のように)で、行メジャーを回避するために動的配列を反復処理する方がよいでしょう。インデックス作成?

19
Paul Renton

これらは「プレーン」C(C++ではない)に適用されます。

最初にいくつかの用語をクリアしましょう

「静的」はCのキーワードであり、関数内で宣言された変数に適用された場合、変数の割り当て方法やアクセス方法を大幅に変更します。

変数(配列を含む)が置かれる可能性のある場所は(Cに関して)3つあります。

  • スタック:これらはstaticのない関数ローカル変数です。
  • データセクション:プログラムの開始時に、これらにスペースが割り当てられます。これらは、任意のグローバル変数(staticであるかどうかに関係なく、キーワードは可視性に関連します)、およびstaticで宣言された関数ローカル変数です。
  • ヒープ:ポインタによって参照される動的に割り当てられたメモリ(malloc()free())。このデータには、ポインターを介してのみアクセスします。

では、1次元配列へのアクセス方法を見てみましょう

定数インデックス(_#define_ dの可能性がありますが、プレーンCではconstではない可能性があります)を使用して配列にアクセスする場合、このインデックスはコンパイラーによって計算できます。 データセクションに真の配列がある場合、間接参照なしでアクセスされます。 スタックにポインタ(ヒープ)または配列がある場合は、常に間接参照が必要です。したがって、このタイプのアクセスを使用したDataセクションの配列は、少しだけ高速になる可能性があります。しかし、これは世界を変えるようなあまり有用なことではありません。

インデックス変数を使用して配列にアクセスする場合、インデックスが変更される可能性があるため(たとえば、forループでのインクリメントなど)、基本的に常にポインターに減衰します。生成されたコードは、ここのすべてのタイプで非常に類似しているか、まったく同じである可能性があります。

より多くの次元をもたらす

2次元配列を宣言し、定数によって部分的または完全にアクセスする場合、インテリジェントコンパイラはこれらの定数を上記のように最適化する可能性があります。

インデックスでアクセスする場合は、メモリが線形であることに注意してください。真の配列の後の次元が2の倍数でない場合、コンパイラーは乗算を生成する必要があります。たとえば、配列_int arr[4][12];_の2番目の次元は12です。_arr[i][j]_としてアクセスする場合、ijはインデックス変数であり、線形メモリは_12 * i + j_としてインデックス付けする必要があります。したがって、コンパイラは定数を乗算するコードをここで生成する必要があります。複雑さは、定数が2の累乗からどれだけ「遠い」かによって異なります。ここで結果のコードは、配列内の要素にアクセスするために_(i<<3) + (i<<2) + j_を計算するように見える可能性があります。

ポインタから2次元の「配列」を構築する場合、構造内に参照ポインタがあるため、次元のサイズは重要ではありません。ここで_arr[i][j]_を記述できる場合、それはたとえば_int* arr[4]_として宣言し、次にそれぞれ12intsのメモリの4つのチャンクをmalloc()したことを意味します。 4つのポインタ(コンパイラがベースとして使用できるようになりました)もメモリを消費することに注意してください。これは、真の配列である場合には取得されませんでした。また、ここで生成されたコードには二重間接参照が含まれることに注意してください。最初に、コードはiからarrによってポインターをロードし、次にintによってそのポインターからjをロードします。

長さが2の累乗から「遠い」場合(要素にアクセスするには、複雑な「定数で乗算」コードを生成する必要があります)、ポインターを使用すると、より高速なアクセスコードが生成される可能性があります。

James Kanze が彼の回答で述べたように、状況によっては、コンパイラーが真の多次元配列へのアクセスを最適化できる場合があります。この種の最適化は、ポインタから構成される配列では不可能です。その場合、「配列」は実際にはメモリの線形チャンクではないためです。

地域性の問題

通常のデスクトップ/モバイルアーキテクチャ(Intel/ARM 32/64ビットプロセッサ))向けに開発している場合は、ローカリティも重要です。これは、キャッシュにある可能性が高いものです。変数がすでに何らかの理由でキャッシュにアクセスすると、アクセスが速くなります。

局所性に関しては、Stackが頻繁に使用されるため、常にStackが勝者となります。キャッシュ。したがって、小さな配列が最適です。

真の配列は常に線形のメモリチャンクであるため、ポインタから配列を作成する代わりに真の多次元配列を使用することも、この点で役立つ可能性があります。逆にmalloc() edチャンクを個別に使用する場合は、より多くのキャッシュブロックが必要になる可能性があり、チャンクが物理的にヒープに到達する方法によっては、キャッシュラインの競合が発生する可能性があります。

22
Jubatian

どちらの選択がより良いパフォーマンスを提供するかに関して、答えはあなたの特定の状況に大きく依存します。 1つの方法が優れているかどうか、またはそれらがほぼ同等であるかどうかを知る唯一の方法は、アプリケーションのパフォーマンスを測定することです。

要因となるいくつかのことは、それを行う頻度、配列/データの実際のサイズ、システムに搭載されているメモリの量、およびシステムがメモリを適切に管理していることです。

あなたが2つの選択肢から選ぶことができる贅沢があるならば、それはサイズがすでに釘付けされていることを意味しなければなりません。次に、説明した複数の割り当てスキームは必要ありません。 2D配列の単一の動的割り当てを実行できます。 Cの場合:

int (*array)[COLUMNS];
array = malloc(ROWS * sizeof(*array));

C++の場合:

std::vector<std::array<int, COLUMNS>> array(ROWS);

COLUMNSが特定されている限り、単一の割り当てを実行して2D配列を取得できます。どちらも固定されていない場合、静的配列を使用するという選択肢はありません。

4
jxh

C++で2次元配列を実装する通常の方法は、std::vector<int>を使用してそれをクラスにラップし、インデックスを計算するクラスアクセサーを使用することです。しかしながら:

最適化に関する質問は、測定によってのみ回答できます。それでも、測定を行うマシンで使用しているコンパイラに対してのみ有効です。

あなたが書く場合:

int array[2][3] = { ... };

そして次のようなもの:

for ( int i = 0; i != 2; ++ i ) {
    for ( int j = 0; j != 3; ++ j ) {
        //  do something with array[i][j]...
    }
}

次のようなものを実際に生成しないコンパイラを想像するのは難しいです。

for ( int* p = array, p != array + whatever; ++ p ) {
    //  do something with *p
}

これは、最も基本的な最適化の1つであり、少なくとも30年前から存在しています。

提案どおりに動的に割り当てる場合、コンパイラーはnotでこの最適化を適用できます。また、単一のアクセスの場合でも、マトリックスの局所性は低く、より多くのメモリアクセスが必要になるため、パフォーマンスが低下する可能性があります。

C++を使用している場合は、通常、メモリにstd::vector<int>を使用し、乗算を使用してインデックスを明示的に計算して、Matrixクラスを記述します。 (局所性が改善されると、乗算にもかかわらず、パフォーマンスが向上する可能性があります。)これにより、コンパイラーが上記の最適化を実行するのが難しくなる可能性がありますが、これが問題であることが判明した場合は、これを処理するための専用のイテレーターをいつでも提供できます。 1つの特定のケース。パフォーマンスをほとんどまたはまったく損なうことなく、より読みやすく、より柔軟なコード(たとえば、寸法が一定である必要はありません)になります。

4
James Kanze

多くの場合、メモリ消費量と速度の間にはトレードオフがあります。経験的に、スタックでの配列の作成は、ヒープでの割り当てよりも高速であることがわかりました。配列サイズが大きくなると、これはより明白になります。

いつでもメモリ消費量を減らすことができます。たとえば、intなどの代わりにshortまたはcharを使用できます。

配列のサイズが大きくなると、特にreallocを使用すると、アイテムの連続した位置を維持するために、ページの置換(上下)が大幅に増える可能性があります。

また、スタックに格納できるもののサイズには下限があることも考慮する必要があります。ヒープの場合、この制限は高くなりますが、パフォーマンスのコストについて説明しました。

1
sgun