feat[next-dace]: eliminate write back buffers that are also consumed - #2763
Conversation
2019239 to
e66c340
Compare
philip-paul-mueller
left a comment
There was a problem hiding this comment.
There are some aspects that needs some work.
If there are more question just drop me a line.
|
Review round pushed as three commits, deliberately separated so the move is reviewable as a move.
Tests: Both earlier correctness guards survive the rewrite: I have also put this branch through an independent adversarial review pass; will follow up here with anything it turns up. |
|
Still iterating on this one... |
|
Second round pushed ( An independent review found seven defects; all are fixedEach was reproduced first and is verified by that reproduction:
Not fixed, but now stated accurately in the docstring: The pass only fires with static domain boundsThis was not previously understood, and it is the most important thing here for a reviewer. Instrumenting
With a dynamic domain the transient is allocated with a clamped shape while the write back carries the unclamped extent, and Arguably the source check is now too tight in the same way the destination check was too loose. I have deliberately not touched it: relaxing it would make the pass start firing in configurations where it currently does not, which is a behaviour change and wants a decision rather than being smuggled in with a defect fix. New: a test on lowering produced input
A second test pins the invariant that makes it safe not to update view strides, and guards against passing vacuously by first asserting that views do exist immediately after lowering. Supporting evidence for that invariant, across 149 dycore stencil tests / 85 programs / 357 pass invocations: 858 View nodes exist at One number in the description I can no longer supportIt says "4 Tests: |
There was a problem hiding this comment.
Pull request overview
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Adds a DaCe pass to eliminate “write-back buffers” (global transient + later copy back) when the transient is also consumed, avoiding extra device-to-device copies while preserving correctness (stride propagation + safety guards).
Changes:
- Introduces
GT4PyWriteBackBufferEliminationpass and integrates it into DaCe auto-optimization. - Adds extensive unit + integration tests covering correctness guards (partial/strided writeback, loops, views, nested SDFGs/strides, unrelated readers, empty memlets).
- Exports the new pass from the transformations package.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/next_tests/unit_tests/program_processor_tests/runners_tests/dace_tests/transformation_tests/test_write_back_buffer_elimination.py | New unit tests for numerous SDFG patterns and safety conditions. |
| tests/next_tests/integration_tests/feature_tests/dace_tests/test_write_back_buffer_elimination_lowering.py | New integration tests ensuring the pattern is reachable from real lowering and checking View-related assumptions. |
| src/gt4py/next/program_processors/runners/dace/transformations/write_back_buffer_elimination.py | New transformation pass implementing write-back buffer elimination + safeguards + stride propagation. |
| src/gt4py/next/program_processors/runners/dace/transformations/auto_optimize.py | Runs the new pass during auto-optimization. |
| src/gt4py/next/program_processors/runners/dace/transformations/init.py | Exposes the new pass in __all__. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| dace_ppl.Pipeline( | ||
| [ | ||
| gtx_transformations.GT4PyWriteBackBufferElimination( | ||
| assume_pointwise=assume_pointwise, | ||
| ) | ||
| ] | ||
| ).apply_pass(sdfg, {}) |
There was a problem hiding this comment.
Legitimate point and we should use it at some point, but at this level not really present.
When a field_operator output is also read inside the same program, the lowering gives it a global transient plus a full device-to-device copy back into the destination. No existing pass removes that pair: the copy lives in a separate state and the transient is a global, so neither map fusion nor the existing buffer eliminations apply. `GT4PyWriteBackBufferElimination` rewrites the accesses to the temporary into the destination array and drops the copy. Includes the stride propagation for the rewritten global that a NestedSDFG consumer needs to address the destination correctly, and an `assume_pointwise` waiver narrowed to the case where the read of the global feeds nothing but the map scopes producing the temporary. Both guard against silently wrong results rather than missed optimizations. On the icon4py program `compute_perturbed_quantities_and_interpolation` this removes 4 `cudaMemcpy2DAsync` calls and 4 of 6 global temporaries.
…n file The pass is not part of `gt_simplify()`, so it does not belong into `simplify.py`. Pure move, no behavioural change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Address review feedback: - Document that `G` must not be written or read between the definition of `T` and the write back. - Define what `_find_candidates()` returns and what it guarantees. - Rewrite the `_has_conflicting_global_access()` docstring so it covers the write case as well, and note that it is conservative with respect to the topology of the state machine. - Rewrite the `_only_feeds_tmp_producer()` docstring and rename its `glob_read` argument, which names an AccessNode, to `glob_read_node`. - Drop the `except ValueError` around `remove_data(validate=True)`. Since the definition of `T` was just removed and ADR-18 guarantees a single write location, the exception can only mean the SDFG is invalid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pers `_eliminate()` used to walk `state.edges()` and rewrite every Memlet whose `data` was `T`. That is wrong as soon as `T` is consumed inside a Map: an inner edge of the Memlet tree may well be associated to the other data container, in which case the subset that refers to `T` is the Memlet's `other_subset` and was silently left unshifted. The new test `test_write_back_buffer_elimination_map_consumer[False]` reproduces this; before this commit it computed wrong values. Use `reroute_edge()` and `reconfigure_dataflow_after_rerouting()` instead, which walk the Memlet tree and adjust the subset on the side of `T` independently of the Memlet's association. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A View of `T` has its own descriptor whose strides were derived from the ones of `T`. After the rewrite the View refers to `G` but keeps the strides of `T`: `gt_propagate_strides_of()` only descends into NestedSDFGs and the stride adjustment of Views skips Views of non transient data. Rejecting such a candidate is the conservative choice, the Views are not repaired. Inside a Map scope a View refers to its data through the MapEntry, so `utils.track_view()` can not resolve it; the name is taken from the Memlet of the View edge instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_has_conflicting_global_access()` accepts a read of `G` in a state that is reachable from `wb_state`, because such a state runs after the write back. With a cycle, e.g. a `LoopRegion` back edge, that implication does not hold: a state that reaches `wb_state` again also runs before it, in the next iteration, and would then observe the value that the rewritten producer stored instead of the one the previous iteration wrote back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Range.size()` folds the step away, so a destination such as `b[0:20:2]` has the same size as the copied `tmp[0:10]` and the offset was then taken from `min_element()` alone, which scattered the writes. The check on the source already rejects a step, because `Range.__eq__` compares it. A write back with conflict resolution combines `tmp` with the old value of `b`, which the rewritten producer, that writes `b` unconditionally, does not do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_eliminate()` replaces all AccessNodes of `T` inside a state by a single AccessNode of `G`. If one of them is read and another one written, that merge reorders the two and, when they are connected, makes the state cyclic. The `remove_data(validate=True)` at the end of `_eliminate()` did detect it, but only after the SDFG had already been rewritten, leaving a half applied transformation behind. Such an input violates ADR-18 rule 6 and is now rejected up front. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… `T` An empty Memlet, which only sequences the dataflow, has no subset, and `reroute_edge()` substitutes the full range of the array for a missing subset. The sequencing edge therefore became a copy of the whole array, which also has no connectors and thus fails the validation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pass removes the descriptor of `T` and adjusts the descriptors inside the NestedSDFGs that were mapped from it, so `Descriptors` and `NestedSDFGs` belong into `modifies()`. It also handles explicit control flow, mark it as such. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The class documentation claimed that reads of `G` by the producer of `T` are covered by ADR-18 rule 3. They are, but only for a write back without offset: with an offset the rewritten producer reads `G[i]` and writes `G[i + off]`, so the Map iterations clobber each other's input. Such an SDFG is out of contract, rule 3 is about the very same memory being input and output, and the pass, like `GT4PyMapBufferElimination`, does not detect it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rejection of a viewed `T` must not disable the pass for every SDFG that contains a View. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other test for this pass hand builds an SDFG, so nothing checked that the shape it matches is one the lowering actually emits. It turns out to be narrow: the pass fires only when the domain bounds are static. With a dynamic domain the transient is allocated with a clamped shape while the write back carries the unclamped extent, and since `Range.__eq__` compares sympy structurally, `0:Max(0, hi - lo)` and `0:hi - lo` do not match, so the full copy check rejects the candidate. The program here is the minimum that reproduces it: an output that is also read inside the same program, a branch whose body reads that output, and a static domain. Removing any one of the three stops the pass from matching. The second test pins the invariant that no selected candidate is viewed, which is what makes it safe not to update the strides of views. It first asserts that views do exist right after lowering, so that it fails rather than passes vacuously if they ever stop being removed before this pass runs. Both fixtures are load bearing: the persistent translation cache has to be bypassed or a second run replays the optimized SDFG and the pass is never called, and compilation has to stay in process for the instrumentation to see anything.
d09db8b to
8fdbc71
Compare
|
Note: I rebased this branch onto |
| from gt4py.next.program_processors.runners.dace import transformations as gtx_transformations | ||
|
|
||
|
|
||
| # Conditional import because `gt4py.cartesian` uses an older DaCe version without |
There was a problem hiding this comment.
cartesian and next use the same dace version in latest gt4py, so the try/except below is not needed.
|
The only reason why it does not apply for dynamic bounds is that SymPy does not know that |
philip-paul-mueller
left a comment
There was a problem hiding this comment.
Better, but I see more work and the need for clarifications.
| dace_ppl.Pipeline( | ||
| [ | ||
| gtx_transformations.GT4PyWriteBackBufferElimination( | ||
| assume_pointwise=assume_pointwise, | ||
| ) | ||
| ] | ||
| ).apply_pass(sdfg, {}) |
There was a problem hiding this comment.
Legitimate point and we should use it at some point, but at this level not really present.
…n from ADR-18 Every condition of `_find_candidates()` now names the ADR-18 rule it enforces or the property it establishes. - Order the states with a strictly before relation instead of may reach. ADR-18 rule 5 mandates `LoopRegion`, whose states reach each other, so may reach does not mean "runs after". This replaces the separate cycle guard. - Drop the rejection of a `T` that is read and written in one state, of a `T` node inside a Map scope and of a `G` that has more dimensions than `T`. The first two describe SDFGs that rules 6, 8 and 10 already forbid, the third is subsumed by comparing the subset sizes. - Allow `G` to be read in the write back state and after the write back, where both versions of the SDFG agree, and upstream of the definition of `T`, where nothing has changed yet. Ignore accesses that do not touch the copied range. - Reject a shifted write back when the producer reads `G`; rule 3 is about using the very same memory as input and output. - Accept a read of `G` that feeds the producer of `T` through another Map, not only one that enters the producer Map directly. - Reuse an AccessNode of `G` that is read instead of adding a second one, which rules 3 and 8 only allow for input plus output. - Give up on any View instead of collecting what is viewed. - Take the AccessNodes from `FindAccessNodes` instead of rescanning all states per array. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…contract The positive case is now a matrix over how a consumer refers to `T` — a direct edge between AccessNodes, an edge inside a Map scope, an edge into a NestedSDFG — crossed with which of the two containers the Memlet is associated to and with a zero versus a non zero copy offset. Every defect found so far is one cell of it; the NestedSDFG cells verify the propagated strides through the result instead of asserting that the propagation was called. Around it, one test per condition of `_find_candidates()`: - `T` defined in both branches of a `ConditionalBlock`, once with the write back after the branches and once with a sibling branch that writes `G` itself. - `T` consumed downstream of the write back. - `G` read in the write back state, downstream of it and upstream of the definition of `T`. - a second write to `G`, overlapping the copied range and disjoint from it. - the producer reading `G` through the Map that writes `T` and through a Map upstream of it, with and without `assume_pointwise`, and with a shifted copy. - an unrelated reader of `G` that shares the AccessNode with the producer. - the empty Memlet ending up on the AccessNode that carries the write, which is what makes it impose the order it did before. Dropped the test that a `T` which is read and written in one state is left alone; ADR-18 rules 6 and 8 forbid such an SDFG. The View tests now expect the transformation to give up, and the lowering test asserts that the pipeline hands it an SDFG without Views, so that giving up is not silently disabling it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — this was the right kind of review to get. I have treated it as four root causes rather than 24 fixes, because patching each comment would have produced another round. Two commits: 1. The preconditions were derived empirically, not from ADR-18That is the source of most of your comments, and you caught it being simultaneously too strict and too lax. I went back to the ADR and re-derived them. Every surviving condition now carries a comment naming the rule it enforces; anything I could not justify from the ADR is gone. Deleted, with what made them unnecessary:
2. Cycles — you are right that the guard should go, but not for the stated reasonYou asked "when do you have cycles, our SDFGs do not have them and ADR-18 forbids them?". Rule 5 forbids cycles in the state graph and, in the same sentence, mandates
Run on such an SDFG, the old code fired and produced wrong numbers — But the guard was a symptom fix. The real defect is that def _runs_strictly_before(first, second, reachable):
return second in reachable[first] and first not in reachable[second]which is loop correct by construction and strictly more permissive than what it replaced — a conflicting access outside the loop is now accepted. The special case is gone; the loop test stays as the reproduction and now fails for the right reason. 3. Views: give up, as you asked
One deviation: I did not use 4. The tests pinned defects, not the contractRebuilt around a matrix of how a consumer can reference
Your other points
Going further than you asked: a shifted write back combined with a producer read of Reusing a
One risk I am accepting deliberately, so it is a decision and not an oversightDeleting the top-level check on Testing
|
The file uses "we" in about half of its comment blocks; ours did not. Also tightens a few blocks and drops one that only said where stride propagation happens, which the call at the end of `_eliminate()` already explains.
|
You see I was maybe not so clear, this transformation only makes sense to apply to |
philip-paul-mueller
left a comment
There was a problem hiding this comment.
I see more work.
… pass `should_reapply()` now also fires on `Descriptors` and `NestedSDFGs`: data that becomes transient turns into a candidate, and a View inside a nested SDFG blocks the pass. They were excluded only because the sibling passes exclude them. The rest is documentation. The class docstring claimed that `T` has to be written by Maps, which the pass never required, and twice concluded from ADR-18 rule 6 that nothing else writes `G`. For global memory rule 3 takes precedence and rules 6 to 10 may be violated, so that conclusion is not available; what rules it out is `_has_conflicting_global_access()`, which the text now cites instead. The two limitations that stay, a single write back and a single incoming edge at the global, are recorded as `TODO`s.
philip-paul-mueller
left a comment
There was a problem hiding this comment.
Mostly minor changes.
…k pass `_eliminate()` uses the `FindAccessNodes` result instead of scanning every state. The pass returns a `set` per state and its iteration order decides in which order the new AccessNodes are inserted, so the loop sorts by `state.node_id`, see GridTools#2779 and GridTools#2780. `_accesses_region()` uses the shared `maybe_intersecting()` and the removal of `T` is validated under the GT4Py debug flag rather than DaCe's. The class docstring described the requirement on `G` as a window between the definition of `T` and the write back. It has to hold from the definition onwards, which is also what serves a consumer that reads after the write back; that was enforced but left to be inferred. It is now stated as the simplification it is. The comment on the subset size was wrong: `Range.size()` does divide by the step. The check holds because the source subset equals `Range.from_array()` and so has unit steps, which the comment now says.
philip-paul-mueller
left a comment
There was a problem hiding this comment.
We are almost there.
| `DistributedBufferRelocator` (requires `out_degree(T) == 1`) nor | ||
| `GT4PyMapBufferElimination` (requires the copy to be in the state where `T` is | ||
| written, and `T` to be unused downstream) applies then, so the full array copy | ||
| survives as a device to device transfer. |
There was a problem hiding this comment.
This is something that belongs to the Notes section or at the end of the doc string.
| Every access to `T` is rewritten into an access to `G`, shifted by the offset of | ||
| the copy, and the copy is removed. The producer of `T` and all of its consumers |
There was a problem hiding this comment.
You can remove that with the "shifted by" it is kind of obvious.
There was a problem hiding this comment.
Removed "The producer of T and all of its consumers thus operate on G directly", which is what I read "that" as pointing at. Say so if you meant the full coverage sentence instead.
| where `T` is defined. As a simplification the transformation therefore requires | ||
| that the range of `G` holding `T` is not modified from the definition of `T` | ||
| onwards, the write back itself excepted, and not only up to the write back. This |
There was a problem hiding this comment.
The first part "that the region of G is not modified until the wb" is a hard requirement, the part that T is not used beyond that point is the simplification.
There was a problem hiding this comment.
You are right, they are not the same kind of constraint, and calling both a simplification was wrong. Not modifying the range of G between the definition of T and the write back is hard: otherwise the preponed write either ends up behind that write or in front of it, and the result is wrong. Extending it past the write back is the simplification, since a later modification only matters for a consumer of T that reads after it, and rejecting it outright avoids testing whether T is still read there. The docstring says that now.
| for state, (tmp_reads, _) in tmp_access.items() | ||
| for node in tmp_reads | ||
| for edge in state.out_edges(node) | ||
| if isinstance(edge.dst, dace_nodes.AccessNode) and not edge.dst.desc(sdfg).transient |
There was a problem hiding this comment.
| if isinstance(edge.dst, dace_nodes.AccessNode) and not edge.dst.desc(sdfg).transient | |
| if isinstance(edge.dst, dace_nodes.AccessNode) and not edge.dst.desc(sdfg).transient and state.scope_dict()[edge.src] is not None |
Ensures that the thing only applies to nodes that are on the top level.
As pointed out above, only they are relevant.
But I can life without it.
There was a problem hiding this comment.
I think the polarity is the other way round. state.scope_dict()[n] is None when n is at the top level and the enclosing MapEntry when it is inside a scope, so is not None selects the nodes inside a scope. I checked on a small SDFG to be sure:
top-level AccessNode -> scope_dict: None
in-scope AccessNode -> scope_dict: MapEntry
Since T is a top level transient by construction, the suggestion as written would most likely make the pass match nothing. What you describe needs is None, and state.scope_dict() should come out of the comprehension, where it is recomputed per edge. Happy to add it in that form, but as you say it is not needed, so I left it out for now rather than guess.
There was a problem hiding this comment.
Yes, you are right it is indeed is None.
There was a problem hiding this comment.
Added in 7faefef95, with is None and scope_dict() hoisted out of the comprehension:
write_backs = []
for state, (tmp_reads, _) in tmp_access.items():
scope_dict = state.scope_dict()
write_backs.extend(
(edge, state)
for node in tmp_reads
if scope_dict[node] is None
for edge in state.out_edges(node)
if isinstance(edge.dst, dace_nodes.AccessNode)
and not edge.dst.desc(sdfg).transient
)The check sits in the node loop, so it runs once per candidate node rather than once per out edge, and scope_dict() once per state. The comment gives ADR-18 rule 10 as the reason: an AccessNode inside a Map scope has Scope lifetime and can therefore not refer to the data this pass removes.
33 unit tests and the whole dace transformation suite are green; the icon4py dycore run is still going, I will report if it turns up anything.
| # Equal sizes do not imply equal ranges, `Range.size()` divides by the | ||
| # step, so `[0:8:2]` and `[0:4]` have the same size. The source is the | ||
| # full array and therefore has unit steps, and shifting an access can not | ||
| # turn it into a scatter, so the destination must have them too. |
There was a problem hiding this comment.
| # Equal sizes do not imply equal ranges, `Range.size()` divides by the | |
| # step, so `[0:8:2]` and `[0:4]` have the same size. The source is the | |
| # full array and therefore has unit steps, and shifting an access can not | |
| # turn it into a scatter, so the destination must have them too. |
There was a problem hiding this comment.
Removed. One thing it takes with it: the comment recorded why testing only the destination is enough, namely that src_subset has to equal Range.from_array(tmp_desc) and therefore has unit steps. Tell me if you want that as a one liner instead.
| continue | ||
| # A write back with conflict resolution (`wcr`) combines `T` with the old | ||
| # value of `G`, while the rewritten producer overwrites `G`. | ||
| if wb_edge.data.wcr is not None: |
There was a problem hiding this comment.
Move it up since it is a super cheap test.
General rule: Order the tests in such a way that you can bail out as fast as possible.
Also the rule below should be moved up.
There was a problem hiding this comment.
Both moved up, right after the write back edge is picked, together with glob_node = wb_edge.dst. They are an attribute read and a degree lookup and do not depend on the subset work, so this is only a reordering.
| state.remove_edge(old_edge) | ||
| continue | ||
|
|
||
| new_edge = gtx_transformations.utils.reroute_edge( |
There was a problem hiding this comment.
Consider putting this into an else branch.
Then you would not have two "remove old edge" but also not this continue.
There was a problem hiding this comment.
Done, else branch, one remove_edge() and no continue.
… pass The two cheap tests, `wcr` and the in degree of the global node, run before the subset work, so the pass bails out earlier. Same candidates, only the order of the checks changes. The empty Memlet case is an `else` branch now, which leaves one place that removes the old edge and no `continue`. In the docstring, the comparison with the two neighbouring passes moves to the `Notes`, and the requirement on `G` is split: that its range is not modified between the definition of `T` and the write back is hard, extending that past the write back is the simplification that avoids testing whether `T` is read there.
philip-paul-mueller
left a comment
There was a problem hiding this comment.
LGTM
With the additional is None check, thanks for spotting.
| for state, (tmp_reads, _) in tmp_access.items() | ||
| for node in tmp_reads | ||
| for edge in state.out_edges(node) | ||
| if isinstance(edge.dst, dace_nodes.AccessNode) and not edge.dst.desc(sdfg).transient |
There was a problem hiding this comment.
Yes, you are right it is indeed is None.
The AccessNode of `T` has to be at the top level of its state. ADR-18 rule 10 gives an AccessNode inside a Map scope `Scope` lifetime, so it can not refer to the data this pass removes. `scope_dict()` is computed once per state.
mypy can not infer the element type from an empty list, where it could from the comprehension this replaced.
A new pass,
GT4PyWriteBackBufferElimination.It matches a transient
Tthat is defined in one state, copied in full into a non transientGin another state, and additionally read by other consumers. Every access toTis rewritten into an access toG, shifted by the offset of the copy, and the copy is removed, so producer and consumers work onGdirectly and the full array copy disappears.Neither
DistributedBufferRelocatornorGT4PyMapBufferEliminationcovers this pattern: the first needsout_degree(T) == 1, the second needs the copy to sit in the state whereTis written andTto be unused afterwards.The rewrite moves the write of
Gearlier, from the write back to the point whereTis defined. That the range ofGholdingTis not modified in between is a hard requirement; the pass additionally rejects a modification after the write back, which is a simplification that avoids testing whetherTis still read there._has_conflicting_global_access()establishes both. Since rule 3 of ADR-18 takes precedence for global memory, and rules 6 to 10 may then be violated, nothing here assumes that a global has a single writer.assume_pointwiseasserts that a read ofGin a state that definesTis elementwise, which ADR-18 rule 3 guarantees for a valid GT4Py program; the pass only checks that such a read is ordered before the definition ofT.Tests in
test_write_back_buffer_elimination.py, one per rejection guard.