web-dev-qa-db-ja.com

効率:配列とポインター

ポインタを介したメモリアクセスは、配列を介したメモリアクセスよりも効率的であると言われています。私はCを学んでおり、上記はK&Rに記載されています。具体的に言うと

配列の添え字付けによって実現できる操作は、ポインターを使用しても実行できます。ポインターのバージョンは一般に高速になります

ビジュアルC++を使用して次のコードを逆アセンブルしました。(私のプロセッサは686プロセッサです。すべての最適化を無効にしました。)

int a[10], *p = a, temp;

void foo()
{
    temp = a[0];
    temp = *p;
}

驚いたことに、ポインタを介したメモリアクセスは、配列を介したメモリアクセスによって取得された2つの命令に3つの命令を使用することがわかります。以下は対応するコードです。

; 5    : temp = a[0];

    mov eax, DWORD PTR _a
    mov DWORD PTR _temp, eax

; 6    : temp = *p;

    mov eax, DWORD PTR _p
    mov ecx, DWORD PTR [eax]
    mov DWORD PTR _temp, ecx

理解してください。ここに何が欠けていますか?


多くの回答とコメントで指摘されているように、配列インデックスとしてコンパイル時定数を使用していたため、配列を介したアクセスがほぼ間違いなく容易になりました。以下は、インデックスとして変数を使用したアセンブリコードです。ポインターと配列を介してアクセスするための命令の数が等しくなりました。私のより広範な質問は依然として有効です。ポインタを介したメモリアクセスは、より効率的であるとは言えません。

; 7    :        temp = a[i];

    mov eax, DWORD PTR _i
    mov ecx, DWORD PTR _a[eax*4]
    mov DWORD PTR _temp, ecx

; 8    : 
; 9    :    
; 10   :        temp = *p;

    mov eax, DWORD PTR _p
    mov ecx, DWORD PTR [eax]
    mov DWORD PTR _temp, ecx
56
Abhijith Madhav

ポインタを介したメモリアクセスは、配列を介したメモリアクセスよりも効率的であると言われています。

これは、コンパイラが比較的愚かな獣だった過去に真実だったかもしれません。高度な最適化モードでgccによって出力されるコードの一部を見るだけで、それがもはや真実ではないことがわかります。そのコードの一部は理解するのが非常に困難ですが、一度理解すれば、その輝きは明白です。

まともなコンパイラは、ポインタアクセスと配列アクセスに対して同じコードを生成します。おそらく、そのレベルのパフォーマンスについて心配する必要はありません。コンパイラを書く人は、単なる人間よりもターゲットアーキテクチャについてはるかに多くのことを知っています。コードを最適化するとき(アルゴリズムの選択など)、マクロレベルに集中し、ツールメーカーが自分の仕事をすることを信頼します。


実際、コンパイラーが全体を最適化していないことに驚いています

_temp = a[0];
_

tempは次の行で異なる値で上書きされ、avolatileとマークされないため、存在しません。

競合他社を数桁上回った最新のVAX Fortranコンパイラーのベンチマーク(ここに私の年齢を示しています)についての昔からの都市の神話を覚えています。

コンパイラーは、ベンチマーク計算の結果がどこにも使用されていなかったため、計算ループ全体を忘却に最適化したことがわかりました。したがって、実行速度が大幅に向上します。


更新:特定のケースで最適化されたコードがより効率的である理由は、場所を見つける方法のためです。 aは、リンク/ロード時に決定された固定位置にあり、それへの参照は同時に固定されます。したがって、_a[0]_または実際には_a[any constant]_は固定された場所にあります。

また、p自体も同じ理由で固定された場所にあります。 ただし _*p_(pの内容)は可変であるため、正しいメモリ位置を見つけるために追加のルックアップが必要になります。

おそらく、別の変数xを0(constではなく)に設定し、_a[x]_を使用すると追加の計算が導入されることに気付くでしょう。


コメントの1つで、次のように述べます。

提案どおりに実行すると、配列を介したメモリアクセスの3つの命令(インデックスの取得、配列要素の値の取得、一時保存)が発生しました。しかし、私はまだ効率を見ることができません。 :

それに対する私の回答は、あなたが非常に可能性が高いということです---(ないポインタの使用の効率を見る最新のコンパイラーは、配列操作とポインター操作を同じ基礎となるマシンコードに変換できるかどうかを判断するだけではありません。

実際、最適化をオンにしないと、ポインターコードはlessになります。次の翻訳を検討してください。

_int *pa, i, a[10];

for (i = 0; i < 10; i++)
    a[i] = 100;
/*
    movl    $0, -16(%ebp)              ; this is i, init to 0
L2:
    cmpl    $9, -16(%ebp)              ; from 0 to 9
    jg      L3
    movl    -16(%ebp), %eax            ; load i into register
    movl    $100, -72(%ebp,%eax,4)     ; store 100 based on array/i
    leal    -16(%ebp), %eax            ; get address of i
    incl    (%eax)                     ; increment
    jmp     L2                         ; and loop
L3:
*/

for (pa = a; pa < a + 10; pa++)
    *pa = 100;
/*
    leal    -72(%ebp), %eax
    movl    %eax, -12(%ebp)            ; this is pa, init to &a[0]
L5:
    leal    -72(%ebp), %eax
    addl    $40, %eax
    cmpl    -12(%ebp), %eax            ; is pa at &(a[10])
    jbe     L6                         ; yes, stop
    movl    -12(%ebp), %eax            ; get pa
    movl    $100, (%eax)               ; store 100
    leal    -12(%ebp), %eax            ; get pa
    addl    $4, (%eax)                 ; add 4 (sizeof int)
    jmp     L5                         ; loop around
L6:
*/
_

その例から、ポインターの例がより長く、不必要にそうであることが実際にわかります。 paを_%eax_に変更せずに複数回ロードし、実際にpa&(a[10])の間で_%eax_を交互に切り替えます。ここでのデフォルトの最適化は、基本的にはまったくありません。

最適化レベル2に切り替えると、取得するコードは次のとおりです。

_    xorl    %eax, %eax
L5:
    movl    $100, %edx
    movl    %edx, -56(%ebp,%eax,4)
    incl    %eax
    cmpl    $9, %eax
    jle     L5
_

配列バージョンの場合、および:

_    leal    -56(%ebp), %eax
    leal    -16(%ebp), %edx
    jmp     L14
L16:
    movl    $100, (%eax)
    addl    $4, %eax
L14:
    cmpl    %eax, %edx
    ja      L16
_

ポインターバージョン用。

ここではクロックサイクルの分析は行いません(作業が多すぎて基本的に怠け者だからです)が、1つだけ指摘しておきます。アセンブラー命令に関しては両方のバージョンのコードに大きな違いはなく、最新のCPUが実際に実行される速度を考えると、これらのbillionsを実行しない限り、違いに気付かないでしょう。操作。私は常に読みやすさのためにコードを書くことを好み、それが問題になる場合にのみパフォーマンスを心配する傾向があります。

余談ですが、参照するステートメント:

5.3ポインターと配列:ポインターのバージョンは一般に高速になりますが、少なくとも未経験の人にとっては、すぐに把握するのが多少難しくなります。

k&Rの初期バージョンにまでさかのぼります。これには、関数がまだ記述されている私の古い1978年のものも含まれます。

_getint(pn)
int *pn;
{
    ...
}
_

コンパイラは当時から非常に長い道のりを歩んできました。

70
paxdiablo

組み込みプラットフォームをプログラミングしている場合、ポインターメソッドはインデックスを使用するよりもはるかに高速であることがすぐにわかります。

struct bar a[10], *p;

void foo()
{
    int i;

    // slow loop
    for (i = 0; i < 10; ++i)
        printf( a[i].value);

    // faster loop
    for (p = a; p < &a[10]; ++p)
        printf( p->value);
}

スローループでは毎回+(i * sizeof(struct bar))を計算する必要がありますが、2番目のループでは毎回pにsizeof(struct bar)を追加するだけです。乗算演算は、多くのプロセッサで追加するよりも多くのクロックサイクルを使用します。

ループ内でa [i]を複数回参照すると、本当に改善が見られます。一部のコンパイラはそのアドレスをキャッシュしないため、ループ内で複数回再計算される場合があります。

サンプルを更新して、構造体を使用し、複数の要素を参照してください。

11
tomlogic

最初の場合、コンパイラは配列のアドレス(最初の要素のアドレスでもある)を直接認識し、それにアクセスします。 2番目のケースでは、彼はポインターのアドレスを知っており、そのメモリー位置を指すポインターの値を読み取ります。これは実際には1つの追加の間接指定であるため、ここではおそらくより低速です。

8

何よりも、ループで速度が向上します。配列を使用するときは、増分するカウンターを使用します。位置を計算するために、システムはこのカウンターに配列要素のサイズを乗算し、最初の要素のアドレスを追加してアドレスを取得します。ポインターを使用すると、次の要素に移動するために必要なことは、すべての要素がメモリ内で互いに隣接していると仮定して、現在のポインターを要素のサイズだけ増やして次のものを取得することです。

このように、ポインター演算は、ループを実行するときの計算が少し少なくなります。また、配列内でインデックスを使用するよりも、正しい要素へのポインタを持っている方が高速です。

しかし、現代の開発では、多くのポインター操作が徐々に取り除かれています。プロセッサーはますます高速になり、配列はポインターよりも管理しやすくなっています。また、配列はコードのバグの量を減らす傾向があります。配列はインデックスチェックを許可し、配列外のデータにアクセスしていないことを確認します。

7
Wim ten Brink

Paxdiabloが言ったように、新しいコンパイラはどれも非常によく似ています。

さらに、配列がポインターよりも速い状況を見ました。これは、ベクトル演算を使用するDSPプロセッサ上にありました。

この場合、配列の使用はrestrictポインターの使用に似ていました。なぜなら、2つの配列を使用することで、コンパイラは暗黙的に同じ場所を指し示していないことを知っているからです。しかし、2つのポインターを扱う場合、コンパイラーは同じ場所を指していると考え、パイプのライニングをスキップします。

例えば:

int a[10],b[10],c[10];
int *pa=a, *pb=b, *pc=c;
int i;

// fill a and b.
fill_arrays(a,b);

// set c[i] = a[i]+b[i];
for (i = 0; i<10; i++)
{
   c[i] = a[i] + b[i];
}

// set *pc++ = *pa++ + *pb++;
for (i = 0; i<10; i++)
{
   *pc++ = *pa++ + *pb++;
}

ケース1では、コンパイラーはaとbを追加し、cに値を格納するパイプライン処理を簡単に行います。

ケース2では、コンパイラーはパイプラインを行いません。Cに保存するときにaまたはbを上書きしている可能性があるためです。

7
Yousf

ポインターは自然に単純な誘導変数を表現しますが、添え字は本質的に、より洗練されたコンパイラー最適化を必要とします


多くの場合、添え字付き式を使用するだけでは、問題に追加のレイヤーを追加する必要があります。下付き文字iをインクリメントするループは、ステートマシンである可能性があり、式a [i]は技術的には、使用されるたびに、iが各要素のサイズに乗算され、ベースアドレスに追加されます。

そのアクセスパターンを変換してポインターを使用するには、コンパイラーはループ全体を分析し、たとえば各要素がアクセスされていることを判断する必要があります。その後、コンパイラは、要素サイズで添え字を乗算する複数のインスタンスを、前のループ値の単純な増分で置き換えることができます。このプロセスは、 共通部分式の除去 および 誘導可変強度減少

ポインターで書き込む場合、プログラマーは通常、最初から配列をステップスルーするだけなので、最適化プロセス全体は必要ありません。

コンパイラが最適化を実行できる場合とできない場合があります。近年、洗練されたコンパイラーを手に入れるのがより一般的であるため、ポインターベースのコードは必ずしも高速ではありません

通常、アレーは連続している必要があるため、ポインターのもう1つの利点は、増分的に割り当てられた複合構造を作成することです。

7
DigitalRoss

これは非常に古い質問であり、回答済みであるため、回答する必要はありません!しかし、私は簡単な答えに気づかなかったので、答えを提供しています。

回答:間接アクセス(ポインター/配列)は(ベース)アドレスをロードするための命令を1つ追加する可能性がありますが、その後のすべてのアクセス(配列の場合は要素/構造体へのポインターの場合はメンバー)は1つの命令である必要があります既にロードされている(ベース)アドレスへのオフセットの単なる追加であるためです。したがって、ある意味では、直接アクセスと同じくらい良いものになるでしょう。そのため、ほとんどの場合、配列/ポインタを介したアクセスは同等であり、要素へのアクセスも変数への直接アクセスと同等です。

例10個の要素を持つ配列(またはポインター)または10個のメンバーを持つ構造体(構造体へのポインターを介してアクセスされる)があり、要素/メンバーにアクセスする場合、1つの可能な追加命令は最初に1回だけ必要です。すべての要素/メンバーアクセスは、その後の1つの命令のみである必要があります。

3
RcnRcf

ここで質問に対する良い回答を得ていますが、学習しているので、そのレベルでの効率はめったに目立たないことを指摘する価値があります。

最大のパフォーマンスを得るためにプログラムをチューニングするときは、少なくともプログラムの構造の大きな問題を見つけて修正することに注意を払う必要があります。それらが修正された後、低レベルの最適化はさらに違いを生むことができます。

これを行う方法の例を次に示します。

2
Mike Dunlavey

ポインター以前は配列よりも高速です。確かに、C言語が設計されたとき、ポインターはかなり高速でした。しかし、最近では、オプティマイザーは通常、配列がより制限されているため、ポインターを使用する場合よりも、配列を最適化するのに適しています。

最新のプロセッサの命令セットも、アレイアクセスの最適化に役立つように設計されています。

そのため、最近では、特にインデックス変数を使用したループで使用する場合、配列はより高速になることが多いということです。

もちろん、リンクされたリストのようなものにポインターを使用したいでしょうが、インデックス変数を使用するのではなく、配列をポインターで移動するという昔からの最適化は、最適化されていない可能性があります。

2
John Knoeller

0は定数として定義されているため、a [0]も定数であり、コンパイラーはコンパイル時にそれがどこにあるかを知っています。 「通常」の場合、コンパイラはベース+オフセットから要素アドレスを計算する必要があります(オフセットは要素サイズに従ってスケーリングされます)。

OTOH、pは変数であり、インダイレクションには追加の移動が必要です。

一般的に、配列インデックスはとにかくポインター演算として内部的に処理されるため、K&Rが作成しようとしていたポイントを確認することはできません。

1
filofel

ほとんどの人はすでに詳細な答えを出しているので、私は直感的な例を挙げます。配列とポインターを大規模に使用する場合、ポインターを使用する効率はより重要になります。たとえば、大きなlong intデータセットをいくつかのサブセットにソートしてソートし、それらをマージする場合。

long int * testData = calloc(N, sizeof(long int));

2017年の毎日の8G RAMマシンでは、Nを400000000に設定できます。これは、この元のデータセットに約1.5Gのメモリを使用することを意味します。 MPIを使用している場合、次を使用してデータをすばやく分離できます。

MPI_Scatterv(testData, partitionLength, partitionIndex, MPI_LONG, MPI_IN_PLACE, N/number_of_thread, MPI_LONG, 0, MPI_COMM_WORLD);

単純にparitionLengthを各同一部分の長さとしてN/number_of_threadを格納するポインターとして扱い、partitionIndexをインデックスを開始するN/number_of_threadsを格納するポインターとして扱うことができます。 4コアのCPUがあり、ジョブを4つのスレッドだけに分離するとします。 MPIは、参照によって迅速な意味で間違いなくジョブを実行します。しかし、配列を使用している場合、このルーチンは、最初にパーティションポイントを見つけるために配列でポインター演算を実行する必要があります。ポインタほど直接ではありません。また、分割されたデータセットをマージする場合、K-way mergeを使用して高速化することもできます。ソートされた4つのデータセットを保存するには、一時スペースが必要です。ここで、ポインターを使用する場合は、4つのアドレスのみを保存する必要があります。ただし、配列を使用する場合、4つのサブ配列全体が格納されるため、効率的ではありません。プログラムがスレッドセーフであることを確認するためにMPI_Barrierを使用していない場合、MPIはメモリの実装が悪いと不平を言うことさえあります。 8スレッドで400000000の長い値を配列メソッドとポインターメソッドでソートするための32Gマシンを取得し、それに応じて11.054980と13.182739を取得しました。また、サイズを1000000000に増やした場合、配列を使用している場合、ソートプログラムは正常に実行されません。これが、Cのスカラーを除くすべてのデータ構造にポインターを使用する理由です。

1
Lingbo Tang

「ポインターのバージョンは一般に高速です」ということは、ほとんどの場合、コンパイラーは、配列と添え字(コンパイラーが必要とすることを意味する)を持つよりも、ポインター(間接参照する必要がある)を持つより効率的なコードを生成しやすいことを意味します配列の先頭からアドレスをシフトします)。ただし、最新のプロセッサと最適化コンパイラでは、典型的な場合の配列アクセスはポインターアクセスより遅くありません。

具体的には、同じ結果を得るために、最適化をオンにする必要があります。

1
Vlad

配列の議論よりも速いptrに少し驚いています。最初はAbhijithからのasmコードによって、そうではないという証拠が得られました。

mov eax、dord ptr _a; // adress _aから直接値をロード

vs

mov eax、dword ptr _p; // pのアドレス/値をeaxにロードする

および

mov ecx、dword ptr [eax]; //ロードされたアドレスを使用して値にアクセスし、ecxに入れる

配列は固定アドレスを表すため、CPUは直接アクセスできますが、PTRを使用してCPUが値にアクセスするために逆参照する必要はありません。

配列オフセットを計算する必要があるため、コードの2番目のバッチは互換性がありません。これを行うには、ptrに対して少なくとも1/2の命令が必要になります。

コンパイラがコンパイル時に推測できるもの(固定アドレス、オフセットなど)は、パフォーマンスコードの鍵です。反復コードの比較と変数への割り当て:

配列:

; 2791:tmp = buf_ai [l];

mov eax, DWORD PTR _l$[ebp]
mov ecx, DWORD PTR _buf_ai$[ebp+eax*4]
mov DWORD PTR _tmp$[ebp], ecx

vs

[〜#〜] ptr [〜#〜]

; 2796:tmp2 = * p;

mov eax, DWORD PTR _p$[ebp]
mov ecx, DWORD PTR [eax]
mov DWORD PTR _tmp2$[ebp], ecx

プラス

; 2801:++ p;

mov eax, DWORD PTR _p$[ebp]
add eax, 4
mov DWORD PTR _p$[ebp], eax

Arrayを使用してアドレスを取得し、同時に値を取得するよりも、単にptrを使用してアドレスを使用するだけです。

宜しくお願いします

0
SwDev42