Files
TheRock/rocm-libraries/projects/hipblaslt/tensilelite/Tensile/ExperimentalLibrary.py
T
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

1865 lines
73 KiB
Python

# Copyright Advanced Micro Devices, Inc., or its affiliates.
# SPDX-License-Identifier: MIT
"""Feature-agnostic developer tool for benchmarking new TensileLite codegen
solution parameters in hipBLASLt.
This tool takes a known TensileLite solution config, toggles one or more *new*
codegen solution parameter(s) on, and produces an experimental,
hipBLASLt-loadable device library (``TensileLibrary_lazy_<arch>.dat`` + code
objects). The resulting library can be loaded at runtime via
``HIPBLASLT_TENSILE_LIBPATH`` and benchmarked with ``hipblaslt-bench`` to
compare a feature across branches.
``gen-logic`` benchmarks on real hardware: winner selection during logic
analysis uses measured GFLOPS. It therefore REQUIRES a GPU of the target
``--arch`` to be present on the host and fails fast otherwise (it does not fall
back to synthetic performance data, which would make forked solutions tie and
silently drop all but the first). Cross-arch generation without benchmarking is
intentionally not supported here.
Pipeline (each stage verifies its own output artifact; nothing is produced
silently half-built):
list-solutions
Filter the solutions in one or more shipped ``3_LibraryLogic``
yaml file(s)/dir(s) by parameter (e.g. ``--where StreamK=5``) to
discover which indices to extract.
extract Reverse a shipped ``3_LibraryLogic`` yaml into a benchmark config
(wraps ``Tensile.TensileLibLogicToYaml``).
merge Combine several per-solution ``extract`` configs into one
multi-problem config so a whole family rebuilds into one library.
augment Validate ``--set NAME=v1[,v2]`` against the canonical
``validParameters`` registry and inject/override them into the
config's ForkParameters; stage the result under an
``Experimental/<feature>/`` tree.
gen-logic Run the Tensile benchmark+analyze flow to emit ``3_LibraryLogic``
and classify failures: kernel-generation error vs. all-solutions
rejected by Solution validation vs. an unedited placeholder
``ProblemSizes`` left over from extracting a ``Prediction``-type
source. Benchmarks on the target-arch GPU (fails fast if that
arch is not present on the host).
build-lib Run ``Tensile.TensileCreateLibrary --experimental`` to turn the
staged logic into a loadable device library.
patch-logic
Override ``--set`` params on shipped ``3_LibraryLogic`` solutions
(optionally narrowed by ``--where``) and build a matched
baseline/patched library pair for an A/B experiment -- no
re-benchmark (works for Origami/learned-rule logic that
``gen-logic`` cannot reproduce).
find-index Run ``hipblaslt-bench --algo_method all`` to discover the solution
indices reachable in the experimental library.
bench Run ``hipblaslt-bench --algo_method index --solution_index N``.
pipeline Chain augment -> gen-logic -> build-lib, short-circuiting on the
first failing stage.
Hardware notes:
* ``gen-logic`` passes ``--gpu-targets <arch>`` and runs the real client, so a
GPU of ``<arch>`` must be present on the host; winner selection uses the
measured GFLOPS. The stage refuses to run on non-matching hardware.
* ``createLibraryLogic`` still calls ``getCUCount()`` which shells to
``rocminfo`` UNLESS the ``CU`` env var is set, so ``gen-logic`` sets it
from ``--cu`` to pin the CU count used for the build predicate.
Example (StreamKFixupTreeReduction, gfx950) -- see ``--help`` of each
subcommand:
python -m Tensile.ExperimentalLibrary extract \\
--logic <shipped>/gfx950/.../<liblogic>.yaml --indices 0 --out base.yaml
python -m Tensile.ExperimentalLibrary pipeline \\
--config base.yaml --set StreamKFixupTreeReduction=1 \\
--set StreamK=3 --feature-name streamk_treereduce \\
--arch gfx950 --cu 256 --out work/
A/B family example -- rebuild every StreamK==5 solution from a shipped logic
with a new feature toggled OFF *and* ON inside a single library, so the two can
be contrasted by solution index (StreamKWorkStealing is illustrative; use any
real parameter, and --skip-validation for a parameter not yet in the registry):
IDX=$(python -m Tensile.ExperimentalLibrary list-solutions \\
--logic-src <shipped>/.../<liblogic>.yaml --where StreamK=5 --indices-only)
python -m Tensile.ExperimentalLibrary extract \\
--logic <shipped>/.../<liblogic>.yaml --indices "$IDX" --out sk5/base.yaml
python -m Tensile.ExperimentalLibrary merge \\
--configs sk5/base*.yaml --out sk5/merged.yaml --feature-name sk5_ws
# (``base*.yaml`` matches both ``base.yaml`` for a single index and
# ``base_<idx>.yaml`` for two or more.)
python -m Tensile.ExperimentalLibrary pipeline \\
--config sk5/merged.yaml --set StreamKWorkStealing=0,1 \\
--feature-name sk5_ws --arch gfx950 --cu 256 --out work/ --skip-validation
Omit the ``--set`` / use ``gen-logic`` + ``build-lib`` directly to just rebuild
the selected family as-is into one experimental library.
Origami/Prediction-logic A/B example -- toggle a parameter on every matching
solution across several shipped files (e.g. one per transpose) and get back
two libraries that differ only in that parameter, with no re-benchmark:
python -m Tensile.ExperimentalLibrary patch-logic \\
--logic-src <shipped>/gfx950/.../gfx950_Cijk_Ailk_Bjlk_*.yaml \\
<shipped>/gfx950/.../gfx950_Cijk_Ailk_Bljk_*.yaml \\
--where StreamK=5 --set PrefetchAcrossPersistent=1 \\
--matched-pair --skip-unbuildable \\
--arch gfx950 --out work/pap --feature-name sk5_pap1
# prints both HIPBLASLT_TENSILE_LIBPATH exports and writes
# work/pap/patch_manifest.csv (per-solution applied/skipped + reason)
``--where`` is optional: dropping it from the command above selects every
solution under ``--logic-src`` instead of just the ``StreamK=5`` family, so
the same ``--set`` is applied library-wide. ``--set`` itself is always
required.
"""
from __future__ import annotations
import argparse
import ast
import difflib
import os
import shlex
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
# The Tensile package directory that contains this module. Used to locate the
# bin/Tensile launcher (``python -m Tensile.Tensile`` is intentionally disabled
# upstream).
_TENSILE_PKG_DIR = Path(__file__).resolve().parent
_SPDX_HEADER = (
"# Copyright Advanced Micro Devices, Inc., or its affiliates.\n"
"# SPDX-License-Identifier: MIT\n"
)
class ExperimentalLibraryError(RuntimeError):
"""Actionable, user-facing error raised by any stage of this tool."""
# ---------------------------------------------------------------------------
# Interpreter / environment helpers
# ---------------------------------------------------------------------------
def default_python() -> str:
"""Pick the interpreter to drive Tensile subprocesses.
Prefers the repo venv under ``projects/hipblaslt/build/venv`` if present,
otherwise falls back to the interpreter running this tool.
"""
# tensilelite/Tensile/ -> tensilelite -> hipblaslt
hipblaslt_root = _TENSILE_PKG_DIR.parent.parent
candidate = hipblaslt_root / "build" / "venv" / "bin" / "python"
if candidate.is_file():
return str(candidate)
return sys.executable
def _format_command(cmd: Sequence[str], env_overrides: Optional[Dict[str, str]]) -> str:
prefix = ""
if env_overrides:
prefix = " ".join(f"{k}={shlex.quote(v)}" for k, v in env_overrides.items()) + " "
return prefix + " ".join(shlex.quote(str(c)) for c in cmd)
def run_command(
cmd: Sequence[str],
*,
env_overrides: Optional[Dict[str, str]] = None,
cwd: Optional[str] = None,
dry_run: bool = False,
verbose: bool = False,
log_path: Optional[str] = None,
stream: bool = False,
) -> Tuple[int, str]:
"""Run a command, optionally capturing combined output to ``log_path``.
Returns ``(returncode, captured_output)``. In ``dry_run`` mode the command
(with env prefix) is printed and ``(0, "")`` is returned without executing.
When ``stream`` is True, output goes straight to the console (used for the
interactive bench run) and is not captured.
"""
pretty = _format_command(cmd, env_overrides)
if dry_run or verbose:
print(f"$ {pretty}")
if dry_run:
return 0, ""
env = os.environ.copy()
if env_overrides:
env.update(env_overrides)
if stream:
proc = subprocess.run(list(map(str, cmd)), env=env, cwd=cwd)
return proc.returncode, ""
proc = subprocess.run(
list(map(str, cmd)),
env=env,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
output = proc.stdout or ""
if log_path:
Path(log_path).parent.mkdir(parents=True, exist_ok=True)
Path(log_path).write_text(output)
if verbose and output:
print(output)
return proc.returncode, output
# ---------------------------------------------------------------------------
# Pure logic: parameter parsing / validation / config augmentation
# ---------------------------------------------------------------------------
def coerce_value(raw: str) -> Any:
"""Coerce a CLI string into the Python value used by ``validParameters``.
Booleans are recognized first (``validParameters`` distinguishes ``bool``
from ``int``); then ``ast.literal_eval`` handles ints, floats and lists;
anything else is left as a bare string (e.g. ``MultipleBuffer``).
"""
s = raw.strip()
if s in ("True", "true", "False", "false"):
return s.lower() == "true"
try:
return ast.literal_eval(s)
except (ValueError, SyntaxError):
return s
def parse_set_arg(arg: str) -> Tuple[str, List[Any]]:
"""Parse a single ``NAME=v1[,v2,...]`` ``--set`` argument."""
if "=" not in arg:
raise ExperimentalLibraryError(
f"Malformed --set '{arg}': expected NAME=value[,value...]"
)
name, _, values_str = arg.partition("=")
name = name.strip()
if not name:
raise ExperimentalLibraryError(f"Malformed --set '{arg}': empty parameter name")
values_str_stripped = values_str.strip()
if values_str_stripped == "":
raise ExperimentalLibraryError(
f"Malformed --set '{arg}': no value(s) provided for '{name}'"
)
# A leading "[" means a single bracketed list value (e.g.
# ``MatrixInstruction=[16,16,16,1]``); treat the whole thing as one token so
# the embedded commas are not split into separate values.
if values_str_stripped.startswith("["):
values = [coerce_value(values_str_stripped)]
else:
values = [coerce_value(v) for v in values_str.split(",")]
return name, values
def validate_sets(sets: Sequence[Tuple[str, List[Any]]]) -> None:
"""Validate parameter names and values against ``validParameters``.
Raises :class:`ExperimentalLibraryError` with an actionable message:
unknown names list the nearest valid candidates; bad values list the
allowed values. A registry entry of ``-1`` means "skip value check".
"""
try:
from Tensile.Common.ValidParameters import validParameters
except Exception as e: # ModuleNotFoundError (rocisa), RuntimeError, etc.
raise ExperimentalLibraryError(
"Parameter validation needs Tensile.Common which requires a built "
f"rocisa, but it could not be imported ({e}). Either build rocisa, "
"launch this tool with the venv python via --python, or pass "
"--skip-validation to bypass validation."
) from e
for name, values in sets:
if name not in validParameters:
suggestions = difflib.get_close_matches(name, list(validParameters.keys()), n=5)
hint = (
f" Did you mean: {', '.join(suggestions)}?"
if suggestions
else " Run with a known solution parameter name."
)
raise ExperimentalLibraryError(
f"Unknown solution parameter '{name}'.{hint}"
)
allowed = validParameters[name]
if allowed == -1:
continue
for value in values:
if value not in allowed:
shown = allowed[:32] if isinstance(allowed, list) else allowed
more = " (first 32 shown)" if isinstance(allowed, list) and len(allowed) > 32 else ""
raise ExperimentalLibraryError(
f"Invalid value {value!r} for '{name}'. "
f"Allowed values{more}: {shown}"
)
def _set_fork_parameter(fork_params: List[Dict[str, Any]], name: str, values: List[Any]) -> None:
"""Inject or override a single-key ForkParameters entry in place.
Replaces an existing ``{name: ...}`` entry if present; otherwise inserts a
new entry before any trailing ``Groups``/``MatrixInstruction`` entry (so
the matrix-instruction block stays last), else appends.
"""
for entry in fork_params:
if isinstance(entry, dict) and name in entry:
entry[name] = list(values)
return
new_entry = {name: list(values)}
for idx, entry in enumerate(fork_params):
if isinstance(entry, dict) and (
"Groups" in entry or "MatrixInstruction" in entry
):
fork_params.insert(idx, new_entry)
return
fork_params.append(new_entry)
def augment_config(
config: Dict[str, Any], sets: Sequence[Tuple[str, List[Any]]]
) -> Dict[str, Any]:
"""Inject/override ForkParameters in every BenchmarkProblems group.
Pure transform: mutates and returns ``config``. Raises
:class:`ExperimentalLibraryError` if the config has no
BenchmarkProblems/ForkParameters structure to augment.
"""
problems = config.get("BenchmarkProblems")
if not isinstance(problems, list) or not problems:
raise ExperimentalLibraryError(
"Config has no 'BenchmarkProblems' list; cannot augment ForkParameters."
)
touched = 0
for group in problems:
if not (isinstance(group, list) and len(group) >= 2 and isinstance(group[1], dict)):
continue
size_group = group[1]
fork_params = size_group.get("ForkParameters")
if not isinstance(fork_params, list):
fork_params = []
size_group["ForkParameters"] = fork_params
for name, values in sets:
_set_fork_parameter(fork_params, name, values)
touched += 1
if touched == 0:
raise ExperimentalLibraryError(
"No BenchmarkProblemSizeGroup found to augment "
"(expected BenchmarkProblems[i][1] to be a dict)."
)
return config
_PLACEHOLDER_PROBLEM_SIZES = [{"Exact": [1, 1, 1, 1]}]
def _placeholder_problem_size_groups(config: Dict[str, Any]) -> List[int]:
"""Indices (within ``BenchmarkProblems``) of groups whose ``ProblemSizes``
is still ``TensileLibLogicToYaml``'s placeholder (``[{'Exact': [1, 1, 1,
1]}]``), emitted when ``extract`` has no real per-size table to recover
from a ``Prediction``-type source and left unedited by the user.
"""
placeholder_groups: List[int] = []
for i, group in enumerate(config.get("BenchmarkProblems") or []):
if not (isinstance(group, list) and len(group) >= 2 and isinstance(group[1], dict)):
continue
for params in group[1].get("BenchmarkFinalParameters") or []:
if isinstance(params, dict) and params.get("ProblemSizes") == _PLACEHOLDER_PROBLEM_SIZES:
placeholder_groups.append(i)
break
return placeholder_groups
def merge_configs(configs: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
"""Combine several per-solution ``extract`` configs into one config.
Each ``extract`` of a single solution index yields a config with one
``BenchmarkProblems`` group. Merging concatenates those groups so a whole
solution family (e.g. every StreamK==5 solution) rebuilds into a single
library in one ``gen-logic``/``build-lib`` pass. The merged config flows
through the existing ``augment``/``pipeline`` path unchanged.
All inputs must target the same architecture (they normally come from the
same shipped ``3_LibraryLogic``). ``GlobalParameters``/``LibraryLogic`` are
taken from the first config; only ``BenchmarkProblems`` accumulate.
"""
import copy
cfgs = [c for c in configs if isinstance(c, dict)]
if not cfgs:
raise ExperimentalLibraryError("merge: no valid configs to combine.")
merged = copy.deepcopy(cfgs[0])
problems = list(merged.get("BenchmarkProblems") or [])
base_arch = (merged.get("LibraryLogic") or {}).get("ArchitectureName")
for c in cfgs[1:]:
arch = (c.get("LibraryLogic") or {}).get("ArchitectureName")
if arch != base_arch:
raise ExperimentalLibraryError(
f"merge: configs target different architectures ({base_arch!r} "
f"vs {arch!r}); merge only combines configs from the same arch."
)
problems.extend(copy.deepcopy(c.get("BenchmarkProblems") or []))
if not problems:
raise ExperimentalLibraryError("merge: combined config has no BenchmarkProblems.")
# GlobalParameters (incl. data-init types derived from the problem DataType)
# come from the first config only. Warn if the merged groups span multiple
# problem types, since those settings may not suit every group.
ptypes = {
(grp[0].get("OperationType"), grp[0].get("DataType"))
for grp in problems
if isinstance(grp, list) and grp and isinstance(grp[0], dict)
}
if len(ptypes) > 1:
sys.stderr.write(
"merge: warning: combined groups span multiple problem types "
f"{sorted(map(str, ptypes))}; GlobalParameters are taken from the "
"first config only and may not suit every group.\n"
)
merged["BenchmarkProblems"] = problems
return merged
# ---------------------------------------------------------------------------
# YAML IO
# ---------------------------------------------------------------------------
def load_yaml(path: str) -> Any:
import yaml
with open(path, "r") as f:
return yaml.safe_load(f)
def dump_config_with_header(config: Dict[str, Any], path: str, feature_name: Optional[str]) -> None:
import yaml
out = Path(path)
out.parent.mkdir(parents=True, exist_ok=True)
with open(out, "w") as f:
f.write(_SPDX_HEADER)
if feature_name:
f.write(f"# Experimental feature config: {feature_name}\n")
f.write("\n")
yaml.safe_dump(config, f, default_flow_style=False, sort_keys=False)
# ---------------------------------------------------------------------------
# Library-logic inspection
# ---------------------------------------------------------------------------
def count_solutions(logic_yaml_path: str) -> int:
"""Return the number of solution states in a ``3_LibraryLogic`` yaml.
Uses the authoritative ``LibraryIO.rawLibraryLogic`` parser when possible
and falls back to a structural scan of the raw yaml.
"""
try:
from Tensile import LibraryIO
raw = LibraryIO.readYAML(logic_yaml_path)
if not raw:
return 0
fields = LibraryIO.rawLibraryLogic(raw)
all_solution_states = fields[5]
return len(all_solution_states) if all_solution_states else 0
except Exception:
return _count_solutions_structural(logic_yaml_path)
def _scan_for_solutions_element(
data: Any,
) -> Optional[Tuple[int, List[Dict[str, Any]]]]:
"""Locate the solution-states element inside a parsed ``3_LibraryLogic``.
A shipped logic yaml parses to a raw LIST, one element of which is itself a
list of solution-state dicts. Returns ``(index_in_data, that_list)``, or
``None`` if ``data`` is not a list or no such element is found.
"""
if not isinstance(data, list):
return None
for i, element in enumerate(data):
if isinstance(element, list) and element and all(isinstance(e, dict) for e in element):
if any(
("SolutionIndex" in e) or ("MacroTile0" in e) or ("ProblemType" in e)
for e in element
):
return i, element
return None
def _library_type(raw: Any) -> Optional[str]:
"""Read the LibraryType discriminator at position 11 of a parsed
``3_LibraryLogic`` list, if present (mirrors ``LibraryIO.parseLibraryLogicList``).
"""
if isinstance(raw, list) and len(raw) > 11 and raw[11]:
return raw[11]
return None
def _count_solutions_structural(logic_yaml_path: str) -> int:
try:
data = load_yaml(logic_yaml_path)
except Exception:
return 0
found = _scan_for_solutions_element(data)
return len(found[1]) if found else 0
_SUMMARY_KEYS = (
"StreamK",
"MatrixInstruction",
"MIWaveTile",
"DepthU",
"PrefetchGlobalRead",
"PrefetchLocalRead",
"GlobalSplitU",
"WorkGroupMapping",
)
def _value_eq(a: Any, b: Any) -> bool:
"""Equality that keeps ``bool`` distinct from ``int``.
Python treats ``True == 1`` and ``False == 0``, so a naive ``in`` test would
let ``--where Flag=1`` match a solution whose value is boolean ``True``.
Require the same bool-ness before comparing so int and bool parameters do
not cross-match.
"""
if isinstance(a, bool) != isinstance(b, bool):
return False
return a == b
def solution_matches(
state: Dict[str, Any], wheres: Sequence[Tuple[str, List[Any]]]
) -> bool:
"""True when ``state`` satisfies every ``(name, values)`` predicate.
AND across predicates, OR within each predicate's values. A solution that
lacks a queried key does not match (so ``--where StreamK=5`` never matches a
solution with no StreamK key).
"""
for name, values in wheres:
if name not in state or not any(_value_eq(state[name], v) for v in values):
return False
return True
def select_indices(
states: Sequence[Dict[str, Any]], wheres: Sequence[Tuple[str, List[Any]]]
) -> List[int]:
"""Indices of solution states matching all ``wheres`` (all indices if none)."""
return [
i
for i, s in enumerate(states)
if isinstance(s, dict) and solution_matches(s, wheres)
]
def _dedup_keys(*key_lists: Sequence[str]) -> List[str]:
"""Flatten several key lists into one, keeping first occurrence order."""
seen = set()
result = []
for keys in key_lists:
for k in keys:
if k not in seen:
seen.add(k)
result.append(k)
return result
def summarize_solution(state: Dict[str, Any], extra_keys: Sequence[str] = ()) -> str:
"""One-line digest of a solution's notable parameters for listing.
``extra_keys`` (e.g. the active ``--where`` names) are shown first, followed
by ``_SUMMARY_KEYS``, de-duplicated; only keys present in ``state`` appear.
"""
keys = _dedup_keys(extra_keys, _SUMMARY_KEYS)
parts = [f"{k}={state[k]}" for k in keys if k in state]
return " ".join(parts) if parts else "(no summary keys)"
def _resolve_logic_sources(sources: Sequence[str]) -> List[str]:
"""Expand ``--logic-src`` entries (files and/or dirs) into logic yaml paths.
A directory contributes every ``*.yaml`` found recursively within it. The
result is de-duplicated (by realpath) while preserving first-seen order.
"""
files: List[str] = []
for src in sources:
p = Path(src)
if p.is_dir():
files.extend(str(f) for f in sorted(p.rglob("*.yaml")))
elif p.is_file():
files.append(str(p))
else:
raise ExperimentalLibraryError(
f"--logic-src not found (no such file or dir): {src}"
)
resolved: List[str] = []
seen = set()
for f in files:
rp = os.path.realpath(f)
if rp not in seen:
seen.add(rp)
resolved.append(rp)
if not resolved:
raise ExperimentalLibraryError(
"--logic-src resolved to no *.yaml logic files."
)
return resolved
def _find_solutions_element(raw: Any, path: str) -> Tuple[int, List[Dict[str, Any]]]:
"""Locate the solution-states element inside a parsed ``3_LibraryLogic``.
Uses the same detection as :func:`_count_solutions_structural` (via
:func:`_scan_for_solutions_element`), but on an already-parsed ``raw``
object, and raises instead of silently returning a fallback.
"""
if not isinstance(raw, list):
raise ExperimentalLibraryError(
f"{path} did not parse as a LibraryLogic list."
)
found = _scan_for_solutions_element(raw)
if found is None:
raise ExperimentalLibraryError(
f"could not find a solution-states list in {path}."
)
return found
# ---------------------------------------------------------------------------
# Subcommand handlers
# ---------------------------------------------------------------------------
def _indexed_out_path(out: str, idx: int, single: bool) -> str:
"""Per-index output path for ``extract``.
A single index uses ``out`` verbatim. For multiple indices each gets a
distinct file derived from ``out``'s stem/suffix (``base.yaml`` ->
``base_<idx>.yaml``; a suffix-less ``base`` -> ``base_<idx>.yaml``), so a
suffix-less ``--out`` still yields one distinct file per index instead of
every index writing the same path.
"""
if single:
return out
p = Path(out)
if p.suffix:
return str(p.with_name(f"{p.stem}_{idx}{p.suffix}"))
return str(p.with_name(f"{p.name}_{idx}.yaml"))
def _extract_snippet() -> str:
return (
"import sys\n"
"from Tensile.TensileLibLogicToYaml import TensileLibLogicToYaml\n"
"from Tensile.ExperimentalLibrary import _indexed_out_path\n"
"inp, out, skip = sys.argv[1], sys.argv[2], sys.argv[3] == '1'\n"
"ids = [int(x.strip()) for x in sys.argv[4].split(',')]\n"
"for idx in ids:\n"
" f = _indexed_out_path(out, idx, len(ids) == 1)\n"
" res = TensileLibLogicToYaml(inp, idx, f, skip)\n"
" if not res:\n"
" raise SystemExit(f'TensileLibLogicToYaml failed for index {idx}')\n"
" print(f'WROTE {f}')\n"
)
def cmd_extract(args: argparse.Namespace) -> int:
logic = os.path.realpath(args.logic)
if not args.dry_run and not os.path.isfile(logic):
raise ExperimentalLibraryError(f"Library logic file not found: {logic}")
out = os.path.realpath(args.out)
ids = [s.strip() for s in args.indices.split(",") if s.strip()]
if not ids:
raise ExperimentalLibraryError("--indices must contain at least one index")
# A structural fact about the source file, independent of whether the
# subprocess actually runs, so this check applies under --dry-run too
# (skipped only if the file doesn't exist yet).
if os.path.isfile(logic) and _library_type(load_yaml(logic)) == "Prediction":
sys.stderr.write(
f"extract: warning: {logic} is a 'Prediction'-type library logic "
"with no real per-size table; the extracted config's ProblemSizes "
"will be a placeholder ([{'Exact': [1, 1, 1, 1]}]) that must be "
"hand-edited to real sizes before benchmarking with gen-logic. To "
"toggle a parameter and rebuild the same library instead, use "
"patch-logic.\n"
)
cmd = [
args.python,
"-c",
_extract_snippet(),
logic,
out,
"1" if args.skip_mi else "0",
",".join(ids),
]
rc, output = run_command(cmd, dry_run=args.dry_run, verbose=args.verbose)
if args.dry_run:
return 0
if rc != 0:
raise ExperimentalLibraryError(
f"extract failed (rc={rc}). Output:\n{output}"
)
produced = []
single = len(ids) == 1
for idx in ids:
f = _indexed_out_path(out, int(idx), single)
if not os.path.isfile(f) or os.path.getsize(f) == 0:
raise ExperimentalLibraryError(
f"extract reported success but config is missing/empty: {f}\n{output}"
)
produced.append(f)
print("Extracted config(s):")
for f in produced:
print(f" {f}")
return 0
def cmd_list_solutions(args: argparse.Namespace) -> int:
wheres = [parse_set_arg(w) for w in args.where]
files = _resolve_logic_sources(args.logic_src)
# Indices are per-file (not global), so a flat comma-separated list is only
# unambiguous when exactly one logic file is in play.
if args.indices_only and len(files) > 1:
raise ExperimentalLibraryError(
f"list-solutions: --logic-src resolved {len(files)} logic files, "
"but --indices-only requires exactly one (its output is a single "
"file's index list, and indices are not comparable across files); "
"narrow --logic-src to one file."
)
per_file: List[Tuple[str, List[Dict[str, Any]], List[int], Optional[str]]] = []
for path in files:
raw = load_yaml(path)
_, states = _find_solutions_element(raw, path)
idxs = select_indices(states, wheres)
per_file.append((path, states, idxs, _library_type(raw)))
if args.indices_only:
_, _, idxs, _ = per_file[0]
print(",".join(str(i) for i in idxs))
return 0
for path, states, idxs, library_type in per_file:
print(f"== {path} [{library_type or 'unknown'}] ({len(idxs)}/{len(states)} match) ==")
for i in idxs:
print(f"{i}\t{summarize_solution(states[i], extra_keys=[name for name, _ in wheres])}")
total_matched = sum(len(idxs) for _, _, idxs, _ in per_file)
total_states = sum(len(states) for _, states, _, _ in per_file)
if len(per_file) > 1:
print(
f"list-solutions: {total_matched}/{total_states} solution(s) "
f"matched across {len(per_file)} file(s)."
)
else:
print(f"list-solutions: {total_matched}/{total_states} solution(s) matched.")
return 0
def cmd_merge(args: argparse.Namespace) -> int:
out = os.path.realpath(args.out)
if args.dry_run:
print(f"[dry-run] would merge {len(args.configs)} config(s) into {out}")
return 0
configs = []
for path in args.configs:
if not os.path.isfile(path):
raise ExperimentalLibraryError(f"merge: config not found: {path}")
data = load_yaml(path)
if not isinstance(data, dict):
raise ExperimentalLibraryError(f"merge: {path} did not parse as a mapping.")
configs.append(data)
merged = merge_configs(configs)
dump_config_with_header(merged, out, args.feature_name)
n = len(merged.get("BenchmarkProblems") or [])
print(
f"merge OK: combined {len(configs)} config(s) into "
f"{n} BenchmarkProblems group(s):\n {out}"
)
return 0
def _augment_staging_path(staging: str, arch: str, feature_name: str, config_basename: str) -> str:
"""Default augment output path when ``--out`` is not given."""
return os.path.join(staging, "Logic", arch, "Experimental", feature_name, config_basename)
def _do_augment(args: argparse.Namespace) -> str:
"""Shared augment implementation; returns the output config path."""
sets = [parse_set_arg(s) for s in args.set]
if not sets:
raise ExperimentalLibraryError("At least one --set NAME=value is required")
if not getattr(args, "skip_validation", False):
validate_sets(sets)
config = load_yaml(args.config)
if not isinstance(config, dict):
raise ExperimentalLibraryError(
f"Config {args.config} did not parse as a mapping."
)
augment_config(config, sets)
if args.out:
out_path = os.path.realpath(args.out)
else:
staging = os.path.realpath(args.staging)
base = os.path.basename(args.config)
out_path = _augment_staging_path(staging, args.arch, args.name, base)
dump_config_with_header(config, out_path, args.name)
return out_path
def cmd_augment(args: argparse.Namespace) -> int:
if not getattr(args, "name", None):
raise ExperimentalLibraryError("augment requires --feature-name (or --name).")
if args.dry_run:
sets = [parse_set_arg(s) for s in args.set]
if not args.skip_validation:
validate_sets(sets)
print(f"[dry-run] would inject {sets} into {args.config}")
return 0
out_path = _do_augment(args)
print(f"Augmented config written to:\n {out_path}")
return 0
def _experimental_staging_path(root: str, arch: str, feature_name: str, filename: str) -> str:
"""Build the ``<root>/<arch>/Experimental/<feature_name>/<filename>`` staging
path shared by gen-logic's output staging and patch-logic's logic trees.
"""
return os.path.join(root, arch, "Experimental", feature_name, filename)
def _stage_logic(workdir: str, arch: str, feature_name: str) -> str:
"""Copy produced 3_LibraryLogic yaml(s) into an Experimental staging tree.
Returns the staging root to pass as TensileCreateLibrary's LogicPath; the
staged path contains an ``Experimental`` component so the library builder
keeps the files only when ``--experimental`` is set.
"""
import shutil
src_dir = Path(workdir) / "3_LibraryLogic"
logic_files = sorted(src_dir.glob("*.yaml"))
staging_root = str(Path(workdir) / "experimental_logic")
dest_dir = Path(_experimental_staging_path(staging_root, arch, feature_name, ""))
dest_dir.mkdir(parents=True, exist_ok=True)
for f in logic_files:
dest = _experimental_staging_path(staging_root, arch, feature_name, f.name)
shutil.copy2(f, dest)
return staging_root
def cmd_gen_logic(args: argparse.Namespace) -> int:
config = os.path.realpath(args.config)
if not args.dry_run and not os.path.isfile(config):
raise ExperimentalLibraryError(f"Config not found: {config}")
workdir = os.path.realpath(args.out)
if not args.dry_run:
os.makedirs(workdir, exist_ok=True)
feature_name = args.feature_name or "feature"
# A structural fact about the config file's contents; skipped under
# --dry-run when the config doesn't exist yet (e.g. a pipeline dry-run
# whose augment stage hasn't actually written it).
if os.path.isfile(config):
placeholder_groups = _placeholder_problem_size_groups(load_yaml(config))
if placeholder_groups:
raise ExperimentalLibraryError(
f"Config {config} has BenchmarkProblems group(s) "
f"{placeholder_groups} with an unedited placeholder "
f"ProblemSizes ({_PLACEHOLDER_PROBLEM_SIZES}), left over from "
"extracting a 'Prediction'-type source with no real per-size "
"table. Set real ProblemSizes for those group(s) before "
"benchmarking, or use patch-logic instead to toggle a "
"parameter and rebuild the same library without benchmarking."
)
# Fail fast (before any kernel generation) if the target arch is not a GPU
# present on this host. gen-logic runs REAL benchmarking so winner selection
# uses measured GFLOPS; on non-matching hardware there is no benchmark to run,
# and falling back to uniform/synthetic GFLOPS would make every forked
# solution tie -- the winner-take-all logic analysis then keeps only the
# first-listed solution and silently drops the others. Refuse rather than
# emit a misleading library. Skipped under --dry-run (must stay HW-independent).
# NOTE: mixed-arch hosts -- we only check that the target arch is present
# somewhere on the host, not that the benchmarked device (config `Device`)
# is that arch. Device pinning on mixed-arch hosts is a follow-up.
if not args.dry_run:
from Tensile.Common.Architectures import detectHostGfxArchs, hostHasArch
if not hostHasArch(args.arch):
detected = detectHostGfxArchs()
detected_str = ", ".join(detected) if detected else "none"
raise ExperimentalLibraryError(
f"Target arch '{args.arch}' is not present on this host "
f"(detected GPU archs: {detected_str}). gen-logic runs real "
"hipblaslt-bench benchmarking so winner selection uses measured "
"GFLOPS; it will not run on non-matching hardware because there is "
"no benchmark to run, and synthetic/uniform GFLOPS would make every "
"forked solution tie -- the winner-take-all analysis would then keep "
"only the first-listed solution and silently drop the rest. Run this "
f"tool on a host with a '{args.arch}' GPU. (Cross-arch generation "
"without benchmarking is intentionally not supported by this tool.)"
)
bin_tensile = _TENSILE_PKG_DIR / "bin" / "Tensile"
if not args.dry_run and not bin_tensile.is_file():
raise ExperimentalLibraryError(f"Tensile launcher not found: {bin_tensile}")
cmd = [
args.python,
str(bin_tensile),
config,
workdir,
"--gpu-targets",
args.arch,
]
env_overrides = {"CU": str(args.cu)}
log_path = os.path.join(workdir, "gen_logic.log")
rc, output = run_command(
cmd,
env_overrides=env_overrides,
dry_run=args.dry_run,
verbose=args.verbose,
log_path=log_path,
)
if args.dry_run:
return 0
logic_dir = Path(workdir) / "3_LibraryLogic"
logic_files = sorted(logic_dir.glob("*.yaml")) if logic_dir.is_dir() else []
total_solutions = sum(count_solutions(str(f)) for f in logic_files)
# SUCCESS: clean exit, at least one logic file, at least one counted solution.
if rc == 0 and logic_files and total_solutions >= 1:
staging_root = _stage_logic(workdir, args.arch, feature_name)
print(f"gen-logic OK: {total_solutions} solution(s) across {len(logic_files)} logic file(s).")
print(f"Tensile log: {log_path}")
print(f"Staged experimental logic dir (pass to build-lib --logic-dir):\n {staging_root}")
return 0
# rc==0 but the solution count came back 0. The all-rejected case exits
# NON-zero upstream (BenchmarkProblems printExit on 0 valid solutions), so a
# clean exit with present-but-uncounted logic is a parse hiccup, not a
# rejection -- report it as its own diagnostic rather than "rejected".
if rc == 0:
non_empty = [f for f in logic_files if f.stat().st_size > 0]
if non_empty:
raise ExperimentalLibraryError(
"gen-logic produced 3_LibraryLogic but could not be parsed to "
f"count solutions: {non_empty[0]}\n"
" (clean exit, so this is inconclusive -- not a validation "
"rejection; inspect the file and the log)\n"
f" Tensile log: {log_path}"
)
raise ExperimentalLibraryError(
"gen-logic exited 0 but produced no 3_LibraryLogic.\n"
f" Tensile log: {log_path}"
)
# rc != 0 below. Only now consult rejection markers, and only when NO
# 3_LibraryLogic was produced, using a TIGHT marker set so real codegen
# crashes are not misreported as a validation rejection.
low = output.lower()
rejection_markers = ("0 valid solutions", "resulted in 0 valid solutions")
if not logic_files and any(m in low for m in rejection_markers):
raise ExperimentalLibraryError(
"NO VALID SOLUTIONS (rejected by Solution validation): your "
"parameter combination left 0 valid solutions. Check the "
"constraints for the parameter(s) you toggled (e.g. "
"StreamKForceDPOnly requires StreamK==3 and is incompatible with "
"StreamKAtomic==1).\n"
f" produced logic files: {len(logic_files)}; total solutions: {total_solutions}\n"
f" Tensile log: {log_path}"
)
raise ExperimentalLibraryError(
f"KERNEL GENERATION FAILED (codegen error): Tensile exited rc={rc}. "
"This is a codegen error rather than a validation rejection. See the "
f"captured log:\n {log_path}"
)
def _tensile_create_library_cmd(
python: str,
logic_dir: str,
out_dir: str,
arch: str,
*,
experimental: bool = True,
jobs: Optional[int] = None,
) -> List[str]:
"""Build the ``python -m Tensile.TensileCreateLibrary ...`` argv shared by
build-lib and patch-logic's buildability probe.
"""
cmd = [
python,
"-m",
"Tensile.TensileCreateLibrary",
logic_dir,
out_dir,
"HIP",
f"--architecture={arch}",
"--code-object-version=default",
"--library-format=msgpack",
"--no-enumerate",
]
if experimental:
cmd.append("--experimental")
if jobs is not None:
cmd += ["--jobs", str(jobs)]
return cmd
def cmd_build_lib(args: argparse.Namespace) -> int:
logic_dir = os.path.realpath(args.logic_dir)
if not args.dry_run and not os.path.isdir(logic_dir):
raise ExperimentalLibraryError(f"Logic dir not found: {logic_dir}")
libdir = os.path.realpath(args.out)
if not args.dry_run:
os.makedirs(libdir, exist_ok=True)
cmd = _tensile_create_library_cmd(
args.python,
logic_dir,
libdir,
args.arch,
experimental=args.experimental,
jobs=getattr(args, "jobs", None),
)
log_path = os.path.join(libdir, "build_lib.log")
rc, output = run_command(
cmd, dry_run=args.dry_run, verbose=args.verbose, log_path=log_path
)
if args.dry_run:
return 0
if rc != 0:
raise ExperimentalLibraryError(
f"TensileCreateLibrary failed (rc={rc}). See log:\n {log_path}"
)
# TensileCreateLibrary writes the master lazy file to a PER-ARCH subdir:
# <out>/library/<base-arch>/TensileLibrary_lazy_<base-arch>.dat
# (target features after the first ':' are stripped from the dir name).
base_arch = args.arch.split(":")[0]
lib_root = Path(libdir) / "library"
arch_dir = lib_root / base_arch
# The msgpack writer always emits a zlib-compressed master file
# (``TensileLibrary_lazy_<arch>.dat.zlib``); the runtime loader probes
# ``.dat.zlib`` first and falls back to a plain ``.dat``. Accept either so
# verification matches what is actually produced/loadable.
stem = f"TensileLibrary_lazy_{base_arch}.dat"
candidates = [arch_dir / stem, arch_dir / f"{stem}.zlib"]
produced = next((c for c in candidates if c.is_file()), None)
if produced is None and lib_root.is_dir():
# Recursive fallback in case the arch dir name differs slightly.
hits = sorted(lib_root.rglob("TensileLibrary_lazy_*.dat")) + sorted(
lib_root.rglob("TensileLibrary_lazy_*.dat.zlib")
)
if hits:
produced = hits[0]
if produced is None or produced.stat().st_size == 0:
raise ExperimentalLibraryError(
f"Library build produced no '{stem}[.zlib]' (or it is empty) under "
f"{arch_dir}. See log:\n {log_path}"
)
# HIPBLASLT_TENSILE_LIBPATH is used verbatim by tensile_host.cpp (it loads
# <env>/TensileLibrary_lazy_<arch>.dat without appending the arch subdir), so
# it MUST point at the per-arch directory that actually holds the master file.
libpath_dir = produced.parent
print(f"build-lib OK. Library file: {produced}")
print("Export this to load the library at runtime:")
print(f" export HIPBLASLT_TENSILE_LIBPATH={libpath_dir}")
return 0
def _bench_problem_args(extra: List[str]) -> List[str]:
# Strip a leading "--" separator if argparse REMAINDER captured it.
if extra and extra[0] == "--":
return extra[1:]
return extra
def _resolve_lib_dir(lib: str, arch: str, *, must_exist: bool) -> str:
"""Resolve ``--lib`` to the per-arch dir holding the master lazy file.
Accepts the build ``<out>`` root, the ``<out>/library`` dir, or the per-arch
``<out>/library/<base-arch>`` dir, and returns the directory that actually
contains ``TensileLibrary_lazy_<base-arch>.dat`` -- which is what
HIPBLASLT_TENSILE_LIBPATH must point at (tensile_host.cpp uses it verbatim).
Searches recursively if the obvious candidates miss. When ``must_exist`` is
False (e.g. dry-run before the library is built) a best-effort per-arch path
is returned instead of raising.
"""
base_arch = arch.split(":")[0]
target = f"TensileLibrary_lazy_{base_arch}.dat"
# The msgpack master file is canonically zlib-compressed (``.dat.zlib``);
# the runtime loads either, so resolve against both forms.
targets = (target, target + ".zlib")
root = Path(os.path.realpath(lib))
candidates = [
root / "library" / base_arch,
root / base_arch,
root,
root / "library",
]
for d in candidates:
if any((d / t).is_file() for t in targets):
return str(d)
if root.is_dir():
hits = sorted(root.rglob(target)) + sorted(root.rglob(target + ".zlib"))
if hits:
return str(hits[0].parent)
# Fall back to any arch's master file if the requested one is absent.
any_hits = sorted(root.rglob("TensileLibrary_lazy_*.dat")) + sorted(
root.rglob("TensileLibrary_lazy_*.dat.zlib")
)
if any_hits:
return str(any_hits[0].parent)
if must_exist:
raise ExperimentalLibraryError(
f"Could not find '{target}' under {root}. Pass --lib pointing at the "
"build <out> root, its 'library/' dir, or the per-arch "
"'library/<arch>' dir produced by build-lib."
)
# Best-effort guess for dry-run / not-yet-built libraries.
if root.name == base_arch:
return str(root)
if root.name == "library":
return str(root / base_arch)
return str(root / "library" / base_arch)
def cmd_find_index(args: argparse.Namespace) -> int:
# Only require the library to exist when we are actually going to run it;
# the dry-run and the "print the command yourself" helper just need a path.
will_execute = bool(args.bench) and not args.dry_run
lib_subdir = _resolve_lib_dir(args.lib, args.arch, must_exist=will_execute)
problem_args = _bench_problem_args(args.extra)
if not args.bench:
full = ["hipblaslt-bench", "--algo_method", "all", *problem_args]
print("No --bench provided. Run this command yourself:")
print(f" HIPBLASLT_TENSILE_LIBPATH={lib_subdir} {' '.join(shlex.quote(c) for c in full)}")
return 0
cmd = [args.bench, "--algo_method", "all", *problem_args]
env_overrides = {"HIPBLASLT_TENSILE_LIBPATH": lib_subdir}
rc, output = run_command(
cmd, env_overrides=env_overrides, dry_run=args.dry_run, verbose=args.verbose
)
if args.dry_run:
return 0
if rc != 0:
raise ExperimentalLibraryError(
f"hipblaslt-bench failed (rc={rc}). Output:\n{output}"
)
print(output)
candidates = []
for line in output.splitlines():
low = line.lower()
if "solution" in low and "index" in low:
candidates.append(line.strip())
print("\nCandidate solution lines:")
if candidates:
for c in candidates:
print(f" {c}")
else:
print(" (could not parse solution indices; inspect the full output above)")
return 0
def cmd_bench(args: argparse.Namespace) -> int:
lib_subdir = _resolve_lib_dir(args.lib, args.arch, must_exist=not args.dry_run)
problem_args = _bench_problem_args(args.extra)
cmd = [
args.bench,
"--algo_method",
"index",
"--solution_index",
str(args.solution_index),
*problem_args,
]
env_overrides = {"HIPBLASLT_TENSILE_LIBPATH": lib_subdir}
rc, _ = run_command(
cmd,
env_overrides=env_overrides,
dry_run=args.dry_run,
verbose=args.verbose,
stream=True,
)
if args.dry_run:
return 0
if rc != 0:
raise ExperimentalLibraryError(f"hipblaslt-bench failed (rc={rc}).")
return 0
def cmd_pipeline(args: argparse.Namespace) -> int:
out_root = os.path.realpath(args.out)
if not args.dry_run:
os.makedirs(out_root, exist_ok=True)
# Stage 1: augment -> write into the Experimental staging tree.
augment_ns = argparse.Namespace(
config=args.config,
set=args.set,
name=args.feature_name,
arch=args.arch,
out=None,
staging=out_root,
skip_validation=args.skip_validation,
dry_run=args.dry_run,
verbose=args.verbose,
)
if args.dry_run:
sets = [parse_set_arg(s) for s in args.set]
if not args.skip_validation:
validate_sets(sets)
print(f"[dry-run] augment: inject {sets} into {args.config}")
augmented = _augment_staging_path(
out_root, args.arch, args.feature_name, os.path.basename(args.config)
)
else:
augmented = _do_augment(augment_ns)
print(f"[1/3] augment OK -> {augmented}")
# Stage 2: gen-logic.
gen_workdir = os.path.join(out_root, "gen", args.feature_name)
gen_ns = argparse.Namespace(
config=augmented,
arch=args.arch,
out=gen_workdir,
cu=args.cu,
feature_name=args.feature_name,
python=args.python,
dry_run=args.dry_run,
verbose=args.verbose,
)
cmd_gen_logic(gen_ns)
if not args.dry_run:
print("[2/3] gen-logic OK")
staging_root = os.path.join(gen_workdir, "experimental_logic")
# Stage 3: build-lib.
libdir = os.path.join(out_root, "lib", args.feature_name)
build_ns = argparse.Namespace(
logic_dir=staging_root,
arch=args.arch,
out=libdir,
experimental=True,
python=args.python,
dry_run=args.dry_run,
verbose=args.verbose,
jobs=None,
)
cmd_build_lib(build_ns)
if not args.dry_run:
print("[3/3] build-lib OK")
return 0
# ---------------------------------------------------------------------------
# patch-logic: override params on matching shipped-logic solutions and build a
# matched baseline/patched library pair (no re-benchmark).
# ---------------------------------------------------------------------------
# Diagnostic-context columns for patch_manifest.csv, appended after whatever
# --set/--where names the run actually queried (see cmd_patch_logic).
_PATCH_MANIFEST_PARAMS = (
"StreamK",
"MacroTile0",
"MacroTile1",
"DepthU",
"PrefetchGlobalRead",
"1LDSBuffer",
"DirectToLdsA",
"DirectToLdsB",
)
# A probe builds exactly one solution, so its internal TensileCreateLibrary -j
# gains nothing from scaling with probe concurrency (that would multiply out
# to concurrency * this many processes).
_PROBE_INTERNAL_JOBS = 1
def _apply_overrides(state: Dict[str, Any], sets: Sequence[Tuple[str, List[Any]]]) -> None:
"""Set each ``NAME=value`` override directly on a solution-state dict.
Each ``--set`` carries exactly one value (enforced by the caller), so the
scalar is written straight onto the state; rebuilding re-derives the kernel
because ``solutionStateToSolution`` clears the derived-parameter flags.
"""
for name, values in sets:
state[name] = values[0]
def _unique_staged_name(base: str, assigned: set) -> str:
"""Pick a collision-free staged filename for a source basename."""
if base not in assigned:
assigned.add(base)
return base
stem, dot, ext = base.rpartition(".")
n = 1
while True:
cand = f"{stem}_{n}.{ext}" if dot else f"{base}_{n}"
if cand not in assigned:
assigned.add(cand)
return cand
n += 1
def _probe_override(
args: argparse.Namespace,
raw: Any,
sol_pos: int,
override_state: Dict[str, Any],
probe_dir: str,
) -> Tuple[bool, str]:
"""Build a single-solution logic (with the override) to test buildability.
Writes ``raw`` with its solution element replaced by ``[override_state]``
into an ``Experimental`` staging tree, then runs TensileCreateLibrary and
classifies the outcome. Returns ``(passed, reason)``: ``reason`` is empty on
success, else ``reject`` / ``codegen`` / ``other`` from scanning output.
"""
import copy
probe_raw = copy.deepcopy(raw)
probe_raw[sol_pos] = [copy.deepcopy(override_state)]
# The truncated one-solution list invalidates any winner table that
# references other positions; force FreeSize's trivial [0, len(solutions)]
# dispatch instead of carrying over the source's real indexOrder/
# exactLogic/rangeLogic/LibraryType, which would otherwise be
# inconsistent with the truncated list.
for i in (6, 7, 8): # indexOrder, exactLogic, rangeLogic
if i < len(probe_raw):
probe_raw[i] = None
if len(probe_raw) > 11:
probe_raw[11] = "FreeSize"
logic_root = Path(probe_dir) / "logic"
dest = _experimental_staging_path(
str(logic_root), args.arch, args.feature_name, "probe.yaml"
)
dump_config_with_header(probe_raw, dest, args.feature_name)
out_dir = Path(probe_dir) / "out"
out_dir.mkdir(parents=True, exist_ok=True)
cmd = _tensile_create_library_cmd(
args.python,
str(logic_root),
str(out_dir),
args.arch,
experimental=True,
jobs=_PROBE_INTERNAL_JOBS,
)
log_path = str(Path(probe_dir) / "probe.log")
# The caller only invokes _probe_override when not args.dry_run, so this
# always runs for real.
rc, output = run_command(cmd, verbose=args.verbose, log_path=log_path)
if rc == 0:
return True, ""
low = output.lower()
if "rejection of a librarylogic" in low:
return False, "reject"
if "resulted in error" in low:
return False, "codegen"
return False, "other"
def cmd_patch_logic(args: argparse.Namespace) -> int:
import copy
import csv
import shutil
from concurrent.futures import ThreadPoolExecutor
if not args.feature_name:
raise ExperimentalLibraryError("patch-logic requires --feature-name.")
wheres = [parse_set_arg(w) for w in args.where]
if not wheres:
sys.stderr.write(
"patch-logic: warning: no --where given; --set will be applied to "
"every solution in --logic-src.\n"
)
sets = [parse_set_arg(s) for s in args.set]
if not sets:
raise ExperimentalLibraryError(
"patch-logic requires at least one --set NAME=value."
)
for name, values in sets:
if len(values) != 1:
raise ExperimentalLibraryError(
f"patch-logic --set '{name}' takes exactly one value (got "
f"{values}); patch-logic writes a single scalar per parameter. "
"Use --matched-pair to contrast this value against baseline."
)
if not args.skip_validation:
validate_sets(sets)
# Manifest columns: the --set/--where names the user actually queried come
# first (what they care about), _PATCH_MANIFEST_PARAMS fills in the rest as
# diagnostic context, with no duplicate columns for overlapping names.
manifest_params = _dedup_keys(
[name for name, _ in sets], [name for name, _ in wheres], _PATCH_MANIFEST_PARAMS
)
files = _resolve_logic_sources(args.logic_src)
out_root = os.path.realpath(args.out)
# Pass 1: parse each file, locate its solution list, and select matches.
# ``raw`` here is the untouched BASELINE for each file; the patched copy is
# derived later so both trees stay identical except for applied overrides.
file_infos: List[Dict[str, Any]] = []
assigned_names: set = set()
total_matched = 0
for path in files:
raw = load_yaml(path)
sol_pos, solutions = _find_solutions_element(raw, path)
matched = [
i
for i, s in enumerate(solutions)
if isinstance(s, dict) and solution_matches(s, wheres)
]
total_matched += len(matched)
staged_name = _unique_staged_name(os.path.basename(path), assigned_names)
file_infos.append(
{
"path": path,
"raw": raw,
"sol_pos": sol_pos,
"solutions": solutions,
"matched": matched,
"staged_name": staged_name,
}
)
print(
f"patch-logic: {path}: {len(matched)}/{len(solutions)} solution(s) "
f"match --where"
)
if total_matched == 0:
sys.stderr.write(
"patch-logic: warning: no solutions matched --where; the patched "
"library will be identical to baseline.\n"
)
# Pass 2: decide, per matching solution, whether the override is applied.
# Default: apply to all matches. With --skip-unbuildable, probe each match
# (single-solution build) and skip the override on any that fail, keeping
# that solution as baseline in BOTH libraries so dispatch never diverges.
probe_root = os.path.join(out_root, "_probe")
if args.skip_unbuildable and not args.dry_run:
os.makedirs(probe_root, exist_ok=True)
tasks = [] # (fi_index, sol_index, override_state, probe_dir)
for fi_idx, fi in enumerate(file_infos):
for pos in fi["matched"]:
override_state = copy.deepcopy(fi["solutions"][pos])
_apply_overrides(override_state, sets)
probe_dir = os.path.join(probe_root, f"{fi_idx}_{pos}")
tasks.append((fi_idx, pos, override_state, probe_dir))
results: Dict[Tuple[int, int], Tuple[bool, str]] = {}
max_workers = args.jobs if (args.jobs and args.jobs > 0) else (os.cpu_count() or 1)
if tasks:
print(
f"patch-logic: probing {len(tasks)} override(s) for buildability "
f"(up to {max_workers} concurrent)..."
)
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {
pool.submit(
_probe_override,
args,
file_infos[fi_idx]["raw"],
file_infos[fi_idx]["sol_pos"],
override_state,
probe_dir,
): (fi_idx, pos)
for (fi_idx, pos, override_state, probe_dir) in tasks
}
for fut, key in futures.items():
results[key] = fut.result()
# Attach per-solution decisions.
for fi_idx, fi in enumerate(file_infos):
decisions = {}
for pos in fi["matched"]:
passed, reason = results.get((fi_idx, pos), (True, ""))
decisions[pos] = (passed, reason)
fi["decisions"] = decisions
shutil.rmtree(probe_root, ignore_errors=True)
else:
# No probing: every match gets the override (a build failure surfaces
# directly). Dry-run also lands here and assumes all-applied for planning.
for fi in file_infos:
fi["decisions"] = {pos: (True, "") for pos in fi["matched"]}
# Pass 3: emit the patched (and, for --matched-pair, baseline) logic trees.
patched_root = os.path.join(out_root, "patched_logic")
baseline_root = os.path.join(out_root, "baseline_logic")
manifest_rows: List[List[Any]] = []
applied_total = 0
skipped_total = 0
for fi in file_infos:
raw = fi["raw"]
sol_pos = fi["sol_pos"]
staged_name = fi["staged_name"]
patched_raw = copy.deepcopy(raw)
patched_solutions = patched_raw[sol_pos]
for pos in fi["matched"]:
passed, reason = fi["decisions"][pos]
base_state = fi["solutions"][pos]
if passed:
_apply_overrides(patched_solutions[pos], sets)
applied_total += 1
status, row_reason = "applied", ""
else:
skipped_total += 1
status, row_reason = "skipped", reason
sol_index = base_state.get("SolutionIndex", pos)
manifest_rows.append(
[fi["path"], sol_index, status, row_reason]
+ [base_state.get(k, "") for k in manifest_params]
)
if not args.dry_run:
patched_dest = _experimental_staging_path(
patched_root, args.arch, args.feature_name, staged_name
)
dump_config_with_header(patched_raw, patched_dest, args.feature_name)
if args.matched_pair:
baseline_dest = _experimental_staging_path(
baseline_root, args.arch, args.feature_name, staged_name
)
dump_config_with_header(raw, baseline_dest, args.feature_name)
print(
f"patch-logic: {applied_total} override(s) applied, "
f"{skipped_total} skipped across {len(file_infos)} file(s)."
)
# Manifest CSV (one row per matching solution).
if not args.dry_run:
manifest_path = os.path.join(out_root, "patch_manifest.csv")
os.makedirs(out_root, exist_ok=True)
with open(manifest_path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(
["logic_file", "solution_index", "status", "reason"] + manifest_params
)
writer.writerows(manifest_rows)
print(f"patch-logic: manifest written to {manifest_path}")
# Pass 4: build the loadable library/libraries via the existing build path.
def _build_tree(logic_root: str, lib_out: str, label: str) -> str:
print(f"=== patch-logic: building {label} library ===")
ns = argparse.Namespace(
logic_dir=logic_root,
arch=args.arch,
out=lib_out,
experimental=True,
python=args.python,
dry_run=args.dry_run,
verbose=args.verbose,
jobs=args.jobs,
)
cmd_build_lib(ns)
return _resolve_lib_dir(lib_out, args.arch, must_exist=not args.dry_run)
patched_libpath = _build_tree(
patched_root, os.path.join(out_root, "patched"), "PATCHED"
)
baseline_libpath = None
if args.matched_pair:
baseline_libpath = _build_tree(
baseline_root, os.path.join(out_root, "baseline"), "BASELINE"
)
print("\npatch-logic OK. Export to load a library at runtime:")
print(f" PATCHED : export HIPBLASLT_TENSILE_LIBPATH={patched_libpath}")
if baseline_libpath is not None:
print(f" BASELINE: export HIPBLASLT_TENSILE_LIBPATH={baseline_libpath}")
return 0
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
def _add_global_flags(p: argparse.ArgumentParser, *, include_python: bool = True) -> None:
if include_python:
p.add_argument(
"--python",
default=None,
help="Interpreter to drive Tensile (default: repo venv if present, else current).",
)
p.add_argument("--dry-run", action="store_true", help="Print commands/env without executing.")
p.add_argument("--verbose", "-v", action="store_true", help="Verbose logging.")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="Tensile.ExperimentalLibrary",
description="Developer tool to build experimental hipBLASLt device libraries "
"for benchmarking new TensileLite codegen parameters.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = parser.add_subparsers(dest="command", required=True)
# list-solutions
pl = sub.add_parser(
"list-solutions",
help="List/filter solution indices in 3_LibraryLogic yaml file(s)/dir(s) by parameter.",
)
pl.add_argument(
"--logic-src", nargs="+", required=True,
help="Shipped 3_LibraryLogic yaml file(s) and/or dir(s) (dirs are "
"globbed for *.yaml recursively).",
)
pl.add_argument(
"--where", action="append", default=[], metavar="NAME=v1[,v2]",
help="Keep solutions whose NAME is one of the values "
"(repeatable; AND across keys, OR within values). Values match raw "
"solution-state shapes, e.g. StreamK=5.",
)
pl.add_argument(
"--indices-only", action="store_true",
help="Print only a comma-separated index list for the single resolved "
"logic file (feed to extract --indices); errors if --logic-src "
"resolves to more than one file, since indices are per-file.",
)
pl.set_defaults(func=cmd_list_solutions)
# extract
pe = sub.add_parser("extract", help="Reverse a 3_LibraryLogic yaml into a benchmark config.")
pe.add_argument("--logic", required=True, help="Shipped 3_LibraryLogic yaml.")
pe.add_argument("--indices", default="0", help="Comma-separated solution indices, e.g. 0,3.")
pe.add_argument("--out", required=True, help="Output config yaml path.")
pe.add_argument("--skip-mi", action="store_true", help="Skip the MatrixInstruction field.")
_add_global_flags(pe)
pe.set_defaults(func=cmd_extract)
# merge
pm = sub.add_parser(
"merge",
help="Merge per-solution extract configs into one multi-problem config.",
)
pm.add_argument(
"--configs", nargs="+", required=True,
help="Config yamls to merge (e.g. base_0.yaml base_3.yaml or base_*.yaml).",
)
pm.add_argument("--out", required=True, help="Output merged config yaml path.")
pm.add_argument(
"--feature-name", default=None, help="Optional header annotation."
)
pm.add_argument(
"--dry-run", action="store_true",
help="Print what would be merged without writing the output.",
)
pm.set_defaults(func=cmd_merge)
# augment
pa = sub.add_parser("augment", help="Validate and inject --set params into ForkParameters.")
pa.add_argument("--config", required=True, help="Base benchmark config yaml.")
pa.add_argument(
"--set", action="append", default=[], metavar="NAME=v1[,v2]",
help="Parameter to toggle (repeatable).",
)
pa.add_argument(
"--feature-name", dest="name", default=None,
help="Feature name (staging dir component).",
)
# Back-compat hidden alias for --feature-name.
pa.add_argument("--name", dest="name", default=None, help=argparse.SUPPRESS)
pa.add_argument("--arch", default="gfx950", help="Target arch for staging path (default gfx950).")
pa.add_argument("--out", default=None, help="Explicit output path (overrides staging layout).")
pa.add_argument(
"--staging", default="experimental_staging",
help="Staging root when --out is not given.",
)
pa.add_argument(
"--skip-validation", action="store_true",
help="Skip --set validation against validParameters (no rocisa needed; "
"pure config editing).",
)
pa.add_argument(
"--dry-run", action="store_true",
help="Print what would be injected without writing the output.",
)
pa.set_defaults(func=cmd_augment)
# gen-logic
pg = sub.add_parser("gen-logic", help="Benchmark on the target-arch GPU and validate the produced logic (requires that GPU present).")
pg.add_argument("--config", required=True, help="Augmented benchmark config yaml.")
pg.add_argument("--arch", default="gfx950", help="GPU target (default gfx950).")
pg.add_argument("--out", required=True, help="Work directory for Tensile output.")
pg.add_argument("--cu", type=int, default=304, help="CU count pinned via the CU env for the build predicate.")
pg.add_argument("--feature-name", default=None, help="Feature name for the Experimental dir.")
_add_global_flags(pg)
pg.set_defaults(func=cmd_gen_logic)
# build-lib
pb = sub.add_parser("build-lib", help="Run TensileCreateLibrary to build a loadable library.")
pb.add_argument("--logic-dir", required=True, help="Dir containing Experimental/... logic.")
pb.add_argument("--arch", default="gfx950", help="GPU target (default gfx950).")
pb.add_argument("--out", required=True, help="Output library directory.")
pb.add_argument(
"--experimental", dest="experimental", action="store_true", default=True,
help="Include Experimental/ logic (default on).",
)
pb.add_argument(
"--no-experimental", dest="experimental", action="store_false",
help="Disable Experimental/ inclusion.",
)
pb.add_argument(
"--jobs", "-j", dest="jobs", type=int, default=None,
help="Parallel jobs for TensileCreateLibrary (passed through as -j).",
)
_add_global_flags(pb)
pb.set_defaults(func=cmd_build_lib)
# patch-logic
ppl = sub.add_parser(
"patch-logic",
help="Override --set params on shipped-logic solutions (optionally "
"narrowed by --where) and build a matched baseline/patched library "
"pair (no re-benchmark).",
)
ppl.add_argument(
"--logic-src", nargs="+", required=True,
help="Shipped 3_LibraryLogic yaml file(s) and/or dir(s) (dirs are "
"globbed for *.yaml recursively).",
)
ppl.add_argument(
"--where", action="append", default=[], metavar="NAME=v1[,v2]",
help="Select solutions to patch (repeatable; AND across keys, OR within "
"values), e.g. --where StreamK=5. Omit to select every solution in "
"--logic-src.",
)
ppl.add_argument(
"--set", action="append", default=[], required=True, metavar="NAME=value",
help="Override to apply to matching solutions (repeatable; one value "
"each), e.g. --set PrefetchAcrossPersistent=1.",
)
ppl.add_argument(
"--matched-pair", action="store_true",
help="Also emit an unmodified baseline library alongside the patched "
"one for A/B comparison.",
)
ppl.add_argument(
"--skip-unbuildable", action="store_true",
help="Probe each override; if a solution's override fails to build, skip "
"the override (keep it as baseline in both libraries) instead of aborting.",
)
ppl.add_argument("--arch", default="gfx950", help="GPU target (default gfx950).")
ppl.add_argument("--out", required=True, help="Output root for logic/libraries/manifest.")
ppl.add_argument("--feature-name", required=True, help="Experimental staging dir component.")
ppl.add_argument(
"--jobs", "-j", dest="jobs", type=int, default=None,
help="Parallel jobs: TensileCreateLibrary -j for the final build(s), and "
"max concurrent probes with --skip-unbuildable (each probe builds a "
"single solution regardless of this value). Default: all CPUs for "
"probing, whatever TensileCreateLibrary's own default is for builds.",
)
ppl.add_argument(
"--skip-validation", action="store_true",
help="Skip --set validation against validParameters (no rocisa needed).",
)
_add_global_flags(ppl)
ppl.set_defaults(func=cmd_patch_logic)
# find-index
pf = sub.add_parser(
"find-index",
help="Discover solution indices via hipblaslt-bench --algo_method all.",
)
pf.add_argument(
"--lib", required=True,
help="Build <out> root, its 'library/' dir, or the per-arch "
"'library/<arch>' dir.",
)
pf.add_argument("--arch", default="gfx950", help="GPU target (default gfx950).")
pf.add_argument("--bench", default=None, help="Path to hipblaslt-bench.")
pf.add_argument("extra", nargs=argparse.REMAINDER, help="Problem args after --.")
_add_global_flags(pf, include_python=False)
pf.set_defaults(func=cmd_find_index)
# bench
pbn = sub.add_parser("bench", help="Run hipblaslt-bench against a specific solution index.")
pbn.add_argument(
"--lib", required=True,
help="Build <out> root, its 'library/' dir, or the per-arch "
"'library/<arch>' dir.",
)
pbn.add_argument("--arch", default="gfx950", help="GPU target (default gfx950).")
pbn.add_argument("--bench", required=True, help="Path to hipblaslt-bench.")
pbn.add_argument("--solution-index", required=True, type=int, help="Solution index to run.")
pbn.add_argument("extra", nargs=argparse.REMAINDER, help="Problem args after --.")
_add_global_flags(pbn, include_python=False)
pbn.set_defaults(func=cmd_bench)
# pipeline
pp = sub.add_parser("pipeline", help="Chain augment -> gen-logic -> build-lib.")
pp.add_argument("--config", required=True, help="Base benchmark config yaml.")
pp.add_argument(
"--set", action="append", default=[], metavar="NAME=v1[,v2]",
help="Parameter to toggle (repeatable).",
)
pp.add_argument("--feature-name", required=True, help="Feature name.")
pp.add_argument("--arch", default="gfx950", help="GPU target (default gfx950).")
pp.add_argument("--cu", type=int, default=304, help="CU count pinned via the CU env for the build predicate.")
pp.add_argument("--out", required=True, help="Output root for all stages.")
pp.add_argument(
"--skip-validation", action="store_true",
help="Skip --set validation against validParameters (no rocisa needed).",
)
_add_global_flags(pp)
pp.set_defaults(func=cmd_pipeline)
return parser
def main(argv: Optional[Sequence[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if getattr(args, "python", None) is None:
args.python = default_python()
try:
return args.func(args)
except ExperimentalLibraryError as e:
print(f"error: {e}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())