LLVM调试笔记(1) - selection结果导致二进制不一致
平时工作常常遇到一些有趣的问题, 可惜因为时间原因没能及时总结. 最近下定决心把这些问题收集起来做个记录, 以供以后有空时候回顾自己当时定位问题的思路与盲区, 总结经验与教训, 如果有空还可以补充拓展相关背景, 补齐自己的知识短板.
问题现象
这个问题是两年前刚接触编译器开发时遇到的问题, 回头来看还是挺简单的. 不过当时我还不熟悉LLVM的代码框架, 定位上还是遇到一些困难.
问题的现象是在32bit windows(MSVC)平台上与64bit linux(gcc)平台上编译同一份LLVM代码, 生成的编译器在编译同一份代码时出现汇编不一致的现象.
棘手的地方是这个现象是概率出现的, 并且windows平台在内网环境, 无法使用VS的调试器. 有同事怀疑是系统位宽的问题, 使用32bit linux平台也无法复现该问题.
解决过程
第一直觉是先打印每个PASS的结果, 确定问题最早的产生点. 打印日志后发现问题最早出现在指令选择之后, 两者区别如下:
// linux
BB1:
%1, %6 = TARGET_INST1 ...
%2 = COPY %1
%3 = add %6, 4
BB2:
%4 = TARGET_INST2 %1
%5 = TARGET_INST3 %2
// windows
BB1:
%2, %6 = TARGET_INST1 ...
%1 = COPY %2
%3 = add %6, 4
BB2:
%4 = TARGET_INST2 %1
%5 = TARGET_INST3 %2
TARGET_INST为特定架构指令, 这里略去名字. 可以看到区别主要在于指令选择后虚拟寄存器分配顺序不同(%1与%2交换了位置).
进一步打印指令选择阶段的debug info发现指令选择后的DAG没有区别:
BB1:
t1 i32, i32 = TARGET_INST1 ...
t2 = CopyToReg %1, t1
t3 = CopyToReg %2, t1
t4 = add t1, constant<i32>
BB2:
t1 = CopyFromReg t0
t2 = CopyFromReg t0
t3 = TARGET_INST2 t1
t4 = TARGET_INST3 t2
基本确定是发射指令时创建虚拟寄存器出错, 由于没有调试器, 只能通过阅读代码确定怀疑点:
void InstrEmitter::CreateVirtualRegisters(SDNode *Node,
MachineInstrBuilder &MIB,
const MCInstrDesc &II,
bool IsClone, bool IsCloned,
DenseMap<SDValue, unsigned> &VRBaseMap) {
......
// If the specific node value is only used by a CopyToReg and the dest reg
// is a vreg in the same register class, use the CopyToReg'd destination
// register instead of creating a new vreg.
if (!VRBase && !IsClone && !IsCloned)
for (SDNode *User : Node->uses()) {
if (User->getOpcode() == ISD::CopyToReg &&
User->getOperand(2).getNode() == Node &&
User->getOperand(2).getResNo() == i) {
unsigned Reg = cast<RegisterSDNode>(User->getOperand(1))->getReg();
if (Register::isVirtualRegister(Reg)) {
const TargetRegisterClass *RegRC = MRI->getRegClass(Reg);
if (RegRC == RC) {
VRBase = Reg;
MIB.addReg(VRBase, RegState::Define);
break;
}
}
}
}
......
}
即创建虚拟寄存器时有个对寄存器拷贝的优化: 如果节点的某个值只被CopyToReg使用, 则不额外创建虚拟寄存器, 而是选择copy的目的寄存器替换, 这样生成的代码可以减少一次寄存器拷贝.
这正是本问题遇到的情况: 一个节点被两个CopyToReg引用. 下一个问题是为什么uses()的返回顺序不一样? 为定位这个问题花了我不少精力打印节点的use链表与DAG图, 但是一无所获. 最后还是靠分析代码发现了蛛丝马迹.
namespace {
/// UseMemo - This class is used by SelectionDAG::ReplaceAllUsesOfValuesWith
/// to record information about a use.
struct UseMemo {
SDNode *User;
unsigned Index;
SDUse *Use;
};
/// operator< - Sort Memos by User.
bool operator<(const UseMemo &L, const UseMemo &R) {
return (intptr_t)L.User < (intptr_t)R.User;
}
} // end anonymous namespace
void SelectionDAG::ReplaceAllUsesOfValuesWith(const SDValue *From,
const SDValue *To,
unsigned Num){
......
// Read up all the uses and make records of them. This helps
// processing new uses that are introduced during the
// replacement process.
SmallVector<UseMemo, 4> Uses;
for (unsigned i = 0; i != Num; ++i) {
unsigned FromResNo = From[i].getResNo();
SDNode *FromNode = From[i].getNode();
for (SDNode::use_iterator UI = FromNode->use_begin(),
E = FromNode->use_end(); UI != E; ++UI) {
SDUse &Use = UI.getUse();
if (Use.getResNo() == FromResNo) {
UseMemo Memo = { *UI, i, &Use };
Uses.push_back(Memo);
}
}
}
// Sort the uses, so that all the uses from a given User are together.
llvm::sort(Uses);
......
}
ReplaceAllUsesOfValuesWith()接口会对节点的user做一次排序, 排序的结果存放在一个容器中. 这样在替换user时可以将来自同一user的节点放在一起, 减少CSE重复计算.
问题就出现在重排序的过程中, UseMemo重载了小于操作符. 由于这个排序的目的是为了将同一user放在一起, 实际并不关心排序结果, 所以它采取比较user指针的大小的方式.
然而比较指针的方式是不稳定的, 这于内存分配/回收状态关联的, 所以导致了问题的出现. 尝试使用其它稳定的排序方式后问题解决.
思考总结
回顾这个问题还是挺简单的, 之所以记录下来是因为它是编译器开发中少见的概率出现, 且问题原因与现象产生点不一致的问题, 定位这个问题也拓宽了我的思路.
一般编译器研究往往是确定性的问题, 出现问题往往是必现问题, 并且因为LLVM模块化特点, 代码漏洞必然导致相关PASS出现问题. 而这个问题恰恰相反, 在这种情况下怎么定位:
- 首先还是缩小问题范围, 利用编译器流式处理的特点回溯最早的现象产生点(通过日志找到最早的差异点, 注意有些差异点并不影响之后的处理, 往往这类差异点不是问题的根源).
- 在基本确定问题范围以后, 对比打印与日志, 分析关键变化点的信息(方便理解做变换/优化的原因, 有些时候问题有多种改法, 如何确定修改是最优的, 需要上下联系, 比如本问题中是否可以修改CopyToReg的逻辑不让默认取第一个user, 而是拓扑排序后的第一个user).
- 通读框架代码, 建立对应的数据模型之间的联系(比如本问题中SDUser/SDValue/SDNode的关联, 如何替换节点).
最后回头看下这个问题的触发条件, 为什么社区一直没有报这个问题? 出现这个问题需要满足若干个条件:
- 一个节点要被多个跨BB的节点引用(否则不会创建多个CopyToReg节点), 且被引用的值必须在本BB内没有user(否则不会做优化).
- 该节点要被本BB引用, 否则IR优化就把它移动到其它BB里了.
- 结合一二点, 该节点必须要是多输出的节点(否则不能满足节点在本BB有user, 但输出值又没有user).
- 内存分配的随机性导致两次运行分配的指针大小顺序不一致.
这个带来教训是写编译器代码时永远不要忽视那些边界条件, 即使如此困难的条件在我司架构下也是可以出现的.