最新のコンパイラーでは、関数呼び出しのコストは依然として重要ですか?


95

私は宗教人であり、罪を犯さないように努力しています。それが、私がきれいなコード聖書によって命じられたいくつかの戒めに従うために、小さな(それよりも小さい、ロバート・C・マーティンを言い換える)関数を書く傾向がある理由です。しかし、いくつかのものをチェックしながら、私はこの投稿に行き、その下でこのコメントを読みました:

言語によっては、メソッド呼び出しのコストがかなり高くなる可能性があることに注意してください。ほとんどの場合、読み取り可能なコードの作成とパフォーマンスコードの作成にはトレードオフがあります。

高性能の最新コンパイラーの豊富な業界を考えると、この引用文はどのような条件下でも今でも有効ですか?

それが私の唯一の質問です。そして、それは長い関数を書くべきか小さな関数を書くべきかということではありません。私はあなたのフィードバックが私の態度を変えることに貢献するかもしれないし、しないかもしれないことを強調し、冒bl者の誘惑に抵抗することはできません。


11
読み取り可能で保守可能なコードを作成します。あなたは、スタックオーバーフローの問題に直面した場合にのみ、あなたのspproach再考えることができます
ファビオ

33
ここでの一般的な答えは不可能です。コンパイラが多すぎるため、実装する言語仕様が多すぎます。そして、JITでコンパイルされた言語、動的に解釈される言語などがあります。ただし、ネイティブCまたはC ++コードを最新のコンパイラでコンパイルする場合、関数呼び出しのコストを心配する必要はありません。オプティマイザーは、必要に応じてこれらをインライン化します。マイクロ最適化の熱狂者として、コンパイラーがインラインの決定を下すことはめったにありません
コーディグレー

6
:個人的な経験から言えば、でも典型的なforループは、速度を最適化する必要がある点に、私は、機能の面でかなりモダンで独自の言語でコードを書くが、関数呼び出しは途方もなく高価であり、for(Integer index = 0, size = someList.size(); index < size; index++)代わりに、単純にfor(Integer index = 0; index < someList.size(); index++)。コンパイラがここ数年で作成されたからといって、必ずしもプロファイリングを控えることができるわけではありません。
phyrfox

5
ループを毎回呼び出すのではなく、ループの外側でsomeList.size()の値を取得するだけの理にかなった@phyrfox。同期中にリーダーとライターが反復中に衝突しようとする可能性がある場合は特にそうです。この場合、反復中の変更からリストを保護することもできます。
クレイグ

8
小さな関数を使いすぎると、モノリシックメガ関数と同じくらい効率的にコードを難読化する可能性があります。あなたが私を信じないなら、いくつかのioccc.orgの勝者をチェックしてください:すべてを1つにコードするものもあればmain()、50の小さな機能にすべてを分割するものもあります。トリックは、いつものように、バランスをとることです。
cmaster

回答:


148

ドメインに依存します。

低電力マイクロコントローラー用のコードを作成している場合、メソッド呼び出しのコストはかなり高くなる可能性があります。ただし、通常のWebサイトまたはアプリケーションを作成している場合、メソッドの呼び出しコストは、残りのコードと比較して無視できます。その場合、メソッド呼び出しのようなマイクロ最適化ではなく、常に正しいアルゴリズムとデータ構造に焦点を当てる価値があります。

また、コンパイラーがメソッドをインライン化する問題もあります。ほとんどのコンパイラは、可能であれば関数をインライン化するのに十分なほどインテリジェントです。

そして最後に、パフォーマンスの黄金律があります。常にプロファイルが最初です。仮定に基づいて「最適化された」コードを記述しないでください。よくわからない場合は、両方のケースを書き、どちらが良いかを確認してください。


13
そして、例えば、HotSpotコンパイラはSpeculative Inliningを実行します。これは、ある意味では不可能な場合でもインライン化されます。
ヨルグWミットタグ

49
実際には、Webアプリケーションでは、全体のコードは... DBアクセスとネットワークトラフィックに関連して、おそらく微々たるものである
AnoE

72
私は実際には、最適化の意味をほとんど知らない非常に古いコンパイラで組み込みの超低消費電力に夢中です。このニッチな分野でさえ、この場合、コード品質が最優先されます。
ティム

2
@Mehrdadこの場合でも、コードの最適化に関連するものが他にない場合は驚きです。コードをプロファイリングするとき、関数呼び出しよりもはるかに重いことがわかります。そこで最適化を探すことが重要です。一部の開発者は、1つまたは2つの最適化されていないLOCに夢中になりますが、SWのプロファイルを作成すると、少なくともコードの大部分については設計がこれ以上に重要であることに気付きます。ボトルネックが見つかったら、最適化を試みることができます。呼び出しのオーバーヘッドを回避するために大きな関数を記述するなど、低レベルの任意の最適化よりもはるかに大きな影響があります。
ティム

8
いい答えだ!最後のポイントが最初になります最適化する場所を決定する前に、常にプロファイルを作成します
CJデニス

56

関数呼び出しのオーバーヘッドは、言語と、最適化するレベルに完全に依存します。

超低レベルでは、関数呼び出しなどの仮想メソッド呼び出しは、分岐の予測ミスやCPUキャッシュミスにつながる場合、コストが高くなる可能性があります。あなたが書いた場合は、アセンブラを、あなたも、あなたが呼び出しの前後でレジスタを保存し、復元するためにいくつかの余分な手順が必要なことを知っています。コンパイラーは言語のセマンティクスによって制限されるため(特にインターフェイスメソッドディスパッチや動的にロードされるライブラリーなどの機能について)、「十分にスマートな」コンパイラーが正しい関数をインライン化してこのオーバーヘッドを回避できるとは限りません。

高レベルでは、Perl、Python、Rubyのような言語は、関数呼び出しごとに多くの簿記を行うため、比較的高価になります。これは、メタプログラミングによってさらに悪化します。私はかつて非常にホットなループから関数呼び出しを巻き上げるだけでPythonソフトウェアを3倍高速化しました。パフォーマンスが重要なコードでは、ヘルパー関数をインライン化すると顕著な効果が得られます。

しかし、大部分のソフトウェアはそれほどパフォーマンスが重要ではないため、関数呼び出しのオーバーヘッドに気付くことができます。いずれにせよ、クリーンでシンプルなコードを書くことは報われる:

  • コードがパフォーマンスに重要でない場合、これによりメンテナンスが容易になります。パフォーマンスが重要なソフトウェアであっても、コードの大部分は「ホットスポット」にはなりません。

  • コードがパフォーマンスクリティカルである場合、シンプルなコードにより、コードを理解しやすくなり、最適化の機会を見つけることができます。通常、最大のメリットは、インライン関数などのマイクロ最適化によるものではなく、アルゴリズムの改善によるものです。または、言い方を変えます。同じことをより速くしないでください。より少ない方法を見つける。

「単純なコード」とは、「1000個の小さな関数に分解される」という意味ではないことに注意してください。すべての機能は、認知オーバーヘッドのビットを紹介する-それはより難しい理由より抽象コードについて。ある時点で、これらの小さな関数はほとんど機能しないため、それらを使用しないとコードが簡素化されます。


16
本当に賢いDBAは、「痛くなるまで正規化してから、痛くないまで非正規化する」と言ったことがあります。私は、「痛むまでメソッドを抽出し、痛くないまでインライン化する」と言い換えることができるように思えます。
ラバーダック

1
認知オーバーヘッドに加えて、デバッガー情報には象徴的なオーバーヘッドがあり、通常、最終的なバイナリのオーバーヘッドは避けられません。
フランクヒルマン

スマートコンパイラに関しては、常にそうとは限りません。たとえば、jvmは、ランタイムプロファイルに基づいて、珍しいパスの非常に安価/無料のトラップまたは特定のメソッド/インターフェースの実装が1つしかないインラインポリモーフィック関数に基づいて物事をインライン化し、新しいサブクラスが動的にロードされるときに適切にポリモーフィックへの呼び出しを非最適化できますランタイム。しかし、はい、そのようなことは不可能である多くの言語があり、一般的なケースでは費用対効果が高くない、または不可能なjvmでも多くの場合があります。
アルトゥールBiesiadowski

19

パフォーマンスのためにコードをチューニングすることに関するほとんどすべての格言は、アムダールの法則の特別な場合です。アムダールの法則の短く、ユーモラスな声明は

プログラムの1つの部分が実行時間の5%を占め、その部分を最適化して実行時間が0%になるようにすると、プログラム全体は5%だけ高速になります。

(実行時間のゼロパーセントまで物事を最適化することは完全に可能です:大きくて複雑なプログラムを最適化するために座っているとき、それはその実行時間の少なくとも一部を何もする必要のないことに費やしていることに気付くでしょう)

これが、人々が通常関数呼び出しのコストを心配しないと言う理由です:どんなに高価であっても、通常プログラム全体は呼び出しオーバーヘッドにランタイムのごく一部しか費やさないので、それらを高速化してもあまり役に立ちません。

ただし、すべての関数呼び出しを高速化するトリックがあれば、そのトリックはおそらく価値があります。それが利益ので、コンパイラの開発者は、「プロローグ」と「エピローグ」機能を最適化する多くの時間を費やすのすべてのプログラムそのコンパイラでコンパイルし、それはそれぞれのほんの少しだ場合でも、。

また、プログラムランタイムの多くを関数呼び出しに費やしていると信じる理由がある場合は、それらの関数呼び出しの一部が不要かどうかを考え始める必要があります。これをいつ行うべきかを知るためのいくつかの経験則があります。

  • 関数の呼び出しごとのランタイムが1ミリ秒未満であるが、その関数が何十万回も呼び出されている場合は、おそらくインライン化する必要があります。

  • プログラムのプロファイルが数千の関数を示し、それらのいずれも実行時間の0.1%程度を占めない場合、おそらく関数呼び出しのオーバーヘッドは総計で重要です。

  • ラザニアコード」があり、次の層へのディスパッチ以外の作業をほとんど行わない抽象化の層が多数あり、これらすべての層が仮想メソッド呼び出しで実装されている場合、CPUが無駄になっている可能性が高い間接分岐パイプラインのストールに多くの時間。残念ながら、これを解決する唯一の方法は、いくつかの層を取り除くことです。


7
ネストされたループの奥深くで行われる高価なものに注意してください。1つの関数を最適化し、10倍高速で実行されるコードを取得しました。プロファイラーが犯人を指摘した後です。(O(n ^ 3)から小さなn O(n ^ 6)までのループで何度も呼び出されました。)
ローレンペクテル

「残念ながら、これに対する唯一の治療法は、いくつかの層を取り除くことであり、これはしばしば非常に困難です。」-これは、言語コンパイラおよび/または仮想マシンテクノロジーに大きく依存します。コードを変更して、コンパイラーがインライン化を容易にすることができる場合(たとえばfinal、Javaで適用可能なクラスやメソッド、またはvirtualC#やC ++の非メソッドを使用して)、コンパイラー/ランタイムによって間接性を排除できます。大規模なリストラなしで利益が得られます。@JorgWMittagは上記、JVMできさえインラインそれが最適であることを証明可能ではない場合は...指摘しているように
ジュール・

...有効であるため、とにかく階層化されているにもかかわらず、コードでそれを実行している可能性があります。
ジュール

@Jules JITコンパイラー投機的最適化実行できることは事実ですが、そのような最適化均一に適用されることを意味するものではありません。特にJavaに関して、私の経験では、開発者の文化は、非常に深いコールスタックにつながるレイヤーの上に重ねられたレイヤーを好むということです。逸話的に、それは多くのJavaアプリケーションの鈍感で肥大化した感触の一因となります。このような高度に階層化されたアーキテクチャは、層が技術的にインライン化可能かどうかに関係なく、JITランタイムに対して機能します。JITは、構造上の問題を自動的に修正できる魔法の弾丸ではありません。
アモン

@amon「ラザニアコード」の私の経験は、深く入れ子になったオブジェクト階層とCOMが流行していた1990年代にまで遡る非常に大きなC ++アプリケーションから得られました。C ++コンパイラは、このようなプログラムの抽象化ペナルティを排除するために非常に勇敢な努力を行ってますが、それでも間接分岐パイプラインストール(およびIキャッシュミスの別の重要なチャンク)でウォールクロックランタイムのかなりの部分を費やしているのを見るかもしれません。
zwol

17

この引用に挑戦します:

ほとんどの場合、読み取り可能なコードの作成とパフォーマンスコードの作成にはトレードオフがあります。

これは本当に誤解を招く声明であり、潜在的に危険な態度です。トレードオフを行う必要がある特定のケースがいくつかありますが、一般に、2つの要因は独立しています。

必要なトレードオフの例は、単純なアルゴリズムと、より複雑だがパフォーマンスの高いアルゴリズムを使用する場合です。ハッシュテーブルの実装は、リンクリストの実装よりも明らかに複雑ですが、ルックアップが遅くなるため、パフォーマンスとシンプルさ(読みやすさの要因)を両立させる必要があります。

関数呼び出しのオーバーヘッドについては、アルゴリズムと言語によっては、再帰アルゴリズムを反復アルゴリズムに変更すると大きなメリットが得られる場合があります。しかし、これも非常に特殊なシナリオであり、一般に、関数呼び出しのオーバーヘッドは無視できるか、最適化されます。

(Pythonのような一部の動的言語には、メソッド呼び出しのオーバーヘッドがかなりあります。しかし、パフォーマンスが問題になる場合は、最初からPythonを使用するべきではありません。)

読み取り可能なコードのほとんどの原則-一貫した書式設定、意味のある識別子名、適切で役立つコメントなどは、パフォーマンスに影響しません。また、文字列ではなく列挙型を使用するなど、パフォーマンス上の利点もあります。


5

ほとんどの場合、関数呼び出しのオーバーヘッドは重要ではありません。

ただし、コードをインライン化することの大きな利点は、インライン化後に新しいコードを最適化することです。

たとえば、定数引数を使用して関数を呼び出す場合、オプティマイザーは、呼び出しをインライン化する前にできなかった引数を定数で折りたたむことができます。引数が関数ポインター(またはラムダ)の場合、オプティマイザーはそのラムダへの呼び出しもインライン化できるようになりました。

これは、実際の関数ポインターが呼び出しサイトまで常に折りたたまれていない限り、仮想関数と関数ポインターがまったくインライン化できないため魅力的ではない大きな理由です。


5

プログラムにとってパフォーマンスが重要であり、実際に多くの呼び出しがあると仮定すると、呼び出しの種類によってコストは問題になる場合とそうでない場合があります。

呼び出された関数が小さく、コンパイラーがそれをインライン化できる場合、コストは本質的にゼロになります。最新のコンパイラ/言語実装には、有益な場合に関数をインライン化する能力を最大化するように設計されたJIT、リンク時間最適化、および/またはモジュールシステムがあります。

OTOH、関数呼び出しには非自明なコストがあります。それらが存在するだけで、呼び出しの前後にコンパイラーの最適化が妨げられる可能性があります。

コンパイラが、呼び出された関数の動作について推論できない場合(たとえば、仮想/動的ディスパッチまたは動的ライブラリ内の関数)、関数に副作用がある可能性があると悲観的に想定しなければならない場合があります。グローバルステート、またはポインタから見えるメモリを変更します。コンパイラは、一時値をメモリに保存し、呼び出し後にそれらを再読み取りする必要がある場合があります。呼び出しの前後で命令を並べ替えることはできないため、ループをベクトル化したり、冗長な計算をループから巻き上げたりすることはできません。

たとえば、各ループの繰り返しで不必要に関数を呼び出す場合:

for(int i=0; i < /* gasp! */ strlen(s); i++) x ^= s[i];

コンパイラーはそれが純粋な関数であることを知っており、ループから移動します(この例のようなひどい場合には、偶発的なO(n ^ 2)アルゴリズムをO(n)に修正することさえあります):

for(int i=0, end=strlen(s); i < end; i++) x ^= s[i];

さらに、ワイド/ SIMD命令を使用して、一度に4/8/16要素を処理するようにループを書き換えることもできます。

呼び出しが同じメモリを指すグローバル変数にアクセスすること-あなたは、コールが何もしないし、超格安そのものであっても、ループ内のいくつかの不透明なコードに呼び出しを追加した場合でも、コンパイラは最悪の事態を想定しているs変更をその内容(const関数内にある場合constでも、他の場所にない場合があります)、最適化を不可能にします:

for(int i=0; i < strlen(s); i++) {
    x ^= s[i];
    do_nothing();
}

3

この古い論文はあなたの質問に答えるかもしれません:

ガイ・ルイス・スティール・ジュニア。「「高価な手続き呼び出し」神話、または有害と考えられる手続き呼び出しの実装を暴く、またはラムダ:究極のGOTO」。MIT AI Lab。AIラボメモAIM-443。1977年10月。

抽象:

フォークロアでは、GOTOステートメントは「安価」で、プロシージャコールは「高価」であると述べています。この神話は、主に不十分に設計された言語実装の結果です。この神話の歴史的な成長が考慮されます。この神話を覆す理論的なアイデアと既存の実装の両方について説明します。プロシージャコールを無制限に使用することにより、スタイリッシュな自由が得られることが示されています。特に、追加の変数を導入することなく、任意のフローチャートを「構造化」プログラムとして作成できます。GOTOステートメントとプロシージャコールの難しさは、抽象プログラミングの概念と具体的な言語構造の矛盾として特徴付けられます。


12
最新のコンパイラでは関数呼び出しのコストが依然として重要かどうか」という質問に古いものが答える論文を非常に疑います。
コーディグレー

6
@CodyGray 1977年以降、コンパイラテクノロジーは進歩したはずだと思います。したがって、1977年に関数呼び出しを安価にできるようになれば、今すぐできるはずです。答えはノーです。もちろん、これは、関数のインライン化などを実行できる適切な言語実装を使用していることを前提としています。
アレックス

4
@AlexVong 1977年のコンパイラの最適化に依存することは、石器時代の商品価格の動向に依存するようなものです。すべてがあまりにも変わっています。たとえば、乗算は、安価な操作としてメモリアクセスに置き換えられていました。現在、それは巨大な要因によってより高価です。仮想メソッドの呼び出しは、以前よりもはるかに高価です(メモリアクセスと分岐の予測ミス)正確にゼロ。1977
。– maaartinus

3
他の人が指摘したように、古い研究を無効にしたのはコンパイラ技術の変化だけではありません。マイクロアーキテクチャがほとんど変更されていない間にコンパイラが改善し続けていた場合、この論文の結論は依然として有効です。しかし、それは起こりませんでした。どちらかといえば、マイクロアーキテクチャーはコンパイラー以上に変更されました。かつては高速だったものが、今では比較的ゆっくりと遅くなっています。
コーディグレー

2
@AlexVongその論文を時代遅れにするCPUの変更をより正確に言うと、1977年には、メインメモリアクセスは単一のCPUサイクルでした。現在、L1(!)キャッシュへの単純なアクセスでさえ、3〜4サイクルのレイテンシがあります。現在、関数呼び出しはメモリアクセス(スタックフレームの作成、リターンアドレスの保存、ローカル変数のレジスタの保存)で非常に重いため、単一の関数呼び出しのコストを20サイクル以上に簡単に押し上げています。関数が引数を再配置するだけで、おそらく別の定数引数を追加してコールスルーに渡す場合、ほぼ100%のオーバーヘッドになります。
cmaster

3
  • C ++では、引数をコピーする関数呼び出しの設計に注意してください。デフォルトは「値渡し」です。レジスタやその他のスタックフレーム関連のものを保存することによる関数呼び出しのオーバーヘッドは、オブジェクトの意図しない(そして潜在的に非常に高価な)コピーによって圧倒される可能性があります。

  • 高度に因数分解されたコードをあきらめる前に調査する必要があるスタックフレーム関連の最適化があります。

  • 遅いプログラムを処理しなければならなかったほとんどの場合、アルゴリズムの変更を行うと、インライン関数呼び出しよりもはるかに高速になりました。たとえば、別のエンジニアがmap-of-maps構造を埋めるパーサーを再編集しました。その一環として、彼は1つのマップから論理的に関連付けられたマップへのキャッシュされたインデックスを削除しました。これは素晴らしいコードの堅牢性の動きでしたが、保存されたインデックスを使用する場合と将来のすべてのアクセスでハッシュルックアップを実行するために、100倍遅くなってプログラムが使用できなくなりました。プロファイリングでは、ほとんどの時間がハッシュ関数に費やされていることが示されました。


4
最初のアドバイスは少し古いです。C ++ 11以降、移動が可能になりました。特に、引数を内部的に変更する必要がある関数の場合、値によって引数を取得し、その場で変更することが最も効率的な選択です。
MSalters

@MSalters:「特に」と「さらに」などと間違えたと思います。コピーまたは参照を渡す決定は、C ++ 11の前に行われました(それを知っていることは知っています)。
フレネル

@phresnel:私はそれを正しかったと思う。私が言及している特定のケースは、呼び出し側でテンポラリーを作成し、それを引数に移動してから、呼び出し先で変更するケースです。これは、C ++ 03が非const参照を一時にバインドできない/できないため、C ++ 11より前には不可能でした。
MSalters17年

@MSalters:それから私はあなたのコメントを最初に読んだときに誤解しています。C ++ 11より前は、値を渡すことは、渡された値を変更したい場合に行うことではないことを暗示しているように思えました。
フレネル

「移動」の出現は、外部よりも関数内でより便利に構築され、参照によって渡されるオブジェクトの戻りに最も大きく役立ちます。その前に、関数からオブジェクトを返すとコピーが呼び出されますが、これは多くの場合高価な移動です。それは関数の引数を扱いません。コンパイラーに関数の引数(&&構文)に「移動」する許可を明示的に与える必要があるため、「設計」という言葉をコメントに慎重に入れました。私は、コピーコンストラクターを「削除」する習慣を身につけ、そうすることが価値のある場所を特定しました。
user2543191

3

他の人が言うように、最初にプログラムのパフォーマンスを測定する必要がありますが、実際には違いはおそらくないでしょう。

それでも、概念的なレベルから、私はあなたの質問に混同されるいくつかのことを明確にすると思った。まず、あなたが尋ねる:

最新のコンパイラーでは、関数呼び出しのコストは依然として重要ですか?

キーワード「関数」と「コンパイラ」に注意してください。あなたの引用は微妙に異なります:

言語によっては、メソッド呼び出しのコストがかなり高くなる可能性があることに注意してください。

これは、オブジェクト指向の意味でのメソッドのことです。

「関数」と「メソッド」は頻繁に交換可能に使用されますが、コスト(あなたが求めている)とコンパイル(あなたが与えたコンテキスト)に関しては違いがあります。

特に、静的ディスパッチ動的ディスパッチについて知る必要があります。現時点では最適化を無視します。

Cのような言語では、通常static staticで関数を呼び出します。例えば:

int foo(int x) {
  return x + 1;
}

int bar(int y) {
  return foo(y);
}

int main() {
  return bar(42);
}

コンパイラは、呼び出しを認識するとfoo(y)、そのfoo名前が参照している関数を知っているため、出力プログラムはそのfoo関数に直接ジャンプできます。これは非常に安価です。それが静的ディスパッチの意味です。

代替手段は動的ディスパッチで、コンパイラどの関数が呼び出されているかを知りません。例として、Haskellのコードをいくつか示します(Cに相当するコードは面倒だからです!):

foo x = x + 1

bar f x = f x

main = print (bar foo 42)

ここで、bar関数は引数を呼び出していますがf、引数は何でもかまいません。したがって、コンパイラはbar高速ジャンプ命令にコンパイルすることはできません。どこにジャンプするかわからないからです。代わりに、生成するコードは、bar参照fしている関数を見つけるために逆参照し、ジャンプします。それが動的ディスパッチの意味です。

これらの例は両方とも関数ですメソッドについては、動的にディスパッチされる特定のスタイルのスタイルと考えることができます。たとえば、次のPythonがあります。

class A:
  def __init__(self, x):
    self.x = x

  def foo(self):
    return self.x + 1

def bar(y):
  return y.foo()

z = A(42)
bar(z)

y.foo()コールは、それがの値まで見ていることから、動的ディスパッチを使用fooしてプロパティをyオブジェクト、そしてそれが見つけたものは何でも呼び出します。それyがclassを持っていることAや、Aクラスにfooメソッドが含まれていることを知らないので、そのままジャンプすることはできません。

OK、それが基本的な考え方です。コンパイルまたは解釈するかどうかに関係なく、静的ディスパッチは動的ディスパッチよりも高速であることに注意してください。他のすべてが等しい。どちらの場合も、逆参照には追加コストが発生します。

では、これは現代の最適化コンパイラにどのように影響しますか?

最初に注意することは、静的ディスパッチをより高度に最適化できることです。ジャンプ先の関数がわかると、インライン化などを実行できます。動的ディスパッチでは、実行時までジャンプしていることがわからないため、実行できる最適化はあまりありません。

第二に、いくつかの言語では、いくつかの動的ディスパッチがジャンプを終了する場所を推測し、それによって静的ディスパッチに最適化することが可能です。これにより、インライン化などの他の最適化を実行できます。

上記のPythonの例では、Pythonが他のコードでクラスとプロパティをオーバーライドできるため、このような推論は非常に絶望的です。

たとえば、注釈を使用yしてクラスに制限するなど、言語により多くの制限を課すAことができる場合、その情報を使用してターゲット関数を推測できます。サブクラス化された言語(クラスを持つほとんどすべての言語です!)では、y実際には異なる(サブ)クラスを持っている可能性があるため、実際には十分ではありませんfinal

Haskellはオブジェクト指向言語ではありませんが、我々はの値を推測することができますfインライン化bar(された静的にディスパッチ)main代わりに、fooためyfooin のターゲットmainは静的に知られているため、呼び出しは静的にディスパッチされ、おそらく完全にインライン化および最適化されます(これらの関数は小さいため、コンパイラーはインライン化する可能性が高くなりますが、一般的には期待できませんが) )。

したがって、コストは次のようになります。

  • 言語は静的または動的に呼び出しをディスパッチしますか?
  • 後者の場合、言語は実装が他の情報(たとえば、型、クラス、注釈、インライン化など)を使用してターゲットを推測できるようにしますか?
  • 静的ディスパッチ(推論またはその他)をどの程度積極的に最適化できますか?

「非常に動的な」言語を使用していて、多くの動的ディスパッチがあり、コンパイラーが利用できる保証がほとんどない場合、すべての呼び出しにコストがかかります。「非常に静的な」言語を使用している場合、成熟したコンパイラは非常に高速なコードを生成します。中間にいる場合は、コーディングスタイルと実装のスマートさによって異なります。


Haskellの例のように、クロージャー(または関数ポインター)を呼び出すことは動的ディスパッチであることに同意しません。動的ディスパッチには、そのクロージャを取得するための計算(たとえば、vtableの使用)が含まれるため、間接呼び出しよりもコストがかかります。そうでなければ、いい答えです。
バジルスタリンケビッチ

2

はい、見逃した分岐予測は、数十年前よりも現代のハードウェアではコストがかかりますが、コンパイラはこれを最適化するのにもっと賢くなりました。

例として、Javaを検討してください。この言語では、一見、関数呼び出しのオーバーヘッドが特に支配的です。

  • JavaBeanの規約により、小さな機能が広く普及しています
  • 関数はデフォルトで仮想であり、通常は
  • コンパイルの単位はクラスです。ランタイムは、以前の単相メソッドをオーバーライドするサブクラスを含む、いつでも新しいクラスのロードをサポートします

これらの慣行に恐ろしいことに、平均的なCプログラマーは、JavaはCより少なくとも1桁遅くなければならないと予測するでしょう。そして20年前には彼は正しかったでしょう。ただし、最新のベンチマークでは、慣用的なJavaコードを同等のCコードの数パーセント以内に配置しています。そんなことがあるものか?

理由の1つは、当然のことながら、最新のJVMインライン関数呼び出しです。投機的なインライン展開を使用してそうします。

  1. 新たにロードされたコードは、最適化なしで実行されます。この段階では、すべての呼び出しサイトについて、JVMは実際に呼び出されたメソッドを追跡します。
  2. コードがパフォーマンスホットスポットとして識別されると、ランタイムはこれらの統計を使用して最も可能性の高い実行パスを特定し、投機的最適化が適用されない場合に条件付きブランチをプレフィックスとして付けます。

つまり、コード:

int x = point.getX();

書き換えられます

if (point.class != Point) GOTO interpreter;
x = point.x;

そしてもちろん、ランタイムは、ポイントが割り当てられていない限り、この型チェックを上に移動するのに十分スマートであり、呼び出し側のコードが型を知っている場合はそれを削除します。

要約すると、Javaでさえメソッドの自動インライン化を管理している場合、インライン化は最新のプロセッサーで非常に有益であるため、コンパイラーが自動インライン化をサポートできなかった固有の理由はありません。したがって、この最も基本的な最適化戦略を知らない現代の主流コンパイラを想像することはほとんどできず、特に証明されない限り、これが可能なコンパイラを想定しています。


4
「コンパイラが自動インライン化をサポートできなかった固有の理由はありません」-あります。JITコンパイルについて説明しましたが、これは自己修正コード(セキュリティが原因でOSによって防止される可能性があります)と、プロファイルに基づく完全なプログラムの自動最適化を行う機能です。動的リンクを可能にする言語用のAOTコンパイラーは、呼び出しを仮想化しインライン化するのに十分な知識がありません。OTOH:AOTコンパイラーは、可能な限りすべてを最適化する時間を持ちます。JITコンパイラーは、ホットスポットで安価な最適化に集中する時間しかありません。ほとんどの場合、これによりJITはわずかに不利になります。
アモン

2
「セキュリティ」のためにGoogle Chromeの実行を妨げるOSを1つ教えてください(V8は実行時にJavaScriptをネイティブコードにコンパイルします)。また、AOTをインライン化することは固有の理由ではなく(言語ではなく、コンパイラに選択したアーキテクチャによって決まります)、動的リンクはコンパイルユニット全体のAOTインライン化を禁止しますが、コンパイル内のインライン化を禁止しませんほとんどの通話が行われるユニット。実際、Javaに比べて動的リンクを過度に使用しない言語では、有用なインライン化が間違いなく簡単になります。
メリトン

4
特に、iOSは非特権アプリのJITを防止します。ChromeまたはFirefoxは、独自のエンジンではなく、Appleが提供するWebビューを使用する必要があります。ただし、AOTとJITの違いは実装レベルであり、言語レベルの選択ではありません。
アモン

@meriton Windows 10 Sおよびビデオゲームコンソールのオペレーティングシステムも、サードパーティのJITエンジンをブロックする傾向があります。
ダミアンジェリック

2

言語によっては、メソッド呼び出しのコストがかなり高くなる可能性があることに注意してください。ほとんどの場合、読み取り可能なコードの作成とパフォーマンスコードの作成にはトレードオフがあります。

残念ながら、これは以下に大きく依存しています。

  • コンパイラツールチェーン(存在する場合はJITを含む)
  • ドメイン。

まず、パフォーマンス最適化の最初の法則はprofile firstです。ソフトウェアパーツのパフォーマンスがスタック全体のパフォーマンスとは関係のないドメインが多数あります。データベースコール、ネットワーク操作、OS操作などです。

これは、ソフトウェアのパフォーマンスがレイテンシを改善しなくても、ソフトウェアのパフォーマンスが完全に無関係であることを意味します。ソフトウェアを最適化すると、エネルギーとハードウェアの節約(またはモバイルアプリのバッテリー節約)が生じる場合があります。

ただし、それらは通常は目立たないことはできず、多くの場合、アルゴリズムの改善により、マイクロ最適化が大幅に改善されます。

そのため、最適化する前に、最適化の対象を理解し、それが価値があるかどうかを理解する必要があります。


現在、純粋なソフトウェアのパフォーマンスに関しては、ツールチェーンによって大きく異なります。

関数呼び出しには2つのコストがあります。

  • 実行時コスト、
  • コンパイル時間のコスト。

実行時のコストはかなり明白です。関数呼び出しを実行するには、一定量の作業が必要です。たとえば、x86でCを使用する場合、関数呼び出しでは、(1)レジスタをスタックにスピルし、(2)引数をレジスタにプッシュし、呼び出しを実行し、その後(3)スタックからレジスタを復元する必要があります。関連する仕事を見るために呼び出しコンベンションのこの概要を見てください

このレジスタのスピル/リストアには、かなりの時間(数十CPUサイクル)かかります。

一般に、このコストは関数を実行する実際のコストと比較して些細なものになると予想されますが、ゲッター、単純な条件で保護された関数など、一部のパターンは非生産的です。

したがって、プログラマーは、インタープリターとは別に、コンパイラーまたはJITが不要な関数呼び出しを最適化することを望みます。ただし、この希望は実を結ばない場合があります。オプティマイザーは魔法ではないからです。

オプティマイザーは、関数呼び出しが些細なことであることを検出し、呼び出しをインライン化します。基本的に、呼び出しサイトで関数の本体をコピー/貼り付けます。これは常に最適な最適化(膨張を引き起こす可能性がある)ではありませんが、インライン化によりコンテキストが公開され、コンテキストにより多くの最適化が可能になるため、一般に価値があります。

典型的な例は次のとおりです。

void func(condition: boolean) {
    if (condition) {
        doLotsOfWork();
    }
}

void call() { func(false); }

場合はfuncインライン化され、オプティマイザは分岐が行われないことを理解し、最適化するであろうcallvoid call() {}

その意味で、関数呼び出しは、オプティマイザーから情報を隠すことにより(まだインライン化されていない場合)、特定の最適化を禁止する場合があります。仮想化関数の呼び出しは、特に有罪です。これは、仮想化(実行時に最終的にどの関数が呼び出されるかを証明する)が必ずしも容易ではないためです。


結論として、私のアドバイスは、最初に早めにアルゴリズムのペシミゼーション(キュービックな複雑さまたは悪化するバイト)を避けて明確に記述し、次に最適化が必要なもののみを最適化することです。


1

「言語によっては、メソッド呼び出しのコストがかなり高くなる可能性があることを忘れないでください。読み取り可能なコードの作成とパフォーマンスコードの作成の間には、ほぼ常にトレードオフがあります。」

高性能の最新コンパイラーの豊富な業界を考えると、この引用文はどのような条件下でも今でも有効ですか?

絶対に言ってはいけません。私は引用がただそこに放り出すために無謀であると信じています。

もちろん、私は完全な真実を話しているわけではありませんが、それほど真実であることは気にしません。そのマトリックス映画のように、私はそれが1または2または3だったかどうか忘れました-私はそれが大きなメロンを持つセクシーなイタリアの女優のものであったと思います(私は本当に最初のもの以外は好きではありませんでした)、オラクルの女性はキアヌ・リーブスに、「あなたが聞きたいことを伝えた」とか、この効果のために何か言った。それが今私がやりたいことだ。

プログラマーはこれを聞く必要はありません。プロファイラーの経験があり、その引用がコンパイラーにある程度当てはまる場合、彼らは既にこれを知っており、プロファイリング出力と特定のリーフコールがホットスポットである理由を測定することで理解すれば、これを適切な方法で学習します。彼らが経験がなく、コードのプロファイルを作成したことがない場合、これは彼らが聞く必要がある最後のことです、彼らはそれが期待されるホットスポットを特定する前にすべてをインライン化する点までコードを書く方法を迷信的に妥協し始めるべきであるパフォーマンスが向上します。

とにかく、より正確な応答のために、それは依存します。条件のボート負荷のいくつかは、すでに良い答えの中にリストされています。1つの言語を選択するだけで可能な条件は、C ++が仮想呼び出しで動的ディスパッチを開始する必要があり、コンパイラーやリンカーさえも最適化できる場合は既に巨大です。あらゆる可能性のある言語やコンパイラーの状況に対処するために。しかし、「誰が気にしますか?」レイトレーシングとしてパフォーマンスが重要な領域で作業している場合でも、測定を行う前に手作業でインライン展開する方法は、最後に手始めに行うことです。

一部の人々は、測定する前に絶対に最適化を行わないことを提案することに熱心になると思います。参照カウントの局所性をマイクロ最適化として最適化する場合、パフォーマンス重視であることが確実であることがわかっている領域(レイトレーシングコードなど)で、データ指向の設計マインドセットからそのような最適化を最初から適用することがよくあります。そうでなければ、これらのドメインで何年も働いた後すぐに大きなセクションを書き直さなければならないことを知っているからです。キャッシュヒットのデータ表現を最適化すると、2次から線形への変換のように話さない限り、アルゴリズムの改善と同じ種類のパフォーマンスの改善が得られることがよくあります。

しかし、特にプロファイラーはインライン化の利点を明らかにするのにきちんとしていますが、インライン化しないことの利点を明らかにするわけではないため、測定の前にインライン化を開始する正当な理由を見たことはありませんインライン化されていない関数呼び出しはまれなケースであり、ホットコードのicacheの参照の局所性を改善し、場合によってはオプティマイザーが実行の一般的なケースパスに対してより良いジョブを実行できるようにします。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.