聚合操作語義

概述

某些平行執行環境會以群組的方式執行執行緒,允許在群組內使用稱為「聚合」操作的特殊原語進行高效通訊。聚合操作的結果取決於「一起」執行它的執行緒集,也就是聚合執行。當控制流程發散時,也就是同一個群組中的執行緒遵循不同的路徑通過 CFG,並非群組中的所有執行緒都能參與此通訊。這是區分聚合操作與其他執行緒間通訊的決定性特徵。

聚合操作涉及在記憶體模型之外發生的執行緒間通訊或同步,其中參與通訊的執行緒集會受到控制流程的隱含影響。

例如,在以下 GPU 計算核心程式碼中,聚合操作期間的通訊預期會精確地發生在滿足 condition 條件的執行緒之間,這些執行緒屬於實作定義的執行範圍(例如工作群組或子群組)。

void example_kernel() {
    ...
    if (condition)
        convergent_operation();
    ...
}

在結構化程式語言中,通常可以使用直觀且明確的方式來確定預期通訊的執行緒。然而,即使在結構化程式語言中,情況並非總是如此,而且在非結構化控制流程中,這種直觀性會完全失效。本文檔描述了 LLVM 中的形式語義,也就是如何確定聚合操作的通訊執行緒集。

本文檔中的定義留下了許多細節,例如如何一開始就形成執行緒群組。它著重於與決定泛型程式轉換和聚合相關分析(例如一致性分析)的正確性相關的問題。

聚合操作

在 LLVM IR 中,如上所述,線程間通訊的唯一方式是呼叫目標定義的匯聚內建函數。 因此,只有 LLVM IR 中的呼叫站點(callinvokecallbr 指令)才會導致匯聚操作。

如果 LLVM IR 中的函數具有 convergent 屬性,則稱該函數為「*匯聚*」函數。

如果 LLVM IR 中的呼叫站點是對匯聚函數的直接呼叫,或者它具有 convergent 屬性或 convergencectrl 運算元套件,則稱該呼叫站點為「*匯聚*」呼叫站點。

參考說明

如果函數或從該函數遞迴呼叫的任何函數包含匯聚呼叫站點,則可能必須將該函數視為匯聚函數。 產生 convergent 屬性的前端在發出函數和函數呼叫時,應將此納入考量。 但情況並非總是如此。

非匯聚函數可能包含匯聚操作;這些操作不直接依賴於作為單一通訊群組進入函數的線程集。 相反地,這些操作依賴於函數主體內由實作定義的線程子集,如 機會性匯聚操作 中所示。

匯聚操作的範例

(本節僅供參考。)

像素著色器中的紋理取樣

以下風格化的像素著色器使用內建函數 textureSample 在一組給定坐標處對紋理進行取樣。 紋理取樣需要坐標的屏幕空間導數來確定樣本的細節層級(mipmap 層級)。 它們通常透過取相鄰像素之間的差異來近似,這些像素由同一個群組中的不同線程計算。

void example_shader() {
  ...
  color = textureSample(texture, coordinates);
  if (condition) {
    use(color);
  }
  ...
}

從純粹單線程的角度來看,將 textureSample 下沉到 if 語句中似乎是合法的。 但是,如果對於某些相鄰像素條件為假,則它們對應的線程將不會在群組中一起執行,因此無法將坐標的差異作為屏幕空間導數的近似值。 實際上,結果將是未定義的值。

也就是說,textureSample 操作符合我們對匯聚操作的定義。

  1. 它與一組隱含依賴於控制流程的線程進行通訊。

  2. 正確性取決於這組線程。

編譯器前端可以發出表示匯聚約束的 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 內建函式產生的權杖,並且沒有其他控制依賴性,因此它必須在同一組執行緒之間進行通訊。 這向泛型程式轉換表明禁止下沉 @textureSample 呼叫。(如果程式轉換可以通過某種方式證明(例如,通過依賴於目標特定回調,這些回調可以利用額外知識分析程式),則仍然可以下沉呼叫,即 %condition 在 *匯聚權杖* %entry 引用的執行緒中始終是統一的。)

發散控制流程中的歸約

以下範例顯示,面對匯聚操作時,合併分支的通用程式碼可能不正確

void example_kernel() {
  delta = ...
  if (delta > 0) {
    total_gains = subgroupAdd(delta);
    ...
  } else {
    total_losses = subgroupAdd(delta);
    ...
  }
}

計算 total_gainssubgroupAdd 將由子群組(波)中具有正 delta 的執行緒子集執行,因此將彙總這些執行緒的所有 delta 值;計算 total_lossessubgroupAdd 也是如此。

如果我們要在 if 語句上方提升並合併 subgroupAdd,它將彙總*所有*執行緒的 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 內建函式產生的權杖,但它們也具有額外的控制依賴性。 根據本文檔中定義的規則,它們僅在實際最終執行相應(靜態)呼叫站點的執行緒子集之間進行通訊。

提升它們將移除控制依賴性,並導致它們在 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 處重新匯聚,以便執行控制屏障的所有執行緒(在一個子群組/波段內)一起執行。在第二個版本中,透過不同路徑到達控制屏障的執行緒會分別同步:第一個(也是唯一的)後支配節點是 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 是入口內建函數通訊的執行緒集,那麼 @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(用於擷取一起執行錨點的執行緒的位元遮罩)與最終的 @subgroupBroadcastFirst 使用相同的執行緒集執行。就匯聚而言,其他任何事情都不是正確性所必需的。

函數 @reserveSpaceInBuffer 本身並_非_ convergent:呼叫者可以自由地移動函數的呼叫位置。這在實務上可能會改變行為,因為它會改變為原子操作組合在一起的執行緒集。這在程式的輸出中是可見的,因為輸出在緩衝區中出現的順序會改變。然而,這並不會破壞 @reserveSpaceInBuffer 與其呼叫者之間的整體合約 – 這是合理的:由於涉及原子操作,輸出的順序本來就是不確定的。

如果函數是內聯的,則錨點內建函數的使用同樣表示,某些通常由於 convergent 操作的存在而被禁止的轉換實際上是被允許的,只要它們不破壞由錨點控制的程式碼區域。

擴展週期:從迴圈發散退出

高階語言通常會提供一個 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 來表達所需的語義。在迴圈標頭中放置對此內建函數的呼叫,該標頭會追蹤迴圈的每次迭代。此操作產生的權杖會被用作 convergent 呼叫的 convergencectrl 運算元。loop 內建函數的語義確保 convergent 呼叫僅由在給定迭代中共同退出迴圈的執行緒共同執行。

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 結尾處的分歧分支到達的出口。但是,使用收斂控制權杖可以清楚地看出,區塊 %C 只能由那些從 %B 收斂地採用出口邊緣到 %C 的執行緒收斂地執行。換句話說,%C 的收斂執行是由循環內對 llvm.experimental.convergence.loop 內建函數的調用控制的。循環被有效地擴展為包含所有位於循環外部的此權杖的使用。

動態實例和收斂權杖

LLVM IR 指令的每次執行都發生在指令的 動態實例 中。動態實例是我們用來討論收斂操作中通訊執行緒的形式化對象。動態實例是為 LLVM 程式中的*所有*操作定義的,無論是否收斂。收斂控制主要是關於收斂操作的動態實例,因為它們通過執行緒間通訊影響程式的執行。非收斂操作的動態實例與確定值的 一致性 相關。

由不同執行緒執行相同*收斂操作*產生的動態實例可能是 收斂的。當執行收斂操作時,執行收斂動態實例的執行緒集是彼此通訊的執行緒集。*收斂權杖*捕獲這種收斂,如下所述。

*收斂權杖*是 token 類型的值,即它們不能用於 phiselect 指令中。收斂權杖值表示產生它的指令的動態實例。

收斂操作可能具有一個可選的 convergencectrl 操作數套件,其中包含一個收斂權杖操作數,用於定義相對於定義權杖的操作的通訊執行緒集。

U 為除調用收斂控制內建函數之外的收斂操作,令 D 為定義用作 Uconvergencectrl 操作數的權杖值的收斂操作。當且僅當兩個執行緒中的權杖值由 D 的收斂動態實例返回時,這兩個執行緒才會執行 U 的收斂動態實例。

備註

本文將收斂權杖值定義為表示動態執行個體。但如果我們假設收斂的動態執行個體產生相同的權杖值,那麼我們幾乎可以將權杖值視為表示一組執行緒,特別是執行定義指令 D 的收斂動態執行個體的執行緒集 S

在此直觀圖像中,當指令 I 上的 convergencectrl 套件使用收斂權杖值 T 時,在 I 中進行通訊的執行緒集是權杖值所代表的集合 S 的子集。具體來說,它是最終在使用權杖值的同時執行 I 的執行緒子集。

僅此一點還不足以作為定義:如果 I 由相同的執行緒多次執行怎麼辦?執行緒 1 中的 I 的哪個執行個體與執行緒 2 中的 I 的哪個執行個體進行通訊?只要 DI 處於相同的迴圈(或週期)嵌套級別,依賴於動態執行個體的概念就可以為這個問題提供可靠的答案。

DI 處於不同迴圈嵌套級別的情況被 靜態規則 禁止 - 處理這種情況是 llvm.experimental.convergence.loop 的目的。

收斂控制內建函數

本節介紹可用於產生收斂權杖的目標無關內建函數。

如果間接呼叫收斂控制內建函數,則行為未定義。

llvm.experimental.convergence.entry

token @llvm.experimental.convergence.entry() convergent readnone

此內建函數用於將函數內部的動態執行個體與呼叫端的動態執行個體綁定。

  1. 如果從 LLVM 範圍之外呼叫函數,則此內建函數的動態執行個體的收斂性由環境定義。例如

    1. 在 OpenCL *核心啟動*中,可以在記憶體模型之外進行通訊的最大執行緒集是一個*工作群組*。因此,一個合適的選擇是指定 OpenCL 中來自單個工作群組的所有執行緒執行此內建函數的收斂動態執行個體。

    2. 在 C/C++ 程式中,執行緒是獨立啟動的,它們只能通過記憶體模型進行通訊。因此,C/C++ 程式中此內建函數的動態執行個體永遠不會收斂。

  2. 如果從 LLVM IR 中的呼叫站點呼叫函數,則當且僅當兩個執行緒都通過執行呼叫站點的收斂動態執行個體進入函數時,這兩個執行緒才會執行此內建函數的收斂動態執行個體。

此內建函數在函數中最多出現一次,並且只能出現在函數的入口區塊中。如果此內建函數出現在基本區塊中,則它必須位於相同基本區塊中任何其他收斂操作之前。

如果此內建函數出現在非收斂函數中,則表示錯誤。

在呼叫此內建函數時指定 convergencectrl 運算元套件是一個錯誤。

函數內聯使用運算元套件中的權杖替換此內建函數。例如

// 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 為收斂操作,其定義用作 Uconvergencectrl 運算元的權杖值。當且僅當滿足以下條件時,兩個執行緒才會執行 U 的收斂動態實例:

  1. 兩個執行緒中的權杖值皆由 D 的收斂動態實例返回,並且

  2. 存在一個整數 n,使得兩個執行緒都使用該權杖值第 n 次執行 U

在呼叫此內建函數時省略 convergencectrl 運算元套件是一個錯誤。

如果此內建函數出現在基本區塊中,則它必須位於同一基本區塊中任何其他收斂操作之前。

循環核心

如果 循環 C 包含此內建函數的出現次數 H,其權杖運算元在 C 之外定義,則 H 稱為 C 的核心。

備註

循環的靜態規則意味著核心只能出現在自然迴圈的標頭中。這確保了核心緊密地表示了迴圈迭代的直觀概念。如果放寬此限制,則生成的語義即使對於不可約循環也提供了一種新的「循環迭代」概念。但這允許自然迴圈在標頭以外的節點中具有核心,這對迴圈迭代在收斂方面的含義產生了有趣的影響。目前,我們不允許這種情況,因為它的實際應用非常少見。

llvm.experimental.convergence.anchor

token @llvm.experimental.convergence.anchor() convergent readnone

此內建函數產生一個初始收斂權杖,該權杖獨立於任何「外部作用域」。執行此內建函數的收斂動態實例的執行緒集是由實現定義的。

在呼叫此內建函數時傳遞 convergencectrl 運算元套件是一個錯誤。

備註

預期是,「恰好在同一時間處於活動狀態」的群組中的所有執行緒都將執行收斂的動態實例,以便程式可以檢測到可以在程式的某些區域內有效通信的最大執行緒集。

不受控的收斂操作

具有顯式 convergencectrl 運算元套件的收斂操作稱為*受控收斂操作*。所有其他收斂操作都稱為*不受控*。

如果一個非受控收斂操作僅由 convergent 屬性決定,則稱其具有*隱式收斂控制*。在 LLVM 中實現的 convergent 屬性的語義與文檔化的語義不同。該實現試圖遵循關於收斂操作的常識,而這些常識仍未被具體說明。因此,無法將隱式收斂控制完全轉換為顯式收斂控制權杖,並且這兩種模式不能在同一個函數中混合使用。

如果一個函數包含受控收斂操作,則該函數中的所有收斂操作都必須是受控操作或對收斂控制內建函數的呼叫。

推斷權杖

(本節僅供參考)

有時,可能需要根據顯式收斂控制權杖重新解釋隱式收斂控制。例如,當內嵌函數呼叫時,如果呼叫者或被呼叫者包含非受控收斂操作,則可能會發生這種情況。

非受控收斂操作的某些用途可能需要滿足以下特性

對於環境定義的執行緒群組(例如 OpenCL 工作群組或子群組),如果群組中的一個執行緒執行收斂操作,則群組中的所有執行緒都與該執行緒收斂地執行該操作。

就顯式收斂控制而言,這意味著每個收斂操作 X 上的 convergencectrl 運算元最終必須來自對 llvm.experimental.convergence.entry 內建函數的呼叫。這保留了在到達 X 時收斂的執行緒群組與最初開始以收斂方式執行程式的群組相同的可能性。相比之下,llvm.experimental.convergence.anchor 內建函數會擷取一個實現定義的執行緒群組,這不足以支援上述特性。

一種根據顯式收斂控制權杖來近似隱式收斂控制的方法是以下過程,該過程保留了上述特性

  1. 將每個不可簡化的迴圈轉換為可簡化的迴圈。

  2. 在函數進入區塊的開頭插入對 llvm.experimental.convergence.entry 的呼叫。

  3. 在每個迴圈標頭的開頭插入對 llvm.experimental.convergence.loop 的呼叫。如果此迴圈是最外層迴圈,則 convergencectrl 運算元是對函數進入區塊中 llvm.experimental.convergence.entry 的呼叫。否則,convergencectrl 運算元是對父迴圈標頭中 llvm.experimental.convergence.loop 的呼叫。

  4. 對於每個未受控制的收斂操作 X,使用由定義 D 定義的標記添加一個 convergencectrl 操作數組合,該定義是此操作的 兄弟節點D 總是支配 X — 如果 X 不在任何循環中,則 D 是對 llvm.experimental.convergence.entry 的調用;否則 DX 的父循環的核心。

靜態規則

LLVM IR 中格式良好的程式必須滿足以下關於循環和收斂區域的靜態規則。

封閉路徑

CFG 中的 封閉路徑 是 CFG 中起點和終點相同的節點和邊的連接序列。

  1. CFG 中包含使用收斂標記 T 的每個封閉路徑(llvm.experimental.convergence.loop 使用的除外)也必須包含 T 的定義。

  2. CFG 中包含兩次不同使用收斂標記 T 的每個封閉路徑也必須包含 T 的定義。

  3. CFG 中包含使用兩個不同收斂標記 T1 和 T2 的每個封閉路徑也必須包含其中至少一個的定義。

綜合起來,這些規則意味著對於每個封閉路徑 C,最多只能有一個在 C 中使用但在 C 之外定義的收斂標記 T,並且 T 在 C 中只能使用一次,並且只能由 llvm.experimental.convergence.loop 使用。

  1. 在包含標記 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 屬性。

記憶體模型非互動性

運算是否為聚合運算,對其在記憶體模型方面的處理方式沒有影響。特別是,就記憶體模型而言,convergentreadnone 的運算不會引入額外的排序限制。無論是在記憶體屏障意義上,還是在同步執行緒的控制屏障意義上,都沒有隱含的屏障。

資訊性註記:執行聚合動態執行個體的執行緒不一定會同時執行。

其他互動

函式可以同時具有 convergentspeculatable 屬性,表示該函式沒有未定義的行為,並且除了計算其結果外沒有其他影響,但仍然會受到執行該函式的執行緒集的影響。這通常會阻止對函式的呼叫進行推測,除非透過其他方式進一步放寬 convergent 所施加的限制。

受控最大聚合

每個受控聚合運算的動態執行個體上的 聚合關係 完全由聚合權杖的語義定義。但是,如果對 llvm.experimental.convergence.anchor 的呼叫發生在不可約週期內,則其實作定義的聚合也取決於所選的週期階層。

當聚合運算 D 定義的權杖在另一個聚合運算 U 中使用時,實作必須確保在 U 處聚合的執行緒是在 D 處聚合後到達 U 的所有執行緒。在大多數實作中,可以合理地假設只有這些執行緒在從 DU 的任何路徑上到達的每個節點處都已聚合。換句話說,D 處的聚合關係會產生執行緒組,這些執行緒組只能在 D 的聚合區域內,在每個組內聚合。

所有這些都會影響動態執行個體上最大收斂關聯,進而影響D收斂區域中靜態執行個體的m-收斂屬性

受控最大收斂關聯

  1. 根據收斂控制權杖的語義,*收斂運算*的動態執行個體在受控最大收斂關聯中相關。

  2. 針對相同*非收斂運算*X,由不同執行緒產生的動態執行個體X1X2在受控最大收斂關聯中相關,若且唯若

    1. 兩個執行緒都執行了每個權杖定義D的收斂動態執行個體,使得X位於D的收斂區域中,並且

    2. X不包含在任何循環中,或者,對於每個包含X且標頭為H的循環C

      • 在各自執行緒中先於X1的每個H的動態執行個體H1都收斂於X2之前,並且

      • 在各自執行緒中先於X2的每個H的動態執行個體H2都收斂於X1之前

      • 而無需假設X1X2收斂。

受控 m-收斂靜態執行個體

若且唯若滿足以下條件,才會報告給定 CFG 中的節點X為 m-收斂:

  1. 對於任何權杖定義D,如果X位於D的收斂區域內,則D本身為 m-收斂,並且

  2. 每個包含X的循環都滿足以下必要條件

    1. 循環內的每個發散分支都滿足發散進入準則,並且

    2. 沒有從循環外的發散分支到達循環的發散路徑

循環出口的時間發散

當一個循環具有發散出口時,最大收斂假設所有執行緒在出口區塊處收斂。但如果循環外的受控收斂操作使用由循環內操作 D 定義的令牌,則 D 的收斂區域現在會延伸到循環外。如果兩個執行緒在退出循環之前執行了 D 的收斂動態實例,則它們會繼續執行循環外 D 的收斂區域中節點的收斂動態實例。因此,對於在循環內定義的值 V,在 T 的收斂區域內對 V 的任何使用 U 都使用 V 的收斂動態實例的輸出。如果 V 是統一的,則它在這樣的 U 處的使用也是統一的。換句話說,時間發散僅適用於 D 的收斂區域之外的 V 的使用。

關於循環的靜態規則的基本原理

(本節僅供參考。)

備註

為方便起見,我們使用運算符 == 表示關係 converged-with,並使用運算符 != 表示其否定。

考慮一個具有(不正確!)收斂控制的循環,如下面的偽代碼所示

; 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。使用所有規則,我們可以推斷

  1. 根據動態實例的基本規則,I1 != I2J1 != J2

  2. 根據關於受控收斂操作的第一個動態規則,I1 == J1:兩個執行緒在使用由指令(錨點)的收斂動態實例產生的收斂令牌值時執行相同的靜態指令。

  3. 根據相同的論點,I1 == J2。此外,I2 == J1I2 == J2

    即使人們可能直覺地認為 I1J2 在不同的迴圈迭代中執行,但这與形式論證完全無關。LLVM IR 語義中沒有機制可以建立不同執行緒中迴圈迭代之間的關聯,除非本文件定義的規則——而本文件中的規則需要迴圈核心內建函數來討論迴圈迭代。

  4. 根據遞移性,我們有 I1 == I2J1 == 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 != J2I2 != 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 的動態實例。然後我們有

  1. j = 1, 2 時,%loop(1;j) == %loop(2;j),因為關於迴圈核心內建函數的動態規則。

  2. i = 1, 2 時,%loop(i;1) != %loop(i;2),因為基本規則是同一個執行緒的不同執行發生在不同的動態實例中。

  3. 因為 @op.1(1) 使用了 %loop 的標記值,它指的是 %loop(1;1),而 @op.1(2) 使用的 %loop 指的是 %loop(2;2) == %loop(1;2),這與 %loop(1;1) 不同,所以 @op.1(1) != @op.1(2)

  4. 同樣地,@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) 如前所述。然後

  1. 由於關於循環核心內建函式的動態規則,%loop(1) == %loop(2)

  2. 因為 @op.1(i) 使用了 %loop 的值,它指的是 %loop(i),而 %loop(1) == %loop(2),所以 @op.1(1) == @op.1(2)

  3. 由於使用了 %free 錨點內建函式,@op.2(1) == @op.2(2) 是否成立是實現定義的。

    在實踐中,它們幾乎肯定必須是非收斂的動態實例。考慮如果一個實現嚴格按照程序中給出的指令順序執行,線程的執行可以「對齊」,如下所示

    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)

首先,請注意迴圈內建函數周圍的一些有趣問題

  1. 它*沒有*在展開的迴圈內複製。這是為了符合靜態規則

  2. 迴圈本質是否應該在剩餘部分複製,或者 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) ]
    ...
  }
}

condition 為 false 的線程中,%id 的值沒有任何保證。如果 @subgroupShuffle 被定義為在 %id 超出通訊線程集合時具有未定義行為 (UB),那麼推測和提升 @subgroupShuffle 可能會引入未定義行為。

另一方面,如果 @subgroupShuffle 的定義是在 %id “超出範圍” 時僅產生未定義值或毒藥值作為結果,那麼推測執行是可以的。

儘管 llvm.experimental.convergence.anchor 被標記為 convergent,但在某些情況下它可以被下沉。例如,在以下虛擬碼中:

%tok = call token @llvm.experimental.convergence.anchor()
if (condition) {
  call void @convergent.operation() [ "convergencectrl"(token %tok) ]
}

假設 %tok 僅在條件區塊內使用,則錨點可以被下沉。其理由有兩個方面。首先,錨點具有實現定義的行為,而下沉是實現的一部分。其次,在原始程式中,@convergent.operation 中通訊的線程集會自動成為 condition 為 true 的線程的子集。

錨點可以在非循環控制流程中被提升。例如:

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) ]
}

錨點可以被提升, resulting in:

%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 值的線程進行通訊。相反,提升收斂操作本身是被禁止的。

禁止將錨點提升到循環外或下沉到循環內。例如:

for (;;) {
  %tok = call token @llvm.experimental.convergence.anchor()
  call void @convergent.operation() [ "convergencectrl"(token %tok) ]
}

根據靜態有效性規則,提升錨點會使程式無效。反之:

%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--;
}

如果將錨點下沉到循環中,程式將保持有效,但其行為可能會有所不同。如果錨點在循環內,則每次循環迭代都會有一個新的動態錨點實例,並且參與這些動態錨點實例的線程集可能會以任意實現定義的方式有所不同。根據關於收斂操作的動態實例的動態規則,這意味著在每次循環迭代中,執行 @convergent.operation 的線程集可能會以任意實現定義的方式有所不同。

收斂操作可以與其錨點一起下沉。同樣以虛擬碼為例:

%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)
}

其理由是錨點內建函數具有實現定義的行為,並且下沉轉換被視為實現的一部分:下沉會將通訊線程集限制為 condition 為 true 的線程,但這在原始程式中也可能因為其他任意原因而發生。

然而,僅下沉產生 %b 的收斂操作是不正確的。這將允許 condition 為 false 的線程在 %a 處通訊,而不是在 %b 處通訊,而這是原始程式不允許的。

請注意,entry intrinsic 的行為有所不同。在以下程式碼片段中,禁止將收斂運算下沉。

%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)
}