web-dev-qa-db-ja.com

関数ポインタはプログラムを遅くしますか?

私はCで関数ポインターについて読みました。そして、それは私のプログラムを遅くするだろうと誰もが言いました。本当ですか?

それをチェックするプログラムを作りました。そして、私は両方のケースで同じ結果を得ました。 (時間を測定します。)

では、関数ポインタを使用するのは悪いことですか?前もって感謝します。

一部の人に対応するため。ループで比較している間、「実行速度が遅い」と言いました。このような:

int end = 1000;
int i = 0;

while (i < end) {
 fp = func;
 fp ();
}

あなたがこれを実行するとき、私がこれを実行すれば私は同じ時間を得ました。

while (i < end) {
 func ();
}

したがって、関数ポインタには時間の差がなく、多くの人々が言っ​​たようにプログラムの実行が遅くなることはないと思います。

53
drigoSkalWalker

サイクルの中で何度も関数を繰り返し呼び出すなど、パフォーマンスの観点から実際に重要な状況では、パフォーマンスにまったく違いがない場合があります。

これは、「機械語」がC言語自体を厳密に反映する抽象的なCマシンによって実行されるものとしてCコードを考えることに慣れている人々には奇妙に聞こえるかもしれません。このようなコンテキストでは、「デフォルトでは」関数の間接呼び出しは、呼び出しのターゲットを決定するために追加のメモリアクセスを正式に伴うため、直接呼び出しよりも実際に遅くなります。

ただし、実際には、コードは実際のマシンによって実行され、基盤となるマシンアーキテクチャについてかなりの知識を持つ最適化コンパイラーによってコンパイルされます。これにより、特定のマシンに最適なコードを生成できます。また、多くのプラットフォームでは、サイクルから関数呼び出しを実行する最も効率的な方法は、直接呼び出しと間接呼び出しの両方で実際にidenticalコードとなり、2つの呼び出しのパフォーマンスが同じになることがわかります。

たとえば、x86プラットフォームについて考えてみます。直接的および間接的な呼び出しを「文字通り」マシンコードに変換すると、次のような結果になる可能性があります

// Direct call
do-it-many-times
  call 0x12345678

// Indirect call
do-it-many-times
  call dword ptr [0x67890ABC]

前者は機械命令で即値オペランドを使用し、実際には通常、後者よりも高速です。後者は、いくつかの独立したメモリロケーションからデータを読み取る必要があります。

この時点で、x86アーキテクチャでは実際にcall命令にオペランドを提供する方法がもう1つあることを覚えておいてください。 registerでターゲットアドレスを提供しています。そして、このフォーマットについての非常に重要なことは、それが通常上記の両方より速いであることです。これは私たちにとって何を意味しますか?これは、優れた最適化コンパイラがその事実を利用する必要があり、利用することを意味します。上記のサイクルを実装するために、コンパイラはbothの場合にレジスタを介した呼び出しを使用しようとします。成功した場合、最終的なコードは次のようになります。

// Direct call

mov eax, 0x12345678

do-it-many-times
  call eax

// Indirect call

mov eax, dword ptr [0x67890ABC]

do-it-many-times
  call eax

重要な部分-サイクル本体の実際の呼び出し-は、どちらの場合もまったく同じです。言うまでもなく、パフォーマンスはほぼ同じになります。

奇妙に聞こえるかもしれませんが、このプラットフォームでは、直接呼び出し(callに即値オペランドを持つ呼び出し)は、間接呼び出しよりも遅い間接呼び出しのオペランドはレジスタで提供されます(メモリに格納されるのではありません)。

もちろん、すべてが一般的な場合ほど簡単ではありません。コンパイラーは、レジスターの使用可能性の制限、エイリアスの問題などに対処する必要があります。ただし、上記の最適化は優れたコンパイラーによって実行され、完全に排除されます。周期的直接呼び出しと周期的間接呼び出しのパフォーマンスの違い。この最適化は、仮想関数を呼び出すときにC++で特にうまく機能します。これは、一般的な実装では、関連するポインターがコンパイラーによって完全に制御され、エイリアシングの図やその他の関連事項についての完全な知識が得られるためです。

もちろん、コンパイラがそのようなことを最適化するのに十分スマートであるかどうかという疑問は常にあります...

82
AnT

これは、関数ポインターを使用するとコンパイラーの最適化(インライン化)とプロセッサーの最適化(分岐予測)が妨げられる可能性があることを示していると私は思います。ただし、関数ポインターがあなたがやろうとしていることを達成するための効果的な方法である場合、それを行う他の方法には同じ欠点がある可能性があります。

また、パフォーマンスクリティカルなアプリケーションまたは非常に遅い組み込みシステムで関数ループがタイトループで使用されていない限り、その違いはとにかく無視できる可能性があります。

25
Tyler McHenry

多くの人が良い答えを出していますが、まだ見逃している点があると思います。関数ポインタは、いくつかのサイクルを遅くする余分な逆参照を追加します。その数は、不十分な分岐予測に基づいて増加する可能性があります(偶然に、関数ポインタ自体とはほとんど関係ありません)。さらに、ポインターを介して呼び出される関数はインライン化できません。しかし、欠けているのは、ほとんどの人が関数ポインタを最適化として使用していることです。

C/c ++ APIで関数ポインターを見つける最も一般的な場所は、コールバック関数としてです。非常に多くのAPIがこれを行う理由は、イベントが発生するたびに関数ポインターを呼び出すシステムを作成する方が、メッセージパッシングなどの他のメソッドよりもはるかに効率的だからです。個人的には、関数ポインターをより複雑な入力処理システムの一部として使用しました。キーボードの各キーには、ジャンプテーブルを介して関数ポインターがマッピングされています。これにより、入力システムから分岐やロジックを削除し、入力されたキーを処理するだけで済みました。

9
Beanz

そして、誰もがそれが私のプログラムを遅くするだろうと言った。本当ですか?

ほとんどの場合、この主張は誤りです。まず、関数ポインタを使用する代わりに、

if (condition1) {
        func1();
} else if (condition2)
        func2();
} else if (condition3)
        func3();
} else {
        func4();
}

これはおそらく、単一の関数ポインタを使用するよりも比較的遅いです。ポインターを介して関数を呼び出すと、ある程度の(通常は無視できる)オーバーヘッドが発生しますが、比較に関連するのは通常、直接関数呼び出しとポインター経由呼び出しの違いではありません。

次に、測定なしでパフォーマンスを最適化しないでください。ボトルネックがどこにあるかを知ることは非常に困難であり(read impossible)、時にはこれが直感的でない場合があります(たとえば、Linuxカーネル開発者が関数からinlineキーワードを削除し始めた実際にパフォーマンスが低下するためです)。

9
hlovdal

前の呼び出しには追加のポインター逆参照が含まれているため、関数ポインターを介して関数を呼び出すと、静的関数呼び出しよりやや遅くなります。しかし、私の知る限り、この違いはほとんどの最新のマシンでは無視できる程度です(リソースが非常に限られている一部の特別なプラットフォームを除く)。

関数ポインターが使用されるのは、プログラムをはるかにシンプル、クリーン、および保守しやすくするためです(もちろん、適切に使用した場合)。これは、考えられる非常に小さな速度差を補う以上のものです。

8
Péter Török

関数ポインターを使用すると、関数を呼び出すだけの場合よりも遅くなります。 (関数のメモリアドレスを取得するには、ポインターを逆参照する必要があります)。それは遅いですが、プログラムが実行する他のすべてのことと比較して(ファイルの読み取り、コンソールへの書き込み)、無視することができます。

関数ポインターを使用する必要がある場合は、それらを使用してください。同じことを実行しようとしても使用を避けようとすると、関数ポインターを使用する場合よりも遅くなり、保守性も低下します。

7
Yacoby

以前の返信には多くの良い点があります。

ただし、C qsort比較関数を見てください。比較関数はインライン化できず、標準のスタックベースの呼び出し規則に従う必要があるため、ソートの合計実行時間は、整数キーの場合桁数(より正確には3-10x)遅くなる可能性があります。それ以外の場合は、直接のインライン化可能な呼び出しを持つ同じコード。

典型的なインライン化された比較は、単純なCMPとおそらくCMOV/SET命令のシーケンスです。関数呼び出しは、CALLのオーバーヘッド、スタックフレームの設定、比較の実行、スタックフレームの破棄と結果の返却も行います。スタック操作は、CPUパイプラインの長さと仮想レジスターが原因でパイプラインの停止を引き起こす可能性があることに注意してください。たとえば、最後に変更されたeaxの実行が完了する前に、eayの値が必要な場合(通常、最新のプロセッサでは約12クロックサイクルかかります)。 CPUが他の命令を実行してそれを待つことができない限り、パイプラインストールが発生します。

6
user267027

たぶん。

答えは、関数ポインターが何のために使用されているか、したがって代替案が何であるかによって異なります。プログラムロジックの一部であり、単に削除できない選択を実装するために関数ポインターが使用されている場合、関数ポインター呼び出しを直接の関数呼び出しと比較すると誤解を招きます。私は先に進みますが、それでもその比較を示し、後でこの考えに戻ります。

関数ポインタ呼び出しは、インライン化を禁止する場合、直接関数呼び出しと比較してパフォーマンスを低下させる可能性が最も高くなります。インライン化はゲートウェイの最適化であるため、関数ポインタが同等の直接関数呼び出しよりも任意に遅くなる、非常に病理学的なケースを作成できます。

_void foo(int* x) {
    *x = 0;
}

void (*foo_ptr)(int*) = foo;

int call_foo(int *p, int size) {
    int r = 0;
    for (int i = 0; i != size; ++i)
        r += p[i];
    foo(&r);
    return r;
}

int call_foo_ptr(int *p, int size) {
    int r = 0;
    for (int i = 0; i != size; ++i)
        r += p[i];
    foo_ptr(&r);
    return r;
}
_

コード生成 for call_foo()

_call_foo(int*, int):
  xor eax, eax
  ret
_

いいね。 foo()はインライン化されただけでなく、そうすることで、コンパイラーは先行するループ全体を排除することができました!生成されたコードは、レジスターとXORを実行して戻りレジスターをゼロに設定してから戻ります。一方、コンパイラはcall_foo_ptr()(gcc 7.3で100行以上)でループのコードを生成する必要があり、そのコードのほとんどは事実上何もしません(_foo_ptr_がまだ指している限り) foo())。 (より一般的なシナリオでは、小さな関数をホットインナーループにインライン化すると、実行時間を最大で約1桁短縮できると予想できます。)

したがって、最悪のシナリオでは、関数ポインターの呼び出しは、直接の関数呼び出しよりも任意に遅くなりますが、これは誤解を招きます。 _foo_ptr_がconstであった場合、call_foo()call_foo_ptr()は同じコードを生成することがわかりました。ただし、これには_foo_ptr_によって提供される間接参照の機会を放棄する必要があります。 _foo_ptr_がconstになるのは「公正」ですか? _foo_ptr_によって提供される間接参照に関心がある場合は、そうではありませんが、そうであれば、直接の関数呼び出しも有効なオプションではありません。

便利な間接参照を提供するために関数ポインターが使用されている場合は、間接参照を移動したり、場合によっては関数ポインターを条件付きまたはマクロ用にスワップアウトしたりできますが、単純に削除することはできません。関数ポインターが適切なアプローチであると判断したが、パフォーマンスが問題である場合は、通常、呼び出しループを間接参照に引き上げて、外部ループでの間接参照のコストを支払う必要があります。たとえば、関数がコールバックを受け取り、それをループで呼び出す一般的なケースでは、最も内側のループをコールバックに移動してみます(それに応じて各コールバック呼び出しの責任を変更します)。

0
Praxeolitic