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 1f352bf9c1 [Fix][Relax] Lower non-contiguous WebGPU cumsum (#20133)
1f352bf9c1 is described below

commit 1f352bf9c1af19b9f43584a5d27e9a14c30b468b
Author: Akaash Parthasarathy <[email protected]>
AuthorDate: Mon Aug 24 19:43:23 2026 -0700

    [Fix][Relax] Lower non-contiguous WebGPU cumsum (#20133)
    
    This PR adds direct WebGPU lowering for inclusive `cumsum` along
    non-innermost axes. Previously, these operations used TOPI's
    transpose-based GPU scan, which materializes a full-size transposed
    tensor and requires multiple WebGPU dispatches. This is particularly
    problematic when the scan extent is symbolic.
    
    The new lowering:
    - Normalizes the scan axis and reshapes known-rank inputs to `[outer,
    scan, inner]` without transposing data
    - Uses a correctness fallback where each GPU thread scans one `(outer,
    inner)` row serially
    - Preserves input/output dtypes and virtual-device information
    - Retains the existing parallel 2D kernel for innermost-axis scans
    - Clamps the existing kernel's reverse-round count so short scans do not
    generate negative shifts during WebGPU index narrowing
---
 python/tvm/relax/backend/dispatch_sort_scan.py     |  47 ++++---
 python/tvm/relax/backend/gpu_generic/__init__.py   |   2 +-
 python/tvm/relax/backend/gpu_generic/cumsum.py     | 104 +++++++++++++--
 .../relax/test_backend_dispatch_sort_scan.py       | 140 +++++++++++++++++++++
 4 files changed, 266 insertions(+), 27 deletions(-)

diff --git a/python/tvm/relax/backend/dispatch_sort_scan.py 
b/python/tvm/relax/backend/dispatch_sort_scan.py
index 76951718c4..df1543761d 100644
--- a/python/tvm/relax/backend/dispatch_sort_scan.py
+++ b/python/tvm/relax/backend/dispatch_sort_scan.py
@@ -146,10 +146,14 @@ class SortScanDispatcher(BackendDispatcher):
             # TODO(tvm-team): Support fully dynamic case with `shape=None`
             if shape is None:
                 raise ValueError("non-symbolic shape is not supported for now")
+            shape_values = [shape[i] for i in range(len(shape))]
             kwargs = {}
+            normalized_axis = axis
+            if normalized_axis is not None and normalized_axis < 0:
+                normalized_axis += len(shape)
             if (
-                shape is not None
-                and (axis == -1 or axis == len(shape) - 1)
+                normalized_axis is not None
+                and (normalized_axis == len(shape) - 1 or tgt.kind.name == 
"webgpu")
                 and self.is_gpu_target(tgt)
                 and not can_use_thrust(tgt, "tvm.contrib.thrust.sum_scan")
                 and call.op.name == "relax.cumsum"
@@ -157,29 +161,44 @@ class SortScanDispatcher(BackendDispatcher):
             ):
                 from tvm.relax.backend.gpu_generic import (  # pylint: 
disable=import-outside-toplevel
                     gpu_2d_continuous_cumsum,
+                    gpu_3d_axis_1_cumsum,
                 )
 
-                dim = 1
-                for i in range(len(shape) - 1):
-                    dim *= shape[i]
+                input_tensor = call.args[0]
                 in_dtype = call.args[0].ty.dtype
                 out_dtype = call.attrs.dtype
                 out_dtype = out_dtype or in_dtype
-                cumsum_2d_shape = relax.ShapeExpr([dim, shape[-1]])
+
+                if normalized_axis == len(shape) - 1:
+                    outer = reduce(mul, shape_values[:-1], 1)
+                    kernel_shape = relax.ShapeExpr([outer, shape[-1]])
+                    kernel = gpu_2d_continuous_cumsum(
+                        in_dtype=in_dtype,
+                        out_dtype=out_dtype,
+                        index_bits=32 if tgt.kind.name == "webgpu" else 64,
+                    )
+                    kernel_name = "gpu_2d_continuous_cumsum"
+                else:
+                    outer = reduce(mul, shape_values[:normalized_axis], 1)
+                    inner = reduce(mul, shape_values[normalized_axis + 1 :], 1)
+                    kernel_shape = relax.ShapeExpr([outer, 
shape[normalized_axis], inner])
+                    kernel = gpu_3d_axis_1_cumsum(
+                        in_dtype=in_dtype,
+                        out_dtype=out_dtype,
+                    )
+                    kernel_name = "gpu_3d_axis_1_cumsum"
+
                 reshape = relax.call_pure_packed(
                     "vm.builtin.reshape",
-                    call.args[0],
-                    cumsum_2d_shape,
-                    ty_args=relax.TensorType(cumsum_2d_shape, out_dtype),
-                )
-                gv = self.builder_.add_func(
-                    gpu_2d_continuous_cumsum(in_dtype=in_dtype, 
out_dtype=out_dtype),
-                    "gpu_2d_continuous_cumsum",
+                    input_tensor,
+                    kernel_shape,
+                    ty_args=relax.TensorType(kernel_shape, in_dtype, 
vdevice=call.ty.vdevice),
                 )
+                gv = self.builder_.add_func(kernel, kernel_name)
                 cumsum = relax.call_tir(
                     gv,
                     reshape,
-                    out_ty=relax.TensorType(cumsum_2d_shape, out_dtype),
+                    out_ty=relax.TensorType(kernel_shape, out_dtype, 
vdevice=call.ty.vdevice),
                 )
                 return relax.call_pure_packed(
                     "vm.builtin.reshape",
diff --git a/python/tvm/relax/backend/gpu_generic/__init__.py 
b/python/tvm/relax/backend/gpu_generic/__init__.py
index 3e8bd0f8f0..bb67a4721c 100644
--- a/python/tvm/relax/backend/gpu_generic/__init__.py
+++ b/python/tvm/relax/backend/gpu_generic/__init__.py
@@ -17,7 +17,7 @@
 # under the License.
 """The Relax Metal backend compilation pipeline and other passes."""
 
-from .cumsum import gpu_2d_continuous_cumsum
+from .cumsum import gpu_2d_continuous_cumsum, gpu_3d_axis_1_cumsum
 from .pipeline import (
     dataflow_lower_passes,
     finalize_passes,
diff --git a/python/tvm/relax/backend/gpu_generic/cumsum.py 
b/python/tvm/relax/backend/gpu_generic/cumsum.py
index 9676131f46..9d79cf7079 100644
--- a/python/tvm/relax/backend/gpu_generic/cumsum.py
+++ b/python/tvm/relax/backend/gpu_generic/cumsum.py
@@ -28,12 +28,22 @@ def _is_power_of_two(n: int):
     return n > 0 and (n & (n - 1)) == 0
 
 
+def _get_total_rounds(n, log_block_n: int, index_bits: int):
+    """Count the hierarchy levels without introducing integer division."""
+    total_rounds = T.int64(0)
+    for round_index in range(1, (index_bits - 1) // log_block_n + 1):
+        threshold = T.int64(1 << (round_index * log_block_n - 1))
+        total_rounds += T.Cast("int64", n > threshold)
+    return total_rounds
+
+
 def gpu_2d_continuous_cumsum(
     ty_len: int = 4,
     tx_len: int = 32,
     thread_elem: int = 4,
     in_dtype: str = "int32",
     out_dtype: str | None = None,
+    index_bits: int = 64,
 ) -> PrimFunc:
     """Generate GPU kernel for 2D continuous cumsum, i.e. The cumsum axis is -1
 
@@ -54,6 +64,9 @@ def gpu_2d_continuous_cumsum(
     out_dtype : Optional[str]
         The output data type, if None, it will be the same as in_dtype
 
+    index_bits : int
+        The number of bits available for signed index expressions
+
     Returns
     -------
     cumsum : PrimFunc
@@ -69,6 +82,8 @@ def gpu_2d_continuous_cumsum(
 
     if not _is_power_of_two(TX) or not _is_power_of_two(TY) or not 
_is_power_of_two(N):
         raise ValueError("Configuration of TX, TY, N must be power of 2")
+    if index_bits not in (32, 64):
+        raise ValueError("index_bits must be either 32 or 64")
 
     # number of elements to be processed by single warp
     warp_elem = T.int64(tx_len * thread_elem)
@@ -76,12 +91,15 @@ def gpu_2d_continuous_cumsum(
     block_elem = T.int64(tx_len * ty_len * thread_elem)
 
     LOG_TX = T.int64(int(math.log2(tx_len)))
-    LOG_BLOCK_N = T.int64(int(math.log2(tx_len * ty_len * thread_elem)))
+    log_block_n = int(math.log2(tx_len * ty_len * thread_elem))
+    LOG_BLOCK_N = T.int64(log_block_n)
+    MAX_INDEX_SHIFT = T.int64(index_bits - 1)
 
     @T.macro
     def block_inclusive_inside_block(
         batch: T.int64,
         cur_len: T.int64,
+        num_blocks: T.int64,
         source: T.Buffer,
         output: T.Buffer,
         tmp_buf: T.Buffer,
@@ -89,7 +107,7 @@ def gpu_2d_continuous_cumsum(
         tmp_offset: T.int64,
     ):
         for by in T.thread_binding(batch, thread="blockIdx.y"):
-            for bx in T.thread_binding(T.ceildiv(cur_len, block_elem), 
thread="blockIdx.x"):
+            for bx in T.thread_binding(num_blocks, thread="blockIdx.x"):
                 with T.sblock():
                     local_buf = T.sblock_alloc_buffer((thread_elem,), 
out_dtype, scope="local")
                     shared_buf = T.sblock_alloc_buffer((block_elem,), 
out_dtype, scope="shared")
@@ -138,13 +156,14 @@ def gpu_2d_continuous_cumsum(
     def update_cross_block(
         batch: T.int64,
         cur_len: T.int64,
+        num_blocks: T.int64,
         source: T.Buffer,
         output: T.Buffer,
         src_offset: T.int64,
         out_offset: T.int64,
     ):
         for by in T.thread_binding(batch, thread="blockIdx.y"):
-            for bx in T.thread_binding(T.ceildiv(cur_len, block_elem), 
thread="blockIdx.x"):
+            for bx in T.thread_binding(num_blocks, thread="blockIdx.x"):
                 for ty in T.thread_binding(TY, thread="threadIdx.y"):
                     for tx in T.thread_binding(TX, thread="threadIdx.x"):
                         for i in T.serial(N):
@@ -161,35 +180,96 @@ def gpu_2d_continuous_cumsum(
         A = T.match_buffer(var_a, [m, n], dtype=in_dtype)
         Out = T.match_buffer(var_out, [m, n], dtype=out_dtype)
         Tmp = T.alloc_buffer([m, n], dtype=out_dtype)
-        total_rounds: T.let[T.int64] = (
-            T.Cast("int64", T.ceil(T.log2(T.Cast("float32", n)))) // 
LOG_BLOCK_N
-        )
+        # LowerIntrin may implement signed FloorDiv using a sign-bit shift.  
Keep
+        # hierarchy counting division-free so WebGPU can narrow indices to 
int32.
+        total_rounds: T.let[T.int64] = _get_total_rounds(n, log_block_n, 
index_bits)
 
         block_inclusive_inside_block(
-            m, n, A, Out, Tmp, src_offset=T.int64(0), tmp_offset=T.int64(0)
+            m,
+            n,
+            T.ceildiv(n, block_elem),
+            A,
+            Out,
+            Tmp,
+            src_offset=T.int64(0),
+            tmp_offset=T.int64(0),
         )
         for i in range(total_rounds):
-            cur_len: T.let[T.int64] = T.ceildiv(n, 1 << (LOG_BLOCK_N * (i + 
1)))
+            shift: T.let[T.int64] = T.min(T.max(LOG_BLOCK_N * (i + 1), 
T.int64(0)), MAX_INDEX_SHIFT)
+            block_shift: T.let[T.int64] = T.min(shift + LOG_BLOCK_N, 
MAX_INDEX_SHIFT)
+            # n is non-negative and WebGPU indices are narrowed to signed 
int32.
+            # Spell out positive ceildiv by a power of two so lowering does not
+            # introduce an int64 sign-bit test (`remainder >> 63`).
+            cur_len: T.let[T.int64] = ((n - 1) >> shift) + 1
+            num_blocks: T.let[T.int64] = ((n - 1) >> block_shift) + 1
             block_inclusive_inside_block(
                 m,
                 cur_len,
+                num_blocks,
                 Tmp,
                 Tmp,
                 Tmp,
                 src_offset=i * T.ceildiv(n, block_elem),
                 tmp_offset=(i + 1) * T.ceildiv(n, block_elem),
             )
-        for i in range(total_rounds - 1):
-            real_idx: T.let[T.int64] = total_rounds - 1 - i - 1
-            cur_len: T.let[T.int64] = T.ceildiv(n, 1 << (LOG_BLOCK_N * 
(real_idx + 1)))
+        reverse_rounds: T.let[T.int64] = T.max(total_rounds - 1, 0)
+        for i in range(reverse_rounds):
+            real_idx: T.let[T.int64] = reverse_rounds - 1 - i
+            shift: T.let[T.int64] = T.min(
+                T.max(LOG_BLOCK_N * (real_idx + 1), T.int64(0)), 
MAX_INDEX_SHIFT
+            )
+            block_shift: T.let[T.int64] = T.min(shift + LOG_BLOCK_N, 
MAX_INDEX_SHIFT)
+            cur_len: T.let[T.int64] = ((n - 1) >> shift) + 1
+            num_blocks: T.let[T.int64] = ((n - 1) >> block_shift) + 1
             update_cross_block(
                 m,
                 cur_len,
+                num_blocks,
                 Tmp,
                 Tmp,
                 src_offset=(real_idx + 1) * T.ceildiv(n, block_elem),
                 out_offset=real_idx * T.ceildiv(n, block_elem),
             )
-        update_cross_block(m, n, Tmp, Out, src_offset=0, out_offset=0)
+        update_cross_block(m, n, T.ceildiv(n, block_elem), Tmp, Out, 
src_offset=0, out_offset=0)
+
+    return cumsum
+
+
+def gpu_3d_axis_1_cumsum(
+    tx_len: int = 128,
+    in_dtype: str = "int32",
+    out_dtype: str | None = None,
+) -> PrimFunc:
+    """Generate a correctness fallback that scans axis 1 of a contiguous 3D 
tensor.
+
+    Each thread handles one pair of outer and inner indices and scans the
+    middle axis sequentially.  The dispatcher collapses arbitrary-rank inputs
+    around the scan axis into this 3D representation.  This fallback avoids a
+    transposed scan on targets where that lowering is unavailable; it is not a
+    parallel scan optimization for rank-3 tensors.
+    """
+
+    out_dtype = out_dtype or in_dtype
+    TX = T.int64(tx_len)
+
+    @T.prim_func(private=True, s_tir=True)
+    def cumsum(var_a: T.handle, var_out: T.handle):
+        T.func_attr({"tirx.is_scheduled": True})
+        outer, scan, inner = T.int64(), T.int64(), T.int64()
+        A = T.match_buffer(var_a, [outer, scan, inner], dtype=in_dtype)
+        Out = T.match_buffer(var_out, [outer, scan, inner], dtype=out_dtype)
+
+        for bx in T.thread_binding(T.ceildiv(outer * inner, TX), 
thread="blockIdx.x"):
+            for tx in T.thread_binding(TX, thread="threadIdx.x"):
+                row: T.let[T.int64] = bx * TX + tx
+                with T.sblock():
+                    accumulator = T.sblock_alloc_buffer((), out_dtype, 
scope="local")
+                    if row < outer * inner:
+                        outer_idx: T.let[T.int64] = row // inner
+                        inner_idx: T.let[T.int64] = row % inner
+                        accumulator[()] = T.Cast(out_dtype, 0)
+                        for k in T.serial(scan):
+                            accumulator[()] += T.Cast(out_dtype, A[outer_idx, 
k, inner_idx])
+                            Out[outer_idx, k, inner_idx] = accumulator[()]
 
     return cumsum
diff --git a/tests/python/relax/test_backend_dispatch_sort_scan.py 
b/tests/python/relax/test_backend_dispatch_sort_scan.py
index 9965b58c15..7fcd264917 100644
--- a/tests/python/relax/test_backend_dispatch_sort_scan.py
+++ b/tests/python/relax/test_backend_dispatch_sort_scan.py
@@ -484,5 +484,145 @@ def test_dispatch_cumprod_cuda_large_batch():
     tvm.testing.run_with_gpu_lock(run_and_check)
 
 
[email protected](
+    "shape, axis, in_dtype, out_dtype, expected_kernel, expected_kernel_rank",
+    [
+        ((3, 5), 0, "float32", None, "gpu_3d_axis_1_cumsum", 3),
+        ((2, 3, 4, 5), 1, "float32", None, "gpu_3d_axis_1_cumsum", 3),
+        ((2, 3, 4, 5), -2, "int32", "float32", "gpu_3d_axis_1_cumsum", 3),
+        # A short scan keeps total_rounds at zero in gpu_2d_continuous_cumsum.
+        ((2, 3, 4, 5), -1, "float32", None, "gpu_2d_continuous_cumsum", 2),
+    ],
+)
+def test_dispatch_cumsum_webgpu_axes_and_dtypes(
+    shape, axis, in_dtype, out_dtype, expected_kernel, expected_kernel_rank
+):
+    """WebGPU dispatch collapses arbitrary-rank scans to the appropriate 
kernel."""
+
+    vdevice = tvm.ir.VDevice("webgpu", 0)
+    x = relax.Var("x", relax.TensorType(shape, in_dtype, vdevice=vdevice))
+    bb = relax.BlockBuilder()
+    with bb.function("main", (x,)):
+        out = bb.emit(relax.op.cumsum(x, axis=axis, dtype=out_dtype))
+        bb.emit_func_output(out)
+    before = bb.finalize()
+    before.update_global_info("vdevice", [vdevice])
+
+    target = tvm.target.Target("webgpu", host="llvm")
+    with target:
+        mod = DispatchSortScan()(before)
+
+    called_kernels = []
+    permute_count = 0
+
+    def collect_calls(expr):
+        nonlocal permute_count
+        if isinstance(expr, relax.Call) and getattr(expr.op, "name", None) == (
+            "relax.permute_dims"
+        ):
+            permute_count += 1
+        if isinstance(expr, relax.Call) and getattr(expr.op, "name", None) == 
"relax.call_tir":
+            called_kernels.append(expr.args[0].name_hint)
+
+    relax.analysis.post_order_visit(mod["main"], collect_calls)
+    assert permute_count == 0
+    assert called_kernels == [expected_kernel]
+
+    cumsum = mod[expected_kernel]
+    buffers = [param for param in cumsum.params if 
tvm.tirx.is_buffer_var(param)]
+    assert len(buffers) == 2
+    assert all(len(buffer.shape) == expected_kernel_rank for buffer in buffers)
+    assert str(buffers[0].dtype) == in_dtype
+    assert str(buffers[1].dtype) == (out_dtype or in_dtype)
+
+    if expected_kernel == "gpu_2d_continuous_cumsum":
+        floor_divisors = []
+
+        def collect_floor_divisors(node):
+            if isinstance(node, tirx.FloorDiv):
+                floor_divisors.append(node.b)
+
+        tirx.stmt_functor.post_order_visit(cumsum.body, collect_floor_divisors)
+        assert floor_divisors
+        assert all(
+            isinstance(divisor, tirx.IntImm)
+            and divisor.value > 0
+            and divisor.value & (divisor.value - 1) == 0
+            for divisor in floor_divisors
+        )
+
+    with target:
+        tvm.compile(mod, target)
+
+
+def test_dispatch_cumsum_webgpu_symbolic_non_contiguous_axis():
+    """The serial WebGPU fallback accepts a symbolic scan extent."""
+
+    @I.ir_module
+    class Symbolic:
+        I.module_global_infos({"vdevice": [I.vdevice("webgpu", 0)]})
+
+        @R.function
+        def main(x: R.Tensor((1, "n", 9), "float32", "webgpu")):
+            return R.cumsum(x, axis=1)
+
+    target = tvm.target.Target("webgpu", host="llvm")
+    with target:
+        mod = DispatchSortScan()(Symbolic)
+        tvm.compile(mod, target)
+
+    called_kernels = []
+
+    def collect_calls(expr):
+        if isinstance(expr, relax.Call) and getattr(expr.op, "name", None) == 
"relax.call_tir":
+            called_kernels.append(expr.args[0].name_hint)
+
+    relax.analysis.post_order_visit(mod["main"], collect_calls)
+    assert called_kernels == ["gpu_3d_axis_1_cumsum"]
+
+
[email protected](
+    "target",
+    [
+        pytest.param("cuda", marks=pytest.mark.gpu),
+        pytest.param({"kind": "vulkan", "supports_int64": True}, 
marks=pytest.mark.gpu),
+        pytest.param("metal", marks=pytest.mark.gpu),
+    ],
+)
[email protected](
+    "in_dtype, out_dtype",
+    [("float32", "float32"), ("int32", "int32"), ("int32", "float32")],
+)
+def test_gpu_axis_1_cumsum_numerical(target, in_dtype, out_dtype):
+    """The fallback matches a sequential cumsum for supported WebGPU dtypes."""
+    if not tvm.testing.device_enabled(target):
+        pytest.skip(f"{target} not enabled")
+
+    from tvm.relax.backend.gpu_generic import (  # pylint: 
disable=import-outside-toplevel
+        gpu_3d_axis_1_cumsum,
+    )
+
+    shape = (2, 5, 7)
+    if in_dtype == "int32":
+        np_data = np.random.randint(-4, 5, shape).astype(in_dtype)
+    else:
+        np_data = np.random.uniform(-2, 2, shape).astype(in_dtype)
+    expected = np.cumsum(np_data, axis=1, dtype=out_dtype)
+
+    func = gpu_3d_axis_1_cumsum(in_dtype=in_dtype, 
out_dtype=out_dtype).with_attr(
+        "global_symbol", "main"
+    )
+    compiled = tvm.compile(func, target=target)
+
+    def run_and_check():
+        dev = tvm.device_from_target(target)
+        input_tensor = tvm.runtime.tensor(np_data, dev)
+        output_tensor = tvm.runtime.empty(shape, out_dtype, dev)
+        compiled(input_tensor, output_tensor)
+        tvm.testing.assert_allclose(output_tensor.numpy(), expected)
+
+    tvm.testing.run_with_gpu_lock(run_and_check)
+
+
 if __name__ == "__main__":
     tvm.testing.main()

Reply via email to