web-dev-qa-db-ja.com

C ++でのメンバー変数の順序の最適化

私は ブログ投稿 のゲームコーダーによって Introversion を読んでいて、彼は忙しくすべてを絞ろうとしています [〜#〜] cpu [〜#〜] 彼はコードから抜け出すことができます。彼がオフハンドで言及する1つのトリックは

「クラスのメンバー変数を、最も使用されているものと最も使用されていないものに並べ替えます。」

私はC++やそのコンパイル方法に精通していませんが、

  1. この声明は正確ですか?
  2. どのように、なぜ?
  3. 他の(コンパイル済み/スクリプト)言語にも適用されますか?

このトリックによって節約される(CPU)時間は最小限であり、大したことではないことを私は知っています。しかし一方で、ほとんどの関数では、どの変数が最も一般的に使用されるかを特定するのはかなり簡単で、デフォルトでこの方法でコーディングを開始するだけです。

46
DevinB

ここで2つの問題:

  • 特定のフィールドをまとめておくかどうか、またいつ維持するかが最適化です。
  • 実際にそれを行う方法。

それが役立つかもしれない理由は、メモリが「キャッシュライン」と呼ばれるチャンクでCPUキャッシュにロードされるためです。これには時間がかかり、一般的に言えば、オブジェクトにロードされるキャッシュラインが多いほど、時間がかかります。また、他の多くのものがキャッシュからスローされてスペースが確保され、予測できない方法で他のコードの速度が低下します。

キャッシュラインのサイズはプロセッサによって異なります。オブジェクトのサイズと比較して大きい場合、キャッシュラインの境界にまたがるオブジェクトはほとんどないため、最適化全体はまったく関係ありません。そうしないと、オブジェクトの一部だけがキャッシュにあり、残りがメインメモリ(またはおそらくL2キャッシュ)にある場合があります。最も一般的な操作(一般的に使用されるフィールドにアクセスする操作)がオブジェクトに使用するキャッシュをできるだけ少なくすることは良いことです。したがって、これらのフィールドをグループ化すると、これが発生する可能性が高くなります。

一般的な原則は「参照の局所性」と呼ばれます。プログラムがアクセスするさまざまなメモリアドレスが近ければ近いほど、キャッシュの動作が良好になる可能性が高くなります。多くの場合、事前にパフォーマンスを予測することは困難です。同じアーキテクチャの異なるプロセッサモデルは異なる動作をする可能性があり、マルチスレッドはキャッシュに何が含まれるかわからないことを意味します。しかし、何が何であるかについて話すことは可能です可能性が高いほとんどの場合に発生します。 know何かをしたい場合は、通常、それを測定する必要があります。

ここにはいくつかの落とし穴があることに注意してください。 CPUベースのアトミック操作(C++ 0xのアトミックタイプが一般的に使用する)を使用している場合、フィールドをロックするためにCPUがキャッシュライン全体をロックすることがあります。次に、複数のアトミックフィールドが近くにあり、異なるスレッドが異なるコアで実行され、同時に異なるフィールドで動作している場合、それらはすべて同じメモリ位置をロックしているため、すべてのアトミック操作がシリアル化されていることがわかります。さまざまな分野での再運用。それらが異なるキャッシュラインで動作していたら、それらは並行して動作し、より高速に実行されたでしょう。実際、Glen(Herb Sutter経由)が彼の回答で指摘しているように、コヒーレントキャッシュアーキテクチャでは、これはアトミック操作がなくても発生し、1日を完全に台無しにする可能性があります。したがって、参照の局所性は必然的にキャッシュを共有している場合でも、複数のコアが関与する場合には良いことではありません。キャッシュミスは通常速度低下の原因であるという理由で、それが予想されますが、特定のケースではひどく間違っています。

現在、一般的に使用されるフィールドとあまり使用されないフィールドを区別することは別として、オブジェクトが小さいほど、占有するメモリが少なくなります(したがってキャッシュが少なくなります)。これは、少なくとも激しい論争がない場合には、かなり良いニュースです。オブジェクトのサイズは、オブジェクト内のフィールドと、フィールドがアーキテクチャに合わせて正しく配置されるようにするためにフィールド間に挿入する必要のあるパディングによって異なります。 C++は(場合によっては)宣言された順序に基づいて、フィールドがオブジェクトに表示されなければならない順序に制約を課します。これは、低レベルのプログラミングを容易にするためです。したがって、オブジェクトに次のものが含まれている場合:

  • int(4バイト、4整列)
  • その後にchar(1バイト、任意の配置)
  • 続いてint(4バイト、4整列)
  • その後にchar(1バイト、任意の配置)

その場合、これはメモリ内で16バイトを占める可能性があります。ちなみに、intのサイズと配置はすべてのプラットフォームで同じではありませんが、4は非常に一般的であり、これは単なる例です。

この場合、コンパイラーは2番目のintの前に3バイトのパディングを挿入して正しく整列させ、最後に3バイトのパディングを挿入します。同じタイプのオブジェクトをメモリ内で隣接して配置できるように、オブジェクトのサイズはその配置の倍数である必要があります。配列はC/C++にあり、隣接するオブジェクトはメモリにあります。構造体がint、int、char、charの場合、charには配置要件がないため、同じオブジェクトは12バイトである可能性があります。

Intが4整列かどうかはプラットフォームに依存すると言いました:on ARM unalignedアクセスはハードウェア例外をスローするため、絶対にそうする必要があります。x86ではint unalignedにアクセスできますが、通常は低速でIIRCはアトミックではないため、コンパイラは通常(常に?)x86でintを4整列します。

コードを書くときの経験則は、パッキングが気になる場合は、構造体の各メンバーの配置要件を確認することです。次に、最大の整列タイプのフィールドを最初に並べ替え、次に小さいフィールドを並べ替えて、アラインメント要件のないメンバーに並べ替えます。たとえば、移植可能なコードを書き込もうとしている場合、次のように思いつくかもしれません。

struct some_stuff {
    double d;   // I expect double is 64bit IEEE, it might not be
    uint64_t l; // 8 bytes, could be 8-aligned or 4-aligned, I don't know
    uint32_t i; // 4 bytes, usually 4-aligned
    int32_t j;  // same
    short s;    // usually 2 bytes, could be 2-aligned or unaligned, I don't know
    char c[4];  // array 4 chars, 4 bytes big but "never" needs 4-alignment
    char d;     // 1 byte, any alignment
};

フィールドの配置がわからない場合、または移植可能なコードを書いているが、大きなトリックなしでできる限り最善を尽くしたい場合は、配置要件が構造内の基本的なタイプの中で最大の要件であると想定します。また、基本タイプの配置要件はそのサイズです。したがって、構造体にuint64_tが含まれている場合、またはlong longが含まれている場合、最良の推測は8整列です。時々あなたは間違っているでしょう、しかしあなたは多くの場合正しいでしょう。

ブロガーのようなゲームプログラマーは、プロセッサーとハードウェアについてすべてを知っていることが多いため、推測する必要がないことに注意してください。彼らはキャッシュラインサイズを知っており、すべてのタイプのサイズと配置を知っており、コンパイラによって使用される構造体レイアウトルールを知っています(PODおよび非PODタイプの場合)。複数のプラットフォームをサポートしている場合は、必要に応じてそれぞれに特別なケースを設定できます。また、ゲーム内のどのオブジェクトがパフォーマンスの向上から恩恵を受けるかを考え、プロファイラーを使用して実際のボトルネックがどこにあるかを見つけるために多くの時間を費やしています。しかし、それでも、オブジェクトがそれを必要とするかどうかに関係なく適用する経験則をいくつか持つことはそれほど悪い考えではありません。コードが不明確にならない限り、「一般的に使用されるフィールドをオブジェクトの先頭に配置する」と「配置要件で並べ替える」の2つの適切なルールです。

59
Steve Jessop

実行しているプログラムの種類によっては、このアドバイスによってパフォーマンスが向上したり、処理速度が大幅に低下したりする場合があります。

マルチスレッドプログラムでこれを行うと、「偽共有」の可能性が高くなります。

主題に関するハーブサッターの記事をチェックしてください ここ

私は前にそれを言いました、そして私はそれを言い続けます。実際のパフォーマンスを向上させる唯一の実際の方法は、コードを測定し、コードベースの内容を任意に変更するのではなく、ツールを使用して実際のボトルネックを特定することです。

10
Glen

ワーキングセットサイズ を最適化する方法の1つです。ワーキングセットのサイズを最適化することでアプリケーションのパフォーマンスを高速化する方法について、John Robbinsによる良い 記事 があります。もちろん、エンドユーザーがアプリケーションで実行する可能性のある最も頻繁なユースケースを慎重に選択する必要があります。

6
Canopus

データアクセスのキャッシュ動作を改善するための参照の局所性は、多くの場合関連する考慮事項ですが、最適化が必要な場合にレイアウトを制御する理由は他にもいくつかあります。特に組み込みシステムでは、多くの組み込みシステムで使用されるCPUにキャッシュ。

-構造体のフィールドのメモリアライメント

配置に関する考慮事項は多くのプログラマーによく理解されているので、ここではあまり詳しく説明しません。

ほとんどのCPUアーキテクチャでは、効率を上げるために、構造内のフィールドにネイティブアライメントでアクセスする必要があります。つまり、さまざまなサイズのフィールドを混在させる場合、コンパイラは、アライメント要件を正しく保つために、フィールド間にパディングを追加する必要があります。したがって、構造体が使用するメモリを最適化するには、これを念頭に置き、必要なパディングを最小限に抑えるために、最大のフィールドの後に小さなフィールドが続くようにフィールドをレイアウトすることが重要です。パディングを防ぐために構造体を「パック」する場合、コンパイラーはフィールドの小さな部分への一連のアクセスとシフトおよびマスクを使用してフィールドをアセンブルする必要があるため、アラインされていないフィールドへのアクセスには高い実行時コストがかかります。レジスタの値。

-構造内で頻繁に使用されるフィールドのオフセット

多くの組み込みシステムで重要になる可能性があるもう1つの考慮事項は、構造の開始時に頻繁にフィールドにアクセスすることです。

一部のアーキテクチャでは、ポインタアクセスへのオフセットをエンコードするために命令で使用できるビット数が限られているため、オフセットがそのビット数を超えるフィールドにアクセスする場合、コンパイラは複数の命令を使用してフィールドへのポインタを形成する必要があります。たとえば、ARMのThumbアーキテクチャにはオフセットをエンコードするための5ビットがあるため、フィールドが最初から124バイト以内にある場合にのみ、単一の命令でワードサイズのフィールドにアクセスできます。したがって、大規模な構造がある場合、組み込みエンジニアが覚えておくとよい最適化は、構造のレイアウトの先頭に頻繁に使用されるフィールドを配置することです。

3
Michael Burr

ここでは、メンバーに対してわずかに異なるガイドラインがあります(ARMアーキテクチャターゲット、さまざまな理由で主にTHUMB 16ビットcodegen):

  • 配置要件によるグループ化(または、初心者の場合、「サイズによるグループ化」が通常はうまくいきます)
  • 最初に最小

「アラインメントによるグループ化」はやや明白であり、この質問の範囲外です。パディングを回避し、メモリの使用量を減らします。

ただし、2番目の箇条書きは、THUMB LDRB(ロードレジスタバイト)、LDRH(ロードレジスタハーフワード)、およびLDR(ロードレジスタ)命令の小さな5ビットの「イミディエート」フィールドサイズに由来します。

5ビットは、0〜31のオフセットをエンコードできることを意味します。事実上、「これ」がレジスター(通常はそうです)で便利であると仮定します。

  • 8ビットバイトがthis + 0からthis + 31に存在する場合、1つの命令でロードできます。
  • this +0からthis + 62に存在する場合、16ビットのハーフワード。
  • this +0からthis + 124に存在する場合、32ビットのマシンワード。

それらがこの範囲外にある場合は、複数の命令を生成する必要があります。レジスタに適切なアドレスを蓄積するためのイミディエートを伴う一連のADD、またはさらに悪いことに、関数の最後のリテラルプールからのロードです。

リテラルプールにヒットした場合、それは痛いです。リテラルプールはiキャッシュではなく、dキャッシュを通過します。これは、最初のリテラルプールアクセスのためにメインメモリから少なくともキャッシュラインに相当するロードを意味し、次にリテラルプールがそれ自体のキャッシュで開始しない場合、dキャッシュとiキャッシュの間で潜在的な排除と無効化の問題のホストを意味します行(つまり、実際のコードがキャッシュ行の終わりで終了しない場合)。

(使用しているコンパイラーにいくつかの要望があった場合、リテラルプールをキャッシュライン境界で開始するように強制する方法はその1つです。)

(無関係に、リテラルプールの使用を回避するために行うことの1つは、すべての「グローバル」を単一のテーブルに保持することです。これは、グローバルごとに複数のルックアップではなく、「GlobalTable」の1つのリテラルプールルックアップを意味します。本当に賢いので、リテラルプールエントリをロードせずにアクセスできるある種のメモリにGlobalTableを保持できるかもしれません-それは.sbssでしたか?)

3
leander

C#では、メンバーの順序は、属性[LayoutKind.Sequential/Explicit]を指定しない限り、コンパイラーによって決定されます。これにより、コンパイラーは、指定された方法で構造体/クラスをレイアウトします。

私の知る限り、コンパイラはデータ型を自然な順序で整列させながらパッキングを最小限に抑えているようです(つまり、4バイトintは4バイトアドレスで始まります)。

2
Remi Lemarchand

最初のメンバーは、それにアクセスするためにポインターにオフセットを追加する必要はありません。

2
Lou Franco

私は、メモリ使用量ではなく、パフォーマンス、実行速度に重点を置いています。コンパイラーは、最適化スイッチなしで、コード内の宣言の同じ順序を使用して変数ストレージ領域をマップします。想像してみてください

 unsigned char a;
 unsigned char b;
 long c;

大きな混乱?アラインスイッチなし、低メモリ操作。 et alでは、DDR3DIMMに64ビットWordを使用し、もう1つに64ビットWordを使用するunsigned charを使用しますが、長い間避けられない文字です。

つまり、これは各変数ごとのフェッチです。

ただし、パックするか、並べ替えると、1つのフェッチと1つのANDマスキングで符号なし文字を使用できるようになります。

したがって、速度的には、現在の64ビットワードメモリマシンでは、整列、並べ替えなどはノーノーです。私はマイクロコントローラーの仕事をしていますが、パックされたものとパックされていないものの違いは本当に顕著です(<10MIPSプロセッサー、8ビットワードメモリーについて話します)

一方で、優れたアルゴリズムが指示する以外のパフォーマンスのためにコードを微調整するために必要なエンジニアリング作業、およびコンパイラーが最適化できることは、多くの場合、実際の効果なしにゴムを燃焼させる結果になることが長い間知られています。それと構文的にdubiusコードの書き込み専用部分。

私が見た最適化の最後の前進(uPでは、PCアプリでは実行可能ではないと思います)は、プログラムを単一のモジュールとしてコンパイルし、コンパイラーに最適化させることです(速度/ポインター解像度/メモリのはるかに一般的なビュー)パッキングなど)、リンカーに呼び出されていないライブラリ関数、メソッドなどをゴミ箱に入れます。

1
jpinto3912

理論的には、大きなオブジェクトがある場合、キャッシュミスを減らすことができます。ただし、通常は、同じサイズのメンバーをグループ化して、メモリのパッキングを強化することをお勧めします。

0
Johan Kotlinski

[〜#〜] cpu [〜#〜] 改善-おそらく読みやすさに関係があるのではないかと私は強く疑っています。特定のフレーム内で実行される一般的に実行される基本ブロックが同じページのセットにある場合は、実行可能コードを最適化できます。これは同じ考えですが、コード内に基本ブロックを作成する方法がわかりません。私の推測では、コンパイラーは関数をここで最適化せずに表示される順序に配置するので、共通の機能を一緒に配置してみることができます。

プロファイラー/オプティマイザーを試して実行してください。まず、プロファイリングオプションを使用してコンパイルし、次にプログラムを実行します。プロファイルされたexeが完了すると、プロファイルされた情報がダンプされます。このダンプを取得し、入力としてオプティマイザーを介して実行します。

私は何年もこの仕事から離れていましたが、彼らの働き方はそれほど変わっていません。

0
AndrewB