中文圖書推薦:《OpenMP編譯原理及實現技術》
5.2 串行程序的性能考慮
目前,單核處理器的性能經常歸因爲未充分利用的cache內存子系統。特別地,緩存分層中的最高層緩存未命中的代價是高昂的,因爲這意味着數據在使用之前必須從主內存中獲取。典型地,相比從緩存中獲取數據,通常需要付出5-10倍更多的代價。在一個共享內存多核處理器系統中,這一負面影響更爲嚴重:涉及的線程越多,潛在地性能問題越大。
我們簡約的討論了存儲器分層和它的影響,因爲這對OpenMP來說是如此的重要。我們強烈建議,編程者在創建OpenMP代碼時考慮串行性能,特別是目標是一個可擴展的OpenMP應用。
5.2.1 內存訪問模式和性能
現代內存系統被組織爲一個分層次的等級結構,最大也是最慢的內存部分被稱爲主存(main memory)。主存被組織成頁,主存的頁是應用程序可以獲取的一個子集。靠近處理器的內存層相對較小較快,共同被稱爲緩存(cache)。當一個程序被編譯,編譯器會安排它的數據對象被存儲在主存中;它們會在需要時被傳輸到緩衝中。如果一個計算需要的值尚未在緩存中(我們稱之爲緩存“丟失”),它必須從更高層次的內存等級結構中被獲取到,這個過程的代價是相當昂貴的。程序數據被帶進緩存塊,每一個都會佔據緩存的一行。在緩存中已存在的數據可能需要被移除,爲新的數據塊騰出空間。不同的系統在決定移除的緩存具有不同的策略。
存儲器分層不能被用戶或者編譯器明確的可編程(除了極少數例外)。數據在必要時動態地獲取到緩存和驅逐出緩存。有很多策略可以幫助編譯器和編程者間接地減少緩存“丟失”。一個主要的目標是組織數據訪問,從而使數據值在緩存中時,儘可能地多被使用。這麼做最常見的策略是基於一個事實,編程語言通常指定數組元素被連續地存儲在存儲器中。因此,如果一個數組元素被取到緩存中,數組的“附近”元素會在相同的緩存塊,作爲相同的傳輸中的一部分被獲取。如果一個計算在它們仍然在緩存中時,使用任何這些可被執行的值,會有性能上的提升。
在C語言中,一個二維數組按行存儲。當一個數組元素被傳輸到緩存,同一行內相鄰元素通常也會被傳輸作爲同一緩存行。
5.2.2 翻譯後備緩衝區(TLB)
我們已跳過了關於存儲系統的一個重要細節。TLB在性能的關鍵路徑上。當一個計算所需的數據和確定物理位置所需的信息不在TLB中,處理器等待直到請求的信息可用。只有那時,它才能傳輸值並回復執行。因此,和數據緩存一樣,好好利用TLB入口是重要的。當一個頁的位置被存儲在TLB中時,我們喜歡經常引用這個頁。無論何時當一個程序訪問數據不是以存儲器順序,經常性的緩存重載附加大量的TLB丟失就可能會發生。
5.2.3 循環優化
編程者和編譯器都可以提升內存的使用效率。因爲很多程序花費了它們很多時間來執行循環,並且因爲絕大多數數組訪問是在那裏,在循環嵌套中一個合適的計算重組織以利用緩存可以明顯提升一個程序的性能。很多循環轉換可能幫組實現這一目標。這個測試如下:
在一個循環嵌套中,如果任何存儲器位置被引用超過一次,並且如果這些引用中至少有一個修改了它的值,那麼它們的相對順序不能被轉換所修改。
循環展開是一種強大的技術,有效減少循環執行的開銷(由循環變量的遞增,完成測試和到循環代碼開始的分支引起的)。循環展開通過提升數據複用,可以幫組提高緩存行利用率。它也可以幫組提高指令級並行化(ILP)。爲了完成這點,轉換工作打包幾次循環迭代到一次,通過複製和恰當修改循環中的聲明。
for (int i=1; i<n; i++) {
a[i] = b[i] + 1;
c[i] = a[i] + a[i-1] + b[i-1];
}
for (int i=1; i<n; i+=2) {
a[i] = b[i] + 1;
c[i] = a[i] + a[i-1] + b[i-1];
a[i+1] = b[i+1] + 1;
c[i+1] = a[i+1] + a[i] + b[i];
}
如果循環已包含大量計算或者如果它包含過程調用,循環展開通常不是個好主意。前一種情況很可能意味着會使緩存使用較爲低效,後一種引入新的開銷相比所節省的。如果在循環中有分支,收益也可能是低的。
循環融合(Loop fusion)合併兩個或更多的循環,創建一個大循環。這可能會使緩存中的數據更高頻率被重用,或者會提升每次迭代的計算量以提高指令級並行化,也使得有較低的循環開銷,因爲每次迭代做了更多的工作。
for (int i=0; i<n; i++)
a[i] = b[i] * 2;
for (int i=0; i<n; i++)
{
x[i] = 2 * x[i];
c[i] = a[i] + 2;
}
for (int i=0; i<n; i++)
{
a[i] = b[i] * 2;
c[i] = a[i] + 2;
x[i] = 2 * x[i];
}
for (int i=0; i<n; i++)
{
c[i] = exp(i/n) ;
for (int j=0; j<m; j++)
a[j][i] = b[j][i] + d[j] * e[i];
}
for (int i=0; i<n; i++)
c[i] = exp(i/n) ;
for (int j=0; j<m; j++)
for (int i=0; i<n; i++)
a[j][i] = b[j][i] + d[j] * e[i];
5.2.4 在C語言中使用指針和連續內存
內存在C應用中被廣泛使用,但是在性能調整時它們造成一些列挑戰。C語言的內存模型是這樣的,沒有附加信息,必須假定所有內存可能引用任何內存地址。這通常被稱爲指針別名問題。它阻止了編譯器執行很多程序優化,因爲它不能確定它們是安全的。結果,性能會有損失。但是如果指針確保指向不重疊的內存,例如因爲每個指針目標內存通過一個明確的malloc函數被分配,更爲激進的優化技術可以被使用。通常,只有編程者知道一個指針可能指向的內存位置。restrict關鍵字告訴編譯器,一個指針指向的內存不會被另一個指針指向的內存區域所覆蓋。
聲明一個線性數組代表一個二維數組。如果被聲明爲二維數組的指針,編譯器在考慮內存佈局時,必須做一個更爲保守的假設。這對編譯器優化代碼的能力有負面影響。矩陣的線性化確保一個連續的內存塊被使用,這幫助編譯器分析和優化循環嵌套來提高內存利用率。它也會引起更少的內存訪問,並且可能提高軟件控制的數據預取,如果支持的話。
5.2.5 使用編譯器
現代編譯器實現了5.2.3中所述絕大多數的循環優化。它們執行很多分析來決定是否它們可能被使用(主要一個被稱爲數據依賴性分析)。它們也應用很多技術來減少執行的操作數量,重排代碼以更好地利用硬件。它們執行的工作量可以被應用開發者所影響。一旦計算結果的正確性是受保證的,試驗編譯器選項來榨取應用的最大性能是值得的。這些選項(或標誌)在編譯器之間有很大不同,因此必須爲每個編譯器重新探索。回想編譯器轉換代碼的能力受限於它分析程序和決定可以安全修改部分的能力。我們已經看到這可能受指針存在的影響。另一種問題出現,當編譯器不能提高內存使用效率,由於涉及修改非本地數據的結構。這裏編程者必須採取行動,一些代碼的重寫可能會有更好的結果。
5.3 衡量OpenMP性能
做並行化後的Amdahl定律:
在此模型中,Tserial是應用程序原本串行版本的CPU時間。處理器核數爲P。並行開銷表示爲Op乘以P,Op被假設爲常量百分比(這做了簡化,因爲開銷可能會隨着處理器數目的增加而增加)。已被並行化的部分被指定爲f,在0到1之間。f=0表示應用時串行的。f=1是最佳的並行應用。
5.3.1 理解一個OpenMP程序的性能
在前面部分,我們已看到存儲器行爲對於串行應用的性能是至關重要的,我們注意到這也適用於OpenMP代碼。
OpenMP的顯著性能受下列因素的影響,除了那些在串行程序中發揮作用的性能:
>獨立線程訪問存儲器的方式。如後面會看到的,這對性能有重大的影響。在整個程序中,如果每個線程一致地訪問數據的一個獨特部分,可能會非常好的使用存儲器分層,包括線程本地緩存。
> 處理OpenMP結構的時間量。我們稱這些爲(OpenMP)並行開銷。
> 同步點的負載均衡。
> 其它同步開銷。典型地,線程浪費時間等待訪問一個臨界區或一個包含原子更新的變量,或者爲了獲取一個鎖。這些共同被稱爲同步開銷。
5.4 最佳實踐
在此部分,我們提供一些關於如何寫一個高效的OpenMP程序的一般性建議。
5.4.1 優化屏障使用
無論屏障(barrier)如何高效地被實現,它們是代價高昂的操作。減少它們的使用到代碼的最少需求是值得的。幸運的是,nowait條例使得很容易地消除一些結構中隱含的屏障。
一個推薦的策略:首先確保OpenMP程序工作正確,然後儘可能使用nowait條例,需要時在程序的特殊點小心地插入顯示的屏障。當這麼做時,需要特別小心地識別和排序讀寫相同內存部分的計算。
如計算5.21中所演示。向量a和c是獨立被更新的。因爲新值a和c隨後被用於計算sum,我們在此之前必須加入屏障。具體如下:
#pragma omp parallel default(none) \
shared(n,a,b,c,d,sum) private(i)
{
#pragma omp for nowait
for (i=0; i<n; i++)
a[i] += b[i];
#pragma omp for nowait
for (i=0; i<n; i++)
c[i] += d[i];
#pragma omp barrier
#pragma omp for nowait reduction(+:sum)
for (i=0; i<n; i++)
sum += a[i] + c[i];
} /*-- End of parallel region --*/
5.4.2 避免順序結構
順序結構通常可以避免。例如,在循環之外等待和執行I/O。
5.4.3 避免大片臨界區
如果可能,一個原子更新是更好的選擇。另一種方法是重寫代碼片段,儘可能分開這些不會導致競爭的計算,它們不需要被保護。
5.4.4 最大化並行區域
例如,如果我們有多個並行循環,我們必須選擇是否封裝每個循環到一個獨立的並行區域,或創建一個並行區域包括他們中的所有。
替代方法在計算5.24中被列出。它具有更少的隱含屏障,並且可能有循環之間的潛在的緩存數據複用。這個方法的缺點是,不能調整基於每個循環的線程數,但這通常不是個限制。
#pragma omp parallel for
for (.....)
{
/*-- Work-sharing loop 1 --*/
}
#pragma omp parallel for
for (.....)
{
/*-- Work-sharing loop 2 --*/
}
.........
#pragma omp parallel for
for (.....)
{
/*-- Work-sharing loop N --*/
}
#pragma omp parallel
{
#pragma omp for /*-- Work-sharing loop 1 --*/
{ ...... }
#pragma omp for /*-- Work-sharing loop 2 --*/
{ ...... }
.........
#pragma omp for /*-- Work-sharing loop N --*/
{ ...... }
}
5.4.5 避免在內循環中的並行區域
for (i=0; i<n; i++)
for (j=0; j<n; j++)
#pragma omp parallel for
for (k=0; k<n; k++)
{ .........}
#pragma omp parallel
for (i=0; i<n; i++)
for (j=0; j<n; j++)
#pragma omp for
for (k=0; k<n; k++)
{ .........}