普通视图

发现新文章,点击刷新页面。
昨天以前小猪的博客

Swift Runtime 符号在动态链接库丢失的排查之路

作者 DreamPiggy
2023年12月27日 01:49

DanceCC 是字节 Mobile Infra 的一套编译工具链的品牌名,基于 Swift.org 的工具链进行了相关定制,包括调试优化,定制 Clang 插件特性,自研 Pass 做包大小和性能优化等等。在先前的文章中均有介绍。

背景

近期,有人发来反馈,他们在接入 DanceCC 的新版本工具链时,在调整了一些库的工具链选择后(即使用 Apple 工具链还是 DanceCC 工具链),重新编译出包,发生启动 Crash,堆栈如下:

1
2
3
"Symbol not found: __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E",
"Referenced from: <42049861-CE9C-3353-ADD2-76C05302E30B> /Volumes/VOLUME/*/App.app/Frameworks/AppStorageCore.framework/AppStorageCore",
"Expected in: <4A119B38-492C-3E7C-B249-E8F49F9D5B99> /Volumes/VOLUME/*/App.app/Frameworks/EEAtomic.framework/EEAtomic"

崩溃的核心原因在于:
__ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E 这个符号找不到,引用发生在 AppStorageCore 动态链接库中,加载发生在 EEAtomic 动态链接库中

符号丢失排查

首先查看 AppStorageCore 的 Load Command,判断其递归加载的动态库(LC_LOAD_DYLIB)包含 EEAtomic 和 LKCommonsLogging,只考虑非系统库(因为该符号必定不在系统库内):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Load command 11
cmd LC_LOAD_DYLIB
cmdsize 64
name @rpath/EEAtomic.framework/EEAtomic (offset 24)
time stamp 2 Thu Jan 1 08:00:02 1970
current version 1.0.0
compatibility version 1.0.0
// ...
Load command 13
cmd LC_LOAD_DYLIB
cmdsize 80
name @rpath/LKCommonsLogging.framework/LKCommonsLogging (offset 24)
time stamp 2 Thu Jan 1 08:00:02 1970
current version 1.0.0
compatibility version 1.0.0

通过 nm 来查看符号分析:

  • EEAtomic:在 dSYM 中存在符号,为 local symbol。在二进制中符号消失(被 strip)
1
2
nm EEAtomic.framework.dSYM/Contents/Resources/DWARF/EEAtomic | grep __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E
000000000000c384 t __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E
  • LKCommonsLogging:在 dSYM 中存在符号,为 local symbol。在二进制中符号消失(被 strip)
1
2
3
nm LKCommonsLogging.framework.dSYM/Contents/Resources/DWARF/LKCommonsLogging | grep
__ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E
000000000000cab4 t __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E
  • AppStorageCore:存在 undefined symbol,需要运行时可见
1
2
nm AppStorageCore.framework/AppStorageCore | grep __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E
U __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E

即然符号在 AppStorageCore 中未定义,那么应该在其递归加载的 EEAtomic/LKCommonsLogging 中,以 T(即 global)符号暴露出来,而现在不是。导致运行时找不到该符号 dyld 报错。我们需要进一步探究源头问题。

Swift 编译器符号哪里来?

通过 Demangle 可知,这个符号是

1
swift::swift50override_conformsToProtocol(swift::TargetMetadata<swift::InProcess> const*, swift::TargetProtocolDescriptor<swift::InProcess> const*, swift::TargetWitnessTable<swift::InProcess> const* (*)(swift::TargetMetadata<swift::InProcess> const*, swift::TargetProtocolDescriptor<swift::InProcess> const*))

其存在于编译器的内置静态库 libswiftCompatibility50.a

1
2
3
4
nm /Applications/Xcode-15.0.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphoneos/libswiftCompatibility50.a | grep __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E

T __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E
U __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E

什么是 swiftCompatibility50

在 iOS 平台上,Swift Runtime 被内置于操作系统一份(在 /usr/lib/swift/libswiftCore.dylib,以及对应的 dyld shared cache 中),内置的版本取决于操作系统发行时刻。

如,在 iOS 12.4 版本上,内置的 Swift 5.0 的 Runtime,而现在的编译器是 Swift 5.9

由于 Swift 5 确认了“ ABI Stable ”的承诺,因此,Swift 编译器需要实现更新语法的 Backport 能力(比如 Concurrency,Opaque Result Type 等语言能力),有些语法会涉及到 Runtime 的更新,必然,需要对“已有的老版本 Swift Runtime ”打上补丁,提供这些老版本 Runtime 中缺少的符号和功能。

具体补丁根据复杂程度,会拆分多个编译器工具链提供的静态库,最终整体链接到 App 中。

举个例子,如果当前编译单元,用到需要 Swift 5.9+ 的运行时语法,那么编译器就需要打上这些补丁:

  • libswiftCompatibility50.a:包含了 Swift 5.0-5.1 的新增 Swfit Runtime API
  • libswiftCompatibility51.a:包含了 Swift 5.1-5.6 的新增 Swfit Runtime API
  • libswiftCompatibility56.a:包含了 Swift 5.6 到当前版本(写稿时即为 5.9)的新增 Swfit Runtime API

注意几个细节:

  1. 如果链接了低版本的.a(如50),那么一定会链接高版本的.a(51和56),低版本的.a中可能会直接依赖高版本.a的符号
  2. 不同版本的.a,如50/51/56,不会重复实现同名符号导致覆盖,每个.a提供的一堆 API 的完整实现,对齐到当前 Swift 版本(即5.9)的行为,即:
  • swift::swift_getTypeName:假设是 Swift 5.0 的新增 API,跳板会访问 __DATA,__swift50_hooks,那么它必须通过 libswiftCompatibility50.a 提供
  • swift::swift_getMangledTypeName:假设是 Swift 5.1 的新增 API,跳板会访问 __DATA,__swift51_hooks,那么它必须通过 libswiftCompatibility51.a 提供

如果接入了 Concurrency,也需要额外的运行时补丁,即:

  • libswiftCompatibilityConcurrency.a:Concurrency Backport

如果接入了 SwiftUI 等依赖@dynamicReplacement 的语法的代码,也需要额外的补丁,即:

  • libswiftCompatibilityDynamicReplacements.a:Dynamic replacement Backport

如果接入了 Swift 的 Paramters Pack 语法 each T,也需要额外的补丁,即:

  • libswiftCompatibilityPacks.a:Paramters Pack Backport

备注:傻瓜省流,当你 App 用到了 SwiftUI 框架,那么你会全部用到上述所有 6 个补丁,因为 SwiftUI 都涉及到这些😮‍💨

补丁机制怎么替换实现

Swift 编译器通过自己在二进制中定义了一个专属的 Section,用动态调用的形式来访问所有 Swift Runtime API

其中,对于 Swift Runtime 的 Hook 存在于 __DATA,__swift50_hooks(假设操作系统内置那份 Swift Runtime 版本是 5.0)
而 Swift Concurrency Backport 的 Hook 存在于 __DATA,__s55async_hook(Concurrency 自身是从 5.5 引入的,也支持补丁)

跳板会检查是否当前运行的 host 环境需要打补丁:

跳板通过 dyld API 去读取 Section 拿到函数指针,随后进行调用:

一句话总结,假设调用 swift::swift_getTypeName 这个 Swift 5.0 的 Runtime API,会进行以下逻辑(其他情形无非就是 MachO Section 和对应静态库不同罢了):

  1. 检查 swift::getOverride_swift_getTypeName 返回的函数指针
    1. swift:getOverride_swift_getTypeName 会从 __DATA,__swift50_hooks MachO Section,找到被链接进去的 libswiftCompatibility50 的符号
  2. 如果返回非空,直接调用 swift::getOverride_swift_getTypeName(App 链接的补丁实现)
  3. 如果返回空,调用 swift::swift_getTypeNameImpl(操作系统的内置实现)

从而实现了上述提到的“补丁机制”。因为通过宏,标记在所有 Swift 的 Runtime API 上,因此在编译时刻都确保支持了运行时支持补丁替换,达成了“向后兼容”。技术上实现其实很原始很简单。

编译器的魔法

那么问题来了,在工具链角度看,编译器,和链接器,是两个不同的独立工作流,在不侵入宿主业务的构建系统的前提下,“ Swift 编译器怎么样告知链接器,需要这些额外的补丁库链接到二进制中呢?”

答案是通过 LC_LINKER_OPTION,即 MachO 的一个 Load Command,允许每个 MachO 提供自己的“额外链接参数”。这个参数原本用于 Clang 社区提倡的 Auto-linking 能力,现在被 Swift 编译器也借过去。参考:深入 iOS 静态链接器(一)— ld64

举个例子,以 SwiftUI 的代码为例子,当你以最低部署版本 -target arm64-apple-ios12.0 进行编译时,编译器给 MachO 写入这些链接参数,告知给链接器:

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
Load command 44
cmd LC_LINKER_OPTION
cmdsize 40
count 1
string #1 -lswiftCompatibility50
Load command 45
cmd LC_LINKER_OPTION
cmdsize 40
count 1
string #1 -lswiftCompatibility51
Load command 46
cmd LC_LINKER_OPTION
cmdsize 56
count 1
string #1 -lswiftCompatibilityDynamicReplacements
Load command 47
cmd LC_LINKER_OPTION
cmdsize 48
count 1
string #1 -lswiftCompatibilityConcurrency
Load command 48
cmd LC_LINKER_OPTION
cmdsize 40
count 1
string #1 -lswiftCompatibility56
Load command 49
cmd LC_LINKER_OPTION
cmdsize 40
count 1
string #1 -lswiftCompatibilityPacks

是没有正确链接补丁吗?

在 DanceCC 的编译器编译下,产出的产物就是上述的 LC_LINKER_OPTION,按理说链接器会正常进行链接,发生了什么?

链接参数对比如图:

通过检查链接参数,看起来似乎没什么问题,这里存在 Library Search Path:-L/path/to/swift-5.9-dancecc.xctoolchain/usr/lib/swift/iphoneos,即指向了工具链内置的 libswiftCompatibility50.a 所在目录,那究竟是什么原因导致符号丢失?

怀疑 libswiftCompatibility50.a 差异

首先进行黑盒对比,观察行为差异

在实际编译机器上进行了如下 4 项测试:

  1. 使用 Apple Clang + Apple libswiftCompatibility50
    1. 产生符号为 T(global)
  2. 使用 DanceCC Clang + DanceCC libswiftCompatibility50
    1. 产生符号为 t(local)
  3. 使用 Apple Clang + DanceCC libswiftCompatibility50
    1. 产生的符号为 t(local)
  4. 使用 DanceCC Clang + Apple libswiftCompatibility50
    1. 产生符号为 T(global)

结果如图:

可见,发生问题的地方不在于 linker,不在于 clang 本身,而在于工具链内置的 libswiftCompatibility50.a,其 visibility 有问题!

对比 libswiftCompatibility50.a 差异

我们将 Apple Xcode 15.0 内置的产物和 DanceCC 进行对比

首先一眼从二进制大小来看,DanceCC 的产物未免有些太小,很反常。进一步反汇编查看,发现 Apple 的.a 包含了 -embed-bitcode 的 LLVM Bitcode 内容。我们需要 strip 后再次进行对比

1
2
3
4
5
6
7
8
9
10
11
12
Section
sectname __bitcode
segname __LLVM
addr 0x0000000000000160
size 0x000000000007da30
offset 1288
align 2^0 (1)
reloff 0
nreloc 0
flags 0x00000000
reserved1 0
reserved2 0

我们关注丢失的符号的 visibility,查看(参考:How to know the visibility of a symbol in an object file):

1
objdump -Ct libswiftCompatibility50.a
  • Apple:
1
0000000000000000 g     F __TEXT,__text swift::swift50override_conformsToProtocol(swift::TargetMetadata<swift::InProcess> const*, swift::TargetProtocolDescriptor<swift::InProcess> const*, swift::TargetWitnessTable<swift::InProcess> const* (*)(swift::TargetMetadata<swift::InProcess> const*, swift::TargetProtocolDescriptor<swift::InProcess> const*))
  • DanceCC:
1
0000000000000000 g     F __TEXT,__text .hidden swift::swift50override_conformsToProtocol(swift::TargetMetadata<swift::InProcess> const*, swift::TargetProtocolDescriptor<swift::InProcess> const*, swift::TargetWitnessTable<swift::InProcess> const* (*)(swift::TargetMetadata<swift::InProcess> const*, swift::TargetProtocolDescriptor<swift::InProcess> const*))

对比直观图:

初步结论

DanceCC 在生成该符号时,设置了 visibility=hidden;而苹果的该符号设置为 visibility=default

定位对应的源码

通过直接在源码仓库搜索该符号,定位到来自这里的C++代码:

可见,这里没有显式的标记 visibility,由编译器生成。那么编译器为什么“不生成 default 的 visibility 呢?”

PS:对该符号的引用出现在其插桩的 Hook 实现里(./stdlib/toolchain/Compatibility50/Overrides.cpp

调查工具链自身的构建参数

注意一个小坑点:Xcode 14(LLVM 14)的 objdump 并不会显示 external hidden symbol,只有 Xcode 15(LLVM 15)的 objdump 会显示,会干扰排查,需要使用同一份二进制进行排查。

定位到原始编译单元产物(Overrides.cpp.o)的 visibility 就是 hidden,和后续流程无关

初步怀疑是以下语法存在问题,编译器识别 visibility 时错误设置为 hidden:
__attribute__((used, section("__DATA,__swift_hooks")))

当然,更有可能是编译器 clang 传入了全局的 -fvisibility=hidden 覆盖了默认值?需要进一步排查

确认是 CI 编译插入了-fvisibility=hidden

在 CI 加入 verbose 编译后,证明和猜想一致

从上述分析可知,当前编译单元(即 swiftCompatibility Target)不应该开启修改默认的 visibility 进行编译,否则就需要源码手动声明 visibility(default)

临时 Workaround

快速绕过改问题,可以对相关库依旧保持 DanceCC 工具链,让链接器以 local symbol 的形式对每个 Swift 库链接了一份 libswiftCompatibility50.a,即 force_load 了一份,使用链接器已有参数 -Wl,-force_load_swift_libs,参考:[lld-macho] Implement -force_load_swift_libs

虽然观察到 Apple 工具链利用了Auto-linking算法,会只对 dylib 被依赖方拷贝该符号,设置为 global symbol(上述问题就是 LKCommonsLogging,nm 显示为 T),dylib 依赖方不拷贝该符号,设置为 undefined symbol(上文就是 AppStorageCore,nm 显示为 U),有点反常(像是一个依赖树,只在树的根节点真正链接了 libswiftCompatibility50.a,兄弟节点不重复静态链接),可以参考下图(Apple 总二进制只 force_load 了 2 份,DanceCC 总二进制 force_load 了 4 份)

screenshot-20231226-184955

这两种集成仅有小量二进制差异,业务 8 个 dylibs,影响较小(一个 force_load 的 libswiftCompatibility50.a 占据 10KB)

修正方式

根据目前 Apple 内置二进制的解析结果,我们一期考虑直接无脑对齐,通过源码手动标记 visibility(“default”),不影响其他编译单元的构建逻辑:

  • libswiftCompatibility50.a:源码标记错误需要更改
    0000000000000088 g O __DATA,__swift_hooks _Swift50Overrides
  • libswiftCompatibility51.a:源码标记错误需要更改
    0000000000000000 g O __DATA,__swift51_hooks _Swift51Overrides
  • libswiftCompatibility56.a:不需要改,源码标记是正确的
    0000000000000000 g O __DATA,__s_async_hook .hidden _Swift56ConcurrencyOverrides

而目前对应修正,已经贡献上游:Fix the symbol visibility in Swift compatibility lib into default instead of hidden, solve auto-linking issue and match Apple’s behavior #70627

总结

这一篇文章不仅仅介绍了具体的一个开源 Swift.org 工具链,和 Apple 闭源工具链的差异,更为重要的是介绍了关于 Swift Runtime Backport 的一些机制流程,并且介绍了一些相关的排查经验,方便工具链开发者用于追查更多类似的行为不一致问题😂。

说起来短短一年期间,DanceCC 工具链已经大大小小修复了数十例子这种行为不对齐的问题,保障了内部业务的可用性。也因此可见 Apple 在其内网维护者庞大的一套自动化验证以及私有分支。如果对这套机制有兴趣的人,可以私聊我,来让这个 Swift.org 工具链能够真正的开源出来有价值,能够在更多的场景产生贡献。

LLDB调试信息裁剪传输方案

作者 DreamPiggy
2023年4月5日 01:01

声明

此篇文章原作者就是我,版权所有。预计未来会刊登在《字节跳动终端技术》

公众号链接:

背景介绍

在如今,越来越多应用采取分布式构建系统,以及一些云IDE的兴起,在这种场景下,如何保证跨机器的编译产物,能够正常的在另一台机器进行正常的开发调试,是一个常见的问题。

传统的单机编译和链接流程中,编译器会在产物中嵌入当前编译单元的单机的路径,中间产物的路径;链接器在链接时,也会尝试写入链接器输入的所有Object File和Archive File的路径。在随后的调试器工作时,会通过读取MachO Executable的Section中,编码的调试信息和路径,以进行行断点的匹配,源码信息的展示等等能力。自然的,如果编译器或者链接器在处理时全部以当前机器的绝对路径进行编码,则跨机器的产物传输后,就不能正常的实现调试功能。

对此,大部分分布式构建解决方案提供了避免绝对路径,或者绝对路径对相对路径对映射方案,其依赖编译器或者链接器的特定参数注入,也可能会依赖dSYM Bundle这种二次链接产物来进行调试信息传输。但是前者其存在一定的项目接入成本,需要依赖其构建时所有二进制(尤其是外部引入的三方预编译好的二进制)都进行了相对路径的处理。而后者的dSYM Bundle对增量不友好,会严重影响开发-调试周期的平均耗时。

当然,解决思路有很多。我们曾经使用了分布式进行编译,单机进行链接(以保证编编码进MachO Executable的Section中的路径都在当前机器可访问),随后在调试器启动时设置Source-Map来映射预二进制的源码路径。但是在更复杂的分布式构建场景下,链接阶段也会进行分布式处理。因此,为了保障开发阶段的应用,在用户设备上也能正常安装,调试,我们提供了一系列的解决能力支持,这篇文章主要用于分享相关的解决方案思路。

OSO和dSYM Bundle

对于C/C++/Objc和Swift编译器,其会将调试信息(如编译单元的路径,函数和变量名,变量的寄存器/栈信息等),按照DWARF规范进行编码。DWARF规范得到的编码数据是二进制的,需要找到文件来实际存储。

在macOS/iOS等类UNIX系统的历史中,这个调试信息会写入到编译器输出的MachO Object File中,其中编译单元的源码路径会写入Symble Table的SO Symbol中,并编码到最终的MachO Executable中。但是这一设计会造成Debug Build的二进制过于庞大(相当于DWARF同时编码在Object File和Executable中并重复占用),对于无论是磁盘存储,还是移动应用分发这种场景都是一大痛点。

因此,在2005年,Apple的ld64链接器,不再直接编码DWARF到最终的MachO Executable中,而是引入了一个中间映射关系,称为Debug Map。其像指针一样记录了MachO Object File的路径,以及修改时间戳(防止用户重编译了Object File但是没有重链接Executable)。这样以来,调试器会从直接访问巨大二进制里的DWARF,转为先打开编译单元产物的DWARF计算偏移,随后读取,解决了这重复一倍的磁盘占用。

而这一设计类似SO Symbol指向源码路径,因此称这些MachO Object File为OSO(SO for Object)。

随之诞生的,还有dSYM Bundle。因为上述改动后,一个MachO Executable不再“内嵌”所有调试信息了,意味着你将一个MachO Executable传输到另一台机器上,需要同时带上所有的OSO,并且每一个路径都放置正确才行,和当时的很多构建流程,以及开发者的习惯不兼容。

因此,Apple开发了一套能够重新把调试信息聚合到一起的工具,也就是dsymutil。dsymutil会根据OSO的指引,打开所有的Object File解析DWARF,并修正地址偏移,去重,“链接”到最终的一个大的DWARF文件,并用MachO格式封装。这也是如今常见的分发调试信息的方式。

当然,凡事都有代价。dsymutil从工作流程上来看,就是一个类似“链接器”的工作,其也有类似的修正地址的rebase和bind动作,是严重的单进程CPU密集型应用,在大型项目中,对于上万个OSO文件,dsymutil会执行超过5分钟才可生成完毕,并且目前是不可增量的(*)。意味着就算改动1行代码,也需要额外等5分钟开销才能开始调试流程,因此主要用于最终发布阶段的调试信息分发和长期存储。

我们的解决方案

回到正题,在分布式场景下的调试能力,只有两种选择:

调试器运行在编译/链接器所在机器上

假设分布式的编译器不产生绝对路径(或者使用类似LTO的流程),我们保证链接器和调试器在同一台机器上。通过远程调试(从Remote Host启动LLDB,Attach到一个Local Process上)的能力,即可达到正确的效果,但是这存在一定的实践局限性:

  1. 调试器依赖古老的gbd-remote协议,在两个机器上以“同步单工(串行)”来传输信息,包括寄存器信息,线程信息,写入内存等等操作,其中不乏大量二进制的压缩数据,对带宽要求较高。在我们测试的大型应用上,一次的断点陷入和Frame Variable变量查看,需要传输接近100MB的信息。传统Xcode和iPhone真机调试,是通过usbmux来进行传输的,但是一旦我们使用TCP Socket替代来进行在我们测试的大型应用上,在带宽受限的环境下可能需要等待10-20秒才能完成。
  2. 各种调试指令的输入和输出,完全依赖网络传输(就算打印一个变量的值也是),导致网络间歇性中断/离线情况下会完全不可用,对云IDE场景会影响用户体验

综上,在实际的落地场景中,在测试效果达不到预期后,我们并没有沿着这条路继续探索,转而使用下文的方案。

调试器不运行在编译/链接器所在机器上

另一种场景就是,编译/链接的机器,和调试器所在机器完全分离。这部分在传统构建中,通常会采取dSYM Bundle + 二进制包来进行分发,随后进行调试的方案来处理,以保证调试产物的可迁移性。但还有痛点:

  1. 但是dSYM Bundle由于上文提到,无法实现“增量生成”,大型项目构建需要等待5分钟的生成,对于工程角度是不可接受的。
  2. OSO的设计导致其很难人工进行跨机器的传输(涉及到生成时间戳,路径收集,路径映射),且大型项目会有较多的预二进制对象文件,其为FAT Binary含有多个架构,会造成比较高的无用带宽开销

在实际的落地场景中,我们最后选择了在此方案的基础上,大幅度优化OSO的传输开销,“等待耗时”等,最终实现在大型项目中,全调试链路启动从6-8分钟(dSYM Bundle + 穿行传输),优化为2分钟(OSO + 并行传输)的优化效果。

跨机器OSO传输处理

我们的解决方案主要侧重于解决开发-调试周期的问题,因此尽量希望从整体视角来看,调试信息的传输能够更快。这可以细分为两个优化:

  1. 调试产物本身大小更小(假设带宽一定)
  2. 调试产物生成的时间更快,或者说能够“并行生成”来提前传输

OSO路径映射

上文也提到,最开始尝试了直接利用dSYM Bundle来进行产物传输,也参考了上游和业内的一些实践,包括New DWARFLinker,但是实践下来结果都不够理想。

因此,最后的落脚点放在采取OSO来存储调试信息,并进行优化。首先我们需要保证直接原封不动从编译机器A,传输OSO到用户机器B能够正常工作,根据前文的知识,首先就需要将编码OSO从绝对路径,转为相对路径。

我们尝试在不修改工具链的情况下进行调研,但是结果是令人沮丧的:

  1. ld64写入OSO时,虽然支持一个-oso_prefix参数,但是其作用是对所有OSO路径删除一个统一前缀,不能像clang编译器的-fdebug-prefix-map=A=B那样自定义替换为相对路径
  2. lldb读取解析OSO时,会当作绝对路径去读取,并没有提供直接的路径映射方案

既然没有办法直接用相对路径,我们还有另一个思路,就是通过绝对路径来进行映射(避免跨机器的前缀路径问题)。在这方面,我们同时提供了两个实现方案(供复杂系统选择):

  1. 针对能自定义DAP(Debugger Adapt Protool)的场景(云IDE场景):我们提供了一套VFS机制,能够对任意的绝对路径虚拟映射到本机的某个路径上。可以兼容Apple的LLDB.framework
    具体实践是利用fishhook,因为LLDB会以动态库形式加载到DAP进程内存,因此能够通过fishhook解决方案来重定向其文件系统的访问,从而走一层我们自己的映射表:
  2. 针对不能自定义DAP的场景(分布式构建场景):我们利用历史文章介绍过我们有自定义的LLDB.framework,在其内部集成了原生的VFS实现,能够读取和LLVM一样的vfsoverlay.yaml文件来映射目录
    具体实践是利用LLVM提供的工具类,通过settings set来设置vfsoverlay.yaml路径,提前生成好并在读取OSO相关逻辑时调用。

OSO大小优化

在解决了传输的路径映射问题之后,另一个优化重点就是如何缩小OSO的大小。我们采取了一个朴素且保守的方案:将OSO(本身是MachO Object)的所有非Debug Info相关的Segment和Section全部清空,并调整符号表和偏移量,让这个MachO Object成为“仅供调试使用的Object”。

此外,当然还有针对FAT Binary的处理,整体功能利用llvm-objcopy,我们实现了不同的裁剪策略(见下),减少了约60-70%的大小原开销。

这样设计的好处是,能够尽量减少对LLDB原生解析逻辑影响(实际LLDB仅改动1行代码),因此为了兼容性我们提供了两个不同的开关,具体行为如下:

  • –extract-oso-zero-fill:删除所有Reolcation,对所有非__DWARF Section都进行填0操作,增加压缩率,可搭配–arch
  • –extract-oso-strip:删除所有Relocation,所有非__DWARF Section都裁剪,并调整segment.filesize和segment.offset,指定segment.flags为ZERO_FILL,需要搭配内网的LLDB.framework才可以正确解读,可搭配–arch

除了裁剪以外,还自动进行了MachO Universal Binary的Slicing(保留单架构),也不用调用方自己唤起lipo(比较慢)

在大型项目的实践中,整体的OSO传输大小,从优化前的15GB左右,优化为最终的10GB大小,减少幅度高达1/3(取决于项目的预二进制的对象文件多少)

OSO并行传输

解决了OSO的传输的大小开销后,我们又产生了另一个优化方案:现有的流程提取OSO依赖链接器链接完成,但是实际上,OSO是编译器产出的结果,链接器仅仅做的是“收集并写入路径”。我们能不能自己做一个“仿造链接器”来完成一样的能力,达到并行提前裁剪和传输OSO呢?

答案是肯定的,我们利用Apple开源的ld64代码,结合一些构建系统提供的Build System监控(如Bazel的BEP,Xcode的XCBBuildService),在链接阶段开始的瞬间,并行唤起我们的仿造链接器进程,处理裁剪OSO和触发传输的逻辑。
screenshot-20231226-185102

在这样的优化之后,原始需要串行等待的2分钟(链接时间)+2分钟(传输OSO时间),被优化为纯粹的2分钟,优化幅度高达50%。

总结

现代构建系统和工具链的日益不断的结合,我们会越来越多涉及到这种类似双向配合才能达到的收益。在这个方案中,我们介绍了如何让调试器,与构建系统的分布式处理,能够协调合一,达到接近本地单机调试的开发体验(但是拥有更高的编译/链接构建速度)。

DanceCC工具链也会后续在更多领域,如编译器、链接器、调试器、LLVM子工具上进行更多的尝试,提供针对移动平台的全套解决方案。
引用链接

  1. Apple’s Linker & Deterministic Builds:milen.me — Apple’s Linker & Deterministic Builds
  2. Apple’s Lazy DWARF Scheme:https://wiki.dwarfstd.org/Apple's_%22Lazy%22_DWARF_Scheme.md
  3. gdb-remote:https://developer.apple.com/library/archive/documentation/DeveloperTools/gdb/gdb/gdb_33.html
  4. ld64:https://github.com/apple-opensource/ld64
  5. LLVM VFS:https://llvm.org/doxygen/classllvm_1_1vfs_1_1OverlayFileSystem.html

DanceCC工具链 Swift调试性能的优化方案

作者 DreamPiggy
2022年5月8日 01:13

声明

此篇文章在字节跳动的技术公众号已经刊登:《字节跳动DanceCC工具链系列之Swift调试性能的优化方案》

原作者是我自己(李卓立 @dreampiggy)而非抄袭,这里在个人博客同时转发一下,去掉了招聘相关文案。不过依旧欢迎大家有兴趣的有志之士加入。

背景

通常来说,大型Swift项目常含有大量混编(Objc/C/C++甚至是Rust)代码,含有超过100个以上的Swift Module,并可能同时包含二进制部分和源码部分。而这种大型项目在目前的Xcode 13体验下非常不好,经常存在类似“断点陷入后变量面板卡顿转菊花”、“显示变量失效”等问题。而且一直存在于多个历史Xcode版本。

图1:Xcode变量区显示卡顿转菊花,测试使用Xcode 13.3和下文提到的复现Demo

这部分Apple Team迟迟不优化的原因在于,Apple公司的内部项目和外部项目开发模式的巨大差异。Apple内部产品,如系统应用,系统库,会直接内嵌到iOS固件中,并直接受益于dyld shared cache(参考WWDC 2017-App Startup Time: Past, Present, and Future[1])来提升加载速度。这意味着他们通常会将一个App,拆分为一个薄的主二进制,搭载以相当多的动态链接库(Dynamic Framework),以及插件(PlugIn)的模式来进行开发。

举个例子,我们以iOS的消息App(MobileSMS.app)为例子,使用iOS 15.4模拟器测试。可以看到其主二进制大小仅有844KB(x86_64架构)。通过otool -L查询链接,可以看到总计动态链接了22个动态链接库,其中有9个是非公开的,大都是支撑消息App的功能库,这些库占据了大量存储。

图2:消息App的动态链接库列表

而iOS平台的第三方开发者的工程,为了追求更快的冷启动时长,由于没有了dyld shared cache的优化(dyld 3提出的启动闭包只能优化非冷启动),很多项目会使用尽量少的动态链接库。加之开源社区的CocoaPods,Carthage,SwiftPM等包管理器的盛行导致的Swift Module爆炸增长,预二进制的Framework/XCFramework包装格式的滥用,加之闭源三方公司的SDK的集成,最终形成了一个无论是体积还是符号量都非常巨大的主二进制,以及相当长的Search Paths。

以公司内飞书应用的内测版为例子,在使用Debug,Onone模式编译,不剥离(Strip)任何符号情况下,可以看到其主二进制大小为1.1GB,动态链接库数量为105,但是仅包含Apple的系统库和Swift标准库。业务代码以静态链接库集成。

图3:公司飞书应用的动态链接库列表

上述这两种不同的工程结构,带来了非常显著的调试体验的差异,并且Apple公司近年来的Xcode Team和Debugger Team优化,并没有完全考虑部分第三方开发者常使用的,厚主二进制下的工程结构。

PS:理论上可以通过业务的工程结构的改造,在本地开发模式下,使用一个动态链接库包裹基础静态链接库的方式,减少主二进制大小(也会减少后续提到的DWARF搜索的耗时),但是大型项目推进工程结构的改造会是一个非常漫长的过程。

图4:一种减少主二进制大小的工程结构设计

解决方案:自定义LLDB工具链

经过调研,我们发现业界常见做法,无外乎这几种思路:

  1. 工程改造:缩减Swift Module/Search Path数量:可行,但是收益较低,且不可能无限制缩减
  2. 通过LLDB一些开关:可行,但是内部测试下依旧达不到理想的调试状态

我们致力于在字节跳动的移动端提供基础能力支持,因此提出了一套解决方案,不依赖业务工程结构的改造,而是从LLDB工具链上入手,提供定向的调试性能优化。

调研期间也确认到,借助自定义LLDB工具链,集成到Xcode IDE是完全可行的,包括iPhone模拟器、真机以及Mac应用。

图5:自定义LLDB工具链的文件结构,系列后续文章会单独讲解,这里不展开

而LLVM/LLDB本身的工具链代码,在Apple的开源范畴之内(仓库地址:https://github.com/apple/llvm-project) 通过严格追踪跟进上游的发布历史,分支模型,能够尽可能地保证工具链的代码和功能的一致性。

实际收益

经过后文提到的一系列优化手段,以公司内大型项目飞书测试,编译器采取Swift 5.6,Xcode选择13.3为例,对比调试性能:

项目 Xcode 13.3 自定义LLDB
v耗时 2分钟 40秒
po耗时 1分钟 5秒
p耗时 20秒 5秒

图6:切换自定义LLDB工具链

图7:调试优化演示,使用Xcode 13.3自定义LLDB,运行文中提到的耗时Demo(原po耗时约1分钟):

简述po/p/v的工作流程

在介绍我们自定义LLDB工具链的优化之前,首先来简述一下LLDB的核心调试场景的工作流程,方便后续理解优化的技术点。

我们一期的目的是主要优化核心的调试场景,包括最常见的“断点陷入到Xcode左侧变量区展示完毕”(v),“点击Show Description”(po),“勾选Show Types”(p)。这些对应LLDB原生的下面三个交互命令。

图8:LLDB的交互命令

Apple在WWDC 2019-LLDB: Beyond “po”[2]中,进行了较为详细的介绍,这里我们进一步详细解释其部分工作流程,为后文的具体优化技术点提供参考。建议可以搭配视频一并学习。

po [expr]

po是命令expression --object-description -- [expr]的alias

图9:po的流程

  1. 使用Swift编译器编译result = expr得到IR
1
2
3
4
// 精简版,实际较为复杂,源代码搜@LLDBDebuggerFunction关键字
func __lldb_expr() {
__lldb_result = expr
}
  1. 执行IR代码

    1. 在支持JIT的平台上使用JIT,不支持则使用LLVM的IRInterpreter
  2. 获取执行结果

  3. 使用Swift编译器编译result.description

    1. 实际上LLDB调用的是Swift标准库的私有方法:_DebuggerSupport.stringForPrintObject[3]
  4. 执行IR代码

  5. 获取执行结果字符串

  6. 对得到的字符串进行格式化输出

p [expr]

p是命令expression -- [expr]的alias

图10:p的流程

  1. 使用Swift编译器编译result = expr得到IR

  2. 执行IR代码

  3. 获取执行结果

  4. result进行Dynamic Type Resolve

    1. 利用Swift编译器提供的remoteAST,拥有源码的AST之后,会根据内存布局直接读取对象细节
    2. 也会利用Swift Reflection,即Mirror来进行读取,和remoteAST二选一
  5. 对得到的对象细节进行格式化输出

对比下来可以看到,po和p的最大不同点,在于表达式执行的结果,如何获取变量的描述这一点上。po会直接利用运行时的object description(支持CustomDebugStringConvertible[4]协议)拿到的字符串直接展示,并不真正了解对象细节。

图11:获取Object Description的实现细节(SwiftLanguageRuntime.cpp)

而p使用了Swift Runtime(Objc的话就是ISA,Method List那些,资料很多不赘述),拿到了对象细节(支持CustomReflectable[5]协议),进行按层遍历打印。不过值得注意的是,Swift Runtime依赖remoteAST(需要源码AST,即swiftmodule)或者Reflection(可能被Strip掉,并不一定有),意味着它强绑定了,编译时的Swift版本和调试时的LLDB的版本(牢记这一点)。并不像Objc那样有一个成熟稳定运行时,不依赖编译器也能动态得知任意的对象细节。

图12:Swift Dynamic Type Resolve的实现(SwiftLanguageRuntimeDynamicTypeResolution.cpp)

v [expr]

v是命令frame variable [expr]的alias

图13:v的流程

  1. 获取程序运行状态(寄存器/内存等)
  2. 递归开始
  3. 解释expr的每一层访问(->或者.),得知当前变量的内存布局
  4. 对当前变量进行Dynamic Type Resolve
  5. 递归结束
  6. 对得到的对象细节格式化输出

v的特点在于全程没有注入任何代码到程序中,也就是它是理论无副作用的。它的expr只支持访问对象的表达式(->/.等),不支持函数调用,并不是真正的C++/C/OC/Swift语法。

优化v

下述所有说明基于发稿日的Swift 5.6(优化思路也适配Swift 5.5)说明优化方案,后续不排除Apple或者LLVM上游进行其他优化替代,具有一定时效性。

(暂时)关闭swift-typeref-system

  • 关闭方式
1
settings set symbols.use-swift-typeref-typesystem false
  • 开关说明

Prefer Swift Remote Mirrors over Remote AST

这里的remoteAST和Swift Mirror的概念,上文介绍过,不同方案会影响Swift的Dynamic Type Resolve的性能。

经过实测,关闭之后,内部项目的复杂场景下,断点陷入耗时从原本的2分20秒,缩减为1分钟。这部分开关,目前已经通过Xcode自定义的LLDBInit[6]文件,在多个项目中设置。

注:和Apple同事沟通后,swift-typeref-typesystem是团队20年提出的新方案,目前有一些已知的性能问题,但是对Swift变量和类型展示有更好的兼容性。关闭以后会导致诸如,typealias的变量在p/v时展示会有差异,比如TimeInterval(alias为__C.Double)等。待Apple后续优化之后,建议恢复开启状态。

修复静态链接库错误地使用dlopen(Fixed in Swift 5.7)

简述问题:LLDB在SwiftASTContext::LoadOneModule时假设所有framework包装格式都是动态链接库,忽略了静态链接库的可能性。

在调试测试工程中,我们追踪日志发现,LLDB会尝试使用dlopen去加载静态链接库(Static Framework),这是很不符合预期的一点,因为对一个静态链接库进行dlopen是必定失败的,如日志所示(使用下文提到的复现Demo):

1
2
3
4
SwiftASTContextForExpressions::LoadOneModule() -- Couldn't import module AAStub: Failed to load linked library AAStub of module AAStub - errors:
Looking for "@rpath/AAStub.framework/AAStub", error: dlopen failed for unknown reasons.
Failed to find framework for "AAStub" looking along paths:
// ...

查看代码阅读发现,这里触发的时机是,LLDB在执行Swift变量Dynamic Type Resolve之前,因为需要激活remoteAST,需要加载源码对应的swiftmodule到内存中。

swiftmodule是编译器序列化的包含了AST的LLVM Bitcode[7]。除了AST之外,还有很多Metadata,如编译器版本,编译时刻的参数,Search Paths等(通过编译器参数-serialize-debugging-options记录)。另外,对Swift代码中出现的import语句,也会记录一条加载模块依赖。而主二进制在编译时会记录所有子模块的递归依赖。

LLDB在进行加载模块依赖时,会根据编译器得到的Search Paths,拼接上当前的Module Name,然后遍历进行dlopen。涉及较高的时间开销:N个Module,M个Search Path,复杂度O(NxM)(内部项目为400x1000数量级)。而在执行前。并未检测当前被加载的路径是否真正是一个动态链接库,最终产生了这个错误的开销。

  • 修复方案

我们的修复方案一期是进行了一次File Signature判定,只对动态链接库进行dlopen,在内部工程测试(约总计1000个Framework Search Path,400个Module)情况下,一举可以减少大约1分钟的额外开销。

  • 复现Demo

仓库地址:https://github.com/PRESIDENT810/slowDebugTest

这个Demo构造了100个Swift Static Framework,每个Module有100个编译单元,以此模拟复杂场景。

后文的一些测试数据优化,会反复提及这个Demo对比。

注:和Apple的同事沟通后,发现可以在上层进行来源区分:只有通过expression import UIKit这种用户交互输入的Module会进行dlopen检查,以支持调试期间注入外部动态库;其他情况统一不执行,因为这些模块的符号必然已经在当前被调试进程的内存中了。

Apple修复的PR:https://github.com/apple/llvm-project/pull/4077 预计在Swift 5.7上车

优化po/p

(暂时)关闭swift-dwarfimporter

  • 关闭方式
1
settings set symbols.use-swift-dwarfimporter false
  • 开关说明
1
Reconstruct Clang module dependencies from DWARF when debugging Swift code

这个开关的作用是,在开启情况下,Swift编译器遇到clang type(如C/C++/Objc)导入到Swift时,允许通过一个自定义代理实现,来从DWARF中读取类型信息,而不是借助编译器使用clang precompiled module[8],即pcm,以及ClangImporter导入桥接类型。

切换以后可能部分clang type的类型解析并不会很精确(比如Apple系统库的那种overlay framework,用原生Swift类型覆盖了同名C类型),但是能稍微加速解析速度,这是因为clang pcm和DWARF的解析实现差异。

禁用之后,对内部项目测试工程部分场景有正向提升约10秒,如果遇到问题建议保持默认的true。

优化External Module的查找路径逻辑

在混编工程中,Swift Module依赖一个C/OC的clang module是非常常见的事情。在这种情况下,LLDB需要同时使用编译器,加载到对应的clang module到内存中,用于进行C/OC Type到Swift Type的导入逻辑。

但是实际情况下,我们可能有一些Swift混编产物,是预二进制的产物,在非当前机器中进行的编译。这种情况下,对应编译器记录的的External Module的路径很可能是在当前机器找不到的。

LLDB的原始逻辑,会针对每一个可能的路径,分别由它的4种ObjectFile插件(为了支持不同的二进制格式)依次进行判断。每个ObjectFile插件会各自通过文件IO读取和解析Header。这是非常大的开销。

  • 优化方案

我们内部采取的策略比较激进,除了直接利用fstat进行前置的判断(而不是分别交给4个ObjectFile插件总计判断4次)外,还针对Mac机器的路径进行了一些特殊路径匹配规则,这里举个例子:

比如说,Mac电脑的编译产物绝对路径,一定是以/Users/${whoami}开头,所以我们可以先尝试获取当前调试器进程的uname(非常快且LLDB进程周期内不会变化),如果不匹配,说明编译产物一定不是在当前设备进行上产出的,直接跳过。

图14:特殊匹配规则,直接避免文件IO判定存在与否

通过这一项优化,在内部项目测试下(1000多个External Module路径,其中800+无效路径),可以减少首次变量显示v耗时约30秒。

增加共享的symbols缓存

我们使用内部项目进行性能Profile时,发现Module::FindTypesSymbolFile::FindTypes函数耗时调用占了主要的大头。这个函数的功能是通过DWARF(记录于Mach-O结构中),查找一个符号字符串是否包含在内。耗时主要是在需要进行一次性DWARF的解析,以及每次查找的section遍历。

LLDB本身是存在一个searched_symbol_files参数用来缓存,但是问题在于,这份缓存并不是存在于一个全局共享池中,而是在每个具体调用处的临时堆栈上。一旦调用方结束了调用,这份缓存会被直接丢弃。

图15:symbols缓存参数

  • 优化方案

我们在这里引入了一个共享的symbols缓存,保存了这份访问记录来避免多个不同调用方依然搜索到同一个符号,以空间换时间。实现方案比较简单。

内部工程实测,下来可以减少10-20秒的第一次访问开销,而每个symbol缓存占据字节约为8KB,一次调试周期约10万个符号占据800MB,对于Mac设备这种有虚拟内存的设备来说,内存压力不算很大。另外,也提供了关闭的开关。

优化不必要的同名symbols查找

另一项优化Module::FindTypesSymbolFile::FindTypes函数开销的方案是,原始的这两个函数会返回所有匹配到的列表,原因在于C++/Rust/Swift等支持重载的语言,会使用naming mangle来区分同一个函数名的不同类型的变种。这些符号名称会以同样的demangled name,记录到DWARF中。

但是调用方可能会关心同名类型的具体的变种(甚至包括是const还是非const),甚至有很多地方只取了第一个符号,搜索全部的Symbol File其实是一种浪费(在Swift 5.6版本中找到累积约10处调用只取了第一个)

  • 优化方案

我们对上述Module::FindTypesSymbolFile::FindTypes函数,提供了一个新的参数match_callback,用于提前过滤所需要的具体类型。类似于很多语言标准库提供sort函数中的stop参数。这样,如果只需要第一个找到的符号就可以提前终止搜索,而需要全部符号列表不受影响。

图16:symbols查找筛选参数

内部项目测试这项优化以后,可以减少C++/C/OC类型导入到Swift类型这种场景下,约5-10秒的第一次查找耗时。

其他优化

定向优化Dynamic Type Resolve的一些特例

在实际项目测试中,我们发现,Dynamic Type Resolve是有一些特例可以进行针对性的shortcut优化,剔除无用开销的。这部分优化仅对特定代码场景有效,并不通用。这里仅列举部分思路

  • 优化Core Foundation类型的Dynamic Type Resolve

Core Foundation类型(后文以CF类型指代),是Apple的诸多底层系统库的支撑。Objc的Founadtion的NS前缀的很多类型,也会Toll-Free Bridging[9]到CF类型上。而Swift也针对部分常用的CF类型支持了Briding。

CF类型的特点是,它内存布局类似Objc的Class ISA,但是又不是真正的Objc Class或者Swift imported Type,ISA固定是__NSCFType

而目前LLDB遇到在Swift堆栈中出现的CF类型,依旧把它当作标准的clang type进行C++/C那一套解析,还会递归寻找父类ivar,比较费时。我们可以利用这一特点提前判定而跳过无用的父类查找。

图17:筛选CF类型

这一项优化在特定场景(如使用CoreText和CoreVideo库和Swift混编)下,可以优化10-20秒的每次Dynamic Type Resolve耗时。

接下来

我们在之后会有一系列的相关话题,包括:

  • Xcode 13.3导致部分项目po提示Couldn’t realize type of self,有什么解决办法?
  • 如何极速构建,分发自定义LLVM/LLDB工具链,来让用户无缝部署?
  • 如何进行调试性能指标的监控和建设,包括Xcode原生的LLDB?

另外,这篇文章提到的非定制的优化和功能,均会向Apple或LLVM上游提交Patches,以回馈社区。

总结

这篇文章讲解了,大型Swift项目如何通过开关,以及自定义LLDB,优化Swift开发同学的调试速度,提高整体的研发效能。其中讲解了LLDB的部分工作流程,以及针对性优化的技术细节,以及实际效果。

我们的优化目标,不仅仅是服务于字节跳动移动端内部,更希望能推动业界的Swift和LLVM结合领域的相关发展,交流更多工具链方向的优化建设。

鸣谢

感谢飞书基础技术团队提供的一系列技术支持,以及最终业务试点提供的帮助推广。
感谢Apple同事Adrian Prantl在GitHub和邮件上进行的交流反馈,协助定位问题。

引用链接

  1. https://developer.apple.com/videos/play/wwdc2017/413/
  2. https://developer.apple.com/videos/play/wwdc2019/429/
  3. https://github.com/apple/swift/blob/release/5.6/stdlib/public/core/DebuggerSupport.swift#L242
  4. https://developer.apple.com/documentation/swift/customdebugstringconvertible
  5. https://developer.apple.com/documentation/swift/customreflectable
  6. https://lldb.llvm.org/man/lldb.html#configuration-files
  7. https://llvm.org/docs/BitCodeFormat.html
  8. https://clang.llvm.org/docs/Modules.html#id20
  9. https://developer.apple.com/library/archive/documentation/General/Conceptual/CocoaEncyclopedia/Toll-FreeBridgin/Toll-FreeBridgin.html

Xcode LLDB耗时监控统计方案

作者 DreamPiggy
2022年9月8日 04:12

声明

此篇文章在字节跳动的技术公众号已经刊登:《字节跳动DanceCC工具链系列之Xcode LLDB耗时监控统计方案》

背景介绍

《Swift 调试性能的优化方案 》一文中,我们介绍了如何使用自定义的工具链,来针对性优化调试器的性能,解决大型Swift项目的调试痛点。

在经过内部项目的接入以及一段时间的试用之后,为了精确测量经过优化后的LLDB调试Xcode项目效率提升效果,衡量项目收益,需要开发一套能够同时获取Xcode官方工具链与DanceCC工具链调试耗时的耗时监控方案。

一般来说,LLDB内置的工作耗时,可以通过输入log timers dump来获取粗略的累计耗时,但是这个耗时只包括了源代码中插入了LLDB_SCOPED_TIMER()宏的函数,并不代表完整的真实耗时。并且这个耗时统计需要用户手动触发,如果要单独获取某次操作的耗时还需要先进行reset操作清空之前的耗时记录;对于我们目前的需求而言不够精确也不够自动。

因此DanceCC提出了一套专门的方案。方案原理基于LLDB Plugin,利用Fishhook,从LLDB的Script Bridge API层面拦截Xcode对LLDB调用,以此来进行耗时监控统计。

注:LLDB论坛也有贡献者,讨论另一套内置的LLDB metries方案,但是目标侧重点和我们略有不同,并且截至发稿日未有完整的结论,因此仅在引用链接提及供读者延伸阅读。

方案原理

LLDB Plugin

Apple在其LLDB和早期Xcode集成中,为了不侵入一些容易改动的上层逻辑,引入了LLDB Plugin的设计和支持。

每个Plugin是一个动态链接库,需要实现特定的C++/C入口函数,由LLDB主进程在运行时通过dladdr找到函数入口并加载进内存。目前有两种Plugin的接口形式(网上常见第一种)

  • 新Plugin接口:
1
2
3
namespace lldb {
bool PluginInitialize(SBDebugger debugger);
}

这种Plugin,需要用户在脚本中手动按需加载,并常驻在内存中:
plugin load /path/to/plugin.dylib

  • 老Plugin接口:
1
2
extern "C" bool LLDBPluginInitialize(void);
extern "C" void LLDBPluginTerminate(void);

将编译的动态库放入以下两个目录,即可自动被加载,无法手动控制时机,在当前调试Session结束时卸载:

1
2
/path/to/LLDB.framework/Resources/Plugins
~/Library/Application Support/LLDB/PlugIns

注入动态库

1

正常流程中,Xcode开始调试时会启动一个lldb-rpc-server的进程,这个进程会加载Xcode默认工具链,或指定工具链中的LLDB.framework,并且通过这个动态库中暴露出的Script Bridge API调用LLDB的各功能。

2

监控流程中,我们向lldbinit文件中添加了command script import ~/.dancecc/dancecc_lldb.py,用于在LLDB启动时加载脚本,脚本内会执行plugin load ~/.dancecc/libLLDBStatistics.dylib,加载监控动态库。

监控动态库在被加载时,因为被加载的动态库和LLDB.framework不在一个MachO Image中,我们能够通过Fishhook方案,对LLDB.framework暴露出的我们关心的Script Bridge API进行hook。

hook成功之后,每次Xcode对Script Bridge API进行调用都会先进入我们的监控逻辑。此时我们记录时间戳来计时,然后再进入LLDB.framework中的逻辑,获取结果后返回给lldb-rpc-server,并在Xcode的GUI中展示。

Hook SB API

Hook SB API时,需要一份含有要部署的LLDB.framework的头文件(Xcode并未内置)。由于上述的流程使用了动态链接的LLDB.framework,我们选择了Swift 5.6的产物,并tbd化避免仓库膨胀。

由于LLDB Script Bridge API相对稳定,因此可以使用一个动态库实现,通过运行时来应对不同版本的API变化(极少出现,截止发文调研5.5~5.7之间Xcode并没有改变调用接口)。

对于hook C++函数的方式,这里借用了Fishhook进行替换。原C++的函数地址,可通过dlsym调用得到。注意C++函数名使用mangled后的名称(在tbd文件中可找到)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
///
/// Hook a SB API using the stub method defined with the macros above
///
#define LLDB_HOOK_METHOD(MANGLED, CLASS, METHOD) \
Logger::Log("Hook "#CLASS"::"#METHOD" started!"); \
ptr_##MANGLED.pvoid = dlsym(RTLD_DEFAULT, #MANGLED); \
if (!ptr_##MANGLED.pvoid) { \
Logger::Log(dlerror()); \
return; \
} \
if (rebind_symbols((struct rebinding[1]){{#MANGLED, (void *) hook_##MANGLED, (void **) & ptr_##MANGLED.pvoid }}, 1) < 0) { \
Logger::Log(dlerror()); \
return; \
} \
Logger::Log("Hook "#CLASS"::"#METHOD" succeed!");

C++的成员函数的函数指针第一个应该是this指针,这里用self命名。也可以调用原实现先获取结果,再根据结果进行相关的统计逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
///
/// Call the original implementation for member function
///
#define LLDB_CALL_HOOKED_METHOD(MANGLED, SELF, ...) (SELF->*(ptr_##MANGLED.pmember))(__VA_ARGS__)
最终整体代码中Hook一个API就可以写为:
// 假设期望Hook方法为:char * ClassA::MethodB(int foo, double bar)
// 这里写被Hook的方法实现
LLDB_GEN_HOOKED_METHOD(mangled, char *, ClassA, MethodB, int foo, double bar) {
return LLDB_CALL_HOOKED_METHOD(mangled, self, 1, 2.0);
}
// 这里是执行Hook(只执行一次)
LLDB_HOOK_METHOD(mangled, ClassA, MethodB);

耗时监控场景

目前耗时监控包含下列场景:

  • 展示frame变量
  • 展开变量的子变量
  • 输入expr命令(p, po命令也是expr命令的alias)
  • Attach进程耗时
  • Launch进程耗时

展示frame变量场景

经过观察,我们发现当在Xcode中进入断点,GUI显示当前frame的变量时,lldb-rpc-server调用SB API的流程为先调用SBFrame::GetVariables方法,返回一个表示当前frame中所有变量的SBValueList对象,然后再调用一系列方法获取它们的详细信息,最后调用SBListener::GetNextEvent等待下一个event出现。

因此我们计算展示frame变量的流程为,当SBFrame::GetVariables方法被调用时记录当前时间戳,等待直至SBListener::GetNextEvent方法被调用,再记录此时时间戳算出耗时。

展示子变量场景

经过观察,我们发现当在Xcode中展开变量,需要显示当前变量的子变量时,lldb-rpc-server调用SB API的流程为先调用SBValue::GetNumChildren方法,返回表示当前变量中子变量的数目,然后再调用SBValue::GetChildAtIndex获取这些子变量以及它们的的详细信息,最后调用SBListener::GetNextEvent等待下一个event出现。

因此我们计算展示frame变量的流程为,当SBValue::GetNumChildren方法被调用时记录当前时间戳,等待直至SBListener::GetNextEvent方法被调用,再记录此时时间戳算出耗时。

输入expr命令场景

Xcode中用户直接从debug console中输入LLDB命令的方式是不走SB API的,因此无法直接通过hook的方式获取耗时。我们发现大多数开发者,都习惯在debug console中使用po/expr等命令而不是GUI点击输入框。因此我们专门做了支持,通过SB API的OverrideCallback方法进行了拦截。

LLDB.framework暴露了一个用于注册在LLDB命令前调用自定义callback的接口:SBCommandInterpreter::SetCommandOverrideCallback;我们利用了这个接口注册了一个用于拦截并获取用户输入命令的callback函数,这个callback会记录当前耗时,然后调用SBDebugger::HandleCommand来处理用户输入的命令。但是当SBDebugger::HandleCommand被调用时,我们注册的callback一样会生效,并再次进入我们拦截的callback流程中。

为了解决这个递归调用自己的问题,我们通过一个static bool isTrapped变量表示当前进入的expr命令是否被OverrideCallback拦截过。如果未被拦截,将isTrapped置true表示expr命令已经被拦截,则调用HandleCommand方法重新处理expr命令,此时进入的HandleCommand方法同样会被OverrideCallback拦截到,但是此时isTrapped已经被置true,因此callback返回false不再进入拦截分支,而是走原有逻辑正常执行expr命令
3

Attach进程场景

Attach进程时,lldb-rpc-server会调用SBTarget::Attach方法,常见于真机调试的场景。
这里在调用前后记录时间戳,计算出耗时即可。

Launch进程场景

Launch进程时,lldb-rpc-server会调用SBTarget::Launch方法,常见于模拟器启动并调试的场景。
这里在调用前后记录时间戳,计算出耗时即可。

上报部分

数据上报

为了进一步还原耗时的细节,除了标记场景的类型以外,我们还会统一记录这些非敏感信息:

  • 正在调试的进程名,用于区分多调试Session并存的场景
  • 正在调试的App的Bundle ID
  • 当前断点位置在哪个文件
  • 当前断点位置在哪一行
  • 当前断点位置在哪个函数
  • 当前断点位置在哪个Module
  • 表示当前使用的工具链是Xcode的还是DanceCC的
  • 表示当前使用的Swift版本(与Xcode版本一一对应)

在内网提供的版本中,也通过外部环境变量,得知对应的App的仓库标识,用于在内网的数据统计平台上展示和区分。

如图,这是内网大型Swift工程,飞书iOS App接入DanceCC工具链之后,某时间的耗时数据,可以明显看出,DanceCC相比于Xcode的变量显示耗时,优化了接近一个数量级。
4
5

极端耗时场景堆栈收集

除了基本的耗时时间收集以外,我们还希望能够及时发现新增的极端耗时场景和新问题,因此设计了一套极端耗时情况下的调试器堆栈收集机制,目前只要发现,展示变量场景和输入expr命令耗时超过10秒种,则会记录LLDB.framework的当前调用堆栈的每个函数耗时,并将数据上报到后台进行统计和人工分析。

堆栈收集使用了log timers dump所产出的堆栈和耗时信息,本质上是LLDB代码中通过LLDB_SCOPED_TIMER()宏记录的函数,其会使用编译器的__PRETTY_FUNCTION__能力来在运行时得到一个用于人类可读的函数名。

在获取到调用前和调用后的两条堆栈后,我们会对每个函数进行Diff计算和排序,将最耗时的前10条进行了采样记录,使用字符串一同上传到统计后台中。
6

总结

无论是App还是工具链,在做性能优化的同时,数据指标建设是必不可少的。这篇文章讲述的监控方案,在后续迭代DanceCC工具链的时候,能够明确相关的优化对实际的调试体验有所帮助,能避免了主观和片面的测试来评估调试器的可用性。

除了调试器之外,DanceCC工具链还包括诸如链接器,编译器,LLVM子工具(如dsymutil)等相关优化,系列文章也会进一步进行相关的分享,敬请期待。

引用链接

  1. https://mp.weixin.qq.com/s/MTt3Igy7fu7hU0ooE8vZog
  2. https://reviews.llvm.org/rG4272cc7d4c1e1a8cb39595cfe691e2d6985f7161
  3. https://github.com/facebook/fishhook
  4. https://lldb.llvm.org/design/sbapi.html
  5. https://discourse.llvm.org/t/rfc-lldb-telemetry-metrics/64588

Swift 调试性能的优化方案

作者 DreamPiggy
2022年5月8日 01:13

声明

此篇文章在字节跳动的技术公众号已经刊登:《字节跳动DanceCC工具链系列之Swift调试性能的优化方案》

原作者是我自己(李卓立 @dreampiggy)而非抄袭,这里在个人博客同时转发一下,去掉了招聘相关文案。不过依旧欢迎大家有兴趣的有志之士加入。

背景

通常来说,大型Swift项目常含有大量混编(Objc/C/C++甚至是Rust)代码,含有超过100个以上的Swift Module,并可能同时包含二进制部分和源码部分。而这种大型项目在目前的Xcode 13体验下非常不好,经常存在类似“断点陷入后变量面板卡顿转菊花”、“显示变量失效”等问题。而且一直存在于多个历史Xcode版本。

图1:Xcode变量区显示卡顿转菊花,测试使用Xcode 13.3和下文提到的复现Demo

这部分Apple Team迟迟不优化的原因在于,Apple公司的内部项目和外部项目开发模式的巨大差异。Apple内部产品,如系统应用,系统库,会直接内嵌到iOS固件中,并直接受益于dyld shared cache(参考WWDC 2017-App Startup Time: Past, Present, and Future[1])来提升加载速度。这意味着他们通常会将一个App,拆分为一个薄的主二进制,搭载以相当多的动态链接库(Dynamic Framework),以及插件(PlugIn)的模式来进行开发。

举个例子,我们以iOS的消息App(MobileSMS.app)为例子,使用iOS 15.4模拟器测试。可以看到其主二进制大小仅有844KB(x86_64架构)。通过otool -L查询链接,可以看到总计动态链接了22个动态链接库,其中有9个是非公开的,大都是支撑消息App的功能库,这些库占据了大量存储。

图2:消息App的动态链接库列表

而iOS平台的第三方开发者的工程,为了追求更快的冷启动时长,由于没有了dyld shared cache的优化(dyld 3提出的启动闭包只能优化非冷启动),很多项目会使用尽量少的动态链接库。加之开源社区的CocoaPods,Carthage,SwiftPM等包管理器的盛行导致的Swift Module爆炸增长,预二进制的Framework/XCFramework包装格式的滥用,加之闭源三方公司的SDK的集成,最终形成了一个无论是体积还是符号量都非常巨大的主二进制,以及相当长的Search Paths。

以公司内飞书应用的内测版为例子,在使用Debug,Onone模式编译,不剥离(Strip)任何符号情况下,可以看到其主二进制大小为1.1GB,动态链接库数量为105,但是仅包含Apple的系统库和Swift标准库。业务代码以静态链接库集成。

图3:公司飞书应用的动态链接库列表

上述这两种不同的工程结构,带来了非常显著的调试体验的差异,并且Apple公司近年来的Xcode Team和Debugger Team优化,并没有完全考虑部分第三方开发者常使用的,厚主二进制下的工程结构。

PS:理论上可以通过业务的工程结构的改造,在本地开发模式下,使用一个动态链接库包裹基础静态链接库的方式,减少主二进制大小(也会减少后续提到的DWARF搜索的耗时),但是大型项目推进工程结构的改造会是一个非常漫长的过程。

图4:一种减少主二进制大小的工程结构设计

解决方案:自定义LLDB工具链

经过调研,我们发现业界常见做法,无外乎这几种思路:

  1. 工程改造:缩减Swift Module/Search Path数量:可行,但是收益较低,且不可能无限制缩减
  2. 通过LLDB一些开关:可行,但是内部测试下依旧达不到理想的调试状态

我们致力于在字节跳动的移动端提供基础能力支持,因此提出了一套解决方案,不依赖业务工程结构的改造,而是从LLDB工具链上入手,提供定向的调试性能优化。

调研期间也确认到,借助自定义LLDB工具链,集成到Xcode IDE是完全可行的,包括iPhone模拟器、真机以及Mac应用。

图5:自定义LLDB工具链的文件结构,系列后续文章会单独讲解,这里不展开

而LLVM/LLDB本身的工具链代码,在Apple的开源范畴之内(仓库地址:https://github.com/apple/llvm-project) 通过严格追踪跟进上游的发布历史,分支模型,能够尽可能地保证工具链的代码和功能的一致性。

实际收益

经过后文提到的一系列优化手段,以公司内大型项目飞书测试,编译器采取Swift 5.6,Xcode选择13.3为例,对比调试性能:

项目 Xcode 13.3 自定义LLDB
v耗时 2分钟 40秒
po耗时 1分钟 5秒
p耗时 20秒 5秒

图6:切换自定义LLDB工具链

图7:调试优化演示,使用Xcode 13.3自定义LLDB,运行文中提到的耗时Demo(原po耗时约1分钟):

简述po/p/v的工作流程

在介绍我们自定义LLDB工具链的优化之前,首先来简述一下LLDB的核心调试场景的工作流程,方便后续理解优化的技术点。

我们一期的目的是主要优化核心的调试场景,包括最常见的“断点陷入到Xcode左侧变量区展示完毕”(v),“点击Show Description”(po),“勾选Show Types”(p)。这些对应LLDB原生的下面三个交互命令。

图8:LLDB的交互命令

Apple在WWDC 2019-LLDB: Beyond “po”[2]中,进行了较为详细的介绍,这里我们进一步详细解释其部分工作流程,为后文的具体优化技术点提供参考。建议可以搭配视频一并学习。

po [expr]

po是命令expression --object-description -- [expr]的alias

图9:po的流程

  1. 使用Swift编译器编译result = expr得到IR
1
2
3
4
// 精简版,实际较为复杂,源代码搜@LLDBDebuggerFunction关键字
func __lldb_expr() {
__lldb_result = expr
}
  1. 执行IR代码

    1. 在支持JIT的平台上使用JIT,不支持则使用LLVM的IRInterpreter
  2. 获取执行结果

  3. 使用Swift编译器编译result.description

    1. 实际上LLDB调用的是Swift标准库的私有方法:_DebuggerSupport.stringForPrintObject[3]
  4. 执行IR代码

  5. 获取执行结果字符串

  6. 对得到的字符串进行格式化输出

p [expr]

p是命令expression -- [expr]的alias

图10:p的流程

  1. 使用Swift编译器编译result = expr得到IR

  2. 执行IR代码

  3. 获取执行结果

  4. result进行Dynamic Type Resolve

    1. 利用Swift编译器提供的remoteAST,拥有源码的AST之后,会根据内存布局直接读取对象细节
    2. 也会利用Swift Reflection,即Mirror来进行读取,和remoteAST二选一
  5. 对得到的对象细节进行格式化输出

对比下来可以看到,po和p的最大不同点,在于表达式执行的结果,如何获取变量的描述这一点上。po会直接利用运行时的object description(支持CustomDebugStringConvertible[4]协议)拿到的字符串直接展示,并不真正了解对象细节。

图11:获取Object Description的实现细节(SwiftLanguageRuntime.cpp)

而p使用了Swift Runtime(Objc的话就是ISA,Method List那些,资料很多不赘述),拿到了对象细节(支持CustomReflectable[5]协议),进行按层遍历打印。不过值得注意的是,Swift Runtime依赖remoteAST(需要源码AST,即swiftmodule)或者Reflection(可能被Strip掉,并不一定有),意味着它强绑定了,编译时的Swift版本和调试时的LLDB的版本(牢记这一点)。并不像Objc那样有一个成熟稳定运行时,不依赖编译器也能动态得知任意的对象细节。

图12:Swift Dynamic Type Resolve的实现(SwiftLanguageRuntimeDynamicTypeResolution.cpp)

v [expr]

v是命令frame variable [expr]的alias

图13:v的流程

  1. 获取程序运行状态(寄存器/内存等)
  2. 递归开始
  3. 解释expr的每一层访问(->或者.),得知当前变量的内存布局
  4. 对当前变量进行Dynamic Type Resolve
  5. 递归结束
  6. 对得到的对象细节格式化输出

v的特点在于全程没有注入任何代码到程序中,也就是它是理论无副作用的。它的expr只支持访问对象的表达式(->/.等),不支持函数调用,并不是真正的C++/C/OC/Swift语法。

优化v

下述所有说明基于发稿日的Swift 5.6(优化思路也适配Swift 5.5)说明优化方案,后续不排除Apple或者LLVM上游进行其他优化替代,具有一定时效性。

(暂时)关闭swift-typeref-system

  • 关闭方式
1
settings set symbols.use-swift-typeref-typesystem false
  • 开关说明

Prefer Swift Remote Mirrors over Remote AST

这里的remoteAST和Swift Mirror的概念,上文介绍过,不同方案会影响Swift的Dynamic Type Resolve的性能。

经过实测,关闭之后,内部项目的复杂场景下,断点陷入耗时从原本的2分20秒,缩减为1分钟。这部分开关,目前已经通过Xcode自定义的LLDBInit[6]文件,在多个项目中设置。

注:和Apple同事沟通后,swift-typeref-typesystem是团队20年提出的新方案,目前有一些已知的性能问题,但是对Swift变量和类型展示有更好的兼容性。关闭以后会导致诸如,typealias的变量在p/v时展示会有差异,比如TimeInterval(alias为__C.Double)等。待Apple后续优化之后,建议恢复开启状态。

修复静态链接库错误地使用dlopen(Fixed in Swift 5.7)

简述问题:LLDB在SwiftASTContext::LoadOneModule时假设所有framework包装格式都是动态链接库,忽略了静态链接库的可能性。

在调试测试工程中,我们追踪日志发现,LLDB会尝试使用dlopen去加载静态链接库(Static Framework),这是很不符合预期的一点,因为对一个静态链接库进行dlopen是必定失败的,如日志所示(使用下文提到的复现Demo):

1
2
3
4
SwiftASTContextForExpressions::LoadOneModule() -- Couldn't import module AAStub: Failed to load linked library AAStub of module AAStub - errors:
Looking for "@rpath/AAStub.framework/AAStub", error: dlopen failed for unknown reasons.
Failed to find framework for "AAStub" looking along paths:
// ...

查看代码阅读发现,这里触发的时机是,LLDB在执行Swift变量Dynamic Type Resolve之前,因为需要激活remoteAST,需要加载源码对应的swiftmodule到内存中。

swiftmodule是编译器序列化的包含了AST的LLVM Bitcode[7]。除了AST之外,还有很多Metadata,如编译器版本,编译时刻的参数,Search Paths等(通过编译器参数-serialize-debugging-options记录)。另外,对Swift代码中出现的import语句,也会记录一条加载模块依赖。而主二进制在编译时会记录所有子模块的递归依赖。

LLDB在进行加载模块依赖时,会根据编译器得到的Search Paths,拼接上当前的Module Name,然后遍历进行dlopen。涉及较高的时间开销:N个Module,M个Search Path,复杂度O(NxM)(内部项目为400x1000数量级)。而在执行前。并未检测当前被加载的路径是否真正是一个动态链接库,最终产生了这个错误的开销。

  • 修复方案

我们的修复方案一期是进行了一次File Signature判定,只对动态链接库进行dlopen,在内部工程测试(约总计1000个Framework Search Path,400个Module)情况下,一举可以减少大约1分钟的额外开销。

  • 复现Demo

仓库地址:https://github.com/PRESIDENT810/slowDebugTest

这个Demo构造了100个Swift Static Framework,每个Module有100个编译单元,以此模拟复杂场景。

后文的一些测试数据优化,会反复提及这个Demo对比。

注:和Apple的同事沟通后,发现可以在上层进行来源区分:只有通过expression import UIKit这种用户交互输入的Module会进行dlopen检查,以支持调试期间注入外部动态库;其他情况统一不执行,因为这些模块的符号必然已经在当前被调试进程的内存中了。

Apple修复的PR:https://github.com/apple/llvm-project/pull/4077 预计在Swift 5.7上车

优化po/p

(暂时)关闭swift-dwarfimporter

  • 关闭方式
1
settings set symbols.use-swift-dwarfimporter false
  • 开关说明
1
Reconstruct Clang module dependencies from DWARF when debugging Swift code

这个开关的作用是,在开启情况下,Swift编译器遇到clang type(如C/C++/Objc)导入到Swift时,允许通过一个自定义代理实现,来从DWARF中读取类型信息,而不是借助编译器使用clang precompiled module[8],即pcm,以及ClangImporter导入桥接类型。

切换以后可能部分clang type的类型解析并不会很精确(比如Apple系统库的那种overlay framework,用原生Swift类型覆盖了同名C类型),但是能稍微加速解析速度,这是因为clang pcm和DWARF的解析实现差异。

禁用之后,对内部项目测试工程部分场景有正向提升约10秒,如果遇到问题建议保持默认的true。

优化External Module的查找路径逻辑

在混编工程中,Swift Module依赖一个C/OC的clang module是非常常见的事情。在这种情况下,LLDB需要同时使用编译器,加载到对应的clang module到内存中,用于进行C/OC Type到Swift Type的导入逻辑。

但是实际情况下,我们可能有一些Swift混编产物,是预二进制的产物,在非当前机器中进行的编译。这种情况下,对应编译器记录的的External Module的路径很可能是在当前机器找不到的。

LLDB的原始逻辑,会针对每一个可能的路径,分别由它的4种ObjectFile插件(为了支持不同的二进制格式)依次进行判断。每个ObjectFile插件会各自通过文件IO读取和解析Header。这是非常大的开销。

  • 优化方案

我们内部采取的策略比较激进,除了直接利用fstat进行前置的判断(而不是分别交给4个ObjectFile插件总计判断4次)外,还针对Mac机器的路径进行了一些特殊路径匹配规则,这里举个例子:

比如说,Mac电脑的编译产物绝对路径,一定是以/Users/${whoami}开头,所以我们可以先尝试获取当前调试器进程的uname(非常快且LLDB进程周期内不会变化),如果不匹配,说明编译产物一定不是在当前设备进行上产出的,直接跳过。

图14:特殊匹配规则,直接避免文件IO判定存在与否

通过这一项优化,在内部项目测试下(1000多个External Module路径,其中800+无效路径),可以减少首次变量显示v耗时约30秒。

增加共享的symbols缓存

我们使用内部项目进行性能Profile时,发现Module::FindTypesSymbolFile::FindTypes函数耗时调用占了主要的大头。这个函数的功能是通过DWARF(记录于Mach-O结构中),查找一个符号字符串是否包含在内。耗时主要是在需要进行一次性DWARF的解析,以及每次查找的section遍历。

LLDB本身是存在一个searched_symbol_files参数用来缓存,但是问题在于,这份缓存并不是存在于一个全局共享池中,而是在每个具体调用处的临时堆栈上。一旦调用方结束了调用,这份缓存会被直接丢弃。

图15:symbols缓存参数

  • 优化方案

我们在这里引入了一个共享的symbols缓存,保存了这份访问记录来避免多个不同调用方依然搜索到同一个符号,以空间换时间。实现方案比较简单。

内部工程实测,下来可以减少10-20秒的第一次访问开销,而每个symbol缓存占据字节约为8KB,一次调试周期约10万个符号占据800MB,对于Mac设备这种有虚拟内存的设备来说,内存压力不算很大。另外,也提供了关闭的开关。

优化不必要的同名symbols查找

另一项优化Module::FindTypesSymbolFile::FindTypes函数开销的方案是,原始的这两个函数会返回所有匹配到的列表,原因在于C++/Rust/Swift等支持重载的语言,会使用naming mangle来区分同一个函数名的不同类型的变种。这些符号名称会以同样的demangled name,记录到DWARF中。

但是调用方可能会关心同名类型的具体的变种(甚至包括是const还是非const),甚至有很多地方只取了第一个符号,搜索全部的Symbol File其实是一种浪费(在Swift 5.6版本中找到累积约10处调用只取了第一个)

  • 优化方案

我们对上述Module::FindTypesSymbolFile::FindTypes函数,提供了一个新的参数match_callback,用于提前过滤所需要的具体类型。类似于很多语言标准库提供sort函数中的stop参数。这样,如果只需要第一个找到的符号就可以提前终止搜索,而需要全部符号列表不受影响。

图16:symbols查找筛选参数

内部项目测试这项优化以后,可以减少C++/C/OC类型导入到Swift类型这种场景下,约5-10秒的第一次查找耗时。

其他优化

定向优化Dynamic Type Resolve的一些特例

在实际项目测试中,我们发现,Dynamic Type Resolve是有一些特例可以进行针对性的shortcut优化,剔除无用开销的。这部分优化仅对特定代码场景有效,并不通用。这里仅列举部分思路

  • 优化Core Foundation类型的Dynamic Type Resolve

Core Foundation类型(后文以CF类型指代),是Apple的诸多底层系统库的支撑。Objc的Founadtion的NS前缀的很多类型,也会Toll-Free Bridging[9]到CF类型上。而Swift也针对部分常用的CF类型支持了Briding。

CF类型的特点是,它内存布局类似Objc的Class ISA,但是又不是真正的Objc Class或者Swift imported Type,ISA固定是__NSCFType

而目前LLDB遇到在Swift堆栈中出现的CF类型,依旧把它当作标准的clang type进行C++/C那一套解析,还会递归寻找父类ivar,比较费时。我们可以利用这一特点提前判定而跳过无用的父类查找。

图17:筛选CF类型

这一项优化在特定场景(如使用CoreText和CoreVideo库和Swift混编)下,可以优化10-20秒的每次Dynamic Type Resolve耗时。

接下来

我们在之后会有一系列的相关话题,包括:

  • Xcode 13.3导致部分项目po提示Couldn’t realize type of self,有什么解决办法?
  • 如何极速构建,分发自定义LLVM/LLDB工具链,来让用户无缝部署?
  • 如何进行调试性能指标的监控和建设,包括Xcode原生的LLDB?

另外,这篇文章提到的非定制的优化和功能,均会向Apple或LLVM上游提交Patches,以回馈社区。

总结

这篇文章讲解了,大型Swift项目如何通过开关,以及自定义LLDB,优化Swift开发同学的调试速度,提高整体的研发效能。其中讲解了LLDB的部分工作流程,以及针对性优化的技术细节,以及实际效果。

我们的优化目标,不仅仅是服务于字节跳动移动端内部,更希望能推动业界的Swift和LLVM结合领域的相关发展,交流更多工具链方向的优化建设。

鸣谢

感谢飞书基础技术团队提供的一系列技术支持,以及最终业务试点提供的帮助推广。
感谢Apple同事Adrian Prantl在GitHub和邮件上进行的交流反馈,协助定位问题。

引用链接

  1. https://developer.apple.com/videos/play/wwdc2017/413/
  2. https://developer.apple.com/videos/play/wwdc2019/429/
  3. https://github.com/apple/swift/blob/release/5.6/stdlib/public/core/DebuggerSupport.swift#L242
  4. https://developer.apple.com/documentation/swift/customdebugstringconvertible
  5. https://developer.apple.com/documentation/swift/customreflectable
  6. https://lldb.llvm.org/man/lldb.html#configuration-files
  7. https://llvm.org/docs/BitCodeFormat.html
  8. https://clang.llvm.org/docs/Modules.html#id20
  9. https://developer.apple.com/library/archive/documentation/General/Conceptual/CocoaEncyclopedia/Toll-FreeBridgin/Toll-FreeBridgin.html
❌
❌