Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions cmake/infini_ops_plugins.cmake
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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}")
Expand All @@ -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}")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
74 changes: 74 additions & 0 deletions docs/plugin_contract.md
Original file line number Diff line number Diff line change
@@ -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/<name>/`. The manifest must include these fields:

- `name`: plugin name. It must match the `plugins/<name>/` 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<Op, Device::Type, implementation_index>` implementation model is preserved in v1.

## Testing

`test_devices` preserves the current `pytest --devices <platform>` 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.
133 changes: 110 additions & 23 deletions scripts/infini_ops_plugin_registry.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import pathlib
import posixpath

KNOWN_DEVICES = {
"cpu",
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"]:
Expand Down
Loading
Loading