本章的目的主要是討論創建共享存儲並行程序所需的步驟,重點在於通過分析代碼來識別出可以並行的任務、確定變量的范圍、協調並行任務,以及向編譯器展現並行性。在最后,將學習基本的共享存儲並行編程技術。
知識點:
3.1並行編程的步驟
共享存儲並行編程:
3.2依賴分析 ⭐⭐⭐
目標是 發現是否有可以並行執行的代碼段 。
依賴分析需要處理的第一個問題是 如何確定代碼分析的粒度。
舉個栗子:
S1: x = 2;
S2: y = x;
S3: y = x + z;
S4: z = 6;
S1→TS2,因為x在S1中寫入並在S2中讀取。
S1→TS3,因為x在S1中寫入並在S3中讀取。
S3→AS4,因為z在S3中讀取並在S4中寫入。
S2→OS3,因為y在S2中寫入並在S3中也寫入。
反依賴和輸岀依賴也被稱為假依賴,因為后續指令並不依賴於先前指令產生的任何值。該依賴關系只是因為它們涉及相同的變量或存儲位置。因此,通過重命名變量實際上可以消除假依賴。
真依賴一般難以消除,因此它們是並行化的真正障礙。並行程序中消除假依賴的典型方法被稱為私有化。
(1)循環級依賴分析
括號“[]”內表示循環迭代空間。例如,迭代空間 [i,j]表示在外層循環上迭代i次並在內層循環上迭代j次的雙重嵌套循環。S[i,j]表示在特定迭代[i,j]中執行的語句S。如果將整個循環體作為一個語句組,S[i,j]表示迭代[i,j]中的整個循環體。
① 循環傳遞依賴可以定義為一次迭代中的語句與另一次迭代中的語句之間存在的依賴關系。
② 循環獨立依賴為循環迭代內部語句之間存在的依賴關系。
舉個栗子:
for (i=l; i<n; i++)
{
S1: a[i] = a[i-1] + 1;
S2: b[i] = a[i];
}
for (i=l; i<n; i++)
for (j=l; j< n; j++)
S3: a[i][j] = a[i][j-1] + 1;
for (i=l; i<n; i++)
for (j=l; j< n; j++)
S4: a [i][ j] = a [i-1][ j] + 1;
第一個循環(S1)中的第一條語句,讀取a[i-1]並對a[i]寫入,這意味着寫入a[i]的值會在下一次迭代中被讀取(第i+1次迭代)。因此,有循環傳遞依賴S1[i]→TS1[i+1]。 例如,在迭代i=4時,語句S1寫入a[4]並讀取a[3], a[3]的值是在迭代i=3時寫入的。
除此之外,在同一次迭代中寫入a[i]的值被S2語句讀取,因此有S1[i]→T S2[i]循環獨立依賴 。
在第二層循環中,a[i] [j]中寫入的值會在接下來的第j+1次迭代中讀取,因此有依賴S3[i, j]→TS3[i,j+1],其中對for j循環而言是循環傳遞依賴,而對for i循環而言是循環獨立依賴。
在第三層循環中,a[i] [j]中寫入的值會在接下來的第i+1次迭代中讀取,因此這里有S4[i, j]→TS4[i+1, j]依賴,其中對for i循環而言是循環傳遞依賴,而對for j循環而言是循環獨立依賴。
總體來說,依賴關系如下:
S1[i]→TS1[i+ 1]
S1[i]→TS2[i]
S3[i,j]→TS3[i,j+1]
S4[i,j]→TS4[i+1,j]
(2)迭代空間遍歷圖ITG和循環傳遞依賴圖LDG
①ITG:以圖形方式展示了迭代空間中的遍歷順序。ITG不能顯示依賴性; 它只顯示循環迭代的訪問順序。
②LDG:以圖形方式展示了真/反/輸岀依賴,其中一個節點就是迭代空間中的一個點,而有向邊顯示依賴的方向。換句話說,LDG將最內層循環體中的所有語句視為一個語句組。由於一個節點代表一次迭代中的所有語句,所以LDG不顯示循環獨立依賴。本質上來說,LDG可以通過繪制每個迭代的依賴關系獲得。
快速記憶:
①ITG:訪問順序,看for循環
②LDG: just傳遞,no獨立
舉個栗子:
for (i=1; i<4; i++)
for (j = 1; j<4; j++)
S3: a[i][j] = a[i][j-1] + 1;
S3[i,j]→TS3[i,j+1] //for a
i是循環獨立,j是循環傳遞
for (i=0; i<n; i++)
{
for (j=n-2; j>=0; j--)
{
S2: a[i][j] = b[i][j] + c[i][j];
S3: b[i] [j] = a[i] [j+1] * d[i] [j];
}
}
S2[i,j]→TS3[i,j-1] //for a
S2[i,j]→AS3[i,j] //for b
i是循環獨立,j是循環傳遞
for (i=1; i<=n; i++)
for (j=l; j<=n; j++)
S1: a[i][j] = a[i][j-1] + a[i][j+1] + a[i-1][j] + a[i+1][j];
S1[i,j]→TS1[i,j+1]
S1[i,j]→TS1[i+1,j]
此處,反依賴的理解:
語句a[i] [j+1]部分再迭代[i,j]中讀取的值還未被寫入並將在迭代[i,j+1]中被寫入。
同理,語句a[i+1] [j]部分在迭代[i,j]中讀取的值將在迭代[i+1,j]中被寫入。
S1[i,j]→AS1[i,j+1]
S1[i,j]→AS1[i+1,j]
i,j都是循環傳遞
3.3識別循環結構中的並行任務
(1)循環迭代間的並行和DOALL並行
分析哪些循環迭代可以被並行執行是識別並行的最有效的方法之一。為了做到這一點,首先要分析循環傳遞依賴。第一個原則是必須遵守依賴關系,特別是真依賴。反依賴和輸出依賴可以通過私有化移除。暫時假定必須遵守所有的依賴關系。 在LDG中可以通過觀察連接代表迭代的兩個節點的邊直觀地看出兩個迭代之間的依賴關系。迭代之間的依賴關系也可以被看作連接兩個節點的路徑(一組邊)。只有當兩個節點之間沒有連接邊或路徑時,才可以說這兩個節點之間沒有依賴。彼此之間沒有依賴的迭代可以被並行執行。
舉個栗子:
for(i=2; i<=n; i++)
S: a[i] = a[i-2];
S[i]→AS[i+2]
從LDG中可以看到,奇數迭代沒有指向偶數迭代的邊,偶數迭代也沒有指向奇數迭代的邊。因此,這里可以提取兩個並行任務:一個執行奇數迭代,另一個執行偶數迭代。為了實現這一點,可以將循環分成兩個較小的循環。這兩個循環現在可以相互並行執行,盡管每個循環內仍然需要順序執行。
此時,將上述代碼的原循環拆分得到的新循環:
for(i=2; i<=n; i+=2)
S: a[i] = a[i-2];
for(i=3; i<=n; i+=2)
S: a[i] = a[i-2];
還可以驚奇的發現:
對角線方向也可以沒有依賴關系。
不幸的是,為編譯器指定這樣的並行任務並不容易。例如,OpenMP並行指令只允許為特定的循環指定DOALL並行,但不允許指定循環嵌套中兩個循環之間的反對角線並行。解決上述缺陷的一個方法是重構代碼,即一個循環遍歷反對角線,而另一個內層循環遍歷一個反對角線的節點。然后可以為內層循環指定DOALL並行。重構偽代碼如下:
計算反對角線的數量
對每條反對角線
{
計算當前反對角線,上點的數量
對當前反對角線上的每個點
計算矩陣中的當前點
}
(2)DOACROSS:循環迭代間的同步並行
DOALL並行很簡單,因為它所應用的循環中所有迭代都是可並行任務。通常,DOALL並行循環中並行任務的數量非常大,因此在識別其他類型的並行之前 應該先嘗試識別DOALL並行。然而,在一些循環中,由於循環迭代中的循環傳遞依賴,導致DOALL並行不可行。在這種情況下如何提取並行性?
此時,引入DOACROSS並行。對於即使存在傳遞依賴的循環,DOACROSS並行也可以提取並行任務。
舉個栗子:
for (i=l; i<=N; i++)
S: a[i] = a[i-1] + b[i] * c[i];
S[i]→TS[i+1]
i是循環傳遞
很明顯,此時沒有DOALL並行性。但b[i]與c[i]相乘的語句沒有循環傳遞依賴,這就帶來了並行的機會。
有兩種方法可利用這個機會:
①將循環拆分成兩個循環
第一個循環只執行沒有循環傳遞依賴的語句部分,而第二個循環只執行有循環傳遞依賴的語句部分。
for(i=1;i<=N;i++) //該循環具有DOALL並行
S1: temp[i] = b[i] * c[i];
for(i=1;i<=N;i++) //該循環沒有
S2: a[i] = a[i-1] + temp[i];
②在具有部分循環傳遞依賴的循環中提取並行任務的解決方案是采用DOACROSS 並行性
其中每個迭代仍然是並行任務(類似於DOALL),但插入了同步以確保使用者迭代 (consumer iteration)只讀取產生者迭代(producer iteration)產生的數據。
post (0);
for (i=l; i<=N; i++)
{
S1: temp = b[i] * c [i];
wait (i-1);
S2: a[i] = a[i-1] + temp;
post (i);
}
(3)循環中語句間的並行
當一個循環具有循環傳遞依賴時,另一種並行化的方法是將一個循環分發(distribute) 到幾個循環中,這些循環執行來自原始循環體的不同語句。
舉個栗子:
for (i=0; i<n; i++)
{
S1: a[i] = b[i+1] * a[i-1];
S2: b[i] = b[i] * coef;
S3: c[i] = 0.5 * (c[i] + a[i]);
S4: d[i] = d[i-1] * d[i];
}
函數並行:每個並行任務在不同數據集上執行不同的計算。
(4)DOPIPE:循環中語句間的流水線並行
3.4識別其他層面的並行
上述代碼是二進制遍歷的代碼,它以深度優先的搜索方式遍歷整個樹,並計算和存儲了與被搜索的數據相匹配的節點數目。依賴分析揭示了以下依賴:
由於對count的真依賴,有S1→TS2
由於對count的真依賴,有S1→TS3
由於對count的真依賴,有S1→TS4
由於對count的真依賴,有S2→T3
由於對count的真依賴,有S2→TS4
由於對count的真依賴,有S3→TS4
新代碼中真依賴數量變少:
由於對count1的真依賴,有S1→TS2
由於對count2的真依賴,有S1→TS3
由於對count3的真依賴,有S1→TS4
3.5通過算法知識識別並行
分析算法可以帶來更多機會以提取並行任務。這是因為代碼結構中嵌入了不必要的串行,這是串行編程語言的產物。
舉個栗子,考慮一個算法來更新一個水粒子受到相鄰的4個水粒子的作用力:
主循環的計算算法是:
While未收斂到一個解do :
foreach 時間步 do:
foreach橫截面do一次掃描:
oreach橫截面中的點do: //主循環
計算與鄰居粒子的相互作用力
然后實際的主循環代碼引入了人為遍歷順序:
for(i=1; i<=N; i++)
{
for(j=1;j<=N; j++)
{
S1: temp = A[i] [j];
S2: A[i][j] = 0.2 * (A[i][j]+A[i][j-l]+A[i-l][j] +A[i][j+l]+A[i+l][j]);
S3: diff += abs(A[i][j]-temp);
}
}
分析代碼表明唯一的並行機會在反對角線上,因此必須重構代碼來利用這個並行機會。 然而,計算的基本算法事實上並沒有指定任何特定的順序,從而確定必須優先更新的橫截面的元素。該算法僅指定在一次掃描中,橫截面中的每個點必須通過考慮與其鄰居的交互來更新一次。
洋流仿真的紅黑分區:
//帶有DOALL並行的外部和內部循環的黑色掃描
for (i=1; i<=N; i++)
{
offset = (i+1) % 2;
for (j=1+offset; j<=N; j+=2)
{
S1: temp = A[i][j];
S2: A[i][j] = 0.2 * (A[i][j]+A[i][j-1]+A[i-1][j]+A[i][j+1]+A[i+1][j]);
S3: diff += abs(A[i][j] - temp);}
//帶有DOALL並行的外部和內部循環的紅色掃描
for (i=1; i<=N; i++)
{
offset = i % 2;
for (j=1+offset; j<=N; j+=2)
{
S1: temp = A[i] [ j];
S2: A[i][j] = 0.2 * (A[i][j]+A[i][j-1]+A[i-1][j]+A[i][j+1]+A[i+1][j]);
S3: diff += abs (A[i][j] - temp);
}
}
3.6確定變量的范圍
①第一步:通過代碼分析或算法分析確定並行任務后,就可以並行執行這些並行任務。
通常情況下,並行任務數量多於可用處理器的數量,因此多個任務在分配給線程執行之前經常會合並為較大的任務。執行任務的線程數通常等於或小於可用處理器的數量。在本節中,假設處理器的數量無限,並且為每個任務分配不同的線程。
②第二步:變量分區,這一步確定變量應該具有線程私有作用域還是線程共享作用 域。
這一步是共享存儲編程特有的;在消息傳遞模型中,所有變量都是私有的,因為每個進程都有自己的地址空間。在這一步中,需要通過已經確定的並行任務來分析不同變量的使用,並將其分類到以下行為類別中:
- 只讀:變量只由所有任務讀取。
- 讀/寫非沖突:變量只由一個任務讀取、寫入或既讀取又寫入;如果變量是矩陣,則其中不同的元素被不同的任務讀取/寫入。
- 讀/寫沖突:如果任務並行執行,由一個任務寫入的變量可能由不同的任務讀取。
讀/寫沖突變量阻礙並行,因為它引入了線程之間的依賴。因此,這里需要相關的技術來消除這種依賴。
①一種技術就是私有化,私有化為每個讀/寫沖突變量創建單線程副本,以便每個線程可以單獨工作在自己的副本上。
②另一種技術是歸約(reduction),歸約為每個讀/寫沖突變量創建單線程副本,使得每個線程能夠在自己的副本中產生部分結果,並且在並行部分的結尾處,所有的部分結果合並成全局結果。
3.7同步
在共享存儲模型中,程序員通過同步機制來控制並行線程執行的操作序列。注意同步在線程間而不是任務間執行。所以,在這一步假設任務已經分配給了線程。
① 第一種是兩個並行任務的點對點同步,如描述DOACROSS和DOPIPE並行時用到的提交和等待。
②第二種流行的同步是鎖。一個鎖只能由一個並行線程獲得,一旦該線程持有該鎖,其他線程將無法獲得它,直到當前線程釋放該鎖。獲取鎖(lock(name))和釋放鎖(unlock(name))是在鎖上執行的兩個操作。因此,本質上講鎖需要保證排他性。
如果一個代碼區被一個鎖保護,那么可以創建一個臨界區,臨界區是一個在任何時刻都只允許最多一個線程執行的代碼區。臨界區對於確保一次只有一個線程訪問不可被私有化或歸約的讀/寫沖突變量是有用的。如果一個數據結構受到鎖的保護,則一次只能被一個線程訪問。
③第三種流行的同步是柵障。柵障定義了一個點,只有在所有線程都到達該點時才允許線程通過。
如上圖所示,四個線程在不同時間到達柵障點,線程1、3和4必須在柵障內等待直到最后一個線程(線程2)到達。只有這時它們才能執行柵障后的代碼。這個例子說明柵障簡單易用,它使並行執行的總執行時間取決於最慢線程的執行時間。因此,當使用柵障時,負載均衡是非常重要的。柵障實現的效率也是設計並行計算機的關鍵目標。
3.8任務到線程的映射
任務映射涉及兩個方面:
①如何將任務映射到線程
通常任務比線程更多,這帶來了兩個問題:哪些任務應該分配給同一個線程,以及如何分配?其中需要解決的問題包括任務管理開銷(較大的任務會帶來較低的開銷)、負載均衡(較大的任務可能會減少負載均衡)以及數據局部性。
②如何將線程映射到處理器,以確保通信的處理器盡可能相互靠近
任務映射的一個考量是靜態還是動態地將任務分配給線程。靜態任務映射意味着任務在執行之前預先分配給線程。動態任務映射意味着任務在執行之前不會分配給線程。動態任務映射給任務隊列管理帶來了額外的開銷,但有時更容易確保所有線程的負載均衡。動態任務映射往往會增加通信量並減少局部性,因為在編譯時不知道數據將由哪個線程使用,因此很難將該數據放置到將要使用它的線程中。最后,也可以采用混合映射,其中映射大部分是靜態的,但周期性地評測負載均衡情況,然后相應地調整映射。
負載均衡和任務開銷並不是任務映射中唯一重要的因素,通信成本也是一個重要因素。
通信開銷分為兩種:來自任務映射對算法影響的固有通信和來自任務映射對數據布局方式和架構影響的人為通信。
評估固有通信的一個有用指標是通信-計算比率(CCR)。用線程的通信量除以該線程的計算量。參數是處理器的數量和輸入規模。見P63
3.9線程到處理器的映射
解決這個問題的一個簡單方法就是什么都不做,即讓操作系統線程調度器去決定。
操作系統線程調度器決定何時就緒線程應該運行,以及就緒線程應該運行在哪些處理器上。操作系統將響應時間、公平性、線程優先級、處理器的利用率以及上下文切換的開銷考慮在內。
3.10OpenMP概述
OpenMP (開放式多處理)是支持共享存儲編程的應用編程接口(API)。
課堂習題:
習題1
注意第二個for循環處:j<=i
答案處的循環傳遞依賴,筆者一般習慣寫為:S1[i,j]→TS1[i,j+1] //for a (沒有本質區別)
(3.4) //for a
(3.5) //for b
(3.6) //for c
習題2
循環傳遞依賴:
S1[i,j]→TS1[i,j+1] //for a
S1[i,j]→TS1[i,j+2] //for a
(3.11) //for a
(3.12) //for a
習題3
要想並行則不能存在依賴關系,需要進行肉眼判斷,顯然S4與其他之間不存在依賴關系
DOACROSS代碼,signal負責發信號給S2和S3
DOPIPE代碼較好記憶,one by one的方式,主要是signal(i,j)和wait(i,j)一一對應。特別的,signal位於主代碼后,wait位於主代碼前。
習題4
共享越多,通信量越大
課后習題:
重點題目:2,3,4,5,6
習題1
(a)
(b)
#progma omp parallel shared(A,n) private(i) section
{
#progma omp section
for(i=4;i<=n;i+=4)
A[i]=A[i]+A[i-4];
#progma omp section
for(i=5;i<=n;i+=4)
A[i]=A[i]+A[i-4];
#progma omp section
for(i=6;i<=n;i+=4)
A[i]=A[i]+A[i-4];
#progma omp section
for(i=7;i<=n;i+=4)
A[i]=A[i]+A[i-4];
}
習題2
習慣性解決(b),解決后方便畫圖。
(b)
循環傳遞依賴:
S1[i, j]→T S2[i+1, j+1] //for a
循環獨立依賴:
S1[i, j]→T S3[i, j] //for a
S1[i, j]→A S2[i, j] //for b
S1[i, j]→A S3[i,j] //for c
(a),(c)
注意內層for循環:j<=i
習題3
習慣性解決(b),解決后方便畫圖。
(b)
循環傳遞依賴:
S1[i, j]→T S2[i+1, j+1] //for a
S3[i-1, j]→T S1[i, j] //for c
S2[i, j]→A S2[i+1, j-1] //for b
S3[i-1, j]→T S2[i+1, j] //for c
循環獨立依賴:
S1[i, j]→T S3[i, j] //for a
S1[i, j]→A S2[i, j] //for b
(a),(c)
注意內層for循環:j<=i
習題4
找出所有的依賴:
循環傳遞依賴:
S[i, j]→T S2[i, j+2] //for a
S[i, j]→A S[i+2, j] //for a
ITG:
LDG:
習題5
(a)
signal(1);
for(i=2;i<=N;i++)
{
wait(i-1);
S1: a[i]=a[i-1]+b[i-2];
S2: b[i]=b[i]+1;
signal(i);
}
(b)
//thread 1
for(i=2;i<=N;i++)
{
S1: a[i]=a[i-1]+b[i-2];
signal(i);
}
//thread 2
for(i=2;i<=N;i++)
{
wait(i-2);
S2: b[i]=b[i]+1;
}
習題6
for(i=0;i<=N;i++)
{
k = C[N-1-i];
for(j=0;j<=N;j++)
{
A[i][j] = k*A[i][j]*B[i/2][j];
}
}
僅並行化for i
變量 | 性質 |
---|---|
i | 私有變量 |
k | 私有變量 |
j | 私有變量 |
C | 共享變量 |
B | 共享變量 |
A | 共享變量 |
僅並行化for j
變量 | 性質 |
---|---|
i | 共享變量 |
k | 共享變量 |
j | 私有變量 |
C | 共享變量 |
B | 共享變量 |
A | 共享變量 |
習題7
(a)僅函數並行
#progma omp parallel shared(x,y,rx,ry) private(j,i) sections
{
#progma omp section
for (j=2;j<n;j++)
{
for(i=2;i<n;i++)
{
x[i][j]=x[i][j]+rx[i][j];
}
}
#progma omp section
for (j=2;j<n;j++)
{
for(i=2;i<n;i++)
{
y[i][j]=y[i][j]+ry[i][j];
}
}
}
(b)僅數據並行
#progma omp parallel for shared(x,y,rx,ry) private(j,i)
{
for (j=2;j<n;j++)
{
for(i=2;i<n;i++)
{
x[i][j]=x[i][j]+rx[i][j];
y[i][j]=x[i][j]+ry[i][j];
}
}
}
(c)函數和數據並行
#progma omp parallel shared(x,y,rx,ry) private(j,i) sections
{
#progma omp section
#progma omp parallel for
for (j=2;j<n;j++)
{
for(i=2;i<n;i++)
{
x[i][j]=x[i][j]+rx[i][j];
}
}
#progma omp section
#progma omp parallel for
for (j=2;j<n;j++)
{
for(i=2;i<n;i++)
{
y[i][j]=y[i][j]+ry[i][j];
}
}
}
習題9
#pragma omp parallel for reduction(*: y) default(shared) private(i)
for (i=0;i<n;i++)
if (x>1 || y>1) y=y*exp(x,A[i]);
print y;