Boost.Pool に勝った方法 #1:紹介とプロファイリングの結果

私がメモリ 0.5 をリリースしたとき、reddit のある人が私のライブラリと Boost.Pool との比較を尋ねてきました。私は機能の比較を提供し、Boost と私の実装の両方をすばやくプロファイリングしました。悲しいことに、Boost.Pool は私のライブラリを打ち負かしました - ほとんどの場合.

そのため、過去数週間にわたって、パフォーマンスの問題に対処し、実装を書き直しました。したがって、バージョン 0.5-1 でも、基本的には同じアルゴリズムを使用していますが、現在、私のライブラリは Boost.Pool と同等か高速です。

このシリーズでは、私が行った変更について説明し、変更を行って学んだ最適化に関するいくつかの教訓を共有します。最初の部分では、ここで使用されるさまざまな割り当てアルゴリズムを紹介し、プロファイリング結果の概要を示します。

アロケーター

私のライブラリには、アロケータでいくつかのパフォーマンス比較を実行する単純なプロファイリング ターゲットが含まれています。それらは:

    <リ>

    ヒープ :私の heap_allocator は、std::malloc() を使用して割り当てます .

    <リ>

    新規 :私の new_allocator は、::operator new を使用して割り当てます .

    <リ>

    スタック :スタック アロケータをモデル化した私の memory_stack 。スタック アロケータは巨大なメモリ ブロックを取り、トップ ポインタを維持します。割り当ては、トップ ポインターを必要なバイト数だけ前方にシフトし、古い位置を返すだけです。解放は直接サポートされていません。アンワインドのみです 以前に照会された場所へのトップ ポインター。

    <リ>

    ノード :私の memory_pool、通常のメモリ プール。プールは 1 つのサイズ (ノード サイズ) の割り当てのみを処理できます .巨大なメモリ ブロックを使用し、現在空いているすべてのノードのリンク リストを維持します。割り当ては単純に最初のノードをポップし、割り当て解除はノードをリストに戻します。空きノードのメモリは無料なので、リンクをそれらに直接埋め込むことができます。ノードのサイズが小さすぎる場合は、大きくする必要があります。

    <リ>

    配列 :私の memory_pool<array_pool> 、配列割り当てのサポートが向上したプール。配列の割り当てでは、ノードをメモリに連続して格納する必要があります。最初はそうです。しかし、リストで多くの (割り当て解除) を行った後、ノードをシャッフルすることができます。したがって、この無料リストは順序付けされています 、ノードは常にソートされたままです。これにより遅くなりますが、配列割り当てのサポートは改善されています。

    <リ>

    :私の memory_pool<small_node_pool> 小さなノード用に最適化されたプール。フリー リストにポインターを格納する代わりに、インデックスを unsigned char として格納するだけです。 .これにより、小さなノードが可能になりますが、unsigned char 以降、簿記が少し増えます。 (通常) 256 しか保持できません 異なる値。そのため、チャンクのリストが維持され、それぞれに個別の空きリストがあります。これは、最新の C++ 設計で説明されているアロケータと同じ設計ですが、わずかに最適化されています。

また、この比較では、Boost のプールの 2 つのバリアントを使用します。1 つは「通常の」割り当てを使用し、もう 1 つは ordered_ を使用します。 バージョン。最初のものは私の Node に似ています プール、私の 配列 への 2 つ目 プール。

Node を参照します 通常/ノード プールとしての順不同の Boost.Pool と私の配列 ordered/array プールとして順序付けられた Boost.Pool どちらも類似した特性とアルゴリズムを持っているためです.

プロファイリング構造

プロファイリング コードは、以下に説明する各割り当て戦略を 1024 回実行し、ナノ秒単位で必要な最小時間を取ります。 すべて (デバッグ) ライブラリのチェックが無効になり、リンク時の最適化を含むすべての最適化が有効になります。

テストされたノード サイズは 1 です 、 248256 、繰り返し 256512 そして 1024 times.配列の場合、{1, 4, 8} * {1, 4, 8} を割り当てます 配列の割り当てをサポートするアロケータのみがテストされます。これは、Small を除くすべてのアロケータです。 そして通常のBoost.Pool.

戦略

割り当て戦略は、要素を割り当てるさまざまな方法を表します。もちろん、アロケーターの存続期間中は、さまざまな割り当て戦略が混在するため、これらは完全に現実的な条件ではありません。

戦略は次のとおりです。

    <リ>

    シングル :単純に 1 つのノード (または 1 つの配列) を割り当てて、割り当てを解除します。これを繰り返す n 回。シングル たとえば、ローカルの std::unique_ptr がある場合、割り当て戦略が発生します。 毎回作成され、後で破棄されるループ内。

    <リ>

    バルク :n を割り当てます ノード (または n ノードの配列) を割り当て、後で同じ割り当て順序で割り当てを解除します。これは、std::vector<std::unique_ptr<T>> がある場合に発生する可能性があります .あなたは n を持っています 作成および破棄される要素 (ここで話しているのはポインターであり、ベクトルの割り当てではありません)。

    <リ>

    一括 (リバース) :バルクと同じです ただし、逆の順序で割り当てを解除します。つまり、最後にすべてコーティングされたノード (配列) が最初に割り当て解除されます。これは std::vector でも発生する可能性があります 、解放の順序は指定されておらず、両方の方法に合理的な議論があります。したがって、優れたアロケーターは両方の Bulk をサポートする必要があります

  • バタフライ :別の バルク です 割り当て解除がランダムな (無秩序な) 順序で行われるバリアント、つまり、割り当てられたポインターが定数シードでシャッフルされます。これは、プログラム内に 1 つのアロケーターからのポインターが多数ある場合に発生する可能性があります。

実際には、単一の戦略ではなく、組み合わせがあります。たとえば、すべての戦略は、以前の割り当てなしでアロケーターから始まります。これはおそらくそうではありません。

期待される結果

ヒープ /新規 任意のを処理する必要がある汎用アロケータです。 割り当てサイズ/スキーム。したがって、他のアロケータのように特定のスキームに特化することはできません。したがって、一般に、他のアロケータよりも遅くなるはずです。

スタック かなり その割り当ては基本的にポインターのインクリメントであり、プロファイリング コードには割り当て解除が存在しないため、他の何よりも高速です。

通常のプールの割り当てはノードをポップし、割り当て解除はノードを押し戻すだけです。これは割り当て戦略に依存しないため、my と Boost の両方の実装のすべての戦略で一定の結果が得られるはずです。

同じことが小さなノード プールにも当てはまります。ただし、フリー リストはチャンクのみであり、最初に適切なチャンクを見つける必要があるため、遅くなります。

ただし、順序付けられたプールは異なります。割り当てはノードをポップするだけですが、割り当て解除はそれを正しい位置に挿入する必要があります リストの順序を維持するため。単一リンク リスト (ノードごとに 1 つのポインター) のみを扱っているため、各ノードを 1 つずつ比較して先頭からリストをトラバースする必要があります。ストラテジーでは、これは前に挿入するだけですが、他の戦略では後ろに挿入する必要があるため、全体 をトラバースする必要があります。 list.ひどいパフォーマンスが Bulk であるかどうか および バルク (リバース) ソート順によって異なります。そして Butterfly 間にあります:一部のノードではリストの大部分を通過する必要がありますが、他のノードでは非常に早く終了する可能性があります。

これは、配列とノード割り当ての両方で同じである必要があります。同じ基本アルゴリズムを使用しているため、私のプール実装と Boost のプール実装に大きな違いはないはずです。

実際の結果 (バージョン 0.5)

これが私が得た実際の結果です:https://gist.github.com/foonathan/3aa3114284863bf3141a

汎用アロケータは 遅い、スタック 最速で小さい およびノード 小さいと同様の安定したパフォーマンスを持っている 少し遅いです。また、順序付けされた Boost.Pool は、順序付けられたプールの期待される動作を示しています。 バルク (リバース) に最適化されていることは明らかです .

これまでのところ、期待どおりです。

しかし…

Boost.Pool はすべてのアロケータを 大幅に 上回っています 、スタックも !また、私の配列プールは、両方のバルクで一定のパフォーマンスを管理し、Butterfly の回帰のみを管理します Boost と同様のパフォーマンスを発揮します。

明らかに、これは私が望んでいるものではありません。

実際の結果 (バージョン 0.5-1)

したがって、一連の最適化の後、次の結果が得られました:https://gist.github.com/foonathan/904ed04f57aeecd132e3

今、スタック は大幅に高速であり、2 つの通常のプールのパフォーマンスは同様です (私のものは 2 つのバルクと Butterfly でわずかに高速です) ).

小さいノード プールも高速ですが、通常のプールよりも低速です。空きリストを使用しますが、チャンクごとに 1 つずつ複数の空きリストを使用します。割り当てと特に割り当て解除は、最初に適切なチャンクを見つける必要があります。

注文したプールは同じ特性を示していますが、はるかに高速です。Single ではわずかに遅いだけです。 および バルク (リバース) しかしかなり 他のバルクでより速く そしてバタフライButterfly ではまだ悪いですが .

これは、配列の割り当てについても同じです。指摘しておくべき唯一のことは、通常のプールも配列の割り当てをサポートしており、それらは順序付けられたプールよりも高速であることです。これは、配列の割り当てに通常のプールを選択する必要があるという意味ではありません。

フリー リストでの配列の割り当てでは、リストをスキャンして、割り当てを満たすのに十分な数の隣接する空きノードを探す必要があります。アロケータの再割り当ては最小限に抑えられます。ただし、通常のプールのようにノードの順序が維持されていない場合、これが発生する可能性が高くなります。また、検索に時間がかかる場合があります。

ソートされた割り当て解除スキームを使用した単一の割り当て戦略しかないため、この動作はここでは明らかになりません (Butterfly を除く)。 ) で、プールの容量は十分に大きいです。しかし、実際には、ノード プールは配列の割り当てに劣り、アロケーターがさらに大きくなる可能性があります。

では、ここで何が起きているのでしょうか?

2 つのバルク ケースで、どのようにして優れた順序付きプールを持つことができたのでしょうか?

では、0.5 でメモリ スタックとプールをどうやって台無しにしてしまったのでしょうか?

このシリーズでは、これらの質問にお答えします。何が起こるかを正確にカバーし、優れた最適化で学んだ一般的なアドバイスを提供します。 ™.

どうぞお楽しみに!