OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes commits / 7948510

exec: run what the output can reach -- 8.3 s -> 4.7 s, past Blender
Mesh bit-identical, as at every step: 2,582,018 verts, x -20..20 y -2..23.765 z -20..20, sum(z)=1792386.7043, sumsq(z)=28382638.9998. whole bake 8.3 s -> 4.67 s Blender, same graph, same switch setting: 2.14 s ... 2.18x Blender with Solidify ON (so it runs the SDF): 4.34 s The executor ran every node in topological order. This graph's Solidify input is 0, so its geometry Switch takes the displaced-mesh branch and GridToMesh is discarded -- yet MeshToSDFGrid and GridToMesh still ran, 4.0 s of an 8.3 s evaluation, to produce a mesh thrown away at the Switch. Both have now vanished from the execution log. HOW BLENDER DOES IT, since this is the same decision made statically: node_geo_switch.cc declares both branches lf::ValueUsage::Maybe, reads the condition, and calls params.set_input_unused() on the branch it does not want. Its geometry nodes are a demand-driven lazy-function graph, so the scheduler simply never runs that subgraph. Here: each Switch's condition is settled first by executing only its condition sub-cone -- cheap by construction, since a condition depending on the heavy branch would be a cycle -- and then liveness is walked backwards from the output, entering a Switch only through the branch it takes. Nodes that cannot reach the output fall out of the same walk, so plain dead code costs nothing extra and needs no second mechanism. ⚠ A VARYING CONDITION KEEPS BOTH BRANCHES, and for Blender's reason: its execute_field() requests both when the condition is a field, because the choice is then per element. A Switch whose condition is not a single value is left alone. ⚠ A Switch fed from inside a ZONE is also left alone -- a zone body cannot be evaluated out of its zone's order -- and anything inside a live zone stays live, because a zone body is reached through its pairing rather than its links. Baselines held: op parity 770/770, golden 31/32, batch 7/8, emitted-density 100/100. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7948510655cb94eac4bc9243216811ee441721f5
by plankatron <fleeky@gmail.com> · 2026-09-08T18:11:11.000Z

Changed files (2) +158 -27

src/graph/graph_executor.cpp +155 -27
……@@ -131,15 +131,6 @@ static bool _bind_context_or_defer(ProcEvalContext &p_ctx, int p_node_idx,
131131 return false;
132132}
133133
134// Per-node time spent in the LAZY path. The value cone is deferred and then run
135// from materialize(), so every one of its nodes is charged to whichever geometry
136// node happened to ask -- "CPU node [SetPosition]: 11.4 s" is the whole cone,
137// and says nothing about which node in it to thread.
138static HashMap<String, double> &_lazy_ms() {
139 static HashMap<String, double> acc;
140 return acc;
141}
142
143134SocketValue ProcEvalContext::materialize(const SocketValue &p_val,
144135 const Ref<ProcGeometry> &p_geo) {
145136 if (!p_val.is_lazy || graph == nullptr || p_val.lazy_node < 0) {
……@@ -153,14 +144,7 @@ SocketValue ProcEvalContext::materialize(const SocketValue &p_val,
153144 // Restoring the previous context keeps nesting honest.
154145 Ref<ProcGeometry> prev = context_geometry;
155146 context_geometry = p_geo;
156 {
157 const uint64_t _t0 = Time::get_singleton()->get_ticks_usec();
158 producer->execute(*this, p_val.lazy_node);
159 const uint64_t _t1 = Time::get_singleton()->get_ticks_usec();
160 HashMap<String, double> &acc = _lazy_ms();
161 const String k = String(producer->get_node_type());
162 acc[k] = (acc.has(k) ? acc[k] : 0.0) + (double)(_t1 - _t0) / 1000.0;
163 }
147 producer->execute(*this, p_val.lazy_node);
164148 context_geometry = prev;
165149 int slot = p_val.lazy_node * max_output_sockets + p_val.lazy_socket;
166150 if (slot < 0 || slot >= output_values.size()) {
……@@ -1480,6 +1464,151 @@ static Ref<ProcGeometry> _to_godot_frame(const Ref<ProcGeometry> &p_geo,
14801464 return p_geo;
14811465}
14821466
1467
1468// ── WHICH NODES CAN REACH THE OUTPUT, GIVEN WHAT THE SWITCHES DECIDE ─────────
1469//
1470// The executor ran every node in topological order. On the bridged voronoi
1471// mountain that meant computing MeshToSDFGrid and GridToMesh -- 4.0 s of an
1472// 8.3 s evaluation -- and then throwing the result away, because the graph's
1473// Solidify input is 0 and the geometry Switch takes the other branch. Blender
1474// never touches it: its geometry nodes are a demand-driven lazy-function graph,
1475// and node_geo_switch.cc declares both branches lf::ValueUsage::Maybe, reads the
1476// condition, then calls params.set_input_unused() on the one it does not want,
1477// so the scheduler never runs that subgraph at all.
1478//
1479// This is the same decision made statically. Conditions are settled first --
1480// each Switch's condition sub-cone is executed on its own, which is cheap
1481// because a condition that depended on the heavy branch would be a cycle -- and
1482// then liveness is walked backwards from the output, entering a Switch only
1483// through the branch it actually takes.
1484//
1485// ⚠ A VARYING CONDITION NEEDS BOTH BRANCHES, and Blender says so in the same
1486// file: execute_field() requests both when the condition is a field, because
1487// the choice is then per element. A Switch whose condition is not a single
1488// value is left alone here for exactly that reason.
1489//
1490// Nodes that cannot reach the output at all drop out of the same walk, so the
1491// dead-code case costs nothing extra.
1492static void _collect_input_cone(const ProcNodeGraph &p_graph, int p_node, int p_socket,
1493 Vector<bool> &r_live) {
1494 const int src = p_graph.get_input_source_node(p_node, p_socket);
1495 if (src < 0 || src >= r_live.size() || r_live[src]) {
1496 return;
1497 }
1498 r_live.write[src] = true;
1499 Ref<ProcNode> n = p_graph.get_node(src);
1500 if (n.is_null()) {
1501 return;
1502 }
1503 for (int s = 0; s < n->get_input_count(); s++) {
1504 _collect_input_cone(p_graph, src, s, r_live);
1505 }
1506}
1507
1508Vector<bool> GraphExecutor::_compute_live_set(const ProcNodeGraph &p_graph,
1509 ExecutionPlan &p_plan, ProcEvalContext &p_ctx) {
1510 const int count = p_graph.get_node_count();
1511 Vector<bool> live;
1512 live.resize(count);
1513 for (int i = 0; i < count; i++) {
1514 live.write[i] = false;
1515 }
1516 if (p_plan.output_node < 0 || p_plan.output_node >= count) {
1517 for (int i = 0; i < count; i++) {
1518 live.write[i] = true;
1519 }
1520 return live;
1521 }
1522
1523 // 1. Settle every Switch's condition, by running only what feeds it.
1524 HashMap<int, int> taken; // switch node -> the input socket to follow
1525 for (int i = 0; i < count; i++) {
1526 Ref<ProcNode> n = p_graph.get_node(i);
1527 if (n.is_null() || n->get_node_type() != StringName("Switch")) {
1528 continue;
1529 }
1530 Vector<bool> cone;
1531 cone.resize(count);
1532 for (int k = 0; k < count; k++) {
1533 cone.write[k] = false;
1534 }
1535 _collect_input_cone(p_graph, i, 0, cone);
1536 if (p_graph.get_input_source_node(i, 0) < 0) {
1537 // Unconnected: the socket default decides, and nothing needs running.
1538 const bool c = (bool)n->get_input_default(0);
1539 taken[i] = c ? 2 : 1;
1540 continue;
1541 }
1542 // A zone body cannot be evaluated out of order, so leave that Switch be.
1543 bool in_zone = false;
1544 for (int k = 0; k < count && !in_zone; k++) {
1545 if (cone[k] && k < p_plan.node_zone_map.size() && p_plan.node_zone_map[k] >= 0) {
1546 in_zone = true;
1547 }
1548 }
1549 if (in_zone) {
1550 continue;
1551 }
1552 for (int o = 0; o < p_plan.execution_order.size(); o++) {
1553 const int idx = p_plan.execution_order[o];
1554 if (idx < 0 || idx >= count || !cone[idx]) {
1555 continue;
1556 }
1557 Ref<ProcNode> cn = p_graph.get_node(idx);
1558 Ref<ProcGeometry> prev;
1559 bool bound = false;
1560 if (cn.is_valid() && !_bind_context_or_defer(p_ctx, idx, cn, prev, bound)) {
1561 cn->execute(p_ctx, idx);
1562 if (bound) {
1563 p_ctx.context_geometry = prev;
1564 }
1565 }
1566 }
1567 const SocketValue cond = p_ctx.read_input(i, 0);
1568 if (cond.is_field || cond.is_lazy) {
1569 continue; // per element: both branches are live, as in Blender
1570 }
1571 taken[i] = cond.to_bool() ? 2 : 1;
1572 }
1573
1574 // 2. Liveness backwards from the output, through the taken branch only.
1575 Vector<int> stack;
1576 live.write[p_plan.output_node] = true;
1577 stack.push_back(p_plan.output_node);
1578 while (!stack.is_empty()) {
1579 const int idx = stack[stack.size() - 1];
1580 stack.remove_at(stack.size() - 1);
1581 Ref<ProcNode> n = p_graph.get_node(idx);
1582 if (n.is_null()) {
1583 continue;
1584 }
1585 const bool is_switch = n->get_node_type() == StringName("Switch") && taken.has(idx);
1586 for (int s = 0; s < n->get_input_count(); s++) {
1587 if (is_switch && s != 0 && s != taken[idx]) {
1588 continue; // the branch this Switch does not take
1589 }
1590 const int src = p_graph.get_input_source_node(idx, s);
1591 if (src < 0 || src >= count || live[src]) {
1592 continue;
1593 }
1594 live.write[src] = true;
1595 stack.push_back(src);
1596 }
1597 }
1598
1599 // A zone's body is reached through its pairing, not its links, so anything
1600 // inside a live zone stays live.
1601 for (int i = 0; i < count; i++) {
1602 if (i < p_plan.node_zone_map.size() && p_plan.node_zone_map[i] >= 0) {
1603 const ProcZoneInfo &z = p_plan.zones[p_plan.node_zone_map[i]];
1604 if (z.output_node >= 0 && z.output_node < count && live[z.output_node]) {
1605 live.write[i] = true;
1606 }
1607 }
1608 }
1609 return live;
1610}
1611
14831612Ref<ProcGeometry> GraphExecutor::execute(
14841613 const ProcNodeGraph &p_graph,
14851614 ExecutionPlan &p_plan,
……@@ -1527,10 +1656,19 @@ Ref<ProcGeometry> GraphExecutor::execute(
15271656 gpu_available = true;
15281657 }
15291658
1659 // What the output can actually reach, once the switches have decided.
1660 const Vector<bool> live = _compute_live_set(p_graph, p_plan, ctx);
1661
15301662 // Execute nodes in topological order
15311663 for (int i = 0; i < p_plan.execution_order.size(); i++) {
15321664 int node_idx = p_plan.execution_order[i];
15331665
1666 // Not on any path to the output: Blender's scheduler would never have
1667 // been asked for it.
1668 if (node_idx >= 0 && node_idx < live.size() && !live[node_idx]) {
1669 continue;
1670 }
1671
15341672 // Skip zone body nodes (executed by zone handler when output node is reached)
15351673 if (node_idx < p_plan.node_zone_map.size() && p_plan.node_zone_map[node_idx] >= 0) {
15361674 int zone_idx = p_plan.node_zone_map[node_idx];
……@@ -1584,16 +1722,6 @@ Ref<ProcGeometry> GraphExecutor::execute(
15841722 }
15851723 }
15861724
1587 {
1588 HashMap<String, double> &acc = _lazy_ms();
1589 for (const KeyValue<String, double> &kv : acc) {
1590 if (kv.value > 0.5) {
1591 UtilityFunctions::print(" LAZY node [", kv.key, "]: ", kv.value, "ms");
1592 }
1593 }
1594 acc.clear();
1595 }
1596
15971725 // Free per-evaluation GPU buffers (pipelines persist in ExecutionPlan for reuse)
15981726 ctx.gpu_buffers.free_all();
15991727
src/graph/graph_executor.h +3 -0
……@@ -178,6 +178,9 @@ public:
178178 int p_node_idx, int p_socket_idx,
179179 const Dictionary &p_input_overrides = Dictionary());
180180
181 static Vector<bool> _compute_live_set(const ProcNodeGraph &p_graph,
182 ExecutionPlan &p_plan, ProcEvalContext &p_ctx);
183
181184 static Ref<ProcGeometry> execute(
182185 const ProcNodeGraph &p_graph,
183186 ExecutionPlan &p_plan,