web-dev-qa-db-ja.com

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

パフォーマンスが重要なバイナリ決定ツリーがあり、この質問を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 [〜#〜] を理解するふりはしませんが、情報をこすり取ろうとすることができるように誰にでも投稿します。

.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内にある場合は同じものを追加したので、

while (node.BranchData != null)

そして

if (node.BranchData != null)

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

別の編集

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


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

104
Will Calderwood

木は巨大です

プロセッサがこれまでで最も高価なことは、命令を実行することではなく、メモリにアクセスすることです。現代の実行コア [〜#〜] cpu [〜#〜] は、メモリバスよりもmany倍高速です。 距離に関連する問題、電気信号がさらに移動する必要があるほど、信号が破損することなくワイヤのもう一方の端に配信されるのが難しくなります。その問題の唯一の治療法は、それを遅くすることです。 CPUをRAMをマシンに接続するワイヤーに大きな問題があります。ケースをポップし、ワイヤーを参照できます。

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

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

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

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

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

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

180
Hans Passant

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

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

10
jfg956

これはそれ自体が答えではなく、メモリシステムの遅延についてHans Passantが書いたことを強調しています。

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

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

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

4
Olof Forshell