C#
Parallel.For
+ Native C による高速化〜その2: タスク並列ライブラリ
2012年の時点で、すでにプロセッサは、4コア、6コアなどがコンシューマ市場に安価に導入されおり、今後メニーコアの時代が来ることは確実です。このような流れを受けて、Visual
Studio 2010および.NET Framework 4.0
では、並列処理のためのランタイム、クラスライブラリ、開発環境が提供され、非常に簡単に並列処理を開発できるようになりました。.NET
Framework 4 の並列プログラミング アーキテクチャの高度な概要を次の図に示します。
MSDN .NET Framework の並列プログラミング
http://msdn.microsoft.com/ja-jp/library/dd460693.aspx より
この図を見るとわかるように、新しい並列メカニズムは、大きく分けてPLINQ
とタスク並列ライブラリに分けられます。さらにタスク並列ライブラリには、次の2つに分類できます。
-
データの並列化
-
タスクの並列化
詳細はMSDNを見てください。ここでは、タスク並列ライブラリのデータの並列化 Parallel.For を使った例で説明を行います。
用語
用語 |
意味 |
TPL |
タスク並列ライブラリ(Task Parallel Library)の略。.NET
Framework Version 4 の
System.Threading
名前空間および
System.Threading.Tasks 名前空間のAPI のセット。
|
タスク並列ライブラリ〜データの並列処理:
Parallel.ForEach
複数のデータに対して、次のようにforeach
で処理を行うことができます。これは、シーケンシャル(順次処理)となります。
foreach (var item in sourceCollection)
{
Process(item);
}
この時 Process(item) が item
に対して完全に独立している場合、Parallel.Foreach により、並列処理が可能になります。
Parallel.ForEach(sourceCollection, item =>
Process(item));
例えば、次のコードを実行してみると、次のようになります。
using System;
using System.Threading.Tasks;
namespace ConsoleParallelTest
{
class Program
{
static int[] item=
{1,2,3,4,5,6,7,8,9,10};
static void Process(int item)
{
Console.WriteLine(item.ToString());
}
static void Main(string[] args)
{
Parallel.ForEach(item, i => Process(i));
Console.WriteLine("Done");
}
}
}
このように、Process は並列実行され、実行順序は保証されません。
ループの実行を停止または中断させる場合は、その他のスレッドのループ状態の監視、スレッド
ローカルの状態の維持、スレッド ローカル
オブジェクトの終了、同時実行の程度の制御などが可能です。これらのヘルパー機能、ParallelLoopState、ParallelOptions、ParallelLoopResult、CancellationToken、CancellationTokenSource
については、「データの並列化
(タスク並列ライブラリ)」を参照してください。
注意: この例では、わかりやすさのために、Process
は非常に簡単な処理ですが、ある程度の粒度で分割しないと、並列化するオーバーヘッドが大きく、逆に遅くなってしまいます。これは、タスク並列ライブラリが、データ
ソースをパーティション分割して、ループで複数の部分を同時に操作できるように分割しているため、並列化のコストはばかになりません。現時点ではプロセッサのコア数は数個程度なので、ほとんどの場合重い処理の外側のループだけを並列化することで、最大限の効果を得ることができます。
タスク並列ライブラリ〜データの並列処理:
Parallel.For
同様に複数のデータに対して、次のように for
ループで処理を行うことができます。これは、シーケンシャル(順次処理)となります。
for (int i = 0; i < 100; i++)
{
Process( item[i] );
}
この時 Process( item[i] ) が item
に対して完全に独立している場合、Parallel.Forにより、並列処理が可能になります。
Parallel.For(0, 100, i =>
{
Process( item[i] );
});
例えば、次のコードを実行してみると、次のようになります。
using System;
using System.Threading.Tasks;
namespace ConsoleParallelTest
{
class Program
{
static int[] item=
{1,2,3,4,5,6,7,8,9,10};
static void Process(int item)
{
Console.WriteLine(item.ToString());
}
static void Main(string[] args)
{
Parallel.For(0, 10, i =>
{
Process(item[i]);
});
Console.WriteLine("Done");
}
}
}
このように、Process は並列実行され、実行順序は保証されません。
ループの実行を停止または中断させる場合は、その他のスレッドのループ状態の監視、スレッド
ローカルの状態の維持、スレッド ローカル
オブジェクトの終了、同時実行の程度の制御などが可能です。これらのヘルパー機能、ParallelLoopState、ParallelOptions、ParallelLoopResult、CancellationToken、CancellationTokenSource
については、「データの並列化
(タスク並列ライブラリ)」を参照してください。
注意: この例では、わかりやすさのために、Process
は非常に簡単な処理ですが、ある程度の粒度で分割しないと、並列化するオーバーヘッドが大きく、逆に遅くなってしまいます。これは、タスク並列ライブラリが、データ
ソースをパーティション分割して、ループで複数の部分を同時に操作できるように分割しているため、並列化のコストはばかになりません。現時点ではプロセッサのコア数は数個程度なので、ほとんどの場合重い処理の外側のループだけを並列化することで、最大限の効果を得ることができます。
タスク並列ライブラリ〜タスクの並列処理:
Parallel.Invoke
Parallel.For、Parallel.ForEach
では、データに内在する並列性に対する同一の処理を簡易に並列化することができます。一方、タスク並列ライブラリでは、異なるタスクに対して、待機、キャンセル、継続、信頼性の高い例外処理、詳細なステータス、カスタムのスケジュール設定などをサポートする豊富な
API が用意されています。
もっとも簡単なタスクの並列処理は、つぎの Parallel.Invoke です。
private static void DoWork()
{
Console.WriteLine("DoWork");
}
private static void DoAnotherWork()
{
Console.WriteLine("DoAnotherWork");
}
private static void ParallelInvoke()
{
Parallel.Invoke(
() => DoWork(),
() => DoAnotherWork()
);
}
タスク並列ライブラリ〜タスクの並列処理:
Task.Factory.StartNew
Parallel.Invokeが、暗黙的なタスクの作成と実行を行ってくれたのに対して、System.Threading.Tasks.Task
名前空間を使用することによって、明示的なタスクの作成、実行を行うことができます。
private static void DoWork()
{
Console.WriteLine("DoWork");
}
private static void DoAnotherWork()
{
Console.WriteLine("DoAnotherWork");
}
Task.Factory.StartNew(() => DoWork());
Task.Factory.StartNew(() => DoAnotherWork());
Task<String> taskReturnString =
Task<String>.Factory.StartNew(() =>
{
string s = "Return value from
taskReturnString";
return s;
});
Console.WriteLine(taskReturnString.Result);
実行結果は次のようになる。
ここで、注意したいのが、パラメータの渡し方です。
次の例だと、DoWorkWithParam メソッドにパラメータが
0から9の10個が渡ると期待できます。
Task[] tasks = new Task[10];
for (int i = 0; i < 10; i++)
{
tasks[i] = Task.Factory.StartNew(() => DoWorkWithParam(i));
}
しかし、実行結果は次のようになります。
この原因は、StartNew
でメインスレッドとは別のスレッドが並行で起動されますが、それぞれのスレッドに渡されるパラメータは、メインスレッドの
for
ループの中で順次値が変更されいます。したがって、タスクにパラメータを決定的に渡すことができません。
どのように適用するか?
このタスク並列ライブラリは、C#から直接利用でき、マルチコア環境を有効に使うことができます。ただし、マルチコアの並列度までなので、4コア8スレッドの場合、最大でも8並列になります。また、ハイパースレッドでは、2倍までの性能は出ないので、せいぜい4倍の並列度が出ればよいほうでしょう。したがって、その程度しかコアの並列度がないのに、10000の並列処理に分割するといったことは、意味がありません。
画像処理を例にとってみると、たとえば複数の画像を並列に処理する場合であれば、個々の画像を独立に処理できるので、タスク並列ライブラリでは、Parallel.ForEach
で個々の画像を並列に処理すればよいと思います。
1つの画像を処理する場合であれば、画像を分割して独立に処理できるのであれば、画像を複数のブロックに分割し、並列に処理すればよいでしょう。例えば次のように
X, Y 座標でループを回しているのであれば、Yに対して、Parallel.For
を適用すると、ロジックを保ったまま、容易にタスク並列ライブラリを適用することができます。
for (int y = 0, ....)
for (int x = 0, ...)
1ラインごとの処理
ここで注意しなければいけないのが、処理粒度です。あまり細かすぎると、データの分割のためのオーバーヘッドが高くなりすぎてしまいます。逆に大きすぎると、十分な並列度を稼げない場合があります。このあたりは、ある程度実験的に確かめる必要があります。
データとタスクの並列化における注意点
MSDN: データのとタスクの並列化における注意点
の記事がありますが、これらは必ず目を通しておいたほうがよいでしょう。
-
並列が常に速いとは限らない
-
共有メモリの位置への書き込みを回避する
-
過剰な並列化を避ける
-
スレッド セーフでないメソッドへの呼び出しを回避する
-
スレッド セーフなメソッドへの呼び出しを制限する
-
スレッドの関係に注意する
-
Parallel.Invoke によって呼び出されるデリゲートで待機する場合は注意する
-
ForEach、For、および ForAll の反復処理が常に並列実行されるとは限らない
-
UI スレッドでの並列ループの実行は避ける
まとめ
以上で、C# からタスク並列ライブラリの使い方の概要がわかったと思います。
次回は、画像処理を例に、C# + Native C でどこまで高速化できるか、実験したいと思います。