LLVM 项目博客

LLVM 项目新闻和来自战壕的细节

使用 Bugpoint 和自定义脚本减少测试用例

LLVM 提供了许多有用的命令行工具来处理位码:opt是最广为人知的,用于对 IR 模块运行单个传递,而llc则调用后端从 IR 模块生成汇编或目标文件。不太为人所知但非常强大的是bugpoint,这是一个自动测试用例缩减工具,应该是每个开发人员工具箱的一部分。

bugpoint工具有助于缩减输入 IR 文件,同时保留一些有趣的行为,通常是编译器崩溃或错误编译。缩减测试用例涉及多种策略(指令重新排列、修改控制流等),但由于它不了解 LLVM 传递和各个后端特性的具体细节,因此“它可能看起来做了一些愚蠢的事情,或者忽略了显而易见的简化”,正如官方描述中所述。文档提供了一些关于bugpoint可能涉及的策略的见解,但细节超出了本文的范围。

继续阅读以了解如何利用 bugpoint 的强大功能来解决一些不明显的问题。

Bugpoint 接口弊端

Bugpoint 是一个强大的工具,可以用来缩减您的测试用例,但它的界面可能会导致沮丧(正如文档中所述:“bugpoint 可能是一个非常有用的工具,但它有时以不明显的方式工作”)。主要问题之一似乎是bugpoint具有讽刺意味地过于先进!它以三种模式运行,并在它们之间自动切换以解决不同类型的问题:崩溃、错误编译或代码生成(有关这些模式的更多信息,请参阅文档)。但是,预先知道哪种模式将被激活以及bugpoint实际上使用的是哪种策略并不总是很明显。

我发现,在我的大多数用例中,我并不需要处理传递顺序等高级bugpoint特性,也不需要bugpoint来检测要运行的模式并自动切换。在我的大多数用例中,compile-custom选项完全足够:类似于
git bisect,它允许您为bugpoint提供一个脚本。这个脚本对于bugpoint来说是一个黑盒子,它需要接受一个参数(要处理的位码文件),如果位码没有表现出您感兴趣的行为,则需要返回 0,否则返回非零值。Bugpoint将应用多种策略来缩减测试用例,并在每次转换后调用您的自定义脚本以验证您要查找的行为是否仍然存在。bugpoint的调用如下所示

$ ./bin/bugpoint -compile-custom -compile-command=./check.sh -opt-command=./bin/opt my_test_case.ll

重要的是两个选项-compile-custom-compile-command=path_to_script.sh,它们指示bugpoint使用您自己的脚本处理文件。另一个重要的部分是-opt-command选项,它应该指向将用于缩减测试用例的正确opt。事实上,默认情况下,bugpoint将在路径中搜索opt,并且可能使用一个旧的系统 opt,该 opt无法正确处理您的 IR,从而导致一些奇怪的错误消息

*** 调试代码生成器崩溃!
检查仅使用以下块是否存在崩溃: diamond .preheader .lr.ph .end: error: Invalid type for value
simplifycfg 失败!

考虑这样一个脚本 check.sh,以这种方式使用您的原始测试用例运行它

$ ./check.sh my_test_case.ll && echo "NON-INTERESTING" || echo "INTERESTING"

在尝试使用 bugpoint 之前,应该显示 INTERESTING,否则您可能会非常惊讶。事实上,bugpoint将该脚本视为编译命令。如果您从一个 NON-INTERESTING 测试用例开始,并将其提供给bugpoint,它将假设代码编译正确,并尝试汇编、链接和执行它以获得参考结果。这就是bugpoint在自动切换模式时可能令人困惑的地方,导致用户得到一个令人困惑的跟踪。正确的调用应该导致一个这样的跟踪

./bin/bugpoint  -compile-custom  -compile-command=./check.sh  -opt-command=./bin/opt slp.ll 
读取输入文件      : 'slp.ll'
*** 所有输入都正常
初始化执行环境:在以下位置找到命令:./check.sh
运行代码生成器以测试是否存在崩溃:
运行工具时出错
  ./check.sh bugpoint-test-program-1aa0e1d.bc
*** 调试代码生成器崩溃!

检查是否可以删除全局初始化程序:<crash>

*** 能够删除所有全局初始化程序!
检查仅使用以下块是否存在崩溃:    .lr.ph6.preheader .preheader .lr.ph.preheader .lr.ph .backedge  ._crit_edge.loopexit... <11 total>: <crash>
检查仅使用以下块是否存在崩溃: .preheader .backedge .lr.ph6.preheader:
检查仅使用以下块是否存在崩溃: .lr.ph ._crit_edge:
...
...
检查指令:  store i8 %16, i8* getelementptr inbounds ([32 x i8], [32 x i8]* @cle, i64 0, i64 15), align 1, !tbaa !2

*** 尝试执行最终清理:<crash>
将位码输出到 'bugpoint-reduced-simplified.bc'

实际上,能够编写自定义脚本非常强大,我将介绍一些我最近使用bugpoint解决的用例。

在输出中搜索字符串

我最近提交了一个补丁(http://reviews.llvm.org/D14364),用于一个循环向量化在相当简单的测试用例上没有启动的情况。在修复了底层问题后,我需要使用我的补丁提交一个测试。原始 IR 有几百行。由于我认为尽可能缩减测试用例是一个好习惯,因此 bugpoint 通常是我最好的朋友。在本例中,分析结果表明,在我应用补丁后的输出中,“内存依赖关系在运行时检查下是安全的”。

编译了带有和不带有我的补丁的 opt,并将每个版本复制到 /tmp/ 后,我编写了以下 shell 脚本


#!/bin/bash

/tmp/opt.original -loop-accesses -analyze $1 | grep "Memory dependences are safe"
res_original=$?
/tmp/opt.patched -loop-accesses -analyze $1 | grep "Memory dependences are safe"
res_patched=$?
[[ $res_original == 1 && $res_patched == 0 ]] && exit 1
exit 0 

它首先将作为参数提供给脚本的位码(上面的 $1)通过opt运行,并使用grep来检查输出中是否存在预期字符串。当grep退出时,$?包含 1(如果字符串不存在于输出中)。如果原始opt没有产生预期的分析,而新的opt产生了,那么缩减的测试用例就是有效的。

在转换产生效果时进行缩减

在另一个用例(http://reviews.llvm.org/D13996)中,我修补了 SLP 向量化器,我想缩减测试用例,以便它在我的更改之前没有向量化,而在我的更改之后向量化

#!/bin/bash
set -e

/tmp/opt.original -slp-vectorizer -S > /tmp/original.ll $1
/tmp/opt.patched -slp-vectorizer -S > /tmp/patched.ll $1
diff /tmp/original.ll /tmp/patched.ll && exit 0
exit 1

使用自定义脚本提供了灵活性,并允许运行任何复杂的逻辑来决定缩减是否有效。我过去曾使用它来缩减特定断言上的崩溃,并避免缩减导致不同的崩溃,或者缩减以跟踪指令计数回归或任何其他指标。

直接使用 FileCheck

LLVM 带有一个灵活的模式匹配文件验证器(FileCheck),测试一直在密集地使用它。您可以对原始测试用例进行注释,并编写一个脚本来为您的补丁缩减它。让我们从公共 LLVM 存储库中的一个示例开始,该示例的提交为 r252051 [SimplifyCFG] 合并条件存储。相关的测试在验证中是 test/Transforms/SimplifyCFG/merge-cond-stores.ll;并且它已经包含了我们需要的全部检查,让我们尝试缩减它。为此,您需要一次处理一个函数,否则 bugpoint 可能不会产生您期望的结果:因为检查将对一个函数失败,bugpoint 可以对另一个函数进行任何转换,并且测试仍然会被认为是“有趣的”。让我们从原始文件中提取函数 test_diamond_simple

$ ./bin/llvm-extract -func=test_diamond_simple test/Transforms/SimplifyCFG/merge-cond-stores.ll -S > /tmp/my_test_case.ll

然后检出并编译opt的修订版 r252050 和 r252051,并将它们复制到 /tmp/opt.r252050 和 /tmp/opt.r252051 中。然后,check.sh 脚本将基于原始测试用例中的CHECK

#!/bin/bash

# 处理补丁之前的测试,并使用 FileCheck 检查,
# 预计会失败。
/tmp/opt.r252050 -simplifycfg -instcombine -phi-node-folding-threshold=2 -S < $1 | ./bin/FileCheck merge-cons-stores.ll
original=$?

# 处理补丁之后的测试,并使用 FileCheck 检查,
# 预计会成功。
/tmp/opt.r252051 -simplifycfg -instcombine -phi-node-folding-threshold=2 -S < $1 | ./bin/FileCheck merge-cons-stores.ll
patched=$?

# 该测试很有趣,如果 FileCheck 在补丁之前失败,
# 并在补丁之后成功。
[[ $original != 0 && $patched == 0 ]] && exit 1
exit 0

我故意选择了一个写得很好的测试来向你展示 bugpoint 的强大功能及其局限性。例如,如果你查看我们刚刚在 my_test_case.ll 中提取的函数

; CHECK-LABEL: @test_diamond_simple
; 这应该进行 if 转换。
; CHECK: store
; CHECK-NOT: store
; CHECK: ret
define i32 @test_diamond_simple(i32%pi32%qi32 %ai32 %b) {
entry
  %x1 = icmp eq i32 %a0
  br i1 %x1label %no1label %yes1

yes1
  store i32 0i32%p
  br label %fallthrough

no1
  %z1 = add i32 %a%b
  br label %fallthrough

fallthrough
  %z2 = phi i32 [ %z1%no1 ], [ 0%yes1 ]
  %x2 = icmp eq i32 %b0
  br i1 %x2label %no2label %yes2

yes2
  store i32 1i32%p
  br label %end

no2
  %z3 = sub i32 %z2%b
  br label %end

end
  %z4 = phi i32 [ %z3%no2 ], [ 3%yes2 ]
  ret i32 %z4
}

此补丁中引入的转换允许合并 true 分支 yes1 和 yes2 中的存储。

declare void @f()

define i32 @test_diamond_simple(i32%pi32%qi32 %ai32 %b) {
entry
  %x1 = icmp eq i32 %a0
  %z1 = add i32 %a%b
  %z2 = select i1 %x1i32 %z1i32 0
  %x2 = icmp eq i32 %b0
  %z3 = sub i32 %z2%b
  %z4 = select i1 %x2i32 %z3i32 3
  %0 = or i32 %a%b
  %1 = icmp eq i32 %00
  br i1 %1label %3label %2

; <label>:2 ; preds = %entry
  %simplifycfg.merge = select i1 %x2i32 %z2i32 1
  store i32 %simplifycfg.mergei32%palign 4
  br label %3

; <label>:3 ; preds = %entry, %2
  ret i32 %z4
}

原始代码看起来非常简洁,变量和块名称明确,易于理解,你可能不会考虑对其进行简化。为了练习,让我们看看 bugpoint 在这里可以为我们做什么

define void @test_diamond_simple(i32%pi32 %b) {
entry
  br i1 undeflabel %fallthroughlabel %yes1

yes1:                  ; preds = %entry
  store i32 0i32%p
  br label %fallthrough

fallthrough:           ; preds = %yes1, %entry
  %x2 = icmp eq i32 %b0
  br i1 %x2label %endlabel %yes2

yes2:                  ; preds = %fallthrough
  store i32 1i32%p
  br label %end

yes2:                  ; preds = %yes2, %fallthrough
  ret void
}

Bugpoint 发现该测试中的 _no_ 分支是无用的,并将其删除。缺点是 bugpoint 也倾向于在某些地方引入 _undef_ 或 _unreachable_,这会导致测试更加脆弱,更难理解。

尚未完成:手动清理

在缩减结束时,测试很小,但可能还不能直接与你的补丁一起提交。可能还需要一些清理:例如,bugpoint 不会将 invoke 转换为调用,也不会删除元数据、tbaa 信息、个性化函数等。我们之前也看到,bugpoint 可以以意想不到的方式修改你的测试,添加 _undef_ 或 _unreachable_。你可能还想重命名变量,以最终得到一个易读的测试用例。

幸运的是,手头有 _check.sh_ 脚本对这个过程很有帮助,因为你可以手动修改你的测试,并连续运行相同的命令

$ ./check.sh my_test_case.ll && echo "NON-INTERESTING" || echo "INTERESTING"

虽然结果  INTERESTING  你知道你仍然有一个有效的测试,你可以继续进行你的清理工作。

请记住,bugpoint 可以做更多的事情,但希望这个子集对那些仍然难以理解其命令行选项的人有所帮助。

最后,感谢任曼曼对这篇文章的审阅。