用.NET展現多核威力(3) – 佛心TPL之Parallel.For好威
在前一篇文章裡,我們驗證了為每個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(),可以參考這篇文章。