diff --git a/ci/run_cuopt_pytests.sh b/ci/run_cuopt_pytests.sh index c074737d2..66e996715 100755 --- a/ci/run_cuopt_pytests.sh +++ b/ci/run_cuopt_pytests.sh @@ -1,5 +1,5 @@ #!/bin/bash -# 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 set -euo pipefail @@ -9,4 +9,4 @@ set -euo pipefail # Support invoking run_cuopt_pytests.sh outside the script directory cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")"/../python/cuopt/cuopt/ -pytest --cache-clear "$@" tests +pytest -s --cache-clear "$@" tests diff --git a/ci/run_cuopt_server_pytests.sh b/ci/run_cuopt_server_pytests.sh index 4ffd21e0f..4cb361a47 100755 --- a/ci/run_cuopt_server_pytests.sh +++ b/ci/run_cuopt_server_pytests.sh @@ -1,5 +1,5 @@ #!/bin/bash -# 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 set -euo pipefail @@ -9,4 +9,4 @@ set -euo pipefail # Support invoking run_cuopt_server_pytests.sh outside the script directory cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")"/../python/cuopt_server/cuopt_server/ -pytest --cache-clear "$@" tests +pytest -s --cache-clear "$@" tests diff --git a/conda/recipes/libcuopt/recipe.yaml b/conda/recipes/libcuopt/recipe.yaml index b4cccd714..279140edf 100644 --- a/conda/recipes/libcuopt/recipe.yaml +++ b/conda/recipes/libcuopt/recipe.yaml @@ -36,7 +36,7 @@ cache: - AWS_SESSION_TOKEN env: # Enable assertions (-a flag) for PR builds, but not for nightly or release builds - BUILD_EXTRA_FLAGS: '${{ "-a" if build_type == "pull-request" else "" }}' + BUILD_EXTRA_FLAGS: '${{ "-a --host-lineinfo" if build_type == "pull-request" else "" }}' CMAKE_C_COMPILER_LAUNCHER: ${{ env.get("CMAKE_C_COMPILER_LAUNCHER") }} CMAKE_CUDA_COMPILER_LAUNCHER: ${{ env.get("CMAKE_CUDA_COMPILER_LAUNCHER") }} CMAKE_CXX_COMPILER_LAUNCHER: ${{ env.get("CMAKE_CXX_COMPILER_LAUNCHER") }} diff --git a/cpp/include/cuopt/linear_programming/cuopt_c.h b/cpp/include/cuopt/linear_programming/cuopt_c.h index c26d9905a..4c4d44c76 100644 --- a/cpp/include/cuopt/linear_programming/cuopt_c.h +++ b/cpp/include/cuopt/linear_programming/cuopt_c.h @@ -695,6 +695,76 @@ cuopt_int_t cuOptGetFloatParameter(cuOptSolverSettings settings, const char* parameter_name, cuopt_float_t* parameter_value); +/** + * @brief Type of callback for receiving incumbent MIP solutions with user context. + * + * @param[in] solution - Pointer to incumbent solution values. + * The allocated array for solution pointer must be at least the number of variables in the original + * problem. + * @param[in] objective_value - Pointer to incumbent objective value. + * @param[in] solution_bound - Pointer to current solution (dual/user) bound. + * @param[in] user_data - Pointer to user data. + * @note All pointer arguments (solution, objective_value, solution_bound, user_data) refer to host + * memory and are only valid during the callback invocation. Do not pass device/GPU pointers. + * Copy any data you need to keep after the callback returns. + */ +typedef void (*cuOptMIPGetSolutionCallback)(const cuopt_float_t* solution, + const cuopt_float_t* objective_value, + const cuopt_float_t* solution_bound, + void* user_data); + +/** + * @brief Type of callback for injecting MIP solutions with user context. + * + * @param[out] solution - Pointer to solution values to set. + * The allocated array for solution pointer must be at least the number of variables in the original + * problem. + * @param[out] objective_value - Pointer to objective value to set. + * @param[in] solution_bound - Pointer to current solution (dual/user) bound. + * @param[in] user_data - Pointer to user data. + * @note All pointer arguments (solution, objective_value, solution_bound, user_data) refer to host + * memory and are only valid during the callback invocation. Do not pass device/GPU pointers. + * Copy any data you need to keep after the callback returns. + */ +typedef void (*cuOptMIPSetSolutionCallback)(cuopt_float_t* solution, + cuopt_float_t* objective_value, + const cuopt_float_t* solution_bound, + void* user_data); + +/** + * @brief Register a callback to receive incumbent MIP solutions. + * + * @param[in] settings - The solver settings object. + * @param[in] callback - Callback function to receive incumbent solutions. + * @param[in] user_data - User-defined pointer passed through to the callback. + * It will be forwarded to ``cuOptMIPGetSolutionCallback`` when invoked. + * @note The callback arguments refer to host memory and are only valid during the callback + * invocation. Do not pass device/GPU pointers. Copy any data you need to keep after the callback + * returns. + * + * @return A status code indicating success or failure. + */ +cuopt_int_t cuOptSetMIPGetSolutionCallback(cuOptSolverSettings settings, + cuOptMIPGetSolutionCallback callback, + void* user_data); + +/** + * @brief Register a callback to inject MIP solutions. + * + * @param[in] settings - The solver settings object. + * @param[in] callback - Callback function to inject solutions. + * @param[in] user_data - User-defined pointer passed through to the callback. + * It will be forwarded to ``cuOptMIPSetSolutionCallback`` when invoked. + * @note Registering a set-solution callback disables presolve. + * @note The callback arguments refer to host memory and are only valid during the callback + * invocation. Do not pass device/GPU pointers. Copy any data you need to keep after the callback + * returns. + * + * @return A status code indicating success or failure. + */ +cuopt_int_t cuOptSetMIPSetSolutionCallback(cuOptSolverSettings settings, + cuOptMIPSetSolutionCallback callback, + void* user_data); /** * @brief Set the initial primal solution for an LP solve. * diff --git a/cpp/include/cuopt/linear_programming/mip/solver_settings.hpp b/cpp/include/cuopt/linear_programming/mip/solver_settings.hpp index 1827e1101..326d7f76a 100644 --- a/cpp/include/cuopt/linear_programming/mip/solver_settings.hpp +++ b/cpp/include/cuopt/linear_programming/mip/solver_settings.hpp @@ -37,8 +37,12 @@ class mip_solver_settings_t { /** * @brief Set the callback for the user solution + * + * @param[in] callback - Callback handler for user solutions. + * @param[in] user_data - Pointer to user-defined data forwarded to the callback. */ - void set_mip_callback(internals::base_solution_callback_t* callback = nullptr); + void set_mip_callback(internals::base_solution_callback_t* callback = nullptr, + void* user_data = nullptr); /** * @brief Add an primal solution. @@ -91,7 +95,7 @@ class mip_solver_settings_t { /** Initial primal solutions */ std::vector>> initial_solutions; - bool mip_scaling = true; + bool mip_scaling = false; bool presolve = true; // this is for extracting info from different places of the solver during // benchmarks diff --git a/cpp/include/cuopt/linear_programming/mip/solver_stats.hpp b/cpp/include/cuopt/linear_programming/mip/solver_stats.hpp index d92b83594..a546354a6 100644 --- a/cpp/include/cuopt/linear_programming/mip/solver_stats.hpp +++ b/cpp/include/cuopt/linear_programming/mip/solver_stats.hpp @@ -1,17 +1,41 @@ /* clang-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 */ /* clang-format on */ #pragma once + +#include +#include namespace cuopt::linear_programming { template struct solver_stats_t { - f_t total_solve_time = 0.; - f_t presolve_time = 0.; - f_t solution_bound = std::numeric_limits::min(); + // Direction-neutral placeholder; solver_context initializes based on maximize/minimize. + solver_stats_t() : solution_bound(std::numeric_limits::infinity()) {} + + solver_stats_t(const solver_stats_t& other) { *this = other; } + + solver_stats_t& operator=(const solver_stats_t& other) + { + if (this == &other) { return *this; } + total_solve_time = other.total_solve_time; + presolve_time = other.presolve_time; + solution_bound.store(other.solution_bound.load(std::memory_order_relaxed), + std::memory_order_relaxed); + num_nodes = other.num_nodes; + num_simplex_iterations = other.num_simplex_iterations; + return *this; + } + + f_t get_solution_bound() const { return solution_bound.load(std::memory_order_relaxed); } + + void set_solution_bound(f_t value) { solution_bound.store(value, std::memory_order_relaxed); } + + f_t total_solve_time = 0.; + f_t presolve_time = 0.; + std::atomic solution_bound; i_t num_nodes = 0; i_t num_simplex_iterations = 0; }; diff --git a/cpp/include/cuopt/linear_programming/solver_settings.hpp b/cpp/include/cuopt/linear_programming/solver_settings.hpp index 180293254..dd910a8f4 100644 --- a/cpp/include/cuopt/linear_programming/solver_settings.hpp +++ b/cpp/include/cuopt/linear_programming/solver_settings.hpp @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2023-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 */ /* clang-format on */ @@ -81,7 +81,8 @@ class solver_settings_t { void add_initial_mip_solution(const f_t* initial_solution, i_t size, rmm::cuda_stream_view stream = rmm::cuda_stream_default); - void set_mip_callback(internals::base_solution_callback_t* callback = nullptr); + void set_mip_callback(internals::base_solution_callback_t* callback = nullptr, + void* user_data = nullptr); const pdlp_warm_start_data_view_t& get_pdlp_warm_start_data_view() const noexcept; const std::vector get_mip_callbacks() const; diff --git a/cpp/include/cuopt/linear_programming/utilities/callbacks_implems.hpp b/cpp/include/cuopt/linear_programming/utilities/callbacks_implems.hpp index f0cd74c24..e13fda2ba 100644 --- a/cpp/include/cuopt/linear_programming/utilities/callbacks_implems.hpp +++ b/cpp/include/cuopt/linear_programming/utilities/callbacks_implems.hpp @@ -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 */ @@ -17,17 +17,6 @@ namespace internals { class default_get_solution_callback_t : public get_solution_callback_t { public: - PyObject* get_numba_matrix(void* data, std::size_t size) - { - PyObject* pycl = (PyObject*)this->pyCallbackClass; - - if (isFloat) { - return PyObject_CallMethod(pycl, "get_numba_matrix", "(lls)", data, size, "float32"); - } else { - return PyObject_CallMethod(pycl, "get_numba_matrix", "(lls)", data, size, "float64"); - } - } - PyObject* get_numpy_array(void* data, std::size_t size) { PyObject* pycl = (PyObject*)this->pyCallbackClass; @@ -38,15 +27,26 @@ class default_get_solution_callback_t : public get_solution_callback_t { } } - void get_solution(void* data, void* objective_value) override + void get_solution(void* data, + void* objective_value, + void* solution_bound, + void* user_data) override { - PyObject* numba_matrix = get_numba_matrix(data, n_variables); - PyObject* numpy_array = get_numba_matrix(objective_value, 1); - PyObject* res = - PyObject_CallMethod(this->pyCallbackClass, "get_solution", "(OO)", numba_matrix, numpy_array); - Py_DECREF(numba_matrix); + PyObject* numpy_matrix = get_numpy_array(data, n_variables); + PyObject* numpy_array = get_numpy_array(objective_value, 1); + PyObject* numpy_bound = get_numpy_array(solution_bound, 1); + PyObject* py_user_data = user_data == nullptr ? Py_None : static_cast(user_data); + PyObject* res = PyObject_CallMethod(this->pyCallbackClass, + "get_solution", + "(OOOO)", + numpy_matrix, + numpy_array, + numpy_bound, + py_user_data); + Py_DECREF(numpy_matrix); Py_DECREF(numpy_array); - Py_DECREF(res); + Py_DECREF(numpy_bound); + if (res != nullptr) { Py_DECREF(res); } } PyObject* pyCallbackClass; @@ -54,17 +54,6 @@ class default_get_solution_callback_t : public get_solution_callback_t { class default_set_solution_callback_t : public set_solution_callback_t { public: - PyObject* get_numba_matrix(void* data, std::size_t size) - { - PyObject* pycl = (PyObject*)this->pyCallbackClass; - - if (isFloat) { - return PyObject_CallMethod(pycl, "get_numba_matrix", "(lls)", data, size, "float32"); - } else { - return PyObject_CallMethod(pycl, "get_numba_matrix", "(lls)", data, size, "float64"); - } - } - PyObject* get_numpy_array(void* data, std::size_t size) { PyObject* pycl = (PyObject*)this->pyCallbackClass; @@ -75,15 +64,26 @@ class default_set_solution_callback_t : public set_solution_callback_t { } } - void set_solution(void* data, void* objective_value) override + void set_solution(void* data, + void* objective_value, + void* solution_bound, + void* user_data) override { - PyObject* numba_matrix = get_numba_matrix(data, n_variables); - PyObject* numpy_array = get_numba_matrix(objective_value, 1); - PyObject* res = - PyObject_CallMethod(this->pyCallbackClass, "set_solution", "(OO)", numba_matrix, numpy_array); - Py_DECREF(numba_matrix); + PyObject* numpy_matrix = get_numpy_array(data, n_variables); + PyObject* numpy_array = get_numpy_array(objective_value, 1); + PyObject* numpy_bound = get_numpy_array(solution_bound, 1); + PyObject* py_user_data = user_data == nullptr ? Py_None : static_cast(user_data); + PyObject* res = PyObject_CallMethod(this->pyCallbackClass, + "set_solution", + "(OOOO)", + numpy_matrix, + numpy_array, + numpy_bound, + py_user_data); + Py_DECREF(numpy_matrix); Py_DECREF(numpy_array); - Py_DECREF(res); + Py_DECREF(numpy_bound); + if (res != nullptr) { Py_DECREF(res); } } PyObject* pyCallbackClass; diff --git a/cpp/include/cuopt/linear_programming/utilities/internals.hpp b/cpp/include/cuopt/linear_programming/utilities/internals.hpp index 90d856b23..7c0e42ede 100644 --- a/cpp/include/cuopt/linear_programming/utilities/internals.hpp +++ b/cpp/include/cuopt/linear_programming/utilities/internals.hpp @@ -31,16 +31,23 @@ class base_solution_callback_t : public Callback { this->n_variables = n_variables_; } + void set_user_data(void* input_user_data) { user_data = input_user_data; } + void* get_user_data() const { return user_data; } + virtual base_solution_callback_type get_type() const = 0; protected: bool isFloat = true; size_t n_variables = 0; + void* user_data = nullptr; }; class get_solution_callback_t : public base_solution_callback_t { public: - virtual void get_solution(void* data, void* objective_value) = 0; + virtual void get_solution(void* data, + void* objective_value, + void* solution_bound, + void* user_data) = 0; base_solution_callback_type get_type() const override { return base_solution_callback_type::GET_SOLUTION; @@ -49,7 +56,10 @@ class get_solution_callback_t : public base_solution_callback_t { class set_solution_callback_t : public base_solution_callback_t { public: - virtual void set_solution(void* data, void* objective_value) = 0; + virtual void set_solution(void* data, + void* objective_value, + void* solution_bound, + void* user_data) = 0; base_solution_callback_type get_type() const override { return base_solution_callback_type::SET_SOLUTION; diff --git a/cpp/src/dual_simplex/branch_and_bound.cpp b/cpp/src/dual_simplex/branch_and_bound.cpp index a3ae12ed6..acdc9888a 100644 --- a/cpp/src/dual_simplex/branch_and_bound.cpp +++ b/cpp/src/dual_simplex/branch_and_bound.cpp @@ -282,6 +282,7 @@ void branch_and_bound_t::report_heuristic(f_t obj) template void branch_and_bound_t::report(char symbol, f_t obj, f_t lower_bound, i_t node_depth) { + update_user_bound(lower_bound); i_t nodes_explored = exploration_stats_.nodes_explored; i_t nodes_unexplored = exploration_stats_.nodes_unexplored; f_t user_obj = compute_user_objective(original_lp_, obj); @@ -300,6 +301,14 @@ void branch_and_bound_t::report(char symbol, f_t obj, f_t lower_bound, toc(exploration_stats_.start_time)); } +template +void branch_and_bound_t::update_user_bound(f_t lower_bound) +{ + if (user_bound_callback_ == nullptr) { return; } + f_t user_lower = compute_user_objective(original_lp_, lower_bound); + user_bound_callback_(user_lower); +} + template void branch_and_bound_t::set_new_solution(const std::vector& solution) { @@ -335,9 +344,10 @@ void branch_and_bound_t::set_new_solution(const std::vector& solu num_fractional); } } + } else { + settings_.log.debug("Solution objective not better than current upper_bound_. Not accepted.\n"); } mutex_upper_.unlock(); - if (is_feasible) { report_heuristic(obj); } if (attempt_repair) { mutex_repair_.lock(); diff --git a/cpp/src/dual_simplex/branch_and_bound.hpp b/cpp/src/dual_simplex/branch_and_bound.hpp index 327f99bc4..19621b889 100644 --- a/cpp/src/dual_simplex/branch_and_bound.hpp +++ b/cpp/src/dual_simplex/branch_and_bound.hpp @@ -21,6 +21,7 @@ #include #include +#include #include namespace cuopt::linear_programming::dual_simplex { @@ -99,6 +100,11 @@ class branch_and_bound_t { // Set a solution based on the user problem during the course of the solve void set_new_solution(const std::vector& solution); + void set_user_bound_callback(std::function callback) + { + user_bound_callback_ = std::move(callback); + } + void set_concurrent_lp_root_solve(bool enable) { enable_concurrent_lp_root_solve_ = enable; } // Repair a low-quality solution from the heuristics. @@ -187,9 +193,11 @@ class branch_and_bound_t { // In case, a best-first thread encounters a numerical issue when solving a node, // its blocks the progression of the lower bound. omp_atomic_t lower_bound_ceiling_; + std::function user_bound_callback_; void report_heuristic(f_t obj); void report(char symbol, f_t obj, f_t lower_bound, i_t node_depth); + void update_user_bound(f_t lower_bound); // Set the final solution. void set_final_solution(mip_solution_t& solution, f_t lower_bound); diff --git a/cpp/src/linear_programming/cuopt_c.cpp b/cpp/src/linear_programming/cuopt_c.cpp index 794c7f4f7..760f0eeca 100644 --- a/cpp/src/linear_programming/cuopt_c.cpp +++ b/cpp/src/linear_programming/cuopt_c.cpp @@ -21,10 +21,64 @@ #include #include #include +#include using namespace cuopt::mps_parser; using namespace cuopt::linear_programming; +class c_get_solution_callback_t : public cuopt::internals::get_solution_callback_t { + public: + explicit c_get_solution_callback_t(cuOptMIPGetSolutionCallback callback) : callback_(callback) {} + + void get_solution(void* data, + void* objective_value, + void* solution_bound, + void* user_data) override + { + if (callback_ == nullptr) { return; } + callback_(static_cast(data), + static_cast(objective_value), + static_cast(solution_bound), + user_data); + } + + private: + cuOptMIPGetSolutionCallback callback_; +}; + +class c_set_solution_callback_t : public cuopt::internals::set_solution_callback_t { + public: + explicit c_set_solution_callback_t(cuOptMIPSetSolutionCallback callback) : callback_(callback) {} + + void set_solution(void* data, + void* objective_value, + void* solution_bound, + void* user_data) override + { + if (callback_ == nullptr) { return; } + callback_(static_cast(data), + static_cast(objective_value), + static_cast(solution_bound), + user_data); + } + + private: + cuOptMIPSetSolutionCallback callback_; +}; + +// Owns solver settings and C callback wrappers for C API lifetime. +struct solver_settings_handle_t { + solver_settings_handle_t() : settings(new solver_settings_t()) {} + ~solver_settings_handle_t() { delete settings; } + solver_settings_t* settings; + std::vector> callbacks; +}; + +solver_settings_handle_t* get_settings_handle(cuOptSolverSettings settings) +{ + return static_cast(settings); +} + int8_t cuOptGetFloatSize() { return sizeof(cuopt_float_t); } int8_t cuOptGetIntSize() { return sizeof(cuopt_int_t); } @@ -570,16 +624,15 @@ cuopt_int_t cuOptGetVariableTypes(cuOptOptimizationProblem problem, char* variab cuopt_int_t cuOptCreateSolverSettings(cuOptSolverSettings* settings_ptr) { if (settings_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } - solver_settings_t* settings = - new solver_settings_t(); - *settings_ptr = static_cast(settings); + solver_settings_handle_t* settings_handle = new solver_settings_handle_t(); + *settings_ptr = static_cast(settings_handle); return CUOPT_SUCCESS; } void cuOptDestroySolverSettings(cuOptSolverSettings* settings_ptr) { if (settings_ptr == nullptr) { return; } - delete static_cast*>(*settings_ptr); + delete get_settings_handle(*settings_ptr); *settings_ptr = nullptr; } @@ -591,7 +644,7 @@ cuopt_int_t cuOptSetParameter(cuOptSolverSettings settings, if (parameter_name == nullptr) { return CUOPT_INVALID_ARGUMENT; } if (parameter_value == nullptr) { return CUOPT_INVALID_ARGUMENT; } solver_settings_t* solver_settings = - static_cast*>(settings); + get_settings_handle(settings)->settings; try { solver_settings->set_parameter_from_string(parameter_name, parameter_value); } catch (const std::exception& e) { @@ -610,7 +663,7 @@ cuopt_int_t cuOptGetParameter(cuOptSolverSettings settings, if (parameter_value == nullptr) { return CUOPT_INVALID_ARGUMENT; } if (parameter_value_size <= 0) { return CUOPT_INVALID_ARGUMENT; } solver_settings_t* solver_settings = - static_cast*>(settings); + get_settings_handle(settings)->settings; try { std::string parameter_value_str = solver_settings->get_parameter_as_string(parameter_name); std::snprintf(parameter_value, parameter_value_size, "%s", parameter_value_str.c_str()); @@ -627,7 +680,7 @@ cuopt_int_t cuOptSetIntegerParameter(cuOptSolverSettings settings, if (settings == nullptr) { return CUOPT_INVALID_ARGUMENT; } if (parameter_name == nullptr) { return CUOPT_INVALID_ARGUMENT; } solver_settings_t* solver_settings = - static_cast*>(settings); + get_settings_handle(settings)->settings; try { solver_settings->set_parameter(parameter_name, parameter_value); } catch (const std::invalid_argument& e) { @@ -652,7 +705,7 @@ cuopt_int_t cuOptGetIntegerParameter(cuOptSolverSettings settings, if (parameter_name == nullptr) { return CUOPT_INVALID_ARGUMENT; } if (parameter_value_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } solver_settings_t* solver_settings = - static_cast*>(settings); + get_settings_handle(settings)->settings; try { *parameter_value_ptr = solver_settings->get_parameter(parameter_name); } catch (const std::invalid_argument& e) { @@ -676,7 +729,7 @@ cuopt_int_t cuOptSetFloatParameter(cuOptSolverSettings settings, if (settings == nullptr) { return CUOPT_INVALID_ARGUMENT; } if (parameter_name == nullptr) { return CUOPT_INVALID_ARGUMENT; } solver_settings_t* solver_settings = - static_cast*>(settings); + get_settings_handle(settings)->settings; try { solver_settings->set_parameter(parameter_name, parameter_value); } catch (const std::exception& e) { @@ -693,7 +746,7 @@ cuopt_int_t cuOptGetFloatParameter(cuOptSolverSettings settings, if (parameter_name == nullptr) { return CUOPT_INVALID_ARGUMENT; } if (parameter_value_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } solver_settings_t* solver_settings = - static_cast*>(settings); + get_settings_handle(settings)->settings; try { *parameter_value_ptr = solver_settings->get_parameter(parameter_name); } catch (const std::exception& e) { @@ -702,6 +755,32 @@ cuopt_int_t cuOptGetFloatParameter(cuOptSolverSettings settings, return CUOPT_SUCCESS; } +cuopt_int_t cuOptSetMIPGetSolutionCallback(cuOptSolverSettings settings, + cuOptMIPGetSolutionCallback callback, + void* user_data) +{ + if (settings == nullptr) { return CUOPT_INVALID_ARGUMENT; } + if (callback == nullptr) { return CUOPT_INVALID_ARGUMENT; } + solver_settings_handle_t* settings_handle = get_settings_handle(settings); + auto callback_wrapper = std::make_unique(callback); + settings_handle->settings->set_mip_callback(callback_wrapper.get(), user_data); + settings_handle->callbacks.push_back(std::move(callback_wrapper)); + return CUOPT_SUCCESS; +} + +cuopt_int_t cuOptSetMIPSetSolutionCallback(cuOptSolverSettings settings, + cuOptMIPSetSolutionCallback callback, + void* user_data) +{ + if (settings == nullptr) { return CUOPT_INVALID_ARGUMENT; } + if (callback == nullptr) { return CUOPT_INVALID_ARGUMENT; } + solver_settings_handle_t* settings_handle = get_settings_handle(settings); + auto callback_wrapper = std::make_unique(callback); + settings_handle->settings->set_mip_callback(callback_wrapper.get(), user_data); + settings_handle->callbacks.push_back(std::move(callback_wrapper)); + return CUOPT_SUCCESS; +} + cuopt_int_t cuOptSetInitialPrimalSolution(cuOptSolverSettings settings, const cuopt_float_t* primal_solution, cuopt_int_t num_variables) @@ -711,7 +790,7 @@ cuopt_int_t cuOptSetInitialPrimalSolution(cuOptSolverSettings settings, if (num_variables <= 0) { return CUOPT_INVALID_ARGUMENT; } solver_settings_t* solver_settings = - static_cast*>(settings); + get_settings_handle(settings)->settings; try { solver_settings->set_initial_pdlp_primal_solution(primal_solution, num_variables); } catch (const std::exception& e) { @@ -729,7 +808,7 @@ cuopt_int_t cuOptSetInitialDualSolution(cuOptSolverSettings settings, if (num_constraints <= 0) { return CUOPT_INVALID_ARGUMENT; } solver_settings_t* solver_settings = - static_cast*>(settings); + get_settings_handle(settings)->settings; try { solver_settings->set_initial_pdlp_dual_solution(dual_solution, num_constraints); } catch (const std::exception& e) { @@ -747,7 +826,7 @@ cuopt_int_t cuOptAddMIPStart(cuOptSolverSettings settings, if (num_variables <= 0) { return CUOPT_INVALID_ARGUMENT; } solver_settings_t* solver_settings = - static_cast*>(settings); + get_settings_handle(settings)->settings; try { solver_settings->get_mip_settings().add_initial_solution(solution, num_variables); } catch (const std::exception& e) { @@ -783,7 +862,7 @@ cuopt_int_t cuOptSolve(cuOptOptimizationProblem problem, if (problem_and_stream_view->op_problem->get_problem_category() == problem_category_t::MIP || problem_and_stream_view->op_problem->get_problem_category() == problem_category_t::IP) { solver_settings_t* solver_settings = - static_cast*>(settings); + get_settings_handle(settings)->settings; mip_solver_settings_t& mip_settings = solver_settings->get_mip_settings(); optimization_problem_t* op_problem = @@ -800,7 +879,7 @@ cuopt_int_t cuOptSolve(cuOptOptimizationProblem problem, solution_and_stream_view->mip_solution_ptr->get_error_status().get_error_type()); } else { solver_settings_t* solver_settings = - static_cast*>(settings); + get_settings_handle(settings)->settings; pdlp_solver_settings_t& pdlp_settings = solver_settings->get_pdlp_settings(); optimization_problem_t* op_problem = diff --git a/cpp/src/math_optimization/solver_settings.cu b/cpp/src/math_optimization/solver_settings.cu index 41c186e19..493e730fb 100644 --- a/cpp/src/math_optimization/solver_settings.cu +++ b/cpp/src/math_optimization/solver_settings.cu @@ -383,9 +383,10 @@ void solver_settings_t::add_initial_mip_solution(const f_t* solution, } template -void solver_settings_t::set_mip_callback(internals::base_solution_callback_t* callback) +void solver_settings_t::set_mip_callback(internals::base_solution_callback_t* callback, + void* user_data) { - mip_settings.set_mip_callback(callback); + mip_settings.set_mip_callback(callback, user_data); } template diff --git a/cpp/src/mip/CMakeLists.txt b/cpp/src/mip/CMakeLists.txt index b043c9dc7..538e3c49a 100644 --- a/cpp/src/mip/CMakeLists.txt +++ b/cpp/src/mip/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 @@ -7,6 +7,7 @@ # Files necessary for Linear Programming functionality set(MIP_LP_NECESSARY_FILES ${CMAKE_CURRENT_SOURCE_DIR}/problem/problem.cu + ${CMAKE_CURRENT_SOURCE_DIR}/problem/presolve_data.cu ${CMAKE_CURRENT_SOURCE_DIR}/solver_settings.cu ${CMAKE_CURRENT_SOURCE_DIR}/solver_solution.cu ${CMAKE_CURRENT_SOURCE_DIR}/local_search/rounding/simple_rounding.cu diff --git a/cpp/src/mip/diversity/diversity_manager.cu b/cpp/src/mip/diversity/diversity_manager.cu index 89bd7006d..cf2180801 100644 --- a/cpp/src/mip/diversity/diversity_manager.cu +++ b/cpp/src/mip/diversity/diversity_manager.cu @@ -524,7 +524,7 @@ void diversity_manager_t::diversity_step(i_t max_iterations_without_im template void diversity_manager_t::set_new_user_bound(f_t new_bound) { - stats.solution_bound = new_bound; + stats.set_solution_bound(new_bound); } template diff --git a/cpp/src/mip/diversity/lns/rins.cu b/cpp/src/mip/diversity/lns/rins.cu index 7456b59ed..af992d2e5 100644 --- a/cpp/src/mip/diversity/lns/rins.cu +++ b/cpp/src/mip/diversity/lns/rins.cu @@ -76,12 +76,12 @@ void rins_t::node_callback(const std::vector& solution, f_t objec template void rins_t::enable() { - rins_thread = std::make_unique>(); - rins_thread->rins_ptr = this; - seed = cuopt::seed_generator::get_seed(); - problem_copy = std::make_unique>(*problem_ptr); - problem_copy->handle_ptr = &rins_handle; - enabled = true; + rins_thread = std::make_unique>(); + rins_thread->rins_ptr = this; + seed = cuopt::seed_generator::get_seed(); + problem_ptr->handle_ptr->sync_stream(); + problem_copy = std::make_unique>(*problem_ptr, &rins_handle); + enabled = true; } template @@ -112,6 +112,7 @@ void rins_t::run_rins() cuopt_assert(dm.population.current_size() > 0, "No solutions in population"); solution_t best_sol(*problem_copy); + rins_handle.sync_stream(); // copy the best from the population into a solution_t in the RINS stream { std::lock_guard lock(dm.population.write_mutex); diff --git a/cpp/src/mip/diversity/population.cu b/cpp/src/mip/diversity/population.cu index 766ed09cb..bfc0e0d03 100644 --- a/cpp/src/mip/diversity/population.cu +++ b/cpp/src/mip/diversity/population.cu @@ -1,6 +1,6 @@ /* clang-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 */ /* clang-format on */ @@ -259,6 +259,38 @@ bool population_t::is_better_than_best_feasible(solution_t& return obj_better && sol.get_feasible(); } +template +void population_t::invoke_get_solution_callback( + solution_t& sol, internals::get_solution_callback_t* callback) +{ + f_t user_objective = sol.get_user_objective(); + f_t user_bound = context.stats.get_solution_bound(); + solution_t temp_sol(sol); + problem_ptr->post_process_assignment(temp_sol.assignment); + if (context.settings.mip_scaling) { + rmm::device_uvector dummy(0, temp_sol.handle_ptr->get_stream()); + context.scaling.unscale_solutions(temp_sol.assignment, dummy); + } + if (problem_ptr->has_papilo_presolve_data()) { + problem_ptr->papilo_uncrush_assignment(temp_sol.assignment); + } + + std::vector user_objective_vec(1); + std::vector user_bound_vec(1); + std::vector user_assignment_vec(temp_sol.assignment.size()); + user_objective_vec[0] = user_objective; + user_bound_vec[0] = user_bound; + raft::copy(user_assignment_vec.data(), + temp_sol.assignment.data(), + temp_sol.assignment.size(), + temp_sol.handle_ptr->get_stream()); + temp_sol.handle_ptr->sync_stream(); + callback->get_solution(user_assignment_vec.data(), + user_objective_vec.data(), + user_bound_vec.data(), + callback->get_user_data()); +} + template void population_t::run_solution_callbacks(solution_t& sol) { @@ -272,34 +304,10 @@ void population_t::run_solution_callbacks(solution_t& sol) if (problem_ptr->branch_and_bound_callback != nullptr) { problem_ptr->branch_and_bound_callback(sol.get_host_assignment()); } - for (auto callback : user_callbacks) { if (callback->get_type() == internals::base_solution_callback_type::GET_SOLUTION) { auto get_sol_callback = static_cast(callback); - solution_t temp_sol(sol); - problem_ptr->post_process_assignment(temp_sol.assignment); - rmm::device_uvector dummy(0, temp_sol.handle_ptr->get_stream()); - if (context.settings.mip_scaling) { - context.scaling.unscale_solutions(temp_sol.assignment, dummy); - // Need to get unscaled problem as well - problem_t n_problem(*sol.problem_ptr->original_problem_ptr); - auto scaled_sol(temp_sol); - scaled_sol.problem_ptr = &n_problem; - scaled_sol.resize_to_original_problem(); - scaled_sol.compute_feasibility(); - if (!scaled_sol.get_feasible()) { - CUOPT_LOG_DEBUG("Discard infeasible after unscaling"); - return; - } - } - - rmm::device_uvector user_objective_vec(1, temp_sol.handle_ptr->get_stream()); - - f_t user_objective = - temp_sol.problem_ptr->get_user_obj_from_solver_obj(temp_sol.get_objective()); - user_objective_vec.set_element_async(0, user_objective, temp_sol.handle_ptr->get_stream()); - CUOPT_LOG_DEBUG("Returning incumbent solution with objective %g", user_objective); - get_sol_callback->get_solution(temp_sol.assignment.data(), user_objective_vec.data()); + invoke_get_solution_callback(sol, get_sol_callback); } } // save the best objective here, because we might not have been able to return the solution to @@ -311,26 +319,34 @@ void population_t::run_solution_callbacks(solution_t& sol) for (auto callback : user_callbacks) { if (callback->get_type() == internals::base_solution_callback_type::SET_SOLUTION) { - auto set_sol_callback = static_cast(callback); - rmm::device_uvector incumbent_assignment( - problem_ptr->original_problem_ptr->get_n_variables(), sol.handle_ptr->get_stream()); - rmm::device_uvector dummy(0, sol.handle_ptr->get_stream()); + auto set_sol_callback = static_cast(callback); + f_t user_bound = context.stats.get_solution_bound(); + auto callback_num_variables = problem_ptr->original_problem_ptr->get_n_variables(); + rmm::device_uvector incumbent_assignment(callback_num_variables, + sol.handle_ptr->get_stream()); solution_t outside_sol(sol); rmm::device_scalar d_outside_sol_objective(sol.handle_ptr->get_stream()); auto inf = std::numeric_limits::infinity(); d_outside_sol_objective.set_value_async(inf, sol.handle_ptr->get_stream()); sol.handle_ptr->sync_stream(); - set_sol_callback->set_solution(incumbent_assignment.data(), d_outside_sol_objective.data()); - - f_t outside_sol_objective = d_outside_sol_objective.value(sol.handle_ptr->get_stream()); + std::vector h_incumbent_assignment(incumbent_assignment.size()); + std::vector h_outside_sol_objective(1, inf); + std::vector h_user_bound(1, user_bound); + set_sol_callback->set_solution(h_incumbent_assignment.data(), + h_outside_sol_objective.data(), + h_user_bound.data(), + set_sol_callback->get_user_data()); + f_t outside_sol_objective = h_outside_sol_objective[0]; // The callback might be called without setting any valid solution or objective which triggers // asserts if (outside_sol_objective == inf) { return; } - CUOPT_LOG_DEBUG("Injecting external solution with objective %g", outside_sol_objective); + d_outside_sol_objective.set_value_async(outside_sol_objective, sol.handle_ptr->get_stream()); + raft::copy(incumbent_assignment.data(), + h_incumbent_assignment.data(), + incumbent_assignment.size(), + sol.handle_ptr->get_stream()); - if (context.settings.mip_scaling) { - context.scaling.scale_solutions(incumbent_assignment, dummy); - } + if (context.settings.mip_scaling) { context.scaling.scale_solutions(incumbent_assignment); } bool is_valid = problem_ptr->pre_process_assignment(incumbent_assignment); if (!is_valid) { return; } cuopt_assert(outside_sol.assignment.size() == incumbent_assignment.size(), @@ -341,10 +357,17 @@ void population_t::run_solution_callbacks(solution_t& sol) sol.handle_ptr->get_stream()); outside_sol.compute_feasibility(); - CUOPT_LOG_DEBUG("Injected solution feasibility = %d objective = %g", + CUOPT_LOG_DEBUG("Injected solution feasibility = %d objective = %g excess = %g", outside_sol.get_feasible(), - outside_sol.get_user_objective()); - + outside_sol.get_user_objective(), + outside_sol.get_total_excess()); + if (std::abs(outside_sol.get_user_objective() - outside_sol_objective) > 1e-6) { + cuopt_func_call( + CUOPT_LOG_DEBUG("External solution objective mismatch: outside_sol.get_user_objective() " + "= %g, outside_sol_objective = %g", + outside_sol.get_user_objective(), + outside_sol_objective)); + } cuopt_assert(std::abs(outside_sol.get_user_objective() - outside_sol_objective) <= 1e-6, "External solution objective mismatch"); auto h_outside_sol = outside_sol.get_host_assignment(); @@ -391,6 +414,11 @@ std::pair population_t::add_solution(solution_t&& { std::lock_guard lock(write_mutex); raft::common::nvtx::range fun_scope("add_solution"); + // Sync the input solution's stream to ensure all device data is visible. + // The solution might have been created/modified on a different stream, + // and we need those operations to complete before reading device data + // for hash computation, quality calculation, and similarity comparisons. + sol.handle_ptr->sync_stream(); population_hash_map.insert(sol); double sol_cost = sol.get_quality(weights); bool best_updated = false; @@ -405,6 +433,7 @@ std::pair population_t::add_solution(solution_t&& solutions[0].first = true; // we only have move assignment operator solution_t temp_sol(sol); + temp_sol.handle_ptr->sync_stream(); solutions[0].second = std::move(temp_sol); indices[0].second = sol_cost; best_updated = true; diff --git a/cpp/src/mip/diversity/population.cuh b/cpp/src/mip/diversity/population.cuh index 05f22b623..ca823a774 100644 --- a/cpp/src/mip/diversity/population.cuh +++ b/cpp/src/mip/diversity/population.cuh @@ -1,6 +1,6 @@ /* clang-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 */ /* clang-format on */ @@ -161,6 +161,9 @@ class population_t { void diversity_step(i_t max_iterations_without_improvement); + void invoke_get_solution_callback(solution_t& sol, + internals::get_solution_callback_t* callback); + // does some consistency tests bool test_invariant(); diff --git a/cpp/src/mip/feasibility_jump/feasibility_jump.cu b/cpp/src/mip/feasibility_jump/feasibility_jump.cu index 6dcd768c9..c43939cd1 100644 --- a/cpp/src/mip/feasibility_jump/feasibility_jump.cu +++ b/cpp/src/mip/feasibility_jump/feasibility_jump.cu @@ -1,6 +1,6 @@ /* clang-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 */ /* clang-format on */ @@ -10,6 +10,7 @@ #include "feasibility_jump.cuh" #include "feasibility_jump_kernels.cuh" +#include #include #include #include @@ -873,6 +874,14 @@ i_t fj_t::host_loop(solution_t& solution, i_t climber_idx) limit_reached = true; } + // every now and then, ensure external solutions are added to the population + // this is done here because FJ is called within FP and also after recombiners + // so FJ is one of the most inner and most frequent functions to be called + if (steps % 10000 == 0) { + context.diversity_manager_ptr->get_population_pointer() + ->add_external_solutions_to_population(); + } + #if !FJ_SINGLE_STEP if (steps % 500 == 0) #endif diff --git a/cpp/src/mip/local_search/feasibility_pump/feasibility_pump.cu b/cpp/src/mip/local_search/feasibility_pump/feasibility_pump.cu index 76d930b45..1174a6781 100644 --- a/cpp/src/mip/local_search/feasibility_pump/feasibility_pump.cu +++ b/cpp/src/mip/local_search/feasibility_pump/feasibility_pump.cu @@ -1,6 +1,6 @@ /* clang-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 */ /* clang-format on */ @@ -8,6 +8,7 @@ #include "feasibility_pump.cuh" #include +#include #include #include #include @@ -477,7 +478,7 @@ bool feasibility_pump_t::run_single_fp_descent(solution_t& s solution.assignment.size(), solution.handle_ptr->get_stream()); while (true) { - if (timer.check_time_limit()) { + if (context.diversity_manager_ptr->check_b_b_preemption() || timer.check_time_limit()) { CUOPT_LOG_DEBUG("FP time limit reached!"); round(solution); return false; diff --git a/cpp/src/mip/local_search/local_search.cu b/cpp/src/mip/local_search/local_search.cu index ecd277065..71b944a09 100644 --- a/cpp/src/mip/local_search/local_search.cu +++ b/cpp/src/mip/local_search/local_search.cu @@ -10,6 +10,7 @@ #include +#include #include #include #include @@ -81,12 +82,13 @@ void local_search_t::start_cpufj_scratch_threads(population_t 0); cpu_fj.fj_cpu->log_prefix = "******* scratch " + std::to_string(counter) + ": "; - cpu_fj.fj_cpu->improvement_callback = [&population](f_t obj, const std::vector& h_vec) { + cpu_fj.fj_cpu->improvement_callback = [&population, problem_ptr = context.problem_ptr]( + f_t obj, const std::vector& h_vec) { population.add_external_solution(h_vec, obj, solution_origin_t::CPUFJ); if (obj < local_search_best_obj) { CUOPT_LOG_TRACE("******* New local search best obj %g, best overall %g", - context.problem_ptr->get_user_obj_from_solver_obj(obj), - context.problem_ptr->get_user_obj_from_solver_obj( + problem_ptr->get_user_obj_from_solver_obj(obj), + problem_ptr->get_user_obj_from_solver_obj( population.is_feasible() ? population.best_feasible().get_objective() : std::numeric_limits::max())); local_search_best_obj = obj; @@ -245,7 +247,7 @@ void local_search_t::generate_fast_solution(solution_t& solu fj.settings.update_weights = true; fj.settings.feasibility_run = true; fj.settings.time_limit = std::min(30., timer.remaining_time()); - while (!timer.check_time_limit()) { + while (!context.diversity_manager_ptr->check_b_b_preemption() && !timer.check_time_limit()) { timer_t constr_prop_timer = timer_t(std::min(timer.remaining_time(), 2.)); // do constraint prop on lp optimal solution constraint_prop.apply_round(solution, 1., constr_prop_timer); diff --git a/cpp/src/mip/presolve/third_party_presolve.cpp b/cpp/src/mip/presolve/third_party_presolve.cpp index 3082d0d6d..9a212ebab 100644 --- a/cpp/src/mip/presolve/third_party_presolve.cpp +++ b/cpp/src/mip/presolve/third_party_presolve.cpp @@ -455,8 +455,18 @@ std::optional> third_party_presolve_t{opt_problem, implied_integer_indices}); + auto const& col_map = result.postsolve.origcol_mapping; + reduced_to_original_map_.assign(col_map.begin(), col_map.end()); + original_to_reduced_map_.assign(op_problem.get_n_variables(), -1); + for (size_t i = 0; i < reduced_to_original_map_.size(); ++i) { + auto original_idx = reduced_to_original_map_[i]; + if (original_idx >= 0 && static_cast(original_idx) < original_to_reduced_map_.size()) { + original_to_reduced_map_[original_idx] = static_cast(i); + } + } + + return std::make_optional(third_party_presolve_result_t{ + opt_problem, implied_integer_indices, reduced_to_original_map_, original_to_reduced_map_}); } template @@ -500,6 +510,22 @@ void third_party_presolve_t::undo(rmm::device_uvector& primal_sol reduced_costs.data(), full_sol.reducedCosts.data(), full_sol.reducedCosts.size(), stream_view); } +template +void third_party_presolve_t::uncrush_primal_solution( + const std::vector& reduced_primal, std::vector& full_primal) const +{ + papilo::Solution reduced_sol(reduced_primal); + papilo::Solution full_sol; + papilo::Message Msg{}; + Msg.setVerbosityLevel(papilo::VerbosityLevel::kQuiet); + papilo::Postsolve post_solver{Msg, post_solve_storage_.getNum()}; + + bool is_optimal = false; + auto status = post_solver.undo(reduced_sol, full_sol, post_solve_storage_, is_optimal); + check_postsolve_status(status); + full_primal = std::move(full_sol.primal); +} + #if MIP_INSTANTIATE_FLOAT template class third_party_presolve_t; #endif diff --git a/cpp/src/mip/presolve/third_party_presolve.hpp b/cpp/src/mip/presolve/third_party_presolve.hpp index 1ece27501..67b338bee 100644 --- a/cpp/src/mip/presolve/third_party_presolve.hpp +++ b/cpp/src/mip/presolve/third_party_presolve.hpp @@ -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 */ @@ -8,6 +8,7 @@ #pragma once #include +#include #include @@ -17,6 +18,8 @@ template struct third_party_presolve_result_t { optimization_problem_t reduced_problem; std::vector implied_integer_indices; + std::vector reduced_to_original_map; + std::vector original_to_reduced_map; // clique info, etc... }; @@ -41,6 +44,15 @@ class third_party_presolve_t { bool status_to_skip, bool dual_postsolve, rmm::cuda_stream_view stream_view); + + void uncrush_primal_solution(const std::vector& reduced_primal, + std::vector& full_primal) const; + const std::vector& get_reduced_to_original_map() const { return reduced_to_original_map_; } + const std::vector& get_original_to_reduced_map() const { return original_to_reduced_map_; } + + private: + std::vector reduced_to_original_map_{}; + std::vector original_to_reduced_map_{}; }; } // namespace cuopt::linear_programming::detail diff --git a/cpp/src/mip/problem/presolve_data.cu b/cpp/src/mip/problem/presolve_data.cu new file mode 100644 index 000000000..d09754bc2 --- /dev/null +++ b/cpp/src/mip/problem/presolve_data.cu @@ -0,0 +1,257 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include "presolve_data.cuh" + +#include "problem.cuh" + +#include +#include + +#include +#include + +#include + +#include + +#include + +namespace cuopt { +namespace linear_programming::detail { + +template +bool presolve_data_t::pre_process_assignment(problem_t& problem, + rmm::device_uvector& assignment) +{ + raft::common::nvtx::range fun_scope("pre_process_assignment"); + auto has_nans = cuopt::linear_programming::detail::has_nans(problem.handle_ptr, assignment); + if (has_nans) { + CUOPT_LOG_DEBUG("Solution discarded due to nans"); + return false; + } + cuopt_assert(assignment.size() == problem.original_problem_ptr->get_n_variables(), + "size mismatch"); + + // NOTE: We can apply substitutions and fixed variables here. + // However, variable fixing and substitutions are already included in the problem and objective + // offsets. So the only advantage of applying them here would be to check the compatibility of the + // assignment values. It is not so important as we would be correcting the infeasibility by + // keeping the correct substitution. + + // create a temp assignment with the var size after bounds standardization (free vars added) + rmm::device_uvector temp_assignment(additional_var_used.size(), + problem.handle_ptr->get_stream()); + // copy the assignment to the first part(the original variable count) of the temp_assignment + raft::copy( + temp_assignment.data(), assignment.data(), assignment.size(), problem.handle_ptr->get_stream()); + auto d_additional_var_used = + cuopt::device_copy(additional_var_used, problem.handle_ptr->get_stream()); + auto d_additional_var_id_per_var = + cuopt::device_copy(additional_var_id_per_var, problem.handle_ptr->get_stream()); + + thrust::for_each( + problem.handle_ptr->get_thrust_policy(), + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(problem.original_problem_ptr->get_n_variables()), + [additional_var_used = d_additional_var_used.data(), + additional_var_id_per_var = d_additional_var_id_per_var.data(), + assgn = temp_assignment.data()] __device__(auto idx) { + if (additional_var_used[idx]) { + cuopt_assert(additional_var_id_per_var[idx] != -1, "additional_var_id_per_var is not set"); + // We have two non-negative variables y and z that simulate a free variable + // x. If the value of x is negative, we can set z to be something higher than + // y. If the value of x is positive we can set y greater than z + assgn[additional_var_id_per_var[idx]] = (assgn[idx] < 0 ? -assgn[idx] : 0.); + assgn[idx] += assgn[additional_var_id_per_var[idx]]; + } + }); + assignment.resize(problem.n_variables, problem.handle_ptr->get_stream()); + assignment.shrink_to_fit(problem.handle_ptr->get_stream()); + cuopt_assert(variable_mapping.size() == problem.n_variables, "size mismatch"); + thrust::gather(problem.handle_ptr->get_thrust_policy(), + variable_mapping.begin(), + variable_mapping.end(), + temp_assignment.begin(), + assignment.begin()); + problem.handle_ptr->sync_stream(); + + auto has_integrality_discrepancy = cuopt::linear_programming::detail::has_integrality_discrepancy( + problem.handle_ptr, + problem.integer_indices, + assignment, + problem.tolerances.integrality_tolerance); + if (has_integrality_discrepancy) { + CUOPT_LOG_DEBUG("Solution discarded due to integrality discrepancy"); + return false; + } + + auto has_variable_bounds_violation = + cuopt::linear_programming::detail::has_variable_bounds_violation( + problem.handle_ptr, assignment, &problem); + if (has_variable_bounds_violation) { + CUOPT_LOG_DEBUG("Solution discarded due to variable bounds violation"); + return false; + } + return true; +} + +// this function is used to post process the assignment +// it removes the additional variable for free variables +// and expands the assignment to the original variable dimension +template +void presolve_data_t::post_process_assignment( + problem_t& problem, + rmm::device_uvector& current_assignment, + bool resize_to_original_problem) +{ + raft::common::nvtx::range fun_scope("post_process_assignment"); + cuopt_assert(current_assignment.size() == variable_mapping.size(), "size mismatch"); + auto assgn = make_span(current_assignment); + auto fixed_assgn = make_span(fixed_var_assignment); + auto var_map = make_span(variable_mapping); + if (current_assignment.size() > 0) { + thrust::for_each(problem.handle_ptr->get_thrust_policy(), + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(current_assignment.size()), + [fixed_assgn, var_map, assgn] __device__(auto idx) { + fixed_assgn[var_map[idx]] = assgn[idx]; + }); + } + expand_device_copy(current_assignment, fixed_var_assignment, problem.handle_ptr->get_stream()); + auto h_assignment = cuopt::host_copy(current_assignment, problem.handle_ptr->get_stream()); + cuopt_assert(additional_var_id_per_var.size() == h_assignment.size(), "Size mismatch"); + cuopt_assert(additional_var_used.size() == h_assignment.size(), "Size mismatch"); + for (i_t i = 0; i < (i_t)h_assignment.size(); ++i) { + if (additional_var_used[i]) { + cuopt_assert(additional_var_id_per_var[i] != -1, "additional_var_id_per_var is not set"); + h_assignment[i] -= h_assignment[additional_var_id_per_var[i]]; + } + } + + // Apply variable substitutions from probing: x_substituted = offset + coefficient * + // x_substituting + for (const auto& sub : variable_substitutions) { + cuopt_assert(sub.substituted_var < (i_t)h_assignment.size(), "substituted_var out of bounds"); + cuopt_assert(sub.substituting_var < (i_t)h_assignment.size(), "substituting_var out of bounds"); + h_assignment[sub.substituted_var] = + sub.offset + sub.coefficient * h_assignment[sub.substituting_var]; + CUOPT_LOG_DEBUG("Post-process substitution: x[%d] = %f + %f * x[%d] = %f", + sub.substituted_var, + sub.offset, + sub.coefficient, + sub.substituting_var, + h_assignment[sub.substituted_var]); + } + + raft::copy(current_assignment.data(), + h_assignment.data(), + h_assignment.size(), + problem.handle_ptr->get_stream()); + // this separate resizing is needed because of the callback + if (resize_to_original_problem) { + current_assignment.resize(problem.original_problem_ptr->get_n_variables(), + problem.handle_ptr->get_stream()); + } +} + +template +void presolve_data_t::post_process_solution(problem_t& problem, + solution_t& solution) +{ + raft::common::nvtx::range fun_scope("post_process_solution"); + post_process_assignment(problem, solution.assignment); + // this is for resizing other fields such as excess, slack so that we can compute the feasibility + solution.resize_to_original_problem(); + problem.handle_ptr->sync_stream(); + solution.post_process_completed = true; +} + +template +void presolve_data_t::set_papilo_presolve_data( + const third_party_presolve_t* presolver_ptr, + std::vector reduced_to_original, + std::vector original_to_reduced, + i_t original_num_variables) +{ + if (original_num_variables <= 0) { + CUOPT_LOG_DEBUG("Papilo presolve data invalid: original_num_variables=%d", + original_num_variables); + return; + } + if (original_to_reduced.empty()) { + CUOPT_LOG_DEBUG("Papilo presolve data invalid: original_to_reduced is empty"); + return; + } + if (original_to_reduced.size() != static_cast(original_num_variables)) { + CUOPT_LOG_DEBUG( + "Papilo presolve data invalid: original_to_reduced.size()=%zu " + "original_num_variables=%d", + original_to_reduced.size(), + original_num_variables); + return; + } + for (size_t i = 0; i < reduced_to_original.size(); ++i) { + const auto original_idx = reduced_to_original[i]; + if (original_idx < 0 || original_idx >= original_num_variables) { + CUOPT_LOG_DEBUG( + "Papilo presolve data invalid: reduced_to_original[%zu]=%d out of range [0,%d)", + i, + original_idx, + original_num_variables); + return; + } + } + for (size_t i = 0; i < original_to_reduced.size(); ++i) { + const auto reduced_idx = original_to_reduced[i]; + if (reduced_idx < -1 || reduced_idx >= static_cast(reduced_to_original.size())) { + CUOPT_LOG_DEBUG( + "Papilo presolve data invalid: original_to_reduced[%zu]=%d out of range [-1,%zu)", + i, + reduced_idx, + reduced_to_original.size()); + return; + } + } + + papilo_presolve_ptr = presolver_ptr; + papilo_reduced_to_original_map = std::move(reduced_to_original); + papilo_original_to_reduced_map = std::move(original_to_reduced); + papilo_original_num_variables = original_num_variables; +} + +template +void presolve_data_t::papilo_uncrush_assignment( + problem_t& problem, rmm::device_uvector& assignment) const +{ + if (papilo_presolve_ptr == nullptr) { + CUOPT_LOG_INFO("Papilo presolve data not set, skipping uncrushing assignment"); + return; + } + cuopt_assert(assignment.size() == papilo_reduced_to_original_map.size(), + "Papilo uncrush assignment size mismatch"); + auto h_assignment = cuopt::host_copy(assignment, problem.handle_ptr->get_stream()); + std::vector full_assignment; + papilo_presolve_ptr->uncrush_primal_solution(h_assignment, full_assignment); + assignment.resize(full_assignment.size(), problem.handle_ptr->get_stream()); + raft::copy(assignment.data(), + full_assignment.data(), + full_assignment.size(), + problem.handle_ptr->get_stream()); + problem.handle_ptr->sync_stream(); +} + +#if MIP_INSTANTIATE_FLOAT +template class presolve_data_t; +#endif + +#if MIP_INSTANTIATE_DOUBLE +template class presolve_data_t; +#endif + +} // namespace linear_programming::detail +} // namespace cuopt diff --git a/cpp/src/mip/problem/presolve_data.cuh b/cpp/src/mip/problem/presolve_data.cuh index be1f9f8cb..51b6bac95 100644 --- a/cpp/src/mip/problem/presolve_data.cuh +++ b/cpp/src/mip/problem/presolve_data.cuh @@ -19,6 +19,12 @@ namespace linear_programming::detail { template class problem_t; +template +class solution_t; + +template +class third_party_presolve_t; + template struct substitution_t { f_t timestamp; @@ -52,6 +58,10 @@ class presolve_data_t { variable_mapping(other.variable_mapping, stream), fixed_var_assignment(other.fixed_var_assignment, stream), var_flags(other.var_flags, stream), + papilo_presolve_ptr(other.papilo_presolve_ptr), + papilo_reduced_to_original_map(other.papilo_reduced_to_original_map), + papilo_original_to_reduced_map(other.papilo_original_to_reduced_map), + papilo_original_num_variables(other.papilo_original_num_variables), variable_substitutions(other.variable_substitutions) { } @@ -76,6 +86,21 @@ class presolve_data_t { additional_var_id_per_var.assign(problem.n_variables, -1); } + bool pre_process_assignment(problem_t& problem, rmm::device_uvector& assignment); + void post_process_assignment(problem_t& problem, + rmm::device_uvector& current_assignment, + bool resize_to_original_problem = true); + void post_process_solution(problem_t& problem, solution_t& solution); + + void set_papilo_presolve_data(const third_party_presolve_t* presolver_ptr, + std::vector reduced_to_original, + std::vector original_to_reduced, + i_t original_num_variables); + bool has_papilo_presolve_data() const { return papilo_presolve_ptr != nullptr; } + i_t get_papilo_original_num_variables() const { return papilo_original_num_variables; } + void papilo_uncrush_assignment(problem_t& problem, + rmm::device_uvector& assignment) const; + presolve_data_t(presolve_data_t&&) = default; presolve_data_t& operator=(presolve_data_t&&) = default; presolve_data_t& operator=(const presolve_data_t&) = delete; @@ -91,6 +116,10 @@ class presolve_data_t { rmm::device_uvector fixed_var_assignment; rmm::device_uvector var_flags; + const third_party_presolve_t* papilo_presolve_ptr{nullptr}; + std::vector papilo_reduced_to_original_map{}; + std::vector papilo_original_to_reduced_map{}; + i_t papilo_original_num_variables{0}; // Variable substitutions from probing: x_substituted = offset + coefficient * x_substituting // Applied in post_process_assignment to recover substituted variable values std::vector> variable_substitutions; diff --git a/cpp/src/mip/problem/problem.cu b/cpp/src/mip/problem/problem.cu index 8feaee523..0a630628b 100644 --- a/cpp/src/mip/problem/problem.cu +++ b/cpp/src/mip/problem/problem.cu @@ -17,6 +17,7 @@ #include #include +#include #include #include @@ -201,6 +202,61 @@ problem_t::problem_t(const problem_t& problem_) { } +template +problem_t::problem_t(const problem_t& problem_, + const raft::handle_t* handle_ptr_) + : original_problem_ptr(problem_.original_problem_ptr), + tolerances(problem_.tolerances), + handle_ptr(handle_ptr_), + integer_fixed_problem(problem_.integer_fixed_problem), + integer_fixed_variable_map(problem_.integer_fixed_variable_map, handle_ptr->get_stream()), + branch_and_bound_callback(nullptr), + set_root_relaxation_solution_callback(nullptr), + n_variables(problem_.n_variables), + n_constraints(problem_.n_constraints), + n_binary_vars(problem_.n_binary_vars), + n_integer_vars(problem_.n_integer_vars), + nnz(problem_.nnz), + maximize(problem_.maximize), + empty(problem_.empty), + is_binary_pb(problem_.is_binary_pb), + presolve_data(problem_.presolve_data, handle_ptr->get_stream()), + original_ids(problem_.original_ids), + reverse_original_ids(problem_.reverse_original_ids), + reverse_coefficients(problem_.reverse_coefficients, handle_ptr->get_stream()), + reverse_constraints(problem_.reverse_constraints, handle_ptr->get_stream()), + reverse_offsets(problem_.reverse_offsets, handle_ptr->get_stream()), + coefficients(problem_.coefficients, handle_ptr->get_stream()), + variables(problem_.variables, handle_ptr->get_stream()), + offsets(problem_.offsets, handle_ptr->get_stream()), + objective_coefficients(problem_.objective_coefficients, handle_ptr->get_stream()), + variable_bounds(problem_.variable_bounds, handle_ptr->get_stream()), + constraint_lower_bounds(problem_.constraint_lower_bounds, handle_ptr->get_stream()), + constraint_upper_bounds(problem_.constraint_upper_bounds, handle_ptr->get_stream()), + combined_bounds(problem_.combined_bounds, handle_ptr->get_stream()), + variable_types(problem_.variable_types, handle_ptr->get_stream()), + integer_indices(problem_.integer_indices, handle_ptr->get_stream()), + binary_indices(problem_.binary_indices, handle_ptr->get_stream()), + nonbinary_indices(problem_.nonbinary_indices, handle_ptr->get_stream()), + is_binary_variable(problem_.is_binary_variable, handle_ptr->get_stream()), + related_variables(problem_.related_variables, handle_ptr->get_stream()), + related_variables_offsets(problem_.related_variables_offsets, handle_ptr->get_stream()), + var_names(problem_.var_names), + row_names(problem_.row_names), + objective_name(problem_.objective_name), + is_scaled_(problem_.is_scaled_), + preprocess_called(problem_.preprocess_called), + objective_is_integral(problem_.objective_is_integral), + lp_state(problem_.lp_state, handle_ptr), + fixing_helpers(problem_.fixing_helpers, handle_ptr), + vars_with_objective_coeffs(problem_.vars_with_objective_coeffs), + expensive_to_fix_vars(problem_.expensive_to_fix_vars), + Q_offsets(problem_.Q_offsets), + Q_indices(problem_.Q_indices), + Q_values(problem_.Q_values) +{ +} + template problem_t::problem_t(const problem_t& problem_, bool no_deep_copy) : original_problem_ptr(problem_.original_problem_ptr), @@ -726,138 +782,6 @@ void problem_t::check_problem_representation(bool check_transposed, } } -template -bool problem_t::pre_process_assignment(rmm::device_uvector& assignment) -{ - raft::common::nvtx::range fun_scope("pre_process_assignment"); - auto has_nans = cuopt::linear_programming::detail::has_nans(handle_ptr, assignment); - if (has_nans) { - CUOPT_LOG_DEBUG("Solution discarded due to nans"); - return false; - } - cuopt_assert(assignment.size() == original_problem_ptr->get_n_variables(), "size mismatch"); - - // create a temp assignment with the var size after bounds standardization (free vars added) - rmm::device_uvector temp_assignment(presolve_data.additional_var_used.size(), - handle_ptr->get_stream()); - // copy the assignment to the first part(the original variable count) of the temp_assignment - raft::copy( - temp_assignment.data(), assignment.data(), assignment.size(), handle_ptr->get_stream()); - auto d_additional_var_used = - cuopt::device_copy(presolve_data.additional_var_used, handle_ptr->get_stream()); - auto d_additional_var_id_per_var = - cuopt::device_copy(presolve_data.additional_var_id_per_var, handle_ptr->get_stream()); - - thrust::for_each(handle_ptr->get_thrust_policy(), - thrust::make_counting_iterator(0), - thrust::make_counting_iterator(original_problem_ptr->get_n_variables()), - [additional_var_used = d_additional_var_used.data(), - additional_var_id_per_var = d_additional_var_id_per_var.data(), - assgn = temp_assignment.data()] __device__(auto idx) { - if (additional_var_used[idx]) { - cuopt_assert(additional_var_id_per_var[idx] != -1, - "additional_var_id_per_var is not set"); - // We have two non-negative variables y and z that simulate a free variable - // x. If the value of x is negative, we can set z to be something higher than - // y. If the value of x is positive we can set y greater than z - assgn[additional_var_id_per_var[idx]] = (assgn[idx] < 0 ? -assgn[idx] : 0.); - assgn[idx] += assgn[additional_var_id_per_var[idx]]; - } - }); - assignment.resize(n_variables, handle_ptr->get_stream()); - assignment.shrink_to_fit(handle_ptr->get_stream()); - cuopt_assert(presolve_data.variable_mapping.size() == n_variables, "size mismatch"); - thrust::gather(handle_ptr->get_thrust_policy(), - presolve_data.variable_mapping.begin(), - presolve_data.variable_mapping.end(), - temp_assignment.begin(), - assignment.begin()); - handle_ptr->sync_stream(); - - auto has_integrality_discrepancy = cuopt::linear_programming::detail::has_integrality_discrepancy( - handle_ptr, integer_indices, assignment, tolerances.integrality_tolerance); - if (has_integrality_discrepancy) { - CUOPT_LOG_DEBUG("Solution discarded due to integrality discrepancy"); - return false; - } - - auto has_variable_bounds_violation = - cuopt::linear_programming::detail::has_variable_bounds_violation(handle_ptr, assignment, this); - if (has_variable_bounds_violation) { - CUOPT_LOG_DEBUG("Solution discarded due to variable bounds violation"); - return false; - } - return true; -} - -// this function is used to post process the assignment -// it removes the additional variable for free variables -// and expands the assignment to the original variable dimension -template -void problem_t::post_process_assignment(rmm::device_uvector& current_assignment, - bool resize_to_original_problem) -{ - raft::common::nvtx::range fun_scope("post_process_assignment"); - cuopt_assert(current_assignment.size() == presolve_data.variable_mapping.size(), "size mismatch"); - auto assgn = make_span(current_assignment); - auto fixed_assgn = make_span(presolve_data.fixed_var_assignment); - auto var_map = make_span(presolve_data.variable_mapping); - if (current_assignment.size() > 0) { - thrust::for_each(handle_ptr->get_thrust_policy(), - thrust::make_counting_iterator(0), - thrust::make_counting_iterator(current_assignment.size()), - [fixed_assgn, var_map, assgn] __device__(auto idx) { - fixed_assgn[var_map[idx]] = assgn[idx]; - }); - } - expand_device_copy( - current_assignment, presolve_data.fixed_var_assignment, handle_ptr->get_stream()); - auto h_assignment = cuopt::host_copy(current_assignment, handle_ptr->get_stream()); - cuopt_assert(presolve_data.additional_var_id_per_var.size() == h_assignment.size(), - "Size mismatch"); - cuopt_assert(presolve_data.additional_var_used.size() == h_assignment.size(), "Size mismatch"); - for (i_t i = 0; i < (i_t)h_assignment.size(); ++i) { - if (presolve_data.additional_var_used[i]) { - cuopt_assert(presolve_data.additional_var_id_per_var[i] != -1, - "additional_var_id_per_var is not set"); - h_assignment[i] -= h_assignment[presolve_data.additional_var_id_per_var[i]]; - } - } - - // Apply variable substitutions from probing: x_substituted = offset + coefficient * - // x_substituting - for (const auto& sub : presolve_data.variable_substitutions) { - cuopt_assert(sub.substituted_var < (i_t)h_assignment.size(), "substituted_var out of bounds"); - cuopt_assert(sub.substituting_var < (i_t)h_assignment.size(), "substituting_var out of bounds"); - h_assignment[sub.substituted_var] = - sub.offset + sub.coefficient * h_assignment[sub.substituting_var]; - CUOPT_LOG_DEBUG("Post-process substitution: x[%d] = %f + %f * x[%d] = %f", - sub.substituted_var, - sub.offset, - sub.coefficient, - sub.substituting_var, - h_assignment[sub.substituted_var]); - } - - raft::copy( - current_assignment.data(), h_assignment.data(), h_assignment.size(), handle_ptr->get_stream()); - // this separate resizing is needed because of the callback - if (resize_to_original_problem) { - current_assignment.resize(original_problem_ptr->get_n_variables(), handle_ptr->get_stream()); - } -} - -template -void problem_t::post_process_solution(solution_t& solution) -{ - raft::common::nvtx::range fun_scope("post_process_solution"); - post_process_assignment(solution.assignment); - // this is for resizing other fields such as excess, slack so that we can compute the feasibility - solution.resize_to_original_problem(); - handle_ptr->sync_stream(); - solution.post_process_completed = true; -} - template void problem_t::recompute_auxilliary_data(bool check_representation) { @@ -1892,6 +1816,44 @@ void problem_t::preprocess_problem() preprocess_called = true; } +template +bool problem_t::pre_process_assignment(rmm::device_uvector& assignment) +{ + return presolve_data.pre_process_assignment(*this, assignment); +} + +template +void problem_t::post_process_assignment(rmm::device_uvector& current_assignment, + bool resize_to_original_problem) +{ + presolve_data.post_process_assignment(*this, current_assignment, resize_to_original_problem); +} + +template +void problem_t::post_process_solution(solution_t& solution) +{ + presolve_data.post_process_solution(*this, solution); +} + +template +void problem_t::set_papilo_presolve_data( + const third_party_presolve_t* presolver_ptr, + std::vector reduced_to_original, + std::vector original_to_reduced, + i_t original_num_variables) +{ + presolve_data.set_papilo_presolve_data(presolver_ptr, + std::move(reduced_to_original), + std::move(original_to_reduced), + original_num_variables); +} + +template +void problem_t::papilo_uncrush_assignment(rmm::device_uvector& assignment) const +{ + presolve_data.papilo_uncrush_assignment(const_cast(*this), assignment); +} + template void problem_t::get_host_user_problem( cuopt::linear_programming::dual_simplex::user_problem_t& user_problem) const diff --git a/cpp/src/mip/problem/problem.cuh b/cpp/src/mip/problem/problem.cuh index 910079916..6cbd5e5a5 100644 --- a/cpp/src/mip/problem/problem.cuh +++ b/cpp/src/mip/problem/problem.cuh @@ -40,6 +40,9 @@ namespace linear_programming::detail { template class solution_t; +template +class third_party_presolve_t; + constexpr double OBJECTIVE_EPSILON = 1e-7; constexpr double MACHINE_EPSILON = 1e-7; constexpr bool USE_REL_TOLERANCE = true; @@ -52,6 +55,7 @@ class problem_t { problem_t() = delete; // copy constructor problem_t(const problem_t& problem); + problem_t(const problem_t& problem, const raft::handle_t* handle_ptr_); problem_t(const problem_t& problem, bool no_deep_copy); problem_t(problem_t&& problem) = default; problem_t& operator=(problem_t&&) = default; @@ -89,6 +93,16 @@ class problem_t { void post_process_assignment(rmm::device_uvector& current_assignment, bool resize_to_original_problem = true); void post_process_solution(solution_t& solution); + void set_papilo_presolve_data(const third_party_presolve_t* presolver_ptr, + std::vector reduced_to_original, + std::vector original_to_reduced, + i_t original_num_variables); + bool has_papilo_presolve_data() const { return presolve_data.has_papilo_presolve_data(); } + i_t get_papilo_original_num_variables() const + { + return presolve_data.get_papilo_original_num_variables(); + } + void papilo_uncrush_assignment(rmm::device_uvector& assignment) const; void compute_transpose_of_problem(); f_t get_user_obj_from_solver_obj(f_t solver_obj) const; f_t get_solver_obj_from_user_obj(f_t user_obj) const; diff --git a/cpp/src/mip/relaxed_lp/lp_state.cuh b/cpp/src/mip/relaxed_lp/lp_state.cuh index fad0a7f61..3820758dc 100644 --- a/cpp/src/mip/relaxed_lp/lp_state.cuh +++ b/cpp/src/mip/relaxed_lp/lp_state.cuh @@ -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 */ @@ -38,6 +38,12 @@ class lp_state_t { { } + lp_state_t(const lp_state_t& other, const raft::handle_t* handle_ptr) + : prev_primal(other.prev_primal, handle_ptr->get_stream()), + prev_dual(other.prev_dual, handle_ptr->get_stream()) + { + } + lp_state_t(lp_state_t&& other) noexcept = default; lp_state_t& operator=(lp_state_t&& other) noexcept = default; diff --git a/cpp/src/mip/solution/solution.cu b/cpp/src/mip/solution/solution.cu index 9e9a2d75f..08f007691 100644 --- a/cpp/src/mip/solution/solution.cu +++ b/cpp/src/mip/solution/solution.cu @@ -607,9 +607,9 @@ mip_solution_t solution_t::get_solution(bool output_feasible if (output_feasible) { // TODO we can streamline these info in class - f_t rel_mip_gap = compute_rel_mip_gap(h_user_obj, stats.solution_bound); - f_t abs_mip_gap = fabs(h_user_obj - stats.solution_bound); - f_t solution_bound = stats.solution_bound; + f_t solution_bound = stats.get_solution_bound(); + f_t rel_mip_gap = compute_rel_mip_gap(h_user_obj, solution_bound); + f_t abs_mip_gap = fabs(h_user_obj - solution_bound); f_t max_constraint_violation = compute_max_constraint_violation(); f_t max_int_violation = compute_max_int_violation(); f_t max_variable_bound_violation = compute_max_variable_violation(); diff --git a/cpp/src/mip/solve.cu b/cpp/src/mip/solve.cu index 3b665accb..62ee0bb95 100644 --- a/cpp/src/mip/solve.cu +++ b/cpp/src/mip/solve.cu @@ -28,6 +28,7 @@ #include #include #include +#include #include @@ -62,6 +63,15 @@ mip_solution_t run_mip(detail::problem_t& problem, auto hyper_params = settings.hyper_params; hyper_params.update_primal_weight_on_initial_solution = false; hyper_params.update_step_size_on_initial_solution = true; + if (settings.get_mip_callbacks().size() > 0) { + auto callback_num_variables = problem.original_problem_ptr->get_n_variables(); + if (problem.has_papilo_presolve_data()) { + callback_num_variables = problem.get_papilo_original_num_variables(); + } + for (auto callback : settings.get_mip_callbacks()) { + callback->template setup(callback_num_variables); + } + } // if the input problem is empty: early exit if (problem.empty) { detail::solution_t solution(problem); @@ -76,10 +86,33 @@ mip_solution_t run_mip(detail::problem_t& problem, }); problem.post_process_solution(solution); solution.compute_objective(); // just to ensure h_user_obj is set - auto stats = solver_stats_t{}; - stats.solution_bound = solution.get_user_objective(); + auto stats = solver_stats_t{}; + stats.set_solution_bound(solution.get_user_objective()); // log the objective for scripts which need it CUOPT_LOG_INFO("Best feasible: %f", solution.get_user_objective()); + for (auto callback : settings.get_mip_callbacks()) { + if (callback->get_type() == internals::base_solution_callback_type::GET_SOLUTION) { + auto temp_sol(solution); + auto get_sol_callback = static_cast(callback); + std::vector user_objective_vec(1); + std::vector user_bound_vec(1); + user_objective_vec[0] = solution.get_user_objective(); + user_bound_vec[0] = stats.get_solution_bound(); + if (problem.has_papilo_presolve_data()) { + problem.papilo_uncrush_assignment(temp_sol.assignment); + } + std::vector user_assignment_vec(temp_sol.assignment.size()); + raft::copy(user_assignment_vec.data(), + temp_sol.assignment.data(), + temp_sol.assignment.size(), + temp_sol.handle_ptr->get_stream()); + solution.handle_ptr->sync_stream(); + get_sol_callback->get_solution(user_assignment_vec.data(), + user_objective_vec.data(), + user_bound_vec.data(), + get_sol_callback->get_user_data()); + } + } return solution.get_solution(true, stats, false); } // problem contains unpreprocessed data @@ -178,15 +211,26 @@ mip_solution_t solve_mip(optimization_problem_t& op_problem, op_problem.get_handle_ptr()->get_stream()); } - auto timer = cuopt::timer_t(time_limit); - + auto timer = cuopt::timer_t(time_limit); double presolve_time = 0.0; std::unique_ptr> presolver; + std::optional> presolve_result; detail::problem_t problem(op_problem, settings.get_tolerances()); - auto run_presolve = settings.presolve; - run_presolve = run_presolve && settings.get_mip_callbacks().empty(); - run_presolve = run_presolve && settings.initial_solutions.size() == 0; + auto run_presolve = settings.presolve; + run_presolve = run_presolve && settings.initial_solutions.size() == 0; + bool has_set_solution_callback = false; + for (auto callback : settings.get_mip_callbacks()) { + if (callback != nullptr && + callback->get_type() == internals::base_solution_callback_type::SET_SOLUTION) { + has_set_solution_callback = true; + break; + } + } + if (run_presolve && has_set_solution_callback) { + CUOPT_LOG_WARN("Presolve is disabled because set_solution callbacks are provided."); + run_presolve = false; + } if (!run_presolve) { CUOPT_LOG_INFO("Presolve is disabled, skipping"); } @@ -209,12 +253,17 @@ mip_solution_t solve_mip(optimization_problem_t& op_problem, solver_stats_t{}, op_problem.get_handle_ptr()->get_stream()); } - - problem = detail::problem_t(result->reduced_problem); - problem.set_implied_integers(result->implied_integer_indices); + presolve_result.emplace(std::move(*result)); + + problem = detail::problem_t(presolve_result->reduced_problem); + problem.set_papilo_presolve_data(presolver.get(), + presolve_result->reduced_to_original_map, + presolve_result->original_to_reduced_map, + op_problem.get_n_variables()); + problem.set_implied_integers(presolve_result->implied_integer_indices); presolve_time = timer.elapsed_time(); - if (result->implied_integer_indices.size() > 0) { - CUOPT_LOG_INFO("%d implied integers", result->implied_integer_indices.size()); + if (presolve_result->implied_integer_indices.size() > 0) { + CUOPT_LOG_INFO("%d implied integers", presolve_result->implied_integer_indices.size()); } if (problem.is_objective_integral()) { CUOPT_LOG_INFO("Objective function is integral"); } CUOPT_LOG_INFO("Papilo presolve time: %f", presolve_time); diff --git a/cpp/src/mip/solver.cu b/cpp/src/mip/solver.cu index 9cb9e11ad..b88d39eaa 100644 --- a/cpp/src/mip/solver.cu +++ b/cpp/src/mip/solver.cu @@ -5,8 +5,6 @@ */ /* clang-format on */ -#include "feasibility_jump/feasibility_jump.cuh" - #include #include "diversity/diversity_manager.cuh" #include "local_search/local_search.cuh" @@ -87,11 +85,6 @@ struct branch_and_bound_solution_helper_t { template solution_t mip_solver_t::run_solver() { - if (context.settings.get_mip_callbacks().size() > 0) { - for (auto callback : context.settings.get_mip_callbacks()) { - callback->template setup(context.problem_ptr->original_problem_ptr->get_n_variables()); - } - } // we need to keep original problem const cuopt_assert(context.problem_ptr != nullptr, "invalid problem pointer"); context.problem_ptr->tolerances = context.settings.get_tolerances(); @@ -99,15 +92,20 @@ solution_t mip_solver_t::run_solver() error_type_t::RuntimeError, "preprocess_problem should be called before running the solver"); + diversity_manager_t dm(context); if (context.problem_ptr->empty) { CUOPT_LOG_INFO("Problem fully reduced in presolve"); solution_t sol(*context.problem_ptr); sol.set_problem_fully_reduced(); + for (auto callback : context.settings.get_mip_callbacks()) { + if (callback->get_type() == internals::base_solution_callback_type::GET_SOLUTION) { + auto get_sol_callback = static_cast(callback); + dm.population.invoke_get_solution_callback(sol, get_sol_callback); + } + } context.problem_ptr->post_process_solution(sol); return sol; } - - diversity_manager_t dm(context); dm.timer = timer_; bool presolve_success = dm.run_presolve(timer_.remaining_time()); if (!presolve_success) { @@ -121,6 +119,12 @@ solution_t mip_solver_t::run_solver() CUOPT_LOG_INFO("Problem full reduced in presolve"); solution_t sol(*context.problem_ptr); sol.set_problem_fully_reduced(); + for (auto callback : context.settings.get_mip_callbacks()) { + if (callback->get_type() == internals::base_solution_callback_type::GET_SOLUTION) { + auto get_sol_callback = static_cast(callback); + dm.population.invoke_get_solution_callback(sol, get_sol_callback); + } + } context.problem_ptr->post_process_solution(sol); return sol; } @@ -143,6 +147,14 @@ solution_t mip_solver_t::run_solver() opt_sol.get_termination_status() == pdlp_termination_status_t::DualInfeasible) { sol.set_problem_fully_reduced(); } + if (opt_sol.get_termination_status() == pdlp_termination_status_t::Optimal) { + for (auto callback : context.settings.get_mip_callbacks()) { + if (callback->get_type() == internals::base_solution_callback_type::GET_SOLUTION) { + auto get_sol_callback = static_cast(callback); + dm.population.invoke_get_solution_callback(sol, get_sol_callback); + } + } + } context.problem_ptr->post_process_solution(sol); return sol; } @@ -209,6 +221,9 @@ solution_t mip_solver_t::run_solver() branch_and_bound_problem, branch_and_bound_settings); context.branch_and_bound_ptr = branch_and_bound.get(); branch_and_bound->set_concurrent_lp_root_solve(true); + auto* stats_ptr = &context.stats; + branch_and_bound->set_user_bound_callback( + [stats_ptr](f_t user_bound) { stats_ptr->set_solution_bound(user_bound); }); // Set the primal heuristics -> branch and bound callback context.problem_ptr->branch_and_bound_callback = @@ -236,13 +251,14 @@ solution_t mip_solver_t::run_solver() } // Start the primal heuristics - auto sol = dm.run_solver(); + context.diversity_manager_ptr = &dm; + auto sol = dm.run_solver(); if (!context.settings.heuristics_only) { // Wait for the branch and bound to finish auto bb_status = branch_and_bound_status_future.get(); if (branch_and_bound_solution.lower_bound > -std::numeric_limits::infinity()) { - context.stats.solution_bound = - context.problem_ptr->get_user_obj_from_solver_obj(branch_and_bound_solution.lower_bound); + context.stats.set_solution_bound( + context.problem_ptr->get_user_obj_from_solver_obj(branch_and_bound_solution.lower_bound)); } if (bb_status == dual_simplex::mip_status_t::INFEASIBLE) { sol.set_problem_fully_reduced(); } context.stats.num_nodes = branch_and_bound_solution.nodes_explored; diff --git a/cpp/src/mip/solver_context.cuh b/cpp/src/mip/solver_context.cuh index 59ada5feb..293a36785 100644 --- a/cpp/src/mip/solver_context.cuh +++ b/cpp/src/mip/solver_context.cuh @@ -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,8 @@ #include #include +#include + #pragma once // Forward declare @@ -21,6 +23,9 @@ class branch_and_bound_t; namespace cuopt::linear_programming::detail { +template +class diversity_manager_t; + // Aggregate structure containing the global context of the solving process for convenience: // The current problem, user settings, raft handle and statistics objects template @@ -32,13 +37,14 @@ struct mip_solver_context_t { : handle_ptr(handle_ptr_), problem_ptr(problem_ptr_), settings(settings_), scaling(scaling) { cuopt_assert(problem_ptr != nullptr, "problem_ptr is nullptr"); - stats.solution_bound = problem_ptr->maximize ? std::numeric_limits::infinity() - : -std::numeric_limits::infinity(); + stats.set_solution_bound(problem_ptr->maximize ? std::numeric_limits::infinity() + : -std::numeric_limits::infinity()); } raft::handle_t const* const handle_ptr; problem_t* problem_ptr; dual_simplex::branch_and_bound_t* branch_and_bound_ptr{nullptr}; + diversity_manager_t* diversity_manager_ptr{nullptr}; std::atomic preempt_heuristic_solver_ = false; const mip_solver_settings_t settings; pdlp_initial_scaling_strategy_t& scaling; diff --git a/cpp/src/mip/solver_settings.cu b/cpp/src/mip/solver_settings.cu index 205d4fe68..f69b45575 100644 --- a/cpp/src/mip/solver_settings.cu +++ b/cpp/src/mip/solver_settings.cu @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2023-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 */ /* clang-format on */ @@ -25,8 +25,10 @@ void mip_solver_settings_t::add_initial_solution(const f_t* initial_so template void mip_solver_settings_t::set_mip_callback( - internals::base_solution_callback_t* callback) + internals::base_solution_callback_t* callback, void* user_data) { + if (callback == nullptr) { return; } + callback->set_user_data(user_data); mip_callbacks_.push_back(callback); } diff --git a/cpp/src/mip/solver_solution.cu b/cpp/src/mip/solver_solution.cu index 2ce6d5700..af3947d69 100644 --- a/cpp/src/mip/solver_solution.cu +++ b/cpp/src/mip/solver_solution.cu @@ -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,7 +105,7 @@ f_t mip_solution_t::get_mip_gap() const template f_t mip_solution_t::get_solution_bound() const { - return stats_.solution_bound; + return stats_.get_solution_bound(); } template 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 33aac9b3e..799a42914 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 @@ -9,8 +9,10 @@ #include +#include #include #include +#include #ifdef _cplusplus #error "This file must be compiled as C code" @@ -131,6 +133,179 @@ cuopt_int_t test_bad_parameter_name() { return status; } +typedef struct mip_callback_context_t { + cuopt_int_t n_variables; + int get_calls; + int set_calls; + int error; + cuopt_float_t last_objective; + cuopt_float_t last_solution_bound; + cuopt_float_t* last_solution; +} mip_callback_context_t; + +static void mip_get_solution_callback(const cuopt_float_t* solution, + const cuopt_float_t* objective_value, + const cuopt_float_t* solution_bound, + void* user_data) +{ + mip_callback_context_t* context = (mip_callback_context_t*)user_data; + if (context == NULL) { return; } + context->get_calls += 1; + if (context->last_solution == NULL) { + context->last_solution = + (cuopt_float_t*)malloc(context->n_variables * sizeof(cuopt_float_t)); + if (context->last_solution == NULL) { + context->error = 1; + return; + } + } + memcpy(context->last_solution, + solution, + context->n_variables * sizeof(cuopt_float_t)); + memcpy(&context->last_objective, objective_value, sizeof(cuopt_float_t)); + memcpy(&context->last_solution_bound, solution_bound, sizeof(cuopt_float_t)); +} + +static void mip_set_solution_callback(cuopt_float_t* solution, + cuopt_float_t* objective_value, + const cuopt_float_t* solution_bound, + void* user_data) +{ + mip_callback_context_t* context = (mip_callback_context_t*)user_data; + if (context == NULL) { return; } + context->set_calls += 1; + memcpy(&context->last_solution_bound, solution_bound, sizeof(cuopt_float_t)); + if (context->last_solution == NULL) { return; } + memcpy(solution, + context->last_solution, + context->n_variables * sizeof(cuopt_float_t)); + memcpy(objective_value, &context->last_objective, sizeof(cuopt_float_t)); +} + +static cuopt_int_t test_mip_callbacks_internal(int include_set_callback) +{ + cuOptOptimizationProblem problem = NULL; + cuOptSolverSettings settings = NULL; + cuOptSolution solution = NULL; + mip_callback_context_t context = {0}; + +#define NUM_ITEMS 8 +#define NUM_CONSTRAINTS 1 + cuopt_int_t num_items = NUM_ITEMS; + cuopt_float_t max_weight = 102; + cuopt_float_t value[] = {15, 100, 90, 60, 40, 15, 10, 1}; + cuopt_float_t weight[] = {2, 20, 20, 30, 40, 30, 60, 10}; + + cuopt_int_t num_variables = NUM_ITEMS; + cuopt_int_t num_constraints = NUM_CONSTRAINTS; + + cuopt_int_t row_offsets[] = {0, NUM_ITEMS}; + cuopt_int_t column_indices[NUM_ITEMS]; + + cuopt_float_t rhs[] = {max_weight}; + char constraint_sense[] = {CUOPT_LESS_THAN}; + cuopt_float_t lower_bounds[NUM_ITEMS]; + cuopt_float_t upper_bounds[NUM_ITEMS]; + char variable_types[NUM_ITEMS]; + cuopt_int_t status; + + for (cuopt_int_t j = 0; j < NUM_ITEMS; j++) { + column_indices[j] = j; + } + + for (cuopt_int_t j = 0; j < NUM_ITEMS; j++) { + variable_types[j] = CUOPT_INTEGER; + lower_bounds[j] = 0; + upper_bounds[j] = 1; + } + + status = cuOptCreateProblem(num_constraints, + num_variables, + CUOPT_MAXIMIZE, + 0, + value, + row_offsets, + column_indices, + weight, + constraint_sense, + rhs, + lower_bounds, + upper_bounds, + variable_types, + &problem); + if (status != CUOPT_SUCCESS) { + printf("Error creating optimization problem\n"); + goto DONE; + } + + status = cuOptCreateSolverSettings(&settings); + if (status != CUOPT_SUCCESS) { + printf("Error creating solver settings\n"); + goto DONE; + } + + context.n_variables = num_variables; + status = cuOptSetMIPGetSolutionCallback(settings, mip_get_solution_callback, &context); + if (status != CUOPT_SUCCESS) { + printf("Error setting get-solution callback\n"); + goto DONE; + } + + if (include_set_callback) { + status = cuOptSetMIPSetSolutionCallback(settings, mip_set_solution_callback, &context); + if (status != CUOPT_SUCCESS) { + printf("Error setting set-solution callback\n"); + goto DONE; + } + } + + status = cuOptSolve(problem, settings, &solution); + if (status != CUOPT_SUCCESS) { + printf("Error solving problem\n"); + goto DONE; + } + + if (context.error != 0) { + printf("Error in callback data transfer\n"); + status = CUOPT_INVALID_ARGUMENT; + goto DONE; + } + + if (context.last_solution_bound != context.last_solution_bound) { + printf("Error reading solution bound in callback\n"); + status = CUOPT_INVALID_ARGUMENT; + goto DONE; + } + + if (context.get_calls < 1) { + printf("Expected get-solution callback to be called at least once\n"); + status = CUOPT_INVALID_ARGUMENT; + goto DONE; + } + if (include_set_callback && context.set_calls < 1) { + printf("Expected set-solution callback to be called at least once\n"); + status = CUOPT_INVALID_ARGUMENT; + goto DONE; + } + +DONE: + if (context.last_solution != NULL) { free(context.last_solution); } + cuOptDestroyProblem(&problem); + cuOptDestroySolverSettings(&settings); + cuOptDestroySolution(&solution); + return status; +} + +cuopt_int_t test_mip_get_callbacks_only() +{ + return test_mip_callbacks_internal(0); +} + +cuopt_int_t test_mip_get_set_callbacks() +{ + return test_mip_callbacks_internal(1); +} + cuopt_int_t burglar_problem() { cuOptOptimizationProblem problem = NULL; 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 e67e6202c..273924ec0 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 @@ -92,6 +92,10 @@ TEST(c_api, solve_time_bb_preemption) TEST(c_api, bad_parameter_name) { EXPECT_EQ(test_bad_parameter_name(), CUOPT_INVALID_ARGUMENT); } +TEST(c_api, mip_get_callbacks_only) { EXPECT_EQ(test_mip_get_callbacks_only(), CUOPT_SUCCESS); } + +TEST(c_api, mip_get_set_callbacks) { EXPECT_EQ(test_mip_get_set_callbacks(), CUOPT_SUCCESS); } + TEST(c_api, burglar) { EXPECT_EQ(burglar_problem(), CUOPT_SUCCESS); } TEST(c_api, test_missing_file) { EXPECT_EQ(test_missing_file(), CUOPT_MPS_FILE_ERROR); } 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 4898e0639..179d7deea 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 @@ -28,6 +28,8 @@ cuopt_int_t solve_mps_file(const char* filename, cuopt_int_t test_missing_file(); cuopt_int_t test_infeasible_problem(); cuopt_int_t test_bad_parameter_name(); +cuopt_int_t test_mip_get_callbacks_only(); +cuopt_int_t test_mip_get_set_callbacks(); cuopt_int_t test_ranged_problem(cuopt_int_t* termination_status_ptr, cuopt_float_t* objective_ptr); cuopt_int_t test_invalid_bounds(cuopt_int_t test_mip); cuopt_int_t test_quadratic_problem(cuopt_int_t* termination_status_ptr, diff --git a/cpp/tests/linear_programming/utilities/pdlp_test_utilities.cuh b/cpp/tests/linear_programming/utilities/pdlp_test_utilities.cuh index 2eefb1838..21f2591e7 100644 --- a/cpp/tests/linear_programming/utilities/pdlp_test_utilities.cuh +++ b/cpp/tests/linear_programming/utilities/pdlp_test_utilities.cuh @@ -40,6 +40,10 @@ static void test_objective_sanity( { const auto primal_vars = host_copy(primal_solution, primal_solution.stream()); const auto& c_vector = op_problem.get_objective_coefficients(); + if (primal_vars.size() != c_vector.size()) { + EXPECT_EQ(primal_vars.size(), c_vector.size()); + return; + } std::vector out(primal_vars.size()); std::transform(primal_vars.cbegin(), primal_vars.cend(), @@ -52,6 +56,30 @@ static void test_objective_sanity( EXPECT_NEAR(sum, objective_value, epsilon); } +// Compute on the CPU x * c to check that the returned objective value is correct +static void test_objective_sanity( + const cuopt::mps_parser::mps_data_model_t& op_problem, + const std::vector& primal_solution, + double objective_value, + double epsilon = tolerance) +{ + const auto& c_vector = op_problem.get_objective_coefficients(); + if (primal_solution.size() != c_vector.size()) { + EXPECT_EQ(primal_solution.size(), c_vector.size()); + return; + } + std::vector out(primal_solution.size()); + std::transform(primal_solution.cbegin(), + primal_solution.cend(), + c_vector.cbegin(), + out.begin(), + std::multiplies()); + + double sum = std::reduce(out.cbegin(), out.cend(), 0.0); + + EXPECT_NEAR(sum, objective_value, epsilon); +} + // Compute A @ x, compute the residual (distance to combined bounds) // Check that it corresponds to the bound resdiual // Check that it respect the absolute/relative tolerance diff --git a/cpp/tests/mip/incumbent_callback_test.cu b/cpp/tests/mip/incumbent_callback_test.cu index 6d6792df2..3fc06a8ad 100644 --- a/cpp/tests/mip/incumbent_callback_test.cu +++ b/cpp/tests/mip/incumbent_callback_test.cu @@ -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 */ @@ -24,8 +24,10 @@ #include #include +#include #include #include +#include #include #include #include @@ -34,48 +36,55 @@ namespace cuopt::linear_programming::test { class test_set_solution_callback_t : public cuopt::internals::set_solution_callback_t { public: - test_set_solution_callback_t( - std::vector, double>>& solutions_) - : solutions(solutions_), n_calls(0) + test_set_solution_callback_t(std::vector, double>>& solutions_, + void* expected_user_data_) + : solutions(solutions_), expected_user_data(expected_user_data_), n_calls(0) { } // This will check that the we are able to recompute our own solution - void set_solution(void* data, void* cost) override + void set_solution(void* data, void* cost, void* solution_bound, void* user_data) override { + EXPECT_EQ(user_data, expected_user_data); n_calls++; - rmm::cuda_stream_view stream{}; + auto bound_ptr = static_cast(solution_bound); + EXPECT_FALSE(std::isnan(bound_ptr[0])); auto assignment = static_cast(data); auto cost_ptr = static_cast(cost); if (solutions.empty()) { return; } auto const& [last_assignment, last_cost] = solutions.back(); - raft::copy(assignment, last_assignment.data(), last_assignment.size(), stream); - raft::copy(cost_ptr, &last_cost, 1, stream); - stream.synchronize(); + std::copy(last_assignment.begin(), last_assignment.end(), assignment); + *cost_ptr = last_cost; } - std::vector, double>>& solutions; + std::vector, double>>& solutions; + void* expected_user_data; int n_calls; }; class test_get_solution_callback_t : public cuopt::internals::get_solution_callback_t { public: - test_get_solution_callback_t( - std::vector, double>>& solutions_in, int n_variables_) - : solutions(solutions_in), n_calls(0), n_variables(n_variables_) + test_get_solution_callback_t(std::vector, double>>& solutions_in, + int n_variables_, + void* expected_user_data_) + : solutions(solutions_in), + expected_user_data(expected_user_data_), + n_calls(0), + n_variables(n_variables_) { } - void get_solution(void* data, void* cost) override + void get_solution(void* data, void* cost, void* solution_bound, void* user_data) override { + EXPECT_EQ(user_data, expected_user_data); n_calls++; - rmm::cuda_stream_view stream{}; - rmm::device_uvector assignment(n_variables, stream); - raft::copy(assignment.data(), static_cast(data), n_variables, stream); - auto h_cost = 0.; - raft::copy(&h_cost, static_cast(cost), 1, stream); - stream.synchronize(); - solutions.push_back(std::make_pair(std::move(assignment), h_cost)); + auto bound_ptr = static_cast(solution_bound); + EXPECT_FALSE(std::isnan(bound_ptr[0])); + auto assignment_ptr = static_cast(data); + auto cost_ptr = static_cast(cost); + std::vector assignment(assignment_ptr, assignment_ptr + n_variables); + solutions.push_back(std::make_pair(std::move(assignment), *cost_ptr)); } - std::vector, double>>& solutions; + std::vector, double>>& solutions; + void* expected_user_data; int n_calls; int n_variables; }; @@ -98,7 +107,7 @@ void check_solutions(const test_get_solution_callback_t& get_solution_callback, } } -void test_incumbent_callback(std::string test_instance) +void test_incumbent_callback(std::string test_instance, bool include_set_callback) { const raft::handle_t handle_{}; std::cout << "Running: " << test_instance << std::endl; @@ -110,23 +119,38 @@ void test_incumbent_callback(std::string test_instance) auto settings = mip_solver_settings_t{}; settings.time_limit = 30.; - std::vector, double>> solutions; - test_get_solution_callback_t get_solution_callback(solutions, op_problem.get_n_variables()); - test_set_solution_callback_t set_solution_callback(solutions); - settings.set_mip_callback(&get_solution_callback); - settings.set_mip_callback(&set_solution_callback); + settings.presolve = true; + int user_data = 42; + std::vector, double>> solutions; + test_get_solution_callback_t get_solution_callback( + solutions, op_problem.get_n_variables(), &user_data); + settings.set_mip_callback(&get_solution_callback, &user_data); + std::unique_ptr set_solution_callback; + if (include_set_callback) { + set_solution_callback = std::make_unique(solutions, &user_data); + settings.set_mip_callback(set_solution_callback.get(), &user_data); + } auto solution = solve_mip(op_problem, settings); EXPECT_GE(get_solution_callback.n_calls, 1); - EXPECT_GE(set_solution_callback.n_calls, 1); + if (include_set_callback) { EXPECT_GE(set_solution_callback->n_calls, 1); } check_solutions(get_solution_callback, mps_problem, settings); } -TEST(mip_solve, incumbent_callback_test) +TEST(mip_solve, incumbent_get_callback_test) +{ + std::vector test_instances = { + "mip/50v-10.mps", "mip/neos5-free-bound.mps", "mip/swath1.mps"}; + for (const auto& test_instance : test_instances) { + test_incumbent_callback(test_instance, false); + } +} + +TEST(mip_solve, incumbent_get_set_callback_test) { std::vector test_instances = { "mip/50v-10.mps", "mip/neos5-free-bound.mps", "mip/swath1.mps"}; for (const auto& test_instance : test_instances) { - test_incumbent_callback(test_instance); + test_incumbent_callback(test_instance, true); } } diff --git a/cpp/tests/mip/mip_utils.cuh b/cpp/tests/mip/mip_utils.cuh index 19c44b2fd..3bb4a2729 100644 --- a/cpp/tests/mip/mip_utils.cuh +++ b/cpp/tests/mip/mip_utils.cuh @@ -42,6 +42,33 @@ static void test_variable_bounds( EXPECT_TRUE(result); } +static void test_variable_bounds( + const cuopt::mps_parser::mps_data_model_t& problem, + const std::vector& solution, + const cuopt::linear_programming::mip_solver_settings_t settings) +{ + const double* lower_bound_ptr = problem.get_variable_lower_bounds().data(); + const double* upper_bound_ptr = problem.get_variable_upper_bounds().data(); + const double* assignment_ptr = solution.data(); + cuopt_assert(solution.size() == problem.get_variable_lower_bounds().size(), ""); + cuopt_assert(solution.size() == problem.get_variable_upper_bounds().size(), ""); + std::vector indices(solution.size()); + std::iota(indices.begin(), indices.end(), 0); + bool result = std::all_of(indices.begin(), indices.end(), [=](int idx) { + bool res = true; + if (lower_bound_ptr != nullptr) { + res = res && (assignment_ptr[idx] >= + lower_bound_ptr[idx] - settings.tolerances.integrality_tolerance); + } + if (upper_bound_ptr != nullptr) { + res = res && (assignment_ptr[idx] <= + upper_bound_ptr[idx] + settings.tolerances.integrality_tolerance); + } + return res; + }); + EXPECT_TRUE(result); +} + template static double combine_finite_abs_bounds(f_t lower, f_t upper) { @@ -80,7 +107,6 @@ static void test_constraint_sanity_per_row( const std::vector& variable_lower_bounds = op_problem.get_variable_lower_bounds(); const std::vector& variable_upper_bounds = op_problem.get_variable_upper_bounds(); std::vector residual(constraint_lower_bounds.size(), 0.0); - std::vector viol(constraint_lower_bounds.size(), 0.0); auto h_solution = cuopt::host_copy(solution, solution.stream()); // CSR SpMV for (size_t i = 0; i < offsets.size() - 1; ++i) { @@ -101,6 +127,37 @@ static void test_constraint_sanity_per_row( } } +static void test_constraint_sanity_per_row( + const cuopt::mps_parser::mps_data_model_t& op_problem, + const std::vector& solution, + double abs_tolerance, + double rel_tolerance) +{ + const std::vector& values = op_problem.get_constraint_matrix_values(); + const std::vector& indices = op_problem.get_constraint_matrix_indices(); + const std::vector& offsets = op_problem.get_constraint_matrix_offsets(); + const std::vector& constraint_lower_bounds = op_problem.get_constraint_lower_bounds(); + const std::vector& constraint_upper_bounds = op_problem.get_constraint_upper_bounds(); + std::vector residual(constraint_lower_bounds.size(), 0.0); + // CSR SpMV + for (size_t i = 0; i < offsets.size() - 1; ++i) { + for (int j = offsets[i]; j < offsets[i + 1]; ++j) { + residual[i] += values[j] * solution[indices[j]]; + } + } + + auto functor = violation{}; + + // Compute violation to lower/upper bound + for (size_t i = 0; i < residual.size(); ++i) { + double tolerance = abs_tolerance + combine_finite_abs_bounds( + constraint_lower_bounds[i], constraint_upper_bounds[i]) * + rel_tolerance; + double viol = functor(residual[i], constraint_lower_bounds[i], constraint_upper_bounds[i]); + EXPECT_LE(viol, tolerance); + } +} + static std::tuple test_mps_file( std::string test_instance, double time_limit = 1, diff --git a/docs/cuopt/source/cuopt-python/lp-qp-milp/examples/incumbent_solutions_example.py b/docs/cuopt/source/cuopt-python/lp-qp-milp/examples/incumbent_solutions_example.py index 87faddc92..39460436e 100644 --- a/docs/cuopt/source/cuopt-python/lp-qp-milp/examples/incumbent_solutions_example.py +++ b/docs/cuopt/source/cuopt-python/lp-qp-milp/examples/incumbent_solutions_example.py @@ -46,12 +46,14 @@ class IncumbentCallback(GetSolutionCallback): """Callback to receive and track incumbent solutions during solving.""" - def __init__(self): + def __init__(self, user_data): super().__init__() self.solutions = [] self.n_callbacks = 0 + self.user_data = user_data - def get_solution(self, solution, solution_cost): + def get_solution(self, solution, solution_cost, solution_bound, user_data): + assert user_data is self.user_data """ Called whenever the solver finds a new incumbent solution. @@ -61,14 +63,18 @@ def get_solution(self, solution, solution_cost): The variable values of the incumbent solution solution_cost : array-like The objective value of the incumbent solution + solution_bound : array-like + The current best bound in user objective space """ self.n_callbacks += 1 # Store the incumbent solution incumbent = { - "solution": solution.copy_to_host(), - "cost": solution_cost.copy_to_host()[0], + "solution": solution.tolist(), + "cost": float(solution_cost[0]), + "bound": float(solution_bound[0]), "iteration": self.n_callbacks, + "user_data": user_data, } self.solutions.append(incumbent) @@ -98,8 +104,9 @@ def main(): # Configure solver settings with callback settings = SolverSettings() # Set the incumbent callback - incumbent_callback = IncumbentCallback() - settings.set_mip_callback(incumbent_callback) + user_data = {"source": "incumbent_solutions_example"} + incumbent_callback = IncumbentCallback(user_data) + settings.set_mip_callback(incumbent_callback, user_data) # Allow enough time to find multiple incumbents settings.set_parameter(CUOPT_TIME_LIMIT, 30) diff --git a/docs/cuopt/source/cuopt-server/examples/milp/examples/incumbent_callback_example.py b/docs/cuopt/source/cuopt-server/examples/milp/examples/incumbent_callback_example.py index 93abd5a56..ef1dcdb38 100644 --- a/docs/cuopt/source/cuopt-server/examples/milp/examples/incumbent_callback_example.py +++ b/docs/cuopt/source/cuopt-server/examples/milp/examples/incumbent_callback_example.py @@ -1,4 +1,4 @@ -# 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 """ MILP Incumbent and Logging Callback Example @@ -61,10 +61,13 @@ def main(): ip="localhost", port=5000, timeout_exception=False ) - # Incumbent callback - receives intermediate solutions - def callback(solution, solution_cost): + # Incumbent callback - receives intermediate host solutions + def callback(solution, solution_cost, solution_bound): """Called when solver finds a new incumbent solution.""" - print(f"Solution : {solution} cost : {solution_cost}\n") + print( + f"Solution : {solution} cost : {solution_cost} " + f"bound : {solution_bound}\n" + ) # Logging callback - receives server log messages def log_callback(log): diff --git a/python/cuopt/cuopt/linear_programming/internals/internals.pyx b/python/cuopt/cuopt/linear_programming/internals/internals.pyx index 0e4342fe1..1f44b5d9d 100644 --- a/python/cuopt/cuopt/linear_programming/internals/internals.pyx +++ b/python/cuopt/cuopt/linear_programming/internals/internals.pyx @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa # SPDX-License-Identifier: Apache-2.0 # cython: profile=False @@ -8,13 +8,10 @@ from libc.stdint cimport uintptr_t +from cpython.ref cimport PyObject +import ctypes import numpy as np -from numba.cuda.api import from_cuda_array_interface - - -cdef extern from "Python.h": - cdef cppclass PyObject cdef extern from "cuopt/linear_programming/utilities/callbacks_implems.hpp" namespace "cuopt::internals": # noqa @@ -23,51 +20,53 @@ cdef extern from "cuopt/linear_programming/utilities/callbacks_implems.hpp" name cdef cppclass default_get_solution_callback_t(Callback): void setup() except + - void get_solution(void* data, void* objective_value) except + + void get_solution(void* data, + void* objective_value, + void* solution_bound, + void* user_data) except + PyObject* pyCallbackClass cdef cppclass default_set_solution_callback_t(Callback): void setup() except + - void set_solution(void* data, void* objective_value) except + + void set_solution(void* data, + void* objective_value, + void* solution_bound, + void* user_data) except + PyObject* pyCallbackClass cdef class PyCallback: - def get_numba_matrix(self, data, shape, typestr): + cdef object _user_data + + def __init__(self): + self._user_data = None - sizeofType = 4 if typestr == "float32" else 8 - desc = { - 'shape': (shape,), - 'strides': None, - 'typestr': typestr, - 'data': (data, True), - 'version': 3, - } + property user_data: + def __get__(self): + return self._user_data + def __set__(self, value): + self._user_data = value - data = from_cuda_array_interface(desc, None, False) - return data + cpdef uintptr_t get_user_data_ptr(self): + cdef PyObject* ptr + if self._user_data is None: + return 0 + ptr = self._user_data + return ptr def get_numpy_array(self, data, shape, typestr): - sizeofType = 4 if typestr == "float32" else 8 - desc = { - 'shape': (shape,), - 'strides': None, - 'typestr': typestr, - 'data': (data, False), - 'version': 3 - } - data = desc['data'][0] - shape = desc['shape'] - - numpy_array = np.array([data], dtype=desc['typestr']).reshape(shape) - return numpy_array + c_type = ctypes.c_float if typestr == "float32" else ctypes.c_double + addr = int(data) + buf = (c_type * shape).from_address(addr) + return np.ctypeslib.as_array(buf) cdef class GetSolutionCallback(PyCallback): cdef default_get_solution_callback_t native_callback def __init__(self): + super().__init__() self.native_callback.pyCallbackClass = self def get_native_callback(self): @@ -79,6 +78,7 @@ cdef class SetSolutionCallback(PyCallback): cdef default_set_solution_callback_t native_callback def __init__(self): + super().__init__() self.native_callback.pyCallbackClass = self def get_native_callback(self): diff --git a/python/cuopt/cuopt/linear_programming/solver/solver.pxd b/python/cuopt/cuopt/linear_programming/solver/solver.pxd index c140e3d0c..6688e5166 100644 --- a/python/cuopt/cuopt/linear_programming/solver/solver.pxd +++ b/python/cuopt/cuopt/linear_programming/solver/solver.pxd @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2023-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa +# SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa # SPDX-License-Identifier: Apache-2.0 @@ -78,7 +78,8 @@ cdef extern from "cuopt/linear_programming/solver_settings.hpp" namespace "cuopt i_t size ) except + void set_mip_callback( - base_solution_callback_t* callback + base_solution_callback_t* callback, + void* user_data ) except + diff --git a/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx b/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx index 1991af0d6..8c1c8fdc3 100644 --- a/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx +++ b/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2023-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa +# SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa # SPDX-License-Identifier: Apache-2.0 @@ -169,6 +169,7 @@ cdef set_solver_setting( 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: if data_model_obj is not None and data_model_obj.get_initial_primal_solution().shape[0] != 0: # noqa c_solver_settings.add_initial_mip_solution( @@ -186,9 +187,15 @@ cdef set_solver_setting( for callback in callbacks: if callback: callback_ptr = callback.get_native_callback() + callback_user_data = ( + callback.get_user_data_ptr() + if hasattr(callback, "get_user_data_ptr") + else 0 + ) c_solver_settings.set_mip_callback( - callback_ptr + callback_ptr, + callback_user_data ) else: if data_model_obj is not None and data_model_obj.get_initial_primal_solution().shape[0] != 0: # noqa diff --git a/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.py b/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.py index 91565d726..cbefbfcdd 100644 --- a/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.py +++ b/python/cuopt/cuopt/linear_programming/solver_settings/solver_settings.py @@ -245,7 +245,7 @@ def set_pdlp_warm_start_data(self, pdlp_warm_start_data): """ self.pdlp_warm_start_data = pdlp_warm_start_data - def set_mip_callback(self, callback): + def set_mip_callback(self, callback, user_data): """ Note: Only supported for MILP @@ -256,36 +256,53 @@ def set_mip_callback(self, callback): callback : class for function callback Callback class that inherits from GetSolutionCallback or SetSolutionCallback. + user_data : object + User context passed to the callback. + + Notes + ----- + Registering a SetSolutionCallback disables presolve. Examples -------- >>> # Callback for incumbent solution >>> class CustomGetSolutionCallback(GetSolutionCallback): - >>> def __init__(self): + >>> def __init__(self, user_data): >>> super().__init__() >>> self.n_callbacks = 0 >>> self.solutions = [] + >>> self.user_data = user_data >>> - >>> def get_solution(self, solution, solution_cost): + >>> def get_solution( + >>> self, solution, solution_cost, solution_bound, user_data + >>> ): + >>> assert user_data is self.user_data >>> self.n_callbacks += 1 >>> assert len(solution) > 0 >>> assert len(solution_cost) == 1 + >>> assert len(solution_bound) == 1 >>> >>> self.solutions.append( >>> { - >>> "solution": solution.copy_to_host(), - >>> "cost": solution_cost.copy_to_host()[0], + >>> "solution": solution.tolist(), + >>> "cost": float(solution_cost[0]), + >>> "bound": float(solution_bound[0]), >>> } >>> ) >>> >>> class CustomSetSolutionCallback(SetSolutionCallback): - >>> def __init__(self, get_callback): + >>> def __init__(self, get_callback, user_data): >>> super().__init__() >>> self.n_callbacks = 0 >>> self.get_callback = get_callback + >>> self.user_data = user_data >>> - >>> def set_solution(self, solution, solution_cost): + >>> def set_solution( + >>> self, solution, solution_cost, solution_bound, user_data + >>> ): + >>> assert user_data is self.user_data >>> self.n_callbacks += 1 + >>> assert len(solution_bound) == 1 >>> if self.get_callback.solutions: >>> solution[:] = >>> self.get_callback.solutions[-1]["solution"] @@ -293,11 +310,14 @@ def set_mip_callback(self, callback): >>> self.get_callback.solutions[-1]["cost"] >>> ) >>> - >>> get_callback = CustomGetSolutionCallback() - >>> set_callback = CustomSetSolutionCallback(get_callback) - >>> settings.set_mip_callback(get_callback) - >>> settings.set_mip_callback(set_callback) + >>> user_data = {"source": "example"} + >>> get_callback = CustomGetSolutionCallback(user_data) + >>> set_callback = CustomSetSolutionCallback(get_callback, user_data) + >>> settings.set_mip_callback(get_callback, user_data) + >>> settings.set_mip_callback(set_callback, user_data) """ + if callback is not None: + callback.user_data = user_data self.mip_callbacks.append(callback) def get_mip_callbacks(self): diff --git a/python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py b/python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py index 28f8085cc..e86391505 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py @@ -1,4 +1,4 @@ -# 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 import os @@ -23,61 +23,72 @@ RAPIDS_DATASET_ROOT_DIR = os.path.join(RAPIDS_DATASET_ROOT_DIR, "datasets") -@pytest.mark.parametrize( - "file_name", - [ - ("/mip/swath1.mps"), - ("/mip/neos5-free-bound.mps"), - ], -) -def test_incumbent_solver_callback(file_name): +def _run_incumbent_solver_callback(file_name, include_set_callback): # Callback for incumbent solution class CustomGetSolutionCallback(GetSolutionCallback): - def __init__(self): + def __init__(self, user_data): super().__init__() self.n_callbacks = 0 self.solutions = [] + self.user_data = user_data - def get_solution(self, solution, solution_cost): + def get_solution( + self, solution, solution_cost, solution_bound, user_data + ): + assert user_data is self.user_data self.n_callbacks += 1 assert len(solution) > 0 assert len(solution_cost) == 1 + assert len(solution_bound) == 1 self.solutions.append( { - "solution": solution.copy_to_host(), - "cost": solution_cost.copy_to_host()[0], + "solution": solution.tolist(), + "cost": float(solution_cost[0]), + "bound": float(solution_bound[0]), } ) class CustomSetSolutionCallback(SetSolutionCallback): - def __init__(self, get_callback): + def __init__(self, get_callback, user_data): super().__init__() self.n_callbacks = 0 self.get_callback = get_callback + self.user_data = user_data - def set_solution(self, solution, solution_cost): + def set_solution( + self, solution, solution_cost, solution_bound, user_data + ): + assert user_data is self.user_data self.n_callbacks += 1 + assert len(solution_bound) == 1 if self.get_callback.solutions: solution[:] = self.get_callback.solutions[-1]["solution"] solution_cost[0] = float( self.get_callback.solutions[-1]["cost"] ) - get_callback = CustomGetSolutionCallback() - set_callback = CustomSetSolutionCallback(get_callback) + user_data = {"source": "test_incumbent_solver_callback"} + get_callback = CustomGetSolutionCallback(user_data) + set_callback = ( + CustomSetSolutionCallback(get_callback, user_data) + if include_set_callback + else None + ) file_path = RAPIDS_DATASET_ROOT_DIR + file_name data_model_obj = cuopt_mps_parser.ParseMps(file_path) settings = solver_settings.SolverSettings() settings.set_parameter(CUOPT_TIME_LIMIT, 10) - settings.set_mip_callback(get_callback) - settings.set_mip_callback(set_callback) + settings.set_mip_callback(get_callback, user_data) + if include_set_callback: + settings.set_mip_callback(set_callback, user_data) solution = solver.Solve(data_model_obj, settings) assert get_callback.n_callbacks > 0 - assert set_callback.n_callbacks > 0 + if include_set_callback: + assert set_callback.n_callbacks > 0 assert ( solution.get_termination_status() == MILPTerminationStatus.FeasibleFound @@ -87,3 +98,25 @@ def set_solution(self, solution, solution_cost): utils.check_solution( data_model_obj, settings, sol["solution"], sol["cost"] ) + + +@pytest.mark.parametrize( + "file_name", + [ + ("/mip/swath1.mps"), + ("/mip/neos5-free-bound.mps"), + ], +) +def test_incumbent_get_callback(file_name): + _run_incumbent_solver_callback(file_name, include_set_callback=False) + + +@pytest.mark.parametrize( + "file_name", + [ + ("/mip/swath1.mps"), + ("/mip/neos5-free-bound.mps"), + ], +) +def test_incumbent_get_set_callback(file_name): + _run_incumbent_solver_callback(file_name, include_set_callback=True) 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 fa5e274e1..71befa512 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py @@ -322,34 +322,45 @@ def test_read_write_mps_and_relaxation(): assert v.getValue() == pytest.approx(expected_values_lp[i]) -def test_incumbent_solutions(): +def _run_incumbent_solutions(include_set_callback): # Callback for incumbent solution class CustomGetSolutionCallback(GetSolutionCallback): - def __init__(self): + def __init__(self, user_data): super().__init__() self.n_callbacks = 0 self.solutions = [] + self.user_data = user_data - def get_solution(self, solution, solution_cost): + def get_solution( + self, solution, solution_cost, solution_bound, user_data + ): + assert user_data is self.user_data self.n_callbacks += 1 assert len(solution) > 0 assert len(solution_cost) == 1 + assert len(solution_bound) == 1 self.solutions.append( { - "solution": solution.copy_to_host(), - "cost": solution_cost.copy_to_host()[0], + "solution": solution.tolist(), + "cost": float(solution_cost[0]), + "bound": float(solution_bound[0]), } ) class CustomSetSolutionCallback(SetSolutionCallback): - def __init__(self, get_callback): + def __init__(self, get_callback, user_data): super().__init__() self.n_callbacks = 0 self.get_callback = get_callback + self.user_data = user_data - def set_solution(self, solution, solution_cost): + def set_solution( + self, solution, solution_cost, solution_bound, user_data + ): + assert user_data is self.user_data self.n_callbacks += 1 + assert len(solution_bound) == 1 if self.get_callback.solutions: solution[:] = self.get_callback.solutions[-1]["solution"] solution_cost[0] = float( @@ -363,11 +374,17 @@ def set_solution(self, solution, solution_cost): prob.addConstraint(3 * x + 2 * y <= 190) prob.setObjective(5 * x + 3 * y, sense=sense.MAXIMIZE) - get_callback = CustomGetSolutionCallback() - set_callback = CustomSetSolutionCallback(get_callback) + user_data = {"source": "test_incumbent_solutions"} + get_callback = CustomGetSolutionCallback(user_data) + set_callback = ( + CustomSetSolutionCallback(get_callback, user_data) + if include_set_callback + else None + ) settings = SolverSettings() - settings.set_mip_callback(get_callback) - settings.set_mip_callback(set_callback) + settings.set_mip_callback(get_callback, user_data) + if include_set_callback: + settings.set_mip_callback(set_callback, user_data) settings.set_parameter("time_limit", 1) prob.solve(settings) @@ -383,6 +400,14 @@ def set_solution(self, solution, solution_cost): assert 5 * x_val + 3 * y_val == cost +def test_incumbent_get_solutions(): + _run_incumbent_solutions(include_set_callback=False) + + +def test_incumbent_get_set_solutions(): + _run_incumbent_solutions(include_set_callback=True) + + def test_warm_start(): file_path = RAPIDS_DATASET_ROOT_DIR + "/linear_programming/a2864/a2864.mps" problem = Problem.readMPS(file_path) diff --git a/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py index 28447bf2e..066e81b02 100644 --- a/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py +++ b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py @@ -497,6 +497,7 @@ def stop_threads(log_t, inc_t, done): poll_start = time.time() try: + do_final_incumbent_fetch = False while True: # just a reqId means the request is still pending if not (len(response) == 1 and "reqId" in response): @@ -537,10 +538,20 @@ def stop_threads(log_t, inc_t, done): response, reqId ) raise ValueError(err) + do_final_incumbent_fetch = True return response finally: stop_threads(log_t, inc_t, done) + if ( + do_final_incumbent_fetch + and incumbent_callback is not None + and reqId is not None + ): + try: + self._get_incumbents(reqId, incumbent_callback) + except Exception: + pass if complete and delete and reqId is not None: self._delete(reqId) diff --git a/python/cuopt_server/cuopt_server/tests/test_incumbents.py b/python/cuopt_server/cuopt_server/tests/test_incumbents.py index 33d6b3503..d4eb5ed71 100644 --- a/python/cuopt_server/cuopt_server/tests/test_incumbents.py +++ b/python/cuopt_server/cuopt_server/tests/test_incumbents.py @@ -1,4 +1,4 @@ -# 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 import time @@ -9,33 +9,39 @@ client = RequestClient() -def test_incumbent_callback(cuoptproc): # noqa +def _run_incumbent_callback(cuoptproc, include_set_callback): # noqa data = { "csr_constraint_matrix": { - "offsets": [0, 2], - "indices": [0, 1], - "values": [1.0, 1.0], + "offsets": [0, 3, 6, 9], + "indices": [0, 1, 2, 0, 1, 2, 0, 1, 2], + "values": [2.0, 1.0, 3.0, 4.0, 5.0, 1.0, 1.0, 2.0, 2.0], + }, + "constraint_bounds": { + "upper_bounds": [9000.0, 12000.0, 7000.0], + "lower_bounds": [0.0, 0.0, 0.0], }, - "constraint_bounds": {"upper_bounds": [5000.0], "lower_bounds": [0.0]}, "objective_data": { - "coefficients": [1.2, 1.7], + "coefficients": [3.1, 2.7, 1.9], "scalability_factor": 1.0, "offset": 0.0, }, "variable_bounds": { - "upper_bounds": [3000.0, 5000.0], - "lower_bounds": [0.0, 0.0], + "upper_bounds": [4000.0, 5000.0, 3500.0], + "lower_bounds": [0.0, 0.0, 0.0], }, "maximize": "True", - "variable_names": ["x", "y"], - "variable_types": ["I", "I"], + "variable_names": ["x", "y", "z"], + "variable_types": ["I", "I", "I"], "solver_config": { "time_limit": 30, "tolerances": {"optimality": 0.0001}, }, } - params = {"incumbent_solutions": True} + params = { + "incumbent_solutions": True, + "incumbent_set_solutions": include_set_callback, + } res = client.post("/cuopt/request", params=params, json=data, block=False) assert res.status_code == 200 reqId = res.json()["reqId"] @@ -50,6 +56,8 @@ def test_incumbent_callback(cuoptproc): # noqa assert len(i["solution"]) > 0 assert "cost" in i assert isinstance(i["cost"], float) + assert "bound" in i + assert isinstance(i["bound"], float) break time.sleep(1) cnt += 1 @@ -59,9 +67,21 @@ def test_incumbent_callback(cuoptproc): # noqa cnt = 0 while cnt < 60: res = client.get(f"/cuopt/solution/{reqId}/incumbents").json() - if len(res) == 1 and res[0] == {"solution": [], "cost": None}: + if len(res) == 1 and res[0] == { + "solution": [], + "cost": None, + "bound": None, + }: saw_sentinel = True break time.sleep(1) cnt += 1 assert saw_sentinel + + +def test_incumbent_callback_get_only(cuoptproc): # noqa + _run_incumbent_callback(cuoptproc, include_set_callback=False) + + +def test_incumbent_callback_get_set(cuoptproc): # noqa + _run_incumbent_callback(cuoptproc, include_set_callback=True) diff --git a/python/cuopt_server/cuopt_server/utils/job_queue.py b/python/cuopt_server/cuopt_server/utils/job_queue.py index 430c86df5..8ddb41c4e 100644 --- a/python/cuopt_server/cuopt_server/utils/job_queue.py +++ b/python/cuopt_server/cuopt_server/utils/job_queue.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2023-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 import io @@ -513,13 +513,15 @@ def add_incumbent(self, sol): # we can send the sentinel value if self.is_done(): logging.warning("Incumbent added after job marked done!") - sol["solution"] = sol["solution"].tolist() + solution = sol.get("solution") + if hasattr(solution, "tolist"): + sol["solution"] = solution.tolist() self.incumbents.append(sol) def get_current_incumbents(self): if self.is_done() and self.incumbents == []: logging.debug("returning incumbent sentinel") - return [{"solution": [], "cost": None}] + return [{"solution": [], "cost": None, "bound": None}] res = self.incumbents self.incumbents = [] return res @@ -692,6 +694,7 @@ def __init__( validator_enabled, response_id=True, incumbents=False, + incumbent_set_solutions=False, solver_logs=False, ): self.id = id @@ -700,6 +703,7 @@ def __init__( self.validator_enabled = validator_enabled self.response_id = response_id self.incumbents = incumbents + self.incumbent_set_solutions = incumbent_set_solutions self.initial_etl_time = 0 self.ncaid = "" @@ -748,6 +752,9 @@ def get_result_mime_type(self): def return_incumbents(self): return self.incumbents + def return_incumbent_set_solutions(self): + return self.incumbent_set_solutions + def delete_data(self): pass @@ -852,6 +859,7 @@ def __init__( response_id=True, transformed=False, incumbents=False, + incumbent_set_solutions=False, solver_logs=False, ): super().__init__( @@ -861,6 +869,7 @@ def __init__( validator_enabled, response_id, incumbents=incumbents, + incumbent_set_solutions=incumbent_set_solutions, solver_logs=solver_logs, ) self.LP_data = LP_data @@ -1026,6 +1035,7 @@ def solve(self, intermediate_sender): intermediate_sender=intermediate_sender if self.return_incumbents() else None, + incumbent_set_solutions=self.return_incumbent_set_solutions(), solver_logging=self.return_solver_logs(), ) logging.debug(f"etl_time {etl}, solve_time {slv}") @@ -1095,6 +1105,7 @@ def __init__( init_sols=[], warmstart_data=None, incumbents=False, + incumbent_set_solutions=False, solver_logs=False, ): # This class is a wrapper object around a real job. The actual @@ -1126,6 +1137,7 @@ def __init__( validator_enabled, response_id, incumbents=incumbents, + incumbent_set_solutions=incumbent_set_solutions, solver_logs=solver_logs, ) @@ -1257,6 +1269,7 @@ def _resolve_job(self): self.resolved_job.response_id, transformed=True, incumbents=self.resolved_job.incumbents, + incumbent_set_solutions=self.resolved_job.incumbent_set_solutions, solver_logs=self.resolved_job.return_solver_logs(), ) @@ -1341,6 +1354,7 @@ def __init__( init_sols=[], warmstart_data=None, incumbents=False, + incumbent_set_solutions=False, solver_logs=False, ): self.file_path = file_path @@ -1355,6 +1369,7 @@ def __init__( init_sols=init_sols, warmstart_data=warmstart_data, incumbents=incumbents, + incumbent_set_solutions=incumbent_set_solutions, solver_logs=solver_logs, ) @@ -1502,6 +1517,7 @@ def _resolve_job(self): self.resolved_job.response_id, transformed=True, incumbents=self.resolved_job.incumbents, + incumbent_set_solutions=self.resolved_job.incumbent_set_solutions, solver_logs=self.resolved_job.return_solver_logs(), ) diff --git a/python/cuopt_server/cuopt_server/utils/linear_programming/data_definition.py b/python/cuopt_server/cuopt_server/utils/linear_programming/data_definition.py index 6be53bade..198b115d4 100644 --- a/python/cuopt_server/cuopt_server/utils/linear_programming/data_definition.py +++ b/python/cuopt_server/cuopt_server/utils/linear_programming/data_definition.py @@ -828,6 +828,7 @@ class LPSolve(StrictModel): class IncumbentSolution(StrictModel): solution: List[float] cost: Union[float, None] + bound: Union[float, None] lp_example_data = { diff --git a/python/cuopt_server/cuopt_server/utils/linear_programming/solver.py b/python/cuopt_server/cuopt_server/utils/linear_programming/solver.py index 81d146bc3..5b49e25d7 100644 --- a/python/cuopt_server/cuopt_server/utils/linear_programming/solver.py +++ b/python/cuopt_server/cuopt_server/utils/linear_programming/solver.py @@ -8,7 +8,10 @@ from fastapi import HTTPException from cuopt import linear_programming -from cuopt.linear_programming.internals import GetSolutionCallback +from cuopt.linear_programming.internals import ( + GetSolutionCallback, + SetSolutionCallback, +) from cuopt.linear_programming.solver.solver_parameters import ( CUOPT_ABSOLUTE_DUAL_TOLERANCE, CUOPT_ABSOLUTE_GAP_TOLERANCE, @@ -77,15 +80,45 @@ def __init__(self, sender, req_id): super().__init__() self.req_id = req_id self.sender = sender - - def get_solution(self, solution, solution_cost): + self.solutions = [] + + def get_solution(self, solution, solution_cost, solution_bound, user_data): + if user_data is not None: + assert user_data == self.req_id + solution_list = solution.tolist() + solution_cost_val = float(solution_cost[0]) + solution_bound_val = float(solution_bound[0]) + self.solutions.append( + { + "solution": solution_list, + "cost": solution_cost_val, + "bound": solution_bound_val, + } + ) self.sender( self.req_id, - solution.copy_to_host(), - solution_cost.copy_to_host()[0], + solution_list, + solution_cost_val, + solution_bound_val, ) +class CustomSetSolutionCallback(SetSolutionCallback): + def __init__(self, get_callback, req_id): + super().__init__() + self.req_id = req_id + self.get_callback = get_callback + self.n_callbacks = 0 + + def set_solution(self, solution, solution_cost, solution_bound, user_data): + if user_data is not None: + assert user_data == self.req_id + self.n_callbacks += 1 + if self.get_callback.solutions: + solution[:] = self.get_callback.solutions[-1]["solution"] + solution_cost[0] = float(self.get_callback.solutions[-1]["cost"]) + + def warn_on_objectives(solver_config): warnings = [] return warnings, solver_config @@ -411,7 +444,13 @@ def get_solver_exception_type(status, message): return RuntimeError(msg) -def solve(LP_data, reqId, intermediate_sender, warmstart_data): +def solve( + LP_data, + reqId, + intermediate_sender, + warmstart_data, + incumbent_set_solutions, +): notes = [] def get_if_attribute_is_valid_else_none(attr): @@ -538,7 +577,11 @@ def create_solution(sol): if intermediate_sender is not None else None ) - solver_settings.set_mip_callback(callback) + if callback is not None: + solver_settings.set_mip_callback(callback, reqId) + if incumbent_set_solutions: + set_callback = CustomSetSolutionCallback(callback, reqId) + solver_settings.set_mip_callback(set_callback, reqId) solve_begin_time = time.time() sol = linear_programming.Solve( data_model, solver_settings=solver_settings diff --git a/python/cuopt_server/cuopt_server/utils/solver.py b/python/cuopt_server/cuopt_server/utils/solver.py index fb4b1dc72..7f4e7896f 100644 --- a/python/cuopt_server/cuopt_server/utils/solver.py +++ b/python/cuopt_server/cuopt_server/utils/solver.py @@ -1,4 +1,4 @@ -# 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 import logging @@ -67,6 +67,7 @@ def solve_LP_sync( validation_only=False, reqId="", intermediate_sender=None, + incumbent_set_solutions=False, solver_logging=False, ): from cuopt_server.utils.linear_programming.data_validation import ( @@ -111,6 +112,7 @@ def solve_LP_sync( reqId, intermediate_sender, warmstart_data, + incumbent_set_solutions, ) warnings.extend(addl_warnings) else: @@ -336,10 +338,10 @@ def process_async_solve( solver_exit, solver_complete, job_queue, results_queue, abort_list, gpu_id ): # Send incumbent solutions - def send_solution(id, solution, cost): + def send_solution(id, solution, cost, bound): results_queue.put( SolverIntermediateResponse( - id, {"solution": solution, "cost": cost} + id, {"solution": solution, "cost": cost, "bound": bound} ) ) diff --git a/python/cuopt_server/cuopt_server/webserver.py b/python/cuopt_server/cuopt_server/webserver.py index d21979cda..4fc3d608f 100644 --- a/python/cuopt_server/cuopt_server/webserver.py +++ b/python/cuopt_server/cuopt_server/webserver.py @@ -1,4 +1,4 @@ -# 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 import json @@ -362,12 +362,12 @@ def getsolverlogs( description="Note: for use with self-hosted cuOpt instances. " "Return incumbent solutions from the MIP solver produced for " "this id since the last GET. Result will be a list of the form " - "[{'solution': [1.0, 1.0], 'cost': 2.0}] where each item " - "contains the fields 'solution' (a list of floats) and " - "'cost' (a float). " + "[{'solution': [1.0, 1.0], 'cost': 2.0, 'bound': 1.5}] where each item " + "contains the fields 'solution' (a list of floats), " + "'cost' (a float), and 'bound' (a float). " "An empty list indicates that there are no current incumbent solutions " - "at this time. A sentinel value of [{'solution': [], 'cost': None}] " - "indicates that no future incumbent values will be produced. " + "at this time. A sentinel value of [{'solution': [], 'cost': None, " + "'bound': None}] indicates that no future incumbent values will be produced. " "The 'id' is the reqId value returned from a POST to /cuopt/request", summary="Get incumbent solutions for MIP (self-hosted)", response_model=List[IncumbentSolution], @@ -941,6 +941,10 @@ async def postrequest( default=False, description="If set to True, MIP problems will produce incumbent solutions that can be retrieved from /cuopt/solution/{id}/incumbents", # noqa ), + incumbent_set_solutions: Optional[bool] = Query( + default=False, + description="If set to True, MIP problems will register a set-solution callback (this disables presolve).", # noqa + ), solver_logs: Optional[bool] = Query( default=False, description="If set to True, math optimization problems will produce detailed solver logs that can be retrieved from /cuopt/log/{id}. ", # noqa @@ -1144,6 +1148,7 @@ async def postrequest( validator_enabled=validation_only, init_sols=init_sols, incumbents=incumbent_solutions, + incumbent_set_solutions=incumbent_set_solutions, solver_logs=solver_logs, warmstart_data=warmstart_data, ) @@ -1160,6 +1165,7 @@ async def postrequest( init_sols=init_sols, warmstart_data=warmstart_data, incumbents=incumbent_solutions, + incumbent_set_solutions=incumbent_set_solutions, solver_logs=solver_logs, ) diff --git a/python/libcuopt/pyproject.toml b/python/libcuopt/pyproject.toml index 7a7d84b8f..5286c5252 100644 --- a/python/libcuopt/pyproject.toml +++ b/python/libcuopt/pyproject.toml @@ -1,4 +1,4 @@ -# 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 [build-system] @@ -53,7 +53,7 @@ libcuopt = "libcuopt" select = [ "distro-too-large-compressed", ] -max_allowed_size_compressed = '620M' +max_allowed_size_compressed = '650M' [project.scripts] cuopt_cli = "libcuopt._cli_wrapper:main"