如何更新除錯資訊:給 LLVM Pass 作者的指南

簡介

某些程式碼轉換可能會無意中導致除錯資訊遺失,或者更糟的是,讓除錯資訊錯誤地表示程式的狀態。

本文檔說明如何在各種程式碼轉換中正確更新除錯資訊,並針對如何為任意轉換建立目標除錯資訊測試提供建議。

如需 LLVM 除錯資訊背後原理的詳細資訊,請參閱 使用 LLVM 進行原始碼層級除錯

更新除錯位置的規則

何時保留指令位置

如果指令仍然在其基本區塊中,或者其基本區塊被摺疊到無條件分支的前置區塊中,則轉換應該保留指令的除錯位置。要使用的 API 是 IRBuilderInstruction::setDebugLoc

此規則的目的是確保常見的區塊內最佳化可以保留在對應於其接觸之指令的原始碼位置上設定中斷點的功能。如果失去此功能,除錯、當機記錄和 SamplePGO 準確性將會受到嚴重影響。

應該遵循此規則的轉換範例包括

  • 指令排程。區塊內指令重新排序不應捨棄原始碼位置,即使這可能會導致不穩定的單步執行行為。

  • 簡單跳躍執行緒。例如,如果區塊 B1 無條件跳轉到 B2 是其唯一的前驅,則可以將 B2 中的指令提升到 B1 中。應保留來自 B2 的原始碼位置。

  • 替換或擴展指令的窺孔優化,例如 (add X X) => (shl X 1)shl 指令的位置應與 add 指令的位置相同。

  • 尾部複製。例如,如果區塊 B1B2 都無條件分支到 B3,並且 B3 可以摺疊到其前驅中,則應保留來自 B3 的原始碼位置。

此規則*不*適用的轉換範例包括

  • LICM。例如,如果將指令從迴圈主體移動到前置標頭,則適用刪除位置的規則。

除了上述規則外,如果目標區塊已包含具有相同偵錯位置的指令,則轉換也應保留在基本區塊之間移動的指令的偵錯位置。

應該遵循此規則的轉換範例包括

  • 在基本區塊之間移動指令。例如,如果將 BB1 中的指令 I1 移動到 BB2 中的 I2 之前,則如果 I1I2 具有相同的原始碼位置,則可以保留 I1 的原始碼位置。

何時合併指令位置

如果轉換將多個指令替換為單個合併指令,並且該合併指令不對應於任何原始指令的位置,則轉換應合併指令位置。要使用的 API 是 Instruction::applyMergedLocation

此規則的目的是確保 a) 單個合併指令具有一個附加了準確範圍的位置,以及 b) 防止誤導性的單步執行(或斷點)行為。通常,合併的指令是可以捕捉的記憶體存取:附加準確的範圍可以透過識別發生錯誤記憶體存取的(可能是內聯的)函式來極大地幫助當機分類。此規則也旨在透過禁止將包含合併指令的區塊的範例錯誤地歸因於包含要合併的指令之一的區塊的情景來協助 SamplePGO。

應該遵循此規則的轉換範例包括

  • 合併 CFG 菱形兩側發生的相同載入/儲存(請參閱 MergedLoadStoreMotion 通道)。

  • 合併相同的迴圈不變儲存(請參閱 LICM 工具程式 llvm::promoteLoopAccessesToScalars)。

  • 窺孔最佳化會將多個指令合併在一起,例如 (add (mul A B) C) => llvm.fma.f32(A, B, C)。請注意,fma 的位置並不完全對應於 muladd 指令的位置。

此規則*不*適用的轉換範例包括

  • 區塊本地窺孔會刪除多餘的指令,例如 (sext (zext i8 %x to i16) to i32) => (zext i8 %x to i32)。內部的 zext 會被修改,但仍保留在其區塊中,因此應套用 保留位置 的規則。

  • 將 if-then-else CFG 菱形轉換為 select。保留推測指令的除錯位置可能會讓條件看起來像是真的,而實際上卻不是(反之亦然),這會導致單步執行體驗的混亂。這裡應套用 捨棄位置 的規則。

  • 將出現在多個後繼區塊中的相同指令提升到前驅區塊(請參閱 BranchFolder::HoistCommonCodeInSuccs)。在這種情況下,沒有單一合併的指令。這裡套用 捨棄位置 的規則。

何時捨棄指令位置

如果 保留合併 除錯位置的規則不適用,則轉換應捨棄除錯位置。要使用的 API 是 Instruction::dropLocation()

此規則的目的是防止在指令與原始程式碼位置之間沒有明確、明確關係的情況下,出現不穩定或誤導性的單步執行行為。

為了處理沒有位置的指令,DWARF 產生器預設允許標籤後最後設定的位置向前串聯,或者如果沒有可用的先前位置,則設定一個第 0 行位置以及可行的範圍資訊。

如需捨棄位置規則適用的範例,請參閱關於 合併位置 的章節中的討論。

更新除錯值的規則

刪除 IR 層級指令

刪除 Instruction 時,其除錯使用會變更為 undef。這是除錯資訊的損失:一個或多個原始程式碼變數的值變得不可用,從 #dbg_value(undef, ...) 開始。如果沒有辦法重建遺失指令的值,這是最好的結果。但是,通常可以做得更好

  • 如果垂死的指令可以被 RAUW'd,那就這樣做。Value::replaceAllUsesWith API 會透明地更新垂死指令的偵錯用途,使其指向替換值。

  • 如果垂死的指令無法被 RAUW'd,請對其呼叫 llvm::salvageDebugInfo。這會盡力嘗試透過將其效果描述為 DIExpression 來重寫垂死指令的偵錯用途。

  • 如果垂死指令的其中一個**運算元**將變成無用狀態,請使用 llvm::replaceAllDbgUsesWith 來重寫該運算元的偵錯用途。請考慮以下範例函數

define i16 @foo(i16 %a) {
  %b = sext i16 %a to i32
  %c = and i32 %b, 15
    #dbg_value(i32 %c, ...)
  %d = trunc i32 %c to i16
  ret i16 %d
}

現在,以下是不必要的截斷指令 %d 被簡化指令替換後會發生的事情

define i16 @foo(i16 %a) {
    #dbg_value(i32 undef, ...)
  %simplified = and i16 %a, 15
  ret i16 %simplified
}

請注意,在刪除 %d 之後,其運算元 %c 的所有用途都將變成無用狀態。過去指向 %c 的偵錯用途現在是 undef,並且偵錯資訊會不必要地遺失。

若要解決此問題,請執行

llvm::replaceAllDbgUsesWith(%c, theSimplifiedAndInstruction, ...)

這會產生更好的偵錯資訊,因為 %c 的偵錯用途會被保留

define i16 @foo(i16 %a) {
  %simplified = and i16 %a, 15
    #dbg_value(i16 %simplified, ...)
  ret i16 %simplified
}

您可能已經注意到 %simplified%c 窄:這不是問題,因為 llvm::replaceAllDbgUsesWith 會負責將必要的轉換操作插入已更新偵錯用途的 DIExpressions 中。

刪除 MIR 層級的 MachineInstr

TODO

更新 DIAssignID 附件的規則

DIAssignID 中繼資料附件由指派追蹤使用,這目前是一種實驗性的偵錯模式。

如需如何更新它們以及有關指派追蹤的更多資訊,請參閱 偵錯資訊指派追蹤

如何自動將測試轉換為偵錯資訊測試

針對 IR 層級轉換的突變測試

在許多情況下,可以自動變更轉換的 IR 測試案例,以測試該轉換中的偵錯資訊處理。這是測試正確偵錯資訊處理的簡單方法。

debugify 公用程式傳遞

debugify 測試公用程式只是一對傳遞:debugifycheck-debugify

第一個傳遞會將合成的偵錯資訊套用至模組的每個指令,而第二個傳遞會檢查在進行最佳化之後此 DI 是否仍然可用,並在執行此操作時報告任何錯誤/警告。

這些指示會被分配到依序遞增的行位置,並且會盡可能在所有地方立即被除錯值記錄使用。

例如,以下是執行前的模組

define void @f(i32* %x) {
entry:
  %x.addr = alloca i32*, align 8
  store i32* %x, i32** %x.addr, align 8
  %0 = load i32*, i32** %x.addr, align 8
  store i32 10, i32* %0, align 4
  ret void
}

以及執行 opt -debugify 之後的模組

define void @f(i32* %x) !dbg !6 {
entry:
  %x.addr = alloca i32*, align 8, !dbg !12
    #dbg_value(i32** %x.addr, !9, !DIExpression(), !12)
  store i32* %x, i32** %x.addr, align 8, !dbg !13
  %0 = load i32*, i32** %x.addr, align 8, !dbg !14
    #dbg_value(i32* %0, !11, !DIExpression(), !14)
  store i32 10, i32* %0, align 4, !dbg !15
  ret void, !dbg !16
}

!llvm.dbg.cu = !{!0}
!llvm.debugify = !{!3, !4}
!llvm.module.flags = !{!5}

!0 = distinct !DICompileUnit(language: DW_LANG_C, file: !1, producer: "debugify", isOptimized: true, runtimeVersion: 0, emissionKind: FullDebug, enums: !2)
!1 = !DIFile(filename: "debugify-sample.ll", directory: "/")
!2 = !{}
!3 = !{i32 5}
!4 = !{i32 2}
!5 = !{i32 2, !"Debug Info Version", i32 3}
!6 = distinct !DISubprogram(name: "f", linkageName: "f", scope: null, file: !1, line: 1, type: !7, isLocal: false, isDefinition: true, scopeLine: 1, isOptimized: true, unit: !0, retainedNodes: !8)
!7 = !DISubroutineType(types: !2)
!8 = !{!9, !11}
!9 = !DILocalVariable(name: "1", scope: !6, file: !1, line: 1, type: !10)
!10 = !DIBasicType(name: "ty64", size: 64, encoding: DW_ATE_unsigned)
!11 = !DILocalVariable(name: "2", scope: !6, file: !1, line: 3, type: !10)
!12 = !DILocation(line: 1, column: 1, scope: !6)
!13 = !DILocation(line: 2, column: 1, scope: !6)
!14 = !DILocation(line: 3, column: 1, scope: !6)
!15 = !DILocation(line: 4, column: 1, scope: !6)
!16 = !DILocation(line: 5, column: 1, scope: !6)

使用 debugify

使用 debugify 的簡單方法如下

$ opt -debugify -pass-to-test -check-debugify sample.ll

這會將合成的 DI 注入 sample.ll,執行 pass-to-test,然後檢查是否有遺漏的 DI。當然,可以省略 -check-debugify 步驟,而使用更可自訂的 FileCheck 指令。

還有其他執行 debugify 的方法

# Same as the above example.
$ opt -enable-debugify -pass-to-test sample.ll

# Suppresses verbose debugify output.
$ opt -enable-debugify -debugify-quiet -pass-to-test sample.ll

# Prepend -debugify before and append -check-debugify -strip after
# each pass on the pipeline (similar to -verify-each).
$ opt -debugify-each -O2 sample.ll

為了讓 check-debugify 能夠運作,DI 必須來自 debugify。因此,具有現有 DI 的模組將會被略過。

debugify 可用於測試後端,例如

$ opt -debugify < sample.ll | llc -o -

還有一個 MIR 級別的 debugify pass 可以在每個後端 pass 之前執行,請參閱:針對 MIR 級別轉換進行突變測試

迴歸測試中的 debugify

debugify pass 的輸出必須穩定到足以在迴歸測試中使用。不允許更改此 pass 來破壞現有的測試。

備註

迴歸測試必須穩固。避免在檢查行中硬編碼行號/變數號碼。如果無法避免這種情況(例如,如果測試不夠精確),則建議將測試移至其自己的檔案中。

測試最佳化中原始除錯資訊的保留

除了自動產生除錯資訊之外,debugify 工具 pass 提供的檢查也可以用於測試先前存在的除錯資訊中繼資料的保留。它可以按如下方式執行

# Run the pass by checking original Debug Info preservation.
$ opt -verify-debuginfo-preserve -pass-to-test sample.ll

# Check the preservation of original Debug Info after each pass.
$ opt -verify-each-debuginfo-preserve -O2 sample.ll

限制觀察函式的數量以加快分析速度

# Test up to 100 functions (per compile unit) per pass.
$ opt -verify-each-debuginfo-preserve -O2 -debugify-func-limit=100 sample.ll

請注意,在大型專案上執行 -verify-each-debuginfo-preserve 可能會非常耗時。因此,我們建議使用 -debugify-func-limit 並設定適當的限制數量,以防止構建時間過長。

此外,還有一種方法可以將發現的問題匯出到 JSON 檔案中,如下所示

$ opt -verify-debuginfo-preserve -verify-di-preserve-export=sample.json -pass-to-test sample.ll

然後使用 llvm/utils/llvm-original-di-preservation.py 腳本以更易於閱讀的形式產生包含報告問題的 HTML 頁面,如下所示

$ llvm-original-di-preservation.py sample.json sample.html

可以從前端級別叫用原始除錯資訊保留的測試,如下所示

# Test each pass.
$ clang -Xclang -fverify-debuginfo-preserve -g -O2 sample.c

# Test each pass and export the issues report into the JSON file.
$ clang -Xclang -fverify-debuginfo-preserve -Xclang -fverify-debuginfo-preserve-export=sample.json -g -O2 sample.c

請注意,對於原始碼位置和除錯記錄檢查,有一些已知的誤報,這將作為未來的改進方向。

針對 MIR 級別轉換進行突變測試

IR 級別轉換的突變測試中描述的 debugify 工具的一個變體也可以用於 MIR 級別的轉換:就像 IR 級別的過程一樣,mir-debugify 會將順序遞增的行位置插入到 Module 中的每個 MachineInstr。而 MIR 級別的 mir-check-debugify 類似於 IR 級別的 check-debugify 過程。

例如,以下是執行前的程式碼片段

name:            test
body:             |
  bb.1 (%ir-block.0):
    %0:_(s32) = IMPLICIT_DEF
    %1:_(s32) = IMPLICIT_DEF
    %2:_(s32) = G_CONSTANT i32 2
    %3:_(s32) = G_ADD %0, %2
    %4:_(s32) = G_SUB %3, %1

以及執行 llc -run-pass=mir-debugify 之後的程式碼片段

name:            test
body:             |
  bb.0 (%ir-block.0):
    %0:_(s32) = IMPLICIT_DEF debug-location !12
    DBG_VALUE %0(s32), $noreg, !9, !DIExpression(), debug-location !12
    %1:_(s32) = IMPLICIT_DEF debug-location !13
    DBG_VALUE %1(s32), $noreg, !11, !DIExpression(), debug-location !13
    %2:_(s32) = G_CONSTANT i32 2, debug-location !14
    DBG_VALUE %2(s32), $noreg, !9, !DIExpression(), debug-location !14
    %3:_(s32) = G_ADD %0, %2, debug-location !DILocation(line: 4, column: 1, scope: !6)
    DBG_VALUE %3(s32), $noreg, !9, !DIExpression(), debug-location !DILocation(line: 4, column: 1, scope: !6)
    %4:_(s32) = G_SUB %3, %1, debug-location !DILocation(line: 5, column: 1, scope: !6)
    DBG_VALUE %4(s32), $noreg, !9, !DIExpression(), debug-location !DILocation(line: 5, column: 1, scope: !6)

預設情況下,mir-debugify 會在允許的情況下無處不在地插入 DBG_VALUE 指令。特別是,每個定義暫存器的(非 PHI)機器指令之後都必須跟著一個使用該定義的 DBG_VALUE。如果指令沒有定義暫存器,但可以跟著一個偵錯指令,則 MIRDebugify 會插入一個參考常數的 DBG_VALUE。可以透過設定 -debugify-level=locations 來停用插入 DBG_VALUE

若要執行 MIRDebugify 一次,只需將 mir-debugify 插入到您的 llc 呼叫中,例如

# Before some other pass.
$ llc -run-pass=mir-debugify,other-pass ...

# After some other pass.
$ llc -run-pass=other-pass,mir-debugify ...

若要在管道中的每個過程之前執行 MIRDebugify,請使用 -debugify-and-strip-all-safe。這可以與 -start-before-start-after 結合使用。例如

$ llc -debugify-and-strip-all-safe -run-pass=... <other llc args>
$ llc -debugify-and-strip-all-safe -O1 <other llc args>

如果您想要在管道中的每個過程之後檢查它,請使用 -debugify-check-and-strip-all-safe。這也可以與 -start-before-start-after 結合使用。例如

$ llc -debugify-check-and-strip-all-safe -run-pass=... <other llc args>
$ llc -debugify-check-and-strip-all-safe -O1 <other llc args>

若要檢查測試中的所有偵錯資訊,請使用 mir-check-debugify,例如

$ llc -run-pass=mir-debugify,other-pass,mir-check-debugify

若要從測試中刪除所有偵錯資訊,請使用 mir-strip-debug,例如

$ llc -run-pass=mir-debugify,other-pass,mir-strip-debug

結合使用 mir-debugifymir-check-debugify 和/或 mir-strip-debug 來識別在存在偵錯資訊時會損壞的後端轉換可能會有幫助。例如,若要執行 AArch64 後端測試,並在 MIRDebugify 和 MIRStripDebugify 突變過程之間「夾住」所有正常過程,請執行

$ llvm-lit test/CodeGen/AArch64 -Dllc="llc -debugify-and-strip-all-safe"

使用 LostDebugLocObserver

TODO