すでに最良の選択アルゴリズムを持っていると仮定すると、C ++コードから甘いスイートフレームレートの最後の数滴を圧縮するために、どのような低レベルのソリューションを提供できますか?
言うまでもなく、これらのヒントは、プロファイラーで既に強調した重要なコードセクションにのみ適用されますが、低レベルの非構造的な改善である必要があります。私は例をシードしました。
すでに最良の選択アルゴリズムを持っていると仮定すると、C ++コードから甘いスイートフレームレートの最後の数滴を圧縮するために、どのような低レベルのソリューションを提供できますか?
言うまでもなく、これらのヒントは、プロファイラーで既に強調した重要なコードセクションにのみ適用されますが、低レベルの非構造的な改善である必要があります。私は例をシードしました。
回答:
データレイアウトを最適化する!(これは単なるC ++よりも多くの言語に適用されます)
データ、プロセッサ、マルチコアの適切な処理などに合わせて、これを特に深く調整することができます。しかし、基本的な概念は次のとおりです。
緊密なループで処理している場合、各反復のデータをできるだけ小さくし、メモリ内でできるだけ近づけたいと思います。つまり、理想は、計算に必要なデータのみを含むオブジェクト(ポインターではない)の配列またはベクトルです。
このように、CPUがループの最初の反復でデータをフェッチすると、次の反復に相当するデータがキャッシュにロードされます。
本当にCPUは高速で、コンパイラは優れています。より少ない、より速い命令を使用することでできることはあまりありません。キャッシュコヒーレンスは、それがどこにあるかということです(これは私がGoogleで調べたランダムな記事です-単にデータを直線的に処理しないアルゴリズムのキャッシュコヒーレンシーを取得する良い例です)。
非常に低レベルのヒントですが、便利なものがあります:
ほとんどのコンパイラは、何らかの形式の明示的な条件付きヒントをサポートしています。GCCには__builtin_expectと呼ばれる関数があり、コンパイラに結果の値がおそらく何であるかを知らせることができます。GCCは、そのデータを使用して条件が最適化され、予想されるケースではできるだけ早く実行され、予想外のケースでは実行がわずかに遅くなります。
if(__builtin_expect(entity->extremely_unlikely_flag, 0)) {
// code that is rarely run
}
これを適切に使用すると、10〜20%の速度向上が見られました。
最初に理解する必要があるのは、実行しているハードウェアです。分岐はどのように処理されますか?キャッシングはどうですか?SIMD命令セットがありますか?プロセッサはいくつ使用できますか?プロセッサ時間を他のものと共有する必要がありますか?
同じ問題を非常に異なる方法で解決できます-アルゴリズムの選択もハードウェアに依存する必要があります。場合によっては、O(N)はO(NlogN)よりも実行速度が遅くなることがあります(実装によって異なります)。
最適化の大まかな概要として、最初に行うことは、解決しようとしている問題とデータを正確に調べることです。次に、そのために最適化します。極端なパフォーマンスが必要な場合は、一般的なソリューションを忘れてください-最もよく使用されるケースと一致しないすべてを特別なケースにできます。
次にプロファイル。プロファイル、プロファイル、プロファイル。メモリ使用量、分岐ペナルティ、関数呼び出しのオーバーヘッド、パイプライン使用率を確認してください。コードの速度が低下している原因を特定します。それはおそらくデータアクセスです(データアクセスのオーバーヘッドについて「The Latency Elephant」という記事を書きました-google it。十分な「評判」がないため、ここに2つのリンクを投稿できません)。その後、データレイアウト(大きくて均一な素晴らしい配列が素晴らしい)とデータアクセス(可能な場合はプリフェッチ)を最適化します。
メモリサブシステムのオーバーヘッドを最小化したら、命令がボトルネックになっているかどうかを確認し(うまくいけばそうです)、アルゴリズムのSIMD実装を調べます-構造体(SoA)実装は非常にデータであり、命令キャッシュが効率的。SIMDが問題に適さない場合は、組み込み関数とアセンブラレベルのコーディングが必要になる場合があります。
さらに速度が必要な場合は、並行してください。PS3で実行するメリットがある場合、SPUはあなたの友人です。それらを使用し、それらを愛しています。SIMDソリューションをすでに作成している場合は、SPUに移行することで大きなメリットが得られます。
そして、さらにプロファイルを作成します。ゲームシナリオでテストする-このコードはまだボトルネックですか?このコードをより高いレベルで使用する方法を変更して、その使用を最小限に抑えることができます(実際、これが最初のステップになります)。複数のフレームにわたって計算を延期できますか?
どのプラットフォームを使用している場合でも、利用可能なハードウェアとプロファイラーについてできる限り学びます。ボトルネックが何であるかを知っていると仮定しないでください-プロファイラーで見つけてください。そして、実際にゲームを高速化したかどうかを判断するためのヒューリスティックがあることを確認してください。
そして、再度プロファイルを作成します。
最初のステップ:アルゴリズムに関連するデータについて慎重に検討してください。O(log n)はO(n)よりも常に高速とは限りません。簡単な例:少数のキーのみを含むハッシュテーブルは、線形検索で置き換えるほうがよい場合がよくあります。
2番目のステップ:生成されたアセンブリを確認します。C ++は多くの暗黙的なコード生成をテーブルにもたらします。時々、知らないうちにあなたに忍び寄る。
しかし、それが本当にペダルから金属への時間だと仮定すると:プロファイル。真剣に。「パフォーマンストリック」をランダムに適用することは、支援するのと同じくらい傷つく可能性があります。
次に、すべてがボトルネックに依存します。
データキャッシュミス=>データレイアウトを最適化します。良い出発点は次のとおりです。http://gamesfromwithin.com/data-oriented-design
コードキャッシュミス=>仮想関数呼び出し、過剰な呼び出しスタックの深さなどを確認します。パフォーマンスが低下する一般的な原因は、基本クラスが仮想でなければならないという誤った考えです。
その他の一般的なC ++パフォーマンスシンク:
上記のすべては、アセンブリを見るとすぐにわかるので、上記を参照してください;)
不要なブランチを削除する
一部のプラットフォームおよび一部のコンパイラでは、ブランチはパイプライン全体を破棄する可能性があるため、わずかな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の詳細は次のとおりです。
メモリアクセス、特にランダムアクセスは避けてください。
これは、最新のCPUで最適化するための単一の最も重要なことです。RAMからのデータを待つ間に、大量の算術演算を実行したり、誤った予測分岐を多数実行したりすることができます。
また、このルールを逆に読むこともできます。メモリアクセス間でできるだけ多くの計算を行います。
コンパイラ組み込み関数を使用します。
コンパイラ組み込み関数-コンパイラが最適化されたアセンブリに変換する関数呼び出しのように見えるコンストラクトを使用して、コンパイラが特定の操作に対して最も効率的なアセンブリを生成していることを確認します。
不要な仮想関数呼び出しを削除する
仮想関数のディスパッチは非常に遅くなる可能性があります。 この記事では、その理由を説明します。可能であれば、フレームごとに何度も呼び出される関数については、それらを避けてください。
これにはいくつかの方法があります。継承を必要としないようにクラスを書き換えることができる場合があります。MachineGunがWeaponの唯一のサブクラスであり、それらを合併できることが判明する場合があります。
テンプレートを使用して、実行時ポリモーフィズムをコンパイル時ポリモーフィズムに置き換えることができます。これは、実行時にオブジェクトのサブタイプを知っている場合にのみ機能し、大幅な書き換えが可能です。
私の基本原則は次のとおりです。必要のないことはしないでください。
特定の関数がボトルネックであることがわかった場合、その関数を最適化できます-または、そもそも呼び出されないようにすることができます。
これは、必ずしも悪いアルゴリズムを使用しているという意味ではありません。たとえば、短時間キャッシュされる(または完全に事前に計算される)フレームごとに計算を実行している可能性があります。
本当に低レベルの最適化を試みる前に、私は常にこのアプローチを試みます。
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);
コンパイラを見落とさないでください-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に低レベルを心配させ、中レベルから高レベルに集中します。
制限キーワードは、特にあなたがポインタでオブジェクトを操作する必要がある場合には、潜在的に便利です。コンパイラーは、指示されたオブジェクトが他の方法で変更されないことを想定できるため、オブジェクトの一部をレジスターに保持したり、読み取りと書き込みをより効果的に並べ替えたりするなど、より積極的な最適化を実行できます。
このキーワードの良い点の1つは、一度適用すると、アルゴリズムを再配置しなくてもメリットが得られるヒントであることです。悪い面は、間違った場所で使用すると、データが破損する可能性があることです。しかし、通常、それを使用するのが合法である場所を見つけるのは非常に簡単です-これは、コンパイラが安全に想定できる以上のことをプログラマが合理的に予想できる数少ない例の1つです。これがキーワードが導入された理由です。
技術的には「制限」は標準C ++には存在しませんが、ほとんどのC ++コンパイラでプラットフォーム固有の同等物が利用できるため、検討する価値があります。
参照:http : //cellperformance.beyond3d.com/articles/2006/05/demystifying-the-restrict-keyword.html
なんでも!
データについてコンパイラーに提供する情報が多いほど、最適化は向上します(少なくとも私の経験では)。
void foo(Bar * x) {...;}
になる;
void foo(const Bar * const x) {...;}
コンパイラーは、ポインターxが変更されないこと、およびポインターxが指しているデータも変更されないことを認識します。
もう1つの追加の利点は、偶発的なバグの数を減らし、自分(または他の人)がすべきでないものを変更するのを防ぐことができることです。
const
コンパイラーの最適化は改善されません。コンパイラーは、変数が変化しないことを知っていれば、より良いコードを生成できますが、const
十分に強力な保証を提供しません。
ほとんどの場合、パフォーマンスを向上させる最良の方法は、アルゴリズムを変更することです。実装が一般的でないほど、金属に近づくことができます。
それが行われたと仮定すると....
実際に非常に重要なコードである場合は、メモリ読み取りを避け、事前計算できるものの計算を避けてください(ただし、ルール番号1に違反するルックアップテーブルはありません)。アルゴリズムが何をするかを知り、コンパイラもそれを知っている方法でそれを書いてください。アセンブリをチェックして、確実に機能することを確認してください。
キャッシュミスを回避します。可能な限りバッチ処理します。仮想関数やその他の間接参照を避けてください。
最終的に、すべてを測定します。ルールは常に変わります。3年前にコードを高速化するために使用されていたものが、今では速度を低下させます。良い例は、「浮動バージョンの代わりに二重数学関数を使用する」です。私はそれを読まなければそれを実現しなかったでしょう。
私は忘れていました-変数を初期化するデフォルトのコンストラクターを持たないか、またはあなたが主張するなら、少なくともそうしないコンストラクターも作成してください。プロファイルに表示されないものに注意してください。コードの行ごとに不要なサイクルを1つ失うと、プロファイラーには何も表示されませんが、全体として多くのサイクルが失われます。繰り返しますが、あなたのコードが何をしているかを知ってください。コア機能を誰でもできるようにするのではなく、無駄のないものにします。必要に応じてフールプルーフバージョンを呼び出すことができますが、常に必要なわけではありません。汎用性には代償が伴います-パフォーマンスは1つです。
デフォルトの初期化が行われない理由を説明するために編集されました。多くのコードが次のように述べています。Vector3bla; bla = DoSomething();
コンストラクターでの初期化は無駄な時間です。また、この場合、無駄な時間は短くなります(おそらくベクトルをクリアします)が、プログラマーがこれを習慣的に行うと、合計されます。また、多くの関数が一時的なものを作成し(オーバーロードされた演算子を考えてください)、ゼロに初期化され、すぐに割り当てられます。プロファイラーのスパイクを見るには小さすぎる非表示の失われたサイクルですが、コードベース全体でサイクルをブリードします。また、一部の人はコンストラクターでより多くのことを行います(これは明らかにノーノーです)。私は、コンストラクターがヘビーサイドに少しある未使用の変数から数ミリ秒のゲインを見てきました。コンストラクターが副作用を引き起こすとすぐに、コンパイラーはそれを最適化することができません。したがって、上記のコードを使用しない限り、初期化しないコンストラクター、または、私が言ったように、
Vector3 bla(noInit); bla = doSomething();
const Vector3 = doSomething()
?次に、戻り値の最適化を開始して、おそらく1つまたは2つの割り当てを省略できます。
ブール式の評価を減らす
これは、コードに対する非常に微妙ではあるが危険な変更であるため、本当に必死です。ただし、何度も評価される条件式がある場合は、代わりにビット演算子を使用してブール評価のオーバーヘッドを削減できます。そう:
if ((foo && bar) || blah) { ... }
になる:
if ((foo & bar) | blah) { ... }
代わりに整数演算を使用します。fooとbarが定数であるか、if()の前に評価される場合、これは通常のブールバージョンよりも高速です。
おまけとして、算術バージョンには通常のブールバージョンよりも分岐数が少なくなります。最適化する別の方法です。
大きな欠点は、遅延評価を失うことです-ブロック全体が評価されるので、できませんfoo != NULL & foo->dereference()
。このため、これを維持するのは難しいと考えられます。そのため、トレードオフが大きすぎる可能性があります。