"""
Search space analysis and logging for the Helion autotuner.
This module provides tools to analyze and log the valid search space
for autotuning, including:
- Which config keys are enabled/disabled and why
- The size of each search dimension
- Total search space size (when computable)
- Coverage metrics (configs tested vs. total space)
- Per-feature exploration tracking
"""
from __future__ import annotations
import dataclasses
import hashlib
import json
import logging
from pathlib import Path
import re
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Mapping
from ..autotuner.config_spec import ConfigSpec
from ..autotuner.config_spec import SearchDimensionInfo
from ..runtime.config import Config
log = logging.getLogger(__name__)
[docs]
def canonical_config_id(config: Config) -> str:
"""Stable 16-hex id for a config: sha256 of its canonical (sorted) JSON.
The same config always maps to the same id, so it is safe as a set key for
counting distinct configs. Shared with the autotuner dataset logger.
"""
canonical = json.dumps(config.config, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
[docs]
@dataclasses.dataclass
class DimensionStats:
"""One search dimension: its possible values and what was observed.
Merges the static description of a dimension (cardinality / enumerable
values / why it is constrained) with the dynamic exploration record
(distinct observed values), so coverage is computed from a single object.
"""
name: str
dim_type: str # "discrete" | "categorical"
cardinality: int # number of possible values (0 == inapplicable)
possible_values: list[object] | None = None # explicit values if enumerable
constrained_by: str | None = None
observed_values: set[object] = dataclasses.field(default_factory=set)
[docs]
def observe(self, value: object) -> None:
try:
self.observed_values.add(value)
except TypeError:
# unhashable (e.g. nested list) -- coerce to repr for tracking
self.observed_values.add(repr(value))
@property
def observed_count(self) -> int:
"""Distinct observed values, clamped to ``cardinality``.
The observed set can exceed a projected-size estimate, so clamp to keep
the displayed fraction sane (never ``7/4``).
"""
if not self.cardinality:
return len(self.observed_values)
return min(len(self.observed_values), self.cardinality)
@property
def coverage_percent(self) -> float:
if not self.cardinality:
return 0.0
return (self.observed_count / self.cardinality) * 100
@property
def applicable(self) -> bool:
return self.cardinality > 0
[docs]
def to_dict(self) -> dict[str, object]:
return {
"name": self.name,
"type": self.dim_type,
"cardinality": self.cardinality,
"possible_values": (
self.possible_values if self.cardinality <= 100 else None
),
"constrained_by": self.constrained_by,
"observed_count": self.observed_count,
"observed_values": sorted(self.observed_values, key=repr),
"coverage_percent": round(self.coverage_percent, 2),
}
[docs]
def to_summary_line(self) -> str:
if not self.cardinality:
return f"{self.name}: no autotunable choices for this kernel"
return (
f"{self.name}: {self.observed_count}/{self.cardinality} "
f"options tested ({self.coverage_percent:.1f}%)"
)
[docs]
@dataclasses.dataclass
class SearchSpaceReport:
"""Unified search-space + exploration report for one kernel.
Combines the search-space description (identity, dimensions, restrictions,
total size) with the exploration outcome (configs attempted/valid/invalid,
timing, algorithm) and owns serialization + logging + saving.
"""
# Identity
kernel_name: str
specialization_key: str | None
backend: str
hardware: str | None
# Search space structure
dimensions: list[DimensionStats]
total_search_space_size: int | None # exact product, or None if unknown
disabled_features: list[str] # "feature: reason" strings
shape_constraints: list[str]
# Exploration outcome (populated at finish()). ``configs_tested`` counts
# distinct configs (by canonical id) and is the denominator for coverage.
# ``explored_valid``/``explored_invalid`` are raw attempt counts (a config
# re-attempted across generations counts each time), kept on the same scale
# so ``explored_valid_percent`` is meaningful.
search_algorithm: str = ""
elapsed_seconds: float = 0.0
configs_tested: int = 0
explored_valid: int = 0
explored_invalid: int = 0
@property
def enabled_features(self) -> list[str]:
return [d.name for d in self.dimensions]
@property
def explored_total(self) -> int:
return self.explored_valid + self.explored_invalid
@property
def explored_valid_percent(self) -> float:
total = self.explored_total
return (self.explored_valid / total) * 100 if total else 0.0
@property
def applicable_dimensions(self) -> list[DimensionStats]:
return [d for d in self.dimensions if d.applicable]
@property
def avg_feature_coverage(self) -> float:
applicable = self.applicable_dimensions
if not applicable:
return 0.0
return sum(d.coverage_percent for d in applicable) / len(applicable)
@property
def min_feature_coverage(self) -> float:
applicable = self.applicable_dimensions
return min((d.coverage_percent for d in applicable), default=0.0)
[docs]
def to_dict(self) -> dict[str, object]:
return {
"kernel_name": self.kernel_name,
"specialization_key": self.specialization_key,
"backend": self.backend,
"hardware": self.hardware,
"total_search_space_size": (
str(self.total_search_space_size)
if self.total_search_space_size is not None
else "unknown"
),
"search_algorithm": self.search_algorithm,
"elapsed_seconds": self.elapsed_seconds,
"configs_tested": self.configs_tested,
"explored_valid": self.explored_valid,
"explored_invalid": self.explored_invalid,
"explored_total": self.explored_total,
"explored_valid_percent": round(self.explored_valid_percent, 2),
"coverage_percent": (
round((self.configs_tested / self.total_search_space_size) * 100, 6)
if self.total_search_space_size
else None
),
"avg_feature_coverage": round(self.avg_feature_coverage, 2),
"min_feature_coverage": round(self.min_feature_coverage, 2),
"dimensions": [d.to_dict() for d in self.dimensions],
"disabled_features": self.disabled_features,
"shape_constraints": self.shape_constraints,
}
[docs]
def to_json(self) -> str:
return json.dumps(self.to_dict(), indent=2, default=str)
[docs]
def log_summary(self, logger: logging.Logger, level: int = logging.INFO) -> None:
"""Log a human-readable search-space + exploration summary."""
size_str = (
f"{self.total_search_space_size:,}"
if self.total_search_space_size is not None
else "unknown"
)
logger.log(level, f"Search space for {self.kernel_name}:")
logger.log(
level, f" Backend: {self.backend}, Hardware: {self.hardware or 'unknown'}"
)
logger.log(level, f" Total search space size: {size_str}")
logger.log(level, f" Search dimensions: {len(self.dimensions)}")
if self.disabled_features:
logger.log(level, f" Disabled features ({len(self.disabled_features)}):")
# Collapse features disabled solely because the selected backend
# doesn't support them into one line; list specific reasons.
generic_suffix = f": Not supported by {self.backend} backend"
backend_specific = [
feat[: -len(generic_suffix)]
for feat in self.disabled_features
if feat.endswith(generic_suffix)
]
other = [
feat
for feat in self.disabled_features
if not feat.endswith(generic_suffix)
]
for feat in other[:10]:
logger.log(level, f" - {feat}")
if len(other) > 10:
logger.log(level, f" ... and {len(other) - 10} more")
if backend_specific:
logger.log(
level,
f" - {len(backend_specific)} feature(s) not supported by "
f"{self.backend} backend (e.g. {', '.join(backend_specific[:3])})",
)
if self.shape_constraints:
logger.log(level, f" Shape constraints ({len(self.shape_constraints)}):")
for constraint in self.shape_constraints[:5]:
logger.log(level, f" - {constraint}")
if len(self.shape_constraints) > 5:
logger.log(level, f" ... and {len(self.shape_constraints) - 5} more")
if not self.search_algorithm:
return
# Exploration outcome.
logger.log(level, f" Search algorithm: {self.search_algorithm}")
logger.log(
level,
f" Time: {self.elapsed_seconds:.1f}s, "
f"Configs tested: {self.configs_tested:,}",
)
if self.explored_total > 0:
logger.log(
level,
f" Configs attempted: {self.explored_total:,} "
f"({self.explored_valid:,} valid, "
f"{self.explored_invalid:,} invalid, "
f"{self.explored_valid_percent:.1f}% valid)",
)
if self.total_search_space_size:
coverage = (self.configs_tested / self.total_search_space_size) * 100
logger.log(level, f" Overall search space coverage: {coverage:.6f}%")
logger.log(level, " Per-feature exploration:")
logger.log(
level, f" Average feature coverage: {self.avg_feature_coverage:.1f}%"
)
logger.log(
level, f" Minimum feature coverage: {self.min_feature_coverage:.1f}%"
)
for dim in sorted(self.dimensions, key=lambda d: d.coverage_percent):
logger.log(level, f" - {dim.to_summary_line()}")
poor = [d for d in self.applicable_dimensions if d.coverage_percent < 50.0]
if poor:
logger.log(level, "\n Features with <50% exploration:")
for dim in poor:
logger.log(
level,
f" - {dim.name}: only {dim.observed_count} "
f"of {dim.cardinality} values tested",
)
[docs]
def save(self, output_path: str, cache_hash: str | None = None) -> str:
"""Best-effort write of the report as one JSON document.
Returns the written path, or an empty string on failure. Never raises:
search-space logging is diagnostic and must not crash the autotuner.
"""
try:
path = resolve_report_path(
output_path,
kernel_name=self.kernel_name,
cache_hash=cache_hash,
)
path.write_text(self.to_json())
return str(path)
except Exception:
log.debug(
"Failed to save search space report to %r", output_path, exc_info=True
)
return ""
[docs]
class SearchSpaceTracker:
"""Record which config values are tested during autotuning.
Owns the exploration counters and feeds observed values into the report's
:class:`DimensionStats`. ``finish()`` returns the completed report.
"""
[docs]
def __init__(self, report: SearchSpaceReport) -> None:
self.report = report
self._dimensions = {d.name: d for d in report.dimensions}
self._seen_keys: set[str] = set()
# Raw count of valid configs recorded (duplicates counted), kept on the
# same scale as invalid_config_count for the validity breakdown.
self.valid_config_count: int = 0
self.invalid_config_count: int = 0
[docs]
def record_config(self, config: Config) -> None:
"""Record a tested (valid) configuration and its observed values."""
self.valid_config_count += 1
self._seen_keys.add(canonical_config_id(config))
for name, dim in self._dimensions.items():
value = _extract_feature_value(config, name)
if value is not None:
dim.observe(value)
[docs]
def record_invalid(self, count: int = 1) -> None:
"""Record ``count`` candidate configs rejected as InvalidConfig."""
if count > 0:
self.invalid_config_count += count
[docs]
def finish(
self, search_algorithm: str, elapsed_seconds: float
) -> SearchSpaceReport:
"""Populate the report's exploration outcome and return it."""
self.report.search_algorithm = search_algorithm
self.report.elapsed_seconds = elapsed_seconds
# configs_tested = distinct configs (coverage denominator);
# explored_valid/invalid = raw attempt counts (validity breakdown).
self.report.configs_tested = len(self._seen_keys)
self.report.explored_valid = self.valid_config_count
self.report.explored_invalid = self.invalid_config_count
return self.report
def _extract_feature_value(config: Config, feature_name: str) -> object:
"""Extract a feature value from a Config object (None if not applicable)."""
# List-valued config attributes are coerced to a hashable tuple.
if feature_name in ("block_sizes", "loop_orders", "l2_groupings", "flatten_loops"):
return tuple(getattr(config, feature_name))
# ``pallas_loop_type`` is stored in the config dict, not as an attribute.
if feature_name == "pallas_loop_type":
return config.get("pallas_loop_type")
# Generic scalar tunables (pid_type, num_warps, num_stages, maxnreg, ...).
return getattr(config, feature_name, None)
[docs]
def analyze_search_space(
config_spec: ConfigSpec,
kernel_name: str = "",
specialization_key: str | None = None,
hardware: str | None = None,
config_overrides: Mapping[str, object] | None = None,
advanced_controls_files: list[str] | None = None,
) -> SearchSpaceReport:
"""Analyze the valid search space for a kernel's config spec.
This examines which features are enabled/disabled based on:
- Backend capabilities (via supports_config_key)
- Hardware constraints (e.g., maxnreg only on CUDA)
- Kernel properties (e.g., epilogue_subtile only for matmul-like)
- Shape-dependent constraints (e.g., block_size limits)
Args:
config_spec: The configuration specification to analyze
kernel_name: Optional kernel name for logging
specialization_key: Optional specialization key
hardware: Optional hardware identifier
config_overrides: ``autotune_config_overrides`` (key -> pinned value).
A field pinned here is frozen during search, so its effective
cardinality is 1; the report clamps it accordingly instead of
reporting the fragment's full static range.
advanced_controls_files: ``autotune_search_acf``. When non-empty this is
an extra enum dimension the searchers explore (the listed files plus
the implicit default ``""``); it lives outside ``_flat_fields()`` so
it is added here explicitly.
Returns:
A SearchSpaceReport describing the valid search space
"""
from ..autotuner.config_spec import VALID_KEYS
from ..autotuner.config_spec import VALID_PID_TYPES
overrides = dict(config_overrides or {})
dimensions: list[DimensionStats] = []
disabled_features: list[str] = []
shape_constraints: list[str] = []
flat_fields = config_spec._flat_fields()
for info in config_spec.iter_search_dimensions():
dim = _dimension_from_info(info, config_spec)
if dim is not None:
if info.name in overrides:
_apply_override(dim, overrides[info.name])
dimensions.append(dim)
# advanced_controls_file is an autotunable enum (the configured ACF paths
# plus the implicit default "") that lives outside _flat_fields(), so
# iter_search_dimensions() never yields it. Add it here so the reported
# total reflects the space the searchers actually explore.
acf_dim = _advanced_controls_file_dimension(advanced_controls_files)
if acf_dim is not None:
if "advanced_controls_file" in overrides:
_apply_override(acf_dim, overrides["advanced_controls_file"])
dimensions.append(acf_dim)
for key in sorted(VALID_KEYS):
if key in flat_fields:
continue
if config_spec.supports_config_key(key):
# Supported but not materialized as a tunable field for this kernel.
continue
reason = _get_disable_reason(config_spec, key)
disabled_features.append(f"{key}: {reason}")
# Check shape-dependent constraints
if config_spec.block_sizes:
for i, spec in enumerate(config_spec.block_sizes):
if spec.autotuner_min != spec.min_size:
shape_constraints.append(
f"block_size[{i}] autotuner range constrained to "
f"[{spec.autotuner_min}, {spec.max_size}] "
f"(natural min_size={spec.min_size})"
)
# Surface pid_types that were disabled for this kernel. pid_type stays an
# enabled feature (some values remain), so this won't appear in
# disabled_features; report it as a constraint instead.
if config_spec.supports_config_key("pid_type"):
disabled_pid_types = [
pt for pt in VALID_PID_TYPES if pt not in config_spec.allowed_pid_types
]
if disabled_pid_types:
# Only annotate pid_types that are *currently* disabled. Iterating
# disabled_pid_types (derived from allowed_pid_types) means a stale
# reason left for a later re-allowed pid_type is ignored, so the two
# structures can't drift into wrong output.
reasons = config_spec.disallowed_pid_type_reasons
disabled_desc = ", ".join(
f"{pt} ({reasons[pt]})" if pt in reasons else pt
for pt in disabled_pid_types
)
shape_constraints.append(
f"pid_type restricted to {list(config_spec.allowed_pid_types)} "
f"(disabled: {disabled_desc})"
)
# Surface non-pid_type search-space restrictions (e.g. tcgen05 cluster_m /
# ab_stages / narrowing) recorded as (feature, reason) pairs at compile time.
for feature, reason in getattr(config_spec, "restriction_reasons", []):
shape_constraints.append(f"{feature} ({reason})")
if config_spec.cute_flash_search_enabled:
shape_constraints.append(
"CuTe flash attention search enabled (restricted surface)"
)
if config_spec.epilogue_subtile_autotune_choices is not None:
shape_constraints.append(
f"epilogue_subtile enabled for k_hint={config_spec.epilogue_subtile_k_hint}"
)
# Total search space size: exact product of per-dimension cardinalities.
# A dimension whose cardinality is unknown (0 sentinel from a fragment that
# can't report one) makes the total unknown; otherwise the product is exact
# (Python big ints, so large attention-style spaces report a real number
# rather than being truncated).
product = 1
total_size: int | None = None
if all(dim.cardinality != 0 for dim in dimensions):
for dim in dimensions:
product *= dim.cardinality
total_size = product
return SearchSpaceReport(
kernel_name=kernel_name,
specialization_key=specialization_key,
backend=config_spec.backend_name,
hardware=hardware,
dimensions=dimensions,
total_search_space_size=total_size,
disabled_features=disabled_features,
shape_constraints=shape_constraints,
)
def _dimension_from_info(
info: SearchDimensionInfo,
config_spec: ConfigSpec,
) -> DimensionStats | None:
"""Build a :class:`DimensionStats` from a spec-provided dimension.
Cardinality and values come from the config spec (fragment-derived); this
only attaches the human-readable ``constrained_by`` annotation, which is
spec-state specific and not encoded in the fragment itself.
"""
from ..autotuner.config_spec import VALID_PID_TYPES
cardinality = info.cardinality if info.cardinality is not None else 0
values = info.values if info.values is not None and cardinality <= 100 else None
constrained_by: str | None = None
if info.name == "block_sizes" and config_spec.tensor_numel_constraints:
constrained_by = "tensor numel constraints"
elif info.name == "pid_type":
disabled = [
pt for pt in VALID_PID_TYPES if pt not in config_spec.allowed_pid_types
]
if disabled:
constrained_by = (
f"{len(disabled)} pid_type(s) disabled by kernel "
f"({', '.join(disabled)})"
)
elif info.is_sequence and info.num_items:
constrained_by = f"{info.num_items} loop(s)"
return DimensionStats(
name=info.name,
dim_type="discrete" if info.is_sequence else "categorical",
cardinality=cardinality,
possible_values=values,
constrained_by=constrained_by,
)
def _apply_override(dim: DimensionStats, value: object) -> None:
"""Freeze a dimension to a single pinned value from config overrides.
An overridden field is not mutated during search, so its effective
cardinality is 1. Clamp the reported dimension to match and note why.
"""
dim.cardinality = 1
dim.possible_values = [value]
dim.constrained_by = "pinned by autotune_config_overrides"
def _advanced_controls_file_dimension(
advanced_controls_files: list[str] | None,
) -> DimensionStats | None:
"""Describe the advanced_controls_file enum dimension, if searched.
Mirrors ``ConfigSpec._advanced_controls_file_fragment``: an empty/absent
list disables ACF search; otherwise the searched values are the listed
files plus the implicit default ``""`` (deduplicated).
"""
if not advanced_controls_files:
return None
values: list[object] = list(dict.fromkeys([*advanced_controls_files, ""]))
return DimensionStats(
name="advanced_controls_file",
dim_type="categorical",
cardinality=len(values),
possible_values=values,
)
def _get_disable_reason(config_spec: ConfigSpec, key: str) -> str:
"""Get human-readable reason why a config key is disabled."""
backend_name = config_spec.backend_name
if backend_name == "pallas":
if key in ("num_warps", "num_stages"):
return "Pallas backend (handled by XLA)"
elif backend_name == "triton":
if key == "pallas_loop_type":
return "Triton backend (no Pallas loops)"
if key == "pallas_pre_broadcast":
return "Triton backend"
if key == "num_threads":
return "Triton backend (uses num_warps)"
elif backend_name == "cute":
if key == "num_threads" and not config_spec.target_device_capability:
return "CuTe requires CUDA target"
if key == "epilogue_subtile":
if not config_spec.epilogue_subtile_candidate_enabled:
return "Not a matmul-like kernel"
if config_spec.epilogue_subtile_k_hint < 1024:
return f"k_hint={config_spec.epilogue_subtile_k_hint} too small (<1024)"
if key == "pallas_loop_type":
if not config_spec.has_pallas_inner_loops:
return "No Pallas inner loops in kernel"
# Check if it's a backend-specific key
from ..autotuner.config_spec import BACKEND_SPECIFIC_KEYS
if key in BACKEND_SPECIFIC_KEYS:
return f"Not supported by {backend_name} backend"
return f"Not supported by {backend_name} backend"
[docs]
def log_search_space_comparison(
logger: logging.Logger,
report: SearchSpaceReport,
) -> None:
"""Log a search-space vs. searched comparison banner.
All figures come from ``report`` so the banner, the report summary, and the
saved JSON never disagree.
"""
logger.info("=" * 60)
logger.info("Autotune Search Space Analysis")
logger.info("=" * 60)
report.log_summary(logger, logging.INFO)
total = report.total_search_space_size
configs_tested = report.configs_tested
logger.info("\nSearch Coverage:")
logger.info(f" Configs tested: {configs_tested:,}")
if total is not None and total > 0:
coverage = (configs_tested / total) * 100
logger.info(f" Total space: {total:,}")
logger.info(f" Coverage: {coverage:.6f}%")
logger.info(f" Search algorithm: {report.search_algorithm}")
logger.info(f" Time elapsed: {report.elapsed_seconds:.1f}s")
else:
logger.info(
" Total space: unknown (a dimension's cardinality could not be determined)"
)
logger.info(f" Search algorithm: {report.search_algorithm}")
logger.info(f" Time elapsed: {report.elapsed_seconds:.1f}s")
logger.info("=" * 60)
[docs]
def resolve_report_path(
output_path: str,
kernel_name: str,
cache_hash: str | None,
default_filename: str = "autotune_search_space.json",
) -> Path:
"""Resolve ``output_path`` to a per-kernel report file path.
Directory paths (existing or trailing-separator) get ``default_filename``.
The kernel name and the autotuner's stable cache hash are injected into the
filename stem so each kernel/shape writes a distinct file (matching the
``.best_config`` cache key). Re-tuning the same kernel/shape reuses the hash
and intentionally rewrites its file; without a hash, a numeric suffix guards
against clobbering an unrelated file.
Example: ``analysis.json`` -> ``analysis.my_kernel.3f9a1c2e.json``.
"""
def token(value: str) -> str:
return re.sub(r"[^0-9A-Za-z._-]+", "_", value).strip("._-")[:64]
path = Path(output_path)
if path.is_dir() or output_path.endswith(("/", "\\")):
path = path / default_filename
path.parent.mkdir(parents=True, exist_ok=True)
parts = [
path.stem,
*(t for t in (token(kernel_name), token(cache_hash or "")) if t),
]
candidate = path.with_name(f"{'.'.join(parts)}{path.suffix}")
if token(cache_hash or ""):
return candidate
counter = 1
while candidate.exists():
candidate = path.with_name(f"{'.'.join(parts)}.{counter}{path.suffix}")
counter += 1
return candidate