並列forループに関する質問があります。私は次のコードを持っています:
public static void MultiplicateArray(double[] array, double factor)
{
for (int i = 0; i < array.Length; i++)
{
array[i] = array[i] * factor;
}
}
public static void MultiplicateArray(double[] arrayToChange, double[] multiplication)
{
for (int i = 0; i < arrayToChange.Length; i++)
{
arrayToChange[i] = arrayToChange[i] * multiplication[i];
}
}
public static void MultiplicateArray(double[] arrayToChange, double[,] multiArray, int dimension)
{
for (int i = 0; i < arrayToChange.Length; i++)
{
arrayToChange[i] = arrayToChange[i] * multiArray[i, dimension];
}
}
今、私は並列機能を追加しようとします:
public static void MultiplicateArray(double[] array, double factor)
{
Parallel.For(0, array.Length, i =>
{
array[i] = array[i] * factor;
});
}
public static void MultiplicateArray(double[] arrayToChange, double[] multiplication)
{
Parallel.For(0, arrayToChange.Length, i =>
{
arrayToChange[i] = arrayToChange[i] * multiplication[i];
});
}
public static void MultiplicateArray(double[] arrayToChange, double[,] multiArray, int dimension)
{
Parallel.For(0, arrayToChange.Length, i =>
{
arrayToChange[i] = arrayToChange[i] * multiArray[i, dimension];
});
}
問題は、無駄にしないで、時間を節約したいということです。標準forループでは約2分計算されますが、並列forループでは3分かかります。どうして?
Parallel.For()
はコードを並列化することでパフォーマンスを大幅に改善できますが、オーバーヘッド(スレッド間の同期、各反復でデリゲートを呼び出す)もあります。また、コードでは、各反復がvery short(基本的にはわずかなCPU命令)であるため、このオーバーヘッドが顕著になる可能性があります。
このため、Parallel.For()
を使用することは適切なソリューションではないと考えました。代わりに、コードを手動で並列化すると(この場合は非常に簡単です)、パフォーマンスが向上することがあります。
これを確認するために、いくつかの測定を実行しました。200,000個のアイテムの配列でMultiplicateArray()
の異なる実装を実行しました(使用したコードは以下です)。私のマシンでは、シリアルバージョンは常に0.21秒かかり、Parallel.For()
は通常約0.45秒かかりましたが、ときどき8〜9秒に急増しました。
まず、一般的なケースを改善しようとしますが、それらの急上昇に後で対処します。 [〜#〜] n [〜#〜] CPUで配列を処理したいので、それを[〜#〜] n [〜#〜]に分割します。同じサイズの部品を使用し、各部品を個別に処理します。結果? 0.35秒それはシリアルバージョンよりもさらに悪いです。ただし、配列内の各項目に対するfor
ループは、最も最適化された構造の1つです。コンパイラを支援するために何かできませんか?ループの境界の計算を抽出すると役立つ場合があります。 0.18秒です。それはシリアルバージョンよりも優れていますが、それほどではありません。そして、興味深いことに、4コアマシン(ハイパースレッディングなし)で並列度を4から2に変更しても、結果は変わらず、依然として0.18秒です。これにより、CPUがボトルネックではなく、メモリ帯域幅がボトルネックであると結論付けられます。
さて、スパイクに戻りましょう:私のカスタム並列化にはそれらがありませんが、Parallel.For()
にはあります、なぜですか? Parallel.For()
は範囲分割を使用します。つまり、各スレッドは配列の独自の部分を処理します。ただし、あるスレッドが早く終了すると、まだ終了していない別のスレッドの範囲の処理を支援しようとします。その場合、大量の偽共有が発生し、コードが大幅に遅くなる可能性があります。そして、偽りの共有を強制することに関する私自身のテストは、これが実際に問題である可能性を示しているようです。 Parallel.For()
の並列度を強制することで、スパイクを少し抑えることができます。
もちろん、これらの測定値はすべて私のコンピューターのハードウェアに固有のものであり、ユーザーごとに異なるため、独自の測定を行う必要があります。
私が使用したコード:
static void Main()
{
double[] array = new double[200 * 1000 * 1000];
for (int i = 0; i < array.Length; i++)
array[i] = 1;
for (int i = 0; i < 5; i++)
{
Stopwatch sw = Stopwatch.StartNew();
Serial(array, 2);
Console.WriteLine("Serial: {0:f2} s", sw.Elapsed.TotalSeconds);
sw = Stopwatch.StartNew();
ParallelFor(array, 2);
Console.WriteLine("Parallel.For: {0:f2} s", sw.Elapsed.TotalSeconds);
sw = Stopwatch.StartNew();
ParallelForDegreeOfParallelism(array, 2);
Console.WriteLine("Parallel.For (degree of parallelism): {0:f2} s", sw.Elapsed.TotalSeconds);
sw = Stopwatch.StartNew();
CustomParallel(array, 2);
Console.WriteLine("Custom parallel: {0:f2} s", sw.Elapsed.TotalSeconds);
sw = Stopwatch.StartNew();
CustomParallelExtractedMax(array, 2);
Console.WriteLine("Custom parallel (extracted max): {0:f2} s", sw.Elapsed.TotalSeconds);
sw = Stopwatch.StartNew();
CustomParallelExtractedMaxHalfParallelism(array, 2);
Console.WriteLine("Custom parallel (extracted max, half parallelism): {0:f2} s", sw.Elapsed.TotalSeconds);
sw = Stopwatch.StartNew();
CustomParallelFalseSharing(array, 2);
Console.WriteLine("Custom parallel (false sharing): {0:f2} s", sw.Elapsed.TotalSeconds);
}
}
static void Serial(double[] array, double factor)
{
for (int i = 0; i < array.Length; i++)
{
array[i] = array[i] * factor;
}
}
static void ParallelFor(double[] array, double factor)
{
Parallel.For(
0, array.Length, i => { array[i] = array[i] * factor; });
}
static void ParallelForDegreeOfParallelism(double[] array, double factor)
{
Parallel.For(
0, array.Length, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
i => { array[i] = array[i] * factor; });
}
static void CustomParallel(double[] array, double factor)
{
var degreeOfParallelism = Environment.ProcessorCount;
var tasks = new Task[degreeOfParallelism];
for (int taskNumber = 0; taskNumber < degreeOfParallelism; taskNumber++)
{
// capturing taskNumber in lambda wouldn't work correctly
int taskNumberCopy = taskNumber;
tasks[taskNumber] = Task.Factory.StartNew(
() =>
{
for (int i = array.Length * taskNumberCopy / degreeOfParallelism;
i < array.Length * (taskNumberCopy + 1) / degreeOfParallelism;
i++)
{
array[i] = array[i] * factor;
}
});
}
Task.WaitAll(tasks);
}
static void CustomParallelExtractedMax(double[] array, double factor)
{
var degreeOfParallelism = Environment.ProcessorCount;
var tasks = new Task[degreeOfParallelism];
for (int taskNumber = 0; taskNumber < degreeOfParallelism; taskNumber++)
{
// capturing taskNumber in lambda wouldn't work correctly
int taskNumberCopy = taskNumber;
tasks[taskNumber] = Task.Factory.StartNew(
() =>
{
var max = array.Length * (taskNumberCopy + 1) / degreeOfParallelism;
for (int i = array.Length * taskNumberCopy / degreeOfParallelism;
i < max;
i++)
{
array[i] = array[i] * factor;
}
});
}
Task.WaitAll(tasks);
}
static void CustomParallelExtractedMaxHalfParallelism(double[] array, double factor)
{
var degreeOfParallelism = Environment.ProcessorCount / 2;
var tasks = new Task[degreeOfParallelism];
for (int taskNumber = 0; taskNumber < degreeOfParallelism; taskNumber++)
{
// capturing taskNumber in lambda wouldn't work correctly
int taskNumberCopy = taskNumber;
tasks[taskNumber] = Task.Factory.StartNew(
() =>
{
var max = array.Length * (taskNumberCopy + 1) / degreeOfParallelism;
for (int i = array.Length * taskNumberCopy / degreeOfParallelism;
i < max;
i++)
{
array[i] = array[i] * factor;
}
});
}
Task.WaitAll(tasks);
}
static void CustomParallelFalseSharing(double[] array, double factor)
{
var degreeOfParallelism = Environment.ProcessorCount;
var tasks = new Task[degreeOfParallelism];
int i = -1;
for (int taskNumber = 0; taskNumber < degreeOfParallelism; taskNumber++)
{
tasks[taskNumber] = Task.Factory.StartNew(
() =>
{
int j = Interlocked.Increment(ref i);
while (j < array.Length)
{
array[j] = array[j] * factor;
j = Interlocked.Increment(ref i);
}
});
}
Task.WaitAll(tasks);
}
出力例:
Serial: 0,20 s
Parallel.For: 0,50 s
Parallel.For (degree of parallelism): 8,90 s
Custom parallel: 0,33 s
Custom parallel (extracted max): 0,18 s
Custom parallel (extracted max, half parallelism): 0,18 s
Custom parallel (false sharing): 7,53 s
Serial: 0,21 s
Parallel.For: 0,52 s
Parallel.For (degree of parallelism): 0,36 s
Custom parallel: 0,31 s
Custom parallel (extracted max): 0,18 s
Custom parallel (extracted max, half parallelism): 0,19 s
Custom parallel (false sharing): 7,59 s
Serial: 0,21 s
Parallel.For: 11,21 s
Parallel.For (degree of parallelism): 0,36 s
Custom parallel: 0,32 s
Custom parallel (extracted max): 0,18 s
Custom parallel (extracted max, half parallelism): 0,18 s
Custom parallel (false sharing): 7,76 s
Serial: 0,21 s
Parallel.For: 0,46 s
Parallel.For (degree of parallelism): 0,35 s
Custom parallel: 0,31 s
Custom parallel (extracted max): 0,18 s
Custom parallel (extracted max, half parallelism): 0,18 s
Custom parallel (false sharing): 7,58 s
Serial: 0,21 s
Parallel.For: 0,45 s
Parallel.For (degree of parallelism): 0,40 s
Custom parallel: 0,38 s
Custom parallel (extracted max): 0,18 s
Custom parallel (extracted max, half parallelism): 0,18 s
Custom parallel (false sharing): 7,58 s
Svickはすでに素晴らしい答えを提供しましたが、キーポイントはParallel.For()
を使用する代わりに「コードを手動で並列化する」ことではなく、処理する必要があることを強調したいと思いますデータの大きな塊。
これは、次のようにParallel.For()
を使用して実行できます。
_static void My(double[] array, double factor)
{
int degreeOfParallelism = Environment.ProcessorCount;
Parallel.For(0, degreeOfParallelism, workerId =>
{
var max = array.Length * (workerId + 1) / degreeOfParallelism;
for (int i = array.Length * workerId / degreeOfParallelism; i < max; i++)
array[i] = array[i] * factor;
});
}
_
これはsvicks CustomParallelExtractedMax()
と同じことをしますが、より短く、より単純で、(私のマシンでは)わずかに高速になります:
_Serial: 3,94 s
Parallel.For: 9,28 s
Parallel.For (degree of parallelism): 9,58 s
Custom parallel: 2,05 s
Custom parallel (extracted max): 1,19 s
Custom parallel (extracted max, half parallelism): 1,49 s
Custom parallel (false sharing): 27,88 s
My: 0,95 s
_
ところで、他のすべての答えから欠落しているこのためのキーワードは、粒度です。
PLINQおよびTPLのカスタムパーティショナー を参照してください。
For
ループでは、ループの本体がデリゲートとしてメソッドに提供されます。そのデリゲートを呼び出すコストは、仮想メソッド呼び出しとほぼ同じです。一部のシナリオでは、並列ループの本体が十分に小さいため、各ループの反復でのデリゲート呼び出しのコストが大きくなる場合があります。そのような状況では、Create
オーバーロードの1つを使用して、ソース要素上に範囲パーティションのIEnumerable<T>
を作成できます。次に、この範囲のコレクションを、本体が通常のforループで構成されるForEach
メソッドに渡すことができます。このアプローチの利点は、デリゲート呼び出しコストが要素ごとに1回ではなく、範囲ごとに1回だけ発生することです。
ループ本体では、1回の乗算を実行しているため、デリゲート呼び出しのオーバーヘッドは非常に顕著です。
これを試して:
public static void MultiplicateArray(double[] array, double factor)
{
var rangePartitioner = Partitioner.Create(0, array.Length);
Parallel.ForEach(rangePartitioner, range =>
{
for (int i = range.Item1; i < range.Item2; i++)
{
array[i] = array[i] * factor;
}
});
}
Parallel.ForEach
documentation および Partitioner.Create
documentation も参照してください。
Parallel.Forには、より複雑なメモリ管理が含まれます。その結果は、CPUの仕様(#cores、L1&L2キャッシュなど)によって異なる場合があります...
この興味深い記事をご覧ください。