mirror of
https://github.com/NVIDIA/cutlass.git
synced 2026-07-18 09:37:30 +00:00
390 lines
14 KiB
Python
390 lines
14 KiB
Python
# SPDX-FileCopyrightText: Copyright (c) 2025 - 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: LicenseRef-NvidiaProprietary
|
|
#
|
|
# Use of this software is governed by the terms and conditions of the
|
|
# NVIDIA End User License Agreement (EULA), available at:
|
|
# https://docs.nvidia.com/cutlass/latest/media/docs/pythonDSL/license.html
|
|
#
|
|
# Any use, reproduction, disclosure, or distribution of this software
|
|
# and related documentation outside the scope permitted by the EULA
|
|
# is strictly prohibited.
|
|
|
|
"""
|
|
This module provides MLIR's OP helper functions
|
|
"""
|
|
|
|
import inspect
|
|
import importlib.util
|
|
import os
|
|
import types
|
|
import warnings
|
|
from functools import wraps, lru_cache
|
|
from typing import Any, Callable, TYPE_CHECKING
|
|
|
|
from .._mlir import ir
|
|
from ..base_dsl.common import DSLRuntimeError, DSLOperationBuildError
|
|
from ..base_dsl.utils.logger import log
|
|
from ..base_dsl.utils.stacktrace import walk_to_top_module
|
|
|
|
# The DSL package root is empty by default.
|
|
_DSL_PACKAGE_ROOT: str | None = ""
|
|
_DSL_PACKAGE_ROOTS: tuple[str, ...] | None = None
|
|
|
|
# Whether location tracking is enabled.
|
|
_ENABLE_FRAME_FILTERING: bool = False
|
|
|
|
# When True, dsl_user_op attributes ops to the closest frame (including DSL
|
|
# library code) instead of skipping up to the user's call site. Enabled when
|
|
# debugging mode is ON so library developers can see where inside the DSL an op
|
|
# was built.
|
|
_INCLUDE_LIB_FRAME: bool = False
|
|
|
|
_SOURCE_FRAMEWORK_DIRS = (
|
|
"_mlir_helpers",
|
|
"base_dsl",
|
|
"cutlass_dsl",
|
|
)
|
|
_FRAMEWORK_MODULE_PREFIXES = (
|
|
"cutlass",
|
|
)
|
|
|
|
# Trace-time MLIR op verification — see `dsl_user_op` docstring.
|
|
# Default resolved lazily from CUTE_DSL_VERIFY_TRACE on first read to
|
|
# avoid a circular import with base_dsl.env_manager (which transitively
|
|
# imports base_dsl.runtime, _mlir_helpers, and back into this module).
|
|
_VERIFY_TRACE_ENABLED: bool | None = None
|
|
|
|
|
|
def _verify_trace_enabled() -> bool:
|
|
global _VERIFY_TRACE_ENABLED
|
|
if _VERIFY_TRACE_ENABLED is None:
|
|
from ..base_dsl.env_manager import get_bool_env_var
|
|
|
|
_VERIFY_TRACE_ENABLED = get_bool_env_var("CUTE_DSL_VERIFY_TRACE", False)
|
|
return _VERIFY_TRACE_ENABLED
|
|
|
|
|
|
def set_verify_trace(enabled: bool) -> None:
|
|
"""Toggle trace-time MLIR op verification at runtime.
|
|
|
|
Useful for hot benchmarking loops where the per-op `verify()`
|
|
cost matters.
|
|
"""
|
|
global _VERIFY_TRACE_ENABLED
|
|
_VERIFY_TRACE_ENABLED = bool(enabled)
|
|
|
|
|
|
def get_verify_trace() -> bool:
|
|
"""Return whether trace-time verification is currently enabled."""
|
|
return _verify_trace_enabled()
|
|
|
|
|
|
def _verify_new_block_ops(snap_block: Any, snap_n_ops: int) -> None:
|
|
"""Verify ops appended to ``snap_block`` after ``snap_n_ops``."""
|
|
ops = snap_block.operations
|
|
new_count = len(ops) - snap_n_ops
|
|
if new_count <= 0:
|
|
return
|
|
|
|
# OperationList materialization is O(block size). Most DSL wrappers append
|
|
# one op, so walk the new tail by negative index instead of list-slicing the
|
|
# whole block on every wrapper call.
|
|
for offset in range(new_count, 0, -1):
|
|
ops[-offset].verify()
|
|
|
|
|
|
def _set_enable_frame_filtering(enable: bool) -> None:
|
|
"""Set whether location tracking is enabled."""
|
|
global _ENABLE_FRAME_FILTERING
|
|
_ENABLE_FRAME_FILTERING = enable
|
|
|
|
|
|
def _set_include_lib_frame(enable: bool) -> None:
|
|
"""Set whether ops are attributed to the closest (library) frame.
|
|
|
|
When enabled, `_find_user_frame` returns the immediate caller of the op
|
|
wrapper instead of walking up past DSL library frames. Used when debugging
|
|
mode is ON for internal DSL development.
|
|
"""
|
|
global _INCLUDE_LIB_FRAME
|
|
_INCLUDE_LIB_FRAME = enable
|
|
|
|
|
|
@lru_cache(maxsize=256)
|
|
def _is_framework_frame(filename: str) -> bool:
|
|
"""Check if a frame's filename belongs to DSL library code."""
|
|
global _DSL_PACKAGE_ROOT, _DSL_PACKAGE_ROOTS
|
|
if _DSL_PACKAGE_ROOTS is None:
|
|
roots: list[str] = []
|
|
|
|
module_root = walk_to_top_module(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
_DSL_PACKAGE_ROOT = module_root
|
|
if _DSL_PACKAGE_ROOT is not None:
|
|
roots.append(os.path.abspath(_DSL_PACKAGE_ROOT))
|
|
|
|
for package_name in _FRAMEWORK_MODULE_PREFIXES:
|
|
spec = importlib.util.find_spec(package_name)
|
|
if spec is None:
|
|
continue
|
|
if spec.submodule_search_locations:
|
|
roots.extend(
|
|
os.path.abspath(path) for path in spec.submodule_search_locations
|
|
)
|
|
elif spec.origin:
|
|
roots.append(os.path.dirname(os.path.abspath(spec.origin)))
|
|
|
|
_DSL_PACKAGE_ROOTS = tuple(dict.fromkeys(roots))
|
|
|
|
if not _DSL_PACKAGE_ROOTS:
|
|
return False
|
|
|
|
filename = os.path.abspath(filename)
|
|
return any(
|
|
filename == root or filename.startswith(root + os.sep)
|
|
for root in _DSL_PACKAGE_ROOTS
|
|
)
|
|
|
|
|
|
def _is_framework_module(module_name: str) -> bool:
|
|
if not module_name:
|
|
return False
|
|
framework_prefixes = _FRAMEWORK_MODULE_PREFIXES
|
|
|
|
return any(
|
|
module_name == prefix or module_name.startswith(prefix + ".")
|
|
for prefix in framework_prefixes
|
|
)
|
|
|
|
|
|
def _is_framework_stack_frame(frame: types.FrameType) -> bool:
|
|
module_name = frame.f_globals.get("__name__", "")
|
|
return _is_framework_module(module_name) or _is_framework_frame(
|
|
frame.f_code.co_filename
|
|
)
|
|
|
|
|
|
def _find_user_frame(start_frame: types.FrameType | None) -> types.FrameType | None:
|
|
"""Walk up the call stack from start_frame to find the first user (non-library) frame.
|
|
|
|
Returns the first frame whose file is not under the DSL package root.
|
|
Falls back to start_frame if no user frame is found.
|
|
|
|
Uses linear search with early termination - returns immediately upon finding the
|
|
first user frame. This is O(m) where m is the position of the first user frame
|
|
(typically 3-10). This is optimal - no algorithm can do better for finding the
|
|
first occurrence in an unsorted sequence.
|
|
|
|
The main optimization is @lru_cache on _is_framework_frame(), which caches filename
|
|
checks across all DSL operations.
|
|
"""
|
|
if _INCLUDE_LIB_FRAME:
|
|
return start_frame
|
|
if not _ENABLE_FRAME_FILTERING:
|
|
return start_frame
|
|
|
|
frame = start_frame
|
|
while frame is not None:
|
|
if not _is_framework_stack_frame(frame):
|
|
return frame
|
|
frame = frame.f_back
|
|
# Fallback: if everything is framework code, use the original caller
|
|
return start_frame
|
|
|
|
|
|
def dsl_user_op(opFunc: Callable[..., Any]) -> Callable[..., Any]:
|
|
"""Decorator for user-facing DSL op wrappers.
|
|
|
|
Responsibilities:
|
|
|
|
1. Attach a source-location to every MLIR op built by ``opFunc`` so that
|
|
diagnostics and IR dumps point back at the user's Python call site.
|
|
2. Run trace-time MLIR verification on each newly-built op so verifier
|
|
errors surface at the call site rather than at module-verify time.
|
|
|
|
Verification snapshots ``InsertionPoint.current.block`` and its op
|
|
count before invoking ``opFunc``, then calls ``OpView.verify()`` on
|
|
every op appended during the call. ``verify()`` recurses through
|
|
regions, so a wrapper that opens an ``scf.if`` covers the body via
|
|
the outer op.
|
|
|
|
Disabled by default; enable with the ``CUTE_DSL_VERIFY_TRACE`` env
|
|
var (``1`` / ``true`` / ``on`` / ``yes``) or ``set_verify_trace(True)``.
|
|
|
|
:param opFunc: The user-facing API function.
|
|
:type opFunc: Callable
|
|
:return: The wrapped user-facing API function.
|
|
:rtype: Callable
|
|
"""
|
|
|
|
@wraps(opFunc)
|
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
# Pop loc= from kwargs so callers that still pass it don't break.
|
|
# We no longer forward it — LOC_TRACEBACKS captures full stacks automatically.
|
|
loc: Any = kwargs.pop("loc", None)
|
|
frameInfo = None
|
|
verifier_error = False
|
|
|
|
if loc is None and ir.Context.current is not None:
|
|
cur_frame = inspect.currentframe()
|
|
assert cur_frame is not None
|
|
frame = _find_user_frame(cur_frame.f_back)
|
|
del cur_frame # break self-ref
|
|
assert frame is not None
|
|
frameInfo = inspect.getframeinfo(frame)
|
|
try:
|
|
# In Python < 3.11, getframeinfo returns a NamedTuple without positions
|
|
if not hasattr(frameInfo, "positions"):
|
|
file_loc = ir.Location.file(
|
|
frameInfo.filename,
|
|
frameInfo.lineno,
|
|
0,
|
|
)
|
|
else:
|
|
file_loc = ir.Location.file(
|
|
frameInfo.filename,
|
|
frameInfo.positions.lineno, # type: ignore[attr-defined]
|
|
frameInfo.positions.col_offset or 0, # type: ignore[attr-defined]
|
|
)
|
|
loc = ir.Location.name(
|
|
(
|
|
"".join([c.strip() for c in frameInfo.code_context])
|
|
if frameInfo.code_context
|
|
else frameInfo.function
|
|
),
|
|
childLoc=file_loc,
|
|
)
|
|
except RuntimeError:
|
|
# No MLIR context available (e.g. validation-only call
|
|
# outside a kernel). Proceed with loc=None so that the
|
|
# wrapped function's own validation can still fire.
|
|
pass
|
|
|
|
is_init = False
|
|
# Snapshot the current insertion block so we can verify newly-built
|
|
# ops after opFunc returns. The dialect Python bindings strip
|
|
# `.result` for value-producing ops before our wrapper sees the
|
|
# return value, so we cannot rely on the return value alone —
|
|
# block-diff sees every op regardless.
|
|
snap_block: Any = None
|
|
snap_n_ops: int = 0
|
|
if _verify_trace_enabled() and not is_init:
|
|
try:
|
|
snap_block = ir.InsertionPoint.current.block
|
|
snap_n_ops = len(snap_block.operations)
|
|
except ValueError:
|
|
# No active InsertionPoint (e.g. validation-only call
|
|
# outside a kernel) — skip trace-time verification.
|
|
snap_block = None
|
|
|
|
try:
|
|
res_or_list = opFunc(*args, **kwargs, loc=loc)
|
|
verifier_error = True
|
|
# Trace-time verification: walk every op appended to the
|
|
# snapshotted block during opFunc. OpView.verify() is
|
|
# recursive — for ops with regions (scf.if, scf.for, ...) it
|
|
# verifies the body too, so we don't need to descend.
|
|
#
|
|
# Skip when opFunc returns a context manager (e.g. elect_one,
|
|
# if_generate): the op's body is filled by the surrounding
|
|
# `with` block after opFunc returns, so verifying here would
|
|
# see an empty region and fail. The body still gets covered
|
|
# at module-verify time.
|
|
is_cm = hasattr(res_or_list, "__enter__") and hasattr(
|
|
res_or_list, "__exit__"
|
|
)
|
|
# Fast path: if opFunc didn't append anything to the block,
|
|
# skip materializing the operation list — it's O(N) in the
|
|
# block size and adds up across thousands of wrapper calls.
|
|
if (
|
|
snap_block is not None
|
|
and not is_cm
|
|
and len(snap_block.operations) > snap_n_ops
|
|
):
|
|
_verify_new_block_ops(snap_block, snap_n_ops)
|
|
# Defensive fallback: cover the rare wrapper that returns an
|
|
# OpView directly. Block-diff already verified it if it was
|
|
# appended to snap_block, so this is a no-op in the common
|
|
# case; the branch only matters for cross-block builders or
|
|
# calls with no active insertion point.
|
|
if hasattr(res_or_list, "verify"):
|
|
res_or_list.verify()
|
|
|
|
except DSLOperationBuildError as e:
|
|
# Nested DSLOperationError
|
|
raise DSLOperationBuildError(
|
|
message=e.message, cause=e, frameInfo=frameInfo
|
|
)
|
|
except Exception as e:
|
|
# Check if it's a decorator config error first
|
|
func_name = getattr(opFunc, "__name__", str(opFunc))
|
|
if "unexpected keyword argument 'loc'" in str(e):
|
|
raise DSLRuntimeError(
|
|
f"Function '{func_name}' decorated with @dsl_user_op does not accept the required 'loc' parameter.",
|
|
suggestion=[
|
|
f"1. Add 'loc=None' as a keyword-only parameter to {func_name}:",
|
|
f" def {func_name}(..., *, loc=None):",
|
|
"",
|
|
"2. Remove the @dsl_user_op decorator if location tracking is not needed",
|
|
],
|
|
cause=e,
|
|
) from e
|
|
if verifier_error:
|
|
raise DSLOperationBuildError(
|
|
message="Operation verification failed",
|
|
cause=e,
|
|
frameInfo=frameInfo,
|
|
auto_translate=False,
|
|
)
|
|
|
|
raise e
|
|
|
|
return res_or_list
|
|
|
|
return wrapper
|
|
|
|
|
|
def _get_call_arg_names(frameInfo: Any) -> dict[str | int, str]:
|
|
"""Extract argument names from the source line (best-effort)."""
|
|
if frameInfo is None or not frameInfo.code_context:
|
|
return {}
|
|
import ast as _ast
|
|
|
|
line = "".join(frameInfo.code_context).strip()
|
|
call_node: _ast.expr | None = None
|
|
try:
|
|
expr_tree = _ast.parse(line, mode="eval")
|
|
call_node = expr_tree.body
|
|
except SyntaxError:
|
|
try:
|
|
mod_tree = _ast.parse(line, mode="exec")
|
|
if not mod_tree.body:
|
|
return {}
|
|
stmt = mod_tree.body[0]
|
|
if isinstance(stmt, _ast.Expr):
|
|
call_node = stmt.value
|
|
elif isinstance(stmt, _ast.Assign):
|
|
call_node = stmt.value
|
|
elif isinstance(stmt, _ast.Return):
|
|
call_node = stmt.value
|
|
elif isinstance(stmt, _ast.AugAssign):
|
|
call_node = stmt.value
|
|
else:
|
|
return {}
|
|
except SyntaxError:
|
|
return {}
|
|
|
|
# Unwrap chained calls: ``type(self)(self, signed)`` parses as a Call
|
|
# whose func is another Call. We want the outermost (rightmost) call.
|
|
if not isinstance(call_node, _ast.Call):
|
|
return {}
|
|
names: dict[str | int, str] = {}
|
|
for i, arg in enumerate(call_node.args):
|
|
names[i] = _ast.unparse(arg)
|
|
for kw in call_node.keywords:
|
|
if kw.arg:
|
|
names[kw.arg] = _ast.unparse(kw.value)
|
|
return names
|
|
|
|
|