Files
tilelang/testing/python/jit/test_tilelang_jit_cutedsl_host_codegen.py
T
Jayce Suanddingsg e7da9025c4 [BugFix][CuTeDSL] Fix TileKernels scan, optional-shape, and e5m6 paths (#2369)
* [BugFix] Complete CuTeDSL scan lowering for TileKernels

TileKernels exercises CuTeDSL scan and routing patterns that were not fully covered by the existing Python backend helpers.

The MoE get_fused_mapping kernel computes expert base offsets with T.cumsum over a 256-entry shared buffer. CuTeDSL CumSum1D only performed a single warp-local prefix scan, while the CUDA scan template uses the first warp to scan all 32-element segments and carry the result between segments. As a result, expert prefixes reset at later warp-sized chunks, positions for experts such as 32 and 64 overlapped earlier expert ranges, and pos_to_expert validation failed on H100.

Bring the CuTeDSL scan helpers in line with the CUDA InclusiveScanLine behavior: add reusable one-warp line scans with carry across 32-element segments, route CumSum1D and CumMax1D through them, and use the same line scan for CumSum2D/CumMax2D on both row-wise and column-wise axes. This removes the previous dim=0 H<=32 limitation and fixes multi-segment cummax as well as cumsum.

The same TileKernels path also relies on CuTeDSL codegen accepting non-flat buffer accesses after TIR lowering. Add a shared index linearization helper for loads, stores, address_of/reinterpret, and atomic pointer generation, and lower tl.match_any_sync through the existing CuTeDSL warp intrinsic. Thread binding now falls back to the IterVar name when thread_tag has already been normalized away.

Verified on H100 CUDA/CuTeDSL with CUDA_VISIBLE_DEVICES=0 and TILELANG_TARGET=cutedsl:

- cmake --build build -j$(nproc)

- ruff format --check tilelang/contrib/cutedsl/reduce.py

- ruff check tilelang/contrib/cutedsl/reduce.py

- git diff --check

- pytest testing/python/target/test_tilelang_codegen_cutedsl_scan.py testing/python/language/test_tilelang_language_scan.py -q -s --tb=short: 13 passed

- pytest testing/python/language/test_tilelang_language_warp_vote.py::test_match_any_sync -q -s --tb=short: 1 passed

- pytest tests/moe/test_get_fused_mapping.py::test_get_fused_mapping[num_send_tokens=4001-num_topk=2-num_experts=72-num_ep_ranks=1-alignment=64] -q -s --tb=short: 1 passed

Co-authored-by: dingsg <shengge.ding@enflame-tech.com>

* [BugFix] Resolve CuTeDSL dynamic shapes from live tensors

CuTeDSL records dynamic symbolic dimensions in the same ordering as the CUDA wrapper, but it previously kept only the first buffer shape or stride location for each symbol. That is not sufficient for kernels with optional tensor arguments. TileKernels reduce_fused declares num_tokens on topk_weights, token_topk_to_pos, and out; when with_weights is false the topk_weights argument is None, so the first recorded source is not a live tensor.

The previous adapter fallback substituted 0 when that first source was None. On H100 with TILELANG_TARGET=cutedsl this passed num_tokens=0 to the generated launcher even though token_topk_to_pos had shape (4001, 2), producing grid=[0, 1, 1] and a CUDA error 1 launch failure in reduce_fused.

Keep all shape/stride candidates for each dynamic symbol and resolve the runtime value from the first candidate backed by a real torch.Tensor. Reuse the same resolution helper for allocated output shapes and for dynamic arguments passed to the generated CuTeDSL module, and fail clearly if no live tensor source exists.

Verified with a CuTeDSL adapter regression test for repeated dynamic shape symbols with a None first candidate, and with the TileKernels reduce_fused H100 failure case that previously launched with num_tokens=0.

Co-authored-by: dingsg <shengge.ding@enflame-tech.com>

* [BugFix] Permit dead optional CuTeDSL stride symbols

TileKernels no-scale MoE expand paths can keep a dynamic stride symbol in the generated CuTeDSL host wrapper ABI even when the optional scale tensor is absent from the lowered device kernel variant. In that case the runtime argument is intentionally None, so the stride-only symbol has no live tensor from which the adapter can read torch stride metadata.

The previous dynamic-symbol fix made missing live tensor sources strict to avoid unsafe shape fallbacks such as launching with num_tokens=0 when the first candidate tensor was optional. That strict behavior is still required for shape symbols because they control output allocation and launch dimensions.

Limit the fallback to symbols whose candidates are stride-only. Such values are ABI placeholders for optional strided tensor parameters in pruned variants; returning 0 preserves the generated call signature without weakening dynamic shape resolution. Add adapter regressions covering both the optional stride fallback and the strict missing-shape error.

Verified on H100 with TILELANG_TARGET=cutedsl:

- ruff format/check on the touched files

- pytest testing/python/jit/test_tilelang_jit_cutedsl_host_codegen.py::test_cutedsl_adapter_resolves_dynamic_symbol_from_live_tensor_candidate testing/python/jit/test_tilelang_jit_cutedsl_host_codegen.py::test_cutedsl_adapter_allows_optional_stride_symbol_without_live_tensor -q

- pytest tests/moe/test_expand_to_fused.py::test_expand_to_fused[num_send_tokens=4001-num_topk=2-num_experts=9-num_ep_ranks=8-hidden=576] -vv -s --tb=short

Co-authored-by: dingsg <shengge.ding@enflame-tech.com>

* [BugFix] Fix CuTeDSL optional ABI and early-return codegen

TileKernels full functional coverage with TILELANG_TARGET=cutedsl exposed additional CuTeDSL gaps in top2_sum_gate after the broader TileKernels fixes.

First, the host adapter still appended dynamic shape symbols for optional tensors that had been compiled out by static flags. In top2_sum_gate, to_physical_map/logical_count can be None when logical expert mapping is disabled, but their shape-only symbol remains in the wrapper ABI. Keep output allocation strict, but allow dead host ABI dynamic shape arguments to use a zero placeholder when no live tensor source exists.

Second, CuTeDSL cannot emit dynamic thread returns directly, so codegen rewrites if cond: thread_return(); rest into if not cond: rest. The existing recognizer only handled a bare thread_return then body, and missed the common if cond: stores; thread_return() pattern. Preserve the pre-return stores and guard the following statements so masked tokens cannot be overwritten by the fallthrough path.

Third, vector binary codegen reused SSA aliases for operands containing BufferLoad nodes. That is not valid for mutable local tensors after an intervening store. The softmax top2_sum_gate path restored raw logits to scores_local, but the subsequent scores_local + bias expression reused an older softmax load and ranked by softmax(logits)+bias instead of logits+bias. Avoid SSA alias reuse for vector operands containing BufferLoad so the generated code reloads the current tensor value.

Verified on H100 with the local TileLang build and TileKernels CuTeDSL backend:

- cmake --build build -j 32

- ruff format/check on touched Python files

- pytest testing/python/jit/test_tilelang_jit_cutedsl_host_codegen.py::{dynamic symbol candidate tests} -q: 3 passed

- tests/moe/test_top2_sum_gate.py::test_top2_sum_gate[num_groups=0-num_topk_groups=0-num_routed_experts=72-num_shared_experts=1-num_topk=6] with TILELANG_TARGET=cutedsl TK_FULL_TEST=1: 1 passed

Co-authored-by: dingsg <shengge.ding@enflame-tech.com>

* [BugFix] Promote narrow CuTeDSL shifts before wide casts

TileKernels e5m6 quantization kernels pack 8 truncated fp16 values into three uint32 words. The TileLang source writes expressions such as T.cast(half_u16[i] << 20, T.uint32), which the CUDA C++ backend evaluates with normal integer promotion before the final uint32 cast.

CuTeDSL uses strongly typed cutlass integer wrappers instead of CUDA C integer promotion. The previous CuTeDSL lowering cast the shift result back to the narrow TIR result type first, producing code like cutlass.Uint32(cutlass.Uint16((half_u16[0] << cutlass.Uint16(20)))). For shifts by more than the uint16 width this drops all high bits before the packed uint32 store, corrupting e5m6 payload bytes. Cast-back then faithfully decoded the corrupted payload into nan/inf values, so TileKernels full correctness failed in cast_back_e5m6 and per_token_cast_to_e5m6 cases.

When a narrow integer shift is immediately cast to a wider integer type, emit the CuTeDSL shift with the left operand promoted to the target integer type before shifting. This preserves the intended packed-bit construction while leaving narrow shift expressions that are not widened on the existing path.

Add a CuTeDSL codegen regression test for uint16 << 20 widened to uint32 so future changes do not reintroduce a narrow in-place shift.

Verified on H100 CUDA/CuTeDSL with TILELANG_TARGET=cutedsl and TILELANG_DISABLE_CACHE=1:

- cmake --build build -j 32

- python -m pytest testing/python/target/test_tilelang_codegen_cutedsl_integer_shift.py -q

- TileKernels focused e5m6 quant tests: tests/quant/test_per_token_cast_to_e5m6.py tests/quant/test_cast_back_e5m6.py -n 2: 168 passed, 112 skipped

Co-authored-by: dingsg <shengge.ding@enflame-tech.com>

---------

Co-authored-by: dingsg <shengge.ding@enflame-tech.com>
2026-06-17 13:41:26 +08:00

367 lines
14 KiB
Python

"""Tests for CuTeDSL host codegen integration."""
import pytest
import torch
import tilelang
import tilelang.language as T
import tilelang.testing
CUTEDSL_SM90_TARGET = {"kind": "cutedsl", "arch": "sm_90"}
def _require_cutedsl():
"""Skip when the CuTeDSL Python stack is unavailable."""
try:
from tilelang.jit.adapter.cutedsl.checks import check_cutedsl_available
check_cutedsl_available()
except (ImportError, ModuleNotFoundError, RuntimeError, AssertionError) as err:
pytest.skip(f"CuTeDSL is not available: {err}")
def _lower_cutedsl(program):
"""Lower a TileLang program and build its CuTeDSL host wrapper."""
_require_cutedsl()
from tilelang.jit.adapter.cutedsl.wrapper import TLCuTeDSLSourceWrapper
from tilelang.backend.target import determine_target
target = determine_target(CUTEDSL_SM90_TARGET)
with target:
artifact = tilelang.lower(program, target=target)
mod = tilelang.tvm.IRModule({program.attrs["global_symbol"]: program})
wrapper = TLCuTeDSLSourceWrapper(mod, artifact.kernel_source, target, artifact.device_mod, artifact.host_mod)
return artifact, wrapper
def _host_entry_gvar_and_func(wrapper):
"""Return the GlobalVar and PrimFunc selected as the wrapper host entry."""
entry_func = wrapper._host_entry_func()
for gvar, host_func in wrapper.host_mod.functions.items():
if host_func.same_as(entry_func):
return gvar, host_func
raise AssertionError("Unable to locate wrapper host entry function in host_mod")
def single_kernel_program(N, block_size=64, dtype=T.float32):
"""Create a single-kernel CuTeDSL program."""
@T.prim_func
def main(
A: T.Tensor((N,), dtype),
B: T.Tensor((N,), dtype),
):
"""Write one output tensor from one device kernel."""
with T.Kernel(T.ceildiv(N, block_size), threads=block_size) as (bx,):
for i in T.Parallel(block_size):
idx = bx * block_size + i
if idx < N:
B[idx] = A[idx] + 1.0
return main
def repeated_dynamic_shape_program():
"""Create a program where one dynamic symbol appears in multiple params."""
N = T.dynamic("N")
@T.prim_func
def main(
optional: T.Tensor((N,), T.float32),
source: T.Tensor((N,), T.float32),
out: T.Tensor((N,), T.float32),
):
T.evaluate(0)
return main
def optional_stride_symbol_program():
"""Create a program with an optional strided tensor stride symbol."""
N = T.dynamic("N")
stride = T.dynamic("stride")
@T.prim_func
def main(
source: T.Tensor((N,), T.float32),
optional: T.StridedTensor[(N,), (stride,), T.float32],
out: T.Tensor((N,), T.float32),
):
T.evaluate(0)
return main
def optional_shape_symbol_program():
"""Create a program with shape symbols that only belong to optional inputs."""
N = T.dynamic("N")
M = T.dynamic("M")
@T.prim_func
def main(
source: T.Tensor((N,), T.float32),
optional_a: T.Tensor((M,), T.float32),
optional_b: T.Tensor((M,), T.float32),
out: T.Tensor((N,), T.float32),
):
T.evaluate(0)
return main
def two_kernel_program(N, block_size=64, dtype=T.float32):
"""Create a program with two lowered host kernel call sites."""
@T.prim_func
def main(
A: T.Tensor((N,), dtype),
B: T.Tensor((N,), dtype),
C: T.Tensor((N,), dtype),
):
"""Launch two device kernels from one host wrapper."""
with T.Kernel(T.ceildiv(N, block_size), threads=block_size) as (bx,):
for i in T.Parallel(block_size):
idx = bx * block_size + i
if idx < N:
B[idx] = A[idx] + 1.0
with T.Kernel(T.ceildiv(N, block_size), threads=block_size) as (bx2,):
for j in T.Parallel(block_size):
idx = bx2 * block_size + j
if idx < N:
C[idx] = B[idx] * 2.0
return main
@tilelang.testing.requires_cuda
def test_cutedsl_host_wrapper_follows_lowered_host_call_sites():
"""Verify generated CuTeDSL launchers use lowered host call-site order."""
_, wrapper = _lower_cutedsl(two_kernel_program(64))
call_sites = wrapper._collect_host_kernel_call_sites()
call_names = [call_site["function_name"] for call_site in call_sites]
assert len(call_names) == 2
assert call_names == wrapper.function_names
launcher_cpp = wrapper.get_launcher_cpp_code()
first_launch = launcher_cpp.index(f"Launch kernel 0: {call_names[0]}")
second_launch = launcher_cpp.index(f"Launch kernel 1: {call_names[1]}")
assert first_launch < second_launch
host_source = wrapper.host_func
first_cubin_launch = host_source.index(f"{call_names[0]}(")
second_cubin_launch = host_source.index(f"{call_names[1]}(")
assert first_cubin_launch < second_cubin_launch
@tilelang.testing.requires_cuda
def test_cutedsl_host_wrapper_preserves_repeated_same_kernel_call_sites():
"""Verify repeated host launches of the same device kernel remain distinct."""
_, wrapper = _lower_cutedsl(single_kernel_program(64))
original_call_site = wrapper._collect_host_kernel_call_sites()[0]
function_name = original_call_site["function_name"]
repeated_body = tilelang.tvm.tirx.SeqStmt(
[
tilelang.tvm.tirx.Evaluate(tilelang.tvm.tirx.call_packed(function_name, *original_call_site["function_params"])),
tilelang.tvm.tirx.Evaluate(tilelang.tvm.tirx.call_packed(function_name, *original_call_site["function_params"])),
]
)
entry_gvar, entry_func = _host_entry_gvar_and_func(wrapper)
wrapper.host_mod = tilelang.tvm.IRModule({entry_gvar.name_hint: entry_func.with_body(repeated_body)})
call_sites = wrapper._collect_host_kernel_call_sites()
call_names = [call_site["function_name"] for call_site in call_sites]
assert call_names == [function_name, function_name]
assert call_sites[0] is not call_sites[1]
wrapper.update_lib_code(wrapper.source)
launcher_cpp = wrapper.get_launcher_cpp_code()
first_launch = launcher_cpp.index(f"Launch kernel 0: {function_name}")
second_launch = launcher_cpp.index(f"Launch kernel 1: {function_name}")
assert first_launch < second_launch
host_source = wrapper.host_func
kernel_wrapper_source = host_source[host_source.index("def kernel_wrapper") :]
first_cubin_launch = kernel_wrapper_source.index(f"{function_name}(")
second_cubin_launch = kernel_wrapper_source.index(f"{function_name}(", first_cubin_launch + 1)
assert first_cubin_launch < second_cubin_launch
@tilelang.testing.requires_cuda
def test_cutedsl_host_call_sites_ignore_dead_helper_funcs():
"""Verify CuTeDSL launch collection only scans the host entry function."""
_, wrapper = _lower_cutedsl(single_kernel_program(64))
call_sites = wrapper._collect_host_kernel_call_sites()
function_name = call_sites[0]["function_name"]
entry_gvar, entry_func = _host_entry_gvar_and_func(wrapper)
dead_helper = tilelang.tvm.tirx.PrimFunc(
[],
tilelang.tvm.tirx.Evaluate(tilelang.tvm.tirx.call_packed(function_name)),
).with_attr("global_symbol", "dead_helper")
wrapper.host_mod = tilelang.tvm.IRModule(
{
entry_gvar.name_hint: entry_func,
"dead_helper": dead_helper,
}
)
assert wrapper._collect_host_kernel_call_sites() == call_sites
@tilelang.testing.requires_cuda
def test_cutedsl_host_call_sites_require_launch_metadata():
"""Verify missing launch metadata for a real host call site fails fast."""
_, wrapper = _lower_cutedsl(single_kernel_program(64))
function_name = wrapper._collect_host_kernel_call_sites()[0]["function_name"]
for metadata_name in ("block_info", "grid_info", "dynamic_smem_buf"):
original_metadata = getattr(wrapper, metadata_name)
metadata = dict(original_metadata)
metadata.pop(function_name)
try:
setattr(wrapper, metadata_name, metadata)
with pytest.raises(AssertionError, match=f".*{function_name}.*{metadata_name}.*"):
wrapper.update_lib_code(wrapper.source)
finally:
setattr(wrapper, metadata_name, original_metadata)
@tilelang.testing.requires_cuda
def test_cutedsl_adapter_exposes_device_and_host_sources():
"""Verify CuTeDSL adapter exposes device, host, and combined sources."""
_require_cutedsl()
kernel = tilelang.compile(
single_kernel_program(64),
target=CUTEDSL_SM90_TARGET,
execution_backend="cutedsl",
)
device_source = kernel.get_kernel_source(kernel_only=True)
host_source = kernel.get_host_source()
combined_source = kernel.get_kernel_source(kernel_only=False)
assert "@cute.kernel" in device_source
assert "def call(" in host_source
assert "--gpu-arch=sm_90" in host_source
assert "_generate_cubin_if_needed" in host_source
assert device_source in combined_source
assert host_source in combined_source
def test_cutedsl_adapter_resolves_dynamic_symbol_from_live_tensor_candidate():
"""Dynamic shape resolution should skip optional None tensor params."""
_require_cutedsl()
from tilelang.jit.adapter.cutedsl.adapter import CuTeDSLKernelAdapter
program = repeated_dynamic_shape_program()
adapter = CuTeDSLKernelAdapter.__new__(CuTeDSLKernelAdapter)
adapter.ir_module = tilelang.tvm.IRModule({program.attrs["global_symbol"]: program})
adapter.dynamic_symbolic_map, adapter.dynamic_symbolic_order = adapter._process_dynamic_symbolic()
assert len(adapter.dynamic_symbolic_order) == 1
dynamic_symbol = adapter.dynamic_symbolic_order[0]
assert adapter._resolve_dynamic_symbolic_value(dynamic_symbol, [None, torch.empty(7), torch.empty(7)]) == 7
with pytest.raises(TypeError, match="no live tensor source"):
adapter._resolve_dynamic_symbolic_value(dynamic_symbol, [None, None, None])
def test_cutedsl_adapter_allows_optional_stride_symbol_without_live_tensor():
"""Dynamic stride-only symbols may be absent for optional tensor params."""
_require_cutedsl()
from tilelang.jit.adapter.cutedsl.adapter import CuTeDSLKernelAdapter
program = optional_stride_symbol_program()
adapter = CuTeDSLKernelAdapter.__new__(CuTeDSLKernelAdapter)
adapter.ir_module = tilelang.tvm.IRModule({program.attrs["global_symbol"]: program})
adapter.dynamic_symbolic_map, adapter.dynamic_symbolic_order = adapter._process_dynamic_symbolic()
dynamic_symbols = {sym.name: sym for sym in adapter.dynamic_symbolic_order}
param_values = [torch.empty(7), None, torch.empty(7)]
assert adapter._resolve_dynamic_symbolic_value(dynamic_symbols["N"], param_values) == 7
assert adapter._resolve_dynamic_symbolic_value(dynamic_symbols["stride"], param_values) == 0
def test_cutedsl_adapter_allows_optional_abi_shape_symbol_without_live_tensor():
"""Dynamic shape symbols may be absent when only needed as dead ABI args."""
_require_cutedsl()
from tilelang.jit.adapter.cutedsl.adapter import CuTeDSLKernelAdapter
program = optional_shape_symbol_program()
adapter = CuTeDSLKernelAdapter.__new__(CuTeDSLKernelAdapter)
adapter.ir_module = tilelang.tvm.IRModule({program.attrs["global_symbol"]: program})
adapter.dynamic_symbolic_map, adapter.dynamic_symbolic_order = adapter._process_dynamic_symbolic()
dynamic_symbols = {sym.name: sym for sym in adapter.dynamic_symbolic_order}
param_values = [torch.empty(7), None, None, torch.empty(7)]
assert adapter._resolve_dynamic_symbolic_value(dynamic_symbols["N"], param_values) == 7
with pytest.raises(TypeError, match="no live tensor source"):
adapter._resolve_dynamic_symbolic_value(dynamic_symbols["M"], param_values)
assert adapter._resolve_dynamic_symbolic_value(dynamic_symbols["M"], param_values, require_live_shape=False) == 0
def test_cutedsl_cache_restore_does_not_fallback_to_host_source(monkeypatch, tmp_path):
"""Verify missing cached generated modules stay distinct from host wrappers."""
from tilelang.jit.adapter.cutedsl.adapter import CuTeDSLKernelAdapter
from tilelang.jit.adapter.cutedsl import adapter as cutedsl_adapter
class FakeLibraryGenerator:
"""Minimal CuTeDSL library generator stub for cache restore tests."""
def __init__(self, target, verbose):
self.target = target
self.verbose = verbose
self.pymodule = object()
def assign_compile_flags(self, compile_flags):
"""Accept compile flags without invoking the real generator."""
self.compile_flags = compile_flags
def load_lib(self, lib_path):
"""Pretend to load a cached Python module from disk."""
self.libpath = lib_path
monkeypatch.setattr(cutedsl_adapter, "CuTeDSLLibraryGenerator", FakeLibraryGenerator)
prim_func = tilelang.tvm.tirx.PrimFunc([], tilelang.tvm.tirx.Evaluate(0)).with_attr("global_symbol", "main")
missing_kernel_py = tmp_path / "missing_kernel.py"
restored = CuTeDSLKernelAdapter.from_database(
params=[],
result_idx=[],
target=CUTEDSL_SM90_TARGET,
func_or_mod=prim_func,
host_kernel_source="# host wrapper",
device_kernel_source="# device kernel",
kernel_lib_path=str(missing_kernel_py),
)
assert restored.get_generated_module_source() is None
assert restored.get_host_source() == "# host wrapper"
assert restored.get_kernel_source(kernel_only=False) == "# device kernel\n\n# host wrapper"
if __name__ == "__main__":
tilelang.testing.main()