[LLVMGPU] Add NVVM paged attention pipeline test (#24770)

## Summary

Adds NVVM compile-time pipeline coverage for paged decode attention.

Part of #24766.

The test follows the existing ROCm paged-attention coverage in
`pipeline_vector_distribute_reduction_gfx942.mlir`, adapted for CUDA's
32-thread subgroups.

- Adds an NVVM `VectorDistribute` testcase covering dynamic page tables,
paged KV gathers, causal masking, and `online_attention` lowering.
- Uses an explicit CUDA layout with 128-thread workgroups and 32-thread
subgroups.

## Testing
```sh
/workspace/iree-build/tools/iree-opt --split-input-file \
  --iree-codegen-llvmgpu-nvvm-lowering-pipeline="include-llvm-lowering=false" \
  compiler/src/iree/compiler/Codegen/LLVMGPU/test/NVVM/pipeline_vector_distribute_paged_attention.mlir \
  | /workspace/iree-build/llvm-project/bin/FileCheck \
      compiler/src/iree/compiler/Codegen/LLVMGPU/test/NVVM/pipeline_vector_distribute_paged_attention.mlir

Signed-off-by: weimin023 <tnwilly@gmail.com>
diff --git a/compiler/src/iree/compiler/Codegen/LLVMGPU/test/NVVM/BUILD.bazel b/compiler/src/iree/compiler/Codegen/LLVMGPU/test/NVVM/BUILD.bazel
index 5bc165f..eb5ead9 100644
--- a/compiler/src/iree/compiler/Codegen/LLVMGPU/test/NVVM/BUILD.bazel
+++ b/compiler/src/iree/compiler/Codegen/LLVMGPU/test/NVVM/BUILD.bazel
@@ -23,6 +23,7 @@
             "pipeline_full_smoketests.mlir",
             "pipeline_tile_and_fuse_mma_sync.mlir",
             "pipeline_vector_distribute_mma_sync.mlir",
+            "pipeline_vector_distribute_paged_attention.mlir",
         ],
         include = ["*.mlir"],
     ),
diff --git a/compiler/src/iree/compiler/Codegen/LLVMGPU/test/NVVM/CMakeLists.txt b/compiler/src/iree/compiler/Codegen/LLVMGPU/test/NVVM/CMakeLists.txt
index 6ca9b6d..efc2d19 100644
--- a/compiler/src/iree/compiler/Codegen/LLVMGPU/test/NVVM/CMakeLists.txt
+++ b/compiler/src/iree/compiler/Codegen/LLVMGPU/test/NVVM/CMakeLists.txt
@@ -21,6 +21,7 @@
     "pipeline_full_smoketests.mlir"
     "pipeline_tile_and_fuse_mma_sync.mlir"
     "pipeline_vector_distribute_mma_sync.mlir"
+    "pipeline_vector_distribute_paged_attention.mlir"
   TOOLS
     FileCheck
     iree-opt
diff --git a/compiler/src/iree/compiler/Codegen/LLVMGPU/test/NVVM/pipeline_vector_distribute_paged_attention.mlir b/compiler/src/iree/compiler/Codegen/LLVMGPU/test/NVVM/pipeline_vector_distribute_paged_attention.mlir
new file mode 100644
index 0000000..62777db
--- /dev/null
+++ b/compiler/src/iree/compiler/Codegen/LLVMGPU/test/NVVM/pipeline_vector_distribute_paged_attention.mlir
@@ -0,0 +1,121 @@
+// RUN: iree-opt --split-input-file \
+// RUN:   --iree-codegen-llvmgpu-nvvm-lowering-pipeline='include-llvm-lowering=false' \
+// RUN:   %s | FileCheck %s
+
+/// Paged attention reduction distribution to multiple subgroups.
+/// Distribute 8x32 reduction dims across 4 subgroups with 2x16 threads shape per subgroup.
+
+#executable_target_cuda = #hal.executable.target<"cuda", "cuda-nvptx-fb">
+#translation = #iree_codegen.translation_info<
+    pipeline = #iree_gpu.pipeline<VectorDistribute>
+    workgroup_size = [128, 1, 1]
+    subgroup_size = 32>
+#qk_attrs_config = #iree_gpu.lowering_config<{
+    subgroup_basis = [[1, 1, 1, 1, 1, 4, 1], [4, 3, 2, 1, 5, 6]],
+    thread = [0, 0, 0, 8, 0, 0],
+    lane_basis = [[1, 1, 1, 1, 1, 2, 16], [2, 1, 0, 4, 5, 6]]
+}>
+#pv_attrs_config = #iree_gpu.lowering_config<{
+    subgroup_basis = [[1, 1, 1, 1, 1, 4, 1], [4, 3, 2, 1, 5, 6]],
+    thread = [0, 0, 0, 8, 0, 0],
+    lane_basis = [[1, 1, 1, 1, 1, 2, 16], [2, 1, 0, 4, 5, 6]]
+}>
+#attention_lowering_config = #iree_gpu.lowering_config<{
+    partial_reduction = [0, 0, 0, 0, 0, 8, 0],
+    workgroup = [1, 1, 1, 0, 0, 0, 0]
+}>
+#pipeline_layout = #hal.pipeline.layout<constants = 1, bindings = [
+  #hal.pipeline.binding<storage_buffer, ReadOnly>,
+  #hal.pipeline.binding<storage_buffer, ReadOnly>,
+  #hal.pipeline.binding<storage_buffer, ReadOnly>,
+  #hal.pipeline.binding<storage_buffer, ReadOnly>,
+  #hal.pipeline.binding<storage_buffer>
+]>
+
+func.func @paged_attention_4xDx1x32x128_f16() attributes {hal.executable.target = #executable_target_cuda, translation_info = #translation} {
+  %scale = arith.constant 8.837890e-02 : f16
+  %zero_f16 = arith.constant 0.000000e+00 : f16
+  %neg_inf_f16 = arith.constant 0xFC00 : f16
+  %zero_f32 = arith.constant 0.000000e+00 : f32
+  %neg_inf_f32 = arith.constant 0xFF800000 : f32
+  %c0 = arith.constant 0 : index
+  %c32 = arith.constant 32 : index
+
+  %num_pages_i32 = hal.interface.constant.load layout(#pipeline_layout) ordinal(0) : i32
+  %num_pages_index = arith.index_castui %num_pages_i32 : i32 to index
+  %num_pages = util.assume.int %num_pages_index<umin = 1, umax = 512> : index
+  %workload = iree_tensor_ext.dispatch.workload.ordinal %num_pages, 0 : index
+
+  %kv_storage = hal.interface.binding.subspan layout(#pipeline_layout) binding(0) alignment(64) offset(%c0) flags(ReadOnly) : !iree_tensor_ext.dispatch.tensor<readonly:tensor<4096x1x32x128xf16>>
+  %key_page_table = hal.interface.binding.subspan layout(#pipeline_layout) binding(1) alignment(64) offset(%c0) flags(ReadOnly) : !iree_tensor_ext.dispatch.tensor<readonly:tensor<4x?xi64>>{%workload}
+  %value_page_table = hal.interface.binding.subspan layout(#pipeline_layout) binding(2) alignment(64) offset(%c0) flags(ReadOnly) : !iree_tensor_ext.dispatch.tensor<readonly:tensor<4x?xi64>>{%workload}
+  %query = hal.interface.binding.subspan layout(#pipeline_layout) binding(3) alignment(64) offset(%c0) flags(ReadOnly) : !iree_tensor_ext.dispatch.tensor<readonly:tensor<4x1x1x128xf16>>
+  %output = hal.interface.binding.subspan layout(#pipeline_layout) binding(4) alignment(64) offset(%c0) : !iree_tensor_ext.dispatch.tensor<writeonly:tensor<4x1x1x128xf32>>
+
+  %key_indices = iree_tensor_ext.dispatch.tensor.load %key_page_table, offsets = [0, 0], sizes = [4, %workload], strides = [1, 1] : !iree_tensor_ext.dispatch.tensor<readonly:tensor<4x?xi64>>{%workload} -> tensor<4x?xi64>
+  %value_indices = iree_tensor_ext.dispatch.tensor.load %value_page_table, offsets = [0, 0], sizes = [4, %workload], strides = [1, 1] : !iree_tensor_ext.dispatch.tensor<readonly:tensor<4x?xi64>>{%workload} -> tensor<4x?xi64>
+  %query_tensor = iree_tensor_ext.dispatch.tensor.load %query, offsets = [0, 0, 0, 0], sizes = [4, 1, 1, 128], strides = [1, 1, 1, 1] : !iree_tensor_ext.dispatch.tensor<readonly:tensor<4x1x1x128xf16>> -> tensor<4x1x1x128xf16>
+  %storage_tensor = iree_tensor_ext.dispatch.tensor.load %kv_storage, offsets = [0, 0, 0, 0], sizes = [4096, 1, 32, 128], strides = [1, 1, 1, 1] : !iree_tensor_ext.dispatch.tensor<readonly:tensor<4096x1x32x128xf16>> -> tensor<4096x1x32x128xf16>
+
+  %output_empty = tensor.empty() : tensor<4x1x1x128xf32>
+  %mask_empty = tensor.empty(%workload) : tensor<4x1x1x?x32xf16>
+  %gather_empty = tensor.empty(%workload) : tensor<4x?x1x32x128xf16>
+  %reduction_empty = tensor.empty() : tensor<4x1x1xf32>
+  %output_init = linalg.fill ins(%zero_f32 : f32) outs(%output_empty : tensor<4x1x1x128xf32>) -> tensor<4x1x1x128xf32>
+  %max_init = linalg.fill ins(%neg_inf_f32 : f32) outs(%reduction_empty : tensor<4x1x1xf32>) -> tensor<4x1x1xf32>
+  %sum_init = linalg.fill ins(%zero_f32 : f32) outs(%reduction_empty : tensor<4x1x1xf32>) -> tensor<4x1x1xf32>
+
+  %key = iree_linalg_ext.gather dimension_map = [0]
+      ins(%storage_tensor, %key_indices : tensor<4096x1x32x128xf16>, tensor<4x?xi64>)
+      outs(%gather_empty : tensor<4x?x1x32x128xf16>) -> tensor<4x?x1x32x128xf16>
+  %value = iree_linalg_ext.gather dimension_map = [0]
+      ins(%storage_tensor, %value_indices : tensor<4096x1x32x128xf16>, tensor<4x?xi64>)
+      outs(%gather_empty : tensor<4x?x1x32x128xf16>) -> tensor<4x?x1x32x128xf16>
+
+  %mask = linalg.generic {
+      indexing_maps = [affine_map<(d0, d1, d2, d3, d4) -> (d0, d1, d2, d3, d4)>],
+      iterator_types = ["parallel", "parallel", "parallel", "parallel", "parallel"]
+    }
+    outs(%mask_empty : tensor<4x1x1x?x32xf16>) {
+      ^bb0(%out: f16):
+        %position_in_page = linalg.index 4 : index
+        %page_index = linalg.index 3 : index
+        %page_start = arith.muli %page_index, %c32 overflow<nsw> : index
+        %kv_position = arith.addi %page_start, %position_in_page : index
+        %is_causal = arith.cmpi sle, %kv_position, %c0 : index
+        %mask_value = arith.select %is_causal, %zero_f16, %neg_inf_f16 : f16
+        linalg.yield %mask_value : f16
+    } -> tensor<4x1x1x?x32xf16>
+
+  %attention:3 = iree_linalg_ext.online_attention {
+      decomposition_config = {
+        pv_attrs = {lowering_config = #pv_attrs_config},
+        qk_attrs = {lowering_config = #qk_attrs_config}
+      },
+      indexing_maps = [
+        affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1, d2, d4)>,
+        affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d5, d1, d6, d4)>,
+        affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d5, d1, d6, d3)>,
+        affine_map<(d0, d1, d2, d3, d4, d5, d6) -> ()>,
+        affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1, d2, d5, d6)>,
+        affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1, d2, d3)>,
+        affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1, d2)>,
+        affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1, d2)>],
+      lowering_config = #attention_lowering_config
+    }
+    ins(%query_tensor, %key, %value, %scale, %mask : tensor<4x1x1x128xf16>, tensor<4x?x1x32x128xf16>, tensor<4x?x1x32x128xf16>, f16, tensor<4x1x1x?x32xf16>)
+    outs(%output_init, %max_init, %sum_init : tensor<4x1x1x128xf32>, tensor<4x1x1xf32>, tensor<4x1x1xf32>) {
+      ^bb0(%score: f32):
+        iree_linalg_ext.yield %score : f32
+    } -> tensor<4x1x1x128xf32>, tensor<4x1x1xf32>, tensor<4x1x1xf32>
+
+  iree_tensor_ext.dispatch.tensor.store %attention#0, %output, offsets = [0, 0, 0, 0], sizes = [4, 1, 1, 128], strides = [1, 1, 1, 1] : tensor<4x1x1x128xf32> -> !iree_tensor_ext.dispatch.tensor<writeonly:tensor<4x1x1x128xf32>>
+  return
+}
+
+// CHECK-LABEL: func.func @paged_attention_4xDx1x32x128_f16
+// CHECK-NOT: iree_linalg_ext.gather
+// CHECK-NOT: iree_linalg_ext.online_attention
+// CHECK: scf.for
+// CHECK: gpu.subgroup_reduce
+// CHECK: iree_codegen.dispatch_config @paged_attention_4xDx1x32x128_f16 workgroup_size = [128, 1, 1] subgroup_size = 32