关于字节跳动:字节跳动DanceCC工具链系列之Swift调试性能的优化方案

4次阅读

共计 9792 个字符,预计需要花费 25 分钟才能阅读完成。

前言

DanceCC (Dance Compiler Collection) 是字节跳动的终端技术团队 (Client Infrastructure) 下的编译工具链品牌,编译工具链团队成员由国内和硅谷两地的编译器专家及构建零碎专家组成,提供基于开源的 LLVM/Swift 我的项目深度定制的 clang/swift 编译器、链接器、lldb 调试器和语言根底库等工具及优化计划,笼罩 构建性能优化 利用性能稳定性优化 等场景,本系列将会围绕这些场景中的优化案例,介绍编译工具链技术在字节的优化计划和落地状况。

背景

通常来说,大型 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…)通过严格追踪跟进上游的公布历史,分支模型,可能尽可能地保障工具链的代码和性能的一致性。

理论收益

通过后文提到的一系列优化伎俩,以公司内大型项目飞书测试,编译器采取 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
// 精简版,理论较为简单,源代码搜 @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

  • 敞开形式
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):

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)(外部我的项目为 400×1000 数量级)。而在执行前。并未检测以后被加载的门路是否真正是一个动态链接库,最终产生了这个谬误的开销。

  • 修复计划

咱们的修复计划一期是进行了一次 File Signature 断定,只对动态链接库进行 dlopen,在外部工程测试(约总计 1000 个 Framework Search Path,400 个 Module)状况下,一举能够缩小大概 1 分钟的额定开销。

  • 复现 Demo

仓库地址:https://github.com/PRESIDENT8…

这个 Demo 结构了 100 个 Swift Static Framework,每个 Module 有 100 个编译单元,以此模仿简单场景。

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

注:和 Apple 的共事沟通后,发现能够在下层进行起源辨别:只有通过 expression import UIKit 这种用户交互输出的 Module 会进行 dlopen 查看,以反对调试期间注入内部动静库;其余状况对立不执行,因为这些模块的符号必然曾经在以后被调试过程的内存中了。

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

优化 po/p

(临时)敞开 swift-dwarfimporter

  • 敞开形式
settings set symbols.use-swift-dwarfimporter false
  • 开关阐明

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 和邮件上进行的交换反馈,帮助定位问题。

对于字节终端技术团队

字节跳动终端技术团队 (Client Infrastructure) 是大前端根底技术的全球化研发团队(别离在北京、上海、杭州、深圳、广州、新加坡和美国山景城设有研发团队),负责整个字节跳动的大前端基础设施建设,晋升公司全产品线的性能、稳定性和工程效率;反对的产品包含但不限于抖音、今日头条、西瓜视频、飞书、瓜瓜龙等,在挪动端、Web、Desktop 等各终端都有深入研究。

退出咱们

咱们是字节的 Client Infrastructure 部门下的编译器工具链团队,团队成员由编译器专家及构建零碎专家组成,咱们基于开源的 LLVM/Swift 我的项目提供深度定制的 clang/swift 编译器、链接器、lldb 调试器和语言根底库等工具及优化计划,笼罩 构建性能优化 利用性能稳定性优化 等场景,并在业务研发效率和利用品质晋升方面获得了显著的成果,同时,在实际的过程中咱们也看到了很多令人兴奋的新机会,心愿有更多对编译工具链技术感兴趣的同学退出咱们一起摸索。

工作地点

深圳、北京

职位形容

  1. 设计与实现高效的编译器 / 链接器 / 调试器优化
  2. 自定义 LLVM 工具链的保护和开发
  3. 晋升 Client Infrastructure 编译工具链的性能及稳定性
  4. 协同业务团队推动技术计划的落地

职位要求

  1. 至多熟练掌握 C++/Objective-C/Swift 其中一门语言,相熟语言个性的实现细节
  2. 相熟编程语言的实现技术,如解释器、编译器、内存治理方面的实现
  3. 相熟某个构建零碎 (CMake/Bazel/Gradle/XCBuild 等)
  4. 有编译器、链接器、调试器等工具的开发和优化教训优先,有 LLVM、GCC 等我的项目我的项目开发经验优先
  5. 有挪动端技术栈开发教训优先

职位链接

https://job.toutiao.com/s/FBS…

援用链接

  1. https://developer.apple.com/v…
  2. https://developer.apple.com/v…
  3. https://github.com/apple/swif…
  4. https://developer.apple.com/d…
  5. https://developer.apple.com/d…
  6. https://lldb.llvm.org/man/lld…
  7. https://llvm.org/docs/BitCode…
  8. https://clang.llvm.org/docs/M…
  9. https://developer.apple.com/l…
正文完
 0