diff --git a/tests/unit/ir/test_failures.py b/tests/unit/ir/test_failures.py new file mode 100644 index 00000000..4c0fd01a --- /dev/null +++ b/tests/unit/ir/test_failures.py @@ -0,0 +1,66 @@ +"""tilelens.ir.failures: what a config's failed host compile means. The +compiled sanitizer's verdicts built on it are pinned in +tests/unit/sanitizer_compiled/test_client.py.""" + +from __future__ import annotations + +import ast +from types import SimpleNamespace + +from tilelens.core.host_compile import HostCompileUnavailable +from tilelens.ir.capture import CompileFailure +from tilelens.ir.failures import ( + compiles_nowhere, + failure_refusal, + none_compiled, + nothing_compiled, + nowhere_note, +) + +HINT = "Client(target=...)" + + +def _cuda89(): + from triton.backends.compiler import GPUTarget + + return GPUTarget("cuda", 89, 32) + + +def test_an_error_no_target_compiles_past_is_a_note(): + from triton.compiler.errors import CompilationError, CompileTimeAssertionFailure + + error = CompilationError(None, ast.Pass(), None) + error.__cause__ = CompileTimeAssertionFailure(None, ast.Pass(), "BLOCK <= 1024") + failure = CompileFailure(error, {"BLOCK": 2048}, _cuda89()) + assert compiles_nowhere(error) + note = nowhere_note(failure, {"BLOCK": 2048}) + assert note.startswith("config {'BLOCK': 2048} was not checked: it failed") + assert "for cuda:89 (CompileTimeAssertionFailure: BLOCK <= 1024)" in note + refusal = none_compiled(SimpleNamespace(failures=[failure])) + assert refusal.kind == "compile-failed" + assert "compiled for cuda:89" in refusal.message + + +def test_an_error_that_may_be_the_targets_names_another_target(): + failure = CompileFailure(RuntimeError("no MMA"), {}, _cuda89()) + assert not compiles_nowhere(failure.error) + refusal = failure_refusal(failure, HINT) + assert refusal.kind == "compile-failed" + assert "for cuda:89 (RuntimeError: no MMA)" in refusal.message + assert refusal.message.endswith(f"name a target it compiles for ({HINT})") + + +def test_a_host_compile_that_could_not_run_is_unavailable(): + error = HostCompileUnavailable("the installed Triton is 3.9.0") + failure = CompileFailure(error, {}, _cuda89()) + assert not compiles_nowhere(error) + refusal = failure_refusal(failure, HINT) + assert refusal.kind == "host-compile-unavailable" + assert refusal.message == "the installed Triton is 3.9.0" + + +def test_a_launch_without_a_jit_function_names_the_eager_client(): + log = SimpleNamespace(call=SimpleNamespace(capture=False)) + assert nothing_compiled(log, "Client()").endswith( + "the eager Client() checks such launches" + ) diff --git a/tests/unit/ir/test_lowering.py b/tests/unit/ir/test_lowering.py index cbc0649c..9b4ad1d2 100644 --- a/tests/unit/ir/test_lowering.py +++ b/tests/unit/ir/test_lowering.py @@ -15,7 +15,7 @@ import pytest import z3 -from tilelens.ir.lowering import Lowerer, children, fold +from tilelens.ir.lowering import GuardDag, Lowerer, children, fold from tilelens.ir.ttir_reader import ( AccessGraph, Arange, @@ -379,3 +379,23 @@ def test_a_loop_term_without_a_loop_is_a_bug(term): graph = _graph(None, (IterArgInfo(0, "p", Const(0), Const(1)),)) with pytest.raises(ValueError, match="without a loop"): Lowerer(graph, _Leaves()).lower(term) + + +def test_guard_dag_guards_the_terms_read_through_select_arms(): + """A term read only through an arm is guarded by the arm's condition; + one also read directly (or the condition itself) is not.""" + graph = _graph() + cond, wide, other = Cmp("slt", X, Y), Bin("*", X, Y), Bin("-", Y, X) + root = Bin("+", Select(cond, wide, other), Y) + lowerer = Lowerer(graph, _Leaves()) + dag = GuardDag((root,), graph, lowerer.cond) + c = lowerer.cond(cond) + assert _proved(dag.guards[id(wide)] == c) + assert _proved(dag.guards[id(other)] == z3.Not(c)) + assert {id(cond), id(X), id(Y)}.isdisjoint(dag.guards) + holds = z3.Bool("holds") + assert _proved(dag.premise(wide, holds) == z3.Implies(c, holds)) + assert dag.premise(cond, holds) is holds + assert dag.rank(cond) < dag.rank(wide) < dag.rank(root) + # Without ``cond``: the ranks only. + assert GuardDag((root,), graph).guards == {} diff --git a/tests/unit/sanitizer_compiled/test_client.py b/tests/unit/sanitizer_compiled/test_client.py index 7cb33f85..706e8c6b 100644 --- a/tests/unit/sanitizer_compiled/test_client.py +++ b/tests/unit/sanitizer_compiled/test_client.py @@ -877,10 +877,10 @@ def check_graph(graph, binding, **kw): def test_a_bug_outside_the_configs_is_an_internal_error(quiet, monkeypatch): - def broken(failure): + def broken(failure, retarget): raise RuntimeError("note bug") - monkeypatch.setattr(client_module, "_failure_refusal", broken) + monkeypatch.setattr(client_module, "failure_refusal", broken) failure = _failed(ADD, _add_args(), {"BLOCK_SIZE": 2048}, RuntimeError("bad")) launch = _launch(quiet, compiled=[_oob_event()], failures=[failure]) diff --git a/tilelens/clients/sanitizer/compiled/client.py b/tilelens/clients/sanitizer/compiled/client.py index 3c3bec7f..cf15b8de 100644 --- a/tilelens/clients/sanitizer/compiled/client.py +++ b/tilelens/clients/sanitizer/compiled/client.py @@ -15,7 +15,7 @@ A config that failed to compile never fails the launch: the target is this client's choice, not the machine's. What the failure means (see -_compiles_nowhere): +tilelens.ir.failures): - an error no target compiles past (a failing ``tl.static_assert``, a Python construct Triton's code generator never accepts), from a compile that @@ -87,26 +87,21 @@ from dataclasses import replace from typing import Any, ClassVar -from ....core.client import LanguagePatchedError, LaunchCall +from ....core.client import LaunchCall from ....core.config import config as cfg from ....core.data import Load, Store -from ....core.host_compile import ( - bind_failed, - format_ir_target, - host_compile_unavailable, - parse_ir_target, - target_queried, - unknown_options, -) -from ....ir.capture import ( - ArtifactLog, - CompiledSpecialization, - CompileFailure, - ParseCache, -) +from ....core.host_compile import parse_ir_target +from ....ir.capture import ArtifactLog, CompiledSpecialization, ParseCache from ....ir.client import IRClient +from ....ir.failures import ( + compiles_nowhere, + failure_refusal, + none_compiled, + nothing_compiled, + nowhere_note, +) from ....ir.launch import LaunchBinding, TensorFacts, tensor_facts -from ....ir.verdict import ConfigVerdict, IRVerdict, Refusal, SourceLocation +from ....ir.verdict import ConfigVerdict, IRVerdict, Refusal from ....utils.traceback_utils import location_to_traceback_info from ..data import CompiledSanitizerRecord from ..sanitizer import Sanitizer @@ -124,6 +119,9 @@ # used dropped first. _REMEMBERED_CHECKS = 256 +# How to check a kernel for another target, as a refusal says it. +_NAME_A_TARGET = "Sanitizer(compile=True, target=...), or TILELENS_IR_TARGET" + class CompiledSanitizer(IRClient): """Out-of-bounds, integer-width and division-by-zero checks over a @@ -201,15 +199,15 @@ def analyze_launch(self, log: ArtifactLog) -> tuple[list, IRVerdict]: failed: list[ConfigVerdict] = [] notes: list[str] = [] for failure in log.failures: - if _compiles_nowhere(failure.error): - notes.append(_nowhere_note(failure)) + if compiles_nowhere(failure.error): + notes.append(nowhere_note(failure, _saveable(failure.config))) else: failed.append( ConfigVerdict( None, _saveable(failure.config), "unsupported", - _failure_refusal(failure), + failure_refusal(failure, _NAME_A_TARGET), ) ) specializations = log.specializations @@ -217,10 +215,11 @@ def analyze_launch(self, log: ArtifactLog) -> tuple[list, IRVerdict]: if failed: refusal = failed[0].refusal elif log.failures: - refusal = _none_compiled(log) + refusal = none_compiled(log) else: refusal = Refusal( - SanitizerKind.NO_COMPILED_KERNEL, _nothing_compiled(log) + SanitizerKind.NO_COMPILED_KERNEL, + nothing_compiled(log, "Sanitizer()"), ) return [], IRVerdict( self.NAME, @@ -363,222 +362,6 @@ def _describe(exc: BaseException) -> str: return f"{type(exc).__name__}: {exc}" -def _nothing_compiled(log: ArtifactLog) -> str: - if log.call is not None and not log.call.capture: - return ( - "no compiled kernel to check: the launch has no JITFunction " - "(TRITON_INTERPRET=1, an InterpretedFunction runner, Gluon or NKI); " - "the eager Sanitizer() checks such launches" - ) - return "no config of the launch was compiled, so nothing was checked" - - -# How to check a kernel for another target, as a refusal or note says it. -_NAME_A_TARGET = "Sanitizer(compile=True, target=...), or TILELENS_IR_TARGET" - - -def _compiles_nowhere(error: BaseException | None) -> bool: - """Whether a config's host compile error says the config compiles for - no target, so it never launches anywhere (a note), rather than perhaps - only not for this client's target (unsupported, compile-failed). - - The rule, by exception type: the error the kernel's code raised (below - the CompilationErrors Triton's code generator wraps a called @jit - helper's or builtin's error in, following ``__cause__``) is a failing - ``tl.static_assert`` (CompileTimeAssertionFailure, which Triton's - autotuner drops a config for too) or a Python construct the code - generator never accepts (UnsupportedLanguageConstruct), and the compile - never asked Triton's driver anything (tilelens.core.host_compile. - target_queried: nor did an earlier compile of the kernel for the target): - a static_assert on ``tl.target_info``, on a device query the kernel's - code caught, or in a branch taken for the target only, is the target's. - Every other error may be the target's: Triton raises a target's - refusals (``num_ctas > 1`` below sm90, an fp8 type the target lacks, - 16-bit descriptor atomic min/max without native TMA, a dot shape the - target's MMA lacks, a PTXAS failure) as plain ValueError / - AssertionError / TypeError / RuntimeError, often wrapped in a - CompilationError like an error of the kernel's own code, so none of - them is taken to hold for every target. So is an unknown error (None). - A call that does not bind (bind_failed) never launches either, but the - untraced program raises for it (so does a traced launch: only an - event built outside the core delivers one), so it is no note; a host - compile that could not run (HostCompileUnavailable, LanguagePatchedError) - is no compile error at all. - """ - if error is None or host_compile_unavailable(error) is not None: - return False - from triton.compiler.errors import ( - CompilationError, - CompileTimeAssertionFailure, - UnsupportedLanguageConstruct, - ) - - target_free = (CompileTimeAssertionFailure, UnsupportedLanguageConstruct) - seen: set[int] = set() - link: BaseException = error - while ( - isinstance(link, CompilationError) - and not isinstance(link, target_free) - and link.__cause__ is not None - and id(link) not in seen - ): - seen.add(id(link)) - link = link.__cause__ - return isinstance(link, target_free) and not target_queried(error) - - -def _for_target(failure: CompileFailure) -> str: - if failure.target is None: - return "" - return f" for {format_ir_target(failure.target)}" - - -def _error_chain(error: BaseException) -> list[BaseException]: - """``error`` and the errors behind it, outermost first, as Triton's code - generator nests them: a CompilationError raised from (``__cause__``), - or ``from None`` while handling (the suppressed ``__context__``), the - error of a called @jit helper, a builtin or the kernel's own code. Only - CompilationErrors are followed.""" - from triton.compiler.errors import CompilationError - - chain = [error] - link: BaseException | None = error - while isinstance(link, CompilationError): - link = link.__cause__ or ( - link.__context__ if link.__suppress_context__ else None - ) - if link is None or any(link is seen for seen in chain): - break - chain.append(link) - return chain - - -def _summary(error: BaseException) -> str: - """One line for a compile error: the innermost error's type and the - first line of its message (a CompilationError's own message, without - the source excerpt it formats around it).""" - from triton.compiler.errors import CompilationError - - inner = _error_chain(error)[-1] - if isinstance(inner, CompilationError): - text = str(getattr(inner, "error_message", None) or "") - else: - text = str(inner) - first = next((line.strip() for line in text.splitlines() if line.strip()), "") - return f"{type(inner).__name__}: {first}" if first else type(inner).__name__ - - -def _kernel_site(jit_fn: Any, error: BaseException | None = None) -> Any: - """Where in ``jit_fn``'s source file ``error`` was raised: the line of - the kernel's own code the code generator names for it (for an error in - a called @jit helper, the call), else the kernel's ``def`` line; None - for a JITFunction without source (e.g. a stand-in).""" - fn = getattr(jit_fn, "fn", None) - code = getattr(fn, "__code__", None) - start = getattr(jit_fn, "starting_line_number", None) - raw_src = getattr(jit_fn, "raw_src", None) - if code is None or not isinstance(start, int) or not raw_src: - return None - # The line of ``def`` after the decorators, as Triton counts it. - offset = next( - (i for i, line in enumerate(raw_src) if line.strip().startswith("def ")), 0 - ) - def_line = start + offset - src = getattr(jit_fn, "src", None) - for link in [] if error is None else _error_chain(error): - lineno = getattr(getattr(link, "node", None), "lineno", None) - if src is not None and getattr(link, "src", None) == src and lineno: - # The code generator's lines count from the ``def`` line. - return SourceLocation(code.co_filename, def_line + lineno - 1) - return SourceLocation(code.co_filename, def_line) - - -def _at(site: Any) -> str: - return "" if site is None else f"{site.file}:{site.line}: " - - -def _failure_refusal(failure: CompileFailure) -> Refusal: - """A failed config's refusal: why its host compile failed (see - _compiles_nowhere), where, and how to check it for another target where - another target may compile it.""" - error = failure.error - site = _kernel_site(failure.jit_fn, error) - unavailable = None if error is None else host_compile_unavailable(error) - if unavailable is not None: - return Refusal( - SanitizerKind.HOST_COMPILE_UNAVAILABLE, - f"{_at(site)}{unavailable}", - loc=site, - ) - if isinstance(error, LanguagePatchedError): - # No compile ran, so it says nothing about the kernel or the target. - return Refusal( - SanitizerKind.HOST_COMPILE_UNAVAILABLE, f"{_at(site)}{error}", loc=site - ) - if error is not None and bind_failed(error): - # The call's own error: the untraced call raises it on any GPU, and - # a traced launch does too; only an event built outside the - # core gets here. - return Refusal( - SanitizerKind.COMPILE_FAILED, - f"{_at(site)}the call does not bind to the kernel's parameters " - f"({_summary(error)}), so it was not checked; Triton raises this " - "error for the call whatever the GPU", - loc=site, - ) - why = "" if error is None else f" ({_summary(error)})" - names = unknown_options(error) - if names: - # No kernel code failed: the call names options the target's backend - # lacks. Only a target of a backend that has them can compile it. - listed = ", ".join(repr(name) for name in names) - return Refusal( - SanitizerKind.COMPILE_FAILED, - f"{_at(site)}it failed to compile{_for_target(failure)}{why}: the " - f"call passes {listed}, neither a parameter of the kernel nor a " - f"compile option{_for_target(failure)}, so it was not checked; " - "Triton raises this for the call on every GPU whose backend lacks " - "that option (a misspelled option: on every GPU); if it is another " - "backend's option, name a target of that backend to check it " - f"({_NAME_A_TARGET})", - loc=site, - ) - return Refusal( - SanitizerKind.COMPILE_FAILED, - f"{_at(site)}it failed to compile{_for_target(failure)}{why}, so it was " - "not checked; a kernel can compile for one target and fail for another, " - "so it may launch on a GPU of another kind: to check it, name a target " - f"it compiles for ({_NAME_A_TARGET})", - loc=site, - ) - - -def _nowhere_note(failure: CompileFailure) -> str: - config = f"config {_saveable(failure.config)}" if failure.config else "the kernel" - error = failure.error - why = "" if error is None else f" ({_summary(error)})" - return ( - f"{config} was not checked: {_at(_kernel_site(failure.jit_fn, error))}it " - f"failed to compile{_for_target(failure)}{why}, an error of its own code " - "whatever the target, so it never launches" - ) - - -def _none_compiled(log: ArtifactLog) -> Refusal: - targets = sorted( - {format_ir_target(f.target) for f in log.failures if f.target is not None} - ) - where = f" for {', '.join(targets)}" if targets else "" - site = _kernel_site(log.failures[0].jit_fn) if log.failures else None - return Refusal( - SanitizerKind.COMPILE_FAILED, - f"{_at(site)}no config of the launch compiled{where}, so nothing was " - "checked: each failed with an error of its own code whatever the target " - "(see the notes)", - loc=site, - ) - - _PLAIN = (str, int, float, bool, type(None)) diff --git a/tilelens/clients/sanitizer/compiled/oob.py b/tilelens/clients/sanitizer/compiled/oob.py index 51c70507..0a30f328 100644 --- a/tilelens/clients/sanitizer/compiled/oob.py +++ b/tilelens/clients/sanitizer/compiled/oob.py @@ -83,7 +83,6 @@ BoolVal, Context, Exists, - Implies, Int, IntVal, ModelRef, @@ -99,8 +98,9 @@ ) from z3 import Not as Z3Not +from ....ir import failures from ....ir.launch import LaunchBinding, TensorFacts -from ....ir.lowering import Lowerer, children +from ....ir.lowering import GuardDag, Lowerer, children from ....ir.ttir_reader import ( AccessEvent, AccessGraph, @@ -114,7 +114,6 @@ Observed, Param, Pid, - Select, WidthObligation, mentions_observed, width_obligations, @@ -145,17 +144,18 @@ class SanitizerKind(str, Enum): # A term the reader marked unmodelable (DataDep) reached a lowered # address, mask, path or loop bound; the reader never lets it. UNMODELED_VALUE = "unmodeled-value" - # The client's own (compiled/client.py): the launch compiled nothing to - # read (no JITFunction, or none of its configs was delivered); a config - # failed to compile for the IR target with an error that need not hold - # for another target, or its call does not bind the kernel's parameters, - # or no config compiled at all; a compiled kernel holds no TTIR; the host - # compile could not run for a config; the analysis itself failed (a bug, - # contained). + # The client's (compiled/client.py, which classifies a failed compile + # with tilelens.ir.failures, whose two kinds these are): the launch + # compiled nothing to read (no JITFunction, or none of its configs was + # delivered); a config failed to compile for the IR target with an error + # that need not hold for another target, or its call does not bind the + # kernel's parameters, or no config compiled at all; a compiled kernel + # holds no TTIR; the host compile could not run for a config; the + # analysis itself failed (a bug, contained). NO_COMPILED_KERNEL = "no-compiled-kernel" - COMPILE_FAILED = "compile-failed" + COMPILE_FAILED = failures.COMPILE_FAILED NO_TTIR = "no-ttir" - HOST_COMPILE_UNAVAILABLE = "host-compile-unavailable" + HOST_COMPILE_UNAVAILABLE = failures.HOST_COMPILE_UNAVAILABLE INTERNAL_ERROR = "internal-error" def __str__(self) -> str: @@ -492,74 +492,6 @@ def cond(self, term: object) -> BoolRef: return self.lowering.cond(term) -class _Dag: - """The nodes one family of queries reads from ``roots``: each one's - rank (children before parents) and, when built with the family's env - (whose memo holds the lowered roots), its guard: the condition under - which its value reaches a root through the arms of Selects (a node - without one reaches a root directly). Iterative, keyed by identity.""" - - def __init__( - self, roots: Sequence[object], graph: AccessGraph, env: _Env | None = None - ) -> None: - self.graph = graph - self.order: dict[int, int] = {} - nodes: list[object] = [] - stack = [(r, False) for r in reversed(roots) if r is not None] - while stack: - t, done = stack.pop() - if id(t) in self.order: - continue - if done: - self.order[id(t)] = len(nodes) - nodes.append(t) - continue - stack.append((t, True)) - stack.extend( - (k, False) - for k in reversed(_operands(t, graph)) - if id(k) not in self.order - ) - self.guards: dict[int, BoolRef] = {} - if env is None: - return - direct = {id(r) for r in roots if r is not None} - shares: dict[int, list[BoolRef]] = {} - for t in reversed(nodes): # every parent before its children - parts = shares.pop(id(t), []) - guard = None - if id(t) not in direct: - guard = parts[0] if len(parts) == 1 else Or(parts) - self.guards[id(t)] = guard - edges: list[tuple[object, BoolRef | None]] - if isinstance(t, Select): - c = env.cond(t.cond) - edges = [(t.cond, guard)] - for arm, taken in ((t.t, c), (t.f, Z3Not(c))): - edges.append((arm, taken if guard is None else And(guard, taken))) - else: - edges = [(k, guard) for k in _operands(t, graph)] - for kid, kid_guard in edges: - if kid_guard is None: - direct.add(id(kid)) - elif id(kid) not in direct: - shares.setdefault(id(kid), []).append(kid_guard) - - def rank(self, term: object) -> float: - """Children first, and a Select's condition before its arms; a term - the walk does not hold (an obligation's own, e.g. a remainder's - quotient) right after its children.""" - index = self.order.get(id(term)) - if index is not None: - return index - kids = [ - self.order[id(k)] - for k in _operands(term, self.graph) - if id(k) in self.order - ] - return max(kids) + 0.5 if kids else len(self.order) - - # ─────────────────────────── view footprint ─────────────────────────── @@ -785,7 +717,7 @@ def check_loop(self, first: int, grid: tuple[int, int, int]) -> _Refused | None: if refusal is not None: return refusal.at(loop.line_no, loop.loc) roots = (loop.lower, loop.upper, loop.step) - dag = _Dag(roots, graph) + dag = GuardDag(roots, graph, operands=_operands) self.loop_ids = {id(n) for n in _walk(roots, graph, set())} self.loop_divs = tuple(_divisions(roots, graph, set())) self.loop_obs = tuple( @@ -872,7 +804,9 @@ def check_access( # One DAG over the three roles: a node's guard covers every root it # reaches, so a node one role reads through a Select arm and another # directly is checked wherever it is read. - dag = _Dag((access.path, access.mask, access.offset), graph, env) + dag = GuardDag( + (access.path, access.mask, access.offset), graph, env.cond, _operands + ) obs: dict[str, list[WidthObligation]] = {"path": [], "mask": [], "offset": []} for ob in width_obligations(graph, access): if ob.role != "loop": @@ -920,7 +854,7 @@ def role( divs: Sequence[Bin], assumed: list[BoolRef], uncertain: SanitizerKind | None, - dag: _Dag, + dag: GuardDag, ) -> list[BoolRef]: """Discharge one role's width obligations and divisions under ``assumed`` (the earlier roles' safety) in one joint query: some @@ -933,12 +867,11 @@ def role( conds: list[_Condition] = [] for i, ob in enumerate(obs): fits = _fits(env.value(ob.term), ob.bits, ob.signed) - guard = None if _undefined_when_wide(ob) else dag.guards.get(id(ob.term)) conds.append( _Condition( "integer-overflow", ob, - fits if guard is None else Implies(guard, fits), + fits if _undefined_when_wide(ob) else dag.premise(ob.term, fits), # Children first (a select's condition before its arms), # and on one term the op's own result before the reads # of the ops that read it. diff --git a/tilelens/ir/failures.py b/tilelens/ir/failures.py new file mode 100644 index 00000000..11e1e08b --- /dev/null +++ b/tilelens/ir/failures.py @@ -0,0 +1,261 @@ +"""What a config's failed host compile means for an IR client's launch. + +A config that failed to compile never fails the launch: the target is the +client's choice, not the machine's. ``compiles_nowhere`` tells an error no +target compiles past (the config never launches anywhere: a note, see +``nowhere_note``) from one that may be the target's (the config was not +checked: ``failure_refusal``, kind ``COMPILE_FAILED``, naming how to check +it for another target); a host compile that could not run at all is +``HOST_COMPILE_UNAVAILABLE``. ``none_compiled`` is the refusal of a launch +whose every config was only noted, ``nothing_compiled`` the message of one +that compiled nothing and failed nothing. + +Each refusal and note names where the kernel failed (``file:line`` of the +kernel's code the error points at, else of its ``def``) and the innermost +error in one line; the whole error stays on the CompileFailure. The +client's own words (how to name another target, which eager client checks +an uncompiled launch, how a config is shown) are passed in. + +Importing this module does not import Triton. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ..core.client import LanguagePatchedError +from ..core.host_compile import ( + bind_failed, + format_ir_target, + host_compile_unavailable, + target_queried, + unknown_options, +) +from .capture import ArtifactLog, CompileFailure +from .verdict import Refusal, SourceLocation + +# A config failed to compile for the IR target with an error that need not +# hold for another target, its call does not bind the kernel's parameters, +# or no config compiled at all. +COMPILE_FAILED = "compile-failed" +# The host compile could not run for a config. +HOST_COMPILE_UNAVAILABLE = "host-compile-unavailable" + + +def nothing_compiled(log: ArtifactLog, eager_client: str) -> str: + """Why a launch with neither a compiled config nor a failed one was not + checked; ``eager_client`` (e.g. ``"Sanitizer()"``) checks a launch + without a JITFunction.""" + if log.call is not None and not log.call.capture: + return ( + "no compiled kernel to check: the launch has no JITFunction " + "(TRITON_INTERPRET=1, an InterpretedFunction runner, Gluon or NKI); " + f"the eager {eager_client} checks such launches" + ) + return "no config of the launch was compiled, so nothing was checked" + + +def compiles_nowhere(error: BaseException | None) -> bool: + """Whether a config's host compile error says the config compiles for + no target, so it never launches anywhere (a note), rather than perhaps + only not for the client's target (unsupported, compile-failed). + + The rule, by exception type: the error the kernel's code raised (below + the CompilationErrors Triton's code generator wraps a called @jit + helper's or builtin's error in, following ``__cause__``) is a failing + ``tl.static_assert`` (CompileTimeAssertionFailure, which Triton's + autotuner drops a config for too) or a Python construct the code + generator never accepts (UnsupportedLanguageConstruct), and the compile + never asked Triton's driver anything (tilelens.core.host_compile. + target_queried: nor did an earlier compile of the kernel for the target): + a static_assert on ``tl.target_info``, on a device query the kernel's + code caught, or in a branch taken for the target only, is the target's. + Every other error may be the target's: Triton raises a target's + refusals (``num_ctas > 1`` below sm90, an fp8 type the target lacks, + 16-bit descriptor atomic min/max without native TMA, a dot shape the + target's MMA lacks, a PTXAS failure) as plain ValueError / + AssertionError / TypeError / RuntimeError, often wrapped in a + CompilationError like an error of the kernel's own code, so none of + them is taken to hold for every target. So is an unknown error (None). + A call that does not bind (bind_failed) never launches either, but the + untraced program raises for it (so does a traced launch: only an + event built outside the core delivers one), so it is no note; a host + compile that could not run (HostCompileUnavailable, LanguagePatchedError) + is no compile error at all. + """ + if error is None or host_compile_unavailable(error) is not None: + return False + from triton.compiler.errors import ( + CompilationError, + CompileTimeAssertionFailure, + UnsupportedLanguageConstruct, + ) + + target_free = (CompileTimeAssertionFailure, UnsupportedLanguageConstruct) + seen: set[int] = set() + link: BaseException = error + while ( + isinstance(link, CompilationError) + and not isinstance(link, target_free) + and link.__cause__ is not None + and id(link) not in seen + ): + seen.add(id(link)) + link = link.__cause__ + return isinstance(link, target_free) and not target_queried(error) + + +def _for_target(failure: CompileFailure) -> str: + if failure.target is None: + return "" + return f" for {format_ir_target(failure.target)}" + + +def _error_chain(error: BaseException) -> list[BaseException]: + """``error`` and the errors behind it, outermost first, as Triton's code + generator nests them: a CompilationError raised from (``__cause__``), + or ``from None`` while handling (the suppressed ``__context__``), the + error of a called @jit helper, a builtin or the kernel's own code. Only + CompilationErrors are followed.""" + from triton.compiler.errors import CompilationError + + chain = [error] + link: BaseException | None = error + while isinstance(link, CompilationError): + link = link.__cause__ or ( + link.__context__ if link.__suppress_context__ else None + ) + if link is None or any(link is seen for seen in chain): + break + chain.append(link) + return chain + + +def _summary(error: BaseException) -> str: + """One line for a compile error: the innermost error's type and the + first line of its message (a CompilationError's own message, without + the source excerpt it formats around it).""" + from triton.compiler.errors import CompilationError + + inner = _error_chain(error)[-1] + if isinstance(inner, CompilationError): + text = str(getattr(inner, "error_message", None) or "") + else: + text = str(inner) + first = next((line.strip() for line in text.splitlines() if line.strip()), "") + return f"{type(inner).__name__}: {first}" if first else type(inner).__name__ + + +def _kernel_site(jit_fn: Any, error: BaseException | None = None) -> Any: + """Where in ``jit_fn``'s source file ``error`` was raised: the line of + the kernel's own code the code generator names for it (for an error in + a called @jit helper, the call), else the kernel's ``def`` line; None + for a JITFunction without source (e.g. a stand-in).""" + fn = getattr(jit_fn, "fn", None) + code = getattr(fn, "__code__", None) + start = getattr(jit_fn, "starting_line_number", None) + raw_src = getattr(jit_fn, "raw_src", None) + if code is None or not isinstance(start, int) or not raw_src: + return None + # The line of ``def`` after the decorators, as Triton counts it. + offset = next( + (i for i, line in enumerate(raw_src) if line.strip().startswith("def ")), 0 + ) + def_line = start + offset + src = getattr(jit_fn, "src", None) + for link in [] if error is None else _error_chain(error): + lineno = getattr(getattr(link, "node", None), "lineno", None) + if src is not None and getattr(link, "src", None) == src and lineno: + # The code generator's lines count from the ``def`` line. + return SourceLocation(code.co_filename, def_line + lineno - 1) + return SourceLocation(code.co_filename, def_line) + + +def _at(site: Any) -> str: + return "" if site is None else f"{site.file}:{site.line}: " + + +def failure_refusal(failure: CompileFailure, retarget: str) -> Refusal: + """A failed config's refusal: why its host compile failed (see + compiles_nowhere), where, and how to check it for another target where + another target may compile it (``retarget``, e.g. + ``"Sanitizer(compile=True, target=...), or TILELENS_IR_TARGET"``).""" + error = failure.error + site = _kernel_site(failure.jit_fn, error) + unavailable = None if error is None else host_compile_unavailable(error) + if unavailable is not None: + return Refusal( + HOST_COMPILE_UNAVAILABLE, + f"{_at(site)}{unavailable}", + loc=site, + ) + if isinstance(error, LanguagePatchedError): + # No compile ran, so it says nothing about the kernel or the target. + return Refusal(HOST_COMPILE_UNAVAILABLE, f"{_at(site)}{error}", loc=site) + if error is not None and bind_failed(error): + # The call's own error: the untraced call raises it on any GPU, and + # a traced launch does too; only an event built outside the + # core gets here. + return Refusal( + COMPILE_FAILED, + f"{_at(site)}the call does not bind to the kernel's parameters " + f"({_summary(error)}), so it was not checked; Triton raises this " + "error for the call whatever the GPU", + loc=site, + ) + why = "" if error is None else f" ({_summary(error)})" + names = unknown_options(error) + if names: + # No kernel code failed: the call names options the target's backend + # lacks. Only a target of a backend that has them can compile it. + listed = ", ".join(repr(name) for name in names) + return Refusal( + COMPILE_FAILED, + f"{_at(site)}it failed to compile{_for_target(failure)}{why}: the " + f"call passes {listed}, neither a parameter of the kernel nor a " + f"compile option{_for_target(failure)}, so it was not checked; " + "Triton raises this for the call on every GPU whose backend lacks " + "that option (a misspelled option: on every GPU); if it is another " + "backend's option, name a target of that backend to check it " + f"({retarget})", + loc=site, + ) + return Refusal( + COMPILE_FAILED, + f"{_at(site)}it failed to compile{_for_target(failure)}{why}, so it was " + "not checked; a kernel can compile for one target and fail for another, " + "so it may launch on a GPU of another kind: to check it, name a target " + f"it compiles for ({retarget})", + loc=site, + ) + + +def nowhere_note(failure: CompileFailure, config: Mapping[str, Any]) -> str: + """The note for a config that compiles nowhere (see compiles_nowhere); + ``config`` is the failed config's kwargs as the client shows them.""" + named = f"config {config}" if config else "the kernel" + error = failure.error + why = "" if error is None else f" ({_summary(error)})" + return ( + f"{named} was not checked: {_at(_kernel_site(failure.jit_fn, error))}it " + f"failed to compile{_for_target(failure)}{why}, an error of its own code " + "whatever the target, so it never launches" + ) + + +def none_compiled(log: ArtifactLog) -> Refusal: + """The refusal of a launch no config of which compiled, each only noted + (see nowhere_note).""" + targets = sorted( + {format_ir_target(f.target) for f in log.failures if f.target is not None} + ) + where = f" for {', '.join(targets)}" if targets else "" + site = _kernel_site(log.failures[0].jit_fn) if log.failures else None + return Refusal( + COMPILE_FAILED, + f"{_at(site)}no config of the launch compiled{where}, so nothing was " + "checked: each failed with an error of its own code whatever the target " + "(see the notes)", + loc=site, + ) diff --git a/tilelens/ir/lowering.py b/tilelens/ir/lowering.py index 067b76f8..646c8ffe 100644 --- a/tilelens/ir/lowering.py +++ b/tilelens/ir/lowering.py @@ -28,6 +28,11 @@ ``IterArgOffset`` is ``offset0 + k * delta``, with ``k`` the client's iteration of that loop. +Select guards: ``GuardDag`` says, for the terms read from some roots, the +condition under which each one's value reaches a root through the arms of +Selects, so a client can assume or discharge a fact about a term (e.g. its +width obligation) only where the IR's result depends on it. + Z3 context: the lowering never creates one. Constants are made in ``leaves.ctx`` (None: Z3's main context) and every other term in its operands' context, so all of a client's terms share the context of its @@ -43,7 +48,7 @@ from collections.abc import Callable, Sequence from typing import Any, Protocol -from z3 import And, ArithRef, BoolRef, Context, If, IntVal, Or, is_bool +from z3 import And, ArithRef, BoolRef, Context, If, Implies, IntVal, Or, is_bool from z3 import Not as Z3Not from .ttir_reader import ( @@ -298,3 +303,87 @@ def _apply(self, t: object, kids: Sequence[Any]) -> Any: if isinstance(t, DataDep): return leaves.data_dep(t) raise TypeError(f"unknown term {type(t).__name__}") + + +class GuardDag: + """The terms read from ``roots``: each one's rank (children before + parents) and, given ``cond`` (a client's lowering of a term to a Bool, + e.g. ``Lowerer.cond``), its guard: the condition under which its value + reaches a root through the arms of Selects (a term without one reaches a + root directly). ``operands`` is the walk's edges (by default + ``children``; a client may stop at some terms). Iterative, keyed by + identity. Raises only what ``operands`` raises for a malformed graph; + an exception from the client's ``cond`` or ``operands`` propagates + unchanged.""" + + def __init__( + self, + roots: Sequence[object], + graph: AccessGraph, + cond: Callable[[object], BoolRef] | None = None, + operands: Callable[[object, AccessGraph], tuple] = children, + ) -> None: + self.graph = graph + self.operands = operands + self.order: dict[int, int] = {} + nodes: list[object] = [] + stack = [(r, False) for r in reversed(roots) if r is not None] + while stack: + t, done = stack.pop() + if id(t) in self.order: + continue + if done: + self.order[id(t)] = len(nodes) + nodes.append(t) + continue + stack.append((t, True)) + stack.extend( + (k, False) + for k in reversed(operands(t, graph)) + if id(k) not in self.order + ) + self.guards: dict[int, BoolRef] = {} + if cond is None: + return + direct = {id(r) for r in roots if r is not None} + shares: dict[int, list[BoolRef]] = {} + for t in reversed(nodes): # every parent before its children + parts = shares.pop(id(t), []) + guard = None + if id(t) not in direct: + guard = parts[0] if len(parts) == 1 else Or(parts) + self.guards[id(t)] = guard + edges: list[tuple[object, BoolRef | None]] + if isinstance(t, Select): + c = cond(t.cond) + edges = [(t.cond, guard)] + for arm, taken in ((t.t, c), (t.f, Z3Not(c))): + edges.append((arm, taken if guard is None else And(guard, taken))) + else: + edges = [(k, guard) for k in operands(t, graph)] + for kid, kid_guard in edges: + if kid_guard is None: + direct.add(id(kid)) + elif id(kid) not in direct: + shares.setdefault(id(kid), []).append(kid_guard) + + def premise(self, term: object, holds: BoolRef) -> BoolRef: + """``holds`` where ``term``'s value reaches a root: implied by its + guard (``holds`` itself for a term read directly, or one the walk + does not hold).""" + guard = self.guards.get(id(term)) + return holds if guard is None else Implies(guard, holds) + + def rank(self, term: object) -> float: + """Children first, and a Select's condition before its arms; a term + the walk does not hold (e.g. a remainder's quotient) right after its + children.""" + index = self.order.get(id(term)) + if index is not None: + return index + kids = [ + self.order[id(k)] + for k in self.operands(term, self.graph) + if id(k) in self.order + ] + return max(kids) + 0.5 if kids else len(self.order)