diff --git a/kernels/src/kernels/deps.py b/kernels/src/kernels/deps.py index 9486c3ed..5fd48063 100644 --- a/kernels/src/kernels/deps.py +++ b/kernels/src/kernels/deps.py @@ -1,23 +1,22 @@ from contextvars import ContextVar from dataclasses import dataclass from types import ModuleType -from typing import Generic, Protocol, TypeVar +from typing import TYPE_CHECKING, Generic, TypeVar from huggingface_hub.hf_api import HfApi -from kernels_data import KernelDependency, KernelVersion, Version -from packaging.version import InvalidVersion, parse +from kernels_data import KernelDependency, KernelVersion -from kernels.archs import _check_arch_incompatibility -from kernels.backends import _backend from kernels.hf_hub import RepoInfo from kernels.importer import _import_from_path -from kernels.python_deps import validate_dependencies from kernels.resolver import ( LocalKernel, RemoteKernel, Resolver, ) +if TYPE_CHECKING: + from kernels.validate import MetadataValidator + # Default state is `None`, to signal that we are not in a kernel # loading context. _KERNEL_DEPS: ContextVar[dict[str, ModuleType] | None] = ContextVar("_KERNEL_DEPS", default=None) @@ -71,107 +70,16 @@ def load(self) -> ModuleType: return _import_from_path(self.location.variant_path, repo_info=repo_info, deps=deps) - def validate( + def validate_metadata( self: "DepTreeNode[LocalKernel | RemoteKernel]", - validator: "Validator", + validator: "MetadataValidator", ) -> None: """Validate this kernel and its dependencies with the given validator.""" - validator.validate(tree=self) + validator.validate_metadata(metadata=self.location.metadata, variant=self.location.variant_str) for node in self.deps.values(): - node.validate(validator) - - -class Validator(Protocol): - """Validator for a node in a resolved kernel dependency tree.""" - - def validate(self, *, tree: DepTreeNode[LocalKernel | RemoteKernel]) -> None: ... - - -class DependencyValidator: - """Validate the Python dependencies of a kernel tree node.""" - - def validate(self, *, tree: DepTreeNode[LocalKernel | RemoteKernel]) -> None: - validate_dependencies( - tree.location.metadata.name.python_name, - tree.location.metadata.python_depends, - _backend(), - ) - - -class ArchValidator: - """Validate architecture compatibility for a kernel tree node.""" - - def validate(self, *, tree: DepTreeNode[LocalKernel | RemoteKernel]) -> None: - _check_arch_incompatibility(tree.location.metadata, tree.location.variant_str) - - -def _installed_version() -> Version | None: - """The installed `kernels` version as a numeric version. - - Pre-release and development suffixes are stripped, since kernel metadata - only records release versions. Without stripping, a development version - like `0.17.0.dev0` would compare as older than the `0.17.0` it implements. - - Returns `None` if the installed version is not a PEP 440 version, which - can happen for versions derived from a VCS checkout. Such a version cannot - be compared, and this check must never make a kernel fail to load. - """ - # Avoid an import cycle. - from kernels import __version__ - - try: - release = parse(__version__).release - except InvalidVersion: - return None - - # packaging < 22 returns a LegacyVersion with `release = None` for - # non-PEP 440 versions instead of raising `InvalidVersion`. - if release is None: - return None - - return Version.from_str(".".join(str(part) for part in release)) - - -class MinverValidator: - """Validate that the installed `kernels` library meets the minimum version - required by a kernel.""" - - def validate(self, *, tree: DepTreeNode[LocalKernel | RemoteKernel]) -> None: - metadata = tree.location.metadata - minver = metadata.kernels_minver - if minver is None: - return - - installed = _installed_version() - if installed is not None and installed < minver: - # Report the verbatim installed version, not the normalized one used - # for comparison, so the message matches what `pip show` reports. - # Avoid an import cycle. - from kernels import __version__ - - raise RuntimeError( - f"Kernel '{metadata.name}' variant '{tree.location.variant_str}' requires " - f"kernels>={minver}, but version {__version__} is installed. " - "Upgrade with: pip install --upgrade kernels" - ) - - -@dataclass -class AllValidator: - """Apply multiple validators to a kernel dependency tree.""" - - validators: list[Validator] - - def validate(self, *, tree: DepTreeNode[LocalKernel | RemoteKernel]) -> None: - for validator in self.validators: - validator.validate(tree=tree) - - -def default_validators() -> list[Validator]: - """The validators that are applied to every kernel dependency tree.""" - return [DependencyValidator(), MinverValidator()] + node.validate_metadata(validator) LoadCache = dict[KernelDependency, DepTreeNode] diff --git a/kernels/src/kernels/layer/func.py b/kernels/src/kernels/layer/func.py index dfd47c72..1d8d5333 100644 --- a/kernels/src/kernels/layer/func.py +++ b/kernels/src/kernels/layer/func.py @@ -10,7 +10,6 @@ from kernels.resolver import LockedHubCacheResolver, LockedHubResolver from .._versions import select_revision_or_version -from ..deps import AllValidator, default_validators from ..hf_hub import _get_hf_api from ..load import ( get_kernel, @@ -21,6 +20,7 @@ get_caller_locked_kernel_revision, get_locked_kernel_revision, ) +from ..validate import AllValidator, default_metadata_validators from .layer import _create_func_module, use_kernel_forward_from_hub from .repos import RepositoryProtocol @@ -332,7 +332,7 @@ def load(self) -> Type["nn.Module"]: backend=None, kernel=self.kernel_dep, resolver=resolver, - validator=AllValidator(validators=default_validators()), + metadata_validator=AllValidator(validators=default_metadata_validators()), ) return _get_kernel_func(self, kernel) diff --git a/kernels/src/kernels/layer/layer.py b/kernels/src/kernels/layer/layer.py index 86d13e8f..ee763376 100644 --- a/kernels/src/kernels/layer/layer.py +++ b/kernels/src/kernels/layer/layer.py @@ -15,7 +15,6 @@ from kernels.resolver import LockedHubCacheResolver, LockedHubResolver from .._versions import select_revision_or_version -from ..deps import AllValidator, default_validators from ..hf_hub import _get_hf_api from ..load import ( get_kernel, @@ -26,6 +25,7 @@ get_caller_locked_kernel_revision, get_locked_kernel_revision, ) +from ..validate import AllValidator, default_metadata_validators from .device import Device from .globals import _DISABLE_KERNEL_MAPPING, _KERNEL_MAPPING from .mode import Mode @@ -241,7 +241,7 @@ def load(self) -> Type["nn.Module"]: backend=None, kernel=self.kernel_dep, resolver=resolver, - validator=AllValidator(validators=default_validators()), + metadata_validator=AllValidator(validators=default_metadata_validators()), ) return _get_kernel_layer(self, kernel) diff --git a/kernels/src/kernels/load.py b/kernels/src/kernels/load.py index a6ed7193..0f3e9376 100644 --- a/kernels/src/kernels/load.py +++ b/kernels/src/kernels/load.py @@ -7,13 +7,7 @@ from kernels_data import KernelDependency, KernelVersion from kernels._versions import revision_or_version -from kernels.deps import ( - AllValidator, - ArchValidator, - Validator, - default_validators, - resolve_kernel_tree, -) +from kernels.deps import resolve_kernel_tree from kernels.hf_hub import _get_hf_api from kernels.locking import ( get_caller_locked_kernel_revision, @@ -29,6 +23,12 @@ Resolver, SequentialResolver, ) +from kernels.validate import ( + AllValidator, + ArchValidator, + MetadataValidator, + default_metadata_validators, +) def _get_local_kernel_overrides() -> Resolver: @@ -61,7 +61,7 @@ def get_kernel_with_resolver( backend: str | None, kernel: KernelDependency, resolver: Resolver | None, - validator: Validator, + metadata_validator: MetadataValidator, ) -> ModuleType: """ Load a kernel and its (transitive) dependencies using the given resolver. @@ -76,7 +76,7 @@ def get_kernel_with_resolver( The kernel to load. resolver (`Resolver`, *optional*): The resolver used to resolve the kernel and its (transitive) dependencies. - validator (`Validator`): + metadata_validator (`MetadataValidator`): The validator to apply to the resolved kernel dependency tree. Returns: @@ -88,7 +88,7 @@ def get_kernel_with_resolver( kernel=kernel, resolver=resolver, ) - tree.validate(validator) + tree.validate_metadata(metadata_validator) tree_only_local = tree.install(api=api) return tree_only_local.load() @@ -162,7 +162,7 @@ def get_kernel( ), ] - validators: list[Validator] = default_validators() + validators: list[MetadataValidator] = default_metadata_validators() if check_arch: validators.append(ArchValidator()) @@ -171,7 +171,7 @@ def get_kernel( backend=backend, kernel=KernelDependency(repo_id=repo_id, version=kernel_version), resolver=SequentialResolver(resolvers=resolvers), - validator=AllValidator(validators=validators), + metadata_validator=AllValidator(validators=validators), ) @@ -225,7 +225,7 @@ def get_local_kernel( # We don't have a name for the kernel, so let's just use the path. kernel=KernelDependency(repo_id=str(repo_path), version=KernelVersion.Version(0)), resolver=SequentialResolver(resolvers), - validator=AllValidator(validators=default_validators()), + metadata_validator=AllValidator(validators=default_metadata_validators()), ) @@ -295,7 +295,7 @@ def has_kernel( if check_arch: try: - tree.validate(ArchValidator()) + tree.validate_metadata(ArchValidator()) except RuntimeError: return False @@ -342,7 +342,7 @@ def load_kernel( backend=backend, kernel=kernel_dep, resolver=resolver, - validator=AllValidator(validators=default_validators()), + metadata_validator=AllValidator(validators=default_metadata_validators()), ) @@ -383,5 +383,5 @@ def get_locked_kernel( backend=None, kernel=kernel_dep, resolver=resolver, - validator=AllValidator(validators=default_validators()), + metadata_validator=AllValidator(validators=default_metadata_validators()), ) diff --git a/kernels/src/kernels/validate.py b/kernels/src/kernels/validate.py new file mode 100644 index 00000000..4d2f4f05 --- /dev/null +++ b/kernels/src/kernels/validate.py @@ -0,0 +1,99 @@ +from dataclasses import dataclass +from typing import Protocol + +from kernels_data import Metadata, Version +from packaging.version import InvalidVersion, parse + +from kernels.archs import _check_arch_incompatibility +from kernels.backends import _backend +from kernels.python_deps import validate_dependencies + + +class MetadataValidator(Protocol): + """Metadata validator for a kernel build variant.""" + + def validate_metadata(self, *, metadata: Metadata, variant: str) -> None: ... + + +class DependencyValidator: + """Validate the Python dependencies of a kernel build variant.""" + + def validate_metadata(self, *, metadata: Metadata, variant: str) -> None: + validate_dependencies( + metadata.name.python_name, + metadata.python_depends, + _backend(), + ) + + +class ArchValidator: + """Validate architecture compatibility for a kernel build variant.""" + + def validate_metadata(self, *, metadata: Metadata, variant: str) -> None: + _check_arch_incompatibility(metadata, variant) + + +def _installed_version() -> Version | None: + """The installed `kernels` version as a numeric version. + + Pre-release and development suffixes are stripped, since kernel metadata + only records release versions. Without stripping, a development version + like `0.17.0.dev0` would compare as older than the `0.17.0` it implements. + + Returns `None` if the installed version is not a PEP 440 version, which + can happen for versions derived from a VCS checkout. Such a version cannot + be compared, and this check must never make a kernel fail to load. + """ + # Avoid an import cycle. + from kernels import __version__ + + try: + release = parse(__version__).release + except InvalidVersion: + return None + + # packaging < 22 returns a LegacyVersion with `release = None` for + # non-PEP 440 versions instead of raising `InvalidVersion`. + if release is None: + return None + + return Version.from_str(".".join(str(part) for part in release)) + + +class MinverValidator: + """Validate that the installed `kernels` library meets the minimum version + required by a kernel.""" + + def validate_metadata(self, *, metadata: Metadata, variant: str) -> None: + minver = metadata.kernels_minver + if minver is None: + return + + installed = _installed_version() + if installed is not None and installed < minver: + # Report the verbatim installed version, not the normalized one used + # for comparison, so the message matches what `pip show` reports. + # Avoid an import cycle. + from kernels import __version__ + + raise RuntimeError( + f"Kernel '{metadata.name}' variant '{variant}' requires " + f"kernels>={minver}, but version {__version__} is installed. " + "Upgrade with: pip install --upgrade kernels" + ) + + +@dataclass +class AllValidator: + """Apply multiple validators to a kernel dependency tree.""" + + validators: list[MetadataValidator] + + def validate_metadata(self, *, metadata: Metadata, variant: str) -> None: + for validator in self.validators: + validator.validate_metadata(metadata=metadata, variant=variant) + + +def default_metadata_validators() -> list[MetadataValidator]: + """The metadata validators that are applied to every kernel dependency tree.""" + return [DependencyValidator(), MinverValidator()] diff --git a/kernels/tests/conftest.py b/kernels/tests/conftest.py index 513261ff..36807676 100644 --- a/kernels/tests/conftest.py +++ b/kernels/tests/conftest.py @@ -1,7 +1,9 @@ import importlib.util +import json import sys import pytest +from kernels_data import Metadata from kernels.backends import _get_torch_privateuse_backend_name @@ -61,6 +63,44 @@ def device(): return "cpu" +@pytest.fixture +def make_metadata(): + def _make_metadata(backend_type: str, archs: list[str] | None, kernels_minver: str | None = None) -> Metadata: + metadata = { + "name": "test-kernel", + "id": "test_id", + "version": 1, + "license": "mit", + "python-depends": [], + "backend": {"type": backend_type, "archs": archs}, + } + if kernels_minver is not None: + metadata["kernels-minver"] = kernels_minver + return Metadata.from_bytes(json.dumps(metadata).encode("utf-8")) + + return _make_metadata + + +@pytest.fixture +def fake_cuda_device(monkeypatch): + monkeypatch.setattr(torch.version, "cuda", "12.8", raising=False) + monkeypatch.setattr(torch.version, "hip", None, raising=False) + monkeypatch.setattr(torch.cuda, "is_available", lambda: True) + monkeypatch.setattr(torch.cuda, "get_device_capability", lambda device=None: (10, 0)) + + +@pytest.fixture +def fake_rocm_device(monkeypatch): + class FakeProperties: + gcnArchName = "gfx90a:sramecc+:xnack-" + + monkeypatch.setattr(torch.version, "cuda", None, raising=False) + monkeypatch.setattr(torch.version, "hip", "6.4.0", raising=False) + monkeypatch.setattr(torch.cuda, "is_available", lambda: True) + monkeypatch.setattr(torch.cuda, "current_device", lambda: 0) + monkeypatch.setattr(torch.cuda, "get_device_properties", lambda device: FakeProperties()) + + def pytest_runtest_setup(item): if "torch_only" in item.keywords and torch is None: pytest.skip("skipping CUDA Torch-only test on host without Torch") diff --git a/kernels/tests/test_archs.py b/kernels/tests/test_archs.py index 6d8a04c9..2c7cde65 100644 --- a/kernels/tests/test_archs.py +++ b/kernels/tests/test_archs.py @@ -1,37 +1,9 @@ -import json -from pathlib import Path - import pytest import torch from kernels_data import Metadata -import kernels.deps as deps_module from kernels import get_kernel, has_kernel, install_kernel from kernels.archs import _supports_cuda_capability -from kernels.deps import ArchValidator, DepTreeNode -from kernels.resolver import LocalKernel - - -def make_metadata(backend_type: str, archs: list[str] | None) -> Metadata: - return Metadata.from_bytes( - json.dumps( - { - "name": "test-kernel", - "id": "test_id", - "version": 1, - "license": "mit", - "python-depends": [], - "backend": {"type": backend_type, "archs": archs}, - } - ).encode("utf-8") - ) - - -def make_tree(metadata: Metadata, variant: str = "test-variant") -> DepTreeNode: - return DepTreeNode( - location=LocalKernel(Path(variant), metadata), - deps={}, - ) @pytest.mark.parametrize( @@ -77,104 +49,6 @@ def test_supports_cuda_capability(archs, capability, supported): assert _supports_cuda_capability(archs, capability) == supported -@pytest.fixture -def fake_cuda_device(monkeypatch): - monkeypatch.setattr(torch.version, "cuda", "12.8", raising=False) - monkeypatch.setattr(torch.version, "hip", None, raising=False) - monkeypatch.setattr(torch.cuda, "is_available", lambda: True) - monkeypatch.setattr(torch.cuda, "get_device_capability", lambda device=None: (10, 0)) - - -@pytest.fixture -def fake_rocm_device(monkeypatch): - class FakeProperties: - gcnArchName = "gfx90a:sramecc+:xnack-" - - monkeypatch.setattr(torch.version, "cuda", None, raising=False) - monkeypatch.setattr(torch.version, "hip", "6.4.0", raising=False) - monkeypatch.setattr(torch.cuda, "is_available", lambda: True) - monkeypatch.setattr(torch.cuda, "current_device", lambda: 0) - monkeypatch.setattr(torch.cuda, "get_device_properties", lambda device: FakeProperties()) - - -def test_cuda_incompatible_arch_is_rejected(fake_cuda_device): - with pytest.raises(RuntimeError) as exc_info: - ArchValidator().validate(tree=make_tree(make_metadata("cuda", ["8.0", "9.0a"]))) - assert "test-variant" in str(exc_info.value) - assert "CUDA capability 10.0" in str(exc_info.value) - assert "8.0, 9.0a" in str(exc_info.value) - - -def test_cuda_compatible_arch_is_accepted(fake_cuda_device): - for archs in (["8.0", "10.0"], ["10.0a"], ["10.0f"]): - ArchValidator().validate(tree=make_tree(make_metadata("cuda", archs))) - - -def test_noarch_build_is_accepted(fake_cuda_device): - # Backends that support archs (e.g. CUDA) can have builds that do not - # declare any (noarch kernels, e.g. pure Triton builds). Such builds are - # never rejected. - ArchValidator().validate(tree=make_tree(make_metadata("cuda", None))) - ArchValidator().validate(tree=make_tree(make_metadata("cuda", []))) - - -def test_rocm_arch_check(fake_rocm_device): - ArchValidator().validate(tree=make_tree(make_metadata("rocm", ["gfx90a", "gfx942"]))) - ArchValidator().validate(tree=make_tree(make_metadata("rocm", None))) - with pytest.raises(RuntimeError) as exc_info: - ArchValidator().validate(tree=make_tree(make_metadata("rocm", ["gfx942"]))) - assert "ROCm arch gfx90a" in str(exc_info.value) - assert "gfx942" in str(exc_info.value) - - -def test_check_skipped_without_device(monkeypatch): - monkeypatch.setattr(torch.version, "cuda", "12.8", raising=False) - monkeypatch.setattr(torch.cuda, "is_available", lambda: False) - ArchValidator().validate(tree=make_tree(make_metadata("cuda", ["8.0"]))) - - -def test_check_skipped_for_backends_without_archs(fake_cuda_device): - # Archs of other backends cannot be checked against the current device. - ArchValidator().validate(tree=make_tree(make_metadata("cpu", None))) - ArchValidator().validate(tree=make_tree(make_metadata("metal", ["applegpu_g13"]))) - - -def test_arch_validator_checks_entire_dependency_tree(monkeypatch): - tree = DepTreeNode( - location=LocalKernel(Path("root-variant"), make_metadata("cuda", ["8.0"])), - deps={ - "test/dependency": DepTreeNode( - location=LocalKernel(Path("dependency-variant"), make_metadata("cuda", ["9.0"])), - deps={}, - ) - }, - ) - validated = [] - - monkeypatch.setattr( - deps_module, - "_check_arch_incompatibility", - lambda metadata, variant: validated.append((metadata.backend.archs, variant)), - ) - - tree.validate(ArchValidator()) - - assert validated == [ - (["8.0"], "root-variant"), - (["9.0"], "dependency-variant"), - ] - - -def test_issue_707_fa3_on_b200(fake_cuda_device): - # https://github.com/huggingface/kernels/issues/707: flash-attn3 only - # declares sm_80/sm_90a archs, but loading it on a B200 (capability 10.0) - # succeeded and the first launch exited the process. The declared archs - # must be rejected for this device. - metadata = make_metadata("cuda", ["8.0", "9.0a"]) - with pytest.raises(RuntimeError, match="does not support the current device"): - ArchValidator().validate(tree=make_tree(metadata)) - - @pytest.mark.cuda_only def test_get_kernel_rejects_unsupported_capability(monkeypatch): variant_path = install_kernel("kernels-community/relu", version=1) diff --git a/kernels/tests/test_minver.py b/kernels/tests/test_minver.py deleted file mode 100644 index 7b1ff995..00000000 --- a/kernels/tests/test_minver.py +++ /dev/null @@ -1,77 +0,0 @@ -import json -import re -from pathlib import Path - -import pytest -from kernels_data import Metadata, Version - -import kernels -from kernels.deps import DepTreeNode, MinverValidator, _installed_version -from kernels.resolver import LocalKernel - - -def _make_tree(minver): - metadata_dict = { - "id": "activation_1_cuda", - "name": "activation", - "version": 1, - "license": "Apache-2.0", - "python-depends": ["torch"], - "backend": {"type": "cuda"}, - } - if minver is not None: - metadata_dict["kernels-minver"] = minver - metadata = Metadata.from_bytes(json.dumps(metadata_dict).encode("utf-8")) - variant_path = Path("build") / "torch28-cxx11-cu128-x86_64-linux" - return DepTreeNode(location=LocalKernel(variant_path, metadata), deps={}) - - -@pytest.mark.parametrize("minver", [None, "0.0.1"]) -def test_no_error_when_minver_met(minver): - MinverValidator().validate(tree=_make_tree(minver)) - - -def test_no_error_for_dev_version_of_required_release(monkeypatch): - # A development version implements the release it leads up to, so - # `0.17.0.dev0` must satisfy a `0.17.0` requirement. - monkeypatch.setattr(kernels, "__version__", "0.17.0.dev0") - MinverValidator().validate(tree=_make_tree("0.17.0")) - - -@pytest.mark.parametrize( - "installed", - ["0.17.0.dev0", "0.17.0rc1", "0.17.0.post1", "0.17.0+cu121", "0.17"], -) -def test_installed_version_uses_release_segment(monkeypatch, installed): - monkeypatch.setattr(kernels, "__version__", installed) - assert _installed_version() == Version.from_str("0.17.0") - - -def test_installed_version_is_none_for_non_pep440_version(monkeypatch): - monkeypatch.setattr(kernels, "__version__", "0.17.0-dirty") - assert _installed_version() is None - - -def test_unparseable_installed_version_does_not_fail_validation(monkeypatch): - # A version that cannot be compared must not turn this check into a - # failure. - monkeypatch.setattr(kernels, "__version__", "0.17.0-dirty") - MinverValidator().validate(tree=_make_tree("999.1.0")) - - -def test_version_ordering_is_numeric_not_lexicographic(): - # `0.9 < 0.10` only holds for numeric comparison; string comparison would - # get this backwards. - assert Version.from_str("0.9") < Version.from_str("0.10") - assert Version.from_str("0.14") == Version.from_str("0.14.0") - assert Version.from_str("0.14.0") < Version.from_str("0.14.1") - - -def test_raises_when_minver_not_met(): - with pytest.raises(RuntimeError, match="requires kernels>=999.1"): - MinverValidator().validate(tree=_make_tree("999.1.0")) - - -def test_error_mentions_installed_version(): - with pytest.raises(RuntimeError, match=f"version {re.escape(kernels.__version__)} is installed"): - MinverValidator().validate(tree=_make_tree("999.1.0")) diff --git a/kernels/tests/test_validate.py b/kernels/tests/test_validate.py new file mode 100644 index 00000000..1f258386 --- /dev/null +++ b/kernels/tests/test_validate.py @@ -0,0 +1,151 @@ +import re +from pathlib import Path + +import pytest +import torch +from kernels_data import Version + +import kernels +import kernels.validate as validate_module +from kernels.deps import DepTreeNode +from kernels.resolver import LocalKernel +from kernels.validate import ArchValidator, MinverValidator, _installed_version + + +@pytest.mark.parametrize("minver", [None, "0.0.1"]) +def test_no_error_when_minver_met(minver, make_metadata): + MinverValidator().validate_metadata( + metadata=make_metadata("cuda", None, kernels_minver=minver), variant="test-variant" + ) + + +def test_no_error_for_dev_version_of_required_release(monkeypatch, make_metadata): + # A development version implements the release it leads up to, so + # `0.17.0.dev0` must satisfy a `0.17.0` requirement. + monkeypatch.setattr(kernels, "__version__", "0.17.0.dev0") + MinverValidator().validate_metadata( + metadata=make_metadata("cuda", None, kernels_minver="0.17.0"), variant="test-variant" + ) + + +@pytest.mark.parametrize( + "installed", + ["0.17.0.dev0", "0.17.0rc1", "0.17.0.post1", "0.17.0+cu121", "0.17"], +) +def test_installed_version_uses_release_segment(monkeypatch, installed): + monkeypatch.setattr(kernels, "__version__", installed) + assert _installed_version() == Version.from_str("0.17.0") + + +def test_installed_version_is_none_for_non_pep440_version(monkeypatch): + monkeypatch.setattr(kernels, "__version__", "0.17.0-dirty") + assert _installed_version() is None + + +def test_unparseable_installed_version_does_not_fail_validation(monkeypatch, make_metadata): + # A version that cannot be compared must not turn this check into a + # failure. + monkeypatch.setattr(kernels, "__version__", "0.17.0-dirty") + MinverValidator().validate_metadata( + metadata=make_metadata("cuda", None, kernels_minver="999.1.0"), variant="test-variant" + ) + + +def test_version_ordering_is_numeric_not_lexicographic(): + # `0.9 < 0.10` only holds for numeric comparison; string comparison would + # get this backwards. + assert Version.from_str("0.9") < Version.from_str("0.10") + assert Version.from_str("0.14") == Version.from_str("0.14.0") + assert Version.from_str("0.14.0") < Version.from_str("0.14.1") + + +def test_raises_when_minver_not_met(make_metadata): + with pytest.raises(RuntimeError, match="requires kernels>=999.1"): + MinverValidator().validate_metadata( + metadata=make_metadata("cuda", None, kernels_minver="999.1.0"), variant="test-variant" + ) + + +def test_error_mentions_installed_version(make_metadata): + with pytest.raises(RuntimeError, match=f"version {re.escape(kernels.__version__)} is installed"): + MinverValidator().validate_metadata( + metadata=make_metadata("cuda", None, kernels_minver="999.1.0"), variant="test-variant" + ) + + +def test_cuda_incompatible_arch_is_rejected(fake_cuda_device, make_metadata): + with pytest.raises(RuntimeError) as exc_info: + ArchValidator().validate_metadata(metadata=make_metadata("cuda", ["8.0", "9.0a"]), variant="test-variant") + assert "test-variant" in str(exc_info.value) + assert "CUDA capability 10.0" in str(exc_info.value) + assert "8.0, 9.0a" in str(exc_info.value) + + +def test_cuda_compatible_arch_is_accepted(fake_cuda_device, make_metadata): + for archs in (["8.0", "10.0"], ["10.0a"], ["10.0f"]): + ArchValidator().validate_metadata(metadata=make_metadata("cuda", archs), variant="test-variant") + + +def test_noarch_build_is_accepted(fake_cuda_device, make_metadata): + # Backends that support archs (e.g. CUDA) can have builds that do not + # declare any (noarch kernels, e.g. pure Triton builds). Such builds are + # never rejected. + ArchValidator().validate_metadata(metadata=make_metadata("cuda", None), variant="test-variant") + ArchValidator().validate_metadata(metadata=make_metadata("cuda", []), variant="test-variant") + + +def test_rocm_arch_check(fake_rocm_device, make_metadata): + ArchValidator().validate_metadata(metadata=make_metadata("rocm", ["gfx90a", "gfx942"]), variant="test-variant") + ArchValidator().validate_metadata(metadata=make_metadata("rocm", None), variant="test-variant") + with pytest.raises(RuntimeError) as exc_info: + ArchValidator().validate_metadata(metadata=make_metadata("rocm", ["gfx942"]), variant="test-variant") + assert "ROCm arch gfx90a" in str(exc_info.value) + assert "gfx942" in str(exc_info.value) + + +def test_check_skipped_without_device(monkeypatch, make_metadata): + monkeypatch.setattr(torch.version, "cuda", "12.8", raising=False) + monkeypatch.setattr(torch.cuda, "is_available", lambda: False) + ArchValidator().validate_metadata(metadata=make_metadata("cuda", ["8.0"]), variant="test-variant") + + +def test_check_skipped_for_backends_without_archs(fake_cuda_device, make_metadata): + # Archs of other backends cannot be checked against the current device. + ArchValidator().validate_metadata(metadata=make_metadata("cpu", None), variant="test-variant") + ArchValidator().validate_metadata(metadata=make_metadata("metal", ["applegpu_g13"]), variant="test-variant") + + +def test_arch_validator_checks_entire_dependency_tree(monkeypatch, make_metadata): + tree = DepTreeNode( + location=LocalKernel(Path("root-variant"), make_metadata("cuda", ["8.0"])), + deps={ + "test/dependency": DepTreeNode( + location=LocalKernel(Path("dependency-variant"), make_metadata("cuda", ["9.0"])), + deps={}, + ) + }, + ) + validated = [] + + monkeypatch.setattr( + validate_module, + "_check_arch_incompatibility", + lambda metadata, variant: validated.append((metadata.backend.archs, variant)), + ) + + tree.validate_metadata(ArchValidator()) + + assert validated == [ + (["8.0"], "root-variant"), + (["9.0"], "dependency-variant"), + ] + + +def test_issue_707_fa3_on_b200(fake_cuda_device, make_metadata): + # https://github.com/huggingface/kernels/issues/707: flash-attn3 only + # declares sm_80/sm_90a archs, but loading it on a B200 (capability 10.0) + # succeeded and the first launch exited the process. The declared archs + # must be rejected for this device. + metadata = make_metadata("cuda", ["8.0", "9.0a"]) + with pytest.raises(RuntimeError, match="does not support the current device"): + ArchValidator().validate_metadata(metadata=metadata, variant="test-variant") diff --git a/nix-builder/pkgs/get-kernel-check/get-kernel-check-hook.py b/nix-builder/pkgs/get-kernel-check/get-kernel-check-hook.py index 2b2b720b..29f25d6e 100755 --- a/nix-builder/pkgs/get-kernel-check/get-kernel-check-hook.py +++ b/nix-builder/pkgs/get-kernel-check/get-kernel-check-hook.py @@ -1,10 +1,10 @@ import os from pathlib import Path -from kernels.deps import AllValidator, default_validators from kernels.hf_hub import _get_hf_api from kernels.load import get_kernel_with_resolver from kernels.resolver import KernelPathsResolver, RepoPathsResolver, SequentialResolver +from kernels.validate import AllValidator, default_metadata_validators from kernels_data import KernelDependency, KernelPaths, KernelVersion out = os.getenv("out") @@ -30,5 +30,5 @@ backend=None, kernel=kernel, resolver=SequentialResolver(resolvers=resolvers), - validator=AllValidator(validators=default_validators()), + metadata_validator=AllValidator(validators=default_metadata_validators()), )