Skip to content

feat[next-dace]: eliminate write back buffers that are also consumed - #2763

Merged
havogt merged 23 commits into
GridTools:mainfrom
havogt:dace-eliminate-write-back-buffers
Aug 14, 2026
Merged

feat[next-dace]: eliminate write back buffers that are also consumed#2763
havogt merged 23 commits into
GridTools:mainfrom
havogt:dace-eliminate-write-back-buffers

Conversation

@havogt

@havogt havogt commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

A new pass, GT4PyWriteBackBufferElimination.

It matches a transient T that is defined in one state, copied in full into a non transient G in another state, and additionally read by other consumers. Every access to T is rewritten into an access to G, shifted by the offset of the copy, and the copy is removed, so producer and consumers work on G directly and the full array copy disappears.

Neither DistributedBufferRelocator nor GT4PyMapBufferElimination covers this pattern: the first needs out_degree(T) == 1, the second needs the copy to sit in the state where T is written and T to be unused afterwards.

The rewrite moves the write of G earlier, from the write back to the point where T is defined. That the range of G holding T is 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 whether T is 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_pointwise asserts that a read of G in a state that defines T is 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 of T.

Tests in test_write_back_buffer_elimination.py, one per rejection guard.

@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.

There are some aspects that needs some work.
If there are more question just drop me a line.

Comment thread src/gt4py/next/program_processors/runners/dace/transformations/simplify.py Outdated
Comment thread src/gt4py/next/program_processors/runners/dace/transformations/simplify.py Outdated
Comment thread src/gt4py/next/program_processors/runners/dace/transformations/simplify.py Outdated
Comment thread src/gt4py/next/program_processors/runners/dace/transformations/simplify.py Outdated
Comment thread src/gt4py/next/program_processors/runners/dace/transformations/simplify.py Outdated
Comment thread src/gt4py/next/program_processors/runners/dace/transformations/simplify.py Outdated
Comment thread src/gt4py/next/program_processors/runners/dace/transformations/simplify.py Outdated
Comment thread src/gt4py/next/program_processors/runners/dace/transformations/simplify.py Outdated
Comment thread src/gt4py/next/program_processors/runners/dace/transformations/simplify.py Outdated
Comment thread src/gt4py/next/program_processors/runners/dace/transformations/simplify.py Outdated

@edopao edopao 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

@havogt

havogt commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Review round pushed as three commits, deliberately separated so the move is reviewable as a move.

  • fdf38d4fb — pure move of GT4PyWriteBackBufferElimination into write_back_buffer_elimination.py. auto_optimize.py needed no change (it already went through gtx_transformations.…), and the test file was already named to match.
  • 8cdf79004 — the documentation and naming points: the :1356 suggestion verbatim; the class docstring now says G must hold its original value between the definition of T and the write back; _find_candidates documents the (tmp_name, wb_edge, wb_state) triple and the conditions it guarantees; glob_readglob_read_node; _has_conflicting_global_access rewritten to cover the write case and both read cases with the reason each disqualifies, plus the Todo: for the state-topology point; _only_feeds_tmp_producer rewritten to stand alone; and the swallowed ValueError removed.
  • cdcad0213 — the rerouting rewrite. Details in the thread, but the headline: your :1538 comment found a real bug, and it produced wrong numbers rather than a validation error. It is specifically the data.data association assumption, not the "used inside Maps" part — inner memlet-tree edges were reached and translated correctly, which is why the pre-existing Map-consumer test passed. The failing shape is a memlet expressed relative to the other container, with the T-side range in other_subset.

Tests: test_write_back_buffer_elimination.py 4 → 7 passed; transformation_tests/ 282 → 285 passed, 3 xfailed. The 1 failure and 2 errors in the wider dace_tests/ run are the pre-existing test_dace_fastcall*[exec_alloc_descriptor1] GPU-allocator set, confirmed by reproducing them on a branch containing none of these changes. ruff, mypy and tach clean on the source files.

Both earlier correctness guards survive the rewrite: gt_propagate_strides_of is still the last statement of _eliminate, and _only_feeds_tmp_producer still narrows the assume_pointwise waiver.

I have also put this branch through an independent adversarial review pass; will follow up here with anything it turns up.

@havogt
havogt marked this pull request as ready for review August 10, 2026 10:53
@havogt

havogt commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Still iterating on this one...

@havogt

havogt commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Second round pushed (49051c272..d09db8bdd), and it changes what I can honestly claim about this pass, so writing it up rather than burying it in commits.

An independent review found seven defects; all are fixed

Each was reproduced first and is verified by that reproduction:

  • Views of T were never stride updatedgt_propagate_strides_of only descends into NestedSDFGs, and _gt_modify_strides_of_views_non_recursive skips views of non-transient data, which is exactly the assumption this pass invalidates. Globals carry symbolic strides and transients concrete ones, so it is guaranteed wrong when it happens. Now rejected in _find_candidates rather than repaired.
  • _has_conflicting_global_access was unsound on a cyclic control flow path — a LoopRegion back edge makes a state that must see the old G look like it runs after the write back. Now rejected.
  • A strided write back destination was acceptedRange("0:20:2").size() folds the step away and the offset came from min_element() alone, so b[i] was written instead of b[2i]. Note the asymmetry: the source check already rejected strides.
  • A WCR on the write back edge was silently dropped, turning b += tmp into b = ....
  • An empty dependency memlet became non empty via reroute_edge's full range fallback, producing an invalid SDFG from a valid one.
  • modifies() under declared Descriptors/NestedSDFGs; added @explicit_cf_compatible.
  • An ADR-18 rule 6 violating input was corrupted and then raised; now rejected up front.

Not fixed, but now stated accurately in the docstring: assume_pointwise does not hold for a non-zero offset. It is out of contract per ADR-18 rule 3, GT4PyMapBufferElimination has the identical hole, and it was verified not to be a regression — run_dace_cpu already miscompiles such a program with both passes disabled.

The pass only fires with static domain bounds

This was not previously understood, and it is the most important thing here for a reviewer.

Instrumenting _find_candidates on compute_perturbed_quantities_and_interpolation:

domain candidates
dynamic 0
compile_time_domain 3 (CPU and GPU alike)

With a dynamic domain the transient is allocated with a clamped shape while the write back carries the unclamped extent, and Range.__eq__ compares sympy structurally, so 0:Max(0, hi - lo) never equals 0:hi - lo. With static bounds these fold to literals and the check succeeds. A sweep of 133 test invocations and 18 hand written program shapes with dynamic domains produced zero candidates, which is why this went unnoticed.

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

test_write_back_buffer_elimination_lowering.py. Until now every test hand built an SDFG, so nothing verified the pass against what the lowering actually emits — plausibly why the defects above survived. The minimal firing program is eight lines, and a reduction ladder shows all three ingredients are necessary: a dual role output, a branch whose body reads it, and a static domain.

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 gt_auto_optimize entry and zero at pass time, every time. _construct_local_view is the only add_view call in the dace runner, and RemovePointwiseViews runs after this pass.

One number in the description I can no longer support

It says "4 cudaMemcpy2DAsync calls removed". The instrumentation finds 3 candidates, on both CPU and GPU. The production benchmark configuration differs from the stencil test, so both may be right, but I have not reconciled them and would rather flag it than leave an unverified figure standing.

Tests: test_write_back_buffer_elimination.py 7 → 15, plus 3 new integration tests; transformation_tests/ 285 → 293 passed, 3 xfailed.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 GT4PyWriteBackBufferElimination pass 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.

Comment on lines +680 to +686
dace_ppl.Pipeline(
[
gtx_transformations.GT4PyWriteBackBufferElimination(
assume_pointwise=assume_pointwise,
)
]
).apply_pass(sdfg, {})

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.

Legitimate point and we should use it at some point, but at this level not really present.

@philip-paul-mueller
philip-paul-mueller self-requested a review August 10, 2026 13:30
havogt and others added 14 commits August 10, 2026 15:31
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.
@havogt
havogt force-pushed the dace-eliminate-write-back-buffers branch from d09db8b to 8fdbc71 Compare August 10, 2026 13:37
@havogt

havogt commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Note: I rebased this branch onto 721946037 (the uv lock file update) — apologies for the force-push on a branch you are reviewing, I should have merged instead. The reviewed code is unchanged by it; the only content change is one reformat for ruff 0.16.1 in a test file. No further force-pushes here.

from gt4py.next.program_processors.runners.dace import transformations as gtx_transformations


# Conditional import because `gt4py.cartesian` uses an older DaCe version without

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.

cartesian and next use the same dace version in latest gt4py, so the try/except below is not needed.

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.

Thanks, applied.

@philip-paul-mueller

Copy link
Copy Markdown
Contributor

The only reason why it does not apply for dynamic bounds is that SymPy does not know that lo <= hi.
However, it is possible to use assumptions, however, they are not properly implemented in DaCe and even if GT4Py sets them at some point, I am not sure that it will survive.
However, this should be seen more as a bug/limitation than intended behaviour.

@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.

Better, but I see more work and the need for clarifications.

Comment on lines +680 to +686
dace_ppl.Pipeline(
[
gtx_transformations.GT4PyWriteBackBufferElimination(
assume_pointwise=assume_pointwise,
)
]
).apply_pass(sdfg, {})

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.

Legitimate point and we should use it at some point, but at this level not really present.

Hannes Vogt and others added 2 commits August 11, 2026 14:46
…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>
@havogt

havogt commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

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: b732249bb re-derives the preconditions, 006e8ab8f rebuilds the tests.

1. The preconditions were derived empirically, not from ADR-18

That 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:

deleted why
all T AccessNodes must be top level rule 10 — an AccessNode in a Map scope needs AllocationLifetime.Scope, which an interstate T cannot have
T read and written through different nodes in one state rules 6 + 8
is_view(tmp_desc), is_view(glob_desc), _find_viewed_data subsumed by the whole-SDFG View bail-out
len(glob_desc.shape) != len(tmp_desc.shape) subsumed by the per-dimension size check
scope_dict()[glob_node] is not None rule 10
wb_state.out_degree(glob_node) != 0 simply wrong, as you noted
"any write to G anywhere" narrowed to a write that may touch the write-back region
"any read in a state not reachable from wb_state" narrowed to three exemptions: disjoint, strictly before every definition, or strictly after the write back

2. Cycles — you are right that the guard should go, but not for the stated reason

You 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 LoopRegion as the replacement — and a LoopRegion does produce a reachability cycle that this pass sees:

  • sdfg.states() on the top-level SDFG returns states inside LoopRegions.
  • ControlFlowBlockReachability._region_closure does closure.update(region.all_control_flow_blocks()) for a LoopRegion, commented "Any point inside the loop may reach any other point inside the loop again." It deliberately surfaces the cycle rather than hiding it.
  • The lowering emits LoopRegions for scans.

Run on such an SDFG, the old code fired and produced wrong numbers — d one iteration late, [2,4,6,8] where the reference is [0,2,4,6].

But the guard was a symptom fix. The real defect is that reachable is a may-reach relation and the code used it as happens-after. Every use is now

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

_find_viewed_data is gone. The pass now bails if the SDFG contains any View.

One deviation: I did not use get_all_view_nodes(). Once the answer is "give up on any View", there is nothing to resolve — that helper walks a View chain to its base array, and the base array cannot change the outcome. _has_view() is a nine line existence check. Happy to swap it if you would rather have the helper used for consistency.

4. The tests pinned defects, not the contract

Rebuilt around a matrix of how a consumer can reference T: direct_copy | map_scope | nested_sdfg x memlet named after T or after the other container x offset (0,0) or (11,22). Every defect found in this PR so far is one cell of it. Each cell runs the SDFG before and after and compares all non-transient arrays.

  • :110 — the stride test is replaced by the NestedSDFG x non-zero-offset cells. b is 100x100 against a 10x10 tmp, so a missing gt_propagate_strides_of shows up as wrong numbers rather than as an assertion about a mock.
  • :217 — the empty Memlet test now asserts the empty edge starts at the same b node the producer writes, which is the property whose loss would lose the ordering.
  • :273 — deleted, it tested that an invalid SDFG stays invalid.
  • :408 — the View tests now expect a bail-out.

Your other points

:361, assume_pointwise. You were right and this changed shape. _only_feeds_tmp_producer rejected exactly your G → Map1 → TT → Map2 → T case. It is now _is_read_by_tmp_producer: for every out-edge of the G read, all T-writing AccessNodes must be downstream of that edge's destination. The one-hop formulation matters — a naive "T is reachable from the G node" wrongly accepts a shared AccessNode, which is now a test.

Going further than you asked: a shifted write back combined with a producer read of G is now rejected rather than documented as a limitation. Rule 3 is about the same memory, and G[i] → G[i+off] clobbers across iterations.

Reusing a G node (:1538). You are right, and my rule was wrong in one direction. It is now decided per T node: written → its own AccessNode (rules 3 and 8 permit the extra output node, and reusing the read would make the state cyclic); read only → join an existing G node that has in_degree == 0 and is top level. Requiring in-degree zero makes the reused node a source, so joining it cannot create a cycle. Both branches have tests.

:179 and :236, the missing cases. Neither needs a condition, and the docstring now says why. From the write back onwards T and G[wb_region] hold the same value, and rule 6 forbids a second AccessNode writing G downstream of glob_node. Other consumers of T are rerouted to G identically by _eliminate(), before or after the write back. Both are covered numerically.

:86. Correct and stronger than what I had, but only for writes — reads after the write back see identical content either way. For writes the extended interval is free, since rule 6 forbids a writer downstream of glob_node outright. The docstring states both halves.

:285, the optional cyclic test on the def states. Declined. Two def states reaching each other means two definitions of T on one path, which rule 6 forbids — that is a defence against invalid input, which is the habit this round is trying to break. It also would not catch anything the strictly-before requirement already lets through.

:273 (the step check). Kept, as you allowed, now with a comment on what it establishes. I could not work out which "much stronger check below" subsumes it — the step check is orthogonal, and DaCe's memlet validation constrains total volume rather than the per-dimension shape the offset shift needs. Happy to drop it if you point at the one you meant.

:227 is moot — the loop it suggested a fix for is deleted.

One risk I am accepting deliberately, so it is a decision and not an oversight

Deleting the top-level check on T's AccessNodes is the deletion with teeth. If a T node did sit in a Map scope — a rule 10 violation — _eliminate would now attach a top-level G node to nodes inside the scope and produce an invalid SDFG rather than declining. _replacement_node refuses to reuse a non-top-level G node, which limits it, but the creation path is unguarded. That is the "assume the contract" principle applied consistently; say the word if you would rather keep the check.

Testing

test_write_back_buffer_elimination.py 33 plus the 3 lowering tests: 36 passed, and the pass still fires on lowering produced input. Full dace_tests/: 435 passed, with the pre-existing test_dace_fastcall*[exec_alloc_descriptor1] GPU allocator failure and its two companion errors. ruff clean, mypy reports nothing new.

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.
@philip-paul-mueller

Copy link
Copy Markdown
Contributor

You see I was maybe not so clear, this transformation only makes sense to apply to T on top level.
So you should ignore everything else.

@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 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 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.

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 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.

We are almost there.

Comment on lines +99 to +102
`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.

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.

This is something that belongs to the Notes section or at the end of the doc string.

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.

Moved into Notes.

Comment on lines +104 to +105
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

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.

You can remove that with the "shifted by" it is kind of obvious.

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.

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.

Comment on lines +111 to +113
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

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.

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.

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.

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

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.

Suggested change
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.

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 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.

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.

Yes, you are right it is indeed is None.

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.

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.

Comment on lines +248 to +251
# 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.

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.

Suggested change
# 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.

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.

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:

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.

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.

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.

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(

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.

Consider putting this into an else branch.
Then you would not have two "remove old edge" but also not this continue.

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.

Done, else branch, one remove_edge() and no continue.

havogt added 2 commits August 14, 2026 09:22
… 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 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

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

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.

Yes, you are right it is indeed is None.

havogt added 2 commits August 14, 2026 10:58
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.
@havogt
havogt merged commit 0d8fb34 into GridTools:main Aug 14, 2026
24 checks passed
@havogt
havogt deleted the dace-eliminate-write-back-buffers branch August 14, 2026 11:03
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.

4 participants