多執行緒優化思路(轉載的)
程式功能:求從1一直到 APPLE_MAX_VALUE (
100000000
)
相加累計的和,並賦值給 apple 的a
和b
;求 orange 資料結構中的 a[i]+b[i ] 的和,迴圈 ORANGE_MAX_VALUE
(
1000000
)
次。
說明:
- 由於樣例程式是從實際應用中抽象出來的模型,所以本文不會進行 test.a=test.b= test.b+sum 、中間變數(查詢表)等類似的優化。
- 以下所有程式片斷均為部分程式碼,完整程式碼請參看本文最下面的附件。
清單 1. 樣例程式
- #define ORANGE_MAX_VALUE 1000000
-
#define APPLE_MAX_VALUE 100000000
- #define MSECOND 1000000
- struct apple
- {
- unsigned longlong a;
- unsigned longlong b;
- };
- struct orange
- {
- int a[ORANGE_MAX_VALUE];
- int b[ORANGE_MAX_VALUE];
- };
- int main (int argc, constchar * argv[]) {
- // insert code here...
-
struct apple test;
- struct orange test1;
- for(sum=0;sum<APPLE_MAX_VALUE;sum++)
- {
- test.a += sum;
- test.b += sum;
- }
- sum=0;
- for(index=0;index<ORANGE_MAX_VALUE;index++)
- {
- sum += test1.a[index]+test1.b[index];
- }
- return 0;
-
}
在檢測程式執行時間這個複雜問題上,將採用 Randal E.Bryant 和 David R. O’Hallaron 提出的 K 次最優測量方法。假設重複的執行一個程式,並紀錄 K 次最快的時間,如果發現測量的誤差 ε 很小,那麼用測量的最快值表示過程的真正執行時間, 稱這種方法為“ K 次最優(K-Best)方法”,要求設定三個引數:
K: 要求在某個接近最快值範圍內的測量值數量。
ε 測量值必須多大程度的接近,即測量值按照升序標號 V1, V2, V3, … , Vi, … ,同時必須滿足(1+ ε)Vi >= Vk
M: 在結束測試之前,測量值的最大數量。
按照升序的方式維護一個 K 個最快時間的陣列,對於每一個新的測量值,如果比當前 K 處的值更快,則用最新的值替換陣列中的元素 K ,然後再進行升序排序,持續不斷的進行該過程,並滿足誤差標準,此時就稱測量值已經收斂。如果 M 次後,不能滿足誤差標準,則稱為不能收斂。
在接下來的所有試驗中,採用 K=10,ε=2%,M=200 來獲取程式執行時間,同時也對 K 次最優測量方法進行了改進,不是採用最小值來表示程式執行的時間,而是採用 K 次測量值的平均值來表示程式的真正執行時間。由於採用的誤差 ε 比較大,在所有試驗程式的時間收集過程中,均能收斂,但也能說明問題。
為了可移植性,採用 gettimeofday() 來獲取系統時鐘(system clock)時間,可以精確到微秒。
硬體:聯想 Dual-core 雙核機器,主頻 2.4G,記憶體 2G
軟體:Suse Linunx Enterprise 10,核心版本:linux-2.6.16
醫生治病首先要望聞問切,然後才確定病因,最後再對症下藥,如果胡亂醫治一通,不死也殘廢。說起來大家都懂的道理,但在軟體優化過程中,往往都喜歡犯這樣的錯誤。不分青紅皁白,一上來這裡改改,那裡改改,其結果往往不如人意。
一般將軟體優化可分為三個層次:系統層面,應用層面及微架構層面。首先從巨集觀進行考慮,進行望聞問切,即系統層面的優化,把所有與程式相關的資訊收集上來,確定病因。確定病因後,開始從微觀上進行優化,即進行應用層面和微架構方面的優化。
- 系統層面的優化:記憶體不夠,CPU 速度過慢,系統中程序過多等
- 應用層面的優化:演算法優化、並行設計等
- 微架構層面的優化:分支預測、資料結構優化、指令優化等
軟體優化可以在應用開發的任一階段進行,當然越早越好,這樣以後的麻煩就會少很多。
在實際應用程式中,採用最多的是應用層面的優化,也會採用微架構層面的優化。將某些優化和維護成本進行對比,往往選擇的都是後者。如分支預測優化和指令優化,在大型應用程式中,往往採用的比較少,因為維護成本過高。
本文將從應用層面和微架構層面,對樣例程式進行優化。對於應用層面的優化,將採用多執行緒和 CPU 親和力技術;在微架構層面,採用 Cache 優化。
利用並行程式設計模型來設計應用程式,就必須把自己的思維從線性模型中拉出來,重新審視整個處理流程,從頭到尾梳理一遍,將能夠並行執行的部分識別出來。
可以將應用程式看成是眾多相互依賴的任務的集合。將應用程式劃分成多個獨立的任務,並確定這些任務之間的相互依賴關係,這個過程被稱為分解(Decomosition)。分解問題的方式主要有三種:任務分解、資料分解和資料流分解。關於這部分的詳細資料,請參看參考資料一。
仔細分析樣例程式,運用任務分解的方法 ,不難發現計算 apple 的值和計算 orange 的值,屬於完全不相關的兩個操作,因此可以並行。
改造後的兩執行緒程式:
清單 2. 兩執行緒程式
- void* add(void* x)
- {
- for(sum=0;sum<APPLE_MAX_VALUE;sum++)
- {
- ((struct apple *)x)->a += sum;
- ((struct apple *)x)->b += sum;
- }
- return NULL;
- }
- int main (int argc, constchar * argv[]) {
- // insert code here...
- struct apple test;
- struct orange test1={{0},{0}};
- pthread_t ThreadA;
- pthread_create(&ThreadA,NULL,add,&test);
- for(index=0;index<ORANGE_MAX_VALUE;index++)
- {
- sum += test1.a[index]+test1.b[index];
- }
- pthread_join(ThreadA,NULL);
- return 0;
- }
更甚一步,通過資料分解的方法,還可以發現,計算 apple 的值可以分解為兩個執行緒,一個用於計算 apple a
的值,另外一個執行緒用於計算 appleb
的值(說明:本方案抽象於實際的應用程式)。但兩個執行緒存在同時訪問 apple 的可能性,所以需要加鎖訪問該資料結構。
改造後的三執行緒程式如下:
清單 3. 三執行緒程式
- struct apple
- {
- unsigned longlong a;
- unsigned longlong b;
- pthread_rwlock_t rwLock;
- };
- void* addx(void* x)
- {
- pthread_rwlock_wrlock(&((struct apple *)x)->rwLock);
- for(sum=0;sum<APPLE_MAX_VALUE;sum++)
- {
- ((struct apple *)x)->a += sum;
- }
- pthread_rwlock_unlock(&((struct apple *)x)->rwLock);
- return NULL;
- }
- void* addy(void* y)
- {
- pthread_rwlock_wrlock(&((struct apple *)y)->rwLock);
- for(sum=0;sum<APPLE_MAX_VALUE;sum++)
- {
- ((struct apple *)y)->b += sum;
- }
- pthread_rwlock_unlock(&((struct apple *)y)->rwLock);
- return NULL;
- }
- int main (int argc, constchar * argv[]) {
- // insert code here...
- struct apple test;
- struct orange test1={{0},{0}};
- pthread_t ThreadA,ThreadB;
- pthread_create(&ThreadA,NULL,addx,&test);
- pthread_create(&ThreadB,NULL,addy,&test);
- for(index=0;index<ORANGE_MAX_VALUE;index++)
- {
- sum+=test1.a[index]+test1.b[index];
- }
- pthread_join(ThreadA,NULL);
- pthread_join(ThreadB,NULL);
- return 0;
- }
這樣改造後,真的能達到我們想要的效果嗎?通過 K-Best 測量方法,其結果讓我們大失所望,如下圖:
圖 1. 單執行緒與多執行緒耗時對比圖
為什麼多執行緒會比單執行緒更耗時呢?其原因就在於,執行緒啟停以及執行緒上下文切換都會引起額外的開銷,所以消耗的時間比單執行緒多。
為什麼加鎖後的三執行緒比兩執行緒還慢呢?其原因也很簡單,那把讀寫鎖就是罪魁禍首。通過 Thread Viewer 也可以印證剛才的結果,實際情況並不是並行執行,反而成了序列執行,如圖2:
圖 2. 通過 Viewer 觀察三執行緒執行情況
其中最下面那個執行緒是主執行緒,一個是 addx
執行緒,另外一個是 addy
執行緒,從圖中不難看出,其他兩個執行緒為序列執行。
通過資料分解來劃分多執行緒,還存在另外一種方式,一個執行緒計算從1到 APPLE_MAX_VALUE/2
的值,另外一個執行緒計算從 APPLE_MAX_VALUE/2+1
到 APPLE_MAX_VALUE
的值,但本文會棄用這種模型,有興趣的讀者可以試一試。
在採用多執行緒方法設計程式時,如果產生的額外開銷大於執行緒的工作任務,就沒有並行的必要。執行緒並不是越多越好,軟體執行緒的數量儘量能與硬體執行緒的數量相匹配。最好根據實際的需要,通過不斷的調優,來確定執行緒數量的最佳值。
針對加鎖的三執行緒方案,由於兩個執行緒訪問的是 apple 的不同元素,根本沒有加鎖的必要,所以修改 apple 的資料結構(刪除讀寫鎖程式碼),通過不加鎖來提高效能。
測試結果如下:
圖 3. 加鎖與不加鎖耗時對比圖
其結果再一次大跌眼鏡,可能有些人就會越來越糊塗了,怎麼不加鎖的效率反而更低呢?將在針對 Cache 的優化一節中細細分析其具體原因。
在實際測試過程中,不加鎖的三執行緒方案非常不穩定,有時所花費的時間相差4倍多。
要提高並行程式的效能,在設計時就需要在較少同步和較多同步之間尋求折中。同步太少會導致錯誤的結果,同步太多又會導致效率過低。儘量使用私有鎖,降低鎖的粒度。無鎖設計既有優點也有缺點,無鎖方案能充分提高效率,但使得設計更加複雜,維護操作困難,不得不借助其他機制來保證程式的正確性。
在序列程式設計過程中,為了節約頻寬或者儲存空間,比較直接的方法,就是對資料結構做一些針對性的設計,將資料壓縮 (pack) 的更緊湊,減少資料的移動,以此來提高程式的效能。但在多核多執行緒程式中,這種方法往往有時會適得其反。
資料不僅在執行核和儲存器