跳转到内容

编译,快至1s内

返回博客

终于将自动测试集内gdscript脚本的平均端到端编译时间缩短到了1秒以内,在跟GDExtension ABI和Clang激情搏斗了半个月之后(或许已经神志不清?), 随着GDCC#37这个5万行的大PR合并,我似乎应该记录一下这个神奇的故事事故,给自己回一回san。

编译耗时拆解对比

背景

自古?以来,项目的推进就一直饱受龟速测试的困扰,由于GDCC项目有着超过1700项单元测试,测试中会经历将该测试所用的gdscript脚本编译为GDExtension库后启动 Godot 引擎在实际项目中测试的过程,缓慢的编译过程极大地拖慢了整个测试,以至于每次往GitHub提交触发集成测试需要接近一个小时的时间。

GitHub Action耗时

鬼受得了啊!话说我是怎么忍了好几个月的

帮帮我!泰铭先生!

当一个故事结尾的时候,我们总会想起它的开始。

优化万法的那个起源是什么?

对,是timing!

一切性能优化的起点总是从知道哪里要优化开始。通过在整个编译运行链路上插入的20个timing探针,我们得到了各个阶段的详细耗时数据:

total=10347.222ms
resources.source=0.390ms
resources.validation=0.288ms
validation.prepare=0.158ms
workdir.prepare=0.117ms
frontend.lower=18.281ms
runtime_class.check=0.016ms
build.total=9100.728ms
build.include=317.310ms
build.codegen=38.321ms
build.write=0.570ms
build.inputs=0.048ms
build.native_compile=8744.444ms
godot_project.prepare=6.475ms
godot.total=307.706ms
godot.binary_lookup=0.022ms
godot.process_start=2.115ms
godot.first_output=35.031ms
godot.run_until_stop=217.989ms
godot.process_wait=305.393ms
godot.stream_collect=0.003ms
output.assert=0.068ms

结果就像钢板一样“直”观,显而易见,90%的时间都花在了C编译器上,其次最大的消耗是在依赖库生成上,这两个开销就是接下来搏斗的风口浪尖。

大调查编译器

编译器究竟是做了什么,是人性的扭曲还是道德的沦丧让一个纯c代码能编译9秒之久? 为了解开谜题,我们通过-ftime-trace追踪探针插入clang内部接口进行行为分析:

clang编译阶段耗时分析

好家伙几乎全都在解析函数定义,哪来这么多函数啊……

啊不对!——

前世种下的孽缘

对的,有6万多个函数,这是她的全部,Godot的所有函数,为了全部支持静态调用分派,我全给预生成了。 一百万行代码,每次用到的就只有数百行,而编译器和链接器,却一直在默默地承受着这一切。

千百年来,沧海桑田,犹如平地上的尖峰,宛若房间里的大象,这个糟糕实现的承诺,无人在意。 她就在那里,它就在这里……

向冗余代码,开战!

啊嘞抱歉好像脑子烧糊了,最近天气太热 ( ̄﹃ ̄)


函数分层

为了减少大量冗余的绑定带来的超长编译耗时,我们把 Godot binding 分成几类,而不是把所有Godot指针函数都当成模块私有生成物。

大体分成三层:

  1. 运行时已提供的绑定

    这些绑定和具体脚本模块关系不大,只和 Godot 版本、API元数据、GDCC 运行时 helper 函数有关。

    例如:

    • GDExtension接口包装器
    • 内建类型构造析构函数 / 方法 / 成员变量 / 运算符重载
    • 全局工具函数
    • 固定 helper/template 会用到的函数
    • GDCC 自己固定需要的 helper 函数包装器

    这些被收集成一个给定集

  2. 固定支持层绑定

    有些绑定不是简单从API元数据全量展开就够了,而是无论如何都固定会用。例如一些单例、类型注册表数据库、对象生命周期、Variant函数等路径。 这些由固定绑定机制生成一次,放进版本化支持文件里。

  3. 模块本地绑定

    只有真正随这个GDScript脚本实际用了什么而变化的东西,才进入模块本地绑定。

    当前主要是:

    • 当前脚本实际用到的单例指针
    • 当前脚本实际用到的常量
    • 具体用到的引擎类的方法
    • 具体用到的引擎类的构造函数

    这类函数才会被写进生成的头文件中。这样做以后,模块生成阶段不再承担大包大揽的大集合输出,显著降低了生成的函数数量。

使用给定集过滤掉已经存在的绑定

之前就提到,我们引入了一个明确的“运行时已经提供的 C 函数名集合”。

生成函数体时,如果代码里调用了某个C函数,系统会先判断这个函数是不是已经有了:

  • 如果已经在运行时支持库、内建类型支持、全局工具函数等类别里提供了,就不再为当前模块生成一份。
  • 如果没有提供,而且这个模块确实需要它,才要求显式登记成模块本地绑定。
  • 如果既没有提供,也没有被显式登记,就直接报错,避免鬼子悄悄地进村,打枪的不要~

这样来说的话就避免了过去那种“看到 C 代码里有 godot 函数名,就为了保险多生成一些 wrapper”的倾向。

按模块收集,只提交成功生成的函数用到的绑定

这下的Godot函数用例收集器没有像以前那样做成一个全局随便写的屎山,而是做成了 module session + function buffer。

流程大概是:

  1. CCodegen.generate() 创建一个模块级 GodotBindingUsageSession。
  2. 每生成一个函数体,创建一个临时 GodotBindingUsageBuffer。
  3. 函数体生成成功后,才把这个 buffer commit 到模块级 session。
  4. 如果某个函数体生成失败,里面临时记录的绑定不会污染最终 header。

这个设计减少的是“猜测性绑定”。也就是说,只有真正成功进入最终生成的C的代码路径,才会让绑定出现在最终模块头文件里。

这对编译速度和可调试性都有帮助,既不会因为中间尝试过某条失败路径就多生成函数绑定,而且绑定数量也更接近实际需要量。

把去重从函数名提升到规范键

模块本地绑定也不是简单一股脑扔到收集器里面,我们把它按规范键合并。规范键是从GDExtension元数据的family、owner、name、cFunctionName和signatureKey生成的唯一键。 这样同一个 binding 被多个函数、多个路径重复用到时,只会输出一次。

如果 C 函数名相同但签名不兼容,会直接报冲突,而不是生成两个名字相同但语义不同的函数绑定。这减少了重复绑定,也避免了靠后生成覆盖前生成这种bug运行的逆天行为。

内建类型和全局函数不再对每个脚本都生成

这是减少绑定数量的一个大头。

内建类型和全局函数的特点是:

  • 多,很多,非常多,真的很多。
  • 和 Godot 版本强相关。
  • 不随某个特定的GDScript脚本模块变化。
  • 很多测试都会重复用到同一批基础函数绑定集。

所以把它们提前放到绑定集中预生成再合适不过啦呢~

btw,这里不是完全少了所有内建类型和全局函数代码,虽然仓库里的支持文件雀实变大了,但每次模块构建不再重复生成和编译一大堆模块私有绑定。 构建速度优化主要来自这里。

是非禁果

从 timing 看,非常的漂亮:

  • build.total: 9.10s -> 700.5ms
  • build.native_compile: 8.74s -> 614.9ms
  • build.include: 317.3ms -> 8.0ms

端到端编译时间被压缩到1秒以内,这雄壮地说明,主要收益不是GDCC编译器代码生成快了一点,而是C编译器处理的东西少了很多:

  • 少解析大量绑定函数。
  • 少处理大量无用内联。
  • 少展开大量错误处理代码。
  • 少编译重复的模块本地绑定。
  • 少处理很大的头文件。

对于测试套件来说,这就像狭管效应一样,速度收益被成倍放大。因为每个测试用例都会生成一个临时工程, 之前每个测试都可能重复付出“生成和编译大量绑定”的成本。现在这些稳定绑定被移到共享层,单个测试的模块代码明显变小。

随之而来的GitHub Action构建耗时也成功地压缩到3分钟以内,巨大飞跃啊?(貌似也没多快)朋友们:

改进后GitHub Action耗时

终于可以愉快地实现新操作符了呢~开发效率又双叒叕提升了

后日谈

可能昨天肝的代码比较多,文章前半部分脑子有点烧了,云里雾里还请谅解。 这里写了一堆碎碎念,希望以后的自己还记得这些,免得又被房间里的大象一脚踹死。