# Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. """Common utilities for CUDA operator scheduling (basic helpers and copy ops).""" import functools import operator import re from enum import Enum from tvm.arith.analyzer import Analyzer from tvm.runtime import DataType from tvm.script import tirx as T from tvm.tirx import Buffer, BufferRegion, PrimFunc from tvm.tirx.operator.tile_primitive import DispatchContext, fail from tvm.tirx.stmt import TilePrimitiveCall def next_power_of_2(x: int) -> int: """Return the smallest power of 2 greater than or equal to x.""" if x <= 1: return 1 return 1 << (x - 1).bit_length() def get_st_extent(buffer_region: BufferRegion): """Get the start and extent of a buffer region.""" region = buffer_region.region return [r.min for r in region], [r.extent for r in region] def get_indices(nth, start, extent): """Convert a fused index into multi-dimensional indices.""" assert len(start) == len(extent) if len(start) == 1: return [start[0] + nth] relative = [] for e in reversed(extent): relative.append(nth % e) nth //= e return [r + s for r, s in zip(reversed(relative), start)] def smem_desc_add_16B_offset(desc_val, offset): """Add a 16B-aligned byte offset to the lower 32 bits of a SMEM descriptor. Uses the SmemDescriptor union defined in the CUDA header (header.py). All callers must share a single implementation to avoid codegen conflicts. """ func_name = "tvm_builtin_smem_desc_add_16B_offset" source_code = f""" __forceinline__ __device__ uint64_t {func_name}(uint64_t desc_base, int32_t offset) {{ SmemDescriptor desc; desc.desc_ = desc_base; desc.lo += static_cast(offset); return desc.desc_; }} """ return T.cuda.func_call( func_name, desc_val, offset, source_code=source_code, return_type="uint64" ) class CopyInstType(Enum): """Enumeration of instruction types for memory operations.""" NORMAL = 0 CP_ASYNC = 1 def validate_copy_op( op_call: TilePrimitiveCall, sctx: DispatchContext, # pylint: disable=unused-argument ) -> bool: """Sanity check for copy op""" dst_buffer_region, src_buffer_region = op_call.args[:2] src: Buffer = src_buffer_region.buffer dst: Buffer = dst_buffer_region.buffer if not (src.layout and dst.layout and src.dtype == dst.dtype): return False # Extract regions and validate dimensions analyzer = Analyzer() src_region, dst_region = src_buffer_region.region, dst_buffer_region.region # Extract extents and validate non-unit dimensions match src_extent_ = [r.extent for r in src_region if r.extent != 1] dst_extent_ = [r.extent for r in dst_region if r.extent != 1] if len(src_extent_) != len(dst_extent_) or not all( analyzer.can_prove_equal(s, d) for s, d in zip(src_extent_, dst_extent_) ): return False return True def get_vec_len( dst_buffer_region: BufferRegion, src_buffer_region: BufferRegion, vec_candidates: list[int], thread_cnt=1, ) -> int | None: """Get the vector length for the copy operation.""" dst: Buffer = dst_buffer_region.buffer src: Buffer = src_buffer_region.buffer # layout=None (flat local buffer) is treated as trivial for vectorization purposes if not ( (dst.layout is None or dst.layout.is_trivial()) and (src.layout is None or src.layout.is_trivial()) ): return None # Extract regions and validate dimensions analyzer = Analyzer() src_st, src_extent = get_st_extent(src_buffer_region) dst_st, dst_extent = get_st_extent(dst_buffer_region) # Thread and vectorization setup DataType(src.dtype).bits # in bits n_elements = functools.reduce(operator.mul, src_extent, 1) if n_elements % thread_cnt != 0: return None # Find valid vector length for vec_len in vec_candidates: if vec_len > 0 and all( analyzer.can_prove_equal(x % vec_len, 0) for x in [ src_st[-1], dst_st[-1], src.shape[-1] if len(src.shape) > 1 else 0, dst.shape[-1] if len(dst.shape) > 1 else 0, src_extent[-1], dst_extent[-1], n_elements // thread_cnt, ] ): return vec_len else: return None def copy_vec_load_impl( op_call: TilePrimitiveCall, sctx: DispatchContext, inst_type: CopyInstType ) -> PrimFunc | None: """Schedule copy operation between global and local/shared memory on CUDA across a CTA/thread. The implementation tries to vectorize the copy operation and parallelize over threads in a CTA/using a single thread. """ dst_buffer_region, src_buffer_region = op_call.args[:2] src: Buffer = src_buffer_region.buffer dst: Buffer = dst_buffer_region.buffer if not ( (src.scope() == "global" and dst.scope().startswith("shared")) or (src.scope().startswith("shared") and dst.scope() == "global") or (src.scope() == "global" and dst.scope() == "local") or (src.scope() == "local" and dst.scope() == "global") or (src.scope().startswith("shared") and dst.scope() == "local") or (dst.scope().startswith("shared") and src.scope() == "local") ): fail(f"unsupported memory scopes src={src.scope()} dst={dst.scope()}") # Thread and vectorization setup if sctx.is_cta: tx = sctx.launch_params["threadIdx.x"].dom.extent assert "threadIdx.y" not in sctx.launch_params and "threadIdx.z" not in sctx.launch_params elif sctx.is_thread: tx = 1 else: fail(f"unsupported exec_scope {sctx.scope_kind}") elem_size = DataType(src.dtype).bits # in bits vec_len = op_call.config.get("vec_len", None) if vec_len is None: vec_len = get_vec_len( dst_buffer_region, src_buffer_region, [128 // elem_size, 64 // elem_size, 32 // elem_size, 1], thread_cnt=tx, ) if vec_len is None: fail("no valid vector length; check alignment/extents/thread-count") # cp-size (the size of data in bytes) can only be 4, 8 and 16 for cp.async if inst_type == CopyInstType.CP_ASYNC: cp_size = vec_len * elem_size // 8 # in bytes if cp_size not in [4, 8, 16]: fail("invalid cp.async cp_size; expected 4, 8 or 16 bytes") src_st, src_extent = get_st_extent(src_buffer_region) dst_st, dst_extent = get_st_extent(dst_buffer_region) n_elements = functools.reduce(operator.mul, src_extent, 1) if sctx.is_cta: # fmt: off @T.prim_func def impl(): """Implement copy operation with vectorized loads/stores.""" for s in T.serial(0, n_elements // (tx * vec_len)): for tid_x in T.thread_binding(tx, "threadIdx.x"): if inst_type == CopyInstType.NORMAL: for vec in T.vectorized(vec_len): fused = T.meta_var((s * tx + tid_x) * vec_len + vec) dst_indices = T.meta_var(get_indices(fused, dst_st, dst_extent)) src_indices = T.meta_var(get_indices(fused, src_st, src_extent)) dst[tuple(dst_indices)] = src[tuple(src_indices)] elif inst_type == CopyInstType.CP_ASYNC: fused = T.meta_var((s * tx + tid_x) * vec_len) dst_indices = T.meta_var(get_indices(fused, dst_st, dst_extent)) src_indices = T.meta_var(get_indices(fused, src_st, src_extent)) T.evaluate(T.ptx.cp_async(dst.ptr_to(dst_indices), src.ptr_to(src_indices), cp_size)) # noqa: E501 if dst.scope().startswith("shared") and inst_type == CopyInstType.NORMAL: T.tvm_storage_sync("shared") # fmt: on elif sctx.is_thread: # fmt: off @T.prim_func(check_well_formed=False) def impl(): for s in T.serial(0, n_elements // (vec_len)): if inst_type == CopyInstType.NORMAL: for vec in T.vectorized(vec_len): fused = T.meta_var(s * vec_len + vec) dst_indices = T.meta_var(get_indices(fused, dst_st, dst_extent)) src_indices = T.meta_var(get_indices(fused, src_st, src_extent)) dst[tuple(dst_indices)] = src[tuple(src_indices)] elif inst_type == CopyInstType.CP_ASYNC: fused = T.meta_var(s * vec_len) dst_indices = T.meta_var(get_indices(fused, dst_st, dst_extent)) src_indices = T.meta_var(get_indices(fused, src_st, src_extent)) T.evaluate(T.ptx.cp_async(dst.ptr_to(dst_indices), src.ptr_to(src_indices), cp_size)) # noqa: E501 # fmt: on else: fail(f"unsupported exec_scope {sctx.scope_kind}") return impl def match_scope(scope: str | None, pattern: str) -> bool: """Glob-lite scope matching: 'shared*' => prefix match; otherwise exact. Returns True when scope is None (meaning "any scope is fine"). """ if scope is None: return True if pattern.endswith("*"): return scope.startswith(pattern[:-1]) return scope == pattern def get_thread_cnt(sctx: DispatchContext) -> int | None: """Get thread count for the current execution scope.""" scope_name = sctx.scope_kind if scope_name == "cta": return sctx.launch_params["threadIdx.x"].dom.extent if scope_name == "warpgroup": return 128 if scope_name == "warp": return 32 if scope_name == "thread": return 1 return None def sm_version_ok( op: TilePrimitiveCall, sctx: DispatchContext, min_version: int ) -> tuple[bool, str | None]: """Check if SM version >= min_version. Usable as a dispatch predicate.""" target_arch = sctx.target.arch if hasattr(sctx.target, "arch") else "" sm_match = re.match(r"sm_(\d+)", target_arch) sm_version = int(sm_match.group(1)) if sm_match else 0 ok = sm_version >= min_version return (ok, None if ok else f"sm_version {sm_version} < {min_version}")