Files
tilelang/testing/python/language/test_tilelang_language_span.py
T
penguin_wwyandLeiWang1999 a42bbc3cb5 [TIR] Inject source spans into tirx IR and surface source locations in compiler errors (#2751)
* Add span

* Fix span

* Fix

* Fix

* Fix

* Fix

* Fix

* Improve source span propagation and diagnostics

---------

Co-authored-by: LeiWang1999 <leiwang1999@outlook.com>
2026-07-28 13:46:14 +08:00

425 lines
14 KiB
Python

"""Tests for source span injection into tirx IR (TILELANG_ENABLE_IR_SPAN).
Spans let compiler diagnostics and tools (LSP, visualizers) map IR nodes back
to user source lines. Injection happens during eager parsing and must be
exact: every emitted statement/buffer carries the line of the user statement
that produced it.
"""
import pytest
import tilelang
import tilelang.language as T
import tilelang.testing
from tilelang import tvm
from tilelang.ir import (
get_buffer_span,
get_stmt_span,
make_span,
set_buffer_span,
set_stmt_span,
span_coverage,
span_to_location,
)
from tvm.tirx.stmt_functor import post_order_visit
def _marker_line(marker: str) -> int:
with open(__file__) as f:
for i, line in enumerate(f, 1):
if marker in line:
return i
raise ValueError(f"marker not found: {marker}")
def _make_kernel():
@T.prim_func
def main(
A: T.Tensor((128, 128), "float16"),
B: T.Tensor((128, 128), "float16"),
C: T.Tensor((128, 128), "float16"),
):
with T.Kernel(1, threads=128) as bx: # span_marker_kernel
A_shared = T.alloc_shared((128, 128), "float16") # span_marker_alloc_shared
C_local = T.alloc_fragment((128, 128), "float32") # span_marker_alloc_fragment
T.copy(A, A_shared) # span_marker_copy
T.clear(C_local) # span_marker_clear
for _k in T.serial(8): # span_marker_for
T.gemm(A_shared, A_shared, C_local) # span_marker_gemm
if bx == 0: # span_marker_if
T.copy(C_local, C) # span_marker_copy_out
return main
def _stmt_span_lines(func) -> dict[int, list[str]]:
"""Map source line -> IR node type names stamped with that line."""
lines: dict[int, list[str]] = {}
def visit(node):
if not isinstance(node, tvm.tirx.Stmt):
return
loc = span_to_location(get_stmt_span(node))
if loc is not None:
assert loc[0] == __file__
lines.setdefault(loc[1], []).append(type(node).__name__)
post_order_visit(func.body, visit)
return lines
def _alloc_buffer_span_lines(func) -> dict[int, list[str]]:
lines: dict[int, list[str]] = {}
def visit(node):
if type(node).__name__ == "SBlockRealize":
for buf in node.block.alloc_buffers:
loc = span_to_location(get_buffer_span(buf))
if loc is not None:
assert loc[0] == __file__
lines.setdefault(loc[1], []).append(buf.name)
post_order_visit(func.body, visit)
return lines
def test_stmt_spans_point_to_user_lines():
func = _make_kernel()
lines = _stmt_span_lines(func)
assert _marker_line("span_marker_copy") in lines
assert _marker_line("span_marker_clear") in lines
assert _marker_line("span_marker_gemm") in lines
assert "For" in lines[_marker_line("span_marker_for")]
assert any(k.startswith("If") for k in lines[_marker_line("span_marker_if")])
assert _marker_line("span_marker_copy_out") in lines
def test_buffer_spans_point_to_alloc_lines():
func = _make_kernel()
buf_lines = _alloc_buffer_span_lines(func)
assert "A_shared" in buf_lines[_marker_line("span_marker_alloc_shared")]
assert "C_local" in buf_lines[_marker_line("span_marker_alloc_fragment")]
def test_span_disabled(monkeypatch):
monkeypatch.setenv("TILELANG_ENABLE_IR_SPAN", "0")
func = _make_kernel()
cov = span_coverage(func)
assert cov["stmts"] == [0, cov["stmts"][1]]
assert cov["buffers"][0] == 0
def test_span_not_in_structural_equal(monkeypatch):
from tvm.ir import assert_structural_equal
func_on = _make_kernel()
monkeypatch.setenv("TILELANG_ENABLE_IR_SPAN", "0")
func_off = _make_kernel()
assert_structural_equal(func_on, func_off)
def test_script_print_ignores_span():
func = _make_kernel()
text_with_span = func.script()
assert text_with_span and isinstance(text_with_span, str)
def test_span_coverage_helper():
func = _make_kernel()
cov = span_coverage(func)
with_span, total = cov["stmts"]
assert total > 0
# SeqStmt and other assembly nodes are never stamped; user-level
# statements must dominate.
assert with_span / total > 0.5
def test_make_span_and_setters_roundtrip():
span = make_span(__file__, 42)
func = _make_kernel()
# Exercise the raw setters on a real node.
set_stmt_span(func.body, span)
assert span_to_location(get_stmt_span(func.body)) == (__file__, 42)
buf = func.buffer_map[func.params[0]]
set_buffer_span(buf, span)
assert span_to_location(get_buffer_span(buf)) == (__file__, 42)
def _make_macro_kernel():
@T.macro
def load_to_shared(A, A_shared):
T.copy(A, A_shared) # span_marker_macro_copy
@T.prim_func
def main(A: T.Tensor((128, 128), "float16"), B: T.Tensor((128, 128), "float16")):
with T.Kernel(1, threads=128):
A_shared = T.alloc_shared((128, 128), "float16")
load_to_shared(A, A_shared) # span_marker_macro_call
return main
def test_spans_inside_macro():
func = _make_macro_kernel()
lines = _stmt_span_lines(func)
# Statements inside a macro body point at the macro definition line,
# mirroring Python traceback semantics for the macro body frame.
macro_lines = lines.get(_marker_line("span_marker_macro_copy"))
call_lines = lines.get(_marker_line("span_marker_macro_call"))
assert macro_lines or call_lines, "macro copy statement carries no span"
# ---------------------------------------------------------------------------
# Error location tests: pass diagnostics must carry a `--> file:line` hint.
# ---------------------------------------------------------------------------
def _expect_error_at(excinfo, marker: str):
message = str(excinfo.value)
expected = f"--> {__file__}:{_marker_line(marker)}:"
assert expected in message, f"expected {expected!r} in error message:\n{message}"
assert marker in message, f"source snippet was not rendered:\n{message}"
def _make_parallel_fragment_bad_index():
@T.prim_func
def main(A: T.Tensor((128,), "float32")):
with T.Kernel(1, threads=128):
frag = T.alloc_fragment((4,), "float32") # span_marker_frag_alloc
for i in T.Parallel(4):
frag[1] = A[i] # span_marker_frag_store
return main
def test_error_span_parallel_fragment_nonzero_index():
func = _make_parallel_fragment_bad_index()
with pytest.raises(Exception) as excinfo:
tilelang.lower(func, target="c")
assert "Only fragment[0] access is allowed" in str(excinfo.value)
_expect_error_at(excinfo, "span_marker_frag_alloc")
def _make_pipeline_duplicate_order():
@T.prim_func
def main(
A: T.Tensor((128, 128), "float16"),
B: T.Tensor((128, 128), "float16"),
C: T.Tensor((128, 128), "float16"),
):
with T.Kernel(1, threads=128):
A_shared = T.alloc_shared((128, 128), "float16")
B_shared = T.alloc_shared((128, 128), "float16")
for _k in T.Pipelined(2, order=[0, 0], stage=[0, 1]): # span_marker_pipelined
T.copy(A, A_shared) # span_marker_pipe_copy1
T.copy(B, B_shared) # span_marker_pipe_copy2
return main
@tilelang.testing.requires_metal
def test_error_span_pipeline_duplicate_order():
func = _make_pipeline_duplicate_order()
with pytest.raises(Exception) as excinfo, tvm.target.Target("metal"):
tilelang.lower(func, target="metal")
assert "same order" in str(excinfo.value)
_expect_error_at(excinfo, "span_marker_pipe_copy2")
def _make_parallel_inconsistent_indices():
@T.prim_func
def main(A: T.Tensor((128,), "float32")):
with T.Kernel(1, threads=128):
frag = T.alloc_fragment((4,), "float32") # span_marker_frag_alloc2
for i in T.Parallel(4):
frag[0] = A[i]
frag[1] = A[i] # span_marker_frag_store2
return main
def test_error_span_parallel_inconsistent_indices():
func = _make_parallel_inconsistent_indices()
with pytest.raises(Exception) as excinfo:
tilelang.lower(func, target="c")
_expect_error_at(excinfo, "span_marker_frag_alloc2")
def _make_copy_range_mismatch():
@T.prim_func
def main(A: T.Tensor((128,), "float32"), B: T.Tensor((64,), "float32")):
with T.Kernel(1, threads=128):
B_shared = T.alloc_shared((64,), "float32")
T.copy(A[0:128], B_shared[0:64]) # span_marker_bad_copy
return main
def test_error_span_copy_range_mismatch():
func = _make_copy_range_mismatch()
with pytest.raises(Exception) as excinfo:
tilelang.lower(func, target="c")
assert "--> " in str(excinfo.value)
# ---------------------------------------------------------------------------
# Review hardening: parameter buffer spans, path edge cases, coverage stats.
# ---------------------------------------------------------------------------
def _make_eager_jit_buffers():
@tilelang.jit
def main(A):
A: T.Tensor((64,), T.float32) # span_marker_eager_param
C = T.empty((64,), T.float32) # span_marker_eager_empty
with T.Kernel(1, threads=128):
T.copy(A, C)
return C
return main.get_tir()
def test_eager_jit_param_and_empty_buffer_spans():
func = _make_eager_jit_buffers()
locations = {buf.name: span_to_location(get_buffer_span(buf)) for buf in func.buffer_map.values()}
assert locations["A"] == (__file__, _marker_line("span_marker_eager_param"))
assert locations["C"] == (__file__, _marker_line("span_marker_eager_empty"))
def _make_eager_jit_wgmma_error():
@tilelang.jit
def main(A, B, C):
A: T.Tensor((32, 16), T.float16) # span_marker_eager_wgmma_param
B: T.Tensor((16, 64), T.float16)
C: T.Tensor((32, 64), T.float16)
with T.Kernel(1, threads=128):
T.wgmma_gemm(A, B, C, clear_accum=True)
return main.get_tir()
@tilelang.testing.requires_cuda
def test_eager_jit_buffer_only_diagnostic_has_span():
func = _make_eager_jit_wgmma_error()
with pytest.raises(Exception) as excinfo:
tilelang.lower(func, target={"kind": "cuda", "arch": "sm_80"})
assert "T.wgmma_gemm() requires Hopper WGMMA lowering" in str(excinfo.value)
_expect_error_at(excinfo, "span_marker_eager_wgmma_param")
def _make_match_buffer_kernel():
@T.prim_func
def main(A_ptr: T.handle):
A = T.match_buffer(A_ptr, (16,), T.float32) # span_marker_match_buffer
with T.Kernel(1, threads=1):
T.evaluate(A[0])
return main
def test_match_buffer_has_span():
func = _make_match_buffer_kernel()
(buffer_a,) = func.buffer_map.values()
assert span_to_location(get_buffer_span(buffer_a)) == (
__file__,
_marker_line("span_marker_match_buffer"),
)
def _make_tensor_from_ptr_kernel():
@T.prim_func
def main(A_ptr: T.ptr):
A = T.make_tensor(A_ptr, (16,), T.float32) # span_marker_make_tensor
with T.Kernel(1, threads=1):
T.evaluate(A[0])
return main
def test_make_tensor_has_span():
func = _make_tensor_from_ptr_kernel()
(buffer_a,) = func.buffer_map.values()
assert span_to_location(get_buffer_span(buffer_a)) == (
__file__,
_marker_line("span_marker_make_tensor"),
)
def _make_kernel_param_span():
@T.prim_func
def main(
A: T.Tensor((128, 128), "float16"), # span_marker_param_a
B: T.Tensor((128, 128), "float16"),
):
with T.Kernel(1, threads=128):
T.copy(A, B)
return main
def test_param_buffer_has_span():
func = _make_kernel_param_span()
buffer_a = func.buffer_map[func.params[0]]
loc = span_to_location(get_buffer_span(buffer_a))
assert loc is not None, "parameter buffer A carries no span"
assert loc == (__file__, _marker_line("span_marker_param_a"))
def test_enrich_error_space_and_windows_paths(tmp_path):
from tilelang.errors import _MARKER_RE, enrich_error
# POSIX path containing spaces: snippet must render.
spaced = tmp_path / "dir with spaces"
spaced.mkdir()
src = spaced / "kernel.py"
src.write_text("line one\nline two\nline three\n")
exc = ValueError(f"boom\n --> {src}:2:1")
enrich_error(exc)
assert "line two" in str(exc)
# Windows drive-letter path: regex must not stop at the drive colon.
m = _MARKER_RE.search("\n --> C:\\work\\kernel.py:21:1")
assert m is not None
assert m.group("file") == "C:\\work\\kernel.py"
assert m.group("line") == "21"
assert m.group("col") == "1"
def test_enrich_error_passes_through_plain_errors():
from tilelang.errors import enrich_error
exc = ValueError("plain error without marker")
assert enrich_error(exc) is exc
assert str(exc) == "plain error without marker"
def test_mutate_with_windows_style_path():
"""Windows absolute paths must not produce truncated \\U escapes."""
import ast as py_ast
import tilelang.language.eager.ast as eager_ast
def sample(a):
return a + 1
fake_path = r"C:\Users\fake\kernel.py"
tree = eager_ast.utils.get_ast(sample)
mut = eager_ast.DSLMutator({}, sample.__globals__, fake_path)
new_tree = mut.visit(tree)
source = py_ast.unparse(new_tree)
# repr() quoting keeps the path intact and the generated source valid.
assert repr(fake_path) in source
compile(source, "<mutated>", "exec")
def test_span_coverage_counts_alloc_buffers():
func = _make_kernel()
cov = span_coverage(func)
# 3 parameter buffers + A_shared + C_local; all must be spanned and the
# alloc (non-parameter) buffers must be counted.
assert cov["buffers"][1] >= 5
assert cov["buffers"][0] == cov["buffers"][1]
if __name__ == "__main__":
tilelang.testing.main()