信任的意外反射:LLVM循环向量化器的离奇案例
"编译器复杂得难以置信。你以为C构建系统很痛苦?那只是编译器的开胃菜。"
——可能是道格拉斯·亚当斯说的
本文假设读者已了解LLVM内部机制的基本知识。我将尝试填补一些鲜为人知的细节空白,但关于LLVM的学习资源还有很多更好的选择。
问题背景
在LLVM核心组件中,我们发现了一个影响几乎为零但却极其有趣的错误编译案例。现代优化编译器中的所有复杂性真的都有必要吗?可能并非如此。
以LLVM为例——一旦深入到后端,它就像是200个编译器挤在一件风衣里。想象这样的场景:凌晨两点,经过数周调试后你终于发现问题所在。在第五杯咖啡的刺激下,你想到一个与目标架构无关的修复方案。但有个小问题——你需要联系其他公司同样超负荷工作的工程师,说服他们抽出宝贵时间,等待反馈,解决所有潜在问题,再继续等待。而如果在你无法访问的硬件上出现问题,那就真的束手无策了。
错误复现
通过以下步骤可以复现这个错误:
- 使用修复前提交编译clang(称为"stage 1"构建)
- 用新编译的clang自举构建("stage 2"构建)
- 针对AArch64构建附带ASAN和模糊测试工具的复现脚本
- 在输出中获得错误编译结果
由于有问题的Clang版本几乎立即被替换,这个stage 2的错误编译除了某些公司专门负责此类问题的人员外,几乎没人注意到。这原本是系统正常工作的表现!但作为一个痴迷此类问题的爱好者,我决定深入调查这个展现现代编译器复杂性的典型案例。
技术分析
SelectionDAG机制
当将指令降级为机器代码时,LLVM默认使用称为SelectionDAG的中间表示。正如其名,这是一个基于有向无环图的中间表示,专为指令选择设计。每个基本块都有自己的SelectionDAG。
通过可视化工具,我们可以看到SelectionDAG的几个重要阶段。以以下代码为例:
void test(int *b, long long *c) {*c = *b * 2345;
}
编译为x86_32架构时,我们观察到SelectionDAG的三个关键阶段:
- 初始转换:LLVM IR首先被转换为数据流图,其中所有节点依赖关系都表示为有向无环图的边
- 合法化阶段:将图转换为能映射到实际硬件指令的形式
- 指令选择:完成初始指令选择后,进入指令调度阶段
错误根源
通过详细的IR差异分析,我们发现SelectionDAG::getVectorShuffle
函数出现了异常:
bool Identity = true, AllSame = true;
for (int i = 0; i != NElts; ++i) {if (MaskVec[i] >= 0 && MaskVec[i] != i) Identity = false;if (MaskVec[i] != MaskVec[0]) AllSame = false;
}
当向量元素数为20时,Identity
被错误地设置为true
。这导致跳过了本应生成的向量洗牌指令,进而造成关键向量掩码数据的丢失。
深入诊断
跨平台验证
为了验证这个假设,我们进行了跨平台编译测试:
$ qemu-aarch64 ./build/stage2/bin/clang --target=arm64-apple-macos -O2 repro.cc -S -o - | sha256sum
cf9f89efb0549051409d2559441404b1e627c73f11e763c975be20fcd7fcda34 -
通过添加-filter-print-funcs
调试标志,我们能够精确捕获问题函数的行为:
-mllvm -filter-print-funcs=_ZN4llvm12SelectionDAG16getVectorShuffleENS_3EVTERKNS_5SDLocENS_7SDValueES5_NS_8ArrayRefIiEE
最小复现案例
我们创建了一个最小复现代码来演示这个问题:
int testarr[] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,16 // 第16个元素(第17个元素!)
};void do_the_thing(int *mask_vec, int n_elts) {asm volatile("":"+r"(mask_vec),"+r"(n_elts)); // 优化屏障bool identity = true;for (int i = 0; i != n_elts; ++i) {if (mask_vec[i] != i)identity = false;}printf("identity: %d, nelts: %d\n", (int)identity, n_elts);
}
当元素数量在17到23之间时,向量化器会生成特定类型的操作,导致运行时错误。
结论
这个看似无害的编译器错误实际上展示了现代编译器架构中难以察觉的深层交互问题。虽然这类错误的根本原因链通常都很深——一个pass生成的代码引发另一个pass生成特定代码,如此循环——但实际修复只需要提供正确的IR和错误的IR对比,添加测试用例即可。
这个特殊的错误可能在我心中占据特殊位置很久。虽然编译器自举过程中存在潜在错误的可能性一直存在,但在实践中极为罕见。希望读者能从这个现代编译器复杂性的典型案例中学到一些东西。当涉及如此多移动部件时,事情可能以荒谬、意想不到的方式出错。
我爱死这种问题了。
更多精彩内容 请关注我的个人公众号 公众号(办公AI智能小助手)
公众号二维码