feat[next-dace]: let a SplitAccessNode fragment have several producers - #2764
Conversation
999d2ca to
dc41f0d
Compare
philip-paul-mueller
left a comment
There was a problem hiding this comment.
Kind of okay, just a few suggestions.
There was a problem hiding this comment.
Pull request overview
Extends DaCe SplitAccessNode to support fragments with multiple producers.
Changes:
- Builds producer fragments using union-find.
- Adds fragment coverage and overlap validation.
- Adds multi-producer and deduplication regression tests.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
split_access_nodes.py |
Implements multi-producer fragment construction and validation. |
test_split_access_node.py |
Tests spanning consumers and merged-fragment behavior. |
Suppressed comments (1)
src/gt4py/next/program_processors/runners/dace/transformations/split_access_nodes.py:519
- This does not actually test whether the consumers' union covers the fragment when their reads overlap.
subset_merger()only joins exactly adjacent ranges (splitting_tools.py:668-673), so reads such as0:10and5:15remain separate and neither individually covers a0:15fragment; this incorrectly rejects a fully consumed multi-producer fragment. Please use a coverage calculation that handles overlapping subsets and add a regression case.
merged = gtx_dace_split.subset_merger(
[consumer_edge.data.src_subset for consumer_edge in consumer_edges]
)
return any(merged_subset.covers(subset) for merged_subset in merged)
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if len(fragment.producers) > 1: | ||
| if not self._is_covered_by_consumers(fragment.subset, consumer_edges): | ||
| return False |
There was a problem hiding this comment.
Added as test_partially_read_multi_producer_fragment_is_rejected(): a spanning consumer merges the second and third producer but stops short of what the third writes, with a first, fully read fragment present so the check is actually reached rather than short-circuited by the single-fragment early return.
Your suppressed comment on the same function was the more important one and it was right — see the separate note on the PR.
|
Review round pushed as two commits on top.
Consumers reading overlapping regions is the normal case for a stencil reading a halo, so this hit precisely the situation multi producer fragments exist for. It fails in the safe direction — it declines a legal split rather than miscompiling — but it would have quietly defeated much of the point of this PR. Coverage is now decided with a merger that also joins overlapping and contained subsets. I kept it local to
Testing: |
| producers=[("a", 0), ("b", 5), ("c", 10)], | ||
| consumers=[("d", "0:5"), ("e", "5:12")], | ||
| ) | ||
| _perform_test(sdfg, explected_applies=0) |
There was a problem hiding this comment.
I do not understand why the subset 0:5 is not split out.
There was a problem hiding this comment.
I mean: a -> d should become an independent graph as in next test case.
There was a problem hiding this comment.
Good question, and sorry for the slow reply — this one slipped past me.
0:5 is not split out because the check is all or nothing: _check_split_constraints() returns a single verdict for the whole node, so one fragment that fails rejects the split entirely, including the fragments that were fine. That is pre-existing behaviour rather than something this PR introduces — on main the same function return Falses out of a loop over all reassignments.
The reason it is structured that way is that split_node() is handed a partition of the entire AccessNode and every edge has to land in exactly one fragment. Splitting only 0:5 would need a residual fragment holding 5:15 that is deliberately left un-tight, and nothing currently constructs that.
So in this test: fragment 0:5 (producer a, consumer d) is fine, fragment 5:15 (producers b and c, consumer e reading only 5:12) is not, and the whole candidate is dropped. The test is checking the rejection, not arguing that rejecting everything is ideal.
You are right that it is more conservative than it needs to be — a partial split would be a real improvement, since 0:5 would then be served directly. I have not changed it here because it is a behaviour change to a pre-existing decision and would want its own reasoning about what happens to the residual, but I am happy to add a Todo on _check_split_constraints() recording it, or to take it on in a follow up if you would prefer.
There was a problem hiding this comment.
Probably this case is unrealistic, because domain inference should shrink the domain of the intermediate field to [0:12] in the first place.
The transformation required every read of an access node to be served by a single write and bailed out entirely otherwise, which is the TODO the class carries. A read spanning two writes is not exotic: it blocks the split, and with it the fusion the split exists to enable. Fragments are now built by union-find over the producer edges, so a read that intersects several writes merges those writes into one fragment instead of rejecting the candidate. A fragment is split off only when its subset is fully covered by its consumers, and a single resulting fragment means there is nothing to split.
Separate the checks that apply to every producer, i.e. the source kind and the view rejection, from the tightness requirement, and then handle a fragment with several producers and one with a single producer in their own branches. The behaviour is unchanged; the point is that the reason for treating them differently is now stated where it applies instead of being spread over two `len(fragment.producers) == 1` conditions. Rename `_is_covered_by_consumers` to `_is_fully_read_by_consumers`, iterate the producer edges with `enumerate`, and assert the resulting fragment structure in the test: the producer counts per fragment and that each fragment reaches the consumer that reads it.
`subset_merger()` only joins subsets that are exactly adjacent, which is what describing a split needs but is too weak to decide whether the consumers of a fragment read it in full: two reads of `0:10` and `5:15` stay separate and neither covers `0:15` on its own, so a fully read fragment was rejected. Consumers reading overlapping regions is the common case for a stencil reading a halo, so this hit the very situation multi producer fragments exist for. Decide the coverage with a merger that also joins subsets that overlap or contain each other, kept local to this module so that the meaning of `subset_merger()` is unchanged. Also adds the missing regression test for the rejection branch, i.e. a merged fragment that a spanning consumer leaves partially unread, with a second fully read fragment present so the check is actually reached.
e850e3e to
bce9726
Compare
|
Note: I rebased this branch onto |
|
Unrelated to this change, but noticed in the same file and it looks like a one word fix, so flagging rather than touching it here. In ```python So Purely a compile time cost, no behavioural consequence, and |
philip-paul-mueller
left a comment
There was a problem hiding this comment.
Some more points.
| return None | ||
| possible_producer = self._find_producer(oedge, edge_reassignments.keys()) | ||
| if possible_producer is None: | ||
| consumer_subset = oedge.data.src_subset |
There was a problem hiding this comment.
consider using oedge.data.get_src_subset(oedge, state) here.
There was a problem hiding this comment.
Switched, here and for the consumer subset in the coverage test. Worth noting it is not only style: the raw property returns None when Memlet._is_data_src was never set, which is the failure mode in #2775.
There was a problem hiding this comment.
I have to walk this one back: switching to get_src_subset() broke the icon4py run, and I pushed it without noticing. Reverted in 6f4e04cdf, with a NOTE recording why.
The accessor initializes the Memlet; the raw property does not. That difference is load bearing at this call site, because two lines below there is
if consumer_subset is None:
return None # TODO(phimuell): Lift this.So a Memlet with no side associated to it had no src_subset, and that None was acting as an implicit guard — an uninitialized Memlet meant "decline this candidate". Going through the accessor initializes it, the guard stops firing, and the transformation starts accepting nodes whose rerouting then fails inside reconfigure_dataflow_after_rerouting() — the subset_to_adjust is not None assertion, on a child of the Memlet tree inside a Map scope. With assertions disabled, as in the ICON runs, that surfaces as AttributeError: 'NoneType' object has no attribute 'offset'.
Bisected: main, and this branch up to and including bce97261a, pass test_compute_advection_in_horizontal_momentum_equation[compile_time_domain-apply_extra_diffusion_on_vn[True]] on dace_cpu. efa882017 fails it. That commit with only this line reverted passes. The other four changes from that round are untouched.
Two things I think are worth your view.
The guard should not be implicit. As it stands, split_node() relies on a Memlet being uninitialized to signal "do not touch this node", which is not something anyone would infer from the code, and it depends on whether the caller went through gt_split_access_nodes() — which does initialize the whole SDFG up front — or applied the transformation directly, as map_fusion_extended does. Your suggestion is the right API; what is missing is that the rerouting has to cope with those nodes before it can be adopted.
This is issue #2775 with a production traceback. I filed that assertion as reachable only from a hand constructed edge and said so explicitly; it is reachable from icon4py, and I have corrected the issue. The helper reads the raw {src,dst}_subset properties with a comment saying it cannot use the accessors because the SDFG is transiently invalid — I tried try_initialize() there and on the newly created bypass edge, and neither helps for an edge inside a Map scope, where neither endpoint is an AccessNode named after the Memlet's data. That is where I stopped, since the fix needs a decision about that helper rather than a guess from me.
Performance, measured after the revert (GH200, mch_icon-ch1_medium, 1800 calls, fresh translation caches): target program 0.7818 → 0.5527 with PR1 → 0.5202 with this PR, i.e. 0.89× → 1.26× → 1.33× against the OpenACC reference. No other program moved by more than 0.006 s against the pre-rework measurement, model total within the 0.020 s run to run spread.
There was a problem hiding this comment.
This is absolutely wrong!
First src_subset is not the raw property it is already a "direction aware property" and a lot of issue we had in the past was that that thing was not updated correctly.
The raw property is either subset or other_subset, however, which one must be used highly depends on the state of the Memlet and how it is integrated into the SDFG.
To put it differently get_src_subset() is correct, the Memlet is wrong and you must find out where the Memlet has gone bad.
There was a problem hiding this comment.
You are right on both counts, and thanks for pushing back. src_subset is direction aware, get_src_subset() is the correct API, and the Memlet is broken long before SplitAccessNode looks at it. Here is where it goes bad.
The corruption
unsqueeze_memlet() (dace/transformation/helpers.py) builds the outer Memlet of a NestedSDFG edge like this:
actual_result = Memlet.from_memlet(internal_memlet) # copies _is_data_src
actual_result.data = external_memlet.data
...
else:
actual_result.subset = result.subsetfrom_memlet() copies _is_data_src from the Memlet inside the nested SDFG, where it refers to a different data container, and then data is replaced by the external one. When the internal Memlet has no other_subset — a write expressed in terms of its destination, so _is_data_src is False, which is correct inside — the resulting outer Memlet claims that its data is on the destination side while carrying no other_subset. Its src_subset is None from then on.
Standalone, no gt4py, dace 2.0.0a5:
import dace
from dace.sdfg import propagation
inner = dace.SDFG("inner")
inner.add_scalar("__arg", dace.float64)
inner.add_scalar("__output", dace.float64)
istate = inner.add_state()
istate.add_nedge(
istate.add_access("__arg"),
istate.add_access("__output"),
dace.Memlet(data="__output", subset="0"), # no other_subset -> _is_data_src is False
)
sdfg = dace.SDFG("outer")
sdfg.add_array("A", [10], dace.float64)
sdfg.add_array("B", [10], dace.float64)
st = sdfg.add_state()
a_node, b_node = st.add_access("A"), st.add_access("B")
map_entry, map_exit = st.add_map("map", {"__i": "0:10"})
nsdfg = st.add_nested_sdfg(sdfg=inner, inputs={"__arg"}, outputs={"__output"})
map_entry.add_in_connector("IN_A")
map_entry.add_out_connector("OUT_A")
st.add_edge(a_node, None, map_entry, "IN_A", dace.Memlet(data="A", subset="0:10"))
st.add_edge(map_entry, "OUT_A", nsdfg, "__arg", dace.Memlet(data="A", subset="__i"))
map_exit.add_in_connector("IN_B")
map_exit.add_out_connector("OUT_B")
st.add_edge(nsdfg, "__output", map_exit, "IN_B", dace.Memlet(data="B", subset="__i"))
st.add_edge(map_exit, "OUT_B", b_node, None, dace.Memlet(data="B", subset="0:10"))
sdfg.validate()
edge = next(iter(st.in_edges(nsdfg)))
print("before:", edge.data, edge.data._is_data_src, edge.data.src_subset)
propagation.propagate_memlets_map_scope(sdfg, st, map_entry)
edge = next(iter(st.in_edges(nsdfg)))
print("after: ", edge.data, edge.data._is_data_src, edge.data.src_subset)
print("get_src_subset() ->", edge.data.get_src_subset(edge, st))before: A[__i] True __i
after: A[__i] False None
get_src_subset() -> __i
data and subset are untouched, only the association flips. Note the last line: the accessor repairs the Memlet.
Why it reached us, and why my earlier explanation was backwards
get_src_subset() never caused anything. It repaired the outer edge and let the split proceed, while the Memlets on the inner edges of the map scope stayed corrupt. reconfigure_dataflow_after_rerouting() walks exactly those inner edges and cannot use the accessor — it runs while the old and the new edge are both present, as the NOTE there says — so it read None and died. The raw property was declining the candidate by accident.
My first attempt at a fix was to repair after our own propagate_memlets_map_scope() call in split_maps(). That fixed one program and left two others failing, because DaCe propagates behind our back: PatternTransformation.apply_pattern() runs propagate_memlets_sdfg() after every applied transformation unless it annotates_memlets(). There is no point in guarding our own propagation calls.
What this PR does
The repair now sits at the entry of split_node(), which is the last moment where the graph still says where the data is: for every edge incident to the node being split, the Memlets of its whole Memlet tree are re-initialized. Those are exactly the edges the rerouting will later have to offset. _find_edge_reassignment() uses get_src_subset() again.
New test test_vertical_map_fusion_with_nested_sdfg_consumer: a Map that consumes the intermediate through a NestedSDFG. It hits assert subset_to_adjust is not None in ~3 s without the repair and passes with it. That is also the answer to why the suite stayed green while the model run aborted — no existing test builds an inner Memlet in that shape.
The real fix belongs in unsqueeze_memlet(), which should not carry _is_data_src across the SDFG boundary. That is DaCe, so I have not touched it; say the word and I will open an issue there with the snippet above.
Documents what `fragment_of_producer` holds, notes that the producer subsets must use the adjacency only merger since overlapping producers would describe the same memory twice, precomputes the union find lookup, takes the consumer subsets through `get_src_subset()` rather than the raw property, and uses a set plus `sorted()` where the order only has to be deterministic. The comment on the tightness requirement now states what the two branches establish and that they differ only in granularity, rather than repeating the `CopyChainRemover` justification that the surrounding code carries, which is the part under discussion.
|
Addressed in
The On the producer
Happy to move it wherever you prefer; #2776 has the reasoning if it is easier to decide there. Tests: 22 passed, 2 xfailed. |
|
The code agent seems to have missed my comment: |
… in full A fragment that is fed by several producers has no consumer associated to an individual producer of it, so requiring that it is read in full rejected the whole AccessNode, including the fragments that were fine. In the test that is now `test_partially_read_multi_producer_fragment` the pair `a -> d` is a self contained producer and consumer and was dropped only because an unrelated fragment kept data that nobody reads. `split_node()` accepts such a split, the result validates and produces the same numbers, and dead data in a fragment is what the `unused_producers` warning already tolerates for a fragment without any consumer. This removes the only caller of the overlap aware merger, so both it and the coverage test go with it.
`get_src_subset()` initializes the Memlet, which the raw property does not, see dace issue 1703. That difference matters here: a Memlet with no side associated to it has no `src_subset`, and the check below turns that into a decline. Going through the accessor removes that guard, so the transformation starts accepting nodes whose rerouting then fails inside `reconfigure_dataflow_after_rerouting()`, which aborts the icon4py run. Bisected to this line; `main` and the earlier commits of this branch pass the same case. The accessor is the better API and the guard being implicit is not good, but making it explicit needs the rerouting to cope with those nodes first. A NOTE records the constraint in the meantime.
f38930f to
6f4e04c
Compare
| return None | ||
| possible_producer = self._find_producer(oedge, edge_reassignments.keys()) | ||
| if possible_producer is None: | ||
| consumer_subset = oedge.data.src_subset |
There was a problem hiding this comment.
This is absolutely wrong!
First src_subset is not the raw property it is already a "direction aware property" and a lot of issue we had in the past was that that thing was not updated correctly.
The raw property is either subset or other_subset, however, which one must be used highly depends on the state of the Memlet and how it is integrated into the SDFG.
To put it differently get_src_subset() is correct, the Memlet is wrong and you must find out where the Memlet has gone bad.
DaCe reruns Memlet propagation after every applied transformation, and `unsqueeze_memlet()` rebuilds the Memlets on the edges of a NestedSDFG from the ones inside it, carrying over which side of the connection the data is on, although inside it refers to a different data container. A read can end up claiming that its data is on the destination side while carrying no `other_subset`, which leaves it without a `src_subset`. `reconfigure_dataflow_after_rerouting()` walks those edges and can not repair them, it runs while the old and the new edge are both present, so `split_node()` does it up front, over the Memlet trees of the edges it is about to reroute. With that, `_find_edge_reassignment()` can read the consumer subset through `get_src_subset()` again. The raw property was declining those candidates by accident, which is what kept the defect out of sight. The new test covers a Map that consumes the intermediate through a NestedSDFG, a shape no existing test built, which is why the suite stayed green while an ICON run aborted on the assertion.
philip-paul-mueller
left a comment
There was a problem hiding this comment.
We are close.
| @@ -310,56 +327,113 @@ def apply( | |||
| def _find_edge_reassignment( | |||
There was a problem hiding this comment.
This function should be called _find_fragments() or so.
There was a problem hiding this comment.
Renamed to _find_fragments().
| # whole node, including the fragments that are fine. What stays unread is | ||
| # dead data, which we already tolerate for a fragment without any | ||
| # consumer, see the warning in `_find_edge_reassignment()`. |
There was a problem hiding this comment.
This is a bit different.
"Dead Data" was fully eliminated by the Dead Dataflow pass, but now this data is generated and written to memory, but it sits around and is never read.
To ensure that no data is generated that is never read, even with multiple producers/consumers.
You simply merge the consumers together (this is either done with your new overlapping subset merger or with dace.subset.bounding_box_union()) and then check if it covers _Fragement.subset.
However, I am not fully sure if this restriction is useful, but it would preserve the old invariant the transformation used to have.
There was a problem hiding this comment.
You are right about the mechanism and my comment was wrong. I checked both shapes against DeadDataflowElimination: it removes a fragment with no consumer entirely (AccessNode, Tasklet, MapEntry and MapExit all go), and it removes nothing from a partially read one, because the array is read, so the producer keeps computing values that are then discarded. The comment now says that instead of equating the two.
On the check itself, three findings argue against implementing it as proposed:
bounding_box_union()can not express it. Consumers[0:2]and[4:6]give[0:6], which covers a fragment[0:6]although2:4is never read. In 2D it is worse, since the per dimension special cases fire independently:[0:2,0:2]and[2:4,2:4]give[0:4,0:4], with 8 of 16 elements read. The test would accept exactly the fragments it is meant to reject. (It lives indace.subsetsand is binary, so it needs a fold;SubsetUnion.covers()isany(...)and fails the other way.)subset_merger()can not either. It merges only exactly adjacent subsets, so['0:10','5:15']stays split and coverage of0:15reportsFalse— it would reject legal splits. The overlap aware merger that could decide this is the one removed inbac31c364, which also answered your question at:525about where that functionality should live.- It would not restore the invariant
mainhad. Onmainthe condition is evaluated per producer edge, and three of the six cases impose nothing at all (zero consumers, non transient source, multi consumer transient source). Where it does apply —MapExitproducers, and single consumer transient AccessNode producers — it requiresconsumer_subset == producer_subsetexactly, per consumer, which is strictly stronger than "the merged consumers cover the fragment". I ran a three producer, two consumer node onmain: it applies and only warns about the unread producer, so produced-but-unread data was tolerated there as well.
So I left the behaviour alone and fixed only the comment. If you do want the restriction, the honest form is an exact coverage test, which means bringing the overlap aware merger back — happy to do that if you say so.
|
Some changes on the way during review killed the performance gain that this should have introduced, claude is back to debugging... |
|
ok, probably unlucky dace indeterminism. |
`_find_edge_reassignment()` is now `_find_fragments()`. The Memlet repair in `split_node()` gets a `TODO`. The cost and the undefined behaviour caveat both sit in `try_initialize()`, which calls `memlet_path()` once per edge, and not in `memlet_tree()`, which is scope local and does not call it. The note on a fragment that is not read in full claimed that what stays unread is the dead data a fragment without consumers already produces. It is not the same: `DeadDataflowElimination` removes the latter and can not remove the former, since the fragment is read. It is still not a regression, without the split the same producer computes the same values and they are discarded just as well.
| # whole node, including the fragments that are fine. | ||
| # NOTE: What stays unread is computed and stored for nothing, and unlike a | ||
| # fragment without any consumer, which `DeadDataflowElimination` removes, | ||
| # it can not be recovered later, the fragment is read. It is not a |
There was a problem hiding this comment.
I still do not see why it is a regression.
Consider the following case you have the following three producers [0:10], [10:20] and [20:30] and one consumer [0:15].
The old transformation would have refused to work, but the new one does not, instead it will create two fragments [0:20] and [20:30], the last one with zero consumers and the first fragment is only partially read.
I agree, the situation before and after the split are equivalent, in the sense that there is data that is written but not read.
However, this check alters the behaviour of the transformation in a very strange way.
Before if there was a single consumer of a fragment then the fragment had to be consumed in full.
Now if there is a single consumer of a fragment the fragment must only be consumed in full if it has a single producer.
This is slightly different than before and caused by the new definition of a fragment.
And the question is, does this matter?
This rule was established, as previous comments noted, to ensure that CopyChainReomver works that assumes some tightness.
And we should still work to make that happy.
Let's make the example from above more concrete:
- P1:
tmp[0:10] = G[a:b] - P2:
tmp[10:20] = G[c:d] - P3:
tmp[20:30] = ...(case not important).
Again P1 and P2 will end up in the same fragment, furthermore they are fed by the same data G (which is not necessarily global).
If we assume b == c then they can be represented as one edge, that was, at some point, split for whatever reasons.
If this edge merge happens after CopyChainRemover then we might have created something that it does not like.
As I said, it is now slightly different and the question is: does it matter.
After some thinking I came to the conclusion that it does not matter, at least I consider the cases where it might matter to be unlikely.
However, this discussion has showed us, that we should probably rework CopyChainRemover.
I think it should be split into two transformations and I think for the Pull case we can sharpen some constraints a bit.
| # TODO(phimuell): Replace this with a cheaper repair. The cost is not the Memlet | ||
| # tree, which is scope local, but `try_initialize()`, which calls `memlet_path()` | ||
| # once per edge; that is also where the "undefined for more than one path" | ||
| # caveat of `memlet_path()` applies. |
There was a problem hiding this comment.
| # TODO(phimuell): Replace this with a cheaper repair. The cost is not the Memlet | |
| # tree, which is scope local, but `try_initialize()`, which calls `memlet_path()` | |
| # once per edge; that is also where the "undefined for more than one path" | |
| # caveat of `memlet_path()` applies. | |
| # TODO(phimuell): Replace this with a cheaper repair, that fixes the Memlet | |
| # trees directly instead of every single Memlet.The cost is not the Memlet. |
There was a problem hiding this comment.
Applied, with one deviation: the suggestion block covers only the first two lines of the TODO, so taking it verbatim would have left ...instead of every single Memlet.The cost is not the Memlet. followed by the old third line once per edge; ..., i.e. a sentence cut in half. I kept your wording and made the rest follow from it:
# TODO(phimuell): Replace this with a cheaper repair, that fixes the Memlet trees
# directly instead of every single Memlet. The cost is not the Memlet tree, which
# is scope local, but `try_initialize()`, which calls `memlet_path()` once per
# edge; that is also where the "undefined for more than one path" caveat of
# `memlet_path()` applies.
Say the word if you meant to drop the second half entirely.
There was a problem hiding this comment.
Correction to my previous reply: I misread the suggestion as covering only the first two lines. It spans 282-285, i.e. the whole TODO, so "The cost is not the Memlet." is a complete sentence and nothing was cut. Applied verbatim in ae122182b (only adding the missing space after the period).
One thing to confirm you meant: it drops the note that the cost and the "undefined for more than one path" caveat both sit in try_initialize() -> memlet_path() rather than in memlet_tree(), which is the part I had measured. Fine by me either way, it is your TODO — say so and I put that one line back.
There was a problem hiding this comment.
Yes this is correct.
Says what a cheaper repair would do, fix the Memlet trees directly rather than every single Memlet, as suggested in review.
Lets a
SplitAccessNodefragment have several producers.Before, a fragment was a single producer edge, so an AccessNode fed by several producers and read by several consumers could not be split, and the maps around it could not be fused. A fragment is now a set of producers joined by union find: a consumer that may intersect more than one producer merges them, so each fragment is a region that is written by a known set of producers and read by a known set of consumers, and the fragments are independent of each other.
Consequences worth knowing:
The check that everything a producer computes is also read applies only to a fragment with a single producer. With several producers no consumer is tied to an individual producer, so requiring the fragment to be read in full would reject the whole node, including the fragments that are fine. What stays unread is computed for nothing, but it is not a regression, without the split the same producer computes the same values and they are discarded just as well.
The producers of a fragment are merged with the adjacency only merger, since producers that overlap would describe the same memory twice.
split_node()now re-initializes the Memlet trees of the edges it is about to reroute. DaCe reruns Memlet propagation after every applied transformation, and it can leave a read on the edge of a NestedSDFG without asrc_subset; the rerouting itself cannot repair that, because it runs while the old and the new edge are both present.