From b93bd540e6ef4fbcebba876013ef897d21234003 Mon Sep 17 00:00:00 2001 From: bhargav <141304156+bala-bhargav@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:05:43 +0530 Subject: [PATCH] feat(ci): add selective test execution for PR builds (#861) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add component-level test filtering to CI scripts so that pull requests only run tests relevant to the changed files (routing, LP, or MIP). Nightly builds continue to run all tests. - New: ci/detect_test_components.sh — detects changed components from the PR diff and exports CUOPT_TEST_COMPONENTS - Modified: run_ctests.sh — added should_run_test() to filter gtests by component - Modified: run_cuopt_pytests.sh — select pytest directories by component - Modified: test_cpp.sh, test_python.sh, test_wheel_cuopt.sh — conditional dataset downloads based on detected components Closes #861 --- ci/detect_test_components.sh | 154 +++++++++++++++++++++++++++++++++++ ci/run_ctests.sh | 47 ++++++++++- ci/run_cuopt_pytests.sh | 28 ++++++- ci/test_cpp.sh | 31 +++++-- ci/test_python.sh | 30 +++++-- ci/test_wheel_cuopt.sh | 30 +++++-- 6 files changed, 297 insertions(+), 23 deletions(-) create mode 100755 ci/detect_test_components.sh diff --git a/ci/detect_test_components.sh b/ci/detect_test_components.sh new file mode 100755 index 0000000000..b871d9f403 --- /dev/null +++ b/ci/detect_test_components.sh @@ -0,0 +1,154 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Helper script to detect which test components should run based on changed files. +# Source this script to set the CUOPT_TEST_COMPONENTS variable. +# +# Usage: source ci/detect_test_components.sh +# +# Sets CUOPT_TEST_COMPONENTS to a comma-separated list of components (routing,lp,mip) +# or "all" if broad/shared changes are detected or if not in a pull-request build. + +set -euo pipefail + +detect_test_components() { + # If CUOPT_TEST_COMPONENTS is already set externally, respect it + if [[ -n "${CUOPT_TEST_COMPONENTS:-}" ]]; then + export CUOPT_TEST_COMPONENTS + return + fi + + # Only apply selective testing for pull-request builds + if [[ "${RAPIDS_BUILD_TYPE:-}" != "pull-request" ]]; then + export CUOPT_TEST_COMPONENTS="all" + return + fi + + # In RAPIDS CI pull-request builds, the branch is a merge commit so + # HEAD~1..HEAD gives the full PR diff. If the parent isn't reachable + # (e.g. shallow clone or non-merge workflow), fall back to running all. + local changed_files + if ! git rev-parse --verify HEAD~1 &>/dev/null; then + export CUOPT_TEST_COMPONENTS="all" + return + fi + if ! changed_files=$(git diff --name-only HEAD~1..HEAD 2>/dev/null); then + # If git diff fails, run all tests to be safe + export CUOPT_TEST_COMPONENTS="all" + return + fi + + if [[ -z "${changed_files}" ]]; then + export CUOPT_TEST_COMPONENTS="all" + return + fi + + local components="" + local run_all=false + + # Check for shared/infrastructure changes that should trigger all tests + while IFS= read -r file; do + case "${file}" in + cpp/include/*|cpp/cmake/*|cpp/CMakeLists.txt|cpp/src/utilities/*) + run_all=true + break + ;; + # Changes to test infrastructure + cpp/tests/CMakeLists.txt|cpp/tests/utilities/*) + run_all=true + break + ;; + # Changes to CI infrastructure + ci/test_cpp.sh|ci/test_python.sh|ci/test_wheel_cuopt.sh|ci/run_ctests.sh|ci/run_cuopt_pytests.sh|ci/detect_test_components.sh) + run_all=true + break + ;; + # Changes to conda/build config + conda/*|dependencies.yaml) + run_all=true + break + ;; + esac + done <<< "${changed_files}" + + if ${run_all}; then + export CUOPT_TEST_COMPONENTS="all" + return + fi + + # Detect individual components + local has_routing=false + local has_lp=false + local has_mip=false + + while IFS= read -r file; do + case "${file}" in + # Routing component + cpp/src/routing/*|cpp/src/distance/*|cpp/tests/routing/*|cpp/tests/distance_engine/*|cpp/tests/examples/routing/*) + has_routing=true + ;; + python/cuopt/cuopt/routing/*|python/cuopt/cuopt/tests/routing/*) + has_routing=true + ;; + regression/routing*) + has_routing=true + ;; + # LP component + cpp/src/dual_simplex/*|cpp/src/barrier/*|cpp/src/pdlp/*|cpp/src/math_optimization/*) + has_lp=true + ;; + cpp/tests/linear_programming/*|cpp/tests/dual_simplex/*|cpp/tests/qp/*) + has_lp=true + ;; + python/cuopt/cuopt/tests/linear_programming/*|python/cuopt/cuopt/tests/quadratic_programming/*) + has_lp=true + ;; + # LP regression + regression/lp*) + has_lp=true + ;; + # MIP component + cpp/src/branch_and_bound/*|cpp/src/cuts/*|cpp/src/mip_heuristics/*) + has_mip=true + ;; + cpp/tests/mip/*) + has_mip=true + ;; + regression/mip*) + has_mip=true + ;; + # Python source changes that could affect any component + python/cuopt/cuopt/*.py|python/cuopt/cuopt/distance_engine/*|python/cuopt/cuopt/utils/*) + has_routing=true + has_lp=true + ;; + python/libcuopt/*) + has_routing=true + has_lp=true + has_mip=true + ;; + esac + done <<< "${changed_files}" + + # Build the components string + if ${has_routing}; then + components="${components:+${components},}routing" + fi + if ${has_lp}; then + components="${components:+${components},}lp" + fi + if ${has_mip}; then + components="${components:+${components},}mip" + fi + + # If no specific component was detected, default to all + if [[ -z "${components}" ]]; then + components="all" + fi + + export CUOPT_TEST_COMPONENTS="${components}" +} + +detect_test_components +echo "CUOPT_TEST_COMPONENTS=${CUOPT_TEST_COMPONENTS}" diff --git a/ci/run_ctests.sh b/ci/run_ctests.sh index 26f8aae5b7..4bc95923a7 100755 --- a/ci/run_ctests.sh +++ b/ci/run_ctests.sh @@ -4,6 +4,44 @@ set -euo pipefail +# Determine which test binary belongs to which component +should_run_test() { + local test_name="$1" + local components="${CUOPT_TEST_COMPONENTS:-all}" + + # If all components should run, return true + if [[ "${components}" == "all" ]]; then + return 0 + fi + + # Map test binary names to components + case "${test_name}" in + ROUTING_*|WAYPOINT_*|VEHICLE_*|OBJECTIVE_*|RETAIL_*) + [[ "${components}" == *"routing"* ]] && return 0 + ;; + LP_*|PDLP_*|C_API_*|DUAL_SIMPLEX_*|QP_*) + [[ "${components}" == *"lp"* ]] && return 0 + ;; + MIP_*|PROBLEM_*|ELIM_*|STANDARDIZATION_*|MULTI_PROBE_*|INCUMBENT_*|DOC_EXAMPLE_*|CUTS_*|EMPTY_*|DETERMINISM_*) + [[ "${components}" == *"mip"* ]] && return 0 + ;; + # UNIT_TEST and PRESOLVE_TEST can belong to LP or MIP + UNIT_TEST|PRESOLVE_TEST) + [[ "${components}" == *"mip"* || "${components}" == *"lp"* ]] && return 0 + ;; + # CLI_TEST is a general utility test, run if any component is selected + CLI_TEST) + return 0 + ;; + # Unknown tests: run them to be safe + *) + return 0 + ;; + esac + + return 1 +} + # Support customizing the gtests' install location # First, try the installed location (CI/conda environments) installed_test_location="${INSTALL_PREFIX:-${CONDA_PREFIX:-/usr}}/bin/gtests/libcuopt/" @@ -23,6 +61,11 @@ fi for gt in "${GTEST_DIR}"/*_TEST; do test_name=$(basename "${gt}") - echo "Running gtest ${test_name}" - "${gt}" "$@" + if should_run_test "${test_name}"; then + echo "Running gtest ${test_name}" + "${gt}" "$@" + else + echo "Skipping gtest ${test_name} (not in CUOPT_TEST_COMPONENTS=${CUOPT_TEST_COMPONENTS:-all})" + fi done + diff --git a/ci/run_cuopt_pytests.sh b/ci/run_cuopt_pytests.sh index 66e996715a..daac8d000e 100755 --- a/ci/run_cuopt_pytests.sh +++ b/ci/run_cuopt_pytests.sh @@ -7,6 +7,30 @@ set -euo pipefail # It is essential to cd into python/cuopt/cuopt as `pytest-xdist` + `coverage` seem to work only at this directory level. # Support invoking run_cuopt_pytests.sh outside the script directory -cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")"/../python/cuopt/cuopt/ +cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")/../python/cuopt/cuopt/" -pytest -s --cache-clear "$@" tests +# Build the list of test directories based on CUOPT_TEST_COMPONENTS +COMPONENTS="${CUOPT_TEST_COMPONENTS:-all}" +TEST_DIRS="" + +if [[ "${COMPONENTS}" == "all" ]]; then + TEST_DIRS="tests" +else + if [[ "${COMPONENTS}" == *"routing"* ]]; then + TEST_DIRS="${TEST_DIRS} tests/routing" + fi + if [[ "${COMPONENTS}" == *"lp"* ]]; then + TEST_DIRS="${TEST_DIRS} tests/linear_programming tests/quadratic_programming" + fi + # MIP does not have separate Python tests (tested through LP tests) + + # If no Python test dirs matched, skip + if [[ -z "${TEST_DIRS}" ]]; then + echo "No Python test directories match CUOPT_TEST_COMPONENTS=${COMPONENTS}, skipping." + exit 0 + fi +fi + +echo "Running pytest on: ${TEST_DIRS} (CUOPT_TEST_COMPONENTS=${COMPONENTS})" +# shellcheck disable=SC2086 +pytest -s --cache-clear "$@" ${TEST_DIRS} diff --git a/ci/test_cpp.sh b/ci/test_cpp.sh index 653c44133a..418a9a547d 100755 --- a/ci/test_cpp.sh +++ b/ci/test_cpp.sh @@ -34,15 +34,32 @@ rapids-print-env rapids-logger "Check GPU usage" nvidia-smi -rapids-logger "Download datasets" -./datasets/linear_programming/download_pdlp_test_dataset.sh -./datasets/mip/download_miplib_test_dataset.sh +# Detect which test components to run +source ./ci/detect_test_components.sh +rapids-logger "Download datasets" RAPIDS_DATASET_ROOT_DIR="$(realpath datasets)" export RAPIDS_DATASET_ROOT_DIR -pushd "${RAPIDS_DATASET_ROOT_DIR}" -./get_test_data.sh -popd + +if [[ "${CUOPT_TEST_COMPONENTS}" == "all" || "${CUOPT_TEST_COMPONENTS}" == *"lp"* ]]; then + ./datasets/linear_programming/download_pdlp_test_dataset.sh +else + rapids-logger "Skipping LP dataset download (not needed for components: ${CUOPT_TEST_COMPONENTS})" +fi + +if [[ "${CUOPT_TEST_COMPONENTS}" == "all" || "${CUOPT_TEST_COMPONENTS}" == *"mip"* ]]; then + ./datasets/mip/download_miplib_test_dataset.sh +else + rapids-logger "Skipping MIP dataset download (not needed for components: ${CUOPT_TEST_COMPONENTS})" +fi + +if [[ "${CUOPT_TEST_COMPONENTS}" == "all" || "${CUOPT_TEST_COMPONENTS}" == *"routing"* ]]; then + pushd "${RAPIDS_DATASET_ROOT_DIR}" + ./get_test_data.sh + popd +else + rapids-logger "Skipping routing dataset downloads (not needed for components: ${CUOPT_TEST_COMPONENTS})" +fi EXITCODE=0 trap "EXITCODE=1" ERR @@ -51,7 +68,7 @@ set +e # Run gtests from libcuopt-tests package export GTEST_OUTPUT=xml:${RAPIDS_TESTS_DIR}/ -rapids-logger "Run gtests" +rapids-logger "Run gtests (components: ${CUOPT_TEST_COMPONENTS})" timeout 40m ./ci/run_ctests.sh rapids-logger "Test script exiting with value: $EXITCODE" diff --git a/ci/test_python.sh b/ci/test_python.sh index 0a70e56fa7..a1018eb3b2 100755 --- a/ci/test_python.sh +++ b/ci/test_python.sh @@ -35,14 +35,32 @@ mkdir -p "${RAPIDS_TESTS_DIR}" "${RAPIDS_COVERAGE_DIR}" rapids-print-env +# Detect which test components to run +source ./ci/detect_test_components.sh + rapids-logger "Download datasets" RAPIDS_DATASET_ROOT_DIR="$(realpath datasets)" export RAPIDS_DATASET_ROOT_DIR -./datasets/linear_programming/download_pdlp_test_dataset.sh -./datasets/mip/download_miplib_test_dataset.sh -pushd "${RAPIDS_DATASET_ROOT_DIR}" -./get_test_data.sh -popd + +if [[ "${CUOPT_TEST_COMPONENTS}" == "all" || "${CUOPT_TEST_COMPONENTS}" == *"lp"* ]]; then + ./datasets/linear_programming/download_pdlp_test_dataset.sh +else + rapids-logger "Skipping LP dataset download (not needed for components: ${CUOPT_TEST_COMPONENTS})" +fi + +if [[ "${CUOPT_TEST_COMPONENTS}" == "all" || "${CUOPT_TEST_COMPONENTS}" == *"mip"* ]]; then + ./datasets/mip/download_miplib_test_dataset.sh +else + rapids-logger "Skipping MIP dataset download (not needed for components: ${CUOPT_TEST_COMPONENTS})" +fi + +if [[ "${CUOPT_TEST_COMPONENTS}" == "all" || "${CUOPT_TEST_COMPONENTS}" == *"routing"* ]]; then + pushd "${RAPIDS_DATASET_ROOT_DIR}" + ./get_test_data.sh + popd +else + rapids-logger "Skipping routing dataset downloads (not needed for components: ${CUOPT_TEST_COMPONENTS})" +fi rapids-logger "Check GPU usage" nvidia-smi @@ -57,7 +75,7 @@ export OMP_NUM_THREADS=1 rapids-logger "Test cuopt_cli" timeout 10m bash ./python/libcuopt/libcuopt/tests/test_cli.sh -rapids-logger "pytest cuopt" +rapids-logger "pytest cuopt (components: ${CUOPT_TEST_COMPONENTS})" timeout 30m ./ci/run_cuopt_pytests.sh \ --junitxml="${RAPIDS_TESTS_DIR}/junit-cuopt.xml" \ --cov-config=.coveragerc \ diff --git a/ci/test_wheel_cuopt.sh b/ci/test_wheel_cuopt.sh index d761b27214..1e30c28f86 100755 --- a/ci/test_wheel_cuopt.sh +++ b/ci/test_wheel_cuopt.sh @@ -43,16 +43,33 @@ elif command -v dnf &> /dev/null; then dnf -y install file unzip fi -./datasets/linear_programming/download_pdlp_test_dataset.sh -./datasets/mip/download_miplib_test_dataset.sh -cd ./datasets -./get_test_data.sh --solomon -./get_test_data.sh --tsp -cd - +# Detect which test components to run +source ./ci/detect_test_components.sh RAPIDS_DATASET_ROOT_DIR="$(realpath datasets)" export RAPIDS_DATASET_ROOT_DIR +if [[ "${CUOPT_TEST_COMPONENTS}" == "all" || "${CUOPT_TEST_COMPONENTS}" == *"lp"* ]]; then + ./datasets/linear_programming/download_pdlp_test_dataset.sh +else + echo "Skipping LP dataset download (not needed for components: ${CUOPT_TEST_COMPONENTS})" +fi + +if [[ "${CUOPT_TEST_COMPONENTS}" == "all" || "${CUOPT_TEST_COMPONENTS}" == *"mip"* ]]; then + ./datasets/mip/download_miplib_test_dataset.sh +else + echo "Skipping MIP dataset download (not needed for components: ${CUOPT_TEST_COMPONENTS})" +fi + +if [[ "${CUOPT_TEST_COMPONENTS}" == "all" || "${CUOPT_TEST_COMPONENTS}" == *"routing"* ]]; then + cd ./datasets + ./get_test_data.sh --solomon + ./get_test_data.sh --tsp + cd - +else + echo "Skipping routing dataset downloads (not needed for components: ${CUOPT_TEST_COMPONENTS})" +fi + # Run CLI tests timeout 10m bash ./python/libcuopt/libcuopt/tests/test_cli.sh @@ -61,6 +78,7 @@ timeout 10m bash ./python/libcuopt/libcuopt/tests/test_cli.sh # Due to race condition in certain cases UCX might not be able to cleanup properly, so we set the number of threads to 1 export OMP_NUM_THREADS=1 +echo "Running cuopt pytests (components: ${CUOPT_TEST_COMPONENTS})" timeout 30m ./ci/run_cuopt_pytests.sh --verbose --capture=no # run thirdparty integration tests for only nightly builds