なぜ私のアプリケーションは、その寿命の24%をnullチェックに費やしているのですか?


104

パフォーマンスが重要なバイナリ決定ツリーがあり、この質問を1行のコードに集中したいと思います。バイナリツリーイテレータのコードは、それに対してパフォーマンス分析を実行した結果です。

        public ScTreeNode GetNodeForState(int rootIndex, float[] inputs)
        {
0.2%        ScTreeNode node = RootNodes[rootIndex].TreeNode;

24.6%       while (node.BranchData != null)
            {
0.2%            BranchNodeData b = node.BranchData;
0.5%            node = b.Child2;
12.8%           if (inputs[b.SplitInputIndex] <= b.SplitValue)
0.8%                node = b.Child1;
            }

0.4%        return node;
        }

BranchDataはフィールドであり、プロパティではありません。インライン化されないリスクを防ぐためにこれを行いました。

BranchNodeDataクラスは次のとおりです。

public sealed class BranchNodeData
{
    /// <summary>
    /// The index of the data item in the input array on which we need to split
    /// </summary>
    internal int SplitInputIndex = 0;

    /// <summary>
    /// The value that we should split on
    /// </summary>
    internal float SplitValue = 0;

    /// <summary>
    /// The nodes children
    /// </summary>
    internal ScTreeNode Child1;
    internal ScTreeNode Child2;
}

ご覧のように、whileループ/ nullチェックはパフォーマンスに大きな影響を与えます。木が大きいので、葉を探すのに時間がかかると思いますが、その一行に費やされた時間の偏りを知りたいです。

私はもう試した:

  • ヌルチェックを分離する-ヒットとなるのはヌルチェックです。
  • オブジェクトにブールフィールドを追加し、それをチェックしても、違いはありませんでした。何を比較するかは関係ありません。問題なのは比較です。

これは分岐予測の問題ですか?もしそうなら、私はそれについて何ができますか?どちらかと言えば?

CILを理解するつもりはありませんが、CILから情報を取得できるように、誰にでも公開するつもりです。

.method public hidebysig
instance class OptimalTreeSearch.ScTreeNode GetNodeForState (
    int32 rootIndex,
    float32[] inputs
) cil managed
{
    // Method begins at RVA 0x2dc8
    // Code size 67 (0x43)
    .maxstack 2
    .locals init (
        [0] class OptimalTreeSearch.ScTreeNode node,
        [1] class OptimalTreeSearch.BranchNodeData b
    )

    IL_0000: ldarg.0
    IL_0001: ldfld class [mscorlib]System.Collections.Generic.List`1<class OptimalTreeSearch.ScRootNode> OptimalTreeSearch.ScSearchTree::RootNodes
    IL_0006: ldarg.1
    IL_0007: callvirt instance !0 class [mscorlib]System.Collections.Generic.List`1<class OptimalTreeSearch.ScRootNode>::get_Item(int32)
    IL_000c: ldfld class OptimalTreeSearch.ScTreeNode OptimalTreeSearch.ScRootNode::TreeNode
    IL_0011: stloc.0
    IL_0012: br.s IL_0039
    // loop start (head: IL_0039)
        IL_0014: ldloc.0
        IL_0015: ldfld class OptimalTreeSearch.BranchNodeData OptimalTreeSearch.ScTreeNode::BranchData
        IL_001a: stloc.1
        IL_001b: ldloc.1
        IL_001c: ldfld class OptimalTreeSearch.ScTreeNode OptimalTreeSearch.BranchNodeData::Child2
        IL_0021: stloc.0
        IL_0022: ldarg.2
        IL_0023: ldloc.1
        IL_0024: ldfld int32 OptimalTreeSearch.BranchNodeData::SplitInputIndex
        IL_0029: ldelem.r4
        IL_002a: ldloc.1
        IL_002b: ldfld float32 OptimalTreeSearch.BranchNodeData::SplitValue
        IL_0030: bgt.un.s IL_0039

        IL_0032: ldloc.1
        IL_0033: ldfld class OptimalTreeSearch.ScTreeNode OptimalTreeSearch.BranchNodeData::Child1
        IL_0038: stloc.0

        IL_0039: ldloc.0
        IL_003a: ldfld class OptimalTreeSearch.BranchNodeData OptimalTreeSearch.ScTreeNode::BranchData
        IL_003f: brtrue.s IL_0014
    // end loop

    IL_0041: ldloc.0
    IL_0042: ret
} // end of method ScSearchTree::GetNodeForState

編集:私は分岐予測テストを行うことにしました、もししばらくの間であれば同一のものを追加したので、

while (node.BranchData != null)

そして

if (node.BranchData != null)

その中に。次に、それに対してパフォーマンス分析を実行しました。最初の比較の実行には、常にtrueを返す2番目の比較の実行に比べて6倍の時間がかかりました。だから、それは確かに分岐予測の問題のように見えます-そして、私はそれについて私が何もすることができないと思いますか?!

別の編集

上記の結果は、whileチェックのためにnode.BranchDataをRAMからロードする必要がある場合にも発生し、ifステートメント用にキャッシュされます。


これは、同様のトピックに関する3番目の質問です。今回は1行のコードに注目します。この問題に関する他の質問は次のとおりです。


3
BranchNodeプロパティの実装を示してください。交換してみてくださいnode.BranchData != null ReferenceEquals(node.BranchData, null)。違いはありますか?
Daniel Hilgarth、2013年

4
24%がwhileステートメントではなく、whileステートメントの一部である条件式ではないことを確信していますか
Rune FS

2
別のテスト:whileループを次のように書き直してみてくださいwhile(true) { /* current body */ if(node.BranchData == null) return node; }。何か変化はありますか?
Daniel Hilgarth 2013年

2
少し最適化すると、次のようになります。while(true) { BranchNodeData b = node.BranchData; if(ReferenceEquals(b, null)) return node; node = b.Child2; if (inputs[b.SplitInputIndex] <= b.SplitValue) node = b.Child1; }これはnode. BranchData一度だけ取得します。
Daniel Hilgarth 2013年

2
時間消費が最も大きい2行の合計実行回数を加算してください。
Daniel Hilgarth 2013年

回答:


180

木は巨大です

プロセッサがこれまでで最も高価なことは、命令を実行することではなく、メモリにアクセスすることです。現代の実行コアCPUがあり、多くの倍高速メモリバスより。距離に関連する問題。電気信号がさらに伝わらなければならないほど、信号が破損することなくその信号をワイヤのもう一方の端に届けることが難しくなります。その問題の唯一の治療法は、それを遅くすることです。CPUをマシンのRAMに接続するワイヤーに関する大きな問題は、ケースをポップしてワイヤーを確認できます。

プロセッサはこの問題に対する対策を備えており、RAMにバイトのコピーを格納するキャッシュ、バッファを使用します。重要なものはL1キャッシュで、通常はデータ用に16キロバイト、命令用に16キロバイトです。小さく、実行エンジンの近くに配置できます。L1キャッシュからバイトを読み取るには、通常2または3 CPUサイクルかかります。次はL2キャッシュで、大きくて遅いです。高級プロセッサーには、L3キャッシュもありますが、それより大きくて遅いです。プロセステクノロジーが向上すると、これらのバッファーは必要なスペースが少なくなり、コアに近づくと自動的に高速になります。これは、新しいプロセッサーが優れている理由と、増え続けるトランジスターの使用方法を管理する大きな理由です。

ただし、これらのキャッシュは完全なソリューションではありません。データがいずれかのキャッシュで使用できない場合、プロセッサはメモリアクセスでストールします。非常に遅いメモリバスがデータを供給するまで続行できません。単一の命令で脂肪の多い100 CPUサイクルを失う可能性があります。

ツリー構造は問題であり、キャッシュフレンドリーではありません。それらのノードは、アドレス空間全体に分散する傾向があります。メモリにアクセスする最も速い方法は、連続したアドレスから読み取ることです。L1キャッシュのストレージの単位は64バイトです。または、言い換えると、プロセッサが1バイトを読み取ると、次の63バイトはキャッシュに存在するため、非常に高速になります。

これにより、配列が最も効率的なデータ構造になります。また、.NET List <>クラスがリストではない理由は、ストレージに配列を使用するためです。辞書のような他のコレクション型についても同じですが、構造的には配列とはあまり似ていませんが、配列で内部的に実装されています。

したがって、while()ステートメントは、BranchDataフィールドにアクセスするためのポインターを逆参照しているため、CPUストールの影響を受ける可能性が非常に高くなります。次のステートメントは、while()ステートメントがすでにメモリから値を取得するという重労働を行っているため、非常に安価です。ローカル変数の割り当ては安価です。プロセッサは書き込みにバッファを使用します。

それ以外の場合は解決する単純な問題ではなく、ツリーを配列にフラット化することは実際的でない可能性が非常に高くなります。ツリーのノードがどの順序でアクセスされるかは通常予測できないためです。赤黒木が役立つかもしれませんが、質問からは明らかではありません。したがって、簡単な結論として、これはすでに期待どおりの速度で実行されているということです。また、高速化が必要な場合は、より高速なメモリバスを備えた優れたハードウェアが必要です。DDR4は今年主流になりつつあります。


1
多分。これらは、メモリ内、つまりキャッシュ内ですでに隣接している可能性が非常に高いです。GCヒープ圧縮アルゴリズムを使用すると、それ以外の場合は予測不可能な影響を及ぼします。私にこれを推測させないために、あなたが事実を知っているように測定してください。
ハンスパッサント2013年

11
スレッドはこの問題を解決しません。より多くのコアを提供しますが、メモリバスは1つだけです。
ハンスパッサント2013年

2
おそらくbツリーを使用すると、ツリーの高さが制限されます。そのため、各ノードは単一の構造であり、キャッシュに効率的に格納できるため、アクセスするポインターを少なくする必要があります。この質問も参照してください。
MatthieuBizien 2013年

4
通常どおり、幅広い関連情報を含む詳細な説明。+1
Tigran 2013年

1
ツリーへのアクセスパターンがわかっていて、それが80/20(アクセスの80%は常にノードの同じ20%にある)ルールに従っている場合、スプレイツリーのような自己調整ツリーもより高速になる可能性があります。 en.wikipedia.org/wiki/Splay_tree
Jens Timmerman、

10

メモリキャッシュ効果に関するHansの素晴らしい答えを補足するために、物理メモリの変換とNUMA効果への仮想メモリの説明を追加します。

仮想メモリコンピュータ(現在のすべてのコンピュータ)では、メモリアクセスを行うときに、各仮想メモリアドレスを物理メモリアドレスに変換する必要があります。これは、変換テーブルを使用してメモリ管理ハードウェアによって行われます。このテーブルは、プロセスごとにオペレーティングシステムによって管理され、それ自体がRAMに格納されます。仮想メモリの各ページについて、この変換テーブルには、仮想ページを物理ページにマッピングするエントリがあります。高価なメモリアクセスについてのハンスの議論を思い出してください。各仮想から物理への変換にメモリ参照が必要な場合、すべてのメモリアクセスのコストは2倍になります。解決策は、変換ルックアサイドバッファーと呼ばれる変換テーブルのキャッシュを用意することです。(略してTLB)。TLBは大きくなく(12〜4096エントリ)、x86-64アーキテクチャでの一般的なページサイズは4 KBのみです。つまり、TLBヒットで直接アクセスできるのは最大16 MBです(それよりもさらに小さい、サンディ)。 TLBサイズが512アイテムのブリッジ)。TLBミスの数を減らすには、オペレーティングシステムとアプリケーションを連携させて2 MBなどの大きなページサイズを使用し、TLBヒットでアクセスできるメモリスペースを大幅に増やすことができます。このページでは、メモリアクセスを大幅に高速化できるJava 大きなページを使用する方法について説明します

コンピューターに多数のソケットがある場合、それはおそらくNUMAアーキテクチャーです。NUMAは、Non-Uniform Memory Accessを意味します。これらのアーキテクチャでは、一部のメモリアクセスのコストが他のアーキテクチャより高くなります。例として、32 GBのRAMを搭載した2ソケットコンピュータの場合、各ソケットにはおそらく16 GBのRAMが搭載されています。このサンプルコンピュータでは、ローカルメモリアクセスは、別のソケットのメモリへのアクセスよりも安価です(リモートアクセスは20〜100%遅く、おそらくそれ以上)。そのようなコンピューターでツリーが20 GBのRAMを使用し、少なくとも4 GBのデータが他のNUMAノード上にあり、リモートメモリのアクセスが50%遅い場合、NUMAアクセスはメモリアクセスを10%遅くします。さらに、単一のNUMAノードに空きメモリしかない場合、飢えたノードでメモリを必要とするすべてのプロセスには、アクセスがより高い他のノードからメモリが割り当てられます。最悪でも、オペレーティングシステムは、飢えたノードのメモリの一部をスワップアウトすることをお勧めします。これにより、さらに高価なメモリアクセスが発生します。これについては、MySQLの「スワップの狂気」問題と、 Linuxにいくつかのソリューションが提供されているNUMAアーキテクチャの影響で詳細に説明されています(すべてのNUMAノードでメモリアクセスを分散し、スワップを回避するためにリモートNUMAアクセスの箇条書きをかむ)さらにソケットにRAMを割り当て(16および16 GBではなく24および8 GB)、プログラムがより大きなNUMAノードでスケジュールされるようにすることも考えられますが、これにはコンピューターとドライバーへの物理的なアクセスが必要です;-) 。


4

これは答えそのものではなく、メモリシステムの遅延についてハンスパッサントが書いたことを強調しています。

コンピュータゲームなどの非常に高性能なソフトウェアは、ゲーム自体を実装するように作成されているだけでなく、コードとデータ構造がキャッシュとメモリシステムを最大限に活用するように、つまり制限されたリソースとして扱うように適合されています。キャッシュの問題に対処する場合、通常、データが存在する場合、L1は3サイクルで配信すると想定しています。そうでなく、L2に行かなければならない場合は、10サイクルと想定します。L3 30サイクルおよびRAMメモリ100。

追加のメモリ関連のアクションがあり、それを使用する必要がある場合は、さらに大きなペナルティが課され、バスロックになります。Windows NT機能を使用する場合、バスロックはクリティカルセクションと呼ばれます。自家製の品種を使用する場合、それをスピンロックと呼ぶかもしれません。ロックが設定される前に、システム内で最も遅いバスマスタリングデバイスと同期する名前は何でもかまいません。最も遅いバスマスタリングデバイスは、33MHzで接続された従来の32ビットPCIカードである可能性があります。33MHzは、標準的なx86 CPU(@ 3.3 GHz)の100分の1の周波数です。バスロックを完了するには300サイクル以上を想定していますが、その時間が何倍もかかる可能性があることを知っているので、3000サイクルが表示されても驚かないでしょう。

初心者のマルチスレッドソフトウェア開発者は、バスロックをいたるところに使用し、コードが遅い理由を不思議に思います。トリック-メモリに関連するすべてのものと同様に-アクセスを節約することです。

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