Patching STB_GNU_UNIQUE of Buggy Binaries

开源工具链里有很多陈年小 "feature", 最初由于各种原因 (例如作为 workaround) 实现了之后, 即使语义模糊或设计不合理, 也因为兼容性被留到了今天.

STB_GNU_UNIQUE 就是 ELF 中一个不太好的设计, 带来了不少语义冲突. 拥有 STB_GNU_UNIQUE binding 的符号, 即使在被用 RTLD_LOCAL 方式装载的时候, 也会拥有 global linkage. 另外它还会导致 dlclose 无效. 网上对此有很多吐槽, 例如这里, 这里.

这个 binding 最初的引入似乎是由于一些全局符号的内在状态不能重复多次, 因此把这些符号标记为 unique, 即使从多个 plugins 里装载了多次, 符号也只有一个定义. 但是另一方面, 程序也会有一些全局符号的状态必须是 local 的. 到底哪种行为是用户需要的, 编译器是不知道的. 结果是, gcc "聪明" 的自动把 template function & inline function 里的 static variable 标记为了 unique

其实 C++ 标准确实规定了这样的 variable 必须是 "single entity". 理论上说 gcc 没做错, 但这并不总是用户的预期行为, 而 C++ 标准也没提供别的办法. 如果要禁用 unique binding, 可以使用 -fno-gnu-unique 重编译, 或者暴力 patch 编译好的 ELF binary.

STB_GNU_UNIQUE 导致了 PyTorch 1.8.0 最近的一个严重 bug, 影响了所有 R-CNN 模型, torchvision / detectron2 / mmdetection 里都有用户报告. 重新编译 PyTorch 太麻烦了, 为了以后更快验证此类问题, 我就写了一个暴力 patch ELF 的脚本:

import sys
from elftools.elf.elffile import ELFFile

def process_file(filename):
with open(filename, 'rb') as f:
elffile = ELFFile(f)

dynsym = elffile.get_section_by_name('.dynsym')
dynsym_offset = dynsym.header.sh_addr
dynsym_idx = [] # addresses of Elf64_Sym
for idx, sb in enumerate(dynsym.iter_symbols()):
bind = sb.entry.st_info.bind
if "UNIQUE" in bind or "LOOS" in bind:
print("Found UNIQUE symbol: ", sb.name[:60])
dynsym_idx.append(dynsym_offset + idx * 24) # 24=sizeof(Elf64_Sym)

print(f"Patching {len(dynsym_idx)} symbols ...")
with open(filename, 'rb+') as f:
for sym_idx in dynsym_idx:
f.seek(sym_idx + 4) # 4=sizeof(st_name)
old = ord(f.read(1))
assert old // 16 == 10, hex(old) # STB_GNU_UNIQUE==10
f.seek(sym_idx + 4)
f.write(bytes([old % 16 + 2 * 16])) # STB_WEAK==2
f.write(bytes([2])) # STV_HIDDEN=2

process_file(sys.argv[1])

以上脚本把所有 STB_GNU_UNIQUE 符号的 binding 改成了 WEAK, visibility 改成了 HIDDEN. 符号表的 entry 结构可参考 /usr/include/elf.h::Elf64_Sym.

用这个脚本 patch 了一下 libtorch_cuda_{cpp,cu}.so 之后, 以上 bug 就消失了. 同时, 这样我也能够方便的确认另一个看似相关的 bug 还是跟 STB_GNU_UNIQUE 有关系.

Comments