优化方案基于 Swift Toolchain 源码,本文不再探讨 Toolchain 相关基本概念及配置流程等,仅聚焦方案本身。
背景
随着混编落地的业务场景越来越多,越来越大,开发中出现的性能痛点开始显现,问题很明显集中在被 Swift 环境所依赖的 OC 仓的头文件改动上。因此基建架构把重点放在接口层依赖的性能分析上,力求解决性能瓶颈。
抖音基础技术团队借助自定义 Toolchain 能力,通过自定义编译参数,裁剪 Clang Header 指定内容,最终实现编译提速 60% 。
本方案已于 2022 年 11 月底上线,在抖音稳定运行近 5 个月。下面就让我们一起回顾下整个方案从提出到落地的全过程。
初步分析
在混编场景下,若要确保 OC 与 Swift 间尽可能充分地互操作,则模块化的启用无法仅用在 Swift 编译上下文中——Swift编译导出的 Clang Header,在工程中以$(project_name)-Swift.h
形式出现,将其需要被 re-export 引用的 OC 依赖项,以模块的形式导出,这就意味着若 OC 编译不启用模块化,则无法正确使用 Swift 提供的头文件。
如图,二者不可兼得,Objc Pod D 为了能够解析语句@import A;
而引入A.modulemap
,则其与 A 的互操作不可能再基于文本导入的逻辑,而全面转向模块化。
对于抖音而言,巨型 OC 项目的大量头文件传递依赖的历史包袱,使得在 OC 编译中引入模块化是一场灾难。模块化环境下,缓存系统决议是否要命中 .o 缓存的耗时,比文本环境下重新编译耗时还要长;增量编译时,也会导致广泛的模块重编,改动一个头文件,就要等待数分钟。
传递依赖治理是一项长期工程,但编译优化等不了那么久,我们需要一个可以快速解决的方案。
优化效果
在介绍方案之前,先上结论。
在抖音工程中选取代码量最大的 OC&Swift 混编仓库进行测试:
- OC 增量编译:选取被 Swift 依赖的 OC 接口层头文件进行改动,编译耗时降低 60%
- Swift 增量编译:选取被 OC 依赖的 Swift public class 进行改动,编译耗时相近,无变化
- 全量编译:清除本地编译缓存进行 clean build,编译耗时降低 17%
可见该方案对编译速度的巨大提升。接下来就让我们回顾一下整个方案从预研到上线的过程。
方案原理
解决问题的关键在于降低将 OC 头文件预编译耗时,这里有两个思路:
- 长期:模块解析的耗时根源在于传递依赖,模块的特性导致不同模块内包含的头文件的传递依赖会将模块增量重编的影响范围扩展到很大。业务库在现有工程架构体系下已经严格控制了接口层传递依赖,因此长期方案会逐步推动治理基础库的传递依赖问题。
- 短期:将 OC 头文件预编译转回文本导入,即裁剪
-fmodule-map-files
注入,但依然保留对 OC 调用 Swift 代码的支持
Swift会将自身接口层(即 public/open )声明使用到的 C/OC 模块,在xxx-Swift.h
中以@import aaa
形式给出,这就要求 OC 侧使用该头文件时也需要将这些模块对 OC 侧可见,我们想要达成目的,就需要对这些声明进行裁剪。这需要自定义工具链的支持。
本次优化方案效果测试针对的是短期方案。
通过修改编译器,对 Swift 编译生成的 Clang Header Interface 进行裁剪,删除掉系统库以外的 @import,而 OC 侧引用该头文件的地方手动补全依赖。即以暂时牺牲接口self-contained为代价,使OC侧不必再关心模块相关的因素。为支持更细粒度的控制,通过向编译器注入编译参数,以针对不同组件控制此功能的启用,以及实现更具体的裁剪内容。
而对于-fmodule-map-files
的裁剪相对容易,只需修改OTHER_CFLAGS
即可关闭-fmodule-map-files
的注入。
预研
方案拆解
我们先来对整个方案做一个任务拆解,可以分析出各部分的依赖关系,节省预研阶段的耗时。
一个工具链相关的落地方案,必须保证其稳定性,因此一定是可以通过一种简单的方式进行外部控制开关的。
从发版角度讲,工具链发版并不像业务代码,和存放在开发仓库的配置一样可以灵活发版,因此应尽可能保证工具链代码的稳定,非必要不修改。
基于这两个原则,我们可以拆解为:
1.分析 swiftc
的参数解析机制,在编译时的参数列表中拼接新的自定义参数以控制裁剪能力。swiftc
是实际的前端 swift-frontend
的一个入口,下面会详细提到,向 swiftc 注入的参数列表,在各 swift-frontend
子任务中并不总是以相同的全集出现,作用机制需要进一步分析。
2.基于细粒度控制的考量,参数选择传入一个配置文件,包含一个白名单,来确定哪些@import Module
是可以留下的。我们也有考虑过黑名单,但实际工程的依赖情况是复杂的,不论是 Cocoapods 还是 seer ,都仅能描述工程层面的依赖情况,而不能保证实际编译时的依赖情况,难以构建一个全面的业务黑名单。而系统库白名单是相对固定的,并不需要经常维护。
3.寻找生成 -Swift.h 的具体函数,以及写入@import Module;
的逻辑以进行裁剪。
4.在写入逻辑处加载白名单文件并进行过滤。
5.通过本地验证,完成无感知下发 Toolchain 的验证,打出测试 Toolchain 。
6.灰度验证。
7.合码发版上线。
快速验证
想要验证方向是否正确,同时给予饱受编译耗时困扰的业务同学以信心,需要先找到最关键的点快速验证。
因此我们决定先直接整体关掉所有 -Swift.h 的@import Module;
生成逻辑。此时我们对整体 Swift 源码的认知还较为模糊,但我们只需要去寻找类似<< "@import"
或其他写文件的逻辑再去筛选即可,所幸这一过程没有花费太久。
我们很快找到了这块逻辑,并直接将out << "@import " << Name.str() << ";\n";
注释掉,打包验证成功,出具了本文开头的数据报告,给业务同学吃下一颗定心丸。
接下来,我们就可以稳健地按部就班地去执行其他任务了。
开发、调试
swift-frontend 参数解析流程
于是我们将目光转向了其他在前端层级应用的原生参数,并参考它们的写法。很快我们将目光锁定在module-cache-path
,这是一个 Swift 前端编译必需的参数,指定模块缓存位置,且后面传入一个路径,完全符合我们的要求。
根据对该参数的分析,可得 -frontend 阶段的参数解析流程,具体调研过程不再展开,直接简单过下流程。
简单流程如上图,下面具体过下修改参数解析流程的代码位置。
定义
此处使用了一种十分类似 python 的,LLVM 推出的 TableGen (https://llvm.org/docs/TableGen/)语言,后面这些 flag ,我们需要的是
- FrontendOption 前端参数,拥有这个flag才会进入前端参数解析流程,而 Clang Header 生成的过程就发生在前端流程中
- ArgumentIsPath 参数为路径,告知编译器该参数后携带路径字符串作为参数
仿照这种形式的自定义参数:
第二个 EQ 定义其实是一种 Alias,定义了可以使用" flag=arg "这种形式来进行传参,没有其他额外作用。
通过tablegen
工具,把 Options.td 的内容生成为 Options.inc ,如下图
结合 Swift 源码中 Options.h 的 OPTION 定义,引入并提供给 cpp 代码使用
解析
解析过程发生在 CompilerInvOCation 的参数解析流程中
在 ArgsToFrontendOptionsConverter 方法中,从参数列表读取需要的信息,赋值到 Opts 当中
Opts 是一个 FrontOptions 类型的实例,我们需要在这里定义一个字符串以存储我们需要的参数
Opts 会在整个前端流程中流转,为各环节提供必要参数。
Clang Header 生成流程
调用过程的流程图如下,PrintAsClang 是一个相对独立的模块,我们改动只需要关注这两个标红环节即可。
增加入参定义
在原方法定义上加入两个传参,分别是我们传入的白名单文件路径,以及诊断信息,诊断信息后面会提到,用于提示一些自定义错误。
这里也是相同,增加两个参数定义。
白名单解析
printAsClangHeader 这里是我们的主要修改之一,在这个 function_ref 当中,我们对 allow list path 指向的文件进行了内容解析,得到白名单指定的模块名称,以参数形式传递给下一个环节。
writeImports 方法在原有基础上增加一个 function_ref,可以理解为 lambda
表达式,就是我们刚刚做的白名单解析的过程。
在具体写入@import Module;
处进行白名单筛选,在白名单内部的允许写入,否则跳过。
自定义诊断信息
DiagnosticsClangImporter.def 中加入两个自定义条目,error 用于提示解析错误,note 仅提示白名单为空,为空是允许的操作,此时退化为默认逻辑。
前面我们在方法定义中传入了 Diags 实例,想要提示信息,只需简单调用即可,note 只会输出到日志,error 则会打断编译流程。
验证、上线
可使用云构建机器打出测试 Toolchain,下载至本地,集成到 Xcode 中在抖音验证即可。
将自定义参数加入到指定混编组件的编译参数当中,即可成功构建。
后记
Swift 工具链定制是一个拥有无限可能的方向,包括编译优化这类效率提升的工作等等,都可以在底层进行传统意义上的架构层所难以进行的深度优化,后续针对这块可做的事还有很多,相信有更多的经验可以分享给到大家。