[Data] Fix DAG inconsistency in Operator._apply_transform - #61245
[Data] Fix DAG inconsistency in Operator._apply_transform#61245veeceey wants to merge 2 commits into
Conversation
Test resultsRan standalone tests (ray doesn't have Python 3.14 wheels yet, so tested by direct module loading): Test 1 is the exact reproduction from the issue: a = Operator("A", [])
b = Operator("B", [])
c = Operator("C", [a, b])
def transform_b(op):
if op.name == "B":
return Operator("Transformed B", op.input_dependencies)
return op
c_transformed = c._apply_transform(transform_b)
# Before fix: FAILS (A points to old C)
# After fix: PASSES
assert c_transformed is c_transformed.input_dependencies[0].output_dependencies[0]Also added |
There was a problem hiding this comment.
Code Review
The pull request effectively addresses the DAG inconsistency issue in Operator._apply_transform by ensuring bidirectional consistency when only some input dependencies are transformed. The changes correctly handle the shallow copy, fresh output dependencies, and removal of stale references, which are crucial for maintaining the integrity of the DAG. The added test cases provide good coverage for the identified scenarios, including partial transform, identity transform, and diamond DAGs, which helps validate the fix.
| for input_op in self.input_dependencies: | ||
| if self in input_op._output_dependencies: | ||
| input_op._output_dependencies.remove(self) |
There was a problem hiding this comment.
The loop iterates over self.input_dependencies to remove stale references. However, self.input_dependencies is the original list of input operators. If transformed_input_op is a new operator, self might not be in its _output_dependencies list, leading to incorrect behavior or missed updates for the new operators. The intent is to update the output_dependencies of the original input operators that were not transformed.
| for input_op in self.input_dependencies: | |
| if self in input_op._output_dependencies: | |
| input_op._output_dependencies.remove(self) | |
| for i, input_op in enumerate(self.input_dependencies): | |
| if transformed_input_ops[i] is input_op and self in input_op._output_dependencies: | |
| input_op._output_dependencies.remove(self) |
| @@ -67,22 +67,30 @@ def _apply_transform( | |||
| """ | |||
|
|
|||
| transformed_input_ops = [] | |||
| new_ops = [] | |||
| input_changed = False | |||
There was a problem hiding this comment.
The variable input_changed is initialized to False but is not used until line 77. It's good practice to initialize variables closer to their first use to improve readability and reduce potential for errors, especially in larger functions.
| input_changed = False | |
| transformed_input_ops = [] | |
| for input_op in self.input_dependencies: | |
| transformed_input_op = input_op._apply_transform(transform) | |
| transformed_input_ops.append(transformed_input_op) | |
| # Keep track of whether any input ops changed | |
| if transformed_input_op is not input_op: | |
| input_changed = True | |
| input_changed = False |
d2904dd to
6e83ecd
Compare
| target._wire_output_deps(new_ops) | ||
| # Create a fresh output_dependencies list for the copy to avoid | ||
| # sharing the same list object as the original operator | ||
| target._output_dependencies = [] |
There was a problem hiding this comment.
It appears that the only difference between this patch and #57887 is this specific line. Could you clarify the exact cases you are aiming to handle here? Providing an example test case would be really helpful for understanding the context better. Thanks!
|
@peterxcli Good question — the two PRs tackle the same root issue (#57825) but the fix here handles one additional detail that #57887 misses. Both PRs share the same core change: tracking The difference is this line in my PR: target._output_dependencies = []Without it, Here's a concrete example where this matters: a = Operator("A", [])
b = Operator("B", [a])
c = Operator("C", [b]) # c is in b's output_dependencies
def transform_a(op):
if op.name == "A":
return Operator("A2", [])
return op
b2 = b._apply_transform(transform_a)
# b2 is a shallow copy of b
# Without `_output_dependencies = []`, b2._output_dependencies IS b._output_dependencies
# So b2 still appears to have c as a downstream, even though c was never rewired to b2.
# Worse, if anything later appends to b2._output_dependencies, it also modifies b's list.In #57887, I also added a |
6e83ecd to
a81e499
Compare
|
gentle ping on this one! |
|
@peterxcli @cursor[bot] thanks for the feedback — just pushed a fix addressing both points. Re: _output_dependencies not initialized on base Operator — good catch. I added Re: concrete test case showing the difference from #57887 — added a = Operator("A", [])
b = Operator("B", [a])
c = Operator("C", [b])
a._output_dependencies = [b]
b._output_dependencies = [c]
def transform_a(op):
if op.name == "A":
return Operator("A2", [])
return op
b_transformed = b._apply_transform(transform_a)
# Without target._output_dependencies = [], this fails:
# b_transformed._output_dependencies is b._output_dependencies, still containing [c]
assert c not in b_transformed._output_dependenciesPR #57887's |
When _apply_transform partially transforms a DAG (only some input dependencies change), the unchanged operators still had their output_dependencies pointing to the original parent instead of the new copy. This broke bidirectional DAG consistency. The fix ensures that when a shallow copy is made: 1. The copy gets a fresh output_dependencies list 2. Stale references from the original are cleaned up 3. All transformed input ops are wired to the new target Closes ray-project#57825 Signed-off-by: Varun Chawla <varun_6april@hotmail.com>
…e_output_deps to base Operator Initialize _output_dependencies in Operator.__init__ and add the output_dependencies property and _wire_output_deps method so that the _apply_transform stale-reference cleanup works on LogicalOperator instances (not just PhysicalOperator which has its own override). Also adds test_apply_transform_copy_isolates_output_dependencies to demonstrate the specific shallow-copy list-sharing bug that target._output_dependencies = [] prevents — this is the concrete scenario that differentiates this fix from PR ray-project#57887. Signed-off-by: Varun Chawla <varun_6april@hotmail.com>
9604bc3 to
561fc5a
Compare
|
This pull request has been automatically marked as stale because it has not had You can always ask for help on our discussion forum or Ray's public slack channel. If you'd like to keep this open, just leave any comment, and the stale label will be removed. |
|
hey, this is still relevant and ready for review -- removing the stale label. the fix handles an edge case that #57887 doesn't cover (stale _output_dependencies). happy to rebase if needed. |

Closes #57825
When
_apply_transformis called and only some input dependencies are transformed, the resulting DAG becomes inconsistent. Unchanged operators still point to the original parent in theiroutput_dependencies, while the new (shallow-copied) parent lists them ininput_dependencies. This means you can't reliably traverse the DAG bidirectionally.The root cause is on the old line 85:
target._wire_output_deps(new_ops)only wires new operators to the copy, but unchanged operators still reference the old parent.The fix does three things when a copy is needed:
output_dependencieslist on the copy (avoids sharing the list with the original)output_dependenciesreferences from unchanged input opsThis is a fresh implementation based on the analysis in #57825 and the stalled PR #57887.
Test plan: