This is an automated email from the ASF dual-hosted git repository.

tlopex pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm.git


The following commit(s) were added to refs/heads/main by this push:
     new 46c37fc523 [Fix][S-TIR][DLight] Guard non-affine reduction write-back 
(#20057)
46c37fc523 is described below

commit 46c37fc523a2ae751272cc131376633cb502908b
Author: J.W (Jun) <[email protected]>
AuthorDate: Fri Aug 28 09:57:55 2026 +0400

    [Fix][S-TIR][DLight] Guard non-affine reduction write-back (#20057)
    
    Fixes #20048.
    
    ## Motivation
    
    A valid Relax `conv2d` can fail during `relax.build(..., target="cuda")`
    when the DLight GPU Reduction rule selects its inner-spatial schedule.
    The rule fuses all spatial loops, but `_sch_inner_spatial` chooses its
    `threadIdx.x` tile from only the innermost spatial extent. For the
    reported shape, the non-unit spatial extents are `(2, 2, 20)` and the
    selected tile extent is 10. After `rfactor` and `reverse_compute_at`,
    the write-back block has to recover the original axes from the remaining
    fused loop. The resulting bindings are not quasi-affine, so `bind`
    rejects the block's compact-dataflow precondition.
    
    The same geometry can occur without mixed spatial/reduction indexing,
    for example in a plain reduction generated by `relax.op.sum`. Therefore
    the fix is based on the fused spatial geometry, rather than on a
    particular access pattern.
    
    ## Changes
    
    This PR adds an applicability check inside `Reduction`. It counts the
    non-unit spatial components that the write-back block must recover after
    fusion and rejects the inner-spatial schedule when that recovery would
    not remain quasi-affine. Unsupported reductions return `None`, allowing
    `GeneralReduction` or `Fallback` to handle them; affine cases continue
    to use the dedicated Reduction schedule.
    
    The check derives spatial domains from the normalized dominant-read
    access, which is the same order consumed by `_normalize`. Reordered or
    transposed accesses therefore use the same loop order for both
    applicability and scheduling. Thread-extent selection is shared with the
    scheduling path, and symbolic extents are handled conservatively.
    
    Unexpected `ScheduleError` exceptions are intentionally not caught by
    `_apply_rules`. `None` remains the contract for a non-applicable rule,
    while an exception from a rule remains visible as a scheduling bug
    instead of being silently converted into a fallback.
    
    ## Testing
    
    The changes were tested in a Python 3.12.13 environment with CUDA 13.0:
    
    ```bash
    python -m pytest \
      tests/python/s_tir/dlight/test_gpu_reduction.py \
      tests/python/s_tir/dlight/test_gpu_fallback.py -q
    ```
    
    Result: `25 passed`.
    
    The regression tests cover the reported convolution-like access, a plain
    reduction without mixed spatial/reduction indices, reordered
    dominant-read access, an affine write-back shape, and propagation of an
    unexpected `ScheduleError` from a broken rule.
    
    The Relax reproduction from #20048 now builds successfully with
    `target="cuda"`, and the previously failing plain `relax.op.sum` shape
    is handled by a later schedule rule.
---
 python/tvm/s_tir/dlight/gpu/reduction.py        |  94 +++++++++++--
 tests/python/s_tir/dlight/test_gpu_fallback.py  |  29 ++++
 tests/python/s_tir/dlight/test_gpu_reduction.py | 170 ++++++++++++++++++++++++
 3 files changed, 279 insertions(+), 14 deletions(-)

diff --git a/python/tvm/s_tir/dlight/gpu/reduction.py 
b/python/tvm/s_tir/dlight/gpu/reduction.py
index ced5c97531..ff22cdfe98 100644
--- a/python/tvm/s_tir/dlight/gpu/reduction.py
+++ b/python/tvm/s_tir/dlight/gpu/reduction.py
@@ -54,6 +54,65 @@ def _has_reduction_loop(block_info):
     return any([info.kind == "R" for info in block_info.iters])
 
 
+def _suggest_inner_spatial_tx(s_factor: int | tirx.Expr) -> int:
+    """Pick the largest divisor no greater than 16."""
+    if not isinstance(s_factor, int):
+        return 1
+    len_tx = 16
+    while len_tx > 1 and s_factor % len_tx != 0:
+        len_tx -= 1
+    return len_tx
+
+
+def _get_spatial_domains_in_access_order(
+    block_info: SBlockInfo, access: arith.IterSumExpr
+) -> list[int | tirx.Expr] | None:
+    """Return normalized spatial extents in access order."""
+    iter_to_info = {info.var: info for info in block_info.iters}
+    spatial_domains = []
+    seen = set()
+    for split_expr in access.args:
+        var = split_expr.source.source
+        info = iter_to_info.get(var)
+        if info is None:
+            return None
+        if info.kind == "S":
+            if var in seen:
+                return None
+            seen.add(var)
+            # `_normalize` fuses the outer loop produced by this split.
+            if split_expr.lower_factor > 1:
+                extent = split_expr.extent
+                extent = int(extent) if isinstance(extent, tirx.IntImm) else 
extent
+                spatial_domains.append(extent)
+            else:
+                spatial_domains.append(info.dom)
+
+    # `_normalize` appends omitted unit spatial loops.
+    for info in block_info.iters:
+        if info.kind == "S" and info.var not in seen:
+            if not isinstance(info.dom, int) or info.dom != 1:
+                return None
+            spatial_domains.append(info.dom)
+
+    if len(spatial_domains) != sum(info.kind == "S" for info in 
block_info.iters):
+        return None
+    return spatial_domains
+
+
+def _inner_spatial_write_back_is_affine(spatial_domains: list[int | 
tirx.Expr]) -> bool:
+    """Check whether the write-back bindings remain quasi-affine."""
+    if not spatial_domains:
+        return False
+    # Each non-unit outer dimension adds a div/mod component to the binding.
+    components = sum(1 for dom in spatial_domains[:-1] if not isinstance(dom, 
int) or dom > 1)
+    innermost = spatial_domains[-1]
+    if not isinstance(innermost, int) or _suggest_inner_spatial_tx(innermost) 
< innermost:
+        # A partial innermost tile adds one more component.
+        components += 1
+    return components < 3
+
+
 class Reduction(GPUScheduleRule):
     """A rule for Reduction."""
 
@@ -92,13 +151,15 @@ class Reduction(GPUScheduleRule):
         ):
             return None
         # Step 2. Normalize the block, merge spatial and reduction iters
+        access = arith.normalize_to_iter_sum(
+            detect_dominant_read(block_stmt),
+            input_iters={i.var: i.dom for i in block_stmt.iter_vars},
+        )
+        spatial_domains = _get_spatial_domains_in_access_order(block_info, 
access)
+        if spatial_domains is None:
+            return None
         is_inner_reduction, c_factor, loop_order, s_split_index = 
self._normalize(
-            sch,
-            block_info,
-            arith.normalize_to_iter_sum(
-                detect_dominant_read(block_stmt),
-                input_iters={i.var: i.dom for i in block_stmt.iter_vars},
-            ),
+            sch, block_info, access
         )
         if is_inner_reduction is None and c_factor is None:
             return None
@@ -108,8 +169,17 @@ class Reduction(GPUScheduleRule):
                 sch, target, block, c_factor, epilogue, loop_order, 
s_split_index
             )
         else:
+            if not _inner_spatial_write_back_is_affine(spatial_domains):
+                return None
             self._sch_inner_spatial(
-                sch, target, block, block_info, c_factor, epilogue, 
loop_order, s_split_index
+                sch,
+                target,
+                block,
+                spatial_domains[-1],
+                c_factor,
+                epilogue,
+                loop_order,
+                s_split_index,
             )
         return sch
 
@@ -239,7 +309,7 @@ class Reduction(GPUScheduleRule):
         sch: s_tir.Schedule,
         _: Target,
         block: s_tir.schedule.SBlockRV,
-        block_info: SBlockInfo,
+        s_factor: int | tirx.Expr,
         unroll_spatial_factor: int | None,
         epilogue_info: SBlockInfo | None,
         loop_order,
@@ -247,14 +317,10 @@ class Reduction(GPUScheduleRule):
     ):
         # pylint: disable=invalid-name
         s, r, _ = sch.get_loops(block)
-        len_tx, len_ty = 16, 16
-        s_factor = [i.dom for i in block_info.iters if i.kind == "S"][-1]
+        len_ty = 16
         # get perfect spatial factor, spatial factor should be divide the 
innermost spatial loop so
         # that the block after r_factor and be reversed compute at the 
original scope
-        while len_tx > 1:
-            if s_factor % len_tx == 0:
-                break
-            len_tx -= 1
+        len_tx = _suggest_inner_spatial_tx(s_factor)
         _, _ = sch.split(s, factors=[None, len_tx])
         _, ty = sch.split(r, factors=[None, len_ty])
         # Schedule the RF block
diff --git a/tests/python/s_tir/dlight/test_gpu_fallback.py 
b/tests/python/s_tir/dlight/test_gpu_fallback.py
index eb94734596..76bb99c8a0 100644
--- a/tests/python/s_tir/dlight/test_gpu_fallback.py
+++ b/tests/python/s_tir/dlight/test_gpu_fallback.py
@@ -16,7 +16,10 @@
 # under the License.
 # pylint: disable=missing-docstring
 # ruff: noqa: E501, E741, F841
+import pytest
+
 import tvm.testing
+from tvm import s_tir
 from tvm.ir import assert_structural_equal
 from tvm.s_tir import dlight as dl
 from tvm.script import ir as I
@@ -258,5 +261,31 @@ def test_gpu_fallback_ignores_non_gpu_functions():
     assert_structural_equal(mod, After)
 
 
+def test_schedule_error_propagates_from_rule():
+    # ScheduleError indicates a broken rule and must propagate.
+    @I.ir_module(s_tir=True)
+    class Before:
+        @T.prim_func(s_tir=True)
+        def main(A: T.Buffer((128,), "float32"), C: T.Buffer((128,), 
"float32")):
+            for i in range(128):
+                with T.sblock("copy"):
+                    vi = T.axis.remap("S", [i])
+                    T.reads(A[vi])
+                    T.writes(C[vi])
+                    C[vi] = A[vi] * T.float32(2)
+
+    class BrokenRule(dl.base.ScheduleRule):
+        def apply(self, func, target, tunable):
+            sch = s_tir.Schedule(func)
+            sch.get_sblock("no_such_block")
+            return sch
+
+    with Target("nvidia/geforce-rtx-3090-ti"), 
pytest.raises(s_tir.ScheduleError):
+        dl.ApplyDefaultSchedule(  # pylint: disable=not-callable
+            BrokenRule(),
+            dl.gpu.Fallback(),
+        )(Before)
+
+
 if __name__ == "__main__":
     tvm.testing.main()
diff --git a/tests/python/s_tir/dlight/test_gpu_reduction.py 
b/tests/python/s_tir/dlight/test_gpu_reduction.py
index ace05f93c3..c34dd6822a 100644
--- a/tests/python/s_tir/dlight/test_gpu_reduction.py
+++ b/tests/python/s_tir/dlight/test_gpu_reduction.py
@@ -926,6 +926,176 @@ def test_reduction_inner_spatial_choose_perfect_factor():
     assert_structural_equal(mod, Expected)
 
 
+def test_reduction_inner_spatial_non_affine_write_back_falls_back():
+    # The partial innermost tile leaves three div/mod components in the 
write-back binding.
+    @I.ir_module(s_tir=True)
+    class Before:
+        @T.prim_func(s_tir=True)
+        def main(
+            A: T.Buffer((2, 4, 20), "float32"),
+            W: T.Buffer((3,), "float32"),
+            C: T.Buffer((2, 2, 20), "float32"),
+        ):
+            for n, y, x, k in T.grid(2, 2, 20, 3):
+                with T.sblock("conv"):
+                    vn, vy, vx, vk = T.axis.remap("SSSR", [n, y, x, k])
+                    T.reads(A[vn, vy + vk, vx], W[vk])
+                    T.writes(C[vn, vy, vx])
+                    with T.init():
+                        C[vn, vy, vx] = T.float32(0)
+                    C[vn, vy, vx] += A[vn, vy + vk, vx] * W[vk]
+
+    @I.ir_module(s_tir=True)
+    class Expected:
+        @T.prim_func(s_tir=True)
+        def main(
+            A: T.Buffer((2, 4, 20), "float32"),
+            W: T.Buffer((3,), "float32"),
+            C: T.Buffer((2, 2, 20), "float32"),
+        ):
+            T.func_attr({"tirx.is_scheduled": True})
+            for ax0_ax1_ax2_fused_0 in T.thread_binding(1, 
thread="blockIdx.x"):
+                for ax0_ax1_ax2_fused_1 in T.thread_binding(1024, 
thread="threadIdx.x"):
+                    with T.sblock("conv_init"):
+                        v0 = T.axis.spatial(
+                            2, (ax0_ax1_ax2_fused_0 * 1024 + 
ax0_ax1_ax2_fused_1) // 40
+                        )
+                        v1 = T.axis.spatial(
+                            2, (ax0_ax1_ax2_fused_0 * 1024 + 
ax0_ax1_ax2_fused_1) % 40 // 20
+                        )
+                        v2 = T.axis.spatial(
+                            20, (ax0_ax1_ax2_fused_0 * 1024 + 
ax0_ax1_ax2_fused_1) % 20
+                        )
+                        T.where(ax0_ax1_ax2_fused_0 * 1024 + 
ax0_ax1_ax2_fused_1 < 80)
+                        T.reads()
+                        T.writes(C[v0, v1, v2])
+                        C[v0, v1, v2] = T.float32(0)
+                    for ax3 in range(3):
+                        with T.sblock("conv_update"):
+                            v0 = T.axis.spatial(
+                                2, (ax0_ax1_ax2_fused_0 * 1024 + 
ax0_ax1_ax2_fused_1) // 40
+                            )
+                            v1 = T.axis.spatial(
+                                2,
+                                (ax0_ax1_ax2_fused_0 * 1024 + 
ax0_ax1_ax2_fused_1) % 40 // 20,
+                            )
+                            v2 = T.axis.spatial(
+                                20, (ax0_ax1_ax2_fused_0 * 1024 + 
ax0_ax1_ax2_fused_1) % 20
+                            )
+                            v3 = T.axis.reduce(3, ax3)
+                            T.where(ax0_ax1_ax2_fused_0 * 1024 + 
ax0_ax1_ax2_fused_1 < 80)
+                            T.reads(C[v0, v1, v2], A[v0, v1 + v3, v2], W[v3])
+                            T.writes(C[v0, v1, v2])
+                            C[v0, v1, v2] += A[v0, v1 + v3, v2] * W[v3]
+
+    with Target("nvidia/geforce-rtx-3090-ti"):
+        # Reduction must decline instead of raising ScheduleError.
+        assert dl.gpu.Reduction().apply(Before["main"], Target.current(), 
False) is None
+        mod = dl.ApplyDefaultSchedule(  # pylint: disable=not-callable
+            dl.gpu.Reduction(),
+            dl.gpu.Fallback(),
+        )(Before)
+    assert_structural_equal(mod, Expected)
+
+
+def test_reduction_inner_spatial_non_affine_without_mixed_access():
+    # The failure depends on the spatial extents, not mixed spatial/reduction 
indexing.
+    @I.ir_module(s_tir=True)
+    class Before:
+        @T.prim_func(s_tir=True)
+        def main(
+            A: T.Buffer((2, 2, 3, 20), "float32"),
+            C: T.Buffer((2, 2, 20), "float32"),
+        ):
+            for n, y, x, k in T.grid(2, 2, 20, 3):
+                with T.sblock("sum"):
+                    vn, vy, vx, vk = T.axis.remap("SSSR", [n, y, x, k])
+                    T.reads(A[vn, vy, vk, vx])
+                    T.writes(C[vn, vy, vx])
+                    with T.init():
+                        C[vn, vy, vx] = T.float32(0)
+                    C[vn, vy, vx] += A[vn, vy, vk, vx]
+
+    with Target("nvidia/geforce-rtx-3090-ti"):
+        assert dl.gpu.Reduction().apply(Before["main"], Target.current(), 
False) is None
+        mod = dl.ApplyDefaultSchedule(  # pylint: disable=not-callable
+            dl.gpu.Reduction(),
+            dl.gpu.GeneralReduction(),
+            dl.gpu.Fallback(),
+        )(Before)
+    assert "tirx.is_scheduled" in mod["main"].attrs
+
+
+def test_reduction_inner_spatial_reordered_access_declines():
+    # `_normalize` fuses the spatial loops in dominant-read order: n, x, y.
+    @I.ir_module(s_tir=True)
+    class Before:
+        @T.prim_func(s_tir=True)
+        def main(
+            A: T.Buffer((2, 16, 3, 20), "float32"),
+            C: T.Buffer((2, 20, 16), "float32"),
+        ):
+            for n, y, x, k in T.grid(2, 20, 16, 3):
+                with T.sblock("sum"):
+                    vn, vy, vx, vk = T.axis.remap("SSSR", [n, y, x, k])
+                    T.reads(A[vn, vx, vk, vy])
+                    T.writes(C[vn, vy, vx])
+                    with T.init():
+                        C[vn, vy, vx] = T.float32(0)
+                    C[vn, vy, vx] += A[vn, vx, vk, vy]
+
+    with Target("nvidia/geforce-rtx-3090-ti"):
+        assert dl.gpu.Reduction().apply(Before["main"], Target.current(), 
False) is None
+
+
+def test_reduction_inner_spatial_affine_write_back_still_applies():
+    # An exact innermost tile leaves only two div/mod components.
+    @I.ir_module(s_tir=True)
+    class Before:
+        @T.prim_func(s_tir=True)
+        def main(
+            A: T.Buffer((2, 4, 16), "float32"),
+            W: T.Buffer((3,), "float32"),
+            C: T.Buffer((2, 2, 16), "float32"),
+        ):
+            for n, y, x, k in T.grid(2, 2, 16, 3):
+                with T.sblock("conv"):
+                    vn, vy, vx, vk = T.axis.remap("SSSR", [n, y, x, k])
+                    T.reads(A[vn, vy + vk, vx], W[vk])
+                    T.writes(C[vn, vy, vx])
+                    with T.init():
+                        C[vn, vy, vx] = T.float32(0)
+                    C[vn, vy, vx] += A[vn, vy + vk, vx] * W[vk]
+
+    with Target("nvidia/geforce-rtx-3090-ti"):
+        sch = dl.gpu.Reduction().apply(Before["main"], Target.current(), False)
+    assert sch is not None, "Reduction rule should still handle affine 
write-back blocks"
+
+
+def test_reduction_inner_spatial_uses_normalized_split_extent():
+    # x // 2 normalizes the x extent from 8 to 4, which must also be used for 
tx.
+    @I.ir_module(s_tir=True)
+    class Before:
+        @T.prim_func(s_tir=True)
+        def main(
+            A: T.Buffer((2, 3, 3, 4), "float32"),
+            C: T.Buffer((2, 3, 8), "float32"),
+        ):
+            for n, y, x, k in T.grid(2, 3, 8, 3):
+                with T.sblock("sum"):
+                    vn, vy, vx, vk = T.axis.remap("SSSR", [n, y, x, k])
+                    T.reads(A[vn, vy, vk, vx // 2])
+                    T.writes(C[vn, vy, vx])
+                    with T.init():
+                        C[vn, vy, vx] = T.float32(0)
+                    C[vn, vy, vx] += A[vn, vy, vk, vx // 2]
+
+    with Target("nvidia/geforce-rtx-3090-ti"):
+        sch = dl.gpu.Reduction().apply(Before["main"], Target.current(), False)
+    assert sch is not None, "Reduction should use the normalized split extent 
for tx"
+    assert 'thread_binding(4, thread="threadIdx.x")' in sch.mod.script()
+
+
 def test_repeat_transpose_gemv():
     # fmt: off
 

Reply via email to