105

私はパフォーマンス クリティカルな二分決定木を持っています。この質問を 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 チェックはパフォーマンスに大きな打撃を与えます。ツリーは巨大なので、葉の検索にはしばらく時間がかかると思いますが、その 1 行に費やされる不釣り合いな時間を理解したいと思います。

私はもう試した:

  • Null チェックを 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

編集:分岐予測テストを行うことにしました。その間に同一のifを追加したので、

while (node.BranchData != null)

if (node.BranchData != null)

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

別の編集

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


これは、同様のトピックに関する 3 番目の質問です。今回は、1 行のコードに焦点を当てています。この件に関する私の他の質問は次のとおりです。

4

3 に答える 3

182

木はでかい

プロセッサがこれまでで最もコストのかかることは、命令を実行することではなく、メモリにアクセスすることです。最新のCPUの実行コアは、メモリ バスよりも倍も高速です。距離に関連する問題で、電気信号が遠くに移動する必要があるほど、その信号が破損することなくワイヤの反対側に配信されるのが難しくなります。その問題の唯一の解決策は、速度を遅くすることです。マシンの CPU を RAM に接続するワイヤの大きな問題は、ケースを開けてワイヤを見ることができることです。

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

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

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

これにより、配列が最も効率的なデータ構造になります。また、.NET List<> クラスがまったくリストではない理由として、ストレージに配列が使用されます。ディクショナリなどの他のコレクション型についても同様で、構造的には配列とほとんど似ていませんが、配列で内部的に実装されています。

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

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

于 2013-05-15T10:20:40.867 に答える
10

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

仮想メモリ コンピューター (現在のすべてのコンピューター) では、メモリ アクセスを行うときに、各仮想メモリ アドレスを物理メモリ アドレスに変換する必要があります。これは、変換テーブルを使用してメモリ管理ハードウェアによって行われます。このテーブルは、プロセスごとにオペレーティング システムによって管理され、それ自体が RAM に格納されます。仮想メモリのページごとに、この変換テーブルにエントリがあり、仮想ページから物理ページにマッピングされます。コストのかかるメモリ アクセスに関する Hans の議論を思い出してください。仮想から物理への変換ごとにメモリ ルックアップが必要な場合、すべてのメモリ アクセスのコストは 2 倍になります。解決策は、変換ルックアサイド バッファと呼ばれる変換テーブルのキャッシュを用意することです。(略してTLB)。TLB は大きくなく (12 から 4096 エントリ)、x86-64 アーキテクチャの典型的なページ サイズはわずか 4 KB です。つまり、TLB ヒットで直接アクセスできるのは最大で 16 MBです (おそらくそれよりもさらに小さく、Sandy 512 アイテムの TLB サイズを持つブリッジ)。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 の「スワップの狂気」問題と NUMA アーキテクチャの影響で詳細に説明されており、Linux 向けのいくつかの解決策が示されています (すべての NUMA ノードにメモリ アクセスを分散し、リモート NUMA アクセスでスワッピングを回避すること)。また、より多くの RAM をソケットに割り当て (16 GB と 16 GB ではなく 24 GB と 8 GB)、プログラムがより大きな NUMA ノードでスケジュールされていることを確認することも考えられますが、これにはコンピューターへの物理的なアクセスとドライバーが必要です ;-) .

于 2013-05-23T10:18:50.923 に答える