diff --git a/.pipelines/integration-tests.yml b/.pipelines/integration-tests.yml new file mode 100644 index 0000000000..1515649ad0 --- /dev/null +++ b/.pipelines/integration-tests.yml @@ -0,0 +1,169 @@ +# ONNX Runtime GenAI - integration tests +# +# Purpose +# Validate the ORT GenAI native layer against real models, end to end. +# The Python binding is the entry point, but the goal is to exercise the +# C++ core: model loading, tokenizer, generator loop, and each supported +# execution provider. +# +# When it runs +# - On every pull request to main: the `pr` suite (small fast set). +# - On every merge to main: the `all` suite (broader coverage). +# - Manually queued: choose the suite and which OS/EP combos. +# +# What gets built +# ORT GenAI is built from source on each run, once per (os, arch): +# - Windows x64: built with --use_cuda (CUDA 12.8, sm_86 for A10). The +# same wheel exercises the CPU, CUDA, and WebGPU lanes. +# - Linux x64: built with --use_cuda (CUDA 12.8, sm_90 for H100) +# inside the manylinux container. The same wheel +# exercises the CPU and CUDA lanes. +# - macOS arm64: built without CUDA. The same wheel exercises the CPU +# and WebGPU lanes. +# +# What gets tested +# The hardwired model catalog lives in test/python/integration/models.py. +# The same catalog is mirrored below in the `pr_models` and `all_models` +# parameter defaults so the pipeline can fan one job out per model. +# Keep them in sync - when adding a new model, update both. +# +# How models are delivered +# Foundry Local publishes models to the `foundrylocalmodels` storage +# account. Each test job (one per model × ep combination) uses the +# agent's managed identity to azcopy just its own model directory, then +# runs pytest against that local copy. +# +# Pipeline structure +# Per (os, arch): +# Stage 1: build the wheel. +# Stage 2: test jobs - one ADO job per (model, ep). Models run in +# parallel on separate agents, so each agent only needs disk +# for one model. + +trigger: + branches: + include: + - main + +pr: + branches: + include: + - main + +parameters: +- name: suite + displayName: 'Test suite. "auto" picks pr for PRs, all for main merges.' + type: string + default: 'auto' + values: + - 'auto' + - 'pr' + - 'all' + +- name: windows_x64_cpu + displayName: 'Run Windows x64 CPU tests' + type: boolean + default: true + +- name: windows_x64_cuda + displayName: 'Run Windows x64 CUDA tests' + type: boolean + default: true + +- name: windows_x64_webgpu + displayName: 'Run Windows x64 WebGPU tests' + type: boolean + default: true + +- name: linux_x64_cpu + displayName: 'Run Linux x64 CPU tests' + type: boolean + default: true + +- name: linux_x64_cuda + displayName: 'Run Linux x64 CUDA tests' + type: boolean + default: true + +- name: macos_arm64_cpu + displayName: 'Run macOS arm64 CPU tests' + type: boolean + default: true + +- name: macos_arm64_webgpu + displayName: 'Run macOS arm64 WebGPU tests' + type: boolean + default: true + +# Suite contents - keep in sync with test/python/integration/models.py. +# The validate_pipeline_in_sync stage runs test/python/integration/check_models_in_sync.py +# at the start of every run and fails the pipeline if these defaults drift +# from the pr / all_ lists in models.py. +- name: pr_models + type: object + default: + - qwen2.5-0.5b-instruct + - qwen3-0.6b + - Phi-3.5-mini-instruct + - Phi-4-mini-instruct + - smollm3-3b + - ministral-3-3b-Instruct-2512 + +- name: all_models + type: object + default: + - qwen2.5-0.5b-instruct + - qwen3-0.6b + - Phi-3.5-mini-instruct + - Phi-4-mini-instruct + - smollm3-3b + - ministral-3-3b-Instruct-2512 + - Phi-3-mini-4k-instruct + - Phi-4 + - Phi-4-mini-reasoning + - Phi-4-reasoning + - deepseek-r1-distill-qwen-1.5b + - olmo-3-7b-instruct + - qwen2.5-1.5b-instruct + - qwen2.5-3b-instruct + - qwen2.5-7b-instruct + - qwen2.5-coder-1.5b-instruct + - qwen3-1.7b + - qwen3-4b + - qwen3-8b + - qwen3.5-0.8b + - qwen3.5-2b + - qwen3.5-4b + +stages: +- template: stages/integration-stage.yml + parameters: + # Pipeline yaml's lists are passed through so the in-sync checker can + # compare them to models.py without parsing yaml itself. + pr_models: ${{ parameters.pr_models }} + all_models: ${{ parameters.all_models }} + # Resolve the suite's model list at compile time so each model + # becomes its own ADO job (fans out via ${{ each }} in the template). + ${{ if eq(parameters.suite, 'pr') }}: + models: ${{ parameters.pr_models }} + ${{ elseif eq(parameters.suite, 'all') }}: + models: ${{ parameters.all_models }} + ${{ elseif eq(variables['Build.Reason'], 'PullRequest') }}: + models: ${{ parameters.pr_models }} + ${{ else }}: + models: ${{ parameters.all_models }} + # Skip the per-(os, arch) build if no test job needs it. + build_windows_x64: ${{ or(parameters.windows_x64_cpu, parameters.windows_x64_cuda, parameters.windows_x64_webgpu) }} + build_linux_x64: ${{ or(parameters.linux_x64_cpu, parameters.linux_x64_cuda) }} + build_macos_arm64: ${{ or(parameters.macos_arm64_cpu, parameters.macos_arm64_webgpu) }} + windows_x64_cpu: ${{ parameters.windows_x64_cpu }} + windows_x64_cuda: ${{ parameters.windows_x64_cuda }} + windows_x64_webgpu: ${{ parameters.windows_x64_webgpu }} + linux_x64_cpu: ${{ parameters.linux_x64_cpu }} + linux_x64_cuda: ${{ parameters.linux_x64_cuda }} + macos_arm64_cpu: ${{ parameters.macos_arm64_cpu }} + macos_arm64_webgpu: ${{ parameters.macos_arm64_webgpu }} + +# TODO: on main-merge failure, post a GitHub issue and a Teams/email to the +# integration-tests owners. Implement as a final job with: +# condition: and(failed(), eq(variables['Build.Reason'], 'IndividualCI')) diff --git a/.pipelines/stages/integration-stage.yml b/.pipelines/stages/integration-stage.yml new file mode 100644 index 0000000000..b6ee243098 --- /dev/null +++ b/.pipelines/stages/integration-stage.yml @@ -0,0 +1,169 @@ +parameters: +# Models that the suite tests. The top-level pipeline picks the list +# based on suite (pr vs all). One ADO job is spawned per (ep, model) +# combination, so models run in parallel on separate agents. +- name: models + type: object + default: [] +# Suite lists for the in-sync checker. Passed through from the top-level +# pipeline so the checker doesn't have to parse YAML itself. +- name: pr_models + type: object + default: [] +- name: all_models + type: object + default: [] +- name: build_windows_x64 + type: boolean + default: true +- name: build_linux_x64 + type: boolean + default: true +- name: windows_x64_cpu + type: boolean + default: true +- name: windows_x64_cuda + type: boolean + default: true +- name: windows_x64_webgpu + type: boolean + default: true +- name: linux_x64_cpu + type: boolean + default: true +- name: linux_x64_cuda + type: boolean + default: true +- name: build_macos_arm64 + type: boolean + default: true +- name: macos_arm64_cpu + type: boolean + default: true +- name: macos_arm64_webgpu + type: boolean + default: true + +stages: +# Fail fast if the pipeline's pr_models / all_models defaults have drifted +# from test/python/integration/models.py. Cheap Linux job, gates all builds. +- stage: validate_pipeline_in_sync + displayName: 'Validate · models.py in sync with pipeline' + dependsOn: [] + jobs: + - job: check_models_in_sync + displayName: 'Check model lists are in sync' + pool: + vmImage: 'ubuntu-latest' + steps: + - checkout: self + fetchDepth: 1 + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + - script: | + python test/python/integration/check_models_in_sync.py \ + --pr "${{ join(',', parameters.pr_models) }}" \ + --all "${{ join(',', parameters.all_models) }}" + displayName: 'Check pr_models / all_models match models.py' + +- ${{ if eq(parameters.build_windows_x64, true) }}: + - stage: build_win_x64 + displayName: 'Build · Windows x64' + dependsOn: validate_pipeline_in_sync + jobs: + - template: jobs/integration-build-job.yml + parameters: + os: 'win' + arch: 'x64' + + - stage: integration_test_win_x64 + displayName: 'Test · Windows x64' + dependsOn: build_win_x64 + jobs: + - ${{ each model in parameters.models }}: + - ${{ if eq(parameters.windows_x64_cpu, true) }}: + - template: jobs/integration-test-job.yml + parameters: + os: 'win' + arch: 'x64' + ep: 'cpu' + model: ${{ model }} + + - ${{ if eq(parameters.windows_x64_cuda, true) }}: + - template: jobs/integration-test-job.yml + parameters: + os: 'win' + arch: 'x64' + ep: 'cuda' + model: ${{ model }} + + - ${{ if eq(parameters.windows_x64_webgpu, true) }}: + - template: jobs/integration-test-job.yml + parameters: + os: 'win' + arch: 'x64' + ep: 'webgpu' + model: ${{ model }} + +- ${{ if eq(parameters.build_linux_x64, true) }}: + - stage: build_linux_x64 + displayName: 'Build · Linux x64' + dependsOn: validate_pipeline_in_sync + jobs: + - template: jobs/integration-build-job.yml + parameters: + os: 'linux' + arch: 'x64' + + - stage: integration_test_linux_x64 + displayName: 'Test · Linux x64' + dependsOn: build_linux_x64 + jobs: + - ${{ each model in parameters.models }}: + - ${{ if eq(parameters.linux_x64_cpu, true) }}: + - template: jobs/integration-test-job.yml + parameters: + os: 'linux' + arch: 'x64' + ep: 'cpu' + model: ${{ model }} + + - ${{ if eq(parameters.linux_x64_cuda, true) }}: + - template: jobs/integration-test-job.yml + parameters: + os: 'linux' + arch: 'x64' + ep: 'cuda' + model: ${{ model }} + +- ${{ if eq(parameters.build_macos_arm64, true) }}: + - stage: build_osx_arm64 + displayName: 'Build · macOS arm64' + dependsOn: validate_pipeline_in_sync + jobs: + - template: jobs/integration-build-job.yml + parameters: + os: 'osx' + arch: 'arm64' + + - stage: integration_test_osx_arm64 + displayName: 'Test · macOS arm64' + dependsOn: build_osx_arm64 + jobs: + - ${{ each model in parameters.models }}: + - ${{ if eq(parameters.macos_arm64_cpu, true) }}: + - template: jobs/integration-test-job.yml + parameters: + os: 'osx' + arch: 'arm64' + ep: 'cpu' + model: ${{ model }} + + - ${{ if eq(parameters.macos_arm64_webgpu, true) }}: + - template: jobs/integration-test-job.yml + parameters: + os: 'osx' + arch: 'arm64' + ep: 'webgpu' + model: ${{ model }} diff --git a/.pipelines/stages/jobs/integration-build-job.yml b/.pipelines/stages/jobs/integration-build-job.yml new file mode 100644 index 0000000000..7d8377a9c4 --- /dev/null +++ b/.pipelines/stages/jobs/integration-build-job.yml @@ -0,0 +1,185 @@ +parameters: +- name: os + type: string + values: + - 'linux' + - 'win' + - 'osx' +- name: arch + type: string + default: 'x64' + +jobs: +- job: build_${{ parameters.os }}_${{ parameters.arch }} + displayName: 'Build wheel (${{ parameters.os }} ${{ parameters.arch }})' + ${{ if eq(parameters.os, 'linux') }}: + pool: 'onnxruntime-Ubuntu2204-AMD-CPU' + ${{ if eq(parameters.os, 'win') }}: + pool: 'onnxruntime-Win-CPU-2022' + ${{ if eq(parameters.os, 'osx') }}: + pool: + name: AcesShared + os: macOS + demands: + - ImageOverride -equals ACES_VM_SharedPool_Sequoia + + timeoutInMinutes: 180 + workspace: + clean: all + + variables: + - name: py_dot_ver + value: '3.12' + - name: py_no_dot_ver + value: '312' + - name: artifactName + value: 'integration-wheel-${{ parameters.os }}-${{ parameters.arch }}' + + steps: + - checkout: self + clean: true + submodules: recursive + + # UsePythonVersion is only needed on Windows and macOS, where build.py + # runs on the host. On Linux the build runs inside the manylinux CUDA + # container which supplies its own /opt/python/cp312-cp312/bin/python3.12. + - ${{ if eq(parameters.os, 'win') }}: + - task: UsePythonVersion@0 + inputs: + versionSpec: $(py_dot_ver) + addToPath: true + architecture: 'x64' + + - ${{ if eq(parameters.os, 'linux') }}: + - task: Docker@2 + displayName: 'Login to Azure container registry' + inputs: + containerRegistry: 'onnxruntimebuildcache' + command: 'login' + addPipelineData: false + + - bash: | + set -e -x + python3 tools/ci_build/get_docker_image.py \ + --dockerfile tools/ci_build/github/linux/docker/manylinux/Dockerfile.manylinux2_28_cuda_12.8 \ + --context tools/ci_build/github/linux/docker/manylinux \ + --docker-build-args "--build-arg BUILD_UID=$( id -u )" \ + --container-registry onnxruntimebuildcache \ + --repository ortgenaicudabuildx64 + displayName: 'Build manylinux CUDA docker image' + workingDirectory: '$(Build.Repository.LocalPath)' + + - bash: | + set -e -x + docker run --rm \ + -v $(Build.Repository.LocalPath):/ort_genai_src \ + -w /ort_genai_src \ + ortgenaicudabuildx64 \ + bash -c " + set -e -x + /opt/python/cp$(py_no_dot_ver)-cp$(py_no_dot_ver)/bin/python$(py_dot_ver) -m pip install requests + /opt/python/cp$(py_no_dot_ver)-cp$(py_no_dot_ver)/bin/python$(py_dot_ver) build.py \ + --config RelWithDebInfo \ + --parallel \ + --use_cuda \ + --cuda_home /usr/local/cuda \ + --cmake_extra_defines MANYLINUX=ON \ + --cmake_extra_defines 'CMAKE_CUDA_ARCHITECTURES=90-real' \ + --cmake_extra_defines PYTHON_EXECUTABLE=/opt/python/cp$(py_no_dot_ver)-cp$(py_no_dot_ver)/bin/python$(py_dot_ver) \ + --skip_tests \ + --skip_examples + " + displayName: 'Build wheel' + workingDirectory: '$(Build.Repository.LocalPath)' + + - task: CopyFiles@2 + displayName: 'Stage wheel' + inputs: + SourceFolder: '$(Build.Repository.LocalPath)/build/Linux/RelWithDebInfo/wheel' + Contents: '*manylinux*.whl' + TargetFolder: '$(Build.ArtifactStagingDirectory)/wheel' + + - ${{ if eq(parameters.os, 'win') }}: + - task: AzureCLI@2 + displayName: 'Download CUDA 12.8' + inputs: + azureSubscription: 'ortcibuild_readonly_mi2-ONNX Runtime' + scriptType: 'pscore' + scriptLocation: 'inlineScript' + workingDirectory: '$(Build.Repository.LocalPath)' + inlineScript: | + azcopy login --identity + azcopy copy --recursive ` + "https://lotusscus.blob.core.windows.net/models/cuda_sdk/v12.8" ` + "cuda_sdk" + + - task: PythonScript@0 + displayName: 'Install python build deps' + inputs: + scriptSource: inline + script: | + import subprocess + subprocess.check_call(['pip', 'install', '-q', 'setuptools', 'wheel', 'build', 'packaging', 'requests']) + workingDirectory: '$(Build.BinariesDirectory)' + + - powershell: | + python build.py ` + --config RelWithDebInfo ` + --parallel ` + --use_cuda ` + --cuda_home "$(Build.Repository.LocalPath)\cuda_sdk\v12.8" ` + --cmake_extra_defines "CMAKE_CUDA_ARCHITECTURES=86-real" ` + --skip_tests ` + --skip_examples + displayName: 'Build wheel' + workingDirectory: '$(Build.Repository.LocalPath)' + + - task: CopyFiles@2 + displayName: 'Stage wheel' + inputs: + SourceFolder: '$(Build.Repository.LocalPath)\build\Windows\RelWithDebInfo\wheel' + Contents: '*.whl' + TargetFolder: '$(Build.ArtifactStagingDirectory)\wheel' + + - ${{ if eq(parameters.os, 'osx') }}: + - task: UsePythonVersion@0 + inputs: + versionSpec: $(py_dot_ver) + addToPath: true + architecture: 'arm64' + + - bash: | + set -e -x + brew update --quiet + brew install --quiet cmake + cmake --version + displayName: 'Install cmake' + + - bash: | + set -e -x + export MACOSX_DEPLOYMENT_TARGET=$(sw_vers -productVersion | cut -d. -f1,2) + python3 -m venv $(Agent.TempDirectory)/genai-build-venv + source $(Agent.TempDirectory)/genai-build-venv/bin/activate + python -m pip install --upgrade pip + python -m pip install requests wheel + python build.py \ + --config RelWithDebInfo \ + --parallel \ + --cmake_extra_defines CMAKE_OSX_ARCHITECTURES=arm64 \ + --skip_tests \ + --skip_examples + displayName: 'Build wheel' + workingDirectory: '$(Build.Repository.LocalPath)' + + - task: CopyFiles@2 + displayName: 'Stage wheel' + inputs: + SourceFolder: '$(Build.Repository.LocalPath)/build/macOS/RelWithDebInfo/wheel' + Contents: '*.whl' + TargetFolder: '$(Build.ArtifactStagingDirectory)/wheel' + + - task: PublishPipelineArtifact@1 + displayName: 'Publish wheel' + inputs: + artifactName: $(artifactName) + targetPath: '$(Build.ArtifactStagingDirectory)/wheel' diff --git a/.pipelines/stages/jobs/integration-test-job.yml b/.pipelines/stages/jobs/integration-test-job.yml new file mode 100644 index 0000000000..336000fa88 --- /dev/null +++ b/.pipelines/stages/jobs/integration-test-job.yml @@ -0,0 +1,103 @@ +parameters: +- name: arch + type: string + default: 'x64' +- name: ep + type: string + values: + - 'cpu' + - 'cuda' + - 'webgpu' +- name: os + type: string + values: + - 'linux' + - 'win' + - 'osx' +- name: model + type: string +- name: py_dot_ver + type: string + default: '3.12' + +jobs: +- job: integration_${{ parameters.ep }}_${{ replace(replace(parameters.model, '.', '_'), '-', '_') }} + displayName: '${{ parameters.ep }} · ${{ parameters.model }}' + ${{ if eq(parameters.os, 'linux') }}: + ${{ if eq(parameters.ep, 'cuda') }}: + pool: 'onnx-publish-Linux-GPU-H100' + ${{ else }}: + pool: 'onnxruntime-Ubuntu2204-AMD-CPU' + ${{ if eq(parameters.os, 'win') }}: + ${{ if or(eq(parameters.ep, 'cuda'), eq(parameters.ep, 'webgpu')) }}: + pool: 'onnxruntime-Win2022-GPU-A10' + ${{ else }}: + pool: 'onnxruntime-Win-CPU-2022' + ${{ if eq(parameters.os, 'osx') }}: + pool: + name: AcesShared + os: macOS + demands: + - ImageOverride -equals ACES_VM_SharedPool_Sequoia + + timeoutInMinutes: 60 + workspace: + clean: all + + variables: + - name: artifactName + value: 'integration-wheel-${{ parameters.os }}-${{ parameters.arch }}' + - name: modelRoot + ${{ if eq(parameters.os, 'win') }}: + value: '$(Agent.TempDirectory)\ortgenai-models' + ${{ else }}: + value: '$(Agent.TempDirectory)/ortgenai-models' + - name: cuda_docker_image + value: onnxruntimebuildcache.azurecr.io/internal/azureml/onnxruntime/build/cuda12_x64_almalinux8_gcc12:20250714.2 + + steps: + - checkout: self + clean: true + submodules: false + + - task: UsePythonVersion@0 + inputs: + versionSpec: ${{ parameters.py_dot_ver }} + addToPath: true + architecture: ${{ parameters.arch }} + + - task: DownloadPipelineArtifact@2 + displayName: 'Download integration wheel' + inputs: + artifactName: $(artifactName) + targetPath: '$(Build.BinariesDirectory)/wheel' + + - ${{ if and(eq(parameters.os, 'win'), eq(parameters.ep, 'cuda')) }}: + - task: AzureCLI@2 + displayName: 'Download CUDA 12.8 runtime' + inputs: + azureSubscription: 'ortcibuild_readonly_mi2-ONNX Runtime' + scriptType: 'pscore' + scriptLocation: 'inlineScript' + workingDirectory: '$(Build.Repository.LocalPath)' + inlineScript: | + $env:AZCOPY_AUTO_LOGIN_TYPE = "AZCLI" + azcopy copy --recursive ` + "https://lotusscus.blob.core.windows.net/models/cuda_sdk/v12.8" ` + "cuda_sdk" + + - template: steps/integration-fetch-models-step.yml + parameters: + os: ${{ parameters.os }} + ep: ${{ parameters.ep }} + model: ${{ parameters.model }} + targetDir: $(modelRoot) + + - template: steps/integration-pytest-step.yml + parameters: + os: ${{ parameters.os }} + ep: ${{ parameters.ep }} + model: ${{ parameters.model }} + modelRoot: $(modelRoot) + cudaDockerImage: $(cuda_docker_image) + wheelDir: '$(Build.BinariesDirectory)/wheel' diff --git a/.pipelines/stages/jobs/steps/integration-fetch-models-step.yml b/.pipelines/stages/jobs/steps/integration-fetch-models-step.yml new file mode 100644 index 0000000000..a8c20d69f6 --- /dev/null +++ b/.pipelines/stages/jobs/steps/integration-fetch-models-step.yml @@ -0,0 +1,90 @@ +parameters: +- name: os + type: string + values: + - 'linux' + - 'win' + - 'osx' +- name: ep + type: string +- name: model + type: string +- name: serviceConnection + type: string + default: 'ortcibuild_readonly_mi2-ONNX Runtime' +- name: storageAccount + type: string + default: 'foundrylocalmodels' +- name: container + type: string + default: 'models' +# Blobs in the container live under this prefix (e.g. +# `models/foundrylocal/models//onnx//v/...`). We point +# azcopy at / so files land at //... +# and ORTGENAI_MODEL_ROOT works without further translation. +- name: pathPrefix + type: string + default: 'foundrylocal/models' +- name: targetDir + type: string + +steps: +- ${{ if eq(parameters.os, 'win') }}: + - task: AzureCLI@2 + displayName: 'azcopy ${{ parameters.model }} (${{ parameters.ep }})' + inputs: + azureSubscription: ${{ parameters.serviceConnection }} + scriptType: 'pscore' + scriptLocation: 'inlineScript' + workingDirectory: '$(Build.Repository.LocalPath)' + inlineScript: | + $subpath = python test/python/integration/suite_paths.py ` + --model "${{ parameters.model }}" ` + --device ${{ parameters.ep }} + if (-not $subpath) { + throw "Model '${{ parameters.model }}' does not support device '${{ parameters.ep }}'" + } + Write-Host "Downloading: $subpath" + New-Item -ItemType Directory -Force "${{ parameters.targetDir }}" | Out-Null + $env:AZCOPY_AUTO_LOGIN_TYPE = "AZCLI" + azcopy copy ` + "https://${{ parameters.storageAccount }}.blob.core.windows.net/${{ parameters.container }}/${{ parameters.pathPrefix }}/*" ` + "${{ parameters.targetDir }}" ` + --include-path "$subpath" ` + --recursive + +- ${{ if eq(parameters.os, 'osx') }}: + - bash: | + set -e -x + if ! command -v azcopy >/dev/null 2>&1; then + brew update --quiet + brew install --quiet azcopy + fi + azcopy --version + displayName: 'Install azcopy (macOS)' + +- ${{ if or(eq(parameters.os, 'linux'), eq(parameters.os, 'osx')) }}: + - task: AzureCLI@2 + displayName: 'azcopy ${{ parameters.model }} (${{ parameters.ep }})' + inputs: + azureSubscription: ${{ parameters.serviceConnection }} + scriptType: 'bash' + scriptLocation: 'inlineScript' + workingDirectory: '$(Build.Repository.LocalPath)' + inlineScript: | + set -e -x + subpath=$(python3 test/python/integration/suite_paths.py \ + --model "${{ parameters.model }}" \ + --device ${{ parameters.ep }}) + if [ -z "$subpath" ]; then + echo "##vso[task.logissue type=error]Model '${{ parameters.model }}' does not support device '${{ parameters.ep }}'" + exit 1 + fi + echo "Downloading: $subpath" + mkdir -p "${{ parameters.targetDir }}" + export AZCOPY_AUTO_LOGIN_TYPE=AZCLI + azcopy copy \ + "https://${{ parameters.storageAccount }}.blob.core.windows.net/${{ parameters.container }}/${{ parameters.pathPrefix }}/*" \ + "${{ parameters.targetDir }}" \ + --include-path "$subpath" \ + --recursive diff --git a/.pipelines/stages/jobs/steps/integration-pytest-step.yml b/.pipelines/stages/jobs/steps/integration-pytest-step.yml new file mode 100644 index 0000000000..853376f4f0 --- /dev/null +++ b/.pipelines/stages/jobs/steps/integration-pytest-step.yml @@ -0,0 +1,139 @@ +parameters: +- name: os + type: string + values: + - 'linux' + - 'win' + - 'osx' +- name: ep + type: string + values: + - 'cpu' + - 'cuda' + - 'webgpu' +- name: model + type: string +- name: modelRoot + type: string +- name: cudaDockerImage + type: string + default: '' +- name: wheelDir + type: string + +steps: +- ${{ if eq(parameters.os, 'win') }}: + - powershell: | + $wheel = Get-ChildItem "${{ parameters.wheelDir }}\*.whl" | Select-Object -First 1 + if (-not $wheel) { throw "no wheel found in ${{ parameters.wheelDir }}" } + python -m pip install --upgrade pip + python -m pip install -r test\python\requirements.txt + python -m pip install pytest + python -m pip install $wheel.FullName + if ("${{ parameters.ep }}" -eq "webgpu") { + python -m pip install onnxruntime-ep-webgpu + } + displayName: 'Install source-built wheel and test deps' + workingDirectory: '$(Build.Repository.LocalPath)' + + - powershell: | + $env:ORTGENAI_MODEL_ROOT = "${{ parameters.modelRoot }}" + if ("${{ parameters.ep }}" -eq "cuda") { + $cudaBin = "$(Build.Repository.LocalPath)\cuda_sdk\v12.8\bin" + if (-not (Test-Path $cudaBin)) { throw "CUDA bin not found at $cudaBin" } + $env:PATH = "$cudaBin;$env:PATH" + nvidia-smi --query-gpu=name,compute_cap,driver_version,memory.total,memory.used,memory.free --format=csv + } + python -m pytest test\python\integration -sv ` + --execution-provider ${{ parameters.ep }} ` + --model "${{ parameters.model }}" ` + --junitxml=$(Common.TestResultsDirectory)\integration-${{ parameters.os }}-${{ parameters.ep }}-${{ parameters.model }}.xml + displayName: 'Run integration test' + workingDirectory: '$(Build.Repository.LocalPath)' + +- ${{ if and(eq(parameters.os, 'linux'), eq(parameters.ep, 'cuda')) }}: + - task: Docker@2 + displayName: 'Login to Azure container registry' + inputs: + containerRegistry: 'onnxruntimebuildcache' + command: 'login' + addPipelineData: false + + - bash: | + set -e -x + docker run --rm --gpus all \ + -v $(Build.Repository.LocalPath):/onnxruntime-genai \ + -v ${{ parameters.wheelDir }}:/wheel \ + -v ${{ parameters.modelRoot }}:/models:ro \ + -v $(Common.TestResultsDirectory):/results \ + -e ORTGENAI_MODEL_ROOT=/models \ + -w /onnxruntime-genai \ + ${{ parameters.cudaDockerImage }} \ + /bin/bash -c " + set -e -x + PYTHON_EXE=/opt/python/cp312-cp312/bin/python3.12 + \$PYTHON_EXE -m pip install -r test/python/requirements.txt + \$PYTHON_EXE -m pip install pytest + \$PYTHON_EXE -m pip install /wheel/*.whl + nvidia-smi --query-gpu=name,compute_cap,driver_version,memory.total,memory.used,memory.free --format=csv + \$PYTHON_EXE -m pytest test/python/integration -sv \ + --execution-provider ${{ parameters.ep }} \ + --model '${{ parameters.model }}' \ + --junitxml=/results/integration-${{ parameters.os }}-${{ parameters.ep }}-${{ parameters.model }}.xml + " + displayName: 'Run integration test in CUDA container' + +- ${{ if and(eq(parameters.os, 'linux'), ne(parameters.ep, 'cuda')) }}: + - bash: | + set -e -x + python3 -m pip install --upgrade pip + python3 -m pip install -r test/python/requirements.txt + python3 -m pip install pytest + python3 -m pip install ${{ parameters.wheelDir }}/*.whl + displayName: 'Install source-built wheel and test deps' + workingDirectory: '$(Build.Repository.LocalPath)' + + - bash: | + set -e -x + export ORTGENAI_MODEL_ROOT="${{ parameters.modelRoot }}" + python3 -m pytest test/python/integration -sv \ + --execution-provider ${{ parameters.ep }} \ + --model "${{ parameters.model }}" \ + --junitxml=$(Common.TestResultsDirectory)/integration-${{ parameters.os }}-${{ parameters.ep }}-${{ parameters.model }}.xml + displayName: 'Run integration test' + workingDirectory: '$(Build.Repository.LocalPath)' + +- ${{ if eq(parameters.os, 'osx') }}: + - bash: | + set -e -x + python3 -m venv $(Agent.TempDirectory)/genai-venv + source $(Agent.TempDirectory)/genai-venv/bin/activate + python -m pip install --upgrade pip + python -m pip install -r test/python/requirements.txt + python -m pip install pytest + python -m pip install ${{ parameters.wheelDir }}/*.whl + if [ "${{ parameters.ep }}" = "webgpu" ]; then + python -m pip install onnxruntime-ep-webgpu + fi + displayName: 'Install source-built wheel and test deps' + workingDirectory: '$(Build.Repository.LocalPath)' + + - bash: | + set -e -x + source $(Agent.TempDirectory)/genai-venv/bin/activate + export ORTGENAI_MODEL_ROOT="${{ parameters.modelRoot }}" + python -m pytest test/python/integration -sv \ + --execution-provider ${{ parameters.ep }} \ + --model "${{ parameters.model }}" \ + --junitxml=$(Common.TestResultsDirectory)/integration-${{ parameters.os }}-${{ parameters.ep }}-${{ parameters.model }}.xml + displayName: 'Run integration test' + workingDirectory: '$(Build.Repository.LocalPath)' + +- task: PublishTestResults@2 + condition: succeededOrFailed() + inputs: + testResultsFormat: 'JUnit' + testResultsFiles: '$(Common.TestResultsDirectory)/integration-*.xml' + testRunTitle: 'integration ${{ parameters.os }}-${{ parameters.ep }}-${{ parameters.model }}' + mergeTestResults: true + failTaskOnFailedTests: true diff --git a/test/python/conftest.py b/test/python/conftest.py index d6583d257e..f320c18ae6 100644 --- a/test/python/conftest.py +++ b/test/python/conftest.py @@ -12,11 +12,13 @@ def pytest_addoption(parser): "--test_models", help="Path to the current working directory", type=str, - required=True, + default=None, ) def get_path_for_model(data_path, model_name, precision, device): + if not data_path: + pytest.skip("--test_models not provided") model_path = os.path.join(data_path, model_name, precision, device) if not os.path.exists(model_path): pytest.skip(f"Model {model_name} not found at {model_path}") diff --git a/test/python/integration/README.md b/test/python/integration/README.md new file mode 100644 index 0000000000..ad15ba7130 --- /dev/null +++ b/test/python/integration/README.md @@ -0,0 +1,104 @@ +# ORT GenAI integration tests + +Real-model integration tests for ONNX Runtime GenAI. The same test code +runs in the ADO `integration-tests` pipeline and on a contributor +machine; only the model source differs. + +## What it tests + +For each (model, execution provider) pair the suite loads the model, +generates a short continuation of the prompt `"The capital of France is"` +with greedy decoding, and asserts non-empty bounded output. A soft check +warns (without failing) when the expected substring (`paris`) is absent. + +## Layout + +``` +test/python/integration/ + models.py # MODELS catalog + suite lists (pr, all) + resolver.py # get_path_for(model, device) -> Path + suite_paths.py # blob prefix for one (model, device) pair + conftest.py # --model, --execution-provider, --model-root + test_integration_text.py # the single text-generation test +``` + +## Running locally + +Point `--model-root` at a directory laid out like +`//onnx//v/genai_config.json` (this is the +exact shape of the `foundrylocalmodels/models` blob container, minus the +`foundrylocal/models/` prefix). Then run: + +```bash +pip install -r test/python/requirements.txt +pip install pytest + +# all models that support cpu +python -m pytest test/python/integration -sv \ + --model-root /path/to/models \ + --execution-provider cpu + +# a single model on cuda +python -m pytest test/python/integration -sv \ + --model-root /path/to/models \ + --execution-provider cuda \ + --model qwen3-0.6b +``` + +`ORTGENAI_MODEL_ROOT` works the same as `--model-root` and is what CI +sets. + +For WebGPU, additionally `pip install onnxruntime-ep-webgpu`. The test +registers the plug-in EP automatically and skips cleanly if the package +isn't installed. + +## Running in CI + +The ADO `integration-tests` pipeline: + +1. Builds ORT GenAI from source for the target OS/EP (one wheel per OS). +2. Fans out test jobs - **one ADO job per (model, ep)** - so each agent + only needs disk for one model. +3. Each test job `azcopy`s its model from `foundrylocalmodels/models` + using the agent's managed identity, then runs `pytest --model `. + +The Foundry Local SDK is intentionally not installed in CI: it bundles a +native ORT GenAI runtime that would shadow the source-built wheel under +test. + +## Adding a new model + +The blob container is populated by the Foundry team. The integration +suites are kept intentionally small - the goal is **coverage of model +architectures**, not coverage of every checkpoint Foundry ships. + +### When to add a model to the suites + +| Situation | Add to `MODELS`? | Add to `pr`? | Add to `all`? | +|---|---|---|---| +| New architecture family arrives (e.g. first time `mamba`/`falcon`/`glm`) | yes | **yes** - pick the smallest size with a real release | yes | +| New size/version of a family we already cover (e.g. `qwen3-32b` when we already test `qwen3-0.6b`) | yes | no | yes | +| Finetune/specialized variant of a model we already cover (e.g. `qwen3-0.6b-pp-finetuned`) | yes | no | no - run manually if you need it | +| Model isn't text-to-text (VLM, MMM, ASR, embeddings) | no - out of scope today | no | no | + +The `pr` suite gates every PR, so its size directly affects developer +wait time. Add to `pr` only when a genuinely new architecture lands; one +representative model per family is enough. + +### Mechanical steps + +1. Confirm the model exists in the container by listing parent + directories of every `genai_config.json` (command in `models.py`). +2. Add an entry to `MODELS` in `models.py` with the device tags it + supports. +3. Append the logical id to `pr` and/or `all` in `models.py` per the + table above. +4. Mirror the change in the `pr_models` / `all_models` default lists in + `.pipelines/integration-tests.yml`. The two must agree; CI fans one + ADO job out per entry in those lists. + +### Scope + +Today: text-to-text only. Vision-language, multimodal, and ASR models +are deliberately out of scope and will be enabled in a separate effort +with a different scenario adapter. diff --git a/test/python/integration/__init__.py b/test/python/integration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/python/integration/check_models_in_sync.py b/test/python/integration/check_models_in_sync.py new file mode 100644 index 0000000000..e8e06289e1 --- /dev/null +++ b/test/python/integration/check_models_in_sync.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Verify the integration pipeline's model lists stay in sync with models.py. + +The pipeline file ``.pipelines/integration-tests.yml`` declares ``pr_models`` +and ``all_models`` parameter defaults so each ADO job can fan out per model. +Those lists must match the ``pr`` and ``all_`` suites in ``models.py``; +otherwise PRs and main merges silently test a different set of models from +what the catalog claims. + +The pipeline passes its own lists in as arguments, so this script doesn't +need to know where the YAML lives or how to parse it: + + python check_models_in_sync.py \\ + --pr qwen2.5-0.5b-instruct,qwen3-0.6b,... \\ + --all qwen2.5-0.5b-instruct,qwen3-0.6b,... + +Exits non-zero with a clear diff on mismatch. +""" + +from __future__ import annotations + +import argparse +import sys + +import models + + +def _split(value: str) -> list[str]: + return [item.strip() for item in value.split(",") if item.strip()] + + +def _diff(expected: list[str], actual: list[str]) -> list[str]: + exp_set, act_set = set(expected), set(actual) + missing = [m for m in expected if m not in act_set] + extra = [m for m in actual if m not in exp_set] + lines: list[str] = [] + if missing: + lines.append(f" missing from pipeline yaml: {missing}") + if extra: + lines.append(f" extra in pipeline yaml: {extra}") + if not missing and not extra and expected != actual: + lines.append(f" order differs:\n models.py: {expected}\n yaml: {actual}") + return lines + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--pr", + required=True, + help="Comma-separated pr_models list from the pipeline yaml.", + ) + parser.add_argument( + "--all", + dest="all_models", + required=True, + help="Comma-separated all_models list from the pipeline yaml.", + ) + args = parser.parse_args(argv) + + suites = ( + ("pr", "pr_models", list(models.pr), _split(args.pr)), + ("all", "all_models", list(models.all_), _split(args.all_models)), + ) + + problems: list[str] = [] + for suite_name, yaml_key, expected, actual in suites: + diff = _diff(expected, actual) + if diff: + problems.append( + f"Suite '{suite_name}' is out of sync: " + f"models.py '{suite_name}' vs pipeline '{yaml_key}':\n" + + "\n".join(diff) + ) + + if problems: + print("ERROR: integration pipeline model lists are out of sync.", file=sys.stderr) + for p in problems: + print(p, file=sys.stderr) + print( + "\nFix: edit .pipelines/integration-tests.yml so 'pr_models' and " + "'all_models' defaults match the 'pr' and 'all_' lists in " + "test/python/integration/models.py.", + file=sys.stderr, + ) + return 1 + + print("OK: pipeline model lists match models.py (pr and all suites).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/python/integration/conftest.py b/test/python/integration/conftest.py new file mode 100644 index 0000000000..8da46e2648 --- /dev/null +++ b/test/python/integration/conftest.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Pytest configuration for the integration suite.""" + +from __future__ import annotations + +import pytest + +from . import models, resolver + + +def pytest_addoption(parser): + group = parser.getgroup("integration") + group.addoption( + "--model-root", + action="store", + default=None, + help="Root directory containing the foundrylocalmodels layout (overrides ORTGENAI_MODEL_ROOT).", + ) + group.addoption( + "--model", + action="append", + default=[], + choices=list(models.MODELS), + help="Logical model id to test (repeatable). Defaults to every entry in MODELS.", + ) + group.addoption( + "--execution-provider", + action="append", + default=[], + choices=list(models.DEVICE_DIRNAMES), + help="Execution providers to test (repeatable). Defaults to cpu only.", + ) + + +def pytest_generate_tests(metafunc): + if "device" in metafunc.fixturenames: + devices = metafunc.config.getoption("--execution-provider") or ["cpu"] + metafunc.parametrize("device", devices) + if "model" in metafunc.fixturenames: + chosen = metafunc.config.getoption("--model") or list(models.MODELS) + metafunc.parametrize("model", chosen) + + +@pytest.fixture +def model_path(device, model, pytestconfig): + return resolver.get_path_for( + model, device, model_root=pytestconfig.getoption("--model-root") + ) diff --git a/test/python/integration/models.py b/test/python/integration/models.py new file mode 100644 index 0000000000..67b2990c4e --- /dev/null +++ b/test/python/integration/models.py @@ -0,0 +1,127 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Catalog of models available in the ``foundrylocalmodels`` blob +container, plus the suites the integration pipeline tests. + +Logical ids here are the exact top-level folder names under +``foundrylocalmodels/models/``. The resolver builds paths shaped like +``//onnx//v/``. + +When the Foundry team uploads a new model, append it to ``MODELS`` (with +the device tags it ships with). Then add it to a suite below if you want +it gated on PRs or main merges. +""" + +from __future__ import annotations + +DEVICE_DIRNAMES: dict[str, str] = { + "cpu": "cpu_and_mobile", + "cuda": "cuda", + "webgpu": "webgpu", +} + + +# Every model in the blob container that is text-to-text and has at least +# one device folder we currently test. Update by listing the parent +# directories of every genai_config.json in the container: +# az storage blob list --account-name foundrylocalmodels \ +# --container-name models --auth-mode login \ +# --query "[?ends_with(name, 'genai_config.json')].name" -o tsv \ +# | sed 's:/genai_config.json$::' +MODELS: dict[str, set[str]] = { + "Phi-3-mini-128k-instruct": {"cpu", "cuda", "webgpu"}, + "Phi-3-mini-4k-instruct": {"cpu", "cuda", "webgpu"}, + "Phi-3.5-mini-instruct": {"cpu", "cuda", "webgpu"}, + "Phi-4": {"cpu", "cuda", "webgpu"}, + "Phi-4-mini-instruct": {"cpu", "cuda", "webgpu"}, + "Phi-4-mini-reasoning": {"cpu", "cuda", "webgpu"}, + "Phi-4-reasoning": {"cpu", "cuda", "webgpu"}, + "deepseek-r1-distill-llama-8b": {"cpu", "cuda", "webgpu"}, + "deepseek-r1-distill-qwen-1.5b": {"cpu", "cuda", "webgpu"}, + "deepseek-r1-distill-qwen-14b": {"cpu", "cuda", "webgpu"}, + "deepseek-r1-distill-qwen-7b": {"cpu", "cuda", "webgpu"}, + "gpt-oss-20b": {"cpu", "cuda", "webgpu"}, + "ministral-3-3b-Instruct-2512": {"cpu", "cuda", "webgpu"}, + "mistral-nemo-12b-instruct": {"cpu", "cuda", "webgpu"}, + "mistralai-Mistral-7B-Instruct-v0-2": {"cpu", "cuda", "webgpu"}, + "olmo-3-7b-instruct": {"cpu", "cuda", "webgpu"}, + "qwen2.5-0.5b-instruct": {"cpu", "cuda", "webgpu"}, + "qwen2.5-1.5b-instruct": {"cpu", "cuda", "webgpu"}, + "qwen2.5-14b-instruct": {"cpu", "cuda", "webgpu"}, + "qwen2.5-3b-instruct": {"cpu", "cuda", "webgpu"}, + "qwen2.5-7b-instruct": {"cpu", "cuda", "webgpu"}, + "qwen2.5-coder-0.5b-instruct": {"cpu", "cuda", "webgpu"}, + "qwen2.5-coder-1.5b-instruct": {"cpu", "cuda", "webgpu"}, + "qwen2.5-coder-14b-instruct": {"cpu", "cuda", "webgpu"}, + "qwen2.5-coder-3b-instruct": {"cpu", "cuda", "webgpu"}, + "qwen2.5-coder-7b-instruct": {"cpu", "cuda", "webgpu"}, + "qwen3-0.6b": {"cpu", "cuda", "webgpu"}, + "qwen3-0.6b-pp-finetuned": {"cpu"}, + "qwen3-0.6b-pp-finetuned-mtt": {"cpu"}, + "qwen3-1.7b": {"cpu", "cuda", "webgpu"}, + "qwen3-14b": {"cpu", "cuda", "webgpu"}, + "qwen3-4b": {"cpu", "cuda", "webgpu"}, + "qwen3-8b": {"cpu", "cuda", "webgpu"}, + "qwen3.5-0.8b": {"cpu", "cuda", "webgpu"}, + "qwen3.5-2b": {"cpu", "cuda", "webgpu"}, + "qwen3.5-2b-text": {"cpu", "cuda", "webgpu"}, + "qwen3.5-4b": {"cpu", "cuda", "webgpu"}, + "qwen3.5-9b": {"cpu", "cuda", "webgpu"}, + "smollm3-3b": {"cpu", "cuda", "webgpu"}, +} + + +# Suites are explicit subsets of MODELS, ordered cheapest-first. +# +# pr - runs on every pull request. One small representative model per +# architecture family, all sub-3B. Optimised for fast PR signal. +# all - runs on every merge to main. Broad coverage including larger +# models and reasoning variants. +pr: list[str] = [ + "qwen2.5-0.5b-instruct", + "qwen3-0.6b", + "Phi-3.5-mini-instruct", + "Phi-4-mini-instruct", + "smollm3-3b", + "ministral-3-3b-Instruct-2512", +] + +all_: list[str] = [ + *pr, + "Phi-3-mini-4k-instruct", + "Phi-4", + "Phi-4-mini-reasoning", + "Phi-4-reasoning", + "deepseek-r1-distill-qwen-1.5b", + "olmo-3-7b-instruct", + "qwen2.5-1.5b-instruct", + "qwen2.5-3b-instruct", + "qwen2.5-7b-instruct", + "qwen2.5-coder-1.5b-instruct", + "qwen3-1.7b", + "qwen3-4b", + "qwen3-8b", + "qwen3.5-0.8b", + "qwen3.5-2b", + "qwen3.5-4b", +] + + +SUITES: dict[str, list[str]] = { + "pr": pr, + "all": all_, +} + + +def supports(logical_id: str, device: str) -> bool: + return device in MODELS.get(logical_id, set()) + + +def storage_subpath(logical_id: str, device: str) -> str: + """Relative path under the blob container for ``(logical_id, device)``. + + The ``vN`` subdirectory is appended at runtime by the resolver after + picking the newest version present. + """ + return f"{logical_id}/onnx/{DEVICE_DIRNAMES[device]}" diff --git a/test/python/integration/resolver.py b/test/python/integration/resolver.py new file mode 100644 index 0000000000..cb7928fbf1 --- /dev/null +++ b/test/python/integration/resolver.py @@ -0,0 +1,77 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Resolve a logical model id + device to an on-disk model directory. + +The integration suite reads models from a directory that mirrors the +``foundrylocalmodels/models/`` blob layout: + + //onnx//v/genai_config.json + +CI populates ```` by azcopy-syncing the prefixes returned by +``test/python/integration/suite_paths.py``. Local devs can either point +``--model-root`` (or ``ORTGENAI_MODEL_ROOT``) at a similarly shaped local +folder, or do their own one-off azcopy. + +The resolver picks the highest available ``vN`` directory automatically. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +import pytest + +from . import models + +_VERSION_DIR = re.compile(r"^v(\d+)$") + + +def _newest_version_dir(base: Path) -> Path | None: + if not base.is_dir(): + return None + versions = [] + for child in base.iterdir(): + m = _VERSION_DIR.match(child.name) + if m and child.is_dir(): + versions.append((int(m.group(1)), child)) + if not versions: + return None + return max(versions, key=lambda v: v[0])[1] + + +def get_path_for( + logical_id: str, + device: str, + *, + model_root: str | None = None, +) -> Path: + """Resolve ``(logical_id, device)`` to an ORT GenAI model directory. + + Skips the test if the model doesn't declare support for that device. + Fails the test if the directory is missing under ``model_root`` - that + indicates a stale azcopy filter or a missing upload, both worth + surfacing loudly. + """ + if not models.supports(logical_id, device): + pytest.skip(f"Model '{logical_id}' does not support device '{device}'.") + + root = model_root or os.environ.get("ORTGENAI_MODEL_ROOT") + if not root: + pytest.skip( + "No model source configured. Set ORTGENAI_MODEL_ROOT or pass --model-root." + ) + + base = Path(root) / models.storage_subpath(logical_id, device) + chosen = _newest_version_dir(base) + if chosen is None: + pytest.fail( + f"Model '{logical_id}' (device={device}) has no v directory under {base}." + ) + if not (chosen / "genai_config.json").exists(): + pytest.fail( + f"Model '{logical_id}' (device={device}) has no genai_config.json at {chosen}." + ) + return chosen diff --git a/test/python/integration/suite_paths.py b/test/python/integration/suite_paths.py new file mode 100644 index 0000000000..b47d02a71f --- /dev/null +++ b/test/python/integration/suite_paths.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Print the storage subpath for a single (model, device) pair. + +Used by the integration pipeline's per-model fetch step. Prints nothing +(empty string) if the model doesn't support the device. + +Usage: + python test/python/integration/suite_paths.py --model qwen3-0.6b --device cuda +""" + +from __future__ import annotations + +import argparse + +import models + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model", required=True, choices=list(models.MODELS)) + parser.add_argument("--device", required=True, choices=list(models.DEVICE_DIRNAMES)) + args = parser.parse_args() + + if not models.supports(args.model, args.device): + return + print(models.storage_subpath(args.model, args.device)) + + +if __name__ == "__main__": + main() diff --git a/test/python/integration/test_integration_text.py b/test/python/integration/test_integration_text.py new file mode 100644 index 0000000000..8b7b906249 --- /dev/null +++ b/test/python/integration/test_integration_text.py @@ -0,0 +1,109 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Text-to-text integration test. + +Loads the model with the requested execution provider, generates a short +deterministic continuation, and asserts non-empty bounded output. The prompt +is a classic completion ("The capital of France is") chosen because it +reliably triggers a continuation across base/instruct/reasoning models +without chat templating. A soft semantic check warns (but does not fail) +when the expected token is absent. + +The model under test is selected via ``--model `` (a single id per +pytest run); the integration pipeline fans the model set out across one +ADO job per model. +""" + +from __future__ import annotations + +import sys +import warnings + +import onnxruntime_genai as og +import pytest + +_PROMPT = "The capital of France is" +_EXPECTED_SUBSTRING = "paris" +_MAX_NEW_TOKENS = 64 + +# Known (platform, device, model) combinations that don't fit on the +# agent's GPU memory. TODO: re-enable these once the GPU agents have +# more VRAM. The current Windows CUDA pool +# (onnxruntime-Win2022-GPU-A10) only exposes ~4 GB to the job. +_VRAM_CONSTRAINED_SKIPS: set[tuple[str, str, str]] = { + ("win32", "cuda", "ministral-3-3b-Instruct-2512"), + ("win32", "cuda", "Phi-4-mini-instruct"), +} + + +def _register_webgpu_plugin_once() -> bool: + """Register the onnxruntime-ep-webgpu plugin once per process. + + The base onnxruntime package doesn't ship a WebGPU EP; the plugin + package provides it as a separate shared library that must be + registered with ORT GenAI before ``append_provider("webgpu")`` works. + Returns True if registration succeeded (or had already happened). + """ + if getattr(_register_webgpu_plugin_once, "_done", False): + return True + try: + import onnxruntime_ep_webgpu as webgpu_ep # noqa: PLC0415 + except ImportError: + return False + og.register_execution_provider_library("webgpu", webgpu_ep.get_library_path()) + _register_webgpu_plugin_once._done = True + return True + + +def _ep_available(device: str) -> bool: + if device == "cpu": + return True + if device == "cuda": + return og.is_cuda_available() + if device == "webgpu": + return _register_webgpu_plugin_once() + return False + + +def test_generates_text(device, model, model_path): + if not _ep_available(device): + pytest.skip(f"Execution provider '{device}' is not available in this build.") + if (sys.platform, device, model) in _VRAM_CONSTRAINED_SKIPS: + pytest.skip( + f"Model '{model}' on device '{device}' ({sys.platform}) " + "is skipped pending more VRAM on the test agent." + ) + + config = og.Config(str(model_path)) + config.clear_providers() + if device != "cpu": + config.append_provider(device) + + og_model = og.Model(config) + tokenizer = og.Tokenizer(og_model) + input_tokens = tokenizer.encode(_PROMPT) + + params = og.GeneratorParams(og_model) + params.set_search_options( + max_length=len(input_tokens) + _MAX_NEW_TOKENS, + do_sample=False, + ) + generator = og.Generator(og_model, params) + generator.append_tokens(input_tokens) + while not generator.is_done(): + generator.generate_next_token() + + new_tokens = generator.get_sequence(0)[len(input_tokens):] + assert len(new_tokens) > 0, "generator produced no new tokens" + assert len(new_tokens) <= _MAX_NEW_TOKENS + + text = tokenizer.decode(new_tokens) + assert isinstance(text, str) and text.strip(), "decoded text was empty" + + if _EXPECTED_SUBSTRING not in text.lower(): + warnings.warn( + f"[{model}/{device}] expected '{_EXPECTED_SUBSTRING}' in completion of " + f"{_PROMPT!r}; got {text!r}", + stacklevel=2, + )