連続していないアレイはパフォーマンスが良いですか?


12

C#では、ユーザーがを作成しList<byte>てバイトを追加すると、スペースが不足し、さらにスペースを割り当てる必要がある可能性があります。以前の配列のサイズの2倍(または他の乗数)を割り当て、バイトをコピーし、古い配列への参照を破棄します。私は、各割り当てが高価であるため、リストが指数関数的に増加することを知っていて、これはそれを制限O(log n)だけ追加配分、10余分な項目ににつながるたびO(n)配分を。

ただし、配列サイズが大きい場合、多くの無駄なスペースが存在する可能性があり、おそらく配列のほぼ半分になります。メモリを削減するために、リストに4MB未満がある場合にバッキングストアとしてNonContiguousArrayList使用する同様のクラスを作成し、サイズが大きくなるとList<byte>追加の4MBバイト配列を割り当てNonContiguousArrayListます。

異なりList<byte>、これらの配列が非連続であるので、周りのデータのないコピー、単に追加の4Mの割り当てはありません。アイテムが検索されると、インデックスは4Mで除算されてアイテムを含む配列のインデックスが取得され、4Mを法として配列内のインデックスが取得されます。

このアプローチの問題を指摘できますか?私のリストは次のとおりです。

  • 不連続な配列にはキャッシュの局所性がないため、パフォーマンスが低下します。ただし、4Mのブロックサイズでは、適切なキャッシングに十分なローカリティがあるようです。
  • アイテムへのアクセスはそれほど単純ではなく、間接的なレベルが余分にあります。これは最適化されますか?キャッシュの問題が発生しますか?
  • 4Mの制限に達すると線形に増加するため、通常よりも多くの割り当てを行うことができます(たとえば、1 GBのメモリに対して最大250の割り当て)。4M以降は追加のメモリはコピーされませんが、追加の割り当てがメモリの大きなチャンクをコピーするよりも高価かどうかはわかりません。

8
理論を使い果たしました(キャッシュを考慮し、漸近的な複雑さについて説明しました)、残っているのはパラメーター(ここではサブリストごとに4Mアイテム)をプラグインし、おそらく最適化するだけです。ハードウェアと実装を修正しなければ、パフォーマンスをさらに議論するにはデータが少なすぎるため、ベンチマークを実施する時が来ました。

3
1つのコレクションで400万を超える要素を使用している場合、コンテナのマイクロ最適化はパフォーマンスの懸念の最も少ないものになると思います。
テラスティン

2
説明する内容は、展開されたリンクリスト(非常に大きなノード)に似ています。キャッシュの局所性がないというあなたの主張は少し間違っています。単一のキャッシュライン内に収まるのは配列の大部分だけです。64バイトとしましょう。したがって、64バイトごとにキャッシュミスが発生します。次に、ノードが正確に64バイトの倍数(ガベージコレクション用のオブジェクトヘッダーを含む)である展開されたリンクリストについて考えます。それでも、64バイトごとに1つのキャッシュミスが発生するだけであり、メモリ内でノードが隣接していないことは問題ではありません。
ドーバル

@Doval 4Mのチャンクは配列自体に格納されるため、実際には展開されたリンクリストではありません。したがって、要素へのアクセスはO(n / B)ではなくO(1)で、Bはブロックサイズです。

2
@ user2313838 1000MBのメモリと350MBのアレイがある場合、アレイを拡張するために必要なメモリは1050MBになり、使用可能な容量より大きくなります。これが主な問題です。有効な制限は合計スペースの1/3です。TrimExcessリストが既に作成されている場合にのみ役立ちますが、それでもコピーのために十分なスペースが必要です。
noisecapella

回答:


5

あなたが言及した尺度では、懸念はあなたが言及したものとは全く異なります。

キャッシュの局所性

  • 関連する2つの概念があります。
    1. 局所性、最近アクセスされた同じキャッシュライン上のデータの再利用(空間的局所性)(一時的局所性)
    2. 自動キャッシュプリフェッチ(ストリーミング)。
  • あなたが言及したスケール(100 MBからギガバイト、4MBチャンク)では、2つの要因は、メモリレイアウトよりもデータ要素アクセスパターンに関係しています。
  • 私の(無知な)予測は、統計的には、巨大な連続したメモリ割り当てよりもパフォーマンスの差はあまりないかもしれないということです。ゲインもロスもありません。

データ要素のアクセスパターン

  • この記事では、メモリアクセスパターンがパフォーマンスにどのように影響するかを視覚的に示します。
  • 要するに、アルゴリズムが既にメモリ帯域幅によってボトルネックになっている場合、パフォーマンスを改善する唯一の方法は、既にキャッシュにロードされているデータを使用してより有用な作業を行うことです。
  • 言い換えれば、たとえではYourList[k]YourList[k+1]連続している可能性が高い(1ではないという四百万のチャンスで)、その事実はない、ヘルプのパフォーマンスあなたは完全にランダムに、または例えば大予測不可能な前進であなたのリストにアクセスする場合while { index += random.Next(1024); DoStuff(YourList[index]); }

GCシステムとの相互作用

  • 私の意見では、これはあなたが最も焦点を当てるべき場所です。
  • 少なくとも、デザインがどのように相互作用するかを理解してください。
  • 私はこれらのトピックに精通していないので、他の人に貢献してもらいます。

アドレスオフセット計算のオーバーヘッド

  • 典型的なC#コードはすでに多くのアドレスオフセット計算を行っているので、スキームからの追加のオーバーヘッドは、単一の配列で動作する典型的なC#コードよりも悪くありません。
    • C#コードは配列範囲のチェックも行うことに注意してください。そしてこの事実は、C#がC ++コードと同等の配列処理パフォーマンスに達することを妨げません。
    • その理由は、パフォーマンスは主にメモリ帯域幅によってボトルネックになるためです。
    • メモリ帯域幅からユーティリティを最大化する秘trickは、メモリの読み取り/書き込み操作にSIMD命令を使用することです。典型的なC#も典型的なC ++もこれを行いません。ライブラリまたは言語アドオンを使用する必要があります。

理由を説明するために:

  • アドレス計算を行う
  • (OPの場合、チャンクのベースアドレス(既にキャッシュにある)を読み込み、さらにアドレス計算を行います)
  • 要素アドレスからの読み取り/書き込み

最後のステップには、まだ多くの時間がかかります。

個人的な提案

  • CopyRange関数のようにArray.Copy動作しますが、の2つのインスタンスNonContiguousByteArray間、または1つのインスタンスと別のnormalの間で機能する関数を提供できますbyte[]。これらの関数は、SIMDコード(C ++またはC#)を使用してメモリ帯域幅の使用率を最大化できます。その後、C#コードは、複数の逆参照またはアドレス計算のオーバーヘッドなしにコピー範囲で動作できます。

ユーザビリティと相互運用性の懸念

  • どうやら、これNonContiguousByteArrayを連続したバイト配列、または固定可能なバイト配列を想定しているC#、C ++、または外国語ライブラリで使用することはできません。
  • ただし、独自のC ++アクセラレーションライブラリ(P / InvokeまたはC ++ / CLIを使用)を作成する場合、いくつかの4MBブロックのベースアドレスのリストを基になるコードに渡すことができます。
    • たとえば、で始まり、(3 * 1024 * 1024)で終わる要素へのアクセスを許可する必要がある場合(5 * 1024 * 1024 - 1)、アクセスはとにまたがることにchunk[0]なりchunk[1]ます。次に、バイト配列(サイズ4M)の配列(サイズ2)を構築し、これらのチャンクアドレスを固定して、基になるコードに渡すことができます。
  • 別のユーザビリティ上の問題は、あなたが実装することはできないということですIList<byte>。効率的インタフェースをInsertしてRemove、彼らが必要となりますので、ちょうどプロセスに時間がかかりすぎるだろうO(N)時間を。
    • 実際、以外は実装できないように見えますIEnumerable<byte>。つまり、順次スキャンすることができます。

2
データ構造の主な利点を見逃しているようです。これは、メモリを使い果たすことなく、非常に大きなリストを作成できることです。List <T>を展開する場合、古い配列の2倍のサイズの新しい配列が必要であり、両方が同時にメモリに存在する必要があります。
フランクヒルマン

6

C ++には、Standardによる同等の構造が既にあることに注意してくださいstd::deque。現在、ランダムアクセスシーケンスが必要な場合のデフォルトの選択肢として推奨されています。

実際には、データが特定のサイズを超えると、連続メモリはほぼ完全に不要になります。キャッシュラインはわずか64バイトで、ページサイズはわずか4〜8 KBです(現在の典型的な値)。数MBの話を始めたら、懸念事項として実際に窓から消えてしまいます。同じことが割り当てコストにも当てはまります。とにかく、すべてのデータを処理するための価格は、たとえそれを読み取るだけでも、割り当ての価格を小さくします。

それを心配する他の唯一の理由は、C APIとのインターフェースのためです。しかし、とにかくリストのバッファへのポインタを取得することはできませんので、ここでは心配はありません。


おもしろいです。deque同じような実装があることは知りませんでした
-noisecapella

現在std :: dequeを推奨しているのは誰ですか?ソースを提供できますか?私はいつもstd :: vectorが推奨されるデフォルトの選択肢だと思っていました。
Teimpz

std::dequeMS標準ライブラリの実装が非常に悪いため、実際には非常に推奨されていません。
セバスチャンレッド

3

データ構造内のサブ配列のように、メモリチャンクが異なる時点で割り当てられると、メモリ内で互いに離れた場所に配置できます。これが問題であるかどうかはCPUに依存し、これ以上予測するのは非常に困難です。あなたはそれをテストしなければなりません。

これは素晴らしいアイデアであり、私が過去に使用したものです。もちろん、サブアレイのサイズと除算のビットシフトには2のべき乗のみを使用する必要があります(最適化の一部として発生する場合があります)。このタイプの構造は、コンパイラが単一の配列間接指定をより簡単に最適化できるという点で、やや遅いことがわかりました。これらのタイプの最適化は常に変化するため、テストする必要があります。

主な利点は、これらのタイプの構造を一貫して使用する限り、システムのメモリの上限に近づけることができることです。データ構造を大きくし、ガベージを生成しない限り、通常のリストで発生する余分なガベージコレクションを回避できます。巨大なリストの場合、これは大きな違いを生む可能性があります。実行を継続することと、メモリが不足することの違いです。

サブアレイチャンクが小さい場合にのみ、追加の割り当てが問題になります。これは、各アレイの割り当てにメモリオーバーヘッドがあるためです。

辞書(ハッシュテーブル)に同様の構造を作成しました。.netフレームワークによって提供される辞書には、リストと同じ問題があります。辞書は、再ハッシュを避ける必要があるという点でより困難です。


圧縮コレクターは、隣接するチャンクを圧縮できます。
DeadMG

@DeadMG私はこれが発生しない状況について言及していました。間には他のチャンクがあり、それはゴミではありません。List <T>を使用すると、アレイの連続メモリが保証されます。チャンクリストを使用すると、言及した幸運な圧縮状況がない限り、メモリはチャンク内でのみ連続します。ただし、圧縮には大量のデータを移動する必要があり、大きな配列は大きなオブジェクトヒープに格納されます。複雑です。
フランクヒルマン

2

4Mのブロックサイズでは、単一のブロックでさえ物理メモリ内で連続しているとは限りません。通常のVMページサイズよりも大きい。その規模では意味のない局所性。

ヒープの断片化について心配する必要があります:ヒープ内でブロックがほとんど連続しないように割り当てが発生した場合、GCによってブロックが回収されると、断片化されすぎて収まることができないヒープになりますその後の割り当て。関係のない場所で障害が発生し、アプリケーションの再起動を強制する可能性があるため、これは通常、悪い状況です。


圧縮GCには断片化がありません。
DeadMG

これは事実ですが、LOHコンパクションは.NET 4.5以降で利用可能です。
user2313838

ヒープの圧縮は、標準の再割り当て時のコピー動作よりもオーバーヘッドが大きくなる場合がありListます。
user2313838

とにかく十分に大きく適切なサイズのオブジェクトは、事実上断片化がありません。
DeadMG

2
@DeadMG:(この4MBスキームを使用した)GCコンパクションに関する真の懸念は、これらの4MBのビーフケーキをシャベルで動かすのに無駄な時間を費やしている可能性があることです。その結果、GCが大幅に一時停止する可能性があります。このため、この4MBスキームを使用する場合、重要なGC統計を監視して、それが何をしているかを確認し、修正アクションを実行することが重要です。
ルワン

1

コードベース(ECSエンジン)の最も中心的な部分のいくつかを、記述したデータ構造のタイプを中心に展開しますが、より小さい連続したブロック(4メガバイトではなく4キロバイトなど)を使用します。

ここに画像の説明を入力してください

ダブルフリーリストを使用して、挿入する準備ができているフリーブロック(完全ではないブロック)の1つのフリーリストと、そのブロック内のインデックスのブロック内のサブフリーリストで、一定時間の挿入と削除を実現します。挿入時に再利用する準備ができています。

この構造の長所と短所について説明します。いくつかの短所があるので、いくつかの短所から始めましょう。

短所

  1. この構造に数億個の要素を挿入するにはstd::vector、純粋に連続した構造よりも約4倍時間がかかります。そして、私はマイクロ最適化にかなりまともですが、一般的なケースでは最初にブロックの空きリストの上部にある空きブロックを検査し、次にブロックにアクセスしてブロックの空きインデックスをポップする必要があるため、概念的にはやるべきことがあります空きリストで、要素を空き位置に書き込み、ブロックがいっぱいかどうかを確認し、いっぱいであればブロック空きリストからブロックをポップします。まだ一定時間の操作ですが、に戻るよりもはるかに大きな定数を使用していstd::vectorます。
  2. インデックス作成のための追加の算術演算と間接的な追加のレイヤーを考慮して、ランダムアクセスパターンを使用して要素にアクセスする場合、約2倍の時間がかかります。
  3. イテレータはインクリメントされるたびに追加の分岐を実行する必要があるため、シーケンシャルアクセスはイテレータデザインに効率的にマッピングされません。
  4. 通常、要素ごとに約1ビットのメモリオーバーヘッドが少しあります。要素ごとに1ビットはあまり聞こえないかもしれませんが、これを使用して100万個の16ビット整数を格納すると、完全にコンパクトな配列よりも6.25%多くのメモリが使用されます。ただし、実際には、これstd::vectorを圧縮しvectorて予約する余分な容量を排除しない限り、使用するメモリが少なくなる傾向があります。また、私は通常、そのような小さな要素を保存するためにそれを使用しません。

長所

  1. for_eachブロック内の要素の範囲を処理するコールバックを使用する関数を使用したシーケンシャルアクセスは、シーケンシャルアクセスの速度にほぼ匹敵しますstd::vector(10%の差分のみ)。 ECSエンジンで費やされる時間のほとんどはシーケンシャルアクセスです。
  2. ブロックが完全に空になったときにブロックの割り当てを解除する構造により、中間からの一定時間の削除が可能です。その結果、通常、データ構造が必要以上に大量のメモリを使用しないようにすることは非常に適切です。
  3. コンテナから直接削除されない要素のインデックスは無効になりません。これは、フリーリストアプローチを使用して、後続の挿入時にそれらの穴を取り戻すために穴を残すだけなので、コンテナから直接削除されません。
  4. この構造が膨大な数の要素を保持している場合でも、OSが膨大な数の連続した未使用を見つけるために挑戦しない小さな連続ブロックのみを要求するため、メモリ不足を心配する必要はありません。ページ。
  5. 操作は一般に個々のブロックにローカライズされるため、構造全体をロックすることなく、並行性とスレッドセーフに適しています。

私にとって最大の長所の1つは、次のように、このデータ構造の不変バージョンを作成するのが簡単になることです。

ここに画像の説明を入力してください

それ以来、例外の安全性、スレッドの安全性などを達成するのをはるかに容易にする副作用のないより多くの関数を書くためのあらゆる種類の扉を開いた。このデータ構造は後から見たものであり、偶然ですが、間違いなくコードベースの保守がはるかに簡単になったため、最終的に得られた最も素晴らしい利点の1つです。

不連続な配列にはキャッシュの局所性がないため、パフォーマンスが低下します。ただし、4Mのブロックサイズでは、適切なキャッシングに十分なローカリティがあるようです。

参照の局所性は、4キロバイトのブロックは言うまでもなく、そのサイズのブロックで心配することではありません。通常、キャッシュラインはわずか64バイトです。キャッシュミスを減らしたい場合は、それらのブロックを適切に配置することに集中し、可能な場合はより多くのシーケンシャルアクセスパターンを優先します。

ランダムアクセスメモリパターンをシーケンシャルパターンに変換する非常に迅速な方法は、ビットセットを使用することです。大量のインデックスがあり、それらがランダムな順序で並んでいるとしましょう。それらをただ耕し、ビットセットのビットをマークすることができます。次に、ビットセットを反復処理して、一度に64ビットなどの非ゼロのバイトを確認できます。少なくとも1つのビットが設定されている64ビットのセットに遭遇したら、FFS命令を使用して、設定されているビットをすばやく判断できます。このビットは、アクセスするインデックスを示しますが、現在はインデックスを順番に並べ替えます。

これにはいくらかのオーバーヘッドがありますが、特にこれらのインデックスを何度もループする場合には、場合によってはやりがいのある交換になります。

アイテムへのアクセスはそれほど単純ではなく、間接的なレベルが余分にあります。これは最適化されますか?キャッシュの問題が発生しますか?

いいえ、最適化して削除することはできません。少なくとも、この構造ではランダムアクセスのコストが高くなります。特に一般的なケースの実行パスがシーケンシャルアクセスパターンを使用している場合は、ブロックへのポインターの配列で一時的な局所性が高くなる傾向があるため、キャッシュミスはそれほど増加しません。

4Mの制限に達すると線形に増加するため、通常よりも多くの割り当てを行うことができます(たとえば、1 GBのメモリに対して最大250の割り当て)。4M以降は追加のメモリはコピーされませんが、追加の割り当てがメモリの大きなチャンクをコピーするよりも高価かどうかはわかりません。

実際には、コピーはまれなケースであるため、コピーはより高速であることが多く、log(N)/log(2)合計時間のようなものが発生するだけで、同時に、要素がいっぱいになり再配置する必要がある前に何度も要素を書き込むことができる一般的なケースを簡素化します。したがって、通常、このタイプの構造では、巨大な配列を再割り当てする高価なまれなケースを処理する必要がない場合でも、一般的なケースの作業はより高価になるため、挿入が速くなりません。

すべての短所にもかかわらず、この構造の主な魅力は、メモリ使用量の削減であり、OOMを心配する必要がなく、無効化されないインデックスとポインタを保存できること、同時実行性、および不変性です。構造体へのポインタとインデックスを無効にせずに自分自身をクリーンアップしながら、一定時間内に物を挿入および削除できるデータ構造があると便利です。

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