| … | … | @@ -0,0 +1,4 @@ |
| 1 | extends SceneTree | |
| 2 | func _init() -> void: | |
| 3 | print("report: ", ProcGeometryNode.get_last_exec_report()) | |
| 4 | quit(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. | |
| 16 | extends SceneTree | |
| 17 | ||
| 18 | const EPS := 1e-4 | |
| 19 | ||
| 20 | var _fail := 0 | |
| 21 | ||
| 22 | func _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 | ||
| 28 | func _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 | ||
| 47 | func _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 | ||
| 58 | func _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) |
| … | … | @@ -842,6 +842,11 @@ void GraphExecutor::_execute_simulation_zone(ProcEvalContext &p_ctx, const ProcZ |
| 842 | 842 | } |
| 843 | 843 | } |
| 844 | 844 | |
| 845 | Dictionary &GraphExecutor::exec_report() { | |
| 846 | static Dictionary report; | |
| 847 | return report; | |
| 848 | } | |
| 849 | ||
| 845 | 850 | void GraphExecutor::_detect_gpu_batches(const ProcNodeGraph &p_graph, ExecutionPlan &p_plan) { |
| 846 | 851 | // Find contiguous runs of GPU-batchable nodes in execution order |
| 847 | 852 | int batch_start = -1; |
| … | … | @@ -900,6 +905,7 @@ void GraphExecutor::_execute_gpu_batch(ProcEvalContext &p_ctx, const GpuBatch &p |
| 900 | 905 | |
| 901 | 906 | // TransformGeometry is too complex for Phase 4 — fall back to CPU |
| 902 | 907 | if (type == StringName("TransformGeometry")) { |
| 908 | exec_report()[String(type)] = "cpu"; | |
| 903 | 909 | node->execute(p_ctx, node_idx); |
| 904 | 910 | continue; |
| 905 | 911 | } |
| … | … | @@ -920,15 +926,34 @@ void GraphExecutor::_execute_gpu_batch(ProcEvalContext &p_ctx, const GpuBatch &p |
| 920 | 926 | if (node->get_input_type(si) == (int)SocketType::Geometry) { |
| 921 | 927 | continue; |
| 922 | 928 | } |
| 923 | if (pc_map.has(si)) { | |
| 929 | if (!p_ctx.is_input_connected(node_idx, si)) { | |
| 924 | 930 | continue; |
| 925 | 931 | } |
| 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) { | |
| 927 | 951 | unrepresentable = true; |
| 928 | 952 | break; |
| 929 | 953 | } |
| 930 | 954 | } |
| 931 | 955 | if (unrepresentable) { |
| 956 | exec_report()[String(type)] = "cpu (a connected input the shader has no channel for)"; | |
| 932 | 957 | node->execute(p_ctx, node_idx); |
| 933 | 958 | continue; |
| 934 | 959 | } |
| … | … | @@ -938,6 +963,7 @@ void GraphExecutor::_execute_gpu_batch(ProcEvalContext &p_ctx, const GpuBatch &p |
| 938 | 963 | Ref<ProcGeometry> input_geom = p_ctx.read_geometry(node_idx, 0); |
| 939 | 964 | if (input_geom.is_null() || input_geom->get_mesh().is_null()) { |
| 940 | 965 | // No geometry — CPU fallback |
| 966 | exec_report()[String(type)] = "cpu (no input geometry)"; | |
| 941 | 967 | node->execute(p_ctx, node_idx); |
| 942 | 968 | continue; |
| 943 | 969 | } |
| … | … | @@ -981,6 +1007,7 @@ void GraphExecutor::_execute_gpu_batch(ProcEvalContext &p_ctx, const GpuBatch &p |
| 981 | 1007 | } |
| 982 | 1008 | } |
| 983 | 1009 | |
| 1010 | exec_report()[String(type)] = "gpu"; | |
| 984 | 1011 | uint64_t t_upload = Time::get_singleton()->get_ticks_usec(); |
| 985 | 1012 | |
| 986 | 1013 | // Upload input buffers (positions and normals as vec4-padded) |
| … | … | @@ -1252,6 +1279,8 @@ Ref<ProcGeometry> GraphExecutor::execute( |
| 1252 | 1279 | ctx.delta_time = p_delta_time; |
| 1253 | 1280 | ctx.simulation_cache = p_simulation_cache; |
| 1254 | 1281 | |
| 1282 | exec_report().clear(); | |
| 1283 | ||
| 1255 | 1284 | // Initialize GPU if a local RenderingDevice is provided and batches exist |
| 1256 | 1285 | bool gpu_available = false; |
| 1257 | 1286 | if (p_use_gpu && p_rd && p_plan.gpu_batches.size() > 0) { |
| … | … | @@ -1291,6 +1320,7 @@ Ref<ProcGeometry> GraphExecutor::execute( |
| 1291 | 1320 | continue; // fed a context field, and no geometry of its own to resolve it |
| 1292 | 1321 | } |
| 1293 | 1322 | if (node.is_valid()) { |
| 1323 | exec_report()[String(node->get_node_type())] = "cpu"; | |
| 1294 | 1324 | uint64_t tn0 = Time::get_singleton()->get_ticks_usec(); |
| 1295 | 1325 | node->execute(ctx, node_idx); |
| 1296 | 1326 | if (_bound_ctx) { |
| … | … | @@ -132,6 +132,23 @@ struct ExecutionPlan { |
| 132 | 132 | |
| 133 | 133 | class GraphExecutor { |
| 134 | 134 | public: |
| 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(); | |
| 135 | 152 | // Compile graph to execution plan (topological sort) |
| 136 | 153 | static ExecutionPlan compile(const ProcNodeGraph &p_graph); |
| 137 | 154 |
| … | … | @@ -22,6 +22,10 @@ Dictionary ProcGeometryNode::_build_overrides() const { |
| 22 | 22 | return d; |
| 23 | 23 | } |
| 24 | 24 | |
| 25 | Dictionary ProcGeometryNode::get_last_exec_report() { | |
| 26 | return GraphExecutor::exec_report(); | |
| 27 | } | |
| 28 | ||
| 25 | 29 | void ProcGeometryNode::_bind_methods() { |
| 26 | 30 | ClassDB::bind_method(D_METHOD("set_input_mesh", "mesh"), &ProcGeometryNode::set_input_mesh); |
| 27 | 31 | ClassDB::bind_method(D_METHOD("get_input_mesh"), &ProcGeometryNode::get_input_mesh); |
| … | … | @@ -39,6 +43,11 @@ void ProcGeometryNode::_bind_methods() { |
| 39 | 43 | ClassDB::bind_method(D_METHOD("mark_dirty"), &ProcGeometryNode::mark_dirty); |
| 40 | 44 | ClassDB::bind_method(D_METHOD("set_graph_from_json", "json"), &ProcGeometryNode::set_graph_from_json); |
| 41 | 45 | 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); | |
| 42 | 51 | ClassDB::bind_method(D_METHOD("_on_graph_changed"), &ProcGeometryNode::_on_graph_changed); |
| 43 | 52 | ClassDB::bind_method(D_METHOD("push_param_delta", "node_idx", "socket_idx", "value"), &ProcGeometryNode::push_param_delta); |
| 44 | 53 | ClassDB::bind_method(D_METHOD("drain_param_deltas"), &ProcGeometryNode::drain_param_deltas); |
| … | … | @@ -99,6 +99,10 @@ public: |
| 99 | 99 | // Mark plan as dirty (graph structure changed — recompile execution plan) |
| 100 | 100 | void invalidate_plan() { plan_dirty = true; needs_update = true; } |
| 101 | 101 | |
| 102 | // node type -> "gpu" | "cpu..." for the last evaluation, from | |
| 103 | // GraphExecutor::last_exec_report. | |
| 104 | static Dictionary get_last_exec_report(); | |
| 105 | ||
| 102 | 106 | // Called when graph resource emits "changed" (e.g. node property edited in inspector) |
| 103 | 107 | void _on_graph_changed(); |
| 104 | 108 |