Skip to content

GDCC 0.0.2 — Benchmarks, Faster Compilation, Implicit Conversions & Static Access

Back to blog

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.

0 us20 us40 us60 us80 us100 us120 us140 us160 us180 us200 us220 us240 us260 us280 us300 us320 us340 us360 us380 us400 usMean time per operationArray mutationBloom filterDictionary lookupLinked listQuadtree lookupTree heap3.9 us13.8 us3 us3.8 us91.5 us21.8 us1.1 us23.5 us1.2 us7.2 us377 us11.6 usCompiledInterpreterCollection benchmark

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.

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 usMean time per operationMatrix opsNewton sqrtSeries recurrenceSliding varianceVector3 transform49.1 ns38.9 ns32.5 ns302 ns1.5 us4.1 us742 ns3 us43.2 us3.2 usCompiledInterpreterMath benchmark

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_TREE can now be resolved by walking the inherits chain from parent class Node, 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:

SourceTargetMaterialization
intfloatintrinsic c_int_to_float
Vector2iVector2intrinsic c_vector2i_to_vector2
Vector3iVector3intrinsic c_vector3i_to_vector3
Vector4iVector4intrinsic c_vector4i_to_vector4
StringStringNametarget-typed construct_builtin
StringNameStringtarget-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 StringStringName 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 == null now 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.