《Using OpenMP》第五章筆記 ing


中文圖書推薦:《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];
}
計算5.3 一個短循環嵌套 --- 當每次迭代只有少量運算時,循環開銷相對較高

  計算5.3中的循環每次迭代加載四個數組元素,執行三次浮點加法,存儲兩個值。循環開銷包括循環變量的遞增,測試它的值和跳到循環開始的分支。相比之下,計算5.4顯示了循環展開後,加載五個值,執行六次浮點加法,和存儲四個值,以相同的開銷。執行循環嵌套的總的開銷已被減半。數據複用也已提升。
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];
}
計算5.4 一個展開的訓話 --- 計算5.3中的循環已被展開,以減少循環開銷。我們假設迭代的次數可以被2整除

  在此例子中,循環體一次執行兩次迭代。這一數字被稱爲“展開因子”。合適的選擇取決於多種約束。較高的值會有較高的性能,但是也增加了所需寄存器的數目。現在,編程者很少需要手動做這一轉換,因爲編譯器非常擅長做這個。它們也非常擅長確定最優的展開因子。

  如果循環包含大量計算或者如果它包含過程調用,循環展開通常不是個好主意。前一種情況很可能意味着會使緩存使用較爲低效後一種引入新的開銷相比所節省的。如果在循環中有分支,收益也可能是低的。

  循環融合(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;
}
計算5.10:都訪問數組a的一對循環 --- 第二個循環重用a[i],但是當它被執行,這一元素所在緩存行可能不再在緩存中
for (int i=0; i<n; i++)
{
    a[i] = b[i] * 2;
    c[i] = a[i] + 2;
    x[i] = 2 * x[i];
}
計算5.11:循環融合的一個例子 --- 計算5.10的循環對已被組合,並且聲明被記錄。這允許數組a的值被立即重用

  循環分裂(Loop fission)是一種將一個循環打破分成幾個循環的轉換。有時,我們可能使用這種方式提高緩存的利用率或者孤立阻止循環完全優化的一部分。如果一個循環嵌套很大並且它的數據不能正好放入緩存,或者是我們可以對循環的一部分以不同的方式進行優化,這種技術是最有用的。
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];
}
計算5.12 緩存利用率佳並且內存訪問不好的循環 --- 如果我們可以分離數組c的更新,循環交換可以被使用來修復這個問題
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.13 循環分裂 --- 計算5.12的循環嵌套已被分成循環對,緊接着循環交換被使用到第二個循環來提升緩存利用率

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.21 減少屏障的數目 --- 在讀取向量a和c的值之前,所有這些向量的更新必須完成。一個屏障確保這點。

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 --*/
}

計算5.23 多個組合的並行化的工作共享循環 --- 每個並行化的循環增加並行化開銷,並且具有不能忽略的隱含屏障
#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.24 單個並行化區域包含所有工作共享的for循環 --- 並行化區域的開銷分攤到多個工作共享循環中

5.4.5 避免在內循環中的並行區域


  另一個提升性能的常用技術是移出最內部循環中的並行區域。否則,我們會一再體驗並行結構的開銷。例如,計算5.25中所示循環迭代,#pragma omp parallel for結構的開銷是n方次。

for (i=0; i<n; i++)
    for (j=0; j<n; j++)
        #pragma omp parallel for
        for (k=0; k<n; k++)
	    { .........}

計算5.25 並行區域嵌入到循環迭代中 --- 並行區域的開銷發生了n方次

  一種更爲高效的解決方案顯示在計算5.26中。#pragma omp parallel for結構被分裂它的構成指令,#pragma omp parallel已被移動來包括整個循環嵌套。#pragma omp for任然在最內層循環。取決於最內層循環的工作量,可以看到一個顯著地性能增益。
#pragma omp parallel 
for (i=0; i<n; i++)
    for (j=0; j<n; j++)
        #pragma omp for
        for (k=0; k<n; k++)
	    { .........}
計算5.26 並行區域移出循環迭代 --- 並行化結構開銷最小化。

5.4.6 解決貧窮的負載均衡


  在一些並行化算法中,線程有不同的工作量要做。在此警告,dynamic和guided負載分配調度具有更高的開銷,相比做static方案。如果負載均衡足夠嚴重,這個代價通過更爲靈活的分配到線程的工作抵消掉。實驗這些方案是個好主意,同時包括塊大小的多種值。




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