InAlloca 屬性的設計與使用

簡介

inalloca 屬性旨在允許取得透過記憶體以傳值方式傳遞的聚合引數之位址。 主要是,此功能是為了與 Microsoft C++ ABI 相容而需要的。 在該 ABI 下,以傳值方式傳遞的類別實例會直接建構到引數堆疊記憶體中。 在新增 inalloca 之前,LLVM 中的呼叫是不可分割的指令。 無法在第一次堆疊調整和最終控制轉移之間執行中介工作,例如物件建構。 透過 inalloca,所有在記憶體中傳遞的引數都會建模為單一 alloca,可以在呼叫之前儲存到其中。 不幸的是,這個複雜的功能帶有一大組限制,旨在限制呼叫周圍引數記憶體的生命週期。

就目前而言,建議前端和最佳化器避免產生此建構,主要是因為它強制使用基底指標。 此功能未來可能會擴展以允許一般中階最佳化,但就目前而言,它應被視為不如使用複製傳值有效率。

預期用途

以下範例是針對某些 C++ 程式碼的預期 LLVM IR 降低,該程式碼在 32 位元 Microsoft C++ ABI 中將兩個預設建構的 Foo 物件傳遞給 g

// Foo is non-trivial.
struct Foo { int a, b; Foo(); ~Foo(); Foo(const Foo &); };
void g(Foo a, Foo b);
void f() {
  g(Foo(), Foo());
}
%struct.Foo = type { i32, i32 }
declare void @Foo_ctor(%struct.Foo* %this)
declare void @Foo_dtor(%struct.Foo* %this)
declare void @g(<{ %struct.Foo, %struct.Foo }>* inalloca %memargs)

define void @f() {
entry:
  %base = call i8* @llvm.stacksave()
  %memargs = alloca <{ %struct.Foo, %struct.Foo }>
  %b = getelementptr <{ %struct.Foo, %struct.Foo }>* %memargs, i32 1
  call void @Foo_ctor(%struct.Foo* %b)

  ; If a's ctor throws, we must destruct b.
  %a = getelementptr <{ %struct.Foo, %struct.Foo }>* %memargs, i32 0
  invoke void @Foo_ctor(%struct.Foo* %a)
      to label %invoke.cont unwind %invoke.unwind

invoke.cont:
  call void @g(<{ %struct.Foo, %struct.Foo }>* inalloca %memargs)
  call void @llvm.stackrestore(i8* %base)
  ...

invoke.unwind:
  call void @Foo_dtor(%struct.Foo* %b)
  call void @llvm.stackrestore(i8* %base)
  ...
}

為了避免堆疊洩漏,前端透過呼叫 llvm.stacksave 來儲存目前的堆疊指標。 然後,它使用 alloca 分配引數堆疊空間並呼叫預設建構子。 預設建構子可能會擲回例外,因此前端必須建立落地墊。 前端必須在還原堆疊指標之前銷毀已建構的引數 b。 如果建構子沒有解開堆疊,則會呼叫 g。 在 Microsoft C++ ABI 中,g 將會銷毀其引數,然後堆疊會在 f 中還原。

設計考量

生命週期

此功能最大的設計考量是物件生命週期。 我們無法將引數建模為進入區塊中的靜態 alloca,因為所有呼叫都需要使用堆疊頂端的記憶體來傳遞引數。 我們無法在函數進入時販售指向該記憶體的指標,因為在程式碼產生之後它們將會產生別名。

禁止在引數分配和呼叫站點之間使用 allocas 的規則避免了這個問題,但它產生了一個清理問題。 清理和生命週期是透過堆疊儲存和還原呼叫來明確處理的。 在未來,我們可能會想要引入新的建構,例如 freeaafree,以清楚表明這種堆疊調整清理不如完整的堆疊儲存和還原強大。

巢狀呼叫與複製省略

我們也希望能夠支援複製省略到這些引數槽中。 這表示我們必須支援多個活動引數分配。

考慮評估

// Foo is non-trivial.
struct Foo { int a; Foo(); Foo(const &Foo); ~Foo(); };
Foo bar(Foo b);
int main() {
  bar(bar(Foo()));
}

在這種情況下,我們希望能夠將複製省略到 bar 的引數槽中。 這表示我們需要同時擁有多個作用中的呼叫框架集。 首先,我們需要為外部呼叫分配框架,以便我們可以將其作為隱藏的結構回傳指標傳遞給中間呼叫。 然後我們對中間呼叫執行相同的操作,分配框架並將其位址傳遞給 Foo 的預設建構子。 透過使用堆疊儲存和還原包裝內部 bar 的評估,我們可以擁有多個重疊的作用中呼叫框架。

被呼叫者清理呼叫慣例

另一個難題是被呼叫者清理慣例的存在。 在 Windows 上,所有方法和許多其他函數都會調整堆疊以清除用於傳遞其引數的記憶體。 從某種意義上說,這表示 allocas 會自動被呼叫清除。 但是,LLVM 將此建模為對傳遞給呼叫的所有 inalloca 值寫入 undef,而不是堆疊調整。 前端仍應還原堆疊指標以避免堆疊洩漏。

例外

也可能發生例外。 如果引數評估或複製建構擲回例外,則落地墊必須執行清理,其中包括調整堆疊指標以避免堆疊洩漏。 這表示堆疊記憶體的清理不能與呼叫本身相關聯。 需要一個單獨的 IR 級別指令,可以對引數執行獨立的清理。

效率

最終,應該可以為此建構產生有效率的程式碼。 特別是,使用 inalloca 不應需要基底指標。 如果後端可以證明 CFG 中的所有點都只有一個可能的堆疊層級,那麼它可以直接從堆疊指標定址堆疊。 雖然這尚未實作,但計畫是 inalloca 屬性不應有太大變化,但前端 IR 產生建議可能會改變。