diff --git a/.gitignore b/.gitignore index d4f98d1484..c9e16fcc5d 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,8 @@ docs/cuopt/build cpp/include/cuopt/semantic_version.hpp !datasets/quadratic_programming !datasets/quadratic_programming/** +# downloaded QPLIB instances (see download_qplib_test_dataset.sh) +datasets/quadratic_programming/qplib/ !datasets/solomon/ !datasets/solomon/In/ !datasets/solomon/In/r107.txt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 841e114a34..95f5e0b327 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -222,6 +222,7 @@ To run the C++ tests, run cd $CUOPT_HOME/datasets && ./get_test_data.sh cd $CUOPT_HOME && datasets/linear_programming/download_pdlp_test_dataset.sh datasets/mip/download_miplib_test_dataset.sh +datasets/quadratic_programming/download_qplib_test_dataset.sh export RAPIDS_DATASET_ROOT_DIR=$CUOPT_HOME/datasets/ ctest --test-dir ${CUOPT_HOME}/cpp/build # libcuopt ``` @@ -234,6 +235,7 @@ To run python tests, run cd $CUOPT_HOME/datasets && ./get_test_data.sh cd $CUOPT_HOME && datasets/linear_programming/download_pdlp_test_dataset.sh datasets/mip/download_miplib_test_dataset.sh +datasets/quadratic_programming/download_qplib_test_dataset.sh export RAPIDS_DATASET_ROOT_DIR=$CUOPT_HOME/datasets/ cd $CUOPT_HOME/python pytest -v ${CUOPT_HOME}/python/cuopt/cuopt/tests diff --git a/ci/test_cpp.sh b/ci/test_cpp.sh index f824aabeaf..ddbe025f7b 100755 --- a/ci/test_cpp.sh +++ b/ci/test_cpp.sh @@ -40,6 +40,7 @@ nvidia-smi rapids-logger "Download datasets" ./datasets/linear_programming/download_pdlp_test_dataset.sh ./datasets/mip/download_miplib_test_dataset.sh +./datasets/quadratic_programming/download_qplib_test_dataset.sh RAPIDS_DATASET_ROOT_DIR="$(realpath datasets)" export RAPIDS_DATASET_ROOT_DIR diff --git a/cpp/include/cuopt/mathematical_optimization/constants.h b/cpp/include/cuopt/mathematical_optimization/constants.h index a425f6d70b..16ab6478ac 100644 --- a/cpp/include/cuopt/mathematical_optimization/constants.h +++ b/cpp/include/cuopt/mathematical_optimization/constants.h @@ -141,6 +141,9 @@ #define CUOPT_MIP_HYPER_SUBMIP_ITERATION_LIMIT_RATIO "mip_hyper_submip_iteration_limit_ratio" #define CUOPT_MIP_HYPER_SUBMIP_ENABLE_CPUFJ "mip_hyper_submip_enable_cpufj" +/* @brief QCQP (barrier) scaling hyper-parameters */ +#define CUOPT_QCQP_HYPER_RUIZ_EQUILIBRATION "qcqp_hyper_ruiz_equilibration" + /* @brief MIP determinism mode constants */ #define CUOPT_MODE_OPPORTUNISTIC 0 #define CUOPT_MODE_DETERMINISTIC 1 diff --git a/cpp/include/cuopt/mathematical_optimization/pdlp/solver_settings.hpp b/cpp/include/cuopt/mathematical_optimization/pdlp/solver_settings.hpp index 96f548ec32..b1dc6fcd9d 100644 --- a/cpp/include/cuopt/mathematical_optimization/pdlp/solver_settings.hpp +++ b/cpp/include/cuopt/mathematical_optimization/pdlp/solver_settings.hpp @@ -280,6 +280,10 @@ class pdlp_solver_settings_t { i_t dualize{-1}; i_t ordering{-1}; i_t barrier_dual_initial_point{-1}; + // Ruiz equilibration for QCQP (barrier) scaling: -1 automatic (row/column + // imbalance heuristic), 0 disabled, 1 enabled. Distinct from PDLP's own Ruiz + // scaling in pdlp_hyper_params_t. + i_t qcqp_ruiz_equilibration{-1}; bool eliminate_dense_columns{true}; pdlp_precision_t pdlp_precision{pdlp_precision_t::DefaultPrecision}; bool barrier_iterative_refinement{true}; diff --git a/cpp/src/dual_simplex/scaling.cpp b/cpp/src/dual_simplex/scaling.cpp index 21f42a29dd..4bc5d0e3ff 100644 --- a/cpp/src/dual_simplex/scaling.cpp +++ b/cpp/src/dual_simplex/scaling.cpp @@ -31,14 +31,16 @@ i_t scaling(const lp_problem_t& unscaled, // For SOCP problems, apply Ruiz equilibration: alternating row and column // infinity-norm scaling to bring the constraint matrix close to equilibrium. // This dramatically improves the conditioning of the augmented KKT system. - // Applied only when the constraint matrix has a large row-norm imbalance. + // Applied only when the constraint matrix has a large row-norm or + // column-norm imbalance. if (!unscaled.second_order_cone_dims.empty() || unscaled.Q.n > 0) { // col_scale and row_scale accumulate reciprocal scale factors during Ruiz iterations. std::vector col_scale(n, 1.0); - // Decide whether Ruiz scaling is needed by checking row-norm imbalance. - // If max_row_norm / min_row_norm is small, the matrix is already well-conditioned - // and scaling can hurt (e.g. by amplifying tiny noise coefficients). + // Decide whether Ruiz scaling is needed by checking row- and column-norm + // imbalance. If both max_norm / min_norm ratios are small, the matrix is + // already well-conditioned and scaling can hurt (e.g. by amplifying tiny + // noise coefficients). csr_matrix_t Arow_check(0, 0, 0); scaled.A.to_compressed_row(Arow_check); f_t max_row_norm = 0; @@ -56,12 +58,45 @@ i_t scaling(const lp_problem_t& unscaled, } f_t row_norm_ratio = (min_row_norm > 0) ? max_row_norm / min_row_norm : 1.0; - if (row_norm_ratio < 100.0) { - settings.log.printf("Skipping Ruiz equilibration (row norm ratio %.1f < 100)\n", - row_norm_ratio); + f_t max_col_norm = 0; + f_t min_col_norm = std::numeric_limits::max(); + for (i_t j = 0; j < n; ++j) { + f_t col_norm = 0; + for (i_t p = scaled.A.col_start[j]; p < scaled.A.col_start[j + 1]; ++p) { + f_t a = std::abs(scaled.A.x[p]); + if (a > col_norm) col_norm = a; + } + if (col_norm > 0) { + max_col_norm = std::max(max_col_norm, col_norm); + min_col_norm = std::min(min_col_norm, col_norm); + } + } + f_t col_norm_ratio = (min_col_norm > 0) ? max_col_norm / min_col_norm : 1.0; + + // qcqp_ruiz_equilibration: -1 automatic (imbalance heuristic), 0 force off, 1 force on. + const i_t ruiz_mode = settings.qcqp_ruiz_equilibration; + const bool skip_ruiz = + (ruiz_mode == 0) || (ruiz_mode < 0 && row_norm_ratio < 100.0 && col_norm_ratio < 100.0); + + if (skip_ruiz) { + if (ruiz_mode == 0) { + settings.log.printf("Skipping Ruiz equilibration (qcqp_hyper_ruiz_equilibration = 0)\n"); + } else { + settings.log.printf( + "Skipping Ruiz equilibration (row norm ratio %.1f, column norm ratio %.1f < 100)\n", + row_norm_ratio, + col_norm_ratio); + } column_scaling.assign(n, 1.0); return 0; } + if (ruiz_mode > 0) { + settings.log.printf( + "Applying Ruiz equilibration (qcqp_hyper_ruiz_equilibration = 1, row norm ratio %.1f, " + "column norm ratio %.1f)\n", + row_norm_ratio, + col_norm_ratio); + } // Apply Ruiz equilibration csr_matrix_t Arow(0, 0, 0); diff --git a/cpp/src/dual_simplex/simplex_solver_settings.hpp b/cpp/src/dual_simplex/simplex_solver_settings.hpp index 6a69cdfcd2..8cbe06ea37 100644 --- a/cpp/src/dual_simplex/simplex_solver_settings.hpp +++ b/cpp/src/dual_simplex/simplex_solver_settings.hpp @@ -77,6 +77,7 @@ struct simplex_solver_settings_t { dualize(-1), ordering(-1), barrier_dual_initial_point(-1), + qcqp_ruiz_equilibration(-1), check_Q(false), crossover(false), refactor_frequency(100), @@ -169,6 +170,7 @@ struct simplex_solver_settings_t { i_t ordering; // -1 automatic, 0 to use nested dissection, 1 to use AMD i_t barrier_dual_initial_point; // -1 automatic, 0 to use Lustig, Marsten, and Shanno initial // point, 1 to use initial point form dual least squares problem + i_t qcqp_ruiz_equilibration; // -1 automatic (imbalance heuristic), 0 disabled, 1 enabled bool check_Q; // true to check if Q is positive semidefinite bool crossover; // true to do crossover, false to not i_t refactor_frequency; // number of basis updates before refactorization diff --git a/cpp/src/math_optimization/solver_settings.cu b/cpp/src/math_optimization/solver_settings.cu index 9193112d71..550012807a 100644 --- a/cpp/src/math_optimization/solver_settings.cu +++ b/cpp/src/math_optimization/solver_settings.cu @@ -184,6 +184,8 @@ solver_settings_t::solver_settings_t() : pdlp_settings(), mip_settings // Recursive sub-MIP (RINS) hyper-parameters (hidden from default --help: name contains "hyper_") {CUOPT_MIP_HYPER_SUBMIP_NODE_LIMIT_BASE, &mip_settings.submip_params.node_limit_base, 0, std::numeric_limits::max(), 200, "base node limit for the sub-MIP"}, {CUOPT_MIP_HYPER_SUBMIP_MAX_LEVEL, &mip_settings.submip_params.max_level, 0, std::numeric_limits::max(), 10, "maximum sub-MIP recursion level"}, + // QCQP (barrier) scaling hyper-parameter + {CUOPT_QCQP_HYPER_RUIZ_EQUILIBRATION, &pdlp_settings.qcqp_ruiz_equilibration, -1, 1, -1, "Ruiz equilibration for QCQP barrier scaling: -1 automatic (row/column imbalance heuristic), 0 disabled, 1 enabled"}, }; // Bool parameters diff --git a/cpp/src/pdlp/solve.cu b/cpp/src/pdlp/solve.cu index 5c1a7542d1..948439f66b 100644 --- a/cpp/src/pdlp/solve.cu +++ b/cpp/src/pdlp/solve.cu @@ -510,6 +510,7 @@ std::tuple, simplex::lp_status_t, f_t, f_t, f_t barrier_settings.eliminate_dense_columns = settings.eliminate_dense_columns; barrier_settings.barrier_iterative_refinement = settings.barrier_iterative_refinement; barrier_settings.barrier_step_scale = settings.barrier_step_scale; + barrier_settings.qcqp_ruiz_equilibration = settings.qcqp_ruiz_equilibration; barrier_settings.cudss_deterministic = settings.cudss_deterministic; barrier_settings.barrier_relaxed_feasibility_tol = settings.tolerances.relative_primal_tolerance; barrier_settings.barrier_relaxed_optimality_tol = settings.tolerances.relative_dual_tolerance; diff --git a/cpp/tests/dual_simplex/unit_tests/solve_barrier.cu b/cpp/tests/dual_simplex/unit_tests/solve_barrier.cu index 76ad2a3e58..dfeb79732e 100644 --- a/cpp/tests/dual_simplex/unit_tests/solve_barrier.cu +++ b/cpp/tests/dual_simplex/unit_tests/solve_barrier.cu @@ -217,4 +217,56 @@ TEST(barrier, min_x_squared_free_variable_dual_correction) EXPECT_NEAR(h_z[0], 0.0, tol); } +TEST(barrier, qplib_8515_column_imbalance) +{ + // Regression test for the Ruiz equilibration skip heuristic in scaling(). + // QPLIB_8515 has balanced rows (max-entry ratio 2) but severely imbalanced + // columns: its free variables appear in the constraint matrix only with + // ~1e-8 coefficients against O(1) rows. + // + // Reference objective 319.9999 from https://qplib.zib.de/QPLIB_8515.html. + const raft::handle_t handle{}; + init_handler(&handle); + + auto path = + cuopt::test::get_rapids_dataset_root_dir() + "/quadratic_programming/qplib/QPLIB_8515.lp"; + auto mps_data = io::read_lp(path); + + auto settings = pdlp_solver_settings_t{}; + settings.method = method_t::Barrier; + + auto solution = solve_lp(&handle, mps_data, settings); + + EXPECT_EQ(solution.get_termination_status(), pdlp_termination_status_t::Optimal); + EXPECT_NEAR(solution.get_objective_value(), 319.9999, 1e-2); + + // With equilibration (the default), the well-conditioned barrier converges + // quickly (~14 iterations); un-equilibrated it needs far more. + const int iters = solution.get_additional_termination_information().number_of_steps_taken; + EXPECT_LT(iters, 30); +} + +TEST(barrier, qplib_8515_ruiz_forced_off) +{ + // Forcing Ruiz equilibration off (=0) leaves QPLIB_8515's severe column + // imbalance in place, so the barrier needs far more iterations than the ~14 + // it takes when equilibrated. The blow-up proves scaling() consumes + // qcqp_hyper_ruiz_equilibration. + const raft::handle_t handle{}; + init_handler(&handle); + + auto path = + cuopt::test::get_rapids_dataset_root_dir() + "/quadratic_programming/qplib/QPLIB_8515.lp"; + auto mps_data = io::read_lp(path); + + auto settings = pdlp_solver_settings_t{}; + settings.method = method_t::Barrier; + settings.qcqp_ruiz_equilibration = 0; // force off + + auto solution = solve_lp(&handle, mps_data, settings); + + const int iters = solution.get_additional_termination_information().number_of_steps_taken; + EXPECT_GT(iters, 50); +} + } // namespace cuopt::mathematical_optimization::simplex::test diff --git a/datasets/quadratic_programming/download_qplib_test_dataset.sh b/datasets/quadratic_programming/download_qplib_test_dataset.sh new file mode 100755 index 0000000000..19bc002339 --- /dev/null +++ b/datasets/quadratic_programming/download_qplib_test_dataset.sh @@ -0,0 +1,102 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +INSTANCES=( + "QPLIB_8515" +) + +BASE_URL="https://qplib.zib.de/lp" +BASEDIR=$(dirname "$0") +OUTDIR="${BASEDIR}/qplib" + +mkdir -p "${OUTDIR}" + +################################################################################ +# S3 Download Support +################################################################################ +# Requires explicit CUOPT credentials to avoid using unintended AWS credentials: +# - CUOPT_S3_URI: Base S3 bucket root (e.g., s3://cuopt-datasets/) +# - CUOPT_AWS_ACCESS_KEY_ID: AWS access key +# - CUOPT_AWS_SECRET_ACCESS_KEY: AWS secret key +# - CUOPT_AWS_REGION (optional): AWS region, defaults to us-east-1 + +function try_download_from_s3() { + if [ -z "${CUOPT_S3_URI:-}" ]; then + echo "WARNING: CUOPT_S3_URI not set — S3 dataset download disabled, using HTTP fallback." >&2 + return 1 + fi + + # Require explicit CUOPT credentials to avoid accidentally using generic AWS credentials + if [ -z "${CUOPT_AWS_ACCESS_KEY_ID:-}" ]; then + echo "WARNING: CUOPT_AWS_ACCESS_KEY_ID not set — cannot download datasets from S3." >&2 + return 1 + fi + + if [ -z "${CUOPT_AWS_SECRET_ACCESS_KEY:-}" ]; then + echo "WARNING: CUOPT_AWS_SECRET_ACCESS_KEY not set — cannot download datasets from S3." >&2 + return 1 + fi + + if ! command -v aws &> /dev/null; then + echo "WARNING: AWS CLI not found — cannot download datasets from S3." >&2 + return 1 + fi + + # Append ci_datasets/quadratic_programming/qplib subdirectory to base S3 URI + local s3_uri="${CUOPT_S3_URI}ci_datasets/quadratic_programming/qplib/" + echo "Downloading QPLIB datasets from S3..." + + # Use CUOPT-specific credentials only + local region="${CUOPT_AWS_REGION:-us-east-1}" + + # Export credentials for AWS CLI + export AWS_ACCESS_KEY_ID="$CUOPT_AWS_ACCESS_KEY_ID" + export AWS_SECRET_ACCESS_KEY="$CUOPT_AWS_SECRET_ACCESS_KEY" + # Unset session token to avoid mixing credentials + unset AWS_SESSION_TOKEN + export AWS_DEFAULT_REGION="$region" + + # Test AWS credentials + if ! aws sts get-caller-identity &> /dev/null 2>&1; then + echo "AWS credentials invalid, skipping S3 download..." + return 1 + fi + + local success=true + local total=${#INSTANCES[@]} + local count=0 + for instance in "${INSTANCES[@]}"; do + count=$((count + 1)) + if ! aws s3 cp "${s3_uri}${instance}.lp" "${OUTDIR}/${instance}.lp" --only-show-errors; then + success=false + fi + printf "\rProgress: %d/%d" "$count" "$total" + done + echo "" + + if $success; then + echo "✓ Downloaded QPLIB datasets from S3" + return 0 + else + echo "S3 download failed, falling back to HTTP..." + return 1 + fi +} + +# Try S3 first +if try_download_from_s3; then + exit 0 +fi + +# HTTP fallback +echo "Downloading QPLIB datasets from HTTP..." +for INSTANCE in "${INSTANCES[@]}"; do + URL="${BASE_URL}/${INSTANCE}.lp" + OUTFILE="${OUTDIR}/${INSTANCE}.lp" + + wget -4 --tries=3 --continue --progress=dot:mega --retry-connrefused "${URL}" -O "${OUTFILE}" || { + echo "Failed to download: ${URL}" + continue + } +done