跳转到内容

GDCC 0.0.2 发布:基准测试、更快的编译、隐式转换与静态访问

返回博客

GDCC 是一个将 GDScript 编译为 GDExtension 本机二进制库的编译器。从 0.0.1 到 0.0.2,GDCC 走过了约 60,000 行新增代码、280 个文件的变更。时隔两个月,我们发布0.0.2版本,重新出发。

持续的性能追踪

编译器的性能优化离不开测量。0.0.2 引入了完整的 Godot 驱动基准测试框架,覆盖算法、集合、数学、运行时四个类别共 14 个用例。每个用例同时在编译后代码和 GDScript 解释器中运行,直接对比编译收益。

0 us20 us40 us60 us80 us100 us120 us140 us160 us180 us200 us220 us240 us260 us280 us300 us320 us340 us360 us380 us400 us每次操作平均耗时数组修改布隆过滤器字典查找链表四叉树查找树堆3.9 us13.8 us3 us3.8 us91.5 us21.8 us1.1 us23.5 us1.2 us7.2 us377 us11.6 us编译后解释执行集合基准测试

集合基准测试用例的编译后与解释执行耗时。

项目 编译后平均值 编译后标准差 解释执行平均值 解释执行标准差 解释执行 / 编译后
数组修改 3.9 us 365 ns 1.1 us 100 ns 0.28x
布隆过滤器 13.8 us 819 ns 23.5 us 245 ns 1.70x
字典查找 3 us 393 ns 1.2 us 796 ns 0.40x
链表 3.8 us 350 ns 7.2 us 335 ns 1.89x
四叉树查找 91.5 us 4.8 us 377 us 5.5 us 4.12x
树堆 21.8 us 536 ns 11.6 us 259 ns 0.53x

从集合测试中可以看到,编译后的代码在计算密集型模式上优势显著——布隆过滤器和四叉树查找分别获得了 1.7 倍和 4.1 倍的加速。但数组修改、字典查找和树堆三项目前仍慢于解释器。一个关键原因在于 GDExtension 的调用开销约为每次 500ns,而 GDScript 解释器的内部调用仅约 200ns——当实际计算量较小、大部分时间消耗在跨边界调用上时,这种开销差异会抵消甚至逆转编译带来的收益。随着编译器逐步将更多调用内联化,这一差距将持续缩小。

在真实游戏场景中,这些测试分别对应着常见的运行时需求:数组和字典操作常用到无需额外说明;布隆过滤器可以服务于空间查询和碰撞预检测;链表和树堆出现在场景遍历、动画混合和渲染排序中;四叉树查找则是大世界或海量实体场景下查询和碰撞优化的核心工具。

0 us2 us4 us6 us8 us10 us12 us14 us16 us18 us20 us22 us24 us26 us28 us30 us32 us34 us36 us38 us40 us42 us44 us46 us48 us50 us每次操作平均耗时矩阵运算牛顿迭代法级数递推滑动方差Vector3 变换49.1 ns38.9 ns32.5 ns302 ns1.5 us4.1 us742 ns3 us43.2 us3.2 us编译后解释执行数学基准测试

数学基准测试用例的编译后与解释执行耗时。

项目 编译后平均值 编译后标准差 解释执行平均值 解释执行标准差 解释执行 / 编译后
矩阵运算 49.1 ns 23.8 ns 4.1 us 154 ns 84.06x
牛顿迭代法 38.9 ns 30.3 ns 742 ns 62.5 ns 19.07x
级数递推 32.5 ns 34.6 ns 3 us 283 ns 92.20x
滑动方差 302 ns 64.5 ns 43.2 us 2.1 us 142.93x
Vector3 变换 1.5 us 80.7 ns 3.2 us 145 ns 2.18x

数学密集型用例展现了编译的压倒性优势:矩阵运算提速 84 倍,滑动方差 143 倍,级数递推 92 倍,即使是计算量最小的 Vector3 变换也有 2.2 倍的提升。这些场景几乎没有 GDExtension 调用的开销干扰,纯数值计算直接映射为高效的 C 代码,编译器的优势得以完全释放。

在游戏中,这些数学运算无处不在:矩阵运算支撑着 3D 变换和着色器数据预处理;牛顿迭代法用于物理模拟中的求根和归一化、模拟游戏中的数值计算等;级数递推驱动着程序化生成和噪声函数;滑动方差服务于性能监控和输入平滑;Vector3 变换则是角色移动、摄像机追踪和射线检测的底层基础。

这套基准测试框架本身也是一项基础设施投资。它通过 GDCC_BENCHMARK_HEADER / GDCC_BENCHMARK_RESULT 协议行在 Godot 进程内完成计时,输出结构化的 JSON 报告,支持批量预热、多次采样和统计聚合。从此以后,每一项性能敏感的改动都有数字可以说话了。

更快的编译工具链

衡量一个编译器的好坏,不仅看它生成的代码跑得多快,也看它自己编译得有多快。0.0.1 中,一个测试用例从 GDScript 到可加载的原生库,平均需要超过 9 秒——其中 90% 的时间耗在 C 编译器上,编译着大量从未被使用的 Godot 绑定函数。

PR #37 对这条管线做了一次系统性的重构。旧方案将第三方 gdextension-lite 库作为 Godot API 的中间层,导致每次构建都要生成并编译超过六万个包装函数,即便实际只用到几百个。新方案放弃中间层,直接生成针对当前 Godot 版本的原生 ABI 头文件,并将 Godot API 函数分为三个层级:运行时提供层(GDExtension 接口、内置类型构造/析构/方法、全局工具函数等预编译在运行时库中的稳定集合)、固定支持层(单例注册表、类型数据库、对象生命周期等始终需要的特殊绑定)和模块本地层(仅当前脚本实际使用的单例指针、常量、方法和构造函数才写入模块头文件)。

这一重构让每次测试构建的 C 编译器耗时从 8.74 秒降至 0.61 秒,端到端编译时间从 9 秒以上压到了 1 秒以内,GitHub Actions CI 从接近一小时缩短到三分钟。同时,模块级绑定按规范键去重合并——同一绑定被多处使用时只生成一次,函数体生成失败时临时绑定点不会泄漏到最终输出中,gdcc-0.0.2.jar 也从 3.8 MB 瘦身到 2.3 MB。

单例、枚举和常量访问支持

0.0.2 中最大的前端变革之一,是彻底打通了引擎单例、枚举常量和类常量的访问路径。

此前的 GDCC 对类似 Input.is_action_pressed("ui_accept") 的写法束手无策。这一版本引入了双角色类型元数据偏向路由——当一个名字同时作为单例和类存在于引擎 API 中时(例如 Engine 既是一个单例实例,又是 Engine 类型的名称),前端能够根据调用上下文正确区分:Engine.get_frames_drawn() 走单例实例调用路径,IP.RESOLVER_MAX_QUERIES 走类型元数据静态加载路径。

更具体地说,这一机制覆盖了以下场景:

  • 单例实例调用Engine.get_frames_drawn()Input.is_action_pressed(...) 等以 @GlobalScope 下的单例名为接收者的方法调用。
  • 类常量访问IP.RESOLVER_MAX_QUERIESResourceUID.INVALID_IDDisplayServer.MAIN_WINDOW_ID 等引擎类常量。
  • 继承静态成员解析Node2D.NOTIFICATION_ENTER_TREE 现在可以沿 inherits 链从父类 Node 中解析出来,不再仅限于直接定义在当前类上的常量。
  • 枚举值查找:引擎类和内置类的枚举值通过 ClassRegistry 统一管理,支持直接查找和继承链查找。

这项特性由 PR #46PR #49 共同完成,涉及从前端语义分析到后端 load_static 指令生成的完整链路。

更完善的局部类型推导

GDScript 的 var x := some_expression() 是一种优雅的语法糖:声明变量同时让编译器推导类型。但在 0.0.1 中,这个推导结果并不总是可靠——在某些分析阶段之间,推导出的类型会被误读为 Variant,导致后续的链式调用无法获得精确的类型信息。

0.0.2 新增了 FrontendLocalTypeStabilizationAnalyzer,插入到语义分析管线中 TopBindingChainBinding 两个阶段之间。它的逻辑很直接:对每个 := 声明的局部变量,在链式绑定消耗其类型之前,先对初始化表达式执行静默解析,将结果写回作用域的类型存储。

这个修复看起来很小,但它的影响遍及所有依赖局部类型推导的代码路径。动态成员访问(PR #45)也借此获得了更可靠的类型事实来源。

受限于当前的分析能力,:= 推导目前仅覆盖函数体内的局部变量声明。属性初始化、for/match 绑定、lambda 捕获等场景的推导稳定性将在后续版本中逐步完善。

隐式转换的基石

隐式类型转换是一座编译器需要小心翼翼地铺设的桥。0.0.1 选择了最保守的策略——几乎全部拒绝。0.0.2 开始有选择地铺设第一批桥面。

这一版本新增了三类隐式转换:

来源目标方式
intfloat内建函数 c_int_to_float
Vector2iVector2内建函数 c_vector2i_to_vector2
Vector3iVector3内建函数 c_vector3i_to_vector3
Vector4iVector4内建函数 c_vector4i_to_vector4
StringStringName目标类型构造
StringNameString目标类型构造

这些转换覆盖了普通赋值、局部/属性初始化、固定参数调用、可变参数边界、返回值槽和下标键/索引等多种边界场景。StringStringName 之间的双向转换还额外覆盖了 GDExtension call_func 入站包装器。

同时,隐式转换矩阵文档 被提升为所有转换决策的单一事实来源。什么被允许、什么被拒绝、为什么——都可以在这里找到答案。就目前而言,float→intVector*→Vector*iRect2i→Rect2、布尔与数值之间的转换等仍然被明确拒绝,尚未进入支持范围。

Bug 修复

除了功能性的推进,0.0.2 也修复了一批影响正确性的问题:

  • 类型化对象的 nil 判等PR #47):if obj == null 现在对类型化对象正确工作,同时修复了对象与 null 比较时的编译门控。
  • Variant 属性与方法绑定的 ABI 修复:此前在生成的 C 代码中,Variant 类型的属性读取和方法调用存在 ABI 层面的不一致,导致运行时可能拿到错误的数据。这一版本对齐了属性表面和方法绑定两端的接口约定。
  • 动态调用写回不变量冻结:前端在动态调用路径上引入了写回不变量的显式冻结点,防止中间状态污染后续分析。
  • 对象生命周期对齐:局部对象清理的时机从”模棱两可”调整为与 Godot 引用计数语义一致。
  • 预留合成属性名拒绝:前端现在会拒绝用户代码中使用 __gdcc_ 前缀的属性名,避免与编译器内部合成的辅助属性发生冲突。

结语

0.0.2 是 GDCC 一次在多条前线上同时推进的版本。更快的编译工具链让开发迭代不再漫长等待,基准测试让优化有了锚点,隐式转换让类型系统不再那么生硬,单例和常量访问则让更多真实的 GDScript 代码可以直接通过编译。接下来的版本将继续拓宽隐式转换的覆盖面、深化类型推导能力,并针对基准测试中暴露的薄弱环节进行定向优化。

一如既往,Jar 和平台分发包已发布在 GitHub Releases。欢迎各位来尝试、反馈、参与。

以上,感谢。