前一篇多核研討文章中,用了一個計算1000萬次Log10運算的範例驗證Thread數與Core相同時可以達到最佳效能,網友Google質疑以Log10計算當範例是否用能代表"以運算為主的大量作業",在此做點補充說明。

我想若以茶包射手實事求是的精神,"以運算為主的大量作業"這個命題是有問題的,應該要修正成"不涉及非CPU資源競爭的大量純運算作業"更貼近原意。用白話來解釋,這裡假設的前題是---有一大堆運算工作要處理,每件運算工作彼此獨立可以同時進行,且每件運算所需的資料自給自足,不需要排隊讀取/寫入記憶體、磁碟、網路等資源。當此前題成立,理論上連續大量運算就會耗光單一CPU的所有運算資源,讓CPU呈現100%佔用狀態。因此不管是"簡單的Log10計算"或是"複雜的超大矩陣運算"應該都能得到相同的結果--要達到最高效能,就是讓每顆CPU都忙到最高點,並避免Thread過多時要耗費CPU去做Context Switch拖累效能。以這個概念演繹,為每一核安排一條執行緒應為最佳解。

補充完前回理論命題的不足,我們也來試一下"矩陣運算的範例",看看是否可以獲得一核一緒的結論?

不想自己造輪子,這裡用現成"內含小型矩陣運算的DES加密"當作範例,但複雜度己比Log10高出許多。我用亂數產生了1000個長度為512KB的byte[],分別用1-16條的Thread分工合作處理對這1000筆資料做DES加密,不同Thread數的測試各做五次,取其總耗用時間的平均值。

using System;
using System.Diagnostics;
using System.Threading;
using System.Security.Cryptography;
using System.Text;
using System.IO;
using System.Collections.Generic;
 
namespace MultiCore
{
    class ComplexCalc
    {
        static void Main(string[] args)
        {
            int MAX_COUNT = 1000;
            Dictionary<int, byte[]> pool = new Dictionary<int, byte[]>();
            Random rnd = new Random();
            for (int i = 0; i < MAX_COUNT; i++)
            {
                byte[] b = new byte[512 * 1024];
                rnd.NextBytes(b);
                pool.Add(i, b);
            }
            int ROUND_COUNT = 5;
            Stopwatch sw = new Stopwatch();
            for (int WORKER_COUNT = 1; WORKER_COUNT < 16; WORKER_COUNT++)
            {
                long sum = 0;
                List<string> detail = new List<string>();
                for (int round = 0; round < ROUND_COUNT; 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++)
                    {
                        int st = jobsCountPerWorker * i;
                        int ed = jobsCountPerWorker * (i + 1);
                        if (ed > MAX_COUNT) ed = MAX_COUNT;
                        workers[i] = new Thread(() =>
                        {
                            DESCryptoServiceProvider des =
                                new DESCryptoServiceProvider();
                            des.Key = Encoding.UTF8.GetBytes("ABCDEFGH");
                            des.Key = Encoding.UTF8.GetBytes("12345678");
                            for (int j = st; j < ed; j++)
                            {
                                byte[] randomContent = pool[j];
                                using (MemoryStream ms = new MemoryStream())
                                {
                                    using (CryptoStream cs = new CryptoStream(ms,
                                        des.CreateEncryptor(), CryptoStreamMode.Write))
                                    {
                                        cs.Write(randomContent, 0, randomContent.Length);
                                        cs.FlushFinalBlock();
                                    }
                                }
                            }
                        });                          
                        workers[i].Start();
                    }
                    for (int i = 0; i < WORKER_COUNT; i++)
                        workers[i].Join();
                    sw.Stop();
                    sum += sw.ElapsedMilliseconds;
                    detail.Add(sw.ElapsedMilliseconds.ToString());
                }
                Console.WriteLine("平行處理[{1}] = {0:N0}ms [{2}]",
                    sum / ROUND_COUNT, WORKER_COUNT, string.Join(",", detail.ToArray()));
            }
            Console.Read();
        }
    }
}

有幸借到一台8核CPU的神器,在上面測試得到的結果,應該可讓我們對"一核一緒"的推論更有信心。

平行處理[1] = 14,838ms [14825,14804,14834,14913,14815]
平行處理[2] = 7,942ms [7932,7781,8069,7992,7938]
平行處理[3] = 5,224ms [5434,5234,5138,5160,5158]
平行處理[4] = 3,976ms [3952,3941,3975,3959,4053]
平行處理[5] = 3,312ms [3299,3322,3374,3305,3261]
平行處理[6] = 2,900ms [2874,2905,2913,2915,2897]
平行處理[7] = 2,615ms [2566,2646,2605,2670,2592]
平行處理[8] = 2,540ms [2526,2449,2506,2566,2654]
平行處理[9] = 2,641ms [2809,2588,2713,2522,2576]
平行處理[10] = 2,726ms [2737,2706,2710,2809,2672]
平行處理[11] = 2,684ms [2724,2638,2670,2625,2767]
平行處理[12] = 2,771ms [2796,2795,2744,2717,2805]
平行處理[13] = 2,755ms [2648,2764,2665,2710,2990]
平行處理[14] = 2,726ms [2741,2705,2809,2711,2667]
平行處理[15] = 2,781ms [2862,2686,2792,2837,2732]

陸續用過不同的演算內容(共同等徵是自給自足的純CPU運算不涉及其他資料共用)做相同測試,大致的結果都很相近--Thread數增加時,耗用時間會遞減,最短的時間會出現在Thread數與CPU核數相同時;而Thread超過CPU核數時,時間會略為增加,但並不會隨Thread數繼續上升而明顯增加,而是呈現隨機變化(以上為例: 11變快,12變慢,13又比12快,反覆做了幾次,發現其無一定規則)。針對這個觀察,我的解釋是,Thread數比CPU核數略多時,產生的Context Switch狀況有限,故對效能的影響也不大。經過多次實驗,耗用時間最短的數據出現在Thread數等於CPU核數這點倒是被獲得證實。

平行運算是門深奧的學問,要深入探討學術理論遠超出一般程式開發人員的能力範圍。不過,依茶包射手的精神,在能力許可範圍親手實驗一下與自己切身相關的效能議題,體驗意想不到的樂趣有何不可? 上述的範例算是一個有趣的實驗基礎,稍加修改就可以用來驗證不同運算需求在不同核緒比例下的效能表現,有興趣的人可以玩玩,檢視一下不同運算需求的最佳核緒比例,也歡迎有不同心得的朋友分享切磋。


Comments

# by laneser

非常棒的實驗. 除了純粹 CPU 運算以外, 其實多執行緒程式另一個發揮長處就是等候 Network, HD,甚至是 UI 的回應等需要一些時間的情況. 比方說, 要寫 spider 去取得多個網頁的內容回來處理. 此時就算只有一核 CPU , 多執行緒的表現相對單一執行緒效果好很多. 感謝黑暗大提供寶貴的經驗.

# by jain

謝謝您的實驗,那如果是要涉及「非CPU資源競爭」的多執行緒時,資源管理如何做?(避免長久的等待)

# by chicken

這實驗看來效果還不錯,有 73% 的效能了,一般有 50% 就很偷笑了... 通常平行處理會看兩個數據,speedup & efficiency: 1. speedup: 循序處理 (14838 ms) / 平行處理 (2540 ms) = 5.84 X 2. efficiency: speedup (5.84X) / cpu (8) * 100% = 73%

Post a comment