堆疊映射與修補點在 LLVM 中的應用¶
定義¶
在本文中,我們將 “執行時期” 統稱為所有作為 LLVM 客戶端的組件,包括 LLVM IR 生成器、目標碼消費者和程式碼修補器。
堆疊映射記錄特定指令位址的 存活值
位置。這些 存活值
並非指堆疊映射中所有存活的 LLVM 值。相反地,它們僅是執行時期在該點需要存活的值。例如,它們可能是執行時期需要恢復程式執行,而與包含堆疊映射的已編譯函數無關的值。
LLVM 將堆疊映射資料發射到目標碼的指定 堆疊映射區段 中。此堆疊映射資料包含每個堆疊映射的記錄。該記錄儲存堆疊映射的指令位址,並包含每個映射值的條目。每個條目將值的位址編碼為暫存器、堆疊偏移量或常數。
修補點是一個指令位址,在此位址保留空間以在執行時期修補新的指令序列。修補點看起來很像對 LLVM 的呼叫。它們接受遵循呼叫約定的參數,並且可能會傳回值。它們也暗示了堆疊映射的生成,這允許執行時期定位修補點並找到該點 存活值
的位置。
動機¶
此功能目前處於實驗階段,但在各種設定中可能很有用,最明顯的是執行時期 (JIT) 編譯器。修補點內建函數的範例應用包括為多型方法分派實作內聯呼叫快取,或最佳化動態類型語言(如 JavaScript)中屬性的檢索。
此處記錄的內建函數目前由開放原始碼 WebKit 專案中的 JavaScript 編譯器使用,請參閱 FTL JIT,但它們旨在用於任何需要堆疊映射或程式碼修補的情況。由於這些內建函數具有實驗性質,因此不保證跨 LLVM 版本的相容性。
本文檔中描述的堆疊映射功能與 計算堆疊映射 中描述的功能是分開的。GCFunctionMetadata 提供了由 GCRoot 內建函數捕獲的指向收集堆積的指標位置,這也可以被視為 “堆疊映射”。與上面定義的堆疊映射不同,GCFunctionMetadata 堆疊映射介面沒有提供將任意類型的存活暫存器值與指令位址關聯的方式,也沒有指定結果堆疊映射的格式。此處描述的堆疊映射可能會為垃圾回收執行時期提供更豐富的資訊,但本文檔中將不討論該用法。
內建函數¶
以下兩種內建函數可用於實作堆疊映射和修補點:llvm.experimental.stackmap
和 llvm.experimental.patchpoint
。這兩種內建函數都會產生堆疊映射記錄,並且都允許某種形式的程式碼修補。它們可以獨立使用(即 llvm.experimental.patchpoint
隱式產生堆疊映射,而無需額外呼叫 llvm.experimental.stackmap
)。選擇使用哪個取決於是否需要保留空間用於程式碼修補,以及是否應根據呼叫約定降低任何內建函數參數。llvm.experimental.stackmap
不保留任何空間,也不期望任何呼叫參數。如果執行時期在堆疊映射的位址修補程式碼,它將破壞性地覆寫程式文字。這與 llvm.experimental.patchpoint
不同,後者保留空間用於就地修補,而不會覆寫周圍的程式碼。llvm.experimental.patchpoint
內建函數還根據其呼叫約定降低指定數量的參數。這允許修補的程式碼進行就地函數呼叫,而無需整理。
這些內建函數的每個實例都會在 堆疊映射區段 中產生堆疊映射記錄。該記錄包括一個 ID,允許執行時期唯一識別堆疊映射,以及從封閉函數開頭的程式碼中的偏移量。
‘llvm.experimental.stackmap
’ 內建函數¶
語法:¶
declare void
@llvm.experimental.stackmap(i64 <id>, i32 <numShadowBytes>, ...)
概述:¶
‘llvm.experimental.stackmap
’ 內建函數記錄堆疊映射中指定值的位置,而不產生任何程式碼。
運算元:¶
第一個運算元是要在堆疊映射中編碼的 ID。第二個運算元是內建函數後面的陰影位元組數。前兩個運算元應該是立即數,例如不能作為變數傳遞。隨後的變數數量運算元是要在堆疊映射中記錄位置的 存活值
。
若要將此內建函數用作基本的堆疊映射,而不支援程式碼修補,則陰影位元組數可以設定為零。
語意:¶
堆疊映射內建函數不會就地產生程式碼,除非需要 nop 來涵蓋其陰影(請參閱下文)。但是,其與函數條目的偏移量儲存在堆疊映射中。這是緊接在堆疊映射之前的指令之後的相對指令位址。
堆疊映射 ID 允許執行時期定位所需的堆疊映射記錄。LLVM 直接將此 ID 傳遞到堆疊映射記錄,而不檢查唯一性。
LLVM 保證在堆疊映射的指令偏移量之後有一個指令陰影,在此期間,基本區塊的結尾或對 llvm.experimental.stackmap
或 llvm.experimental.patchpoint
的另一次呼叫都不得發生。這允許執行時期響應從程式碼外部觸發的事件,在此點修補程式碼。堆疊映射之後的指令程式碼可能會在堆疊映射的陰影中發射,並且這些指令可能會被破壞性修補覆寫。如果沒有陰影位元組,則此破壞性修補可能會覆寫目前函數外部的程式文字或資料。我們不允許重疊的堆疊映射陰影,以便執行時期不需要考慮這種邊角情況。
例如,具有 8 位元組陰影的堆疊映射
call void @runtime()
call void (i64, i32, ...) @llvm.experimental.stackmap(i64 77, i32 8,
ptr %ptr)
%val = load i64, ptr %ptr
%add = add i64 %val, 3
ret i64 %add
可能需要一個位元組的 nop 填充
0x00 callq _runtime
0x05 nop <--- stack map address
0x06 movq (%rdi), %rax
0x07 addq $3, %rax
0x0a popq %rdx
0x0b ret <---- end of 8-byte shadow
現在,如果執行時期需要使已編譯的程式碼失效,它可能會在堆疊映射的位址修補 8 個位元組的程式碼,如下所示
0x00 callq _runtime
0x05 movl $0xffff, %rax <--- patched code at stack map address
0x0a callq *%rax <---- end of 8-byte shadow
這樣,在正常呼叫執行時期傳回後,程式碼將執行修補的呼叫,以呼叫特殊進入點,該進入點可以從堆疊映射定位的值重建堆疊框架。
‘llvm.experimental.patchpoint.*
’ 內建函數¶
語法:¶
declare void
@llvm.experimental.patchpoint.void(i64 <id>, i32 <numBytes>,
ptr <target>, i32 <numArgs>, ...)
declare i64
@llvm.experimental.patchpoint.i64(i64 <id>, i32 <numBytes>,
ptr <target>, i32 <numArgs>, ...)
概述:¶
‘llvm.experimental.patchpoint.*
’ 內建函數建立對指定 <target>
的函數呼叫,並記錄堆疊映射中指定值的位置。
運算元:¶
第一個運算元是 ID,第二個運算元是為可修補區域保留的位元組數,第三個運算元是函數的目標位址(可選為 null),第四個運算元指定以下變數運算元中有多少被視為函數呼叫參數。剩餘的變數數量運算元是要在堆疊映射中記錄位置的 存活值
。
語意:¶
修補點內建函數產生堆疊映射。如果位址不是常數 null,它也會發射對 <target>
指定的位址的函數呼叫。函數呼叫及其參數會根據內建函數呼叫站點指定的呼叫約定降低。具有非 void 傳回類型之內建函數的變體也會根據呼叫約定傳回值。
在 PowerPC 上,請注意 <target>
必須是間接呼叫預期目標的 ABI 函數指標。具體來說,在為 ELF V1 ABI 編譯時,<target>
是通常用作 C/C++ 函數指標表示形式的函數描述符位址。
請求零個修補點參數是有效的。在這種情況下,所有變數運算元的處理方式都與 llvm.experimental.stackmap.*
相同。不同之處在於,仍將保留空間用於修補,將發射呼叫,並且允許傳回值。
參數的位置通常不會記錄在堆疊映射中,因為它們已經由呼叫約定固定。剩餘的 存活值
將記錄其位置,這可能是暫存器、堆疊位置或常數。已引入一個特殊的呼叫約定,用於堆疊映射 anyregcc,它強制將參數載入暫存器,但允許動態分配這些暫存器。除了剩餘的 存活值
之外,這些參數暫存器還將在堆疊映射中記錄其暫存器位置。
修補點也會發射 nop 以涵蓋至少 <numBytes>
的指令編碼空間。因此,客戶端必須確保 <numBytes>
足以在支援的目標上編碼對目標位址的呼叫。如果呼叫目標是常數 null,則沒有最低要求。零位元組的 null 目標修補點是有效的。
執行時期可能會修補為修補點發射的程式碼,包括呼叫序列和 nop。但是,執行時期不得對 LLVM 在保留空間內發射的程式碼做任何假設。不允許部分修補。執行時期必須修補所有保留的位元組,並在必要時使用 nop 填充。
此範例顯示修補點保留 15 個位元組,其中一個參數在 $rdi 中,一個傳回值在 $rax 中,每個原生呼叫約定
%target = inttoptr i64 -281474976710654 to ptr
%val = call i64 (i64, i32, ...)
@llvm.experimental.patchpoint.i64(i64 78, i32 15,
ptr %target, i32 1, ptr %ptr)
%add = add i64 %val, 3
ret i64 %add
可能會產生
0x00 movabsq $0xffff000000000002, %r11 <--- patch point address
0x0a callq *%r11
0x0d nop
0x0e nop <--- end of reserved 15-bytes
0x0f addq $0x3, %rax
0x10 movl %rax, 8(%rsp)
請注意,不會記錄任何堆疊映射位置。如果修補的程式碼序列不需要固定到特定呼叫約定暫存器的參數,則可以使用 anyregcc
約定
%val = call anyregcc @llvm.experimental.patchpoint(i64 78, i32 15,
ptr %target, i32 1,
ptr %ptr)
堆疊映射現在指示 %ptr 參數和傳回值的位置
Stack Map: ID=78, Loc0=%r9 Loc1=%r8
修補程式碼序列現在可以使用恰好分配在 %r8 中的參數,並傳回分配在 %r9 中的值
0x00 movslq 4(%r8) %r9 <--- patched code at patch point address
0x03 nop
...
0x0e nop <--- end of reserved 15-bytes
0x0f addq $0x3, %r9
0x10 movl %r9, 8(%rsp)
堆疊映射格式¶
LLVM 模組中堆疊映射或修補點內建函數的存在強制程式碼發射建立 堆疊映射區段。此區段的格式如下
Header {
uint8 : Stack Map Version (current version is 3)
uint8 : Reserved (expected to be 0)
uint16 : Reserved (expected to be 0)
}
uint32 : NumFunctions
uint32 : NumConstants
uint32 : NumRecords
StkSizeRecord[NumFunctions] {
uint64 : Function Address
uint64 : Stack Size (or UINT64_MAX if not statically known)
uint64 : Record Count
}
Constants[NumConstants] {
uint64 : LargeConstant
}
StkMapRecord[NumRecords] {
uint64 : PatchPoint ID
uint32 : Instruction Offset
uint16 : Reserved (record flags)
uint16 : NumLocations
Location[NumLocations] {
uint8 : Register | Direct | Indirect | Constant | ConstantIndex
uint8 : Reserved (expected to be 0)
uint16 : Location Size
uint16 : Dwarf RegNum
uint16 : Reserved (expected to be 0)
int32 : Offset or SmallConstant
}
uint32 : Padding (only if required to align to 8 byte)
uint16 : Padding
uint16 : NumLiveOuts
LiveOuts[NumLiveOuts]
uint16 : Dwarf RegNum
uint8 : Reserved
uint8 : Size in Bytes
}
uint32 : Padding (only if required to align to 8 byte)
}
每個位置的第一個位元組編碼一個類型,該類型指示如何解釋 RegNum
和 Offset
欄位,如下所示
編碼 |
類型 |
值 |
描述 |
0x1 |
暫存器 |
Reg |
暫存器中的值 |
0x2 |
直接 |
Reg + Offset |
框架索引值 |
0x3 |
間接 |
[Reg + Offset] |
溢出的值 |
0x4 |
常數 |
Offset |
小常數 |
0x5 |
常數索引 |
Constants[Offset] |
大常數 |
在常見情況下,值在暫存器中可用,並且 Offset
欄位將為零。溢出到堆疊的值編碼為 間接
位置。執行時期必須從堆疊位址載入這些值,通常形式為 [BP + Offset]
。如果將 alloca
值直接傳遞到堆疊映射內建函數,則 LLVM 可以將框架索引摺疊到堆疊映射中,作為避免分配暫存器或堆疊槽的最佳化。這些框架索引將在 直接
位置中編碼,形式為 BP + Offset
。LLVM 也可以透過直接在堆疊映射中發射常數來最佳化常數,無論是在 常數
位置的 Offset
中還是在常數池中,由 常數索引
位置引用。
在每個呼叫站點,也會記錄 “liveout” 暫存器列表。這些是在堆疊映射中存活的暫存器,因此必須由執行時期儲存。當修補點內建函數與預設保留大多數暫存器作為被呼叫者儲存的呼叫約定一起使用時,這是一個重要的最佳化。
liveout 暫存器列表中的每個條目都包含 DWARF 暫存器編號和大小(以位元組為單位)。堆疊映射格式有意省略了特定的子暫存器資訊。相反,執行時期必須保守地解釋此資訊。例如,如果堆疊映射報告 %rax
處的一個位元組,則該值可能在 %al
或 %ah
中。這在實務中無關緊要,因為執行時期只會儲存 %rax
。但是,如果堆疊映射報告 %ymm0
處的 16 個位元組,則執行時期可以安全地透過僅儲存 %xmm0
來進行最佳化。
堆疊映射格式是 LLVM SVN 修訂版與執行時期之間的合約。它目前處於實驗階段,並且可能在短期內發生變化,但是最大程度地減少更新執行時期的需求非常重要。因此,堆疊映射設計的動機是簡潔性和可擴展性。表示的緊湊性是次要的,因為預期執行時期在編譯模組後立即解析資料,並以其自己的格式編碼資訊。由於執行時期控制區段的分配,因此它可以為多個模組重複使用相同的堆疊映射空間。
堆疊映射支援目前僅在 64 位元平台上實作。但是,32 位元實作應該能夠使用相同的格式,而浪費的空間微不足道。
堆疊映射區段¶
JIT 編譯器可以透過經由 LLVM C API LLVMCreateSimpleMCJITMemoryManager()
提供其自己的記憶體管理器來輕鬆存取此區段。建立記憶體管理器時,JIT 提供一個回呼:LLVMMemoryManagerAllocateDataSectionCallback()
。當 LLVM 建立此區段時,它會調用回呼並傳遞區段名稱。JIT 可以此時記錄區段的記憶體內位址,然後解析它以恢復堆疊映射資料。
對於 MachO(例如在 Darwin 上),堆疊映射區段名稱為 “__llvm_stackmaps”。區段名稱為 “__LLVM_STACKMAPS”。
對於 ELF(例如在 Linux 上),堆疊映射區段名稱為 “.llvm_stackmaps”。區段名稱為 “__LLVM_STACKMAPS”。
堆疊映射用法¶
本文檔中描述的堆疊映射支援可用於精確確定程式碼中特定位置的值的位置。LLVM 不維護這些值與任何更高等級實體之間的任何映射。執行時期必須能夠僅根據 ID、偏移量以及位置、記錄和函數的順序來解釋堆疊映射記錄,LLVM 會保留這些順序。
請注意,這與偵錯資訊的目標截然不同,後者的目標是盡最大努力追蹤每個指令中具名變數的位置。
此設計的一個重要動機是允許執行時期在執行到達與堆疊映射關聯的指令位址時徵用堆疊框架。執行時期必須能夠使用堆疊映射提供的資訊重建堆疊框架並恢復程式執行。例如,執行可能會在直譯器或相同函數的重新編譯版本中恢復。
此用法限制了 LLVM 最佳化。顯然,LLVM 不得跨堆疊映射移動儲存。但是,載入也必須保守處理。如果載入可能觸發異常,則將其提升到堆疊映射之上可能是無效的。例如,執行時期可以根據類型系統的當前狀態確定載入可以安全執行而無需類型檢查。如果在堆疊上存在載入函數的某些活動時類型系統發生更改,則載入變得不安全。執行時期可以透過立即修補當前呼叫站點和載入之間的所有堆疊映射位置來防止後續執行該載入(通常,執行時期只會修補所有堆疊映射位置以使函數失效)。如果編譯器已將載入提升到堆疊映射之上,則程式可能會在執行時期可以收回控制權之前崩潰。
為了強制執行這些語意,堆疊映射和修補點內建函數被認為可能會讀取和寫入所有記憶體。這可能會比某些客戶端期望的更限制最佳化。可以透過將呼叫站點標記為 “readonly” 來避免此限制。將來,我們也可能允許將元資料新增至內建函數呼叫以表達別名,從而允許最佳化將某些載入提升到堆疊映射之上。
直接堆疊映射條目¶
如 堆疊映射區段 中所示,直接堆疊映射位置記錄框架索引的位址。此位址本身是執行時期請求的值。這與間接位置不同,後者指的是必須從中載入請求值的堆疊位置。直接位置可以傳達 alloca 的位址,而間接位置處理暫存器溢出。
例如
entry:
%a = alloca i64...
llvm.experimental.stackmap(i64 <ID>, i32 <shadowBytes>, ptr %a)
執行時期可以在編譯後立即或之後的任何時間確定此 alloca 在堆疊上的相對位置。這與暫存器和間接位置不同,因為執行時期只能在執行到達堆疊映射的指令位址時讀取這些位置中的值。
此功能要求 LLVM 在直接被內建函數消耗時,將條目區塊 alloca 視為特殊情況。(這與 llvm.gcroot 內建函數施加的要求相同。)LLVM 轉換不得將 alloca 替換為任何介入值。執行時期只需檢查堆疊映射的位置是否為直接位置類型即可驗證這一點。
支援的架構¶
對 StackMap 生成和相關內建函數的支援需要每個後端的一些程式碼。如今,僅支援 LLVM 後端的一個子集。目前支援的架構為 X86_64、PowerPC、AArch64 和 SystemZ。