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

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

鬼受得了啊!话说我是怎么忍了好几个月的
帮帮我!泰铭先生!
当一个故事结尾的时候,我们总会想起它的开始。
优化万法的那个起源是什么?
对,是timing!
一切性能优化的起点总是从知道哪里要优化开始。通过在整个编译运行链路上插入的20个timing探针,我们得到了各个阶段的详细耗时数据:
total=10347.222msresources.source=0.390msresources.validation=0.288msvalidation.prepare=0.158msworkdir.prepare=0.117msfrontend.lower=18.281msruntime_class.check=0.016ms
build.total=9100.728msbuild.include=317.310msbuild.codegen=38.321msbuild.write=0.570msbuild.inputs=0.048msbuild.native_compile=8744.444ms
godot_project.prepare=6.475msgodot.total=307.706msgodot.binary_lookup=0.022msgodot.process_start=2.115msgodot.first_output=35.031msgodot.run_until_stop=217.989msgodot.process_wait=305.393msgodot.stream_collect=0.003msoutput.assert=0.068ms结果就像钢板一样“直”观,显而易见,90%的时间都花在了C编译器上,其次最大的消耗是在依赖库生成上,这两个开销就是接下来搏斗的风口浪尖。
大调查编译器
编译器究竟是做了什么,是人性的扭曲还是道德的沦丧让一个纯c代码能编译9秒之久?
为了解开谜题,我们通过-ftime-trace追踪探针插入clang内部接口进行行为分析:

好家伙几乎全都在解析函数定义,哪来这么多函数啊……
啊不对!——
前世种下的孽缘
对的,有6万多个函数,这是她的全部,Godot的所有函数,为了全部支持静态调用分派,我全给预生成了。 一百万行代码,每次用到的就只有数百行,而编译器和链接器,却一直在默默地承受着这一切。
千百年来,沧海桑田,犹如平地上的尖峰,宛若房间里的大象,这个糟糕实现的承诺,无人在意。 她就在那里,它就在这里……
向冗余代码,开战!
啊嘞抱歉好像脑子烧糊了,最近天气太热 ( ̄﹃ ̄)
函数分层
为了减少大量冗余的绑定带来的超长编译耗时,我们把 Godot binding 分成几类,而不是把所有Godot指针函数都当成模块私有生成物。
大体分成三层:
-
运行时已提供的绑定
这些绑定和具体脚本模块关系不大,只和 Godot 版本、API元数据、GDCC 运行时 helper 函数有关。
例如:
- GDExtension接口包装器
- 内建类型构造析构函数 / 方法 / 成员变量 / 运算符重载
- 全局工具函数
- 固定 helper/template 会用到的函数
- GDCC 自己固定需要的 helper 函数包装器
这些被收集成一个给定集。
-
固定支持层绑定
有些绑定不是简单从API元数据全量展开就够了,而是无论如何都固定会用。例如一些单例、类型注册表数据库、对象生命周期、Variant函数等路径。 这些由固定绑定机制生成一次,放进版本化支持文件里。
-
模块本地绑定
只有真正随这个GDScript脚本实际用了什么而变化的东西,才进入模块本地绑定。
当前主要是:
- 当前脚本实际用到的单例指针
- 当前脚本实际用到的常量
- 具体用到的引擎类的方法
- 具体用到的引擎类的构造函数
这类函数才会被写进生成的头文件中。这样做以后,模块生成阶段不再承担大包大揽的大集合输出,显著降低了生成的函数数量。
使用给定集过滤掉已经存在的绑定
之前就提到,我们引入了一个明确的“运行时已经提供的 C 函数名集合”。
生成函数体时,如果代码里调用了某个C函数,系统会先判断这个函数是不是已经有了:
- 如果已经在运行时支持库、内建类型支持、全局工具函数等类别里提供了,就不再为当前模块生成一份。
- 如果没有提供,而且这个模块确实需要它,才要求显式登记成模块本地绑定。
- 如果既没有提供,也没有被显式登记,就直接报错,避免鬼子悄悄地进村,打枪的不要~
这样来说的话就避免了过去那种“看到 C 代码里有 godot 函数名,就为了保险多生成一些 wrapper”的倾向。
按模块收集,只提交成功生成的函数用到的绑定
这下的Godot函数用例收集器没有像以前那样做成一个全局随便写的屎山,而是做成了 module session + function buffer。
流程大概是:
- CCodegen.generate() 创建一个模块级 GodotBindingUsageSession。
- 每生成一个函数体,创建一个临时 GodotBindingUsageBuffer。
- 函数体生成成功后,才把这个 buffer commit 到模块级 session。
- 如果某个函数体生成失败,里面临时记录的绑定不会污染最终 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分钟以内,巨大飞跃啊?(貌似也没多快)朋友们:

终于可以愉快地实现新操作符了呢~开发效率又双叒叕提升了
后日谈
可能昨天肝的代码比较多,文章前半部分脑子有点烧了,云里雾里还请谅解。 这里写了一堆碎碎念,希望以后的自己还记得这些,免得又被房间里的大象一脚踹死。