スタックサイズがデフォルトの50倍のスレッドを作成する際の危険は何ですか?


228

私は現在、非常にパフォーマンスが重要なプログラムに取り組んでいます。リソースの消費を減らすのに役立つ可能性があると考える1つのパスは、ワーカースレッドのスタックサイズを増やして、アクセスするほとんどのデータ(float[]s)を移動できるようにしましたスタック(を使用stackalloc)。

スレッドのデフォルトのスタックサイズは1 MBであることを読んだので、すべてのを移動float[]するには、スタックを約50倍(50 MB〜まで)拡張する必要があります。

これは一般に「安全でない」と見なされており、推奨されないことを理解していますが、この方法に対して現在のコードをベンチマークした後、処理速度が530%向上しました。したがって、私はこれ以上の調査なしにこのオプションを単に通り抜けることはできません。それが私の質問につながります。スタックをこのような大きなサイズに増やすことに関連する危険性(何が問題になる可能性があるのか​​)と、そのような危険性を最小限に抑えるためにどのような予防策を講じる必要がありますか?

私のテストコード、

public static unsafe void TestMethod1()
{
    float* samples = stackalloc float[12500000];

    for (var ii = 0; ii < 12500000; ii++)
    {
        samples[ii] = 32768;
    }
}

public static void TestMethod2()
{
    var samples = new float[12500000];

    for (var i = 0; i < 12500000; i++)
    {
        samples[i] = 32768;
    }
}

98
+1。真剣に。あなたは標準から馬鹿げた質問のように見えるものを尋ね、それからあなたはあなたの宿題をして結果を測定したのであなたが特定のシナリオでそれを考慮するのが賢明なことである非常に良いケースを作ります。これはとても良いことです-多くの質問で私はそれを逃しています。とても良いです-このようなものを検討してください。残念ながら、多くのC#プログラマーは、これらの最適化の機会を認識していません。はい、多くの場合必要ありませんが、時にはそれが重要であり、大きな違いを生みます。
TomTom 2014年

5
配列をスタックに移動しただけの理由で、処理速度が530%異なる2つのコードを確認したいと思います。それはただ正しくないと感じています。
Dialecticus

13
その道を進む前に、管理メモリのにデータを割り当てるMarshal.AllocHGlobalために(忘れずにFreeHGlobal)を使用してみましたか?次に、ポインタをにキャストすると、ソートされます。float*
Marc Gravell

2
多くの割り当てを行う場合、それは正しいと感じます。Stackallocは、プロセッサレベルで非常に強力なローカリティを作成または作成する可能性があるすべてのGC問題をバイパスします。これは、マイクロ最適化のように見えるものの1つです。高性能の数学プログラムを作成し、この動作を正確に実行していて、違いがある場合を除きます;)
TomTom

6
私の疑い:これらの方法の1つは、ループの反復ごとに境界チェックをトリガーしますが、他の方法はトリガーしないか、最適化されます。
pjc50 2014年

回答:


45

テストコードをサムと比較したところ、私たちはどちらも正しいと判断しました。
ただし、さまざまなことについて:

  • メモリへのアクセス(読み取りと書き込み)は、スタック、グローバル、ヒープのどこにいても同じくらい高速です。
  • ただし、割り当てはスタックで最も速く、ヒープで最も遅くなります。

それはこのように書きます:stack< global< heap。(割り当て時間)
技術的には、スタック割り当ては実際には割り当てではありません。ランタイムは、スタックの一部(フレーム?)が配列用に予約されていることを確認するだけです。

ただし、これには注意することを強くお勧めします。
私は以下をお勧めします:

  1. 関数を離れることのない配列を頻繁に作成する必要がある場合(たとえば、その参照を渡すことにより)、スタックを使用すると、大幅に改善されます。
  2. アレイをリサイクルできる場合は、できるだけリサイクルしてください。ヒープは、オブジェクトを長期間保存するのに最適な場所です。(グローバルメモリの汚染は良くありません。スタックフレームが消えることがあります)

:1.値型にのみ適用されます。参照型はヒープに割り当てられ、メリットは0に減少します)

質問自体に答えるには、大規模スタックテストでまったく問題が発生していません。
システムが不足している場合にスレッドを作成するときに関数呼び出しとメモリ不足に注意しないと、スタックオーバーフローのみが問題の可能性があると思います。

以下のセクションは私の最初の答えです。それは間違っているようで、テストは正しくありません。参考のために保管しています。


私のテストでは、スタックで割り当てられたメモリとグローバルメモリは、配列で使用するためにヒープに割り当てられたメモリよりも少なくとも15%遅い(時間は120%かかる)ことを示しています。

これは私のテストコードであり、これはサンプル出力です:

Stack-allocated array time: 00:00:00.2224429
Globally-allocated array time: 00:00:00.2206767
Heap-allocated array time: 00:00:00.1842670
------------------------------------------
Fastest: Heap.

  |    S    |    G    |    H    |
--+---------+---------+---------+
S |    -    | 100.80 %| 120.72 %|
--+---------+---------+---------+
G |  99.21 %|    -    | 119.76 %|
--+---------+---------+---------+
H |  82.84 %|  83.50 %|    -    |
--+---------+---------+---------+
Rates are calculated by dividing the row's value to the column's.

.NET 4.5.1の下でi7 4700 MQを使用して、Windows 8.1 Pro(Update 1付き)で
テストしました。x86とx64の両方でテストしましたが、結果は同じです。

編集:すべてのスレッドのスタックサイズを201 MBに増やし、サンプルサイズを5,000万に、反復を5に減らしました。
結果は上記と同じです

Stack-allocated array time: 00:00:00.4504903
Globally-allocated array time: 00:00:00.4020328
Heap-allocated array time: 00:00:00.3439016
------------------------------------------
Fastest: Heap.

  |    S    |    G    |    H    |
--+---------+---------+---------+
S |    -    | 112.05 %| 130.99 %|
--+---------+---------+---------+
G |  89.24 %|    -    | 116.90 %|
--+---------+---------+---------+
H |  76.34 %|  85.54 %|    -    |
--+---------+---------+---------+
Rates are calculated by dividing the row's value to the column's.

ただし、スタックが実際に遅くなっているようです。


私のベンチマークの結果結果についてはページ下部のコメントを参照)によると、私は同意する必要があります。スタックはグローバルよりもわずかに速く、ヒープよりもはるかに速いことを示しています。そして、私の結果が正確であることを確実にするために、テストを20回実行し、各メソッドは、テストの反復ごとに100回呼び出されました。ベンチマークを正しく実行していますか?
サム・

非常に一貫性のない結果が出ています。完全な信頼、x64、リリース構成、デバッガーなしで、それらはすべて同等に高速(1%未満の差、変動)ですが、スタックを使用すると実際に高速になります。さらにテストする必要があります!編集:あなたのスタックオーバーフロー例外をスローする必要があります。配列に十分なだけを割り当てます。O_O
Vercas

ええ、私はそれが近いです。私と同じように、ベンチマークを数回繰り返す必要があります。多分5回以上の実行を試してみてください。
サム・

1
@Voo 1回目の実行には、テストの100回目の実行と同じくらいの時間がかかりました。私の経験から、このJava JITは.NETにはまったく適用されません。.NETが行う「ウォームアップ」は、初めて使用するときにクラスとアセンブリをロードすることだけです。
Vercas

2
@Voo私のベンチマークと、彼がこの回答にコメントで追加した要点からのベンチマークをテストします。コードをまとめて、数百のテストを実行します。その後、戻って結論を報告してください。私は非常に徹底的にテストを行い、.NETはJavaのようにバイトコードを解釈せず、JITで即座に解釈すると言っているときの話をよく理解しています。
Vercas

28

処理速度が530%向上しました。

これは、私が言う最大の危険です。ベンチマークに深刻な問題があります。これを予期せずに動作させるコードには、通常、厄介なバグがどこかに隠れています。

過度の再帰を除いて、.NETプログラムで多くのスタック領域を消費することは非常に困難です。マネージメソッドのスタックフレームのサイズは固定されています。メソッドの引数とメソッドのローカル変数の合計です。CPUレジスタに格納できるものを除いて、それらは非常に少ないので無視できます。

スタックサイズを増やしても何も起こりません。使用されないアドレススペースの束を予約するだけです。もちろん、メモリを使用しないことによるパフォーマンスの向上を説明できるメカニズムはありません。

これは、ネイティブプログラム、特にCで記述されたプログラムとは異なり、スタックフレームの配列用にスペースを予約することもできます。スタックバッファオーバーフローの背後にある基本的なマルウェア攻撃ベクトル。C#でも可能ですが、stackallocキーワードを使用する必要があります。その場合、明らかな危険は、そのような攻撃を受けやすい安全でないコードや、ランダムなスタックフレームの破損を書かなければならないことです。バグの診断は非常に困難です。後のジッターにはこれに対する対策があります。.NET4.0以降では、ジッターがスタックフレームに「Cookie」を配置するコードを生成し、メソッドが戻ったときにそれがまだ元のままかどうかをチェックします。それが発生した場合、事故を横取りしたり、事故を報告したりすることなく、デスクトップに瞬時にクラッシュします。それは...ユーザーの精神状態にとって危険です。

プログラムのメインスレッド(オペレーティングシステムによって開始されたスレッド)には、デフォルトで1 MBのスタックがあり、x64をターゲットとするプログラムをコンパイルすると4 MBになります。これを増やすには、ビルド後のイベントで/ STACKオプションを指定してEditbin.exeを実行する必要があります。32ビットモードで実行しているときにプログラムが問題を起こす前に、通常は最大500 MBを要求できます。もちろん、スレッドも非常に簡単です。32ビットプログラムの場合、危険ゾーンは通常約90 MBです。プログラムが長時間実行され、アドレス空間が以前の割り当てから断片化されたときにトリガーされます。この障害モードを取得するには、アドレススペースの総使用量がギグを超えてすでに高くなっている必要があります。

コードをトリプルチェックしてください。非常に問題があります。それを利用するコードを明示的に記述しない限り、より大きなスタックでx5のスピードアップを得ることができません。これは常に安全でないコードを必要とします。C#でのポインターの使用には、より高速なコードを作成するためのコツが常にあり、配列の境界チェックを受けません。


21
報告された5倍のスピードアップは、からへの移動によるfloat[]ものfloat*です。大規模なスタックは、単にそれがどのようにして達成されたかということです。一部のシナリオでのx5の高速化は、その変更に対して完全に妥当です。
Marc Gravell

3
さて、質問に答え始めたとき、私はまだコードスニペットを持っていませんでした。まだ十分に近い。
ハンスパッサント2014年

22

私はそれを予測する方法がわからないという予約があります-アクセス許可、GC(スタックをスキャンする必要がある)など-すべてが影響を受ける可能性があります。代わりにアンマネージメモリを使用したくなります。

var ptr = Marshal.AllocHGlobal(sizeBytes);
try
{
    float* x = (float*)ptr;
    DoWork(x);
}
finally
{
    Marshal.FreeHGlobal(ptr);
}

1
補足質問:なぜGCはスタックをスキャンする必要があるのですか?によって割り当てられたメモリstackallocは、ガベージコレクションの対象ではありません。
dcastro 2014年

6
@dcastroはスタックをスキャンして、スタックにのみ存在する参照をチェックする必要があります。こんなに巨大になったらどうなるかわからないstackalloc-ちょっとジャンプする必要があるし、簡単にジャンプできるといいけど-導入しようとしている点は不要な合併症/懸念。IMO、stackallocスクラッチバッファとして大きいが、専用ワークスペースのために、それは、よりだけではなく、スタックを混乱/乱用よりも、チャンク-O-メモリのどこかに割り当てることが期待される
マルクGravell

8

うまくいかない可能性があるのは、許可を得られない可能性があることです。完全信頼モードで実行されていない限り、フレームワークはより大きなスタックサイズの要求を無視します(MSDN onを参照Thread Constructor (ParameterizedThreadStart, Int32))。

システムスタックサイズをこのような膨大な数に増やすのではなく、コードでコードを書き直して、ヒープで反復と手動スタック実装を使用するようにすることをお勧めします。


1
良い考えです。代わりに繰り返します。その上、私のコードは完全信頼モードで実行されているので、他に注意すべき点はありますか?
サム・

6

高性能の配列には、通常のC#の配列と同じ方法でアクセスできますが、それが問題の始まりになる可能性があります。次のコードを検討してください。

float[] someArray = new float[100]
someArray[200] = 10.0;

範囲外の例外が予想されますが、要素200にアクセスしようとしているため、これは完全に意味がありますが、許可される最大値は99です。stackallocルートに移動すると、境界チェックと以下は例外を表示しません:

Float* pFloat =  stackalloc float[100];
fFloat[200]= 10.0;

上記では、100個のフロートを保持するのに十分なメモリを割り当てており、このメモリの開始位置から開始するsizeof(float)メモリの位置+フロート値10を保持するための200 * sizeof(float)を設定しています。当然のことながら、このメモリはfloatに割り当てられたメモリ。誰もそのアドレスに何を格納できるかわからないでしょう。運が良ければ、現在使用されていないメモリを使用した可能性がありますが、同時に、他の変数を格納するために使用されていた場所を上書きする可能性があります。要約すると:予測できない実行時の動作。


事実上間違っている。ランタイムとコンパイラのテストはまだ残っています。
TomTom 2014年

9
@TomTom erm、いいえ; 答えにはメリットがあります。質問はについて話しstackallocます。その場合、私たちはfloat*等について話します-これは同じチェックがありません。それはunsafe非常に正当な理由で呼ばれます。個人的にunsafeは、正当な理由がある場合は完全に満足していますが、ソクラテスはいくつかの合理的な点を指摘しています。
マークグラベル

@Marc表示されたコード(JITの実行後)では、すべてのアクセスがインバウンドであるとコンパイラが判断するのは簡単であるため、バウンドチェックはありません。一般に、これは確かに違いを生むことができます。
Voo

6

JavaやC#などのJITとGCを使用したマイクロベンチマーク言語は少し複雑になる可能性があるため、既存のフレームワークを使用することは一般的には良い考えです。それらに近づく何か。ジョン・スキートがこれをここに書きましたが、私は盲目的に最も重要なことを処理すると仮定します(ジョンはその領域で何をしているのかを知っています。また、実際に確認した心配はありません)。ウォームアップ後のテストごとの30秒は我慢できなかったので(5秒はすべきです)、少しタイミングを微調整しました。

したがって、最初の結果は、Windows 7 x64での.NET 4.5.1です。数値は、5秒で実行できる反復を示しているので、値が大きいほど優れています。

x64 JIT:

Standard       10,589.00  (1.00)
UnsafeStandard 10,612.00  (1.00)
Stackalloc     12,088.00  (1.14)
FixedStandard  10,715.00  (1.01)
GlobalAlloc    12,547.00  (1.18)

x86 JIT(ええ、それはまだ悲しいことです):

Standard       14,787.00   (1.02)
UnsafeStandard 14,549.00   (1.00)
Stackalloc     15,830.00   (1.09)
FixedStandard  14,824.00   (1.02)
GlobalAlloc    18,744.00   (1.29)

これにより、最大で14%のはるかに妥当なスピードアップが得られます(オーバーヘッドのほとんどは、GCを実行する必要があるためです。現実的には、最悪のシナリオと考えてください)。x86の結果は興味深いものですが、そこで何が起こっているのかは完全には明らかではありません。

そしてここにコードがあります:

public static float Standard(int size) {
    float[] samples = new float[size];
    for (var ii = 0; ii < size; ii++) {
        samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
    }
    return samples[size - 1];
}

public static unsafe float UnsafeStandard(int size) {
    float[] samples = new float[size];
    for (var ii = 0; ii < size; ii++) {
        samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
    }
    return samples[size - 1];
}

public static unsafe float Stackalloc(int size) {
    float* samples = stackalloc float[size];
    for (var ii = 0; ii < size; ii++) {
        samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
    }
    return samples[size - 1];
}

public static unsafe float FixedStandard(int size) {
    float[] prev = new float[size];
    fixed (float* samples = &prev[0]) {
        for (var ii = 0; ii < size; ii++) {
            samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
        }
        return samples[size - 1];
    }
}

public static unsafe float GlobalAlloc(int size) {
    var ptr = Marshal.AllocHGlobal(size * sizeof(float));
    try {
        float* samples = (float*)ptr;
        for (var ii = 0; ii < size; ii++) {
            samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
        }
        return samples[size - 1];
    } finally {
        Marshal.FreeHGlobal(ptr);
    }
}

static void Main(string[] args) {
    int inputSize = 100000;
    var results = TestSuite.Create("Tests", inputSize, Standard(inputSize)).
        Add(Standard).
        Add(UnsafeStandard).
        Add(Stackalloc).
        Add(FixedStandard).
        Add(GlobalAlloc).
        RunTests();
    results.Display(ResultColumns.NameAndIterations);
}

興味深い観察ですが、ベンチマークをもう一度確認する必要があります。これはまだ私の質問に実際には答えませんが、「...スタックをこのような大きなサイズに増やすことに関連する危険とは...」です。私の結果が正しくない場合でも、質問は有効です。それでも努力に感謝します。
サム・

1
@Sam 12500000サイズとして使用すると、実際にはstackoverflow例外が発生します。しかし、これは主に、スタック割り当てコードを使用すると数桁速くなるという根本的な前提を拒否することに関するものでした。それ以外の場合は、可能な限り最小限の作業を行っていますが、その差はすでに約10〜15%にすぎません。実際にはそれよりも低くなります。これにより、議論全体が確実に変わります。
Voo

5

パフォーマンスの違いが大きすぎるため、問題はほとんど割り当てに関連していません。アレイアクセスが原因である可能性があります。

関数のループ本体を逆アセンブルしました。

TestMethod1:

IL_0011:  ldloc.0 
IL_0012:  ldloc.1 
IL_0013:  ldc.i4.4 
IL_0014:  mul 
IL_0015:  add 
IL_0016:  ldc.r4 32768.
IL_001b:  stind.r4 // <----------- This one
IL_001c:  ldloc.1 
IL_001d:  ldc.i4.1 
IL_001e:  add 
IL_001f:  stloc.1 
IL_0020:  ldloc.1 
IL_0021:  ldc.i4 12500000
IL_0026:  blt IL_0011

TestMethod2:

IL_0012:  ldloc.0 
IL_0013:  ldloc.1 
IL_0014:  ldc.r4 32768.
IL_0019:  stelem.r4 // <----------- This one
IL_001a:  ldloc.1 
IL_001b:  ldc.i4.1 
IL_001c:  add 
IL_001d:  stloc.1 
IL_001e:  ldloc.1 
IL_001f:  ldc.i4 12500000
IL_0024:  blt IL_0012

命令の使用法をチェックできます。さらに重要なのは、ECMA仕様でスローされる例外です。

stind.r4: Store value of type float32 into memory at address

スローする例外:

System.NullReferenceException

そして

stelem.r4: Replace array element at index with the float32 value on the stack.

スローする例外:

System.NullReferenceException
System.IndexOutOfRangeException
System.ArrayTypeMismatchException

ご覧のとおりstelem、配列範囲チェックと型チェックでより多くの作業を行います。ループ本体はほとんど何もしない(値を割り当てるだけ)ので、チェックのオーバーヘッドが計算時間を支配します。そのため、パフォーマンスは530%異なります。

そしてこれはまたあなたの質問に答えます:危険は配列の範囲と型のチェックがないことです。これは安全ではありません(関数宣言; Dで説明)。


4

編集:(コードと測定の小さな変更により、結果に大きな変化が生じます)

まず、デバッガー(F5)で最適化されたコードを実行しましたが、それは誤りでした。デバッガなしで実行する必要があります(Ctrl + F5)。第二に、コードは徹底的に最適化される可能性があるため、オプティマイザが測定を妨害しないようにコードを複雑にする必要があります。すべてのメソッドが配列の最後の項目を返すようにしたので、配列には異なる値が入力されます。また、OPには追加のゼロがあり、TestMethod2常に10倍遅くなります。

あなたが提供した2つに加えて、他のいくつかの方法を試しました。メソッド3はメソッド2と同じコードですが、関数は宣言されていunsafeます。方法4は、定期的に作成される配列へのポインターアクセスを使用しています。方法5は、Marc Gravellによって説明されているように、アンマネージメモリへのポインターアクセスを使用しています。5つのメソッドはすべて非常に似た時間で実行されます。M5が最速です(M1が2番目に近い)。最速と最遅の違いは約5%で、これは私が気にすることではありません。

    public static unsafe float TestMethod3()
    {
        float[] samples = new float[5000000];

        for (var ii = 0; ii < 5000000; ii++)
        {
            samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
        }

        return samples[5000000 - 1];
    }

    public static unsafe float TestMethod4()
    {
        float[] prev = new float[5000000];
        fixed (float* samples = &prev[0])
        {
            for (var ii = 0; ii < 5000000; ii++)
            {
                samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
            }

            return samples[5000000 - 1];
        }
    }

    public static unsafe float TestMethod5()
    {
        var ptr = Marshal.AllocHGlobal(5000000 * sizeof(float));
        try
        {
            float* samples = (float*)ptr;

            for (var ii = 0; ii < 5000000; ii++)
            {
                samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
            }

            return samples[5000000 - 1];
        }
        finally
        {
            Marshal.FreeHGlobal(ptr);
        }
    }

M3はM2と同じで、「unsafe」とマークされているだけですか?むしろ速くなるのではないかと疑っています...よろしいですか?
ローマスターコフ2014年

@romkyns私はベンチマークを実行しました(M2対M3)、そして驚くべきことに、M3は実際にはM2よりも2.14%高速です。
サム・

スタックを使用する必要がないという結論です。」私が投稿で与えたような大きなブロックを割り当てるとき、私は同意しますが、さらにいくつかのベンチマークM1対M2(両方の方法に対するPFMのアイデアを使用)を完了した後、私は確かにM1はM2よりも135%高速であるため、同意する必要はありません。
サム

1
@Samしかし、ポインタアクセスと配列アクセスを比較しています。それはより速くそれを作るものprimarlyです。TestMethod4vs TestMethod1は、のより優れた比較ですstackalloc
Roman Starkov 14年

@romkynsええ、良い点です。忘れていました。ベンチマークを再実行しましたが、現在は8%の違いしかありません(M1は2つのうちの方が高速です)。
サム
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.