多くの文献は、インライン関数を使用して「関数呼び出しのオーバーヘッドを回避する」ことについて語っています。しかし、定量化可能なデータは見ていません。関数呼び出しの実際のオーバーヘッドとは何ですか?つまり、関数をインライン化することでどのようなパフォーマンスの向上を達成しますか?
ほとんどのアーキテクチャでは、コストはレジスタのすべて(または一部、またはなし)をスタックに保存し、関数の引数をスタックにプッシュ(またはレジスタに配置)し、スタックポインタをインクリメントし、新しいコード。次に、関数が完了したら、スタックからレジスタを復元する必要があります。 このWebページ には、さまざまな呼び出し規約に関係する内容の説明があります。
現在、ほとんどのC++コンパイラは、関数をインライン化するのに十分スマートです。 inlineキーワードは、コンパイラーへの単なるヒントです。一部の翻訳ユニットは、翻訳ユニットが有用であると判断した場合にインライン化を行います。
技術的および実用的な答えがあります。実際的な答えは、それは決して重要ではないということであり、非常にまれなケースでは、実際にプロファイルされたテストを介してのみ知ることができます。
あなたの文献が参照している技術的な答えは、コンパイラの最適化のために一般的には関係ありません。しかし、あなたがまだ興味があるなら、 Josh で十分に説明されています。
「パーセンテージ」に関しては、関数自体がどれほど高価であるかを知る必要があります。呼び出された関数のコスト以外は、ゼロコスト操作と比較しているため、パーセンテージはありません。インラインコードの場合、コストはかかりません。プロセッサは次の命令に移動します。インリングのマイナス面は、コードのサイズが大きいことです。これは、スタックの構築/分解コストとは異なる方法でコストが発生することを示しています。
単純な増分関数に対して単純なベンチマークを作成しました。
inc.c:
typedef unsigned long ulong;
ulong inc(ulong x){
return x+1;
}
main.c
#include <stdio.h>
#include <stdlib.h>
typedef unsigned long ulong;
#ifdef EXTERN
ulong inc(ulong);
#else
static inline ulong inc(ulong x){
return x+1;
}
#endif
int main(int argc, char** argv){
if (argc < 1+1)
return 1;
ulong i, sum = 0, cnt;
cnt = atoi(argv[1]);
for(i=0;i<cnt;i++){
sum+=inc(i);
}
printf("%lu\n", sum);
return 0;
}
私のIntel(R)Core(TM)i5 CPU M 430 @ 2.27GHzで10億回の反復を実行しました:
(最大0.2まで変動するようですが、適切な標準偏差を計算するのが面倒ですし、気にしません)
これは、このコンピューターでの関数呼び出しのオーバーヘッドが約ナノ秒であることを示唆しています。
私がそれで何かを測定した最速は約0.3nsだったので、非常に単純に言えば、関数呼び出しコストが9プリミティブopsであることが示唆されます。
このオーバーヘッドは、PLTを介して呼び出される関数(共有ライブラリー内の関数)の場合、呼び出しごとに約2ns(合計呼び出し時間6ns)程度増加します。
あなたの質問は質問の1つです。「絶対的な真実」と呼ぶことのできる答えはありません。通常の関数呼び出しのオーバーヘッドは、次の3つの要因に依存します。
CPU。 x86、PPC、およびARM CPUのオーバーヘッドは大きく異なります。1つのアーキテクチャにとどまる場合でも、オーバーヘッドはIntel Pentium 4、Intel Core 2 DuoとIntel Core i7。キャッシュサイズ、キャッシュアルゴリズム、メモリアクセスパターン、コールオペコードの実際のハードウェア実装などの要因があるため、両方が同じクロック速度で動作する場合でも、Intel CPUとAMD CPUの間でオーバーヘッドが著しく異なる場合があります。それ自体がオーバーヘッドに大きな影響を与える可能性があります。
ABI(アプリケーションバイナリインターフェイス)。同じCPUであっても、関数呼び出しがパラメーターを渡す方法(レジスタ、スタック、または両方の組み合わせ)と、スタックフレームの初期化とクリーンアップが行われる場所と方法を指定するさまざまなABIが存在することがよくあります。これはすべてオーバーヘッドに影響します。異なるオペレーティングシステムは、同じCPUに対して異なるABIを使用する場合があります。例えばLinux、Windows、およびSolarisはすべて、同じCPUに対して異なるABIを使用する場合があります。
コンパイラ。 ABIに厳密に従うことは、独立したコードユニット間で関数が呼び出される場合にのみ重要です。アプリケーションがシステムライブラリの関数を呼び出すか、ユーザーライブラリが別のユーザーライブラリの関数を呼び出す場合。関数が「プライベート」であり、特定のライブラリまたはバイナリの外部から見えない限り、コンパイラは「チート」する可能性があります。 ABIに厳密に従うことはできませんが、代わりに、より高速な関数呼び出しにつながるショートカットを使用します。例えば。スタックを使用する代わりにレジスタでパラメーターを渡すか、本当に必要でない場合は、スタックフレームのセットアップとクリーンアップを完全にスキップします。
上記の3つの要素の特定の組み合わせのオーバーヘッドを知りたい場合は、 GCCを使用するLinux上のIntel Core i5の場合、この情報を取得する唯一の方法は、関数呼び出しを使用する場合と呼び出し元にコードを直接コピーする場合の2つの実装の違いをベンチマークすることです。インラインステートメントは単なるヒントであり、常にインライン化につながるとは限らないため、この方法では確実にインライン化を強制します。
ただし、ここでの本当の質問は、正確なオーバーヘッドは本当に重要なのでしょうか? 1つ確かなことは、関数呼び出しには常にオーバーヘッドがあることです。小さくても大きくてもかまいませんが、確かに存在します。また、パフォーマンスクリティカルセクションで関数が頻繁に呼び出される場合、それがどんなに小さくても、オーバーヘッドはある程度問題になります。インライン化によってコードが遅くなることはめったにありません。ただし、コードが大きくなります。今日のコンパイラは、インライン化するタイミングとインライン化しないタイミングを決定するのに非常に優れているため、頭を悩ませる必要はほとんどありません。
個人的には、プロファイリングによって特定の機能が実際に頻繁に呼び出され、アプリケーションのパフォーマンスクリティカルセクション内で呼び出される場合にのみ、プロファイルできる使用可能な製品ができるまで、開発中のインライン化を完全に無視します。この関数の「強制インライン化」を検討してください。
これまでのところ、私の答えは非常に一般的で、C++とObjective-Cに適用されるのと同じくらいCにも適用されます。最後の言葉として、特にC++についてお話します。仮想メソッドは二重間接関数呼び出しです。つまり、通常の関数呼び出しよりも関数呼び出しのオーバーヘッドが大きく、インライン化することもできません。非仮想メソッドはコンパイラーによってインライン化される場合とそうでない場合がありますが、インライン化されていない場合でも、仮想メソッドよりも大幅に高速であるため、メソッドをオーバーライドするかオーバーライドしない限り、メソッドを仮想化しないでください。
オーバーヘッドの量は、コンパイラ、CPUなどによって異なります。オーバーヘッドの割合は、インライン化するコードによって異なります。知る唯一の方法はコードを取り、両方の方法でプロファイリングすることです-それが決定的な答えがない理由です。
関数呼び出しの(小さな)コストは、関数本体の(非常に小さな)コストに比べて大きいため、非常に小さな関数のインライン化は理にかなっています。数行にわたるほとんどの機能では、大きな勝利にはなりません。
インライン関数は呼び出し関数のサイズを大きくし、関数のサイズを大きくするとキャッシュに悪影響を与える可能性があることに注意してください。境界に近い場合、インラインコードの「もう1つだけ薄いウェーハ」がパフォーマンスに劇的な悪影響を与える可能性があります。
「関数呼び出しのコスト」について警告している文献を読んでいるなら、現代のプロセッサを反映していない古い資料かもしれません。あなたが組み込みの世界にいない限り、Cが「ポータブルアセンブリ言語」である時代は本質的に過ぎ去りました。過去10年間のチップ設計者の多大な工夫(たとえば)は、「昔」のやり方とは根本的に異なる、あらゆる種類の低レベルの複雑さをもたらしています。
最近のCPUは非常に高速です(明らかに!)。呼び出しと引数の受け渡しに関連するほぼすべての操作は、フルスピードの命令です(間接呼び出しは、ほとんどの場合ループを介して初めて、少し高価になる可能性があります)。
関数呼び出しのオーバーヘッドは非常に小さいため、関数を呼び出すループだけが呼び出しオーバーヘッドを関連させることができます。
したがって、今日の関数呼び出しのオーバーヘッドについて話す(そして測定する)ときは、通常、ループから一般的な部分式を巻き上げることができないというオーバーヘッドについて実際に話します。関数が呼び出されるたびに一連の(同一の)作業を行わなければならない場合、コンパイラーはループから「巻き上げ」、インライン化されていれば一度実行することができます。インライン化されていない場合、コードはおそらく先に進み、作業を繰り返すでしょう、とあなたは言いました!
インライン関数は、呼び出しと引数のオーバーヘッドのためではなく、関数から巻き上げられる一般的な部分式のために、信じられないほど高速に見えます。
例:
Foo::result_type MakeMeFaster()
{
Foo t = 0;
for (auto i = 0; i < 1000; ++i)
t += CheckOverhead(SomethingUnpredictible());
return t.result();
}
Foo CheckOverhead(int i)
{
auto n = CalculatePi_1000_digits();
return i * n;
}
オプティマイザーはこの愚かさを見て、以下を実行できます。
Foo::result_type MakeMeFaster()
{
Foo t;
auto _hidden_optimizer_tmp = CalculatePi_1000_digits();
for (auto i = 0; i < 1000; ++i)
t += SomethingUnpredictible() * _hidden_optimizer_tmp;
return t.result();
}
ループ(CalculatePi_1000_digits呼び出し)から関数の大きな部分を実際に引き出しているため、呼び出しのオーバーヘッドが大幅に削減されているようです。コンパイラーは、CalculatePi_1000_digitsが常に同じ結果を返すことを証明する必要がありますが、優れたオプティマイザーはそれを行うことができます。
「レジスタシャドウイング」と呼ばれる優れた概念があり、スタック(メモリ)の代わりに(CPU上の)レジスタを介して(最大6?)の値を渡すことができます。また、内部で使用される関数と変数に応じて、コンパイラはフレーム管理コードが不要であると判断する場合があります!!
また、C++コンパイラでさえ「末尾再帰最適化」を行う場合があります。つまり、A()がB()を呼び出し、B()を呼び出した後、Aが戻ると、コンパイラはスタックフレームを再利用します!!
もちろん、プログラムが標準のセマンティクスに固執している場合にのみ、これをすべて行うことができます(ポインターのエイリアシングと最適化への影響を参照してください)
ここにはいくつかの問題があります。
十分に賢いコンパイラーがあれば、インラインを指定しなくても、自動インライン化が行われます。一方、インライン化できないものはたくさんあります。
関数が仮想の場合、当然、ターゲットは実行時に決定されるため、インライン化できない価格を支払うことになります。逆に、Javaでは、メソッドが最終的であることを示さない限り、この価格を支払う可能性があります。
コードがメモリ内でどのように構成されているかによっては、コードが他の場所にあるため、キャッシュミスやページミスでコストが発生する場合があります。その結果、アプリケーションによっては大きな影響を与える場合があります。
特に小さな(インライン化可能な)関数やクラスの場合でも、オーバーヘッドはあまりありません。
次の例には、それぞれが何度も何度も実行され、時間指定された3つの異なるテストがあります。結果は常に、単位時間の1000分の2のオーダーに等しくなります。
#include <boost/timer/timer.hpp>
#include <iostream>
#include <cmath>
double sum;
double a = 42, b = 53;
//#define ITERATIONS 1000000 // 1 million - for testing
//#define ITERATIONS 10000000000 // 10 billion ~ 10s per run
//#define WORK_UNIT sum += a + b
/* output
8.609619s wall, 8.611255s user + 0.000000s system = 8.611255s CPU(100.0%)
8.604478s wall, 8.611255s user + 0.000000s system = 8.611255s CPU(100.1%)
8.610679s wall, 8.595655s user + 0.000000s system = 8.595655s CPU(99.8%)
9.5e+011 9.5e+011 9.5e+011
*/
#define ITERATIONS 100000000 // 100 million ~ 10s per run
#define WORK_UNIT sum += std::sqrt(a*a + b*b + sum) + std::sin(sum) + std::cos(sum)
/* output
8.485689s wall, 8.486454s user + 0.000000s system = 8.486454s CPU (100.0%)
8.494153s wall, 8.486454s user + 0.000000s system = 8.486454s CPU (99.9%)
8.467291s wall, 8.470854s user + 0.000000s system = 8.470854s CPU (100.0%)
2.50001e+015 2.50001e+015 2.50001e+015
*/
// ------------------------------
double simple()
{
sum = 0;
boost::timer::auto_cpu_timer t;
for (unsigned long long i = 0; i < ITERATIONS; i++)
{
WORK_UNIT;
}
return sum;
}
// ------------------------------
void call6()
{
WORK_UNIT;
}
void call5(){ call6(); }
void call4(){ call5(); }
void call3(){ call4(); }
void call2(){ call3(); }
void call1(){ call2(); }
double calls()
{
sum = 0;
boost::timer::auto_cpu_timer t;
for (unsigned long long i = 0; i < ITERATIONS; i++)
{
call1();
}
return sum;
}
// ------------------------------
class Obj3{
public:
void runIt(){
WORK_UNIT;
}
};
class Obj2{
public:
Obj2(){it = new Obj3();}
~Obj2(){delete it;}
void runIt(){it->runIt();}
Obj3* it;
};
class Obj1{
public:
void runIt(){it.runIt();}
Obj2 it;
};
double objects()
{
sum = 0;
Obj1 obj;
boost::timer::auto_cpu_timer t;
for (unsigned long long i = 0; i < ITERATIONS; i++)
{
obj.runIt();
}
return sum;
}
// ------------------------------
int main(int argc, char** argv)
{
double ssum = 0;
double csum = 0;
double osum = 0;
ssum = simple();
csum = calls();
osum = objects();
std::cout << ssum << " " << csum << " " << osum << std::endl;
}
10,000,000回の反復(単純、6回の関数呼び出し、3回のオブジェクト呼び出し)を実行した場合の出力は、この半複雑な作業ペイロードでした:
sum += std::sqrt(a*a + b*b + sum) + std::sin(sum) + std::cos(sum)
次のように:
8.485689s wall, 8.486454s user + 0.000000s system = 8.486454s CPU (100.0%)
8.494153s wall, 8.486454s user + 0.000000s system = 8.486454s CPU (99.9%)
8.467291s wall, 8.470854s user + 0.000000s system = 8.470854s CPU (100.0%)
2.50001e+015 2.50001e+015 2.50001e+015
の単純な作業ペイロードの使用
sum += a + b
各ケースで数桁速いことを除いて、同じ結果が得られます。
新しい関数ごとに、新しいローカルスタックを作成する必要があります。しかし、このオーバーヘッドは、非常に多くの反復でループの各反復で関数を呼び出す場合にのみ顕著になります。
ほとんどの関数では、C++対Cでそれらを呼び出すための追加のオーバーヘッドはありません(すべての関数への不要な引数として「this」ポインターをカウントしない限り。).
仮想関数の場合、追加のレベルの間接化(Cのポインターを介した関数の呼び出しに相当)です。しかし、実際には、今日のハードウェアではこれは簡単です。
コードの構造、モジュールやライブラリなどのユニットへの分割によっては、場合によっては重要になることがあります。
同じペナルティは、コードが別のモジュールで定義されている他の関数と同様に、C++の仮想関数の使用に影響を与える可能性が最も高いでしょう。
良いニュースは、プログラム全体の最適化により、静的ライブラリとモジュール間の依存関係の問題が解決される可能性があることです。
数字もありませんが、聞いてくれてうれしいです。多くの場合、人々はオーバーヘッドの漠然としたアイデアから始めてコードを最適化しようとするのを見るが、実際には知らない。
他の人が言ったように、究極のパフォーマンスやそれに類するものを求めない限り、オーバーヘッドについて心配する必要はありません。関数を作成するとき、コンパイラは次のコードを記述する必要があります。
ただし、コードの可読性の低下、およびテスト戦略、保守計画、およびsrcファイルの全体的なサイズへの影響への影響を考慮する必要があります。