InlineDynamicShifts does not inline a let-bound producer shared between two dynamically shifted consumers, so it reaches infer_domain as an UNKNOWN domain and is emitted as an as_fieldop without a domain.
Reproducer
@gtx.field_operator
def testee(a: IKFloatField, b: IKFloatField, off: IKField) -> IKFloatField:
x = b * 3.0
p = (a + x) * 2.0
q = (b + x) * 4.0
return p(as_offset(Koff, off)) + q(as_offset(Koff, off))
Fails on roundtrip.gtir with TypeError: as_fieldop() missing 1 required positional argument: 'domain', and on dace_cpu / dace_cpu_noopt with the assert len(fun_node.args) == 2 at gtir_to_sdfg_primitives.py:239. Under PYTHONOPTIMIZE — which ICON's generated setting script exports — that assert is stripped and the next line surfaces it as ValueError: not enough values to unpack (expected 2, got 1). Passes on embedded and on roundtrip.default.
Cause
The IR reaching the pass binds the shared subexpression b * 3.0 in a let and uses it inside both shifted consumers:
(λ(xᐞ0) → as_fieldop(+)(
as_fieldop(λ(a, xᐞ0, off) → …⟪Koff, ·off⟫(a) + …⟪Koff, ·off⟫(xᐞ0)…)(a, xᐞ0, off),
as_fieldop(λ(b, xᐞ0, off) → …⟪Koff, ·off⟫(b) + …⟪Koff, ·off⟫(xᐞ0)…)(b, xᐞ0, off)))(
as_fieldop(×)(b, 3.0))
Two things have to line up and neither does:
- The let-inlining pre-pass in
visit_FunCall only fires when the let body is itself the dynamically shifted as_fieldop. Here the body is the + node one level up, so _dynamic_shift_args(let_body) is all-False and xᐞ0 is never marked for inlining.
- Because
xᐞ0 stays a SymRef, the fusion loop skips it — fuse_args starts with not isinstance(inp, itir.SymRef) — and stops at if not any(fuse_args): break with arguments a, xᐞ0, off.
infer_domain then marks xᐞ0 UNKNOWN and emits its producer without a domain.
Why the obvious fix is not acceptable
Searching the whole let body for dynamically shifted as_fieldops (node.fun.expr.pre_walk_values()) and revisiting after inlining does make the reproducer pass, and the existing suites stay green. It is not viable on cost grounds: visit_FunCall runs at every let node, and _dynamic_shift_args calls trace_shifts.trace_stencil at every FunCall it walks past, so each let re-traverses its entire body with a stencil trace per node. On the deep let chains that CSE produces for real stencils that is far too expensive.
A workable fix within the current IR likely needs the reverse direction — collecting, in one pass, which symbols are consumed in a dynamically shifted argument position, and consulting that when the let node is reached — rather than re-walking the body per let.
More fundamentally, this is a symptom of the IR being a tree in which sharing is encoded as a let binding and a reference is just a name. The pass has a SymRef in hand and needs to know what it denotes, and the only ways to answer that are to carry a symbol table, to re-walk, or to inline the binding away — which is what the pre-pass does, and why it has to guess in advance which bindings will matter. In a graph representation, where a reference is an edge to the defining node, the question is answered by following the edge: the fusion loop could look through xᐞ0 directly, see the as_fieldop behind it, and fuse without any inlining, any pre-pass, or any traversal of the surrounding body. Shared subexpressions would then be shared structurally instead of by name, so the "is this argument a SymRef?" test — the thing that makes the loop skip exactly the shared case — would not need to exist at all.
Notes
AI Disclaimer: This issue was written with the help of AI tools. I read the description and found it to be decent.
Reported by @havogt in #2829 (comment).
InlineDynamicShiftsdoes not inline a let-bound producer shared between two dynamically shifted consumers, so it reachesinfer_domainas anUNKNOWNdomain and is emitted as anas_fieldopwithout a domain.Reproducer
Fails on
roundtrip.gtirwithTypeError: as_fieldop() missing 1 required positional argument: 'domain', and ondace_cpu/dace_cpu_nooptwith theassert len(fun_node.args) == 2atgtir_to_sdfg_primitives.py:239. UnderPYTHONOPTIMIZE— which ICON's generatedsettingscript exports — that assert is stripped and the next line surfaces it asValueError: not enough values to unpack (expected 2, got 1). Passes on embedded and onroundtrip.default.Cause
The IR reaching the pass binds the shared subexpression
b * 3.0in a let and uses it inside both shifted consumers:Two things have to line up and neither does:
visit_FunCallonly fires when the let body is itself the dynamically shiftedas_fieldop. Here the body is the+node one level up, so_dynamic_shift_args(let_body)is all-Falseandxᐞ0is never marked for inlining.xᐞ0stays aSymRef, the fusion loop skips it —fuse_argsstarts withnot isinstance(inp, itir.SymRef)— and stops atif not any(fuse_args): breakwith argumentsa,xᐞ0,off.infer_domainthen marksxᐞ0UNKNOWNand emits its producer without a domain.Why the obvious fix is not acceptable
Searching the whole let body for dynamically shifted
as_fieldops (node.fun.expr.pre_walk_values()) and revisiting after inlining does make the reproducer pass, and the existing suites stay green. It is not viable on cost grounds:visit_FunCallruns at every let node, and_dynamic_shift_argscallstrace_shifts.trace_stencilat everyFunCallit walks past, so each let re-traverses its entire body with a stencil trace per node. On the deep let chains that CSE produces for real stencils that is far too expensive.A workable fix within the current IR likely needs the reverse direction — collecting, in one pass, which symbols are consumed in a dynamically shifted argument position, and consulting that when the let node is reached — rather than re-walking the body per let.
More fundamentally, this is a symptom of the IR being a tree in which sharing is encoded as a let binding and a reference is just a name. The pass has a
SymRefin hand and needs to know what it denotes, and the only ways to answer that are to carry a symbol table, to re-walk, or to inline the binding away — which is what the pre-pass does, and why it has to guess in advance which bindings will matter. In a graph representation, where a reference is an edge to the defining node, the question is answered by following the edge: the fusion loop could look throughxᐞ0directly, see theas_fieldopbehind it, and fuse without any inlining, any pre-pass, or any traversal of the surrounding body. Shared subexpressions would then be shared structurally instead of by name, so the "is this argument aSymRef?" test — the thing that makes the loop skip exactly the shared case — would not need to exist at all.Notes
InlineDynamicShifts#2829; that PR narrows it (roundtrip.defaultgoes from failing to passing) without closing it.assertthis trips in the dace lowering is the one fix[next-dace]: loweras_offsetapplied to a staggered access #2830 deliberately leaves as an assert; turning it into an explicit error (as in fix[next]: loweras_offsetapplied to a staggered access havogt/gt4py#77) would at least make the failure legible underPYTHONOPTIMIZE.AI Disclaimer: This issue was written with the help of AI tools. I read the description and found it to be decent.
Reported by @havogt in #2829 (comment).