Files
tilelang/testing/python/debug/test_lower_trace.py
T
cc106fa2ad [Feature] Add lower-trace support for debugging & rebased (#2725)
* [feat] add lower-trace support for debugging

* [fix] address review: rebase, doc migration, lower_trace robustness

Address CodeRabbit + SiriusNEO review feedback on #2470:

Blocking (SiriusNEO):
- Rebase onto tile-ai/main (resolve docs/ + tilelang/__init__.py conflicts).
- Move IR Lower Trace doc from docs/tutorials/ to docs/tools/lower_trace.md
  (alongside pass_diff.md); add index entry and 'superseded' pointer on
  pass_diff.md; cross-link from the tutorial.

CodeRabbit Major:
- lower_trace/__init__.py: finally block uses active_exc pattern so a
  generate_html() failure can no longer mask the real lowering exception.
- lower_trace/html.py: sanitize rec.phase into DOM-safe IDs via _safe_id()
  before embedding in id="..." / selectors (avoids markup/JS breakage and
  local HTML injection from special characters).

CodeRabbit Minor:
- test_lower_trace.py: restore caller's TL_LOWER_TRACE* env after the guarded
  tilelang import instead of permanently clearing it.
- lower_trace/__init__.py: terminal mode prints a FAILED stanza (pass name,
  exception, pre-pass IR) before re-raising so the failing step is visible.
- lower_trace/core.py: _get_codegen_output_path() no longer auto-writes
  codegen.cpp in terminal-only mode (explicit override still honored).
- lower_trace/html.py: expandAll()/collapseCtx() null-guard the table lookup
  so a changed diff with no table fails safe.

Cleanup:
- Add docstrings to test helpers and tools.__getattr__ (coverage now 94%+).
- ruff check + format clean.

* [Docs] Add lower trace to tools sidebar

* [fix] route lower-trace env vars through tilelang.env

Register TL_LOWER_TRACE / TL_LOWER_TRACE_DIR as EnvVar in the central
Environment class with get_lower_trace_mode()/get_lower_trace_dir()
parsers, mirroring the existing TILELANG_PASS_DIFF convention, instead
of reading os.environ directly in the lower_trace module.

* [fix] simplify lower_trace lazy-import in tools __getattr__

Drop the redundant globals() cache assignment (import_module already
binds the submodule onto the parent package) and fix the docstring: the
module is imported lazily for cost, not to defer monkey-patch side
effects, which happen in enable(). Note why importlib is required over
"from . import" (the latter re-enters __getattr__ and recurses).

---------

Co-authored-by: caojian5 <caojian5@huawei.com>
Co-authored-by: SiriusNEO <chaofan@deepseek.com>
2026-07-22 16:27:01 +08:00

787 lines
30 KiB
Python

# type: ignore
"""Tests for the lower_trace debugging feature."""
import contextlib
import os
import pytest
import tempfile
# Clear any inherited TL_LOWER_TRACE* env before importing tilelang so the
# import-time activation hook in ``tilelang/__init__.py`` cannot fire during
# pytest collection. The autouse ``_isolate_env`` fixture below handles
# per-test isolation; this guard prevents leaks across collected modules.
# The caller's original env values are restored immediately after import so
# other collected modules or debugging runs still see their own settings.
_TRACE_ENV_KEYS = ("TL_LOWER_TRACE", "TL_LOWER_TRACE_DIR")
_saved_trace_env = {key: os.environ.get(key) for key in _TRACE_ENV_KEYS}
try:
for key in _TRACE_ENV_KEYS:
os.environ.pop(key, None)
import tilelang
import tilelang.testing
import tilelang.language as T
from tilelang import tvm
from tilelang.tools.lower_trace import core as _core
finally:
for key, value in _saved_trace_env.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
del _saved_trace_env
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch):
"""Ensure each test starts with tracing disabled and env vars cleared (before and after)."""
monkeypatch.delenv("TL_LOWER_TRACE", raising=False)
monkeypatch.delenv("TL_LOWER_TRACE_DIR", raising=False)
_core.disable()
yield
_core.disable()
monkeypatch.delenv("TL_LOWER_TRACE", raising=False)
monkeypatch.delenv("TL_LOWER_TRACE_DIR", raising=False)
def _simple_program():
"""Return a trivial elementwise-add prim_func used as trace input."""
@T.prim_func
def program(A: T.Tensor((128,), "float32"), B: T.Tensor((128,), "float32")):
with T.Kernel(threads=128):
tid = T.get_thread_binding()
B[tid] = A[tid] + 1.0
return program
def _noop_pass():
"""Return a Simplify pass (typically a no-op on the simple test program)."""
return tvm.tirx.transform.Simplify()
def test_env_default_off():
"""With no TL_LOWER_TRACE set, tracing mode resolves to None (off)."""
assert _core._get_mode() is None
def test_env_off_values(monkeypatch):
"""Falsish env values ('0','off','false','no','') all disable tracing."""
for v in ("0", "off", "false", "no", ""):
monkeypatch.setenv("TL_LOWER_TRACE", v)
assert _core._get_mode() is None, f"Expected None for {v!r}"
def test_env_truthy_maps_to_html(monkeypatch):
"""Truthy shorthand values ('1','on','true','yes') map to 'html' mode."""
for v in ("1", "on", "true", "yes"):
monkeypatch.setenv("TL_LOWER_TRACE", v)
assert _core._get_mode() == "html", f"Expected 'html' for {v!r}"
def test_env_explicit_modes(monkeypatch):
"""Explicit mode names ('terminal','html','both') are passed through verbatim."""
monkeypatch.setenv("TL_LOWER_TRACE", "terminal")
assert _core._get_mode() == "terminal"
monkeypatch.setenv("TL_LOWER_TRACE", "html")
assert _core._get_mode() == "html"
monkeypatch.setenv("TL_LOWER_TRACE", "both")
assert _core._get_mode() == "both"
def test_lower_trace_api_single_pass(capsys):
"""lower_trace() with a single pass returns one result and prints a 'Pass 1' header."""
from tilelang.tools.lower_trace import lower_trace
program = _simple_program()
results = lower_trace(program, _noop_pass(), mode="terminal")
assert len(results) == 1
assert "name" in results[0]
assert "changed" in results[0]
captured = capsys.readouterr()
assert "Pass 1" in captured.out
def test_lower_trace_api_chain():
"""lower_trace() applies a named pass chain in order and preserves names."""
from tilelang.tools.lower_trace import lower_trace
program = _simple_program()
passes = [
("Simplify1", _noop_pass()),
("Simplify2", _noop_pass()),
]
results = lower_trace(program, passes, mode="terminal")
assert len(results) == 2
assert results[0]["name"] == "Simplify1"
assert results[1]["name"] == "Simplify2"
def test_enable_disable():
"""enable()/disable() round-trip installs and removes tracing hooks cleanly."""
from tilelang.tools.lower_trace import enable, disable
enable()
disable()
def test_lower_trace_html():
"""lower_trace() in html mode writes a report file containing pass content."""
from tilelang.tools.lower_trace import lower_trace
program = _simple_program()
with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as f:
html_path = f.name
try:
results = lower_trace(program, _noop_pass(), mode="html", html_path=html_path)
assert len(results) == 1
assert os.path.exists(html_path)
with open(html_path) as f:
content = f.read()
assert "TileLang" in content or "pass" in content.lower()
finally:
os.unlink(html_path)
def test_discover_passes():
"""_discover_passes() extracts the ordered pass names from a pipeline class."""
from tilelang.tools.lower_trace.core import _discover_passes
from tilelang.cpu.pipeline import CPUPassPipelineBody
pass_names = _discover_passes(CPUPassPipelineBody)
assert len(pass_names) > 10, f"Expected >10 passes, got {len(pass_names)}"
assert "Simplify" in pass_names
assert "LayoutInference" in pass_names
assert "BindTarget" in pass_names
def test_lower_trace_dark_theme():
"""The HTML report embeds theme toggle CSS/JS and a localStorage key."""
from tilelang.tools.lower_trace import lower_trace
program = _simple_program()
with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as f:
html_path = f.name
try:
lower_trace(program, _noop_pass(), mode="html", html_path=html_path)
with open(html_path) as f:
content = f.read()
assert 'id="theme-btn"' in content, "Theme toggle button missing"
assert "toggleTheme" in content, "toggleTheme JS function missing"
assert "--bg:" in content, "CSS variable --bg missing"
assert '[data-theme="dark"]' in content, "Dark theme CSS override missing"
assert "localStorage" in content, "localStorage persistence missing"
assert "lower-trace-theme" in content, "Theme localStorage key missing"
finally:
os.unlink(html_path)
def test_multi_run_accumulation(monkeypatch, tmp_path):
"""Repeated pipeline.lower() calls accumulate records under run2_/run3_ phases."""
from tilelang.tools.lower_trace import enable, disable
from tilelang.tools.lower_trace import core as _core
from tilelang.backend.pass_pipeline import resolve_pipeline
import tilelang.language as T
monkeypatch.setenv("TL_LOWER_TRACE", "both")
monkeypatch.setenv("TL_LOWER_TRACE_DIR", str(tmp_path))
disable()
enable()
@T.prim_func
def tiny(A: T.Tensor((32,), "float32"), B: T.Tensor((32,), "float32")):
with T.Kernel(32):
tid = T.get_thread_binding()
B[tid] = A[tid] + 1.0
mod = tvm.IRModule({"main": tiny})
target = tvm.target.Target("c")
pipeline = resolve_pipeline(target)
assert _core._run_counter == 0, f"Expected run_counter=0 before enable, got {_core._run_counter}"
pipeline.lower(mod, target)
run1_count = len(_core._records)
assert _core._run_counter == 1, f"Expected run_counter=1 after first run, got {_core._run_counter}"
assert run1_count > 0, "First run should produce records"
pipeline.lower(mod, target)
total_count = len(_core._records)
assert _core._run_counter == 2, f"Expected run_counter=2 after second run, got {_core._run_counter}"
assert total_count > run1_count, f"Second run should accumulate records: total={total_count}, run1={run1_count}"
phases = {rec.phase for rec in _core._records}
assert "pipeline_c" in phases, "First run should have phase 'pipeline_c'"
assert "run2_pipeline_c" in phases, "Second run should have phase 'run2_pipeline_c'"
disable()
monkeypatch.delenv("TL_LOWER_TRACE", raising=False)
monkeypatch.delenv("TL_LOWER_TRACE_DIR", raising=False)
def test_diff_html_line_numbers_monotone():
"""Diff row line-number columns must stay ascending after strip-level pairing."""
import re
from tilelang.tools.lower_trace.diff import _make_diff_html
# Whitespace-variant duplicates land inside a single replace hunk: top-level
# difflib (full-line) won't pre-match them as equal, but the strip-level
# pairing inside the hunk used to greedily pair a later left line to an
# earlier right line, making the right column render out of order.
before = "\n".join([" A", "A", " B"])
after = "\n".join(["C", "A ", "B"])
html = _make_diff_html(before, after, context=3)
left, right = [], []
for row in re.finditer(r"<tr[^>]*>(.*?)</tr>", html, re.S):
for side, txt in re.findall(r'<td class="ln[^"]*"\s+data-side="([lr])"[^>]*>(\d*)</td>', row.group(1)):
(left if side == "l" else right).append(int(txt) if txt.strip() else None)
assert left and right, f"no line-number cells parsed:\n{html}"
for name, col in (("left", left), ("right", right)):
nums = [n for n in col if n is not None]
assert nums == sorted(nums), f"{name} column line numbers not ascending: {nums}"
def test_diff_html_trailing_newline_only_difference():
"""Only-trailing-newline diffs must not be misreported as 'No differences.'.
Regression guard: ``splitlines()`` normalises ``"x\\n"`` and ``"x"`` to the
same content, so a sole EOF-newline change used to short-circuit to the
no-op message. The compromise fix renders an explicit trailing-newline
notice instead.
"""
from tilelang.tools.lower_trace.diff import _make_diff_html
html_present = _make_diff_html("x\n", "x", context=3)
assert "No differences." not in html_present
assert "trailing newline" in html_present
html_absent = _make_diff_html("x", "x\n", context=3)
assert "No differences." not in html_absent
assert "trailing newline" in html_absent
# Identical content *and* identical trailing newline → still no-op.
assert _make_diff_html("x\n", "x\n", context=3) == '<p class="noop-msg">No differences.</p>'
assert _make_diff_html("x", "x", context=3) == '<p class="noop-msg">No differences.</p>'
def test_no_skipped_phantom_records(monkeypatch, tmp_path):
"""Pre-registration is gone: no SKIPPED records, indices global-monotonic."""
from tilelang.tools.lower_trace import enable, disable
from tilelang.tools.lower_trace import core as _core
from tilelang.tools.lower_trace.core import STATUS_SKIPPED
from tilelang.backend.pass_pipeline import resolve_pipeline
import tilelang.language as T
monkeypatch.setenv("TL_LOWER_TRACE", "both")
monkeypatch.setenv("TL_LOWER_TRACE_DIR", str(tmp_path))
disable()
enable()
@T.prim_func
def tiny(A: T.Tensor((32,), "float32"), B: T.Tensor((32,), "float32")):
with T.Kernel(32):
tid = T.get_thread_binding()
B[tid] = A[tid] + 1.0
mod = tvm.IRModule({"main": tiny})
target = tvm.target.Target("c")
pipeline = resolve_pipeline(target)
pipeline.lower(mod, target)
# No phantom/skipped records remain — every record is COMPLETED or FAILED
skipped = [r for r in _core._records if r.status == STATUS_SKIPPED]
assert not skipped, f"Found {len(skipped)} SKIPPED records (pre-registration not removed)"
# Indices are strictly increasing across all records (global-monotonic)
indices = [r.index for r in _core._records]
assert indices == sorted(indices), f"Indices not ascending: {indices}"
assert len(indices) == len(set(indices)), f"Duplicate indices: {indices}"
# No phantom LetInline slot when should_force_let_inline() is False
letinline = [r for r in _core._records if "LetInline" in r.name]
assert not letinline, f"Phantom LetInline records found: {letinline}"
disable()
monkeypatch.delenv("TL_LOWER_TRACE", raising=False)
monkeypatch.delenv("TL_LOWER_TRACE_DIR", raising=False)
def test_terminal_mode_no_html(monkeypatch, tmp_path):
"""TL_LOWER_TRACE=terminal must not produce an HTML report at process exit.
Regression guard for the ``_final_report()`` hook: ``_save_raw_files()``
populates ``_run_dir`` even in terminal-only mode, so without an explicit
``_should_gen_html()`` check the atexit hook would still emit ``report.html``.
"""
from tilelang.tools.lower_trace import enable, disable
from tilelang.backend.pass_pipeline import resolve_pipeline
import tilelang.language as T
monkeypatch.setenv("TL_LOWER_TRACE", "terminal")
monkeypatch.setenv("TL_LOWER_TRACE_DIR", str(tmp_path))
disable()
enable()
try:
@T.prim_func
def tiny(A: T.Tensor((32,), "float32"), B: T.Tensor((32,), "float32")):
with T.Kernel(32):
tid = T.get_thread_binding()
B[tid] = A[tid] + 1.0
mod = tvm.IRModule({"main": tiny})
target = tvm.target.Target("c")
pipeline = resolve_pipeline(target)
pipeline.lower(mod, target)
# Simulate the atexit hook that fires at process exit.
_core._final_report()
script_dir = _core._script_dir
assert script_dir is not None, "script_dir should be set after a run"
symlink_report = os.path.join(script_dir, "report.html")
assert not os.path.exists(symlink_report), f"terminal mode must not write report.html symlink at {symlink_report}"
if _core._run_dir is not None:
run_report = os.path.join(_core._run_dir, "report.html")
assert not os.path.exists(run_report), f"terminal mode must not write report.html at {run_report}"
finally:
disable()
def test_lower_trace_html_on_failure(tmp_path):
"""lower_trace() flushes a partial HTML report even when a pass raises.
Regression guard: previously an exception from ``p(mod)`` aborted the
function before ``generate_html()`` ran, so ``mode='html'``/``'both'``
lost the partial trace of completed passes.
"""
from tilelang.tools.lower_trace import lower_trace
program = _simple_program()
html_path = str(tmp_path / "partial.html")
class _BoomPass:
def __call__(self, mod):
"""Always raise so the traced pass loop hits its failure path."""
raise RuntimeError("intentional boom")
passes = [
("Simplify", _noop_pass()),
("Boom", _BoomPass()),
]
with pytest.raises(RuntimeError, match="intentional boom"):
lower_trace(program, passes, mode="html", html_path=html_path)
assert os.path.exists(html_path), "partial HTML report must be flushed on failure"
with open(html_path) as f:
content = f.read()
assert "Simplify" in content, "completed pass must still appear in partial report"
assert "Boom" in content, "failing pass name must appear in partial report"
assert "FAILED" in content, "failing step must be marked FAILED"
# ---------------------------------------------------------------------------
# Codegen edit-and-recompile (Phase 1: _make_patched_source_module for _without_compile)
# ---------------------------------------------------------------------------
class _MockCodegenModule:
"""Minimal stand-in for a TVM runtime.Module returned by codegen FFIs."""
def __init__(self, source: str):
"""Store the source string to return from inspect_source/get_source."""
self._source = source
def inspect_source(self) -> str:
"""Return the source string captured at construction."""
return self._source
def get_source(self) -> str:
"""Return the source string captured at construction."""
return self._source
def _make_mock_build(source: str):
"""Return a mock codegen FFI that always produces *source*."""
def mock_build(*args, **kwargs):
"""Pretend to compile and return a _MockCodegenModule holding *source*."""
return _MockCodegenModule(source)
return mock_build
class _MockPatchedModule:
"""Stand-in for the CSourceModule returned by _make_patched_source_module."""
def __init__(self, source: str):
"""Store the patched source string to return from get_source."""
self._source = source
def get_source(self) -> str:
"""Return the patched source string."""
return self._source
def inspect_source(self) -> str:
"""Return the patched source string."""
return self._source
def _patched_module_factory(original_module, patched_source):
"""Test replacement for _make_patched_source_module (avoids real TVM C++ FFI)."""
return _MockPatchedModule(patched_source)
def _setup_trace_overrides(tmp_path, mode="terminal"):
"""Set lower_trace overrides for unit testing.
The autouse ``_isolate_env`` fixture calls ``disable()`` before each test,
so overrides can be set safely inside the test body.
"""
_core._mode_override = mode
_core._trace_dir_override = str(tmp_path)
_core._codegen_output_path_override = str(tmp_path / "codegen.cpp")
_core.reset()
def _clear_trace_overrides():
"""Reset overrides (also done by the autouse fixture's ``disable()``)."""
_core._mode_override = _core._UNSET
_core._trace_dir_override = _core._UNSET
_core._codegen_output_path_override = _core._UNSET
_core.reset()
@contextlib.contextmanager
def _patch_make_patched_source_module():
"""Patch _make_patched_source_module so tests avoid the real TVM C++ FFI."""
from unittest.mock import patch
with patch("tilelang.tools.lower_trace.core._make_patched_source_module", side_effect=_patched_module_factory) as mock:
yield mock
def test_codegen_proxy_for_without_compile(tmp_path):
"""*_without_compile FFIs return a patched module when user edits codegen.cpp."""
from tilelang.tools.lower_trace.core import _wrap_codegen_ffi
source_v1 = "// generated kernel v1\n"
mock_build = _make_mock_build(source_v1)
wrapper = _wrap_codegen_ffi(mock_build, "target.build.tilelang_cuda_without_compile")
_setup_trace_overrides(tmp_path)
codegen_path = _core._codegen_output_path_override
with _patch_make_patched_source_module() as mock_factory:
try:
# Run 1: initializes codegen.cpp + .original from codegen output
result1 = wrapper("fake_mod")
assert result1.inspect_source() == source_v1
# Edit codegen.cpp (user edit)
edited = "// edited by user\n"
with open(codegen_path, "w") as f:
f.write(edited)
# Run 2: user edited, codegen unchanged → PATCHED → patched module returned
result2 = wrapper("fake_mod")
assert mock_factory.called, "_make_patched_source_module should be called for _without_compile FFI"
assert result2.get_source() == edited, "Patched module should return the user-edited source"
finally:
_clear_trace_overrides()
def test_codegen_proxy_for_source_only_ffi(tmp_path):
"""Source-only FFIs without a _without_compile suffix (tilelang_c, webgpu) also return patched module."""
from tilelang.tools.lower_trace.core import _wrap_codegen_ffi
source_v1 = "// generated C kernel v1\n"
mock_build = _make_mock_build(source_v1)
wrapper = _wrap_codegen_ffi(mock_build, "target.build.tilelang_c")
_setup_trace_overrides(tmp_path)
codegen_path = _core._codegen_output_path_override
with _patch_make_patched_source_module() as mock_factory:
try:
# Run 1: initializes codegen.cpp + .original
wrapper("fake_mod")
# Edit codegen.cpp
edited = "// edited C kernel\n"
with open(codegen_path, "w") as f:
f.write(edited)
# Run 2: PATCHED → patched module returned (tilelang_c is in _SOURCE_ONLY_CODEGEN_FFIS)
result2 = wrapper("fake_mod")
assert mock_factory.called, "Expected patched module for source-only FFI tilelang_c"
assert result2.get_source() == edited
finally:
_clear_trace_overrides()
def test_codegen_no_proxy_for_full_compile(tmp_path, capsys):
"""Full-compile FFIs return the real module (not patched) + NOTE when user edits codegen.cpp."""
from tilelang.tools.lower_trace.core import _wrap_codegen_ffi
source_v1 = "// generated kernel v1\n"
mock_build = _make_mock_build(source_v1)
wrapper = _wrap_codegen_ffi(mock_build, "target.build.tilelang_cuda")
_setup_trace_overrides(tmp_path)
codegen_path = _core._codegen_output_path_override
target = tvm.target.Target("cuda")
with _patch_make_patched_source_module() as mock_factory:
try:
# Run 1: initializes codegen.cpp + .original
result1 = wrapper("fake_mod", target)
assert result1.inspect_source() == source_v1
# Edit codegen.cpp
edited = "// edited by user\n"
with open(codegen_path, "w") as f:
f.write(edited)
# Run 2: user edited, codegen unchanged → PATCHED
# But full-compile FFI → return real module, NOT patched
capsys.readouterr() # clear prior output
result2 = wrapper("fake_mod", target)
assert not mock_factory.called, "Full-compile FFI must NOT call _make_patched_source_module (would crash tvm_ffi backend)"
assert result2.inspect_source() == source_v1, "Full-compile FFI should return original (unpatched) module"
captured = capsys.readouterr()
assert "NOT recompiled" in captured.out
assert "nvrtc" in captured.out # backend hint
finally:
_clear_trace_overrides()
def test_codegen_conflict_backup(tmp_path):
"""CONFLICT: both user edited and codegen changed → backup + regenerate."""
from tilelang.tools.lower_trace.core import _wrap_codegen_ffi
source_v1 = "// generated kernel v1\n"
mock_build = _make_mock_build(source_v1)
wrapper = _wrap_codegen_ffi(mock_build, "target.build.tilelang_cuda_without_compile")
_setup_trace_overrides(tmp_path)
codegen_path = _core._codegen_output_path_override
original_path = codegen_path + ".original"
with _patch_make_patched_source_module() as mock_factory:
try:
# Run 1: init
wrapper("fake_mod")
# Edit codegen.cpp (user edit)
with open(codegen_path, "w") as f:
f.write("// user edit\n")
# Change codegen output (new wrapper with different source)
source_v2 = "// new codegen output v2\n"
wrapper = _wrap_codegen_ffi(_make_mock_build(source_v2), "target.build.tilelang_cuda_without_compile")
# Run 2: CONFLICT — working != current
result2 = wrapper("fake_mod")
# .bak files created
assert os.path.exists(codegen_path + ".bak"), "User working copy not backed up"
assert os.path.exists(original_path + ".bak"), "Old baseline not backed up"
# codegen.cpp regenerated from new codegen
with open(codegen_path) as f:
assert f.read() == source_v2
# .original advanced to new codegen
with open(original_path) as f:
assert f.read() == source_v2
# No patched module returned (regenerated from new codegen, patched_text=None)
assert not mock_factory.called, "CONFLICT must not call _make_patched_source_module"
assert result2.inspect_source() == source_v2
finally:
_clear_trace_overrides()
def test_codegen_synced(tmp_path):
"""SYNCED: user edits match new codegen output → baseline advances, patched module returned."""
from tilelang.tools.lower_trace.core import _wrap_codegen_ffi
source_v1 = "// generated kernel v1\n"
mock_build = _make_mock_build(source_v1)
wrapper = _wrap_codegen_ffi(mock_build, "target.build.tilelang_cuda_without_compile")
_setup_trace_overrides(tmp_path)
codegen_path = _core._codegen_output_path_override
original_path = codegen_path + ".original"
with _patch_make_patched_source_module() as mock_factory:
try:
# Run 1: init
wrapper("fake_mod")
# Edit codegen.cpp to match what the new codegen will produce
source_v2 = "// new codegen output v2\n"
with open(codegen_path, "w") as f:
f.write(source_v2)
# Change codegen output to the same value
wrapper = _wrap_codegen_ffi(_make_mock_build(source_v2), "target.build.tilelang_cuda_without_compile")
# Run 2: SYNCED
result2 = wrapper("fake_mod")
# .original advanced to new codegen
with open(original_path) as f:
assert f.read() == source_v2
# Patched module returned (patched_text = working_text = source_v2)
assert mock_factory.called, "SYNCED should call _make_patched_source_module for _without_compile FFI"
assert result2.get_source() == source_v2
finally:
_clear_trace_overrides()
def test_codegen_phase_reset_on_inspect_source_failure(tmp_path):
"""_current_phase must be reset even if post-codegen tracing raises.
Regression guard: previously an exception in the codegen post-processing
(inspect_source / file I/O / diff) left _current_phase stuck at "codegen",
misattributing later records. The exception is now caught and warned
(does not propagate), but _current_phase must still be restored.
"""
from tilelang.tools.lower_trace.core import _wrap_codegen_ffi
class _ExplodingModule:
def inspect_source(self):
"""Raise to exercise the post-codegen error-handling path."""
raise RuntimeError("inspect_source blew up")
def mock_build(*args, **kwargs):
"""Return an _ExplodingModule to trigger the inspect_source failure path."""
return _ExplodingModule()
wrapper = _wrap_codegen_ffi(mock_build, "target.build.tilelang_cuda_without_compile")
_setup_trace_overrides(tmp_path)
try:
# The post-codegen tracing exception is caught + warned (not re-raised).
wrapper("fake_mod")
assert _core._current_phase is None, "_current_phase must be reset after inspect_source failure"
finally:
_clear_trace_overrides()
def test_codegen_restores_outer_phase(tmp_path):
"""codegen nested in an active pipeline phase must restore it, not clear to None."""
from tilelang.tools.lower_trace.core import _wrap_codegen_ffi
source_v1 = "// generated kernel v1\n"
wrapper = _wrap_codegen_ffi(_make_mock_build(source_v1), "target.build.tilelang_cuda_without_compile")
_setup_trace_overrides(tmp_path)
try:
_core._current_phase = "pipeline_test"
wrapper("fake_mod")
assert _core._current_phase == "pipeline_test", "outer phase must be restored after codegen"
finally:
_core._current_phase = None
_clear_trace_overrides()
def test_codegen_record_index_after_nested_pass(tmp_path):
"""codegen record index must come after any pass invoked inside original_build.
Regression guard: pre-allocating the codegen idx before ``original_build`` ran
let an internal traced pass (e.g. ``tir.transform.Simplify``) grab a later
index, so records could appear as N+1 before N. The idx is now allocated
immediately before appending the codegen record.
"""
from tilelang.tools.lower_trace.core import _wrap_codegen_ffi, LowerRecord, STATUS_COMPLETED
nested_idx = []
def mock_build(*args, **kwargs):
"""Append an internal traced pass then return a normal mock module."""
with _core._lock:
nested_idx.append(_core._pass_index)
_core._pass_index += 1
_core._records.append(
LowerRecord(
phase="codegen",
name="internal_simplify",
index=nested_idx[0],
before_text="",
after_text="",
changed=False,
add_lines=0,
del_lines=0,
status=STATUS_COMPLETED,
)
)
return _MockCodegenModule("// generated\n")
wrapper = _wrap_codegen_ffi(mock_build, "target.build.tilelang_cuda_without_compile")
_setup_trace_overrides(tmp_path)
try:
wrapper("fake_mod")
codegen_records = [r for r in _core._records if r.name == "codegen"]
assert codegen_records, "no codegen record found"
assert codegen_records[-1].index > nested_idx[0], "codegen index not after nested pass"
indices = [r.index for r in _core._records]
assert indices == sorted(indices), f"records not in ascending index order: {indices}"
finally:
_clear_trace_overrides()
def test_import_time_activation(tmp_path):
"""The env hook in tilelang/__init__.py must activate tracing on first import.
This module clears TL_LOWER_TRACE before importing tilelang, so the rest of
the suite never exercises the import-time activation path. Use a subprocess
to set the env var *before* the first import and verify tracing is on.
"""
import subprocess
import sys
env = dict(os.environ)
env["TL_LOWER_TRACE"] = "1"
env["TL_LOWER_TRACE_DIR"] = str(tmp_path)
code = (
"import tilelang\n"
"from tilelang.tools.lower_trace import core as _core\n"
"assert _core._is_trace_enabled(), 'tracing not enabled at import time'\n"
"assert _core._get_mode() == 'html', 'expected html mode for TL_LOWER_TRACE=1'\n"
)
result = subprocess.run(
[sys.executable, "-c", code],
env=env,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, f"subprocess failed (rc={result.returncode}):\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
if __name__ == "__main__":
tilelang.testing.main()