前一篇文章裡,我們驗證了為每個CPU Core開一條獨立Thread並事先分攤好計算工作,可以讓巨量Log10計算程式飆出最高效能! 但是,仔細看看程式碼:

排版顯示純文字
int WORKER_COUNT = 2;
Thread[] workers = new Thread[WORKER_COUNT];
int jobsCountPerWorker = MAX_COUNT / WORKER_COUNT;
for (int i = 0; i < WORKER_COUNT; i++)
{
    int st = jobsCountPerWorker * i;
    int ed = jobsCountPerWorker * (i + 1);
    if (ed > MAX_COUNT) ed = MAX_COUNT;
    workers[i] = new Thread(() =>
    {
        for (int j = st; j < ed; j++)
        {
            double d = Math.Log10(Convert.ToDouble(j));
        }
    });
    workers[i].Start();
}
for (int i = 0; i < WORKER_COUNT; i++)
    workers[i].Join();

我們寫了近20行的程式碼,而且還得花腦筋寫邏輯分割工作給多條Thread,要曉得如何用Thread.Join同步完成時間。說實在話,沒有三兩三,恐怕沒膽玩。

如果我說有一種很簡單的新寫法可以實現類似的效果:

排版顯示純文字
Parallel.For(0, MAX_COUNT, j =>
{
    double d = Math.Log10(Convert.ToDouble(j));
});

看到這裡,大家會不會有想起立鼔掌的衝動?

這是.NET Framework 4.0裡內建的Task Parallel Library(TPL),一組幫助程式新手的佛心API。把原本複雜的多執行緒運算程式簡化成一行打死,寫程式的人就算對Thread.Join、lock()、ManualResetEvent一無所悉,照樣可以寫出漂亮的平行運算程式。(這下子,程式老鳥又有一項優勢被剝奪了,被菜鳥幹掉的日子愈來愈近了,我好怕...)

依我個人的理解,TPL強調的是平行運算的能力,目的在搾乾每一滴CPU運算能力。它在概念上介於ThreadPool與自行管理數條Thread之間,最精彩的地方是會依CPU的負載狀況自動調節Thread數,直到所有CPU使用率都飆上100%,系統運算能力完全被榨乾為止。換句話說,一開始迴圈只啟動一條Thread,接著會在資源允許的前題下增加平行處理的Thread數(the loop starts with a degree of 1, and may work its way up to any maximum that’s specified as resources become available,參考)。

接著,我們就讓原來寫法跟Parallel.For車拼一下(測試平台為E6400雙核CPU): (註: 要體驗.NET 4.0,請先下載VS2010 Beta回家玩)

排版顯示純文字
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
 
namespace MultiCore
{
    class TestParalleFor
    {
        static void Main(string[] args)
        {
            int MAX_COUNT = 5000 * 10000;
            Stopwatch sw = new Stopwatch();
            for (int round = 0; round < 3; round++)
            {
                sw.Reset();
                sw.Start();
                int WORKER_COUNT = 2;
                Thread[] workers = new Thread[WORKER_COUNT];
                int jobsCountPerWorker = MAX_COUNT / WORKER_COUNT;
                for (int i = 0; i < WORKER_COUNT; i++)
                {
                    int st = jobsCountPerWorker * i;
                    int ed = jobsCountPerWorker * (i + 1);
                    if (ed > MAX_COUNT) ed = MAX_COUNT;
                    workers[i] = new Thread(() =>
                    {
                        for (int j = st; j < ed; j++)
                        {
                            double d = Math.Log10(Convert.ToDouble(j));
                        }
                    });
                    workers[i].Start();
                }
                for (int i = 0; i < WORKER_COUNT; i++)
                    workers[i].Join();
                sw.Stop();
                Console.WriteLine("Multi-Thread[{1}] = {0:N0}ms", 
                    sw.ElapsedMilliseconds, WORKER_COUNT);
 
                sw.Reset();
                sw.Start();
                Parallel.For(0, MAX_COUNT, j =>
                {
                    double d = Math.Log10(Convert.ToDouble(j));
                });
                sw.Stop();
                Console.WriteLine("Parallel.For = {0:N0}ms",
                    sw.ElapsedMilliseconds);
            }
            Console.Read();
        }
    }
}

Multi-Thread[2] = 2,003ms
Parallel.For = 2,119ms
Multi-Thread[2] = 2,098ms
Parallel.For = 2,283ms
Multi-Thread[2] = 1,966ms
Parallel.For = 2,113ms

就數據而言,二者相近,但在這個例子中,大部分的時間Parallel.For還是略輸一籌。理由是Parallel.For強調的是動態調節,由一條Thread開始,再逐步增加,自然會比事先規劃好全程用兩條Thread衝刺慢一些。不過別沮喪,我們動個手腳,馬上就能還Parallel.For一個公道。

我們將:

double d = Math.Log10(Convert.ToDouble(j));

改成每5000次Delay 10ms: (假裝在等待某項非CPU資源)

double d = Math.Log10(Convert.ToDouble(j));
if (j % 5000 == 0) Thread.Sleep(10);

並把執行次數改為100萬次縮短總執行時間。

Multi-Thread[2] = 1,046ms
Parallel.For = 652ms
Multi-Thread[2] = 1,009ms
Parallel.For = 502ms
Multi-Thread[2] = 1,007ms
Parallel.For = 550ms

怎樣? 前一個測試二者結果相近,加入Thread.Sleep後比原來的一核一緒寫法快了近一倍,程式簡潔N倍,在這兩回合比試中,我認定Parallel.For明顯勝出!!

因為Thread.Sleep的加入會降低CPU使用率,Parallel.For動態增加Thead的能力便可派上用場,填補了CPU空檔,也就縮短了總執行時間。在實務上,即便是以運算為主的工作,還是免不了有等待I/O、等待其他Thread就緒的同步需求,必須暫停等待其他非CPU資源後再繼續,等待期間會產生CPU使用率下降的情況。平行運算哲學中,讓CPU閒著是一種罪惡,在CPU使用率未達100%時增加Thread數充分利用閒置的運算能力,自然會比一核一緒更上一層樓。要自己寫出視CPU使用率動態調節Thread數的程式並非易事,而Parallel.For可以幫我們做到這一點,很威吧? 大家如果在.NET 4.0中開發類似的平行運算需求時,千萬不要錯過它囉~~

[2010-02-05補充] 若想在.NET 3.5中使用 Parallel.For(),可以參考這篇文章


Comments

# by Fillano

Parallel.For的用法感覺有點像OpenMP...

# by Lan

印象中N年前學平行程式設計時,Parallel Pascal就有類似的語法了 不過根本不用擔心被菜鳥幹掉啊,因為老鳥才曉得這些強大工具背後其實是包裝掉哪些事,才有辦法當茶包射手,菜鳥遇到問題只會: 1.裝死 2.問人 3.Google

# by mis2000lab

謝謝您, 又長了見識,學到新東西了

# by jain

哇,身為菜鳥的我還蠻驚訝的, 可以如此簡化, 才摸vs2008沒多久, 又要摸vs2010。

# by KENCHAO

不知道有沒有組件可以讓3.5的去參考使用呢?,這真的很方便但是不是每個客戶都用到4.0

# by 路人假

請問一下 那魔老手 遇到問題時是如何解決的ㄋ???

# by C.J.

偶然从google链接进来。 作者您也好威

# by Glegoo

Parallel.For果然是好東西,竟然還能動態調節,等我重寫一下我的程式看看

# by Loops

您好,我寫了一個程式測試Parallel For,發現如果再回圈一開始如果有多一行Console.WriteLine({0},i),Parallel.For就會比一般的For慢,但如果不要這一行,Parallel就比較快。要怎麼解釋這個現象呢?

# by Loops

程式碼如下: using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Diagnostics; using System.Collections.Concurrent; namespace ParallelTest { class Program { static void Main(string[] args) { int totalNum = 20000; bool isParallelCalculate = false; ConcurrentStack<float> scores = new ConcurrentStack<float>(); //float[] scores = new float[totalNum]; Random randomNumber = new Random(); Stopwatch timer = new Stopwatch(); timer.Reset(); timer.Start(); if (isParallelCalculate) { Parallel.For(0, totalNum, (i, state) => { //Console.WriteLine("{0}", i); scores.Push(2 * 2); float sum = scores.Sum(); for (int rndCount = 0; rndCount < totalNum; rndCount++) { sum += rndCount; } }); timer.Stop(); Console.WriteLine("Parallel.For = {0:N0}ms", timer.ElapsedMilliseconds); } else { for (int i = 0; i < totalNum; i++) { //Console.WriteLine("{0}", i); scores.Push(2 * 2); float sum = scores.Sum(); for (int rndCount = 0; rndCount < totalNum; rndCount++) { sum += rndCount; } } timer.Stop(); Console.WriteLine("Sequencial For = {0:N0}ms", timer.ElapsedMilliseconds); } Console.WriteLine("Press any key..."); Console.ReadLine(); } } }

# by Jeffrey

to Loops, Console屬共用資源,初步猜想是多個Thread爭奪協調使用權產生的Overhead拖累,我再試試是否能用實例驗證。

Post a comment