デバッグ情報の更新方法:LLVMパス作成者向けガイド

はじめに

特定の種類のコード変換によって、意図せずデバッグ情報が失われたり、さらに悪いことに、デバッグ情報がプログラムの状態を誤って表現する可能性があります。

このドキュメントでは、さまざまな種類のコード変換においてデバッグ情報を正しく更新する方法を指定し、任意の変換に対してターゲットを絞ったデバッグ情報テストを作成する方法に関する提案を示します。

LLVMデバッグ情報の背後にある考え方については、LLVMを使用したソースレベルデバッグを参照してください。

デバッグ位置の更新ルール

命令の位置を保持する場合

変換によって、命令がその基本ブロックに残っている場合、またはその基本ブロックが無条件に分岐する先行ブロックに折りたたまれる場合、命令のデバッグ位置を保持する必要があります。使用するAPIはIRBuilderまたはInstruction::setDebugLocです。

このルールの目的は、一般的なブロックローカル最適化によって、アクセスする命令に対応するソース位置にブレークポイントを設定する機能が維持されるようにすることです。その機能が失われると、デバッグ、クラッシュログ、SamplePGOの精度に深刻な影響が出ます。

このルールに従うべき変換の例を次に示します。

  • 命令スケジューリング。ブロックローカルの命令の順序変更では、ジャンプするシングルステップ動作につながる可能性があっても、ソース位置を削除するべきではありません。

  • 単純なジャンプスレッディング。たとえば、ブロックB1が無条件にB2にジャンプし、かつそれがB2の唯一の先行ブロックである場合、B2の命令をB1にホイスティングできます。B2のソース位置は保持する必要があります。

  • (add X X) => (shl X 1)のような、命令を置換または展開するピーホール最適化。shl命令の位置は、add命令の位置と同じである必要があります。

  • テール複製。たとえば、ブロックB1B2の両方が無条件にB3に分岐し、B3が先行ブロックに折りたたまれる場合、B3のソース位置は保持する必要があります。

このルールが適用されない変換の例を次に示します。

  • LICM。たとえば、命令がループ本体からプリヘッダーに移動された場合、位置の削除に関するルールが適用されます。

上記のルールに加えて、変換によって、基本ブロック間で移動される命令のデバッグ位置も、宛先ブロックに既に同一のデバッグ位置を持つ命令が含まれている場合は保持する必要があります。

このルールに従うべき変換の例を次に示します。

  • 基本ブロック間の命令の移動。たとえば、BB1の命令I1BB2I2の前に移動された場合、I1のソース位置は、I2と同じソース位置を持つ場合は保持できます。

命令の位置をマージする場合

複数の命令を1つのマージされた命令に置き換え、かつそのマージされた命令が元の命令のいずれかの位置に対応しない場合、変換によって命令の位置をマージする必要があります。使用するAPIはInstruction::applyMergedLocationです。

このルールの目的は、a) 単一のマージされた命令に正確なスコープが添付された位置があること、b) 誤解を招くシングルステップ動作(またはブレークポイント動作)を防ぐことです。多くの場合、マージされた命令はトラップする可能性のあるメモリアクセスであり、正確なスコープを添付することで、不正なメモリアクセスが発生した(おそらくインライン化された)関数を識別することにより、クラッシュの診断が大幅に容易になります。このルールは、マージされる命令のいずれかを含むブロックに、マージされた命令を含むブロックのサンプルが誤って割り当てられるシナリオを禁止することにより、SamplePGOを支援するためにも意図されています。

このルールに従うべき変換の例を次に示します。

  • CFGダイアモンドの両側で発生する同一のロード/ストアのマージ(MergedLoadStoreMotionパスを参照)。

  • 同一のループ不変ストアのマージ(LICMユーティリティllvm::promoteLoopAccessesToScalarsを参照)。

  • (add (mul A B) C) => llvm.fma.f32(A, B, C)のような、複数の命令を組み合わせるピーホール最適化。fmaの位置は、mul命令またはadd命令の位置に正確に対応するとは限りません。

このルールが適用されない変換の例を次に示します。

  • (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 に変更されます。これはデバッグ情報の損失であり、1つ以上のソース変数の値が使用できなくなります。#dbg_value(undef, ...) から始まります。削除された命令の値を再構成する方法がない場合、これが最善の結果です。しかし、多くの場合、より良い方法があります。

  • 削除される命令がRAUWできる場合は、RAUWします。Value::replaceAllUsesWith APIは、削除される命令のデバッグ使用箇所を置き換え値を指すように透過的に更新します。

  • 削除される命令がRAUWできない場合は、その命令に対して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 は、更新されたデバッグ使用箇所のDIExpressionに必要となる変換操作を挿入します。

MIRレベルのMachineInstrの削除

TODO

DIAssignID アタッチメントの更新ルール

DIAssignID メタデータのアタッチメントは、現在実験的なデバッグモードである割り当て追跡で使用されます。

デバッグ情報割り当て追跡で、それらの更新方法と割り当て追跡の詳細を確認してください。

テストをデバッグ情報テストに自動的に変換する方法

IRレベル変換の突然変異テスト

多くの場合、変換のIRテストケースは、その変換内のデバッグ情報の処理をテストするように自動的に変更できます。これは、適切なデバッグ情報の処理をテストするための簡単な方法です。

debugify ユーティリティパス

debugify テストユーティリティは、debugifycheck-debugify の2つのパスからなるペアです。

最初のパスはモジュールのすべての命令に合成デバッグ情報を適用し、2番目のパスは最適化後もこの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

これにより、sample.ll に合成DIが挿入され、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パスもあります。参照:MIRレベル変換の突然変異テスト

回帰テストにおけるdebugify

debugify パスの出力は、回帰テストで使用できるほど安定している必要があります。このパスへの変更は、既存のテストを壊すことはできません。

注記

回帰テストは堅牢である必要があります。チェック行には行番号/変数番号をハードコーディングしないでください。これらを避けられない場合(テストの精度が不十分な場合など)、テストを独自のファイルに移動することをお勧めします。

最適化における元のデバッグ情報の保持テスト

debugify ユーティリティパスによって提供されるチェックは、自動的にデバッグ情報を生成することに加えて、既存のデバッグ情報メタデータの保持をテストするためにも使用できます。次のように実行できます。

# 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-debugifyModule内の各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)マシン命令は、そのdefのDBG_VALUE 使用に続く必要があります。命令がレジスタを定義しないがデバッグ命令に続くことができる場合、MIRDebugifyは定数を参照するDBG_VALUEを挿入します。DBG_VALUEの挿入は、-debugify-level=locationsを設定することで無効にできます。

MIRDebugifyを一度実行するには、次のようにmir-debugifyllc呼び出しに挿入します。

# 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を組み合わせて使用すると、デバッグ情報が存在する場合に失敗するバックエンド変換を特定するのに役立ちます。たとえば、すべての通常のパスをMIRDebugifyとMIRStripDebugifyのミューテーションパスの間に「挟み込んだ」状態でAArch64バックエンドテストを実行するには、以下を実行します。

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

LostDebugLocObserverの使用

TODO