Rate this Page
★ ★ ★ ★ ★

Source code for helion.runtime.kernel

from __future__ import annotations

import ast
from collections.abc import Sequence
import contextlib
import dataclasses
import functools
import hashlib
import inspect
import itertools
import logging
import os
import re
import sys
import textwrap
import threading
import types
from typing import TYPE_CHECKING
from typing import Any
from typing import Callable
from typing import Generic
from typing import Hashable
from typing import Literal
from typing import NamedTuple
from typing import TypeVar
from typing import cast
from typing import overload
from typing_extensions import Protocol
import weakref

import sympy
import torch
from torch._dynamo.source import GetItemSource
from torch._dynamo.source import LocalSource
from torch._dynamo.source import TensorProperty
from torch._dynamo.source import TensorPropertySource
from torch._inductor.codecache import PyCodeCache
from torch._inductor.codecache import compiled_fx_graph_hash
from torch._subclasses import FakeTensor
from torch._subclasses.fake_tensor import unset_fake_temporarily
import torch.distributed as dist
from torch.utils._pytree import tree_map_only
from torch.utils.weak import WeakIdKeyDictionary

from .. import exc
from .._argument_device import _ArgumentDeviceResolver
from .._argument_device import _canonicalize_argument_device
from .._argument_device import _current_device_index
from .._argument_device import _find_argument_device as _find_device
from .._compat import shape_env_size_hint
from .._compat import target_device_capability
from .._compile_time import measure
from .._compiler.ast_extension import unparse
from .._compiler.autotuner_heuristics import compiler_promotion_specialization_key
from .._compiler.autotuner_heuristics import compiler_seed_configs
from .._compiler.autotuner_heuristics import compiler_seed_specialization_facts
from .._compiler.autotuner_heuristics import register_compiler_coverage_groups
from .._compiler.backend_registry import get_backend
from .._compiler.compile_environment import CUDA_TENSOR_DESCRIPTOR_MAX_BLOCK_SIZE
from .._compiler.compile_environment import CompileEnvironment
from .._compiler.compile_environment import _concrete_tensor_satisfies_alignment_guard
from .._compiler.compile_environment import _is_supported_tensor_input_source
from .._compiler.compile_environment import _replay_tensor_input_source
from .._compiler.compile_environment import _symint_free_symbols
from .._compiler.compile_environment import shape_env_var_hints
from .._compiler.compile_environment import (
    tensor_descriptor_layout_signature_from_strides,
)
from .._compiler.generate_ast import generate_ast
from .._compiler.inductor_lowering_extra import patch_inductor_lowerings
from .._compiler.kernel_compiler import KernelCompiler
from .._compiler.output_header import assert_no_conflicts
from .._compiler.variable_origin import ArgumentOrigin
from .._compiler.variable_origin import BlockSizeOrigin
from .._dist_utils import _find_process_group_name
from .._dist_utils import check_config_consistancy as dist_check_config_consistancy
from .._dist_utils import kernel_declares_process_group
from .._dist_utils import kernel_uses_symm_mem
from .._logging import LazyString
from .._utils import counters
from .._utils import next_power_of_2
from ..autotuner.base_search import _AutotunableKernel
from ..autotuner.benchmark_provider import _SEARCHING_CANDIDATES
from ..language.constexpr import ConstExpr
from .config import Config
from .cute.structural_config import DEFAULT_STRUCTURAL_POLICY
from .cute.structural_config import CuteStructuralConfig
from .cute.structural_config import select_structural_policy
from .ref_mode import RefModeContext
from .ref_mode import is_ref_mode_enabled
from .settings import Settings
from .structural_policy import StructuralConfig
from .structural_policy import StructuralPolicyError
from .structural_policy import require_same_structural_policy

if TYPE_CHECKING:
    from collections.abc import Generator

    from torch._guards import Source

    from .._compiler.autotuner_heuristics.registry import (
        CompilerHeuristicSpecializationFact,
    )
    from .._compiler.compile_environment import PreparedGuardFactory
    from .._compiler.compile_environment import TensorDescriptorLayoutGuard
    from .._compiler.host_function import HostFunction
    from ..autotuner import ConfigSpec
    from ..autotuner.base_cache import BoundKernelInMemoryCacheKey
    from .cute.structural_config import CuteStructuralPolicyRequest
    from .cute.structural_policy import CuteStructuralPolicy

    ConfigLike = Config | dict[str, object] | CuteStructuralConfig

log: logging.Logger = logging.getLogger(__name__)


def _indexing_config_uses_tensor_descriptor(indexing: object, index: int) -> bool:
    if indexing == "tensor_descriptor":
        return True
    if isinstance(indexing, list):
        return index < len(indexing) and indexing[index] == "tensor_descriptor"
    return False


class _TensorDescriptorOperationGuard(Protocol):
    memory_op_indices: set[int]
    atomic_op_indices: set[int]


def _td_guard_active_for_config(
    guard: _TensorDescriptorOperationGuard, config: Config
) -> bool:
    return any(
        _indexing_config_uses_tensor_descriptor(config.indexing, index)
        for index in guard.memory_op_indices
    ) or any(
        _indexing_config_uses_tensor_descriptor(config.atomic_indexing, index)
        for index in guard.atomic_op_indices
    )


_R = TypeVar("_R")
CompiledConfig = Callable[..., _R]

# Opt-in: auto-capture Pallas kernels under torch.compile (see
# pallas._tpu_compile_capture).
# Off by default so the eager dispatch path is unchanged.
_TPU_COMPILE_CAPTURE = os.environ.get("HELION_TPU_COMPILE_CAPTURE", "0") == "1"

# Cache for GraphModule hashes
_graph_module_hash_cache: WeakIdKeyDictionary = WeakIdKeyDictionary()

_INT32_INDEX_LIMIT = torch.iinfo(torch.int32).max
_CUSTOM_KEY_UNSET = object()

_HostSemanticInputNormalization = tuple[tuple[int, str, int, int], ...]


class _FastDispatchEntry(NamedTuple):
    """A dispatch key together with the state used to build it."""

    key: tuple[Hashable, ...]
    extra_guards: tuple[tuple[Callable[[Sequence[object]], Hashable], Hashable], ...]
    specialization_generation: int


class _FastDispatchSchema:
    """What one exact fast-key prefix resolves to.

    The prefix (exact per-argument metadata, ``is_distributed``, the custom
    key and the input-alias partition) of a call the memo admits determines
    the base specialization signature for fixed kernel settings
    (``Kernel._memoized_fast_dispatch_key``), hence which published
    ``_specialize_extra`` list applies and the result of every extractor that
    exact metadata implies (``_implied_by_exact_metadata``).  ``results``
    holds the extras tuple of the call that resolved the prefix; ``per_call``
    names the slots every call re-evaluates (pointer alignment, runtime input
    classifiers, ``hl.specialize``'d scalar values, ...).
    """

    __slots__ = (
        "extra_fns",
        "full_key",
        "generation",
        "per_call",
        "results",
        "settings",
    )

    def __init__(
        self,
        generation: int,
        settings: tuple[Hashable, ...],
        extra_fns: list[Callable[[Sequence[object]], Hashable]],
        results: tuple[Hashable, ...],
        per_call: tuple[tuple[int, Callable[[Sequence[object]], Hashable]], ...],
        prefix: tuple[Hashable, ...],
    ) -> None:
        self.generation = generation
        self.settings = settings
        # Held so the list cannot be collected while this entry is alive.
        self.extra_fns = extra_fns
        self.results = results
        self.per_call = per_call
        # Without per-call slots, the whole fast key: one object shared by
        # every call, so dispatch-cache lookups compare it by identity.
        self.full_key: tuple[Hashable, ...] | None = None
        if not per_call:
            self.full_key = (*prefix, results) if extra_fns else prefix


def _nested_fast_dispatch_key(
    value: object, *, specialize_numeric: bool = False
) -> tuple[Hashable, bool] | None:
    """Return a specialization-safe key and whether ``value`` contains a tensor."""
    value_type = type(value)
    if value_type is torch.Tensor or value_type is torch.nn.Parameter:
        if specialize_numeric:
            # A constexpr-annotated argument specializes on the raw container,
            # so tensor identity (not metadata) drives the full key; a
            # metadata-based fast key would conflate keys the full key
            # distinguishes.
            return None
        tensor = cast("torch.Tensor", value)
        static_indices = getattr(tensor, "_dynamo_static_indices", None)
        return (
            (
                tensor.dtype,
                tensor.shape,
                tensor.stride(),
                tensor.device,
                None if static_indices is None else frozenset(static_indices),
            ),
            True,
        )
    if value_type in (int, float, bool):
        return ((value_type, value) if specialize_numeric else value_type), False
    if value_type is str:
        return (value_type, value), False
    if isinstance(value, ConstExpr):
        try:
            hash(value.value)
        except TypeError:
            return None
        return (value_type, value.value), False
    if value is None:
        return None, False
    if value_type is torch.dtype or value_type is torch.device:
        return cast("Hashable", value), False
    if value_type is tuple or value_type is list:
        items: list[Hashable] = []
        has_tensor = False
        for item in cast("Sequence[object]", value):
            nested = _nested_fast_dispatch_key(
                item, specialize_numeric=specialize_numeric
            )
            if nested is None:
                return None
            item_key, item_has_tensor = nested
            items.append(item_key)
            has_tensor = has_tensor or item_has_tensor
        return (value_type, tuple(items)), has_tensor
    if value_type is dict:
        entries: list[tuple[str | int, Hashable]] = []
        has_tensor = False
        for item_key, item_value in cast("dict[object, object]", value).items():
            if type(item_key) not in (str, int):
                return None
            nested = _nested_fast_dispatch_key(
                item_value, specialize_numeric=specialize_numeric
            )
            if nested is None:
                return None
            value_key, value_has_tensor = nested
            entries.append((cast("str | int", item_key), value_key))
            has_tensor = has_tensor or value_has_tensor
        try:
            entries.sort()
        except TypeError:
            return None
        return (value_type, tuple(entries)), has_tensor
    if value_type is types.FunctionType and not specialize_numeric:
        return _function_fast_dispatch_key(cast("types.FunctionType", value))
    if value_type is types.BuiltinFunctionType:
        # ``_specialization_extractors`` keys builtins by identity.
        return (value_type, value), False
    return None


def _function_fast_dispatch_key(
    fn: types.FunctionType,
) -> tuple[Hashable, bool] | None:
    """Refine ``_function_key``: the code object, every closure cell's key and
    every default value's key.

    Callable arguments (score-modifier style hooks) are common in attention
    kernels; keying them by code object keeps repeat calls on the prepared
    fast path even when the caller rebuilds the function object per call.
    Closure cells are re-read on every call because ``nonlocal`` rebinding can
    change their contents without changing the function object.  Default
    values are baked into the compiled kernel when the lifted function is
    traced (``lift_closures`` passes ``__defaults__`` through), so they key by
    value like constexpr arguments; a default the constexpr rules cannot key
    (a tensor, a function) keeps the slow path.  Module globals the function
    reads are not part of the key: like the kernel's own globals they are read
    from the module when the kernel runs and specialized once, on the first
    call's values.
    """
    closure = fn.__closure__
    defaults = _function_defaults_fast_dispatch_key(fn)
    if defaults is None:
        return None
    if not closure and not defaults:
        return (types.FunctionType, fn.__code__), False
    cells: list[Hashable] = []
    has_tensor = False
    for cell in closure or ():
        nested = _nested_fast_dispatch_key(cell.cell_contents)
        if nested is None:
            return None
        cell_key, cell_has_tensor = nested
        cells.append(cell_key)
        has_tensor = has_tensor or cell_has_tensor
    if not defaults:
        return (types.FunctionType, fn.__code__, tuple(cells)), has_tensor
    return (types.FunctionType, fn.__code__, tuple(cells), defaults), has_tensor


def _function_defaults_fast_dispatch_key(fn: types.FunctionType) -> Hashable | None:
    """Value keys of ``fn``'s positional and keyword-only defaults (``()`` when
    it has none; ``None`` when a default has no constexpr key)."""
    defaults = fn.__defaults__ or ()
    kwdefaults = fn.__kwdefaults__ or {}
    if not defaults and not kwdefaults:
        return ()
    positional: list[Hashable] = []
    for value in defaults:
        nested = _nested_fast_dispatch_key(value, specialize_numeric=True)
        if nested is None:
            return None
        positional.append(nested[0])
    keyword: list[tuple[str, Hashable]] = []
    for name, value in kwdefaults.items():
        nested = _nested_fast_dispatch_key(value, specialize_numeric=True)
        if nested is None:
            return None
        keyword.append((name, nested[0]))
    keyword.sort()
    return (tuple(positional), tuple(keyword))


_TensorMetadataPathStep = (
    tuple[Literal["sequence"], type[object], int]
    | tuple[Literal["mapping"], type[object], type[object], Hashable]
    | tuple[Literal["dataclass"], type[object], str]
)


def _mapping_metadata_sort_key(key: object) -> tuple[str, str, str]:
    key_type = type(key)
    return key_type.__module__, key_type.__qualname__, repr(key)


def _walk_input_tensors(
    value: object,
    path: tuple[_TensorMetadataPathStep, ...] = (),
) -> Generator[tuple[tuple[_TensorMetadataPathStep, ...], torch.Tensor], None, None]:
    """Yield input tensors with deterministic structural roles."""
    if isinstance(value, torch.Tensor):
        yield path, value
        return
    if isinstance(value, ConstExpr):
        return
    container_type = type(value)
    if dataclasses.is_dataclass(value) and not isinstance(value, type):
        for field in dataclasses.fields(value):
            yield from _walk_input_tensors(
                getattr(value, field.name),
                (*path, ("dataclass", container_type, field.name)),
            )
    elif isinstance(value, (tuple, list)):
        for index, item in enumerate(value):
            yield from _walk_input_tensors(
                item,
                (*path, ("sequence", container_type, index)),
            )
    elif isinstance(value, dict):
        for key, item in sorted(
            value.items(),
            key=lambda entry: _mapping_metadata_sort_key(entry[0]),
        ):
            yield from _walk_input_tensors(
                item,
                (
                    *path,
                    ("mapping", container_type, type(key), cast("Hashable", key)),
                ),
            )


def _input_tensor_metadata(values: Sequence[object]) -> tuple[Hashable, ...]:
    """Return exact tensor metadata keyed by deterministic structural roles."""
    return tuple(
        (path, _hashable_dims(tensor.size()), _hashable_dims(tensor.stride()))
        for path, tensor in _walk_input_tensors(values)
    )


def _input_tensor_aliases(values: Sequence[object]) -> tuple[int, ...] | None:
    """Return a canonical key only when tensor arguments alias."""
    # Eager dispatch normally receives a flat argument list. Only structured
    # inputs need the deterministic recursive walk and its metadata paths.
    tensors: list[torch.Tensor] = []
    for value in values:
        if isinstance(value, torch.Tensor):
            tensors.append(value)
        elif isinstance(value, ConstExpr):
            continue
        elif isinstance(value, (tuple, list, dict)) or (
            dataclasses.is_dataclass(value) and not isinstance(value, type)
        ):
            tensors = [tensor for _path, tensor in _walk_input_tensors(values)]
            break
    return _tensor_aliases(tensors)


def _tensor_aliases(tensors: Sequence[torch.Tensor]) -> tuple[int, ...] | None:
    """Canonical alias partition of ``tensors``; None when all are distinct."""
    if len(tensors) < 2:
        return None
    # Identity comparisons only: ``id()`` fires an audit event per call once
    # any audit hook is installed (importing inductor installs one).  The
    # common all-distinct case returns before building the partition.
    unique_tensors: list[torch.Tensor] = []
    for tensor in tensors:
        for previous_tensor in unique_tensors:
            if tensor is previous_tensor:
                break
        else:
            unique_tensors.append(tensor)
            continue
        break
    else:
        return None
    aliases: list[int] = []
    unique_tensors = []
    for tensor in tensors:
        for index, previous_tensor in enumerate(unique_tensors):
            if tensor is previous_tensor:
                aliases.append(index)
                break
        else:
            aliases.append(len(unique_tensors))
            unique_tensors.append(tensor)
    return tuple(aliases)


@dataclasses.dataclass(frozen=True)
class _CompilerSeedSpecializationExtractor:
    fact: CompilerHeuristicSpecializationFact
    reserved_sms: int

    def for_device(self, device: torch.device) -> Hashable:
        from . import get_num_sm
        from .settings import is_pallas_interpret

        if device.type == "cpu" and not is_pallas_interpret():
            # CPU tensors bound on a GPU backend (e.g. type-propagation debug
            # binds) have no SM count and can never launch; any stable key
            # keeps the specialization cache coherent.
            return self.fact, 0
        if self.fact == "device_num_sm":
            return self.fact, get_num_sm(device)
        assert self.fact == "config_num_sm"
        return self.fact, get_num_sm(device, reserved_sms=self.reserved_sms)

    def __call__(self, values: Sequence[object]) -> Hashable:
        if self.fact == "input_tensor_metadata":
            return self.fact, _input_tensor_metadata(values)

        device = _canonicalize_argument_device(_find_device(tuple(values)))
        return self.for_device(device)


def _compiler_seed_specialization_extractors(
    facts: frozenset[CompilerHeuristicSpecializationFact],
    reserved_sms: int,
    *,
    static_shapes: bool,
) -> tuple[_CompilerSeedSpecializationExtractor, ...]:
    if static_shapes:
        facts = frozenset(fact for fact in facts if fact != "input_tensor_metadata")
    return tuple(
        _CompilerSeedSpecializationExtractor(fact, reserved_sms)
        for fact in sorted(facts)
    )


# (slot index, extractor) pairs of an extras tuple re-evaluated on every call.
_PerCallSlots = tuple[tuple[int, Callable[[Sequence[object]], Hashable]], ...]


class _SpecializationAlias:
    """An omitted-default signature backed by a normalized signature."""

    __slots__ = (
        "schemas",
        "canonical_signature",
        "trailing_defaults",
        "per_call_cache",
    )

    def __init__(
        self,
        schemas: dict[Hashable, list[Callable[[Sequence[object]], Hashable]]],
        canonical_signature: tuple[Hashable, ...],
        trailing_defaults: tuple[object, ...],
    ) -> None:
        self.schemas = schemas
        self.canonical_signature = canonical_signature
        self.trailing_defaults = trailing_defaults
        # The canonical list ``_specialization_alias_per_call`` last split and
        # its result: published lists are replaced, never mutated.
        self.per_call_cache: (
            tuple[
                list[Callable[[Sequence[object]], Hashable]] | None,
                _PerCallSlots | None,
            ]
            | None
        ) = None

    def __call__(self, values: Sequence[object]) -> Hashable:
        normalized = (*values, *self.trailing_defaults)
        return tuple(
            extractor(normalized)
            for extractor in self.schemas[self.canonical_signature]
        )


@dataclasses.dataclass(frozen=True)
class PreparedMetadataSpecializationExtractor:
    """Specialization read from input tensor metadata.

    Exact metadata, which ``_make_prepared_arg_guard`` checks, implies its
    result unless it reads a storage offset (``_implied_by_exact_metadata``).
    """

    extractor: Callable[[Sequence[object]], Hashable]

    def __call__(self, args: Sequence[object]) -> Hashable:
        return self.extractor(args)


@dataclasses.dataclass(frozen=True, eq=False)
class _TensorDescriptorAlignmentExtractor:
    """Whether a host-descriptor tensor's base pointer meets its alignment
    guard.  Prepared metadata guards do not cover base pointers, so this runs
    on every call."""

    extract_tensor: Callable[[Sequence[object]], Hashable]
    requires_zero_storage_offset: bool

    def __call__(self, args: Sequence[object]) -> Hashable:
        tensor = self.extract_tensor(args)
        tensor_type = type(tensor)
        if tensor_type is torch.Tensor or tensor_type is torch.nn.Parameter:
            # ``_concrete_tensor_satisfies_alignment_guard`` for a concrete
            # tensor, inline: this runs on every dispatch.
            tensor = cast("torch.Tensor", tensor)
            return tensor.data_ptr() % 16 == 0 and (
                not self.requires_zero_storage_offset or tensor.storage_offset() == 0
            )
        return _concrete_tensor_satisfies_alignment_guard(
            tensor, self.requires_zero_storage_offset
        )


def _implied_by_exact_metadata(
    extractor: Callable[[Sequence[object]], Hashable],
) -> bool:
    """Whether exact argument metadata implies ``extractor``'s result.

    Both the fast-key prefix and the prepared argument guard
    (``_make_prepared_arg_guard``) pin each tensor's exact
    dtype/shape/stride/device but not its storage offset (symbolic in a
    dynamic-shape compile), so a guard fact over a storage-offset symbol is
    evaluated on every call like pointer alignment and scalar values.
    """
    if not isinstance(extractor, PreparedMetadataSpecializationExtractor):
        return False
    inner = extractor.extractor
    return not (
        isinstance(inner, _GuardedInputFact)
        and any(
            source.prop == TensorProperty.STORAGE_OFFSET
            for _symbol, source in inner.sources
        )
    )


# Omitted defaults an alias can look through: immutable, so the canonical
# extractors exact metadata implies read nothing a later call can change.
_IMMUTABLE_DEFAULT_TYPES: tuple[type[object], ...] = (
    int,
    float,
    bool,
    str,
    type(None),
    torch.dtype,
    torch.device,
)


class _SpecializationAliasDispatch:
    """The per-call part of an omitted-default ``_SpecializationAlias`` slot.

    Produces the alias's result tuple from memoized canonical results,
    re-evaluating only the canonical extractors that exact metadata does not
    imply, on the arguments with the trailing defaults appended.
    """

    __slots__ = ("per_call", "results", "trailing_defaults")

    def __init__(
        self,
        trailing_defaults: tuple[object, ...],
        results: tuple[Hashable, ...],
        per_call: tuple[tuple[int, Callable[[Sequence[object]], Hashable]], ...],
    ) -> None:
        self.trailing_defaults = trailing_defaults
        self.results = results
        self.per_call = per_call

    def __call__(self, args: Sequence[object]) -> Hashable:
        normalized = (*args, *self.trailing_defaults)
        results = list(self.results)
        for index, extractor in self.per_call:
            results[index] = extractor(normalized)
        return tuple(results)


def _evaluate_fast_dispatch_extras(
    extra_fns: Sequence[Callable[[Sequence[object]], Hashable]],
    args: Sequence[object],
) -> tuple[
    tuple[Hashable, ...],
    tuple[tuple[int, Callable[[Sequence[object]], Hashable]], ...],
]:
    """Evaluate a published schema on ``args``, in order, and split off the
    slots the exact fast-key prefix does not imply (with what re-evaluates
    each)."""
    results: list[Hashable] = []
    per_call: list[tuple[int, Callable[[Sequence[object]], Hashable]]] = []
    for index, extractor in enumerate(extra_fns):
        dispatch: Callable[[Sequence[object]], Hashable] | None
        if isinstance(extractor, _SpecializationAlias):
            result, dispatch = _evaluate_specialization_alias(extractor, args)
        else:
            result = extractor(args)
            dispatch = None if _implied_by_exact_metadata(extractor) else extractor
        results.append(result)
        if dispatch is not None:
            per_call.append((index, dispatch))
    return tuple(results), tuple(per_call)


def _specialization_alias_per_call(
    alias: _SpecializationAlias,
) -> tuple[list[Callable[[Sequence[object]], Hashable]], _PerCallSlots] | None:
    """The canonical schema behind ``alias`` and its slots exact metadata does
    not imply; None when the whole alias stays per call (a mutable trailing
    default, or no canonical schema, which makes the alias raise).

    Cached per canonical list: every re-prepared call looks it up.
    """
    canonical = alias.schemas.get(alias.canonical_signature)
    cached = alias.per_call_cache
    if cached is None or cached[0] is not canonical:
        per_call = None
        if canonical is not None and all(
            type(default) in _IMMUTABLE_DEFAULT_TYPES
            for default in alias.trailing_defaults
        ):
            per_call = tuple(
                (index, extractor)
                for index, extractor in enumerate(canonical)
                if not _implied_by_exact_metadata(extractor)
            )
        cached = alias.per_call_cache = (canonical, per_call)
    canonical, per_call = cached
    if canonical is None or per_call is None:
        return None
    return canonical, per_call


def _evaluate_specialization_alias(
    alias: _SpecializationAlias,
    args: Sequence[object],
) -> tuple[Hashable, Callable[[Sequence[object]], Hashable] | None]:
    """``alias(args)`` and what re-evaluates it per call (None when exact
    metadata implies all of it)."""
    if (split := _specialization_alias_per_call(alias)) is None:
        return alias(args), alias
    canonical, per_call = split
    normalized = (*args, *alias.trailing_defaults)
    # The same tuple ``alias(args)`` builds.
    results = tuple(extractor(normalized) for extractor in canonical)
    if not per_call:
        return results, None
    return results, _SpecializationAliasDispatch(
        alias.trailing_defaults, results, per_call
    )


def _capture_safe_specialization(
    extractor: Callable[[Sequence[object]], Hashable],
) -> bool:
    if isinstance(extractor, PreparedMetadataSpecializationExtractor):
        return True
    if isinstance(extractor, _SpecializationAlias):
        return all(
            _capture_safe_specialization(item)
            for item in extractor.schemas[extractor.canonical_signature]
        )
    return False


@dataclasses.dataclass(frozen=True)
class _RuntimeInputSpecializationExtractor:
    """Runtime classifier together with its source projections."""

    source_extractors: tuple[Callable[[Sequence[object]], Hashable], ...]
    classifier: Callable[[Sequence[object]], Hashable]
    reusable_tensor_properties: frozenset[str]
    specialization_key: str | None = None
    prepared_guard_factory: PreparedGuardFactory | None = None

    def source_values(self, args: Sequence[object]) -> tuple[object, ...]:
        return tuple(extract(args) for extract in self.source_extractors)

    def __call__(self, args: Sequence[object]) -> Hashable:
        return self.classifier(self.source_values(args))


def _partition_prepared_extra_guards(
    args: tuple[object, ...],
    extra_guards: tuple[tuple[Callable[[Sequence[object]], Hashable], Hashable], ...],
) -> tuple[
    tuple[tuple[Callable[[Sequence[object]], Hashable], Hashable], ...],
    tuple[Callable[[Sequence[object]], bool], ...],
]:
    """Split guards into always-check classifiers and prepared fact guards.

    Tensor type/shape/stride/dtype/device facts are already checked by the
    prepared argument guard, so extractors exact metadata implies are
    dropped, and an omitted-default alias keeps only its per-call part
    (``_prepared_alias_guard``).  A runtime input specialization may supply a
    ``prepared_guard_factory`` that turns its expected result into a direct
    check of the pointer/storage facts those pins leave open, so repeat calls
    with fresh output tensors never re-run the full classifier.  Classifiers
    without one (tensor contents, arbitrary Python state) remain in the
    always-check set, as does any factory guard that rejects the very
    arguments it was prepared from.
    """
    always_check: list[tuple[Callable[[Sequence[object]], Hashable], Hashable]] = []
    fact_guards: list[Callable[[Sequence[object]], bool]] = []
    for extractor, expected in extra_guards:
        if isinstance(extractor, _SpecializationAlias):
            alias_guard = _prepared_alias_guard(extractor, expected, args)
            if alias_guard is not None:
                always_check.append((alias_guard, expected))
            continue
        if _implied_by_exact_metadata(extractor):
            continue
        guard = (
            _build_prepared_fact_guard(extractor, expected, args)
            if isinstance(extractor, _RuntimeInputSpecializationExtractor)
            else None
        )
        if guard is None:
            always_check.append((extractor, expected))
        else:
            fact_guards.append(guard)
    return tuple(always_check), tuple(fact_guards)


def _prepared_alias_guard(
    alias: _SpecializationAlias,
    expected: Hashable,
    args: tuple[object, ...],
) -> Callable[[Sequence[object]], Hashable] | None:
    """What a prepared call re-evaluates for an omitted-default alias slot.

    Looks through to the canonical schema like the fast-key memo
    (``_specialization_alias_per_call``): None when exact metadata, which the
    prepared argument guard pins, implies every canonical extractor, else a
    ``_SpecializationAliasDispatch`` that re-evaluates only the rest and
    takes the implied slots from ``expected``, the fast key's value for these
    arguments (as the guards of non-alias extractors exact metadata implies
    are dropped unevaluated; evaluating them would cost a few microseconds
    each time a call re-prepares).  The whole alias stays when its defaults
    are mutable or its canonical schema is missing, and, failing closed, when
    ``expected`` does not have the canonical shape or the re-evaluated slots
    do not reproduce it.
    """
    split = _specialization_alias_per_call(alias)
    if split is None or type(expected) is not tuple or len(expected) != len(split[0]):
        return alias
    per_call = split[1]
    if not per_call:
        return None
    dispatch = _SpecializationAliasDispatch(alias.trailing_defaults, expected, per_call)
    return dispatch if dispatch(args) == expected else alias


def _build_prepared_fact_guard(
    extractor: _RuntimeInputSpecializationExtractor,
    expected: Hashable,
    args: tuple[object, ...],
) -> Callable[[Sequence[object]], bool] | None:
    factory = extractor.prepared_guard_factory
    if factory is None:
        return None
    try:
        guard = factory(extractor.source_extractors, expected)
        return guard if guard(args) else None
    except Exception:
        return None


def _make_prepared_arg_guard(
    kernel: Kernel,
    args: tuple[object, ...],
) -> Callable[[tuple[object, ...]], bool]:
    namespace: dict[str, object] = {}
    checks = [f"len(args) == {len(args)}"]
    counter = itertools.count()
    tensor_paths: list[tuple[str, weakref.ReferenceType[torch.Tensor]]] = []
    # Plain tensors whose dtype / device / shape / stride one C++ guard
    # (dynamo's ``TensorGuards``) checks in front of the Python checks: ~0.1
    # us per tensor against ~0.5 us for the four attribute comparisons.
    cxx_tensor_paths: list[tuple[str, torch.Tensor]] = []

    def append_checks(
        value: object,
        prefix: str,
        annotation: object | None = None,
    ) -> None:
        token = next(counter)
        value_type = type(value)
        namespace[f"type_{token}"] = value_type
        checks.append(f"type({prefix}) is type_{token}")
        if value_type in (torch.Tensor, torch.nn.Parameter):
            assert isinstance(value, torch.Tensor)
            for previous_prefix, previous_tensor_ref in tensor_paths:
                relation = "is" if previous_tensor_ref() is value else "is not"
                checks.append(f"{prefix} {relation} {previous_prefix}")
            tensor_paths.append((prefix, weakref.ref(value)))
            static_indices = getattr(value, "_dynamo_static_indices", None)
            if value_type is torch.Tensor and static_indices is None:
                # (The C++ guard also pins requires_grad and the dispatch
                # keys: a change there falls back to the full dispatch.)
                cxx_tensor_paths.append((prefix, value))
            else:
                namespace[f"dtype_{token}"] = value.dtype
                namespace[f"shape_{token}"] = value.shape
                namespace[f"stride_{token}"] = value.stride()
                namespace[f"device_{token}"] = value.device
                checks.extend(
                    (
                        f"{prefix}.dtype is dtype_{token}",
                        f"{prefix}.shape == shape_{token}",
                        f"{prefix}.stride() == stride_{token}",
                        f"{prefix}.device == device_{token}",
                    )
                )
            if static_indices is None:
                checks.append(
                    f"getattr({prefix}, '_dynamo_static_indices', None) is None"
                )
            else:
                namespace[f"static_indices_{token}"] = frozenset(static_indices)
                checks.extend(
                    (
                        f"getattr({prefix}, '_dynamo_static_indices', None) is not None",
                        f"frozenset({prefix}._dynamo_static_indices) == static_indices_{token}",
                    )
                )
        elif value_type is tuple or value_type is list:
            sequence = cast("Sequence[object]", value)
            checks.append(f"len({prefix}) == {len(sequence)}")
            for item_index, item in enumerate(sequence):
                append_checks(
                    item,
                    f"{prefix}[{item_index}]",
                    annotation,
                )
        elif value_type is dict:
            mapping = cast("dict[str | int, object]", value)
            checks.append(f"len({prefix}) == {len(mapping)}")
            for item_key, item_value in mapping.items():
                key_name = f"key_{token}_{len(namespace)}"
                namespace[key_name] = item_key
                checks.append(f"{key_name} in {prefix}")
                append_checks(
                    item_value,
                    f"{prefix}[{key_name}]",
                    annotation,
                )
        elif value_type in (bool, int, float):
            # Ordinary numeric arguments are runtime values in Helion's base
            # specialization.  A constexpr annotation is the one exception;
            # hl.specialize() constraints are covered by ``_extra_guards``.
            if annotation is ConstExpr:
                if value_type is float and value != value:
                    # Python's equality/hash behavior does not provide a stable
                    # equivalence class for constexpr NaNs.  Keep those calls on
                    # the regular specialization path rather than weakening its
                    # semantics in the prepared guard.
                    raise TypeError("constexpr NaN cannot use a prepared call")
                namespace[f"value_{token}"] = (
                    cast("float", value).hex() if value_type is float else value
                )
                checks.append(
                    f"{prefix}.hex() == value_{token}"
                    if value_type is float
                    else f"{prefix} == value_{token}"
                )
        elif value_type in (str, type(None), torch.dtype, torch.device):
            namespace[f"value_{token}"] = value
            checks.append(f"{prefix} == value_{token}")
        elif isinstance(value, ConstExpr):
            inner = value.value
            if type(inner) is float:
                if inner != inner:
                    # Match the constexpr-annotation path above: NaN has no
                    # stable equivalence class, so keep those calls on the
                    # regular specialization path.
                    raise TypeError("constexpr NaN cannot use a prepared call")
                namespace[f"value_{token}"] = inner.hex()
                checks.append(f"{prefix}.value.hex() == value_{token}")
            else:
                namespace[f"value_{token}"] = inner
                checks.append(f"{prefix}.value == value_{token}")
        elif value_type is types.FunctionType and annotation is not ConstExpr:
            # Mirror ``_function_key``: the code object identifies the
            # function, every closure cell is guarded like an argument of its
            # own type (cells are re-read because ``nonlocal`` can rebind them
            # without replacing the function object), and every default value
            # like a constexpr argument (defaults are baked into the traced
            # kernel).  Module globals the function reads are not guarded, as
            # the kernel's own globals are not.
            function = cast("types.FunctionType", value)
            namespace[f"code_{token}"] = function.__code__
            checks.append(f"{prefix}.__code__ is code_{token}")
            for cell_index, cell in enumerate(function.__closure__ or ()):
                append_checks(
                    cell.cell_contents,
                    f"{prefix}.__closure__[{cell_index}].cell_contents",
                )
            if _function_defaults_fast_dispatch_key(function) is None:
                raise TypeError("callable default without a constexpr key")
            defaults = function.__defaults__ or ()
            checks.append(f"len({prefix}.__defaults__ or ()) == {len(defaults)}")
            for default_index, default in enumerate(defaults):
                append_checks(
                    default, f"{prefix}.__defaults__[{default_index}]", ConstExpr
                )
            kwdefaults = function.__kwdefaults__ or {}
            checks.append(f"len({prefix}.__kwdefaults__ or {{}}) == {len(kwdefaults)}")
            for name, default in kwdefaults.items():
                checks.append(f"{name!r} in {prefix}.__kwdefaults__")
                append_checks(default, f"{prefix}.__kwdefaults__[{name!r}]", ConstExpr)
        elif value_type is types.BuiltinFunctionType:
            namespace[f"value_{token}"] = value
            checks.append(f"{prefix} is value_{token}")
        else:
            raise TypeError(f"unsupported prepared-call argument: {value_type!r}")

    try:
        for index, arg in enumerate(args):
            append_checks(arg, f"args[{index}]", kernel._annotations[index])
    finally:
        # Break the recursive closure cycle so its temporary C++ guard inputs
        # are released even when cyclic GC is disabled or traversal raises.
        append_checks = cast("Any", None)
    if cxx_tensor_paths:
        cxx_check = _prepared_cxx_tensor_check(
            [tensor for _prefix, tensor in cxx_tensor_paths]
        )
        if cxx_check is not None:
            namespace["cxx_tensor_check"] = cxx_check
            paths = ", ".join(prefix for prefix, _tensor in cxx_tensor_paths)
            # After every structural check: a path below a container or a
            # closure cell exists only once the checks before it have held
            # (a shorter list, a builtin in a function's place), and the
            # chain short-circuits before reading it.
            checks.append(f"cxx_tensor_check({paths})")
        else:
            for prefix, value in cxx_tensor_paths:
                token = next(counter)
                namespace[f"dtype_{token}"] = value.dtype
                namespace[f"shape_{token}"] = value.shape
                namespace[f"stride_{token}"] = value.stride()
                namespace[f"device_{token}"] = value.device
                checks.extend(
                    (
                        f"{prefix}.dtype is dtype_{token}",
                        f"{prefix}.shape == shape_{token}",
                        f"{prefix}.stride() == stride_{token}",
                        f"{prefix}.device == device_{token}",
                    )
                )
    return eval(f"lambda args: {' and '.join(checks)}", namespace)


def _prepared_cxx_tensor_check(
    tensors: Sequence[torch.Tensor],
) -> Callable[..., bool] | None:
    """One C++ check of every tensor's type, dtype, device, requires_grad,
    dispatch keys, sizes and strides against these tensors' (dynamo's
    ``TensorGuards``), or None when the guard is unavailable or does not
    accept them (the Python checks then stand alone)."""
    try:
        from torch._C._dynamo.guards import TensorGuards
    except ImportError:  # pragma: no cover - older torch
        return None
    try:
        guards = TensorGuards(
            *tensors,
            dynamic_dims_sizes=cast("Any", [list(t.shape) for t in tensors]),
            dynamic_dims_strides=cast("Any", [list(t.stride()) for t in tensors]),
        )
        if not guards.check(*tensors):
            return None
    except Exception:
        return None
    return guards.check


class _PreparedCall:
    """Monomorphic eager-call guard for a compiled ``BoundKernel``.

    ``Kernel._dispatch_cache`` remains the general multi-specialization cache.
    This object only makes its most recently used entry cheap to revisit: it
    compares argument metadata without rebuilding and hashing a nested cache
    key, then calls the already-compiled host wrapper directly.
    """

    __slots__ = (
        "_dist_initialized",
        "_extra_guards",
        "_fact_guards",
        "_is_distributed",
        "_matches_args",
        "_reset_generation",
        "_specialization_generation",
        "bound",
    )

    def __init__(
        self,
        bound: BoundKernel,
        args: tuple[object, ...],
        *,
        dist_initialized: bool,
        extra_guards: tuple[
            tuple[Callable[[Sequence[object]], Hashable], Hashable], ...
        ],
        is_distributed: bool,
    ) -> None:
        self._matches_args = _make_prepared_arg_guard(bound.kernel, args)
        self._dist_initialized = dist_initialized
        self._is_distributed = is_distributed
        self._extra_guards, self._fact_guards = _partition_prepared_extra_guards(
            args, extra_guards
        )
        self._specialization_generation = bound.kernel._specialization_generation
        self._reset_generation = bound.kernel._reset_generation
        self.bound = bound

    @classmethod
    def build(
        cls,
        kernel: Kernel,
        bound: BoundKernel,
        args: tuple[object, ...],
        *,
        dist_initialized: bool,
        extra_guards: tuple[
            tuple[Callable[[Sequence[object]], Hashable], Hashable], ...
        ],
        is_distributed: bool,
    ) -> _PreparedCall | None:
        if (
            not bound.env.backend.supports_eager_prepared_call
            or kernel._key_fn is not None
        ):
            return None
        # The caller already obtained a non-None ``_fast_dispatch_key``, which
        # proves every argument has an exact supported type.
        try:
            return cls(
                bound,
                args,
                dist_initialized=dist_initialized,
                extra_guards=extra_guards,
                is_distributed=is_distributed,
            )
        except Exception:
            # Preparation is optional and must not introduce a new user-visible
            # failure after a kernel has run successfully.
            return None

    def matches(self, kernel: Kernel, args: tuple[object, ...]) -> bool:
        try:
            if (
                self._reset_generation != kernel._reset_generation
                or self._specialization_generation != kernel._specialization_generation
                or not self._matches_args(args)
            ):
                return False
            dist_initialized = dist.is_initialized()
            # ``kernel_uses_symm_mem`` and declared distributed intent are both
            # false before process-group initialization. If that state changes,
            # reject the prepared call before rechecking it.
            if dist_initialized != self._dist_initialized or (
                dist_initialized
                and kernel._compute_is_distributed(
                    args, dist_initialized=dist_initialized
                )
                != self._is_distributed
            ):
                return False
            for extractor, expected in self._extra_guards:
                if extractor(args) != expected:
                    return False
            for guard in self._fact_guards:
                if not guard(args):
                    return False
            return (
                self._reset_generation == kernel._reset_generation
                and self._specialization_generation == kernel._specialization_generation
            )
        except Exception:
            # Guard evaluation is an optional fast path. Falling through lets
            # the normal dispatch machinery preserve its own error semantics.
            return False


def _canonicalize_multi_shape_device(device: torch.device) -> torch.device:
    if device.type in ("cpu", "meta", "mps"):
        raise exc.InvalidAPIUsage(
            f"autotune_multi requires an indexed accelerator device, got {device}"
        )
    if device.index is not None:
        return device
    try:
        index = _current_device_index(device.type)
    except exc.InvalidAPIUsage as error:
        raise exc.InvalidAPIUsage(
            "autotune_multi requires a current indexed accelerator, "
            f"got {device.type!r}"
        ) from error
    return torch.device(device.type, index)


def _has_unspecialized_numeric_value(value: object) -> bool:
    """Return whether normal specialization records only a numeric value's type."""
    if isinstance(value, ConstExpr):
        return False
    if type(value) in (bool, int, float):
        return True
    if dataclasses.is_dataclass(value) and not isinstance(value, type):
        return any(
            _has_unspecialized_numeric_value(getattr(value, field.name))
            for field in dataclasses.fields(value)
        )
    if isinstance(value, dict):
        return any(_has_unspecialized_numeric_value(item) for item in value.values())
    if isinstance(value, (list, tuple)):
        return any(_has_unspecialized_numeric_value(item) for item in value)
    return False


def _resolve_index_dtype(
    settings: Settings,
    args: Sequence[object] | tuple[object, ...],
) -> torch.dtype:
    if (index_dtype := settings.index_dtype) is not None:
        limit = torch.iinfo(index_dtype).max
    else:
        limit = _INT32_INDEX_LIMIT
    over_limit = False

    def _check(tensor: torch.Tensor) -> None:
        nonlocal over_limit
        if over_limit:
            return
        try:
            over_limit = bool(tensor.numel() > limit)
        except RuntimeError:  # unbacked SymInt
            if index_dtype is None:
                over_limit = True

    tree_map_only(torch.Tensor, _check, args)
    # pyrefly: ignore [unbound-name]
    if index_dtype is None:  # Auto-select when not provided
        return torch.int64 if over_limit else torch.int32
    if over_limit:
        # pyrefly: ignore [unbound-name]
        raise exc.InputTensorNumelExceedsIndexType(index_dtype=index_dtype)
    # pyrefly: ignore [unbound-name]
    return index_dtype


def _argument_device_is_fixed(args: Sequence[object]) -> bool:
    """Whether the device ``_device_specialization_key`` keys is the same for
    every insertion order of ``args``' dicts and every current device.

    True when every tensor and ``torch.device`` that ``_find_device`` can
    reach is one device, and that device is indexed (or has no index to
    resolve).  The fast-key prefix sorts dict entries, so with several devices
    a dict's insertion order can change which one is found first.  ``args``
    are arguments ``_fast_dispatch_key`` admits, every value of an exact
    type, so exact type checks walk what ``_find_device``'s ``isinstance``
    checks do (a ``ConstExpr`` is not a plain tuple).
    """
    device: torch.device | None = None
    pending = list(args)
    while pending:
        value = pending.pop()
        value_type = type(value)
        if value_type is torch.Tensor or value_type is torch.nn.Parameter:
            value_device = cast("torch.Tensor", value).device
        elif value_type is torch.device:
            value_device = cast("torch.device", value)
        else:
            if value_type is tuple or value_type is list:
                pending.extend(cast("Sequence[object]", value))
            elif value_type is dict:
                pending.extend(cast("dict[object, object]", value).values())
            continue
        if device is None:
            device = value_device
        elif value_device != device:
            return False
    return (
        device is None
        or device.index is not None
        or device.type in ("cpu", "meta", "mps")
    )


def _device_specialization_key(
    args: Sequence[object],
    *,
    backend: str,
    compiler_heuristics_enabled: bool,
) -> tuple[
    str | None,
    tuple[int, int] | None,
    tuple[tuple[str, str | None], ...],
]:
    """Return the recursive argument device key used by bound-kernel caching.

    `_find_device` intentionally searches tensors and bare `torch.device`
    objects inside supported containers to match binding device selection.
    Capability splits mixed-sm cache entries. Exact hardware identity is added
    only when an active compiler heuristic uses it to gate default promotion.
    Device index remains excluded so otherwise-compatible devices share bound
    kernels, matching prior behavior.
    """
    try:
        device = _canonicalize_argument_device(_find_device(tuple(args)))
    except exc.NoTensorArgs:
        return None, None, ()
    promotion_key = (
        compiler_promotion_specialization_key(backend, device)
        if compiler_heuristics_enabled
        else ()
    )
    return device.type, target_device_capability(device), promotion_key


@dataclasses.dataclass
class OutputCodeOptions:
    """Options for :meth:`BoundKernel.to_code`.

    Passing ``options=None`` (the default) keeps ``to_code``'s original behavior.

    Attributes:
        allow_helion_deps: When ``False``, emit a self-contained module that does
            not import ``helion`` at runtime -- the dependency-free launcher is
            inlined (and any in-kernel runtime helpers are embedded) so the only
            deps are ``torch`` + the backend DSL.
        jax_fn: Pallas only. When ``True``, emit a module whose entrypoint operates
            on ``jax.Array`` inputs instead of TorchTPU tensors. Orthogonal to
            ``allow_helion_deps``: combine with ``allow_helion_deps=False`` for a
            pure-JAX module (launch core inlined), or leave ``allow_helion_deps=True``
            to import the launch core from helion.
    """

    allow_helion_deps: bool = True
    jax_fn: bool = False


[docs] class Kernel(Generic[_R]):
[docs] def __init__( self, fn: Callable[..., _R], *, configs: Sequence[ConfigLike] | None = None, settings: Settings | None, key: Callable[..., Hashable] | None = None, cute_structural_policy: CuteStructuralPolicyRequest = DEFAULT_STRUCTURAL_POLICY, ) -> None: """ Initialize the Kernel object. This is typically called from the `@helion.kernel` decorator. Args: fn: The function to be compiled as a Helion kernel. configs: A list of configurations to use for the kernel. settings: The settings to be used by the Kernel. If None, a new `Settings()` instance is created. key: Optional callable that returns an extra hashable component for specialization. """ super().__init__() assert isinstance(fn, types.FunctionType) assert_no_conflicts(fn) self.name: str = fn.__name__ # pyrefly: ignore [read-only] self.fn: types.FunctionType = fn self._kernel_source: str | None = None self.signature: inspect.Signature = inspect.signature(fn) self.settings, selected_configs, self._cute_structural_policy = ( select_structural_policy( settings or Settings(), configs or [], cute_structural_policy ) ) self._key_fn: Callable[..., Hashable] | None = key # Whether the kernel declares distributed intent via an hl.ProcessGroupName # argument. Computed once so the per-call is_distributed check stays cheap # on the dispatch hot path (avoids re-running inspect.signature). See #3024. self._declares_process_group: bool = kernel_declares_process_group(fn) self.configs: list[Config] = [ # pyrefly: ignore [bad-argument-type] Config(**config) if isinstance(config, dict) else config for config in selected_configs ] self._bind_lock = threading.RLock() self._specialize_extra_lock = threading.Lock() self._bound_kernels: dict[BoundKernelInMemoryCacheKey, BoundKernel] = {} # Fast dispatch cache: maps a cheap, fine-grained argument key (exact # dtype/shape/stride/device per tensor) directly to a BoundKernel, # skipping the full specialization-key machinery on repeat calls. self._dispatch_cache: dict[Hashable, BoundKernel] = {} # Fast-key prefix -> the schema it resolves to (``_FastDispatchSchema``), # so repeat ``_fast_dispatch_key`` calls skip the base specialization # key and the extractors exact metadata implies. Entries are valid for # one ``_specialization_generation``; every generation bump drops them. self._fast_dispatch_schemas: dict[ tuple[Hashable, ...], _FastDispatchSchema ] = {} self._prepared_call: _PreparedCall | None = None # Signature -> its published extractor list. Writers either fill a # missing (None) slot or bump ``_specialization_generation``, and a # published list is replaced, never mutated in place: memoized fast # dispatch schemas (and any cache keyed by a list's id(), which must # also hold the list) rely on both. self._specialize_extra: dict[ Hashable, list[Callable[[Sequence[object]], Hashable]] ] = {} self._compiler_seed_specialize_extra: dict[ Hashable, tuple[_CompilerSeedSpecializationExtractor, ...] ] = {} self._specialization_aliases: dict[ tuple[Hashable, ...], _SpecializationAlias ] = {} self._specialization_generation = 0 self._reset_generation = 0 self._has_specialization_extras = False # Descriptors of the input-value specializations a backend registered # from generated code (``Backend.after_codegen`` / ``after_compile``), # per signature, so each is keyed once. self._cute_grouped_static_tail_extra_descriptors: dict[ Hashable, set[Hashable] ] = {} self._cute_reduction_extent_descriptors: dict[Hashable, set[Hashable]] = {} if any( param.kind in ( inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD, inspect.Parameter.KEYWORD_ONLY, ) for param in self.signature.parameters.values() ): raise TypeError( f"Kernel({self.name}) cannot have *args, **kwargs, or keyword-only arguments" ) self._annotations: list[object] = [] for param in self.signature.parameters.values(): ann = param.annotation if isinstance(ann, str) and re.search(r"constexpr", ann, re.IGNORECASE): self._annotations.append(ConstExpr) else: self._annotations.append(ann) # Cache the number of parameters to avoid accessing self.signature.parameters # during torch.compile tracing. self._num_params: int = len(self.signature.parameters) self._normalize_args_plans: dict[ tuple[int, tuple[str, ...]], tuple[tuple[object, ...], tuple[int, ...]] | None, ] = {} # Expose function attributes for compatibility with torch.library.custom_op # These are set as instance attributes to allow the Kernel to be used # as if it were a regular function for introspection purposes functools.update_wrapper(self, fn) # Manually add function-specific attributes not copied by update_wrapper self.__globals__ = fn.__globals__ self.__code__ = fn.__code__ self.__defaults__ = fn.__defaults__ self.__kwdefaults__ = fn.__kwdefaults__ # Opt-in: register the torch.compile(backend="tpu") capture op now, so an # annotated/functional/benchmark-free Pallas kernel is captured with zero # warm-up (like a hand-written custom_op). None if it must use first-call # registration (unannotated, mutating, or autotuning among configs=[...]). self._capture_op: Callable[..., Any] | None = None if self.settings.backend == "pallas" and _TPU_COMPILE_CAPTURE: from .pallas._tpu_compile_capture import register_decoration_op self._capture_op = register_decoration_op(self)
[docs] def kernel_source(self) -> str: """ Return the kernel's source text. This is the stable identifier across processes/runs, suitable for grouping telemetry rows by kernel during analysis. """ if self._kernel_source is None: self._kernel_source = inspect.getsource(self.fn) return self._kernel_source
def _validate_structural_policy(self) -> None: # Legacy kernels retain their existing mutable Settings behavior. An # explicitly policy-bound kernel cannot silently change its IR contract. if self.cute_structural_policy is not None: policy_type = get_backend(self.settings.backend).structural_policy_type() if policy_type is None: raise StructuralPolicyError( "A policy-bound Kernel requires a backend with a structural " f"policy (the {self.settings.backend} backend has none)" ) require_same_structural_policy( policy_type.from_settings(self.settings), self.cute_structural_policy, context="Kernel settings", ) @property def cute_structural_policy(self) -> CuteStructuralPolicy | None: """The explicitly selected policy, or None for the unchanged legacy API.""" return self._cute_structural_policy def _get_bound_kernel_cache_key( self, args: tuple[object, ...], signature: tuple[Hashable, ...] ) -> BoundKernelInMemoryCacheKey | None: from ..autotuner.base_cache import BoundKernelInMemoryCacheKey extra_results = self._stable_specialization_extra_results(args, signature) if extra_results is None: return None compiler_seed_results = self._stable_compiler_seed_results(args, signature) if compiler_seed_results is None: return None return BoundKernelInMemoryCacheKey( signature, extra_results, compiler_seed_results=compiler_seed_results, ) def _stable_specialization_extra_results( self, args: Sequence[object], signature: tuple[Hashable, ...], ) -> tuple[Hashable, ...] | None: # Evaluate outside the schema lock because some backend extractors may # synchronize device data. Schema changes are finite and retry here. while True: with self._specialize_extra_lock: extra_fns = self._specialize_extra.get(signature) generation = self._specialization_generation extra_results = ( None if extra_fns is None else tuple(fn(args) for fn in extra_fns) ) with self._specialize_extra_lock: if ( self._specialization_generation == generation and self._specialize_extra.get(signature) is extra_fns ): return extra_results def _stable_compiler_seed_results( self, args: Sequence[object], signature: tuple[Hashable, ...], ) -> tuple[Hashable, ...] | None: while True: with self._specialize_extra_lock: extractors = self._compiler_seed_specialize_extra.get(signature) generation = self._specialization_generation results = ( None if extractors is None else tuple(extractor(args) for extractor in extractors) ) with self._specialize_extra_lock: if ( self._specialization_generation == generation and self._compiler_seed_specialize_extra.get(signature) is extractors ): return results def _create_bound_kernel_cache_key( self, bound_kernel: BoundKernel, args: tuple[object, ...], signature: tuple[Hashable, ...], *, extra_fns: list[Callable[[Sequence[object]], Hashable]] | None = None, snapshot_runtime_results: bool = False, ) -> BoundKernelInMemoryCacheKey: from ..autotuner.base_cache import BoundKernelInMemoryCacheKey if extra_fns is None: extra_fns = bound_kernel._specialize_extra() compiler_seed_fns = bound_kernel._compiler_seed_specialization_extractors if not bound_kernel._cache_managed: extra_results = tuple(s(args) for s in extra_fns) compiler_seed_results = tuple(s(args) for s in compiler_seed_fns) return BoundKernelInMemoryCacheKey( signature, extra_results, compiler_seed_results=compiler_seed_results, ) # Autotune cache keys can be generated outside eager binding, so # schema synchronization must not wait for the eager bind lock or hold # the schema lock while backend extractors run. while True: with self._specialize_extra_lock: if bound_kernel._reset_generation != self._reset_generation: active_extra_fns = extra_fns active_compiler_seed_fns = compiler_seed_fns generation = None else: published_extra_fns = self._specialize_extra.get(signature) if published_extra_fns is None: # Fills an unpublished slot, which no memoized fast # dispatch schema references, without a generation bump. self._specialize_extra[signature] = extra_fns self._compiler_seed_specialize_extra[signature] = ( compiler_seed_fns ) if extra_fns: self._has_specialization_extras = True else: # Late specialization can extend this schema after the # bound is constructed. The published list is the # authoritative schema. extra_fns = published_extra_fns active_compiler_seed_fns = self._compiler_seed_specialize_extra.get( signature ) if active_compiler_seed_fns is None: active_compiler_seed_fns = compiler_seed_fns self._compiler_seed_specialize_extra[signature] = ( active_compiler_seed_fns ) generation = self._specialization_generation active_extra_fns = extra_fns extra_results = tuple(s(args) for s in active_extra_fns) compiler_seed_results = tuple(s(args) for s in active_compiler_seed_fns) cache_key = BoundKernelInMemoryCacheKey( signature, extra_results, compiler_seed_results=compiler_seed_results, ) if generation is None: return cache_key with self._specialize_extra_lock: if bound_kernel._reset_generation != self._reset_generation: return cache_key if ( self._specialization_generation == generation and self._specialize_extra.get(signature) is active_extra_fns and self._compiler_seed_specialize_extra.get(signature) is active_compiler_seed_fns ): if snapshot_runtime_results: bound_kernel._record_runtime_input_specialization_results( active_extra_fns, extra_results, ) return cache_key def _extend_bound_kernel_specializations( self, bound_kernel: BoundKernel, signature: tuple[Hashable, ...], extractors: list[Callable[[Sequence[object]], Hashable]], args: Sequence[object], ) -> bool: if not extractors: return False from ..autotuner.base_cache import BoundKernelInMemoryCacheKey with self._bind_lock: if bound_kernel._reset_generation != self._reset_generation: return False full_args = tuple(args) aliases = { alias_signature: alias for alias_signature, alias in self._specialization_aliases.items() if alias.canonical_signature == signature } # Keep cache-key generation from snapshotting the old schema while # this extension is being validated and published. with self._specialize_extra_lock: updated_extractors = [ *self._specialize_extra.get(signature, []), *extractors, ] compiler_seed_fns = self._compiler_seed_specialize_extra.get( signature, (), ) with unset_fake_temporarily(): current_results = tuple( extractor(full_args) for extractor in updated_extractors ) current_compiler_seed_results = tuple( extractor(full_args) for extractor in compiler_seed_fns ) updated_cache_key = BoundKernelInMemoryCacheKey( signature, current_results, compiler_seed_results=current_compiler_seed_results, ) hash(updated_cache_key) for alias_signature, alias in aliases.items(): alias_arg_count = len(full_args) - len(alias.trailing_defaults) alias_args = full_args[:alias_arg_count] alias_normalized_args = ( *alias_args, *alias.trailing_defaults, ) alias_results = tuple( extractor(alias_normalized_args) for extractor in updated_extractors ) alias_compiler_seed_results = tuple( extractor(alias_normalized_args) for extractor in compiler_seed_fns ) hash( BoundKernelInMemoryCacheKey( alias_signature, (alias_results,), compiler_seed_results=alias_compiler_seed_results, ) ) # Publish the extended specialization only after every extractor # succeeds. Otherwise a failed extension could leave a prepared # call guarding an already-mutated extractor list. Replacing # published lists requires the generation bump below. self._has_specialization_extras = True self._specialize_extra[signature] = updated_extractors for alias_signature, alias in aliases.items(): self._specialize_extra[alias_signature] = [alias] self._compiler_seed_specialize_extra[alias_signature] = ( compiler_seed_fns ) self._specialization_generation += 1 # Every memoized fast dispatch schema is stale now. self._fast_dispatch_schemas = {} affected_signatures = {signature, *aliases} stale_keys = [ key for key in self._bound_kernels if key.specialization_key in affected_signatures ] for stale_key in stale_keys: self._bound_kernels.pop(stale_key) for fast_key, cached_bound in list(self._dispatch_cache.items()): if cached_bound._base_spec_key == signature: self._dispatch_cache.pop(fast_key) self._prepared_call = None if bound_kernel._record_runtime_input_specialization_results( updated_extractors, current_results, ): self._bound_kernels[updated_cache_key] = bound_kernel else: # A BoundKernel's generated programs may already consume its # construction-time storage facts. If those facts changed # while a late specialization was discovered, do not migrate # the existing programs to the new cache identity. Retire the # bound through the same generation check used by reset(); a # later call will bind and compile against the extended schema. bound_kernel._reset_generation = self._reset_generation - 1 bound_kernel._direct_prepared_call = None bound_kernel._run = None bound_kernel._config = None bound_kernel._compile_cache.clear() bound_kernel._cache_path_map.clear() return True def _publish_guard_facts( self, bound_kernel: BoundKernel, facts: Sequence[PreparedMetadataSpecializationExtractor], ) -> None: """Append guard facts to a signature's dispatch schema. A guard fact evaluates at any cached bound's bind-time hints, so every bound of the signature (the publishing one included) is re-keyed in place rather than evicted, and no runtime arguments are needed: code generation can publish too. """ from ..autotuner.base_cache import BoundKernelInMemoryCacheKey signature = bound_kernel._base_spec_key with self._bind_lock, self._specialize_extra_lock: if bound_kernel._reset_generation != self._reset_generation: return published = self._specialize_extra.get(signature, []) new_facts = [fact for fact in facts if fact not in published] if not new_facts: return aliases = { alias_signature: alias for alias_signature, alias in self._specialization_aliases.items() if alias.canonical_signature == signature } for key, bound in list(self._bound_kernels.items()): if key.specialization_key not in (signature, *aliases): continue del self._bound_kernels[key] # Alias keys pack the canonical results; let them rebind. if ( key.specialization_key == signature and (results := _bound_guarded_input_facts(bound, new_facts)) is not None ): self._bound_kernels[ BoundKernelInMemoryCacheKey( signature, (*key.extra_results, *results), compiler_seed_results=key.compiler_seed_results, ) ] = bound # Replacing a published list requires the generation bump below. self._specialize_extra[signature] = [*published, *new_facts] for alias_signature, alias in aliases.items(): # An alias published while the canonical schema was empty # keys no canonical extractor; let it key the new facts. if alias_signature in self._specialize_extra: self._specialize_extra[alias_signature] = [alias] self._has_specialization_extras = True self._specialization_generation += 1 self._fast_dispatch_schemas = {} for fast_key, cached_bound in list(self._dispatch_cache.items()): if cached_bound._base_spec_key == signature: del self._dispatch_cache[fast_key] self._prepared_call = None def _compute_is_distributed( self, args: Sequence[object], *, dist_initialized: bool | None = None, ) -> bool: """Whether this call should compile as a distributed kernel. True when the arguments carry symmetric-memory tensors, or the author declared distributed intent (``settings.distributed`` or an ``hl.ProcessGroupName`` argument) inside an initialized process group. This is folded into the specialization key so a symmetric-memory call and an identically-shaped ordinary call never share a compiled kernel. See GitHub issue #3024. """ if dist_initialized is None: dist_initialized = dist.is_initialized() return kernel_uses_symm_mem(tuple(args), dist_initialized=dist_initialized) or ( dist_initialized and (self.settings.distributed or self._declares_process_group) ) def _fast_dispatch_key( self, args: tuple[object, ...], *, is_distributed: bool | None = None, signature: tuple[Hashable, ...] | None = None, _extra_guards: list[tuple[Callable[[Sequence[object]], Hashable], Hashable]] | None = None, ) -> tuple[Hashable, ...] | None: """Build a specialization-safe eager key, optionally collecting guards. Without ``signature``, a kernel with specialization extras reads them through the schema memoized for the key's prefix (``_memoized_fast_dispatch_key``); the key's value is the same. """ key: list[Hashable] = [] has_tensor = False # Top-level tensors, for the alias partition below; a container # argument defers to the recursive ``_input_tensor_aliases`` walk. tensors: list[torch.Tensor] = [] has_container = False # A constexpr-annotated tensor keys the base signature by identity, # which its metadata in the prefix does not pin, and an index-less # accelerator device resolves against the current device. memoizable = True for index, a in enumerate(args): t = type(a) annotation = self._annotations[index] if index < self._num_params else None if t is torch.Tensor or t is torch.nn.Parameter: tensor = cast("torch.Tensor", a) has_tensor = True tensors.append(tensor) if annotation is ConstExpr: memoizable = False si = getattr(tensor, "_dynamo_static_indices", None) key.append( ( tensor.dtype, tensor.shape, tensor.stride(), tensor.device, None if si is None else frozenset(si), ) ) elif t is int or t is float or t is bool: key.append((t, a) if annotation is ConstExpr else t) elif t is str: key.append((t, a)) elif isinstance(a, ConstExpr): try: hash(a.value) except TypeError: return None key.append((t, a.value)) elif a is None: key.append(None) elif t is torch.dtype: key.append(a) elif t is torch.device: key.append(a) device = cast("torch.device", a) # (Even behind a tensor, which ``_find_device`` would find # first: rare enough to stay conservative.) if device.index is None and device.type not in ("cpu", "meta", "mps"): memoizable = False elif ( t is tuple or t is list or t is dict or t is types.FunctionType or t is types.BuiltinFunctionType ): nested = _nested_fast_dispatch_key( a, specialize_numeric=annotation is ConstExpr, ) if nested is None: return None nested_key, nested_has_tensor = nested key.append(nested_key) has_tensor = has_tensor or nested_has_tensor # Callables are flat to the alias walk and the device lookup. if t is tuple or t is list or t is dict: has_container = True else: return None if not has_tensor: return None if is_distributed is None: is_distributed = self._compute_is_distributed(args) key.append(is_distributed) custom_key: object = _CUSTOM_KEY_UNSET if self._key_fn is not None: # User key functions execute exactly once per launch. custom_key = self._key_fn(*args) if signature is None else signature[-1] key.append(custom_key) # ``_input_tensor_aliases(args)`` without re-walking flat arguments. tensor_aliases = ( _input_tensor_aliases(args) if has_container else _tensor_aliases(tensors) ) if tensor_aliases is not None: key.append(("input_tensor_aliases", tensor_aliases)) prefix = tuple(key) if signature is None: if not self._has_specialization_extras: return prefix # Dynamo would guard on the memo's contents and replay its writes. if memoizable and not torch.compiler.is_compiling(): return self._memoized_fast_dispatch_key( prefix, args, is_distributed, custom_key, _extra_guards, has_container, ) signature = self._base_specialization_key( args, is_distributed=is_distributed, custom_key=custom_key ) extra_fns = self._specialize_extra.get(signature) if not extra_fns: return prefix extra_results: list[Hashable] = [] for extractor in extra_fns: result = extractor(args) extra_results.append(result) if _extra_guards is not None: _extra_guards.append((extractor, result)) return (*prefix, tuple(extra_results)) def _memoized_fast_dispatch_key( self, prefix: tuple[Hashable, ...], args: tuple[object, ...], is_distributed: bool, custom_key: object, extra_guards: list[tuple[Callable[[Sequence[object]], Hashable], Hashable]] | None, has_container: bool, ) -> tuple[Hashable, ...]: """``_fast_dispatch_key``'s value from the schema memoized for ``prefix``. The base signature is a function of the prefix and the kernel settings it reads: every per-argument component of ``_base_specialization_key`` derives from the exact metadata/values the prefix records, and the alias partition, custom key and ``is_distributed`` are in it. Its device component comes from the first device-bearing argument, which the prefix pins for flat arguments, except an index-less accelerator ``torch.device`` resolving against the *current* device (such calls never get here). The prefix sorts dict entries, so with container arguments a miss memoizes only when every reachable device is one indexed device (``_argument_device_is_fixed``). Nor is a signature without a published schema memoized: its first publication does not bump the generation. A hit skips ``_base_specialization_key`` and every extractor exact metadata implies. """ settings = self.settings # The settings ``_base_specialization_key`` reads. base_key_settings = ( settings.static_shapes, settings.index_dtype, settings.backend, settings.disable_autotuner_heuristics, ) schema = self._fast_dispatch_schemas.get(prefix) if ( schema is not None and schema.generation == self._specialization_generation and schema.settings == base_key_settings ): if (key := schema.full_key) is not None: extras = schema.results else: results = list(schema.results) for index, extractor in schema.per_call: results[index] = extractor(args) extras = tuple(results) key = (*prefix, extras) if extra_guards is not None: extra_guards.extend(zip(schema.extra_fns, extras, strict=True)) return key # Read before the schema: an extension publishes its list before # bumping the generation, so a racing resolve can only memoize an # entry that is already stale. generation = self._specialization_generation signature = self._base_specialization_key( args, is_distributed=is_distributed, custom_key=custom_key ) extra_fns = self._specialize_extra.get(signature) if extra_fns is None: return prefix if has_container and not _argument_device_is_fixed(args): # The signature is not a function of the prefix: evaluate the # schema as for an explicit signature. extras = tuple(extractor(args) for extractor in extra_fns) if extra_guards is not None: extra_guards.extend(zip(extra_fns, extras, strict=True)) return (*prefix, extras) if extra_fns else prefix extras, per_call = _evaluate_fast_dispatch_extras(extra_fns, args) if extra_guards is not None: extra_guards.extend(zip(extra_fns, extras, strict=True)) schema = _FastDispatchSchema( generation, base_key_settings, extra_fns, extras, per_call, prefix ) self._fast_dispatch_schemas[prefix] = schema if schema.full_key is not None: return schema.full_key return (*prefix, extras) def _fast_dispatch_key_and_guards( self, args: tuple[object, ...], *, is_distributed: bool | None = None, signature: tuple[Hashable, ...] | None = None, ) -> _FastDispatchEntry | None: """ Build a cheap dispatch key for the fast-path cache in ``__call__``. The key records exact per-argument metadata (dtype/shape/stride/device for tensors), exact values for specialized arguments, and types for ordinary runtime numeric values. It refines the full specialization key: any two argument lists that produce different full keys also produce different fast keys. That makes it safe to map a fast key directly to the BoundKernel that a full ``bind()`` resolved for the same arguments. If a base signature has extra specialization extractors, their results are appended to preserve the same no-collision invariant for value-based specializations. Returns None when an argument type is not handled (for example, tensor subclasses or unsupported objects nested in a container), or when there is no tensor argument to pin down the device; callers must then take the regular ``bind()`` path. """ specialization_generation = self._specialization_generation extra_guards: list[tuple[Callable[[Sequence[object]], Hashable], Hashable]] = [] key = self._fast_dispatch_key( args, is_distributed=is_distributed, signature=signature, _extra_guards=extra_guards, ) if key is None: return None return _FastDispatchEntry( key, tuple(extra_guards), specialization_generation, ) def _prepare_dispatch_entry( self, args: tuple[object, ...], bound: BoundKernel[_R], fast_entry: _FastDispatchEntry, ) -> tuple[_PreparedCall | None, bool] | None: """Validate and construct eager fast paths from one runtime snapshot.""" try: if fast_entry.specialization_generation != self._specialization_generation: return None if bound._reset_generation != self._reset_generation: return None dist_initialized = dist.is_initialized() is_distributed = self._compute_is_distributed( args, dist_initialized=dist_initialized ) if fast_entry.key[len(args)] != is_distributed: return None if self._key_fn is None: raw_signature = self._base_specialization_key( args, is_distributed=is_distributed ) else: # Reuse the custom key captured by the dispatch lookup; user # key functions must execute exactly once per launch. raw_signature = self._base_specialization_key( args, is_distributed=is_distributed, custom_key=fast_entry.key[len(args) + 1], ) alias = self._specialization_aliases.get(raw_signature) signature = raw_signature if alias is None else alias.canonical_signature if signature != bound._base_spec_key: return None if self._key_fn is None: prepared = _PreparedCall.build( self, bound, args, dist_initialized=dist_initialized, extra_guards=fast_entry.extra_guards, is_distributed=is_distributed, ) keyed_direct_dispatch = False else: prepared = None keyed_direct_dispatch = ( not self.settings.distributed and not self._declares_process_group and not bound._env._is_distributed ) # Process-group initialization is external to ``_bind_lock``. Do # not publish a key assembled across a state transition. if dist.is_initialized() != dist_initialized: return None return prepared, keyed_direct_dispatch except Exception: # Fast-path publication is optional and must not add a failure # after the compiled kernel has already run successfully. return None
[docs] def bind(self, args: tuple[object, ...]) -> BoundKernel[_R]: """ Bind the given arguments to the Kernel and return a BoundKernel object. Args: args: The arguments to bind to the Kernel. Returns: BoundKernel: A BoundKernel object with the given arguments bound. """ # Dynamo executes bind while capturing the call but cannot trace an RLock. # Capture only needs an independent host function and compile environment. if torch.compiler.is_compiling(): return self._bind_isolated(args) with self._bind_lock: return self._bind(args)
def _validate_bind_args( self, args: tuple[object, ...] | list[object] ) -> tuple[object, ...]: if not isinstance(args, tuple): assert isinstance(args, list), "args must be a tuple or list" args = tuple(args) if len(args) > self._num_params: raise TypeError( f"Too many arguments passed to the kernel, expected: {self._num_params} got: {len(args)}." ) return args def _bind_isolated( self, args: tuple[object, ...], *, rewrite_ast: bool = True ) -> BoundKernel[_R]: """Construct a canonical bound without reading or publishing shared caches. ``rewrite_ast=False`` traces the kernel as written, without the backend's AST customizations. """ self._validate_structural_policy() args = self._validate_bind_args(args) args = self.normalize_args(*args) dist_initialized = dist.is_initialized() is_distributed = self._compute_is_distributed( args, dist_initialized=dist_initialized ) signature = self._base_specialization_key(args, is_distributed=is_distributed) return BoundKernel( self, args, base_spec_key=signature, is_distributed=is_distributed, cache_managed=False, rewrite_ast=rewrite_ast, ) def _bind(self, args: tuple[object, ...]) -> BoundKernel[_R]: self._validate_structural_policy() with measure("Kernel.bind"): args = self._validate_bind_args(args) dist_initialized = dist.is_initialized() is_distributed = self._compute_is_distributed( args, dist_initialized=dist_initialized ) signature = self._base_specialization_key( args, is_distributed=is_distributed ) cache_key = self._get_bound_kernel_cache_key(args, signature) bound_kernel = ( None if cache_key is None else self._bound_kernels.get(cache_key, None) ) created = False if bound_kernel is None: normalized_args: tuple[object, ...] = self.normalize_args(*args) extra_fns: list[Callable[[Sequence[object]], Hashable]] | None = None if len(normalized_args) != len(args): # we had default args that needed to be applied bound_kernel = self._bind(normalized_args) canonical_signature = bound_kernel._base_spec_key alias = self._specialization_aliases.get(signature) if alias is None: trailing_defaults = normalized_args[len(args) :] alias = _SpecializationAlias( self._specialize_extra, canonical_signature, trailing_defaults, ) self._specialization_aliases[signature] = alias extra_fns = ( [alias] if self._specialize_extra.get(canonical_signature) else [] ) else: bound_kernel = BoundKernel( self, args, base_spec_key=signature, is_distributed=is_distributed, ) created = True if cache_key is None: cache_key = self._create_bound_kernel_cache_key( bound_kernel, args, signature, extra_fns=extra_fns, snapshot_runtime_results=( signature == bound_kernel._base_spec_key ), ) elif signature == bound_kernel._base_spec_key: published_extra_fns = self._specialize_extra.get(signature) if published_extra_fns is not None: bound_kernel._record_runtime_input_specialization_results( published_extra_fns, cache_key.extra_results, ) self._bound_kernels[cache_key] = bound_kernel if created: # Key the input-shape guards this compile recorded. bound_kernel._publish_guarded_input_facts() return bound_kernel def _base_specialization_key( self, args: Sequence[object], *, is_distributed: bool | None = None, custom_key: object = _CUSTOM_KEY_UNSET, ) -> tuple[Hashable, ...]: """ Generate the base specialization key from input argument metadata only, using the per-type extractor functions defined in `_specialization_extractors`, without any extras discovered during compilation. Used internally for _specialize_extra lookups. """ result: list[Hashable] = [] if self.cute_structural_policy is not None: result.append( ("cute_structural_policy", self.cute_structural_policy.identity()) ) assert len(args) <= len(self._annotations) for value, annotation in zip(args, self._annotations, strict=False): if isinstance(value, ConstExpr): result.append(value.value) elif annotation is ConstExpr: result.append(value) else: result.append(self._specialization_key(value)) if (tensor_aliases := _input_tensor_aliases(args)) is not None: result.append(("input_tensor_aliases", tensor_aliases)) device_type, device_capability, promotion_hardware_key = ( _device_specialization_key( args, backend=self.settings.backend, compiler_heuristics_enabled=( not self.settings.disable_autotuner_heuristics ), ) ) promotion_key = (promotion_hardware_key,) if promotion_hardware_key else () if is_distributed is None: is_distributed = self._compute_is_distributed(args) if self._key_fn is not None: if custom_key is _CUSTOM_KEY_UNSET: custom_key = self._key_fn(*args) return ( *result, device_type, device_capability, *promotion_key, is_distributed, cast("Hashable", custom_key), ) return ( *result, device_type, device_capability, *promotion_key, is_distributed, )
[docs] def specialization_key(self, args: Sequence[object]) -> tuple[Hashable, ...]: """ Generate the full specialization key for the given arguments, including any additional specialization constraints discovered during compilation (e.g. from hl.specialize() calls). Before the first compilation, these extras are not yet known and the key may be incomplete. Args: args: The arguments to generate a specialization key for. Returns: Hashable: A hashable key representing the specialization of the arguments. """ base = self._base_specialization_key(args) extra_results = self._stable_specialization_extra_results(args, base) compiler_seed_results = self._stable_compiler_seed_results(args, base) compiler_seed_key = ( () if compiler_seed_results is None or not compiler_seed_results else (("compiler_seed_results", compiler_seed_results),) ) return (*base, *compiler_seed_key, *(extra_results or ()))
def _specialization_key(self, obj: object) -> Hashable: """ Helper used to generate a specialization key for the given object. This method determines a unique key for the object based on its type and the corresponding extractor function defined in `_specialization_extractors`. Args: obj: The argument to generate a specialization key for. Returns: Hashable: A hashable key representing the specialization of the object. """ extractor = _specialization_extractors.get(type(obj)) if extractor is None: if isinstance(obj, torch.fx.GraphModule): # GraphModule subclasses need special handling extractor = _specialization_extractors[torch.fx.GraphModule] elif isinstance(obj, torch.Tensor): # torch.Tensor subclasses (e.g. the JAX-export adapter) # share the standard tensor specialization key. Use the # SymInt-safe extractor: unlike exact ``torch.Tensor``, # subclasses may carry symbolic sizes/strides. extractor = _specialization_extractors["tensor_subclass"] elif isinstance(obj, tuple) and hasattr(obj, "_fields"): # this is a namedtuple extractor = _specialization_extractors["namedtuple"] elif dataclasses.is_dataclass(obj): extractor = _specialization_extractors["dataclass"] else: raise TypeError(f"unsupported argument type: {type(obj).__name__}") return extractor(self, obj)
[docs] def normalize_args(self, *args: object, **kwargs: object) -> tuple[object, ...]: """ Normalize the given arguments and keyword arguments according to the function signature. Args: args: The positional arguments to normalize. kwargs: The keyword arguments to normalize. Returns: tuple[object, ...]: A tuple of normalized positional arguments. """ key = (len(args), tuple(kwargs)) plans = self._normalize_args_plans if torch.compiler.is_compiling(): # Dynamo would guard on the plan cache's contents and replay the # write, recompiling once per call shape; trace inspect instead. plan = None elif key in plans: plan = plans[key] else: plan = plans[key] = self._plan_normalize_args(*key) if plan is None: # Calls that ``inspect`` must reject (or that need its binding # rules) raise its errors. bound_args = self.signature.bind(*args, **kwargs) bound_args.apply_defaults() return tuple(bound_args.args) defaults, positions = plan normalized = [*args, *defaults[len(args) :]] for position, value in zip(positions, kwargs.values(), strict=True): normalized[position] = value return tuple(normalized)
def _plan_normalize_args( self, positional_count: int, keyword_names: tuple[str, ...] ) -> tuple[tuple[object, ...], tuple[int, ...]] | None: """Defaults template and keyword slots for one call shape, if bindable. ``inspect.Signature.bind`` costs ~10us per call; a call shape (count of positional arguments plus the keyword names) that binds cleanly to a positional-or-keyword-only signature can be replayed from this plan. """ parameters = list(self.signature.parameters.values()) if positional_count > len(parameters) or any( parameter.kind is not inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in parameters ): return None names = [parameter.name for parameter in parameters] positions: list[int] = [] for name in keyword_names: if name not in names or names.index(name) < positional_count: return None positions.append(names.index(name)) defaults = tuple(parameter.default for parameter in parameters) if any( default is inspect.Parameter.empty for index, default in enumerate(defaults) if index >= positional_count and index not in positions ): return None return defaults, tuple(positions)
[docs] def autotune( self, args: Sequence[object], *, force: bool = True, **options: object, ) -> Config: """ Perform autotuning to find the optimal configuration for the kernel. This uses the default setting, you can call helion.autotune.* directly for more customization. If config= or configs= is provided to helion.kernel(), the search will be restricted to the provided configs. Use force=True to ignore the provided configs. Mutates (the bound version of) self so that `__call__` will run the best config found. Args: args: Example arguments used for benchmarking during autotuning. force: If True, force full autotuning even if a config is provided. options: Additional keyword options forwarded to the autotuner. Returns: Config: The best configuration found during autotuning. """ args = self.normalize_args(*args) return self.bind(args).autotune(args, force=force, **options)
[docs] def autotune_multi( self, arg_sets: Sequence[Sequence[object]], *, aggregation: Literal["geomean", "max"] = "geomean", relative_to: Literal["default", "baseline"] | None = None, cache_tag: str | None = None, force: bool = True, **options: object, ) -> Config: """Find one config using an objective measured across several inputs. The first argument set anchors config generation. Each candidate is measured on every set, then reduced with a geometric mean or maximum. ``relative_to`` optionally optimizes per-shape latency relative to each shape's default config or custom baseline. Only the supplied bound specializations are configured. Every argument set must bind normally to the same exact, current accelerator device. Distributed processes are not supported. A non-empty ``cache_tag`` is required for custom callbacks, dynamic-shape tuning, and runtime numeric arguments; callers own tag invalidation in those cases. Structural differences in per-operation fields are allowed when a scalar override broadcasts across every operation. Args: arg_sets: Non-empty sequence of representative kernel argument sequences. aggregation: Joint objective, either ``"geomean"`` or ``"max"``. relative_to: Optional ``"default"`` or ``"baseline"`` normalization. cache_tag: User-managed cache discriminator for dynamic shapes, runtime numeric arguments, or callbacks. force: If true, ignore pinned configs and cache reads during the search. options: Additional options forwarded to the registered autotuner. Returns: The config selected for all supplied specializations. """ from ..autotuner.benchmark_provider import LocalBenchmarkProvider from ..autotuner.benchmark_provider import _has_valid_multi_shape_measurement from ..autotuner.benchmark_provider import _materialize_multi_shape_config from ..autotuner.benchmark_provider import _MultiShapeAutotuneArgs from .settings import default_autotuner_fn if aggregation not in ("geomean", "max"): raise exc.InvalidAPIUsage( "autotune_multi aggregation must be 'geomean' or 'max'" ) if relative_to not in (None, "default", "baseline"): raise exc.InvalidAPIUsage( "autotune_multi relative_to must be None, 'default', or 'baseline'" ) if cache_tag is not None and (not isinstance(cache_tag, str) or not cache_tag): raise exc.InvalidAPIUsage( "autotune_multi cache_tag must be a non-empty string" ) if ( not isinstance(arg_sets, Sequence) or isinstance(arg_sets, (str, bytes)) or not arg_sets ): raise exc.InvalidAPIUsage( "autotune_multi arg_sets must be a non-empty sequence" ) if dist.is_initialized(): raise exc.InvalidAPIUsage( "autotune_multi does not support an initialized distributed process group" ) if not get_backend(self.settings.backend).supports_autotune_multi: raise exc.InvalidAPIUsage( f"autotune_multi does not support backend {self.settings.backend!r}" ) if self.settings.autotuner_fn is not default_autotuner_fn: raise exc.InvalidAPIUsage( "autotune_multi requires the default registered autotuner" ) if self.settings.autotune_cache == "AOTAutotuneCache": raise exc.InvalidAPIUsage( "autotune_multi does not support AOTAutotuneCache" ) if self.settings.autotune_cache not in { "LocalAutotuneCache", "StrictLocalAutotuneCache", "RemoteAutotuneCache", "StrictRemoteAutotuneCache", }: raise exc.InvalidAPIUsage( "autotune_multi requires a built-in local or remote best-config cache" ) if "benchmark_provider_cls" in options: if options.pop("benchmark_provider_cls") is not LocalBenchmarkProvider: raise exc.InvalidAPIUsage( "autotune_multi does not support a custom benchmark provider" ) if relative_to == "baseline" and self.settings.autotune_baseline_fn is None: raise exc.InvalidAPIUsage( "autotune_multi relative_to='baseline' requires autotune_baseline_fn" ) if self.settings.autotune_benchmark_fn is not None: raise exc.InvalidAPIUsage( "autotune_multi does not support autotune_benchmark_fn" ) custom_callbacks = ( self.settings.autotune_baseline_fn, self.settings.autotune_baseline_accuracy_check_fn, self.settings.autotune_config_filter, ) if cache_tag is None and any(fn is not None for fn in custom_callbacks): raise exc.InvalidAPIUsage( "autotune_multi requires cache_tag when a custom baseline, " "accuracy check, or config filter is configured" ) if cache_tag is None and not self.settings.static_shapes: raise exc.InvalidAPIUsage( "autotune_multi requires cache_tag when static_shapes=False" ) normalized_arg_sets: list[tuple[object, ...]] = [] for case_index, arg_set in enumerate(arg_sets): if not isinstance(arg_set, Sequence) or isinstance(arg_set, (str, bytes)): raise exc.InvalidAPIUsage( f"autotune_multi arg_sets[{case_index}] must be a sequence" ) try: normalized_arg_sets.append(self.normalize_args(*arg_set)) except TypeError as error: raise exc.InvalidAPIUsage( f"autotune_multi arg_sets[{case_index}] does not match the " f"kernel signature: {error}" ) from error if cache_tag is None: for case_index, normalized_args in enumerate(normalized_arg_sets): if any( annotation is not ConstExpr and _has_unspecialized_numeric_value(value) for value, annotation in zip( normalized_args, self._annotations, strict=True ) ): raise exc.InvalidAPIUsage( "autotune_multi requires cache_tag when an argument set " "contains a runtime numeric value; " f"arg_sets[{case_index}] does" ) cases: list[tuple[BoundKernel[_R], tuple[object, ...]]] = [] case_keys: list[BoundKernelInMemoryCacheKey] = [] for case_index, normalized_args in enumerate(normalized_arg_sets): try: bound_kernel = self.bind(normalized_args) except exc.NoTensorArgs as error: raise exc.InvalidAPIUsage( "autotune_multi requires each argument set to have a device " f"discoverable by normal kernel binding; arg_sets[{case_index}] did not" ) from error signature = self._base_specialization_key(normalized_args) case_key = self._get_bound_kernel_cache_key(normalized_args, signature) assert case_key is not None cases.append((bound_kernel, normalized_args)) case_keys.append(case_key) anchor = cases[0][0] canonical_device = _canonicalize_multi_shape_device(anchor.env.device) current_index = _current_device_index(canonical_device.type) if canonical_device.index != current_index: raise exc.InvalidAPIUsage( "autotune_multi requires the indexed accelerator to be current: " f"got {canonical_device}, current index is {current_index}" ) unique_bound_kernels: list[BoundKernel[_R]] = [] seen_bound_kernel_ids: set[int] = set() for bound_kernel, _ in cases: if id(bound_kernel) not in seen_bound_kernel_ids: seen_bound_kernel_ids.add(id(bound_kernel)) unique_bound_kernels.append(bound_kernel) anchor_backend = anchor.env.backend.name anchor_capability = target_device_capability(anchor.env.device) advanced_controls_files = self.settings.autotune_search_acf or None broadcastable_per_operation_fields = frozenset( { "indexing", "atomic_indexing", "load_eviction_policies", } ) scalar_overridden_config_keys = frozenset( key for key, value in (self.settings.autotune_config_overrides or {}).items() if key in broadcastable_per_operation_fields and isinstance(value, str) ) def effective_fingerprint( bound_kernel: BoundKernel[_R], ) -> tuple[tuple[str | int, ...], ...]: return tuple( field for field in bound_kernel.config_spec.structural_fingerprint( advanced_controls_files=advanced_controls_files ) if field[0] not in scalar_overridden_config_keys ) anchor_fingerprint = effective_fingerprint(anchor) for case_index, (bound_kernel, _) in enumerate(cases): if bound_kernel.env.process_group_name is not None: raise exc.InvalidAPIUsage( "autotune_multi does not support a bound kernel with a process group" ) bound_device = _canonicalize_multi_shape_device(bound_kernel.env.device) if bound_device != canonical_device: raise exc.InvalidAPIUsage( "autotune_multi bound a different device for " f"arg_sets[{case_index}]: {bound_device} != {canonical_device}" ) if bound_kernel.env.backend.name != anchor_backend: raise exc.InvalidAPIUsage( "autotune_multi requires every case to use the same backend" ) if target_device_capability(bound_kernel.env.device) != anchor_capability: raise exc.InvalidAPIUsage( "autotune_multi requires every case to have the same device capability" ) fingerprint = effective_fingerprint(bound_kernel) if fingerprint != anchor_fingerprint: raise exc.InvalidAPIUsage( "autotune_multi requires structurally compatible ConfigSpec " f"instances; arg_sets[{case_index}] is incompatible with the anchor" ) multi_args = _MultiShapeAutotuneArgs( cases=tuple(cases), aggregation=aggregation, relative_to=relative_to, cache_tag=cache_tag, workload_key=( "multi_shape:v1", aggregation, relative_to, cache_tag, tuple( ( case_key.specialization_key, case_key.extra_results, case_key.compiler_seed_results, ) for case_key in case_keys ), ), reference_latencies=None, ) ephemeral = anchor.env.backend.make_ephemeral_cache() ctx = ephemeral if ephemeral is not None else contextlib.nullcontext() with ctx: config = anchor.env.backend.autotune( anchor, cast("Sequence[object]", multi_args), force=force, **options, ) if multi_args.search_started and not multi_args.found_valid_config: raise exc.NoConfigFound if multi_args.search_started and not _has_valid_multi_shape_measurement( multi_args, anchor.config_spec, config, ): raise exc.NoConfigFound winner = _materialize_multi_shape_config(anchor.config_spec, config) if ephemeral is not None: for bound_kernel in unique_bound_kernels: bound_kernel.env.backend.finalize_ephemeral_cache(bound_kernel, winner) for bound_kernel in unique_bound_kernels: bound_kernel.compile_config(winner) for bound_kernel in unique_bound_kernels: bound_kernel.set_config(winner) return winner
[docs] def __call__(self, *args: object, **kwargs: object) -> _R: """ Call the Kernel with the given arguments and keyword arguments. Args: args: The positional arguments to pass to the Kernel. kwargs: The keyword arguments to pass to the Kernel. Returns: _R: The result of the Kernel function call. """ self._validate_structural_policy() if kwargs: args = self.normalize_args(*args, **kwargs) is_compiling = torch.compiler.is_compiling() if ( not is_compiling and (prepared := self._prepared_call) is not None and prepared.matches(self, args) and prepared.bound._run is not None ): return prepared.bound._run(*args) # Runtime specialization extractors can inspect pointers/storage offsets. # Capture may reuse metadata-only entries; legacy Dynamo integration # needs their compiled launchers and cannot trace isolated binding. if self._dispatch_cache and ( not is_compiling or all( _capture_safe_specialization(extractor) for extractors in self._specialize_extra.values() for extractor in extractors ) ): # Fast path: repeat call with argument metadata seen before. The # cache is only populated by calls that already took the slow # path below, so hitting it cannot skip autotuning/compilation. # (The cache stays empty under TPU compile capture, so this # cannot bypass auto_capture_call below.) fast_entry: _FastDispatchEntry | None = None if ( self._key_fn is not None and not self.settings.distributed and not self._declares_process_group ): # Keyed kernels cannot use a prepared call. Build their legacy # exact key without allocating guard metadata on every launch. specialization_generation = self._specialization_generation dist_initialized = dist.is_initialized() is_distributed = self._compute_is_distributed( args, dist_initialized=dist_initialized ) fast_key = self._fast_dispatch_key(args, is_distributed=is_distributed) if fast_key is not None: bound = self._dispatch_cache.get(fast_key) if bound is not None and bound._run is not None: run = bound._run if is_compiling: return run(*args) if ( bound._dispatch_generation == specialization_generation and bound._run is run and self._specialization_generation == specialization_generation and dist.is_initialized() == dist_initialized ): return run(*args) fast_entry = _FastDispatchEntry( fast_key, (), specialization_generation, ) if fast_entry is None: fast_entry = self._fast_dispatch_key_and_guards(args) if fast_entry is not None: fast_key = fast_entry.key bound = self._dispatch_cache.get(fast_key) if bound is not None and bound._run is not None: run = bound._run if is_compiling: return run(*args) # A compilation can discover a late specialization while a # different thread is reading this cache. Revalidate fast- # path state while the mapping is still current; the same # lock protects specialization-driven invalidation. with self._bind_lock: if ( self._dispatch_cache.get(fast_key) is bound and bound._run is run ): entry = self._prepare_dispatch_entry( args, bound, fast_entry, ) if entry is not None: self._prepared_call = entry[0] if entry[1]: bound._dispatch_generation = ( fast_entry.specialization_generation ) else: run = None else: run = None if run is not None: return run(*args) if self.settings.backend == "pallas" and _TPU_COMPILE_CAPTURE: # Local import: _tpu_compile_capture pulls in the dynamo HOP machinery, # not ready when kernel.py first loads during ``import helion``. from .pallas._tpu_compile_capture import RUN_NORMAL from .pallas._tpu_compile_capture import auto_capture_call result = auto_capture_call(self, args) if result is not RUN_NORMAL: return cast("_R", result) return self.bind(args)(*args) bound = self.bind(args) result = bound(*args) if is_compiling: return result # Avoid a second bind for signatures the fast paths cannot support. # This cheap key check also captures a custom key exactly once. fast_entry = self._fast_dispatch_key_and_guards(args) if fast_entry is not None: fast_key = fast_entry.key # Resolve any late specialization and publish both fast paths as # one atomic update with respect to discovery and reset. with self._bind_lock: if self._has_specialization_extras: bound = self._bind(args) if bound._run is not None: entry = self._prepare_dispatch_entry( args, bound, fast_entry, ) if entry is not None: self._dispatch_cache[fast_key] = bound self._prepared_call = entry[0] if entry[1]: bound._dispatch_generation = ( fast_entry.specialization_generation ) return result
[docs] def reset(self) -> None: """ Clears the cache of bound kernels, meaning subsequent calls will recompile and re-autotune. """ with self._bind_lock: self._bound_kernels.clear() self._dispatch_cache.clear() self._prepared_call = None # Specialization extractors are discovered by tracing the host # function and can change after an explicit reset. Keeping the old # schema could hide newly discovered hl.specialize() calls. with self._specialize_extra_lock: # Replace rather than clear: in-flight omitted-default aliases # retain the old schema mapping while new work starts fresh. # The generation bump below retires every published list. self._specialize_extra = {} self._compiler_seed_specialize_extra = {} self._specialization_aliases = {} self._cute_grouped_static_tail_extra_descriptors = {} self._cute_reduction_extent_descriptors = {} self._has_specialization_extras = False self._specialization_generation += 1 self._reset_generation += 1 self._fast_dispatch_schemas = {}
@property def jax_fn(self) -> Callable[..., Any]: """A pure-JAX callable view of this Helion kernel. Pallas-backend kernels can be called directly with JAX arrays or tracers (i.e. inside ``jax.jit``) by going through this property. The kernel's compile/specialize path runs the first time the callable is invoked; subsequent calls reuse the cached compilation just like ``__call__``. This only supports kernels compiled for the Pallas backend. """ from .pallas.jax_export import make_jax_fn cached = getattr(self, "_jax_fn_callable", None) if cached is not None: return cast("Callable[..., Any]", cached) rv = make_jax_fn(self) # pyrefly: ignore [unsupported-attribute-set] self._jax_fn_callable = rv return rv
class BoundKernel(_AutotunableKernel, Generic[_R]): def __init__( self, kernel: Kernel[_R], args: tuple[object, ...], *, base_spec_key: tuple[Hashable, ...] | None = None, is_distributed: bool | None = None, cache_managed: bool = True, rewrite_ast: bool = True, ) -> None: """ Initialize a BoundKernel object. This constructor sets up the environment, compiles the kernel function, and prepares the arguments for execution. Args: kernel: The Kernel object to bind. args: A tuple of arguments to bind to the kernel. cache_managed: Whether this bound participates in the kernel's shared specialization and bound caches. rewrite_ast: Whether to apply the backend's AST customizations. """ super().__init__() kernel._validate_structural_policy() self.kernel = kernel self._reset_generation = kernel._reset_generation # Extending this bound's schema evicts all of its dispatch mappings. self._dispatch_generation: int | None = None # ``Kernel.__call__`` owns a shared prepared fast path, but callers may # also retain and invoke a BoundKernel directly (benchmark harnesses do # this deliberately). Keep the latest validated direct-call guard here. self._direct_prepared_call: _PreparedCall | None = None self._cache_managed = cache_managed if is_distributed is None: dist_initialized = dist.is_initialized() is_distributed = kernel._compute_is_distributed( args, dist_initialized=dist_initialized ) # Base specialization key from the REAL args (the same key bind() uses to # distinguish BoundKernels). Reused in compile_config as the PyCodeCache # ``extra`` discriminator for backends that cache shape-specific module state # (Backend.requires_shape_specialized_module). Must be computed on the real # args: fake_args turn scalar int/float/bool into SymInt/SymFloat/SymBool, # which the specialization extractors reject. self._base_spec_key = ( kernel._base_specialization_key(args, is_distributed=is_distributed) if base_spec_key is None else base_spec_key ) self._run: Callable[..., _R] | None = None self._config: Config | None = None self._compiler_seed_specialization_extractors: tuple[ _CompilerSeedSpecializationExtractor, ... ] = () self._compiler_seed_specialization_results: tuple[Hashable, ...] = () # Direct BoundKernel calls bypass Kernel's dispatch cache. Remember the # first device-bearing argument path and the immutable SM-derived facts # per device so their hot path does not recursively walk args or query # device properties on every launch. self._compiler_seed_device_resolver: _ArgumentDeviceResolver | None = None self._compiler_seed_device_results: dict[ torch.device, tuple[Hashable | None, ...], ] = {} self._compile_cache: dict[Config, CompiledConfig] = {} # Source an autotune render worker produced, keyed like the compile # cache and consumed by the next compile_config of that config. self._primed_renders: dict[Config, str] = {} # Configs whose cached callable was loaded from a worker's source: the # facts code generation records on this kernel (see to_code) are # missing until the parent renders them itself. self._render_only_configs: set[Config] = set() # Shape guards recorded past this index come from code generation. self._bind_guard_count = 0 # Backend specialization descriptors recorded per generated config # (``Backend.after_codegen``; CuTe: hint-sized reduction extents). self._cute_reduction_extents: dict[Config, tuple[Hashable, ...]] = {} self._cache_path_map: dict[Config, str | None] = {} self._host_semantic_fingerprints: dict[ tuple[_HostSemanticInputNormalization, tuple[object, ...]], str ] = {} # Direct to_code() has no call arguments, so keep this bound kernel's # construction-time tensor values as its stable weak fallback. self._runtime_tensor_refs_by_name = { name: weakref.ref(value) for name, value in zip(self.kernel.signature.parameters, args, strict=False) if isinstance(value, torch.Tensor) } self._first_compile_lock = threading.RLock() self._backward_compiled: ( tuple[Kernel[object], str, BoundKernel[object]] | None ) = None # Distributed detection lives on Kernel so the same value feeds both the # specialization cache key and CompileEnvironment gating; that keeps a # symmetric-memory call from ever aliasing a non-distributed compiled # kernel of the same shape. See GitHub issue #3024. self._env = CompileEnvironment( _canonicalize_argument_device(_find_device(args)), self.kernel.settings, index_dtype=_resolve_index_dtype(self.kernel.settings, args), is_distributed=is_distributed, ) # The structural policy the traced IR will follow (None for backends # whose config schema does not depend on Settings). policy_type = self._env.backend.structural_policy_type() self._structural_policy = ( None if policy_type is None else policy_type.from_settings(kernel.settings) ) if is_ref_mode_enabled(self.kernel.settings): self.fake_args = [] # type: ignore[assignment] self.host_function = None # type: ignore[assignment] return self._env.backend.on_bind(self, args, rewrite_ast=rewrite_ast) with self.env: self._env.process_group_name = _find_process_group_name( kernel.fn, args, is_distributed ) assert len(args) == len(self.kernel.signature.parameters) self.fake_args: list[object] = [] constexpr_args = {} for name, arg, annotation in zip( self.kernel.signature.parameters, args, self.kernel._annotations, strict=False, ): if isinstance(arg, ConstExpr): assert not isinstance(arg.value, torch.Tensor), ( "ConstExpr cannot be a tensor" ) self.fake_args.append(arg.value) constexpr_args[name] = arg.value elif annotation is ConstExpr: assert not isinstance(arg, torch.Tensor), ( "ConstExpr cannot be a tensor" ) self.fake_args.append(arg) constexpr_args[name] = arg else: self.fake_args.append(self.env.to_fake(arg, ArgumentOrigin(name))) self._apply_mark_static(args) runtime_args = dict( zip(self.kernel.signature.parameters, args, strict=False) ) with ( maybe_skip_dtype_check_in_meta_registrations(), patch_inductor_lowerings(), measure("BoundKernel.create_host_function"), ): try: compiler = KernelCompiler(self.env) with self.env.use_runtime_arg_values(runtime_args): self.host_function: HostFunction = compiler.compile( self.kernel.fn, self.fake_args, constexpr_args, rewrite_ast=rewrite_ast, ) except Exception: config = self.env.config_spec.default_config() self.maybe_log_repro(log.warning, args, config=config) raise self.env.restrict_pid_types_for_persistent(args) self._bind_guard_count = len(self.env.shape_env.guards) self.env.config_spec.configure_epilogue_subtile_autotune(args) self.env.snapshot_tensor_descriptor_alignments(runtime_args) # Structural facts the compiler seeds and the search surface # read off the traced host function (recorded before # ``compiler_seed_configs`` runs; independent of the runtime # arguments). self.env.backend.host_structural_facts( self.host_function, self.env.config_spec ) with self.env.use_runtime_arg_values(runtime_args): self.env.config_spec.compiler_seed_configs = compiler_seed_configs( self.env, self.host_function.device_ir, ) if not self._cache_managed: self.env.snapshot_runtime_input_specialization_results( runtime_args ) self._compiler_seed_specialization_extractors = ( _compiler_seed_specialization_extractors( compiler_seed_specialization_facts( self.env.backend_name, self.config_spec.autotuner_heuristics, ) | self.env.compiler_fact_specialization_facts, self.settings.persistent_reserved_sms, static_shapes=self.settings.static_shapes, ) ) self._compiler_seed_specialization_results = tuple( extractor(args) for extractor in self._compiler_seed_specialization_extractors ) if any( extractor.fact != "input_tensor_metadata" for extractor in self._compiler_seed_specialization_extractors ): self._compiler_seed_device_resolver = ( _ArgumentDeviceResolver.from_values(args) ) self._compiler_seed_device_results[self.env.device] = tuple( (None if extractor.fact == "input_tensor_metadata" else result) for extractor, result in zip( self._compiler_seed_specialization_extractors, self._compiler_seed_specialization_results, strict=True, ) ) if not self.env.settings.disable_autotuner_heuristics: for seed_config in self.env.config_spec.autotune_seed_configs(): if ( seed_config not in self.env.config_spec.compiler_seed_configs ): self.env.config_spec.compiler_seed_configs.append( seed_config ) with self.env.use_runtime_arg_values(runtime_args): register_compiler_coverage_groups( self.env, self.host_function.device_ir ) def _apply_mark_static(self, args: tuple[object, ...]) -> None: """ Apply torch._dynamo.mark_static() markings from input tensors. This reads _dynamo_static_indices from each tensor argument and marks the corresponding dimensions as specialized (constant) in the kernel. """ for arg, fake_arg in zip(args, self.fake_args, strict=True): if isinstance(arg, torch.Tensor) and isinstance(fake_arg, torch.Tensor): for dim in getattr(arg, "_dynamo_static_indices", ()): size = fake_arg.size(dim) if isinstance(size, torch.SymInt): self.env.specialized_vars.update(_symint_free_symbols(size)) @property def env(self) -> CompileEnvironment: # pyrefly: ignore[bad-override] return self._env @property def settings(self) -> Settings: """ Retrieve the settings associated with the kernel. Returns: Settings: The settings of the kernel. """ return self.kernel.settings @property def config_spec(self) -> ConfigSpec: """ Retrieve the configuration specification for the kernel. Returns: ConfigSpec: The configuration specification. """ return self.env.config_spec @property def configs(self) -> list[Config]: """Return the kernel's configured configs (alias for `self.kernel.configs`).""" return self.kernel.configs def _normalize_config(self, config: ConfigLike) -> Config: self.kernel._validate_structural_policy() if isinstance(config, StructuralConfig): require_same_structural_policy( config.policy, self._structural_policy, context="Late config envelope" ) return config.config if isinstance(config, Config): return config # pyrefly: ignore [bad-argument-type] return Config(**config) def _normalized_config_copy(self, config: ConfigLike) -> Config: return self.env.config_spec.normalized_config(self._normalize_config(config)) def config_envelope(self, config: ConfigLike) -> CuteStructuralConfig: """Snapshot a config and this bound's policy for a later pre-binding load.""" if self._structural_policy is None: raise ValueError("Config policy envelopes require backend='cute'") return CuteStructuralConfig( self._normalize_config(config), self._structural_policy ) def format_kernel_decorator(self, config: ConfigLike, settings: Settings) -> str: """Return the @helion.kernel decorator capturing backend codegen settings.""" if self is not None and self.kernel.cute_structural_policy is not None: config = self.config_envelope(config) parts = [ f"config={config.__repr__()}", f"static_shapes={settings.static_shapes}", ] if settings.index_dtype is not None: parts.append(f"index_dtype={settings.index_dtype}") # Resolved from the settings' backend: callers pass foreign Settings # and ``self`` may be None. parts.extend(get_backend(settings.backend).decorator_settings(settings)) return f"@helion.kernel({', '.join(parts)})" def to_code( self, config: ConfigLike | None = None, *, options: OutputCodeOptions | None = None, emit_repro_caller: bool = False, output_origin_lines: bool | None = None, ) -> str: """ Generate backend-specific code for the kernel based on the given configuration. Args: config: The configuration to use for code generation. options: Optional :class:`~helion.runtime.kernel.OutputCodeOptions`. With ``allow_helion_deps=False`` the returned module is self-contained (no ``helion`` import at runtime); ``jax_fn=True`` (Pallas only) emits a pure-JAX module operating on ``jax.Array``s. ``None`` keeps the default behavior. emit_repro_caller: Emits a main function to call the kernel with example inputs. Returns: str: The generated code as a string. """ if config is None: config = self._require_implicit_config() with self.env, measure("BoundKernel.to_code"): # Work on a copy so the caller's Config is not mutated with defaults # specific to this BoundKernel's config_spec. config = self._normalized_config_copy(config) with ( self._runtime_arg_values_for_codegen(), measure("BoundKernel.generate_ast"), ): self.env.backend.before_codegen(self, config) # pyrefly: ignore [bad-argument-type] root = generate_ast(self.host_function, config, emit_repro_caller) # Code generation can guard on input sizes too. self._publish_guarded_input_facts() self.env.backend.after_codegen(self, config) if output_origin_lines is None: output_origin_lines = self.settings.output_origin_lines import_lines: list[str] = [] body_start = 0 for i, stmt in enumerate(root.body): if isinstance(stmt, (ast.Import, ast.ImportFrom)): if not ( isinstance(stmt, ast.ImportFrom) and stmt.module == "__future__" ): import_lines.append(ast.unparse(stmt)) continue body_start = i break else: body_start = len(root.body) body_root = ast.Module(body=root.body[body_start:], type_ignores=[]) ast.fix_missing_locations(body_root) # Export after codegen and outside the fake-tensor environment: backends # may capture launch metadata before rewriting the module. Check jax_fn # first because it supports both dependency modes. if options is not None and options.jax_fn: from .._compiler.output_code_utils import build_jax_fn_module from .._compiler.output_code_utils import capture_jax_launch_metadata jax_meta = capture_jax_launch_metadata(self, config) body_root = build_jax_fn_module( self, options, import_lines, body_root, jax_meta ) elif options is not None and not options.allow_helion_deps: from .._compiler.output_code_utils import build_dependency_free_code body_root = build_dependency_free_code( self, options, import_lines, body_root ) with measure("BoundKernel.unparse"): body = unparse(body_root, output_origin_lines=output_origin_lines) imports = "\n".join(import_lines) if imports: return f"from __future__ import annotations\n\n{imports}\n\n{body}" return f"from __future__ import annotations\n\n{body}" def to_triton_code( self, config: ConfigLike | None = None, *, emit_repro_caller: bool = False, output_origin_lines: bool | None = None, ) -> str: """Backward-compatible alias for :meth:`to_code`.""" return self.to_code( config, emit_repro_caller=emit_repro_caller, output_origin_lines=output_origin_lines, ) def has_compiled_config(self, config: ConfigLike) -> bool: """Whether ``compile_config(config)`` would hit this kernel's compile cache. A config the strict normalization rejects (a search candidate carrying a knob its other knobs do not admit, e.g. a grouped register profile on a schedule without one) is not compiled: it answers ``False`` and the compile path reports the rejection for that one config, instead of the rejection aborting the batch that asked. """ try: normalized = self._normalized_config_copy(self._normalize_config(config)) except exc.InvalidConfig: return False return normalized in self._compile_cache def prime_rendered_code(self, config: ConfigLike, code: str) -> None: """Hand :meth:`compile_config` the source a render worker made for ``config``. Honoured only while the autotuner measures candidates: the next ``compile_config(config)`` loads ``code`` instead of generating it, and remembers that the facts code generation records on this kernel (guard facts, CuTe specializations; see :meth:`to_code`) are missing for that config. The first ``compile_config`` of such a config outside the search renders it again so a config that runs for real always went through this process's code generation; that render happens when the selected config is set on the kernel, after the search returned and outside its wall-clock budget. Workers render from the parent's state at fork time; with ``static_shapes=False`` the guards the parent accumulates meanwhile only widen the dispatch key, not the source. See :mod:`helion.autotuner.render_pool`. """ self._primed_renders[ self._normalized_config_copy(self._normalize_config(config)) ] = code def compile_config( self, config: ConfigLike | None = None, *, allow_print: bool = True ) -> CompiledConfig: """ Compile the kernel for a specific configuration. Args: config: The configuration to compile the kernel with. allow_print: Set to suppress printing the output code when autotuning. Returns: CompiledConfig: A callable object representing the compiled kernel. """ if config is None: config = self._require_implicit_config() requested_config = self._normalize_config(config) config = self._normalized_config_copy(requested_config) dist_check_config_consistancy( config, process_group_name=self._env.process_group_name ) primed = self._primed_renders.pop(config, None) if (rv := self._compile_cache.get(config)) is not None: if config in self._render_only_configs and not _SEARCHING_CANDIDATES.get(): # Loaded from a render worker's source for a measurement; a # config about to run for real is rendered here so to_code # records its guard facts and CuTe specializations. self._render_only_configs.discard(config) del self._compile_cache[config] else: self.env.backend.after_compile(self, config) return rv device_index = ( self._env.device.index if self._env.device.index is not None else 0 ) self.env.backend.setup_compile_cache_dir(device_index) try: if primed is not None and _SEARCHING_CANDIDATES.get(): triton_code = primed self._render_only_configs.add(config) else: triton_code = self.to_triton_code( config, emit_repro_caller=self.settings.print_output_code ) self._render_only_configs.discard(config) # static_shapes=True keys a distinct BoundKernel per input shape (see # _tensor_key), but PyCodeCache keys compiled modules by SOURCE TEXT, so # two shapes that emit byte-identical source (e.g. compact_worklist, whose # token dim enters only via runtime offsets + a data-dependent loop) would # share one module. For backends whose generated module caches shape- # specific state (Backend.requires_shape_specialized_module -- e.g. Pallas, # whose output-meta descriptor / launcher cache / ds-pad decision / # signature lock are all monomorphic) that shared module returns the first # shape's cached output extent for both. Fold the specialization key into # the PyCodeCache key (via ``extra``, leaving the source untouched) so each # specialization gets its own module. Reusing the same key that # distinguishes BoundKernels (``self._base_spec_key``, computed from the # real args in __init__) guarantees distinct BoundKernel => distinct module # and already covers shape/dtype/stride recursively through nested # container args. cache_extra = "" if ( self.settings.static_shapes and self.env.backend.requires_shape_specialized_module ): cache_extra = repr(self._base_spec_key) if self.kernel.cute_structural_policy is not None: cache_extra += self.extra_cache_key() with measure("BoundKernel.PyCodeCache.load"): module = PyCodeCache.load(triton_code, extra=cache_extra) self.env.backend.annotate_compiled_module( module, triton_code, self.kernel.name ) except Exception: log.warning( "Helion compiler triton codegen error for %s", self.format_kernel_decorator(requested_config, self.settings), exc_info=True, ) self.maybe_log_repro(log.warning, self.fake_args, config=requested_config) raise if allow_print: log.info("Output code written to: %s", module.__file__) log.debug("Debug string: \n%s", LazyString(lambda: self._debug_str())) # for distributed kernel, print rank1 code since rank0 # code can skip some offset computation. if ( not dist.is_initialized() or dist.get_rank() == 1 ) and self.settings.print_output_code: log.info("Output code: \n%s", triton_code) print(f"# Output code written to: {module.__file__}", file=sys.stderr) print(triton_code, file=sys.stderr) rv = getattr(module, self.kernel.name) self._compile_cache[config] = rv self._cache_path_map[config] = module.__file__ self.env.backend.after_compile(self, config) return rv def bench_compile_config( self, config: Config | dict[str, object] | None = None, *, allow_print: bool = True, ) -> Callable[..., object]: return self.compile_config(config, allow_print=allow_print) def extra_cache_key(self) -> str: """Return extra data folded into the disk-cache key. Returns ``""`` by default, leaving the cache key unchanged. """ policy = self.kernel.cute_structural_policy return "" if policy is None else f"cute_structural_policy:{policy.identity()}" def supports_subprocess_benchmark(self) -> bool: return True def is_cacheable(self) -> bool: return True def get_cached_path(self, config: ConfigLike | None = None) -> str | None: """ Get the file path of the generated Triton code for a specific configuration. Args: config: The configuration to get the file path for. Returns: str | None: The file path of the generated Triton code, or None if not found. """ if config is None: config = self._require_implicit_config() requested_config = self._normalize_config(config) if requested_config in self._cache_path_map: return self._cache_path_map[requested_config] try: config = self._normalized_config_copy(requested_config) except exc.InvalidConfig: return None return self._cache_path_map.get(config, None) def _debug_str(self) -> str: """ Generate a debug string for the kernel. Returns: str: A string containing debug information about the kernel. """ if self.host_function is None: # In ref mode, host_function is not created return f"<BoundKernel {self.kernel.fn.__name__} in ref mode>" with self.env: return self.host_function.debug_str() def _get_host_semantic_input_normalization( self, ) -> _HostSemanticInputNormalization: """Return capture-time equivalence classes for dynamic input symbols.""" if self.settings.static_shapes: return () canonical_by_expr: dict[sympy.Expr, int] = {} normalization: list[tuple[int, str, int, int]] = [] with self.env: tensor_args = self._host_semantic_input_tensors() for tensor_index, arg in enumerate(tensor_args): for property_name, values in ( ("size", arg.size()), ("stride", arg.stride()), ): for index, value in enumerate(values): if not isinstance(value, torch.SymInt): continue expr = self.env.shape_env.simplify(value._sympy_()) if isinstance(expr, sympy.Integer): continue canonical = canonical_by_expr.get(expr) if canonical is None: canonical = len(canonical_by_expr) canonical_by_expr[expr] = canonical normalization.append( (tensor_index, property_name, index, canonical) ) return tuple(normalization) def _host_semantic_debug_renames( self, normalization: _HostSemanticInputNormalization, ) -> tuple[dict[sympy.Basic, sympy.Basic], bool]: tensor_args = self._host_semantic_input_tensors() renames: dict[sympy.Basic, sympy.Basic] = {} class_by_expr: dict[sympy.Basic, int] = {} compatible = True for tensor_index, property_name, index, canonical in normalization: # A missing or concretized property is left unnormalized. If it is # semantically used, the rendered host/device trace still differs. if tensor_index >= len(tensor_args): continue tensor = tensor_args[tensor_index] values = tensor.size() if property_name == "size" else tensor.stride() if index >= len(values): continue value = values[index] if not isinstance(value, torch.SymInt): continue expr = self.env.shape_env.simplify(value._sympy_()) existing = class_by_expr.get(expr) if existing is not None and existing != canonical: compatible = False continue class_by_expr[expr] = canonical renames[expr] = sympy.Symbol( f"<helion_input_symbol_{canonical}>", integer=True, ) return renames, compatible def _host_semantic_input_tensors(self) -> list[torch.Tensor]: return [ tensor for tensor, source in self.env.input_sources.items() if _is_supported_tensor_input_source(source) ] def _host_semantic_external_tensor_keys(self) -> tuple[object, ...]: return tuple( ( type(source).__name__, tuple(self._semantic_dimension_key(dim) for dim in tensor.shape), tuple(self._semantic_dimension_key(dim) for dim in tensor.stride()), str(tensor.dtype), str(tensor.device), str(tensor.layout), tensor.requires_grad, ) for tensor, source in self.env.input_sources.items() if not _is_supported_tensor_input_source(source) ) def _get_host_semantic_fingerprint( self, outputs: Sequence[object], *, input_normalization: _HostSemanticInputNormalization | None = None, ) -> str: assert self.host_function is not None if input_normalization is None: input_normalization = self._get_host_semantic_input_normalization() output_keys: list[object] = [] for output in outputs: if isinstance(output, torch.Tensor): if self.settings.static_shapes: shape = tuple( self._semantic_dimension_key(dim) for dim in output.shape ) else: shape = ("rank", output.ndim) output_keys.append( ( "tensor", shape, str(output.dtype), str(output.device), str(output.layout), ) ) elif isinstance(output, (torch.SymInt, torch.SymFloat, torch.SymBool)): output_keys.append((type(output).__name__,)) elif output is None or type(output) in (bool, float, int, str): output_type = type(output) output_keys.append( ( f"{output_type.__module__}.{output_type.__qualname__}", repr(output), ) ) else: raise TypeError( f"Unsupported Helion host output in semantic fingerprint: " f"{type(output).__name__}" ) output_key = tuple(output_keys) cache_key = (input_normalization, output_key) fingerprint = self._host_semantic_fingerprints.get(cache_key) if fingerprint is not None: return fingerprint normalization_compatible = True with self.env: if input_normalization: renames, normalization_compatible = self._host_semantic_debug_renames( input_normalization ) with self.env.use_debug_shape_renames(renames): host_source = self.host_function.semantic_debug_str() else: host_source = self.host_function.semantic_debug_str() payload = ( input_normalization, normalization_compatible, self.env.backend_name, str(self.env.index_dtype), self._host_semantic_external_tensor_keys(), host_source, output_key, ) fingerprint = hashlib.sha256(repr(payload).encode()).hexdigest() self._host_semantic_fingerprints[cache_key] = fingerprint return fingerprint def _semantic_dimension_key(self, dim: object) -> object: if isinstance(dim, torch.SymInt) and dim.node.shape_env is self.env.shape_env: return shape_env_size_hint(self.env.shape_env, dim.node.expr) return dim def autotune( self, args: Sequence[object], *, force: bool = True, **kwargs: object, ) -> Config: """ Perform autotuning to find the optimal configuration for the kernel. This uses the default setting, you can call helion.autotune.* directly for more customization. If config= or configs= is provided to helion.kernel(), the search will be restricted to the provided configs. Use force=True to ignore the provided configs. Mutates self so that `__call__` will run the best config found. Args: args: Example arguments used for benchmarking during autotuning. force: If True, force full autotuning even if a config is provided. kwargs: Additional keyword options forwarded to the autotuner. Returns: Config: The best configuration found during autotuning. """ normalized_args = self.kernel.normalize_args(*args) if self._cache_managed: rebound = self.kernel.bind(normalized_args) if rebound is not self: return rebound.autotune( normalized_args, force=force, **kwargs, ) ephemeral = self.env.backend.make_ephemeral_cache() ctx = ephemeral if ephemeral is not None else contextlib.nullcontext() with ctx: config = self.env.backend.autotune( self, normalized_args, force=force, **kwargs, ) if ephemeral is not None: self.env.backend.finalize_ephemeral_cache(self, config) self.set_config(config) return config def set_config(self, config: ConfigLike) -> None: """ Set the configuration for the kernel and compile it. Mutates self so that `__call__` will run the provided config. Args: config: The configuration to set. """ requested_config = config config = self._normalize_config(config) self._run = self.compile_config(config) self._config = config repro_config = ( requested_config if isinstance(requested_config, StructuralConfig) else config ) counters["best_config_decorator"][ self.format_kernel_decorator(repro_config, self.settings) ] = 1 def _specialize_extra(self) -> list[Callable[[Sequence[object]], Hashable]]: """ Returns a list of functions that will be called to generate extra specialization keys. This is used to specialize on the values hl.specialize()'ed arguments. Returns: list[Callable[[Sequence[object]], Hashable]]: A list of functions that generate extra specialization keys. """ tensor_descriptor_layout_guards = self.env.tensor_descriptor_layout_guards tensor_descriptor_alignment_guards = getattr( self.env, "tensor_descriptor_alignment_guards", {} ) if ( not self.env.specialized_vars and not self.env.specialized_strides and not tensor_descriptor_layout_guards and not tensor_descriptor_alignment_guards and not self.env.runtime_input_specializations ): return [] def make_extractor(v: Source) -> Callable[[Sequence[object]], Hashable]: if isinstance(v, TensorPropertySource): index = v.idx assert index is not None inner = make_extractor(v.base) if v.prop == TensorProperty.SIZE: def size_extractor( args: Sequence[object], _inner: Callable[[Sequence[object]], Hashable] = inner, _index: int = index, ) -> Hashable: result = _inner(args) # Handle list of tensors: return tuple of sizes for all tensors if isinstance(result, (list, tuple)): return tuple( cast("torch.Tensor", t).size(_index) for t in result ) return cast("torch.Tensor", result).size(_index) return size_extractor if v.prop == TensorProperty.STRIDE: def stride_extractor( args: Sequence[object], _inner: Callable[[Sequence[object]], Hashable] = inner, _index: int = index, ) -> Hashable: result = _inner(args) # Handle list of tensors: return tuple of strides for all tensors if isinstance(result, (list, tuple)): return tuple( cast("torch.Tensor", t).stride(_index) for t in result ) return cast("torch.Tensor", result).stride(_index) return stride_extractor raise exc.SpecializeArgType(v) if isinstance(v, GetItemSource): if not isinstance(v.index, (int, str)) or v.index_is_slice: raise exc.SpecializeArgType(v) inner = make_extractor(v.base) def getitem_extractor( args: Sequence[object], _inner: Callable[[Sequence[object]], Hashable] = inner, _index: int | str = v.index, ) -> Hashable: result: Any = _inner(args) if isinstance(result, dict): return result[_index] if isinstance(_index, str): return getattr(result, _index) return result[_index] return getitem_extractor if isinstance(v, LocalSource): index = arg_name_to_index[v.local_name] def local_extractor( args: Sequence[object], _index: int = index, ) -> Hashable: return args[_index] # pyrefly: ignore[bad-return] # The argument index, for generated guards that read the # argument directly instead of calling the extractor. local_extractor._helion_arg_index = index # pyrefly: ignore[missing-attribute] return local_extractor raise exc.SpecializeArgType(v) arg_name_to_index: dict[str, int] = { n: i for i, n in enumerate(self.kernel.signature.parameters.keys()) } extractors: list[Callable[[Sequence[object]], Hashable]] = [] extracted_strides: set[TensorPropertySource] = set() for v in sorted(self.env.specialized_vars, key=lambda v: v.name): source = self.env.shape_env.var_to_sources[v][0] extractor = make_extractor(source) if isinstance(source, TensorPropertySource): extractor = PreparedMetadataSpecializationExtractor(extractor) extractors.append(extractor) if ( isinstance(source, TensorPropertySource) and source.prop == TensorProperty.STRIDE and source.idx is not None ): extracted_strides.add(source) for source in sorted(self.env.specialized_strides, key=repr): if source in extracted_strides: continue extractors.append( PreparedMetadataSpecializationExtractor(make_extractor(source)) ) candidate_configs: tuple[Config, ...] | None if tensor_descriptor_layout_guards or tensor_descriptor_alignment_guards: implicit_config = self._fixed_config_for_td_layout_guards() if implicit_config is not None: candidate_configs = (implicit_config,) elif not self.settings.force_autotune and len(self.kernel.configs) > 1: normalized_configs = [] for config in self.kernel.configs: try: normalized_configs.append(self._normalized_config_copy(config)) except exc.InvalidConfig: # Finite-search autotuning deliberately permits invalid # candidates and skips them at compile time. Descriptor # guard discovery must not make those failures eager. continue candidate_configs = tuple(normalized_configs) else: candidate_configs = None else: candidate_configs = None def guard_is_active(guard: _TensorDescriptorOperationGuard) -> bool: return candidate_configs is None or any( _td_guard_active_for_config(guard, config) for config in candidate_configs ) def descriptor_extent_cap( guard: TensorDescriptorLayoutGuard, ) -> int | None: if guard.has_derived_block_extent: return ( CUDA_TENSOR_DESCRIPTOR_MAX_BLOCK_SIZE if self.env.device.type == "cuda" else None ) if candidate_configs is None: return ( CUDA_TENSOR_DESCRIPTOR_MAX_BLOCK_SIZE if self.env.device.type == "cuda" else None ) active_configs = tuple( config for config in candidate_configs if _td_guard_active_for_config(guard, config) ) with self.env: resolved_block_sizes = ( block_size.from_config(config) for config in active_configs for block_size in self.env.block_sizes ) cap = max( ( value for value in resolved_block_sizes if type(value) is int and value > 0 and value & (value - 1) == 0 ), default=None, ) if self.env.device.type != "cuda": return cap if cap is None: return CUDA_TENSOR_DESCRIPTOR_MAX_BLOCK_SIZE return min(cap, CUDA_TENSOR_DESCRIPTOR_MAX_BLOCK_SIZE) for source, guard in sorted( tensor_descriptor_layout_guards.items(), key=lambda item: repr(item[0]), ): if not guard_is_active(guard): continue extract_tensor = make_extractor(source) def td_layout_extractor( args: Sequence[object], _extract_tensor: Callable[ [Sequence[object]], Hashable ] = extract_tensor, _ndim: int = guard.ndim, _element_size: int = guard.element_size, _extent_cap: int | None = descriptor_extent_cap(guard), ) -> Hashable: tensor = cast("torch.Tensor", _extract_tensor(args)) if tensor.ndim != _ndim: return ("ndim", tensor.ndim) layout = tensor_descriptor_layout_signature_from_strides( tensor.stride(), _element_size, ) # Extent classes guard Triton's descriptor block-shape and # int32-coordinate legality. Other backends retain the legacy # layout-only key; they neither consume host descriptors nor # use Triton's descriptor legality checks. if self.env.backend_name != "triton": return layout return ( layout, tuple( _tensor_descriptor_extent_class(int(size), _extent_cap) for size in tensor.size() ), all(int(size) < 2**31 for size in tensor.size()), ) extractors.append( PreparedMetadataSpecializationExtractor(td_layout_extractor) ) for source, guard in sorted( tensor_descriptor_alignment_guards.items(), key=lambda item: repr(item[0]), ): if not guard_is_active(guard): continue # Prepared metadata guards do not cover base pointers. extractors.append( _TensorDescriptorAlignmentExtractor( make_extractor(source), guard.requires_zero_storage_offset ) ) for key, specialization in sorted( self.env.runtime_input_specializations.items(), ): source_extractors = tuple( make_extractor(source) for source in specialization.sources ) extractors.append( _RuntimeInputSpecializationExtractor( source_extractors, specialization.classifier, frozenset(specialization.reusable_tensor_properties), key, specialization.prepared_guard_factory, ) ) return extractors def _publish_guarded_input_facts(self) -> None: """Extend the dispatch schema with guard facts it does not check yet.""" if self._cache_managed: self.kernel._publish_guard_facts(self, self._guarded_input_facts()) def _guarded_input_facts( self, ) -> list[PreparedMetadataSpecializationExtractor]: """Input-shape guards this dynamic-shape compilation baked into its code. ``static_shapes=False`` keeps 0/1 sizes symbolic so one bound kernel can serve every size, but a compile-time comparison still guards on them: ``size == 1`` at a size-1 dim replaces the symbol and drops that dim's index, ``size != 1`` keeps a broadcast dim's index, and equal sizes merge two reduction dims. Each replacement of an input size/stride (by a constant or other input sizes) and each ``!=`` against a constant that code generation recorded becomes a cache-key fact, as do those on a reduction block symbol (an empty reduction guarded 1-wide), keyed by the block its extent's input sizes give. Nothing is added when no such guard fired, so the common case keeps #2353's single bound kernel and its dispatch cost. """ if self.settings.static_shapes: return [] shape_env = self.env.shape_env # Container elements share their container's source, so only direct # tensor arguments replay; descriptor guards on elements are keyed by # the descriptor layout extractor. tensor_params = { name for name, fake in zip( self.kernel.signature.parameters, self.fake_args, strict=False ) if isinstance(fake, torch.Tensor) } sources: dict[sympy.Symbol, TensorPropertySource] = {} for symbol, symbol_sources in shape_env.var_to_sources.items(): for source in symbol_sources: if ( isinstance(source, TensorPropertySource) and isinstance(source.base, LocalSource) and source.base.local_name in tensor_params ): sources[symbol] = source break def guarded_quantity(symbol: sympy.Symbol) -> tuple[sympy.Expr, bool] | None: """The input expression a guarded symbol stands for, and if a block.""" if (source := sources.get(symbol)) is not None: # hl.specialize already keys the exact value. if ( symbol in self.env.specialized_vars and symbol not in self.env.singleton_vars ) or source in self.env.specialized_strides: return None return symbol, False assert self.host_function is not None origin = self.host_function.expr_to_origin.get(symbol) if ( origin is not None and isinstance(origin.origin, BlockSizeOrigin) and (info := self.env.block_sizes[origin.origin.block_id]).reduction and isinstance(info.size, torch.SymInt) ): return cast("sympy.Expr", info.size._sympy_()), True return None relations: list[tuple[sympy.Symbol, sympy.Expr, bool]] = [ (symbol, expr, True) for symbol, expr in shape_env.replacements.items() if expr != symbol ] relations.extend( (symbol, expr, True) for symbol, expr in self.env.keyed_size_equalities ) relations.extend( (lhs, rhs, False) # Binding records ``!= 1`` for any size it saw >= 2 without code # depending on it; code generation's (block pointers keep a # broadcast dim's index) does. for guard in shape_env.guards[self._bind_guard_count :] if isinstance(guard.expr, sympy.Ne) and isinstance(lhs := guard.expr.lhs, sympy.Symbol) and isinstance(rhs := guard.expr.rhs, sympy.Integer) ) param_names = tuple(self.kernel.signature.parameters) facts: dict[_GuardedInputFact, None] = {} for symbol, expr, equal in relations: if (quantity := guarded_quantity(symbol)) is None: continue lhs, block = quantity free_symbols = sorted( { cast("sympy.Symbol", free) for free in (*lhs.free_symbols, *expr.free_symbols) }, key=str, ) if lhs.free_symbols and all(free in sources for free in free_symbols): facts.setdefault( _GuardedInputFact( lhs, expr, tuple((free, sources[free]) for free in free_symbols), block, equal, param_names, ) ) return [ PreparedMetadataSpecializationExtractor(fact) for fact in sorted(facts, key=repr) ] def _record_runtime_input_specialization_results( self, extractors: Sequence[Callable[[Sequence[object]], Hashable]], results: Sequence[Hashable], ) -> bool: """Initialize immutable reusable facts from an exact cache-key evaluation.""" expected_keys = { key for key, specialization in self.env.runtime_input_specializations.items() if specialization.reusable_tensor_properties } observed = { extractor.specialization_key: result for extractor, result in zip(extractors, results, strict=True) if isinstance(extractor, _RuntimeInputSpecializationExtractor) and extractor.specialization_key is not None and extractor.reusable_tensor_properties } if observed.keys() != expected_keys: return False previous = self.env.bound_runtime_input_specialization_results if previous and previous != observed: return False if not previous: self.env.bound_runtime_input_specialization_results = observed return True @contextlib.contextmanager def _runtime_arg_values_for_codegen(self) -> Generator[None, None, None]: values: dict[str, object] = self.env.runtime_arg_values_by_name if not values: values = { name: value for name, ref in self._runtime_tensor_refs_by_name.items() if (value := ref()) is not None } with self.env.use_runtime_arg_values(values): yield def _extend_descriptor_specializations( self, seen_by_signature: dict[Hashable, set[Hashable]], descriptors: Sequence[Hashable], make_extractor: Callable[[Any], Callable[[Sequence[object]], Hashable]], ) -> None: """Key every descriptor that this signature does not key yet. Backends call this from their ``after_codegen`` / ``after_compile`` hooks with a per-kernel ``seen_by_signature`` slot. """ if not descriptors: return signature = self._base_spec_key # Serialize descriptor discovery with schema extension so concurrent # code generation cannot publish duplicate equivalent extractors. with self.kernel._bind_lock: if self._reset_generation != self.kernel._reset_generation: return seen = seen_by_signature.setdefault(signature, set()) new_descriptors = [ descriptor for descriptor in descriptors if descriptor not in seen ] with self._runtime_arg_values_for_codegen(): runtime_args = tuple( self.env.runtime_arg_values_by_name.get(name) for name in self.kernel.signature.parameters ) if self.kernel._extend_bound_kernel_specializations( self, signature, [make_extractor(descriptor) for descriptor in new_descriptors], runtime_args, ): seen.update(new_descriptors) def _fixed_config_for_td_layout_guards(self) -> Config | None: """Return the fixed config if TD layout guards can be filtered safely.""" if self._config is not None: config = self._config elif self.kernel.settings.autotune_effort == "none" and ( len(self.kernel.configs) == 0 or self.settings.force_autotune ): config = self.config_spec.default_config() elif self.settings.force_autotune: return None elif len(self.kernel.configs) == 1: config = self.kernel.configs[0] else: return None # Decorator configs are intentionally allowed to omit inferred fields # such as block_sizes. Resolve the same effective config that codegen # will see before asking BlockSizeSource to read those fields. return self._normalized_config_copy(config) def _user_provided_config(self) -> Config | None: """Return a config if the user explicitly provided one, else None. Checks the kernel's config list and settings to determine if a config can be resolved without autotuning. """ configs = self.kernel.configs if self.kernel.settings.autotune_effort == "none" and ( len(configs) == 0 or self.settings.force_autotune ): config = self.config_spec.default_config() if not is_ref_mode_enabled(self.kernel.settings): kernel_decorator = self.format_kernel_decorator(config, self.settings) print( f"Using implicit config: {kernel_decorator}", file=sys.stderr, ) return config if self.settings.force_autotune: return None if len(configs) == 1: return configs[0] return None def _implicit_config(self) -> Config | None: """ Returns a single config that is implicitly used by this kernel, if any. """ if self._config is not None: return self._config return self._user_provided_config() def _require_implicit_config(self) -> Config: """ Returns the implicit config for this kernel, or raises an error if no implicit config is available. """ if (config := self._implicit_config()) is None: raise RuntimeError("no config provided and no implicit config available") return config def ensure_config_exists(self, args: Sequence[object]) -> None: """ Ensure a config is available, triggering autotuning if needed. If an implicit config is available (from configs list or default), it will be used. Otherwise, autotuning will be triggered with the provided args. """ if self._config is not None: return # Already have a config if (config := self._implicit_config()) is not None: with measure("BoundKernel.set_config"): self.set_config(config) else: with measure("BoundKernel.autotune"): self.autotune(args, force=False) # pyrefly: ignore [bad-return] def run_ref(self, *args: object) -> _R: # Unwrap ConstExpr arguments clean_args = [] for arg in args: if isinstance(arg, ConstExpr): clean_args.append(arg.value) else: clean_args.append(arg) # Pass the config to RefModeContext with RefModeContext(self.env, self._config): result = self.kernel.fn(*clean_args) return cast("_R", result) def __call__(self, *args: object) -> _R: """ Execute the kernel with the given arguments. Args: args: The arguments to pass to the kernel. Returns: _R: The result of the kernel execution. """ if ( self._cache_managed and self._reset_generation != self.kernel._reset_generation ): return self.kernel.bind(args)(*args) is_compiling = torch.compiler.is_compiling() if ( not is_compiling and self._cache_managed and (prepared := self._direct_prepared_call) is not None and prepared.bound is self and prepared.matches(self.kernel, args) and self._run is not None ): return self._run(*args) if self._cache_managed and self._compiler_seed_specialization_extractors: device_results: tuple[Hashable | None, ...] | None = None new_compiler_seed_device = False if self._compiler_seed_device_resolver is not None: device = self._compiler_seed_device_resolver(args) device_results = self._compiler_seed_device_results.get(device) if device_results is None: new_compiler_seed_device = True device_results = tuple( ( None if extractor.fact == "input_tensor_metadata" else extractor.for_device(device) ) for extractor in self._compiler_seed_specialization_extractors ) self._compiler_seed_device_results[device] = device_results compiler_seed_results = tuple( ( extractor(args) if extractor.fact == "input_tensor_metadata" else cast("tuple[Hashable | None, ...]", device_results)[index] ) for index, extractor in enumerate( self._compiler_seed_specialization_extractors ) ) if ( new_compiler_seed_device or compiler_seed_results != self._compiler_seed_specialization_results ): rebound = self.kernel.bind(args) if rebound is not self: return rebound(*args) if self._cache_managed and self.kernel._has_specialization_extras: rebound = self.kernel.bind(args) if rebound is not self: return rebound(*args) if self._run is None: with self._first_compile_lock: # Another caller may have discovered a late specialization while # this caller waited for the first compile. if self._cache_managed and self.kernel._has_specialization_extras: rebound = self.kernel.bind(args) if rebound is not self: return rebound(*args) if self._run is None: if is_ref_mode_enabled(self.kernel.settings): if (config := self._implicit_config()) is not None: self._config = config return self.run_ref(*args) runtime_args: dict[str, object] = { name: value for name, value in zip( self.kernel.signature.parameters, args, strict=False ) if isinstance(value, torch.Tensor) } with self.env.use_runtime_arg_values(runtime_args): self.ensure_config_exists(args) assert self._run is not None self.maybe_log_repro(log.warning, args) result = self._run(*args) if not is_compiling and self._cache_managed: self._prepare_direct_call(args) return result def _prepare_direct_call(self, args: tuple[object, ...]) -> None: """Publish a monomorphic fast path for repeated BoundKernel calls.""" run = self._run if ( run is None or self.kernel._key_fn is not None or not self.env.backend.supports_eager_prepared_call or not self.kernel._has_specialization_extras ): return try: fast_entry = self.kernel._fast_dispatch_key_and_guards(args) if fast_entry is None: return with self.kernel._bind_lock: if ( self._reset_generation != self.kernel._reset_generation or self.kernel._bind(args) is not self or self._run is not run ): return entry = self.kernel._prepare_dispatch_entry(args, self, fast_entry) if entry is not None and entry[0] is not None: self._direct_prepared_call = entry[0] except Exception: # Preparation runs after the real kernel call. It is optional and # must not turn a successful launch into a user-visible failure. return def backend_cache_key(self, config: ConfigLike | None = None) -> str | None: """ Return the backend cache key for the compiled kernel. For the Triton backend, this is the base32 encoding of the SHA-256 hash that Triton uses to cache compiled GPU binaries under ``TRITON_CACHE_DIR/<key>/``. For the CuTe backend, it is the base32 encoding of the SHA-256 hash of the compiled IR module, which names the ``CUTE_DSL_CACHE_DIR/<key>.mlir`` artifact. Args: config: The configuration to look up. Defaults to the implicit config. Returns: str | None: The cache key, or None if the kernel hasn't been JIT-compiled yet or the backend doesn't support cache keys. """ if config is None: config = self._require_implicit_config() config = self._normalized_config_copy(config) compiled_fn = self._compile_cache.get(config) if compiled_fn is None: return None return self.env.backend.compiled_cache_key(self, compiled_fn) def maybe_log_repro( self, log_func: Callable[[str], None], args: Sequence[object], config: Config | None = None, ) -> None: if not self.settings.print_repro: return effective_config = config or self._config assert effective_config is not None # Get kernel source try: raw_source = inspect.getsource(self.kernel.fn) source_lines = textwrap.dedent(raw_source).splitlines() # Skip decorator lines (including multi-line decorators) start_idx = 0 while start_idx < len(source_lines) and not source_lines[ start_idx ].lstrip().startswith("def "): start_idx += 1 kernel_body = "\n".join(source_lines[start_idx:]) except (OSError, TypeError): kernel_body = f"# Source unavailable for {self.kernel.fn.__module__}.{self.kernel.fn.__qualname__}" # Format decorator decorator = self.format_kernel_decorator(effective_config, self.settings) # Build output output_lines = [ "# === HELION KERNEL REPRO ===", "import helion", "import helion.language as hl", "import torch", "from torch._dynamo.testing import rand_strided", "", decorator, kernel_body, ] # Generate caller function if args: def _render_input_arg_assignment(name: str, value: object) -> list[str]: if isinstance(value, torch.Tensor): shape = tuple(int(d) for d in value.shape) stride = tuple(int(s) for s in value.stride()) device = str(value.device) dtype = str(value.dtype) lines = [ f"{name} = rand_strided({shape!r}, {stride!r}, dtype={dtype}, device={device!r})" ] if value.requires_grad: lines.append(f"{name}.requires_grad_(True)") return lines return [f"{name} = {value!r}"] sig_param_names = list(self.kernel.signature.parameters.keys()) assert len(args) == len(sig_param_names) output_lines.extend(["", "def helion_repro_caller():"]) output_lines.append(" torch.manual_seed(0)") arg_names: list[str] = [] for i, value in enumerate(args): var_name = sig_param_names[i] arg_names.append(var_name) # Add assignment lines with indentation for line in _render_input_arg_assignment(var_name, value): output_lines.append(f" {line}") # Add return statement call_args = ", ".join(arg_names) output_lines.append(f" return {self.kernel.name}({call_args})") output_lines.extend(["", "helion_repro_caller()"]) output_lines.append("# === END HELION KERNEL REPRO ===") repro_text = "\n" + "\n".join(output_lines) log_func(repro_text) class _KernelDecorator(Protocol): def __call__( self, fn: Callable[..., _R], ) -> Kernel[_R]: ... @overload def kernel( fn: Callable[..., _R], *, config: ConfigLike | None = None, configs: Sequence[ConfigLike] | None = None, key: Callable[..., Hashable] | None = None, cute_structural_policy: CuteStructuralPolicyRequest = DEFAULT_STRUCTURAL_POLICY, **settings: object, ) -> Kernel[_R]: ... @overload def kernel( fn: None = None, *, config: ConfigLike | None = None, configs: Sequence[ConfigLike] | None = None, key: Callable[..., Hashable] | None = None, cute_structural_policy: CuteStructuralPolicyRequest = DEFAULT_STRUCTURAL_POLICY, **settings: object, ) -> _KernelDecorator: ...
[docs] def kernel( fn: Callable[..., _R] | None = None, *, config: ConfigLike | None = None, configs: Sequence[ConfigLike] | None = None, key: Callable[..., Hashable] | None = None, cute_structural_policy: CuteStructuralPolicyRequest = DEFAULT_STRUCTURAL_POLICY, **settings: object, ) -> Kernel[_R] | _KernelDecorator: """ Decorator to create a Kernel object from a Python function. Args: fn: The function to be wrapped by the Kernel. If None, a decorator is returned. config: A single configuration to use for the kernel. Refer to the ``helion.Config`` class for details. configs: A list of configurations to use for the kernel. Can only specify one of config or configs. Refer to the ``helion.Config`` class for details. key: Optional callable returning a hashable that augments the specialization key. cute_structural_policy: Select the CuTe config schema before binding. Ordinary CuTe searches without recorded configs automatically enable proved structural passes while honoring explicit settings. None preserves legacy configs and cache identity; "auto" also permits selection for a no-search invocation. A recorded policy selects its exact schema. Existing unversioned declarations retain their axes. settings: Keyword arguments representing settings for the Kernel. Can also use settings=Settings(...) to pass a Settings object directly. Refer to the ``helion.Settings`` class for available options. Returns: object: A Kernel object or a decorator that returns a Kernel object. See Also: - :class:`~helion.Settings`: Controls compilation behavior and debugging options - :class:`~helion.Config`: Controls GPU execution parameters and optimization strategies """ if config is not None: assert not configs, "Cannot specify both config and configs" configs = [config] elif configs is None: configs = [] if settings_obj := settings.get("settings"): assert len(settings) == 1, "settings must be the only keyword argument" assert isinstance(settings_obj, Settings), "settings must be a Settings object" else: settings_obj = Settings(**settings) if fn is None: return functools.partial( kernel, configs=configs, settings=settings_obj, key=key, cute_structural_policy=cute_structural_policy, ) return Kernel( fn, configs=configs, settings=settings_obj, key=key, cute_structural_policy=cute_structural_policy, )
def _hashable_dim(s: int | torch.SymInt) -> Hashable: if isinstance(s, torch.SymInt): return (id(s.node.shape_env), s.node.expr) return s def _safe_bucket_dim(s: int | torch.SymInt) -> Hashable: if isinstance(s, torch.SymInt): return (id(s.node.shape_env), s.node.expr) # Dynamic-shape kernels should not get separate bound kernels for sizes # 0 or 1. Keep 2 as the canonical "dynamic dimension" bucket that was # already used for all concrete sizes >= 2. return 2 def _tensor_descriptor_extent_class(size: int, cap: int | None) -> int: """Largest power-of-two descriptor block that can fit this dimension.""" result = 0 if size <= 0 else 1 << (size.bit_length() - 1) return result if cap is None else min(result, cap) _EMPTY_FROZENSET: frozenset[int] = frozenset() def _bucketed_size(obj: torch.Tensor) -> tuple[Hashable, ...]: sz = obj.size() n = len(sz) if n == 1: return (_safe_bucket_dim(sz[0]),) if n == 2: return (_safe_bucket_dim(sz[0]), _safe_bucket_dim(sz[1])) if n == 3: return ( _safe_bucket_dim(sz[0]), _safe_bucket_dim(sz[1]), _safe_bucket_dim(sz[2]), ) return tuple(_safe_bucket_dim(s) for s in sz) def _hashable_dims(dims: Sequence[int | torch.SymInt]) -> tuple[Hashable, ...]: n = len(dims) if n == 1: return (_hashable_dim(dims[0]),) if n == 2: return (_hashable_dim(dims[0]), _hashable_dim(dims[1])) if n == 3: return (_hashable_dim(dims[0]), _hashable_dim(dims[1]), _hashable_dim(dims[2])) return tuple(_hashable_dim(s) for s in dims) def _concrete_tensor_key(fn: Kernel, obj: torch.Tensor) -> Hashable: # Fast extractor for plain ``torch.Tensor`` / ``torch.nn.Parameter``: # exact-type dispatch guarantees concrete int sizes/strides, so # ``torch.Size`` and the stride tuple can be used directly (both are # tuple subclasses that hash/compare identically to plain int tuples). # The ``_hashable_dims`` wrap in ``_tensor_key`` exists only to # normalize SymInts, which appear on FakeTensors during tracing. si = getattr(obj, "_dynamo_static_indices", None) static_indices = frozenset(si) if si is not None else _EMPTY_FROZENSET if fn.settings.static_shapes: return (obj.dtype, obj.size(), obj.stride(), static_indices) bucketed = _bucketed_size(obj) if fn.settings.index_dtype is None: try: needs_int64 = bool(obj.numel() > _INT32_INDEX_LIMIT) except RuntimeError: needs_int64 = True # unbacked SymInt return ( obj.dtype, bucketed, needs_int64, static_indices, ) return ( obj.dtype, bucketed, static_indices, ) def _tensor_key(fn: Kernel, obj: torch.Tensor) -> Hashable: si = getattr(obj, "_dynamo_static_indices", None) static_indices = frozenset(si) if si is not None else _EMPTY_FROZENSET if fn.settings.static_shapes: return ( obj.dtype, _hashable_dims(obj.size()), _hashable_dims(obj.stride()), static_indices, ) bucketed = _bucketed_size(obj) if fn.settings.index_dtype is None: try: needs_int64 = bool(obj.numel() > _INT32_INDEX_LIMIT) except RuntimeError: needs_int64 = True # unbacked SymInt return ( obj.dtype, bucketed, needs_int64, static_indices, ) return ( obj.dtype, bucketed, static_indices, ) def _sequence_key(fn: Kernel, obj: Sequence) -> Hashable: return type(obj), tuple([fn._specialization_key(item) for item in obj]) def _mapping_key( fn: Kernel, obj: dict[str | int, object], real_type: type[object] ) -> Hashable: return real_type, tuple( sorted((k, fn._specialization_key(v)) for k, v in obj.items()) ) def _number_key(fn: Kernel, n: float | bool) -> object: return type(n) def _function_key( fn: Kernel, obj: types.FunctionType, *, baked: bool = False ) -> object: """Specialization key of a callable argument: its code object, the key of every closure cell and the key of every default. A cell of the argument itself is a runtime input of the compiled kernel (``lift_closures`` lifts it, so it is read on each call) and keys like an argument of its type. Defaults are baked into the kernel when the lifted function is traced, so they key like constexprs through ``_constant_key``, and so does everything reachable from a default: with ``baked`` the function is itself a default (``lift_closures`` passes ``__defaults__`` through raw, so a default function is traced with its own cells, which are never lifted) and its cells key by value too. Module globals the function reads are not part of the key: like the kernel's own globals they are read from the module when the kernel runs and specialized once, on the first call's values.""" cells = obj.__closure__ or () defaults = obj.__defaults__ or () kwdefaults = obj.__kwdefaults__ or {} if not cells and not defaults and not kwdefaults: return obj.__code__ if baked: closures = tuple(_constant_key(fn, cell.cell_contents) for cell in cells) else: closures = tuple(fn._specialization_key(cell.cell_contents) for cell in cells) return ( obj.__code__, closures, tuple(_constant_key(fn, value) for value in defaults), tuple( sorted( (name, _constant_key(fn, value)) for name, value in kwdefaults.items() ) ), ) class _IdentityKey: """Hashable stand-in for a baked-in value that cannot key by its own equality (a tensor or a container holding one, whose ``==`` is not a bool; an unhashable object): equal only to a key of the same object. It keeps the object alive, so its identity cannot pass to another object while a cache holds the key.""" __slots__ = ("value",) def __init__(self, value: object) -> None: self.value = value def __hash__(self) -> int: return id(self.value) def __eq__(self, other: object) -> bool: return type(other) is _IdentityKey and other.value is self.value def __repr__(self) -> str: return f"_IdentityKey({self.value!r})" def _constant_key(fn: Kernel, value: object) -> Hashable: """Key of a value baked into the compiled kernel: by value through the constexpr rules (numbers, strings, dtypes, containers of them), a function through ``_function_key`` with its cells baked too (a lambda default rebuilt per call keys like one passed as an argument), any other hashable value with no tensor inside by its own equality (a frozenset, a namedtuple, an enum member, a frozen dataclass, a bound method), else by identity (a tensor or a container holding one, an unhashable object). Equality here is Python's: a frozenset, namedtuple or frozen dataclass holding ``1``, ``1.0`` or ``True`` (or ``0.0`` and ``-0.0``) shares one specialization although the baked constant differs (``1.0`` casts an int32 input through float32), the same merge a constexpr argument holding that container makes. Only a default that is itself a function is read through ``_function_key``: a function inside a container default keys by identity with the container, so its cells are not re-read (a ``nonlocal`` rebind reuses the old kernel) and a lambda rebuilt per call inside a tuple is a new specialization each call.""" if type(value) is types.FunctionType: return cast("Hashable", _function_key(fn, value, baked=True)) nested = _nested_fast_dispatch_key(value, specialize_numeric=True) if nested is not None: return nested[0] try: hash(value) except TypeError: return _IdentityKey(value) if _contains_tensor(value, set()): return _IdentityKey(value) return (type(value), cast("Hashable", value)) def _contains_tensor(value: object, visited: set[int]) -> bool: """Whether a ``torch.Tensor`` is reachable from ``value`` through tuples, lists, sets, frozensets, dict keys and values, dataclass fields or a bound method's ``__self__``. ``visited`` holds the ids of the objects already walked (every object reachable from ``value`` stays alive for the walk), so a cycle such as a dataclass holding itself ends.""" if isinstance(value, torch.Tensor): return True if id(value) in visited: return False visited.add(id(value)) if isinstance(value, (tuple, list, set, frozenset)): return any(_contains_tensor(item, visited) for item in value) if isinstance(value, dict): return any( _contains_tensor(key, visited) or _contains_tensor(item, visited) for key, item in value.items() ) if dataclasses.is_dataclass(value) and not isinstance(value, type): return any( _contains_tensor(getattr(value, field.name), visited) for field in dataclasses.fields(value) ) if isinstance(value, types.MethodType): return _contains_tensor(value.__self__, visited) return False def tensor_property_value( source: TensorPropertySource, root_values: dict[str, object] ) -> int | None: tensor = _replay_tensor_input_source(source.base, root_values) if not isinstance(tensor, torch.Tensor): return None if source.prop == TensorProperty.STORAGE_OFFSET: return int(tensor.storage_offset()) assert source.idx is not None if source.prop == TensorProperty.SIZE: return int(tensor.size(source.idx)) return int(tensor.stride(source.idx)) def evaluate_sympy_expr(expr: sympy.Expr, values: dict[sympy.Symbol, int]) -> int: if isinstance(expr, sympy.Symbol): return values[expr] if isinstance(expr, sympy.Integer): return int(expr) return int(expr.xreplace(values)) @dataclasses.dataclass(frozen=True) class _GuardedInputFact: """Whether a guard ``lhs == rhs`` (or ``!=`` without ``equal``) still holds. Both sides are expressions of input sizes/strides; ``sources`` names the input behind each symbol. With ``block`` the guarded symbol was a reduction block, whose value is ``next_power_of_2`` of the extent ``lhs``. """ lhs: sympy.Expr rhs: sympy.Expr sources: tuple[tuple[sympy.Symbol, TensorPropertySource], ...] block: bool equal: bool param_names: tuple[str, ...] def _holds(self, values: dict[sympy.Symbol, int]) -> bool: lhs = evaluate_sympy_expr(self.lhs, values) same = (next_power_of_2(lhs) if self.block else lhs) == evaluate_sympy_expr( self.rhs, values ) # Branch rather than compare: traced by dynamo the sizes are SymInts, # and their SymBool compared with a bool nests an Eq the shape env # cannot evaluate, while a branch guards on it. return self.equal if same else not self.equal def __call__(self, args: Sequence[object]) -> Hashable: root_values = dict(zip(self.param_names, args, strict=False)) values: dict[sympy.Symbol, int] = {} for symbol, source in self.sources: if (value := tensor_property_value(source, root_values)) is None: return None values[symbol] = value return self._holds(values) def bound_value(self, env: CompileEnvironment) -> Hashable: """This fact at ``env``'s bind-time hints (None when not recoverable).""" symbols = { source: symbol for symbol, sources in env.shape_env.var_to_sources.items() for source in sources } hints = shape_env_var_hints(env.shape_env) values: dict[sympy.Symbol, int] = {} for symbol, source in self.sources: if ( bound_symbol := symbols.get(source) ) is None or bound_symbol not in hints: return None values[symbol] = int(hints[bound_symbol]) return self._holds(values) def _bound_guarded_input_facts( bound: BoundKernel, extractors: Sequence[Callable[[Sequence[object]], Hashable]], ) -> tuple[Hashable, ...] | None: """Guard-fact results for a cached bound, or None if any is not a guard fact.""" results: list[Hashable] = [] for extractor in extractors: if not ( isinstance(extractor, PreparedMetadataSpecializationExtractor) and isinstance(fact := extractor.extractor, _GuardedInputFact) ): return None if (result := fact.bound_value(bound.env)) is None: return None results.append(result) return tuple(results) def _graph_module_key(fn: Kernel, obj: torch.fx.GraphModule) -> Hashable: """Generate a specialization key for GraphModule arguments.""" # Check if already cached if obj in _graph_module_hash_cache: return _graph_module_hash_cache[obj] # Check for unsupported operations unsupported_ops = { node.op for node in itertools.chain( obj.graph.find_nodes(op="call_module"), obj.graph.find_nodes(op="get_attr"), ) } if unsupported_ops: raise exc.GraphModuleUnsupportedOps(", ".join(sorted(unsupported_ops))) _graph_module_hash_cache[obj] = rv = str(compiled_fx_graph_hash(obj, [], {}, [])) return rv _specialization_extractors: dict[ type[object] | str, Callable[[Kernel, object], Hashable], # pyrefly: ignore [bad-assignment] ] = { # Exact-type dispatch (see ``_specialization_key``): plain tensors and # Parameters always have concrete int sizes/strides and take the fast # extractor. Subclasses (FakeTensor below, or anything hitting the # ``isinstance`` fallback) go through SymInt-safe ``_tensor_key``. torch.Tensor: _concrete_tensor_key, torch.nn.Parameter: _concrete_tensor_key, FakeTensor: _tensor_key, # SymInt-safe extractor for torch.Tensor subclasses reached via the # isinstance fallback in ``_specialization_key`` (string key so the # fallback stays loosely typed, like "namedtuple" / "dataclass"). "tensor_subclass": _tensor_key, torch.dtype: lambda fn, x: x, torch.device: lambda fn, x: x, int: _number_key, float: _number_key, bool: _number_key, str: lambda fn, x: x, list: _sequence_key, tuple: _sequence_key, # pyrefly: ignore [bad-argument-type] dict: lambda fn, x: _mapping_key(fn, x, type(x)), # pyrefly: ignore [missing-attribute] "namedtuple": lambda fn, x: _mapping_key(fn, x._asdict(), type(x)), # pyrefly: ignore [no-matching-overload, bad-argument-type] "dataclass": lambda fn, x: _mapping_key(fn, dataclasses.asdict(x), type(x)), types.FunctionType: _function_key, types.BuiltinFunctionType: lambda fn, x: x, torch.fx.GraphModule: _graph_module_key, # pyrefly: ignore [missing-attribute] ConstExpr: lambda fn, x: x.value, type(None): lambda fn, x: None, } def maybe_skip_dtype_check_in_meta_registrations() -> contextlib.AbstractContextManager[ None, None ]: # pyrefly: ignore [implicit-import] if hasattr(torch.fx.experimental._config, "skip_dtype_check_in_meta_registrations"): # pyrefly: ignore [implicit-import, missing-attribute] return torch.fx.experimental._config.patch( skip_dtype_check_in_meta_registrations=True ) return contextlib.nullcontext()