This is an automated email from the ASF dual-hosted git repository.
tqchen pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm.git
The following commit(s) were added to refs/heads/main by this push:
new 974a099a75 [REFACTOR][TVMScript] Fix syntax roots and simplify
annotation scopes (#20446)
974a099a75 is described below
commit 974a099a75deecb4d90e8f1caa5e08e4dee27f69
Author: Tianqi Chen <[email protected]>
AuthorDate: Fri Sep 25 10:09:36 2026 -0400
[REFACTOR][TVMScript] Fix syntax roots and simplify annotation scopes
(#20446)
TVMScript selects special syntax from fixed dialect roots and reserved
`range`/`int` names. Validate invalid bindings during prescan, then
match direct `X.member` calls without reconstructing Python shadowing or
following callable aliases.
Annotation helpers capture values under their original names so Python
handles parameter, lambda, comprehension, and body scopes. Keep
declaration snapshots separate from body closures, preserve lazy
missing-name checks, ordinary calls and symbolic range lowering, and use
ordinary call contexts for nested namespace members.
Explicit symbols remove the need for unconditional-declaration tracking
and expression-string namespace context. Remove unused module-decorator
metadata while retaining definition-site decorator options, lazy
captures and source diagnostics.
---
docs/arch/tvmscript.rst | 5 +-
docs/reference/api/python/script/parser.rst | 2 +-
python/tvm/backend/trn/script.py | 4 +-
python/tvm/relax/script/__init__.py | 6 -
python/tvm/relax/script/ir_builder/ir.py | 5 -
python/tvm/script/ir_builder/parser_protocol.py | 1 -
python/tvm/script/parser/entry.py | 32 +-
python/tvm/script/parser/prescan.py | 231 +++++---
python/tvm/script/parser/protocol_registry.py | 37 +-
python/tvm/script/parser/transpile.py | 637 ++++++++-------------
python/tvm/tirx/script/jit.py | 20 +-
tests/python/script/test_basic_usage.py | 8 +-
tests/python/script/test_error_handling.py | 94 +++
tests/python/script/test_jit_specialization.py | 52 ++
tests/python/script/test_meta_programming.py | 68 +++
tests/python/script/test_parser_entry.py | 16 +-
.../python/script/test_special_parser_protocol.py | 34 ++
17 files changed, 702 insertions(+), 550 deletions(-)
diff --git a/docs/arch/tvmscript.rst b/docs/arch/tvmscript.rst
index 5740a4f4dc..612527f591 100644
--- a/docs/arch/tvmscript.rst
+++ b/docs/arch/tvmscript.rst
@@ -55,8 +55,9 @@ namespace aliases from imports or ``extra_vars``, not
executable prefix assignme
``GeneratedBuilder`` records connect generated body helpers to original
functions and
identify bindings preserved during recomposition. Original-name wrapper
parameters
capture definition values as defaults. Execution globals remain separate from
the
-locals that evaluate those defaults. Known unshadowed signature values use
these
-parameters; lazy snapshots remain for missing or shadowed names and body
annotations.
+locals that evaluate those defaults. Declaration and annotation helpers bind
snapshot
+values under their original names, so Python handles parameter and local
shadowing.
+Missing snapshot values raise only when their annotation expressions read them.
Class setup and declaration snapshots execute in source order before function
bodies.
Body globals, closures and local shadowing retain their Python scope. Missing
values
are read only when needed, including conditional and optional annotations.
diff --git a/docs/reference/api/python/script/parser.rst
b/docs/reference/api/python/script/parser.rst
index 8f41fa584f..70a7cc989a 100644
--- a/docs/reference/api/python/script/parser.rst
+++ b/docs/reference/api/python/script/parser.rst
@@ -40,7 +40,7 @@ registered through
:func:`tvm.script.parser.register_namespace` and
:func:`tvm.script.parser.register_namespace_initializer`.
.. automodule:: tvm.script.parser.protocol_registry
- :members: constexpr, register_scalar_annotation, mutable_cell_decl,
result_span, module_decorator, declaration_kind
+ :members: constexpr, register_scalar_annotation, mutable_cell_decl,
result_span, declaration_kind
The language variant aliases below share the public construction namespaces
documented
in :doc:`script`. Parser entry points above use the canonical frontend.
diff --git a/python/tvm/backend/trn/script.py b/python/tvm/backend/trn/script.py
index 701523f273..03d134d582 100644
--- a/python/tvm/backend/trn/script.py
+++ b/python/tvm/backend/trn/script.py
@@ -21,8 +21,6 @@ from __future__ import annotations
from collections.abc import Callable
from typing import Any
-from tvm.script.parser.protocol_registry import result_span as _result_span
-
from . import op as _trn_op
OpWrapper = Callable[[Callable[..., Any]], Callable[..., Any]]
@@ -33,7 +31,7 @@ def _default_op_wrapper(func: Callable[..., Any]) ->
Callable[..., Any]:
from tvm.tirx.script.ir_builder.ir import _op_wrapper # pylint:
disable=import-outside-toplevel
# NKI producers return a single call_intrin node and never emit separately.
- return
_result_span(f"T.nki.{func.__name__.removeprefix('nki_')}")(_op_wrapper(func))
+ return _op_wrapper(func)
class NKINamespace:
diff --git a/python/tvm/relax/script/__init__.py
b/python/tvm/relax/script/__init__.py
index b1bdfe93e8..fd4d9b9686 100644
--- a/python/tvm/relax/script/__init__.py
+++ b/python/tvm/relax/script/__init__.py
@@ -26,8 +26,6 @@ from collections.abc import Callable as _Callable
from typing import TYPE_CHECKING
from typing import Any as _Any
-from tvm.script.parser.protocol_registry import module_decorator as
_module_decorator
-
if TYPE_CHECKING:
from tvm.ir import IRModule
from tvm.relax.base_py_module import BasePyModule
@@ -212,7 +210,3 @@ def py_module(
return result
return apply(module) if module is not None else apply
-
-
-# Member decorators may defer to this shared-parser entry without importing
Relax.
-_module_decorator("R.py_module")(py_module)
diff --git a/python/tvm/relax/script/ir_builder/ir.py
b/python/tvm/relax/script/ir_builder/ir.py
index 4996b000e6..58f46e5813 100644
--- a/python/tvm/relax/script/ir_builder/ir.py
+++ b/python/tvm/relax/script/ir_builder/ir.py
@@ -209,7 +209,6 @@ from tvm.runtime._tensor import (
)
from tvm.script.ir_builder import IRBuilder, decl_function
from tvm.script.ir_builder.ir import _class_global_infos
-from tvm.script.parser.protocol_registry import module_decorator as
_module_decorator
from . import _ffi_api, frame
@@ -474,10 +473,6 @@ def rewriter(rewriter_mod: IRModule | type) ->
PatternMatchingRewriter:
return PatternMatchingRewriter.from_module(rewriter_mod)
-# Member decorators defer to the same shared module construction boundary.
-_module_decorator("R.rewriter")(rewriter)
-
-
############################# BindingBlock ##############################
diff --git a/python/tvm/script/ir_builder/parser_protocol.py
b/python/tvm/script/ir_builder/parser_protocol.py
index 258d5b9b3b..1d09c15eaa 100644
--- a/python/tvm/script/ir_builder/parser_protocol.py
+++ b/python/tvm/script/ir_builder/parser_protocol.py
@@ -874,7 +874,6 @@ def module_member_(name: str, value: Any) -> Any:
# ``register_scalar_annotation(path, constructor, dtype=...)`` describes
scalar annotations.
# ``mutable_cell_decl(path)`` marks mutable storage declarations.
# ``result_span(path)`` permits attaching a call's result span without a call
context.
-# ``module_decorator(path)`` marks module declaration decorators.
# ``declaration_kind(path, kind)`` marks function and helper declarations.
# ``with X.function_(...) as fn:`` builds a function frame.
# ``a = X.arg("a", ty)`` declares a parameter.
diff --git a/python/tvm/script/parser/entry.py
b/python/tvm/script/parser/entry.py
index ceb9d94d0c..a5c26857bb 100644
--- a/python/tvm/script/parser/entry.py
+++ b/python/tvm/script/parser/entry.py
@@ -503,7 +503,9 @@ def make_macro_decorator(
{**environment, **bound.arguments},
set(bound.arguments),
preserve_return=preserve_return,
- definition_scope=definition_scope,
+ definition_scope=(
+ definition_scope if options.get("hygienic", True) else
environment
+ ),
)
return invoke
@@ -672,7 +674,16 @@ def _run_statements(
)
namespace[transformer.function.dialect_prefix] = builder
node = tree.body[-1]
- statements = transformer.transform_statements(node.body)
+ # Macros enter through their body instead of function declaration lowering.
+ # Snapshot annotations before defining the helper, in the definition scope.
+ # Bound arguments remain original-name parameters of the macro body.
+ _, _, annotation_names = transformer._read_function_annotations(
+ node, [], transformer.module.prescan_ctx.bindings.get(node, ())
+ )
+ captures = transformer.module.make_fresh_name("_definition")
+ transformer.function.definition_captures = captures
+ statements = transformer._create_definition_bindings(node,
annotation_names, captures=captures)
+ body = transformer.transform_statements(node.body)
names = sorted(name for name in bound_names if name in namespace)
helper_name = transformer.module.make_fresh_name("_macro")
helper = ast.copy_location(
@@ -685,7 +696,7 @@ def _run_statements(
kw_defaults=[],
defaults=[],
),
- statements or [ast.Pass()],
+ body or [ast.Pass()],
[],
None,
),
@@ -693,17 +704,23 @@ def _run_statements(
)
if "type_params" in ast.FunctionDef._fields:
helper.type_params = []
+ transformer.module.generated_builders.append(
+ GeneratedBuilder(
+ helper, node.name, set(names) |
transformer.module.prescan_ctx.namespaces.keys()
+ )
+ )
runnable = _recompose_builder(
- ast.Module([helper], []),
+ ast.Module([*statements, helper], []),
source_fn=source,
definition_scope=transformer.module.definition_scope,
generated_builders=transformer.module.generated_builders,
filename=filename,
flags=flags,
- name=helper_name,
+ name=transformer.module.make_fresh_name("_builder"),
make_fresh_name=transformer.module.make_fresh_name,
environment=namespace,
- )
+ result=helper_name,
+ )()
return runnable(*(namespace[name] for name in names))
@@ -894,7 +911,4 @@ def ir_module(module: type | None = None, **options: Any)
-> IRModule | Callable
return apply(module) if module is not None else apply
-syntax_protocol.module_decorator("I.ir_module")(ir_module)
-syntax_protocol.module_decorator("script.ir_module")(ir_module)
-
from_source = parse
diff --git a/python/tvm/script/parser/prescan.py
b/python/tvm/script/parser/prescan.py
index 2cc344a582..f5aea4b0bb 100644
--- a/python/tvm/script/parser/prescan.py
+++ b/python/tvm/script/parser/prescan.py
@@ -19,8 +19,9 @@
from __future__ import annotations
import ast
+import builtins
import inspect
-from collections.abc import Mapping, Sequence
+from collections.abc import Mapping
from types import ModuleType
from typing import NamedTuple, NoReturn
@@ -28,24 +29,23 @@ from . import protocol_registry as protocol
from .annotation import parse_annotation
-def collect_annotation_free_names(
- node: ast.expr, bound: set[str] | None = None
-) -> dict[str, ast.Name]:
- """Find annotation reads, respecting Python lambda/comprehension binders.
+def collect_annotation_free_names(node: ast.expr) -> dict[str, ast.Name]:
+ """Find annotation captures and their source introduction locations."""
+ return {reference.id: reference for reference in
collect_annotation_free_reads(node)}
- The returned nodes retain the introduction locations for diagnostics and
- supply the names needed by temporary/deferred definition capture.
- """
+
+def collect_annotation_free_reads(node: ast.expr, bound: set[str] | None =
None) -> list[ast.Name]:
+ """Collect original free reads for captures and lazy missing-name
checks."""
bound = set() if bound is None else bound
if isinstance(node, ast.Name):
- return {node.id: node} if isinstance(node.ctx, ast.Load) and node.id
not in bound else {}
+ return [node] if isinstance(node.ctx, ast.Load) and node.id not in
bound else []
if isinstance(node, ast.Lambda):
# -------------------- Pattern --------------------
# Python source:
# lambda n=outer: shape(n)
#
# Builder:
- # lambda n=captured_outer: shape(n)
+ # lambda n=outer: shape(n)
# -------------------------------------------------
# Only outer is free; n belongs to the lambda.
arguments = [*node.args.posonlyargs, *node.args.args,
*node.args.kwonlyargs]
@@ -53,12 +53,12 @@ def collect_annotation_free_names(
arguments.append(node.args.vararg)
if node.args.kwarg:
arguments.append(node.args.kwarg)
- result: dict[str, ast.Name] = {}
+ result: list[ast.Name] = []
for value in [*node.args.defaults, *node.args.kw_defaults]:
if value is not None:
- result.update(collect_annotation_free_names(value, bound))
- result.update(
- collect_annotation_free_names(node.body, bound | {arg.arg for arg
in arguments})
+ result.extend(collect_annotation_free_reads(value, bound))
+ result.extend(
+ collect_annotation_free_reads(node.body, bound | {arg.arg for arg
in arguments})
)
return result
if isinstance(node, ast.ListComp | ast.SetComp | ast.DictComp |
ast.GeneratorExp):
@@ -67,35 +67,34 @@ def collect_annotation_free_names(
# tuple(n for n in shape)
#
# Builder:
- # tuple(n for n in captured_shape)
+ # tuple(n for n in shape)
# -------------------------------------------------
# Only shape is free; n belongs to the comprehension.
- result = {}
+ result = []
local = set(bound)
for generator in node.generators:
- result.update(collect_annotation_free_names(generator.iter, local))
+ result.extend(collect_annotation_free_reads(generator.iter, local))
local.update(
item.id for item in ast.walk(generator.target) if
isinstance(item, ast.Name)
)
for condition in generator.ifs:
- result.update(collect_annotation_free_names(condition, local))
+ result.extend(collect_annotation_free_reads(condition, local))
values = [node.key, node.value] if isinstance(node, ast.DictComp) else
[node.elt]
for value in values:
- result.update(collect_annotation_free_names(value, local))
+ result.extend(collect_annotation_free_reads(value, local))
return result
- result = {}
+ result = []
for child in ast.iter_child_nodes(node):
if isinstance(child, ast.expr):
- result.update(collect_annotation_free_names(child, bound))
+ result.extend(collect_annotation_free_reads(child, bound))
elif isinstance(child, ast.keyword):
- result.update(collect_annotation_free_names(child.value, bound))
+ result.extend(collect_annotation_free_reads(child.value, bound))
return result
def resolve_namespace_key(
node: ast.AST | None,
environment: Mapping[str, object],
- bindings: Sequence[Binding] = (),
) -> str | None:
"""Normalize a fixed namespace alias, without resolving its members or
receivers."""
from . import _NAMESPACES
@@ -104,7 +103,7 @@ def resolve_namespace_key(
while isinstance(node, ast.Attribute):
parts.append(node.attr)
node = node.value
- if not isinstance(node, ast.Name) or any(item.name == node.id for item in
bindings):
+ if not isinstance(node, ast.Name):
return None
owner = environment.get(node.id)
parts.reverse()
@@ -139,15 +138,22 @@ def resolve_namespace_value(node: ast.AST | None,
environment: Mapping[str, obje
return value
-def resolve_constructor(
- node: ast.AST | None, environment: Mapping[str, object], bindings:
Sequence[Binding] = ()
-) -> str | None:
- """Read only a fixed-namespace source call's canonical policy key."""
- return (
- resolve_namespace_key(node.func, environment, bindings)
- if isinstance(node, ast.Call)
- else None
- )
+# Design principle: Once builtin names and language namespaces are selected
+# for script syntax, they cannot be shadowed. Prescan enforces this rule and
+# reports clear errors at the offending bindings. This lets the transpiler
+# rely on fixed bindings without tracking shadowing.
+#
+# Imports may establish an initial dialect root;
+# aliases created inside a function cannot rename it. Other names and calls
keep
+# ordinary Python scope and bind_ behavior. Annotation captures and IRModule
aliases
+# serve separate purposes and retain their own declaration facts.
+def _match_special_func(node: ast.AST | None, namespaces: Mapping[str, str])
-> str | None:
+ """Match direct X.member syntax without evaluating a callee or receiver."""
+ if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name):
+ root = namespaces.get(node.value.id)
+ if root is not None:
+ return f"{root}.{node.attr}"
+ return None
class Binding(NamedTuple):
@@ -158,7 +164,6 @@ class Binding(NamedTuple):
kind: str
annotation: ast.expr | None = None
dtype: object = None
- direct: bool = False
class PrescanContext:
@@ -179,7 +184,7 @@ class PrescanContext:
bindings: dict[ast.AST | None, list[Binding]],
sites: dict[ast.AST, Binding],
conditional_outputs: dict[ast.stmt, str],
- namespaces: set[str],
+ namespaces: dict[str, str],
with_outputs: dict[ast.With, list[str]],
recursive_functions: set[ast.FunctionDef | ast.AsyncFunctionDef],
) -> None:
@@ -198,7 +203,7 @@ class PrescanContext:
}
# Collected branch-ending names select conditional results without
later updates.
self.conditional_outputs = conditional_outputs
- # Collected entry namespaces forbid source rebinding; the set stays
read-only.
+ # Fixed source roots map to canonical dialect names; the map stays
read-only.
self.namespaces = namespaces
# Collected explicit output names select exports after each
with-region exits;
# rewriting reads the original lists without adding inferred outputs.
@@ -207,6 +212,10 @@ class PrescanContext:
# lowering; rewriting does not add or remove functions from this set.
self.recursive_functions = recursive_functions
+ def _match_special_func(self, node: ast.AST | None) -> str | None:
+ """Match direct source syntax against the roots fixed by this
prescan."""
+ return _match_special_func(node, self.namespaces)
+
class PrescanCollector(ast.NodeVisitor):
"""Collect binding syntax once, then discard all traversal accumulators."""
@@ -220,7 +229,7 @@ class PrescanCollector(ast.NodeVisitor):
self.bindings: dict[ast.AST | None, list[Binding]] = {}
self.sites: dict[ast.AST, Binding] = {}
self.outputs: dict[ast.stmt, str] = {}
- self.namespaces: set[str] = set()
+ self.namespaces: dict[str, str] = {}
self.exports: dict[ast.With, list[str]] = {}
self.recursive: set[ast.FunctionDef | ast.AsyncFunctionDef] = set()
# Active source declarations, used only to recognize self references.
@@ -228,11 +237,9 @@ class PrescanCollector(ast.NodeVisitor):
self.module_name: str | None = None
# Temporary lexical with-stack routes explicit output calls, then
resets.
self.regions: list[ast.With] = []
- # Lexical scope and language variant restore on function/class exit.
direct marks
- # unconditional function-body declarations eligible before signatures.
+ # Lexical scope and language variant restore on function/class exit.
self.scope: ast.Module | ast.ClassDef | ast.FunctionDef |
ast.AsyncFunctionDef | None = None
self.builder: object = None
- self.direct: bool = False
def collect(self, tree: ast.Module) -> PrescanContext:
"""Collect reserved names, scoped declarations and region-result
syntax.
@@ -254,12 +261,21 @@ class PrescanCollector(ast.NodeVisitor):
SyntaxError
If source bindings or declarations violate a parser restriction.
"""
- # Only registered namespace objects establish fixed source aliases.
- self.namespaces.update(
- name
- for name in self.environment
- if resolve_namespace_key(ast.Name(name, ast.Load()),
self.environment) is not None
- )
+ from . import _NAMESPACES
+
+ # Resolve roots once. Qualified receivers remain ordinary Python
expressions.
+ for name, value in self.environment.items():
+ for alias, namespace in _NAMESPACES.items():
+ if value is namespace:
+ self.namespaces[name] = alias
+ break
+ # Entry executes only these direct source-prefix imports before
prescan.
+ self.initial_imports = {
+ alias
+ for statement in tree.body[:-1]
+ if isinstance(statement, ast.Import | ast.ImportFrom)
+ for alias in statement.names
+ }
self.scope = tree
self.bindings[tree] = []
self.visit(tree)
@@ -288,6 +304,14 @@ class PrescanCollector(ast.NodeVisitor):
),
)
+ def _check_reserved(self, name: str, node: ast.AST) -> None:
+ if name in self.namespaces:
+ self._raise_error(node, f"Script namespace {name!r} cannot be
rebound or shadowed")
+ if name in ("range", "int"):
+ self._raise_error(
+ node, f"Name {name!r} is reserved for script syntax and cannot
be rebound"
+ )
+
def _record_binding(
self,
name: str,
@@ -296,10 +320,9 @@ class PrescanCollector(ast.NodeVisitor):
annotation: ast.expr | None = None,
dtype: object = None,
) -> None:
- if name in self.namespaces:
- self._raise_error(node, f"Script namespace {name!r} cannot be
rebound or shadowed")
+ self._check_reserved(name, node)
self.names.add(name)
- item = Binding(name, node, kind, annotation, dtype, self.direct)
+ item = Binding(name, node, kind, annotation, dtype)
self.bindings[self.scope].append(item)
self.sites[node] = item
@@ -312,8 +335,14 @@ class PrescanCollector(ast.NodeVisitor):
# value = X.bind_(f(), name="value")
# -------------------------------------------------
# Reserve source names and recognize self references before lowering.
- if isinstance(node.ctx, ast.Store) and node.id in self.namespaces:
- self._raise_error(node, f"Script namespace {node.id!r} cannot be
rebound or shadowed")
+ if isinstance(node.ctx, ast.Store | ast.Del):
+ self._check_reserved(node.id, node)
+ elif node.id in ("range", "int") and self.environment.get(
+ node.id, getattr(builtins, node.id)
+ ) is not getattr(builtins, node.id):
+ self._raise_error(
+ node, f"Name {node.id!r} is reserved for script syntax and
cannot be shadowed"
+ )
self.names.add(node.id)
if isinstance(node.ctx, ast.Load):
for function in reversed(self.functions):
@@ -331,8 +360,7 @@ class PrescanCollector(ast.NodeVisitor):
# x = X.arg("x", ty)
# -------------------------------------------------
# Parameter names cannot shadow registered namespaces.
- if node.arg in self.namespaces:
- self._raise_error(node, f"Script namespace {node.arg!r} cannot be
rebound or shadowed")
+ self._check_reserved(node.arg, node)
self.names.add(node.arg)
self.generic_visit(node)
@@ -346,10 +374,40 @@ class PrescanCollector(ast.NodeVisitor):
# -------------------------------------------------
# Reserve the imported Python name.
name = node.asname or node.name.split(".")[0]
- if isinstance(self.scope, ast.FunctionDef) and name in self.namespaces:
- self._raise_error(node, f"Script namespace {name!r} cannot be
rebound or shadowed")
+ if node not in self.initial_imports:
+ self._check_reserved(name, node)
self.names.add(name)
+ def visit_TypeVar(self, node: ast.AST) -> None:
+ self._check_reserved(node.name, node)
+ self.generic_visit(node)
+
+ visit_ParamSpec = visit_TypeVar
+ visit_TypeVarTuple = visit_TypeVar
+
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
+ if node.name:
+ self._check_reserved(node.name, node)
+ self.generic_visit(node)
+
+ def visit_MatchAs(self, node: ast.MatchAs) -> None:
+ if node.name:
+ self._check_reserved(node.name, node)
+ self.generic_visit(node)
+
+ def visit_MatchStar(self, node: ast.MatchStar) -> None:
+ if node.name:
+ self._check_reserved(node.name, node)
+
+ def visit_MatchMapping(self, node: ast.MatchMapping) -> None:
+ if node.rest:
+ self._check_reserved(node.rest, node)
+ self.generic_visit(node)
+
+ def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
+ self._collect_target(node.target, node.value)
+ self.visit(node.value)
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
# -------------------- Pattern --------------------
# Python source:
@@ -364,6 +422,7 @@ class PrescanCollector(ast.NodeVisitor):
# X.func_name("f")
# -------------------------------------------------
# Class host bindings and members share one lexical scope.
+ self._check_reserved(node.name, node)
self.names.add(node.name)
old, old_module = self.scope, self.module_name
self.scope, self.module_name = node, node.name
@@ -384,14 +443,14 @@ class PrescanCollector(ast.NodeVisitor):
# -------------------------------------------------
# Collect this function separately from its enclosing scope.
self._record_binding(node.name, node, "function")
- old_scope, old_builder, old_direct = self.scope, self.builder,
self.direct
- self.scope, self.direct = node, True
+ old_scope, old_builder = self.scope, self.builder
+ self.scope = node
self.functions.append(node)
self.bindings[node] = []
for decorator in node.decorator_list:
target = decorator.func if isinstance(decorator, ast.Call) else
decorator
if isinstance(target, ast.Attribute) and
protocol.DECLARATION_KIND.get(
- resolve_namespace_key(target, self.environment)
+ _match_special_func(target, self.namespaces)
) in ("function", "helper"):
namespace = resolve_namespace_value(target.value,
self.environment)
if namespace is not None:
@@ -411,7 +470,7 @@ class PrescanCollector(ast.NodeVisitor):
"int64"
if bound is None or (isinstance(bound, ast.Name) and bound.id
== "int")
else protocol.SCALAR_ANNOTATION_DTYPE.get(
- resolve_namespace_key(bound, self.environment)
+ _match_special_func(bound, self.namespaces)
)
)
self._record_binding(parameter.name, parameter, "symbol",
dtype=dtype)
@@ -429,9 +488,9 @@ class PrescanCollector(ast.NodeVisitor):
# without evaluating types.
annotation = parse_annotation(arg.annotation, self.filename) if
arg.annotation else None
arg.annotation = annotation
- constructor = resolve_namespace_key(
+ constructor = _match_special_func(
annotation.func if isinstance(annotation, ast.Call) else
annotation,
- self.environment,
+ self.namespaces,
)
self._record_binding(
arg.arg,
@@ -443,10 +502,13 @@ class PrescanCollector(ast.NodeVisitor):
else "parameter",
arg.annotation,
)
+ # Parameters, defaults and annotation-local binders are part of the
same
+ # fixed-name invariant, even when signature lowering handles them
separately.
+ self.visit(node.args)
+ for parameter in getattr(node, "type_params", ()):
+ self.visit(parameter)
for statement in node.body:
self.visit(statement)
- if isinstance(statement, ast.Return | ast.Raise):
- self.direct = False
for decorator in node.decorator_list:
self.visit(decorator)
if node.returns:
@@ -454,7 +516,7 @@ class PrescanCollector(ast.NodeVisitor):
self.visit(node.returns)
self._validate_symbols(node)
self.functions.pop()
- self.scope, self.builder, self.direct = old_scope, old_builder,
old_direct
+ self.scope, self.builder = old_scope, old_builder
visit_AsyncFunctionDef = visit_FunctionDef
@@ -500,7 +562,21 @@ class PrescanCollector(ast.NodeVisitor):
*,
kind: str = "ordinary",
) -> None:
- constructor = resolve_constructor(value, self.environment,
self.bindings[self.scope])
+ if self.functions:
+ values = [value]
+ while values:
+ item = values.pop()
+ if isinstance(item, ast.Tuple | ast.List):
+ values.extend(item.elts)
+ elif isinstance(item, ast.Starred):
+ values.append(item.value)
+ elif isinstance(item, ast.Name) and item.id in self.namespaces:
+ self._raise_error(
+ item, f"Script namespace {item.id!r} cannot be renamed
through an alias"
+ )
+ constructor = _match_special_func(
+ value.func if isinstance(value, ast.Call) else None,
self.namespaces
+ )
if isinstance(target, ast.Name):
if getattr(self.builder, "supports_mutable_declarations", True)
and (
"call" in protocol.MUTABLE_CELL_DECL.get(constructor, ())
@@ -508,11 +584,11 @@ class PrescanCollector(ast.NodeVisitor):
annotation is not None
and "annotation"
in protocol.MUTABLE_CELL_DECL.get(
- resolve_namespace_key(
+ _match_special_func(
annotation.value
if isinstance(annotation, ast.Subscript)
else annotation,
- self.environment,
+ self.namespaces,
),
(),
)
@@ -606,7 +682,6 @@ class PrescanCollector(ast.NodeVisitor):
# y = frame.var
# -------------------------------------------------
# Record matching branch outputs; lexical helpers are assembled during
rewriting.
- old_direct, self.direct = self.direct, False
self.generic_visit(node)
marker = (
isinstance(node.test, ast.Call)
@@ -643,7 +718,6 @@ class PrescanCollector(ast.NodeVisitor):
location, "IR conditional branches must end with the
same named output"
)
self.outputs[node] = then
- self.direct = old_direct
def visit_For(self, node: ast.For) -> None:
# -------------------- Pattern --------------------
@@ -656,27 +730,10 @@ class PrescanCollector(ast.NodeVisitor):
# X.emit_(body(i))
# -------------------------------------------------
# Loop binders remain assignable and are never signature declarations.
- old_direct, self.direct = self.direct, False
self._collect_target(node.target, kind="loop")
self.visit(node.iter)
for statement in node.body + node.orelse:
self.visit(statement)
- self.direct = old_direct
-
- def visit_While(self, node: ast.While) -> None:
- # -------------------- Pattern --------------------
- # Python source:
- # while condition:
- # body()
- #
- # Builder:
- # with X.While(condition):
- # X.emit_(body())
- # -------------------------------------------------
- # Declarations inside this region are conditional.
- old_direct, self.direct = self.direct, False
- self.generic_visit(node)
- self.direct = old_direct
def visit_With(self, node: ast.With) -> None:
# -------------------- Pattern --------------------
@@ -690,7 +747,6 @@ class PrescanCollector(ast.NodeVisitor):
# X.emit_(body(value))
# -------------------------------------------------
# Collect targets and explicit region outputs.
- old_direct, self.direct = self.direct, False
self.regions.append(node)
for item in node.items:
self.visit(item.context_expr)
@@ -699,7 +755,6 @@ class PrescanCollector(ast.NodeVisitor):
for statement in node.body:
self.visit(statement)
self.regions.pop()
- self.direct = old_direct
def visit_Call(self, node: ast.Call) -> None:
# -------------------- Pattern --------------------
diff --git a/python/tvm/script/parser/protocol_registry.py
b/python/tvm/script/parser/protocol_registry.py
index f0119cf662..17f16137f0 100644
--- a/python/tvm/script/parser/protocol_registry.py
+++ b/python/tvm/script/parser/protocol_registry.py
@@ -19,7 +19,7 @@
Each language variant registers its namespace through ``register_namespace``
and supplies
explicit canonical keys such as ``T.int32`` or ``R.Tensor`` at its marker
sites.
The first registered alias names that namespace. Source aliases normalize to
this
-same root; nested namespace members append their attribute names. Consumers
read
+same root; only direct root members select special syntax. Consumers read
the public dictionaries directly. Ordinary captured callables, local aliases,
instance methods and descriptors do not acquire syntax policies through
identity
or receiver inference.
@@ -42,7 +42,6 @@ _Callable = TypeVar("_Callable", bound=Callable[..., Any])
SCALAR_ANNOTATION_DTYPE: dict[str, object] = {}
MUTABLE_CELL_DECL: dict[str, frozenset[str]] = {}
RESULT_SPAN: dict[str, bool] = {}
-MODULE_DECORATOR: dict[str, bool] = {}
DECLARATION_KIND: dict[str, Literal["function", "helper"]] = {}
@@ -204,40 +203,6 @@ def result_span(namespace_path: str) ->
Callable[[_Callable], _Callable]:
return decorate
-def module_decorator(namespace_path: str) -> Callable[[_Callable], _Callable]:
- """Mark a module entry point and return the same callable.
-
- Parameters
- ----------
- namespace_path : str
- Canonical registered namespace alias and exported module-decorator
path,
- such as ``"I.ir_module"``.
-
- Returns
- -------
- Callable
- Registration decorator that returns the module entry point unchanged.
-
- Notes
- -----
- Member function decorators use this fixed namespace syntax to defer parsing
- until the complete enclosing module is available. No source-function
records,
- captured scopes or constructed module results are retained in the table.
-
- .. code:: python
-
- @module_decorator("I.ir_module")
- def ir_module(source):
- return parse(source)
- """
-
- def decorate(decorator: _Callable) -> _Callable:
- MODULE_DECORATOR[namespace_path] = True
- return decorator
-
- return decorate
-
-
def declaration_kind(
namespace_path: str, kind: Literal["function", "helper"]
) -> Callable[[_Callable], _Callable]:
diff --git a/python/tvm/script/parser/transpile.py
b/python/tvm/script/parser/transpile.py
index fbe3eb141a..b62f9f6ec2 100644
--- a/python/tvm/script/parser/transpile.py
+++ b/python/tvm/script/parser/transpile.py
@@ -38,7 +38,6 @@ from __future__ import annotations
import ast
import builtins
-import copy
from collections.abc import Callable, Iterator, Mapping
from contextlib import contextmanager
from types import FunctionType
@@ -51,8 +50,7 @@ from .prescan import (
Binding,
PrescanContext,
collect_annotation_free_names,
- resolve_namespace_key,
- resolve_namespace_value,
+ collect_annotation_free_reads,
)
_Node = TypeVar("_Node", bound=ast.AST)
@@ -87,6 +85,15 @@ def _require_constexpr_arg(value: _Value, name: str) ->
_Value:
return value
+def _require_annotation_value(value: _Value, name: str) -> _Value:
+ """Keep captured values unchanged, reporting missing names only when
read."""
+ from tvm.script.ir_builder.base import MISSING
+
+ if value is MISSING:
+ raise NameError(f"name {name!r} is not defined")
+ return value
+
+
def _unwrap_optional_annotation(
annotation: Any, const_args: Mapping[str, Any] | None = None
) -> Any:
@@ -172,7 +179,7 @@ class ModuleContext:
) -> None:
# Source filename used by _raise_error and parse_annotation
diagnostics.
self.filename = filename
- # Definition/lexical lookup layers used by _resolve to identify
namespaces.
+ # Definition/lexical lookup layers used to identify construction
namespaces.
# These preserve source meanings independently of injected execution
globals.
self.environment = environment
# Generated name of the shared IR namespace used for module frames and
MISSING.
@@ -226,22 +233,20 @@ class FunctionContext:
"""
def __init__(self, current_scope: ast.AST | None, dialect_prefix: str) ->
None:
- # Active source function (or None before root lowering);
_binding_facts and
- # declaration rewriting use it to select lexical prescan facts.
+ # Active source function (or None before root lowering); declaration
rewriting
+ # uses it to select lexical prescan facts.
self.current_scope = current_scope
# Namespace identifier used by _call_dialect for this function's
builder operations.
self.dialect_prefix = dialect_prefix
- # Annotation reads start with definition captures and grow in signature
- # order. Annotation rewriting reads this one map; lexical masks
temporarily
- # replace it and restore it on exit. Ordinary body lookup does not use
it.
- self.annotation_bindings: dict[str, ast.expr] = {}
+ # One snapshot supplies original-name helper defaults at the
definition site.
+ self.definition_captures: str | None = None
class IRBuilderTranspiler(ast.NodeTransformer):
"""A single statement/expression visitor over the entry-owned AST.
The module context shares source inputs, name allocation and injected
values.
- The active function context selects lexical facts and annotation
substitutions.
+ The active function context selects lexical facts and definition captures.
Function entry and temporary expression modes save and restore their state
at
lexical boundaries, including failures. Generated AST calls construct
native
frames at execution time; this visitor does not own their IR state.
@@ -258,9 +263,8 @@ class IRBuilderTranspiler(ast.NodeTransformer):
# Bypass syntax-to-builder lowering.
# Still traverse children and instrument source calls with spans.
self.bypass_ast_rewrite = False
- # Only annotation syntax consults the function's one substitution map;
- # ordinary body globals keep Python lookup even when names coincide.
- self.annotation_expression = False
+ # Only free reads of the active annotation need lazy missing-name
checks.
+ self.annotation_reads: set[ast.Name] = set()
# Only this expression's result is already located by its enclosing
emit_.
# Child operations retain their locations; restore the borrowed node
on exit.
self.emitted_expression: ast.expr | None = None
@@ -415,55 +419,14 @@ class IRBuilderTranspiler(ast.NodeTransformer):
self.bypass_ast_rewrite = old
@contextmanager
- def _use_aliases(self, mapping: dict[str, ast.expr]) -> Iterator[None]:
- """Restore lexical annotation substitutions even when a visitor
fails."""
- # Mask the active context's one map, retaining the enclosing map by
identity.
- old = self.function.annotation_bindings
- self.function.annotation_bindings = mapping
+ def _rewrite_annotation(self, node: ast.expr) -> Iterator[None]:
+ """Check missing values only at free reads, without renaming Python
bindings."""
+ previous_reads = self.annotation_reads
+ self.annotation_reads = set(collect_annotation_free_reads(node))
try:
yield
finally:
- # Nested annotation scopes cannot leak substitutions into their
caller.
- self.function.annotation_bindings = old
-
- @contextmanager
- def _rewrite_annotation(self) -> Iterator[None]:
- """Use definition substitutions only while rewriting annotation
syntax."""
- previous, self.annotation_expression = self.annotation_expression, True
- try:
- yield
- finally:
- self.annotation_expression = previous
-
- def _binding_facts(self) -> list[Binding]:
- """Read existing lexical bindings from the innermost source scope
outward."""
- scope = self.function.current_scope
- facts: list[Binding] = []
- while isinstance(scope, ast.FunctionDef):
- current = self.module.prescan_ctx.bindings.get(scope, ())
- hidden = {item.name for item in facts}
- facts.extend(item for item in current if item.name not in hidden)
- scope = next(
- (
- parent
- for parent, items in
self.module.prescan_ctx.bindings.items()
- if any(item.node is scope and item.kind == "function" for
item in items)
- ),
- None,
- )
- return facts
-
- def _resolve(self, node: ast.AST | None) -> str | None:
- """Recognize fixed namespace syntax, leaving all other Python values
opaque."""
- return resolve_namespace_key(node, self.module.environment,
self._binding_facts())
-
- def _is_builtin(self, node: ast.AST, value: object) -> bool:
- """Respect ordinary lexical/global shadowing of a builtin spelling."""
- return (
- isinstance(node, ast.Name)
- and not any(item.name == node.id for item in self._binding_facts())
- and self.module.environment.get(node.id, getattr(builtins,
node.id, None)) is value
- )
+ self.annotation_reads = previous_reads
def _read_constexpr_operand(self, node: ast.expr) -> ast.expr | None:
# -------------------- Pattern --------------------
@@ -474,11 +437,7 @@ class IRBuilderTranspiler(ast.NodeTransformer):
# expr
# -------------------------------------------------
# The same visitor keeps Python operators and still instruments nested
source calls.
- if not isinstance(node, ast.Call) or not isinstance(node.func,
ast.Attribute):
- return None
- if node.func.attr != "constexpr" or not isinstance(node.func.value,
ast.Name):
- return None
- if node.func.value.id not in self.module.prescan_ctx.namespaces:
+ if not isinstance(node, ast.Call) or not
self._is_constexpr_annotation(node.func):
return None
if len(node.args) != 1 or node.keywords or isinstance(node.args[0],
ast.Starred):
self._raise_error(node, "constexpr expects exactly one controlling
value")
@@ -490,26 +449,18 @@ class IRBuilderTranspiler(ast.NodeTransformer):
# value: X.Tensor((n,))
#
# Builder:
- # value_type = X.Tensor((_definition["n"],))
+ # value_type = X.Tensor((_require_annotation_value(n, "n"),))
# -------------------------------------------------
- # Definition substitutions apply only within annotation syntax.
- # A preceding body target is already a Python binding (including
symbols).
- # Consult its existing prescan location, without a second body-alias
map.
- if self.annotation_expression:
- if any(
- item.name == node.id
- and isinstance(item.node, ast.Name)
- and (item.node.lineno, item.node.col_offset) < (node.lineno,
node.col_offset)
- for item in
self.module.prescan_ctx.bindings.get(self.function.current_scope, ())
- ):
+ # Helpers bind captured values under their original names. Python
handles
+ # lambda/comprehension locals and preceding signature parameters
directly.
+ if node in self.annotation_reads:
+ if node.id in self.module.prescan_ctx.namespaces:
return node
- if (
- self.annotation_expression
- and isinstance(node.ctx, ast.Load)
- and node.id in self.function.annotation_bindings
- ):
return ast.copy_location(
- copy.deepcopy(self.function.annotation_bindings[node.id]), node
+ ast.Call(
+ self._inject(_require_annotation_value), [node,
ast.Constant(node.id)], []
+ ),
+ node,
)
return node
@@ -537,60 +488,6 @@ class IRBuilderTranspiler(ast.NodeTransformer):
else result
)
- def visit_Lambda(self, node: ast.Lambda) -> ast.Lambda:
- # -------------------- Pattern --------------------
- # Python source:
- # lambda n: n + outer
- #
- # Builder:
- # lambda n: n + captured_outer
- # -------------------------------------------------
- # Inside annotations, lambda-local binders mask definition
substitutions.
- node.args.defaults = [self.visit(value) for value in
node.args.defaults]
- node.args.kw_defaults = [
- self.visit(value) if value is not None else None for value in
node.args.kw_defaults
- ]
- arguments = [*node.args.posonlyargs, *node.args.args,
*node.args.kwonlyargs]
- arguments += [arg for arg in (node.args.vararg, node.args.kwarg) if
arg]
- local_names = {argument.arg for argument in arguments}
- with self._use_aliases(
- {
- name: alias
- for name, alias in self.function.annotation_bindings.items()
- if name not in local_names
- }
- ):
- node.body = self.visit(node.body)
- return node
-
- def visit_ListComp(
- self, node: ast.ListComp | ast.SetComp | ast.DictComp |
ast.GeneratorExp
- ) -> ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp:
- # -------------------- Pattern --------------------
- # Python source:
- # [f(n) for n in values]
- #
- # Builder:
- # [_S[i].ctx(lambda: f(n)) for n in values]
- # -------------------------------------------------
- # Comprehension binders mask annotation substitutions in Python
evaluation order.
- with self._use_aliases(dict(self.function.annotation_bindings)):
- for generator in node.generators:
- generator.iter = self.visit(generator.iter)
- for target in ast.walk(generator.target):
- if isinstance(target, ast.Name):
- self.function.annotation_bindings.pop(target.id, None)
- generator.ifs = [self.visit(value) for value in generator.ifs]
- if isinstance(node, ast.DictComp):
- node.key, node.value = self.visit(node.key),
self.visit(node.value)
- else:
- node.elt = self.visit(node.elt)
- return node
-
- visit_SetComp = visit_ListComp
- visit_DictComp = visit_ListComp
- visit_GeneratorExp = visit_ListComp
-
def visit_Constant(self, node: ast.Constant) -> ast.expr:
# -------------------- Pattern --------------------
# Python source:
@@ -662,7 +559,7 @@ class IRBuilderTranspiler(ast.NodeTransformer):
def _visit_direct_operand(self, node: ast.expr) -> ast.expr:
"""Preserve an existing payload's span without bypassing child
operations."""
if isinstance(node, ast.Name):
- return self.visit(node) if self.annotation_expression else node
+ return self.visit(node)
if isinstance(node, ast.Attribute):
node.value = self._visit_direct_operand(node.value)
return node
@@ -687,7 +584,7 @@ class IRBuilderTranspiler(ast.NodeTransformer):
if marker is not None:
with self._bypass_rewrite():
return self.visit(marker)
- constructor = self._resolve(node.func)
+ constructor = self.module.prescan_ctx._match_special_func(node.func)
global_call = (
isinstance(node.func, ast.Name) and node.func.id in
self.module.global_func_names
) or (
@@ -1210,8 +1107,12 @@ class IRBuilderTranspiler(ast.NodeTransformer):
if node.value
else ast.Attribute(ast.Name(self.module.ir_prefix, ast.Load()),
"MISSING", ast.Load())
)
- with self._rewrite_annotation():
- annotation = self.visit(parse_annotation(node.annotation,
self.module.filename))
+ annotation = parse_annotation(node.annotation, self.module.filename)
+ names = set(collect_annotation_free_names(annotation))
+ with self._rewrite_annotation(annotation):
+ helper = self._create_lambda([], self.visit(annotation))
+ self._capture_defaults(helper, names, node.annotation,
body_locals=True)
+ annotation = ast.copy_location(ast.Call(helper, [], []),
node.annotation)
return self._bind(node.target, value, node, ty=annotation,
value_span=value_span)
def visit_AugAssign(self, node: ast.AugAssign) -> ast.AugAssign |
list[ast.stmt]:
@@ -1471,9 +1372,13 @@ class IRBuilderTranspiler(ast.NodeTransformer):
return self.generic_visit(node)
if node.orelse:
self._raise_error(node, "A construction loop does not support an
else clause")
- if isinstance(node.iter, ast.Call) and
self._is_builtin(node.iter.func, range):
- # Normalize only the known builtin; a lexical range binding follows
- # the ordinary source-call path. Arguments keep that one call
scope.
+ if (
+ isinstance(node.iter, ast.Call)
+ and isinstance(node.iter.func, ast.Name)
+ and node.iter.func.id == "range"
+ ):
+ # Prescan reserves range, so symbolic bounds need no per-use scope
lookup.
+ # Arguments keep the original call's ordinary evaluation order.
iterable = self.visit_Call(
node.iter,
callee=ast.Attribute(
@@ -1664,7 +1569,7 @@ class IRBuilderTranspiler(ast.NodeTransformer):
"""Read the construction namespace from a qualified source
decorator."""
for decorator in node.decorator_list:
target = decorator.func if isinstance(decorator, ast.Call) else
decorator
- key = self._resolve(target)
+ key = self.module.prescan_ctx._match_special_func(target)
if key is None:
continue
kind = protocol.DECLARATION_KIND.get(key)
@@ -1672,13 +1577,9 @@ class IRBuilderTranspiler(ast.NodeTransformer):
return None, ast.Dict([], [])
if kind != "function":
continue
- namespace = (
- resolve_namespace_value(target.value, self.module.environment)
- if isinstance(target, ast.Attribute)
- else None
- )
- if namespace is None:
- continue
+ # A matched decorator is a direct member of a fixed root.
Construction
+ # needs that root's actual builder, not another syntax/value
resolution.
+ namespace = self.module.environment[target.value.id]
if (
self.module.root_function_kwargs is not None
and self.module.module_name is None
@@ -1728,13 +1629,8 @@ class IRBuilderTranspiler(ast.NodeTransformer):
return self._inject(namespace, "_X").id
def _read_function_annotations(
- self,
- node: ast.FunctionDef,
- parameters: list[ast.arg],
- facts: list[Binding],
- *,
- captures: str,
- ) -> tuple[list[ast.expr | None], ast.expr | None, dict[str, ast.expr]]:
+ self, node: ast.FunctionDef, parameters: list[ast.arg], facts:
list[Binding]
+ ) -> tuple[list[ast.expr | None], ast.expr | None, set[str]]:
"""Find definition-scope names needed by signatures and body
annotations."""
declared_names = {item.name for item in getattr(node, "type_params",
())}
annotations = [
@@ -1745,10 +1641,10 @@ class IRBuilderTranspiler(ast.NodeTransformer):
]
returns = parse_annotation(node.returns, self.module.filename) if
node.returns else None
annotation_names = {
- item.id
+ name
for annotation in [*annotations, returns]
if annotation is not None
- for item in collect_annotation_free_names(annotation).values()
+ for name in collect_annotation_free_names(annotation)
}
body_annotations = [
parse_annotation(item.annotation, self.module.filename)
@@ -1756,61 +1652,31 @@ class IRBuilderTranspiler(ast.NodeTransformer):
if item.annotation is not None
and item.kind not in ("parameter", "mutable_parameter", "symbol")
]
- body_annotation_names = {
- item.id
+ annotation_names.update(
+ name
for annotation in body_annotations
- for item in collect_annotation_free_names(annotation).values()
- }
- annotation_names.update(body_annotation_names)
- # Known, unshadowed signature inputs use original-name wrapper
defaults.
- # Body annotations and potentially absent/rebound names retain lazy
- # declaration-point snapshots: source body assignments must not
redirect them.
- available = _AnnotationScope(annotation_names,
self.module.definition_scope)
- rebound = {
- item.name for scope in self.module.prescan_ctx.bindings.values()
for item in scope
- }
- direct = available.keys() - body_annotation_names - rebound
- bindings = {
- name: (
- ast.Name(name, ast.Load())
- if name in direct
- or (
- name in self.module.prescan_ctx.namespaces
- and name in self.module.exec_globals
- and self.module.environment.get(name) is
self.module.exec_globals[name]
- )
- else ast.Subscript(ast.Name(captures, ast.Load()),
ast.Constant(name), ast.Load())
- )
- for name in sorted(annotation_names - declared_names)
- }
- return annotations, returns, bindings
+ for name in collect_annotation_free_names(annotation)
+ )
+ return annotations, returns, annotation_names - declared_names
def _create_definition_bindings(
self,
node: ast.FunctionDef,
- bindings: dict[str, ast.expr],
+ names: set[str],
*,
captures: str,
) -> list[ast.stmt]:
"""Snapshot only lexical annotation/constexpr names at their
definition site."""
- names = {
- name for name, binding in bindings.items() if isinstance(binding,
ast.Subscript)
- } | {
+ names = names | {
parameter.arg
for parameter in [*node.args.posonlyargs, *node.args.args,
*node.args.kwonlyargs]
if self._is_constexpr_annotation(parameter.annotation)
}
- if not names:
- return []
# Inject builtin operations so same-named source bindings cannot
replace them.
# Acquire each scope once; the snapshot retains selected values, never
frames.
scopes: list[ast.expr] = [ast.Call(self._inject(locals), [], [])]
scopes.append(ast.Call(self._inject(globals), [], []))
- defaults = {
- name: getattr(builtins, name)
- for name in names
- if name in bindings and hasattr(builtins, name)
- }
+ defaults = {name: getattr(builtins, name) for name in names if
hasattr(builtins, name)}
if defaults:
scopes.append(self._inject(defaults))
captured = ast.Call(
@@ -1820,6 +1686,47 @@ class IRBuilderTranspiler(ast.NodeTransformer):
)
return [self._assign(captures, captured, node)]
+ def _capture_defaults(
+ self,
+ helper: ast.FunctionDef | ast.Lambda,
+ names: set[str],
+ node: ast.AST,
+ *,
+ body_locals: bool = False,
+ ) -> None:
+ """Give a helper original-name captures; Python owns all lexical
shadowing."""
+ for name in sorted(names):
+ value = self._call(
+ self.function.definition_captures,
+ "get",
+ [
+ ast.Constant(name),
+ ast.Attribute(
+ ast.Name(self.module.ir_prefix, ast.Load()),
"MISSING", ast.Load()
+ ),
+ ],
+ node,
+ )
+ if body_locals and any(
+ item.name == name
+ and (item.node.lineno, item.node.col_offset) < (node.lineno,
node.col_offset)
+ for item in
self.module.prescan_ctx.bindings.get(self.function.current_scope, ())
+ ):
+ # Keep a real name read so Python captures enclosing helper
cells.
+ # A prior conditional binding may not have executed: snapshot
its
+ # absence without reading it until the annotation selects that
name.
+ value = ast.IfExp(
+ ast.Compare(
+ ast.Constant(name), [ast.In()],
[ast.Call(self._inject(locals), [], [])]
+ ),
+ ast.Name(name, ast.Load()),
+ ast.Attribute(
+ ast.Name(self.module.ir_prefix, ast.Load()),
"MISSING", ast.Load()
+ ),
+ )
+ helper.args.kwonlyargs.append(ast.arg(name))
+ helper.args.kw_defaults.append(value)
+
def _create_const_args(self, node: ast.FunctionDef, *, const_args: str) ->
list[ast.stmt]:
"""Read root JIT inputs; nested functions keep ordinary runtime
parameters."""
from . import jit_support
@@ -1832,12 +1739,9 @@ class IRBuilderTranspiler(ast.NodeTransformer):
)
return [self._assign(const_args, const_args_expr, node)]
- def _create_symbol_declarations(
- self, node: ast.FunctionDef
- ) -> tuple[list[ast.stmt], dict[str, ast.expr]]:
+ def _create_symbol_declarations(self, node: ast.FunctionDef) ->
list[ast.stmt]:
"""Bind explicit signature type parameters with their declared
dtypes."""
declaration: list[ast.stmt] = []
- symbol_aliases: dict[str, ast.expr] = {}
# -------------------- Pattern --------------------
# Python source:
# def f[n]():
@@ -1851,19 +1755,22 @@ class IRBuilderTranspiler(ast.NodeTransformer):
self._raise_error(parameter, "Only scalar type parameters are
supported")
bound = getattr(parameter, "bound", None)
dtype = self.module.prescan_ctx.sites[parameter].dtype
- if bound is not None and not self._is_builtin(bound, int):
- if protocol.SCALAR_ANNOTATION_DTYPE.get(self._resolve(bound))
is None:
+ if bound is not None and not (isinstance(bound, ast.Name) and
bound.id == "int"):
+ if (
+ protocol.SCALAR_ANNOTATION_DTYPE.get(
+ self.module.prescan_ctx._match_special_func(bound)
+ )
+ is None
+ ):
self._raise_error(
parameter,
"A symbolic type parameter bound must be int or a
registered scalar dtype",
)
if getattr(parameter, "default_value", None) is not None:
self._raise_error(parameter, "A symbolic type parameter cannot
have a default")
- alias = self.module.make_fresh_name("_symbol")
- symbol_aliases[parameter.name] = ast.Name(alias, ast.Load())
declaration.append(
self._assign(
- alias,
+ parameter.name,
self._call_dialect(
"resolve_type_var_",
[ast.Constant(parameter.name)],
@@ -1873,7 +1780,7 @@ class IRBuilderTranspiler(ast.NodeTransformer):
parameter,
)
)
- return declaration, symbol_aliases
+ return declaration
@staticmethod
def _select_specialized_value(
@@ -1898,8 +1805,9 @@ class IRBuilderTranspiler(ast.NodeTransformer):
)
def _is_constexpr_annotation(self, annotation: ast.expr | None) -> bool:
- """Recognize the registered marker identity, including ordinary
aliases."""
- return resolve_namespace_value(annotation, self.module.environment) is
protocol.constexpr
+ """Recognize constexpr syntax through a canonical registered
root/member key."""
+ key = self.module.prescan_ctx._match_special_func(annotation)
+ return key is not None and key.endswith(".constexpr")
def _rewrite_parameters(
self,
@@ -1910,7 +1818,6 @@ class IRBuilderTranspiler(ast.NodeTransformer):
const_args: str | None,
) -> tuple[list[ast.stmt], dict[str, str]]:
"""Declare signature parameters, making constexpr values available
first."""
-
declaration: list[ast.stmt] = []
constexpr_aliases: dict[str, str] = {}
constexpr_params: list[tuple[ast.arg, ast.expr | None, bool]] = []
@@ -1921,15 +1828,7 @@ class IRBuilderTranspiler(ast.NodeTransformer):
is_constexpr = self._is_constexpr_annotation(annotation)
group = constexpr_params if is_constexpr else other_params
group.append((parameter, annotation, is_constexpr))
- constexpr_reads = {
- parameter.arg
- for parameter, _, _ in constexpr_params
- if parameter.arg in self.function.annotation_bindings
- }
- constexpr_scope = self.module.make_fresh_name("_constexpr") if
constexpr_reads else None
- for index, (parameter, annotation, is_constexpr) in enumerate(
- [*constexpr_params, *other_params]
- ):
+ for parameter, annotation, is_constexpr in [*constexpr_params,
*other_params]:
if annotation is None:
self._raise_error(parameter, f"Parameter {parameter.arg!r}
requires an annotation")
name = parameter.arg
@@ -1960,7 +1859,8 @@ class IRBuilderTranspiler(ast.NodeTransformer):
[],
)
else:
- translated = self.visit(annotation)
+ with self._rewrite_annotation(annotation):
+ translated = self.visit(annotation)
if const_args is not None:
# Optional annotations are unwrapped only for
specialization;
# ordinary annotation validation belongs to the language
variant arg.
@@ -1981,30 +1881,8 @@ class IRBuilderTranspiler(ast.NodeTransformer):
)
)
declaration.append(self._assign(alias, value, parameter))
- self.function.annotation_bindings[name] = (
- ast.Subscript(ast.Name(constexpr_scope, ast.Load()),
ast.Constant(name), ast.Load())
- if is_constexpr and name in constexpr_reads
- else ast.Name(alias, ast.Load())
- )
- if constexpr_scope is not None and index + 1 ==
len(constexpr_params):
- # Selected host values may be MISSING. Preserve lazy
missing-name
- # errors without changing definition captures or Python body
locals.
- names = sorted(constexpr_reads)
- values = ast.Dict(
- [ast.Constant(name) for name in names],
- [ast.Name(constexpr_aliases[name], ast.Load()) for name in
names],
- )
- declaration.append(
- self._assign(
- constexpr_scope,
- ast.Call(
- self._inject(_AnnotationScope),
- [ast.Tuple([ast.Constant(name) for name in names],
ast.Load()), values],
- [],
- ),
- parameter,
- )
- )
+ if alias != name:
+ declaration.append(self._assign(name, ast.Name(alias,
ast.Load()), parameter))
return declaration, constexpr_aliases
def _create_function_frame(
@@ -2145,66 +2023,38 @@ class IRBuilderTranspiler(ast.NodeTransformer):
self,
node: ast.FunctionDef,
frame: str,
- body_name: str,
definition: ast.FunctionDef,
frame_declaration: list[ast.stmt],
+ capture_names: set[str],
+ constexpr_aliases: dict[str, str],
) -> list[ast.stmt]:
- """Give each signature a lexical scope and retain its body closure."""
- # Each signature needs its own Python parameter scope. Keep
- # definition captures outside, at the original declaration
- # point, and retain the body helper's signature closures.
- # -------------------- Pattern --------------------
- # Python source:
- # def f(x: annotation):
- # body(x)
- #
- # Builder:
- # def declare(frame):
- # with frame:
- # x = X.arg("x", annotation)
- # def build():
- # x, = frame.params
- # body(x)
- # return frame, build
- # frame, build = declare(X.function_(decl=True))
- # f = frame.global_var
- # with frame:
- # build()
- # -------------------------------------------------
+ """Keep signature snapshots separate from the body's enclosing Python
scope."""
+ # The declaration helper returns only the frame and selected host
values.
+ # Runtime parameters and explicit symbols are recovered from the frame
by
+ # the sibling body helper, whose closures still observe source class
setup.
declare_name = self.module.make_fresh_name("_declare")
signature = frame_declaration[0]
- # Decorator options belong to the outer definition scope;
- # a same-named parameter must not hide them in the helper.
+ # Decorator options evaluate outside the signature's same-named
parameters.
constructor = signature.items[0].context_expr
signature.items[0].context_expr = ast.copy_location(ast.Name(frame,
ast.Load()), node)
+ outputs = [frame, *constexpr_aliases.values()]
returned = ast.copy_location(
- ast.Return(
- ast.Tuple(
- [ast.Name(frame, ast.Load()), ast.Name(body_name,
ast.Load())],
- ast.Load(),
- )
- ),
+ ast.Return(ast.Tuple([ast.Name(name, ast.Load()) for name in
outputs], ast.Load())),
node,
)
- declare = self._create_definition(declare_name, [signature,
definition, returned], node)
+ declare = self._create_definition(declare_name, [signature, returned],
node)
declare.args.args.append(ast.arg(frame))
+ self._capture_defaults(declare, capture_names, node)
return [
declare,
ast.copy_location(
ast.Assign(
- [
- ast.Tuple(
- [
- ast.Name(frame, ast.Store()),
- ast.Name(body_name, ast.Store()),
- ],
- ast.Store(),
- )
- ],
+ [ast.Tuple([ast.Name(name, ast.Store()) for name in
outputs], ast.Store())],
ast.Call(ast.Name(declare_name, ast.Load()),
[constructor], []),
),
node,
),
+ definition,
frame_declaration[1],
]
@@ -2213,10 +2063,9 @@ class IRBuilderTranspiler(ast.NodeTransformer):
) -> tuple[list[ast.stmt], str, ast.With]:
"""Declare a native frame and emit a lexical body helper inside its
scope.
- Definition captures retain outer annotation values. Signature bindings
add
- declared symbols and each preceding parameter; constexpr aliases retain
- compile-time values for the body. One substitution map serves
annotation syntax;
- ordinary body reads retain Python globals/closures. None owns native
IR.
+ Original-name helper defaults retain definition values. Subsequent
Python
+ assignments expose each signature parameter to later annotations. Body
+ helpers preserve source globals/closures, independently of annotation
captures.
"""
kind, options = self.read_function_metadata(node)
builder = self._function_namespace(node, kind)
@@ -2226,111 +2075,133 @@ class IRBuilderTranspiler(ast.NodeTransformer):
old = self.function
self.function = FunctionContext(node, builder)
try:
- with self._use_aliases({}):
- parameters = [*node.args.posonlyargs, *node.args.args,
*node.args.kwonlyargs]
- if node.args.vararg or node.args.kwarg:
- self._raise_error(node, "IR signatures require ordinary
named parameters")
- facts = self.module.prescan_ctx.bindings.get(node, [])
- captures = self.module.make_fresh_name("_definition")
- annotations, returns, definition_aliases =
self._read_function_annotations(
- node, parameters, facts, captures=captures
- )
- self.function.annotation_bindings = definition_aliases
- statements = self._create_definition_bindings(
- node, definition_aliases, captures=captures
+ parameters = [*node.args.posonlyargs, *node.args.args,
*node.args.kwonlyargs]
+ if node.args.vararg or node.args.kwarg:
+ self._raise_error(node, "IR signatures require ordinary named
parameters")
+ facts = self.module.prescan_ctx.bindings.get(node, [])
+ annotations, returns, capture_names =
self._read_function_annotations(
+ node, parameters, facts
+ )
+ captures = self.module.make_fresh_name("_definition")
+ self.function.definition_captures = captures
+ statements = self._create_definition_bindings(node, capture_names,
captures=captures)
+ const_args = None
+ if self.module.enable_jit_map and not local_function:
+ const_args = self.module.make_fresh_name("_const_args")
+ statements.extend(self._create_const_args(node,
const_args=const_args))
+ declaration: list[ast.stmt] = [
+ ast.copy_location(
+ ast.Expr(self._call(builder, "func_name",
[ast.Constant(node.name)], node)),
+ node,
)
- const_args = None
- if self.module.enable_jit_map and not local_function:
- const_args = self.module.make_fresh_name("_const_args")
- statements.extend(self._create_const_args(node,
const_args=const_args))
- declaration: list[ast.stmt] = [
+ ]
+ declaration.extend(self._create_symbol_declarations(node))
+ arguments, constexpr_aliases = self._rewrite_parameters(
+ parameters, annotations, captures=captures,
const_args=const_args
+ )
+ declaration.extend(arguments)
+ if returns is not None:
+ with self._rewrite_annotation(returns):
+ translated_return = self.visit(returns)
+ declaration.append(
ast.copy_location(
- ast.Expr(self._call(builder, "func_name",
[ast.Constant(node.name)], node)),
- node,
- )
- ]
- symbols, symbol_aliases =
self._create_symbol_declarations(node)
- declaration.extend(symbols)
- with (
- self._use_aliases({**definition_aliases,
**symbol_aliases}),
- self._rewrite_annotation(),
- ):
- arguments, constexpr_aliases = self._rewrite_parameters(
- parameters, annotations, captures=captures,
const_args=const_args
- )
- declaration.extend(arguments)
- if returns is not None:
- declaration.append(
- ast.copy_location(
- ast.Expr(
- self._call(
- builder,
- "func_ret_type",
- [self._create_lambda([],
self.visit(returns))],
- returns,
- )
- ),
+ ast.Expr(
+ self._call(
+ builder,
+ "func_ret_type",
+ [self._create_lambda([], translated_return)],
returns,
)
- )
-
definition_aliases.update(self.function.annotation_bindings)
- with self._bypass_rewrite():
- options = self.visit(options)
- frame_declaration, body_entry = self._create_function_frame(
- node,
- options,
- declaration,
- frame=frame,
- local_function=local_function,
- split_declare=split_declare,
+ ),
+ returns,
+ )
+ )
+ with self._bypass_rewrite():
+ options = self.visit(options)
+ frame_declaration, body_entry = self._create_function_frame(
+ node,
+ options,
+ declaration,
+ frame=frame,
+ local_function=local_function,
+ split_declare=split_declare,
+ )
+ body = self._create_body_parameters(
+ node, parameters, constexpr_aliases, frame=frame,
const_args=const_args
+ )
+ body.extend(self.transform_statements(node.body))
+ definition = self._create_definition(body_name, body, node)
+ if not local_function:
+ protected = (
+ {item.name for item in getattr(node, "type_params", ())}
+ | {node.name}
+ | set(self.module.global_func_names)
+ | self.module.prescan_ctx.namespaces.keys()
)
- body = self._create_body_parameters(
- node, parameters, constexpr_aliases, frame=frame,
const_args=const_args
+ if self.module.module_name:
+ protected.add(self.module.module_name)
+ self.module.generated_builders.append(
+ GeneratedBuilder(definition, node.name, protected)
)
- body.extend(self.transform_statements(node.body))
- definition = self._create_definition(body_name, body, node)
- if not local_function:
- protected = (
- {item.name for item in getattr(node, "type_params",
())}
- | {node.name}
- | set(self.module.global_func_names)
- | self.module.prescan_ctx.namespaces
- )
- if self.module.module_name:
- protected.add(self.module.module_name)
- self.module.generated_builders.append(
- GeneratedBuilder(definition, node.name, protected)
+ # -------------------- Pattern --------------------
+ # Python source:
+ # def f():
+ # body()
+ #
+ # Builder:
+ # with frame:
+ # def build():
+ # X.emit_(body())
+ # build()
+ # -------------------------------------------------
+ # The helper owns lexical scope; the surrounding with owns frame
+ # entry/exit and error unwinding.
+ invocation = ast.copy_location(
+ ast.Expr(ast.Call(ast.Name(body_name, ast.Load()), [], [])),
node
+ )
+ if split_declare:
+ statements.extend(
+ self._create_split_declaration(
+ node, frame, definition, frame_declaration,
capture_names, constexpr_aliases
)
- # -------------------- Pattern --------------------
- # Python source:
- # def f():
- # body()
- #
- # Builder:
- # with frame:
- # def build():
- # X.emit_(body())
- # build()
- # -------------------------------------------------
- # The helper owns lexical scope; the surrounding with owns
frame
- # entry/exit and error unwinding.
- invocation = ast.copy_location(
- ast.Expr(ast.Call(ast.Name(body_name, ast.Load()), [],
[])), node
)
- if split_declare:
- statements.extend(
- self._create_split_declaration(
- node, frame, body_name, definition,
frame_declaration
+ else:
+ outputs = list(constexpr_aliases.values())
+ if outputs:
+ declaration.append(
+ ast.copy_location(
+ ast.Return(
+ ast.Tuple(
+ [ast.Name(name, ast.Load()) for name in
outputs], ast.Load()
+ )
+ ),
+ node,
)
)
- resumed = ast.copy_location(
- ast.With(
- [body_entry],
- [invocation] if split_declare else [*declaration,
definition, invocation],
- ),
+ signature = self._create_definition(
+ self.module.make_fresh_name("_declare"), declaration, node
+ )
+ self._capture_defaults(signature, capture_names, node)
+ call = ast.Call(ast.Name(signature.name, ast.Load()), [], [])
+ signature_call = ast.copy_location(
+ ast.Assign(
+ [ast.Tuple([ast.Name(name, ast.Store()) for name in
outputs], ast.Store())],
+ call,
+ )
+ if outputs
+ else ast.Expr(call),
node,
)
- return statements, frame, resumed
+ resumed = ast.copy_location(
+ ast.With(
+ [body_entry],
+ [invocation]
+ if split_declare
+ else [signature, signature_call, definition, invocation],
+ ),
+ node,
+ )
+ return statements, frame, resumed
finally:
# Restore the enclosing function before returning fragments or
propagating
# an error; partially rewritten nested functions cannot leave
active state.
diff --git a/python/tvm/tirx/script/jit.py b/python/tvm/tirx/script/jit.py
index 7ca5f43e68..544b1c89e9 100644
--- a/python/tvm/tirx/script/jit.py
+++ b/python/tvm/tirx/script/jit.py
@@ -236,7 +236,7 @@ class TIRJit:
raw_anns = getattr(func, "__annotations__", {}) or {}
annotation_scope = {**func.__globals__, **self._closure_vars,
**self._definition_scope}
- from tvm.script.parser.prescan import resolve_namespace_value
+ from tvm.script.parser.prescan import resolve_namespace_key,
resolve_namespace_value
sig = inspect.signature(func)
constexpr_names: set[str] = set()
@@ -250,11 +250,21 @@ class TIRJit:
node = ast.parse(ann, mode="eval").body
if isinstance(node, ast.Constant) and
isinstance(node.value, str):
node = ast.parse(node.value, mode="eval").body
- ann = resolve_namespace_value(
- node.func if isinstance(node, ast.Call) else node,
annotation_scope
+ key = (
+ resolve_namespace_key(node, annotation_scope)
+ if isinstance(node, ast.Attribute) and
isinstance(node.value, ast.Name)
+ else None
)
- if isinstance(node, ast.Call) and ann is not
OptionalAnnotation:
- ann = None
+ if key is not None and key.endswith(".constexpr"):
+ ann = protocol.constexpr
+ else:
+ ann = resolve_namespace_value(
+ node.func if isinstance(node, ast.Call) else node,
annotation_scope
+ )
+ if ann is protocol.constexpr or (
+ isinstance(node, ast.Call) and ann is not
OptionalAnnotation
+ ):
+ ann = None
except SyntaxError:
ann = None
if ann is protocol.constexpr:
diff --git a/tests/python/script/test_basic_usage.py
b/tests/python/script/test_basic_usage.py
index 6d05987a2c..5068c9aa8b 100644
--- a/tests/python/script/test_basic_usage.py
+++ b/tests/python/script/test_basic_usage.py
@@ -407,8 +407,8 @@ def
test_void_branch_statements_need_no_synthetic_named_output(language):
assert [value for kind, value in main.body] == [None, None, 9]
-def test_lexical_range_binding_calls_the_custom_iterator_once(language):
- # A lexical range alias must call the captured iterator once instead of
the builtin range.
+def test_ordinary_iterator_binding_calls_the_custom_iterator_once(language):
+ # An ordinary iterator alias calls its captured implementation exactly
once.
M = language.M
calls = []
@@ -418,8 +418,8 @@ def
test_lexical_range_binding_calls_the_custom_iterator_once(language):
@M.function
def main():
- range = custom_range
- for i in range(4):
+ iterator = custom_range
+ for i in iterator(4):
M.record(i)
assert calls == [4]
diff --git a/tests/python/script/test_error_handling.py
b/tests/python/script/test_error_handling.py
index 61d7f29b8d..cb7764d68b 100644
--- a/tests/python/script/test_error_handling.py
+++ b/tests/python/script/test_error_handling.py
@@ -28,6 +28,7 @@ import pytest
from tvm import ir
from tvm.ir import prim
from tvm.script import tirx as T
+from tvm.script.parser import entry
def
test_nested_function_failure_preserves_error_and_recovers(spanned_language):
@@ -190,6 +191,99 @@ def
test_namespace_rebinding_reports_source_location(language):
assert not language.functions
[email protected](
+ "name, statement",
+ [
+ ("range", "range = 1"),
+ ("int", "int: object = 1"),
+ ("M", "M += 1"),
+ ("range", "(range, other) = (1, 2)"),
+ ("int", "for int in M.grid(2):\n pass"),
+ ("M", "with M.grid(2) as M:\n pass"),
+ ("range", "def range():\n pass"),
+ ("int", "class int:\n pass"),
+ ("M", "import math as M"),
+ ("range", "from math import floor as range"),
+ ("int", "values = [0 for int in (1,)]"),
+ ("M", "value = lambda M: 0"),
+ ("range", "value = (range := 1)"),
+ ("int", "del int"),
+ ("M", "try:\n pass\nexcept ValueError as M:\n pass"),
+ ("range", "match 1:\n case range:\n pass"),
+ ("int", "match [1]:\n case [*int]:\n pass"),
+ ("M", "match {}:\n case {**M}:\n pass"),
+ ],
+)
+def test_reserved_bindings_fail_before_construction(language, name, statement):
+ source = "@M.function\ndef main():\n effect()\n" + "\n".join(
+ " " + line for line in statement.splitlines()
+ )
+ effects = []
+ with pytest.raises(SyntaxError, match=repr(name)) as caught:
+ entry.parse(
+ source, {"M": language.M, "effect": lambda: effects.append(1)},
filename="reserved.py"
+ )
+ error = caught.value
+ assert error.filename == "reserved.py"
+ assert error.lineno >= 4
+ assert name in source.splitlines()[error.lineno - 1]
+ assert error.offset > 0
+ assert not language.functions
+ assert not effects
+
+
[email protected](
+ "name, parameter", [("M", "M: object"), ("range", "*range"), ("int",
"**int")]
+)
+def test_reserved_parameters_fail_before_construction(language, name,
parameter):
+ source = "@M.function\ndef main(" + parameter.format(name=name) + "):\n
pass"
+ with pytest.raises(SyntaxError, match=repr(name)) as caught:
+ entry.parse(source, {"M": language.M}, filename="parameter.py")
+ assert (caught.value.filename, caught.value.lineno) == ("parameter.py", 2)
+ assert source.splitlines()[1][caught.value.offset - 1 :].startswith(name)
+ assert not language.functions
+
+
[email protected]("name", ["range", "int"])
+def test_reserved_captured_builtin_reports_its_use(language, name):
+ source = f"@M.function\ndef main():\n {name}(2)"
+ with pytest.raises(SyntaxError, match=repr(name)) as caught:
+ entry.parse(source, {"M": language.M, name: lambda value: value},
filename="capture.py")
+ assert (caught.value.filename, caught.value.lineno, caught.value.offset)
== ("capture.py", 3, 5)
+ assert not language.functions
+
+
[email protected]("binding", ["alias, *other = M, 1, 2", "value =
(alias := M)"])
+def test_local_namespace_alias_is_rejected(language, binding):
+ source = "@M.function\ndef main():\n " + binding
+ with pytest.raises(SyntaxError, match="namespace 'M'.*alias") as caught:
+ entry.parse(source, {"M": language.M}, filename="alias.py")
+ assert (caught.value.filename, caught.value.lineno) == ("alias.py", 3)
+ assert source.splitlines()[2][caught.value.offset - 1] == "M"
+ assert not language.functions
+
+
[email protected](
+ "prefix",
+ ["class Module:", "if True:"],
+)
+def test_host_import_cannot_replace_reserved_names(language, prefix):
+ effects = []
+ source = prefix + "\n from math import floor as range\n effect()\n"
+ source += (
+ " @M.function\n def main():\n pass"
+ if prefix.startswith("class")
+ else "@M.function\ndef main():\n pass"
+ )
+ with pytest.raises(SyntaxError, match="'range'.*reserved") as caught:
+ entry.parse(
+ source, {"M": language.M, "effect": lambda: effects.append(1)},
filename="import.py"
+ )
+ assert (caught.value.filename, caught.value.lineno) == ("import.py", 2)
+ assert not effects
+ assert not language.functions
+
+
def test_undefined_name_reports_the_original_source(language):
# A missing body value must remain a Python NameError at the user's
identifier.
M = language.M
diff --git a/tests/python/script/test_jit_specialization.py
b/tests/python/script/test_jit_specialization.py
index b5b1966f70..f2fce055e7 100644
--- a/tests/python/script/test_jit_specialization.py
+++ b/tests/python/script/test_jit_specialization.py
@@ -24,6 +24,7 @@ from __future__ import annotations
import gc
import weakref
+from types import SimpleNamespace
import pytest
@@ -257,3 +258,54 @@ def
test_jit_does_not_retain_ordinary_decorator_owners(jit_language):
function, reference = make()
assert reference() is None
assert function.specialize().body == []
+
+
[email protected]("postponed", [False, True])
+def test_jit_initial_alias_preserves_annotation_forms(tmp_path, postponed):
+ source = """@Script.jit(private=True)
+def kernel(output: Script.Buffer((1,), "int32"), *, value: Script.constexpr,
+ optional: Script.Optional(Script.Buffer((1,), "int32"))):
+ output[0] = value
+"""
+ if postponed:
+ source = "from __future__ import annotations\n" + source.replace(
+ "value: Script.constexpr", "value: 'Script.constexpr'"
+ )
+ path = tmp_path / "aliased_jit.py"
+ path.write_text(source)
+ namespace = {"Script": T}
+ exec(compile(source, str(path), "exec", dont_inherit=True), namespace)
+ kernel = namespace["kernel"]
+ assert kernel.constexpr_names == {"value"}
+ assert kernel.optional_names == {"optional"}
+ result = kernel.specialize(value=3, optional=None)
+ assert len(result.params) == 1
+ assert int(result.body.value) == 3
+
+
+def test_postponed_jit_uses_registered_constexpr_syntax(jit_language,
monkeypatch):
+ M = jit_language.M
+ Alias = M
+ # Syntax classification must not inspect the value behind a registered
marker name.
+ monkeypatch.setattr(M, "constexpr", object())
+ Ordinary = SimpleNamespace(constexpr=I.constexpr, Optional=T.Optional)
+ M.marker_alias = I.constexpr
+
+ def unavailable():
+ pytest.fail("Unselected annotations must remain unevaluated")
+
+ @M.jit
+ def specialized(value: Alias.constexpr):
+ M.record(value)
+
+ assert specialized.specialize(value=4).body == [("emit", 4)]
+
+ @M.jit
+ def ordinary(
+ value: Ordinary.constexpr,
+ optional: Ordinary.Optional(unavailable()),
+ alias: M.marker_alias,
+ ):
+ M.record(value)
+
+ assert ordinary.constexpr_names == ordinary.optional_names == set()
diff --git a/tests/python/script/test_meta_programming.py
b/tests/python/script/test_meta_programming.py
index 513241e695..868bfae8c5 100644
--- a/tests/python/script/test_meta_programming.py
+++ b/tests/python/script/test_meta_programming.py
@@ -28,10 +28,13 @@ import gc
import weakref
from contextlib import contextmanager
from types import SimpleNamespace
+from typing import TypeVar
import pytest
+from tvm import ir
from tvm.script import ir as I
+from tvm.script import relax as R
from tvm.script.parser import entry, protocol_registry
EXTENT = 11
@@ -101,6 +104,27 @@ def
test_module_parameter_does_not_replace_another_functions_capture(language):
assert function.body == [("emit", function.params[0])]
+def test_body_local_shadows_capture_for_later_annotation(language):
+ M = language.M
+ extent = 7
+ seen = []
+
+ def annotation(shape):
+ seen.append(shape)
+ return M.Tensor(shape)
+
+ @M.function
+ def main(x: M.Tensor((extent,))):
+ extent = 3
+ value: annotation((extent,)) = x
+ M.record(value)
+
+ assert main.params[0].args[0].args[0] == (7,)
+ assert seen == [(3,)]
+ assert extent == 7
+ assert main.body == [("emit", main.params[0])]
+
+
def
test_unrelated_same_file_caller_does_not_supply_annotation_locals(language):
# Before: unrelated caller locals shadow both annotation and body global
names.
# Expected builder: X.arg receives global 11; X.record receives global 1.
@@ -240,6 +264,19 @@ def
test_local_annotation_preserves_lambda_and_comprehension_bindings(language):
assert result.args == (function.params[0], function.params[0])
+def test_return_annotation_keeps_local_symbols_and_unused_captures():
+ n = ir.Var("n", "int64")
+ unused = TypeVar("unused", bound=int)
+
+ @R.function
+ def main(x: R.Tensor((n,), "float32")) -> R.Tensor(
+ ((lambda local: local if I.constexpr(True) else unused)(n),), "float32"
+ ):
+ return x
+
+ assert main.ret_ty.shape[0].same_as(n)
+
+
def test_class_annotation_scope_keeps_distinct_method_closure(language):
# Before: an enclosing extent=7 and class extent=3 share a method spelling.
# Expected builder program: the annotation reads class 3, the body closes
over 7.
@@ -375,6 +412,37 @@ def
test_nonlocal_declaration_preserves_captured_values(language):
assert dtype == "float32" and value == 3
[email protected]("hygienic", [True, False])
+def test_macro_local_annotation_captures_definition_and_argument_names(
+ language, hygienic, monkeypatch
+):
+ M = language.M
+ M.macro = protocol_registry.declaration_kind("M.macro",
"helper")(entry.make_macro_decorator(M))
+ MACRO_VALUE = 7 # noqa: F841 — captured only by the postponed local
annotation.
+ observed = []
+
+ def annotation(shape):
+ observed.append(shape)
+ return M.Tensor(shape, "float32")
+
+ monkeypatch.setitem(globals(), "annotation", annotation)
+
+ @M.macro(hygienic=hygienic)
+ def typed_local(size):
+ value: annotation((MACRO_VALUE, size)) = M.value(1, 2)
+ M.record(value)
+
+ monkeypatch.setitem(globals(), "MACRO_VALUE", 9)
+
+ @M.function
+ def function():
+ typed_local(3)
+
+ assert observed == [(7 if hygienic else 9, 3)]
+ assert function.body[0][0] == "emit"
+ assert function.body[0][1].op == "value"
+
+
def test_macro_capture_policy_remains_explicit(language, monkeypatch):
# A changed global must distinguish captured macro scope from caller
scope: values 2 then 1.
M = language.M
diff --git a/tests/python/script/test_parser_entry.py
b/tests/python/script/test_parser_entry.py
index 4679d25fa9..f2e1f3189c 100644
--- a/tests/python/script/test_parser_entry.py
+++ b/tests/python/script/test_parser_entry.py
@@ -23,7 +23,6 @@ from __future__ import annotations
# Keep the single generated-program golden exactly as ast.unparse emits it.
import ast
-import sys
from textwrap import dedent
from tvm.script.parser import entry
@@ -74,9 +73,14 @@ def
test_callable_entry_emits_the_expected_builder_program(language, monkeypatch
expected = dedent(
"""
with _I0.IRBuilder() as _builder0:
+ _definition0 = _host2(('M',), _host0(), _host1())
with M.function_() as _fn0:
- M.func_name('identity')
- x = M.arg('x', M.Tensor((4,)))
+
+ def _declare0(*, M=_definition0.get('M', _I0.MISSING)):
+ M.func_name('identity')
+ x = M.arg('x', M.Tensor((4,)))
+
+ _declare0()
def _build0():
x, = _fn0.params
@@ -87,10 +91,8 @@ def
test_callable_entry_emits_the_expected_builder_program(language, monkeypatch
M.check_well_formed_(_result0)
"""
).strip()
- if sys.version_info[:2] == (3, 10):
- # Python 3.10's ast.unparse parenthesizes this tuple assignment target.
- expected = expected.replace("x, = _fn0.params", "(x,) = _fn0.params")
- assert programs == [expected]
+ assert len(programs) == 1
+ assert ast.dump(ast.parse(programs[0])) == ast.dump(ast.parse(expected))
assert result.params[0].args[0].args[0] == (4,)
assert result.body == [("emit", result.params[0])]
diff --git a/tests/python/script/test_special_parser_protocol.py
b/tests/python/script/test_special_parser_protocol.py
index 6cb7c63bf8..2cdf1fdcb8 100644
--- a/tests/python/script/test_special_parser_protocol.py
+++ b/tests/python/script/test_special_parser_protocol.py
@@ -20,11 +20,13 @@ A failed constructor must not change the next function's
interpretation of liter
import inspect
import traceback
+from types import SimpleNamespace
import pytest
from tvm.script import tirx as T
from tvm.script.ir_builder import resolve_global_info_args
+from tvm.script.parser import entry
def test_constructor_policy_survives_a_failed_definition(language):
@@ -121,3 +123,35 @@ def
test_tirx_rejects_global_info_at_the_call_site(monkeypatch):
location,
column + len('T.global_annotation(device="cuda:0")'),
)
+
+
+def test_ordinary_calls_preserve_literal_arguments(language):
+ M = language.M
+ Alias = M
+ seen = []
+
+ M.special = lambda value: seen.append(value)
+ M.nested = SimpleNamespace(special=M.special)
+ special = M.special
+
+ @M.function
+ def main():
+ Alias.special("n")
+ M.nested.special("n")
+ special("n")
+ M.record(int("3"))
+
+ assert seen == ["n", "n", "n"]
+ assert main.body[-1] == ("emit", 3)
+
+
+def test_initial_import_alias_and_symbolic_range():
+ source = """from tvm.script import tirx as Script
[email protected]_func
+def main(n: Script.int32):
+ for i in range(n):
+ Script.evaluate(i)
+"""
+ function = entry.parse(source)
+ assert function.body.extent.same_as(function.params[0])
+ assert function.body.body.value.same_as(function.body.loop_var)