Add collection-based For and ForReturn loop nodes - #189
Conversation
joshistoast
left a comment
There was a problem hiding this comment.
migrate to v7 shell
joshistoast
left a comment
There was a problem hiding this comment.
Handy node and a good port for the most part, needs some updates before I'd call it merge-ready.
# Conflicts: # invokeai/frontend/webv2/performance/architecture-baseline.json # invokeai/frontend/webv2/performance/browser-baseline.json
# Conflicts: # invokeai/frontend/webv2/performance/architecture-baseline.json # invokeai/frontend/webv2/performance/browser-baseline.json # invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.tsx
joshistoast
left a comment
There was a problem hiding this comment.
Solid design and very thorough tests, but not merge-ready yet. Two blocking issues in the scheduler, four things I'd want fixed before merge, and a handful of follow-ups. The short version:
Blocking
- For-loop scheduling is O(N²) and plain Iterate graphs got ~2.5× slower per node. See the comment on
is_complete()for the benchmark and profile. - The
invocation_completeevent for the last For iteration carries the empty placeholder outputs, so the editor never shows the realoutput_collection. See_finalize_for_outputs.
Fix before merge
3. Raw nodes.* error codes still reach users through the library preview path, and the localizer is duplicated into useInvocationState.ts.
4. "loop_linkage" literal still hardcoded beside LOOP_LINKAGE_FIELD in two places.
5. None items are dropped from output_collection, contradicting the docs.
6. No parity fixtures between validateForLoopGraph and the backend validators.
There was a problem hiding this comment.
This is the biggest cost in a regression that seems to affect every graph, not just loops.
Benchmark, driving next()/complete() directly with trivial nodes so only scheduler cost is measured:
| Graph | Items | Total | Per exec node |
|---|---|---|---|
Iterate, main |
600 | 0.15 s | 0.06 ms, flat |
| Iterate, this branch | 600 | 0.37 s | 0.16 ms, growing |
| For, this branch | 100 | 0.24 s | 0.48 ms |
| For, this branch | 300 | 1.58 s | 1.05 ms |
| For, this branch | 600 | 6.0 s | 2.00 ms |
Per-node cost doubles when the collection doubles, so a 2000-item loop spends about a minute in the scheduler before any node runs. cProfile on the 300-item For run: this method → _all_for_contexts_finalized → _get_for_parent_iteration_paths accounts for ~1.3 s of 3.0 s, because it walks every prepared id for every For source on every completion. On main this method iterated source nodes; it now effectively iterates execution nodes.
Suggest caching finalization per source (set it in _mark_loop_context_finalized, clear it when a new context is opened) so this becomes a set lookup. Please also add the benchmark shape as a test with a generous ceiling so it can't regress silently.
Separately: this predicate now mutates executed and executed_history (lines 4232–4236). That belongs in complete().
| if source_node_id not in self.executed_history: | ||
| self.executed_history.append(source_node_id) | ||
|
|
||
| def _get_for_parent_iteration_paths(self, source_for_id: str) -> set[tuple[int, ...]]: |
There was a problem hiding this comment.
An index of prepared For execution nodes keyed by (source_id, parent_iteration_path), maintained in _register_prepared_exec_node, makes this and _get_final_prepared_for_id O(1).
| if source_node_id not in self._state.executed_history: | ||
| self._state.executed_history.append(source_node_id) | ||
|
|
||
| def _get_for_parent(self, exec_node_id: str) -> Optional[str]: |
There was a problem hiding this comment.
Second profiled hotspot (~0.8 s of 3.0 s). Scans all prepared For nodes for every ForReturn completion, and rebuilds nx_graph_flat() on the execution graph each time (line 1754). With the index suggested above, the owning For is a direct lookup by iteration_path[:-1].
Also: nx_graph_flat() is called ~3× per completion across the scheduler (4528 calls for 1502 completions in the profile). Worth caching per complete() call.
| ), | ||
| ) | ||
|
|
||
| for ancestor_id in nx.ancestors(execution_graph, exec_node_id): |
There was a problem hiding this comment.
This fallback returns the first ForInvocation ancestor, which for nested loops can be the outer For. The primary lookup above handles the known cases, so this is defensive, but please add a comment saying when it's expected to fire, or turn it into a RuntimeError if it's genuinely unreachable.
| if isinstance((output := self._state.results.get(prepared_return_id)), ForReturnInvocationOutput) | ||
| ] | ||
|
|
||
| def _finalize_for_outputs( |
There was a problem hiding this comment.
This writes output_collection and final_state into results after the last iteration's invocation_complete has already been emitted from session_processor_default.py:468 with the placeholders returned by ForInvocation.invoke.
Probe: a 2-item loop emits output_collection=[] at invocation_complete time and has [1, 2] in results afterwards. The persisted session is right and downstream nodes get the right value, but the For node's output preview in the editor always shows an empty collection.
Either emit a follow-up event when finalizing, or finalize before the emit (e.g. have complete() run finalization before _record_completed_node returns to the processor).
| }, | ||
| edge, | ||
| node, | ||
| type: 'addNodeAndEdge', |
There was a problem hiding this comment.
Adding a ForReturn from an iteration output dispatches this, then reads projectStore.getSnapshot() at 386, then dispatches a second addEdge at 412. That's two undo steps for one user gesture, and it depends on the store updating synchronously between them. Extend addNodeAndEdge to accept edges: WorkflowEdge[] (or add an addNodeAndEdges action) so the linkage lands in the same history entry.
| const isForIterationOutputConnection = ( | ||
| connectionFilter: AddNodeConnectionFilter | null, | ||
| nodes: WorkflowNode[], | ||
| edges: Parameters<typeof resolveConnectorSource>[2], |
There was a problem hiding this comment.
| edges: Parameters<typeof resolveConnectorSource>[2], | |
| edges: WorkflowEdge[], |
| @@ -1,10 +1,16 @@ | |||
| import type { InvocationTemplate } from '@features/workflow/contracts'; | |||
| import type { InvocationTemplate, InvocationTemplates } from '@features/workflow/contracts'; | |||
| import type { WorkflowNode } from '@features/workflow/core/types'; | |||
There was a problem hiding this comment.
Companion to below
| import type { WorkflowNode } from '@features/workflow/core/types'; | |
| import type { WorkflowEdge, WorkflowNode } from '@features/workflow/core/types'; |
| | `index` | The zero-based position of the current item | Labels, counters, or position-based logic | | ||
| | `total` | The collection length | Progress or position-based logic | | ||
| | `state` | The current loop state | Values carried from earlier iterations | | ||
| | `output_collection` | All values returned through `ForReturn.output` | Work that should happen after the loop | |
There was a problem hiding this comment.
This says "All values", but graph.py:1844 drops None. Whichever way you resolve that comment, make this row match. If you keep the filter:
| | `output_collection` | All values returned through `ForReturn.output` | Work that should happen after the loop | | |
| | `output_collection` | All non-`None` values returned through `ForReturn.output` | Work that should happen after the loop | |
| Object.assign(node, changes); | ||
| // Keep the runtime mutation generic without asking TypeScript to expand the full | ||
| // generated invocation union for Object.assign's inferred intersection type. | ||
| Object.assign(node as object, changes as object); |
There was a problem hiding this comment.
[nit] Understood this is only to keep the legacy frontend compiling after the schema union grew, and it's the smallest possible change. Noting it because we don't normally touch web/; no action needed.
# Conflicts: # invokeai/frontend/webv2/performance/architecture-baseline.json # invokeai/frontend/webv2/performance/browser-baseline.json
Summary
This PR adds bounded, collection-based
ForandForReturnworkflow nodes.Highlights:
LoopState.Forloops and boundedIterate/Collectbodies.CollectionConcatCollectionZipCollectionCartesianloop_linkageedges betweenForandForReturn.ForReturndiscovery and auto-connection in the node picker.happy-domsetup without adding browser-test dependencies.Related Issues / Discussions
QA Instructions
Create some workflows with nested

Forloops,Forloops containing boundedIterate/Collectbodies. This is what an exampleForworkflow might look like:Merge Plan
Checklist
What's Newcopy (if doing a release after this PR)