diff --git a/dnn-providers/hip-kernel-provider/rocke/platform/AGENTS.md b/dnn-providers/hip-kernel-provider/rocke/platform/AGENTS.md index 0dcf13ee6b88..f00284f03929 100644 --- a/dnn-providers/hip-kernel-provider/rocke/platform/AGENTS.md +++ b/dnn-providers/hip-kernel-provider/rocke/platform/AGENTS.md @@ -186,6 +186,8 @@ GPU node. - **Default arch is `gfx950`** (the byte-identity baseline). Do not change the codegen default; for on-GPU runs, prefer the local device via `rocke.runtime.hip_module.get_device_arch()` and fall back to `gfx950`. + *Note*: For non-`gfx950` targets, pass `target_architecture` and beware that some code + paths still silently fall back to `gfx950` on non-GPU systems. - **torch is optional, never import it from a library to gain a side effect.** numpy is the only hard dep. torch is needed only for the fusion frontend, torch-tensor launch, and on-GPU torch-eager numeric checks; gate it behind diff --git a/dnn-providers/hip-kernel-provider/rocke/platform/python/rocke/core/arch/__init__.py b/dnn-providers/hip-kernel-provider/rocke/platform/python/rocke/core/arch/__init__.py index 923ea103d882..19411d8c53f9 100644 --- a/dnn-providers/hip-kernel-provider/rocke/platform/python/rocke/core/arch/__init__.py +++ b/dnn-providers/hip-kernel-provider/rocke/platform/python/rocke/core/arch/__init__.py @@ -16,6 +16,7 @@ arch_from_isa, known_arches, normalize_dtype, + validate_arch, ) __all__ = [ @@ -28,4 +29,5 @@ "arch_from_isa", "known_arches", "normalize_dtype", + "validate_arch", ] diff --git a/dnn-providers/hip-kernel-provider/rocke/platform/python/rocke/core/arch/target.py b/dnn-providers/hip-kernel-provider/rocke/platform/python/rocke/core/arch/target.py index 55b77de469eb..bf502f43c215 100644 --- a/dnn-providers/hip-kernel-provider/rocke/platform/python/rocke/core/arch/target.py +++ b/dnn-providers/hip-kernel-provider/rocke/platform/python/rocke/core/arch/target.py @@ -1021,3 +1021,14 @@ def known_arches() -> Tuple[str, ...]: def arch_from_isa(isa: str) -> str: """Extract the gfx token from an isa triple like ``amdgcn-amd-amdhsa--gfx942``.""" return isa.rsplit("-", 1)[-1] if "-" in isa else isa + + +def validate_arch(arch: Optional[str]) -> None: + if arch is None: + raise ValueError( + "Could not detect a GPU architecture. Pass in an explicit architecture instead." + ) + if arch not in known_arches(): + raise ValueError( + f"Unknown GPU architecture detected. Known architectures include: {known_arches()}" + ) diff --git a/dnn-providers/hip-kernel-provider/rocke/platform/python/rocke/helpers/fusion_lowering.py b/dnn-providers/hip-kernel-provider/rocke/platform/python/rocke/helpers/fusion_lowering.py index 77e6f8c6bb6e..5c9e8dd41ccd 100644 --- a/dnn-providers/hip-kernel-provider/rocke/platform/python/rocke/helpers/fusion_lowering.py +++ b/dnn-providers/hip-kernel-provider/rocke/platform/python/rocke/helpers/fusion_lowering.py @@ -166,23 +166,21 @@ def _build_region_hsaco(kernel, *, explicit_arch: Optional[str] = None) -> bytes Fusion regions are built to be launched immediately on the local device, so this targets the active device's gfx (via :func:`rocke.runtime.hip_module.get_device_arch`) instead of defaulting - to a fixed architecture. Falls back to ``gfx950`` when no device/arch can - be detected (e.g. IR-only/no-GPU contexts), preserving the historical - default. Pass ``explicit_arch`` to override (reproducible builds). + to a fixed architecture. Raises an error when no device/arch can + be detected (e.g. IR-only/no-GPU contexts). Pass ``explicit_arch`` to override (reproducible builds). The comgr ISA always matches the resolved arch; the LLVM lowering uses the - matching ISA backend when the arch is one rocke models, else the gfx950 - backend (the device still gets a code object built for its own ISA). + matching ISA backend when the arch is one rocke models. """ - from ..core.arch import known_arches + from ..core.arch import validate_arch from ..core.lower_llvm import lower_kernel_to_llvm from ..runtime.comgr import build_hsaco_from_llvm_ir from ..runtime.hip_module import get_device_arch - arch = explicit_arch or get_device_arch() or "gfx950" + arch = explicit_arch or get_device_arch() + validate_arch(arch) isa = f"amdgcn-amd-amdhsa--{arch}" - lower_arch = arch if arch in known_arches() else "gfx950" - ir = lower_kernel_to_llvm(kernel, arch=lower_arch) + ir = lower_kernel_to_llvm(kernel, arch=arch) hsaco, _ = build_hsaco_from_llvm_ir(ir, isa=isa) return hsaco @@ -349,7 +347,7 @@ def candidates( ) from .fuse import dtype_to_ir - from ..core.arch import ArchTarget, known_arches + from ..core.arch import ArchTarget, validate_arch from ..instances.common.gemm_universal import is_valid_spec from ..runtime.hip_module import get_device_arch @@ -371,9 +369,9 @@ def candidates( # filtered through the arch-aware ``is_valid_spec`` so configs that don't # fit (e.g. an LDS tile too large for gfx942's 64 KB, or a non-WMMA-legal # tile/pipeline on gfx1151) are dropped instead of crashing comgr. - arch = get_device_arch() or "gfx950" - target_gfx = arch if arch in known_arches() else "gfx950" - target = ArchTarget.from_gfx(target_gfx) + arch = get_device_arch() + validate_arch(arch) + target = ArchTarget.from_gfx(arch) wmma = target.wave_size == 32 family = "wmma" if wmma else "mma" wave_size = target.wave_size @@ -433,7 +431,7 @@ def candidates( object.__setattr__(spec, "_fused_epilogue", epilogue) object.__setattr__(spec, "_bias_arg", bias_name) object.__setattr__(spec, "_residual_args", residual_names) - if not is_valid_spec(spec, arch=target_gfx)[0]: + if not is_valid_spec(spec, arch=arch)[0]: continue configs.append( AutotuneConfig( @@ -445,7 +443,7 @@ def candidates( return configs def build(self, config: AutotuneConfig) -> BuiltRegion: - from ..core.arch import ArchTarget, known_arches + from ..core.arch import ArchTarget, known_arches, validate_arch from ..instances.common.gemm_universal import build_universal_gemm from ..runtime.hip_module import get_device_arch from ..runtime.launcher import KernelLauncher @@ -463,15 +461,15 @@ def build(self, config: AutotuneConfig) -> BuiltRegion: # when it matches the spec's wave size (the common case); otherwise fall # back to the first known arch with the matching wave size so a wave32 # WMMA candidate still builds on a wave64 host (and vice versa). - dev = get_device_arch() or "gfx950" - build_gfx = dev if dev in known_arches() else "gfx950" - if ArchTarget.from_gfx(build_gfx).wave_size != spec.wave_size: + arch = get_device_arch() + validate_arch(arch) + if ArchTarget.from_gfx(arch).wave_size != spec.wave_size: for cand in known_arches(): if ArchTarget.from_gfx(cand).wave_size == spec.wave_size: - build_gfx = cand + arch = cand break - kernel = build_universal_gemm(spec, arch=build_gfx) - hsaco = _build_region_hsaco(kernel, explicit_arch=build_gfx) + kernel = build_universal_gemm(spec, arch=arch) + hsaco = _build_region_hsaco(kernel, explicit_arch=arch) ptr_ty = f"ptr<{ir_dtype.name}, global>" sig: List[Mapping[str, Any]] = [ {"name": "A", "type": ptr_ty, "size_bytes": 8}, diff --git a/dnn-providers/hip-kernel-provider/rocke/platform/tests/instances/test_rocke_multiarch.py b/dnn-providers/hip-kernel-provider/rocke/platform/tests/instances/test_rocke_multiarch.py index 90befd3fef63..7fad1963470b 100644 --- a/dnn-providers/hip-kernel-provider/rocke/platform/tests/instances/test_rocke_multiarch.py +++ b/dnn-providers/hip-kernel-provider/rocke/platform/tests/instances/test_rocke_multiarch.py @@ -16,7 +16,7 @@ import pathlib import unittest -from rocke.core.arch import ArchTarget, known_arches, normalize_dtype +from rocke.core.arch import ArchTarget, known_arches, normalize_dtype, validate_arch from rocke.core.isa import Gfx9MfmaBackend, Gfx950Backend, backend_for _ROCKE_ROOT = pathlib.Path(__file__).resolve().parents[2] / "python" / "rocke" @@ -113,6 +113,26 @@ def test_dtype_normalization(self): self.assertEqual(normalize_dtype("bf16"), "bf16") self.assertEqual(normalize_dtype("f32"), "fp32") + def test_validate_arch_none(self): + """`validate_arch` should raise ValueError when arch is None.""" + with self.assertRaises(ValueError) as ctx: + validate_arch(None) + self.assertIn("Could not detect", str(ctx.exception)) + self.assertIn("Pass in an explicit", str(ctx.exception)) + + def test_validate_arch_unknown(self): + """`validate_arch` should raise ValueError for unknown arch.""" + with self.assertRaises(ValueError) as ctx: + validate_arch("gfx9999") + self.assertIn("Unknown GPU architecture", str(ctx.exception)) + self.assertIn("Known architectures include", str(ctx.exception)) + + def test_validate_arch_valid(self): + """`validate_arch` should not raise for known arches.""" + for arch in known_arches(): + # Should not raise + validate_arch(arch) + class TestMmaCatalog(unittest.TestCase): def test_f16_atom_divergence(self): diff --git a/dnn-providers/hip-kernel-provider/rocke/platform/tests/test_rocke.py b/dnn-providers/hip-kernel-provider/rocke/platform/tests/test_rocke.py index a0e951ec97fe..4beed3675bc7 100644 --- a/dnn-providers/hip-kernel-provider/rocke/platform/tests/test_rocke.py +++ b/dnn-providers/hip-kernel-provider/rocke/platform/tests/test_rocke.py @@ -20,6 +20,7 @@ from __future__ import annotations import unittest +from unittest.mock import patch, Mock import pytest @@ -82,12 +83,6 @@ # A handful of tests below exercise torch-facing features (the torch.fx # fusion planner, torch-eager validation baselines) and are skipped when # torch is absent — torch-full CI lanes still run them. -# -# A separate handful drive the lowerer all the way through -# ``hipModuleLoadData``, which blocks indefinitely on a host with no ROCm -# GPU (no ``/dev/kfd``). Those are skipped when no GPU device node is -# present. The probe is deliberately torch-free (the point of this suite is -# torch-independence) and avoids issuing any HIP call that could itself hang. try: # torch is optional; gate torch-facing tests on its presence. import torch as _torch # noqa: F401 @@ -96,17 +91,7 @@ except Exception: # pragma: no cover - depends on the environment _HAVE_TORCH = False -import os as _os - -# A ROCm GPU exposes the kernel-fusion device node at /dev/kfd. Its absence -# means launches/module loads cannot succeed and would hang; skip then. -_HAVE_GPU = _os.path.exists("/dev/kfd") - _requires_torch = unittest.skipUnless(_HAVE_TORCH, "requires torch") -_requires_gpu = unittest.skipUnless( - _HAVE_GPU, "requires a ROCm GPU (no /dev/kfd device node present)" -) - # --------------------------------------------------------------------- # Core IR @@ -3080,14 +3065,47 @@ def fn(A): class TestLoweringRegistryBuild(unittest.TestCase): - """End-to-end ``can_lower`` + ``candidates`` + ``build`` smoke tests. + """Lowerer tests: candidate generation phase (GPU-agnostic). - These tests cover the path from a normalized fusion graph all the - way through HSACO build for each concrete lowerer. They do NOT - launch the kernels (no GPU); the goal is to confirm the lowerers - wire up a real launcher object on every supported region kind. + Tests that validate lowerers can recognize regions and generate + spec configurations without requiring GPU hardware. Architecture + is mocked to gfx950 so that tests can still run without a GPU. """ + @classmethod + def setUpClass(cls): + """Mock HIP runtime dependencies for in-process testing without GPU. + + These tests do not launch kernels or require a physical GPU. To achieve this, we mock: + + - ``get_device_arch``: Returns a fixed architecture string (gfx950) + so the compiler targets a known ISA without querying the actual + device via ``hipGetDeviceProperties``. + + - ``Runtime.load_module``: Bypasses ``hipModuleLoadData``. The + mock allows tests to validate that lowerers produce a well-formed + launcher object without requiring a GPU driver. + + Individual tests can override these mocks (via nested ``patch`` + context managers) to exercise error paths or architecture-specific + behavior. + """ + cls.arch_patcher = patch( + "rocke.runtime.hip_module.get_device_arch", return_value="gfx950" + ) + + cls.load_module_patcher = patch( + "rocke.runtime.hip_module.Runtime.load_module", return_value=Mock() + ) + cls.arch_patcher.start() + cls.load_module_patcher.start() + + @classmethod + def tearDownClass(cls): + """Clean up HIP runtime mocks after all tests complete.""" + cls.arch_patcher.stop() + cls.load_module_patcher.stop() + def _toy_gemm_graph(self, with_epilogue=True): from rocke.helpers import FusionOp, FusionTensor, build_graph @@ -3130,7 +3148,33 @@ def test_gemm_epilogue_can_lower_and_emits_candidates(self): for cfg in cfgs: self.assertTrue(hasattr(cfg.spec, "_fused_epilogue")) - @_requires_gpu + def test_gemm_epilogue_candidates_errors_on_invalid_arch(self): + from rocke.helpers import GemmEpilogueLowerer, GreedyFusionScheduler + + graph = self._toy_gemm_graph(with_epilogue=True) + plan = GreedyFusionScheduler().schedule(graph) + region = plan.regions[0] + lowerer = GemmEpilogueLowerer() + # Override the original class mock to test the error case when get_device_arch returns None + with patch("rocke.runtime.hip_module.get_device_arch", return_value=None): + with self.assertRaises(ValueError) as ctx: + lowerer.candidates(graph, region) + self.assertIn("Could not detect", str(ctx.exception)) + + def test_gemm_epilogue_build_errors_on_invalid_arch(self): + from rocke.helpers import GemmEpilogueLowerer, GreedyFusionScheduler + + graph = self._toy_gemm_graph(with_epilogue=True) + plan = GreedyFusionScheduler().schedule(graph) + region = plan.regions[0] + lowerer = GemmEpilogueLowerer() + cfgs = lowerer.candidates(graph, region) + # Override the original class mock to test the error case when get_device_arch returns None + with patch("rocke.runtime.hip_module.get_device_arch", return_value=None): + with self.assertRaises(ValueError) as ctx: + lowerer.build(cfgs[0]) + self.assertIn("Could not detect", str(ctx.exception)) + def test_gemm_epilogue_build_emits_kernel_launcher(self): from rocke.helpers import GemmEpilogueLowerer, GreedyFusionScheduler @@ -3146,7 +3190,6 @@ def test_gemm_epilogue_build_emits_kernel_launcher(self): self.assertGreater(built.block_size, 0) self.assertEqual(built.extra.get("bias"), "bias") - @_requires_gpu def test_elementwise_lowerer_round_trips(self): from rocke.helpers import ( ElementwiseLowerer, @@ -3181,7 +3224,6 @@ def test_elementwise_lowerer_round_trips(self): self.assertEqual(built.spec.op, "relu") self.assertEqual(built.spec.dtype, "f16") - @_requires_gpu def test_reduction_lowerer_round_trips(self): from rocke.helpers import ( FusionOp,