diff --git a/docs/_static/img/lower_trace_html.png b/docs/_static/img/lower_trace_html.png
new file mode 100644
index 0000000000..dde5a57f74
Binary files /dev/null and b/docs/_static/img/lower_trace_html.png differ
diff --git a/docs/tutorials/debug_tools_for_tilelang.md b/docs/tutorials/debug_tools_for_tilelang.md
index 711128073f..17a7206604 100644
--- a/docs/tutorials/debug_tools_for_tilelang.md
+++ b/docs/tutorials/debug_tools_for_tilelang.md
@@ -194,8 +194,280 @@ C_local inferenced layout:
Index: [_j % 16 // 8 * 4 + _i % 16 // 8 * 2 + _j % 2]
```
+## IR Lower Trace: Full-Pipeline IR Tracking
+
+TileLang programs are lowered through a sequence of compiler *passes*, each of which may transform the IR. Understanding exactly what each pass changes is essential for debugging incorrect transformations, unexpected optimizations, or missing passes.
+
+**IR Lower Trace** is the recommended tool for this task. It transparently captures the IR before and after *every* pass in the compilation pipeline — including the final codegen step that produces C/CUDA/HIP source — and renders a human-readable diff report in the terminal and/or a self-contained HTML page. No code changes are required: enabling a single environment variable is enough.
+
+Compared to the older **Pass Diff** tool (`TILELANG_PASS_DIFF`), IR Lower Trace adds:
+
+- **Phase context** — each pass is tagged with its pipeline phase (e.g. `pipeline_c`, `phase1_...`), so you can tell which backend stage a pass belongs to.
+- **Codegen capture** — the final TIR-to-source lowering is recorded, and the generated C/CUDA/HIP code is dropped to disk for inspection or editing.
+- **Edit-and-recompile workflow** — edit the generated codegen source on disk and rerun; your edits are injected back into compilation (with conflict detection).
+- **Multi-run accumulation** — repeated compilations in the same process are tagged with `run2_`, `run3_`, … prefixes, so you can diff across runs.
+- **Raw `.tir` dumps** — before/after IR for every pass is written to disk, keyed by phase and pass index.
+- **Crash-safe incremental HTML** — the report is flushed after every pass, so partial results survive even if the process crashes.
+- **Enhanced HTML report** — sidebar pass navigation, status dots, phase tabs, `j`/`k` keyboard navigation, `Shift+E` global expand, `F7` manual alignment, dark/light theme.
+
+### Quick Start (Environment Variable)
+
+The simplest way to enable IR Lower Trace is to set the `TL_LOWER_TRACE` environment variable before running your script:
+
+```bash
+# HTML report (default when set to 1/on/true/yes)
+TL_LOWER_TRACE=1 python3 my_script.py
+
+# Colored diff printed to the terminal only
+TL_LOWER_TRACE=terminal python3 my_script.py
+
+# Both terminal output and HTML report
+TL_LOWER_TRACE=both python3 my_script.py
+
+# Disabled (default — zero overhead, no patching)
+python3 my_script.py
+```
+
+When HTML output is enabled, a stable symlink `/report.html` is maintained and points to the latest run's report. Open it directly in a browser:
+
+```bash
+# typical location
+open tmp/lower_trace_dir/my_script/report.html
+```
+
+### Environment Variables
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `TL_LOWER_TRACE` | Enable tracing. Values: `0`/`off`/`false`/`no` (off), `1`/`on`/`true`/`yes` (→ html), `terminal`, `html`, `both` | off |
+| `TL_LOWER_TRACE_DIR` | Base output directory for all trace artifacts | `./tmp/lower_trace_dir` |
+
+### Output Directory Structure
+
+A single run produces the following layout under `TL_LOWER_TRACE_DIR`:
+
+```text
+/
+└── / # derived from sys.argv[0], e.g. "my_script"
+ ├── report.html # symlink → latest run's report
+ ├── codegen.cpp # generated codegen source (editable, see below)
+ ├── codegen.cpp.original # baseline snapshot for edit/recompile workflow
+ ├── codegen.cpp.latest # actual codegen output of the most recent run
+ └── .run_records/
+ └── run__/
+ ├── report.html # this run's full report
+ ├── pipeline_c/ # one subdir per phase (example)
+ │ ├── 00_BindTarget_before.tir
+ │ ├── 00_BindTarget_after.tir
+ │ ├── 01_Simplify_before.tir
+ │ └── 01_Simplify_after.tir
+ ├── phase2_optimize/ # another phase (illustrative)
+ │ └── ...
+ ├── codegen/
+ │ ├── 42_codegen_before.tir
+ │ └── 42_codegen_after.cpp
+ └── unscoped/ # passes outside any pipeline window
+ └── ...
+```
+
+Each phase gets its own subdirectory; passes are numbered globally (`00_`, `01_`, …) so ordering is unambiguous. Codegen records write the *after* artifact as `.cpp` (the generated source), while ordinary passes use `.tir` for both sides.
+
+### Programmatic API
+
+For fine-grained control, IR Lower Trace exposes two layers of API.
+
+#### One-shot: `lower_trace()`
+
+Diff a fixed chain of passes against an IR module without installing any global hook:
+
+```python
+from tilelang.tools import lower_trace as lt
+from tilelang import tvm
+import tilelang.transform as transform
+
+# Diff a single pass
+results = lt.lower_trace(func, transform.Simplify(), mode="terminal")
+
+# Diff a named chain, write an HTML report
+results = lt.lower_trace(
+ func,
+ [
+ ("Annotate", tvm.tirx.transform.AnnotateDeviceRegions()),
+ ("Split", tvm.tirx.transform.SplitHostDevice()),
+ ("ThreadSync", transform.ThreadSync("shared")),
+ ],
+ mode="both",
+ html_path="my_diff.html",
+)
+```
+
+| Parameter | Description |
+|-----------|-------------|
+| `func_or_mod` | A `PrimFunc` or `IRModule` to run passes on |
+| `passes` | A single pass, a list of passes, or a list of `(name, pass)` tuples |
+| `mode` | `"terminal"`, `"html"`, or `"both"` (default: `"terminal"`) |
+| `context` | Number of context lines in the unified diff (default: 3) |
+| `html_path` | Output path for the HTML report (default: `"lower_trace_report.html"`) |
+
+Returns a `list[dict]` with one entry per pass step, each containing `name`, `before_script`, `after_script`, `diff_lines`, `insertions`, `deletions`, and `changed`.
+
+#### Global hook: `enable()` / `disable()` / `reset()`
+
+To trace the *entire* compilation pipeline of a real kernel (what the environment variable does, but programmatically):
+
+```python
+from tilelang.tools import lower_trace as lt
+
+# Enable tracing for the rest of the process.
+lt.enable(mode="both")
+
+# ... run tilelang.compile() / kernel compilation ...
+```
+
+All three parameters of `lt.enable()` are optional — `mode`, `trace_dir`, and `codegen_output` fall back to the `TL_LOWER_TRACE` / `TL_LOWER_TRACE_DIR` env vars (or sensible defaults) when omitted. See the parameter table below for details.
+
+| Parameter | Description |
+|-----------|-------------|
+| `mode` | Force a trace mode: `"terminal"`, `"html"`, `"both"`, or `None` to disable. When omitted, falls back to the `TL_LOWER_TRACE` env var. |
+| `trace_dir` | Base output directory. When omitted, falls back to `TL_LOWER_TRACE_DIR`, then `./tmp/lower_trace_dir`. |
+| `codegen_output` | Path to save the generated codegen source (enables the edit-recompile workflow). When omitted, defaults to `/codegen.cpp`. Pass `None` explicitly to suppress. |
+
+`enable()` is idempotent — calling it multiple times is safe.
+
+##### When to use `reset()` and `disable()`
+
+Both are **optional** and only needed in specific scenarios:
+
+| Function | When to call | What it does |
+|----------|--------------|--------------|
+| `reset()` | Compiling **multiple kernels in the same process** and you want each kernel's report to start fresh (instead of accumulating into one combined report) | Clears collected records while keeping the hook active. Without it, records accumulate across compilations, tagged with `run2_`, `run3_`, … prefixes — which is desirable if you *want* to compare runs side by side. |
+| `disable()` | You want to **disable tracing for subsequent compilations** within the same process (e.g. a long-running service that only traces the first kernel) | Restores the original `Pass.__call__`, `PassPipeline.lower`, and codegen FFIs, and clears all state. |
+
+```python
+from tilelang.tools import lower_trace as lt
+
+lt.enable(mode="both")
+
+# First kernel — traced.
+kernel1 = tilelang.compile(func_a)
+
+# Optional: clear records so kernel2 gets its own clean report.
+# Omit this line if you prefer a combined multi-run report.
+lt.reset()
+
+# Second kernel — traced (into a fresh report if lt.reset() was called).
+kernel2 = tilelang.compile(func_b)
+
+# Optional: disable tracing for any further compilations.
+lt.disable()
+```
+
+:::{note}
+If neither `lt.reset()` nor `lt.disable()` is called, tracing stays active for the lifetime of the process and the final HTML report is generated automatically at exit. This is the simplest workflow and is sufficient for most one-off scripts.
+:::
+
+### HTML Report Features
+
+The HTML report is a single self-contained file (no external assets) providing:
+
+- **Sidebar** with per-pass navigation, status dots (● changed / ○ no-op / ✕ failed / ◆ codegen), and `+`/`−` line-count statistics. Collapsible and drag-resizable.
+- **Phase tabs** to filter passes by pipeline phase.
+- **Summary bar** with clickable filter badges (changed / failed / codegen).
+- **Side-by-side diff** with GitHub-style coloring, word-level inline highlighting, and collapsible context (`↑↓ Expand` buttons reveal hidden equal lines).
+- **Keyboard navigation** — `j`/`k` to move between passes, `Shift+E` to expand/collapse all, `F7` for Beyond-Compare-style manual alignment.
+- **Dark/Light theme toggle** persisted via `localStorage`.
+- **Copy buttons** for before/after IR of any pass.
+- **Error boxes** — a failed pass shows its exception message alongside the IR *before* the crash.
+
+```{figure} ../_static/img/lower_trace_html.png
+:width: 600
+:alt: Screenshot of the IR Lower Trace HTML report
+:align: center
+
+```
+
+### Codegen Source Capture & Edit-Recompile Workflow
+
+When tracing is enabled, the final codegen step (TIR → C/CUDA/HIP/…) is intercepted. The generated source is written to `/codegen.cpp` (or the path passed to `codegen_output=`), so you can inspect — and even edit — the code that will actually be compiled.
+
+To support editing the generated code and re-running with your edits applied, IR Lower Trace maintains **three cooperating files**:
+
+| File | Role |
+|------|------|
+| `codegen.cpp` | **Working copy** — user-editable. This is what gets compiled when you rerun. |
+| `codegen.cpp.original` | **Baseline** — the codegen snapshot the working copy was last synced from. Written only on init or re-sync, never blindly overwritten. |
+| `codegen.cpp.latest` | **Latest codegen output** — the actual output of the most recent run, overwritten every run for diff reference. |
+
+On each run a three-way comparison (baseline / working copy / current codegen output) decides how to proceed:
+
+| Situation | `codegen.cpp` vs `.original` | `.latest` vs `.original` | Action | Console tag |
+|-----------|------------------------------|--------------------------|--------|-------------|
+| No change | identical | identical | Compile with codegen output as-is | — |
+| Codegen changed only | identical | **differs** | Regenerate `codegen.cpp` and `.original` from new codegen | `REGENERATED` |
+| User edited only | **differs** | identical | Inject the working copy (`PATCHED`) | `PATCHED` |
+| Both changed, working == latest | **differs** | **differs** (working matches latest) | Advance baseline; use working copy | `SYNCED` |
+| Both changed, working != latest | **differs** | **differs** (working differs from latest) | **CONFLICT** — back up working copy → `.bak` (*conflict backup*) and old baseline → `.original.bak`, then regenerate from new codegen | `CONFLICT` |
+| First run (no baseline) | — | — | Initialise `.original`, copy to `codegen.cpp` | (init) |
+| `codegen.cpp` exists without baseline | — | — | Back up pre-existing `codegen.cpp` → `.bak` (*safety backup*), then initialise baseline | `INIT-BACKUP` |
+
+> **Note on `.bak` files:** The backups created by `CONFLICT` and `INIT-BACKUP` serve different purposes. `INIT-BACKUP` preserves a pre-existing `codegen.cpp` of unknown origin before the trace tool takes it over. `CONFLICT` preserves the user's edits before a codegen change overwrites them. Recover `CONFLICT` edits with `diff codegen.cpp.original.bak codegen.cpp.bak`.
+
+#### Typical Workflow
+
+1. **Inspect** — Run once with `TL_LOWER_TRACE=1`. Open `codegen.cpp` to read the generated source.
+2. **Edit** — Modify `codegen.cpp` (e.g. add a `printf`, tweak a loop). Do *not* touch `.original`.
+3. **Rerun** — Run again. Because `codegen.cpp` differs from `.original` but codegen output is unchanged, you'll see `PATCHED from …/codegen.cpp` and your edited source is compiled.
+4. **Iterate** — Keep editing and rerunning. Each run re-injects your working copy.
+5. **If codegen itself changes** (e.g. you modified the TileLang program) — two outcomes:
+ - If your edits happen to match the new codegen output → `SYNCED` (baseline advances, your edits preserved).
+ - If both your edits and codegen changed and they differ → `CONFLICT`. Your working copy is backed up to `codegen.cpp.bak` and the old baseline to `codegen.cpp.original.bak`. Recover your edits with `diff codegen.cpp.original.bak codegen.cpp.bak`, then re-apply them against the freshly regenerated `codegen.cpp`.
+
+:::{note}
+**Backend requirements for edit-and-recompile.** The edit-and-recompile workflow requires a source-compiling execution backend — `nvrtc`, `cython`, or `cutedsl`. These backends use `*_without_compile` codegen FFIs that produce source-only modules, then compile the (edited) source string at runtime via NVRTC / Cython / CuTeDSL.
+
+The default `tvm_ffi` backend pre-compiles device code to a binary (PTX/hsaco) from TIR during codegen. When the `tvm_ffi` backend is active and you edit `codegen.cpp`, you'll see a `NOTE` message indicating that your edits are recorded in the trace for diff viewing but were **not recompiled**. To use edit-and-recompile, switch to a source-compiling backend:
+
+```python
+# For CUDA targets:
+tilelang.compile(..., execution_backend="nvrtc")
+
+# For HIP targets:
+tilelang.compile(..., execution_backend="cython")
+```
+:::
+
+:::{note}
+The `codegen_output` path defaults to `/codegen.cpp` when tracing is enabled. To disable codegen-to-disk entirely, pass `codegen_output=None` to `enable()`.
+:::
+
+### How It Works
+
+IR Lower Trace installs three layers of transparent hooks (all via `monkey-patch`, restored by `disable()`):
+
+1. **`tvm.ir.transform.Pass.__call__`** — every pass invocation is intercepted to capture `str(mod)` before and after, compute `+`/`−` line counts, and append a `LowerRecord`. Passes that run outside any pipeline window are tagged with the `unscoped` phase.
+2. **`PassPipeline.lower`** (new architecture) or **phase functions** (legacy architecture) — sets the current phase context so passes invoked within a pipeline run are grouped under a label like `pipeline_c`. Legacy phase functions are discovered via AST scanning (`_discover_passes`) and bytecode inspection.
+3. **Codegen FFI** (`target.build.tilelang_cuda`, `…_hip`, `…_c`, `…_llvm`, etc.) — captures the final TIR → source lowering and drives the three-file edit-recompile workflow described above.
+
+Pass records are appended **at runtime** (not pre-registered), so conditional passes that are skipped at runtime — e.g. `LetInline` when `should_force_let_inline()` is `False` — simply do not appear, leaving no phantom/skipped slots. The HTML report is flushed **incrementally** after every pass (O(n) total cost), so partial results survive even a crash or `SIGKILL`.
+
+When the same process compiles multiple kernels, each `PassPipeline.lower` invocation increments a run counter and tags phases with a `run2_`, `run3_`, … prefix; all records accumulate into a single report so you can compare runs side by side.
+
+### Tips
+
+- **Use `terminal` mode for quick checks** — the colored diff prints as passes run, so you can see changes in real time.
+- **Use `html` mode for thorough analysis** — navigate across many passes, expand hidden context, and copy IR snippets.
+- **Combine with `TL_LOWER_TRACE_DIR`** to direct reports to a specific location, e.g. when running in CI or comparing across runs.
+- **The hook captures all passes** in the lowering pipeline, including those triggered internally by `tilelang.compile()`. This makes it useful for understanding the full compilation flow.
+- **If you previously used `TILELANG_PASS_DIFF`**, switch to `TL_LOWER_TRACE` — it is a strict superset and is the tool that receives future improvements.
+
## Pass Diff: Observing IR Changes Across Passes
+:::{admonition} Superseded — use IR Lower Trace
+:class: warning
+
+This tool has been superseded by **IR Lower Trace** (`TL_LOWER_TRACE`) documented above. New users should use IR Lower Trace directly — it provides phase context, codegen capture, multi-run accumulation, and an enhanced HTML report. `TILELANG_PASS_DIFF` is retained only for backward compatibility.
+:::
+
TileLang programs are lowered through a sequence of compiler *passes*, each of which may transform the IR. Understanding exactly what each pass changes is essential for debugging incorrect transformations, unexpected optimizations, or missing passes.
TileLang provides a built-in **Pass Diff** tool that automatically captures the IR before and after every pass and generates a human-readable diff report. It works transparently — no code changes are required.
@@ -388,7 +660,7 @@ A complete example is available in `examples/autodd/`:
## Conclusion
-By carefully examining intermediate representations (IR) before final code generation—and by leveraging runtime printing through `T.print`—one can quickly diagnose where index calculations, copy logic, or other kernel operations deviate from the intended behavior. The **Pass Diff** tool complements this by providing automatic, pass-by-pass visibility into every IR transformation, making it easy to pinpoint exactly which pass introduces an unexpected change. This three-pronged approach (inspecting IR transformations, observing pass-level diffs, and using runtime prints) is often sufficient for resolving generation and correctness issues in TileLang programs.
+By carefully examining intermediate representations (IR) before final code generation—and by leveraging runtime printing through `T.print`—one can quickly diagnose where index calculations, copy logic, or other kernel operations deviate from the intended behavior. The **IR Lower Trace** tool (`TL_LOWER_TRACE`) complements this by providing automatic, pass-by-pass visibility into every IR transformation — including the final codegen step — making it easy to pinpoint exactly which pass introduces an unexpected change. (The older **Pass Diff** tool is retained for backward compatibility but is superseded by IR Lower Trace.) This three-pronged approach (inspecting IR transformations, observing pass-level diffs, and using runtime prints) is often sufficient for resolving generation and correctness issues in TileLang programs.
For complex programs where manual debugging is tedious, **AutoDD** provides automated delta debugging to quickly isolate the minimal code that reproduces a bug.
diff --git a/testing/python/debug/test_lower_trace.py b/testing/python/debug/test_lower_trace.py
new file mode 100644
index 0000000000..b9d6bf7d43
--- /dev/null
+++ b/testing/python/debug/test_lower_trace.py
@@ -0,0 +1,756 @@
+# 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.
+os.environ.pop("TL_LOWER_TRACE", None)
+os.environ.pop("TL_LOWER_TRACE_DIR", None)
+
+import tilelang
+import tilelang.testing
+import tilelang.language as T
+from tilelang import tvm
+from tilelang.tools.lower_trace import core as _core
+
+
+@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():
+ 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():
+ from tilelang.tools.lower_trace import enable, disable
+
+ enable()
+ disable()
+
+
+def test_lower_trace_html():
+ 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():
+ 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():
+ 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):
+ 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():
+ 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"
]*>(.*?)
", html, re.S):
+ for side, txt in re.findall(r'
]*>(\d*)
', 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) == '
'
+
+
+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):
+ 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):
+ self._source = source
+
+ def inspect_source(self) -> str:
+ return self._source
+
+ def get_source(self) -> str:
+ return self._source
+
+
+def _make_mock_build(source: str):
+ """Return a mock codegen FFI that always produces *source*."""
+
+ def mock_build(*args, **kwargs):
+ return _MockCodegenModule(source)
+
+ return mock_build
+
+
+class _MockPatchedModule:
+ """Stand-in for the CSourceModule returned by _make_patched_source_module."""
+
+ def __init__(self, source: str):
+ self._source = source
+
+ def get_source(self) -> str:
+ return self._source
+
+ def inspect_source(self) -> str:
+ 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 RuntimeError("inspect_source blew up")
+
+ def mock_build(*args, **kwargs):
+ 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):
+ 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()
diff --git a/tilelang/__init__.py b/tilelang/__init__.py
index a0d1a8eb16..a515e99679 100644
--- a/tilelang/__init__.py
+++ b/tilelang/__init__.py
@@ -214,6 +214,12 @@ def _load_tile_lang_lib():
from . import rocm as rocm # noqa: F401
from . import metal as metal # noqa: F401
+ _lt_value = os.environ.get("TL_LOWER_TRACE", "")
+ if _lt_value and _lt_value.lower().strip() not in ("0", "false", "no", "off", ""):
+ from .tools.lower_trace import enable as _lower_trace_enable
+
+ _lower_trace_enable()
+ del _lt_value
del _lazy_load_lib
# Install pass diff hook if TILELANG_PASS_DIFF is enabled (zero overhead when off)
diff --git a/tilelang/tools/__init__.py b/tilelang/tools/__init__.py
index 7a8bde5140..6fd4e98f41 100644
--- a/tilelang/tools/__init__.py
+++ b/tilelang/tools/__init__.py
@@ -1,2 +1,12 @@
from .plot_layout import plot_layout # noqa: F401
from .Analyzer import *
+
+
+def __getattr__(name):
+ if name == "lower_trace":
+ import importlib
+
+ mod = importlib.import_module(".lower_trace", __name__)
+ globals()["lower_trace"] = mod
+ return mod
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/tilelang/tools/lower_trace/__init__.py b/tilelang/tools/lower_trace/__init__.py
new file mode 100644
index 0000000000..696978024d
--- /dev/null
+++ b/tilelang/tools/lower_trace/__init__.py
@@ -0,0 +1,192 @@
+"""IR Lower Trace — zero-intrusion debug tool for visualizing compilation passes.
+
+Generates a self-contained HTML page and/or terminal diff showing all passes in the
+compilation pipeline with side-by-side IR diff for each pass.
+
+Usage::
+
+ TL_LOWER_TRACE=1 python my_kernel.py # HTML report
+ TL_LOWER_TRACE=terminal python my_kernel.py # terminal diff only
+ TL_LOWER_TRACE=both python my_kernel.py # both terminal and HTML
+
+Programmatic API::
+
+ from tilelang.tools.lower_trace import lower_trace
+
+ lower_trace(func, my_pass, mode="terminal")
+ lower_trace(func, [pass_a, pass_b], mode="both", html_path="diff.html")
+"""
+
+from __future__ import annotations
+
+from .core import (
+ enable,
+ disable,
+ reset,
+ LowerRecord,
+ STATUS_COMPLETED,
+ STATUS_FAILED,
+ STATUS_SKIPPED,
+ STATUS_CODEGEN,
+ _get_pass_display_name,
+)
+
+__all__ = [
+ "STATUS_CODEGEN",
+ "STATUS_COMPLETED",
+ "STATUS_FAILED",
+ "STATUS_SKIPPED",
+ "LowerRecord",
+ "disable",
+ "enable",
+ "lower_trace",
+ "reset",
+]
+
+
+def lower_trace(
+ func_or_mod,
+ passes,
+ *,
+ mode: str = "terminal",
+ context: int = 3,
+ html_path: str = "lower_trace_report.html",
+) -> list[dict]:
+ """Compare IR before and after each pass in a chain.
+
+ Parameters
+ ----------
+ func_or_mod : PrimFunc or IRModule
+ The starting IR.
+ passes : Pass or list[Pass] or list[tuple[str, Pass]]
+ A single pass, a list of passes, or a list of (name, pass) pairs.
+ mode : {"terminal", "html", "both"}
+ Output mode.
+ context : int
+ Number of context lines in the unified diff (default 3).
+ html_path : str
+ Output path for HTML report (default ``lower_trace_report.html``).
+
+ Returns
+ -------
+ list[dict]
+ One entry per pass step, each containing:
+ ``name``, ``before_script``, ``after_script``, ``diff_lines``,
+ ``insertions``, ``deletions``, ``changed``.
+ """
+ from tilelang import tvm
+ from .diff import unified_diff
+
+ mode = str(mode).strip().lower()
+ if mode not in ("terminal", "html", "both"):
+ raise ValueError(f"mode must be one of 'terminal', 'html', 'both', got {mode!r}")
+
+ if isinstance(func_or_mod, tvm.IRModule):
+ mod = func_or_mod
+ else:
+ mod = tvm.IRModule({"main": func_or_mod})
+
+ # A bare ``(name, pass)`` tuple is a single named pass, not an iterable of
+ # passes; wrap it so the loop below treats it as one entry. A non-list/tuple
+ # value (a bare Pass) is likewise wrapped. A list/tuple of passes (or of
+ # named pairs) is iterated as-is.
+ if (isinstance(passes, tuple) and len(passes) == 2 and isinstance(passes[0], str)) or not isinstance(passes, (list, tuple)):
+ passes = [passes]
+
+ named_passes: list[tuple[str, object]] = []
+ for p in passes:
+ if isinstance(p, (list, tuple)) and len(p) == 2:
+ named_passes.append((str(p[0]), p[1]))
+ else:
+ named_passes.append((_get_pass_display_name(p), p))
+
+ results: list[dict] = []
+
+ try:
+ for step_idx, (name, p) in enumerate(named_passes, 1):
+ before_script = mod.script()
+ try:
+ mod = p(mod)
+ except Exception as e:
+ results.append(
+ {
+ "name": name,
+ "before_script": before_script,
+ "after_script": "",
+ "diff_lines": [],
+ "insertions": 0,
+ "deletions": 0,
+ "changed": False,
+ "error": str(e),
+ }
+ )
+ raise
+
+ after_script = mod.script()
+
+ diff_text = unified_diff(
+ before_script,
+ after_script,
+ before_label=f"step {step_idx} before",
+ after_label=f"step {step_idx} after",
+ context=context,
+ color=False,
+ )
+ diff_lines = diff_text.splitlines() if diff_text else []
+
+ insertions = sum(1 for d in diff_lines if d.startswith("+") and not d.startswith("+++"))
+ deletions = sum(1 for d in diff_lines if d.startswith("-") and not d.startswith("---"))
+ changed = insertions > 0 or deletions > 0
+
+ step_result = {
+ "name": name,
+ "before_script": before_script,
+ "after_script": after_script,
+ "diff_lines": diff_lines,
+ "insertions": insertions,
+ "deletions": deletions,
+ "changed": changed,
+ }
+ results.append(step_result)
+
+ if mode in ("terminal", "both"):
+ header = f"\n{'=' * 60}\n Pass {step_idx}: {name}\n{'=' * 60}\n"
+ print(header)
+ if changed:
+ colored = unified_diff(
+ before_script,
+ after_script,
+ before_label=f"step {step_idx} before",
+ after_label=f"step {step_idx} after",
+ context=context,
+ color=True,
+ )
+ print(colored, end="")
+ print(f"\n >>> +{insertions} insertion(s), -{deletions} deletion(s)")
+ else:
+ print(" (no changes)")
+ finally:
+ if mode in ("html", "both"):
+ from .html import generate_html
+
+ records = []
+ for i, r in enumerate(results):
+ failed = "error" in r
+ records.append(
+ LowerRecord(
+ phase="lower_trace",
+ name=r["name"],
+ index=i,
+ before_text=r["before_script"],
+ after_text=r["after_script"],
+ changed=r["changed"],
+ add_lines=r["insertions"],
+ del_lines=r["deletions"],
+ status=STATUS_FAILED if failed else STATUS_COMPLETED,
+ error_msg=r.get("error", ""),
+ )
+ )
+ generate_html(records, html_path)
+ print(f"\nHTML report written to: {html_path}")
+
+ return results
diff --git a/tilelang/tools/lower_trace/core.py b/tilelang/tools/lower_trace/core.py
new file mode 100644
index 0000000000..7777d18b68
--- /dev/null
+++ b/tilelang/tools/lower_trace/core.py
@@ -0,0 +1,1287 @@
+"""IR lower trace — zero-intrusion debug tool for visualizing compilation passes.
+
+Monkey-patches ``tvm.ir.transform.Pass.__call__`` and ``PassPipeline.lower``
+to automatically capture IR before/after every pass and generate diff reports.
+
+This module has **no dependency on ``tilelang.env``**; configuration is read
+from ``os.environ`` directly, or passed programmatically via ``enable()``.
+
+Supports two architectures:
+- New: ``PassPipeline.lower`` (each backend registers a pipeline object)
+- Old: phase-based functions called from ``tilelang.engine.lower``
+
+Usage::
+
+ TL_LOWER_TRACE=1 python my_kernel.py # HTML report
+ TL_LOWER_TRACE=terminal python my_kernel.py # terminal diff only
+ TL_LOWER_TRACE=both python my_kernel.py # both terminal and HTML
+"""
+
+from __future__ import annotations
+
+import ast
+import contextlib
+import difflib
+import dis
+import functools
+import inspect
+import os
+import re
+import shutil
+import sys
+import threading
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from .diff import (
+ _ANSI_BOLD,
+ _ANSI_BLUE,
+ _ANSI_CYAN,
+ _ANSI_DIM,
+ _ANSI_GREEN,
+ _ANSI_RED,
+ _ANSI_RESET,
+ _ANSI_YELLOW,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+STATUS_COMPLETED = "completed"
+STATUS_FAILED = "failed"
+STATUS_SKIPPED = "skipped"
+STATUS_CODEGEN = "codegen"
+
+
+def _get_tvm_ffi():
+ """FFI facade exposing ``get_global_func``/``register_global_func``.
+
+ Prefers the new-style ``tvm.ffi`` (unified FFI); falls back to the legacy
+ ``tvm._ffi`` shipped in ``3rdparty/tvm``, where the register entry is named
+ ``register_func`` instead of ``register_global_func``.
+ """
+ try:
+ import tvm.ffi as _ffi
+
+ if hasattr(_ffi, "register_global_func") and hasattr(_ffi, "get_global_func"):
+ return _ffi
+ except ImportError:
+ pass
+ import tvm._ffi as _ffi
+
+ class _LegacyFFI:
+ """Adapter exposing the ``register_global_func``/``get_global_func`` API over the legacy ``tvm._ffi`` (where registration is named ``register_func``)."""
+
+ get_global_func = staticmethod(_ffi.get_global_func)
+
+ @staticmethod
+ def register_global_func(name, func=None, override=False):
+ """Register a global FFI function via the legacy ``tvm._ffi.register_func`` entry point."""
+ return _ffi.register_func(name, func, override=override)
+
+ return _LegacyFFI()
+
+
+def _inspect_module_source(mod):
+ """Return the source text of a ``tvm.runtime.Module``.
+
+ Prefers the newer ``inspect_source``; falls back to the legacy ``get_source``
+ shipped with ``3rdparty/tvm``. Returns ``""`` only when neither hook is
+ available, so downstream string handling stays safe.
+
+ Exceptions raised by an *available* hook propagate to the caller — this is
+ required so codegen tracing can observe failures (e.g. a module whose
+ ``inspect_source`` raises) and reset the current phase correctly. The
+ fallback to ``get_source`` only happens when the preferred attribute is
+ missing or not callable.
+ """
+ for _attr in ("inspect_source", "get_source"):
+ _fn = getattr(mod, _attr, None)
+ if callable(_fn):
+ return _fn() or ""
+ return ""
+
+
+@dataclass
+class LowerRecord:
+ """Result of running a single pass."""
+
+ phase: str
+ name: str
+ index: int
+ before_text: str
+ after_text: str
+ changed: bool
+ add_lines: int = 0
+ del_lines: int = 0
+ status: str = STATUS_COMPLETED
+ error_msg: str = ""
+
+
+_records: list[LowerRecord] = []
+_section_cache: dict[tuple[str, int], str] = {}
+_original_pass_call: Callable | None = None
+_original_pipeline_lower: object | None = None
+_original_codegen_ffis: dict[str, Callable] = {}
+_legacy_patched: bool = False
+# (target, attr_name, original_or_MISSING, is_dict) — restored by disable()
+_legacy_phase_originals: list[tuple[object, str, object, bool]] = []
+_MISSING: object = object()
+
+_CODEGEN_FFI_NAMES: list[str] = [
+ "target.build.tilelang_cuda",
+ "target.build.tilelang_cuda_without_compile",
+ "target.build.tilelang_cutedsl",
+ "target.build.tilelang_cutedsl_without_compile",
+ "target.build.tilelang_hip",
+ "target.build.tilelang_hip_without_compile",
+ "target.build.tilelang_metal",
+ "target.build.tilelang_c",
+ "target.build.tilelang_c_host",
+ "target.build.tilelang_ascend",
+ "target.build.tilelang_ascend_pto",
+ "target.build.llvm",
+ "target.build.webgpu",
+ "target.build.tilelang_cpp",
+ "target.build.tilelang_webgpu",
+]
+
+# FFIs whose returned module is consumed *only* via ``get_source()`` — i.e.
+# the module holds source text (C / WGSL / etc.), not a compiled binary, and is
+# never passed to ``host_mod.import_module()``. For these FFIs a real
+# ``CSourceModule`` rebuilt from the user-edited source can be returned in
+# place of the original module so that the downstream JIT adapter (NVRTC /
+# Cython / CuTeDSL / bisheng) recompiles the user-edited source.
+#
+# Membership is determined by tracing the call sites in
+# ``tilelang.engine.lower``:
+# - ``*_without_compile`` FFIs are only called from ``device_codegen_without_compile``
+# (which sets ``enable_device_compile=False`` → ``enable_host_codegen=False``),
+# so the result is never passed to ``import_module``.
+# - ``tilelang_cpp`` and ``tilelang_webgpu`` produce ``CSourceModule`` / ``WebGPUModule``
+# (source-only, no binary compilation step); they are likewise only called from
+# ``device_codegen_without_compile``.
+# - ``tilelang_metal`` is dual-use: called from both ``device_codegen``
+# (full-compile → ``import_module``) and ``device_codegen_without_compile``
+# (source-only). Since the wrapper cannot distinguish the two call sites
+# at runtime, it is conservatively excluded.
+# - ``llvm``, ``tilelang_c_host`` are excluded because they may be reached
+# via ``host_codegen`` → ``import_module`` (the module's binary or source
+# is consumed by the host runtime module tree).
+# - ``tilelang_cuda``, ``tilelang_hip``, ``tilelang_cutedsl`` (full-compile
+# variants) produce binary modules consumed via ``import_module``.
+# - ``tilelang_ascend`` and ``tilelang_ascend_pto`` are called from
+# ``device_codegen`` (not ``device_codegen_without_compile``), but
+# ``BuildTileLangAscend`` returns a ``CSourceModuleCreate(code, "c", ...)``
+# — a *source-only* module, not a binary. ``tilelang.engine.lower`` only
+# calls ``.get_source()`` on it and never ``import_module()``; the
+# ``CythonKernelAdapter`` recompiles that source string via the ``bisheng``
+# compiler. Hence a ``CSourceModule`` rebuilt from the patched source is
+# safe and user edits get recompiled.
+#
+# New FFIs default to *not* being in this set (conservative: return real module
+# + NOTE), and must be explicitly added here once their call chain is verified
+# to be source-only.
+_SOURCE_ONLY_CODEGEN_FFIS: frozenset[str] = frozenset(
+ {
+ "target.build.tilelang_cuda_without_compile",
+ "target.build.tilelang_cutedsl_without_compile",
+ "target.build.tilelang_hip_without_compile",
+ "target.build.tilelang_c",
+ "target.build.webgpu",
+ "target.build.tilelang_cpp",
+ "target.build.tilelang_webgpu",
+ "target.build.tilelang_ascend",
+ "target.build.tilelang_ascend_pto",
+ }
+)
+_current_phase: str | None = None
+_pass_index: int = 0
+_auto_flush: bool = False
+_script_dir: str | None = None
+_run_dir: str | None = None
+_lock = threading.RLock()
+_run_counter: int = 0
+_atexit_registered: bool = False
+
+_UNSET: object = object()
+_mode_override: str | None | object = _UNSET
+_trace_dir_override: str | None | object = _UNSET
+_codegen_output_path_override: str | None | object = _UNSET
+
+# Phase label used for passes that run outside any PassPipeline.lower window
+# (e.g. pre-pipeline module passes and tvm.build postproc), so they are still
+# captured by the global Pass.__call__ hook.
+_UNSCOPED_PHASE = "unscoped"
+
+
+def _parse_lower_trace_mode(value: str | None) -> str | None:
+ """Parse a TL_LOWER_TRACE-style value into a mode string."""
+ if value is None:
+ return None
+ v = value.lower().strip()
+ if v in ("", "0", "false", "no", "off"):
+ return None
+ if v in ("1", "true", "yes", "on"):
+ return "html"
+ if v in ("terminal", "html", "both"):
+ return v
+ return "html"
+
+
+def _get_mode() -> str | None:
+ """Return the effective trace mode, preferring the programmatic override then the env var."""
+ if _mode_override is not _UNSET:
+ return _mode_override # type: ignore[return-value]
+ return _parse_lower_trace_mode(os.environ.get("TL_LOWER_TRACE"))
+
+
+def _is_trace_enabled() -> bool:
+ """Return True when tracing is currently active (mode is not None)."""
+ return _get_mode() is not None
+
+
+def _should_print_terminal() -> bool:
+ """Return True when the terminal diff output should be produced."""
+ mode = _get_mode()
+ return mode in ("terminal", "both")
+
+
+def _should_gen_html() -> bool:
+ """Return True when the HTML report should be produced."""
+ mode = _get_mode()
+ return mode in ("html", "both")
+
+
+def _get_base_trace_dir() -> str:
+ """Return the configured base trace directory (first level)."""
+ if _trace_dir_override is not _UNSET and _trace_dir_override:
+ return _trace_dir_override # type: ignore[return-value]
+ return os.environ.get("TL_LOWER_TRACE_DIR") or os.path.join(".", "tmp", "lower_trace_dir")
+
+
+def _ensure_script_dir() -> str:
+ """Return ``//`` (created on first call, stable across runs)."""
+ global _script_dir
+
+ if _script_dir is not None:
+ return _script_dir
+
+ base_dir = _get_base_trace_dir()
+ script_name = os.path.splitext(os.path.basename(sys.argv[0]))[0] or "kernel"
+ _script_dir = os.path.join(base_dir, script_name)
+
+ os.makedirs(_script_dir, exist_ok=True)
+ return _script_dir
+
+
+def _ensure_run_dir() -> str:
+ """Return ``/.run_records/run__/`` (new per run)."""
+ global _run_dir
+
+ if _run_dir is not None:
+ return _run_dir
+
+ from datetime import datetime
+
+ script_dir = _ensure_script_dir()
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
+ _run_dir = os.path.join(script_dir, ".run_records", f"run_{timestamp}_{os.getpid()}")
+
+ os.makedirs(_run_dir, exist_ok=True)
+ return _run_dir
+
+
+def _update_html_symlink(run_html_path: str):
+ """Create/refresh ``/report.html`` → ``run_html_path``.
+
+ On platforms where ``os.symlink`` fails (e.g. Windows without privileges),
+ falls back to copying the file and prints a one-time warning.
+ """
+ script_dir = _ensure_script_dir()
+ link_path = os.path.join(script_dir, "report.html")
+ try:
+ if os.path.islink(link_path) or os.path.exists(link_path):
+ os.remove(link_path)
+ os.symlink(os.path.relpath(run_html_path, script_dir), link_path)
+ except OSError:
+ import shutil
+
+ shutil.copyfile(run_html_path, link_path)
+
+
+def _get_codegen_output_path() -> str | None:
+ """Return the configured codegen source output path, or None when tracing is off."""
+ if _codegen_output_path_override is not _UNSET:
+ return _codegen_output_path_override
+ if _is_trace_enabled():
+ script_dir = _ensure_script_dir()
+ return os.path.join(script_dir, "codegen.cpp")
+ return None
+
+
+def _safe_filename_component(name: str) -> str:
+ """Sanitize a record-derived name for use as a path component.
+
+ Replaces path separators and other filesystem-unsafe characters so that a
+ custom pass/phase name cannot escape its phase subdirectory (CWE-22).
+ """
+ return re.sub(r"[^A-Za-z0-9._-]", "_", str(name))
+
+
+def _save_raw_files(record: LowerRecord):
+ """Write before/after files to disk (phase subdirectory layout).
+
+ For codegen records the *after* text is C++ source, so we write ``*.cpp``
+ instead of ``*.tir``.
+
+ Persistence is best-effort: any filesystem failure (unwritable
+ ``TL_LOWER_TRACE_DIR``, bad filename, transient error) degrades to a
+ warning so the observational tracing flow never aborts compilation.
+ """
+ try:
+ trace_dir = _ensure_run_dir()
+ phase_dir = os.path.join(trace_dir, _safe_filename_component(record.phase))
+ os.makedirs(phase_dir, exist_ok=True)
+
+ prefix = f"{record.index:02d}_{_safe_filename_component(record.name)}"
+ before_ext = ".tir"
+ after_ext = ".cpp" if record.status == STATUS_CODEGEN else ".tir"
+ with open(os.path.join(phase_dir, f"{prefix}_before{before_ext}"), "w", encoding="utf-8") as f:
+ f.write(record.before_text)
+ with open(os.path.join(phase_dir, f"{prefix}_after{after_ext}"), "w", encoding="utf-8") as f:
+ f.write(record.after_text)
+ except Exception as exc:
+ print(f" {_ANSI_RED}[lower_trace] WARNING: could not save raw trace files: {exc}{_ANSI_RESET}")
+
+
+def _get_pass_display_name(pass_obj) -> str:
+ """Extract display name from pass_info.name, e.g. 'tir.Simplify' -> 'Simplify'."""
+ try:
+ name = str(pass_obj.info.name)
+ return name.split(".")[-1] if "." in name else name
+ except Exception:
+ return type(pass_obj).__name__
+
+
+def _incremental_flush_html():
+ """Write the current HTML report incrementally.
+
+ Passes ``_section_cache`` to ``generate_html`` so that already-rendered
+ pass sections (and their computed diffs) are reused instead of being
+ recomputed on every flush. Only newly recorded passes incur the diff
+ cost, keeping the total tracing overhead O(n) rather than O(n^2).
+ """
+ if not _should_gen_html() or not _records or not _run_dir:
+ return
+
+ from .html import generate_html
+
+ html_path = os.path.join(_run_dir, "report.html")
+ generate_html(_records, html_path, section_cache=_section_cache)
+ _update_html_symlink(html_path)
+
+
+def _traced_pass_call(self, mod):
+ """Intercept all Pass.__call__ invocations to record before/after IR.
+
+ Captures every pass invocation globally (matching pass_diff's hook),
+ including those outside any PassPipeline.lower window (pre-pipeline module
+ passes, tvm.build postproc). Records are appended at runtime with the
+ pass's actual display name, eliminating the prior index-based pre-registration
+ that could drift when conditional passes (e.g. LetInline) were skipped.
+ """
+ global _pass_index
+
+ if not _is_trace_enabled():
+ return _original_pass_call(self, mod)
+
+ phase = _current_phase or _UNSCOPED_PHASE
+ gen_html = _should_gen_html()
+ if gen_html:
+ _ensure_run_dir()
+ before_text = str(mod)
+
+ with _lock:
+ idx = _pass_index
+ _pass_index += 1
+
+ try:
+ result = _original_pass_call(self, mod)
+ except Exception as e:
+ with _lock:
+ record = LowerRecord(
+ phase=phase,
+ name=_get_pass_display_name(self),
+ index=idx,
+ before_text=before_text,
+ after_text="",
+ changed=False,
+ add_lines=0,
+ del_lines=0,
+ status=STATUS_FAILED,
+ error_msg=str(e),
+ )
+ _records.append(record)
+ _save_raw_files(record)
+ print(f" {_ANSI_RED}[lower_trace] {phase}/{idx:02d}_{record.name}: FAILED ({e}){_ANSI_RESET}")
+ if gen_html:
+ with contextlib.suppress(Exception):
+ _incremental_flush_html()
+ raise
+
+ after_text = str(result)
+ changed = before_text != after_text
+
+ pass_name = _get_pass_display_name(self)
+
+ add_count = del_count = 0
+ if changed:
+ sm = difflib.SequenceMatcher(None, before_text.splitlines(), after_text.splitlines())
+ for tag, i1, i2, j1, j2 in sm.get_opcodes():
+ if tag == "insert":
+ add_count += j2 - j1
+ elif tag == "delete":
+ del_count += i2 - i1
+ elif tag == "replace":
+ add_count += j2 - j1
+ del_count += i2 - i1
+
+ with _lock:
+ record = LowerRecord(
+ phase=phase,
+ name=pass_name,
+ index=idx,
+ before_text=before_text,
+ after_text=after_text,
+ changed=changed,
+ add_lines=add_count,
+ del_lines=del_count,
+ status=STATUS_COMPLETED,
+ )
+ _records.append(record)
+ _save_raw_files(record)
+ tag = "CHANGED" if changed else "NO-OP"
+ tag_color = _ANSI_GREEN if changed else _ANSI_DIM
+ print(f" [lower_trace] {phase}/{idx:02d}_{pass_name}: {tag_color}{tag}{_ANSI_RESET}")
+
+ if gen_html:
+ with contextlib.suppress(Exception):
+ _incremental_flush_html()
+
+ if _should_print_terminal() and changed:
+ from .diff import print_diff
+
+ label = f"{phase}/{pass_name}"
+ print_diff(before_text, after_text, f"{label} (before)", f"{label} (after)")
+
+ return result
+
+
+def _extract_pass_name_from_attr_chain(node: ast.expr) -> str | None:
+ """Walk an attribute chain (e.g. tilelang.transform.Simplify) and extract pass name.
+
+ Returns the pass name (e.g. 'Simplify') if the chain contains a 'transform' segment
+ followed by an uppercase CamelCase name. Returns None otherwise.
+ """
+ if not isinstance(node, ast.Attribute):
+ return None
+ names = []
+ cur = node
+ while isinstance(cur, ast.Attribute):
+ names.append(cur.attr)
+ cur = cur.value
+ if isinstance(cur, ast.Name):
+ names.append(cur.id)
+ names.reverse()
+ try:
+ transform_idx = names.index("transform")
+ except ValueError:
+ return None
+ if transform_idx + 1 >= len(names):
+ return None
+ pass_name = names[transform_idx + 1]
+ if not pass_name or not pass_name[0].isupper():
+ return None
+ return pass_name
+
+
+def _discover_passes(phase_func) -> list[str]:
+ """Extract pass names from a phase function's source code via AST parsing."""
+ try:
+ source = inspect.getsource(phase_func)
+ except (OSError, TypeError):
+ return []
+
+ try:
+ tree = ast.parse(source)
+ except SyntaxError:
+ return []
+
+ passes = []
+ seen_calls: set = set()
+
+ class _PassVisitor(ast.NodeVisitor):
+ """AST visitor that collects pass names from function call sites."""
+
+ def visit_Call(self, node):
+ """Record any pass-like call found at this node (and nested callels)."""
+ func = node.func
+ found_in_nested = False
+ while isinstance(func, ast.Call):
+ if id(func) not in seen_calls:
+ seen_calls.add(id(func))
+ name = _extract_pass_name_from_attr_chain(func.func)
+ if name:
+ passes.append(name)
+ found_in_nested = True
+ func = func.func
+
+ if not found_in_nested and id(node) not in seen_calls:
+ seen_calls.add(id(node))
+ name = _extract_pass_name_from_attr_chain(func)
+ if name:
+ passes.append(name)
+
+ self.generic_visit(node)
+
+ _PassVisitor().visit(tree)
+ return passes
+
+
+def _discover_passes_recursive(phase_func) -> list[str]:
+ """Extract pass names, following local helper calls in the same module."""
+ passes = []
+ visited = set()
+ seen_calls: set = set()
+
+ def _visit(func):
+ """Recively visit ``func`` and its locally-referenced callees, collecting pass names."""
+ func_id = id(func)
+ if func_id in visited:
+ return
+ visited.add(func_id)
+
+ try:
+ source = inspect.getsource(func)
+ except (OSError, TypeError):
+ return
+
+ try:
+ tree = ast.parse(source)
+ except SyntaxError:
+ return
+
+ func_module = inspect.getmodule(func)
+ local_ns = {}
+ if func_module:
+ local_ns.update(vars(func_module))
+ if hasattr(func, "__globals__"):
+ local_ns.update(func.__globals__)
+
+ class _PassVisitor(ast.NodeVisitor):
+ """AST visitor that collects pass names and follows local helper calls."""
+
+ def visit_Call(self, node):
+ """Record pass-like calls and recurse into locally-defined helpers."""
+ call_func = node.func
+
+ found_in_nested = False
+ while isinstance(call_func, ast.Call):
+ if id(call_func) not in seen_calls:
+ seen_calls.add(id(call_func))
+ name = _extract_pass_name_from_attr_chain(call_func.func)
+ if name:
+ passes.append(name)
+ found_in_nested = True
+ call_func = call_func.func
+
+ if not found_in_nested and id(node) not in seen_calls:
+ seen_calls.add(id(node))
+ if isinstance(call_func, ast.Attribute):
+ name = _extract_pass_name_from_attr_chain(call_func)
+ if name:
+ passes.append(name)
+
+ elif isinstance(call_func, ast.Name):
+ name = call_func.id
+ resolved = local_ns.get(name)
+ if (
+ resolved is not None
+ and callable(resolved)
+ and not isinstance(resolved, type)
+ and not inspect.isbuiltin(resolved)
+ ):
+ resolved_module = getattr(resolved, "__module__", None)
+ func_module_name = getattr(func, "__module__", None)
+ if resolved_module == func_module_name:
+ _visit(resolved)
+
+ self.generic_visit(node)
+
+ _PassVisitor().visit(tree)
+
+ _visit(phase_func)
+ return passes
+
+
+def _discover_phases(lower_func) -> list:
+ """Discover phase functions from the old architecture via bytecode scanning."""
+ try:
+ from tilelang.engine import phase as phase_module
+ except ImportError:
+ return []
+
+ phase_funcs = []
+ seen_names = set()
+ try:
+ for instr in dis.get_instructions(lower_func):
+ if instr.opname == "LOAD_GLOBAL" and instr.argval not in seen_names:
+ name = instr.argval
+ seen_names.add(name)
+ func = getattr(phase_module, name, None)
+ if func is not None and callable(func):
+ phase_funcs.append(func)
+ except (TypeError, OSError):
+ pass
+
+ if not phase_funcs:
+ phase_funcs = [
+ getattr(phase_module, name)
+ for name in sorted(dir(phase_module))
+ if not name.startswith("_") and callable(getattr(phase_module, name, None))
+ ]
+
+ def _src_line(f):
+ """Return the source line number of ``f`` (large sentinel on failure) for stable sorting."""
+ try:
+ return inspect.getsourcelines(f)[1]
+ except (OSError, TypeError):
+ return 999999
+
+ phase_funcs.sort(key=_src_line)
+ return phase_funcs
+
+
+def _wrap_phase(original_func, phase_index, total_phases):
+ """Wrap a phase function to set tracing context (legacy architecture).
+
+ Phase context is set so that passes invoked within this window are tagged
+ with the phase label. Pass records are appended at runtime by
+ ``_traced_pass_call``; no pre-registration is performed.
+ """
+ base_phase_name = f"phase{phase_index}_{original_func.__name__}"
+
+ @functools.wraps(original_func)
+ def wrapper(*args, **kwargs):
+ """Set per-phase tracing context, run the phase, then flush the HTML report."""
+ global _run_counter, _current_phase, _auto_flush, _run_dir
+
+ with _lock:
+ if phase_index == 1:
+ _run_counter += 1
+ # Don't reset() on the first run: pre-pipeline passes may have
+ # already been recorded under _UNSCOPED_PHASE, and resetting
+ # would wipe them and break global pass numbering. State is
+ # already clean (module load / disable()); subsequent runs
+ # just get a fresh _run_dir below.
+ if _run_counter > 1:
+ _run_dir = None
+
+ run_prefix = f"run{_run_counter}_" if _run_counter > 1 else ""
+ phase_name = f"{run_prefix}{base_phase_name}"
+
+ _current_phase = phase_name
+ _auto_flush = _should_gen_html()
+
+ try:
+ result = original_func(*args, **kwargs)
+ except Exception as e:
+ with _lock:
+ _auto_flush = False
+ _current_phase = None
+ print(f" [lower_trace] EXCEPTION in {phase_name}: {e}")
+
+ with contextlib.suppress(Exception):
+ _incremental_flush_html()
+
+ raise
+
+ with _lock:
+ _auto_flush = False
+ _current_phase = None
+
+ if phase_index == total_phases:
+ print(f" [lower_trace] run {_run_counter} ({phase_name}) complete: {len(_records)} total records")
+
+ with contextlib.suppress(Exception):
+ _incremental_flush_html()
+
+ return result
+
+ return wrapper
+
+
+def _traced_pipeline_lower(self, mod, target):
+ """Intercept PassPipeline.lower to set phase context for pass tracing (new architecture).
+
+ Only sets ``_current_phase`` so that passes invoked within this window are
+ tagged with the pipeline label. Pass records are appended at runtime by
+ ``_traced_pass_call``; no pre-registration is performed, so conditional
+ passes (e.g. LetInline) that are skipped at runtime simply do not appear,
+ matching the behaviour of ``pass_diff``.
+ """
+ global _run_counter, _current_phase, _auto_flush, _run_dir
+
+ with _lock:
+ _run_counter += 1
+ # Don't reset() on the first run: pre-pipeline passes may have
+ # already been recorded under _UNSCOPED_PHASE, and resetting would
+ # wipe them and break global pass numbering. State is already clean
+ # (module load / disable()); subsequent runs just get a fresh
+ # _run_dir below.
+ if _run_counter > 1:
+ _run_dir = None
+
+ run_prefix = f"run{_run_counter}_" if _run_counter > 1 else ""
+ phase_name = f"{run_prefix}pipeline_{self.name}"
+ _current_phase = phase_name
+ _auto_flush = _should_gen_html()
+
+ try:
+ result = _original_pipeline_lower(self, mod, target)
+ except Exception as e:
+ with _lock:
+ _auto_flush = False
+ _current_phase = None
+ print(f" [lower_trace] EXCEPTION in {phase_name}: {e}")
+
+ with contextlib.suppress(Exception):
+ _incremental_flush_html()
+
+ raise
+
+ with _lock:
+ _auto_flush = False
+ _current_phase = None
+
+ with contextlib.suppress(Exception):
+ _incremental_flush_html()
+
+ print(f" [lower_trace] run {_run_counter} ({phase_name}) complete: {len(_records)} total records")
+
+ return result
+
+
+def _make_patched_source_module(original_module, patched_source: str):
+ """Build a real ``CSourceModule`` carrying ``patched_source``.
+
+ Every codegen FFI in ``_CODEGEN_FFI_NAMES`` is invoked via
+ ``tvm._ffi.get_global_func(name)(...)`` (see ``tilelang.engine.lower``
+ ``device_codegen`` / ``device_codegen_without_compile``). The wrapper is
+ registered as a PackedFunc, so its return value is marshalled back through
+ ``TVMCFuncSetReturn`` which only accepts TVM-recognised types (Module /
+ Object / PackedFunc / scalars / str / ...). A pure-Python proxy cannot
+ cross that boundary (``TypeError: Don't know how to handle type ...``).
+
+ We therefore construct a genuine ``CSourceModule`` — the same factory used
+ by ``BuildTileLangAscend`` in C++ (``CSourceModuleCreate(code, "c",
+ function_names)``) — so the patched source both crosses the FFI boundary
+ and is returned by ``.get_source()``, which is the sole consumer in
+ ``tilelang.engine.lower`` (line 233: ``codegen_mod.get_source()``).
+ """
+ import tvm.runtime._ffi_api as _ffi_api
+
+ try:
+ fmt = original_module.format
+ except Exception:
+ fmt = "c"
+ return _ffi_api.CSourceModuleCreate(patched_source, fmt, [], None)
+
+
+def _wrap_codegen_ffi(original_build, ffi_name=""):
+ """Return a wrapper around a codegen FFI build function (``target.build.*``).
+
+ Parameters
+ ----------
+ original_build : Callable
+ The original codegen FFI function.
+ ffi_name : str
+ The registered FFI name (e.g. ``target.build.tilelang_cuda``).
+ Used to decide whether a patched ``CSourceModule`` can be returned
+ (safe for source-only FFIs) or the real module must be
+ returned (required for full-compile FFIs whose binary is consumed
+ downstream via ``host_mod.import_module``).
+
+ The wrapper:
+ 1. Captures the final lowered TIR right before codegen runs (``str(mod)``).
+ 2. Temporarily sets ``_current_phase = 'codegen'`` so that the internal
+ ``tir.transform.Simplify()`` call in ``device_codegen`` is automatically
+ attributed to the ``codegen`` phase.
+ 3. After codegen finishes, captures the generated source via
+ ``result.inspect_source()`` and appends a ``STATUS_CODEGEN`` record.
+
+ Codegen output handling (when ``codegen_output`` path is configured):
+
+ Three files collaborate to disambiguate whether a content difference is
+ caused by user edits, by a codegen change, or by both:
+ - ```` — user-editable working copy.
+ - ``.original`` — baseline: the codegen snapshot the working copy
+ was last synced from (written only on init or
+ re-sync, never blindly overwritten).
+ - ``.latest`` — the actual codegen output of *this* run
+ (overwritten every run, for diff reference).
+
+ On each run a three-way comparison (baseline / working / current codegen)
+ decides:
+ - neither changed → use codegen as-is.
+ - only codegen changed → regenerate ```` and ``.original``
+ from the new codegen.
+ - only user edited → inject the working copy (PATCHED).
+ - both changed, working== → user already synced manually; advance
+ current baseline and use the working copy.
+ - both changed, working!= → CONFLICT: back up the user's working copy
+ current to ``.bak`` and the old baseline to
+ ``.original.bak``, then regenerate
+ ```` and ``.original`` from the new
+ codegen and compile with it. The user can
+ recover their edits via
+ ``diff(.original.bak, .bak)``.
+
+ When the working copy is injected (PATCHED / SYNCED), the return value
+ depends on whether the FFI is in ``_SOURCE_ONLY_CODEGEN_FFIS``:
+ - Source-only FFIs (``*_without_compile``, ``tilelang_c``, ``webgpu``,
+ ``tilelang_ascend``, ``tilelang_ascend_pto``)
+ → return a ``CSourceModule`` rebuilt from the patched source (via
+ ``_make_patched_source_module``) so it crosses the FFI boundary and
+ the downstream JIT adapter (NVRTC / Cython / CuTeDSL / bisheng)
+ recompiles the edited source.
+ - Full-compile FFIs (``tilelang_cuda``, ``tilelang_hip``, ``tilelang_metal``,
+ ``llvm``, ``tilelang_c_host``, …) → return the original ``result``
+ (whose binary was compiled from TIR) and print a NOTE advising the
+ user to switch to a source-compiling execution backend (e.g.
+ ``nvrtc``) for edit-and-recompile support.
+ """
+
+ @functools.wraps(original_build)
+ def wrapper(*args, **kwargs):
+ """Run codegen under the trace: capture TIR-before/C++-after and emit a STATUS_CODEGEN record."""
+ global _pass_index, _current_phase
+
+ if not _is_trace_enabled():
+ return original_build(*args, **kwargs)
+
+ mod = args[0] if args else kwargs.get("mod")
+ gen_html = _should_gen_html()
+ if gen_html:
+ _ensure_run_dir()
+
+ before_text = str(mod)
+ codegen_out_path = _get_codegen_output_path()
+
+ with _lock:
+ previous_phase = _current_phase
+ _current_phase = "codegen"
+
+ try:
+ result = original_build(*args, **kwargs)
+ except Exception as e:
+ with _lock:
+ idx = _pass_index
+ _pass_index += 1
+ record = LowerRecord(
+ phase="codegen",
+ name=getattr(original_build, "__name__", "codegen"),
+ index=idx,
+ before_text=before_text,
+ after_text="",
+ changed=False,
+ status=STATUS_FAILED,
+ error_msg=str(e),
+ )
+ _records.append(record)
+ _save_raw_files(record)
+ _current_phase = previous_phase
+ print(f" [lower_trace] codegen/{idx:02d}_codegen: FAILED ({e})")
+ raise
+
+ try:
+ with _lock:
+ idx = _pass_index
+ _pass_index += 1
+ patched_text = None
+ codegen_text = ""
+ after_text = ""
+ try:
+ codegen_text = _inspect_module_source(result)
+ if codegen_out_path:
+ original_path = codegen_out_path + ".original"
+ latest_path = codegen_out_path + ".latest"
+ try:
+ os.makedirs(os.path.dirname(os.path.abspath(codegen_out_path)), exist_ok=True)
+ with open(latest_path, "w", encoding="utf-8") as _f:
+ _f.write(codegen_text)
+ if not os.path.isfile(codegen_out_path) or not os.path.isfile(original_path):
+ if os.path.isfile(codegen_out_path):
+ shutil.copyfile(codegen_out_path, codegen_out_path + ".bak")
+ print(
+ f" [lower_trace] codegen/{idx:02d}_codegen: INIT-BACKUP — {_ANSI_BOLD}{_ANSI_YELLOW}{codegen_out_path}{_ANSI_RESET} existed without baseline, backed up to {_ANSI_BOLD}{_ANSI_YELLOW}{codegen_out_path}.bak{_ANSI_RESET}"
+ )
+ with open(original_path, "w", encoding="utf-8") as _f:
+ _f.write(codegen_text)
+ shutil.copyfile(original_path, codegen_out_path)
+ print(f" [lower_trace] codegen source initialized at: {_ANSI_GREEN}{codegen_out_path}{_ANSI_RESET}")
+ else:
+ with open(original_path, encoding="utf-8") as _f:
+ baseline_text = _f.read()
+ with open(codegen_out_path, encoding="utf-8") as _f:
+ working_text = _f.read()
+ user_edited = working_text.rstrip() != baseline_text.rstrip()
+ codegen_changed = codegen_text.rstrip() != baseline_text.rstrip()
+ if not user_edited and not codegen_changed:
+ patched_text = None
+ elif not user_edited and codegen_changed:
+ with open(original_path, "w", encoding="utf-8") as _f:
+ _f.write(codegen_text)
+ with open(codegen_out_path, "w", encoding="utf-8") as _f:
+ _f.write(codegen_text)
+ print(
+ f" {_ANSI_CYAN}[lower_trace] codegen/{idx:02d}_codegen: REGENERATED (codegen changed, no user edits){_ANSI_RESET}"
+ )
+ patched_text = None
+ elif user_edited and not codegen_changed:
+ patched_text = working_text
+ print(
+ f" {_ANSI_BOLD}{_ANSI_GREEN}[lower_trace] codegen/{idx:02d}_codegen: PATCHED (user edits){_ANSI_RESET}"
+ )
+ else:
+ if working_text.rstrip() == codegen_text.rstrip():
+ with open(original_path, "w", encoding="utf-8") as _f:
+ _f.write(codegen_text)
+ patched_text = working_text
+ print(
+ f" {_ANSI_BOLD}{_ANSI_GREEN}[lower_trace] codegen/{idx:02d}_codegen: SYNCED (user edits & codegen changed, but they are the same){_ANSI_RESET}"
+ )
+ else:
+ shutil.copyfile(codegen_out_path, codegen_out_path + ".bak")
+ shutil.copyfile(original_path, original_path + ".bak")
+ with open(original_path, "w", encoding="utf-8") as _f:
+ _f.write(codegen_text)
+ with open(codegen_out_path, "w", encoding="utf-8") as _f:
+ _f.write(codegen_text)
+ print(
+ f" {_ANSI_BOLD}{_ANSI_YELLOW}[lower_trace] codegen/{idx:02d}_codegen: CONFLICT (user edits & codegen changed, conflict with each other, codegen overwrites user edits). {_ANSI_RESET}"
+ )
+ patched_text = None
+ except Exception as _exc:
+ print(f" {_ANSI_RED}[lower_trace] WARNING: codegen file I/O failed: {_exc}{_ANSI_RESET}")
+ patched_text = None
+
+ after_text = patched_text if patched_text is not None else codegen_text
+
+ sm = difflib.SequenceMatcher(None, before_text.splitlines(), after_text.splitlines())
+ add_count = del_count = 0
+ for _tag, i1, i2, j1, j2 in sm.get_opcodes():
+ if _tag == "insert":
+ add_count += j2 - j1
+ elif _tag == "delete":
+ del_count += i2 - i1
+ elif _tag == "replace":
+ add_count += j2 - j1
+ del_count += i2 - i1
+
+ with _lock:
+ record = LowerRecord(
+ phase="codegen",
+ name="codegen",
+ index=idx,
+ before_text=before_text,
+ after_text=after_text,
+ changed=True,
+ add_lines=add_count,
+ del_lines=del_count,
+ status=STATUS_CODEGEN,
+ )
+ _records.append(record)
+ _save_raw_files(record)
+ tag = "CODEGEN"
+ path_suffix = f" → {_ANSI_BLUE}{codegen_out_path}{_ANSI_RESET}" if codegen_out_path else ""
+ print(f" [lower_trace] codegen/{idx:02d}_codegen: {tag} (+{add_count}/-{del_count}){path_suffix}")
+
+ if gen_html:
+ with contextlib.suppress(Exception):
+ _incremental_flush_html()
+ except Exception as exc:
+ print(f" {_ANSI_RED}[lower_trace] WARNING: post-codegen tracing failed: {exc}{_ANSI_RESET}")
+ finally:
+ with _lock:
+ _current_phase = previous_phase
+
+ if _should_print_terminal():
+ from .diff import print_diff
+
+ print_diff(before_text, after_text, "codegen (TIR before)", "codegen (C++ after)")
+
+ if patched_text is not None:
+ if ffi_name in _SOURCE_ONLY_CODEGEN_FFIS:
+ # Source-only FFIs produce modules whose sole consumer is
+ # get_source()/inspect_source(); the downstream JIT adapter
+ # (NVRTC/Cython/CuTeDSL/bisheng) recompiles the source string.
+ # Return a real CSourceModule built from the patched source so
+ # that (1) it crosses the TVM PackedFunc FFI return-value
+ # boundary (a pure-Python proxy cannot) and (2) get_source()
+ # yields the user-edited source for recompilation.
+ return _make_patched_source_module(result, patched_text)
+ else:
+ # Full-compile FFIs return a module whose binary (PTX/hsaco)
+ # was compiled from TIR and is consumed downstream via
+ # host_mod.import_module(). A pure-Python proxy cannot be
+ # used here (the FFI boundary requires a real Module handle),
+ # so the unpatched module is returned. Only the trace/display
+ # text reflects the user's edits.
+ if patched_text.rstrip() != codegen_text.rstrip():
+ target_kind = ""
+ _t = args[1] if len(args) > 1 else kwargs.get("target")
+ target_kind = getattr(getattr(_t, "kind", None), "name", "")
+ backend_hint = ""
+ if target_kind == "cuda":
+ backend_hint = " Use execution_backend='nvrtc' for edit-and-recompile support."
+ elif target_kind == "hip":
+ backend_hint = " Use execution_backend='cython' for edit-and-recompile support."
+ print(
+ f" {_ANSI_YELLOW}[lower_trace] codegen/{idx:02d}_codegen: NOTE — "
+ f"user edits in {codegen_out_path} are recorded in the trace for diff "
+ f"viewing, but were NOT recompiled (the codegen FFI builds from TIR, "
+ f"not from C++ source). The compiled artifact reflects the unpatched "
+ f"codegen output.{backend_hint}{_ANSI_RESET}"
+ )
+
+ return result
+
+ return wrapper
+
+
+def _register_atexit():
+ """Register the final-report atexit handler (idempotent)."""
+ global _atexit_registered
+ if _atexit_registered:
+ return
+ import atexit
+
+ atexit.register(_final_report)
+ _atexit_registered = True
+
+
+def enable(*, mode=_UNSET, trace_dir=_UNSET, codegen_output=_UNSET):
+ """Enable IR pass tracing via monkey-patching.
+
+ Parameters
+ ----------
+ mode : str | None, optional
+ Force a trace mode (``'terminal'``, ``'html'``, ``'both'``, or
+ ``None`` to disable). When omitted, the mode is read from the
+ ``TL_LOWER_TRACE`` env var (or a prior ``enable`` override),
+ keeping this module free of any ``tilelang.env`` dependency.
+ trace_dir : str | None, optional
+ Force the trace output base directory. When omitted, falls back to
+ the ``TL_LOWER_TRACE_DIR`` env var, then
+ ``./tmp/lower_trace_dir``.
+ codegen_output : str | None, optional
+ Path to save the codegen-generated C++/CUDA/etc. source code. When
+ omitted, defaults to ``/codegen.cpp`` (inside the
+ per-script output directory, beside ``.run_records/``). Pass ``None``
+ explicitly to suppress all extra saves. See ``_wrap_codegen_ffi``
+ for the three-file (```` / ``.original`` /
+ ``.latest``) patch-and-recompile workflow.
+ """
+ global _mode_override, _trace_dir_override, _codegen_output_path_override, _script_dir, _run_dir
+
+ if mode is not _UNSET:
+ _mode_override = _parse_lower_trace_mode(mode if mode is None else str(mode))
+ if trace_dir is not _UNSET:
+ new_trace_dir = trace_dir if trace_dir is None else str(trace_dir)
+ if new_trace_dir != _trace_dir_override:
+ _script_dir = None
+ _run_dir = None
+ _trace_dir_override = new_trace_dir
+ if codegen_output is not _UNSET:
+ _codegen_output_path_override = codegen_output if codegen_output is None else str(codegen_output)
+
+ # Explicitly disabling (mode=None or an off-value): remove any hooks a
+ # prior enable() may have installed so global state is left unchanged,
+ # then re-assert the None override so a stale TL_LOWER_TRACE env var
+ # cannot silently re-enable tracing. The no-args case (mode unset) still
+ # falls through to install hooks and resolve the mode at runtime.
+ if mode is not _UNSET and _mode_override is None:
+ disable()
+ _mode_override = None
+ return
+
+ from tvm.ir.transform import Pass
+
+ global _original_pass_call, _original_pipeline_lower, _atexit_registered, _legacy_patched
+ if _original_pass_call is None:
+ _original_pass_call = Pass.__call__
+ Pass.__call__ = _traced_pass_call
+
+ if not _original_codegen_ffis:
+ _ffi = _get_tvm_ffi()
+
+ for ffi_name in _CODEGEN_FFI_NAMES:
+ try:
+ orig = _ffi.get_global_func(ffi_name)
+ if orig is not None:
+ wrapped = _wrap_codegen_ffi(orig, ffi_name)
+ _original_codegen_ffis[ffi_name] = orig
+ _ffi.register_global_func(ffi_name, wrapped, override=True)
+ except Exception as exc:
+ print(f"[lower_trace] WARNING: could not wrap codegen FFI {ffi_name}: {exc}")
+
+ _register_atexit()
+
+ if _original_pipeline_lower is not None or _legacy_patched:
+ return
+
+ try:
+ from tilelang.backend.pass_pipeline import PassPipeline
+
+ _original_pipeline_lower = PassPipeline.lower
+ PassPipeline.lower = _traced_pipeline_lower
+ print("[lower_trace] IR pass tracing enabled (PassPipeline architecture). Set TL_LOWER_TRACE=1 to enable.")
+ return
+ except ImportError:
+ pass
+
+ try:
+ import tilelang.engine.lower as lower_mod
+
+ lower_func = lower_mod.lower
+ patch_mod = lower_mod
+ except (ImportError, AttributeError):
+ try:
+ from tilelang.engine import lower as lower_func
+
+ import tilelang.engine as patch_mod
+ except (ImportError, AttributeError) as e:
+ print(f"[lower_trace] WARNING: could not enable tracing — {e}")
+ return
+
+ phase_funcs = _discover_phases(lower_func)
+ for i, phase_func in enumerate(phase_funcs):
+ wrapped = _wrap_phase(phase_func, i + 1, len(phase_funcs))
+ name = phase_func.__name__
+
+ # Save originals on every target before overwriting so disable() can
+ # restore them cleanly (CWE: tracing must be fully reversible).
+ _legacy_phase_originals.append((patch_mod, name, getattr(patch_mod, name, _MISSING), False))
+ setattr(patch_mod, name, wrapped)
+ try:
+ from tilelang.engine import phase as phase_module
+
+ if hasattr(phase_module, name):
+ _legacy_phase_originals.append((phase_module, name, getattr(phase_module, name, _MISSING), False))
+ setattr(phase_module, name, wrapped)
+ except ImportError:
+ pass
+ glbls = getattr(lower_func, "__globals__", None)
+ if glbls is not None and name in glbls:
+ _legacy_phase_originals.append((glbls, name, glbls[name], True))
+ glbls[name] = wrapped
+
+ _legacy_patched = True
+ print(f"[lower_trace] IR pass tracing enabled (phase-based architecture, {len(phase_funcs)} phases). Set TL_LOWER_TRACE=1 to enable.")
+
+
+def _final_report():
+ """Generate final HTML report at process exit, covering all accumulated runs."""
+ if not _should_gen_html() or not _records or not _run_dir:
+ return
+ try:
+ from .html import generate_html
+
+ html_path = os.path.join(_run_dir, "report.html")
+ generate_html(_records, html_path, section_cache=_section_cache)
+ _update_html_symlink(html_path)
+ print(f" [lower_trace] Final HTML report: {_ANSI_BLUE}{os.path.join(_script_dir, 'report.html')}{_ANSI_RESET}")
+ except Exception as exc:
+ print(f" {_ANSI_RED}[lower_trace] WARNING: failed to generate final HTML report: {exc}{_ANSI_RESET}")
+
+
+def disable():
+ """Remove the pass tracing hook and restore original behavior."""
+ global _original_pass_call, _original_pipeline_lower, _atexit_registered, _run_counter, _legacy_patched
+ global _mode_override, _trace_dir_override, _codegen_output_path_override, _script_dir, _run_dir
+ global _legacy_phase_originals
+
+ if _original_pass_call is not None:
+ from tvm.ir.transform import Pass
+
+ Pass.__call__ = _original_pass_call
+ _original_pass_call = None
+
+ if _original_pipeline_lower is not None:
+ from tilelang.backend.pass_pipeline import PassPipeline
+
+ PassPipeline.lower = _original_pipeline_lower
+
+ _original_pipeline_lower = None
+ _legacy_patched = False
+
+ # Restore the phase callables that the legacy fallback path overwrote in
+ # patch_mod / tilelang.engine.phase / lower_func.__globals__.
+ for target, name, original, is_dict in _legacy_phase_originals:
+ with contextlib.suppress(Exception):
+ if original is _MISSING:
+ if is_dict:
+ del target[name] # type: ignore[operator]
+ else:
+ delattr(target, name)
+ else:
+ if is_dict:
+ target[name] = original # type: ignore[index]
+ else:
+ setattr(target, name, original)
+ _legacy_phase_originals = []
+
+ _ffi = _get_tvm_ffi()
+
+ for ffi_name, orig in _original_codegen_ffis.items():
+ with contextlib.suppress(Exception):
+ _ffi.register_global_func(ffi_name, orig, override=True)
+ _original_codegen_ffis.clear()
+
+ _mode_override = _UNSET
+ _trace_dir_override = _UNSET
+ _codegen_output_path_override = _UNSET
+
+ if _atexit_registered:
+ import atexit
+
+ atexit.unregister(_final_report)
+ _atexit_registered = False
+
+ _run_counter = 0
+ _script_dir = None
+ _run_dir = None
+ reset()
+
+
+def reset():
+ """Clear collected records and section cache.
+
+ ``_script_dir`` is preserved (stable across runs, holds codegen files +
+ html symlink). ``_run_dir`` is also preserved: clearing it here would
+ split a single run into two directories, because pre-pipeline passes
+ (which lazily create it via ``_ensure_run_dir``) run before
+ ``PassPipeline.lower``/``_wrap_phase`` invokes ``reset`` on its first run.
+ A fresh ``_run_dir`` for each subsequent run is instead established
+ directly in ``_traced_pipeline_lower`` / ``_wrap_phase`` (when
+ ``_run_counter > 1``), without calling ``reset`` so that records keep
+ accumulating across runs.
+ """
+ global _records, _section_cache, _current_phase, _pass_index, _auto_flush
+ _records = []
+ _section_cache = {}
+ _current_phase = None
+ _pass_index = 0
+ _auto_flush = False
diff --git a/tilelang/tools/lower_trace/diff.py b/tilelang/tools/lower_trace/diff.py
new file mode 100644
index 0000000000..f1063e41b7
--- /dev/null
+++ b/tilelang/tools/lower_trace/diff.py
@@ -0,0 +1,417 @@
+"""Diff utilities for lower trace."""
+
+from __future__ import annotations
+
+import difflib
+
+
+_ANSI_RESET = "\033[0m"
+_ANSI_RED = "\033[31m"
+_ANSI_GREEN = "\033[32m"
+_ANSI_YELLOW = "\033[33m"
+_ANSI_BLUE = "\033[34m"
+_ANSI_CYAN = "\033[36m"
+_ANSI_BOLD = "\033[1m"
+_ANSI_DIM = "\033[2m"
+
+
+def _esc(text: str) -> str:
+ """Escape HTML special characters."""
+ return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
+
+
+def _inline_diff(line_before: str, line_after: str) -> tuple[str, str, bool]:
+ """Compute character-level inline diff between two lines.
+
+ Returns (left_html, right_html, is_ws_only) with highlighted changes.
+ """
+ sm = difflib.SequenceMatcher(None, line_before, line_after)
+ left_parts = []
+ right_parts = []
+ is_ws_only = True
+
+ for tag, i1, i2, j1, j2 in sm.get_opcodes():
+ if tag == "equal":
+ eq = _esc(line_before[i1:i2])
+ left_parts.append(eq)
+ right_parts.append(eq)
+ else:
+ left_chunk = line_before[i1:i2] if i2 > i1 else ""
+ right_chunk = line_after[j1:j2] if j2 > j1 else ""
+ if left_chunk.strip() != "" or right_chunk.strip() != "":
+ is_ws_only = False
+
+ if tag == "replace":
+ left_parts.append(f'{_esc(left_chunk)}')
+ right_parts.append(f'{_esc(right_chunk)}')
+ elif tag == "delete":
+ left_parts.append(f'{_esc(left_chunk)}')
+ elif tag == "insert":
+ right_parts.append(f'{_esc(right_chunk)}')
+
+ return "".join(left_parts), "".join(right_parts), is_ws_only
+
+
+def _merge_whitespace_diffs(opcodes: list, before_lines: list, after_lines: list) -> list:
+ """Post-process opcodes to merge adjacent delete+insert into replace when whitespace-only."""
+ result = []
+ i = 0
+ while i < len(opcodes):
+ tag, i1, i2, j1, j2 = opcodes[i]
+
+ if tag == "delete" and i + 1 < len(opcodes) and opcodes[i + 1][0] == "insert":
+ _, _, _, j1_next, j2_next = opcodes[i + 1]
+ del_lines = list(range(i1, i2))
+ ins_lines = list(range(j1_next, j2_next))
+
+ matched_del = set()
+ matched_ins = set()
+ pairs = []
+
+ for di, d_idx in enumerate(del_lines):
+ for ii, i_idx in enumerate(ins_lines):
+ if ii in matched_ins:
+ continue
+ if before_lines[d_idx].strip() == after_lines[i_idx].strip():
+ pairs.append((d_idx, i_idx))
+ matched_del.add(di)
+ matched_ins.add(ii)
+ break
+
+ if pairs:
+ pairs.sort()
+ prev_d = i1
+ prev_i = j1_next
+ for d_idx, i_idx in pairs:
+ while prev_d < d_idx:
+ result.append(("delete", prev_d, prev_d + 1, prev_i, prev_i))
+ prev_d += 1
+ while prev_i < i_idx:
+ result.append(("insert", d_idx, d_idx, prev_i, prev_i + 1))
+ prev_i += 1
+ result.append(("replace", d_idx, d_idx + 1, i_idx, i_idx + 1))
+ prev_d = d_idx + 1
+ prev_i = i_idx + 1
+ for d_idx in range(prev_d, i2):
+ result.append(("delete", d_idx, d_idx + 1, prev_i, prev_i))
+ for i_idx in range(prev_i, j2_next):
+ result.append(("insert", i2, i2, i_idx, i_idx + 1))
+
+ i += 2
+ continue
+
+ elif tag == "insert" and i + 1 < len(opcodes) and opcodes[i + 1][0] == "delete":
+ _, i1_next, i2_next, _, _ = opcodes[i + 1]
+ ins_lines = list(range(j1, j2))
+ del_lines = list(range(i1_next, i2_next))
+
+ matched_ins = set()
+ matched_del = set()
+ pairs = []
+
+ for ii, i_idx in enumerate(ins_lines):
+ for di, d_idx in enumerate(del_lines):
+ if di in matched_del:
+ continue
+ if after_lines[i_idx].strip() == before_lines[d_idx].strip():
+ pairs.append((d_idx, i_idx))
+ matched_ins.add(ii)
+ matched_del.add(di)
+ break
+
+ if pairs:
+ pairs.sort()
+ prev_d = i1_next
+ prev_i = j1
+ for d_idx, i_idx in pairs:
+ while prev_i < i_idx:
+ result.append(("insert", prev_d, prev_d, prev_i, prev_i + 1))
+ prev_i += 1
+ while prev_d < d_idx:
+ result.append(("delete", prev_d, prev_d + 1, prev_i, prev_i))
+ prev_d += 1
+ result.append(("replace", d_idx, d_idx + 1, i_idx, i_idx + 1))
+ prev_d = d_idx + 1
+ prev_i = i_idx + 1
+ for i_idx in range(prev_i, j2):
+ result.append(("insert", prev_d, prev_d, i_idx, i_idx + 1))
+ for d_idx in range(prev_d, i2_next):
+ result.append(("delete", d_idx, d_idx + 1, j2, j2))
+
+ i += 2
+ continue
+
+ result.append((tag, i1, i2, j1, j2))
+ i += 1
+
+ return result
+
+
+def _flush_gap_rows(target: list, gap_l: list, gap_r: list) -> None:
+ """Append position-paired rows for unmatched left/right lines within a gap.
+
+ Pairs ``gap_l[k]`` with ``gap_r[k]`` for inline diff rendering; leftovers
+ become single-side rows. Both lists are ascending, so the appended rows keep
+ the left and right columns ascending.
+ """
+ for k in range(max(len(gap_l), len(gap_r))):
+ if k < len(gap_l) and k < len(gap_r):
+ target.append((gap_l[k], gap_r[k], False))
+ elif k < len(gap_l):
+ target.append((gap_l[k], None, False))
+ else:
+ target.append((None, gap_r[k], False))
+
+
+def _make_diff_html(before_text: str, after_text: str, context: int = 3) -> str:
+ """Generate a GitHub-style side-by-side diff HTML table."""
+ before_lines = before_text.splitlines()
+ after_lines = after_text.splitlines()
+
+ sm = difflib.SequenceMatcher(None, before_lines, after_lines)
+ opcodes = sm.get_opcodes()
+
+ opcodes = _merge_whitespace_diffs(opcodes, before_lines, after_lines)
+
+ content_equal = all(tag == "equal" for tag, *_ in opcodes)
+ nl_equal = before_text.endswith("\n") == after_text.endswith("\n")
+ if content_equal and nl_equal:
+ return '
No differences.
'
+ if content_equal and not nl_equal:
+ before_nl = "present" if before_text.endswith("\n") else "absent"
+ after_nl = "present" if after_text.endswith("\n") else "absent"
+ return f'
Only trailing newline differs (before: {before_nl}, after: {after_nl}).
'
+
+ before_collapse = [True] * len(before_lines)
+ after_collapse = [True] * len(after_lines)
+ for tag, i1, i2, j1, j2 in opcodes:
+ if tag != "equal":
+ for i in range(i1, i2):
+ before_collapse[i] = False
+ for j in range(j1, j2):
+ after_collapse[j] = False
+ for tag, i1, i2, j1, j2 in opcodes:
+ if tag == "equal":
+ for k in range(min(context, i2 - i1)):
+ before_collapse[i1 + k] = False
+ after_collapse[j1 + k] = False
+ for k in range(min(context, i2 - i1)):
+ before_collapse[i2 - 1 - k] = False
+ after_collapse[j2 - 1 - k] = False
+
+ rows = []
+
+ for tag, i1, i2, j1, j2 in opcodes:
+ if tag == "equal":
+ for i, j in zip(range(i1, i2), range(j1, j2), strict=True):
+ collapsed = before_collapse[i]
+ hidden_attr = ' class="row-hidden" data-collapse="1"' if collapsed else ""
+ ln_l = f'
{i + 1}
'
+ ln_r = f'
{j + 1}
'
+ rows.append(
+ f"
{ln_l}"
+ f'
{_esc(before_lines[i])}
'
+ f"{ln_r}"
+ f'
{_esc(after_lines[j])}
'
+ )
+ elif tag == "replace":
+ # Monotone matching: LCS on stripped content guarantees both the
+ # left and right line-number columns render in ascending order.
+ # Greedy bipartite matching + sort could pair a later left line to
+ # an earlier right line, making the right column jump backwards.
+ before_stripped = [before_lines[i].strip() for i in range(i1, i2)]
+ after_stripped = [after_lines[j].strip() for j in range(j1, j2)]
+ inner = difflib.SequenceMatcher(None, before_stripped, after_stripped)
+ matched: list[tuple[int, int]] = []
+ for t2, a1, a2, b1, _b2 in inner.get_opcodes():
+ if t2 == "equal":
+ for k in range(a2 - a1):
+ matched.append((i1 + a1 + k, j1 + b1 + k))
+
+ matched_left = {li for li, _ in matched}
+ matched_right = {ri for _, ri in matched}
+ unmatched_left = [i for i in range(i1, i2) if i not in matched_left]
+ unmatched_right = [j for j in range(j1, j2) if j not in matched_right]
+
+ all_rows: list = []
+ up = vp = 0
+
+ for li, ri in matched:
+ gap_l = []
+ while up < len(unmatched_left) and unmatched_left[up] < li:
+ gap_l.append(unmatched_left[up])
+ up += 1
+ gap_r = []
+ while vp < len(unmatched_right) and unmatched_right[vp] < ri:
+ gap_r.append(unmatched_right[vp])
+ vp += 1
+ _flush_gap_rows(all_rows, gap_l, gap_r)
+ all_rows.append((li, ri, True))
+
+ _flush_gap_rows(all_rows, unmatched_left[up:], unmatched_right[vp:])
+
+ for li, ri, is_matched in all_rows:
+ if li is not None and ri is not None:
+ left_html, right_html, is_ws_only = _inline_diff(before_lines[li], after_lines[ri])
+ if is_ws_only and is_matched:
+ ln_l = f'
{li + 1}
'
+ ln_r = f'
{ri + 1}
'
+ rows.append(
+ f"
{ln_l}"
+ f'
~
{left_html}
'
+ f"{ln_r}"
+ f'
~
{right_html}
'
+ )
+ else:
+ ln_l = f'
{li + 1}
'
+ ln_r = f'
{ri + 1}
'
+ rows.append(
+ f"
{ln_l}"
+ f'
\u2212
{left_html}
'
+ f"{ln_r}"
+ f'
+
{right_html}
'
+ )
+ elif li is not None:
+ ln_l = f'
{li + 1}
'
+ rows.append(
+ f"
{ln_l}"
+ f'
\u2212
{_esc(before_lines[li])}
'
+ f'
'
+ )
+ else:
+ ln_r = f'
{ri + 1}
'
+ rows.append(
+ f'
'
+ f"{ln_r}"
+ f'
+
{_esc(after_lines[ri])}
'
+ )
+ elif tag == "delete":
+ for i in range(i1, i2):
+ ln_l = f'
{i + 1}
'
+ rows.append(
+ f"
{ln_l}"
+ f'
\u2212
{_esc(before_lines[i])}
'
+ f'
'
+ )
+ elif tag == "insert":
+ for j in range(j1, j2):
+ ln_r = f'
{j + 1}
'
+ rows.append(
+ f'
'
+ f"{ln_r}"
+ f'
+
{_esc(after_lines[j])}
'
+ )
+
+ # --- Identify contiguous runs of collapsed (hidden) rows ---
+ collapse_flags = ['data-collapse="1"' in r and 'class="row-hidden"' in r for r in rows]
+
+ run_list: list[tuple[int, int]] = []
+ in_run = False
+ for idx, is_collapsed in enumerate(collapse_flags):
+ if is_collapsed and not in_run:
+ run_start = idx
+ in_run = True
+ elif not is_collapsed and in_run:
+ run_list.append((run_start, idx))
+ in_run = False
+ if in_run:
+ run_list.append((run_start, len(rows)))
+
+ # Tag hidden rows with their run index (used by JS to scope expansion)
+ for run_idx, (r1, r2) in enumerate(run_list):
+ for i in range(r1, r2):
+ rows[i] = rows[i].replace('class="row-hidden"', f'class="row-hidden" data-run="{run_idx}"')
+
+ # --- Insert expand button rows ---
+ # One "↑↓ Expand" button after each run, expanding hidden rows in both
+ # directions. Insertions are applied from bottom to top so indices stay valid.
+ insertions: list[tuple[int, str]] = []
+ for ri, (_r1, r2) in enumerate(run_list):
+ insertions.append(
+ (
+ r2,
+ f'
'
+ f'
'
+ f'\u2191\u2193'
+ f'Expand'
+ f"
",
+ )
+ )
+ for pos, html in sorted(insertions, key=lambda x: x[0], reverse=True):
+ rows.insert(pos, html)
+
+ return (
+ '
';
+ var rows = [];
+ for (var o = 0; o < opcodes.length; o++) {
+ var op = opcodes[o];
+ if (op.tag === 'equal') {
+ for (var i = op.i1; i < op.i2; i++) {
+ var j = op.j1 + (i - op.i1);
+ rows.push('
' +
+ '
' + (i+1) + '
' +
+ '
' + escHtml(beforeLines[i]||'') + '
' +
+ '
' + (j+1) + '
' +
+ '
' + escHtml(afterLines[j]||'') + '
');
+ }
+ } else if (op.tag === 'replace') {
+ // Monotone matching: LCS on stripped content guarantees both
+ // line-number columns render in ascending order (no sort needed).
+ var bStripped = [], aStripped = [];
+ for (var x = op.i1; x < op.i2; x++) bStripped.push((beforeLines[x]||'').trim());
+ for (var y = op.j1; y < op.j2; y++) aStripped.push((afterLines[y]||'').trim());
+ var subOps = lcsOpcodes(bStripped, aStripped, op.i1, op.j1);
+ var matched = [];
+ for (var k = 0; k < subOps.length; k++) {
+ var so = subOps[k];
+ if (so.tag === 'equal') {
+ for (var t = 0; t < so.i2 - so.i1; t++) matched.push([so.i1 + t, so.j1 + t]);
+ }
+ }
+ var mL = {}, mR = {};
+ for (var k = 0; k < matched.length; k++) { mL[matched[k][0]] = true; mR[matched[k][1]] = true; }
+ var unmatchedL = [], unmatchedR = [];
+ for (var k = op.i1; k < op.i2; k++) { if (!mL[k]) unmatchedL.push(k); }
+ for (var k = op.j1; k < op.j2; k++) { if (!mR[k]) unmatchedR.push(k); }
+ var all = [];
+ var up = 0, vp = 0;
+ var flushGap = function(gl, gr) {
+ for (var k = 0; k < Math.max(gl.length, gr.length); k++) {
+ if (k < gl.length && k < gr.length) all.push([gl[k], gr[k], false]);
+ else if (k < gl.length) all.push([gl[k], null, false]);
+ else all.push([null, gr[k], false]);
+ }
+ };
+ for (var mi = 0; mi < matched.length; mi++) {
+ var li = matched[mi][0], ri = matched[mi][1];
+ var gl = [], gr = [];
+ while (up < unmatchedL.length && unmatchedL[up] < li) { gl.push(unmatchedL[up]); up++; }
+ while (vp < unmatchedR.length && unmatchedR[vp] < ri) { gr.push(unmatchedR[vp]); vp++; }
+ flushGap(gl, gr);
+ all.push([li, ri, true]);
+ }
+ flushGap(unmatchedL.slice(up), unmatchedR.slice(vp));
+ for (var k = 0; k < all.length; k++) {
+ var li = all[k][0], ri = all[k][1], matched = all[k][2];
+ var isPinned = (li === pinL && ri === pinR);
+ if (li !== null && ri !== null) {
+ var diff = jsInlineDiff(beforeLines[li]||'', afterLines[ri]||'');
+ if (diff.isWsOnly && matched) {
+ var cls = isPinned ? ' class="row-aligned"' : '';
+ rows.push('