Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
datasets/quadratic_programming/qplib/
!datasets/quadratic_programming/qplib/

Should this be

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is correct because we want to ignore the downloaded instances, while not ignoring the checked-in instances.

!datasets/solomon/
!datasets/solomon/In/
!datasets/solomon/In/r107.txt
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions ci/test_cpp.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions cpp/include/cuopt/mathematical_optimization/constants.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
49 changes: 42 additions & 7 deletions cpp/src/dual_simplex/scaling.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,16 @@ i_t scaling(const lp_problem_t<i_t, f_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<f_t> 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<i_t, f_t> Arow_check(0, 0, 0);
scaled.A.to_compressed_row(Arow_check);
f_t max_row_norm = 0;
Expand All @@ -56,12 +58,45 @@ i_t scaling(const lp_problem_t<i_t, f_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<f_t>::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<i_t, f_t> Arow(0, 0, 0);
Expand Down
2 changes: 2 additions & 0 deletions cpp/src/dual_simplex/simplex_solver_settings.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions cpp/src/math_optimization/solver_settings.cu
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ solver_settings_t<i_t, f_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<i_t>::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<i_t>::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
Expand Down
1 change: 1 addition & 0 deletions cpp/src/pdlp/solve.cu
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ std::tuple<simplex::lp_solution_t<i_t, f_t>, 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;
Expand Down
52 changes: 52 additions & 0 deletions cpp/tests/dual_simplex/unit_tests/solve_barrier.cu
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, double>(path);

auto settings = pdlp_solver_settings_t<int, double>{};
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<int, double>(path);

auto settings = pdlp_solver_settings_t<int, double>{};
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);
Comment on lines +266 to +269

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Require a successful solve before asserting the iteration blow-up.

This test currently passes for any result taking more than 50 steps, including a non-optimal or iteration-limited termination. Assert optimal termination and the 319.9999 reference objective before checking the iteration count.

As per path instructions, regression tests must validate numerical correctness, not only iteration behavior.

Proposed fix
   auto solution = solve_lp(&handle, mps_data, settings);
 
+  ASSERT_EQ(solution.get_termination_status(), pdlp_termination_status_t::Optimal);
+  EXPECT_NEAR(solution.get_objective_value(), 319.9999, 1e-2);
   const int iters = solution.get_additional_termination_information().number_of_steps_taken;
   EXPECT_GT(iters, 50);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
auto solution = solve_lp(&handle, mps_data, settings);
const int iters = solution.get_additional_termination_information().number_of_steps_taken;
EXPECT_GT(iters, 50);
auto solution = solve_lp(&handle, mps_data, settings);
ASSERT_EQ(solution.get_termination_status(), pdlp_termination_status_t::Optimal);
EXPECT_NEAR(solution.get_objective_value(), 319.9999, 1e-2);
const int iters = solution.get_additional_termination_information().number_of_steps_taken;
EXPECT_GT(iters, 50);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cpp/tests/dual_simplex/unit_tests/solve_barrier.cu` around lines 266 - 269,
Update the test around solve_lp and the existing solution object to first assert
optimal termination and verify the objective matches the 319.9999 reference
value, then retain the iteration-count assertion. Ensure the numerical
correctness checks occur before validating that more than 50 steps were taken.

Source: Path instructions

}

} // namespace cuopt::mathematical_optimization::simplex::test
102 changes: 102 additions & 0 deletions datasets/quadratic_programming/download_qplib_test_dataset.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/bin/bash
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

INSTANCES=(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we only downloading this specific example?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's the first instance from qplib that we're using in tests.

"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
Loading