Skip to content

fix[next-dace]: detect write-after-read hazard in copy chain removal - #2863

Closed
edopao wants to merge 13 commits into
GridTools:mainfrom
edopao:fix/copy-chain-remover-war
Closed

edopao wants to merge 13 commits into
GridTools:mainfrom
edopao:fix/copy-chain-remover-war

Conversation

@edopao

@edopao edopao commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Description

CopyChainRemover merges a (A1) -> (A2) copy by removing one container and
rewiring the flow onto the survivor. Depending on the mode this moves either the
writes or the reads of the removed transient:

  • PULL mode ((T) -> (G), survivor G): the transient T is removed and
    its writes are taken over by G, which moves them back to where T was
    produced.
  • PUSH mode ((G) -> (T), survivor G): the transient T is removed and
    its consumers are rerouted to read from G directly, which moves the read of
    G downstream to where T was consumed.

When the surviving container is a global that is also accessed elsewhere in
the same state, the removed transient was acting as a double buffer and the move
can let a write overtake a read (PULL) or a read overtake a write (PUSH),
yielding silently wrong results. This is the pattern generated by an in-place
update stencil, i.e. a field that a field_operator call takes as an input and
also produces as an output.

Fix

Add _creates_write_after_read_hazard and split it by copy-chain mode:

  • PULL: the write to G is moved upstream, so a read of G upstream of
    the survivor can be overtaken. Scan the ancestor region of the survivor for
    reads of the same data.
  • PUSH: the read of G is moved downstream, so a write to G between the
    survivor and where the transient was consumed can be overtaken. Scan the
    descendant region of the survivor for writes to the same data.

Transients are written only once (ADR-18), so they are unaffected and are
skipped by the check. As part of the PUSH scan, find_downstream_nodes is added
as the symmetric counterpart to find_upstream_nodes.

Tests

  • test_copy_chain_destination_read_in_map_no_apply reproduces the in-place
    update pattern in PULL mode:

    for i in range(10):
        d[i] = c[i] + 1.0  # `c` read in a branch independent of the producer of `b`.
        b[i] = a[i] * 2.0
    c[:] = b[:]  # Copy chain `(b) -> (c)`: the surviving global `c` is read in the Map.

    The (b) -> (c) copy chain must not be removed, since c survives and is
    still read in the Map.

  • test_push_chain_source_written_in_map_no_apply mirrors the above for
    PUSH mode:

    b[:] = a[:]  # Copy chain `(a) -> (b)`: the surviving global `a` is copied into `b`.
    for i in range(10):
        d[i] = b[i] + 1.0  # `b` read in a branch (old value of `a`).
        a[i] = a[i] * 2.0  # `a` written again in another branch.

    The (a) -> (b) copy chain must not be removed, since a survives and is
    written again in the Map before the consumer reads.

Both tests compile and run the SDFG before and after, assert the transformation
does not apply, and that the transient double buffer is retained. Each was
verified to fail when the corresponding guard is neutralized, confirming the
tests reproduce the issue.

Notes

This is one of a family of write-after-read fixes for in-place update stencils on
the DaCe backend (cf. #2815 GT4PyMapBufferElimination, #2816 DoubleWriteRemover,
#2817 GT4PyStateFusion). It targets a distinct transformation (CopyChainRemover)
and does not overlap with those PRs; the PULL reproducer deliberately mirrors the SDFG
shape used in #2816. The fix was extracted from the dace_refactor_lowering draft
(#2690) into this standalone PR.

@edopao
edopao force-pushed the fix/copy-chain-remover-war branch from 1e3e29c to eca19e5 Compare September 4, 2026 14:02

@philip-paul-mueller philip-paul-mueller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that this is a similar but different kind of bug than the other ones and it is a real defect in the transformation.
The changes looks okay, however, I think that we do not need to scan all nodes in the graph, but it is enough to perform the test on the upstream nodes (although it could even be reduced more, by recognizing that a global access node acts as a serialization point.

@philip-paul-mueller philip-paul-mueller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks good to me, but there is one question that I have and might trigger another round.
But if the answer is negative, i.e. this case is harmless, then we can approve it right away.

Address review comments on PR GridTools#2863 in
`_creates_write_after_read_hazard`:

- reword the comment describing which reads are affected, so the
  "downstream of" vs "pass through" distinction is clearer
- add a TODO noting that empty Memlets and the order they impose are
  currently ignored
- remove the redundant `graph.out_degree(node) != 0` guard: every node
  returned by `find_upstream_nodes` is reached by walking an incoming
  edge, so it always has at least one outgoing edge
The write-after-read hazard check in CopyChainRemover only scanned
upstream of the survivor, which is correct for PULL mode ((T) -> (G),
survivor G): the write to G is moved upstream, so reads upstream of
the survivor can be overtaken.

In PUSH mode ((G) -> (T), survivor G) the read of G is instead
moved downstream, to where the transient was consumed. If G is written
at another AccessNode between the survivor and that consumer, the moved
read would observe the new value instead of the one frozen in the
temporary. The upstream scan did not catch this.

Split `_creates_write_after_read_hazard` by mode:
- PULL: scan upstream for reads (unchanged)
- PUSH: scan downstream for writes to the same data

Add `find_downstream_nodes` as the symmetric counterpart to
`find_upstream_nodes`, and a regression test mirroring the existing
PULL-mode test for the PUSH case.

@philip-paul-mueller philip-paul-mueller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM some modified comments.
It is important that we do a performance test to see if there are changes.

Comment on lines -224 to -227
# NOTE: In case the node we keep is a non-transient we do not have to
# check if it is read or written to somewhere else in this state.
# The reason is that ADR18 guarantees that every access is point-wise,
# therefore the temporary we remove is never used as double buffer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please keep this note but reformulate it to:
NOTE: ADR18 technically only allows for point-wise dependencies for global accesses, however, to support ICON4Py this is sometimes not fully validated, see test_copy_chain_destination_read_in_map_no_apply() for an example.
The issue could be solved by adding empty Memlets, but the pipeline support of them outside of Maps is not very mature.
The simplest thing would be to forbid inout variables altogether.
Thus we simply forbid this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have updated the comment., but differently.

The hazard check previously rejected the copy-chain removal whenever any
AccessNode with the same data as the survivor was found upstream (PULL)
or downstream (PUSH), using plain graph traversal that ignores connector
names.  This over-rejects the common case of a single-branch map whose
tasklet reads the surviving global upstream of the copy: the read and the
moved write share the same branch, so no write-after-read hazard exists.

Add a connector-aware path counter 'count_paths' to the dace
transformations utils.  Unlike the existing find_upstream_nodes /
find_downstream_nodes / is_reachable (and DaCe's own all_simple_paths,
which is networkx-backed and connector-agnostic), it only continues
through the scope connector that pairs with the one it arrived on
(IN_<x> <-> OUT_<x>), following real data flow.  A single map tasklet
reading two inputs therefore reports one path to a downstream survivor,
not two.

Relax the check: when the removed temporary has a single producer and
the survivor has no other producer/consumer besides the copy, a
same-data access with a single connector-aware path to the survivor is
ordered before the moved write both before and after the merge, so the
removal is safe.  Otherwise the conservative answer (reject) is kept,
including the case where the plain-upstream match was a connector-
crossing false positive (count == 0).  This preserves the existing
hazard detection (test_copy_chain_destination_read_in_map_no_apply,
test_push_chain_source_written_in_map_no_apply) while allowing the new
test_copy_chain_destination_read_single_path_apply.

Also drop a test-only 'raise AssertionError' debug line in can_be_applied.
@edopao
edopao force-pushed the fix/copy-chain-remover-war branch from 411303f to 8db5d1f Compare October 14, 2026 14:17
@edopao

edopao commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

After analysis, it is very difficult to detect all valid cases, which do not represent a write-after-read hazard.

@edopao edopao closed this Oct 15, 2026
@edopao
edopao deleted the fix/copy-chain-remover-war branch October 15, 2026 10:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants