调试信息赋值跟踪

赋值跟踪是一种替代技术,用于在 LLVM 的优化过程中跟踪变量位置调试信息。它为局部变量(或其字段)作为左值的情况提供准确的变量位置。在一些罕见且复杂的情况下,间接赋值可能会在未被跟踪的情况下被优化掉,但在其他情况下,我们会尽最大努力跟踪所有变量位置。

其核心思想是按顺序跟踪有关源代码赋值的更多信息,并保留足够的信息,以便能够推迟关于是否使用非内存位置(寄存器、常量)或内存位置的决策,直到中间端优化运行完毕。这与使用#dbg_declare#dbg_value 形成对比,后者是在早期为大多数变量做出决策,这可能导致次优的变量位置,这些位置可能不正确或不完整。

赋值跟踪的次要目标是为 LLVM 传递编写者带来最少的额外工作,并尽量减少对 LLVM 本身的干扰。

状态和用法

状态:正在进行的实验性工作。除非用于开发和测试,否则强烈建议不要启用。

在 Clang 中启用-Xclang -fexperimental-assignment-tracking

这会导致 Clang 让 LLVM 运行 declare-to-assign 传递。该传递将传统的调试记录转换为赋值跟踪元数据,并将模块标志 debug-info-assignment-tracking 设置为值 i1 true。要检查模块是否启用了赋值跟踪,请调用 isAssignmentTrackingEnabled(const Module &M)(来自 llvm/IR/DebugInfo.h)。

设计与实现

赋值标记:#dbg_assign

#dbg_value,一种传统的调试记录,标记出 IR 中变量取特定值的位置。类似地,赋值跟踪使用名为 #dbg_assign 的记录来标记赋值的位置。

为了知道在 IR 的哪个位置适合为变量使用内存位置,每个赋值标记必须以某种方式引用执行赋值的存储(如果有的话,或者多个!)。这样,在做出该选择时,就可以将存储和标记的位置一起考虑。引用存储的另一个重要好处是,我们可以构建存储<->标记的双向映射,该映射可用于查找在存储被修改时需要更新的标记。

未链接到任何指令的 #dbg_assign 标记表示执行赋值的存储已被优化掉,因此内存位置对于程序的至少一部分将无效。

以下是 #dbg_assign 签名。Value * 类型参数首先包装在 ValueAsMetadata

  #dbg_assign(Value *Value,
              DIExpression *ValueExpression,
              DILocalVariable *Variable,
              DIAssignID *ID,
              Value *Address,
              DIExpression *AddressExpression)

前三个参数看起来并表现得像 #dbg_valueID 是对存储的引用(参见下一节)。Address 是存储的目标地址,它由 AddressExpression 修改。空/未定义/中毒地址表示地址组件已被删除(内存地址不再是有效位置)。LLVM 目前在 DIExpression 中编码变量片段信息,因此作为实现上的怪癖,VariableFragmentInfo 仅包含在 ValueExpression 中。

类似存储的指令

在没有链接的 #dbg_assign 的情况下,对已知是变量的后备存储的地址的存储被认为表示对该变量的赋值。

这为我们提供了一个安全的后备方案,在 #dbg_assign 记录已被删除、存储上的 DIAssignID 附加已被删除或优化器已将曾经间接的存储(未用赋值跟踪进行跟踪)变为直接存储的情况下。

中间端:传递编写者的注意事项

非调试指令更新

克隆指令:无需执行任何新操作。克隆会自动克隆 DIAssignID 附加。多个指令可能具有相同的 DIAssignID 指令。在这种情况下,赋值被认为发生在程序中的多个位置。

移动非调试指令:无需执行任何新操作。链接到 #dbg_assign 的指令的初始 IR 位置由 #dbg_assign 的位置标记。

删除非调试指令:无需执行任何新操作。简单的 DSE 不需要任何更改;删除具有 DIAssignID 附加的指令是安全的。使用未附加到任何指令的 DIAssignID#dbg_assign 指示内存位置无效。

合并存储:在许多情况下,不需要更改,因为如果调用了 combineMetadataDIAssignID 附加会自动合并。无论如何,必须合并 DIAssignID 附加,以便新的存储链接到合并的存储链接到的所有 #dbg_assign 记录。这可以通过简单地调用辅助函数 Instruction::mergeDIAssignID 来实现。

内联存储:随着存储被内联,我们生成 #dbg_assign 记录和 DIAssignID 附加,就像存储表示源代码赋值一样,就像在前端一样。这并不完美,因为存储可能在内联之前已被移动、修改或删除,但它至少使非内联范围内的变量信息保持正确。

拆分存储:SROA 和拆分存储的传递将 #dbg_assign 记录视为与 #dbg_declare 记录类似。克隆链接到存储的 #dbg_assign 记录,更新 ValueExpression 中的 FragmentInfo,并分别为拆分的存储(和克隆的记录)提供新的 DIAssignID 附加。换句话说,将拆分的存储视为单独的赋值。对于部分 DSE(例如,缩短 memset),我们执行相同的操作,只是已死片段的 #dbg_assign 获取 Undef Address

提升 alloca 和存储/加载:#dbg_assign 记录隐式地描述了 CFG 连接处内存位置中的连接值,但在提升(或部分提升)变量后,情况并非总是如此。提升变量的传递负责在提升期间生成的生成的 PHI 之后插入 #dbg_assign 记录。mem2reg 已经必须为此(使用 #dbg_value)执行 #dbg_declare 操作。在存储没有链接记录的情况下,该存储被假定为表示存储在目标地址处的变量的赋值。

调试记录更新

移动调试记录:尽可能避免移动 #dbg_assign 记录,因为它们表示源代码级赋值,其在程序中的位置不应受优化传递的影响。

删除调试记录:无需做任何新的操作。与传统的调试记录一样,除非它不可达,否则删除#dbg_assign记录几乎总是错误的。

#dbg_assign降低到MIR

首先,只支持SelectionDAG ISel。#dbg_assign记录被降低到MIR DBG_INSTR_REF指令。在此之前,我们需要确定何时使用内存位置,以及何时必须为每个变量使用非内存位置(或无位置)。为了做出这些决定,我们运行一个标准的固定点数据流分析,该分析在每个指令处做出选择,并迭代地合并每个块的结果。

TODO 列表

由于这是一项正在进行的实验性工作,因此我们还有一些项目需要处理。

  • 如 llvm/test/DebugInfo/assignment-tracking/X86/diamond-3.ll 中所述,分析应该将逃逸调用视为未标记的存储。

  • 系统期望局部变量由局部 alloca 支持。情况并非总是如此 - 有时会将指向存储区的指针传递给函数(例如 sret、byval)。我们需要能够处理这些情况。有关示例,请参见 llvm/test/DebugInfo/Generic/assignment-tracking/track-assignments.ll 和 clang/test/CodeGen/assignment-tracking/assignment-tracking.cpp。

  • trackAssignments 尚未适用于其#dbg_declare位置被DIExpression修改的变量,例如,当变量的地址本身存储在alloca中时,#dbg_declare使用DIExpression(DW_OP_deref)。有关示例,请参见 llvm/test/DebugInfo/Generic/assignment-tracking/track-assignments.ll 中的indirectReturn以及 clang/test/CodeGen/assignment-tracking/assignment-tracking.cpp。

  • 为了解决第一个要点,我们需要能够指定内存位置可用,而无需使用DIAssignID。这是因为存储地址不是由指令计算的(它是参数值),因此我们无处放置元数据附件。为了解决这个问题,我们可能需要另一个标记记录来表示“变量的栈起始地址为 X 地址” - 类似于#dbg_declare,但它需要与#dbg_assign记录组合,以便只有当#dbg_assign记录同意时,栈起始地址才会被选为变量的位置。

  • 鉴于上述情况(一个特殊的“栈起始地址为 X”记录),以及我们只能跟踪具有固定偏移量和大小的赋值的事实,我认为我们可能可以删除地址和地址表达式部分,因为它总是可以用我们拥有的信息计算出来。