收斂操作語義¶
概述¶
某些平行執行環境以群組方式執行執行緒,這些群組允許使用稱為收斂操作的特殊原語在群組內進行有效率的通訊。收斂操作的結果對於「一起」執行它的執行緒集合(即,收斂地)是敏感的。當控制流發散時,即,同一群組的執行緒遵循不同的 CFG 路徑時,並非群組的所有執行緒都可用於參與此通訊。這是區分收斂操作與其他執行緒間通訊的定義性特徵
收斂操作涉及在記憶體模型之外發生的執行緒間通訊或同步,其中參與通訊的執行緒集合隱含地受到控制流的影響。
例如,在以下 GPU 計算核心中,預期收斂操作期間的通訊精確地發生在實作定義的執行範圍(例如工作群組或子群組)中,其中 condition
為 true 的那些執行緒之間
void example_kernel() {
...
if (condition)
convergent_operation();
...
}
在結構化程式語言中,通常有一種直觀且明確的方式來確定預期通訊的執行緒。然而,即使在結構化程式語言中,情況也並非總是如此,並且直覺在非結構化控制流中完全崩潰。本文檔描述了 LLVM 中的形式語義,即如何確定收斂操作的通訊執行緒集合。
本文檔中的定義留下了許多開放的細節,例如執行緒群組最初是如何形成的。它著重於與決定通用程式轉換和收斂相關分析(例如一致性分析)正確性相關的問題。
收斂操作¶
在 LLVM IR 中,上述執行緒之間通訊的唯一方法是呼叫目標定義的收斂內建函數。因此,只有 LLVM IR 中的呼叫點(call、invoke 或 callbr 指令)可能會導致收斂操作。
如果 LLVM IR 中的函數具有 convergent 屬性,則稱該函數為收斂。
如果 LLVM IR 中的呼叫點是直接呼叫收斂函數,或者它具有 convergent 屬性或 convergencectrl 運算元捆綁包,則稱該呼叫點為收斂。
資訊性註解
如果函數本身或從該函數遞迴呼叫的任何函數包含收斂呼叫點,則該函數可能必須被視為收斂函數。產生
convergent
屬性的前端在發出函數和函數呼叫時應考慮到這一點。但情況並非總是如此非收斂函數可能包含收斂操作;此類操作並不直接取決於作為單個通訊群組進入函數的執行緒集合。相反,這些操作取決於函數體內實作定義的執行緒子集,如機會性收斂操作所示。
收斂操作範例¶
(本節為資訊性內容。)
像素著色器中的紋理採樣¶
以下程式化的像素著色器使用內建函數 textureSample 在給定座標集處採樣紋理。紋理採樣需要座標的螢幕空間導數,以確定採樣的細節層次 (mipmap level)。它們通常透過取相鄰像素之間的差異來近似,這些相鄰像素由同一群組中的不同執行緒計算
void example_shader() {
...
color = textureSample(texture, coordinates);
if (condition) {
use(color);
}
...
}
從純粹的單執行緒角度來看,將 textureSample 下沉到 if 語句中似乎是合法的。然而,如果條件對於某些相鄰像素為 false,則它們對應的執行緒將不會在群組中一起執行,從而無法將座標的差異作為螢幕空間導數的近似值。實際上,結果將是一個未定義的值。
也就是說,textureSample 操作符合我們對收斂操作的定義
它與一組隱含地依賴於控制流的執行緒進行通訊。
正確性取決於這組執行緒。
編譯器前端可以發出 IR,以如下方式表達收斂約束
define void @example_shader() convergent {
%entry = call token @llvm.experimental.convergence.entry()
...
%color = call T @textureSample(U %texture, V %coordinates) [ "convergencectrl"(token %entry) ]
br i1 %condition, label %then, label %end
then:
call void @use(T %color)
br label %end
end:
ret void
}
llvm.experimental.convergence.entry 內建函數本身是 convergent
,並且我們期望它至少在同一「四像素」(quad)的所有執行緒之間進行通訊 – 一組 2x2 像素,它們為了近似螢幕空間導數的目的而一起評估。這個事實並不是通用 LLVM IR 語義的一部分;它必須在其他地方定義,例如作為目標特定 ABI 定義的一部分和/或參考一些相關的 API 規範。
由於 @textureSample
呼叫接著在其 convergencectrl
捆綁包中使用由 entry 內建函數產生的 token,並且沒有額外的控制依賴性,因此它必須在同一組執行緒之間進行通訊。這向通用程式轉換表明,禁止下沉 @textureSample
呼叫。(如果程式轉換可以透過某種方式證明,例如透過依賴可以利用額外知識分析程式的目標特定回呼,%condition
在 收斂 token %entry
引用的執行緒之間始終是一致的,則程式轉換仍然可以下沉呼叫。)
發散控制流內的歸約¶
以下範例顯示,面對收斂操作時,合併分支的共同程式碼可能是錯誤的
void example_kernel() {
delta = ...
if (delta > 0) {
total_gains = subgroupAdd(delta);
...
} else {
total_losses = subgroupAdd(delta);
...
}
}
計算 total_gains
的 subgroupAdd
將由子群組(wave)中具有正 delta
的執行緒子集執行,因此將總結這些執行緒的所有 delta
值;對於計算 total_losses
的 subgroupAdd
也是如此。
如果我們將 subgroupAdd
提升並合併到 if 語句之上,它將總結所有執行緒的 delta
。
編譯器前端可以發出 IR,以如下方式表達收斂約束
define void @example_kernel() convergent {
%entry = call token @llvm.experimental.convergence.entry()
%delta = ...
%cc = icmp sgt i32 %delta, 0
br i1 %cc, label %then, label %else
then:
%total_gains = call i32 @subgroupAdd(i32 %delta) [ "convergencectrl"(token %entry) ]
...
br label %end
else:
%total_losses = call i32 @subgroupAdd(i32 %delta) [ "convergencectrl"(token %entry) ]
...
br label %end
end:
...
}
entry 內建函數的行為與先前的範例類似:假設 @example_kernel
是一個 OpenCL 核心(如「子群組」術語所暗示的那樣),我們期望它在「子群組」內的所有執行緒之間進行通訊。這通常對應於 GPU 硬體上的 SIMD 向量。
對 @subgroupAdd
的呼叫使用由 entry 內建函數產生的 token,但它們也具有額外的控制依賴性。根據本文檔中定義的規則,它們僅在實際最終執行各自(靜態)呼叫點的執行緒子集之間進行通訊。
提升它們將消除控制依賴性,並導致它們在 entry 內建函數通訊的完整執行緒集合之間進行通訊。同樣,如果可以證明 %cc
在相關的執行緒集合中始終是一致的,則允許提升:在這種情況下,@subgroupAdd
已經在原始程式中的完整執行緒集合之間進行通訊。
收斂控制的動機範例¶
(本節為資訊性內容。)
非結構化控制流¶
考慮一個範例,說明跳躍執行緒如何以使其語義變得不明顯的方式消除結構,而沒有本文檔中描述的收斂內建函數
void example_original() {
entry:
...
br i1 %cond1, label %then1, label %mid
then1:
...
%cond2 = ...
br label %mid
mid:
%flag = phi i1 [ true, %entry ], [ %cond2, %then1 ]
br i1 %flag, label %then2, label %end
then2:
...
call void @subgroupControlBarrier()
...
br label %end
end:
}
void example_jumpthreaded() {
entry:
...
br i1 %cond1, label %then1, label %then2
then1:
...
%cond2 = ...
br i1 %cond2, label %then2, label %end
then2:
...
call void @subgroupControlBarrier()
...
br label %end
end:
}
控制屏障是否保證在兩種情況下都在相同的執行緒集合之間同步?文獻中的不同實作可能會對這個問題給出不同的答案
在後支配節點處重新收斂的實作中,執行緒在第一個版本中的
mid
處重新收斂,以便所有執行控制屏障的執行緒(在子群組/wave 內)一起執行。在第二個版本中,透過不同路徑到達控制屏障的執行緒會單獨同步:第一個(也是唯一的)後支配節點是end
,因此執行緒在那之前不會重新收斂。對基本區塊進行拓撲排序並確保每個基本區塊最大程度重新收斂的實作在兩個版本中的行為方式相同。
我們通常採取在非循環控制流中重新收斂必須是最大的立場。編譯器前端可以增強原始碼如下
define void @example_original() convergent {
entry:
%entry = call token @llvm.experimental.convergence.entry()
...
br i1 %cond1, label %then1, label %mid
then1:
...
%cond2 = ...
br label %mid
mid:
%flag = phi i1 [ true, %entry ], [ %cond2, %then1 ]
br i1 %flag, label %then2, label %end
then2:
...
call void @subgroupControlBarrier() [ "convergencectrl"(token %entry) ]
...
br label %end
end:
}
如果 S 是 entry 內建函數通訊的執行緒集合,則 @subgroupControlBarrier
呼叫與實際到達呼叫點的 S 子集進行通訊。這個執行緒集合在跳躍執行緒之後不會改變,因此上述問題的答案保持不變。
機會性收斂操作¶
某些程式具有程式碼的局部區域,其中包含一系列收斂操作,程式碼不關心執行它的確切執行緒集合,而只關心執行緒集合對於序列中的所有操作都是相同的。(如果序列中收斂操作的子集具有額外的、非一致的控制依賴性,則這是不可能的。但是,程式碼可能仍然需要執行緒集合在邏輯上與這些控制依賴性的條件一致。)在這種情況下,可以使用 llvm.experimental.convergence.anchor 來表達所需的語義。
以下範例函數可能是假設的「附加緩衝區」實作的一部分,其中執行緒有條件地將固定大小的記錄連續寫入全域緩衝區。函數 @reserveSpaceInBuffer
傳回呼叫執行緒應在其儲存資料的緩衝區索引。
這可以透過在每個執行緒中使用簡單的原子操作來增加分配計數器來實現。
然而,以下實作在某些硬體上可能更高效能,因為它僅對整個執行緒群組使用單個原子操作。為此,它首先確定群組的總大小,這將是原子操作的運算元,然後稍後將原子操作的結果廣播到群組的所有執行緒,以便每個執行緒都可以計算其在緩衝區中的個別位置
define i32 @reserveSpaceInBuffer() { ; NOTE: _not_ a convergent function!
entry:
%anchor = call token @llvm.experimental.convergence.anchor()
%ballot = call i64 @subgroupBallot(i1 true) [ "convergencectrl"(token %anchor) ]
%numThreads.p = call i64 @llvm.ctpop.i64(i64 %ballot)
%numThreads = trunc i64 %numThreads.p to i32
%absoluteThreadIdx = call i32 @getSubgroupLocalInvocationId()
%absoluteThreadIdx.ext = zext i32 %absoluteThreadIdx to i64
%mask.p = shl i64 1, %absoluteThreadIdx.ext
%mask = sub i64 %mask.p, 1
%maskedBallot = and i64 %ballot, %mask
%relativeThreadIdx.p = call i64 @llvm.ctpop.i64(i64 %maskedBallot)
%relativeThreadIdx = trunc i64 %relativeThreadIdx.p to i32
%isFirstThread = icmp eq i32 %relativeThreadIdx, 0
br i1 %isFirstThread, label %then, label %end
then:
%baseOffset.1 = atomicrmw add ptr @bufferAllocationCount, i32 %numThreads monotonic
br label %end
end:
%baseOffset.2 = phi i32 [ undef, %entry ], [ %baseOffset.1, %then ]
%baseOffset = call i32 @subgroupBroadcastFirst(i32 %baseOffset.2) [ "convergencectrl"(token %anchor) ]
%offset = add i32 %baseOffset, %relativeThreadIdx
ret i32 %offset
}
這裡的關鍵是該函數實際上並不關心它被呼叫的執行緒集合。它接受它可以獲得的任何執行緒集合。函數的實作關心的是,初始 @subgroupBallot
– 用於檢索與 anchor 一起執行的執行緒的位元遮罩 – 與最終 @subgroupBroadcastFirst
使用相同的執行緒集合執行。就收斂而言,正確性不需要其他任何東西。
函數 @reserveSpaceInBuffer
本身_不是_ convergent
:呼叫者可以隨意移動函數的呼叫點。這實際上可能會改變行為,透過改變為原子操作分組在一起的執行緒集合。這可以在程式的輸出中看到,因為輸出在緩衝區中出現的順序發生了變化。然而,這並沒有破壞 @reserveSpaceInBuffer
與其呼叫者的整體契約 – 這是合理的:輸出的順序無論如何都是非確定性的,因為涉及到原子操作。
如果函數是內聯的,則 anchor 內建函數的使用類似地表明,通常由於收斂操作的存在而被禁止的某些轉換實際上是允許的,只要它們不破壞由 anchor 控制的程式碼區域即可。
擴展循環:從迴圈發散退出¶
高階語言通常提供 break
語句,將控制權轉移出迴圈語句。在大多數情況下,迴圈是結構化的,因此迴圈內的收斂沒有歧義。但是,當 break
控制依賴於迴圈內發散條件時,就會出現歧義。考慮以下範例
void example() {
// A
...
for (...) {
// B
if (condition) { // divergent condition
// C
convergent_op();
break;
}
// D
...
}
// E
}
在這個程式中,對 convergent_op() 的呼叫在詞法上「在」for
迴圈內。但是,當翻譯成 LLVM IR 時,基本區塊 B 是一個以發散分支結尾的退出區塊,而基本區塊 C 是迴圈的出口。因此,對 convergent_op() 的呼叫在迴圈之外。這導致程式設計師的期望與編譯後的程式之間不匹配。對於迴圈的每次迭代,呼叫都應由一起分支退出迴圈的執行緒收斂地執行。但是,當編譯時,在不同迭代中採取發散退出的所有執行緒首先在基本區塊 C 的開頭處收斂,然後一起執行對 convergent_op() 的呼叫。
在這種情況下,可以使用 llvm.experimental.convergence.loop 來表達所需的語義。對此內建函數的呼叫放置在迴圈標頭中,它追蹤迴圈的每次迭代。由此產生的 token 用作收斂呼叫的 convergencectrl
運算元。loop
內建函數的語義確保收斂呼叫僅由在給定迭代中收斂地退出迴圈的那些執行緒收斂地執行。
define void @example() convergent {
%entry = call token @llvm.experimental.convergence.entry()
br label %for
for:
%inner = call token @llvm.experimental.convergence.loop() ["convergencectrl"(token %entry)]
%for.cond = i1 ...
br i1 %for.cond, label %B, label %E
B:
...
%condition = i1 ...
br i1 %condition, label %C, label %D
C:
call void @convergent_op() ["convergencectrl"(token %inner)]
br label %E
D:
...
br label %for
E:
...
ret void
}
相同程式的 LLVM IR 版本顯示一個由基本區塊 %for
、%B
和 %D
組成的循環,而 %C
是由退出區塊 %B
結尾處的發散分支到達的出口。但是,收斂控制 token 的使用清楚地表明,區塊 %C
必須僅由那些從 %B 到 %C
收斂地採取出口邊緣的執行緒收斂地執行。換句話說,%C
的收斂執行由循環內對 llvm.experimental.convergence.loop 內建函數的呼叫控制。循環有效地擴展為包括所有位於循環外部的 token 用途。
動態實例和收斂 Token¶
LLVM IR 指令的每次執行都發生在指令的動態實例中。動態實例是我們談論收斂操作中通訊執行緒的形式物件。動態實例是為 LLVM 程式中的所有操作定義的,無論是否收斂。收斂控制主要關於收斂操作的動態實例,因為它們透過執行緒間通訊影響程式的執行。非收斂操作的動態實例與確定值的一致性相關。
不同執行緒執行相同收斂操作產生的動態實例可能是收斂的。當執行收斂操作時,執行收斂動態實例的執行緒集合是彼此通訊的執行緒集合。收斂 token 捕獲了如下所述的收斂。
收斂 token 是 token
類型的值,即它們不能在 phi
或 select
指令中使用。收斂 token 值表示產生它的指令的動態實例。
收斂操作可能具有可選的 convergencectrl
運算元捆綁包,其中包含收斂 token 運算元,以定義相對於定義 token 的操作的通訊執行緒集合。
令
U
為收斂操作,而不是對收斂控制內建函數的呼叫,並且D
為定義用作U
的convergencectrl
運算元的 token 值的收斂操作。當且僅當兩個執行緒中的 token 值都是由D
的收斂動態實例傳回時,兩個執行緒才執行U
的收斂動態實例。
注意
文字將收斂 token 值定義為表示動態實例。但是,如果我們假設收斂動態實例產生相同的 token 值,那麼我們幾乎可以將 token 值視為表示一組執行緒 – 具體來說,是執行 D
的定義指令的收斂動態實例的執行緒集合 S
。
在這個直觀的圖片中,當指令 I
的 convergencectrl
捆綁包使用收斂 token 值 T
時,I
中通訊的執行緒集合是 token 值表示的集合 S
的子集。具體來說,它是最終在執行 I
的同時使用 token 值的執行緒子集。
就其本身而言,這並不能完全作為定義:如果同一個執行緒多次執行 I
怎麼辦?執行緒 1 中 I
的哪個執行與執行緒 2 中 I
的哪個執行通訊?只要 D
和 I
處於相同的迴圈(或循環)巢狀層次,依賴動態實例的概念就可以為這個問題提供穩健的答案。
D
和 I
處於不同迴圈巢狀層次的情況被 靜態規則 禁止 – 處理這種情況是 llvm.experimental.convergence.loop 的目的。
收斂控制內建函數¶
本節描述了可用於產生收斂 token 的目標獨立內建函數。
如果間接呼叫收斂控制內建函數,則行為未定義。
llvm.experimental.convergence.entry
¶
token @llvm.experimental.convergence.entry() convergent readnone
此內建函數用於將函數內部的動態實例與呼叫者中的動態實例聯繫起來。
如果從 LLVM 範圍之外呼叫函數,則此內建函數的動態實例的收斂是環境定義的。例如
在 OpenCL 核心啟動中,可以在記憶體模型之外通訊的最大執行緒集合是工作群組。因此,一個合適的選擇是指定來自 OpenCL 中單個工作群組的所有執行緒都執行此內建函數的收斂動態實例。
在 C/C++ 程式中,執行緒是獨立啟動的,它們只能透過記憶體模型進行通訊。因此,C/C++ 程式中此內建函數的動態實例永遠不會收斂。
如果從 LLVM IR 中的呼叫點呼叫函數,則當且僅當兩個執行緒都透過執行呼叫點的收斂動態實例進入函數時,兩個執行緒才執行此內建函數的收斂動態實例。
此內建函數在一個函數中最多只能出現一次,並且只能在函數的進入區塊中出現。如果此內建函數出現在基本區塊中,則它必須先於同一個基本區塊中的任何其他收斂操作。
如果此內建函數出現在非收斂函數中,則會發生錯誤。
在對此內建函數的呼叫中指定 convergencectrl
運算元捆綁包是錯誤的。
函數內聯將此內建函數替換為來自運算元捆綁包的 token。例如
// Before inlining:
void callee() convergent {
%tok = call token @llvm.experimental.convergence.entry()
convergent_operation(...) [ "convergencectrl"(token %tok) ]
}
void main() {
%outer = call token @llvm.experimental.convergence.anchor()
for (...) {
%inner = call token @llvm.experimental.convergence.loop() [ "convergencectrl"(token %outer) ]
callee() [ "convergencectrl"(token %inner) ]
}
}
// After inlining:
void main() {
%outer = call token @llvm.experimental.convergence.anchor()
for (...) {
%inner = call token @llvm.experimental.convergence.loop() [ "convergencectrl"(token %outer) ]
convergent_operation(...) [ "convergencectrl"(token %inner) ]
}
}
llvm.experimental.convergence.loop
¶
token @llvm.experimental.convergence.loop() [ "convergencectrl"(token) ] convergent readnone
此內建函數表示一個虛擬計數器遞增的位置,用於確定控制流循環內的收斂。
令 U
為對此內建函數的呼叫,並且 D
為定義用作 U
的 convergencectrl
運算元的 token 值的收斂操作。當且僅當
兩個執行緒中的 token 值都是由
D
的收斂動態實例傳回的,並且,存在一個整數 n,使得兩個執行緒都使用該 token 值第 n 次執行
U
。
在對此內建函數的呼叫中省略 convergencectrl
運算元捆綁包是錯誤的。
如果此內建函數出現在基本區塊中,則它必須先於同一個基本區塊中的任何其他收斂操作。
循環的核心
如果循環
C
包含此內建函數的出現H
,其 token 運算元在C
外部定義,則H
稱為C
的核心。注意
循環的靜態規則暗示核心只能出現在自然迴圈的標頭中。這確保核心密切代表迴圈迭代的直觀概念。如果放寬此限制,則產生的語義為即使是不可約循環也提供了新的「循環迭代」概念。但是,這允許自然迴圈在其標頭以外的節點中具有核心,這對收斂方面的迴圈迭代含義產生了有趣的後果。目前,我們不允許這種情況,因為其實際應用非常罕見。
llvm.experimental.convergence.anchor
¶
token @llvm.experimental.convergence.anchor() convergent readnone
此內建函數產生一個獨立於任何「外部範圍」的初始收斂 token。執行此內建函數的收斂動態實例的執行緒集合是實作定義的。
在對此內建函數的呼叫中傳遞 convergencectrl
運算元捆綁包是錯誤的。
注意
預期在群組內「恰好同時處於活躍狀態」的所有執行緒都將執行收斂的動態實例,以便程式可以偵測出在程式的某些局部區域內可以有效率地溝通的最大執行緒集合。
不受控的收斂操作¶
具有顯式 convergencectrl
運算元組的收斂操作稱為受控的收斂操作。所有其他收斂操作都稱為不受控的。
不受控的收斂操作被認為具有由 convergent
屬性單獨決定的隱含收斂控制。convergent
屬性在 LLVM 中的實作語意與文件記載的語意不同。此實作嘗試遵循關於收斂操作的常見直覺,而這些直覺仍然未充分指定。因此,無法完全將隱含收斂控制轉換為顯式收斂控制符記,並且這兩種模式不能在同一個函式中混合使用。
如果一個函式包含受控的收斂操作,則該函式中的所有收斂操作必須是受控操作,或是對收斂控制內建函式的呼叫。
推斷符記¶
(本節為資訊性內容)
有時,可能需要根據顯式收斂控制符記來重新詮釋隱含收斂控制。例如,當函式呼叫被內聯,且呼叫者或被呼叫者包含不受控的收斂操作時,可能會發生這種情況。
不受控的收斂操作的某些使用可能需要滿足以下屬性
對於環境定義的執行緒群組(例如 OpenCL 工作群組或子群組),如果群組中的一個執行緒執行收斂操作,則群組中的所有執行緒都與該執行緒收斂地執行。
以顯式收斂控制的角度來看,這表示每個收斂操作 X
上的 convergencectrl
運算元最終必須源自於對 llvm.experimental.convergence.entry 內建函式的呼叫。這保留了這樣一種可能性,即在到達 X
時收斂的執行緒群組,與最初開始在收斂中執行程式的群組相同。相比之下,llvm.experimental.convergence.anchor 內建函式捕獲的是實作定義的執行緒群組,這不足以支援上述屬性。
一種以顯式收斂控制符記來近似隱含收斂控制的方法是以下程序,它保留了上述提到的屬性
將每個不可簡化的循環轉換為可簡化的循環。
在函式的進入區塊的開始處插入對 llvm.experimental.convergence.entry 的呼叫。
在每個迴圈標頭的開始處插入對 llvm.experimental.convergence.loop 的呼叫。如果此迴圈是最外層迴圈,則
convergencectrl
運算元是對函式進入區塊中 llvm.experimental.convergence.entry 的呼叫。否則,convergencectrl
運算元是對父迴圈標頭中 llvm.experimental.convergence.loop 的呼叫。對於每個不受控的收斂操作
X
,新增一個convergencectrl
運算元組,使用由定義D
定義的符記,該定義D
是此操作的同級。D
始終支配X
— 如果X
不在任何循環中,則D
是對 llvm.experimental.convergence.entry 的呼叫;否則D
是X
的父循環的核心。
靜態規則¶
LLVM IR 中良好形式的程式必須滿足以下關於循環和收斂區域的靜態規則。
封閉路徑¶
CFG 中的封閉路徑是 CFG 中節點和邊的連接序列,其起點和終點相同。
CFG 中每個包含收斂符記 T 的使用的封閉路徑(除了 llvm.experimental.convergence.loop 的使用外)也必須包含 T 的定義。
CFG 中每個包含收斂符記 T 的兩個不同使用的封閉路徑也必須包含 T 的定義。
CFG 中每個包含兩個不同收斂符記 T1 和 T2 的使用的封閉路徑也必須包含至少其中一個的定義。
綜合來看,這些規則表示,對於每個封閉路徑 C,最多只能有一個收斂符記 T 在 C 中使用但在 C 外部定義,且 T 在 C 中只能使用一次,且僅由 llvm.experimental.convergence.loop
使用。
在每個包含符記 T 的使用 U 但不包含 T 的定義的封閉路徑中,U 必須支配封閉路徑中的所有節點。
這表示 llvm.experimental.convergence.loop
只能作為自然迴圈標頭中的核心出現。
充分條件: 從循環的屬性來看,證明上述循環的屬性(而不是封閉路徑的屬性)就足夠了。簡而言之,任何違反上述一個或多個靜態規則的封閉路徑都包含在也違反相同規則的循環中。
收斂區域¶
收斂符記 T 的收斂區域是 T 為活用的且被使用的最小區域,即由 T 的定義 D 支配,且可以從中到達 T 的使用的程式點集合。
有效的程式必須滿足以下關於收斂區域的靜態規則
如果符記 T1 的收斂區域 R 包含收斂符記 T2 的使用,則 R 也必須包含 T2 的定義。(換句話說,收斂區域必須合理地巢狀。)
注意
為了簡潔起見,本文檔使用術語「符記定義 D
的收斂區域」來實際指代由 D
定義的符記 T
的收斂區域。
推斷非收斂¶
當目標或環境保證執行緒不使用收斂操作進行溝通,或執行緒永遠不會發散時,程式中的動態實例是不相關的,最佳化器可能會移除呼叫點或函式上 convergent
屬性的任何出現,以及呼叫點上的任何顯式 convergencectrl
運算元組。
如果最佳化器可以證明此呼叫點的執行始終導致呼叫非收斂函式,則最佳化器可以從呼叫點移除 convergent
屬性和任何顯式 convergencectrl
運算元組。
如果最佳化器可以證明函式不包含對 llvm.experimental.convergence.entry 或任何不受控的收斂操作的呼叫,則最佳化器可以移除函式上的 convergent
屬性。
記憶體模型非互動¶
操作是收斂的事實對如何在記憶體模型目的上處理它沒有影響。特別是,就記憶體模型而言,convergent
和 readnone
操作不會引入額外的排序約束。既沒有記憶體屏障意義上的隱含屏障,也沒有同步執行緒執行的控制屏障意義上的隱含屏障。
資訊性註記:執行收斂動態實例的執行緒不一定同時執行。
其他互動¶
函式可以同時是 convergent
和 speculatable
,表示該函式沒有未定義行為,且除了計算其結果外沒有其他影響,但仍然受到執行此函式的執行緒集合的影響。除非 convergent
施加的約束透過其他方式進一步放寬,否則這通常會阻止對函式的推測呼叫。
受控的最大收斂¶
每個受控收斂操作的動態實例上的收斂關係完全由收斂符記的語意定義。但是,如果對 llvm.experimental.convergence.anchor 的呼叫發生在不可簡化的循環內,則實作定義的收斂也取決於選擇的循環層次結構。
當由收斂操作 D
定義的符記在另一個收斂操作 U
中使用時,實作必須確保在 U
收斂的執行緒是所有在 D
收斂後到達 U
的執行緒。在大多數實作上,合理假設只有這些執行緒在它們從 D
到 U
的任何路徑上到達的每個節點都收斂。換句話說,在 D
的收斂關係產生可以在每個群組內收斂的執行緒群組,而在 D
的收斂區域內。
所有這些都會影響動態實例上的最大收斂關係,進而影響 D
的收斂區域中靜態實例的m-收斂屬性。
受控的最大收斂關係
收斂操作的動態實例根據收斂控制符記的語意,在受控的最大收斂關係中相關。
由不同執行緒為相同非收斂操作
X
產生的動態實例X1
和X2
當且僅當滿足以下條件時,才在受控的最大收斂關係中相關
兩個執行緒都執行了每個符記定義
D
的收斂動態實例,使得X
在D
的收斂區域中,且,或者
X
不包含在任何循環中,或者,對於每個包含X
且標頭為H
的循環C
每個在個別執行緒中先於
X1
的H
的動態實例H1
都比X2
更早收斂,且,每個在個別執行緒中先於
X2
的H
的動態實例H2
都比X1
更早收斂,不假設
X1
與X2
收斂。
在循環退出的時間發散¶
當循環具有發散出口時,最大收斂假設所有執行緒都在出口區塊收斂。但是,如果循環外部的受控收斂操作使用由循環內部的操作 D
定義的符記,則 D
的收斂區域現在延伸到循環外部。如果兩個執行緒在退出循環之前執行了 D
的收斂動態實例,則它們繼續執行循環外部 D
的收斂區域中節點的收斂動態實例。因此,對於在循環內部定義的值 V
,在 D
的收斂區域內 V
的任何使用 U
都使用 V
的收斂動態實例的輸出。如果 V
是均勻的,則它在這樣的 U
的使用也是均勻的。換句話說,時間發散僅適用於在 D
的收斂區域外部對 V
的使用。
關於循環的靜態規則的理由¶
(本節為資訊性內容。)
注意
為了方便起見,我們使用運算子 ==
來表示收斂關係
,並使用運算子 !=
來表示其否定。
考慮一個具有(不正確!)收斂控制的迴圈,如以下虛擬碼所示
; WARNING: Example of incorrect convergence control!
%anchor = call token @llvm.experimental.convergence.anchor()
for (;;) {
...
call void @convergent.op() [ "convergencectrl"(token %anchor) ]
...
}
此程式碼被關於循環的第一個靜態規則禁止。
我們必須這樣做的第一個正式論點是,用於決定兩個執行緒是否執行 @convergent.op
的收斂動態實例的動態規則導致此程式碼中的邏輯矛盾。假設兩個執行緒執行錨點的收斂動態實例,然後執行迴圈的兩個迭代。執行緒 1 執行 @convergent.op
的動態實例 I1 和 I2,執行緒 2 執行動態實例 J1 和 J2。使用所有規則,我們可以推斷出
I1 != I2
且J1 != J2
,根據動態實例的基本規則。I1 == J1
,根據關於受控收斂操作的第一個動態規則:兩個執行緒在執行相同的靜態指令時,都使用由指令(錨點)的收斂動態實例產生的收斂符記值。I1 == J2
,根據相同的論點。同樣,I2 == J1
且I2 == J2
。人們可能直覺地傾向於認為 I1 和 J2 是在不同的迴圈迭代中執行的事實,對於正式論點來說完全無關。LLVM IR 語意中沒有機制可以在不同執行緒的迴圈迭代之間建立關聯,除了本文檔中定義的規則之外——且本文檔中的規則要求使用迴圈核心內建函式來談論迴圈迭代。
根據遞移性,我們得到
I1 == I2
和J1 == J2
。這是一個矛盾。
透過如下所示插入迴圈核心內建函式,這個問題就消失了,這在不同執行緒的迴圈迭代之間建立了關係。
%anchor = call token @llvm.experimental.convergence.anchor()
for (;;) {
%loop = call token @llvm.experimental.convergence.loop() [ "convergencectrl"(token %anchor) ]
...
call void @convergent.op() [ "convergencectrl"(token %loop) ]
...
}
在兩個執行緒執行錨點的收斂動態實例,然後執行迴圈的兩個迭代的相同情況下,關於迴圈核心內建函式的動態規則表示,兩個執行緒都在它們各自的第一次迭代中執行迴圈核心內建函式的收斂動態實例,然後又在它們各自的迴圈第二次迭代中執行。
這接著表示,它們在它們的第一次迭代中執行 @convergent.op
的收斂動態實例 I1 == J1
,然後在它們的第二次迭代中執行 I2 == J2
。該規則是「當且僅當」規則,因此也表示 I1 != J2
和 I2 != J1
,因為那些執行看到源自迴圈內建函式的非收斂動態實例的 %loop
符記值。
人們可能會問,我們是否可以更改動態規則,而不是新增關於循環的靜態規則。由於更深層次的困難,這是不可行的。考慮以下迴圈,再次使用不正確的收斂控制
; WARNING: Example of incorrect convergence control!
; (A)
%anchor = call token @llvm.experimental.convergence.anchor()
for (;;) {
; (B)
if (condition1) {
; (C)
call void @convergent.op.1() [ "convergencectrl"(token %anchor) ]
}
; (D)
if (condition2) {
; (E)
call void @convergent.op.2() [ "convergencectrl"(token %anchor) ]
}
; (F)
}
; (G)
假設兩個執行緒執行錨點的收斂動態實例,然後執行此基本區塊序列
Thread 1: A B C D F B D E F G
Thread 2: A B D E F B C D F G
也就是說,兩個執行緒都執行迴圈的兩個迭代,但它們在不同的迭代中執行不同的收斂操作。在沒有在不同執行緒的迴圈迭代之間建立關係的情況下,如果有的話,就沒有合理的方法來定義哪些收斂操作的動態實例在不同執行緒之間應該是相同的。
同樣,這可以透過新增迴圈核心內建函式來解決,最自然的方式是
; (A)
%anchor = call token @llvm.experimental.convergence.anchor()
for (;;) {
; (B)
%loop = call token @llvm.experimental.convergence.loop() [ "convergencectrl"(token %anchor) ]
if (condition1) {
; (C)
call void @convergent.op.1() [ "convergencectrl"(token %loop) ]
}
; (D)
if (condition2) {
; (E)
call void @convergent.op.2() [ "convergencectrl"(token %loop) ]
}
; (F)
}
; (G)
令 %loop(i;j)
為執行緒 i 第 j 次執行迴圈核心內建函式的動態實例,並類似地,令 @op.k(i)
和 @op.k(i)
為執行緒 i 執行 @convergent.op.k
的動態實例。然後我們有
%loop(1;j) == %loop(2;j)
,對於j = 1, 2
,因為關於迴圈核心內建函式的動態規則。%loop(i;1) != %loop(i;2)
,對於i = 1, 2
,因為基本規則規定,同一執行緒的不同執行發生在不同的動態實例中。@op.1(1) != @op.1(2)
,因為@op.1(1)
使用%loop
的符記值,該值指向%loop(1;1)
,而@op.1(2)
使用指向%loop(2;2) == %loop(1;2)
的值,這與%loop(1;1)
不同。類似地,
@op.2(1) != @op.2(2)
。
然而,迴圈核心內建函式可以以不同的方式插入,代價是也插入一個獨立的錨點
; (A)
%anchor = call token @llvm.experimental.convergence.anchor()
for (;;) {
; (B)
if (condition1) {
; (C)
%loop = call token @llvm.experimental.convergence.loop() [ "convergencectrl"(token %anchor) ]
call void @convergent.op.1() [ "convergencectrl"(token %loop) ]
}
; (D)
if (condition2) {
; (E)
%free = call token @llvm.experimental.convergence.anchor()
call void @convergent.op.2() [ "convergencectrl"(token %free) ]
}
; (F)
}
; (G)
這導致了別處也提到的「迴圈迭代的不自然計數」。令 %loop(i)
為執行緒 i 執行迴圈核心內建函式的動態實例(每個執行緒只執行一次),並令 @op.k(i)
與之前相同。
%loop(1) == %loop(2)
,因為關於迴圈核心內建函式的動態規則。@op.1(1) == @op.1(2)
,因為@op.1(i)
使用%loop
的值,該值指向%loop(i)
,且%loop(1) == %loop(2)
。@op.2(1) == @op.2(2)
是否成立是實作定義的,因為使用了%free
錨點內建函式。實際上,它們幾乎肯定必須是非收斂的動態實例。考慮到,如果實作嚴格遵循程式中給定的指令順序,執行緒的執行可以如下「對齊」
Thread 1: A B C D F B D E F G Thread 2: A B D E F B C D F G
因此,
@op.2(1)
在物理上比@op.2(2)
執行得更晚,且執行緒之間不可能進行通訊,這表示它們執行非收斂的動態實例。話雖如此,但可以想像,實際上並沒有任何資料或其他依賴關係會強制執行此執行順序。在這種情況下,高度亂序的實作可能會允許通訊。這就是為什麼本文檔中定義的規則對於
@op.2(1) == @op.2(2)
是否成立保持沉默。
這種型別的收斂控制似乎不太可能出現在真實程式中。它的可能性僅僅是模型的一個邏輯結果。
如果收斂操作被巢狀迴圈取代,而巢狀迴圈的迴圈核心內建函式直接引用 %anchor
,則會出現等效的問題,因此,適用於它們的關於循環的靜態規則的變體如下
; WARNING: Example of incorrect convergence control!
%anchor = call token @llvm.experimental.convergence.anchor()
for (;;) {
if (condition1) {
for (;;) {
%loop1 = call token @llvm.experimental.convergence.loop() [ "convergencectrl"(token %anchor) ]
}
}
if (condition2) {
for (;;) {
%loop2 = call token @llvm.experimental.convergence.loop() [ "convergencectrl"(token %anchor) ]
}
}
}
有一個循環(CFG 中的封閉路徑),它通過使用 %anchor
的兩個迴圈核心內建函式,但不通過 %anchor
的定義,因此此程式碼無效。
程式轉換正確性的範例¶
(本節為資訊性內容。)
如前幾節中的規則所示,如果程式轉換保留或改進了收斂操作的語意,則程式轉換在收斂操作方面是正確的。這表示轉換後程式中的通訊執行緒集合必須在原始程式中是可能的。
如果程式轉換不會跨分支下沉或提升收斂操作,則以單執行緒為中心的程式轉換通常是保守正確的。這甚至適用於更改控制流程圖的程式轉換。
例如,展開不包含收斂操作的迴圈不會破壞迴圈外部收斂操作所需的任何保證。
迴圈展開範例¶
我們在這裡考慮三種類型的迴圈展開
部分展開,沒有已知的行程倍數,因此需要「尾部」來收集剩餘元素。
按行程倍數部分展開,因此不需要「尾部」。
完全展開,這消除了迴圈。
當使用 @llvm.experimental.convergence.loop
時,第一種類型是被禁止的。我們用一些範例來說明推理。
首先,如果所有收斂操作都回指到迴圈內部的錨點,則包含收斂操作的任意迴圈可以用所有這些方式展開,即使帶有「尾部」。例如(在虛擬碼中)
while (counter > 0) {
%tok = call token @llvm.experimental.convergence.anchor()
call void @convergent.operation() [ "convergencectrl"(token %tok) ]
counter--;
}
這可以展開為
while (counter >= 2) {
%tok = call token @llvm.experimental.convergence.anchor()
call void @convergent.operation() [ "convergencectrl"(token %tok) ]
%tok = call token @llvm.experimental.convergence.anchor()
call void @convergent.operation() [ "convergencectrl"(token %tok) ]
counter -= 2;
}
while (counter > 0) {
%tok = call token @llvm.experimental.convergence.anchor()
call void @convergent.operation() [ "convergencectrl"(token %tok) ]
counter--;
}
如果有執行緒的初始計數器值不是 2 的倍數,這可能會改變收斂操作的行為。特別是,行程計數為奇數的所有執行緒現在很可能在其各自的最終迭代中一起執行收斂操作,因為底層實作很可能會嘗試將盡可能多的執行緒組合在一起以執行「尾部」。
允許此更改,因為錨點內建函式具有實作定義的收斂行為,並且迴圈展開轉換被視為實作的一部分。另一種推理方式是,雖然程式碼的可能行為已更改,但關於其行為的保證保持不變。
如果迴圈包含不受控的收斂操作,則禁止此類型的展開。
當必須引入「尾部」或「餘數」時,禁止展開具有引用迴圈外部產生的符記的收斂操作的迴圈。考慮
; (A)
%outer = call token @llvm.experimental.convergence.anchor()
while (counter > 0) {
%inner = call token @llvm.experimental.convergence.loop() [ "convergencectrl"(token %outer) ]
; (B)
call void @convergent.operation() [ "convergencectrl"(token %inner) ]
counter--;
}
; (C)
為了理解為什麼禁止展開,請考慮兩個執行緒,它們執行錨點的收斂動態實例,然後分別進行 3 次和 4 次迴圈迭代
Thread 1: A B B B C
Thread 2: A B B B B C
根據關於迴圈核心內建函式的動態規則,這些執行緒在前 3 次迭代中執行迴圈內建函式的收斂動態實例,然後執行緒 2 單獨執行另一個動態實例。
根據關於一般收斂運算的動態規則,在最先的 3 個迭代中,執行緒會執行 @convergent.operation
的收斂動態實例(亦即,執行緒 1 在迭代 n 中執行的動態實例與執行緒 2 在迭代 n 中執行的動態實例相同,對於 n = 1,2,3;迭代 1 中執行的動態實例與迭代 2 中的不同,依此類推)。
現在假設迴圈展開了 2 倍因子,這需要一個餘數如下
; (A)
%outer = call token @llvm.experimental.convergence.anchor()
while (counter >= 2) {
%inner = call token @llvm.experimental.convergence.loop() [ "convergencectrl"(token %outer) ]
; (B)
call void @convergent.operation() [ "convergencectrl"(token %inner) ]
call void @convergent.operation() [ "convergencectrl"(token %inner) ]
counter -= 2;
}
; (C)
if (counter > 0) {
%remainder = call token @llvm.experimental.convergence.loop() [ "convergencectrl"(token %outer) ]
; (D)
call void @convergent.operation() [ "convergencectrl"(token %remainder) ]
}
; (E)
首先,請注意一些圍繞迴圈內建函數的有趣問題
它並未在展開的迴圈內重複。這是為了遵守靜態規則。
目前尚不清楚迴圈內建函數是否應該在餘數中重複,或者 D 中的最終
@convergent.operation
是否應該僅參考%inner
(在 SSA 形式中是可能的)或直接參考%outer
。此處做出的決定是任意的,並且不會改變後續的論點。最終,這根本不重要,因為無論如何轉換都是不正確的。
現在執行緒執行以下區塊序列
Thread 1: A B C D E
Thread 2: A B B C D E
與上面的論點類似,它們在展開迴圈的第一次迭代中執行 %inner
內建函數和 @convergent.operation
的收斂動態實例,這對應於原始迴圈的前 2 次迭代。
然而,對於原始迴圈的第 3 次迭代,它們執行對 @convergent.operation
的不同靜態呼叫。在執行緒 1 中,該迭代對應於餘數中的呼叫,而在執行緒 2 中,它對應於展開迴圈中對 @convergent.operation
的第一次呼叫。因此,它們執行非收斂的動態實例,這表示原始迴圈第 3 次迭代的通訊執行緒集合不同。這就是展開不正確的原因。
另一方面,允許不帶「尾部」的展開。例如,假設行程計數器已知是 2 的倍數,我們可以如下展開迴圈
%outer = call token @llvm.experimental.convergence.anchor()
while (counter > 0) {
%inner = call token @llvm.experimental.convergence.loop() [ "convergencectrl"(token %outer) ]
call void @convergent.operation() [ "convergencectrl"(token %inner) ]
call void @convergent.operation() [ "convergencectrl"(token %inner) ]
counter -= 2;
}
再次注意,迴圈內建函數沒有重複。
llvm.experimental.convergence.loop 內建函數通常預期會出現在自然迴圈的標頭中。但是,它也可能出現在迴圈的非標頭區塊中。在這種情況下,迴圈通常無法展開。
上浮和下沉¶
一般而言,禁止收斂運算的提升和下沉。這是因為將運算移動到控制流程中的不同點通常會更改到達運算的執行緒集合,因此也會更改執行運算收斂動態實例的執行緒集合。依照定義,這會更改參與收斂運算通訊的執行緒集合,這通常會更改其結果。
不過,有一些例外情況,但大多數例外情況都需要額外的知識。
例如,跨一致性條件分支(即,在每個可能的相關執行緒集合中,所有執行緒始終採用相同的方向的條件分支)的提升和下沉通常是允許的。有關簡短討論,請參閱控制流程內縮減範例的末尾。
某些收斂運算可以提升但不能下沉,反之亦然。一個簡單的例子是 subgroupShuffle(data, id)
運算。它傳回由 id
識別的執行緒的 data
運算元,其中執行緒 ID 是固定的,並在啟動時分配給每個執行緒。如果執行緒 id
不在通訊執行緒集合中,則結果是未定義的(或者可能存在 UB,取決於語言和環境)。因此,在以下虛擬碼範例中允許提升
define void @example(...) convergent {
%entry = call token @llvm.experimental.convergence.entry()
%data = ...
%id = ...
if (condition) {
%shuffled = call i32 @subgroupShuffle(i32 %data, i32 %id) [ "convergencectrl"(token %entry) ]
...
} else {
%shuffled = call i32 @subgroupShuffle(i32 %data, i32 %id) [ "convergencectrl"(token %entry) ]
...
}
}
在提升對 @subgroupShuffle
的呼叫之後,通訊執行緒集合是原始程式中兩個執行緒集合的聯集,因此只有在原始程式中 %id
超出範圍時,提升後才可能超出範圍。
但是,在以下程式中推測性執行 @subgroupShuffle
可能是禁止的
define void @example(...) convergent {
%entry = call token @llvm.experimental.convergence.entry()
%data = ...
%id = ...
if (condition) {
%shuffled = call i32 @subgroupShuffle(i32 %data, i32 %id) [ "convergencectrl"(token %entry) ]
...
}
}
無法保證 %id
在 condition
為 false 的執行緒中的值。如果 @subgroupShuffle
定義為當 %id
超出通訊執行緒集合範圍時具有 UB,則推測和提升 @subgroupShuffle
可能會引入 UB。
另一方面,如果 @subgroupShuffle
的定義僅在 %id
「超出範圍」時產生未定義的值或 poison 作為結果,則推測是可以的。
即使 llvm.experimental.convergence.anchor 被標記為 convergent
,在某些情況下也可以下沉。例如,在虛擬碼中
%tok = call token @llvm.experimental.convergence.anchor()
if (condition) {
call void @convergent.operation() [ "convergencectrl"(token %tok) ]
}
假設 %tok
僅在條件區塊內使用,則 anchor 可以下沉。理由有兩個。首先,anchor 具有實作定義的行為,而下沉是實作的一部分。其次,即使在原始程式中,在 @convergent.operation
中通訊的執行緒集合也會自動子集化為 condition
為 true 的執行緒。
Anchors 可以在非循環控制流程中提升。例如
if (condition) {
%tok1 = call token @llvm.experimental.convergence.anchor()
call void @convergent.operation() [ "convergencectrl"(token %tok1) ]
} else {
%tok2 = call token @llvm.experimental.convergence.anchor()
call void @convergent.operation() [ "convergencectrl"(token %tok2) ]
}
Anchors 可以提升,結果如下
%tok = call token @llvm.experimental.convergence.anchor()
if (condition) {
call void @convergent.operation() [ "convergencectrl"(token %tok) ]
} else {
call void @convergent.operation() [ "convergencectrl"(token %tok) ]
}
行為沒有改變,因為每個靜態收斂運算都只與具有相同 condition
值的執行緒通訊。相比之下,禁止提升收斂運算本身。
禁止將 anchors 提升和下沉到迴圈內外。例如
for (;;) {
%tok = call token @llvm.experimental.convergence.anchor()
call void @convergent.operation() [ "convergencectrl"(token %tok) ]
}
根據靜態有效性規則,提升 anchor 會使程式無效。相反地
%outer = call token @llvm.experimental.convergence.anchor()
while (counter > 0) {
%inner = call token @llvm.experimental.convergence.loop() [ "convergencectrl"(token %outer) ]
call void @convergent.operation() [ "convergencectrl"(token %inner) ]
counter--;
}
如果 anchor 下沉到迴圈中,程式將保持有效,但其行為最終可能會有所不同。如果 anchor 在迴圈內部,則每個迴圈迭代都有 anchor 的新動態實例,並且參與這些 anchor 動態實例的執行緒集合可能以任意實作定義的方式不同。透過關於收斂運算動態實例的動態規則,這接著暗示執行 @convergent.operation
的執行緒集合在每個迴圈迭代中可能以任意實作定義的方式不同。
收斂運算可以與其 anchor 一起下沉。再次以虛擬碼為例
%tok = call token @llvm.experimental.convergence.anchor()
%a = call T @pure.convergent.operation(...) [ "convergencectrl"(token %tok) ]
%b = call T @pure.convergent.operation(...) [ "convergencectrl"(token %tok) ]
if (condition) {
use(%a, %b)
}
假設 %tok
、%a
和 %b
僅在條件區塊內使用,則所有這些都可以一起下沉
if (condition) {
%tok = call token @llvm.experimental.convergence.anchor()
%a = call T @pure.convergent.operation(...) [ "convergencectrl"(token %tok) ]
%b = call T @pure.convergent.operation(...) [ "convergencectrl"(token %tok) ]
use(%a, %b)
}
理由是 anchor 內建函數具有實作定義的行為,並且下沉轉換被視為實作的一部分:下沉將把通訊執行緒集合限制為 condition
為 true 的那些執行緒,但這在原始程式中也可能由於某些任意其他原因而發生。
但是,僅下沉產生 %b
的收斂運算將是不正確的。這將允許 condition
為 false 的執行緒在 %a
處通訊,但不在 %b
處通訊,這是原始程式不允許的。
請注意,entry 內建函數的行為有所不同。在以下程式碼片段中,禁止下沉收斂運算
%tok = call token @llvm.experimental.convergence.entry()
%a = call T @pure.convergent.operation(...) [ "convergencectrl"(token %tok) ]
%b = call T @pure.convergent.operation(...) [ "convergencectrl"(token %tok) ]
if (condition) {
use(%a, %b)
}