diff --git a/export/TARGETS b/export/TARGETS index bf1002a701e..77d6b07795e 100644 --- a/export/TARGETS +++ b/export/TARGETS @@ -24,6 +24,7 @@ python_library( deps = [ ":recipe", "//executorch/runtime:runtime", + ":recipe_registry" ] ) @@ -35,5 +36,30 @@ python_library( deps = [ ":export", ":recipe", + ":recipe_registry", + ":recipe_provider" ], ) + + +python_library( + name = "recipe_registry", + srcs = [ + "recipe_registry.py", + ], + deps = [ + ":recipe", + ":recipe_provider" + ], +) + + +python_library( + name = "recipe_provider", + srcs = [ + "recipe_provider.py", + ], + deps = [ + ":recipe", + ] +) diff --git a/export/__init__.py b/export/__init__.py index 5eaf2add02e..a39f7b86a53 100644 --- a/export/__init__.py +++ b/export/__init__.py @@ -4,6 +4,8 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +# pyre-strict + """ ExecuTorch export module. @@ -12,13 +14,18 @@ export management. """ -# pyre-strict - from .export import export, ExportSession -from .recipe import ExportRecipe +from .recipe import ExportRecipe, QuantizationRecipe, RecipeType +from .recipe_provider import BackendRecipeProvider +from .recipe_registry import recipe_registry + __all__ = [ "ExportRecipe", + "QuantizationRecipe", "ExportSession", "export", + "BackendRecipeProvider", + "recipe_registry", + "RecipeType", ] diff --git a/export/export.py b/export/export.py index f21fe33a75e..b0c9e000867 100644 --- a/export/export.py +++ b/export/export.py @@ -1,3 +1,9 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + from abc import ABC, abstractmethod from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union @@ -10,11 +16,14 @@ ExecutorchProgramManager, to_edge_transform_and_lower, ) +from executorch.exir.program._program import _transform from executorch.exir.schema import Program from executorch.extension.export_util.utils import save_pte_program from executorch.runtime import Runtime, Verification from tabulate import tabulate from torch import nn + +from torch._export.pass_base import PassType from torch.export import ExportedProgram from torchao.quantization import quantize_ from torchao.quantization.pt2e import allow_exported_model_train_eval @@ -95,9 +104,7 @@ class ExportStage(Stage): def __init__( self, - pre_edge_transform_passes: Optional[ - Callable[[ExportedProgram], ExportedProgram] - ] = None, + pre_edge_transform_passes: Optional[List[PassType]] = None, ) -> None: self._exported_program: Dict[str, ExportedProgram] = {} self._pre_edge_transform_passes = pre_edge_transform_passes @@ -153,10 +160,10 @@ def run( ) # Apply pre-edge transform passes if available - if self._pre_edge_transform_passes is not None: - for pre_edge_transform_pass in self._pre_edge_transform_passes: - self._exported_program[method_name] = pre_edge_transform_pass( - self._exported_program[method_name] + if pre_edge_transform_passes := self._pre_edge_transform_passes or []: + for pass_ in pre_edge_transform_passes: + self._exported_program[method_name] = _transform( + self._exported_program[method_name], pass_ ) def get_artifacts(self) -> Dict[str, ExportedProgram]: diff --git a/export/recipe.py b/export/recipe.py index b993fce26e3..d95c4e77696 100644 --- a/export/recipe.py +++ b/export/recipe.py @@ -3,6 +3,19 @@ # # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +from abc import ABCMeta, abstractmethod +from dataclasses import dataclass +from enum import Enum, EnumMeta +from typing import List, Optional, Sequence + +from executorch.exir._warnings import experimental + +from executorch.exir.backend.partitioner import Partitioner +from executorch.exir.capture import EdgeCompileConfig, ExecutorchBackendConfig +from executorch.exir.pass_manager import PassType +from torchao.core.config import AOBaseConfig +from torchao.quantization.pt2e.quantizer import Quantizer + """ Export recipe definitions for ExecuTorch. @@ -11,18 +24,29 @@ for ExecuTorch models, including export configurations and quantization recipes. """ -from dataclasses import dataclass -from enum import Enum -from typing import Callable, List, Optional, Sequence -from executorch.exir._warnings import experimental +class RecipeTypeMeta(EnumMeta, ABCMeta): + """Metaclass that combines EnumMeta and ABCMeta""" -from executorch.exir.backend.partitioner import Partitioner -from executorch.exir.capture import EdgeCompileConfig, ExecutorchBackendConfig -from executorch.exir.pass_manager import PassType -from torch.export import ExportedProgram -from torchao.core.config import AOBaseConfig -from torchao.quantization.pt2e.quantizer import Quantizer + pass + + +class RecipeType(Enum, metaclass=RecipeTypeMeta): + """ + Base recipe type class that backends can extend to define their own recipe types. + Backends should create their own enum classes that inherit from RecipeType: + """ + + @classmethod + @abstractmethod + def get_backend_name(cls) -> str: + """ + Return the backend name for this recipe type. + + Returns: + str: The backend name (e.g., "xnnpack", "qnn", etc.) + """ + pass class Mode(str, Enum): @@ -52,7 +76,7 @@ class QuantizationRecipe: quantizers: Optional[List[Quantizer]] = None ao_base_config: Optional[List[AOBaseConfig]] = None - def get_quantizers(self) -> Optional[Quantizer]: + def get_quantizers(self) -> Optional[List[Quantizer]]: """ Get the quantizer associated with this recipe. @@ -89,17 +113,40 @@ class ExportRecipe: name: Optional[str] = None quantization_recipe: Optional[QuantizationRecipe] = None - edge_compile_config: Optional[EdgeCompileConfig] = ( - None # pyre-ignore[11]: Type not defined - ) - pre_edge_transform_passes: Optional[ - Callable[[ExportedProgram], ExportedProgram] - | List[Callable[[ExportedProgram], ExportedProgram]] - ] = None + # pyre-ignore[11]: Type not defined + edge_compile_config: Optional[EdgeCompileConfig] = None + pre_edge_transform_passes: Optional[Sequence[PassType]] = None edge_transform_passes: Optional[Sequence[PassType]] = None transform_check_ir_validity: bool = True partitioners: Optional[List[Partitioner]] = None - executorch_backend_config: Optional[ExecutorchBackendConfig] = ( - None # pyre-ignore[11]: Type not defined - ) + # pyre-ignore[11]: Type not defined + executorch_backend_config: Optional[ExecutorchBackendConfig] = None mode: Mode = Mode.RELEASE + + @classmethod + def get_recipe(cls, recipe: "RecipeType", **kwargs) -> "ExportRecipe": + """ + Get an export recipe from backend. Backend is automatically determined based on the + passed recipe type. + + Args: + recipe: The type of recipe to create + **kwargs: Recipe-specific parameters + + Returns: + ExportRecipe configured for the specified recipe type + """ + from .recipe_registry import recipe_registry + + if not isinstance(recipe, RecipeType): + raise ValueError(f"Invalid recipe type: {recipe}") + + backend = recipe.get_backend_name() + export_recipe = recipe_registry.create_recipe(recipe, backend, **kwargs) + if export_recipe is None: + supported = recipe_registry.get_supported_recipes(backend) + raise ValueError( + f"Recipe '{recipe.value}' not supported by '{backend}'. " + f"Supported: {[r.value for r in supported]}" + ) + return export_recipe diff --git a/export/recipe_provider.py b/export/recipe_provider.py new file mode 100644 index 00000000000..d5c689fa8b6 --- /dev/null +++ b/export/recipe_provider.py @@ -0,0 +1,60 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-strict + +""" +Recipe registry for managing backend recipe providers. + +This module provides the registry system for backend recipe providers and +the abstract interface that all backends must implement. +""" + +from abc import ABC, abstractmethod +from typing import Any, Optional, Sequence + +from .recipe import ExportRecipe, RecipeType + + +class BackendRecipeProvider(ABC): + """ + Abstract recipe provider that all backends must implement + """ + + @property + @abstractmethod + def backend_name(self) -> str: + """ + Name of the backend (ex: 'xnnpack', 'qnn' etc) + """ + pass + + @abstractmethod + def get_supported_recipes(self) -> Sequence[RecipeType]: + """ + Get list of supported recipes. + """ + pass + + @abstractmethod + def create_recipe( + self, recipe_type: RecipeType, **kwargs: Any + ) -> Optional[ExportRecipe]: + """ + Create a recipe for the given type. + Returns None if the recipe is not supported by this backend. + + Args: + recipe_type: The type of recipe to create + **kwargs: Recipe-specific parameters (ex: group_size) + + Returns: + ExportRecipe if supported, None otherwise + """ + pass + + def supports_recipe(self, recipe_type: RecipeType) -> bool: + return recipe_type in self.get_supported_recipes() diff --git a/export/recipe_registry.py b/export/recipe_registry.py new file mode 100644 index 00000000000..e3f0b0fd79a --- /dev/null +++ b/export/recipe_registry.py @@ -0,0 +1,86 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Recipe registry for managing backend recipe providers. + +This module provides the registry system for backend recipe providers and +the abstract interface that all backends must implement. +""" + +from typing import Any, Dict, Optional, Sequence + +from .recipe import ExportRecipe, RecipeType +from .recipe_provider import BackendRecipeProvider + + +class RecipeRegistry: + """Global registry for all backend recipe providers""" + + _instance = None + _initialized = False + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self) -> None: + # Only initialize once to avoid resetting state on subsequent calls + if not RecipeRegistry._initialized: + self._providers: Dict[str, BackendRecipeProvider] = {} + RecipeRegistry._initialized = True + + def register_backend_recipe_provider(self, provider: BackendRecipeProvider) -> None: + """ + Register a backend recipe provider + """ + self._providers[provider.backend_name] = provider + + def create_recipe( + self, recipe_type: RecipeType, backend: str, **kwargs: Any + ) -> Optional[ExportRecipe]: + """ + Create a recipe for a specific backend. + + Args: + recipe_type: The type of recipe to create + backend: Backend name + **kwargs: Recipe-specific parameters + + Returns: + ExportRecipe if supported, None if not supported + """ + if backend not in self._providers: + raise ValueError( + f"Backend '{backend}' not available. Available: {list(self._providers.keys())}" + ) + + return self._providers[backend].create_recipe(recipe_type, **kwargs) + + def get_supported_recipes(self, backend: str) -> Sequence[RecipeType]: + """ + Get list of recipes supported by a backend. + + Args: + backend: Backend name + + Returns: + List of supported recipe types + """ + if backend not in self._providers: + raise ValueError(f"Backend '{backend}' not available") + return self._providers[backend].get_supported_recipes() + + def list_backends(self) -> Sequence[str]: + """ + Get list of all registered backends + """ + return list(self._providers.keys()) + + +# initialize recipe registry +recipe_registry = RecipeRegistry() diff --git a/export/tests/TARGETS b/export/tests/TARGETS index 93556cb03dd..e92bdc77eb0 100644 --- a/export/tests/TARGETS +++ b/export/tests/TARGETS @@ -1,8 +1,8 @@ -load("@fbcode_macros//build_defs:python_unittest.bzl", "python_unittest") +load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "runtime") oncall("executorch") -python_unittest( +runtime.python_test( name = "executorch_export", srcs = [ "test_executorch_export.py", @@ -14,3 +14,15 @@ python_unittest( "//executorch/runtime:runtime", ] ) + +runtime.python_test( + name = "test_export_recipe", + srcs = [ + "test_recipe_provider.py", + "test_recipe_registry.py", + "test_export_recipe.py", + ], + deps = [ + "//executorch/export:lib", + ] +) diff --git a/export/tests/test_export_recipe.py b/export/tests/test_export_recipe.py new file mode 100644 index 00000000000..d22442371e2 --- /dev/null +++ b/export/tests/test_export_recipe.py @@ -0,0 +1,131 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-strict + +import unittest +from typing import Any, Dict, Optional, Sequence + +from executorch.export.recipe import ExportRecipe, RecipeType +from executorch.export.recipe_provider import BackendRecipeProvider +from executorch.export.recipe_registry import recipe_registry + + +class TestRecipeType(RecipeType): + FP32 = "fp32" + INT8 = "int8" + UNSUPPORTED = "unsupported" + + @classmethod + def get_backend_name(cls) -> str: + return "test_backend" + + +class AnotherTestRecipeType(RecipeType): + DYNAMIC = "dynamic" + + @classmethod + def get_backend_name(cls) -> str: + return "another_backend" + + +class ConcreteBackendProvider(BackendRecipeProvider): + def __init__( + self, backend_name: str, supported_recipes: Sequence[RecipeType] + ) -> None: + self._backend_name = backend_name + self._supported_recipes = supported_recipes + self.last_kwargs: Optional[Dict[str, Any]] = None + + @property + def backend_name(self) -> str: + return self._backend_name + + def get_supported_recipes(self) -> Sequence[RecipeType]: + return self._supported_recipes + + def create_recipe( + self, recipe_type: RecipeType, **kwargs: Any + ) -> Optional[ExportRecipe]: + self.last_kwargs = kwargs + if recipe_type in self._supported_recipes: + return ExportRecipe(name=f"{self._backend_name}_{recipe_type.value}") + return None + + +class TestExportRecipeGetRecipe(unittest.TestCase): + + def setUp(self) -> None: + self.provider = ConcreteBackendProvider( + "test_backend", [TestRecipeType.FP32, TestRecipeType.INT8] + ) + recipe_registry.register_backend_recipe_provider(self.provider) + + self.another_provider = ConcreteBackendProvider( + "another_backend", [AnotherTestRecipeType.DYNAMIC] + ) + recipe_registry.register_backend_recipe_provider(self.another_provider) + + def tearDown(self) -> None: + if recipe_registry._initialized: + recipe_registry._providers.clear() + + def test_get_recipe_success(self) -> None: + result = ExportRecipe.get_recipe(TestRecipeType.FP32) + + self.assertIsNotNone(result) + self.assertEqual(result.name, "test_backend_fp32") + + def test_get_recipe_unsupported_recipe_raises_error(self) -> None: + with self.assertRaises(ValueError) as context: + ExportRecipe.get_recipe(TestRecipeType.UNSUPPORTED) + + error_message = str(context.exception) + self.assertIn( + "Recipe 'unsupported' not supported by 'test_backend'", error_message + ) + self.assertIn("Supported: ['fp32', 'int8']", error_message) + + def test_get_recipe_unsupported_recipe_type_raises_error(self) -> None: + with self.assertRaises(ValueError) as context: + # pyre-ignore[6] + ExportRecipe.get_recipe("abc") + + error_message = str(context.exception) + self.assertIn("Invalid recipe type:", error_message) + + def test_get_recipe_backend_name_extraction(self) -> None: + result = ExportRecipe.get_recipe(TestRecipeType.FP32) + self.assertIsNotNone(result) + self.assertEqual(result.name, "test_backend_fp32") + + result2 = ExportRecipe.get_recipe(AnotherTestRecipeType.DYNAMIC) + self.assertIsNotNone(result2) + self.assertEqual(result2.name, "another_backend_dynamic") + + def test_get_recipe_empty_kwargs(self) -> None: + result = ExportRecipe.get_recipe(TestRecipeType.FP32, **{}) + + self.assertIsNotNone(result) + self.assertEqual(result.name, "test_backend_fp32") + + def test_get_recipe_returns_correct_type(self) -> None: + result = ExportRecipe.get_recipe(TestRecipeType.FP32) + + self.assertIsInstance(result, ExportRecipe) + + def test_get_recipe_with_kwargs_verification(self) -> None: + """Test that kwargs are properly passed to recipe_registry.create_recipe""" + kwargs = {"group_size": 32, "custom_kwarg": "val"} + + result = ExportRecipe.get_recipe(TestRecipeType.INT8, **kwargs) + + self.assertIsNotNone(result) + self.assertEqual(result.name, "test_backend_int8") + + # Verify that the kwargs were passed to the backend provider's create_recipe method + self.assertIsNotNone(self.provider.last_kwargs) + self.assertEqual(self.provider.last_kwargs, kwargs) diff --git a/export/tests/test_recipe_provider.py b/export/tests/test_recipe_provider.py new file mode 100644 index 00000000000..182061c6bbe --- /dev/null +++ b/export/tests/test_recipe_provider.py @@ -0,0 +1,98 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-strict + +import unittest +from typing import Any, Optional, Sequence + +from executorch.export import BackendRecipeProvider, ExportRecipe, RecipeType + + +class TestRecipeType(RecipeType): + FP32 = "fp32" + INT8 = "int8" + UNSUPPORTED = "unsupported" + + @classmethod + def get_backend_name(cls) -> str: + return "test_backend" + + +class ConcreteBackendProvider(BackendRecipeProvider): + """Mock backend provider for testing""" + + def __init__( + self, backend_name: str, supported_recipes: Sequence[RecipeType] + ) -> None: + self._backend_name = backend_name + self._supported_recipes = supported_recipes + + @property + def backend_name(self) -> str: + return self._backend_name + + def get_supported_recipes(self) -> Sequence[RecipeType]: + return self._supported_recipes + + def create_recipe( + self, recipe_type: RecipeType, **kwargs: Any + ) -> Optional[ExportRecipe]: + _ = kwargs + if recipe_type in self._supported_recipes: + return ExportRecipe(name=f"{self._backend_name}_{recipe_type.value}") + return None + + +class TestBackendRecipeProvider(unittest.TestCase): + + def setUp(self) -> None: + self.supported_recipes = [TestRecipeType.FP32, TestRecipeType.INT8] + self.provider = ConcreteBackendProvider("test_backend", self.supported_recipes) + + def test_get_supported_recipes(self) -> None: + recipes = self.provider.get_supported_recipes() + self.assertIn(TestRecipeType.FP32, recipes) + self.assertIn(TestRecipeType.INT8, recipes) + + def test_create_recipe_supported(self) -> None: + recipe = self.provider.create_recipe(TestRecipeType.FP32) + self.assertIsNotNone(recipe) + self.assertIsInstance(recipe, ExportRecipe) + self.assertEqual(recipe.name, "test_backend_fp32") + + def test_supports_recipe_true(self) -> None: + self.assertTrue(self.provider.supports_recipe(TestRecipeType.FP32)) + self.assertTrue(self.provider.supports_recipe(TestRecipeType.INT8)) + + def test_supports_recipe_false(self) -> None: + self.assertFalse(self.provider.supports_recipe(TestRecipeType.UNSUPPORTED)) + + def test_empty_supported_recipes(self) -> None: + empty_provider = ConcreteBackendProvider("empty_backend", []) + + self.assertEqual(empty_provider.get_supported_recipes(), []) + self.assertFalse(empty_provider.supports_recipe(TestRecipeType.FP32)) + self.assertIsNone(empty_provider.create_recipe(TestRecipeType.FP32)) + + def test_create_recipe_consistency(self) -> None: + for recipe_type in [ + TestRecipeType.FP32, + TestRecipeType.INT8, + TestRecipeType.UNSUPPORTED, + ]: + supports = self.provider.supports_recipe(recipe_type) + recipe = self.provider.create_recipe(recipe_type) + + if supports: + self.assertIsNotNone( + recipe, f"Recipe should be created for supported type {recipe_type}" + ) + else: + self.assertIsNone( + recipe, + f"Recipe should not be created for unsupported type {recipe_type}", + ) diff --git a/export/tests/test_recipe_registry.py b/export/tests/test_recipe_registry.py new file mode 100644 index 00000000000..a9d0b290545 --- /dev/null +++ b/export/tests/test_recipe_registry.py @@ -0,0 +1,139 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-strict + +import unittest +from typing import Any, Optional, Sequence + +from executorch.export.recipe import ExportRecipe, RecipeType +from executorch.export.recipe_provider import BackendRecipeProvider +from executorch.export.recipe_registry import recipe_registry, RecipeRegistry + + +class TestRecipeType(RecipeType): + FP32 = "fp32" + INT8 = "int8" + + @classmethod + def get_backend_name(cls) -> str: + return "test_backend" + + +class MockBackendProvider(BackendRecipeProvider): + def __init__( + self, backend_name: str, supported_recipes: Sequence[RecipeType] + ) -> None: + self._backend_name = backend_name + self._supported_recipes = supported_recipes + + @property + def backend_name(self) -> str: + return self._backend_name + + def get_supported_recipes(self) -> Sequence[RecipeType]: + return self._supported_recipes + + def create_recipe( + self, recipe_type: RecipeType, **kwargs: Any + ) -> Optional[ExportRecipe]: + _ = kwargs + if recipe_type in self._supported_recipes: + return ExportRecipe(name=f"{self._backend_name}_{recipe_type.value}") + return None + + +class TestRecipeRegistry(unittest.TestCase): + + def setUp(self) -> None: + # Create a fresh registry for each test + RecipeRegistry._instance = None + RecipeRegistry._initialized = False + self.registry = RecipeRegistry() + + def test_get_supported_recipes_type(self) -> None: + provider = MockBackendProvider("test_backend", [TestRecipeType.FP32]) + self.registry.register_backend_recipe_provider(provider) + + self.assertIsInstance(self.registry.get_supported_recipes("test_backend"), list) + for recipe in self.registry.get_supported_recipes("test_backend"): + self.assertIsInstance(recipe, RecipeType) + + def test_singleton_pattern(self) -> None: + registry1 = RecipeRegistry() + registry2 = RecipeRegistry() + self.assertIs(registry1, registry2) + + def test_register_backend_recipe_provider(self) -> None: + provider = MockBackendProvider("test_backend", [TestRecipeType.FP32]) + self.registry.register_backend_recipe_provider(provider) + + backends = self.registry.list_backends() + self.assertIn("test_backend", backends) + + def test_create_recipe_success(self) -> None: + provider = MockBackendProvider( + "test_backend", [TestRecipeType.FP32, TestRecipeType.INT8] + ) + self.registry.register_backend_recipe_provider(provider) + + recipe = self.registry.create_recipe(TestRecipeType.FP32, "test_backend") + self.assertIsNotNone(recipe) + self.assertEqual(recipe.name, "test_backend_fp32") + + def test_create_recipe_unsupported_backend(self) -> None: + with self.assertRaises(ValueError) as context: + self.registry.create_recipe(TestRecipeType.FP32, "nonexistent_backend") + self.assertIn( + "Backend 'nonexistent_backend' not available", str(context.exception) + ) + + def test_create_recipe_unsupported_recipe_type(self) -> None: + provider = MockBackendProvider("test_backend", [TestRecipeType.FP32]) + self.registry.register_backend_recipe_provider(provider) + recipe = self.registry.create_recipe(TestRecipeType.INT8, "test_backend") + self.assertIsNone(recipe) + + def test_get_supported_recipes(self) -> None: + supported_recipes = [TestRecipeType.FP32, TestRecipeType.INT8] + provider = MockBackendProvider("test_backend", supported_recipes) + self.registry.register_backend_recipe_provider(provider) + + recipes = self.registry.get_supported_recipes("test_backend") + self.assertEqual(recipes, supported_recipes) + + def test_get_supported_recipes_unknown_backend(self) -> None: + with self.assertRaises(ValueError) as context: + self.registry.get_supported_recipes("unknown_backend") + + self.assertIn("Backend 'unknown_backend' not available", str(context.exception)) + + def test_list_backends(self) -> None: + provider1 = MockBackendProvider("backend1", [TestRecipeType.FP32]) + provider2 = MockBackendProvider("backend2", [TestRecipeType.INT8]) + + self.registry.register_backend_recipe_provider(provider1) + self.registry.register_backend_recipe_provider(provider2) + + backends = self.registry.list_backends() + self.assertIn("backend1", backends) + self.assertIn("backend2", backends) + self.assertEqual(len(backends), 2) + + def test_list_backends_empty(self) -> None: + backends = self.registry.list_backends() + self.assertEqual(backends, []) + + def test_global_registry_instance(self) -> None: + provider = MockBackendProvider("global_test", [TestRecipeType.FP32]) + recipe_registry.register_backend_recipe_provider(provider) + + backends = recipe_registry.list_backends() + self.assertIn("global_test", backends) + + recipe = recipe_registry.create_recipe(TestRecipeType.FP32, "global_test") + self.assertIsNotNone(recipe) + self.assertEqual(recipe.name, "global_test_fp32")