web-dev-qa-db-ja.com

ソートされたツリーをメモリに保存する最適な方法

ツリーをメモリに最も効率的に格納するための一般的な構造は何ですか(アクセス操作とスペース)。

アクセス操作では、連続したメモリ(メモリの局所性)を何らかの形で利用する必要がありますが、ツリーでどのように機能するかを判断するのに苦労しています。このようにして、走査またはランダムサブツリーアクセス中にキャッシュをより有効に活用できます。また、ディスクなどに行くのではなく、メモリ内の構造だけに関心があると仮定します。特にB +ツリーについて考えていますが、どのBSTもおそらく機能するでしょう。

具体的には、効率を上げるためにポインタを整理する方法を考えています。

たとえば、次のJSツリーがあるとします。

var tree = {
  a: {
    b: {
      c: {
        d: {
          e: 100,
          f: 200,
          g: 300
        },
        h: {
          i: 400,
          j: 500,
          k: 600
        }
      }
    }
  }
}

これが実際にハッシュテーブルとして実装されている(キーが値を取得するためにハッシュされる)と想像すると、ツリーのメモリ内の最適なレイアウトは何ですか。各層は、それ自体がハッシュテーブルであり、その構造として基になる配列を持つ場合があります。これは、各レベルのノードが互いに同じリージョンにあるが、おそらく隣り合っていないことを意味します。

ハッシュテーブルの代わりに、リンクされたリストのようなものとして実装された場合、各レベルcouldは互いに隣接する子を持ちますが、リスト自体は依然としてメモリ内の任意の場所にある可能性があります。問題は、リストを整理して、効率を上げるために何らかの方法でリストをメモリ内に近づけることができるかどうかです。または、リストに標準の構造/サイズの範囲があるため、メモリ内の特定の場所にジャンプして検索を高速化できます。そのようなもの。

更新

おおよそ数百万のデータと約500MBのスペースの必要性の制限を考えると。

4
Lance Pollard

最適なレイアウトは、ほぼ確実にアプリケーションに依存します。とはいえ、キャッシュライン(通常は現在のプロセッサでは64バイト)にまとめてアクセスされる、可能な限り適合するものであれば何でも機能します。検索ツリーの場合、かなり小さいキーがあると想定して、キャッシュラインに収まるだけの数のキーとポインタの並べ替えられたリストを格納することは、おそらく達成できる最高の方法です。

つまり、任意にbinaryツリーに制限するのではなく、キャッシュラインの使用可能なスペースを使用して、ヒットする必要のあるキャッシュラインの数を最小限に抑えます。基本的に、ディスク上の構造にB +ツリーを使用するのと同じ考慮事項ですが、ターゲットサイズは小さくなります。

文字列キーの場合は、ノード内のすべてのキーの共通のプレフィックスを削除して、より多くのアイテムを格納できるようにします。可変長のキーを格納する必要がある場合があるため、各ノードで可変数のアイテムを操作できることが重要です。

3
Jules

アクセス操作では、連続したメモリ(メモリの局所性)を何らかの形で利用する必要がありますが、ツリーでどのように機能するかを判断するのに苦労しています。

私が最近使用している一般化されたソリューションは必ずしも最適ではありません(最適なソリューションは指摘されているようにユースケースに依存し、さらにいくつかの仮定を行うと非常にコンパクトになりますが、これにより仮定が最小限に保たれ、N-aryツリーでも機能しますそして、不均衡なもの)、しかし、少なくとも頻繁に同時にアクセスされるノードで参照の局所性を達成し、合理的なコンパクトさは2パスのアプローチです(最初のパスはツリーの構築に関係し、2番目のパスはそれをトラバースし、参照の局所性を最適化します) 。

ソリューションの最初の側面は、ツリー全体のすべてのノードを1つの配列(例:C++ではstd::vectorまたはJavaではArrayList)に隣接して格納することです。 parent->childリンクは、無効になる傾向がある64ビットポインター(配列のサイズを変更する必要がある場合)として保存されませんが、32ビットインデックスであり、リンクのサイズを半分にすると、隣接するノードが多くなる傾向があります。キャッシュラインに収まる。

struct BstNode
{
    ...

    // Child links as indices into the nodes array. -1 is
    // used in place of a null pointer.
    int32_t children[2];
};

// The array storing all the nodes for the tree.
std::vector<BstNode> nodes;

これで、ツリーの構築方法に応じて、リンクが場所全体の配列にポイントされ、一般的なケーストラバーサルパターンの多くをスキップする可能性があります。

そのため、ツリーのコピーを作成するコピーコンストラクター内に2番目のパスがあり、コピーコンストラクターはクリティカル実行パスに合わせてツリーを走査し、その走査順序でノードを挿入します。これは、このアプローチでは、トラバース中に頻繁にアクセスされるノードをこのnodes配列内で互いに隣り合わせに配置する効果があります。

ツリーが頻繁に更新(挿入と削除)される傾向があり、空間的な局所性を改善したい場合(動的なユースケースでこれを実行することを検討するためにいくつかのホットスポットを確認する必要があります)、定期的にこれを行う可能性がありますツリーが変更されたものとしてマークされているときにメモリレイアウトを最適化し、最適化されたコピーを元のコピーと交換するためのコピー構築パス。

参照の局所性のためにリンクされた構造を最適化するこの手法を使用して別の質問のためにホイップしたことがあります。ここでも、それは最適ではありませんが実装が簡単で、非常に一般化された「まともな」ソリューションです。これはGIF形式であり、50万のエージェントが衝突検出(フレームごとの空間インデックスへの50万のクエリを含む)でフレームごとに移動し、CPUのラスタライズはすべて1つのスレッドで100 FPSを超えて行われます(残念ながら通信されません) GIFでは、フレームレートは少し縮小して、一度に50万のエージェントをすべて表示しますが、これはリンクトラバーサルではなく、ラスター化によるものであり、そのパフォーマンスは主に結果です。衝突検出に使用される空間インデックスに対して、この「1つの配列へのインデックスとしてのリンク」アプローチを使用した、この種のLOR最適化の例:

enter image description here

繰り返しますが、私はこれよりも上手くできます(SO質問の2時間の実装だけで、シングルスレッドで簡単に理解できるように意図的にコードを記述しました)。よりカスタマイズされた構造を作成した仮定ですが、これはあらゆる種類のリンクされた構造に機能する迅速で遅延のあるアプローチです。スキップしてスキップするリンクとしてインデックスを持つ配列を使用して、このようにリンクリストを作成することもできます。

隣接する配列にノードを格納するリンクリスト: enter image description here

一定時間で要素を途中から削除する: enter image description hereenter image description here

新しい要素の挿入: enter image description here

また、リンクリストの中央への挿入や削除などの処理を行うと、走査の空間的な局所性が低下することがわかります(中央への一定時間の挿入は、実際には配列の後ろに追加されるため)。順番にトラバースするリストをコピーしてトラバース順でコピーに挿入するだけで、すばやく復元されます。これは同じ方法で、二重リンクリストノードは次のようになります。

struct DllNode
{
     ...

     // Using -1 in place of a null.
     int32_t prev;
     int32_t next;
};

// Stores all the nodes for the linked list in one array.
std::vector<DllNode> nodes;

// Stores an index to the first removed node in the array to reclaim
// on subsequent insertions, or -1 if there are no removed (free) nodes.
int32_t free_head;

これは、フリーリストアロケーターで得られるものと、アロケーターとは別のリンクリストの実装を組み合わせたものですが、64ビットのリンクの半分のサイズで、はるかに単純でエラーが発生しにくい実装で1つのデータ構造に結合されています。アーキテクチャ。最近では、アロケータをいじらずに、効率的なメモリ割り当てとメモリレイアウトの懸念をデータ構造自体に統合する方がはるかに簡単であるため、カスタムアロケータはほとんど使用しません(最近、カスタムアロケータを使用する唯一の理由は、パフォーマンスに関連する理由。たとえば、アロケータがテストに割り当ててメモリ不足の例外がテストで適切に処理されることを確認できるメモリの量に表面的に制限を課すことができるなど)。

四分木のように同じこと:

struct QuadTreeNode
{
    ...
    int32_t children[4];
};

// Stores all the nodes for the tree.
std::vector<QuadTreeNode> nodes;

...ただし、クワッドツリーの場合、この担当者を好むと、パフォーマンスが向上します(必要以上のノードを作成できる場合でも、ノードのサイズははるかに小さくなります)。

struct QuadTreeNode
{
    ...
    // All four children are always stored contiguously, so we
    // only need the index of the first child.
    int32_t first_child;
};

などなど。私は最近のリンク構造にも同じアプローチを使用していますが、合理的に効率的であり、あらゆる場所でキャッシュミスを回避する必要がありますが、できるだけ効率的ではありません(オフラインレンダリングなど、最も重要なものにははるかに複雑なアプローチを使用しています)バウンディングボリューム階層のパフォーマンス要件)。

最初に必要なのは、すべてのノードを格納する1つの配列です。これは、あらゆる種類の空間的な局所性を確保するためのメモリ割り当てに関しては、実際的な要件になるためです。次に、ポインタの代わりにインデックスを使用できます。最後に、その段階に到達すると、コピー構築パスがトラバース順序でノードを挿入するだけで、コピーのためにメモリ内(配列内)で互いに隣接して頻繁にトラバースしたノードを取得できることがわかります。

1
Dragon Energy

キャッシュメモリの効果は、ツリーの編成/構造化の方法による影響はほとんどなく、ツリーの使用シナリオによる影響が非常に大きいことを考慮してください。

ビットを入力してすぐに同じ要素を読み取る場合、キャッシュは適切に機能します。入力した場合は、他のものを実行してから読み取ります(はるかに実用的なシナリオ)。キャッシュは他のものでフラッシュされるため、おそらくそれほど多くありません。

0
Martin Maat