前一篇文章裡,我們陰了ThreadPool一下,把一個運算十分簡單,但是數量極其龐大的計算需求拆解成無數UserWorkItem交給ThreadPool執行,然後冷眼旁觀ThreadPool在lock機制的消磨下,慘敗給傻瓜都會的單一執行緒寫法,速度足足慢了七倍有餘...

lock機制看來是最大的殺手。明明人手充足,卻規定所有人員必須排隊成一列輪流完成某個動作才能繼續工作,當完成工作本身所需的時間很短,則耗費在排隊的時間就顯得漫長而荒謬。這就是前一篇文章所點出的事實。

那麼,在這個案例中,我們應如何改善? 概念很簡單---讓所有可用的CPU資源都專心投入在執行運算上,避免花費任何額外資源去處理不必要的工作(例如: lock機制)。

第一步,先要除去心頭大患---lock機制!! 回到前文提到的50個阿宅組電腦的例子上(謎之聲: 上回尊稱"電腦組裝達人",這回簡稱"阿宅",夠現實!),要求所有人排隊領組裝套件是為了確保所有的套件都有分配出去,並且每份套件只被交付給一個人。在我們的案例中,還有另一方免排隊的好方法: 先算好有多少份組件,有多少人,預先把誰處理哪幾件分配好。一開始就把各人應處理的組件發下去,就不必大家擠著排隊。而我們也不再需要再去關心還有多少組件沒有分完做完,改問每個人是否已做完所有分配到的工作即可,這麼一來也不必麻煩大家排隊到黑板上寫正字。

對應到程式碼上,假設我們有一台四核的電腦,便可以把1000萬次(0 - 9,999,999)拆成0 - 2,499,999、2,500,000 - 4,999,999、5,000,000 - 7,499,999、7,500,000 - 9,999,999四組,建立四個執行緒執行,如此讓四核同時各跑250萬次運算,理論上總運算時間就可以大幅減少(理論值是1/4,但會有其他額外的Overhead,並不會真的變成1/4)。

至於應該要拆幾條執行緒,因為CPU在切換多條執行緒時會有Context Switch的損耗,原則上設成一核一條執行緒,應可將損耗壓到最低,但事實是否如此,我們可以實驗看看。

以下的程式是由前文的Log10演算改寫而來,分為前後兩段,前段直接用迴圈計算1-5000萬的Log10,後段則建立WORKER_COUNT條執行緒,平分5000萬次的運算。每次測試時會前後段會各執行三次,以避免實驗誤差。

using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
 
namespace MultiCore
{
    class TestAnonymousMethod
    {
        static void Main(string[] args)
        {
            //做5000萬次
            int MAX_COUNT = 5000 * 10000;
            Stopwatch sw = new Stopwatch();
            for (int round = 0; round < 3; round++)
            {
                sw.Reset();
                //Console.WriteLine("LOOP: {0:N0} - {1:N0}", 0, MAX_COUNT);
                sw.Start();
                //直接計算
                for (int i = 0; i < MAX_COUNT; i++)
                {
                    double d = Math.Log10(Convert.ToDouble(i));
                }
                sw.Stop();
                Console.WriteLine("循序處理 = {0:N0}ms",
                    sw.ElapsedMilliseconds);
 
                sw.Reset();
                sw.Start();
                //控制Thread數
                int WORKER_COUNT = 4;
                Thread[] workers = new Thread[WORKER_COUNT];
                int jobsCountPerWorker = MAX_COUNT / WORKER_COUNT;
                for (int i = 0; i < WORKER_COUNT; i++)
                {
                    //將全部工作切成WORKER_COUNT份,
                    //分給WORKER_COUNT個Thread執行
                    int st = jobsCountPerWorker * i;
                    int ed = jobsCountPerWorker * (i + 1);
                    if (ed > MAX_COUNT) ed = MAX_COUNT;
                    workers[i] = new Thread(() =>
                    {
                        //Console.WriteLine("LOOP: {0:N0} - {1:N0}", st, ed);
                        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("平行處理[{1}] = {0:N0}ms",
                    sw.ElapsedMilliseconds, WORKER_COUNT);
            }
            Console.Read();
        }
    }
}

測試結果為: (測試環境為Windows 7 Hyper VM on Windows 2008 R2,VM啟用了四個CPU)

循序處理 = 2,750ms
平行處理[4] = 1,006ms
循序處理 = 2,762ms
平行處理[4] = 1,121ms
循序處理 = 2,764ms
平行處理[4] = 1,409ms

執行時間縮短為1/2到1/3左右。(數字不知是否受"VM模擬的4 CPU環境"影響,或許在實體機器上效益會更明顯)

接著我們修改程式,讓WORKER_COUNT由1-16,跑十次取平均時間,程式與結果以下:

//做5000萬次
int MAX_COUNT = 5000 * 10000;
Stopwatch sw = new Stopwatch();
for (int WORKER_COUNT = 1; WORKER_COUNT < 16; WORKER_COUNT++)
{=
    long sum = 0;
    for (int round = 0; round < 50; round++)
    {
        sw.Reset();
        sw.Start();
        Thread[] workers = new Thread[WORKER_COUNT];
        int jobsCountPerWorker = MAX_COUNT / WORKER_COUNT;
        for (int i = 0; i < WORKER_COUNT; i++)
        {
            //...省略...
            workers[i].Start();
        }
        for (int i = 0; i < WORKER_COUNT; i++)
            workers[i].Join();
        sw.Stop();
        sum += sw.ElapsedMilliseconds;
    }
    Console.WriteLine("平行處理[{1}] = {0:N0}ms",
        sum / 50, WORKER_COUNT);
}
Console.Read();

平行處理[1] = 3,253ms
平行處理[2] = 1,723ms
平行處理[3] = 1,316ms
平行處理[4] = 1,155ms
平行處理[5] = 1,459ms
平行處理[6] = 1,448ms
平行處理[7] = 1,466ms
平行處理[8] = 1,517ms
平行處理[9] = 1,587ms
平行處理[10] = 1,633ms
平行處理[11] = 1,642ms
平行處理[12] = 1,735ms
平行處理[13] = 1,716ms
平行處理[14] = 1,759ms
平行處理[15] = 1,810ms
平行處理[16] = 1,863ms

測試數據證明了我們的假設--針對以運算為主的大量作業在這個以簡單運算為主的大量作業案例中,Thread數與CPU數目相同時可獲得最佳執行效率

在接下來的文章裡,我們要來看看CLR 4.0幹了什麼好事?


Comments

# by R.

Soga! 原來如此呀!

# by ABC

那單核但有HT的CPU要用幾個執行緒呢?

# by chicken

吐個槽... 一核配一條 thread 大部份都不成立... 因為 OS 也要在背景做事 (所以四核可能只能配 3.5 threads ...), 而要做的事也不完全是只用到 CPU,如 DISK I/O,NETWORK I/O 等等,又會變成一個核心可以搭配多個 threads.. 我這篇剛好有討論到這類的問題,可以參考看看 :D http://columns.chicken-house.net/post/RUNPC-e7b2bee981b8e69687e7aba0-e9818be794a8ThreadPoole799bce68faeCPUe9818be7ae97e883bde58a9b.aspx

# by Jeffrey

to chicken, 一核一緒的結論必須建構在"程式碼對CPU運算依賴度極高"的前題下。依我粗淺的想法,相較於沈重的運算需求,OS在背景做事消耗的CPU比例可以被忽略,為此空出一顆CPU就顯得有些不划算,而.NET 4.0 PLINQ中看來也採用類似的Idea,會開出核心數目個Thread來處理工作。至於等待I/O造成的延遲會減少CPU的使用,當比例高到一等程度時,的確應該再增加Thread以求搾乾CPU。(嘿... 不小心洩漏了劇情,下一篇文章裡,一核一緒的說法又要被修理了)

# by Google

LOG10也算"針對以運算為主的大量作業"啊...呵呵 黑暗兄, 要不要把LOG10改成基本的超大矩陣運算再來驗證你說的"一核一緒"理論... 平行處理這玩意學術界已經玩很久了, 您這種開玩笑式的演繹該不會只是要騙個MS MVP用的吧!

# by Jeffrey

to Google, 粗淺文章不敢遑稱學術研究! 當初選用Log10只是找一個以簡單以運算為主又不會讓人看到眼花的範例,但您確實有點出它在推導結論上的薄弱性,因此我修改了文章結論,謝謝您的指教!

# by 匿名罵人才是王道

果然匿名罵人是王道, 以後大家都不要分享技術心得, 自己在家修練就好了

Post a comment