OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes commits / dfb575f

fields: do not materialise an output nobody reads
Mesh bit-identical: 2,582,018 verts, sum(z)=1792386.7043, sumsq(z)=28382638.9998. Whole bake 4.67 s -> 4.51 s. Every node wrote every output whether the graph read it or not. Measured on the bridged mountain, where EVERY node reads exactly one port: VoronoiTexture writes 5 arrays, port 0 read -- 41 MB Colour, 31 MB position, 2 x 10 MB NoiseTexture writes 2, port 0 read -- 41 MB Colour GradientTexture writes 2, port 1 read -- 41 MB Colour ~175 MB of materialisation an evaluation, plus the arithmetic behind it, for values with no reader. The liveness walk added in 7948510 already crosses every edge to decide which NODES run; it now records which SOCKET each edge reads, and ProcEvalContext::is_output_needed answers from that. Recorded even when the producer is already live, because a second consumer may want a different port. An empty table means "no information" and answers true for everything, so a graph evaluated without a liveness pass behaves exactly as before. ⚠ ONLY 160 ms, against ~175 MB of writes avoided. So this graph is not bound by that traffic the way the audit assumed -- the arrays are written once and never read again, which is the cheap direction on a machine with this much bandwidth. Kept because computing values with no reader is wrong regardless, and because the saving scales with the number of unread outputs rather than with this one graph. ⛔ NOT chunked field evaluation, which is what the audit actually called for and is still open. That is fields becoming lazily-evaluated functions over an index range, consumer-driven, with intermediates living in per-chunk buffers -- a rewrite of SocketValue, ProcEvalContext and every node's execute(), not an addition beside them. Blender's equivalent is FN_field.hh with parallel_for(mask.index_range(), 2048). This commit is the other end of the same problem: not materialising what has no reader, rather than not materialising in full. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dfb575fe6b263b7e7b7a8dfb494db2663fa0f466
by plankatron <fleeky@gmail.com> · 2026-09-08T18:22:41.000Z

Changed files (3) +80 -15

src/graph/graph_executor.cpp +28 -1
……@@ -174,6 +174,17 @@ SocketValue ProcEvalContext::materialize_on(const SocketValue &p_val,
174174 return adapted;
175175}
176176
177bool ProcEvalContext::is_output_needed(int p_node_idx, int p_socket_idx) const {
178 if (output_needed.is_empty() || max_output_sockets <= 0) {
179 return true; // no liveness information: behave as before
180 }
181 const int slot = p_node_idx * max_output_sockets + p_socket_idx;
182 if (slot < 0 || slot >= output_needed.size()) {
183 return true;
184 }
185 return output_needed[slot];
186}
187
177188bool ProcEvalContext::is_input_connected(int p_node_idx, int p_socket_idx) const {
178189 if (graph == nullptr) {
179190 return false;
……@@ -1513,10 +1524,15 @@ Vector<bool> GraphExecutor::_compute_live_set(const ProcNodeGraph &p_graph,
15131524 for (int i = 0; i < count; i++) {
15141525 live.write[i] = false;
15151526 }
1527 p_ctx.output_needed.resize(count * p_ctx.max_output_sockets);
1528 for (int i = 0; i < p_ctx.output_needed.size(); i++) {
1529 p_ctx.output_needed.write[i] = false;
1530 }
15161531 if (p_plan.output_node < 0 || p_plan.output_node >= count) {
15171532 for (int i = 0; i < count; i++) {
15181533 live.write[i] = true;
15191534 }
1535 p_ctx.output_needed.clear(); // no information: is_output_needed says yes
15201536 return live;
15211537 }
15221538
……@@ -1588,7 +1604,18 @@ Vector<bool> GraphExecutor::_compute_live_set(const ProcNodeGraph &p_graph,
15881604 continue; // the branch this Switch does not take
15891605 }
15901606 const int src = p_graph.get_input_source_node(idx, s);
1591 if (src < 0 || src >= count || live[src]) {
1607 if (src < 0 || src >= count) {
1608 continue;
1609 }
1610 // Which SOCKET of the producer this edge reads. Recorded even when
1611 // the producer is already live, because a second consumer may want
1612 // a different port.
1613 const int ssock = p_graph.get_input_source_socket(idx, s);
1614 const int slot = src * p_ctx.max_output_sockets + ssock;
1615 if (slot >= 0 && slot < p_ctx.output_needed.size()) {
1616 p_ctx.output_needed.write[slot] = true;
1617 }
1618 if (live[src]) {
15921619 continue;
15931620 }
15941621 live.write[src] = true;
src/graph/graph_executor.h +19 -0
……@@ -65,6 +65,25 @@ public:
6565 // A default cannot express that, so the node has to ask.
6666 bool is_input_connected(int p_node_idx, int p_socket_idx) const;
6767
68 // ── DOES ANYONE READ THIS OUTPUT? ───────────────────────────────────────
69 //
70 // Nodes wrote every output whether or not the graph read it. Measured on
71 // the bridged voronoi mountain, where EVERY node reads exactly one port:
72 // Voronoi Texture writes five arrays over 2.58M elements -- a 41 MB Colour,
73 // a 31 MB position and two more -- for one float anybody wanted. Noise
74 // Texture and Gradient Texture each write a 41 MB Colour nobody reads.
75 // ~175 MB of materialisation an evaluation, plus the arithmetic behind it.
76 //
77 // Blender does not have this problem because a field is a FUNCTION and
78 // nothing is materialised until a consumer asks over its own domain. This
79 // is the same answer at socket granularity: skip what has no reader.
80 //
81 // Empty means "no information" -- a graph evaluated without a liveness pass
82 // gets true for everything, so the old behaviour is what an unanalysed
83 // graph still does.
84 bool is_output_needed(int p_node_idx, int p_socket_idx) const;
85 Vector<bool> output_needed; // node * max_output_sockets + socket
86
6887 // Redeem a context field against a specific geometry. A value that is not
6988 // lazy is returned unchanged, so callers need not care which they have.
7089 SocketValue materialize(const SocketValue &p_val, const Ref<ProcGeometry> &p_geo);
src/nodes/noise_nodes.cpp +33 -14
……@@ -204,14 +204,18 @@ void PerlinNoiseNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
204204 // and this did two of them per element. Measured on the bridged mountain,
205205 // NoiseTexture was 9.8 s of an 11.4 s value cone -- the largest single node
206206 // in it once Voronoi was threaded.
207 float *facs_p = facs.ptrw();
208 Color *cols_p = cols.ptrw();
207 const bool need_fac = p_ctx.is_output_needed(p_node_idx, 0);
208 const bool need_col = p_ctx.is_output_needed(p_node_idx, 1);
209 if (!need_fac) { facs.resize(0); }
210 if (!need_col) { cols.resize(0); }
211 float *facs_p = need_fac ? facs.ptrw() : nullptr;
212 Color *cols_p = need_col ? cols.ptrw() : nullptr;
209213 proc_parallel_for(n, [&](int e) {
210214 float fac;
211215 Color col;
212216 at(e, fac, col);
213 facs_p[e] = fac;
214 cols_p[e] = col;
217 if (facs_p) facs_p[e] = fac;
218 if (cols_p) cols_p[e] = col;
215219 });
216220 p_ctx.write_float_field(p_node_idx, 0, facs, domain);
217221 p_ctx.write_color_field(p_node_idx, 1, cols, domain);
……@@ -658,11 +662,26 @@ void VoronoiTextureNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
658662 // ⛔ HOIST THE WRITE POINTERS. ptrw() is a copy-on-write check, and this
659663 // wrote through five of them PER ELEMENT -- 12.9 million CoW checks on the
660664 // bridged mountain's 2.58M vertices, for five stores.
661 float *dist_p = is_field ? dist_a.ptrw() : nullptr;
662 Color *col_p = is_field ? col_a.ptrw() : nullptr;
663 Vector3 *pos_p = is_field ? pos_a.ptrw() : nullptr;
664 float *w_p = is_field ? w_a.ptrw() : nullptr;
665 float *rad_p = is_field ? rad_a.ptrw() : nullptr;
665 // Only the ports something reads. Voronoi has five, and a graph reading one
666 // of them still paid for a 41 MB Colour and a 31 MB position over 2.58M
667 // elements.
668 const bool need_dist = p_ctx.is_output_needed(p_node_idx, 0);
669 const bool need_col = p_ctx.is_output_needed(p_node_idx, 1);
670 const bool need_pos = p_ctx.is_output_needed(p_node_idx, 2);
671 const bool need_w = p_ctx.is_output_needed(p_node_idx, 3);
672 const bool need_rad = p_ctx.is_output_needed(p_node_idx, 4);
673 if (is_field) {
674 if (!need_dist) { dist_a.resize(0); }
675 if (!need_col) { col_a.resize(0); }
676 if (!need_pos) { pos_a.resize(0); }
677 if (!need_w) { w_a.resize(0); }
678 if (!need_rad) { rad_a.resize(0); }
679 }
680 float *dist_p = (is_field && need_dist) ? dist_a.ptrw() : nullptr;
681 Color *col_p = (is_field && need_col) ? col_a.ptrw() : nullptr;
682 Vector3 *pos_p = (is_field && need_pos) ? pos_a.ptrw() : nullptr;
683 float *w_p = (is_field && need_w) ? w_a.ptrw() : nullptr;
684 float *rad_p = (is_field && need_rad) ? rad_a.ptrw() : nullptr;
666685
667686 // Per element and independent: every iteration reads its own inputs and
668687 // writes its own slot. The scalar path is n == 1 and stays serial.
……@@ -751,11 +770,11 @@ void VoronoiTextureNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
751770 Color c(color.x, color.y, color.z, 1.0f);
752771 Vector3 p3(position.x, position.y, position.z);
753772 if (is_field) {
754 dist_p[e] = dist;
755 col_p[e] = c;
756 pos_p[e] = p3;
757 w_p[e] = position.w;
758 rad_p[e] = radius;
773 if (dist_p) dist_p[e] = dist;
774 if (col_p) col_p[e] = c;
775 if (pos_p) pos_p[e] = p3;
776 if (w_p) w_p[e] = position.w;
777 if (rad_p) rad_p[e] = radius;
759778 } else {
760779 s_dist = dist;
761780 s_col = c;