‘信任的反思’,但这次完全是意外 | 秘密俱乐部
秘密俱乐部首页 关于
‘信任的反思’,但这次完全是意外
duk 2024年10月21日
编译器非常复杂。你简直无法相信它们有多么庞大、多么复杂、多么令人难以置信的复杂。我的意思是,你可能认为C构建系统很痛苦,但与编译器相比,它们只是小菜一碟。- 道格拉斯·亚当斯,可能
这篇博文假设你对LLVM内部有一定了解 - 我会尝试填补一些不太为人知的空白,但可能还有其他更好的资源来学习这方面的知识。
在撰写本文时,我的博客上只有另一篇文章。它描述了LLVM核心组件中一个有点无聊、容易解释的优化遗漏,并具有一些实际影响。这篇博文虽然大致遵循相同的格式,但恰恰相反:对一个基本上没有影响任何人的错误编译进行了详尽的分析。
介绍与免责声明
现代优化编译器中的所有复杂性都是必要的吗?可能不是。
以LLVM为例 - 一旦你进入后端,它就像是200个编译器挤在一件风衣里。想象一下:凌晨两点,经过几周的调试,你终于弄清楚出了什么问题。你正在喝第五杯咖啡,并且有一个与目标无关的补丁想法。只是有一个小问题 - 你必须联系其他公司过度劳累的人,说服他们给你一些极其有限的时间是值得的,等待一段时间,解决所有潜在的担忧,再等待一段时间,如果在你无法访问的硬件上出现问题,那就只能求上帝保佑了。
或者,你可以只是添加另一个if语句,ping一个同事快速进行代码审查,因为更改仅限于你的小llvm/lib/Target沙盒,然后愉快地继续。每天重复几次,现在你的Modular™框架最终会有一堆重复、复杂、不必要地依赖于目标的代码生成逻辑。
是的,相当多的复杂性是康威定律和数十年代码库不可避免的腐化的结果。话虽如此,在以(大部分)正确和高性能的方式针对数十种架构时,仍然存在大量固有的混乱。没有人会一次性对整个系统有完整、深入的了解,即使他们做到了,也会在下一次“Revert “[NFC] …"”提交时过时。
地球上的每台计算机都是一个编译器模糊测试器
我们通过信息时代才可能实现的极其详尽的测试来驯服潜在错误交互的组合爆炸。即使是一个简单的“Hello, world!”也是对编译器、链接器、运行时、操作系统、终端、任何渲染中间件(可能也在运行LLVM来编译着色器!)、显示驱动程序、底层硬件本身以及构建任何这些软件过程中使用的所有软件的可靠性测试。因此,你可以相当有信心地认为,生产编译器的发布版本,在使用其他人使用的标志和目标架构时,可能不会破坏任何东西。这并不是说没有东西漏网 - yarpgen、alive2、Csmith和类似工具就不会有一长串战利品 - 但这些工具现在也只是这个测试过程的一部分。
一个直接的推论是,错误经常被引入主线分支,即使是由经验丰富的开发人员引入,并且在这种详尽测试发生时以及人们真正关心修复它们时得到修复。无论如何,看看这个提交:
https://github.com/llvm/llvm-project/commit/c6e01627acf8591830ee1d211cff4d5388095f3d
极其重要的是要强调:这个提交者知道他们在做什么!他们很擅长自己的工作!这只是编译器和llvm-project/main的性质;事情发生了。错误编译在大约一周内被发现并修复,如果这就是全部,我们就不会在这里了。
最有趣的编译器错误
这里有一个错误。
感谢@dougall。
总结一下,发生了以下事情。
- 使用上述修复之前的提交编译clang - 这通常称为“阶段1”构建
- 使用新编译的clang引导clang - 这是“阶段2”构建
- 在针对AArch64时,使用ASAN和模糊测试工具构建附加的复现脚本
- 在输出中获得错误编译
由于Clang版本已知有错误并且几乎立即被替换,阶段2的错误编译几乎没有人注意到,除了那些受雇于公司并被支付来查看这些东西的人。这是系统按预期工作!不幸的是,我完全沉迷于这样的错误,但没有得到报酬来查看它们。我想弄清楚这里出了什么问题,因为它是现代编译器 emergent复杂性的一个很好的例子。
听到了吗?这是我接下来一周空闲时间流失的声音。 fwsssssssshhhhhhhhhhhhhhhhhhhhhhhh
有一些好消息:这是循环向量化器中的一个错误,意味着我们的阶段2编译器可能不会以无法调试的某种目标特定寄存器分配方式被破坏。情况可能并非总是如此(特别是如果涉及undef/poison),但似乎我们将在流水线的大部分目标无关部分得到一个很好的、确定性问题。
undef和poison大致是LLVM建模所有可能值集合和延迟形式的未定义行为的方式。我不会解释这是如何形式化的,也不会解释对编译器转换的影响。这变得很奇怪。请不要问。
不幸的是,也有一些坏消息:这是循环向量化器中的一个错误。向量化器可能是整个通用优化流水线中单个最针对每个目标调整的传递。这意味着我们可能很难说服编译器在没有交叉编译的平台上故意发出错误的指令序列。交叉编译并不有趣。我不想交叉编译,所以如果可能的话,我想尝试在X86上 coax编译器发出正确(错误?)的代码。
预示是一种叙事手法
目前,重要的是使用上述阶段1/阶段2可执行文件在完全相同的构建环境中复现原始错误。与此同时,让我们添加一些有用的调试选项,希望这些选项能帮助我们后续工作:
-print-after=loop-vectorize 让我们在循环向量化器传递完成后打印出IR的文本转储
-ir-dump-directory 让我们将此输出重定向到某个文件夹
这将生成大量文本文件。不过没关系,因为计算机非常快,如果我们使用SSD,不会以任何有意义的方式影响构建时间。
只需为阶段1和阶段2构建运行这组易于记忆的CMake咒语:
1
2
3
4
5
6
7
|
LLVM_DIR=$(pwd)
cmake -S llvm -B build/stage1 -G Ninja -DCMAKE_BUILD_TYPE=Release -DLLVM_ENABLE_PROJECTS=clang -DLLVM_TARGETS_TO_BUILD=AArch64
cmake --build build/stage1
cmake -S llvm -B build/stage2 -G Ninja -DCMAKE_BUILD_TYPE=Release -DLLVM_ENABLE_PROJECTS=clang -DLLVM_TARGETS_TO_BUILD=AArch64 -DCMAKE_C_COMPILER="$(realpath build/stage1/bin)/clang" -DCMAKE_CXX_COMPILER="$(realpath build/stage1/bin)/clang++" -DCMAKE_C_FLAGS="-mllvm -print-after=loop-vectorize -mllvm -ir-dump-directory=$LLVM_DIR/build/stage2/ir_dump" -DCMAKE_CXX_FLAGS="-mllvm -print-after=loop-vectorize -mllvm -ir-dump-directory=$LLVM_DIR/build/stage2/ir_dump"
cmake --build build/stage2
|
不幸的是,我没有Apple设备 - 因此,我要感谢一位拥有M3笔记本电脑的匿名朋友花时间帮助我。是时候测试了。
1
2
3
4
5
|
$ ./build/stage1/bin/clang++ --target=arm64-apple-macos -O2 -fsanitize=fuzzer-no-link -fsanitize=address repro.cc -S -o - | sha256sum
b8dd73117741b08fddb6065fb9289f861f9375b63ebab3ee67edf547ecb0c17a -
$ ./build/stage2/bin/clang++ --target=arm64-apple-macos -O2 -fsanitize=fuzzer-no-link -fsanitize=address repro.cc -S -o - | sha256sum
cf9f89efb0549051409d2559441404b1e627c73f11e763c975be20fcd7fcda34 -
|
好的,我们成功复现了错误!我们并不真正关心为什么这段代码在运行时特别会崩溃 - 它只是一个最小化的复现器 - 我们只是想确保我们能够捕捉到它。有了复现器,我们立即注意到一些奇怪的变化:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
|
输出差异 < .section __TEXT,__literal16,16byte_literals
< .p2align 4, 0x0 ; -- Begin function _ZN3re28Compiler9PostVisitEPNS_6RegexpENS_4FragES3_PS3_i
< lCPI1_0:
< .byte 255 ; 0xff
< .byte 255 ; 0xff
< .byte 255 ; 0xff
< .byte 255 ; 0xff
< .byte 255 ; 0xff
< .byte 255 ; 0xff
< .byte 255 ; 0xff
< .byte 255 ; 0xff
< .byte 8 ; 0x8
< .byte 9 ; 0x9
< .byte 10 ; 0xa
< .byte 11 ; 0xb
< .byte 12 ; 0xc
< .byte 255 ; 0xff
< .byte 255 ; 0xff
< .byte 255 ; 0xff
< lCPI1_1:
< .byte 16 ; 0x10
< .byte 17 ; 0x11
< .byte 18 ; 0x12
< .byte 19 ; 0x13
< .byte 20 ; 0x14
< .byte 21 ; 0x15
< .byte 22 ; 0x16
< .byte 23 ; 0x17
< .byte 8 ; 0x8
< .byte 9 ; 0x9
< .byte 10 ; 0xa
< .byte 11 ; 0xb
< .byte 12 ; 0xc
< .byte 29 ; 0x1d
< .byte 30 ; 0x1e
< .byte 31 ; 0x1f
< .section __TEXT,__text,regular,pure_instructions
< .globl __ZN3re28Compiler9PostVisitEPNS_6RegexpENS_4FragES3_PS3_i
---
> .globl __ZN3re28Compiler9PostVisitEPNS_6RegexpENS_4FragES3_PS3_i ; -- Begin function _ZN3re28Compiler9PostVisitEPNS_6RegexpENS_4FragES3_PS3_i
63,71c26,34
< sub sp, sp, #192
[...]
< ldr q0, [x8, lCPI1_1@PAGEOFF]
< str q0, [sp] ; 16-byte Folded Spill
< adrp x28, l___sancov_gen_.2@PAGE+5
< add x8, sp, #48
< ld1.2d { v1, v2 }, [x8] ; 32-byte Folded Reload
---
> mov w22, #4 ; =0x4
> mov w27, #2 ; =0x2
> movi.2d v0, #0000000000000000
> mov.d x8, v0[1]
> str x8, [sp]
[...]
|
这里发生了相当可疑的事情:我们丢失了一大堆看起来很像某种向量掩码的数据。很好知道!
由于我们正在诊断Clang阶段2构建中的错误编译,我们也应该同时获取已知良好的编译器文本IR版本。这只需要使用修复(Revert “…")提交运行相同的命令集。最后,我们有两组充满IR文件的文件夹,其中大部分是相同的:
1
2
3
|
$ du -sh *
2.5G ir_dump_bad
2.5G ir_dump_good
|
所有这些在以后都会有用;让我们暂时搁置它,尽量避免使用别人的计算机,因为不断唠叨别人重新编译LLVM对双方都很痛苦。
情况好转
好的,既然我们已经成功捕获了至少一些调试信息,让我们先尝试简单的方法,尽管完全知道上帝在嘲笑我们。这意味着在X86 Windows上编译到X86 windows并进行测试。理想情况下,我不需要做任何奇怪的事情来让它工作。
哈,不行。两种情况下的输出相同。
好吧,让我们试试WSL2。System-V更接近AAPCS,也许有一些奇怪的ABI东西在发生。
1
2
3
4
5
6
|
# (在X86-64 via WSL上)
$ ./build/stage1/bin/clang++ --target=arm64-apple-macos -O2 -fsanitize=fuzzer-no-link -fsanitize=address repro.cc -S -o - | sha256sum
b8dd73117741b08fddb6065fb9289f861f9375b63ebab3ee67edf547ecb0c17a -
$ ./build/stage2/bin/clang++ --target=arm64-apple-macos -O2 -fsanitize=fuzzer-no-link -fsanitize=address repro.cc -S -o - | sha256sum
b8dd73117741b08fddb6065fb9289f861f9375b63ebab3ee67edf547ecb0c17a -
|
不行。
也许STL涉及其中 - libstdc++和libc++之间的一些变化。
1
2
|
$ ./build/stage2_lcxx/bin/clang++ --target=arm64-apple-macos -O2 -fsanitize=fuzzer-no-link -fsanitize=address repro.cc -S -o - | sha256sum
b8dd73117741b08fddb6065fb9289f861f9375b63ebab3ee67edf547ecb0c17a -
|
很好。没有简单的方法。去他妈的,直接交叉编译这个东西
替代标题:我们生活在我们自己创造的/usr/include/hell-gnueabihfelmnop中
C构建系统并不有趣。有人甚至可能说它们真的、真的、真的不有趣。这有很多原因,但一个 painfully明显的例子是交叉编译。假设你安装了AArch64 sysroot和适当的CMake工具链文件,以下是如何在Linux上编译Clang以针对AArch64,并带有有用的IR调试信息:
1
|
cmake -S llvm -B build/stage2 -DCMAKE_TOOLCHAIN_FILE=/home/user/aarch64.cmake -DLLVM_ENABLE_THREADS=OFF -DCMAKE_BUILD_TYPE=Release -DLLVM_USE_LINKER=lld -DLLVM_HOST_TRIPLE=aarch64-linux-gnu -DLLVM_ENABLE_PROJECTS=clang -DLLVM_TARGETS_TO_BUILD=AArch64 -DCMAKE_C_COMPILER="$(realpath build/stage1/bin)/clang" -DCMAKE_CXX_COMPILER="$(realpath build/stage1/bin)/clang++" -DCMAKE_C_FLAGS="-fPIC -fuse-ld=lld --target=aarch64-linux-gnu -mllvm -print-after=loop-vectorize -mllvm -ir-dump-directory=$(realpath build/stage2/ir_dump)" -DCMAKE_CXX_FLAGS="-fPIC -fuse-ld=lld --target=aarch64-linux-gnu -mllvm -print-after=loop-vectorize -mllvm -ir-dump-directory=$(realpath build/stage2/ir_dump)" -DCMAKE_ASM_FLAGS="-fPIC --target=aarch64-linux-gnu" -G Ninja
|
是的,尽管有AArch64工具链,–target选项是必要的。是的,尽管有-DLLVM_USE_LINKER=lld,-fuse-ld=lld是必要的。没有理由这应该像今天这样复杂。没有。零。没有其他语言会像这样胡闹并且逃脱惩罚。
太多时间之后:
1
2
|
$ qemu-aarch64 ./build/stage2/bin/clang --target=arm64-apple-macos -O2 repro.cc -S -o - | sha256sum
cf9f89efb0549051409d2559441404b1e627c73f11e763c975be20fcd7fcda34 -
|
成功!如果阶段2编译器目标必须是Apple特定的,我想我会开始质疑我的人生选择。总结一下:
- 使用上述修复之前的提交编译clang
- 使用新编译的clang引导clang <– 并且针对AArch64
- 在针对AArch64时,使用ASAN和模糊测试工具构建附加的复现脚本
可能有一些人为的方法可以通过调整盈利性启发式来说服向量化器在X86上错误编译这个,但现在这已经足够好了。回到错误狩猎!
其中大约29,000行文本IR差异被手动检查,我们非常幸运
1
2
3
|
$ diff ir_dump_bad ir_dump_good > yeouch.diff
$ ls -lh yeouch.diff
-rw-r--r-- 1 user user 1.6M Sep 25 21:43 yeouch.diff
|
里面有很多事情发生。我将乐观地假设Clang前端没有发生任何可疑的事情,这大大减少了一部分。之后,我们手动检查剩余的任何内容,找到任何可疑的差异,然后手动检查IR转储中的函数名称,因为这些不在差异本身中。
如果问题实际上在clang中,使用-emit-llvm并检查两个阶段是否发出不同的东西,也会很容易。我可以 retroactively说它们没有。
最终,在接近底部的地方,我们发现SelectionDAG::