From e4a04f4babb45b14cbb377561977a5e19e22fa2f Mon Sep 17 00:00:00 2001 From: eric8810 Date: Tue, 6 Oct 2026 19:25:46 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(npu):=20=E5=8F=8C=E8=8A=AF=E5=85=A5?= =?UTF-8?q?=E5=8D=B7=EF=BC=8C=E9=AA=8C=E8=BF=B9=E9=9A=8F=E8=A1=8C=20=C2=B7?= =?UTF-8?q?=20add=20Intel=20and=20AMD=20NPU=20backends=20and=20release=20p?= =?UTF-8?q?ackaging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/npm-release.yml | 48 +++- .github/workflows/npu-native.yml | 96 +++++++ CHANGELOG.md | 19 +- CMakeLists.txt | 30 ++- bindings/node/CMakeLists.txt | 19 +- bindings/node/src/addon.cpp | 139 ++++++++-- cmake/NpuRuntime.cmake | 32 +++ docs/amd-npu-acceleration.md | 164 ++---------- docs/intel-npu-acceleration.md | 4 +- docs/npu-runtime-release.md | 103 ++++++++ packages/runtime/src/load-native.cjs | 19 +- packages/runtime/src/npu-descriptor.cjs | 97 +++++++ src/core/engine.cpp | 101 ++++++- src/core/engine_factory.hpp | 8 + src/inference/amdnpu/backend.cpp | 335 ++++++++++++++++++++++++ src/inference/amdnpu/backend.hpp | 26 ++ src/inference/backend.hpp | 19 ++ src/inference/openvino/backend.cpp | 39 ++- tools/npm_release.py | 61 ++++- tools/npu/__init__.py | 1 + tools/npu/accept_qualification.py | 53 ++++ tools/npu/build_openvino.py | 78 ++++++ tools/npu/compile_amdnpu.py | 108 ++++++++ tools/npu/import_amdnpu.py | 113 ++++++++ tools/npu/openvino.lock.json | 26 ++ tools/npu/prepare_release.py | 38 +++ tools/npu/sdk.py | 310 ++++++++++++++++++++++ tools/npu/vaip_config.json | 12 + 28 files changed, 1895 insertions(+), 203 deletions(-) create mode 100644 .github/workflows/npu-native.yml create mode 100644 cmake/NpuRuntime.cmake create mode 100644 docs/npu-runtime-release.md create mode 100644 packages/runtime/src/npu-descriptor.cjs create mode 100644 src/inference/amdnpu/backend.cpp create mode 100644 src/inference/amdnpu/backend.hpp create mode 100644 tools/npu/__init__.py create mode 100644 tools/npu/accept_qualification.py create mode 100644 tools/npu/build_openvino.py create mode 100644 tools/npu/compile_amdnpu.py create mode 100644 tools/npu/import_amdnpu.py create mode 100644 tools/npu/openvino.lock.json create mode 100644 tools/npu/prepare_release.py create mode 100644 tools/npu/sdk.py create mode 100644 tools/npu/vaip_config.json diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 171c2e2..2fe5757 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -13,9 +13,15 @@ on: required: true default: false type: boolean + npu_sdk_run_id: + description: Optional run containing reviewed npu-release-sdks (Linux x64 only) + required: false + default: "" + type: string permissions: contents: read + actions: read concurrency: group: npm-release-${{ inputs.version }} @@ -83,6 +89,17 @@ jobs: - name: Bootstrap pinned native dependencies shell: bash run: python tools/bootstrap_dependencies.py --cache-dir .cache/dependencies + - name: Download accepted NPU SDKs + if: matrix.id == 'linux-x64' && inputs.npu_sdk_run_id != '' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: npu-release-sdks + path: dist/npu-sdk + run-id: ${{ inputs.npu_sdk_run_id }} + github-token: ${{ github.token }} + - name: Verify accepted NPU evidence and runtime identities + if: matrix.id == 'linux-x64' && inputs.npu_sdk_run_id != '' + run: python tools/npu/prepare_release.py --sdk-root dist/npu-sdk --github-env "$GITHUB_ENV" - name: Fetch the pinned PDF renderer for npm bundling (Unix) if: runner.os != 'Windows' shell: bash @@ -145,6 +162,8 @@ jobs: -DLIGHT_OCR_DEPENDENCY_CACHE_DIR="$PWD/.cache/dependencies" -DLIGHT_OCR_ONNXRUNTIME_FLAVOR=${{ matrix.runtime_flavor }} -DLIGHT_OCR_WEBGPU_SDK_DIR="$PWD/dist/webgpu-sdk/${{ matrix.id }}" + -DLIGHT_OCR_OPENVINO_SDK_DIR="$OPENVINO_SDK_DIR" + -DLIGHT_OCR_AMDNPU_SDK_DIR="$AMDNPU_SDK_DIR" -DLIGHT_OCR_BUILD_NODE=ON -DLIGHT_OCR_BUILD_TESTS=OFF -DLIGHT_OCR_BUILD_TOOLS=OFF @@ -176,17 +195,24 @@ jobs: --model-free - name: Stage native package input shell: bash - run: >- - python tools/npm_release.py stage-native - --platform-id "${{ matrix.id }}" - --build-dir build-npm - --configuration Release - --metadata-dir "reports/npm/${{ matrix.id }}" - --pdfium-dir .cache/pdfium-package/node_modules/pdfium-native - --output-dir "dist/native-input/${{ matrix.id }}" - --runtime-flavor "${{ matrix.runtime_flavor }}" - --webgpu-artifact-manifest - "dist/webgpu-sdk/${{ matrix.id }}/artifact-manifest.json" + run: | + npu_arguments=() + if [[ -n "${OPENVINO_SDK_DIR:-}" ]]; then + npu_arguments+=(--openvino-sdk-dir "$OPENVINO_SDK_DIR") + fi + if [[ -n "${AMDNPU_SDK_DIR:-}" ]]; then + npu_arguments+=(--amdnpu-sdk-dir "$AMDNPU_SDK_DIR") + fi + python tools/npm_release.py stage-native \ + --platform-id "${{ matrix.id }}" \ + --build-dir build-npm \ + --configuration Release \ + --metadata-dir "reports/npm/${{ matrix.id }}" \ + --pdfium-dir .cache/pdfium-package/node_modules/pdfium-native \ + --output-dir "dist/native-input/${{ matrix.id }}" \ + --runtime-flavor "${{ matrix.runtime_flavor }}" \ + --webgpu-artifact-manifest "dist/webgpu-sdk/${{ matrix.id }}/artifact-manifest.json" \ + "${npu_arguments[@]}" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: native-${{ matrix.id }} diff --git a/.github/workflows/npu-native.yml b/.github/workflows/npu-native.yml new file mode 100644 index 0000000..549e46c --- /dev/null +++ b/.github/workflows/npu-native.yml @@ -0,0 +1,96 @@ +name: NPU native candidates + +on: + workflow_dispatch: + inputs: + amd_sdk_run_id: + description: Optional run containing amdnpu-candidate-sdk imported from Ryzen AI 1.8 + required: false + default: "" + type: string + reviewed_reports_run_id: + description: Optional run containing npu-reviewed-reports with openvino/ and amdnpu/ JSON reports + required: false + default: "" + type: string + +permissions: + contents: read + actions: read + +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: "22" + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.11" + - name: Assemble pinned Intel native SDK + run: python tools/npu/build_openvino.py --package-cache .cache/npu --output-dir dist/npu-sdk/openvino + - name: Import acquired AMD candidate + if: inputs.amd_sdk_run_id != '' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: amdnpu-candidate-sdk + path: dist/npu-sdk/amdnpu + run-id: ${{ inputs.amd_sdk_run_id }} + github-token: ${{ github.token }} + - name: Build the native addon with packaged NPU SDKs + shell: bash + run: | + python tools/bootstrap_dependencies.py --cache-dir .cache/dependencies + node_version="$(node -p process.versions.node)" + npx --yes node-gyp@11.4.2 install "$node_version" --devdir "$PWD/.cache/node-gyp" + amd_arguments=() + if [[ -d dist/npu-sdk/amdnpu ]]; then + amd_arguments+=(-DLIGHT_OCR_AMDNPU_SDK_DIR="$PWD/dist/npu-sdk/amdnpu") + fi + cmake -S . -B build-npu -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DLIGHT_OCR_DEPENDENCY_CACHE_DIR="$PWD/.cache/dependencies" \ + -DLIGHT_OCR_OPENVINO_SDK_DIR="$PWD/dist/npu-sdk/openvino" \ + -DLIGHT_OCR_BUILD_NODE=ON -DLIGHT_OCR_BUILD_TESTS=OFF -DLIGHT_OCR_BUILD_TOOLS=OFF \ + -DLIGHT_OCR_NODE_INCLUDE_DIR="$PWD/.cache/node-gyp/$node_version/include/node" \ + -DLIGHT_OCR_NODE_EXECUTABLE="$(command -v node)" "${amd_arguments[@]}" + cmake --build build-npu --parallel 2 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: npu-candidates + path: | + dist/npu-sdk/ + build-npu/node-runtime/ + if-no-files-found: error + - name: Fetch reviewed device reports + if: inputs.reviewed_reports_run_id != '' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: npu-reviewed-reports + path: reports/npu-reviewed + run-id: ${{ inputs.reviewed_reports_run_id }} + github-token: ${{ github.token }} + - name: Bind reviewed reports to immutable SDKs + if: inputs.reviewed_reports_run_id != '' + shell: bash + run: | + for provider in openvino amdnpu; do + [[ -d "dist/npu-sdk/$provider" ]] || continue + reports=() + for report in "reports/npu-reviewed/$provider/"*.json; do + [[ -f "$report" ]] || continue + reports+=(--report "$report") + done + python tools/npu/accept_qualification.py \ + --sdk-dir "dist/npu-sdk/$provider" "${reports[@]}" \ + --qualification-id "$provider-linux-x64-reviewed-v1" \ + --output-dir "dist/npu-release/$provider" + done + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: inputs.reviewed_reports_run_id != '' + with: + name: npu-release-sdks + path: dist/npu-release/ + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index b78c84d..03c0f88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,16 +11,19 @@ This file records user-visible changes to `light-ocr`. Published artifact detail runtime at run time, run detection and 20-bucket recognition on the NPU, and place `openvino` first in a qualification-only Auto policy (`openvino → webgpu → cpu`); hosts without an NPU skip it with - `adapter_unavailable`. Published npm packages do not include OpenVINO yet, so + `adapter_unavailable`. Added a pinned native SDK extractor, package-local + runtime descriptor 2.1, numeric driver/compiler checks, and reviewed-evidence + release staging. Published npm packages do not include OpenVINO yet, so `provider: "openvino"` returns `unsupported_capability` there. See [Intel NPU 加速技术方案](docs/intel-npu-acceleration.md). -- Reserved the AMD NPU execution provider name (`provider: "amdnpu"`) across the - C++ enum, Node addon, TypeScript types, CLI flags, and the server - `EXECUTION_MODE`. No build ships an AMD NPU backend yet, so any runtime that - lists it fails fast with `provider_abi_mismatch` and explicit requests on - current packages return `unsupported_capability`; the contract keeps only the - default precision (`precision: "auto"`) until the INT8/BF16 route is chosen. - See [AMD NPU 加速技术方案(预研)](docs/amd-npu-acceleration.md). +- Added a source-level AMD NPU backend (`provider: "amdnpu"`) for Linux x64 + glibc and Ryzen AI STX/KRK devices. It isolates the vendor ORT runtime, + loads 20 immutable BF16 recognition contexts, and runs detection on CPU. + Added model compilation and SDK import tools, descriptor validation, + license/SBOM staging, and candidate/release workflows. AMD hardware and + model quality qualification remain pending; current published packages + return `unsupported_capability`. See [AMD NPU 加速技术方案](docs/amd-npu-acceleration.md) + and [NPU SDK 构建与发布](docs/npu-runtime-release.md). ## [0.5.8] - 2026-09-24 diff --git a/CMakeLists.txt b/CMakeLists.txt index dfe502d..6cce915 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,8 @@ set(LIGHT_OCR_OPENVINO_SDK_DIR "" CACHE PATH "OpenVINO SDK root with include/openvino/c; enables the Intel NPU backend on Linux x64") set(LIGHT_OCR_OPENVINO_C_LIBRARY "" CACHE FILEPATH "OpenVINO C runtime loaded by direct C++ callers; defaults to /lib/libopenvino_c.so") +set(LIGHT_OCR_AMDNPU_SDK_DIR "" CACHE PATH + "Verified Ryzen AI runtime SDK with precompiled BF16 recognition buckets (Linux x64 glibc)") if(LIGHT_OCR_ENABLE_SANITIZERS AND LIGHT_OCR_ENABLE_THREAD_SANITIZER) message(FATAL_ERROR "Address/Undefined sanitizers and ThreadSanitizer require separate builds") @@ -143,6 +145,9 @@ if(LIGHT_OCR_HAS_WEBGPU) endif() set(LIGHT_OCR_HAS_OPENVINO OFF) +include(NpuRuntime) +set(LIGHT_OCR_OPENVINO_QUALIFICATION_BUILD ON) +set(LIGHT_OCR_OPENVINO_QUALIFICATION_ID "builtin-openvino-v1") if(LIGHT_OCR_OPENVINO_SDK_DIR) if(NOT (CMAKE_SYSTEM_NAME STREQUAL "Linux" AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64)$")) @@ -154,7 +159,19 @@ if(LIGHT_OCR_OPENVINO_SDK_DIR) "LIGHT_OCR_OPENVINO_SDK_DIR has no include/openvino/c/openvino.h: ${LIGHT_OCR_OPENVINO_SDK_DIR}") endif() if(NOT LIGHT_OCR_OPENVINO_C_LIBRARY) - set(LIGHT_OCR_OPENVINO_C_LIBRARY "${LIGHT_OCR_OPENVINO_SDK_DIR}/lib/libopenvino_c.so") + if(EXISTS "${LIGHT_OCR_OPENVINO_SDK_DIR}/sdk-manifest.json") + light_ocr_validate_npu_sdk(openvino "${LIGHT_OCR_OPENVINO_SDK_DIR}") + set(LIGHT_OCR_OPENVINO_C_LIBRARY "${LIGHT_OCR_OPENVINO_VERIFIED_LIBRARY}") + elseif(LIGHT_OCR_BUILD_NODE) + message(FATAL_ERROR "Node NPU builds require a verified SDK manifest") + else() + set(LIGHT_OCR_OPENVINO_C_LIBRARY "${LIGHT_OCR_OPENVINO_SDK_DIR}/lib/libopenvino_c.so") + endif() + elseif(LIGHT_OCR_BUILD_NODE) + light_ocr_validate_npu_sdk(openvino "${LIGHT_OCR_OPENVINO_SDK_DIR}") + if(NOT LIGHT_OCR_OPENVINO_C_LIBRARY STREQUAL LIGHT_OCR_OPENVINO_VERIFIED_LIBRARY) + message(FATAL_ERROR "The OpenVINO runtime must match the verified SDK") + endif() endif() if(NOT EXISTS "${LIGHT_OCR_OPENVINO_C_LIBRARY}") message(FATAL_ERROR "OpenVINO C runtime not found: ${LIGHT_OCR_OPENVINO_C_LIBRARY}") @@ -166,10 +183,21 @@ if(LIGHT_OCR_OPENVINO_SDK_DIR) target_include_directories(light_ocr_core SYSTEM PRIVATE "${_light_ocr_openvino_include}") target_compile_definitions(light_ocr_core PRIVATE LIGHT_OCR_HAS_OPENVINO=1 + LIGHT_OCR_OPENVINO_QUALIFICATION_BUILD=$ LIGHT_OCR_OPENVINO_DEFAULT_LIBRARY="${LIGHT_OCR_OPENVINO_C_LIBRARY}") target_link_libraries(light_ocr_core PRIVATE ${CMAKE_DL_LIBS}) endif() +set(LIGHT_OCR_HAS_AMDNPU OFF) +if(LIGHT_OCR_AMDNPU_SDK_DIR) + light_ocr_validate_npu_sdk(amdnpu "${LIGHT_OCR_AMDNPU_SDK_DIR}") + set(LIGHT_OCR_HAS_AMDNPU ON) + target_sources(light_ocr_core PRIVATE src/inference/amdnpu/backend.cpp) + target_compile_definitions(light_ocr_core PRIVATE LIGHT_OCR_HAS_AMDNPU=1 + LIGHT_OCR_AMDNPU_QUALIFICATION_BUILD=$) + target_link_libraries(light_ocr_core PRIVATE ${CMAKE_DL_LIBS}) +endif() + if(MSVC) target_compile_options(light_ocr_core PRIVATE /W4 /WX /permissive- /EHsc /fp:strict) else() diff --git a/bindings/node/CMakeLists.txt b/bindings/node/CMakeLists.txt index bf78134..6dc1556 100644 --- a/bindings/node/CMakeLists.txt +++ b/bindings/node/CMakeLists.txt @@ -148,7 +148,9 @@ else() set(_light_ocr_node_runtime_version "1.22.0") set(_light_ocr_node_runtime_abi "onnxruntime-c-api-22") endif() -if(LIGHT_OCR_HAS_WEBGPU AND LIGHT_OCR_WEBGPU_QUALIFICATION_BUILD) +if((LIGHT_OCR_HAS_WEBGPU AND LIGHT_OCR_WEBGPU_QUALIFICATION_BUILD) OR + (LIGHT_OCR_HAS_OPENVINO AND LIGHT_OCR_OPENVINO_QUALIFICATION_BUILD) OR + (LIGHT_OCR_HAS_AMDNPU AND LIGHT_OCR_AMDNPU_QUALIFICATION_BUILD)) set(_light_ocr_node_qualification_only 1) set(_light_ocr_node_released 0) else() @@ -165,9 +167,13 @@ target_compile_definitions(light_ocr_node PRIVATE LIGHT_OCR_NODE_CPU_QUALIFICATION_ID="cpu-baseline-v1" LIGHT_OCR_NODE_APPLE_QUALIFICATION_ID="apple-open-macos-v1" LIGHT_OCR_NODE_WEBGPU_QUALIFICATION_ID="${LIGHT_OCR_WEBGPU_QUALIFICATION_ID}" + LIGHT_OCR_NODE_OPENVINO_QUALIFICATION_ID="${LIGHT_OCR_OPENVINO_QUALIFICATION_ID}" + LIGHT_OCR_NODE_AMDNPU_QUALIFICATION_ID="${LIGHT_OCR_AMDNPU_QUALIFICATION_ID}" LIGHT_OCR_NODE_QUALIFICATION_ONLY=${_light_ocr_node_qualification_only} LIGHT_OCR_NODE_RELEASED=${_light_ocr_node_released} LIGHT_OCR_NODE_HAS_APPLE=${_light_ocr_node_has_apple_provider} + LIGHT_OCR_NODE_HAS_OPENVINO=$ + LIGHT_OCR_NODE_HAS_AMDNPU=$ LIGHT_OCR_NODE_HAS_WEBGPU=$) add_custom_command(TARGET light_ocr_node POST_BUILD COMMAND "${CMAKE_COMMAND}" @@ -185,6 +191,17 @@ add_custom_command(TARGET light_ocr_node POST_BUILD -P "${PROJECT_SOURCE_DIR}/cmake/StageNodeRuntime.cmake" VERBATIM) +foreach(_provider openvino amdnpu) + string(TOUPPER "${_provider}" _upper) + if(LIGHT_OCR_HAS_${_upper}) + add_custom_command(TARGET light_ocr_node POST_BUILD + COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tools/npu/sdk.py" + --sdk-dir "${LIGHT_OCR_${_upper}_SDK_DIR}" + --provider "${_provider}" --stage-dir "${_light_ocr_node_stage_dir}" + VERBATIM) + endif() +endforeach() + if(LIGHT_OCR_BUILD_TESTS) if(NOT LIGHT_OCR_NODE_EXECUTABLE) find_program(LIGHT_OCR_NODE_EXECUTABLE NAMES node) diff --git a/bindings/node/src/addon.cpp b/bindings/node/src/addon.cpp index 86c2fe7..89959f5 100644 --- a/bindings/node/src/addon.cpp +++ b/bindings/node/src/addon.cpp @@ -142,35 +142,51 @@ napi_value string_array(napi_env env, const std::vector& values) { } std::vector compiled_available_providers() { + std::vector result; #if LIGHT_OCR_NODE_HAS_APPLE - return {"apple", "cpu"}; + result = {"apple", "cpu"}; #elif LIGHT_OCR_NODE_HAS_WEBGPU - return {"cpu", "webgpu"}; + result = {"cpu", "webgpu"}; #else - return {"cpu"}; + result = {"cpu"}; #endif +#if LIGHT_OCR_NODE_HAS_OPENVINO + result.push_back("openvino"); +#endif +#if LIGHT_OCR_NODE_HAS_AMDNPU + result.push_back("amdnpu"); +#endif + std::sort(result.begin(), result.end()); + return result; } std::vector compiled_provider_qualification_ids() { -#if LIGHT_OCR_NODE_HAS_APPLE - return {LIGHT_OCR_NODE_APPLE_QUALIFICATION_ID, - LIGHT_OCR_NODE_CPU_QUALIFICATION_ID}; -#elif LIGHT_OCR_NODE_HAS_WEBGPU - return {LIGHT_OCR_NODE_CPU_QUALIFICATION_ID, - LIGHT_OCR_NODE_WEBGPU_QUALIFICATION_ID}; -#else - return {LIGHT_OCR_NODE_CPU_QUALIFICATION_ID}; -#endif + std::vector result; + for (const auto& provider : compiled_available_providers()) { + if (provider == "cpu") result.push_back(LIGHT_OCR_NODE_CPU_QUALIFICATION_ID); + if (provider == "apple") result.push_back(LIGHT_OCR_NODE_APPLE_QUALIFICATION_ID); + if (provider == "webgpu") result.push_back(LIGHT_OCR_NODE_WEBGPU_QUALIFICATION_ID); + if (provider == "openvino") result.push_back(LIGHT_OCR_NODE_OPENVINO_QUALIFICATION_ID); + if (provider == "amdnpu") result.push_back(LIGHT_OCR_NODE_AMDNPU_QUALIFICATION_ID); + } + return result; } std::vector compiled_ordered_candidates() { + std::vector result; +#if LIGHT_OCR_NODE_HAS_OPENVINO + result.push_back("openvino"); +#endif +#if LIGHT_OCR_NODE_HAS_AMDNPU + result.push_back("amdnpu"); +#endif #if LIGHT_OCR_NODE_HAS_APPLE - return {"apple", "cpu"}; + result.push_back("apple"); #elif LIGHT_OCR_NODE_HAS_WEBGPU - return {"webgpu", "cpu"}; -#else - return {"cpu"}; + result.push_back("webgpu"); #endif + result.push_back("cpu"); + return result; } napi_value create_runtime_contract(napi_env env) { @@ -546,9 +562,10 @@ internal::RuntimePolicy parse_runtime_policy(napi_env env, napi_value value) { "runtimeAbi", "qualificationOnly", "released", "orderedCandidates", "availableProviders", "providerQualificationIds", "webgpuProviderLibrary", "webgpuProviderBytes", - "webgpuProviderSha256"}; + "webgpuProviderSha256", "npuProviders"}; reject_unknown_properties(env, value, allowed, "runtime policy"); for (const auto& name : allowed) { + if (name == "npuProviders") continue; if (!has_own(env, value, name.c_str())) { throw AddonFailure("package_load_failed", "runtime policy is missing required field: " + name); @@ -566,7 +583,7 @@ internal::RuntimePolicy parse_runtime_policy(napi_env env, napi_value value) { std::uint32_t length = 0; check(env, napi_get_array_length(env, input, &length), "get runtime policy array length"); - if (length == 0 || length > 3) { + if (length == 0 || length > 5) { throw AddonFailure("package_load_failed", std::string("runtime policy ") + name + " has invalid length"); } @@ -621,6 +638,76 @@ internal::RuntimePolicy parse_runtime_policy(napi_env env, napi_value value) { policy.provider_qualification_ids = read_strings("providerQualificationIds", false); + if (has_own(env, value, "npuProviders")) { + const auto npu = get_named(env, value, "npuProviders"); + require_object(env, npu, "NPU providers"); + reject_unknown_properties(env, npu, {"openvino", "amdnpu"}, "NPU providers"); + for (const auto& name : {"openvino", "amdnpu"}) { + if (!has_own(env, npu, name)) continue; + const auto provider = get_named(env, npu, name); + require_object(env, provider, "NPU provider"); + reject_unknown_properties(env, provider, + {"providerLibrary", "providerBytes", "providerSha256", "configuration"}, "NPU provider"); + internal::RuntimeArtifact artifact; + artifact.path = get_string(env, get_named(env, provider, "providerLibrary"), "NPU provider library"); + artifact.bytes = get_safe_u64(env, get_named(env, provider, "providerBytes"), "NPU provider bytes"); + artifact.sha256 = get_string(env, get_named(env, provider, "providerSha256"), "NPU provider hash"); + const auto configuration = get_named(env, provider, "configuration"); + require_object(env, configuration, "NPU configuration"); + if (std::string(name) == "openvino") { + policy.openvino_runtime_library = artifact.path; + policy.openvino_runtime_bytes = artifact.bytes; + policy.openvino_runtime_sha256 = artifact.sha256; + reject_unknown_properties(env, configuration, + {"runtimeVersionPrefix", "minimumDriverVersion", "minimumCompilerVersion"}, "OpenVINO configuration"); + policy.openvino_runtime_version_prefix = get_string(env, + get_named(env, configuration, "runtimeVersionPrefix"), "OpenVINO runtime version"); + policy.openvino_minimum_driver_version = get_string(env, + get_named(env, configuration, "minimumDriverVersion"), "OpenVINO minimum driver"); + policy.openvino_minimum_compiler_version = get_string(env, + get_named(env, configuration, "minimumCompilerVersion"), "OpenVINO minimum compiler"); + } else { + policy.amdnpu_runtime = artifact; + reject_unknown_properties(env, configuration, + {"sourceModelSha256", "recognitionModels", "compilerConfiguration"}, "AMD NPU configuration"); + const auto compiler_configuration = get_named(env, configuration, "compilerConfiguration"); + require_object(env, compiler_configuration, "AMD NPU compiler configuration"); + reject_unknown_properties(env, compiler_configuration, {"path", "bytes", "sha256"}, + "AMD NPU compiler configuration"); + policy.amdnpu_compiler_configuration.path = get_string(env, + get_named(env, compiler_configuration, "path"), "AMD NPU compiler configuration path"); + policy.amdnpu_compiler_configuration.bytes = get_safe_u64(env, + get_named(env, compiler_configuration, "bytes"), "AMD NPU compiler configuration bytes"); + policy.amdnpu_compiler_configuration.sha256 = get_string(env, + get_named(env, compiler_configuration, "sha256"), "AMD NPU compiler configuration hash"); + policy.amdnpu_source_model_sha256 = get_string(env, + get_named(env, configuration, "sourceModelSha256"), "AMD NPU source model hash"); + const auto models = get_named(env, configuration, "recognitionModels"); + bool array = false; + check(env, napi_is_array(env, models, &array), "check AMD NPU model array"); + std::uint32_t length = 0; + if (!array) throw AddonFailure("package_load_failed", "AMD NPU models must be an array"); + check(env, napi_get_array_length(env, models, &length), "get AMD NPU model count"); + if (length != 20) throw AddonFailure("package_load_failed", "AMD NPU requires 20 width buckets"); + for (std::uint32_t i = 0; i < length; ++i) { + napi_value entry = nullptr; + check(env, napi_get_element(env, models, i, &entry), "get AMD NPU model"); + require_object(env, entry, "AMD NPU model"); + reject_unknown_properties(env, entry, {"width", "artifact"}, "AMD NPU model"); + internal::AmdNpuRecognitionModel model; + model.width = get_u32(env, get_named(env, entry, "width"), "AMD NPU model width", 1); + const auto file = get_named(env, entry, "artifact"); + require_object(env, file, "AMD NPU model artifact"); + reject_unknown_properties(env, file, {"path", "bytes", "sha256"}, "AMD NPU model artifact"); + model.artifact.path = get_string(env, get_named(env, file, "path"), "AMD NPU model path"); + model.artifact.bytes = get_safe_u64(env, get_named(env, file, "bytes"), "AMD NPU model bytes"); + model.artifact.sha256 = get_string(env, get_named(env, file, "sha256"), "AMD NPU model hash"); + policy.amdnpu_recognition_models.push_back(std::move(model)); + } + } + } + } + auto available = policy.available_providers; std::sort(available.begin(), available.end()); #if LIGHT_OCR_NODE_HAS_WEBGPU @@ -666,6 +753,22 @@ internal::RuntimePolicy parse_runtime_policy(napi_env env, napi_value value) { "package_load_failed", "Runtime descriptor is incompatible with the native addon ABI or capabilities"); } + for (const auto& name : {"openvino", "amdnpu"}) { + const bool present = std::find(available.begin(), available.end(), name) != available.end(); + const auto library = std::string(name) == "openvino" ? policy.openvino_runtime_library + : policy.amdnpu_runtime.path; + const auto hash = std::string(name) == "openvino" ? policy.openvino_runtime_sha256 + : policy.amdnpu_runtime.sha256; + const auto bytes = std::string(name) == "openvino" ? policy.openvino_runtime_bytes + : policy.amdnpu_runtime.bytes; + if (present != !library.empty() || (present && + (!std::filesystem::u8path(library).is_absolute() || bytes == 0 || hash.size() != 64 || + !std::all_of(hash.begin(), hash.end(), [](char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + })))) { + throw AddonFailure("package_load_failed", "NPU runtime artifact contract is invalid"); + } + } return policy; } diff --git a/cmake/NpuRuntime.cmake b/cmake/NpuRuntime.cmake new file mode 100644 index 0000000..25275ab --- /dev/null +++ b/cmake/NpuRuntime.cmake @@ -0,0 +1,32 @@ +# NPU SDKs are verified before headers, binaries or release metadata are used. +function(light_ocr_validate_npu_sdk provider directory) + if(NOT (CMAKE_SYSTEM_NAME STREQUAL "Linux" AND + CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64)$")) + message(FATAL_ERROR "NPU SDKs currently target Linux x64 glibc") + endif() + if(LIGHT_OCR_TARGET_LIBC STREQUAL "musl") + message(FATAL_ERROR "NPU SDKs do not support musl") + endif() + find_package(Python3 REQUIRED COMPONENTS Interpreter) + set(Python3_EXECUTABLE "${Python3_EXECUTABLE}" PARENT_SCOPE) + execute_process(COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/tools/npu/sdk.py" + --sdk-dir "${directory}" --provider "${provider}" + --emit-header "${CMAKE_BINARY_DIR}/generated/${provider}_defaults.hpp" + RESULT_VARIABLE _result OUTPUT_VARIABLE _output ERROR_VARIABLE _error) + if(NOT _result EQUAL 0) + message(FATAL_ERROR "Invalid ${provider} SDK: ${_output}${_error}") + endif() + file(READ "${directory}/sdk-manifest.json" _manifest) + string(JSON _qualification GET "${_manifest}" qualificationId) + string(JSON _qualification_only GET "${_manifest}" qualificationOnly) + string(JSON _runtime_library GET "${_manifest}" runtimeLibrary) + string(TOUPPER "${provider}" _upper) + target_include_directories(light_ocr_core PRIVATE "${CMAKE_BINARY_DIR}/generated") + target_compile_definitions(light_ocr_core PRIVATE + LIGHT_OCR_${_upper}_PACKAGE_SDK=1 + LIGHT_OCR_${_upper}_QUALIFICATION_ID="${_qualification}") + set(LIGHT_OCR_${_upper}_QUALIFICATION_ID "${_qualification}" PARENT_SCOPE) + set(LIGHT_OCR_${_upper}_QUALIFICATION_BUILD "${_qualification_only}" PARENT_SCOPE) + set(LIGHT_OCR_${_upper}_VERIFIED_LIBRARY "${directory}/${_runtime_library}" PARENT_SCOPE) +endfunction() diff --git a/docs/amd-npu-acceleration.md b/docs/amd-npu-acceleration.md index 624c5ec..8529083 100644 --- a/docs/amd-npu-acceleration.md +++ b/docs/amd-npu-acceleration.md @@ -1,153 +1,37 @@ -# AMD NPU 加速技术方案(预研) +# AMD NPU 加速技术方案 -状态:Phase 0 无硬件预研。所有结论来自 AMD 官方文档与社区证据,**无真机数据,未立项开发**;本阶段只回答"AMD NPU 路线值不值得投、无硬件能推进到哪一步、真机需要验证什么"。 +更新时间:2026-10-06。状态:Linux x64 glibc 后端与打包改造已实现,Ryzen AI SDK、编译产物及真机验收尚缺。当前已发布 npm 包不含 AMD NPU 运行时。 -更新时间:2026-09-29 +## 范围与模型路线 -范围:AMD Ryzen AI NPU(XDNA/XDNA2:Phoenix、Hawk Point、Strix、Strix Halo、Krackan Point)。AMD GPU(iGPU/dGPU)继续走已发布的 Native WebGPU 主线,不在本方案内。关联文档:[Linux Device 加速技术方案 §6](linux-device-acceleration.md#6-厂商-gpunpu-路线)、[Intel NPU 加速技术方案](intel-npu-acceleration.md)(Direct OpenVINO 后端,本文多处对照)、[D111](decisions.md#d111--freeze-a-provider-neutral-execution-contract-before-enabling-accelerators)、[D112](decisions.md#d112--use-platform-aware-auto-with-creation-time-ordered-fallback)、[D113](decisions.md#d113--derive-locked-fp16-artifacts-for-the-webgpu-provider)(派生物规则)。 +首期针对 STX/KRK(Ryzen AI 300、Max 等 XDNA2),使用 BF16 识别模型,检测保持 ORT CPU。PHX/HPT 的 INT8 路线、Windows、musl 均未实现。`precision` 仅接受 `auto`;`cpuPartition=forbid` 不支持此路线。 -## 1. 结论 +Ryzen AI 1.8 的 Linux 文档支持 CNN BF16/INT8 编译和执行。旧版预研把 release notes 中 LLM 的 Linux 模型生成限制推广到了 CNN OCR,现予纠正。[AMD Linux 文档](https://ryzenai.docs.amd.com/en/latest/linux.html)、[Release Notes](https://ryzenai.docs.amd.com/en/latest/relnotes.html)。 -AMD NPU 与 Intel NPU 是**两条不同性质的路线**,不能把 Intel 方案直接平移: +BF16 路线由 VAIML 编译 FP32 ONNX,生成可嵌入权重与微码的 EPContext;无需引入 INT8 校准集。C++ 部署使用预编译 BF16 context,保留 FP32 输入输出。VitisAI 自动将未支持的算子分配给 CPU,诊断因此明确包含 CPU provider。[AMD 模型编译与部署](https://ryzenai.docs.amd.com/en/latest/modelrun.html)。 -- **官方推理栈以 Windows 为第一平台。** ORT 官方文档中 Vitis AI EP 对 Ryzen AI 的支持平台只列 Windows(Linux 仅覆盖 Zynq/Versal 等 Arm SoC);Ryzen AI Software 从 1.7.1 起提供 Linux installer,但 Linux 属于后补平台,社区在 ORT 1.20.1 Linux 上仍遇到 voe 模块缺失、无法 offload 到 NPU 的问题(RyzenAI-SW #341)。**若坚持 Linux x64 优先,AMD NPU 的生态成熟度显著低于 Intel NPU(OpenVINO Linux 一等公民)。** -- **模型不能直接跑,必须量化派生。** Vitis AI EP 只接受 INT8 或 BF16 输入模型:Phoenix/Hawk Point 仅支持 CNN INT8;Strix/Krackan Point 支持 INT8 与 BF16。现有 locked FP32 ONNX 需要经过 Quark/Vitis AI Quantizer 量化,属于 D113 意义上的新 immutable 派生物,且 INT8 需要校准与独立质量 Gate。Intel NPU 的"直接读上游 ONNX + FP16 编译器默认"在这里不成立。 -- **模型编译(NPU 微码生成)在 Linux 上不支持。** Ryzen AI Software 1.8.0 明确"Model generation is not supported on Linux in this release; models generated on Windows are compatible with Linux"——编译产物需要一个 Windows 构建步骤,派生物流水线必须为此设计(类似 Intel 方案排除的"离线 IR 派生物",但在 AMD 这边是强制项)。 -- **更贴近现有 WebGPU 路线而非 Intel Direct 路线。** Vitis AI EP 是 ONNX Runtime 的动态库 provider(模型在 session 创建时编译为微码,首次可能耗时数分钟,之后缓存),与现有 `OnnxSession` + dlopen provider 的结构同构;复用 WebGPU 的 provider 注册框架与 D113 派生物流程,比新建 Direct 后端成本低。 -- **无硬件可以推进到"除了真机证据之外的一切"**:契约层、量化派生、Windows 编译产物锁定、栈审计、CI 失败路径(§8)。Spike 与 Gate 必须真机,无模拟器可用(§9)。 -- **优先级判断:AMD 平台的"加速"诉求应先由 WebGPU 满足。** Strix iGPU 走 Mesa RADV/Vulkan 属于现有开放兼容路径,补 Provider Gate 即可;NPU 的独立收益(释放 CPU/GPU、省电)在真机 Spike 证实之前不构成开发理由。 +## 已完成代码 -## 2. 目标与非目标(预研阶段) +- `src/inference/amdnpu/backend.cpp`:通过独立 glibc link namespace 装载 AMD 自带 ORT C API 20,避免绑定到本进程的 CPU/WebGPU ORT;AMD runtime 对象始终由对应 API 释放。 +- 校验源识别模型哈希、VAIML 配置和 20 个静态宽度桶;识别形状为 `[1,3,48,width]`,批次为 1。编译结果的字典类别数必须与 bundle 一致。 +- 设备检查要求可读写的 AMD STX/KRK `/dev/accel` 节点;无设备返回 `adapter_unavailable`。库或模型哈希不符属于不可恢复错误,不能静默跳到 CPU。 +- detector 使用 CPU,recognition 使用 `VitisAIExecutionProvider` 和其允许的 CPU 分区。只有首次会话及对应桶实际创建成功后才选中该候选;后续推理失败直接报告错误。 +- `tools/npu/compile_amdnpu.py`:在 vendor SDK Python 环境编译所有桶,要求存在嵌入式 VitisAI EPContext,并重新打开部署产物。 +- `tools/npu/import_amdnpu.py`:从已取得的 SDK 部署目录导入原生库,设置包内 RPATH、检查 ELF 依赖闭包、记录变更前后哈希,并锁定模型、配置及许可证。 +- CMake、Node descriptor 2.1、JS loader、npm staging、SBOM 和候选/正式发布流程均已接入。构建入口与报告格式见 [NPU SDK 构建与发布](npu-runtime-release.md)。 -### 2.1 目标 +## 发布资格 -1. 确认 AMD NPU 路线的技术可行性与前置条件,产出立项决策所需的证据清单。 -2. 明确无硬件阶段可交付物,并尽可能在无硬件环境下完成。 -3. 给出真机 Spike 的最小设备矩阵与验证项。 +SDK 导入默认 `qualificationOnly: true`。候选构建包含 `amdnpu` 时,可按 `openvino → amdnpu → webgpu → cpu` 的包含项顺序探索 Auto;当前已发布包的 Auto 策略保持原有行为。 -### 2.2 非目标 +接受报告必须绑定同一 SDK 的 artifact-set SHA-256 和配置,包含审阅人、设备型号与证据,并通过质量、性能、冷启动、缓存、生命周期、失败路径、Auto、离线分发、真实 NPU placement 和再分发许可全部门槛。正式 npm staging 不接受候选 SDK。 -- 本阶段不写后端代码,不接入 runtime descriptor,不改动 Auto policy。 -- 不评估 AMD GPU 的 WebGPU Provider Gate(另行按现有流程跑)。 -- 不覆盖 Xilinx SoC(Zynq/Versal)——与本项目无 Tier 1 交集。 +## 仍需真机完成的工作 -## 3. 已确认事实(官方文档与社区证据) +1. 取得 Ryzen AI 1.8 Linux SDK 和 STX/KRK 主机;确认独立 namespace 装载、XRT/amdxdna 驱动及 VitisAI C API 20 能协同工作。 +2. 编译真实 PP-OCRv6 模型,核对 20 桶的 NPU 分区与 FP32 CPU goldens;当前没有生成或伪造这些产物。 +3. 对完整 locked corpus 记录逐行文本、框与置信度差异,审阅 BF16 误差;测量冷/热延迟、CPU 时间和 RSS。 +4. 覆盖 20 次关闭/重建、驱动兼容、权限不足、无设备、损坏产物,以及与 WebGPU 共存和回退。 +5. 核实 SDK/模型再分发条款和完整第三方 notices,再创建 accepted SDK。 -| 事实 | 来源 | -| --- | --- | -| Vitis AI EP 官方支持矩阵:Ryzen AI 仅 Windows;Linux 仅 Arm SoC | ORT 官方 EP 文档 Requirements 表 | -| Ryzen AI Software 1.8.0 支持 PHX/HPT/STX/STX Halo/KRK;Linux installer 自 1.7.1 提供 | 官方 Release Notes | -| 模型兼容矩阵:CNN INT8 全系支持;CNN/NLP BF16 仅 STX/KRK | 官方 Release Notes | -| 模型生成(编译)仅支持 Windows;Windows 产物可跑在 Linux | 官方 Release Notes Known Issues | -| EP 在 session 创建时把图与权重编译为微码可执行文件,首次编译可能数分钟,产物可缓存 | ORT 官方 EP 文档 | -| 量化工具:AMD Quark(ONNX/PyTorch)、Vitis AI Quantizer(提供 Docker:amdih/ryzen-ai-pytorch 等)、Olive(实验) | ORT 官方 EP 文档 | -| Linux 内核驱动 `amdxdna` 已进 mainline 6.14(Ubuntu 25.04 起),但 XRT SHIM 用户态库仍需自行构建 | amd/xdna-driver README、kernel docs | -| Linux 上 Vitis AI EP 存在可用性问题(voe 模块缺失、无法 offload) | RyzenAI-SW issue #341(2026-02) | -| Windows NPU production driver 32.0.203.376;安装依赖 VS2022/conda | 官方安装文档 | -| Linux NPU 生态的成熟用例是 FastFlowLM(LLM),与 CNN OCR 无关 | Frame.work 社区指南 | - -尚未核实、需要真机或进一步调研的事项在 §11 列出。 - -## 4. 与 Intel NPU 路线的关键差异 - -| 维度 | Intel NPU(已实现 Phase B) | AMD NPU(本预研) | -| --- | --- | --- | -| 软件栈 | OpenVINO C API,Linux x64 一等公民 | ORT Vitis AI EP;Windows 一等,Linux 后补 | -| 后端形态 | 新 Direct 后端(`OpenVinoSession`) | 大概率复用 `OnnxSession` + provider 插件(WebGPU 模式) | -| 模型输入 | 上游 FP32 ONNX 直接读 | 必须量化派生(INT8/BF16) | -| 精度 | FP16(编译器默认) | INT8(全系)/ BF16(仅 STX/KRK) | -| 编译 | 运行时按 shape 编译,0.7–2 s,磁盘缓存 | session 创建时编译微码,可能数分钟,产物缓存 | -| 编译平台 | Linux 本机 | **仅 Windows**,产物跨平台使用 | -| 静态 shape | 桶 + 实际 shape 两种路由 | 待验证(EP 对动态 shape 的处理未知) | -| 驱动 | `intel_vpu` + Level Zero,随系统 | `amdxdna` mainline 6.14+,XRT SHIM 需自建 | - -## 5. 设备与精度矩阵 - -| 设备 | XDNA 代 | CNN INT8 | CNN BF16 | 备注 | -| --- | --- | :-: | :-: | --- | -| Ryzen 7040 / 8040(PHX/HPT) | XDNA 1 | ✅ | ❌ | 只能走 INT8,量化质量风险最高 | -| Ryzen AI 300 / Krackan(STX/KRK) | XDNA 2 | ✅ | ✅ | BF16 绕开 INT8 校准,预研首选 | -| Ryzen AI Max(Strix Halo) | XDNA 2 | ✅ | ✅ | 台式/迷你主机形态,便于 Gate | - -真机优先级:STX 系(BF16 可用、量化路径最短)> PHX/HPT(验证 INT8 路线是否值得)。 - -## 6. 候选架构 - -### 6.1 方案 A:ORT Vitis AI EP(倾向) - -- 沿用 `OnnxSession` 框架:dlopen provider 库、注册 EP、provider options 传入量化模型路径与编译缓存目录。 -- provider 名称建议 `amdnpu`,与 `webgpu`/`openvino` 并列;qualification-only policy、D112 原因映射照抄现有模式。 -- 优点:与 WebGPU 路线同构,改造点集中在"量化派生模型加载 + provider options + 编译缓存管理"。 -- 风险:EP 对 ORT 版本的绑定(社区踩坑在 ORT 1.20.1,本项目锁 1.22,兼容性未知);Linux 分发形态(pip/conda 还是独立 .so)待审计。 - -### 6.2 方案 B:等待 OpenVINO 系 AMD NPU 支持 - -- 若 OpenVINO 生态出现成熟的 AMD NPU plugin,则可与 `OpenVinoSession` 合流,仅换 device 与精度契约。 -- 现状:OpenVINO 主线的 NPU plugin 面向 Intel;AMD 侧无官方对应物。**仅作观察项,不作为计划。** - -### 6.3 否决项(预研即排除) - -- **整机模拟**:NPU 固件不开源,无 QEMU 设备模型;EP 的 NPU 编译器 target 真硬件,CPU fallback 不经过 NPU 代码路径——"无硬件验证推理行为"不存在。 -- **FastFlowLM**:面向 transformer LLM,不覆盖 CNN OCR。 -- **先做 PHX/HPT INT8**:量化派生 + 校准 + 质量 Gate 三重前置成本,若无 STX 真机数据支撑收益,投入产出比最差。 - -## 7. 模型派生(最大工作量,无硬件可完成大半) - -按 D113 的派生物规则设计,所有产物可复现、锁定 SHA-256、进入 bundle manifest: - -1. **量化(CPU 上可做)**:AMD Quark 或 Vitis AI Quantizer(官方 Docker 镜像),输入上游 FP32 ONNX,输出 INT8 或 BF16 ONNX。需要: - - 校准集:复用 14-fixture locked corpus 的代表性样本; - - INT8 需要精度预算与逐层审查(D113 未覆盖的新维度:量化误差审计)。 -2. **NPU 编译(Windows 步骤)**:在 Windows 环境(本地或 CI runner)用 Ryzen AI 工具链把量化模型编译为 NPU 微码产物;产物锁定哈希后随包分发,运行时不再编译。 - - 这直接消除"首次编译数分钟"的运行时成本,比 Intel 路线(运行时编译 + 磁盘缓存)更可控; - - 代价是派生物流程新增一个 Windows 构建环节,需要 CI 化与 provenance 记录。 -3. **形状契约**:recognition 宽度桶复用现有 20 桶契约(是否被 EP 支持待验证);detection 实际 shape 路由在 AMD 上如何表现(编译产物是否可按 shape 集合预生成)是 Spike 必答项。 - -## 8. 无硬件阶段可交付物(Phase 0 清单) - -| 交付物 | 验证方式 | -| --- | --- | -| ✅ `ExecutionProvider::amdnpu` 契约层(枚举、校验、policy、CLI/Node/TS 类型) | 已落地:单测覆盖精度契约(仅 `auto`)、无 bundled provider 的 `unsupported_capability`、`provider_abi_mismatch`、Auto 下的 fail-fast | -| EP 装载 plumbing(dlopen、符号、注册) | 安装 Ryzen AI Software 后在无 NPU 主机上跑到"设备枚举失败",覆盖 `adapter_unavailable` | -| INT8/BF16 量化派生流水线 | CPU Docker 内对 locked 模型跑通,产物与哈希入库 | -| Windows 编译步骤 CI 化 | GitHub Windows runner 生成微码产物 + provenance | -| 软件栈审计报告 | 包体、依赖闭包、许可证/SBOM、ORT 版本矩阵、XRT SHIM 分发形态 | -| ✅ 更新 [linux-device-acceleration.md §6](linux-device-acceleration.md#6-厂商-gpunpu-路线) AMD NPU 行链接本文 | 文档评审 | - -## 9. 真机前置(Phase A Spike 必答项) - -需要一台 STX 设备(Ryzen AI 300 或 Strix Halo 系): - -1. det/rec 单模型 microbenchmark:BF16 与 INT8 各自的 P50、首次编译/缓存命中行为、与 CPU 基线倍数。 -2. 14-fixture 完整 OCR:文本一致率、置信度偏差(对照 CPU FP32 goldens);INT8 的行级差异审计。 -3. EP 动态 shape 行为:20 桶 + detection 实际 shape 是否可行,还是需要全静态 shape 集合。 -4. 生命周期与缓存:20 次 engine close/recreate 的 RSS/句柄;微码缓存的跨进程行为。 -5. Linux 可用性专项:XRT SHIM 构建、EP 注册、issue #341 类故障是否复现;若 Linux 不通,评估 Windows x64 作为第一平台(与 Intel 路线的 Linux 优先相反)。 - -## 10. 分阶段落地(草案) - -- **Phase 0(本预研 → 可立即开工)**:完成 §8 清单;产出立项决策材料。 -- **Phase A(真机 Spike)**:借/购 STX 设备,完成 §9;数据不达预期则止损归档。 -- **Phase B(后端实现)**:按 §6.1 实现,qualification-only;仅当 Phase A 通过。 -- **Phase C/D(Gate 与分发)**:结构与 Intel NPU 方案 §11 相同,增加 INT8 质量审计与 Windows 编译产物锁定。 -- **进入 Auto**:按 D112 记录新 D 编号后才进入 released policy。 - -## 11. 待决问题 - -1. Linux 是否继续作为第一平台,还是 AMD 路线以 Windows x64 起步? -2. BF16(仅 STX+)与 INT8(全系但需校准)如何取舍:双精度派生还是单精度? -3. Vitis AI EP 与本项目锁定的 ORT 1.22 是否兼容;EP 库以何种形态随包分发(pip/conda/NuGet 之外的 .so 提取)? -4. 微码编译产物是否与 NPU 驱动版本耦合(驱动升级是否使产物失效)? -5. XRT SHIM 在无发行版包的现状下,是否视为驱动栈前置条件随包分发? -6. PHX/HPT(仅 INT8)是否纳入 Gate 矩阵,还是明确不支持? -7. 真机来源:购置 STX 笔记本/迷你主机,或租用云上 Ryzen AI 主机? - -## 12. 参考 - -- [Vitis AI Execution Provider(ORT 官方文档)](https://onnxruntime.ai/docs/execution-providers/Vitis-AI-ExecutionProvider.html) -- [Ryzen AI Software 1.8.0 Release Notes](https://ryzenai.docs.amd.com/en/latest/relnotes.html) -- [Ryzen AI 安装文档(Windows 主线)](https://ryzenai.docs.amd.com/en/latest/inst.html) -- [AMD XDNA Driver for Linux](https://github.com/amd/xdna-driver) -- [Linux amdxdna 内核文档](https://docs.kernel.org/accel/amdxdna/index.html) -- [AMD Quark 量化工具](https://quark.docs.amd.com/latest/supported_accelerators/ryzenai/index.html) -- [RyzenAI-SW Linux EP issue #341](https://github.com/amd/RyzenAI-SW/issues/341) +在这些证据齐备前,不能把源码实现或成功编译等同于 AMD NPU 已通过产品验收。 diff --git a/docs/intel-npu-acceleration.md b/docs/intel-npu-acceleration.md index aaa6a62..349a188 100644 --- a/docs/intel-npu-acceleration.md +++ b/docs/intel-npu-acceleration.md @@ -1,6 +1,6 @@ # Intel NPU 加速技术方案 -状态:Phase B 核心已在源码实现(qualification build);Linux x64 真机 14-fixture 验证通过;未进入 released policy,npm 包未包含 OpenVINO,不代表已经发布;目标是 Intel NPU 主机上 Auto 优先使用 NPU +状态:Phase B 后端和 Phase D 打包代码已实现;Linux x64 真机 14-fixture 已有验证记录;两代设备 Gate 尚未完成,当前 npm 包未包含 OpenVINO。构建和发布说明见 [NPU SDK 构建与发布](npu-runtime-release.md)。 更新时间:2026-09-29 @@ -300,7 +300,7 @@ Spike 数据见 §3;临时代码不合入主干。 | 无 NPU(bwrap 隐藏 `/dev/accel`)Auto | `openvino skipped(adapter_unavailable) → webgpu selected` | | 显式 `webgpu` | 进程不加载 OpenVINO | -尚未实现(留在 Phase B/C):后台预热其余 recognition 桶、按 descriptor 的驱动/compiler 最低版本检查(`driver_version_unsupported`)、LRU 中优先保留 recognition 桶、`model_cache_status` 的 hit/miss 区分、Node runtime descriptor 中的 OpenVINO 条目(Phase D)。 +2026-10-06 补齐:锁定 OpenVINO 2026.4.0 官方 wheel,提取 C headers 和五个原生运行时库;Node descriptor 2.1 声明包内路径、完整哈希和驱动/compiler 最低版本;最低版本使用 OpenVINO 属性的十进制数值,避免误用发行版包版本。会话通过 `LOADED_FROM_CACHE` 报告 hit/miss。发布工具附带许可证、SBOM 和审阅报告,并拒绝未经验收的候选 SDK。尚未实现的性能优化:后台预热其余 recognition 桶、LRU 中优先保留 recognition 桶。 ### Phase C — Linux x64 真机 Gate diff --git a/docs/npu-runtime-release.md b/docs/npu-runtime-release.md new file mode 100644 index 0000000..797a4af --- /dev/null +++ b/docs/npu-runtime-release.md @@ -0,0 +1,103 @@ +# NPU SDK 构建与发布 + +当前实现覆盖 Linux x64 glibc。Intel 使用 OpenVINO C API;AMD 使用独立装载的 Ryzen AI ORT 与预编译 BF16 识别模型。安装后的 OCR 进程无需 Python,系统仍需厂商 NPU 驱动。当前已发布 npm 包未包含这些候选运行时。 + +## Intel 候选 SDK + +```bash +python tools/npu/build_openvino.py \ + --package-cache .cache/npu-packages --output-dir dist/npu-sdk/openvino +``` + +锁文件 `tools/npu/openvino.lock.json` 固定官方 wheel URL、大小和 SHA-256。工具提取 C headers、OpenVINO core/C API/NPU plugin/ONNX frontend/TBB 及许可证;不会安装 wheel。已有缓存可加 `--offline`。输出目录必须不存在,避免覆盖已锁定产物。 + +当前 driver/compiler floor 来自本机 OpenVINO 设备属性的数值,尚未覆盖其他代设备。调整配置会要求重新绑定审阅报告。 + +## AMD 候选 SDK + +先自行取得 Ryzen AI 1.8 Linux SDK,并在该 SDK 的 Python 环境执行: + +```bash +python tools/npu/compile_amdnpu.py \ + --source-model /path/to/locked-recognition.onnx \ + --source-sha256 \ + --output-dir dist/amdnpu-models +``` + +默认配置为 `tools/npu/vaip_config.json`(VAIML BF16);可用 `--configuration` 指定审阅后的配置。编译和重新打开 context 需要可用的 vendor 栈及设备,尚未在本项目的 AMD 真机上执行。 + +在安装了 `patchelf` 和 `readelf` 的 Linux 主机导入完整部署目录: + +```bash +python tools/npu/import_amdnpu.py \ + --runtime-dir /path/to/vendor/deployment-libraries \ + --models-dir dist/amdnpu-models \ + --license-file /path/to/sdk-license \ + --license-file /path/to/third-party-notices \ + --output-dir dist/npu-sdk/amdnpu +``` + +部署目录需含 vendor ORT、VitisAI provider 及所有配套原生库。导入工具只允许 OS 和已安装的 NPU 驱动组件留在包外;不安装驱动、不修改系统库或 `LD_LIBRARY_PATH`。应先确认提供的许可证允许再分发。 + +## 编译 Core 与 Node + +```bash +cmake -S . -B build-npu -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DLIGHT_OCR_DEPENDENCY_CACHE_DIR="$PWD/.cache/dependencies" \ + -DLIGHT_OCR_OPENVINO_SDK_DIR="$PWD/dist/npu-sdk/openvino" \ + -DLIGHT_OCR_AMDNPU_SDK_DIR="$PWD/dist/npu-sdk/amdnpu" \ + -DLIGHT_OCR_BUILD_NODE=ON \ + -DLIGHT_OCR_NODE_INCLUDE_DIR=/path/to/node/include/node +cmake --build build-npu --parallel 2 +``` + +可省略未取得的 SDK 参数。Node 构建要求 SDK manifest、完整库存和哈希全部有效。产物在 `build-npu/node-runtime`,descriptor 使用 schema 2.1,把 NPU runtime 与基础 ORT 分开声明。JS loader 先验证全部产物,再把包内绝对路径传入 addon;候选排序采用已包含的 `openvino → amdnpu → webgpu → cpu`。 + +AMD 默认需要 CPU detector,且其模型绑定具体识别模型哈希;换用其他 tier 时需重新编译、审阅对应 SDK。Windows、arm64 和 musl 不接受当前 NPU SDK。 + +## 审阅报告与正式 SDK + +`accept_qualification.py` 只检查审阅结果及其绑定关系,不执行设备实验。每份 JSON 报告应包含: + +```json +{ + "schemaVersion": "1.0", + "provider": "openvino", + "platformId": "linux-x64", + "artifactSetSha256": "", + "configuration": "<替换为sdk-manifest中完整configuration对象>", + "deviceFamily": "<实际NPU代际,不能使用机器序列号代替>", + "reviewedBy": "<审阅人>", + "evidence": {"report": "<完整质量、性能和失败路径报告及其不可变身份>"}, + "gates": { + "quality": false, "performance": false, "coldStart": false, + "cache": false, "lifecycle": false, "failurePaths": false, + "autoSelection": false, "offlineDistribution": false, + "placement": false, "redistribution": false + } +} +``` + +报告中的门槛需经实际实验及人工审阅后才能填写为 `true`。具体 Intel 门槛见 [Intel Gate](intel-npu-acceleration.md#11-gate),AMD 待验收项见 [AMD 方案](amd-npu-acceleration.md)。Intel `deviceFamily` 必须使用设备属性 `DEVICE_ARCHITECTURE` 的十进制字符串(如 `5010`),至少需要两个不同架构报告;不能把同代不同机器计作两代。AMD 至少需要一份真实 STX/KRK 报告,family 使用 `STX`、`KRK` 或 `STX/KRK`。 + +```bash +python tools/npu/accept_qualification.py \ + --sdk-dir dist/npu-sdk/openvino \ + --report /path/to/reviewed-generation-a.json \ + --report /path/to/reviewed-generation-b.json \ + --qualification-id openvino-linux-x64-reviewed-v1 \ + --output-dir dist/npu-release/openvino +``` + +工具创建新的 SDK 目录,保留所有运行时字节,将报告及其哈希纳入库存,并设置正式资格。原候选保持不变。 + +## CI 与 npm staging + +`NPU native candidates` workflow 生成 Intel SDK 与 Node 候选构建。可输入另一个 run 的 `amdnpu-candidate-sdk` artifact,内容应为 AMD SDK 目录。提供 `npu-reviewed-reports` artifact run ID 时,报告按 `openvino/*.json`、`amdnpu/*.json` 放置;全部审阅门槛通过后才上传 `npu-release-sdks`。 + +`npm release` workflow 的可选 `npu_sdk_run_id` 读取同仓库的 `npu-release-sdks`(根目录下为 `openvino/`、`amdnpu/`),仅 Linux x64 构建使用。CI 在配置 CMake 前重新验证接受报告及 SDK 字节。 + +本地 `tools/npm_release.py stage-native` 接受 `--openvino-sdk-dir` 和 `--amdnpu-sdk-dir`;SDK 需与构建 addon 使用的版本、资格 ID 和包含的 provider 一致。候选必须显式添加 `--qualification-build`;正式 `assemble` 始终拒绝候选。许可证、SBOM、SDK manifest 和接受报告随平台包保存。 + +正式发布仍沿用项目补丁版本策略;当前候选尚未发布到 npm。 diff --git a/packages/runtime/src/load-native.cjs b/packages/runtime/src/load-native.cjs index dcae2d2..405f8fb 100644 --- a/packages/runtime/src/load-native.cjs +++ b/packages/runtime/src/load-native.cjs @@ -285,7 +285,7 @@ function validateRuntimeDescriptor(descriptorPath) { 'autoPolicy', 'providers', 'addon'], 'runtime descriptor', ); - if (descriptor.schemaVersion !== '2.0') { + if (!['2.0', '2.1'].includes(descriptor.schemaVersion)) { throw adapterError('package_load_failed', 'Unsupported native runtime descriptor schema'); } const root = path.dirname(path.dirname(absoluteDescriptor)); @@ -317,7 +317,7 @@ function validateRuntimeDescriptor(descriptorPath) { typeof policy.id !== 'string' || policy.id === '' || !Number.isSafeInteger(policy.version) || policy.version < 1 || policy.version > 0xffffffff || !Array.isArray(policy.providers) || policy.providers.length === 0 || - policy.providers.length > 3 || policy.providers.at(-1) !== 'cpu' || + policy.providers.length > 5 || policy.providers.at(-1) !== 'cpu' || new Set(policy.providers).size !== policy.providers.length ) { throw adapterError('package_load_failed', 'Native runtime descriptor Auto policy is invalid'); @@ -344,7 +344,8 @@ function validateRuntimeDescriptor(descriptorPath) { if (runtime.flavor === 'webgpu' && !['linux', 'win32'].includes(actual.os)) { throw adapterError('package_load_failed', 'WebGPU runtime is not supported on this platform'); } - if (runtime.flavor !== 'webgpu' && descriptor.qualificationOnly) { + if (runtime.flavor !== 'webgpu' && descriptor.qualificationOnly && + !descriptor.providers.openvino && !descriptor.providers.amdnpu) { throw adapterError('package_load_failed', 'CPU runtime cannot be qualification-only'); } @@ -365,7 +366,7 @@ function validateRuntimeDescriptor(descriptorPath) { if ( availableProviders.length === 0 || new Set(availableProviders).size !== availableProviders.length || - availableProviders.some((provider) => !['cpu', 'apple', 'webgpu'].includes(provider)) || + availableProviders.some((provider) => !['cpu', 'apple', 'webgpu', 'openvino', 'amdnpu'].includes(provider)) || !descriptor.providers.cpu ) { throw adapterError('package_load_failed', 'Native runtime descriptor provider policy is invalid'); @@ -373,7 +374,10 @@ function validateRuntimeDescriptor(descriptorPath) { let webgpuLibrary = ''; let webgpuProviderBytes = 0; let webgpuProviderSha256 = ''; + const npu = require('./npu-descriptor.cjs').validateNpuProviders(descriptor, root, + { exactKeys, verifyArtifact, sameArtifact, adapterError }); for (const [providerId, provider] of Object.entries(descriptor.providers)) { + if (['openvino', 'amdnpu'].includes(providerId)) continue; const expectedKeys = providerId === 'webgpu' ? ['runtimeProvider', 'providerVersion', 'qualificationId', 'providerLibrary', 'artifacts'] : ['runtimeProvider', 'qualificationId', 'artifacts']; @@ -433,7 +437,8 @@ function validateRuntimeDescriptor(descriptorPath) { : actual.os === 'darwin' ? 'libonnxruntime.1.22.0.dylib' : 'libonnxruntime.so.1'; - const runtimeNames = [...verifiedRuntime.values()].map((filename) => path.basename(filename)).sort(); + const runtimeNames = [...verifiedRuntime.entries()].filter(([name]) => !npu.paths.has(name)) + .map(([, filename]) => path.basename(filename)).sort(); const expectedRuntimeNames = runtime.flavor === 'webgpu' ? actual.os === 'win32' ? ['dxcompiler.dll', 'dxil.dll', 'onnxruntime.dll', 'onnxruntime_providers_webgpu.dll'] @@ -490,6 +495,9 @@ function validateRuntimeDescriptor(descriptorPath) { ? ['apple', 'cpu'] : ['cpu']; const sortedAvailable = [...availableProviders].sort(); + expectedPolicy.unshift(...npu.names); + expectedAvailable.push(...npu.names); + expectedAvailable.sort(); if ( policy.providers.length !== expectedPolicy.length || policy.providers.some((provider, index) => provider !== expectedPolicy[index]) || @@ -520,6 +528,7 @@ function validateRuntimeDescriptor(descriptorPath) { webgpuProviderLibrary: webgpuLibrary, webgpuProviderBytes, webgpuProviderSha256, + ...(npu.names.length ? { npuProviders: npu.providers } : {}), }); return { addon, diff --git a/packages/runtime/src/npu-descriptor.cjs b/packages/runtime/src/npu-descriptor.cjs new file mode 100644 index 0000000..be4cc34 --- /dev/null +++ b/packages/runtime/src/npu-descriptor.cjs @@ -0,0 +1,97 @@ +'use strict'; + +const path = require('node:path'); + +const WIDTHS = Object.freeze([320, 384, 480, 544, 576, 608, 704, 736, 832, 960, + 1056, 1184, 1248, 1376, 1600, 1984, 2240, 2560, 2880, 3200]); + +function validateNpuProviders(descriptor, root, { exactKeys, verifyArtifact, sameArtifact, adapterError }) { + const paths = new Set(); + const providers = {}; + const fail = (message) => { throw adapterError('package_load_failed', message); }; + for (const name of ['openvino', 'amdnpu']) { + const provider = descriptor.providers[name]; + if (!provider) continue; + if (descriptor.schemaVersion !== '2.1' || descriptor.platform.id !== 'linux-x64') { + fail('NPU providers require schema 2.1 and Linux x64 glibc'); + } + exactKeys(provider, ['runtimeProvider', 'providerVersion', 'qualificationId', + 'providerLibrary', 'configuration', 'artifacts'], `providers.${name}`); + const expected = name === 'openvino' ? 'OpenVINO' : 'VitisAIExecutionProvider'; + if (provider.runtimeProvider !== expected || typeof provider.providerVersion !== 'string' || + !provider.providerVersion || typeof provider.qualificationId !== 'string' || + !provider.qualificationId || !Array.isArray(provider.artifacts) || !provider.artifacts.length) { + fail('Invalid NPU provider identity'); + } + const declaredPaths = new Set(); + for (const artifact of provider.artifacts) { + verifyArtifact(root, artifact, `providers.${name}.artifacts`); + if (!artifact.path.startsWith(`native/${name}/`) || declaredPaths.has(artifact.path) || + !descriptor.runtime.artifacts.some((item) => sameArtifact(item, artifact))) { + fail('NPU artifact is outside its provider inventory'); + } + declaredPaths.add(artifact.path); + paths.add(artifact.path); + } + const library = verifyArtifact(root, provider.providerLibrary, `providers.${name}.providerLibrary`); + if (!provider.artifacts.some((item) => sameArtifact(item, provider.providerLibrary))) { + fail('NPU library is outside its provider inventory'); + } + const configuration = provider.configuration; + let nativeConfiguration; + if (name === 'openvino') { + exactKeys(configuration, ['runtimeVersionPrefix', 'minimumDriverVersion', 'minimumCompilerVersion'], + 'OpenVINO configuration'); + if (typeof configuration.runtimeVersionPrefix !== 'string' || !configuration.runtimeVersionPrefix || + !/^[0-9]+$/.test(configuration.minimumDriverVersion) || + typeof configuration.minimumDriverVersion !== 'string' || + !/^[0-9]+$/.test(configuration.minimumCompilerVersion) || + typeof configuration.minimumCompilerVersion !== 'string') { + fail('Invalid OpenVINO numeric driver/compiler floors'); + } + const expectedNames = ['libopenvino.so.2640', 'libopenvino_c.so.2640', + 'libopenvino_intel_npu_plugin.so', 'libopenvino_onnx_frontend.so.2640', 'libtbb.so.12'].sort(); + const names = [...declaredPaths].map((item) => path.basename(item)).sort(); + if (path.basename(library) !== 'libopenvino_c.so.2640' || + names.length !== expectedNames.length || names.some((item, i) => item !== expectedNames[i])) { + fail('Incomplete OpenVINO NPU dependency closure'); + } + nativeConfiguration = Object.freeze({ ...configuration }); + } else { + exactKeys(configuration, ['sourceModelSha256', 'recognitionModels', 'compilerConfiguration'], 'AMD NPU configuration'); + const configPath = verifyArtifact(root, configuration.compilerConfiguration, 'AMD NPU compiler configuration'); + if (!provider.artifacts.some((item) => sameArtifact(item, configuration.compilerConfiguration))) { + fail('AMD NPU compiler configuration is outside its inventory'); + } + if (typeof configuration.sourceModelSha256 !== 'string' || + !/^[0-9a-f]{64}$/.test(configuration.sourceModelSha256) || + !Array.isArray(configuration.recognitionModels) || configuration.recognitionModels.length !== WIDTHS.length || + !path.basename(library).startsWith('libonnxruntime.so')) { + fail('Invalid AMD NPU recognition model identity'); + } + const modelPaths = new Set(); + const models = configuration.recognitionModels.map((model, i) => { + exactKeys(model, ['width', 'artifact'], 'AMD NPU recognition bucket'); + const modelPath = verifyArtifact(root, model.artifact, 'AMD NPU recognition bucket artifact'); + if (model.width !== WIDTHS[i] || modelPaths.has(modelPath) || + !provider.artifacts.some((item) => sameArtifact(item, model.artifact))) { + fail('Incomplete AMD NPU recognition buckets'); + } + modelPaths.add(modelPath); + return Object.freeze({ width: model.width, artifact: Object.freeze({ + path: modelPath, bytes: model.artifact.bytes, sha256: model.artifact.sha256, + }) }); + }); + nativeConfiguration = Object.freeze({ sourceModelSha256: configuration.sourceModelSha256, + compilerConfiguration: Object.freeze({ path: configPath, + bytes: configuration.compilerConfiguration.bytes, sha256: configuration.compilerConfiguration.sha256 }), + recognitionModels: Object.freeze(models) }); + } + providers[name] = Object.freeze({ providerLibrary: library, + providerBytes: provider.providerLibrary.bytes, providerSha256: provider.providerLibrary.sha256, + configuration: nativeConfiguration }); + } + return { paths, providers: Object.freeze(providers), names: Object.keys(providers) }; +} + +module.exports = { validateNpuProviders, WIDTHS }; diff --git a/src/core/engine.cpp b/src/core/engine.cpp index a5e43f5..fb88a94 100644 --- a/src/core/engine.cpp +++ b/src/core/engine.cpp @@ -24,6 +24,13 @@ #endif #include "inference/onnxruntime/backend.hpp" #include "inference/openvino/backend.hpp" +#if defined(LIGHT_OCR_HAS_AMDNPU) +#include "inference/amdnpu/backend.hpp" +#include "amdnpu_defaults.hpp" +#endif +#if defined(LIGHT_OCR_OPENVINO_PACKAGE_SDK) +#include "openvino_defaults.hpp" +#endif #include "inference/selection.hpp" #include "model/bundle_data.hpp" #include "preprocess/image.hpp" @@ -896,16 +903,15 @@ Engine::~Engine() noexcept = default; internal::RuntimePolicy internal::builtin_runtime_policy() { RuntimePolicy policy; #if defined(LIGHT_OCR_HAS_OPENVINO) - // The OpenVINO NPU backend has not passed a platform Gate, so builds that - // include it are qualification-only regardless of the other providers. + // The SDK determines whether the exact runtime bytes have reviewed gates. policy.id = "builtin-openvino-v1"; #if defined(LIGHT_OCR_HAS_WEBGPU) policy.ordered_candidates = {"openvino", "webgpu", "cpu"}; #else policy.ordered_candidates = {"openvino", "cpu"}; #endif - policy.qualification_only = true; - policy.released = false; + policy.qualification_only = LIGHT_OCR_OPENVINO_QUALIFICATION_BUILD != 0; + policy.released = !policy.qualification_only; if (const char* route = std::getenv("LIGHT_OCR_QUALIFICATION_OPENVINO_DETECTOR"); route != nullptr && std::string(route) == "cpu") { policy.openvino_detector_route = "cpu"; @@ -934,7 +940,29 @@ internal::RuntimePolicy internal::builtin_runtime_policy() { #endif #if defined(LIGHT_OCR_HAS_OPENVINO) policy.available_providers.push_back("openvino"); +#if defined(LIGHT_OCR_OPENVINO_PACKAGE_SDK) + apply_openvino_sdk_defaults(policy); + policy.provider_qualification_ids.push_back(LIGHT_OCR_OPENVINO_QUALIFICATION_ID); +#else policy.provider_qualification_ids.push_back("builtin-openvino-v1"); +#endif +#endif +#if defined(LIGHT_OCR_HAS_AMDNPU) + apply_amdnpu_sdk_defaults(policy); + policy.available_providers.push_back("amdnpu"); + policy.provider_qualification_ids.push_back(LIGHT_OCR_AMDNPU_QUALIFICATION_ID); + const auto insertion = policy.ordered_candidates.front() == "openvino" + ? policy.ordered_candidates.begin() + 1 + : policy.ordered_candidates.begin(); + policy.ordered_candidates.insert(insertion, "amdnpu"); + if (LIGHT_OCR_AMDNPU_QUALIFICATION_BUILD) { + policy.qualification_only = true; + policy.released = false; + } +#endif +#if defined(LIGHT_OCR_HAS_WEBGPU) && defined(LIGHT_OCR_WEBGPU_QUALIFICATION_BUILD) + policy.qualification_only = true; + policy.released = false; #endif return policy; } @@ -1209,6 +1237,8 @@ Result> internal::EngineFactory::create( openvino_detection_config.provider = ExecutionProvider::openvino; openvino_detection_config.qualification_id = policy_qualification_id(runtime_policy, candidate); + openvino_detection_config.npu_device_validated = + runtime_policy.released && !runtime_policy.qualification_only; openvino_detection_config.shape_policy = "nchw-static-exact-32-960-v1"; openvino_detection_config.openvino_runtime_library = runtime_policy.openvino_runtime_library; @@ -1216,10 +1246,18 @@ Result> internal::EngineFactory::create( runtime_policy.openvino_runtime_bytes; openvino_detection_config.openvino_runtime_sha256 = runtime_policy.openvino_runtime_sha256; + openvino_detection_config.openvino_runtime_version_prefix = + runtime_policy.openvino_runtime_version_prefix; + openvino_detection_config.openvino_minimum_driver_version = + runtime_policy.openvino_minimum_driver_version; + openvino_detection_config.openvino_minimum_compiler_version = + runtime_policy.openvino_minimum_compiler_version; auto openvino_recognition_config = recognition_config; openvino_recognition_config.provider = ExecutionProvider::openvino; openvino_recognition_config.qualification_id = openvino_detection_config.qualification_id; + openvino_recognition_config.npu_device_validated = + openvino_detection_config.npu_device_validated; openvino_recognition_config.shape_policy = "nchw-static-width-buckets-20-v1"; openvino_recognition_config.openvino_runtime_library = @@ -1228,6 +1266,12 @@ Result> internal::EngineFactory::create( openvino_detection_config.openvino_runtime_bytes; openvino_recognition_config.openvino_runtime_sha256 = openvino_detection_config.openvino_runtime_sha256; + openvino_recognition_config.openvino_runtime_version_prefix = + openvino_detection_config.openvino_runtime_version_prefix; + openvino_recognition_config.openvino_minimum_driver_version = + openvino_detection_config.openvino_minimum_driver_version; + openvino_recognition_config.openvino_minimum_compiler_version = + openvino_detection_config.openvino_minimum_compiler_version; std::optional creation_reason; auto openvino_recognition = internal::OpenVinoSession::create( recognition_bytes, openvino_recognition_config, @@ -1281,14 +1325,53 @@ Result> internal::EngineFactory::create( #endif } if (candidate == "amdnpu") { - // No build ships the AMD NPU backend yet; a policy or descriptor - // that lists it can never be honored by this Core. created.provider = ExecutionProvider::amdnpu; +#if defined(LIGHT_OCR_HAS_AMDNPU) + if (runtime_policy.amdnpu_runtime.path.empty()) { + return fail(Error{ErrorCode::unsupported_capability, + "AMD NPU runtime is missing from the runtime policy", {}}, + CreationReason::provider_abi_mismatch); + } + if (batch_size != 1 || detection_strategy != DetectionStrategy::bounded || + detection_max_side > 960 || bundle.data_->recognition.height != 48 || + bundle.data_->recognition.minimum_tensor_width > 320 || + bundle.data_->recognition.maximum_tensor_width != 3200 || + options.execution.cpu_partition == CpuPartition::forbid) { + return fail(Error{ErrorCode::unsupported_capability, + "AMD NPU requires batch 1, bounded detection and a CPU detector partition", {}}, + CreationReason::model_compute_unsupported); + } + auto amd_config = recognition_config; + amd_config.provider = ExecutionProvider::amdnpu; + amd_config.qualification_id = policy_qualification_id(runtime_policy, candidate); + amd_config.npu_device_validated = runtime_policy.released && !runtime_policy.qualification_only; + amd_config.amdnpu_runtime = runtime_policy.amdnpu_runtime; + amd_config.amdnpu_compiler_configuration = runtime_policy.amdnpu_compiler_configuration; + amd_config.amdnpu_source_model_sha256 = runtime_policy.amdnpu_source_model_sha256; + amd_config.amdnpu_recognition_models = runtime_policy.amdnpu_recognition_models; + std::optional reason; + auto recognition = AmdNpuSession::create( + amd_config, bundle.data_->recognition.characters.size() + 1, &reason); + if (!recognition) return fail_from_error(recognition.error(), reason); + auto cpu_config = detection_config; + cpu_config.provider = ExecutionProvider::cpu; + cpu_config.requested_provider_override = "amdnpu"; + cpu_config.qualification_id = amd_config.qualification_id; + auto detection = internal::OnnxSession::create(detection_bytes, cpu_config, internal::ModelKind::detection, 0, &reason); + if (!detection) return fail_from_error(detection.error(), reason); + created.detection = std::move(detection).value(); + created.recognition = std::move(recognition).value(); + created.recognition_width_buckets = openvino_recognition_width_buckets(); + created.recognition_natural_content_width = true; + created.maximum_backend_batch_size = 1; + return internal::CandidateResult::success(std::move(created)); +#else return fail( Error{ErrorCode::unsupported_capability, "The runtime descriptor and Core AMD NPU capabilities disagree", {}}, CreationReason::provider_abi_mismatch); +#endif } if (candidate != "apple") { return fail(Error{ErrorCode::internal_error, @@ -1398,6 +1481,8 @@ Result> internal::EngineFactory::create( ? "CoreML" : selected_provider == ExecutionProvider::openvino ? "OpenVINO" + : selected_provider == ExecutionProvider::amdnpu + ? "VitisAIExecutionProvider" : selected_provider == ExecutionProvider::webgpu ? "WebGpuExecutionProvider" : "CPUExecutionProvider"; @@ -1425,7 +1510,9 @@ Result> internal::EngineFactory::create( } if (policy_includes_provider(runtime_policy, "amdnpu")) { info.execution.provider_capabilities.push_back(ProviderCapabilityInfo{ - "amdnpu", false, false, false}); + "amdnpu", true, selected_provider == ExecutionProvider::amdnpu, + selected_provider == ExecutionProvider::amdnpu && runtime_policy.released && + !runtime_policy.qualification_only}); } info.execution.selection_trace = std::move(selection.trace); if (policy_includes_provider(runtime_policy, "apple") && diff --git a/src/core/engine_factory.hpp b/src/core/engine_factory.hpp index e199e27..ec5e9a6 100644 --- a/src/core/engine_factory.hpp +++ b/src/core/engine_factory.hpp @@ -6,6 +6,7 @@ #include #include "light_ocr/core.hpp" +#include "inference/backend.hpp" namespace light_ocr::internal { @@ -29,6 +30,13 @@ struct RuntimePolicy { std::string openvino_runtime_library; std::uint64_t openvino_runtime_bytes = 0; std::string openvino_runtime_sha256; + std::string openvino_runtime_version_prefix; + std::string openvino_minimum_driver_version; + std::string openvino_minimum_compiler_version; + RuntimeArtifact amdnpu_runtime; + RuntimeArtifact amdnpu_compiler_configuration; + std::string amdnpu_source_model_sha256; + std::vector amdnpu_recognition_models; // "npu" runs both models on the NPU; "cpu" keeps the detector on the CPU // backend and sends only recognition to the NPU. std::string openvino_detector_route = "npu"; diff --git a/src/inference/amdnpu/backend.cpp b/src/inference/amdnpu/backend.cpp new file mode 100644 index 0000000..245d3ea --- /dev/null +++ b/src/inference/amdnpu/backend.cpp @@ -0,0 +1,335 @@ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#include "inference/amdnpu/backend.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "inference/openvino/backend.hpp" +#include "util/sha256.hpp" + +namespace light_ocr::internal { +namespace { +namespace fs = std::filesystem; +constexpr std::uint32_t kApiVersion = 20; + +class SetupError final : public std::runtime_error { + public: + SetupError(CreationReason reason, const std::string& message) + : std::runtime_error(message), reason(reason) {} + CreationReason reason; +}; + +std::vector read_artifact(const RuntimeArtifact& artifact) { + const auto path = fs::u8path(artifact.path); + std::error_code error; + const auto status = fs::symlink_status(path, error); + if (!path.is_absolute() || error || !fs::is_regular_file(status) || fs::is_symlink(status)) { + throw SetupError(CreationReason::package_corrupt, "AMD NPU artifact is missing or not regular"); + } + const auto size = fs::file_size(path, error); + if (error || artifact.bytes == 0 || size != artifact.bytes || + size > static_cast(std::numeric_limits::max()) || + size > std::numeric_limits::max()) { + throw SetupError(CreationReason::artifact_hash_mismatch, "AMD NPU artifact byte count mismatch"); + } + std::vector contents(static_cast(size)); + std::ifstream stream(path, std::ios::binary); + if (!stream.read(reinterpret_cast(contents.data()), static_cast(size)) || + stream.peek() != std::ifstream::traits_type::eof() || + sha256_hex(contents.data(), contents.size()) != artifact.sha256) { + throw SetupError(CreationReason::artifact_hash_mismatch, "AMD NPU artifact hash mismatch"); + } + return contents; +} + +std::string read_word(const fs::path& path) { + std::ifstream stream(path); + std::string value; + stream >> value; + return value; +} + +bool stx_npu_available() { + std::error_code error; + fs::directory_iterator it("/sys/class/accel", fs::directory_options::skip_permission_denied, error); + for (; !error && it != fs::directory_iterator(); it.increment(error)) { + const auto device = it->path() / "device"; + if (read_word(device / "vendor") == "0x1022" && read_word(device / "device") == "0x17f0") { + const auto node = fs::path("/dev/accel") / it->path().filename(); + if (::access(node.c_str(), R_OK | W_OK) == 0) return true; + } + } + return false; +} + +void check(const OrtApi& api, OrtStatus* status) { + if (status == nullptr) return; + const std::string message(api.GetErrorMessage(status)); + api.ReleaseStatus(status); + throw std::runtime_error(message); +} + +template +auto owned(T* pointer, Release release) { + return std::unique_ptr(pointer, release); +} + +struct Runtime { + const OrtApi* api = nullptr; + OrtEnv* environment = nullptr; + std::string version; +}; + +std::shared_ptr runtime_for(const RuntimeArtifact& artifact) { + // dlmopen prevents Ryzen AI's ORT/provider symbols from binding to the + // CPU/WebGPU ORT already loaded in the primary namespace. Libraries stay + // mapped because AMD providers keep process/thread-local state. + static std::mutex mutex; + static auto* runtimes = new std::map>; + const std::lock_guard lock(mutex); + (void)read_artifact(artifact); + const auto key = artifact.path + ":" + artifact.sha256; + const auto found = runtimes->find(key); + if (found != runtimes->end()) return found->second; + void* handle = ::dlmopen(LM_ID_NEWLM, artifact.path.c_str(), RTLD_NOW | RTLD_LOCAL); + if (!handle) { + const char* error = ::dlerror(); + throw SetupError(CreationReason::unrecoverable_load_failed, + error ? error : "Cannot load the Ryzen AI runtime"); + } + auto get_api = reinterpret_cast(::dlsym(handle, "OrtGetApiBase")); + if (!get_api || !get_api() || !get_api()->GetApi(kApiVersion)) { + throw SetupError(CreationReason::provider_abi_mismatch, "Ryzen AI runtime has no ORT C API 20"); + } + auto runtime = std::make_shared(); + runtime->api = get_api()->GetApi(kApiVersion); + runtime->version = get_api()->GetVersionString(); + char** providers = nullptr; + int count = 0; + check(*runtime->api, runtime->api->GetAvailableProviders(&providers, &count)); + bool available = false; + for (int i = 0; i < count; ++i) { + if (std::string(providers[i]) == "VitisAIExecutionProvider") available = true; + } + check(*runtime->api, runtime->api->ReleaseAvailableProviders(providers, count)); + if (!available) { + throw SetupError(CreationReason::provider_abi_mismatch, + "The supplied Ryzen AI runtime does not include VitisAIExecutionProvider"); + } + check(*runtime->api, runtime->api->CreateEnv(ORT_LOGGING_LEVEL_ERROR, "light-ocr-amdnpu", + &runtime->environment)); + runtimes->emplace(key, runtime); + return runtime; +} + +std::vector tensor_shape(const OrtApi& api, const OrtTensorTypeAndShapeInfo* tensor) { + if (!tensor) throw std::runtime_error("AMD NPU model input/output must be a tensor"); + std::size_t rank = 0; + check(api, api.GetDimensionsCount(tensor, &rank)); + if (rank > 8) throw std::runtime_error("AMD NPU tensor rank is outside the contract"); + std::vector shape(rank); + check(api, api.GetDimensions(tensor, shape.data(), shape.size())); + ONNXTensorElementDataType type = ONNX_TENSOR_ELEMENT_DATA_TYPE_UNDEFINED; + check(api, api.GetTensorElementType(tensor, &type)); + if (type != ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT) { + throw std::runtime_error("AMD NPU model must expose FP32 input and output tensors"); + } + return shape; +} +} // namespace + +struct AmdNpuSession::State { + struct Session { + OrtSession* value = nullptr; + std::string input; + std::string output; + }; + std::shared_ptr runtime; + std::vector models; + std::string compiler_configuration; + std::map sessions; + std::size_t classes = 0; + ~State() { + for (auto& entry : sessions) runtime->api->ReleaseSession(entry.second.value); + } + Session& session_for(std::uint32_t width) { + const auto existing = sessions.find(width); + if (existing != sessions.end()) return existing->second; + const auto model = std::find_if(models.begin(), models.end(), [width](const auto& item) { + return item.width == width; + }); + if (model == models.end()) throw std::runtime_error("AMD NPU width has no compiled context model"); + const auto bytes = read_artifact(model->artifact); + const auto& api = *runtime->api; + OrtSessionOptions* raw_options = nullptr; + check(api, api.CreateSessionOptions(&raw_options)); + auto options = owned(raw_options, api.ReleaseSessionOptions); + check(api, api.SetIntraOpNumThreads(options.get(), 1)); + check(api, api.SetInterOpNumThreads(options.get(), 1)); + const char* keys[] = {"config_file"}; + const char* values[] = {compiler_configuration.c_str()}; + check(api, api.SessionOptionsAppendExecutionProvider_VitisAI(options.get(), keys, values, 1)); + OrtSession* raw_session = nullptr; + check(api, api.CreateSessionFromArray(runtime->environment, bytes.data(), bytes.size(), + options.get(), &raw_session)); + auto session = owned(raw_session, api.ReleaseSession); + std::size_t inputs = 0; + std::size_t outputs = 0; + check(api, api.SessionGetInputCount(session.get(), &inputs)); + check(api, api.SessionGetOutputCount(session.get(), &outputs)); + if (inputs != 1 || outputs != 1) throw std::runtime_error("AMD NPU model must have one input and output"); + OrtTypeInfo* raw_input = nullptr; + check(api, api.SessionGetInputTypeInfo(session.get(), 0, &raw_input)); + auto input_type = owned(raw_input, api.ReleaseTypeInfo); + const OrtTensorTypeAndShapeInfo* input_tensor = nullptr; + check(api, api.CastTypeInfoToTensorInfo(input_type.get(), &input_tensor)); + if (tensor_shape(api, input_tensor) != std::vector{1, 3, 48, width}) { + throw std::runtime_error("AMD NPU input shape does not match its width bucket"); + } + OrtTypeInfo* raw_output = nullptr; + check(api, api.SessionGetOutputTypeInfo(session.get(), 0, &raw_output)); + auto output_type = owned(raw_output, api.ReleaseTypeInfo); + const OrtTensorTypeAndShapeInfo* output_tensor = nullptr; + check(api, api.CastTypeInfoToTensorInfo(output_type.get(), &output_tensor)); + const auto output_shape = tensor_shape(api, output_tensor); + if (output_shape.size() != 3 || output_shape[0] != 1 || output_shape[2] != static_cast(classes)) { + throw std::runtime_error("AMD NPU output class count does not match the locked dictionary"); + } + OrtAllocator* allocator = nullptr; + check(api, api.GetAllocatorWithDefaultOptions(&allocator)); + char* raw_input_name = nullptr; + check(api, api.SessionGetInputName(session.get(), 0, allocator, &raw_input_name)); + auto input_name = owned(raw_input_name, [allocator](char* name) { allocator->Free(allocator, name); }); + char* raw_output_name = nullptr; + check(api, api.SessionGetOutputName(session.get(), 0, allocator, &raw_output_name)); + auto output_name = owned(raw_output_name, [allocator](char* name) { allocator->Free(allocator, name); }); + Session result{session.get(), input_name.get(), output_name.get()}; + auto& inserted = sessions.emplace(width, std::move(result)).first->second; + (void)session.release(); + return inserted; + } +}; + +AmdNpuSession::AmdNpuSession(std::unique_ptr state, SessionExecutionInfo info) + : state_(std::move(state)), info_(std::move(info)) {} +AmdNpuSession::~AmdNpuSession() noexcept = default; + +Result> AmdNpuSession::create( + const InferenceSessionConfig& config, std::size_t classes, + std::optional* reason) noexcept { + using R = Result>; + auto fail = [&](CreationReason why, const std::string& message) { + if (reason) *reason = why; + return R::failure(Error{ErrorCode::unsupported_capability, message, "amdnpu"}); + }; + try { + const auto& widths = openvino_recognition_width_buckets(); + if (config.cpu_partition == CpuPartition::forbid || + config.model_sha256 != config.amdnpu_source_model_sha256 || + config.amdnpu_recognition_models.size() != widths.size()) { + return fail(CreationReason::model_compute_unsupported, + "AMD NPU requires its locked BF16 recognition model and a CPU detector partition"); + } + for (std::size_t i = 0; i < widths.size(); ++i) { + if (config.amdnpu_recognition_models[i].width != widths[i]) { + return fail(CreationReason::model_compute_unsupported, "AMD NPU recognition buckets are incomplete"); + } + } + (void)read_artifact(config.amdnpu_runtime); + (void)read_artifact(config.amdnpu_compiler_configuration); + if (!stx_npu_available()) { + return fail(CreationReason::adapter_unavailable, "No accessible AMD STX/KRK NPU on this host"); + } + auto state = std::make_unique(); + state->runtime = runtime_for(config.amdnpu_runtime); + state->models = config.amdnpu_recognition_models; + state->compiler_configuration = config.amdnpu_compiler_configuration.path; + state->classes = classes; + try { + (void)state->session_for(widths.front()); + } catch (const SetupError&) { + throw; + } catch (const std::exception& error) { + return fail(CreationReason::model_compute_unsupported, error.what()); + } + SessionExecutionInfo info; + info.requested_provider = config.requested_provider_override.empty() ? "amdnpu" : config.requested_provider_override; + info.actual_provider_chain = {"VitisAIExecutionProvider", "CPUExecutionProvider"}; + info.device = "npu:AMD XDNA2"; + info.device_family = "STX/KRK"; + info.operating_system = "linux"; + info.precision = "bf16"; + info.shape_policy = "nchw-static-amd-bf16-buckets-v1"; + info.model_id = config.model_id; + info.model_sha256 = config.model_sha256; + info.runtime = "Ryzen AI ONNX Runtime"; + info.runtime_version = state->runtime->version; + info.model_cache_status = "precompiled"; + info.qualification_id = config.qualification_id; + info.device_validated = config.npu_device_validated; + return R::success(std::unique_ptr(new AmdNpuSession(std::move(state), std::move(info)))); + } catch (const SetupError& error) { + return fail(error.reason, error.what()); + } catch (const std::exception& error) { + return fail(CreationReason::unrecoverable_load_failed, error.what()); + } +} + +Result AmdNpuSession::run(const std::vector& values, + const std::vector& shape) noexcept { + try { + if (shape.size() != 4 || shape[0] != 1 || shape[1] != 3 || shape[2] != 48 || + shape[3] <= 0 || shape[3] > 3200 || + values.size() != static_cast(144 * shape[3])) { + throw std::runtime_error("AMD NPU input does not match a recognition bucket"); + } + auto& session = state_->session_for(static_cast(shape[3])); + const auto& api = *state_->runtime->api; + OrtMemoryInfo* raw_memory = nullptr; + check(api, api.CreateCpuMemoryInfo(OrtArenaAllocator, OrtMemTypeDefault, &raw_memory)); + auto memory = owned(raw_memory, api.ReleaseMemoryInfo); + OrtValue* raw_input = nullptr; + check(api, api.CreateTensorWithDataAsOrtValue(memory.get(), const_cast(values.data()), + values.size() * sizeof(float), shape.data(), shape.size(), ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT, &raw_input)); + auto input = owned(raw_input, api.ReleaseValue); + const OrtValue* inputs[] = {input.get()}; + const char* input_names[] = {session.input.c_str()}; + const char* output_names[] = {session.output.c_str()}; + OrtValue* raw_output = nullptr; + check(api, api.Run(session.value, nullptr, input_names, inputs, 1, output_names, 1, &raw_output)); + auto output = owned(raw_output, api.ReleaseValue); + OrtTensorTypeAndShapeInfo* raw_type = nullptr; + check(api, api.GetTensorTypeAndShape(output.get(), &raw_type)); + auto type = owned(raw_type, api.ReleaseTensorTypeAndShapeInfo); + const auto output_shape = tensor_shape(api, type.get()); + if (output_shape.size() != 3 || output_shape[0] != 1 || output_shape[1] <= 0 || + output_shape[2] != static_cast(state_->classes)) { + throw std::runtime_error("AMD NPU returned an invalid recognition tensor"); + } + std::size_t count = 0; + check(api, api.GetTensorShapeElementCount(type.get(), &count)); + if (count == 0 || count > std::numeric_limits::max() / sizeof(float)) { + throw std::runtime_error("AMD NPU output tensor is too large"); + } + void* data = nullptr; + check(api, api.GetTensorMutableData(output.get(), &data)); + auto copied = std::make_shared>(static_cast(data), static_cast(data) + count); + return Result::success(TensorOutput(copied, copied->data(), output_shape, count)); + } catch (const std::exception& error) { + return Result::failure(Error{ErrorCode::inference_failed, "AMD NPU inference failed", error.what()}); + } +} + +} // namespace light_ocr::internal diff --git a/src/inference/amdnpu/backend.hpp b/src/inference/amdnpu/backend.hpp new file mode 100644 index 0000000..1af93b7 --- /dev/null +++ b/src/inference/amdnpu/backend.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "inference/backend.hpp" + +namespace light_ocr::internal { + +// Ryzen AI owns a separate ORT C API table. No Ort:: wrapper or global ORT +// API pointer may be used for objects allocated by this runtime. +class AmdNpuSession final : public InferenceSession { + public: + ~AmdNpuSession() noexcept override; + static Result> create( + const InferenceSessionConfig& config, std::size_t classes, + std::optional* reason = nullptr) noexcept; + Result run(const std::vector& values, + const std::vector& shape) noexcept override; + const SessionExecutionInfo& execution_info() const noexcept override { return info_; } + + private: + struct State; + AmdNpuSession(std::unique_ptr state, SessionExecutionInfo info); + std::unique_ptr state_; + SessionExecutionInfo info_; +}; + +} // namespace light_ocr::internal diff --git a/src/inference/backend.hpp b/src/inference/backend.hpp index a49e48d..00492a6 100644 --- a/src/inference/backend.hpp +++ b/src/inference/backend.hpp @@ -18,6 +18,17 @@ void shutdown_webgpu_runtime_if_idle() noexcept; enum class ModelKind { detection, recognition }; +struct RuntimeArtifact { + std::string path; + std::uint64_t bytes = 0; + std::string sha256; +}; + +struct AmdNpuRecognitionModel { + std::uint32_t width = 0; + RuntimeArtifact artifact; +}; + struct ModelPackageFile { std::string path; SharedBytes bytes; @@ -61,8 +72,16 @@ struct InferenceSessionConfig { std::string openvino_runtime_library; std::uint64_t openvino_runtime_bytes = 0; std::string openvino_runtime_sha256; + std::string openvino_runtime_version_prefix; + std::string openvino_minimum_driver_version; + std::string openvino_minimum_compiler_version; + bool npu_device_validated = false; // Empty selects the per-user cache directory. std::string openvino_cache_directory; + RuntimeArtifact amdnpu_runtime; + RuntimeArtifact amdnpu_compiler_configuration; + std::string amdnpu_source_model_sha256; + std::vector amdnpu_recognition_models; std::optional apple_package; std::string requested_provider_override; bool session_fallback_used = false; diff --git a/src/inference/openvino/backend.cpp b/src/inference/openvino/backend.cpp index bc59d01..c0b1705 100644 --- a/src/inference/openvino/backend.cpp +++ b/src/inference/openvino/backend.cpp @@ -1,6 +1,7 @@ #include "inference/openvino/backend.hpp" #include +#include #include #include #include @@ -64,6 +65,7 @@ struct Api { decltype(&::ov_partial_shape_free) partial_shape_free = nullptr; decltype(&::ov_compiled_model_create_infer_request) compiled_model_create_infer_request = nullptr; decltype(&::ov_compiled_model_free) compiled_model_free = nullptr; + decltype(&::ov_compiled_model_get_property) compiled_model_get_property = nullptr; decltype(&::ov_infer_request_set_input_tensor_by_index) infer_request_set_input_tensor_by_index = nullptr; decltype(&::ov_infer_request_infer) infer_request_infer = nullptr; decltype(&::ov_infer_request_get_output_tensor_by_index) infer_request_get_output_tensor_by_index = nullptr; @@ -180,6 +182,7 @@ Api load_api(void* handle) { bind(handle, "ov_partial_shape_free", &api.partial_shape_free); bind(handle, "ov_compiled_model_create_infer_request", &api.compiled_model_create_infer_request); bind(handle, "ov_compiled_model_free", &api.compiled_model_free); + bind(handle, "ov_compiled_model_get_property", &api.compiled_model_get_property); bind(handle, "ov_infer_request_set_input_tensor_by_index", &api.infer_request_set_input_tensor_by_index); bind(handle, "ov_infer_request_infer", &api.infer_request_infer); @@ -271,6 +274,26 @@ Runtime& require_npu(const InferenceSessionConfig& config) { throw OpenVinoSetupError(CreationReason::adapter_unavailable, "OpenVINO reports no usable Intel NPU on this host"); } + if (!config.openvino_runtime_version_prefix.empty() && + runtime.device.runtime_version.rfind(config.openvino_runtime_version_prefix, 0) != 0) { + throw OpenVinoSetupError(CreationReason::provider_abi_mismatch, + "OpenVINO version does not match the verified SDK"); + } + auto at_least = [](const std::string& actual, const std::string& minimum) { + if (minimum.empty()) return true; + std::uint64_t a = 0; + std::uint64_t b = 0; + const auto left = std::from_chars(actual.data(), actual.data() + actual.size(), a); + const auto right = std::from_chars(minimum.data(), minimum.data() + minimum.size(), b); + return left.ec == std::errc{} && left.ptr == actual.data() + actual.size() && + right.ec == std::errc{} && right.ptr == minimum.data() + minimum.size() && a >= b; + }; + // OpenVINO returns numeric driver/compiler identities, not OS package SemVer. + if (!at_least(runtime.device.driver_version, config.openvino_minimum_driver_version) || + !at_least(runtime.device.compiler_version, config.openvino_minimum_compiler_version)) { + throw OpenVinoSetupError(CreationReason::driver_version_unsupported, + "Intel NPU driver or compiler is below the SDK's qualified floor"); + } return runtime; } @@ -374,7 +397,7 @@ struct OpenVinoSession::State { std::map, Compiled> compiled; std::size_t maximum_shapes = 0; std::uint64_t clock = 0; - bool last_compile_cached = false; + std::string last_cache_status = "disabled"; ~State() { for (auto& entry : compiled) release(entry.second); @@ -435,12 +458,20 @@ struct OpenVinoSession::State { "PERFORMANCE_HINT", "LATENCY", "INFERENCE_PRECISION_HINT", "f16", "NPU_COMPILER_TYPE", "DRIVER"); } - last_compile_cached = cached; + last_cache_status = cached ? "miss" : "disabled"; } if (status != OK || entry.model == nullptr) { + release(entry); throw std::runtime_error("OpenVINO cannot compile shape " + shape_text(shape) + " for the NPU: " + last_error(api)); } + char* loaded = nullptr; + if (last_cache_status != "disabled" && + api.compiled_model_get_property(entry.model, "LOADED_FROM_CACHE", &loaded) == OK && + loaded != nullptr) { + last_cache_status = std::string(loaded) == "YES" ? "hit" : "miss"; + api.free(loaded); + } if (api.compiled_model_create_infer_request(entry.model, &entry.request) != OK) { const auto message = last_error(api); release(entry); @@ -542,9 +573,9 @@ Result> OpenVinoSession::create( info.runtime = "OpenVINO"; info.runtime_version = runtime.device.runtime_version; info.provider_version = runtime.device.driver_version; - info.model_cache_status = state->last_compile_cached ? "compiled_cache" : "disabled"; + info.device_validated = config.npu_device_validated; + info.model_cache_status = state->last_cache_status; info.qualification_id = config.qualification_id; - info.device_validated = false; return CreateResult::success(std::unique_ptr( new OpenVinoSession(std::move(state), std::move(info)))); } catch (const OpenVinoSetupError& error) { diff --git a/tools/npm_release.py b/tools/npm_release.py index 7c84b25..677240c 100644 --- a/tools/npm_release.py +++ b/tools/npm_release.py @@ -18,8 +18,10 @@ try: from tools.webgpu import build_runtime as webgpu_runtime + from tools.npu import sdk as npu_sdk except ModuleNotFoundError: # Direct execution sets sys.path to tools/. from webgpu import build_runtime as webgpu_runtime + from npu import sdk as npu_sdk ROOT = Path(__file__).resolve().parents[1] @@ -322,8 +324,8 @@ def validate_runtime_descriptor( "addon", }: raise RuntimeError("runtime descriptor fields are invalid") - if descriptor.get("schemaVersion") != "2.0": - raise RuntimeError("runtime descriptor schemaVersion must be 2.0") + if descriptor.get("schemaVersion") not in {"2.0", "2.1"}: + raise RuntimeError("runtime descriptor schemaVersion must be 2.0 or 2.1") platform = descriptor.get("platform") if not isinstance(platform, dict) or not isinstance(platform.get("id"), str): raise RuntimeError("runtime descriptor platform is invalid") @@ -385,7 +387,8 @@ def validate_runtime_descriptor( raise RuntimeError("runtime descriptor ABI identity is invalid") if runtime["flavor"] == "webgpu" and platform.get("os") not in {"linux", "win32"}: raise RuntimeError("WebGPU runtime platform is invalid") - if runtime["flavor"] != "webgpu" and qualification_only: + if (runtime["flavor"] != "webgpu" and qualification_only and + not any(name in provider_records for name in ("openvino", "amdnpu"))): raise RuntimeError("CPU runtime cannot be qualification-only") addon = descriptor.get("addon") @@ -402,7 +405,13 @@ def validate_runtime_descriptor( "cpu": "CPUExecutionProvider", "apple": "CoreML", "webgpu": "WebGpuExecutionProvider", + "openvino": "OpenVINO", + "amdnpu": "VitisAIExecutionProvider", } + try: + npu_paths = npu_sdk.validate_descriptor_npu(descriptor, package_root) + except (ValueError, KeyError, TypeError) as exception: + raise RuntimeError(f"invalid NPU runtime descriptor: {exception}") from exception for provider_id, provider in provider_records.items(): expected_fields = ( { @@ -412,7 +421,8 @@ def validate_runtime_descriptor( "providerLibrary", "artifacts", } - if provider_id == "webgpu" + | ({"configuration"} if provider_id in {"openvino", "amdnpu"} else set()) + if provider_id in {"webgpu", "openvino", "amdnpu"} else {"runtimeProvider", "qualificationId", "artifacts"} ) if ( @@ -470,7 +480,8 @@ def validate_runtime_descriptor( if platform.get("os") == "darwin" else "libonnxruntime.so.1" ) - actual_names = sorted(PurePosixPath(value).name for value in runtime_records) + actual_names = sorted(PurePosixPath(value).name for value in runtime_records + if value not in npu_paths) expected_names = ( [ "dxcompiler.dll", @@ -509,6 +520,9 @@ def validate_runtime_descriptor( if platform.get("id") == "macos-arm64" else {"cpu"} ) + npu_providers = [name for name in ["openvino", "amdnpu"] if name in provider_records] + expected_policy = npu_providers + expected_policy + expected_available.update(npu_providers) if providers != expected_policy or set(provider_records) != expected_available: raise RuntimeError( "runtime descriptor providers disagree with platform capabilities" @@ -837,6 +851,39 @@ def stage_native(arguments: argparse.Namespace) -> None: "providers": provider_entries, "addon": file_record(addon, stage), } + for provider in ("openvino", "amdnpu"): + sdk_directory = getattr(arguments, f"{provider}_sdk_dir", None) + if sdk_directory is None: + continue + sdk_directory = Path(sdk_directory).resolve() + sdk_manifest = npu_sdk.validate_sdk(sdk_directory, provider) + if sdk_manifest["qualificationOnly"] and not qualification_build: + raise RuntimeError("NPU candidates require --qualification-build; accepted evidence is required for release") + sdk_manifest = npu_sdk.stage_sdk(sdk_directory, stage, descriptor) + evidence_directory = stage / "qualification" / provider + copy_file(sdk_directory / "sdk-manifest.json", evidence_directory / "sdk-manifest.json") + for item in sdk_manifest.get("acceptance", {}).get("reports", []): + copy_file(sdk_directory / item["path"], evidence_directory / Path(item["path"]).name) + license_inventory = read_json(stage / "license-inventory.json") + for item in sdk_manifest["licenses"]: + destination = stage / "licenses" / f"{provider}-{Path(item['path']).name}" + copy_file(sdk_directory / item["path"], destination) + license_inventory.setdefault("files", []).append({ + "component": provider, "file": destination.relative_to(stage).as_posix(), + "sha256": sha256(destination)}) + write_json(stage / "license-inventory.json", license_inventory) + sbom = read_json(stage / "sbom.spdx.json") + identifier = f"SPDXRef-Package-{provider}" + sbom.setdefault("packages", []).append({ + "name": provider, "SPDXID": identifier, + "versionInfo": sdk_manifest["providerVersion"], "filesAnalyzed": False, + "downloadLocation": sdk_manifest.get("source", {}).get("url", "NOASSERTION"), + "licenseDeclared": "Apache-2.0" if provider == "openvino" else "NOASSERTION", + "licenseConcluded": "NOASSERTION"}) + sbom.setdefault("relationships", []).append({ + "spdxElementId": "SPDXRef-Package-light-ocr-core", + "relationshipType": "DEPENDS_ON", "relatedSpdxElement": identifier}) + write_json(stage / "sbom.spdx.json", sbom) write_json(native / "runtime-descriptor.json", descriptor) validate_runtime_descriptor( descriptor, stage, platform_id=arguments.platform_id @@ -853,7 +900,7 @@ def stage_native(arguments: argparse.Namespace) -> None: "platformId": arguments.platform_id, "package": platform["package"], "runtimeFlavor": runtime_flavor, - "qualificationOnly": qualification_only, + "qualificationOnly": descriptor["qualificationOnly"], "files": records, }, ) @@ -1596,6 +1643,8 @@ def main() -> int: native.add_argument("--output-dir", type=Path, required=True) native.add_argument("--runtime-flavor", choices=["cpu", "webgpu"], default="cpu") native.add_argument("--webgpu-artifact-manifest", type=Path) + native.add_argument("--openvino-sdk-dir", type=Path) + native.add_argument("--amdnpu-sdk-dir", type=Path) native.add_argument("--qualification-build", action="store_true") native.set_defaults(handler=stage_native) diff --git a/tools/npu/__init__.py b/tools/npu/__init__.py new file mode 100644 index 0000000..69092b2 --- /dev/null +++ b/tools/npu/__init__.py @@ -0,0 +1 @@ +"""Pinned, package-local NPU runtimes and qualification tooling.""" diff --git a/tools/npu/accept_qualification.py b/tools/npu/accept_qualification.py new file mode 100644 index 0000000..598c891 --- /dev/null +++ b/tools/npu/accept_qualification.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Create a release SDK from immutable candidate bytes and reviewed reports.""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import shutil + +try: + from .sdk import record, validate_acceptance, validate_sdk +except ImportError: + from sdk import record, validate_acceptance, validate_sdk + + +def accept(source: Path, reports: list[Path], output: Path, qualification_id: str) -> dict: + manifest = validate_sdk(source) + if not manifest["qualificationOnly"]: + raise ValueError("acceptance input must be a candidate SDK") + values = [json.loads(path.read_text("utf-8")) for path in reports] + validate_acceptance(manifest, values) + if output.exists() or not qualification_id.strip(): + raise ValueError("select a fresh output directory and a reviewed qualification ID") + shutil.copytree(source, output) + directory = output / "qualification" + directory.mkdir() + records = [] + for index, path in enumerate(reports): + target = directory / f"report-{index}.json" + shutil.copyfile(path, target) + records.append(record(target, output)) + manifest["files"].extend(records) + manifest["acceptance"] = {"artifactSetSha256": manifest["artifactSetSha256"], + "providerGatePassed": True, "reports": records} + manifest["qualificationOnly"] = False + manifest["qualificationId"] = qualification_id + (output / "sdk-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", "utf-8") + return validate_sdk(output) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--sdk-dir", type=Path, required=True) + parser.add_argument("--report", type=Path, action="append", required=True) + parser.add_argument("--qualification-id", required=True) + parser.add_argument("--output-dir", type=Path, required=True) + args = parser.parse_args() + value = accept(args.sdk_dir, args.report, args.output_dir, args.qualification_id) + print(f"Accepted {value['provider']}: {value['artifactSetSha256']}") + + +if __name__ == "__main__": + main() diff --git a/tools/npu/build_openvino.py b/tools/npu/build_openvino.py new file mode 100644 index 0000000..0f7ac6d --- /dev/null +++ b/tools/npu/build_openvino.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Extract the locked native payload and C headers; Python is never shipped.""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import shutil +import urllib.request +import zipfile + +try: + from .sdk import artifact_set, digest, record, relative_path, validate_sdk +except ImportError: + from sdk import artifact_set, digest, record, relative_path, validate_sdk + +LOCK = Path(__file__).with_name("openvino.lock.json") + + +def build(cache: Path, output: Path, offline: bool = False) -> dict: + lock = json.loads(LOCK.read_text("utf-8")) + package = lock["package"] + cache.mkdir(parents=True, exist_ok=True) + archive = cache / package["filename"] + if not archive.is_file(): + if offline: + raise ValueError("locked OpenVINO wheel is absent from the offline cache") + temporary = archive.with_suffix(".partial") + with urllib.request.urlopen(package["url"], timeout=60) as source, temporary.open("wb") as target: + shutil.copyfileobj(source, target) + temporary.replace(archive) + if archive.stat().st_size != package["bytes"] or digest(archive) != package["sha256"]: + raise ValueError("OpenVINO wheel identity mismatch") + if output.exists(): + raise ValueError("output directory already exists; choose a fresh SDK directory") + output.mkdir(parents=True) + with zipfile.ZipFile(archive) as wheel: + names = wheel.namelist() + if len(names) != len(set(names)): + raise ValueError("OpenVINO archive has duplicate entries") + for name in names: + relative_path(name.rstrip("/")) + selected = {f"openvino/libs/{name}": f"lib/{name}" for name in lock["libraries"]} + selected.update({name: name.removeprefix("openvino/") for name in names + if name.startswith("openvino/include/openvino/c/") and not name.endswith("/")}) + selected.update({name: f"licenses/{Path(name).name}" for name in names + if "/licenses/" in name and not name.endswith("/")}) + for source, destination in selected.items(): + target = output / destination + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(wheel.read(source)) + files = [record(path, output) for path in sorted(output.rglob("*")) if path.is_file()] + artifacts = [item for item in files if item["path"].startswith("lib/")] + manifest = { + "schemaVersion": "1.0", "provider": "openvino", "platformId": "linux-x64", + "providerVersion": lock["version"], "qualificationId": lock["qualificationId"], + "qualificationOnly": True, "runtimeLibrary": lock["runtimeLibrary"], + "configuration": lock["configuration"], "artifacts": artifacts, + "artifactSetSha256": artifact_set(artifacts), "files": files, + "licenses": [item for item in files if item["path"].startswith("licenses/")], + "source": package, + } + (output / "sdk-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", "utf-8") + return validate_sdk(output) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--package-cache", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--offline", action="store_true") + args = parser.parse_args() + value = build(args.package_cache, args.output_dir, args.offline) + print(f"OpenVINO SDK: {value['artifactSetSha256']} (qualification-only)") + + +if __name__ == "__main__": + main() diff --git a/tools/npu/compile_amdnpu.py b/tools/npu/compile_amdnpu.py new file mode 100644 index 0000000..0ba7397 --- /dev/null +++ b/tools/npu/compile_amdnpu.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Compile locked FP32 recognition into embedded Ryzen AI BF16 EP contexts. + +Run using the vendor SDK's Python interpreter. Neither Python nor a compiler +is required by the shipped light-ocr process. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import shutil + +try: + from .sdk import digest, record, recognition_widths +except ImportError: + from sdk import digest, record, recognition_widths + + +def validate_context(path: Path) -> int: + import onnx + model = onnx.load_model(path, load_external_data=False) + if any(item.data_location == onnx.TensorProto.EXTERNAL for item in model.graph.initializer): + raise ValueError("AMD deployment models must embed all external weights") + contexts = [node for node in model.graph.node if node.op_type == "EPContext"] + if not contexts: + raise ValueError("VitisAI compiled no NPU context; refusing CPU-only derived models") + for node in contexts: + attributes = {item.name: onnx.helper.get_attribute_value(item) for item in node.attribute} + if (attributes.get("embed_mode") != 1 or not attributes.get("ep_cache_context") or + attributes.get("source") not in {b"VitisAI", b"VitisAIExecutionProvider"}): + raise ValueError("AMD EP contexts must be embedded and owned by VitisAI") + return len(contexts) + + +def compile_models(source: Path, expected_sha256: str, configuration: Path, output: Path) -> dict: + import numpy as np + import onnx + import onnxruntime as ort + if digest(source) != expected_sha256: + raise ValueError("source recognition model does not match its immutable bundle hash") + if "VitisAIExecutionProvider" not in ort.get_available_providers(): + raise ValueError("run this tool with the Ryzen AI SDK Python interpreter") + config = json.loads(configuration.read_text("utf-8")) + if (config.get("target") != "VAIML" or + not any(item.get("name") == "vaiml_partition" for item in config.get("passes", []))): + raise ValueError("recognition requires a VAIML BF16 compiler configuration") + if output.exists(): + raise ValueError("output already exists; derived model identities are immutable") + output.mkdir(parents=True) + config_path = output / "vaip_config.json" + shutil.copyfile(configuration, config_path) + model = onnx.load_model(source, load_external_data=False) + if len(model.graph.input) != 1 or len(model.graph.output) != 1: + raise ValueError("recognition model must expose one input and output") + source_shape = model.graph.input[0].type.tensor_type.shape.dim + if len(source_shape) != 4: + raise ValueError("recognition input must be NCHW") + models = [] + for width in recognition_widths(): + shaped = onnx.ModelProto() + shaped.CopyFrom(model) + for dimension, value in zip(shaped.graph.input[0].type.tensor_type.shape.dim, [1, 3, 48, width]): + dimension.ClearField("dim_param") + dimension.dim_value = value + static_path = output / f"rec-{width}-source.onnx" + context_path = output / f"rec-{width}-context.onnx" + onnx.save_model(shaped, static_path) + options = ort.SessionOptions() + options.add_session_config_entry("ep.context_enable", "1") + options.add_session_config_entry("ep.context_file_path", str(context_path.absolute())) + options.add_session_config_entry("ep.context_embed_mode", "1") + session = ort.InferenceSession(str(static_path), sess_options=options, + providers=["VitisAIExecutionProvider", "CPUExecutionProvider"], + provider_options=[{"config_file": str(config_path.absolute())}, {}]) + result = session.run(None, {session.get_inputs()[0].name: np.zeros([1, 3, 48, width], dtype=np.float32)}) + if len(result) != 1 or result[0].dtype != np.float32 or result[0].ndim != 3: + raise ValueError("AMD compiler produced an incompatible recognition tensor") + del session + count = validate_context(context_path) + # Reopen the exact deployment artifact: compilation success alone is + # insufficient evidence that the embedded context can be loaded. + deployed = ort.InferenceSession(str(context_path), providers=["VitisAIExecutionProvider", "CPUExecutionProvider"], + provider_options=[{"config_file": str(config_path.absolute())}, {}]) + deployed.run(None, {deployed.get_inputs()[0].name: np.zeros([1, 3, 48, width], dtype=np.float32)}) + del deployed + models.append({"width": width, "artifact": record(context_path, output), "contextCount": count}) + static_path.unlink() + print(f"Compiled and reopened recognition bucket {width}", flush=True) + manifest = {"schemaVersion": "1.0", "sourceModelSha256": expected_sha256, + "precision": "bf16", "runtimeVersion": ort.__version__, + "compilerConfiguration": record(config_path, output), "recognitionModels": models} + (output / "compile-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", "utf-8") + return manifest + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--source-model", type=Path, required=True) + parser.add_argument("--source-sha256", required=True) + parser.add_argument("--configuration", type=Path, default=Path(__file__).with_name("vaip_config.json")) + parser.add_argument("--output-dir", type=Path, required=True) + args = parser.parse_args() + compile_models(args.source_model, args.source_sha256, args.configuration, args.output_dir) + + +if __name__ == "__main__": + main() diff --git a/tools/npu/import_amdnpu.py b/tools/npu/import_amdnpu.py new file mode 100644 index 0000000..645a842 --- /dev/null +++ b/tools/npu/import_amdnpu.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Turn an acquired Ryzen AI native deployment directory into a locked SDK. + +The proprietary SDK is acquired separately from AMD. This tool neither +installs it nor changes system drivers, environment variables, or libraries. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +import shutil +import subprocess + +try: + from .sdk import artifact_set, digest, record, recognition_widths, validate_sdk, verify_record +except ImportError: + from sdk import artifact_set, digest, record, recognition_widths, validate_sdk, verify_record + + +def import_sdk(runtime: Path, models: Path, licenses: list[Path], output: Path, version: str) -> dict: + compiled = json.loads((models / "compile-manifest.json").read_text("utf-8")) + if (compiled.get("schemaVersion") != "1.0" or compiled.get("precision") != "bf16" or + [item["width"] for item in compiled["recognitionModels"]] != recognition_widths() or + any(item.get("contextCount", 0) < 1 for item in compiled["recognitionModels"])): + raise ValueError("AMD NPU requires all 20 precompiled BF16 context models") + if not licenses or any(not item.is_file() for item in licenses): + raise ValueError("supply the SDK and third-party license files for redistribution") + if output.exists(): + raise ValueError("SDK output already exists; select a fresh output directory") + patchelf = shutil.which("patchelf") + if not patchelf: + raise ValueError("patchelf is required to make the AMD library closure package-local") + output.mkdir(parents=True) + lib = output / "lib" + lib.mkdir() + provenance = [] + for source in sorted(runtime.iterdir()): + if not re.fullmatch(r"lib[^/]+\.so(?:\.[0-9]+)*", source.name): + continue + if not source.resolve().is_file(): + raise ValueError(f"AMD runtime entry is not a file: {source}") + destination = lib / source.name + shutil.copyfile(source.resolve(), destination) + source_hash = digest(destination) + subprocess.run([patchelf, "--set-rpath", "$ORIGIN", str(destination)], check=True, capture_output=True) + provenance.append({"path": destination.relative_to(output).as_posix(), "sourceSha256": source_hash, + "packagedSha256": digest(destination)}) + names = {item.name for item in lib.iterdir()} + if "libonnxruntime_providers_vitisai.so" not in names: + raise ValueError("AMD deployment directory has no VitisAI provider library") + library = lib / "libonnxruntime.so" + if not library.exists(): + candidates = sorted(lib.glob("libonnxruntime.so.*")) + if len(candidates) != 1: + raise ValueError("AMD deployment directory has no unambiguous ORT runtime") + library = candidates[0] + # Check the direct ELF closure, allowing only the OS and installed NPU + # driver stack outside the bundle. No LD_LIBRARY_PATH is needed for SDK libs. + system = {"libc.so.6", "libstdc++.so.6", "libgcc_s.so.1", "libm.so.6", "libdl.so.2", + "libpthread.so.0", "librt.so.1", "ld-linux-x86-64.so.2"} + for file in lib.iterdir(): + dynamic = subprocess.run(["readelf", "-d", str(file)], check=True, capture_output=True, text=True).stdout + needed = set(re.findall(r"\(NEEDED\).*\[([^\]]+)\]", dynamic)) + missing = {name for name in needed - names - system if not name.startswith(("libxrt", "libdrm", "libudev"))} + if missing: + raise ValueError(f"incomplete AMD dependency closure for {file.name}: {sorted(missing)}") + model_dir = output / "models" + model_dir.mkdir() + model_records = [] + for item in compiled["recognitionModels"]: + source = verify_record(item["artifact"], models) + destination = model_dir / f"rec-{item['width']}.onnx" + shutil.copyfile(source, destination) + model_records.append({"width": item["width"], "artifact": record(destination, output)}) + config_source = verify_record(compiled["compilerConfiguration"], models) + config = output / "vaip_config.json" + shutil.copyfile(config_source, config) + license_dir = output / "licenses" + license_dir.mkdir() + for index, source in enumerate(licenses): + shutil.copyfile(source, license_dir / f"{index}-{source.name}") + artifacts = [record(path, output) for path in sorted(output.rglob("*")) + if path.is_file() and path.parent != license_dir] + manifest = {"schemaVersion": "1.0", "provider": "amdnpu", "platformId": "linux-x64", + "providerVersion": version, "qualificationId": f"amdnpu-{version}-linux-x64-candidate-v1", + "qualificationOnly": True, "runtimeLibrary": library.relative_to(output).as_posix(), + "configuration": {"sourceModelSha256": compiled["sourceModelSha256"], + "recognitionModels": model_records, "compilerConfiguration": record(config, output)}, + "artifacts": artifacts, "artifactSetSha256": artifact_set(artifacts), + "files": [record(path, output) for path in sorted(output.rglob("*")) if path.is_file()], + "licenses": [record(path, output) for path in sorted(license_dir.iterdir())], + "provenance": provenance, + "source": {"url": "https://ryzenai.docs.amd.com/en/latest/linux.html"}} + (output / "sdk-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", "utf-8") + return validate_sdk(output, "amdnpu") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--runtime-dir", type=Path, required=True) + parser.add_argument("--models-dir", type=Path, required=True) + parser.add_argument("--license-file", type=Path, action="append", required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--version", default="1.8.0") + args = parser.parse_args() + value = import_sdk(args.runtime_dir, args.models_dir, args.license_file, args.output_dir, args.version) + print(f"AMD NPU SDK: {value['artifactSetSha256']} (qualification-only)") + + +if __name__ == "__main__": + main() diff --git a/tools/npu/openvino.lock.json b/tools/npu/openvino.lock.json new file mode 100644 index 0000000..0547192 --- /dev/null +++ b/tools/npu/openvino.lock.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": "1.0", + "version": "2026.4.0", + "qualificationId": "openvino-2026.4.0-linux-x64-candidate-v1", + "package": { + "filename": "openvino-2026.4.0-linux-x64.whl", + "url": "https://files.pythonhosted.org/packages/3f/93/a24476fbf4e96b034adba461d3cc8df5e62c92dcb153a07d9656f9da77aa/openvino-2026.4.0-22959-cp311-cp311-manylinux_2_28_x86_64.whl", + "bytes": 59116483, + "sha256": "71ee4e65a914044a510af43da16f3279c8f9dae22cbc8eac63d4a785c4a9c03f", + "license": "Apache-2.0", + "metadata": "https://pypi.org/pypi/openvino/2026.4.0/json" + }, + "runtimeLibrary": "lib/libopenvino_c.so.2640", + "libraries": [ + "libopenvino.so.2640", + "libopenvino_c.so.2640", + "libopenvino_intel_npu_plugin.so", + "libopenvino_onnx_frontend.so.2640", + "libtbb.so.12" + ], + "configuration": { + "runtimeVersionPrefix": "2026.4.0", + "minimumDriverVersion": "1789315676", + "minimumCompilerVersion": "524291" + } +} diff --git a/tools/npu/prepare_release.py b/tools/npu/prepare_release.py new file mode 100644 index 0000000..8f86027 --- /dev/null +++ b/tools/npu/prepare_release.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Validate accepted SDK inputs and expose paths to the release workflow.""" +import argparse +from pathlib import Path + +try: + from .sdk import validate_sdk +except ImportError: + from sdk import validate_sdk + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--sdk-root", type=Path, required=True) + parser.add_argument("--github-env", type=Path, required=True) + args = parser.parse_args() + found = False + lines = [] + for provider in ("openvino", "amdnpu"): + directory = args.sdk_root / provider + if not directory.exists(): + continue + value = validate_sdk(directory, provider) + if value["qualificationOnly"]: + raise ValueError(f"{provider} is a candidate; release requires reviewed device reports") + path = str(directory.resolve()) + if "\n" in path or "\r" in path: + raise ValueError("invalid SDK directory") + lines.append(f"{provider.upper()}_SDK_DIR={path}\n") + found = True + if not found: + raise ValueError("accepted SDK artifact contains neither openvino/ nor amdnpu/") + with args.github_env.open("a", encoding="utf-8") as stream: + stream.writelines(lines) + + +if __name__ == "__main__": + main() diff --git a/tools/npu/sdk.py b/tools/npu/sdk.py new file mode 100644 index 0000000..1a134c9 --- /dev/null +++ b/tools/npu/sdk.py @@ -0,0 +1,310 @@ +"""Validate and stage NPU SDKs without trusting system library search paths.""" +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path, PurePosixPath +import shutil +import re + +GATES = {"quality", "performance", "coldStart", "cache", "lifecycle", + "failurePaths", "autoSelection", "offlineDistribution", "placement", + "redistribution"} + + +def validate_acceptance(value: dict, reports: list[dict]) -> None: + """Validate reviewed gate reports, never infer acceptance from build success.""" + if not reports: + raise ValueError("NPU acceptance requires device reports") + families = set() + for report in reports: + if (report.get("schemaVersion") != "1.0" or + report.get("provider") != value["provider"] or + report.get("platformId") != value["platformId"] or + report.get("artifactSetSha256") != value["artifactSetSha256"] or + report.get("configuration") != value["configuration"] or + not isinstance(report.get("reviewedBy"), str) or not report["reviewedBy"].strip() or + not isinstance(report.get("evidence"), dict) or not report["evidence"] or + set(report.get("gates", {})) != GATES or + any(result is not True for result in report["gates"].values()) or + not isinstance(report.get("deviceFamily"), str) or not report["deviceFamily"]): + raise ValueError("NPU device report is not reviewed, complete, or bound to this SDK") + families.add(report["deviceFamily"]) + if value["provider"] == "openvino" and any( + not re.fullmatch(r"[1-9][0-9]*", family) for family in families): + raise ValueError("Intel deviceFamily must be the numeric DEVICE_ARCHITECTURE property") + if value["provider"] == "openvino" and len(families) < 2: + raise ValueError("Intel release requires reviewed reports from at least two NPU generations") + if value["provider"] == "amdnpu" and not families <= {"STX", "KRK", "STX/KRK"}: + raise ValueError("AMD BF16 release requires STX/KRK device reports") + + +def digest(path: Path) -> str: + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def record(path: Path, root: Path) -> dict: + return {"path": path.relative_to(root).as_posix(), + "bytes": path.stat().st_size, "sha256": digest(path)} + + +def relative_path(value: str) -> PurePosixPath: + if not isinstance(value, str): + raise ValueError("NPU artifact path must be a string") + path = PurePosixPath(value) + if (not value or value == "." or path.is_absolute() or ".." in path.parts or + "\\" in value or path.as_posix() != value): + raise ValueError(f"unsafe NPU artifact path: {value}") + return path + + +def verify_record(item: dict, root: Path) -> Path: + if not isinstance(item, dict) or set(item) != {"path", "bytes", "sha256"}: + raise ValueError("invalid NPU artifact record") + path = root.joinpath(*relative_path(item["path"]).parts) + if (path.is_symlink() or not path.is_file() or + any(parent.is_symlink() for parent in path.parents if parent != root.parent)): + raise ValueError(f"NPU artifact must be a regular file: {path}") + path.resolve().relative_to(root.resolve()) + if (type(item["bytes"]) is not int or item["bytes"] <= 0 or + path.stat().st_size != item["bytes"] or digest(path) != item["sha256"]): + raise ValueError(f"NPU artifact identity mismatch: {path}") + return path + + +def artifact_set(items: list[dict]) -> str: + return hashlib.sha256(json.dumps(items, sort_keys=True, + separators=(",", ":")).encode()).hexdigest() + + +def validate_sdk(root: Path, provider: str | None = None) -> dict: + root = root.absolute() + if (root / "sdk-manifest.json").is_symlink(): + raise ValueError("NPU SDK manifest must be a regular file") + value = json.loads((root / "sdk-manifest.json").read_text("utf-8")) + if (value.get("schemaVersion") != "1.0" or + value.get("provider") not in {"openvino", "amdnpu"} or + value.get("platformId") != "linux-x64" or + (provider and value["provider"] != provider) or + type(value.get("qualificationOnly")) is not bool or + not isinstance(value.get("qualificationId"), str) or + not re.fullmatch(r"[A-Za-z0-9_.-]+", value["qualificationId"]) or + not isinstance(value.get("providerVersion"), str) or not value["providerVersion"]): + raise ValueError("invalid NPU SDK identity") + files = value.get("files", []) + if not files or len({item["path"] for item in files}) != len(files): + raise ValueError("duplicate or empty NPU SDK inventory") + for item in files: + verify_record(item, root) + if any(p.is_symlink() for p in root.rglob("*")): + raise ValueError("NPU SDK inventory cannot contain symlinks") + actual = {p.relative_to(root).as_posix() for p in root.rglob("*") + if p.is_file() and p != root / "sdk-manifest.json"} + if actual != {item["path"] for item in files}: + raise ValueError("NPU SDK inventory is incomplete") + artifacts = value.get("artifacts", []) + if (not artifacts or len({item["path"] for item in artifacts}) != len(artifacts) + or any(item not in files for item in artifacts)): + raise ValueError("NPU runtime artifacts are outside the SDK inventory") + if artifact_set(artifacts) != value.get("artifactSetSha256"): + raise ValueError("NPU artifact set hash mismatch") + if value.get("runtimeLibrary") not in {item["path"] for item in artifacts}: + raise ValueError("NPU runtime library is absent from the SDK") + if not value.get("licenses") or any(item not in files for item in value["licenses"]): + raise ValueError("NPU SDK has no verified licenses") + config = value.get("configuration", {}) + if value["provider"] == "openvino": + if set(config) != {"runtimeVersionPrefix", "minimumDriverVersion", "minimumCompilerVersion"}: + raise ValueError("invalid OpenVINO configuration") + if (any(not isinstance(item, str) or not item for item in config.values()) or + not re.fullmatch(r"[0-9]+", config["minimumDriverVersion"]) or + not re.fullmatch(r"[0-9]+", config["minimumCompilerVersion"]) or + any(int(config[key]) > 2**64 - 1 for key in + ("minimumDriverVersion", "minimumCompilerVersion"))): + raise ValueError("invalid OpenVINO numeric driver/compiler floors") + required = {"libopenvino_c.so.2640", "libopenvino.so.2640", + "libopenvino_intel_npu_plugin.so", "libopenvino_onnx_frontend.so.2640", + "libtbb.so.12"} + if {Path(item["path"]).name for item in artifacts} != required: + raise ValueError("incomplete OpenVINO NPU dependency closure") + else: + if set(config) != {"sourceModelSha256", "recognitionModels", "compilerConfiguration"}: + raise ValueError("invalid AMD NPU configuration") + widths = [item.get("width") for item in config["recognitionModels"]] + if widths != recognition_widths(): + raise ValueError("AMD NPU recognition must contain all 20 locked width buckets") + if (not isinstance(config["sourceModelSha256"], str) or + not re.fullmatch(r"[0-9a-f]{64}", config["sourceModelSha256"]) or + len({item["artifact"]["path"] for item in config["recognitionModels"]}) != 20 or + config["compilerConfiguration"] not in artifacts or + any(item["artifact"] not in artifacts for item in config["recognitionModels"])): + raise ValueError("AMD NPU recognition models are outside the SDK inventory") + compiler = json.loads(verify_record(config["compilerConfiguration"], root).read_text("utf-8")) + if (compiler.get("target") != "VAIML" or + not any(item.get("name") == "vaiml_partition" for item in compiler.get("passes", []))): + raise ValueError("AMD NPU SDK must use a VAIML BF16 configuration") + # A production SDK must carry reviewed evidence bound to the exact bytes. + if not value["qualificationOnly"]: + accepted = value.get("acceptance", {}) + if (accepted.get("artifactSetSha256") != value["artifactSetSha256"] or + accepted.get("providerGatePassed") is not True or not accepted.get("reports") or + any(item not in files for item in accepted["reports"])): + raise ValueError("NPU SDK has no accepted qualification evidence") + validate_acceptance(value, [json.loads(verify_record(item, root).read_text("utf-8")) + for item in accepted["reports"]]) + return value + + +def recognition_widths() -> list[int]: + return [320, 384, 480, 544, 576, 608, 704, 736, 832, 960, + 1056, 1184, 1248, 1376, 1600, 1984, 2240, 2560, 2880, 3200] + + +def validate_descriptor_npu(descriptor: dict, root: Path) -> set[str]: + """Check the additional providers before accepting their runtime inventory.""" + paths = set() + for name in ("openvino", "amdnpu"): + provider = descriptor["providers"].get(name) + if provider is None: + continue + if descriptor["schemaVersion"] != "2.1" or descriptor["platform"]["id"] != "linux-x64": + raise ValueError("NPU providers require schema 2.1 and Linux x64 glibc") + if set(provider) != {"runtimeProvider", "providerVersion", "qualificationId", + "providerLibrary", "configuration", "artifacts"}: + raise ValueError("invalid NPU provider descriptor fields") + expected = "OpenVINO" if name == "openvino" else "VitisAIExecutionProvider" + if (provider["runtimeProvider"] != expected or not provider["providerVersion"] or + not provider["qualificationId"] or not provider["artifacts"] or + provider["providerLibrary"] not in provider["artifacts"]): + raise ValueError("invalid NPU provider descriptor identity") + seen = set() + for item in provider["artifacts"]: + verify_record(item, root) + if (not item["path"].startswith(f"native/{name}/") or + item not in descriptor["runtime"]["artifacts"] or item["path"] in seen): + raise ValueError("NPU artifact is outside its provider inventory") + seen.add(item["path"]) + paths.update(seen) + config = provider["configuration"] + if name == "openvino": + if (set(config) != {"runtimeVersionPrefix", "minimumDriverVersion", "minimumCompilerVersion"} + or any(not isinstance(v, str) or not v for v in config.values()) + or not config["minimumDriverVersion"].isascii() + or not config["minimumDriverVersion"].isdecimal() + or not config["minimumCompilerVersion"].isascii() + or not config["minimumCompilerVersion"].isdecimal()): + raise ValueError("invalid OpenVINO numeric driver/compiler floors") + expected_names = {"libopenvino_c.so.2640", "libopenvino.so.2640", + "libopenvino_intel_npu_plugin.so", "libopenvino_onnx_frontend.so.2640", + "libtbb.so.12"} + if {Path(p).name for p in seen} != expected_names: + raise ValueError("incomplete OpenVINO dependency closure") + if Path(provider["providerLibrary"]["path"]).name != "libopenvino_c.so.2640": + raise ValueError("invalid OpenVINO C runtime library") + else: + if (set(config) != {"sourceModelSha256", "recognitionModels", "compilerConfiguration"} or + not isinstance(config["sourceModelSha256"], str) or + len(config["sourceModelSha256"]) != 64 or + any(c not in "0123456789abcdef" for c in config["sourceModelSha256"])): + raise ValueError("invalid AMD NPU source model identity") + if config["compilerConfiguration"] not in provider["artifacts"]: + raise ValueError("AMD NPU compiler configuration is outside its inventory") + models = config["recognitionModels"] + if ([item.get("width") for item in models] != recognition_widths() or + any(set(item) != {"width", "artifact"} or + item["artifact"] not in provider["artifacts"] for item in models)): + raise ValueError("incomplete AMD NPU recognition buckets") + if not Path(provider["providerLibrary"]["path"]).name.startswith("libonnxruntime.so"): + raise ValueError("invalid AMD ORT runtime library") + return paths + + +def stage_sdk(root: Path, stage: Path, descriptor: dict) -> dict: + value = validate_sdk(root) + provider = value["provider"] + if descriptor["platform"]["id"] != value["platformId"]: + raise ValueError("NPU SDK platform does not match the native package") + prefix = f"native/{provider}/" + artifacts = [] + by_path = {} + for item in value["artifacts"]: + source = verify_record(item, root) + destination = stage / (prefix + item["path"]) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + output = record(destination, stage) + artifacts.append(output) + by_path[item["path"]] = output + configuration = dict(value["configuration"]) + if provider == "amdnpu": + configuration["compilerConfiguration"] = by_path[configuration["compilerConfiguration"]["path"]] + configuration["recognitionModels"] = [ + {"width": item["width"], "artifact": by_path[item["artifact"]["path"]]} + for item in configuration["recognitionModels"]] + descriptor["providers"][provider] = { + "runtimeProvider": "OpenVINO" if provider == "openvino" else "VitisAIExecutionProvider", + "providerVersion": value["providerVersion"], + "qualificationId": value["qualificationId"], + "providerLibrary": by_path[value["runtimeLibrary"]], + "configuration": configuration, "artifacts": artifacts, + } + descriptor["runtime"]["artifacts"].extend(artifacts) + # NPU candidates are ordered consistently across Core, addon and loader. + descriptor["autoPolicy"]["providers"] = [ + name for name in ["openvino", "amdnpu", "webgpu", "cpu"] + if name in descriptor["providers"]] + descriptor["qualificationOnly"] |= value["qualificationOnly"] + descriptor["released"] = not descriptor["qualificationOnly"] + descriptor["schemaVersion"] = "2.1" + return value + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--sdk-dir", type=Path, required=True) + parser.add_argument("--provider", choices=["openvino", "amdnpu"]) + parser.add_argument("--stage-dir", type=Path) + parser.add_argument("--emit-header", type=Path) + args = parser.parse_args() + manifest = validate_sdk(args.sdk_dir, args.provider) + if args.emit_header: + provider = manifest["provider"] + q = json.dumps + library = next(item for item in manifest["artifacts"] if item["path"] == manifest["runtimeLibrary"]) + lines = ["#pragma once", '#include "core/engine_factory.hpp"', + "namespace light_ocr::internal {", + f"inline void apply_{provider}_sdk_defaults(RuntimePolicy& policy) {{"] + library_path = (args.sdk_dir.absolute() / library["path"]).as_posix() + if provider == "openvino": + lines.extend([f"policy.openvino_runtime_library = {q(library_path)};", + f"policy.openvino_runtime_bytes = {library['bytes']}ULL;", + f"policy.openvino_runtime_sha256 = {q(library['sha256'])};"]) + for field, key in [("runtime_version_prefix", "runtimeVersionPrefix"), + ("minimum_driver_version", "minimumDriverVersion"), + ("minimum_compiler_version", "minimumCompilerVersion")]: + lines.append(f"policy.openvino_{field} = {q(manifest['configuration'][key])};") + else: + lines.append(f"policy.amdnpu_runtime = {{{q(library_path)}, {library['bytes']}ULL, {q(library['sha256'])}}};") + file = manifest["configuration"]["compilerConfiguration"] + file_path = (args.sdk_dir.absolute() / file["path"]).as_posix() + lines.append(f"policy.amdnpu_compiler_configuration = {{{q(file_path)}, {file['bytes']}ULL, {q(file['sha256'])}}};") + lines.append(f"policy.amdnpu_source_model_sha256 = {q(manifest['configuration']['sourceModelSha256'])};") + for item in manifest["configuration"]["recognitionModels"]: + file = item["artifact"] + file_path = (args.sdk_dir.absolute() / file["path"]).as_posix() + lines.append(f"policy.amdnpu_recognition_models.push_back({{{item['width']}, " + f"{{{q(file_path)}, {file['bytes']}ULL, {q(file['sha256'])}}}}});") + lines.extend(["}", "}", ""]) + args.emit_header.parent.mkdir(parents=True, exist_ok=True) + args.emit_header.write_text("\n".join(lines), "utf-8") + if args.stage_dir: + path = args.stage_dir / "native/runtime-descriptor.json" + descriptor = json.loads(path.read_text("utf-8")) + stage_sdk(args.sdk_dir, args.stage_dir, descriptor) + path.write_text(json.dumps(descriptor, indent=2) + "\n", "utf-8") + + +if __name__ == "__main__": + main() diff --git a/tools/npu/vaip_config.json b/tools/npu/vaip_config.json new file mode 100644 index 0000000..6dba3aa --- /dev/null +++ b/tools/npu/vaip_config.json @@ -0,0 +1,12 @@ +{ + "passes": [ + {"name": "init", "plugin": "vaip-pass_init"}, + { + "name": "vaiml_partition", + "plugin": "vaip-pass_vaiml_partition", + "vaiml_config": {"optimize_level": 1, "preferred_data_storage": "vectorized"} + } + ], + "target": "VAIML", + "targets": [{"name": "VAIML", "pass": ["init", "vaiml_partition"]}] +} From 89305e17c93ed0601f8a96fd8f4a9bddac0b3c2b Mon Sep 17 00:00:00 2001 From: eric8810 Date: Tue, 6 Oct 2026 19:41:23 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(npu):=20=E8=8A=AF=E9=97=A8=E5=BE=85?= =?UTF-8?q?=E5=90=AF=EF=BC=8C=E6=98=8E=E9=85=8D=E6=96=B9=E8=A1=8C=20=C2=B7?= =?UTF-8?q?=20make=20AMD=20NPU=20opt-in=20and=20device=20reports=20optiona?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/npm-release.yml | 6 +++--- .github/workflows/npu-native.yml | 19 ++++++++++-------- CHANGELOG.md | 10 ++++++---- CMakeLists.txt | 4 +++- bindings/node/src/addon.cpp | 3 --- cmake/NpuRuntime.cmake | 5 +++-- docs/amd-npu-acceleration.md | 12 +++++------ docs/intel-npu-acceleration.md | 8 +++++--- docs/npu-runtime-release.md | 30 ++++++++++++++++++++-------- packages/runtime/src/load-native.cjs | 2 +- src/core/engine.cpp | 20 +++++-------------- tests/unit/test_selection.cpp | 7 ++++++- tools/npm_release.py | 5 +---- tools/npu/import_amdnpu.py | 2 +- tools/npu/prepare_release.py | 8 +++----- tools/npu/sdk.py | 6 +++--- 16 files changed, 79 insertions(+), 68 deletions(-) diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 2fe5757..d782e94 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -14,7 +14,7 @@ on: default: false type: boolean npu_sdk_run_id: - description: Optional run containing reviewed npu-release-sdks (Linux x64 only) + description: Optional run containing npu-release-sdks (AMD requires explicit provider configuration) required: false default: "" type: string @@ -89,7 +89,7 @@ jobs: - name: Bootstrap pinned native dependencies shell: bash run: python tools/bootstrap_dependencies.py --cache-dir .cache/dependencies - - name: Download accepted NPU SDKs + - name: Download distributable NPU SDKs if: matrix.id == 'linux-x64' && inputs.npu_sdk_run_id != '' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: @@ -97,7 +97,7 @@ jobs: path: dist/npu-sdk run-id: ${{ inputs.npu_sdk_run_id }} github-token: ${{ github.token }} - - name: Verify accepted NPU evidence and runtime identities + - name: Verify NPU SDK inventory and runtime identities if: matrix.id == 'linux-x64' && inputs.npu_sdk_run_id != '' run: python tools/npu/prepare_release.py --sdk-root dist/npu-sdk --github-env "$GITHUB_ENV" - name: Fetch the pinned PDF renderer for npm bundling (Unix) diff --git a/.github/workflows/npu-native.yml b/.github/workflows/npu-native.yml index 549e46c..89556dc 100644 --- a/.github/workflows/npu-native.yml +++ b/.github/workflows/npu-native.yml @@ -9,7 +9,7 @@ on: default: "" type: string reviewed_reports_run_id: - description: Optional run containing npu-reviewed-reports with openvino/ and amdnpu/ JSON reports + description: Optional device reports for metadata; not required to ship NPU support required: false default: "" type: string @@ -72,8 +72,7 @@ jobs: path: reports/npu-reviewed run-id: ${{ inputs.reviewed_reports_run_id }} github-token: ${{ github.token }} - - name: Bind reviewed reports to immutable SDKs - if: inputs.reviewed_reports_run_id != '' + - name: Prepare distributable NPU SDKs shell: bash run: | for provider in openvino amdnpu; do @@ -83,13 +82,17 @@ jobs: [[ -f "$report" ]] || continue reports+=(--report "$report") done - python tools/npu/accept_qualification.py \ - --sdk-dir "dist/npu-sdk/$provider" "${reports[@]}" \ - --qualification-id "$provider-linux-x64-reviewed-v1" \ - --output-dir "dist/npu-release/$provider" + if [[ ${#reports[@]} -gt 0 ]]; then + python tools/npu/accept_qualification.py \ + --sdk-dir "dist/npu-sdk/$provider" "${reports[@]}" \ + --qualification-id "$provider-linux-x64-reviewed-v1" \ + --output-dir "dist/npu-release/$provider" + else + mkdir -p dist/npu-release + cp -R "dist/npu-sdk/$provider" "dist/npu-release/$provider" + fi done - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - if: inputs.reviewed_reports_run_id != '' with: name: npu-release-sdks path: dist/npu-release/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 03c0f88..fb732fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,19 +9,21 @@ This file records user-visible changes to `light-ocr`. Published artifact detail - Added a source-level Intel NPU backend (`provider: "openvino"`) for Linux x64. Builds configured with `LIGHT_OCR_OPENVINO_SDK_DIR` load the OpenVINO C runtime at run time, run detection and 20-bucket recognition on the NPU, and - place `openvino` first in a qualification-only Auto policy + place `openvino` first in the package Auto policy (`openvino → webgpu → cpu`); hosts without an NPU skip it with `adapter_unavailable`. Added a pinned native SDK extractor, package-local runtime descriptor 2.1, numeric driver/compiler checks, and reviewed-evidence - release staging. Published npm packages do not include OpenVINO yet, so + release staging with optional device reports. Published npm packages do not include OpenVINO yet, so `provider: "openvino"` returns `unsupported_capability` there. See [Intel NPU 加速技术方案](docs/intel-npu-acceleration.md). - Added a source-level AMD NPU backend (`provider: "amdnpu"`) for Linux x64 glibc and Ryzen AI STX/KRK devices. It isolates the vendor ORT runtime, loads 20 immutable BF16 recognition contexts, and runs detection on CPU. Added model compilation and SDK import tools, descriptor validation, - license/SBOM staging, and candidate/release workflows. AMD hardware and - model quality qualification remain pending; current published packages + license/SBOM staging, and candidate/release workflows. AMD is excluded + from Auto and enabled only through `execution.provider: "amdnpu"` (or + `--provider amdnpu`). NPU device reports are optional for release; + `deviceValidated` remains false. Current published packages return `unsupported_capability`. See [AMD NPU 加速技术方案](docs/amd-npu-acceleration.md) and [NPU SDK 构建与发布](docs/npu-runtime-release.md). diff --git a/CMakeLists.txt b/CMakeLists.txt index 6cce915..53bd1b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -271,7 +271,9 @@ if(LIGHT_OCR_BUILD_TESTS) LIGHT_OCR_WEBGPU_QUALIFICATION_BUILD=1) endif() if(LIGHT_OCR_HAS_OPENVINO) - target_compile_definitions(light_ocr_unit_tests PRIVATE LIGHT_OCR_HAS_OPENVINO=1) + target_compile_definitions(light_ocr_unit_tests PRIVATE + LIGHT_OCR_HAS_OPENVINO=1 + LIGHT_OCR_OPENVINO_QUALIFICATION_BUILD=$) endif() light_ocr_stage_onnxruntime(light_ocr_unit_tests) add_test(NAME light_ocr_unit_tests COMMAND light_ocr_unit_tests) diff --git a/bindings/node/src/addon.cpp b/bindings/node/src/addon.cpp index 89959f5..0a808f4 100644 --- a/bindings/node/src/addon.cpp +++ b/bindings/node/src/addon.cpp @@ -177,9 +177,6 @@ std::vector compiled_ordered_candidates() { #if LIGHT_OCR_NODE_HAS_OPENVINO result.push_back("openvino"); #endif -#if LIGHT_OCR_NODE_HAS_AMDNPU - result.push_back("amdnpu"); -#endif #if LIGHT_OCR_NODE_HAS_APPLE result.push_back("apple"); #elif LIGHT_OCR_NODE_HAS_WEBGPU diff --git a/cmake/NpuRuntime.cmake b/cmake/NpuRuntime.cmake index 25275ab..d82aca0 100644 --- a/cmake/NpuRuntime.cmake +++ b/cmake/NpuRuntime.cmake @@ -19,7 +19,6 @@ function(light_ocr_validate_npu_sdk provider directory) endif() file(READ "${directory}/sdk-manifest.json" _manifest) string(JSON _qualification GET "${_manifest}" qualificationId) - string(JSON _qualification_only GET "${_manifest}" qualificationOnly) string(JSON _runtime_library GET "${_manifest}" runtimeLibrary) string(TOUPPER "${provider}" _upper) target_include_directories(light_ocr_core PRIVATE "${CMAKE_BINARY_DIR}/generated") @@ -27,6 +26,8 @@ function(light_ocr_validate_npu_sdk provider directory) LIGHT_OCR_${_upper}_PACKAGE_SDK=1 LIGHT_OCR_${_upper}_QUALIFICATION_ID="${_qualification}") set(LIGHT_OCR_${_upper}_QUALIFICATION_ID "${_qualification}" PARENT_SCOPE) - set(LIGHT_OCR_${_upper}_QUALIFICATION_BUILD "${_qualification_only}" PARENT_SCOPE) + # Shipping the backend does not assert hardware qualification. Device + # reports remain optional; AMD is excluded from automatic selection. + set(LIGHT_OCR_${_upper}_QUALIFICATION_BUILD OFF PARENT_SCOPE) set(LIGHT_OCR_${_upper}_VERIFIED_LIBRARY "${directory}/${_runtime_library}" PARENT_SCOPE) endfunction() diff --git a/docs/amd-npu-acceleration.md b/docs/amd-npu-acceleration.md index 8529083..12875ab 100644 --- a/docs/amd-npu-acceleration.md +++ b/docs/amd-npu-acceleration.md @@ -1,6 +1,6 @@ # AMD NPU 加速技术方案 -更新时间:2026-10-06。状态:Linux x64 glibc 后端与打包改造已实现,Ryzen AI SDK、编译产物及真机验收尚缺。当前已发布 npm 包不含 AMD NPU 运行时。 +更新时间:2026-10-06。状态:Linux x64 glibc 后端与打包改造已实现,默认关闭、配置显式开启;Ryzen AI SDK 与编译产物尚缺,真机验收不再作为发布前置。当前已发布 npm 包不含 AMD NPU 运行时。 ## 范围与模型路线 @@ -20,13 +20,13 @@ BF16 路线由 VAIML 编译 FP32 ONNX,生成可嵌入权重与微码的 EPCont - `tools/npu/import_amdnpu.py`:从已取得的 SDK 部署目录导入原生库,设置包内 RPATH、检查 ELF 依赖闭包、记录变更前后哈希,并锁定模型、配置及许可证。 - CMake、Node descriptor 2.1、JS loader、npm staging、SBOM 和候选/正式发布流程均已接入。构建入口与报告格式见 [NPU SDK 构建与发布](npu-runtime-release.md)。 -## 发布资格 +## 默认关闭与发布策略 -SDK 导入默认 `qualificationOnly: true`。候选构建包含 `amdnpu` 时,可按 `openvino → amdnpu → webgpu → cpu` 的包含项顺序探索 Auto;当前已发布包的 Auto 策略保持原有行为。 +AMD 始终排除在 Auto 候选列表之外;用户通过 `execution.provider: "amdnpu"`、CLI `--provider amdnpu` 或 C++ 的 `ExecutionProvider::amdnpu` 显式开启。构建必须含 vendor SDK 与匹配的识别模型;运行时未配置 AMD 时不装载 vendor ORT。 -接受报告必须绑定同一 SDK 的 artifact-set SHA-256 和配置,包含审阅人、设备型号与证据,并通过质量、性能、冷启动、缓存、生命周期、失败路径、Auto、离线分发、真实 NPU placement 和再分发许可全部门槛。正式 npm staging 不接受候选 SDK。 +按维护者决定,NPU 真机验收不再阻塞发布。SDK 导入的 `qualificationOnly: true` 仅记录尚无设备证据,平台包可随其他已满足条件的运行时一起发布;`deviceValidated` 保持 `false`。库存、模型哈希、依赖闭包和许可证校验继续执行,设备报告可选。 -## 仍需真机完成的工作 +## 尚未验证的设备行为(可选后续工作) 1. 取得 Ryzen AI 1.8 Linux SDK 和 STX/KRK 主机;确认独立 namespace 装载、XRT/amdxdna 驱动及 VitisAI C API 20 能协同工作。 2. 编译真实 PP-OCRv6 模型,核对 20 桶的 NPU 分区与 FP32 CPU goldens;当前没有生成或伪造这些产物。 @@ -34,4 +34,4 @@ SDK 导入默认 `qualificationOnly: true`。候选构建包含 `amdnpu` 时, 4. 覆盖 20 次关闭/重建、驱动兼容、权限不足、无设备、损坏产物,以及与 WebGPU 共存和回退。 5. 核实 SDK/模型再分发条款和完整第三方 notices,再创建 accepted SDK。 -在这些证据齐备前,不能把源码实现或成功编译等同于 AMD NPU 已通过产品验收。 +这些设备行为尚无实测结论,不再作为源码合入或 AMD 显式开启支持发布的前置条件。 diff --git a/docs/intel-npu-acceleration.md b/docs/intel-npu-acceleration.md index 349a188..69b4b3f 100644 --- a/docs/intel-npu-acceleration.md +++ b/docs/intel-npu-acceleration.md @@ -1,6 +1,6 @@ # Intel NPU 加速技术方案 -状态:Phase B 后端和 Phase D 打包代码已实现;Linux x64 真机 14-fixture 已有验证记录;两代设备 Gate 尚未完成,当前 npm 包未包含 OpenVINO。构建和发布说明见 [NPU SDK 构建与发布](npu-runtime-release.md)。 +状态:Phase B 后端和 Phase D 打包代码已实现;Linux x64 真机 14-fixture 已有验证记录;按维护者决定,真机 Gate 不再作为发布前置;当前 npm 包未包含 OpenVINO。构建和发布说明见 [NPU SDK 构建与发布](npu-runtime-release.md)。 更新时间:2026-09-29 @@ -251,7 +251,9 @@ Spike 以临时 `InferenceSession` 实现替换 CPU 会话,由环境变量启 ## 11. Gate -沿用 Linux WebGPU Gate 的结构,增加 NPU 特有项: +2026-10-06 发布策略更新:以下设备实验作为可选后续证据,不再作为 NPU 发布前置。历史分阶段 Gate 规划由这一决定覆盖;发布仍检查包内库存、哈希、模型和许可证。AMD 始终不进入 Auto,Intel 保留默认候选位置,诊断不声称设备已经验收。 + +原实验清单沿用 Linux WebGPU Gate 的结构,增加 NPU 特有项: - **质量**:14-fixture locked corpus 与 CPU FP32 goldens 对比;文本不一致行数不超过审阅后的 parity exceptions,框最大偏差与置信度差设上限;FP16 边界差异须逐条审阅。 - **性能**:每个 case 3 次独立 cold start × (2 warmup + 10 次测量);报告 P50、P95、各阶段时间、进程 CPU-s 与 RSS。 @@ -300,7 +302,7 @@ Spike 数据见 §3;临时代码不合入主干。 | 无 NPU(bwrap 隐藏 `/dev/accel`)Auto | `openvino skipped(adapter_unavailable) → webgpu selected` | | 显式 `webgpu` | 进程不加载 OpenVINO | -2026-10-06 补齐:锁定 OpenVINO 2026.4.0 官方 wheel,提取 C headers 和五个原生运行时库;Node descriptor 2.1 声明包内路径、完整哈希和驱动/compiler 最低版本;最低版本使用 OpenVINO 属性的十进制数值,避免误用发行版包版本。会话通过 `LOADED_FROM_CACHE` 报告 hit/miss。发布工具附带许可证、SBOM 和审阅报告,并拒绝未经验收的候选 SDK。尚未实现的性能优化:后台预热其余 recognition 桶、LRU 中优先保留 recognition 桶。 +2026-10-06 补齐:锁定 OpenVINO 2026.4.0 官方 wheel,提取 C headers 和五个原生运行时库;Node descriptor 2.1 声明包内路径、完整哈希和驱动/compiler 最低版本;最低版本使用 OpenVINO 属性的十进制数值,避免误用发行版包版本。会话通过 `LOADED_FROM_CACHE` 报告 hit/miss。发布工具附带许可证、SBOM 和审阅报告,设备报告可选,不再拒绝缺少真机报告的 SDK;包已发布不等同于设备已验收,`deviceValidated` 保持 `false`。尚未实现的性能优化:后台预热其余 recognition 桶、LRU 中优先保留 recognition 桶。 ### Phase C — Linux x64 真机 Gate diff --git a/docs/npu-runtime-release.md b/docs/npu-runtime-release.md index 797a4af..edfe99a 100644 --- a/docs/npu-runtime-release.md +++ b/docs/npu-runtime-release.md @@ -52,11 +52,25 @@ cmake -S . -B build-npu -G Ninja \ cmake --build build-npu --parallel 2 ``` -可省略未取得的 SDK 参数。Node 构建要求 SDK manifest、完整库存和哈希全部有效。产物在 `build-npu/node-runtime`,descriptor 使用 schema 2.1,把 NPU runtime 与基础 ORT 分开声明。JS loader 先验证全部产物,再把包内绝对路径传入 addon;候选排序采用已包含的 `openvino → amdnpu → webgpu → cpu`。 +可省略未取得的 SDK 参数。Node 构建要求 SDK manifest、完整库存和哈希全部有效。产物在 `build-npu/node-runtime`,descriptor 使用 schema 2.1,把 NPU runtime 与基础 ORT 分开声明。JS loader 先验证全部产物,再把包内绝对路径传入 addon;Auto 排序采用已包含的 `openvino → webgpu → cpu`;AMD 始终不进入 Auto。 -AMD 默认需要 CPU detector,且其模型绑定具体识别模型哈希;换用其他 tier 时需重新编译、审阅对应 SDK。Windows、arm64 和 musl 不接受当前 NPU SDK。 +AMD 默认需要 CPU detector,且其模型绑定具体识别模型哈希;换用其他 tier 时需重新编译对应 SDK。Windows、arm64 和 musl 不接受当前 NPU SDK。 -## 审阅报告与正式 SDK +## 用户显式开启 AMD + +含 AMD SDK 与模型的构建默认不会选择或装载 AMD runtime。Node.js 用户在创建引擎时指定: + +```js +const engine = await createEngine({ + execution: { provider: "amdnpu" } +}); +``` + +模型无关的 `@arcships/light-ocr-runtime` 还需传入 `bundlePath`;模型 facade 自动提供 bundle。CLI 使用 `--provider amdnpu`;C++ 使用 `options.execution.provider = ExecutionProvider::amdnpu`。省略该配置或指定 `auto` 会走默认候选列表。显式 AMD 请求不静默回退;缺少包内 AMD SDK/模型、设备或驱动时报告对应错误。 + +发布不要求 NPU 真机报告;`deviceValidated` 保持 `false`。SDK 和真实编译模型仍必须取得并随构建交付,此设置不会生成缺失的 vendor 产物。 + +## 可选设备报告 `accept_qualification.py` 只检查审阅结果及其绑定关系,不执行设备实验。每份 JSON 报告应包含: @@ -79,7 +93,7 @@ AMD 默认需要 CPU detector,且其模型绑定具体识别模型哈希;换 } ``` -报告中的门槛需经实际实验及人工审阅后才能填写为 `true`。具体 Intel 门槛见 [Intel Gate](intel-npu-acceleration.md#11-gate),AMD 待验收项见 [AMD 方案](amd-npu-acceleration.md)。Intel `deviceFamily` 必须使用设备属性 `DEVICE_ARCHITECTURE` 的十进制字符串(如 `5010`),至少需要两个不同架构报告;不能把同代不同机器计作两代。AMD 至少需要一份真实 STX/KRK 报告,family 使用 `STX`、`KRK` 或 `STX/KRK`。 +真机报告不作为 NPU 支持发布前置。下面的报告接受工具仅供以后记录设备证据;使用它时,门槛需经实际实验及人工审阅后才能填写为 `true`。具体实验清单见 [Intel Gate](intel-npu-acceleration.md#11-gate) 和 [AMD 方案](amd-npu-acceleration.md)。Intel `deviceFamily` 必须使用设备属性 `DEVICE_ARCHITECTURE` 的十进制字符串(如 `5010`),至少需要两个不同架构报告;不能把同代不同机器计作两代。AMD 至少需要一份真实 STX/KRK 报告,family 使用 `STX`、`KRK` 或 `STX/KRK`。 ```bash python tools/npu/accept_qualification.py \ @@ -90,14 +104,14 @@ python tools/npu/accept_qualification.py \ --output-dir dist/npu-release/openvino ``` -工具创建新的 SDK 目录,保留所有运行时字节,将报告及其哈希纳入库存,并设置正式资格。原候选保持不变。 +工具创建新的 SDK 目录,保留所有运行时字节,将报告及其哈希纳入库存,并记录 SDK 的设备资格。原候选保持不变;发布不要求执行这一步,运行时也不会仅因已发布就声称 `deviceValidated: true`。 ## CI 与 npm staging -`NPU native candidates` workflow 生成 Intel SDK 与 Node 候选构建。可输入另一个 run 的 `amdnpu-candidate-sdk` artifact,内容应为 AMD SDK 目录。提供 `npu-reviewed-reports` artifact run ID 时,报告按 `openvino/*.json`、`amdnpu/*.json` 放置;全部审阅门槛通过后才上传 `npu-release-sdks`。 +`NPU native candidates` workflow 生成 Intel SDK 与 Node 候选构建。可输入另一个 run 的 `amdnpu-candidate-sdk` artifact,内容应为 AMD SDK 目录。始终上传可分发的 `npu-release-sdks`,无需设备报告。可选的 `npu-reviewed-reports` artifact 按 `openvino/*.json`、`amdnpu/*.json` 放置;仅在主动提供报告时才检查其绑定关系。 -`npm release` workflow 的可选 `npu_sdk_run_id` 读取同仓库的 `npu-release-sdks`(根目录下为 `openvino/`、`amdnpu/`),仅 Linux x64 构建使用。CI 在配置 CMake 前重新验证接受报告及 SDK 字节。 +`npm release` workflow 的可选 `npu_sdk_run_id` 读取同仓库的 `npu-release-sdks`(根目录下为 `openvino/`、`amdnpu/`),仅 Linux x64 构建使用。CI 在配置 CMake 前验证 SDK 库存、模型、配置和哈希;已附带的接受报告仍会校验,但不要求提供报告。 -本地 `tools/npm_release.py stage-native` 接受 `--openvino-sdk-dir` 和 `--amdnpu-sdk-dir`;SDK 需与构建 addon 使用的版本、资格 ID 和包含的 provider 一致。候选必须显式添加 `--qualification-build`;正式 `assemble` 始终拒绝候选。许可证、SBOM、SDK manifest 和接受报告随平台包保存。 +本地 `tools/npm_release.py stage-native` 接受 `--openvino-sdk-dir` 和 `--amdnpu-sdk-dir`;SDK 需与构建 addon 使用的版本、资格 ID 和包含的 provider 一致。NPU SDK 的 `qualificationOnly` 记录其设备证据状态,不再使整个平台包变成 qualification build;无需真机报告即可 staging。WebGPU 自身的既有资格门槛仍适用,正式 `assemble` 仍拒绝基础运行时的 qualification build。许可证、SBOM、SDK manifest 和可选报告随平台包保存。 正式发布仍沿用项目补丁版本策略;当前候选尚未发布到 npm。 diff --git a/packages/runtime/src/load-native.cjs b/packages/runtime/src/load-native.cjs index 405f8fb..acad02e 100644 --- a/packages/runtime/src/load-native.cjs +++ b/packages/runtime/src/load-native.cjs @@ -495,7 +495,7 @@ function validateRuntimeDescriptor(descriptorPath) { ? ['apple', 'cpu'] : ['cpu']; const sortedAvailable = [...availableProviders].sort(); - expectedPolicy.unshift(...npu.names); + expectedPolicy.unshift(...npu.names.filter((name) => name !== 'amdnpu')); expectedAvailable.push(...npu.names); expectedAvailable.sort(); if ( diff --git a/src/core/engine.cpp b/src/core/engine.cpp index fb88a94..f70c54b 100644 --- a/src/core/engine.cpp +++ b/src/core/engine.cpp @@ -951,14 +951,7 @@ internal::RuntimePolicy internal::builtin_runtime_policy() { apply_amdnpu_sdk_defaults(policy); policy.available_providers.push_back("amdnpu"); policy.provider_qualification_ids.push_back(LIGHT_OCR_AMDNPU_QUALIFICATION_ID); - const auto insertion = policy.ordered_candidates.front() == "openvino" - ? policy.ordered_candidates.begin() + 1 - : policy.ordered_candidates.begin(); - policy.ordered_candidates.insert(insertion, "amdnpu"); - if (LIGHT_OCR_AMDNPU_QUALIFICATION_BUILD) { - policy.qualification_only = true; - policy.released = false; - } + // AMD NPU is available only through an explicit provider request. #endif #if defined(LIGHT_OCR_HAS_WEBGPU) && defined(LIGHT_OCR_WEBGPU_QUALIFICATION_BUILD) policy.qualification_only = true; @@ -1237,8 +1230,7 @@ Result> internal::EngineFactory::create( openvino_detection_config.provider = ExecutionProvider::openvino; openvino_detection_config.qualification_id = policy_qualification_id(runtime_policy, candidate); - openvino_detection_config.npu_device_validated = - runtime_policy.released && !runtime_policy.qualification_only; + openvino_detection_config.npu_device_validated = false; openvino_detection_config.shape_policy = "nchw-static-exact-32-960-v1"; openvino_detection_config.openvino_runtime_library = runtime_policy.openvino_runtime_library; @@ -1344,7 +1336,7 @@ Result> internal::EngineFactory::create( auto amd_config = recognition_config; amd_config.provider = ExecutionProvider::amdnpu; amd_config.qualification_id = policy_qualification_id(runtime_policy, candidate); - amd_config.npu_device_validated = runtime_policy.released && !runtime_policy.qualification_only; + amd_config.npu_device_validated = false; amd_config.amdnpu_runtime = runtime_policy.amdnpu_runtime; amd_config.amdnpu_compiler_configuration = runtime_policy.amdnpu_compiler_configuration; amd_config.amdnpu_source_model_sha256 = runtime_policy.amdnpu_source_model_sha256; @@ -1505,14 +1497,12 @@ Result> internal::EngineFactory::create( if (policy_includes_provider(runtime_policy, "openvino")) { info.execution.provider_capabilities.push_back(ProviderCapabilityInfo{ "openvino", true, selected_provider == ExecutionProvider::openvino, - selected_provider == ExecutionProvider::openvino && - runtime_policy.released && !runtime_policy.qualification_only}); + false}); } if (policy_includes_provider(runtime_policy, "amdnpu")) { info.execution.provider_capabilities.push_back(ProviderCapabilityInfo{ "amdnpu", true, selected_provider == ExecutionProvider::amdnpu, - selected_provider == ExecutionProvider::amdnpu && runtime_policy.released && - !runtime_policy.qualification_only}); + false}); } info.execution.selection_trace = std::move(selection.trace); if (policy_includes_provider(runtime_policy, "apple") && diff --git a/tests/unit/test_selection.cpp b/tests/unit/test_selection.cpp index b78a3d6..4c47fd5 100644 --- a/tests/unit/test_selection.cpp +++ b/tests/unit/test_selection.cpp @@ -126,7 +126,7 @@ LIGHT_OCR_TEST(auto_final_candidate_failure_is_fatal) { ErrorCode::runtime_initialization_failed); } -LIGHT_OCR_TEST(builtin_openvino_policy_prefers_the_npu_and_stays_qualification_only) { +LIGHT_OCR_TEST(builtin_openvino_policy_prefers_the_npu_and_tracks_build_release_status) { const auto policy = internal::builtin_runtime_policy(); const bool openvino = std::find(policy.available_providers.begin(), policy.available_providers.end(), @@ -136,8 +136,13 @@ LIGHT_OCR_TEST(builtin_openvino_policy_prefers_the_npu_and_stays_qualification_o EXPECT_EQ(policy.id, std::string("builtin-openvino-v1")); EXPECT_EQ(policy.ordered_candidates.front(), std::string("openvino")); EXPECT_EQ(policy.ordered_candidates.back(), std::string("cpu")); +#if defined(LIGHT_OCR_WEBGPU_QUALIFICATION_BUILD) EXPECT_TRUE(policy.qualification_only); EXPECT_TRUE(!policy.released); +#else + EXPECT_EQ(policy.qualification_only, LIGHT_OCR_OPENVINO_QUALIFICATION_BUILD != 0); + EXPECT_EQ(policy.released, LIGHT_OCR_OPENVINO_QUALIFICATION_BUILD == 0); +#endif #else EXPECT_FALSE(openvino); #endif diff --git a/tools/npm_release.py b/tools/npm_release.py index 677240c..e3b8d9a 100644 --- a/tools/npm_release.py +++ b/tools/npm_release.py @@ -521,7 +521,7 @@ def validate_runtime_descriptor( else {"cpu"} ) npu_providers = [name for name in ["openvino", "amdnpu"] if name in provider_records] - expected_policy = npu_providers + expected_policy + expected_policy = [name for name in npu_providers if name != "amdnpu"] + expected_policy expected_available.update(npu_providers) if providers != expected_policy or set(provider_records) != expected_available: raise RuntimeError( @@ -856,9 +856,6 @@ def stage_native(arguments: argparse.Namespace) -> None: if sdk_directory is None: continue sdk_directory = Path(sdk_directory).resolve() - sdk_manifest = npu_sdk.validate_sdk(sdk_directory, provider) - if sdk_manifest["qualificationOnly"] and not qualification_build: - raise RuntimeError("NPU candidates require --qualification-build; accepted evidence is required for release") sdk_manifest = npu_sdk.stage_sdk(sdk_directory, stage, descriptor) evidence_directory = stage / "qualification" / provider copy_file(sdk_directory / "sdk-manifest.json", evidence_directory / "sdk-manifest.json") diff --git a/tools/npu/import_amdnpu.py b/tools/npu/import_amdnpu.py index 645a842..5182135 100644 --- a/tools/npu/import_amdnpu.py +++ b/tools/npu/import_amdnpu.py @@ -84,7 +84,7 @@ def import_sdk(runtime: Path, models: Path, licenses: list[Path], output: Path, artifacts = [record(path, output) for path in sorted(output.rglob("*")) if path.is_file() and path.parent != license_dir] manifest = {"schemaVersion": "1.0", "provider": "amdnpu", "platformId": "linux-x64", - "providerVersion": version, "qualificationId": f"amdnpu-{version}-linux-x64-candidate-v1", + "providerVersion": version, "qualificationId": f"amdnpu-{version}-linux-x64-opt-in-v1", "qualificationOnly": True, "runtimeLibrary": library.relative_to(output).as_posix(), "configuration": {"sourceModelSha256": compiled["sourceModelSha256"], "recognitionModels": model_records, "compilerConfiguration": record(config, output)}, diff --git a/tools/npu/prepare_release.py b/tools/npu/prepare_release.py index 8f86027..f229032 100644 --- a/tools/npu/prepare_release.py +++ b/tools/npu/prepare_release.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate accepted SDK inputs and expose paths to the release workflow.""" +"""Validate NPU SDK inputs and expose paths to the release workflow.""" import argparse from pathlib import Path @@ -20,16 +20,14 @@ def main() -> None: directory = args.sdk_root / provider if not directory.exists(): continue - value = validate_sdk(directory, provider) - if value["qualificationOnly"]: - raise ValueError(f"{provider} is a candidate; release requires reviewed device reports") + validate_sdk(directory, provider) path = str(directory.resolve()) if "\n" in path or "\r" in path: raise ValueError("invalid SDK directory") lines.append(f"{provider.upper()}_SDK_DIR={path}\n") found = True if not found: - raise ValueError("accepted SDK artifact contains neither openvino/ nor amdnpu/") + raise ValueError("SDK artifact contains neither openvino/ nor amdnpu/") with args.github_env.open("a", encoding="utf-8") as stream: stream.writelines(lines) diff --git a/tools/npu/sdk.py b/tools/npu/sdk.py index 1a134c9..54ee341 100644 --- a/tools/npu/sdk.py +++ b/tools/npu/sdk.py @@ -253,10 +253,10 @@ def stage_sdk(root: Path, stage: Path, descriptor: dict) -> dict: descriptor["runtime"]["artifacts"].extend(artifacts) # NPU candidates are ordered consistently across Core, addon and loader. descriptor["autoPolicy"]["providers"] = [ - name for name in ["openvino", "amdnpu", "webgpu", "cpu"] + name for name in ["openvino", "webgpu", "cpu"] if name in descriptor["providers"]] - descriptor["qualificationOnly"] |= value["qualificationOnly"] - descriptor["released"] = not descriptor["qualificationOnly"] + # SDK qualification metadata records optional hardware evidence. Shipping + # an unvalidated NPU backend does not change the base runtime release gate. descriptor["schemaVersion"] = "2.1" return value