diff --git a/cmake/infini_ops_plugins.cmake b/cmake/infini_ops_plugins.cmake index 19af9356..15242e40 100644 --- a/cmake/infini_ops_plugins.cmake +++ b/cmake/infini_ops_plugins.cmake @@ -1,9 +1,9 @@ include_guard(GLOBAL) set(INFINI_OPS_PLUGINS "" CACHE STRING - "Comma- or semicolon-separated infini_ops build-time plugins to enable") + "Comma- or semicolon-separated `infini_ops` build-time plugins to enable.") set(INFINI_OPS_PLUGIN_ROOT "${PROJECT_SOURCE_DIR}/plugins" CACHE PATH - "Directory containing infini_ops build-time plugins") + "Directory containing `infini_ops` build-time plugins.") set(INFINI_OPS_PLUGIN_CONTRACT_VERSION 1) set(_INFINI_OPS_KNOWN_DEVICE_PLUGINS @@ -36,34 +36,34 @@ function(infini_ops_register_plugin) foreach(_required NAME KIND CONTRACT_VERSION CMAKE_ENTRY) if(NOT ARG_${_required}) - message(FATAL_ERROR "`infini_ops_register_plugin` missing `${_required}`") + message(FATAL_ERROR "`infini_ops_register_plugin` is missing `${_required}`.") endif() endforeach() if(NOT ARG_KIND STREQUAL "shared" AND NOT ARG_KIND STREQUAL "device") - message(FATAL_ERROR "infini_ops plugin `${ARG_NAME}` has invalid `kind`: `${ARG_KIND}`") + message(FATAL_ERROR "`infini_ops` plugin `${ARG_NAME}` has invalid `kind`: `${ARG_KIND}`.") endif() if(NOT "${ARG_CONTRACT_VERSION}" STREQUAL "${INFINI_OPS_PLUGIN_CONTRACT_VERSION}") message(FATAL_ERROR - "infini_ops plugin `${ARG_NAME}` uses contract `${ARG_CONTRACT_VERSION}`; " - "expected `${INFINI_OPS_PLUGIN_CONTRACT_VERSION}`") + "`infini_ops` plugin `${ARG_NAME}` uses contract `${ARG_CONTRACT_VERSION}`; " + "expected `${INFINI_OPS_PLUGIN_CONTRACT_VERSION}`.") endif() foreach(_device IN LISTS ARG_DEVICES) list(FIND _INFINI_OPS_KNOWN_DEVICE_PLUGINS "${_device}" _known_index) if(_known_index EQUAL -1) message(FATAL_ERROR - "infini_ops plugin `${ARG_NAME}` declares unknown device `${_device}`") + "`infini_ops` plugin `${ARG_NAME}` declares unknown device `${_device}`.") endif() endforeach() if(ARG_KIND STREQUAL "device" AND NOT ARG_DEVICES) - message(FATAL_ERROR "infini_ops device plugin `${ARG_NAME}` must declare `DEVICES`") + message(FATAL_ERROR "`infini_ops` device plugin `${ARG_NAME}` must declare `DEVICES`.") endif() if(ARG_KIND STREQUAL "shared" AND ARG_DEVICES) - message(FATAL_ERROR "infini_ops shared plugin `${ARG_NAME}` must not declare `DEVICES`") + message(FATAL_ERROR "`infini_ops` shared plugin `${ARG_NAME}` must not declare `DEVICES`.") endif() _infini_ops_append_unique_global(INFINI_OPS_PLUGIN_NAMES "${ARG_NAME}") @@ -93,12 +93,12 @@ function(infini_ops_enable_plugin name) if(NOT _loading_index EQUAL -1) list(APPEND _loading "${name}") string(REPLACE ";" " -> " _cycle "${_loading}") - message(FATAL_ERROR "infini_ops plugin dependency cycle detected: `${_cycle}`") + message(FATAL_ERROR "`infini_ops` plugin dependency cycle detected: `${_cycle}`.") endif() set(_entry_path "${INFINI_OPS_PLUGIN_ROOT}/${name}/plugin.cmake") if(NOT EXISTS "${_entry_path}") - message(FATAL_ERROR "infini_ops plugin `${name}` `CMake` entry not found: `${_entry_path}`") + message(FATAL_ERROR "`infini_ops` plugin `${name}` `CMake` entry was not found: `${_entry_path}`.") endif() set_property(GLOBAL APPEND PROPERTY INFINI_OPS_PLUGIN_LOADING_STACK "${name}") @@ -110,7 +110,7 @@ function(infini_ops_enable_plugin name) get_property(_registered GLOBAL PROPERTY INFINI_OPS_PLUGIN_NAMES) list(FIND _registered "${name}" _registered_index) if(_registered_index EQUAL -1) - message(FATAL_ERROR "infini_ops plugin `${name}` did not call `infini_ops_register_plugin`") + message(FATAL_ERROR "`infini_ops` plugin `${name}` did not call `infini_ops_register_plugin`.") endif() set_property(GLOBAL APPEND PROPERTY INFINI_OPS_PLUGIN_LOADED "${name}") @@ -206,7 +206,7 @@ function(_infini_ops_append_json_map path field trailing_comma) foreach(_entry ${ARGN}) string(FIND "${_entry}" "=" _equals) if(_equals EQUAL -1) - message(FATAL_ERROR "Invalid infini_ops plugin map entry `${_entry}`") + message(FATAL_ERROR "Invalid `infini_ops` plugin map entry `${_entry}`.") endif() string(SUBSTRING "${_entry}" 0 ${_equals} _key) math(EXPR _value_start "${_equals} + 1") @@ -248,6 +248,6 @@ function(infini_ops_write_plugin_registry path) _infini_ops_append_json_map("${path}" "test_devices" FALSE ${_test_devices}) file(APPEND "${path}" "}\n") - message(STATUS "infini_ops plugins: `${_plugins}`") - message(STATUS "infini_ops plugin devices: `${_devices}`") + message(STATUS "`infini_ops` plugins: `${_plugins}`.") + message(STATUS "`infini_ops` plugin devices: `${_devices}`.") endfunction() diff --git a/docs/plugin_contract.md b/docs/plugin_contract.md new file mode 100644 index 00000000..c70c7853 --- /dev/null +++ b/docs/plugin_contract.md @@ -0,0 +1,74 @@ +# Infini Ops Plugin Contract v1 + +This contract defines the build-time plugin boundary for `infini::ops`. It is a source-level integration contract, not a runtime loading ABI. v1 still builds one `libinfiniops.so` and the existing Python `ops` module. Plugins only decide which platform sources, operator roots, device headers, CMake entries, and test device mappings participate in that build. + +## Scope + +- v1 plugins are loaded during configure and code generation. They do not use `dlopen`, do not publish a stable binary ABI, and do not change the public C++ or Python operator API. +- v1 only accepts device names already known by core: `cpu`, `nvidia`, `iluvatar`, `hygon`, `metax`, `moore`, `cambricon`, and `ascend`. External plugins cannot add a new `Device::Type` yet. +- `kind=shared` is for shared source layers such as `cuda-common`. Shared plugins do not declare user-visible devices. +- `kind=device` is for actual device platforms. Device plugins must declare at least one device and must provide header and test mappings for each declared device. + +## Manifest + +Each built-in plugin owns a `plugin.json` under `plugins//`. The manifest must include these fields: + +- `name`: plugin name. It must match the `plugins//` directory name. +- `kind`: either `shared` or `device`. +- `contract_version`: currently `1`. +- `devices`: device names implemented by this plugin. Use `[]` for `shared` plugins. +- `depends`: plugin names that must be enabled before this plugin. +- `cmake_entry`: CMake entry file, relative to the plugin directory. +- `source_roots`: source roots scanned by the build for enabled plugins. +- `operator_roots`: operator implementation roots scanned by wrapper generation. +- `device_headers`: mapping from owned device name to the corresponding device header. +- `test_devices`: mapping from owned device name to the existing `pytest --devices` selector. + +Example: + +```json +{ + "name": "cpu", + "kind": "device", + "contract_version": 1, + "devices": ["cpu"], + "depends": [], + "cmake_entry": "plugin.cmake", + "source_roots": ["src/native/cpu"], + "operator_roots": ["src/native/cpu/ops"], + "device_headers": {"cpu": "native/cpu/device_.h"}, + "test_devices": {"cpu": "cpu"} +} +``` + +## Path Rules + +Manifest paths are logical repository paths unless noted otherwise. `source_roots`, `operator_roots`, and `device_headers` values must be relative paths, must be non-empty strings, and must not contain `..` components. `cmake_entry` is relative to the plugin directory and must stay inside that plugin directory. Absolute paths are rejected. + +Use Markdown code spans in diagnostics when naming fields, paths, CMake options, or generated files, for example `cmake_entry`, `plugins/cpu/plugin.json`, and `INFINI_OPS_PLUGINS`. + +## CMake Contract + +Core exposes internal CMake APIs for built-in plugins: + +- `infini_ops_register_plugin`: registers plugin metadata and source roots. +- `infini_ops_register_device`: registers one device owned by a device plugin. +- `infini_ops_enable_plugin`: enables one plugin and its dependencies. +- `infini_ops_enable_requested_plugins`: resolves the requested plugin set during configure. +- `infini_ops_write_plugin_registry`: writes the build registry consumed by code generation. + +Platform entries should keep platform-specific compile definitions, source lists, and link libraries in their own `plugin.cmake` files. Core CMake should consume registered data instead of hard-coding per-platform globbing or link rules. + +## Selection + +`INFINI_OPS_PLUGINS` is the canonical configure option for selecting plugins. Legacy options such as `WITH_NVIDIA`, `WITH_ASCEND`, and `WITH_CAMBRICON` remain compatibility shims and map onto the same plugin enable path. + +`cuda-common` is a `shared` plugin. It is enabled through dependencies from CUDA-like device plugins such as `nvidia`, `iluvatar`, `metax`, `moore`, and `hygon`; it does not expose a standalone user device. + +## Code Generation + +The build writes a plugin registry after dependency resolution. `scripts/generate_wrappers.py` reads that registry and scans only enabled `operator_roots` and `device_headers`. The existing `Operator` implementation model is preserved in v1. + +## Testing + +`test_devices` preserves the current `pytest --devices ` workflow. A device plugin change should normally run tests for its mapped devices. Changes to core, this contract, code generation, or shared plugins such as `cuda-common` can affect multiple devices and should trigger broader compatibility testing. diff --git a/scripts/infini_ops_plugin_registry.py b/scripts/infini_ops_plugin_registry.py index b1b1fc7a..09778771 100644 --- a/scripts/infini_ops_plugin_registry.py +++ b/scripts/infini_ops_plugin_registry.py @@ -1,5 +1,6 @@ import json import pathlib +import posixpath KNOWN_DEVICES = { "cpu", @@ -28,86 +29,172 @@ def _as_list(value, field, plugin_name): if not isinstance(value, list): - raise ValueError(f"plugin `{plugin_name}` field `{field}` must be a `list`") + raise ValueError(f"Plugin `{plugin_name}` field `{field}` must be a `list`.") return value def _as_dict(value, field, plugin_name): if not isinstance(value, dict): - raise ValueError(f"plugin `{plugin_name}` field `{field}` must be a `dict`") + raise ValueError(f"Plugin `{plugin_name}` field `{field}` must be a `dict`.") return value +def _validate_relative_path(value, field, plugin_name): + if not isinstance(value, str) or not value: + raise ValueError( + f"Plugin `{plugin_name}` field `{field}` must contain non-empty strings." + ) + + normalized = posixpath.normpath(value) + if normalized == "." or posixpath.isabs(value) or ".." in normalized.split("/"): + raise ValueError( + f"Plugin `{plugin_name}` field `{field}` must use relative paths " + "without `..` components." + ) + + return value + + +def _validate_relative_path_list(values, field, plugin_name): + values = _as_list(values, field, plugin_name) + for value in values: + _validate_relative_path(value, field, plugin_name) + + return values + + +def _validate_relative_path_map(values, field, plugin_name): + values = _as_dict(values, field, plugin_name) + for key, value in values.items(): + if not isinstance(key, str) or not key: + raise ValueError( + f"Plugin `{plugin_name}` field `{field}` must use non-empty " + "`string` keys." + ) + _validate_relative_path(value, field, plugin_name) + + return values + + +def _is_relative_to(path, root): + try: + path.relative_to(root) + except ValueError: + return False + + return True + + +def _validate_cmake_entry(path, name, cmake_entry): + normalized = posixpath.normpath(cmake_entry) + if ( + normalized == "." + or posixpath.isabs(cmake_entry) + or ".." in normalized.split("/") + ): + raise ValueError( + f"Plugin `{name}` field `cmake_entry` must be a relative path inside " + "plugin directory." + ) + + plugin_dir = path.parent.resolve() + cmake_entry_path = (path.parent / cmake_entry).resolve() + if not _is_relative_to(cmake_entry_path, plugin_dir): + raise ValueError( + f"Plugin `{name}` field `cmake_entry` must be a relative path inside " + "plugin directory." + ) + + return cmake_entry_path + + def _load_manifest(path): data = json.loads(path.read_text(encoding="utf-8")) missing = REQUIRED_FIELDS.difference(data) if missing: raise ValueError( - f"plugin manifest `{path}` is missing required fields: " - f"{', '.join(sorted(missing))}" + f"Plugin manifest `{path}` is missing required fields: `" + f"{', '.join(sorted(missing))}`." ) name = data["name"] if name != path.parent.name: raise ValueError( - f"plugin manifest `{path}` declares name `{name}`, " - f"expected `{path.parent.name}`" + f"Plugin manifest `{path}` declares name `{name}`, " + f"expected `{path.parent.name}`." ) if data["kind"] not in {"shared", "device"}: - raise ValueError(f"plugin `{name}` has invalid kind `{data['kind']}`") + raise ValueError(f"Plugin `{name}` has invalid kind `{data['kind']}`.") if data["contract_version"] != 1: raise ValueError( - f"plugin `{name}` uses unsupported contract version " - f"`{data['contract_version']}`" + f"Plugin `{name}` uses unsupported contract version " + f"`{data['contract_version']}`." ) cmake_entry = data["cmake_entry"] if not isinstance(cmake_entry, str) or not cmake_entry: - raise ValueError(f"plugin `{name}` field `cmake_entry` must be a `string`") + raise ValueError(f"Plugin `{name}` field `cmake_entry` must be a `string`.") - if not (path.parent / cmake_entry).is_file(): + cmake_entry_path = _validate_cmake_entry(path, name, cmake_entry) + if not cmake_entry_path.is_file(): raise ValueError( - f"plugin `{name}` `CMake` entry `{cmake_entry}` was not found" + f"Plugin `{name}` `CMake` entry `{cmake_entry}` was not found." ) devices = _as_list(data["devices"], "devices", name) depends = _as_list(data["depends"], "depends", name) - _as_list(data["source_roots"], "source_roots", name) - _as_list(data["operator_roots"], "operator_roots", name) - device_headers = _as_dict(data["device_headers"], "device_headers", name) + _validate_relative_path_list(data["source_roots"], "source_roots", name) + _validate_relative_path_list(data["operator_roots"], "operator_roots", name) + device_headers = _validate_relative_path_map( + data["device_headers"], "device_headers", name + ) test_devices = _as_dict(data["test_devices"], "test_devices", name) for device in devices: if device not in KNOWN_DEVICES: - raise ValueError(f"plugin `{name}` declares unknown device `{device}`") + raise ValueError(f"Plugin `{name}` declares unknown device `{device}`.") for device in device_headers: if device not in devices: raise ValueError( - f"plugin `{name}` has device header for non-owned device `{device}`" + f"Plugin `{name}` has device header for non-owned device `{device}`." ) for device in test_devices: if device not in devices: raise ValueError( - f"plugin `{name}` has test device for non-owned device `{device}`" + f"Plugin `{name}` has test device for non-owned device `{device}`." ) if data["kind"] == "device" and not devices: - raise ValueError(f"device plugin `{name}` must declare at least one device") + raise ValueError(f"Device plugin `{name}` must declare at least one device.") + + missing_headers = sorted(set(devices).difference(device_headers)) + if missing_headers: + raise ValueError( + f"Plugin `{name}` field `device_headers` must include device " + f"`{missing_headers[0]}`." + ) + + missing_tests = sorted(set(devices).difference(test_devices)) + if missing_tests: + raise ValueError( + f"Plugin `{name}` field `test_devices` must include device " + f"`{missing_tests[0]}`." + ) if data["kind"] == "shared" and devices: - raise ValueError(f"shared plugin `{name}` must not declare devices") + raise ValueError(f"Shared plugin `{name}` must not declare devices.") for dependency in depends: if not isinstance(dependency, str): - raise ValueError(f"plugin `{name}` dependency names must be `string`s") + raise ValueError(f"Plugin `{name}` dependency names must be `string`s.") return data @@ -135,10 +222,10 @@ def visit(name): if name in visiting: cycle = " -> ".join([*visiting, name]) - raise ValueError(f"plugin dependency cycle detected: {cycle}") + raise ValueError(f"Plugin dependency cycle detected: `{cycle}`.") if name not in manifests: - raise ValueError(f"requested plugin `{name}` was not found") + raise ValueError(f"Requested plugin `{name}` was not found.") visiting.append(name) for dependency in manifests[name]["depends"]: diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py index 431f3f11..f0827b43 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -32,6 +32,24 @@ def _write_manifest(root, name, data, create_cmake_entry=True): return path +def _cpu_manifest(**overrides): + data = { + "name": "cpu", + "kind": "device", + "contract_version": 1, + "devices": ["cpu"], + "depends": [], + "cmake_entry": "plugin.cmake", + "source_roots": ["src/native/cpu"], + "operator_roots": ["src/native/cpu/ops"], + "device_headers": {"cpu": "native/cpu/device_.h"}, + "test_devices": {"cpu": "cpu"}, + } + data.update(overrides) + + return data + + def test_load_plugins_orders_dependencies_and_merges_device_metadata(tmp_path): module = _load_registry_module() plugin_root = tmp_path / "plugins" @@ -90,18 +108,7 @@ def test_load_plugins_rejects_missing_cmake_entry(tmp_path): _write_manifest( plugin_root, "cpu", - { - "name": "cpu", - "kind": "device", - "contract_version": 1, - "devices": ["cpu"], - "depends": [], - "cmake_entry": "plugin.cmake", - "source_roots": ["src/native/cpu"], - "operator_roots": ["src/native/cpu/ops"], - "device_headers": {"cpu": "native/cpu/device_.h"}, - "test_devices": {"cpu": "cpu"}, - }, + _cpu_manifest(), create_cmake_entry=False, ) @@ -113,6 +120,72 @@ def test_load_plugins_rejects_missing_cmake_entry(tmp_path): raise AssertionError("manifest with missing `CMake` entry should be rejected") +def test_load_plugins_rejects_cmake_entry_outside_plugin_dir(tmp_path): + module = _load_registry_module() + plugin_root = tmp_path / "plugins" + plugin_root.mkdir() + (plugin_root / "outside.cmake").write_text("# outside\n", encoding="utf-8") + _write_manifest( + plugin_root, + "cpu", + _cpu_manifest(cmake_entry="../outside.cmake"), + create_cmake_entry=False, + ) + + try: + module.load_plugin_registry(plugin_root, ["cpu"]) + except ValueError as exc: + assert "`cmake_entry`" in str(exc) + assert "relative path inside plugin" in str(exc) + else: + raise AssertionError("manifest with escaping `cmake_entry` should be rejected") + + +def test_load_plugins_rejects_absolute_contract_paths(tmp_path): + module = _load_registry_module() + plugin_root = tmp_path / "plugins" + plugin_root.mkdir() + _write_manifest( + plugin_root, + "cpu", + _cpu_manifest(source_roots=["/tmp/native/cpu"]), + ) + + try: + module.load_plugin_registry(plugin_root, ["cpu"]) + except ValueError as exc: + assert "`source_roots`" in str(exc) + assert "relative paths" in str(exc) + else: + raise AssertionError("manifest with absolute source root should be rejected") + + +def test_load_plugins_requires_device_header_and_test_mapping(tmp_path): + module = _load_registry_module() + + for missing_field, overrides in ( + ("device_headers", {"device_headers": {}}), + ("test_devices", {"test_devices": {}}), + ): + plugin_root = tmp_path / missing_field / "plugins" + plugin_root.mkdir(parents=True) + _write_manifest( + plugin_root, + "cpu", + _cpu_manifest(**overrides), + ) + + try: + module.load_plugin_registry(plugin_root, ["cpu"]) + except ValueError as exc: + assert f"`{missing_field}`" in str(exc) + assert "`cpu`" in str(exc) + else: + raise AssertionError( + f"device plugin without `{missing_field}` should be rejected" + ) + + def test_load_plugins_rejects_unknown_devices(tmp_path): module = _load_registry_module() plugin_root = tmp_path / "plugins" @@ -223,3 +296,17 @@ def test_builtin_plugin_manifests_cover_cpu_and_cuda_common_dependencies(): assert nvidia_registry["devices"] == ["nvidia"] assert "src/native/cuda/ops" in nvidia_registry["operator_roots"] assert "src/native/cuda/nvidia/ops" in nvidia_registry["operator_roots"] + + +def test_plugin_contract_documentation_exists_and_names_public_entrypoints(): + contract_doc = ( + pathlib.Path(__file__).resolve().parents[1] + / "docs" + / "plugin_contract.md" + ) + text = contract_doc.read_text(encoding="utf-8") + + assert "`INFINI_OPS_PLUGINS`" in text + assert "`infini_ops_register_plugin`" in text + assert "`plugin.json`" in text + assert "`cmake_entry`" in text