[ 2017年の編集: この投稿の最後にあるC#7に関する重要なコメントを参照してください]
この正確な問題に長年取り組んだ後、私が見つけたいくつかのテクニックと解決策を要約します。文体の好みはさておき、構造体の配列は、実際にはC#で使用可能なメモリ内のバルクストレージ方式です。アプリが高スループット条件下で何百万もの中規模オブジェクトを本当に処理する場合、他の管理された代替手段はありません。
オブジェクトヘッダーとGCプレッシャーがすぐにマウントできるという@kaalusに同意します。それにもかかわらず、私のNLP文法処理システムは、長い自然言語の文を解析および/または生成するときに、1分未満で8〜10ギガバイト(またはそれ以上)の構造分析を操作できます。コーラスをキューに入れます:「C#はそのような問題のためのものではありません...」、「アセンブリ言語に切り替えます...」、「FPGAをワイヤラップします...」など。
代わりに、いくつかのテストを実行してみましょう。まず第一に、値型(struct
)管理の問題の全範囲と、class
対struct
トレードオフのスイートスポットを完全に理解することが重要です。もちろん、ボクシング、ピン留め/安全でないコード、固定バッファーなどGCHandle,
IntPtr,
もありますが、私の意見では最も重要なのは、マネージドポインター(別名「内部ポインター」)の賢明な利用です。
struct
これらのトピックの習得には、 (blittableプリミティブだけでなく)マネージドタイプへの参照を1つ以上含めると、ポインターを使用してアクセスするためのオプションが大幅に減少するという事実の知識も含まれstruct
ますunsafe
。これは、以下で説明するマネージドポインタ方式では問題になりません。したがって、一般的に、オブジェクト参照を含めることは問題なく、この議論に関してはあまり変わりません。
ああ、本当にunsafe
アクセスを維持する必要がある場合はGCHandle
、「通常」モードを使用して、オブジェクト参照を構造体に無期限に格納できます。幸い、GCHandle
構造体にを入れても、安全でないアクセスの禁止はトリガーされません。(GCHandle
それ自体が値型であり、定義して町に行くこともできます。
var gch = GCHandle.Alloc("spookee",GCHandleType.Normal);
GCHandle* p = &gch;
String s = (String)p->Target;
...など。値型として、GCHandleは構造体に直接画像化されますが、格納する参照型は明らかにそうではありません。それらはヒープ内にあり、アレイの物理レイアウトには含まれていません。Free
最後に、GCHandleについては、そのコピーセマンティクスに注意してください。最終的に各GCHandleを割り当てないと、メモリリークが発生するためです。
@Aniは、可変インスタンスを「悪」と見なす人がいることを思い出させstruct
ますが、実際には、それらが事故を起こしやすいという事実が問題です。確かに、OPの例...
s[543].a = 3;
...私たちが達成しようとしていることを正確に示しています:その場でデータレコードにアクセスします。(注意:参照型' class
'インスタンスの配列の構文は同じ外観ですが、この記事では、ここではユーザー定義の値型のギザギザでない配列についてのみ具体的に説明します。)私自身のプログラムでは、通常、配列ストレージ行から(偶然に)完全にイメージ化された特大のblittable構造に遭遇した場合、それは重大なバグと見なします。
rec no_no = s [543]; // no_no.a=3 を実行しないでください
//このように
前の例で示したようなことを絶対にstruct
行わないように注意する必要があるため、できる範囲(幅)がどれだけ大きいか(幅が広いか)は問題ではありません。その埋め込み配列。実際、これはこの記事全体の基本的な前提を示しています。struct
規則:
の配列のstruct
場合、常にその場で個々のフィールドにアクセスします。インスタンス自体を( C#で)「メンション」しないでください。struct
残念ながら、C#言語では、このルールに違反するコードに体系的にフラグを立てたり禁止したりする方法がないため、ここでの成功は通常、慎重なプログラミング規律に依存します。
私たちの「ジャンボ構造体」は配列からイメージ化されることはないため、実際にはメモリ上の単なるテンプレートです。言い換えれば、正しい考え方は、配列要素をオーバーレイstruct
するものとして考えることです。私たちは常に、転送可能またはポータブルなカプセル化装置やデータコンテナではなく、それぞれを空虚な「メモリテンプレート」と見なしています。配列にバインドされた「ジャンボ」値型の場合、「 」の最も実存的な特性、つまり値渡しを呼び出すことは決してありません。struct
例:
public struct rec
{
public int a, b, c, d, e, f;
}
ここではint
、「レコード」ごとに合計24バイトの6秒をオーバーレイします。位置合わせに適したサイズを取得するには、パッキングオプションを検討し、注意する必要があります。ただし、過度のパディングはメモリバジェットを削減する可能性があります。より重要な考慮事項は、非LOHオブジェクトの85,000バイトの制限であるためです。レコードサイズに予想される行数を掛けた値がこの制限を超えないようにしてください。
rec
したがって、ここに示す例では、 sの配列をそれぞれ3,000行以下に保つことをお勧めします。うまくいけば、アプリケーションはこのスイートスポットを中心に設計できます。これは、各行が1つの配列ではなく、個別のガベージコレクションオブジェクトになることを覚えている場合は、それほど制限されません。オブジェクトの増殖を3桁削減しました。これは、1日の作業に適しています。したがって、ここでの.NET環境は、かなり特定の制約で私たちを強く導いています。アプリのメモリ設計を30〜70 KBの範囲のモノリシック割り当てに向けると、実際には多くのそれらを回避できるようです。実際、代わりに、パフォーマンスのボトルネックの厄介なセット(つまり、ハードウェアメモリバスの帯域幅)によって制限されることになります。
これで、物理的に隣接する表形式のストレージに3,000個の6タプルを持つ単一の.NET参照型(配列)ができました。何よりもまず、構造体の1つを「ピックアップ」しないように細心の注意を払う必要があります。Jon Skeetが上で述べたように、「大規模な構造体はクラスよりもパフォーマンスが悪いことがよくあります」。これは絶対に正しいことです。ウィリーニリーの周りにふっくらとした値型を投げ始めるよりも、メモリバスを麻痺させるより良い方法はありません。
それでは、構造体の配列のめったに言及されない側面を利用しましょう。配列全体のすべての行のすべてのオブジェクト(およびそれらのオブジェクトまたは構造体のフィールド)は常にデフォルト値に初期化されます。配列内の任意の行または列(フィールド)に、一度に1つずつ値をプラグインし始めることができます。一部のフィールドをデフォルト値のままにするか、途中のフィールドを邪魔することなく隣接フィールドを置き換えることができます。使用前にスタック常駐(ローカル変数)構造体で必要とされる煩わしい手動初期化はなくなりました。
.NETは常に、d-up構造体全体を爆破しようとしているため、フィールドごとのアプローチを維持するのが難しい場合がありますnew
が、私にとって、このいわゆる「初期化」は、私たちの違反にすぎません。別の装いで、タブー(構造体全体を配列から引き抜くことに対して)。
今、私たちは問題の核心に到達します。明らかに、その場で表形式のデータにアクセスすることで、データシャッフルの忙しい作業を最小限に抑えることができます。しかし、多くの場合、これは不便な面倒です。.NETでは、境界チェックが原因で配列アクセスが遅くなる可能性があります。では、システムがインデックスオフセットを絶えず再計算することを回避するために、配列の内部への「機能する」ポインタをどのように維持しますか。
評価
値型配列ストレージ行内の個々のフィールドを操作するための5つの異なるメソッドのパフォーマンスを評価してみましょう。以下のテストは、構造体全体(配列要素)を抽出または書き換えることなく、その場で、つまり「それらが存在する場所」で、ある配列インデックスに配置された構造体のデータフィールドに集中的にアクセスする効率を測定するように設計されています。5つの異なるアクセス方法が比較され、他のすべての要素は同じに保たれます。
5つの方法は次のとおりです。
- 角かっことフィールド指定子ドットを介した通常の直接配列アクセス。.NETでは、配列は共通型システムの特別でユニークなプリミティブであることに注意してください。@Aniが前述したように、この構文を使用して、値型でパラメーター化されている場合でも、リストなどの参照インスタンスの個々のフィールドを変更することはできません。
- 文書化されていない
__makeref
C#言語キーワードを使用します。
- キーワードを使用するデリゲートを介したマネージドポインタ
ref
- 「安全でない」ポインタ
- #3と同じですが、デリゲートの代わりにC#関数を使用します。
C#のテスト結果を提供する前に、テストハーネスの実装を示します。これらのテストは、.NET 4.5、x64、Workstationgcで実行されるAnyCPUリリースビルドで実行されました。(このテストでは、アレイ自体の割り当てと割り当て解除の効率には関心がないため、上記のLOHの考慮事項は適用されないことに注意してください。)
const int num_test = 100000;
static rec[] s1, s2, s3, s4, s5;
static long t_n, t_r, t_m, t_u, t_f;
static Stopwatch sw = Stopwatch.StartNew();
static Random rnd = new Random();
static void test2()
{
s1 = new rec[num_test];
s2 = new rec[num_test];
s3 = new rec[num_test];
s4 = new rec[num_test];
s5 = new rec[num_test];
for (int x, i = 0; i < 5000000; i++)
{
x = rnd.Next(num_test);
test_m(x); test_n(x); test_r(x); test_u(x); test_f(x);
x = rnd.Next(num_test);
test_n(x); test_r(x); test_u(x); test_f(x); test_m(x);
x = rnd.Next(num_test);
test_r(x); test_u(x); test_f(x); test_m(x); test_n(x);
x = rnd.Next(num_test);
test_u(x); test_f(x); test_m(x); test_n(x); test_r(x);
x = rnd.Next(num_test);
test_f(x); test_m(x); test_n(x); test_r(x); test_u(x);
x = rnd.Next(num_test);
}
Debug.Print("Normal (subscript+field): {0,18}", t_n);
Debug.Print("Typed-reference: {0,18}", t_r);
Debug.Print("C# Managed pointer: (ref delegate) {0,18}", t_m);
Debug.Print("C# Unsafe pointer: {0,18}", t_u);
Debug.Print("C# Managed pointer: (ref func): {0,18}", t_f);
}
特定のメソッドごとにテストを実装するコードフラグメントは長いため、最初に結果を示します。時間は「ダニ」です。低いほど良いことを意味します。
Normal (subscript+field): 20,804,691
Typed-reference: 30,920,655
Managed pointer: (ref delegate) 18,777,666 // <- a close 2nd
Unsafe pointer: 22,395,806
Managed pointer: (ref func): 18,767,179 // <- winner
これらの結果が非常に明白であることに私は驚いた。TypedReferences
おそらく、ポインタと一緒に型情報を持ち歩くため、最も遅くなります。精巧な「通常」バージョンのILコードの重さを考えると、驚くほどうまく機能しました。モードの移行は、安全でないコードを傷つけて、デプロイする各場所を正当化し、計画し、測定する必要があるように思われます。
ただし、配列の内部を指す目的で関数のパラメーターの受け渡しでキーワードを利用することにより、最速の時間を実現しref
、「フィールドアクセスごと」の配列インデックス計算を排除します。
おそらく私のテストのデザインはこれを支持していますが、テストシナリオは私のアプリでの経験的な使用パターンを表しています。これらの数値について私が驚いたのは、ポインターを持っている間もマネージドモードを維持することの利点が、関数を呼び出したり、デリゲートを介して呼び出したりすることによってキャンセルされなかったことです。
勝者
最速のもの:(そしておそらく最も単純なものですか?)
static void f(ref rec e)
{
e.a = 4;
e.e = e.a;
e.b = e.d;
e.f = e.d;
e.b = e.e;
e.a = e.c;
e.b = 5;
e.d = e.f;
e.c = e.b;
e.e = e.a;
e.b = e.d;
e.f = e.d;
e.c = 6;
e.b = e.e;
e.a = e.c;
e.d = e.f;
e.c = e.b;
e.e = e.a;
e.d = 7;
e.b = e.d;
e.f = e.d;
e.b = e.e;
e.a = e.c;
e.d = e.f;
e.e = 8;
e.c = e.b;
e.e = e.a;
e.b = e.d;
e.f = e.d;
e.b = e.e;
e.f = 9;
e.a = e.c;
e.d = e.f;
e.c = e.b;
e.e = e.a;
e.b = e.d;
e.a = 10;
e.f = e.d;
e.b = e.e;
e.a = e.c;
e.d = e.f;
e.c = e.b;
}
static void test_f(int ix)
{
long q = sw.ElapsedTicks;
f(ref s5[ix]);
t_f += sw.ElapsedTicks - q;
}
ただし、プログラム内で関連するロジックをまとめることができないという欠点があります。関数の実装は、2つのC#関数fとtest_fに分割されます。
この特定の問題は、パフォーマンスをわずかに犠牲にするだけで対処できます。次の関数は基本的に前述のものと同じですが、ラムダ関数として関数の1つを他の関数内に埋め込みます。
すぐに
前の例の静的関数をインラインデリゲートに置き換えるには、ref
引数を使用する必要があります。これにより、Func<T>
ラムダ構文を使用できなくなります。代わりに、古いスタイルの.NETからの明示的なデリゲートを使用する必要があります。
このグローバル宣言を1回追加することにより、次のようになります。
delegate void b(ref rec ee);
...プログラム全体でこれを使用してref
、配列rec []の要素に直接アクセスし、それらにインラインでアクセスできます。
static void test_m(int ix)
{
long q = sw.ElapsedTicks;
/// the element to manipulate "e", is selected at the bottom of this lambda block
((b)((ref rec e) =>
{
e.a = 4;
e.e = e.a;
e.b = e.d;
e.f = e.d;
e.b = e.e;
e.a = e.c;
e.b = 5;
e.d = e.f;
e.c = e.b;
e.e = e.a;
e.b = e.d;
e.f = e.d;
e.c = 6;
e.b = e.e;
e.a = e.c;
e.d = e.f;
e.c = e.b;
e.e = e.a;
e.d = 7;
e.b = e.d;
e.f = e.d;
e.b = e.e;
e.a = e.c;
e.d = e.f;
e.e = 8;
e.c = e.b;
e.e = e.a;
e.b = e.d;
e.f = e.d;
e.b = e.e;
e.f = 9;
e.a = e.c;
e.d = e.f;
e.c = e.b;
e.e = e.a;
e.b = e.d;
e.a = 10;
e.f = e.d;
e.b = e.e;
e.a = e.c;
e.d = e.f;
e.c = e.b;
}))(ref s3[ix]);
t_m += sw.ElapsedTicks - q;
}
また、呼び出しごとに新しいラムダ関数がインスタンス化されているように見えるかもしれませんが、注意が必要な場合は発生しません。このメソッドを使用するときは、ローカル変数を「閉じない」ようにしてください(つまり、ラムダ関数の外部にある変数を参照するか、その本体の内部から)、またはデリゲートインスタンスが静的になるのを妨げる他のことを行います。ローカル変数がたまたまラムダに分類され、ラムダがインスタンス/クラスにプロモートされた場合、500万のデリゲートを作成しようとするときに、「おそらく」違いに気付くでしょう。
ラムダ関数にこれらの副作用がないようにしておく限り、複数のインスタンスは存在しません。ここで起こっていることは、ラムダに非明示的な依存関係がないとC#が判断するたびに、静的シングルトンを遅延的に作成(およびキャッシュ)することです。この劇的なパフォーマンスの変化が、サイレント最適化としての私たちの見方から隠されているのは少し残念です。全体的に、私はこの方法が好きです。高速で雑然としません。奇妙な括弧を除いて、ここでは省略できません。
そして残りは
完全を期すために、残りのテストは次のとおりです。通常のブラケットとドット。TypedReference; と安全でないポインタ。
static void test_n(int ix)
{
long q = sw.ElapsedTicks;
s1[ix].a = 4;
s1[ix].e = s1[ix].a;
s1[ix].b = s1[ix].d;
s1[ix].f = s1[ix].d;
s1[ix].b = s1[ix].e;
s1[ix].a = s1[ix].c;
s1[ix].b = 5;
s1[ix].d = s1[ix].f;
s1[ix].c = s1[ix].b;
s1[ix].e = s1[ix].a;
s1[ix].b = s1[ix].d;
s1[ix].f = s1[ix].d;
s1[ix].c = 6;
s1[ix].b = s1[ix].e;
s1[ix].a = s1[ix].c;
s1[ix].d = s1[ix].f;
s1[ix].c = s1[ix].b;
s1[ix].e = s1[ix].a;
s1[ix].d = 7;
s1[ix].b = s1[ix].d;
s1[ix].f = s1[ix].d;
s1[ix].b = s1[ix].e;
s1[ix].a = s1[ix].c;
s1[ix].d = s1[ix].f;
s1[ix].e = 8;
s1[ix].c = s1[ix].b;
s1[ix].e = s1[ix].a;
s1[ix].b = s1[ix].d;
s1[ix].f = s1[ix].d;
s1[ix].b = s1[ix].e;
s1[ix].f = 9;
s1[ix].a = s1[ix].c;
s1[ix].d = s1[ix].f;
s1[ix].c = s1[ix].b;
s1[ix].e = s1[ix].a;
s1[ix].b = s1[ix].d;
s1[ix].a = 10;
s1[ix].f = s1[ix].d;
s1[ix].b = s1[ix].e;
s1[ix].a = s1[ix].c;
s1[ix].d = s1[ix].f;
s1[ix].c = s1[ix].b;
t_n += sw.ElapsedTicks - q;
}
static void test_r(int ix)
{
long q = sw.ElapsedTicks;
var tr = __makeref(s2[ix]);
__refvalue(tr, rec).a = 4;
__refvalue(tr, rec).e = __refvalue( tr, rec).a;
__refvalue(tr, rec).b = __refvalue( tr, rec).d;
__refvalue(tr, rec).f = __refvalue( tr, rec).d;
__refvalue(tr, rec).b = __refvalue( tr, rec).e;
__refvalue(tr, rec).a = __refvalue( tr, rec).c;
__refvalue(tr, rec).b = 5;
__refvalue(tr, rec).d = __refvalue( tr, rec).f;
__refvalue(tr, rec).c = __refvalue( tr, rec).b;
__refvalue(tr, rec).e = __refvalue( tr, rec).a;
__refvalue(tr, rec).b = __refvalue( tr, rec).d;
__refvalue(tr, rec).f = __refvalue( tr, rec).d;
__refvalue(tr, rec).c = 6;
__refvalue(tr, rec).b = __refvalue( tr, rec).e;
__refvalue(tr, rec).a = __refvalue( tr, rec).c;
__refvalue(tr, rec).d = __refvalue( tr, rec).f;
__refvalue(tr, rec).c = __refvalue( tr, rec).b;
__refvalue(tr, rec).e = __refvalue( tr, rec).a;
__refvalue(tr, rec).d = 7;
__refvalue(tr, rec).b = __refvalue( tr, rec).d;
__refvalue(tr, rec).f = __refvalue( tr, rec).d;
__refvalue(tr, rec).b = __refvalue( tr, rec).e;
__refvalue(tr, rec).a = __refvalue( tr, rec).c;
__refvalue(tr, rec).d = __refvalue( tr, rec).f;
__refvalue(tr, rec).e = 8;
__refvalue(tr, rec).c = __refvalue( tr, rec).b;
__refvalue(tr, rec).e = __refvalue( tr, rec).a;
__refvalue(tr, rec).b = __refvalue( tr, rec).d;
__refvalue(tr, rec).f = __refvalue( tr, rec).d;
__refvalue(tr, rec).b = __refvalue( tr, rec).e;
__refvalue(tr, rec).f = 9;
__refvalue(tr, rec).a = __refvalue( tr, rec).c;
__refvalue(tr, rec).d = __refvalue( tr, rec).f;
__refvalue(tr, rec).c = __refvalue( tr, rec).b;
__refvalue(tr, rec).e = __refvalue( tr, rec).a;
__refvalue(tr, rec).b = __refvalue( tr, rec).d;
__refvalue(tr, rec).a = 10;
__refvalue(tr, rec).f = __refvalue( tr, rec).d;
__refvalue(tr, rec).b = __refvalue( tr, rec).e;
__refvalue(tr, rec).a = __refvalue( tr, rec).c;
__refvalue(tr, rec).d = __refvalue( tr, rec).f;
__refvalue(tr, rec).c = __refvalue( tr, rec).b;
t_r += sw.ElapsedTicks - q;
}
static void test_u(int ix)
{
long q = sw.ElapsedTicks;
fixed (rec* p = &s4[ix])
{
p->a = 4;
p->e = p->a;
p->b = p->d;
p->f = p->d;
p->b = p->e;
p->a = p->c;
p->b = 5;
p->d = p->f;
p->c = p->b;
p->e = p->a;
p->b = p->d;
p->f = p->d;
p->c = 6;
p->b = p->e;
p->a = p->c;
p->d = p->f;
p->c = p->b;
p->e = p->a;
p->d = 7;
p->b = p->d;
p->f = p->d;
p->b = p->e;
p->a = p->c;
p->d = p->f;
p->e = 8;
p->c = p->b;
p->e = p->a;
p->b = p->d;
p->f = p->d;
p->b = p->e;
p->f = 9;
p->a = p->c;
p->d = p->f;
p->c = p->b;
p->e = p->a;
p->b = p->d;
p->a = 10;
p->f = p->d;
p->b = p->e;
p->a = p->c;
p->d = p->f;
p->c = p->b;
}
t_u += sw.ElapsedTicks - q;
}
概要
大規模なC#アプリでメモリを大量に消費する作業の場合は、マネージポインターを使用して、値型の配列要素のフィールドに in-situで直接アクセスする方法があります。
パフォーマンスを真剣に考えている場合は、アプリの関連部分の代わりにC++/CLI
(または、さらに言えば)使用するのに十分な理由かもしれません。これらの言語では、関数本体内でマネージポインターを直接宣言できるからです。CIL
C#
では、マネージポインタを作成する唯一の方法は、または引数C#
を使用して関数を宣言することです。そうすると、呼び出し先はマネージポインタを監視します。したがって、C#でパフォーマンスを向上させるには、上記の(上位2つの)方法のいずれかを使用する必要があります。ref
out
[下記のC#7を参照]
悲しいことに、これらは、配列要素にアクセスするためだけに、関数を複数の部分に分割するというクラッジを展開します。同等のコードよりもかなり洗練されていませC++/CLI
んが、テストによると、C#でも、高スループットのアプリケーションでは、ナイーブな値型の配列アクセスと比較して、パフォーマンスが大幅に向上します。
[ 2017年の編集:この記事の一般的な推奨事項にある程度の予知を与える可能性がありますが、同時にC#7のリリースにより、上記の特定の方法が完全に廃止されます。Visual Studio 2017
つまり、この言語の新しいref locals機能を使用すると、独自のマネージポインターをローカル変数として宣言し、それを使用して単一配列の逆参照操作を統合できます。たとえば、上からのテスト構造を考えてみましょう...
public struct rec { public int a, b, c, d, e, f; }
static rec[] s7 = new rec[100000];
...上記と同じテスト関数を作成する方法は次のとおりです。
static void test_7(int ix)
{
ref rec e = ref s7[ix]; // <--- C#7 ref local
e.a = 4; e.e = e.a; e.b = e.d; e.f = e.d; e.b = e.e; e.a = e.c;
e.b = 5; e.d = e.f; e.c = e.b; e.e = e.a; e.b = e.d; e.f = e.d;
e.c = 6; e.b = e.e; e.a = e.c; e.d = e.f; e.c = e.b; e.e = e.a;
e.d = 7; e.b = e.d; e.f = e.d; e.b = e.e; e.a = e.c; e.d = e.f;
e.e = 8; e.c = e.b; e.e = e.a; e.b = e.d; e.f = e.d; e.b = e.e;
e.f = 9; e.a = e.c; e.d = e.f; e.c = e.b; e.e = e.a; e.b = e.d;
e.a = 10; e.f = e.d; e.b = e.e; e.a = e.c; e.d = e.f; e.c = e.b;
}
これにより、上記で説明したような応急修理の必要性が完全になくなることに注意してください。マネージドポインターをより洗練された方法で使用することで、「勝者」で使用された不要な関数呼び出しを回避できます。これは、私がレビューした方法の中で最もパフォーマンスの高い方法です。したがって、新機能を使用したパフォーマンスは、上記と比較した方法の勝者よりも優れているだけです。
皮肉なことに、C#7はローカル関数も追加します。これは、前述の2つのハックで提起したカプセル化の不備に関する苦情を直接解決する機能です。幸いなことに、マネージドポインターにアクセスするためだけに専用機能を急増させている企業全体は、今では完全に無意味です。