From 2636fe4203e15f3251bdd0fc5e9941cf0c5312fd Mon Sep 17 00:00:00 2001 From: Baiju Meswani Date: Mon, 20 Jul 2026 15:23:27 -0700 Subject: [PATCH 1/5] Add Linux aarch64 builds --- .pipelines/v2/sdk_v2-js-pipeline-plan.md | 71 +++-- .pipelines/v2/sdk_v2-pipeline-plan.md | 38 ++- .../v2/templates/stages-build-native.yml | 51 ++- .pipelines/v2/templates/stages-cs.yml | 35 +- .pipelines/v2/templates/stages-js.yml | 70 +++- .pipelines/v2/templates/stages-python.yml | 110 ++++++- .pipelines/v2/templates/steps-build-js.yml | 12 +- .pipelines/v2/templates/steps-build-linux.yml | 15 +- .../v2/templates/steps-pack-cpp-sdk.yml | 6 + .pipelines/v2/templates/steps-pack-js.yml | 4 +- .pipelines/v2/templates/steps-pack-nuget.yml | 5 +- .pipelines/v2/templates/steps-test-js.yml | 6 +- sdk_v2/cpp/cmake/FindOnnxRuntime.cmake | 72 +++-- sdk_v2/cpp/cmake/FindOnnxRuntimeGenAI.cmake | 6 +- .../Microsoft.AI.Foundry.Local.Runtime.nuspec | 2 +- sdk_v2/cpp/nuget/pack.py | 10 +- sdk_v2/cpp/nuget/test_pack.py | 300 ++++++++++++++++++ .../ep_detection/webgpu_ep_bootstrapper.cc | 33 +- .../src/ep_detection/webgpu_ep_bootstrapper.h | 4 +- sdk_v2/cpp/test/CMakeLists.txt | 1 + .../webgpu_ep_bootstrapper_test.cc | 117 +++++++ sdk_v2/cpp/triplets/arm64-linux.cmake | 7 + sdk_v2/js/script/install-native.cjs | 9 +- sdk_v2/python/_build_backend/__init__.py | 11 +- sdk_v2/python/pyproject.toml | 11 +- .../foundry_local_sdk/_native/lib_loader.py | 5 + sdk_v2/python/test/unit/test_build_backend.py | 191 +++++++++++ sdk_v2/python/test/unit/test_lib_loader.py | 133 ++++++++ 28 files changed, 1220 insertions(+), 115 deletions(-) create mode 100644 sdk_v2/cpp/nuget/test_pack.py create mode 100644 sdk_v2/cpp/test/internal_api/webgpu_ep_bootstrapper_test.cc create mode 100644 sdk_v2/cpp/triplets/arm64-linux.cmake create mode 100644 sdk_v2/python/test/unit/test_build_backend.py create mode 100644 sdk_v2/python/test/unit/test_lib_loader.py diff --git a/.pipelines/v2/sdk_v2-js-pipeline-plan.md b/.pipelines/v2/sdk_v2-js-pipeline-plan.md index ec5d2ebd6..7d4aa28b0 100644 --- a/.pipelines/v2/sdk_v2-js-pipeline-plan.md +++ b/.pipelines/v2/sdk_v2-js-pipeline-plan.md @@ -17,8 +17,6 @@ C++, C#, and Python stages. Out of scope (deferred): -- **Linux ARM64.** Cross-cutting ARM64 work item will add this for C++, - C#, Python, and JS together. - **npm publishing.** Pipeline produces the `.tgz` as an artifact only. ## Supported platforms @@ -28,6 +26,7 @@ Out of scope (deferred): | Windows x64 | `cpp_build_win_x64` | ✅ | ✅ | | | Windows ARM64 | `cpp_build_win_arm64` | ✅ | ❌ | Cross-compiled on x64 host | | Linux x64 | `cpp_build_linux_x64` | ✅ | ✅ | | +| Linux ARM64 | `cpp_build_linux_arm64` | ✅ | ✅ | Native on `onnxruntime-linux-ARM64-CPU-2019`; CPU-only ORT package | | macOS ARM64 | `cpp_build_osx_arm64` | ✅ | ✅ | Native on AcesShared Sequoia | ## Stage graph @@ -38,9 +37,10 @@ compute_version ├── cpp_build_win_x64 ──── js_build_win_x64 ──── js_test_win_x64 ─┐ ├── cpp_build_win_arm64 ── js_build_win_arm64 (build-only) ───────┤ ├── cpp_build_linux_x64 ── js_build_linux_x64 ── js_test_linux_x64┤ + ├── cpp_build_linux_arm64 ─ js_build_linux_arm64 ─ js_test_linux_arm64┤ └── cpp_build_osx_arm64 ── js_build_osx_arm64 ── js_test_osx_arm64┘ - │ - (all 4 builds) ──> js_pack ──> js-sdk + │ + (all 5 builds) ──> js_pack ──> js-sdk ``` Every `js_build_` stage also depends on `cpp_build_win_x64` because @@ -49,17 +49,20 @@ only by that stage and consumed by every JS build. ## Artifacts -| Stage | Artifact name | Contents | -|------------------------|----------------------------|------------------------------------------------------------------------------------------------| -| `js_build_win_x64` | `js-prebuild-win-x64` | `prebuilds/win32-x64/{foundry_local_node.node, foundry_local_preload.node, foundry_local.dll}` | -| `js_build_win_arm64` | `js-prebuild-win-arm64` | `prebuilds/win32-arm64/{…, foundry_local.dll}` | -| `js_build_linux_x64` | `js-prebuild-linux-x64` | `prebuilds/linux-x64/{…, libfoundry_local.so}` | -| `js_build_osx_arm64` | `js-prebuild-osx-arm64` | `prebuilds/darwin-arm64/{…, libfoundry_local.dylib}` | -| `js_pack` | `js-sdk` | `foundry-local-sdk-.tgz` | +| Stage | Artifact name | Contents | +|---------------------------|-----------------------------|------------------------------------------------------------------------------------------------| +| `js_build_win_x64` | `js-prebuild-win-x64` | `prebuilds/win32-x64/{foundry_local_node.node, foundry_local_preload.node, foundry_local.dll}` | +| `js_build_win_arm64` | `js-prebuild-win-arm64` | `prebuilds/win32-arm64/{…, foundry_local.dll}` | +| `js_build_linux_x64` | `js-prebuild-linux-x64` | `prebuilds/linux-x64/{…, libfoundry_local.so}` | +| `js_build_linux_arm64` | `js-prebuild-linux-arm64` | `prebuilds/linux-arm64/{…, libfoundry_local.so}` | +| `js_build_osx_arm64` | `js-prebuild-osx-arm64` | `prebuilds/darwin-arm64/{…, libfoundry_local.dylib}` | +| `js_pack` | `js-sdk` | `foundry-local-sdk-.tgz` | Naming convention: artifact names use ADO RIDs (`win-x64`, `osx-arm64`); in-tarball directories use Node's `${process.platform}-${process.arch}` (`win32-x64`, `darwin-arm64`) so the runtime addon loader finds them. +Note: `linux-arm64` uses the same key for both ADO RID and the npm tarball +directory (Node's `process.platform` is `"linux"`, `process.arch` is `"arm64"`). ## Build-time native dependencies @@ -91,7 +94,8 @@ three env-var overrides: - `FOUNDRY_LOCAL_PREBUILD_DIR` — forces the `copy_addon_to_prebuilds` destination. Required on win-arm64 because `print-prebuild-dir.mjs` otherwise uses the host Node's `process.arch` (x64) and lands the - cross-compiled addon in `prebuilds/win32-x64/`. + cross-compiled addon in `prebuilds/win32-x64/`. Also set on linux-arm64 + for consistency (though native builds already resolve the right arch). All three overrides are real DX wins for external consumers building the addon themselves against a downloaded native artifact. @@ -105,6 +109,14 @@ needs. `FOUNDRY_LOCAL_PREBUILD_DIR` points the addon-copy step at `prebuilds/win32-arm64/`. No test stage — matches the C# / Python matrix. +## Linux ARM64: native build + test + +Build and test on the `onnxruntime-linux-ARM64-CPU-2019` pool +(`hostArchitecture: arm64`). Same `node-gyp rebuild` path as Linux x64. +`install-native.cjs` selects the CPU-only ORT NuGet package +(`Microsoft.ML.OnnxRuntime.Foundry`) for `linux-arm64` — matching the +C++ native pipeline which also uses CPU-only ORT for ARM64. + ## Test stage details - `NodeTool@0` for Node 20. @@ -119,7 +131,7 @@ matrix. ## Pack stage -Runs on a Windows agent (`onnxruntime-Win-CPU-2022`) after all four +Runs on a Windows agent (`onnxruntime-Win-CPU-2022`) after all five builds. Downloads each `js-prebuild-` artifact, merges them into `sdk_v2/js/prebuilds/`, stamps the version via `npm version --no-git-tag-version --allow-same-version`, builds @@ -137,13 +149,14 @@ require routing back to a macOS agent to sign Darwin binaries; signing per-platform avoids that. The `.tgz` itself is **not signed** — npm has no equivalent to NuGet package signing. -| Stage | Files signed | ESRP keyCode | Tool | -|------------------------|-------------------------------------------------------------------------------|---------------|-----------------------| -| `js_build_win_x64` | `foundry_local_node.node`, `foundry_local_preload.node`, `foundry_local.dll` | `CP-230012` | SigntoolSign | -| `js_build_win_arm64` | same three Windows files | `CP-230012` | SigntoolSign | -| `js_build_osx_arm64` | both `.node` files + `libfoundry_local.dylib` | `CP-401337` | `MacAppDeveloperSign` (placeholder — confirm against ESRP policy on first run) | -| `js_build_linux_x64` | none | n/a | Linux `.so` has no standard signing | -| `js_pack` | none | n/a | `.tgz` not signed by npm convention | +| Stage | Files signed | ESRP keyCode | Tool | +|---------------------------|-------------------------------------------------------------------------------|---------------|-----------------------| +| `js_build_win_x64` | `foundry_local_node.node`, `foundry_local_preload.node`, `foundry_local.dll` | `CP-230012` | SigntoolSign | +| `js_build_win_arm64` | same three Windows files | `CP-230012` | SigntoolSign | +| `js_build_osx_arm64` | both `.node` files + `libfoundry_local.dylib` | `CP-401337` | `MacAppDeveloperSign` (placeholder — confirm against ESRP policy on first run) | +| `js_build_linux_x64` | none | n/a | Linux `.so` has no standard signing | +| `js_build_linux_arm64` | none | n/a | Linux `.so` has no standard signing | +| `js_pack` | none | n/a | `.tgz` not signed by npm convention | Windows signing block is a near-copy of the SDK DLL signing step in [`steps-build-cs.yml`](templates/steps-build-cs.yml) — same @@ -173,6 +186,19 @@ shape. `FOUNDRY_LOCAL_PREBUILD_DIR` (added for win-arm64 cross-compile). - `.gitignore` — negation rule preserving `sdk_v2/js/package-lock.json` past the repo-wide lockfile ignore. +- `stages-js.yml` — added `js_build_linux_arm64` and `js_test_linux_arm64` + stages; updated `js_pack` dependsOn and artifact inputs to include + `js-prebuild-linux-arm64`. +- `steps-build-js.yml` — added `linux-arm64` to `rid` values; added + `Set prebuild directory (linux-arm64)` bash step; expanded Linux/macOS + build and staging bash conditions to cover the new RID. +- `steps-test-js.yml` — added `linux-arm64` to `rid` values; expanded + all Linux/macOS bash conditions to cover the new RID. +- `steps-pack-js.yml` — added `linux-arm64` to the assembly sources + array; updated header comment. +- `sdk_v2/js/script/install-native.cjs` — strengthened the missing-RID + case from a silent `console.warn + return` to a thrown Error with a + clear message directing users to `FOUNDRY_LOCAL_SKIP_INSTALL=1`. **Unchanged (intentional):** - `sdk_v2/js/script/copy-native.mjs` and `pack-prebuilds.mjs` — local-dev @@ -191,10 +217,11 @@ shape. stamped via `npm version` in `js_pack`. - **Signing:** Authenticode for Windows, ESRP MacAppDeveloperSign for macOS, none for Linux, none for the `.tgz`. -- **Single combined tarball:** `js_pack` assembles all four prebuilds +- **Single combined tarball:** `js_pack` assembles all five prebuilds into one `foundry-local-sdk-.tgz`. - **JS scoped out of WinML.** -- **Linux ARM64 deferred** to the cross-cutting ARM64 work item. +- **Linux ARM64 ORT package:** `Microsoft.ML.OnnxRuntime.Foundry` (CPU-only), + matching the C++ native ARM64 pipeline. ## Open items diff --git a/.pipelines/v2/sdk_v2-pipeline-plan.md b/.pipelines/v2/sdk_v2-pipeline-plan.md index a6bad8c9b..8fc06f88e 100644 --- a/.pipelines/v2/sdk_v2-pipeline-plan.md +++ b/.pipelines/v2/sdk_v2-pipeline-plan.md @@ -13,7 +13,8 @@ In scope: `sdk_v2/cpp` (native runtime), `sdk_v2/cs/src` (C# SDK), for both C# (via NuGet) and Python (bundled in the wheel), replacing the legacy `foundry-local-core` from neutron-server. -Out of scope: JS, Rust, and a standalone `foundry-local-runtime` Python wheel. +JS is documented separately in `sdk_v2-js-pipeline-plan.md`. Rust and a standalone +`foundry-local-runtime` Python wheel remain out of scope. ## Top-level pipelines @@ -31,6 +32,7 @@ are gated separately via `.pipelines/v1/templates/stages-sdk-v1.yml`. | Windows x64 | `onnxruntime-Win-CPU-2022` | ✅ | ✅ | Also stages public headers | | Windows ARM64 | `onnxruntime-Win-CPU-2022` | ✅ | ❌ | Cross-compiled from x64 host | | Linux x64 | `onnxruntime-Ubuntu2404-AMD-CPU` | ✅ | ✅ | Pulls extra `OnnxRuntime.Gpu.Linux` pkg | +| Linux ARM64 | `onnxruntime-linux-ARM64-CPU-2019`| ✅ | ✅ | Native CPU-only build | | macOS ARM64 | `AcesShared` (Sequoia) | ✅ | ✅ | Native | ## Architectural decisions (locked) @@ -86,7 +88,8 @@ are gated separately via `.pipelines/v1/templates/stages-sdk-v1.yml`. for the wheel install path: - `onnxruntime-core` (Windows/macOS) - `onnxruntime-genai-core` (Windows/macOS) - - `onnxruntime-gpu` / `onnxruntime-genai-cuda` (Linux) + - `onnxruntime-gpu` / `onnxruntime-genai-cuda` (Linux x64) + - `onnxruntime` / `onnxruntime-genai` (Linux ARM64, CPU-only) Import-name mapping is platform-dependent: Linux uses `onnxruntime` / `onnxruntime_genai`; Windows/macOS use `onnxruntime_core` / @@ -136,10 +139,11 @@ are gated separately via `.pipelines/v1/templates/stages-sdk-v1.yml`. ``` .pipelines/v2/ └── templates/ - ├── stages-sdk-v2.yml # Coordinator: native + cs + python - ├── stages-build-native.yml # 4 build stages + 1 pack stage (C++) + ├── stages-sdk-v2.yml # Coordinator: native + C# + Python + JS + ├── stages-build-native.yml # 5 native build stages + C++/NuGet pack stages ├── stages-cs.yml # C# build + test ├── stages-python.yml # Python build + test + ├── stages-js.yml # JS build + test + combined npm pack ├── steps-prefetch-nuget.yml # ORT/GenAI/WinML NuGet pre-fetch (pwsh + bash) ├── steps-build-windows.yml # arch: x64 | arm64 (always bundles WinML) ├── steps-build-linux.yml @@ -162,14 +166,17 @@ compute_version | |-- cpp_build_win_x64 ----------+ |-- cpp_build_win_arm64 --------| - |-- cpp_build_linux_x64 --------+--> pack_nuget --+--> cs_build --+--> cs_test_win_x64 - |-- cpp_build_osx_arm64 --------+ | |--> cs_test_linux_x64 - | | +--> cs_test_osx_arm64 - | +--> (cs-sdk-v2 artifact) + |-- cpp_build_linux_x64 --------| + |-- cpp_build_linux_arm64 ------+--> pack_nuget --> cs_build --+--> cs_test_win_x64 + |-- cpp_build_osx_arm64 --------+ |--> cs_test_linux_x64 + | |--> cs_test_linux_arm64 + | +--> cs_test_osx_arm64 | - | +--> py_build_win_x64 --> py_test_win_x64 - +-->|--> py_build_linux_x64 --> py_test_linux_x64 - +--> py_build_osx_arm64 --> py_test_osx_arm64 + |-- python_build_win_x64 ------> python_test_win_x64 + |-- python_build_win_arm64 ----> (build-only) + |-- python_build_linux_x64 ----> python_test_linux_x64 + |-- python_build_linux_arm64 --> python_test_linux_arm64 + +-- python_build_osx_arm64 ----> python_test_osx_arm64 ``` * All build stages are independent (`dependsOn: [compute_version]`) and run @@ -178,7 +185,7 @@ compute_version * The Windows native build always bundles the reg-free WinML 2.x runtime, which links against the same `ortVersion` as every other platform — there is no separate WinML-aligned ORT pin or build flavor. -* Tests run on `cpp_build_win_x64`, `cpp_build_linux_x64`, and +* Tests run on `cpp_build_win_x64`, both Linux native stages, and `cpp_build_osx_arm64`. The ARM64 Windows stage cross-compiles from an x64 host and skips tests. @@ -193,6 +200,7 @@ Published via 1ES `templateContext.outputs` (no manual `PublishPipelineArtifact` | `cpp_build_win_x64` | `cpp-native-include` | Public headers (sourced once, from win-x64) | | `cpp_build_win_arm64` | `cpp-native-win-arm64` | `foundry_local.dll`, `.pdb`, `.lib` + `Microsoft.Windows.AI.MachineLearning.dll` | | `cpp_build_linux_x64` | `cpp-native-linux-x64` | `libfoundry_local.so` | +| `cpp_build_linux_arm64` | `cpp-native-linux-arm64` | `libfoundry_local.so` (CPU-only) | | `cpp_build_osx_arm64` | `cpp-native-osx-arm64` | `libfoundry_local.dylib` | | `cpp_pack_nuget` | `cpp-nuget` | `Microsoft.AI.Foundry.Local.Runtime..nupkg` (bundles WinML on Windows) | @@ -276,6 +284,7 @@ Test data is fetched on every stage that runs tests: * `cpp_build_win_x64` * `cpp_build_linux_x64` +* `cpp_build_linux_arm64` * `cpp_build_osx_arm64` * All `cs_test_*` and `py_test_*` stages @@ -309,15 +318,14 @@ Build output directories follow `build.py`'s convention: * **ESRP signing of native binaries.** Slot a signing stage between the builds and pack once the unsigned end-to-end is fully stable. * **Publishing.** No push to NuGet/internal feeds from this pipeline. -* **Linux ARM64 / macOS x64.** Add when there's a customer ask; pools and - pack support are both ready. +* **macOS x64.** Add when there's a customer ask. * **Code coverage upload.** `run_coverage.ps1` exists for local use but is not wired into CI. ## Things explicitly out of scope - No `foundry-local-runtime` standalone wheel. -- No JS or Rust sdk_v2 stages. +- No Rust sdk_v2 stages. - No multi-repo `Foundry-Local`/`test-data-shared` path-juggling logic in the sdk_v2 templates — sdk_v2 paths are repo-relative. - No private Azure DevOps feed dependency for the Python wheel install diff --git a/.pipelines/v2/templates/stages-build-native.yml b/.pipelines/v2/templates/stages-build-native.yml index f46777676..7d1843288 100644 --- a/.pipelines/v2/templates/stages-build-native.yml +++ b/.pipelines/v2/templates/stages-build-native.yml @@ -6,7 +6,8 @@ # (PR and main): # # pack_nuget – Microsoft.AI.Foundry.Local.Runtime -# (win-x64, win-arm64, linux-x64, osx-arm64; Windows ships WinML) +# (win-x64, win-arm64, linux-x64, linux-arm64, osx-arm64; +# Windows ships WinML; ARM64 Linux is CPU-only) parameters: - name: buildConfig @@ -114,6 +115,41 @@ stages: steps: - template: steps-build-linux.yml parameters: + arch: x64 + buildConfig: ${{ parameters.buildConfig }} + ortVersion: ${{ parameters.ortVersion }} + genaiVersion: ${{ parameters.genaiVersion }} + runTests: true + + # ==================================================================== + # Linux ARM64 — build + test (native, CPU-only) + # ==================================================================== + - stage: cpp_build_linux_arm64 + displayName: 'C++ Native: Linux ARM64' + dependsOn: + - compute_version + jobs: + - job: build + pool: + name: onnxruntime-linux-ARM64-CPU-2019 + os: linux + hostArchitecture: arm64 + templateContext: + inputs: + - input: pipelineArtifact + artifactName: 'version-info' + targetPath: '$(Pipeline.Workspace)/version-info' + outputs: + - output: pipelineArtifact + artifactName: 'cpp-native-linux-arm64' + targetPath: '$(Build.ArtifactStagingDirectory)/native' + - output: pipelineArtifact + artifactName: 'cpp-native-symbols-linux-arm64' + targetPath: '$(Build.ArtifactStagingDirectory)/symbols' + steps: + - template: steps-build-linux.yml + parameters: + arch: arm64 buildConfig: ${{ parameters.buildConfig }} ortVersion: ${{ parameters.ortVersion }} genaiVersion: ${{ parameters.genaiVersion }} @@ -162,6 +198,7 @@ stages: - cpp_build_win_x64 - cpp_build_win_arm64 - cpp_build_linux_x64 + - cpp_build_linux_arm64 - cpp_build_osx_arm64 jobs: - job: pack @@ -185,6 +222,12 @@ stages: - input: pipelineArtifact artifactName: 'cpp-native-symbols-linux-x64' targetPath: '$(Pipeline.Workspace)/cpp-native-symbols-linux-x64' + - input: pipelineArtifact + artifactName: 'cpp-native-linux-arm64' + targetPath: '$(Pipeline.Workspace)/cpp-native-linux-arm64' + - input: pipelineArtifact + artifactName: 'cpp-native-symbols-linux-arm64' + targetPath: '$(Pipeline.Workspace)/cpp-native-symbols-linux-arm64' - input: pipelineArtifact artifactName: 'cpp-native-osx-arm64' targetPath: '$(Pipeline.Workspace)/cpp-native-osx-arm64' @@ -201,7 +244,7 @@ stages: outputDir: '$(Build.ArtifactStagingDirectory)/cpp-sdk-v2' # ==================================================================== - # Pack — NuGet package (all 4 platforms; Windows includes WinML) + # Pack — NuGet package (all 5 platforms; Windows includes WinML) # ==================================================================== - stage: cpp_pack_nuget displayName: 'C++ Native: Pack NuGet' @@ -210,6 +253,7 @@ stages: - cpp_build_win_x64 - cpp_build_win_arm64 - cpp_build_linux_x64 + - cpp_build_linux_arm64 - cpp_build_osx_arm64 jobs: - job: pack @@ -230,6 +274,9 @@ stages: - input: pipelineArtifact artifactName: 'cpp-native-linux-x64' targetPath: '$(Pipeline.Workspace)/cpp-native-linux-x64' + - input: pipelineArtifact + artifactName: 'cpp-native-linux-arm64' + targetPath: '$(Pipeline.Workspace)/cpp-native-linux-arm64' - input: pipelineArtifact artifactName: 'cpp-native-osx-arm64' targetPath: '$(Pipeline.Workspace)/cpp-native-osx-arm64' diff --git a/.pipelines/v2/templates/stages-cs.yml b/.pipelines/v2/templates/stages-cs.yml index b83f48b35..2d3f90ee1 100644 --- a/.pipelines/v2/templates/stages-cs.yml +++ b/.pipelines/v2/templates/stages-cs.yml @@ -74,7 +74,7 @@ stages: testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' # ==================================================================== -# Test — Linux x64 / macOS ARM64 +# Test — Linux x64 # ==================================================================== - stage: cs_test_linux_x64 displayName: 'C# SDK: Test Linux x64' @@ -102,6 +102,39 @@ stages: flNugetDir: '$(Pipeline.Workspace)/cpp-nuget' testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' +# ==================================================================== +# Test — Linux ARM64 +# ==================================================================== +- stage: cs_test_linux_arm64 + displayName: 'C# SDK: Test Linux ARM64' + dependsOn: + - cs_build + jobs: + - job: test + pool: + name: onnxruntime-linux-ARM64-CPU-2019 + os: linux + hostArchitecture: arm64 + templateContext: + inputs: + - input: pipelineArtifact + artifactName: 'version-info' + targetPath: '$(Pipeline.Workspace)/version-info' + - input: pipelineArtifact + artifactName: 'cpp-nuget' + targetPath: '$(Pipeline.Workspace)/cpp-nuget' + steps: + - checkout: self + clean: true + - template: ../../templates/fetch-test-data-from-blob.yml@self + - template: steps-test-cs.yml + parameters: + flNugetDir: '$(Pipeline.Workspace)/cpp-nuget' + testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' + +# ==================================================================== +# Test — macOS ARM64 +# ==================================================================== - stage: cs_test_osx_arm64 displayName: 'C# SDK: Test macOS ARM64' dependsOn: diff --git a/.pipelines/v2/templates/stages-js.yml b/.pipelines/v2/templates/stages-js.yml index f11ed23bb..c97bd3f0c 100644 --- a/.pipelines/v2/templates/stages-js.yml +++ b/.pipelines/v2/templates/stages-js.yml @@ -4,7 +4,7 @@ # Each per-platform build stage produces a `js-prebuild-` artifact # containing prebuilds/-/ (two .node addons + the matching # foundry_local shared library). The final `js_pack` stage downloads all -# four prebuild artifacts, merges them under sdk_v2/js/prebuilds/, stamps +# five prebuild artifacts, merges them under sdk_v2/js/prebuilds/, stamps # the version, and runs `npm pack` to produce a single .tgz. # # Build stages depend on: @@ -136,6 +136,43 @@ stages: includeArtifactDir: '$(Pipeline.Workspace)/cpp-native-include' outputDir: '$(Build.ArtifactStagingDirectory)/js-prebuild' +- stage: js_build_linux_arm64 + displayName: 'JS SDK: Build Linux ARM64' + dependsOn: + - compute_version + - cpp_build_linux_arm64 + - cpp_build_win_x64 + jobs: + - job: build + pool: + name: onnxruntime-linux-ARM64-CPU-2019 + os: linux + hostArchitecture: arm64 + templateContext: + inputs: + - input: pipelineArtifact + artifactName: 'version-info' + targetPath: '$(Pipeline.Workspace)/version-info' + - input: pipelineArtifact + artifactName: 'cpp-native-linux-arm64' + targetPath: '$(Pipeline.Workspace)/cpp-native-linux-arm64' + - input: pipelineArtifact + artifactName: 'cpp-native-include' + targetPath: '$(Pipeline.Workspace)/cpp-native-include' + outputs: + - output: pipelineArtifact + artifactName: 'js-prebuild-linux-arm64' + targetPath: '$(Build.ArtifactStagingDirectory)/js-prebuild' + steps: + - checkout: self + clean: true + - template: steps-build-js.yml + parameters: + rid: 'linux-arm64' + nativeArtifactDir: '$(Pipeline.Workspace)/cpp-native-linux-arm64' + includeArtifactDir: '$(Pipeline.Workspace)/cpp-native-include' + outputDir: '$(Build.ArtifactStagingDirectory)/js-prebuild' + - stage: js_build_osx_arm64 displayName: 'JS SDK: Build macOS ARM64' dependsOn: @@ -229,6 +266,31 @@ stages: prebuildArtifactDir: '$(Pipeline.Workspace)/js-prebuild-linux-x64' testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' +- stage: js_test_linux_arm64 + displayName: 'JS SDK: Test Linux ARM64' + dependsOn: + - js_build_linux_arm64 + jobs: + - job: test + pool: + name: onnxruntime-linux-ARM64-CPU-2019 + os: linux + hostArchitecture: arm64 + templateContext: + inputs: + - input: pipelineArtifact + artifactName: 'js-prebuild-linux-arm64' + targetPath: '$(Pipeline.Workspace)/js-prebuild-linux-arm64' + steps: + - checkout: self + clean: true + - template: ../../templates/fetch-test-data-from-blob.yml@self + - template: steps-test-js.yml + parameters: + rid: 'linux-arm64' + prebuildArtifactDir: '$(Pipeline.Workspace)/js-prebuild-linux-arm64' + testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' + - stage: js_test_osx_arm64 displayName: 'JS SDK: Test macOS ARM64' dependsOn: @@ -256,7 +318,7 @@ stages: testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' # ===================================================================== -# Pack stage — runs after all four builds. Pure packaging on Windows. +# Pack stage — runs after all five builds. Pure packaging on Windows. # ===================================================================== - stage: js_pack @@ -265,6 +327,7 @@ stages: - js_build_win_x64 - js_build_win_arm64 - js_build_linux_x64 + - js_build_linux_arm64 - js_build_osx_arm64 jobs: - job: pack @@ -285,6 +348,9 @@ stages: - input: pipelineArtifact artifactName: 'js-prebuild-linux-x64' targetPath: '$(Pipeline.Workspace)/js-prebuild-linux-x64' + - input: pipelineArtifact + artifactName: 'js-prebuild-linux-arm64' + targetPath: '$(Pipeline.Workspace)/js-prebuild-linux-arm64' - input: pipelineArtifact artifactName: 'js-prebuild-osx-arm64' targetPath: '$(Pipeline.Workspace)/js-prebuild-osx-arm64' diff --git a/.pipelines/v2/templates/stages-python.yml b/.pipelines/v2/templates/stages-python.yml index c67002f9b..b25ea6e9b 100644 --- a/.pipelines/v2/templates/stages-python.yml +++ b/.pipelines/v2/templates/stages-python.yml @@ -1,7 +1,8 @@ # Build + test stages for the sdk_v2 Python SDK (foundry-local-sdk). # -# Builds win-x64 + win-arm64 + linux-x64 + osx-arm64; tests win-x64 + linux-x64 -# + osx-arm64 (win-arm64 is build-only, matching the C# matrix). +# Builds win-x64 + win-arm64 + linux-x64 + linux-arm64 + osx-arm64; +# tests win-x64 + linux-x64 + linux-arm64 + osx-arm64 +# (win-arm64 is build-only, matching the C# matrix). # # Each build stage depends on the matching native build stage from # stages-build-native.yml and consumes the `cpp-native-` pipeline artifact @@ -9,6 +10,9 @@ # src/foundry_local_sdk/_native//). On Windows that native artifact carries # the reg-free WinML 2.x runtime, so the single wheel includes WinML support. # +# linux-arm64 is CPU-only; it uses onnxruntime + onnxruntime-genai (plain +# packages) rather than the -gpu/-cuda variants used on linux-x64. +# # Each build stage emits one wheel under the `python-sdk-` pipeline artifact. stages: @@ -110,6 +114,78 @@ stages: rid: 'linux-x64' outputDir: '$(Build.ArtifactStagingDirectory)/python-sdk' +- stage: python_build_linux_arm64 + displayName: 'Python SDK: Build Linux ARM64' + dependsOn: + - compute_version + - cpp_build_linux_arm64 + jobs: + - job: build + pool: + name: onnxruntime-linux-ARM64-CPU-2019 + os: linux + hostArchitecture: arm64 + templateContext: + inputs: + - input: pipelineArtifact + artifactName: 'version-info' + targetPath: '$(Pipeline.Workspace)/version-info' + - input: pipelineArtifact + artifactName: 'cpp-native-linux-arm64' + targetPath: '$(Pipeline.Workspace)/cpp-native-linux-arm64' + outputs: + - output: pipelineArtifact + artifactName: 'python-sdk-linux-arm64' + targetPath: '$(Build.ArtifactStagingDirectory)/python-sdk' + steps: + - checkout: self + clean: true + - template: steps-build-python.yml + parameters: + nativeArtifactDir: '$(Pipeline.Workspace)/cpp-native-linux-arm64' + rid: 'linux-arm64' + pythonArchitecture: 'arm64' + outputDir: '$(Build.ArtifactStagingDirectory)/python-sdk' + # Assert the produced wheel has the correct platform tag and bundles the + # linux-arm64 native library. This runs on the ARM64 agent immediately + # after the build so any packaging error is caught before the artifact is + # uploaded and replicated to downstream stages. + - task: Bash@3 + displayName: 'Assert wheel is linux_aarch64 and contains native lib' + inputs: + targetType: inline + script: | + set -euo pipefail + whl=$(find "$(Build.ArtifactStagingDirectory)/python-sdk" -maxdepth 2 -name "*.whl" | head -n 1) + if [[ -z "$whl" ]]; then + echo "ERROR: no .whl found under $(Build.ArtifactStagingDirectory)/python-sdk" + exit 1 + fi + echo "Wheel: $whl" + basename "$whl" | grep -q 'linux_aarch64' || { + echo "ERROR: expected linux_aarch64 platform tag in wheel name: $(basename $whl)" + exit 1 + } + python3 - "$whl" <<'PY' + import sys + import zipfile + + with zipfile.ZipFile(sys.argv[1]) as wheel: + names = wheel.namelist() + + hits = [name for name in names if "_native/linux-arm64/libfoundry_local.so" in name] + if not hits: + print("ERROR: _native/linux-arm64/libfoundry_local.so not found in wheel") + print("Wheel entries containing .so or linux-arm64:") + for name in names: + if ".so" in name or "linux-arm64" in name: + print(" ", name) + sys.exit(1) + + print("OK: found", hits[0]) + PY + echo "Wheel validation passed." + - stage: python_build_osx_arm64 displayName: 'Python SDK: Build macOS ARM64' dependsOn: @@ -195,6 +271,31 @@ stages: wheelDir: '$(Pipeline.Workspace)/python-sdk-linux-x64' testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' +- stage: python_test_linux_arm64 + displayName: 'Python SDK: Test Linux ARM64' + dependsOn: + - python_build_linux_arm64 + jobs: + - job: test + pool: + name: onnxruntime-linux-ARM64-CPU-2019 + os: linux + hostArchitecture: arm64 + templateContext: + inputs: + - input: pipelineArtifact + artifactName: 'python-sdk-linux-arm64' + targetPath: '$(Pipeline.Workspace)/python-sdk-linux-arm64' + steps: + - checkout: self + clean: true + - template: ../../templates/fetch-test-data-from-blob.yml@self + - template: steps-test-python.yml + parameters: + wheelDir: '$(Pipeline.Workspace)/python-sdk-linux-arm64' + testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' + pythonArchitecture: 'arm64' + - stage: python_test_osx_arm64 displayName: 'Python SDK: Test macOS ARM64' dependsOn: @@ -230,6 +331,7 @@ stages: - python_build_win_x64 - python_build_win_arm64 - python_build_linux_x64 + - python_build_linux_arm64 - python_build_osx_arm64 jobs: - job: pack @@ -247,6 +349,9 @@ stages: - input: pipelineArtifact artifactName: 'python-sdk-linux-x64' targetPath: '$(Pipeline.Workspace)/python-sdk-linux-x64' + - input: pipelineArtifact + artifactName: 'python-sdk-linux-arm64' + targetPath: '$(Pipeline.Workspace)/python-sdk-linux-arm64' - input: pipelineArtifact artifactName: 'python-sdk-osx-arm64' targetPath: '$(Pipeline.Workspace)/python-sdk-osx-arm64' @@ -268,6 +373,7 @@ stages: '$(Pipeline.Workspace)/python-sdk-win-x64', '$(Pipeline.Workspace)/python-sdk-win-arm64', '$(Pipeline.Workspace)/python-sdk-linux-x64', + '$(Pipeline.Workspace)/python-sdk-linux-arm64', '$(Pipeline.Workspace)/python-sdk-osx-arm64' ) diff --git a/.pipelines/v2/templates/steps-build-js.yml b/.pipelines/v2/templates/steps-build-js.yml index 657704bd9..77535d208 100644 --- a/.pipelines/v2/templates/steps-build-js.yml +++ b/.pipelines/v2/templates/steps-build-js.yml @@ -18,7 +18,7 @@ parameters: - name: rid type: string - values: ['win-x64', 'win-arm64', 'linux-x64', 'osx-arm64'] + values: ['win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'osx-arm64'] displayName: 'ADO RID for the platform being built' - name: nativeArtifactDir type: string @@ -72,6 +72,12 @@ steps: echo "##vso[task.setvariable variable=nativeLib]libfoundry_local.so" displayName: 'Set prebuild directory (linux-x64)' +- ${{ if eq(parameters.rid, 'linux-arm64') }}: + - bash: | + echo "##vso[task.setvariable variable=prebuildDir]linux-arm64" + echo "##vso[task.setvariable variable=nativeLib]libfoundry_local.so" + displayName: 'Set prebuild directory (linux-arm64)' + - ${{ if eq(parameters.rid, 'osx-arm64') }}: - bash: | echo "##vso[task.setvariable variable=prebuildDir]darwin-arm64" @@ -111,7 +117,7 @@ steps: # cross-compiling to arm64. FOUNDRY_LOCAL_PREBUILD_DIR: '$(Build.SourcesDirectory)/sdk_v2/js/prebuilds/$(prebuildDir)' -- ${{ if or(eq(parameters.rid, 'linux-x64'), eq(parameters.rid, 'osx-arm64')) }}: +- ${{ if or(eq(parameters.rid, 'linux-x64'), eq(parameters.rid, 'linux-arm64'), eq(parameters.rid, 'osx-arm64')) }}: - bash: | set -euo pipefail cd "$(Build.SourcesDirectory)/sdk_v2/js" @@ -159,7 +165,7 @@ steps: Get-ChildItem $dst | ForEach-Object { Write-Host " $($_.Name) $($_.Length) bytes" } displayName: 'Stage prebuild directory' -- ${{ if or(eq(parameters.rid, 'linux-x64'), eq(parameters.rid, 'osx-arm64')) }}: +- ${{ if or(eq(parameters.rid, 'linux-x64'), eq(parameters.rid, 'linux-arm64'), eq(parameters.rid, 'osx-arm64')) }}: - bash: | set -euo pipefail dst="${{ parameters.outputDir }}/prebuilds/$(prebuildDir)" diff --git a/.pipelines/v2/templates/steps-build-linux.yml b/.pipelines/v2/templates/steps-build-linux.yml index b1328cba2..9d425bf0d 100644 --- a/.pipelines/v2/templates/steps-build-linux.yml +++ b/.pipelines/v2/templates/steps-build-linux.yml @@ -1,10 +1,17 @@ # Reusable Linux build steps for the Foundry Local C++ SDK. # -# Pool: onnxruntime-Ubuntu2404-AMD-CPU. Has the toolchain pre-installed but we -# still bootstrap vcpkg and pre-download NuGet packages from the aiinfra feed -# for version pinning. +# Supports both linux-x64 (pool: onnxruntime-Ubuntu2404-AMD-CPU) and +# linux-arm64 (pool: onnxruntime-linux-ARM64-CPU-2019) via the `arch` +# parameter. Each pool has the toolchain pre-installed; we still bootstrap +# vcpkg and pre-download NuGet packages from the aiinfra feed for version +# pinning. x64 also pre-downloads Microsoft.ML.OnnxRuntime.Gpu.Linux for +# CUDA support; arm64 is CPU-only and skips that package. parameters: +- name: arch + type: string + default: x64 + values: ['x64', 'arm64'] - name: buildConfig type: string - name: ortVersion @@ -29,7 +36,7 @@ steps: genaiVersion: ${{ parameters.genaiVersion }} winmlVersion: '' includeWinml: false - includeOrtGpuLinux: true + includeOrtGpuLinux: ${{ eq(parameters.arch, 'x64') }} shell: bash # Bake the pipeline-computed version into the binary so FoundryLocalGetVersionString() diff --git a/.pipelines/v2/templates/steps-pack-cpp-sdk.yml b/.pipelines/v2/templates/steps-pack-cpp-sdk.yml index a878dbf3d..90f26c04e 100644 --- a/.pipelines/v2/templates/steps-pack-cpp-sdk.yml +++ b/.pipelines/v2/templates/steps-pack-cpp-sdk.yml @@ -130,6 +130,12 @@ steps: 'libfoundry_local.so.dbg' ) + New-SdkArchive -Rid 'linux-arm64' -NativeArtifact 'cpp-native-linux-arm64' -Files @( + 'libfoundry_local.so' + ) -SymbolsArtifact 'cpp-native-symbols-linux-arm64' -SymbolItems @( + 'libfoundry_local.so.dbg' + ) + New-SdkArchive -Rid 'osx-arm64' -NativeArtifact 'cpp-native-osx-arm64' -Files @( 'libfoundry_local.dylib' ) -SymbolsArtifact 'cpp-native-symbols-osx-arm64' -SymbolItems @( diff --git a/.pipelines/v2/templates/steps-pack-js.yml b/.pipelines/v2/templates/steps-pack-js.yml index 439ab41fd..4f7481c54 100644 --- a/.pipelines/v2/templates/steps-pack-js.yml +++ b/.pipelines/v2/templates/steps-pack-js.yml @@ -6,9 +6,10 @@ # * js-prebuild-win-x64 -> prebuilds/win32-x64/ # * js-prebuild-win-arm64 -> prebuilds/win32-arm64/ # * js-prebuild-linux-x64 -> prebuilds/linux-x64/ +# * js-prebuild-linux-arm64 -> prebuilds/linux-arm64/ # * js-prebuild-osx-arm64 -> prebuilds/darwin-arm64/ # -# Merges all four prebuilds into sdk_v2/js/prebuilds/, stamps the version, +# Merges all five prebuilds into sdk_v2/js/prebuilds/, stamps the version, # builds TypeScript, and runs `npm pack` to produce a single multi-platform # .tgz that ships every supported addon binary. # @@ -36,6 +37,7 @@ steps: @{ rid = 'win-x64'; plat = 'win32-x64' }, @{ rid = 'win-arm64'; plat = 'win32-arm64' }, @{ rid = 'linux-x64'; plat = 'linux-x64' }, + @{ rid = 'linux-arm64'; plat = 'linux-arm64' }, @{ rid = 'osx-arm64'; plat = 'darwin-arm64' } ) diff --git a/.pipelines/v2/templates/steps-pack-nuget.yml b/.pipelines/v2/templates/steps-pack-nuget.yml index eb088e4b5..4328b935c 100644 --- a/.pipelines/v2/templates/steps-pack-nuget.yml +++ b/.pipelines/v2/templates/steps-pack-nuget.yml @@ -5,8 +5,8 @@ # $(Pipeline.Workspace)//. # # Produces Microsoft.AI.Foundry.Local.Runtime (win-x64, win-arm64, linux-x64, -# osx-arm64). On Windows the artifacts carry the reg-free WinML 2.x runtime DLL, -# which pack.py forwards automatically. +# linux-arm64, osx-arm64). On Windows the artifacts carry the reg-free WinML 2.x +# runtime DLL, which pack.py forwards automatically. Linux ARM64 is CPU-only. parameters: - name: ortVersion @@ -61,6 +61,7 @@ steps: --win_x64 "$(Pipeline.Workspace)/cpp-native-win-x64" ` --win_arm64 "$(Pipeline.Workspace)/cpp-native-win-arm64" ` --linux_x64 "$(Pipeline.Workspace)/cpp-native-linux-x64" ` + --linux_arm64 "$(Pipeline.Workspace)/cpp-native-linux-arm64" ` --osx_arm64 "$(Pipeline.Workspace)/cpp-native-osx-arm64" ` --output_dir "$outDir" diff --git a/.pipelines/v2/templates/steps-test-js.yml b/.pipelines/v2/templates/steps-test-js.yml index bf7e7b74c..601bbab01 100644 --- a/.pipelines/v2/templates/steps-test-js.yml +++ b/.pipelines/v2/templates/steps-test-js.yml @@ -7,7 +7,7 @@ parameters: - name: rid type: string - values: ['win-x64', 'linux-x64', 'osx-arm64'] + values: ['win-x64', 'linux-x64', 'linux-arm64', 'osx-arm64'] displayName: 'ADO RID for the platform being tested (no cross-arch testing)' - name: prebuildArtifactDir type: string @@ -34,7 +34,7 @@ steps: Get-ChildItem -Recurse $dst | ForEach-Object { Write-Host " $($_.FullName)" } displayName: 'Drop prebuild into sdk_v2/js/prebuilds' -- ${{ if or(eq(parameters.rid, 'linux-x64'), eq(parameters.rid, 'osx-arm64')) }}: +- ${{ if or(eq(parameters.rid, 'linux-x64'), eq(parameters.rid, 'linux-arm64'), eq(parameters.rid, 'osx-arm64')) }}: - bash: | set -euo pipefail src="${{ parameters.prebuildArtifactDir }}/prebuilds" @@ -71,7 +71,7 @@ steps: env: FOUNDRY_TEST_DATA_DIR: '${{ parameters.testDataSharedDir }}' -- ${{ if or(eq(parameters.rid, 'linux-x64'), eq(parameters.rid, 'osx-arm64')) }}: +- ${{ if or(eq(parameters.rid, 'linux-x64'), eq(parameters.rid, 'linux-arm64'), eq(parameters.rid, 'osx-arm64')) }}: - bash: | set -euo pipefail cd "$(Build.SourcesDirectory)/sdk_v2/js" diff --git a/sdk_v2/cpp/cmake/FindOnnxRuntime.cmake b/sdk_v2/cpp/cmake/FindOnnxRuntime.cmake index f11385873..624be2d4e 100644 --- a/sdk_v2/cpp/cmake/FindOnnxRuntime.cmake +++ b/sdk_v2/cpp/cmake/FindOnnxRuntime.cmake @@ -24,7 +24,11 @@ if(ANDROID) message(FATAL_ERROR "Unsupported Android ABI for OnnxRuntime: ${ANDROID_ABI}") endif() elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") - set(_ORT_PLATFORM "linux-x64") + if(CMAKE_SYSTEM_PROCESSOR MATCHES "arm64|aarch64") + set(_ORT_PLATFORM "linux-arm64") + else() + set(_ORT_PLATFORM "linux-x64") + endif() elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") if(CMAKE_SYSTEM_PROCESSOR MATCHES "arm64|aarch64") set(_ORT_PLATFORM "osx-arm64") @@ -132,41 +136,47 @@ else() set(_ORT_LIB_DIR "${ortlib_SOURCE_DIR}/runtimes/android/native/jni/${ANDROID_ABI}") message(STATUS "Extracted ORT Android AAR: ${_ORT_AAR_PATH}") elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") - # On Linux the Foundry meta-package doesn't contain libonnxruntime.so directly — - # it's in the Microsoft.ML.OnnxRuntime.Gpu.Linux dependency package. - set(_ORT_GPU_LINUX_PACKAGE "Microsoft.ML.OnnxRuntime.Gpu.Linux") - - # ORT_GPU_LINUX_FETCH_URL can be set externally (e.g. for CI where nuget.org is blocked). - set(ORT_GPU_LINUX_FETCH_URL "" CACHE STRING "Override URL or local path for the ORT GPU Linux NuGet package") - - if(NOT ORT_GPU_LINUX_FETCH_URL) - if(ORT_VERSION MATCHES "-dev-") - set(ORT_GPU_LINUX_FETCH_URL "https://pkgs.dev.azure.com/${ORT_FEED_ORG}/${ORT_FEED_PROJECT}/_apis/packaging/feeds/${ORT_FEED_ID}/nuget/packages/${_ORT_GPU_LINUX_PACKAGE}/versions/${ORT_VERSION}/content?api-version=6.0-preview.1") - message(STATUS "Downloading ${_ORT_GPU_LINUX_PACKAGE} ${ORT_VERSION} from ORT-Nightly feed") + if(_ORT_PLATFORM STREQUAL "linux-x64") + # On Linux x64 the Foundry meta-package doesn't contain libonnxruntime.so directly — + # it's in the Microsoft.ML.OnnxRuntime.Gpu.Linux dependency package. + set(_ORT_GPU_LINUX_PACKAGE "Microsoft.ML.OnnxRuntime.Gpu.Linux") + + # ORT_GPU_LINUX_FETCH_URL can be set externally (e.g. for CI where nuget.org is blocked). + set(ORT_GPU_LINUX_FETCH_URL "" CACHE STRING "Override URL or local path for the ORT GPU Linux NuGet package") + + if(NOT ORT_GPU_LINUX_FETCH_URL) + if(ORT_VERSION MATCHES "-dev-") + set(ORT_GPU_LINUX_FETCH_URL "https://pkgs.dev.azure.com/${ORT_FEED_ORG}/${ORT_FEED_PROJECT}/_apis/packaging/feeds/${ORT_FEED_ID}/nuget/packages/${_ORT_GPU_LINUX_PACKAGE}/versions/${ORT_VERSION}/content?api-version=6.0-preview.1") + message(STATUS "Downloading ${_ORT_GPU_LINUX_PACKAGE} ${ORT_VERSION} from ORT-Nightly feed") + else() + string(TOLOWER "${_ORT_GPU_LINUX_PACKAGE}" _ORT_GPU_LINUX_LOWER) + set(ORT_GPU_LINUX_FETCH_URL "https://api.nuget.org/v3-flatcontainer/${_ORT_GPU_LINUX_LOWER}/${ORT_VERSION}/${_ORT_GPU_LINUX_LOWER}.${ORT_VERSION}.nupkg") + message(STATUS "Downloading ${_ORT_GPU_LINUX_PACKAGE} ${ORT_VERSION} from nuget.org") + endif() else() - string(TOLOWER "${_ORT_GPU_LINUX_PACKAGE}" _ORT_GPU_LINUX_LOWER) - set(ORT_GPU_LINUX_FETCH_URL "https://api.nuget.org/v3-flatcontainer/${_ORT_GPU_LINUX_LOWER}/${ORT_VERSION}/${_ORT_GPU_LINUX_LOWER}.${ORT_VERSION}.nupkg") - message(STATUS "Downloading ${_ORT_GPU_LINUX_PACKAGE} ${ORT_VERSION} from nuget.org") + message(STATUS "Using pre-configured ORT_GPU_LINUX_FETCH_URL: ${ORT_GPU_LINUX_FETCH_URL}") endif() - else() - message(STATUS "Using pre-configured ORT_GPU_LINUX_FETCH_URL: ${ORT_GPU_LINUX_FETCH_URL}") - endif() - # Normalize backslashes and handle .nupkg extension - string(REPLACE "\\" "/" ORT_GPU_LINUX_FETCH_URL "${ORT_GPU_LINUX_FETCH_URL}") - if(ORT_GPU_LINUX_FETCH_URL MATCHES "\\.nupkg$" AND NOT ORT_GPU_LINUX_FETCH_URL MATCHES "^https?://") - set(_ORT_GPU_ZIP_PATH "${CMAKE_BINARY_DIR}/_deps/ort_gpu_linux-download/ort_gpu_linux.zip") - get_filename_component(_ORT_GPU_ZIP_DIR "${_ORT_GPU_ZIP_PATH}" DIRECTORY) - file(MAKE_DIRECTORY "${_ORT_GPU_ZIP_DIR}") - file(COPY_FILE "${ORT_GPU_LINUX_FETCH_URL}" "${_ORT_GPU_ZIP_PATH}") - set(ORT_GPU_LINUX_FETCH_URL "${_ORT_GPU_ZIP_PATH}") - endif() + # Normalize backslashes and handle .nupkg extension + string(REPLACE "\\" "/" ORT_GPU_LINUX_FETCH_URL "${ORT_GPU_LINUX_FETCH_URL}") + if(ORT_GPU_LINUX_FETCH_URL MATCHES "\\.nupkg$" AND NOT ORT_GPU_LINUX_FETCH_URL MATCHES "^https?://") + set(_ORT_GPU_ZIP_PATH "${CMAKE_BINARY_DIR}/_deps/ort_gpu_linux-download/ort_gpu_linux.zip") + get_filename_component(_ORT_GPU_ZIP_DIR "${_ORT_GPU_ZIP_PATH}" DIRECTORY) + file(MAKE_DIRECTORY "${_ORT_GPU_ZIP_DIR}") + file(COPY_FILE "${ORT_GPU_LINUX_FETCH_URL}" "${_ORT_GPU_ZIP_PATH}") + set(ORT_GPU_LINUX_FETCH_URL "${_ORT_GPU_ZIP_PATH}") + endif() - FetchContent_Declare(ort_gpu_linux URL ${ORT_GPU_LINUX_FETCH_URL} DOWNLOAD_EXTRACT_TIMESTAMP TRUE DOWNLOAD_NAME ort_gpu_linux.zip) - FetchContent_MakeAvailable(ort_gpu_linux) + FetchContent_Declare(ort_gpu_linux URL ${ORT_GPU_LINUX_FETCH_URL} DOWNLOAD_EXTRACT_TIMESTAMP TRUE DOWNLOAD_NAME ort_gpu_linux.zip) + FetchContent_MakeAvailable(ort_gpu_linux) - set(_ORT_LIB_DIR "${ort_gpu_linux_SOURCE_DIR}/runtimes/${_ORT_PLATFORM}/native") - message(STATUS "OnnxRuntime GPU Linux package: ${ort_gpu_linux_SOURCE_DIR}") + set(_ORT_LIB_DIR "${ort_gpu_linux_SOURCE_DIR}/runtimes/${_ORT_PLATFORM}/native") + message(STATUS "OnnxRuntime GPU Linux package: ${ort_gpu_linux_SOURCE_DIR}") + else() + # linux-arm64: libonnxruntime.so is included directly in the Microsoft.ML.OnnxRuntime.Foundry + # package under runtimes/linux-arm64/native — no separate GPU package needed. + message(STATUS "OnnxRuntime Linux ARM64: using Foundry package runtimes/${_ORT_PLATFORM}/native") + endif() endif() endif() diff --git a/sdk_v2/cpp/cmake/FindOnnxRuntimeGenAI.cmake b/sdk_v2/cpp/cmake/FindOnnxRuntimeGenAI.cmake index f83002d64..5e46ac817 100644 --- a/sdk_v2/cpp/cmake/FindOnnxRuntimeGenAI.cmake +++ b/sdk_v2/cpp/cmake/FindOnnxRuntimeGenAI.cmake @@ -85,7 +85,11 @@ if(ANDROID) message(FATAL_ERROR "Unsupported Android ABI for OnnxRuntimeGenAI: ${ANDROID_ABI}") endif() elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") - set(_GENAI_PLATFORM "linux-x64") + if(CMAKE_SYSTEM_PROCESSOR MATCHES "arm64|aarch64") + set(_GENAI_PLATFORM "linux-arm64") + else() + set(_GENAI_PLATFORM "linux-x64") + endif() elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") if(CMAKE_SYSTEM_PROCESSOR MATCHES "arm64|aarch64") set(_GENAI_PLATFORM "osx-arm64") diff --git a/sdk_v2/cpp/nuget/Microsoft.AI.Foundry.Local.Runtime.nuspec b/sdk_v2/cpp/nuget/Microsoft.AI.Foundry.Local.Runtime.nuspec index faf12ce63..7091acfa3 100644 --- a/sdk_v2/cpp/nuget/Microsoft.AI.Foundry.Local.Runtime.nuspec +++ b/sdk_v2/cpp/nuget/Microsoft.AI.Foundry.Local.Runtime.nuspec @@ -11,7 +11,7 @@ Native runtime libraries for the Microsoft AI Foundry Local SDK. Contains platform-specific foundry_local binaries for - Windows (x64, ARM64), Linux (x64), and macOS (ARM64). + Windows (x64, ARM64), Linux (x64, ARM64 CPU-only), and macOS (ARM64). © Microsoft Corporation. All rights reserved. Microsoft AI Foundry Local Runtime Native diff --git a/sdk_v2/cpp/nuget/pack.py b/sdk_v2/cpp/nuget/pack.py index 902ba288a..219273775 100644 --- a/sdk_v2/cpp/nuget/pack.py +++ b/sdk_v2/cpp/nuget/pack.py @@ -12,10 +12,11 @@ --version 0.1.0 \\ --ort_version 1.24.4 \\ --genai_version 0.13.1 \\ - --win_x64 path/to/win-x64/bin \\ - --win_arm64 path/to/win-arm64/bin \\ - --linux_x64 path/to/linux-x64/bin \\ - --osx_arm64 path/to/osx-arm64/bin \\ + --win_x64 path/to/win-x64/bin \\ + --win_arm64 path/to/win-arm64/bin \\ + --linux_x64 path/to/linux-x64/bin \\ + --linux_arm64 path/to/linux-arm64/bin \\ + --osx_arm64 path/to/osx-arm64/bin \\ --output_dir ./out Each ``--`` argument points to the directory containing the built @@ -52,6 +53,7 @@ "win_x64": ("win-x64", "foundry_local.dll"), "win_arm64": ("win-arm64", "foundry_local.dll"), "linux_x64": ("linux-x64", "libfoundry_local.so"), + "linux_arm64": ("linux-arm64", "libfoundry_local.so"), "osx_arm64": ("osx-arm64", "libfoundry_local.dylib"), } diff --git a/sdk_v2/cpp/nuget/test_pack.py b/sdk_v2/cpp/nuget/test_pack.py new file mode 100644 index 000000000..809e5068d --- /dev/null +++ b/sdk_v2/cpp/nuget/test_pack.py @@ -0,0 +1,300 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Focused unit tests for pack.py staging behaviour. + +Run with: + python -m unittest test_pack (from sdk_v2/cpp/nuget/) + python test_pack.py (direct invocation) + +These tests use only the Python standard library (unittest, tempfile, pathlib) +and do NOT require nuget.exe or any build artefacts on disk — they verify the +staging logic itself by creating minimal fake artefact trees. +""" + +from __future__ import annotations + +import argparse +import sys +import tempfile +import unittest +from pathlib import Path + +# Make sure pack module can be imported from any working directory. +sys.path.insert(0, str(Path(__file__).parent)) +import pack # noqa: E402 (side-effect-free until main() is called) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _fake_args(**overrides) -> argparse.Namespace: + """Return a minimal Namespace that satisfies pack.stage().""" + defaults: dict = { + "version": "0.0.0-test", + "ort_version": "1.0.0", + "genai_version": "1.0.0", + "package_id": "Test.Package", + "nuget_path": "nuget", + "output_dir": None, # filled in by callers + "staging_dir": None, + } + for arg_name in pack.RIDS: + defaults[arg_name] = None + defaults.update(overrides) + return argparse.Namespace(**defaults) + + +def _make_fake_artifact(root: Path, rid_arg: str) -> Path: + """Create a minimal fake artefact directory for *rid_arg* and return its path.""" + _rid, lib_name = pack.RIDS[rid_arg] + artifact_dir = root / rid_arg + artifact_dir.mkdir(parents=True, exist_ok=True) + (artifact_dir / lib_name).write_bytes(b"fake-binary") + return artifact_dir + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestRidsMapping(unittest.TestCase): + """Verify that the RIDS table contains all expected platforms.""" + + def test_all_expected_rids_present(self): + expected_arg_names = {"win_x64", "win_arm64", "linux_x64", "linux_arm64", "osx_arm64"} + self.assertEqual(set(pack.RIDS.keys()), expected_arg_names, + "RIDS table must contain exactly the five supported platforms") + + def test_linux_arm64_maps_to_correct_rid_and_lib(self): + rid, lib = pack.RIDS["linux_arm64"] + self.assertEqual(rid, "linux-arm64", + "linux_arm64 must map to NuGet RID 'linux-arm64'") + self.assertEqual(lib, "libfoundry_local.so", + "linux_arm64 primary library must be 'libfoundry_local.so'") + + def test_linux_x64_unchanged(self): + rid, lib = pack.RIDS["linux_x64"] + self.assertEqual(rid, "linux-x64") + self.assertEqual(lib, "libfoundry_local.so") + + def test_windows_rids_use_dll(self): + for arg in ("win_x64", "win_arm64"): + with self.subTest(arg=arg): + _rid, lib = pack.RIDS[arg] + self.assertEqual(lib, "foundry_local.dll") + + def test_osx_arm64_uses_dylib(self): + _rid, lib = pack.RIDS["osx_arm64"] + self.assertEqual(lib, "libfoundry_local.dylib") + + +class TestStagingLayout(unittest.TestCase): + """Verify that stage() produces the expected runtimes//native/ layout.""" + + def _run_stage(self, rid_arg: str) -> tuple[Path, Path]: + """ + Run pack.stage() with a single platform artifact. + Returns (staging_dir, artifact_dir). + """ + with tempfile.TemporaryDirectory() as tmp_str: + tmp = Path(tmp_str) + artifact_dir = _make_fake_artifact(tmp / "artifacts", rid_arg) + output_dir = tmp / "out" + staging_dir = tmp / "_staging" + staging_dir.mkdir() + + # pack.stage() needs nuspec + build/ + buildTransitive/ to exist. + # Point SCRIPT_DIR-relative paths to real files only if they exist; + # if not (CI artefacts not present), create stubs so staging doesn't fail. + # We monkey-patch SCRIPT_DIR temporarily to a minimal fake tree. + fake_script_dir = tmp / "nuget_stub" + fake_script_dir.mkdir() + nuspec_stub = fake_script_dir / "Microsoft.AI.Foundry.Local.Runtime.nuspec" + nuspec_stub.write_text('', encoding="utf-8") + (fake_script_dir / "build").mkdir() + (fake_script_dir / "buildTransitive").mkdir() + + # REPO_ROOT = SCRIPT_DIR.parent; make include/ optional + (fake_script_dir.parent / "include").mkdir(exist_ok=True) + + original_script_dir = pack.SCRIPT_DIR + original_repo_root = pack.REPO_ROOT + try: + pack.SCRIPT_DIR = fake_script_dir + pack.REPO_ROOT = fake_script_dir.parent + + args = _fake_args(output_dir=output_dir) + setattr(args, rid_arg, artifact_dir) + + rid_count = pack.stage(args, staging_dir) + finally: + pack.SCRIPT_DIR = original_script_dir + pack.REPO_ROOT = original_repo_root + + # Snapshot the layout before the tempdir is deleted. + layout = { + p.relative_to(staging_dir).as_posix() + for p in staging_dir.rglob("*") + if p.is_file() + } + return layout, rid_count + + def _check_native_file_present(self, layout: set[str], rid: str, lib: str): + expected = f"runtimes/{rid}/native/{lib}" + self.assertIn(expected, layout, + f"Expected '{expected}' in staging layout.\nActual layout:\n " + + "\n ".join(sorted(layout))) + + def test_linux_arm64_staged_correctly(self): + layout, rid_count = self._run_stage("linux_arm64") + self.assertEqual(rid_count, 1) + self._check_native_file_present(layout, "linux-arm64", "libfoundry_local.so") + + def test_linux_x64_staged_correctly(self): + layout, rid_count = self._run_stage("linux_x64") + self.assertEqual(rid_count, 1) + self._check_native_file_present(layout, "linux-x64", "libfoundry_local.so") + + def test_win_x64_staged_correctly(self): + layout, rid_count = self._run_stage("win_x64") + self.assertEqual(rid_count, 1) + self._check_native_file_present(layout, "win-x64", "foundry_local.dll") + + def test_win_arm64_staged_correctly(self): + layout, rid_count = self._run_stage("win_arm64") + self.assertEqual(rid_count, 1) + self._check_native_file_present(layout, "win-arm64", "foundry_local.dll") + + def test_osx_arm64_staged_correctly(self): + layout, rid_count = self._run_stage("osx_arm64") + self.assertEqual(rid_count, 1) + self._check_native_file_present(layout, "osx-arm64", "libfoundry_local.dylib") + + def test_all_platforms_staged_together(self): + """Staging all five platforms at once yields rid_count == 5.""" + with tempfile.TemporaryDirectory() as tmp_str: + tmp = Path(tmp_str) + + fake_script_dir = tmp / "nuget_stub" + fake_script_dir.mkdir() + nuspec_stub = fake_script_dir / "Microsoft.AI.Foundry.Local.Runtime.nuspec" + nuspec_stub.write_text('', encoding="utf-8") + (fake_script_dir / "build").mkdir() + (fake_script_dir / "buildTransitive").mkdir() + (fake_script_dir.parent / "include").mkdir(exist_ok=True) + + artifact_dirs = { + rid_arg: _make_fake_artifact(tmp / "artifacts", rid_arg) + for rid_arg in pack.RIDS + } + + staging_dir = tmp / "_staging" + staging_dir.mkdir() + output_dir = tmp / "out" + + args = _fake_args(output_dir=output_dir) + for rid_arg, artifact_dir in artifact_dirs.items(): + setattr(args, rid_arg, artifact_dir) + + original_script_dir = pack.SCRIPT_DIR + original_repo_root = pack.REPO_ROOT + try: + pack.SCRIPT_DIR = fake_script_dir + pack.REPO_ROOT = fake_script_dir.parent + rid_count = pack.stage(args, staging_dir) + finally: + pack.SCRIPT_DIR = original_script_dir + pack.REPO_ROOT = original_repo_root + + self.assertEqual(rid_count, 5, + "All five RIDs should be staged when all -- args are provided") + + layout = { + p.relative_to(staging_dir).as_posix() + for p in staging_dir.rglob("*") + if p.is_file() + } + for rid_arg, (rid, lib) in pack.RIDS.items(): + with self.subTest(rid=rid): + self._check_native_file_present(layout, rid, lib) + + def test_zero_rids_returns_zero(self): + """Omitting all -- args should return 0 (main() would then exit(1)).""" + with tempfile.TemporaryDirectory() as tmp_str: + tmp = Path(tmp_str) + + fake_script_dir = tmp / "nuget_stub" + fake_script_dir.mkdir() + nuspec_stub = fake_script_dir / "Microsoft.AI.Foundry.Local.Runtime.nuspec" + nuspec_stub.write_text('', encoding="utf-8") + (fake_script_dir / "build").mkdir() + (fake_script_dir / "buildTransitive").mkdir() + (fake_script_dir.parent / "include").mkdir(exist_ok=True) + + staging_dir = tmp / "_staging" + staging_dir.mkdir() + output_dir = tmp / "out" + + args = _fake_args(output_dir=output_dir) # all rid args stay None + + original_script_dir = pack.SCRIPT_DIR + original_repo_root = pack.REPO_ROOT + try: + pack.SCRIPT_DIR = fake_script_dir + pack.REPO_ROOT = fake_script_dir.parent + rid_count = pack.stage(args, staging_dir) + finally: + pack.SCRIPT_DIR = original_script_dir + pack.REPO_ROOT = original_repo_root + + self.assertEqual(rid_count, 0) + + +class TestArgParser(unittest.TestCase): + """Verify that the argument parser accepts --linux_arm64.""" + + def test_linux_arm64_arg_accepted(self): + parser_args = pack._parse_args.__wrapped__ if hasattr(pack._parse_args, "__wrapped__") else None + # Reconstruct via sys.argv override + import sys + original_argv = sys.argv + try: + sys.argv = [ + "pack.py", + "--version", "1.0.0", + "--ort_version", "1.0.0", + "--genai_version", "1.0.0", + "--linux_arm64", "/tmp/linux-arm64", + ] + args = pack._parse_args() + finally: + sys.argv = original_argv + + self.assertIsNotNone(args.linux_arm64, + "--linux_arm64 should be parsed and non-None when provided") + # Compare as Path objects so Windows/POSIX separator differences don't matter. + self.assertEqual(args.linux_arm64, Path("/tmp/linux-arm64")) + + def test_all_rid_args_registered(self): + import sys + original_argv = sys.argv + try: + sys.argv = [ + "pack.py", + "--version", "1.0.0", + "--ort_version", "1.0.0", + "--genai_version", "1.0.0", + ] + args = pack._parse_args() + finally: + sys.argv = original_argv + + for rid_arg in pack.RIDS: + self.assertTrue(hasattr(args, rid_arg), + f"Argument namespace missing attribute '{rid_arg}'") + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk_v2/cpp/src/ep_detection/webgpu_ep_bootstrapper.cc b/sdk_v2/cpp/src/ep_detection/webgpu_ep_bootstrapper.cc index d8b63db52..8342c06d3 100644 --- a/sdk_v2/cpp/src/ep_detection/webgpu_ep_bootstrapper.cc +++ b/sdk_v2/cpp/src/ep_detection/webgpu_ep_bootstrapper.cc @@ -75,13 +75,18 @@ const std::unordered_map kPackageMetada #endif // Platform-specific package metadata is baked into the binary to keep -// verification inputs fixed at build time. +// verification inputs fixed at build time. linux-arm64 is intentionally absent +// from kPackageMetadata — there is no WebGPU EP payload for that target, so +// GetPackageMetadata() throws and DownloadAndRegister returns false gracefully, +// leaving CPU inference fully operational. #if defined(_WIN32) && defined(_M_ARM64) constexpr const char* kPlatformKey = "win-arm64"; #elif defined(_WIN32) constexpr const char* kPlatformKey = "win-x64"; #elif defined(__APPLE__) constexpr const char* kPlatformKey = "macos-arm64"; +#elif defined(__aarch64__) +constexpr const char* kPlatformKey = "linux-arm64"; #else constexpr const char* kPlatformKey = "linux-x64"; #endif @@ -145,19 +150,6 @@ bool WebGpuEpBootstrapper::DownloadAndRegister(bool force, auto parent_dir = ep_dir.parent_path(); try { - const auto& package_metadata = GetPackageMetadata(); - #if defined(_WIN32) - const auto expected_files = std::initializer_list>{ - {kWebGpuProviderLib, package_metadata.provider_sha256}, - {"dxcompiler.dll", package_metadata.dxcompiler_sha256}, - {"dxil.dll", package_metadata.dxil_sha256}, - }; - #else - const auto expected_files = std::initializer_list>{ - {kWebGpuProviderLib, package_metadata.provider_sha256}, - }; - #endif - auto override_path = Utils::GetEnv(kWebGpuProviderOverrideEnv); if (override_path.has_value() && !override_path->empty()) { std::filesystem::path provider_path(*override_path); @@ -196,6 +188,19 @@ bool WebGpuEpBootstrapper::DownloadAndRegister(bool force, return true; } + const auto& package_metadata = GetPackageMetadata(); + #if defined(_WIN32) + const auto expected_files = std::initializer_list>{ + {kWebGpuProviderLib, package_metadata.provider_sha256}, + {"dxcompiler.dll", package_metadata.dxcompiler_sha256}, + {"dxil.dll", package_metadata.dxil_sha256}, + }; + #else + const auto expected_files = std::initializer_list>{ + {kWebGpuProviderLib, package_metadata.provider_sha256}, + }; + #endif + // Check if package already exists and is valid logger.Log(LogLevel::Debug, fmt::format("WebGPU EP: verifying existing install at {}", ep_dir.string())); diff --git a/sdk_v2/cpp/src/ep_detection/webgpu_ep_bootstrapper.h b/sdk_v2/cpp/src/ep_detection/webgpu_ep_bootstrapper.h index e80d4651d..03374043e 100644 --- a/sdk_v2/cpp/src/ep_detection/webgpu_ep_bootstrapper.h +++ b/sdk_v2/cpp/src/ep_detection/webgpu_ep_bootstrapper.h @@ -16,7 +16,9 @@ class ILogger; /// Uses platform-specific package metadata (download URL and SHA-256 hash), /// downloads the binary, verifies integrity, then registers with ORT. /// -/// Supports Windows x64/ARM64, Linux x64, and macOS ARM64. +/// Supported: Windows x64/ARM64, Linux x64, macOS ARM64. +/// Linux ARM64: no WebGPU payload exists; DownloadAndRegister returns false, +/// leaving CPU inference fully operational. class WebGpuEpBootstrapper : public IEpBootstrapper { public: /// @param ep_dir Base directory for EP packages (e.g., appdata/foundry-local). diff --git a/sdk_v2/cpp/test/CMakeLists.txt b/sdk_v2/cpp/test/CMakeLists.txt index 3a5eb51b3..0bbe411a2 100644 --- a/sdk_v2/cpp/test/CMakeLists.txt +++ b/sdk_v2/cpp/test/CMakeLists.txt @@ -59,6 +59,7 @@ add_executable(foundry_local_tests internal_api/toolcalling/grammar_test.cc internal_api/utils_test.cc internal_api/web_service_test.cc + internal_api/webgpu_ep_bootstrapper_test.cc internal_api/zip_extract_test.cc ) diff --git a/sdk_v2/cpp/test/internal_api/webgpu_ep_bootstrapper_test.cc b/sdk_v2/cpp/test/internal_api/webgpu_ep_bootstrapper_test.cc new file mode 100644 index 000000000..02f9451aa --- /dev/null +++ b/sdk_v2/cpp/test/internal_api/webgpu_ep_bootstrapper_test.cc @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Unit tests for WebGpuEpBootstrapper. +// These tests cover behavior that is safe to exercise without network access: +// - Name and initial registration state +// - DownloadAndRegister returns false when the override env points to a +// non-existent file (early-return path before any I/O). +// +// The linux-arm64 "no WebGPU payload" path relies on the compile-time +// kPlatformKey being "linux-arm64", which causes GetPackageMetadata() to +// throw at the start of DownloadAndRegister — caught and returned as false. +// That path can only be exercised on an ARM64 Linux host; the contract is +// documented by the header comment and the static preprocessor block. +// +#include "ep_detection/webgpu_ep_bootstrapper.h" +#include "logger.h" + +#include + +#include +#include +#include + +using namespace fl; + +namespace { + +// RAII helper that sets an environment variable and restores it on destruction. +struct ScopedEnv { + ScopedEnv(const char* name, const char* value) : name_(name) { +#ifdef _WIN32 + char* prev = nullptr; + size_t len = 0; + if (_dupenv_s(&prev, &len, name) == 0 && prev != nullptr) { + prev_ = prev; + free(prev); + } + _putenv_s(name, value); +#else + const char* prev = std::getenv(name); + if (prev) { + prev_ = prev; + } + ::setenv(name, value, /*overwrite=*/1); +#endif + } + + ~ScopedEnv() { +#ifdef _WIN32 + _putenv_s(name_.c_str(), prev_.value_or("").c_str()); +#else + if (prev_.has_value()) { + ::setenv(name_.c_str(), prev_->c_str(), /*overwrite=*/1); + } else { + ::unsetenv(name_.c_str()); + } +#endif + } + + std::string name_; + std::optional prev_; +}; + +// A no-op registration callback — never actually reached in these tests. +EpRegistrationCallback MakeNoopCallback() { + return [](const std::string& /*name*/, const std::filesystem::path& /*path*/) -> bool { + return false; + }; +} + +} // namespace + +// ======================================================================== +// WebGpuEpBootstrapper — contract tests (no network I/O) +// ======================================================================== + +TEST(WebGpuEpBootstrapperTest, Name_IsWebGpuExecutionProvider) { + WebGpuEpBootstrapper bootstrapper("/tmp/foundry-ep-test", MakeNoopCallback()); + EXPECT_EQ(bootstrapper.Name(), "WebGpuExecutionProvider"); +} + +TEST(WebGpuEpBootstrapperTest, IsRegistered_InitiallyFalse) { + WebGpuEpBootstrapper bootstrapper("/tmp/foundry-ep-test", MakeNoopCallback()); + EXPECT_FALSE(bootstrapper.IsRegistered()); +} + +// When FOUNDRY_LOCAL_WEBGPU_EP_LIBRARY points to a non-existent file the +// bootstrapper returns false at the override-path check — before any download +// attempt or platform-specific metadata lookup. +TEST(WebGpuEpBootstrapperTest, DownloadAndRegister_ReturnsFalse_WhenOverrideFileIsMissing) { + StderrLogger logger; + + ScopedEnv env("FOUNDRY_LOCAL_WEBGPU_EP_LIBRARY", "/nonexistent/path/to/webgpu-ep.so"); + + WebGpuEpBootstrapper bootstrapper("/tmp/foundry-ep-test", MakeNoopCallback()); + bool result = bootstrapper.DownloadAndRegister(/*force=*/false, nullptr, logger); + + EXPECT_FALSE(result); + EXPECT_FALSE(bootstrapper.IsRegistered()); +} + +// A second call after a failed attempt also returns false and leaves the +// bootstrapper unregistered. +TEST(WebGpuEpBootstrapperTest, DownloadAndRegister_Unregistered_AfterRepeatedFailure) { + StderrLogger logger; + + ScopedEnv env("FOUNDRY_LOCAL_WEBGPU_EP_LIBRARY", "/nonexistent/path/to/webgpu-ep.so"); + + WebGpuEpBootstrapper bootstrapper("/tmp/foundry-ep-test", MakeNoopCallback()); + + bootstrapper.DownloadAndRegister(/*force=*/false, nullptr, logger); + bootstrapper.DownloadAndRegister(/*force=*/false, nullptr, logger); + + EXPECT_FALSE(bootstrapper.IsRegistered()); +} + diff --git a/sdk_v2/cpp/triplets/arm64-linux.cmake b/sdk_v2/cpp/triplets/arm64-linux.cmake new file mode 100644 index 000000000..239ee2c30 --- /dev/null +++ b/sdk_v2/cpp/triplets/arm64-linux.cmake @@ -0,0 +1,7 @@ +# Custom triplet that adds -fPIC so static vcpkg libraries can be linked +# into the foundry_local shared library (libfoundry_local.so). +set(VCPKG_TARGET_ARCHITECTURE arm64) +set(VCPKG_CRT_LINKAGE dynamic) +set(VCPKG_LIBRARY_LINKAGE static) +set(VCPKG_CMAKE_SYSTEM_NAME Linux) +set(VCPKG_CMAKE_CONFIGURE_OPTIONS "-DCMAKE_POSITION_INDEPENDENT_CODE=ON") diff --git a/sdk_v2/js/script/install-native.cjs b/sdk_v2/js/script/install-native.cjs index bbb372091..f0693cf8d 100644 --- a/sdk_v2/js/script/install-native.cjs +++ b/sdk_v2/js/script/install-native.cjs @@ -204,8 +204,13 @@ async function installPackage(artifact, tempDir, binDir) { const zip = new AdmZip(nupkgPath); const entries = nativeEntriesForRid(zip); if (entries.length === 0) { - console.warn(` No files found for RID ${RID} in ${artifact.name}.`); - return; + throw new Error( + [ + `No native files found for RID '${RID}' in ${artifact.name} ${artifact.version}.`, + 'The package may not yet support this platform.', + 'Set FOUNDRY_LOCAL_SKIP_INSTALL=1 to bypass if you are building from source.', + ].join(' '), + ); } for (const entry of entries) { zip.extractEntryTo(entry, binDir, false, true); diff --git a/sdk_v2/python/_build_backend/__init__.py b/sdk_v2/python/_build_backend/__init__.py index b6de7e945..c2452d643 100644 --- a/sdk_v2/python/_build_backend/__init__.py +++ b/sdk_v2/python/_build_backend/__init__.py @@ -48,8 +48,15 @@ # Patterns for rewriting ORT/GenAI version pins in the dependencies list. Each # captures the package name + ``==`` and we substitute in the version read from # deps_versions.json. -_ORT_PIN_PATTERN = re.compile(r'("onnxruntime(?:-core|-gpu)==)[^\s";]+') -_GENAI_PIN_PATTERN = re.compile(r'("onnxruntime-genai(?:-core|-cuda)==)[^\s";]+') +# +# The suffix group is optional (``?``) so the patterns also match the plain +# ``onnxruntime==`` and ``onnxruntime-genai==`` package names used for Linux +# ARM64 (CPU-only). The ``==`` anchor prevents the ORT pattern from accidentally +# matching ``onnxruntime-genai==`` — the regex engine tries the group first; if +# neither ``-core`` nor ``-gpu`` match the next chars it collapses to zero length, +# then ``==`` must follow immediately, which fails for ``-genai==``. +_ORT_PIN_PATTERN = re.compile(r'("onnxruntime(?:-core|-gpu)?==)[^\s";]+') +_GENAI_PIN_PATTERN = re.compile(r'("onnxruntime-genai(?:-core|-cuda)?==)[^\s";]+') def _read_versions() -> tuple[str, str]: diff --git a/sdk_v2/python/pyproject.toml b/sdk_v2/python/pyproject.toml index 0c5bda0bd..26abff49b 100644 --- a/sdk_v2/python/pyproject.toml +++ b/sdk_v2/python/pyproject.toml @@ -44,15 +44,22 @@ classifiers = [ # If a wheel ever ships with ``==0.0.0`` it means the backend wasn't # invoked (e.g. raw setuptools bypass) — pip install will fail loudly # with "no matching version", which is the desired safety net. +# +# Linux is split by platform_machine: +# x86_64 → onnxruntime-gpu + onnxruntime-genai-cuda (CUDA-capable) +# aarch64 → onnxruntime + onnxruntime-genai (CPU-only, ARM64) +# non-Linux uses the -core/-genai-core packages (Windows, macOS). dependencies = [ "cffi>=1.16", "typing_extensions>=4.5", "pydantic>=2.0.0", "requests>=2.32.4", "openai>=2.24.0", - "onnxruntime-gpu==0.0.0; platform_system == 'Linux'", + "onnxruntime-gpu==0.0.0; platform_system == 'Linux' and platform_machine == 'x86_64'", + "onnxruntime==0.0.0; platform_system == 'Linux' and platform_machine == 'aarch64'", "onnxruntime-core==0.0.0; platform_system != 'Linux'", - "onnxruntime-genai-cuda==0.0.0; platform_system == 'Linux'", + "onnxruntime-genai-cuda==0.0.0; platform_system == 'Linux' and platform_machine == 'x86_64'", + "onnxruntime-genai==0.0.0; platform_system == 'Linux' and platform_machine == 'aarch64'", "onnxruntime-genai-core==0.0.0; platform_system != 'Linux'", ] diff --git a/sdk_v2/python/src/foundry_local_sdk/_native/lib_loader.py b/sdk_v2/python/src/foundry_local_sdk/_native/lib_loader.py index 1369759c8..46a92a101 100644 --- a/sdk_v2/python/src/foundry_local_sdk/_native/lib_loader.py +++ b/sdk_v2/python/src/foundry_local_sdk/_native/lib_loader.py @@ -32,6 +32,11 @@ def _platform_subdir() -> str: return "win-x64" if sys.platform == "darwin": return "osx-arm64" if platform.machine() == "arm64" else "osx-x64" + # Linux: both "aarch64" (kernel convention) and "arm64" (some distros) map + # to the linux-arm64 RID; everything else (x86_64, riscv64, etc.) falls + # through to linux-x64 (only x64 is currently supported on non-arm64 Linux). + if platform.machine() in ("aarch64", "arm64"): + return "linux-arm64" return "linux-x64" diff --git a/sdk_v2/python/test/unit/test_build_backend.py b/sdk_v2/python/test/unit/test_build_backend.py new file mode 100644 index 000000000..177ad531f --- /dev/null +++ b/sdk_v2/python/test/unit/test_build_backend.py @@ -0,0 +1,191 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- +"""Unit tests for _build_backend pin-rewrite logic. + +Exercises the regex patterns and _patch_pyproject_text so we can verify +that all ORT/GenAI package name variants — including the new plain +``onnxruntime`` and ``onnxruntime-genai`` names added for Linux ARM64 — +are correctly rewritten at wheel-build time. + +The module is loaded via spec_from_file_location to avoid a full `pip install +foundry-local-sdk[dev]` cycle; setuptools must be installed since +_build_backend wildcard-imports from setuptools.build_meta. All tests are +skipped when setuptools is absent. +""" +from __future__ import annotations + +import importlib.util +import pathlib +from unittest.mock import patch + +import pytest + +# --------------------------------------------------------------------------- +# Load _build_backend in isolation. +# --------------------------------------------------------------------------- +_BB_PATH = ( + pathlib.Path(__file__).resolve().parents[2] / "_build_backend" / "__init__.py" +) + +try: + import setuptools # noqa: F401 — side-effect: confirms setuptools is present + + _spec = importlib.util.spec_from_file_location("_build_backend_isolated", _BB_PATH) + _bb = importlib.util.module_from_spec(_spec) + _spec.loader.exec_module(_bb) # type: ignore[union-attr] + _BB_AVAILABLE = True +except (ImportError, ModuleNotFoundError): + _bb = None # type: ignore[assignment] + _BB_AVAILABLE = False + +pytestmark = pytest.mark.skipif( + not _BB_AVAILABLE, + reason="setuptools not installed or _build_backend not loadable", +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_ORT_VER = "1.21.0" +_GENAI_VER = "0.7.1" + + +def _patch(text: str) -> str: + """Run _patch_pyproject_text with fixed version stubs.""" + assert _bb is not None + with patch.object(_bb, "_read_versions", return_value=(_ORT_VER, _GENAI_VER)): + return _bb._patch_pyproject_text(text) + + +# --------------------------------------------------------------------------- +# Tests: ORT pin variants +# --------------------------------------------------------------------------- + + +class TestOrtPinRewrite: + def test_plain_onnxruntime_sentinel_rewritten(self): + """Plain onnxruntime== (Linux ARM64 CPU-only package).""" + line = ' "onnxruntime==0.0.0; platform_system == \'Linux\' and platform_machine != \'x86_64\'",\n' + result = _patch(line) + assert f'"onnxruntime=={_ORT_VER}' in result + assert "0.0.0" not in result + + def test_onnxruntime_gpu_sentinel_rewritten(self): + """onnxruntime-gpu== (Linux x86_64 CUDA package) — must still work.""" + line = ' "onnxruntime-gpu==0.0.0; platform_system == \'Linux\'",\n' + result = _patch(line) + assert f'"onnxruntime-gpu=={_ORT_VER}' in result + assert "0.0.0" not in result + + def test_onnxruntime_core_sentinel_rewritten(self): + """onnxruntime-core== (non-Linux package) — must still work.""" + line = ' "onnxruntime-core==0.0.0; platform_system != \'Linux\'",\n' + result = _patch(line) + assert f'"onnxruntime-core=={_ORT_VER}' in result + assert "0.0.0" not in result + + def test_ort_pattern_does_not_match_onnxruntime_genai(self): + """The ORT pattern must not corrupt the genai line.""" + line = ' "onnxruntime-genai==0.0.0; platform_system == \'Linux\'",\n' + # Only the genai pattern should modify this — apply ORT pattern alone. + assert _bb is not None + result = _bb._ORT_PIN_PATTERN.sub( + lambda m: f"{m.group(1)}{_ORT_VER}", line + ) + # onnxruntime-genai should be untouched by the ORT pattern. + assert "onnxruntime-genai==0.0.0" in result + + def test_ort_already_at_target_version_is_idempotent(self): + line = f' "onnxruntime=={_ORT_VER}; platform_system == \'Linux\'",\n' + result = _patch(line) + assert f'"onnxruntime=={_ORT_VER}' in result + + +# --------------------------------------------------------------------------- +# Tests: GenAI pin variants +# --------------------------------------------------------------------------- + + +class TestGenaiPinRewrite: + def test_plain_onnxruntime_genai_sentinel_rewritten(self): + """Plain onnxruntime-genai== (Linux ARM64 CPU-only package).""" + line = ' "onnxruntime-genai==0.0.0; platform_system == \'Linux\' and platform_machine != \'x86_64\'",\n' + result = _patch(line) + assert f'"onnxruntime-genai=={_GENAI_VER}' in result + assert "0.0.0" not in result + + def test_onnxruntime_genai_cuda_sentinel_rewritten(self): + """onnxruntime-genai-cuda== (Linux x86_64) — must still work.""" + line = ' "onnxruntime-genai-cuda==0.0.0; platform_system == \'Linux\'",\n' + result = _patch(line) + assert f'"onnxruntime-genai-cuda=={_GENAI_VER}' in result + assert "0.0.0" not in result + + def test_onnxruntime_genai_core_sentinel_rewritten(self): + """onnxruntime-genai-core== (non-Linux) — must still work.""" + line = ' "onnxruntime-genai-core==0.0.0; platform_system != \'Linux\'",\n' + result = _patch(line) + assert f'"onnxruntime-genai-core=={_GENAI_VER}' in result + assert "0.0.0" not in result + + def test_genai_already_at_target_version_is_idempotent(self): + line = f' "onnxruntime-genai=={_GENAI_VER}; platform_system == \'Linux\'",\n' + result = _patch(line) + assert f'"onnxruntime-genai=={_GENAI_VER}' in result + + +# --------------------------------------------------------------------------- +# Tests: full pyproject.toml block rewrite (integration-style) +# --------------------------------------------------------------------------- + + +class TestFullDependenciesBlock: + """Simulate the complete six-line dependency block from pyproject.toml.""" + + _SAMPLE = """\ +dependencies = [ + "cffi>=1.16", + "typing_extensions>=4.5", + "pydantic>=2.0.0", + "requests>=2.32.4", + "openai>=2.24.0", + "onnxruntime-gpu==0.0.0; platform_system == 'Linux' and platform_machine == 'x86_64'", + "onnxruntime==0.0.0; platform_system == 'Linux' and platform_machine != 'x86_64'", + "onnxruntime-core==0.0.0; platform_system != 'Linux'", + "onnxruntime-genai-cuda==0.0.0; platform_system == 'Linux' and platform_machine == 'x86_64'", + "onnxruntime-genai==0.0.0; platform_system == 'Linux' and platform_machine != 'x86_64'", + "onnxruntime-genai-core==0.0.0; platform_system != 'Linux'", +] +""" + + def test_all_six_sentinels_rewritten(self): + result = _patch(self._SAMPLE) + assert "0.0.0" not in result + + def test_ort_versions_correct(self): + result = _patch(self._SAMPLE) + assert f'"onnxruntime-gpu=={_ORT_VER}' in result + assert f'"onnxruntime=={_ORT_VER}' in result + assert f'"onnxruntime-core=={_ORT_VER}' in result + + def test_genai_versions_correct(self): + result = _patch(self._SAMPLE) + assert f'"onnxruntime-genai-cuda=={_GENAI_VER}' in result + assert f'"onnxruntime-genai=={_GENAI_VER}' in result + assert f'"onnxruntime-genai-core=={_GENAI_VER}' in result + + def test_non_ort_dependencies_unchanged(self): + result = _patch(self._SAMPLE) + assert '"cffi>=1.16"' in result + assert '"pydantic>=2.0.0"' in result + assert '"openai>=2.24.0"' in result + + def test_platform_markers_preserved(self): + result = _patch(self._SAMPLE) + assert "platform_machine == 'x86_64'" in result + assert "platform_machine != 'x86_64'" in result + assert "platform_system == 'Linux'" in result + assert "platform_system != 'Linux'" in result diff --git a/sdk_v2/python/test/unit/test_lib_loader.py b/sdk_v2/python/test/unit/test_lib_loader.py new file mode 100644 index 000000000..f1dd3acb3 --- /dev/null +++ b/sdk_v2/python/test/unit/test_lib_loader.py @@ -0,0 +1,133 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- +"""Unit tests for lib_loader platform RID detection — pure Python, no native deps. + +lib_loader.py is loaded via spec_from_file_location so we can import it +without triggering foundry_local_sdk._native.__init__, which requires the +compiled cffi extension (_cffi_bindings) that is not present in the test venv. +""" +from __future__ import annotations + +import importlib.util +import pathlib +import sys +from unittest.mock import patch + +import pytest + +# --------------------------------------------------------------------------- +# Load lib_loader.py in isolation (no package __init__ side-effects). +# --------------------------------------------------------------------------- +_LIB_LOADER_PATH = ( + pathlib.Path(__file__).resolve().parents[2] + / "src" + / "foundry_local_sdk" + / "_native" + / "lib_loader.py" +) + +_spec = importlib.util.spec_from_file_location( + "foundry_local_sdk._native.lib_loader_isolated", _LIB_LOADER_PATH +) +_ll = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_ll) # type: ignore[union-attr] + + +class TestPlatformSubdir: + """_platform_subdir() must return the correct RID for every supported host.""" + + # ------------------------------------------------------------------ + # Windows + # ------------------------------------------------------------------ + def test_win32_returns_win_x64(self): + with patch.object(_ll.sys, "platform", "win32"): + assert _ll._platform_subdir() == "win-x64" + + # ------------------------------------------------------------------ + # macOS + # ------------------------------------------------------------------ + def test_darwin_arm64_returns_osx_arm64(self): + with patch.object(_ll.sys, "platform", "darwin"), \ + patch.object(_ll.platform, "machine", return_value="arm64"): + assert _ll._platform_subdir() == "osx-arm64" + + def test_darwin_x86_64_returns_osx_x64(self): + with patch.object(_ll.sys, "platform", "darwin"), \ + patch.object(_ll.platform, "machine", return_value="x86_64"): + assert _ll._platform_subdir() == "osx-x64" + + # ------------------------------------------------------------------ + # Linux x64 + # ------------------------------------------------------------------ + def test_linux_x86_64_returns_linux_x64(self): + with patch.object(_ll.sys, "platform", "linux"), \ + patch.object(_ll.platform, "machine", return_value="x86_64"): + assert _ll._platform_subdir() == "linux-x64" + + # ------------------------------------------------------------------ + # Linux ARM64 — both naming conventions must resolve to linux-arm64 + # ------------------------------------------------------------------ + def test_linux_aarch64_returns_linux_arm64(self): + """Kernel reports 'aarch64' on most Linux ARM64 distros.""" + with patch.object(_ll.sys, "platform", "linux"), \ + patch.object(_ll.platform, "machine", return_value="aarch64"): + assert _ll._platform_subdir() == "linux-arm64" + + def test_linux_arm64_returns_linux_arm64(self): + """Some distros / cross-toolchain envs report 'arm64' instead.""" + with patch.object(_ll.sys, "platform", "linux"), \ + patch.object(_ll.platform, "machine", return_value="arm64"): + assert _ll._platform_subdir() == "linux-arm64" + + # ------------------------------------------------------------------ + # Linux — non-arm non-x64 arch falls through to linux-x64 (unsupported + # but should not crash; the wheel simply won't contain the native lib). + # ------------------------------------------------------------------ + def test_linux_other_arch_returns_linux_x64(self): + with patch.object(_ll.sys, "platform", "linux"), \ + patch.object(_ll.platform, "machine", return_value="riscv64"): + assert _ll._platform_subdir() == "linux-x64" + + +class TestLibName: + """_lib_name() must return the platform-appropriate filename.""" + + def test_win32_returns_dll(self): + with patch.object(_ll.sys, "platform", "win32"): + assert _ll._lib_name() == "foundry_local.dll" + + def test_darwin_returns_dylib(self): + with patch.object(_ll.sys, "platform", "darwin"): + assert _ll._lib_name() == "libfoundry_local.dylib" + + def test_linux_returns_so(self): + with patch.object(_ll.sys, "platform", "linux"): + assert _ll._lib_name() == "libfoundry_local.so" + + +class TestFindLibraryWheelBundled: + """find_library() must probe the wheel-bundled path for the current RID.""" + + def test_linux_arm64_probes_linux_arm64_subdir(self, tmp_path): + # Arrange: create a fake wheel-bundled layout under tmp_path. + lib_path = tmp_path / "linux-arm64" / "libfoundry_local.so" + lib_path.parent.mkdir() + lib_path.touch() + + # Point __file__ of the isolated module to the tmp_path parent so + # the resolve().parent gives us tmp_path. + fake_init = tmp_path / "__init__.py" + fake_init.touch() + + with patch.object(_ll.sys, "platform", "linux"), \ + patch.object(_ll.platform, "machine", return_value="aarch64"), \ + patch("pathlib.Path.resolve", return_value=fake_init): + # We can't trivially patch __file__ on an already-loaded module, + # so just verify that _platform_subdir returns the right value + # and the wheel candidate path is constructed correctly. + subdir = _ll._platform_subdir() + assert subdir == "linux-arm64" + candidate = tmp_path / subdir / "libfoundry_local.so" + assert candidate.exists(), f"Expected fake native lib at {candidate}" From 41a0aad7bd7a45413530a331d3c7ba4563acc862 Mon Sep 17 00:00:00 2001 From: Baiju Meswani Date: Mon, 20 Jul 2026 16:31:27 -0700 Subject: [PATCH 2/5] Fix --- .pipelines/v2/templates/stages-python.yml | 4 +- .../v2/templates/steps-build-python.yml | 36 ++++++++++--- .pipelines/v2/templates/steps-test-python.yml | 36 ++++++++++--- sdk_v2/cpp/nuget/test_pack.py | 24 --------- sdk_v2/python/_build_backend/__init__.py | 7 ++- sdk_v2/python/test/unit/test_lib_loader.py | 51 ++++++++++++------- 6 files changed, 97 insertions(+), 61 deletions(-) diff --git a/.pipelines/v2/templates/stages-python.yml b/.pipelines/v2/templates/stages-python.yml index b25ea6e9b..b7651e19d 100644 --- a/.pipelines/v2/templates/stages-python.yml +++ b/.pipelines/v2/templates/stages-python.yml @@ -144,7 +144,7 @@ stages: parameters: nativeArtifactDir: '$(Pipeline.Workspace)/cpp-native-linux-arm64' rid: 'linux-arm64' - pythonArchitecture: 'arm64' + useSystemPython: true outputDir: '$(Build.ArtifactStagingDirectory)/python-sdk' # Assert the produced wheel has the correct platform tag and bundles the # linux-arm64 native library. This runs on the ARM64 agent immediately @@ -294,7 +294,7 @@ stages: parameters: wheelDir: '$(Pipeline.Workspace)/python-sdk-linux-arm64' testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' - pythonArchitecture: 'arm64' + useSystemPython: true - stage: python_test_osx_arm64 displayName: 'Python SDK: Test macOS ARM64' diff --git a/.pipelines/v2/templates/steps-build-python.yml b/.pipelines/v2/templates/steps-build-python.yml index 50f86c9da..268493a9e 100644 --- a/.pipelines/v2/templates/steps-build-python.yml +++ b/.pipelines/v2/templates/steps-build-python.yml @@ -31,15 +31,39 @@ parameters: default: 'x64' values: ['x64', 'arm64'] displayName: 'Architecture of the host Python interpreter (matches the agent OS arch).' +- name: useSystemPython + type: boolean + default: false + displayName: 'Use the agent system Python instead of the Azure Pipelines toolcache.' steps: -- task: UsePythonVersion@0 - displayName: 'Use Python 3.12' - inputs: - versionSpec: '3.12' - addToPath: true - architecture: '${{ parameters.pythonArchitecture }}' +- ${{ if eq(parameters.useSystemPython, false) }}: + - task: UsePythonVersion@0 + displayName: 'Use Python 3.12' + inputs: + versionSpec: '3.12' + addToPath: true + architecture: '${{ parameters.pythonArchitecture }}' + +- ${{ if eq(parameters.useSystemPython, true) }}: + - task: PowerShell@2 + displayName: 'Configure system Python 3.12' + inputs: + targetType: inline + pwsh: true + script: | + python3 --version + python3 -c "import sys; assert sys.version_info[:2] == (3, 12), sys.version" + + $venvDir = "$(Agent.TempDirectory)/fl-py-build-$(System.JobId)" + if (Test-Path $venvDir) { Remove-Item -Recurse -Force $venvDir } + python3 -m venv $venvDir + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $venvBin = Join-Path $venvDir 'bin' + Write-Host "##vso[task.prependpath]$venvBin" + Write-Host "Using system Python through build venv: $venvDir" - task: PowerShell@2 displayName: 'Set package version from pyVersion.txt' diff --git a/.pipelines/v2/templates/steps-test-python.yml b/.pipelines/v2/templates/steps-test-python.yml index 223ef527f..763a7232b 100644 --- a/.pipelines/v2/templates/steps-test-python.yml +++ b/.pipelines/v2/templates/steps-test-python.yml @@ -17,15 +17,39 @@ parameters: default: 'x64' values: ['x64', 'arm64'] displayName: 'Architecture of the host Python interpreter (matches the agent OS arch).' +- name: useSystemPython + type: boolean + default: false + displayName: 'Use the agent system Python instead of the Azure Pipelines toolcache.' steps: -- task: UsePythonVersion@0 - displayName: 'Use Python 3.12' - inputs: - versionSpec: '3.12' - addToPath: true - architecture: '${{ parameters.pythonArchitecture }}' +- ${{ if eq(parameters.useSystemPython, false) }}: + - task: UsePythonVersion@0 + displayName: 'Use Python 3.12' + inputs: + versionSpec: '3.12' + addToPath: true + architecture: '${{ parameters.pythonArchitecture }}' + +- ${{ if eq(parameters.useSystemPython, true) }}: + - task: PowerShell@2 + displayName: 'Configure system Python 3.12' + inputs: + targetType: inline + pwsh: true + script: | + python3 --version + python3 -c "import sys; assert sys.version_info[:2] == (3, 12), sys.version" + + $userBase = & python3 -c "import site; print(site.USER_BASE)" + $userBin = Join-Path $userBase 'bin' + New-Item -ItemType Directory -Force -Path $userBin | Out-Null + $python3Path = (& which python3).Trim() + & ln -sf $python3Path (Join-Path $userBin 'python') + + Write-Host "##vso[task.prependpath]$userBin" + Write-Host "Using system Python: $python3Path" # Job-local venv so installs never pollute the agent's site-packages. - task: PowerShell@2 diff --git a/sdk_v2/cpp/nuget/test_pack.py b/sdk_v2/cpp/nuget/test_pack.py index 809e5068d..c21a926cb 100644 --- a/sdk_v2/cpp/nuget/test_pack.py +++ b/sdk_v2/cpp/nuget/test_pack.py @@ -152,26 +152,6 @@ def test_linux_arm64_staged_correctly(self): self.assertEqual(rid_count, 1) self._check_native_file_present(layout, "linux-arm64", "libfoundry_local.so") - def test_linux_x64_staged_correctly(self): - layout, rid_count = self._run_stage("linux_x64") - self.assertEqual(rid_count, 1) - self._check_native_file_present(layout, "linux-x64", "libfoundry_local.so") - - def test_win_x64_staged_correctly(self): - layout, rid_count = self._run_stage("win_x64") - self.assertEqual(rid_count, 1) - self._check_native_file_present(layout, "win-x64", "foundry_local.dll") - - def test_win_arm64_staged_correctly(self): - layout, rid_count = self._run_stage("win_arm64") - self.assertEqual(rid_count, 1) - self._check_native_file_present(layout, "win-arm64", "foundry_local.dll") - - def test_osx_arm64_staged_correctly(self): - layout, rid_count = self._run_stage("osx_arm64") - self.assertEqual(rid_count, 1) - self._check_native_file_present(layout, "osx-arm64", "libfoundry_local.dylib") - def test_all_platforms_staged_together(self): """Staging all five platforms at once yields rid_count == 5.""" with tempfile.TemporaryDirectory() as tmp_str: @@ -256,9 +236,6 @@ class TestArgParser(unittest.TestCase): """Verify that the argument parser accepts --linux_arm64.""" def test_linux_arm64_arg_accepted(self): - parser_args = pack._parse_args.__wrapped__ if hasattr(pack._parse_args, "__wrapped__") else None - # Reconstruct via sys.argv override - import sys original_argv = sys.argv try: sys.argv = [ @@ -278,7 +255,6 @@ def test_linux_arm64_arg_accepted(self): self.assertEqual(args.linux_arm64, Path("/tmp/linux-arm64")) def test_all_rid_args_registered(self): - import sys original_argv = sys.argv try: sys.argv = [ diff --git a/sdk_v2/python/_build_backend/__init__.py b/sdk_v2/python/_build_backend/__init__.py index c2452d643..88169581b 100644 --- a/sdk_v2/python/_build_backend/__init__.py +++ b/sdk_v2/python/_build_backend/__init__.py @@ -51,10 +51,9 @@ # # The suffix group is optional (``?``) so the patterns also match the plain # ``onnxruntime==`` and ``onnxruntime-genai==`` package names used for Linux -# ARM64 (CPU-only). The ``==`` anchor prevents the ORT pattern from accidentally -# matching ``onnxruntime-genai==`` — the regex engine tries the group first; if -# neither ``-core`` nor ``-gpu`` match the next chars it collapses to zero length, -# then ``==`` must follow immediately, which fails for ``-genai==``. +# ARM64 (CPU-only). The trailing ``==`` anchor keeps the ORT pattern from +# matching ``onnxruntime-genai==`` (its optional suffix collapses to empty, then +# ``==`` must follow immediately, which ``-genai==`` fails). _ORT_PIN_PATTERN = re.compile(r'("onnxruntime(?:-core|-gpu)?==)[^\s";]+') _GENAI_PIN_PATTERN = re.compile(r'("onnxruntime-genai(?:-core|-cuda)?==)[^\s";]+') diff --git a/sdk_v2/python/test/unit/test_lib_loader.py b/sdk_v2/python/test/unit/test_lib_loader.py index f1dd3acb3..544b3ca73 100644 --- a/sdk_v2/python/test/unit/test_lib_loader.py +++ b/sdk_v2/python/test/unit/test_lib_loader.py @@ -108,26 +108,39 @@ def test_linux_returns_so(self): class TestFindLibraryWheelBundled: - """find_library() must probe the wheel-bundled path for the current RID.""" - - def test_linux_arm64_probes_linux_arm64_subdir(self, tmp_path): - # Arrange: create a fake wheel-bundled layout under tmp_path. - lib_path = tmp_path / "linux-arm64" / "libfoundry_local.so" - lib_path.parent.mkdir() + """find_library() must return the wheel-bundled native lib for the current RID. + + Drives the real find_library() wheel-bundled branch (step 2 in its search + order) by pointing the module's ``__file__`` at a fake ``_native`` package + directory that contains a ``linux-arm64/libfoundry_local.so`` payload. + """ + + def test_linux_arm64_returns_bundled_native_lib(self, tmp_path, monkeypatch): + # Arrange: a fake _native package dir holding the linux-arm64 wheel payload. + native_pkg_dir = tmp_path / "_native" + (native_pkg_dir / "linux-arm64").mkdir(parents=True) + lib_path = native_pkg_dir / "linux-arm64" / "libfoundry_local.so" lib_path.touch() - # Point __file__ of the isolated module to the tmp_path parent so - # the resolve().parent gives us tmp_path. - fake_init = tmp_path / "__init__.py" - fake_init.touch() + # find_library() derives the package dir from ``__file__``; the env + # override (step 1) must be unset so it reaches the wheel-bundled branch. + monkeypatch.delenv("FOUNDRY_LOCAL_LIB_DIR", raising=False) + monkeypatch.setattr(_ll, "__file__", str(native_pkg_dir / "lib_loader.py")) + + with patch.object(_ll.sys, "platform", "linux"), \ + patch.object(_ll.platform, "machine", return_value="aarch64"): + resolved = _ll.find_library() + + assert resolved == lib_path.resolve() + + def test_returns_none_when_no_bundled_lib_and_no_dev_build(self, tmp_path, monkeypatch): + """No wheel payload and no sdk_v2/ dev tree → fall through to None (system path).""" + native_pkg_dir = tmp_path / "_native" + native_pkg_dir.mkdir() + + monkeypatch.delenv("FOUNDRY_LOCAL_LIB_DIR", raising=False) + monkeypatch.setattr(_ll, "__file__", str(native_pkg_dir / "lib_loader.py")) with patch.object(_ll.sys, "platform", "linux"), \ - patch.object(_ll.platform, "machine", return_value="aarch64"), \ - patch("pathlib.Path.resolve", return_value=fake_init): - # We can't trivially patch __file__ on an already-loaded module, - # so just verify that _platform_subdir returns the right value - # and the wheel candidate path is constructed correctly. - subdir = _ll._platform_subdir() - assert subdir == "linux-arm64" - candidate = tmp_path / subdir / "libfoundry_local.so" - assert candidate.exists(), f"Expected fake native lib at {candidate}" + patch.object(_ll.platform, "machine", return_value="aarch64"): + assert _ll.find_library() is None From 94784b3d7cd947928544bb9f48ab693e09136ae8 Mon Sep 17 00:00:00 2001 From: Baiju Meswani Date: Wed, 22 Jul 2026 10:22:01 -0700 Subject: [PATCH 3/5] webgpu ep only on windows and macos --- .../ep_detection/webgpu_ep_bootstrapper.cc | 33 +++-- .../src/ep_detection/webgpu_ep_bootstrapper.h | 4 +- sdk_v2/cpp/src/manager.cc | 5 +- sdk_v2/cpp/test/CMakeLists.txt | 1 - .../webgpu_ep_bootstrapper_test.cc | 117 ------------------ 5 files changed, 19 insertions(+), 141 deletions(-) delete mode 100644 sdk_v2/cpp/test/internal_api/webgpu_ep_bootstrapper_test.cc diff --git a/sdk_v2/cpp/src/ep_detection/webgpu_ep_bootstrapper.cc b/sdk_v2/cpp/src/ep_detection/webgpu_ep_bootstrapper.cc index 8342c06d3..d8b63db52 100644 --- a/sdk_v2/cpp/src/ep_detection/webgpu_ep_bootstrapper.cc +++ b/sdk_v2/cpp/src/ep_detection/webgpu_ep_bootstrapper.cc @@ -75,18 +75,13 @@ const std::unordered_map kPackageMetada #endif // Platform-specific package metadata is baked into the binary to keep -// verification inputs fixed at build time. linux-arm64 is intentionally absent -// from kPackageMetadata — there is no WebGPU EP payload for that target, so -// GetPackageMetadata() throws and DownloadAndRegister returns false gracefully, -// leaving CPU inference fully operational. +// verification inputs fixed at build time. #if defined(_WIN32) && defined(_M_ARM64) constexpr const char* kPlatformKey = "win-arm64"; #elif defined(_WIN32) constexpr const char* kPlatformKey = "win-x64"; #elif defined(__APPLE__) constexpr const char* kPlatformKey = "macos-arm64"; -#elif defined(__aarch64__) -constexpr const char* kPlatformKey = "linux-arm64"; #else constexpr const char* kPlatformKey = "linux-x64"; #endif @@ -150,6 +145,19 @@ bool WebGpuEpBootstrapper::DownloadAndRegister(bool force, auto parent_dir = ep_dir.parent_path(); try { + const auto& package_metadata = GetPackageMetadata(); + #if defined(_WIN32) + const auto expected_files = std::initializer_list>{ + {kWebGpuProviderLib, package_metadata.provider_sha256}, + {"dxcompiler.dll", package_metadata.dxcompiler_sha256}, + {"dxil.dll", package_metadata.dxil_sha256}, + }; + #else + const auto expected_files = std::initializer_list>{ + {kWebGpuProviderLib, package_metadata.provider_sha256}, + }; + #endif + auto override_path = Utils::GetEnv(kWebGpuProviderOverrideEnv); if (override_path.has_value() && !override_path->empty()) { std::filesystem::path provider_path(*override_path); @@ -188,19 +196,6 @@ bool WebGpuEpBootstrapper::DownloadAndRegister(bool force, return true; } - const auto& package_metadata = GetPackageMetadata(); - #if defined(_WIN32) - const auto expected_files = std::initializer_list>{ - {kWebGpuProviderLib, package_metadata.provider_sha256}, - {"dxcompiler.dll", package_metadata.dxcompiler_sha256}, - {"dxil.dll", package_metadata.dxil_sha256}, - }; - #else - const auto expected_files = std::initializer_list>{ - {kWebGpuProviderLib, package_metadata.provider_sha256}, - }; - #endif - // Check if package already exists and is valid logger.Log(LogLevel::Debug, fmt::format("WebGPU EP: verifying existing install at {}", ep_dir.string())); diff --git a/sdk_v2/cpp/src/ep_detection/webgpu_ep_bootstrapper.h b/sdk_v2/cpp/src/ep_detection/webgpu_ep_bootstrapper.h index 03374043e..e80d4651d 100644 --- a/sdk_v2/cpp/src/ep_detection/webgpu_ep_bootstrapper.h +++ b/sdk_v2/cpp/src/ep_detection/webgpu_ep_bootstrapper.h @@ -16,9 +16,7 @@ class ILogger; /// Uses platform-specific package metadata (download URL and SHA-256 hash), /// downloads the binary, verifies integrity, then registers with ORT. /// -/// Supported: Windows x64/ARM64, Linux x64, macOS ARM64. -/// Linux ARM64: no WebGPU payload exists; DownloadAndRegister returns false, -/// leaving CPU inference fully operational. +/// Supports Windows x64/ARM64, Linux x64, and macOS ARM64. class WebGpuEpBootstrapper : public IEpBootstrapper { public: /// @param ep_dir Base directory for EP packages (e.g., appdata/foundry-local). diff --git a/sdk_v2/cpp/src/manager.cc b/sdk_v2/cpp/src/manager.cc index 23c6ccb3f..70e67a6a0 100644 --- a/sdk_v2/cpp/src/manager.cc +++ b/sdk_v2/cpp/src/manager.cc @@ -291,9 +291,12 @@ Manager::Manager(const Configuration& config) bootstrappers.push_back(std::make_unique(cuda_ep_dir.string(), register_ep)); } - // WebGPU EP — always available (no hardware detection needed). + // WebGPU EP — only on platforms that ship a WebGPU EP payload (Windows + // x64/ARM64, macOS ARM64). Not injected on Linux or Android. +#if defined(_WIN32) || defined(__APPLE__) const auto webgpu_ep_dir = cache_dir / "webgpu-ep"; bootstrappers.push_back(std::make_unique(webgpu_ep_dir.string(), register_ep)); +#endif ep_detector_ = std::make_unique(*ort_api_, *ort_env_, std::move(bootstrappers), *logger_); diff --git a/sdk_v2/cpp/test/CMakeLists.txt b/sdk_v2/cpp/test/CMakeLists.txt index 0bbe411a2..3a5eb51b3 100644 --- a/sdk_v2/cpp/test/CMakeLists.txt +++ b/sdk_v2/cpp/test/CMakeLists.txt @@ -59,7 +59,6 @@ add_executable(foundry_local_tests internal_api/toolcalling/grammar_test.cc internal_api/utils_test.cc internal_api/web_service_test.cc - internal_api/webgpu_ep_bootstrapper_test.cc internal_api/zip_extract_test.cc ) diff --git a/sdk_v2/cpp/test/internal_api/webgpu_ep_bootstrapper_test.cc b/sdk_v2/cpp/test/internal_api/webgpu_ep_bootstrapper_test.cc deleted file mode 100644 index 02f9451aa..000000000 --- a/sdk_v2/cpp/test/internal_api/webgpu_ep_bootstrapper_test.cc +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -// -// Unit tests for WebGpuEpBootstrapper. -// These tests cover behavior that is safe to exercise without network access: -// - Name and initial registration state -// - DownloadAndRegister returns false when the override env points to a -// non-existent file (early-return path before any I/O). -// -// The linux-arm64 "no WebGPU payload" path relies on the compile-time -// kPlatformKey being "linux-arm64", which causes GetPackageMetadata() to -// throw at the start of DownloadAndRegister — caught and returned as false. -// That path can only be exercised on an ARM64 Linux host; the contract is -// documented by the header comment and the static preprocessor block. -// -#include "ep_detection/webgpu_ep_bootstrapper.h" -#include "logger.h" - -#include - -#include -#include -#include - -using namespace fl; - -namespace { - -// RAII helper that sets an environment variable and restores it on destruction. -struct ScopedEnv { - ScopedEnv(const char* name, const char* value) : name_(name) { -#ifdef _WIN32 - char* prev = nullptr; - size_t len = 0; - if (_dupenv_s(&prev, &len, name) == 0 && prev != nullptr) { - prev_ = prev; - free(prev); - } - _putenv_s(name, value); -#else - const char* prev = std::getenv(name); - if (prev) { - prev_ = prev; - } - ::setenv(name, value, /*overwrite=*/1); -#endif - } - - ~ScopedEnv() { -#ifdef _WIN32 - _putenv_s(name_.c_str(), prev_.value_or("").c_str()); -#else - if (prev_.has_value()) { - ::setenv(name_.c_str(), prev_->c_str(), /*overwrite=*/1); - } else { - ::unsetenv(name_.c_str()); - } -#endif - } - - std::string name_; - std::optional prev_; -}; - -// A no-op registration callback — never actually reached in these tests. -EpRegistrationCallback MakeNoopCallback() { - return [](const std::string& /*name*/, const std::filesystem::path& /*path*/) -> bool { - return false; - }; -} - -} // namespace - -// ======================================================================== -// WebGpuEpBootstrapper — contract tests (no network I/O) -// ======================================================================== - -TEST(WebGpuEpBootstrapperTest, Name_IsWebGpuExecutionProvider) { - WebGpuEpBootstrapper bootstrapper("/tmp/foundry-ep-test", MakeNoopCallback()); - EXPECT_EQ(bootstrapper.Name(), "WebGpuExecutionProvider"); -} - -TEST(WebGpuEpBootstrapperTest, IsRegistered_InitiallyFalse) { - WebGpuEpBootstrapper bootstrapper("/tmp/foundry-ep-test", MakeNoopCallback()); - EXPECT_FALSE(bootstrapper.IsRegistered()); -} - -// When FOUNDRY_LOCAL_WEBGPU_EP_LIBRARY points to a non-existent file the -// bootstrapper returns false at the override-path check — before any download -// attempt or platform-specific metadata lookup. -TEST(WebGpuEpBootstrapperTest, DownloadAndRegister_ReturnsFalse_WhenOverrideFileIsMissing) { - StderrLogger logger; - - ScopedEnv env("FOUNDRY_LOCAL_WEBGPU_EP_LIBRARY", "/nonexistent/path/to/webgpu-ep.so"); - - WebGpuEpBootstrapper bootstrapper("/tmp/foundry-ep-test", MakeNoopCallback()); - bool result = bootstrapper.DownloadAndRegister(/*force=*/false, nullptr, logger); - - EXPECT_FALSE(result); - EXPECT_FALSE(bootstrapper.IsRegistered()); -} - -// A second call after a failed attempt also returns false and leaves the -// bootstrapper unregistered. -TEST(WebGpuEpBootstrapperTest, DownloadAndRegister_Unregistered_AfterRepeatedFailure) { - StderrLogger logger; - - ScopedEnv env("FOUNDRY_LOCAL_WEBGPU_EP_LIBRARY", "/nonexistent/path/to/webgpu-ep.so"); - - WebGpuEpBootstrapper bootstrapper("/tmp/foundry-ep-test", MakeNoopCallback()); - - bootstrapper.DownloadAndRegister(/*force=*/false, nullptr, logger); - bootstrapper.DownloadAndRegister(/*force=*/false, nullptr, logger); - - EXPECT_FALSE(bootstrapper.IsRegistered()); -} - From 030c5519637b9625b8655842098cba4e6477dc3b Mon Sep 17 00:00:00 2001 From: Baiju Meswani Date: Wed, 22 Jul 2026 13:07:01 -0700 Subject: [PATCH 4/5] Address pr review comments: --- .pipelines/v2/templates/stages-python.yml | 42 --------------- .../v2/templates/steps-build-python.yml | 53 +++++++++++++++++++ .pipelines/v2/templates/steps-pack-nuget.yml | 10 ++++ sdk_v2/cpp/nuget/test_pack.py | 19 ++++--- sdk_v2/python/pyproject.toml | 16 +++--- 5 files changed, 85 insertions(+), 55 deletions(-) diff --git a/.pipelines/v2/templates/stages-python.yml b/.pipelines/v2/templates/stages-python.yml index b7651e19d..2e891c47a 100644 --- a/.pipelines/v2/templates/stages-python.yml +++ b/.pipelines/v2/templates/stages-python.yml @@ -10,9 +10,6 @@ # src/foundry_local_sdk/_native//). On Windows that native artifact carries # the reg-free WinML 2.x runtime, so the single wheel includes WinML support. # -# linux-arm64 is CPU-only; it uses onnxruntime + onnxruntime-genai (plain -# packages) rather than the -gpu/-cuda variants used on linux-x64. -# # Each build stage emits one wheel under the `python-sdk-` pipeline artifact. stages: @@ -146,45 +143,6 @@ stages: rid: 'linux-arm64' useSystemPython: true outputDir: '$(Build.ArtifactStagingDirectory)/python-sdk' - # Assert the produced wheel has the correct platform tag and bundles the - # linux-arm64 native library. This runs on the ARM64 agent immediately - # after the build so any packaging error is caught before the artifact is - # uploaded and replicated to downstream stages. - - task: Bash@3 - displayName: 'Assert wheel is linux_aarch64 and contains native lib' - inputs: - targetType: inline - script: | - set -euo pipefail - whl=$(find "$(Build.ArtifactStagingDirectory)/python-sdk" -maxdepth 2 -name "*.whl" | head -n 1) - if [[ -z "$whl" ]]; then - echo "ERROR: no .whl found under $(Build.ArtifactStagingDirectory)/python-sdk" - exit 1 - fi - echo "Wheel: $whl" - basename "$whl" | grep -q 'linux_aarch64' || { - echo "ERROR: expected linux_aarch64 platform tag in wheel name: $(basename $whl)" - exit 1 - } - python3 - "$whl" <<'PY' - import sys - import zipfile - - with zipfile.ZipFile(sys.argv[1]) as wheel: - names = wheel.namelist() - - hits = [name for name in names if "_native/linux-arm64/libfoundry_local.so" in name] - if not hits: - print("ERROR: _native/linux-arm64/libfoundry_local.so not found in wheel") - print("Wheel entries containing .so or linux-arm64:") - for name in names: - if ".so" in name or "linux-arm64" in name: - print(" ", name) - sys.exit(1) - - print("OK: found", hits[0]) - PY - echo "Wheel validation passed." - stage: python_build_osx_arm64 displayName: 'Python SDK: Build macOS ARM64' diff --git a/.pipelines/v2/templates/steps-build-python.yml b/.pipelines/v2/templates/steps-build-python.yml index 268493a9e..3bb89adaa 100644 --- a/.pipelines/v2/templates/steps-build-python.yml +++ b/.pipelines/v2/templates/steps-build-python.yml @@ -354,3 +354,56 @@ steps: script: | Get-ChildItem "${{ parameters.outputDir }}" -Filter '*.whl' | ForEach-Object { Write-Host ("{0,12} {1}" -f $_.Length, $_.Name) } + +# Assert every build stage produced the expected platform-specific wheel. +- task: PowerShell@2 + displayName: 'Assert wheel platform tag and bundled native lib' + inputs: + targetType: inline + pwsh: true + script: | + $ErrorActionPreference = 'Stop' + switch ('${{ parameters.rid }}') { + 'win-x64' { $tags = @('win_amd64'); $lib = 'foundry_local.dll' } + 'win-arm64' { $tags = @('win_arm64'); $lib = 'foundry_local.dll' } + 'linux-x64' { $tags = @('linux_x86_64'); $lib = 'libfoundry_local.so' } + 'linux-arm64' { $tags = @('linux_aarch64'); $lib = 'libfoundry_local.so' } + 'osx-arm64' { $tags = @('macosx', 'arm64'); $lib = 'libfoundry_local.dylib' } + default { throw "Unknown rid: ${{ parameters.rid }}" } + } + + $wheels = @(Get-ChildItem "${{ parameters.outputDir }}" -Filter '*.whl') + if ($wheels.Count -ne 1) { + throw "Expected exactly one .whl under ${{ parameters.outputDir }}, found $($wheels.Count)" + } + + $whl = $wheels[0] + Write-Host "Wheel: $($whl.Name)" + + foreach ($tag in $tags) { + if ($whl.Name -notlike "*$tag*") { + throw "Expected platform tag '$tag' in wheel name: $($whl.Name)" + } + } + + $expected = "_native/${{ parameters.rid }}/$lib" + $code = @' +import sys, zipfile + +whl, expected = sys.argv[1], sys.argv[2] +with zipfile.ZipFile(whl) as wheel: + names = wheel.namelist() + +hits = [name for name in names if name.endswith(expected)] +if not hits: + print("ERROR: " + expected + " not found in wheel") + for name in names: + if name.endswith((".so", ".dll", ".dylib")): + print(" " + name) + sys.exit(1) + +print("OK: found " + hits[0]) +'@ + $code | python - "$($whl.FullName)" "$expected" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Host "Wheel validation passed." diff --git a/.pipelines/v2/templates/steps-pack-nuget.yml b/.pipelines/v2/templates/steps-pack-nuget.yml index 4328b935c..13d96e915 100644 --- a/.pipelines/v2/templates/steps-pack-nuget.yml +++ b/.pipelines/v2/templates/steps-pack-nuget.yml @@ -39,6 +39,16 @@ steps: } } +- task: PowerShell@2 + displayName: 'Test pack.py staging logic' + inputs: + targetType: inline + pwsh: true + script: | + $ErrorActionPreference = 'Stop' + # Guards pack.py's RID mapping and runtimes//native staging before packing. + python "$(Build.SourcesDirectory)/sdk_v2/cpp/nuget/test_pack.py" + - task: PowerShell@2 displayName: 'Pack NuGet package' inputs: diff --git a/sdk_v2/cpp/nuget/test_pack.py b/sdk_v2/cpp/nuget/test_pack.py index c21a926cb..f44e3100c 100644 --- a/sdk_v2/cpp/nuget/test_pack.py +++ b/sdk_v2/cpp/nuget/test_pack.py @@ -3,13 +3,13 @@ """Focused unit tests for pack.py staging behaviour. -Run with: +The ``cpp_pack_nuget`` stage runs these tests before pack.py to guard the RID +table and ``runtimes//native`` staging layout without requiring nuget.exe or +real build artifacts. + +Run manually with: python -m unittest test_pack (from sdk_v2/cpp/nuget/) python test_pack.py (direct invocation) - -These tests use only the Python standard library (unittest, tempfile, pathlib) -and do NOT require nuget.exe or any build artefacts on disk — they verify the -staging logic itself by creating minimal fake artefact trees. """ from __future__ import annotations @@ -93,10 +93,12 @@ def test_osx_arm64_uses_dylib(self): class TestStagingLayout(unittest.TestCase): """Verify that stage() produces the expected runtimes//native/ layout.""" - def _run_stage(self, rid_arg: str) -> tuple[Path, Path]: + def _run_stage(self, rid_arg: str) -> tuple[set[str], int]: """ Run pack.stage() with a single platform artifact. - Returns (staging_dir, artifact_dir). + Returns (layout, rid_count) where ``layout`` is the set of staged file + paths (relative to the staging dir, posix-style) and ``rid_count`` is the + number of RIDs pack.stage() staged. """ with tempfile.TemporaryDirectory() as tmp_str: tmp = Path(tmp_str) @@ -118,6 +120,7 @@ def _run_stage(self, rid_arg: str) -> tuple[Path, Path]: # REPO_ROOT = SCRIPT_DIR.parent; make include/ optional (fake_script_dir.parent / "include").mkdir(exist_ok=True) + (fake_script_dir.parent / "LICENSE.txt").write_text("MIT", encoding="utf-8") original_script_dir = pack.SCRIPT_DIR original_repo_root = pack.REPO_ROOT @@ -164,6 +167,7 @@ def test_all_platforms_staged_together(self): (fake_script_dir / "build").mkdir() (fake_script_dir / "buildTransitive").mkdir() (fake_script_dir.parent / "include").mkdir(exist_ok=True) + (fake_script_dir.parent / "LICENSE.txt").write_text("MIT", encoding="utf-8") artifact_dirs = { rid_arg: _make_fake_artifact(tmp / "artifacts", rid_arg) @@ -212,6 +216,7 @@ def test_zero_rids_returns_zero(self): (fake_script_dir / "build").mkdir() (fake_script_dir / "buildTransitive").mkdir() (fake_script_dir.parent / "include").mkdir(exist_ok=True) + (fake_script_dir.parent / "LICENSE.txt").write_text("MIT", encoding="utf-8") staging_dir = tmp / "_staging" staging_dir.mkdir() diff --git a/sdk_v2/python/pyproject.toml b/sdk_v2/python/pyproject.toml index 26abff49b..d4a7746e1 100644 --- a/sdk_v2/python/pyproject.toml +++ b/sdk_v2/python/pyproject.toml @@ -45,10 +45,14 @@ classifiers = [ # invoked (e.g. raw setuptools bypass) — pip install will fail loudly # with "no matching version", which is the desired safety net. # -# Linux is split by platform_machine: -# x86_64 → onnxruntime-gpu + onnxruntime-genai-cuda (CUDA-capable) -# aarch64 → onnxruntime + onnxruntime-genai (CPU-only, ARM64) -# non-Linux uses the -core/-genai-core packages (Windows, macOS). +# Platform-specific ORT/GenAI packages. The -gpu/-cuda variants are only +# published for Linux x86_64, so ARM64 (and non-Linux) use CPU/core packages: +# +# platform ORT package GenAI package +# --------------------------- ---------------- ---------------------- +# Linux x86_64 (CUDA-capable) onnxruntime-gpu onnxruntime-genai-cuda +# Linux aarch64/arm64 (CPU) onnxruntime onnxruntime-genai +# Windows / macOS onnxruntime-core onnxruntime-genai-core dependencies = [ "cffi>=1.16", "typing_extensions>=4.5", @@ -56,10 +60,10 @@ dependencies = [ "requests>=2.32.4", "openai>=2.24.0", "onnxruntime-gpu==0.0.0; platform_system == 'Linux' and platform_machine == 'x86_64'", - "onnxruntime==0.0.0; platform_system == 'Linux' and platform_machine == 'aarch64'", + "onnxruntime==0.0.0; platform_system == 'Linux' and (platform_machine == 'aarch64' or platform_machine == 'arm64')", "onnxruntime-core==0.0.0; platform_system != 'Linux'", "onnxruntime-genai-cuda==0.0.0; platform_system == 'Linux' and platform_machine == 'x86_64'", - "onnxruntime-genai==0.0.0; platform_system == 'Linux' and platform_machine == 'aarch64'", + "onnxruntime-genai==0.0.0; platform_system == 'Linux' and (platform_machine == 'aarch64' or platform_machine == 'arm64')", "onnxruntime-genai-core==0.0.0; platform_system != 'Linux'", ] From 12d5de861d262c28c90b3598faf062bc9d5d955c Mon Sep 17 00:00:00 2001 From: Baiju Meswani Date: Wed, 22 Jul 2026 13:33:39 -0700 Subject: [PATCH 5/5] Fix pipeline yaml file --- .../v2/templates/steps-build-python.yml | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.pipelines/v2/templates/steps-build-python.yml b/.pipelines/v2/templates/steps-build-python.yml index 3bb89adaa..8f3782558 100644 --- a/.pipelines/v2/templates/steps-build-python.yml +++ b/.pipelines/v2/templates/steps-build-python.yml @@ -388,22 +388,22 @@ steps: $expected = "_native/${{ parameters.rid }}/$lib" $code = @' -import sys, zipfile + import sys, zipfile -whl, expected = sys.argv[1], sys.argv[2] -with zipfile.ZipFile(whl) as wheel: - names = wheel.namelist() + whl, expected = sys.argv[1], sys.argv[2] + with zipfile.ZipFile(whl) as wheel: + names = wheel.namelist() -hits = [name for name in names if name.endswith(expected)] -if not hits: - print("ERROR: " + expected + " not found in wheel") - for name in names: - if name.endswith((".so", ".dll", ".dylib")): - print(" " + name) - sys.exit(1) + hits = [name for name in names if name.endswith(expected)] + if not hits: + print("ERROR: " + expected + " not found in wheel") + for name in names: + if name.endswith((".so", ".dll", ".dylib")): + print(" " + name) + sys.exit(1) -print("OK: found " + hits[0]) -'@ + print("OK: found " + hits[0]) + '@ $code | python - "$($whl.FullName)" "$expected" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } Write-Host "Wheel validation passed."