利用多核多線程進行程序優化

樣例程序

程序功能:求從1一直到 APPLE_MAX_VALUE (100000000) 相加累計的和,並賦值給 apple 的 a 和 b ;求 orange 數據結構中的 a[i]+b[i ] 的和,循環 ORANGE_MAX_VALUE(1000000) 次。

說明:

  1. 由於樣例程序是從實際應用中抽象出來的模型,所以本文不會進行 test.a=test.b= test.b+sum 、中間變量(查找表)等類似的優化。
  2. 以下所有程序片斷均爲部分代碼,完整代碼請參看本文最下面的附件。
清單 1. 樣例程序
#define ORANGE_MAX_VALUE      1000000
#define APPLE_MAX_VALUE       100000000
#define MSECOND               1000000

struct apple
{
     unsigned long long a;
	unsigned long long b;
};

struct orange
{
	int a[ORANGE_MAX_VALUE];
	int b[ORANGE_MAX_VALUE];
	
};

int main (int argc, const char * 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

醫生治病首先要望聞問切,然後才確定病因,最後再對症下藥,如果胡亂醫治一通,不死也殘廢。說起來大家都懂的道理,但在軟件優化過程中,往往都喜歡犯這樣的錯誤。不分青紅皁白,一上來這裏改改,那裏改改,其結果往往不如人意。

一般將軟件優化可分爲三個層次:系統層面,應用層面及微架構層面。首先從宏觀進行考慮,進行望聞問切,即系統層面的優化,把所有與程序相關的信息收集上來,確定病因。確定病因後,開始從微觀上進行優化,即進行應用層面和微架構方面的優化。

  1. 系統層面的優化:內存不夠,CPU 速度過慢,系統中進程過多等
  2. 應用層面的優化:算法優化、並行設計等
  3. 微架構層面的優化:分支預測、數據結構優化、指令優化等

軟件優化可以在應用開發的任一階段進行,當然越早越好,這樣以後的麻煩就會少很多。

在實際應用程序中,採用最多的是應用層面的優化,也會採用微架構層面的優化。將某些優化和維護成本進行對比,往往選擇的都是後者。如分支預測優化和指令優化,在大型應用程序中,往往採用的比較少,因爲維護成本過高。

本文將從應用層面和微架構層面,對樣例程序進行優化。對於應用層面的優化,將採用多線程和 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, const char * 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 的值,另外一個線程用於計算 apple b 的值(說明:本方案抽象於實際的應用程序)。但兩個線程存在同時訪問 apple 的可能性,所以需要加鎖訪問該數據結構。

改造後的三線程程序如下:

清單 3. 三線程程序
struct apple
{
     unsigned long long a;
	unsigned long long 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, const char * 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 觀察三線程運行情況
通過 Viewer 觀察三線程運行情況

其中最下面那個線程是主線程,一個是 addx 線程,另外一個是 addy 線程,從圖中不難看出,其他兩個線程爲串行執行。

通過數據分解來劃分多線程,還存在另外一種方式,一個線程計算從1到 APPLE_MAX_VALUE/2 的值,另外一個線程計算從APPLE_MAX_VALUE/2+1 到 APPLE_MAX_VALUE 的值,但本文會棄用這種模型,有興趣的讀者可以試一試。

在採用多線程方法設計程序時,如果產生的額外開銷大於線程的工作任務,就沒有並行的必要。線程並不是越多越好,軟件線程的數量儘量能與硬件線程的數量相匹配。最好根據實際的需要,通過不斷的調優,來確定線程數量的最佳值。


加鎖與不加鎖

針對加鎖的三線程方案,由於兩個線程訪問的是 apple 的不同元素,根本沒有加鎖的必要,所以修改 apple 的數據結構(刪除讀寫鎖代碼),通過不加鎖來提高性能。

測試結果如下:

圖 3. 加鎖與不加鎖耗時對比圖
加鎖與不加鎖耗時對比圖

其結果再一次大跌眼鏡,可能有些人就會越來越糊塗了,怎麼不加鎖的效率反而更低呢?將在針對 Cache 的優化一節中細細分析其具體原因。

在實際測試過程中,不加鎖的三線程方案非常不穩定,有時所花費的時間相差4倍多。

要提高並行程序的性能,在設計時就需要在較少同步和較多同步之間尋求折中。同步太少會導致錯誤的結果,同步太多又會導致效率過低。儘量使用私有鎖,降低鎖的粒度。無鎖設計既有優點也有缺點,無鎖方案能充分提高效率,但使得設計更加複雜,維護操作困難,不得不借助其他機制來保證程序的正確性。

針對 Cache 的優化

在串行程序設計過程中,爲了節約帶寬或者存儲空間,比較直接的方法,就是對數據結構做一些針對性的設計,將數據壓縮 (pack) 的更緊湊,減少數據的移動,以此來提高程序的性能。但在多核多線程程序中,這種方法往往有時會適得其反。

數據不僅在執行核和存儲器之間移動,還會在執行核之間傳輸。根據數據相關性,其中有兩種讀寫模式會涉及到數據的移動:寫後讀和寫後寫 ,因爲這兩種模式會引發數據的競爭,表面上是並行執行,但實際只能串行執行,進而影響到性能。

處理器交換的最小單元是 cache 行,或稱 cache 塊。在多核體系中,對於不共享 cache 的架構來說,兩個獨立的 cache 在需要讀取同一 cache 行時,會共享該 cache 行,如果在其中一個 cache 中,該 cache 行被寫入,而在另一個 cache 中該 cache 行被讀取,那麼即使讀寫的地址不相交,也需要在這兩個 cache 之間移動數據,這就被稱爲 cache 僞共享,導致執行核必須在存儲總線上來回傳遞這個 cache 行,這種現象被稱爲“乒乓效應”。

同樣地,當兩個線程寫入同一個 cache 的不同部分時,也會互相競爭該 cache 行,也就是寫後寫的問題。上文曾提到,不加鎖的方案反而比加鎖的方案更慢,就是互相競爭 cache 的原因。

在 X86 機器上,某些處理器的一個 cache 行是64字節,具體可以參看 Intel 的參考手冊。

既然不加鎖三線程方案的瓶頸在於 cache,那麼讓 apple 的兩個成員 a 和 b 位於不同的 cache 行中,效率會有所提高嗎?

修改後的代碼片斷如下:

清單 4. 針對Cache的優化
struct apple
{
	unsigned long long a;
	char c[128];  /*32,64,128*/
	unsigned long long b;
};

測量結果如下圖所示:

圖 4. 增加 Cache 時間耗時對比圖
增加 Cache 時間耗時對比圖

小小的一行代碼,盡然帶來了如此高的收益,不難看出,我們是用空間來換時間。當然讀者也可以採用更簡便的方法: __attribute__((__aligned__(L1_CACHE_BYTES))) 來確定 cache 的大小。

如果對加鎖三線程方案中的 apple 數據結構也增加一行類似功能的代碼,效率也是否會提升呢?性能不會有所提升,其原因是加鎖的三線程方案效率低下的原因不是 Cache 失效造成的,而是那把鎖。

在多核和多線程程序設計過程中,要全盤考慮多個線程的訪存需求,不要單獨考慮一個線程的需求。在選擇並行任務分解方法時,要綜合考慮訪存帶寬和競爭問題,將不同處理器和不同線程使用的數據放在不同的 Cache 行中,將只讀數據和可寫數據分離開。

CPU 親和力可分爲兩大類:軟親和力和硬親和力。

Linux 內核進程調度器天生就具有被稱爲 CPU 軟親和力(affinity) 的特性,這意味着進程通常不會在處理器之間頻繁遷移。這種狀態正是我們希望的,因爲進程遷移的頻率小就意味着產生的負載小。但不代表不會進行小範圍的遷移。

CPU 硬親和力是指進程固定在某個處理器上運行,而不是在不同的處理器之間進行頻繁的遷移。這樣不僅改善了程序的性能,還提高了程序的可靠性。

從以上不難看出,在某種程度上硬親和力比軟親和力具有一定的優勢。但在內核開發者不斷的努力下,2.6內核軟親和力的缺陷已經比2.4的內核有了很大的改善。

在雙核機器上,針對兩線程的方案,如果將計算 apple 的線程綁定到一個 CPU 上,將計算 orange 的線程綁定到另外一個 CPU 上,效率是否會有所提高呢?

程序如下:

清單 5. CPU 親和力
struct apple
{
	unsigned long long a;
	unsigned long long b;
};
	
struct orange
{
	int a[ORANGE_MAX_VALUE];
	int b[ORANGE_MAX_VALUE];		
};
		
inline int set_cpu(int i)
{
	CPU_ZERO(&mask);
	
	if(2 <= cpu_nums)
	{
		CPU_SET(i,&mask);
		
		if(-1 == sched_setaffinity(gettid(),sizeof(&mask),&mask))
		{
			return -1;
		}
	}
	return 0;
}

	
void* add(void* x)
{
	if(-1 == set_cpu(1))
	{
		return NULL;
	} 
		
	for(sum=0;sum<APPLE_MAX_VALUE;sum++)
	{
		((struct apple *)x)->a += sum;
		((struct apple *)x)->b += sum;
	}	
	
	return NULL;
}
	
int main (int argc, const char * argv[]) {
		// insert code here...
	struct apple test;
	struct orange test1;
	
	cpu_nums = sysconf(_SC_NPROCESSORS_CONF);
	
	if(-1 == set_cpu(0))
	{
		return -1;
	} 
		
	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;
}

測量結果爲:

圖 5. 採用硬親和力時間對比圖(兩線程)
採用硬親和力時間對比圖(兩線程)

其測量結果正是我們所希望的,但花費的時間還是比單線程的多,其原因與上面分析的類似。

進一步分析不難發現,樣例程序大部分時間都消耗在計算 apple 上,如果將計算 a 和 b 的值,分佈到不同的 CPU 上進行計算,同時考慮 Cache 的影響,效率是否也會有所提升呢?

圖 6. 採用硬親和力時間對比圖(三線程)
採用硬親和力時間對比圖(三線程)

從時間上觀察,設置親和力的程序所花費的時間略高於採用 Cache 的三線程方案。由於考慮了 Cache 的影響,排除了一級緩存造成的瓶頸,多出的時間主要消耗在系統調用及內核上,可以通過 time 命令來驗證:

#time ./unlockcachemultiprocess
    real   0m0.834s      user  0m1.644s       sys    0m0.004s
#time ./affinityunlockcacheprocess
    real   0m0.875s      user  0m1.716s       sys    0m0.008s

通過設置 CPU 親和力來利用多核特性,爲提高應用程序性能提供了捷徑。同時也是一把雙刃劍,如果忽略負載均衡、數據競爭等因素,效率將大打折扣,甚至帶來事倍功半的結果。

在進行具體的設計過程中,需要設計良好的數據結構和算法,使其適合於應用的數據移動和處理器的性能特性。


總結

根據以上分析及實驗,對所有改進方案的測試時間做一個綜合對比,如下圖所示:

圖 7. 各方案時間對比圖
各方案時間對比圖

單線程原始程序平均耗時:1.049046s,最慢的不加鎖三線程方案平均耗時:2.217413s,最快的三線程( Cache 爲128)平均耗時:0.826674s,效率提升約26%。當然,還可以進一步優化,讓效率得到更高的提升。

從上圖不難得出結論:採用多核多線程並行設計方案,能有效提高性能,但如果考慮不全面,如忽略帶寬、數據競爭及數據同步不當等因素,效率反而降低,程序執行越來越慢。

如果拋開本文開篇時的限制,採用上文曾提到的另外一種數據分解模型,同時結合硬親和力對樣例程序進行優化,測試時間爲0.54s,效率提升了92%。

軟件優化是一個貫穿整個軟件開發週期,從開始設計到最終完成一直進行的連續過程。在優化前,需要找出瓶頸和熱點所在。正如最偉大的 C 語言大師 Rob Pike 所說:

如果你無法斷定程序會在什麼地方耗費運行時間,瓶頸經常出現在意想不到的地方,所以別急於胡亂找個地方改代碼,除非你已經證實那兒就是瓶頸所在。

將這句話送給所有的優化人員,和大家共勉。


參考資料

  • 請參考書籍《多核程序設計技術》,瞭解更多關於多線程設計的理念
  • 請參考書籍《軟件優化技術》,瞭解更多關於軟件優化的技術
  • 請參考書籍《UNIX編程藝術》, 瞭解更多關於軟件架構方面的知識
  • 參考文章《CPU Affinity》,瞭解更多關於CPU親和力的信息
  • 參考文章《管理處理器的親和性(affinity)》,瞭解更多關於CPU親和力的信息


以下的兩篇博文我感覺也不錯, 與大家分享一下:


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章