C ++低レベル最適化のヒント[終了]


79

すでに最良の選択アルゴリズムを持っていると仮定すると、C ++コードから甘いスイートフレームレートの最後の数滴を圧縮するために、どのような低レベルのソリューションを提供できますか?

言うまでもなく、これらのヒントは、プロファイラーで既に強調した重要なコードセクションにのみ適用されますが、低レベルの非構造的な改善である必要があります。私は例をシードしました。


1
これがゲーム開発の質問であり、次のような一般的なプログラミングの質問ではない
理由

@Danny-これはおそらく一般的なプログラミングの質問かもしれません。また、ゲームプログラミングに関連する問題でもあります。両方のサイトで実行可能な質問だと思います。
Smashery

@Smasheryこの2つの唯一の違いは、ゲームプログラミングで特定のグラフィックエンジンレベルの最適化またはシェーダーコーダーの最適化が必要になる場合があることです。C++の部分は同じです。
ダニーヴァロッド

@Danny-確かに、いくつかの質問は、どちらのサイトでも「より」関連性があります。しかし、他のサイトで質問することもできるからといって、関連する質問を無視したくありません。
Smashery

回答:


76

データレイアウトを最適化する!(これは単なるC ++よりも多くの言語に適用されます)

データ、プロセッサ、マルチコアの適切な処理などに合わせて、これを特に深く調整することができます。しかし、基本的な概念は次のとおりです。

緊密なループで処理している場合、各反復のデータをできるだけ小さくし、メモリ内でできるだけ近づけたいと思います。つまり、理想は、計算に必要なデータのみを含むオブジェクト(ポインターではない)の配列またはベクトルです。

このように、CPUがループの最初の反復でデータをフェッチすると、次の反復に相当するデータがキャッシュにロードされます。

本当にCPUは高速で、コンパイラは優れています。より少ない、より速い命令を使用することでできることはあまりありません。キャッシュコヒーレンスは、それがどこにあるかということです(これは私がGoogleで調べたランダムな記事です-単にデータを直線的に処理しないアルゴリズムのキャッシュコヒーレンシーを取得する良い例です)。


リンクされたキャッシュの一貫性ページでCの例を試してみる価値があります。このことを初めて知ったとき、それがどれほどの違いをもたらすかについてショックを受けました。
ニール

9
また、オブジェクト指向プログラミングのプレゼンテーションの優れた落とし穴(ソニーR&D)(参照research.scee.net/files/presentations/gcapaustralia09/...(マイク・アクトンによると不機嫌が、魅力的なCellPerformanceの記事を- )cellperformance.beyond3d.com/articles/ index.html)。Noel LlopisのGames from Withinブログでも、このテーマについて頻繁に触れています(gamesfromwithin.com)。私は
ピットフォールの

2
「各反復のデータをできる限り小さくし、メモリ内でできる限り近づける」ことだけを警告します。アライメントされていないデータにアクセスすると、処理が遅くなる可能性があります。この場合、パディングによりパフォーマンスが向上します。データの順序も重要です。適切に順序付けられたデータは、パディングを少なくすることができます。スコットメイヤーズはこれを私よりもよく説明できます:)
ジョナサンコネル

Sonyプレゼンテーションへの+1。私はそれを前に読んで、データをチャンクに分割して適切に整列することを考慮して、プラットフォームレベルでデータを最適化する方法に本当に意味があります。
ChrisC

84

非常に低レベルのヒントですが、便利なものがあります:

ほとんどのコンパイラは、何らかの形式の明示的な条件付きヒントをサポートしています。GCCには__builtin_expectと呼ばれる関数があり、コンパイラに結果の値がおそらく何であるかを知らせることができます。GCCは、そのデータを使用して条件が最適化され、予想されるケースではできるだけ早く実行され、予想外のケースでは実行がわずかに遅くなります。

if(__builtin_expect(entity->extremely_unlikely_flag, 0)) {
  // code that is rarely run
}

これを適切に使用すると、10〜20%の速度向上が見られました。


1
できれば2回投票します。
tenpn

10
+ 1、Linuxカーネルはこれをスケジューラコードのマイクロ最適化に広く使用し、特定のコードパスに大きな違いをもたらします。
greyfade

2
残念ながら、Visual Studioには同等の機能はないようです。stackoverflow.com/questions/1440570/...
mmyers

1
それでは、パフォーマンスを得るために、通常どのような頻度で期待値が正しい値である必要がありますか?49/50回?または999999/1000000回?
ダグラス

36

最初に理解する必要があるのは、実行しているハードウェアです。分岐はどのように処理されますか?キャッシングはどうですか?SIMD命令セットがありますか?プロセッサはいくつ使用できますか?プロセッサ時間を他のものと共有する必要がありますか?

同じ問題を非常に異なる方法で解決できます-アルゴリズムの選択もハードウェアに依存する必要があります。場合によっては、O(N)はO(NlogN)よりも実行速度が遅くなることがあります(実装によって異なります)。

最適化の大まかな概要として、最初に行うことは、解決しようとしている問題とデータを正確に調べることです。次に、そのために最適化します。極端なパフォーマンスが必要な場合は、一般的なソリューションを忘れてください-最もよく使用されるケースと一致しないすべてを特別なケースにできます。

次にプロファイル。プロファイル、プロファイル、プロファイル。メモリ使用量、分岐ペナルティ、関数呼び出しのオーバーヘッド、パイプライン使用率を確認してください。コードの速度が低下している原因を特定します。それはおそらくデータアクセスです(データアクセスのオーバーヘッドについて「The Latency Elephant」という記事を書きました-google it。十分な「評判」がないため、ここに2つのリンクを投稿できません)。その後、データレイアウト(大きくて均一な素晴らしい配列が素晴らしい)とデータアクセス(可能な場合はプリフェッチ)を最適化します。

メモリサブシステムのオーバーヘッドを最小化したら、命令がボトルネックになっているかどうかを確認し(うまくいけばそうです)、アルゴリズムのSIMD実装を調べます-構造体(SoA)実装は非常にデータであり、命令キャッシュが効率的。SIMDが問題に適さない場合は、組み込み関数とアセンブラレベルのコーディングが必要になる場合があります。

さらに速度が必要な場合は、並行してください。PS3で実行するメリットがある場合、SPUはあなたの友人です。それらを使用し、それらを愛しています。SIMDソリューションをすでに作成している場合は、SPUに移行することで大きなメリットが得られます。

そして、さらにプロファイルを作成します。ゲームシナリオでテストする-このコードはまだボトルネックですか?このコードをより高いレベルで使用する方法を変更して、その使用を最小限に抑えることができます(実際、これが最初のステップになります)。複数のフレームにわたって計算を延期できますか?

どのプラットフォームを使用している場合でも、利用可能なハードウェアとプロファイラーについてできる限り学びます。ボトルネックが何であるかを知っていると仮定しないでください-プロファイラーで見つけてください。そして、実際にゲームを高速化したかどうかを判断するためのヒューリスティックがあることを確認してください。

そして、再度プロファイルを作成します。


31

最初のステップ:アルゴリズムに関連するデータについて慎重に検討してください。O(log n)はO(n)よりも常に高速とは限りません。簡単な例:少数のキーのみを含むハッシュテーブルは、線形検索で置き換えるほうがよい場合がよくあります。

2番目のステップ:生成されたアセンブリを確認します。C ++は多くの暗黙的なコード生成をテーブルにもたらします。時々、知らないうちにあなたに忍び寄る。

しかし、それが本当にペダルから金属への時間だと仮定すると:プロファイル。真剣に。「パフォーマンストリック」をランダムに適用することは、支援するのと同じくらい傷つく可能性があります。

次に、すべてがボトルネックに依存します。

データキャッシュミス=>データレイアウトを最適化します。良い出発点は次のとおりです。http//gamesfromwithin.com/data-oriented-design

コードキャッシュミス=>仮想関数呼び出し、過剰な呼び出しスタックの深さなどを確認します。パフォーマンスが低下する一般的な原因は、基本クラスが仮想でなければならないという誤った考えです。

その他の一般的なC ++パフォーマンスシンク:

  • 過剰な割り当て/割り当て解除。パフォーマンスが重要な場合は、ランタイムを呼び出さないでください。今まで。
  • 構造をコピーします。できる限り避けてください。const参照にできる場合は、1つにします。

上記のすべては、アセンブリを見るとすぐにわかるので、上記を参照してください;)


19

不要なブランチを削除する

一部のプラットフォームおよび一部のコンパイラでは、ブランチはパイプライン全体を破棄する可能性があるため、わずかなif()ブロックでも高価になる可能性があります。

PowerPCアーキテクチャ(PS3 / x360)は、浮動小数点選択命令を提供しますfsel。ブロックが単純な割り当てである場合、これはブランチの代わりに使用できます。

float result = 0;
if (foo > bar) { result = 2.0f; }
else { result = 1.0f; }

になる:

float result = fsel(foo-bar, 2.0f, 1.0f);

最初のパラメーターが0以上の場合、2番目のパラメーターが返され、そうでない場合は3番目のパラメーターが返されます。

分岐を失う代償として、if {}ブロックとelse {}ブロックの両方が実行されるため、1つが高価な操作であるかNULLポインターを逆参照する場合、この最適化は適切ではありません。

コンパイラがすでにこの作業を行っている場合があるため、最初にアセンブリを確認してください。

分岐とfselの詳細は次のとおりです。

http://assemblyrequired.crashworks.org/tag/intrinsics/


float result =(foo> bar)?2.f:1.f
knight666

3
@ knight666:ロングハンドの "if"が行った場所であればどこでもブランチが生成されます。ARMでは、少なくとも、そのような小さなシーケンスは、分岐を必要としない条件付き命令で実装できるため、そういうことです。
chrisbtoo

1
@ knight666運が良ければ、コンパイラーはそれをfselに変えることができますが、確かではありません。FWIW、私は通常、そのスニペットを三次演算子で書き、その後プロファイラーが同意すればfselに最適化します。
tenpn

IA32では、代わりにCMOVccがあります。
スキズ

blueraja.com/blog/285/…も参照してください(この場合、コンパイラーが優れていれば、コンパイラー自体を最適化できるはずなので、通常は心配する必要はありません)
BlueRaja-Danny Pflughoeft

16

メモリアクセス、特にランダムアクセスは避けてください。

これは、最新のCPUで最適化するための単一の最も重要なことです。RAMからのデータを待つ間に、大量の算術演算を実行したり、誤った予測分岐を多数実行したりすることができます。

また、このルールを逆に読むこともできます。メモリアクセス間でできるだけ多くの計算を行います。



11

不要な仮想関数呼び出しを削除する

仮想関数のディスパッチは非常に遅くなる可能性があります。 この記事では、その理由を説明します。可能であれば、フレームごとに何度も呼び出される関数については、それらを避けてください。

これにはいくつかの方法があります。継承を必要としないようにクラスを書き換えることができる場合があります。MachineGunがWeaponの唯一のサブクラスであり、それらを合併できることが判明する場合があります。

テンプレートを使用して、実行時ポリモーフィズムをコンパイル時ポリモーフィズムに置き換えることができます。これは、実行時にオブジェクトのサブタイプを知っている場合にのみ機能し、大幅な書き換えが可能です。


9

私の基本原則は次のとおりです必要のないことはしないでください

特定の関数がボトルネックであることがわかった場合、その関数を最適化できます-または、そもそも呼び出されないようにすることができます。

これは、必ずしも悪いアルゴリズムを使用しているという意味ではありません。たとえば、短時間キャッシュされる(または完全に事前に計算される)フレームごとに計算を実行している可能性があります。

本当に低レベルの最適化を試みる前に、私は常にこのアプローチを試みます。


2
この質問は、あなたができるすべての構造的なことをすでに完了していることを前提としています。
tenpn

2
します。しかし、多くの場合、あなたは持っていると仮定し、持っていない。本当に、高価な関数を最適化する必要があるたびに、その関数を呼び出す必要があるかどうかを自問してください。
レイチェルブルーム

2
...しかし、分岐よりも後で結果を破棄する場合でも、実際に計算を行う方が実際に速い場合があります。
tenpn


6

CPUパイプラインをより有効に活用するには、依存関係チェーンを最小限に抑えます。

単純な場合、ループの展開を有効にすると、コンパイラーがこれを行うことがあります。ただし、特に式の順序を変更すると結果が変化するため、フロートが含まれる場合は特にそうなりません。

例:

float *data = ...;
int length = ...;

// Slow version
float total = 0.0f;
int i;
for (i=0; i < length; i++)
{
  total += data[i]
}

// Fast version
float total1, total2, total3, total4;
for (i=0; i < length-3; i += 4)
{
  total1 += data[i];
  total2 += data[i+1];
  total3 += data[i+2];
  total4 += data[i+3];
}
for (; i < length; i++)
{
  total += data[i]
}
total += (total1 + total2) + (total3 + total4);

4

コンパイラを見落とさないでください-Intelでgccを使用している場合、たとえば、Intel C / C ++コンパイラに切り替えることで簡単にパフォーマンスを向上させることができます。ARMプラットフォームをターゲットにしている場合は、ARMの商用コンパイラをご覧ください。iPhoneを使用している場合、AppleはiOS 4.0 SDKからClangの使用を許可しました。

特にx86で最適化を行う際におそらく問題になるのは、最近のCPU実装で多くの直感的なことが動作しなくなることです。残念ながら、私たちの多くにとって、コンパイラを最適化する機能はなくなっています。コンパイラは、CPUの内部知識に基づいて、ストリーム内の命令をスケジュールできます。さらに、CPUは自身のニーズに基づいて命令を再スケジュールすることもできます。メソッドを配置する最適な方法を考えたとしても、可能性としては、コンパイラーまたはCPUがそれを独自に考え出し、すでにその最適化を実行している可能性があります。

私の最善のアドバイスは、低レベルの最適化を無視し、高レベルの最適化に集中することです。コンパイラーとCPUは、アルゴリズムがどれほど良くても、アルゴリズムをO(n ^ 2)からO(1)アルゴリズムに変更することはできません。それには、あなたがやろうとしていることを正確に見て、それを行うためのより良い方法を見つける必要があります。コンパイラとCPUに低レベルを心配させ、中レベルから高レベルに集中します。


あなたが言っていることはわかりますが、O(logN)に到達した時点で、低レベルの最適化が機能し、あなたを獲得できる構造的な変化からこれ以上出てこないでしょう。その余分な0.5ミリ秒。
tenpn

1
私の答えを参照してください:O(log n)。また、0.5ミリ秒を探す場合は、より高いレベルを調べる必要があります。それはあなたのフレーム時間の3%です!
レイチェルブルーム

4

制限キーワードは、特にあなたがポインタでオブジェクトを操作する必要がある場合には、潜在的に便利です。コンパイラーは、指示されたオブジェクトが他の方法で変更されないことを想定できるため、オブジェクトの一部をレジスターに保持したり、読み取りと書き込みをより効果的に並べ替えたりするなど、より積極的な最適化を実行できます。

このキーワードの良い点の1つは、一度適用すると、アルゴリズムを再配置しなくてもメリットが得られるヒントであることです。悪い面は、間違った場所で使用すると、データが破損する可能性があることです。しかし、通常、それを使用するのが合法である場所を見つけるのは非常に簡単です-これは、コンパイラが安全に想定できる以上のことをプログラマが合理的に予想できる数少ない例の1つです。これがキーワードが導入された理由です。

技術的には「制限」は標準C ++には存在しませんが、ほとんどのC ++コンパイラでプラットフォーム固有の同等物が利用できるため、検討する価値があります。

参照:http : //cellperformance.beyond3d.com/articles/2006/05/demystifying-the-restrict-keyword.html


2

なんでも!

データについてコンパイラーに提供する情報が多いほど、最適化は向上します(少なくとも私の経験では)。

void foo(Bar * x) {...;}

になる;

void foo(const Bar * const x) {...;}

コンパイラーは、ポインターxが変更されないこと、およびポインターxが指しているデータも変更されないことを認識します。

もう1つの追加の利点は、偶発的なバグの数を減らし、自分(または他の人)がすべきでないものを変更するのを防ぐことができることです。


そして、コードバディはあなたを愛します!
-tenpn

4
constコンパイラーの最適化は改善されません。コンパイラーは、変数が変化しないことを知っていれば、より良いコードを生成できますが、const十分に強力な保証を提供しません。
deft_code

3
いや。「制限」は「定数」よりもはるかに便利です。gamedev.stackexchange.com/questions/853/を
Justicle

const cantヘルプが間違っていると言う+1 ppl ... infoq.com/presentations/kixeye-scalability
NoSenseEtAl

2

ほとんどの場合、パフォーマンスを向上させる最良の方法は、アルゴリズムを変更することです。実装が一般的でないほど、金属に近づくことができます。

それが行われたと仮定すると....

実際に非常に重要なコードである場合は、メモリ読み取りを避け、事前計算できるものの計算を避けてください(ただし、ルール番号1に違反するルックアップテーブルはありません)。アルゴリズムが何をするかを知り、コンパイラもそれを知っている方法でそれを書いてください。アセンブリをチェックして、確実に機能することを確認してください。

キャッシュミスを回避します。可能な限りバッチ処理します。仮想関数やその他の間接参照を避けてください。

最終的に、すべてを測定します。ルールは常に変わります。3年前にコードを高速化するために使用されていたものが、今では速度を低下させます。良い例は、「浮動バージョンの代わりに二重数学関数を使用する」です。私はそれを読まなければそれを実現しなかったでしょう。

私は忘れていました-変数を初期化するデフォルトのコンストラクターを持たないか、またはあなたが主張するなら、少なくともそうしないコンストラクターも作成してください。プロファイルに表示されないものに注意してください。コードの行ごとに不要なサイクルを1つ失うと、プロファイラーには何も表示されませんが、全体として多くのサイクルが失われます。繰り返しますが、あなたのコードが何をしているかを知ってください。コア機能を誰でもできるようにするのではなく、無駄のないものにします。必要に応じてフールプルーフバージョンを呼び出すことができますが、常に必要なわけではありません。汎用性には代償が伴います-パフォーマンスは1つです。

デフォルトの初期化が行われない理由を説明するために編集されました。多くのコードが次のように述べています。Vector3bla; bla = DoSomething();

コンストラクターでの初期化は無駄な時間です。また、この場合、無駄な時間は短くなります(おそらくベクトルをクリアします)が、プログラマーがこれを習慣的に行うと、合計されます。また、多くの関数が一時的なものを作成し(オーバーロードされた演算子を考えてください)、ゼロに初期化され、すぐに割り当てられます。プロファイラーのスパイクを見るには小さすぎる非表示の失われたサイクルですが、コードベース全体でサイクルをブリードします。また、一部の人はコンストラクターでより多くのことを行います(これは明らかにノーノーです)。私は、コンストラクターがヘビーサイドに少しある未使用の変数から数ミリ秒のゲインを見てきました。コンストラクターが副作用を引き起こすとすぐに、コンパイラーはそれを最適化することができません。したがって、上記のコードを使用しない限り、初期化しないコンストラクター、または、私が言ったように、

Vector3 bla(noInit); bla = doSomething();


/コンストラクタでメンバーを初期化しないでください それはどのように役立ちますか?
tenpn

編集済みの投稿をご覧ください。コメントボックスに収まりませんでした。
カイ

const Vector3 = doSomething()?次に、戻り値の最適化を開始して、おそらく1つまたは2つの割り当てを省略できます。
tenpn

1

ブール式の評価を減らす

これは、コードに対する非常に微妙ではあるが危険な変更であるため、本当に必死です。ただし、何度も評価される条件式がある場合は、代わりにビット演算子を使用してブール評価のオーバーヘッドを削減できます。そう:

if ((foo && bar) || blah) { ... } 

になる:

if ((foo & bar) | blah) { ... }

代わりに整数演算を使用します。fooとbarが定数であるか、if()の前に評価される場合、これは通常のブールバージョンよりも高速です。

おまけとして、算術バージョンには通常のブールバージョンよりも分岐数が少なくなります。最適化する別の方法です。

大きな欠点は、遅延評価を失うことです-ブロック全体が評価されるので、できませんfoo != NULL & foo->dereference()。このため、これを維持するのは難しいと考えられます。そのため、トレードオフが大きすぎる可能性があります。


1
これは、パフォーマンスを目的とした非常にひどいトレードオフです。これは、主に意図されていることがすぐに明らかではないためです。
ボブ・ソマーズ

私はあなたにほぼ完全に同意します。必死だったと言った!
tenpn

3
これはまた、短絡を壊し、分岐予測をより信頼できないものにしませんか?
エゴン

1
fooが2でbarが1の場合、コードはまったく同じように動作しません。早期評価ではなく、それが最大のマイナス面だと思います。

1
実際には、C ++のブール値は0または1であること保証されているため、これをブール値でのみ実行している限り、安全です。詳細:altdevblogaday.org/2011/04/18/understanding-your-bool-type
tenpn

1

スタックの使用状況に注意してください

スタックに追加するものはすべて、関数が呼び出されたときの追加のプッシュと構築です。大量のスタックスペースが必要な場合、作業メモリを事前に割り当てることが有益な場合があります。作業中のプラットフォームに使用可能な高速RAMがあれば、さらに良いことです。

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