2018年5月2日水曜日

C# Parallel 入門

Parallelって何?
プログラムを並列化するための仕組みとなります。
既存のプログラムの 「for文」を「Parallel.for」に置き換えるような形で導入できるので、
比較的簡単に導入が可能です。

排他制御は必要か?
結論としては必要です。

Microsoftの「データの並列化 (タスク並列ライブラリ)」には、「基本のループでは、ロックを取得する必要はありません。」とあり、不要なのかな?とも思ったのですが、
詳細な資料「Patterns for Parallel Programming: Understanding and Applying Parallel Patterns with the .NET Framework 4」 では排他制御が行われているので、やっぱり必要という結論となりました。

どうやって実装するか?
「Patterns for Parallel Programming: Understanding and Applying Parallel Patterns with the .NET Framework 4」P68~P70がとても参考になります。

そちらには、π(パイ)の計算を参考にして、シングルで計算する方法 → 並列化した方法( よくない方法 ) → 並列化した方法( よい方法 )の順番でサンプルが記載されてます。

その順番でコードを書いてみたのがこちらになります。
π(パイ)を求める計算式は今回の本題ではないので、計算を行って最後に結果を足し合わせているところに着目してください。
足し合わせている部分は排他が必要な部分となります。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace TestParallel
{
    class Program
    {
        //-------------------------------
        //ここはテストコードを呼び出しているだけなので重要でないです。
        //-------------------------------
        static void Main(string[] args)
        {

            // 時間測定用
            System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch();
            sw.Start();

            // 本体
            double pi = SerialPi();
            Console.WriteLine("--- シリアル ---");
            Console.WriteLine("答え:" + pi.ToString("R"));

            //時間測定用
            sw.Stop();
            Console.WriteLine("時間:"+ sw.Elapsed);
            sw.Restart();

            // 本体
            double npi = NaiveParallelPi();
            Console.WriteLine("--- パラレル( よくない ) ---");
            Console.WriteLine("答え:" + npi.ToString("R"));


            //時間測定用
            sw.Stop();
            Console.WriteLine("時間:" + sw.Elapsed);
            sw.Restart();

            // 本体
            double ppi = ParallelPi();
            Console.WriteLine("--- パラレル( よい )---");
            Console.WriteLine("答え:" + ppi.ToString("R"));


            //時間測定用
            sw.Stop();
            Console.WriteLine("時間:" + sw.Elapsed);

            // getchar
            Console.ReadKey();
        }

        const long NUM_STEPS = 500000000;

        //-------------------------------
        //シリアルで計算  (単純なfor文)
        //-------------------------------
        static double SerialPi()
        {
            double sum = 0.0;
            double step = 1.0 / (double)NUM_STEPS;
            for (long i = 0; i < NUM_STEPS; i++)
            {
                double x = (i + 0.5) * step;
                double partial = 4.0 / (1.0 + x * x);
                sum += partial;
            }
            return step * sum;
        }

        //-------------------------------
        //パラレルで計算  (よくない)
        //-------------------------------
        static double NaiveParallelPi()
        {
            double sum = 0.0;
            double step = 1.0 / (double)NUM_STEPS;
            object obj = new object();
            Parallel.For(0, NUM_STEPS, i =>
            {
                double x = (i + 0.5) * step;
                double partial = 4.0 / (1.0 + x * x);
                lock (obj) sum += partial;
            });
            return step * sum;
        }
        //-------------------------------
        //パラレルで計算  (よい)
        //-------------------------------
        static double ParallelPi()
        {
            double sum = 0.0;
            double step = 1.0 / (double)NUM_STEPS;
            object obj = new object();
            Parallel.For(0, 
                NUM_STEPS,
                () => 0.0,
                (i, state, partial) =>
                {
                    double x = (i + 0.5) * step;
                    return partial + 4.0 / (1.0 + x * x);
                },
                partial => { lock (obj) sum += partial; });
            return step * sum;
        }

    }
}

実行結果はこちらになります。
πの計算結果と、それぞれの実行時間を出力してます。

「パラレル(よくない)」は、単純にfor文をParallel.for文に置き換えて、排他制御が必要な部分にlockをかけた場合なのですが、 シリアルで計算したときよりも遅くなっていることがわかります。

排他制御が必要な計算を並列化する場合は、「パラレルで計算(よい)」の方法で実装する必要があることが分かりました。

ただ、ラムダ式が苦手な自分としては、一見なにがなんだかよくわかりませんでした。(今もよくわかってません...)
ですが、Parallel.For文のオーバーロードされたメソッドの一つであることはわかりました。
https://msdn.microsoft.com/ja-jp/library/dd782936(v=vs.110).aspx
なんとなく理解したことは、for分の毎回実施する操作を「body」に記載し、足し合わせるとかの排他する部分を「localFinally」に記載すればよいということなのかな?
p.s.
MSの資料には、さらによい方法が紹介されてます。
「ParallelPartitionerPi()」という関数ですが、
こちらはまだ理解できていないので、端折りました。