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) == '

No differences.

' + assert _make_diff_html("x", "x", context=3) == '

No differences.

' + + +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 ( + '
' + "" + '' + '' + "" + "\n".join(rows) + "
" + ) + + +def unified_diff( + before_text: str, + after_text: str, + before_label: str = "before", + after_label: str = "after", + context: int = 3, + color: bool = True, +) -> str: + """Generate a unified diff string, optionally with terminal ANSI colors.""" + before_lines = before_text.splitlines(keepends=True) + after_lines = after_text.splitlines(keepends=True) + + diff = list( + difflib.unified_diff( + before_lines, + after_lines, + fromfile=before_label, + tofile=after_label, + n=context, + ) + ) + + if not diff: + return "" + + if not color: + return "".join(diff) + + colored = [] + for line in diff: + if line.startswith("---") or line.startswith("+++"): + colored.append(f"{_ANSI_BOLD}{line}{_ANSI_RESET}") + elif line.startswith("@@"): + colored.append(f"{_ANSI_CYAN}{line}{_ANSI_RESET}") + elif line.startswith("-"): + colored.append(f"{_ANSI_RED}{line}{_ANSI_RESET}") + elif line.startswith("+"): + colored.append(f"{_ANSI_GREEN}{line}{_ANSI_RESET}") + else: + colored.append(line) + + return "".join(colored) + + +def print_diff( + before_text: str, + after_text: str, + before_label: str = "before", + after_label: str = "after", + context: int = 3, + color: bool = True, +) -> bool: + """Print a unified diff to stdout. Returns True if there were differences.""" + result = unified_diff(before_text, after_text, before_label, after_label, context, color) + if result: + print(result, end="") + return True + return False + + +def _count_changes(diff_lines: list[str]) -> tuple[int, int]: + """Count insertions and deletions from a unified diff.""" + insertions = sum(1 for line in diff_lines if line.startswith("+") and not line.startswith("+++")) + deletions = sum(1 for line in diff_lines if line.startswith("-") and not line.startswith("---")) + return insertions, deletions diff --git a/tilelang/tools/lower_trace/html.py b/tilelang/tools/lower_trace/html.py new file mode 100644 index 0000000000..77af11dbd4 --- /dev/null +++ b/tilelang/tools/lower_trace/html.py @@ -0,0 +1,1787 @@ +"""HTML report generation for lower trace.""" + +from __future__ import annotations + +import json +import os + +from .core import LowerRecord, STATUS_COMPLETED, STATUS_FAILED, STATUS_SKIPPED, STATUS_CODEGEN +from .diff import _esc, _make_diff_html + + +def _js_str(text: str) -> str: + """Render a value as a JS string literal safe for an inline HTML event handler. + + ``json.dumps`` produces a double-quoted JS string (escaping quotes, + backslashes, newlines); ``_esc`` then neutralises the double quotes so the + result can be embedded inside a double-quoted HTML attribute. The HTML + parser decodes ``"`` back to ``"`` before the JS engine runs, so the + browser sees a valid string literal even if the name contains quotes or + markup. + """ + return _esc(json.dumps(str(text))) + + +def _js_safe_json(value) -> str: + """Render a Python value as JSON safe for embedding inside a ````, ``&``, and the U+2028/U+2029 line + separators unescaped, all of which can prematurely terminate the script + element or break the JS parser. This escapes those sequences so the + serialized value round-trips safely through an HTML parser. + """ + return ( + json.dumps(value) + .replace("<", "\\u003c") + .replace(">", "\\u003e") + .replace("&", "\\u0026") + .replace("\u2028", "\\u2028") + .replace("\u2029", "\\u2029") + ) + + +_CSS = """ +:root { + --bg: #f5f7fa; + --bg-elevated: #ffffff; + --bg-inset: #e2e8f0; + --bg-hover: #f1f5f9; + --bg-active: #eff6ff; + --bg-code: #f6f8fa; + --bg-header-hover: #f8fafc; + --bg-btn-hover: #f3f4f6; + --border: #e2e8f0; + --border-strong: #cbd5e1; + --border-input: #d0d7de; + --text: #1e293b; + --text-secondary: #334155; + --text-muted: #64748b; + --text-faint: #8c959f; + --text-fainter: #94a3b8; + --text-dim: #475569; + --text-diff: #24292f; + --text-dot-noop: #d1d5db; + --accent: #2563eb; + --accent-blue: #3b82f6; + --green: #22c55e; + --green-text: #166534; + --green-bg: #dcfce7; + --green-dark: #1a7f37; + --green-light: #abf2ca; + --green-bg-light: #dafbe1; + --green-word: #acf2bd; + --green-text-dark: #116329; + --red: #dc2626; + --red-text: #991b1b; + --red-text-dark: #82071e; + --red-bg: #ffebe9; + --red-bg-light: #ffd7d5; + --red-bg-bright: #fecaca; + --red-dark: #cf222e; + --red-word: #ffcecb; + --red-border: #fca5a5; + --red-accent: #ef4444; + --red-bg-lighter: #fef2f2; + --amber: #f59e0b; + --failed-bg: #fffbfb; + --skipped-bg: #fffdf5; + --yellow-bg: #fff8c5; + --purple-bg: #f0f0ff; + --purple-bg-light: #e8e8f8; + --purple-text: #4040a0; + --purple-text-light: #6060b0; + --purple-word: #d8d8f0; + --align-left-from: #0f172a; + --align-left-to: #1e293b; + --align-pending-bg: #fef3c7; + --align-pending-text: #664d03; + --align-right-bg: #cfe2ff; + --align-right-text: #084298; + --align-locked-bg: #dbeafe; + --blue-bg-light: #ddf4ff; + --blue-bg-hover: #c8e9ff; + --toast-bg: #1f2937; +} + +[data-theme="dark"] { + --bg: #1e1e2e; + --bg-elevated: #313244; + --bg-inset: #181825; + --bg-hover: #45475a; + --bg-active: #1e3a5f; + --bg-code: #262636; + --bg-header-hover: #45475a; + --bg-btn-hover: #585b70; + --border: #45475a; + --border-strong: #585b70; + --border-input: #585b70; + --text: #cdd6f4; + --text-secondary: #bac2de; + --text-muted: #a6adc8; + --text-faint: #6c7086; + --text-fainter: #585b70; + --text-dim: #a6adc8; + --text-diff: #cdd6f4; + --text-dot-noop: #585b70; + --accent: #89b4fa; + --accent-blue: #89b4fa; + --green: #a6e3a1; + --green-text: #a6e3a1; + --green-bg: rgba(166, 227, 161, 0.12); + --green-dark: #a6e3a1; + --green-light: #2d4a2d; + --green-bg-light: #2d4a2d; + --green-word: #3a6a3a; + --green-text-dark: #a6e3a1; + --red: #f38ba8; + --red-text: #f38ba8; + --red-text-dark: #f38ba8; + --red-bg: #2e1a1a; + --red-bg-light: #2e1a1a; + --red-bg-bright: #4a2d2d; + --red-dark: #f38ba8; + --red-word: #5a3030; + --red-border: #5a3030; + --red-accent: #f38ba8; + --red-bg-lighter: #2e1a1a; + --amber: #fab387; + --failed-bg: #2a1a1e; + --skipped-bg: #2a2418; + --yellow-bg: #3e3e5e; + --purple-bg: #262636; + --purple-bg-light: #313244; + --purple-text: #b4befe; + --purple-text-light: #7f849c; + --purple-word: #45475a; + --align-left-from: #1a1a2e; + --align-left-to: #1e1e2e; + --align-pending-bg: #3e3a2e; + --align-pending-text: #f9e2af; + --align-right-bg: #1e3a5f; + --align-right-text: #89dceb; + --align-locked-bg: #1e3a5f; + --blue-bg-light: #1e3a5f; + --blue-bg-hover: #2e4a6f; + --toast-bg: #cdd6f4; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.header { + background: linear-gradient(135deg, var(--align-left-from), var(--align-left-to)); + color: white; + padding: 12px 20px; + flex-shrink: 0; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + position: relative; +} +.header h1 { font-size: 17px; font-weight: 600; } +.header .sub { font-size: 12px; opacity: 0.6; margin-top: 2px; } + +.theme-btn { + position: absolute; + right: 20px; + top: 12px; + padding: 4px 12px; + border: 1px solid rgba(255,255,255,0.3); + border-radius: 4px; + background: rgba(255,255,255,0.1); + color: white; + cursor: pointer; + font-size: 12px; + font-family: inherit; + transition: background 0.15s; + z-index: 10; +} +.theme-btn:hover { background: rgba(255,255,255,0.2); } + +.phase-tabs { + display: flex; + background: var(--bg-inset); + border-bottom: 1px solid var(--border-strong); + flex-shrink: 0; +} +.phase-tab { + padding: 8px 20px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + color: var(--text-muted); + border-bottom: 3px solid transparent; + transition: all 0.15s; + user-select: none; +} +.phase-tab:hover { background: var(--bg-hover); color: var(--text); } +.phase-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); + background: var(--bg); +} + +.summary-bar { + background: var(--bg-elevated); + padding: 8px 20px; + border-bottom: 1px solid var(--border); + font-size: 13px; + flex-shrink: 0; +} +.summary-bar .badge { + display: inline-block; + padding: 2px 10px; + border-radius: 10px; + margin-right: 8px; + font-weight: 600; + font-size: 12px; + cursor: pointer; + transition: all 0.15s; + border: 2px solid transparent; + user-select: none; +} +.summary-bar .badge:hover { filter: brightness(0.92); } +.summary-bar .badge.active { border-color: var(--text); box-shadow: 0 0 0 1px var(--text); } +.summary-bar .badge.dimmed { opacity: 0.35; } +.badge-total { background: var(--bg-inset); color: var(--text-dim); } +.badge-changed { background: var(--green-bg); color: var(--green-text); } +.badge-noop { background: var(--bg-hover); color: var(--text-fainter); } +.badge-failed { + background: var(--red); color: #fff; + font-weight: 700; font-size: 12px; + padding: 3px 12px; + animation: badgePulse 1.5s ease-in-out infinite; +} +.badge-skipped { + background: var(--amber); color: #fff; + font-weight: 700; font-size: 12px; + padding: 3px 12px; +} +.badge-codegen { + background: var(--purple-bg); color: var(--purple-text); + font-weight: 700; font-size: 12px; + padding: 3px 12px; +} +@keyframes badgePulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.4); } + 50% { box-shadow: 0 0 0 4px rgba(220, 38, 38, 0); } +} + +.main { + display: flex; + flex: 1; + overflow: hidden; + position: relative; +} + +.sidebar { + width: 270px; + min-width: 0; + background: var(--bg-elevated); + border-right: 1px solid var(--border); + overflow-y: auto; + overflow-x: hidden; + padding: 8px 0; + flex-shrink: 0; + transition: width 0.2s ease; + position: relative; +} +.sidebar.collapsed { + width: 0 !important; + padding: 0; + border-right: none; +} +.sidebar .section-title { + padding: 8px 14px 4px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-fainter); + font-weight: 700; + white-space: nowrap; +} + +.sidebar-resize { + width: 5px; + cursor: col-resize; + background: transparent; + flex-shrink: 0; + position: relative; + z-index: 20; + margin-left: -3px; + margin-right: -2px; +} +.sidebar-resize:hover, +.sidebar-resize.active { background: var(--accent-blue); } + +.sidebar-toggle-btn { + position: absolute; + top: 8px; + z-index: 30; + width: 28px; + height: 28px; + border: 1px solid var(--border-input); + border-radius: 6px; + background: var(--bg-elevated); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: background 0.15s, box-shadow 0.15s; + user-select: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.08); +} +.sidebar-toggle-btn:hover { background: var(--bg-hover); box-shadow: 0 1px 4px rgba(0,0,0,0.12); } + +.sidebar-toggle-btn .chevron { + display: block; + width: 7px; + height: 7px; + border-right: 2px solid var(--text-muted); + border-bottom: 2px solid var(--text-muted); + transition: transform 0.25s ease; +} +.sidebar-toggle-btn:hover .chevron { border-color: var(--text); } + +.sidebar-toggle-btn.inside { + left: auto; + right: 10px; + box-shadow: none; + border-color: var(--border); +} +.sidebar-toggle-btn.inside .chevron { + transform: rotate(135deg); + margin-left: 2px; +} + +#sidebar-open-btn { + left: 4px; +} +#sidebar-open-btn .chevron { + transform: rotate(-45deg); + margin-right: 2px; +} + +.pass-link { + display: flex; + align-items: center; + padding: 5px 14px; + font-size: 12.5px; + cursor: pointer; + color: var(--text-secondary); + text-decoration: none; + transition: background 0.1s, opacity 0.15s; + gap: 7px; + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; +} +.pass-link:hover { background: var(--bg-hover); } +.pass-link.active { background: var(--bg-active); color: var(--accent); } +.pass-link.filtered-out { display: none; } + +.pass-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.pass-dot.changed { background: var(--green); } +.pass-dot.noop { background: var(--text-dot-noop); } +.pass-dot.failed { + background: var(--red); + width: 10px; height: 10px; + box-shadow: 0 0 0 2px var(--red-bg-bright), 0 0 6px rgba(220,38,38,0.5); + animation: dotPulse 1.5s ease-in-out infinite; +} +.pass-dot.skipped { + background: transparent; + border: 2px solid var(--amber); + width: 10px; height: 10px; +} +.pass-dot.codegen { + background: var(--accent-blue); + width: 10px; height: 10px; + box-shadow: 0 0 0 2px rgba(37,99,235,0.2), 0 0 6px rgba(37,99,235,0.4); +} +@keyframes dotPulse { + 0%, 100% { box-shadow: 0 0 0 2px var(--red-bg-bright), 0 0 6px rgba(220,38,38,0.5); } + 50% { box-shadow: 0 0 0 4px var(--red-bg-bright), 0 0 10px rgba(220,38,38,0.3); } +} + +.pass-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.pass-idx { + font-size: 10px; + color: var(--text-fainter); + flex-shrink: 0; + width: 18px; + text-align: right; +} + +.pass-stats { + font-size: 10px; + flex-shrink: 0; + white-space: nowrap; + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; +} +.pass-stats .st-add { color: var(--green-dark); } +.pass-stats .st-del { color: var(--red-dark); } + +.content { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +.pass-section { + display: none; + background: var(--bg-elevated); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.05); + padding: 20px; + margin-bottom: 16px; +} +.pass-section.active { display: block; } +.pass-section.failed-section { + border-left: 4px solid var(--red); + background: var(--failed-bg); +} +.pass-section.skipped-section { + border-left: 4px solid var(--amber); + background: var(--skipped-bg); + opacity: 0.85; +} + +.pass-section.collapsed > *:not(.pass-header) { display: none; } + +.pass-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 14px; + padding-bottom: 10px; + border-bottom: 1px solid var(--bg-hover); + border-radius: 4px; + transition: background 0.1s; + position: sticky; + top: 0; + z-index: 10; + background: var(--bg-elevated); +} +.pass-header.collapsible { cursor: pointer; user-select: none; } +.pass-header.collapsible:hover { background: var(--bg-header-hover); } + +.pass-toggle { + width: 16px; + flex-shrink: 0; + font-size: 10px; + color: var(--text-fainter); + text-align: center; +} +.pass-section:not(.collapsed) > .pass-header .pass-toggle::before { content: '\\25BC'; } +.pass-section.collapsed > .pass-header .pass-toggle::before { content: '\\25B6'; } + +.pass-header h2 { font-size: 15px; font-weight: 600; } +.pass-header .status { + font-size: 11px; + font-weight: 700; + padding: 2px 10px; + border-radius: 10px; + letter-spacing: 0.03em; + margin-left: auto; +} +.status-changed { background: var(--green-bg); color: var(--green-text); } +.status-noop { background: var(--bg-hover); color: var(--text-fainter); } +.status-failed { + background: var(--red); color: #fff; + font-size: 12px; padding: 3px 14px; + border-radius: 10px; + animation: badgePulse 1.5s ease-in-out infinite; +} +.status-skipped { + background: var(--amber); color: #fff; + font-size: 12px; padding: 3px 14px; + border-radius: 10px; +} +.status-codegen { + background: var(--purple-bg); color: var(--purple-text); + font-size: 11px; padding: 2px 10px; + border-radius: 10px; + border: 1px solid var(--purple-text-light, var(--accent-blue)); +} + +.pass-section.codegen-section { + border-left: 4px solid var(--purple-text, var(--accent-blue)); +} + +.codegen-lang-label { + display: inline-block; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 2px 8px; + border-radius: 4px; + margin-bottom: 6px; +} +.codegen-lang-label.tir { background: var(--purple-bg-light, var(--bg-inset)); color: var(--purple-text, var(--text-dim)); } +.codegen-lang-label.cpp { background: var(--bg-active, var(--bg-inset)); color: var(--accent-blue, var(--text-dim)); } + +.codegen-lang-bar { + display: flex; + gap: 16px; + align-items: center; + margin-bottom: 8px; + padding: 4px 8px; + background: var(--bg-inset, var(--bg-elevated)); + border-radius: 4px; + font-size: 11px; +} +.codegen-lang-bar::before { + content: "▸"; + color: var(--text-faint); + margin-right: 4px; + font-size: 10px; +} + +.error-box { + background: var(--red-bg-lighter); + border: 1px solid var(--red-border); + border-left: 4px solid var(--red-accent); + border-radius: 6px; + padding: 12px 16px; + margin-bottom: 14px; + font-size: 13px; + color: var(--red-text); + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + line-height: 1.5; + word-break: break-word; +} +.error-box .error-label { + font-weight: 700; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 4px; + color: var(--red); +} + +.noop-msg { + color: var(--text-fainter); + font-size: 13px; + text-align: center; + padding: 16px; +} + +.ir-toggle { + display: block; + margin: 10px auto 0; + padding: 5px 18px; + background: var(--bg-hover); + border: 1px solid var(--border); + border-radius: 5px; + cursor: pointer; + font-size: 12px; + color: var(--text-muted); + transition: background 0.1s; +} +.ir-toggle:hover { background: var(--border); } + +.ir-block { + display: none; + margin-top: 10px; + max-height: 500px; + overflow: auto; +} +.ir-block.show { display: block; } + +.ir-block pre { + background: var(--bg-elevated); + color: var(--text-diff); + padding: 14px; + border-radius: 6px; + border: 1px solid var(--border-input); + font-size: 12px; + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + line-height: 20px; + white-space: pre; + tab-size: 2; +} +.ir-block .ir-line { + display: block; +} +.ir-block .ir-line.hl { + background: var(--yellow-bg); +} +.ir-block .ir-ln { + display: inline-block; + width: 50px; + text-align: right; + padding-right: 12px; + color: var(--text-faint); + user-select: none; + cursor: pointer; +} +.ir-block .ir-ln:hover { + text-decoration: underline; +} + +.diff-table-wrap { + overflow-x: auto; + border-radius: 6px; + border: 1px solid var(--border-input); + background: var(--bg-elevated); +} +.diff-table-wrap table { + width: 100%; + border-collapse: collapse; + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + font-size: 12px; + line-height: 20px; + table-layout: fixed; +} +.diff-table-wrap td { + padding: 0 10px; + white-space: pre-wrap; + word-break: break-all; + vertical-align: top; +} + +.diff-table-wrap .ln { + width: 50px; + min-width: 50px; + max-width: 50px; + text-align: right; + padding: 0 8px; + color: var(--text-faint); + background: var(--bg-code); + border-right: 1px solid var(--border-input); + user-select: none; + font-size: 11px; +} + +.diff-table-wrap .sg { + width: 20px; + min-width: 20px; + max-width: 20px; + text-align: center; + padding: 0; + user-select: none; + font-weight: 700; +} + +.diff-table-wrap .ln-eq { background: var(--bg-code); } +.diff-table-wrap .eq { background: var(--bg-elevated); } + +.diff-table-wrap .ln-del { background: var(--red-bg-light); color: var(--red-text-dark); } +.diff-table-wrap .sg-del { background: var(--red-bg-light); color: var(--red-dark); } +.diff-table-wrap .del { background: var(--red-bg); color: var(--text-diff); } +.diff-table-wrap .del-word { background: var(--red-word); border-radius: 2px; } + +.diff-table-wrap .ln-add { background: var(--green-light); color: var(--green-text-dark); } +.diff-table-wrap .sg-add { background: var(--green-light); color: var(--green-dark); } +.diff-table-wrap .add { background: var(--green-bg-light); color: var(--text-diff); } +.diff-table-wrap .add-word { background: var(--green-word); border-radius: 2px; } + +.diff-table-wrap .ln-ws { background: var(--purple-bg-light); color: var(--purple-text); } +.diff-table-wrap .sg-ws { background: var(--purple-bg-light); color: var(--purple-text-light); } +.diff-table-wrap .ws { background: var(--purple-bg); color: var(--text-diff); } +.diff-table-wrap .ws .del-word { background: var(--purple-word); border-radius: 2px; } +.diff-table-wrap .ws .add-word { background: var(--purple-word); border-radius: 2px; } + +.diff-table-wrap tr.row-hidden { display: none; } + +.diff-table-wrap tr.row-hl td { background: var(--yellow-bg) !important; } +.diff-table-wrap td.ln:not(:empty) { cursor: pointer; } +.diff-table-wrap td.ln:not(:empty):hover { text-decoration: underline; } + +/* Inline expand button rows (GitHub-style full-row) */ +.diff-table-wrap .btn-row td { + text-align: center; + padding: 4px 8px; + background: var(--blue-bg-light, #ddf4ff); + color: var(--accent-blue); + cursor: pointer; + font-size: 12px; + border-top: 1px solid var(--accent-blue); + border-bottom: 1px solid var(--accent-blue); + user-select: none; + line-height: 20px; +} +.diff-table-wrap .btn-row td:hover { background: var(--blue-bg-hover, #c8e9ff); } +.diff-table-wrap .btn-row.all-expanded td { display: none; } +.diff-table-wrap .btn-row .exp-arrow { font-weight: 700; font-size: 14px; padding: 0 2px; } +.diff-table-wrap .btn-row .exp-label { padding: 0 4px; } + +/* ---- Manual alignment mode (Beyond Compare style) ---- */ +.align-status { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + padding: 8px 20px; + font-size: 13px; + font-family: 'Segoe UI', system-ui, sans-serif; + text-align: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); +} +.align-status.active { display: block; } +.align-status.mode-left { background: var(--align-pending-bg); color: var(--align-pending-text); } +.align-status.mode-right { background: var(--align-right-bg); color: var(--align-right-text); } +.align-status kbd { + display: inline-block; + padding: 1px 6px; + font-size: 11px; + font-family: inherit; + background: rgba(0,0,0,0.08); + border: 1px solid rgba(0,0,0,0.15); + border-radius: 3px; + margin: 0 2px; +} + +.diff-table-wrap td.align-pending { + outline: 2px solid var(--amber); + outline-offset: -2px; + background: var(--align-pending-bg) !important; +} +.diff-table-wrap td.align-pending-sibling { + background: var(--align-pending-bg) !important; +} + +.diff-table-wrap td.align-locked { + outline: 2px solid var(--accent-blue); + outline-offset: -2px; + background: var(--align-locked-bg) !important; +} +.diff-table-wrap td.align-locked-sibling { + background: var(--align-locked-bg) !important; +} + +.diff-table-wrap.waiting-right td[data-side="r"]:not(:empty) { + cursor: crosshair; +} +.diff-table-wrap.waiting-right td[data-side="r"]:not(:empty):hover { + outline: 2px dashed var(--accent-blue); + outline-offset: -2px; +} + +.diff-table-wrap tr.row-aligned { + border-left: 3px solid var(--amber); +} + +.diff-toolbar { + display: flex; + gap: 6px; + margin-bottom: 6px; + padding: 6px 8px; + background: var(--bg-code); + border: 1px solid var(--border-input); + border-bottom: none; + border-radius: 6px 6px 0 0; +} +.diff-toolbar + .diff-table-wrap { border-radius: 0 0 6px 6px; } +.diff-toolbar button { + padding: 3px 10px; + font-size: 12px; + cursor: pointer; + background: var(--bg-elevated); + border: 1px solid var(--border-input); + border-radius: 4px; + color: var(--text-diff); + line-height: 20px; + transition: background 0.1s; +} +.diff-toolbar button:hover:not(:disabled) { background: var(--bg-btn-hover); } +.diff-toolbar button:disabled { opacity: 0.35; cursor: default; } + +.btn-copy { float: right; } +.copy-spacer { flex: 1; } +.copy-toast { + position: fixed; + bottom: 24px; + right: 24px; + background: var(--toast-bg); + color: var(--bg); + padding: 8px 18px; + border-radius: 6px; + font-size: 13px; + z-index: 9999; + animation: toastFade 1.5s ease forwards; + pointer-events: none; +} +@keyframes toastFade { + 0%,60% { opacity: 1; } + 100% { opacity: 0; } +} +""" + +_JS = """ +var _activeFilter = null; + +function getStoredTheme() { + try { return localStorage.getItem('lower-trace-theme'); } catch (e) { return null; } +} + +function setStoredTheme(theme) { + try { localStorage.setItem('lower-trace-theme', theme); } catch (e) {} +} + +function toggleTheme() { + var html = document.documentElement; + var btn = document.getElementById('theme-btn'); + if (!btn) return; + if (html.getAttribute('data-theme') === 'dark') { + html.removeAttribute('data-theme'); + btn.textContent = '\\u263E Dark'; + setStoredTheme('light'); + } else { + html.setAttribute('data-theme', 'dark'); + btn.textContent = '\\u2600 Light'; + setStoredTheme('dark'); + } +} + +function filterByBadge(badgeEl) { + var filter = badgeEl.getAttribute('data-filter'); + var bar = badgeEl.closest('.summary-bar'); + var allBadges = bar.querySelectorAll('.badge'); + + if (_activeFilter === filter || filter === 'all') { + _activeFilter = null; + } else { + _activeFilter = filter; + } + + allBadges.forEach(function(b) { + b.classList.remove('active', 'dimmed'); + if (_activeFilter) { + if (b.getAttribute('data-filter') === _activeFilter) { + b.classList.add('active'); + } else if (b.getAttribute('data-filter') !== 'all') { + b.classList.add('dimmed'); + } + } + }); + + var sidebar = document.querySelector('.sidebar:not([style*="display: none"])'); + if (!sidebar) sidebar = document.querySelector('.sidebar'); + if (!sidebar) return; + + var links = sidebar.querySelectorAll('.pass-link'); + var firstVisible = null; + + links.forEach(function(link) { + var status = link.getAttribute('data-status'); + if (!_activeFilter || status === _activeFilter) { + link.classList.remove('filtered-out'); + if (!firstVisible) firstVisible = link; + } else { + link.classList.add('filtered-out'); + } + }); + + if (firstVisible) { + var activeLink = sidebar.querySelector('.pass-link.active'); + if (!activeLink || activeLink.classList.contains('filtered-out')) { + var sid = firstVisible.getAttribute('data-target'); + showPass(firstVisible, sid); + } + } +} + +function showPass(el, id) { + document.querySelectorAll('.pass-section').forEach(s => s.classList.remove('active')); + document.querySelectorAll('.pass-link').forEach(l => l.classList.remove('active')); + var sec = document.getElementById(id); + if (sec) sec.classList.add('active'); + if (el) el.classList.add('active'); + if (typeof cancelAlign === 'function') cancelAlign(); + if (sec) { + var tbl = sec.querySelector('table'); + if (tbl && typeof updBtns === 'function') updBtns(tbl); + } +} + +function showPhase(el, phase) { + document.querySelectorAll('.phase-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.sidebar').forEach(s => s.style.display = 'none'); + document.querySelectorAll('.summary-bar').forEach(s => s.style.display = 'none'); + if (el) el.classList.add('active'); + var sb = document.getElementById('sb-' + phase); + if (sb) sb.style.display = ''; + var sm = document.getElementById('sm-' + phase); + if (sm) sm.style.display = ''; + document.querySelectorAll('.pass-section').forEach(s => s.classList.remove('active')); + document.querySelectorAll('.pass-link').forEach(l => l.classList.remove('active')); + var first = document.querySelector('[data-phase="' + phase + '"]'); + if (first) { + var sid = first.getAttribute('data-target'); + showPass(first, sid); + } +} + +function toggleIr(btn) { + var section = btn.closest('.pass-section'); + var block = section ? section.querySelector('.ir-block') : null; + if (block) { + block.classList.toggle('show'); + btn.textContent = block.classList.contains('show') + ? '\\u25BC Collapse IR' : '\\u25B6 Show full IR'; + } +} + +function toggleIrLine(ln) { + var line = ln.closest('.ir-line'); + var block = ln.closest('.ir-block'); + if (!line || !block) return; + var wasHl = line.classList.contains('hl'); + block.querySelectorAll('.ir-line.hl').forEach(function(l) { l.classList.remove('hl'); }); + if (!wasHl) line.classList.add('hl'); +} + +function toggleCollapse(el) { + var sec = el.closest('.pass-section'); + sec.classList.toggle('collapsed'); +} + +function copyIr(btn, side) { + var sec = btn.closest('.pass-section'); + var el = sec.querySelector('.ir-data-' + side); + if (!el) return; + var text = el.textContent; + var label = side === 'before' ? 'Before' : side === 'after' ? 'After' : 'IR'; + function showToast() { + var toast = document.createElement('div'); + toast.className = 'copy-toast'; + toast.textContent = 'Copied ' + label + ' IR'; + document.body.appendChild(toast); + setTimeout(function() { toast.remove(); }, 1600); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(showToast).catch(function() { + fallbackCopy(text, showToast); + }); + } else { + fallbackCopy(text, showToast); + } +} +function fallbackCopy(text, cb) { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;left:-9999px'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); if (cb) cb(); } catch(e) {} + ta.remove(); +} + +function expand(el, n, evt) { + var run = el.dataset.run; + var tr = el.closest('tr'); + var table = tr.closest('table'); + var limit = (evt && evt.altKey) ? 999999 : n; + var shown = 0, r; + r = tr.previousElementSibling; + while (r && shown < limit) { + if (!r.classList.contains('row-hidden') || r.dataset.run !== run) break; + r.classList.remove('row-hidden'); shown++; + r = r.previousElementSibling; + } + shown = 0; + r = tr.nextElementSibling; + while (r && shown < limit) { + if (!r.classList.contains('row-hidden') || r.dataset.run !== run) break; + r.classList.remove('row-hidden'); shown++; + r = r.nextElementSibling; + } + updBtns(table); +} + +function expandAll(btn) { + var table = btn.closest('.pass-section').querySelector('table'); + table.querySelectorAll('tr.row-hidden').forEach(function(r) { r.classList.remove('row-hidden'); }); + updBtns(table); +} + +function collapseCtx(btn) { + var table = btn.closest('.pass-section').querySelector('table'); + table.querySelectorAll('tr[data-collapse="1"]').forEach(function(r) { + if (!r.classList.contains('btn-row')) r.classList.add('row-hidden'); + }); + updBtns(table); +} + +function updBtns(table) { + var sec = table.closest('.pass-section'); + var hid = table.querySelectorAll('tr.row-hidden'); + var ba = sec.querySelector('.btn-expand-all'); + if (hid.length === 0) { + table.querySelectorAll('.btn-row').forEach(function(r) { r.classList.add('all-expanded'); }); + if (ba) ba.disabled = true; + return; + } + table.querySelectorAll('.btn-row td[data-run]').forEach(function(td) { + var run = td.dataset.run; + var tr = td.closest('tr'); + var hasHidden = false; + var r = tr.previousElementSibling; + while (r) { + if (r.classList.contains('row-hidden') && r.dataset.run === run) { hasHidden = true; break; } + if (!r.classList.contains('row-hidden') || r.dataset.run !== run) break; + r = r.previousElementSibling; + } + if (!hasHidden) { + r = tr.nextElementSibling; + while (r) { + if (r.classList.contains('row-hidden') && r.dataset.run === run) { hasHidden = true; break; } + if (!r.classList.contains('row-hidden') || r.dataset.run !== run) break; + r = r.nextElementSibling; + } + } + tr.style.display = hasHidden ? '' : 'none'; + }); + if (ba) ba.disabled = false; +} + +/* ---- F7 Manual alignment (Beyond Compare style) ---- */ +var _alignMode = null; +var _pendingLeft = null; +var _alignStatus = null; + +function cancelAlign() { + if (_pendingLeft) { + var row = _pendingLeft.closest('tr'); + if (row) row.querySelectorAll('.align-pending,.align-pending-sibling,.align-locked,.align-locked-sibling') + .forEach(function(c){ c.classList.remove('align-pending','align-pending-sibling','align-locked','align-locked-sibling'); }); + } + document.querySelectorAll('.align-locked,.align-locked-sibling').forEach(function(c){ c.classList.remove('align-locked','align-locked-sibling'); }); + document.querySelectorAll('.waiting-right').forEach(function(c){ c.classList.remove('waiting-right'); }); + _pendingLeft = null; + _alignMode = null; + if (_alignStatus) { _alignStatus.className = 'align-status'; _alignStatus.innerHTML = ''; } +} + +function showAlignStatus(mode, msg) { + if (!_alignStatus) return; + _alignStatus.className = 'align-status active mode-' + mode; + _alignStatus.innerHTML = msg; +} + +function alignRows(leftTd, rightTd) { + var lIdx = parseInt(leftTd.getAttribute('data-idx'), 10); + var rIdx = parseInt(rightTd.getAttribute('data-idx'), 10); + var section = leftTd.closest('.pass-section'); + var beforeLines = getBeforeLines(section); + var afterLines = getAfterLines(section); + if (isNaN(lIdx) || isNaN(rIdx)) return; + + var upper = lcsOpcodes(beforeLines.slice(0, lIdx), afterLines.slice(0, rIdx), 0, 0); + var lower = lcsOpcodes(beforeLines.slice(lIdx + 1), afterLines.slice(rIdx + 1), lIdx + 1, rIdx + 1); + var mid = [{ tag: 'replace', i1: lIdx, i2: lIdx + 1, j1: rIdx, j2: rIdx + 1 }]; + var opcodes = upper.concat(mid, lower); + + var wrap = section.querySelector('.diff-table-wrap'); + var table = wrap.querySelector('table'); + table.innerHTML = buildDiffRowsHtml(opcodes, beforeLines, afterLines, lIdx, rIdx); +} + +function getBeforeLines(section) { + var el = section.querySelector('.ir-data-before'); + return el ? el.textContent.split('\\n') : []; +} +function getAfterLines(section) { + var el = section.querySelector('.ir-data-after'); + return el ? el.textContent.split('\\n') : []; +} + +function lcsOpcodes(a, b, aOff, bOff) { + var m = a.length, n = b.length; + var dp = []; + for (var i = 0; i <= m; i++) { dp[i] = new Array(n + 1).fill(0); } + for (var i = m - 1; i >= 0; i--) { + for (var j = n - 1; j >= 0; j--) { + dp[i][j] = (a[i] === b[j]) ? dp[i+1][j+1] + 1 : Math.max(dp[i+1][j], dp[i][j+1]); + } + } + var blocks = [], i = 0, j = 0; + while (i < m && j < n) { + if (a[i] === b[j]) { + var si = i, sj = j; + while (i < m && j < n && a[i] === b[j]) { i++; j++; } + blocks.push([si, sj, i - si]); + } else if (dp[i+1][j] >= dp[i][j+1]) { i++; } else { j++; } + } + var ops = [], ai = 0, bi = 0; + for (var k = 0; k < blocks.length; k++) { + var si = blocks[k][0], sj = blocks[k][1], size = blocks[k][2]; + if (si > ai || sj > bi) { + var tag = (si > ai && sj > bi) ? 'replace' : (si > ai ? 'delete' : 'insert'); + ops.push({ tag: tag, i1: ai + aOff, i2: si + aOff, j1: bi + bOff, j2: sj + bOff }); + } + ops.push({ tag: 'equal', i1: si + aOff, i2: si + size + aOff, j1: sj + bOff, j2: sj + size + bOff }); + ai = si + size; bi = sj + size; + } + if (ai < m || bi < n) { + var tag = (ai < m && bi < n) ? 'replace' : (ai < m ? 'delete' : 'insert'); + ops.push({ tag: tag, i1: ai + aOff, i2: m + aOff, j1: bi + bOff, j2: n + bOff }); + } + return ops; +} + +function buildDiffRowsHtml(opcodes, beforeLines, afterLines, pinL, pinR) { + var s = ''; + 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('' + + '' + (li+1) + '' + + '~' + diff.left + '' + + '' + (ri+1) + '' + + '~' + diff.right + ''); + } else { + var cls = isPinned ? ' class="row-aligned"' : ''; + rows.push('' + + '' + (li+1) + '' + + '\u2212' + diff.left + '' + + '' + (ri+1) + '' + + '+' + diff.right + ''); + } + } else if (li !== null) { + rows.push('' + + '' + (li+1) + '' + + '\u2212' + escHtml(beforeLines[li]||'') + '' + + ''); + } else { + rows.push('' + + '' + + '' + (ri+1) + '' + + '+' + escHtml(afterLines[ri]||'') + ''); + } + } + } else if (op.tag === 'delete') { + for (var i = op.i1; i < op.i2; i++) { + rows.push('' + + '' + (i+1) + '' + + '\u2212' + escHtml(beforeLines[i]||'') + '' + + ''); + } + } else if (op.tag === 'insert') { + for (var j = op.j1; j < op.j2; j++) { + rows.push('' + + '' + + '' + (j+1) + '' + + '+' + escHtml(afterLines[j]||'') + ''); + } + } + } + return s + rows.join(''); +} + +function jsInlineDiff(before, after) { + var m = before.length, n = after.length; + var left = [], right = [], isWsOnly = true; + var sm = []; + for (var i = 0; i <= m; i++) { + sm[i] = []; + for (var j = 0; j <= n; j++) sm[i][j] = 0; + } + for (var i = m - 1; i >= 0; i--) { + for (var j = n - 1; j >= 0; j--) { + if (before[i] === after[j]) sm[i][j] = sm[i+1][j+1] + 1; + else sm[i][j] = Math.max(sm[i+1][j], sm[i][j+1]); + } + } + var i = 0, j = 0; + while (i < m && j < n) { + if (before[i] === after[j]) { + left.push(escHtml(before[i])); + right.push(escHtml(after[j])); + i++; j++; + } else if (sm[i+1] && sm[i+1][j] >= sm[i][j+1]) { + var chunk = before[i]; + i++; + while (i < m && (j >= n || (sm[i+1] && sm[i+1][j] >= sm[i][j+1])) && before[i] !== after[j]) { + chunk += before[i]; i++; + } + if (chunk.trim() !== '') isWsOnly = false; + left.push('' + escHtml(chunk) + ''); + } else { + var chunk = after[j]; + j++; + while (j < n && (i >= m || sm[i][j+1] > sm[i+1][j]) && before[i] !== after[j]) { + chunk += after[j]; j++; + } + if (chunk.trim() !== '') isWsOnly = false; + right.push('' + escHtml(chunk) + ''); + } + } + if (i < m) { + var rest = before.slice(i); + if (rest.trim() !== '') isWsOnly = false; + left.push('' + escHtml(rest) + ''); + } + if (j < n) { + var rest = after.slice(j); + if (rest.trim() !== '') isWsOnly = false; + right.push('' + escHtml(rest) + ''); + } + return { left: left.join(''), right: right.join(''), isWsOnly: isWsOnly }; +} + +function escHtml(text) { + return text.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +function initSidebarResize() { + var handle = document.getElementById('sidebar-resize'); + var openBtn = document.getElementById('sidebar-open-btn'); + if (!handle) return; + + function getVisibleSidebar() { + return document.querySelector('.sidebar:not([style*="display: none"])') || document.querySelector('.sidebar'); + } + + var sidebar = null; + var dragging = false, startX = 0, startW = 0; + + handle.addEventListener('mousedown', function(e) { + sidebar = getVisibleSidebar(); + if (!sidebar || sidebar.classList.contains('collapsed')) return; + dragging = true; + startX = e.clientX; + startW = sidebar.offsetWidth; + handle.classList.add('active'); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + e.preventDefault(); + }); + + document.addEventListener('mousemove', function(e) { + if (!dragging || !sidebar) return; + var w = Math.max(150, Math.min(600, startW + e.clientX - startX)); + sidebar.style.width = w + 'px'; + }); + + document.addEventListener('mouseup', function() { + if (!dragging) return; + dragging = false; + handle.classList.remove('active'); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }); +} + +function toggleSidebar(btn) { + var sidebars = document.querySelectorAll('.sidebar'); + var handle = document.getElementById('sidebar-resize'); + var openBtn = document.getElementById('sidebar-open-btn'); + sidebars.forEach(function(s) { s.classList.add('collapsed'); }); + if (handle) handle.style.display = 'none'; + if (openBtn) openBtn.style.display = ''; + document.querySelectorAll('.sidebar-toggle-btn.inside').forEach(function(b) { b.style.display = 'none'; }); +} + +function openSidebar() { + var sidebars = document.querySelectorAll('.sidebar'); + var handle = document.getElementById('sidebar-resize'); + var openBtn = document.getElementById('sidebar-open-btn'); + sidebars.forEach(function(s) { s.classList.remove('collapsed'); s.style.width = ''; }); + if (handle) handle.style.display = ''; + if (openBtn) openBtn.style.display = 'none'; + document.querySelectorAll('.sidebar-toggle-btn.inside').forEach(function(b) { b.style.display = ''; }); +} +""" + + +def render_pass_section(rec: LowerRecord) -> str: + """Render a single pass section as HTML.""" + sid = f"sec-{rec.phase}-{rec.index}" + + if rec.status == STATUS_FAILED: + error_html = f'
Exception
{_esc(rec.error_msg)}
' + if rec.before_text: + _ir_lines = rec.before_text.splitlines() + _ln_width = len(str(len(_ir_lines))) + _ir_with_ln = "".join( + f'{str(i + 1).rjust(_ln_width)}{_esc(line)}' + for i, line in enumerate(_ir_lines) + ) + before_content = ( + '

IR before this pass (execution failed):

' + '' + f'
{_ir_with_ln}
' + ) + else: + before_content = "" + status_html = '✘ FAILED' + return ( + f'
' + f'
' + f"

{rec.index:02d}. {_esc(rec.name)}

" + f"{status_html}" + f"
" + f"{error_html}" + f"{before_content}" + f'' + f"
" + ) + + elif rec.status == STATUS_SKIPPED: + status_html = '— SKIPPED' + return ( + f'
' + f'
' + f"

{rec.index:02d}. {_esc(rec.name)}

" + f"{status_html}" + f"
" + f'

This pass did not run (a previous pass failed).

' + f"
" + ) + + elif rec.changed: + diff_html = _make_diff_html(rec.before_text, rec.after_text, context=3) + if rec.status == STATUS_CODEGEN: + lang_labels = ( + '
' + 'Lowered TIR' + 'Generated C++' + "
" + ) + diff_content = ( + f'
' + f'' + f'' + f'' + f'' + f'' + f"
" + f"{lang_labels}" + f"{diff_html}" + ) + status_html = 'CODEGEN' + return ( + f'
' + f'
' + f'' + f"

{rec.index:02d}. {_esc(rec.name)}

" + f"{status_html}" + f"
" + f"{diff_content}" + f'\n' + f'\n' + f"
" + ) + diff_content = ( + f'
' + f'' + f'' + f'' + f'' + f'' + f"
" + f"{diff_html}" + ) + status_html = 'CHANGED' + return ( + f'
' + f'
' + f'' + f"

{rec.index:02d}. {_esc(rec.name)}

" + f"{status_html}" + f"
" + f"{diff_content}" + f'' + f'' + f"
" + ) + else: + _ir_lines = rec.after_text.splitlines() + _ln_width = len(str(len(_ir_lines))) + _ir_with_ln = "".join( + f'{str(i + 1).rjust(_ln_width)}{_esc(line)}' + for i, line in enumerate(_ir_lines) + ) + diff_content = ( + '

This pass did not modify the IR.

' + '' + '' + f'
{_ir_with_ln}
' + ) + status_html = 'NO-OP' + return ( + f'
' + f'
' + f"

{rec.index:02d}. {_esc(rec.name)}

" + f"{status_html}" + f"
" + f"{diff_content}" + f'' + f"
" + ) + + +def generate_html(records: list[LowerRecord], output_path: str, section_cache: dict | None = None): + """Generate a self-contained HTML file with pass trace visualization. + + ``section_cache`` (keyed by ``(phase, index)``) memoizes the expensive + per-pass section rendering (which includes the IR diff). When supplied by + the incremental flush path, previously rendered sections are reused so the + diff is computed at most once per record, keeping total cost O(n). + """ + if section_cache is None: + section_cache = {} + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) + + phases: dict = {} + for rec in records: + phases.setdefault(rec.phase, []).append(rec) + + phase_tabs_html = [] + sidebars_html = [] + summaries_html = [] + sections_html = [] + + for pi, (phase_name, phase_records) in enumerate(phases.items()): + is_active = pi == 0 + active_cls = " active" if is_active else "" + active_style = "" if is_active else ' style="display:none"' + + pretty_phase = ( + phase_name.replace("_", " ") + .replace("phase1", "Phase 1:") + .replace("phase2", "Phase 2:") + .replace("pipeline", "Pipeline:") + .replace("codegen", "Codegen") + ) + pretty_phase = pretty_phase.strip() + if pretty_phase and pretty_phase[0].islower(): + pretty_phase = pretty_phase[0].upper() + pretty_phase[1:] + + n_total = len(phase_records) + n_completed = sum(1 for r in phase_records if r.status == STATUS_COMPLETED) + n_changed = sum(1 for r in phase_records if r.changed and r.status != STATUS_CODEGEN) + n_codegen = sum(1 for r in phase_records if r.status == STATUS_CODEGEN) + n_failed = sum(1 for r in phase_records if r.status == STATUS_FAILED) + n_skipped = sum(1 for r in phase_records if r.status == STATUS_SKIPPED) + n_noop = n_completed - n_changed + + phase_tabs_html.append( + f'
{_esc(pretty_phase)}
' + ) + + failed_badge = "" + skipped_badge = "" + codegen_badge = "" + if n_failed: + failed_badge = f'✘ {n_failed} failed' + if n_skipped: + skipped_badge = ( + f'— {n_skipped} skipped' + ) + if n_codegen: + codegen_badge = ( + f'{n_codegen} codegen' + ) + summaries_html.append( + f'
' + f'{n_total} passes' + f'{n_changed} changed' + f'{n_noop} no-op' + f"{codegen_badge}" + f"{failed_badge}" + f"{skipped_badge}" + f"
" + ) + + links = [] + for rec in phase_records: + if rec.status == STATUS_FAILED: + dot_cls = "failed" + status_attr = "failed" + elif rec.status == STATUS_SKIPPED: + dot_cls = "skipped" + status_attr = "skipped" + elif rec.status == STATUS_CODEGEN: + dot_cls = "codegen" + status_attr = "codegen" + elif rec.changed: + dot_cls = "changed" + status_attr = "changed" + else: + dot_cls = "noop" + status_attr = "noop" + sid = f"sec-{rec.phase}-{rec.index}" + stats_html = "" + if rec.changed: + stats_html = ( + f'' + f'+{rec.add_lines} ' + f'−{rec.del_lines}' + f"" + ) + elif rec.status == STATUS_FAILED: + stats_html = 'ERROR' + elif rec.status == STATUS_SKIPPED: + stats_html = '' + links.append( + f'' + f'{rec.index:02d}' + f'' + f'{_esc(rec.name)}' + f"{stats_html}" + f"" + ) + sidebars_html.append( + f'" + ) + + for rec in phase_records: + key = (rec.phase, rec.index) + rendered = section_cache.get(key) + if rendered is None: + rendered = render_pass_section(rec) + section_cache[key] = rendered + sections_html.append(rendered) + + auto_show_js = "" + if records: + first_sid = f"sec-{records[0].phase}-{records[0].index}" + auto_show_js = ( + "document.addEventListener('DOMContentLoaded', function() {\n" + f" var firstSid = {_js_safe_json(first_sid)};\n" + " var el = document.querySelector('[data-target=\"' + firstSid + '\"]');\n" + " if (el) showPass(el, firstSid);\n" + " if (typeof initSidebarResize === 'function') initSidebarResize();\n" + " _alignStatus = document.getElementById('align-status');\n" + " var _saved = getStoredTheme();\n" + " if (_saved === 'dark') {\n" + " document.documentElement.setAttribute('data-theme', 'dark');\n" + " var _tb = document.getElementById('theme-btn');\n" + " if (_tb) _tb.textContent = '\\u2600 Light';\n" + " }\n" + "\n" + " var passLinks = document.getElementsByClassName('pass-link');\n" + " var activeLink = el || null;\n" + "\n" + " document.addEventListener('keydown', function(e) {\n" + " var tag = (e.target.tagName || '').toLowerCase();\n" + " if (tag === 'input' || tag === 'textarea' || tag === 'select') return;\n" + "\n" + " if (e.key === 'j' || e.key === 'k') {\n" + " e.preventDefault();\n" + " var idx = -1;\n" + " var currentLink = document.querySelector('.pass-link.active') || activeLink;\n" + " for (var i = 0; i < passLinks.length; i++) {\n" + " if (passLinks[i] === currentLink) { idx = i; break; }\n" + " }\n" + " if (e.key === 'j' && idx < passLinks.length - 1) idx++;\n" + " else if (e.key === 'k' && idx > 0) idx--;\n" + " else return;\n" + " var next = passLinks[idx];\n" + " var phase = next.getAttribute('data-phase');\n" + " var curTab = document.querySelector('.phase-tab.active');\n" + " if (!curTab || curTab.textContent.indexOf(phase.replace('phase1','Phase 1').replace('phase2','Phase 2')) < 0) {\n" + " var tabs = document.querySelectorAll('.phase-tab');\n" + " for (var t = 0; t < tabs.length; t++) {\n" + " if (tabs[t].getAttribute('onclick').indexOf(phase) > -1) {\n" + " showPhase(tabs[t], phase); break;\n" + " }\n" + " }\n" + " }\n" + " activeLink = next;\n" + " var sid = next.getAttribute('data-target');\n" + " showPass(next, sid);\n" + " next.scrollIntoView({block: 'nearest'});\n" + " var sec = document.getElementById(sid);\n" + " if (sec) sec.scrollIntoView({block: 'start'});\n" + " }\n" + "\n" + " if (e.key === 'E' && e.shiftKey && !e.ctrlKey && !e.metaKey) {\n" + " e.preventDefault();\n" + " document.querySelectorAll('.pass-section table tr.row-hidden').forEach(function(r) {\n" + " r.classList.remove('row-hidden');\n" + " });\n" + " document.querySelectorAll('.pass-section table').forEach(function(tbl) { updBtns(tbl); });\n" + " }\n" + "\n" + " if (e.key === 'F7') {\n" + " e.preventDefault();\n" + " if (_alignMode === null) {\n" + " _alignMode = 'left';\n" + " showAlignStatus('left', 'Align: Click a left (before) line number   F7 to confirm   Esc to cancel');\n" + " } else if (_alignMode === 'left' && _pendingLeft) {\n" + " _alignMode = 'right';\n" + " _pendingLeft.classList.remove('align-pending');\n" + " _pendingLeft.classList.add('align-locked');\n" + " var sib = _pendingLeft.nextElementSibling;\n" + " while (sib && sib.getAttribute('data-side') !== 'r') {\n" + " if (sib.getAttribute('data-side') === 'l') sib.classList.add('align-locked-sibling');\n" + " sib = sib.nextElementSibling;\n" + " }\n" + " var wrap = _pendingLeft.closest('.diff-table-wrap');\n" + " if (wrap) wrap.classList.add('waiting-right');\n" + " showAlignStatus('right', 'Align: Click a right (after) line number to align   Esc to cancel');\n" + " } else if (_alignMode === 'right') {\n" + " cancelAlign();\n" + " }\n" + " return;\n" + " }\n" + "\n" + " if (e.key === 'Escape' && _alignMode) {\n" + " e.preventDefault();\n" + " cancelAlign();\n" + " return;\n" + " }\n" + " });\n" + "\n" + " document.addEventListener('click', function(e) {\n" + " if (!_alignMode) return;\n" + " var td = e.target.closest('td[data-side]');\n" + " if (!td) return;\n" + " var section = td.closest('.pass-section.active');\n" + " if (!section) return;\n" + " if (_alignMode === 'left' && td.getAttribute('data-side') === 'l' && td.textContent.trim() !== '') {\n" + " e.stopPropagation();\n" + " if (_pendingLeft) {\n" + " var oldRow = _pendingLeft.closest('tr');\n" + " if (oldRow) oldRow.querySelectorAll('.align-pending,.align-pending-sibling')\n" + " .forEach(function(c){ c.classList.remove('align-pending','align-pending-sibling'); });\n" + " }\n" + " _pendingLeft = td;\n" + " td.classList.add('align-pending');\n" + " var sib = td.nextElementSibling;\n" + " while (sib && sib.getAttribute('data-side') !== 'r') {\n" + " if (sib.getAttribute('data-side') === 'l') sib.classList.add('align-pending-sibling');\n" + " sib = sib.nextElementSibling;\n" + " }\n" + " showAlignStatus('left', 'Left line ' + td.textContent.trim() + ' selected   Press F7 to confirm   Esc to cancel');\n" + " return;\n" + " }\n" + " if (_alignMode === 'right' && td.getAttribute('data-side') === 'r' && td.textContent.trim() !== '' && _pendingLeft) {\n" + " e.stopPropagation();\n" + " alignRows(_pendingLeft, td);\n" + " cancelAlign();\n" + " return;\n" + " }\n" + " }, true);\n" + "\n" + " document.addEventListener('click', function(e) {\n" + " var td = e.target.closest('td[data-side]');\n" + " if (!td) return;\n" + " var tr = td.closest('tr');\n" + " if (!tr || tr.closest('.pass-section.active') === null) return;\n" + " var wasHl = tr.classList.contains('row-hl');\n" + " document.querySelectorAll('tr.row-hl').forEach(function(r) { r.classList.remove('row-hl'); });\n" + " if (!wasHl) tr.classList.add('row-hl');\n" + " });\n" + "});\n" + ) + + html = f""" + + + +TileLang Lower Trace + + + +
+
+

TileLang Lower Trace

+
Compilation pipeline visualization · {len(records)} passes recorded
+ +
+
+ {"".join(phase_tabs_html)} +
+{"".join(summaries_html)} +
+ {"".join(sidebars_html)} + + +
+ {"".join(sections_html)} +
+
+ + +""" + + with open(output_path, "w", encoding="utf-8") as f: + f.write(html)