39

私は、Cでは、ポインター演算を使用する方が、配列アクセスの添え字よりも一般的に高速であることを読み続けています。これは、最新の(おそらく最適化されている)コンパイラーでも当てはまりますか?

もしそうなら、私がCの学習からObjective-CとMacのCocoaに移行し始めたとき、これはまだ当てはまりますか?

CとObjective-Cの両方で、配列アクセスに適したコーディングスタイルはどれですか?(それぞれの言語の専門家によって)より読みやすく、より「正しい」(より良い用語がないため)と見なされるのはどれですか?

4

11 に答える 11

76

この主張の背後にある理由を理解する必要があります。なぜ速いのか疑問に思ったことはありますか?いくつかのコードを比較してみましょう:

int i;
int a[20];

// Init all values to zero
memset(a, 0, sizeof(a));
for (i = 0; i < 20; i++) {
    printf("Value of %d is %d\n", i, a[i]);
}

それらはすべてゼロです、なんと驚きです:-P問題は、a[i]低レベルのマシンコードで実際に何を意味するのかということです。その意味は

  1. メモリ内のアドレスを取得aします。

  2. iそのアドレスにの単一アイテムのサイズの倍を追加aします(intは通常4バイトです)。

  3. そのアドレスから値を取得します。

したがって、から値をフェッチするたびaに、のベースアドレスが4aの乗算の結果に追加されiます。ポインタを逆参照するだけの場合は、ステップ1と2を実行する必要はなく、ステップ3だけを実行します。

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

int i;
int a[20];
int * b;

memset(a, 0, sizeof(a));
b = a;
for (i = 0; i < 20; i++) {
    printf("Value of %d is %d\n", i, *b);
    b++;
}

このコードはもっと速いかもしれません...しかし、たとえそうだとしても、違いはごくわずかです。なぜもっと速いのでしょうか?「*b」は上記の手順3と同じです。ただし、「b++」はステップ1およびステップ2と同じではありません。「b++」はポインターを4つ増やします。

初心者にとって重要:ポインタで実行++ しても、メモリ内のポインタは1バイト増加しません!ポインタが指すデータのサイズと同じ数のメモリ内のポインタが増加します。これは、を指しintintは4バイトです。私のマシンなので、b ++はbを4つ増やします!)

わかりましたが、なぜもっと速いのでしょうか?なぜなら、ポインターに4を加算する方が、4を乗算iしてそれをポインターに加算するよりも高速だからです。どちらの場合も加算がありますが、2番目の場合は乗算がありません(1回の乗算に必要なCPU時間を回避できます)。最近のCPUの速度を考えると、配列が1 mio要素であったとしても、実際に違いをベンチマークできるかどうか疑問に思います。

最新のコンパイラがどちらかを同等に高速に最適化できることは、生成されるアセンブリ出力を確認することで確認できます。これを行うには、「-S」オプション(大文字のS)をGCCに渡します。

最初のCコードのコードは次のとおりです(最適化レベル-Osが使用されています。これは、コードサイズと速度を最適化することを意味しますが、コードサイズを著しく増加させる速度最適化は行わないでください-O2-O3

_main:
    pushl   %ebp
    movl    %esp, %ebp
    pushl   %edi
    pushl   %esi
    pushl   %ebx
    subl    $108, %esp
    call    ___i686.get_pc_thunk.bx
"L00000000001$pb":
    leal    -104(%ebp), %eax
    movl    $80, 8(%esp)
    movl    $0, 4(%esp)
    movl    %eax, (%esp)
    call    L_memset$stub
    xorl    %esi, %esi
    leal    LC0-"L00000000001$pb"(%ebx), %edi
L2:
    movl    -104(%ebp,%esi,4), %eax
    movl    %eax, 8(%esp)
    movl    %esi, 4(%esp)
    movl    %edi, (%esp)
    call    L_printf$stub
    addl    $1, %esi
    cmpl    $20, %esi
    jne L2
    addl    $108, %esp
    popl    %ebx
    popl    %esi
    popl    %edi
    popl    %ebp
    ret

2番目のコードと同じ:

_main:
    pushl   %ebp
    movl    %esp, %ebp
    pushl   %edi
    pushl   %esi
    pushl   %ebx
    subl    $124, %esp
    call    ___i686.get_pc_thunk.bx
"L00000000001$pb":
    leal    -104(%ebp), %eax
    movl    %eax, -108(%ebp)
    movl    $80, 8(%esp)
    movl    $0, 4(%esp)
    movl    %eax, (%esp)
    call    L_memset$stub
    xorl    %esi, %esi
    leal    LC0-"L00000000001$pb"(%ebx), %edi
L2:
    movl    -108(%ebp), %edx
    movl    (%edx,%esi,4), %eax
    movl    %eax, 8(%esp)
    movl    %esi, 4(%esp)
    movl    %edi, (%esp)
    call    L_printf$stub
    addl    $1, %esi
    cmpl    $20, %esi
    jne L2
    addl    $124, %esp
    popl    %ebx
    popl    %esi
    popl    %edi
    popl    %ebp
    ret

まあ、それは違います、それは確かです。104と108の数の違いは、変数に起因しますb(最初のコードでは、スタック上に変数が1つ少なくなりましたが、スタックアドレスを変更するためにもう1つあります)。forループ内の実際のコードの違いは

movl    -104(%ebp,%esi,4), %eax

に比べ

movl    -108(%ebp), %edx
movl    (%edx,%esi,4), %eax

実際、私には、最初のアプローチの方が速いように見えます(!)。これは、2つのマシンコードではなく、1つのCPUマシンコードを発行してすべての作業を実行するためです(CPUがすべてを実行します)。一方、以下の2つのアセンブリコマンドは、上記のコマンドよりも実行時間が全体的に短くなる可能性があります。

最後に、コンパイラとCPUの機能(CPUがどのような方法でメモリにアクセスするために提供するコマンド)に応じて、結果はどちらの方法でもかまいません。どちらかが速い/遅いかもしれません。正確に1つのコンパイラ(つまり1つのバージョン)と1つの特定のCPUに制限しない限り、確実に言うことはできません。CPUは単一のアセンブリコマンドでますます多くのことを実行できるため(昔は、コンパイラは実際にアドレスを手動でフェッチし、i4を掛けて、値をフェッチする前に両方を加算する必要がありました)、昔は絶対的な真実であったステートメントは次のとおりです。今日、ますます疑わしい。また、CPUが内部でどのように機能するかを誰が知っていますか?上記では、1つの組み立て手順を他の2つの手順と比較しています。

命令の数が異なり、そのような命令が必要な時間も異なる可能性があることがわかります。また、これらの命令がマシンのプレゼンテーションで必要とするメモリの量も異なります(結局、メモリからCPUキャッシュに転送する必要があります)。ただし、最近のCPUは、フィードした方法で命令を実行しません。大きな命令(CISCと呼ばれることが多い)を小さなサブ命令(RISCと呼ばれることが多い)に分割します。これにより、プログラムフローを内部で速度を上げるために最適化することもできます。実際、以下の最初の単一の命令と他の2つの命令は、同じサブ命令のセットになる可能性があります。その場合、測定可能な速度の違いはまったくありません。

Objective-Cに関しては、拡張機能を備えたCだけです。したがって、Cに当てはまるものはすべて、ポインターと配列に関してObjective-Cにも当てはまります。一方、オブジェクト(たとえば、NSArrayまたはNSMutableArray)を使用する場合、これは完全に異なる獣です。ただし、その場合は、とにかくメソッドを使用してこれらの配列にアクセスする必要があります。選択できるポインタ/配列アクセスはありません。

于 2008-10-24T11:31:32.263 に答える
10

「ポインタ演算の使用は、通常、配列アクセスの添え字よりも高速です」

いや。どちらにしても同じ操作です。添字は、配列の開始アドレスに(要素サイズ*インデックス)を追加するための構文糖衣です。

とはいえ、配列内の要素を反復処理する場合、最初の要素へのポインターを取得し、ループを通過するたびにそれを増やすと、通常、ループ変数から現在の要素の位置を毎回計算するよりもわずかに速くなります。(これが実際のアプリケーションで非常に重要になることはまれですが、最初にアルゴリズムを調べてください。時期尚早の最適化はすべての悪の根源などです)

于 2008-10-24T11:31:56.753 に答える
5

これは、実行速度に関する質問に答えられないため、少し話題から外れている可能性があります(申し訳ありません)が、時期尚早の最適化がすべての悪の根源であることを考慮する必要があります(Knuth)。私の意見では、特にまだ言語を(再)学習しているときは、必ず最初に読むのが最も簡単な方法でそれを書いてください。次に、プログラムが正しく実行されている場合は、速度の最適化を検討してください。ほとんどの場合、コーディングはとにかく十分に高速になります。

于 2008-10-24T12:02:20.033 に答える
4

メッキーは素晴らしい説明をしています。私の経験から言うと、インデックス作成とポインターでよく問題になることの 1 つは、他のコードがループ内にあることです。例:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <iostream>

using namespace std;

typedef int64_t int64;
static int64 nsTime() {
  struct timespec tp;
  clock_gettime(CLOCK_REALTIME, &tp);
  return tp.tv_sec*(int64)1000000000 + tp.tv_nsec;
}

typedef int T;
size_t const N = 1024*1024*128;
T data[N];

int main(int, char**) {
  cout << "starting\n";

  {
    int64 const a = nsTime();
    int sum = 0;
    for (size_t i=0; i<N; i++) {
      sum += data[i];
    }
    int64 const b = nsTime();
    cout << "Simple loop (indexed): " << (b-a)/1e9 << "\n";
  }

  {
    int64 const a = nsTime();
    int sum = 0;
    T *d = data;
    for (size_t i=0; i<N; i++) {
      sum += *d++;
    }
    int64 const b = nsTime();
    cout << "Simple loop (pointer): " << (b-a)/1e9 << "\n";
  }

  {
    int64 const a = nsTime();
    int sum = 0;
    for (size_t i=0; i<N; i++) {
      int a = sum+3;
      int b = 4-sum;
      int c = sum+5;
      sum += data[i] + a - b + c;
    }
    int64 const b = nsTime();
    cout << "Loop that uses more ALUs (indexed): " << (b-a)/1e9 << "\n";
  }

  {
    int64 const a = nsTime();
    int sum = 0;
    T *d = data;
    for (size_t i=0; i<N; i++) {
      int a = sum+3;
      int b = 4-sum;
      int c = sum+5;
      sum += *d++ + a - b + c;
    }
    int64 const b = nsTime();
    cout << "Loop that uses more ALUs (pointer): " << (b-a)/1e9 << "\n";
  }
}

高速な Core 2 ベースのシステム (g++ 4.1.2、x64) では、タイミングは次のとおりです。

    単純なループ (インデックス付き): 0.400842
    単純なループ (ポインター): 0.380633
    より多くの ALU を使用するループ (インデックス付き): 0.768398
    より多くの ALU (ポインター) を使用するループ: 0.777886

索引付けの方が速い場合もあれば、ポインター演算の方が速い場合もあります。これは、CPU とコンパイラがループ実行をどのようにパイプライン処理できるかに依存します。

于 2008-10-24T12:25:43.857 に答える
3

スーパースカラーCPUなどでマシンコードを見ても実行速度は予測しにくいので注意してください。

  • 故障した
  • パイプライン
  • 分岐予測
  • ハイパースレッディング
  • ..。

機械の命令を数えるだけでなく、時計の針を数えるだけでもありません。本当に必要な場合に測定するだけの方が簡単なようです。与えられたプログラムの正しいサイクル数を計算することは不可能ではありませんが(私たちは大学でそれをしなければなりませんでした)、それはほとんど楽しくなく、正しくするのは難しいです。補足:マルチスレッド/マルチプロセッサ環境では、正しく測定することも困難です。

于 2008-10-24T12:18:38.660 に答える
3

配列型のデータを扱っている場合、添え字を使用するとコードが読みやすくなると思います。今日のマシンでは(特にこのような単純なものの場合)、読み取り可能なコードがより重要です。

ここで、malloc()したデータのチャンクを明示的に処理していて、そのデータ内にポインターを取得したい場合、たとえばオーディオファイルヘッダー内に20バイトを取得したい場合、アドレス演算はあなたが何であるかをより明確に表現すると思いますやろうとしています。

この点でコンパイラの最適化についてはよくわかりませんが、添え字が遅い場合でも、せいぜい数クロックサイクルだけ遅くなります。あなたが思考の列の明晰さからこれほど多くを得ることができるとき、それはほとんど何でもありません。

編集:これらの他の応答のいくつかによると、添え字は単なる合成要素であり、私が考えたようにパフォーマンスに影響を与えません。その場合は、ポインターが指すブロック内のアクセスデータを介して表現しようとしているコンテキストに必ず合わせてください。

于 2008-10-24T11:31:54.803 に答える
1

それは真実ではない。添え字演算子を使用した場合とまったく同じ速度です。Objective-Cでは、Cやオブジェクト指向スタイルのように配列を使用できます。オブジェクト指向スタイルは、呼び出しの動的な性質により、すべての呼び出しでいくつかの操作を行うため、オブジェクト指向スタイルは非常に遅くなります。

于 2008-10-24T11:32:01.683 に答える
1
char p1[ ] = "12345";
char* p2 = "12345";

char *ch = p1[ 3 ]; /* 4 */
ch = *(p2 + 3); /* 4 */

C 標準では、どちらが速いかはわかりません。観察可能な動作は同じであり、必要な方法で実装するのはコンパイラ次第です。多くの場合、メモリをまったく読み取れません。

一般に、コンパイラ、バージョン、アーキテクチャ、およびコンパイル オプションを指定しない限り、どちらが「速い」とは言えません。それでも、最適化は周囲のコンテキストに依存します。

したがって、一般的なアドバイスは、より明確で単純なコードを提供するものを使用することです。array[ i ] を使用すると、一部のツールでインデックスが範囲外の条件を試行および検出できるようになるため、配列を使用している場合は、それらをそのように扱うことをお勧めします。

重要な場合は、コンパイラが生成するアセンブラを調べてください。ただし、それを囲むコードを変更すると、変更される可能性があることに注意してください。

于 2008-10-24T11:44:20.150 に答える
1

いいえ、ポインタ演算の使用は高速ではなく、おそらく低速です。これは、最適化コンパイラが Intel プロセッサの LEA (Load Effective Address) などの命令をポインタ演算に使用し、add または add/mul よりも高速であるためです。一度にいくつかのことを実行し、フラグに影響を与えないという利点があり、計算にも 1 サイクルかかります。ところで、以下はGCCマニュアルからのものです。その-Osため、主に速度を最適化するわけではありません。

私もthemarkoに完全に同意します。最初に、クリーンで読みやすく再利用可能なコードを作成してから、最適化について考え、いくつかのプロファイリング ツールを使用してボトルネックを見つけます。ほとんどの場合、パフォーマンスの問題は I/O 関連か、何らかのアルゴリズムの誤りか、追跡しなければならない何らかのバグです。クヌースは男です;-)

構造体配列で何をするのだろうと思いました。ポインター演算を実行する場合は、構造体の各メンバーに対して必ず実行する必要があります。それはやり過ぎのように聞こえますか?はい、もちろんやり過ぎですし、バグを隠す大きな扉を開きます。

-Osサイズを最適化します。Os通常はコード サイズを増加させない O2 最適化をすべて有効にします。また、コード サイズを削減するために設計されたさらなる最適化も実行します。

于 2008-10-24T12:32:34.683 に答える
0

速度に違いが生じる可能性はほとんどありません。

配列演算子[]を使用することをお勧めします。これは、C ++の場合と同じ構文を他のコンテナー(ベクトルなど)で使用できるためです。

于 2008-10-24T11:33:12.883 に答える
0

私は10 年間、いくつかのAAAタイトルの C++/アセンブリの最適化に取り組んできましたが、私が取り組んできた特定のプラットフォーム/コンパイラでは、ポインタ演算によってかなり大きな違いが生じたと言えます。

物事を大局的に見るための例として、私はすべての配列アクセスをポインター演算に置き換えることで、パーティクル ジェネレーターの非常にタイトなループを 40% 速くすることができ、同僚は完全に信じられませんでした。昔、私の先生の 1 人から良いトリックとして聞いたことがありますが、現在のコンパイラ/CPU との違いはないと思いました。私は間違っていた ;)

コンソールARMプロセッサの多くは、最新のCISC CPU の優れた機能をすべて備えているわけではなく、コンパイラが少し不安定な場合があることを指摘しておく必要があります。

于 2016-03-01T22:12:18.580 に答える