在dsymutil中采用并行DWARF链接器
摘要
苹果的dsymutil工具用于将DWARF调试信息链接到自包含的捆绑包中,现在正在采用并行DWARF链接器来解决类型去重中的单线程瓶颈,尽管由于输出并非二进制完全相同而在验证方面面临挑战。
暂无内容
查看缓存全文
缓存时间: 2026/06/09 14:43
# 在 dsymutil 中采用并行 DWARF 链接器
来源:https://jonasdevlieghere.com/post/dsymutil-parallel-linker/
在 Apple 平台上,开发体验围绕尽可能快地完成编译-链接-调试循环而设计。就调试而言,这意味着链接器不会处理大量 DWARF 数据以将其链接到最终二进制文件中,而是将调试信息保留在目标文件中,并记录一张调试映射表,告诉调试器在哪里找到这些信息。在本地调试时,这就足够了。但如果想为崩溃报告或远程调试归档调试信息,就需要一种生成自包含包的方法。这时候`dsymutil`就派上用场了。
`dsymutil`不仅仅是 DWARF *拼接器*。它是一个优化链接器,利用单一定义规则(ODR)对编译单元间的类型进行去重。在 C++ 项目中,每个包含头文件的翻译单元都会为该头文件中定义的每个类型获得一份自己的 DWARF 副本。1 (https://jonasdevlieghere.com/post/dsymutil-parallel-linker/#fn:1)`dsymutil`识别出等价类型并只保留一个规范副本。对于大型 C++ 项目,这决定了能否满足 Mach-O 的 4GB 限制 (https://jonasdevlieghere.com/post/macho-4gb-limit/)。为了进行这些优化,`dsymutil`需要解析和语义分析 DWARF,这也是最耗时的地方。
经典的 DWARF 链接算法本质上是单线程的。大型项目的调试信息很容易达到数百 GB。为了避免一次性将所有数据加载到内存中,`dsymutil`一次处理一个编译单元并流式输出。这种约束使得并行化核心链接循环变得不简单。多年来我们进行了渐进式改进,比如并行处理架构,以及让分析和克隆阶段在单独线程上以锁步 (https://jonasdevlieghere.com/post/dsymutil-lockstep-algorithm/) 方式运行,但根本瓶颈依然存在:ODR 去重只在一个线程上完成。LLVM 中已经存在一个能够跨线程进行类型去重的并行 DWARF 链接器。构建它是一项艰巨的努力,但不幸的是,由于一些重大限制,它尚未完全达到生产就绪状态。
## 资格认证问题
`dsymutil` 面临的最大挑战一直是资格认证。当我们把 `dsymutil` 上游合并到 LLVM 时,我们通过生成逐缺陷相同的 DWARF 来对其进行资格认证。当我们重写克隆阶段以使用锁步算法时,我们也做了同样的事情。拥有二进制一致的输出意味着我们可以对两个 dSYM 执行 `diff`,从而确信某项更改真正是 NFC(无功能变更)的。
并行链接器无法产生二进制一致的输出。它并发处理编译单元,因此类型被遇到和去重的顺序是不同的。输出在语义上是相同的(或者应该是),但 DWARF 结构(因而字节)不同。这意味着以往用于认证每一次 `dsymutil` 变更的二进制兼容性方法在这里不适用。
如果没有一种方法来在语义上比较输出,我们就无法确认并行链接器生成的 DWARF 的正确性。在测试中抽查小东西相对容易,但这无法扩展到即使是中等规模的项目。真正棘手的问题只有在调试时调试器开始行为异常时才会显现。为了考虑在 `dsymutil` 中使用并行链接器,我们需要一个工具,能够具体告诉我们并行链接器的输出与经典链接器的输出有何不同。
## 语义 DWARF 差异比较
尽管 DWARF 看起来像是一个由标签和属性构成的树,但它实际上是一个有向无环图。属性可以引用树中其他部分的 DIE(调试信息条目)。一个变量引用其类型,一个类型引用其成员的类型的,一个子程序引用其参数类型,等等。比较两个 DWARF 输出意味着匹配两个图中的节点,并验证它们的属性和可达子图是否等价。
你不能通过对 `dwarfdump` 文本进行 diff 来实现这一点。偏移量不同,DIE 的顺序可能不同,交叉引用指向不同的位置。这还没有考虑对任何实际项目来说,`dwarfdump` 输出太大以至于大多数工具无法处理。你需要做的是,将比较锚定在稳定的标识符上,比如链接名称、声明坐标和类型签名,然后从那里遍历图,在结构上比较属性和子节点。
我们原型了一个语义差异比较工具 (https://github.com/llvm/llvm-project/pull/194089) ,并对 `clang` 运行它,比较经典和并行链接器的输出。在大约 500 万个 DIE 中,它识别出了大约 5 万个差异。我们尚未验证所有这些结果,并且该工具本身远非生产就绪,但这足以让我们具体了解两个链接器在哪里存在分歧。
## 确定性
采用并行链接器的最大障碍是它的非确定性。可重现的构建对于任何严肃的构建工具都是不可协商的。没有可重现性,你就无法缓存、二分验证或校验你的制品。
非确定性来自并行链接器在 ODR 去重过程中如何选择规范 DIE。当多个编译单元定义了相同的类型时,线程会竞争声明规范副本。哪个线程先到达,哪个就获胜。由于线程调度在不同运行之间变化,不同运行会选取不同的规范 DIE。
修复 (https://github.com/llvm/llvm-project/pull/194777) 为每个编译单元分配一个基于其在链接顺序中位置的优先级。当一个线程想要注册一个规范 DIE 时,它仅当优先级严格更高(即更早出现在输入中)时才会覆盖当前的规范 DIE。这保证了无论调度如何都能获得相同的规范 DIE,同时保留了并行性。
## 切换默认设置
差异比较工具是资格认证策略的一部分,但不是全部。它告诉我们两个链接器是否一致,而不是任何一个是否正确。它也是流程中自动化程度最低的部分。为了切换默认设置,我们需要一个全面的资格认证策略来确保正确性。
第一步是使用 `--linker=parallel` 运行 `dsymutil` 现有的测试套件。这些测试涵盖了特定的 DWARF 构造和边界情况,任何通过经典链接器的测试也应该通过并行链接器。让所有这些测试通过,或者理解为何不通过,是切换默认设置的前提。
第二步是针对由并行链接器生成的 dSYM 运行 LLDB 测试套件。LLDB 的测试覆盖了广泛的调试场景。默认情况下,每个测试会运行多个变体:一次使用目标文件中的 DWARF,一次使用 dSYM。这将使我们能够识别经典链接器和并行链接器之间的回归,以及目标文件中的 DWARF 和由并行链接器生成的 dSYM 之间的回归。
最后一步是使用差异比较工具手动检查经典和并行链接器为越来越大的项目生成的 dSYM 之间的差异。
这项工作已跟踪 (https://github.com/llvm/llvm-project/issues/195390) 并正在进行中。
## 性能
为了全面看待这一切:对 `clang` 运行 `dsymutil`,经典链接器大约需要 3 分钟,而并行链接器大约需要 40 秒。加速是预期的结果。并行链接器就是为了更快而设计的。这里描述的工作是为了建立实际使用它的信心。
相似文章
使用DMD自举GDC
作者描述了如何在FreeBSD上使用DMD编译器和一个小的包装程序自举GNU D编译器(GDC),挑战了GCC关于GDC必须使用GDC构建的说法。
编写可移植的ARM64汇编代码
一份关于编写可在Apple Darwin和Linux/BSD系统间移植的ARM64汇编代码的指南,涵盖ABI、符号命名和向量助记符的差异。
Zig ELF 链接器改进开发日志
新的 Zig ELF 链接器现在支持外部库和 C 源码的快速增量编译,在 x86_64 Linux 上能够实现毫秒级重建。
字节码虚拟机在意外场景中的应用 (2024)
本文探讨了字节码虚拟机的出人意料的应用,特别是Linux内核中的eBPF以及编译后二进制文件中用于调试信息的DWARF表达式。
@antirez:DS4 现已更名为 DwarfStar4,因为你可以将大量质量压缩进极小的空间……几分钟后它将……
Antirez 宣布将 DS4 更名为 DwarfStar4,并预告将采用自研 iMatrix 方案,为 128GB Mac 带来优化后的 2-bit 量化模型。