OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes commits / 00fb4ae

gpu: a connected push-constant socket was one value for every vertex
The batch path did not merely fall back to the CPU too often -- when it DID run, it could compute a different mesh, silently. `_execute_gpu_batch` refused a connected input that was not in the node's push-constant map, and treated being IN the map as proof the socket could cross. It is not. The packer reads such a socket with read_vector() and writes the single value it gets, so a per-vertex FIELD -- the ordinary way to drive Set Position's Offset -- displaced every vertex by the same vector on the GPU while the CPU displaced each by its own. Measured on a 16x16 grid with Offset <- Position * 0.25: worst |dp| = 3.19 between the two paths, on a graph the batch ACCEPTED. A connected constant is still fine; a field or a lazy promise now forces the fallback. CPU and GPU agree exactly (0.0) after. THE REASON NOBODY SAW IT is that nothing could see it. There was no way to ask whether a node had actually run on the GPU, so a CPU-vs-GPU comparison passed by comparing the CPU against itself. `GraphExecutor::exec_report()` is now a node type -> "gpu" | "cpu..." map rebuilt every execute(), bound as `ProcGeometryNode.get_last_exec_report()`, and `demo/gpu_batch_gate.gd` asserts BOTH halves: that the numbers agree and that the node under test was on the GPU when they did. ⚠ THE GATE NEEDS A REAL DEVICE. `--headless` gives a dummy driver, create_local_rendering_device() returns null, and every assertion about the GPU passes vacuously. Run it as DISPLAY=:0 godot --rendering-driver vulkan --path demo \ --script res://gpu_batch_gate.gd ⛔ And a GDExtension trap worth the line: a CLASS-LEVEL `static Dictionary` is constructed when the .so loads, before godot-cpp's bindings exist, and its constructor calls into the engine -- signal 11 on first touch with a backtrace naming libc and nothing else. It is a function-local static now. STILL OPEN, and it is the performance half: the batch admits three node types by NAME (`_is_gpu_geometry_node`, graph_executor.cpp:350) and runs one node per dispatch, so a field feeding a batched node has no channel and both fall to the CPU. GpuCompiler::compile_batch already loops over a node list threading values through GlslEmitContext -- the executor just never hands it more than one. The gate's two remaining red lines are exactly that work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
00fb4ae266d67908554b7f5790f933ffa3645ce9
by plankatron <fleeky@gmail.com> · 2026-09-06T19:02:08.000Z

Changed files (6) +158 -2

demo/_report_probe.gd +4 -0
……@@ -0,0 +1,4 @@
1extends SceneTree
2func _init() -> void:
3 print("report: ", ProcGeometryNode.get_last_exec_report())
4 quit(0)
demo/gpu_batch_gate.gd +92 -0
……@@ -0,0 +1,92 @@
1## Does the GPU batch actually run on the GPU, and does it agree with the CPU?
2##
3## Both halves matter and the second is the one that is easy to fake. The batch
4## falls back to the CPU per node, silently, for four separate reasons -- so a
5## bake-vs-bake comparison passes by comparing the CPU against itself and says
6## nothing at all. ProcGeometryNode.get_last_exec_report() is what makes the
7## difference visible: node type -> "gpu" | "cpu...".
8##
9## The graph is the shape the real ones have: a primitive, a per-vertex FIELD,
10## and a geometry node consuming it.
11##
12## MeshGrid -> SetPosition(Offset <- VectorMath(Position * 0.25))
13##
14## Nothing here is noise, so CPU and GPU must agree to floating-point slack
15## rather than to a tolerance anybody had to argue about.
16extends SceneTree
17
18const EPS := 1e-4
19
20var _fail := 0
21
22func _ck(n: String, ok: bool, d: String) -> void:
23 print(" %-46s %s %s" % [n, "PASS" if ok else "FAIL", d])
24 if not ok:
25 _fail += 1
26
27
28func _build() -> ProcNodeGraph:
29 var g := ProcNodeGraph.new()
30 var grid := g.add_node(MeshGridNode.new())
31 g.get_node(grid).set_input_default(0, 4.0) # size x
32 g.get_node(grid).set_input_default(1, 4.0) # size y
33 g.get_node(grid).set_input_default(2, 16) # verts x
34 g.get_node(grid).set_input_default(3, 16) # verts y
35
36 var pos := g.add_node(PositionNode.new())
37 var vm := g.add_node(VectorMathNode.new())
38 g.get_node(vm).set_input_default(1, Vector3(0.25, 0.25, 0.25))
39 g.connect_sockets(pos, 0, vm, 0)
40
41 var sp := g.add_node(SetPositionNode.new())
42 g.connect_sockets(grid, 0, sp, 0)
43 g.connect_sockets(vm, 0, sp, 3) # Offset
44 return g
45
46
47func _bake(use_gpu: bool) -> PackedVector3Array:
48 var host := ProcGeometryNode.new()
49 host.use_gpu = use_gpu
50 host.set_graph(_build())
51 get_root().add_child(host)
52 var m: ArrayMesh = host.bake_mesh()
53 if m == null or m.get_surface_count() == 0:
54 return PackedVector3Array()
55 return m.surface_get_arrays(0)[Mesh.ARRAY_VERTEX]
56
57
58func _init() -> void:
59 var cpu := _bake(false)
60 _ck("CPU bake produced a mesh", cpu.size() > 0, "%d verts" % cpu.size())
61
62 var gpu := _bake(true)
63 var report: Dictionary = ProcGeometryNode.get_last_exec_report()
64 _ck("GPU bake produced a mesh", gpu.size() > 0, "%d verts" % gpu.size())
65 print(" execution report: %s" % report)
66
67 # ⛔ THE ASSERTION THAT STOPS THIS BEING A NO-OP. Without it the two bakes
68 # agree because they were the same bake.
69 _ck("SetPosition ran on the GPU", report.get("SetPosition", "absent") == "gpu",
70 "reported '%s'" % report.get("SetPosition", "absent"))
71 # VectorMath is deliberately NOT asserted: Position is a lazy field producer,
72 # so its consumer is deferred and materialised inside SetPosition rather than
73 # executed in its own right. What matters is that the node consuming the
74 # field did not bail out for want of a channel to read it through.
75 var bailed := []
76 for k in report.keys():
77 if String(report[k]).begins_with("cpu (a connected input"):
78 bailed.append(k)
79 _ck("no node bailed for an unreadable input", bailed.is_empty(),
80 "bailed: %s" % [bailed])
81
82 if cpu.size() == gpu.size() and cpu.size() > 0:
83 var worst := 0.0
84 for i in range(cpu.size()):
85 worst = max(worst, (cpu[i] - gpu[i]).length())
86 _ck("CPU and GPU agree", worst < EPS, "worst |dp| = " + String.num(worst, 9))
87 else:
88 _ck("CPU and GPU agree", false,
89 "vertex counts differ: %d vs %d" % [cpu.size(), gpu.size()])
90
91 print("\n%d/%d passed" % [6 - _fail, 6])
92 quit(1 if _fail > 0 else 0)
src/graph/graph_executor.cpp +32 -2
……@@ -842,6 +842,11 @@ void GraphExecutor::_execute_simulation_zone(ProcEvalContext &p_ctx, const ProcZ
842842 }
843843}
844844
845Dictionary &GraphExecutor::exec_report() {
846 static Dictionary report;
847 return report;
848}
849
845850void GraphExecutor::_detect_gpu_batches(const ProcNodeGraph &p_graph, ExecutionPlan &p_plan) {
846851 // Find contiguous runs of GPU-batchable nodes in execution order
847852 int batch_start = -1;
……@@ -900,6 +905,7 @@ void GraphExecutor::_execute_gpu_batch(ProcEvalContext &p_ctx, const GpuBatch &p
900905
901906 // TransformGeometry is too complex for Phase 4 — fall back to CPU
902907 if (type == StringName("TransformGeometry")) {
908 exec_report()[String(type)] = "cpu";
903909 node->execute(p_ctx, node_idx);
904910 continue;
905911 }
……@@ -920,15 +926,34 @@ void GraphExecutor::_execute_gpu_batch(ProcEvalContext &p_ctx, const GpuBatch &p
920926 if (node->get_input_type(si) == (int)SocketType::Geometry) {
921927 continue;
922928 }
923 if (pc_map.has(si)) {
929 if (!p_ctx.is_input_connected(node_idx, si)) {
924930 continue;
925931 }
926 if (p_ctx.is_input_connected(node_idx, si)) {
932 if (!pc_map.has(si)) {
933 unrepresentable = true;
934 break;
935 }
936 // ⛔ A PUSH CONSTANT IS ONE VALUE FOR EVERY VERTEX.
937 //
938 // Being in the map was treated as proof a socket could cross,
939 // and it is not: the packer reads it with read_vector() and
940 // writes the single value it gets. Wire a per-vertex FIELD into
941 // it -- which is the ordinary way to drive Set Position's
942 // Offset -- and the GPU displaces every vertex by the same
943 // vector while the CPU displaces each by its own. Measured on a
944 // 16x16 grid with Offset <- Position * 0.25: worst |dp| = 3.19
945 // between the two paths, on a graph the batch ACCEPTED. Silent,
946 // and the mesh was simply wrong.
947 //
948 // A connected CONSTANT is still fine -- one value is what it is.
949 const SocketValue raw = p_ctx._read_input_raw(node_idx, si);
950 if (raw.is_field || raw.is_lazy) {
927951 unrepresentable = true;
928952 break;
929953 }
930954 }
931955 if (unrepresentable) {
956 exec_report()[String(type)] = "cpu (a connected input the shader has no channel for)";
932957 node->execute(p_ctx, node_idx);
933958 continue;
934959 }
……@@ -938,6 +963,7 @@ void GraphExecutor::_execute_gpu_batch(ProcEvalContext &p_ctx, const GpuBatch &p
938963 Ref<ProcGeometry> input_geom = p_ctx.read_geometry(node_idx, 0);
939964 if (input_geom.is_null() || input_geom->get_mesh().is_null()) {
940965 // No geometry — CPU fallback
966 exec_report()[String(type)] = "cpu (no input geometry)";
941967 node->execute(p_ctx, node_idx);
942968 continue;
943969 }
……@@ -981,6 +1007,7 @@ void GraphExecutor::_execute_gpu_batch(ProcEvalContext &p_ctx, const GpuBatch &p
9811007 }
9821008 }
9831009
1010 exec_report()[String(type)] = "gpu";
9841011 uint64_t t_upload = Time::get_singleton()->get_ticks_usec();
9851012
9861013 // Upload input buffers (positions and normals as vec4-padded)
……@@ -1252,6 +1279,8 @@ Ref<ProcGeometry> GraphExecutor::execute(
12521279 ctx.delta_time = p_delta_time;
12531280 ctx.simulation_cache = p_simulation_cache;
12541281
1282 exec_report().clear();
1283
12551284 // Initialize GPU if a local RenderingDevice is provided and batches exist
12561285 bool gpu_available = false;
12571286 if (p_use_gpu && p_rd && p_plan.gpu_batches.size() > 0) {
……@@ -1291,6 +1320,7 @@ Ref<ProcGeometry> GraphExecutor::execute(
12911320 continue; // fed a context field, and no geometry of its own to resolve it
12921321 }
12931322 if (node.is_valid()) {
1323 exec_report()[String(node->get_node_type())] = "cpu";
12941324 uint64_t tn0 = Time::get_singleton()->get_ticks_usec();
12951325 node->execute(ctx, node_idx);
12961326 if (_bound_ctx) {
src/graph/graph_executor.h +17 -0
……@@ -132,6 +132,23 @@ struct ExecutionPlan {
132132
133133class GraphExecutor {
134134public:
135 // ⛔ THE ONLY WAY TO SEE WHETHER A NODE ACTUALLY RAN ON THE GPU.
136 //
137 // The batch path falls back to the CPU per node, silently, for four
138 // separate reasons -- so a CPU-vs-GPU equivalence test passes trivially by
139 // comparing the CPU against itself. Nothing observed that until now, which
140 // is why the batch could contain three node types for months without
141 // anyone noticing. node type -> "gpu" | "cpu", rebuilt every execute().
142 //
143 // A static because evaluation is single-threaded and one report per
144 // process is what a gate reads; the alternative is threading a Dictionary
145 // through every signature for the benefit of a test.
146 // A FUNCTION-LOCAL static, not a class-level one: a class-level Dictionary
147 // is constructed when the .so loads, before godot-cpp's bindings exist, and
148 // the constructor calls straight into the engine -- signal 11 on the first
149 // touch, with a backtrace that names libc and nothing else. Constructed on
150 // first use, it is built after initialisation like everything else.
151 static Dictionary &exec_report();
135152 // Compile graph to execution plan (topological sort)
136153 static ExecutionPlan compile(const ProcNodeGraph &p_graph);
137154
src/scene/proc_geometry_node.cpp +9 -0
……@@ -22,6 +22,10 @@ Dictionary ProcGeometryNode::_build_overrides() const {
2222 return d;
2323}
2424
25Dictionary ProcGeometryNode::get_last_exec_report() {
26 return GraphExecutor::exec_report();
27}
28
2529void ProcGeometryNode::_bind_methods() {
2630 ClassDB::bind_method(D_METHOD("set_input_mesh", "mesh"), &ProcGeometryNode::set_input_mesh);
2731 ClassDB::bind_method(D_METHOD("get_input_mesh"), &ProcGeometryNode::get_input_mesh);
……@@ -39,6 +43,11 @@ void ProcGeometryNode::_bind_methods() {
3943 ClassDB::bind_method(D_METHOD("mark_dirty"), &ProcGeometryNode::mark_dirty);
4044 ClassDB::bind_method(D_METHOD("set_graph_from_json", "json"), &ProcGeometryNode::set_graph_from_json);
4145 ClassDB::bind_method(D_METHOD("invalidate_plan"), &ProcGeometryNode::invalidate_plan);
46 // node type -> "gpu" | "cpu..." for the LAST evaluation. See
47 // GraphExecutor::last_exec_report: without it a CPU-vs-GPU gate cannot tell
48 // agreement from a silent fallback comparing the CPU against itself.
49 ClassDB::bind_static_method("ProcGeometryNode",
50 D_METHOD("get_last_exec_report"), &ProcGeometryNode::get_last_exec_report);
4251 ClassDB::bind_method(D_METHOD("_on_graph_changed"), &ProcGeometryNode::_on_graph_changed);
4352 ClassDB::bind_method(D_METHOD("push_param_delta", "node_idx", "socket_idx", "value"), &ProcGeometryNode::push_param_delta);
4453 ClassDB::bind_method(D_METHOD("drain_param_deltas"), &ProcGeometryNode::drain_param_deltas);
src/scene/proc_geometry_node.h +4 -0
……@@ -99,6 +99,10 @@ public:
9999 // Mark plan as dirty (graph structure changed — recompile execution plan)
100100 void invalidate_plan() { plan_dirty = true; needs_update = true; }
101101
102 // node type -> "gpu" | "cpu..." for the last evaluation, from
103 // GraphExecutor::last_exec_report.
104 static Dictionary get_last_exec_report();
105
102106 // Called when graph resource emits "changed" (e.g. node property edited in inspector)
103107 void _on_graph_changed();
104108