GDCC 0.0.2 — Benchmarks, Faster Compilation, Implicit Conversions & Static Access
GDCC is a compiler that compiles GDScript into GDExtension native binary libraries. From 0.0.1 to 0.0.2, GDCC saw roughly 60,000 lines added across 280 files. After two months, we’re releasing 0.0.2 and setting off once more.
Continuous Performance Tracking
Compiler optimization can’t happen without measurement. 0.0.2 introduces a full Godot-driven benchmark framework covering 14 cases across four categories: algorithm, collection, math, and runtime. Each case runs in both compiled and interpreted modes, producing a direct head-to-head comparison.
Compiled and interpreted execution time for collection benchmark cases.
| Case | Compiled mean | Compiled stddev | Interpreter mean | Interpreter stddev | Interpreter / compiled |
|---|---|---|---|---|---|
| Array mutation | 3.9 us | 365 ns | 1.1 us | 100 ns | 0.28x |
| Bloom filter | 13.8 us | 819 ns | 23.5 us | 245 ns | 1.70x |
| Dictionary lookup | 3 us | 393 ns | 1.2 us | 796 ns | 0.40x |
| Linked list | 3.8 us | 350 ns | 7.2 us | 335 ns | 1.89x |
| Quadtree lookup | 91.5 us | 4.8 us | 377 us | 5.5 us | 4.12x |
| Tree heap | 21.8 us | 536 ns | 11.6 us | 259 ns | 0.53x |
The collection results show that compiled code has a clear edge on compute-heavy patterns — bloom filter and quadtree lookup achieve 1.7x and 4.1x speedups respectively. However, array mutation, dictionary lookup, and tree heap currently run slower than the interpreter. A key reason is that GDExtension call overhead sits at roughly 500ns per invocation, while the GDScript interpreter’s internal call overhead is only about 200ns. When the actual computation is light and most time is spent crossing the boundary, this overhead gap can offset or even reverse the compilation gains. As the compiler progressively inlines more calls, this gap will continue to narrow.
In real game scenarios, these tests map directly to common runtime needs: array and dictionary operations are so ubiquitous they need no introduction; bloom filters serve spatial queries and collision pre-checks; linked lists and tree heaps appear in scene traversal, animation blending, and render ordering; quadtree lookup is the core tool for entity queries and collision optimization in large worlds or scenes with massive entity counts.
Compiled and interpreted execution time for math benchmark cases.
| Case | Compiled mean | Compiled stddev | Interpreter mean | Interpreter stddev | Interpreter / compiled |
|---|---|---|---|---|---|
| Matrix ops | 49.1 ns | 23.8 ns | 4.1 us | 154 ns | 84.06x |
| Newton sqrt | 38.9 ns | 30.3 ns | 742 ns | 62.5 ns | 19.07x |
| Series recurrence | 32.5 ns | 34.6 ns | 3 us | 283 ns | 92.20x |
| Sliding variance | 302 ns | 64.5 ns | 43.2 us | 2.1 us | 142.93x |
| Vector3 transform | 1.5 us | 80.7 ns | 3.2 us | 145 ns | 2.18x |
Math-heavy workloads demonstrate compiled code’s overwhelming advantage: matrix operations are 84x faster, sliding variance 143x, series recurrence 92x, and even the smallest Vector3 transform gains 2.2x. These scenarios have almost no GDExtension call overhead to interfere — pure numeric computation maps directly to efficient C code, allowing the compiler’s advantages to shine fully.
In games, these math operations are everywhere: matrix operations power 3D transforms and shader data preprocessing; Newton’s method handles root-finding and normalization in physics simulation, as well as general numerical computation in games; series recurrence drives procedural generation and noise functions; sliding variance serves performance monitoring and input smoothing; Vector3 transforms are the foundation of character movement, camera tracking, and raycasting.
The benchmark framework itself is also an infrastructure investment. It uses GDCC_BENCHMARK_HEADER / GDCC_BENCHMARK_RESULT protocol lines to measure performance inside the Godot process, outputs structured JSON reports, and supports batch warmup, multi-sample collection, and statistical aggregation. From now on, every performance-sensitive change has numbers to back it up.
A Faster Compilation Toolchain
A compiler is judged not only by how fast the code it generates runs, but also by how fast it compiles. In 0.0.1, building a single test case — from GDScript to a loadable native library — took over 9 seconds on average. 90% of that time was spent inside the C compiler, churning through tens of thousands of Godot binding functions that were never actually used.
PR #37 systematically restructured this pipeline. The old approach used the third-party gdextension-lite library as a Godot API intermediary, generating and compiling over 60,000 wrapper functions per build even when only a few hundred were actually needed. The new approach eliminates the middle layer, generating native ABI headers tailored to the current Godot version and dividing Godot API functions into three tiers: runtime-provided (GDExtension interfaces, builtin type constructors/destructors/methods, global utilities — pre-compiled into the runtime library), fixed support (singleton registries, type databases, object lifetime paths — always-needed special bindings generated once), and module-local (only singleton pointers, constants, methods, and constructors genuinely used by the current script are written into the module header).
This restructuring dropped per-test C compiler time from 8.74 seconds to 0.61 seconds, brought end-to-end compile time from over 9 seconds to under 1 second, and shrank GitHub Actions CI from nearly an hour to under three minutes. Module-local bindings are also now deduplicated by canonical key — the same binding used in multiple places is emitted only once, and failed function body generation no longer leaks temporary bindings into the final output. Alongside these changes, gdcc-0.0.2.jar slimmed from 3.8 MB to 2.3 MB.
Singleton, Enum, and Constant Access
One of the biggest frontend transformations in 0.0.2 is the full wiring of engine singleton, enum constant, and class constant access paths.
Previously, GDCC had no answer for code like Input.is_action_pressed("ui_accept"). This release introduces dual-role type-meta bias routing: when a name exists in the engine API as both a singleton instance and a type (for example, Engine is both a singleton and the name of the Engine class), the frontend correctly distinguishes based on call context — Engine.get_frames_drawn() takes the singleton instance path, while IP.RESOLVER_MAX_QUERIES takes the type-meta static load path.
Specifically, this covers:
- Singleton instance calls:
Engine.get_frames_drawn(),Input.is_action_pressed(...), and any method call whose receiver is a singleton name under@GlobalScope. - Class constant access:
IP.RESOLVER_MAX_QUERIES,ResourceUID.INVALID_ID,DisplayServer.MAIN_WINDOW_ID, and other engine class constants. - Inherited static member resolution:
Node2D.NOTIFICATION_ENTER_TREEcan now be resolved by walking theinheritschain from parent classNode, rather than being limited to constants defined directly on the current class. - Enum value lookups: Engine and built-in class enum values are managed uniformly through
ClassRegistry, supporting both direct and inherited-chain lookups.
This feature was delivered across PR #46 and PR #49, covering the full pipeline from frontend semantic analysis to backend load_static instruction generation.
Better Local Type Inference
GDScript’s var x := some_expression() is an elegant piece of syntax sugar: declare a variable and let the compiler figure out the type. But in 0.0.1, the inferred result wasn’t always reliable — the type could be misread as Variant between certain analysis stages, denying downstream chain calls the precise type information they needed.
0.0.2 adds FrontendLocalTypeStabilizationAnalyzer, inserted into the semantic analysis pipeline between the TopBinding and ChainBinding phases. The logic is straightforward: for every := declared local, before chain binding consumes its type, the initializer expression is silently resolved and the result written back into the scope’s type storage.
This fix looks small, but its impact touches every code path that depends on local type inference. Dynamic member access (PR #45) also gained a more reliable source of type facts as a result.
Given current analysis capabilities, := inference currently covers only local variable declarations inside function bodies. Inference stability for property initialization, for/match bindings, and lambda captures will be progressively addressed in future releases.
Laying the Groundwork for Implicit Conversions
Implicit type conversion is a bridge a compiler must lay carefully. 0.0.1 took the most conservative approach — reject almost everything. 0.0.2 begins selectively laying the first spans.
This release adds three families of implicit conversions:
| Source | Target | Materialization |
|---|---|---|
int | float | intrinsic c_int_to_float |
Vector2i | Vector2 | intrinsic c_vector2i_to_vector2 |
Vector3i | Vector3 | intrinsic c_vector3i_to_vector3 |
Vector4i | Vector4 | intrinsic c_vector4i_to_vector4 |
String | StringName | target-typed construct_builtin |
StringName | String | target-typed construct_builtin |
These conversions cover ordinary assignment, local/property initializers, fixed call arguments, vararg boundaries, return slots, and subscript key/index boundaries. The bidirectional String ↔ StringName conversion also covers GDExtension call_func inbound wrappers.
Meanwhile, the implicit conversion matrix document has been promoted to the single source of truth for all conversion decisions. What’s allowed, what’s rejected, and why — all find their answers there. For now, float→int, Vector*→Vector*i, Rect2i→Rect2, numeric↔boolean conversions, and others remain explicitly unsupported.
Bug Fixes
Beyond feature work, 0.0.2 also ships a batch of correctness fixes:
- Typed object nil equality (PR #47):
if obj == nullnow works correctly for typed objects, and the compile-time gate for object/null comparisons has been fixed. - Variant property and method binding ABI fixes: Previously, Variant-type property reads and method calls had ABI-level inconsistencies in the generated C code that could produce wrong data at runtime. This release aligns the interface contracts on both the property surface and method binding sides.
- Dynamic call writeback invariants frozen: The frontend now introduces explicit freeze points for writeback invariants on dynamic call paths, preventing intermediate state from polluting downstream analysis.
- Object lifetime alignment: Local object cleanup timing has been adjusted from “ambiguous” to match Godot reference-counting semantics.
- Reserved synthetic property name rejection: The frontend now rejects user code that uses
__gdcc_-prefixed property names, preventing collisions with compiler-generated helper properties.
Closing Notes
0.0.2 is a multi-front release. A faster compilation toolchain means iteration no longer waits on the build, benchmarks provide an anchor for optimization, implicit conversions soften rough edges in the type system, and singleton/constant access lets more real-world GDScript code pass through the compiler. Upcoming releases will continue widening implicit conversion coverage, deepening type inference capabilities, and targeting the weak spots exposed by the benchmark suite.
As always, the jar and platform distributions are available on GitHub Releases. Come try it, give feedback, get involved.
Thank you.