From 748f703d26ffe6aed789ff05978033c79f9e7ab8 Mon Sep 17 00:00:00 2001
From: Alice Boucher <160623740+aliceb-nv@users.noreply.github.com>
Date: Fri, 30 Jan 2026 16:12:05 +0100
Subject: [PATCH] C api additions (#721)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR extends the C API to add support for saving the problem to a file, adding initial primal and dual solutions for LP, and initial solutions for MIP. This functionality exists in the underlying C++ layer, and is a feature of most LP/MIP solver APIs available.
Kept as a draft for now, as it is intended as a proposal to iterate on.
closes #719
closes #720
## Summary by CodeRabbit
* **New Features**
* Write optimization problems to MPS files.
* Set initial primal and dual solutions for LP solves and specify initial MIP starts.
* Check semantic equivalence between optimization problems.
* **Enhancements**
* Improved MPS writer: explicit handling of orphan variables and refined bounds/objective emissions.
* **Tests**
* Added MPS write/read roundtrip tests to validate I/O and equivalence.
✏️ Tip: You can customize this high-level summary in your review settings.
Authors:
- Alice Boucher (https://github.com/aliceb-nv)
- James Lamb (https://github.com/jameslamb)
Approvers:
- Ramakrishnap (https://github.com/rgsl888prabhu)
- Rajesh Gandham (https://github.com/rg20)
URL: https://github.com/NVIDIA/cuopt/pull/721
---
.../cuopt/linear_programming/constants.h | 3 +
.../cuopt/linear_programming/cuopt_c.h | 70 ++++-
.../optimization_problem.hpp | 16 +-
cpp/libmps_parser/src/mps_writer.cpp | 56 +++-
cpp/src/linear_programming/cuopt_c.cpp | 102 +++++--
.../linear_programming/cuopt_c_internal.hpp | 46 +++
.../optimization_problem.cu | 261 ++++++++++++++++++
cpp/tests/linear_programming/CMakeLists.txt | 6 +-
.../c_api_tests/c_api_test.c | 81 ++++++
.../c_api_tests/c_api_tests.cpp | 95 ++++++-
.../c_api_tests/c_api_tests.h | 4 +-
11 files changed, 698 insertions(+), 42 deletions(-)
create mode 100644 cpp/src/linear_programming/cuopt_c_internal.hpp
diff --git a/cpp/include/cuopt/linear_programming/constants.h b/cpp/include/cuopt/linear_programming/constants.h
index c1d62342f..4ee022484 100644
--- a/cpp/include/cuopt/linear_programming/constants.h
+++ b/cpp/include/cuopt/linear_programming/constants.h
@@ -108,6 +108,9 @@
#define CUOPT_METHOD_DUAL_SIMPLEX 2
#define CUOPT_METHOD_BARRIER 3
+/* @brief File format constants for problem I/O */
+#define CUOPT_FILE_FORMAT_MPS 0
+
/* @brief Status codes constants */
#define CUOPT_SUCCESS 0
#define CUOPT_INVALID_ARGUMENT 1
diff --git a/cpp/include/cuopt/linear_programming/cuopt_c.h b/cpp/include/cuopt/linear_programming/cuopt_c.h
index 06af2ae86..c26d9905a 100644
--- a/cpp/include/cuopt/linear_programming/cuopt_c.h
+++ b/cpp/include/cuopt/linear_programming/cuopt_c.h
@@ -1,6 +1,6 @@
/* clang-format off */
/*
- * SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+ * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
/* clang-format on */
@@ -111,6 +111,20 @@ cuopt_int_t cuOptGetVersion(cuopt_int_t* version_major,
*/
cuopt_int_t cuOptReadProblem(const char* filename, cuOptOptimizationProblem* problem_ptr);
+/**
+ * @brief Write an optimization problem to a file.
+ *
+ * @param[in] problem - The optimization problem to write.
+ * @param[in] filename - The path to the output file.
+ * @param[in] format - The file format to use. Currently only CUOPT_FILE_FORMAT_MPS is supported.
+ *
+ * @return A status code indicating success or failure. Returns CUOPT_INVALID_ARGUMENT
+ * if an unsupported format is specified.
+ */
+cuopt_int_t cuOptWriteProblem(cuOptOptimizationProblem problem,
+ const char* filename,
+ cuopt_int_t format);
+
/** @brief Create an optimization problem of the form
*
* @verbatim
@@ -681,6 +695,60 @@ cuopt_int_t cuOptGetFloatParameter(cuOptSolverSettings settings,
const char* parameter_name,
cuopt_float_t* parameter_value);
+/**
+ * @brief Set the initial primal solution for an LP solve.
+ *
+ * @note This function is only supported for PDLP.
+ *
+ * @param[in] settings - The solver settings object.
+ * @param[in] primal_solution - A pointer to an array of type cuopt_float_t
+ * of size num_variables containing the initial primal values.
+ * @param[in] num_variables - The number of variables (size of the primal_solution array).
+ *
+ * @note All pointer arguments (primal_solution) refer to host memory.
+ * @return A status code indicating success or failure.
+ */
+cuopt_int_t cuOptSetInitialPrimalSolution(cuOptSolverSettings settings,
+ const cuopt_float_t* primal_solution,
+ cuopt_int_t num_variables);
+
+/**
+ * @brief Set the initial dual solution for an LP solve.
+ *
+ * @note This function is only supported for PDLP.
+ *
+ * @param[in] settings - The solver settings object.
+ * @param[in] dual_solution - A pointer to an array of type cuopt_float_t
+ * of size num_constraints containing the initial dual values.
+ * @param[in] num_constraints - The number of constraints (size of the dual_solution array).
+ *
+ * @note All pointer arguments (dual_solution) refer to host memory.
+ * @return A status code indicating success or failure.
+ */
+cuopt_int_t cuOptSetInitialDualSolution(cuOptSolverSettings settings,
+ const cuopt_float_t* dual_solution,
+ cuopt_int_t num_constraints);
+
+/**
+ * @brief Add an initial solution (MIP start) for MIP solving.
+ *
+ * This function can be called multiple times to add multiple MIP starts.
+ * The solver will use these as starting points for the MIP search.
+ *
+ * @param[in] settings - The solver settings object.
+ * @param[in] solution - A pointer to an array of type cuopt_float_t
+ * of size num_variables containing the solution values.
+ * @param[in] num_variables - The number of variables (size of the solution array).
+ *
+ * @attention Currently unsupported with presolve on.
+ *
+ * @note All pointer arguments (solution) refer to host memory.
+ * @return A status code indicating success or failure.
+ */
+cuopt_int_t cuOptAddMIPStart(cuOptSolverSettings settings,
+ const cuopt_float_t* solution,
+ cuopt_int_t num_variables);
+
/** @brief Check if an optimization problem is a mixed integer programming problem.
*
* @param[in] problem - The optimization problem.
diff --git a/cpp/include/cuopt/linear_programming/optimization_problem.hpp b/cpp/include/cuopt/linear_programming/optimization_problem.hpp
index dd912ec94..d0731f3aa 100644
--- a/cpp/include/cuopt/linear_programming/optimization_problem.hpp
+++ b/cpp/include/cuopt/linear_programming/optimization_problem.hpp
@@ -1,6 +1,6 @@
/* clang-format off */
/*
- * SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+ * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
/* clang-format on */
@@ -105,6 +105,20 @@ class optimization_problem_t {
optimization_problem_t(raft::handle_t const* handle_ptr);
optimization_problem_t(const optimization_problem_t& other);
+ /**
+ * @brief Check if this optimization problem is equivalent to another.
+ *
+ * Two problems are considered equivalent if they represent the same mathematical
+ * optimization problem, potentially with variables and constraints in a different order.
+ * The mapping between problems is determined by matching variable names and row names.
+ * Essentially checks for graph isomorphism given label mappings.
+ *
+ * @param other The other optimization problem to compare against.
+ * @return true if the problems are equivalent (up to permutation of variables/constraints),
+ * false otherwise.
+ */
+ bool is_equivalent(const optimization_problem_t& other) const;
+
std::vector mip_callbacks_;
/**
diff --git a/cpp/libmps_parser/src/mps_writer.cpp b/cpp/libmps_parser/src/mps_writer.cpp
index 5ec0052ce..4c562ec24 100644
--- a/cpp/libmps_parser/src/mps_writer.cpp
+++ b/cpp/libmps_parser/src/mps_writer.cpp
@@ -1,6 +1,6 @@
/* clang-format off */
/*
- * SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+ * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
/* clang-format on */
@@ -138,6 +138,7 @@ void mps_writer_t::write(const std::string& mps_file_path)
// Keep a single integer section marker by going over constraints twice and writing out
// integral/nonintegral nonzeros ordered map
+ std::vector var_in_constraint(n_variables, false);
std::map>> integral_col_nnzs;
std::map>> continuous_col_nnzs;
for (size_t row_id = 0; row_id < (size_t)n_constraints; row_id++) {
@@ -150,12 +151,37 @@ void mps_writer_t::write(const std::string& mps_file_path)
} else {
continuous_col_nnzs[var].emplace_back(row_id, constraint_matrix_values[k]);
}
+ var_in_constraint[var] = true;
+ }
+ }
+
+ // Record and explicitely declared variables not contained in any constraint.
+ std::vector orphan_continuous_vars;
+ std::vector orphan_integer_vars;
+ for (i_t var = 0; var < n_variables; ++var) {
+ if (!var_in_constraint[var]) {
+ if (variable_types[var] == 'I') {
+ orphan_integer_vars.push_back(var);
+ } else {
+ orphan_continuous_vars.push_back(var);
+ }
}
}
for (size_t is_integral = 0; is_integral < 2; is_integral++) {
- auto& col_map = is_integral ? integral_col_nnzs : continuous_col_nnzs;
+ auto& col_map = is_integral ? integral_col_nnzs : continuous_col_nnzs;
+ auto& orphan_vars = is_integral ? orphan_integer_vars : orphan_continuous_vars;
if (is_integral) mps_file << " MARK0001 'MARKER' 'INTORG'\n";
+ for (auto& var_id : orphan_vars) {
+ std::string col_name = var_id < problem_.get_variable_names().size()
+ ? problem_.get_variable_names()[var_id]
+ : "C" + std::to_string(var_id);
+ // Write that column even if it is orphan as has a zero objective coefficient.
+ // Some tools require variables to be declared in "COLUMNS" before any "BOUNDS" statements.
+ mps_file << " " << col_name << " "
+ << (problem_.get_objective_name().empty() ? "OBJ" : problem_.get_objective_name())
+ << " " << objective_coefficients[var_id] << "\n";
+ }
for (auto& [var_id, nnzs] : col_map) {
std::string col_name = var_id < problem_.get_variable_names().size()
? problem_.get_variable_names()[var_id]
@@ -222,24 +248,34 @@ void mps_writer_t::write(const std::string& mps_file_path)
// BOUNDS section
mps_file << "BOUNDS\n";
for (size_t j = 0; j < (size_t)n_variables; j++) {
- std::string col_name = j < problem_.get_variable_names().size()
- ? problem_.get_variable_names()[j]
- : "C" + std::to_string(j);
+ std::string col_name = j < problem_.get_variable_names().size()
+ ? problem_.get_variable_names()[j]
+ : "C" + std::to_string(j);
+ std::string lower_bound_str = variable_types[j] == 'I' ? "LI" : "LO";
+ std::string upper_bound_str = variable_types[j] == 'I' ? "UI" : "UP";
if (variable_lower_bounds[j] == -std::numeric_limits::infinity() &&
variable_upper_bounds[j] == std::numeric_limits::infinity()) {
mps_file << " FR BOUND1 " << col_name << "\n";
+ }
+ // Ambiguity exists in the spec about the case where upper_bound == 0 and lower_bound == 0, and
+ // only UP is specified. Handle fixed variables explicitely to avoid this pitfall.
+ else if (variable_lower_bounds[j] == variable_upper_bounds[j]) {
+ mps_file << " FX BOUND1 " << col_name << " " << variable_lower_bounds[j] << "\n";
} else {
- if (variable_lower_bounds[j] != 0.0 || objective_coefficients[j] == 0.0 ||
- variable_types[j] != 'C') {
+ if (variable_lower_bounds[j] != 0.0) {
if (variable_lower_bounds[j] == -std::numeric_limits::infinity()) {
mps_file << " MI BOUND1 " << col_name << "\n";
} else {
- mps_file << " LO BOUND1 " << col_name << " " << variable_lower_bounds[j] << "\n";
+ mps_file << " " << lower_bound_str << " BOUND1 " << col_name << " "
+ << variable_lower_bounds[j] << "\n";
}
}
- if (variable_upper_bounds[j] != std::numeric_limits::infinity()) {
- mps_file << " UP BOUND1 " << col_name << " " << variable_upper_bounds[j] << "\n";
+ // Integer variables get different default bounds compared to continuous variables
+ if (variable_upper_bounds[j] != std::numeric_limits::infinity() ||
+ variable_types[j] == 'I') {
+ mps_file << " " << upper_bound_str << " BOUND1 " << col_name << " "
+ << variable_upper_bounds[j] << "\n";
}
}
}
diff --git a/cpp/src/linear_programming/cuopt_c.cpp b/cpp/src/linear_programming/cuopt_c.cpp
index 0772dd14b..794c7f4f7 100644
--- a/cpp/src/linear_programming/cuopt_c.cpp
+++ b/cpp/src/linear_programming/cuopt_c.cpp
@@ -1,6 +1,6 @@
/* clang-format off */
/*
- * SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+ * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
/* clang-format on */
@@ -11,6 +11,7 @@
#include
#include
#include
+#include
#include
#include
@@ -24,31 +25,6 @@
using namespace cuopt::mps_parser;
using namespace cuopt::linear_programming;
-struct problem_and_stream_view_t {
- problem_and_stream_view_t()
- : op_problem(nullptr), stream_view(rmm::cuda_stream_per_thread), handle(stream_view)
- {
- }
- raft::handle_t* get_handle_ptr() { return &handle; }
- cuopt::linear_programming::optimization_problem_t* op_problem;
- rmm::cuda_stream_view stream_view;
- raft::handle_t handle;
-};
-
-struct solution_and_stream_view_t {
- solution_and_stream_view_t(bool solution_for_mip, rmm::cuda_stream_view stream_view)
- : is_mip(solution_for_mip),
- mip_solution_ptr(nullptr),
- lp_solution_ptr(nullptr),
- stream_view(stream_view)
- {
- }
- bool is_mip;
- mip_solution_t* mip_solution_ptr;
- optimization_problem_solution_t* lp_solution_ptr;
- rmm::cuda_stream_view stream_view;
-};
-
int8_t cuOptGetFloatSize() { return sizeof(cuopt_float_t); }
int8_t cuOptGetIntSize() { return sizeof(cuopt_int_t); }
@@ -92,6 +68,26 @@ cuopt_int_t cuOptReadProblem(const char* filename, cuOptOptimizationProblem* pro
return CUOPT_SUCCESS;
}
+cuopt_int_t cuOptWriteProblem(cuOptOptimizationProblem problem,
+ const char* filename,
+ cuopt_int_t format)
+{
+ if (problem == nullptr) { return CUOPT_INVALID_ARGUMENT; }
+ if (filename == nullptr) { return CUOPT_INVALID_ARGUMENT; }
+ if (strlen(filename) == 0) { return CUOPT_INVALID_ARGUMENT; }
+ if (format != CUOPT_FILE_FORMAT_MPS) { return CUOPT_INVALID_ARGUMENT; }
+
+ problem_and_stream_view_t* problem_and_stream_view =
+ static_cast(problem);
+ try {
+ problem_and_stream_view->op_problem->write_to_mps(std::string(filename));
+ } catch (const std::exception& e) {
+ CUOPT_LOG_INFO("Error writing MPS file: %s", e.what());
+ return CUOPT_MPS_FILE_ERROR;
+ }
+ return CUOPT_SUCCESS;
+}
+
cuopt_int_t cuOptCreateProblem(cuopt_int_t num_constraints,
cuopt_int_t num_variables,
cuopt_int_t objective_sense,
@@ -706,6 +702,60 @@ cuopt_int_t cuOptGetFloatParameter(cuOptSolverSettings settings,
return CUOPT_SUCCESS;
}
+cuopt_int_t cuOptSetInitialPrimalSolution(cuOptSolverSettings settings,
+ const cuopt_float_t* primal_solution,
+ cuopt_int_t num_variables)
+{
+ if (settings == nullptr) { return CUOPT_INVALID_ARGUMENT; }
+ if (primal_solution == nullptr) { return CUOPT_INVALID_ARGUMENT; }
+ if (num_variables <= 0) { return CUOPT_INVALID_ARGUMENT; }
+
+ solver_settings_t* solver_settings =
+ static_cast*>(settings);
+ try {
+ solver_settings->set_initial_pdlp_primal_solution(primal_solution, num_variables);
+ } catch (const std::exception& e) {
+ return CUOPT_INVALID_ARGUMENT;
+ }
+ return CUOPT_SUCCESS;
+}
+
+cuopt_int_t cuOptSetInitialDualSolution(cuOptSolverSettings settings,
+ const cuopt_float_t* dual_solution,
+ cuopt_int_t num_constraints)
+{
+ if (settings == nullptr) { return CUOPT_INVALID_ARGUMENT; }
+ if (dual_solution == nullptr) { return CUOPT_INVALID_ARGUMENT; }
+ if (num_constraints <= 0) { return CUOPT_INVALID_ARGUMENT; }
+
+ solver_settings_t* solver_settings =
+ static_cast*>(settings);
+ try {
+ solver_settings->set_initial_pdlp_dual_solution(dual_solution, num_constraints);
+ } catch (const std::exception& e) {
+ return CUOPT_INVALID_ARGUMENT;
+ }
+ return CUOPT_SUCCESS;
+}
+
+cuopt_int_t cuOptAddMIPStart(cuOptSolverSettings settings,
+ const cuopt_float_t* solution,
+ cuopt_int_t num_variables)
+{
+ if (settings == nullptr) { return CUOPT_INVALID_ARGUMENT; }
+ if (solution == nullptr) { return CUOPT_INVALID_ARGUMENT; }
+ if (num_variables <= 0) { return CUOPT_INVALID_ARGUMENT; }
+
+ solver_settings_t* solver_settings =
+ static_cast*>(settings);
+ try {
+ solver_settings->get_mip_settings().add_initial_solution(solution, num_variables);
+ } catch (const std::exception& e) {
+ return CUOPT_INVALID_ARGUMENT;
+ }
+ return CUOPT_SUCCESS;
+}
+
cuopt_int_t cuOptIsMIP(cuOptOptimizationProblem problem, cuopt_int_t* is_mip_ptr)
{
if (problem == nullptr) { return CUOPT_INVALID_ARGUMENT; }
diff --git a/cpp/src/linear_programming/cuopt_c_internal.hpp b/cpp/src/linear_programming/cuopt_c_internal.hpp
new file mode 100644
index 000000000..de9d6e559
--- /dev/null
+++ b/cpp/src/linear_programming/cuopt_c_internal.hpp
@@ -0,0 +1,46 @@
+/* clang-format off */
+/*
+ * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/* clang-format on */
+
+#pragma once
+
+#include
+#include
+#include
+#include
+
+#include
+
+#include
+
+namespace cuopt::linear_programming {
+
+struct problem_and_stream_view_t {
+ problem_and_stream_view_t()
+ : op_problem(nullptr), stream_view(rmm::cuda_stream_per_thread), handle(stream_view)
+ {
+ }
+ raft::handle_t* get_handle_ptr() { return &handle; }
+ optimization_problem_t* op_problem;
+ rmm::cuda_stream_view stream_view;
+ raft::handle_t handle;
+};
+
+struct solution_and_stream_view_t {
+ solution_and_stream_view_t(bool solution_for_mip, rmm::cuda_stream_view stream_view)
+ : is_mip(solution_for_mip),
+ mip_solution_ptr(nullptr),
+ lp_solution_ptr(nullptr),
+ stream_view(stream_view)
+ {
+ }
+ bool is_mip;
+ mip_solution_t* mip_solution_ptr;
+ optimization_problem_solution_t* lp_solution_ptr;
+ rmm::cuda_stream_view stream_view;
+};
+
+} // namespace cuopt::linear_programming
diff --git a/cpp/src/linear_programming/optimization_problem.cu b/cpp/src/linear_programming/optimization_problem.cu
index f2176bf15..ba57141e9 100644
--- a/cpp/src/linear_programming/optimization_problem.cu
+++ b/cpp/src/linear_programming/optimization_problem.cu
@@ -17,11 +17,21 @@
#include
#include
+#include
+
+#include
#include
+#include
+#include
+#include
+#include
+#include
+#include
#include
#include
+#include
namespace cuopt::linear_programming {
@@ -78,6 +88,257 @@ optimization_problem_t::optimization_problem_t(
{
}
+/**
+ * @brief Compare two CSR matrices for equivalence under row and column permutations.
+ *
+ * @param this_offsets Row offsets of first matrix
+ * @param this_indices Column indices of first matrix
+ * @param this_values Values of first matrix
+ * @param other_offsets Row offsets of second matrix
+ * @param other_indices Column indices of second matrix
+ * @param other_values Values of second matrix
+ * @param d_row_perm_inv Inverse row permutation (maps other's row indices to this's)
+ * @param d_col_perm_inv Inverse column permutation (maps other's col indices to this's)
+ * @param n_cols Number of columns (used for sort key computation)
+ * @param stream CUDA stream
+ * @return true if matrices are equivalent under the given permutations
+ */
+template
+static bool csr_matrices_equivalent_with_permutation(const rmm::device_uvector& this_offsets,
+ const rmm::device_uvector& this_indices,
+ const rmm::device_uvector& this_values,
+ const rmm::device_uvector& other_offsets,
+ const rmm::device_uvector& other_indices,
+ const rmm::device_uvector& other_values,
+ const rmm::device_uvector& d_row_perm_inv,
+ const rmm::device_uvector& d_col_perm_inv,
+ i_t n_cols,
+ rmm::cuda_stream_view stream)
+{
+ const i_t nnz = static_cast(this_values.size());
+ if (nnz != static_cast(other_values.size())) { return false; }
+ if (nnz == 0) { return true; }
+
+ auto policy = rmm::exec_policy(stream);
+
+ // Expand CSR row offsets to row indices for 'this'
+ rmm::device_uvector this_rows(nnz, stream);
+ rmm::device_uvector this_cols(nnz, stream);
+ rmm::device_uvector this_vals(nnz, stream);
+
+ // upper_bound returns 1-based indices; convert to 0-based
+ thrust::upper_bound(policy,
+ this_offsets.begin(),
+ this_offsets.end(),
+ thrust::make_counting_iterator(0),
+ thrust::make_counting_iterator(nnz),
+ this_rows.begin());
+ thrust::transform(
+ policy, this_rows.begin(), this_rows.end(), this_rows.begin(), [] __device__(i_t r) {
+ return r - 1;
+ });
+
+ thrust::copy(policy, this_indices.begin(), this_indices.end(), this_cols.begin());
+ thrust::copy(policy, this_values.begin(), this_values.end(), this_vals.begin());
+
+ // For 'other': expand and apply inverse permutations to map to 'this' coordinate system
+ rmm::device_uvector other_rows(nnz, stream);
+ rmm::device_uvector other_cols(nnz, stream);
+ rmm::device_uvector other_vals(nnz, stream);
+
+ thrust::upper_bound(policy,
+ other_offsets.begin(),
+ other_offsets.end(),
+ thrust::make_counting_iterator(0),
+ thrust::make_counting_iterator(nnz),
+ other_rows.begin());
+ thrust::transform(
+ policy, other_rows.begin(), other_rows.end(), other_rows.begin(), [] __device__(i_t r) {
+ return r - 1;
+ });
+
+ thrust::gather(
+ policy, other_rows.begin(), other_rows.end(), d_row_perm_inv.begin(), other_rows.begin());
+
+ thrust::gather(
+ policy, other_indices.begin(), other_indices.end(), d_col_perm_inv.begin(), other_cols.begin());
+
+ thrust::copy(policy, other_values.begin(), other_values.end(), other_vals.begin());
+
+ // Create sort keys: row * n_cols + col (to sort by row then column)
+ rmm::device_uvector this_keys(nnz, stream);
+ rmm::device_uvector other_keys(nnz, stream);
+
+ const int64_t n_cols_64 = n_cols;
+ thrust::transform(policy,
+ thrust::make_zip_iterator(this_rows.begin(), this_cols.begin()),
+ thrust::make_zip_iterator(this_rows.end(), this_cols.end()),
+ this_keys.begin(),
+ [n_cols_64] __device__(thrust::tuple rc) {
+ return static_cast(thrust::get<0>(rc)) * n_cols_64 +
+ static_cast(thrust::get<1>(rc));
+ });
+
+ thrust::transform(policy,
+ thrust::make_zip_iterator(other_rows.begin(), other_cols.begin()),
+ thrust::make_zip_iterator(other_rows.end(), other_cols.end()),
+ other_keys.begin(),
+ [n_cols_64] __device__(thrust::tuple rc) {
+ return static_cast(thrust::get<0>(rc)) * n_cols_64 +
+ static_cast(thrust::get<1>(rc));
+ });
+
+ thrust::sort_by_key(policy, this_keys.begin(), this_keys.end(), this_vals.begin());
+ thrust::sort_by_key(policy, other_keys.begin(), other_keys.end(), other_vals.begin());
+
+ if (!thrust::equal(policy, this_keys.begin(), this_keys.end(), other_keys.begin())) {
+ return false;
+ }
+
+ if (!thrust::equal(policy, this_vals.begin(), this_vals.end(), other_vals.begin())) {
+ return false;
+ }
+
+ return true;
+}
+
+template
+bool optimization_problem_t::is_equivalent(
+ const optimization_problem_t& other) const
+{
+ if (maximize_ != other.maximize_) { return false; }
+ if (n_vars_ != other.n_vars_) { return false; }
+ if (n_constraints_ != other.n_constraints_) { return false; }
+ if (objective_scaling_factor_ != other.objective_scaling_factor_) { return false; }
+ if (objective_offset_ != other.objective_offset_) { return false; }
+ if (problem_category_ != other.problem_category_) { return false; }
+ if (A_.size() != other.A_.size()) { return false; }
+
+ if (var_names_.empty() || other.var_names_.empty()) { return false; }
+ if (row_names_.empty() || other.row_names_.empty()) { return false; }
+
+ // Build variable permutation: var_perm[i] = index j in other where var_names_[i] ==
+ // other.var_names_[j]
+ std::unordered_map other_var_idx;
+ for (size_t j = 0; j < other.var_names_.size(); ++j) {
+ other_var_idx[other.var_names_[j]] = static_cast(j);
+ }
+ std::vector var_perm(n_vars_);
+ for (i_t i = 0; i < n_vars_; ++i) {
+ auto it = other_var_idx.find(var_names_[i]);
+ if (it == other_var_idx.end()) { return false; }
+ var_perm[i] = it->second;
+ }
+
+ // Build row permutation: row_perm[i] = index j in other where row_names_[i] ==
+ // other.row_names_[j]
+ std::unordered_map other_row_idx;
+ for (size_t j = 0; j < other.row_names_.size(); ++j) {
+ other_row_idx[other.row_names_[j]] = static_cast(j);
+ }
+ std::vector row_perm(n_constraints_);
+ for (i_t i = 0; i < n_constraints_; ++i) {
+ auto it = other_row_idx.find(row_names_[i]);
+ if (it == other_row_idx.end()) { return false; }
+ row_perm[i] = it->second;
+ }
+
+ // Upload permutations to GPU
+ rmm::device_uvector d_var_perm(n_vars_, stream_view_);
+ rmm::device_uvector d_row_perm(n_constraints_, stream_view_);
+ raft::copy(d_var_perm.data(), var_perm.data(), n_vars_, stream_view_);
+ raft::copy(d_row_perm.data(), row_perm.data(), n_constraints_, stream_view_);
+
+ auto policy = rmm::exec_policy(stream_view_);
+
+ auto permuted_eq = [&](auto this_begin, auto this_end, auto other_begin, auto perm_begin) {
+ auto other_perm = thrust::make_permutation_iterator(other_begin, perm_begin);
+ return thrust::equal(policy, this_begin, this_end, other_perm);
+ };
+
+ // Compare variable-indexed arrays
+ if (c_.size() != other.c_.size()) { return false; }
+ if (!permuted_eq(c_.begin(), c_.end(), other.c_.begin(), d_var_perm.begin())) { return false; }
+ if (variable_lower_bounds_.size() != other.variable_lower_bounds_.size()) { return false; }
+ if (!permuted_eq(variable_lower_bounds_.begin(),
+ variable_lower_bounds_.end(),
+ other.variable_lower_bounds_.begin(),
+ d_var_perm.begin())) {
+ return false;
+ }
+ if (variable_upper_bounds_.size() != other.variable_upper_bounds_.size()) { return false; }
+ if (!permuted_eq(variable_upper_bounds_.begin(),
+ variable_upper_bounds_.end(),
+ other.variable_upper_bounds_.begin(),
+ d_var_perm.begin())) {
+ return false;
+ }
+ if (variable_types_.size() != other.variable_types_.size()) { return false; }
+ if (!permuted_eq(variable_types_.begin(),
+ variable_types_.end(),
+ other.variable_types_.begin(),
+ d_var_perm.begin())) {
+ return false;
+ }
+
+ // Compare constraint-indexed arrays
+ if (b_.size() != other.b_.size()) { return false; }
+ if (!permuted_eq(b_.begin(), b_.end(), other.b_.begin(), d_row_perm.begin())) { return false; }
+ if (constraint_lower_bounds_.size() != other.constraint_lower_bounds_.size()) { return false; }
+ if (!permuted_eq(constraint_lower_bounds_.begin(),
+ constraint_lower_bounds_.end(),
+ other.constraint_lower_bounds_.begin(),
+ d_row_perm.begin())) {
+ return false;
+ }
+ if (constraint_upper_bounds_.size() != other.constraint_upper_bounds_.size()) { return false; }
+ if (!permuted_eq(constraint_upper_bounds_.begin(),
+ constraint_upper_bounds_.end(),
+ other.constraint_upper_bounds_.begin(),
+ d_row_perm.begin())) {
+ return false;
+ }
+ if (row_types_.size() != other.row_types_.size()) { return false; }
+ if (!permuted_eq(
+ row_types_.begin(), row_types_.end(), other.row_types_.begin(), d_row_perm.begin())) {
+ return false;
+ }
+
+ // Build inverse permutations on CPU (needed for CSR comparisons)
+ std::vector var_perm_inv(n_vars_);
+ for (i_t i = 0; i < n_vars_; ++i) {
+ var_perm_inv[var_perm[i]] = i;
+ }
+ std::vector row_perm_inv(n_constraints_);
+ for (i_t i = 0; i < n_constraints_; ++i) {
+ row_perm_inv[row_perm[i]] = i;
+ }
+
+ // Upload inverse permutations to GPU
+ rmm::device_uvector d_var_perm_inv(n_vars_, stream_view_);
+ rmm::device_uvector d_row_perm_inv(n_constraints_, stream_view_);
+ raft::copy(d_var_perm_inv.data(), var_perm_inv.data(), n_vars_, stream_view_);
+ raft::copy(d_row_perm_inv.data(), row_perm_inv.data(), n_constraints_, stream_view_);
+
+ // Constraint matrix (A) comparison with row and column permutations
+ if (!csr_matrices_equivalent_with_permutation(A_offsets_,
+ A_indices_,
+ A_,
+ other.A_offsets_,
+ other.A_indices_,
+ other.A_,
+ d_row_perm_inv,
+ d_var_perm_inv,
+ n_vars_,
+ stream_view_)) {
+ return false;
+ }
+
+ // Q matrix writing to MPS not supported yet. Don't check for equivalence here
+
+ return true;
+}
+
template
void optimization_problem_t::set_csr_constraint_matrix(const f_t* A_values,
i_t size_values,
diff --git a/cpp/tests/linear_programming/CMakeLists.txt b/cpp/tests/linear_programming/CMakeLists.txt
index c091751f9..40e284baf 100644
--- a/cpp/tests/linear_programming/CMakeLists.txt
+++ b/cpp/tests/linear_programming/CMakeLists.txt
@@ -1,5 +1,5 @@
# cmake-format: off
-# SPDX-FileCopyrightText: Copyright (c) 2024-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# cmake-format: on
@@ -52,6 +52,10 @@ if (NOT SKIP_C_PYTHON_ADAPTERS)
${CUOPT_PRIVATE_CUDA_LIBS}
)
+ if(NOT DEFINED INSTALL_TARGET OR "${INSTALL_TARGET}" STREQUAL "")
+ target_link_options(C_API_TEST PRIVATE -Wl,--enable-new-dtags)
+ endif()
+
add_test(NAME C_API_TEST COMMAND C_API_TEST)
install(
diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_test.c b/cpp/tests/linear_programming/c_api_tests/c_api_test.c
index 52be9e16f..33aac9b3e 100644
--- a/cpp/tests/linear_programming/c_api_tests/c_api_test.c
+++ b/cpp/tests/linear_programming/c_api_tests/c_api_test.c
@@ -1201,3 +1201,84 @@ cuOptDestroySolution(&solution);
return status;
}
+
+cuopt_int_t test_write_problem(const char* input_filename, const char* output_filename)
+{
+ cuOptOptimizationProblem problem = NULL;
+ cuOptOptimizationProblem problem_read = NULL;
+ cuOptSolverSettings settings = NULL;
+ cuOptSolution solution = NULL;
+ cuopt_int_t status;
+ cuopt_int_t termination_status;
+ cuopt_float_t objective_value;
+
+ /* Read the input problem */
+ status = cuOptReadProblem(input_filename, &problem);
+ if (status != CUOPT_SUCCESS) {
+ printf("Error reading problem from %s: %d\n", input_filename, status);
+ goto DONE;
+ }
+
+ /* Write the problem to MPS file */
+ status = cuOptWriteProblem(problem, output_filename, CUOPT_FILE_FORMAT_MPS);
+ if (status != CUOPT_SUCCESS) {
+ printf("Error writing problem to MPS: %d\n", status);
+ goto DONE;
+ }
+ printf("Problem written to %s\n", output_filename);
+
+ /* Read the problem back */
+ status = cuOptReadProblem(output_filename, &problem_read);
+ if (status != CUOPT_SUCCESS) {
+ printf("Error reading problem from MPS: %d\n", status);
+ goto DONE;
+ }
+ printf("Problem read back from %s\n", output_filename);
+
+ status = cuOptCreateSolverSettings(&settings);
+ if (status != CUOPT_SUCCESS) {
+ printf("Error creating solver settings: %d\n", status);
+ goto DONE;
+ }
+
+ status = cuOptSetIntegerParameter(settings, CUOPT_METHOD, CUOPT_METHOD_PDLP);
+ if (status != CUOPT_SUCCESS) {
+ printf("Error setting method: %d\n", status);
+ goto DONE;
+ }
+
+ status = cuOptSolve(problem_read, settings, &solution);
+ if (status != CUOPT_SUCCESS) {
+ printf("Error solving problem: %d\n", status);
+ goto DONE;
+ }
+
+ status = cuOptGetTerminationStatus(solution, &termination_status);
+ if (status != CUOPT_SUCCESS) {
+ printf("Error getting termination status: %d\n", status);
+ goto DONE;
+ }
+
+ status = cuOptGetObjectiveValue(solution, &objective_value);
+ if (status != CUOPT_SUCCESS) {
+ printf("Error getting objective value: %d\n", status);
+ goto DONE;
+ }
+
+ printf("Termination status: %d, Objective: %f\n", termination_status, objective_value);
+
+ if (termination_status != CUOPT_TERIMINATION_STATUS_OPTIMAL) {
+ printf("Expected optimal status\n");
+ status = -1;
+ goto DONE;
+ }
+
+ printf("Write problem test passed\n");
+
+DONE:
+ cuOptDestroyProblem(&problem);
+ cuOptDestroyProblem(&problem_read);
+ cuOptDestroySolverSettings(&settings);
+ cuOptDestroySolution(&solution);
+ return status;
+}
diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp b/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp
index af1295298..e67e6202c 100644
--- a/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp
+++ b/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp
@@ -1,13 +1,17 @@
/* clang-format off */
/*
- * SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+ * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
/* clang-format on */
#include "c_api_tests.h"
+#include
+#include
+
#include
+#include
#include
#include
@@ -128,3 +132,92 @@ TEST(c_api, test_quadratic_ranged_problem)
EXPECT_EQ(termination_status, (int)CUOPT_TERIMINATION_STATUS_OPTIMAL);
EXPECT_NEAR(objective, -32.0, 1e-3);
}
+
+TEST(c_api, test_write_problem)
+{
+ const std::string& rapidsDatasetRootDir = cuopt::test::get_rapids_dataset_root_dir();
+ std::string input_file = rapidsDatasetRootDir + "/linear_programming/afiro_original.mps";
+ std::string temp_file = std::filesystem::temp_directory_path().string() + "/c_api_test_write.mps";
+ EXPECT_EQ(test_write_problem(input_file.c_str(), temp_file.c_str()), CUOPT_SUCCESS);
+ std::filesystem::remove(temp_file);
+}
+
+static bool test_mps_roundtrip(const std::string& mps_file_path)
+{
+ using cuopt::linear_programming::problem_and_stream_view_t;
+
+ cuOptOptimizationProblem original_handle = nullptr;
+ cuOptOptimizationProblem reread_handle = nullptr;
+ bool result = false;
+
+ std::string model_basename = std::filesystem::path(mps_file_path).filename().string();
+ std::string temp_file =
+ std::filesystem::temp_directory_path().string() + "/roundtrip_temp_" + model_basename;
+
+ if (cuOptReadProblem(mps_file_path.c_str(), &original_handle) != CUOPT_SUCCESS) {
+ std::cerr << "Failed to read original MPS file: " << mps_file_path << std::endl;
+ goto cleanup;
+ }
+
+ if (cuOptWriteProblem(original_handle, temp_file.c_str(), CUOPT_FILE_FORMAT_MPS) !=
+ CUOPT_SUCCESS) {
+ std::cerr << "Failed to write MPS file: " << temp_file << std::endl;
+ goto cleanup;
+ }
+
+ if (cuOptReadProblem(temp_file.c_str(), &reread_handle) != CUOPT_SUCCESS) {
+ std::cerr << "Failed to re-read MPS file: " << temp_file << std::endl;
+ goto cleanup;
+ }
+
+ {
+ auto* original_problem_wrapper = static_cast(original_handle);
+ auto* reread_problem_wrapper = static_cast(reread_handle);
+
+ result =
+ original_problem_wrapper->op_problem->is_equivalent(*reread_problem_wrapper->op_problem);
+ }
+
+cleanup:
+ std::filesystem::remove(temp_file);
+ cuOptDestroyProblem(&original_handle);
+ cuOptDestroyProblem(&reread_handle);
+
+ return result;
+}
+
+class WriteRoundtripTestFixture : public ::testing::TestWithParam {};
+TEST_P(WriteRoundtripTestFixture, roundtrip)
+{
+ const std::string& rapidsDatasetRootDir = cuopt::test::get_rapids_dataset_root_dir();
+ EXPECT_TRUE(test_mps_roundtrip(rapidsDatasetRootDir + GetParam()));
+}
+INSTANTIATE_TEST_SUITE_P(c_api,
+ WriteRoundtripTestFixture,
+ ::testing::Values("/linear_programming/afiro_original.mps",
+ "/mip/50v-10.mps",
+ "/mip/fiball.mps",
+ "/mip/gen-ip054.mps",
+ "/mip/sct2.mps",
+ "/mip/uccase9.mps",
+ "/mip/drayage-25-23.mps",
+ "/mip/tr12-30.mps",
+ "/mip/neos-3004026-krka.mps",
+ "/mip/ns1208400.mps",
+ "/mip/gmu-35-50.mps",
+ "/mip/n2seq36q.mps",
+ "/mip/seymour1.mps",
+ "/mip/rmatr200-p5.mps",
+ "/mip/cvs16r128-89.mps",
+ "/mip/thor50dday.mps",
+ "/mip/stein9inf.mps",
+ "/mip/neos5.mps",
+ "/mip/neos5-free-bound.mps",
+ "/mip/crossing_var_bounds.mps",
+ "/mip/cod105_max.mps",
+ "/mip/sudoku.mps",
+ "/mip/presolve-infeasible.mps",
+ "/mip/swath1.mps",
+ "/mip/enlight_hard.mps",
+ "/mip/enlight11.mps",
+ "/mip/supportcase22.mps"));
diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_tests.h b/cpp/tests/linear_programming/c_api_tests/c_api_tests.h
index 5726c3a99..4898e0639 100644
--- a/cpp/tests/linear_programming/c_api_tests/c_api_tests.h
+++ b/cpp/tests/linear_programming/c_api_tests/c_api_tests.h
@@ -1,6 +1,6 @@
/* clang-format off */
/*
- * SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+ * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
/* clang-format on */
@@ -34,7 +34,7 @@ cuopt_int_t test_quadratic_problem(cuopt_int_t* termination_status_ptr,
cuopt_float_t* objective_ptr);
cuopt_int_t test_quadratic_ranged_problem(cuopt_int_t* termination_status_ptr,
cuopt_float_t* objective_ptr);
-
+cuopt_int_t test_write_problem(const char* input_filename, const char* output_filename);
#ifdef __cplusplus
}
#endif