From ed3dcb64500c1fdc303ed0772b717fb3ff227f12 Mon Sep 17 00:00:00 2001 From: Garby Gilbert Date: Thu, 8 Oct 2026 07:53:40 -0600 Subject: [PATCH 01/13] ci(hipdnn): run the tooling pytest suites in the Linux superbuild lane The IngestorGenerator and DescriptorGenerator pytest suites never ran in the superbuild lanes: the CI venv lacked PyYAML and Jinja2, and nothing registered them with ctest. Register both as ctest tests behind HIPDNN_ENABLE_TOOLING_TESTS, which is off by default and fails configure when pytest, PyYAML or Jinja2 is missing from the interpreter, and enable it in the Linux superbuild job. ctest reports a suite whose every case skips as a pass, so check_pytest_junit.py reads the pytest junit and fails on any skip that hipdnn-tooling-allowed-skips.txt does not name, and on any allow-list entry that no longer matches a skip. The gates that skipped for want of an input are closed where the build can supply it. ctest exports HIPDNN_VALIDATE_DESCRIPTORS for the round-trip tests and writes the flatbuffers and HIP include directories to a file named by HIPDNN_INGESTOR_STUB_INCLUDE_FILE for the native-stub tests, where a bad value now fails instead of skipping. The DescriptorGenerator CLI tests run under sys.executable instead of skipping without a local .venv, and the dispatch-parity tests fail, rather than skip, when the shipped profile names a module the in-tree rocKE does not provide. ALMIOPEN-2843 --- .github/hipdnn-tooling-allowed-skips.txt | 13 ++ .github/requirements-hipdnn-superbuild-ci.txt | 3 + .github/scripts/check_pytest_junit.py | 135 ++++++++++++++++++ .../scripts/tests/test_check_pytest_junit.py | 119 +++++++++++++++ .github/workflows/hipdnn-superbuild-ci.yml | 24 +++- projects/hipdnn/CMakeLists.txt | 3 + projects/hipdnn/test_categories.yaml | 4 + projects/hipdnn/tools/CMakeLists.txt | 101 +++++++++++++ .../tests/test_generate.py | 8 +- .../hipdnn/tools/IngestorGenerator/README.md | 5 +- .../tests/test_dispatch_parity.py | 35 +++++ .../tests/test_native_stub.py | 45 ++++-- 12 files changed, 476 insertions(+), 19 deletions(-) create mode 100644 .github/hipdnn-tooling-allowed-skips.txt create mode 100644 .github/scripts/check_pytest_junit.py create mode 100644 .github/scripts/tests/test_check_pytest_junit.py diff --git a/.github/hipdnn-tooling-allowed-skips.txt b/.github/hipdnn-tooling-allowed-skips.txt new file mode 100644 index 000000000000..389b8edef4e2 --- /dev/null +++ b/.github/hipdnn-tooling-allowed-skips.txt @@ -0,0 +1,13 @@ +# Skips the Linux superbuild lane accepts from the hipDNN tooling pytest suites. +# +# Read by .github/scripts/check_pytest_junit.py in .github/workflows/hipdnn-superbuild-ci.yml. +# Each line is ` # `; every entry needs a reason. The pattern is an fnmatch +# glob over `::::`, e.g. +# `hipdnn_ingestor_generator_pytest::tests.test_round_trip::test_x[param]`. The junit stem is +# the ctest test name. An entry that matches no skipped test fails the check, so delete entries +# whose skip is gone. +# +# Add an entry only for an input the author supplies by design. A skip whose input the build +# can provide is a gate to close, not an entry to add. +hipdnn_ingestor_generator_pytest::tests.test_coverage_gate.TestAgainstTheRealBuild::* # needs an authoring profile matching the packed build; the repo ships none by design (test_coverage_gate.py _NEEDS_BUILD_REASON) +hipdnn_ingestor_generator_pytest::tests.test_variant_reachability.TestGfx950RealBundle::* # needs a mined request corpus via HIPDNN_INGESTOR_SHAPES; the repo ships none by design diff --git a/.github/requirements-hipdnn-superbuild-ci.txt b/.github/requirements-hipdnn-superbuild-ci.txt index 176b77e9f322..53727c7f955d 100644 --- a/.github/requirements-hipdnn-superbuild-ci.txt +++ b/.github/requirements-hipdnn-superbuild-ci.txt @@ -5,8 +5,11 @@ # pytest -- the descriptor-packaging suite, and the frontend wheel tests. # build -- the PEP 517 frontend that packs the frontend wheel. # numpy -- imported by frontend_wheel_package/tests, not by the build. +# PyYAML, Jinja2 -- the DescriptorGenerator/IngestorGenerator tooling suites (HIPDNN_ENABLE_TOOLING_TESTS). # # Installed with --upgrade, so nothing is pinned here. pytest build numpy +PyYAML +Jinja2 diff --git a/.github/scripts/check_pytest_junit.py b/.github/scripts/check_pytest_junit.py new file mode 100644 index 000000000000..2d4bd77fb87c --- /dev/null +++ b/.github/scripts/check_pytest_junit.py @@ -0,0 +1,135 @@ +# Copyright (c) Advanced Micro Devices, Inc., or its affiliates. +# SPDX-License-Identifier: MIT +"""Fail CI when a pytest suite skipped a test its allow-list does not name. + +ctest reports a pytest invocation as passed even when every case inside it skipped, +so a gate that skips for want of an input (a compiler, a binary, an environment +variable) passes silently. The pytest junit file is the only place those skips are +visible. This reads one or more of them and fails on: + + unexpected-skip a skipped (or xfailed) testcase no allow-list pattern matches + stale-allow an allow-list pattern that matched no skipped testcase, so the + entry outlived the skip it excused + junit-unreadable a junit file that is missing or is not valid XML + junit-empty a junit file with no testcase, i.e. the suite collected nothing + allow-list-unreadable the allow-list file cannot be read + allow-list-malformed an allow-list line that is not `` # `` + +Allow-list format: blank lines and lines starting with ``#`` are ignored; every other +line is `` # ``, split on the first ``#``, both parts non-empty. The +pattern is an fnmatch glob over ``::::``. +""" + +import argparse +import fnmatch +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +PREFIX = "check_pytest_junit" + + +def _parse_allow_list(allow_path, failures): + """Return the allow-list's patterns; append any problem to ``failures``.""" + try: + text = Path(allow_path).read_text(encoding="utf-8") + except OSError as exc: + failures.append(("allow-list-unreadable", f"{allow_path}: {exc}")) + return [] + patterns = [] + for number, raw in enumerate(text.splitlines(), start=1): + line = raw.strip() + if not line or line.startswith("#"): + continue + pattern, sep, reason = line.partition("#") + pattern, reason = pattern.strip(), reason.strip() + if not sep or not pattern or not reason: + failures.append( + ( + "allow-list-malformed", + f"{allow_path}:{number}: expected ' # ', got {line!r}", + ) + ) + continue + patterns.append(pattern) + return patterns + + +def check(junit_paths, allow_path): + """Return ``(kind, detail)`` for every problem; empty means the check passed.""" + failures = [] + patterns = _parse_allow_list(allow_path, failures) + matched = set() + for junit_path in map(Path, junit_paths): + try: + root = ET.parse(junit_path).getroot() + except (OSError, ET.ParseError) as exc: + failures.append(("junit-unreadable", f"{junit_path}: {exc}")) + continue + testcases = list(root.iter("testcase")) + if not testcases: + failures.append(("junit-empty", f"{junit_path}: no testcase recorded")) + continue + for case in testcases: + skipped = case.find("skipped") + if skipped is None: + continue + case_id = f"{junit_path.stem}::{case.get('classname', '')}::{case.get('name', '')}" + hits = [p for p in patterns if fnmatch.fnmatchcase(case_id, p)] + if hits: + matched.update(hits) + else: + message = skipped.get("message") or (skipped.text or "").strip() + failures.append(("unexpected-skip", f"{case_id}: {message}")) + for pattern in patterns: + if pattern not in matched: + failures.append(("stale-allow", f"{pattern}: matched no skipped testcase")) + return failures + + +def _count(junit_paths): + """``(testcases, allow-listed skips)`` over the readable junit files.""" + testcases = skips = 0 + for junit_path in map(Path, junit_paths): + try: + root = ET.parse(junit_path).getroot() + except (OSError, ET.ParseError): + continue + cases = list(root.iter("testcase")) + testcases += len(cases) + skips += sum(1 for case in cases if case.find("skipped") is not None) + return testcases, skips + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument( + "--junit", + action="append", + required=True, + metavar="PATH", + help="pytest junit XML file; repeat for several suites", + ) + parser.add_argument( + "--allow-skips", + required=True, + metavar="PATH", + help="allow-list of ' # ' lines naming the skips to accept", + ) + args = parser.parse_args(argv) + + failures = check(args.junit, args.allow_skips) + if failures: + for kind, detail in failures: + print(f"{PREFIX}: FAIL {kind}: {detail}") + return 1 + testcases, skips = _count(args.junit) + print( + f"{PREFIX}: OK {testcases} testcase(s) in {len(args.junit)} junit file(s), " + f"{skips} allow-listed skip(s)" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/tests/test_check_pytest_junit.py b/.github/scripts/tests/test_check_pytest_junit.py new file mode 100644 index 000000000000..0b661fd564cc --- /dev/null +++ b/.github/scripts/tests/test_check_pytest_junit.py @@ -0,0 +1,119 @@ +# Copyright (c) Advanced Micro Devices, Inc., or its affiliates. +# SPDX-License-Identifier: MIT + +import os +import sys +from pathlib import Path + +sys.path.insert(0, os.fspath(Path(__file__).parent.parent)) +import check_pytest_junit as cpj + + +def _junit(path, cases): + """Write a junit file; ``cases`` is [(classname, name, skip_message_or_None)].""" + rows = [] + for classname, name, skip in cases: + body = "" if skip is None else f'' + rows.append( + f'{body}' + ) + path.write_text( + '' + + "".join(rows) + + "" + ) + return path + + +def _allow(path, text): + path.write_text(text) + return path + + +def _run(junit, allow, capsys): + rc = cpj.main(["--junit", str(junit), "--allow-skips", str(allow)]) + return rc, capsys.readouterr().out + + +def test_all_passing_with_empty_allow_list_is_ok(tmp_path, capsys): + junit = _junit(tmp_path / "suite.xml", [("tests.test_a", "test_one", None)]) + rc, out = _run(junit, _allow(tmp_path / "allow.txt", "# nothing\n"), capsys) + assert rc == 0 + assert "OK 1 testcase(s) in 1 junit file(s), 0 allow-listed skip(s)" in out + + +def test_unlisted_skip_fails(tmp_path, capsys): + junit = _junit( + tmp_path / "suite.xml", [("tests.test_a", "test_one", "no compiler")] + ) + rc, out = _run(junit, _allow(tmp_path / "allow.txt", ""), capsys) + assert rc == 1 + assert "unexpected-skip" in out + assert "suite::tests.test_a::test_one" in out + assert "no compiler" in out + + +def test_allow_listed_skip_passes(tmp_path, capsys): + junit = _junit( + tmp_path / "suite.xml", + [("tests.test_a.TestReal", "test_x[p1]", "needs input")], + ) + allow = _allow( + tmp_path / "allow.txt", + "suite::tests.test_a.TestReal::* # needs input by design\n", + ) + rc, out = _run(junit, allow, capsys) + assert rc == 0 + assert "1 allow-listed skip(s)" in out + + +def test_pattern_matching_no_skip_is_stale(tmp_path, capsys): + junit = _junit(tmp_path / "suite.xml", [("tests.test_a", "test_one", None)]) + allow = _allow( + tmp_path / "allow.txt", "suite::tests.test_gone::* # was skipped once\n" + ) + rc, out = _run(junit, allow, capsys) + assert rc == 1 + assert "stale-allow" in out + + +def test_pattern_is_scoped_to_its_junit_file(tmp_path, capsys): + junit = _junit(tmp_path / "other.xml", [("tests.test_a", "test_one", "skip")]) + allow = _allow(tmp_path / "allow.txt", "suite::tests.test_a::* # wrong suite\n") + rc, out = _run(junit, allow, capsys) + assert rc == 1 + assert "unexpected-skip" in out + assert "stale-allow" in out + + +def test_missing_junit_is_unreadable(tmp_path, capsys): + rc, out = _run(tmp_path / "absent.xml", _allow(tmp_path / "allow.txt", ""), capsys) + assert rc == 1 + assert "junit-unreadable" in out + + +def test_junit_without_testcases_is_empty(tmp_path, capsys): + junit = _junit(tmp_path / "suite.xml", []) + rc, out = _run(junit, _allow(tmp_path / "allow.txt", ""), capsys) + assert rc == 1 + assert "junit-empty" in out + + +def test_allow_list_line_without_reason_is_malformed(tmp_path, capsys): + junit = _junit(tmp_path / "suite.xml", [("tests.test_a", "test_one", "skip")]) + allow = _allow(tmp_path / "allow.txt", "suite::tests.test_a::*\n") + rc, out = _run(junit, allow, capsys) + assert rc == 1 + assert "allow-list-malformed" in out + + +def test_several_junit_files_are_all_checked(tmp_path, capsys): + clean = _junit(tmp_path / "a.xml", [("tests.t", "test_one", None)]) + skipping = _junit(tmp_path / "b.xml", [("tests.t", "test_two", "skip")]) + allow = _allow(tmp_path / "allow.txt", "") + rc = cpj.main( + ["--junit", str(clean), "--junit", str(skipping), "--allow-skips", str(allow)] + ) + out = capsys.readouterr().out + assert rc == 1 + assert "b::tests.t::test_two" in out diff --git a/.github/workflows/hipdnn-superbuild-ci.yml b/.github/workflows/hipdnn-superbuild-ci.yml index 581d3fff8a9b..74f32cd4a8cc 100644 --- a/.github/workflows/hipdnn-superbuild-ci.yml +++ b/.github/workflows/hipdnn-superbuild-ci.yml @@ -11,6 +11,9 @@ on: types: [opened, synchronize, reopened] paths: - '.github/workflows/hipdnn-superbuild-ci.yml' + - '.github/requirements-hipdnn-superbuild-ci.txt' + - '.github/scripts/check_pytest_junit.py' + - '.github/hipdnn-tooling-allowed-skips.txt' - 'projects/hipdnn/**' - 'dnn-providers/**' schedule: @@ -336,7 +339,7 @@ jobs: - name: Install build tools run: | sudo apt-get update - sudo apt-get install -y ninja-build ccache + sudo apt-get install -y ninja-build ccache g++ # clang-tidy-20 comes from Ubuntu's noble-updates archive rather than # apt.llvm.org, whose intermittent outages failed this step. sudo apt-get install -y clang-tidy-20 clang-tools-20 @@ -442,8 +445,10 @@ jobs: echo "Fetched $count golden tensor blob(s)" # Must precede configure: configuration fails if pytest is not importable - # by the interpreter descriptor-packaging registers its tests under. The - # interpreter is named explicitly because it must be the one passed as + # by the interpreter descriptor-packaging registers its tests under, or if + # pytest, PyYAML and Jinja2 are not importable by it for the hipDNN + # DescriptorGenerator/IngestorGenerator tooling suites. The interpreter is + # named explicitly because it must be the one passed as # -DPython3_EXECUTABLE below -- a bare `pip` here would install into the # setup-python tool-cache interpreter instead. - name: Install Python dependencies @@ -462,6 +467,7 @@ jobs: -DHIPKERNELPROVIDER_ENABLE_ROCKE=ON \ -DHIPDNN_ENABLE_CUDNN_COMPATIBILITY=ON \ -DHIPDNN_ENABLE_KERNEL_INGESTOR=ON \ + -DHIPDNN_ENABLE_TOOLING_TESTS=ON \ -DHIPDNN_ENABLE_SDPA=ON \ -DHIPKERNELPROVIDER_PRODUCTION_SOURCE_ROOT="$GITHUB_WORKSPACE/dnn-providers/hip-kernel-provider/descriptor-packaging/examples/descriptors" \ -DHIPKERNELPROVIDER_ROCKE_COMGR_LIB="$HKP_ROCKE_COMGR_LIB" \ @@ -486,8 +492,20 @@ jobs: printf '%s\n' "${kpacks[@]}" - name: Run tests + id: run-tests run: ctest --test-dir build --output-on-failure + # The tooling suites run inside the ctest step above as two ctest tests, which pass + # even when every pytest case inside them skips. Their pytest junit is the only + # place a skip is visible; fail on any skip the allow-list does not name. + - name: Check tooling tests ran without unexpected skips + if: ${{ !cancelled() && steps.run-tests.outcome != 'skipped' }} + run: | + "$VENV_PYTHON" .github/scripts/check_pytest_junit.py \ + --junit build/tooling-junit/hipdnn_descriptor_generator_pytest.xml \ + --junit build/tooling-junit/hipdnn_ingestor_generator_pytest.xml \ + --allow-skips .github/hipdnn-tooling-allowed-skips.txt + # Golden-data validation used to live here as a schedule-gated re-run of the # external integration targets with HIPDNN_TEST_VERIFICATION_MODE=golden-check. # That spelling is retired: it is now the hipdnn_golden_data_tests binary, diff --git a/projects/hipdnn/CMakeLists.txt b/projects/hipdnn/CMakeLists.txt index 730981343517..8264258937a0 100644 --- a/projects/hipdnn/CMakeLists.txt +++ b/projects/hipdnn/CMakeLists.txt @@ -197,6 +197,9 @@ if(DEFINED HIP_DNN_SKIP_TESTS) message(DEPRECATION "HIP_DNN_SKIP_TESTS has been renamed to HIPDNN_SKIP_TESTS. Please update your build scripts to use -DHIPDNN_SKIP_TESTS instead.") endif() option(HIPDNN_SKIP_TESTS "Skips building all tests" OFF) +option(HIPDNN_ENABLE_TOOLING_TESTS + "Register the DescriptorGenerator/IngestorGenerator pytest suites as ctest tests (needs pytest, PyYAML and Jinja2 importable by Python3_EXECUTABLE)" + OFF) option(HIPDNN_ENABLE_COVERAGE "Build with code coverage flags" OFF) option(BUILD_ADDRESS_SANITIZER "Build with Address Sanitizer enabled" OFF) diff --git a/projects/hipdnn/test_categories.yaml b/projects/hipdnn/test_categories.yaml index 77d76223c5a7..ccd6a5ae86f0 100644 --- a/projects/hipdnn/test_categories.yaml +++ b/projects/hipdnn/test_categories.yaml @@ -45,6 +45,10 @@ test_categories: - "hipdnn_frontend_tests" - "hipdnn_test_sdk_tests" - "hipdnn_plugin_sdk_tests" + # Build tree only, registered only with HIPDNN_ENABLE_TOOLING_TESTS=ON; see + # tools/CMakeLists.txt. + - "hipdnn_descriptor_generator_pytest" + - "hipdnn_ingestor_generator_pytest" integration: description: "Current hipDNN integration-check coverage" diff --git a/projects/hipdnn/tools/CMakeLists.txt b/projects/hipdnn/tools/CMakeLists.txt index 695e90431034..391dbe614d5a 100644 --- a/projects/hipdnn/tools/CMakeLists.txt +++ b/projects/hipdnn/tools/CMakeLists.txt @@ -121,6 +121,41 @@ endif() _apply_hipdnn_test_category_labels(hipdnn_list_engines_smoke) endif() +# DescriptorGenerator pytest suite, plus the junit directory both tooling suites write to. +# ctest reports a suite whose every case skips as a pass, so the pytest junit is the only place +# a skip is visible: .github/scripts/check_pytest_junit.py reads it in the Linux superbuild +# lane and fails on any skip .github/hipdnn-tooling-allowed-skips.txt does not name. +# Build tree only, like the smoke tests above. The option is OFF by default so TheRock and +# developer builds are unchanged; when ON, a missing prerequisite fails configure rather than +# silently skipping. +if(HIPDNN_ENABLE_TOOLING_TESTS AND NOT HIPDNN_SKIP_TESTS) + if(NOT Python3_FOUND) + message(FATAL_ERROR "hipDNN: HIPDNN_ENABLE_TOOLING_TESTS=ON needs a Python3 interpreter") + endif() + execute_process( + COMMAND "${Python3_EXECUTABLE}" -c "import pytest, yaml, jinja2" + RESULT_VARIABLE _hipdnn_tooling_imports + OUTPUT_QUIET ERROR_QUIET + ) + if(NOT _hipdnn_tooling_imports EQUAL 0) + message( + FATAL_ERROR + "hipDNN: pytest, PyYAML and Jinja2 are not all importable by " + "${Python3_EXECUTABLE}, so the tooling tests cannot run. Install " + "tools/IngestorGenerator/requirements.txt for that interpreter, or configure " + "with -DHIPDNN_ENABLE_TOOLING_TESTS=OFF." + ) + endif() + set(HIPDNN_TOOLING_JUNIT_DIR "${CMAKE_BINARY_DIR}/tooling-junit") + add_test( + NAME hipdnn_descriptor_generator_pytest + COMMAND + "${Python3_EXECUTABLE}" -m pytest "${CMAKE_CURRENT_SOURCE_DIR}/DescriptorGenerator/tests" + -v "--junitxml=${HIPDNN_TOOLING_JUNIT_DIR}/hipdnn_descriptor_generator_pytest.xml" + ) + _apply_hipdnn_test_category_labels(hipdnn_descriptor_generator_pytest) +endif() + if(HIPDNN_ENABLE_KERNEL_INGESTOR) # ValidateDescriptors.cpp transitively includes through # DescriptorLoader.hpp, so it needs the same HIP wiring every ingestor test target @@ -243,5 +278,71 @@ endif() endif() _apply_hipdnn_test_category_labels(hipdnn_validate_descriptors_smoke) + + # IngestorGenerator pytest suite. It runs here, not next to the DescriptorGenerator + # suite above, because two of its gates need this directory's tool target: + # * tests/test_round_trip.py runs the validator named by HIPDNN_VALIDATE_DESCRIPTORS. + # * tests/test_native_stub.py compiles generated stubs against the flatbuffers and + # HIP headers listed in the file named by HIPDNN_INGESTOR_STUB_INCLUDE_FILE. + # Without them those tests skip, and ctest reports a suite of skips as a pass. + if(HIPDNN_ENABLE_TOOLING_TESTS) + add_test( + NAME hipdnn_ingestor_generator_pytest + COMMAND + "${Python3_EXECUTABLE}" -m pytest + "${CMAKE_CURRENT_SOURCE_DIR}/IngestorGenerator/tests" -v + "--junitxml=${HIPDNN_TOOLING_JUNIT_DIR}/hipdnn_ingestor_generator_pytest.xml" + ) + + set(_hipdnn_ingestor_pytest_env + "HIPDNN_VALIDATE_DESCRIPTORS=$" + ) + + # The native-stub tests need a POSIX C++ compiler; the Windows lane is out of scope. + if(NOT WIN32) + # TARGET_PROPERTY does not follow ALIAS targets, so resolve to the real target. + set(_hipdnn_stub_include_exprs "") + foreach(_stub_dep flatbuffers::flatbuffers hip::host hip::amdhip64) + if(TARGET ${_stub_dep}) + get_target_property(_stub_real ${_stub_dep} ALIASED_TARGET) + if(NOT _stub_real) + set(_stub_real ${_stub_dep}) + endif() + string( + APPEND + _hipdnn_stub_include_exprs + "$,\n>\n" + ) + endif() + endforeach() + file( + GENERATE + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/ingestor_stub_include_dirs.txt" + CONTENT "${_hipdnn_stub_include_exprs}" + ) + list( + APPEND + _hipdnn_ingestor_pytest_env + "HIPDNN_INGESTOR_STUB_INCLUDE_FILE=${CMAKE_CURRENT_BINARY_DIR}/ingestor_stub_include_dirs.txt" + ) + endif() + + if(DEFINED TEST_ENVIRONMENT) + list(APPEND _hipdnn_ingestor_pytest_env ${TEST_ENVIRONMENT}) + endif() + set_tests_properties( + hipdnn_ingestor_generator_pytest PROPERTIES ENVIRONMENT + "${_hipdnn_ingestor_pytest_env}" + ) + + if(DEFINED TEST_ENVIRONMENT_MODIFICATION) + set_tests_properties( + hipdnn_ingestor_generator_pytest + PROPERTIES ENVIRONMENT_MODIFICATION "${TEST_ENVIRONMENT_MODIFICATION}" + ) + endif() + + _apply_hipdnn_test_category_labels(hipdnn_ingestor_generator_pytest) + endif() endif() endif() diff --git a/projects/hipdnn/tools/DescriptorGenerator/tests/test_generate.py b/projects/hipdnn/tools/DescriptorGenerator/tests/test_generate.py index f2b4fd7bb57d..645188138d84 100644 --- a/projects/hipdnn/tools/DescriptorGenerator/tests/test_generate.py +++ b/projects/hipdnn/tools/DescriptorGenerator/tests/test_generate.py @@ -8,6 +8,7 @@ """ import subprocess +import sys from pathlib import Path import pytest @@ -324,11 +325,8 @@ def generate_script(self): @pytest.fixture def python_exe(self): - """Path to the .venv Python executable.""" - exe = Path(__file__).parent.parent / ".venv" / "bin" / "python" - if not exe.exists(): - pytest.skip("No .venv/bin/python found; CLI tests require a local venv") - return exe + """The interpreter running pytest; it already has PyYAML/Jinja2.""" + return Path(sys.executable) def test_missing_config_file_exits(self, generate_script, python_exe, tmp_path): """--config pointing to a nonexistent file exits with code 1.""" diff --git a/projects/hipdnn/tools/IngestorGenerator/README.md b/projects/hipdnn/tools/IngestorGenerator/README.md index bd081f2ec73b..fecc163eb3ba 100644 --- a/projects/hipdnn/tools/IngestorGenerator/README.md +++ b/projects/hipdnn/tools/IngestorGenerator/README.md @@ -575,7 +575,10 @@ matcher-test stubs with `g++`/`clang++`, plus an intentionally broken source con plugin/data/flatbuffers SDK and provider sources beside this checkout plus `flatbuffers/array.h` under `/opt/rocm/include`, and generates stand-ins for the CMake-configured `version.h`/`CacheRootDefaults.h` headers. Missing prerequisites skip -explicitly. +explicitly. Setting `HIPDNN_INGESTOR_STUB_INCLUDE_FILE` to a file of include directories +(one per line, containing `flatbuffers/array.h` and `hip/hip_runtime.h`) replaces the +`/opt/rocm/include` probe, and a bad value then fails rather than skips; the hipDNN +superbuild sets it under `HIPDNN_ENABLE_TOOLING_TESTS`. ### Fragment contracts (`tests/test_fragment_contracts.py`) diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py index d3592230a6c8..93dcde30991e 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py @@ -380,6 +380,30 @@ def test_dense_persistent_is_the_string_off_not_a_yaml_boolean(self): ) assert value == "off", value + @staticmethod + def _provided_by_the_bound_provider(top: str, profile) -> bool: + """True when top-level module ``top`` resolves to a file inside the profile's + provider root, i.e. the provider itself, not one of its dependencies.""" + import importlib.util + + if not top: + return False + try: + spec = importlib.util.find_spec(top) + except (ImportError, ValueError): + return False + if spec is None: + return False + root = ( + Path(__file__).resolve().parents[5] + / os.path.expanduser(profile["provider_root"]) + ).resolve() + locations = [spec.origin, *(spec.submodule_search_locations or [])] + for location in locations: + if location and Path(location).resolve().is_relative_to(root): + return True + return False + @staticmethod def _resolve_with_the_real_dispatcher(monkeypatch, **overrides): """B1, Sq=Skv=8192, Hq=Hkv=8, D=128, bf16, causal through the dispatcher and @@ -399,6 +423,17 @@ def _resolve_with_the_real_dispatcher(monkeypatch, **overrides): factory_module = importlib.import_module(dispatch["module"]) request_module = importlib.import_module(request["module"]) except ImportError as exc: + missing = exc.name or "" + owner = TestTheShippedProfilePinsTheDispatchArmItsCatalogWasBuiltFrom + if owner._provided_by_the_bound_provider(missing.split(".")[0], profile): + # The library is bound and owns this name, so the profile is stale. A skip + # here hid that: fail so it is seen. + pytest.fail( + f"the shipped profile names {missing!r}, which the in-tree rocKE no " + f"longer provides -- port {_SHIPPED_PROFILE.name} to the current " + "rocKE dispatcher API" + ) + # Not the provider's own: a third-party dependency of it is absent. pytest.skip( f"the rocKE library cannot be imported here ({exc}) -- run with an " "interpreter that has its dependencies, e.g. /dnn-providers/" diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_native_stub.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_native_stub.py index c064b478a36d..64318c9b7919 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_native_stub.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_native_stub.py @@ -4,6 +4,7 @@ """A real compiler's verdict on the emitted C++. ``packs/Native.cpp`` is what an agent or a human fills in to make an engine serve real graphs (see RUNBOOK.md).""" +import os import subprocess import shutil import tempfile @@ -45,15 +46,39 @@ def compile_env(): "this checkout -- cannot attempt a real compile" ) - # Vendored flatbuffers headers: prefer an installed ROCm's, since this - # repo does not vendor flatbuffers itself. - fb_vendor = None - for candidate in (Path("/opt/rocm/include"),): - if (candidate / "flatbuffers" / "array.h").is_file(): - fb_vendor = candidate - break - if fb_vendor is None: - pytest.skip("no flatbuffers/array.h found (checked /opt/rocm/include)") + # Header roots for flatbuffers and HIP. A build that wires the gate sets + # HIPDNN_INGESTOR_STUB_INCLUDE_FILE to a file of include dirs (one per line, written by + # tools/CMakeLists.txt); a bad value fails rather than skips, so a miswired build cannot + # pass by skipping. Unset, probe an installed ROCm and skip when it is absent. + raw = os.environ.get("HIPDNN_INGESTOR_STUB_INCLUDE_FILE") + if raw is not None: + include_file = Path(raw) + if not include_file.is_file(): + pytest.fail( + f"HIPDNN_INGESTOR_STUB_INCLUDE_FILE is set to {raw!r}, " + "which is not an existing file" + ) + stub_includes = [ + Path(line.strip()) + for line in include_file.read_text().splitlines() + if line.strip() + ] + for header in ("flatbuffers/array.h", "hip/hip_runtime.h"): + if not any((d / header).is_file() for d in stub_includes): + pytest.fail( + f"HIPDNN_INGESTOR_STUB_INCLUDE_FILE {raw!r} lists no include " + f"directory containing {header}" + ) + else: + # Vendored flatbuffers headers: prefer an installed ROCm's, since this + # repo does not vendor flatbuffers itself. + stub_includes = [] + for candidate in (Path("/opt/rocm/include"),): + if (candidate / "flatbuffers" / "array.h").is_file(): + stub_includes.append(candidate) + break + if not stub_includes: + pytest.skip("no flatbuffers/array.h found (checked /opt/rocm/include)") gen_dir = Path(tempfile.mkdtemp(prefix="ingestor_gen_include_")) configure_targets = { @@ -114,7 +139,7 @@ def compile_env(): data_sdk, flatbuffers_sdk, provider_src, - fb_vendor, + *stub_includes, ], } From 6e5d35fb0f6c12ecbd5ab225258b529a9a874102 Mon Sep 17 00:00:00 2001 From: Garby Gilbert Date: Thu, 8 Oct 2026 07:55:01 -0600 Subject: [PATCH 02/13] test(hip-kernel-provider): run specialization agreement in the quick tier and make the sys.path probe self-contained test_specialization_agreement.py needs neither hipcc nor comgr, so it belongs to the quick tier; the marker moves its 73 tests from the hkp-pack ctest entry to hkp-pack-quick. test_worker_inherits_parent_sys_path watched HIPKERNELPROVIDER_ROCM_KPACK_DIR, which made it skip when the variable was unset and pass without checking anything when the directory was already a site-packages directory that the child sees regardless. It now prepends a directory it creates itself, so the premise holds in every environment. ALMIOPEN-2843 --- .../tests/test_hkp_pack_parallel.py | 24 +++++++++---------- .../tests/test_specialization_agreement.py | 4 ++++ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/test_hkp_pack_parallel.py b/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/test_hkp_pack_parallel.py index 3adc510be263..fd2ad6c338e2 100644 --- a/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/test_hkp_pack_parallel.py +++ b/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/test_hkp_pack_parallel.py @@ -800,7 +800,7 @@ def test_variant_key_for_uses_module_globals(monkeypatch): @pytest.mark.quick -def test_worker_inherits_parent_sys_path(): +def test_worker_inherits_parent_sys_path(tmp_path, monkeypatch): """A pool worker starts with the parent's `sys.path`, conftest inserts and all. CPython propagates `sys.path` to children under both `spawn` and @@ -814,19 +814,17 @@ def test_worker_inherits_parent_sys_path(): pool must be built after parent-side path setup, is documented at the construction site. - The path watched is the rocm_kpack directory conftest inserts from - HIPKERNELPROVIDER_ROCM_KPACK_DIR, which no `PYTHONPATH` carries. The hkp_pack - package root conftest also inserts is exported by the ctest environment, so a - child that re-read `PYTHONPATH` would have it too. + The path watched is one the test prepends to the parent's `sys.path` itself, in a + directory no `PYTHONPATH` carries, so the premise holds in every environment. An + operator-supplied directory such as HIPKERNELPROVIDER_ROCM_KPACK_DIR cannot serve: + it can already be a site-packages directory (as in CI), which the child has on its + `sys.path` whether or not the parent's propagated. """ - kpack_dir = os.environ.get("HIPKERNELPROVIDER_ROCM_KPACK_DIR") - if not kpack_dir: - pytest.skip( - "HIPKERNELPROVIDER_ROCM_KPACK_DIR is unset, so conftest inserts no " - "path that PYTHONPATH does not already carry" - ) - expected = _conftest_only_paths([kpack_dir]) - assert expected, "the premise: conftest inserted it and PYTHONPATH lacks it" + probe = tmp_path / "parent-only-path" + probe.mkdir() + monkeypatch.syspath_prepend(str(probe)) + expected = _conftest_only_paths([str(probe)]) + assert expected, "the premise: the probe is on sys.path and PYTHONPATH lacks it" with concurrent.futures.ProcessPoolExecutor(max_workers=1) as pool: child_path = list(pool.map(_child_sys_path, [None], chunksize=1))[0] diff --git a/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/test_specialization_agreement.py b/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/test_specialization_agreement.py index fabaad7ab6e4..df7424cba982 100644 --- a/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/test_specialization_agreement.py +++ b/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/test_specialization_agreement.py @@ -31,6 +31,10 @@ from hkp_pack.kpack_resolver import load_kpack from pack_helpers import write_shipped +# quick: no hipcc/comgr (the marker's definition); TestRealArchiveReaders needs only +# rocm_kpack, as other quick files do. +pytestmark = pytest.mark.quick + ARCH = "gfx942" PAYLOAD = b"\x7fELF-not-really-a-code-object" PAYLOAD_SHA = hashlib.sha256(PAYLOAD).hexdigest() From 514e58e426bf5797aeb40c59cc8ec2f968d8cc51 Mon Sep 17 00:00:00 2001 From: Garby Gilbert Date: Thu, 8 Oct 2026 09:21:32 -0600 Subject: [PATCH 03/13] fix(hipdnn): port the gfx950 dense ingestor profile to the current rocKE dispatcher The rocKE dispatcher replaced dense_spec_for_request and the dense_* request fields with opt-in dense candidates selected by algorithm and spec_id, so the shipped gfx950 dense profile named modules and fields that no longer exist and its dispatch-parity tests failed. The profile now selects the non-persistent grid body through a dispatch.pin and resolves it with dispatch_attention, and the reference-candidates family follows the new algorithm name. dispatch_parity.py reads four optional keys from the profile's dispatch block. The pin is merged into every request and outranks the shape, so a shape cannot mix another kernel family into the catalog. The result_attr and spec_attr keys walk the dispatcher's return value down to the architecture's spec, and refusal_exception names the class the dispatcher raises to decline a shape, which is reported as a per-shape decline as the predicate's was. reconcile_applicability.py inherits the pin for its reference requests, since an opt-in candidate declines every request that does not name it. The shipped-profile tests resolve through the tool and check that the grid body is non-persistent, that pinning the persistent body flips it, that a head size the body refuses is a decline, that a shape naming another candidate aborts, and that the reference oracle reaches the pinned candidate. The test that pinned dense_persistent as a YAML string is removed because the field no longer exists. ALMIOPEN-2843 --- .../gfx950_attention_dense.profile.yaml | 54 +++--- .../tests/test_dispatch_parity.py | 167 ++++++++++-------- .../tools/dispatch_parity.py | 111 +++++++++++- .../tools/reconcile_applicability.py | 16 +- 4 files changed, 244 insertions(+), 104 deletions(-) diff --git a/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml b/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml index 87a30fc75782..1e50e2e3fbf1 100644 --- a/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml +++ b/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml @@ -15,35 +15,45 @@ reference_candidates: module: dispatch.attention function: attention_candidates match: algorithm - family: attention_dense + family: attention_dense_grid source: kernels/gfx950/attention_dense.py builder: build_attention_dense dispatch: - module: dispatch.attention.gfx950 - function: dense_spec_for_request + module: dispatch.attention + function: dispatch_attention + # Selection is by PIN. `dispatch_attention` unpinned returns the unified 2D/3D + # routing path, which is a different kernel family on a different ABI; the dense + # catalog exists only under an explicit algorithm + spec_id. + # + # `attention_dense_grid` is the NON-persistent arm and the only one this catalog + # ships. It is also the only dense arm on the eight-argument signature: the + # persistent arms bake batch/seqlen_q/seqlen_kv into the binary and take five + # arguments, a different kernel-argument contract this engine's + # expectedSignature check would reject. + # + # Names are post-`ae4db22a5b8`, which split the dense arms into algorithms and + # moved the tile out of the spec_id into a knob axis. The pre-refactor spelling + # (`algorithm: attention_dense`, `spec_id: gfx950_dense_grid_default`) no longer + # resolves to anything. + pin: + algorithm: attention_dense_grid + spec_id: gfx950_dense_grid + # `dispatch_attention` returns a DispatchResult, not the builder's spec: + # .spec is the AttentionTuningSpec, .spec.kernel_spec the + # Gfx950AttentionDenseSpec that hkp_pack hydrates and comgr compiles. + result_attr: spec + spec_attr: kernel_spec + # A pin the dispatcher will not serve for a given shape. Per-shape decline, + # counted by --report-gaps -- not a tool failure. + refusal_exception: rocke.dispatch.core.PinRefused request: module: dispatch.attention.common class: AttentionRequest defaults: arch: gfx950 - algorithm: attention_dense - # Pins the NON-persistent arm, which is the only one this catalog ships. - # - # AttentionRequest defaults this field to "auto", and the gfx950 dispatcher then - # reads auto as `persistent = work >= dense_num_persistent` (256). B=1, - # Sq=Skv=8192, Hq=Hkv=8, D=128 gives work = 32*8*1 = 256, so auto resolves - # persistent=True -- and, at D=128 causal bf16/fp16, wide_lds_dma=True with it. - # That is a different compiled kernel and a different kernel-argument contract - # from the variants here, every one of which is non-persistent on the - # eight-argument signature. - # - # The quotes are mandatory, not style: PyYAML reads a bare `off` as the boolean - # False, and the dispatcher calls `.strip().lower()` on this field, so the - # unquoted spelling aborts the tool instead of selecting an arm. - dense_persistent: "off" predicate: module: kernels.gfx950.attention_dense @@ -263,7 +273,9 @@ launch_surface: guard: > attentionDenseKernelSignature() always returns the 8-arg form (q,k,v,o,scale,batch,seqlen_q,seqlen_kv). Every variant is - non-persistent, which is what request.defaults.dense_persistent: "off" - above keeps true. use_sinks=False; no sink_ptr slot. Validated at - prepare() time by buildIngestorKernelCode(..., expectedSignature). + non-persistent, which is what the dispatch.pin above keeps true by + pinning the `attention_dense_grid` algorithm: the persistent arms are a + separate algorithm on a five-argument signature. use_sinks=False; no + sink_ptr slot. Validated at prepare() time by + buildIngestorKernelCode(..., expectedSignature). test: dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestGfx950AttentionDenseSignature.cpp diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py index 93dcde30991e..d1ebc6cf101f 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py @@ -359,26 +359,22 @@ def test_the_sibling_tools_share_this_one_binding(self): class TestTheShippedProfilePinsTheDispatchArmItsCatalogWasBuiltFrom: - """The request defaults decide which kernel the dispatcher resolves, and this - catalog contains one arm of that choice only.""" - - def test_dense_persistent_is_the_string_off_not_a_yaml_boolean(self): - defaults = dispatch_parity._load_profile(str(_SHIPPED_PROFILE))["request"][ - "defaults" - ] - assert "dense_persistent" in defaults, ( - "the profile leaves dense_persistent unset, so AttentionRequest defaults " - "it to 'auto' and the dispatcher resolves the persistent arm once " - "work >= dense_num_persistent -- a kernel this catalog does not ship" - ) - value = defaults["dense_persistent"] - assert isinstance(value, str), ( - f"dense_persistent parsed as {type(value).__name__} ({value!r}): the key " - "was written unquoted and PyYAML read `off` as a boolean. The dispatcher " - "calls .strip().lower() on it, so the tool aborts rather than pinning " - "the non-persistent arm" - ) - assert value == "off", value + """``dispatch.pin`` selects one gfx950 dense candidate, and this catalog contains + one arm of that choice only: the non-persistent grid body.""" + + #: B1, Sq=Skv=8192, Hq=Hkv=8, D=128, bf16, causal. A shape every gfx950 dense + #: body serves, so a control pinned to another body resolves rather than declines. + _SHAPE = { + "batch": 1, + "seqlen_q": 8192, + "seqlen_k": 8192, + "nhead_q": 8, + "nhead_k": 8, + "hdim_q": 128, + "hdim_v": 128, + "dtype": "bf16", + "mask_type": 1, + } @staticmethod def _provided_by_the_bound_provider(top: str, profile) -> bool: @@ -404,28 +400,23 @@ def _provided_by_the_bound_provider(top: str, profile) -> bool: return True return False - @staticmethod - def _resolve_with_the_real_dispatcher(monkeypatch, **overrides): - """B1, Sq=Skv=8192, Hq=Hkv=8, D=128, bf16, causal through the dispatcher and - request class the shipped profile binds, on its own ``request.defaults``. + @classmethod + def _resolve(cls, monkeypatch, shape=None, **pin): + """The shipped profile's own resolution of one shape, through the tool. - At that shape ``work = 32 * 8 * 1 = 256 = dense_num_persistent``, so the - unpinned ``auto`` arm resolves persistent and, at D=128 causal bf16, wide DMA - with it: the one shape where the pin is the whole difference. + ``pin`` overrides ``dispatch.pin``, so a control can name another candidate. """ - import importlib - profile = dispatch_parity._load_profile(str(_SHIPPED_PROFILE)) + profile["dispatch"]["pin"].update(pin) monkeypatch.setattr(sys, "path", list(sys.path)) dispatch_parity._bind_provider(profile["provider_root"]) - dispatch, request = profile["dispatch"], profile["request"] try: - factory_module = importlib.import_module(dispatch["module"]) - request_module = importlib.import_module(request["module"]) - except ImportError as exc: - missing = exc.name or "" - owner = TestTheShippedProfilePinsTheDispatchArmItsCatalogWasBuiltFrom - if owner._provided_by_the_bound_provider(missing.split(".")[0], profile): + (resolution,) = dispatch_parity.resolve_shapes( + [shape or cls._SHAPE], profile + ) + except dispatch_parity.ParityError as exc: + missing = getattr(exc.__cause__ or exc.__context__, "name", None) or "" + if cls._provided_by_the_bound_provider(missing.split(".")[0], profile): # The library is bound and owns this name, so the profile is stale. A skip # here hid that: fail so it is seen. pytest.fail( @@ -433,45 +424,75 @@ def _resolve_with_the_real_dispatcher(monkeypatch, **overrides): f"longer provides -- port {_SHIPPED_PROFILE.name} to the current " "rocKE dispatcher API" ) - # Not the provider's own: a third-party dependency of it is absent. - pytest.skip( - f"the rocKE library cannot be imported here ({exc}) -- run with an " - "interpreter that has its dependencies, e.g. /dnn-providers/" - "hip-kernel-provider/descriptor-packaging/hkp-rocke-venv/bin/python" - ) - factory = getattr(factory_module, dispatch["function"]) - request_cls = getattr(request_module, request["class"]) - fields = { - **request["defaults"], - "batch": 1, - "seqlen_q": 8192, - "seqlen_k": 8192, - "nhead_q": 8, - "nhead_k": 8, - "hdim_q": 128, - "hdim_v": 128, - "dtype": "bf16", - "mask_type": 1, - **overrides, - } - return factory(request_cls(**fields)) - - def test_the_real_dispatcher_resolves_the_arm_the_catalog_ships(self, monkeypatch): - """Checking the YAML value is only half of it: this is what the dispatcher does - with that value, so a profile that parses correctly but no longer reaches the - non-persistent eight-argument kernel still fails.""" - spec = self._resolve_with_the_real_dispatcher(monkeypatch) - assert spec.persistent is False, ( - "the shipped profile resolves the persistent arm at B1/Sq8192/H8/D128 -- " + if isinstance(exc.__cause__ or exc.__context__, ImportError): + # Not the provider's own: a third-party dependency of it is absent. + pytest.skip( + f"the rocKE library cannot be imported here ({exc}) -- run with an " + "interpreter that has its dependencies, e.g. /dnn-providers/" + "hip-kernel-provider/descriptor-packaging/hkp-rocke-venv/bin/python" + ) + raise + return resolution + + def test_the_dispatcher_resolves_the_non_persistent_grid_body(self, monkeypatch): + """What the dispatcher does with the profile's pin, not the pin's spelling: a + profile that parses but no longer reaches the non-persistent eight-argument + kernel still fails.""" + resolution = self._resolve(monkeypatch) + assert resolution.kind == "constructed", resolution.reason + assert resolution.spec.persistent is False, ( + "the shipped profile resolves a persistent body at B1/Sq8192/H8/D128 -- " "a kernel with a different argument contract that this catalog does not " "ship" ) - assert spec.wide_lds_dma is False, spec + assert resolution.spec.wide_lds_dma is False, resolution.spec + + def test_a_control_pinned_to_the_persistent_body_resolves_it(self, monkeypatch): + """A control: the same shape pinned to the persistent body IS persistent, so + the case above exercises the pin rather than a shape no body makes + persistent.""" + resolution = self._resolve( + monkeypatch, + algorithm="attention_dense_persist", + spec_id="gfx950_dense_persist", + ) + assert resolution.kind == "constructed", resolution.reason + assert resolution.spec.persistent is True, resolution.spec - def test_the_unpinned_control_does_resolve_the_persistent_arm(self, monkeypatch): - """A control: without the pin this shape IS persistent, so the case above - exercises the pin rather than a shape that is never persistent.""" - spec = self._resolve_with_the_real_dispatcher( - monkeypatch, dense_persistent="auto" + def test_a_shape_the_pinned_body_refuses_is_a_decline_with_its_reason( + self, monkeypatch + ): + """The dispatcher's refusal is a per-shape verdict, not an abort: the tool + reports it under ``--report-gaps`` instead of exiting 2.""" + resolution = self._resolve( + monkeypatch, shape={**self._SHAPE, "hdim_q": 96, "hdim_v": 96} + ) + assert resolution.kind == "declined" + assert "head_size must be 64 or 128" in resolution.reason, resolution.reason + + def test_a_shape_naming_another_candidate_is_fatal_not_a_mixed_catalog( + self, monkeypatch + ): + """The pin decides which kernel family the catalog is, so a shape that + overrides it aborts rather than emitting another family's kernels.""" + with pytest.raises(dispatch_parity.ParityError, match="dispatch.pin"): + self._resolve( + monkeypatch, + shape={**self._SHAPE, "algorithm": "attention_dense_persist"}, + ) + + def test_the_reference_oracle_reaches_the_pinned_candidate(self, monkeypatch): + """``reconcile_applicability`` builds its own requests from ``request.defaults``, + which carries no selector under a pinning dispatcher. It must inherit the pin, + or an opt-in reference declines every shape and reconciles with nothing.""" + import reconcile_applicability + + profile = dispatch_parity._load_profile(str(_SHIPPED_PROFILE)) + monkeypatch.setattr(sys, "path", list(sys.path)) + dispatch_parity._bind_provider(profile["provider_root"]) + refused = {**self._SHAPE, "hdim_q": 96, "hdim_v": 96} + served = reconcile_applicability.reference_serves( + [self._SHAPE, refused], profile ) - assert spec.persistent is True, spec + assert served[0][0] is True, served[0] + assert served[1][0] is False, served[1] diff --git a/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py index a8d4192ace9a..32ce2bf6f431 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py @@ -3,8 +3,8 @@ Stage 1 of an integration, and the only configuration argued from rocKE's behaviour rather than from measurement: the validators, the spec dataclass and the ``supports_*`` predicate answer what is legal, the dispatcher answers what -the library ships. Calling the factory applies rules the dispatcher derives -from the request (``persistent = work >= num_persistent``). +the library ships. Calling the factory resolves the candidate the request pins, +which applies whatever rules the dispatcher derives from the request. Emits one variant per servable shape, at the dispatcher's resolved spec, as a generator config ready for ``generate.py``. Not a cross-product: one spec per @@ -18,6 +18,27 @@ 2). A decline -- the eligibility predicate returning false with a reason -- is a per-shape outcome, counted and printed by ``--report-gaps``. +**Two profile dialects for the ``dispatch`` block.** The original names a +function that takes the request and returns the builder's spec. A dispatcher +that selects by *pinning* instead needs four more keys:: + + dispatch: + module: dispatch.attention + function: dispatch_attention + pin: {algorithm: attention_dense_grid, spec_id: gfx950_dense_grid} + result_attr: spec # DispatchResult -> the tuned spec + spec_attr: kernel_spec # tuned spec -> the builder's spec + refusal_exception: rocke.dispatch.core.PinRefused + +``pin`` is merged into every request and outranks the shape, because the pin is +what makes the emitted catalog one kernel family. ``result_attr``/``spec_attr`` +walk the dispatcher's return value down to the object the builder compiles; a +``None`` on the way is fatal and says so, since a route-only candidate resolves +to a routing plan rather than a buildable spec. ``refusal_exception`` names the +class the dispatcher raises for a pin it will not serve -- that is a per-shape +decline like any other, while an import error, a missing attribute or a failed +request construction stays fatal. + This tool does not sweep. ``--report-knobs`` partitions the spec fields into those that vary across dispatch decisions and those the library ships (see ``knob_partition``). @@ -169,6 +190,46 @@ def _required(decl: dict, scope: str, *keys: str) -> list: return [decl[k] for k in keys] +def _import_dotted(path: str, scope: str): + """Import `a.b.C` written as one string, for a profile key naming a class.""" + module, _, symbol = path.rpartition(".") + if not module or not symbol: + raise ParityError( + f"the profile's '{scope}' must be a dotted path to a class, like " + f"'rocke.dispatch.core.PinRefused'; got {path!r}." + ) + attribute = _import(module, symbol) + if not isinstance(attribute, type) or not issubclass(attribute, BaseException): + raise ParityError(f"'{path}' is not an exception class.") + return attribute + + +def _project(obj, attrs: list[tuple[str, str]]): + """Walk named attributes off the dispatcher's result. + + A missing attribute is operational: the profile and the dispatcher disagree + about the shape of the result. So is a ``None``, and that one is worth + naming -- a route-only candidate resolves to a spec whose ``kernel_spec`` + is ``None``, a routing plan rather than something a builder can compile, + and silently writing that into the catalog would emit kernels with no spec. + """ + for name, key in attrs: + if not hasattr(obj, name): + raise ParityError( + f"the profile's dispatch.{key} names '{name}', which " + f"{type(obj).__name__} does not have. Available: " + f"{sorted(a for a in dir(obj) if not a.startswith('_'))}." + ) + obj = getattr(obj, name) + if obj is None: + raise ParityError( + f"dispatch.{key} '{name}' is None. The pin resolved to a " + f"routing decision, not a buildable spec -- pin a candidate " + f"that carries one (a route-only candidate never does)." + ) + return obj + + def resolve_shapes(shapes: list[dict], profile: dict) -> list[Resolution]: """Ask the dispatcher for every shape; API errors are operational failures.""" dispatch = profile.get("dispatch") or {} @@ -185,22 +246,58 @@ def resolve_shapes(shapes: list[dict], profile: dict) -> list[Resolution]: arch = profile.get("arch") defaults = dict(request_decl.get("defaults") or {}) + # The selector fields that name one candidate. Authoritative over a shape: + # a shape quietly switching `algorithm` would mix two kernel families into + # one catalog, which the loader reads as one engine's inventory. + pin = dict(dispatch.get("pin") or {}) + # Walked off the dispatcher's return value. Absent: the function already + # returns the builder's spec, which is the older profile dialect. + projection = [ + (dispatch[key], key) + for key in ("result_attr", "spec_attr") + if dispatch.get(key) + ] + # A pin the dispatcher refuses is a SHAPE-level outcome, not a tool + # failure: the catalog simply does not serve that shape, which is what + # --report-gaps counts. Operational errors stay fatal. + refusal = ( + _import_dotted(dispatch["refusal_exception"], "dispatch.refusal_exception") + if dispatch.get("refusal_exception") + else None + ) + out: list[Resolution] = [] for shape in shapes: # Keys prefixed `_` are carried provenance, not request fields: they # travel with a shape so results can be split by origin, but the # request class would reject the key. - fields = { - **defaults, - **{k: v for k, v in shape.items() if not k.startswith("_")}, - } + shape_fields = {k: v for k, v in shape.items() if not k.startswith("_")} + conflicting = sorted( + k for k, v in pin.items() if k in shape_fields and shape_fields[k] != v + ) + if conflicting: + raise ParityError( + f"shape {shape!r} sets {conflicting}, which dispatch.pin also " + f"sets. The pin decides which kernel family this catalog is; a " + f"shape overriding it would emit another family's kernels under " + f"this engine. Remove it from the shape or from the pin." + ) + fields = {**defaults, **shape_fields, **pin} if arch and "arch" not in fields: fields["arch"] = arch try: request = request_cls(**fields) + except Exception as exc: + raise ParityError(f"request/spec construction failed: {exc}") from exc + try: spec = factory(request) except Exception as exc: + if refusal is not None and isinstance(exc, refusal): + out.append(Resolution(shape, reason=str(exc), kind="declined")) + continue raise ParityError(f"request/spec construction failed: {exc}") from exc + if projection: + spec = _project(spec, projection) if predicate is not None: supported, why = _predicate_result( predicate, spec, **({"arch": arch} if arch else {}) @@ -532,7 +629,7 @@ def main(argv=None) -> int: print("dispatcher parity") print(f" shapes in {len(resolutions)}") print(f" servable {len(served)}") - print(f" declined {len(declined)} (predicate said no)") + print(f" declined {len(declined)} (the kernel or dispatcher said no)") if args.report_gaps: for resolution in declined: diff --git a/projects/hipdnn/tools/IngestorGenerator/tools/reconcile_applicability.py b/projects/hipdnn/tools/IngestorGenerator/tools/reconcile_applicability.py index fefc4a176fa7..5082d4632019 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tools/reconcile_applicability.py +++ b/projects/hipdnn/tools/IngestorGenerator/tools/reconcile_applicability.py @@ -35,6 +35,12 @@ ) +def _pin(profile: dict) -> dict: + """The selector fields ``dispatch.pin`` names, or none for a profile whose + dispatcher selects without pinning.""" + return dict((profile.get("dispatch") or {}).get("pin") or {}) + + def reference_serves(shapes: list[dict], profile: dict) -> dict: """For each shape: does the reference kernel FAMILY serve it, and which candidate? @@ -89,7 +95,8 @@ def reference_serves(shapes: list[dict], profile: dict) -> dict: arch = profile.get("arch") # Opt-in defaults ARE inherited here: without its selector, a candidate that only # matches when the request names it declines everything and reconciles trivially. - defaults = dict(reference_decl.get("defaults") or {}) + # `dispatch.pin` is that selector for a pinning dispatcher, so it is inherited too. + defaults = {**(reference_decl.get("defaults") or {}), **_pin(profile)} # An optional translator (`reference_request.via: {module, function}`) # taking the shape dict to a reference request, for a corpus written in the @@ -322,7 +329,10 @@ def main(argv=None) -> int: # count alone proves nothing. A shape either side serves proves the # comparison is live. match_key = ((profile.get("reference_candidates") or {}).get("match")) or "" - request_defaults = (profile.get("request") or {}).get("defaults") or {} + request_defaults = { + **((profile.get("request") or {}).get("defaults") or {}), + **_pin(profile), + } nothing_served = not both_serve and not only_reference and not only_ours if shapes and nothing_served and match_key and match_key not in request_defaults: print( @@ -331,7 +341,7 @@ def main(argv=None) -> int: f"opt-in kernel only\n admits a request that names it, so the reference " f"declines every shape and this\n integration declines them for the same " f"reason -- agreement about nothing.\n Add '{match_key}' to " - f"request.defaults, or pass --allow-empty if a corpus that\n nothing " + f"request.defaults or dispatch.pin, or pass --allow-empty if a corpus\n that nothing " f"serves is genuinely what you meant to reconcile.", file=sys.stderr, ) From fabf6e98b19a6bbc42e1a1dcb1c24599747aea89 Mon Sep 17 00:00:00 2001 From: Garby Gilbert Date: Thu, 8 Oct 2026 13:15:41 -0600 Subject: [PATCH 04/13] fix(hipdnn): write DescriptorGenerator file lists with forward slashes The renderers returned str(rel_path) for each file they wrote, which is a backslash path on Windows. The returned lists name files for downstream consumers and for the preview output, so a Windows run produced a different list than a Linux one. Use as_posix() so the lists are identical on every platform; all 695 DescriptorGenerator tests pass on Windows, where 23 failed before. ALMIOPEN-2843 --- .../hipdnn/tools/DescriptorGenerator/codegen/generator.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/projects/hipdnn/tools/DescriptorGenerator/codegen/generator.py b/projects/hipdnn/tools/DescriptorGenerator/codegen/generator.py index 70b5615b4d02..0de2c081576f 100644 --- a/projects/hipdnn/tools/DescriptorGenerator/codegen/generator.py +++ b/projects/hipdnn/tools/DescriptorGenerator/codegen/generator.py @@ -227,7 +227,7 @@ def render_backend(self, config: OperationConfig, output_dir: Path) -> list[str] out_path.parent.mkdir(parents=True, exist_ok=True) content = self._render_template(template_name, config) out_path.write_text(content) - written.append(str(rel_path)) + written.append(rel_path.as_posix()) fragments_dir = output_dir / "fragments" fragments_dir.mkdir(parents=True, exist_ok=True) @@ -273,7 +273,7 @@ def render_frontend(self, config: OperationConfig, output_dir: Path) -> list[str out_path.parent.mkdir(parents=True, exist_ok=True) content = self._render_template(template_name, config) out_path.write_text(content) - written.append(str(rel_path)) + written.append(rel_path.as_posix()) test_templates = { "test_attributes.cpp.j2": Path("frontend/tests") @@ -291,7 +291,7 @@ def render_frontend(self, config: OperationConfig, output_dir: Path) -> list[str out_path.parent.mkdir(parents=True, exist_ok=True) content = self._render_template(template_name, config) out_path.write_text(content) - written.append(str(rel_path)) + written.append(rel_path.as_posix()) # Frontend fragment templates fragment_templates = { @@ -371,7 +371,7 @@ def _render_constants(self, config: OperationConfig, output_dir: Path) -> list[s out_path.parent.mkdir(parents=True, exist_ok=True) content = self._render_template("constants.hpp.j2", config) out_path.write_text(content) - return [str(rel_path)] + return [rel_path.as_posix()] def _render_template(self, template_name: str, config: OperationConfig) -> str: try: From a297129b40224ec4bcf556ccc9286dce985932f2 Mon Sep 17 00:00:00 2001 From: Garby Gilbert Date: Thu, 8 Oct 2026 13:21:42 -0600 Subject: [PATCH 05/13] ci(hipdnn): run the tooling pytest suites in the Windows superbuild lane Enable HIPDNN_ENABLE_TOOLING_TESTS in the Windows job and check its junit with the same checker as the Linux lane. The flatbuffers and HIP include list is written on Windows as well, and the build's own clang++ directory is prepended to the ingestor test's PATH, since the native-stub tests look for g++ or clang++ there and the lane only puts the ROCm bin directory on PATH. The MSVC environment the lane already configures gives that clang its standard library headers. check_pytest_junit.py accepts --allow-skips more than once and pools the lists, so the Windows lane adds its own entries to the shared ones; Linux reads only the shared list, where a Windows-only entry would be stale. The Windows list covers the sweep tests, which drive a benchmark on an allocated Linux device host by design. ALMIOPEN-2843 --- .../hipdnn-tooling-allowed-skips-windows.txt | 7 ++ .github/hipdnn-tooling-allowed-skips.txt | 3 +- .github/scripts/check_pytest_junit.py | 16 +++-- .../scripts/tests/test_check_pytest_junit.py | 41 +++++++++++ .github/workflows/hipdnn-superbuild-ci.yml | 20 +++++- projects/hipdnn/tools/CMakeLists.txt | 68 +++++++++++-------- 6 files changed, 120 insertions(+), 35 deletions(-) create mode 100644 .github/hipdnn-tooling-allowed-skips-windows.txt diff --git a/.github/hipdnn-tooling-allowed-skips-windows.txt b/.github/hipdnn-tooling-allowed-skips-windows.txt new file mode 100644 index 000000000000..dcbd76368aa7 --- /dev/null +++ b/.github/hipdnn-tooling-allowed-skips-windows.txt @@ -0,0 +1,7 @@ +# Skips the Windows superbuild lane accepts from the hipDNN tooling pytest suites, on top of +# the shared list in hipdnn-tooling-allowed-skips.txt. check_pytest_junit.py is given both +# files on this lane and only the shared one on Linux, so an entry here may not match a +# skip Linux makes, and the stale-allow check keeps it from outliving the skip it excuses. +# +# Format and rules are the shared file's: ` # `, every entry needs a reason. +hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.*::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang, runs it directly, and the cases withdraw directory write access with chmod, which Windows ignores diff --git a/.github/hipdnn-tooling-allowed-skips.txt b/.github/hipdnn-tooling-allowed-skips.txt index 389b8edef4e2..289e24b2b8d8 100644 --- a/.github/hipdnn-tooling-allowed-skips.txt +++ b/.github/hipdnn-tooling-allowed-skips.txt @@ -1,6 +1,7 @@ -# Skips the Linux superbuild lane accepts from the hipDNN tooling pytest suites. +# Skips the superbuild lanes accept from the hipDNN tooling pytest suites. # # Read by .github/scripts/check_pytest_junit.py in .github/workflows/hipdnn-superbuild-ci.yml. +# The Windows lane also reads hipdnn-tooling-allowed-skips-windows.txt for the skips only it makes. # Each line is ` # `; every entry needs a reason. The pattern is an fnmatch # glob over `::::`, e.g. # `hipdnn_ingestor_generator_pytest::tests.test_round_trip::test_x[param]`. The junit stem is diff --git a/.github/scripts/check_pytest_junit.py b/.github/scripts/check_pytest_junit.py index 2d4bd77fb87c..544aafc0f4d3 100644 --- a/.github/scripts/check_pytest_junit.py +++ b/.github/scripts/check_pytest_junit.py @@ -17,7 +17,9 @@ Allow-list format: blank lines and lines starting with ``#`` are ignored; every other line is `` # ``, split on the first ``#``, both parts non-empty. The -pattern is an fnmatch glob over ``::::``. +pattern is an fnmatch glob over ``::::``. Several +allow-lists may be given; their patterns are pooled, so a lane can add its own entries to +the shared ones. """ import argparse @@ -55,10 +57,12 @@ def _parse_allow_list(allow_path, failures): return patterns -def check(junit_paths, allow_path): +def check(junit_paths, allow_paths): """Return ``(kind, detail)`` for every problem; empty means the check passed.""" failures = [] - patterns = _parse_allow_list(allow_path, failures) + patterns = [] + for allow_path in allow_paths: + patterns += _parse_allow_list(allow_path, failures) matched = set() for junit_path in map(Path, junit_paths): try: @@ -112,9 +116,13 @@ def main(argv=None): ) parser.add_argument( "--allow-skips", + action="append", required=True, metavar="PATH", - help="allow-list of ' # ' lines naming the skips to accept", + help=( + "allow-list of ' # ' lines naming the skips to accept; " + "repeat to pool several" + ), ) args = parser.parse_args(argv) diff --git a/.github/scripts/tests/test_check_pytest_junit.py b/.github/scripts/tests/test_check_pytest_junit.py index 0b661fd564cc..7a7e38765211 100644 --- a/.github/scripts/tests/test_check_pytest_junit.py +++ b/.github/scripts/tests/test_check_pytest_junit.py @@ -117,3 +117,44 @@ def test_several_junit_files_are_all_checked(tmp_path, capsys): out = capsys.readouterr().out assert rc == 1 assert "b::tests.t::test_two" in out + + +def test_several_allow_lists_are_pooled(tmp_path, capsys): + junit = _junit( + tmp_path / "suite.xml", + [("tests.a", "test_one", "skip"), ("tests.b", "test_two", "skip")], + ) + shared = _allow(tmp_path / "shared.txt", "suite::tests.a::* # shared skip\n") + lane = _allow(tmp_path / "lane.txt", "suite::tests.b::* # lane-only skip\n") + rc = cpj.main( + [ + "--junit", + str(junit), + "--allow-skips", + str(shared), + "--allow-skips", + str(lane), + ] + ) + assert rc == 0 + assert "2 allow-listed skip(s)" in capsys.readouterr().out + + +def test_a_lane_entry_that_matches_nothing_is_stale_even_when_the_shared_list_matches( + tmp_path, capsys +): + junit = _junit(tmp_path / "suite.xml", [("tests.a", "test_one", "skip")]) + shared = _allow(tmp_path / "shared.txt", "suite::tests.a::* # shared skip\n") + lane = _allow(tmp_path / "lane.txt", "suite::tests.gone::* # no longer skipped\n") + rc = cpj.main( + [ + "--junit", + str(junit), + "--allow-skips", + str(shared), + "--allow-skips", + str(lane), + ] + ) + assert rc == 1 + assert "stale-allow" in capsys.readouterr().out diff --git a/.github/workflows/hipdnn-superbuild-ci.yml b/.github/workflows/hipdnn-superbuild-ci.yml index 74f32cd4a8cc..be262618209b 100644 --- a/.github/workflows/hipdnn-superbuild-ci.yml +++ b/.github/workflows/hipdnn-superbuild-ci.yml @@ -12,6 +12,7 @@ on: paths: - '.github/workflows/hipdnn-superbuild-ci.yml' - '.github/requirements-hipdnn-superbuild-ci.txt' + - '.github/hipdnn-tooling-allowed-skips-windows.txt' - '.github/scripts/check_pytest_junit.py' - '.github/hipdnn-tooling-allowed-skips.txt' - 'projects/hipdnn/**' @@ -204,8 +205,10 @@ jobs: echo "Fetched $count golden tensor blob(s)" # Must precede configure: configuration fails if pytest is not importable - # by the interpreter descriptor-packaging registers its tests under. The - # interpreter is named explicitly because it must be the one passed as + # by the interpreter descriptor-packaging registers its tests under, or if + # pytest, PyYAML and Jinja2 are not importable by it for the hipDNN + # DescriptorGenerator/IngestorGenerator tooling suites. The interpreter is + # named explicitly because it must be the one passed as # -DPython3_EXECUTABLE below -- a bare `pip` here would install into the # setup-python tool-cache interpreter instead. - name: Install Python dependencies @@ -223,6 +226,7 @@ jobs: -DHIPKERNELPROVIDER_ENABLE_ROCKE=ON \ -DHIPDNN_ENABLE_CUDNN_COMPATIBILITY=ON \ -DHIPDNN_ENABLE_KERNEL_INGESTOR=ON \ + -DHIPDNN_ENABLE_TOOLING_TESTS=ON \ -DHIPDNN_ENABLE_SDPA=ON \ -DHIPKERNELPROVIDER_KPACK_PYTHON_DIR="$KPACK_PYTHON_DIR" \ -DENABLE_CLANG_TIDY=OFF \ @@ -249,8 +253,20 @@ jobs: run: echo "$ROCM_SDK_PATH/bin" >> "$GITHUB_PATH" - name: Run tests + id: run-tests run: ctest --test-dir build --output-on-failure + # See the Linux job's step of the same name. The Windows list adds the skips the + # POSIX-only sweep tooling makes here. + - name: Check tooling tests ran without unexpected skips + if: ${{ !cancelled() && steps.run-tests.outcome != 'skipped' }} + run: | + "$VENV_PYTHON" .github/scripts/check_pytest_junit.py \ + --junit build/tooling-junit/hipdnn_descriptor_generator_pytest.xml \ + --junit build/tooling-junit/hipdnn_ingestor_generator_pytest.xml \ + --allow-skips .github/hipdnn-tooling-allowed-skips.txt \ + --allow-skips .github/hipdnn-tooling-allowed-skips-windows.txt + - name: Patch ROCm SDK with superbuild outputs shell: pwsh run: | diff --git a/projects/hipdnn/tools/CMakeLists.txt b/projects/hipdnn/tools/CMakeLists.txt index 391dbe614d5a..9a604e49d8ae 100644 --- a/projects/hipdnn/tools/CMakeLists.txt +++ b/projects/hipdnn/tools/CMakeLists.txt @@ -298,34 +298,33 @@ endif() "HIPDNN_VALIDATE_DESCRIPTORS=$" ) - # The native-stub tests need a POSIX C++ compiler; the Windows lane is out of scope. - if(NOT WIN32) - # TARGET_PROPERTY does not follow ALIAS targets, so resolve to the real target. - set(_hipdnn_stub_include_exprs "") - foreach(_stub_dep flatbuffers::flatbuffers hip::host hip::amdhip64) - if(TARGET ${_stub_dep}) - get_target_property(_stub_real ${_stub_dep} ALIASED_TARGET) - if(NOT _stub_real) - set(_stub_real ${_stub_dep}) - endif() - string( - APPEND - _hipdnn_stub_include_exprs - "$,\n>\n" - ) + # test_native_stub.py compiles generated stubs against the flatbuffers and HIP + # headers listed in this file, one directory per line. + # TARGET_PROPERTY does not follow ALIAS targets, so resolve to the real target. + set(_hipdnn_stub_include_exprs "") + foreach(_stub_dep flatbuffers::flatbuffers hip::host hip::amdhip64) + if(TARGET ${_stub_dep}) + get_target_property(_stub_real ${_stub_dep} ALIASED_TARGET) + if(NOT _stub_real) + set(_stub_real ${_stub_dep}) endif() - endforeach() - file( - GENERATE - OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/ingestor_stub_include_dirs.txt" - CONTENT "${_hipdnn_stub_include_exprs}" - ) - list( - APPEND - _hipdnn_ingestor_pytest_env - "HIPDNN_INGESTOR_STUB_INCLUDE_FILE=${CMAKE_CURRENT_BINARY_DIR}/ingestor_stub_include_dirs.txt" - ) - endif() + string( + APPEND + _hipdnn_stub_include_exprs + "$,\n>\n" + ) + endif() + endforeach() + file( + GENERATE + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/ingestor_stub_include_dirs.txt" + CONTENT "${_hipdnn_stub_include_exprs}" + ) + list( + APPEND + _hipdnn_ingestor_pytest_env + "HIPDNN_INGESTOR_STUB_INCLUDE_FILE=${CMAKE_CURRENT_BINARY_DIR}/ingestor_stub_include_dirs.txt" + ) if(DEFINED TEST_ENVIRONMENT) list(APPEND _hipdnn_ingestor_pytest_env ${TEST_ENVIRONMENT}) @@ -335,10 +334,23 @@ endif() "${_hipdnn_ingestor_pytest_env}" ) + # PATH prepends (Windows ASAN runtime / ROCm / build DLL dirs) go through + # ENVIRONMENT_MODIFICATION so the runtime PATH is extended rather than replaced. + set(_hipdnn_ingestor_pytest_env_mod "") if(DEFINED TEST_ENVIRONMENT_MODIFICATION) + list(APPEND _hipdnn_ingestor_pytest_env_mod ${TEST_ENVIRONMENT_MODIFICATION}) + endif() + if(WIN32) + # test_native_stub.py looks for g++ or clang++ on PATH. The ROCm clang++ that + # builds this tree lives in lib/llvm/bin, which the lane does not put on PATH. + get_filename_component(_hipdnn_stub_cxx_dir "${CMAKE_CXX_COMPILER}" DIRECTORY) + list(APPEND _hipdnn_ingestor_pytest_env_mod + "PATH=path_list_prepend:${_hipdnn_stub_cxx_dir}") + endif() + if(_hipdnn_ingestor_pytest_env_mod) set_tests_properties( hipdnn_ingestor_generator_pytest - PROPERTIES ENVIRONMENT_MODIFICATION "${TEST_ENVIRONMENT_MODIFICATION}" + PROPERTIES ENVIRONMENT_MODIFICATION "${_hipdnn_ingestor_pytest_env_mod}" ) endif() From 6bdd6c2bd4d580f3571ecd671b954be4146ef15a Mon Sep 17 00:00:00 2001 From: Garby Gilbert Date: Thu, 8 Oct 2026 13:56:43 -0600 Subject: [PATCH 06/13] ci(hipdnn): align check_pytest_junit.py with check_probe_junit.py Give the script the shape of its sibling: a shebang, a usage and exit-code section in the module docstring, type hints, and Path-typed arguments. ALMIOPEN-2843 --- .github/scripts/check_pytest_junit.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/scripts/check_pytest_junit.py b/.github/scripts/check_pytest_junit.py index 544aafc0f4d3..aa74883e3dfd 100644 --- a/.github/scripts/check_pytest_junit.py +++ b/.github/scripts/check_pytest_junit.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 # Copyright (c) Advanced Micro Devices, Inc., or its affiliates. # SPDX-License-Identifier: MIT """Fail CI when a pytest suite skipped a test its allow-list does not name. @@ -20,6 +21,16 @@ pattern is an fnmatch glob over ``::::``. Several allow-lists may be given; their patterns are pooled, so a lane can add its own entries to the shared ones. + +Usage: + check_pytest_junit.py --junit build/tooling-junit/.xml \\ + --allow-skips .github/hipdnn-tooling-allowed-skips.txt + +Exit codes: + 0 no skipped testcase outside the allow-lists, and every entry matched one + 1 one or more checks failed; each prints + "check_pytest_junit: FAIL : ", where is one of the + identifiers above """ import argparse @@ -31,7 +42,7 @@ PREFIX = "check_pytest_junit" -def _parse_allow_list(allow_path, failures): +def _parse_allow_list(allow_path: Path, failures: list[tuple[str, str]]) -> list[str]: """Return the allow-list's patterns; append any problem to ``failures``.""" try: text = Path(allow_path).read_text(encoding="utf-8") @@ -57,7 +68,7 @@ def _parse_allow_list(allow_path, failures): return patterns -def check(junit_paths, allow_paths): +def check(junit_paths: list[Path], allow_paths: list[Path]) -> list[tuple[str, str]]: """Return ``(kind, detail)`` for every problem; empty means the check passed.""" failures = [] patterns = [] @@ -91,7 +102,7 @@ def check(junit_paths, allow_paths): return failures -def _count(junit_paths): +def _count(junit_paths: list[Path]) -> tuple[int, int]: """``(testcases, allow-listed skips)`` over the readable junit files.""" testcases = skips = 0 for junit_path in map(Path, junit_paths): @@ -105,12 +116,13 @@ def _count(junit_paths): return testcases, skips -def main(argv=None): +def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) parser.add_argument( "--junit", action="append", required=True, + type=Path, metavar="PATH", help="pytest junit XML file; repeat for several suites", ) @@ -118,6 +130,7 @@ def main(argv=None): "--allow-skips", action="append", required=True, + type=Path, metavar="PATH", help=( "allow-list of ' # ' lines naming the skips to accept; " From bafec4e0087d2b7e5d0523904be9bce0c22b82e3 Mon Sep 17 00:00:00 2001 From: Garby Gilbert Date: Fri, 9 Oct 2026 14:15:12 -0600 Subject: [PATCH 07/13] fix(hipdnn): tighten the tooling-test gates and keep their allow-lists beside the suites The allow-lists now live in projects/hipdnn/tools next to the tests they excuse and the ctest names they key on, so hipdnn-core maintains them and the list headers cite the KNOWN_BAD and KNOWN_VIOLATIONS rule that a list only shrinks. The Windows list names the eight sweep-test classes that carry the POSIX-only markers instead of the whole module, so a new environment-gated skip elsewhere in it still fails the check. The header example no longer uses a bracketed test id, which fnmatch reads as a character class. HIPDNN_ENABLE_TOOLING_TESTS now fails configure when the kernel ingestor is off, because the IngestorGenerator suite runs the validator that option builds and registering only the other suite would leave it silently absent, and it says so when HIPDNN_SKIP_TESTS makes it a no-op. The pin dialect of dispatch_parity.py gains tests that need no rocKE, covering the route-only guard, a missing attribute, a non-refusal exception staying fatal and a malformed refusal_exception. The provider-root lookup in the dispatch-parity tests goes through launch_surface.find_repo_root like the tool does, and two comments that described a lane and a try-block structure no longer true are dropped or reworded. The two pool tests that check sys.path reaches a worker no longer pass vacuously on Linux, where the default start method forks and the child copies the parent's memory: they pin the pool to spawn, and the first drops an assertion that could not fail. The checker's tests use unittest like the rest of their directory and assert the exact failure ids, the counts in the OK line and the skip reason. In the dispatch-parity tests the pin-conflict case moves to the class that needs no rocKE, the stub proves the pin reaches every request, and the refusal test no longer asserts rocKE's own wording. ALMIOPEN-2843 --- .../hipdnn-tooling-allowed-skips-windows.txt | 7 - .github/scripts/check_pytest_junit.py | 2 +- .../scripts/tests/test_check_pytest_junit.py | 300 ++++++++++-------- .github/workflows/hipdnn-superbuild-ci.yml | 8 +- .../rocke/test_hkp_pack_parallel_rocke.py | 11 +- .../tests/test_hkp_pack_parallel.py | 22 +- projects/hipdnn/tools/CMakeLists.txt | 18 +- .../tests/test_dispatch_parity.py | 142 ++++++++- .../tools/tooling-allowed-skips-windows.txt | 17 + .../hipdnn/tools/tooling-allowed-skips.txt | 12 +- 10 files changed, 355 insertions(+), 184 deletions(-) delete mode 100644 .github/hipdnn-tooling-allowed-skips-windows.txt create mode 100644 projects/hipdnn/tools/tooling-allowed-skips-windows.txt rename .github/hipdnn-tooling-allowed-skips.txt => projects/hipdnn/tools/tooling-allowed-skips.txt (55%) diff --git a/.github/hipdnn-tooling-allowed-skips-windows.txt b/.github/hipdnn-tooling-allowed-skips-windows.txt deleted file mode 100644 index dcbd76368aa7..000000000000 --- a/.github/hipdnn-tooling-allowed-skips-windows.txt +++ /dev/null @@ -1,7 +0,0 @@ -# Skips the Windows superbuild lane accepts from the hipDNN tooling pytest suites, on top of -# the shared list in hipdnn-tooling-allowed-skips.txt. check_pytest_junit.py is given both -# files on this lane and only the shared one on Linux, so an entry here may not match a -# skip Linux makes, and the stale-allow check keeps it from outliving the skip it excuses. -# -# Format and rules are the shared file's: ` # `, every entry needs a reason. -hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.*::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang, runs it directly, and the cases withdraw directory write access with chmod, which Windows ignores diff --git a/.github/scripts/check_pytest_junit.py b/.github/scripts/check_pytest_junit.py index aa74883e3dfd..0a0d6a0d7317 100644 --- a/.github/scripts/check_pytest_junit.py +++ b/.github/scripts/check_pytest_junit.py @@ -24,7 +24,7 @@ Usage: check_pytest_junit.py --junit build/tooling-junit/.xml \\ - --allow-skips .github/hipdnn-tooling-allowed-skips.txt + --allow-skips projects/hipdnn/tools/tooling-allowed-skips.txt Exit codes: 0 no skipped testcase outside the allow-lists, and every entry matched one diff --git a/.github/scripts/tests/test_check_pytest_junit.py b/.github/scripts/tests/test_check_pytest_junit.py index 7a7e38765211..01aa95e0adf9 100644 --- a/.github/scripts/tests/test_check_pytest_junit.py +++ b/.github/scripts/tests/test_check_pytest_junit.py @@ -1,160 +1,192 @@ # Copyright (c) Advanced Micro Devices, Inc., or its affiliates. # SPDX-License-Identifier: MIT +import contextlib +import io import os +import re import sys +import tempfile +import unittest from pathlib import Path sys.path.insert(0, os.fspath(Path(__file__).parent.parent)) import check_pytest_junit as cpj -def _junit(path, cases): - """Write a junit file; ``cases`` is [(classname, name, skip_message_or_None)].""" - rows = [] - for classname, name, skip in cases: - body = "" if skip is None else f'' - rows.append( - f'{body}' - ) - path.write_text( - '' - + "".join(rows) - + "" - ) - return path - - -def _allow(path, text): - path.write_text(text) - return path - - -def _run(junit, allow, capsys): - rc = cpj.main(["--junit", str(junit), "--allow-skips", str(allow)]) - return rc, capsys.readouterr().out - +def case_xml(classname: str, name: str, skip: str | None = None) -> str: + """One testcase. ``skip`` is None (ran), "skip" (a skip) or "xfail".""" + child = "" + if skip == "skip": + child = '' + elif skip == "xfail": + child = '' + return f'{child}' -def test_all_passing_with_empty_allow_list_is_ok(tmp_path, capsys): - junit = _junit(tmp_path / "suite.xml", [("tests.test_a", "test_one", None)]) - rc, out = _run(junit, _allow(tmp_path / "allow.txt", "# nothing\n"), capsys) - assert rc == 0 - assert "OK 1 testcase(s) in 1 junit file(s), 0 allow-listed skip(s)" in out - -def test_unlisted_skip_fails(tmp_path, capsys): - junit = _junit( - tmp_path / "suite.xml", [("tests.test_a", "test_one", "no compiler")] +def junit_xml(cases: list[str]) -> str: + return ( + '' + f'{"".join(cases)}' ) - rc, out = _run(junit, _allow(tmp_path / "allow.txt", ""), capsys) - assert rc == 1 - assert "unexpected-skip" in out - assert "suite::tests.test_a::test_one" in out - assert "no compiler" in out - -def test_allow_listed_skip_passes(tmp_path, capsys): - junit = _junit( - tmp_path / "suite.xml", - [("tests.test_a.TestReal", "test_x[p1]", "needs input")], - ) - allow = _allow( - tmp_path / "allow.txt", - "suite::tests.test_a.TestReal::* # needs input by design\n", - ) - rc, out = _run(junit, allow, capsys) - assert rc == 0 - assert "1 allow-listed skip(s)" in out - - -def test_pattern_matching_no_skip_is_stale(tmp_path, capsys): - junit = _junit(tmp_path / "suite.xml", [("tests.test_a", "test_one", None)]) - allow = _allow( - tmp_path / "allow.txt", "suite::tests.test_gone::* # was skipped once\n" - ) - rc, out = _run(junit, allow, capsys) - assert rc == 1 - assert "stale-allow" in out +class CheckPytestJunitTest(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.dir = Path(tmp.name) + + def junit(self, cases: list[str], stem: str = "suite") -> Path: + path = self.dir / f"{stem}.xml" + path.write_text(junit_xml(cases)) + return path + + def allow(self, text: str, stem: str = "allow") -> Path: + path = self.dir / f"{stem}.txt" + path.write_text(text) + return path + + def ids(self, junits: list[Path], allows: list[Path]) -> list[str]: + return [kind for kind, _ in cpj.check(junits, allows)] + + def run_main(self, junits: list[Path], allows: list[Path]) -> tuple[int, str]: + argv = [] + for junit in junits: + argv += ["--junit", str(junit)] + for allow in allows: + argv += ["--allow-skips", str(allow)] + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = cpj.main(argv) + return rc, out.getvalue() + + def test_all_passing_with_an_empty_allow_list_is_clean(self): + junit = self.junit([case_xml("tests.a", "test_one")]) + self.assertEqual(self.ids([junit], [self.allow("# nothing\n")]), []) + + def test_an_unlisted_skip_fails(self): + junit = self.junit([case_xml("tests.a", "test_one", "skip")]) + self.assertEqual(self.ids([junit], [self.allow("")]), ["unexpected-skip"]) + + def test_an_unlisted_xfail_fails_like_a_skip(self): + junit = self.junit([case_xml("tests.a", "test_one", "xfail")]) + self.assertEqual(self.ids([junit], [self.allow("")]), ["unexpected-skip"]) + + def test_an_allow_listed_skip_passes(self): + junit = self.junit([case_xml("tests.a.TestReal", "test_x[p1]", "skip")]) + allow = self.allow("suite::tests.a.TestReal::* # needs input by design\n") + self.assertEqual(self.ids([junit], [allow]), []) + + def test_a_bracketed_id_is_matched_by_a_star_or_an_escaped_bracket(self): + """`[` opens a glob character class, so a literal `[p1]` matches nothing.""" + junit = self.junit([case_xml("tests.a", "test_x[p1]", "skip")]) + escaped = self.allow("suite::tests.a::test_x[[]p1] # escaped\n") + literal = self.allow("suite::tests.a::test_x[p1] # unescaped\n", "literal") + self.assertEqual(self.ids([junit], [escaped]), []) + self.assertEqual( + self.ids([junit], [literal]), ["unexpected-skip", "stale-allow"] + ) -def test_pattern_is_scoped_to_its_junit_file(tmp_path, capsys): - junit = _junit(tmp_path / "other.xml", [("tests.test_a", "test_one", "skip")]) - allow = _allow(tmp_path / "allow.txt", "suite::tests.test_a::* # wrong suite\n") - rc, out = _run(junit, allow, capsys) - assert rc == 1 - assert "unexpected-skip" in out - assert "stale-allow" in out - + def test_a_pattern_that_matches_no_skip_is_stale(self): + junit = self.junit([case_xml("tests.a", "test_one")]) + allow = self.allow("suite::tests.gone::* # was skipped once\n") + self.assertEqual(self.ids([junit], [allow]), ["stale-allow"]) -def test_missing_junit_is_unreadable(tmp_path, capsys): - rc, out = _run(tmp_path / "absent.xml", _allow(tmp_path / "allow.txt", ""), capsys) - assert rc == 1 - assert "junit-unreadable" in out + def test_a_pattern_is_scoped_to_its_junit_file(self): + junit = self.junit([case_xml("tests.a", "test_one", "skip")], stem="other") + allow = self.allow("suite::tests.a::* # wrong suite\n") + self.assertEqual(self.ids([junit], [allow]), ["unexpected-skip", "stale-allow"]) + def test_a_missing_junit_is_unreadable(self): + allow = self.allow("") + self.assertEqual( + self.ids([self.dir / "absent.xml"], [allow]), ["junit-unreadable"] + ) -def test_junit_without_testcases_is_empty(tmp_path, capsys): - junit = _junit(tmp_path / "suite.xml", []) - rc, out = _run(junit, _allow(tmp_path / "allow.txt", ""), capsys) - assert rc == 1 - assert "junit-empty" in out + def test_a_junit_that_is_not_xml_is_unreadable(self): + junit = self.dir / "suite.xml" + junit.write_text("not xml") + self.assertEqual(self.ids([junit], [self.allow("")]), ["junit-unreadable"]) + def test_a_junit_without_testcases_is_empty(self): + self.assertEqual(self.ids([self.junit([])], [self.allow("")]), ["junit-empty"]) -def test_allow_list_line_without_reason_is_malformed(tmp_path, capsys): - junit = _junit(tmp_path / "suite.xml", [("tests.test_a", "test_one", "skip")]) - allow = _allow(tmp_path / "allow.txt", "suite::tests.test_a::*\n") - rc, out = _run(junit, allow, capsys) - assert rc == 1 - assert "allow-list-malformed" in out + def test_an_allow_list_line_without_a_reason_is_malformed(self): + junit = self.junit([case_xml("tests.a", "test_one", "skip")]) + allow = self.allow("suite::tests.a::*\n") + self.assertEqual( + self.ids([junit], [allow]), ["allow-list-malformed", "unexpected-skip"] + ) + def test_a_missing_allow_list_is_unreadable(self): + junit = self.junit([case_xml("tests.a", "test_one")]) + self.assertEqual( + self.ids([junit], [self.dir / "absent.txt"]), ["allow-list-unreadable"] + ) -def test_several_junit_files_are_all_checked(tmp_path, capsys): - clean = _junit(tmp_path / "a.xml", [("tests.t", "test_one", None)]) - skipping = _junit(tmp_path / "b.xml", [("tests.t", "test_two", "skip")]) - allow = _allow(tmp_path / "allow.txt", "") - rc = cpj.main( - ["--junit", str(clean), "--junit", str(skipping), "--allow-skips", str(allow)] - ) - out = capsys.readouterr().out - assert rc == 1 - assert "b::tests.t::test_two" in out + def test_every_junit_file_is_checked(self): + clean = self.junit([case_xml("tests.t", "test_one")], stem="a") + skipping = self.junit([case_xml("tests.t", "test_two", "skip")], stem="b") + failures = cpj.check([clean, skipping], [self.allow("")]) + self.assertEqual([kind for kind, _ in failures], ["unexpected-skip"]) + self.assertIn("b::tests.t::test_two", failures[0][1]) + self.assertIn("no input", failures[0][1]) + + def test_several_allow_lists_are_pooled(self): + junit = self.junit( + [ + case_xml("tests.a", "test_one", "skip"), + case_xml("tests.b", "test_two", "skip"), + ] + ) + shared = self.allow("suite::tests.a::* # shared skip\n", "shared") + lane = self.allow("suite::tests.b::* # lane-only skip\n", "lane") + self.assertEqual(self.ids([junit], [shared, lane]), []) + + def test_a_lane_entry_matching_nothing_is_stale_even_when_the_shared_list_matches( + self, + ): + junit = self.junit([case_xml("tests.a", "test_one", "skip")]) + shared = self.allow("suite::tests.a::* # shared skip\n", "shared") + lane = self.allow("suite::tests.gone::* # no longer skipped\n", "lane") + self.assertEqual(self.ids([junit], [shared, lane]), ["stale-allow"]) + + def test_main_reports_ok_with_exit_zero(self): + junit = self.junit([case_xml("tests.a", "test_one")]) + rc, out = self.run_main([junit], [self.allow("")]) + self.assertEqual(rc, 0) + self.assertTrue(out.startswith("check_pytest_junit: OK "), out) + + def test_main_reports_the_counts_it_checked(self): + """The OK line is what a CI log shows, so its numbers have to be right: they + sum over every junit file.""" + first = self.junit( + [case_xml("tests.a", "test_one"), case_xml("tests.a", "test_two", "skip")], + stem="a", + ) + second = self.junit([case_xml("tests.b", "test_three", "skip")], stem="b") + allow = self.allow("a::tests.a::* # first\nb::tests.b::* # second\n") + rc, out = self.run_main([first, second], [allow]) + self.assertEqual(rc, 0) + match = re.search( + r"OK (\d+) testcase\(s\) in (\d+) junit file\(s\), (\d+) allow-listed skip\(s\)", + out, + ) + self.assertIsNotNone(match, out) + self.assertEqual(tuple(map(int, match.groups())), (3, 2, 2)) + + def test_main_prints_one_fail_line_naming_the_failure_and_why(self): + junit = self.junit([case_xml("tests.a", "test_one", "skip")]) + rc, out = self.run_main([junit], [self.allow("")]) + self.assertEqual(rc, 1) + lines = out.splitlines() + self.assertEqual(len(lines), 1, out) + self.assertTrue( + lines[0].startswith("check_pytest_junit: FAIL unexpected-skip:"), out + ) + self.assertIn("no input", lines[0]) -def test_several_allow_lists_are_pooled(tmp_path, capsys): - junit = _junit( - tmp_path / "suite.xml", - [("tests.a", "test_one", "skip"), ("tests.b", "test_two", "skip")], - ) - shared = _allow(tmp_path / "shared.txt", "suite::tests.a::* # shared skip\n") - lane = _allow(tmp_path / "lane.txt", "suite::tests.b::* # lane-only skip\n") - rc = cpj.main( - [ - "--junit", - str(junit), - "--allow-skips", - str(shared), - "--allow-skips", - str(lane), - ] - ) - assert rc == 0 - assert "2 allow-listed skip(s)" in capsys.readouterr().out - - -def test_a_lane_entry_that_matches_nothing_is_stale_even_when_the_shared_list_matches( - tmp_path, capsys -): - junit = _junit(tmp_path / "suite.xml", [("tests.a", "test_one", "skip")]) - shared = _allow(tmp_path / "shared.txt", "suite::tests.a::* # shared skip\n") - lane = _allow(tmp_path / "lane.txt", "suite::tests.gone::* # no longer skipped\n") - rc = cpj.main( - [ - "--junit", - str(junit), - "--allow-skips", - str(shared), - "--allow-skips", - str(lane), - ] - ) - assert rc == 1 - assert "stale-allow" in capsys.readouterr().out +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/hipdnn-superbuild-ci.yml b/.github/workflows/hipdnn-superbuild-ci.yml index be262618209b..262306c847fb 100644 --- a/.github/workflows/hipdnn-superbuild-ci.yml +++ b/.github/workflows/hipdnn-superbuild-ci.yml @@ -12,9 +12,7 @@ on: paths: - '.github/workflows/hipdnn-superbuild-ci.yml' - '.github/requirements-hipdnn-superbuild-ci.txt' - - '.github/hipdnn-tooling-allowed-skips-windows.txt' - '.github/scripts/check_pytest_junit.py' - - '.github/hipdnn-tooling-allowed-skips.txt' - 'projects/hipdnn/**' - 'dnn-providers/**' schedule: @@ -264,8 +262,8 @@ jobs: "$VENV_PYTHON" .github/scripts/check_pytest_junit.py \ --junit build/tooling-junit/hipdnn_descriptor_generator_pytest.xml \ --junit build/tooling-junit/hipdnn_ingestor_generator_pytest.xml \ - --allow-skips .github/hipdnn-tooling-allowed-skips.txt \ - --allow-skips .github/hipdnn-tooling-allowed-skips-windows.txt + --allow-skips projects/hipdnn/tools/tooling-allowed-skips.txt \ + --allow-skips projects/hipdnn/tools/tooling-allowed-skips-windows.txt - name: Patch ROCm SDK with superbuild outputs shell: pwsh @@ -520,7 +518,7 @@ jobs: "$VENV_PYTHON" .github/scripts/check_pytest_junit.py \ --junit build/tooling-junit/hipdnn_descriptor_generator_pytest.xml \ --junit build/tooling-junit/hipdnn_ingestor_generator_pytest.xml \ - --allow-skips .github/hipdnn-tooling-allowed-skips.txt + --allow-skips projects/hipdnn/tools/tooling-allowed-skips.txt # Golden-data validation used to live here as a schedule-gated re-run of the # external integration targets with HIPDNN_TEST_VERIFICATION_MODE=golden-check. diff --git a/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/rocke/test_hkp_pack_parallel_rocke.py b/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/rocke/test_hkp_pack_parallel_rocke.py index 2dbf246338d9..041504091ffb 100644 --- a/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/rocke/test_hkp_pack_parallel_rocke.py +++ b/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/rocke/test_hkp_pack_parallel_rocke.py @@ -6,6 +6,7 @@ import concurrent.futures import importlib +import multiprocessing import pickle import re import sys @@ -99,14 +100,18 @@ def test_prewarm_failure_names_variant(failing_corpus, tmp_path, monkeypatch): @pytest.mark.quick def test_worker_inherits_the_in_tree_rocke_paths(): - """A pool worker imports rocke and kernels from the paths this directory's + """A spawned pool worker imports rocke and kernels from the paths this directory's conftest inserts, which no `PYTHONPATH` carries, so it can only have them by - inheriting the parent's `sys.path`.""" + inheriting the parent's `sys.path`. The pool is pinned to `spawn`: under the Linux + default (`fork` up to Python 3.13) the child copies the parent's memory and the + check would prove nothing.""" candidates = [_ROCKE_ROOT / "platform" / "python", _ROCKE_ROOT / "library"] expected = _conftest_only_paths(candidates) assert len(expected) == 2, "the premise: conftest inserted both, PYTHONPATH neither" - with concurrent.futures.ProcessPoolExecutor(max_workers=1) as pool: + with concurrent.futures.ProcessPoolExecutor( + max_workers=1, mp_context=multiprocessing.get_context("spawn") + ) as pool: child_path = list(pool.map(_child_sys_path, [None], chunksize=1))[0] assert set(expected) <= set(child_path) diff --git a/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/test_hkp_pack_parallel.py b/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/test_hkp_pack_parallel.py index fd2ad6c338e2..88f006381477 100644 --- a/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/test_hkp_pack_parallel.py +++ b/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/test_hkp_pack_parallel.py @@ -10,6 +10,7 @@ import concurrent.futures import itertools +import multiprocessing import os import re import sys @@ -24,7 +25,6 @@ from hkp_pack.hip_compile import hip_source_relpath, hip_variant_key from pack_helpers import ( _child_sys_path, - _conftest_only_paths, _kdp, _silent, _ukd, @@ -801,7 +801,7 @@ def test_variant_key_for_uses_module_globals(monkeypatch): @pytest.mark.quick def test_worker_inherits_parent_sys_path(tmp_path, monkeypatch): - """A pool worker starts with the parent's `sys.path`, conftest inserts and all. + """A spawned pool worker starts with the parent's `sys.path`, conftest inserts and all. CPython propagates `sys.path` to children under both `spawn` and `forkserver`, so a worker can import `hkp_pack`, and any producer module a @@ -814,22 +814,22 @@ def test_worker_inherits_parent_sys_path(tmp_path, monkeypatch): pool must be built after parent-side path setup, is documented at the construction site. - The path watched is one the test prepends to the parent's `sys.path` itself, in a - directory no `PYTHONPATH` carries, so the premise holds in every environment. An - operator-supplied directory such as HIPKERNELPROVIDER_ROCM_KPACK_DIR cannot serve: - it can already be a site-packages directory (as in CI), which the child has on its - `sys.path` whether or not the parent's propagated. + The pool is pinned to `spawn` because the platform default is not one start + method: it is `fork` on Linux up to Python 3.13, where the child inherits + `sys.path` with the rest of the parent's memory and a check would prove nothing. + The path watched is one the test prepends itself, in a directory no `PYTHONPATH` + carries, so it reaches the child only if the interpreter propagates it. """ probe = tmp_path / "parent-only-path" probe.mkdir() monkeypatch.syspath_prepend(str(probe)) - expected = _conftest_only_paths([str(probe)]) - assert expected, "the premise: the probe is on sys.path and PYTHONPATH lacks it" - with concurrent.futures.ProcessPoolExecutor(max_workers=1) as pool: + with concurrent.futures.ProcessPoolExecutor( + max_workers=1, mp_context=multiprocessing.get_context("spawn") + ) as pool: child_path = list(pool.map(_child_sys_path, [None], chunksize=1))[0] - assert set(expected) <= set(child_path) + assert str(probe) in child_path @pytest.mark.quick diff --git a/projects/hipdnn/tools/CMakeLists.txt b/projects/hipdnn/tools/CMakeLists.txt index 9a604e49d8ae..0afc87d5a8de 100644 --- a/projects/hipdnn/tools/CMakeLists.txt +++ b/projects/hipdnn/tools/CMakeLists.txt @@ -123,15 +123,29 @@ endif() # DescriptorGenerator pytest suite, plus the junit directory both tooling suites write to. # ctest reports a suite whose every case skips as a pass, so the pytest junit is the only place -# a skip is visible: .github/scripts/check_pytest_junit.py reads it in the Linux superbuild -# lane and fails on any skip .github/hipdnn-tooling-allowed-skips.txt does not name. +# a skip is visible: .github/scripts/check_pytest_junit.py reads it and fails on any skip +# tooling-allowed-skips.txt in this directory does not name. # Build tree only, like the smoke tests above. The option is OFF by default so TheRock and # developer builds are unchanged; when ON, a missing prerequisite fails configure rather than # silently skipping. +if(HIPDNN_ENABLE_TOOLING_TESTS AND HIPDNN_SKIP_TESTS) + message(STATUS "hipDNN: HIPDNN_ENABLE_TOOLING_TESTS is ignored because HIPDNN_SKIP_TESTS is ON") +endif() if(HIPDNN_ENABLE_TOOLING_TESTS AND NOT HIPDNN_SKIP_TESTS) if(NOT Python3_FOUND) message(FATAL_ERROR "hipDNN: HIPDNN_ENABLE_TOOLING_TESTS=ON needs a Python3 interpreter") endif() + # The IngestorGenerator suite is registered below only with the kernel ingestor, whose + # hipdnn_validate_descriptors tool its round-trip tests run. Without it the option would + # register one suite and leave the other silently absent. + if(NOT HIPDNN_ENABLE_KERNEL_INGESTOR) + message( + FATAL_ERROR + "hipDNN: HIPDNN_ENABLE_TOOLING_TESTS=ON also needs HIPDNN_ENABLE_KERNEL_INGESTOR=ON: " + "the IngestorGenerator suite runs the hipdnn_validate_descriptors tool that option " + "builds. Enable it, or configure with -DHIPDNN_ENABLE_TOOLING_TESTS=OFF." + ) + endif() execute_process( COMMAND "${Python3_EXECUTABLE}" -c "import pytest, yaml, jinja2" RESULT_VARIABLE _hipdnn_tooling_imports diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py index d1ebc6cf101f..e2d75ce659a8 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py @@ -180,10 +180,8 @@ def test_an_unhydratable_shape_exits_2_naming_the_failure(self, parity, capsys): ) def test_a_dispatcher_that_raises_also_exits_2(self, parity, capsys, monkeypatch): - """The factory is inside the same try as the request constructor, so a - dispatcher that raises is operational, never a decline. The dispatcher's own - message is asserted so an exit 2 raised while resolving the symbol does not - pass.""" + """A dispatcher that raises is operational, never a decline. Its own message is + asserted so an exit 2 raised while resolving the symbol does not pass.""" shapes = [{"seqlen_q": 256}] argv = parity(shapes) real_resolve_shapes = dispatch_parity.resolve_shapes @@ -391,7 +389,7 @@ def _provided_by_the_bound_provider(top: str, profile) -> bool: if spec is None: return False root = ( - Path(__file__).resolve().parents[5] + launch_surface.find_repo_root(_TOOLS) / os.path.expanduser(profile["provider_root"]) ).resolve() locations = [spec.origin, *(spec.submodule_search_locations or [])] @@ -468,18 +466,7 @@ def test_a_shape_the_pinned_body_refuses_is_a_decline_with_its_reason( monkeypatch, shape={**self._SHAPE, "hdim_q": 96, "hdim_v": 96} ) assert resolution.kind == "declined" - assert "head_size must be 64 or 128" in resolution.reason, resolution.reason - - def test_a_shape_naming_another_candidate_is_fatal_not_a_mixed_catalog( - self, monkeypatch - ): - """The pin decides which kernel family the catalog is, so a shape that - overrides it aborts rather than emitting another family's kernels.""" - with pytest.raises(dispatch_parity.ParityError, match="dispatch.pin"): - self._resolve( - monkeypatch, - shape={**self._SHAPE, "algorithm": "attention_dense_persist"}, - ) + assert resolution.reason.strip(), "a decline carries the dispatcher's reason" def test_the_reference_oracle_reaches_the_pinned_candidate(self, monkeypatch): """``reconcile_applicability`` builds its own requests from ``request.defaults``, @@ -496,3 +483,124 @@ def test_the_reference_oracle_reaches_the_pinned_candidate(self, monkeypatch): ) assert served[0][0] is True, served[0] assert served[1][0] is False, served[1] + + +#: A pinning dispatcher in miniature, so the dialect's failure paths are tested without +#: rocKE. `seqlen_q` picks the outcome: 777 is refused, 888 fails operationally, 999 +#: resolves to a route-only spec (no `kernel_spec`) and 555 to a result that lacks the +#: attribute altogether. +_PIN_STUB = """ +import dataclasses +from types import SimpleNamespace + + +class Refused(ValueError): + pass + + +@dataclasses.dataclass +class Request: + seqlen_q: int + algorithm: str = "auto" + + +@dataclasses.dataclass +class Spec: + seqlen_q: int + + +def dispatch(request): + if request.algorithm != "stub_grid": + raise RuntimeError("the pin never reached the request") + if request.seqlen_q == 777: + raise Refused("pin does not admit seqlen_q 777") + if request.seqlen_q == 888: + raise RuntimeError("dispatcher exploded") + if request.seqlen_q == 999: + return SimpleNamespace(spec=SimpleNamespace(kernel_spec=None)) + if request.seqlen_q == 555: + return SimpleNamespace(spec=SimpleNamespace()) + return SimpleNamespace(spec=SimpleNamespace(kernel_spec=Spec(request.seqlen_q))) +""" + + +@pytest.fixture +def pin_resolve(tmp_path, monkeypatch): + """``resolve_shapes`` over one shape against the stub, with ``overrides`` merged + into the profile's ``dispatch`` block.""" + (tmp_path / "pin_stub.py").write_text(_PIN_STUB) + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.delitem(sys.modules, "pin_stub", raising=False) + + def resolve(seqlen_q, shape=None, **overrides): + profile = { + "dispatch": { + "module": "pin_stub", + "function": "dispatch", + "pin": {"algorithm": "stub_grid"}, + "result_attr": "spec", + "spec_attr": "kernel_spec", + "refusal_exception": "pin_stub.Refused", + **overrides, + }, + "request": {"module": "pin_stub", "class": "Request"}, + } + shapes = [{"seqlen_q": seqlen_q, **(shape or {})}] + return dispatch_parity.resolve_shapes(shapes, profile) + + return resolve + + +class TestThePinDialectFailsLoudlyWhereItShould: + def test_a_served_shape_reaches_the_builders_spec(self, pin_resolve): + """The control: the failures below are not a harness that raises on everything. + The stub raises unless the pin reached the request, so this also proves the + pin is merged into every request.""" + (resolution,) = pin_resolve(256) + assert resolution.kind == "constructed" + assert resolution.spec.seqlen_q == 256 + + def test_a_shape_restating_the_pin_is_not_a_conflict(self, pin_resolve): + (resolution,) = pin_resolve(256, shape={"algorithm": "stub_grid"}) + assert resolution.kind == "constructed" + + def test_a_shape_naming_another_candidate_is_fatal_not_a_mixed_catalog( + self, pin_resolve + ): + """The pin decides which kernel family the catalog is, so a shape that + overrides it aborts rather than emitting another family's kernels.""" + with pytest.raises(dispatch_parity.ParityError, match="dispatch.pin"): + pin_resolve(256, shape={"algorithm": "other_grid"}) + + def test_a_refusal_is_a_decline_carrying_the_dispatchers_reason(self, pin_resolve): + (resolution,) = pin_resolve(777) + assert resolution.kind == "declined" + assert "does not admit seqlen_q 777" in resolution.reason + + def test_a_refusal_aborts_when_the_profile_names_no_refusal_class( + self, pin_resolve + ): + with pytest.raises(dispatch_parity.ParityError, match="does not admit"): + pin_resolve(777, refusal_exception=None) + + def test_an_exception_that_is_not_the_refusal_stays_fatal(self, pin_resolve): + with pytest.raises(dispatch_parity.ParityError, match="dispatcher exploded"): + pin_resolve(888) + + def test_a_route_only_result_is_fatal_rather_than_a_catalog_entry( + self, pin_resolve + ): + with pytest.raises(dispatch_parity.ParityError, match="routing decision"): + pin_resolve(999) + + def test_a_result_without_the_named_attribute_is_fatal(self, pin_resolve): + with pytest.raises(dispatch_parity.ParityError, match="does not have"): + pin_resolve(555) + + def test_the_refusal_must_name_an_exception_class(self, pin_resolve): + with pytest.raises(dispatch_parity.ParityError, match="not an exception class"): + pin_resolve(256, refusal_exception="pin_stub.Spec") + + def test_the_refusal_must_be_a_dotted_path(self, pin_resolve): + with pytest.raises(dispatch_parity.ParityError, match="dotted path"): + pin_resolve(256, refusal_exception="Refused") diff --git a/projects/hipdnn/tools/tooling-allowed-skips-windows.txt b/projects/hipdnn/tools/tooling-allowed-skips-windows.txt new file mode 100644 index 000000000000..dd6a17208d11 --- /dev/null +++ b/projects/hipdnn/tools/tooling-allowed-skips-windows.txt @@ -0,0 +1,17 @@ +# Skips the Windows superbuild lane accepts from the hipDNN tooling pytest suites, on top of +# the shared list in tooling-allowed-skips.txt. check_pytest_junit.py is given both +# files on this lane and only the shared one on Linux, so an entry here may not match a +# skip Linux makes, and the stale-allow check keeps it from outliving the skip it excuses. +# +# Format and rules are the shared file's: ` # `, every entry needs a reason. +# +# One entry per class that carries the POSIX-only markers in test_sweep_tools.py, not the +# whole module, so a new environment-gated skip elsewhere in it still fails the check. +hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.TestCorpusGraphIdentity::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang and runs it directly, which Windows cannot do +hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.TestCorrectnessEvidenceIsRequiredNotOptional::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang and runs it directly, which Windows cannot do +hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.TestEngineIdentity::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang and runs it directly, which Windows cannot do +hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.TestGatesFailIndependently::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang and runs it directly, which Windows cannot do +hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.TestPluginAttribution::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang and runs it directly, which Windows cannot do +hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.TestResume::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang and runs it directly, which Windows cannot do +hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.TestTheCLIsRunAsPrograms::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang and runs it directly, which Windows cannot do +hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.TestTheDriverRefusesAnUnsafeConfig::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang and runs it directly, which Windows cannot do; one case also withdraws directory write access with chmod, which Windows ignores diff --git a/.github/hipdnn-tooling-allowed-skips.txt b/projects/hipdnn/tools/tooling-allowed-skips.txt similarity index 55% rename from .github/hipdnn-tooling-allowed-skips.txt rename to projects/hipdnn/tools/tooling-allowed-skips.txt index 289e24b2b8d8..dfa737da8980 100644 --- a/.github/hipdnn-tooling-allowed-skips.txt +++ b/projects/hipdnn/tools/tooling-allowed-skips.txt @@ -1,12 +1,16 @@ # Skips the superbuild lanes accept from the hipDNN tooling pytest suites. # # Read by .github/scripts/check_pytest_junit.py in .github/workflows/hipdnn-superbuild-ci.yml. -# The Windows lane also reads hipdnn-tooling-allowed-skips-windows.txt for the skips only it makes. +# The Windows lane also reads tooling-allowed-skips-windows.txt, beside this file, for the skips +# only it makes. Both live next to the suites they excuse so that their owners maintain them. # Each line is ` # `; every entry needs a reason. The pattern is an fnmatch # glob over `::::`, e.g. -# `hipdnn_ingestor_generator_pytest::tests.test_round_trip::test_x[param]`. The junit stem is -# the ctest test name. An entry that matches no skipped test fails the check, so delete entries -# whose skip is gone. +# `hipdnn_ingestor_generator_pytest::tests.test_round_trip::*`. The junit stem is the ctest +# test name. `[` starts a glob character class, so a parametrized id such as `test_x[p1]` is +# matched with `*` or written `test_x[[]p1]`. An entry that matches no skipped test fails the +# check, so the list only shrinks: the same rule as KNOWN_BAD in +# dnn-providers/hip-kernel-provider/rocke/TESTING.md and KNOWN_VIOLATIONS in +# dnn-providers/hip-kernel-provider/rocke/library/tests/test_library_layering.py. # # Add an entry only for an input the author supplies by design. A skip whose input the build # can provide is a gate to close, not an entry to add. From f6ace6500443331af2adb64ac4cfbfd89a830a59 Mon Sep 17 00:00:00 2001 From: Garby Gilbert Date: Tue, 13 Oct 2026 13:59:22 -0600 Subject: [PATCH 08/13] fix(hipdnn): remove the stale gfx950 dense ingestor profile The gfx950 dense ingestor profile named a rocKE dispatcher entry point, a request field and spec ids the dispatcher no longer has, and the tests that read it skipped without rocKE, so nothing flagged the drift. Porting it to the current dispatcher only restated in YAML what the dispatcher registry already answers, and the next dispatcher change would break it again, so the profile is deleted rather than maintained. The repository ships no profile again, and dispatch_parity.py, reconcile_applicability.py and knob_sweep.py take the author's own through --profile. The pin dialect that the earlier port added to dispatch_parity.py (dispatch.pin, result_attr, spec_attr and refusal_exception) and the pin inheritance in reconcile_applicability.py existed only to serve that profile, so both are removed with their tests, which returns the two tools to their form before the port. The dispatch-parity tests that resolved the profile through the real dispatcher or checked its provider_root are removed as well, along with the two config-loader tests that required a profile under configs/. The stub-based tests of the tools remain. ALMIOPEN-2843 --- .../gfx950_attention_dense.profile.yaml | 281 ----------------- .../tests/test_config_loader.py | 57 +--- .../tests/test_dispatch_parity.py | 288 +----------------- .../tools/dispatch_parity.py | 111 +------ .../tools/reconcile_applicability.py | 16 +- 5 files changed, 20 insertions(+), 733 deletions(-) delete mode 100644 projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml diff --git a/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml b/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml deleted file mode 100644 index 1e50e2e3fbf1..000000000000 --- a/projects/hipdnn/tools/IngestorGenerator/configs/gfx950_attention_dense.profile.yaml +++ /dev/null @@ -1,281 +0,0 @@ -# Profile: gfx950 dense flash-attention prefill -# -# Read by the profile-driven scripts under tools/. - -arch: gfx950 -slug: gfx950_attention_dense - -# Repository-relative, resolved against the checkout root the tools find from their -# own location -- never against the current directory. A `../..` chain is only correct -# when the tool is invoked from one particular directory, which nothing guarantees. -provider_root: dnn-providers/hip-kernel-provider - -# Read by reconcile_applicability.py. -reference_candidates: - module: dispatch.attention - function: attention_candidates - match: algorithm - family: attention_dense_grid - -source: kernels/gfx950/attention_dense.py -builder: build_attention_dense - -dispatch: - module: dispatch.attention - function: dispatch_attention - # Selection is by PIN. `dispatch_attention` unpinned returns the unified 2D/3D - # routing path, which is a different kernel family on a different ABI; the dense - # catalog exists only under an explicit algorithm + spec_id. - # - # `attention_dense_grid` is the NON-persistent arm and the only one this catalog - # ships. It is also the only dense arm on the eight-argument signature: the - # persistent arms bake batch/seqlen_q/seqlen_kv into the binary and take five - # arguments, a different kernel-argument contract this engine's - # expectedSignature check would reject. - # - # Names are post-`ae4db22a5b8`, which split the dense arms into algorithms and - # moved the tile out of the spec_id into a knob axis. The pre-refactor spelling - # (`algorithm: attention_dense`, `spec_id: gfx950_dense_grid_default`) no longer - # resolves to anything. - pin: - algorithm: attention_dense_grid - spec_id: gfx950_dense_grid - # `dispatch_attention` returns a DispatchResult, not the builder's spec: - # .spec is the AttentionTuningSpec, .spec.kernel_spec the - # Gfx950AttentionDenseSpec that hkp_pack hydrates and comgr compiles. - result_attr: spec - spec_attr: kernel_spec - # A pin the dispatcher will not serve for a given shape. Per-shape decline, - # counted by --report-gaps -- not a tool failure. - refusal_exception: rocke.dispatch.core.PinRefused - -request: - module: dispatch.attention.common - class: AttentionRequest - defaults: - arch: gfx950 - -predicate: - module: kernels.gfx950.attention_dense - function: supports_attention_dense - -arch_spec: - module: kernels.gfx950.attention_dense - class: Gfx950AttentionDenseSpec - -# Metadata fields the compiled binary specializes on. block_m/block_n are the -# tile, also public engine knobs; a record omitting them completes to the -# 256/64 baseline tile, and with no tile knob set, cold selection prefers the -# applicable 256/64 candidate. -metadata_fields: - - dtype - - head_size - - num_query_heads - - num_kv_heads - - causal - - ragged - - sliding_window - - batch - - seqlen_q - - seqlen_kv - - block_m - - block_n - -# Vocabulary: map kernel dtype spelling to hipDNN matcher spelling. -vocabulary: - dtype: - bf16: BF16 - fp16: FP16 - -specialization: - metadata_fields: - - dtype - - head_size - - num_query_heads - - num_kv_heads - - causal - - ragged - - sliding_window - - batch - - seqlen_q - - seqlen_kv - - block_m - - block_n - matcher_only_fields: [] - vocabulary: - dtype: - bf16: BF16 - fp16: FP16 - bindings: - dtype: - field: dtype - head_size: - field: head_size - num_query_heads: - field: num_query_heads - num_kv_heads: - field: num_kv_heads - causal: - field: causal - ragged: - field: ragged - sliding_window: - field: sliding_window - batch: - field: batch - seqlen_q: - field: seqlen_q - seqlen_kv: - field: seqlen_kv - block_m: - field: block_m - block_n: - field: block_n - -dialect: packaged -authored_subpath: rocKE/gfx950_attention_dense - -engine: - name: "hipkernel:Gfx950AttentionDense" - sdk_version: "1.0.0" - knobs: - - head_size - - num_query_heads - - num_kv_heads - - causal - - block_m - - block_n - heuristic: native - -kmd_fields: - - name: dtype - type: string - - name: head_size - type: int - default_value: 128 - - name: num_query_heads - type: int - default_value: 8 - - name: num_kv_heads - type: int - default_value: 8 - - name: causal - type: int - default_value: 1 - - name: ragged - type: int - default_value: 0 - - name: sliding_window - type: int - default_value: 0 - - name: batch - type: int - default_value: 1 - - name: seqlen_q - type: int - default_value: 512 - - name: seqlen_kv - type: int - default_value: 512 - - name: block_m - type: int - default_value: 256 - - name: block_n - type: int - default_value: 64 - -kernel_source_kind: rocke -workspace_policy: none -delegates_to_existing_plan: false - -# The padding knobs are settled. The tile knobs are open sweep inputs; the -# dispatcher default stays block_m=256/block_n=64, and the shipped catalog is the -# `knob_sets` tile lists in gfx950_attention_dense.yaml, not a sweep product. -sweep: - lds_k_group_pad: - verdict: > - Settled at 8. Sweep {0,8,16,24,32} at D=64: 8 and 16 tied at the - 4-deep bank-conflict floor; 0 and 32 were measurably worse. Commit a2cb8ab. - lds_v_row_pad: - verdict: > - Settled at 32. Sweep {0,8,16,32}: 32 eliminates all bank conflicts for - the D=128 transposed PV accumulation slab. Commit 88b8bc5. - block_m: - values: [128, 256] - hazard: > - Moves with block_n. Catalog tiles satisfy block_m % block_n == 0, - block_n % (block_m / 32) == 0 and static LDS (528 * block_n bytes at - D=64, 1184 * block_n at D=128) within 163,840 bytes. Arms the spec or - supports_attention_dense() refuses are reported NARROWED; an arm that - builds is not thereby a legal catalog tile. - block_n: - values: [32, 64, 128, 256] - hazard: > - Moves with block_m; same tile constraints as block_m. BN256 is legal - only with block_m=256 at D=64. - -# Fields the aligned kernel reads only from kernel arguments: every entry is -# built at the canonical B1/Sq512/Skv512 and reused for any runtime shape its -# tile divides. Documentation only: no generator tool reads this block, and -# the rocKE spec's own runtime_param_fields decides per mode. -runtime_param_fields: - - batch - - seqlen_q - - seqlen_kv - -graph_match: - shape: shared_shape - discriminator: none - -# Launch-surface audit: every quantity the C++ engine restates from the Python -# launch path, per surface. See tools/launch_surface.py for the schema and checks. -# -# No sliding_window or dtype surface: neither controls the grid, block or ABI. -launch_surface: - - name: grid - python_source: kernels/gfx950/attention_dense.py:attention_dense_grid - cpp_mirror: dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/Gfx950AttentionDenseGeometry.hpp - kmd_fields: [block_m, seqlen_q, num_query_heads, batch] - guard: > - gfx950AttentionDenseGeometry() throws HipdnnPluginException for - non-positive dimensions (seqlen_q, num_query_heads or batch <= 0). - Grid is (ceil(Sq/block_m), Hq, B) from the candidate's own block_m. - test: dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestGfx950AttentionDenseGeometry.cpp - - - name: block - python_source: kernels/gfx950/attention_dense.py:attention_dense_block - cpp_mirror: dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/Gfx950AttentionDenseGeometry.hpp - kmd_fields: [block_m] - guard: > - block_m / GFX950_ROWS_PER_WAVE(32) * GFX950_WAVE_LANES(64) threads: - 256 for block_m=128, 512 for block_m=256. - test: dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestGfx950AttentionDenseGeometry.cpp - - - name: matcher - python_source: kernels/gfx950/attention_dense.py:build_attention_dense - cpp_mirror: dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/Gfx950AttentionDenseNative.cpp - kmd_fields: [dtype, head_size, num_query_heads, num_kv_heads, causal, sliding_window, ragged, block_m, block_n] - guard: > - kernelMatches() returns false (no panic) if dtype, head_size, - num_query_heads, num_kv_heads, causal or sliding_window disagrees with - the graph, the candidate's tile is illegal, or the candidate is ragged - (ragged != 0). The metadata's batch, seqlen_q and seqlen_kv are build - inputs of a shape-generic kernel and are never compared; the graph's - shape enters only through seqlen_q % block_m == 0 and - seqlen_kv % block_n == 0 per candidate. score() ranks an applicable - 256/64 baseline first, then ascending (block_m, block_n). - test: dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestGfx950AttentionDenseMatchers.cpp - - - name: kernargs - python_source: kernels/gfx950/attention_dense.py:build_attention_dense - cpp_mirror: dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/packs/Gfx950AttentionDenseNative.cpp - kmd_fields: [ragged, seqlen_q, num_query_heads, batch] - guard: > - attentionDenseKernelSignature() always returns the 8-arg form - (q,k,v,o,scale,batch,seqlen_q,seqlen_kv). Every variant is - non-persistent, which is what the dispatch.pin above keeps true by - pinning the `attention_dense_grid` algorithm: the persistent arms are a - separate algorithm on a five-argument signature. use_sinks=False; no - sink_ptr slot. Validated at prepare() time by - buildIngestorKernelCode(..., expectedSignature). - test: dnn-providers/hip-kernel-provider/src/tests/engines/kernel_ingestor_engine/packs/TestGfx950AttentionDenseSignature.cpp diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_config_loader.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_config_loader.py index e7665f426af4..34ed27bfa8a8 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_config_loader.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_config_loader.py @@ -1206,67 +1206,14 @@ def test_an_unsafe_tag_is_still_rejected(self, tmp_path, entry): class TestShippedExampleConfigsLoad: """Every config under `configs/` is a worked example a reader copies, so a retired - key they still set would make each copy a config the loader refuses. - - `configs/` holds TWO schemas. `*.profile.yaml` is a dispatch profile: it names the - dispatcher, request class and predicate the tools import, and carries none of the - keys `load_config` requires. Selecting it by extension alone feeds the wrong schema - into the generator loader, so it is selected out here and checked through the loader - that owns it. - """ - - @staticmethod - def _generator_configs(configs_dir): - return sorted( - path - for path in configs_dir.glob("*.yaml") - if not path.name.endswith(".profile.yaml") - ) + key they still set would make each copy a config the loader refuses.""" def test_every_shipped_generator_config_loads(self, configs_dir): - paths = self._generator_configs(configs_dir) + paths = sorted(configs_dir.glob("*.yaml")) assert paths, f"no generator configs found under {configs_dir}" for path in paths: assert load_config(path).engine.name - def test_every_shipped_dispatch_profile_loads_through_its_own_loader( - self, configs_dir - ): - """The profiles are shipped worked examples too, so leaving them unchecked - would just move the gap rather than close it.""" - # Imported here rather than at module scope: this suite is about - # codegen.config_loader, and `tools/` is not otherwise on its path. - import sys - from pathlib import Path - - sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) - import dispatch_parity - - paths = sorted(configs_dir.glob("*.profile.yaml")) - assert paths, f"no dispatch profiles found under {configs_dir}" - for path in paths: - profile = dispatch_parity._load_profile(str(path)) - # The blocks every profile tool dereferences; `_required` is the tool's - # own check, so this cannot drift from what the tools demand. - for scope, keys in ( - ("dispatch", ("module", "function")), - ("request", ("module", "class")), - ("predicate", ("module", "function")), - ): - dispatch_parity._required(profile[scope], scope, *keys) - assert profile["provider_root"], path - - def test_a_dispatch_profile_is_not_a_generator_config(self, configs_dir): - """The control for the selection above: if `load_config` ever accepted a - profile, the split would be silently unnecessary and the next reader would - re-merge it.""" - profiles = sorted(configs_dir.glob("*.profile.yaml")) - assert profiles, f"no dispatch profiles found under {configs_dir}" - assert not [p for p in self._generator_configs(configs_dir) if p in profiles] - for path in profiles: - with pytest.raises(ConfigError): - load_config(path) - class TestBehaviorNotesVocabulary: def test_unknown_behavior_note_rejected(self, tmp_path): diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py index e2d75ce659a8..3a4e75f84352 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py @@ -10,7 +10,7 @@ failure was checked for. The dispatcher, request class and predicate are stubs. Also what the tool BINDS before it can report anything: where a profile's -``provider_root`` resolves from, and which dispatch arm the shipped profile pins. +``provider_root`` resolves from. """ from __future__ import annotations @@ -28,11 +28,6 @@ import launch_surface # noqa: E402 _TOOLS = Path(__file__).resolve().parents[1] / "tools" -_SHIPPED_PROFILE = ( - Path(__file__).resolve().parents[1] - / "configs" - / "gfx950_attention_dense.profile.yaml" -) #: The decline carries a real reason rather than a blanket refusal so the served #: control survives alongside it. @@ -180,8 +175,10 @@ def test_an_unhydratable_shape_exits_2_naming_the_failure(self, parity, capsys): ) def test_a_dispatcher_that_raises_also_exits_2(self, parity, capsys, monkeypatch): - """A dispatcher that raises is operational, never a decline. Its own message is - asserted so an exit 2 raised while resolving the symbol does not pass.""" + """The factory is inside the same try as the request constructor, so a + dispatcher that raises is operational, never a decline. The dispatcher's own + message is asserted so an exit 2 raised while resolving the symbol does not + pass.""" shapes = [{"seqlen_q": 256}] argv = parity(shapes) real_resolve_shapes = dispatch_parity.resolve_shapes @@ -213,8 +210,9 @@ def test_a_predicate_decline_is_not_promoted_to_an_abort(self, parity, capsys): assert "no shape resolved" in capsys.readouterr().err -#: The relative root the shipped profile names. Spelled out so the decoy below can -#: reproduce it exactly: under a cwd-relative resolution the decoy is what binds. +#: A repository-relative provider root, as a profile would write it. Spelled out so the +#: decoy below can reproduce it exactly: under a cwd-relative resolution the decoy is +#: what binds. _REPO_RELATIVE_ROOT = "dnn-providers/hip-kernel-provider" @@ -274,26 +272,6 @@ def test_a_relative_root_binds_the_checkout_not_a_look_alike_under_the_cwd( "neither the cwd nor the repository root" ) - def test_the_shipped_profile_binds_from_an_unrelated_directory( - self, bind, tmp_path, monkeypatch - ): - """End to end on the value that actually ships, so a correct resolver paired - with a stale profile string still fails.""" - profile = dispatch_parity._load_profile(str(_SHIPPED_PROFILE)) - root = profile["provider_root"] - assert not os.path.isabs(root), ( - f"the shipped profile names an absolute provider root ({root!r}); it " - "would only resolve on the machine that wrote it" - ) - monkeypatch.chdir(tmp_path) - - added = bind(root) - - assert added, ( - f"the shipped provider_root {root!r} did not bind from an unrelated " - "directory" - ) - def test_a_relative_root_that_is_absent_names_the_checkout_it_looked_under( self, bind, tmp_path, monkeypatch ): @@ -354,253 +332,3 @@ def test_the_sibling_tools_share_this_one_binding(self): assert knob_sweep._bind_provider is dispatch_parity._bind_provider assert reconcile_applicability._bind_provider is dispatch_parity._bind_provider - - -class TestTheShippedProfilePinsTheDispatchArmItsCatalogWasBuiltFrom: - """``dispatch.pin`` selects one gfx950 dense candidate, and this catalog contains - one arm of that choice only: the non-persistent grid body.""" - - #: B1, Sq=Skv=8192, Hq=Hkv=8, D=128, bf16, causal. A shape every gfx950 dense - #: body serves, so a control pinned to another body resolves rather than declines. - _SHAPE = { - "batch": 1, - "seqlen_q": 8192, - "seqlen_k": 8192, - "nhead_q": 8, - "nhead_k": 8, - "hdim_q": 128, - "hdim_v": 128, - "dtype": "bf16", - "mask_type": 1, - } - - @staticmethod - def _provided_by_the_bound_provider(top: str, profile) -> bool: - """True when top-level module ``top`` resolves to a file inside the profile's - provider root, i.e. the provider itself, not one of its dependencies.""" - import importlib.util - - if not top: - return False - try: - spec = importlib.util.find_spec(top) - except (ImportError, ValueError): - return False - if spec is None: - return False - root = ( - launch_surface.find_repo_root(_TOOLS) - / os.path.expanduser(profile["provider_root"]) - ).resolve() - locations = [spec.origin, *(spec.submodule_search_locations or [])] - for location in locations: - if location and Path(location).resolve().is_relative_to(root): - return True - return False - - @classmethod - def _resolve(cls, monkeypatch, shape=None, **pin): - """The shipped profile's own resolution of one shape, through the tool. - - ``pin`` overrides ``dispatch.pin``, so a control can name another candidate. - """ - profile = dispatch_parity._load_profile(str(_SHIPPED_PROFILE)) - profile["dispatch"]["pin"].update(pin) - monkeypatch.setattr(sys, "path", list(sys.path)) - dispatch_parity._bind_provider(profile["provider_root"]) - try: - (resolution,) = dispatch_parity.resolve_shapes( - [shape or cls._SHAPE], profile - ) - except dispatch_parity.ParityError as exc: - missing = getattr(exc.__cause__ or exc.__context__, "name", None) or "" - if cls._provided_by_the_bound_provider(missing.split(".")[0], profile): - # The library is bound and owns this name, so the profile is stale. A skip - # here hid that: fail so it is seen. - pytest.fail( - f"the shipped profile names {missing!r}, which the in-tree rocKE no " - f"longer provides -- port {_SHIPPED_PROFILE.name} to the current " - "rocKE dispatcher API" - ) - if isinstance(exc.__cause__ or exc.__context__, ImportError): - # Not the provider's own: a third-party dependency of it is absent. - pytest.skip( - f"the rocKE library cannot be imported here ({exc}) -- run with an " - "interpreter that has its dependencies, e.g. /dnn-providers/" - "hip-kernel-provider/descriptor-packaging/hkp-rocke-venv/bin/python" - ) - raise - return resolution - - def test_the_dispatcher_resolves_the_non_persistent_grid_body(self, monkeypatch): - """What the dispatcher does with the profile's pin, not the pin's spelling: a - profile that parses but no longer reaches the non-persistent eight-argument - kernel still fails.""" - resolution = self._resolve(monkeypatch) - assert resolution.kind == "constructed", resolution.reason - assert resolution.spec.persistent is False, ( - "the shipped profile resolves a persistent body at B1/Sq8192/H8/D128 -- " - "a kernel with a different argument contract that this catalog does not " - "ship" - ) - assert resolution.spec.wide_lds_dma is False, resolution.spec - - def test_a_control_pinned_to_the_persistent_body_resolves_it(self, monkeypatch): - """A control: the same shape pinned to the persistent body IS persistent, so - the case above exercises the pin rather than a shape no body makes - persistent.""" - resolution = self._resolve( - monkeypatch, - algorithm="attention_dense_persist", - spec_id="gfx950_dense_persist", - ) - assert resolution.kind == "constructed", resolution.reason - assert resolution.spec.persistent is True, resolution.spec - - def test_a_shape_the_pinned_body_refuses_is_a_decline_with_its_reason( - self, monkeypatch - ): - """The dispatcher's refusal is a per-shape verdict, not an abort: the tool - reports it under ``--report-gaps`` instead of exiting 2.""" - resolution = self._resolve( - monkeypatch, shape={**self._SHAPE, "hdim_q": 96, "hdim_v": 96} - ) - assert resolution.kind == "declined" - assert resolution.reason.strip(), "a decline carries the dispatcher's reason" - - def test_the_reference_oracle_reaches_the_pinned_candidate(self, monkeypatch): - """``reconcile_applicability`` builds its own requests from ``request.defaults``, - which carries no selector under a pinning dispatcher. It must inherit the pin, - or an opt-in reference declines every shape and reconciles with nothing.""" - import reconcile_applicability - - profile = dispatch_parity._load_profile(str(_SHIPPED_PROFILE)) - monkeypatch.setattr(sys, "path", list(sys.path)) - dispatch_parity._bind_provider(profile["provider_root"]) - refused = {**self._SHAPE, "hdim_q": 96, "hdim_v": 96} - served = reconcile_applicability.reference_serves( - [self._SHAPE, refused], profile - ) - assert served[0][0] is True, served[0] - assert served[1][0] is False, served[1] - - -#: A pinning dispatcher in miniature, so the dialect's failure paths are tested without -#: rocKE. `seqlen_q` picks the outcome: 777 is refused, 888 fails operationally, 999 -#: resolves to a route-only spec (no `kernel_spec`) and 555 to a result that lacks the -#: attribute altogether. -_PIN_STUB = """ -import dataclasses -from types import SimpleNamespace - - -class Refused(ValueError): - pass - - -@dataclasses.dataclass -class Request: - seqlen_q: int - algorithm: str = "auto" - - -@dataclasses.dataclass -class Spec: - seqlen_q: int - - -def dispatch(request): - if request.algorithm != "stub_grid": - raise RuntimeError("the pin never reached the request") - if request.seqlen_q == 777: - raise Refused("pin does not admit seqlen_q 777") - if request.seqlen_q == 888: - raise RuntimeError("dispatcher exploded") - if request.seqlen_q == 999: - return SimpleNamespace(spec=SimpleNamespace(kernel_spec=None)) - if request.seqlen_q == 555: - return SimpleNamespace(spec=SimpleNamespace()) - return SimpleNamespace(spec=SimpleNamespace(kernel_spec=Spec(request.seqlen_q))) -""" - - -@pytest.fixture -def pin_resolve(tmp_path, monkeypatch): - """``resolve_shapes`` over one shape against the stub, with ``overrides`` merged - into the profile's ``dispatch`` block.""" - (tmp_path / "pin_stub.py").write_text(_PIN_STUB) - monkeypatch.syspath_prepend(str(tmp_path)) - monkeypatch.delitem(sys.modules, "pin_stub", raising=False) - - def resolve(seqlen_q, shape=None, **overrides): - profile = { - "dispatch": { - "module": "pin_stub", - "function": "dispatch", - "pin": {"algorithm": "stub_grid"}, - "result_attr": "spec", - "spec_attr": "kernel_spec", - "refusal_exception": "pin_stub.Refused", - **overrides, - }, - "request": {"module": "pin_stub", "class": "Request"}, - } - shapes = [{"seqlen_q": seqlen_q, **(shape or {})}] - return dispatch_parity.resolve_shapes(shapes, profile) - - return resolve - - -class TestThePinDialectFailsLoudlyWhereItShould: - def test_a_served_shape_reaches_the_builders_spec(self, pin_resolve): - """The control: the failures below are not a harness that raises on everything. - The stub raises unless the pin reached the request, so this also proves the - pin is merged into every request.""" - (resolution,) = pin_resolve(256) - assert resolution.kind == "constructed" - assert resolution.spec.seqlen_q == 256 - - def test_a_shape_restating_the_pin_is_not_a_conflict(self, pin_resolve): - (resolution,) = pin_resolve(256, shape={"algorithm": "stub_grid"}) - assert resolution.kind == "constructed" - - def test_a_shape_naming_another_candidate_is_fatal_not_a_mixed_catalog( - self, pin_resolve - ): - """The pin decides which kernel family the catalog is, so a shape that - overrides it aborts rather than emitting another family's kernels.""" - with pytest.raises(dispatch_parity.ParityError, match="dispatch.pin"): - pin_resolve(256, shape={"algorithm": "other_grid"}) - - def test_a_refusal_is_a_decline_carrying_the_dispatchers_reason(self, pin_resolve): - (resolution,) = pin_resolve(777) - assert resolution.kind == "declined" - assert "does not admit seqlen_q 777" in resolution.reason - - def test_a_refusal_aborts_when_the_profile_names_no_refusal_class( - self, pin_resolve - ): - with pytest.raises(dispatch_parity.ParityError, match="does not admit"): - pin_resolve(777, refusal_exception=None) - - def test_an_exception_that_is_not_the_refusal_stays_fatal(self, pin_resolve): - with pytest.raises(dispatch_parity.ParityError, match="dispatcher exploded"): - pin_resolve(888) - - def test_a_route_only_result_is_fatal_rather_than_a_catalog_entry( - self, pin_resolve - ): - with pytest.raises(dispatch_parity.ParityError, match="routing decision"): - pin_resolve(999) - - def test_a_result_without_the_named_attribute_is_fatal(self, pin_resolve): - with pytest.raises(dispatch_parity.ParityError, match="does not have"): - pin_resolve(555) - - def test_the_refusal_must_name_an_exception_class(self, pin_resolve): - with pytest.raises(dispatch_parity.ParityError, match="not an exception class"): - pin_resolve(256, refusal_exception="pin_stub.Spec") - - def test_the_refusal_must_be_a_dotted_path(self, pin_resolve): - with pytest.raises(dispatch_parity.ParityError, match="dotted path"): - pin_resolve(256, refusal_exception="Refused") diff --git a/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py index 32ce2bf6f431..a8d4192ace9a 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tools/dispatch_parity.py @@ -3,8 +3,8 @@ Stage 1 of an integration, and the only configuration argued from rocKE's behaviour rather than from measurement: the validators, the spec dataclass and the ``supports_*`` predicate answer what is legal, the dispatcher answers what -the library ships. Calling the factory resolves the candidate the request pins, -which applies whatever rules the dispatcher derives from the request. +the library ships. Calling the factory applies rules the dispatcher derives +from the request (``persistent = work >= num_persistent``). Emits one variant per servable shape, at the dispatcher's resolved spec, as a generator config ready for ``generate.py``. Not a cross-product: one spec per @@ -18,27 +18,6 @@ 2). A decline -- the eligibility predicate returning false with a reason -- is a per-shape outcome, counted and printed by ``--report-gaps``. -**Two profile dialects for the ``dispatch`` block.** The original names a -function that takes the request and returns the builder's spec. A dispatcher -that selects by *pinning* instead needs four more keys:: - - dispatch: - module: dispatch.attention - function: dispatch_attention - pin: {algorithm: attention_dense_grid, spec_id: gfx950_dense_grid} - result_attr: spec # DispatchResult -> the tuned spec - spec_attr: kernel_spec # tuned spec -> the builder's spec - refusal_exception: rocke.dispatch.core.PinRefused - -``pin`` is merged into every request and outranks the shape, because the pin is -what makes the emitted catalog one kernel family. ``result_attr``/``spec_attr`` -walk the dispatcher's return value down to the object the builder compiles; a -``None`` on the way is fatal and says so, since a route-only candidate resolves -to a routing plan rather than a buildable spec. ``refusal_exception`` names the -class the dispatcher raises for a pin it will not serve -- that is a per-shape -decline like any other, while an import error, a missing attribute or a failed -request construction stays fatal. - This tool does not sweep. ``--report-knobs`` partitions the spec fields into those that vary across dispatch decisions and those the library ships (see ``knob_partition``). @@ -190,46 +169,6 @@ def _required(decl: dict, scope: str, *keys: str) -> list: return [decl[k] for k in keys] -def _import_dotted(path: str, scope: str): - """Import `a.b.C` written as one string, for a profile key naming a class.""" - module, _, symbol = path.rpartition(".") - if not module or not symbol: - raise ParityError( - f"the profile's '{scope}' must be a dotted path to a class, like " - f"'rocke.dispatch.core.PinRefused'; got {path!r}." - ) - attribute = _import(module, symbol) - if not isinstance(attribute, type) or not issubclass(attribute, BaseException): - raise ParityError(f"'{path}' is not an exception class.") - return attribute - - -def _project(obj, attrs: list[tuple[str, str]]): - """Walk named attributes off the dispatcher's result. - - A missing attribute is operational: the profile and the dispatcher disagree - about the shape of the result. So is a ``None``, and that one is worth - naming -- a route-only candidate resolves to a spec whose ``kernel_spec`` - is ``None``, a routing plan rather than something a builder can compile, - and silently writing that into the catalog would emit kernels with no spec. - """ - for name, key in attrs: - if not hasattr(obj, name): - raise ParityError( - f"the profile's dispatch.{key} names '{name}', which " - f"{type(obj).__name__} does not have. Available: " - f"{sorted(a for a in dir(obj) if not a.startswith('_'))}." - ) - obj = getattr(obj, name) - if obj is None: - raise ParityError( - f"dispatch.{key} '{name}' is None. The pin resolved to a " - f"routing decision, not a buildable spec -- pin a candidate " - f"that carries one (a route-only candidate never does)." - ) - return obj - - def resolve_shapes(shapes: list[dict], profile: dict) -> list[Resolution]: """Ask the dispatcher for every shape; API errors are operational failures.""" dispatch = profile.get("dispatch") or {} @@ -246,58 +185,22 @@ def resolve_shapes(shapes: list[dict], profile: dict) -> list[Resolution]: arch = profile.get("arch") defaults = dict(request_decl.get("defaults") or {}) - # The selector fields that name one candidate. Authoritative over a shape: - # a shape quietly switching `algorithm` would mix two kernel families into - # one catalog, which the loader reads as one engine's inventory. - pin = dict(dispatch.get("pin") or {}) - # Walked off the dispatcher's return value. Absent: the function already - # returns the builder's spec, which is the older profile dialect. - projection = [ - (dispatch[key], key) - for key in ("result_attr", "spec_attr") - if dispatch.get(key) - ] - # A pin the dispatcher refuses is a SHAPE-level outcome, not a tool - # failure: the catalog simply does not serve that shape, which is what - # --report-gaps counts. Operational errors stay fatal. - refusal = ( - _import_dotted(dispatch["refusal_exception"], "dispatch.refusal_exception") - if dispatch.get("refusal_exception") - else None - ) - out: list[Resolution] = [] for shape in shapes: # Keys prefixed `_` are carried provenance, not request fields: they # travel with a shape so results can be split by origin, but the # request class would reject the key. - shape_fields = {k: v for k, v in shape.items() if not k.startswith("_")} - conflicting = sorted( - k for k, v in pin.items() if k in shape_fields and shape_fields[k] != v - ) - if conflicting: - raise ParityError( - f"shape {shape!r} sets {conflicting}, which dispatch.pin also " - f"sets. The pin decides which kernel family this catalog is; a " - f"shape overriding it would emit another family's kernels under " - f"this engine. Remove it from the shape or from the pin." - ) - fields = {**defaults, **shape_fields, **pin} + fields = { + **defaults, + **{k: v for k, v in shape.items() if not k.startswith("_")}, + } if arch and "arch" not in fields: fields["arch"] = arch try: request = request_cls(**fields) - except Exception as exc: - raise ParityError(f"request/spec construction failed: {exc}") from exc - try: spec = factory(request) except Exception as exc: - if refusal is not None and isinstance(exc, refusal): - out.append(Resolution(shape, reason=str(exc), kind="declined")) - continue raise ParityError(f"request/spec construction failed: {exc}") from exc - if projection: - spec = _project(spec, projection) if predicate is not None: supported, why = _predicate_result( predicate, spec, **({"arch": arch} if arch else {}) @@ -629,7 +532,7 @@ def main(argv=None) -> int: print("dispatcher parity") print(f" shapes in {len(resolutions)}") print(f" servable {len(served)}") - print(f" declined {len(declined)} (the kernel or dispatcher said no)") + print(f" declined {len(declined)} (predicate said no)") if args.report_gaps: for resolution in declined: diff --git a/projects/hipdnn/tools/IngestorGenerator/tools/reconcile_applicability.py b/projects/hipdnn/tools/IngestorGenerator/tools/reconcile_applicability.py index 5082d4632019..fefc4a176fa7 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tools/reconcile_applicability.py +++ b/projects/hipdnn/tools/IngestorGenerator/tools/reconcile_applicability.py @@ -35,12 +35,6 @@ ) -def _pin(profile: dict) -> dict: - """The selector fields ``dispatch.pin`` names, or none for a profile whose - dispatcher selects without pinning.""" - return dict((profile.get("dispatch") or {}).get("pin") or {}) - - def reference_serves(shapes: list[dict], profile: dict) -> dict: """For each shape: does the reference kernel FAMILY serve it, and which candidate? @@ -95,8 +89,7 @@ def reference_serves(shapes: list[dict], profile: dict) -> dict: arch = profile.get("arch") # Opt-in defaults ARE inherited here: without its selector, a candidate that only # matches when the request names it declines everything and reconciles trivially. - # `dispatch.pin` is that selector for a pinning dispatcher, so it is inherited too. - defaults = {**(reference_decl.get("defaults") or {}), **_pin(profile)} + defaults = dict(reference_decl.get("defaults") or {}) # An optional translator (`reference_request.via: {module, function}`) # taking the shape dict to a reference request, for a corpus written in the @@ -329,10 +322,7 @@ def main(argv=None) -> int: # count alone proves nothing. A shape either side serves proves the # comparison is live. match_key = ((profile.get("reference_candidates") or {}).get("match")) or "" - request_defaults = { - **((profile.get("request") or {}).get("defaults") or {}), - **_pin(profile), - } + request_defaults = (profile.get("request") or {}).get("defaults") or {} nothing_served = not both_serve and not only_reference and not only_ours if shapes and nothing_served and match_key and match_key not in request_defaults: print( @@ -341,7 +331,7 @@ def main(argv=None) -> int: f"opt-in kernel only\n admits a request that names it, so the reference " f"declines every shape and this\n integration declines them for the same " f"reason -- agreement about nothing.\n Add '{match_key}' to " - f"request.defaults or dispatch.pin, or pass --allow-empty if a corpus\n that nothing " + f"request.defaults, or pass --allow-empty if a corpus that\n nothing " f"serves is genuinely what you meant to reconcile.", file=sys.stderr, ) From 6386c2025c5daeca8c98084e393ed2bff9699119 Mon Sep 17 00:00:00 2001 From: Garby Gilbert Date: Mon, 19 Oct 2026 09:09:39 -0600 Subject: [PATCH 09/13] ci(hipdnn): fail skipped tooling tests inside pytest instead of reading junit ctest reports a pytest run as passed when every case skips, so a compiler, a validator binary or a kpack that is missing at run time left the IngestorGenerator and DescriptorGenerator suites green without running part of them. Both suites run under a pytest plugin, hipdnn_strict_skips, that turns any skipped or xfailed test into a failure. A skip that is correct by design, such as an author-supplied input or the POSIX-only sweep tests on Windows, opts out with @pytest.mark.allow_skip(reason=...) beside the code that skips, and a skip raised while a module is imported fails the collection. This replaces check_pytest_junit.py with its allow-lists and the separate junit step in each lane, so the suites run in the ordinary ctest step. The packaging-probe gate follows the same reasoning. HIPKERNELPROVIDER_MIN_PACKAGING_PROBES fails configure when probe discovery declares fewer probes than a lane requires, or when it is set without the probes option, so a lane that must always have a probe cannot pass with none. Both superbuild lanes set it to 1 and run ctest with --no-tests=error, which replaces check_probe_junit.py and the manifest it read. The native-stub tests get the compiler that builds the tree by putting its directory on the test PATH on every platform, so the Linux runner needs no separate C++ compiler. ALMIOPEN-2843 --- .github/scripts/check_probe_junit.py | 166 --------------- .github/scripts/check_pytest_junit.py | 156 -------------- .../scripts/tests/test_check_probe_junit.py | 160 --------------- .../scripts/tests/test_check_pytest_junit.py | 192 ------------------ .github/workflows/hipdnn-superbuild-ci.yml | 81 +------- .../descriptor-packaging/CMakeLists.txt | 8 + .../descriptor-packaging/README.md | 29 ++- .../cmake/HkpPackagingProbes.cmake | 25 ++- .../tests/test_hkp_pack_parallel.py | 6 +- projects/hipdnn/tools/CMakeLists.txt | 74 +++---- .../hipdnn/tools/IngestorGenerator/README.md | 17 +- .../tools/IngestorGenerator/pyproject.toml | 1 + .../tests/test_coverage_gate.py | 11 +- .../tests/test_dispatch_parity.py | 5 +- .../tests/test_native_stub.py | 7 +- .../tests/test_strict_skips_plugin.py | 115 +++++++++++ .../tests/test_sweep_tools.py | 31 +-- .../tests/test_variant_reachability.py | 3 + .../ai/skills/hipdnn-superbuild-test/SKILL.md | 13 +- .../pytest_plugins/hipdnn_strict_skips.py | 82 ++++++++ .../tools/tooling-allowed-skips-windows.txt | 17 -- .../hipdnn/tools/tooling-allowed-skips.txt | 18 -- 22 files changed, 331 insertions(+), 886 deletions(-) delete mode 100755 .github/scripts/check_probe_junit.py delete mode 100644 .github/scripts/check_pytest_junit.py delete mode 100644 .github/scripts/tests/test_check_probe_junit.py delete mode 100644 .github/scripts/tests/test_check_pytest_junit.py create mode 100644 projects/hipdnn/tools/IngestorGenerator/tests/test_strict_skips_plugin.py create mode 100644 projects/hipdnn/tools/pytest_plugins/hipdnn_strict_skips.py delete mode 100644 projects/hipdnn/tools/tooling-allowed-skips-windows.txt delete mode 100644 projects/hipdnn/tools/tooling-allowed-skips.txt diff --git a/.github/scripts/check_probe_junit.py b/.github/scripts/check_probe_junit.py deleted file mode 100755 index f8f4b379715f..000000000000 --- a/.github/scripts/check_probe_junit.py +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) Advanced Micro Devices, Inc., or its affiliates. -# SPDX-License-Identifier: MIT - -"""Prove that every declared hipDNN packaging probe ran and passed. - -The superbuild lanes run the whole ctest suite once with --output-junit. ctest -exits 0 when a test is disabled or not run, so this script reads that junit file -and checks it against the manifest the CMake configure wrote (one ctest test -name per line). Testcases not named in the manifest are ignored. - -The manifest always names the probe tooling test "hkp-probe-tools", plus one entry -per declared probe. A lane whose GPU_TARGETS cover every architecture the -descriptors name declares no probe, so "hkp-probe-tools" alone is a valid manifest; -an empty one means the configure did not write it properly. - -The manifest is written by the same configure that discovers the probes, so on its -own it cannot tell "this lane builds every architecture" from "discovery found -nothing". A lane that must always declare probes passes --min-probes N: the check -fails when the manifest names fewer than N tests besides "hkp-probe-tools". The -default is 0, which accepts a manifest with no probe. - -For every manifest name, the junit must hold exactly one testcase with that -name, and that testcase must have run (status "run" or "fail"; without a status -attribute, no child) and must have no or child. - -Usage: - check_probe_junit.py --junit build/ctest-junit.xml \ - --manifest build/hkp-probes/manifest.txt [--min-probes N] - -Exit codes: - 0 every manifest name ran exactly once and passed - 1 one or more checks failed; each prints - "check_probe_junit: FAIL : ", where is one of - manifest-unreadable, manifest-too-small, probes-too-few, junit-unreadable, - name-missing, name-duplicate, not-run, failure, error -""" - -import argparse -import sys -import xml.etree.ElementTree as ET -from pathlib import Path - -# The "hkp-probe-tools" test; probes are optional unless --min-probes asks for them. -MIN_MANIFEST_ENTRIES = 1 -TOOLS_TEST = "hkp-probe-tools" - - -def read_manifest(path: Path) -> list[str]: - return [line.strip() for line in path.read_text().splitlines() if line.strip()] - - -def ran(case: ET.Element) -> bool: - """Return whether ctest ran the testcase. - - ctest writes status "run", "fail", "disabled" or "notrun"; only the first - two mean the test executed. - """ - status = case.get("status") - if status is None: - return case.find("skipped") is None - return status in ("run", "fail") - - -def check( - junit_path: Path, manifest_path: Path, min_probes: int = 0 -) -> list[tuple[str, str]]: - """Return (id, detail) for every failed check; empty means green.""" - try: - names = read_manifest(manifest_path) - except OSError as exc: - return [("manifest-unreadable", f"{manifest_path}: {exc}")] - - failures: list[tuple[str, str]] = [] - if len(names) < MIN_MANIFEST_ENTRIES: - failures.append( - ( - "manifest-too-small", - f"{manifest_path} lists {len(names)} test(s), expected at least " - f"{MIN_MANIFEST_ENTRIES} (hkp-probe-tools)", - ) - ) - - probes = [name for name in names if name != TOOLS_TEST] - if len(probes) < min_probes: - failures.append( - ( - "probes-too-few", - f"{manifest_path} declares {len(probes)} probe(s), expected at " - f"least {min_probes}; if this lane now builds every architecture " - f"the descriptors name, drop --min-probes for it, otherwise probe " - f"discovery found nothing", - ) - ) - - try: - root = ET.parse(junit_path).getroot() - except (OSError, ET.ParseError) as exc: - failures.append(("junit-unreadable", f"{junit_path}: {exc}")) - return failures - - cases: dict[str, list[ET.Element]] = {name: [] for name in names} - for case in root.iter("testcase"): - found = cases.get(case.get("name", "")) - if found is not None: - found.append(case) - - for name, found in cases.items(): - if not found: - failures.append( - ("name-missing", f"declared test '{name}' is not in the junit") - ) - continue - if len(found) > 1: - failures.append( - ( - "name-duplicate", - f"declared test '{name}' appears {len(found)} times in the junit", - ) - ) - for case in found: - if not ran(case): - status = case.get("status") - failures.append( - ("not-run", f"test '{name}' did not run (status={status})") - ) - if case.get("status") == "fail" or case.find("failure") is not None: - failures.append(("failure", f"test '{name}' failed")) - if case.find("error") is not None: - failures.append(("error", f"test '{name}' errored")) - - return failures - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument( - "--junit", required=True, type=Path, help="ctest --output-junit file" - ) - parser.add_argument( - "--manifest", - required=True, - type=Path, - help="file listing one declared ctest test name per line", - ) - parser.add_argument( - "--min-probes", - type=int, - default=0, - help="fail when the manifest names fewer probes than this, not counting " - f"{TOOLS_TEST} (default 0: no probe is acceptable)", - ) - args = parser.parse_args(argv) - - failures = check(args.junit, args.manifest, args.min_probes) - for check_id, detail in failures: - print(f"check_probe_junit: FAIL {check_id}: {detail}") - if failures: - return 1 - count = len(read_manifest(args.manifest)) - print(f"check_probe_junit: OK {count} declared test(s) ran and passed") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/scripts/check_pytest_junit.py b/.github/scripts/check_pytest_junit.py deleted file mode 100644 index 0a0d6a0d7317..000000000000 --- a/.github/scripts/check_pytest_junit.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) Advanced Micro Devices, Inc., or its affiliates. -# SPDX-License-Identifier: MIT -"""Fail CI when a pytest suite skipped a test its allow-list does not name. - -ctest reports a pytest invocation as passed even when every case inside it skipped, -so a gate that skips for want of an input (a compiler, a binary, an environment -variable) passes silently. The pytest junit file is the only place those skips are -visible. This reads one or more of them and fails on: - - unexpected-skip a skipped (or xfailed) testcase no allow-list pattern matches - stale-allow an allow-list pattern that matched no skipped testcase, so the - entry outlived the skip it excused - junit-unreadable a junit file that is missing or is not valid XML - junit-empty a junit file with no testcase, i.e. the suite collected nothing - allow-list-unreadable the allow-list file cannot be read - allow-list-malformed an allow-list line that is not `` # `` - -Allow-list format: blank lines and lines starting with ``#`` are ignored; every other -line is `` # ``, split on the first ``#``, both parts non-empty. The -pattern is an fnmatch glob over ``::::``. Several -allow-lists may be given; their patterns are pooled, so a lane can add its own entries to -the shared ones. - -Usage: - check_pytest_junit.py --junit build/tooling-junit/.xml \\ - --allow-skips projects/hipdnn/tools/tooling-allowed-skips.txt - -Exit codes: - 0 no skipped testcase outside the allow-lists, and every entry matched one - 1 one or more checks failed; each prints - "check_pytest_junit: FAIL : ", where is one of the - identifiers above -""" - -import argparse -import fnmatch -import sys -import xml.etree.ElementTree as ET -from pathlib import Path - -PREFIX = "check_pytest_junit" - - -def _parse_allow_list(allow_path: Path, failures: list[tuple[str, str]]) -> list[str]: - """Return the allow-list's patterns; append any problem to ``failures``.""" - try: - text = Path(allow_path).read_text(encoding="utf-8") - except OSError as exc: - failures.append(("allow-list-unreadable", f"{allow_path}: {exc}")) - return [] - patterns = [] - for number, raw in enumerate(text.splitlines(), start=1): - line = raw.strip() - if not line or line.startswith("#"): - continue - pattern, sep, reason = line.partition("#") - pattern, reason = pattern.strip(), reason.strip() - if not sep or not pattern or not reason: - failures.append( - ( - "allow-list-malformed", - f"{allow_path}:{number}: expected ' # ', got {line!r}", - ) - ) - continue - patterns.append(pattern) - return patterns - - -def check(junit_paths: list[Path], allow_paths: list[Path]) -> list[tuple[str, str]]: - """Return ``(kind, detail)`` for every problem; empty means the check passed.""" - failures = [] - patterns = [] - for allow_path in allow_paths: - patterns += _parse_allow_list(allow_path, failures) - matched = set() - for junit_path in map(Path, junit_paths): - try: - root = ET.parse(junit_path).getroot() - except (OSError, ET.ParseError) as exc: - failures.append(("junit-unreadable", f"{junit_path}: {exc}")) - continue - testcases = list(root.iter("testcase")) - if not testcases: - failures.append(("junit-empty", f"{junit_path}: no testcase recorded")) - continue - for case in testcases: - skipped = case.find("skipped") - if skipped is None: - continue - case_id = f"{junit_path.stem}::{case.get('classname', '')}::{case.get('name', '')}" - hits = [p for p in patterns if fnmatch.fnmatchcase(case_id, p)] - if hits: - matched.update(hits) - else: - message = skipped.get("message") or (skipped.text or "").strip() - failures.append(("unexpected-skip", f"{case_id}: {message}")) - for pattern in patterns: - if pattern not in matched: - failures.append(("stale-allow", f"{pattern}: matched no skipped testcase")) - return failures - - -def _count(junit_paths: list[Path]) -> tuple[int, int]: - """``(testcases, allow-listed skips)`` over the readable junit files.""" - testcases = skips = 0 - for junit_path in map(Path, junit_paths): - try: - root = ET.parse(junit_path).getroot() - except (OSError, ET.ParseError): - continue - cases = list(root.iter("testcase")) - testcases += len(cases) - skips += sum(1 for case in cases if case.find("skipped") is not None) - return testcases, skips - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) - parser.add_argument( - "--junit", - action="append", - required=True, - type=Path, - metavar="PATH", - help="pytest junit XML file; repeat for several suites", - ) - parser.add_argument( - "--allow-skips", - action="append", - required=True, - type=Path, - metavar="PATH", - help=( - "allow-list of ' # ' lines naming the skips to accept; " - "repeat to pool several" - ), - ) - args = parser.parse_args(argv) - - failures = check(args.junit, args.allow_skips) - if failures: - for kind, detail in failures: - print(f"{PREFIX}: FAIL {kind}: {detail}") - return 1 - testcases, skips = _count(args.junit) - print( - f"{PREFIX}: OK {testcases} testcase(s) in {len(args.junit)} junit file(s), " - f"{skips} allow-listed skip(s)" - ) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/scripts/tests/test_check_probe_junit.py b/.github/scripts/tests/test_check_probe_junit.py deleted file mode 100644 index 0cfa93080774..000000000000 --- a/.github/scripts/tests/test_check_probe_junit.py +++ /dev/null @@ -1,160 +0,0 @@ -# Copyright (c) Advanced Micro Devices, Inc., or its affiliates. -# SPDX-License-Identifier: MIT - -import os -import sys -import tempfile -import unittest -from pathlib import Path - -sys.path.insert(0, os.fspath(Path(__file__).parent.parent)) -import check_probe_junit as cpj - -MANIFEST = ["hkp-probe-gfx950", "hkp-probe-tools"] - - -def case_xml(name: str, child: str = "", status: str | None = None) -> str: - body = f"<{child}/>" if child else "" - if status is None: - status = {"skipped": "notrun", "failure": "fail"}.get(child, "run") - status_attr = f' status="{status}"' if status else "" - return ( - f'' - f"{body}" - ) - - -def junit_xml(cases: list[str]) -> str: - return ( - '' - f'{"".join(cases)}' - ) - - -class CheckProbeJunitTest(unittest.TestCase): - def setUp(self): - tmp = tempfile.TemporaryDirectory() - self.addCleanup(tmp.cleanup) - self.dir = Path(tmp.name) - - def run_check( - self, xml: str, manifest: list[str], min_probes: int = 0 - ) -> list[str]: - junit_path = self.dir / "ctest-junit.xml" - manifest_path = self.dir / "manifest.txt" - junit_path.write_text(xml) - manifest_path.write_text("".join(f"{n}\n" for n in manifest)) - return [ - check_id for check_id, _ in cpj.check(junit_path, manifest_path, min_probes) - ] - - def test_all_declared_tests_passed(self): - xml = junit_xml([case_xml(n) for n in MANIFEST]) - self.assertEqual(self.run_check(xml, MANIFEST), []) - - def test_skipped_test_fails(self): - xml = junit_xml([case_xml(MANIFEST[0]), case_xml(MANIFEST[1], "skipped")]) - self.assertEqual(self.run_check(xml, MANIFEST), ["not-run"]) - - def test_disabled_test_without_skipped_child_fails(self): - xml = junit_xml( - [case_xml(MANIFEST[0], status="disabled"), case_xml(MANIFEST[1])] - ) - self.assertEqual(self.run_check(xml, MANIFEST), ["not-run"]) - - def test_unknown_status_fails(self): - xml = junit_xml([case_xml(MANIFEST[0], status="bogus"), case_xml(MANIFEST[1])]) - self.assertEqual(self.run_check(xml, MANIFEST), ["not-run"]) - - def test_skipped_child_without_status_fails(self): - xml = junit_xml( - [case_xml(MANIFEST[0], "skipped", status=""), case_xml(MANIFEST[1])] - ) - self.assertEqual(self.run_check(xml, MANIFEST), ["not-run"]) - - def test_missing_status_without_skipped_child_passes(self): - xml = junit_xml([case_xml(n, status="") for n in MANIFEST]) - self.assertEqual(self.run_check(xml, MANIFEST), []) - - def test_unrelated_testcases_are_ignored(self): - xml = junit_xml( - [ - case_xml("hipdnn-unit-tests"), - case_xml(MANIFEST[0]), - case_xml("other-disabled", status="disabled"), - case_xml("other-failed", "failure"), - case_xml(MANIFEST[1]), - ] - ) - self.assertEqual(self.run_check(xml, MANIFEST), []) - - def test_duplicate_declared_name_fails(self): - xml = junit_xml([case_xml(n) for n in MANIFEST] + [case_xml(MANIFEST[0])]) - self.assertEqual(self.run_check(xml, MANIFEST), ["name-duplicate"]) - - def test_declared_name_missing_fails(self): - xml = junit_xml([case_xml(MANIFEST[0]), case_xml("hipdnn-unit-tests")]) - self.assertEqual(self.run_check(xml, MANIFEST), ["name-missing"]) - - def test_empty_manifest_fails(self): - xml = junit_xml([case_xml(n) for n in MANIFEST]) - self.assertIn("manifest-too-small", self.run_check(xml, [])) - - def test_manifest_without_probe_passes(self): - xml = junit_xml([case_xml("hkp-probe-tools")]) - self.assertEqual(self.run_check(xml, ["hkp-probe-tools"]), []) - - def test_failure_fails(self): - xml = junit_xml([case_xml(MANIFEST[0], "failure"), case_xml(MANIFEST[1])]) - self.assertEqual(self.run_check(xml, MANIFEST), ["failure"]) - - def test_fail_status_without_failure_child_fails(self): - xml = junit_xml([case_xml(MANIFEST[0], status="fail"), case_xml(MANIFEST[1])]) - self.assertEqual(self.run_check(xml, MANIFEST), ["failure"]) - - def test_error_fails(self): - xml = junit_xml([case_xml(MANIFEST[0]), case_xml(MANIFEST[1], "error")]) - self.assertEqual(self.run_check(xml, MANIFEST), ["error"]) - - def test_unparseable_junit_fails(self): - self.assertEqual(self.run_check(" str: - """One testcase. ``skip`` is None (ran), "skip" (a skip) or "xfail".""" - child = "" - if skip == "skip": - child = '' - elif skip == "xfail": - child = '' - return f'{child}' - - -def junit_xml(cases: list[str]) -> str: - return ( - '' - f'{"".join(cases)}' - ) - - -class CheckPytestJunitTest(unittest.TestCase): - def setUp(self): - tmp = tempfile.TemporaryDirectory() - self.addCleanup(tmp.cleanup) - self.dir = Path(tmp.name) - - def junit(self, cases: list[str], stem: str = "suite") -> Path: - path = self.dir / f"{stem}.xml" - path.write_text(junit_xml(cases)) - return path - - def allow(self, text: str, stem: str = "allow") -> Path: - path = self.dir / f"{stem}.txt" - path.write_text(text) - return path - - def ids(self, junits: list[Path], allows: list[Path]) -> list[str]: - return [kind for kind, _ in cpj.check(junits, allows)] - - def run_main(self, junits: list[Path], allows: list[Path]) -> tuple[int, str]: - argv = [] - for junit in junits: - argv += ["--junit", str(junit)] - for allow in allows: - argv += ["--allow-skips", str(allow)] - out = io.StringIO() - with contextlib.redirect_stdout(out): - rc = cpj.main(argv) - return rc, out.getvalue() - - def test_all_passing_with_an_empty_allow_list_is_clean(self): - junit = self.junit([case_xml("tests.a", "test_one")]) - self.assertEqual(self.ids([junit], [self.allow("# nothing\n")]), []) - - def test_an_unlisted_skip_fails(self): - junit = self.junit([case_xml("tests.a", "test_one", "skip")]) - self.assertEqual(self.ids([junit], [self.allow("")]), ["unexpected-skip"]) - - def test_an_unlisted_xfail_fails_like_a_skip(self): - junit = self.junit([case_xml("tests.a", "test_one", "xfail")]) - self.assertEqual(self.ids([junit], [self.allow("")]), ["unexpected-skip"]) - - def test_an_allow_listed_skip_passes(self): - junit = self.junit([case_xml("tests.a.TestReal", "test_x[p1]", "skip")]) - allow = self.allow("suite::tests.a.TestReal::* # needs input by design\n") - self.assertEqual(self.ids([junit], [allow]), []) - - def test_a_bracketed_id_is_matched_by_a_star_or_an_escaped_bracket(self): - """`[` opens a glob character class, so a literal `[p1]` matches nothing.""" - junit = self.junit([case_xml("tests.a", "test_x[p1]", "skip")]) - escaped = self.allow("suite::tests.a::test_x[[]p1] # escaped\n") - literal = self.allow("suite::tests.a::test_x[p1] # unescaped\n", "literal") - self.assertEqual(self.ids([junit], [escaped]), []) - self.assertEqual( - self.ids([junit], [literal]), ["unexpected-skip", "stale-allow"] - ) - - def test_a_pattern_that_matches_no_skip_is_stale(self): - junit = self.junit([case_xml("tests.a", "test_one")]) - allow = self.allow("suite::tests.gone::* # was skipped once\n") - self.assertEqual(self.ids([junit], [allow]), ["stale-allow"]) - - def test_a_pattern_is_scoped_to_its_junit_file(self): - junit = self.junit([case_xml("tests.a", "test_one", "skip")], stem="other") - allow = self.allow("suite::tests.a::* # wrong suite\n") - self.assertEqual(self.ids([junit], [allow]), ["unexpected-skip", "stale-allow"]) - - def test_a_missing_junit_is_unreadable(self): - allow = self.allow("") - self.assertEqual( - self.ids([self.dir / "absent.xml"], [allow]), ["junit-unreadable"] - ) - - def test_a_junit_that_is_not_xml_is_unreadable(self): - junit = self.dir / "suite.xml" - junit.write_text("not xml") - self.assertEqual(self.ids([junit], [self.allow("")]), ["junit-unreadable"]) - - def test_a_junit_without_testcases_is_empty(self): - self.assertEqual(self.ids([self.junit([])], [self.allow("")]), ["junit-empty"]) - - def test_an_allow_list_line_without_a_reason_is_malformed(self): - junit = self.junit([case_xml("tests.a", "test_one", "skip")]) - allow = self.allow("suite::tests.a::*\n") - self.assertEqual( - self.ids([junit], [allow]), ["allow-list-malformed", "unexpected-skip"] - ) - - def test_a_missing_allow_list_is_unreadable(self): - junit = self.junit([case_xml("tests.a", "test_one")]) - self.assertEqual( - self.ids([junit], [self.dir / "absent.txt"]), ["allow-list-unreadable"] - ) - - def test_every_junit_file_is_checked(self): - clean = self.junit([case_xml("tests.t", "test_one")], stem="a") - skipping = self.junit([case_xml("tests.t", "test_two", "skip")], stem="b") - failures = cpj.check([clean, skipping], [self.allow("")]) - self.assertEqual([kind for kind, _ in failures], ["unexpected-skip"]) - self.assertIn("b::tests.t::test_two", failures[0][1]) - self.assertIn("no input", failures[0][1]) - - def test_several_allow_lists_are_pooled(self): - junit = self.junit( - [ - case_xml("tests.a", "test_one", "skip"), - case_xml("tests.b", "test_two", "skip"), - ] - ) - shared = self.allow("suite::tests.a::* # shared skip\n", "shared") - lane = self.allow("suite::tests.b::* # lane-only skip\n", "lane") - self.assertEqual(self.ids([junit], [shared, lane]), []) - - def test_a_lane_entry_matching_nothing_is_stale_even_when_the_shared_list_matches( - self, - ): - junit = self.junit([case_xml("tests.a", "test_one", "skip")]) - shared = self.allow("suite::tests.a::* # shared skip\n", "shared") - lane = self.allow("suite::tests.gone::* # no longer skipped\n", "lane") - self.assertEqual(self.ids([junit], [shared, lane]), ["stale-allow"]) - - def test_main_reports_ok_with_exit_zero(self): - junit = self.junit([case_xml("tests.a", "test_one")]) - rc, out = self.run_main([junit], [self.allow("")]) - self.assertEqual(rc, 0) - self.assertTrue(out.startswith("check_pytest_junit: OK "), out) - - def test_main_reports_the_counts_it_checked(self): - """The OK line is what a CI log shows, so its numbers have to be right: they - sum over every junit file.""" - first = self.junit( - [case_xml("tests.a", "test_one"), case_xml("tests.a", "test_two", "skip")], - stem="a", - ) - second = self.junit([case_xml("tests.b", "test_three", "skip")], stem="b") - allow = self.allow("a::tests.a::* # first\nb::tests.b::* # second\n") - rc, out = self.run_main([first, second], [allow]) - self.assertEqual(rc, 0) - match = re.search( - r"OK (\d+) testcase\(s\) in (\d+) junit file\(s\), (\d+) allow-listed skip\(s\)", - out, - ) - self.assertIsNotNone(match, out) - self.assertEqual(tuple(map(int, match.groups())), (3, 2, 2)) - - def test_main_prints_one_fail_line_naming_the_failure_and_why(self): - junit = self.junit([case_xml("tests.a", "test_one", "skip")]) - rc, out = self.run_main([junit], [self.allow("")]) - self.assertEqual(rc, 1) - lines = out.splitlines() - self.assertEqual(len(lines), 1, out) - self.assertTrue( - lines[0].startswith("check_pytest_junit: FAIL unexpected-skip:"), out - ) - self.assertIn("no input", lines[0]) - - -if __name__ == "__main__": - unittest.main() diff --git a/.github/workflows/hipdnn-superbuild-ci.yml b/.github/workflows/hipdnn-superbuild-ci.yml index a1bde517122d..8dec0c516602 100644 --- a/.github/workflows/hipdnn-superbuild-ci.yml +++ b/.github/workflows/hipdnn-superbuild-ci.yml @@ -12,7 +12,6 @@ on: paths: - '.github/workflows/hipdnn-superbuild-ci.yml' - '.github/requirements-hipdnn-superbuild-ci.txt' - - '.github/scripts/check_pytest_junit.py' - 'projects/hipdnn/**' - 'dnn-providers/**' schedule: @@ -203,10 +202,8 @@ jobs: echo "Fetched $count golden tensor blob(s)" # Must precede configure: configuration fails if pytest is not importable - # by the interpreter descriptor-packaging registers its tests under, or if - # pytest, PyYAML and Jinja2 are not importable by it for the hipDNN - # DescriptorGenerator/IngestorGenerator tooling suites. The interpreter is - # named explicitly because it must be the one passed as + # by the interpreter descriptor-packaging registers its tests under. The + # interpreter is named explicitly because it must be the one passed as # -DPython3_EXECUTABLE below -- a bare `pip` here would install into the # setup-python tool-cache interpreter instead. - name: Install Python dependencies @@ -232,7 +229,8 @@ jobs: -DFETCHCONTENT_SOURCE_DIR_TSL_ROBIN_MAP="$THIRD_PARTY_DEPS_DIR/robin-map-1.4.1" \ -DPython_EXECUTABLE="$VENV_PYTHON" \ -DPython3_EXECUTABLE="$VENV_PYTHON" \ - -DHIPKERNELPROVIDER_ENABLE_PACKAGING_PROBES=ON + -DHIPKERNELPROVIDER_ENABLE_PACKAGING_PROBES=ON \ + -DHIPKERNELPROVIDER_MIN_PACKAGING_PROBES=1 - name: Build run: cmake --build build @@ -251,36 +249,8 @@ jobs: - name: Add ROCm bin to PATH run: echo "$ROCM_SDK_PATH/bin" >> "$GITHUB_PATH" - # --output-junit is relative to --test-dir: this writes build/ctest-junit.xml. - name: Run tests - id: run-tests - run: ctest --test-dir build --output-on-failure --output-junit ctest-junit.xml - - # ctest counts a disabled or not-run test as success. Read the junit - # "Run tests" wrote and fail if a declared packaging probe (listed in the - # manifest configure wrote) did not run or failed. Runs whenever "Run - # tests" ran, pass or fail, so a probe failure is reported by name. - # --min-probes 1: this lane never builds every architecture the descriptors - # name, so a manifest with no probe means discovery broke. Drop the flag if - # the lane starts building them all. - - name: Check packaging probes ran - if: ${{ !cancelled() && steps.run-tests.outcome != 'skipped' }} - run: | - "$VENV_PYTHON" .github/scripts/check_probe_junit.py \ - --junit build/ctest-junit.xml \ - --manifest build/hkp-probes/manifest.txt \ - --min-probes 1 - - # See the Linux job's step of the same name. The Windows list adds the skips the - # POSIX-only sweep tooling makes here. - - name: Check tooling tests ran without unexpected skips - if: ${{ !cancelled() && steps.run-tests.outcome != 'skipped' }} - run: | - "$VENV_PYTHON" .github/scripts/check_pytest_junit.py \ - --junit build/tooling-junit/hipdnn_descriptor_generator_pytest.xml \ - --junit build/tooling-junit/hipdnn_ingestor_generator_pytest.xml \ - --allow-skips projects/hipdnn/tools/tooling-allowed-skips.txt \ - --allow-skips projects/hipdnn/tools/tooling-allowed-skips-windows.txt + run: ctest --test-dir build --output-on-failure --no-tests=error - name: Patch ROCm SDK with superbuild outputs shell: pwsh @@ -370,7 +340,7 @@ jobs: - name: Install build tools run: | sudo apt-get update - sudo apt-get install -y ninja-build ccache g++ + sudo apt-get install -y ninja-build ccache # clang-tidy-20 comes from Ubuntu's noble-updates archive rather than # apt.llvm.org, whose intermittent outages failed this step. sudo apt-get install -y clang-tidy-20 clang-tools-20 @@ -476,10 +446,8 @@ jobs: echo "Fetched $count golden tensor blob(s)" # Must precede configure: configuration fails if pytest is not importable - # by the interpreter descriptor-packaging registers its tests under, or if - # pytest, PyYAML and Jinja2 are not importable by it for the hipDNN - # DescriptorGenerator/IngestorGenerator tooling suites. The interpreter is - # named explicitly because it must be the one passed as + # by the interpreter descriptor-packaging registers its tests under. The + # interpreter is named explicitly because it must be the one passed as # -DPython3_EXECUTABLE below -- a bare `pip` here would install into the # setup-python tool-cache interpreter instead. - name: Install Python dependencies @@ -508,7 +476,8 @@ jobs: -DFETCHCONTENT_SOURCE_DIR_TSL_ROBIN_MAP="$THIRD_PARTY_DEPS_DIR/robin-map-1.4.1" \ -DPython_EXECUTABLE="$VENV_PYTHON" \ -DPython3_EXECUTABLE="$VENV_PYTHON" \ - -DHIPKERNELPROVIDER_ENABLE_PACKAGING_PROBES=ON + -DHIPKERNELPROVIDER_ENABLE_PACKAGING_PROBES=ON \ + -DHIPKERNELPROVIDER_MIN_PACKAGING_PROBES=1 - name: Build run: cmake --build build @@ -523,39 +492,11 @@ jobs: test "${#kpacks[@]}" -gt 0 || { echo "no .kpack archives produced" >&2; exit 1; } printf '%s\n' "${kpacks[@]}" - # --output-junit is relative to --test-dir: this writes build/ctest-junit.xml. - name: Run tests - id: run-tests env: # Native rocKE AUTO detection must see the wheel COMGR used by packaging. ROCKE_COMGR_LIB: ${{ env.HKP_ROCKE_COMGR_LIB }} - run: ctest --test-dir build --output-on-failure --output-junit ctest-junit.xml - - # ctest counts a disabled or not-run test as success. Read the junit - # "Run tests" wrote and fail if a declared packaging probe (listed in the - # manifest configure wrote) did not run or failed. Runs whenever "Run - # tests" ran, pass or fail, so a probe failure is reported by name. - # --min-probes 1: this lane never builds every architecture the descriptors - # name, so a manifest with no probe means discovery broke. Drop the flag if - # the lane starts building them all. - - name: Check packaging probes ran - if: ${{ !cancelled() && steps.run-tests.outcome != 'skipped' }} - run: | - "$VENV_PYTHON" .github/scripts/check_probe_junit.py \ - --junit build/ctest-junit.xml \ - --manifest build/hkp-probes/manifest.txt \ - --min-probes 1 - - # The tooling suites run inside the ctest step above as two ctest tests, which pass - # even when every pytest case inside them skips. Their pytest junit is the only - # place a skip is visible; fail on any skip the allow-list does not name. - - name: Check tooling tests ran without unexpected skips - if: ${{ !cancelled() && steps.run-tests.outcome != 'skipped' }} - run: | - "$VENV_PYTHON" .github/scripts/check_pytest_junit.py \ - --junit build/tooling-junit/hipdnn_descriptor_generator_pytest.xml \ - --junit build/tooling-junit/hipdnn_ingestor_generator_pytest.xml \ - --allow-skips projects/hipdnn/tools/tooling-allowed-skips.txt + run: ctest --test-dir build --output-on-failure --no-tests=error # Golden-data validation used to live here as a schedule-gated re-run of the # external integration targets with HIPDNN_TEST_VERIFICATION_MODE=golden-check. diff --git a/dnn-providers/hip-kernel-provider/descriptor-packaging/CMakeLists.txt b/dnn-providers/hip-kernel-provider/descriptor-packaging/CMakeLists.txt index ffcc570dd840..9f67c9766e67 100644 --- a/dnn-providers/hip-kernel-provider/descriptor-packaging/CMakeLists.txt +++ b/dnn-providers/hip-kernel-provider/descriptor-packaging/CMakeLists.txt @@ -8,5 +8,13 @@ include(HkpPackaging) option(HIPKERNELPROVIDER_ENABLE_PACKAGING_PROBES "CI-only, non-shipping: register packaging probes (build-tree ctest entries hkp-probe-*)" OFF) +set(HIPKERNELPROVIDER_MIN_PACKAGING_PROBES 0 CACHE STRING + "Fail configure when fewer than this many packaging probes are declared. A lane that never builds every architecture the descriptors name sets 1, so broken probe discovery cannot pass as a lane with nothing to probe") +if(HIPKERNELPROVIDER_MIN_PACKAGING_PROBES GREATER 0 AND NOT HIPKERNELPROVIDER_ENABLE_PACKAGING_PROBES) + message(FATAL_ERROR + "hkp: HIPKERNELPROVIDER_MIN_PACKAGING_PROBES=${HIPKERNELPROVIDER_MIN_PACKAGING_PROBES} " + "needs -DHIPKERNELPROVIDER_ENABLE_PACKAGING_PROBES=ON: with probes off none are " + "declared, so the minimum could never be met.") +endif() hkp_add_packaging() diff --git a/dnn-providers/hip-kernel-provider/descriptor-packaging/README.md b/dnn-providers/hip-kernel-provider/descriptor-packaging/README.md index 43302b311590..908d89fa494b 100644 --- a/dnn-providers/hip-kernel-provider/descriptor-packaging/README.md +++ b/dnn-providers/hip-kernel-provider/descriptor-packaging/README.md @@ -462,7 +462,8 @@ configure. (prebuilt `.co` files) and C++/runtime paths, and install staging. - Device behavior: no kernel is launched, no numerics are checked. - Wildcard descriptors on architectures no lane builds (see below for why none ship). -- That the CI job runs: that is the job of the junit check below. +- That the CI job runs: a lane that must always have a probe sets + `HIPKERNELPROVIDER_MIN_PACKAGING_PROBES` (below). ### Which architectures are probed @@ -482,9 +483,10 @@ hkp: probe gfx950: derived root /hkp-probes/gfx950/root (17 UKD(s) for gf A new architecture, or a new pack under one, is therefore probed with no edit anywhere. A lane whose `GPU_TARGETS` cover every named architecture declares no probe; that is valid, -and the manifest then lists `hkp-probe-tools` alone. A lane that must always have a probe -passes `--min-probes N` to `check_probe_junit.py`, which fails when the manifest names fewer -than N probes; without it, a tools-only manifest is accepted. +and only `hkp-probe-tools` is registered. A lane that must always have a probe configures +with `-DHIPKERNELPROVIDER_MIN_PACKAGING_PROBES=N`, which fails configure when fewer than N +probes are declared, so broken probe discovery cannot pass as a lane with nothing to probe; +the default, 0, accepts no probe. **Wildcards are excluded.** A descriptor with no `arch` (absent or empty) names no architecture and gets no automatic probe. That is safe: a wildcard ships for whatever the @@ -640,10 +642,7 @@ cmake --preset hipdnn-dev-all -GNinja -B build-probe \ -DENABLE_CLANG_FORMAT=OFF -DENABLE_CLANG_TIDY=OFF -DROCM_LIBS_ENABLE_ROOT_CTEST=ON \ -DHIPKERNELPROVIDER_ENABLE_PACKAGING_PROBES=ON cmake --build build-probe --target hkp_packaging_probes # or the full build -ctest --test-dir build-probe -R '^hkp-probe-' --no-tests=error --output-on-failure \ - --output-junit probe-junit.xml -python3 .github/scripts/check_probe_junit.py \ - --junit build-probe/probe-junit.xml --manifest build-probe/hkp-probes/manifest.txt +ctest --test-dir build-probe -R '^hkp-probe-' --no-tests=error --output-on-failure ``` The selector is `^hkp-probe-`, anchored: `-R` is an unanchored regex. It matches every @@ -651,18 +650,14 @@ probe (`hkp-probe-`) and `hkp-probe-tools`, the pytest suite of the probe (`probes/tests/`; the tests that pack rocKE content live in `probes/tests/rocke/` and nowhere else, which a build with `HIPKERNELPROVIDER_ENABLE_ROCKE=OFF` never collects). `--no-tests=error` makes an empty selection a failure instead of a -green run that proved nothing. `manifest.txt` is written at configure and lists the ctest -names that must run; the junit check fails unless every one ran and passed (a skipped, -disabled or missing entry fails), and ignores testcases not in the manifest, so it also -accepts the junit of a full ctest run. Configuring without the option and running the same +green run that proved nothing. Configuring without the option and running the same build and ctest lines fails (unknown target, then "No tests were found"). In CI both superbuild lanes configure with the option `ON`. The probe packs run in the Build step as part of `all`; the probe ctest entries run once, inside the lane's normal -"Run tests" ctest, which writes `build/ctest-junit.xml`. The "Check packaging probes ran" -step then runs only `check_probe_junit.py` on that junit against the manifest, with -`--min-probes 1` because neither lane builds every architecture the production root names -(drop the flag from a lane that does). It runs whenever "Run tests" ran, pass or fail. +"Run tests" ctest (`--no-tests=error`). Both lanes configure with +`HIPKERNELPROVIDER_MIN_PACKAGING_PROBES=1` because neither builds every architecture the +production root names (drop it from a lane that does). Locally on Linux the build is quick (the wheel environment plus the probed UKDs) and each probe ctest entry takes a few seconds; `hkp-probe-tools` takes longer, since it really @@ -716,7 +711,7 @@ does not build it, with the default sample; nothing needs declaring. archive rule). This is a design decision, not an entry. After adding an architecture or a probe line, reconfigure and run the local block above: -the new `hkp-probe-` entry and its manifest line must appear. Then run the +the new `hkp-probe-` ctest entry must appear. Then run the mutation checks below. ### Mutation checks when adding a probe diff --git a/dnn-providers/hip-kernel-provider/descriptor-packaging/cmake/HkpPackagingProbes.cmake b/dnn-providers/hip-kernel-provider/descriptor-packaging/cmake/HkpPackagingProbes.cmake index 38504ee76167..6d9f00062945 100644 --- a/dnn-providers/hip-kernel-provider/descriptor-packaging/cmake/HkpPackagingProbes.cmake +++ b/dnn-providers/hip-kernel-provider/descriptor-packaging/cmake/HkpPackagingProbes.cmake @@ -408,11 +408,11 @@ endfunction() # and UKDs (hkp_probe_derive_root.py --list-arches; wildcard descriptors name none, # generic targets map to a member, see _hkp_probe_automatic_arches) minus # hkp_selected_arches(), each as hkp_add_packaging_probe(ARCH ). Register -# the probe tooling tests, write the manifest of ctest names -# (${CMAKE_BINARY_DIR}/hkp-probes/manifest.txt) and define the aggregate target +# the probe tooling tests and define the aggregate target # hkp_packaging_probes. Zero probes is valid: every explicit arch is a build target, -# so the production pack covers it. Configuration fails when the production root -# cannot be read or pytest is not importable by Python3_EXECUTABLE. +# so the production pack covers it, unless HIPKERNELPROVIDER_MIN_PACKAGING_PROBES asks +# for more. Configuration fails when the production root cannot be read, pytest is not +# importable by Python3_EXECUTABLE, or fewer probes are declared than that minimum. # --------------------------------------------------------------------------- function(hkp_load_packaging_probes rocm_kpack_dir hipcc rocke_comgr_lib) set_property(GLOBAL PROPERTY HKP_PROBE_ROCM_KPACK_DIR "${rocm_kpack_dir}") @@ -439,6 +439,18 @@ function(hkp_load_packaging_probes rocm_kpack_dir hipcc rocke_comgr_lib) get_property(_names GLOBAL PROPERTY HKP_PROBE_NAMES) + # Broken discovery and a build with nothing to probe both declare zero probes; a lane that + # must always have one sets the minimum. + list(LENGTH _names _probe_count) + if(_probe_count LESS HIPKERNELPROVIDER_MIN_PACKAGING_PROBES) + message(FATAL_ERROR + "hkp: ${_probe_count} packaging probe(s) declared, but " + "HIPKERNELPROVIDER_MIN_PACKAGING_PROBES requires at least " + "${HIPKERNELPROVIDER_MIN_PACKAGING_PROBES}. If this build now targets every " + "architecture the descriptors name, lower the minimum; otherwise probe " + "discovery found nothing.") + endif() + execute_process( COMMAND "${Python3_EXECUTABLE}" -c "import pytest" RESULT_VARIABLE _pytest_rc @@ -472,15 +484,10 @@ function(hkp_load_packaging_probes rocm_kpack_dir hipcc rocke_comgr_lib) ${_rocke_ignore}) set_tests_properties(hkp-probe-tools PROPERTIES ENVIRONMENT "${_pyenv}") - set(_test_names hkp-probe-tools) set(_targets "") foreach(_name IN LISTS _names) - list(APPEND _test_names "hkp-probe-${_name}") list(APPEND _targets "hkp_packaging_probe_${_name}") endforeach() - list(SORT _test_names) - string(REPLACE ";" "\n" _manifest "${_test_names}") - file(WRITE "${CMAKE_BINARY_DIR}/hkp-probes/manifest.txt" "${_manifest}\n") add_custom_target(hkp_packaging_probes COMMENT "hkp: packaging probes") if(_targets) diff --git a/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/test_hkp_pack_parallel.py b/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/test_hkp_pack_parallel.py index 49d841a0db7d..e7476cf2bf93 100644 --- a/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/test_hkp_pack_parallel.py +++ b/dnn-providers/hip-kernel-provider/descriptor-packaging/tests/test_hkp_pack_parallel.py @@ -817,9 +817,9 @@ def test_worker_inherits_parent_sys_path(tmp_path, monkeypatch): pool must be built after parent-side path setup, is documented at the construction site. - The pool is pinned to `spawn` because the platform default is not one start - method: it is `fork` on Linux up to Python 3.13, where the child inherits - `sys.path` with the rest of the parent's memory and a check would prove nothing. + The pool is pinned to `spawn`: under `fork`, the Linux default up to Python 3.13, + the child inherits `sys.path` with the rest of the parent's memory and the check + would prove nothing. The path watched is one the test prepends itself, in a directory no `PYTHONPATH` carries, so it reaches the child only if the interpreter propagates it. """ diff --git a/projects/hipdnn/tools/CMakeLists.txt b/projects/hipdnn/tools/CMakeLists.txt index 0afc87d5a8de..4b5ecc46045d 100644 --- a/projects/hipdnn/tools/CMakeLists.txt +++ b/projects/hipdnn/tools/CMakeLists.txt @@ -121,13 +121,9 @@ endif() _apply_hipdnn_test_category_labels(hipdnn_list_engines_smoke) endif() -# DescriptorGenerator pytest suite, plus the junit directory both tooling suites write to. -# ctest reports a suite whose every case skips as a pass, so the pytest junit is the only place -# a skip is visible: .github/scripts/check_pytest_junit.py reads it and fails on any skip -# tooling-allowed-skips.txt in this directory does not name. -# Build tree only, like the smoke tests above. The option is OFF by default so TheRock and -# developer builds are unchanged; when ON, a missing prerequisite fails configure rather than -# silently skipping. +# DescriptorGenerator pytest suite, then the IngestorGenerator one below. Build tree only, like +# the smoke tests above. Both run under the hipdnn_strict_skips plugin: ctest reports a suite +# whose cases all skip as a pass, so a missing compiler or binary would otherwise go unnoticed. if(HIPDNN_ENABLE_TOOLING_TESTS AND HIPDNN_SKIP_TESTS) message(STATUS "hipDNN: HIPDNN_ENABLE_TOOLING_TESTS is ignored because HIPDNN_SKIP_TESTS is ON") endif() @@ -135,9 +131,6 @@ if(HIPDNN_ENABLE_TOOLING_TESTS AND NOT HIPDNN_SKIP_TESTS) if(NOT Python3_FOUND) message(FATAL_ERROR "hipDNN: HIPDNN_ENABLE_TOOLING_TESTS=ON needs a Python3 interpreter") endif() - # The IngestorGenerator suite is registered below only with the kernel ingestor, whose - # hipdnn_validate_descriptors tool its round-trip tests run. Without it the option would - # register one suite and leave the other silently absent. if(NOT HIPDNN_ENABLE_KERNEL_INGESTOR) message( FATAL_ERROR @@ -160,12 +153,16 @@ if(HIPDNN_ENABLE_TOOLING_TESTS AND NOT HIPDNN_SKIP_TESTS) "with -DHIPDNN_ENABLE_TOOLING_TESTS=OFF." ) endif() - set(HIPDNN_TOOLING_JUNIT_DIR "${CMAKE_BINARY_DIR}/tooling-junit") + set(HIPDNN_STRICT_SKIPS_PLUGIN_DIR "${CMAKE_CURRENT_SOURCE_DIR}/pytest_plugins") add_test( NAME hipdnn_descriptor_generator_pytest - COMMAND - "${Python3_EXECUTABLE}" -m pytest "${CMAKE_CURRENT_SOURCE_DIR}/DescriptorGenerator/tests" - -v "--junitxml=${HIPDNN_TOOLING_JUNIT_DIR}/hipdnn_descriptor_generator_pytest.xml" + COMMAND "${Python3_EXECUTABLE}" -m pytest -p hipdnn_strict_skips + "${CMAKE_CURRENT_SOURCE_DIR}/DescriptorGenerator/tests" -v + ) + set_tests_properties( + hipdnn_descriptor_generator_pytest + PROPERTIES ENVIRONMENT_MODIFICATION + "PYTHONPATH=path_list_prepend:${HIPDNN_STRICT_SKIPS_PLUGIN_DIR}" ) _apply_hipdnn_test_category_labels(hipdnn_descriptor_generator_pytest) endif() @@ -293,28 +290,22 @@ endif() _apply_hipdnn_test_category_labels(hipdnn_validate_descriptors_smoke) - # IngestorGenerator pytest suite. It runs here, not next to the DescriptorGenerator - # suite above, because two of its gates need this directory's tool target: - # * tests/test_round_trip.py runs the validator named by HIPDNN_VALIDATE_DESCRIPTORS. - # * tests/test_native_stub.py compiles generated stubs against the flatbuffers and - # HIP headers listed in the file named by HIPDNN_INGESTOR_STUB_INCLUDE_FILE. - # Without them those tests skip, and ctest reports a suite of skips as a pass. + # IngestorGenerator pytest suite. It is registered here, after the validator target: + # tests/test_round_trip.py runs that binary (HIPDNN_VALIDATE_DESCRIPTORS) and + # tests/test_native_stub.py needs the include list written below. if(HIPDNN_ENABLE_TOOLING_TESTS) add_test( NAME hipdnn_ingestor_generator_pytest - COMMAND - "${Python3_EXECUTABLE}" -m pytest - "${CMAKE_CURRENT_SOURCE_DIR}/IngestorGenerator/tests" -v - "--junitxml=${HIPDNN_TOOLING_JUNIT_DIR}/hipdnn_ingestor_generator_pytest.xml" + COMMAND "${Python3_EXECUTABLE}" -m pytest -p hipdnn_strict_skips + "${CMAKE_CURRENT_SOURCE_DIR}/IngestorGenerator/tests" -v ) set(_hipdnn_ingestor_pytest_env "HIPDNN_VALIDATE_DESCRIPTORS=$" ) - # test_native_stub.py compiles generated stubs against the flatbuffers and HIP - # headers listed in this file, one directory per line. - # TARGET_PROPERTY does not follow ALIAS targets, so resolve to the real target. + # Include directories for test_native_stub.py, one per line. TARGET_PROPERTY does not + # follow ALIAS targets, so resolve to the real target. set(_hipdnn_stub_include_exprs "") foreach(_stub_dep flatbuffers::flatbuffers hip::host hip::amdhip64) if(TARGET ${_stub_dep}) @@ -348,25 +339,22 @@ endif() "${_hipdnn_ingestor_pytest_env}" ) - # PATH prepends (Windows ASAN runtime / ROCm / build DLL dirs) go through - # ENVIRONMENT_MODIFICATION so the runtime PATH is extended rather than replaced. - set(_hipdnn_ingestor_pytest_env_mod "") + # PYTHONPATH makes the plugin named by -p importable. + set(_hipdnn_ingestor_pytest_env_mod + "PYTHONPATH=path_list_prepend:${HIPDNN_STRICT_SKIPS_PLUGIN_DIR}" + ) if(DEFINED TEST_ENVIRONMENT_MODIFICATION) list(APPEND _hipdnn_ingestor_pytest_env_mod ${TEST_ENVIRONMENT_MODIFICATION}) endif() - if(WIN32) - # test_native_stub.py looks for g++ or clang++ on PATH. The ROCm clang++ that - # builds this tree lives in lib/llvm/bin, which the lane does not put on PATH. - get_filename_component(_hipdnn_stub_cxx_dir "${CMAKE_CXX_COMPILER}" DIRECTORY) - list(APPEND _hipdnn_ingestor_pytest_env_mod - "PATH=path_list_prepend:${_hipdnn_stub_cxx_dir}") - endif() - if(_hipdnn_ingestor_pytest_env_mod) - set_tests_properties( - hipdnn_ingestor_generator_pytest - PROPERTIES ENVIRONMENT_MODIFICATION "${_hipdnn_ingestor_pytest_env_mod}" - ) - endif() + # Give test_native_stub.py the compiler that builds this tree; its lib/llvm/bin is not + # on the lanes' PATH. + get_filename_component(_hipdnn_stub_cxx_dir "${CMAKE_CXX_COMPILER}" DIRECTORY) + list(APPEND _hipdnn_ingestor_pytest_env_mod + "PATH=path_list_prepend:${_hipdnn_stub_cxx_dir}") + set_tests_properties( + hipdnn_ingestor_generator_pytest PROPERTIES ENVIRONMENT_MODIFICATION + "${_hipdnn_ingestor_pytest_env_mod}" + ) _apply_hipdnn_test_category_labels(hipdnn_ingestor_generator_pytest) endif() diff --git a/projects/hipdnn/tools/IngestorGenerator/README.md b/projects/hipdnn/tools/IngestorGenerator/README.md index 0047837e6f6d..64d09ffe8b17 100644 --- a/projects/hipdnn/tools/IngestorGenerator/README.md +++ b/projects/hipdnn/tools/IngestorGenerator/README.md @@ -602,10 +602,19 @@ matcher-test stubs with `g++`/`clang++`, plus an intentionally broken source con plugin/data/flatbuffers SDK and provider sources beside this checkout plus `flatbuffers/array.h` under `/opt/rocm/include`, and generates stand-ins for the CMake-configured `version.h`/`CacheRootDefaults.h` headers. Missing prerequisites skip -explicitly. Setting `HIPDNN_INGESTOR_STUB_INCLUDE_FILE` to a file of include directories -(one per line, containing `flatbuffers/array.h` and `hip/hip_runtime.h`) replaces the -`/opt/rocm/include` probe, and a bad value then fails rather than skips; the hipDNN -superbuild sets it under `HIPDNN_ENABLE_TOOLING_TESTS`. +explicitly, so a checkout with no compiler or ROCm headers still runs green. Setting +`HIPDNN_INGESTOR_STUB_INCLUDE_FILE` to a file of include directories (one per line, +containing `flatbuffers/array.h` and `hip/hip_runtime.h`) replaces the +`/opt/rocm/include` probe, and a bad value then fails rather than skips. + +Under `HIPDNN_ENABLE_TOOLING_TESTS` the hipDNN build runs this suite with the +`tools/pytest_plugins/hipdnn_strict_skips.py` plugin (`-p hipdnn_strict_skips`), which turns +every skip into a failure, so a missing compiler, validator binary, `hkp_pack` or +`rocm_kpack` fails CI instead of passing by not running. A skip that is correct by design +(an author-supplied input such as `HIPDNN_INGESTOR_SHAPES` or an authoring profile, or the +POSIX-only sweep tests on Windows) carries `@pytest.mark.allow_skip(reason="...")` beside the +skip. Run by hand, the plugin is not loaded: `python -m pytest -p hipdnn_strict_skips` (with +`tools/pytest_plugins` on `PYTHONPATH`) reproduces the CI behavior. ### Fragment contracts (`tests/test_fragment_contracts.py`) diff --git a/projects/hipdnn/tools/IngestorGenerator/pyproject.toml b/projects/hipdnn/tools/IngestorGenerator/pyproject.toml index 4964e97e02b5..2c6234b931d1 100644 --- a/projects/hipdnn/tools/IngestorGenerator/pyproject.toml +++ b/projects/hipdnn/tools/IngestorGenerator/pyproject.toml @@ -6,6 +6,7 @@ python_functions = "test_*" markers = [ "round_trip: exercises hipdnn_validate_descriptors; skipped unless HIPDNN_VALIDATE_DESCRIPTORS is set.", "needs_rocm_kpack: builds a real rocm-kpack archive; skipped unless rocm_kpack is importable or HIPKERNELPROVIDER_ROCM_KPACK_DIR is set.", + "allow_skip(reason): may skip when the hipDNN build runs the suite with tools/pytest_plugins/hipdnn_strict_skips.py, which fails every other skip.", ] [tool.coverage.run] diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_coverage_gate.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_coverage_gate.py index 8b6ba1510477..85ee6af0a143 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_coverage_gate.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_coverage_gate.py @@ -154,9 +154,14 @@ def _missing_prerequisite(validator: Path | None, profile: Path | None) -> str | return None -_needs_build = pytest.mark.skipif( - _missing_prerequisite(_VALIDATOR, _PROFILE) is not None, reason=_NEEDS_BUILD_REASON -) +_NEEDS_BUILD_MISSING = _missing_prerequisite(_VALIDATOR, _PROFILE) is not None + + +def _needs_build(obj): + """Skip `obj` without a build and a profile; allowed to skip in a strict run, because + the profile is an author's input this repo does not ship.""" + obj = pytest.mark.skipif(_NEEDS_BUILD_MISSING, reason=_NEEDS_BUILD_REASON)(obj) + return pytest.mark.allow_skip(reason=_NEEDS_BUILD_REASON)(obj) def _run(*args) -> subprocess.CompletedProcess: diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py index 3a4e75f84352..5a40fcbe3dc5 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_dispatch_parity.py @@ -210,9 +210,8 @@ def test_a_predicate_decline_is_not_promoted_to_an_abort(self, parity, capsys): assert "no shape resolved" in capsys.readouterr().err -#: A repository-relative provider root, as a profile would write it. Spelled out so the -#: decoy below can reproduce it exactly: under a cwd-relative resolution the decoy is -#: what binds. +#: A repository-relative provider root. Spelled out so the decoy below can reproduce it +#: exactly: under a cwd-relative resolution the decoy is what binds. _REPO_RELATIVE_ROOT = "dnn-providers/hip-kernel-provider" diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_native_stub.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_native_stub.py index 7c43c417966c..c3b20139fa57 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_native_stub.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_native_stub.py @@ -47,10 +47,9 @@ def compile_env(): "this checkout -- cannot attempt a real compile" ) - # Header roots for flatbuffers and HIP. A build that wires the gate sets - # HIPDNN_INGESTOR_STUB_INCLUDE_FILE to a file of include dirs (one per line, written by - # tools/CMakeLists.txt); a bad value fails rather than skips, so a miswired build cannot - # pass by skipping. Unset, probe an installed ROCm and skip when it is absent. + # HIPDNN_INGESTOR_STUB_INCLUDE_FILE (written by tools/CMakeLists.txt) lists the flatbuffers + # and HIP include dirs, one per line; a bad value fails rather than skips. Unset, probe an + # installed ROCm and skip when it is absent. raw = os.environ.get("HIPDNN_INGESTOR_STUB_INCLUDE_FILE") if raw is not None: include_file = Path(raw) diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_strict_skips_plugin.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_strict_skips_plugin.py new file mode 100644 index 000000000000..1366081e5dcb --- /dev/null +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_strict_skips_plugin.py @@ -0,0 +1,115 @@ +# Copyright (c) Advanced Micro Devices, Inc., or its affiliates. +# SPDX-License-Identifier: MIT + +"""The strict-skips plugin is what keeps CI from passing by not running a test. Each case +runs a throwaway suite in a child pytest, once without the plugin and once with it.""" + +import os +import re +import subprocess +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parents[2] / "pytest_plugins" + +SUITE = """ +import pytest + +def test_passes(): + pass + +def test_skips_for_a_missing_prerequisite(): + pytest.skip("no compiler") + +@pytest.mark.skipif(True, reason="no such platform") +def test_skipif_for_a_missing_prerequisite(): + pass + +@pytest.fixture +def fixture_that_skips(): + pytest.skip("no binary") + +def test_skip_raised_in_a_fixture(fixture_that_skips): + pass + +@pytest.mark.xfail(reason="known bug") +def test_xfail(): + assert False + +@pytest.mark.allow_skip(reason="author supplies the input") +def test_allowed_skip(): + pytest.skip("no input") + +@pytest.mark.allow_skip(reason="no such platform") +class TestAllowedClass: + @pytest.mark.skipif(True, reason="no such platform") + def test_member(self): + pass + +@pytest.mark.allow_skip +def test_allow_skip_without_a_reason(): + pytest.skip("no input") +""" + + +def _run(tmp_path, *extra): + (tmp_path / "test_suite.py").write_text(SUITE) + env = dict(os.environ, PYTHONPATH=str(PLUGIN_DIR)) + result = subprocess.run( + [sys.executable, "-m", "pytest", "-v", "-p", "no:cacheprovider", *extra], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + return { + match.group(1): match.group(2) + for match in re.finditer( + r"^test_suite\.py::(\S+) (PASSED|SKIPPED|FAILED|ERROR|XFAIL)", + result.stdout, + flags=re.MULTILINE, + ) + }, result.stdout + + +def test_without_the_plugin_every_skip_stays_a_skip(tmp_path): + outcomes, out = _run(tmp_path) + assert "FAILED" not in outcomes.values() and "ERROR" not in outcomes.values(), out + assert outcomes["test_skips_for_a_missing_prerequisite"] == "SKIPPED" + + +def test_with_the_plugin_a_skip_fails_unless_marked_allowed(tmp_path): + outcomes, out = _run(tmp_path, "-p", "hipdnn_strict_skips") + assert outcomes == { + "test_passes": "PASSED", + "test_skips_for_a_missing_prerequisite": "FAILED", + "test_skipif_for_a_missing_prerequisite": "ERROR", + "test_skip_raised_in_a_fixture": "ERROR", + "test_xfail": "FAILED", + "test_allowed_skip": "SKIPPED", + "TestAllowedClass::test_member": "SKIPPED", + "test_allow_skip_without_a_reason": "FAILED", + }, out + assert "skipped in a strict run: no compiler" in out + + +def test_a_module_level_skip_fails_the_collection_under_the_plugin(tmp_path): + (tmp_path / "test_ok.py").write_text("def test_ok():\n pass\n") + (tmp_path / "test_modskip.py").write_text( + "import pytest\npytest.importorskip('no_such_module_xyz')\n" + ) + env = dict(os.environ, PYTHONPATH=str(PLUGIN_DIR)) + + def run(*extra): + return subprocess.run( + [sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", *extra], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + + assert run().returncode == 0 + strict = run("-p", "hipdnn_strict_skips") + assert strict.returncode != 0, strict.stdout + assert "skipped while collecting test_modskip.py in a strict run" in strict.stdout diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_sweep_tools.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_sweep_tools.py index 7da7a6f5b359..de5a1435dad4 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_sweep_tools.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_sweep_tools.py @@ -201,24 +201,29 @@ def value(flag): #: Marks a case that drives a sweep far enough to execute a staged fixture. Narrower #: than the file: staging a fixture and refusing a config work anywhere, so the #: refusal, device-probe and field-audit cases stay live on every platform. -_needs_posix_exec = pytest.mark.skipif( - os.name != "posix", - reason=( - "driving a sweep to a result needs POSIX execution semantics: sweep.py " - "selects the benchmark interpreter from its shebang and runs the benchmark " - "directly. sweep.py targets an allocated device host." - ), +def _skip_off_posix(reason: str): + """Skip off POSIX, and stay allowed to skip in a strict run: the platform cannot run + the case, which no build can fix.""" + + def mark(obj): + obj = pytest.mark.skipif(os.name != "posix", reason=reason)(obj) + return pytest.mark.allow_skip(reason=reason)(obj) + + return mark + + +_needs_posix_exec = _skip_off_posix( + "driving a sweep to a result needs POSIX execution semantics: sweep.py " + "selects the benchmark interpreter from its shebang and runs the benchmark " + "directly. sweep.py targets an allocated device host." ) #: Marks a case that withdraws write access to drive an operational error. `chmod` #: does not deny directory writes on Windows, so the condition never holds there. -_needs_posix_permissions = pytest.mark.skipif( - os.name != "posix", - reason=( - "making a directory unwritable needs POSIX permission semantics: chmod does " - "not withdraw directory write access on Windows" - ), +_needs_posix_permissions = _skip_off_posix( + "making a directory unwritable needs POSIX permission semantics: chmod does " + "not withdraw directory write access on Windows" ) diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_variant_reachability.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_variant_reachability.py index db0fcdc48cd5..2087d01e0b52 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_variant_reachability.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_variant_reachability.py @@ -328,6 +328,9 @@ def test_a_provenance_root_for_an_unmarked_kdp_is_a_usage_error( assert "holds no hkp-packed.marker" in result.stderr +@pytest.mark.allow_skip( + reason="needs a mined request corpus via HIPDNN_INGESTOR_SHAPES; the repo ships none by design" +) class TestGfx950RealBundle: """The shipped gfx950 bundle against the shipped shape corpus. Needs no device and no build, and every expectation is derived from the bundle rather than written down, so diff --git a/projects/hipdnn/tools/ai/skills/hipdnn-superbuild-test/SKILL.md b/projects/hipdnn/tools/ai/skills/hipdnn-superbuild-test/SKILL.md index 74272444cb8b..51e944ba4864 100644 --- a/projects/hipdnn/tools/ai/skills/hipdnn-superbuild-test/SKILL.md +++ b/projects/hipdnn/tools/ai/skills/hipdnn-superbuild-test/SKILL.md @@ -130,14 +130,11 @@ architecture explicitly named under the production descriptor root that `GPU_TAR does not include (automatic; none when the build targets them all), `hkp-probe-`, one per named `hkp_add_packaging_probe()` line in `probes.cmake`, and `hkp-probe-tools`. A full ctest run includes them; to run them alone, select with the anchored `-R '^hkp-probe-'` and `--no-tests=error`. -`/hkp-probes/manifest.txt` lists the entries that must run. Check a ctest junit -(`--output-junit`) against it with `.github/scripts/check_probe_junit.py --junit ---manifest `, which fails on any listed entry missing, not run, failed or -errored and ignores the other testcases. - -In the superbuild CI lanes the probes run once, inside the "Run tests" ctest, which writes -`build/ctest-junit.xml`; the "Check packaging probes ran" step then runs only the junit -check, whenever "Run tests" ran, pass or fail. A pass does not cover device behavior, +`-DHIPKERNELPROVIDER_MIN_PACKAGING_PROBES=N` fails configure when fewer than N probes are +declared (default 0). + +In the superbuild CI lanes the probes run once, inside the "Run tests" ctest, with N=1. +A pass does not cover device behavior, install staging, UKDs the probe does not pack, or standalone-UKD references. ## Ingestor proof boundaries diff --git a/projects/hipdnn/tools/pytest_plugins/hipdnn_strict_skips.py b/projects/hipdnn/tools/pytest_plugins/hipdnn_strict_skips.py new file mode 100644 index 000000000000..93c1b0726efd --- /dev/null +++ b/projects/hipdnn/tools/pytest_plugins/hipdnn_strict_skips.py @@ -0,0 +1,82 @@ +# Copyright (c) Advanced Micro Devices, Inc., or its affiliates. +# SPDX-License-Identifier: MIT + +"""pytest plugin that turns a skipped test into a failure, so a build that should run a suite +cannot pass by silently not running part of it. + +ctest reports a pytest run as passed when every case skips, and a test that needs a compiler, +a built binary or an installed package skips without a word when it is missing. The hipDNN +build runs the DescriptorGenerator and IngestorGenerator suites with this plugin +(``-p hipdnn_strict_skips``, see tools/CMakeLists.txt). Run by hand it is not loaded, so those +tests skip as usual. + +A skip that is correct by design (an author-supplied input, a platform that cannot run the +test) opts out with ``@pytest.mark.allow_skip(reason="...")`` on the test or its class; the +reason is required. A skip raised while a module is imported (``pytest.importorskip(...)``, +``pytest.skip(allow_module_level=True)``) has no test to carry the marker, so it fails the +collection. +""" + +import pytest + +MARKER = "allow_skip" + + +def pytest_configure(config): + config.addinivalue_line( + "markers", + f"{MARKER}(reason): this test may skip under hipdnn_strict_skips, for the stated " + "reason (an author-supplied input, or a platform that cannot run it).", + ) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + report = outcome.get_result() + # xfail also reports as skipped, with `wasxfail` set; a strict run has no use for it. + if not report.skipped: + return + + if isinstance(report.longrepr, tuple): + reason = report.longrepr[2].removeprefix("Skipped: ") + else: + reason = getattr(report, "wasxfail", None) or str(report.longrepr) + + marker = item.get_closest_marker(MARKER) + if marker is not None: + if marker.kwargs.get("reason"): + return + report.outcome = "failed" + report.longrepr = ( + f"@pytest.mark.{MARKER} needs a reason: " + f'@pytest.mark.{MARKER}(reason="...") (the test skipped: {reason})' + ) + return + + report.outcome = "failed" + report.longrepr = ( + f"skipped in a strict run: {reason}\n" + "Provide the missing prerequisite. If the skip is by design, mark the test " + f'@pytest.mark.{MARKER}(reason="...").' + ) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_make_collect_report(collector): + outcome = yield + report = outcome.get_result() + if not report.skipped: + return + + reason = ( + report.longrepr[2].removeprefix("Skipped: ") + if isinstance(report.longrepr, tuple) + else str(report.longrepr) + ) + report.outcome = "failed" + report.longrepr = ( + f"skipped while collecting {collector.nodeid} in a strict run: {reason}\n" + "Provide the missing prerequisite. A module-level skip cannot be marked " + f"@pytest.mark.{MARKER}; skip inside the tests that need it instead." + ) diff --git a/projects/hipdnn/tools/tooling-allowed-skips-windows.txt b/projects/hipdnn/tools/tooling-allowed-skips-windows.txt deleted file mode 100644 index dd6a17208d11..000000000000 --- a/projects/hipdnn/tools/tooling-allowed-skips-windows.txt +++ /dev/null @@ -1,17 +0,0 @@ -# Skips the Windows superbuild lane accepts from the hipDNN tooling pytest suites, on top of -# the shared list in tooling-allowed-skips.txt. check_pytest_junit.py is given both -# files on this lane and only the shared one on Linux, so an entry here may not match a -# skip Linux makes, and the stale-allow check keeps it from outliving the skip it excuses. -# -# Format and rules are the shared file's: ` # `, every entry needs a reason. -# -# One entry per class that carries the POSIX-only markers in test_sweep_tools.py, not the -# whole module, so a new environment-gated skip elsewhere in it still fails the check. -hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.TestCorpusGraphIdentity::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang and runs it directly, which Windows cannot do -hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.TestCorrectnessEvidenceIsRequiredNotOptional::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang and runs it directly, which Windows cannot do -hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.TestEngineIdentity::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang and runs it directly, which Windows cannot do -hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.TestGatesFailIndependently::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang and runs it directly, which Windows cannot do -hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.TestPluginAttribution::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang and runs it directly, which Windows cannot do -hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.TestResume::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang and runs it directly, which Windows cannot do -hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.TestTheCLIsRunAsPrograms::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang and runs it directly, which Windows cannot do -hipdnn_ingestor_generator_pytest::tests.test_sweep_tools.TestTheDriverRefusesAnUnsafeConfig::* # sweep.py drives a benchmark on an allocated Linux device host: it selects the interpreter from the benchmark's shebang and runs it directly, which Windows cannot do; one case also withdraws directory write access with chmod, which Windows ignores diff --git a/projects/hipdnn/tools/tooling-allowed-skips.txt b/projects/hipdnn/tools/tooling-allowed-skips.txt deleted file mode 100644 index dfa737da8980..000000000000 --- a/projects/hipdnn/tools/tooling-allowed-skips.txt +++ /dev/null @@ -1,18 +0,0 @@ -# Skips the superbuild lanes accept from the hipDNN tooling pytest suites. -# -# Read by .github/scripts/check_pytest_junit.py in .github/workflows/hipdnn-superbuild-ci.yml. -# The Windows lane also reads tooling-allowed-skips-windows.txt, beside this file, for the skips -# only it makes. Both live next to the suites they excuse so that their owners maintain them. -# Each line is ` # `; every entry needs a reason. The pattern is an fnmatch -# glob over `::::`, e.g. -# `hipdnn_ingestor_generator_pytest::tests.test_round_trip::*`. The junit stem is the ctest -# test name. `[` starts a glob character class, so a parametrized id such as `test_x[p1]` is -# matched with `*` or written `test_x[[]p1]`. An entry that matches no skipped test fails the -# check, so the list only shrinks: the same rule as KNOWN_BAD in -# dnn-providers/hip-kernel-provider/rocke/TESTING.md and KNOWN_VIOLATIONS in -# dnn-providers/hip-kernel-provider/rocke/library/tests/test_library_layering.py. -# -# Add an entry only for an input the author supplies by design. A skip whose input the build -# can provide is a gate to close, not an entry to add. -hipdnn_ingestor_generator_pytest::tests.test_coverage_gate.TestAgainstTheRealBuild::* # needs an authoring profile matching the packed build; the repo ships none by design (test_coverage_gate.py _NEEDS_BUILD_REASON) -hipdnn_ingestor_generator_pytest::tests.test_variant_reachability.TestGfx950RealBundle::* # needs a mined request corpus via HIPDNN_INGESTOR_SHAPES; the repo ships none by design From 760d396622cf8a6f94ce6dfff8138bf1263047e9 Mon Sep 17 00:00:00 2001 From: Garby Gilbert Date: Mon, 19 Oct 2026 09:39:32 -0600 Subject: [PATCH 10/13] test(hipdnn): keep two coverage-gate tests that no lane can run out of the tooling suite TestAgainstTheRealBuild gates a build packed with the gfx950 attention engine and runs against any build directory that holds a validator and a packed tree. The superbuild lanes pack other descriptors, so the class fails on Linux, where the packed tree holds the example engines, and cannot find the packed tree on Windows, where the plugin tree lives under bin. The ingestor ctest entry deselects it rather than counting a skip, since no lane is expected to pack that engine. The coverage-gate test that serves a threshold the tool cannot check uses a shebang script as its stand-in validator, which Windows cannot execute directly, so it skips off POSIX and is allowed to skip under the strict-skips plugin. ALMIOPEN-2843 --- projects/hipdnn/tools/CMakeLists.txt | 8 ++++++-- .../tools/IngestorGenerator/tests/test_coverage_gate.py | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/projects/hipdnn/tools/CMakeLists.txt b/projects/hipdnn/tools/CMakeLists.txt index 4b5ecc46045d..5e09ec87e60d 100644 --- a/projects/hipdnn/tools/CMakeLists.txt +++ b/projects/hipdnn/tools/CMakeLists.txt @@ -294,10 +294,14 @@ endif() # tests/test_round_trip.py runs that binary (HIPDNN_VALIDATE_DESCRIPTORS) and # tests/test_native_stub.py needs the include list written below. if(HIPDNN_ENABLE_TOOLING_TESTS) + # TestAgainstTheRealBuild gates a build packed with the gfx950 attention engine; the + # lanes pack other descriptors, so it would fail there. add_test( NAME hipdnn_ingestor_generator_pytest - COMMAND "${Python3_EXECUTABLE}" -m pytest -p hipdnn_strict_skips - "${CMAKE_CURRENT_SOURCE_DIR}/IngestorGenerator/tests" -v + COMMAND + "${Python3_EXECUTABLE}" -m pytest -p hipdnn_strict_skips + "${CMAKE_CURRENT_SOURCE_DIR}/IngestorGenerator/tests" -v + --deselect=tests/test_coverage_gate.py::TestAgainstTheRealBuild ) set(_hipdnn_ingestor_pytest_env diff --git a/projects/hipdnn/tools/IngestorGenerator/tests/test_coverage_gate.py b/projects/hipdnn/tools/IngestorGenerator/tests/test_coverage_gate.py index b237e6f62f4e..de975003e34a 100644 --- a/projects/hipdnn/tools/IngestorGenerator/tests/test_coverage_gate.py +++ b/projects/hipdnn/tools/IngestorGenerator/tests/test_coverage_gate.py @@ -373,6 +373,13 @@ def test_serves_is_always_reported_as_owed_never_inferred(self, tmp_path): "reports another engine's work as this engine's" ) + @pytest.mark.skipif( + os.name != "posix", + reason="the stand-in validator is a shebang script, which Windows cannot execute", + ) + @pytest.mark.allow_skip( + reason="the stand-in validator is a shebang script, which Windows cannot execute" + ) def test_a_serving_threshold_this_tool_cannot_check_fails_the_gate(self, tmp_path): """Rungs 1 and 2 green, rung 3 not run: asking for served graphs must not pass on that evidence.""" From 14c67603bd7b68bf2950e2e4d75710cfbad575eb Mon Sep 17 00:00:00 2001 From: Garby Gilbert Date: Mon, 19 Oct 2026 13:07:43 -0600 Subject: [PATCH 11/13] ci(hipdnn): fetch the rocKE descriptors in the Linux superbuild lane test_registry_regeneration.py diffs the regenerated catalog against the shipped gfx950 attention KDP, which is DVC-managed. The Linux lane packs the example descriptors rather than the production tree, so nothing fetched it and both tests skipped. The tooling suite fails skips inside pytest, so the lane failed. Pull the descriptors tree the way the Windows lane does, and check that a pointer-backed KDP landed. ALMIOPEN-2843 --- .github/workflows/hipdnn-superbuild-ci.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/hipdnn-superbuild-ci.yml b/.github/workflows/hipdnn-superbuild-ci.yml index 8b67a3d946c0..b08299bc68d7 100644 --- a/.github/workflows/hipdnn-superbuild-ci.yml +++ b/.github/workflows/hipdnn-superbuild-ci.yml @@ -455,6 +455,21 @@ jobs: test "$count" -gt 0 || { echo "dvc pull produced no golden blobs" >&2; exit 1; } echo "Fetched $count golden tensor blob(s)" + # test_registry_regeneration.py regenerates the gfx950 attention catalog and + # diffs it against the shipped KDP. The rocKE KDPs are DVC-managed (each pointer + # names the `ingestor` remote), and this lane packs the example descriptors + # rather than the production tree, so nothing else fetches them. Without them + # both tests skip, and the tooling suite fails skips inside pytest. Same pull as + # the Windows lane, which packs the production tree. + - name: Fetch rocKE descriptors + run: | + set -euo pipefail + LOGNAME=github-runner dvc pull --recursive dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/descriptors + # A pull can exit 0 having fetched nothing; count what it should produce. + count=$(find dnn-providers/hip-kernel-provider/src/engines/kernel_ingestor_engine/descriptors -name '*.kdp.json' -path '*artifacts*' | wc -l) + test "$count" -gt 0 || { echo "dvc pull produced no rocKE KDPs" >&2; exit 1; } + echo "Fetched $count rocKE KDP(s)" + # Must precede configure: configuration fails if pytest is not importable # by the interpreter descriptor-packaging registers its tests under. The # interpreter is named explicitly because it must be the one passed as From 948d795c572450efd7ada71850ec74aa5772ea16 Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Wed, 21 Oct 2026 23:26:46 +0000 Subject: [PATCH 12/13] Fix indentation after merge --- projects/hipdnn/tools/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/hipdnn/tools/CMakeLists.txt b/projects/hipdnn/tools/CMakeLists.txt index 277cf029b5e2..7be479c91af4 100644 --- a/projects/hipdnn/tools/CMakeLists.txt +++ b/projects/hipdnn/tools/CMakeLists.txt @@ -398,7 +398,7 @@ endforeach() ) endif() - _apply_hipdnn_test_category_labels(${HIPDNN_VALIDATE_DESCRIPTORS_TESTS}) + _apply_hipdnn_test_category_labels(${HIPDNN_VALIDATE_DESCRIPTORS_TESTS}) # IngestorGenerator pytest suite. It is registered here, after the validator target: # tests/test_round_trip.py runs that binary (HIPDNN_VALIDATE_DESCRIPTORS) and From f8e969ddc9524f0a15f7d210024b8f009663168e Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Thu, 22 Oct 2026 00:41:58 +0000 Subject: [PATCH 13/13] fix(hipdnn): emit the ingestor NativeHooks include from the pack templates The live packs moved to hipdnn_plugin_sdk/ingestor/NativeHooks.hpp; the templates still emitted hipdnn_plugin_sdk/NativeRegistry.hpp, so the census regeneration and clang-format fixed-point tests failed once they ran in CI. --- projects/hipdnn/tools/IngestorGenerator/templates/native.cpp.j2 | 2 +- .../hipdnn/tools/IngestorGenerator/templates/test_packs.cpp.j2 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/hipdnn/tools/IngestorGenerator/templates/native.cpp.j2 b/projects/hipdnn/tools/IngestorGenerator/templates/native.cpp.j2 index 2ea82e8b51be..e26ed77b8af1 100644 --- a/projects/hipdnn/tools/IngestorGenerator/templates/native.cpp.j2 +++ b/projects/hipdnn/tools/IngestorGenerator/templates/native.cpp.j2 @@ -15,7 +15,7 @@ #include #include #include -#include +#include #include #include "core/Handle.hpp" diff --git a/projects/hipdnn/tools/IngestorGenerator/templates/test_packs.cpp.j2 b/projects/hipdnn/tools/IngestorGenerator/templates/test_packs.cpp.j2 index 238a7f353f8a..ccb47e9ce3c9 100644 --- a/projects/hipdnn/tools/IngestorGenerator/templates/test_packs.cpp.j2 +++ b/projects/hipdnn/tools/IngestorGenerator/templates/test_packs.cpp.j2 @@ -18,7 +18,7 @@ #include #include #include -#include +#include {% if config.engine.census_name_symbol %} #include "engines/kernel_ingestor_engine/IngestorPacks.hpp"