diff --git a/conda/environments/all_cuda-129_arch-aarch64.yaml b/conda/environments/all_cuda-129_arch-aarch64.yaml index 3f87fff34b..970b16fdfa 100644 --- a/conda/environments/all_cuda-129_arch-aarch64.yaml +++ b/conda/environments/all_cuda-129_arch-aarch64.yaml @@ -34,7 +34,7 @@ dependencies: - libcurand-dev - libcusolver-dev - libcusparse-dev -- libgrpc +- libgrpc >=1.78.0,<1.80.0a0 - libprotobuf - libraft-headers==26.4.*,>=0.0.0a0 - librmm==26.4.*,>=0.0.0a0 diff --git a/conda/environments/all_cuda-129_arch-x86_64.yaml b/conda/environments/all_cuda-129_arch-x86_64.yaml index 490e3798cb..96d571087e 100644 --- a/conda/environments/all_cuda-129_arch-x86_64.yaml +++ b/conda/environments/all_cuda-129_arch-x86_64.yaml @@ -34,7 +34,7 @@ dependencies: - libcurand-dev - libcusolver-dev - libcusparse-dev -- libgrpc +- libgrpc >=1.78.0,<1.80.0a0 - libprotobuf - libraft-headers==26.4.*,>=0.0.0a0 - librmm==26.4.*,>=0.0.0a0 diff --git a/conda/environments/all_cuda-131_arch-aarch64.yaml b/conda/environments/all_cuda-131_arch-aarch64.yaml index bf7b0de734..1e25d9b39e 100644 --- a/conda/environments/all_cuda-131_arch-aarch64.yaml +++ b/conda/environments/all_cuda-131_arch-aarch64.yaml @@ -34,7 +34,7 @@ dependencies: - libcurand-dev - libcusolver-dev - libcusparse-dev -- libgrpc +- libgrpc >=1.78.0,<1.80.0a0 - libprotobuf - libraft-headers==26.4.*,>=0.0.0a0 - librmm==26.4.*,>=0.0.0a0 diff --git a/conda/environments/all_cuda-131_arch-x86_64.yaml b/conda/environments/all_cuda-131_arch-x86_64.yaml index 6f554809b1..6beee00f5b 100644 --- a/conda/environments/all_cuda-131_arch-x86_64.yaml +++ b/conda/environments/all_cuda-131_arch-x86_64.yaml @@ -34,7 +34,7 @@ dependencies: - libcurand-dev - libcusolver-dev - libcusparse-dev -- libgrpc +- libgrpc >=1.78.0,<1.80.0a0 - libprotobuf - libraft-headers==26.4.*,>=0.0.0a0 - librmm==26.4.*,>=0.0.0a0 diff --git a/conda/recipes/libcuopt/recipe.yaml b/conda/recipes/libcuopt/recipe.yaml index 789ab55c33..682f9d33ef 100644 --- a/conda/recipes/libcuopt/recipe.yaml +++ b/conda/recipes/libcuopt/recipe.yaml @@ -93,7 +93,7 @@ cache: - bzip2 - openssl - c-ares - - libgrpc + - libgrpc >=1.78.0,<1.80.0a0 - libprotobuf - libabseil - re2 @@ -127,14 +127,22 @@ outputs: - bzip2 ignore_run_exports: by_name: + - c-ares - cuda-nvtx - cuda-version - - libcurand + - libabseil + - libboost - libcudss + - libcurand - libcusparse + - libgrpc + - libprotobuf - librmm - - libzlib - libbz2 + - libzlib + - openssl + - re2 + - tbb tests: - package_contents: files: @@ -174,10 +182,11 @@ outputs: - openssl - c-ares - libuuid - - libgrpc + - libgrpc >=1.78.0,<1.80.0a0 - libprotobuf - libabseil - re2 + - tbb-devel run: - ${{ pin_compatible("cuda-version", upper_bound="x", lower_bound="x") }} - ${{ pin_subpackage("libmps-parser", exact=True) }} @@ -188,9 +197,10 @@ outputs: - openssl - c-ares - libuuid - - libgrpc + - libgrpc >=1.78.0,<1.80.0a0 - libprotobuf - libabseil + - tbb ignore_run_exports: by_name: - cuda-nvtx @@ -235,7 +245,7 @@ outputs: - libcusparse-dev - openssl - c-ares - - libgrpc + - libgrpc >=1.78.0,<1.80.0a0 - libprotobuf - libabseil run: diff --git a/cpp/include/cuopt/linear_programming/mip/solver_solution.hpp b/cpp/include/cuopt/linear_programming/mip/solver_solution.hpp index 70c2e4bcac..b8fa884540 100644 --- a/cpp/include/cuopt/linear_programming/mip/solver_solution.hpp +++ b/cpp/include/cuopt/linear_programming/mip/solver_solution.hpp @@ -75,6 +75,7 @@ class mip_solution_t : public base_solution_t { const std::vector& get_variable_names() const; const std::vector>& get_solution_pool() const; void write_to_sol_file(std::string_view filename, rmm::cuda_stream_view stream_view) const; + void log_detailed_summary() const; void log_summary() const; private: diff --git a/cpp/src/branch_and_bound/branch_and_bound.cpp b/cpp/src/branch_and_bound/branch_and_bound.cpp index 1526baa367..33a2d983c9 100644 --- a/cpp/src/branch_and_bound/branch_and_bound.cpp +++ b/cpp/src/branch_and_bound/branch_and_bound.cpp @@ -313,6 +313,12 @@ f_t branch_and_bound_t::get_lower_bound() } } +template +void branch_and_bound_t::set_initial_upper_bound(f_t bound) +{ + upper_bound_ = bound; +} + template void branch_and_bound_t::report_heuristic(f_t obj) { @@ -469,10 +475,7 @@ void branch_and_bound_t::set_new_solution(const std::vector& solu mutex_original_lp_.unlock(); bool is_feasible = false; bool attempt_repair = false; - mutex_upper_.lock(); - f_t current_upper_bound = upper_bound_; - mutex_upper_.unlock(); - if (obj < current_upper_bound) { + if (!incumbent_.has_incumbent || obj < incumbent_.objective) { f_t primal_err; f_t bound_err; i_t num_fractional; @@ -487,8 +490,8 @@ void branch_and_bound_t::set_new_solution(const std::vector& solu original_lp_, settings_, var_types_, crushed_solution, primal_err, bound_err, num_fractional); mutex_original_lp_.unlock(); mutex_upper_.lock(); - if (is_feasible && obj < upper_bound_) { - upper_bound_ = obj; + if (is_feasible && improves_incumbent(obj)) { + upper_bound_ = std::min(upper_bound_.load(), obj); incumbent_.set_incumbent_solution(obj, crushed_solution); } else { attempt_repair = true; @@ -648,8 +651,8 @@ void branch_and_bound_t::repair_heuristic_solutions() if (is_feasible) { mutex_upper_.lock(); - if (repaired_obj < upper_bound_) { - upper_bound_ = repaired_obj; + if (improves_incumbent(repaired_obj)) { + upper_bound_ = std::min(upper_bound_.load(), repaired_obj); incumbent_.set_incumbent_solution(repaired_obj, repaired_solution); report_heuristic(repaired_obj); @@ -735,7 +738,9 @@ void branch_and_bound_t::set_final_solution(mip_solution_t& if (gap <= settings_.absolute_mip_gap_tol || gap_rel <= settings_.relative_mip_gap_tol) { solver_status_ = mip_status_t::OPTIMAL; #ifdef CHECK_CUTS_AGAINST_SAVED_SOLUTION - if (settings_.sub_mip == 0) { write_solution_for_cut_verification(original_lp_, incumbent_.x); } + if (settings_.sub_mip == 0 && has_solver_space_incumbent()) { + write_solution_for_cut_verification(original_lp_, incumbent_.x); + } #endif if (gap > 0 && gap <= settings_.absolute_mip_gap_tol) { settings_.log.printf("Optimal solution found within absolute MIP gap tolerance (%.1e)\n", @@ -762,11 +767,10 @@ void branch_and_bound_t::set_final_solution(mip_solution_t& } } - if (upper_bound_ != inf) { - assert(incumbent_.has_incumbent); + if (has_solver_space_incumbent()) { uncrush_primal_solution(original_problem_, original_lp_, incumbent_.x, solution.x); + solution.objective = incumbent_.objective; } - solution.objective = incumbent_.objective; solution.lower_bound = lower_bound; solution.nodes_explored = exploration_stats_.nodes_explored; solution.simplex_iterations = exploration_stats_.total_lp_iters; @@ -785,9 +789,9 @@ void branch_and_bound_t::add_feasible_solution(f_t leaf_objective, compute_user_objective(original_lp_, leaf_objective)); mutex_upper_.lock(); - if (leaf_objective < upper_bound_) { + if (improves_incumbent(leaf_objective)) { incumbent_.set_incumbent_solution(leaf_objective, leaf_solution); - upper_bound_ = leaf_objective; + upper_bound_ = std::min(upper_bound_.load(), leaf_objective); report(feasible_solution_symbol(thread_type), leaf_objective, get_lower_bound(), leaf_depth, 0); send_solution = true; } @@ -795,7 +799,7 @@ void branch_and_bound_t::add_feasible_solution(f_t leaf_objective, if (send_solution && settings_.solution_callback != nullptr) { std::vector original_x; uncrush_primal_solution(original_problem_, original_lp_, incumbent_.x, original_x); - settings_.solution_callback(original_x, upper_bound_); + settings_.solution_callback(original_x, leaf_objective); } mutex_upper_.unlock(); } @@ -921,7 +925,7 @@ struct nondeterministic_policy_t : tree_update_policy_t { { } - f_t upper_bound() const override { return bnb.get_cutoff(); } + f_t upper_bound() const override { return bnb.get_upper_bound(); } void update_pseudo_costs(mip_node_t* node, f_t leaf_obj) override { @@ -1339,7 +1343,7 @@ dual::status_t branch_and_bound_t::solve_node_lp( simplex_solver_settings_t lp_settings = settings_; lp_settings.concurrent_halt = &node_concurrent_halt_; lp_settings.set_log(false); - f_t cutoff = get_cutoff(); + f_t cutoff = upper_bound_.load(); if (original_lp_.objective_is_integral) { lp_settings.cut_off = std::ceil(cutoff - settings_.integer_tol) + settings_.dual_tol; } else { @@ -1452,7 +1456,7 @@ void branch_and_bound_t::plunge_with(branch_and_bound_worker_tlower_bound = node_ptr->lower_bound; - if (node_ptr->lower_bound > get_cutoff()) { + if (node_ptr->lower_bound > upper_bound_.load()) { search_tree_.graphviz_node(settings_.log, node_ptr, "cutoff", node_ptr->lower_bound); search_tree_.update(node_ptr, node_status_t::FATHOMED); worker->recompute_basis = true; @@ -1590,7 +1594,7 @@ void branch_and_bound_t::dive_with(branch_and_bound_worker_t worker->lower_bound = node_ptr->lower_bound; - if (node_ptr->lower_bound > get_cutoff()) { + if (node_ptr->lower_bound > upper_bound_.load()) { worker->recompute_basis = true; worker->recompute_bounds = true; continue; @@ -1649,7 +1653,7 @@ void branch_and_bound_t::run_scheduler() diving_heuristics_settings_t diving_settings = settings_.diving_settings; const i_t num_workers = 2 * settings_.num_threads; - if (!std::isfinite(upper_bound_)) { diving_settings.guided_diving = false; } + if (!has_solver_space_incumbent()) { diving_settings.guided_diving = false; } std::vector strategies = get_search_strategies(diving_settings); std::array max_num_workers_per_type = get_max_workers(num_workers, strategies); @@ -1682,7 +1686,7 @@ void branch_and_bound_t::run_scheduler() // If the guided diving was disabled previously due to the lack of an incumbent solution, // re-enable as soon as a new incumbent is found. if (settings_.diving_settings.guided_diving != diving_settings.guided_diving) { - if (std::isfinite(upper_bound_)) { + if (has_solver_space_incumbent()) { diving_settings.guided_diving = settings_.diving_settings.guided_diving; strategies = get_search_strategies(diving_settings); max_num_workers_per_type = get_max_workers(num_workers, strategies); @@ -1733,7 +1737,7 @@ void branch_and_bound_t::run_scheduler() std::optional*> start_node = node_queue_.pop_best_first(); if (!start_node.has_value()) { continue; } - if (get_cutoff() < start_node.value()->lower_bound) { + if (upper_bound_.load() < start_node.value()->lower_bound) { // This node was put on the heap earlier but its lower bound is now greater than the // current upper bound search_tree_.graphviz_node( @@ -1757,7 +1761,7 @@ void branch_and_bound_t::run_scheduler() std::optional*> start_node = node_queue_.pop_diving(); if (!start_node.has_value()) { continue; } - if (get_cutoff() < start_node.value()->lower_bound || + if (upper_bound_.load() < start_node.value()->lower_bound || start_node.value()->depth < diving_settings.min_node_depth) { continue; } @@ -1831,7 +1835,7 @@ void branch_and_bound_t::single_threaded_solve() std::optional*> start_node = node_queue_.pop_best_first(); if (!start_node.has_value()) { continue; } - if (get_cutoff() < start_node.value()->lower_bound) { + if (upper_bound_.load() < start_node.value()->lower_bound) { // This node was put on the heap earlier but its lower bound is now greater than the // current upper bound search_tree_.graphviz_node( @@ -2331,12 +2335,12 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut return mip_status_t::NUMERICAL; } - if (settings_.reduced_cost_strengthening >= 1 && get_cutoff() < last_upper_bound) { + if (settings_.reduced_cost_strengthening >= 1 && upper_bound_.load() < last_upper_bound) { mutex_upper_.lock(); - last_upper_bound = get_cutoff(); + last_upper_bound = upper_bound_.load(); std::vector lower_bounds; std::vector upper_bounds; - find_reduced_cost_fixings(get_cutoff(), lower_bounds, upper_bounds); + find_reduced_cost_fixings(upper_bound_.load(), lower_bounds, upper_bounds); mutex_upper_.unlock(); mutex_original_lp_.lock(); original_lp_.lower = lower_bounds; @@ -2468,7 +2472,7 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut f_t rel_gap = user_relative_gap(original_lp_, upper_bound_.load(), root_objective_); f_t abs_gap = compute_user_abs_gap(original_lp_, upper_bound_.load(), root_objective_); if (rel_gap < settings_.relative_mip_gap_tol || abs_gap < settings_.absolute_mip_gap_tol) { - set_solution_at_root(solution, cut_info); + if (num_fractional == 0) { set_solution_at_root(solution, cut_info); } set_final_solution(solution, root_objective_); return mip_status_t::OPTIMAL; } @@ -2528,10 +2532,10 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut return solver_status_; } - if (settings_.reduced_cost_strengthening >= 2 && get_cutoff() < last_upper_bound) { + if (settings_.reduced_cost_strengthening >= 2 && upper_bound_.load() < last_upper_bound) { std::vector lower_bounds; std::vector upper_bounds; - i_t num_fixed = find_reduced_cost_fixings(get_cutoff(), lower_bounds, upper_bounds); + i_t num_fixed = find_reduced_cost_fixings(upper_bound_.load(), lower_bounds, upper_bounds); if (num_fixed > 0) { std::vector bounds_changed(original_lp_.num_cols, true); std::vector row_sense; @@ -2636,7 +2640,7 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut std::optional*> start_node = node_queue_.pop_best_first(); if (!start_node.has_value()) { continue; } - if (get_cutoff() < start_node.value()->lower_bound) { + if (upper_bound_.load() < start_node.value()->lower_bound) { // This node was put on the heap earlier but its lower bound is now greater than the // current upper bound search_tree_.graphviz_node( @@ -3279,8 +3283,8 @@ void branch_and_bound_t::deterministic_process_worker_solutions( deterministic_current_horizon_); bool improved = false; - if (sol->objective < upper_bound_) { - upper_bound_ = sol->objective; + if (improves_incumbent(sol->objective)) { + upper_bound_ = std::min(upper_bound_.load(), sol->objective); incumbent_.set_incumbent_solution(sol->objective, sol->solution); current_upper = sol->objective; improved = true; @@ -3442,8 +3446,8 @@ void branch_and_bound_t::deterministic_sort_replay_events( // Process heuristic solution at its correct work unit timestamp position f_t new_upper = std::numeric_limits::infinity(); - if (hsol.objective < upper_bound_) { - upper_bound_ = hsol.objective; + if (improves_incumbent(hsol.objective)) { + upper_bound_ = std::min(upper_bound_.load(), hsol.objective); incumbent_.set_incumbent_solution(hsol.objective, hsol.solution); new_upper = hsol.objective; } @@ -3477,7 +3481,7 @@ void branch_and_bound_t::deterministic_sort_replay_events( template void branch_and_bound_t::deterministic_prune_worker_nodes_vs_incumbent() { - f_t upper_bound = get_cutoff(); + f_t upper_bound = upper_bound_.load(); for (auto& worker : *deterministic_workers_) { // Check nodes in plunge stack - filter in place @@ -3613,7 +3617,7 @@ void branch_and_bound_t::deterministic_populate_diving_heap() const int num_diving = deterministic_diving_workers_->size(); constexpr int target_nodes_per_worker = 10; const int target_total = num_diving * target_nodes_per_worker; - f_t cutoff = get_cutoff(); + f_t cutoff = upper_bound_.load(); // Collect candidate nodes from BFS worker backlog heaps std::vector*, f_t>> candidates; diff --git a/cpp/src/branch_and_bound/branch_and_bound.hpp b/cpp/src/branch_and_bound/branch_and_bound.hpp index 0d07cf12a5..f2917ba930 100644 --- a/cpp/src/branch_and_bound/branch_and_bound.hpp +++ b/cpp/src/branch_and_bound/branch_and_bound.hpp @@ -120,19 +120,12 @@ class branch_and_bound_t { void set_concurrent_lp_root_solve(bool enable) { enable_concurrent_lp_root_solve_ = enable; } - // Set a cutoff bound from an external source (e.g., early FJ during presolve). - // Used for node pruning and reduced cost strengthening but NOT for gap computation. - // Unlike upper_bound_, this does not imply a verified incumbent solution exists. - // - // IMPORTANT: `bound` must be in B&B's internal objective space, i.e. the space of - // original_lp_ where: user_obj = obj_scale * (internal_obj + obj_constant). - // The caller (solver.cu) converts from user-space via - // problem_ptr->get_solver_obj_from_user_obj(user_cutoff) - // which accounts for both the presolve objective offset and maximization. - void set_initial_cutoff(f_t bound) { initial_cutoff_ = bound; } - - // Effective cutoff for node pruning: min of verified incumbent and external cutoff. - f_t get_cutoff() const { return std::min(upper_bound_.load(), initial_cutoff_); } + // Seed the global upper bound from an external source (e.g., early FJ during presolve). + // `bound` must be in B&B's internal objective space. + void set_initial_upper_bound(f_t bound); + + f_t get_upper_bound() const { return upper_bound_.load(); } + bool has_solver_space_incumbent() const { return incumbent_.has_incumbent; } // Repair a low-quality solution from the heuristics. bool repair_solution(const std::vector& leaf_edge_norms, @@ -199,16 +192,23 @@ class branch_and_bound_t { // Mutex for upper bound omp_mutex_t mutex_upper_; - // Verified incumbent bound (only set when B&B has an actual integer-feasible solution). + // Global upper bound in B&B's internal objective space. + // A finite value implies an incumbent exists somewhere (solver-space in incumbent_, or + // original-space in the mip_solver_context_t), but does NOT imply incumbent_.has_incumbent. omp_atomic_t upper_bound_; - // External cutoff from early heuristics (for pruning only, no verified solution). - // Must be in B&B internal objective space (see set_initial_cutoff). - f_t initial_cutoff_{std::numeric_limits::infinity()}; - - // Global variable for incumbent. The incumbent should be updated with the upper bound + // Solver-space incumbent tracked directly by B&B. mip_solution_t incumbent_; + // Whether obj should replace the stored incumbent. Must be called under mutex_upper_. + // Compares against the stored incumbent's objective, NOT against upper_bound_, because + // set_initial_upper_bound can set a tighter bound from an OG-space solution that has no + // corresponding solver-space incumbent (e.g. papilo can't crush it back). + bool improves_incumbent(f_t obj) const + { + return !incumbent_.has_incumbent || obj < incumbent_.objective; + } + // Structure with the general info of the solver. branch_and_bound_stats_t exploration_stats_; diff --git a/cpp/src/dual_simplex/solution.hpp b/cpp/src/dual_simplex/solution.hpp index 5739cedaea..86213c86e9 100644 --- a/cpp/src/dual_simplex/solution.hpp +++ b/cpp/src/dual_simplex/solution.hpp @@ -9,6 +9,7 @@ #include +#include #include namespace cuopt::linear_programming::dual_simplex { @@ -74,7 +75,7 @@ class mip_solution_t { f_t lower_bound; int64_t nodes_explored; int64_t simplex_iterations; - bool has_incumbent; + omp_atomic_t has_incumbent; }; } // namespace cuopt::linear_programming::dual_simplex diff --git a/cpp/src/mip_heuristics/diversity/diversity_manager.cu b/cpp/src/mip_heuristics/diversity/diversity_manager.cu index e821c016c2..b8dc3d33bf 100644 --- a/cpp/src/mip_heuristics/diversity/diversity_manager.cu +++ b/cpp/src/mip_heuristics/diversity/diversity_manager.cu @@ -603,12 +603,18 @@ solution_t diversity_manager_t::run_solver() generate_solution(timer.remaining_time(), false); if (timer.check_time_limit()) { + rins.stop_rins(); + population.add_external_solutions_to_population(); + return population.best_feasible(); + } + if (check_b_b_preemption()) { + rins.stop_rins(); population.add_external_solutions_to_population(); return population.best_feasible(); } - if (check_b_b_preemption()) { return population.best_feasible(); } run_fp_alone(); + rins.stop_rins(); population.add_external_solutions_to_population(); return population.best_feasible(); }; diff --git a/cpp/src/mip_heuristics/diversity/population.cuh b/cpp/src/mip_heuristics/diversity/population.cuh index 2509ae17df..c83a4bfb83 100644 --- a/cpp/src/mip_heuristics/diversity/population.cuh +++ b/cpp/src/mip_heuristics/diversity/population.cuh @@ -207,7 +207,9 @@ class population_t { std::mutex solution_mutex; std::atomic early_exit_primal_generation = false; std::atomic solutions_in_external_queue_ = false; - f_t best_feasible_objective = std::numeric_limits::max(); + // Best known primal upper bound used to gate callbacks and external-solution handling. This may + // be seeded from an early-FJ incumbent objective before a matching population solution exists. + f_t best_feasible_objective = std::numeric_limits::max(); assignment_hash_map_t population_hash_map; cuopt::timer_t timer; }; diff --git a/cpp/src/mip_heuristics/early_heuristic.cuh b/cpp/src/mip_heuristics/early_heuristic.cuh index ab924f3441..090cfd4901 100644 --- a/cpp/src/mip_heuristics/early_heuristic.cuh +++ b/cpp/src/mip_heuristics/early_heuristic.cuh @@ -24,8 +24,8 @@ namespace cuopt::linear_programming::detail { template -using early_incumbent_callback_t = - std::function& assignment)>; +using early_incumbent_callback_t = std::function& assignment, const char* heuristic_name)>; // CRTP base for early heuristics that run on the original (or papilo-presolved) problem // during presolve to find incumbents as early as possible. @@ -89,13 +89,11 @@ class early_heuristic_t { best_assignment_ = user_assignment; solution_found_ = true; f_t user_obj = problem_ptr_->get_user_obj_from_solver_obj(solver_obj); - double elapsed = - std::chrono::duration(std::chrono::steady_clock::now() - start_time_).count(); - CUOPT_LOG_INFO("Early heuristics (%s) lowered the primal bound. Objective %g. Time %.2f", - Derived::name(), - user_obj, - elapsed); - if (incumbent_callback_) { incumbent_callback_(solver_obj, user_obj, user_assignment); } + // Log and callback are deferred to the shared incumbent_callback_ which enforces + // global monotonicity across all early heuristic instances. + if (incumbent_callback_) { + incumbent_callback_(solver_obj, user_obj, user_assignment, Derived::name()); + } } int device_id_{0}; diff --git a/cpp/src/mip_heuristics/solution/solution.cu b/cpp/src/mip_heuristics/solution/solution.cu index daa12b4f7b..e4192c0195 100644 --- a/cpp/src/mip_heuristics/solution/solution.cu +++ b/cpp/src/mip_heuristics/solution/solution.cu @@ -617,42 +617,23 @@ mip_solution_t solution_t::get_solution(bool output_feasible 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(); - f_t total_solve_time = stats.total_solve_time; - f_t presolve_time = stats.presolve_time; - i_t num_nodes = stats.num_nodes; - i_t num_simplex_iterations = stats.num_simplex_iterations; handle_ptr->sync_stream(); - if (log_stats) { - CUOPT_LOG_INFO( - "Solution objective: %f , relative_mip_gap %f solution_bound %f presolve_time %f " - "total_solve_time %f " - "max constraint violation %f max int violation %f max var bounds violation %f " - "nodes %d simplex_iterations %d", - h_user_obj, - rel_mip_gap, - solution_bound, - presolve_time, - total_solve_time, - max_constraint_violation, - max_int_violation, - max_variable_bound_violation, - num_nodes, - num_simplex_iterations); - } const bool not_optimal = rel_mip_gap > problem_ptr->tolerances.relative_mip_gap && abs_mip_gap > problem_ptr->tolerances.absolute_mip_gap; auto term_reason = not_optimal ? mip_termination_status_t::FeasibleFound : mip_termination_status_t::Optimal; if (is_problem_fully_reduced) { term_reason = mip_termination_status_t::Optimal; } - return mip_solution_t(std::move(assignment), - problem_ptr->var_names, - h_user_obj, - rel_mip_gap, - term_reason, - max_constraint_violation, - max_int_violation, - max_variable_bound_violation, - stats); + auto sol = mip_solution_t(std::move(assignment), + problem_ptr->var_names, + h_user_obj, + rel_mip_gap, + term_reason, + max_constraint_violation, + max_int_violation, + max_variable_bound_violation, + stats); + if (log_stats) { sol.log_detailed_summary(); } + return sol; } else { return mip_solution_t{is_problem_fully_reduced ? mip_termination_status_t::Infeasible : mip_termination_status_t::TimeLimit, diff --git a/cpp/src/mip_heuristics/solve.cu b/cpp/src/mip_heuristics/solve.cu index 4e9cd6a2a5..be01516657 100644 --- a/cpp/src/mip_heuristics/solve.cu +++ b/cpp/src/mip_heuristics/solve.cu @@ -83,7 +83,8 @@ template mip_solution_t run_mip(detail::problem_t& problem, mip_solver_settings_t const& settings, timer_t& timer, - f_t initial_cutoff = std::numeric_limits::infinity()) + f_t& initial_upper_bound, + std::vector& initial_incumbent_assignment) { try { raft::common::nvtx::range fun_scope("run_mip"); @@ -156,9 +157,10 @@ mip_solution_t run_mip(detail::problem_t& problem, detail::trivial_presolve(scaled_problem); detail::mip_solver_t solver(scaled_problem, settings, timer); - // initial_cutoff is in user-space (representation-invariant). + // initial_upper_bound is in user-space (representation-invariant). // It will be converted to the target solver-space at each consumption point. - solver.context.initial_cutoff = initial_cutoff; + solver.context.initial_upper_bound = initial_upper_bound; + solver.context.initial_incumbent_assignment = initial_incumbent_assignment; if (timer.check_time_limit()) { CUOPT_LOG_INFO("Time limit reached before main solve"); detail::solution_t sol(problem); @@ -169,32 +171,46 @@ mip_solution_t run_mip(detail::problem_t& problem, } // Run early CPUFJ on papilo-presolved problem during cuOpt presolve (probing cache). - // Stopped by run_solver after presolve completes; its best objective feeds into initial_cutoff. - // This CPUFJ operates on *problem.original_problem_ptr (papilo-presolved + // Stopped by run_solver after presolve completes; its best objective feeds into + // initial_upper_bound. This CPUFJ operates on *problem.original_problem_ptr (papilo-presolved // optimization_problem_t). Its solver-space differs from both the first-pass FJ (original - // problem) and B&B (post-trivial- presolve), so initial_cutoff (user-space) is converted via - // problem.get_solver_obj_from_user_obj. + // problem) and B&B (post-trivial- presolve), so initial_upper_bound (user-space) is converted + // via problem.get_solver_obj_from_user_obj. std::unique_ptr> early_cpufj; bool run_early_cpufj = problem.has_papilo_presolve_data() && settings.determinism_mode != CUOPT_MODE_DETERMINISTIC && problem.original_problem_ptr->get_n_integers() > 0; if (run_early_cpufj) { + auto early_fj_start = std::chrono::steady_clock::now(); auto* presolver_ptr = problem.presolve_data.papilo_presolve_ptr; auto mip_callbacks = settings.get_mip_callbacks(); f_t no_bound = problem.presolve_data.objective_scaling_factor >= 0 ? (f_t)-1e20 : (f_t)1e20; - auto incumbent_callback = - [presolver_ptr, mip_callbacks, no_bound]( - f_t solver_obj, f_t user_obj, const std::vector& assignment) { - std::vector user_assignment; - presolver_ptr->uncrush_primal_solution(assignment, user_assignment); - invoke_solution_callbacks(mip_callbacks, user_obj, user_assignment, no_bound); - }; + auto incumbent_callback = [presolver_ptr, + mip_callbacks, + no_bound, + ctx_ptr = &solver.context, + early_fj_start](f_t solver_obj, + f_t user_obj, + const std::vector& assignment, + const char* heuristic_name) { + std::vector user_assignment; + presolver_ptr->uncrush_primal_solution(assignment, user_assignment); + ctx_ptr->initial_incumbent_assignment = user_assignment; + ctx_ptr->initial_upper_bound = user_obj; + double elapsed = + std::chrono::duration(std::chrono::steady_clock::now() - early_fj_start).count(); + CUOPT_LOG_INFO("New solution from early primal heuristics (%s). Objective %+.6e. Time %.2f", + heuristic_name, + user_obj, + elapsed); + invoke_solution_callbacks(mip_callbacks, user_obj, user_assignment, no_bound); + }; early_cpufj = std::make_unique>( *problem.original_problem_ptr, settings.get_tolerances(), incumbent_callback); - // Convert initial_cutoff from user-space to the CPUFJ's solver-space (papilo-presolved). + // Convert initial_upper_bound from user-space to the CPUFJ's solver-space (papilo-presolved). // problem.get_solver_obj_from_user_obj uses the papilo offset/scale (matching the CPUFJ). - if (std::isfinite(initial_cutoff)) { - early_cpufj->set_best_objective(problem.get_solver_obj_from_user_obj(initial_cutoff)); + if (std::isfinite(initial_upper_bound)) { + early_cpufj->set_best_objective(problem.get_solver_obj_from_user_obj(initial_upper_bound)); } early_cpufj->start(); solver.context.early_cpufj_ptr = early_cpufj.get(); @@ -217,6 +233,10 @@ mip_solution_t run_mip(detail::problem_t& problem, auto sol = presolved_sol.get_solution( is_feasible_on_presolved || is_feasible_on_original, solver.get_solver_stats(), false); + // Write back the (possibly updated) incumbent from the papilo-phase callback. + initial_upper_bound = solver.context.initial_upper_bound; + initial_incumbent_assignment = solver.context.initial_incumbent_assignment; + int hidesol = std::getenv("CUOPT_MIP_HIDE_SOLUTION") ? atoi(std::getenv("CUOPT_MIP_HIDE_SOLUTION")) : 0; if (!hidesol) { detail::print_solution(scaled_problem.handle_ptr, sol.get_solution()); } @@ -324,22 +344,35 @@ mip_solution_t solve_mip(optimization_problem_t& op_problem, // passed to run_mip for correct cross-space conversion. std::atomic early_best_objective{std::numeric_limits::infinity()}; f_t early_best_user_obj{std::numeric_limits::infinity()}; + std::vector early_best_user_assignment; std::mutex early_callback_mutex; bool run_early_fj = run_presolve && settings.determinism_mode != CUOPT_MODE_DETERMINISTIC && op_problem.get_n_integers() > 0 && op_problem.get_n_constraints() > 0; f_t no_bound = problem.presolve_data.objective_scaling_factor >= 0 ? (f_t)-1e20 : (f_t)1e20; if (run_early_fj) { + auto early_fj_start = std::chrono::steady_clock::now(); auto early_fj_callback = [&early_best_objective, &early_best_user_obj, + &early_best_user_assignment, &early_callback_mutex, + &early_fj_start, mip_callbacks = settings.get_mip_callbacks(), - no_bound]( - f_t solver_obj, f_t user_obj, const std::vector& assignment) { + no_bound](f_t solver_obj, + f_t user_obj, + const std::vector& assignment, + const char* heuristic_name) { std::lock_guard lock(early_callback_mutex); if (solver_obj >= early_best_objective.load()) { return; } early_best_objective.store(solver_obj); - early_best_user_obj = user_obj; + early_best_user_obj = user_obj; + early_best_user_assignment = assignment; + double elapsed = + std::chrono::duration(std::chrono::steady_clock::now() - early_fj_start).count(); + CUOPT_LOG_INFO("New solution from early primal heuristics (%s). Objective %+.6e. Time %.2f", + heuristic_name, + user_obj, + elapsed); auto user_assignment = assignment; invoke_solution_callbacks(mip_callbacks, user_obj, user_assignment, no_bound); }; @@ -348,13 +381,13 @@ mip_solution_t solve_mip(optimization_problem_t& op_problem, early_cpufj = std::make_unique>( op_problem, settings.get_tolerances(), early_fj_callback); early_cpufj->start(); - CUOPT_LOG_INFO("Started early CPUFJ on original problem"); + CUOPT_LOG_DEBUG("Started early CPUFJ on original problem"); // Start early GPU FJ (uses GPU while CPU is busy with Papilo) early_gpufj = std::make_unique>(op_problem, settings, early_fj_callback); early_gpufj->start(); - CUOPT_LOG_INFO("Started early GPUFJ during presolve"); + CUOPT_LOG_DEBUG("Started early GPUFJ during presolve"); } auto constexpr const dual_postsolve = false; @@ -412,8 +445,8 @@ mip_solution_t solve_mip(optimization_problem_t& op_problem, if (early_gpufj) { early_gpufj->stop(); if (early_gpufj->solution_found()) { - CUOPT_LOG_INFO("Early GPU FJ found incumbent with objective %.6e during presolve", - early_gpufj->get_best_objective()); + CUOPT_LOG_DEBUG("Early GPU FJ found incumbent with objective %.6e during presolve", + early_gpufj->get_best_objective()); } early_gpufj.reset(); // Free GPU memory } @@ -421,8 +454,9 @@ mip_solution_t solve_mip(optimization_problem_t& op_problem, if (early_cpufj && run_presolve && presolve_result_opt.has_value()) { early_cpufj->stop(); if (early_cpufj->solution_found()) { - CUOPT_LOG_INFO("Early CPUFJ (original) found incumbent with objective %.6e", - early_cpufj->get_best_objective()); + CUOPT_LOG_DEBUG( + "Early CPUFJ (original) found incumbent with objective %.6e during presolve", + early_cpufj->get_best_objective()); } early_cpufj.reset(); } @@ -437,8 +471,9 @@ mip_solution_t solve_mip(optimization_problem_t& op_problem, } // early_best_user_obj is in user-space. - // run_mip stores it in context.initial_cutoff and converts to target spaces as needed. - auto sol = run_mip(problem, settings, timer, early_best_user_obj); + // run_mip stores it in context.initial_upper_bound and converts to target spaces as needed. + auto sol = run_mip(problem, settings, timer, early_best_user_obj, early_best_user_assignment); + const f_t cuopt_presolve_time = sol.get_stats().presolve_time; if (run_presolve) { auto status_to_skip = sol.get_termination_status() == mip_termination_status_t::TimeLimit || @@ -475,15 +510,45 @@ mip_solution_t solve_mip(optimization_problem_t& op_problem, auto full_stats = sol.get_stats(); // add third party presolve time to cuopt presolve time - full_stats.presolve_time += presolve_time; + full_stats.presolve_time = cuopt_presolve_time + presolve_time; // FIXME:: reduced_solution.get_stats() is not correct, we need to compute the stats for // the full problem full_sol.post_process_completed = true; // hack - sol = full_sol.get_solution(true, full_stats); + sol = full_sol.get_solution(true, full_stats, false); + } + } + + // Use the early heuristic OG-space incumbent if it is better than what the solver-space + // pipeline returned (or if the pipeline returned no feasible solution at all). + if (!early_best_user_assignment.empty()) { + bool sol_has_incumbent = + sol.get_termination_status() == mip_termination_status_t::FeasibleFound || + sol.get_termination_status() == mip_termination_status_t::Optimal; + bool is_maximization = problem.presolve_data.objective_scaling_factor < 0; + bool early_heuristic_is_better = + !sol_has_incumbent || (is_maximization ? early_best_user_obj > sol.get_objective_value() + : early_best_user_obj < sol.get_objective_value()); + if (early_heuristic_is_better) { + detail::problem_t full_problem(op_problem); + detail::solution_t fallback_sol(full_problem); + fallback_sol.copy_new_assignment(early_best_user_assignment); + fallback_sol.compute_feasibility(); + if (fallback_sol.get_feasible()) { + auto stats = sol.get_stats(); + stats.presolve_time = cuopt_presolve_time + presolve_time; + fallback_sol.post_process_completed = true; + sol = fallback_sol.get_solution(true, stats, false); + CUOPT_LOG_DEBUG("Using early heuristic incumbent (objective %g)", early_best_user_obj); + } } } + if (sol.get_termination_status() == mip_termination_status_t::FeasibleFound || + sol.get_termination_status() == mip_termination_status_t::Optimal) { + sol.log_detailed_summary(); + } + if (settings.sol_file != "") { CUOPT_LOG_INFO("Writing solution to file %s", settings.sol_file.c_str()); sol.write_to_sol_file(settings.sol_file, op_problem.get_handle_ptr()->get_stream()); diff --git a/cpp/src/mip_heuristics/solver.cu b/cpp/src/mip_heuristics/solver.cu index 0bbf48d95e..ce6b602fba 100644 --- a/cpp/src/mip_heuristics/solver.cu +++ b/cpp/src/mip_heuristics/solver.cu @@ -220,15 +220,8 @@ solution_t mip_solver_t::run_solver() if (context.early_cpufj_ptr) { context.early_cpufj_ptr->stop(); if (context.early_cpufj_ptr->solution_found()) { - // Compare in user-space (representation-invariant) to pick the tighter cutoff. - f_t cpufj_user_obj = context.early_cpufj_ptr->get_best_user_objective(); - bool should_update = - !std::isfinite(context.initial_cutoff) || - (context.problem_ptr->maximize ? cpufj_user_obj > context.initial_cutoff - : cpufj_user_obj < context.initial_cutoff); - if (should_update) { context.initial_cutoff = cpufj_user_obj; } - CUOPT_LOG_INFO("Early CPUFJ found incumbent with user-space objective %g during presolve", - cpufj_user_obj); + CUOPT_LOG_DEBUG("Early CPUFJ found incumbent with user-space objective %g during presolve", + context.early_cpufj_ptr->get_best_user_objective()); } } @@ -402,16 +395,16 @@ solution_t mip_solver_t::run_solver() context.problem_ptr->clique_table); context.branch_and_bound_ptr = branch_and_bound.get(); - // Convert initial_cutoff from user-space to B&B's internal objective space. + // Convert the best external upper bound from user-space to B&B's internal objective space. // context.problem_ptr is the post-trivial-presolve problem, whose get_solver_obj_from_user_obj // produces values in the same space as B&B node lower bounds. - if (std::isfinite(context.initial_cutoff)) { - f_t bb_cutoff = context.problem_ptr->get_solver_obj_from_user_obj(context.initial_cutoff); - branch_and_bound->set_initial_cutoff(bb_cutoff); - dm.population.best_feasible_objective = bb_cutoff; - CUOPT_LOG_INFO("B&B using initial cutoff %.6e (user-space: %.6e) from early heuristics", - bb_cutoff, - context.initial_cutoff); + if (std::isfinite(context.initial_upper_bound)) { + f_t bb_ub = context.problem_ptr->get_solver_obj_from_user_obj(context.initial_upper_bound); + branch_and_bound->set_initial_upper_bound(bb_ub); + dm.population.best_feasible_objective = bb_ub; + CUOPT_LOG_DEBUG("B&B using initial upper bound %.6e (user-space: %.6e) from early heuristics", + bb_ub, + context.initial_upper_bound); } auto* stats_ptr = &context.stats; @@ -482,6 +475,7 @@ solution_t mip_solver_t::run_solver() context.stats.num_simplex_iterations = branch_and_bound_solution.simplex_iterations; } sol.compute_feasibility(); + rmm::device_scalar is_feasible(sol.handle_ptr->get_stream()); sol.test_variable_bounds(true, is_feasible.data()); // test_variable_bounds clears is_feasible if the test is failed @@ -494,7 +488,6 @@ solution_t mip_solver_t::run_solver() } context.stats.total_solve_time = timer_.elapsed_time(); context.problem_ptr->post_process_solution(sol); - dm.rins.stop_rins(); return sol; } diff --git a/cpp/src/mip_heuristics/solver_context.cuh b/cpp/src/mip_heuristics/solver_context.cuh index 3ea7377e15..b1bf3fbd70 100644 --- a/cpp/src/mip_heuristics/solver_context.cuh +++ b/cpp/src/mip_heuristics/solver_context.cuh @@ -63,12 +63,14 @@ struct mip_solver_context_t { work_unit_scheduler_t work_unit_scheduler_{5.0}; early_cpufj_t* early_cpufj_ptr{nullptr}; - // Best objective from early heuristics, in user-space. + // Best upper bound from early heuristics, in user-space. // Must be converted to the target solver-space before use: - // - B&B: problem_ptr->get_solver_obj_from_user_obj(initial_cutoff) - // - CPUFJ: papilo_problem.get_solver_obj_from_user_obj(initial_cutoff) - // Use std::isfinite() to check whether a valid cutoff exists. - f_t initial_cutoff{std::numeric_limits::infinity()}; + // - B&B: problem_ptr->get_solver_obj_from_user_obj(initial_upper_bound) + // - CPUFJ: papilo_problem.get_solver_obj_from_user_obj(initial_upper_bound) + f_t initial_upper_bound{std::numeric_limits::infinity()}; + + // Matching incumbent assignment in original output space from early heuristics. + std::vector initial_incumbent_assignment{}; }; } // namespace cuopt::linear_programming::detail diff --git a/cpp/src/mip_heuristics/solver_solution.cu b/cpp/src/mip_heuristics/solver_solution.cu index a9bc6c5416..8f6f8de05f 100644 --- a/cpp/src/mip_heuristics/solver_solution.cu +++ b/cpp/src/mip_heuristics/solver_solution.cu @@ -235,6 +235,26 @@ void mip_solution_t::log_summary() const CUOPT_LOG_INFO("Total Solve Time: %f", get_total_solve_time()); } +template +void mip_solution_t::log_detailed_summary() const +{ + CUOPT_LOG_INFO( + "Solution objective: %f , relative_mip_gap %f solution_bound %f presolve_time %f " + "total_solve_time %f " + "max constraint violation %f max int violation %f max var bounds violation %f " + "nodes %d simplex_iterations %d", + objective_, + mip_gap_, + stats_.get_solution_bound(), + stats_.presolve_time, + stats_.total_solve_time, + max_constraint_violation_, + max_int_violation_, + max_variable_bound_violation_, + stats_.num_nodes, + stats_.num_simplex_iterations); +} + #if MIP_INSTANTIATE_FLOAT || PDLP_INSTANTIATE_FLOAT template class mip_solution_t; #endif diff --git a/cpp/tests/linear_programming/grpc/grpc_integration_test.cpp b/cpp/tests/linear_programming/grpc/grpc_integration_test.cpp index b86e2d41b9..8d43f03294 100644 --- a/cpp/tests/linear_programming/grpc/grpc_integration_test.cpp +++ b/cpp/tests/linear_programming/grpc/grpc_integration_test.cpp @@ -407,7 +407,7 @@ class GrpcIntegrationTestBase : public ::testing::Test { problem.set_variable_types(var_types.data(), 2); std::vector con_lb = {1.0}; - std::vector con_ub = {1e20}; + std::vector con_ub = {std::numeric_limits::infinity()}; problem.set_constraint_lower_bounds(con_lb.data(), 1); problem.set_constraint_upper_bounds(con_ub.data(), 1); diff --git a/cpp/tests/mip/incumbent_callback_test.cu b/cpp/tests/mip/incumbent_callback_test.cu index a9593fa559..92ce2dd69c 100644 --- a/cpp/tests/mip/incumbent_callback_test.cu +++ b/cpp/tests/mip/incumbent_callback_test.cu @@ -154,4 +154,44 @@ TEST(mip_solve, incumbent_get_set_callback_test) } } +// Verify that when only early heuristics find a feasible incumbent but the solver-space +// pipeline (B&B + GPU heuristics) does not, the solver still returns that incumbent. +// B&B runs but exits immediately (node_limit=0); GPU heuristics are disabled so the +// population stays empty. The fallback in solver.cu must use the OG-space incumbent. +TEST(mip_solve, early_heuristic_incumbent_fallback) +{ + setenv("CUOPT_DISABLE_GPU_HEURISTICS", "1", 1); + + const raft::handle_t handle_{}; + auto path = make_path_absolute("mip/pk1.mps"); + cuopt::mps_parser::mps_data_model_t mps_problem = + cuopt::mps_parser::parse_mps(path, false); + handle_.sync_stream(); + auto op_problem = mps_data_model_to_optimization_problem(&handle_, mps_problem); + + auto settings = mip_solver_settings_t{}; + settings.time_limit = 10.; + settings.presolver = presolver_t::Papilo; + settings.node_limit = 0; + + int user_data = 0; + std::vector, double>> callback_solutions; + test_get_solution_callback_t get_cb(callback_solutions, op_problem.get_n_variables(), &user_data); + settings.set_mip_callback(&get_cb, &user_data); + + auto solution = solve_mip(op_problem, settings); + + unsetenv("CUOPT_DISABLE_GPU_HEURISTICS"); + + EXPECT_GE(get_cb.n_calls, 1) << "Early heuristics should have emitted at least one incumbent"; + auto status = solution.get_termination_status(); + EXPECT_TRUE(status == mip_termination_status_t::FeasibleFound || + status == mip_termination_status_t::Optimal) + << "Expected feasible result, got " + << mip_solution_t::get_termination_status_string(status); + EXPECT_TRUE(std::isfinite(solution.get_objective_value())); + + if (!callback_solutions.empty()) { check_solutions(get_cb, mps_problem, settings); } +} + } // namespace cuopt::linear_programming::test diff --git a/datasets/mip/download_miplib_test_dataset.sh b/datasets/mip/download_miplib_test_dataset.sh index 3040f0f543..d9cefbc32d 100755 --- a/datasets/mip/download_miplib_test_dataset.sh +++ b/datasets/mip/download_miplib_test_dataset.sh @@ -25,6 +25,7 @@ INSTANCES=( "enlight_hard" "enlight11" "supportcase22" + "pk1" ) BASE_URL="https://miplib.zib.de/WebData/instances" diff --git a/dependencies.yaml b/dependencies.yaml index 84dc5eed00..6a9fe8dd59 100644 --- a/dependencies.yaml +++ b/dependencies.yaml @@ -304,7 +304,7 @@ dependencies: - bzip2 - openssl - c-ares - - libgrpc + - libgrpc >=1.78.0,<1.80.0a0 - libprotobuf - libabseil - re2