From 366bfbdc527dfc76ff3b118ed434c7110a3c23df Mon Sep 17 00:00:00 2001 From: Ishika Roy Date: Wed, 15 Apr 2026 19:12:52 -0700 Subject: [PATCH 1/3] update solver settings and LP python tests to only test API --- python/cuopt/cuopt/CMakeLists.txt | 1 + .../linear_programming/solver/CMakeLists.txt | 2 +- .../linear_programming/solver/solver.pxd | 79 +---- .../solver/solver_parameters.py | 16 + .../solver/solver_parameters.pyx | 45 --- .../solver/solver_wrapper.pyx | 151 ++------- .../solver_settings/CMakeLists.txt | 10 + .../solver_settings/__init__.py | 22 +- .../solver_settings/solver_settings.pxd | 90 ++++++ ...solver_settings.py => solver_settings.pyx} | 148 ++++++++- .../linear_programming/test_lp_solver.py | 173 +--------- .../linear_programming/test_python_API.py | 303 +----------------- 12 files changed, 325 insertions(+), 715 deletions(-) create mode 100644 python/cuopt/cuopt/linear_programming/solver/solver_parameters.py delete mode 100644 python/cuopt/cuopt/linear_programming/solver/solver_parameters.pyx create mode 100644 python/cuopt/cuopt/linear_programming/solver_settings/CMakeLists.txt create mode 100644 python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pxd rename python/cuopt/cuopt/linear_programming/solver_settings/{solver_settings.py => solver_settings.pyx} (64%) diff --git a/python/cuopt/cuopt/CMakeLists.txt b/python/cuopt/cuopt/CMakeLists.txt index d996471797..848fb0be98 100644 --- a/python/cuopt/cuopt/CMakeLists.txt +++ b/python/cuopt/cuopt/CMakeLists.txt @@ -6,6 +6,7 @@ add_subdirectory(distance_engine) add_subdirectory(linear_programming/data_model) add_subdirectory(linear_programming/solver) +add_subdirectory(linear_programming/solver_settings) # We don't need to have mps_parser within cuOpt # Remove subdirectory addition in future diff --git a/python/cuopt/cuopt/linear_programming/solver/CMakeLists.txt b/python/cuopt/cuopt/linear_programming/solver/CMakeLists.txt index ef0ccbdfbd..11bb106a86 100644 --- a/python/cuopt/cuopt/linear_programming/solver/CMakeLists.txt +++ b/python/cuopt/cuopt/linear_programming/solver/CMakeLists.txt @@ -3,7 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 # cmake-format: on -set(cython_sources solver_wrapper.pyx solver_parameters.pyx) +set(cython_sources solver_wrapper.pyx) set(linked_libraries cuopt::cuopt cuopt::mps_parser) rapids_cython_create_modules(SOURCE_FILES "${cython_sources}" LINKED_LIBRARIES "${linked_libraries}" ASSOCIATED_TARGETS cuopt diff --git a/python/cuopt/cuopt/linear_programming/solver/solver.pxd b/python/cuopt/cuopt/linear_programming/solver/solver.pxd index 3bb2cba34a..5570af1a04 100644 --- a/python/cuopt/cuopt/linear_programming/solver/solver.pxd +++ b/python/cuopt/cuopt/linear_programming/solver/solver.pxd @@ -16,80 +16,11 @@ from pylibraft.common.handle cimport * from rmm.librmm.device_buffer cimport device_buffer from cuopt.linear_programming.data_model.data_model cimport data_model_view_t - - -cdef extern from "cuopt/linear_programming/utilities/internals.hpp" namespace "cuopt::internals": # noqa - cdef cppclass base_solution_callback_t - -cdef extern from "cuopt/linear_programming/pdlp/solver_settings.hpp" namespace "cuopt::linear_programming": # noqa - ctypedef enum pdlp_solver_mode_t "cuopt::linear_programming::pdlp_solver_mode_t": # noqa - Stable1 "cuopt::linear_programming::pdlp_solver_mode_t::Stable1" # noqa - Stable2 "cuopt::linear_programming::pdlp_solver_mode_t::Stable2" # noqa - Methodical1 "cuopt::linear_programming::pdlp_solver_mode_t::Methodical1" # noqa - Fast1 "cuopt::linear_programming::pdlp_solver_mode_t::Fast1" # noqa - Stable3 "cuopt::linear_programming::pdlp_solver_mode_t::Stable3" # noqa - - ctypedef enum method_t "cuopt::linear_programming::method_t": # noqa - Concurrent "cuopt::linear_programming::method_t::Concurrent" # noqa - PDLP "cuopt::linear_programming::method_t::PDLP" # noqa - DualSimplex "cuopt::linear_programming::method_t::DualSimplex" # noqa - Barrier "cuopt::linear_programming::method_t::Barrier" # noqa - Unset "cuopt::linear_programming::method_t::Unset" # noqa - -cdef extern from "cuopt/linear_programming/solver_settings.hpp" namespace "cuopt::linear_programming": # noqa - - cdef cppclass solver_settings_t[i_t, f_t]: - solver_settings_t() except + - - void set_pdlp_warm_start_data( - const f_t* current_primal_solution, - const f_t* current_dual_solution, - const f_t* initial_primal_average, - const f_t* initial_dual_average, - const f_t* current_ATY, - const f_t* sum_primal_solutions, - const f_t* sum_dual_solutions, - const f_t* last_restart_duality_gap_primal_solution, - const f_t* last_restart_duality_gap_dual_solution, - i_t primal_size, - i_t dual_size, - f_t initial_primal_weight_, - f_t initial_step_size_, - i_t total_pdlp_iterations_, - i_t total_pdhg_iterations_, - f_t last_candidate_kkt_score_, - f_t last_restart_kkt_score_, - f_t sum_solution_weight_, - i_t iterations_since_last_restart_) except + - - void set_parameter_from_string( - const string& name, - const string& value - ) except + - - string get_parameter_as_string(const string& name) except + - - vector[string] get_parameter_names() except + - - # LP settings - void set_initial_pdlp_primal_solution( - const f_t* initial_primal_solution, - i_t size - ) except + - void set_initial_pdlp_dual_solution( - const f_t* initial_dual_solution, - i_t size - ) except + - - # MIP settings - void add_initial_mip_solution( - const f_t* initial_solution, - i_t size - ) except + - void set_mip_callback( - base_solution_callback_t* callback, - void* user_data - ) except + +from cuopt.linear_programming.solver_settings.solver_settings cimport ( + method_t, + pdlp_solver_mode_t, + solver_settings_t, +) cdef extern from "cuopt/linear_programming/optimization_problem.hpp" namespace "cuopt::linear_programming": # noqa diff --git a/python/cuopt/cuopt/linear_programming/solver/solver_parameters.py b/python/cuopt/cuopt/linear_programming/solver/solver_parameters.py new file mode 100644 index 0000000000..daed3a28c7 --- /dev/null +++ b/python/cuopt/cuopt/linear_programming/solver/solver_parameters.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Backward-compatible module; LP parameter helpers live in solver_settings.""" + +from cuopt.linear_programming.solver_settings.solver_settings import ( + get_solver_parameter_names, + get_solver_setting, + solver_params, +) + +import cuopt.linear_programming.solver_settings.solver_settings as _solver_settings_ext + +for _name in dir(_solver_settings_ext): + if _name.startswith("CUOPT_"): + globals()[_name] = getattr(_solver_settings_ext, _name) diff --git a/python/cuopt/cuopt/linear_programming/solver/solver_parameters.pyx b/python/cuopt/cuopt/linear_programming/solver/solver_parameters.pyx deleted file mode 100644 index 871919dad3..0000000000 --- a/python/cuopt/cuopt/linear_programming/solver/solver_parameters.pyx +++ /dev/null @@ -1,45 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa -# SPDX-License-Identifier: Apache-2.0 - - -# cython: profile=False -# distutils: language = c++ -# cython: embedsignature = True -# cython: language_level = 3 - -from libcpp.memory cimport unique_ptr -from libcpp.string cimport string -from libcpp.vector cimport vector - -from cuopt.linear_programming.solver.solver cimport solver_settings_t - -def get_solver_setting(name): - cdef unique_ptr[solver_settings_t[int, double]] unique_solver_settings - - unique_solver_settings.reset(new solver_settings_t[int, double]()) - - cdef solver_settings_t[int, double]* c_solver_settings = ( - unique_solver_settings.get() - ) - return c_solver_settings.get_parameter_as_string( - name.encode('utf-8') - ).decode('utf-8') - - -cpdef get_solver_parameter_names(): - cdef unique_ptr[solver_settings_t[int, double]] unique_solver_settings - unique_solver_settings.reset(new solver_settings_t[int, double]()) - cdef solver_settings_t[int, double]* c_solver_settings = ( - unique_solver_settings.get() - ) - cdef vector[string] parameter_names = c_solver_settings.get_parameter_names() - - cdef list py_parameter_names = [] - cdef size_t i - for i in range(parameter_names.size()): - # std::string -> Python str - py_parameter_names.append(parameter_names[i].decode("utf-8")) - return py_parameter_names - -solver_params = get_solver_parameter_names() -for param in solver_params: globals()["CUOPT_"+param.upper()] = param diff --git a/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx b/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx index f5a7aaab48..6b1840663e 100644 --- a/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx +++ b/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx @@ -63,6 +63,8 @@ import cudf from cuopt.linear_programming.solver_settings.solver_settings import ( PDLPSolverMode, SolverMethod, +) +from cuopt.linear_programming.solver_settings.solver_settings cimport ( SolverSettings, ) from cuopt.utilities import InputValidationError, series_from_buf @@ -164,13 +166,30 @@ def type_cast(cudf_obj, np_type, name): cdef set_solver_setting( - unique_ptr[solver_settings_t[int, double]]& unique_solver_settings, - settings, + SolverSettings settings, DataModel data_model_obj=None, mip=False): - cdef solver_settings_t[int, double]* c_solver_settings = ( - unique_solver_settings.get() - ) + settings.c_solver_settings.reset(new solver_settings_t[int, double]()) + if settings.get_pdlp_warm_start_data() is not None: # noqa + if len(data_model_obj.get_objective_coefficients()) != len( + settings.get_pdlp_warm_start_data().current_primal_solution + ): + raise Exception( + "Invalid PDLPWarmStart data. Passed problem and PDLPWarmStart " # noqa + "data should have the same amount of variables." + ) + if len(data_model_obj.get_constraint_matrix_offsets()) - 1 != len( # noqa + settings.get_pdlp_warm_start_data().current_dual_solution + ): + raise Exception( + "Invalid PDLPWarmStart data. Passed problem and PDLPWarmStart " # noqa + "data should have the same amount of constraints." + ) + # Set solver parameters and pdlp warmstart data + settings.set_c_solver_settings() + + cdef solver_settings_t[int, double]* c_solver_settings = settings.c_solver_settings.get() + # Set initial solution on the C++ side if set on the Python side cdef uintptr_t c_initial_primal_solution = ( 0 if data_model_obj is None else get_data_ptr(data_model_obj.get_initial_primal_solution()) # noqa @@ -179,15 +198,6 @@ cdef set_solver_setting( 0 if data_model_obj is None else get_data_ptr(data_model_obj.get_initial_dual_solution()) # noqa ) - cdef uintptr_t c_current_primal_solution - cdef uintptr_t c_current_dual_solution - cdef uintptr_t c_initial_primal_average - cdef uintptr_t c_initial_dual_average - cdef uintptr_t c_current_ATY - cdef uintptr_t c_sum_primal_solutions - cdef uintptr_t c_sum_dual_solutions - cdef uintptr_t c_last_restart_duality_gap_primal_solution - cdef uintptr_t c_last_restart_duality_gap_dual_solution cdef uintptr_t callback_ptr = 0 cdef uintptr_t callback_user_data = 0 if mip: @@ -197,12 +207,6 @@ cdef set_solver_setting( data_model_obj.get_initial_primal_solution().shape[0] ) - for name, value in settings.settings_dict.items(): - c_solver_settings.set_parameter_from_string( - name.encode('utf-8'), - str(value).encode('utf-8') - ) - callbacks = settings.get_mip_callbacks() for callback in callbacks: if callback: @@ -229,96 +233,6 @@ cdef set_solver_setting( data_model_obj.get_initial_dual_solution().shape[0] ) - for name, value in settings.settings_dict.items(): - c_solver_settings.set_parameter_from_string( - name.encode('utf-8'), - str(value).encode('utf-8') - ) - - - if settings.get_pdlp_warm_start_data() is not None: # noqa - if len(data_model_obj.get_objective_coefficients()) != len( - settings.get_pdlp_warm_start_data().current_primal_solution - ): - raise Exception( - "Invalid PDLPWarmStart data. Passed problem and PDLPWarmStart " # noqa - "data should have the same amount of variables." - ) - if len(data_model_obj.get_constraint_matrix_offsets()) - 1 != len( # noqa - settings.get_pdlp_warm_start_data().current_dual_solution - ): - raise Exception( - "Invalid PDLPWarmStart data. Passed problem and PDLPWarmStart " # noqa - "data should have the same amount of constraints." - ) - c_current_primal_solution = ( - get_data_ptr( - settings.get_pdlp_warm_start_data().current_primal_solution # noqa - ) - ) - c_current_dual_solution = ( - get_data_ptr( - settings.get_pdlp_warm_start_data().current_dual_solution - ) - ) - c_initial_primal_average = ( - get_data_ptr( - settings.get_pdlp_warm_start_data().initial_primal_average # noqa - ) - ) - c_initial_dual_average = ( - get_data_ptr( - settings.get_pdlp_warm_start_data().initial_dual_average - ) - ) - c_current_ATY = ( - get_data_ptr( - settings.get_pdlp_warm_start_data().current_ATY - ) - ) - c_sum_primal_solutions = ( - get_data_ptr( - settings.get_pdlp_warm_start_data().sum_primal_solutions - ) - ) - c_sum_dual_solutions = ( - get_data_ptr( - settings.get_pdlp_warm_start_data().sum_dual_solutions - ) - ) - c_last_restart_duality_gap_primal_solution = ( - get_data_ptr( - settings.get_pdlp_warm_start_data().last_restart_duality_gap_primal_solution # noqa - ) - ) - c_last_restart_duality_gap_dual_solution = ( - get_data_ptr( - settings.get_pdlp_warm_start_data().last_restart_duality_gap_dual_solution # noqa - ) - ) - warm_start_data = settings.get_pdlp_warm_start_data() - c_solver_settings.set_pdlp_warm_start_data( - c_current_primal_solution, - c_current_dual_solution, - c_initial_primal_average, - c_initial_dual_average, - c_current_ATY, - c_sum_primal_solutions, - c_sum_dual_solutions, - c_last_restart_duality_gap_primal_solution, - c_last_restart_duality_gap_dual_solution, - warm_start_data.last_restart_duality_gap_primal_solution.shape[0], # Primal size # noqa - warm_start_data.last_restart_duality_gap_dual_solution.shape[0], # Dual size # noqa - warm_start_data.initial_primal_weight, - warm_start_data.initial_step_size, - warm_start_data.total_pdlp_iterations, - warm_start_data.total_pdhg_iterations, - warm_start_data.last_candidate_kkt_score, - warm_start_data.last_restart_kkt_score, - warm_start_data.sum_solution_weight, - warm_start_data.iterations_since_last_restart # noqa - ) - cdef create_solution(unique_ptr[solver_ret_t] sol_ret_ptr, DataModel data_model_obj, is_batch=False): @@ -503,19 +417,16 @@ cdef create_solution(unique_ptr[solver_ret_t] sol_ret_ptr, ) -def Solve(py_data_model_obj, settings, mip=False): +def Solve(py_data_model_obj, SolverSettings settings, mip=False): cdef DataModel data_model_obj = py_data_model_obj - cdef unique_ptr[solver_settings_t[int, double]] unique_solver_settings - - unique_solver_settings.reset(new solver_settings_t[int, double]()) data_model_obj.variable_types = type_cast( data_model_obj.variable_types, "S1", "variable_types" ) set_solver_setting( - unique_solver_settings, settings, data_model_obj, mip + settings, data_model_obj, mip ) data_model_obj.set_data_model_view() @@ -523,7 +434,7 @@ def Solve(py_data_model_obj, settings, mip=False): with nogil: sol_ret_ptr = move(call_solve( data_model_obj.c_data_model_view.get(), - unique_solver_settings.get(), + settings.c_solver_settings.get(), )) return create_solution(move(sol_ret_ptr), data_model_obj) @@ -535,13 +446,11 @@ cdef set_and_insert_vector( data_model_views.push_back(data_model_obj.c_data_model_view.get()) -def BatchSolve(py_data_model_list, settings): - cdef unique_ptr[solver_settings_t[int, double]] unique_solver_settings - unique_solver_settings.reset(new solver_settings_t[int, double]()) +def BatchSolve(py_data_model_list, SolverSettings settings): if settings.get_pdlp_warm_start_data() is not None: # noqa raise Exception("Cannot use warmstart data with Batch Solve") - set_solver_setting(unique_solver_settings, settings) + set_solver_setting(settings) cdef vector[data_model_view_t[int, double] *] data_model_views @@ -552,7 +461,7 @@ def BatchSolve(py_data_model_list, settings): vector[unique_ptr[solver_ret_t]], double] batch_solve_result with nogil: - batch_solve_result = move(call_batch_solve(data_model_views, unique_solver_settings.get())) # noqa + batch_solve_result = move(call_batch_solve(data_model_views, settings.c_solver_settings.get())) # noqa cdef vector[unique_ptr[solver_ret_t]] c_solutions = ( move(batch_solve_result.first) diff --git a/python/cuopt/cuopt/linear_programming/solver_settings/CMakeLists.txt b/python/cuopt/cuopt/linear_programming/solver_settings/CMakeLists.txt new file mode 100644 index 0000000000..8bd0e91ee2 --- /dev/null +++ b/python/cuopt/cuopt/linear_programming/solver_settings/CMakeLists.txt @@ -0,0 +1,10 @@ +# cmake-format: off +# SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# cmake-format: on + +set(cython_sources solver_settings.pyx) +set(linked_libraries cuopt::cuopt cuopt::mps_parser) + +rapids_cython_create_modules(SOURCE_FILES "${cython_sources}" LINKED_LIBRARIES "${linked_libraries}" ASSOCIATED_TARGETS cuopt + CXX) diff --git a/python/cuopt/cuopt/linear_programming/solver_settings/__init__.py b/python/cuopt/cuopt/linear_programming/solver_settings/__init__.py index 35ad6eefff..51482e4dd7 100644 --- a/python/cuopt/cuopt/linear_programming/solver_settings/__init__.py +++ b/python/cuopt/cuopt/linear_programming/solver_settings/__init__.py @@ -1,4 +1,22 @@ -# SPDX-FileCopyrightText: Copyright (c) 2024-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -from .solver_settings import PDLPSolverMode, SolverMethod, SolverSettings +"""LP/MIP solver settings package; implementation is in the ``solver_settings`` extension.""" + +from .solver_settings import ( + PDLPSolverMode, + SolverMethod, + SolverSettings, + get_solver_parameter_names, + get_solver_setting, + solver_params, +) + +__all__ = [ + "PDLPSolverMode", + "SolverMethod", + "SolverSettings", + "get_solver_parameter_names", + "get_solver_setting", + "solver_params", +] diff --git a/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pxd b/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pxd new file mode 100644 index 0000000000..88fa48b239 --- /dev/null +++ b/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pxd @@ -0,0 +1,90 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa +# SPDX-License-Identifier: Apache-2.0 + + +# cython: profile=False +# distutils: language = c++ +# cython: embedsignature = True +# cython: language_level = 3 + +from libcpp.memory cimport unique_ptr +from libcpp.string cimport string +from libcpp.vector cimport vector + +cdef extern from "cuopt/linear_programming/utilities/internals.hpp" namespace "cuopt::internals": # noqa + cdef cppclass base_solution_callback_t + +cdef extern from "cuopt/linear_programming/pdlp/solver_settings.hpp" namespace "cuopt::linear_programming": # noqa + ctypedef enum pdlp_solver_mode_t "cuopt::linear_programming::pdlp_solver_mode_t": # noqa + Stable1 "cuopt::linear_programming::pdlp_solver_mode_t::Stable1" # noqa + Stable2 "cuopt::linear_programming::pdlp_solver_mode_t::Stable2" # noqa + Methodical1 "cuopt::linear_programming::pdlp_solver_mode_t::Methodical1" # noqa + Fast1 "cuopt::linear_programming::pdlp_solver_mode_t::Fast1" # noqa + Stable3 "cuopt::linear_programming::pdlp_solver_mode_t::Stable3" # noqa + + ctypedef enum method_t "cuopt::linear_programming::method_t": # noqa + Concurrent "cuopt::linear_programming::method_t::Concurrent" # noqa + PDLP "cuopt::linear_programming::method_t::PDLP" # noqa + DualSimplex "cuopt::linear_programming::method_t::DualSimplex" # noqa + Barrier "cuopt::linear_programming::method_t::Barrier" # noqa + Unset "cuopt::linear_programming::method_t::Unset" # noqa + +cdef extern from "cuopt/linear_programming/solver_settings.hpp" namespace "cuopt::linear_programming": # noqa + + cdef cppclass solver_settings_t[i_t, f_t]: + solver_settings_t() except + + + void set_pdlp_warm_start_data( + const f_t* current_primal_solution, + const f_t* current_dual_solution, + const f_t* initial_primal_average, + const f_t* initial_dual_average, + const f_t* current_ATY, + const f_t* sum_primal_solutions, + const f_t* sum_dual_solutions, + const f_t* last_restart_duality_gap_primal_solution, + const f_t* last_restart_duality_gap_dual_solution, + i_t primal_size, + i_t dual_size, + f_t initial_primal_weight_, + f_t initial_step_size_, + i_t total_pdlp_iterations_, + i_t total_pdhg_iterations_, + f_t last_candidate_kkt_score_, + f_t last_restart_kkt_score_, + f_t sum_solution_weight_, + i_t iterations_since_last_restart_) except + + + void set_parameter_from_string( + const string& name, + const string& value + ) except + + + string get_parameter_as_string(const string& name) except + + + vector[string] get_parameter_names() except + + + void set_initial_pdlp_primal_solution( + const f_t* initial_primal_solution, + i_t size + ) except + + void set_initial_pdlp_dual_solution( + const f_t* initial_dual_solution, + i_t size + ) except + + + void add_initial_mip_solution( + const f_t* initial_solution, + i_t size + ) except + + void set_mip_callback( + base_solution_callback_t* callback, + void* user_data + ) except + + + +cdef class SolverSettings: + cdef unique_ptr[solver_settings_t[int, double]] c_solver_settings + cdef public dict settings_dict + cdef public object pdlp_warm_start_data + cdef public list mip_callbacks diff --git a/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.py b/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pyx similarity index 64% rename from python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.py rename to python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pyx index dc689b75fe..5813719d0e 100644 --- a/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.py +++ b/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pyx @@ -1,12 +1,51 @@ -# SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa # SPDX-License-Identifier: Apache-2.0 -from enum import IntEnum, auto -from cuopt.linear_programming.solver.solver_parameters import ( - solver_params, - get_solver_setting, -) +# cython: profile=False +# distutils: language = c++ +# cython: embedsignature = True +# cython: language_level = 3 + +from libcpp.memory cimport unique_ptr +from libc.stdint cimport uintptr_t +from libcpp.string cimport string +from libcpp.vector cimport vector + + +def get_solver_setting(name): + cdef unique_ptr[solver_settings_t[int, double]] unique_solver_settings + + unique_solver_settings.reset(new solver_settings_t[int, double]()) + + cdef solver_settings_t[int, double]* c_solver_settings = ( + unique_solver_settings.get() + ) + return c_solver_settings.get_parameter_as_string( + name.encode('utf-8') + ).decode('utf-8') + + +cpdef get_solver_parameter_names(): + cdef unique_ptr[solver_settings_t[int, double]] unique_solver_settings + unique_solver_settings.reset(new solver_settings_t[int, double]()) + cdef solver_settings_t[int, double]* c_solver_settings = ( + unique_solver_settings.get() + ) + cdef vector[string] parameter_names = c_solver_settings.get_parameter_names() + + cdef list py_parameter_names = [] + cdef size_t i + for i in range(parameter_names.size()): + # std::string -> Python str + py_parameter_names.append(parameter_names[i].decode("utf-8")) + return py_parameter_names + + +solver_params = get_solver_parameter_names() +for param in solver_params: globals()["CUOPT_"+param.upper()] = param + +from enum import IntEnum, auto class SolverMethod(IntEnum): @@ -67,8 +106,9 @@ def __str__(self): return "%d" % self.value -class SolverSettings: +cdef class SolverSettings: def __init__(self): + self.c_solver_settings.reset(new solver_settings_t[int, double]()) self.settings_dict = {} self.pdlp_warm_start_data = None self.mip_callbacks = [] @@ -301,6 +341,100 @@ def get_pdlp_warm_start_data(self): """ return self.pdlp_warm_start_data + def set_c_solver_settings(self): + # All cdef declarations must precede other statements in this function. + cdef solver_settings_t[int, double]* c_solver_settings + cdef uintptr_t c_current_primal_solution + cdef uintptr_t c_current_dual_solution + cdef uintptr_t c_initial_primal_average + cdef uintptr_t c_initial_dual_average + cdef uintptr_t c_current_ATY + cdef uintptr_t c_sum_primal_solutions + cdef uintptr_t c_sum_dual_solutions + cdef uintptr_t c_last_restart_duality_gap_primal_solution + cdef uintptr_t c_last_restart_duality_gap_dual_solution + + c_solver_settings = self.c_solver_settings.get() + + for name, value in self.settings_dict.items(): + c_solver_settings.set_parameter_from_string( + name.encode('utf-8'), + str(value).encode('utf-8') + ) + + if self.get_pdlp_warm_start_data() is not None: + from cuopt.linear_programming.solver.solver_wrapper import ( + get_data_ptr, + ) + + warm_start_data = self.get_pdlp_warm_start_data() + c_current_primal_solution = ( + get_data_ptr( + warm_start_data.current_primal_solution # noqa + ) + ) + c_current_dual_solution = ( + get_data_ptr( + warm_start_data.current_dual_solution + ) + ) + c_initial_primal_average = ( + get_data_ptr( + warm_start_data.initial_primal_average # noqa + ) + ) + c_initial_dual_average = ( + get_data_ptr( + warm_start_data.initial_dual_average + ) + ) + c_current_ATY = ( + get_data_ptr( + warm_start_data.current_ATY + ) + ) + c_sum_primal_solutions = ( + get_data_ptr( + warm_start_data.sum_primal_solutions + ) + ) + c_sum_dual_solutions = ( + get_data_ptr( + warm_start_data.sum_dual_solutions + ) + ) + c_last_restart_duality_gap_primal_solution = ( + get_data_ptr( + warm_start_data.last_restart_duality_gap_primal_solution # noqa + ) + ) + c_last_restart_duality_gap_dual_solution = ( + get_data_ptr( + warm_start_data.last_restart_duality_gap_dual_solution # noqa + ) + ) + c_solver_settings.set_pdlp_warm_start_data( + c_current_primal_solution, + c_current_dual_solution, + c_initial_primal_average, + c_initial_dual_average, + c_current_ATY, + c_sum_primal_solutions, + c_sum_dual_solutions, + c_last_restart_duality_gap_primal_solution, + c_last_restart_duality_gap_dual_solution, + warm_start_data.last_restart_duality_gap_primal_solution.shape[0], # Primal size # noqa + warm_start_data.last_restart_duality_gap_dual_solution.shape[0], # Dual size # noqa + warm_start_data.initial_primal_weight, + warm_start_data.initial_step_size, + warm_start_data.total_pdlp_iterations, + warm_start_data.total_pdhg_iterations, + warm_start_data.last_candidate_kkt_score, + warm_start_data.last_restart_kkt_score, + warm_start_data.sum_solution_weight, + warm_start_data.iterations_since_last_restart # noqa + ) + def toDict(self): solver_config = {} solver_config["tolerances"] = {} diff --git a/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py b/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py index 291c80d925..fd00a9126b 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py @@ -100,72 +100,6 @@ def test_parser_and_solver(): assert solution.get_termination_reason() == "Optimal" -def test_very_low_tolerance(): - file_path = ( - RAPIDS_DATASET_ROOT_DIR + "/linear_programming/afiro_original.mps" - ) - data_model_obj = cuopt_mps_parser.ParseMps(file_path) - - settings = solver_settings.SolverSettings() - settings.set_optimality_tolerance(1e-12) - # Test with the former/legacy solver_mode - settings.set_parameter(CUOPT_PDLP_SOLVER_MODE, PDLPSolverMode.Methodical1) - settings.set_parameter(CUOPT_INFEASIBILITY_DETECTION, False) - - solution = solver.Solve(data_model_obj, settings) - - expected_time = 69 - - assert solution.get_termination_status() == LPTerminationStatus.Optimal - assert solution.get_primal_objective() == pytest.approx(-464.7531) - # Rougly up to 5 times slower on V100 - assert solution.get_solve_time() <= expected_time * 5 - - -# TODO: should test all LP solver modes? -def test_iteration_limit_solver(): - file_path = ( - RAPIDS_DATASET_ROOT_DIR + "/linear_programming/savsched1/savsched1.mps" - ) - data_model_obj = cuopt_mps_parser.ParseMps(file_path) - - settings = solver_settings.SolverSettings() - settings.set_optimality_tolerance(1e-12) - settings.set_parameter(CUOPT_ITERATION_LIMIT, 1) - # Setting both to make sure the lowest one is picked - settings.set_parameter(CUOPT_TIME_LIMIT, 99999999) - - solution = solver.Solve(data_model_obj, settings) - assert ( - solution.get_termination_status() == LPTerminationStatus.IterationLimit - ) - # Check we don't return empty (all 0) solution - assert solution.get_primal_objective() != 0.0 - assert np.any(solution.get_primal_solution()) - - -def test_time_limit_solver(): - file_path = ( - RAPIDS_DATASET_ROOT_DIR + "/linear_programming/savsched1/savsched1.mps" - ) - data_model_obj = cuopt_mps_parser.ParseMps(file_path) - - settings = solver_settings.SolverSettings() - settings.set_optimality_tolerance(1e-12) - time_limit_seconds = 0.2 - settings.set_parameter(CUOPT_TIME_LIMIT, time_limit_seconds) - # Solver mode isn't what's tested here. - # Set it to Stable2 as CI is more reliable with this mode - settings.set_parameter(CUOPT_PDLP_SOLVER_MODE, PDLPSolverMode.Stable2) - # Setting both to make sure the lowest one is picked - settings.set_parameter(CUOPT_ITERATION_LIMIT, 99999999) - - solution = solver.Solve(data_model_obj, settings) - assert solution.get_termination_status() == LPTerminationStatus.TimeLimit - # Check that around 200 ms has passed with some tolerance - assert solution.get_solve_time() <= (time_limit_seconds * 10) - - def test_set_get_fields(): data_model_obj = data_model.DataModel() @@ -539,71 +473,22 @@ def test_warm_start(): == iterations_first_solve ) - -def test_warm_start_other_problem(): - file_path = RAPIDS_DATASET_ROOT_DIR + "/linear_programming/a2864/a2864.mps" - data_model_obj = cuopt_mps_parser.ParseMps(file_path) - - settings = solver_settings.SolverSettings() - settings.set_parameter(CUOPT_PDLP_SOLVER_MODE, PDLPSolverMode.Stable2) - settings.set_optimality_tolerance(1e-1) - settings.set_parameter(CUOPT_INFEASIBILITY_DETECTION, False) - settings.set_parameter(CUOPT_PRESOLVE, 0) - solution = solver.Solve(data_model_obj, settings) - - file_path = ( - RAPIDS_DATASET_ROOT_DIR + "/linear_programming/afiro_original.mps" - ) - data_model_obj2 = cuopt_mps_parser.ParseMps(file_path) - settings.set_pdlp_warm_start_data(solution.get_pdlp_warm_start_data()) - # Should raise an exception as problems are different - with pytest.raises(Exception): - solver.Solve(data_model_obj2, settings) - - -def test_batch_solver_warm_start(): - data_model_list = [] file_path = ( RAPIDS_DATASET_ROOT_DIR + "/linear_programming/afiro_original.mps" ) + data_model_obj_different = cuopt_mps_parser.ParseMps(file_path) + with pytest.raises(Exception, match="Invalid PDLPWarmStart data"): + solver.Solve(data_model_obj_different, settings) - nb_solves = 2 - - for i in range(nb_solves): - data_model_list.append(cuopt_mps_parser.ParseMps(file_path)) - - settings = solver_settings.SolverSettings() - settings.set_optimality_tolerance(1e-3) - - # Solve a first time to get a warm start - solution = solver.Solve(cuopt_mps_parser.ParseMps(file_path), settings) - - settings.set_pdlp_warm_start_data(solution.get_pdlp_warm_start_data()) - + # Should raise an exception for batch solve # Should raise an exception - with pytest.raises(Exception): + data_model_list = [data_model_obj, data_model_obj] + with pytest.raises(Exception, match="Cannot use warmstart data with Batch Solve"): solver.BatchSolve(data_model_list, settings) -def test_dual_simplex(): - file_path = ( - RAPIDS_DATASET_ROOT_DIR + "/linear_programming/afiro_original.mps" - ) - data_model_obj = cuopt_mps_parser.ParseMps(file_path) - - settings = solver_settings.SolverSettings() - settings.set_parameter(CUOPT_METHOD, SolverMethod.DualSimplex) - settings.set_parameter(CUOPT_DUAL_POSTSOLVE, False) - - solution = solver.Solve(data_model_obj, settings) - - assert solution.get_termination_status() == LPTerminationStatus.Optimal - assert solution.get_primal_objective() == pytest.approx(-464.7531) - assert solution.get_solved_by() == SolverMethod.DualSimplex - - -def test_barrier(): +def test_solved_by(): # maximize 5*xs + 20*xl # subject to 1*xs + 3*xl <= 200 # 3*xs + 2*xl <= 160 @@ -633,6 +518,7 @@ def test_barrier(): solution = solver.Solve(data_model_obj, settings) assert solution.get_termination_reason() == "Optimal" assert solution.get_primal_objective() == pytest.approx(1333.33, 2) + assert solution.get_solved_by() == SolverMethod.Barrier def test_heuristics_only(): @@ -709,6 +595,7 @@ def test_write_files(): settings = solver_settings.SolverSettings() settings.set_parameter(CUOPT_METHOD, SolverMethod.DualSimplex) settings.set_parameter(CUOPT_USER_PROBLEM_FILE, "afiro_out.mps") + settings.set_parameter(CUOPT_SOLUTION_FILE, "afiro.sol") solver.Solve(data_model_obj, settings) @@ -748,44 +635,4 @@ def test_unbounded_problem(): problem.solve(settings) - assert problem.Status.name == "UnboundedOrInfeasible" - - -def test_pdlp_precision_single(): - file_path = ( - RAPIDS_DATASET_ROOT_DIR + "/linear_programming/afiro_original.mps" - ) - data_model_obj = cuopt_mps_parser.ParseMps(file_path) - - settings = solver_settings.SolverSettings() - settings.set_parameter(CUOPT_METHOD, SolverMethod.PDLP) - settings.set_parameter(CUOPT_PDLP_PRECISION, 0) # Single - settings.set_optimality_tolerance(1e-4) - - solution = solver.Solve(data_model_obj, settings) - - assert solution.get_termination_status() == LPTerminationStatus.Optimal - assert solution.get_primal_objective() == pytest.approx( - -464.7531, rel=1e-1 - ) - assert solution.get_solved_by() == SolverMethod.PDLP - - -def test_pdlp_precision_single_crossover(): - file_path = ( - RAPIDS_DATASET_ROOT_DIR + "/linear_programming/afiro_original.mps" - ) - data_model_obj = cuopt_mps_parser.ParseMps(file_path) - - settings = solver_settings.SolverSettings() - settings.set_parameter(CUOPT_METHOD, SolverMethod.PDLP) - settings.set_parameter(CUOPT_PDLP_PRECISION, 1) # Single - settings.set_parameter("crossover", True) - settings.set_optimality_tolerance(1e-4) - - solution = solver.Solve(data_model_obj, settings) - - assert solution.get_termination_status() == LPTerminationStatus.Optimal - assert solution.get_primal_objective() == pytest.approx( - -464.7531, rel=1e-1 - ) + assert problem.Status.name == "UnboundedOrInfeasible" \ No newline at end of file diff --git a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py index 467d714fee..fc9fbc382d 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py @@ -478,237 +478,6 @@ def test_problem_update(): assert prob.ObjValue == pytest.approx(5) -@pytest.mark.parametrize( - "test_name,settings_config", - [ - ( - "automatic", - { - CUOPT_FOLDING: -1, - CUOPT_DUALIZE: -1, - CUOPT_ORDERING: -1, - CUOPT_AUGMENTED: -1, - }, - ), - ( - "forced_on", - { - CUOPT_FOLDING: 1, - CUOPT_DUALIZE: 1, - CUOPT_ORDERING: 1, - CUOPT_AUGMENTED: 1, - CUOPT_ELIMINATE_DENSE_COLUMNS: True, - CUOPT_CUDSS_DETERMINISTIC: True, - }, - ), - ( - "disabled", - { - CUOPT_FOLDING: 0, - CUOPT_DUALIZE: 0, - CUOPT_ORDERING: 0, - CUOPT_AUGMENTED: 0, - CUOPT_ELIMINATE_DENSE_COLUMNS: False, - CUOPT_CUDSS_DETERMINISTIC: False, - }, - ), - pytest.param( - "mixed", - { - CUOPT_FOLDING: 1, - CUOPT_DUALIZE: 0, - CUOPT_ORDERING: -1, - CUOPT_AUGMENTED: 1, - }, - marks=pytest.mark.skip( - reason="Barrier augmented-system numerical issue; re-enable when barrier initial-point fix is in the build" - ), - ), - ( - "folding_on", - { - CUOPT_FOLDING: 1, - }, - ), - ( - "folding_off", - { - CUOPT_FOLDING: 0, - }, - ), - ( - "dualize_on", - { - CUOPT_DUALIZE: 1, - }, - ), - ( - "dualize_off", - { - CUOPT_DUALIZE: 0, - }, - ), - ( - "amd_ordering", - { - CUOPT_ORDERING: 1, - }, - ), - ( - "cudss_ordering", - { - CUOPT_ORDERING: 0, - }, - ), - pytest.param( - "augmented_system", - { - CUOPT_AUGMENTED: 1, - }, - marks=pytest.mark.skip( - reason="Barrier augmented-system numerical issue; re-enable when barrier initial-point fix is in the build" - ), - ), - ( - "adat_system", - { - CUOPT_AUGMENTED: 0, - }, - ), - ( - "no_dense_elim", - { - CUOPT_ELIMINATE_DENSE_COLUMNS: False, - }, - ), - ( - "cudss_deterministic", - { - CUOPT_CUDSS_DETERMINISTIC: True, - }, - ), - ( - "combo1", - { - CUOPT_FOLDING: 1, - CUOPT_DUALIZE: 1, - CUOPT_ORDERING: 1, - }, - ), - ( - "combo2", - { - CUOPT_FOLDING: 0, - CUOPT_AUGMENTED: 0, - CUOPT_ELIMINATE_DENSE_COLUMNS: False, - }, - ), - ( - "dual_initial_point_automatic", - { - CUOPT_BARRIER_DUAL_INITIAL_POINT: -1, - }, - ), - ( - "dual_initial_point_lustig", - { - CUOPT_BARRIER_DUAL_INITIAL_POINT: 0, - }, - ), - ( - "dual_initial_point_least_squares", - { - CUOPT_BARRIER_DUAL_INITIAL_POINT: 1, - }, - ), - pytest.param( - "combo3_with_dual_init", - { - CUOPT_AUGMENTED: 1, - CUOPT_BARRIER_DUAL_INITIAL_POINT: 1, - CUOPT_ELIMINATE_DENSE_COLUMNS: True, - }, - marks=pytest.mark.skip( - reason="Barrier augmented-system numerical issue; re-enable when barrier initial-point fix is in the build" - ), - ), - ], -) -def test_barrier_solver_settings(test_name, settings_config): - """ - Parameterized test for barrier solver with different configurations. - - Tests the barrier solver across various settings combinations to ensure - correctness and robustness. Each configuration tests different aspects - of the barrier solver implementation. - - Problem: - maximize 5*xs + 20*xl - subject to 1*xs + 3*xl <= 200 - 3*xs + 2*xl <= 160 - xs, xl >= 0 - - Expected Solution: - Optimal objective: 1333.33 - xs = 0, xl = 66.67 (corner solution where constraint 1 is binding) - - Args - ---- - test_name: Descriptive name for the test configuration - settings_config: Dictionary of barrier solver parameters to set - """ - prob = Problem(f"Barrier Test - {test_name}") - - # Add variables - xs = prob.addVariable(lb=0, vtype=VType.CONTINUOUS, name="xs") - xl = prob.addVariable(lb=0, vtype=VType.CONTINUOUS, name="xl") - - # Add constraints - prob.addConstraint(xs + 3 * xl <= 200, name="constraint1") - prob.addConstraint(3 * xs + 2 * xl <= 160, name="constraint2") - - # Set objective: maximize 5*xs + 20*xl - prob.setObjective(5 * xs + 20 * xl, sense=MAXIMIZE) - - # Configure solver settings - settings = SolverSettings() - settings.set_parameter(CUOPT_METHOD, SolverMethod.Barrier) - settings.set_parameter("time_limit", 10) - - # Apply test-specific settings - for param_name, param_value in settings_config.items(): - settings.set_parameter(param_name, param_value) - - print(f"\nTesting configuration: {test_name}") - print(f"Settings: {settings_config}") - - # Solve the problem - prob.solve(settings) - - print(f"Status: {prob.Status.name}") - print(f"Objective: {prob.ObjValue}") - print(f"xs = {xs.Value}, xl = {xl.Value}") - - # Verify solution - assert prob.solved, f"Problem not solved for {test_name}" - assert prob.Status.name == "Optimal", f"Not optimal for {test_name}" - assert prob.ObjValue == pytest.approx(1333.33, rel=0.01), ( - f"Incorrect objective for {test_name}" - ) - assert xs.Value == pytest.approx(0.0, abs=1e-4), ( - f"Incorrect xs value for {test_name}" - ) - assert xl.Value == pytest.approx(66.67, rel=0.01), ( - f"Incorrect xl value for {test_name}" - ) - - # Verify constraint slacks are non-negative - for c in prob.getConstraints(): - assert c.Slack >= -1e-6, ( - f"Negative slack for {c.getConstraintName()} in {test_name}" - ) - - def test_quadratic_expression_and_matrix(): problem = Problem() x = problem.addVariable(lb=9.0, vtype="I", name="x") @@ -968,74 +737,4 @@ def test_quadratic_matrix_2(): assert x1.getValue() == pytest.approx(0.2295081, abs=1e-3) assert x2.getValue() == pytest.approx(0.0000000, abs=1e-3) assert x3.getValue() == pytest.approx(0.1092896, abs=1e-3) - assert problem.ObjValue == pytest.approx(3.715847, abs=1e-3) - - -def test_cuts(): - # Minimize - 86*y1 - 4*y2 - 40*y3 - # subject to 774*y1 + 76*y2 + 42*y3 <= 875 - # 67*y1 + 27*y2 + 53*y3 <= 875 - # y1, y2, y3 in {0, 1} - - problem = Problem() - y1 = problem.addVariable(lb=0, ub=1, vtype=INTEGER, name="y1") - y2 = problem.addVariable(lb=0, ub=1, vtype=INTEGER, name="y2") - y3 = problem.addVariable(lb=0, ub=1, vtype=INTEGER, name="y3") - - problem.addConstraint(774 * y1 + 76 * y2 + 42 * y3 <= 875) - problem.addConstraint(67 * y1 + 27 * y2 + 53 * y3 <= 875) - - problem.setObjective(-86 * y1 - 4 * y2 - 40 * y3) - - # Set Solver Settings - settings = SolverSettings() - settings.set_parameter(CUOPT_PRESOLVE, 0) - settings.set_parameter(CUOPT_TIME_LIMIT, 1) - settings.set_parameter(CUOPT_MIP_CUT_PASSES, 0) - - # Solve - problem.solve(settings) - assert problem.Status.name == "Optimal" - assert problem.SolutionStats.num_nodes > 0 - - # Update Solver Settings - settings.set_parameter(CUOPT_MIP_CUT_PASSES, 10) - - # Solve - problem.solve(settings) - - assert problem.Status.name == "Optimal" - assert problem.ObjValue == pytest.approx(-126, abs=1e-3) - assert problem.SolutionStats.num_nodes == 0 - - -def test_batch_pdlp_strong_branching(): - # Minimize - 86*y1 - 4*y2 - 40*y3 - # subject to 774*y1 + 76*y2 + 42*y3 <= 875 - # 67*y1 + 27*y2 + 53*y3 <= 875 - # y1, y2, y3 in {0, 1} - - problem = Problem() - y1 = problem.addVariable(lb=0, ub=1, vtype=INTEGER, name="y1") - y2 = problem.addVariable(lb=0, ub=1, vtype=INTEGER, name="y2") - y3 = problem.addVariable(lb=0, ub=1, vtype=INTEGER, name="y3") - - problem.addConstraint(774 * y1 + 76 * y2 + 42 * y3 <= 875) - problem.addConstraint(67 * y1 + 27 * y2 + 53 * y3 <= 875) - - problem.setObjective(-86 * y1 - 4 * y2 - 40 * y3) - - settings = SolverSettings() - settings.set_parameter(CUOPT_PRESOLVE, 0) - settings.set_parameter(CUOPT_TIME_LIMIT, 10) - settings.set_parameter(CUOPT_MIP_BATCH_PDLP_STRONG_BRANCHING, 0) - - problem.solve(settings) - assert problem.Status.name == "Optimal" - assert problem.ObjValue == pytest.approx(-126, abs=1e-3) - - settings.set_parameter(CUOPT_MIP_BATCH_PDLP_STRONG_BRANCHING, 1) - - problem.solve(settings) - assert problem.Status.name == "Optimal" - assert problem.ObjValue == pytest.approx(-126, abs=1e-3) + assert problem.ObjValue == pytest.approx(3.715847, abs=1e-3) \ No newline at end of file From f2791df831942a507df37646cc4ea62a1b3003b6 Mon Sep 17 00:00:00 2001 From: Ishika Roy Date: Wed, 15 Apr 2026 20:59:07 -0700 Subject: [PATCH 2/3] update testing solver settings from c++ --- .../solver_settings/solver_settings.pxd | 8 ++ .../solver_settings/solver_settings.pyx | 45 +++++++ .../linear_programming/test_lp_solver.py | 125 +++++++++++++++++- 3 files changed, 177 insertions(+), 1 deletion(-) diff --git a/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pxd b/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pxd index 88fa48b239..0c21ca9751 100644 --- a/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pxd +++ b/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pxd @@ -8,6 +8,7 @@ # cython: language_level = 3 from libcpp.memory cimport unique_ptr +from libcpp cimport bool from libcpp.string cimport string from libcpp.vector cimport vector @@ -82,6 +83,13 @@ cdef extern from "cuopt/linear_programming/solver_settings.hpp" namespace "cuopt void* user_data ) except + + bool dump_parameters_to_file( + const string& path, + bool hyperparameters_only, + ) except + + + void load_parameters_from_file(const string& path) except + + cdef class SolverSettings: cdef unique_ptr[solver_settings_t[int, double]] c_solver_settings diff --git a/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pyx b/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pyx index 5813719d0e..ec5f94aafc 100644 --- a/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pyx +++ b/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pyx @@ -435,6 +435,51 @@ cdef class SolverSettings: warm_start_data.iterations_since_last_restart # noqa ) + def dump_parameters_to_file(self, path, hyperparameters_only=True): + """Apply ``settings_dict`` / warm start to C++, then dump parameters to *path*. + + Calls :meth:`set_c_solver_settings` then the C++ ``solver_settings_t::dump_parameters_to_file``. + + Parameters + ---------- + path : str + Output path (e.g. file path or ``/dev/stdout``). + hyperparameters_only : bool, optional + Forwarded to C++; when ``True``, dump hyperparameter subset only. + + Returns + ------- + bool + ``True`` if the C++ layer reports success. + """ + self.set_c_solver_settings() + cdef solver_settings_t[int, double]* c_ss = self.c_solver_settings.get() + cdef string c_path = path.encode("utf-8") + return c_ss.dump_parameters_to_file(c_path, hyperparameters_only) + + def load_parameters_from_file(self, path): + """Load parameters from a cuOpt config file into the C++ settings object. + + After a successful load, :attr:`settings_dict` is refreshed from C++ + for every name in :data:`solver_params` so :meth:`get_parameter` matches + the loaded state. + + Parameters + ---------- + path : str + Path to a parameter file with ``name = value`` lines (see C++ + ``solver_settings_t::load_parameters_from_file``). + """ + cdef solver_settings_t[int, double]* c_ss = self.c_solver_settings.get() + cdef string c_path = path.encode("utf-8") + cdef string c_name + cdef string c_val + c_ss.load_parameters_from_file(c_path) + for name in solver_params: + c_name = name.encode("utf-8") + c_val = c_ss.get_parameter_as_string(c_name) + self.settings_dict[name] = self.to_base_type(c_val.decode("utf-8")) + def toDict(self): solver_config = {} solver_config["tolerances"] = {} diff --git a/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py b/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py index fd00a9126b..cdcc85c654 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py @@ -1,7 +1,9 @@ # SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +import math import os +from enum import IntEnum import cuopt_mps_parser import numpy as np @@ -13,6 +15,7 @@ solver_settings, ) from cuopt.linear_programming.solver.solver_parameters import ( + solver_params, CUOPT_ABSOLUTE_DUAL_TOLERANCE, CUOPT_ABSOLUTE_GAP_TOLERANCE, CUOPT_ABSOLUTE_PRIMAL_TOLERANCE, @@ -196,7 +199,86 @@ def test_set_get_fields(): assert data_model_obj.get_sense() -def test_solver_settings(): +def _cuopt_commented_dump_to_loadable(src_path, dst_path, param_names): + """Turn ``dump_parameters_to_file`` output (commented ``# name = value``) into a loadable file. + + C++ ``load_parameters_from_file`` skips commented lines; the dump format is + intentionally commented, so tests strip the leading ``#`` for assignment lines. + """ + names_by_len = sorted(param_names, key=len, reverse=True) + with open(src_path, encoding="utf-8") as src, open(dst_path, "w", encoding="utf-8") as dst: + for line in src: + stripped = line.strip() + for name in names_by_len: + prefix = f"# {name} = " + if stripped.startswith(prefix): + dst.write(f"{name} = {stripped[len(prefix):]}\n") + break + + +def _assert_solver_param_equal(name, expected, got): + """Compare get_parameter values across dump/load; enums and floats may differ in type.""" + exp = int(expected) if isinstance(expected, IntEnum) else expected + g = int(got) if isinstance(got, IntEnum) else got + if isinstance(exp, float) and isinstance(g, float): + if math.isnan(exp) and math.isnan(g): + return + assert g == pytest.approx(exp), (name, got, expected) + else: + assert g == exp, (name, got, expected) + + +def _non_default_solver_param_value(name, current): + """Pick a valid but non-default value for each registered solver parameter.""" + if name.startswith("mip_hyper"): + return current + if name in ("user_problem_file", "solution_file"): + return "" + if name == "time_limit": + return 3600.0 + if name == "iteration_limit": + return 9_999_999 + if name == "method": + cur_i = int(current) if current is not None else -1 + return ( + SolverMethod.DualSimplex + if cur_i != int(SolverMethod.DualSimplex) + else SolverMethod.PDLP + ) + if name == "pdlp_solver_mode": + cur_i = int(current) if current is not None else -1 + return ( + PDLPSolverMode.Fast1 + if cur_i != int(PDLPSolverMode.Fast1) + else PDLPSolverMode.Stable2 + ) + if name == "presolve": + return 0 if int(current) == 1 else 1 + if name == "pdlp_precision": + return 1 if int(current) == 0 else 0 + if isinstance(current, bool): + return not current + if isinstance(current, str): + low = current.lower() + if low == "true": + return False + if low == "false": + return True + return current + "_x" if current else "1" + if isinstance(current, float): + if not math.isfinite(current): + return 7200.0 + if abs(current) < 1e-30: + return 1e-2 + return current * 2.0 if abs(current) < 1.0 else current * 0.5 + if isinstance(current, int): + if int(current) > 1: + return int(current) - 1 + return int(current) + 1 + return current + + +def test_solver_settings_basic(): settings = solver_settings.SolverSettings() tolerance_value = 1e-5 @@ -251,6 +333,47 @@ def test_solver_settings(): ) +def test_solver_settings(tmp_path): + """Push every registered parameter to the C++ layer via set_c_solver_settings.""" + settings = solver_settings.SolverSettings() + for name in list(set(solver_params)): + current = settings.get_parameter(name) + new_val = _non_default_solver_param_value(name, current) + settings.set_parameter(name, new_val) + + expected_by_name = {name: settings.get_parameter(name) for name in solver_params} + + dump_path = tmp_path / "solver_settings_dump.config" + load_path = tmp_path / "solver_settings_load.config" + assert settings.dump_parameters_to_file( + str(dump_path), hyperparameters_only=False + ) + _cuopt_commented_dump_to_loadable( + str(dump_path), str(load_path), solver_params + ) + reloaded = solver_settings.SolverSettings() + reloaded.load_parameters_from_file(str(load_path)) + for name in solver_params: + _assert_solver_param_equal( + name, + expected_by_name[name], + reloaded.get_parameter(name), + ) + + settings.set_c_solver_settings() + data_model_obj = data_model.DataModel() + A_values = np.array([1.0, 1.0]) + A_indices = np.array([0, 0]) + A_offsets = np.array([0, 1, 2]) + data_model_obj.set_csr_constraint_matrix(A_values, A_indices, A_offsets) + data_model_obj.set_constraint_bounds(np.array([1.0, 1.0])) + data_model_obj.set_objective_coefficients(np.array([1.0])) + data_model_obj.set_row_types(np.array(["L", "L"])) + + solution = solver.Solve(data_model_obj, settings) + assert solution.get_termination_reason() == "Optimal" + + def test_check_data_model_validity(): data_model_obj = data_model.DataModel() From 8d26500ed4627afe5c043909811ad4ea3d3305dc Mon Sep 17 00:00:00 2001 From: Ishika Roy Date: Mon, 27 Apr 2026 20:00:19 -0700 Subject: [PATCH 3/3] address review comments --- .../solver/solver_parameters.py | 19 ++++++++++---- .../solver_settings/__init__.py | 12 ++++++--- .../solver_settings/solver_settings.pyx | 20 ++++++++++++--- .../linear_programming/test_lp_solver.py | 25 ++++++++++++------- .../linear_programming/test_python_API.py | 12 +-------- 5 files changed, 56 insertions(+), 32 deletions(-) diff --git a/python/cuopt/cuopt/linear_programming/solver/solver_parameters.py b/python/cuopt/cuopt/linear_programming/solver/solver_parameters.py index daed3a28c7..ad749d9a30 100644 --- a/python/cuopt/cuopt/linear_programming/solver/solver_parameters.py +++ b/python/cuopt/cuopt/linear_programming/solver/solver_parameters.py @@ -1,7 +1,7 @@ -# SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Backward-compatible module; LP parameter helpers live in solver_settings.""" +"""Backward-compatible module; LP parameter helpers live in ``solver_settings``.""" from cuopt.linear_programming.solver_settings.solver_settings import ( get_solver_parameter_names, @@ -11,6 +11,15 @@ import cuopt.linear_programming.solver_settings.solver_settings as _solver_settings_ext -for _name in dir(_solver_settings_ext): - if _name.startswith("CUOPT_"): - globals()[_name] = getattr(_solver_settings_ext, _name) +_cuopt_constant_names = tuple( + f"CUOPT_{p.upper()}" for p in _solver_settings_ext.solver_params +) +for _name in _cuopt_constant_names: + globals()[_name] = getattr(_solver_settings_ext, _name) + +__all__ = ( + "get_solver_parameter_names", + "get_solver_setting", + "solver_params", + *_cuopt_constant_names, +) diff --git a/python/cuopt/cuopt/linear_programming/solver_settings/__init__.py b/python/cuopt/cuopt/linear_programming/solver_settings/__init__.py index 51482e4dd7..7d984489f2 100644 --- a/python/cuopt/cuopt/linear_programming/solver_settings/__init__.py +++ b/python/cuopt/cuopt/linear_programming/solver_settings/__init__.py @@ -1,7 +1,11 @@ -# SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""LP/MIP solver settings package; implementation is in the ``solver_settings`` extension.""" +"""LP/MIP solver settings package; implementation is in the ``solver_settings`` extension. + +Public ``solver_params`` is an immutable tuple so callers cannot mutate the extension +module's internal parameter-name list used for validation. +""" from .solver_settings import ( PDLPSolverMode, @@ -9,9 +13,11 @@ SolverSettings, get_solver_parameter_names, get_solver_setting, - solver_params, + solver_params as _solver_params_list, ) +solver_params = tuple(_solver_params_list) + __all__ = [ "PDLPSolverMode", "SolverMethod", diff --git a/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pyx b/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pyx index ec5f94aafc..f7f394f54e 100644 --- a/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pyx +++ b/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.pyx @@ -1,12 +1,15 @@ -# SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa # SPDX-License-Identifier: Apache-2.0 - # cython: profile=False # distutils: language = c++ # cython: embedsignature = True # cython: language_level = 3 +"""Cython extension: LP/MIP ``SolverSettings`` backed by ``solver_settings_t``.""" + +from enum import IntEnum, auto + from libcpp.memory cimport unique_ptr from libc.stdint cimport uintptr_t from libcpp.string cimport string @@ -14,6 +17,7 @@ from libcpp.vector cimport vector def get_solver_setting(name): + """Return the default string form of solver parameter *name* from a fresh C++ settings object.""" cdef unique_ptr[solver_settings_t[int, double]] unique_solver_settings unique_solver_settings.reset(new solver_settings_t[int, double]()) @@ -27,6 +31,7 @@ def get_solver_setting(name): cpdef get_solver_parameter_names(): + """Return all registered solver parameter names (same order as the C++ layer).""" cdef unique_ptr[solver_settings_t[int, double]] unique_solver_settings unique_solver_settings.reset(new solver_settings_t[int, double]()) cdef solver_settings_t[int, double]* c_solver_settings = ( @@ -45,8 +50,6 @@ cpdef get_solver_parameter_names(): solver_params = get_solver_parameter_names() for param in solver_params: globals()["CUOPT_"+param.upper()] = param -from enum import IntEnum, auto - class SolverMethod(IntEnum): """ @@ -342,6 +345,15 @@ cdef class SolverSettings: return self.pdlp_warm_start_data def set_c_solver_settings(self): + """Push Python-side state into the C++ ``solver_settings_t`` object. + + ``Solve`` / ``BatchSolve`` reset ``c_solver_settings`` to a new C++ instance + and call this method immediately after, so ``settings_dict``, + ``pdlp_warm_start_data``, and ``mip_callbacks`` are the source of truth for + each solve. Any direct mutation of the C++ object alone would be discarded + on the next solve. :meth:`load_parameters_from_file` mirrors loaded values + back into ``settings_dict`` so this contract stays consistent. + """ # All cdef declarations must precede other statements in this function. cdef solver_settings_t[int, double]* c_solver_settings cdef uintptr_t c_current_primal_solution diff --git a/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py b/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py index cdcc85c654..54b491d7f8 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py @@ -20,12 +20,10 @@ CUOPT_ABSOLUTE_GAP_TOLERANCE, CUOPT_ABSOLUTE_PRIMAL_TOLERANCE, CUOPT_DUAL_INFEASIBLE_TOLERANCE, - CUOPT_DUAL_POSTSOLVE, CUOPT_INFEASIBILITY_DETECTION, CUOPT_ITERATION_LIMIT, CUOPT_METHOD, CUOPT_MIP_HEURISTICS_ONLY, - CUOPT_PDLP_PRECISION, CUOPT_PDLP_SOLVER_MODE, CUOPT_PRIMAL_INFEASIBLE_TOLERANCE, CUOPT_RELATIVE_DUAL_TOLERANCE, @@ -206,13 +204,16 @@ def _cuopt_commented_dump_to_loadable(src_path, dst_path, param_names): intentionally commented, so tests strip the leading ``#`` for assignment lines. """ names_by_len = sorted(param_names, key=len, reverse=True) - with open(src_path, encoding="utf-8") as src, open(dst_path, "w", encoding="utf-8") as dst: + with ( + open(src_path, encoding="utf-8") as src, + open(dst_path, "w", encoding="utf-8") as dst, + ): for line in src: stripped = line.strip() for name in names_by_len: prefix = f"# {name} = " if stripped.startswith(prefix): - dst.write(f"{name} = {stripped[len(prefix):]}\n") + dst.write(f"{name} = {stripped[len(prefix) :]}\n") break @@ -341,7 +342,9 @@ def test_solver_settings(tmp_path): new_val = _non_default_solver_param_value(name, current) settings.set_parameter(name, new_val) - expected_by_name = {name: settings.get_parameter(name) for name in solver_params} + expected_by_name = { + name: settings.get_parameter(name) for name in solver_params + } dump_path = tmp_path / "solver_settings_dump.config" load_path = tmp_path / "solver_settings_load.config" @@ -360,7 +363,7 @@ def test_solver_settings(tmp_path): reloaded.get_parameter(name), ) - settings.set_c_solver_settings() + reloaded.set_c_solver_settings() data_model_obj = data_model.DataModel() A_values = np.array([1.0, 1.0]) A_indices = np.array([0, 0]) @@ -370,8 +373,10 @@ def test_solver_settings(tmp_path): data_model_obj.set_objective_coefficients(np.array([1.0])) data_model_obj.set_row_types(np.array(["L", "L"])) - solution = solver.Solve(data_model_obj, settings) + solution = solver.Solve(data_model_obj, reloaded) assert solution.get_termination_reason() == "Optimal" + assert solution.get_primal_objective() == pytest.approx(0.0) + assert solution.get_primal_solution()[0] == pytest.approx(0.0) def test_check_data_model_validity(): @@ -607,7 +612,9 @@ def test_warm_start(): # Should raise an exception for batch solve # Should raise an exception data_model_list = [data_model_obj, data_model_obj] - with pytest.raises(Exception, match="Cannot use warmstart data with Batch Solve"): + with pytest.raises( + Exception, match="Cannot use warmstart data with Batch Solve" + ): solver.BatchSolve(data_model_list, settings) @@ -758,4 +765,4 @@ def test_unbounded_problem(): problem.solve(settings) - assert problem.Status.name == "UnboundedOrInfeasible" \ No newline at end of file + assert problem.Status.name == "UnboundedOrInfeasible" diff --git a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py index fc9fbc382d..863994ad2e 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py @@ -23,20 +23,10 @@ QuadraticExpression, ) from cuopt.linear_programming.solver.solver_parameters import ( - CUOPT_AUGMENTED, - CUOPT_BARRIER_DUAL_INITIAL_POINT, - CUOPT_CUDSS_DETERMINISTIC, - CUOPT_DUALIZE, - CUOPT_ELIMINATE_DENSE_COLUMNS, - CUOPT_FOLDING, CUOPT_INFEASIBILITY_DETECTION, - CUOPT_MIP_BATCH_PDLP_STRONG_BRANCHING, - CUOPT_MIP_CUT_PASSES, CUOPT_METHOD, - CUOPT_ORDERING, CUOPT_PDLP_SOLVER_MODE, CUOPT_PRESOLVE, - CUOPT_TIME_LIMIT, ) from cuopt.linear_programming.solver_settings import ( PDLPSolverMode, @@ -737,4 +727,4 @@ def test_quadratic_matrix_2(): assert x1.getValue() == pytest.approx(0.2295081, abs=1e-3) assert x2.getValue() == pytest.approx(0.0000000, abs=1e-3) assert x3.getValue() == pytest.approx(0.1092896, abs=1e-3) - assert problem.ObjValue == pytest.approx(3.715847, abs=1e-3) \ No newline at end of file + assert problem.ObjValue == pytest.approx(3.715847, abs=1e-3)