How CeresGraph Compilation and Runtime Initialization Works: A Deep Dive into the JIT Pipeline
CeresGraph compilation and runtime initialization employ a just-in-time (JIT) model that transforms serialized graph descriptions into fully wired executable objects through pooled compilers, topological sorting, and lazy evaluation on first access.
The akikurisu/ceres repository implements this system to convert static FlowGraphData assets into live execution engines. Understanding CeresGraph compilation and runtime initialization is critical for optimizing performance and minimizing garbage collection in high-frequency graph executions.
Phase 1: Deserialization into Persistent Graph Objects
The process begins when a serialized graph definition—typically a CeresGraphData or derived type like FlowGraphData—is loaded into memory. During this phase, the raw data structure is converted into a concrete CeresGraph or FlowGraph instance.
According to the source code in Runtime/Core/Models/Graph/CeresGraph.cs (lines 45-66), the BuildGraph method handles the initial wiring of nodes, variables, and sub-graph slots. This creates the persistent graph object that serves as the foundation for subsequent compilation steps, establishing the node list and variable containers without yet preparing them for execution.
Phase 2: JIT Compilation with CeresGraphCompiler
When a graph is first accessed, the just-in-time compilation phase begins. The Compile method (e.g., FlowGraph.Compile) triggers the transformation from static description to executable runtime.
The compilation infrastructure centers on CeresGraphCompiler, located in Runtime/Core/Models/Graph/CeresGraphCompiler.cs. The static method GetPooled (lines 20-50) retrieves a compiler instance from an object pool, initializing it with references to both the source graph (Source) and the target graph (Target)—typically the same object for standard graphs. This design minimizes allocation overhead by reusing compiler state across multiple graph instances.
The compiler also supports extensibility through ICeresGraphCompilationContext callbacks, allowing custom logic to hook into the compilation pipeline.
The Six-Step Compilation Sequence
Inside CeresGraph.Compile (Runtime/Core/Models/Graph/CeresGraph.cs), the JIT process executes six distinct phases:
-
SetCompilerTarget(lines 28-32): Assigns theTargetreference on the compiler, enabling nodes to reference the compiled graph instance during initialization. -
InitVariables(lines 59-71): Collects everySharedVariabledeclared by nodes, registers them on the graph's blackboard, and establishes links to their source definitions. -
InitPorts(lines 74-88): CallsInitializePortson each node instance, then links every port to its counterpart—including delegate ports—viaLinkPortoperations. -
CollectDependencyPath(lines 93-101): Performs a topological sort of the node dependency graph and caches the execution order as_nodeDependencyPath. This pre-computation eliminates runtime sorting overhead during event execution. -
CompileNodes(lines 123-131): Iterates over every node and, for those implementingIRuntimeCompiledNode, invokes theirCompilemethod. This step handles node-specific JIT work, such as building expression delegates or optimizing internal state machines. -
Blackboard.LinkToGlobal(lines 133-135): Connects the graph's local blackboard to any global blackboard system, enabling cross-graph variable access and shared state management.
After these steps complete, the CeresGraphCompiler automatically returns to the pool when the using block disposes it (see the Dispose method in CeresGraphCompiler.cs, lines 52-62).
Phase 3: Runtime Execution and Event Handling
Once compilation finishes, the graph enters the execution phase. The compiled FlowGraph maintains an array of ExecutableEvent objects representing entry points such as functions and custom events (defined in Runtime/Flow/Models/Nodes/CustomFunctions/FlowNode_ExecuteCustomFunction.cs, lines 30-38).
Runtime components implement IFlowGraphRuntime (typically MonoBehaviours) to interact with the graph. When executing an event, the extension method ProcessEvent from FlowGraphRuntimeExtensions creates an ExecuteFlowEvent instance, obtains a callback handler via FlowGraph.GetOrCreateEventHandler, and forwards execution through an ExecutionContext (see Runtime/Flow/Models/FlowGraph.cs, lines 31-40).
The handler extracts the event name from the EventBase using CustomExecutionEvent.GetEventName, locates the matching ExecutableEvent in the FlowGraph.Events array, and creates a pooled ExecutionContext. The system then asynchronously traverses the node chain using the pre-computed dependency path from Phase 2.
Key Optimizations in CeresGraph Runtime
The CeresGraph compilation and runtime initialization pipeline implements several performance-critical optimizations:
-
Lazy compilation: Graphs compile only when first accessed via the
Graphproperty orGetOrCreateEventHandler, deferring initialization costs until actually needed. -
Object pooling: Both
CeresGraphCompilerinstances and per-executionExecutionContextobjects utilizeObjectPool<T>andListPool<T>to eliminate GC pressure during repeated graph executions. -
Dependency path caching: The topological sort result (
_nodeDependencyPath) persists on the graph instance, allowing event execution to walk a pre-computed index list rather than recalculating dependencies dynamically. -
Blackboard linking: Post-compilation linking to global blackboards enables efficient variable sharing across graph boundaries without marshaling overhead.
Practical Implementation Example
The following pattern demonstrates explicit compilation and event execution:
// Load serialized asset
var data = myFlowGraphAsset; // FlowGraphData
var flowGraph = data.CreateFlowGraphInstance();
// Explicit compilation (optional - happens automatically on first use)
using var compiler = CeresGraphCompiler.GetPooled(flowGraph, null);
flowGraph.Compile(compiler);
// Runtime execution from a MonoBehaviour
public class MyBehaviour : MonoBehaviour, IFlowGraphRuntime
{
public FlowGraph Graph => flowGraph;
public UnityEngine.Object Object => this;
void Start()
{
// Fires "OnStart" event using CallerMemberName attribute
this.ProcessEvent();
}
void Update()
{
// Pass parameters to the graph execution
this.ProcessEvent(Time.deltaTime, "player");
}
}
Summary
- CeresGraph compilation and runtime initialization follow a three-phase pipeline: deserialization, JIT compilation via
CeresGraphCompiler, and event-driven execution. - The
CeresGraphCompileruses object pooling and implements a six-step sequence including variable initialization, port linking, topological sorting, and node-specific compilation. - Runtime execution leverages pre-cached dependency paths and pooled
ExecutionContextinstances to minimize overhead. - Graphs link to global blackboards after compilation, enabling cross-graph variable sharing.
- Compilation occurs lazily on first access, optimizing memory usage for inactive graph instances.
Frequently Asked Questions
What triggers CeresGraph compilation?
Compilation triggers automatically upon first access to the Graph property or when calling GetOrCreateEventHandler. This lazy initialization pattern ensures that graphs incur initialization costs only when actively used, improving scene load times and memory efficiency for dormant graph assets.
How does CeresGraph minimize garbage collection during execution?
The system employs extensive object pooling through ObjectPool<T> and ListPool<T> for both the CeresGraphCompiler instances and per-execution ExecutionContext objects. Additionally, the topological dependency path is computed once during compilation and cached as _nodeDependencyPath, eliminating the need for runtime list allocations during event processing.
Can I customize the compilation process for specific node types?
Yes. Nodes implementing IRuntimeCompiledNode receive a Compile callback during the CompileNodes phase (lines 123-131 of CeresGraph.cs). This allows node implementations to perform JIT optimizations such as pre-building expression delegates or caching reflection-heavy lookups before runtime execution begins.
What is the relationship between Source and Target in CeresGraphCompiler?
In most graph configurations, Source and Target reference the same graph instance. The Source represents the original graph definition being compiled, while Target represents the compiled output. This separation supports advanced scenarios where compilation might generate a different runtime representation, though standard flows maintain identity between both references.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →