chore: import upstream snapshot with attribution
Lint / lint (push) Has been cancelled
CI / MacOS (push) Has been cancelled
CI / Windows (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:36:25 +08:00
commit 26446540fa
3151 changed files with 974126 additions and 0 deletions
@@ -0,0 +1,27 @@
# 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.
from .fallback import *
from .gmem_smem import *
from .ld_stmatrix import *
from .reg import *
from .utils import (
_is_valid_copy,
_is_valid_smem_tmem_copy,
_scope_allowed,
_single_thread_exec,
)
@@ -0,0 +1,556 @@
# 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.
"""Shared partition / layout algorithm for synthesized-partition copy
dispatches (currently ``gmem_smem`` and ``ldgsts``).
``gmem_smem`` (sync ``Tx.copy`` global ↔ shared) and ``ldgsts`` (async
``Tx.copy_async`` global → shared via cp.async / SASS LDGSTS) share the
same algorithm to pick a vec-isolating + thread-distributing layout for
``G ↔ S`` copies. Only emit-time details differ (which copy instruction
to call, allowed vec widths). All the layout/partition logic lives here.
"""
from tvm import arith
from tvm.tirx.layout import ComposeLayout, Iter, S, SwizzleLayout, TileLayout
from tvm.tirx.operator.tile_primitive.registry import DispatchContext
def _alignment_ok(vec_len: int, terms) -> bool:
"""Every term must be a multiple of ``vec_len``. Constants checked
directly; Expr / symbolic terms checked via ``arith.Analyzer``.
``vec_len=1`` always passes (the scalar fallback). When a symbolic
term can't be proved divisible, returns ``False`` conservatively —
the candidate loop will then try a smaller ``vec_len``.
"""
if vec_len <= 1:
return True
analyzer = arith.Analyzer()
for t in terms:
if isinstance(t, int):
if t % vec_len != 0:
return False
else:
if not analyzer.can_prove_equal(t % vec_len, 0):
return False
return True
# scope_kind → name of the scope_id that decomposes the scope into per-thread.
_TID_AXIS_FOR_SCOPE = {
"warp": "laneid",
"warpgroup": "tid_in_wg",
"cta": "tx",
}
def _thread_cnt(sctx: DispatchContext) -> int:
"""Total threads active in the current scope = ∏ intra-axis extents.
For thread scope ``sctx.intra`` is empty → returns 1.
"""
n = 1
for ext, _off in sctx.intra.values():
n *= int(ext)
return n
# -----------------------------------------------------------------------------
# Layout primitives
# -----------------------------------------------------------------------------
def _contig_group(iters: list) -> list[int]:
"""Indices (in iters) of the maximal physical-contiguous chain starting
at the stride=1 iter, ordered stride-ascending.
Returns [] if no stride=1 iter exists.
"""
one_idx = next(
(i for i, it in enumerate(iters) if int(it.stride) == 1),
None,
)
if one_idx is None:
return []
chain = [one_idx]
acc = int(iters[one_idx].extent)
used = {one_idx}
while True:
nxt = next(
(i for i, it in enumerate(iters) if i not in used and int(it.stride) == acc),
None,
)
if nxt is None:
break
chain.append(nxt)
acc *= int(iters[nxt].extent)
used.add(nxt)
return chain
def _try_split_vec(iters: list, vec_len: int):
"""Try to walk ``vec_len`` consecutive elements along the contig chain.
Returns ``(new_iters, selected_positions)`` on success, ``None`` on
failure. ``new_iters`` may contain a freshly-split iter (replacing one
entry with its "outer" half, with the "inner" half appended at the end);
``selected_positions`` are positions in ``new_iters`` that together
cover the ``vec_len`` contig elements.
"""
chain = _contig_group(iters)
if not chain:
return None
rem = vec_len
new_iters = list(iters)
selected: list[int] = []
for orig_idx in chain:
if rem == 0:
break
it = new_iters[orig_idx]
ext = int(it.extent)
if ext <= rem:
if rem % ext != 0:
return None
selected.append(orig_idx)
rem //= ext
else:
if ext % rem != 0:
return None
stride = int(it.stride)
outer = Iter(ext // rem, stride * rem, it.axis)
inner = Iter(rem, stride, it.axis)
new_iters[orig_idx] = outer
new_iters.append(inner)
selected.append(len(new_iters) - 1)
rem = 0
break
if rem != 0:
return None
return new_iters, selected
def _isolated_shape(iters: list, selected: list[int]) -> tuple[list[int], list[tuple[int, int]]]:
"""Build the isolated shape: each selected iter is its own segment;
adjacent unselected iters are merged into a single segment.
Returns ``(shape, segments)`` where ``segments[i] = (start, end)`` is
the half-open range in ``iters`` covered by shape entry ``i``.
"""
sel_set = set(selected)
shape: list[int] = []
segments: list[tuple[int, int]] = []
cur_start = None
cur_ext = 1
for i, it in enumerate(iters):
if i in sel_set:
if cur_start is not None:
shape.append(cur_ext)
segments.append((cur_start, i))
cur_start = None
cur_ext = 1
shape.append(int(it.extent))
segments.append((i, i + 1))
else:
if cur_start is None:
cur_start = i
cur_ext *= int(it.extent)
if cur_start is not None:
shape.append(cur_ext)
segments.append((cur_start, len(iters)))
return shape, segments
def _vec_perm(iters: list, selected: list[int]) -> list[int]:
"""Reorder ``iters`` into ``[outer, vec]``, both ordered by stride
descending so the stride=1 iter ends up at the very last position."""
sel_set = set(selected)
unsel_sorted = sorted(
(i for i in range(len(iters)) if i not in sel_set),
key=lambda i: -int(iters[i].stride),
)
sel_sorted = sorted(selected, key=lambda i: -int(iters[i].stride))
return list(unsel_sorted) + sel_sorted
def _try_split_thread(iters: list, vec_selected: list[int], thread_cnt: int):
"""After ``_try_split_vec``, carve ``thread_cnt`` from the OUTER tail
(smallest-stride outer iter, then towards bigger stride if needed).
Unlike vec split, this doesn't require physical contiguity — T
consecutive fused indices map to per-thread offsets via the layout's
stride (which may be > 1).
Returns ``(new_iters, thread_selected_positions)`` on success, ``None``
on failure (outer doesn't divide T cleanly, or no outer iters left).
"""
if thread_cnt == 1:
return list(iters), []
vec_set = set(vec_selected)
outer = [i for i in range(len(iters)) if i not in vec_set]
if not outer:
return None
outer_by_stride_desc = sorted(outer, key=lambda i: -int(iters[i].stride))
rem = thread_cnt
new_iters = list(iters)
thread_selected: list[int] = []
for orig_idx in reversed(outer_by_stride_desc):
if rem == 0:
break
it = new_iters[orig_idx]
ext = int(it.extent)
if ext <= rem:
if rem % ext != 0:
return None
thread_selected.append(orig_idx)
rem //= ext
else:
if ext % rem != 0:
return None
stride = int(it.stride)
new_iters[orig_idx] = Iter(ext // rem, stride * rem, it.axis)
new_iters.append(Iter(rem, stride, it.axis))
thread_selected.append(len(new_iters) - 1)
rem = 0
break
if rem != 0:
return None
return new_iters, thread_selected
def _three_segment_perm(iters: list, t_selected: list[int], vec_selected: list[int]) -> list[int]:
"""Reorder ``iters`` into ``[outer, T, vec]`` segments. Within each
segment, stride descending so stride=1 sits at the very end."""
t_set = set(t_selected)
vec_set = set(vec_selected)
outer = sorted(
(i for i in range(len(iters)) if i not in t_set and i not in vec_set),
key=lambda i: -int(iters[i].stride),
)
t_sorted = sorted(t_selected, key=lambda i: -int(iters[i].stride))
vec_sorted = sorted(vec_selected, key=lambda i: -int(iters[i].stride))
return list(outer) + t_sorted + vec_sorted
def _shape_perm_for_isolated(
shape_segments: list[tuple[int, int]], iter_perm: list[int]
) -> list[int]:
"""Given segments (one per shape entry, each = (start, end) in
pre-perm iter positions) and the iter permutation, compute the
corresponding shape permutation."""
seg_of = [0] * sum(end - start for start, end in shape_segments)
for seg_idx, (start, end) in enumerate(shape_segments):
for k in range(start, end):
seg_of[k] = seg_idx
seen: set[int] = set()
perm: list[int] = []
for orig_idx in iter_perm:
seg_idx = seg_of[orig_idx]
if seg_idx not in seen:
seen.add(seg_idx)
perm.append(seg_idx)
return perm
def _verify_s_tail_contig(s_p: TileLayout, vec_len: int) -> bool:
"""Check the last iters of ``s_p`` form a stride=1 contig chain whose
extent product equals ``vec_len``."""
iters = list(s_p.shard)
if not iters:
return vec_len == 1
last = iters[-1]
if int(last.stride) != 1:
return False
acc = int(last.extent)
if acc == vec_len:
return True
for k in range(len(iters) - 2, -1, -1):
it = iters[k]
if int(it.stride) != acc:
break
acc *= int(it.extent)
if acc == vec_len:
return True
if acc > vec_len:
return False
return acc >= vec_len and acc % vec_len == 0
_VEC_BITS_CANDIDATES = (128, 64, 32, 16, 8)
def _vec_len_candidates(elem_bits: int, allowed_bits: tuple | None = None) -> list[int]:
"""Vec-length candidates (in elements) for the given element width.
``allowed_bits`` optionally filters the per-instruction allowed widths
(e.g. cp.async only accepts {128, 64, 32} bits = 16/8/4 bytes).
Defaults to ``_VEC_BITS_CANDIDATES`` if not specified.
"""
bits_tuple = allowed_bits if allowed_bits is not None else _VEC_BITS_CANDIDATES
out: list[int] = []
for vb in bits_tuple:
if vb < elem_bits or vb % elem_bits != 0:
continue
n = vb // elem_bits
if n not in out:
out.append(n)
if 1 not in out and allowed_bits is None:
# Scalar fallback is only added for the unrestricted candidate set;
# an instruction-specific list (cp.async etc.) keeps its strictness.
out.append(1)
return out
def _extract_tile(layout, region):
"""Strip swizzle so we can perm/group as a TileLayout."""
if isinstance(layout, ComposeLayout):
return layout.tile_layout
if isinstance(layout, SwizzleLayout):
extents = [int(end - start) for (start, end) in region]
return TileLayout(S[tuple(extents)])
return layout
def _sort_by_stride_desc(layout: TileLayout) -> TileLayout:
"""Reorder shard so list order = traversal order (outer first, stride=1
last). Required before canonicalize() can fuse non-adjacent-but-contig
iters."""
iters = list(layout.shard)
perm = sorted(range(len(iters)), key=lambda i: -int(iters[i].stride))
if perm == list(range(len(iters))):
return layout
return layout.permute_dims(perm)
def _carve_tail(iters: list, chunk: int):
"""Carve ``chunk`` elements off the tail. Walk back across multiple
iters as needed; at most one iter is split.
Per iter (from last to first), let ``ext`` = iter extent and ``rem``
= remaining chunk to fill:
* ``ext == rem``: eat this iter whole, done.
* ``ext < rem``: must divide ``rem``; eat whole, ``rem //= ext``.
* ``ext > rem``: must divide ``ext``; split into
``(ext/rem, stride*rem) + (rem, stride)``, take the inner. Done.
Returns the new iter list on success, ``None`` on failure.
"""
if not iters or chunk <= 0:
return None
rem = chunk
work = list(iters)
for idx in range(len(work) - 1, -1, -1):
it = work[idx]
ext = int(it.extent)
if ext == rem:
return work
if ext < rem:
if rem % ext != 0:
return None
rem //= ext
continue
if ext % rem != 0:
return None
stride = int(it.stride)
work[idx] = Iter(ext // rem, stride * rem, it.axis)
work.insert(idx + 1, Iter(rem, stride, it.axis))
return work
return None
def align_layouts_gs(
g_layout,
g_shape,
g_region,
s_layout,
s_shape,
s_region,
elem_bits,
thread_cnt: int,
vec_bits_candidates: tuple | None = None,
debug: bool = False,
):
"""Align G and S layouts for a synthesized G↔S copy.
Algorithm:
1. Sort G iters by stride desc + canonicalize (fuses anything physically
contig). Same for S.
2. Group S by G's iter shape (per-iter shape list) + permute_by_groups
with identity so S's groups line up with G's iters.
3. Carve ``vec_len`` off G's tail (a single iter split if needed).
4. Carve ``T = thread_cnt`` off the iter before vec (multi-iter walk,
single split).
5. Re-group S (already permuted) by the new finer per-iter shape. No
further permute needed because steps 3-4 only refine the tail.
6. Verify S's tail (vec segment) is physically contig.
``vec_bits_candidates`` optionally restricts the per-instruction allowed
widths (e.g. cp.async only accepts {128, 64, 32} bits). When ``None``
(default), uses the full {128, 64, 32, 16, 8} set plus a scalar (1)
fallback.
Returns ``(g_p, s_p, vec_len)``. ``g_p.shard`` ends as
``[outer iters..., T iter, vec iter]``; ``s_p.shard`` has the same iter
count and matching iter-by-iter extents.
"""
g = g_layout.slice(list(g_shape), g_region)
s = s_layout.slice(list(s_shape), s_region)
# Detect a SwizzleLayout on the S side BEFORE _extract_tile strips it.
# vec_len must fit inside one swizzle chunk (C = 2^per_element elements);
# otherwise the vec ld/st crosses a swizzle XOR boundary and hits the
# wrong physical bytes mid-vec.
s_swizzle_chunk_elems = None
if isinstance(s_layout, ComposeLayout):
s_swizzle_chunk_elems = 1 << int(s_layout.swizzle.per_element)
elif isinstance(s_layout, SwizzleLayout):
s_swizzle_chunk_elems = 1 << int(s_layout.per_element)
g = _extract_tile(g, g_region)
s = _extract_tile(s, s_region)
# Only G drives the canonical form. S's iter order is derived from G's
# pre-sort iter extents (used as the grouping shape) and then permuted
# by G's stride-desc permutation. Independently sorting/canonicalizing
# S would fuse iters whose strides happen to chain into one contiguous
# range — that loses the layout's logical `(i, j) → addr` mapping for
# layout-permuting copies (e.g. row-major GMEM → K-tiled SMEM, where
# both layouts cover the same byte range but with different coord maps).
g_pre_sort_extents = [int(it.extent) for it in g.shard]
g_perm = sorted(range(len(g.shard)), key=lambda i: -int(g.shard[i].stride))
try:
s_grp1, seps1 = s.group(g_pre_sort_extents)
except Exception as e:
if debug:
print(f" Step-2 S.group({g_pre_sort_extents}) failed: {e}")
return _sort_by_stride_desc(g).canonicalize(), s, 1
# S iters keep their original strides; only the *group order* is
# rearranged to follow G's sorted iter order. Canonicalize after the
# permute is safe — it only fuses iters whose strides genuinely chain
# in the post-permute order, which preserves the logical structure.
s_aligned = s_grp1.permute_by_groups(list(seps1), g_perm).canonicalize()
g = _sort_by_stride_desc(g).canonicalize()
if debug:
print(f" g (sort+canon): shard={[(int(it.extent), int(it.stride)) for it in g.shard]}")
print(
f" s (grouped+permuted by G shape {g_pre_sort_extents}): shard="
f"{[(int(it.extent), int(it.stride)) for it in s_aligned.shard]}"
)
print(f" thread_cnt: {thread_cnt}")
dummy_axis = g.shard[-1].axis if g.shard else None
for vec_len in _vec_len_candidates(elem_bits, vec_bits_candidates):
if vec_len == 1:
g_after_vec = [*list(g.shard), Iter(1, 1, dummy_axis)]
else:
# Swizzle chunk-size cap: vec must fit in one swizzle chunk.
if s_swizzle_chunk_elems is not None and vec_len > s_swizzle_chunk_elems:
if debug:
print(
f" vec_len={vec_len}: exceeds swizzle chunk "
f"({s_swizzle_chunk_elems} elements)"
)
continue
g_after_vec = _carve_tail(list(g.shard), vec_len)
if g_after_vec is None:
if debug:
print(f" vec_len={vec_len}: G vec carve failed")
continue
outer_for_t = g_after_vec[:-1]
outer_after_t = _carve_tail(outer_for_t, thread_cnt) if thread_cnt > 1 else outer_for_t
if outer_after_t is None:
if debug:
print(f" vec_len={vec_len}: T={thread_cnt} carve failed")
continue
g_final_iters = [*outer_after_t, g_after_vec[-1]]
new_shape = [int(it.extent) for it in g_final_iters]
try:
s_final_grp, _ = s_aligned.group(new_shape)
except Exception as e:
if debug:
print(f" vec_len={vec_len}: S.group({new_shape}) failed: {e}")
continue
g_p = TileLayout.from_iters(g_final_iters, list(g.replica), dict(g.offset))
s_p = s_final_grp
ok = _verify_s_tail_contig(s_p, vec_len)
if debug:
print(
f" vec_len={vec_len}: shape={new_shape}, "
f"g_p.shard={[(int(it.extent), int(it.stride)) for it in g_p.shard]}, "
f"s_p.shard={[(int(it.extent), int(it.stride)) for it in s_p.shard]}, "
f"s_tail_contig={ok}"
)
if not ok:
continue
# Alignment: per-thread starting addr = base_ptr + sizeof(elem) *
# (region_base + tid*t_stride + outer_iter_strides). For the
# vec_bits/8 byte vector op to be naturally aligned, every one of
# those element-count terms must be a multiple of vec_len.
align_terms = []
for it in s_p.shard[:-1]:
align_terms.append(int(it.stride))
for it in g_p.shard[:-1]:
align_terms.append(int(it.stride))
align_terms.extend(s_p.offset.values())
align_terms.extend(g_p.offset.values())
if not _alignment_ok(vec_len, align_terms):
if debug:
print(f" vec_len={vec_len}: alignment check failed")
continue
return g_p, s_p, vec_len
return g, s_aligned, 1
def _flat_outer_coords(outer_exts: list[int], flat_idx: int) -> list[int]:
"""Decode a row-major flat index into per-iter coords. ``outer_exts`` is
in stride-desc order (outermost first), so the first coord changes
slowest."""
coords: list[int] = []
rem = flat_idx
for ext in reversed(outer_exts):
coords.append(rem % ext)
rem //= ext
coords.reverse()
return coords
def _outer_offsets(outer_iters_s, outer_iters_g, flat_idx):
"""Returns ``(ds, dg)``: constant offsets on S and G sides for the
given flat outer-loop iteration."""
outer_exts = [int(it.extent) for it in outer_iters_s]
coords = _flat_outer_coords(outer_exts, flat_idx)
ds = sum(c * int(it.stride) for c, it in zip(coords, outer_iters_s))
dg = sum(c * int(it.stride) for c, it in zip(coords, outer_iters_g))
return ds, dg
def copy_ptx_form(num_bytes: int) -> tuple[str, str]:
"""Map copy width (bytes) to PTX ``(vec, ptx_type)`` for ``T.ptx.ld`` / ``T.ptx.st``."""
return {
16: ("v4", "u32"),
8: ("v2", "u32"),
4: ("", "u32"),
2: ("", "u16"),
1: ("", "u8"),
}[num_bytes]
def copy_ptx_ld_return_type(ptx_type: str) -> str:
"""TVM dtype string for ``T.ptx.ld``'s ``return_type`` argument."""
return {"u32": "uint32", "u16": "uint16", "u8": "uint32"}[ptx_type]
@@ -0,0 +1,406 @@
# 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.
"""Generic swizzle-aware iter pattern for CUDA copy dispatches.
When the per-thread outer-iter loop satisfies (C1)+(C2) below for a
``SwizzleLayout(per_element=p, swizzle_len=sw, atom_len=at,
swizzle_inner=True)`` on the SMEM side, the swizzled physical address
at unrolled iter ``k`` reduces to
addr(k) = base_off + sum_{j : bit_j(k)=1} signed_strides[j]
where ``base_off`` and the ``signed_strides[j]`` are per-thread runtime
constants set once at thread setup. Per-iter cost is then ``popcount(k)``
register adds instead of a full ``swizzle.apply(...)`` per iter.
Notation. Each binary outer iter has element-stride ``2^(bj + p)`` for
some chunk bit position ``bj >= 0`` (so ``stride / C = 2^bj`` where
``C = 1 << p``). The chunk index ``q(M0) = M0 // C`` partitions into
four bit ranges by where ``bj`` lands:
* ``[0, sw)`` — "inner" (Case 1.A in the proof)
* ``[sw, at)`` — "mid" (Case 1.B)
* ``[at, at + sw)`` — "outer" (Case 1.C; the bit overlaps the swizzle
outer mask, so its addition produces a
secondary contribution at ``bj - at``)
* ``[at + sw, ∞)`` — "above" (Case 1.D)
Conditions for the linear-combination fast path:
(C1) bit-clear no-carry: ``bit_bj(q(M0)) = 0`` for every binary iter.
(C2) support disjointness: no inner-outer pair ``(bj_A, bj_C)`` with
``bj_C in [at, at+sw)`` and ``bj_A = bj_C - at`` both present.
(distinctness) The ``bj`` values across all binary iters must be
distinct — two iters at the same ``bj`` collapse into bit
``bj + 1`` whose case behavior may differ.
Under (C1)+(C2)+(distinctness), for each binary iter at position ``bj``:
T(bj) = 2^(bj + p) # element stride
sigma_b(M0) = 1 - 2 * bit_b(q(M0)) # ∈ {+1, -1}
signed_strides[j] = sigma_(at + bj)(M0) * T(bj) bj in [0, sw)
= T(bj) bj in [sw, at)
= T(bj) + sigma_(bj - at)(M0) * T(bj - at)
bj in [at, at + sw)
= T(bj) bj >= at + sw
The ``swizzle_inner=False`` mode swaps the inner/outer roles and is not
yet covered; ``try_recognize`` gates on this.
"""
from dataclasses import dataclass
import tvm
from tvm import arith
from tvm.script import tirx as T
from tvm.tirx.expr import IntImm as _IntImm
from tvm.tirx.layout import ComposeLayout, SwizzleLayout
@dataclass
class _BitIter:
"""Pow2-extent outer iter, binary-split into ``n_bits`` chunk-bit flips.
``slot_start..slot_start + n_bits`` is this iter's range in the global
``bit_positions`` / ``iter_strides_elems`` / ``signed_strides`` arrays.
Slot ``slot_start + b`` corresponds to bit position ``n_bits - 1 - b``
of this iter's per-iter coord (outermost binary bit first).
"""
ext: int
n_bits: int
slot_start: int
@dataclass
class _LinearIter:
"""Outer iter contributing ``c * stride`` to the offset (no bit decomp).
Used when ``stride`` is a multiple of ``2^(p + at + sw)`` (pure Case 1.D
regime: swizzle XOR has no effect on bits the iter flips). ``ext`` does
not need to be a power of two.
"""
ext: int
stride: int
@dataclass
class SwizzlePattern:
"""A recognized swizzle iter pattern.
``bit_positions[j]`` and ``iter_strides_elems[j]`` collect the binary
sub-iters from every BitIter in outer-iter order (outermost first).
``outer_iters`` lists every outer iter (BitIter or LinearIter) in
outermost-first order; ``emit_iter_offset`` walks this list to
decompose ``mm`` per-iter. Empty lists = trivially recognized
degenerate case (no outer iter, just base_off).
"""
swizzle: SwizzleLayout
bit_positions: list[int]
iter_strides_elems: list[int]
outer_iters: "list[_BitIter | _LinearIter]"
@property
def n_binary_iters(self) -> int:
return len(self.bit_positions)
def get_swizzle(layout) -> SwizzleLayout | None:
"""Return the SwizzleLayout from ``layout`` if present, else ``None``.
Accepts ``ComposeLayout(SwizzleLayout, TileLayout)`` (the common case
when a TileLayout is wrapped by a swizzle), or a bare ``SwizzleLayout``.
"""
if isinstance(layout, ComposeLayout):
return layout.swizzle
if isinstance(layout, SwizzleLayout):
return layout
return None
def _is_pow2(n: int) -> bool:
return n > 0 and (n & (n - 1)) == 0
def try_recognize(
swizzle: SwizzleLayout,
iter_extents: list[int],
iter_strides: list[int],
s_off_template,
var_bounds: dict | None = None,
) -> SwizzlePattern | None:
"""Return a ``SwizzlePattern`` if (C1)+(C2)+(distinctness) hold, else ``None``.
``iter_extents`` / ``iter_strides``: the outer-iter list on the S side
(excluding T iter and vec iter), in outermost-first order matching
``s_p.shard[:-2]`` (or the atom-derived analog in ``reg.py``).
Strides are in element units.
Each outer iter with ``extent=2^k`` and ``stride=s`` is conceptually
split into ``k`` binary iters of strides ``2^(k-1)*s, ..., 2*s, s``
(outermost first within the split — this matches ``_flat_outer_coords``
semantics, since the highest-stride iter must change slowest in the
flat-index decomposition).
``s_off_template`` is the per-thread linear base offset expression
(with a placeholder var for the thread-id contribution). It is used
only to check condition (C1) symbolically via ``arith.Analyzer``;
``emit_init`` takes the resolved form separately.
``var_bounds`` is an optional ``{Var: tvm.ir.Range}`` map of placeholder
bounds to ``analyzer.bind`` before the (C1) check. Without bounds,
structurally-OK forms like ``(lane // 8) * 8 + (lane % 8) * Q`` where
``lane < 32`` make ``(... // (C·2^bj)) % 2 == 0`` unprovable — the
bit is in fact always 0 but the analyzer can't conclude it universally.
Pass ``{lane_ph: Range(0, 32), warp_ph: Range(0, n_warps)}`` (or the
scope's equivalents) to let the (C1) check fire on these templates.
"""
# swizzle_inner=False swaps the inner/outer xor direction — Cases 1.A
# and 1.C roles flip. Not derived/tested yet; reject for safety.
if not swizzle.swizzle_inner:
return None
p = swizzle.per_element
sw = swizzle.swizzle_len
at = swizzle.atom_len
C = 1 << p
# Pure Case 1.D threshold: stride a multiple of this means every chunk-bit
# the iter flips is at position >= at + sw (above the swizzle XOR region),
# so swizzle has no effect and the contribution is purely linear in the
# iter coord — no power-of-2 ext requirement.
pure_1d = 1 << (p + at + sw)
bit_positions: list[int] = []
iter_strides_elems: list[int] = []
outer_iters: list = []
for ext, stride in zip(iter_extents, iter_strides):
# Zero-stride iters degrade dq=0 → log2 undefined. Explicit guard.
if stride == 0 or stride % C != 0:
return None
if ext <= 0:
return None
if ext == 1:
# Trivial iter contributes nothing; skip without forcing pow2.
continue
if not _is_pow2(ext):
# Non-pow2 ext can only be handled by the linear path. That in turn
# requires the iter to be in pure Case 1.D (stride a multiple of
# the swizzle period) so the swizzle does not interact with the
# per-coord contribution.
if stride % pure_1d != 0:
return None
outer_iters.append(_LinearIter(ext=ext, stride=stride))
continue
# pow2 ext: binary split (existing path).
k = ext.bit_length() - 1 # log2(ext)
slot_start = len(bit_positions)
# Split into k binary iters; the outermost (within this split) carries
# the largest stride so that flat-index bit decomp matches our
# outer-iter list ordering.
for j in range(k - 1, -1, -1):
substride = stride * (1 << j)
dq = substride // C
# dq must be a single bit set (so this binary iter flips exactly
# one bit of the chunk index). _is_pow2 also rejects dq=0.
if not _is_pow2(dq):
return None
bj = dq.bit_length() - 1
# All bj >= 0 accepted; case branching happens in emit_init.
bit_positions.append(bj)
iter_strides_elems.append(substride)
outer_iters.append(_BitIter(ext=ext, n_bits=k, slot_start=slot_start))
# Distinctness: two binary iters at the same bj collapse to bj+1, whose
# case behavior may differ from bj. See module docstring NB.
if len(set(bit_positions)) != len(bit_positions):
return None
bj_set = set(bit_positions)
# (C2) support disjointness: the only possible collision is between a
# Case-1.A iter at bj_A and a Case-1.C iter at bj_A + at. Checking the
# 1.C direction alone is symmetric and complete.
for bj in bj_set:
if at <= bj < at + sw and (bj - at) in bj_set:
return None # inner-outer pair collision
# (C1) per-iter no-carry on q(M0). Must hold *symbolically over all*
# free lane / warp placeholders in s_off_template — ``can_prove_equal``
# returns False if the analyzer can't discharge the equality
# universally, conservatively forcing a fallback.
analyzer = arith.Analyzer()
if var_bounds:
for var, rng in var_bounds.items():
analyzer.bind(var, rng)
for bj in bj_set:
divisor = C * (1 << bj)
check = tvm.tirx.floormod(
tvm.tirx.floordiv(s_off_template, _IntImm("int32", divisor)),
_IntImm("int32", 2),
)
if not analyzer.can_prove_equal(check, _IntImm("int32", 0)):
return None
return SwizzlePattern(
swizzle=swizzle,
bit_positions=bit_positions,
iter_strides_elems=iter_strides_elems,
outer_iters=outer_iters,
)
def emit_init(pattern: SwizzlePattern, s_off_resolved):
"""Emit at thread setup (call from inside the @T.prim_func body):
1. ``base_off = swizzle.apply(s_off_resolved)`` — runtime, per-thread,
computed once.
2. ``signed_strides[j]`` for each binary iter j, written into a local
buffer using the sigma formula above.
Returns ``(signed_strides_buffer_or_None, base_off_primexpr)``. The
buffer is ``None`` when ``pattern.n_binary_iters == 0`` (no outer
iter, no signed_strides needed).
``s_off_resolved`` is the per-thread offset with the real tid Var
substituted in (not the placeholder).
"""
swizzle = pattern.swizzle
p = swizzle.per_element
sw = swizzle.swizzle_len
at = swizzle.atom_len
C = 1 << p
base_off = swizzle.apply(s_off_resolved)["m"]
n = pattern.n_binary_iters
if n == 0:
return None, base_off
signed_strides = T.alloc_buffer([n], "int32", scope="local")
q = tvm.tirx.floordiv(s_off_resolved, C)
def _sigma_bit(bit_pos: int):
# 1 - 2 * bit_(bit_pos)(q); ∈ {+1, -1}.
row_bit = tvm.tirx.bitwise_and(
tvm.tirx.shift_right(q, _IntImm("int32", bit_pos)),
_IntImm("int32", 1),
)
return _IntImm("int32", 1) - row_bit * _IntImm("int32", 2)
for j, (bj, stride) in enumerate(zip(pattern.bit_positions, pattern.iter_strides_elems)):
stride_pow = stride # = 2^(bj + p) elements
if 0 <= bj < sw:
# Case 1.A (inner): signed_stride = sigma_(at + bj) · T.
value = _sigma_bit(at + bj) * _IntImm("int32", stride_pow)
elif sw <= bj < at:
# Case 1.B (mid): signed_stride = +T.
value = _IntImm("int32", stride_pow)
elif at <= bj < at + sw:
# Case 1.C (outer): signed_stride = T + sigma_(bj - at) · T_sec.
# Invariant: bj >= at, so T_sec = T >> at = 2^(bj - at + p)
# = T(bj - at) is well-defined (no underflow).
stride_sec = stride_pow >> at
value = _IntImm("int32", stride_pow) + _sigma_bit(bj - at) * _IntImm(
"int32", stride_sec
)
else: # bj >= at + sw, Case 1.D (above)
# No swizzle effect at this bit; signed_stride = +T.
value = _IntImm("int32", stride_pow)
# NB: Buffer.__setitem__ syntax (``signed_strides[j] = value``) is
# intercepted by the TIRx script parser but not by raw Python when
# this function is called from outside an @T.inline body. Use the
# low-level buffer_store builder instead.
T.buffer_store(signed_strides, value, [_IntImm("int32", j)])
return signed_strides, base_off
def emit_iter_offset(pattern: SwizzlePattern, signed_strides, base_off, k):
"""Compute the per-mm physical S offset = ``base_off`` + sum of per-iter
contributions.
``k`` is the flat outer iter index ∈ ``[0, prod(it.ext for it in outer_iters))``.
Decomposed innermost-first across ``pattern.outer_iters`` into per-iter
coords ``c_i``. Each iter contributes:
* ``_BitIter``: ``sum_b bit_(n_bits-1-b)(c_i) * signed_strides[slot_start + b]``,
i.e. each binary bit of ``c_i`` selects its precomputed sigma-stride.
The slot order (outermost-first within the iter) means the highest
bit of ``c_i`` indexes the slot at ``slot_start``.
* ``_LinearIter``: ``c_i * stride`` (no bit decomposition; used when
``stride`` is a multiple of ``2^(p + at + sw)`` so swizzle has no
XOR effect and ``ext`` need not be pow2).
Two paths per iter:
* Python int ``k`` — coords and bits known at parse time; emits only
the necessary adds, no runtime shift/mask.
* TIRx Var ``k`` — emits floormod/floordiv + bit-and/shift; relies on
downstream unroll + constant-fold.
"""
if not pattern.outer_iters:
return base_off
off = base_off
remaining = k
is_const = isinstance(k, int)
for it in reversed(pattern.outer_iters): # innermost first
ext = it.ext
if is_const:
c = remaining % ext
remaining = remaining // ext
else:
c = tvm.tirx.floormod(remaining, _IntImm("int32", ext))
remaining = tvm.tirx.floordiv(remaining, _IntImm("int32", ext))
if isinstance(it, _LinearIter):
if is_const:
if c != 0:
off = off + c * it.stride
else:
off = off + c * _IntImm("int32", it.stride)
continue
# _BitIter
for b in range(it.n_bits):
bit_pos = it.n_bits - 1 - b
slot = it.slot_start + b
if is_const:
if (c >> bit_pos) & 1:
off = off + signed_strides[slot]
else:
bit = tvm.tirx.bitwise_and(
tvm.tirx.shift_right(c, _IntImm("int32", bit_pos)),
_IntImm("int32", 1),
)
off = off + bit * signed_strides[slot]
return off
def emit_fallback_offset(swizzle: SwizzleLayout, s_off_resolved, ds_k):
"""Slow but always-correct path: full ``swizzle.apply(s_off + ds_k)``
per iter. Use when ``try_recognize`` returns ``None``.
``ds_k`` is the outer-iter delta for unrolled iter k — typically a
Expr (a function of the unroll var that simplifies to a constant
after unrolling) or a Python int. ``s_off_resolved`` is the per-thread
base linear offset with the real tid Var substituted.
"""
return swizzle.apply(s_off_resolved + ds_k)["m"]
@@ -0,0 +1,116 @@
# 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.
"""Scalar single-thread copy fallback (priority=0)."""
import warnings
import tvm
from tvm.script import tirx as T
from tvm.tirx import Buffer, PrimFunc
from tvm.tirx.operator.tile_primitive.dispatcher import (
predicate,
register_dispatch,
)
from tvm.tirx.operator.tile_primitive.registry import DispatchContext
from tvm.tirx.stmt import TilePrimitiveCall
from ._common import _TID_AXIS_FOR_SCOPE
from .reg import _axis_decl
from .utils import _is_valid_copy
def _region_st_extent(buffer_region):
region = buffer_region.region
return [r.min for r in region], [r.extent for r in region]
def _emit_fallback(op_call: TilePrimitiveCall, sctx: DispatchContext) -> PrimFunc:
op_call = TilePrimitiveCall.downcast(op_call)
src: Buffer = op_call.src.buffer
dst: Buffer = op_call.dst.buffer
src_st, src_extent = _region_st_extent(op_call.src)
dst_st, dst_extent = _region_st_extent(op_call.dst)
warnings.warn(
f"copy/fallback (scalar single-thread) picked for {src.scope()} -> "
f"{dst.scope()} at scope_kind={sctx.scope_kind}; all faster variants "
f"rejected.",
stacklevel=2,
)
def _copy_body(dst_buf, src_buf):
dst_indices = [i for i in range(len(dst_buf.shape)) if dst_extent[i] != 1]
src_indices = [i for i in range(len(src_buf.shape)) if src_extent[i] != 1]
assert len(dst_indices) == len(src_indices)
copy_extents = [dst_extent[i] for i in dst_indices]
def _dst_coord(lvs):
if isinstance(lvs, tvm.tirx.Var):
lvs = [lvs]
coord = list(dst_st)
for k, lv in enumerate(lvs):
coord[dst_indices[k]] += lv
return coord
def _src_coord(lvs):
if isinstance(lvs, tvm.tirx.Var):
lvs = [lvs]
coord = list(src_st)
for k, lv in enumerate(lvs):
coord[src_indices[k]] += lv
return coord
with T.grid(*copy_extents) as lvs:
T.buffer_store(dst_buf, src_buf[tuple(_src_coord(lvs))], _dst_coord(lvs))
scope_kind = sctx.scope_kind
if scope_kind == "thread":
@T.prim_func(check_well_formed=False)
def impl():
_copy_body(dst, src)
return impl
tid_axis_name = _TID_AXIS_FOR_SCOPE[scope_kind]
# first-active tid = composition of per-axis offsets (radix-32, since a warp is 32 lanes)
first_tid = int(sctx.intra["laneid"][1])
if scope_kind == "warpgroup":
first_tid += 32 * int(sctx.intra["wid_in_wg"][1])
elif scope_kind == "cta":
first_tid += 32 * int(sctx.intra["warpid"][1])
@T.prim_func(check_well_formed=False)
def impl():
tid = _axis_decl(tid_axis_name, sctx)
if tid == first_tid:
_copy_body(dst, src)
return impl
@register_dispatch(
"copy",
"cuda",
variant="fallback",
priority=0,
when=[predicate("validate_copy_op", _is_valid_copy)],
)
def copy_schedule_fallback(op_call: TilePrimitiveCall, sctx: DispatchContext) -> PrimFunc:
return _emit_fallback(op_call, sctx)
@@ -0,0 +1,328 @@
# 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.
"""Copy dispatch for ``global ↔ shared`` (no register side).
There's no per-thread register side to inherit a partition from — both sides
are cross-thread storage. The partition is synthesized from the surrounding
scope context (warp / warpgroup / cta / thread): ``thread_cnt`` is derived
from ``sctx.intra`` and each thread takes ``n_elements / thread_cnt``
consecutive fused-index slots. Layout / partition algorithm lives in
``_common.py`` and is shared with ``ldgsts.py``.
"""
import tvm
from tvm.runtime import DataType
from tvm.script import tirx as T
from tvm.tirx import Buffer, PrimFunc
from tvm.tirx import Var as _TirVar
from tvm.tirx.expr import IntImm as _IntImm
from tvm.tirx.operator.tile_primitive.dispatcher import (
predicate,
register_dispatch,
)
from tvm.tirx.operator.tile_primitive.registry import DispatchContext
from tvm.tirx.stmt import TilePrimitiveCall
from ._common import (
_TID_AXIS_FOR_SCOPE,
_thread_cnt,
align_layouts_gs,
copy_ptx_form,
copy_ptx_ld_return_type,
)
from ._swizzle_iter import (
emit_init,
emit_iter_offset,
get_swizzle,
try_recognize,
)
from .reg import _all_threads_active, _axis_decl, _ptr_off
from .utils import _is_valid_copy, _scope_allowed
_GMEM_SMEM_PAIRS = [
("global", "shared*"),
("shared*", "global"),
]
def _divides_thread_cnt(
op_call: TilePrimitiveCall, sctx: DispatchContext
) -> tuple[bool, str | None]:
"""Reject copies whose region element count does not divide ``thread_cnt``.
Without this guard the emit's ``[outer, T, vec]`` partition has no
integer solution: either every thread gets fractional work, or
``thread_cnt=0`` (degenerate scope) hits a modulo-by-zero. Both cases
indicate a poorly-shaped copy (e.g. 1024-thread CTA writing a 64-elem
tail) that this dispatch refuses to paper over with a slow scalar emit.
"""
op_call = TilePrimitiveCall.downcast(op_call)
thread_cnt = _thread_cnt(sctx)
if thread_cnt <= 0:
return False, f"degenerate thread_cnt={thread_cnt} (scope has empty intra)"
g_br = op_call.src if op_call.src.buffer.scope() == "global" else op_call.dst
n_elements = 1
for r in g_br.region:
ext = r.extent
try:
n_elements *= int(ext)
except (TypeError, ValueError):
return False, f"non-constant region extent {ext}"
if n_elements % thread_cnt != 0:
return False, (f"region size {n_elements} not divisible by thread_cnt={thread_cnt}")
return True, None
def _is_gmem_smem(op_call: TilePrimitiveCall, sctx: DispatchContext) -> tuple[bool, str | None]:
if not sctx.is_target("cuda"):
return False, "non-cuda target"
if sctx.scope_kind not in ("thread", "warp", "warpgroup", "cta"):
return False, f"unsupported exec_scope {sctx.scope_kind}"
for check in (
lambda: _all_threads_active(sctx),
lambda: _is_valid_copy(op_call, sctx),
lambda: _scope_allowed(op_call, sctx, allowed_pairs=_GMEM_SMEM_PAIRS),
lambda: _divides_thread_cnt(op_call, sctx),
):
ok, msg = check()
if not ok:
return False, msg
return True, None
def _emit_gmem_smem(op_call: TilePrimitiveCall, sctx: DispatchContext) -> PrimFunc:
op_call = TilePrimitiveCall.downcast(op_call)
src: Buffer = op_call.src.buffer
dst: Buffer = op_call.dst.buffer
if src.scope() == "global":
g_buf, g_br, s_buf, s_br = src, op_call.src, dst, op_call.dst
g_is_src = True
else:
g_buf, g_br, s_buf, s_br = dst, op_call.dst, src, op_call.src
g_is_src = False
g_region = [(r.min, r.min + r.extent) for r in g_br.region]
s_region = [(r.min, r.min + r.extent) for r in s_br.region]
elem_bits = DataType(src.dtype).bits
thread_cnt = _thread_cnt(sctx)
with sctx.target:
g_p, s_p, vec_len = align_layouts_gs(
g_buf.layout,
g_buf.shape,
g_region,
s_buf.layout,
s_buf.shape,
s_region,
elem_bits,
thread_cnt,
)
# vec_len=1 is the scalar fallback — uses the same unified
# [outer x thread x vec] coord scheme below.
vec_bits = vec_len * elem_bits
num_bytes = vec_bits // 8
vec, ptx_type = copy_ptx_form(num_bytes)
# Partition guarantees ``prod(s_p.shard.extents) == prod(g_p.shard.extents)
# == n_elements`` (the total transfer count). Express the per-thread
# per-round address as a 3D coord ``(f, tid, 0)`` against shape
# ``[total_outer, thread_cnt, vec_len]``, and let ``layout.apply`` flatten
# it through whatever multi-iter T / outer-iter structure ``align_layouts_gs``
# picked. This makes the emit oblivious to how many iters the partition
# split T or outer across.
n_elements = 1
for it in s_p.shard:
n_elements *= int(it.extent)
assert n_elements % (thread_cnt * vec_len) == 0, (
f"partition produced {n_elements} elements but thread_cnt({thread_cnt}) * "
f"vec_len({vec_len}) = {thread_cnt * vec_len} doesn't divide it"
)
total_outer = n_elements // (thread_cnt * vec_len)
apply_shape = [
_IntImm("int32", total_outer),
_IntImm("int32", thread_cnt),
_IntImm("int32", vec_len),
]
s_zero = [0] * len(s_buf.shape)
g_zero = [0] * len(g_buf.shape)
tid_axis_name = _TID_AXIS_FOR_SCOPE[sctx.scope_kind] if thread_cnt > 1 else None
# Walk shard from the vec iter backward to find the prefix that covers
# the T region exactly (∏ext == thread_cnt). The iters consumed are T
# iters; the leading prefix is the outer iter list — handed to
# ``try_recognize`` so the swizzle fast path can decide whether the
# outer iter strides match a pattern it can lower to signed_strides.
if thread_cnt > 1:
acc, _i = 1, len(s_p.shard) - 2
while _i >= 0 and acc < thread_cnt:
_ext = int(s_p.shard[_i].extent)
if acc * _ext > thread_cnt:
break
acc *= _ext
_i -= 1
outer_iters_s = list(s_p.shard[: _i + 1]) if acc == thread_cnt else []
else:
outer_iters_s = list(s_p.shard[:-1])
# SwizzleLayout on s_buf: try the closed-form signed-strides pattern
# (precomputed once per thread, then per-iter is a sum of register
# adds); fall back to per-iter ``swizzle.apply`` (one full XOR +
# decompose per iter). Closure picked at parse time so the TIRx parser
# doesn't AST-evaluate a "dead" ternary branch.
swizzle = get_swizzle(s_buf.layout)
swizzle_pattern = None
if swizzle is not None and outer_iters_s:
if tid_axis_name is not None:
_tid_placeholder = _TirVar(tid_axis_name, "int32")
else:
_tid_placeholder = _IntImm("int32", 0)
s_off_template = s_p.apply(
_IntImm("int32", 0),
_tid_placeholder,
_IntImm("int32", 0),
shape=apply_shape,
)["m"]
# Bind the tid placeholder's range so the (C1) analyzer check can
# discharge ``bit_bj(s_off // C) == 0`` for high bj's. Outer iter
# stride here is ``thread_cnt * vec_len`` ⇒ bj ∈ [log2(thread_cnt),
# ...]; without bounds the analyzer can't prove the lane's high bits
# are 0 and rejects.
var_bounds = {}
if tid_axis_name is not None:
var_bounds[_tid_placeholder] = tvm.ir.Range.from_min_extent(0, thread_cnt)
swizzle_pattern = try_recognize(
swizzle,
[int(it.extent) for it in outer_iters_s],
[int(it.stride) for it in outer_iters_s],
s_off_template,
var_bounds=var_bounds or None,
)
class _SwizzleState:
def __init__(self):
self.signed_strides = None
self.base_off = None
state = _SwizzleState()
def _decl_tid():
if tid_axis_name is not None:
return _axis_decl(tid_axis_name, sctx)
return _IntImm("int32", 0)
def _setup_swizzle(tid):
if swizzle_pattern is None:
return
s_off_resolved = s_p.apply(
_IntImm("int32", 0),
tid,
_IntImm("int32", 0),
shape=apply_shape,
)["m"]
state.signed_strides, state.base_off = emit_init(
swizzle_pattern,
s_off_resolved,
)
if swizzle_pattern is not None:
def _s_off(f, s_lin):
return emit_iter_offset(
swizzle_pattern,
state.signed_strides,
state.base_off,
f,
)
elif swizzle is not None:
_sw = swizzle
def _s_off(f, s_lin):
return _sw.apply(s_lin)["m"]
else:
def _s_off(f, s_lin):
return s_lin
v0 = _IntImm("int32", 0)
# fmt: off
@T.prim_func(check_well_formed=False)
def impl():
tid = _decl_tid()
_setup_swizzle(tid)
tmp = T.alloc_local((vec_len,), src.dtype)
tmp_ptr = tmp.ptr_to([0])
# NB: pass typed ptr_to(...) directly to _ptr_off; caching in a
# local var turns it into void* + offset = byte arithmetic →
# misaligned vector ops.
#
# Use a serial TIR loop and let ptxas unroll downstream. Mirrors
# the reg.py rationale in commit ac7ecf70f0: explicit ``T.unroll``
# materializes the per-iter scratch (s_lin/g_lin/s_off/s_ptr/g_ptr)
# as N copies of each ``alignas(64)`` declaration. For large
# ``total_outer`` (e.g. thread-scope fp32 swizzled copies of 32x256
# at vec=4 ⇒ 2048 iters; ldgsts test4 ⇒ ~4k iters once both
# g2s/s2g sites add up) this floods the kernel and nvcc times out.
for f in range(total_outer):
s_lin = s_p.apply(f, tid, v0, shape=apply_shape)["m"]
g_lin = g_p.apply(f, tid, v0, shape=apply_shape)["m"]
s_off = _s_off(f, s_lin)
s_ptr = _ptr_off(s_buf.ptr_to(s_zero), s_off)
g_ptr = _ptr_off(g_buf.ptr_to(g_zero), g_lin)
if g_is_src:
T.ptx.ld(
g_ptr,
copy_ptx_ld_return_type(ptx_type),
ptx_type,
dst=tmp_ptr,
space="global",
vec=vec,
)
T.ptx.st(
s_ptr, src=tmp_ptr, space="shared", vec=vec, ptx_type=ptx_type
)
else:
T.ptx.ld(
s_ptr,
copy_ptx_ld_return_type(ptx_type),
ptx_type,
dst=tmp_ptr,
space="shared",
vec=vec,
)
T.ptx.st(
g_ptr, src=tmp_ptr, space="global", vec=vec, ptx_type=ptx_type
)
# fmt: on
return impl
@register_dispatch(
"copy",
"cuda",
variant="gmem_smem",
priority=10,
when=[predicate("gmem_smem_applicable", _is_gmem_smem)],
)
def copy_schedule_gmem_smem(op_call: TilePrimitiveCall, sctx: DispatchContext) -> PrimFunc:
return _emit_gmem_smem(op_call, sctx)
@@ -0,0 +1,450 @@
# 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.
"""copy dispatch variant: ldmatrix / stmatrix (TBD algorithm).
Handles register ↔ shared copies on CUDA via PTX ``ldmatrix`` / ``stmatrix``.
Direction (ld vs st) and exec scope (warp / warpgroup) are decided inside
``_emit`` from the src/dst scopes and ``sctx.scope_kind``.
"""
from math import prod
import tvm
from tvm.script import tirx as T
from tvm.tirx import PrimFunc
from tvm.tirx import Var as _TirVar
from tvm.tirx.expr import IntImm as _IntImm
from tvm.tirx.layout import S, TileLayout
from tvm.tirx.operator.tile_primitive.dispatcher import fail, predicate, register_dispatch
from tvm.tirx.operator.tile_primitive.registry import DispatchContext
from tvm.tirx.stmt import TilePrimitiveCall
from ._common import ( # noqa: F401 (_carve_tail reserved for future variants)
_carve_tail,
_extract_tile,
)
from ._swizzle_iter import emit_init, emit_iter_offset, get_swizzle, try_recognize
from .reg import _all_threads_active, _ptr_off
from .utils import _is_valid_copy, _scope_allowed
_REG_SMEM_PAIRS = [
("local", "shared*"),
("shared*", "local"),
]
_VALID_R_LANE_AXES = {"laneid", "tid_in_wg", "tx"}
def _compute_r_perm(r):
"""Permutation: thread iters first (stride-desc), then memory iters (stride-desc)."""
def key(p):
it = p[1]
return (0 if it.axis.is_thread() else 1, -int(it.stride))
return [i for i, _ in sorted(enumerate(r.shard), key=key)]
def _is_ldstmatrix(op_call: TilePrimitiveCall, sctx: DispatchContext) -> tuple[bool, str | None]:
if not sctx.is_target("cuda"):
return False, "non-cuda target"
if sctx.scope_kind not in ("warp", "warpgroup", "cta"):
return False, f"unsupported exec_scope {sctx.scope_kind} (need warp, warpgroup, or cta)"
for check in (
lambda: _all_threads_active(sctx),
lambda: _is_valid_copy(op_call, sctx),
lambda: _scope_allowed(op_call, sctx, allowed_pairs=_REG_SMEM_PAIRS),
):
ok, msg = check()
if not ok:
return False, msg
return True, None
def _emit(op_call: TilePrimitiveCall, sctx: DispatchContext) -> PrimFunc:
op_call = TilePrimitiveCall.downcast(op_call)
# Step 1: identify reg / smem sides and pull their tensor shape + layout.
src_br = op_call.src
dst_br = op_call.dst
if src_br.buffer.scope() == "local":
r_br, s_br = src_br, dst_br
direction = "st" # reg -> smem (stmatrix)
else:
r_br, s_br = dst_br, src_br
direction = "ld" # smem -> reg (ldmatrix)
r_buf = r_br.buffer
s_buf = s_br.buffer
r_shape = list(r_buf.shape)
r_layout = r_buf.layout
s_shape = list(s_buf.shape)
s_layout = s_buf.layout
r_region = [(r.min, r.min + r.extent) for r in r_br.region]
s_region = [(r.min, r.min + r.extent) for r in s_br.region]
with sctx.target:
r_sliced = r_layout.slice(r_shape, r_region)
s_sliced = s_layout.slice(s_shape, s_region)
r = r_sliced.canonicalize()
s = s_sliced.canonicalize()
# Step 2.5: peel any S-side swizzle wrapper to expose the underlying
# TileLayout. The ComposeLayout doesn't have ``.replica`` / ``.shard``,
# so we must peel *before* the structural checks below. Capture the
# swizzle separately for use at emit time.
# NB: read swizzle from the *buffer* layout, not the post-canon ``s``.
# When the underlying tile is trivial, ``ComposeLayoutNode::Canonicalize``
# returns a bare ``SwizzleLayout``; isinstance(s, ComposeLayout) is then
# False and we'd miss the swizzle here.
s_swizzle = get_swizzle(s_buf.layout)
if s_swizzle is not None and s_swizzle.per_element < 3:
# ldmatrix/stmatrix .b16 reads/writes 8 fp16 = 128b per lane in one
# contiguous chunk. The swizzle preserves the lowest ``per_element``
# bits of the address (in-chunk offset). For the per-lane 128b unit
# to stay contiguous post-swizzle, ``2^per_element >= 8`` ⇒ p >= 3.
fail(
f"swizzle per_element={s_swizzle.per_element} < 3 incompatible "
f"with .b16 ldmatrix/stmatrix (need 8-fp16 chunk integrity)"
)
s = _extract_tile(s, s_region)
# Step 3: ldstmatrix doesn't broadcast — require zero replica on both sides.
if len(r.replica) != 0:
fail(f"R layout has replica {list(r.replica)}; ldstmatrix requires no replica")
if len(s.replica) != 0:
fail(f"S layout has replica {list(s.replica)}; ldstmatrix requires no replica")
# Step 4: R must have exactly one kind of lane axis from the valid set.
r_thread_axes = {it.axis.name for it in r.shard if it.axis.is_thread()}
if len(r_thread_axes) != 1:
fail(f"R must have exactly one thread axis name; got {sorted(r_thread_axes)}")
r_lane_axis = next(iter(r_thread_axes))
if r_lane_axis not in _VALID_R_LANE_AXES:
fail(f"R thread axis {r_lane_axis!r} not in {sorted(_VALID_R_LANE_AXES)}")
# Step 5: group S by R's iter extents (one S group per R iter, outer→inner).
r_group_shape = [int(it.extent) for it in r.shard]
s_grp, s_seps = s.group(r_group_shape)
# Step 6: permute R so thread iters come first (stride-desc), then memory
# iters (stride-desc).
r_perm = _compute_r_perm(r)
r = r.permute_dims(r_perm)
# Step 7: apply R's perm to S in group units (1-to-1 with R's iters), and
# rebuild s_seps to track group boundaries in the new order.
s = s_grp.permute_by_groups(list(s_seps), r_perm)
old_sizes = [s_seps[i + 1] - s_seps[i] for i in range(len(s_seps) - 1)]
s_seps = [0]
for pi in r_perm:
s_seps.append(s_seps[-1] + old_sizes[pi])
# Step 7.5: canonicalize both R and S after permute. Fuses adjacent
# contig iters — keeps step 8's group input clean. Push target so
# scope-aware fusers run (laneid+wid_in_wg → tid_in_wg, etc.).
with sctx.target:
r = r.canonicalize()
s = s.canonicalize()
t_total = prod(int(it.extent) for it in r.shard if it.axis.is_thread())
m_total = prod(int(it.extent) for it in r.shard if not it.axis.is_thread())
if t_total % 32 != 0:
fail(f"R thread section total {t_total} not divisible by 32")
def _strs(lay, seps):
# Atoms 8 / 4 / 2 (segs 1, 2, 5) must be single iters — their strides
# feed downstream stride checks (lane partition + fragment 2-fp16
# contig). The num atom (seg 4) may be MULTI-ITER: we return its iter
# list and let layout.apply handle the decomposition at emit time.
fixed_segs = [list(lay.shard[seps[i] : seps[i + 1]]) for i in (1, 2, 5)]
if not all(len(g) == 1 for g in fixed_segs):
return None
num_iters = list(lay.shard[seps[4] : seps[5]])
return (
int(fixed_segs[0][0].stride), # 8 atom stride
int(fixed_segs[1][0].stride), # 4 atom stride
num_iters, # num atom iter list (multi-iter OK)
int(fixed_segs[2][0].stride), # 2 atom stride
)
def _try_num(r_in, s_in, num):
"""Try grouping (r_in, s_in) with [T/32, 8, 4, M/(2num), num, 2].
Returns (rg, rsep, sg, ssep, trans, p, num) if structural checks pass,
else None. ``trans`` is the ldmatrix .trans flag; ``p`` is the
per-tile-row S stride used at emit.
"""
gs = [t_total // 32, 8, 4, m_total // (num * 2), num, 2]
try:
rg, rsep = r_in.group(gs)
sg, ssep = s_in.group(gs)
except Exception:
return None
# R seg 0 (T/32 outer): require single iter with stride 32. When
# T/32 == 1 the segment is trivial — skip.
if t_total > 32:
seg0 = list(rg.shard[rsep[0] : rsep[1]])
if len(seg0) != 1 or int(seg0[0].stride) != 32:
return None
rs, ss = _strs(rg, rsep), _strs(sg, ssep)
if rs is None or ss is None:
return None
r8, r4, _r_num_iters, r2 = rs
s8, s4, s_num_iters, s2 = ss
if (r8, r4, r2) != (4, 1, 1):
return None
# S num atom: every iter must have stride > 0 and multiple of 8 (the
# per-tile spacing geometry of ldmatrix m8n8; 8 fp16 = 16 bytes = one
# tile column dimension).
if num > 1 and not all(
int(it.stride) > 0 and int(it.stride) % 8 == 0 for it in s_num_iters
):
return None
# m_outer (seg 3) iters: each per-mm advance must keep the per-lane
# SMEM address 16-byte aligned (ldmatrix .b16 reads 8 fp16 = 16 bytes
# per lane), so the m_outer S-stride must also be a multiple of 8.
# Without this, mm > 0 iterations land at unaligned addresses and
# silently read garbage even though the layout group succeeds.
# Skip extent-1 trivial iters — they contribute no per-mm advance,
# so their (placeholder) stride is irrelevant.
m_outer_iters = list(sg.shard[ssep[3] : ssep[4]])
if not all(int(it.extent) == 1 or int(it.stride) % 8 == 0 for it in m_outer_iters):
return None
if (s4, s2) == (2, 1) and s8 > 0 and s8 % 8 == 0:
return (rg, rsep, sg, ssep, False, s8, num)
if s8 == 1 and s2 > 0 and s2 % 8 == 0 and s4 == 2 * s2:
return (rg, rsep, sg, ssep, True, s2, num)
return None
# Try the **sorted** variant: 5D-group, sub-group R's M/2 by S's M/2
# extents, sort the sub-groups by descending S-stride, rebuild. This
# makes the m_outer iter list carry the largest S-strides on top, which
# maximizes the §2 swizzle fast-path applicability later. If anything
# in the rebuild raises (e.g. M/2 can't be sub-grouped by S's extents),
# we silently fall back to the no-sort path below.
r_sort = s_sort = None
try:
gs5 = [t_total // 32, 8, 4, m_total // 2, 2]
rg5, rsep5 = r.group(gs5)
sg5, ssep5 = s.group(gs5)
r_m_iters = list(rg5.shard[rsep5[3] : rsep5[4]])
s_m_iters = list(sg5.shard[ssep5[3] : ssep5[4]])
s_m_extents = [int(it.extent) for it in s_m_iters]
# Sub-group R's M/2 iters by S's M/2 iter extents. This 1-to-1's
# the R sub-groups with the S iters so we can permute them together.
r_m_sub = TileLayout.from_iters(r_m_iters)
r_m_grouped, r_m_seps = r_m_sub.group(s_m_extents)
# Sort S iters by S-stride descending; permute R sub-groups in lockstep.
perm = sorted(range(len(s_m_iters)), key=lambda i: -int(s_m_iters[i].stride))
if perm != list(range(len(perm))):
r_m_permuted = r_m_grouped.permute_by_groups(list(r_m_seps), perm)
s_m_permuted = [s_m_iters[i] for i in perm]
r_sort = TileLayout.from_iters(
list(rg5.shard[: rsep5[3]])
+ list(r_m_permuted.shard)
+ list(rg5.shard[rsep5[4] :]),
offset=dict(rg5.offset),
)
s_sort = TileLayout.from_iters(
list(sg5.shard[: ssep5[3]]) + list(s_m_permuted) + list(sg5.shard[ssep5[4] :]),
offset=dict(sg5.offset),
)
# If perm is identity, sorted == unsorted; no need to build duplicate layouts.
except Exception:
r_sort = s_sort = None
# Enumerate num largest-first; for each num try sorted then unsorted.
chosen = None
for num in (4, 2, 1):
if m_total % (num * 2):
continue
if r_sort is not None:
res = _try_num(r_sort, s_sort, num)
if res is not None:
chosen = res
break
res = _try_num(r, s, num)
if res is not None:
chosen = res
break
if chosen is None:
fail("ldstmatrix layout doesn't fit any num ∈ {4,2,1}")
r, r_seps, s, s_seps, trans, p, num = chosen
# Step 10: emit one ldmatrix/stmatrix per mm, per warp.
def _get_warp_idx_in_T():
# T.warp_id_in_wg() / T.warp_id() must be called from inside a
# @T.prim_func body — wrap so the prim_func parser calls us at parse
# time (Python `if` here is plain control flow, not TIR-intercepted).
if r_lane_axis == "laneid":
return 0
if r_lane_axis == "tid_in_wg":
return T.warp_id_in_wg()
return T.warp_id() # "tx"
def _seg4_coord(laneid_expr):
# num=1: seg 4 trivially extent-1, pass 0. num>1: use lane//8 (tile
# index in ldmatrix lane convention); layout.apply decomposes through
# the seg's iter structure (single or multi-iter).
if num > 1:
return laneid_expr // 8
return 0
apply_shape = [t_total // 32, 8, 4, m_total // (num * 2), num, 2]
r_mem_axis = r.shard[r_seps[5]].axis.name
s_mem_axis = s.shard[s_seps[5]].axis.name
m_outer = m_total // (num * 2)
s_zero = [0] * len(s_buf.shape)
# Swizzle fast-path setup. When S is swizzled, the per-mm `tile_off +
# row_off` is a logical offset; the physical SMEM address is
# `swizzle.apply(logical)`. The slow path computes that per iter; the
# fast path (§2.E of the swizzle-iter plan) reduces it to
# `base_off + sum_j bit_j(mm) · signed_strides[j]` where base_off and
# signed_strides are per-thread constants set once. We try to
# recognize the m_outer iter list as such a pattern; if it fails (e.g.
# the analyzer can't discharge condition C1 over the lane/warp
# placeholders) we silently fall through to the slow path.
swizzle_pattern = None
s_off_template = None
lane_ph = warp_ph = None
if s_swizzle is not None:
m_outer_iters = list(s.shard[s_seps[3] : s_seps[4]])
iter_extents = [int(it.extent) for it in m_outer_iters]
iter_strides = [int(it.stride) for it in m_outer_iters]
# Build s_off at mm=0 with placeholder vars for lane and warp_idx.
lane_ph = _TirVar("lane_ph", "int32")
seg4_ph = (lane_ph // 8) if num > 1 else _IntImm("int32", 0)
if r_lane_axis == "laneid":
warp_ph_expr = _IntImm("int32", 0)
else:
warp_ph = _TirVar("warp_ph", "int32")
warp_ph_expr = warp_ph
s_off_template = s.apply(
warp_ph_expr,
_IntImm("int32", 0),
_IntImm("int32", 0),
_IntImm("int32", 0),
seg4_ph,
_IntImm("int32", 0),
shape=apply_shape,
)[s_mem_axis] + (lane_ph % 8) * _IntImm("int32", p)
# Bind lane / warp placeholder bounds for the (C1) analyzer. ``lane_ph``
# is the per-warp lane id ∈ [0, 32); ``warp_ph`` (when present) is the
# warp index inside the scope: warpgroup ⇒ [0, 4), cta ⇒ [0, t_total/32).
var_bounds = {lane_ph: tvm.ir.Range.from_min_extent(0, 32)}
if warp_ph is not None:
var_bounds[warp_ph] = tvm.ir.Range.from_min_extent(0, t_total // 32)
swizzle_pattern = try_recognize(
s_swizzle,
iter_extents,
iter_strides,
s_off_template,
var_bounds=var_bounds,
)
class _SwizzleState:
def __init__(self):
self.signed_strides = None
self.base_off = None
state = _SwizzleState()
def _resolve_s_off(laneid_var, warp_var):
# Build the placeholder→runtime-var map and substitute. Keep this in a
# regular Python helper — the @T.prim_func parser intercepts dict
# literals when written directly in the body.
vmap = {lane_ph: laneid_var}
if warp_ph is not None:
vmap[warp_ph] = warp_var
return tvm.tirx.stmt_functor.substitute(s_off_template, vmap)
def _setup_swizzle(s_off_resolved):
if swizzle_pattern is None:
return
state.signed_strides, state.base_off = emit_init(
swizzle_pattern,
s_off_resolved,
)
def _smem_off(mm_idx, logical_off):
# Three paths:
# * pattern matched: physical off = base_off + Σ bit_j(mm)·ss[j].
# * swizzle present, pattern missed: per-iter swizzle.apply(logical).
# * no swizzle: identity.
if swizzle_pattern is not None:
return emit_iter_offset(
swizzle_pattern,
state.signed_strides,
state.base_off,
mm_idx,
)
if s_swizzle is not None:
return s_swizzle.apply(logical_off)["m"]
return logical_off
# fmt: off
@T.prim_func(check_well_formed=False)
def impl():
r_local = r_buf.local(m_total, layout=TileLayout(S[(m_total,)]))
laneid = T.lane_id()
warp_idx_in_T = _get_warp_idx_in_T()
# Resolve s_off_template by substituting placeholders → actual
# scope-id vars (via _resolve_s_off helper to keep the dict literal
# out of the parser's view). Only the swizzle fast path needs this;
# without swizzle we keep using the per-iter s.apply directly.
if swizzle_pattern is not None:
_setup_swizzle(_resolve_s_off(laneid, warp_idx_in_T))
for mm in T.unroll(m_outer):
tile_off = s.apply(
warp_idx_in_T, 0, 0, mm, _seg4_coord(laneid), 0, shape=apply_shape,
)[s_mem_axis]
row_off = (laneid % 8) * p
logical_off = tile_off + row_off
smem_ptr = _ptr_off(s_buf.ptr_to(s_zero), _smem_off(mm, logical_off))
handles = [
r_local.ptr_to([
r.apply(0, 0, 0, mm, i, 0, shape=apply_shape)[r_mem_axis]
])
for i in range(num)
]
if direction == "ld":
T.ptx.ldmatrix(trans, num, ".b16", smem_ptr, *handles)
else:
T.ptx.stmatrix(
trans, num, ".b16", smem_ptr, *handles,
shape="m8n8", space="shared",
)
# fmt: on
return impl
@register_dispatch(
"copy",
"cuda",
variant="ldstmatrix",
priority=10,
when=[predicate("ldstmatrix_applicable", _is_ldstmatrix)],
)
def copy_schedule_ldstmatrix(op_call: TilePrimitiveCall, sctx: DispatchContext) -> PrimFunc:
return _emit(op_call, sctx)
__all__ = ["copy_schedule_ldstmatrix"]
@@ -0,0 +1,596 @@
# 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.
"""Non-ldmatrix copy dispatch for register ↔ memory.
This file owns every copy where one side is per-thread local (``R`` =
register). That R side carries the partition: its ``TileLayout`` ``shard``
has thread-axis iters telling us which thread owns which logical coordinate.
The other side (``S``) can be ``shared*`` or ``global`` — the algorithm is
identical either way.
Slice/canonicalize both sides, align via perm+group, then emit a per-thread
vectorized copy loop. Direction-symmetric: covers R2S / S2R / R2G / G2R.
"""
import tvm
from tvm.arith import Analyzer
from tvm.runtime import DataType
from tvm.script import tirx as T
from tvm.tirx import Buffer, PrimFunc
from tvm.tirx import Var as _TirVar
from tvm.tirx.expr import IntImm as _IntImm
from tvm.tirx.layout import ComposeLayout, S, SwizzleLayout, TileLayout
from tvm.tirx.operator.tile_primitive.dispatcher import predicate, register_dispatch
from tvm.tirx.operator.tile_primitive.registry import DispatchContext
from tvm.tirx.stmt import TilePrimitiveCall
from ._common import _alignment_ok, copy_ptx_form, copy_ptx_ld_return_type
from ._swizzle_iter import (
emit_fallback_offset,
emit_init,
emit_iter_offset,
get_swizzle,
try_recognize,
)
from .utils import _is_valid_copy, _scope_allowed
def _extract_tile(layout, region):
"""Strip swizzle off ``layout`` so we can perm/group it as a TileLayout.
``region`` is the per-axis ``(start, end)`` pair list — we only consume
its extents when ``layout`` is a bare ``SwizzleLayout`` (rebuilding a
trivial TileLayout for it). Plain ``TileLayout`` / ``ComposeLayout``
don't need the extent, so symbolic regions are fine for them.
"""
if isinstance(layout, ComposeLayout):
return layout.tile_layout
if isinstance(layout, SwizzleLayout):
# TODO: keep swizzle info around for later (addressing in emit).
extents = [int(end - start) for (start, end) in region]
return TileLayout(S[tuple(extents)])
return layout
_REG_PAIRS = [
("local", "shared*"),
("shared*", "local"),
("local", "global"),
("global", "local"),
]
_SCOPE_RANK = {"thread": 0, "warp": 1, "warpgroup": 2, "cta": 3}
_VALID_R_SUBSCOPES = {"thread", "warp", "warpgroup"}
def _all_threads_active(sctx: DispatchContext) -> tuple[bool, str | None]:
if sctx.scope_kind == "thread":
return True, None
required: dict[str, int] = {}
if sctx.scope_kind in ("warp", "warpgroup", "cta"):
required["laneid"] = 32
if sctx.scope_kind == "warpgroup":
required["wid_in_wg"] = 4
if sctx.scope_kind == "cta":
tx_iv = sctx.launch_params.get("threadIdx.x")
if tx_iv is None:
return False, "cta scope missing threadIdx.x launch_params"
try:
required["warpid"] = int(tx_iv.dom.extent) // 32
except (TypeError, ValueError):
return False, f"non-static threadIdx.x extent: {tx_iv.dom.extent}"
for axis_name, expected in required.items():
if axis_name not in sctx.intra:
return False, f"sctx.intra missing {axis_name!r}"
ext_raw, off_raw = sctx.intra[axis_name]
try:
ext, off = int(ext_raw), int(off_raw)
except (TypeError, ValueError):
return False, f"non-static range for {axis_name}: ({ext_raw}, {off_raw})"
if ext != expected or off != 0:
return False, f"{axis_name} narrowed to [{off}, {off + ext}) vs full [0, {expected})"
return True, None
def _r_side_layout_valid(
op_call: TilePrimitiveCall, sctx: DispatchContext
) -> tuple[bool, str | None]:
op_call = TilePrimitiveCall.downcast(op_call)
src: Buffer = op_call.src.buffer
dst: Buffer = op_call.dst.buffer
r_buf = src if src.scope() == "local" else dst
layout = r_buf.layout
if layout is None:
return False, "R has no layout"
if layout.is_swizzle():
return False, "R layout is swizzle"
if not isinstance(layout, TileLayout):
return False, f"R layout is {type(layout).__name__}, not TileLayout"
scope_rank = _SCOPE_RANK[sctx.scope_kind]
for it in layout.shard:
ax = it.axis
if not ax.is_thread():
continue
ax_scope = ax.get_scope()
ax_sub = ax.get_subscope()
if ax_scope is None or ax_sub is None:
return False, f"R thread axis {ax.name!r} missing scope/subscope"
if ax_sub.name not in _VALID_R_SUBSCOPES:
return False, f"R thread axis {ax.name!r} subscope={ax_sub.name!r} (not register-level)"
if ax_scope.name not in _SCOPE_RANK or _SCOPE_RANK[ax_scope.name] > scope_rank:
return (
False,
f"R thread axis {ax.name!r} scope={ax_scope.name!r} > exec {sctx.scope_kind!r}",
)
r_br = op_call.src if src.scope() == "local" else op_call.dst
region = [(r.min, r.min + r.extent) for r in r_br.region]
sliced = layout.slice(list(r_buf.shape), region)
if sliced is None:
return False, "R layout slice failed"
analyzer = Analyzer()
for axis, off in sliced.offset.items():
if axis.is_thread() and not analyzer.can_prove_equal(off, 0):
return False, f"R sliced offset on thread axis {axis.name!r} = {off}"
return True, None
def _s_side_slice_ok(op_call: TilePrimitiveCall) -> tuple[bool, str | None]:
"""S is the non-local side (shared* or global). Slice must succeed."""
op_call = TilePrimitiveCall.downcast(op_call)
src_br = op_call.src
dst_br = op_call.dst
s_br = dst_br if src_br.buffer.scope() == "local" else src_br
s_buf: Buffer = s_br.buffer
layout = s_buf.layout
if layout is None:
return False, "S has no layout"
region = [(r.min, r.min + r.extent) for r in s_br.region]
if layout.slice(list(s_buf.shape), region) is None:
return False, "S layout slice failed"
return True, None
def _is_reg_copy(op_call: TilePrimitiveCall, sctx: DispatchContext) -> tuple[bool, str | None]:
if not sctx.is_target("cuda"):
return False, "non-cuda target"
if sctx.scope_kind not in ("thread", "warp", "warpgroup", "cta"):
return False, f"unsupported exec_scope {sctx.scope_kind}"
for check in (
lambda: _all_threads_active(sctx),
lambda: _is_valid_copy(op_call, sctx),
lambda: _scope_allowed(op_call, sctx, allowed_pairs=_REG_PAIRS),
lambda: _r_side_layout_valid(op_call, sctx),
lambda: _s_side_slice_ok(op_call),
):
ok, msg = check()
if not ok:
return False, msg
return True, None
def _compute_perm_r(r):
# thread axes first, then by stride descending
def key(p):
it = p[1]
return (0 if it.axis.is_thread() else 1, -int(it.stride))
return [i for i, _ in sorted(enumerate(r.shard), key=key)]
def align_layouts_raw(r_sliced, s_sliced, s_region):
"""Returns (r_p, s_p, s_seps, r_perm).
``r_p`` for ``_s_thread_offset``; ``r_perm`` for ``_split_thread_loop`` pairing.
"""
r = r_sliced.canonicalize()
s = s_sliced.canonicalize()
s = _extract_tile(s, s_region)
perm = _compute_perm_r(r)
r_shape_for_group = [int(it.extent) for it in r.shard]
s_grp, seps = s.group(r_shape_for_group)
s_p = s_grp.permute_by_groups(list(seps), perm)
r_perm = r.permute_dims(perm)
r_p = r_perm.canonicalize()
sizes = [seps[i + 1] - seps[i] for i in range(len(seps) - 1)]
s_seps = [0]
for p in perm:
s_seps.append(s_seps[-1] + sizes[p])
return r_p, s_p, s_seps, r_perm
def _split_thread_loop(r_perm, s_p, s_seps):
"""Drop R's thread-axis positions and return per-R-position bundles:
(r_iters, s_groups) — same length lists; s_groups[k] is the list of S
iters belonging to the k-th kept R position.
``r_perm`` is the permuted-but-uncanonicalized R layout: its iter list lines
up one-to-one with ``s_seps`` (which is built from the same ``perm``), so a
multi-group memory axis keeps each of its groups paired with the matching S
group instead of collapsing into one (see ``align_layouts_raw``)."""
r_iters = []
s_groups = []
for k, r_it in enumerate(r_perm.shard):
if r_it.axis.is_thread():
continue
r_iters.append(r_it)
s_groups.append(list(s_p.shard[s_seps[k] : s_seps[k + 1]]))
return r_iters, s_groups
def _build_atoms(r_iters, s_groups):
"""One atom per (R position, intra-group S iter): (extent, s_stride, r_mul).
r_mul = R_stride_at_position * (product of S group extents to the right
of this intra-position index) — i.e. how much R address advances per unit
of this iter's loop input."""
atoms = []
for r_it, s_group in zip(r_iters, s_groups, strict=True):
rs = int(r_it.stride)
extents = [int(it.extent) for it in s_group]
for j, s_it in enumerate(s_group):
inner_prod = 1
for e in extents[j + 1 :]:
inner_prod *= e
atoms.append((int(s_it.extent), int(s_it.stride), rs * inner_prod))
return atoms
def _atoms_contiguous_tail_extent(atoms) -> int:
"""Like _contiguous_tail_extent but on atoms (uses s_stride for chaining)."""
if not atoms or atoms[-1][1] != 1:
return 0
acc = atoms[-1][0]
for k in range(len(atoms) - 2, -1, -1):
if atoms[k][1] == acc:
acc *= atoms[k][0]
else:
break
return acc
def _split_atoms_for_vec(atoms, vec_len):
"""Returns outer atoms (the inner vec_len-element tail is consumed by one
vec ld/st and dropped). Splits the boundary atom if needed."""
outer = list(atoms)
acc = 1
while outer:
ext, ss, rm = outer[-1]
new_acc = acc * ext
if new_acc == vec_len:
outer.pop()
return outer
if new_acc > vec_len:
inner_factor = vec_len // acc
outer[-1] = (ext // inner_factor, ss * inner_factor, rm * inner_factor)
return outer
acc = new_acc
outer.pop()
raise ValueError(f"tail too short for vec_len {vec_len}")
def _align_layouts(op_call: TilePrimitiveCall, sctx: DispatchContext):
op_call = TilePrimitiveCall.downcast(op_call)
src_br = op_call.src
dst_br = op_call.dst
if src_br.buffer.scope() == "local":
r_br, s_br = src_br, dst_br
else:
r_br, s_br = dst_br, src_br
r_buf = r_br.buffer
s_buf = s_br.buffer
r_region = [(r.min, r.min + r.extent) for r in r_br.region]
s_region = [(r.min, r.min + r.extent) for r in s_br.region]
with sctx.target:
r_sliced = r_buf.layout.slice(list(r_buf.shape), r_region)
s_sliced = s_buf.layout.slice(list(s_buf.shape), s_region)
return align_layouts_raw(r_sliced, s_sliced, s_region)
def _make_thread_placeholders(r_p) -> dict[str, _TirVar]:
placeholders: dict[str, _TirVar] = {}
for it in r_p.shard:
name = it.axis.name
if it.axis.is_thread() and name not in placeholders:
placeholders[name] = _TirVar(name, "int32")
return placeholders
def _s_thread_offset(r_p, s_p, placeholders: dict[str, _TirVar]):
"""Per-thread S base offset. Coord per R position is placeholder (thread
axis) or 0 (memory axis); apply_to_shape decomposes across s_p iters.
Includes layout-level offsets (e.g. from slicing a non-zero S region)."""
coord = [
placeholders[it.axis.name] if it.axis.is_thread() else _IntImm("int32", 0)
for it in r_p.shard
]
input_shape = [int(it.extent) for it in r_p.shard]
per_iter = s_p.apply_to_shape(coord, input_shape)
off = _IntImm("int32", 0)
for c, it in zip(per_iter, s_p.shard, strict=True):
off = off + c * it.stride
for _axis, val in s_p.offset.items():
off = off + val
return off
_VEC_BITS_CANDIDATES = (128, 64, 32, 16, 8)
def _vec_len_candidates(elem_bits: int) -> list[int]:
"""Widest-first element counts to try; always ends with scalar (1)."""
out: list[int] = []
for vb in _VEC_BITS_CANDIDATES:
if vb < elem_bits or vb % elem_bits != 0:
continue
n = vb // elem_bits
if n not in out:
out.append(n)
if 1 not in out:
out.append(1)
return out
def _choose_vec_len(elem_bits: int, atoms, r_p, s_p) -> int:
"""Widest candidate that:
1. divides the atom contiguous-tail extent (so vec_len consecutive
R-side regs map to vec_len contiguous S-side elements), AND
2. keeps every per-thread / per-round address-offset term a
multiple of vec_len, so the resulting vec ld/st pointer is
naturally aligned to vec_bits/8 bytes.
Only **mem-axis** strides contribute to physical address. Thread-axis
iter strides live in partition-coord space (which thread owns which
logical position), not in the buffer's storage space — they're
redistributed through ``apply_to_shape`` into the mem iters and don't
appear directly in the per-thread address. So neither r-side nor
s-side thread-axis strides belong in the alignment check.
The contig-tail atoms (whose extents the vec ld/st consumes) have
stride 1 by definition; they live entirely inside the vec and
contribute nothing to the per-round address delta. Only the
**post-vec-split** outer atom strides matter for the per-round delta.
"""
t = _atoms_contiguous_tail_extent(atoms)
# Region-base offsets are real address contributions. Thread-iter
# strides on either side are partition-virtual, not storage-physical,
# so they don't enter the per-thread address — exclude them.
shared_terms = list(s_p.offset.values()) + list(r_p.offset.values())
for n in _vec_len_candidates(elem_bits):
if n == 1:
return n
if t % n != 0 or t < n:
continue
# Post-vec-split outer atoms: these are the strides that contribute
# to per-round address deltas after the vec consumes the inner tail.
outer = _split_atoms_for_vec(atoms, n)
outer_atom_terms = [a[1] for a in outer] + [a[2] for a in outer]
if not _alignment_ok(n, outer_atom_terms + shared_terms):
continue
return n
return 1
def _axis_decl(axis_name: str, sctx: DispatchContext):
"""Declare the runtime Var for one thread axis (called inside impl body).
Each scope_id declarator emits a ``ScopeIdDef`` stmt at the current
builder frame. ``TilePrimitiveDispatch`` re-gathers + resolves all
ScopeIdDefs after dispatch (see ``ResolveAllScopeBinds`` in
``tile_primitive_dispatch.cc``), so dispatch-introduced vars are bound
alongside kernel-declared ones.
Extents are deferred: the kernel header is expected to declare the full
scope-id chain (``cta_id`` / ``warpgroup_id`` / ``warp_id_in_wg`` /
``lane_id`` / ``thread_id`` / ``thread_id_in_wg``) — the verifier then
fills our deferred defs from those siblings.
"""
if axis_name == "tx":
return sctx.launch_params["threadIdx.x"].var
if axis_name == "laneid":
return T.lane_id()
if axis_name == "wid_in_wg":
return T.warp_id_in_wg()
if axis_name == "tid_in_wg":
return T.thread_id_in_wg()
if axis_name == "warpid":
return T.warp_id()
if axis_name == "wgid":
return T.warpgroup_id()
raise ValueError(f"unsupported thread axis {axis_name}")
def _s_thread_offset_with_vars(r_p, s_p, axis_var_map: dict):
coord = [
axis_var_map[it.axis.name] if it.axis.is_thread() else _IntImm("int32", 0)
for it in r_p.shard
]
input_shape = [int(it.extent) for it in r_p.shard]
per_iter = s_p.apply_to_shape(coord, input_shape)
off = _IntImm("int32", 0)
for c, it in zip(per_iter, s_p.shard, strict=True):
off = off + c * it.stride
for _ax, val in s_p.offset.items():
off = off + val
return off
def _substitute_axes(s_off_template, placeholders: dict[str, _TirVar], sctx: DispatchContext):
"""Inside an impl body: declare real scope_ids and rewrite the
placeholder-built ``s_off_template`` to use them."""
vmap = {placeholders[name]: _axis_decl(name, sctx) for name in placeholders}
return tvm.tirx.stmt_functor.substitute(s_off_template, vmap)
def _flat_coords(outer_atoms, flat_idx: int) -> list[int]:
coords = []
rem = flat_idx
for a in reversed(outer_atoms):
coords.append(rem % a[0])
rem //= a[0]
coords.reverse()
return coords
_POINTER_OFFSET_SRC = (
"\ntemplate <typename T>\n"
"__forceinline__ __device__ T* tvm_builtin_pointer_offset(T* ptr, int offset) {\n"
" return ptr + offset;\n"
"}\n"
)
def _ptr_off(base_ptr, off):
return T.cuda.func_call(
"tvm_builtin_pointer_offset",
base_ptr,
off,
source_code=_POINTER_OFFSET_SRC,
return_type=base_ptr.ty,
)
def _outer_const_offsets(outer_atoms, flat_idx: int) -> tuple[int, int]:
"""Returns (s_offset_const, r_offset_const) for one outer-loop flat index."""
coords = _flat_coords(outer_atoms, flat_idx)
ds = sum(c * a[1] for c, a in zip(coords, outer_atoms))
dr = sum(c * a[2] for c, a in zip(coords, outer_atoms))
return ds, dr
def _emit_reg(op_call: TilePrimitiveCall, sctx: DispatchContext) -> PrimFunc:
op_call = TilePrimitiveCall.downcast(op_call)
src: Buffer = op_call.src.buffer
dst: Buffer = op_call.dst.buffer
if src.scope() == "local":
r_buf, s_buf, r_is_src = src, dst, True
else:
r_buf, s_buf, r_is_src = dst, src, False
with sctx.target:
r_p, s_p, s_seps, r_perm = _align_layouts(op_call, sctx)
r_iters, s_groups = _split_thread_loop(r_perm, s_p, s_seps)
atoms = _build_atoms(r_iters, s_groups)
elem_bits = DataType(src.dtype).bits
vec_len = _choose_vec_len(elem_bits, atoms, r_p, s_p)
vec_bits = vec_len * elem_bits
outer = _split_atoms_for_vec(atoms, vec_len)
per_thread_r_total = 1
for it in r_iters:
per_thread_r_total *= int(it.extent)
per_thread_r_shape = [per_thread_r_total or 1]
# Build the per-thread S offset OUTSIDE the impl using placeholder Vars
# (one per thread axis). Inside the impl we'll declare the real scope_ids
# via T.lane_id/T.thread_id_in_wg/... and substitute them in.
placeholders = _make_thread_placeholders(r_p)
s_off_template = _s_thread_offset(r_p, s_p, placeholders)
# R-side base offset from slicing (e.g. ``R[i*8:i*8+8]`` ⇒ ``i*8``). The
# canonicalize() result lives in ``r_p.offset``; sum across axes (memory
# or thread — irrelevant once it's all on R's local stride-1 storage).
r_off_base = _IntImm("int32", 0)
for _ax, val in r_p.offset.items():
r_off_base = r_off_base + val
s_is_shared = s_buf.scope().startswith("shared")
num_bytes = vec_bits // 8
vec, ptx_type = copy_ptx_form(num_bytes)
space = "shared" if s_is_shared else "global"
total_outer = 1
for a in outer:
total_outer *= a[0]
# Swizzle handling: recognize the iter-pattern on S side from the atom
# extents/strides (atom = (extent, s_stride, r_mul); a[1] is the S-side
# stride per outer round, equivalent to outer_iter strides in gmem_smem).
swizzle = get_swizzle(s_buf.layout)
swizzle_pattern = None
if swizzle is not None:
swizzle_pattern = try_recognize(
swizzle,
[a[0] for a in outer],
[a[1] for a in outer],
s_off_template,
)
class _SwizzleState:
def __init__(self):
self.signed_strides = None
self.base_off = None
state = _SwizzleState()
def _setup_swizzle(s_off):
if swizzle_pattern is None:
return
state.signed_strides, state.base_off = emit_init(swizzle_pattern, s_off)
def _s_iter_off(f, ds, s_off):
if swizzle_pattern is not None:
return emit_iter_offset(swizzle_pattern, state.signed_strides, state.base_off, f)
if swizzle is not None:
return emit_fallback_offset(swizzle, s_off, ds)
return s_off + ds
# fmt: off
s_zero_indices = [0] * len(s_buf.shape)
@T.prim_func(check_well_formed=False)
def impl():
s_off = _substitute_axes(s_off_template, placeholders, sctx)
_setup_swizzle(s_off)
r_local = r_buf.local(*per_thread_r_shape)
# Keep as a serial TIR loop and let ptxas unroll downstream. An
# explicit ``T.unroll`` materializes the per-iter scratch
# (ds/dr/s_ptr/r_ptr, swizzle ``v_<n>[]`` signed-strides) as N
# copies of each buffer declaration; on kernels with many R↔S copy
# sites and large ``total_outer`` (FA4 writeback) this floods the
# function with ``alignas(64) int`` arrays and pressures registers.
for f in range(total_outer):
ds, dr = _outer_const_offsets(outer, f)
s_ptr = _ptr_off(s_buf.ptr_to(s_zero_indices), _s_iter_off(f, ds, s_off))
r_ptr = _ptr_off(r_local.ptr_to([0]), r_off_base + dr)
if r_is_src:
T.ptx.st(s_ptr, src=r_ptr, space=space, vec=vec, ptx_type=ptx_type)
else:
T.ptx.ld(
s_ptr,
copy_ptx_ld_return_type(ptx_type),
ptx_type,
dst=r_ptr,
space=space,
vec=vec,
)
# fmt: on
import os
if os.environ.get("R2S_DUMP"):
print("=== emitted impl ===")
print(impl.script())
return impl
@register_dispatch(
"copy",
"cuda",
variant="reg",
priority=10,
when=[predicate("reg_applicable", _is_reg_copy)],
)
def copy_schedule_reg(op_call: TilePrimitiveCall, sctx: DispatchContext) -> PrimFunc:
return _emit_reg(op_call, sctx)
@@ -0,0 +1,101 @@
# 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.
"""Shared helpers for copy operator dispatches on CUDA targets."""
from collections.abc import Iterable
from tvm.tirx import Buffer
from tvm.tirx.operator.tile_primitive.registry import DispatchContext
from tvm.tirx.stmt import TilePrimitiveCall
from ..common import match_scope, validate_copy_op
def _is_valid_smem_tmem_copy(op_call: TilePrimitiveCall, sctx: DispatchContext):
"""Validate smem->tmem copy operation.
The new tcgen05.cp.32x128b.warpx4 dispatch requires the destination tmem
buffer to declare warpx4 broadcast as ``R[4 : 32@TLane]``. The legacy
128-row dispatch path (no replica) goes through a separate code path and
is not handled here.
"""
dst_region, src_region = op_call.args[:2]
src: Buffer = src_region.buffer
dst: Buffer = dst_region.buffer
if not (src.scope().startswith("shared") and dst.scope() == "tmem"):
return (False, f"expected shared->tmem, got {src.scope()}->{dst.scope()}")
if not (src.layout and dst.layout):
return (False, "both buffers must have layouts")
if dst.allocated_addr is None:
return (False, "tmem buffer must have allocated_addr")
# Require warpx4 router on TMEM side so this dispatch only handles the
# 32x128b.warpx4 case; other shapes (128x256b/128x128b etc.) fall back
# to the legacy dispatch.
rep = dst.layout.replica
if not (
len(rep) == 1
and int(rep[0].extent) == 4
and int(rep[0].stride) == 32
and "TLane" in str(rep[0].axis)
):
return (False, f"requires R[4:32@TLane] on tmem, got replica={list(rep)}")
return (True, None)
def _single_thread_exec(op_call: TilePrimitiveCall, sctx: DispatchContext):
"""Predicate: exec scope must be single-thread."""
exec_scope = sctx.scope_kind
ok = exec_scope == "thread"
return (ok, None if ok else f"expected thread exec_scope, got {exec_scope}")
DEFAULT_ALLOWED_PAIRS: tuple[tuple[str, str], ...] = (
("global", "shared*"),
("shared*", "global"),
("global", "local"),
("local", "global"),
("shared*", "local"),
("local", "shared*"),
)
def _scope_allowed(
op_call: TilePrimitiveCall,
sctx: DispatchContext,
allowed_pairs: Iterable[tuple[str, str]] = DEFAULT_ALLOWED_PAIRS,
):
op_call = TilePrimitiveCall.downcast(op_call)
dst_buffer_region, src_buffer_region = (op_call.dst, op_call.src)
src_scope = src_buffer_region.buffer.scope()
dst_scope = dst_buffer_region.buffer.scope()
ok = any(
(
match_scope(src_scope, src_pat) and match_scope(dst_scope, dst_pat)
for src_pat, dst_pat in allowed_pairs
)
)
if not ok:
allowed_str = ", ".join((f"{a}->{b}" for a, b in allowed_pairs))
return (
False,
f"unsupported memory scopes src={src_scope} dst={dst_scope}; allowed: {allowed_str}",
)
return (True, None)
def _is_valid_copy(op_call: TilePrimitiveCall, sctx: DispatchContext):
return (validate_copy_op(op_call, sctx), "validate_copy_op failed")